diff --git a/.config/CredScanSuppressions.json b/.config/CredScanSuppressions.json index 9e26dfeeb6e..3bbc3c4e0b2 100644 --- a/.config/CredScanSuppressions.json +++ b/.config/CredScanSuppressions.json @@ -1 +1,9 @@ -{} \ No newline at end of file +{ + "tool": "Credential Scanner", + "suppressions": [ + { + "file": "\\test\\Libraries\\Microsoft.Extensions.Compliance.Redaction.Tests\\HmacRedactorTest.cs", + "_justification": "Tests" + } + ] +} diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000000..e5e77b86217 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,183 @@ +# .NET Extensions Repository + +**Always reference these instructions first and fallback to search or bash commands only when you encounter unexpected information that does not match the info here.** + +The .NET Extensions repository contains a suite of libraries providing facilities commonly needed when creating production-ready applications. Major areas include AI abstractions, compliance mechanisms, diagnostics, contextual options, resilience (Polly), telemetry, AspNetCore extensions, static analysis, and testing utilities. + +## Working Effectively + +### Prerequisites and Bootstrap +- **CRITICAL**: Ensure you have access to the appropriate Azure DevOps feeds. If build fails with "Name or service not known" for `pkgs.dev.azure.com`, this indicates network/authentication issues with required internal feeds. +- Install .NET SDK 9.0.109 (as specified in global.json) if you do not already have it: + ```bash + curl -sSL https://dot.net/v1/dotnet-install.sh | bash /dev/stdin --version 9.0.109 --install-dir ~/.dotnet + export PATH="$HOME/.dotnet:$PATH" + ``` +- Verify installation: `dotnet --version` should show `9.0.109` + +### Essential Build Commands +- **Restore dependencies**: `./restore.sh` (Linux/Mac) or `restore.cmd` (Windows) + - **CRITICAL - NEVER CANCEL**: Takes 5-10 minutes on first run. Always set timeout to 30+ minutes. + - Downloads .NET SDK, tools, and packages from Azure DevOps feeds + - Equivalent to `./build.sh --restore` + +- **Build the entire solution**: `./build.sh` (Linux/Mac) or `build.cmd` (Windows) + - **CRITICAL - NEVER CANCEL**: Takes 45-60 minutes for full build. Always set timeout to 90+ minutes. + - Repository has 124 total projects - build times are expected to be substantial + - Without parameters: equivalent to `./build.sh --restore --build` + - Individual actions: `--restore`, `--build`, `--test`, `--pack` + +- **Run tests**: `./build.sh --test` + - **CRITICAL - NEVER CANCEL**: Takes 20-30 minutes. Always set timeout to 60+ minutes. + - Runs all unit tests across the solution + +### Solution Generation (Key Workflow) +This repository does NOT contain a single solution file. Instead, use the slngen tool to generate filtered solutions: + +- **Generate filtered solution**: `./build.sh --vs ` + - Examples: + - `./build.sh --vs Http,Fakes,AspNetCore` + - `./build.sh --vs Polly` (for resilience libraries) + - `./build.sh --vs AI` (for AI-related projects) + - `./build.sh --vs '*'` (all projects - escape asterisk) + - Creates `SDK.sln` in repository root + - Also performs restore operation + - **NEVER CANCEL**: Takes 5-15 minutes depending on filter scope. Set timeout to 30+ minutes. + +- **Open in Visual Studio Code**: `./start-code.sh` (Linux/Mac) or `start-code.cmd` (Windows) + - Sets up environment variables for proper .NET SDK resolution + - Opens repository in VS Code with correct configuration + +### Build Validation and CI Requirements +- **Always run before committing**: + ```bash + ./build.sh --restore --build --test + ``` +- **Check for API changes**: If you modify public APIs, run `./scripts/MakeApiBaselines.ps1` to update API baseline manifest files +- **NEVER CANCEL** long-running builds or tests - this repository has hundreds of projects and build times are expected to be lengthy + +### Common Build Issues and Workarounds + +1. **"Workload manifest microsoft.net.sdk.aspire not installed"**: + - Run `git clean -xdf` then restore again + - Caused by multiple SDK installations + +2. **"Could not resolve SDK Microsoft.DotNet.Arcade.Sdk"** or feed access errors: + - Indicates no access to internal Microsoft Azure DevOps feeds + - **NOT A CODE ISSUE** - environment/network limitation + - Document as "Build requires access to internal Microsoft feeds" + +3. **SSL connection errors during restore**: + - Try disabling IPv6 on network adapter + - May indicate network/firewall restrictions + +### Project Structure and Navigation + +#### Key Directories +- `src/` - Main source code: + - `src/Analyzers/` - Code analyzers + - `src/Generators/` - Source generators + - `src/Libraries/` - Core extension libraries + - `src/Packages/` - NuGet package definitions + - `src/ProjectTemplates/` - Project templates +- `test/` - Test projects (organized to match src/ structure) +- `docs/` - Documentation including `building.md` +- `scripts/` - PowerShell automation scripts +- `eng/` - Build engineering and configuration + +#### Build Outputs +- `artifacts/bin/` - Compiled binaries +- `artifacts/log/` - Build logs (including `Build.binlog` for MSBuild Structured Log Viewer) +- `artifacts/packages/` - Generated NuGet packages + +#### Key Files +- `global.json` - Specifies required .NET SDK version (9.0.109) +- `Directory.Build.props` - MSBuild properties for entire repository +- `NuGet.config` - Package source configuration (internal Microsoft feeds) + +## Validation Scenarios + +**After making changes, always execute these validation steps**: +1. **Generate relevant filtered solution**: `./build.sh --vs ` + - For AI libraries: `./build.sh --vs AI` + - For AspNetCore: `./build.sh --vs AspNetCore` + - For telemetry: `./build.sh --vs Telemetry,Logging,Metrics` + - For resilience: `./build.sh --vs Polly,Resilience` +2. **Build and test**: `./build.sh --build --test` (remember: NEVER CANCEL, 60+ minute timeout) +3. **For public API changes**: Run `./scripts/MakeApiBaselines.ps1` to update API baseline manifest files +4. **Manual verification**: + - Check that your changes compile across target frameworks (net8.0, net9.0) + - Review generated packages in `artifacts/packages/` if applicable + - Verify no new build warnings in `artifacts/log/Build.binlog` + +**Cannot run applications interactively** - this is a library repository. Validation is primarily through unit tests and integration tests. + +**Common validation patterns by library type**: +- **Source generators** (Microsoft.Gen.*): Build consumer projects that use the generator +- **AspNetCore extensions**: Build test web applications that reference the extensions +- **Testing utilities**: Use them in test projects to verify functionality +- **Analyzers**: Build projects that trigger the analyzer rules + +## Time Expectations and Timeouts + +**CRITICAL - NEVER CANCEL BUILD OR TEST COMMANDS**: +- **First-time setup**: 15-20 minutes (SDK download + initial restore) - timeout: 45+ minutes +- **Restore operation**: 5-10 minutes - **ALWAYS set timeout to 30+ minutes, NEVER CANCEL** +- **Full build**: 45-60 minutes - **ALWAYS set timeout to 90+ minutes, NEVER CANCEL** +- **Test execution**: 20-30 minutes - **ALWAYS set timeout to 60+ minutes, NEVER CANCEL** +- **Filtered solution generation**: 5-15 minutes - **ALWAYS set timeout to 30+ minutes** + +**Repository has 124 total projects** - build times are substantial by design. If commands appear to hang, wait at least 60 minutes before considering alternatives. + +## Advanced Usage + +### Custom Solution Generation +```bash +# Using PowerShell script directly with options +./scripts/Slngen.ps1 -Keywords "Http","Fakes" -Folders -OutSln "MyCustom.sln" +``` + +### Build Configuration Options +- `--configuration Debug|Release` - Build configuration +- `--verbosity minimal|normal|detailed|diagnostic` - MSBuild verbosity +- `--onlyTfms "net8.0;net9.0"` - Build specific target frameworks only + +### Code Coverage +```bash +./build.sh --restore --build --configuration Release --testCoverage +``` +Results available at: `artifacts/TestResults/Release/CoverageResultsHtml/index.html` + +## Common Tasks Reference + +**Key library areas and their keywords for filtered solutions**: +- **AI libraries**: `./build.sh --vs AI` (Microsoft.Extensions.AI.*, embedding, chat completion) +- **Telemetry**: `./build.sh --vs Telemetry,Logging,Metrics` (logging, metrics, tracing) +- **AspNetCore**: `./build.sh --vs AspNetCore` (middleware, diagnostics, testing) +- **Resilience**: `./build.sh --vs Polly,Resilience` (Polly integration, resilience patterns) +- **Compliance**: `./build.sh --vs Compliance,Audit` (data classification, audit reports) +- **Hosting**: `./build.sh --vs Hosting,Options` (contextual options, ambient metadata) +- **Testing utilities**: `./build.sh --vs Testing,Fakes` (mocking, fake implementations) + +**Find projects by pattern**: +```bash +# AI-related projects +find src/Libraries -name "*AI*" -name "*.csproj" + +# All AspNetCore extensions +find src/Libraries -name "*AspNetCore*" -name "*.csproj" + +# Source generators +find src/Generators -name "*.csproj" + +# Test projects for a specific library +find test/Libraries -name "*[LibraryName]*" -name "*.csproj" +``` + +**Working with specific libraries workflow**: +1. Identify library area: `ls src/Libraries/` or use find commands above +2. Generate focused solution: `./build.sh --vs ` +3. Navigate to library directory: `cd src/Libraries/Microsoft.Extensions.[Area]` +4. Check corresponding tests: `cd test/Libraries/Microsoft.Extensions.[Area].Tests` +5. Review library README: `cat src/Libraries/Microsoft.Extensions.[Area]/README.md` +6. Build and test: `./build.sh --build --test` (with appropriate timeouts) \ No newline at end of file diff --git a/.nuget/nuget.exe b/.nuget/nuget.exe new file mode 100644 index 00000000000..ed048fe8892 Binary files /dev/null and b/.nuget/nuget.exe differ diff --git a/Directory.Build.props b/Directory.Build.props index 0c0fcf22bfd..213d4b272b6 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,7 +5,7 @@ net - 9 + 10 0 $(TargetFrameworkMajorVersion).$(TargetFrameworkMinorVersion) @@ -13,7 +13,7 @@ $(TargetFrameworkName)$(TargetFrameworkVersion) $(LatestTargetFramework) - $(SupportedNetCoreTargetFrameworks);net8.0 + $(SupportedNetCoreTargetFrameworks);net9.0;net8.0 net8.0 diff --git a/Directory.Build.targets b/Directory.Build.targets index 5fcf797523c..31c6cd27154 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -8,6 +8,11 @@ + + + false + + $(MSBuildWarningsAsMessages);NETSDK1138;MSB3270 @@ -35,6 +40,9 @@ $(NoWarn);CA1062 + + + $(NoWarn);CS1998 @@ -59,6 +67,13 @@ + + + <_Parameter1>OriginalRepoCommitHash + <_Parameter2>$(RepoOriginalSourceRevisionId) + + + diff --git a/NuGet.config b/NuGet.config index 5080151679a..4e7655f10c7 100644 --- a/NuGet.config +++ b/NuGet.config @@ -3,38 +3,20 @@ + + + - - - - - - - - - + + - - - - - - - - - + + - - - - - - - - - + + @@ -47,38 +29,20 @@ + + + - - - - - - - - - + + - - - - - - - - - + + - - - - - - - - - + + diff --git a/azure-pipelines-public.yml b/azure-pipelines-public.yml index 30fa2c85fa5..9668b4ed6fb 100644 --- a/azure-pipelines-public.yml +++ b/azure-pipelines-public.yml @@ -237,10 +237,10 @@ stages: pool: name: $(DncEngPublicBuildPool) - demands: ImageOverride -equals build.ubuntu.2004.amd64.open + demands: ImageOverride -equals windows.vs2022preview.amd64.open variables: - - _buildScript: $(Build.SourcesDirectory)/build.sh --ci + - _buildScript: $(Build.SourcesDirectory)/build.cmd -ci -NativeToolsOnMachine preSteps: - checkout: self @@ -257,4 +257,4 @@ stages: repoTestResultsPath: $(Build.Arcade.TestResultsPath) skipTests: true skipQualityGates: true - isWindows: false + isWindows: true diff --git a/azure-pipelines-unofficial.yml b/azure-pipelines-unofficial.yml new file mode 100644 index 00000000000..57594fd5ef1 --- /dev/null +++ b/azure-pipelines-unofficial.yml @@ -0,0 +1,256 @@ +trigger: none + +pr: none + +variables: + - name: _TeamName + value: dotnet-r9 + - name: NativeToolsOnMachine + value: true + - name: DOTNET_SKIP_FIRST_TIME_EXPERIENCE + value: true + + - name: SkipQualityGates + value: false + + - name: runAsPublic + value: ${{ eq(variables['System.TeamProject'], 'public') }} + + - name: _BuildConfig + value: Release + - name: isOfficialBuild + value: ${{ and(ne(variables['runAsPublic'], 'true'), notin(variables['Build.Reason'], 'PullRequest')) }} + - name: Build.Arcade.ArtifactsPath + value: $(Build.SourcesDirectory)/artifacts/ + - name: Build.Arcade.LogsPath + value: $(Build.Arcade.ArtifactsPath)log/$(_BuildConfig)/ + - name: Build.Arcade.TestResultsPath + value: $(Build.Arcade.ArtifactsPath)TestResults/$(_BuildConfig)/ + - name: Build.Arcade.VSIXOutputPath + value: $(Build.Arcade.ArtifactsPath)VSIX + + - ${{ if or(startswith(variables['Build.SourceBranch'], 'refs/heads/release/'), startswith(variables['Build.SourceBranch'], 'refs/heads/internal/release/'), startswith(variables['Build.SourceBranch'], 'refs/heads/validation/'), eq(variables['Build.Reason'], 'Manual')) }}: + - name: PostBuildSign + value: false + - ${{ else }}: + - name: PostBuildSign + value: true + + - name: _PublishArgs + value: >- + /p:DotNetPublishUsingPipelines=true + - name: _OfficialBuildIdArgs + value: /p:OfficialBuildId=$(BUILD.BUILDNUMBER) + # needed for signing + - name: _SignType + value: test + - name: _SignArgs + value: /p:DotNetSignType=$(_SignType) /p:TeamName=$(_TeamName) /p:Sign=$(_Sign) /p:DotNetPublishUsingPipelines=true + - name: _Sign + value: true + + - name: enableSourceIndex + value: false + +resources: + repositories: + - repository: 1ESPipelineTemplates + type: git + name: 1ESPipelineTemplates/1ESPipelineTemplates + ref: refs/tags/release + +extends: + template: v1/1ES.Unofficial.PipelineTemplate.yml@1ESPipelineTemplates + parameters: + sdl: + sourceAnalysisPool: + name: NetCore1ESPool-Internal + image: windows.vs2022preview.amd64 + os: windows + customBuildTags: + - ES365AIMigrationTooling + + stages: + - stage: build + displayName: Build + 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 + enableSourceIndex: ${{ variables['enableSourceIndex'] }} + runAsPublic: ${{ variables['runAsPublic'] }} + # Publish build logs + enablePublishBuildArtifacts: false + # Publish test logs + enablePublishTestResults: true + # Publish NuGet packages using v3 + # https://github.com/dotnet/arcade/blob/main/Documentation/CorePackages/Publishing.md#basic-onboarding-scenario-for-new-repositories-to-the-current-publishing-version-v3 + enablePublishUsingPipelines: false + enablePublishBuildAssets: false + workspace: + clean: all + + jobs: + + # ---------------------------------------------------------------- + # This job build and run tests on Windows + # ---------------------------------------------------------------- + - job: Windows + timeoutInMinutes: 180 + testResultsFormat: VSTest + pool: + name: NetCore1ESPool-Internal + image: windows.vs2022preview.amd64 + os: windows + + variables: + - _buildScript: $(Build.SourcesDirectory)/build.cmd -ci -NativeToolsOnMachine + + templateContext: + outputs: + - output: pipelineArtifact + displayName: 'Publish Azure DevOps extension artifacts' + condition: succeeded() + targetPath: '$(Build.Arcade.VSIXOutputPath)' + artifactName: 'VSIXArtifacts' + + preSteps: + - checkout: self + clean: true + persistCredentials: true + fetchDepth: 1 + + steps: + - template: /eng/pipelines/templates/BuildAndTest.yml + parameters: + buildScript: $(_buildScript) + buildConfig: $(_BuildConfig) + repoLogPath: $(Build.Arcade.LogsPath) + repoTestResultsPath: $(Build.Arcade.TestResultsPath) + skipQualityGates: ${{ eq(variables['SkipQualityGates'], 'true') }} + isWindows: true + + # ---------------------------------------------------------------- + # This job build and run tests on Ubuntu + # ---------------------------------------------------------------- + - job: Ubuntu + timeoutInMinutes: 180 + testResultsFormat: VSTest + pool: + name: NetCore1ESPool-Internal + image: 1es-mariner-2 + os: linux + + variables: + - _buildScript: $(Build.SourcesDirectory)/build.sh --ci + + preSteps: + - checkout: self + clean: true + persistCredentials: true + fetchDepth: 1 + + steps: + - template: /eng/pipelines/templates/BuildAndTest.yml + parameters: + buildScript: $(_buildScript) + buildConfig: $(_BuildConfig) + repoLogPath: $(Build.Arcade.LogsPath) + repoTestResultsPath: $(Build.Arcade.TestResultsPath) + skipQualityGates: ${{ eq(variables['SkipQualityGates'], 'true') }} + isWindows: false + + # ---------------------------------------------------------------- + # 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 + # ---------------------------------------------------------------- + - stage: correctness + displayName: Correctness + dependsOn: [] + 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 + + jobs: + - job: WarningsCheck + timeoutInMinutes: 180 + + pool: + name: NetCore1ESPool-Internal + image: 1es-mariner-2 + os: linux + + variables: + - _buildScript: $(Build.SourcesDirectory)/build.sh --ci + + preSteps: + - checkout: self + clean: true + persistCredentials: true + fetchDepth: 1 + + steps: + - template: '\eng\pipelines\templates\BuildAndTest.yml' + parameters: + buildScript: $(_buildScript) + buildConfig: $(_BuildConfig) + repoLogPath: $(Build.Arcade.LogsPath) + repoTestResultsPath: $(Build.Arcade.TestResultsPath) + skipTests: true + skipQualityGates: true + isWindows: false diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 0052dc9f706..59fbfbf7405 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -75,6 +75,13 @@ variables: - name: Build.Arcade.VSIXOutputPath value: $(Build.Arcade.ArtifactsPath)VSIX + # Enable extraction of published outputs for analysis + - name: GDN_EXTRACT_TOOLS + value: 'binskim,bandit,roslynanalyzers' + + - name: GDN_EXTRACT_FILTER + value: 'f|**/*.zip;f|**/*.nupkg;f|**/*.vsix;f|**/*.cspkg;f|**/*.sfpkg;f|**/*.package' + - ${{ if or(startswith(variables['Build.SourceBranch'], 'refs/heads/release/'), startswith(variables['Build.SourceBranch'], 'refs/heads/internal/release/'), startswith(variables['Build.SourceBranch'], 'refs/heads/validation/'), eq(variables['Build.Reason'], 'Manual')) }}: - name: PostBuildSign value: false @@ -117,6 +124,8 @@ variables: - ${{ if and(ne(variables['runAsPublic'], 'true'), notin(variables['Build.Reason'], 'PullRequest'), eq(variables['Build.SourceBranch'], 'refs/heads/main')) }}: - name: enableSourceIndex value: true + - name: sourceIndexBuildCommand + value: $(Build.SourcesDirectory)/build.cmd -ci -NativeToolsOnMachine - ${{ else }}: - name: enableSourceIndex value: false @@ -131,6 +140,8 @@ resources: extends: template: v1/1ES.Official.PipelineTemplate.yml@1ESPipelineTemplates parameters: + featureFlags: + binskimScanAllExtensions: true sdl: policheck: enabled: true @@ -152,18 +163,19 @@ extends: jobs: - template: /eng/common/templates-official/jobs/jobs.yml@self parameters: + artifacts: + publish: + logs: true + manifests: true enableMicrobuild: true enableTelemetry: true enableSourceIndex: ${{ variables['enableSourceIndex'] }} runAsPublic: ${{ variables['runAsPublic'] }} - # Publish build logs - enablePublishBuildArtifacts: true # Publish test logs enablePublishTestResults: true # Publish NuGet packages using v3 # https://github.com/dotnet/arcade/blob/main/Documentation/CorePackages/Publishing.md#basic-onboarding-scenario-for-new-repositories-to-the-current-publishing-version-v3 enablePublishUsingPipelines: true - enablePublishBuildAssets: true workspace: clean: all @@ -187,9 +199,16 @@ extends: outputs: - output: pipelineArtifact displayName: 'Publish Azure DevOps extension artifacts' - condition: succeeded() targetPath: '$(Build.Arcade.VSIXOutputPath)' artifactName: 'VSIXArtifacts' + condition: always() + continueOnError: true + - output: pipelineArtifact + displayName: 'Publish Packages' + targetPath: '$(Build.Arcade.ArtifactsPath)packages' + artifactName: 'PackageArtifacts_Windows' + condition: always() + continueOnError: true preSteps: - checkout: self @@ -206,7 +225,6 @@ extends: repoTestResultsPath: $(Build.Arcade.TestResultsPath) skipQualityGates: ${{ eq(variables['SkipQualityGates'], 'true') }} isWindows: true - warnAsError: 0 # ---------------------------------------------------------------- # This job build and run tests on Ubuntu @@ -237,7 +255,6 @@ extends: repoTestResultsPath: $(Build.Arcade.TestResultsPath) skipQualityGates: ${{ eq(variables['SkipQualityGates'], 'true') }} isWindows: false - warnAsError: 0 # ---------------------------------------------------------------- # This stage only performs a build treating warnings as errors diff --git a/bench/.editorconfig b/bench/.editorconfig index f66bffda880..c86f7f34899 100644 --- a/bench/.editorconfig +++ b/bench/.editorconfig @@ -481,11 +481,6 @@ dotnet_diagnostic.CA1507.severity = warning # Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1508 dotnet_diagnostic.CA1508.severity = warning -# Title : Avoid dead conditional code -# Category : Maintainability -# Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1508 -dotnet_diagnostic.CA1508.severity = warning - # Title : Invalid entry in code metrics rule specification file # Category : Maintainability # Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1509 diff --git a/eng/MSBuild/LegacySupport.props b/eng/MSBuild/LegacySupport.props index 7bda63a6607..9e83541b0d8 100644 --- a/eng/MSBuild/LegacySupport.props +++ b/eng/MSBuild/LegacySupport.props @@ -2,7 +2,7 @@ - + @@ -47,10 +47,6 @@ - - - - diff --git a/eng/MSBuild/Packaging.targets b/eng/MSBuild/Packaging.targets index fdde9aa70d1..0e1a385d685 100644 --- a/eng/MSBuild/Packaging.targets +++ b/eng/MSBuild/Packaging.targets @@ -37,7 +37,7 @@ true - $(ApiCompatBaselineVersion) + 9.10.0 @@ -93,7 +93,7 @@ !@(_PackageBuildFile->AnyHaveMetadataValue('PackagePathWithoutFilename', '$(_NETStandardCompatErrorPlaceholderFilePackagePath)'))" /> - + @@ -102,4 +102,15 @@ + + + + <_PackageVersionInfo Include="$(MSBuildProjectFullPath)"> + $(PackageVersion) + $(PackageId) + + + + diff --git a/eng/Publishing.props b/eng/Publishing.props index 0bcb04d4f32..a39ae2df5ac 100644 --- a/eng/Publishing.props +++ b/eng/Publishing.props @@ -1,5 +1,9 @@ + + + + 3 true diff --git a/eng/Signing.props b/eng/Signing.props index d4b1ec3e6cf..133e317085a 100644 --- a/eng/Signing.props +++ b/eng/Signing.props @@ -1,6 +1,7 @@ + \ No newline at end of file diff --git a/eng/Tools/.editorconfig b/eng/Tools/.editorconfig index be77d5da2f3..8b26ad2938d 100644 --- a/eng/Tools/.editorconfig +++ b/eng/Tools/.editorconfig @@ -479,11 +479,6 @@ dotnet_diagnostic.CA1507.severity = warning # Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1508 dotnet_diagnostic.CA1508.severity = warning -# Title : Avoid dead conditional code -# Category : Maintainability -# Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1508 -dotnet_diagnostic.CA1508.severity = warning - # Title : Invalid entry in code metrics rule specification file # Category : Maintainability # Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1509 diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index cb6ead9fa88..154e91caeb2 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -1,210 +1,222 @@ - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 + + + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime + fa7cdded37981a97cec9a3e233c4a6af58a91c57 + + + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3c298d9f00936d651cc47d221762474e25277672 + fa7cdded37981a97cec9a3e233c4a6af58a91c57 + + + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore + d3aba8fe1a0d0f5c145506f292b72ea9d28406fc - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - f6b3a5da75eb405046889a5447ec9b14cc29d285 + d3aba8fe1a0d0f5c145506f292b72ea9d28406fc - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - f6b3a5da75eb405046889a5447ec9b14cc29d285 + d3aba8fe1a0d0f5c145506f292b72ea9d28406fc - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - f6b3a5da75eb405046889a5447ec9b14cc29d285 + d3aba8fe1a0d0f5c145506f292b72ea9d28406fc - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - f6b3a5da75eb405046889a5447ec9b14cc29d285 + d3aba8fe1a0d0f5c145506f292b72ea9d28406fc - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - f6b3a5da75eb405046889a5447ec9b14cc29d285 + d3aba8fe1a0d0f5c145506f292b72ea9d28406fc - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - f6b3a5da75eb405046889a5447ec9b14cc29d285 + d3aba8fe1a0d0f5c145506f292b72ea9d28406fc - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - f6b3a5da75eb405046889a5447ec9b14cc29d285 + d3aba8fe1a0d0f5c145506f292b72ea9d28406fc - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - f6b3a5da75eb405046889a5447ec9b14cc29d285 + d3aba8fe1a0d0f5c145506f292b72ea9d28406fc - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - f6b3a5da75eb405046889a5447ec9b14cc29d285 + d3aba8fe1a0d0f5c145506f292b72ea9d28406fc - + https://dev.azure.com/dnceng/internal/_git/dotnet-efcore - 67d253c17619e6ba325e5390905ea2a13cc7f532 + f55fe13550b5f821336abb63ef5ac454ce4de5fa - + https://github.com/dotnet/arcade - 13b20849f8294593bf150a801cab639397e6c29d + 6666973b629b24e259162dba03486c23af464bab - + https://github.com/dotnet/arcade - 13b20849f8294593bf150a801cab639397e6c29d + 6666973b629b24e259162dba03486c23af464bab - + https://github.com/dotnet/arcade - 13b20849f8294593bf150a801cab639397e6c29d + 6666973b629b24e259162dba03486c23af464bab diff --git a/eng/Versions.props b/eng/Versions.props index ac0cc576af8..3c01e6cdc7b 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -1,12 +1,11 @@ - 9 - 7 + 10 + 0 0 preview 1 $(MajorVersion).$(MinorVersion).$(PatchVersion) - 9.5.0 $(MajorVersion).$(MinorVersion).0.0 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 - 9.0.7 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 + 9.0.11 - 9.0.7 + 9.0.11 - 9.0.0-beta.25325.4 + 9.0.0-beta.25515.2 + + + + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + + 10.0.0 + + 10.0.0-beta.25523.111 @@ -108,8 +167,9 @@ 8.0.1 8.0.0 8.0.2 - 8.0.18 - 8.0.18 + 8.0.0 + 8.0.22 + 8.0.22 8.0.0 8.0.1 8.0.1 @@ -122,21 +182,22 @@ 8.0.1 8.0.2 8.0.0 - 8.0.0 + 8.0.1 8.0.6 8.0.0 - 8.0.18 - 8.0.18 - 8.0.18 - 8.0.18 - 8.0.18 - 8.0.18 - 8.0.18 - 8.0.18 - 8.0.18 + 8.0.22 + 8.0.22 + 8.0.22 + 8.0.22 + 8.0.22 + 8.0.22 + 8.0.22 + 8.0.22 + 8.0.22 + 8.0.22 - 8.0.18 + 8.0.22 2.9.3 + + 2.8.2 + + 9.7.0 + 1.67.0-preview + 0.43.0 diff --git a/eng/common/SetupNugetSources.ps1 b/eng/common/SetupNugetSources.ps1 index 5db4ad71ee2..792b60b49d4 100644 --- a/eng/common/SetupNugetSources.ps1 +++ b/eng/common/SetupNugetSources.ps1 @@ -10,8 +10,8 @@ # displayName: Setup Private Feeds Credentials # condition: eq(variables['Agent.OS'], 'Windows_NT') # inputs: -# filePath: $(Build.SourcesDirectory)/eng/common/SetupNugetSources.ps1 -# arguments: -ConfigFile $(Build.SourcesDirectory)/NuGet.config -Password $Env:Token +# filePath: $(System.DefaultWorkingDirectory)/eng/common/SetupNugetSources.ps1 +# arguments: -ConfigFile $(System.DefaultWorkingDirectory)/NuGet.config -Password $Env:Token # env: # Token: $(dn-bot-dnceng-artifact-feeds-rw) # diff --git a/eng/common/SetupNugetSources.sh b/eng/common/SetupNugetSources.sh index 4604b61b032..facb415ca6f 100755 --- a/eng/common/SetupNugetSources.sh +++ b/eng/common/SetupNugetSources.sh @@ -11,8 +11,8 @@ # - task: Bash@3 # displayName: Setup Internal Feeds # inputs: -# filePath: $(Build.SourcesDirectory)/eng/common/SetupNugetSources.sh -# arguments: $(Build.SourcesDirectory)/NuGet.config +# filePath: $(System.DefaultWorkingDirectory)/eng/common/SetupNugetSources.sh +# arguments: $(System.DefaultWorkingDirectory)/NuGet.config # condition: ne(variables['Agent.OS'], 'Windows_NT') # - task: NuGetAuthenticate@1 # diff --git a/eng/common/core-templates/job/job.yml b/eng/common/core-templates/job/job.yml index abe80a2a0e0..8da43d3b583 100644 --- a/eng/common/core-templates/job/job.yml +++ b/eng/common/core-templates/job/job.yml @@ -19,6 +19,7 @@ parameters: # publishing defaults artifacts: '' enableMicrobuild: false + microbuildUseESRP: true enablePublishBuildArtifacts: false enablePublishBuildAssets: false enablePublishTestResults: false @@ -134,10 +135,11 @@ jobs: signType: $(_SignType) zipSources: false feedSource: https://dnceng.pkgs.visualstudio.com/_packaging/MicroBuildToolset/nuget/v3/index.json - ${{ if eq(variables['System.TeamProject'], 'DevDiv') }}: - ConnectedPMEServiceName: 6cc74545-d7b9-4050-9dfa-ebefcc8961ea - ${{ else }}: - ConnectedPMEServiceName: 248d384a-b39b-46e3-8ad5-c2c210d5e7ca + ${{ if eq(parameters.microbuildUseESRP, true) }}: + ${{ if eq(variables['System.TeamProject'], 'DevDiv') }}: + ConnectedPMEServiceName: 6cc74545-d7b9-4050-9dfa-ebefcc8961ea + ${{ else }}: + ConnectedPMEServiceName: 248d384a-b39b-46e3-8ad5-c2c210d5e7ca env: TeamName: $(_TeamName) MicroBuildOutputFolderOverride: '$(Agent.TempDirectory)' @@ -164,7 +166,7 @@ jobs: inputs: languages: ${{ coalesce(parameters.richCodeNavigationLanguage, 'csharp') }} environment: ${{ coalesce(parameters.richCodeNavigationEnvironment, 'internal') }} - richNavLogOutputDirectory: $(Build.SourcesDirectory)/artifacts/bin + richNavLogOutputDirectory: $(System.DefaultWorkingDirectory)/artifacts/bin uploadRichNavArtifacts: ${{ coalesce(parameters.richCodeNavigationUploadArtifacts, false) }} continueOnError: true @@ -187,7 +189,7 @@ jobs: inputs: testResultsFormat: 'xUnit' testResultsFiles: '*.xml' - searchFolder: '$(Build.SourcesDirectory)/artifacts/TestResults/$(_BuildConfig)' + searchFolder: '$(System.DefaultWorkingDirectory)/artifacts/TestResults/$(_BuildConfig)' testRunTitle: ${{ coalesce(parameters.testRunTitle, parameters.name, '$(System.JobName)') }}-xunit mergeTestResults: ${{ parameters.mergeTestResults }} continueOnError: true @@ -198,7 +200,7 @@ jobs: inputs: testResultsFormat: 'VSTest' testResultsFiles: '*.trx' - searchFolder: '$(Build.SourcesDirectory)/artifacts/TestResults/$(_BuildConfig)' + searchFolder: '$(System.DefaultWorkingDirectory)/artifacts/TestResults/$(_BuildConfig)' testRunTitle: ${{ coalesce(parameters.testRunTitle, parameters.name, '$(System.JobName)') }}-trx mergeTestResults: ${{ parameters.mergeTestResults }} continueOnError: true @@ -242,7 +244,7 @@ jobs: - task: CopyFiles@2 displayName: Gather buildconfiguration for build retry inputs: - SourceFolder: '$(Build.SourcesDirectory)/eng/common/BuildConfiguration' + SourceFolder: '$(System.DefaultWorkingDirectory)/eng/common/BuildConfiguration' Contents: '**' TargetFolder: '$(Build.ArtifactStagingDirectory)/eng/common/BuildConfiguration' continueOnError: true diff --git a/eng/common/core-templates/job/onelocbuild.yml b/eng/common/core-templates/job/onelocbuild.yml index 00feec8ebbc..edefa789d36 100644 --- a/eng/common/core-templates/job/onelocbuild.yml +++ b/eng/common/core-templates/job/onelocbuild.yml @@ -8,7 +8,7 @@ parameters: CeapexPat: $(dn-bot-ceapex-package-r) # PAT for the loc AzDO instance https://dev.azure.com/ceapex GithubPat: $(BotAccount-dotnet-bot-repo-PAT) - SourcesDirectory: $(Build.SourcesDirectory) + SourcesDirectory: $(System.DefaultWorkingDirectory) CreatePr: true AutoCompletePr: false ReusePr: true @@ -68,7 +68,7 @@ jobs: - ${{ if ne(parameters.SkipLocProjectJsonGeneration, 'true') }}: - task: Powershell@2 inputs: - filePath: $(Build.SourcesDirectory)/eng/common/generate-locproject.ps1 + filePath: $(System.DefaultWorkingDirectory)/eng/common/generate-locproject.ps1 arguments: $(_GenerateLocProjectArguments) displayName: Generate LocProject.json condition: ${{ parameters.condition }} @@ -115,7 +115,7 @@ jobs: is1ESPipeline: ${{ parameters.is1ESPipeline }} args: displayName: Publish LocProject.json - pathToPublish: '$(Build.SourcesDirectory)/eng/Localize/' + pathToPublish: '$(System.DefaultWorkingDirectory)/eng/Localize/' publishLocation: Container artifactName: Loc condition: ${{ parameters.condition }} \ No newline at end of file diff --git a/eng/common/core-templates/job/publish-build-assets.yml b/eng/common/core-templates/job/publish-build-assets.yml index 3d3356e3196..a58c8a418e8 100644 --- a/eng/common/core-templates/job/publish-build-assets.yml +++ b/eng/common/core-templates/job/publish-build-assets.yml @@ -32,6 +32,10 @@ parameters: is1ESPipeline: '' + repositoryAlias: self + + officialBuildId: '' + jobs: - job: Asset_Registry_Publish @@ -54,6 +58,11 @@ jobs: value: false # unconditional - needed for logs publishing (redactor tool version) - template: /eng/common/core-templates/post-build/common-variables.yml + - name: OfficialBuildId + ${{ if ne(parameters.officialBuildId, '') }}: + value: ${{ parameters.officialBuildId }} + ${{ else }}: + value: $(Build.BuildNumber) pool: # We don't use the collection uri here because it might vary (.visualstudio.com vs. dev.azure.com) @@ -72,7 +81,7 @@ jobs: - 'Illegal entry point, is1ESPipeline is not defined. Repository yaml should not directly reference templates in core-templates folder.': error - ${{ if and(eq(parameters.runAsPublic, 'false'), ne(variables['System.TeamProject'], 'public'), notin(variables['Build.Reason'], 'PullRequest')) }}: - - checkout: self + - checkout: ${{ parameters.repositoryAlias }} fetchDepth: 3 clean: true @@ -93,12 +102,12 @@ jobs: azureSubscription: "Darc: Maestro Production" scriptType: ps scriptLocation: scriptPath - scriptPath: $(Build.SourcesDirectory)/eng/common/sdk-task.ps1 + scriptPath: $(System.DefaultWorkingDirectory)/eng/common/sdk-task.ps1 arguments: -task PublishBuildAssets -restore -msbuildEngine dotnet /p:ManifestsPath='$(Build.StagingDirectory)/Download/AssetManifests' /p:MaestroApiEndpoint=https://maestro.dot.net /p:PublishUsingPipelines=${{ parameters.publishUsingPipelines }} - /p:OfficialBuildId=$(Build.BuildNumber) + /p:OfficialBuildId=$(OfficialBuildId) condition: ${{ parameters.condition }} continueOnError: ${{ parameters.continueOnError }} @@ -113,7 +122,7 @@ jobs: Add-Content -Path $filePath -Value "$(DefaultChannels)" Add-Content -Path $filePath -Value $(IsStableBuild) - $symbolExclusionfile = "$(Build.SourcesDirectory)/eng/SymbolPublishingExclusionsFile.txt" + $symbolExclusionfile = "$(System.DefaultWorkingDirectory)/eng/SymbolPublishingExclusionsFile.txt" if (Test-Path -Path $symbolExclusionfile) { Write-Host "SymbolExclusionFile exists" @@ -142,7 +151,7 @@ jobs: azureSubscription: "Darc: Maestro Production" scriptType: ps scriptLocation: scriptPath - scriptPath: $(Build.SourcesDirectory)/eng/common/post-build/publish-using-darc.ps1 + scriptPath: $(System.DefaultWorkingDirectory)/eng/common/post-build/publish-using-darc.ps1 arguments: > -BuildId $(BARBuildId) -PublishingInfraVersion 3 diff --git a/eng/common/core-templates/job/source-build.yml b/eng/common/core-templates/job/source-build.yml index d47f09d58fd..5baedac1e03 100644 --- a/eng/common/core-templates/job/source-build.yml +++ b/eng/common/core-templates/job/source-build.yml @@ -33,6 +33,9 @@ parameters: # container and pool. platform: {} + # Optional list of directories to ignore for component governance scans. + componentGovernanceIgnoreDirectories: [] + is1ESPipeline: '' # If set to true and running on a non-public project, @@ -93,3 +96,4 @@ jobs: parameters: is1ESPipeline: ${{ parameters.is1ESPipeline }} platform: ${{ parameters.platform }} + componentGovernanceIgnoreDirectories: ${{ parameters.componentGovernanceIgnoreDirectories }} diff --git a/eng/common/core-templates/job/source-index-stage1.yml b/eng/common/core-templates/job/source-index-stage1.yml index 8b833332b3e..662b9fcce15 100644 --- a/eng/common/core-templates/job/source-index-stage1.yml +++ b/eng/common/core-templates/job/source-index-stage1.yml @@ -66,7 +66,7 @@ jobs: - script: ${{ parameters.sourceIndexBuildCommand }} displayName: Build Repository - - script: $(Agent.TempDirectory)/.source-index/tools/BinLogToSln -i $(BinlogPath) -r $(Build.SourcesDirectory) -n $(Build.Repository.Name) -o .source-index/stage1output + - script: $(Agent.TempDirectory)/.source-index/tools/BinLogToSln -i $(BinlogPath) -r $(System.DefaultWorkingDirectory) -n $(Build.Repository.Name) -o .source-index/stage1output displayName: Process Binlog into indexable sln - ${{ if and(eq(parameters.runAsPublic, 'false'), ne(variables['System.TeamProject'], 'public'), notin(variables['Build.Reason'], 'PullRequest')) }}: diff --git a/eng/common/core-templates/jobs/codeql-build.yml b/eng/common/core-templates/jobs/codeql-build.yml index f2144252cc6..4571a7864df 100644 --- a/eng/common/core-templates/jobs/codeql-build.yml +++ b/eng/common/core-templates/jobs/codeql-build.yml @@ -25,7 +25,7 @@ jobs: - name: DefaultGuardianVersion value: 0.109.0 - name: GuardianPackagesConfigFile - value: $(Build.SourcesDirectory)\eng\common\sdl\packages.config + value: $(System.DefaultWorkingDirectory)\eng\common\sdl\packages.config - name: GuardianVersion value: ${{ coalesce(parameters.overrideGuardianVersion, '$(DefaultGuardianVersion)') }} diff --git a/eng/common/core-templates/jobs/jobs.yml b/eng/common/core-templates/jobs/jobs.yml index ea69be4341c..bf33cdc2cc7 100644 --- a/eng/common/core-templates/jobs/jobs.yml +++ b/eng/common/core-templates/jobs/jobs.yml @@ -43,6 +43,8 @@ parameters: artifacts: {} is1ESPipeline: '' + repositoryAlias: self + officialBuildId: '' # Internal resources (telemetry, microbuild) can only be accessed from non-public projects, # and some (Microbuild) should only be applied to non-PR cases for internal builds. @@ -117,3 +119,5 @@ jobs: enablePublishBuildArtifacts: ${{ parameters.enablePublishBuildArtifacts }} artifactsPublishingAdditionalParameters: ${{ parameters.artifactsPublishingAdditionalParameters }} signingValidationAdditionalParameters: ${{ parameters.signingValidationAdditionalParameters }} + repositoryAlias: ${{ parameters.repositoryAlias }} + officialBuildId: ${{ parameters.officialBuildId }} diff --git a/eng/common/core-templates/jobs/source-build.yml b/eng/common/core-templates/jobs/source-build.yml index a10ccfbee6d..0b408a67bd5 100644 --- a/eng/common/core-templates/jobs/source-build.yml +++ b/eng/common/core-templates/jobs/source-build.yml @@ -21,6 +21,9 @@ parameters: # one job runs on 'defaultManagedPlatform'. platforms: [] + # Optional list of directories to ignore for component governance scans. + componentGovernanceIgnoreDirectories: [] + is1ESPipeline: '' # If set to true and running on a non-public project, @@ -47,6 +50,7 @@ jobs: is1ESPipeline: ${{ parameters.is1ESPipeline }} jobNamePrefix: ${{ parameters.jobNamePrefix }} platform: ${{ platform }} + componentGovernanceIgnoreDirectories: ${{ parameters.componentGovernanceIgnoreDirectories }} enableInternalSources: ${{ parameters.enableInternalSources }} - ${{ if eq(length(parameters.platforms), 0) }}: @@ -55,4 +59,5 @@ jobs: is1ESPipeline: ${{ parameters.is1ESPipeline }} jobNamePrefix: ${{ parameters.jobNamePrefix }} platform: ${{ parameters.defaultManagedPlatform }} + componentGovernanceIgnoreDirectories: ${{ parameters.componentGovernanceIgnoreDirectories }} enableInternalSources: ${{ parameters.enableInternalSources }} diff --git a/eng/common/core-templates/post-build/post-build.yml b/eng/common/core-templates/post-build/post-build.yml index a8c0bd3b921..2ee8bbfff54 100644 --- a/eng/common/core-templates/post-build/post-build.yml +++ b/eng/common/core-templates/post-build/post-build.yml @@ -149,7 +149,7 @@ stages: - task: PowerShell@2 displayName: Validate inputs: - filePath: $(Build.SourcesDirectory)/eng/common/post-build/nuget-validation.ps1 + filePath: $(System.DefaultWorkingDirectory)/eng/common/post-build/nuget-validation.ps1 arguments: -PackagesPath $(Build.ArtifactStagingDirectory)/PackageArtifacts/ - job: @@ -206,7 +206,7 @@ stages: filePath: eng\common\sdk-task.ps1 arguments: -task SigningValidation -restore -msbuildEngine vs /p:PackageBasePath='$(Build.ArtifactStagingDirectory)/PackageArtifacts' - /p:SignCheckExclusionsFile='$(Build.SourcesDirectory)/eng/SignCheckExclusionsFile.txt' + /p:SignCheckExclusionsFile='$(System.DefaultWorkingDirectory)/eng/SignCheckExclusionsFile.txt' ${{ parameters.signingValidationAdditionalParameters }} - template: /eng/common/core-templates/steps/publish-logs.yml @@ -256,7 +256,7 @@ stages: - task: PowerShell@2 displayName: Validate inputs: - filePath: $(Build.SourcesDirectory)/eng/common/post-build/sourcelink-validation.ps1 + filePath: $(System.DefaultWorkingDirectory)/eng/common/post-build/sourcelink-validation.ps1 arguments: -InputPath $(Build.ArtifactStagingDirectory)/BlobArtifacts/ -ExtractPath $(Agent.BuildDirectory)/Extract/ -GHRepoName $(Build.Repository.Name) @@ -311,7 +311,7 @@ stages: azureSubscription: "Darc: Maestro Production" scriptType: ps scriptLocation: scriptPath - scriptPath: $(Build.SourcesDirectory)/eng/common/post-build/publish-using-darc.ps1 + scriptPath: $(System.DefaultWorkingDirectory)/eng/common/post-build/publish-using-darc.ps1 arguments: > -BuildId $(BARBuildId) -PublishingInfraVersion ${{ parameters.publishingInfraVersion }} diff --git a/eng/common/core-templates/post-build/setup-maestro-vars.yml b/eng/common/core-templates/post-build/setup-maestro-vars.yml index f7602980dbe..a7abd58c4bb 100644 --- a/eng/common/core-templates/post-build/setup-maestro-vars.yml +++ b/eng/common/core-templates/post-build/setup-maestro-vars.yml @@ -36,7 +36,7 @@ steps: $AzureDevOpsBuildId = $Env:Build_BuildId } else { - . $(Build.SourcesDirectory)\eng\common\tools.ps1 + . $(System.DefaultWorkingDirectory)\eng\common\tools.ps1 $darc = Get-Darc $buildInfo = & $darc get-build ` --id ${{ parameters.BARBuildId }} ` diff --git a/eng/common/core-templates/steps/enable-internal-sources.yml b/eng/common/core-templates/steps/enable-internal-sources.yml index 64f881bffc3..4085512b690 100644 --- a/eng/common/core-templates/steps/enable-internal-sources.yml +++ b/eng/common/core-templates/steps/enable-internal-sources.yml @@ -17,8 +17,8 @@ steps: - task: PowerShell@2 displayName: Setup Internal Feeds inputs: - filePath: $(Build.SourcesDirectory)/eng/common/SetupNugetSources.ps1 - arguments: -ConfigFile $(Build.SourcesDirectory)/NuGet.config -Password $Env:Token + filePath: $(System.DefaultWorkingDirectory)/eng/common/SetupNugetSources.ps1 + arguments: -ConfigFile $(System.DefaultWorkingDirectory)/NuGet.config -Password $Env:Token env: Token: ${{ parameters.legacyCredential }} # If running on dnceng (internal project), just use the default behavior for NuGetAuthenticate. @@ -29,8 +29,8 @@ steps: - task: PowerShell@2 displayName: Setup Internal Feeds inputs: - filePath: $(Build.SourcesDirectory)/eng/common/SetupNugetSources.ps1 - arguments: -ConfigFile $(Build.SourcesDirectory)/NuGet.config + filePath: $(System.DefaultWorkingDirectory)/eng/common/SetupNugetSources.ps1 + arguments: -ConfigFile $(System.DefaultWorkingDirectory)/NuGet.config - ${{ else }}: - template: /eng/common/templates/steps/get-federated-access-token.yml parameters: @@ -39,8 +39,8 @@ steps: - task: PowerShell@2 displayName: Setup Internal Feeds inputs: - filePath: $(Build.SourcesDirectory)/eng/common/SetupNugetSources.ps1 - arguments: -ConfigFile $(Build.SourcesDirectory)/NuGet.config -Password $(dnceng-artifacts-feeds-read-access-token) + filePath: $(System.DefaultWorkingDirectory)/eng/common/SetupNugetSources.ps1 + arguments: -ConfigFile $(System.DefaultWorkingDirectory)/NuGet.config -Password $(dnceng-artifacts-feeds-read-access-token) # This is required in certain scenarios to install the ADO credential provider. # It installed by default in some msbuild invocations (e.g. VS msbuild), but needs to be installed for others # (e.g. dotnet msbuild). diff --git a/eng/common/core-templates/steps/generate-sbom.yml b/eng/common/core-templates/steps/generate-sbom.yml index 56a09009482..7f5b84c4cb8 100644 --- a/eng/common/core-templates/steps/generate-sbom.yml +++ b/eng/common/core-templates/steps/generate-sbom.yml @@ -6,7 +6,7 @@ parameters: PackageVersion: 9.0.0 - BuildDropPath: '$(Build.SourcesDirectory)/artifacts' + BuildDropPath: '$(System.DefaultWorkingDirectory)/artifacts' PackageName: '.NET' ManifestDirPath: $(Build.ArtifactStagingDirectory)/sbom IgnoreDirectories: '' diff --git a/eng/common/core-templates/steps/publish-logs.yml b/eng/common/core-templates/steps/publish-logs.yml index 80788c52319..0623ac6e112 100644 --- a/eng/common/core-templates/steps/publish-logs.yml +++ b/eng/common/core-templates/steps/publish-logs.yml @@ -12,22 +12,22 @@ steps: inputs: targetType: inline script: | - New-Item -ItemType Directory $(Build.SourcesDirectory)/PostBuildLogs/${{parameters.StageLabel}}/${{parameters.JobLabel}}/ - Move-Item -Path $(Build.SourcesDirectory)/artifacts/log/Debug/* $(Build.SourcesDirectory)/PostBuildLogs/${{parameters.StageLabel}}/${{parameters.JobLabel}}/ + New-Item -ItemType Directory $(System.DefaultWorkingDirectory)/PostBuildLogs/${{parameters.StageLabel}}/${{parameters.JobLabel}}/ + Move-Item -Path $(System.DefaultWorkingDirectory)/artifacts/log/Debug/* $(System.DefaultWorkingDirectory)/PostBuildLogs/${{parameters.StageLabel}}/${{parameters.JobLabel}}/ continueOnError: true condition: always() - task: PowerShell@2 displayName: Redact Logs inputs: - filePath: $(Build.SourcesDirectory)/eng/common/post-build/redact-logs.ps1 + filePath: $(System.DefaultWorkingDirectory)/eng/common/post-build/redact-logs.ps1 # For now this needs to have explicit list of all sensitive data. Taken from eng/publishing/v3/publish.yml - # Sensitive data can as well be added to $(Build.SourcesDirectory)/eng/BinlogSecretsRedactionFile.txt' + # Sensitive data can as well be added to $(System.DefaultWorkingDirectory)/eng/BinlogSecretsRedactionFile.txt' # If the file exists - sensitive data for redaction will be sourced from it # (single entry per line, lines starting with '# ' are considered comments and skipped) - arguments: -InputPath '$(Build.SourcesDirectory)/PostBuildLogs' + arguments: -InputPath '$(System.DefaultWorkingDirectory)/PostBuildLogs' -BinlogToolVersion ${{parameters.BinlogToolVersion}} - -TokensFilePath '$(Build.SourcesDirectory)/eng/BinlogSecretsRedactionFile.txt' + -TokensFilePath '$(System.DefaultWorkingDirectory)/eng/BinlogSecretsRedactionFile.txt' '$(publishing-dnceng-devdiv-code-r-build-re)' '$(MaestroAccessToken)' '$(dn-bot-all-orgs-artifact-feeds-rw)' @@ -42,7 +42,7 @@ steps: - task: CopyFiles@2 displayName: Gather post build logs inputs: - SourceFolder: '$(Build.SourcesDirectory)/PostBuildLogs' + SourceFolder: '$(System.DefaultWorkingDirectory)/PostBuildLogs' Contents: '**' TargetFolder: '$(Build.ArtifactStagingDirectory)/PostBuildLogs' diff --git a/eng/common/core-templates/steps/source-build.yml b/eng/common/core-templates/steps/source-build.yml index 37133b55b75..0718e4ba902 100644 --- a/eng/common/core-templates/steps/source-build.yml +++ b/eng/common/core-templates/steps/source-build.yml @@ -11,6 +11,10 @@ parameters: # for details. The entire object is described in the 'job' template for simplicity, even though # the usage of the properties on this object is split between the 'job' and 'steps' templates. platform: {} + + # Optional list of directories to ignore for component governance scans. + componentGovernanceIgnoreDirectories: [] + is1ESPipeline: false steps: @@ -97,7 +101,7 @@ steps: - task: CopyFiles@2 displayName: Prepare BuildLogs staging directory inputs: - SourceFolder: '$(Build.SourcesDirectory)' + SourceFolder: '$(System.DefaultWorkingDirectory)' Contents: | **/*.log **/*.binlog @@ -126,5 +130,8 @@ steps: parameters: displayName: Component Detection (Exclude upstream cache) is1ESPipeline: ${{ parameters.is1ESPipeline }} - componentGovernanceIgnoreDirectories: '$(Build.SourcesDirectory)/artifacts/sb/src/artifacts/obj/source-built-upstream-cache' + ${{ if eq(length(parameters.componentGovernanceIgnoreDirectories), 0) }}: + componentGovernanceIgnoreDirectories: '$(System.DefaultWorkingDirectory)/artifacts/sb/src/artifacts/obj/source-built-upstream-cache' + ${{ else }}: + componentGovernanceIgnoreDirectories: ${{ join(',', parameters.componentGovernanceIgnoreDirectories) }} disableComponentGovernance: ${{ eq(variables['System.TeamProject'], 'public') }} diff --git a/eng/common/post-build/nuget-verification.ps1 b/eng/common/post-build/nuget-verification.ps1 index a365194a938..ac5c69ffcac 100644 --- a/eng/common/post-build/nuget-verification.ps1 +++ b/eng/common/post-build/nuget-verification.ps1 @@ -30,7 +30,7 @@ [CmdletBinding(PositionalBinding = $false)] param( [string]$NuGetExePath, - [string]$PackageSource = "https://api.nuget.org/v3/index.json", + [string]$PackageSource = "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public/nuget/v3/index.json", [string]$DownloadPath, [Parameter(ValueFromRemainingArguments = $true)] [string[]]$args diff --git a/eng/common/template-guidance.md b/eng/common/template-guidance.md index 98bbc1ded0b..4bf4cf41bd7 100644 --- a/eng/common/template-guidance.md +++ b/eng/common/template-guidance.md @@ -50,7 +50,7 @@ extends: - task: CopyFiles@2 displayName: Gather build output inputs: - SourceFolder: '$(Build.SourcesDirectory)/artifacts/marvel' + SourceFolder: '$(System.DefaultWorkingDirectory)/artifacts/marvel' Contents: '**' TargetFolder: '$(Build.ArtifactStagingDirectory)/artifacts/marvel' ``` diff --git a/eng/common/templates-official/job/job.yml b/eng/common/templates-official/job/job.yml index 817555505aa..81ea7a261f2 100644 --- a/eng/common/templates-official/job/job.yml +++ b/eng/common/templates-official/job/job.yml @@ -3,7 +3,7 @@ parameters: enableSbom: true runAsPublic: false PackageVersion: 9.0.0 - BuildDropPath: '$(Build.SourcesDirectory)/artifacts' + BuildDropPath: '$(System.DefaultWorkingDirectory)/artifacts' jobs: - template: /eng/common/core-templates/job/job.yml diff --git a/eng/common/templates-official/variables/sdl-variables.yml b/eng/common/templates-official/variables/sdl-variables.yml index dbdd66d4a4b..f1311bbb1b3 100644 --- a/eng/common/templates-official/variables/sdl-variables.yml +++ b/eng/common/templates-official/variables/sdl-variables.yml @@ -4,4 +4,4 @@ variables: - name: DefaultGuardianVersion value: 0.109.0 - name: GuardianPackagesConfigFile - value: $(Build.SourcesDirectory)\eng\common\sdl\packages.config \ No newline at end of file + value: $(System.DefaultWorkingDirectory)\eng\common\sdl\packages.config \ No newline at end of file diff --git a/eng/common/templates/job/job.yml b/eng/common/templates/job/job.yml index d1aeb92fcea..5bdd3dd85fd 100644 --- a/eng/common/templates/job/job.yml +++ b/eng/common/templates/job/job.yml @@ -6,7 +6,7 @@ parameters: enableSbom: true runAsPublic: false PackageVersion: 9.0.0 - BuildDropPath: '$(Build.SourcesDirectory)/artifacts' + BuildDropPath: '$(System.DefaultWorkingDirectory)/artifacts' jobs: - template: /eng/common/core-templates/job/job.yml @@ -75,7 +75,7 @@ jobs: parameters: is1ESPipeline: false args: - targetPath: '$(Build.SourcesDirectory)\eng\common\BuildConfiguration' + targetPath: '$(System.DefaultWorkingDirectory)\eng\common\BuildConfiguration' artifactName: 'BuildConfiguration' displayName: 'Publish build retry configuration' continueOnError: true diff --git a/eng/common/tools.ps1 b/eng/common/tools.ps1 index 22b49e09d09..9b3ad8840fd 100644 --- a/eng/common/tools.ps1 +++ b/eng/common/tools.ps1 @@ -416,7 +416,7 @@ function InitializeVisualStudioMSBuild([bool]$install, [object]$vsRequirements = # Locate Visual Studio installation or download x-copy msbuild. $vsInfo = LocateVisualStudio $vsRequirements - if ($vsInfo -ne $null) { + if ($vsInfo -ne $null -and $env:ForceUseXCopyMSBuild -eq $null) { # Ensure vsInstallDir has a trailing slash $vsInstallDir = Join-Path $vsInfo.installationPath "\" $vsMajorVersion = $vsInfo.installationVersion.Split('.')[0] diff --git a/eng/packages/General-LTS.props b/eng/packages/General-LTS.props index 884d874c5e1..54b54eff5ae 100644 --- a/eng/packages/General-LTS.props +++ b/eng/packages/General-LTS.props @@ -2,8 +2,9 @@ - + + @@ -17,6 +18,7 @@ + @@ -28,6 +30,7 @@ + diff --git a/eng/packages/General-net10.props b/eng/packages/General-net10.props new file mode 100644 index 00000000000..e40b1c44c90 --- /dev/null +++ b/eng/packages/General-net10.props @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/eng/packages/General-net9.props b/eng/packages/General-net9.props index 341f69458a8..fecfa5b84e2 100644 --- a/eng/packages/General-net9.props +++ b/eng/packages/General-net9.props @@ -2,8 +2,9 @@ - + + @@ -17,6 +18,7 @@ + @@ -28,6 +30,7 @@ + @@ -35,6 +38,7 @@ + diff --git a/eng/packages/General.props b/eng/packages/General.props index aa9771bfb4a..503e2c1c321 100644 --- a/eng/packages/General.props +++ b/eng/packages/General.props @@ -1,26 +1,32 @@ - - + + - + + + + + + - + + @@ -33,6 +39,7 @@ + @@ -52,5 +59,6 @@ + diff --git a/eng/packages/TestOnly.props b/eng/packages/TestOnly.props index 3b511fa037f..47097ab042a 100644 --- a/eng/packages/TestOnly.props +++ b/eng/packages/TestOnly.props @@ -2,17 +2,20 @@ - + + + + @@ -22,13 +25,18 @@ - + + + $(NoWarn);NU1903 + + @@ -39,8 +47,13 @@ - + + + + + + diff --git a/eng/pipelines/templates/BuildAndTest.yml b/eng/pipelines/templates/BuildAndTest.yml index 6c54a911e84..8df9a2bede6 100644 --- a/eng/pipelines/templates/BuildAndTest.yml +++ b/eng/pipelines/templates/BuildAndTest.yml @@ -48,6 +48,7 @@ steps: - script: ${{ parameters.buildScript }} -restore + -warnAsError ${{ parameters.warnAsError }} /bl:${{ parameters.repoLogPath }}/restore.binlog displayName: Restore @@ -57,6 +58,7 @@ steps: - script: ${{ parameters.buildScript }} -restore + -warnAsError ${{ parameters.warnAsError }} /bl:${{ parameters.repoLogPath }}/restore2.binlog displayName: Restore solution @@ -78,7 +80,7 @@ steps: - script: ${{ parameters.buildScript }} -pack -configuration ${{ parameters.buildConfig }} - -warnAsError 1 + -warnAsError ${{ parameters.warnAsError }} /bl:${{ parameters.repoLogPath }}/pack.binlog /p:Restore=false /p:Build=false $(_OfficialBuildIdArgs) @@ -176,6 +178,7 @@ steps: # Publishing will happen in a subsequent step - script: ${{ parameters.buildScript }} -projects $(Build.SourcesDirectory)/src/Packages/Microsoft.Internal.Extensions.DotNetApiDocs.Transport/Microsoft.Internal.Extensions.DotNetApiDocs.Transport.proj + -warnAsError ${{ parameters.warnAsError }} -pack -configuration ${{ parameters.buildConfig }} /bl:${{ parameters.repoLogPath }}/transport.binlog @@ -187,11 +190,12 @@ steps: displayName: Build Azure DevOps plugin - script: ${{ parameters.buildScript }} + -restore -sign $(_SignArgs) -publish $(_PublishArgs) -configuration ${{ parameters.buildConfig }} - -warnAsError 1 + -warnAsError ${{ parameters.warnAsError }} /bl:${{ parameters.repoLogPath }}/publish.binlog - /p:Restore=false /p:Build=false + /p:Build=false $(_OfficialBuildIdArgs) displayName: Sign and publish diff --git a/eng/sdl-tsa-vars.config b/eng/sdl-tsa-vars.config new file mode 100644 index 00000000000..76961251412 --- /dev/null +++ b/eng/sdl-tsa-vars.config @@ -0,0 +1,14 @@ +-SourceToolsList @("policheck","credscan") +-ArtifactToolsList @("binskim") +-TsaInstanceURL https://devdiv.visualstudio.com/ +-TsaProjectName DEVDIV +-TsaNotificationEmail aspnetcore-build@microsoft.com +-TsaCodebaseAdmin REDMOND\aspnetcore-build +-TsaBugAreaPath "DevDiv\ASP.NET Core\Policy Violations" +-TsaIterationPath DevDiv +-TsaRepositoryName dotnetextensions +-TsaCodebaseName dotnetextensions +-TsaOnboard $True +-TsaPublish $True +-PoliCheckAdditionalRunConfigParams @("UserExclusionPath < $(Build.SourcesDirectory)/.config/PoliCheckExclusions.xml") +-CrScanAdditionalRunConfigParams @("SuppressionsPath < $(Build.SourcesDirectory)/.config/CredScanSuppressions.json") diff --git a/es-metadata.yml b/es-metadata.yml new file mode 100644 index 00000000000..9061e11812c --- /dev/null +++ b/es-metadata.yml @@ -0,0 +1,8 @@ +schemaVersion: 0.0.1 +isProduction: true +accountableOwners: + service: 4db45fa9-fb0f-43ce-b523-ad1da773dfbc +routing: + defaultAreaPath: + org: devdiv + path: DevDiv\ASP.NET Core diff --git a/global.json b/global.json index 70681bc9744..b0500c7f83e 100644 --- a/global.json +++ b/global.json @@ -1,24 +1,24 @@ { "sdk": { - "version": "9.0.107" + "version": "10.0.100-rc.1.25451.107" }, "tools": { - "dotnet": "9.0.107", + "dotnet": "10.0.100-rc.1.25451.107", "runtimes": { "dotnet": [ "8.0.0", - "9.0.0-rc.1.24431.7" + "9.0.0" ], "aspnetcore": [ "8.0.0", - "9.0.0-rc.1.24452.1" + "9.0.0" ] } }, "msbuild-sdks": { "Microsoft.Build.NoTargets": "3.7.0", "Microsoft.Build.Traversal": "3.2.0", - "Microsoft.DotNet.Arcade.Sdk": "9.0.0-beta.25325.4", - "Microsoft.DotNet.Helix.Sdk": "9.0.0-beta.25325.4" + "Microsoft.DotNet.Arcade.Sdk": "9.0.0-beta.25515.2", + "Microsoft.DotNet.Helix.Sdk": "9.0.0-beta.25515.2" } } diff --git a/src/Analyzers/.editorconfig b/src/Analyzers/.editorconfig index c46aa61b062..4f73565695b 100644 --- a/src/Analyzers/.editorconfig +++ b/src/Analyzers/.editorconfig @@ -482,11 +482,6 @@ dotnet_diagnostic.CA1507.severity = warning # Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1508 dotnet_diagnostic.CA1508.severity = warning -# Title : Avoid dead conditional code -# Category : Maintainability -# Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1508 -dotnet_diagnostic.CA1508.severity = warning - # Title : Invalid entry in code metrics rule specification file # Category : Maintainability # Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1509 diff --git a/src/Analyzers/Microsoft.Analyzers.Local/ApiLifecycle/ApiLifecycleAnalyzer.cs b/src/Analyzers/Microsoft.Analyzers.Local/ApiLifecycle/ApiLifecycleAnalyzer.cs index 76973c7fc30..6424a114757 100644 --- a/src/Analyzers/Microsoft.Analyzers.Local/ApiLifecycle/ApiLifecycleAnalyzer.cs +++ b/src/Analyzers/Microsoft.Analyzers.Local/ApiLifecycle/ApiLifecycleAnalyzer.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; using Microsoft.CodeAnalysis; diff --git a/src/Analyzers/Microsoft.Analyzers.Local/ApiLifecycle/Json/JsonObjectExtensions.cs b/src/Analyzers/Microsoft.Analyzers.Local/ApiLifecycle/Json/JsonObjectExtensions.cs index 27a2e6a5493..0fa9fb224f3 100644 --- a/src/Analyzers/Microsoft.Analyzers.Local/ApiLifecycle/Json/JsonObjectExtensions.cs +++ b/src/Analyzers/Microsoft.Analyzers.Local/ApiLifecycle/Json/JsonObjectExtensions.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using Microsoft.Extensions.LocalAnalyzers.Json; namespace Microsoft.Extensions.LocalAnalyzers.Json; diff --git a/src/Analyzers/Microsoft.Analyzers.Local/DiagDescriptors.cs b/src/Analyzers/Microsoft.Analyzers.Local/DiagDescriptors.cs index 46af17c1e0b..ae718238cca 100644 --- a/src/Analyzers/Microsoft.Analyzers.Local/DiagDescriptors.cs +++ b/src/Analyzers/Microsoft.Analyzers.Local/DiagDescriptors.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; using Microsoft.CodeAnalysis; [assembly: System.Resources.NeutralResourcesLanguage("en-us")] diff --git a/src/Generators/.editorconfig b/src/Generators/.editorconfig index c46aa61b062..4f73565695b 100644 --- a/src/Generators/.editorconfig +++ b/src/Generators/.editorconfig @@ -482,11 +482,6 @@ dotnet_diagnostic.CA1507.severity = warning # Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1508 dotnet_diagnostic.CA1508.severity = warning -# Title : Avoid dead conditional code -# Category : Maintainability -# Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1508 -dotnet_diagnostic.CA1508.severity = warning - # Title : Invalid entry in code metrics rule specification file # Category : Maintainability # Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1509 diff --git a/src/Generators/Microsoft.Gen.Logging/Emission/Emitter.Method.cs b/src/Generators/Microsoft.Gen.Logging/Emission/Emitter.Method.cs index 68549c11540..de0252657b7 100644 --- a/src/Generators/Microsoft.Gen.Logging/Emission/Emitter.Method.cs +++ b/src/Generators/Microsoft.Gen.Logging/Emission/Emitter.Method.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Text; using Microsoft.Gen.Logging.Model; using Microsoft.Gen.Shared; @@ -91,7 +90,9 @@ private void GenLogMethod(LoggingMethod lm) } else { - OutLn($"new({GetNonRandomizedHashCode(eventName)}, {eventName}),"); + var eventNameToCalcId = string.IsNullOrWhiteSpace(lm.EventName) ? lm.Name : lm.EventName!; + var calculatedEventId = GetNonRandomizedHashCode(eventNameToCalcId); + OutLn($"new({calculatedEventId}, {eventName}),"); } OutLn($"{stateName},"); diff --git a/src/Generators/Microsoft.Gen.Logging/Model/LoggingMethodParameter.cs b/src/Generators/Microsoft.Gen.Logging/Model/LoggingMethodParameter.cs index 7f90823e9a4..b14ce4dc4e5 100644 --- a/src/Generators/Microsoft.Gen.Logging/Model/LoggingMethodParameter.cs +++ b/src/Generators/Microsoft.Gen.Logging/Model/LoggingMethodParameter.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; using System.Collections.Generic; using System.Diagnostics; diff --git a/src/Generators/Microsoft.Gen.Logging/Parsing/AttributeProcessors.cs b/src/Generators/Microsoft.Gen.Logging/Parsing/AttributeProcessors.cs index f20dd59f52b..ab351819b57 100644 --- a/src/Generators/Microsoft.Gen.Logging/Parsing/AttributeProcessors.cs +++ b/src/Generators/Microsoft.Gen.Logging/Parsing/AttributeProcessors.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; using Microsoft.CodeAnalysis; namespace Microsoft.Gen.Logging.Parsing; diff --git a/src/Generators/Microsoft.Gen.Logging/Parsing/Parser.LogProperties.cs b/src/Generators/Microsoft.Gen.Logging/Parsing/Parser.LogProperties.cs index 3a8981c4918..23aebcdb00f 100644 --- a/src/Generators/Microsoft.Gen.Logging/Parsing/Parser.LogProperties.cs +++ b/src/Generators/Microsoft.Gen.Logging/Parsing/Parser.LogProperties.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; using System.Collections.Generic; using System.Linq; using System.Threading; diff --git a/src/Generators/Microsoft.Gen.Logging/Parsing/Parser.Records.cs b/src/Generators/Microsoft.Gen.Logging/Parsing/Parser.Records.cs index c5f16008de7..484361d7347 100644 --- a/src/Generators/Microsoft.Gen.Logging/Parsing/Parser.Records.cs +++ b/src/Generators/Microsoft.Gen.Logging/Parsing/Parser.Records.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; using System.Collections.Generic; using System.Threading; using Microsoft.CodeAnalysis; diff --git a/src/Generators/Microsoft.Gen.Metrics/Parser.cs b/src/Generators/Microsoft.Gen.Metrics/Parser.cs index c0c0d6481e1..680bf579f2c 100644 --- a/src/Generators/Microsoft.Gen.Metrics/Parser.cs +++ b/src/Generators/Microsoft.Gen.Metrics/Parser.cs @@ -574,7 +574,7 @@ private bool CheckMethodReturnType(IMethodSymbol methodSymbol) returnType.TypeKind != TypeKind.Error) { // Make sure return type is not from existing known type - Diag(DiagDescriptors.ErrorInvalidMethodReturnType, methodSymbol.ReturnType.GetLocation(), methodSymbol.Name); + Diag(DiagDescriptors.ErrorInvalidMethodReturnType, returnType.GetLocation(), returnType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)); return false; } diff --git a/src/Generators/Microsoft.Gen.MetricsReports/MetricsReportsHelpers.cs b/src/Generators/Microsoft.Gen.MetricsReports/MetricsReportsHelpers.cs index ee610c0c032..0354528f810 100644 --- a/src/Generators/Microsoft.Gen.MetricsReports/MetricsReportsHelpers.cs +++ b/src/Generators/Microsoft.Gen.MetricsReports/MetricsReportsHelpers.cs @@ -6,6 +6,7 @@ using Microsoft.Gen.Metrics.Model; namespace Microsoft.Gen.MetricsReports; + internal static class MetricsReportsHelpers { internal static ReportedMetricClass[] MapToCommonModel(IReadOnlyList meteringClasses, string? rootNamespace) diff --git a/src/Generators/Shared/RoslynExtensions.cs b/src/Generators/Shared/RoslynExtensions.cs index 82860a09f59..ef4f7e07911 100644 --- a/src/Generators/Shared/RoslynExtensions.cs +++ b/src/Generators/Shared/RoslynExtensions.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Collections.Immutable; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -103,33 +102,6 @@ internal static class RoslynExtensions ? throw new ArgumentException("The input type must correspond to a named type symbol.") : GetBestTypeByMetadataName(compilation, type.FullName); - public static ImmutableArray ToImmutableArray(this ReadOnlySpan span) - { -#pragma warning disable S109 // Magic numbers should not be used - switch (span.Length) - { - case 0: - return ImmutableArray.Empty; - case 1: - return ImmutableArray.Create(span[0]); - case 2: - return ImmutableArray.Create(span[0], span[1]); - case 3: - return ImmutableArray.Create(span[0], span[1], span[2]); - case 4: - return ImmutableArray.Create(span[0], span[1], span[2], span[3]); - default: - var builder = ImmutableArray.CreateBuilder(span.Length); - foreach (var item in span) - { - builder.Add(item); - } - - return builder.MoveToImmutable(); - } -#pragma warning restore S109 // Magic numbers should not be used - } - public static SimpleNameSyntax GetUnqualifiedName(this NameSyntax name) => name switch { diff --git a/src/LegacySupport/.editorconfig b/src/LegacySupport/.editorconfig index bc980461f04..757d371f53c 100644 --- a/src/LegacySupport/.editorconfig +++ b/src/LegacySupport/.editorconfig @@ -482,11 +482,6 @@ dotnet_diagnostic.CA1507.severity = warning # Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1508 dotnet_diagnostic.CA1508.severity = warning -# Title : Avoid dead conditional code -# Category : Maintainability -# Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1508 -dotnet_diagnostic.CA1508.severity = warning - # Title : Invalid entry in code metrics rule specification file # Category : Maintainability # Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1509 diff --git a/src/Libraries/.editorconfig b/src/Libraries/.editorconfig index b74b979edaf..e6feaee1f0f 100644 --- a/src/Libraries/.editorconfig +++ b/src/Libraries/.editorconfig @@ -208,7 +208,7 @@ dotnet_code_quality.CA1030.api_surface = public # Title : Do not catch general exception types # Category : Design # Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1031 -dotnet_diagnostic.CA1031.severity = warning +dotnet_diagnostic.CA1031.severity = suggestion # Title : Implement standard exception constructors # Category : Design @@ -223,7 +223,7 @@ dotnet_diagnostic.CA1033.severity = warning # Title : Nested types should not be visible # Category : Design # Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1034 -dotnet_diagnostic.CA1034.severity = warning +dotnet_diagnostic.CA1034.severity = suggestion # Title : Override methods on comparable types # Category : Design @@ -293,19 +293,19 @@ dotnet_code_quality.CA1052.api_surface = all # Title : URI-like parameters should not be strings # Category : Design # Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1054 -dotnet_diagnostic.CA1054.severity = warning +dotnet_diagnostic.CA1054.severity = suggestion dotnet_code_quality.CA1054.api_surface = public # Title : URI-like return values should not be strings # Category : Design # Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1055 -dotnet_diagnostic.CA1055.severity = warning +dotnet_diagnostic.CA1055.severity = suggestion dotnet_code_quality.CA1055.api_surface = public # Title : URI-like properties should not be strings # Category : Design # Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1056 -dotnet_diagnostic.CA1056.severity = warning +dotnet_diagnostic.CA1056.severity = suggestion dotnet_code_quality.CA1056.api_surface = public # Title : Types should not extend certain base types @@ -497,12 +497,7 @@ dotnet_diagnostic.CA1507.severity = warning # Title : Avoid dead conditional code # Category : Maintainability # Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1508 -dotnet_diagnostic.CA1508.severity = warning - -# Title : Avoid dead conditional code -# Category : Maintainability -# Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1508 -dotnet_diagnostic.CA1508.severity = warning +dotnet_diagnostic.CA1508.severity = suggestion # Title : Invalid entry in code metrics rule specification file # Category : Maintainability @@ -578,7 +573,7 @@ dotnet_diagnostic.CA1715.severity = none # Title : Identifiers should not match keywords # Category : Naming # Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1716 -dotnet_diagnostic.CA1716.severity = warning +dotnet_diagnostic.CA1716.severity = suggestion dotnet_code_quality.CA1716.api_surface = all dotnet_code_quality.CA1716.analyzed_symbol_kinds = all @@ -1137,7 +1132,7 @@ dotnet_diagnostic.CA2226.severity = none # Title : Collection properties should be read only # Category : Usage # Help Link: https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2227 -dotnet_diagnostic.CA2227.severity = warning +dotnet_diagnostic.CA2227.severity = suggestion # Title : Implement serialization constructors # Category : Usage @@ -1783,7 +1778,7 @@ dotnet_diagnostic.EA0001.severity = warning # Title : Use 'System.TimeProvider' to make the code easier to test # Category : Reliability # Help Link: https://aka.ms/dotnet-extensions-warnings/EA0002 -dotnet_diagnostic.EA0002.severity = warning +dotnet_diagnostic.EA0002.severity = suggestion # Title : Use the character-based overloads of 'String.StartsWith' or 'String.EndsWith' # Category : Performance @@ -1828,7 +1823,7 @@ dotnet_diagnostic.EA0010.severity = warning # Title : Consider removing unnecessary conditional access operator (?) # Category : Performance # Help Link: https://aka.ms/dotnet-extensions-warnings/EA0011 -dotnet_diagnostic.EA0011.severity = warning +dotnet_diagnostic.EA0011.severity = suggestion # Title : Consider removing unnecessary null coalescing assignment (??=) # Category : Performance @@ -2936,12 +2931,12 @@ dotnet_diagnostic.S101.severity = none # Title : Lines should not be too long # Category : Major Code Smell # Help Link: https://rules.sonarsource.com/csharp/RSPEC-103 -dotnet_diagnostic.S103.severity = warning +dotnet_diagnostic.S103.severity = suggestion # Title : Files should not have too many lines of code # Category : Major Code Smell # Help Link: https://rules.sonarsource.com/csharp/RSPEC-104 -dotnet_diagnostic.S104.severity = warning +dotnet_diagnostic.S104.severity = none # Title : Finalizers should not throw exceptions # Category : Blocker Bug @@ -2968,12 +2963,12 @@ dotnet_diagnostic.S1066.severity = none # Title : Expressions should not be too complex # Category : Critical Code Smell # Help Link: https://rules.sonarsource.com/csharp/RSPEC-1067 -dotnet_diagnostic.S1067.severity = warning +dotnet_diagnostic.S1067.severity = suggestion # Title : Methods should not have too many parameters # Category : Major Code Smell # Help Link: https://rules.sonarsource.com/csharp/RSPEC-107 -dotnet_diagnostic.S107.severity = warning +dotnet_diagnostic.S107.severity = suggestion # Title : URIs should not be hardcoded # Category : Minor Code Smell @@ -2988,7 +2983,7 @@ dotnet_diagnostic.S108.severity = warning # Title : Magic numbers should not be used # Category : Major Code Smell # Help Link: https://rules.sonarsource.com/csharp/RSPEC-109 -dotnet_diagnostic.S109.severity = warning +dotnet_diagnostic.S109.severity = suggestion # Title : Inheritance tree of classes should not be too deep # Category : Major Code Smell @@ -3039,7 +3034,7 @@ dotnet_diagnostic.S112.severity = none # Title : Assignments should not be made from within sub-expressions # Category : Major Code Smell # Help Link: https://rules.sonarsource.com/csharp/RSPEC-1121 -dotnet_diagnostic.S1121.severity = warning +dotnet_diagnostic.S1121.severity = suggestion # Title : "Obsolete" attributes should include explanations # Category : Major Code Smell @@ -3288,12 +3283,12 @@ dotnet_diagnostic.S1656.severity = warning # Title : Multiple variables should not be declared on the same line # Category : Minor Code Smell # Help Link: https://rules.sonarsource.com/csharp/RSPEC-1659 -dotnet_diagnostic.S1659.severity = warning +dotnet_diagnostic.S1659.severity = suggestion # Title : An abstract class should have both abstract and concrete methods # Category : Minor Code Smell # Help Link: https://rules.sonarsource.com/csharp/RSPEC-1694 -dotnet_diagnostic.S1694.severity = warning +dotnet_diagnostic.S1694.severity = suggestion # Title : NullReferenceException should not be caught # Category : Major Code Smell @@ -3380,7 +3375,7 @@ dotnet_diagnostic.S1944.severity = warning # Title : "for" loop increment clauses should modify the loops' counters # Category : Critical Code Smell # Help Link: https://rules.sonarsource.com/csharp/RSPEC-1994 -dotnet_diagnostic.S1994.severity = warning +dotnet_diagnostic.S1994.severity = suggestion # Title : Hashes should include an unpredictable salt # Category : Critical Vulnerability @@ -3617,7 +3612,7 @@ dotnet_diagnostic.S2330.severity = warning # Title : Redundant modifiers should not be used # Category : Minor Code Smell # Help Link: https://rules.sonarsource.com/csharp/RSPEC-2333 -dotnet_diagnostic.S2333.severity = warning +dotnet_diagnostic.S2333.severity = suggestion # Title : Public constant members should not be used # Category : Critical Code Smell @@ -4021,7 +4016,7 @@ dotnet_diagnostic.S3251.severity = warning # Title : Constructor and destructor declarations should not be redundant # Category : Minor Code Smell # Help Link: https://rules.sonarsource.com/csharp/RSPEC-3253 -dotnet_diagnostic.S3253.severity = warning +dotnet_diagnostic.S3253.severity = suggestion # Title : Default parameter values should not be passed as arguments # Category : Minor Code Smell @@ -4104,7 +4099,7 @@ dotnet_diagnostic.S3353.severity = warning # Title : Ternary operators should not be nested # Category : Major Code Smell # Help Link: https://rules.sonarsource.com/csharp/RSPEC-3358 -dotnet_diagnostic.S3358.severity = warning +dotnet_diagnostic.S3358.severity = suggestion # Title : Date and time should not be used as a type for primary keys # Category : Minor Bug @@ -4270,7 +4265,7 @@ dotnet_diagnostic.S3603.severity = warning # Title : Member initializer values should not be redundant # Category : Minor Code Smell # Help Link: https://rules.sonarsource.com/csharp/RSPEC-3604 -dotnet_diagnostic.S3604.severity = warning +dotnet_diagnostic.S3604.severity = suggestion # Title : Nullable type comparison should not be redundant # Category : Major Bug @@ -4538,12 +4533,12 @@ dotnet_diagnostic.S3994.severity = none # Title : URI return values should not be strings # Category : Major Code Smell # Help Link: https://rules.sonarsource.com/csharp/RSPEC-3995 -dotnet_diagnostic.S3995.severity = warning +dotnet_diagnostic.S3995.severity = suggestion # Title : URI properties should not be strings # Category : Major Code Smell # Help Link: https://rules.sonarsource.com/csharp/RSPEC-3996 -dotnet_diagnostic.S3996.severity = warning +dotnet_diagnostic.S3996.severity = suggestion # Title : String URI overloads should call "System.Uri" overloads # Category : Major Code Smell @@ -5201,7 +5196,7 @@ dotnet_diagnostic.S881.severity = suggestion # Title : "goto" statement should not be used # Category : Major Code Smell # Help Link: https://rules.sonarsource.com/csharp/RSPEC-907 -dotnet_diagnostic.S907.severity = warning +dotnet_diagnostic.S907.severity = suggestion # Title : Parameter names should match base declaration and other partial definitions # Category : Critical Code Smell @@ -5648,12 +5643,12 @@ dotnet_diagnostic.SA1201.severity = none # Title : Elements should be ordered by access # Category : StyleCop.CSharp.OrderingRules # Help Link: https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1202.md -dotnet_diagnostic.SA1202.severity = warning +dotnet_diagnostic.SA1202.severity = suggestion # Title : Constants should appear before fields # Category : StyleCop.CSharp.OrderingRules # Help Link: https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1203.md -dotnet_diagnostic.SA1203.severity = warning +dotnet_diagnostic.SA1203.severity = suggestion # Title : Static elements should appear before instance elements # Category : StyleCop.CSharp.OrderingRules @@ -5812,7 +5807,7 @@ dotnet_diagnostic.SA1314.severity = none # Title : Tuple element names should use correct casing # Category : StyleCop.CSharp.NamingRules # Help Link: https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1316.md -dotnet_diagnostic.SA1316.severity = warning +dotnet_diagnostic.SA1316.severity = suggestion # Title : Access modifier should be declared # Category : StyleCop.CSharp.MaintainabilityRules @@ -5894,7 +5889,7 @@ dotnet_diagnostic.SA1414.severity = warning # Title : Braces for multi-line statements should not share line # Category : StyleCop.CSharp.LayoutRules # Help Link: https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1500.md -dotnet_diagnostic.SA1500.severity = warning +dotnet_diagnostic.SA1500.severity = suggestion # rule does not work well with field-based property initializers # Title : Statement should not be on a single line # Category : StyleCop.CSharp.LayoutRules @@ -5962,7 +5957,7 @@ dotnet_diagnostic.SA1512.severity = none # Title : Closing brace should be followed by blank line # Category : StyleCop.CSharp.LayoutRules # Help Link: https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1513.md -dotnet_diagnostic.SA1513.severity = warning +dotnet_diagnostic.SA1513.severity = suggestion # rule does not work well with field-based property initializers # Title : Element documentation header should be preceded by blank line # Category : StyleCop.CSharp.LayoutRules @@ -6292,7 +6287,7 @@ dotnet_diagnostic.VSTHRD002.severity = warning # Title : Avoid awaiting foreign Tasks # Category : Usage # Help Link: https://github.com/Microsoft/vs-threading/blob/main/doc/analyzers/VSTHRD003.md -dotnet_diagnostic.VSTHRD003.severity = warning +dotnet_diagnostic.VSTHRD003.severity = suggestion # Title : Await SwitchToMainThreadAsync # Category : Usage @@ -6402,3 +6397,7 @@ dotnet_diagnostic.VSTHRD114.severity = error # Help Link: https://github.com/Microsoft/vs-threading/blob/main/doc/analyzers/VSTHRD200.md dotnet_diagnostic.VSTHRD200.severity = warning +# Title : Async method lacks 'await' operators and will run synchronously +# Category : Reliability +# Help Link: https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/compiler-messages/cs1998 +dotnet_diagnostic.CS1998.severity = suggestion diff --git a/src/Libraries/Microsoft.AspNetCore.AsyncState/TypeWrapper.cs b/src/Libraries/Microsoft.AspNetCore.AsyncState/TypeWrapper.cs index 83bd97fb515..876ace53876 100644 --- a/src/Libraries/Microsoft.AspNetCore.AsyncState/TypeWrapper.cs +++ b/src/Libraries/Microsoft.AspNetCore.AsyncState/TypeWrapper.cs @@ -14,8 +14,6 @@ namespace Microsoft.AspNetCore.AsyncState; /// Note that is not public, so nobody else can use it. /// /// The type of the value to store into . -#pragma warning disable S1694 // Convert this 'abstract' class to a concrete type with protected constructor. internal abstract class TypeWrapper -#pragma warning restore S1694 // Convert this 'abstract' class to a concrete type with protected constructor. { } diff --git a/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Buffering/PerRequestLogBufferingOptions.cs b/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Buffering/PerRequestLogBufferingOptions.cs index f675466ebe5..5e0cc99f779 100644 --- a/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Buffering/PerRequestLogBufferingOptions.cs +++ b/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Buffering/PerRequestLogBufferingOptions.cs @@ -58,7 +58,6 @@ public class PerRequestLogBufferingOptions [Range(MinimumPerRequestBufferSizeInBytes, MaximumPerRequestBufferSizeInBytes)] public int MaxPerRequestBufferSizeInBytes { get; set; } = DefaultPerRequestBufferSizeInBytes; -#pragma warning disable CA2227 // Collection properties should be read only - setter is necessary for options pattern /// /// Gets or sets the collection of used for filtering log messages for the purpose of further buffering. /// @@ -71,6 +70,5 @@ public class PerRequestLogBufferingOptions /// If a log entry size is greater than , it will not be buffered and will be emitted normally. /// public IList Rules { get; set; } = []; -#pragma warning restore CA2227 } #endif diff --git a/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Logging/HttpLoggingRedactionInterceptor.cs b/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Logging/HttpLoggingRedactionInterceptor.cs index 31a253f68db..f69b711a206 100644 --- a/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Logging/HttpLoggingRedactionInterceptor.cs +++ b/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Logging/HttpLoggingRedactionInterceptor.cs @@ -160,7 +160,6 @@ public ValueTask OnResponseAsync(HttpLoggingInterceptorContext logContext) { foreach (var enricher in _enrichers) { -#pragma warning disable CA1031 // Do not catch general exception types try { enricher.Enrich(loggerMessageState, context); @@ -169,7 +168,6 @@ public ValueTask OnResponseAsync(HttpLoggingInterceptorContext logContext) { _logger.EnricherFailed(ex, enricher.GetType().Name); } -#pragma warning restore CA1031 // Do not catch general exception types } foreach (var pair in loggerMessageState) diff --git a/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Logging/LoggingRedactionOptions.cs b/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Logging/LoggingRedactionOptions.cs index d2cb34ac547..0310d59a0d7 100644 --- a/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Logging/LoggingRedactionOptions.cs +++ b/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Logging/LoggingRedactionOptions.cs @@ -55,9 +55,7 @@ public class LoggingRedactionOptions /// If you don't want a parameter to be redacted, mark it as . /// [Required] -#pragma warning disable CA2227 // Collection properties should be read only public IDictionary RouteParameterDataClasses { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); -#pragma warning restore CA2227 // Collection properties should be read only /// /// Gets or sets a map between request headers to be logged and their data classification. @@ -66,9 +64,7 @@ public class LoggingRedactionOptions /// The default value is an empty dictionary, which means that no request header is logged by default. /// [Required] -#pragma warning disable CA2227 // Collection properties should be read only public IDictionary RequestHeadersDataClasses { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); -#pragma warning restore CA2227 // Collection properties should be read only /// /// Gets or sets a map between response headers to be logged and their data classification. @@ -77,9 +73,7 @@ public class LoggingRedactionOptions /// The default value is an empty dictionary, which means that no response header is logged by default. /// [Required] -#pragma warning disable CA2227 // Collection properties should be read only public IDictionary ResponseHeadersDataClasses { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); -#pragma warning restore CA2227 // Collection properties should be read only /// /// Gets or sets the set of HTTP paths that should be excluded from logging. @@ -97,9 +91,7 @@ public class LoggingRedactionOptions /// - "/probe/ready". /// [Required] -#pragma warning disable CA2227 // Collection properties should be read only public ISet ExcludePathStartsWith { get; set; } = new HashSet(StringComparer.OrdinalIgnoreCase); -#pragma warning restore CA2227 // Collection properties should be read only /// /// Gets or sets a value indicating whether to report unmatched routes. diff --git a/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Logging/RequestHeadersLogEnricherOptions.cs b/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Logging/RequestHeadersLogEnricherOptions.cs index 81483224810..e18822e7ad5 100644 --- a/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Logging/RequestHeadersLogEnricherOptions.cs +++ b/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Logging/RequestHeadersLogEnricherOptions.cs @@ -23,7 +23,5 @@ public class RequestHeadersLogEnricherOptions /// [Required] [Experimental(diagnosticId: DiagnosticIds.Experiments.Telemetry, UrlFormat = DiagnosticIds.UrlFormat)] -#pragma warning disable CA2227 // Collection properties should be read only public IDictionary HeadersDataClasses { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); -#pragma warning restore CA2227 // Collection properties should be read only } diff --git a/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Microsoft.AspNetCore.Diagnostics.Middleware.csproj b/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Microsoft.AspNetCore.Diagnostics.Middleware.csproj index 15e0e9d9225..2d6e518f1b5 100644 --- a/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Microsoft.AspNetCore.Diagnostics.Middleware.csproj +++ b/src/Libraries/Microsoft.AspNetCore.Diagnostics.Middleware/Microsoft.AspNetCore.Diagnostics.Middleware.csproj @@ -11,6 +11,8 @@ $(NetCoreTargetFrameworks) true + + $(InterceptorsNamespaces);Microsoft.Extensions.Configuration.Binder.SourceGeneration true true false @@ -39,7 +41,6 @@ - diff --git a/src/Libraries/Microsoft.AspNetCore.HeaderParsing/HeaderParser.cs b/src/Libraries/Microsoft.AspNetCore.HeaderParsing/HeaderParser.cs index 34e78e3b37c..22297373047 100644 --- a/src/Libraries/Microsoft.AspNetCore.HeaderParsing/HeaderParser.cs +++ b/src/Libraries/Microsoft.AspNetCore.HeaderParsing/HeaderParser.cs @@ -10,7 +10,6 @@ namespace Microsoft.AspNetCore.HeaderParsing; /// Parses raw header value to a header type. /// /// The resulting strong type representing the header's value. -[SuppressMessage("Minor Code Smell", "S1694:An abstract class should have both abstract and concrete methods", Justification = "Want abstract class for extensibility and perf")] public abstract class HeaderParser where T : notnull { @@ -21,6 +20,5 @@ public abstract class HeaderParser /// A resulting value. /// An error if parsing failed. /// Parsing result. - [SuppressMessage("Naming", "CA1716:Identifiers should not match keywords", Justification = "There is no such keyword in C#.")] public abstract bool TryParse(StringValues values, [NotNullWhen(true)] out T? result, [NotNullWhen(false)] out string? error); } diff --git a/src/Libraries/Microsoft.AspNetCore.HeaderParsing/HeaderParsingFeature.cs b/src/Libraries/Microsoft.AspNetCore.HeaderParsing/HeaderParsingFeature.cs index b3974603efb..ca5e38f3651 100644 --- a/src/Libraries/Microsoft.AspNetCore.HeaderParsing/HeaderParsingFeature.cs +++ b/src/Libraries/Microsoft.AspNetCore.HeaderParsing/HeaderParsingFeature.cs @@ -15,7 +15,6 @@ namespace Microsoft.AspNetCore.HeaderParsing; /// public sealed partial class HeaderParsingFeature { - private readonly IHeaderRegistry _registry; private readonly ILogger _logger; private readonly HeaderParsingMetrics _metrics; @@ -26,11 +25,10 @@ public sealed partial class HeaderParsingFeature internal HttpContext? Context { get; set; } - internal HeaderParsingFeature(IHeaderRegistry registry, ILogger logger, HeaderParsingMetrics metrics) + internal HeaderParsingFeature(ILogger logger, HeaderParsingMetrics metrics) { _logger = logger; _metrics = metrics; - _registry = registry; } /// @@ -91,12 +89,11 @@ internal sealed class PoolHelper : IDisposable public PoolHelper( ObjectPool pool, - IHeaderRegistry registry, ILogger logger, HeaderParsingMetrics metrics) { _pool = pool; - Feature = new HeaderParsingFeature(registry, logger, metrics); + Feature = new HeaderParsingFeature(logger, metrics); } public void Dispose() @@ -114,7 +111,6 @@ private enum BoxState NotFound = ParsingResult.NotFound, } - [SuppressMessage("Minor Code Smell", "S1694:An abstract class should have both abstract and concrete methods", Justification = "Analyzer issue")] private abstract class Box { public abstract void Reset(); diff --git a/src/Libraries/Microsoft.AspNetCore.HeaderParsing/HeaderParsingOptions.cs b/src/Libraries/Microsoft.AspNetCore.HeaderParsing/HeaderParsingOptions.cs index f8aa8680fb5..2c063d1aa8b 100644 --- a/src/Libraries/Microsoft.AspNetCore.HeaderParsing/HeaderParsingOptions.cs +++ b/src/Libraries/Microsoft.AspNetCore.HeaderParsing/HeaderParsingOptions.cs @@ -6,8 +6,6 @@ using System.ComponentModel.DataAnnotations; using Microsoft.Extensions.Primitives; -#pragma warning disable CA2227 // Collection properties should be read only - namespace Microsoft.AspNetCore.HeaderParsing; /// diff --git a/src/Libraries/Microsoft.AspNetCore.HeaderParsing/Microsoft.AspNetCore.HeaderParsing.csproj b/src/Libraries/Microsoft.AspNetCore.HeaderParsing/Microsoft.AspNetCore.HeaderParsing.csproj index adbae73bd9e..32020fa29f9 100644 --- a/src/Libraries/Microsoft.AspNetCore.HeaderParsing/Microsoft.AspNetCore.HeaderParsing.csproj +++ b/src/Libraries/Microsoft.AspNetCore.HeaderParsing/Microsoft.AspNetCore.HeaderParsing.csproj @@ -10,6 +10,8 @@ $(NetCoreTargetFrameworks) true + + $(InterceptorsNamespaces);Microsoft.Extensions.Configuration.Binder.SourceGeneration true true true @@ -30,10 +32,6 @@ - - - - diff --git a/src/Libraries/Microsoft.AspNetCore.Testing/FakeSslCertificateFactory.cs b/src/Libraries/Microsoft.AspNetCore.Testing/FakeSslCertificateFactory.cs index 1a3252df00a..136b6828537 100644 --- a/src/Libraries/Microsoft.AspNetCore.Testing/FakeSslCertificateFactory.cs +++ b/src/Libraries/Microsoft.AspNetCore.Testing/FakeSslCertificateFactory.cs @@ -19,7 +19,6 @@ internal static class FakeSslCertificateFactory /// Creates a self-signed instance for testing. /// /// An instance for testing. - [SuppressMessage("Reliability", "EA0002:Use System.TimeProvider when dealing with time in your code.", Justification = "declarations")] public static X509Certificate2 CreateSslCertificate() { var request = new CertificateRequest( diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/AITool.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/AITool.cs deleted file mode 100644 index ab9f010ae57..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/AITool.cs +++ /dev/null @@ -1,55 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Collections.Generic; -using System.Diagnostics; -using System.Text; -using Microsoft.Shared.Collections; - -namespace Microsoft.Extensions.AI; - -#pragma warning disable S1694 // An abstract class should have both abstract and concrete methods - -/// Represents a tool that can be specified to an AI service. -[DebuggerDisplay("{DebuggerDisplay,nq}")] -public abstract class AITool -{ - /// Initializes a new instance of the class. - protected AITool() - { - } - - /// Gets the name of the tool. - public virtual string Name => GetType().Name; - - /// Gets a description of the tool, suitable for use in describing the purpose to a model. - public virtual string Description => string.Empty; - - /// Gets any additional properties associated with the tool. - public virtual IReadOnlyDictionary AdditionalProperties => EmptyReadOnlyDictionary.Instance; - - /// - public override string ToString() => Name; - - /// Gets the string to display in the debugger for this instance. - [DebuggerBrowsable(DebuggerBrowsableState.Never)] - private string DebuggerDisplay - { - get - { - StringBuilder sb = new(Name); - - if (Description is string description && !string.IsNullOrEmpty(description)) - { - _ = sb.Append(" (").Append(description).Append(')'); - } - - foreach (var entry in AdditionalProperties) - { - _ = sb.Append(", ").Append(entry.Key).Append(" = ").Append(entry.Value); - } - - return sb.ToString(); - } - } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary.cs index dab50ff11ee..9bd7d266a6a 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary.cs @@ -1,10 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable 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; diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary{TValue}.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary{TValue}.cs index 14125e95b76..6b40a6c0d35 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary{TValue}.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/AdditionalPropertiesDictionary{TValue}.cs @@ -10,9 +10,6 @@ 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 @@ -255,7 +252,9 @@ private sealed class DebugView(AdditionalPropertiesDictionary properties private readonly AdditionalPropertiesDictionary _properties = Throw.IfNull(properties); [DebuggerBrowsable(DebuggerBrowsableState.RootHidden)] +#pragma warning disable S2365 // Properties should not make collection or array copies public AdditionalProperty[] Items => (from p in _properties select new AdditionalProperty(p.Key, p.Value)).ToArray(); +#pragma warning restore S2365 [DebuggerDisplay("{Value}", Name = "[{Key}]")] public readonly struct AdditionalProperty(string key, TValue value) 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..e8c2ea1e971 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md @@ -0,0 +1,195 @@ +# Release History + +## 9.10.2 + +- Updated `AIFunctionFactory` to respect `[DisplayName(...)]` on functions as a way to override the function name. +- Updated `AIFunctionFactory` to respect `[DefaultValue(...)]` on function parameters as a way to specify default values. +- Added `CodeInterpreterToolCallContent`/`CodeInterpreterToolResultContent` for representing code interpreter tool calls and results. +- Added `Name`, `MediaType`, and `HasTopLevelMediaType` to `HostedFileContent`. +- Fixed the serialization/deserialization of variables typed as `UserInputRequestContent`/`UserInputResponseContent`. + +## 9.10.1 + +- Updated `HostedMcpServerTool` to allow for non-`Uri` server addresses, in order to enable built-in names. +- Updated `HostedMcpServerTool` to replace the header collection with an `AuthorizationToken` property. +- Fixed `ToChatResponse{Async}` to not discard `TextReasoningContent.ProtectedData` when coalescing messages. +- Fixed `AIFunctionFactory.Create` to special-case return types of `AIContent` and `IEnumerable` to not automatically JSON serialize them. + +## 9.10.0 + +- Added protected copy constructors to options types (e.g. `ChatOptions`). +- Added `[Experimental]` support for background responses, such that non-streaming responses are allowed to be pollable and responses / response updates can be tagged with continuation tokens to support later resumption. +- Updated `AIFunctionFactory.Create` to produce better default names for lambdas and local functions. +- Fixed `AIJsonUtilities.DefaultOptions` to handle the built-in `[Experimental]` `AIContent` types, like `FunctionApprovalRequestContent`. +- Fixed `ToChatResponse{Async}` to factor `ChatResponseUpdate.AuthorName` into message boundary detection. +- Fixed `ToChatResponse{Async}` to not overwrite `ChatMessage/ChatResponse.CreatedAt` with older timestamps during coalescing. +- Fixed `EmbeddingGeneratorOptions`/`SpeechToTextOptions` `Clone` methods to correctly copy all properties. + +## 9.9.1 + +- Added new `ChatResponseFormat.ForJsonSchema` overloads that export a JSON schema from a .NET type. +- Added new `AITool.GetService` virtual method. +- Updated `TextReasoningContent` to include `ProtectedData` for representing encrypted/redacted content. +- Fixed `MinLength`/`MaxLength`/`Length` attribute mapping in nullable string properties during schema export. + +## 9.9.0 + +- Added non-invocable `AIFunctionDeclaration` (base class for `AIFunction`), `AIFunctionFactory.CreateDeclaration`, and `AIFunction.AsDeclarationOnly`. +- Added `[Experimental]` support for user approval of function invocations via `ApprovalRequiredAIFunction`, `FunctionApprovalRequestContent`, and friends. +- Added `[Experimental]` support for MCP server-hosted tools via `HostedMcpServerTool`, `HostedMcpServerToolApprovalMode`, and friends. +- Updated `AIContent` coalescing logic used by `ToChatResponse`/`ToChatResponseUpdate` to factor in `ChatMessage.Role`. +- Moved `IChatReducer` into `Microsoft.Extensions.AI.Abstractions` from `Microsoft.Extensions.AI`. + +## 9.8.0 + +- Added `AIAnnotation` and related types to represent citations and other annotations in chat messages. +- Added `ChatMessage.CreatedAt` so that chat messages can carry their timestamp. +- Added a `[Description(...)]` attribute to `DataContent.Uri` to clarify its purpose when used in schemas. +- Added `DataContent.Name` property to associate a name with the binary data, like a filename. +- Added `HostedFileContent` for representing files hosted by the service. +- Added `HostedVectorStoreContent` for representing vector stores hosted by the service. +- Added `HostedFileSearchTool` to represent server-side file search tools. +- Added `HostedCodeInterpreterTool.Inputs` to supply context about what state is available to the code interpreter tool. +- Added [Experimental] `IImageGenerator` and supporting types. +- Improved handling of function parameter data annotation attributes in `AIJsonUtilities.CreateJsonSchema`. +- Fixed schema generation to include an items keyword for arrays of objects in `AIJsonUtilities.CreateJsonSchema`. + +## 9.7.1 + +- Fixed schema generation for nullable function parameters in `AIJsonUtilities.CreateJsonSchema`. +- Added a flag for `AIFunctionFactory` to control whether return schemas are generated. +- Added `DelegatingAIFunction` to simplify creating `AIFunction`s that call other `AIFunction`s. +- Updated `AIFunctionFactory` to tolerate JSON string function parameters. +- Fixed schema generation for nullable value type parameters. + +## 9.7.0 + +- Added `ChatOptions.Instructions` property for configuring system instructions separate from chat messages. +- Added `Usage` property to `SpeechToTextResponse` to provide details about the token usage. +- Augmented `AIJsonUtilities.CreateJsonSchema` with support for data annotations. + +## 9.6.0 + +- Added `AIFunction.ReturnJsonSchema` to represent the JSON schema of the return value of a function. +- Removed title and description keywords from root-level schemas in `AIFunctionFactory`. + +## 9.5.0 + +- Moved `AIFunctionFactory` down from `Microsoft.Extensions.AI` to `Microsoft.Extensions.AI.Abstractions`. +- Added `BinaryEmbedding` type for representing bit embeddings. +- Added `TextReasoningContent` to represent reasoning content in chat messages. +- Added `ChatOptions.AllowMultipleToolCalls` for configuring parallel tool calling. +- Added a public constructor to the base `AIContent`. +- Added a missing `[DebuggerDisplay]` attribute on `AIFunctionArguments`. +- Added `ChatOptions.RawRepresentationFactory` to facilitate passing raw options to the underlying service. +- Added an `AIJsonSchemaTransformOptions` property inside `AIJsonSchemaCreateOptions`. +- Added `DataContent.Base64Data` property for easier and more efficient handling of base64-encoded data. +- Added JSON schema transformation functionality to `AIJsonUtilities`. +- Fixed `AIJsonUtilities.CreateJsonSchema` to handle `JsonSerializerOptions` that do not have a `TypeInfoResolver` configured. +- Fixed `AIFunctionFactory` handling of default struct arguments. +- Fixed schema generation to ensure the type keyword is included when generating schemas for nullable enums. +- Renamed the `GenerateXx` extension methods on `IEmbeddingGenerator<>`. +- Renamed `ChatThreadId` to `ConversationId` across the libraries. +- Replaced `Type targetType` parameter in `AIFunctionFactory.Create` with a delegate. +- Remove `[Obsolete]` members from previews. + +## 9.4.4-preview.1.25259.16 + +- Added `AIJsonUtilities.TransformSchema` and supporting types. +- Added `BinaryEmbedding` for bit embeddings. +- Added `ChatOptions.RawRepresentationFactory` to make it easier to pass options to the underlying service. +- Added `Base64Data` property to `DataContent`. +- Moved `AIFunctionFactory` to `Microsoft.Extensions.AI.Abstractions`. +- Fixed `AIFunctionFactory` handling of default struct arguments. + +## 9.4.3-preview.1.25230.7 + +- Renamed `ChatThreadId` to `ConversationId` on `ChatResponse`, `ChatResponseUpdate`, and `ChatOptions`. +- Renamed `EmbeddingGeneratorExtensions` method `GenerateEmbeddingAsync` to `GenerateAsync` and `GenerateEmbeddingVectorAsync` to `GenerateVectorAsync`. +- Made `AIContent`'s constructor `public` instead of `protected`. +- Fixed `AIJsonUtilities.CreateJsonSchema` to tolerate `JsonSerializerOptions` instances that don't have a `TypeInfoResolver` already configured. + +## 9.4.0-preview.1.25207.5 + +- Added `ErrorContent` and `TextReasoningContent`. +- Added `MessageId` to `ChatMessage` and `ChatResponseUpdate`. +- Added `AIFunctionArguments`, changing `AIFunction.InvokeAsync` to accept one and to return a `ValueTask`. +- Updated `AIJsonUtilities`'s schema generation to not use `default` when `RequireAllProperties` is set to `true`. +- Added [Experimental] `ISpeechToTextClient` and supporting types. +- Fixed several issues related to Native AOT support. + +## 9.3.0-preview.1.25161.3 + +- Changed `IChatClient.GetResponseAsync` and `IChatClient.GetStreamingResponseAsync` to accept an `IEnumerable` rather than an `IList`. It is no longer mutated by implementations. +- Removed `ChatResponse.Choice` and `ChatResponseUpdate.ChoiceIndex`. +- Replaced `ChatResponse.Message` with `ChatResponse.Messages`. Responses now carry with them all messages generated as part of the operation, rather than all but the last being added to the history and the last returned. +- Added `GetRequiredService` extension method for `IChatClient`/`IEmbeddingGenerator`. +- Added non-generic `IEmbeddingGenerator` interface, which is inherited by `IEmbeddingGenerator`. The `GetService` method moves down to the non-generic interface, and the `GetService`/`GetRequiredService` extension methods are now in terms of the non-generic. +- `AIJsonUtilities.CreateFunctionJsonSchema` now special-cases `CancellationToken` to not include it in the schema. +- Improved the debugger displays for `ChatMessage` and the `AIContent` types. +- Added a static `AIJsonUtilities.HashDataToString` method. +- Split `DataContent`, which handled both in-memory data and URIs to remote data, into `DataContent` (for the former) and `UriContent` (for the latter). +- Renamed `DataContent.MediaTypeStartsWith` to `DataContent.HasTopLevelMediaType`, and changed semantics accordingly. + +## 9.3.0-preview.1.25114.11 + +- Renamed `IChatClient.Complete{Streaming}Async` to `IChatClient.Get{Streaming}ResponseAsync`. This is to avoid confusion with "Complete" being about stopping an operation, as well as to avoid tying the methods to a particular implementation detail of how responses are generated. Along with this, renamed `ChatCompletion` to `ChatResponse`, `StreamingChatCompletionUpdate` to `ChatResponseUpdate`, `CompletionId` to `ResponseId`, `ToStreamingChatCompletionUpdates` to `ToChatResponseUpdates`, and `ToChatCompletion{Async}` to `ToChatResponse{Async}`. +- Removed `IChatClient.Metadata` and `IEmbeddingGenerator.Metadata`. The `GetService` method may be used to retrieve `ChatClientMetadata` and `EmbeddingGeneratorMetadata`, respectively. +- Added overloads of `Get{Streaming}ResponseAsync` that accept a single `ChatMessage` (in addition to the other overloads that accept a `List` or a `string`). +- Added `ChatThreadId` properties to `ChatOptions`, `ChatResponse`, and `ChatResponseUpdate`. `IChatClient` can now be used in both stateful and stateless modes of operation, such as with agents that maintain server-side chat history. +- Made `ChatOptions.ToolMode` nullable and added a `None` option. +- Changed `UsageDetails`'s properties from `int?` to `long?`. +- Removed `DataContent.ContainsData`; `DataContent.Data.HasValue` may be used instead. +- Removed `ImageContent` and `AudioContent`; the base `DataContent` should now be used instead, with a new `DataContent.MediaTypeStartsWith` helper for routing based on media type. +- Removed setters on `FunctionCallContent` and `FunctionResultContent` properties where the value is supplied to the constructor. +- Removed `FunctionResultContent.Name`. +- Augmented the base `AITool` with `Name`, `Description`, and `AdditionalProperties` virtual properties. +- Added a `CodeInterpreterTool` for use with services that support server-side code execution. +- Changed `AIFunction`'s schema representation to be for the whole function rather than per parameter, and exposed corresponding methods on `AIJsonUtilities`, e.g. `CreateFunctionJsonSchema`. +- Removed `AIFunctionParameterMetadata` and `AIFunctionReturnParameterMetadata` classes and corresponding properties on `AIFunction` and `AIFunctionFactoryCreateOptions`, replacing them with a `MethodInfo?`. All relevant metadata, such as the JSON schema for the function, are moved to properties directly on `AIFunction`. +- Renamed `AIFunctionFactoryCreateOptions` to `AIFunctionFactoryOptions` and made all its properties nullable. +- Changed `AIJsonUtilities.DefaultOptions` to use relaxed JSON escaping. +- Made `IEmbeddingGenerator` contravariant on `TInput`. + +## 9.1.0-preview.1.25064.3 + +- Added `AdditionalPropertiesDictionary` and changed `UsageDetails.AdditionalProperties` to be named `AdditionalCounts` and to be of type `AdditionalPropertiesDictionary`. +- Updated `FunctionCallingChatClient` to sum all `UsageDetails` token counts from all intermediate messages. +- Fixed JSON schema generation for floating-point types. +- Added `AddAIContentType` for enabling custom `AIContent`-derived types to participate in polymorphic serialization. + +## 9.0.1-preview.1.24570.5 + +- Changed `IChatClient`/`IEmbeddingGenerator`.`GetService` to be non-generic. +- Added `ToChatCompletion` / `ToChatCompletionUpdate` extension methods for `IEnumerable` / `IAsyncEnumerable`, respectively. +- Added `ToStreamingChatCompletionUpdates` instance method to `ChatCompletion`. +- Added `IncludeTypeInEnumSchemas`, `DisallowAdditionalProperties`, `RequireAllProperties`, and `TransformSchemaNode` options to `AIJsonSchemaCreateOptions`. +- Fixed a Native AOT warning in `AIFunctionFactory.Create`. +- Fixed a bug in `AIJsonUtilities` in the handling of Boolean schemas. +- Improved the `ToString` override of `ChatMessage` and `StreamingChatCompletionUpdate` to include all `TextContent`, and of `ChatCompletion` to include all choices. +- Added `DebuggerDisplay` attributes to `DataContent` and `GeneratedEmbeddings`. +- Improved the documentation. + +## 9.0.0-preview.9.24556.5 + +- Added a strongly-typed `ChatOptions.Seed` property. +- Improved `AdditionalPropertiesDictionary` with a `TryAdd` method, a strongly-typed `Enumerator`, and debugger-related attributes for improved debuggability. +- Fixed `AIJsonUtilities` schema generation for Boolean schemas. + +## 9.0.0-preview.9.24525.1 + +- Lowered the required version of System.Text.Json to 8.0.5 when targeting net8.0 or older. +- Annotated `FunctionCallContent.Exception` and `FunctionResultContent.Exception` as `[JsonIgnore]`, such that they're ignored when serializing instances with `JsonSerializer`. The corresponding constructors accepting an `Exception` were removed. +- Annotated `ChatCompletion.Message` as `[JsonIgnore]`, such that it's ignored when serializing instances with `JsonSerializer`. +- Added the `FunctionCallContent.CreateFromParsedArguments` method. +- Added the `AdditionalPropertiesDictionary.TryGetValue` method. +- Added the `StreamingChatCompletionUpdate.ModelId` property and removed the `AIContent.ModelId` property. +- Renamed the `GenerateAsync` extension method on `IEmbeddingGenerator<,>` to `GenerateEmbeddingsAsync` and updated it to return `Embedding` rather than `GeneratedEmbeddings`. +- Added `GenerateAndZipAsync` and `GenerateEmbeddingVectorAsync` extension methods for `IEmbeddingGenerator<,>`. +- Added the `EmbeddingGeneratorOptions.Dimensions` property. +- Added the `ChatOptions.TopK` property. +- Normalized `null` inputs in `TextContent` to be empty strings. + +## 9.0.0-preview.9.24507.7 + +- Initial Preview diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatFinishReason.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatFinishReason.cs index 18b3ec658e3..1852aa07f7c 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatFinishReason.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatFinishReason.cs @@ -14,9 +14,6 @@ namespace Microsoft.Extensions.AI; [JsonConverter(typeof(Converter))] public readonly struct ChatFinishReason : IEquatable { - /// The finish reason value. If 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 . @@ -24,11 +21,11 @@ namespace Microsoft.Extensions.AI; [JsonConstructor] public ChatFinishReason(string value) { - _value = Throw.IfNullOrWhitespace(value); + Value = Throw.IfNullOrWhitespace(value); } /// Gets the finish reason value. - public string Value => _value ?? Stop.Value; + public string Value => field ?? Stop.Value; /// public override bool Equals([NotNullWhen(true)] object? obj) => obj is ChatFinishReason other && Equals(other); diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatMessage.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatMessage.cs index 43b3e321df9..4cdf3b30f0a 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatMessage.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatMessage.cs @@ -53,6 +53,7 @@ public ChatMessage Clone() => AdditionalProperties = AdditionalProperties, _authorName = _authorName, _contents = _contents, + CreatedAt = CreatedAt, RawRepresentation = RawRepresentation, Role = Role, MessageId = MessageId, @@ -65,6 +66,9 @@ public string? AuthorName set => _authorName = string.IsNullOrWhiteSpace(value) ? null : value; } + /// Gets or sets a timestamp for the chat message. + public DateTimeOffset? CreatedAt { get; set; } + /// Gets or sets the role of the author of the message. public ChatRole Role { get; set; } = ChatRole.User; @@ -103,7 +107,17 @@ public IList Contents /// Gets a object to display in the debugger display. [DebuggerBrowsable(DebuggerBrowsableState.Never)] - private AIContent? ContentForDebuggerDisplay => _contents is { Count: > 0 } ? _contents[0] : null; + private AIContent? ContentForDebuggerDisplay + { + get + { + string text = Text; + return + !string.IsNullOrWhiteSpace(text) ? new TextContent(text) : + _contents is { Count: > 0 } ? _contents[0] : + null; + } + } /// Gets an indication for the debugger display of whether there's more content. [DebuggerBrowsable(DebuggerBrowsableState.Never)] diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs index 0be912430fa..738f724dcd2 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatOptions.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; namespace Microsoft.Extensions.AI; @@ -11,6 +12,48 @@ namespace Microsoft.Extensions.AI; /// Provide options. public class ChatOptions { + /// Initializes a new instance of the class. + public ChatOptions() + { + } + + /// Initializes a new instance of the class, performing a shallow copy of all properties from . + protected ChatOptions(ChatOptions? other) + { + if (other is null) + { + return; + } + + AdditionalProperties = other.AdditionalProperties?.Clone(); + AllowBackgroundResponses = other.AllowBackgroundResponses; + AllowMultipleToolCalls = other.AllowMultipleToolCalls; + ConversationId = other.ConversationId; + ContinuationToken = other.ContinuationToken; + FrequencyPenalty = other.FrequencyPenalty; + Instructions = other.Instructions; + MaxOutputTokens = other.MaxOutputTokens; + ModelId = other.ModelId; + PresencePenalty = other.PresencePenalty; + RawRepresentationFactory = other.RawRepresentationFactory; + ResponseFormat = other.ResponseFormat; + Seed = other.Seed; + Temperature = other.Temperature; + ToolMode = other.ToolMode; + TopK = other.TopK; + TopP = other.TopP; + + if (other.StopSequences is not null) + { + StopSequences = [.. other.StopSequences]; + } + + if (other.Tools is not null) + { + Tools = [.. other.Tools]; + } + } + /// Gets or sets an optional identifier used to associate a request with an existing conversation. /// Stateless vs. stateful clients. public string? ConversationId { get; set; } @@ -90,24 +133,24 @@ public class ChatOptions public IList? StopSequences { get; set; } /// - /// Gets or sets a flag to indicate whether a single response is allowed to include multiple tool calls. - /// If , the is asked to return a maximum of one tool call per request. - /// If , there is no limit. - /// If , the provider may select its own default. + /// Gets or sets a value that indicates whether a single response is allowed to include multiple tool calls. /// + /// + /// for no limit. if the is asked to return a maximum of one tool call per request. If , the provider can select its own default. + /// /// /// /// When used with function calling middleware, this does not affect the ability to perform multiple function calls in sequence. /// It only affects the number of function calls within a single iteration of the function calling loop. /// /// - /// The underlying provider is not guaranteed to support or honor this flag. For example it may choose to ignore it and return multiple tool calls regardless. + /// The underlying provider is not guaranteed to support or honor this flag. For example it might choose to ignore it and return multiple tool calls regardless. /// /// public bool? AllowMultipleToolCalls { get; set; } /// Gets or sets the tool mode for the chat request. - /// The default value is , which is treated the same as . + /// The default is , which is treated the same as . public ChatToolMode? ToolMode { get; set; } /// Gets or sets the list of tools to include with a chat request. @@ -115,21 +158,62 @@ public class ChatOptions [JsonIgnore] public IList? Tools { get; set; } + /// Gets or sets a value indicating whether the background responses are allowed. + /// + /// + /// Background responses allow running long-running operations or tasks asynchronously in the background that can be resumed by streaming APIs + /// and polled for completion by non-streaming APIs. + /// + /// + /// When this property is set to , non-streaming APIs have permission to start a background operation and return an initial + /// response with a continuation token. Subsequent calls to the same API should be made in a polling manner with + /// the continuation token to get the final result of the operation. + /// + /// + /// When this property is set to , streaming APIs are also permitted to start a background operation and begin streaming + /// response updates until the operation is completed. If the streaming connection is interrupted, the + /// continuation token obtained from the last update that has one should be supplied to a subsequent call to the same streaming API + /// to resume the stream from the point of interruption and continue receiving updates until the operation is completed. + /// + /// + /// This property only takes effect if the implementation it's used with supports background responses. + /// If the implementation does not support background responses, this property will be ignored. + /// + /// + [Experimental("MEAI001")] + [JsonIgnore] + public bool? AllowBackgroundResponses { get; set; } + + /// Gets or sets the continuation token for resuming and getting the result of the chat response identified by this token. + /// + /// This property is used for background responses that can be activated via the + /// property if the implementation supports them. + /// Streamed background responses, such as those returned by default by , + /// can be resumed if interrupted. This means that a continuation token obtained from the + /// of an update just before the interruption occurred can be passed to this property to resume the stream from the point of interruption. + /// Non-streamed background responses, such as those returned by , + /// can be polled for completion by obtaining the token from the property + /// and passing it to this property on subsequent calls to . + /// + [Experimental("MEAI001")] + [JsonIgnore] + public object? ContinuationToken { get; set; } + /// /// Gets or sets a callback responsible for creating the raw representation of the chat options from an underlying implementation. /// /// - /// The underlying implementation may have its own representation of options. + /// The underlying implementation might have its own representation of options. /// When or - /// is invoked with a , that implementation may convert the provided options into + /// is invoked with a , that implementation might convert the provided options into /// its own representation in order to use it while performing the operation. For situations where a consumer knows /// which concrete is being used and how it represents options, a new instance of that - /// implementation-specific options type may be returned by this callback, for the - /// implementation to use instead of creating a new instance. Such implementations may mutate the supplied options + /// implementation-specific options type can be returned by this callback for the + /// implementation to use, instead of creating a new instance. Such implementations might mutate the supplied options /// instance further based on other settings supplied on this instance or from other inputs, - /// like the enumerable of s, therefore, it is strongly recommended to not return shared instances + /// like the enumerable of s. Therefore, it is strongly recommended to not return shared instances /// and instead make the callback return a new instance on each call. - /// This is typically used to set an implementation-specific setting that isn't otherwise exposed from the strongly-typed + /// This is typically used to set an implementation-specific setting that isn't otherwise exposed from the strongly typed /// properties on . /// [JsonIgnore] @@ -141,41 +225,14 @@ public class ChatOptions /// 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. + /// + /// + /// Derived types should override to return an instance of the derived type. + /// /// - public virtual ChatOptions Clone() - { - ChatOptions options = new() - { - AdditionalProperties = AdditionalProperties?.Clone(), - AllowMultipleToolCalls = AllowMultipleToolCalls, - ConversationId = ConversationId, - FrequencyPenalty = FrequencyPenalty, - Instructions = Instructions, - MaxOutputTokens = MaxOutputTokens, - ModelId = ModelId, - PresencePenalty = PresencePenalty, - RawRepresentationFactory = RawRepresentationFactory, - ResponseFormat = ResponseFormat, - Seed = Seed, - Temperature = Temperature, - ToolMode = ToolMode, - TopK = TopK, - TopP = TopP, - }; - - if (StopSequences is not null) - { - options.StopSequences = new List(StopSequences); - } - - if (Tools is not null) - { - options.Tools = new List(Tools); - } - - return options; - } + public virtual ChatOptions Clone() => new(this); } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponse.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponse.cs index a342ef1e69e..6f7ca4eeda2 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponse.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponse.cs @@ -12,10 +12,10 @@ namespace Microsoft.Extensions.AI; /// Represents the response to a chat request. /// /// provides one or more response messages and metadata about the response. -/// A typical response will contain a single message, however a response may contain multiple messages +/// A typical response will contain a single message, however a response might contain multiple messages /// in a variety of scenarios. For example, if automatic function calling is employed, such that a single -/// request to a may actually generate multiple roundtrips to an inner -/// it uses, all of the involved messages may be surfaced as part of the final . +/// request to a might actually generate multiple round-trips to an inner +/// it uses, all of the involved messages might be surfaced as part of the final . /// public class ChatResponse { @@ -69,8 +69,7 @@ public IList Messages /// the input messages supplied to need only be the additional messages beyond /// what's already stored. If this property is non-, it represents an identifier for that state, /// and it should be used in a subsequent instead of supplying the same messages - /// (and this 's message) as part of the messages parameter. Note that the value may - /// or may not differ on every response, depending on whether the underlying provider uses a fixed ID for each conversation + /// (and this 's message) as part of the messages parameter. Note that the value might differ on every response, depending on whether the underlying provider uses a fixed ID for each conversation /// or updates it for each message. /// /// Stateless vs. stateful clients. @@ -88,6 +87,23 @@ public IList Messages /// Gets or sets usage details for the chat response. public UsageDetails? Usage { get; set; } + /// Gets or sets the continuation token for getting result of the background chat response. + /// + /// implementations that support background responses will return + /// a continuation token if background responses are allowed in + /// and the result of the response has not been obtained yet. If the response has completed and the result has been obtained, + /// the token will be . + /// + /// This property should be used in conjunction with to + /// continue to poll for the completion of the response. Pass this token to + /// on subsequent calls to + /// to poll for completion. + /// + /// + [Experimental("MEAI001")] + [JsonIgnore] + public object? ContinuationToken { get; set; } + /// Gets or sets the raw representation of the chat response from an underlying implementation. /// /// If a is created to represent some underlying object from another object @@ -104,7 +120,7 @@ public IList Messages public override string ToString() => Text; /// Creates an array of instances that represent this . - /// An array of instances that may be used to represent this . + /// An array of instances that can be used to represent this . public ChatResponseUpdate[] ToChatResponseUpdates() { ChatResponseUpdate? extra = null; @@ -130,19 +146,20 @@ public ChatResponseUpdate[] ToChatResponseUpdates() ChatMessage message = _messages![i]; updates[i] = new ChatResponseUpdate { - ConversationId = ConversationId, - AdditionalProperties = message.AdditionalProperties, AuthorName = message.AuthorName, Contents = message.Contents, + MessageId = message.MessageId, RawRepresentation = message.RawRepresentation, Role = message.Role, - ResponseId = ResponseId, - MessageId = message.MessageId, - CreatedAt = CreatedAt, + ConversationId = ConversationId, FinishReason = FinishReason, - ModelId = ModelId + ModelId = ModelId, + ResponseId = ResponseId, + + CreatedAt = message.CreatedAt ?? CreatedAt, + ContinuationToken = ContinuationToken, }; } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs index 01ce878e79c..e7b535e6995 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs @@ -3,15 +3,18 @@ using System; using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO; using System.Linq; +#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 S1121 // Assignments should not be made from within sub-expressions - namespace Microsoft.Extensions.AI; /// @@ -84,9 +87,10 @@ public static void AddMessages(this IList list, ChatResponseUpdate var contentsList = filter is null ? update.Contents : update.Contents.Where(filter).ToList(); if (contentsList.Count > 0) { - list.Add(new ChatMessage(update.Role ?? ChatRole.Assistant, contentsList) + list.Add(new(update.Role ?? ChatRole.Assistant, contentsList) { AuthorName = update.AuthorName, + CreatedAt = update.CreatedAt, RawRepresentation = update.RawRepresentation, AdditionalProperties = update.AdditionalProperties, }); @@ -180,59 +184,288 @@ static async Task ToChatResponseAsync( } } + /// + /// Coalesces image result content elements in the provided list of items. + /// Unlike other content coalescing methods, this will coalesce non-sequential items based on their Name property, + /// and it will replace earlier items with later ones when duplicates are found. + /// + private static void CoalesceImageResultContent(IList contents) + { + Dictionary? imageResultIndexById = null; + bool hasRemovals = false; + + for (int i = 0; i < contents.Count; i++) + { + if (contents[i] is ImageGenerationToolResultContent imageResult && !string.IsNullOrEmpty(imageResult.ImageId)) + { + // Check if there's an existing ImageGenerationToolResultContent with the same ImageId to replace + if (imageResultIndexById is null) + { + imageResultIndexById = new(StringComparer.Ordinal); + } + + if (imageResultIndexById.TryGetValue(imageResult.ImageId!, out int existingIndex)) + { + // Replace the existing imageResult with the new one + contents[existingIndex] = imageResult; + contents[i] = null!; // Mark the current one for removal, then remove in single o(n) pass + hasRemovals = true; + } + else + { + imageResultIndexById[imageResult.ImageId!] = i; + } + } + } + + // Remove all of the null slots left over from the coalescing process. + if (hasRemovals) + { + RemoveNullContents(contents); + } + } + /// Coalesces sequential content elements. - internal static void CoalesceTextContent(List contents) + internal static void CoalesceContent(IList contents) { - Coalesce(contents, static text => new(text)); - Coalesce(contents, static text => new(text)); + Coalesce( + contents, + mergeSingle: false, + canMerge: null, + static (contents, start, end) => new(MergeText(contents, start, end)) { AdditionalProperties = contents[start].AdditionalProperties?.Clone() }); + + Coalesce( + contents, + mergeSingle: false, + canMerge: static (r1, r2) => string.IsNullOrEmpty(r1.ProtectedData), // we allow merging if the first item has no ProtectedData, even if the second does + static (contents, start, end) => + { + TextReasoningContent content = new(MergeText(contents, start, end)) + { + AdditionalProperties = contents[start].AdditionalProperties?.Clone() + }; - // This implementation relies on TContent's ToString returning its exact text. - static void Coalesce(List contents, Func fromText) - where TContent : AIContent +#if DEBUG + for (int i = start; i < end - 1; i++) + { + Debug.Assert(contents[i] is TextReasoningContent { ProtectedData: null }, "Expected all but the last to have a null ProtectedData"); + } +#endif + + if (((TextReasoningContent)contents[end - 1]).ProtectedData is { } protectedData) + { + content.ProtectedData = protectedData; + } + + return content; + }); + + CoalesceImageResultContent(contents); + + Coalesce( + contents, + mergeSingle: false, + canMerge: static (r1, r2) => r1.MediaType == r2.MediaType && r1.HasTopLevelMediaType("text") && r1.Name == r2.Name, + static (contents, start, end) => + { + Debug.Assert(end - start > 1, "Expected multiple contents to merge"); + + MemoryStream ms = new(); + for (int i = start; i < end; i++) + { + var current = (DataContent)contents[i]; +#if NET + ms.Write(current.Data.Span); +#else + if (!MemoryMarshal.TryGetArray(current.Data, out var segment)) + { + segment = new(current.Data.ToArray()); + } + + ms.Write(segment.Array!, segment.Offset, segment.Count); +#endif + } + + var first = (DataContent)contents[start]; + return new DataContent(new ReadOnlyMemory(ms.GetBuffer(), 0, (int)ms.Length), first.MediaType) { Name = first.Name }; + }); + + Coalesce( + contents, + mergeSingle: true, + canMerge: static (r1, r2) => r1.CallId == r2.CallId, + static (contents, start, end) => + { + var firstContent = (CodeInterpreterToolCallContent)contents[start]; + + if (start == end - 1) + { + if (firstContent.Inputs is not null) + { + CoalesceContent(firstContent.Inputs); + } + + return firstContent; + } + + List? inputs = null; + + for (int i = start; i < end; i++) + { + (inputs ??= []).AddRange(((CodeInterpreterToolCallContent)contents[i]).Inputs ?? []); + } + + if (inputs is not null) + { + CoalesceContent(inputs); + } + + return new() + { + CallId = firstContent.CallId, + Inputs = inputs, + AdditionalProperties = firstContent.AdditionalProperties?.Clone(), + }; + }); + + Coalesce( + contents, + mergeSingle: true, + canMerge: static (r1, r2) => r1.CallId is not null && r2.CallId is not null && r1.CallId == r2.CallId, + static (contents, start, end) => + { + var firstContent = (CodeInterpreterToolResultContent)contents[start]; + + if (start == end - 1) + { + if (firstContent.Outputs is not null) + { + CoalesceContent(firstContent.Outputs); + } + + return firstContent; + } + + List? output = null; + + for (int i = start; i < end; i++) + { + (output ??= []).AddRange(((CodeInterpreterToolResultContent)contents[i]).Outputs ?? []); + } + + if (output is not null) + { + CoalesceContent(output); + } + + return new() + { + CallId = firstContent.CallId, + Outputs = output, + AdditionalProperties = firstContent.AdditionalProperties?.Clone(), + }; + }); + + static string MergeText(IList contents, int start, int end) { - StringBuilder? coalescedText = null; + Debug.Assert(end - start > 1, "Expected multiple contents to merge"); + StringBuilder sb = new(); + for (int i = start; i < end; i++) + { + _ = sb.Append(contents[i]); + } + + return sb.ToString(); + } + + static void Coalesce( + IList contents, + bool mergeSingle, + Func? canMerge, + Func, int, int, TContent> merge) + where TContent : AIContent + { // 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) + while (start < contents.Count) { - // We need at least two TextContents in a row to be able to coalesce. - if (contents[start] is not TContent firstText) + if (!TryAsCoalescable(contents[start], out var firstContent)) { start++; continue; } - if (contents[start + 1] is not TContent secondText) + // Iterate until we find a non-coalescable item. + int i = start + 1; + TContent prev = firstContent; + while (i < contents.Count && TryAsCoalescable(contents[i], out TContent? next) && (canMerge is null || canMerge(prev, next))) { - start += 2; - continue; + i++; + prev = next; } - // 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).Append(secondText); - contents[start + 1] = null!; - int i = start + 2; - for (; i < contents.Count && contents[i] is TContent next; i++) + // If there's only one item in the run, and we don't want to merge single items, skip it. + if (start == i - 1 && !mergeSingle) { - _ = coalescedText.Append(next); - contents[i] = null!; + start++; + continue; } - // Store the replacement node. 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. - var newContent = fromText(coalescedText.ToString()); - contents[start] = newContent; - newContent.AdditionalProperties = firstText.AdditionalProperties?.Clone(); + // Store the replacement node and null out all of the nodes that we coalesced. + // We can then remove all coalesced nodes in one O(N) operation via RemoveAll. + // Leave start positioned at the start of the next run. + contents[start] = merge(contents, start, i); - start = i; + start++; + while (start < i) + { + contents[start++] = null!; + } + + static bool TryAsCoalescable(AIContent content, [NotNullWhen(true)] out TContent? coalescable) + { + if (content is TContent tmp && tmp.Annotations is not { Count: > 0 }) + { + coalescable = tmp; + return true; + } + + coalescable = null; + return false; + } } // Remove all of the null slots left over from the coalescing process. - _ = contents.RemoveAll(u => u is null); + RemoveNullContents(contents); + } + } + + private static void RemoveNullContents(IList contents) + where T : class + { + if (contents is List contentsList) + { + _ = contentsList.RemoveAll(u => u is null); + } + else + { + int nextSlot = 0; + int contentsCount = contents.Count; + for (int i = 0; i < contentsCount; i++) + { + if (contents[i] is { } content) + { + contents[nextSlot++] = content; + } + } + + for (int i = contentsCount - 1; i >= nextSlot; i--) + { + contents.RemoveAt(i); + } + + Debug.Assert(nextSlot == contents.Count, "Expected final count to equal list length."); } } @@ -242,7 +475,7 @@ private static void FinalizeResponse(ChatResponse response) int count = response.Messages.Count; for (int i = 0; i < count; i++) { - CoalesceTextContent((List)response.Messages[i].Contents); + CoalesceContent((List)response.Messages[i].Contents); } } @@ -252,23 +485,22 @@ private static void FinalizeResponse(ChatResponse response) private static void ProcessUpdate(ChatResponseUpdate update, ChatResponse response) { // If there is no message created yet, or if the last update we saw had a different - // message ID than the newest update, create a new message. - ChatMessage message; - var isNewMessage = false; - if (response.Messages.Count == 0) + // identifying parts, create a new message. + bool isNewMessage = true; + if (response.Messages.Count != 0) { - isNewMessage = true; - } - else if (update.MessageId is { Length: > 0 } updateMessageId - && response.Messages[response.Messages.Count - 1].MessageId is string lastMessageId - && updateMessageId != lastMessageId) - { - isNewMessage = true; + var lastMessage = response.Messages[response.Messages.Count - 1]; + isNewMessage = + NotEmptyOrEqual(update.AuthorName, lastMessage.AuthorName) || + NotEmptyOrEqual(update.MessageId, lastMessage.MessageId) || + NotNullOrEqual(update.Role, lastMessage.Role); } + // Get the message to target, either a new one or the last ones. + ChatMessage message; if (isNewMessage) { - message = new ChatMessage(ChatRole.Assistant, []); + message = new(ChatRole.Assistant, []); response.Messages.Add(message); } else @@ -280,11 +512,17 @@ private static void ProcessUpdate(ChatResponseUpdate update, ChatResponse respon // Incorporate those into the latest message; in cases where the message // stores a single value, prefer the latest update's value over anything // stored in the message. + if (update.AuthorName is not null) { message.AuthorName = update.AuthorName; } + if (message.CreatedAt is null || (update.CreatedAt is not null && update.CreatedAt > message.CreatedAt)) + { + message.CreatedAt = update.CreatedAt; + } + if (update.Role is ChatRole role) { message.Role = role; @@ -325,7 +563,7 @@ private static void ProcessUpdate(ChatResponseUpdate update, ChatResponse respon response.ConversationId = update.ConversationId; } - if (update.CreatedAt is not null) + if (response.CreatedAt is null || (update.CreatedAt is not null && update.CreatedAt > response.CreatedAt)) { response.CreatedAt = update.CreatedAt; } @@ -352,4 +590,12 @@ private static void ProcessUpdate(ChatResponseUpdate update, ChatResponse respon } } } + + /// Gets whether both strings are not null/empty and not the same as each other. + private static bool NotEmptyOrEqual(string? s1, string? s2) => + s1 is { Length: > 0 } str1 && s2 is { Length: > 0 } str2 && str1 != str2; + + /// Gets whether two roles are not null and not the same as each other. + private static bool NotNullOrEqual(ChatRole? r1, ChatRole? r2) => + r1.HasValue && r2.HasValue && r1.Value != r2.Value; } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormat.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormat.cs index ac59cfc263e..76f8a486ad1 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormat.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseFormat.cs @@ -1,19 +1,29 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; +using System.ComponentModel; +using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI; +#pragma warning disable CA1052 // Static holder types should be Static or NotInheritable + /// 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 +public partial class ChatResponseFormat { + private static readonly AIJsonSchemaCreateOptions _inferenceOptions = new() + { + IncludeSchemaKeyword = true, + }; + /// 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() @@ -33,7 +43,61 @@ private protected ChatResponseFormat() /// The instance. public static ChatResponseFormatJson ForJsonSchema( JsonElement schema, string? schemaName = null, string? schemaDescription = null) => - new(schema, - schemaName, - schemaDescription); + new(schema, schemaName, schemaDescription); + + /// Creates a representing structured JSON data with a schema based on . + /// The type for which a schema should be exported and used as the response schema. + /// The JSON serialization options to use. + /// An optional name of the schema. By default, this will be inferred from . + /// An optional description of the schema. By default, this will be inferred from . + /// The instance. + /// + /// Many AI services that support structured output require that the JSON schema have a top-level 'type=object'. + /// If is a primitive type like , , or , + /// or if it's a type that serializes as a JSON array, attempting to use the resulting schema with such services may fail. + /// In such cases, consider instead using a that wraps the actual type in a class or struct so that + /// it serializes as a JSON object with the original type as a property of that object. + /// + public static ChatResponseFormatJson ForJsonSchema( + JsonSerializerOptions? serializerOptions = null, string? schemaName = null, string? schemaDescription = null) => + ForJsonSchema(typeof(T), serializerOptions, schemaName, schemaDescription); + + /// Creates a representing structured JSON data with a schema based on . + /// The for which a schema should be exported and used as the response schema. + /// The JSON serialization options to use. + /// An optional name of the schema. By default, this will be inferred from . + /// An optional description of the schema. By default, this will be inferred from . + /// The instance. + /// + /// Many AI services that support structured output require that the JSON schema have a top-level 'type=object'. + /// If is a primitive type like , , or , + /// or if it's a type that serializes as a JSON array, attempting to use the resulting schema with such services may fail. + /// In such cases, consider instead using a that wraps the actual type in a class or struct so that + /// it serializes as a JSON object with the original type as a property of that object. + /// + /// is . + public static ChatResponseFormatJson ForJsonSchema( + Type schemaType, JsonSerializerOptions? serializerOptions = null, string? schemaName = null, string? schemaDescription = null) + { + _ = Throw.IfNull(schemaType); + + var schema = AIJsonUtilities.CreateJsonSchema( + schemaType, + serializerOptions: serializerOptions ?? AIJsonUtilities.DefaultOptions, + inferenceOptions: _inferenceOptions); + + return ForJsonSchema( + schema, + schemaName ?? schemaType.GetCustomAttribute()?.DisplayName ?? InvalidNameCharsRegex().Replace(schemaType.Name, "_"), + schemaDescription ?? schemaType.GetCustomAttribute()?.Description); + } + + /// Regex that flags any character other than ASCII digits, ASCII letters, or 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/ChatCompletion/ChatResponseUpdate.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseUpdate.cs index 63dbfbc0d7d..f1ad70cd22f 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseUpdate.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseUpdate.cs @@ -20,9 +20,9 @@ namespace Microsoft.Extensions.AI; /// /// /// The relationship between and is -/// codified in the and +/// codified in the and /// , which enable bidirectional conversions -/// between the two. Note, however, that the provided conversions may be lossy, for example if multiple +/// between the two. Note, however, that the provided conversions might be lossy, for example, if multiple /// updates all have different objects whereas there's only one slot for /// such an object available in . Similarly, if different /// updates provide different values for properties like , @@ -35,9 +35,6 @@ public class ChatResponseUpdate /// The response update content items. private IList? _contents; - /// The name of the author of the update. - private string? _authorName; - /// Initializes a new instance of the class. [JsonConstructor] public ChatResponseUpdate() @@ -61,11 +58,34 @@ public ChatResponseUpdate(ChatRole? role, IList? contents) _contents = contents; } + /// + /// Creates a new ChatResponseUpdate instance that is a copy of the current object. + /// + /// The cloned object is a shallow copy; reference-type properties will reference the same + /// objects as the original. Use this method to duplicate the response update for further modification without + /// affecting the original instance. + /// A new ChatResponseUpdate object with the same property values as the current instance. + public ChatResponseUpdate Clone() => + new() + { + AdditionalProperties = AdditionalProperties, + AuthorName = AuthorName, + Contents = Contents, + CreatedAt = CreatedAt, + ConversationId = ConversationId, + FinishReason = FinishReason, + MessageId = MessageId, + ModelId = ModelId, + RawRepresentation = RawRepresentation, + ResponseId = ResponseId, + Role = Role, + }; + /// Gets or sets the name of the author of the response update. public string? AuthorName { - get => _authorName; - set => _authorName = string.IsNullOrWhiteSpace(value) ? null : value; + get; + set => field = string.IsNullOrWhiteSpace(value) ? null : value; } /// Gets or sets the role of the author of the response update. @@ -103,12 +123,12 @@ public IList Contents /// Gets or sets the ID of the message of which this update is a part. /// - /// A single streaming response may be composed of multiple messages, each of which may be represented + /// A single streaming response might be composed of multiple messages, each of which might be represented /// by multiple updates. This property is used to group those updates together into messages. /// - /// Some providers may consider streaming responses to be a single message, and in that case - /// the value of this property may be the same as the response ID. - /// + /// Some providers might consider streaming responses to be a single message, and in that case + /// the value of this property might be the same as the response ID. + /// /// This value is used when /// groups instances into instances. /// The value must be unique to each call to the underlying provider, and must be shared by @@ -122,7 +142,7 @@ public IList Contents /// the input messages supplied to need only be the additional messages beyond /// what's already stored. If this property is non-, it represents an identifier for that state, /// and it should be used in a subsequent instead of supplying the same messages - /// (and this streaming message) as part of the messages parameter. Note that the value may or may not differ on every + /// (and this streaming message) as part of the messages parameter. Note that the value might differ on every /// response, depending on whether the underlying provider uses a fixed ID for each conversation or updates it for each message. /// public string? ConversationId { get; set; } @@ -139,9 +159,34 @@ public IList Contents /// public override string ToString() => Text; + /// Gets or sets the continuation token for resuming the streamed chat response of which this update is a part. + /// + /// implementations that support background responses return + /// a continuation token on each update if background responses are allowed in . + /// However, for the last update, the token will be . + /// + /// This property should be used for stream resumption, where the continuation token of the latest received update should be + /// passed to on subsequent calls to + /// to resume streaming from the point of interruption. + /// + /// + [Experimental("MEAI001")] + [JsonIgnore] + public object? ContinuationToken { get; set; } + /// Gets a object to display in the debugger display. [DebuggerBrowsable(DebuggerBrowsableState.Never)] - private AIContent? ContentForDebuggerDisplay => _contents is { Count: > 0 } ? _contents[0] : null; + private AIContent? ContentForDebuggerDisplay + { + get + { + string text = Text; + return + !string.IsNullOrWhiteSpace(text) ? new TextContent(text) : + _contents is { Count: > 0 } ? _contents[0] : + null; + } + } /// Gets an indication for the debugger display of whether there's more content. [DebuggerBrowsable(DebuggerBrowsableState.Never)] diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatToolMode.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatToolMode.cs index 05e1f28f476..73134a5d894 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatToolMode.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatToolMode.cs @@ -55,8 +55,7 @@ private protected ChatToolMode() /// /// Instantiates a indicating that tool usage is required, - /// and that the specified must be selected. The function name - /// must match an entry in . + /// and that the specified function name must be selected. /// /// The name of the required function. /// An instance of for the specified function name. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/DelegatingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/DelegatingChatClient.cs index 112e846d41f..34aa665450b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/DelegatingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/DelegatingChatClient.cs @@ -23,6 +23,7 @@ public class DelegatingChatClient : IChatClient /// Initializes a new instance of the class. /// /// The wrapped client instance. + /// is . protected DelegatingChatClient(IChatClient innerClient) { InnerClient = Throw.IfNull(innerClient); diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/IChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/IChatClient.cs index 570eb7ef497..f4e0141ac94 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/IChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/IChatClient.cs @@ -62,7 +62,7 @@ IAsyncEnumerable GetStreamingResponseAsync( /// The found object, otherwise . /// is . /// - /// The purpose of this method is to allow for the retrieval of strongly-typed services that might be provided by the , + /// 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. For example, to access the for the instance, /// may be used to request it. /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/RequiredChatToolMode.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/RequiredChatToolMode.cs index 91397e67602..899ba04251e 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/RequiredChatToolMode.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/RequiredChatToolMode.cs @@ -15,17 +15,17 @@ namespace Microsoft.Extensions.AI; public sealed class RequiredChatToolMode : ChatToolMode { /// - /// Gets the name of a specific that must be called. + /// Gets the name of a specific tool that must be called. /// /// - /// If the value is , any available function can be selected (but at least one must be). + /// If the value is , any available tool can 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. + /// Initializes a new instance of the class that requires a specific tool to be called. /// - /// The name of the function that must be called. + /// The name of the tool that must be called. /// is empty or composed entirely of whitespace. /// /// can be . However, it's preferable to use @@ -41,12 +41,6 @@ public RequiredChatToolMode(string? 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. [DebuggerBrowsable(DebuggerBrowsableState.Never)] private string DebuggerDisplay => $"Required: {RequiredFunctionName ?? "Any"}"; diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatReduction/IChatReducer.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatReduction/IChatReducer.cs new file mode 100644 index 00000000000..5d85924f251 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatReduction/IChatReducer.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a reducer capable of shrinking the size of a list of chat messages. +/// +[Experimental("MEAI001")] +public interface IChatReducer +{ + /// Reduces the size of a list of chat messages. + /// The messages to reduce. + /// The to monitor for cancellation requests. + /// The new list of messages. + Task> ReduceAsync(IEnumerable messages, CancellationToken cancellationToken); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/CompatibilitySuppressions.xml b/src/Libraries/Microsoft.Extensions.AI.Abstractions/CompatibilitySuppressions.xml new file mode 100644 index 00000000000..8488d3969c4 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/CompatibilitySuppressions.xml @@ -0,0 +1,88 @@ + + + + + CP0002 + M:Microsoft.Extensions.AI.HostedMcpServerTool.get_Headers + lib/net462/Microsoft.Extensions.AI.Abstractions.dll + lib/net462/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.HostedMcpServerTool.get_Url + lib/net462/Microsoft.Extensions.AI.Abstractions.dll + lib/net462/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.HostedMcpServerTool.set_Headers(System.Collections.Generic.IDictionary{System.String,System.String}) + lib/net462/Microsoft.Extensions.AI.Abstractions.dll + lib/net462/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.HostedMcpServerTool.get_Headers + lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll + lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.HostedMcpServerTool.get_Url + lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll + lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.HostedMcpServerTool.set_Headers(System.Collections.Generic.IDictionary{System.String,System.String}) + lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll + lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.HostedMcpServerTool.get_Headers + lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll + lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.HostedMcpServerTool.get_Url + lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll + lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.HostedMcpServerTool.set_Headers(System.Collections.Generic.IDictionary{System.String,System.String}) + lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll + lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.HostedMcpServerTool.get_Headers + lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll + lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.HostedMcpServerTool.get_Url + lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll + lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.HostedMcpServerTool.set_Headers(System.Collections.Generic.IDictionary{System.String,System.String}) + lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll + lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll + true + + \ No newline at end of file diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIAnnotation.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIAnnotation.cs new file mode 100644 index 00000000000..73fdff81aa2 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIAnnotation.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents an annotation on content. +/// +[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] +[JsonDerivedType(typeof(CitationAnnotation), typeDiscriminator: "citation")] +public class AIAnnotation +{ + /// + /// Initializes a new instance of the class. + /// + public AIAnnotation() + { + } + + /// Gets or sets any target regions for the annotation, pointing to where in the associated this annotation applies. + /// + /// The most common form of is , which provides starting and ending character indices + /// for . + /// + public IList? AnnotatedRegions { get; set; } + + /// Gets or sets the raw representation of the annotation from an underlying implementation. + /// + /// If an is created to represent some underlying object from another object + /// model, this property can be used to store that original object. This can be useful for debugging or + /// for enabling a consumer to access the underlying object model, if needed. + /// + [JsonIgnore] + public object? RawRepresentation { get; set; } + + /// + /// Gets or sets additional metadata specific to the provider or source type. + /// + public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs index 06798f10f3d..af8b19c8d84 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Generic; using System.Text.Json.Serialization; namespace Microsoft.Extensions.AI; @@ -11,10 +12,27 @@ namespace Microsoft.Extensions.AI; [JsonDerivedType(typeof(ErrorContent), typeDiscriminator: "error")] [JsonDerivedType(typeof(FunctionCallContent), typeDiscriminator: "functionCall")] [JsonDerivedType(typeof(FunctionResultContent), typeDiscriminator: "functionResult")] +[JsonDerivedType(typeof(HostedFileContent), typeDiscriminator: "hostedFile")] +[JsonDerivedType(typeof(HostedVectorStoreContent), typeDiscriminator: "hostedVectorStore")] [JsonDerivedType(typeof(TextContent), typeDiscriminator: "text")] [JsonDerivedType(typeof(TextReasoningContent), typeDiscriminator: "reasoning")] [JsonDerivedType(typeof(UriContent), typeDiscriminator: "uri")] [JsonDerivedType(typeof(UsageContent), typeDiscriminator: "usage")] + +// These should be added in once they're no longer [Experimental]. If they're included while still +// experimental, any JsonSerializerContext that includes AIContent will incur errors about using +// experimental types in its source generated files. When [Experimental] is removed from these types, +// these lines should be uncommented and the corresponding lines in AIJsonUtilities.CreateDefaultOptions +// as well as the [JsonSerializable] attributes for them on the JsonContext should be removed. +// [JsonDerivedType(typeof(FunctionApprovalRequestContent), typeDiscriminator: "functionApprovalRequest")] +// [JsonDerivedType(typeof(FunctionApprovalResponseContent), typeDiscriminator: "functionApprovalResponse")] +// [JsonDerivedType(typeof(McpServerToolCallContent), typeDiscriminator: "mcpServerToolCall")] +// [JsonDerivedType(typeof(McpServerToolResultContent), typeDiscriminator: "mcpServerToolResult")] +// [JsonDerivedType(typeof(McpServerToolApprovalRequestContent), typeDiscriminator: "mcpServerToolApprovalRequest")] +// [JsonDerivedType(typeof(McpServerToolApprovalResponseContent), typeDiscriminator: "mcpServerToolApprovalResponse")] +// [JsonDerivedType(typeof(CodeInterpreterToolCallContent), typeDiscriminator: "codeInterpreterToolCall")] +// [JsonDerivedType(typeof(CodeInterpreterToolResultContent), typeDiscriminator: "codeInterpreterToolResult")] + public class AIContent { /// @@ -24,6 +42,11 @@ public AIContent() { } + /// + /// Gets or sets a list of annotations on this content. + /// + public IList? Annotations { get; set; } + /// Gets or sets the raw representation of the content from an underlying implementation. /// /// If an is created to represent some underlying object from another object diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AnnotatedRegion.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AnnotatedRegion.cs new file mode 100644 index 00000000000..fed6dc886b7 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AnnotatedRegion.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI; + +/// Describes the portion of an associated to which an annotation applies. +/// +/// Details about the region is provided by derived types based on how the region is described. For example, starting +/// and ending indices into text content are provided by . +/// +[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] +[JsonDerivedType(typeof(TextSpanAnnotatedRegion), typeDiscriminator: "textSpan")] +public class AnnotatedRegion +{ + /// + /// Initializes a new instance of the class. + /// + public AnnotatedRegion() + { + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/CitationAnnotation.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/CitationAnnotation.cs new file mode 100644 index 00000000000..0f1925f057f --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/CitationAnnotation.cs @@ -0,0 +1,53 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents an annotation that links content to source references, +/// such as documents, URLs, files, or tool outputs. +/// +public class CitationAnnotation : AIAnnotation +{ + /// + /// Initializes a new instance of the class. + /// + public CitationAnnotation() + { + } + + /// + /// Gets or sets the title or name of the source. + /// + /// + /// This value could be the title of a document, the title from a web page, the name of a file, or similarly descriptive text. + /// + public string? Title { get; set; } + + /// + /// Gets or sets a URI from which the source material was retrieved. + /// + public Uri? Url { get; set; } + + /// Gets or sets a source identifier associated with the annotation. + /// + /// This is a provider-specific identifier that can be used to reference the source material by + /// an ID. This might be a document ID, a file ID, or some other identifier for the source material + /// that can be used to uniquely identify it with the provider. + /// + public string? FileId { get; set; } + + /// Gets or sets the name of any tool involved in the production of the associated content. + /// + /// This might be a function name, such as one from , or the name of a built-in tool + /// from the provider, such as "code_interpreter" or "file_search". + /// + public string? ToolName { get; set; } + + /// + /// Gets or sets a snippet or excerpt from the source that was cited. + /// + public string? Snippet { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/CodeInterpreterToolCallContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/CodeInterpreterToolCallContent.cs new file mode 100644 index 00000000000..31681b171be --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/CodeInterpreterToolCallContent.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a code interpreter tool call invocation by a hosted service. +/// +/// +/// This content type represents when a hosted AI service invokes a code interpreter tool. +/// It is informational only and represents the call itself, not the result. +/// +[Experimental("MEAI001")] +public sealed class CodeInterpreterToolCallContent : AIContent +{ + /// + /// Initializes a new instance of the class. + /// + public CodeInterpreterToolCallContent() + { + } + + /// + /// Gets or sets the tool call ID. + /// + public string? CallId { get; set; } + + /// + /// Gets or sets the inputs to the code interpreter tool. + /// + /// + /// Inputs can include various types of content such as for files, + /// for binary data, or other types as supported + /// by the service. Typically includes a with a "text/x-python" + /// media type representing the code for execution by the code interpreter tool. + /// + public IList? Inputs { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/CodeInterpreterToolResultContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/CodeInterpreterToolResultContent.cs new file mode 100644 index 00000000000..486ee7072ea --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/CodeInterpreterToolResultContent.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.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents the result of a code interpreter tool invocation by a hosted service. +/// +[Experimental("MEAI001")] +public sealed class CodeInterpreterToolResultContent : AIContent +{ + /// + /// Initializes a new instance of the class. + /// + public CodeInterpreterToolResultContent() + { + } + + /// + /// Gets or sets the tool call ID that this result corresponds to. + /// + public string? CallId { get; set; } + + /// + /// Gets or sets the output of code interpreter tool. + /// + /// + /// Outputs can include various types of content such as for files, + /// for binary data, for standard output text, + /// or other types as supported by the service. + /// + public IList? Outputs { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataContent.cs index 5bbde1e1444..fbc05e14405 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataContent.cs @@ -5,18 +5,18 @@ #if NET using System.Buffers; using System.Buffers.Text; +using System.ComponentModel; #endif using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Text; #if !NET using System.Runtime.InteropServices; #endif using System.Text.Json.Serialization; using Microsoft.Shared.Diagnostics; -#pragma warning disable S3996 // URI properties should not be strings -#pragma warning disable CA1054 // URI-like parameters should not be strings -#pragma warning disable CA1056 // URI-like properties should not be strings +#pragma warning disable IDE0032 // Use auto property #pragma warning disable CA1307 // Specify StringComparison for clarity namespace Microsoft.Extensions.AI; @@ -116,6 +116,7 @@ public DataContent([StringSyntax(StringSyntaxAttribute.Uri)] string uri, string? /// The media type (also known as MIME type) represented by the content. /// is . /// is empty or composed entirely of whitespace. + /// represents an invalid media type. public DataContent(ReadOnlyMemory data, string mediaType) { MediaType = DataUriParser.ThrowIfInvalidMediaType(mediaType); @@ -142,6 +143,9 @@ public DataContent(ReadOnlyMemory data, string mediaType) /// or from a . /// [StringSyntax(StringSyntaxAttribute.Uri)] +#if NET + [Description("A data URI representing the content.")] +#endif public string Uri { get @@ -183,6 +187,13 @@ public string Uri [JsonIgnore] public string MediaType { get; } + /// Gets or sets an optional name associated with the data. + /// + /// A service might use this name as part of citations or to help infer the type of data + /// being represented based on a file extension. + /// + public string? Name { get; set; } + /// Gets the data represented by this instance. /// /// If the instance was constructed from a , this property returns that data. @@ -227,6 +238,16 @@ private string DebuggerDisplay { get { + if (HasTopLevelMediaType("text")) + { + return $"MediaType = {MediaType}, Text = \"{Encoding.UTF8.GetString(Data.ToArray())}\""; + } + + if ("application/json".Equals(MediaType, StringComparison.OrdinalIgnoreCase)) + { + return $"JSON = {Encoding.UTF8.GetString(Data.ToArray())}"; + } + const int MaxLength = 80; string uri = Uri; diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataUriParser.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataUriParser.cs index cff25e9c30b..6afe1409e75 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataUriParser.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/DataUriParser.cs @@ -148,9 +148,7 @@ private static bool IsValidBase64Data(ReadOnlySpan value) #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; } @@ -171,9 +169,7 @@ private static bool IsValidBase64Data(ReadOnlySpan value) // 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; @@ -187,13 +183,11 @@ private static bool IsValidBase64Data(ReadOnlySpan value) /// 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()) : diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ErrorContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ErrorContent.cs index 4588531262b..4b82c4c5e91 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ErrorContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ErrorContent.cs @@ -14,22 +14,19 @@ namespace Microsoft.Extensions.AI; [DebuggerDisplay("{DebuggerDisplay,nq}")] public class ErrorContent : AIContent { - /// The error message. - private string? _message; - /// Initializes a new instance of the class with the specified error message. /// The error message to store in this content. public ErrorContent(string? message) { - _message = message; + Message = message; } /// Gets or sets the error message. [AllowNull] public string Message { - get => _message ?? string.Empty; - set => _message = value; + get => field ?? string.Empty; + set; } /// Gets or sets an error code associated with the error. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionApprovalRequestContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionApprovalRequestContent.cs new file mode 100644 index 00000000000..d3ec7ab8f0b --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionApprovalRequestContent.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a request for user approval of a function call. +/// +[Experimental("MEAI001")] +public sealed class FunctionApprovalRequestContent : UserInputRequestContent +{ + /// + /// Initializes a new instance of the class. + /// + /// The ID that uniquely identifies the function approval request/response pair. + /// The function call that requires user approval. + /// is . + /// is empty or composed entirely of whitespace. + /// is . + public FunctionApprovalRequestContent(string id, FunctionCallContent functionCall) + : base(id) + { + FunctionCall = Throw.IfNull(functionCall); + } + + /// + /// Gets the function call that pre-invoke approval is required for. + /// + public FunctionCallContent FunctionCall { get; } + + /// + /// Creates a to indicate whether the function call is approved or rejected based on the value of . + /// + /// if the function call is approved; otherwise, . + /// The representing the approval response. + public FunctionApprovalResponseContent CreateResponse(bool approved) => new(Id, approved, FunctionCall); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionApprovalResponseContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionApprovalResponseContent.cs new file mode 100644 index 00000000000..948dc6a1347 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionApprovalResponseContent.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a response to a function approval request. +/// +[Experimental("MEAI001")] +public sealed class FunctionApprovalResponseContent : UserInputResponseContent +{ + /// + /// Initializes a new instance of the class. + /// + /// The ID that uniquely identifies the function approval request/response pair. + /// if the function call is approved; otherwise, . + /// The function call that requires user approval. + /// is . + /// is empty or composed entirely of whitespace. + /// is . + public FunctionApprovalResponseContent(string id, bool approved, FunctionCallContent functionCall) + : base(id) + { + Approved = approved; + FunctionCall = Throw.IfNull(functionCall); + } + + /// + /// Gets a value indicating whether the user approved the request. + /// + public bool Approved { get; } + + /// + /// Gets the function call for which approval was requested. + /// + public FunctionCallContent FunctionCall { get; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs index d19988b2b76..836d5a4110b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs @@ -83,7 +83,6 @@ public static FunctionCallContent CreateFromParsedArguments( IDictionary? arguments = null; Exception? parsingException = null; -#pragma warning disable CA1031 // Do not catch general exception types try { arguments = argumentParser(encodedArguments); @@ -92,7 +91,6 @@ public static FunctionCallContent CreateFromParsedArguments( { 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.Abstractions/Contents/HostedFileContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/HostedFileContent.cs new file mode 100644 index 00000000000..cc9d8fd4e97 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/HostedFileContent.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.Diagnostics; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a file that is hosted by the AI service. +/// +/// +/// Unlike which contains the data for a file or blob, this class represents a file that is hosted +/// by the AI service and referenced by an identifier. Such identifiers are specific to the provider. +/// +[DebuggerDisplay("{DebuggerDisplay,nq}")] +public sealed class HostedFileContent : AIContent +{ + /// + /// Initializes a new instance of the class. + /// + /// The ID of the hosted file. + /// is . + /// is empty or composed entirely of whitespace. + public HostedFileContent(string fileId) + { + FileId = Throw.IfNullOrWhitespace(fileId); + } + + /// + /// Gets or sets the ID of the hosted file. + /// + /// is . + /// is empty or composed entirely of whitespace. + public string FileId + { + get => field; + set => field = Throw.IfNullOrWhitespace(value); + } + + /// Gets or sets an optional media type (also known as MIME type) associated with the file. + /// represents an invalid media type. + public string? MediaType + { + get; + set => field = value is not null ? DataUriParser.ThrowIfInvalidMediaType(value) : value; + } + + /// Gets or sets an optional name associated with the file. + public string? Name { get; set; } + + /// + /// Determines whether the 's top-level type matches the specified . + /// + /// The type to compare against . + /// if the type portion of matches the specified value; otherwise, false. + /// + /// + /// A media type is primarily composed of two parts, a "type" and a "subtype", separated by a slash ("/"). + /// The type portion is also referred to as the "top-level type"; for example, + /// "image/png" has a top-level type of "image". compares + /// the specified against the type portion of . + /// + /// + /// If is , this method returns . + /// + /// + public bool HasTopLevelMediaType(string topLevelType) => MediaType is not null && DataUriParser.HasTopLevelMediaType(MediaType, topLevelType); + + /// Gets a string representing this instance to display in the debugger. + [DebuggerBrowsable(DebuggerBrowsableState.Never)] + private string DebuggerDisplay + { + get + { + string display = $"FileId = {FileId}"; + + if (MediaType is string mediaType) + { + display += $", MediaType = {mediaType}"; + } + + if (Name is string name) + { + display += $", Name = \"{name}\""; + } + + return display; + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/HostedVectorStoreContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/HostedVectorStoreContent.cs new file mode 100644 index 00000000000..cab314486cf --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/HostedVectorStoreContent.cs @@ -0,0 +1,44 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a vector store that is hosted by the AI service. +/// +/// +/// Unlike which represents a specific file that is hosted by the AI service, +/// represents a vector store that can contain multiple files, indexed +/// for searching. +/// +[DebuggerDisplay("VectorStoreId = {VectorStoreId}")] +public sealed class HostedVectorStoreContent : AIContent +{ + private string _vectorStoreId; + + /// + /// Initializes a new instance of the class. + /// + /// The ID of the hosted file store. + /// is . + /// is empty or composed entirely of whitespace. + public HostedVectorStoreContent(string vectorStoreId) + { + _vectorStoreId = Throw.IfNullOrWhitespace(vectorStoreId); + } + + /// + /// Gets or sets the ID of the hosted vector store. + /// + /// is . + /// is empty or composed entirely of whitespace. + public string VectorStoreId + { + get => _vectorStoreId; + set => _vectorStoreId = Throw.IfNullOrWhitespace(value); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ImageGenerationToolCallContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ImageGenerationToolCallContent.cs new file mode 100644 index 00000000000..f5703a39e69 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ImageGenerationToolCallContent.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents the invocation of an image generation tool call by a hosted service. +/// +[Experimental("MEAI001")] +public sealed class ImageGenerationToolCallContent : AIContent +{ + /// + /// Initializes a new instance of the class. + /// + public ImageGenerationToolCallContent() + { + } + + /// + /// Gets or sets the unique identifier of the image generation item. + /// + public string? ImageId { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ImageGenerationToolResultContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ImageGenerationToolResultContent.cs new file mode 100644 index 00000000000..2ce6d5045f7 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/ImageGenerationToolResultContent.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents an image generation tool call invocation by a hosted service. +/// +/// +/// This content type represents when a hosted AI service invokes an image generation tool. +/// It is informational only and represents the call itself, not the result. +/// +[Experimental("MEAI001")] +public sealed class ImageGenerationToolResultContent : AIContent +{ + /// + /// Initializes a new instance of the class. + /// + public ImageGenerationToolResultContent() + { + } + + /// + /// Gets or sets the unique identifier of the image generation item. + /// + public string? ImageId { get; set; } + + /// + /// Gets or sets the generated content items. + /// + /// + /// Content is typically for images streamed from the tool, or for remotely hosted images, but + /// can also be provider-specific content types that represent the generated images. + /// + public IList? Outputs { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolApprovalRequestContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolApprovalRequestContent.cs new file mode 100644 index 00000000000..8f302d901b4 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolApprovalRequestContent.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a request for user approval of an MCP server tool call. +/// +[Experimental("MEAI001")] +public sealed class McpServerToolApprovalRequestContent : UserInputRequestContent +{ + /// + /// Initializes a new instance of the class. + /// + /// The ID that uniquely identifies the MCP server tool approval request/response pair. + /// The tool call that requires user approval. + /// is . + /// is empty or composed entirely of whitespace. + /// is . + public McpServerToolApprovalRequestContent(string id, McpServerToolCallContent toolCall) + : base(id) + { + ToolCall = Throw.IfNull(toolCall); + } + + /// + /// Gets the tool call that pre-invoke approval is required for. + /// + public McpServerToolCallContent ToolCall { get; } + + /// + /// Creates a to indicate whether the function call is approved or rejected based on the value of . + /// + /// if the function call is approved; otherwise, . + /// The representing the approval response. + public McpServerToolApprovalResponseContent CreateResponse(bool approved) => new(Id, approved); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolApprovalResponseContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolApprovalResponseContent.cs new file mode 100644 index 00000000000..0e239a79d7f --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolApprovalResponseContent.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.Diagnostics.CodeAnalysis; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a response to an MCP server tool approval request. +/// +[Experimental("MEAI001")] +public sealed class McpServerToolApprovalResponseContent : UserInputResponseContent +{ + /// + /// Initializes a new instance of the class. + /// + /// The ID that uniquely identifies the MCP server tool approval request/response pair. + /// if the MCP server tool call is approved; otherwise, . + /// is . + /// is empty or composed entirely of whitespace. + public McpServerToolApprovalResponseContent(string id, bool approved) + : base(id) + { + Approved = approved; + } + + /// + /// Gets a value indicating whether the user approved the request. + /// + public bool Approved { get; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolCallContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolCallContent.cs new file mode 100644 index 00000000000..3283c09a7ee --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolCallContent.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 Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a tool call request to a MCP server. +/// +/// +/// This content type is used to represent an invocation of an MCP server tool by a hosted service. +/// It is informational only. +/// +[Experimental("MEAI001")] +public sealed class McpServerToolCallContent : AIContent +{ + /// + /// Initializes a new instance of the class. + /// + /// The tool call ID. + /// The tool name. + /// The MCP server name that hosts the tool. + /// or is . + /// or is empty or composed entirely of whitespace. + public McpServerToolCallContent(string callId, string toolName, string? serverName) + { + CallId = Throw.IfNullOrWhitespace(callId); + ToolName = Throw.IfNullOrWhitespace(toolName); + ServerName = serverName; + } + + /// + /// Gets the tool call ID. + /// + public string CallId { get; } + + /// + /// Gets the name of the tool called. + /// + public string ToolName { get; } + + /// + /// Gets the name of the MCP server that hosts the tool. + /// + public string? ServerName { get; } + + /// + /// Gets or sets the arguments used for the tool call. + /// + public IReadOnlyDictionary? Arguments { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolResultContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolResultContent.cs new file mode 100644 index 00000000000..b8329c74d99 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolResultContent.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents the result of a MCP server tool call. +/// +/// +/// This content type is used to represent the result of an invocation of an MCP server tool by a hosted service. +/// It is informational only. +/// +[Experimental("MEAI001")] +public sealed class McpServerToolResultContent : AIContent +{ + /// + /// Initializes a new instance of the class. + /// + /// The tool call ID. + /// is . + /// is empty or composed entirely of whitespace. + public McpServerToolResultContent(string callId) + { + CallId = Throw.IfNullOrWhitespace(callId); + } + + /// + /// Gets the tool call ID. + /// + public string CallId { get; } + + /// + /// Gets or sets the output of the tool call. + /// + public IList? Output { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/TextContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/TextContent.cs index 0f70a5f8b0a..d6bac57420f 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/TextContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/TextContent.cs @@ -12,15 +12,13 @@ namespace Microsoft.Extensions.AI; [DebuggerDisplay("{DebuggerDisplay,nq}")] 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; } /// @@ -29,8 +27,8 @@ public TextContent(string? text) [AllowNull] public string Text { - get => _text ?? string.Empty; - set => _text = value; + get => field ?? string.Empty; + set; } /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/TextReasoningContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/TextReasoningContent.cs index ccf84af2e3d..57fec14cc0e 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/TextReasoningContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/TextReasoningContent.cs @@ -17,15 +17,13 @@ namespace Microsoft.Extensions.AI; [DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class TextReasoningContent : AIContent { - private string? _text; - /// /// Initializes a new instance of the class. /// /// The text reasoning content. public TextReasoningContent(string? text) { - _text = text; + Text = text; } /// @@ -34,10 +32,26 @@ public TextReasoningContent(string? text) [AllowNull] public string Text { - get => _text ?? string.Empty; - set => _text = value; + get => field ?? string.Empty; + set; } + /// Gets or sets an optional opaque blob of data associated with this reasoning content. + /// + /// + /// This property is used to store data from a provider that should be roundtripped back to the provider but that is not + /// intended for human consumption. It is often encrypted or otherwise redacted information that is only intended to be + /// sent back to the provider and not displayed to the user. It's possible for a to contain + /// only and have an empty property. This data also may be associated with + /// the corresponding , acting as a validation signature for it. + /// + /// + /// Note that whereas can be provider agnostic, + /// is provider-specific, and is likely to only be understood by the provider that created it. + /// + /// + public string? ProtectedData { get; set; } + /// public override string ToString() => Text; diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/TextSpanAnnotatedRegion.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/TextSpanAnnotatedRegion.cs new file mode 100644 index 00000000000..8ce3dbfa3c5 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/TextSpanAnnotatedRegion.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI; + +/// Describes a location in the associated based on starting and ending character indices. +/// This typically applies to . +[DebuggerDisplay("[{StartIndex}, {EndIndex})")] +public sealed class TextSpanAnnotatedRegion : AnnotatedRegion +{ + /// + /// Initializes a new instance of the class. + /// + public TextSpanAnnotatedRegion() + { + } + + /// + /// Gets or sets the start character index (inclusive) of the annotated span in the . + /// + [JsonPropertyName("start")] + public int? StartIndex { get; set; } + + /// + /// Gets or sets the end character index (exclusive) of the annotated span in the . + /// + [JsonPropertyName("end")] + public int? EndIndex { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UriContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UriContent.cs index 7beaa40efdf..37acd121960 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UriContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UriContent.cs @@ -30,7 +30,7 @@ public class UriContent : AIContent /// is . /// is . /// is an invalid media type. - /// is an invalid URL. + /// is an invalid URL. /// /// A media type must be specified, so that consumers know what to do with the content. /// If an exact media type is not known, but the category (e.g. image) is known, a wildcard @@ -67,6 +67,7 @@ public Uri Uri } /// Gets or sets the media type (also known as MIME type) for this content. + /// represents an invalid media type. public string MediaType { get => _mediaType; diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UserInputRequestContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UserInputRequestContent.cs new file mode 100644 index 00000000000..b2a2e0e6e95 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UserInputRequestContent.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.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a request for user input. +/// +[Experimental("MEAI001")] +[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] +[JsonDerivedType(typeof(FunctionApprovalRequestContent), "functionApprovalRequest")] +[JsonDerivedType(typeof(McpServerToolApprovalRequestContent), "mcpServerToolApprovalRequest")] +public class UserInputRequestContent : AIContent +{ + /// + /// Initializes a new instance of the class. + /// + /// The ID that uniquely identifies the user input request/response pair. + /// is . + /// is empty or composed entirely of whitespace. + protected UserInputRequestContent(string id) + { + Id = Throw.IfNullOrWhitespace(id); + } + + /// + /// Gets the ID that uniquely identifies the user input request/response pair. + /// + public string Id { get; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UserInputResponseContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UserInputResponseContent.cs new file mode 100644 index 00000000000..6902f047282 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UserInputResponseContent.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.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents the response to a request for user input. +/// +[Experimental("MEAI001")] +[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] +[JsonDerivedType(typeof(FunctionApprovalResponseContent), "functionApprovalResponse")] +[JsonDerivedType(typeof(McpServerToolApprovalResponseContent), "mcpServerToolApprovalResponse")] +public class UserInputResponseContent : AIContent +{ + /// + /// Initializes a new instance of the class. + /// + /// The ID that uniquely identifies the user input request/response pair. + /// is . + /// is empty or composed entirely of whitespace. + protected UserInputResponseContent(string id) + { + Id = Throw.IfNullOrWhitespace(id); + } + + /// + /// Gets the ID that uniquely identifies the user input request/response pair. + /// + public string Id { get; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGenerationOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGenerationOptions.cs index b9a13d43dd0..e5d8459351e 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGenerationOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGenerationOptions.cs @@ -10,12 +10,29 @@ namespace Microsoft.Extensions.AI; /// Represents the options for an embedding generation request. public class EmbeddingGenerationOptions { - private int? _dimensions; + /// Initializes a new instance of the class. + public EmbeddingGenerationOptions() + { + } + + /// Initializes a new instance of the class, performing a shallow copy of all properties from . + protected EmbeddingGenerationOptions(EmbeddingGenerationOptions? other) + { + if (other is null) + { + return; + } + + AdditionalProperties = other.AdditionalProperties?.Clone(); + Dimensions = other.Dimensions; + ModelId = other.ModelId; + RawRepresentationFactory = other.RawRepresentationFactory; + } /// Gets or sets the number of dimensions requested in the embedding. public int? Dimensions { - get => _dimensions; + get; set { if (value is not null) @@ -23,7 +40,7 @@ public int? Dimensions _ = Throw.IfLessThan(value.Value, 1, nameof(value)); } - _dimensions = value; + field = value; } } @@ -38,7 +55,7 @@ public int? Dimensions /// /// /// The underlying implementation may have its own representation of options. - /// When + /// When /// is invoked with an , that implementation may convert the provided options into /// its own representation in order to use it while performing the operation. For situations where a consumer knows /// which concrete is being used and how it represents options, a new instance of that @@ -46,7 +63,7 @@ public int? Dimensions /// implementation to use instead of creating a new instance. Such implementations may mutate the supplied options /// instance further based on other settings supplied on this instance or from other inputs, /// therefore, it is strongly recommended to not return shared instances and instead make the callback return a new instance on each call. - /// This is typically used to set an implementation-specific setting that isn't otherwise exposed from the strongly-typed + /// This is typically used to set an implementation-specific setting that isn't otherwise exposed from the strongly typed /// properties on . /// [JsonIgnore] @@ -58,11 +75,5 @@ public int? Dimensions /// 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() => - new() - { - ModelId = ModelId, - Dimensions = Dimensions, - AdditionalProperties = AdditionalProperties?.Clone(), - }; + public virtual EmbeddingGenerationOptions Clone() => new(this); } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGeneratorExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGeneratorExtensions.cs index 895b7bf7ea7..d4503f57c2b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGeneratorExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Embeddings/EmbeddingGeneratorExtensions.cs @@ -8,9 +8,6 @@ using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; -#pragma warning disable S2302 // "nameof" should be used -#pragma warning disable S4136 // Method overloads should be grouped together - namespace Microsoft.Extensions.AI; /// Provides a collection of static methods for extending instances. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunction.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunction.cs index 3910040d0a0..9af299013e5 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunction.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunction.cs @@ -9,41 +9,12 @@ namespace Microsoft.Extensions.AI; /// Represents a function that can be described to an AI service and invoked. -public abstract class AIFunction : AITool +public abstract class AIFunction : AIFunctionDeclaration { - /// Gets a JSON Schema describing the function and its input parameters. - /// - /// - /// When specified, declares a self-contained JSON schema document that describes the function and its input parameters. - /// A simple example of a JSON schema for a function that adds two numbers together is shown below: - /// - /// - /// { - /// "title" : "addNumbers", - /// "description": "A simple function that adds two numbers together.", - /// "type": "object", - /// "properties": { - /// "a" : { "type": "number" }, - /// "b" : { "type": "number", "default": 1 } - /// }, - /// "required" : ["a"] - /// } - /// - /// - /// The metadata present in the schema document plays an important role in guiding AI function invocation. - /// - /// - /// When no schema is specified, consuming chat clients should assume the "{}" or "true" schema, indicating that any JSON input is admissible. - /// - /// - public virtual JsonElement JsonSchema => AIJsonUtilities.DefaultJsonSchema; - - /// Gets a JSON Schema describing the function's return value. - /// - /// A typically reflects a function that doesn't specify a return schema - /// or a function that returns , , or . - /// - public virtual JsonElement? ReturnJsonSchema => null; + /// Initializes a new instance of the class. + protected AIFunction() + { + } /// /// Gets the underlying that this might be wrapping. @@ -72,4 +43,14 @@ public abstract class AIFunction : AITool protected abstract ValueTask InvokeCoreAsync( AIFunctionArguments arguments, CancellationToken cancellationToken); + + /// Creates a representation of this that can't be invoked. + /// The created instance. + /// + /// derives from , layering on the ability to invoke the function in addition + /// to describing it. creates a new object that describes the function but that can't be invoked. + /// + public AIFunctionDeclaration AsDeclarationOnly() => new NonInvocableAIFunction(this); + + private sealed class NonInvocableAIFunction(AIFunction function) : DelegatingAIFunctionDeclaration(function); } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionArguments.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionArguments.cs index 3238b88e532..4a7c9a555ce 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionArguments.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionArguments.cs @@ -9,8 +9,6 @@ #pragma warning disable SA1111 // Closing parenthesis should be on line of last parameter #pragma warning disable SA1112 // Closing parenthesis should be on line of opening parenthesis #pragma warning disable SA1114 // Parameter list should follow declaration -#pragma warning disable S3358 // Extract this nested ternary operation into an independent statement. -#pragma warning disable S1067 // Expressions should not be too complex #pragma warning disable S4039 // Make 'AIFunctionArguments' sealed #pragma warning disable CA1033 // Make 'AIFunctionArguments' sealed #pragma warning disable CA1710 // Identifiers should have correct suffix @@ -79,14 +77,10 @@ public AIFunctionArguments(IEqualityComparer? comparer) /// public AIFunctionArguments(IDictionary? arguments, IEqualityComparer? comparer) { -#pragma warning disable S1698 // Consider using 'Equals' if value comparison is intended. _arguments = - arguments is null - ? new Dictionary(comparer) - : (arguments is Dictionary dc) && (comparer is null || dc.Comparer == comparer) - ? dc - : new Dictionary(arguments, comparer); -#pragma warning restore S1698 // Consider using 'Equals' if value comparison is intended. + arguments is null ? new(comparer) : + arguments is Dictionary dc && (comparer is null || ReferenceEquals(dc.Comparer, comparer)) ? dc : + new(arguments, comparer); } /// Gets or sets services optionally associated with these arguments. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionDeclaration.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionDeclaration.cs new file mode 100644 index 00000000000..203045f92b2 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionDeclaration.cs @@ -0,0 +1,54 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.AI; + +/// Represents a function that can be described to an AI service. +/// +/// is the base class for , which +/// adds the ability to invoke the function. Components can type test instances +/// for to determine whether they can be described as functions, +/// and can type test for to determine whether they can be invoked. +/// +public abstract class AIFunctionDeclaration : AITool +{ + /// Initializes a new instance of the class. + protected AIFunctionDeclaration() + { + } + + /// Gets a JSON Schema describing the function and its input parameters. + /// + /// + /// When specified, declares a self-contained JSON schema document that describes the function and its input parameters. + /// A simple example of a JSON schema for a function that adds two numbers together is shown below: + /// + /// + /// { + /// "type": "object", + /// "properties": { + /// "a" : { "type": "number" }, + /// "b" : { "type": ["number","null"], "default": 1 } + /// }, + /// "required" : ["a"] + /// } + /// + /// + /// The metadata present in the schema document plays an important role in guiding AI function invocation. + /// + /// + /// When no schema is specified, consuming chat clients should assume the "{}" or "true" schema, indicating that any JSON input is admissible. + /// + /// + public virtual JsonElement JsonSchema => AIJsonUtilities.DefaultJsonSchema; + + /// Gets a JSON Schema describing the function's return value. + /// + /// A typically reflects a function that doesn't specify a return schema + /// or a function that returns , , or . + /// + public virtual JsonElement? ReturnJsonSchema => null; +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs index 5ad178e7bc8..7daa5a49340 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs @@ -22,11 +22,7 @@ using Microsoft.Shared.Collections; using Microsoft.Shared.Diagnostics; -#pragma warning disable CA1031 // Do not catch general exception types -#pragma warning disable S2333 // Redundant modifiers should not be used #pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields -#pragma warning disable SA1202 // Public members should come before private members -#pragma warning disable SA1203 // Constants should appear before fields namespace Microsoft.Extensions.AI; @@ -49,13 +45,13 @@ public static partial class AIFunctionFactory /// /// By default, any parameters to are sourced from the 's dictionary /// of key/value pairs and are represented in the JSON schema for the function, as exposed in the returned 's - /// . There are a few exceptions to this: + /// . There are a few exceptions to this: /// /// /// /// parameters are automatically bound to the passed into /// the invocation via 's parameter. The parameter is - /// not included in the generated JSON schema. The behavior of parameters may not be overridden. + /// not included in the generated JSON schema. The behavior of parameters can't be overridden. /// /// /// @@ -64,7 +60,7 @@ public static partial class AIFunctionFactory /// and are not included in the JSON schema. If the parameter is optional, such that a default value is provided, /// is allowed to be ; otherwise, /// must be non-, or else the invocation will fail with an exception due to the required nature of the parameter. - /// The handling of parameters may be overridden via . + /// The handling of parameters can be overridden via . /// /// /// @@ -72,19 +68,19 @@ public static partial class AIFunctionFactory /// By default, parameters are bound directly to instance /// passed into and are not included in the JSON schema. If the /// instance passed to is , the implementation - /// manufactures an empty instance, such that parameters of type may always be satisfied, whether - /// optional or not. The handling of parameters may be overridden via + /// manufactures an empty instance, such that parameters of type can always be satisfied, whether + /// optional or not. The handling of parameters can be overridden via /// . /// /// /// /// All other parameter types are, by default, bound from the dictionary passed into - /// and are included in the generated JSON schema. This may be overridden by the provided + /// and are included in the generated JSON schema. This can be overridden by the provided /// via the parameter; for every parameter, the delegate is enabled to choose if the parameter should be included in the /// generated schema and how its value should be bound, including handling of optionality (by default, required parameters that are not included in the - /// dictionary will result in an exception being thrown). Loosely-typed additional context information may be passed + /// dictionary will result in an exception being thrown). Loosely-typed additional context information can be passed /// into via the 's dictionary; the default - /// binding ignores this collection, but a custom binding supplied via may choose to + /// binding ignores this collection, but a custom binding supplied via can choose to /// source arguments from this data. /// /// @@ -97,13 +93,18 @@ public static partial class AIFunctionFactory /// /// In general, the data supplied via an 's dictionary is supplied from an AI service and should be considered /// unvalidated and untrusted. To provide validated and trusted data to the invocation of , consider having - /// point to an instance method on an instance configured to hold the appropriate state. An parameter may also be + /// point to an instance method on an instance configured to hold the appropriate state. An parameter can also be /// used to resolve services from a dependency injection container. /// /// /// By default, return values are serialized to using 's /// if provided, or else using . - /// Handling of return values may be overridden via . + /// However, return values whose declared type is , a derived type of , or + /// any type assignable from (e.g. AIContent[], List<AIContent>) are + /// special-cased and are not serialized: the created function returns the original instance(s) directly to enable + /// callers (such as an IChatClient) to perform type tests and implement specialized handling. If + /// is supplied, that delegate governs the behavior instead. + /// Handling of return values can be overridden via . /// /// /// is . @@ -119,7 +120,7 @@ public static AIFunction Create(Delegate method, AIFunctionFactoryOptions? optio /// The method to be represented via the created . /// /// The name to use for the . If , the name will be derived from - /// the name of . + /// any on , if available, or else from the name of . /// /// /// The description to use for the . If , a description will be derived from @@ -131,7 +132,7 @@ public static AIFunction Create(Delegate method, AIFunctionFactoryOptions? optio /// /// Any parameters to are sourced from the 's dictionary /// of key/value pairs and are represented in the JSON schema for the function, as exposed in the returned 's - /// . There are a few exceptions to this: + /// . There are a few exceptions to this: /// /// /// @@ -153,7 +154,7 @@ public static AIFunction Create(Delegate method, AIFunctionFactoryOptions? optio /// By default, parameters are bound directly to instance /// passed into and are not included in the JSON schema. If the /// instance passed to is , the implementation - /// manufactures an empty instance, such that parameters of type may always be satisfied, whether + /// manufactures an empty instance, such that parameters of type can always be satisfied, whether /// optional or not. /// /// @@ -171,12 +172,14 @@ public static AIFunction Create(Delegate method, AIFunctionFactoryOptions? optio /// /// In general, the data supplied via an 's dictionary is supplied from an AI service and should be considered /// unvalidated and untrusted. To provide validated and trusted data to the invocation of , consider having - /// point to an instance method on an instance configured to hold the appropriate state. An parameter may also be + /// point to an instance method on an instance configured to hold the appropriate state. An parameter can also be /// used to resolve services from a dependency injection container. /// /// /// Return values are serialized to using if provided, - /// or else using . + /// or else using . However, return values whose declared type is , a + /// derived type of , or any type assignable from are not serialized; + /// they are returned as-is to facilitate specialized handling. /// /// /// is . @@ -212,13 +215,13 @@ public static AIFunction Create(Delegate method, string? name = null, string? de /// /// By default, any parameters to are sourced from the 's dictionary /// of key/value pairs and are represented in the JSON schema for the function, as exposed in the returned 's - /// . There are a few exceptions to this: + /// . There are a few exceptions to this: /// /// /// /// parameters are automatically bound to the passed into /// the invocation via 's parameter. The parameter is - /// not included in the generated JSON schema. The behavior of parameters may not be overridden. + /// not included in the generated JSON schema. The behavior of parameters can't be overridden. /// /// /// @@ -227,7 +230,7 @@ public static AIFunction Create(Delegate method, string? name = null, string? de /// and are not included in the JSON schema. If the parameter is optional, such that a default value is provided, /// is allowed to be ; otherwise, /// must be non-, or else the invocation will fail with an exception due to the required nature of the parameter. - /// The handling of parameters may be overridden via . + /// The handling of parameters can be overridden via . /// /// /// @@ -235,19 +238,19 @@ public static AIFunction Create(Delegate method, string? name = null, string? de /// By default, parameters are bound directly to instance /// passed into and are not included in the JSON schema. If the /// instance passed to is , the implementation - /// manufactures an empty instance, such that parameters of type may always be satisfied, whether - /// optional or not. The handling of parameters may be overridden via + /// manufactures an empty instance, such that parameters of type can always be satisfied, whether + /// optional or not. The handling of parameters can be overridden via /// . /// /// /// /// All other parameter types are, by default, bound from the dictionary passed into - /// and are included in the generated JSON schema. This may be overridden by the provided + /// and are included in the generated JSON schema. This can be overridden by the provided /// via the parameter; for every parameter, the delegate is enabled to choose if the parameter should be included in the /// generated schema and how its value should be bound, including handling of optionality (by default, required parameters that are not included in the - /// dictionary will result in an exception being thrown). Loosely-typed additional context information may be passed + /// dictionary will result in an exception being thrown). Loosely typed additional context information can be passed /// into via the 's dictionary; the default - /// binding ignores this collection, but a custom binding supplied via may choose to + /// binding ignores this collection, but a custom binding supplied via can choose to /// source arguments from this data. /// /// @@ -260,13 +263,15 @@ public static AIFunction Create(Delegate method, string? name = null, string? de /// /// In general, the data supplied via an 's dictionary is supplied from an AI service and should be considered /// unvalidated and untrusted. To provide validated and trusted data to the invocation of , consider having - /// point to an instance method on an instance configured to hold the appropriate state. An parameter may also be + /// point to an instance method on an instance configured to hold the appropriate state. An parameter can also be /// used to resolve services from a dependency injection container. /// /// /// By default, return values are serialized to using 's /// if provided, or else using . - /// Handling of return values may be overridden via . + /// However, return values whose declared type is , a derived type of , or + /// any type assignable from are not serialized and are instead returned directly. + /// Handling of return values can be overridden via . /// /// /// is . @@ -292,7 +297,7 @@ public static AIFunction Create(MethodInfo method, object? target, AIFunctionFac /// /// /// The name to use for the . If , the name will be derived from - /// the name of . + /// any on , if available, or else from the name of . /// /// /// The description to use for the . If , a description will be derived from @@ -304,7 +309,7 @@ public static AIFunction Create(MethodInfo method, object? target, AIFunctionFac /// /// Any parameters to are sourced from the 's dictionary /// of key/value pairs and are represented in the JSON schema for the function, as exposed in the returned 's - /// . There are a few exceptions to this: + /// . There are a few exceptions to this: /// /// /// @@ -326,7 +331,7 @@ public static AIFunction Create(MethodInfo method, object? target, AIFunctionFac /// By default, parameters are bound directly to instance /// passed into and are not included in the JSON schema. If the /// instance passed to is , the implementation - /// manufactures an empty instance, such that parameters of type may always be satisfied, whether + /// manufactures an empty instance, such that parameters of type can always be satisfied, whether /// optional or not. /// /// @@ -344,12 +349,14 @@ public static AIFunction Create(MethodInfo method, object? target, AIFunctionFac /// /// In general, the data supplied via an 's dictionary is supplied from an AI service and should be considered /// unvalidated and untrusted. To provide validated and trusted data to the invocation of , consider having - /// point to an instance method on an instance configured to hold the appropriate state. An parameter may also be + /// point to an instance method on an instance configured to hold the appropriate state. An parameter can also be /// used to resolve services from a dependency injection container. /// /// /// Return values are serialized to using if provided, - /// or else using . + /// or else using . However, return values whose declared type is , a + /// derived type of , or any type assignable from are returned + /// without serialization to enable specialized handling. /// /// /// is . @@ -398,13 +405,13 @@ public static AIFunction Create(MethodInfo method, object? target, string? name /// /// By default, any parameters to are sourced from the 's dictionary /// of key/value pairs and are represented in the JSON schema for the function, as exposed in the returned 's - /// . There are a few exceptions to this: + /// . There are a few exceptions to this: /// /// /// /// parameters are automatically bound to the passed into /// the invocation via 's parameter. The parameter is - /// not included in the generated JSON schema. The behavior of parameters may not be overridden. + /// not included in the generated JSON schema. The behavior of parameters can't be overridden. /// /// /// @@ -413,7 +420,7 @@ public static AIFunction Create(MethodInfo method, object? target, string? name /// and are not included in the JSON schema. If the parameter is optional, such that a default value is provided, /// is allowed to be ; otherwise, /// must be non-, or else the invocation will fail with an exception due to the required nature of the parameter. - /// The handling of parameters may be overridden via . + /// The handling of parameters can be overridden via . /// /// /// @@ -421,19 +428,19 @@ public static AIFunction Create(MethodInfo method, object? target, string? name /// By default, parameters are bound directly to instance /// passed into and are not included in the JSON schema. If the /// instance passed to is , the implementation - /// manufactures an empty instance, such that parameters of type may always be satisfied, whether - /// optional or not. The handling of parameters may be overridden via + /// manufactures an empty instance, such that parameters of type can always be satisfied, whether + /// optional or not. The handling of parameters can be overridden via /// . /// /// /// /// All other parameter types are, by default, bound from the dictionary passed into - /// and are included in the generated JSON schema. This may be overridden by the provided + /// and are included in the generated JSON schema. This can be overridden by the provided /// via the parameter; for every parameter, the delegate is enabled to choose if the parameter should be included in the /// generated schema and how its value should be bound, including handling of optionality (by default, required parameters that are not included in the - /// dictionary will result in an exception being thrown). Loosely-typed additional context information may be passed + /// dictionary will result in an exception being thrown). Loosely-typed additional context information can be passed /// into via the 's dictionary; the default - /// binding ignores this collection, but a custom binding supplied via may choose to + /// binding ignores this collection, but a custom binding supplied via can choose to /// source arguments from this data. /// /// @@ -446,13 +453,15 @@ public static AIFunction Create(MethodInfo method, object? target, string? name /// /// In general, the data supplied via an 's dictionary is supplied from an AI service and should be considered /// unvalidated and untrusted. To provide validated and trusted data to the invocation of , the instance constructed - /// for each invocation may contain that data in it, such that it's then available to as instance data. - /// An parameter may also be used to resolve services from a dependency injection container. + /// for each invocation can contain that data in it, such that it's then available to as instance data. + /// An parameter can also be used to resolve services from a dependency injection container. /// /// /// By default, return values are serialized to using 's /// if provided, or else using . - /// Handling of return values may be overridden via . + /// However, return values whose declared type is , a derived type of , or any type + /// assignable from are returned directly without serialization. + /// Handling of return values can be overridden via . /// /// /// is . @@ -467,6 +476,39 @@ public static AIFunction Create( AIFunctionFactoryOptions? options = null) => ReflectionAIFunction.Build(method, createInstanceFunc, options ?? _defaultOptions); + /// Creates an using the specified parameters as the implementation of its corresponding properties. + /// The name of the function. + /// A description of the function, suitable for use in describing the purpose to a model. + /// A JSON schema describing the function and its input parameters. + /// A JSON schema describing the function's return value. + /// The created that describes a function. + /// is . + /// + /// creates an that can be used to describe a function + /// but not invoke it. To create an invocable , use Create. A non-invocable + /// can also be created from an invocable using that function's method. + /// + public static AIFunctionDeclaration CreateDeclaration( + string name, + string? description, + JsonElement jsonSchema, + JsonElement? returnJsonSchema = null) => + new DefaultAIFunctionDeclaration( + Throw.IfNullOrEmpty(name), + description ?? string.Empty, + jsonSchema, + returnJsonSchema); + + private sealed class DefaultAIFunctionDeclaration( + string name, string description, JsonElement jsonSchema, JsonElement? returnJsonSchema) : + AIFunctionDeclaration + { + public override string Name => name; + public override string Description => description; + public override JsonElement JsonSchema => jsonSchema; + public override JsonElement? ReturnJsonSchema => returnJsonSchema; + } + private sealed class ReflectionAIFunction : AIFunction { public static ReflectionAIFunction Build(MethodInfo method, object? target, AIFunctionFactoryOptions options) @@ -615,7 +657,7 @@ public static ReflectionAIFunctionDescriptor GetOrCreate(MethodInfo method, AIFu serializerOptions.MakeReadOnly(); ConcurrentDictionary innerCache = _descriptorCache.GetOrCreateValue(serializerOptions); - DescriptorKey key = new(method, options.Name, options.Description, options.ConfigureParameterBinding, options.MarshalResult, schemaOptions); + DescriptorKey key = new(method, options.Name, options.Description, options.ConfigureParameterBinding, options.MarshalResult, options.ExcludeResultSchema, schemaOptions); if (innerCache.TryGetValue(key, out ReflectionAIFunctionDescriptor? descriptor)) { return descriptor; @@ -687,11 +729,11 @@ private ReflectionAIFunctionDescriptor(DescriptorKey key, JsonSerializerOptions ReturnParameterMarshaller = GetReturnParameterMarshaller(key, serializerOptions, out Type? returnType); Method = key.Method; - Name = key.Name ?? GetFunctionName(key.Method); + Name = key.Name ?? key.Method.GetCustomAttribute(inherit: true)?.DisplayName ?? GetFunctionName(key.Method); Description = key.Description ?? key.Method.GetCustomAttribute(inherit: true)?.Description ?? string.Empty; JsonSerializerOptions = serializerOptions; - ReturnJsonSchema = returnType is null ? null : AIJsonUtilities.CreateJsonSchema( - returnType, + ReturnJsonSchema = returnType is null || key.ExcludeResultSchema ? null : AIJsonUtilities.CreateJsonSchema( + NormalizeReturnType(returnType, serializerOptions), description: key.Method.ReturnParameter.GetCustomAttribute(inherit: true)?.Description, serializerOptions: serializerOptions, inferenceOptions: schemaOptions); @@ -720,11 +762,21 @@ private static string GetFunctionName(MethodInfo method) string name = SanitizeMemberName(method.Name); const string AsyncSuffix = "Async"; - if (IsAsyncMethod(method) && - name.EndsWith(AsyncSuffix, StringComparison.Ordinal) && - name.Length > AsyncSuffix.Length) + if (IsAsyncMethod(method)) { - name = name.Substring(0, name.Length - AsyncSuffix.Length); + // If the method ends in "Async" or contains "Async_", remove the "Async". + int asyncIndex = name.LastIndexOf(AsyncSuffix, StringComparison.Ordinal); + if (asyncIndex > 0 && + (asyncIndex + AsyncSuffix.Length == name.Length || + ((asyncIndex + AsyncSuffix.Length < name.Length) && (name[asyncIndex + AsyncSuffix.Length] == '_')))) + { + name = +#if NET + string.Concat(name.AsSpan(0, asyncIndex), name.AsSpan(asyncIndex + AsyncSuffix.Length)); +#else + string.Concat(name.Substring(0, asyncIndex), name.Substring(asyncIndex + AsyncSuffix.Length)); +#endif + } } return name; @@ -792,10 +844,11 @@ static bool IsAsyncMethod(MethodInfo method) // For IServiceProvider parameters, we bind to the services passed to InvokeAsync via AIFunctionArguments. if (parameterType == typeof(IServiceProvider)) { + bool hasDefault = AIJsonUtilities.TryGetEffectiveDefaultValue(parameter, out _); return (arguments, _) => { IServiceProvider? services = arguments.Services; - if (!parameter.HasDefaultValue && services is null) + if (!hasDefault && services is null) { ThrowNullServices(parameter.Name); } @@ -807,6 +860,7 @@ static bool IsAsyncMethod(MethodInfo method) // For all other parameters, create a marshaller that tries to extract the value from the arguments dictionary. // Resolve the contract used to marshal the value from JSON -- can throw if not supported or not found. JsonTypeInfo? typeInfo = serializerOptions.GetTypeInfo(parameterType); + bool hasDefaultValue = AIJsonUtilities.TryGetEffectiveDefaultValue(parameter, out object? effectiveDefaultValue); return (arguments, _) => { // If the parameter has an argument specified in the dictionary, return that argument. @@ -855,13 +909,13 @@ static bool IsAsyncMethod(MethodInfo method) } // If the parameter is required and there's no argument specified for it, throw. - if (!parameter.HasDefaultValue) + if (!hasDefaultValue) { Throw.ArgumentException(nameof(arguments), $"The arguments dictionary is missing a value for the required parameter '{parameter.Name}'."); } // Otherwise, use the optional parameter's default value. - return parameter.DefaultValue; + return effectiveDefaultValue; }; // Throws an ArgumentNullException indicating that AIFunctionArguments.Services must be provided. @@ -939,6 +993,7 @@ static void ThrowNullServices(string parameterName) => MethodInfo taskResultGetter = GetMethodFromGenericMethodDefinition(returnType, _taskGetResult); returnType = taskResultGetter.ReturnType; + // If a MarshalResult delegate is provided, use it. if (marshalResult is not null) { return async (taskObj, cancellationToken) => @@ -949,6 +1004,18 @@ static void ThrowNullServices(string parameterName) => }; } + // Special-case AIContent results to not be serialized, so that IChatClients can type test and handle them + // specially, such as by returning content to the model/service in a manner appropriate to the content type. + if (IsAIContentRelatedType(returnType)) + { + return async (taskObj, cancellationToken) => + { + await ((Task)ThrowIfNullResult(taskObj)).ConfigureAwait(true); + return ReflectionInvoke(taskResultGetter, taskObj, null); + }; + } + + // For everything else, just serialize the result as-is. returnTypeInfo = serializerOptions.GetTypeInfo(returnType); return async (taskObj, cancellationToken) => { @@ -965,6 +1032,7 @@ static void ThrowNullServices(string parameterName) => MethodInfo asTaskResultGetter = GetMethodFromGenericMethodDefinition(valueTaskAsTask.ReturnType, _taskGetResult); returnType = asTaskResultGetter.ReturnType; + // If a MarshalResult delegate is provided, use it. if (marshalResult is not null) { return async (taskObj, cancellationToken) => @@ -976,6 +1044,19 @@ static void ThrowNullServices(string parameterName) => }; } + // Special-case AIContent results to not be serialized, so that IChatClients can type test and handle them + // specially, such as by returning content to the model/service in a manner appropriate to the content type. + if (IsAIContentRelatedType(returnType)) + { + return async (taskObj, cancellationToken) => + { + var task = (Task)ReflectionInvoke(valueTaskAsTask, ThrowIfNullResult(taskObj), null)!; + await task.ConfigureAwait(true); + return ReflectionInvoke(asTaskResultGetter, task, null); + }; + } + + // For everything else, just serialize the result as-is. returnTypeInfo = serializerOptions.GetTypeInfo(returnType); return async (taskObj, cancellationToken) => { @@ -987,13 +1068,21 @@ static void ThrowNullServices(string parameterName) => } } - // For everything else, just serialize the result as-is. + // If a MarshalResult delegate is provided, use it. if (marshalResult is not null) { Type returnTypeCopy = returnType; return (result, cancellationToken) => marshalResult(result, returnTypeCopy, cancellationToken); } + // Special-case AIContent results to not be serialized, so that IChatClients can type test and handle them + // specially, such as by returning content to the model/service in a manner appropriate to the content type. + if (IsAIContentRelatedType(returnType)) + { + return static (result, _) => new ValueTask(result); + } + + // For everything else, just serialize the result as-is. returnTypeInfo = serializerOptions.GetTypeInfo(returnType); return (result, cancellationToken) => SerializeResultAsync(result, returnTypeInfo, cancellationToken); @@ -1030,12 +1119,48 @@ private static MethodInfo GetMethodFromGenericMethodDefinition(Type specializedT #endif } + private static bool IsAIContentRelatedType(Type type) => + typeof(AIContent).IsAssignableFrom(type) || + typeof(IEnumerable).IsAssignableFrom(type); + + private static Type NormalizeReturnType(Type type, JsonSerializerOptions? options) + { + options ??= AIJsonUtilities.DefaultOptions; + + if (options == AIJsonUtilities.DefaultOptions && !options.TryGetTypeInfo(type, out _)) + { + // GetTypeInfo is not polymorphic, so attempts to look up derived types will fail even if the + // base type is registered. In some cases, though, we can fall back to using interfaces + // we know we have contracts for in AIJsonUtilities.DefaultOptions where the semantics of using + // that interface will be reasonable. This should really only affect situations where + // reflection-based serialization is disabled. + + if (typeof(IEnumerable).IsAssignableFrom(type)) + { + return typeof(IEnumerable); + } + + if (typeof(IEnumerable).IsAssignableFrom(type)) + { + return typeof(IEnumerable); + } + + if (typeof(IEnumerable).IsAssignableFrom(type)) + { + return typeof(IEnumerable); + } + } + + return type; + } + private record struct DescriptorKey( MethodInfo Method, string? Name, string? Description, Func? GetBindParameterOptions, Func>? MarshalResult, + bool ExcludeResultSchema, AIJsonSchemaCreateOptions SchemaOptions); } @@ -1075,16 +1200,37 @@ private record struct DescriptorKey( /// Replaces non-alphanumeric characters in the identifier with the underscore character. /// Primarily intended to remove characters produced by compiler-generated method name mangling. /// - private static string SanitizeMemberName(string memberName) => - InvalidNameCharsRegex().Replace(memberName, "_"); + private static string SanitizeMemberName(string memberName) + { + // Handle compiler-generated names (local functions and lambdas) + // Local functions: g__LocalFunctionName|ordinal_depth -> ContainingMethod_LocalFunctionName_ordinal_depth + // Lambdas: b__ordinal_depth -> ContainingMethod_ordinal_depth + if (CompilerGeneratedNameRegex().Match(memberName) is { Success: true } match) + { + memberName = $"{match.Groups[1].Value}_{match.Groups[2].Value}"; + } + + // Replace all non-alphanumeric characters with underscores. + return InvalidNameCharsRegex().Replace(memberName, "_"); + } + + /// Regex that matches compiler-generated names (local functions and lambdas). +#if NET + [GeneratedRegex(@"^<([^>]+)>\w__(.+)")] + private static partial Regex CompilerGeneratedNameRegex(); +#else + private static Regex CompilerGeneratedNameRegex() => _compilerGeneratedNameRegex; + private static readonly Regex _compilerGeneratedNameRegex = new(@"^<([^>]+)>\w__(.+)", RegexOptions.Compiled); +#endif - /// Regex that flags any character other than ASCII digits or letters or the underscore. + /// Regex that flags any character other than ASCII digits or letters. + /// Underscore isn't included so that sequences of underscores are replaced by a single one. #if NET - [GeneratedRegex("[^0-9A-Za-z_]")] + [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); + private static readonly Regex _invalidNameCharsRegex = new("[^0-9A-Za-z]+", RegexOptions.Compiled); #endif /// Invokes the MethodInfo with the specified target object and arguments. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactoryOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactoryOptions.cs index e71a4687422..5caef21900c 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactoryOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactoryOptions.cs @@ -39,7 +39,7 @@ public AIFunctionFactoryOptions() /// 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 . + /// The name to use for the function. The default value is a name derived from the passed or (for example, via a on the method). /// public string? Name { get; set; } @@ -90,7 +90,7 @@ public AIFunctionFactoryOptions() /// -returning methods). /// /// - /// Methods strongly-typed to return types of , , , + /// Methods strongly typed to return types of , , , /// and are special-cased. For methods typed to return or , /// will be invoked with the value after the returned task has successfully completed. /// For methods typed to return or , the delegate will be invoked with the @@ -106,6 +106,19 @@ public AIFunctionFactoryOptions() /// public Func>? MarshalResult { get; set; } + /// + /// Gets or sets a value indicating whether a schema should be created for the function's result type, if possible, and included as . + /// + /// + /// + /// The default value is . + /// + /// + /// When set to , results in the produced to always be . + /// + /// + public bool ExcludeResultSchema { get; set; } + /// Provides configuration options produced by the delegate. public readonly record struct ParameterBindingOptions { diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/ApprovalRequiredAIFunction.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/ApprovalRequiredAIFunction.cs new file mode 100644 index 00000000000..994e4660ac1 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/ApprovalRequiredAIFunction.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents an that can be described to an AI service and invoked, but for which +/// the invoker should obtain user approval before the function is actually invoked. +/// +/// +/// This class simply augments an with an indication that approval is required before invocation. +/// It does not enforce the requirement for user approval; it is the responsibility of the invoker to obtain that approval before invoking the function. +/// +[Experimental("MEAI001")] +public sealed class ApprovalRequiredAIFunction : DelegatingAIFunction +{ + /// + /// Initializes a new instance of the class. + /// + /// The represented by this instance. + /// is . + public ApprovalRequiredAIFunction(AIFunction innerFunction) + : base(innerFunction) + { + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/DelegatingAIFunction.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/DelegatingAIFunction.cs index a52c5acd959..263d1ad6739 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/DelegatingAIFunction.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/DelegatingAIFunction.cs @@ -9,8 +9,6 @@ using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; -#pragma warning disable SA1202 // Elements should be ordered by access - namespace Microsoft.Extensions.AI; /// @@ -58,4 +56,14 @@ protected DelegatingAIFunction(AIFunction innerFunction) /// protected override ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) => InnerFunction.InvokeAsync(arguments, cancellationToken); + + /// + public override object? GetService(Type serviceType, object? serviceKey = null) + { + _ = Throw.IfNull(serviceType); + + return + serviceKey is null && serviceType.IsInstanceOfType(this) ? this : + InnerFunction.GetService(serviceType, serviceKey); + } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/DelegatingAIFunctionDeclaration.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/DelegatingAIFunctionDeclaration.cs new file mode 100644 index 00000000000..38ebcf0ffd9 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/DelegatingAIFunctionDeclaration.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 System.Collections.Generic; +using System.Text.Json; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Provides an optional base class for an that passes through calls to another instance. +/// +internal class DelegatingAIFunctionDeclaration : AIFunctionDeclaration // could be made public in the future if there's demand +{ + /// + /// Initializes a new instance of the class as a wrapper around . + /// + /// The inner AI function to which all calls are delegated by default. + /// is . + protected DelegatingAIFunctionDeclaration(AIFunctionDeclaration innerFunction) + { + InnerFunction = Throw.IfNull(innerFunction); + } + + /// Gets the inner . + protected AIFunctionDeclaration InnerFunction { get; } + + /// + public override string Name => InnerFunction.Name; + + /// + public override string Description => InnerFunction.Description; + + /// + public override JsonElement JsonSchema => InnerFunction.JsonSchema; + + /// + public override JsonElement? ReturnJsonSchema => InnerFunction.ReturnJsonSchema; + + /// + public override IReadOnlyDictionary AdditionalProperties => InnerFunction.AdditionalProperties; + + /// + public override string ToString() => InnerFunction.ToString(); + + /// + public override object? GetService(Type serviceType, object? serviceKey = null) + { + _ = Throw.IfNull(serviceType); + + return + serviceKey is null && serviceType.IsInstanceOfType(this) ? this : + InnerFunction.GetService(serviceType, serviceKey); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedMcpServerToolAlwaysRequireApprovalMode.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedMcpServerToolAlwaysRequireApprovalMode.cs new file mode 100644 index 00000000000..388ffbc2f7f --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedMcpServerToolAlwaysRequireApprovalMode.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Extensions.AI; + +/// +/// Indicates that approval is always required for tool calls to a hosted MCP server. +/// +/// +/// Use to get an instance of . +/// +[Experimental("MEAI001")] +[DebuggerDisplay(nameof(AlwaysRequire))] +public sealed class HostedMcpServerToolAlwaysRequireApprovalMode : HostedMcpServerToolApprovalMode +{ + /// Initializes a new instance of the class. + /// Use to get an instance of . + public HostedMcpServerToolAlwaysRequireApprovalMode() + { + } + + /// + public override bool Equals(object? obj) => obj is HostedMcpServerToolAlwaysRequireApprovalMode; + + /// + public override int GetHashCode() => typeof(HostedMcpServerToolAlwaysRequireApprovalMode).GetHashCode(); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedMcpServerToolApprovalMode.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedMcpServerToolApprovalMode.cs new file mode 100644 index 00000000000..9bc1a7e6423 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedMcpServerToolApprovalMode.cs @@ -0,0 +1,48 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI; + +/// +/// Describes how approval is required for tool calls to a hosted MCP server. +/// +/// +/// The predefined values , and are provided to specify handling for all tools. +/// To specify approval behavior for individual tool names, use . +/// +[Experimental("MEAI001")] +[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] +[JsonDerivedType(typeof(HostedMcpServerToolNeverRequireApprovalMode), typeDiscriminator: "never")] +[JsonDerivedType(typeof(HostedMcpServerToolAlwaysRequireApprovalMode), typeDiscriminator: "always")] +[JsonDerivedType(typeof(HostedMcpServerToolRequireSpecificApprovalMode), typeDiscriminator: "requireSpecific")] +#pragma warning disable CA1052 // Static holder types should be Static or NotInheritable +public class HostedMcpServerToolApprovalMode +#pragma warning restore CA1052 +{ + /// + /// Gets a predefined indicating that all tool calls to a hosted MCP server always require approval. + /// + public static HostedMcpServerToolAlwaysRequireApprovalMode AlwaysRequire { get; } = new(); + + /// + /// Gets a predefined indicating that all tool calls to a hosted MCP server never require approval. + /// + public static HostedMcpServerToolNeverRequireApprovalMode NeverRequire { get; } = new(); + + private protected HostedMcpServerToolApprovalMode() + { + } + + /// + /// Instantiates a that specifies approval behavior for individual tool names. + /// + /// The list of tool names that always require approval. + /// The list of tool names that never require approval. + /// An instance of for the specified tool names. + public static HostedMcpServerToolRequireSpecificApprovalMode RequireSpecific(IList? alwaysRequireApprovalToolNames, IList? neverRequireApprovalToolNames) + => new(alwaysRequireApprovalToolNames, neverRequireApprovalToolNames); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedMcpServerToolNeverRequireApprovalMode.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedMcpServerToolNeverRequireApprovalMode.cs new file mode 100644 index 00000000000..bca80649f0d --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedMcpServerToolNeverRequireApprovalMode.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Extensions.AI; + +/// +/// Indicates that approval is never required for tool calls to a hosted MCP server. +/// +/// +/// Use to get an instance of . +/// +[Experimental("MEAI001")] +[DebuggerDisplay(nameof(NeverRequire))] +public sealed class HostedMcpServerToolNeverRequireApprovalMode : HostedMcpServerToolApprovalMode +{ + /// Initializes a new instance of the class. + /// Use to get an instance of . + public HostedMcpServerToolNeverRequireApprovalMode() + { + } + + /// + public override bool Equals(object? obj) => obj is HostedMcpServerToolNeverRequireApprovalMode; + + /// + public override int GetHashCode() => typeof(HostedMcpServerToolNeverRequireApprovalMode).GetHashCode(); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedMcpServerToolRequireSpecificApprovalMode.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedMcpServerToolRequireSpecificApprovalMode.cs new file mode 100644 index 00000000000..267b25334e1 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedMcpServerToolRequireSpecificApprovalMode.cs @@ -0,0 +1,86 @@ +// Licensed 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; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a mode where approval behavior is specified for individual tool names. +/// +[Experimental("MEAI001")] +public sealed class HostedMcpServerToolRequireSpecificApprovalMode : HostedMcpServerToolApprovalMode +{ + /// + /// Initializes a new instance of the class that specifies approval behavior for individual tool names. + /// + /// The list of tools names that always require approval. + /// The list of tools names that never require approval. + public HostedMcpServerToolRequireSpecificApprovalMode(IList? alwaysRequireApprovalToolNames, IList? neverRequireApprovalToolNames) + { + AlwaysRequireApprovalToolNames = alwaysRequireApprovalToolNames; + NeverRequireApprovalToolNames = neverRequireApprovalToolNames; + } + + /// + /// Gets or sets the list of tool names that always require approval. + /// + public IList? AlwaysRequireApprovalToolNames { get; set; } + + /// + /// Gets or sets the list of tool names that never require approval. + /// + public IList? NeverRequireApprovalToolNames { get; set; } + + /// + public override bool Equals(object? obj) => obj is HostedMcpServerToolRequireSpecificApprovalMode other && + ListEquals(AlwaysRequireApprovalToolNames, other.AlwaysRequireApprovalToolNames) && + ListEquals(NeverRequireApprovalToolNames, other.NeverRequireApprovalToolNames); + + /// + public override int GetHashCode() => + Combine(GetListHashCode(AlwaysRequireApprovalToolNames), GetListHashCode(NeverRequireApprovalToolNames)); + + private static bool ListEquals(IList? list1, IList? list2) => + ReferenceEquals(list1, list2) || + (list1 is not null && list2 is not null && list1.SequenceEqual(list2)); + + private static int GetListHashCode(IList? list) + { + if (list is null) + { + return 0; + } + +#if NET + HashCode hc = default; + for (int i = 0; i < list.Count; i++) + { + hc.Add(list[i]); + } + + return hc.ToHashCode(); +#else + int hash = 0; + for (int i = 0; i < list.Count; i++) + { + hash = Combine(hash, list[i]?.GetHashCode() ?? 0); + } + + return hash; +#endif + } + + private static int Combine(int h1, int h2) + { +#if NET + return HashCode.Combine(h1, h2); +#else + uint rol5 = ((uint)h1 << 5) | ((uint)h1 >> 27); + return ((int)rol5 + h1) ^ h2; +#endif + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/DelegatingImageGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/DelegatingImageGenerator.cs new file mode 100644 index 00000000000..91ffb136af5 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/DelegatingImageGenerator.cs @@ -0,0 +1,69 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.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 generators that can be chained in any order around an underlying . +/// The default implementation simply passes each call to the inner generator instance. +/// +[Experimental("MEAI001")] +public class DelegatingImageGenerator : IImageGenerator +{ + /// + /// Initializes a new instance of the class. + /// + /// The wrapped generator instance. + /// is . + protected DelegatingImageGenerator(IImageGenerator innerGenerator) + { + InnerGenerator = Throw.IfNull(innerGenerator); + } + + /// + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + /// Gets the inner . + protected IImageGenerator InnerGenerator { get; } + + /// + public virtual Task GenerateAsync( + ImageGenerationRequest request, ImageGenerationOptions? options = null, CancellationToken cancellationToken = default) + { + return InnerGenerator.GenerateAsync(request, options, cancellationToken); + } + + /// + public virtual object? GetService(Type serviceType, object? serviceKey = null) + { + _ = Throw.IfNull(serviceType); + + // If the key is non-null, we don't know what it means so pass through to the inner service. + return + serviceKey is null && serviceType.IsInstanceOfType(this) ? this : + InnerGenerator.GetService(serviceType, serviceKey); + } + + /// Provides a mechanism for releasing unmanaged resources. + /// if being called from ; otherwise, . + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + InnerGenerator.Dispose(); + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/IImageGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/IImageGenerator.cs new file mode 100644 index 00000000000..e630ecff8e9 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/IImageGenerator.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a generator of images. +/// +[Experimental("MEAI001")] +public interface IImageGenerator : IDisposable +{ + /// + /// Sends an image generation request and returns the generated image as a . + /// + /// The image generation request containing the prompt and optional original images for editing. + /// The image generation options to configure the request. + /// The to monitor for cancellation requests. The default is . + /// is . + /// The images generated by the . + Task GenerateAsync(ImageGenerationRequest request, ImageGenerationOptions? options = null, CancellationToken cancellationToken = default); + + /// Asks the for an object of the specified type . + /// The type of object being requested. + /// An optional key that can be used to help identify the target service. + /// The found object, otherwise . + /// is . + /// + /// The purpose of this method is to allow for the retrieval of strongly typed services that might be provided by the , + /// including itself or any services it might be wrapping. + /// + object? GetService(Type serviceType, object? serviceKey = null); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGenerationOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGenerationOptions.cs new file mode 100644 index 00000000000..586fbcc8bbe --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGenerationOptions.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.Diagnostics.CodeAnalysis; +using System.Drawing; +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI; + +/// Represents the options for an image generation request. +[Experimental("MEAI001")] +public class ImageGenerationOptions +{ + /// Initializes a new instance of the class. + public ImageGenerationOptions() + { + } + + /// Initializes a new instance of the class, performing a shallow copy of all properties from . + protected ImageGenerationOptions(ImageGenerationOptions? other) + { + if (other is null) + { + return; + } + + AdditionalProperties = other.AdditionalProperties?.Clone(); + Count = other.Count; + ImageSize = other.ImageSize; + MediaType = other.MediaType; + ModelId = other.ModelId; + RawRepresentationFactory = other.RawRepresentationFactory; + ResponseFormat = other.ResponseFormat; + } + + /// + /// Gets or sets the number of images to generate. + /// + public int? Count { get; set; } + + /// + /// Gets or sets the size of the generated image. + /// + /// + /// If a provider only supports fixed sizes, the closest supported size is used. + /// + public Size? ImageSize { get; set; } + + /// + /// Gets or sets the media type (also known as MIME type) of the generated image. + /// + public string? MediaType { get; set; } + + /// + /// Gets or sets the model ID to use for image generation. + /// + public string? ModelId { get; set; } + + /// + /// Gets or sets a callback responsible for creating the raw representation of the image generation options from an underlying implementation. + /// + /// + /// The underlying implementation can have its own representation of options. + /// When is invoked with an , + /// that implementation can convert the provided options into its own representation in order to use it while performing + /// the operation. For situations where a consumer knows which concrete is being used + /// and how it represents options, a new instance of that implementation-specific options type can be returned by this + /// callback for the implementation to use instead of creating a new instance. + /// Such implementations might mutate the supplied options instance further based on other settings supplied on this + /// instance or from other inputs, therefore, it is strongly recommended to not + /// return shared instances and instead make the callback return a new instance on each call. + /// This is typically used to set an implementation-specific setting that isn't otherwise exposed from the strongly typed + /// properties on . + /// + [JsonIgnore] + public Func? RawRepresentationFactory { get; set; } + + /// + /// Gets or sets the response format of the generated image. + /// + public ImageGenerationResponseFormat? ResponseFormat { get; set; } + + /// + /// Gets or sets the number of intermediate streaming images to generate. + /// + public int? StreamingCount { get; set; } + + /// Gets or sets any additional properties associated with the options. + public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } + + /// Produces a clone of the current instance. + /// A clone of the current instance. + public virtual ImageGenerationOptions Clone() => new(this); +} + +/// +/// Represents the requested response format of the generated image. +/// +/// +/// Not all implementations support all response formats and this value might be ignored by the implementation if not supported. +/// +[Experimental("MEAI001")] +public enum ImageGenerationResponseFormat +{ + /// + /// The generated image is returned as a URI pointing to the image resource. + /// + Uri, + + /// + /// The generated image is returned as in-memory image data. + /// + Data, + + /// + /// The generated image is returned as a hosted resource identifier, which can be used to retrieve the image later. + /// + Hosted, +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGenerationRequest.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGenerationRequest.cs new file mode 100644 index 00000000000..d519d08c731 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGenerationRequest.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.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Extensions.AI; + +/// Represents a request for image generation. +[Experimental("MEAI001")] +public class ImageGenerationRequest +{ + /// Initializes a new instance of the class. + public ImageGenerationRequest() + { + } + + /// Initializes a new instance of the class. + /// The prompt to guide the image generation. + public ImageGenerationRequest(string prompt) + { + Prompt = prompt; + } + + /// Initializes a new instance of the class. + /// The prompt to guide the image generation. + /// The original images to base edits on. + public ImageGenerationRequest(string prompt, IEnumerable? originalImages) + { + Prompt = prompt; + OriginalImages = originalImages; + } + + /// Gets or sets the prompt to guide the image generation. + public string? Prompt { get; set; } + + /// + /// Gets or sets the original images to base edits on. + /// + /// + /// If this property is set, the request will behave as an image edit operation. + /// If this property is null or empty, the request will behave as a new image generation operation. + /// + public IEnumerable? OriginalImages { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGenerationResponse.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGenerationResponse.cs new file mode 100644 index 00000000000..8f093634783 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGenerationResponse.cs @@ -0,0 +1,52 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace Microsoft.Extensions.AI; + +/// Represents the result of an image generation request. +[Experimental("MEAI001")] +public class ImageGenerationResponse +{ + /// Initializes a new instance of the class. + [JsonConstructor] + public ImageGenerationResponse() + { + } + + /// Initializes a new instance of the class. + /// The contents for this response. + public ImageGenerationResponse(IList? contents) + { + Contents = contents; + } + + /// Gets or sets the raw representation of the image generation response from an underlying implementation. + /// + /// If a is created to represent some underlying object from another object + /// model, this property can be used to store that original object. This can be useful for debugging or + /// for enabling a consumer to access the underlying object model if needed. + /// + [JsonIgnore] + public object? RawRepresentation { get; set; } + + /// + /// Gets or sets the generated content items. + /// + /// + /// Content is typically for images streamed from the generator, or for remotely hosted images, but + /// can also be provider-specific content types that represent the generated images. + /// + [AllowNull] + public IList Contents + { + get => field ??= []; + set; + } + + /// Gets or sets usage details for the image generation response. + public UsageDetails? Usage { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGeneratorExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGeneratorExtensions.cs new file mode 100644 index 00000000000..fe976231635 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGeneratorExtensions.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.CodeAnalysis; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides extension methods for . +[Experimental("MEAI001")] +public static class ImageGeneratorExtensions +{ + private static readonly Dictionary _extensionToMimeType = new(StringComparer.OrdinalIgnoreCase) + { + [".png"] = "image/png", + [".jpg"] = "image/jpeg", + [".jpeg"] = "image/jpeg", + [".webp"] = "image/webp", + [".gif"] = "image/gif", + [".bmp"] = "image/bmp", + [".tiff"] = "image/tiff", + [".tif"] = "image/tiff", + }; + + /// Asks the for an object of type . + /// The type of the object to be retrieved. + /// The generator. + /// An optional key that can be used to help identify the target service. + /// The found object, otherwise . + /// is . + /// + /// The purpose of this method is to allow for the retrieval of strongly typed services that may be provided by the , + /// including itself or any services it might be wrapping. + /// + public static TService? GetService(this IImageGenerator generator, object? serviceKey = null) + { + _ = Throw.IfNull(generator); + + return generator.GetService(typeof(TService), serviceKey) is TService service ? service : default; + } + + /// + /// Asks the for an object of the specified type + /// and throws an exception if one isn't available. + /// + /// The generator. + /// The type of object being requested. + /// An optional key that can be used to help identify the target service. + /// The found object. + /// is . + /// is . + /// No service of the requested type for the specified key is available. + /// + /// The purpose of this method is to allow for the retrieval of services that are required to be provided by the , + /// including itself or any services it might be wrapping. + /// + public static object GetRequiredService(this IImageGenerator generator, Type serviceType, object? serviceKey = null) + { + _ = Throw.IfNull(generator); + _ = Throw.IfNull(serviceType); + + return + generator.GetService(serviceType, serviceKey) ?? + throw Throw.CreateMissingServiceException(serviceType, serviceKey); + } + + /// + /// Asks the for an object of type + /// and throws an exception if one isn't available. + /// + /// The type of the object to be retrieved. + /// The generator. + /// An optional key that can be used to help identify the target service. + /// The found object. + /// is . + /// No service of the requested type for the specified key is available. + /// + /// The purpose of this method is to allow for the retrieval of strongly typed services that are required to be provided by the , + /// including itself or any services it might be wrapping. + /// + public static TService GetRequiredService(this IImageGenerator generator, object? serviceKey = null) + { + _ = Throw.IfNull(generator); + + if (generator.GetService(typeof(TService), serviceKey) is not TService service) + { + throw Throw.CreateMissingServiceException(typeof(TService), serviceKey); + } + + return service; + } + + /// + /// Generates images based on a text prompt. + /// + /// The image generator. + /// The prompt to guide the image generation. + /// The image generation options to configure the request. + /// The to monitor for cancellation requests. The default is . + /// or is . + /// The images generated by the generator. + public static Task GenerateImagesAsync( + this IImageGenerator generator, + string prompt, + ImageGenerationOptions? options = null, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(generator); + _ = Throw.IfNull(prompt); + + return generator.GenerateAsync(new ImageGenerationRequest(prompt), options, cancellationToken); + } + + /// + /// Edits images based on original images and a text prompt. + /// + /// The image generator. + /// The images to base edits on. + /// The prompt to guide the image editing. + /// The image generation options to configure the request. + /// The to monitor for cancellation requests. The default is . + /// , , or is . + /// The images generated by the generator. + public static Task EditImagesAsync( + this IImageGenerator generator, + IEnumerable originalImages, + string prompt, + ImageGenerationOptions? options = null, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(generator); + _ = Throw.IfNull(originalImages); + _ = Throw.IfNull(prompt); + + return generator.GenerateAsync(new ImageGenerationRequest(prompt, originalImages), options, cancellationToken); + } + + /// + /// Edits a single image based on the original image and the specified prompt. + /// + /// The image generator. + /// The single image to base edits on. + /// The prompt to guide the image generation. + /// The image generation options to configure the request. + /// The to monitor for cancellation requests. The default is . + /// , , or is . + /// The images generated by the generator. + public static Task EditImageAsync( + this IImageGenerator generator, + DataContent originalImage, + string prompt, + ImageGenerationOptions? options = null, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(generator); + _ = Throw.IfNull(originalImage); + _ = Throw.IfNull(prompt); + + return generator.GenerateAsync(new ImageGenerationRequest(prompt, [originalImage]), options, cancellationToken); + } + + /// + /// Edits a single image based on a byte array and the specified prompt. + /// + /// The image generator. + /// The byte array containing the image data to base edits on. + /// The filename for the image data. + /// The prompt to guide the image generation. + /// The image generation options to configure the request. + /// The to monitor for cancellation requests. The default is . + /// + /// , , or is . + /// + /// The images generated by the generator. + public static Task EditImageAsync( + this IImageGenerator generator, + ReadOnlyMemory originalImageData, + string fileName, + string prompt, + ImageGenerationOptions? options = null, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(generator); + _ = Throw.IfNull(fileName); + _ = Throw.IfNull(prompt); + + // Infer media type from file extension + string mediaType = GetMediaTypeFromFileName(fileName); + + var dataContent = new DataContent(originalImageData, mediaType) { Name = fileName }; + return generator.GenerateAsync(new ImageGenerationRequest(prompt, [dataContent]), options, cancellationToken); + } + + /// + /// Gets the media type based on the file extension. + /// + /// The filename to extract the media type from. + /// The inferred media type. + private static string GetMediaTypeFromFileName(string fileName) + { + string extension = Path.GetExtension(fileName); + + if (_extensionToMimeType.TryGetValue(extension, out string? mediaType)) + { + return mediaType; + } + + return "image/png"; // Default to PNG if unknown extension + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGeneratorMetadata.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGeneratorMetadata.cs new file mode 100644 index 00000000000..c5604155285 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Image/ImageGeneratorMetadata.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Extensions.AI; + +/// Provides metadata about an . +[Experimental("MEAI001")] +public class ImageGeneratorMetadata +{ + /// Initializes a new instance of the class. + /// + /// The name of the image 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 image generation provider, if applicable. + /// The ID of the image generation model used by default, if applicable. + public ImageGeneratorMetadata(string? providerName = null, Uri? providerUri = null, string? defaultModelId = null) + { + DefaultModelId = defaultModelId; + ProviderName = providerName; + ProviderUri = providerUri; + } + + /// Gets the name of the image 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 image generation provider. + public Uri? ProviderUri { get; } + + /// Gets the ID of the default model used by this image generator. + /// + /// This value can be if no default model is set on the corresponding . + /// An individual request may override this value via . + /// + public string? DefaultModelId { 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 f5472854def..22c7461de35 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj @@ -1,9 +1,10 @@ - + Microsoft.Extensions.AI Abstractions representing generative AI components. AI + true @@ -14,7 +15,6 @@ $(TargetFrameworks);netstandard2.0 - $(NoWarn);CA2227;CA1034;SA1316;S3253 $(NoWarn);MEAI001 true true @@ -22,7 +22,6 @@ true - true true true true @@ -30,7 +29,7 @@ true - + @@ -38,5 +37,5 @@ - + diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json index 5e87edc01f9..b5ef3774b45 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json @@ -123,6 +123,30 @@ } ] }, + { + "Type": "class Microsoft.Extensions.AI.AIAnnotation", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.AIAnnotation.AIAnnotation();", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "System.Collections.Generic.IList? Microsoft.Extensions.AI.AIAnnotation.AnnotatedRegions { get; set; }", + "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.AdditionalPropertiesDictionary? Microsoft.Extensions.AI.AIAnnotation.AdditionalProperties { get; set; }", + "Stage": "Stable" + }, + { + "Member": "object? Microsoft.Extensions.AI.AIAnnotation.RawRepresentation { get; set; }", + "Stage": "Stable" + } + ] + }, { "Type": "class Microsoft.Extensions.AI.AIContent", "Stage": "Stable", @@ -137,6 +161,10 @@ "Member": "Microsoft.Extensions.AI.AdditionalPropertiesDictionary? Microsoft.Extensions.AI.AIContent.AdditionalProperties { get; set; }", "Stage": "Stable" }, + { + "Member": "System.Collections.Generic.IList? Microsoft.Extensions.AI.AIContent.Annotations { get; set; }", + "Stage": "Stable" + }, { "Member": "object? Microsoft.Extensions.AI.AIContent.RawRepresentation { get; set; }", "Stage": "Stable" @@ -144,7 +172,7 @@ ] }, { - "Type": "abstract class Microsoft.Extensions.AI.AIFunction : Microsoft.Extensions.AI.AITool", + "Type": "abstract class Microsoft.Extensions.AI.AIFunction : Microsoft.Extensions.AI.AIFunctionDeclaration", "Stage": "Stable", "Methods": [ { @@ -158,23 +186,39 @@ { "Member": "abstract System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.AIFunction.InvokeCoreAsync(Microsoft.Extensions.AI.AIFunctionArguments arguments, System.Threading.CancellationToken cancellationToken);", "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.AIFunctionDeclaration Microsoft.Extensions.AI.AIFunction.AsDeclarationOnly();", + "Stage": "Stable" } ], "Properties": [ { - "Member": "virtual System.Text.Json.JsonElement Microsoft.Extensions.AI.AIFunction.JsonSchema { get; }", + "Member": "virtual System.Text.Json.JsonSerializerOptions Microsoft.Extensions.AI.AIFunction.JsonSerializerOptions { get; }", "Stage": "Stable" }, { - "Member": "virtual System.Text.Json.JsonSerializerOptions Microsoft.Extensions.AI.AIFunction.JsonSerializerOptions { get; }", + "Member": "virtual System.Reflection.MethodInfo? Microsoft.Extensions.AI.AIFunction.UnderlyingMethod { get; }", "Stage": "Stable" - }, + } + ] + }, + { + "Type": "abstract class Microsoft.Extensions.AI.AIFunctionDeclaration : Microsoft.Extensions.AI.AITool", + "Stage": "Stable", + "Methods": [ { - "Member": "virtual System.Text.Json.JsonElement? Microsoft.Extensions.AI.AIFunction.ReturnJsonSchema { get; }", + "Member": "Microsoft.Extensions.AI.AIFunctionDeclaration.AIFunctionDeclaration();", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "virtual System.Text.Json.JsonElement Microsoft.Extensions.AI.AIFunctionDeclaration.JsonSchema { get; }", "Stage": "Stable" }, { - "Member": "virtual System.Reflection.MethodInfo? Microsoft.Extensions.AI.AIFunction.UnderlyingMethod { get; }", + "Member": "virtual System.Text.Json.JsonElement? Microsoft.Extensions.AI.AIFunctionDeclaration.ReturnJsonSchema { get; }", "Stage": "Stable" } ] @@ -278,6 +322,10 @@ { "Member": "static Microsoft.Extensions.AI.AIFunction Microsoft.Extensions.AI.AIFunctionFactory.Create(System.Reflection.MethodInfo method, System.Func createInstanceFunc, Microsoft.Extensions.AI.AIFunctionFactoryOptions? options = null);", "Stage": "Stable" + }, + { + "Member": "static Microsoft.Extensions.AI.AIFunctionDeclaration Microsoft.Extensions.AI.AIFunctionFactory.CreateDeclaration(string name, string? description, System.Text.Json.JsonElement jsonSchema, System.Text.Json.JsonElement? returnJsonSchema = null);", + "Stage": "Stable" } ] }, @@ -303,6 +351,10 @@ "Member": "string? Microsoft.Extensions.AI.AIFunctionFactoryOptions.Description { get; set; }", "Stage": "Stable" }, + { + "Member": "bool Microsoft.Extensions.AI.AIFunctionFactoryOptions.ExcludeResultSchema { get; set; }", + "Stage": "Stable" + }, { "Member": "Microsoft.Extensions.AI.AIJsonSchemaCreateOptions? Microsoft.Extensions.AI.AIFunctionFactoryOptions.JsonSchemaCreateOptions { get; set; }", "Stage": "Stable" @@ -481,6 +533,10 @@ "Member": "System.Text.Json.JsonElement Microsoft.Extensions.AI.AIJsonSchemaTransformCache.GetOrCreateTransformedSchema(Microsoft.Extensions.AI.AIFunction function);", "Stage": "Stable" }, + { + "Member": "System.Text.Json.JsonElement Microsoft.Extensions.AI.AIJsonSchemaTransformCache.GetOrCreateTransformedSchema(Microsoft.Extensions.AI.AIFunctionDeclaration function);", + "Stage": "Stable" + }, { "Member": "System.Text.Json.JsonElement? Microsoft.Extensions.AI.AIJsonSchemaTransformCache.GetOrCreateTransformedSchema(Microsoft.Extensions.AI.ChatResponseFormatJson responseFormat);", "Stage": "Stable" @@ -629,6 +685,14 @@ "Member": "Microsoft.Extensions.AI.AITool.AITool();", "Stage": "Stable" }, + { + "Member": "virtual object? Microsoft.Extensions.AI.AITool.GetService(System.Type serviceType, object? serviceKey = null);", + "Stage": "Stable" + }, + { + "Member": "TService? Microsoft.Extensions.AI.AITool.GetService(object? serviceKey = null);", + "Stage": "Stable" + }, { "Member": "override string Microsoft.Extensions.AI.AITool.ToString();", "Stage": "Stable" @@ -649,6 +713,16 @@ } ] }, + { + "Type": "class Microsoft.Extensions.AI.AnnotatedRegion", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.AnnotatedRegion.AnnotatedRegion();", + "Stage": "Stable" + } + ] + }, { "Type": "sealed class Microsoft.Extensions.AI.AutoChatToolMode : Microsoft.Extensions.AI.ChatToolMode", "Stage": "Stable", @@ -879,6 +953,10 @@ "Member": "System.Collections.Generic.IList Microsoft.Extensions.AI.ChatMessage.Contents { get; set; }", "Stage": "Stable" }, + { + "Member": "System.DateTimeOffset? Microsoft.Extensions.AI.ChatMessage.CreatedAt { get; set; }", + "Stage": "Stable" + }, { "Member": "string? Microsoft.Extensions.AI.ChatMessage.MessageId { get; set; }", "Stage": "Stable" @@ -905,6 +983,10 @@ "Member": "Microsoft.Extensions.AI.ChatOptions.ChatOptions();", "Stage": "Stable" }, + { + "Member": "Microsoft.Extensions.AI.ChatOptions.ChatOptions(Microsoft.Extensions.AI.ChatOptions? other);", + "Stage": "Stable" + }, { "Member": "virtual Microsoft.Extensions.AI.ChatOptions Microsoft.Extensions.AI.ChatOptions.Clone();", "Stage": "Stable" @@ -1086,6 +1168,14 @@ { "Member": "static Microsoft.Extensions.AI.ChatResponseFormatJson Microsoft.Extensions.AI.ChatResponseFormat.ForJsonSchema(System.Text.Json.JsonElement schema, string? schemaName = null, string? schemaDescription = null);", "Stage": "Stable" + }, + { + "Member": "static Microsoft.Extensions.AI.ChatResponseFormatJson Microsoft.Extensions.AI.ChatResponseFormat.ForJsonSchema(System.Text.Json.JsonSerializerOptions? serializerOptions = null, string? schemaName = null, string? schemaDescription = null);", + "Stage": "Stable" + }, + { + "Member": "static Microsoft.Extensions.AI.ChatResponseFormatJson Microsoft.Extensions.AI.ChatResponseFormat.ForJsonSchema(System.Type schemaType, System.Text.Json.JsonSerializerOptions? serializerOptions = null, string? schemaName = null, string? schemaDescription = null);", + "Stage": "Stable" } ], "Properties": [ @@ -1157,6 +1247,10 @@ "Member": "Microsoft.Extensions.AI.ChatResponseUpdate.ChatResponseUpdate(Microsoft.Extensions.AI.ChatRole? role, System.Collections.Generic.IList? contents);", "Stage": "Stable" }, + { + "Member": "Microsoft.Extensions.AI.ChatResponseUpdate Microsoft.Extensions.AI.ChatResponseUpdate.Clone();", + "Stage": "Stable" + }, { "Member": "override string Microsoft.Extensions.AI.ChatResponseUpdate.ToString();", "Stage": "Stable" @@ -1315,6 +1409,38 @@ } ] }, + { + "Type": "class Microsoft.Extensions.AI.CitationAnnotation : Microsoft.Extensions.AI.AIAnnotation", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.CitationAnnotation.CitationAnnotation();", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "string? Microsoft.Extensions.AI.CitationAnnotation.Title { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.CitationAnnotation.ToolName { get; set; }", + "Stage": "Stable" + }, + { + "Member": "System.Uri? Microsoft.Extensions.AI.CitationAnnotation.Url { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.CitationAnnotation.FileId { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.CitationAnnotation.Snippet { get; set; }", + "Stage": "Stable" + } + ] + }, { "Type": "class Microsoft.Extensions.AI.DataContent : Microsoft.Extensions.AI.AIContent", "Stage": "Stable", @@ -1345,6 +1471,10 @@ "Member": "System.ReadOnlyMemory Microsoft.Extensions.AI.DataContent.Data { get; }", "Stage": "Stable" }, + { + "Member": "string? Microsoft.Extensions.AI.DataContent.Name { get; set; }", + "Stage": "Stable" + }, { "Member": "string Microsoft.Extensions.AI.DataContent.MediaType { get; }", "Stage": "Stable" @@ -1363,6 +1493,10 @@ "Member": "Microsoft.Extensions.AI.DelegatingAIFunction.DelegatingAIFunction(Microsoft.Extensions.AI.AIFunction innerFunction);", "Stage": "Stable" }, + { + "Member": "override object? Microsoft.Extensions.AI.DelegatingAIFunction.GetService(System.Type serviceType, object? serviceKey = null);", + "Stage": "Stable" + }, { "Member": "override System.Threading.Tasks.ValueTask Microsoft.Extensions.AI.DelegatingAIFunction.InvokeCoreAsync(Microsoft.Extensions.AI.AIFunctionArguments arguments, System.Threading.CancellationToken cancellationToken);", "Stage": "Stable" @@ -1567,6 +1701,10 @@ "Member": "Microsoft.Extensions.AI.EmbeddingGenerationOptions.EmbeddingGenerationOptions();", "Stage": "Stable" }, + { + "Member": "Microsoft.Extensions.AI.EmbeddingGenerationOptions.EmbeddingGenerationOptions(Microsoft.Extensions.AI.EmbeddingGenerationOptions? other);", + "Stage": "Stable" + }, { "Member": "virtual Microsoft.Extensions.AI.EmbeddingGenerationOptions Microsoft.Extensions.AI.EmbeddingGenerationOptions.Clone();", "Stage": "Stable" @@ -1813,6 +1951,40 @@ "Member": "Microsoft.Extensions.AI.HostedCodeInterpreterTool.HostedCodeInterpreterTool();", "Stage": "Stable" } + ], + "Properties": [ + { + "Member": "System.Collections.Generic.IList? Microsoft.Extensions.AI.HostedCodeInterpreterTool.Inputs { get; set; }", + "Stage": "Stable" + }, + { + "Member": "override string Microsoft.Extensions.AI.HostedCodeInterpreterTool.Name { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.HostedFileSearchTool : Microsoft.Extensions.AI.AITool", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.HostedFileSearchTool.HostedFileSearchTool();", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "System.Collections.Generic.IList? Microsoft.Extensions.AI.HostedFileSearchTool.Inputs { get; set; }", + "Stage": "Stable" + }, + { + "Member": "int? Microsoft.Extensions.AI.HostedFileSearchTool.MaximumResultCount { get; set; }", + "Stage": "Stable" + }, + { + "Member": "override string Microsoft.Extensions.AI.HostedFileSearchTool.Name { get; }", + "Stage": "Stable" + } ] }, { @@ -1823,6 +1995,56 @@ "Member": "Microsoft.Extensions.AI.HostedWebSearchTool.HostedWebSearchTool();", "Stage": "Stable" } + ], + "Properties": [ + { + "Member": "override string Microsoft.Extensions.AI.HostedWebSearchTool.Name { get; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.HostedFileContent : Microsoft.Extensions.AI.AIContent", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.HostedFileContent.HostedFileContent(string fileId);", + "Stage": "Stable" + }, + { + "Member": "bool Microsoft.Extensions.AI.HostedFileContent.HasTopLevelMediaType(string topLevelType);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "string Microsoft.Extensions.AI.HostedFileContent.FileId { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.HostedFileContent.MediaType { get; set; }", + "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.HostedFileContent.Name { get; set; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.HostedVectorStoreContent : Microsoft.Extensions.AI.AIContent", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.HostedVectorStoreContent.HostedVectorStoreContent(string vectorStoreId);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "string Microsoft.Extensions.AI.HostedVectorStoreContent.VectorStoreId { get; set; }", + "Stage": "Stable" + } ] }, { @@ -2258,6 +2480,30 @@ { "Member": "string Microsoft.Extensions.AI.TextReasoningContent.Text { get; set; }", "Stage": "Stable" + }, + { + "Member": "string? Microsoft.Extensions.AI.TextReasoningContent.ProtectedData { get; set; }", + "Stage": "Stable" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.TextSpanAnnotatedRegion : Microsoft.Extensions.AI.AnnotatedRegion", + "Stage": "Stable", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.TextSpanAnnotatedRegion.TextSpanAnnotatedRegion();", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "int? Microsoft.Extensions.AI.TextSpanAnnotatedRegion.StartIndex { get; set; }", + "Stage": "Stable" + }, + { + "Member": "int? Microsoft.Extensions.AI.TextSpanAnnotatedRegion.EndIndex { get; set; }", + "Stage": "Stable" } ] }, diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ResponseContinuationToken.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ResponseContinuationToken.cs new file mode 100644 index 00000000000..cf73130be10 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ResponseContinuationToken.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.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a token used to resume, continue, or rehydrate an operation across multiple scenarios/calls, +/// such as resuming a streamed response from a specific point or retrieving the result of a background operation. +/// Subclasses of this class encapsulate all necessary information within the token to facilitate these actions. +/// +[JsonConverter(typeof(Converter))] +[Experimental("MEAI001")] +public class ResponseContinuationToken +{ + /// Bytes representing this token. + private readonly ReadOnlyMemory _bytes; + + /// Initializes a new instance of the class. + protected ResponseContinuationToken() + { + } + + /// Initializes a new instance of the class. + /// Bytes to create the token from. + protected ResponseContinuationToken(ReadOnlyMemory bytes) + { + _bytes = bytes; + } + + /// Create a new instance of from the provided . + /// + /// Bytes representing the . + /// A equivalent to the one from which + /// the original bytes were obtained. + public static ResponseContinuationToken FromBytes(ReadOnlyMemory bytes) => new(bytes); + + /// Gets the bytes representing this . + /// Bytes representing the ."/> + public virtual ReadOnlyMemory ToBytes() => _bytes; + + /// Provides a for serializing instances. + [EditorBrowsable(EditorBrowsableState.Never)] + [Experimental("MEAI001")] + public sealed class Converter : JsonConverter + { + /// + public override ResponseContinuationToken Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return ResponseContinuationToken.FromBytes(reader.GetBytesFromBase64()); + } + + /// + public override void Write(Utf8JsonWriter writer, ResponseContinuationToken value, JsonSerializerOptions options) + { + _ = Throw.IfNull(writer); + _ = Throw.IfNull(value); + + writer.WriteBase64StringValue(value.ToBytes().Span); + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/SpeechToTextOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/SpeechToTextOptions.cs index 8efbf510164..0e93a9bb1af 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/SpeechToTextOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/SpeechToTextOptions.cs @@ -11,6 +11,27 @@ namespace Microsoft.Extensions.AI; [Experimental("MEAI001")] public class SpeechToTextOptions { + /// Initializes a new instance of the class. + public SpeechToTextOptions() + { + } + + /// Initializes a new instance of the class, performing a shallow copy of all properties from . + protected SpeechToTextOptions(SpeechToTextOptions? other) + { + if (other is null) + { + return; + } + + AdditionalProperties = other.AdditionalProperties?.Clone(); + ModelId = other.ModelId; + RawRepresentationFactory = other.RawRepresentationFactory; + SpeechLanguage = other.SpeechLanguage; + SpeechSampleRate = other.SpeechSampleRate; + TextLanguage = other.TextLanguage; + } + /// Gets or sets any additional properties associated with the options. public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } @@ -39,7 +60,7 @@ public class SpeechToTextOptions /// implementation to use instead of creating a new instance. Such implementations may mutate the supplied options /// instance further based on other settings supplied on this instance or from other inputs, /// therefore, it is strongly recommended to not return shared instances and instead make the callback return a new instance on each call. - /// This is typically used to set an implementation-specific setting that isn't otherwise exposed from the strongly-typed + /// This is typically used to set an implementation-specific setting that isn't otherwise exposed from the strongly typed /// properties on . /// [JsonIgnore] @@ -47,17 +68,5 @@ public class SpeechToTextOptions /// Produces a clone of the current instance. /// A clone of the current instance. - public virtual SpeechToTextOptions Clone() - { - SpeechToTextOptions options = new() - { - AdditionalProperties = AdditionalProperties?.Clone(), - ModelId = ModelId, - SpeechLanguage = SpeechLanguage, - SpeechSampleRate = SpeechSampleRate, - TextLanguage = TextLanguage, - }; - - return options; - } + public virtual SpeechToTextOptions Clone() => new(this); } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/SpeechToTextResponse.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/SpeechToTextResponse.cs index 63c6c137411..a63d5cf2d63 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/SpeechToTextResponse.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/SpeechToTextResponse.cs @@ -7,8 +7,6 @@ using System.Text.Json.Serialization; using Microsoft.Shared.Diagnostics; -#pragma warning disable EA0011 // Consider removing unnecessary conditional access operators - namespace Microsoft.Extensions.AI; /// Represents the result of an speech to text request. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/SpeechToTextResponseUpdate.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/SpeechToTextResponseUpdate.cs index 24b7f079302..e65dd7dcbe7 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/SpeechToTextResponseUpdate.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/SpeechToTextResponseUpdate.cs @@ -7,8 +7,6 @@ using System.Text.Json.Serialization; using Microsoft.Shared.Diagnostics; -#pragma warning disable EA0011 // Consider removing unnecessary conditional access operators - namespace Microsoft.Extensions.AI; /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/SpeechToTextResponseUpdateExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/SpeechToTextResponseUpdateExtensions.cs index 0f83a7a8bee..67272761ceb 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/SpeechToTextResponseUpdateExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/SpeechToTextResponseUpdateExtensions.cs @@ -7,8 +7,6 @@ using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; -#pragma warning disable S1121 // Assignments should not be made from within sub-expressions - namespace Microsoft.Extensions.AI; /// @@ -32,7 +30,7 @@ public static SpeechToTextResponse ToSpeechToTextResponse( ProcessUpdate(update, response); } - ChatResponseExtensions.CoalesceTextContent((List)response.Contents); + ChatResponseExtensions.CoalesceContent((List)response.Contents); return response; } @@ -58,7 +56,7 @@ static async Task ToResponseAsync( ProcessUpdate(update, response); } - ChatResponseExtensions.CoalesceTextContent((List)response.Contents); + ChatResponseExtensions.CoalesceContent((List)response.Contents); return response; } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/ToolReduction/IToolReductionStrategy.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ToolReduction/IToolReductionStrategy.cs new file mode 100644 index 00000000000..029eeae47a1 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/ToolReduction/IToolReductionStrategy.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a strategy capable of selecting a reduced set of tools for a chat request. +/// +/// +/// A tool reduction strategy is invoked prior to sending a request to an underlying , +/// enabling scenarios where a large tool catalog must be trimmed to fit provider limits or to improve model +/// tool selection quality. +/// +/// The implementation should return a non- enumerable. Returning the original +/// instance indicates no change. Returning a different enumerable indicates +/// the caller may replace the existing tool list. +/// +/// +[Experimental("MEAI001")] +public interface IToolReductionStrategy +{ + /// + /// Selects the tools that should be included for a specific request. + /// + /// The chat messages for the request. This is an to avoid premature materialization. + /// The chat options for the request (may be ). + /// A token to observe cancellation. + /// + /// A (possibly reduced) enumerable of instances. Must never be . + /// Returning the same instance referenced by . signals no change. + /// + Task> SelectToolsForRequestAsync( + IEnumerable messages, + ChatOptions? options, + CancellationToken cancellationToken = default); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/AITool.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/AITool.cs new file mode 100644 index 00000000000..366dc66f77c --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/AITool.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 System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text; +using Microsoft.Shared.Collections; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Represents a tool that can be specified to an AI service. +[DebuggerDisplay("{DebuggerDisplay,nq}")] +public abstract class AITool +{ + /// Initializes a new instance of the class. + protected AITool() + { + } + + /// Gets the name of the tool. + public virtual string Name => GetType().Name; + + /// Gets a description of the tool, suitable for use in describing the purpose to a model. + public virtual string Description => string.Empty; + + /// Gets any additional properties associated with the tool. + public virtual IReadOnlyDictionary AdditionalProperties => EmptyReadOnlyDictionary.Instance; + + /// + public override string ToString() => Name; + + /// Asks the for an object of the specified type . + /// The type of object being requested. + /// An optional key that can be used to help identify the target service. + /// The found object, otherwise . + /// is . + /// + /// The purpose of this method is to allow for the retrieval of strongly typed services that might be provided by the , + /// including itself or any services it might be wrapping. + /// + public virtual object? GetService(Type serviceType, object? serviceKey = null) + { + _ = Throw.IfNull(serviceType); + + return + serviceKey is null && serviceType.IsInstanceOfType(this) ? this : + null; + } + + /// Asks the for an object of type . + /// The type of the object to be retrieved. + /// An optional key that can be used to help identify the target service. + /// The found object, otherwise . + /// + /// The purpose of this method is to allow for the retrieval of strongly typed services that may be provided by the , + /// including itself or any services it might be wrapping. + /// + public TService? GetService(object? serviceKey = null) => + GetService(typeof(TService), serviceKey) is TService service ? service : default; + + /// Gets the string to display in the debugger for this instance. + [DebuggerBrowsable(DebuggerBrowsableState.Never)] + private string DebuggerDisplay + { + get + { + StringBuilder sb = new(Name); + + if (Description is string description && !string.IsNullOrEmpty(description)) + { + _ = sb.Append(" (").Append(description).Append(')'); + } + + foreach (var entry in AdditionalProperties) + { + _ = sb.Append(", ").Append(entry.Key).Append(" = ").Append(entry.Value); + } + + return sb.ToString(); + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedCodeInterpreterTool.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedCodeInterpreterTool.cs similarity index 52% rename from src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedCodeInterpreterTool.cs rename to src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedCodeInterpreterTool.cs index 6662fc420e3..4bd63a0df75 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedCodeInterpreterTool.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedCodeInterpreterTool.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; + namespace Microsoft.Extensions.AI; /// Represents a hosted tool that can be specified to an AI service to enable it to execute code it generates. @@ -14,4 +16,15 @@ public class HostedCodeInterpreterTool : AITool public HostedCodeInterpreterTool() { } + + /// + public override string Name => "code_interpreter"; + + /// Gets or sets a collection of to be used as input to the code interpreter tool. + /// + /// Services support different varied kinds of inputs. Most support the IDs of files that are hosted by the service, + /// represented via . Some also support binary data, represented via . + /// Unsupported inputs will be ignored by the to which the tool is passed. + /// + public IList? Inputs { get; set; } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedFileSearchTool.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedFileSearchTool.cs new file mode 100644 index 00000000000..b130e26b647 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedFileSearchTool.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.Collections.Generic; + +namespace Microsoft.Extensions.AI; + +/// Represents a hosted tool that can be specified to an AI service to enable it to perform file search operations. +/// +/// This tool is designed to facilitate file search functionality within AI services. It allows the service to search +/// for relevant content based on the provided inputs and constraints, such as the maximum number of results. +/// +public class HostedFileSearchTool : AITool +{ + /// Initializes a new instance of the class. + public HostedFileSearchTool() + { + } + + /// + public override string Name => "file_search"; + + /// Gets or sets a collection of to be used as input to the file search tool. + /// + /// If no explicit inputs are provided, the service determines what inputs should be searched. Different services + /// support different kinds of inputs, for example, some might respect using provider-specific file IDs, + /// others might support binary data uploaded as part of the request in , and others might support + /// content in a hosted vector store and represented by a . + /// + public IList? Inputs { get; set; } + + /// Gets or sets a requested bound on the number of matches the tool should produce. + public int? MaximumResultCount { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedImageGenerationTool.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedImageGenerationTool.cs new file mode 100644 index 00000000000..aca072653ab --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedImageGenerationTool.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Extensions.AI; + +/// Represents a hosted tool that can be specified to an AI service to enable it to perform image generation. +/// +/// This tool does not itself implement image generation. It is a marker that can be used to inform a service +/// that the service is allowed to perform image generation if the service is capable of doing so. +/// +[Experimental("MEAI001")] +public class HostedImageGenerationTool : AITool +{ + /// + /// Initializes a new instance of the class with the specified options. + /// + public HostedImageGenerationTool() + { + } + + /// + /// Gets or sets the options used to configure image generation. + /// + public ImageGenerationOptions? Options { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs new file mode 100644 index 00000000000..aa33a581710 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs @@ -0,0 +1,102 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a hosted MCP server tool that can be specified to an AI service. +/// +[Experimental("MEAI001")] +public class HostedMcpServerTool : AITool +{ + /// + /// Initializes a new instance of the class. + /// + /// The name of the remote MCP server. + /// The address of the remote MCP server. This may be a URL, or in the case of a service providing built-in MCP servers with known names, it can be such a name. + /// or is . + /// or is empty or composed entirely of whitespace. + public HostedMcpServerTool(string serverName, string serverAddress) + { + ServerName = Throw.IfNullOrWhitespace(serverName); + ServerAddress = Throw.IfNullOrWhitespace(serverAddress); + } + + /// + /// Initializes a new instance of the class. + /// + /// The name of the remote MCP server. + /// The URL of the remote MCP server. + /// or is . + /// is empty or composed entirely of whitespace. + /// is not an absolute URL. + public HostedMcpServerTool(string serverName, Uri serverUrl) + : this(serverName, ValidateUrl(serverUrl)) + { + } + + private static string ValidateUrl(Uri serverUrl) + { + _ = Throw.IfNull(serverUrl); + + if (!serverUrl.IsAbsoluteUri) + { + Throw.ArgumentException(nameof(serverUrl), "The provided URL is not absolute."); + } + + return serverUrl.AbsoluteUri; + } + + /// + public override string Name => "mcp"; + + /// + /// Gets the name of the remote MCP server that is used to identify it. + /// + public string ServerName { get; } + + /// + /// Gets the address of the remote MCP server. This may be a URL, or in the case of a service providing built-in MCP servers with known names, it can be such a name. + /// + public string ServerAddress { get; } + + /// + /// Gets or sets the OAuth authorization token that the AI service should use when calling the remote MCP server. + /// + public string? AuthorizationToken { get; set; } + + /// + /// Gets or sets the description of the remote MCP server, used to provide more context to the AI service. + /// + public string? ServerDescription { get; set; } + + /// + /// Gets or sets the list of tools allowed to be used by the AI service. + /// + /// + /// The default value is , which allows any tool to be used. + /// + public IList? AllowedTools { get; set; } + + /// + /// Gets or sets the approval mode that indicates when the AI service should require user approval for tool calls to the remote MCP server. + /// + /// + /// + /// You can set this property to to require approval for all tool calls, + /// or to to never require approval. + /// + /// + /// The default value is , which some providers might treat the same as . + /// + /// + /// The underlying provider is not guaranteed to support or honor the approval mode. + /// + /// + public HostedMcpServerToolApprovalMode? ApprovalMode { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedWebSearchTool.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedWebSearchTool.cs similarity index 90% rename from src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedWebSearchTool.cs rename to src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedWebSearchTool.cs index 06d11bf40ed..19d25510d19 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedWebSearchTool.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedWebSearchTool.cs @@ -14,4 +14,7 @@ public class HostedWebSearchTool : AITool public HostedWebSearchTool() { } + + /// + public override string Name => "web_search"; } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateContext.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateContext.cs index 22e3bc6066a..5b3656630e7 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateContext.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateContext.cs @@ -15,7 +15,7 @@ namespace Microsoft.Extensions.AI; /// Defines the context in which a JSON schema within a type graph is being generated. /// /// -/// This struct is being passed to the user-provided +/// This struct is being passed to the user-provided /// callback by the method and cannot be instantiated directly. /// public readonly struct AIJsonSchemaCreateContext @@ -51,32 +51,20 @@ internal AIJsonSchemaCreateContext(JsonSchemaExporterContext exporterContext) /// Gets the declaring type of the property or parameter being processed. /// public Type? DeclaringType => -#if NET9_0_OR_GREATER _exporterContext.PropertyInfo?.DeclaringType; -#else - _exporterContext.DeclaringType; -#endif /// /// Gets the corresponding to the property or field being processed. /// public ICustomAttributeProvider? PropertyAttributeProvider => -#if NET9_0_OR_GREATER _exporterContext.PropertyInfo?.AttributeProvider; -#else - _exporterContext.PropertyAttributeProvider; -#endif /// /// Gets the of the /// constructor parameter associated with the accompanying . /// public ICustomAttributeProvider? ParameterAttributeProvider => -#if NET9_0_OR_GREATER _exporterContext.PropertyInfo?.AssociatedParameter?.AttributeProvider; -#else - _exporterContext.ParameterInfo; -#endif /// /// Retrieves a custom attribute of a specified type that is applied to the specified schema node context. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateOptions.cs index 667b8fee475..9da0d72e5a5 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaCreateOptions.cs @@ -6,8 +6,6 @@ using System.Text.Json.Nodes; using System.Threading; -#pragma warning disable S1067 // Expressions should not be too complex - namespace Microsoft.Extensions.AI; /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaTransformCache.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaTransformCache.cs index a1aaeff26ac..438c05ce39b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaTransformCache.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaTransformCache.cs @@ -1,7 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; +using System.ComponentModel; using System.Runtime.CompilerServices; using System.Text.Json; using Microsoft.Shared.Diagnostics; @@ -18,15 +18,15 @@ namespace Microsoft.Extensions.AI; /// implementations that enforce vendor-specific restrictions on what constitutes a valid JSON schema for a given function or response format. /// /// -/// It is recommended implementations with schema transformation requirements should create a single static instance of this cache. +/// It is recommended implementations with schema transformation requirements create a single static instance of this cache. /// /// public sealed class AIJsonSchemaTransformCache { - private readonly ConditionalWeakTable _functionSchemaCache = new(); + private readonly ConditionalWeakTable _functionSchemaCache = new(); private readonly ConditionalWeakTable _responseFormatCache = new(); - private readonly ConditionalWeakTable.CreateValueCallback _functionSchemaCreateValueCallback; + private readonly ConditionalWeakTable.CreateValueCallback _functionSchemaCreateValueCallback; private readonly ConditionalWeakTable.CreateValueCallback _responseFormatCreateValueCallback; /// @@ -55,9 +55,18 @@ public AIJsonSchemaTransformCache(AIJsonSchemaTransformOptions transformOptions) /// /// Gets or creates a transformed JSON schema for the specified instance. /// - /// The function whose JSON schema we want to transform. + /// The function whose JSON schema is to be transformed. /// The transformed JSON schema corresponding to . - public JsonElement GetOrCreateTransformedSchema(AIFunction function) + [EditorBrowsable(EditorBrowsableState.Never)] // maintained for binary compat; functionality for AIFunction is satisfied by AIFunctionDeclaration overload + public JsonElement GetOrCreateTransformedSchema(AIFunction function) => + GetOrCreateTransformedSchema((AIFunctionDeclaration)function); + + /// + /// Gets or creates a transformed JSON schema for the specified instance. + /// + /// The function whose JSON schema is to be transformed. + /// The transformed JSON schema corresponding to . + public JsonElement GetOrCreateTransformedSchema(AIFunctionDeclaration function) { _ = Throw.IfNull(function); return (JsonElement)_functionSchemaCache.GetValue(function, _functionSchemaCreateValueCallback); @@ -66,7 +75,7 @@ public JsonElement GetOrCreateTransformedSchema(AIFunction function) /// /// Gets or creates a transformed JSON schema for the specified instance. /// - /// The response format whose JSON schema we want to transform. + /// The response format whose JSON schema is to be transformed. /// The transformed JSON schema corresponding to . public JsonElement? GetOrCreateTransformedSchema(ChatResponseFormatJson responseFormat) { diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaTransformOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaTransformOptions.cs index 46e7476afcf..101cfa03168 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaTransformOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonSchemaTransformOptions.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. -#pragma warning disable S1067 // Expressions should not be too complex - using System; using System.Text.Json.Nodes; diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs index 33531661813..d01294836bc 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs @@ -48,6 +48,18 @@ private static JsonSerializerOptions CreateDefaultOptions() Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, }; + // Temporary workaround: these types are [Experimental] and can't be added as [JsonDerivedType] on AIContent yet, + // or else consuming assemblies that used source generation with AIContent would implicitly reference them. + // Once they're no longer [Experimental] and added as [JsonDerivedType] on AIContent, these lines should be removed. + AddAIContentType(options, typeof(FunctionApprovalRequestContent), typeDiscriminatorId: "functionApprovalRequest", checkBuiltIn: false); + AddAIContentType(options, typeof(FunctionApprovalResponseContent), typeDiscriminatorId: "functionApprovalResponse", checkBuiltIn: false); + AddAIContentType(options, typeof(McpServerToolCallContent), typeDiscriminatorId: "mcpServerToolCall", checkBuiltIn: false); + AddAIContentType(options, typeof(McpServerToolResultContent), typeDiscriminatorId: "mcpServerToolResult", checkBuiltIn: false); + AddAIContentType(options, typeof(McpServerToolApprovalRequestContent), typeDiscriminatorId: "mcpServerToolApprovalRequest", checkBuiltIn: false); + AddAIContentType(options, typeof(McpServerToolApprovalResponseContent), typeDiscriminatorId: "mcpServerToolApprovalResponse", checkBuiltIn: false); + AddAIContentType(options, typeof(CodeInterpreterToolCallContent), typeDiscriminatorId: "codeInterpreterToolCall", checkBuiltIn: false); + AddAIContentType(options, typeof(CodeInterpreterToolResultContent), typeDiscriminatorId: "codeInterpreterToolResult", checkBuiltIn: false); + if (JsonSerializer.IsReflectionEnabledByDefault) { // If reflection-based serialization is enabled by default, use it as a fallback for all other types. @@ -65,37 +77,23 @@ private static JsonSerializerOptions CreateDefaultOptions() UseStringEnumConverter = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, WriteIndented = true)] - [JsonSerializable(typeof(SpeechToTextOptions))] - [JsonSerializable(typeof(SpeechToTextClientMetadata))] - [JsonSerializable(typeof(SpeechToTextResponse))] - [JsonSerializable(typeof(SpeechToTextResponseUpdate))] - [JsonSerializable(typeof(IReadOnlyList))] - [JsonSerializable(typeof(IList))] - [JsonSerializable(typeof(IEnumerable))] - [JsonSerializable(typeof(ChatMessage[]))] - [JsonSerializable(typeof(ChatOptions))] - [JsonSerializable(typeof(EmbeddingGenerationOptions))] - [JsonSerializable(typeof(ChatClientMetadata))] - [JsonSerializable(typeof(EmbeddingGeneratorMetadata))] - [JsonSerializable(typeof(ChatResponse))] - [JsonSerializable(typeof(ChatResponseUpdate))] - [JsonSerializable(typeof(IReadOnlyList))] - [JsonSerializable(typeof(Dictionary))] - [JsonSerializable(typeof(IDictionary))] + + // JSON [JsonSerializable(typeof(JsonDocument))] [JsonSerializable(typeof(JsonElement))] [JsonSerializable(typeof(JsonNode))] [JsonSerializable(typeof(JsonObject))] [JsonSerializable(typeof(JsonValue))] [JsonSerializable(typeof(JsonArray))] - [JsonSerializable(typeof(IEnumerable))] - [JsonSerializable(typeof(char))] + + // Primitives [JsonSerializable(typeof(string))] - [JsonSerializable(typeof(int))] + [JsonSerializable(typeof(char))] [JsonSerializable(typeof(short))] - [JsonSerializable(typeof(long))] - [JsonSerializable(typeof(uint))] [JsonSerializable(typeof(ushort))] + [JsonSerializable(typeof(int))] + [JsonSerializable(typeof(uint))] + [JsonSerializable(typeof(long))] [JsonSerializable(typeof(ulong))] [JsonSerializable(typeof(float))] [JsonSerializable(typeof(double))] @@ -104,6 +102,42 @@ private static JsonSerializerOptions CreateDefaultOptions() [JsonSerializable(typeof(TimeSpan))] [JsonSerializable(typeof(DateTime))] [JsonSerializable(typeof(DateTimeOffset))] + + // AIFunction + [JsonSerializable(typeof(AIFunctionArguments))] + + // IChatClient + [JsonSerializable(typeof(IEnumerable))] + [JsonSerializable(typeof(IList))] + [JsonSerializable(typeof(ChatMessage[]))] + [JsonSerializable(typeof(ChatOptions))] + [JsonSerializable(typeof(ChatClientMetadata))] + [JsonSerializable(typeof(ChatResponse))] + [JsonSerializable(typeof(ChatResponseUpdate))] + [JsonSerializable(typeof(IReadOnlyList))] + [JsonSerializable(typeof(Dictionary))] + [JsonSerializable(typeof(IDictionary))] + [JsonSerializable(typeof(IEnumerable))] + [JsonSerializable(typeof(AIContent))] + [JsonSerializable(typeof(IEnumerable))] + + // Temporary workaround: These should be implicitly added in once they're no longer [Experimental] + // and are included via [JsonDerivedType] on AIContent. + [JsonSerializable(typeof(UserInputRequestContent))] + [JsonSerializable(typeof(UserInputResponseContent))] + [JsonSerializable(typeof(FunctionApprovalRequestContent))] + [JsonSerializable(typeof(FunctionApprovalResponseContent))] + [JsonSerializable(typeof(McpServerToolCallContent))] + [JsonSerializable(typeof(McpServerToolResultContent))] + [JsonSerializable(typeof(McpServerToolApprovalRequestContent))] + [JsonSerializable(typeof(McpServerToolApprovalResponseContent))] + [JsonSerializable(typeof(CodeInterpreterToolCallContent))] + [JsonSerializable(typeof(CodeInterpreterToolResultContent))] + [JsonSerializable(typeof(ResponseContinuationToken))] + + // IEmbeddingGenerator + [JsonSerializable(typeof(EmbeddingGenerationOptions))] + [JsonSerializable(typeof(EmbeddingGeneratorMetadata))] [JsonSerializable(typeof(Embedding))] [JsonSerializable(typeof(Embedding))] [JsonSerializable(typeof(Embedding))] @@ -112,8 +146,18 @@ private static JsonSerializerOptions CreateDefaultOptions() #endif [JsonSerializable(typeof(Embedding))] [JsonSerializable(typeof(Embedding))] - [JsonSerializable(typeof(AIContent))] - [JsonSerializable(typeof(AIFunctionArguments))] + + // ISpeechToTextClient + [JsonSerializable(typeof(SpeechToTextOptions))] + [JsonSerializable(typeof(SpeechToTextClientMetadata))] + [JsonSerializable(typeof(SpeechToTextResponse))] + [JsonSerializable(typeof(SpeechToTextResponseUpdate))] + [JsonSerializable(typeof(IReadOnlyList))] + + // IImageGenerator + [JsonSerializable(typeof(ImageGenerationOptions))] + [JsonSerializable(typeof(ImageGenerationResponse))] + [EditorBrowsable(EditorBrowsableState.Never)] // Never use JsonContext directly, use DefaultOptions instead. private sealed partial class JsonContext : JsonSerializerContext; diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs index a9d3ac3e3ee..667b3c4d080 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs @@ -14,13 +14,11 @@ using System.Text.Json.Nodes; using System.Text.Json.Schema; using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; using System.Threading; using Microsoft.Shared.Diagnostics; -#pragma warning disable S107 // Methods should not have too many parameters -#pragma warning disable S109 // Magic numbers should not be used #pragma warning disable S1075 // URIs should not be hardcoded -#pragma warning disable S1121 // Assignments should not be made from within sub-expressions #pragma warning disable S1199 // Nested block #pragma warning disable SA1118 // Parameter should not span multiple lines @@ -82,7 +80,7 @@ public static JsonElement CreateFunctionJsonSchema( serializerOptions ??= DefaultOptions; inferenceOptions ??= AIJsonSchemaCreateOptions.Default; - title ??= method.Name; + title ??= method.GetCustomAttribute()?.DisplayName ?? method.Name; description ??= method.GetCustomAttribute()?.Description; JsonObject parameterSchemas = new(); @@ -110,17 +108,18 @@ public static JsonElement CreateFunctionJsonSchema( continue; } + bool hasDefaultValue = TryGetEffectiveDefaultValue(parameter, out object? defaultValue); JsonNode parameterSchema = CreateJsonSchemaCore( type: parameter.ParameterType, - parameterName: parameter.Name, + parameter: parameter, description: parameter.GetCustomAttribute(inherit: true)?.Description, - hasDefaultValue: parameter.HasDefaultValue, - defaultValue: GetDefaultValueNormalized(parameter), + hasDefaultValue: hasDefaultValue, + defaultValue: defaultValue, serializerOptions, inferenceOptions); parameterSchemas.Add(parameter.Name, parameterSchema); - if (!parameter.IsOptional) + if (!parameter.IsOptional && !hasDefaultValue) { (requiredProperties ??= []).Add((JsonNode)parameter.Name); } @@ -177,7 +176,7 @@ public static JsonElement CreateJsonSchema( { serializerOptions ??= DefaultOptions; inferenceOptions ??= AIJsonSchemaCreateOptions.Default; - JsonNode schema = CreateJsonSchemaCore(type, parameterName: null, description, hasDefaultValue, defaultValue, serializerOptions, inferenceOptions); + JsonNode schema = CreateJsonSchemaCore(type, parameter: null, description, hasDefaultValue, defaultValue, serializerOptions, inferenceOptions); // Finally, apply any schema transformations if specified. if (inferenceOptions.TransformOptions is { } options) @@ -200,14 +199,9 @@ internal static void ValidateSchemaDocument(JsonElement document, [CallerArgumen } } -#if !NET9_0_OR_GREATER - [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", - Justification = "Pre STJ-9 schema extraction can fail with a runtime exception if certain reflection metadata have been trimmed. " + - "The exception message will guide users to turn off 'IlcTrimMetadata' which resolves all issues.")] -#endif private static JsonNode CreateJsonSchemaCore( Type? type, - string? parameterName, + ParameterInfo? parameter, string? description, bool hasDefaultValue, object? defaultValue, @@ -271,14 +265,14 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext schemaExporterContext, Js // The resulting schema might be a $ref using a pointer to a different location in the document. // As JSON pointer doesn't support relative paths, parameter schemas need to fix up such paths // to accommodate the fact that they're being nested inside of a higher-level schema. - if (parameterName is not null && objSchema.TryGetPropertyValue(RefPropertyName, out JsonNode? paramName)) + if (parameter?.Name is not null && objSchema.TryGetPropertyValue(RefPropertyName, out JsonNode? paramName)) { // Fix up any $ref URIs to match the path from the root document. string refUri = paramName!.GetValue(); Debug.Assert(refUri is "#" || refUri.StartsWith("#/", StringComparison.Ordinal), $"Expected {nameof(refUri)} to be either # or start with #/, got {refUri}"); refUri = refUri == "#" - ? $"#/{PropertiesPropertyName}/{parameterName}" - : $"#/{PropertiesPropertyName}/{parameterName}/{refUri.AsMemory("#/".Length)}"; + ? $"#/{PropertiesPropertyName}/{parameter.Name}" + : $"#/{PropertiesPropertyName}/{parameter.Name}/{refUri.AsMemory("#/".Length)}"; objSchema[RefPropertyName] = (JsonNode)refUri; } @@ -289,24 +283,55 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext schemaExporterContext, Js objSchema.InsertAtStart(TypePropertyName, "string"); } - // Include the type keyword in nullable enum types - if (Nullable.GetUnderlyingType(ctx.TypeInfo.Type)?.IsEnum is true && objSchema.ContainsKey(EnumPropertyName) && !objSchema.ContainsKey(TypePropertyName)) + // Include a trivial items keyword if missing + if (ctx.TypeInfo.Kind is JsonTypeInfoKind.Enumerable && !objSchema.ContainsKey(ItemsPropertyName)) { - objSchema.InsertAtStart(TypePropertyName, new JsonArray { (JsonNode)"string", (JsonNode)"null" }); + objSchema.Add(ItemsPropertyName, new JsonObject()); } // Some consumers of the JSON schema, including Ollama as of v0.3.13, don't understand // schemas with "type": [...], and only understand "type" being a single value. // In certain configurations STJ represents .NET numeric types as ["string", "number"], which will then lead to an error. - if (TypeIsIntegerWithStringNumberHandling(ctx, objSchema, out string? numericType)) + if (TypeIsIntegerWithStringNumberHandling(ctx, objSchema, out string? numericType, out bool isNullable)) { // We don't want to emit any array for "type". In this case we know it contains "integer" or "number", // so reduce the type to that alone, assuming it's the most specific type. // This makes schemas for Int32 (etc) work with Ollama. JsonObject obj = ConvertSchemaToObject(ref schema); - obj[TypePropertyName] = numericType; + if (isNullable) + { + // If the type is nullable, we still need use a type array + obj[TypePropertyName] = new JsonArray { (JsonNode)numericType, (JsonNode)"null" }; + } + else + { + obj[TypePropertyName] = (JsonNode)numericType; + } + _ = obj.Remove(PatternPropertyName); } + + if (Nullable.GetUnderlyingType(ctx.TypeInfo.Type) is Type nullableElement) + { + // Account for bug https://github.com/dotnet/runtime/issues/117493 + // To be removed once System.Text.Json v10 becomes the lowest supported version. + // null not inserted in the type keyword for root-level Nullable types. + if (objSchema.TryGetPropertyValue(TypePropertyName, out JsonNode? typeKeyWord) && + typeKeyWord?.GetValueKind() is JsonValueKind.String) + { + string typeValue = typeKeyWord.GetValue()!; + if (typeValue is not "null") + { + objSchema[TypePropertyName] = new JsonArray { (JsonNode)typeValue, (JsonNode)"null" }; + } + } + + // Include the type keyword in nullable enum types + if (nullableElement.IsEnum && objSchema.ContainsKey(EnumPropertyName) && !objSchema.ContainsKey(TypePropertyName)) + { + objSchema.InsertAtStart(TypePropertyName, new JsonArray { (JsonNode)"string", (JsonNode)"null" }); + } + } } if (ctx.Path.IsEmpty && hasDefaultValue) @@ -327,7 +352,7 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext schemaExporterContext, Js ConvertSchemaToObject(ref schema).InsertAtStart(SchemaPropertyName, (JsonNode)SchemaKeywordUri); } - ApplyDataAnnotations(parameterName, ref schema, ctx); + ApplyDataAnnotations(ref schema, ctx); // Finally, apply any user-defined transformations if specified. if (inferenceOptions.TransformSchemaNode is { } transformer) @@ -357,30 +382,30 @@ static JsonObject ConvertSchemaToObject(ref JsonNode schema) } } - void ApplyDataAnnotations(string? parameterName, ref JsonNode schema, AIJsonSchemaCreateContext ctx) + void ApplyDataAnnotations(ref JsonNode schema, AIJsonSchemaCreateContext ctx) { - if (ctx.GetCustomAttribute() is { } displayNameAttribute) + if (ResolveAttribute() is { } displayNameAttribute) { ConvertSchemaToObject(ref schema)[TitlePropertyName] ??= displayNameAttribute.DisplayName; } #if NET || NETFRAMEWORK - if (ctx.GetCustomAttribute() is { } emailAttribute) + if (ResolveAttribute() is { } emailAttribute) { ConvertSchemaToObject(ref schema)[FormatPropertyName] ??= "email"; } - if (ctx.GetCustomAttribute() is { } urlAttribute) + if (ResolveAttribute() is { } urlAttribute) { ConvertSchemaToObject(ref schema)[FormatPropertyName] ??= "uri"; } - if (ctx.GetCustomAttribute() is { } regexAttribute) + if (ResolveAttribute() is { } regexAttribute) { ConvertSchemaToObject(ref schema)[PatternPropertyName] ??= regexAttribute.Pattern; } - if (ctx.GetCustomAttribute() is { } stringLengthAttribute) + if (ResolveAttribute() is { } stringLengthAttribute) { JsonObject obj = ConvertSchemaToObject(ref schema); @@ -392,10 +417,10 @@ void ApplyDataAnnotations(string? parameterName, ref JsonNode schema, AIJsonSche obj[MaxLengthStringPropertyName] ??= stringLengthAttribute.MaximumLength; } - if (ctx.GetCustomAttribute() is { } minLengthAttribute) + if (ResolveAttribute() is { } minLengthAttribute) { JsonObject obj = ConvertSchemaToObject(ref schema); - if (obj[TypePropertyName] is JsonNode typeNode && typeNode.GetValueKind() is JsonValueKind.String && typeNode.GetValue() is "string") + if (TryGetSchemaType(obj, out string? schemaType, out _) && schemaType is "string") { obj[MinLengthStringPropertyName] ??= minLengthAttribute.Length; } @@ -405,10 +430,10 @@ void ApplyDataAnnotations(string? parameterName, ref JsonNode schema, AIJsonSche } } - if (ctx.GetCustomAttribute() is { } maxLengthAttribute) + if (ResolveAttribute() is { } maxLengthAttribute) { JsonObject obj = ConvertSchemaToObject(ref schema); - if (obj[TypePropertyName] is JsonNode typeNode && typeNode.GetValueKind() is JsonValueKind.String && typeNode.GetValue() is "string") + if (TryGetSchemaType(obj, out string? schemaType, out _) && schemaType is "string") { obj[MaxLengthStringPropertyName] ??= maxLengthAttribute.Length; } @@ -418,7 +443,7 @@ void ApplyDataAnnotations(string? parameterName, ref JsonNode schema, AIJsonSche } } - if (ctx.GetCustomAttribute() is { } rangeAttribute) + if (ResolveAttribute() is { } rangeAttribute) { JsonObject obj = ConvertSchemaToObject(ref schema); @@ -489,16 +514,16 @@ void ApplyDataAnnotations(string? parameterName, ref JsonNode schema, AIJsonSche #endif #if NET - if (ctx.GetCustomAttribute() is { } base64Attribute) + if (ResolveAttribute() is { } base64Attribute) { ConvertSchemaToObject(ref schema)[ContentEncodingPropertyName] ??= "base64"; } - if (ctx.GetCustomAttribute() is { } lengthAttribute) + if (ResolveAttribute() is { } lengthAttribute) { JsonObject obj = ConvertSchemaToObject(ref schema); - if (obj[TypePropertyName] is JsonNode typeNode && typeNode.GetValueKind() is JsonValueKind.String && typeNode.GetValue() is "string") + if (TryGetSchemaType(obj, out string? schemaType, out _) && schemaType is "string") { if (lengthAttribute.MinimumLength > 0) { @@ -518,7 +543,7 @@ void ApplyDataAnnotations(string? parameterName, ref JsonNode schema, AIJsonSche } } - if (ctx.GetCustomAttribute() is { } allowedValuesAttribute) + if (ResolveAttribute() is { } allowedValuesAttribute) { JsonObject obj = ConvertSchemaToObject(ref schema); if (!obj.ContainsKey(EnumPropertyName)) @@ -530,7 +555,7 @@ void ApplyDataAnnotations(string? parameterName, ref JsonNode schema, AIJsonSche } } - if (ctx.GetCustomAttribute() is { } deniedValuesAttribute) + if (ResolveAttribute() is { } deniedValuesAttribute) { JsonObject obj = ConvertSchemaToObject(ref schema); @@ -565,7 +590,7 @@ static JsonArray CreateJsonArray(object?[] values, JsonSerializerOptions seriali return enumArray; } - if (ctx.GetCustomAttribute() is { } dataTypeAttribute) + if (ResolveAttribute() is { } dataTypeAttribute) { JsonObject obj = ConvertSchemaToObject(ref schema); switch (dataTypeAttribute.DataType) @@ -597,15 +622,79 @@ static JsonArray CreateJsonArray(object?[] values, JsonSerializerOptions seriali } } #endif +#if NET || NETFRAMEWORK + static bool TryGetSchemaType(JsonObject schema, [NotNullWhen(true)] out string? schemaType, out bool isNullable) + { + schemaType = null; + isNullable = false; + + if (!schema.TryGetPropertyValue(TypePropertyName, out JsonNode? typeNode)) + { + return false; + } + + switch (typeNode?.GetValueKind()) + { + case JsonValueKind.String: + schemaType = typeNode.GetValue(); + return true; + + case JsonValueKind.Array: + string? foundSchemaType = null; + foreach (JsonNode? entry in (JsonArray)typeNode) + { + if (entry?.GetValueKind() is not JsonValueKind.String) + { + return false; + } + + string entryValue = entry.GetValue(); + if (entryValue is "null") + { + isNullable = true; + continue; + } + + if (foundSchemaType is null) + { + foundSchemaType = entryValue; + } + else if (foundSchemaType != entryValue) + { + return false; + } + } + + schemaType = foundSchemaType; + return schemaType is not null; + + default: + return false; + } + } +#endif + + TAttribute? ResolveAttribute() + where TAttribute : Attribute + { + // If this is the root schema, check for any parameter attributes first. + if (ctx.Path.IsEmpty && parameter?.GetCustomAttribute(inherit: true) is TAttribute attr) + { + return attr; + } + + return ctx.GetCustomAttribute(inherit: true); + } } } } - private static bool TypeIsIntegerWithStringNumberHandling(AIJsonSchemaCreateContext ctx, JsonObject schema, [NotNullWhen(true)] out string? numericType) + private static bool TypeIsIntegerWithStringNumberHandling(AIJsonSchemaCreateContext ctx, JsonObject schema, [NotNullWhen(true)] out string? numericType, out bool isNullable) { numericType = null; + isNullable = false; - if (ctx.TypeInfo.NumberHandling is not JsonNumberHandling.Strict && schema["type"] is JsonArray { Count: 2 } typeArray) + if (ctx.TypeInfo.NumberHandling is not JsonNumberHandling.Strict && schema["type"] is JsonArray typeArray) { bool allowString = false; @@ -617,11 +706,23 @@ private static bool TypeIsIntegerWithStringNumberHandling(AIJsonSchemaCreateCont switch (type) { case "integer" or "number": + if (numericType is not null) + { + // Conflicting numeric type + return false; + } + numericType = type; break; case "string": allowString = true; break; + case "null": + isNullable = true; + break; + default: + // keyword is not valid in the context of numeric types. + return false; } } } @@ -655,6 +756,32 @@ private static JsonElement ParseJsonElement(ReadOnlySpan utf8Json) return JsonElement.ParseValue(ref reader); } + /// + /// Tries to get the effective default value for a parameter, checking both C# default value syntax and DefaultValueAttribute. + /// + /// The parameter to check. + /// The default value if one exists. + /// if the parameter has a default value; otherwise, . + internal static bool TryGetEffectiveDefaultValue(ParameterInfo parameterInfo, out object? defaultValue) + { + // First check for DefaultValueAttribute + if (parameterInfo.GetCustomAttribute(inherit: true) is { } attr) + { + defaultValue = attr.Value; + return true; + } + + // Fall back to the parameter's declared default value + if (parameterInfo.HasDefaultValue) + { + defaultValue = GetDefaultValueNormalized(parameterInfo); + return true; + } + + defaultValue = null; + return false; + } + [UnconditionalSuppressMessage("Trimming", "IL2072:Target parameter argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method.", Justification = "Called conditionally on structs whose default ctor never gets trimmed.")] private static object? GetDefaultValueNormalized(ParameterInfo parameterInfo) @@ -665,7 +792,7 @@ private static JsonElement ParseJsonElement(ReadOnlySpan utf8Json) if (defaultValue is null || (defaultValue == DBNull.Value && parameterType != typeof(DBNull))) { - return parameterType.IsValueType + return parameterType.IsValueType && Nullable.GetUnderlyingType(parameterType) is null #if NET ? RuntimeHelpers.GetUninitializedObject(parameterType) #else diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.cs index 7e28a5983ee..b69d0fb2aab 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.cs @@ -17,9 +17,6 @@ #endif using Microsoft.Shared.Diagnostics; -#pragma warning disable S109 // Magic numbers should not be used -#pragma warning disable S1121 // Assignments should not be made from within sub-expressions - namespace Microsoft.Extensions.AI; public static partial class AIJsonUtilities @@ -39,7 +36,7 @@ public static void AddAIContentType(this JsonSerializerOptions options _ = Throw.IfNull(options); _ = Throw.IfNull(typeDiscriminatorId); - AddAIContentTypeCore(options, typeof(TContent), typeDiscriminatorId); + AddAIContentType(options, typeof(TContent), typeDiscriminatorId, checkBuiltIn: true); } /// @@ -59,10 +56,10 @@ public static void AddAIContentType(this JsonSerializerOptions options, Type con if (!typeof(AIContent).IsAssignableFrom(contentType)) { - Throw.ArgumentException(nameof(contentType), "The content type must derive from AIContent."); + Throw.ArgumentException(nameof(contentType), $"The content type must derive from {nameof(AIContent)}."); } - AddAIContentTypeCore(options, contentType, typeDiscriminatorId); + AddAIContentType(options, contentType, typeDiscriminatorId, checkBuiltIn: true); } /// Serializes the supplied values and computes a string hash of the resulting JSON. @@ -189,11 +186,11 @@ static void NormalizeJsonNode(JsonNode? node) } } - private static void AddAIContentTypeCore(JsonSerializerOptions options, Type contentType, string typeDiscriminatorId) + private static void AddAIContentType(JsonSerializerOptions options, Type contentType, string typeDiscriminatorId, bool checkBuiltIn) { - if (contentType.Assembly == typeof(AIContent).Assembly) + if (checkBuiltIn && (contentType.Assembly == typeof(AIContent).Assembly)) { - Throw.ArgumentException(nameof(contentType), "Cannot register built-in AI content types."); + Throw.ArgumentException(nameof(contentType), $"Cannot register built-in {nameof(AIContent)} types."); } IJsonTypeInfoResolver resolver = options.TypeInfoResolver ?? DefaultOptions.TypeInfoResolver!; diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs index bb23e1de489..45081c0ab6c 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceChatClient.cs @@ -15,7 +15,6 @@ 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 #pragma warning disable SA1204 // Static elements should appear before instance elements @@ -95,6 +94,7 @@ public async Task GetResponseAsync( // Create the return message. ChatMessage message = new(ToChatRole(response.Role), response.Content) { + CreatedAt = response.Created, MessageId = response.Id, // There is no per-message ID, but there's only one message per response, so use the response ID RawRepresentation = response, }; @@ -182,13 +182,6 @@ public async IAsyncEnumerable GetStreamingResponseAsync( // Transfer over tool call updates. 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; @@ -342,7 +335,7 @@ private ChatCompletionsOptions ToAzureAIOptions(IEnumerable chatCon { foreach (AITool tool in tools) { - if (tool is AIFunction af) + if (tool is AIFunctionDeclaration af) { result.Tools.Add(ToAzureAIChatTool(af)); } @@ -409,7 +402,7 @@ private ChatCompletionsOptions ToAzureAIOptions(IEnumerable chatCon private static readonly BinaryData _falseString = BinaryData.FromString("false"); /// Converts an Extensions function to an AzureAI chat tool. - private static ChatCompletionsToolDefinition ToAzureAIChatTool(AIFunction aiFunction) + private static ChatCompletionsToolDefinition ToAzureAIChatTool(AIFunctionDeclaration aiFunction) { // Map to an intermediate model so that redundant properties are skipped. var tool = JsonSerializer.Deserialize(SchemaTransformCache.GetOrCreateTransformedSchema(aiFunction), JsonContext.Default.AzureAIChatToolJson)!; @@ -484,8 +477,6 @@ private static IEnumerable ToAzureAIInferenceChatMessages(IE } 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(string.Concat(input.Contents.Where(c => c is TextContent))); foreach (var content in input.Contents) diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceEmbeddingGenerator.cs index 95cea4e2a3b..04383a85b86 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceEmbeddingGenerator.cs @@ -15,9 +15,7 @@ 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; diff --git a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceImageEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceImageEmbeddingGenerator.cs index 91222722b2a..b04a7c73a39 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceImageEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/AzureAIInferenceImageEmbeddingGenerator.cs @@ -11,9 +11,7 @@ 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; 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..60423003454 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/CHANGELOG.md @@ -0,0 +1,84 @@ +# Release History + +## 9.10.1-preview.1.25521.4 + +- No changes. + +## 9.10.0-preview.1.25513.3 + +- Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. + +## 9.9.1-preview.1.25474.6 + +- Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. + +## 9.9.0-preview.1.25458.4 + +- Updated tool mapping to recognize any `AIFunctionDeclaration`. +- Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. +- Updated `AsIChatClient` for `OpenAIResponseClient` to support reasoning content with `GetStreamingResponseAsync`. + +## 9.8.0-preview.1.25412.6 + +- Updated to depend on Azure.AI.Inference 1.0.0-beta.5. +- Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. + +## 9.7.0-preview.1.25356.2 + +- Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. + +## 9.6.0-preview.1.25310.2 + +- Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. + +## 9.5.0-preview.1.25265.7 + +- Added `AsIEmbeddingGenerator` for Azure.AI.Inference `ImageEmbeddingsClient`. +- Updated to accommodate the changes in `Microsoft.Extensions.AI.Abstractions`. + +## 9.4.4-preview.1.25259.16 + +- Added an `AsIEmbeddingGenerator` extension method for `ImageEmbeddingsClient`. +- Updated to accommodate the changes in `Microsoft.Extensions.AI.Abstractions`. + +## 9.4.3-preview.1.25230.7 + +- Updated to accommodate the changes in `Microsoft.Extensions.AI.Abstractions`. + +## 9.4.0-preview.1.25207.5 + +- Updated to Azure.AI.Inference 1.0.0-beta.4. +- Renamed `AsChatClient`/`AsEmbeddingGenerator` extension methods to `AsIChatClient`/`AsIEmbeddingGenerator`. +- Removed the public `AzureAIInferenceChatClient`/`AzureAIInferenceEmbeddingGenerator` types. These are only created now via the extension methods. +- Updated to accommodate the changes in `Microsoft.Extensions.AI.Abstractions`. + +## 9.3.0-preview.1.25161.3 + +- Updated to accommodate the changes in `Microsoft.Extensions.AI.Abstractions`. + +## 9.3.0-preview.1.25114.11 + +- Updated to use Azure.AI.Inference 1.0.0-beta.3, adding support for structured output and audio input. + +## 9.1.0-preview.1.25064.3 + +- Fixed handling of text-only user messages. + +## 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`. + +## 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.AzureAIInference/Microsoft.Extensions.AI.AzureAIInference.csproj b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/Microsoft.Extensions.AI.AzureAIInference.csproj index 5384a7992d7..e40a2cc34b1 100644 --- a/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/Microsoft.Extensions.AI.AzureAIInference.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.AzureAIInference/Microsoft.Extensions.AI.AzureAIInference.csproj @@ -1,9 +1,10 @@ - + Microsoft.Extensions.AI Implementation of generative AI abstractions for Azure.AI.Inference. AI + true @@ -15,7 +16,7 @@ $(TargetFrameworks);netstandard2.0 - $(NoWarn);CA1063;CA2227;SA1316;S1067;S1121;S3358 + $(NoWarn);CA1063 true true diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Commands/CleanCacheCommand.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Commands/CleanCacheCommand.cs index b0d975edb43..a3224e411cd 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Commands/CleanCacheCommand.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Commands/CleanCacheCommand.cs @@ -2,46 +2,66 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Collections.Generic; using System.IO; using System.Threading; using System.Threading.Tasks; using Azure.Identity; using Azure.Storage.Files.DataLake; +using Microsoft.Extensions.AI.Evaluation.Console.Telemetry; using Microsoft.Extensions.AI.Evaluation.Console.Utilities; using Microsoft.Extensions.AI.Evaluation.Reporting; using Microsoft.Extensions.AI.Evaluation.Reporting.Storage; using Microsoft.Extensions.Logging; +using static Microsoft.Extensions.AI.Evaluation.Console.Telemetry.TelemetryConstants; namespace Microsoft.Extensions.AI.Evaluation.Console.Commands; -internal sealed class CleanCacheCommand(ILogger logger) +internal sealed class CleanCacheCommand(ILogger logger, TelemetryHelper telemetryHelper) { - internal async Task InvokeAsync(DirectoryInfo? storageRootDir, Uri? endpointUri, CancellationToken cancellationToken = default) + internal async Task InvokeAsync( + DirectoryInfo? storageRootDir, + Uri? endpointUri, + CancellationToken cancellationToken = default) { - IEvaluationResponseCacheProvider cacheProvider; - - if (storageRootDir is not null) - { - string storageRootPath = storageRootDir.FullName; - logger.LogInformation("Storage root path: {storageRootPath}", storageRootPath); - logger.LogInformation("Deleting expired cache entries..."); - - cacheProvider = new DiskBasedResponseCacheProvider(storageRootPath); - } - else if (endpointUri is not null) - { - logger.LogInformation("Azure Storage endpoint: {endpointUri}", endpointUri); - - var fsClient = new DataLakeDirectoryClient(endpointUri, new DefaultAzureCredential()); - cacheProvider = new AzureStorageResponseCacheProvider(fsClient); - } - else - { - throw new InvalidOperationException("Either --path or --endpoint must be specified"); - } + var telemetryProperties = new Dictionary(); await logger.ExecuteWithCatchAsync( - () => cacheProvider.DeleteExpiredCacheEntriesAsync(cancellationToken)).ConfigureAwait(false); + operation: () => + telemetryHelper.ReportOperationAsync( + operationName: EventNames.CleanCacheCommand, + operation: () => + { + IEvaluationResponseCacheProvider cacheProvider; + + if (storageRootDir is not null) + { + string storageRootPath = storageRootDir.FullName; + logger.LogInformation("Storage root path: {storageRootPath}", storageRootPath); + logger.LogInformation("Deleting expired cache entries..."); + + cacheProvider = new DiskBasedResponseCacheProvider(storageRootPath); + + telemetryProperties[PropertyNames.StorageType] = PropertyValues.StorageTypeDisk; + } + else if (endpointUri is not null) + { + logger.LogInformation("Azure Storage endpoint: {endpointUri}", endpointUri); + + var fsClient = new DataLakeDirectoryClient(endpointUri, new DefaultAzureCredential()); + cacheProvider = new AzureStorageResponseCacheProvider(fsClient); + + telemetryProperties[PropertyNames.StorageType] = PropertyValues.StorageTypeAzure; + } + else + { + throw new InvalidOperationException("Either --path or --endpoint must be specified"); + } + + return cacheProvider.DeleteExpiredCacheEntriesAsync(cancellationToken); + }, + properties: telemetryProperties, + logger: logger)).ConfigureAwait(false); return 0; } diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Commands/CleanResultsCommand.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Commands/CleanResultsCommand.cs index 8d6617d8302..ec5a8805424 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Commands/CleanResultsCommand.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Commands/CleanResultsCommand.cs @@ -8,14 +8,16 @@ using System.Threading.Tasks; using Azure.Identity; using Azure.Storage.Files.DataLake; +using Microsoft.Extensions.AI.Evaluation.Console.Telemetry; using Microsoft.Extensions.AI.Evaluation.Console.Utilities; using Microsoft.Extensions.AI.Evaluation.Reporting; using Microsoft.Extensions.AI.Evaluation.Reporting.Storage; using Microsoft.Extensions.Logging; +using static Microsoft.Extensions.AI.Evaluation.Console.Telemetry.TelemetryConstants; namespace Microsoft.Extensions.AI.Evaluation.Console.Commands; -internal sealed class CleanResultsCommand(ILogger logger) +internal sealed class CleanResultsCommand(ILogger logger, TelemetryHelper telemetryHelper) { internal async Task InvokeAsync( DirectoryInfo? storageRootDir, @@ -23,61 +25,81 @@ internal async Task InvokeAsync( int lastN, CancellationToken cancellationToken = default) { - IEvaluationResultStore resultStore; - - if (storageRootDir is not null) - { - string storageRootPath = storageRootDir.FullName; - logger.LogInformation("Storage root path: {storageRootPath}", storageRootPath); + var telemetryProperties = + new Dictionary + { + [PropertyNames.LastN] = lastN.ToTelemetryPropertyValue() + }; - resultStore = new DiskBasedResultStore(storageRootPath); - } - else if (endpointUri is not null) - { - logger.LogInformation("Azure Storage endpoint: {endpointUri}", endpointUri); + await logger.ExecuteWithCatchAsync( + operation: () => + telemetryHelper.ReportOperationAsync( + operationName: EventNames.CleanResultsCommand, + operation: async ValueTask () => + { + IEvaluationResultStore resultStore; - var fsClient = new DataLakeDirectoryClient(endpointUri, new DefaultAzureCredential()); - resultStore = new AzureStorageResultStore(fsClient); - } - else - { - throw new InvalidOperationException("Either --path or --endpoint must be specified"); - } + if (storageRootDir is not null) + { + string storageRootPath = storageRootDir.FullName; + logger.LogInformation("Storage root path: {storageRootPath}", storageRootPath); - await logger.ExecuteWithCatchAsync( - async ValueTask () => - { - if (lastN is 0) - { - logger.LogInformation("Deleting all results..."); + resultStore = new DiskBasedResultStore(storageRootPath); - await resultStore.DeleteResultsAsync(cancellationToken: cancellationToken).ConfigureAwait(false); - } - else - { - logger.LogInformation("Deleting all results except the {lastN} most recent ones...", lastN); + telemetryProperties[PropertyNames.StorageType] = PropertyValues.StorageTypeDisk; + } + else if (endpointUri is not null) + { + logger.LogInformation("Azure Storage endpoint: {endpointUri}", endpointUri); - HashSet toPreserve = []; + var fsClient = new DataLakeDirectoryClient(endpointUri, new DefaultAzureCredential()); + resultStore = new AzureStorageResultStore(fsClient); - await foreach (string executionName in - resultStore.GetLatestExecutionNamesAsync(lastN, cancellationToken).ConfigureAwait(false)) - { - _ = toPreserve.Add(executionName); - } + telemetryProperties[PropertyNames.StorageType] = PropertyValues.StorageTypeAzure; + } + else + { + throw new InvalidOperationException("Either --path or --endpoint must be specified"); + } - await foreach (string executionName in - resultStore.GetLatestExecutionNamesAsync( - cancellationToken: cancellationToken).ConfigureAwait(false)) - { - if (!toPreserve.Contains(executionName)) + if (lastN is 0) { + logger.LogInformation("Deleting all results..."); + await resultStore.DeleteResultsAsync( - executionName, cancellationToken: cancellationToken).ConfigureAwait(false); } - } - } - }).ConfigureAwait(false); + else + { + logger.LogInformation( + "Deleting all results except the {lastN} most recent ones...", + lastN); + + HashSet toPreserve = []; + + await foreach (string executionName in + resultStore.GetLatestExecutionNamesAsync( + lastN, + cancellationToken).ConfigureAwait(false)) + { + _ = toPreserve.Add(executionName); + } + + await foreach (string executionName in + resultStore.GetLatestExecutionNamesAsync( + cancellationToken: cancellationToken).ConfigureAwait(false)) + { + if (!toPreserve.Contains(executionName)) + { + await resultStore.DeleteResultsAsync( + executionName, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + } + } + }, + properties: telemetryProperties, + logger: logger)).ConfigureAwait(false); return 0; } diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Commands/ReportCommand.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Commands/ReportCommand.cs index 2611695e542..cf2242eaac6 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Commands/ReportCommand.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Commands/ReportCommand.cs @@ -5,19 +5,24 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Azure.Identity; using Azure.Storage.Files.DataLake; +using Microsoft.Extensions.AI.Evaluation.Console.Telemetry; +using Microsoft.Extensions.AI.Evaluation.Console.Utilities; using Microsoft.Extensions.AI.Evaluation.Reporting; using Microsoft.Extensions.AI.Evaluation.Reporting.Formats.Html; using Microsoft.Extensions.AI.Evaluation.Reporting.Formats.Json; using Microsoft.Extensions.AI.Evaluation.Reporting.Storage; +using Microsoft.Extensions.AI.Evaluation.Utilities; using Microsoft.Extensions.Logging; +using static Microsoft.Extensions.AI.Evaluation.Console.Telemetry.TelemetryConstants; namespace Microsoft.Extensions.AI.Evaluation.Console.Commands; -internal sealed partial class ReportCommand(ILogger logger) +internal sealed partial class ReportCommand(ILogger logger, TelemetryHelper telemetryHelper) { internal async Task InvokeAsync( DirectoryInfo? storageRootDir, @@ -28,89 +33,367 @@ internal async Task InvokeAsync( Format format, CancellationToken cancellationToken = default) { - IEvaluationResultStore resultStore; + var telemetryProperties = + new Dictionary + { + [PropertyNames.LastN] = lastN.ToTelemetryPropertyValue(), + [PropertyNames.Format] = format.ToString(), + [PropertyNames.OpenReport] = openReport.ToTelemetryPropertyValue() + }; - if (storageRootDir is not null) - { - string storageRootPath = storageRootDir.FullName; - logger.LogInformation("Storage root path: {storageRootPath}", storageRootPath); + await logger.ExecuteWithCatchAsync( + operation: () => + telemetryHelper.ReportOperationAsync( + operationName: EventNames.ReportCommand, + operation: async ValueTask () => + { + IEvaluationResultStore resultStore; - resultStore = new DiskBasedResultStore(storageRootPath); - } - else if (endpointUri is not null) - { - logger.LogInformation("Azure Storage endpoint: {endpointUri}", endpointUri); + if (storageRootDir is not null) + { + string storageRootPath = storageRootDir.FullName; + logger.LogInformation("Storage root path: {storageRootPath}", storageRootPath); - var fsClient = new DataLakeDirectoryClient(endpointUri, new DefaultAzureCredential()); - resultStore = new AzureStorageResultStore(fsClient); - } - else - { - throw new InvalidOperationException("Either --path or --endpoint must be specified"); - } + resultStore = new DiskBasedResultStore(storageRootPath); - List results = []; + telemetryProperties[PropertyNames.StorageType] = PropertyValues.StorageTypeDisk; + } + else if (endpointUri is not null) + { + logger.LogInformation("Azure Storage endpoint: {endpointUri}", endpointUri); - string? latestExecutionName = null; + var fsClient = new DataLakeDirectoryClient(endpointUri, new DefaultAzureCredential()); + resultStore = new AzureStorageResultStore(fsClient); - await foreach (string executionName in - resultStore.GetLatestExecutionNamesAsync(lastN, cancellationToken).ConfigureAwait(false)) - { - latestExecutionName ??= executionName; + telemetryProperties[PropertyNames.StorageType] = PropertyValues.StorageTypeAzure; + } + else + { + throw new InvalidOperationException("Either --path or --endpoint must be specified"); + } + + List results = []; + string? latestExecutionName = null; + + int resultId = 0; + var usageDetailsByModel = + new Dictionary<(string? model, string? modelProvider), TurnAndTokenUsageDetails>(); + + await foreach (string executionName in + resultStore.GetLatestExecutionNamesAsync(lastN, cancellationToken).ConfigureAwait(false)) + { + latestExecutionName ??= executionName; + + await foreach (ScenarioRunResult result in + resultStore.ReadResultsAsync( + executionName, + cancellationToken: cancellationToken).ConfigureAwait(false)) + { + if (result.ExecutionName == latestExecutionName) + { + ReportScenarioRunResult( + ++resultId, + result, + usageDetailsByModel, + cancellationToken); + } + else + { + // Clear the chat data for following executions + result.Messages = []; + result.ModelResponse = new ChatResponse(); + } + + results.Add(result); - await foreach (ScenarioRunResult result in - resultStore.ReadResultsAsync( - executionName, - cancellationToken: cancellationToken).ConfigureAwait(false)) + logger.LogInformation( + "Execution: {executionName} Scenario: {scenarioName} Iteration: {iterationName}", + result.ExecutionName, + result.ScenarioName, + result.IterationName); + } + } + + ReportUsageDetails(usageDetailsByModel, cancellationToken); + + string outputFilePath = outputFile.FullName; + string? outputPath = Path.GetDirectoryName(outputFilePath); + if (outputPath is not null && !Directory.Exists(outputPath)) + { + _ = Directory.CreateDirectory(outputPath); + } + + IEvaluationReportWriter reportWriter = format switch + { + Format.html => new HtmlReportWriter(outputFilePath), + Format.json => new JsonReportWriter(outputFilePath), + _ => throw new NotSupportedException(), + }; + + await reportWriter.WriteReportAsync(results, cancellationToken).ConfigureAwait(false); + logger.LogInformation("Report: {outputFilePath} [{format}]", outputFilePath, format); + + // See the following issues for reasoning behind this check. We want to avoid opening the + // report if this process is running as a service or in a CI pipeline. + // https://github.com/dotnet/runtime/issues/770#issuecomment-564700467 + // https://github.com/dotnet/runtime/issues/66530#issuecomment-1065854289 + bool isRedirected = + System.Console.IsInputRedirected && + System.Console.IsOutputRedirected && + System.Console.IsErrorRedirected; + + bool isInteractive = + Environment.UserInteractive && (OperatingSystem.IsWindows() || !isRedirected); + + if (openReport && isInteractive) + { + // Open the generated report in the default browser. + _ = Process.Start( + new ProcessStartInfo + { + FileName = outputFilePath, + UseShellExecute = true + }); + } + }, + properties: telemetryProperties, + logger: logger)).ConfigureAwait(false); + + return 0; + } + + private void ReportScenarioRunResult( + int resultId, + ScenarioRunResult result, + Dictionary<(string? model, string? modelProvider), TurnAndTokenUsageDetails> usageDetailsByModel, + CancellationToken cancellationToken) + { + logger.ExecuteWithCatch(() => + { + if (result.ChatDetails?.TurnDetails is IList turns) { - if (result.ExecutionName != latestExecutionName) + foreach (ChatTurnDetails turn in turns) { - // Clear the chat data for following executions - result.Messages = []; - result.ModelResponse = new ChatResponse(); + cancellationToken.ThrowIfCancellationRequested(); + + (string? model, string? modelProvider) key = (turn.Model, turn.ModelProvider); + if (!usageDetailsByModel.TryGetValue(key, out TurnAndTokenUsageDetails? usageDetails)) + { + usageDetails = new TurnAndTokenUsageDetails(); + usageDetailsByModel[key] = usageDetails; + } + + usageDetails.Add(turn); } + } + + string resultIdValue = resultId.ToTelemetryPropertyValue(); + ICollection metrics = result.EvaluationResult.Metrics.Values; + + var properties = + new Dictionary + { + [PropertyNames.ScenarioRunResultId] = resultIdValue, + [PropertyNames.MetricsCount] = metrics.Count.ToTelemetryPropertyValue() + }; - results.Add(result); + telemetryHelper.ReportEvent(eventName: EventNames.ScenarioRunResult, properties); - logger.LogInformation("Execution: {executionName} Scenario: {scenarioName} Iteration: {iterationName}", result.ExecutionName, result.ScenarioName, result.IterationName); + foreach (EvaluationMetric metric in metrics) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (metric.IsBuiltIn()) + { + ReportBuiltInMetric(metric, resultIdValue); + } } - } + }, + swallowUnhandledExceptions: true); // Log and ignore exceptions encountered when trying to report telemetry. - string outputFilePath = outputFile.FullName; - string? outputPath = Path.GetDirectoryName(outputFilePath); - if (outputPath is not null && !Directory.Exists(outputPath)) + void ReportBuiltInMetric(EvaluationMetric metric, string resultIdValue) { - _ = Directory.CreateDirectory(outputPath); + // We always want to report the diagnostics counts - even when metric.Diagnostics is null. This is because + // we know that when metric.Diagnostics is null, that means there were no diagnostics (as opposed to + // meaning that diagnostic information was somehow missing or unavailable). + int errorDiagnosticsCount = + metric.Diagnostics?.Count(d => d.Severity == EvaluationDiagnosticSeverity.Error) ?? 0; + int warningDiagnosticsCount = + metric.Diagnostics?.Count(d => d.Severity == EvaluationDiagnosticSeverity.Warning) ?? 0; + int informationalDiagnosticsCount = + metric.Diagnostics?.Count(d => d.Severity == EvaluationDiagnosticSeverity.Informational) ?? 0; + + var properties = + new Dictionary + { + [PropertyNames.MetricName] = metric.Name, + [PropertyNames.ScenarioRunResultId] = resultIdValue, + [PropertyNames.ErrorDiagnosticsCount] = errorDiagnosticsCount.ToTelemetryPropertyValue(), + [PropertyNames.WarningDiagnosticsCount] = warningDiagnosticsCount.ToTelemetryPropertyValue(), + [PropertyNames.InformationalDiagnosticsCount] = + informationalDiagnosticsCount.ToTelemetryPropertyValue() + }; + + // We want to omit reporting the below properties (such as token counts) when the corresponding metadata is + // missing. This is because we know that when the metadata is missing, that means the corresponding + // information was not available. For example, it would be wrong to report the token counts as 0 when in + // reality the token count information is missing because it was not available as part of the ChatResponse + // returned from the IChatClient during evaluation. + if (TryGetPropertyValueFromMetadata(BuiltInMetricUtilities.EvalModelMetadataName) is string model) + { + properties[PropertyNames.Model] = model; + } + + if (TryGetPropertyValueFromMetadata(BuiltInMetricUtilities.EvalInputTokensMetadataName) + is string inputTokenCount) + { + properties[PropertyNames.InputTokenCount] = inputTokenCount; + } + + if (TryGetPropertyValueFromMetadata(BuiltInMetricUtilities.EvalOutputTokensMetadataName) + is string outputTokenCount) + { + properties[PropertyNames.OutputTokenCount] = outputTokenCount; + } + + if (TryGetPropertyValueFromMetadata(BuiltInMetricUtilities.EvalDurationMillisecondsMetadataName) + is string durationInMilliseconds) + { + properties[PropertyNames.DurationInMilliseconds] = durationInMilliseconds; + } + + if (metric.Interpretation?.Failed is bool failed) + { + properties[PropertyNames.IsInterpretedAsFailed] = failed.ToTelemetryPropertyValue(); + } + + telemetryHelper.ReportEvent(eventName: EventNames.BuiltInMetric, properties); + + string? TryGetPropertyValueFromMetadata(string metadataName) + { + if ((metric.Metadata?.TryGetValue(metadataName, out string? value)) is not true || + string.IsNullOrWhiteSpace(value)) + { + return null; + } + + return value; + } } + } - IEvaluationReportWriter reportWriter = format switch + private void ReportUsageDetails( + Dictionary<(string? model, string? modelProvider), TurnAndTokenUsageDetails> usageDetailsByModel, + CancellationToken cancellationToken) + { + logger.ExecuteWithCatch(() => { - Format.html => new HtmlReportWriter(outputFilePath), - Format.json => new JsonReportWriter(outputFilePath), - _ => throw new NotSupportedException(), - }; - - await reportWriter.WriteReportAsync(results, cancellationToken).ConfigureAwait(false); - logger.LogInformation("Report: {outputFilePath} [{format}]", outputFilePath, format); - - // See the following issues for reasoning behind this check. We want to avoid opening the report - // if this process is running as a service or in a CI pipeline. - // https://github.com/dotnet/runtime/issues/770#issuecomment-564700467 - // https://github.com/dotnet/runtime/issues/66530#issuecomment-1065854289 - bool isRedirected = System.Console.IsInputRedirected && System.Console.IsOutputRedirected && System.Console.IsErrorRedirected; - bool isInteractive = Environment.UserInteractive && (OperatingSystem.IsWindows() || !(isRedirected)); - - if (openReport && isInteractive) + foreach (((string? model, string? modelProvider), TurnAndTokenUsageDetails usageDetails) + in usageDetailsByModel) + { + cancellationToken.ThrowIfCancellationRequested(); + + string isModelHostWellKnown = ModelInfo.IsModelHostWellKnown(modelProvider).ToTelemetryPropertyValue(); + string isModelHostedLocally = ModelInfo.IsModelHostedLocally(modelProvider).ToTelemetryPropertyValue(); + string cachedTurnCount = usageDetails.CachedTurnCount.ToTelemetryPropertyValue(); + string nonCachedTurnCount = usageDetails.NonCachedTurnCount.ToTelemetryPropertyValue(); + + var properties = + new Dictionary + { + [PropertyNames.Model] = model.ToTelemetryPropertyValue(defaultValue: PropertyValues.Unknown), + [PropertyNames.ModelProvider] = + modelProvider.ToTelemetryPropertyValue(defaultValue: PropertyValues.Unknown), + [PropertyNames.IsModelHostWellKnown] = isModelHostWellKnown, + [PropertyNames.IsModelHostedLocally] = isModelHostedLocally, + [PropertyNames.CachedTurnCount] = cachedTurnCount, + [PropertyNames.NonCachedTurnCount] = nonCachedTurnCount + }; + + // We want to omit reporting the below token counts when the information is not available. It would be + // wrong to report the token counts as 0 when in reality the token count information is missing because + // it was not available as part of the ChatResponses returned from the IChatClients used during + // evaluation. + if (usageDetails.CachedInputTokenCount is long cachedInputTokenCount) + { + properties[PropertyNames.CachedInputTokenCount] = cachedInputTokenCount.ToTelemetryPropertyValue(); + } + + if (usageDetails.CachedOutputTokenCount is long cachedOutputTokenCount) + { + properties[PropertyNames.CachedOutputTokenCount] = + cachedOutputTokenCount.ToTelemetryPropertyValue(); + } + + if (usageDetails.NonCachedInputTokenCount is long nonCachedInputTokenCount) + { + properties[PropertyNames.NonCachedInputTokenCount] = + nonCachedInputTokenCount.ToTelemetryPropertyValue(); + } + + if (usageDetails.NonCachedOutputTokenCount is long nonCachedOutputTokenCount) + { + properties[PropertyNames.NonCachedOutputTokenCount] = + nonCachedOutputTokenCount.ToTelemetryPropertyValue(); + } + + telemetryHelper.ReportEvent(eventName: EventNames.ModelUsageDetails, properties); + } + }, + swallowUnhandledExceptions: true); // Log and ignore exceptions encountered when trying to report telemetry. + } + + private sealed class TurnAndTokenUsageDetails + { + internal long CachedTurnCount { get; private set; } + internal long NonCachedTurnCount { get; private set; } + internal long? CachedInputTokenCount { get; private set; } + internal long? NonCachedInputTokenCount { get; private set; } + internal long? CachedOutputTokenCount { get; private set; } + internal long? NonCachedOutputTokenCount { get; private set; } + + internal void Add(ChatTurnDetails turn) { - // Open the generated report in the default browser. - _ = Process.Start( - new ProcessStartInfo + EnsureTokenCountsInitialized(); + + bool isCached = turn.CacheHit ?? false; + if (isCached) + { + ++CachedTurnCount; + CachedInputTokenCount += turn.Usage?.InputTokenCount; + CachedOutputTokenCount += turn.Usage?.OutputTokenCount; + } + else + { + ++NonCachedTurnCount; + NonCachedInputTokenCount += turn.Usage?.InputTokenCount; + NonCachedOutputTokenCount += turn.Usage?.OutputTokenCount; + } + + void EnsureTokenCountsInitialized() + { + // If any turn (for a particular model and model provider combination) contains token usage details, we + // initialize both the cumulative cached token counts as well as the cumulative non-cached token counts + // (for this model and model provider combination) to 0. This is done so that when all token usage (for + // a particular model and model provider combination) is non-cached, we can report the cumulative + // cached token counts (for this model and model provider combination) as 0 (as opposed to treating the + // cached token counts as unknown and omitting them from the reported event), and vice-versa. The + // assumption here is that if any turn (for a particular model and model provider combination) contains + // token usage details, then all other turns (for the same model and model provider combination) will + // also contain this. + + if (turn.Usage?.InputTokenCount is not null) { - FileName = outputFilePath, - UseShellExecute = true - }); - } + CachedInputTokenCount ??= 0; + NonCachedInputTokenCount ??= 0; + } - return 0; + if (turn.Usage?.OutputTokenCount is not null) + { + CachedOutputTokenCount ??= 0; + NonCachedOutputTokenCount ??= 0; + } + } + } } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Microsoft.Extensions.AI.Evaluation.Console.csproj b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Microsoft.Extensions.AI.Evaluation.Console.csproj index 67995f0ac60..dafcc78f988 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Microsoft.Extensions.AI.Evaluation.Console.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Microsoft.Extensions.AI.Evaluation.Console.csproj @@ -28,11 +28,22 @@ false + + true + + + + + + + + - + + diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Program.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Program.cs index 8150f3a4d8c..013c3c2de84 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Program.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Program.cs @@ -1,32 +1,50 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#if DEBUG -using System.Diagnostics; -#endif using System; using System.CommandLine; using System.CommandLine.Parsing; using System.IO; using System.Threading.Tasks; using Microsoft.Extensions.AI.Evaluation.Console.Commands; +using Microsoft.Extensions.AI.Evaluation.Console.Telemetry; using Microsoft.Extensions.Logging; +#if DEBUG +using System.Diagnostics; +#endif + namespace Microsoft.Extensions.AI.Evaluation.Console; internal sealed class Program { private const string ShortName = "aieval"; private const string Name = "Microsoft.Extensions.AI.Evaluation.Console"; - private const string Banner = $"{Name} [{Constants.Version}]"; + private const string Banner = $"{Name} ({ShortName}) version {Constants.Version}"; #pragma warning disable EA0014 // Async methods should support cancellation. private static async Task Main(string[] args) #pragma warning restore EA0014 { - using ILoggerFactory factory = LoggerFactory.Create(builder => builder.AddConsole()); +#pragma warning disable CA1303 // Do not pass literals as localized parameters. + // Use Console.WriteLine directly instead of ILogger to ensure proper formatting. + System.Console.WriteLine(Banner); + System.Console.WriteLine(); +#pragma warning restore CA1303 + + using ILoggerFactory factory = + LoggerFactory.Create(builder => + builder.AddSimpleConsole(options => + { + options.SingleLine = true; + })); + ILogger logger = factory.CreateLogger(ShortName); - logger.LogInformation("{banner}", Banner); + await logger.DisplayTelemetryOptOutMessageIfNeededAsync().ConfigureAwait(false); + +#pragma warning disable CA2007 // Consider calling ConfigureAwait on the awaited task. + await using var telemetryHelper = new TelemetryHelper(logger); +#pragma warning restore CA2007 var rootCmd = new RootCommand(Banner); @@ -97,7 +115,9 @@ private static async Task Main(string[] args) reportCmd.AddOption(formatOpt); reportCmd.SetHandler( - (path, endpoint, output, openReport, lastN, format) => new ReportCommand(logger).InvokeAsync(path, endpoint, output, openReport, lastN, format), + (path, endpoint, output, openReport, lastN, format) => + new ReportCommand(logger, telemetryHelper) + .InvokeAsync(path, endpoint, output, openReport, lastN, format), pathOpt, endpointOpt, outputOpt, @@ -109,7 +129,7 @@ private static async Task Main(string[] args) // TASK: Support more granular filters such as the specific scenario / iteration / execution whose results must // be cleaned up. - var cleanResultsCmd = new Command("cleanResults", "Delete results"); + var cleanResultsCmd = new Command("clean-results", "Delete results"); cleanResultsCmd.AddOption(pathOpt); cleanResultsCmd.AddOption(endpointOpt); cleanResultsCmd.AddValidator(requiresPathOrEndpoint); @@ -118,20 +138,21 @@ private static async Task Main(string[] args) cleanResultsCmd.AddOption(lastNOpt2); cleanResultsCmd.SetHandler( - (path, endpoint, lastN) => new CleanResultsCommand(logger).InvokeAsync(path, endpoint, lastN), + (path, endpoint, lastN) => + new CleanResultsCommand(logger, telemetryHelper).InvokeAsync(path, endpoint, lastN), pathOpt, endpointOpt, lastNOpt2); rootCmd.Add(cleanResultsCmd); - var cleanCacheCmd = new Command("cleanCache", "Delete expired cache entries"); + var cleanCacheCmd = new Command("clean-cache", "Delete expired cache entries"); cleanCacheCmd.AddOption(pathOpt); cleanCacheCmd.AddOption(endpointOpt); cleanCacheCmd.AddValidator(requiresPathOrEndpoint); cleanCacheCmd.SetHandler( - (path, endpoint) => new CleanCacheCommand(logger).InvokeAsync(path, endpoint), + (path, endpoint) => new CleanCacheCommand(logger, telemetryHelper).InvokeAsync(path, endpoint), pathOpt, endpointOpt); rootCmd.Add(cleanCacheCmd); @@ -148,7 +169,7 @@ private static async Task Main(string[] args) } #endif - return await rootCmd.InvokeAsync(args).ConfigureAwait(false); + int exitCode = await rootCmd.InvokeAsync(args).ConfigureAwait(false); + return exitCode; } - } diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/TELEMETRY.md b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/TELEMETRY.md new file mode 100644 index 00000000000..7db74c24036 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/TELEMETRY.md @@ -0,0 +1,73 @@ +# Telemetry in the `aieval` .NET tool + +## Overview + +The `aieval` .NET tool (Microsoft.Extensions.AI.Evaluation.Console) collects anonymous usage data to help us understand how the tool is being used and to improve your experience. This document describes the telemetry data collected, how to opt out, and our commitment to your privacy. + +## When is telemetry collected? + +Telemetry is collected only when you use the **`aieval` command-line .NET tool** for operations such as: +- Generating evaluation reports (`dotnet aieval report`) +- Cleaning cached evaluation results (`dotnet aieval clean-cache`) +- Cleaning stored evaluation results (`dotnet aieval clean-results`) + +Note that using the Microsoft.Extensions.AI.Evaluation libraries in your own code **does not** trigger any telemetry collection. Telemetry is only collected when explicitly using the `aieval` command-line .NET tool. + +## What data is collected? + +The tool collects anonymous usage information to help us understand how the tool and the evaluation libraries are being used, and to help us identify areas for improvement. + +Below are some examples of the kinds of data collected: + +### System and environment details +- Version of the `aieval` .NET tool +- .NET runtime version +- Operating system platform and version +- Whether the tool was running in a CI environment + +### Command usage +- Which tool commands were executed (`report`, `clean-cache`, `clean-results`) +- Command execution status (success v/s failure) +- Command execution duration +- Whether the persisted evaluation data for report generation was stored on local disk v/s azure blob storage +- Report generation format (e.g., HTML, JSON) + +### Evaluation functionality used +- Number of scenarios evaluated +- Whether or not the response caching functionality was utilized during evaluation +- Names of built-in evaluation metrics used (e.g., Coherence, Fluency, Groundedness) and whether any errors were encountered when computing these metrics +- Model used for evaluation and (input and output) token counts +- Evaluation duration + +## What data is NOT collected? + +We are committed to protecting your privacy. All telemetry collected by the `aieval` .NET tool is anonymous and does not contain any personally identifiable information. Additionally, the telemetry collected by the `aieval` .NET tool **does not** include any information that falls into the following categories: + +- **Your prompts or conversation content** - The content of your LLM prompts, conversation histories, tool calls, and AI responses that you are evaluating +- **Contextual data passed to evaluators** - Any additional data you provide as input to the evaluation process +- **Custom evaluator implementation details** - Code or logic from your custom evaluators (including details pertaining to your own custom evaluation metrics) +- **File paths or names** - Local file system paths or file names from your projects +- **Any other personal or sensitive information** - Personal data, credentials, usernames, email addresses, URLs, or any other sensitive information + +## How to opt out + +You can opt out of telemetry collection by setting the `DOTNET_AIEVAL_TELEMETRY_OPTOUT` environment variable to `1` or `true`. + +## First-run experience + +The first time you run a particular version of the `aieval` .NET tool, you will see a message informing you about telemetry collection and how to opt out. + +``` +--------- +Telemetry +--------- +The aieval .NET tool collects usage data in order to help us improve your experience. The data is anonymous and doesn't include personal information. You can opt-out of this data collection by setting the DOTNET_AIEVAL_TELEMETRY_OPTOUT environment variable to '1' or 'true' using your favorite shell. +``` + +To skip the above first-run telemetry notification (for example, in automated environments), you can set the `DOTNET_AIEVAL_SKIP_FIRST_TIME_EXPERIENCE` environment variable to `1` or `true`. Note that this only suppresses the notification message; telemetry will still be collected unless you also set the `DOTNET_AIEVAL_TELEMETRY_OPTOUT` environment variable. + +## Data handling and privacy + +- All telemetry data collected is anonymous and does not contain personally identifiable information. +- The data is used to improve the Microsoft.Extensions.AI.Evaluation libraries and the `aieval` .NET tool. +- The data is sent securely to Microsoft servers and managed in accordance with Microsoft's privacy policies. diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Telemetry/DeviceIdHelper.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Telemetry/DeviceIdHelper.cs new file mode 100644 index 00000000000..4088d265250 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Telemetry/DeviceIdHelper.cs @@ -0,0 +1,165 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; +using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging; +using Microsoft.Win32; + +namespace Microsoft.Extensions.AI.Evaluation.Console.Telemetry; + +// Note: The below code is based on the code in the following file in the dotnet CLI: +// https://github.com/dotnet/sdk/blob/main/src/Cli/dotnet/Telemetry/DevDeviceIDGetter.cs. +// +// The logic below should be kept in sync with the code linked above to ensure that the device ID remains consistent +// across tools. + +internal sealed class DeviceIdHelper +{ + private const string RegistryKeyPath = @"SOFTWARE\Microsoft\DeveloperTools"; + private const string RegistryValueName = "deviceid"; + private const string CacheFileName = "deviceid"; + + private static string? _deviceId; + + private readonly ILogger _logger; + + internal DeviceIdHelper(ILogger logger) + { + _logger = logger; + } + + internal string GetDeviceId() + { + string? deviceId = GetCachedDeviceId(); + + if (string.IsNullOrWhiteSpace(deviceId)) + { +#pragma warning disable CA1308 // Normalize strings to uppercase. + // The DevDeviceId must follow the format specified below. + // 1. The value is a randomly generated Guid/ UUID. + // 2. The value follows the 8-4-4-4-12 format (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx). + // 3. The value shall be all lowercase and only contain hyphens. No braces or brackets. + deviceId = Guid.NewGuid().ToString("D").ToLowerInvariant(); +#pragma warning restore CA1308 + + try + { + CacheDeviceId(deviceId); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to cache device ID."); + + // If caching fails, return empty string to avoid reporting a non-cached id. + deviceId = string.Empty; + } + } + + return deviceId; + } + + private static string? GetCachedDeviceId() + { + if (_deviceId is not null) + { + return _deviceId; + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + using RegistryKey baseKey = RegistryKey.OpenBaseKey(RegistryHive.CurrentUser, RegistryView.Registry64); + using RegistryKey? key = baseKey.OpenSubKey(RegistryKeyPath); + _deviceId = key?.GetValue(RegistryValueName) as string; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + string cacheFileDirectoryPath = GetCacheFileDirectoryPathForLinux(); + ReadCacheFile(cacheFileDirectoryPath); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + string cacheFileDirectoryPath = GetCacheFileDirectoryPathForMacOS(); + ReadCacheFile(cacheFileDirectoryPath); + } + + return _deviceId; + + static void ReadCacheFile(string cacheFileDirectoryPath) + { + string cacheFilePath = Path.Combine(cacheFileDirectoryPath, CacheFileName); + if (File.Exists(cacheFilePath)) + { + _deviceId = File.ReadAllText(cacheFilePath); + } + } + } + + private static void CacheDeviceId(string deviceId) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + using RegistryKey baseKey = RegistryKey.OpenBaseKey(RegistryHive.CurrentUser, RegistryView.Registry64); + using RegistryKey key = baseKey.CreateSubKey(RegistryKeyPath); + key.SetValue(RegistryValueName, deviceId); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + string cacheFileDirectoryPath = GetCacheFileDirectoryPathForLinux(); + WriteCacheFile(cacheFileDirectoryPath, deviceId); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + string cacheFileDirectoryPath = GetCacheFileDirectoryPathForMacOS(); + WriteCacheFile(cacheFileDirectoryPath, deviceId); + } + + _deviceId = deviceId; + + static void WriteCacheFile(string cacheFileDirectoryPath, string deviceId) + { + _ = Directory.CreateDirectory(cacheFileDirectoryPath); + string cacheFilePath = Path.Combine(cacheFileDirectoryPath, CacheFileName); + File.WriteAllText(cacheFilePath, deviceId); + } + } + + private static string GetCacheFileDirectoryPathForLinux() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + throw new InvalidOperationException(); + } + + string cacheFileDirectoryPath; + string? xdgCacheHome = Environment.GetEnvironmentVariable("XDG_CACHE_HOME"); + + if (string.IsNullOrWhiteSpace(xdgCacheHome)) + { + string userProfilePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + cacheFileDirectoryPath = Path.Combine(userProfilePath, ".cache"); + } + else + { + cacheFileDirectoryPath = Path.Combine(xdgCacheHome, "Microsoft", "DeveloperTools"); + } + + return cacheFileDirectoryPath; + } + + private static string GetCacheFileDirectoryPathForMacOS() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + throw new InvalidOperationException(); + } + + string userProfilePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + string cacheFileDirectoryPath = + Path.Combine(userProfilePath, "Library", "Application Support", "Microsoft", "DeveloperTools"); + + return cacheFileDirectoryPath; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Telemetry/EnvironmentHelper.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Telemetry/EnvironmentHelper.cs new file mode 100644 index 00000000000..28942cb4eb1 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Telemetry/EnvironmentHelper.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.Linq; + +namespace Microsoft.Extensions.AI.Evaluation.Console.Telemetry; + +// Note: The below code is based on the code in the following file in the dotnet CLI: +// https://github.com/dotnet/sdk/blob/main/src/Cli/dotnet/Telemetry/CIEnvironmentDetectorForTelemetry.cs. +// +// The logic below should be kept in sync with the code linked above. + +internal static class EnvironmentHelper +{ + internal static bool GetEnvironmentVariableAsBoolean(string name) => + Environment.GetEnvironmentVariable(name)?.ToUpperInvariant() switch + { + "TRUE" or "1" or "YES" => true, + _ => false + }; + + // CI systems that can be detected via an environment variable with boolean value true. + private static readonly string[] _mustBeTrueCIVariables = + [ + "CI", // A general-use flag supported by many of the major CI systems including: Azure DevOps, GitHub, GitLab, AppVeyor, Travis CI, CircleCI. + "TF_BUILD", // https://docs.microsoft.com/en-us/azure/devops/pipelines/build/variables#system-variables-devops-services + "GITHUB_ACTIONS", // https://docs.github.com/en/actions/reference/workflows-and-actions/variables + "APPVEYOR", // https://www.appveyor.com/docs/environment-variables/ + "TRAVIS", // https://docs.travis-ci.com/user/environment-variables/#default-environment-variables + "CIRCLECI", // https://circleci.com/docs/reference/variables/#built-in-environment-variables + ]; + + // CI systems that that can be detected via a set of environment variables where every variable must be present and + // must have a non-null value. + private static readonly string[][] _mustNotBeNullCIVariables = + [ + ["CODEBUILD_BUILD_ID", "AWS_REGION"], // https://docs.aws.amazon.com/codebuild/latest/userguide/build-env-ref-env-vars.html + ["BUILD_ID", "BUILD_URL"], // https://github.com/jenkinsci/jenkins/blob/master/core/src/main/resources/jenkins/model/CoreEnvironmentContributor/buildEnv.groovy + ["BUILD_ID", "PROJECT_ID"], // https://cloud.google.com/build/docs/configuring-builds/substitute-variable-values#using_default_substitutions + ["TEAMCITY_VERSION"], // https://www.jetbrains.com/help/teamcity/predefined-build-parameters.html#Predefined+Server+Build+Parameters + ["JB_SPACE_API_URL"] // https://www.jetbrains.com/help/space/automation-parameters.html#provided-parameters + ]; + + public static bool IsCIEnvironment() + { + foreach (string variable in _mustBeTrueCIVariables) + { + if (bool.TryParse(Environment.GetEnvironmentVariable(variable), out bool value) && value) + { + return true; + } + } + + foreach (string[] variables in _mustNotBeNullCIVariables) + { + if (variables.All(variable => !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(variable)))) + { + return true; + } + } + + return false; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Telemetry/TelemetryConstants.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Telemetry/TelemetryConstants.cs new file mode 100644 index 00000000000..6a6d5549e07 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Telemetry/TelemetryConstants.cs @@ -0,0 +1,174 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Extensions.AI.Evaluation.Console.Telemetry; + +internal static class TelemetryConstants +{ + internal const string ConnectionString = "InstrumentationKey=469489a6-628b-4bb9-80db-ec670f70d874"; + + internal const string EventNamespace = "dotnet/aieval"; + + internal static class EventNames + { + internal const string CleanCacheCommand = "CleanCacheCommand"; + internal const string CleanResultsCommand = "CleanResultsCommand"; + internal const string ReportCommand = "ReportCommand"; + + internal const string ScenarioRunResult = "ScenarioRunResult"; + internal const string BuiltInMetric = "BuiltInMetric"; + internal const string ModelUsageDetails = "ModelUsageDetails"; + } + + internal static class PropertyNames + { + // Properties common to all events. + internal const string DevDeviceId = "DevDeviceId"; + internal const string OSVersion = "OSVersion"; + internal const string OSPlatform = "OSPlatform"; + internal const string KernelVersion = "KernelVersion"; + internal const string RuntimeId = "RuntimeId"; + internal const string ProductVersion = "ProductVersion"; + internal const string IsCIEnvironment = "IsCIEnvironment"; + + // Properties common to all *Command events. + internal const string Success = "Success"; + internal const string DurationInMilliseconds = "DurationInMilliseconds"; + + // Properties for parameters included in corresponding *Command events. + internal const string StorageType = "StorageType"; + internal const string LastN = "LastN"; + internal const string Format = "Format"; + internal const string OpenReport = "OpenReport"; + + // Properties included in the ScenarioRunResult event. + internal const string ScenarioRunResultId = "ScenarioRunResultId"; + internal const string MetricsCount = "MetricsCount"; + + // Properties included in the BuiltInMetric event. + internal const string MetricName = "MetricName"; + internal const string InputTokenCount = "InputTokenCount"; + internal const string OutputTokenCount = "OutputTokenCount"; + internal const string IsInterpretedAsFailed = "IsInterpretedAsFailed"; + internal const string ErrorDiagnosticsCount = "ErrorDiagnosticsCount"; + internal const string WarningDiagnosticsCount = "WarningDiagnosticsCount"; + internal const string InformationalDiagnosticsCount = "InformationalDiagnosticsCount"; + + // Properties included in the ModelUsageDetails event. + internal const string Model = "Model"; + internal const string ModelProvider = "ModelProvider"; + internal const string IsModelHostWellKnown = "IsModelHostWellKnown"; + internal const string IsModelHostedLocally = "IsModelHostedLocally"; + internal const string CachedTurnCount = "CachedTurnCount"; + internal const string NonCachedTurnCount = "NonCachedTurnCount"; + internal const string CachedInputTokenCount = "CachedInputTokenCount"; + internal const string NonCachedInputTokenCount = "NonCachedInputTokenCount"; + internal const string CachedOutputTokenCount = "CachedOutputTokenCount"; + internal const string NonCachedOutputTokenCount = "NonCachedOutputTokenCount"; + } + + internal static class PropertyValues + { + internal const string Unknown = "unknown"; + + internal const string StorageTypeDisk = "disk"; + internal const string StorageTypeAzure = "azure"; + + // Kusto recongnizes "true" and "false" as boolean literals. + internal const string True = "true"; + internal const string False = "false"; + } + + private const string TelemetryOptOutEnvironmentVariableName = "DOTNET_AIEVAL_TELEMETRY_OPTOUT"; + private const string SkipFirstTimeExperienceEnvironmentVariableName = "DOTNET_AIEVAL_SKIP_FIRST_TIME_EXPERIENCE"; + private const string TelemetryOptOutMessage = + $""" + --------- + Telemetry + --------- + The aieval .NET tool collects usage data in order to help us improve your experience. The data is anonymous and doesn't include personal information. You can opt-out of this data collection by setting the {TelemetryOptOutEnvironmentVariableName} environment variable to '1' or 'true' using your favorite shell. + """; + + private static readonly string? _firstUseSentinelFilePath; + private static readonly bool _firstUseSentinelFileExists; + private static readonly bool _shouldDisplayTelemetryOptOutMessage; + + internal static bool IsTelemetryEnabled { get; } + +#pragma warning disable CA1810 + // CA1810: Initialize all static fields when declared. + // We disable this warning because the static fields above must be initialized in a specific order which is not + // guaranteed when initializing via field initializers. + static TelemetryConstants() +#pragma warning restore CA1810 + { + _firstUseSentinelFilePath = GetFirstUseSentinelFilePath(); + _firstUseSentinelFileExists = File.Exists(_firstUseSentinelFilePath); + + _shouldDisplayTelemetryOptOutMessage = + !EnvironmentHelper.GetEnvironmentVariableAsBoolean(SkipFirstTimeExperienceEnvironmentVariableName) && + !_firstUseSentinelFileExists; + + IsTelemetryEnabled = + !EnvironmentHelper.GetEnvironmentVariableAsBoolean(TelemetryOptOutEnvironmentVariableName) && + !_shouldDisplayTelemetryOptOutMessage; + + static string? GetFirstUseSentinelFilePath() + { + string? homeDirectoryPath = Environment.GetEnvironmentVariable("DOTNET_CLI_HOME"); + if (string.IsNullOrWhiteSpace(homeDirectoryPath)) + { + homeDirectoryPath = + Environment.GetEnvironmentVariable( + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "USERPROFILE" : "HOME"); + } + + string? sentinelFilePath = + string.IsNullOrWhiteSpace(homeDirectoryPath) + ? null + : Path.Combine(homeDirectoryPath, ".dotnet", $"{Constants.Version}.aieval.dotnetFirstUseSentinel"); + + return sentinelFilePath; + } + } + +#pragma warning disable EA0014 // The async method doesn't support cancellation. + internal static async Task DisplayTelemetryOptOutMessageIfNeededAsync(this ILogger logger) +#pragma warning restore EA0014 + { + if (_shouldDisplayTelemetryOptOutMessage) + { +#pragma warning disable CA1303 // Do not pass literals as localized parameters. + // Use Console.WriteLine directly instead of ILogger to ensure proper formatting. + System.Console.WriteLine(TelemetryOptOutMessage); + System.Console.WriteLine(); +#pragma warning restore CA1303 + } + + if (_firstUseSentinelFilePath is null) + { + logger.LogWarning("Could not determine sentinel file path."); + return; + } + + if (_firstUseSentinelFileExists) + { + return; + } + + try + { + await File.WriteAllBytesAsync(_firstUseSentinelFilePath, []).ConfigureAwait(false); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to create sentinel file."); + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Telemetry/TelemetryExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Telemetry/TelemetryExtensions.cs new file mode 100644 index 00000000000..685e7153053 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Telemetry/TelemetryExtensions.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.Threading.Tasks; +using Microsoft.Extensions.AI.Evaluation.Console.Utilities; +using Microsoft.Extensions.AI.Evaluation.Utilities; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Text; + +namespace Microsoft.Extensions.AI.Evaluation.Console.Telemetry; + +internal static class TelemetryExtensions +{ + internal static string ToTelemetryPropertyValue(this bool value) => + value ? TelemetryConstants.PropertyValues.True : TelemetryConstants.PropertyValues.False; + + internal static string ToTelemetryPropertyValue(this int value) => + value.ToInvariantString(); + + internal static string ToTelemetryPropertyValue(this long value) => + value.ToInvariantString(); + + internal static string ToTelemetryPropertyValue(this string? value, string defaultValue) => + string.IsNullOrWhiteSpace(value) ? defaultValue : value; + + internal static void ReportOperation( + this TelemetryHelper telemetryHelper, + string operationName, + Action operation, + IDictionary? properties = null, + IDictionary? metrics = null, + ILogger? logger = null) + { + try + { + TimeSpan duration = TimingHelper.ExecuteWithTiming(operation); + telemetryHelper.ReportOperationSuccess(operationName, duration, properties, metrics, logger); + } + catch (Exception ex) + { + telemetryHelper.ReportOperationFailure(operationName, ex, properties, metrics, logger); + throw; + } + } + + internal static TResult ReportOperation( + this TelemetryHelper telemetryHelper, + string operationName, + Func operation, + IDictionary? properties = null, + IDictionary? metrics = null, + ILogger? logger = null) + { + try + { + (TResult result, TimeSpan duration) = TimingHelper.ExecuteWithTiming(operation); + telemetryHelper.ReportOperationSuccess(operationName, duration, properties, metrics, logger); + return result; + } + catch (Exception ex) + { + telemetryHelper.ReportOperationFailure(operationName, ex, properties, metrics, logger); + throw; + } + } + +#pragma warning disable EA0014 // The async method doesn't support cancellation. + internal static async ValueTask ReportOperationAsync( + this TelemetryHelper telemetryHelper, + string operationName, + Func operation, + IDictionary? properties = null, + IDictionary? metrics = null, + ILogger? logger = null) + { + try + { + TimeSpan duration = await TimingHelper.ExecuteWithTimingAsync(operation).ConfigureAwait(false); + telemetryHelper.ReportOperationSuccess(operationName, duration, properties, metrics, logger); + } + catch (Exception ex) + { + telemetryHelper.ReportOperationFailure(operationName, ex, properties, metrics, logger); + throw; + } + } + + internal static async ValueTask ReportOperationAsync( + this TelemetryHelper telemetryHelper, + string operationName, + Func operation, + IDictionary? properties = null, + IDictionary? metrics = null, + ILogger? logger = null) + { + try + { + TimeSpan duration = await TimingHelper.ExecuteWithTimingAsync(operation).ConfigureAwait(false); + telemetryHelper.ReportOperationSuccess(operationName, duration, properties, metrics, logger); + } + catch (Exception ex) + { + telemetryHelper.ReportOperationFailure(operationName, ex, properties, metrics, logger); + throw; + } + } + + internal static async ValueTask ReportOperationAsync( + this TelemetryHelper telemetryHelper, + string operationName, + Func> operation, + IDictionary? properties = null, + IDictionary? metrics = null, + ILogger? logger = null) + { + try + { + (TResult result, TimeSpan duration) = + await TimingHelper.ExecuteWithTimingAsync(operation).ConfigureAwait(false); + + telemetryHelper.ReportOperationSuccess(operationName, duration, properties, metrics, logger); + return result; + } + catch (Exception ex) + { + telemetryHelper.ReportOperationFailure(operationName, ex, properties, metrics, logger); + throw; + } + } + + internal static async ValueTask ReportOperationAsync( + this TelemetryHelper telemetryHelper, + string operationName, + Func> operation, + IDictionary? properties = null, + IDictionary? metrics = null, + ILogger? logger = null) + { + try + { + (TResult result, TimeSpan duration) = + await TimingHelper.ExecuteWithTimingAsync(operation).ConfigureAwait(false); + + telemetryHelper.ReportOperationSuccess(operationName, duration, properties, metrics, logger); + return result; + } + catch (Exception ex) + { + telemetryHelper.ReportOperationFailure(operationName, ex, properties, metrics, logger); + throw; + } + } + + private static void ReportOperationSuccess( + this TelemetryHelper telemetryHelper, + string operationName, + TimeSpan duration, + IDictionary? properties = null, + IDictionary? metrics = null, + ILogger? logger = null) + { + void Report() + { + string durationInMilliseconds = duration.ToMillisecondsText(); + + properties ??= new Dictionary(); + properties.Add(TelemetryConstants.PropertyNames.Success, TelemetryConstants.PropertyValues.True); + properties.Add(TelemetryConstants.PropertyNames.DurationInMilliseconds, durationInMilliseconds); + + telemetryHelper.ReportEvent(eventName: operationName, properties, metrics); + } + + if (logger is null) + { + try + { + Report(); + } + catch + { + // Ignore exceptions encountered when trying to report telemetry. + } + } + else + { + // Log and ignore exceptions encountered when trying to report telemetry. + logger.ExecuteWithCatch(Report, swallowUnhandledExceptions: true); + } + } + + private static void ReportOperationFailure( + this TelemetryHelper telemetryHelper, + string operationName, + Exception exception, + IDictionary? properties = null, + IDictionary? metrics = null, + ILogger? logger = null) + { + void Report() + { + properties ??= new Dictionary(); + properties.Add(TelemetryConstants.PropertyNames.Success, TelemetryConstants.PropertyValues.False); + + telemetryHelper.ReportEvent(eventName: operationName, properties, metrics); + telemetryHelper.ReportException(exception, properties, metrics); + } + + if (logger is null) + { + try + { + Report(); + } + catch + { + // Ignore exceptions encountered when trying to report telemetry. + } + } + else + { + // Log and ignore exceptions encountered when trying to report telemetry. + logger.ExecuteWithCatch(Report, swallowUnhandledExceptions: true); + } + } +#pragma warning restore EA0014 +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Telemetry/TelemetryHelper.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Telemetry/TelemetryHelper.cs new file mode 100644 index 00000000000..f72822e150b --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Telemetry/TelemetryHelper.cs @@ -0,0 +1,184 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.ApplicationInsights; +using Microsoft.ApplicationInsights.Extensibility; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Extensions.AI.Evaluation.Console.Telemetry; + +internal sealed class TelemetryHelper : IAsyncDisposable +{ + private readonly ILogger _logger; + private readonly TelemetryConfiguration? _telemetryConfiguration; + private readonly TelemetryClient? _telemetryClient; + private readonly Dictionary? _commonProperties; + private bool _disposed; + + [MemberNotNullWhen(false, nameof(_telemetryConfiguration), nameof(_telemetryClient), nameof(_commonProperties))] + private bool Disabled { get; } + + internal TelemetryHelper(ILogger logger) + { + _logger = logger; + + if (!TelemetryConstants.IsTelemetryEnabled) + { + Disabled = true; + return; + } + + try + { + _telemetryConfiguration = TelemetryConfiguration.CreateDefault(); + _telemetryConfiguration.ConnectionString = TelemetryConstants.ConnectionString; + _telemetryClient = new TelemetryClient(_telemetryConfiguration); + + var deviceIdHelper = new DeviceIdHelper(logger); + string deviceId = deviceIdHelper.GetDeviceId(); + string isCIEnvironment = EnvironmentHelper.IsCIEnvironment().ToTelemetryPropertyValue(); + + _commonProperties = + new Dictionary + { + [TelemetryConstants.PropertyNames.DevDeviceId] = deviceId, + [TelemetryConstants.PropertyNames.OSVersion] = Environment.OSVersion.VersionString, + [TelemetryConstants.PropertyNames.OSPlatform] = Environment.OSVersion.Platform.ToString(), + [TelemetryConstants.PropertyNames.KernelVersion] = RuntimeInformation.OSDescription, + [TelemetryConstants.PropertyNames.RuntimeId] = RuntimeInformation.RuntimeIdentifier, + [TelemetryConstants.PropertyNames.ProductVersion] = Constants.Version, + [TelemetryConstants.PropertyNames.IsCIEnvironment] = isCIEnvironment + }; + + _telemetryClient.Context.Session.Id = Guid.NewGuid().ToString(); + _telemetryClient.Context.Device.OperatingSystem = RuntimeInformation.OSDescription; + } + catch (Exception ex) + { + _logger.LogWarning(ex, $"Failed to initialize {nameof(TelemetryHelper)}."); + + _telemetryConfiguration?.Dispose(); + _telemetryConfiguration = null; + _telemetryClient = null; + _commonProperties = null; + + Disabled = true; + } + } + + public async ValueTask DisposeAsync() + { + if (Disabled || _disposed) + { + return; + } + + try + { + _ = await FlushAsync().ConfigureAwait(false); + _telemetryConfiguration.Dispose(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, $"Failed to dispose {nameof(TelemetryHelper)}."); + } + + _disposed = true; + } + + internal void ReportEvent( + string eventName, + IDictionary? properties = null, + IDictionary? metrics = null) + { + if (Disabled || _disposed) + { + return; + } + + try + { + IDictionary? combinedProperties = GetCombinedProperties(properties); + + _telemetryClient.TrackEvent( + $"{TelemetryConstants.EventNamespace}/{eventName}", + combinedProperties, + metrics); + } + catch (Exception ex) + { + _logger.LogWarning(ex, $"Failed to report event '{eventName}' in telemetry."); + } + } + + internal void ReportException( + Exception exception, + IDictionary? properties = null, + IDictionary? metrics = null) + { + if (Disabled || _disposed) + { + return; + } + + try + { + IDictionary? combinedProperties = GetCombinedProperties(properties); + + _telemetryClient.TrackException(exception, combinedProperties, metrics); + } + catch (Exception ex) + { + _logger.LogWarning(ex, $"Failed to report exception in telemetry."); + } + } + + internal async Task FlushAsync(CancellationToken cancellationToken = default) + { + if (Disabled || _disposed) + { + return false; + } + + try + { + return await _telemetryClient.FlushAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to flush telemetry."); + + return false; + } + } + + private Dictionary? GetCombinedProperties(IDictionary? properties) + { + if (Disabled || _disposed) + { + return null; + } + + Dictionary combinedProperties; + if (properties is null) + { + combinedProperties = _commonProperties; + } + else + { + combinedProperties = new Dictionary(_commonProperties); + foreach (var kvp in properties) + { + combinedProperties.Add(kvp.Key, kvp.Value); + } + } + + return combinedProperties; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Utilities/LoggerExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Utilities/LoggerExtensions.cs index 4ff77f242d1..f18ef6c1f4a 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Utilities/LoggerExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Console/Utilities/LoggerExtensions.cs @@ -24,10 +24,6 @@ internal static void ExecuteWithCatch( { operation(); } - catch (Exception ex) when (swallowUnhandledExceptions && ex.IsCancellation()) - { - // Do nothing. - } catch (Exception ex) when (!ex.IsCancellation() && logger.LogException(ex) && swallowUnhandledExceptions) { // Do nothing. The exception is logged in the when clause above. @@ -44,10 +40,6 @@ internal static void ExecuteWithCatch( { return operation(); } - catch (Exception ex) when (swallowUnhandledExceptions && ex.IsCancellation()) - { - // Do nothing. - } catch (Exception ex) when (!ex.IsCancellation() && logger.LogException(ex) && swallowUnhandledExceptions) { // Do nothing. The exception is logged in the when clause above. @@ -56,7 +48,7 @@ internal static void ExecuteWithCatch( return defaultValue; } -#pragma warning disable EA0014 // The async method doesn't support cancellation +#pragma warning disable EA0014 // The async method doesn't support cancellation. internal static async ValueTask ExecuteWithCatchAsync( this ILogger logger, Func operation, @@ -66,10 +58,6 @@ internal static async ValueTask ExecuteWithCatchAsync( { await operation().ConfigureAwait(false); } - catch (Exception ex) when (swallowUnhandledExceptions && ex.IsCancellation()) - { - // Do nothing. - } catch (Exception ex) when (!ex.IsCancellation() && logger.LogException(ex) && swallowUnhandledExceptions) { // Do nothing. The exception is logged in the when clause above. @@ -85,10 +73,6 @@ internal static async ValueTask ExecuteWithCatchAsync( { await operation().ConfigureAwait(false); } - catch (Exception ex) when (swallowUnhandledExceptions && ex.IsCancellation()) - { - // Do nothing. - } catch (Exception ex) when (!ex.IsCancellation() && logger.LogException(ex) && swallowUnhandledExceptions) { // Do nothing. The exception is logged in the when clause above. @@ -105,10 +89,6 @@ internal static async ValueTask ExecuteWithCatchAsync( { return await operation().ConfigureAwait(false); } - catch (Exception ex) when (swallowUnhandledExceptions && ex.IsCancellation()) - { - // Do nothing. - } catch (Exception ex) when (!ex.IsCancellation() && logger.LogException(ex) && swallowUnhandledExceptions) { // Do nothing. The exception is logged in the when clause above. @@ -127,10 +107,6 @@ internal static async ValueTask ExecuteWithCatchAsync( { return await operation().ConfigureAwait(false); } - catch (Exception ex) when (swallowUnhandledExceptions && ex.IsCancellation()) - { - // Do nothing. - } catch (Exception ex) when (!ex.IsCancellation() && logger.LogException(ex) && swallowUnhandledExceptions) { // Do nothing. The exception is logged in the when clause above. diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/BLEUEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/BLEUEvaluator.cs index f3030ec7cfb..1eecbc20510 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/BLEUEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/BLEUEvaluator.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -50,6 +49,7 @@ public ValueTask EvaluateAsync( var metric = new NumericMetric(BLEUMetricName); var result = new EvaluationResult(metric); + metric.MarkAsBuiltIn(); if (string.IsNullOrWhiteSpace(modelResponse.Text)) { @@ -78,16 +78,27 @@ public ValueTask EvaluateAsync( return new ValueTask(result); } - (double score, TimeSpan duration) = TimingHelper.ExecuteWithTiming(() => - { - string[][] references = context.References.Select(reference => SimpleWordTokenizer.WordTokenize(reference).ToArray()).ToArray(); - string[] hypothesis = SimpleWordTokenizer.WordTokenize(modelResponse.Text).ToArray(); - return BLEUAlgorithm.SentenceBLEU(references, hypothesis, BLEUAlgorithm.DefaultBLEUWeights, SmoothingFunction.Method4); - }); + (double score, TimeSpan duration) = + TimingHelper.ExecuteWithTiming(() => + { + string[][] references = + context.References.Select( + reference => SimpleWordTokenizer.WordTokenize(reference).ToArray()).ToArray(); + + string[] hypothesis = SimpleWordTokenizer.WordTokenize(modelResponse.Text).ToArray(); + + double score = + BLEUAlgorithm.SentenceBLEU( + references, + hypothesis, + BLEUAlgorithm.DefaultBLEUWeights, + SmoothingFunction.Method4); + + return score; + }); metric.Value = score; - string durationText = $"{duration.TotalSeconds.ToString("F4", CultureInfo.InvariantCulture)} s"; - metric.AddOrUpdateMetadata(name: "evaluation-duration", value: durationText); + metric.AddOrUpdateDurationMetadata(duration); metric.AddOrUpdateContext(context); metric.Interpretation = metric.Interpret(); diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/BLEUEvaluatorContext.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/BLEUEvaluatorContext.cs index c1eaf699565..702b47948ed 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/BLEUEvaluatorContext.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/BLEUEvaluatorContext.cs @@ -1,11 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - using System.Collections.Generic; using System.Linq; diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/Common/SimpleWordTokenizer.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/Common/SimpleWordTokenizer.cs index 322ea4cedd6..46ae984cb9d 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/Common/SimpleWordTokenizer.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/Common/SimpleWordTokenizer.cs @@ -6,8 +6,6 @@ using System.Text; using Microsoft.Shared.Diagnostics; -#pragma warning disable S109 // Magic numbers should not be used - namespace Microsoft.Extensions.AI.Evaluation.NLP.Common; /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/F1Evaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/F1Evaluator.cs index e070577c448..52519e0350f 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/F1Evaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/F1Evaluator.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -50,6 +49,7 @@ public ValueTask EvaluateAsync( var metric = new NumericMetric(F1MetricName); var result = new EvaluationResult(metric); + metric.MarkAsBuiltIn(); if (string.IsNullOrWhiteSpace(modelResponse.Text)) { @@ -69,16 +69,17 @@ public ValueTask EvaluateAsync( return new ValueTask(result); } - (double score, TimeSpan duration) = TimingHelper.ExecuteWithTiming(() => - { - string[] reference = SimpleWordTokenizer.WordTokenize(context.GroundTruth).ToArray(); - string[] hypothesis = SimpleWordTokenizer.WordTokenize(modelResponse.Text).ToArray(); - return F1Algorithm.CalculateF1Score(reference, hypothesis); - }); + (double score, TimeSpan duration) = + TimingHelper.ExecuteWithTiming(() => + { + string[] reference = SimpleWordTokenizer.WordTokenize(context.GroundTruth).ToArray(); + string[] hypothesis = SimpleWordTokenizer.WordTokenize(modelResponse.Text).ToArray(); + double score = F1Algorithm.CalculateF1Score(reference, hypothesis); + return score; + }); metric.Value = score; - string durationText = $"{duration.TotalSeconds.ToString("F4", CultureInfo.InvariantCulture)} s"; - metric.AddOrUpdateMetadata(name: "evaluation-duration", value: durationText); + metric.AddOrUpdateDurationMetadata(duration); metric.AddOrUpdateContext(context); metric.Interpretation = metric.Interpret(); diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/F1EvaluatorContext.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/F1EvaluatorContext.cs index d6dafcc3c6a..ff2091d9dc5 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/F1EvaluatorContext.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/F1EvaluatorContext.cs @@ -1,11 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - namespace Microsoft.Extensions.AI.Evaluation.NLP; /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/GLEUEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/GLEUEvaluator.cs index 60df30879a4..04282a6164d 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/GLEUEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/GLEUEvaluator.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -50,6 +49,7 @@ public ValueTask EvaluateAsync( var metric = new NumericMetric(GLEUMetricName); var result = new EvaluationResult(metric); + metric.MarkAsBuiltIn(); if (string.IsNullOrWhiteSpace(modelResponse.Text)) { @@ -78,16 +78,20 @@ public ValueTask EvaluateAsync( return new ValueTask(result); } - (double score, TimeSpan duration) = TimingHelper.ExecuteWithTiming(() => - { - string[][] references = context.References.Select(reference => SimpleWordTokenizer.WordTokenize(reference).ToArray()).ToArray(); - string[] hypothesis = SimpleWordTokenizer.WordTokenize(modelResponse.Text).ToArray(); - return GLEUAlgorithm.SentenceGLEU(references, hypothesis); - }); + (double score, TimeSpan duration) = + TimingHelper.ExecuteWithTiming(() => + { + string[][] references = context.References.Select( + reference => SimpleWordTokenizer.WordTokenize(reference).ToArray()).ToArray(); + + string[] hypothesis = SimpleWordTokenizer.WordTokenize(modelResponse.Text).ToArray(); + + double score = GLEUAlgorithm.SentenceGLEU(references, hypothesis); + return score; + }); metric.Value = score; - string durationText = $"{duration.TotalSeconds.ToString("F4", CultureInfo.InvariantCulture)} s"; - metric.AddOrUpdateMetadata(name: "evaluation-duration", value: durationText); + metric.AddOrUpdateDurationMetadata(duration); metric.AddOrUpdateContext(context); metric.Interpretation = metric.Interpret(); diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/GLEUEvaluatorContext.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/GLEUEvaluatorContext.cs index d98aac6811d..dd5a75d439d 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/GLEUEvaluatorContext.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/GLEUEvaluatorContext.cs @@ -1,11 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - using System.Collections.Generic; using System.Linq; diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/Microsoft.Extensions.AI.Evaluation.NLP.csproj b/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/Microsoft.Extensions.AI.Evaluation.NLP.csproj index 12e7cebb957..7f77f5ce397 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/Microsoft.Extensions.AI.Evaluation.NLP.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/Microsoft.Extensions.AI.Evaluation.NLP.csproj @@ -21,7 +21,11 @@ - + + + + + @@ -33,8 +37,4 @@ - - - - diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/AIToolExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/AIToolExtensions.cs index 3dbc8211416..dcba14f92c0 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/AIToolExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/AIToolExtensions.cs @@ -19,7 +19,7 @@ internal static string RenderAsJson( var toolDefinitionsJsonArray = new JsonArray(); - foreach (AIFunction function in toolDefinitions.OfType()) + foreach (AIFunctionDeclaration function in toolDefinitions.OfType()) { JsonNode functionJsonNode = new JsonObject diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/CoherenceEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/CoherenceEvaluator.cs index 1f93712a35c..7617c6ee2d0 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/CoherenceEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/CoherenceEvaluator.cs @@ -76,6 +76,7 @@ public async ValueTask EvaluateAsync( var metric = new NumericMetric(CoherenceMetricName); var result = new EvaluationResult(metric); + metric.MarkAsBuiltIn(); if (string.IsNullOrWhiteSpace(modelResponse.Text)) { @@ -103,7 +104,6 @@ await TimingHelper.ExecuteWithTimingAsync(() => private static List GetEvaluationInstructions(ChatMessage? userRequest, ChatResponse modelResponse) { -#pragma warning disable S103 // Lines should not be too long const string SystemPrompt = """ # Instruction @@ -113,14 +113,12 @@ private static List GetEvaluationInstructions(ChatMessage? userRequ - **Data**: Your input data include a QUERY and a RESPONSE. - **Tasks**: To complete your evaluation you will be asked to evaluate the Data in different ways. """; -#pragma warning restore S103 List evaluationInstructions = [new ChatMessage(ChatRole.System, SystemPrompt)]; string renderedUserRequest = userRequest?.RenderText() ?? string.Empty; string renderedModelResponse = modelResponse.RenderText(); -#pragma warning disable S103 // Lines should not be too long string evaluationPrompt = $$""" # Definition @@ -193,7 +191,6 @@ private static List GetEvaluationInstructions(ChatMessage? userRequ ## Please provide your answers between the tags: your chain of thoughts, your explanation, your Score. # Output """; -#pragma warning restore S103 evaluationInstructions.Add(new ChatMessage(ChatRole.User, evaluationPrompt)); diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/CompletenessEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/CompletenessEvaluator.cs index 3bd57cf322b..b398ca7b4aa 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/CompletenessEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/CompletenessEvaluator.cs @@ -73,6 +73,7 @@ public async ValueTask EvaluateAsync( var metric = new NumericMetric(CompletenessMetricName); var result = new EvaluationResult(metric); + metric.MarkAsBuiltIn(); if (string.IsNullOrWhiteSpace(modelResponse.Text)) { @@ -111,7 +112,6 @@ private static List GetEvaluationInstructions( ChatResponse modelResponse, CompletenessEvaluatorContext context) { -#pragma warning disable S103 // Lines should not be too long const string SystemPrompt = """ # Instruction @@ -121,14 +121,12 @@ private static List GetEvaluationInstructions( - **Data**: Your input data include Response and Ground Truth. - **Tasks**: To complete your evaluation you will be asked to evaluate the Data in different ways. """; -#pragma warning restore S103 List evaluationInstructions = [new ChatMessage(ChatRole.System, SystemPrompt)]; string renderedModelResponse = modelResponse.RenderText(); string groundTruth = context.GroundTruth; -#pragma warning disable S103 // Lines should not be too long string evaluationPrompt = $$""" # Definition @@ -185,7 +183,6 @@ private static List GetEvaluationInstructions( ## Please provide your answers between the tags: your chain of thoughts, your explanation, your score. # Output """; -#pragma warning restore S103 evaluationInstructions.Add(new ChatMessage(ChatRole.User, evaluationPrompt)); diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/CompletenessEvaluatorContext.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/CompletenessEvaluatorContext.cs index b9750da0a4d..8c7bf5d55e3 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/CompletenessEvaluatorContext.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/CompletenessEvaluatorContext.cs @@ -1,11 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - namespace Microsoft.Extensions.AI.Evaluation.Quality; /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/EquivalenceEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/EquivalenceEvaluator.cs index ced79652bc7..6ca15b7bda4 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/EquivalenceEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/EquivalenceEvaluator.cs @@ -48,11 +48,13 @@ public sealed class EquivalenceEvaluator : IEvaluator /// public IReadOnlyCollection EvaluationMetricNames { get; } = [EquivalenceMetricName]; + // Note: We intentionally don't set MaxOutputTokens below. + // See https://github.com/dotnet/extensions/issues/6814, https://github.com/dotnet/extensions/issues/6945 + // and https://github.com/dotnet/extensions/issues/7002. private static readonly ChatOptions _chatOptions = new ChatOptions { Temperature = 0.0f, - MaxOutputTokens = 1, TopP = 1.0f, PresencePenalty = 0.0f, FrequencyPenalty = 0.0f, @@ -72,6 +74,7 @@ public async ValueTask EvaluateAsync( var metric = new NumericMetric(EquivalenceMetricName); var result = new EvaluationResult(metric); + metric.MarkAsBuiltIn(); if (string.IsNullOrWhiteSpace(modelResponse.Text)) { @@ -113,12 +116,10 @@ private static List GetEvaluationInstructions( ChatResponse modelResponse, EquivalenceEvaluatorContext context) { -#pragma warning disable S103 // Lines should not be too long const string SystemPrompt = """ You are an AI assistant. You will be given the definition of an evaluation metric for assessing the quality of an answer in a question-answering task. Your job is to compute an accurate evaluation score using the provided evaluation metric. You should return a single integer value between 1 to 5 representing the evaluation metric. You will include no other text or information. """; -#pragma warning restore S103 List evaluationInstructions = [new ChatMessage(ChatRole.System, SystemPrompt)]; @@ -126,7 +127,6 @@ private static List GetEvaluationInstructions( string renderedModelResponse = modelResponse.RenderText(); string groundTruth = context.GroundTruth; -#pragma warning disable S103 // Lines should not be too long string evaluationPrompt = $$""" Equivalence, as a metric, measures the similarity between the predicted answer and the correct answer. If the information and content in the predicted answer is similar or equivalent to the correct answer, then the value of the Equivalence metric should be high, else it should be low. Given the question, correct answer, and predicted answer, determine the value of Equivalence metric using the following rating scale: @@ -170,7 +170,6 @@ This rating value should always be an integer between 1 and 5. So the rating pro predicted answer: {{renderedModelResponse}} stars: """; -#pragma warning restore S103 evaluationInstructions.Add(new ChatMessage(ChatRole.User, evaluationPrompt)); diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/EquivalenceEvaluatorContext.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/EquivalenceEvaluatorContext.cs index 31718bacfbf..b45b5b8bbe3 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/EquivalenceEvaluatorContext.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/EquivalenceEvaluatorContext.cs @@ -1,11 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - namespace Microsoft.Extensions.AI.Evaluation.Quality; /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/FluencyEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/FluencyEvaluator.cs index 44a36a6dcaa..41149bafe20 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/FluencyEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/FluencyEvaluator.cs @@ -70,6 +70,7 @@ public async ValueTask EvaluateAsync( var metric = new NumericMetric(FluencyMetricName); var result = new EvaluationResult(metric); + metric.MarkAsBuiltIn(); if (string.IsNullOrWhiteSpace(modelResponse.Text)) { @@ -95,7 +96,6 @@ await TimingHelper.ExecuteWithTimingAsync(() => private static List GetEvaluationInstructions(ChatResponse modelResponse) { -#pragma warning disable S103 // Lines should not be too long const string SystemPrompt = """ # Instruction @@ -105,13 +105,11 @@ private static List GetEvaluationInstructions(ChatResponse modelRes - **Data**: Your input data include a RESPONSE. - **Tasks**: To complete your evaluation you will be asked to evaluate the Data in different ways. """; -#pragma warning restore S103 List evaluationInstructions = [new ChatMessage(ChatRole.System, SystemPrompt)]; string renderedModelResponse = modelResponse.RenderText(); -#pragma warning disable S103 // Lines should not be too long string evaluationPrompt = $$""" # Definition @@ -173,7 +171,6 @@ private static List GetEvaluationInstructions(ChatResponse modelRes ## Please provide your answers between the tags: your chain of thoughts, your explanation, your Score. # Output """; -#pragma warning restore S103 evaluationInstructions.Add(new ChatMessage(ChatRole.User, evaluationPrompt)); diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/GroundednessEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/GroundednessEvaluator.cs index a52fbcf2ad9..706655620e5 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/GroundednessEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/GroundednessEvaluator.cs @@ -71,6 +71,7 @@ public async ValueTask EvaluateAsync( var metric = new NumericMetric(GroundednessMetricName); var result = new EvaluationResult(metric); + metric.MarkAsBuiltIn(); if (string.IsNullOrWhiteSpace(modelResponse.Text)) { @@ -113,7 +114,6 @@ private static List GetEvaluationInstructions( ChatResponse modelResponse, GroundednessEvaluatorContext context) { -#pragma warning disable S103 // Lines should not be too long const string SystemPrompt = """ # Instruction @@ -123,14 +123,12 @@ private static List GetEvaluationInstructions( - **Data**: Your input data include CONTEXT, RESPONSE and an optional QUERY. - **Tasks**: To complete your evaluation you will be asked to evaluate the Data in different ways. """; -#pragma warning restore S103 List evaluationInstructions = [new ChatMessage(ChatRole.System, SystemPrompt)]; string renderedModelResponse = modelResponse.RenderText(); string groundingContext = context.GroundingContext; -#pragma warning disable S103 // Lines should not be too long string evaluationPrompt; if (userRequest is null) { @@ -295,7 +293,6 @@ private static List GetEvaluationInstructions( # Output """; } -#pragma warning restore S103 evaluationInstructions.Add(new ChatMessage(ChatRole.User, evaluationPrompt)); diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/GroundednessEvaluatorContext.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/GroundednessEvaluatorContext.cs index a1c41989f44..d637c4d1ac9 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/GroundednessEvaluatorContext.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/GroundednessEvaluatorContext.cs @@ -1,11 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - namespace Microsoft.Extensions.AI.Evaluation.Quality; /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/IntentResolutionEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/IntentResolutionEvaluator.cs index 4f19d308f10..bce372e467c 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/IntentResolutionEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/IntentResolutionEvaluator.cs @@ -25,7 +25,7 @@ namespace Microsoft.Extensions.AI.Evaluation.Quality; /// /// /// Note that at the moment, only supports evaluating calls to tools that are -/// defined as s. Any other definitions that are supplied via +/// defined as s. Any other definitions that are supplied via /// will be ignored. /// /// @@ -84,6 +84,7 @@ public async ValueTask EvaluateAsync( var metric = new NumericMetric(IntentResolutionMetricName); var result = new EvaluationResult(metric); + metric.MarkAsBuiltIn(); if (!messages.Any()) { @@ -175,7 +176,6 @@ private static List GetEvaluationInstructions( string renderedModelResponse = modelResponse.RenderAsJson(); string? renderedToolDefinitions = context?.ToolDefinitions.RenderAsJson(); -#pragma warning disable S103 // Lines should not be too long string evaluationPrompt = $$""" # Goal @@ -312,7 +312,6 @@ Note that the QUERY can either be a string with a user request or an entire conv # Output """; -#pragma warning restore S103 evaluationInstructions.Add(new ChatMessage(ChatRole.User, evaluationPrompt)); diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/IntentResolutionEvaluatorContext.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/IntentResolutionEvaluatorContext.cs index c19cb5dcd71..c8f407a12d8 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/IntentResolutionEvaluatorContext.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/IntentResolutionEvaluatorContext.cs @@ -19,7 +19,7 @@ namespace Microsoft.Extensions.AI.Evaluation.Quality; /// /// /// Note that at the moment, only supports evaluating calls to tools that are -/// defined as s. Any other definitions that are supplied via +/// defined as s. Any other definitions that are supplied via /// will be ignored. /// /// @@ -36,7 +36,7 @@ public sealed class IntentResolutionEvaluatorContext : EvaluationContext /// /// /// Note that at the moment, only supports evaluating calls to tools that - /// are defined as s. Any other definitions will be ignored. + /// are defined as s. Any other definitions will be ignored. /// /// public IntentResolutionEvaluatorContext(params AITool[] toolDefinitions) @@ -55,7 +55,7 @@ public IntentResolutionEvaluatorContext(params AITool[] toolDefinitions) /// /// /// Note that at the moment, only supports evaluating calls to tools that - /// are defined as s. Any other definitions will be ignored. + /// are defined as s. Any other definitions will be ignored. /// /// public IntentResolutionEvaluatorContext(IEnumerable toolDefinitions) @@ -81,7 +81,7 @@ public IntentResolutionEvaluatorContext(IEnumerable toolDefinitions) /// /// /// Note that at the moment, only supports evaluating calls to tools that - /// are defined as s. Any other definitions that are supplied via + /// are defined as s. Any other definitions that are supplied via /// will be ignored. /// /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/IntentResolutionRating.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/IntentResolutionRating.cs index a1d9b0ef90e..c7eb8791f75 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/IntentResolutionRating.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/IntentResolutionRating.cs @@ -55,7 +55,6 @@ internal sealed class IntentResolutionRating public bool IsInconclusive => ResolutionScore < MinValue || ResolutionScore > MaxValue; [JsonConstructor] -#pragma warning disable S107 // Methods should not have too many parameters public IntentResolutionRating( int resolutionScore, string explanation, @@ -64,7 +63,6 @@ public IntentResolutionRating( bool conversationHasIntent, bool correctIntentDetected, bool intentResolved) -#pragma warning restore S107 { ResolutionScore = resolutionScore; Explanation = explanation; diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/Microsoft.Extensions.AI.Evaluation.Quality.csproj b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/Microsoft.Extensions.AI.Evaluation.Quality.csproj index b539eedef95..a80aa4951bc 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/Microsoft.Extensions.AI.Evaluation.Quality.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/Microsoft.Extensions.AI.Evaluation.Quality.csproj @@ -20,6 +20,7 @@ + diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RelevanceEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RelevanceEvaluator.cs index 3946853a2a4..31f7a68d510 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RelevanceEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RelevanceEvaluator.cs @@ -74,6 +74,7 @@ public async ValueTask EvaluateAsync( var metric = new NumericMetric(RelevanceMetricName); var result = new EvaluationResult(metric); + metric.MarkAsBuiltIn(); if (!messages.TryGetUserRequest(out ChatMessage? userRequest) || string.IsNullOrWhiteSpace(userRequest.Text)) { @@ -108,7 +109,6 @@ await TimingHelper.ExecuteWithTimingAsync(() => private static List GetEvaluationInstructions(ChatMessage userRequest, ChatResponse modelResponse) { -#pragma warning disable S103 // Lines should not be too long const string SystemPrompt = """ # Instruction @@ -118,14 +118,12 @@ private static List GetEvaluationInstructions(ChatMessage userReque - **Data**: Your input data include QUERY and RESPONSE. - **Tasks**: To complete your evaluation you will be asked to evaluate the Data in different ways. """; -#pragma warning restore S103 List evaluationInstructions = [new ChatMessage(ChatRole.System, SystemPrompt)]; string renderedUserRequest = userRequest.RenderText(); string renderedModelResponse = modelResponse.RenderText(); -#pragma warning disable S103 // Lines should not be too long string evaluationPrompt = $$""" # Definition @@ -199,7 +197,6 @@ private static List GetEvaluationInstructions(ChatMessage userReque ## Please provide your answers between the tags: your chain of thoughts, your explanation, your Score. # Output """; -#pragma warning restore S103 evaluationInstructions.Add(new ChatMessage(ChatRole.User, evaluationPrompt)); diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RelevanceTruthAndCompletenessEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RelevanceTruthAndCompletenessEvaluator.cs index 4eb41b15361..a41f7a92824 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RelevanceTruthAndCompletenessEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RelevanceTruthAndCompletenessEvaluator.cs @@ -90,6 +90,10 @@ public async ValueTask EvaluateAsync( var completeness = new NumericMetric(CompletenessMetricName); var result = new EvaluationResult(relevance, truth, completeness); + relevance.MarkAsBuiltIn(); + truth.MarkAsBuiltIn(); + completeness.MarkAsBuiltIn(); + if (!messages.TryGetUserRequest( out ChatMessage? userRequest, out IReadOnlyList conversationHistory) || @@ -139,7 +143,6 @@ private static List GetEvaluationInstructions( string renderedModelResponse = modelResponse.RenderText(); string renderedConversationHistory = conversationHistory.RenderText(); -#pragma warning disable S103 // Lines should not be too long string evaluationPrompt = $$""" Read the History, User Query, and Model Response below and produce your response as a single JSON object. @@ -259,7 +262,6 @@ Step 3a. Record your response as the value of the "completeness" property in the ----- """; -#pragma warning restore S103 return [new ChatMessage(ChatRole.User, evaluationPrompt)]; } diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RelevanceTruthAndCompletenessRating.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RelevanceTruthAndCompletenessRating.cs index 83c76a1825e..8d4cd88fb5a 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RelevanceTruthAndCompletenessRating.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RelevanceTruthAndCompletenessRating.cs @@ -53,20 +53,16 @@ internal sealed class RelevanceTruthAndCompletenessRating private const int MinValue = 1; private const int MaxValue = 5; -#pragma warning disable S1067 // Expressions should not be too complex. public bool IsInconclusive => Relevance < MinValue || Relevance > MaxValue || Truth < MinValue || Truth > MaxValue || Completeness < MinValue || Completeness > MaxValue; -#pragma warning restore S1067 [JsonConstructor] -#pragma warning disable S107 // Methods should not have too many parameters. public RelevanceTruthAndCompletenessRating( int relevance, string relevanceReasoning, string[] relevanceReasons, int truth, string truthReasoning, string[] truthReasons, int completeness, string completenessReasoning, string[] completenessReasons) -#pragma warning restore S107 { (Relevance, RelevanceReasoning, RelevanceReasons, Truth, TruthReasoning, TruthReasons, diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RetrievalEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RetrievalEvaluator.cs index cd2f94456e6..7bd776f2db6 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RetrievalEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/RetrievalEvaluator.cs @@ -79,6 +79,7 @@ public async ValueTask EvaluateAsync( var metric = new NumericMetric(RetrievalMetricName); var result = new EvaluationResult(metric); + metric.MarkAsBuiltIn(); if (!messages.TryGetUserRequest(out ChatMessage? userRequest) || string.IsNullOrWhiteSpace(userRequest.Text)) { @@ -127,7 +128,6 @@ private static List GetEvaluationInstructions( ChatMessage userRequest, RetrievalEvaluatorContext context) { -#pragma warning disable S103 // Lines should not be too long const string SystemPrompt = """ # Instruction @@ -137,7 +137,6 @@ private static List GetEvaluationInstructions( - **Data**: Your input data include QUERY and CONTEXT. - **Tasks**: To complete your evaluation you will be asked to evaluate the Data in different ways. """; -#pragma warning restore S103 List evaluationInstructions = [new ChatMessage(ChatRole.System, SystemPrompt)]; @@ -157,7 +156,6 @@ private static List GetEvaluationInstructions( _ = builder.Append(']'); string renderedContext = builder.ToString(); -#pragma warning disable S103 // Lines should not be too long string evaluationPrompt = $$""" # Definition @@ -224,7 +222,6 @@ private static List GetEvaluationInstructions( ## Please provide your answers between the tags: your chain of thoughts, your explanation, your Score. # Output """; -#pragma warning restore S103 evaluationInstructions.Add(new ChatMessage(ChatRole.User, evaluationPrompt)); diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/TaskAdherenceEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/TaskAdherenceEvaluator.cs index cf4ba4073ee..5f447138312 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/TaskAdherenceEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/TaskAdherenceEvaluator.cs @@ -24,7 +24,7 @@ namespace Microsoft.Extensions.AI.Evaluation.Quality; /// /// /// Note that at the moment, only supports evaluating calls to tools that are -/// defined as s. Any other definitions that are supplied via +/// defined as s. Any other definitions that are supplied via /// will be ignored. /// /// @@ -83,6 +83,7 @@ public async ValueTask EvaluateAsync( var metric = new NumericMetric(TaskAdherenceMetricName); var result = new EvaluationResult(metric); + metric.MarkAsBuiltIn(); if (!messages.Any()) { @@ -165,7 +166,6 @@ private static List GetEvaluationInstructions( string renderedModelResponse = modelResponse.RenderAsJson(); string? renderedToolDefinitions = context?.ToolDefinitions.RenderAsJson(); -#pragma warning disable S103 // Lines should not be too long string systemPrompt = $$""" # Instruction @@ -260,7 +260,6 @@ Response meets the core requirements but lacks precision or clarity. ## Please provide your answers between the tags: your chain of thoughts, your explanation, your score. # Output """; -#pragma warning restore S103 List evaluationInstructions = [new ChatMessage(ChatRole.System, systemPrompt)]; return evaluationInstructions; diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/TaskAdherenceEvaluatorContext.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/TaskAdherenceEvaluatorContext.cs index c8e94d03b26..535306b5d4e 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/TaskAdherenceEvaluatorContext.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/TaskAdherenceEvaluatorContext.cs @@ -20,7 +20,7 @@ namespace Microsoft.Extensions.AI.Evaluation.Quality; /// /// /// Note that at the moment, only supports evaluating calls to tools that are -/// defined as s. Any other definitions that are supplied via +/// defined as s. Any other definitions that are supplied via /// will be ignored. /// /// @@ -37,7 +37,7 @@ public sealed class TaskAdherenceEvaluatorContext : EvaluationContext /// /// /// Note that at the moment, only supports evaluating calls to tools that - /// are defined as s. Any other definitions will be ignored. + /// are defined as s. Any other definitions will be ignored. /// /// public TaskAdherenceEvaluatorContext(params AITool[] toolDefinitions) @@ -56,7 +56,7 @@ public TaskAdherenceEvaluatorContext(params AITool[] toolDefinitions) /// /// /// Note that at the moment, only supports evaluating calls to tools that - /// are defined as s. Any other definitions will be ignored. + /// are defined as s. Any other definitions will be ignored. /// /// public TaskAdherenceEvaluatorContext(IEnumerable toolDefinitions) @@ -83,7 +83,7 @@ public TaskAdherenceEvaluatorContext(IEnumerable toolDefinitions) /// /// /// Note that at the moment, only supports evaluating calls to tools that are - /// defined as s. Any other definitions that are supplied via + /// defined as s. Any other definitions that are supplied via /// will be ignored. /// /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/ToolCallAccuracyEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/ToolCallAccuracyEvaluator.cs index 5b3631bf598..05dbf4bbc1d 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/ToolCallAccuracyEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/ToolCallAccuracyEvaluator.cs @@ -25,7 +25,7 @@ namespace Microsoft.Extensions.AI.Evaluation.Quality; /// /// /// Note that at the moment, only supports evaluating calls to tools that are -/// defined as s. Any other definitions that are supplied via +/// defined as s. Any other definitions that are supplied via /// will be ignored. /// /// @@ -85,6 +85,7 @@ public async ValueTask EvaluateAsync( var metric = new BooleanMetric(ToolCallAccuracyMetricName); var result = new EvaluationResult(metric); + metric.MarkAsBuiltIn(); if (!messages.Any()) { @@ -156,7 +157,6 @@ private static List GetEvaluationInstructions( ChatResponse modelResponse, ToolCallAccuracyEvaluatorContext context) { -#pragma warning disable S103 // Lines should not be too long const string SystemPrompt = """ # Instruction @@ -166,7 +166,6 @@ private static List GetEvaluationInstructions( - **Data**: Your input data include CONVERSATION , TOOL CALL and TOOL DEFINITION. - **Tasks**: To complete your evaluation you will be asked to evaluate the Data in different ways. """; -#pragma warning restore S103 List evaluationInstructions = [new ChatMessage(ChatRole.System, SystemPrompt)]; @@ -174,7 +173,6 @@ private static List GetEvaluationInstructions( string renderedToolCallsAndResults = modelResponse.RenderToolCallsAndResultsAsJson(); string renderedToolDefinitions = context.ToolDefinitions.RenderAsJson(); -#pragma warning disable S103 // Lines should not be too long string evaluationPrompt = $$""" # Definition @@ -217,7 +215,6 @@ 3. TOOL CALL has parameters that is present in TOOL DEFINITION. ## Please provide your answers between the tags: your chain of thoughts, your explanation, your Score. # Output """; -#pragma warning restore S103 evaluationInstructions.Add(new ChatMessage(ChatRole.User, evaluationPrompt)); diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/ToolCallAccuracyEvaluatorContext.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/ToolCallAccuracyEvaluatorContext.cs index 037d811e0f4..7b01b3f50eb 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/ToolCallAccuracyEvaluatorContext.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Quality/ToolCallAccuracyEvaluatorContext.cs @@ -21,7 +21,7 @@ namespace Microsoft.Extensions.AI.Evaluation.Quality; /// /// /// Note that at the moment, only supports evaluating calls to tools that are -/// defined as s. Any other definitions that are supplied via +/// defined as s. Any other definitions that are supplied via /// will be ignored. /// /// @@ -38,7 +38,7 @@ public sealed class ToolCallAccuracyEvaluatorContext : EvaluationContext /// /// /// Note that at the moment, only supports evaluating calls to tools that - /// are defined as s. Any other definitions will be ignored. + /// are defined as s. Any other definitions will be ignored. /// /// public ToolCallAccuracyEvaluatorContext(params AITool[] toolDefinitions) @@ -57,7 +57,7 @@ public ToolCallAccuracyEvaluatorContext(params AITool[] toolDefinitions) /// /// /// Note that at the moment, only supports evaluating calls to tools that - /// are defined as s. Any other definitions will be ignored. + /// are defined as s. Any other definitions will be ignored. /// /// public ToolCallAccuracyEvaluatorContext(IEnumerable toolDefinitions) @@ -85,7 +85,7 @@ public ToolCallAccuracyEvaluatorContext(IEnumerable toolDefinitions) /// /// /// Note that at the moment, only supports evaluating calls to tools that - /// are defined as s. Any other definitions that are supplied via + /// are defined as s. Any other definitions that are supplied via /// will be ignored. /// /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/JsonSerialization/AzureStorageJsonUtilities.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/JsonSerialization/AzureStorageJsonUtilities.cs index b36c8d8bd56..da8d699699d 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/JsonSerialization/AzureStorageJsonUtilities.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/JsonSerialization/AzureStorageJsonUtilities.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.Diagnostics.CodeAnalysis; -using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; @@ -12,19 +10,16 @@ namespace Microsoft.Extensions.AI.Evaluation.Reporting.JsonSerialization; internal static partial class AzureStorageJsonUtilities { - [SuppressMessage("Naming", "CA1716:Identifiers should not match keywords", Justification = "Default matches the generated source naming convention.")] internal static class Default { - private static JsonSerializerOptions? _options; - internal static JsonSerializerOptions Options => _options ??= CreateJsonSerializerOptions(writeIndented: true); + internal static JsonSerializerOptions Options => field ??= CreateJsonSerializerOptions(writeIndented: true); internal static JsonTypeInfo CacheEntryTypeInfo => Options.GetTypeInfo(); internal static JsonTypeInfo ScenarioRunResultTypeInfo => Options.GetTypeInfo(); } internal static class Compact { - private static JsonSerializerOptions? _options; - internal static JsonSerializerOptions Options => _options ??= CreateJsonSerializerOptions(writeIndented: false); + internal static JsonSerializerOptions Options => field ??= CreateJsonSerializerOptions(writeIndented: false); internal static JsonTypeInfo CacheEntryTypeInfo => Options.GetTypeInfo(); internal static JsonTypeInfo ScenarioRunResultTypeInfo => Options.GetTypeInfo(); } @@ -36,7 +31,6 @@ private static JsonSerializerOptions CreateJsonSerializerOptions(bool writeInden var options = new JsonSerializerOptions(JsonContext.Default.Options) { WriteIndented = writeIndented, - Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, }; options.TypeInfoResolverChain.Add(AIJsonUtilities.DefaultOptions.TypeInfoResolver!); options.MakeReadOnly(); diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Microsoft.Extensions.AI.Evaluation.Reporting.Azure.csproj b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Microsoft.Extensions.AI.Evaluation.Reporting.Azure.csproj index 77ed508757c..ddc986f187a 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Microsoft.Extensions.AI.Evaluation.Reporting.Azure.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Microsoft.Extensions.AI.Evaluation.Reporting.Azure.csproj @@ -4,8 +4,6 @@ A library that supports the Microsoft.Extensions.AI.Evaluation.Reporting library with an implementation for caching LLM responses and storing the evaluation results in an Azure Storage container. $(TargetFrameworks);netstandard2.0 Microsoft.Extensions.AI.Evaluation.Reporting - - $(NoWarn);EA0002 diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Storage/AzureStorageReportingConfiguration.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Storage/AzureStorageReportingConfiguration.cs index 3131fc1c5d9..197d795e742 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Storage/AzureStorageReportingConfiguration.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Storage/AzureStorageReportingConfiguration.cs @@ -66,7 +66,6 @@ public static class AzureStorageReportingConfiguration /// (persisted to the cache using older versions of the library) will no longer be used - instead new responses /// will be fetched from the LLM and added to the cache for use in subsequent executions. /// -#pragma warning disable S107 // Methods should not have too many parameters public static ReportingConfiguration Create( DataLakeDirectoryClient client, IEnumerable evaluators, @@ -77,7 +76,6 @@ public static ReportingConfiguration Create( string executionName = Defaults.DefaultExecutionName, Func? evaluationMetricInterpreter = null, IEnumerable? tags = null) -#pragma warning restore S107 { IEvaluationResponseCacheProvider? responseCacheProvider = chatConfiguration is not null && enableResponseCaching diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Storage/AzureStorageResponseCache.CacheEntry.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Storage/AzureStorageResponseCache.CacheEntry.cs index c40c4bafcf1..eedf15d75ba 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Storage/AzureStorageResponseCache.CacheEntry.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Storage/AzureStorageResponseCache.CacheEntry.cs @@ -1,11 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - using System; using System.Globalization; using System.IO; diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Storage/AzureStorageResponseCache.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Storage/AzureStorageResponseCache.cs index 5e83b456a0b..05f4f01eeaf 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Storage/AzureStorageResponseCache.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Storage/AzureStorageResponseCache.cs @@ -1,11 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - #pragma warning disable CA1725 // CA1725: Parameter names should match base declaration. // All functions on 'IDistributedCache' use the parameter name 'token' in place of 'cancellationToken'. However, diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Storage/AzureStorageResponseCacheProvider.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Storage/AzureStorageResponseCacheProvider.cs index 6c6d1431a1a..3507bd3769e 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Storage/AzureStorageResponseCacheProvider.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Azure/Storage/AzureStorageResponseCacheProvider.cs @@ -1,11 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - using System; using System.Threading; using System.Threading.Tasks; diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ChatDetails.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ChatDetails.cs index 623485a8460..744a1098560 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ChatDetails.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ChatDetails.cs @@ -13,17 +13,11 @@ namespace Microsoft.Extensions.AI.Evaluation.Reporting; /// public sealed class ChatDetails { -#pragma warning disable CA2227 - // CA2227: Collection properties should be read only. - // We disable this warning because we want this type to be fully mutable for serialization purposes and for general - // convenience. - /// /// Gets or sets the for the LLM chat conversation turns recorded in this /// object. /// public IList TurnDetails { get; set; } -#pragma warning restore CA2227 /// /// Initializes a new instance of the class. diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ChatTurnDetails.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ChatTurnDetails.cs index f6c8628dd54..2b1ad34e1ca 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ChatTurnDetails.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ChatTurnDetails.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. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - using System; +using System.Text.Json.Serialization; namespace Microsoft.Extensions.AI.Evaluation.Reporting; @@ -14,39 +10,13 @@ namespace Microsoft.Extensions.AI.Evaluation.Reporting; /// A class that records details related to a particular LLM chat conversation turn involved in the execution of a /// particular . /// -/// -/// The duration between the time when the request was sent to the LLM and the time when the response was received for -/// the chat conversation turn. -/// -/// -/// The model that was used in the creation of the response for the chat conversation turn. Can be -/// if this information was not available via . -/// -/// -/// Usage details for the chat conversation turn (including input and output token counts). Can be -/// if usage details were not available via . -/// -/// -/// The cache key for the cached model response for the chat conversation turn if response caching was enabled; -/// otherwise. -/// -/// -/// if response caching was enabled and the model response for the chat conversation turn was -/// retrieved from the cache; if response caching was enabled and the model response was not -/// retrieved from the cache; if response caching was disabled. -/// -public sealed class ChatTurnDetails( - TimeSpan latency, - string? model = null, - UsageDetails? usage = null, - string? cacheKey = null, - bool? cacheHit = null) +public sealed class ChatTurnDetails { /// /// Gets or sets the duration between the time when the request was sent to the LLM and the time when the response /// was received for the chat conversation turn. /// - public TimeSpan Latency { get; set; } = latency; + public TimeSpan Latency { get; set; } /// /// Gets or sets the model that was used in the creation of the response for the chat conversation turn. @@ -54,7 +24,16 @@ public sealed class ChatTurnDetails( /// /// Returns if this information was not available via . /// - public string? Model { get; set; } = model; + public string? Model { get; set; } + + /// + /// Gets or sets the name of the provider for the model identified by . + /// + /// + /// Can be if this information was not available via the + /// for the . + /// + public string? ModelProvider { get; set; } /// /// Gets or sets usage details for the chat conversation turn (including input and output token counts). @@ -62,7 +41,7 @@ public sealed class ChatTurnDetails( /// /// Returns if usage details were not available via . /// - public UsageDetails? Usage { get; set; } = usage; + public UsageDetails? Usage { get; set; } /// /// Gets or sets the cache key for the cached model response for the chat conversation turn. @@ -70,7 +49,7 @@ public sealed class ChatTurnDetails( /// /// Returns if response caching was disabled. /// - public string? CacheKey { get; set; } = cacheKey; + public string? CacheKey { get; set; } /// /// Gets or sets a value indicating whether the model response was retrieved from the cache. @@ -78,5 +57,85 @@ public sealed class ChatTurnDetails( /// /// Returns if response caching was disabled. /// - public bool? CacheHit { get; set; } = cacheHit; + public bool? CacheHit { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// + /// The duration between the time when the request was sent to the LLM and the time when the response was received + /// for the chat conversation turn. + /// + /// + /// The model that was used in the creation of the response for the chat conversation turn. Can be + /// if this information was not available via . + /// + /// + /// Usage details for the chat conversation turn (including input and output token counts). Can be + /// if usage details were not available via . + /// + /// + /// The cache key for the cached model response for the chat conversation turn if response caching was enabled; + /// otherwise. + /// + /// + /// if response caching was enabled and the model response for the chat conversation turn + /// was retrieved from the cache; if response caching was enabled and the model response + /// was not retrieved from the cache; if response caching was disabled. + /// + public ChatTurnDetails( + TimeSpan latency, + string? model = null, + UsageDetails? usage = null, + string? cacheKey = null, + bool? cacheHit = null) + : this(latency, model, modelProvider: null, usage, cacheKey, cacheHit) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// + /// The duration between the time when the request was sent to the LLM and the time when the response was received + /// for the chat conversation turn. + /// + /// + /// The model that was used in the creation of the response for the chat conversation turn. Can be + /// if this information was not available via . + /// + /// + /// The name of the provider for the model identified by . Can be + /// if this information was not available via the for the + /// . + /// + /// + /// Usage details for the chat conversation turn (including input and output token counts). Can be + /// if usage details were not available via . + /// + /// + /// The cache key for the cached model response for the chat conversation turn if response caching was enabled; + /// otherwise. + /// + /// + /// if response caching was enabled and the model response for the chat conversation turn + /// was retrieved from the cache; if response caching was enabled and the model response + /// was not retrieved from the cache; if response caching was disabled. + /// + [JsonConstructor] + public ChatTurnDetails( + TimeSpan latency, + string? model, + string? modelProvider, + UsageDetails? usage = null, + string? cacheKey = null, + bool? cacheHit = null) + { + Latency = latency; + Model = model; + ModelProvider = modelProvider; + Usage = usage; + CacheKey = cacheKey; + CacheHit = cacheHit; + } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Formats/Dataset.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Formats/Dataset.cs index 1fb8b6c5ec9..146d3ae999b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Formats/Dataset.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Formats/Dataset.cs @@ -1,11 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - using System; using System.Collections.Generic; using System.Text.Json.Serialization; diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/IEvaluationReportWriter.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/IEvaluationReportWriter.cs index 97c1fdca15e..70b6492a17c 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/IEvaluationReportWriter.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/IEvaluationReportWriter.cs @@ -17,7 +17,7 @@ public interface IEvaluationReportWriter /// Writes a report containing all the s present in the supplied /// s. /// - /// An enumeration of s. + /// A collection of run results from which to generate the report. /// A that can cancel the operation. /// A that represents the asynchronous operation. ValueTask WriteReportAsync( diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/JsonSerialization/JsonUtilities.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/JsonSerialization/JsonUtilities.cs index 3a8c2af1ce2..a94282ad7f3 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/JsonSerialization/JsonUtilities.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/JsonSerialization/JsonUtilities.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.Diagnostics.CodeAnalysis; -using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; @@ -13,11 +11,9 @@ namespace Microsoft.Extensions.AI.Evaluation.Reporting.JsonSerialization; internal static partial class JsonUtilities { - [SuppressMessage("Naming", "CA1716:Identifiers should not match keywords", Justification = "Default matches the generated source naming convention.")] internal static class Default { - private static JsonSerializerOptions? _options; - internal static JsonSerializerOptions Options => _options ??= CreateJsonSerializerOptions(writeIndented: true); + internal static JsonSerializerOptions Options => field ??= CreateJsonSerializerOptions(writeIndented: true); internal static JsonTypeInfo DatasetTypeInfo => Options.GetTypeInfo(); internal static JsonTypeInfo CacheEntryTypeInfo => Options.GetTypeInfo(); internal static JsonTypeInfo ScenarioRunResultTypeInfo => Options.GetTypeInfo(); @@ -25,8 +21,7 @@ internal static class Default internal static class Compact { - private static JsonSerializerOptions? _options; - internal static JsonSerializerOptions Options => _options ??= CreateJsonSerializerOptions(writeIndented: false); + internal static JsonSerializerOptions Options => field ??= CreateJsonSerializerOptions(writeIndented: false); internal static JsonTypeInfo DatasetTypeInfo => Options.GetTypeInfo(); internal static JsonTypeInfo CacheEntryTypeInfo => Options.GetTypeInfo(); internal static JsonTypeInfo ScenarioRunResultTypeInfo => Options.GetTypeInfo(); @@ -39,7 +34,6 @@ private static JsonSerializerOptions CreateJsonSerializerOptions(bool writeInden var options = new JsonSerializerOptions(JsonContext.Default.Options) { WriteIndented = writeIndented, - Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, }; options.TypeInfoResolverChain.Add(AIJsonUtilities.DefaultOptions.TypeInfoResolver!); options.MakeReadOnly(); diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Microsoft.Extensions.AI.Evaluation.Reporting.csproj b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Microsoft.Extensions.AI.Evaluation.Reporting.csproj index 8a9ef12f2bd..8ee31bc2b1a 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Microsoft.Extensions.AI.Evaluation.Reporting.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Microsoft.Extensions.AI.Evaluation.Reporting.csproj @@ -11,8 +11,6 @@ A library that contains support for caching LLM responses, storing the results of evaluations and generating reports from that data. $(TargetFrameworks);netstandard2.0 Microsoft.Extensions.AI.Evaluation.Reporting - - $(NoWarn);EA0002 @@ -23,6 +21,10 @@ n/a + + + + diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Microsoft.Extensions.AI.Evaluation.Reporting.json b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Microsoft.Extensions.AI.Evaluation.Reporting.json index 165fbd37d82..c5b0186f0bd 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Microsoft.Extensions.AI.Evaluation.Reporting.json +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Microsoft.Extensions.AI.Evaluation.Reporting.json @@ -1,5 +1,5 @@ { - "Name": "Microsoft.Extensions.AI.Evaluation.Reporting, Version=9.6.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35", + "Name": "Microsoft.Extensions.AI.Evaluation.Reporting, Version=9.9.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35", "Types": [ { "Type": "sealed class Microsoft.Extensions.AI.Evaluation.Reporting.ChatDetails", @@ -40,15 +40,16 @@ ] }, { - // After generating the baseline, manually edit this file to remove primary constructor portion - // This is needed until ICSharpCode.Decompiler adds support for primary constructors - // See: https://github.com/icsharpcode/ILSpy/issues/829 "Type": "sealed class Microsoft.Extensions.AI.Evaluation.Reporting.ChatTurnDetails", "Stage": "Stable", "Methods": [ { "Member": "Microsoft.Extensions.AI.Evaluation.Reporting.ChatTurnDetails.ChatTurnDetails(System.TimeSpan latency, string? model = null, Microsoft.Extensions.AI.UsageDetails? usage = null, string? cacheKey = null, bool? cacheHit = null);", "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.Evaluation.Reporting.ChatTurnDetails.ChatTurnDetails(System.TimeSpan latency, string? model, string? modelProvider, Microsoft.Extensions.AI.UsageDetails? usage = null, string? cacheKey = null, bool? cacheHit = null);", + "Stage": "Stable" } ], "Properties": [ @@ -68,6 +69,10 @@ "Member": "string? Microsoft.Extensions.AI.Evaluation.Reporting.ChatTurnDetails.Model { get; set; }", "Stage": "Stable" }, + { + "Member": "string? Microsoft.Extensions.AI.Evaluation.Reporting.ChatTurnDetails.ModelProvider { get; set; }", + "Stage": "Stable" + }, { "Member": "Microsoft.Extensions.AI.UsageDetails? Microsoft.Extensions.AI.Evaluation.Reporting.ChatTurnDetails.Usage { get; set; }", "Stage": "Stable" diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ReportingConfiguration.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ReportingConfiguration.cs index 2f6613bbcbc..e74ca096f2c 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ReportingConfiguration.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ReportingConfiguration.cs @@ -132,7 +132,6 @@ public sealed class ReportingConfiguration /// A optional set of text tags applicable to all s created using this /// . /// -#pragma warning disable S107 // Methods should not have too many parameters public ReportingConfiguration( IEnumerable evaluators, IEvaluationResultStore resultStore, @@ -142,7 +141,6 @@ public ReportingConfiguration( string executionName = Defaults.DefaultExecutionName, Func? evaluationMetricInterpreter = null, IEnumerable? tags = null) -#pragma warning restore S107 { Evaluators = [.. evaluators]; ResultStore = resultStore; diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ResponseCachingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ResponseCachingChatClient.cs index bc49d76e1be..c983cb87a03 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ResponseCachingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ResponseCachingChatClient.cs @@ -6,6 +6,7 @@ using System.Diagnostics; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.AI.Evaluation.Utilities; using Microsoft.Extensions.Caching.Distributed; namespace Microsoft.Extensions.AI.Evaluation.Reporting; @@ -14,6 +15,7 @@ internal sealed class ResponseCachingChatClient : DistributedCachingChatClient { private readonly ChatDetails _chatDetails; private readonly ConcurrentDictionary _stopWatches; + private readonly ChatClientMetadata? _metadata; internal ResponseCachingChatClient( IChatClient originalChatClient, @@ -23,8 +25,10 @@ internal ResponseCachingChatClient( : base(originalChatClient, cache) { CacheKeyAdditionalValues = [.. cachingKeys]; + _chatDetails = chatDetails; _stopWatches = new ConcurrentDictionary(); + _metadata = this.GetService(); } protected override async Task ReadCacheAsync(string key, CancellationToken cancellationToken) @@ -41,10 +45,14 @@ internal ResponseCachingChatClient( { stopwatch.Stop(); + string? model = response.ModelId; + string? modelProvider = ModelInfo.GetModelProvider(model, _metadata); + _chatDetails.AddTurnDetails( new ChatTurnDetails( latency: stopwatch.Elapsed, - model: response.ModelId, + model, + modelProvider, usage: response.Usage, cacheKey: key, cacheHit: true)); @@ -71,10 +79,14 @@ internal ResponseCachingChatClient( stopwatch.Stop(); ChatResponse response = updates.ToChatResponse(); + string? model = response.ModelId; + string? modelProvider = ModelInfo.GetModelProvider(model, _metadata); + _chatDetails.AddTurnDetails( new ChatTurnDetails( latency: stopwatch.Elapsed, - model: response.ModelId, + model, + modelProvider, usage: response.Usage, cacheKey: key, cacheHit: true)); @@ -91,10 +103,14 @@ protected override async Task WriteCacheAsync(string key, ChatResponse value, Ca { stopwatch.Stop(); + string? model = value.ModelId; + string? modelProvider = ModelInfo.GetModelProvider(model, _metadata); + _chatDetails.AddTurnDetails( new ChatTurnDetails( latency: stopwatch.Elapsed, - model: value.ModelId, + model, + modelProvider, usage: value.Usage, cacheKey: key, cacheHit: false)); @@ -113,10 +129,14 @@ protected override async Task WriteCacheStreamingAsync( stopwatch.Stop(); ChatResponse response = value.ToChatResponse(); + string? model = response.ModelId; + string? modelProvider = ModelInfo.GetModelProvider(model, _metadata); + _chatDetails.AddTurnDetails( new ChatTurnDetails( latency: stopwatch.Elapsed, - model: response.ModelId, + model, + modelProvider, usage: response.Usage, cacheKey: key, cacheHit: false)); diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ScenarioRun.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ScenarioRun.cs index 5fa46e7e4ec..80ca411edb3 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ScenarioRun.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ScenarioRun.cs @@ -100,7 +100,6 @@ public sealed class ScenarioRun : IAsyncDisposable private ScenarioRunResult? _result; -#pragma warning disable S107 // Methods should not have too many parameters internal ScenarioRun( string scenarioName, string iterationName, @@ -111,7 +110,6 @@ internal ScenarioRun( Func? evaluationMetricInterpreter = null, ChatDetails? chatDetails = null, IEnumerable? tags = null) -#pragma warning restore { ScenarioName = scenarioName; IterationName = iterationName; @@ -150,10 +148,8 @@ public async ValueTask EvaluateAsync( { if (_result is not null) { -#pragma warning disable S103 // Lines should not be too long throw new InvalidOperationException( $"The {nameof(ScenarioRun)} with {nameof(ScenarioName)}: {ScenarioName}, {nameof(IterationName)}: {IterationName} and {nameof(ExecutionName)}: {ExecutionName} has already been evaluated. Do not call {nameof(EvaluateAsync)} more than once on a given {nameof(ScenarioRun)}."); -#pragma warning restore S103 } EvaluationResult evaluationResult = diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ScenarioRunResult.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ScenarioRunResult.cs index af2c1d08a4c..e851a48dfdd 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ScenarioRunResult.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/ScenarioRunResult.cs @@ -1,11 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - using System; using System.Collections.Generic; using System.Text.Json.Serialization; @@ -126,17 +121,11 @@ public ScenarioRunResult( /// public DateTime CreationTime { get; set; } = creationTime; -#pragma warning disable CA2227 - // CA2227: Collection properties should be read only. - // We disable this warning because we want this type to be fully mutable for serialization purposes and for general - // convenience. - /// /// Gets or sets the conversation history including the request that produced the being /// evaluated in this . /// public IList Messages { get; set; } = messages; -#pragma warning restore CA2227 /// /// Gets or sets the response being evaluated in this . @@ -165,16 +154,10 @@ public ScenarioRunResult( /// public ChatDetails? ChatDetails { get; set; } = chatDetails; -#pragma warning disable CA2227 - // CA2227: Collection properties should be read only. - // We disable this warning because we want this type to be fully mutable for serialization purposes and for general - // convenience. - /// /// Gets or sets a set of text tags applicable to this . /// public IList? Tags { get; set; } = tags; -#pragma warning restore CA2227 /// /// Gets or sets the version of the format used to persist the current . diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/SimpleChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/SimpleChatClient.cs index 8ef344ab982..875d6a6c26a 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/SimpleChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/SimpleChatClient.cs @@ -6,17 +6,20 @@ using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.AI.Evaluation.Utilities; namespace Microsoft.Extensions.AI.Evaluation.Reporting; internal sealed class SimpleChatClient : DelegatingChatClient { private readonly ChatDetails _chatDetails; + private readonly ChatClientMetadata? _metadata; internal SimpleChatClient(IChatClient originalChatClient, ChatDetails chatDetails) : base(originalChatClient) { _chatDetails = chatDetails; + _metadata = this.GetService(); } public async override Task GetResponseAsync( @@ -37,10 +40,14 @@ public async override Task GetResponseAsync( if (response is not null) { + string? model = response.ModelId; + string? modelProvider = ModelInfo.GetModelProvider(model, _metadata); + _chatDetails.AddTurnDetails( new ChatTurnDetails( latency: stopwatch.Elapsed, - model: response.ModelId, + model, + modelProvider, usage: response.Usage)); } } @@ -74,10 +81,14 @@ public override async IAsyncEnumerable GetStreamingResponseA if (updates is not null) { ChatResponse response = updates.ToChatResponse(); + string? model = response.ModelId; + string? modelProvider = ModelInfo.GetModelProvider(model, _metadata); + _chatDetails.AddTurnDetails( new ChatTurnDetails( latency: stopwatch.Elapsed, - model: response.ModelId, + model, + modelProvider, usage: response.Usage)); } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Storage/DiskBasedReportingConfiguration.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Storage/DiskBasedReportingConfiguration.cs index 10350446229..ad28389aa30 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Storage/DiskBasedReportingConfiguration.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Storage/DiskBasedReportingConfiguration.cs @@ -66,7 +66,6 @@ public static class DiskBasedReportingConfiguration /// (persisted to the cache using older versions of the library) will no longer be used - instead new responses /// will be fetched from the LLM and added to the cache for use in subsequent executions. /// -#pragma warning disable S107 // Methods should not have too many parameters public static ReportingConfiguration Create( string storageRootPath, IEnumerable evaluators, @@ -77,7 +76,6 @@ public static ReportingConfiguration Create( string executionName = Defaults.DefaultExecutionName, Func? evaluationMetricInterpreter = null, IEnumerable? tags = null) -#pragma warning restore S107 { storageRootPath = Path.GetFullPath(storageRootPath); diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Storage/DiskBasedResponseCache.CacheEntry.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Storage/DiskBasedResponseCache.CacheEntry.cs index 6d5000c0395..12ac20923ee 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Storage/DiskBasedResponseCache.CacheEntry.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Storage/DiskBasedResponseCache.CacheEntry.cs @@ -1,11 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - using System; using System.Globalization; using System.IO; diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Storage/DiskBasedResponseCache.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Storage/DiskBasedResponseCache.cs index d0a107d8710..3d2e53a9ff4 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Storage/DiskBasedResponseCache.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Storage/DiskBasedResponseCache.cs @@ -1,11 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - #pragma warning disable CA1725 // CA1725: Parameter names should match base declaration. // All functions on 'IDistributedCache' use the parameter name 'token' in place of 'cancellationToken'. However, diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Storage/DiskBasedResponseCacheProvider.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Storage/DiskBasedResponseCacheProvider.cs index 8b60fe5a272..cfbdb207c0c 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Storage/DiskBasedResponseCacheProvider.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Storage/DiskBasedResponseCacheProvider.cs @@ -1,11 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - using System; using System.Threading; using System.Threading.Tasks; diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Storage/DiskBasedResultStore.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Storage/DiskBasedResultStore.cs index 4662857ec59..72bed04f2cd 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Storage/DiskBasedResultStore.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/CSharp/Storage/DiskBasedResultStore.cs @@ -177,11 +177,9 @@ public ValueTask DeleteResultsAsync( } /// -#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously. public async IAsyncEnumerable GetLatestExecutionNamesAsync( int? count = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) -#pragma warning restore CS1998 { if (count.HasValue && count <= 0) { @@ -204,11 +202,9 @@ public async IAsyncEnumerable GetLatestExecutionNamesAsync( } /// -#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously. public async IAsyncEnumerable GetScenarioNamesAsync( string executionName, [EnumeratorCancellation] CancellationToken cancellationToken = default) -#pragma warning restore CS1998 { IEnumerable executionDirs = EnumerateExecutionDirs(executionName, cancellationToken); @@ -224,12 +220,10 @@ public async IAsyncEnumerable GetScenarioNamesAsync( } /// -#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously. public async IAsyncEnumerable GetIterationNamesAsync( string executionName, string scenarioName, [EnumeratorCancellation] CancellationToken cancellationToken = default) -#pragma warning restore CS1998 { IEnumerable resultFiles = EnumerateResultFiles(executionName, scenarioName, cancellationToken: cancellationToken); diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/ChatDetailsSection.tsx b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/ChatDetailsSection.tsx index b05dcca1eae..545f181220d 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/ChatDetailsSection.tsx +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/ChatDetailsSection.tsx @@ -15,7 +15,8 @@ export const ChatDetailsSection = ({ chatDetails }: { chatDetails: ChatDetails; const hasCacheKey = chatDetails.turnDetails.some(turn => turn.cacheKey !== undefined); const hasCacheStatus = chatDetails.turnDetails.some(turn => turn.cacheHit !== undefined); - const hasModelInfo = chatDetails.turnDetails.some(turn => turn.model !== undefined); + const hasModel = chatDetails.turnDetails.some(turn => turn.model !== undefined); + const hasModelProvider = chatDetails.turnDetails.some(turn => turn.modelProvider !== undefined); const hasInputTokens = chatDetails.turnDetails.some(turn => turn.usage?.inputTokenCount !== undefined); const hasOutputTokens = chatDetails.turnDetails.some(turn => turn.usage?.outputTokenCount !== undefined); const hasTotalTokens = chatDetails.turnDetails.some(turn => turn.usage?.totalTokenCount !== undefined); @@ -42,13 +43,14 @@ export const ChatDetailsSection = ({ chatDetails }: { chatDetails: ChatDetails; {isExpanded && (
- +
{hasCacheKey && Cache Key} {hasCacheStatus && Cache Status} Latency (s) - {hasModelInfo && Model Used} + {hasModel && Model} + {hasModelProvider && Model Provider} {hasInputTokens && Input Tokens} {hasOutputTokens && Output Tokens} {hasTotalTokens && Total Tokens} @@ -92,7 +94,8 @@ export const ChatDetailsSection = ({ chatDetails }: { chatDetails: ChatDetails; )} {turn.latency.toFixed(2)} - {hasModelInfo && {turn.model || '-'}} + {hasModel && {turn.model || '-'}} + {hasModelProvider && {turn.modelProvider || '-'}} {hasInputTokens && {turn.usage?.inputTokenCount || '-'}} {hasOutputTokens && {turn.usage?.outputTokenCount || '-'}} {hasTotalTokens && {turn.usage?.totalTokenCount || '-'}} diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/EvalTypes.d.ts b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/EvalTypes.d.ts index 2b6d84b6086..4d52836975c 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/EvalTypes.d.ts +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/EvalTypes.d.ts @@ -39,6 +39,7 @@ type ChatDetails = { type ChatTurnDetails = { latency: number; model?: string; + modelProvider?: string; usage?: UsageDetails; cacheKey?: string; cacheHit?: boolean; diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/MetricCard.tsx b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/MetricCard.tsx index 5f770298bf4..18476474697 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/MetricCard.tsx +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/components/MetricCard.tsx @@ -242,9 +242,14 @@ export const MetricDisplay = ({ metric }: { metric: MetricWithNoValue | NumericM const classes = useCardStyles(); const { fg, bg } = useCardColors(metric.interpretation); - const pillClass = mergeClasses( - bg, - classes.metricPill, + const pillClass = mergeClasses(bg, classes.metricPill); + const valueClass = mergeClasses(fg, classes.metricValueText); + + return ( + +
+ {metricValue} +
+
); - return (
{metricValue}
); }; diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/package-lock.json b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/package-lock.json index 6e76e88aa03..1282ff91ef4 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/package-lock.json +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/package-lock.json @@ -33,7 +33,7 @@ "tfx-cli": "^0.21.0", "typescript": "^5.5.3", "typescript-eslint": "^8.27.0", - "vite": "^6.3.4", + "vite": "^6.4.1", "vite-plugin-singlefile": "^2.0.2" } }, @@ -747,10 +747,11 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.6.0.tgz", - "integrity": "sha512-WhCn7Z7TauhBtmzhvKpoQs0Wwb/kBcy4CwpuI0/eEIr2Lx2auxmulAzLr91wVZJaz47iUZdkXOK7WlAfxGKCnA==", + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", "dev": true, + "license": "MIT", "dependencies": { "eslint-visitor-keys": "^3.4.3" }, @@ -786,10 +787,11 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz", - "integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", "dev": true, + "license": "Apache-2.0", "dependencies": { "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", @@ -800,19 +802,21 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.1.tgz", - "integrity": "sha512-RI17tsD2frtDu/3dmI7QRrD4bedNKPM08ziRYaC5AhkGrzIAJelm9kJU1TznK+apx6V+cqRz8tfpEeG3oIyjxw==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", + "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.12.0.tgz", - "integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==", + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", "dev": true, + "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, @@ -856,12 +860,16 @@ } }, "node_modules/@eslint/js": { - "version": "9.24.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.24.0.tgz", - "integrity": "sha512-uIY/y3z0uvOGX8cp1C2fiC4+ZmBhp6yZWkojtHL1YEMnRt1Y63HB9TM17proGEmeG7HeUY+UP36F0aknKYTpYA==", + "version": "9.35.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.35.0.tgz", + "integrity": "sha512-30iXE9whjlILfWobBkNerJo+TXYsgVM5ERQwMcMKCHckHflCmf7wXDAHlARoWnh0s1U72WqlbeyE7iAcCzuCPw==", "dev": true, + "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" } }, "node_modules/@eslint/object-schema": { @@ -869,35 +877,25 @@ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.8.tgz", - "integrity": "sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.13.0", + "@eslint/core": "^0.15.2", "levn": "^0.4.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.13.0.tgz", - "integrity": "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==", - "dev": true, - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, "node_modules/@floating-ui/core": { "version": "1.6.9", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz", @@ -3114,7 +3112,8 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/mdast": { "version": "4.0.4", @@ -3298,10 +3297,11 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -3398,10 +3398,11 @@ } }, "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, + "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -3467,26 +3468,6 @@ "integrity": "sha512-OKap/l8oElrynRMEbtwubVW5M5G16LKz9Wxo0DYBmce595lao0EbmD4O82j7qo9yukVWMTDriWvgrbt6yPnb9A==", "dev": true }, - "node_modules/arch": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz", - "integrity": "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, "node_modules/archiver": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/archiver/-/archiver-2.0.3.tgz", @@ -3743,10 +3724,11 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -3989,16 +3971,21 @@ } }, "node_modules/clipboardy": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-1.2.3.tgz", - "integrity": "sha512-2WNImOvCRe6r63Gk9pShfkwXsVtKCroMAevIbiae021mS850UkWPbevxsBz3tnvjZIEGvlwaqCPsw+4ulzNgJA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-4.0.0.tgz", + "integrity": "sha512-5mOlNS0mhX0707P2I0aZ2V/cmHUEO/fL7VFLqszkhUsxt7RwnmrInf/eEQKlf5GzvYeHIjT+Ov1HRfNmymlG0w==", "dev": true, + "license": "MIT", "dependencies": { - "arch": "^2.1.0", - "execa": "^0.8.0" + "execa": "^8.0.1", + "is-wsl": "^3.1.0", + "is64bit": "^2.0.0" }, "engines": { - "node": ">=4" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/color-convert": { @@ -4551,19 +4538,20 @@ } }, "node_modules/eslint": { - "version": "9.24.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.24.0.tgz", - "integrity": "sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ==", + "version": "9.35.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.35.0.tgz", + "integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==", "dev": true, + "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.20.0", - "@eslint/config-helpers": "^0.2.0", - "@eslint/core": "^0.12.0", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.1", + "@eslint/core": "^0.15.2", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.24.0", - "@eslint/plugin-kit": "^0.2.7", + "@eslint/js": "9.35.0", + "@eslint/plugin-kit": "^0.3.5", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -4574,9 +4562,9 @@ "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.3.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -4632,10 +4620,11 @@ } }, "node_modules/eslint-scope": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz", - "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -4648,10 +4637,11 @@ } }, "node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -4660,14 +4650,15 @@ } }, "node_modules/espree": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", - "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.14.0", + "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.0" + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4693,6 +4684,7 @@ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" }, @@ -4728,89 +4720,29 @@ } }, "node_modules/execa": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-0.8.0.tgz", - "integrity": "sha512-zDWS+Rb1E8BlqqhALSt9kUhss8Qq4nN3iof3gsOdyINksElaPyNBtKUMTR62qhvgVWR0CqCX7sdnKe4MnUbFEA==", - "dev": true, - "dependencies": { - "cross-spawn": "^5.0.1", - "get-stream": "^3.0.0", - "is-stream": "^1.1.0", - "npm-run-path": "^2.0.0", - "p-finally": "^1.0.0", - "signal-exit": "^3.0.0", - "strip-eof": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/execa/node_modules/cross-spawn": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz", - "integrity": "sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==", - "dev": true, - "dependencies": { - "lru-cache": "^4.0.1", - "shebang-command": "^1.2.0", - "which": "^1.2.9" - } - }, - "node_modules/execa/node_modules/lru-cache": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", - "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==", - "dev": true, - "dependencies": { - "pseudomap": "^1.0.2", - "yallist": "^2.1.2" - } - }, - "node_modules/execa/node_modules/shebang-command": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", - "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", "dev": true, + "license": "MIT", "dependencies": { - "shebang-regex": "^1.0.0" + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" }, "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/execa/node_modules/shebang-regex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", - "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/execa/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true - }, - "node_modules/execa/node_modules/which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" + "node": ">=16.17" }, - "bin": { - "which": "bin/which" + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/execa/node_modules/yallist": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", - "integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==", - "dev": true - }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -5093,12 +5025,16 @@ } }, "node_modules/get-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", - "integrity": "sha512-GlhdIUuVakc8SJ6kK0zAFbiGzRFzNnY4jUuEbV9UROo4Y+0Ny4fjvcZFVTeDA4odpFyOQzaw6hXukJSq/f28sQ==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", "dev": true, + "license": "MIT", "engines": { - "node": ">=4" + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/get-symbol-description": { @@ -5154,10 +5090,11 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -5363,6 +5300,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -5626,6 +5573,22 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -5698,6 +5661,25 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -5792,12 +5774,16 @@ } }, "node_modules/is-stream": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", - "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", "dev": true, + "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/is-string": { @@ -5891,6 +5877,38 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-wsl": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", + "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is64bit": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is64bit/-/is64bit-2.0.0.tgz", + "integrity": "sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "system-architecture": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -6292,6 +6310,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -6735,6 +6760,19 @@ "node": ">=8.6" } }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", @@ -6837,24 +6875,32 @@ } }, "node_modules/npm-run-path": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", - "integrity": "sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", "dev": true, + "license": "MIT", "dependencies": { - "path-key": "^2.0.0" + "path-key": "^4.0.0" }, "engines": { - "node": ">=4" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/npm-run-path/node_modules/path-key": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", - "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", "dev": true, + "license": "MIT", "engines": { - "node": ">=4" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/object-assign": { @@ -6945,6 +6991,22 @@ "node": ">=0.4.8" } }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -6971,15 +7033,6 @@ "node": ">=0.10.0" } }, - "node_modules/os-tmpdir": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -6997,15 +7050,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/p-finally": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", - "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -7257,12 +7301,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/pseudomap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", - "integrity": "sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==", - "dev": true - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -8124,13 +8162,17 @@ "node": ">=8" } }, - "node_modules/strip-eof": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", - "integrity": "sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==", + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", "dev": true, + "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/strip-json-comments": { @@ -8190,6 +8232,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/system-architecture": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/system-architecture/-/system-architecture-0.1.0.tgz", + "integrity": "sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tabster": { "version": "8.5.4", "resolved": "https://registry.npmjs.org/tabster/-/tabster-8.5.4.tgz", @@ -8219,15 +8274,16 @@ } }, "node_modules/tfx-cli": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/tfx-cli/-/tfx-cli-0.21.1.tgz", - "integrity": "sha512-tQD3XqynSmlR1Teawp6ogUNSXLXsmRfSNSRXXcQ/FePGRp09avIGsZfiF8F03NwzzhMU2HSqbGThY2vc94OBEw==", + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/tfx-cli/-/tfx-cli-0.21.3.tgz", + "integrity": "sha512-EygWAziQ8Mdh9k38zkvNs47PVgU6Mb0QmdyHClOXqJ2u+ZKtREMKt1tYgaSHEwXV2F64ei6+CykFXfzAz7dJrg==", "dev": true, + "license": "MIT", "dependencies": { "app-root-path": "1.0.0", "archiver": "2.0.3", "azure-devops-node-api": "^14.0.0", - "clipboardy": "~1.2.3", + "clipboardy": "^4.0.0", "colors": "~1.3.0", "glob": "7.1.2", "jju": "^1.4.0", @@ -8241,7 +8297,7 @@ "prompt": "^1.3.0", "read": "^1.0.6", "shelljs": "^0.8.5", - "tmp": "0.0.26", + "tmp": "^0.2.4", "tracer": "0.7.4", "util.promisify": "^1.0.0", "uuid": "^3.0.1", @@ -8329,15 +8385,13 @@ } }, "node_modules/tmp": { - "version": "0.0.26", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.26.tgz", - "integrity": "sha512-XYEM7aFncfdEdU4/3jUG2edvFAryxtKbahJXTv8WK34MoOmexbbyNyneT3nY8yPVD3h0J1b5fL6kqlDoyuebQQ==", + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", "dev": true, - "dependencies": { - "os-tmpdir": "~1.0.0" - }, + "license": "MIT", "engines": { - "node": ">=0.4.0" + "node": ">=14.14" } }, "node_modules/to-buffer": { @@ -8774,10 +8828,11 @@ } }, "node_modules/validator": { - "version": "13.15.0", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.0.tgz", - "integrity": "sha512-36B2ryl4+oL5QxZ3AzD0t5SsMNGvTtQHpjgFO5tbNxfXbMFkY822ktCDe1MnlqV3301QQI9SLHDNJokDI+Z9pA==", + "version": "13.15.20", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.20.tgz", + "integrity": "sha1-BU6SOBCVOKG/Rq4+EpCEWmT6IYY=", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.10" } @@ -8809,9 +8864,9 @@ } }, "node_modules/vite": { - "version": "6.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", - "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "version": "6.4.1", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/vite/-/vite-6.4.1.tgz", + "integrity": "sha1-r74UUYzdaIfiQKSwIhq20M5zP5Y=", "dev": true, "license": "MIT", "dependencies": { diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/package.json b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/package.json index 0e32f4ae6f7..7cc3384d10b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/package.json +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting/TypeScript/package.json @@ -34,7 +34,7 @@ "tfx-cli": "^0.21.0", "typescript": "^5.5.3", "typescript-eslint": "^8.27.0", - "vite": "^6.3.4", + "vite": "^6.4.1", "vite-plugin-singlefile": "^2.0.2" } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentHarmEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentHarmEvaluator.cs index e4788bcdc81..0935e5a1f58 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentHarmEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentHarmEvaluator.cs @@ -30,7 +30,7 @@ namespace Microsoft.Extensions.AI.Evaluation.Safety; /// , and /// . /// -#pragma warning disable SA1118 // Parameter should not span multiple lines +#pragma warning disable SA1118 // Parameter should not span multiple lines. public class ContentHarmEvaluator(IDictionary? metricNames = null) : ContentSafetyEvaluator( contentSafetyServiceAnnotationTask: "content harm", diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyChatClient.cs index 347975cb695..01618997069 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyChatClient.cs @@ -1,11 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - using System; using System.Collections.Generic; using System.Diagnostics; @@ -14,14 +9,13 @@ using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.AI.Evaluation.Utilities; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI.Evaluation.Safety; internal sealed class ContentSafetyChatClient : IChatClient { - private const string Moniker = "Azure AI Foundry Evaluation"; - private readonly ContentSafetyService _service; private readonly IChatClient? _originalChatClient; private readonly ChatClientMetadata _metadata; @@ -34,28 +28,23 @@ public ContentSafetyChatClient( _originalChatClient = originalChatClient; ChatClientMetadata? originalMetadata = _originalChatClient?.GetService(); - - string providerName = - $"{Moniker} (" + - $"Subscription: {contentSafetyServiceConfiguration.SubscriptionId}, " + - $"Resource Group: {contentSafetyServiceConfiguration.ResourceGroupName}, " + - $"Project: {contentSafetyServiceConfiguration.ProjectName})"; - - if (originalMetadata?.ProviderName is string originalProviderName && - !string.IsNullOrWhiteSpace(originalProviderName)) + if (originalMetadata is null) { - providerName = $"{originalProviderName}; {providerName}"; + _metadata = + new ChatClientMetadata( + providerName: ModelInfo.KnownModelProviders.AzureAIFoundry, + defaultModelId: ModelInfo.KnownModels.AzureAIFoundryEvaluation); } - - string modelId = Moniker; - - if (originalMetadata?.DefaultModelId is string originalModelId && - !string.IsNullOrWhiteSpace(originalModelId)) + else { - modelId = $"{originalModelId}; {modelId}"; + // If we are wrapping an existing client, prefer its metadata. Preserving the metadata of the inner client + // (when available) ensures that the contained information remains available for requests that are + // delegated to the inner client and serviced by an LLM endpoint. For requests that are not delegated, the + // ChatResponse.ModelId for the produced response would be sufficient to identify that the model used was + // the finetuned model provided by the Azure AI Foundry Evaluation service (even though the outer client's + // metadata will not reflect this). + _metadata = originalMetadata; } - - _metadata = new ChatClientMetadata(providerName, originalMetadata?.ProviderUri, modelId); } public async Task GetResponseAsync( @@ -77,7 +66,7 @@ await _service.AnnotateAsync( return new ChatResponse(new ChatMessage(ChatRole.Assistant, annotationResult)) { - ModelId = Moniker + ModelId = ModelInfo.KnownModels.AzureAIFoundryEvaluation }; } else @@ -110,7 +99,7 @@ await _service.AnnotateAsync( yield return new ChatResponseUpdate(ChatRole.Assistant, annotationResult) { - ModelId = Moniker + ModelId = ModelInfo.KnownModels.AzureAIFoundryEvaluation }; } else diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyChatOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyChatOptions.cs index 741bca9f790..c59f585b4ad 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyChatOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyChatOptions.cs @@ -1,15 +1,27 @@ // 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 S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. +using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI.Evaluation.Safety; -internal sealed class ContentSafetyChatOptions(string annotationTask, string evaluatorName) : ChatOptions +internal sealed class ContentSafetyChatOptions : ChatOptions { - internal string AnnotationTask { get; } = annotationTask; - internal string EvaluatorName { get; } = evaluatorName; + public ContentSafetyChatOptions(string annotationTask, string evaluatorName) + { + AnnotationTask = annotationTask; + EvaluatorName = evaluatorName; + } + + private ContentSafetyChatOptions(ContentSafetyChatOptions other) + : base(Throw.IfNull(other)) + { + AnnotationTask = other.AnnotationTask; + EvaluatorName = other.EvaluatorName; + } + + public string AnnotationTask { get; } + public string EvaluatorName { get; } + + public override ChatOptions Clone() => new ContentSafetyChatOptions(this); } diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyEvaluator.cs index afe90b0ac1d..5f173bfb0b3 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyEvaluator.cs @@ -1,11 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - using System; using System.Collections.Generic; using System.Linq; @@ -30,11 +25,9 @@ namespace Microsoft.Extensions.AI.Evaluation.Safety; /// AI Foundry Evaluation service, to the s of the s /// returned by this . /// -#pragma warning disable S1694 // An abstract class should have both abstract and concrete methods public abstract class ContentSafetyEvaluator( string contentSafetyServiceAnnotationTask, IDictionary metricNames) : IEvaluator -#pragma warning restore S1694 { /// public IReadOnlyCollection EvaluationMetricNames { get; } = [.. metricNames.Values]; @@ -115,13 +108,11 @@ protected async ValueTask EvaluateContentSafetyAsync( { IReadOnlyList? relevantContext = FilterAdditionalContext(additionalContext); -#pragma warning disable S1067 // Expressions should not be too complex if (relevantContext is not null && relevantContext.Any() && relevantContext.SelectMany(c => c.Contents) is IEnumerable contents && contents.Any() && contents.OfType() is IEnumerable textContents && textContents.Any() && string.Join(Environment.NewLine, textContents.Select(c => c.Text)) is string contextString && !string.IsNullOrWhiteSpace(contextString)) -#pragma warning restore S1067 { // Currently we only support supplying a context for the last conversation turn (which is the main one // that is being evaluated). @@ -166,6 +157,7 @@ EvaluationResult UpdateMetrics() metric.Name = metricName; } + metric.MarkAsBuiltIn(); metric.AddOrUpdateChatMetadata(annotationResponse, annotationDuration); metric.Interpretation = @@ -181,7 +173,7 @@ EvaluationResult UpdateMetrics() metric.AddDiagnostics(diagnostics); } -#pragma warning disable S125 // Sections of code should not be commented out +#pragma warning disable S125 // Sections of code should not be commented out. // The following commented code can be useful for debugging purposes. // metric.LogJsonData(payload); // metric.LogJsonData(annotationResult); diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyService.UrlCacheKey.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyService.UrlCacheKey.cs index 41be29e9ed3..9454cb7f0e4 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyService.UrlCacheKey.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyService.UrlCacheKey.cs @@ -1,11 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - using System; namespace Microsoft.Extensions.AI.Evaluation.Safety; @@ -18,30 +13,23 @@ private sealed class UrlCacheKey(ContentSafetyServiceConfiguration configuration internal ContentSafetyServiceConfiguration Configuration { get; } = configuration; internal string AnnotationTask { get; } = annotationTask; - public bool Equals(UrlCacheKey? other) - { - if (other is null) - { - return false; - } - else - { - return - other.Configuration.SubscriptionId == Configuration.SubscriptionId && - other.Configuration.ResourceGroupName == Configuration.ResourceGroupName && - other.Configuration.ProjectName == Configuration.ProjectName && - other.AnnotationTask == AnnotationTask; - } - } + public bool Equals(UrlCacheKey? other) => + other is not null && + other.Configuration.SubscriptionId == Configuration.SubscriptionId && + other.Configuration.ResourceGroupName == Configuration.ResourceGroupName && + other.Configuration.ProjectName == Configuration.ProjectName && + other.Configuration.Endpoint == Configuration.Endpoint && + other.AnnotationTask == AnnotationTask; - public override bool Equals(object? other) - => other is UrlCacheKey otherKey && Equals(otherKey); + public override bool Equals(object? other) => + other is UrlCacheKey otherKey && Equals(otherKey); public override int GetHashCode() => HashCode.Combine( Configuration.SubscriptionId, Configuration.ResourceGroupName, Configuration.ProjectName, + Configuration.Endpoint, AnnotationTask); } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyService.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyService.cs index 6028a82544c..3f81cdbc1e6 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyService.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyService.cs @@ -1,11 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - using System; using System.Collections.Concurrent; using System.Diagnostics; @@ -22,15 +17,13 @@ namespace Microsoft.Extensions.AI.Evaluation.Safety; internal sealed partial class ContentSafetyService(ContentSafetyServiceConfiguration serviceConfiguration) { - private static HttpClient? _sharedHttpClient; - private static HttpClient SharedHttpClient - { - get - { - _sharedHttpClient ??= new HttpClient(); - return _sharedHttpClient; - } - } + private const string APIVersionForServiceDiscoveryInHubBasedProjects = "?api-version=2023-08-01-preview"; + private const string APIVersionForNonHubBasedProjects = "?api-version=2025-05-15-preview"; + + private static HttpClient SharedHttpClient => + field ?? + Interlocked.CompareExchange(ref field, new(), null) ?? + field; private static readonly ConcurrentDictionary _serviceUrlCache = new ConcurrentDictionary(); @@ -41,7 +34,7 @@ private static HttpClient SharedHttpClient internal static EvaluationResult ParseAnnotationResult(string annotationResponse) { -#pragma warning disable S125 // Sections of code should not be commented out +#pragma warning disable S125 // Sections of code should not be commented out. // Example annotation response: // [ // { @@ -168,20 +161,27 @@ private async ValueTask GetServiceUrlAsync( return _serviceUrl; } - string discoveryUrl = - await GetServiceDiscoveryUrlAsync(evaluatorName, cancellationToken).ConfigureAwait(false); - - serviceUrl = - $"{discoveryUrl}/raisvc/v1.0" + - $"/subscriptions/{serviceConfiguration.SubscriptionId}" + - $"/resourceGroups/{serviceConfiguration.ResourceGroupName}" + - $"/providers/Microsoft.MachineLearningServices/workspaces/{serviceConfiguration.ProjectName}"; + if (serviceConfiguration.IsHubBasedProject) + { + string discoveryUrl = + await GetServiceDiscoveryUrlAsync(evaluatorName, cancellationToken).ConfigureAwait(false); + + serviceUrl = + $"{discoveryUrl}/raisvc/v1.0" + + $"/subscriptions/{serviceConfiguration.SubscriptionId}" + + $"/resourceGroups/{serviceConfiguration.ResourceGroupName}" + + $"/providers/Microsoft.MachineLearningServices/workspaces/{serviceConfiguration.ProjectName}"; + } + else + { + serviceUrl = $"{serviceConfiguration.Endpoint.AbsoluteUri}/evaluations"; + } await EnsureServiceAvailabilityAsync( - serviceUrl, - capability: annotationTask, - evaluatorName, - cancellationToken).ConfigureAwait(false); + serviceUrl, + capability: annotationTask, + evaluatorName, + cancellationToken).ConfigureAwait(false); _ = _serviceUrlCache.TryAdd(key, serviceUrl); _serviceUrl = serviceUrl; @@ -196,7 +196,7 @@ private async ValueTask GetServiceDiscoveryUrlAsync( $"https://management.azure.com/subscriptions/{serviceConfiguration.SubscriptionId}" + $"/resourceGroups/{serviceConfiguration.ResourceGroupName}" + $"/providers/Microsoft.MachineLearningServices/workspaces/{serviceConfiguration.ProjectName}" + - $"?api-version=2023-08-01-preview"; + $"{APIVersionForServiceDiscoveryInHubBasedProjects}"; HttpResponseMessage response = await GetResponseAsync( @@ -244,7 +244,10 @@ private async ValueTask EnsureServiceAvailabilityAsync( string evaluatorName, CancellationToken cancellationToken) { - string serviceAvailabilityUrl = $"{serviceUrl}/checkannotation"; + string serviceAvailabilityUrl = + serviceConfiguration.IsHubBasedProject + ? $"{serviceUrl}/checkannotation" + : $"{serviceUrl}/checkannotation{APIVersionForNonHubBasedProjects}"; HttpResponseMessage response = await GetResponseAsync( @@ -297,7 +300,10 @@ private async ValueTask SubmitAnnotationRequestAsync( string evaluatorName, CancellationToken cancellationToken) { - string annotationUrl = $"{serviceUrl}/submitannotation"; + string annotationUrl = + serviceConfiguration.IsHubBasedProject + ? $"{serviceUrl}/submitannotation" + : $"{serviceUrl}/submitannotation{APIVersionForNonHubBasedProjects}"; HttpResponseMessage response = await GetResponseAsync( @@ -376,9 +382,7 @@ await GetResponseAsync( } else { -#pragma warning disable EA0002 // Use 'System.TimeProvider' to make the code easier to test await Task.Delay(InitialDelayInMilliseconds * attempts, cancellationToken).ConfigureAwait(false); -#pragma warning restore EA0002 } } } @@ -426,16 +430,18 @@ private async ValueTask AddHeadersAsync( httpRequestMessage.Headers.Add("User-Agent", userAgent); + TokenRequestContext context = + serviceConfiguration.IsHubBasedProject + ? new TokenRequestContext(scopes: ["https://management.azure.com/.default"]) + : new TokenRequestContext(scopes: ["https://ai.azure.com/.default"]); + AccessToken token = - await serviceConfiguration.Credential.GetTokenAsync( - new TokenRequestContext(scopes: ["https://management.azure.com/.default"]), - cancellationToken).ConfigureAwait(false); + await serviceConfiguration.Credential.GetTokenAsync(context, cancellationToken).ConfigureAwait(false); httpRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token); - if (httpRequestMessage.Content is not null) - { - httpRequestMessage.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); - } +#pragma warning disable IDE0058 // Temporary workaround for Roslyn analyzer issue (see https://github.com/dotnet/roslyn/issues/80499). + httpRequestMessage.Content?.Headers.ContentType = new MediaTypeHeaderValue("application/json"); +#pragma warning restore IDE0058 } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyServiceConfiguration.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyServiceConfiguration.cs index ec721fa59c7..3e814f430e3 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyServiceConfiguration.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyServiceConfiguration.cs @@ -1,69 +1,62 @@ // 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 S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - +using System; +using System.Diagnostics.CodeAnalysis; using System.Net.Http; using Azure.Core; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI.Evaluation.Safety; /// -/// Specifies configuration parameters such as the Azure AI project that should be used, and the credentials that -/// should be used, when a communicates with the Azure AI Foundry Evaluation +/// Specifies configuration parameters, such as the Azure AI Foundry project and the credentials +/// that should be used, when a communicates with the Azure AI Foundry Evaluation /// service to perform evaluations. /// -/// -/// The Azure that should be used when authenticating requests. -/// -/// -/// The ID of the Azure subscription that contains the project identified by . -/// -/// -/// The name of the Azure resource group that contains the project identified by . -/// -/// -/// The name of the Azure AI project. -/// -/// -/// The that should be used when communicating with the Azure AI Foundry Evaluation service. -/// While the parameter is optional, it is recommended to supply an that is configured with -/// robust resilience and retry policies. -/// -/// -/// The timeout (in seconds) after which a should stop retrying failed attempts -/// to communicate with the Azure AI Foundry Evaluation service when performing evaluations. -/// -public sealed class ContentSafetyServiceConfiguration( - TokenCredential credential, - string subscriptionId, - string resourceGroupName, - string projectName, - HttpClient? httpClient = null, - int timeoutInSecondsForRetries = 300) // 5 minutes +/// +/// +/// Azure AI Foundry supports two kinds of projects - Hub-based projects and non-Hub-based projects (also +/// known simply as Foundry projects). See Create a project for Azure AI Foundry. +/// +/// +/// Hub-based projects are configured by specifying the , +/// , and for the project. Non-Hub-based projects, on the +/// other hand, are configured by specifying only the for the project. Use the appropriate +/// constructor overload to initialize based on the kind of project you +/// are working with. +/// +/// +public sealed class ContentSafetyServiceConfiguration { + private const int DefaultTimeoutInSecondsForRetries = 300; // 5 minutes + /// /// Gets the Azure that should be used when authenticating requests. /// - public TokenCredential Credential { get; } = credential; + public TokenCredential Credential { get; } + + /// + /// Gets the ID of the Azure subscription that contains the project identified by if the + /// project is a Hub-based project. + /// + public string? SubscriptionId { get; } /// - /// Gets the ID of the Azure subscription that contains the project identified by . + /// Gets the name of the Azure resource group that contains the project identified by if + /// the project is a Hub-based project. /// - public string SubscriptionId { get; } = subscriptionId; + public string? ResourceGroupName { get; } /// - /// Gets the name of the Azure resource group that contains the project identified by . + /// Gets the name of the Azure AI Foundry project if the project is a Hub-based project. /// - public string ResourceGroupName { get; } = resourceGroupName; + public string? ProjectName { get; } /// - /// Gets the name of the Azure AI project. + /// Gets the endpoint for the Azure AI Foundry project if the project is a non-Hub-based project. /// - public string ProjectName { get; } = projectName; + public Uri? Endpoint { get; } /// /// Gets the that should be used when communicating with the Azure AI Foundry Evaluation @@ -73,11 +66,152 @@ public sealed class ContentSafetyServiceConfiguration( /// While supplying an is optional, it is recommended to supply one that is configured /// with robust resilience and retry policies. /// - public HttpClient? HttpClient { get; } = httpClient; + public HttpClient? HttpClient { get; } /// /// Gets the timeout (in seconds) after which a should stop retrying failed /// attempts to communicate with the Azure AI Foundry Evaluation service when performing evaluations. /// - public int TimeoutInSecondsForRetries { get; } = timeoutInSecondsForRetries; + public int TimeoutInSecondsForRetries { get; } + + [MemberNotNullWhen(true, nameof(SubscriptionId), nameof(ResourceGroupName), nameof(ProjectName))] + [MemberNotNullWhen(false, nameof(Endpoint))] + internal bool IsHubBasedProject => + !string.IsNullOrWhiteSpace(SubscriptionId) && + !string.IsNullOrWhiteSpace(ResourceGroupName) && + !string.IsNullOrWhiteSpace(ProjectName) && + Endpoint is null; + + /// + /// Initializes a new instance of the class for a Hub-based Azure + /// AI Foundry project with the specified . + /// + /// + /// The Azure that should be used when authenticating requests. + /// + /// + /// The ID of the Azure subscription that contains the Hub-based AI Foundry project identified by + /// . + /// + /// + /// The name of the Azure resource group that contains the Hub-based AI Foundry project identified by + /// . + /// + /// + /// The name of the Hub-based Azure AI Foundry project. + /// + /// + /// The that should be used when communicating with the Azure AI Foundry Evaluation + /// service. While the parameter is optional, it is recommended to supply an that is + /// configured with robust resilience and retry policies. + /// + /// + /// The timeout (in seconds) after which a should stop retrying failed + /// attempts to communicate with the Azure AI Foundry Evaluation service when performing evaluations. + /// + /// + /// + /// Azure AI Foundry supports two kinds of projects - Hub-based projects and non-Hub-based projects (also + /// known simply as Foundry projects). See Create a project for Azure AI Foundry. + /// + /// + /// Use this constructor overload if you are working with a Hub-based project. + /// + /// + public ContentSafetyServiceConfiguration( + TokenCredential credential, + string subscriptionId, + string resourceGroupName, + string projectName, + HttpClient? httpClient = null, + int timeoutInSecondsForRetries = DefaultTimeoutInSecondsForRetries) + { + Credential = Throw.IfNull(credential); + SubscriptionId = Throw.IfNullOrWhitespace(subscriptionId); + ResourceGroupName = Throw.IfNullOrWhitespace(resourceGroupName); + ProjectName = Throw.IfNullOrWhitespace(projectName); + HttpClient = httpClient; + TimeoutInSecondsForRetries = timeoutInSecondsForRetries; + } + + /// + /// Initializes a new instance of the class for a non-Hub-based + /// Azure AI Foundry project with the specified . + /// + /// + /// The Azure that should be used when authenticating requests. + /// + /// + /// The endpoint for the non-Hub-based Azure AI Foundry project. + /// + /// + /// The that should be used when communicating with the Azure AI Foundry Evaluation + /// service. While the parameter is optional, it is recommended to supply an that is + /// configured with robust resilience and retry policies. + /// + /// + /// The timeout (in seconds) after which a should stop retrying failed + /// attempts to communicate with the Azure AI Foundry Evaluation service when performing evaluations. + /// + /// + /// + /// Azure AI Foundry supports two kinds of projects - Hub-based projects and non-Hub-based projects (also + /// known simply as Foundry projects). See Create a project for Azure AI Foundry. + /// + /// + /// Use this constructor overload if you are working with a non-Hub-based project. + /// + /// + public ContentSafetyServiceConfiguration( + TokenCredential credential, + Uri endpoint, + HttpClient? httpClient = null, + int timeoutInSecondsForRetries = DefaultTimeoutInSecondsForRetries) + { + Credential = Throw.IfNull(credential); + Endpoint = Throw.IfNull(endpoint); + HttpClient = httpClient; + TimeoutInSecondsForRetries = timeoutInSecondsForRetries; + } + + /// + /// Initializes a new instance of the class for a non-Hub-based + /// Azure AI Foundry project with the specified . + /// + /// + /// The Azure that should be used when authenticating requests. + /// + /// + /// The endpoint URL for the non-Hub-based Azure AI Foundry project. + /// + /// + /// The that should be used when communicating with the Azure AI Foundry Evaluation + /// service. While the parameter is optional, it is recommended to supply an that is + /// configured with robust resilience and retry policies. + /// + /// + /// The timeout (in seconds) after which a should stop retrying failed + /// attempts to communicate with the Azure AI Foundry Evaluation service when performing evaluations. + /// + /// + /// + /// Azure AI Foundry supports two kinds of projects - Hub-based projects and non-Hub-based projects (also + /// known simply as Foundry projects). See Create a project for Azure AI Foundry. + /// + /// + /// Use this constructor overload if you are working with a non-Hub-based project. + /// + /// + public ContentSafetyServiceConfiguration( + TokenCredential credential, + string endpointUrl, + HttpClient? httpClient = null, + int timeoutInSecondsForRetries = DefaultTimeoutInSecondsForRetries) + : this( + credential, + endpoint: new Uri(Throw.IfNullOrWhitespace(endpointUrl)), + httpClient, + timeoutInSecondsForRetries) + { + } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyServicePayloadUtilities.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyServicePayloadUtilities.cs index feecec3be46..1d2f0768a1f 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyServicePayloadUtilities.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/ContentSafetyServicePayloadUtilities.cs @@ -74,7 +74,6 @@ internal static (string payload, IReadOnlyList? diagnostic _ => throw new NotSupportedException($"The payload kind '{payloadFormat}' is not supported."), }; -#pragma warning disable S107 // Methods should not have too many parameters private static (string payload, IReadOnlyList? diagnostics) GetUserTextListPayloadWithEmbeddedXml( IEnumerable conversation, @@ -87,7 +86,6 @@ private static (string payload, IReadOnlyList? diagnostics string contextElementName = "Context", ContentSafetyServicePayloadStrategy strategy = ContentSafetyServicePayloadStrategy.AnnotateConversation, CancellationToken cancellationToken = default) -#pragma warning restore S107 { List> turns; List? normalizedPerTurnContext; @@ -162,7 +160,6 @@ private static (string payload, IReadOnlyList? diagnostics return (payload.ToJsonString(), diagnostics); } -#pragma warning disable S107 // Methods should not have too many parameters private static (string payload, IReadOnlyList? diagnostics) GetUserTextListPayloadWithEmbeddedJson( IEnumerable conversation, @@ -175,7 +172,6 @@ private static (string payload, IReadOnlyList? diagnostics string contextPropertyName = "context", ContentSafetyServicePayloadStrategy strategy = ContentSafetyServicePayloadStrategy.AnnotateLastTurn, CancellationToken cancellationToken = default) -#pragma warning restore S107 { if (strategy is ContentSafetyServicePayloadStrategy.AnnotateConversation) { diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/GroundednessProEvaluatorContext.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/GroundednessProEvaluatorContext.cs index 677fd4154b3..56af247350d 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/GroundednessProEvaluatorContext.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/GroundednessProEvaluatorContext.cs @@ -1,11 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - namespace Microsoft.Extensions.AI.Evaluation.Safety; /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/Microsoft.Extensions.AI.Evaluation.Safety.csproj b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/Microsoft.Extensions.AI.Evaluation.Safety.csproj index 12512e6884c..8cfc9e1b538 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/Microsoft.Extensions.AI.Evaluation.Safety.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/Microsoft.Extensions.AI.Evaluation.Safety.csproj @@ -17,6 +17,8 @@ + + diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/UngroundedAttributesEvaluatorContext.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/UngroundedAttributesEvaluatorContext.cs index b3273b93798..ef72729f6bb 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/UngroundedAttributesEvaluatorContext.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.Safety/UngroundedAttributesEvaluatorContext.cs @@ -1,11 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - namespace Microsoft.Extensions.AI.Evaluation.Safety; /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation/ChatConfiguration.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation/ChatConfiguration.cs index 881816b198b..0d1db0ed487 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation/ChatConfiguration.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation/ChatConfiguration.cs @@ -1,11 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - namespace Microsoft.Extensions.AI.Evaluation; /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation/CompositeEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation/CompositeEvaluator.cs index e3d0fad4caf..07e2636d7f9 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation/CompositeEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation/CompositeEvaluator.cs @@ -50,10 +50,8 @@ public CompositeEvaluator(IEnumerable evaluators) { if (evaluator.EvaluationMetricNames.Count == 0) { -#pragma warning disable S103 // Lines should not be too long throw new InvalidOperationException( $"The '{nameof(evaluator.EvaluationMetricNames)}' property on '{evaluator.GetType().FullName}' returned an empty collection. An evaluator must advertise the names of the metrics that it supports."); -#pragma warning restore S103 } foreach (string metricName in evaluator.EvaluationMetricNames) @@ -149,10 +147,8 @@ async ValueTask EvaluateAsync(IEvaluator e) if (e.EvaluationMetricNames.Count == 0) { -#pragma warning disable S103 // Lines should not be too long throw new InvalidOperationException( $"The '{nameof(e.EvaluationMetricNames)}' property on '{e.GetType().FullName}' returned an empty collection. An evaluator must advertise the names of the metrics that it supports."); -#pragma warning restore S103 } foreach (string metricName in e.EvaluationMetricNames) diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationContext.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationContext.cs index 203d86a84fa..05bdac6e68e 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationContext.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationContext.cs @@ -42,20 +42,13 @@ namespace Microsoft.Extensions.AI.Evaluation; /// contextual information that is modeled by the . /// /// -#pragma warning disable S1694 // An abstract class should have both abstract and concrete methods public abstract class EvaluationContext -#pragma warning restore S1694 { /// /// Gets or sets the name for this . /// public string Name { get; set; } -#pragma warning disable CA2227 - // CA2227: Collection properties should be read only. - // We disable this warning because we want this property to be fully mutable for serialization purposes and for - // general convenience. - /// /// Gets or sets a list of objects that include all the information present in this /// . @@ -97,7 +90,6 @@ public abstract class EvaluationContext /// . /// public IList Contents { get; set; } -#pragma warning restore CA2227 /// /// Initializes a new instance of the class. diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationDiagnostic.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationDiagnostic.cs index 67ec3b13ebb..64ec8e913b9 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationDiagnostic.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationDiagnostic.cs @@ -1,11 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - namespace Microsoft.Extensions.AI.Evaluation; /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationMetric.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationMetric.cs index 19d05c20bc4..112cd53d382 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationMetric.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationMetric.cs @@ -1,11 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - using System.Collections.Generic; using System.Text.Json.Serialization; @@ -43,11 +38,6 @@ public class EvaluationMetric(string name, string? reason = null) /// public EvaluationMetricInterpretation? Interpretation { get; set; } -#pragma warning disable CA2227 - // CA2227: Collection properties should be read only. - // We disable this warning because we want this type to be fully mutable for serialization purposes and for general - // convenience. - /// /// Gets or sets any s that were considered by the as part /// of the evaluation that produced the current . @@ -65,5 +55,4 @@ public class EvaluationMetric(string name, string? reason = null) /// . /// public IDictionary? Metadata { get; set; } -#pragma warning restore CA2227 } diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationMetricExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationMetricExtensions.cs index 534f5e300f7..1940ee562d5 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationMetricExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationMetricExtensions.cs @@ -3,9 +3,10 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; +using Microsoft.Extensions.AI.Evaluation.Utilities; using Microsoft.Shared.Diagnostics; +using Microsoft.Shared.Text; namespace Microsoft.Extensions.AI.Evaluation; @@ -85,7 +86,7 @@ public static void AddDiagnostics(this EvaluationMetric metric, IEnumerable(); + metric.Diagnostics ??= []; foreach (EvaluationDiagnostic diagnostic in diagnostics) { @@ -143,7 +144,8 @@ public static void AddOrUpdateMetadata(this EvaluationMetric metric, IDictionary /// The that contains metadata to be added or updated. /// /// An optional duration that represents the amount of time that it took for the AI model to produce the supplied - /// . If supplied, the duration will also be included as part of the added metadata. + /// . If supplied, the duration (in milliseconds) will also be included as part of the + /// added metadata. /// public static void AddOrUpdateChatMetadata( this EvaluationMetric metric, @@ -154,31 +156,52 @@ public static void AddOrUpdateChatMetadata( if (!string.IsNullOrWhiteSpace(response.ModelId)) { - metric.AddOrUpdateMetadata(name: "evaluation-model-used", value: response.ModelId!); + metric.AddOrUpdateMetadata(name: BuiltInMetricUtilities.EvalModelMetadataName, value: response.ModelId!); } if (response.Usage is UsageDetails usage) { if (usage.InputTokenCount is not null) { - metric.AddOrUpdateMetadata(name: "evaluation-input-tokens-used", value: $"{usage.InputTokenCount}"); + metric.AddOrUpdateMetadata( + name: BuiltInMetricUtilities.EvalInputTokensMetadataName, + value: usage.InputTokenCount.Value.ToInvariantString()); } if (usage.OutputTokenCount is not null) { - metric.AddOrUpdateMetadata(name: "evaluation-output-tokens-used", value: $"{usage.OutputTokenCount}"); + metric.AddOrUpdateMetadata( + name: BuiltInMetricUtilities.EvalOutputTokensMetadataName, + value: usage.OutputTokenCount.Value.ToInvariantString()); } if (usage.TotalTokenCount is not null) { - metric.AddOrUpdateMetadata(name: "evaluation-total-tokens-used", value: $"{usage.TotalTokenCount}"); + metric.AddOrUpdateMetadata( + name: BuiltInMetricUtilities.EvalTotalTokensMetadataName, + value: usage.TotalTokenCount.Value.ToInvariantString()); } } if (duration is not null) { - string durationText = $"{duration.Value.TotalSeconds.ToString("F4", CultureInfo.InvariantCulture)} s"; - metric.AddOrUpdateMetadata(name: "evaluation-duration", value: durationText); + metric.AddOrUpdateDurationMetadata(duration.Value); } } + + /// + /// Adds or updates metadata identifying the amount of time (in milliseconds) that it took to perform the + /// evaluation in the supplied 's dictionary. + /// + /// The . + /// + /// The amount of time that it took to perform the evaluation that produced the supplied . + /// + public static void AddOrUpdateDurationMetadata(this EvaluationMetric metric, TimeSpan duration) + { + string durationInMilliseconds = duration.ToMillisecondsText(); + metric.AddOrUpdateMetadata( + name: BuiltInMetricUtilities.EvalDurationMillisecondsMetadataName, + value: durationInMilliseconds); + } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationMetricInterpretation.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationMetricInterpretation.cs index 5206324edb6..b54f92e57c9 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationMetricInterpretation.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationMetricInterpretation.cs @@ -1,11 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - namespace Microsoft.Extensions.AI.Evaluation; /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationMetric{T}.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationMetric{T}.cs index d2745069bc5..73965d9527d 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationMetric{T}.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationMetric{T}.cs @@ -1,11 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable S3604 -// S3604: Member initializer values should not be redundant. -// We disable this warning because it is a false positive arising from the analyzer's lack of support for C#'s primary -// constructor syntax. - namespace Microsoft.Extensions.AI.Evaluation; /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationResult.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationResult.cs index 94ce86abfd9..a60ebd71e42 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationResult.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationResult.cs @@ -16,17 +16,11 @@ namespace Microsoft.Extensions.AI.Evaluation; /// public sealed class EvaluationResult { -#pragma warning disable CA2227 - // CA2227: Collection properties should be read only. - // We disable this warning because we want this type to be fully mutable for serialization purposes and for general - // convenience. - /// /// Gets or sets a collection of one or more s that represent the result of an /// evaluation. /// public IDictionary Metrics { get; set; } -#pragma warning restore CA2227 /// /// Initializes a new instance of the class. diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationResultExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationResultExtensions.cs index 7ea21dec91f..ba199e26de1 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationResultExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationResultExtensions.cs @@ -173,7 +173,8 @@ public static void AddOrUpdateMetadataInAllMetrics( /// The that contains metadata to be added or updated. /// /// An optional duration that represents the amount of time that it took for the AI model to produce the supplied - /// . If supplied, the duration will also be included as part of the added metadata. + /// . If supplied, the duration (in milliseconds) will also be included as part of the + /// added metadata. /// public static void AddOrUpdateChatMetadataInAllMetrics( this EvaluationResult result, @@ -187,4 +188,24 @@ public static void AddOrUpdateChatMetadataInAllMetrics( metric.AddOrUpdateChatMetadata(response, duration); } } + + /// + /// Adds or updates metadata identifying the amount of time (in milliseconds) that it took to perform the + /// evaluation in all s contained in the supplied . + /// + /// + /// The containing the s that are to be altered. + /// + /// + /// The amount of time that it took to perform the evaluation that produced the supplied . + /// + public static void AddOrUpdateDurationMetadataInAllMetrics(this EvaluationResult result, TimeSpan duration) + { + _ = Throw.IfNull(result); + + foreach (EvaluationMetric metric in result.Metrics.Values) + { + metric.AddOrUpdateDurationMetadata(duration); + } + } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation/Microsoft.Extensions.AI.Evaluation.csproj b/src/Libraries/Microsoft.Extensions.AI.Evaluation/Microsoft.Extensions.AI.Evaluation.csproj index 9e9c1eeec3d..129ae2aab89 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation/Microsoft.Extensions.AI.Evaluation.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation/Microsoft.Extensions.AI.Evaluation.csproj @@ -14,6 +14,10 @@ n/a + + true + + diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation/Microsoft.Extensions.AI.Evaluation.json b/src/Libraries/Microsoft.Extensions.AI.Evaluation/Microsoft.Extensions.AI.Evaluation.json index 59fc0cad90a..d9b464ef6b4 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation/Microsoft.Extensions.AI.Evaluation.json +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation/Microsoft.Extensions.AI.Evaluation.json @@ -259,6 +259,10 @@ "Member": "static void Microsoft.Extensions.AI.Evaluation.EvaluationMetricExtensions.AddOrUpdateContext(this Microsoft.Extensions.AI.Evaluation.EvaluationMetric metric, params Microsoft.Extensions.AI.Evaluation.EvaluationContext[] context);", "Stage": "Stable" }, + { + "Member": "static void Microsoft.Extensions.AI.Evaluation.EvaluationMetricExtensions.AddOrUpdateDurationMetadata(this Microsoft.Extensions.AI.Evaluation.EvaluationMetric metric, System.TimeSpan duration);", + "Stage": "Stable" + }, { "Member": "static void Microsoft.Extensions.AI.Evaluation.EvaluationMetricExtensions.AddOrUpdateMetadata(this Microsoft.Extensions.AI.Evaluation.EvaluationMetric metric, string name, string value);", "Stage": "Stable" @@ -403,6 +407,10 @@ "Member": "static void Microsoft.Extensions.AI.Evaluation.EvaluationResultExtensions.AddOrUpdateContextInAllMetrics(this Microsoft.Extensions.AI.Evaluation.EvaluationResult result, params Microsoft.Extensions.AI.Evaluation.EvaluationContext[] context);", "Stage": "Stable" }, + { + "Member": "static void Microsoft.Extensions.AI.Evaluation.EvaluationResultExtensions.AddOrUpdateDurationMetadataInAllMetrics(this Microsoft.Extensions.AI.Evaluation.EvaluationResult result, System.TimeSpan duration);", + "Stage": "Stable" + }, { "Member": "static void Microsoft.Extensions.AI.Evaluation.EvaluationResultExtensions.AddOrUpdateMetadataInAllMetrics(this Microsoft.Extensions.AI.Evaluation.EvaluationResult result, string name, string value);", "Stage": "Stable" diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation/Utilities/BuiltInMetricUtilities.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation/Utilities/BuiltInMetricUtilities.cs new file mode 100644 index 00000000000..5718cf95a6e --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation/Utilities/BuiltInMetricUtilities.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. + +namespace Microsoft.Extensions.AI.Evaluation.Utilities; + +internal static class BuiltInMetricUtilities +{ + internal const string EvalModelMetadataName = "eval-model"; + internal const string EvalInputTokensMetadataName = "eval-input-tokens"; + internal const string EvalOutputTokensMetadataName = "eval-output-tokens"; + internal const string EvalTotalTokensMetadataName = "eval-total-tokens"; + internal const string EvalDurationMillisecondsMetadataName = "eval-duration-ms"; + internal const string BuiltInEvalMetadataName = "built-in-eval"; + + internal static void MarkAsBuiltIn(this EvaluationMetric metric) => + metric.AddOrUpdateMetadata(name: BuiltInEvalMetadataName, value: bool.TrueString); + + internal static bool IsBuiltIn(this EvaluationMetric metric) => + metric.Metadata?.TryGetValue(BuiltInEvalMetadataName, out string? stringValue) is true && + bool.TryParse(stringValue, out bool value) && + value; +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation/Utilities/ModelInfo.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation/Utilities/ModelInfo.cs new file mode 100644 index 00000000000..483e1fbe77f --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation/Utilities/ModelInfo.cs @@ -0,0 +1,131 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Text.RegularExpressions; + +namespace Microsoft.Extensions.AI.Evaluation.Utilities; + +internal static class ModelInfo +{ + internal static class KnownModels + { + internal const string AzureAIFoundryEvaluation = "azure.ai.foundry.evaluation"; + } + + internal static class KnownModelProviders + { + internal const string AzureAIFoundry = "azure.ai.foundry"; + } + + internal static class KnownModelHostMonikers + { + internal const string LocalMachine = "local"; + internal const string AzureAIFoundry = "azure.ai.foundry"; + internal const string AzureOpenAI = "azure.openai"; + internal const string AzureML = "azure.ml"; + internal const string GitHubModels = "github.models"; + internal const string Azure = "azure"; + internal const string GitHub = "github"; + internal const string Microsoft = "microsoft"; + } + + private const string LocalMachineHost = "localhost"; + + private static Regex LocalMachineHostMonikerRegex { get; } = + new Regex($"\\({Regex.Escape(KnownModelHostMonikers.LocalMachine)}\\)$"); + + // NOTE: Order more specific patterns first. + private static (string hostPattern, string hostMoniker)[] KnownHostMonikers { get; } = + [ + ("services.ai.azure.", KnownModelHostMonikers.AzureAIFoundry), + ("openai.azure.", KnownModelHostMonikers.AzureOpenAI), + ("ml.azure.", KnownModelHostMonikers.AzureML), + ("models.github.ai", KnownModelHostMonikers.GitHubModels), + ("models.inference.ai.azure.", KnownModelHostMonikers.GitHubModels), + (".azure.", KnownModelHostMonikers.Azure), + (".github.", KnownModelHostMonikers.GitHub), + (".microsoft.", KnownModelHostMonikers.Microsoft) + ]; + + private static Regex KnownHostMonikersRegex { get; } = + new Regex( + $"\\((" + + $"{Regex.Escape(KnownModelHostMonikers.AzureAIFoundry)}|" + + $"{Regex.Escape(KnownModelHostMonikers.AzureOpenAI)}|" + + $"{Regex.Escape(KnownModelHostMonikers.AzureML)}|" + + $"{Regex.Escape(KnownModelHostMonikers.GitHubModels)}|" + + $"{Regex.Escape(KnownModelHostMonikers.Azure)}|" + + $"{Regex.Escape(KnownModelHostMonikers.GitHub)}|" + + $"{Regex.Escape(KnownModelHostMonikers.Microsoft)}" + + $")\\)$"); + + /// + /// Returns a string with format {provider} ({host}) where {provider} is the name of the model + /// provider (available via - for example, openai) and + /// {host} is a moniker that identifies the hosting service (for example, azure.openai or + /// github.models). If the hosting service is not recognized, only the name of the model provider is + /// returned. + /// + /// + /// The that identifies the model that produced a particular response. + /// + /// + /// The for the that was used to communicate with the + /// model. + /// + internal static string? GetModelProvider(string? model, ChatClientMetadata? metadata) + { +#pragma warning disable S2219 // Runtime type checking should be simplified. + if (model is KnownModels.AzureAIFoundryEvaluation) +#pragma warning restore S2219 + { + // We know that the model provider and the host are both Azure AI Foundry in this case. + return $"{KnownModelProviders.AzureAIFoundry} ({KnownModelHostMonikers.AzureAIFoundry})"; + } + + if (metadata is null) + { + return null; + } + + string? provider = metadata.ProviderName; + string? host = metadata.ProviderUri?.Host; + + if (!string.IsNullOrWhiteSpace(host)) + { + if (string.Equals(host, LocalMachineHost, StringComparison.OrdinalIgnoreCase)) + { + return $"{provider} ({KnownModelHostMonikers.LocalMachine})"; + } + + foreach (var (hostPattern, hostMoniker) in KnownHostMonikers) + { +#if NET + if (host.Contains(hostPattern, StringComparison.OrdinalIgnoreCase)) +#else + if (host!.IndexOf(hostPattern, StringComparison.OrdinalIgnoreCase) >= 0) +#endif + { + return $"{provider} ({hostMoniker})"; + } + } + } + + return provider; + } + + /// + /// Returns if the specified indicates that the model is + /// hosted by a well-known (Microsoft-owned) service; otherwise. + /// + internal static bool IsModelHostWellKnown(string? modelProvider) + => !string.IsNullOrWhiteSpace(modelProvider) && KnownHostMonikersRegex.IsMatch(modelProvider); + + /// + /// Returns if the specified indicates that the model is + /// hosted locally (using ollama, for example); otherwise. + /// + internal static bool IsModelHostedLocally(string? modelProvider) + => !string.IsNullOrWhiteSpace(modelProvider) && LocalMachineHostMonikerRegex.IsMatch(modelProvider); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation/Utilities/TimingHelper.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation/Utilities/TimingHelper.cs index 74b68b25b9e..fdd16aef3e2 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation/Utilities/TimingHelper.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation/Utilities/TimingHelper.cs @@ -3,12 +3,16 @@ using System; using System.Diagnostics; +using System.Globalization; using System.Threading.Tasks; namespace Microsoft.Extensions.AI.Evaluation.Utilities; internal static class TimingHelper { + internal static string ToMillisecondsText(this TimeSpan duration) => + duration.TotalMilliseconds.ToString("F2", CultureInfo.InvariantCulture); + internal static TimeSpan ExecuteWithTiming(Action operation) { Stopwatch stopwatch = Stopwatch.StartNew(); @@ -42,7 +46,7 @@ internal static (TResult result, TimeSpan duration) ExecuteWithTiming(F return (result, duration: stopwatch.Elapsed); } -#pragma warning disable EA0014 // The async method doesn't support cancellation +#pragma warning disable EA0014 // The async method doesn't support cancellation. internal static async ValueTask ExecuteWithTimingAsync(Func operation) { Stopwatch stopwatch = Stopwatch.StartNew(); diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/JsonContext.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/JsonContext.cs deleted file mode 100644 index 6de0144c7cf..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/JsonContext.cs +++ /dev/null @@ -1,24 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Text.Json.Serialization; - -namespace Microsoft.Extensions.AI; - -[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] -[JsonSerializable(typeof(OllamaChatRequest))] -[JsonSerializable(typeof(OllamaChatRequestMessage))] -[JsonSerializable(typeof(OllamaChatResponse))] -[JsonSerializable(typeof(OllamaChatResponseMessage))] -[JsonSerializable(typeof(OllamaFunctionCallContent))] -[JsonSerializable(typeof(OllamaFunctionResultContent))] -[JsonSerializable(typeof(OllamaFunctionTool))] -[JsonSerializable(typeof(OllamaFunctionToolCall))] -[JsonSerializable(typeof(OllamaFunctionToolParameter))] -[JsonSerializable(typeof(OllamaFunctionToolParameters))] -[JsonSerializable(typeof(OllamaRequestOptions))] -[JsonSerializable(typeof(OllamaTool))] -[JsonSerializable(typeof(OllamaToolCall))] -[JsonSerializable(typeof(OllamaEmbeddingRequest))] -[JsonSerializable(typeof(OllamaEmbeddingResponse))] -internal sealed partial class JsonContext : JsonSerializerContext; diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj b/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj deleted file mode 100644 index 96734131c83..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.csproj +++ /dev/null @@ -1,44 +0,0 @@ - - - - Microsoft.Extensions.AI - Implementation of generative AI abstractions for Ollama. This package is deprecated, and the OllamaSharp package is recommended. - AI - - - - preview - false - 78 - 0 - - - - $(TargetFrameworks);netstandard2.0 - $(NoWarn);CA2227;SA1316;S1121;EA0002 - true - true - - - - true - true - true - true - true - - - - - - - - - - - - - - - - diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs deleted file mode 100644 index 42f75af495e..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatClient.cs +++ /dev/null @@ -1,505 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Net.Http; -using System.Net.Http.Json; -using System.Runtime.CompilerServices; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Shared.Diagnostics; - -#pragma warning disable EA0011 // Consider removing unnecessary conditional access operator (?) -#pragma warning disable SA1204 // Static elements should appear before instance elements -#pragma warning disable S3358 // Ternary operators should not be nested - -namespace Microsoft.Extensions.AI; - -/// Represents an for Ollama. -public sealed class OllamaChatClient : IChatClient -{ - private static readonly JsonElement _schemalessJsonResponseFormatValue = JsonDocument.Parse("\"json\"").RootElement; - - private static readonly AIJsonSchemaTransformCache _schemaTransformCache = new(new() - { - ConvertBooleanSchemas = true, - }); - - /// Metadata about the client. - private readonly ChatClientMetadata _metadata; - - /// The api/chat endpoint URI. - private readonly Uri _apiChatEndpoint; - - /// The to use for sending requests. - private readonly HttpClient _httpClient; - - /// The use for any serialization activities related to tool call arguments and results. - private JsonSerializerOptions _toolCallJsonSerializerOptions = AIJsonUtilities.DefaultOptions; - - /// Initializes a new instance of the class. - /// The endpoint URI where Ollama is hosted. - /// - /// The ID of the model to use. This ID can also be overridden per request via . - /// Either this parameter or must provide a valid model ID. - /// - /// An instance to use for HTTP operations. - public OllamaChatClient(string endpoint, string? modelId = null, HttpClient? httpClient = null) - : this(new Uri(Throw.IfNull(endpoint)), modelId, httpClient) - { - } - - /// Initializes a new instance of the class. - /// The endpoint URI where Ollama is hosted. - /// - /// The ID of the model to use. This ID can also be overridden per request via . - /// Either this parameter or must provide a valid model ID. - /// - /// An instance to use for HTTP operations. - /// is . - /// is empty or composed entirely of whitespace. - public OllamaChatClient(Uri endpoint, string? modelId = null, HttpClient? httpClient = null) - { - _ = Throw.IfNull(endpoint); - if (modelId is not null) - { - _ = Throw.IfNullOrWhitespace(modelId); - } - - _apiChatEndpoint = new Uri(endpoint, "api/chat"); - _httpClient = httpClient ?? OllamaUtilities.SharedClient; - - _metadata = new ChatClientMetadata("ollama", endpoint, modelId); - } - - /// Gets or sets to use for any serialization activities related to tool call arguments and results. - public JsonSerializerOptions ToolCallJsonSerializerOptions - { - get => _toolCallJsonSerializerOptions; - set => _toolCallJsonSerializerOptions = Throw.IfNull(value); - } - - /// - public async Task GetResponseAsync( - IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - { - _ = Throw.IfNull(messages); - - using var httpResponse = await _httpClient.PostAsJsonAsync( - _apiChatEndpoint, - ToOllamaChatRequest(messages, options, stream: false), - JsonContext.Default.OllamaChatRequest, - cancellationToken).ConfigureAwait(false); - - if (!httpResponse.IsSuccessStatusCode) - { - await OllamaUtilities.ThrowUnsuccessfulOllamaResponseAsync(httpResponse, cancellationToken).ConfigureAwait(false); - } - - var response = (await httpResponse.Content.ReadFromJsonAsync( - JsonContext.Default.OllamaChatResponse, - cancellationToken).ConfigureAwait(false))!; - - if (!string.IsNullOrEmpty(response.Error)) - { - throw new InvalidOperationException($"Ollama error: {response.Error}"); - } - - var responseId = Guid.NewGuid().ToString("N"); - - return new(FromOllamaMessage(response.Message!, responseId)) - { - CreatedAt = DateTimeOffset.TryParse(response.CreatedAt, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTimeOffset createdAt) ? createdAt : null, - FinishReason = ToFinishReason(response), - ModelId = response.Model ?? options?.ModelId ?? _metadata.DefaultModelId, - ResponseId = responseId, - Usage = ParseOllamaChatResponseUsage(response), - }; - } - - /// - public async IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - _ = Throw.IfNull(messages); - - using HttpRequestMessage request = new(HttpMethod.Post, _apiChatEndpoint) - { - Content = JsonContent.Create(ToOllamaChatRequest(messages, options, stream: true), JsonContext.Default.OllamaChatRequest) - }; - using var httpResponse = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - - if (!httpResponse.IsSuccessStatusCode) - { - await OllamaUtilities.ThrowUnsuccessfulOllamaResponseAsync(httpResponse, cancellationToken).ConfigureAwait(false); - } - - // Ollama doesn't set a response ID on streamed chunks, so we need to generate one. - var responseId = Guid.NewGuid().ToString("N"); - - using var httpResponseStream = await httpResponse.Content -#if NET - .ReadAsStreamAsync(cancellationToken) -#else - .ReadAsStreamAsync() -#endif - .ConfigureAwait(false); - - using var streamReader = new StreamReader(httpResponseStream); -#if NET - while ((await streamReader.ReadLineAsync(cancellationToken).ConfigureAwait(false)) is { } line) -#else - while ((await streamReader.ReadLineAsync().ConfigureAwait(false)) is { } line) -#endif - { - var chunk = JsonSerializer.Deserialize(line, JsonContext.Default.OllamaChatResponse); - if (chunk is null) - { - continue; - } - - string? modelId = chunk.Model ?? _metadata.DefaultModelId; - - ChatResponseUpdate update = new() - { - CreatedAt = DateTimeOffset.TryParse(chunk.CreatedAt, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTimeOffset createdAt) ? createdAt : null, - FinishReason = ToFinishReason(chunk), - ModelId = modelId, - ResponseId = responseId, - MessageId = responseId, // There is no per-message ID, but there's only one message per response, so use the response ID - Role = chunk.Message?.Role is not null ? new ChatRole(chunk.Message.Role) : null, - }; - - if (chunk.Message is { } message) - { - if (message.ToolCalls is { Length: > 0 }) - { - foreach (var toolCall in message.ToolCalls) - { - if (toolCall.Function is { } function) - { - update.Contents.Add(ToFunctionCallContent(function)); - } - } - } - - // Equivalent rule to the nonstreaming case - if (message.Content?.Length > 0 || update.Contents.Count == 0) - { - update.Contents.Insert(0, new TextContent(message.Content)); - } - } - - if (ParseOllamaChatResponseUsage(chunk) is { } usage) - { - update.Contents.Add(new UsageContent(usage)); - } - - yield return update; - } - } - - /// - object? IChatClient.GetService(Type serviceType, object? serviceKey) - { - _ = Throw.IfNull(serviceType); - - return - serviceKey is not null ? null : - serviceType == typeof(ChatClientMetadata) ? _metadata : - serviceType.IsInstanceOfType(this) ? this : - null; - } - - /// - public void Dispose() - { - if (_httpClient != OllamaUtilities.SharedClient) - { - _httpClient.Dispose(); - } - } - - private static UsageDetails? ParseOllamaChatResponseUsage(OllamaChatResponse response) - { - AdditionalPropertiesDictionary? additionalCounts = null; - OllamaUtilities.TransferNanosecondsTime(response, static r => r.LoadDuration, "load_duration", ref additionalCounts); - OllamaUtilities.TransferNanosecondsTime(response, static r => r.TotalDuration, "total_duration", ref additionalCounts); - OllamaUtilities.TransferNanosecondsTime(response, static r => r.PromptEvalDuration, "prompt_eval_duration", ref additionalCounts); - OllamaUtilities.TransferNanosecondsTime(response, static r => r.EvalDuration, "eval_duration", ref additionalCounts); - - if (additionalCounts is not null || response.PromptEvalCount is not null || response.EvalCount is not null) - { - return new() - { - InputTokenCount = response.PromptEvalCount, - OutputTokenCount = response.EvalCount, - TotalTokenCount = response.PromptEvalCount.GetValueOrDefault() + response.EvalCount.GetValueOrDefault(), - AdditionalCounts = additionalCounts, - }; - } - - return null; - } - - private static ChatFinishReason? ToFinishReason(OllamaChatResponse response) => - response.DoneReason switch - { - null => null, - "length" => ChatFinishReason.Length, - "stop" => ChatFinishReason.Stop, - _ => new ChatFinishReason(response.DoneReason), - }; - - private static ChatMessage FromOllamaMessage(OllamaChatResponseMessage message, string responseId) - { - List contents = []; - - // Add any tool calls. - if (message.ToolCalls is { Length: > 0 }) - { - foreach (var toolCall in message.ToolCalls) - { - if (toolCall.Function is { } function) - { - contents.Add(ToFunctionCallContent(function)); - } - } - } - - // Ollama frequently sends back empty content with tool calls. Rather than always adding an empty - // content, we only add the content if either it's not empty or there weren't any tool calls. - if (message.Content?.Length > 0 || contents.Count == 0) - { - contents.Insert(0, new TextContent(message.Content)); - } - - // Ollama doesn't have per-message IDs, so use the response ID in the same way we do when streaming - return new ChatMessage(new(message.Role), contents) { MessageId = responseId }; - } - - private static FunctionCallContent ToFunctionCallContent(OllamaFunctionToolCall function) - { -#if NET - var id = System.Security.Cryptography.RandomNumberGenerator.GetHexString(8); -#else - var id = Guid.NewGuid().ToString().Substring(0, 8); -#endif - return new FunctionCallContent(id, function.Name, function.Arguments); - } - - private static JsonElement? ToOllamaChatResponseFormat(ChatResponseFormat? format) - { - if (format is ChatResponseFormatJson jsonFormat) - { - return _schemaTransformCache.GetOrCreateTransformedSchema(jsonFormat) ?? _schemalessJsonResponseFormatValue; - } - else - { - return null; - } - } - - private OllamaChatRequest ToOllamaChatRequest(IEnumerable messages, ChatOptions? options, bool stream) - { - var requestMessages = messages.SelectMany(ToOllamaChatRequestMessages).ToList(); - if (options?.Instructions is string instructions) - { - requestMessages.Insert(0, new OllamaChatRequestMessage - { - Role = ChatRole.System.Value, - Content = instructions, - }); - } - - OllamaChatRequest request = new() - { - Format = ToOllamaChatResponseFormat(options?.ResponseFormat), - Messages = requestMessages, - Model = options?.ModelId ?? _metadata.DefaultModelId ?? string.Empty, - Stream = stream, - Tools = options?.ToolMode is not NoneChatToolMode && options?.Tools is { Count: > 0 } tools ? tools.OfType().Select(ToOllamaTool) : null, - }; - - if (options is not null) - { - TransferMetadataValue(nameof(OllamaRequestOptions.embedding_only), (options, value) => options.embedding_only = value); - TransferMetadataValue(nameof(OllamaRequestOptions.f16_kv), (options, value) => options.f16_kv = value); - TransferMetadataValue(nameof(OllamaRequestOptions.logits_all), (options, value) => options.logits_all = value); - TransferMetadataValue(nameof(OllamaRequestOptions.low_vram), (options, value) => options.low_vram = value); - TransferMetadataValue(nameof(OllamaRequestOptions.main_gpu), (options, value) => options.main_gpu = value); - TransferMetadataValue(nameof(OllamaRequestOptions.min_p), (options, value) => options.min_p = value); - TransferMetadataValue(nameof(OllamaRequestOptions.mirostat), (options, value) => options.mirostat = value); - TransferMetadataValue(nameof(OllamaRequestOptions.mirostat_eta), (options, value) => options.mirostat_eta = value); - TransferMetadataValue(nameof(OllamaRequestOptions.mirostat_tau), (options, value) => options.mirostat_tau = value); - TransferMetadataValue(nameof(OllamaRequestOptions.num_batch), (options, value) => options.num_batch = value); - TransferMetadataValue(nameof(OllamaRequestOptions.num_ctx), (options, value) => options.num_ctx = value); - TransferMetadataValue(nameof(OllamaRequestOptions.num_gpu), (options, value) => options.num_gpu = value); - TransferMetadataValue(nameof(OllamaRequestOptions.num_keep), (options, value) => options.num_keep = value); - TransferMetadataValue(nameof(OllamaRequestOptions.num_thread), (options, value) => options.num_thread = value); - TransferMetadataValue(nameof(OllamaRequestOptions.numa), (options, value) => options.numa = value); - TransferMetadataValue(nameof(OllamaRequestOptions.penalize_newline), (options, value) => options.penalize_newline = value); - TransferMetadataValue(nameof(OllamaRequestOptions.repeat_last_n), (options, value) => options.repeat_last_n = value); - TransferMetadataValue(nameof(OllamaRequestOptions.repeat_penalty), (options, value) => options.repeat_penalty = value); - TransferMetadataValue(nameof(OllamaRequestOptions.tfs_z), (options, value) => options.tfs_z = value); - TransferMetadataValue(nameof(OllamaRequestOptions.typical_p), (options, value) => options.typical_p = value); - TransferMetadataValue(nameof(OllamaRequestOptions.use_mmap), (options, value) => options.use_mmap = value); - TransferMetadataValue(nameof(OllamaRequestOptions.use_mlock), (options, value) => options.use_mlock = value); - TransferMetadataValue(nameof(OllamaRequestOptions.vocab_only), (options, value) => options.vocab_only = value); - - if (options.FrequencyPenalty is float frequencyPenalty) - { - (request.Options ??= new()).frequency_penalty = frequencyPenalty; - } - - if (options.MaxOutputTokens is int maxOutputTokens) - { - (request.Options ??= new()).num_predict = maxOutputTokens; - } - - if (options.PresencePenalty is float presencePenalty) - { - (request.Options ??= new()).presence_penalty = presencePenalty; - } - - if (options.StopSequences is { Count: > 0 }) - { - (request.Options ??= new()).stop = [.. options.StopSequences]; - } - - if (options.Temperature is float temperature) - { - (request.Options ??= new()).temperature = temperature; - } - - if (options.TopP is float topP) - { - (request.Options ??= new()).top_p = topP; - } - - if (options.TopK is int topK) - { - (request.Options ??= new()).top_k = topK; - } - - if (options.Seed is long seed) - { - (request.Options ??= new()).seed = seed; - } - } - - return request; - - void TransferMetadataValue(string propertyName, Action setOption) - { - if (options.AdditionalProperties?.TryGetValue(propertyName, out T? t) is true) - { - request.Options ??= new(); - setOption(request.Options, t); - } - } - } - - private IEnumerable ToOllamaChatRequestMessages(ChatMessage content) - { - // In general, we return a single request message for each understood content item. - // However, various image models expect both text and images in the same request message. - // To handle that, attach images to a previous text message if one exists. - - OllamaChatRequestMessage? currentTextMessage = null; - foreach (var item in content.Contents) - { - if (item is DataContent dataContent && dataContent.HasTopLevelMediaType("image")) - { - IList images = currentTextMessage?.Images ?? []; - images.Add(dataContent.Base64Data.ToString()); - - if (currentTextMessage is not null) - { - currentTextMessage.Images = images; - } - else - { - yield return new OllamaChatRequestMessage - { - Role = content.Role.Value, - Images = images, - }; - } - } - else - { - if (currentTextMessage is not null) - { - yield return currentTextMessage; - currentTextMessage = null; - } - - switch (item) - { - case TextContent textContent: - currentTextMessage = new OllamaChatRequestMessage - { - Role = content.Role.Value, - Content = textContent.Text, - }; - break; - - case FunctionCallContent fcc: - { - yield return new OllamaChatRequestMessage - { - Role = "assistant", - Content = JsonSerializer.Serialize(new OllamaFunctionCallContent - { - CallId = fcc.CallId, - Name = fcc.Name, - Arguments = JsonSerializer.SerializeToElement(fcc.Arguments, ToolCallJsonSerializerOptions.GetTypeInfo(typeof(IDictionary))), - }, JsonContext.Default.OllamaFunctionCallContent) - }; - break; - } - - case FunctionResultContent frc: - { - JsonElement jsonResult = JsonSerializer.SerializeToElement(frc.Result, ToolCallJsonSerializerOptions.GetTypeInfo(typeof(object))); - yield return new OllamaChatRequestMessage - { - Role = "tool", - Content = JsonSerializer.Serialize(new OllamaFunctionResultContent - { - CallId = frc.CallId, - Result = jsonResult, - }, JsonContext.Default.OllamaFunctionResultContent) - }; - break; - } - } - } - } - - if (currentTextMessage is not null) - { - yield return currentTextMessage; - } - } - - private static OllamaTool ToOllamaTool(AIFunction function) - { - return new() - { - Type = "function", - Function = new OllamaFunctionTool - { - Name = function.Name, - Description = function.Description, - Parameters = JsonSerializer.Deserialize(_schemaTransformCache.GetOrCreateTransformedSchema(function), JsonContext.Default.OllamaFunctionToolParameters)!, - } - }; - } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatRequest.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatRequest.cs deleted file mode 100644 index 7cdadb91666..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatRequest.cs +++ /dev/null @@ -1,17 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Collections.Generic; -using System.Text.Json; - -namespace Microsoft.Extensions.AI; - -internal sealed class OllamaChatRequest -{ - public required string Model { get; set; } - public required IList Messages { get; set; } - public JsonElement? Format { get; set; } - public bool Stream { get; set; } - public IEnumerable? Tools { get; set; } - public OllamaRequestOptions? Options { get; set; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatRequestMessage.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatRequestMessage.cs deleted file mode 100644 index 5a377b1eb34..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatRequestMessage.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Collections.Generic; - -namespace Microsoft.Extensions.AI; - -internal sealed class OllamaChatRequestMessage -{ - public required string Role { get; set; } - public string? Content { get; set; } - public IList? Images { get; set; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatResponse.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatResponse.cs deleted file mode 100644 index 8c39f9ab598..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatResponse.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.Extensions.AI; - -internal sealed class OllamaChatResponse -{ - public string? Model { get; set; } - public string? CreatedAt { get; set; } - public long? TotalDuration { get; set; } - public long? LoadDuration { get; set; } - public string? DoneReason { get; set; } - public int? PromptEvalCount { get; set; } - public long? PromptEvalDuration { get; set; } - public int? EvalCount { get; set; } - public long? EvalDuration { get; set; } - public OllamaChatResponseMessage? Message { get; set; } - public bool Done { get; set; } - public string? Error { get; set; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatResponseMessage.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatResponseMessage.cs deleted file mode 100644 index bf73c08d793..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaChatResponseMessage.cs +++ /dev/null @@ -1,11 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.Extensions.AI; - -internal sealed class OllamaChatResponseMessage -{ - public required string Role { get; set; } - public required string Content { get; set; } - public OllamaToolCall[]? ToolCalls { get; set; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingGenerator.cs deleted file mode 100644 index 0b0d4d3b344..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingGenerator.cs +++ /dev/null @@ -1,165 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net.Http; -using System.Net.Http.Json; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Shared.Diagnostics; - -#pragma warning disable S3358 // Ternary operators should not be nested - -namespace Microsoft.Extensions.AI; - -/// Represents an for Ollama. -public sealed class OllamaEmbeddingGenerator : IEmbeddingGenerator> -{ - /// Metadata about the embedding generator. - private readonly EmbeddingGeneratorMetadata _metadata; - - /// The api/embeddings endpoint URI. - private readonly Uri _apiEmbeddingsEndpoint; - - /// The to use for sending requests. - private readonly HttpClient _httpClient; - - /// Initializes a new instance of the class. - /// The endpoint URI where Ollama is hosted. - /// - /// The ID of the model to use. This ID can also be overridden per request via . - /// Either this parameter or must provide a valid model ID. - /// - /// An instance to use for HTTP operations. - public OllamaEmbeddingGenerator(string endpoint, string? modelId = null, HttpClient? httpClient = null) - : this(new Uri(Throw.IfNull(endpoint)), modelId, httpClient) - { - } - - /// Initializes a new instance of the class. - /// The endpoint URI where Ollama is hosted. - /// - /// The ID of the model to use. This ID can also be overridden per request via . - /// Either this parameter or must provide a valid model ID. - /// - /// An instance to use for HTTP operations. - /// is . - /// is empty or composed entirely of whitespace. - public OllamaEmbeddingGenerator(Uri endpoint, string? modelId = null, HttpClient? httpClient = null) - { - _ = Throw.IfNull(endpoint); - if (modelId is not null) - { - _ = Throw.IfNullOrWhitespace(modelId); - } - - _apiEmbeddingsEndpoint = new Uri(endpoint, "api/embed"); - _httpClient = httpClient ?? OllamaUtilities.SharedClient; - _metadata = new("ollama", endpoint, modelId); - } - - /// - object? IEmbeddingGenerator.GetService(Type serviceType, object? serviceKey) - { - _ = Throw.IfNull(serviceType); - - return - serviceKey is not null ? null : - serviceType == typeof(EmbeddingGeneratorMetadata) ? _metadata : - serviceType.IsInstanceOfType(this) ? this : - null; - } - - /// - public void Dispose() - { - if (_httpClient != OllamaUtilities.SharedClient) - { - _httpClient.Dispose(); - } - } - - /// - public async Task>> GenerateAsync( - IEnumerable values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) - { - _ = Throw.IfNull(values); - - // Create request. - string[] inputs = values.ToArray(); - string? requestModel = options?.ModelId ?? _metadata.DefaultModelId; - var request = new OllamaEmbeddingRequest - { - Model = requestModel ?? string.Empty, - Input = inputs, - }; - - if (options?.AdditionalProperties is { } requestProps) - { - if (requestProps.TryGetValue("keep_alive", out long keepAlive)) - { - request.KeepAlive = keepAlive; - } - - if (requestProps.TryGetValue("truncate", out bool truncate)) - { - request.Truncate = truncate; - } - } - - // Send request and get response. - var httpResponse = await _httpClient.PostAsJsonAsync( - _apiEmbeddingsEndpoint, - request, - JsonContext.Default.OllamaEmbeddingRequest, - cancellationToken).ConfigureAwait(false); - - if (!httpResponse.IsSuccessStatusCode) - { - await OllamaUtilities.ThrowUnsuccessfulOllamaResponseAsync(httpResponse, cancellationToken).ConfigureAwait(false); - } - - var response = (await httpResponse.Content.ReadFromJsonAsync( - JsonContext.Default.OllamaEmbeddingResponse, - cancellationToken).ConfigureAwait(false))!; - - // Validate response. - if (!string.IsNullOrEmpty(response.Error)) - { - throw new InvalidOperationException($"Ollama error: {response.Error}"); - } - - if (response.Embeddings is null || response.Embeddings.Length != inputs.Length) - { - throw new InvalidOperationException($"Ollama generated {response.Embeddings?.Length ?? 0} embeddings but {inputs.Length} were expected."); - } - - // Convert response into result objects. - AdditionalPropertiesDictionary? additionalCounts = null; - OllamaUtilities.TransferNanosecondsTime(response, r => r.TotalDuration, "total_duration", ref additionalCounts); - OllamaUtilities.TransferNanosecondsTime(response, r => r.LoadDuration, "load_duration", ref additionalCounts); - - UsageDetails? usage = null; - if (additionalCounts is not null || response.PromptEvalCount is not null) - { - usage = new() - { - InputTokenCount = response.PromptEvalCount, - TotalTokenCount = response.PromptEvalCount, - AdditionalCounts = additionalCounts, - }; - } - - return new(response.Embeddings.Select(e => - new Embedding(e) - { - CreatedAt = DateTimeOffset.UtcNow, - ModelId = response.Model ?? requestModel, - })) - { - Usage = usage, - }; - } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingRequest.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingRequest.cs deleted file mode 100644 index 07e3530b8ed..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingRequest.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.Extensions.AI; - -internal sealed class OllamaEmbeddingRequest -{ - public required string Model { get; set; } - public required string[] Input { get; set; } - public OllamaRequestOptions? Options { get; set; } - public bool? Truncate { get; set; } - public long? KeepAlive { get; set; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingResponse.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingResponse.cs deleted file mode 100644 index c4fd2cde87c..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaEmbeddingResponse.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Text.Json.Serialization; - -namespace Microsoft.Extensions.AI; - -internal sealed class OllamaEmbeddingResponse -{ - [JsonPropertyName("model")] - public string? Model { get; set; } - [JsonPropertyName("embeddings")] - public float[][]? Embeddings { get; set; } - [JsonPropertyName("total_duration")] - public long? TotalDuration { get; set; } - [JsonPropertyName("load_duration")] - public long? LoadDuration { get; set; } - [JsonPropertyName("prompt_eval_count")] - public int? PromptEvalCount { get; set; } - public string? Error { get; set; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionCallContent.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionCallContent.cs deleted file mode 100644 index f518413586a..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionCallContent.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Text.Json; - -namespace Microsoft.Extensions.AI; - -internal sealed class OllamaFunctionCallContent -{ - public string? CallId { get; set; } - public string? Name { get; set; } - public JsonElement Arguments { get; set; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionResultContent.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionResultContent.cs deleted file mode 100644 index ba3eab607b8..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionResultContent.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Text.Json; - -namespace Microsoft.Extensions.AI; - -internal sealed class OllamaFunctionResultContent -{ - public string? CallId { get; set; } - public JsonElement Result { get; set; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionTool.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionTool.cs deleted file mode 100644 index 880e37bec2a..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionTool.cs +++ /dev/null @@ -1,11 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.Extensions.AI; - -internal sealed class OllamaFunctionTool -{ - public required string Name { get; set; } - public required string Description { get; set; } - public required OllamaFunctionToolParameters Parameters { get; set; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionToolCall.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionToolCall.cs deleted file mode 100644 index c94d41bd3f3..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionToolCall.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Collections.Generic; - -namespace Microsoft.Extensions.AI; - -internal sealed class OllamaFunctionToolCall -{ - public required string Name { get; set; } - public IDictionary? Arguments { get; set; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionToolParameter.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionToolParameter.cs deleted file mode 100644 index 77ba2a5561c..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionToolParameter.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Collections.Generic; - -namespace Microsoft.Extensions.AI; - -internal sealed class OllamaFunctionToolParameter -{ - public string? Type { get; set; } - public string? Description { get; set; } - public IEnumerable? Enum { get; set; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionToolParameters.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionToolParameters.cs deleted file mode 100644 index 9fa7d0d2adc..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaFunctionToolParameters.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Collections.Generic; -using System.Text.Json; - -namespace Microsoft.Extensions.AI; - -internal sealed class OllamaFunctionToolParameters -{ - public string Type { get; set; } = "object"; - public required IDictionary Properties { get; set; } - public IList? Required { get; set; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaRequestOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaRequestOptions.cs deleted file mode 100644 index cc8b548c1a1..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaRequestOptions.cs +++ /dev/null @@ -1,41 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.Extensions.AI; - -#pragma warning disable IDE1006 // Naming Styles - -internal sealed class OllamaRequestOptions -{ - public bool? embedding_only { get; set; } - public bool? f16_kv { get; set; } - public float? frequency_penalty { get; set; } - public bool? logits_all { get; set; } - public bool? low_vram { get; set; } - public int? main_gpu { get; set; } - public float? min_p { get; set; } - public int? mirostat { get; set; } - public float? mirostat_eta { get; set; } - public float? mirostat_tau { get; set; } - public int? num_batch { get; set; } - public int? num_ctx { get; set; } - public int? num_gpu { get; set; } - public int? num_keep { get; set; } - public int? num_predict { get; set; } - public int? num_thread { get; set; } - public bool? numa { get; set; } - public bool? penalize_newline { get; set; } - public float? presence_penalty { get; set; } - public int? repeat_last_n { get; set; } - public float? repeat_penalty { get; set; } - public long? seed { get; set; } - public string[]? stop { get; set; } - public float? temperature { get; set; } - public float? tfs_z { get; set; } - public int? top_k { get; set; } - public float? top_p { get; set; } - public float? typical_p { get; set; } - public bool? use_mlock { get; set; } - public bool? use_mmap { get; set; } - public bool? vocab_only { get; set; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaTool.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaTool.cs deleted file mode 100644 index 457793dc476..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaTool.cs +++ /dev/null @@ -1,10 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.Extensions.AI; - -internal sealed class OllamaTool -{ - public required string Type { get; set; } - public required OllamaFunctionTool Function { get; set; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaUtilities.cs b/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaUtilities.cs deleted file mode 100644 index ea2625bd50e..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaUtilities.cs +++ /dev/null @@ -1,72 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Net.Http; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; - -namespace Microsoft.Extensions.AI; - -internal static class OllamaUtilities -{ - /// Gets a singleton used when no other instance is supplied. - public static HttpClient SharedClient { get; } = new() - { - // Expected use is localhost access for non-production use. Typical production use should supply - // an HttpClient configured with whatever more robust resilience policy / handlers are appropriate. - Timeout = Timeout.InfiniteTimeSpan, - }; - - public static void TransferNanosecondsTime(TResponse response, Func getNanoseconds, string key, ref AdditionalPropertiesDictionary? metadata) - { - if (getNanoseconds(response) is long duration) - { - try - { - (metadata ??= [])[key] = duration; - } - catch (OverflowException) - { - // Ignore options that don't convert - } - } - } - - [DoesNotReturn] - public static async ValueTask ThrowUnsuccessfulOllamaResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken) - { - Debug.Assert(!response.IsSuccessStatusCode, "must only be invoked for unsuccessful responses."); - - // Read the entire response content into a string. - string errorContent = -#if NET - await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); -#else - await response.Content.ReadAsStringAsync().ConfigureAwait(false); -#endif - - // The response content *could* be JSON formatted, try to extract the error field. - -#pragma warning disable CA1031 // Do not catch general exception types - try - { - using JsonDocument document = JsonDocument.Parse(errorContent); - if (document.RootElement.TryGetProperty("error", out JsonElement errorElement) && - errorElement.ValueKind is JsonValueKind.String) - { - errorContent = errorElement.GetString()!; - } - } - catch - { - // Ignore JSON parsing errors. - } -#pragma warning restore CA1031 // Do not catch general exception types - - throw new InvalidOperationException($"Ollama error: {errorContent}"); - } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/README.md b/src/Libraries/Microsoft.Extensions.AI.Ollama/README.md deleted file mode 100644 index 3c85cd17087..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# Microsoft.Extensions.AI.Ollama - -This package is deprecated and the [OllamaSharp](https://www.nuget.org/packages/ollamasharp) package is recommended. `OllamaSharp` provides .NET bindings for the [Ollama API](https://github.com/jmorganca/ollama/blob/main/docs/api.md), simplifying interactions with Ollama both locally and remotely. - -No further updates, features, or fixes are planned for the `Microsoft.Extensions.AI.Ollama` package. - -## Related packages - -* [OllamaSharp](https://www.nuget.org/packages/OllamaSharp) -* [Microsoft.Extensions.AI](https://www.nuget.org/packages/Microsoft.Extensions.AI) -* [Microsoft.Extensions.AI.Abstractions](https://www.nuget.org/packages/Microsoft.Extensions.AI.Abstractions) diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md new file mode 100644 index 00000000000..491624332da --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md @@ -0,0 +1,126 @@ +# Release History + +## 9.10.2-preview.1.25552.1 + +- Updated to depend on OpenAI 2.6.0. +- Updated the OpenAI Responses `IChatClient` to allow either conversation or response ID for `ChatOptions.ConversationId`. +- Updated the OpenAI Responses `IChatClient` to support `AIFunction`s that return `AIContent` like `DataContent`. +- Updated the OpenAI Chat Completion `IChatClient`, the Responses `IChatClient`, and the `IEmbeddingGenerator` to support per-request `ModelId` overrides. +- Added an `AITool` to `ResponseTool` conversion utility. +- Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. + +## 9.10.1-preview.1.25521.4 + +- Updated the OpenAI Responses `IChatClient` to support connectors with `HostedMcpServerTool`. +- Fixed the OpenAI Responses `IChatClient` to roundtrip a `ResponseItem` stored in an `AIContent` in a `ChatRole.User` message. +- Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. + +## 9.10.0-preview.1.25513.3 + +- Fixed issue with the OpenAI Assistants `IChatClient` where a chat history including unrelated function calls would cause an exception. +- Fixed issue with the OpenAI Assistants `IChatClient` sending a tool in `ChatOptions.Tools` that had the same name as a function configured with the Assistant would cause an exception. +- Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. + +## 9.9.1-preview.1.25474.6 + +- Updated to depend on OpenAI 2.5.0. +- Added M.E.AI to OpenAI conversions for response format types. +- Added `ResponseTool` to `AITool` conversions. +- Fixed the handling of `HostedCodeInterpreterTool` with Responses when no file IDs were provided. +- Fixed an issue where requests would fail when AllowMultipleToolCalls was set with no tools provided. +- Fixed an issue where requests would fail when an AuthorName was provided containing invalid characters. + +## 9.9.0-preview.1.25458.4 + +- Updated to depend on OpenAI 2.4.0. +- Updated tool mappings to recognize any `AIFunctionDeclaration`. +- Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. +- Fixed handling of annotated but empty content in the `AsIChatClient` for `AssistantClient`. + +## 9.8.0-preview.1.25412.6 + +- Updated to depend on OpenAI 2.3.0. +- Added more conversion helpers for converting bidirectionally between Microsoft.Extensions.AI messages and OpenAI messages. +- Fixed handling of multiple response messages in the OpenAI Responses `IChatClient`. +- Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. + +## 9.7.1-preview.1.25365.4 + +- Added some conversion helpers for converting Microsoft.Extensions.AI messages to OpenAI messages. +- Enabled specifying "strict" via ChatOptions for OpenAI clients. + +## 9.7.0-preview.1.25356.2 + +- Updated to depend on OpenAI 2.2.0. +- Added conversion helpers from `AIFunction` to various OpenAI tool representations. +- Added `AsIChatClient` extension method for OpenAI's `AssistantClient`, enabling `IChatClient` to be used with OpenAI Assistants. +- Tweaked how JSON schemas for functions are transformed for better compatibility with OpenAI `strict` constraints. +- Improved handling of `RawRepresentation` in `IChatClients` for Responses and Chat Completion APIs. +- Improved `ISpeechToTextClient` implementation to support streaming transcriptions. +- Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. + +## 9.6.0-preview.1.25310.2 + +- Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. + +## 9.5.0-preview.1.25265.7 + +- Added PDF support to `IChatClient` implementations. +- Disabled use of `strict` schema handling by default. +- Added support for creating `ErrorContent` in `IChatClient` implementations, such as for refusals. +- Updated to accommodate the changes in `Microsoft.Extensions.AI.Abstractions`. + +## 9.4.4-preview.1.25259.16 + +- Made `IChatClient` implementation more resilient with non-OpenAI services. +- Added `ErrorContent` to represent refusals. +- Updated to accommodate the changes in `Microsoft.Extensions.AI.Abstractions`. + +## 9.4.3-preview.1.25230.7 + +- Reverted previous change that enabled `strict` schemas by default. +- Updated `IChatClient` implementations to support `DataContent`s for PDFs. +- Updated to accommodate the changes in `Microsoft.Extensions.AI.Abstractions`. + +## 9.4.0-preview.1.25207.5 + +- Updated to OpenAI 2.2.0-beta-4. +- Added `AsISpeechToTextClient` extension method for `AudioClient`. +- Removed `AsChatClient(OpenAIClient)`/`AsEmbeddingGenerator(OpenAIClient)` extension methods, renamed the remaining `AsChatClient` methods to `AsIChatClient`, renamed the remaining `AsEmbeddingGenerator` methods to `AsIEmbeddingGenerator`, and added an `AsIChatClient` for `OpenAIResponseClient`. +- Removed the public `OpenAIChatClient`/`OpenAIEmbeddingGenerator` types. These are only created now via the extension methods. +- Removed serialization/deserialization helpers. +- Updated to support pulling propagating image detail from `AdditionalProperties`. +- Updated to accommodate the changes in `Microsoft.Extensions.AI.Abstractions`. + +## 9.3.0-preview.1.25161.3 + +- Updated to accommodate the changes in `Microsoft.Extensions.AI.Abstractions`. + +## 9.3.0-preview.1.25114.11 + +- Updated to depend on OpenAI 2.2.0-beta.1, updating with support for the Developer role, audio input and output, and additional options like output prediction. It is now also compatible with NativeAOT. +- Added an `AsChatClient` extension method for OpenAI's `AssistantClient`, enabling `IChatClient` to be used with OpenAI Assistants. +- Improved the OpenAI serialization helpers, including a custom converter for the `OpenAIChatCompletionRequest` envelope type. + +## 9.1.0-preview.1.25064.3 + +- Updated to depend on OpenAI 2.1.0. +- Updated to propagate `Metadata` and `StoredOutputEnabled` from `ChatOptions.AdditionalProperties`. +- Added serialization helpers methods for deserializing OpenAI compatible JSON into the Microsoft.Extensions.AI object model, and vice versa serializing the Microsoft.Extensions.AI object model into OpenAI compatible JSON. + +## 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. +- 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.OpenAI/Microsoft.Extensions.AI.OpenAI.csproj b/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.csproj index a135ee011ea..1cad3704cbb 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.csproj @@ -4,6 +4,7 @@ Microsoft.Extensions.AI Implementation of generative AI abstractions for OpenAI-compatible endpoints. AI + true @@ -15,8 +16,8 @@ $(TargetFrameworks);netstandard2.0 - $(NoWarn);CA1063;CA1508;CA2227;SA1316;S1121;S3358;EA0002 - $(NoWarn);OPENAI001;OPENAI002;MEAI001 + $(NoWarn);CA1063 + $(NoWarn);OPENAI001;OPENAI002;MEAI001;SCME0001 true true true @@ -26,8 +27,6 @@ true true true - true - true true diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIAssistantsExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIAssistantsExtensions.cs new file mode 100644 index 00000000000..793c906bd9d --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIAssistantsExtensions.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace OpenAI.Assistants; + +/// Provides extension methods for working with content associated with OpenAI.Assistants. +public static class MicrosoftExtensionsAIAssistantsExtensions +{ + /// Creates an OpenAI from an . + /// The function to convert. + /// An OpenAI representing . + /// is . + public static FunctionToolDefinition AsOpenAIAssistantsFunctionToolDefinition(this AIFunctionDeclaration function) => + OpenAIAssistantsChatClient.ToOpenAIAssistantsFunctionToolDefinition(Throw.IfNull(function)); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIChatExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIChatExtensions.cs new file mode 100644 index 00000000000..acdc42be3e0 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIChatExtensions.cs @@ -0,0 +1,290 @@ +// Licensed 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; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace OpenAI.Chat; + +/// Provides extension methods for working with content associated with OpenAI.Chat. +public static class MicrosoftExtensionsAIChatExtensions +{ + /// Creates an OpenAI from an . + /// The function to convert. + /// An OpenAI representing . + /// is . + public static ChatTool AsOpenAIChatTool(this AIFunctionDeclaration function) => + OpenAIChatClient.ToOpenAIChatTool(Throw.IfNull(function)); + + /// + /// Creates an OpenAI from a . + /// + /// The format. + /// The options to use when interpreting the format. + /// The converted OpenAI . + public static ChatResponseFormat? AsOpenAIChatResponseFormat(this Microsoft.Extensions.AI.ChatResponseFormat? format, ChatOptions? options = null) => + OpenAIChatClient.ToOpenAIChatResponseFormat(format, options); + + /// Creates a sequence of OpenAI instances from the specified input messages. + /// The input messages to convert. + /// The options employed while processing . + /// A sequence of OpenAI chat messages. + public static IEnumerable AsOpenAIChatMessages(this IEnumerable messages, ChatOptions? options = null) => + OpenAIChatClient.ToOpenAIChatMessages(Throw.IfNull(messages), options); + + /// Creates an OpenAI from a . + /// The to convert to a . + /// A converted . + /// is . + public static ChatCompletion AsOpenAIChatCompletion(this ChatResponse response) + { + _ = Throw.IfNull(response); + + if (response.RawRepresentation is ChatCompletion chatCompletion) + { + return chatCompletion; + } + + var lastMessage = response.Messages.LastOrDefault(); + + ChatMessageRole role = ToChatMessageRole(lastMessage?.Role); + + ChatFinishReason finishReason = ToChatFinishReason(response.FinishReason); + + ChatTokenUsage usage = OpenAIChatModelFactory.ChatTokenUsage( + (int?)response.Usage?.OutputTokenCount ?? 0, + (int?)response.Usage?.InputTokenCount ?? 0, + (int?)response.Usage?.TotalTokenCount ?? 0); + + IEnumerable? toolCalls = lastMessage?.Contents + .OfType().Select(c => ChatToolCall.CreateFunctionToolCall(c.CallId, c.Name, + new BinaryData(JsonSerializer.SerializeToUtf8Bytes(c.Arguments, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IDictionary)))))); + + return OpenAIChatModelFactory.ChatCompletion( + response.ResponseId, + finishReason, + new(OpenAIChatClient.ToOpenAIChatContent(lastMessage?.Contents ?? [])), + toolCalls: toolCalls, + role: role, + createdAt: response.CreatedAt ?? default, + model: response.ModelId, + usage: usage, + outputAudio: lastMessage?.Contents.OfType().Where(dc => dc.HasTopLevelMediaType("audio")).Select(a => OpenAIChatModelFactory.ChatOutputAudio(new(a.Data))).FirstOrDefault(), + messageAnnotations: ConvertAnnotations(lastMessage?.Contents)); + + static IEnumerable ConvertAnnotations(IEnumerable? contents) + { + if (contents is null) + { + yield break; + } + + foreach (var content in contents) + { + if (content.Annotations is null) + { + continue; + } + + foreach (var annotation in content.Annotations) + { + if (annotation is not CitationAnnotation citation) + { + continue; + } + + if (citation.AnnotatedRegions?.OfType().ToArray() is { Length: > 0 } regions) + { + foreach (var region in regions) + { + yield return OpenAIChatModelFactory.ChatMessageAnnotation(region.StartIndex ?? 0, region.EndIndex ?? 0, citation.Url, citation.Title); + } + } + else + { + yield return OpenAIChatModelFactory.ChatMessageAnnotation(0, 0, citation.Url, citation.Title); + } + } + } + } + } + + /// + /// Creates a sequence of OpenAI instances from the specified + /// sequence of instances. + /// + /// The update instances. + /// The to monitor for cancellation requests. The default is . + /// A sequence of converted instances. + /// is . + public static async IAsyncEnumerable AsOpenAIStreamingChatCompletionUpdatesAsync( + this IAsyncEnumerable responseUpdates, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(responseUpdates); + + await foreach (var update in responseUpdates.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + if (update.RawRepresentation is StreamingChatCompletionUpdate streamingUpdate) + { + yield return streamingUpdate; + continue; + } + + var usage = update.Contents.FirstOrDefault(c => c is UsageContent) is UsageContent usageContent ? + OpenAIChatModelFactory.ChatTokenUsage( + (int?)usageContent.Details.OutputTokenCount ?? 0, + (int?)usageContent.Details.InputTokenCount ?? 0, + (int?)usageContent.Details.TotalTokenCount ?? 0) : + null; + + var toolCallUpdates = update.Contents.OfType().Select((fcc, index) => + OpenAIChatModelFactory.StreamingChatToolCallUpdate( + index, fcc.CallId, ChatToolCallKind.Function, fcc.Name, + new(JsonSerializer.SerializeToUtf8Bytes(fcc.Arguments, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IDictionary)))))) + .ToList(); + + yield return OpenAIChatModelFactory.StreamingChatCompletionUpdate( + update.ResponseId, + new(OpenAIChatClient.ToOpenAIChatContent(update.Contents)), + toolCallUpdates: toolCallUpdates, + role: ToChatMessageRole(update.Role), + finishReason: ToChatFinishReason(update.FinishReason), + createdAt: update.CreatedAt ?? default, + model: update.ModelId, + usage: usage); + } + } + + /// Creates a sequence of instances from the specified input messages. + /// The input messages to convert. + /// A sequence of Microsoft.Extensions.AI chat messages. + /// is . + public static IEnumerable AsChatMessages(this IEnumerable messages) + { + _ = Throw.IfNull(messages); + + foreach (var message in messages) + { + Microsoft.Extensions.AI.ChatMessage resultMessage = new() + { + RawRepresentation = message, + }; + + switch (message) + { + case AssistantChatMessage acm: + resultMessage.Role = ChatRole.Assistant; + resultMessage.AuthorName = acm.ParticipantName; + OpenAIChatClient.ConvertContentParts(acm.Content, resultMessage.Contents); + foreach (var toolCall in acm.ToolCalls) + { + var fcc = OpenAIClientExtensions.ParseCallContent(toolCall.FunctionArguments, toolCall.Id, toolCall.FunctionName); + fcc.RawRepresentation = toolCall; + resultMessage.Contents.Add(fcc); + } + + break; + + case UserChatMessage ucm: + resultMessage.Role = ChatRole.User; + resultMessage.AuthorName = ucm.ParticipantName; + OpenAIChatClient.ConvertContentParts(ucm.Content, resultMessage.Contents); + break; + + case DeveloperChatMessage dcm: + resultMessage.Role = ChatRole.System; + resultMessage.AuthorName = dcm.ParticipantName; + OpenAIChatClient.ConvertContentParts(dcm.Content, resultMessage.Contents); + break; + + case SystemChatMessage scm: + resultMessage.Role = ChatRole.System; + resultMessage.AuthorName = scm.ParticipantName; + OpenAIChatClient.ConvertContentParts(scm.Content, resultMessage.Contents); + break; + + case ToolChatMessage tcm: + resultMessage.Role = ChatRole.Tool; + resultMessage.Contents.Add(new FunctionResultContent(tcm.ToolCallId, ToToolResult(tcm.Content)) + { + RawRepresentation = tcm, + }); + + static object ToToolResult(ChatMessageContent content) + { + if (content.Count == 1 && content[0] is { Text: { } text }) + { + return text; + } + + MemoryStream ms = new(); + using Utf8JsonWriter writer = new(ms, new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }); + foreach (IJsonModel part in content) + { + part.Write(writer, ModelReaderWriterOptions.Json); + } + + return JsonSerializer.Deserialize(ms.GetBuffer().AsSpan(0, (int)ms.Position), AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonElement)))!; + } + + break; + } + + yield return resultMessage; + } + } + + /// Creates a Microsoft.Extensions.AI from a . + /// The to convert to a . + /// The options employed in the creation of the response. + /// A converted . + /// is . + public static ChatResponse AsChatResponse(this ChatCompletion chatCompletion, ChatCompletionOptions? options = null) => + OpenAIChatClient.FromOpenAIChatCompletion(Throw.IfNull(chatCompletion), options); + + /// + /// Creates a sequence of Microsoft.Extensions.AI instances from the specified + /// sequence of OpenAI instances. + /// + /// The update instances. + /// The options employed in the creation of the response. + /// The to monitor for cancellation requests. The default is . + /// A sequence of converted instances. + /// is . + public static IAsyncEnumerable AsChatResponseUpdatesAsync( + this IAsyncEnumerable chatCompletionUpdates, ChatCompletionOptions? options = null, CancellationToken cancellationToken = default) => + OpenAIChatClient.FromOpenAIStreamingChatCompletionAsync(Throw.IfNull(chatCompletionUpdates), options, cancellationToken); + + /// Converts the to a . + private static ChatMessageRole ToChatMessageRole(ChatRole? role) => + role?.Value switch + { + "user" => ChatMessageRole.User, + "function" => ChatMessageRole.Function, + "tool" => ChatMessageRole.Tool, + "developer" => ChatMessageRole.Developer, + "system" => ChatMessageRole.System, + _ => ChatMessageRole.Assistant, + }; + + /// Converts the to a . + private static ChatFinishReason ToChatFinishReason(Microsoft.Extensions.AI.ChatFinishReason? finishReason) => + finishReason?.Value switch + { + "length" => ChatFinishReason.Length, + "content_filter" => ChatFinishReason.ContentFilter, + "tool_calls" => ChatFinishReason.ToolCalls, + "function_call" => ChatFinishReason.FunctionCall, + _ => ChatFinishReason.Stop, + }; +} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIRealtimeExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIRealtimeExtensions.cs new file mode 100644 index 00000000000..903c6253dde --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIRealtimeExtensions.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace OpenAI.Realtime; + +/// Provides extension methods for working with content associated with OpenAI.Realtime. +public static class MicrosoftExtensionsAIRealtimeExtensions +{ + /// Creates an OpenAI from an . + /// The function to convert. + /// An OpenAI representing . + /// is . + public static ConversationFunctionTool AsOpenAIConversationFunctionTool(this AIFunctionDeclaration function) => + OpenAIRealtimeConversationClient.ToOpenAIConversationFunctionTool(Throw.IfNull(function)); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs new file mode 100644 index 00000000000..6d989c0b56d --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs @@ -0,0 +1,149 @@ +// Licensed 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 Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +#pragma warning disable S3254 // Default parameter values should not be passed as arguments + +namespace OpenAI.Responses; + +/// Provides extension methods for working with content associated with OpenAI.Responses. +public static class MicrosoftExtensionsAIResponsesExtensions +{ + /// Creates an OpenAI from an . + /// The function to convert. + /// An OpenAI representing . + /// is . + public static FunctionTool AsOpenAIResponseTool(this AIFunctionDeclaration function) => + OpenAIResponsesChatClient.ToResponseTool(Throw.IfNull(function)); + + /// Creates an OpenAI from an . + /// The tool to convert. + /// An OpenAI representing or if there is no mapping. + /// is . + /// + /// This method is only able to create s for types + /// it's aware of, namely all of those available from the Microsoft.Extensions.AI.Abstractions library. + /// + public static ResponseTool? AsOpenAIResponseTool(this AITool tool) => + OpenAIResponsesChatClient.ToResponseTool(Throw.IfNull(tool)); + + /// + /// Creates an OpenAI from a . + /// + /// The format. + /// The options to use when interpreting the format. + /// The converted OpenAI . + public static ResponseTextFormat? AsOpenAIResponseTextFormat(this ChatResponseFormat? format, ChatOptions? options = null) => + OpenAIResponsesChatClient.ToOpenAIResponseTextFormat(format, options); + + /// Creates a sequence of OpenAI instances from the specified input messages. + /// The input messages to convert. + /// The options employed while processing . + /// A sequence of OpenAI response items. + /// is . + public static IEnumerable AsOpenAIResponseItems(this IEnumerable messages, ChatOptions? options = null) => + OpenAIResponsesChatClient.ToOpenAIResponseItems(Throw.IfNull(messages), options); + + /// Creates a sequence of instances from the specified input items. + /// The input messages to convert. + /// A sequence of instances. + /// is . + public static IEnumerable AsChatMessages(this IEnumerable items) => + OpenAIResponsesChatClient.ToChatMessages(Throw.IfNull(items)); + + /// Creates a Microsoft.Extensions.AI from an . + /// The to convert to a . + /// The options employed in the creation of the response. + /// A converted . + /// is . + public static ChatResponse AsChatResponse(this OpenAIResponse response, ResponseCreationOptions? options = null) => + OpenAIResponsesChatClient.FromOpenAIResponse(Throw.IfNull(response), options, conversationId: null); + + /// + /// Creates a sequence of Microsoft.Extensions.AI instances from the specified + /// sequence of OpenAI instances. + /// + /// The update instances. + /// The options employed in the creation of the response. + /// The to monitor for cancellation requests. The default is . + /// A sequence of converted instances. + /// is . + public static IAsyncEnumerable AsChatResponseUpdatesAsync( + this IAsyncEnumerable responseUpdates, ResponseCreationOptions? options = null, CancellationToken cancellationToken = default) => + OpenAIResponsesChatClient.FromOpenAIStreamingResponseUpdatesAsync(Throw.IfNull(responseUpdates), options, conversationId: null, cancellationToken: cancellationToken); + + /// Creates an OpenAI from a . + /// The response to convert. + /// The options employed in the creation of the response. + /// The created . + public static OpenAIResponse AsOpenAIResponse(this ChatResponse response, ChatOptions? options = null) + { + _ = Throw.IfNull(response); + + if (response.RawRepresentation is OpenAIResponse openAIResponse) + { + return openAIResponse; + } + + return OpenAIResponsesModelFactory.OpenAIResponse( + response.ResponseId, + response.CreatedAt ?? default, + ResponseStatus.Completed, + usage: null, // No way to construct a ResponseTokenUsage right now from external to the OpenAI library + maxOutputTokenCount: options?.MaxOutputTokens, + outputItems: OpenAIResponsesChatClient.ToOpenAIResponseItems(response.Messages, options), + parallelToolCallsEnabled: options?.AllowMultipleToolCalls ?? false, + model: response.ModelId ?? options?.ModelId, + temperature: options?.Temperature, + topP: options?.TopP, + previousResponseId: options?.ConversationId, + instructions: options?.Instructions); + } + + /// Adds the to the list of s. + /// The list of s to which the provided tool should be added. + /// The to add. + /// + /// does not derive from , so it cannot be added directly to a list of s. + /// Instead, this method wraps the provided in an and adds that to the list. + /// The returned by will + /// be able to unwrap the when it processes the list of tools and use the provided as-is. + /// + public static void Add(this IList tools, ResponseTool tool) + { + _ = Throw.IfNull(tools); + + tools.Add(AsAITool(tool)); + } + + /// Creates an to represent a raw . + /// The tool to wrap as an . + /// The wrapped as an . + /// + /// + /// The returned tool is only suitable for use with the returned by + /// (or s that delegate + /// to such an instance). It is likely to be ignored by any other implementation. + /// + /// + /// When a tool has a corresponding -derived type already defined in Microsoft.Extensions.AI, + /// such as , , , or + /// , those types should be preferred instead of this method, as they are more portable, + /// capable of being respected by any implementation. This method does not attempt to + /// map the supplied to any of those types, it simply wraps it as-is: + /// the returned by will + /// be able to unwrap the when it processes the list of tools. + /// + /// + public static AITool AsAITool(this ResponseTool tool) + { + _ = Throw.IfNull(tool); + + return new OpenAIResponsesChatClient.ResponseToolAITool(tool); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantsChatClient.cs similarity index 63% rename from src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantChatClient.cs rename to src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantsChatClient.cs index c1d59e30a68..065ad80d23a 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantsChatClient.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Reflection; using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; @@ -13,21 +12,16 @@ using Microsoft.Shared.Diagnostics; using OpenAI.Assistants; -#pragma warning disable CA1031 // Do not catch general exception types #pragma warning disable SA1005 // Single line comments should begin with single space #pragma warning disable SA1204 // Static elements should appear before instance elements #pragma warning disable S125 // Sections of code should not be commented out -#pragma warning disable S907 // "goto" statement should not be used -#pragma warning disable S1067 // Expressions should not be too complex #pragma warning disable S1751 // Loops with at most one iteration should be refactored #pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields -#pragma warning disable S4456 // Parameter validation in yielding methods should be wrapped -#pragma warning disable S4457 // Parameter validation in "async"/"await" methods should be wrapped namespace Microsoft.Extensions.AI; -/// Represents an for an Azure.AI.Agents.Persistent . -internal sealed class OpenAIAssistantChatClient : IChatClient +/// Represents an for an OpenAI . +internal sealed class OpenAIAssistantsChatClient : IChatClient { /// The underlying . private readonly AssistantClient _client; @@ -44,22 +38,21 @@ internal sealed class OpenAIAssistantChatClient : IChatClient /// List of tools associated with the assistant. private IReadOnlyList? _assistantTools; - /// Initializes a new instance of the class for the specified . - public OpenAIAssistantChatClient(AssistantClient assistantClient, string assistantId, string? defaultThreadId) + /// Initializes a new instance of the class for the specified . + public OpenAIAssistantsChatClient(AssistantClient assistantClient, string assistantId, string? defaultThreadId) { _client = Throw.IfNull(assistantClient); _assistantId = Throw.IfNullOrWhitespace(assistantId); - _defaultThreadId = defaultThreadId; - // 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. - Uri providerUrl = typeof(AssistantClient).GetField("_endpoint", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) - ?.GetValue(assistantClient) as Uri ?? OpenAIClientExtensions.DefaultOpenAIEndpoint; + _metadata = new("openai", assistantClient.Endpoint); + } - _metadata = new("openai", providerUrl); + /// Initializes a new instance of the class for the specified . + public OpenAIAssistantsChatClient(AssistantClient assistantClient, Assistant assistant, string? defaultThreadId) + : this(assistantClient, Throw.IfNull(assistant).Id, defaultThreadId) + { + _assistantTools = assistant.Tools; } /// @@ -83,18 +76,14 @@ public async IAsyncEnumerable GetStreamingResponseAsync( _ = Throw.IfNull(messages); // Extract necessary state from messages and options. - (RunCreationOptions runOptions, List? toolResults) = await CreateRunOptionsAsync(messages, options, cancellationToken).ConfigureAwait(false); + (RunCreationOptions runOptions, ToolResources? toolResources, List? toolResults) = await CreateRunOptionsAsync(messages, options, cancellationToken).ConfigureAwait(false); // Get the thread ID. string? threadId = options?.ConversationId ?? _defaultThreadId; - if (threadId is null && toolResults is not null) - { - Throw.ArgumentException(nameof(messages), "No thread ID was provided, but chat messages includes tool results."); - } // Get any active run ID for this thread. This is necessary in case a thread has been left with an - // active run, in which all attempts other than submitting tools will fail. We thus need to cancel - // any active run on the thread. + // active run, in which case all attempts other than submitting tools will fail. We thus need to cancel + // any active run on the thread if we're not submitting tool results to it. ThreadRun? threadRun = null; if (threadId is not null) { @@ -118,7 +107,7 @@ public async IAsyncEnumerable GetStreamingResponseAsync( ConvertFunctionResultsToToolOutput(toolResults, out List? toolOutputs) is { } toolRunId && toolRunId == threadRun.Id) { - // There's an active run and we have tool results to submit, so submit the results and continue streaming. + // There's an active run and, critically, we have tool results to submit for that exact run, so submit the results and continue streaming. // This is going to ignore any additional messages in the run options, as we are only submitting tool outputs, // but there doesn't appear to be a way to submit additional messages, and having such additional messages is rare. updates = _client.SubmitToolOutputsToRunStreamingAsync(threadRun.ThreadId, threadRun.Id, toolOutputs, cancellationToken); @@ -128,7 +117,11 @@ public async IAsyncEnumerable GetStreamingResponseAsync( if (threadId is null) { // No thread ID was provided, so create a new thread. - ThreadCreationOptions threadCreationOptions = new(); + ThreadCreationOptions threadCreationOptions = new() + { + ToolResources = toolResources, + }; + foreach (var message in runOptions.AdditionalMessages) { threadCreationOptions.InitialMessages.Add(message); @@ -192,18 +185,71 @@ public async IAsyncEnumerable GetStreamingResponseAsync( if (ru is RequiredActionUpdate rau && rau.ToolCallId is string toolCallId && rau.FunctionName is string functionName) { - ruUpdate.Contents.Add( - new FunctionCallContent( - JsonSerializer.Serialize([ru.Value.Id, toolCallId], OpenAIJsonContext.Default.StringArray), - functionName, - JsonSerializer.Deserialize(rau.FunctionArguments, OpenAIJsonContext.Default.IDictionaryStringObject)!)); + var fcc = OpenAIClientExtensions.ParseCallContent( + rau.FunctionArguments, + JsonSerializer.Serialize([ru.Value.Id, toolCallId], OpenAIJsonContext.Default.StringArray), + functionName); + fcc.RawRepresentation = ru; + ruUpdate.Contents.Add(fcc); } yield return ruUpdate; break; + case RunStepDetailsUpdate details: + if (!string.IsNullOrEmpty(details.CodeInterpreterInput)) + { + CodeInterpreterToolCallContent hcitcc = new() + { + CallId = details.ToolCallId, + Inputs = [new DataContent(Encoding.UTF8.GetBytes(details.CodeInterpreterInput), "text/x-python")], + RawRepresentation = details, + }; + + yield return new ChatResponseUpdate(ChatRole.Assistant, [hcitcc]) + { + AuthorName = _assistantId, + ConversationId = threadId, + MessageId = responseId, + RawRepresentation = update, + ResponseId = responseId, + }; + } + + if (details.CodeInterpreterOutputs is { Count: > 0 }) + { + CodeInterpreterToolResultContent hcitrc = new() + { + CallId = details.ToolCallId, + RawRepresentation = details, + }; + + foreach (var output in details.CodeInterpreterOutputs) + { + if (output.ImageFileId is not null) + { + (hcitrc.Outputs ??= []).Add(new HostedFileContent(output.ImageFileId) { MediaType = "image/*" }); + } + + if (output.Logs is string logs) + { + (hcitrc.Outputs ??= []).Add(new TextContent(logs)); + } + } + + yield return new ChatResponseUpdate(ChatRole.Assistant, [hcitrc]) + { + AuthorName = _assistantId, + ConversationId = threadId, + MessageId = responseId, + RawRepresentation = update, + ResponseId = responseId, + }; + } + break; + case MessageContentUpdate mcu: - yield return new(mcu.Role == MessageRole.User ? ChatRole.User : ChatRole.Assistant, mcu.Text) + ChatResponseUpdate textUpdate = new(mcu.Role == MessageRole.User ? ChatRole.User : ChatRole.Assistant, mcu.Text) { AuthorName = _assistantId, ConversationId = threadId, @@ -211,10 +257,48 @@ public async IAsyncEnumerable GetStreamingResponseAsync( RawRepresentation = mcu, ResponseId = responseId, }; + + // Add any annotations from the text update. The OpenAI Assistants API does not support passing these back + // into the model (MessageContent.FromXx does not support providing annotations), so they end up being one way and are dropped + // on subsequent requests. + if (mcu.TextAnnotation is { } tau) + { + string? fileId = null; + string? toolName = null; + if (!string.IsNullOrWhiteSpace(tau.InputFileId)) + { + fileId = tau.InputFileId; + toolName = "file_search"; + } + else if (!string.IsNullOrWhiteSpace(tau.OutputFileId)) + { + fileId = tau.OutputFileId; + toolName = "code_interpreter"; + } + + if (fileId is not null) + { + if (textUpdate.Contents.Count == 0) + { + // In case a chunk doesn't have text content, create one with empty text to hold the annotation. + textUpdate.Contents.Add(new TextContent(string.Empty)); + } + + (((TextContent)textUpdate.Contents[0]).Annotations ??= []).Add(new CitationAnnotation + { + RawRepresentation = tau, + AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = tau.StartIndex, EndIndex = tau.EndIndex }], + FileId = fileId, + ToolName = toolName, + }); + } + } + + yield return textUpdate; break; default: - yield return new ChatResponseUpdate + yield return new() { AuthorName = _assistantId, ConversationId = threadId, @@ -235,7 +319,7 @@ void IDisposable.Dispose() } /// Converts an Extensions function to an OpenAI assistants function tool. - internal static FunctionToolDefinition ToOpenAIAssistantsFunctionToolDefinition(AIFunction aiFunction, ChatOptions? options = null) + internal static FunctionToolDefinition ToOpenAIAssistantsFunctionToolDefinition(AIFunctionDeclaration aiFunction, ChatOptions? options = null) { bool? strict = OpenAIClientExtensions.HasStrict(aiFunction.AdditionalProperties) ?? @@ -253,7 +337,7 @@ internal static FunctionToolDefinition ToOpenAIAssistantsFunctionToolDefinition( /// Creates the to use for the request and extracts any function result contents /// that need to be submitted as tool results. /// - private async ValueTask<(RunCreationOptions RunOptions, List? ToolResults)> CreateRunOptionsAsync( + private async ValueTask<(RunCreationOptions RunOptions, ToolResources? Resources, List? ToolResults)> CreateRunOptionsAsync( IEnumerable messages, ChatOptions? options, CancellationToken cancellationToken) { // Create the options instance to populate, either a fresh or using one the caller provides. @@ -261,6 +345,8 @@ internal static FunctionToolDefinition ToOpenAIAssistantsFunctionToolDefinition( options?.RawRepresentationFactory?.Invoke(this) as RunCreationOptions ?? new(); + ToolResources? resources = null; + // Populate the run options from the ChatOptions, if provided. if (options is not null) { @@ -272,6 +358,8 @@ internal static FunctionToolDefinition ToOpenAIAssistantsFunctionToolDefinition( if (options.Tools is { Count: > 0 } tools) { + HashSet toolsOverride = new(ToolDefinitionNameEqualityComparer.Instance); + // If the caller has provided any tool overrides, we'll assume they don't want to use the assistant's tools. // But if they haven't, the only way we can provide our tools is via an override, whereas we'd really like to // just add them. To handle that, we'll get all of the assistant's tools and add them to the override list @@ -284,10 +372,7 @@ internal static FunctionToolDefinition ToOpenAIAssistantsFunctionToolDefinition( _assistantTools = assistant.Value.Tools; } - foreach (var tool in _assistantTools) - { - runOptions.ToolsOverride.Add(tool); - } + toolsOverride.UnionWith(_assistantTools); } // The caller can provide tools in the supplied ThreadAndRunOptions. Augment it with any supplied via ChatOptions.Tools. @@ -295,15 +380,56 @@ internal static FunctionToolDefinition ToOpenAIAssistantsFunctionToolDefinition( { switch (tool) { - case AIFunction aiFunction: - runOptions.ToolsOverride.Add(ToOpenAIAssistantsFunctionToolDefinition(aiFunction, options)); + case AIFunctionDeclaration aiFunction: + _ = toolsOverride.Add(ToOpenAIAssistantsFunctionToolDefinition(aiFunction, options)); + break; + + case HostedCodeInterpreterTool codeInterpreterTool: + var interpreterToolDef = ToolDefinition.CreateCodeInterpreter(); + _ = toolsOverride.Add(interpreterToolDef); + + if (codeInterpreterTool.Inputs?.Count is > 0) + { + ThreadInitializationMessage? threadInitializationMessage = null; + foreach (var input in codeInterpreterTool.Inputs) + { + if (input is HostedFileContent hostedFile) + { + threadInitializationMessage ??= new(MessageRole.User, [MessageContent.FromText("attachments")]); + threadInitializationMessage.Attachments.Add(new(hostedFile.FileId, [interpreterToolDef])); + } + } + + if (threadInitializationMessage is not null) + { + runOptions.AdditionalMessages.Add(threadInitializationMessage); + } + } + break; - case HostedCodeInterpreterTool: - runOptions.ToolsOverride.Add(new CodeInterpreterToolDefinition()); + case HostedFileSearchTool fileSearchTool: + _ = toolsOverride.Add(ToolDefinition.CreateFileSearch(fileSearchTool.MaximumResultCount)); + if (fileSearchTool.Inputs is { Count: > 0 } fileSearchInputs) + { + foreach (var input in fileSearchInputs) + { + if (input is HostedVectorStoreContent file) + { + (resources ??= new()).FileSearch ??= new(); + resources.FileSearch.VectorStoreIds.Add(file.VectorStoreId); + } + } + } + break; } } + + foreach (var tool in toolsOverride) + { + runOptions.ToolsOverride.Add(tool); + } } // Store the tool mode, if relevant. @@ -401,6 +527,10 @@ void AppendSystemInstructions(string? toAppend) { switch (content) { + case AIContent when content.RawRepresentation is MessageContent rawRep: + messageContents.Add(rawRep); + break; + case TextContent text: messageContents.Add(MessageContent.FromText(text.Text)); break; @@ -409,18 +539,9 @@ void AppendSystemInstructions(string? toAppend) messageContents.Add(MessageContent.FromImageUri(image.Uri)); break; - // Assistants doesn't support data URIs. - //case DataContent image when image.HasTopLevelMediaType("image"): - // messageContents.Add(MessageContent.FromImageUri(new Uri(image.Uri))); - // break; - - case FunctionResultContent result: + case FunctionResultContent result when chatMessage.Role == ChatRole.Tool: (functionResults ??= []).Add(result); break; - - case AIContent when content.RawRepresentation is MessageContent rawRep: - messageContents.Add(rawRep); - break; } } @@ -434,7 +555,7 @@ void AppendSystemInstructions(string? toAppend) runOptions.AdditionalInstructions = instructions?.ToString(); - return (runOptions, functionResults); + return (runOptions, resources, functionResults); } /// Convert instances to instances. @@ -478,4 +599,22 @@ void AppendSystemInstructions(string? toAppend) return runId; } + + /// + /// Provides the same behavior as , except + /// for it compares names so that two function tool definitions with the + /// same name compare equally. + /// + private sealed class ToolDefinitionNameEqualityComparer : IEqualityComparer + { + public static ToolDefinitionNameEqualityComparer Instance { get; } = new(); + + public bool Equals(ToolDefinition? x, ToolDefinition? y) => + x is FunctionToolDefinition xFtd && y is FunctionToolDefinition yFtd ? xFtd.FunctionName.Equals(yFtd.FunctionName, StringComparison.Ordinal) : + EqualityComparer.Default.Equals(x, y); + + public int GetHashCode(ToolDefinition obj) => + obj is FunctionToolDefinition ftd ? ftd.FunctionName.GetHashCode(StringComparison.Ordinal) : + EqualityComparer.Default.GetHashCode(obj); + } } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index 3be0a1cc1ee..70ef6674bd4 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -2,11 +2,15 @@ // 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.Linq; using System.Reflection; using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; @@ -14,16 +18,35 @@ using OpenAI.Chat; #pragma warning disable CA1308 // Normalize strings to uppercase -#pragma warning disable EA0011 // Consider removing unnecessary conditional access operator (?) -#pragma warning disable S1067 // Expressions should not be too complex #pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields #pragma warning disable SA1204 // Static elements should appear before instance elements namespace Microsoft.Extensions.AI; /// Represents an for an OpenAI or . -internal sealed class OpenAIChatClient : IChatClient +internal sealed partial class OpenAIChatClient : IChatClient { + // These delegate instances are used to call the internal overloads of CompleteChatAsync and CompleteChatStreamingAsync that accept + // a RequestOptions. These should be replaced once a better way to pass RequestOptions is available. + private static readonly Func, ChatCompletionOptions, RequestOptions, Task>>? + _completeChatAsync = + (Func, ChatCompletionOptions, RequestOptions, Task>>?) + typeof(ChatClient) + .GetMethod( + nameof(ChatClient.CompleteChatAsync), BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, + null, [typeof(IEnumerable), typeof(ChatCompletionOptions), typeof(RequestOptions)], null) + ?.CreateDelegate( + typeof(Func, ChatCompletionOptions, RequestOptions, Task>>)); + private static readonly Func, ChatCompletionOptions, RequestOptions, AsyncCollectionResult>? + _completeChatStreamingAsync = + (Func, ChatCompletionOptions, RequestOptions, AsyncCollectionResult>?) + typeof(ChatClient) + .GetMethod( + nameof(ChatClient.CompleteChatStreamingAsync), BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, + null, [typeof(IEnumerable), typeof(ChatCompletionOptions), typeof(RequestOptions)], null) + ?.CreateDelegate( + typeof(Func, ChatCompletionOptions, RequestOptions, AsyncCollectionResult>)); + /// Metadata about the client. private readonly ChatClientMetadata _metadata; @@ -35,20 +58,9 @@ internal sealed class OpenAIChatClient : IChatClient /// is . 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. - Uri providerUrl = typeof(ChatClient).GetField("_endpoint", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) - ?.GetValue(chatClient) as Uri ?? OpenAIClientExtensions.DefaultOpenAIEndpoint; - string? model = typeof(ChatClient).GetField("_model", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) - ?.GetValue(chatClient) as string; + _chatClient = Throw.IfNull(chatClient); - _metadata = new("openai", providerUrl, model); + _metadata = new("openai", chatClient.Endpoint, _chatClient.Model); } /// @@ -70,13 +82,16 @@ public async Task GetResponseAsync( { _ = Throw.IfNull(messages); - var openAIChatMessages = ToOpenAIChatMessages(messages, options, AIJsonUtilities.DefaultOptions); + var openAIChatMessages = ToOpenAIChatMessages(messages, options); var openAIOptions = ToOpenAIOptions(options); // Make the call to OpenAI. - var response = await _chatClient.CompleteChatAsync(openAIChatMessages, openAIOptions, cancellationToken).ConfigureAwait(false); + var task = _completeChatAsync is not null ? + _completeChatAsync(_chatClient, openAIChatMessages, openAIOptions, cancellationToken.ToRequestOptions(streaming: false)) : + _chatClient.CompleteChatAsync(openAIChatMessages, openAIOptions, cancellationToken); + var response = await task.ConfigureAwait(false); - return FromOpenAIChatCompletion(response.Value, options, openAIOptions); + return FromOpenAIChatCompletion(response.Value, openAIOptions); } /// @@ -85,11 +100,13 @@ public IAsyncEnumerable GetStreamingResponseAsync( { _ = Throw.IfNull(messages); - var openAIChatMessages = ToOpenAIChatMessages(messages, options, AIJsonUtilities.DefaultOptions); + var openAIChatMessages = ToOpenAIChatMessages(messages, options); var openAIOptions = ToOpenAIOptions(options); // Make the call to OpenAI. - var chatCompletionUpdates = _chatClient.CompleteChatStreamingAsync(openAIChatMessages, openAIOptions, cancellationToken); + var chatCompletionUpdates = _completeChatStreamingAsync is not null ? + _completeChatStreamingAsync(_chatClient, openAIChatMessages, openAIOptions, cancellationToken.ToRequestOptions(streaming: true)) : + _chatClient.CompleteChatStreamingAsync(openAIChatMessages, openAIOptions, cancellationToken); return FromOpenAIStreamingChatCompletionAsync(chatCompletionUpdates, openAIOptions, cancellationToken); } @@ -101,7 +118,7 @@ void IDisposable.Dispose() } /// Converts an Extensions function to an OpenAI chat tool. - internal static ChatTool ToOpenAIChatTool(AIFunction aiFunction, ChatOptions? options = null) + internal static ChatTool ToOpenAIChatTool(AIFunctionDeclaration aiFunction, ChatOptions? options = null) { bool? strict = OpenAIClientExtensions.HasStrict(aiFunction.AdditionalProperties) ?? @@ -115,7 +132,7 @@ internal static ChatTool ToOpenAIChatTool(AIFunction aiFunction, ChatOptions? op } /// Converts an Extensions chat message enumerable to an OpenAI chat message enumerable. - private static IEnumerable ToOpenAIChatMessages(IEnumerable inputs, ChatOptions? chatOptions, JsonSerializerOptions jsonOptions) + internal static IEnumerable ToOpenAIChatMessages(IEnumerable inputs, ChatOptions? chatOptions) { // Maps all of the M.E.AI types to the corresponding OpenAI types. // Unrecognized or non-processable content is ignored. @@ -127,15 +144,22 @@ internal static ChatTool ToOpenAIChatTool(AIFunction aiFunction, ChatOptions? op foreach (ChatMessage input in inputs) { + if (input.RawRepresentation is OpenAI.Chat.ChatMessage raw) + { + yield return raw; + continue; + } + if (input.Role == ChatRole.System || input.Role == ChatRole.User || input.Role == OpenAIClientExtensions.ChatRoleDeveloper) { var parts = ToOpenAIChatContent(input.Contents); + string? name = SanitizeAuthorName(input.AuthorName); yield return - input.Role == ChatRole.System ? new SystemChatMessage(parts) { ParticipantName = input.AuthorName } : - input.Role == OpenAIClientExtensions.ChatRoleDeveloper ? new DeveloperChatMessage(parts) { ParticipantName = input.AuthorName } : - new UserChatMessage(parts) { ParticipantName = input.AuthorName }; + input.Role == ChatRole.System ? new SystemChatMessage(parts) { ParticipantName = name } : + input.Role == OpenAIClientExtensions.ChatRoleDeveloper ? new DeveloperChatMessage(parts) { ParticipantName = name } : + new UserChatMessage(parts) { ParticipantName = name }; } else if (input.Role == ChatRole.Tool) { @@ -148,7 +172,7 @@ internal static ChatTool ToOpenAIChatTool(AIFunction aiFunction, ChatOptions? op { try { - result = JsonSerializer.Serialize(resultContent.Result, jsonOptions.GetTypeInfo(typeof(object))); + result = JsonSerializer.Serialize(resultContent.Result, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))); } catch (NotSupportedException) { @@ -176,7 +200,7 @@ internal static ChatTool ToOpenAIChatTool(AIFunction aiFunction, ChatOptions? op case FunctionCallContent fc: (toolCalls ??= []).Add( ChatToolCall.CreateFunctionToolCall(fc.CallId, fc.Name, new(JsonSerializer.SerializeToUtf8Bytes( - fc.Arguments, jsonOptions.GetTypeInfo(typeof(IDictionary)))))); + fc.Arguments, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IDictionary)))))); break; default: @@ -208,7 +232,7 @@ internal static ChatTool ToOpenAIChatTool(AIFunction aiFunction, ChatOptions? op new(ChatMessageContentPart.CreateTextPart(string.Empty)); } - message.ParticipantName = input.AuthorName; + message.ParticipantName = SanitizeAuthorName(input.AuthorName); message.Refusal = refusal; yield return message; @@ -217,15 +241,22 @@ internal static ChatTool ToOpenAIChatTool(AIFunction aiFunction, ChatOptions? op } /// Converts a list of to a list of . - private static List ToOpenAIChatContent(IList contents) + internal static List ToOpenAIChatContent(IEnumerable contents) { List parts = []; foreach (var content in contents) { - if (ToChatMessageContentPart(content) is { } part) + if (content.RawRepresentation is ChatMessageContentPart raw) + { + parts.Add(raw); + } + else { - parts.Add(part); + if (ToChatMessageContentPart(content) is { } part) + { + parts.Add(part); + } } } @@ -241,6 +272,9 @@ private static List ToOpenAIChatContent(IList { switch (content) { + case AIContent when content.RawRepresentation is ChatMessageContentPart rawContentPart: + return rawContentPart; + case TextContent textContent: return ChatMessageContentPart.CreateTextPart(textContent.Text); @@ -264,10 +298,10 @@ private static List ToOpenAIChatContent(IList break; case DataContent dataContent when dataContent.MediaType.StartsWith("application/pdf", StringComparison.OrdinalIgnoreCase): - return ChatMessageContentPart.CreateFilePart(BinaryData.FromBytes(dataContent.Data), dataContent.MediaType, $"{Guid.NewGuid():N}.pdf"); + return ChatMessageContentPart.CreateFilePart(BinaryData.FromBytes(dataContent.Data), dataContent.MediaType, dataContent.Name ?? $"{Guid.NewGuid():N}.pdf"); - case AIContent when content.RawRepresentation is ChatMessageContentPart rawContentPart: - return rawContentPart; + case HostedFileContent fileContent: + return ChatMessageContentPart.CreateFilePart(fileContent.FileId); } return null; @@ -288,7 +322,7 @@ private static List ToOpenAIChatContent(IList return null; } - private static async IAsyncEnumerable FromOpenAIStreamingChatCompletionAsync( + internal static async IAsyncEnumerable FromOpenAIStreamingChatCompletionAsync( IAsyncEnumerable updates, ChatCompletionOptions? options, [EnumeratorCancellation] CancellationToken cancellationToken) @@ -326,13 +360,7 @@ private static async IAsyncEnumerable FromOpenAIStreamingCha // Transfer over content update items. if (update.ContentUpdate is { Count: > 0 }) { - foreach (ChatMessageContentPart contentPart in update.ContentUpdate) - { - if (ToAIContent(contentPart) is AIContent aiContent) - { - responseUpdate.Contents.Add(aiContent); - } - } + ConvertContentParts(update.ContentUpdate, responseUpdate.Contents); } if (update.OutputAudioUpdate is { } audioUpdate) @@ -400,7 +428,7 @@ private static async IAsyncEnumerable FromOpenAIStreamingCha FunctionCallInfo fci = entry.Value; if (!string.IsNullOrWhiteSpace(fci.Name)) { - var callContent = ParseCallContentFromJsonString( + var callContent = OpenAIClientExtensions.ParseCallContent( fci.Arguments?.ToString() ?? string.Empty, fci.CallId!, fci.Name!); @@ -430,13 +458,14 @@ private static string GetOutputAudioMimeType(ChatCompletionOptions? options) => "mp3" or _ => "audio/mpeg", }; - private static ChatResponse FromOpenAIChatCompletion(ChatCompletion openAICompletion, ChatOptions? options, ChatCompletionOptions chatCompletionOptions) + internal static ChatResponse FromOpenAIChatCompletion(ChatCompletion openAICompletion, ChatCompletionOptions? chatCompletionOptions) { _ = Throw.IfNull(openAICompletion); // Create the return message. ChatMessage returnMessage = new() { + CreatedAt = openAICompletion.CreatedAt, MessageId = openAICompletion.Id, // There's no per-message ID, so we use the same value as the response ID RawRepresentation = openAICompletion, Role = FromOpenAIChatRole(openAICompletion.Role), @@ -461,17 +490,14 @@ private static ChatResponse FromOpenAIChatCompletion(ChatCompletion openAIComple } // Also manufacture function calling content items from any tool calls in the response. - if (options?.Tools is { Count: > 0 }) + foreach (ChatToolCall toolCall in openAICompletion.ToolCalls) { - foreach (ChatToolCall toolCall in openAICompletion.ToolCalls) + if (!string.IsNullOrWhiteSpace(toolCall.FunctionName)) { - if (!string.IsNullOrWhiteSpace(toolCall.FunctionName)) - { - var callContent = ParseCallContentFromBinaryData(toolCall.FunctionArguments, toolCall.Id, toolCall.FunctionName); - callContent.RawRepresentation = toolCall; + var callContent = OpenAIClientExtensions.ParseCallContent(toolCall.FunctionArguments, toolCall.Id, toolCall.FunctionName); + callContent.RawRepresentation = toolCall; - returnMessage.Contents.Add(callContent); - } + returnMessage.Contents.Add(callContent); } } @@ -481,6 +507,30 @@ private static ChatResponse FromOpenAIChatCompletion(ChatCompletion openAIComple returnMessage.Contents.Add(new ErrorContent(refusal) { ErrorCode = nameof(openAICompletion.Refusal) }); } + // And add annotations. OpenAI chat completion specifies annotations at the message level (and as such they can't be + // roundtripped back); we store them either on the first text content, assuming there is one, or on a dedicated content + // instance if not. + if (openAICompletion.Annotations is { Count: > 0 }) + { + TextContent? annotationContent = returnMessage.Contents.OfType().FirstOrDefault(); + if (annotationContent is null) + { + annotationContent = new(null); + returnMessage.Contents.Add(annotationContent); + } + + foreach (var annotation in openAICompletion.Annotations) + { + (annotationContent.Annotations ??= []).Add(new CitationAnnotation + { + RawRepresentation = annotation, + AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = annotation.StartIndex, EndIndex = annotation.EndIndex }], + Title = annotation.WebResourceTitle, + Url = annotation.WebResourceUri, + }); + } + } + // Wrap the content in a ChatResponse to return. var response = new ChatResponse(returnMessage) { @@ -504,12 +554,12 @@ private ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) { if (options is null) { - return new ChatCompletionOptions(); + return new(); } if (options.RawRepresentationFactory?.Invoke(this) is not ChatCompletionOptions result) { - result = new ChatCompletionOptions(); + result = new(); } result.FrequencyPenalty ??= options.FrequencyPenalty; @@ -517,8 +567,8 @@ private ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) result.TopP ??= options.TopP; result.PresencePenalty ??= options.PresencePenalty; result.Temperature ??= options.Temperature; - result.AllowParallelToolCalls ??= options.AllowMultipleToolCalls; result.Seed ??= options.Seed; + OpenAIClientExtensions.PatchModelIfNotSet(ref result.Patch, options.ModelId); if (options.StopSequences is { Count: > 0 } stopSequences) { @@ -532,12 +582,17 @@ private ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) { foreach (AITool tool in tools) { - if (tool is AIFunction af) + if (tool is AIFunctionDeclaration af) { result.Tools.Add(ToOpenAIChatTool(af, options)); } } + if (result.Tools.Count > 0) + { + result.AllowParallelToolCalls ??= options.AllowMultipleToolCalls; + } + if (result.ToolChoice is null && result.Tools.Count > 0) { switch (options.ToolMode) @@ -560,27 +615,28 @@ private ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) } } - if (result.ResponseFormat is null) - { - if (options.ResponseFormat is ChatResponseFormatText) - { - result.ResponseFormat = OpenAI.Chat.ChatResponseFormat.CreateTextFormat(); - } - else if (options.ResponseFormat is ChatResponseFormatJson jsonFormat) - { - result.ResponseFormat = OpenAIClientExtensions.StrictSchemaTransformCache.GetOrCreateTransformedSchema(jsonFormat) is { } jsonSchema ? - OpenAI.Chat.ChatResponseFormat.CreateJsonSchemaFormat( - jsonFormat.SchemaName ?? "json_schema", - BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, OpenAIJsonContext.Default.JsonElement)), - jsonFormat.SchemaDescription, - OpenAIClientExtensions.HasStrict(options.AdditionalProperties)) : - OpenAI.Chat.ChatResponseFormat.CreateJsonObjectFormat(); - } - } + result.ResponseFormat ??= ToOpenAIChatResponseFormat(options.ResponseFormat, options); return result; } + internal static OpenAI.Chat.ChatResponseFormat? ToOpenAIChatResponseFormat(ChatResponseFormat? format, ChatOptions? options) => + format switch + { + ChatResponseFormatText => OpenAI.Chat.ChatResponseFormat.CreateTextFormat(), + + ChatResponseFormatJson jsonFormat when OpenAIClientExtensions.StrictSchemaTransformCache.GetOrCreateTransformedSchema(jsonFormat) is { } jsonSchema => + OpenAI.Chat.ChatResponseFormat.CreateJsonSchemaFormat( + jsonFormat.SchemaName ?? "json_schema", + BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, OpenAIJsonContext.Default.JsonElement)), + jsonFormat.SchemaDescription, + OpenAIClientExtensions.HasStrict(options?.AdditionalProperties)), + + ChatResponseFormatJson => OpenAI.Chat.ChatResponseFormat.CreateJsonObjectFormat(), + + _ => null + }; + private static UsageDetails FromOpenAIUsage(ChatTokenUsage tokenUsage) { var destination = new UsageDetails @@ -624,6 +680,20 @@ private static ChatRole FromOpenAIChatRole(ChatMessageRole role) => _ => new ChatRole(role.ToString()), }; + /// Creates s from . + /// The content parts to convert into a content. + /// The result collection into which to write the resulting content. + internal static void ConvertContentParts(ChatMessageContent content, IList results) + { + foreach (ChatMessageContentPart contentPart in content) + { + if (ToAIContent(contentPart) is { } aiContent) + { + results.Add(aiContent); + } + } + } + /// Creates an from a . /// The content part to convert into a content. /// The constructed , or if the content part could not be converted. @@ -631,21 +701,31 @@ private static ChatRole FromOpenAIChatRole(ChatMessageRole role) => { AIContent? aiContent = null; - if (contentPart.Kind == ChatMessageContentPartKind.Text) - { - aiContent = new TextContent(contentPart.Text); - } - else if (contentPart.Kind == ChatMessageContentPartKind.Image) + switch (contentPart.Kind) { - aiContent = - contentPart.ImageUri is not null ? new UriContent(contentPart.ImageUri, "image/*") : - contentPart.ImageBytes is not null ? new DataContent(contentPart.ImageBytes.ToMemory(), contentPart.ImageBytesMediaType) : - null; + case ChatMessageContentPartKind.Text: + aiContent = new TextContent(contentPart.Text); + break; - if (aiContent is not null && contentPart.ImageDetailLevel?.ToString() is string detail) - { - (aiContent.AdditionalProperties ??= [])[nameof(contentPart.ImageDetailLevel)] = detail; - } + case ChatMessageContentPartKind.Image: + aiContent = + contentPart.ImageUri is not null ? new UriContent(contentPart.ImageUri, OpenAIClientExtensions.ImageUriToMediaType(contentPart.ImageUri)) : + contentPart.ImageBytes is not null ? new DataContent(contentPart.ImageBytes.ToMemory(), contentPart.ImageBytesMediaType) : + null; + + if (aiContent is not null && contentPart.ImageDetailLevel?.ToString() is string detail) + { + (aiContent.AdditionalProperties ??= [])[nameof(contentPart.ImageDetailLevel)] = detail; + } + + break; + + case ChatMessageContentPartKind.File: + aiContent = + contentPart.FileId is not null ? new HostedFileContent(contentPart.FileId) { Name = contentPart.Filename } : + contentPart.FileBytes is not null ? new DataContent(contentPart.FileBytes.ToMemory(), contentPart.FileBytesMediaType) { Name = contentPart.Filename } : + null; + break; } if (aiContent is not null) @@ -673,13 +753,26 @@ private static ChatRole FromOpenAIChatRole(ChatMessageRole role) => _ => new ChatFinishReason(s), }; - private static FunctionCallContent ParseCallContentFromJsonString(string json, string callId, string name) => - FunctionCallContent.CreateFromParsedArguments(json, callId, name, - argumentParser: static json => JsonSerializer.Deserialize(json, OpenAIJsonContext.Default.IDictionaryStringObject)!); + /// Sanitizes the author name to be appropriate for including as an OpenAI participant name. + private static string? SanitizeAuthorName(string? name) + { + if (name is not null) + { + const int MaxLength = 64; - private static FunctionCallContent ParseCallContentFromBinaryData(BinaryData ut8Json, string callId, string name) => - FunctionCallContent.CreateFromParsedArguments(ut8Json, callId, name, - argumentParser: static json => JsonSerializer.Deserialize(json, OpenAIJsonContext.Default.IDictionaryStringObject)!); + name = InvalidAuthorNameRegex().Replace(name, string.Empty); + if (name.Length == 0) + { + name = null; + } + else if (name.Length > MaxLength) + { + name = name.Substring(0, MaxLength); + } + } + + return name; + } /// POCO representing function calling info. Used to concatenation information for a single function call from across multiple streaming updates. private sealed class FunctionCallInfo @@ -688,4 +781,13 @@ private sealed class FunctionCallInfo public string? Name; public StringBuilder? Arguments; } + + private const string InvalidAuthorNamePattern = @"[^a-zA-Z0-9_]+"; +#if NET + [GeneratedRegex(InvalidAuthorNamePattern)] + private static partial Regex InvalidAuthorNameRegex(); +#else + private static Regex InvalidAuthorNameRegex() => _invalidAuthorNameRegex; + private static readonly Regex _invalidAuthorNameRegex = new(InvalidAuthorNamePattern, RegexOptions.Compiled); +#endif } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs index dccddf3038e..285b2c1e7ae 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs @@ -2,26 +2,22 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.ClientModel.Primitives; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; -using Microsoft.Shared.Diagnostics; using OpenAI; using OpenAI.Assistants; using OpenAI.Audio; using OpenAI.Chat; using OpenAI.Embeddings; -using OpenAI.Realtime; +using OpenAI.Images; using OpenAI.Responses; -#pragma warning disable S103 // Lines should not be too long -#pragma warning disable S1067 // Expressions should not be too complex #pragma warning disable SA1515 // Single-line comment should be preceded by blank line -#pragma warning disable CA1305 // Specify IFormatProvider -#pragma warning disable S1135 // Track uses of "TODO" tags namespace Microsoft.Extensions.AI; @@ -120,7 +116,7 @@ public static IChatClient AsIChatClient(this ChatClient chatClient) => /// An that can be used to converse via the . /// is . public static IChatClient AsIChatClient(this OpenAIResponseClient responseClient) => - new OpenAIResponseChatClient(responseClient); + new OpenAIResponsesChatClient(responseClient); /// Gets an for use with this . /// The instance to be accessed as an . @@ -135,7 +131,21 @@ public static IChatClient AsIChatClient(this OpenAIResponseClient responseClient /// is . /// is empty or composed entirely of whitespace. public static IChatClient AsIChatClient(this AssistantClient assistantClient, string assistantId, string? threadId = null) => - new OpenAIAssistantChatClient(assistantClient, assistantId, threadId); + new OpenAIAssistantsChatClient(assistantClient, assistantId, threadId); + + /// Gets an for use with this . + /// The instance to be accessed as an . + /// The with which to interact. + /// + /// An optional existing thread identifier for the chat session. This serves as a default, and may be overridden per call to + /// or via the + /// property. If no thread ID is provided via either mechanism, a new thread will be created for the request. + /// + /// An instance configured to interact with the specified agent and thread. + /// is . + /// is . + public static IChatClient AsIChatClient(this AssistantClient assistantClient, Assistant assistant, string? threadId = null) => + new OpenAIAssistantsChatClient(assistantClient, assistant, threadId); /// Gets an for use with this . /// The client. @@ -145,6 +155,14 @@ public static IChatClient AsIChatClient(this AssistantClient assistantClient, st public static ISpeechToTextClient AsISpeechToTextClient(this AudioClient audioClient) => new OpenAISpeechToTextClient(audioClient); + /// Gets an for use with this . + /// The client. + /// An that can be used to generate images via the . + /// is . + [Experimental("MEAI001")] + public static IImageGenerator AsIImageGenerator(this ImageClient imageClient) => + new OpenAIImageGenerator(imageClient); + /// Gets an for use with this . /// The client. /// The number of dimensions to generate in each embedding. @@ -153,44 +171,14 @@ public static ISpeechToTextClient AsISpeechToTextClient(this AudioClient audioCl public static IEmbeddingGenerator> AsIEmbeddingGenerator(this EmbeddingClient embeddingClient, int? defaultModelDimensions = null) => new OpenAIEmbeddingGenerator(embeddingClient, defaultModelDimensions); - /// Creates an OpenAI from an . - /// The function to convert. - /// An OpenAI representing . - /// is . - public static ChatTool AsOpenAIChatTool(this AIFunction function) => - OpenAIChatClient.ToOpenAIChatTool(Throw.IfNull(function)); - - /// Creates an OpenAI from an . - /// The function to convert. - /// An OpenAI representing . - /// is . - public static FunctionToolDefinition AsOpenAIAssistantsFunctionToolDefinition(this AIFunction function) => - OpenAIAssistantChatClient.ToOpenAIAssistantsFunctionToolDefinition(Throw.IfNull(function)); - - /// Creates an OpenAI from an . - /// The function to convert. - /// An OpenAI representing . - /// is . - public static ResponseTool AsOpenAIResponseTool(this AIFunction function) => - OpenAIResponseChatClient.ToResponseTool(Throw.IfNull(function)); - - /// Creates an OpenAI from an . - /// The function to convert. - /// An OpenAI representing . - /// is . - public static ConversationFunctionTool AsOpenAIConversationFunctionTool(this AIFunction function) => - OpenAIRealtimeConversationClient.ToOpenAIConversationFunctionTool(Throw.IfNull(function)); - - // TODO: Once we're ready to rely on C# 14 features, add an extension property ChatOptions.Strict. - /// Gets whether the properties specify that strict schema handling is desired. internal static bool? HasStrict(IReadOnlyDictionary? additionalProperties) => additionalProperties?.TryGetValue(StrictKey, out object? strictObj) is true && strictObj is bool strictValue ? strictValue : null; - /// Extracts from an the parameters and strictness setting for use with OpenAI's APIs. - internal static BinaryData ToOpenAIFunctionParameters(AIFunction aiFunction, bool? strict) + /// Extracts from an the parameters and strictness setting for use with OpenAI's APIs. + internal static BinaryData ToOpenAIFunctionParameters(AIFunctionDeclaration aiFunction, bool? strict) { // Perform any desirable transformations on the function's JSON schema, if it'll be used in a strict setting. JsonElement jsonSchema = strict is true ? @@ -205,6 +193,55 @@ internal static BinaryData ToOpenAIFunctionParameters(AIFunction aiFunction, boo return functionParameters; } + /// Creates a new instance of parsing arguments using a specified encoding and parser. + /// The input arguments to be parsed. + /// The function call ID. + /// The function name. + /// A new instance of containing the parse result. + /// is . + /// is . + internal static FunctionCallContent ParseCallContent(string json, string callId, string name) => + FunctionCallContent.CreateFromParsedArguments(json, callId, name, + static json => JsonSerializer.Deserialize(json, OpenAIJsonContext.Default.IDictionaryStringObject)!); + + /// Creates a new instance of parsing arguments using a specified encoding and parser. + /// The input arguments to be parsed. + /// The function call ID. + /// The function name. + /// A new instance of containing the parse result. + /// is . + /// is . + internal static FunctionCallContent ParseCallContent(BinaryData utf8json, string callId, string name) => + FunctionCallContent.CreateFromParsedArguments(utf8json, callId, name, + static utf8json => JsonSerializer.Deserialize(utf8json, OpenAIJsonContext.Default.IDictionaryStringObject)!); + + /// Gets a media type for an image based on the file extension in the provided URI. + internal static string ImageUriToMediaType(Uri uri) + { + string absoluteUri = uri.AbsoluteUri; + return + absoluteUri.EndsWith(".png", StringComparison.OrdinalIgnoreCase) ? "image/png" : + absoluteUri.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) ? "image/jpeg" : + absoluteUri.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase) ? "image/jpeg" : + absoluteUri.EndsWith(".gif", StringComparison.OrdinalIgnoreCase) ? "image/gif" : + absoluteUri.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase) ? "image/bmp" : + absoluteUri.EndsWith(".webp", StringComparison.OrdinalIgnoreCase) ? "image/webp" : + "image/*"; + } + + /// Sets $.model in to if not already set. + internal static void PatchModelIfNotSet(ref JsonPatch patch, string? modelId) + { + if (modelId is not null) + { + _ = patch.TryGetValue("$.model"u8, out string? existingModel); + if (existingModel is null) + { + patch.Set("$.model"u8, modelId); + } + } + } + /// Used to create the JSON payload for an OpenAI tool description. internal sealed class ToolJson { diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs index 004f6274265..d0e1276462f 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIEmbeddingGenerator.cs @@ -2,16 +2,16 @@ // 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.Linq; using System.Reflection; using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; -using OpenAI; using OpenAI.Embeddings; -#pragma warning disable S1067 // Expressions should not be too complex #pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields namespace Microsoft.Extensions.AI; @@ -19,8 +19,17 @@ namespace Microsoft.Extensions.AI; /// An for an OpenAI . internal sealed class OpenAIEmbeddingGenerator : IEmbeddingGenerator> { - /// Default OpenAI endpoint. - private const string DefaultOpenAIEndpoint = "https://api.openai.com/v1"; + // This delegate instance is used to call the internal overload of GenerateEmbeddingsAsync that accepts + // a RequestOptions. This should be replaced once a better way to pass RequestOptions is available. + private static readonly Func, OpenAI.Embeddings.EmbeddingGenerationOptions, RequestOptions, Task>>? + _generateEmbeddingsAsync = + (Func, OpenAI.Embeddings.EmbeddingGenerationOptions, RequestOptions, Task>>?) + typeof(EmbeddingClient) + .GetMethod( + nameof(EmbeddingClient.GenerateEmbeddingsAsync), BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, + null, [typeof(IEnumerable), typeof(OpenAI.Embeddings.EmbeddingGenerationOptions), typeof(RequestOptions)], null) + ?.CreateDelegate( + typeof(Func, OpenAI.Embeddings.EmbeddingGenerationOptions, RequestOptions, Task>>)); /// Metadata about the embedding generator. private readonly EmbeddingGeneratorMetadata _metadata; @@ -38,27 +47,15 @@ internal sealed class OpenAIEmbeddingGenerator : IEmbeddingGenerator is not positive. public OpenAIEmbeddingGenerator(EmbeddingClient embeddingClient, int? defaultModelDimensions = null) { - _ = Throw.IfNull(embeddingClient); + _embeddingClient = Throw.IfNull(embeddingClient); + _dimensions = defaultModelDimensions; + if (defaultModelDimensions < 1) { Throw.ArgumentOutOfRangeException(nameof(defaultModelDimensions), "Value must be greater than 0."); } - _embeddingClient = embeddingClient; - _dimensions = defaultModelDimensions; - - // 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 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? modelId = modelField?.GetValue(embeddingClient) as string; - - _metadata = CreateMetadata("openai", providerUrl, modelId, defaultModelDimensions); + _metadata = new("openai", embeddingClient.Endpoint, _embeddingClient.Model, defaultModelDimensions); } /// @@ -66,7 +63,10 @@ public async Task>> GenerateAsync(IEnumerab { OpenAI.Embeddings.EmbeddingGenerationOptions? openAIOptions = ToOpenAIOptions(options); - var embeddings = (await _embeddingClient.GenerateEmbeddingsAsync(values, openAIOptions, cancellationToken).ConfigureAwait(false)).Value; + var t = _generateEmbeddingsAsync is not null ? + _generateEmbeddingsAsync(_embeddingClient, values, openAIOptions, cancellationToken.ToRequestOptions(streaming: false)) : + _embeddingClient.GenerateEmbeddingsAsync(values, openAIOptions, cancellationToken); + var embeddings = (await t.ConfigureAwait(false)).Value; return new(embeddings.Select(e => new Embedding(e.ToFloats()) @@ -102,19 +102,17 @@ void IDisposable.Dispose() null; } - /// Creates the for this instance. - private static EmbeddingGeneratorMetadata CreateMetadata(string providerName, string providerUrl, string? defaultModelId, int? defaultModelDimensions) => - new(providerName, Uri.TryCreate(providerUrl, UriKind.Absolute, out Uri? providerUri) ? providerUri : null, defaultModelId, defaultModelDimensions); - /// Converts an extensions options instance to an OpenAI options instance. private OpenAI.Embeddings.EmbeddingGenerationOptions ToOpenAIOptions(EmbeddingGenerationOptions? options) { if (options?.RawRepresentationFactory?.Invoke(this) is not OpenAI.Embeddings.EmbeddingGenerationOptions result) { - result = new OpenAI.Embeddings.EmbeddingGenerationOptions(); + result = new(); } result.Dimensions ??= options?.Dimensions ?? _dimensions; + OpenAIClientExtensions.PatchModelIfNotSet(ref result.Patch, options?.ModelId); + return result; } } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIImageGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIImageGenerator.cs new file mode 100644 index 00000000000..a51454d532c --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIImageGenerator.cs @@ -0,0 +1,241 @@ +// Licensed 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.Drawing; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; +using OpenAI; +using OpenAI.Images; + +#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields + +namespace Microsoft.Extensions.AI; + +/// Represents an for an OpenAI or . +internal sealed class OpenAIImageGenerator : IImageGenerator +{ + private static readonly Dictionary _mimeTypeToExtension = new(StringComparer.OrdinalIgnoreCase) + { + ["image/png"] = ".png", + ["image/jpeg"] = ".jpg", + ["image/webp"] = ".webp", + ["image/gif"] = ".gif", + ["image/bmp"] = ".bmp", + ["image/tiff"] = ".tiff", + }; + + /// Metadata about the client. + private readonly ImageGeneratorMetadata _metadata; + + /// The underlying . + private readonly ImageClient _imageClient; + + /// Initializes a new instance of the class for the specified . + /// The underlying client. + /// is . + public OpenAIImageGenerator(ImageClient imageClient) + { + _imageClient = Throw.IfNull(imageClient); + + _metadata = new("openai", imageClient.Endpoint, _imageClient.Model); + } + + /// + public async Task GenerateAsync(ImageGenerationRequest request, ImageGenerationOptions? options = null, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(request); + + string? prompt = request.Prompt; + _ = Throw.IfNull(prompt); + + // If the request has original images, treat this as an edit operation + if (request.OriginalImages is not null && request.OriginalImages.Any()) + { + ImageEditOptions editOptions = ToOpenAIImageEditOptions(options); + string? fileName = null; + Stream? imageStream = null; + + // Currently only a single image is supported for editing. + var originalImage = request.OriginalImages.FirstOrDefault(); + + if (originalImage is DataContent dataContent) + { + imageStream = MemoryMarshal.TryGetArray(dataContent.Data, out var array) ? + new MemoryStream(array.Array!, array.Offset, array.Count) : + new MemoryStream(dataContent.Data.ToArray()); + fileName = dataContent.Name; + + if (fileName is null) + { + // If no file name is provided, use the default based on the content type. + if (dataContent.MediaType is not null && _mimeTypeToExtension.TryGetValue(dataContent.MediaType, out var extension)) + { + fileName = $"image{extension}"; + } + else + { + fileName = "image.png"; // Default to PNG if no content type is available. + } + } + } + + GeneratedImageCollection editResult = await _imageClient.GenerateImageEditsAsync( + imageStream, fileName, prompt, options?.Count ?? 1, editOptions, cancellationToken).ConfigureAwait(false); + + return ToImageGenerationResponse(editResult); + } + + OpenAI.Images.ImageGenerationOptions openAIOptions = ToOpenAIImageGenerationOptions(options); + + GeneratedImageCollection result = await _imageClient.GenerateImagesAsync(prompt, options?.Count ?? 1, openAIOptions, cancellationToken).ConfigureAwait(false); + + return ToImageGenerationResponse(result); + } + + /// + public object? GetService(Type serviceType, object? serviceKey = null) => + serviceType is null ? throw new ArgumentNullException(nameof(serviceType)) : + serviceKey is not null ? null : + serviceType == typeof(ImageGeneratorMetadata) ? _metadata : + serviceType == typeof(ImageClient) ? _imageClient : + serviceType.IsInstanceOfType(this) ? this : + null; + + /// + void IDisposable.Dispose() + { + // Nothing to dispose. Implementation required for the IImageGenerator interface. + } + + /// + /// Converts a to an OpenAI . + /// + /// User's requested size. + /// Closest supported size. + private static GeneratedImageSize? ToOpenAIImageSize(Size? requestedSize) => + requestedSize is null ? null : new GeneratedImageSize(requestedSize.Value.Width, requestedSize.Value.Height); + + /// Converts a to a . + private static ImageGenerationResponse ToImageGenerationResponse(GeneratedImageCollection generatedImages) + { + string contentType = "image/png"; // Default content type for images + + // OpenAI doesn't expose the content type, so we need to read from the internal JSON representation. + // https://github.com/openai/openai-dotnet/issues/561 + var additionalRawData = typeof(GeneratedImageCollection) + .GetProperty("SerializedAdditionalRawData", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + ?.GetValue(generatedImages) as IDictionary; + + if (additionalRawData?.TryGetValue("output_format", out var outputFormat) ?? false) + { + var stringJsonTypeInfo = (JsonTypeInfo)AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(string)); + var outputFormatString = JsonSerializer.Deserialize(outputFormat, stringJsonTypeInfo); + contentType = $"image/{outputFormatString}"; + } + + List contents = []; + + foreach (GeneratedImage image in generatedImages) + { + if (image.ImageBytes is not null) + { + contents.Add(new DataContent(image.ImageBytes.ToMemory(), contentType)); + } + else if (image.ImageUri is not null) + { + contents.Add(new UriContent(image.ImageUri, contentType)); + } + else + { + throw new InvalidOperationException("Generated image does not contain a valid URI or byte array."); + } + } + + UsageDetails? ud = null; + if (generatedImages.Usage is { } usage) + { + ud = new() + { + InputTokenCount = usage.InputTokenCount, + OutputTokenCount = usage.OutputTokenCount, + TotalTokenCount = usage.TotalTokenCount, + }; + + if (usage.InputTokenDetails is { } inputDetails) + { + ud.AdditionalCounts ??= []; + ud.AdditionalCounts.Add($"{nameof(usage.InputTokenDetails)}.{nameof(inputDetails.ImageTokenCount)}", inputDetails.ImageTokenCount); + ud.AdditionalCounts.Add($"{nameof(usage.InputTokenDetails)}.{nameof(inputDetails.TextTokenCount)}", inputDetails.TextTokenCount); + } + } + + return new ImageGenerationResponse(contents) + { + RawRepresentation = generatedImages, + Usage = ud, + }; + } + + /// Converts a to a . + private OpenAI.Images.ImageGenerationOptions ToOpenAIImageGenerationOptions(ImageGenerationOptions? options) + { + OpenAI.Images.ImageGenerationOptions result = options?.RawRepresentationFactory?.Invoke(this) as OpenAI.Images.ImageGenerationOptions ?? new(); + + if (result.OutputFileFormat is null) + { + if (options?.MediaType?.Equals("image/png", StringComparison.OrdinalIgnoreCase) == true) + { + result.OutputFileFormat = GeneratedImageFileFormat.Png; + } + else if (options?.MediaType?.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase) == true) + { + result.OutputFileFormat = GeneratedImageFileFormat.Jpeg; + } + else if (options?.MediaType?.Equals("image/webp", StringComparison.OrdinalIgnoreCase) == true) + { + result.OutputFileFormat = GeneratedImageFileFormat.Webp; + } + } + + result.ResponseFormat ??= options?.ResponseFormat switch + { + ImageGenerationResponseFormat.Uri => GeneratedImageFormat.Uri, + ImageGenerationResponseFormat.Data => GeneratedImageFormat.Bytes, + + // ImageGenerationResponseFormat.Hosted not supported by ImageGenerator, however other OpenAI API support file IDs. + _ => (GeneratedImageFormat?)null + }; + + result.Size ??= ToOpenAIImageSize(options?.ImageSize); + + return result; + } + + /// Converts a to a . + private ImageEditOptions ToOpenAIImageEditOptions(ImageGenerationOptions? options) + { + ImageEditOptions result = options?.RawRepresentationFactory?.Invoke(this) as ImageEditOptions ?? new(); + + result.ResponseFormat ??= options?.ResponseFormat switch + { + ImageGenerationResponseFormat.Uri => GeneratedImageFormat.Uri, + ImageGenerationResponseFormat.Data => GeneratedImageFormat.Bytes, + + // ImageGenerationResponseFormat.Hosted not supported by ImageGenerator, however other OpenAI API support file IDs. + _ => (GeneratedImageFormat?)null + }; + + result.Size ??= ToOpenAIImageSize(options?.ImageSize); + + return result; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIJsonContext.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIJsonContext.cs index 00b0089ddf7..33d17e2963e 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIJsonContext.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIJsonContext.cs @@ -14,6 +14,9 @@ namespace Microsoft.Extensions.AI; WriteIndented = true)] [JsonSerializable(typeof(OpenAIClientExtensions.ToolJson))] [JsonSerializable(typeof(IDictionary))] +[JsonSerializable(typeof(IReadOnlyDictionary))] [JsonSerializable(typeof(string[]))] +[JsonSerializable(typeof(IEnumerable))] [JsonSerializable(typeof(JsonElement))] +[JsonSerializable(typeof(List))] internal sealed partial class OpenAIJsonContext : JsonSerializerContext; diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeConversationClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeConversationClient.cs index 7c944ac5edb..dbbabea026f 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeConversationClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeConversationClient.cs @@ -8,7 +8,7 @@ namespace Microsoft.Extensions.AI; /// Provides helpers for interacting with OpenAI Realtime. internal sealed class OpenAIRealtimeConversationClient { - public static ConversationFunctionTool ToOpenAIConversationFunctionTool(AIFunction aiFunction, ChatOptions? options = null) + public static ConversationFunctionTool ToOpenAIConversationFunctionTool(AIFunctionDeclaration aiFunction, ChatOptions? options = null) { bool? strict = OpenAIClientExtensions.HasStrict(aiFunction.AdditionalProperties) ?? diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs deleted file mode 100644 index 6aee4bc77e4..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs +++ /dev/null @@ -1,670 +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; -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Shared.Diagnostics; -using OpenAI.Responses; - -#pragma warning disable S907 // "goto" statement should not be used -#pragma warning disable S1067 // Expressions should not be too complex -#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields -#pragma warning disable S3604 // Member initializer values should not be redundant -#pragma warning disable SA1204 // Static elements should appear before instance elements - -namespace Microsoft.Extensions.AI; - -/// Represents an for an . -internal sealed class OpenAIResponseChatClient : IChatClient -{ - /// Metadata about the client. - private readonly ChatClientMetadata _metadata; - - /// The underlying . - private readonly OpenAIResponseClient _responseClient; - - /// Initializes a new instance of the class for the specified . - /// The underlying client. - /// is . - public OpenAIResponseChatClient(OpenAIResponseClient responseClient) - { - _ = Throw.IfNull(responseClient); - - _responseClient = responseClient; - - // 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. - Uri providerUrl = typeof(OpenAIResponseClient).GetField("_endpoint", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) - ?.GetValue(responseClient) as Uri ?? OpenAIClientExtensions.DefaultOpenAIEndpoint; - string? model = typeof(OpenAIResponseClient).GetField("_model", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) - ?.GetValue(responseClient) as string; - - _metadata = new("openai", providerUrl, model); - } - - /// - object? IChatClient.GetService(Type serviceType, object? serviceKey) - { - _ = Throw.IfNull(serviceType); - - return - serviceKey is not null ? null : - serviceType == typeof(ChatClientMetadata) ? _metadata : - serviceType == typeof(OpenAIResponseClient) ? _responseClient : - serviceType.IsInstanceOfType(this) ? this : - null; - } - - /// - public async Task GetResponseAsync( - IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - { - _ = Throw.IfNull(messages); - - // Convert the inputs into what OpenAIResponseClient expects. - var openAIResponseItems = ToOpenAIResponseItems(messages); - var openAIOptions = ToOpenAIResponseCreationOptions(options); - - // Make the call to the OpenAIResponseClient. - var openAIResponse = (await _responseClient.CreateResponseAsync(openAIResponseItems, openAIOptions, cancellationToken).ConfigureAwait(false)).Value; - - // Convert and return the results. - ChatResponse response = new() - { - ConversationId = openAIOptions.StoredOutputEnabled is false ? null : openAIResponse.Id, - CreatedAt = openAIResponse.CreatedAt, - FinishReason = ToFinishReason(openAIResponse.IncompleteStatusDetails?.Reason), - Messages = [new(ChatRole.Assistant, [])], - ModelId = openAIResponse.Model, - RawRepresentation = openAIResponse, - ResponseId = openAIResponse.Id, - Usage = ToUsageDetails(openAIResponse), - }; - - if (!string.IsNullOrEmpty(openAIResponse.EndUserId)) - { - (response.AdditionalProperties ??= [])[nameof(openAIResponse.EndUserId)] = openAIResponse.EndUserId; - } - - if (openAIResponse.Error is not null) - { - (response.AdditionalProperties ??= [])[nameof(openAIResponse.Error)] = openAIResponse.Error; - } - - if (openAIResponse.OutputItems is not null) - { - ChatMessage message = response.Messages[0]; - Debug.Assert(message.Contents is List, "Expected a List for message contents."); - - foreach (ResponseItem outputItem in openAIResponse.OutputItems) - { - switch (outputItem) - { - case MessageResponseItem messageItem: - message.MessageId = messageItem.Id; - message.RawRepresentation = messageItem; - message.Role = ToChatRole(messageItem.Role); - (message.AdditionalProperties ??= []).Add(nameof(messageItem.Id), messageItem.Id); - ((List)message.Contents).AddRange(ToAIContents(messageItem.Content)); - break; - - case ReasoningResponseItem reasoningItem when reasoningItem.GetSummaryText() is string summary && !string.IsNullOrWhiteSpace(summary): - message.Contents.Add(new TextReasoningContent(summary) - { - RawRepresentation = reasoningItem - }); - break; - - case FunctionCallResponseItem functionCall: - response.FinishReason ??= ChatFinishReason.ToolCalls; - var fcc = FunctionCallContent.CreateFromParsedArguments( - functionCall.FunctionArguments.ToMemory(), - functionCall.CallId, - functionCall.FunctionName, - static json => JsonSerializer.Deserialize(json.Span, OpenAIJsonContext.Default.IDictionaryStringObject)!); - fcc.RawRepresentation = outputItem; - message.Contents.Add(fcc); - break; - - default: - message.Contents.Add(new() - { - RawRepresentation = outputItem, - }); - break; - } - } - - if (openAIResponse.Error is { } error) - { - message.Contents.Add(new ErrorContent(error.Message) { ErrorCode = error.Code.ToString() }); - } - } - - return response; - } - - /// - public async IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - _ = Throw.IfNull(messages); - - // Convert the inputs into what OpenAIResponseClient expects. - var openAIResponseItems = ToOpenAIResponseItems(messages); - var openAIOptions = ToOpenAIResponseCreationOptions(options); - - // Make the call to the OpenAIResponseClient and process the streaming results. - DateTimeOffset? createdAt = null; - string? responseId = null; - string? conversationId = null; - string? modelId = null; - string? lastMessageId = null; - ChatRole? lastRole = null; - Dictionary outputIndexToMessages = []; - Dictionary? functionCallInfos = null; - await foreach (var streamingUpdate in _responseClient.CreateResponseStreamingAsync(openAIResponseItems, openAIOptions, cancellationToken).ConfigureAwait(false)) - { - switch (streamingUpdate) - { - case StreamingResponseCreatedUpdate createdUpdate: - createdAt = createdUpdate.Response.CreatedAt; - responseId = createdUpdate.Response.Id; - conversationId = openAIOptions.StoredOutputEnabled is false ? null : responseId; - modelId = createdUpdate.Response.Model; - goto default; - - case StreamingResponseCompletedUpdate completedUpdate: - yield return new() - { - Contents = ToUsageDetails(completedUpdate.Response) is { } usage ? [new UsageContent(usage)] : [], - ConversationId = conversationId, - CreatedAt = createdAt, - FinishReason = - ToFinishReason(completedUpdate.Response?.IncompleteStatusDetails?.Reason) ?? - (functionCallInfos is not null ? ChatFinishReason.ToolCalls : ChatFinishReason.Stop), - MessageId = lastMessageId, - ModelId = modelId, - RawRepresentation = streamingUpdate, - ResponseId = responseId, - Role = lastRole, - }; - break; - - case StreamingResponseOutputItemAddedUpdate outputItemAddedUpdate: - switch (outputItemAddedUpdate.Item) - { - case MessageResponseItem mri: - outputIndexToMessages[outputItemAddedUpdate.OutputIndex] = mri; - break; - - case FunctionCallResponseItem fcri: - (functionCallInfos ??= [])[outputItemAddedUpdate.OutputIndex] = new(fcri); - break; - } - - goto default; - - case StreamingResponseOutputItemDoneUpdate outputItemDoneUpdate: - _ = outputIndexToMessages.Remove(outputItemDoneUpdate.OutputIndex); - goto default; - - case StreamingResponseOutputTextDeltaUpdate outputTextDeltaUpdate: - _ = outputIndexToMessages.TryGetValue(outputTextDeltaUpdate.OutputIndex, out MessageResponseItem? messageItem); - lastMessageId = messageItem?.Id; - lastRole = ToChatRole(messageItem?.Role); - yield return new ChatResponseUpdate(lastRole, outputTextDeltaUpdate.Delta) - { - ConversationId = conversationId, - CreatedAt = createdAt, - MessageId = lastMessageId, - ModelId = modelId, - RawRepresentation = streamingUpdate, - ResponseId = responseId, - }; - break; - - case StreamingResponseFunctionCallArgumentsDeltaUpdate functionCallArgumentsDeltaUpdate: - { - if (functionCallInfos?.TryGetValue(functionCallArgumentsDeltaUpdate.OutputIndex, out FunctionCallInfo? callInfo) is true) - { - _ = (callInfo.Arguments ??= new()).Append(functionCallArgumentsDeltaUpdate.Delta); - } - - goto default; - } - - case StreamingResponseFunctionCallArgumentsDoneUpdate functionCallOutputDoneUpdate: - { - if (functionCallInfos?.TryGetValue(functionCallOutputDoneUpdate.OutputIndex, out FunctionCallInfo? callInfo) is true) - { - _ = functionCallInfos.Remove(functionCallOutputDoneUpdate.OutputIndex); - - var fci = FunctionCallContent.CreateFromParsedArguments( - callInfo.Arguments?.ToString() ?? string.Empty, - callInfo.ResponseItem.CallId, - callInfo.ResponseItem.FunctionName, - static json => JsonSerializer.Deserialize(json, OpenAIJsonContext.Default.IDictionaryStringObject)!); - - lastMessageId = callInfo.ResponseItem.Id; - lastRole = ChatRole.Assistant; - yield return new ChatResponseUpdate(lastRole, [fci]) - { - ConversationId = conversationId, - CreatedAt = createdAt, - MessageId = lastMessageId, - ModelId = modelId, - RawRepresentation = streamingUpdate, - ResponseId = responseId, - }; - - break; - } - - goto default; - } - - case StreamingResponseErrorUpdate errorUpdate: - yield return new ChatResponseUpdate - { - Contents = - [ - new ErrorContent(errorUpdate.Message) - { - ErrorCode = errorUpdate.Code, - Details = errorUpdate.Param, - } - ], - ConversationId = conversationId, - CreatedAt = createdAt, - MessageId = lastMessageId, - ModelId = modelId, - RawRepresentation = streamingUpdate, - ResponseId = responseId, - Role = lastRole, - }; - break; - - case StreamingResponseRefusalDoneUpdate refusalDone: - yield return new ChatResponseUpdate - { - Contents = [new ErrorContent(refusalDone.Refusal) { ErrorCode = nameof(ResponseContentPart.Refusal) }], - ConversationId = conversationId, - CreatedAt = createdAt, - MessageId = lastMessageId, - ModelId = modelId, - RawRepresentation = streamingUpdate, - ResponseId = responseId, - Role = lastRole, - }; - break; - - default: - yield return new ChatResponseUpdate - { - ConversationId = conversationId, - CreatedAt = createdAt, - MessageId = lastMessageId, - ModelId = modelId, - RawRepresentation = streamingUpdate, - ResponseId = responseId, - Role = lastRole, - }; - break; - } - } - } - - /// - void IDisposable.Dispose() - { - // Nothing to dispose. Implementation required for the IChatClient interface. - } - - internal static ResponseTool ToResponseTool(AIFunction aiFunction, ChatOptions? options = null) - { - bool? strict = - OpenAIClientExtensions.HasStrict(aiFunction.AdditionalProperties) ?? - OpenAIClientExtensions.HasStrict(options?.AdditionalProperties); - - return ResponseTool.CreateFunctionTool( - aiFunction.Name, - aiFunction.Description, - OpenAIClientExtensions.ToOpenAIFunctionParameters(aiFunction, strict), - strict ?? false); - } - - /// Creates a from a . - private static ChatRole ToChatRole(MessageRole? role) => - role switch - { - MessageRole.System => ChatRole.System, - MessageRole.Developer => OpenAIClientExtensions.ChatRoleDeveloper, - MessageRole.User => ChatRole.User, - _ => ChatRole.Assistant, - }; - - /// Creates a from a . - private static ChatFinishReason? ToFinishReason(ResponseIncompleteStatusReason? statusReason) => - statusReason == ResponseIncompleteStatusReason.ContentFilter ? ChatFinishReason.ContentFilter : - statusReason == ResponseIncompleteStatusReason.MaxOutputTokens ? ChatFinishReason.Length : - null; - - /// Converts a to a . - private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? options) - { - if (options is null) - { - return new ResponseCreationOptions(); - } - - if (options.RawRepresentationFactory?.Invoke(this) is not ResponseCreationOptions result) - { - result = new ResponseCreationOptions(); - } - - // Handle strongly-typed properties. - result.MaxOutputTokenCount ??= options.MaxOutputTokens; - result.ParallelToolCallsEnabled ??= options.AllowMultipleToolCalls; - result.PreviousResponseId ??= options.ConversationId; - result.Temperature ??= options.Temperature; - result.TopP ??= options.TopP; - - if (options.Instructions is { } instructions) - { - result.Instructions = string.IsNullOrEmpty(result.Instructions) ? - instructions : - $"{result.Instructions}{Environment.NewLine}{instructions}"; - } - - // Populate tools if there are any. - if (options.Tools is { Count: > 0 } tools) - { - foreach (AITool tool in tools) - { - switch (tool) - { - case AIFunction aiFunction: - result.Tools.Add(ToResponseTool(aiFunction, options)); - break; - - case HostedWebSearchTool: - WebSearchUserLocation? location = null; - if (tool.AdditionalProperties.TryGetValue(nameof(WebSearchUserLocation), out object? objLocation)) - { - location = objLocation as WebSearchUserLocation; - } - - WebSearchContextSize? size = null; - if (tool.AdditionalProperties.TryGetValue(nameof(WebSearchContextSize), out object? objSize) && - objSize is WebSearchContextSize) - { - size = (WebSearchContextSize)objSize; - } - - result.Tools.Add(ResponseTool.CreateWebSearchTool(location, size)); - break; - } - } - - if (result.ToolChoice is null && result.Tools.Count > 0) - { - switch (options.ToolMode) - { - case NoneChatToolMode: - result.ToolChoice = ResponseToolChoice.CreateNoneChoice(); - break; - - case AutoChatToolMode: - case null: - result.ToolChoice = ResponseToolChoice.CreateAutoChoice(); - break; - - case RequiredChatToolMode required: - result.ToolChoice = required.RequiredFunctionName is not null ? - ResponseToolChoice.CreateFunctionChoice(required.RequiredFunctionName) : - ResponseToolChoice.CreateRequiredChoice(); - break; - } - } - } - - if (result.TextOptions is null) - { - if (options.ResponseFormat is ChatResponseFormatText) - { - result.TextOptions = new() - { - TextFormat = ResponseTextFormat.CreateTextFormat() - }; - } - else if (options.ResponseFormat is ChatResponseFormatJson jsonFormat) - { - result.TextOptions = new() - { - TextFormat = OpenAIClientExtensions.StrictSchemaTransformCache.GetOrCreateTransformedSchema(jsonFormat) is { } jsonSchema ? - ResponseTextFormat.CreateJsonSchemaFormat( - jsonFormat.SchemaName ?? "json_schema", - BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, OpenAIJsonContext.Default.JsonElement)), - jsonFormat.SchemaDescription, - OpenAIClientExtensions.HasStrict(options.AdditionalProperties)) : - ResponseTextFormat.CreateJsonObjectFormat(), - }; - } - } - - return result; - } - - /// Convert a sequence of s to s. - private static IEnumerable ToOpenAIResponseItems( - IEnumerable inputs) - { - foreach (ChatMessage input in inputs) - { - if (input.Role == ChatRole.System || - input.Role == OpenAIClientExtensions.ChatRoleDeveloper) - { - string text = input.Text; - if (!string.IsNullOrWhiteSpace(text)) - { - yield return input.Role == ChatRole.System ? - ResponseItem.CreateSystemMessageItem(text) : - ResponseItem.CreateDeveloperMessageItem(text); - } - - continue; - } - - if (input.Role == ChatRole.User) - { - yield return ResponseItem.CreateUserMessageItem(ToOpenAIResponsesContent(input.Contents)); - continue; - } - - if (input.Role == ChatRole.Tool) - { - foreach (AIContent item in input.Contents) - { - switch (item) - { - case FunctionResultContent resultContent: - string? result = resultContent.Result as string; - if (result is null && resultContent.Result is not null) - { - try - { - result = JsonSerializer.Serialize(resultContent.Result, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))); - } - catch (NotSupportedException) - { - // If the type can't be serialized, skip it. - } - } - - yield return ResponseItem.CreateFunctionCallOutputItem(resultContent.CallId, result ?? string.Empty); - break; - } - } - - continue; - } - - if (input.Role == ChatRole.Assistant) - { - foreach (AIContent item in input.Contents) - { - switch (item) - { - case TextContent textContent: - yield return ResponseItem.CreateAssistantMessageItem(textContent.Text); - break; - - case TextReasoningContent reasoningContent: - yield return ResponseItem.CreateReasoningItem(reasoningContent.Text); - break; - - case FunctionCallContent callContent: - yield return ResponseItem.CreateFunctionCallItem( - callContent.CallId, - callContent.Name, - BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes( - callContent.Arguments, - AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IDictionary))))); - break; - - case AIContent when item.RawRepresentation is ResponseItem rawRep: - yield return rawRep; - break; - } - } - - continue; - } - } - } - - /// Extract usage details from an . - private static UsageDetails? ToUsageDetails(OpenAIResponse? openAIResponse) - { - UsageDetails? ud = null; - if (openAIResponse?.Usage is { } usage) - { - ud = new() - { - InputTokenCount = usage.InputTokenCount, - OutputTokenCount = usage.OutputTokenCount, - TotalTokenCount = usage.TotalTokenCount, - }; - - if (usage.InputTokenDetails is { } inputDetails) - { - ud.AdditionalCounts ??= []; - ud.AdditionalCounts.Add($"{nameof(usage.InputTokenDetails)}.{nameof(inputDetails.CachedTokenCount)}", inputDetails.CachedTokenCount); - } - - if (usage.OutputTokenDetails is { } outputDetails) - { - ud.AdditionalCounts ??= []; - ud.AdditionalCounts.Add($"{nameof(usage.OutputTokenDetails)}.{nameof(outputDetails.ReasoningTokenCount)}", outputDetails.ReasoningTokenCount); - } - } - - return ud; - } - - /// Convert a sequence of s to a list of . - private static List ToAIContents(IEnumerable contents) - { - List results = []; - - foreach (ResponseContentPart part in contents) - { - switch (part.Kind) - { - case ResponseContentPartKind.OutputText: - results.Add(new TextContent(part.Text) - { - RawRepresentation = part, - }); - break; - - case ResponseContentPartKind.Refusal: - results.Add(new ErrorContent(part.Refusal) - { - ErrorCode = nameof(ResponseContentPartKind.Refusal), - RawRepresentation = part, - }); - break; - - default: - results.Add(new() - { - RawRepresentation = part, - }); - break; - } - } - - return results; - } - - /// Convert a list of s to a list of . - private static List ToOpenAIResponsesContent(IList contents) - { - List parts = []; - foreach (var content in contents) - { - switch (content) - { - case TextContent textContent: - parts.Add(ResponseContentPart.CreateInputTextPart(textContent.Text)); - break; - - case UriContent uriContent when uriContent.HasTopLevelMediaType("image"): - parts.Add(ResponseContentPart.CreateInputImagePart(uriContent.Uri)); - break; - - case DataContent dataContent when dataContent.HasTopLevelMediaType("image"): - parts.Add(ResponseContentPart.CreateInputImagePart(BinaryData.FromBytes(dataContent.Data), dataContent.MediaType)); - break; - - case DataContent dataContent when dataContent.MediaType.StartsWith("application/pdf", StringComparison.OrdinalIgnoreCase): - parts.Add(ResponseContentPart.CreateInputFilePart(BinaryData.FromBytes(dataContent.Data), dataContent.MediaType, $"{Guid.NewGuid():N}.pdf")); - break; - - case ErrorContent errorContent when errorContent.ErrorCode == nameof(ResponseContentPartKind.Refusal): - parts.Add(ResponseContentPart.CreateRefusalPart(errorContent.Message)); - break; - - case AIContent when content.RawRepresentation is ResponseContentPart rawRep: - parts.Add(rawRep); - break; - } - } - - if (parts.Count == 0) - { - parts.Add(ResponseContentPart.CreateInputTextPart(string.Empty)); - } - - return parts; - } - - /// POCO representing function calling info. - /// Used to concatenation information for a single function call from across multiple streaming updates. - private sealed class FunctionCallInfo(FunctionCallResponseItem item) - { - public readonly FunctionCallResponseItem ResponseItem = item; - public StringBuilder? Arguments; - } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs new file mode 100644 index 00000000000..6913e999936 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -0,0 +1,1429 @@ +// Licensed 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.Diagnostics.CodeAnalysis; +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.Diagnostics; +using OpenAI.Responses; + +#pragma warning disable S1226 // Method parameters, caught exceptions and foreach variables' initial values should not be ignored +#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields +#pragma warning disable S3254 // Default parameter values should not be passed as arguments +#pragma warning disable SA1204 // Static elements should appear before instance elements + +namespace Microsoft.Extensions.AI; + +/// Represents an for an . +internal sealed class OpenAIResponsesChatClient : IChatClient +{ + // Fix this to not use reflection once https://github.com/openai/openai-dotnet/issues/643 is addressed. + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] + private static readonly Type? _internalResponseReasoningSummaryTextDeltaEventType = Type.GetType("OpenAI.Responses.InternalResponseReasoningSummaryTextDeltaEvent, OpenAI"); + private static readonly PropertyInfo? _summaryTextDeltaProperty = _internalResponseReasoningSummaryTextDeltaEventType?.GetProperty("Delta"); + + // These delegate instances are used to call the internal overloads of CreateResponseAsync and CreateResponseStreamingAsync that accept + // a RequestOptions. These should be replaced once a better way to pass RequestOptions is available. + private static readonly Func, ResponseCreationOptions, RequestOptions, Task>>? + _createResponseAsync = + (Func, ResponseCreationOptions, RequestOptions, Task>>?) + typeof(OpenAIResponseClient).GetMethod( + nameof(OpenAIResponseClient.CreateResponseAsync), BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, + null, [typeof(IEnumerable), typeof(ResponseCreationOptions), typeof(RequestOptions)], null) + ?.CreateDelegate(typeof(Func, ResponseCreationOptions, RequestOptions, Task>>)); + + private static readonly Func, ResponseCreationOptions, RequestOptions, AsyncCollectionResult>? + _createResponseStreamingAsync = + (Func, ResponseCreationOptions, RequestOptions, AsyncCollectionResult>?) + typeof(OpenAIResponseClient).GetMethod( + nameof(OpenAIResponseClient.CreateResponseStreamingAsync), BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, + null, [typeof(IEnumerable), typeof(ResponseCreationOptions), typeof(RequestOptions)], null) + ?.CreateDelegate(typeof(Func, ResponseCreationOptions, RequestOptions, AsyncCollectionResult>)); + + private static readonly Func>>? + _getResponseAsync = + (Func>>?) + typeof(OpenAIResponseClient).GetMethod( + nameof(OpenAIResponseClient.GetResponseAsync), BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, + null, [typeof(string), typeof(RequestOptions)], null) + ?.CreateDelegate(typeof(Func>>)); + + private static readonly Func>? + _getResponseStreamingAsync = + (Func>?) + typeof(OpenAIResponseClient).GetMethod( + nameof(OpenAIResponseClient.GetResponseStreamingAsync), BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, + null, [typeof(string), typeof(RequestOptions), typeof(int?)], null) + ?.CreateDelegate(typeof(Func>)); + + /// Metadata about the client. + private readonly ChatClientMetadata _metadata; + + /// The underlying . + private readonly OpenAIResponseClient _responseClient; + + /// Initializes a new instance of the class for the specified . + /// The underlying client. + /// is . + public OpenAIResponsesChatClient(OpenAIResponseClient responseClient) + { + _ = Throw.IfNull(responseClient); + + _responseClient = responseClient; + + _metadata = new("openai", responseClient.Endpoint, responseClient.Model); + } + + /// + object? IChatClient.GetService(Type serviceType, object? serviceKey) + { + _ = Throw.IfNull(serviceType); + + return + serviceKey is not null ? null : + serviceType == typeof(ChatClientMetadata) ? _metadata : + serviceType == typeof(OpenAIResponseClient) ? _responseClient : + serviceType.IsInstanceOfType(this) ? this : + null; + } + + /// + public async Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(messages); + + // Convert the inputs into what OpenAIResponseClient expects. + var openAIOptions = ToOpenAIResponseCreationOptions(options, out string? openAIConversationId); + + // Provided continuation token signals that an existing background response should be fetched. + if (GetContinuationToken(messages, options) is { } token) + { + var getTask = _getResponseAsync is not null ? + _getResponseAsync(_responseClient, token.ResponseId, cancellationToken.ToRequestOptions(streaming: false)) : + _responseClient.GetResponseAsync(token.ResponseId, cancellationToken); + var response = (await getTask.ConfigureAwait(false)).Value; + + return FromOpenAIResponse(response, openAIOptions, openAIConversationId); + } + + var openAIResponseItems = ToOpenAIResponseItems(messages, options); + + // Make the call to the OpenAIResponseClient. + var createTask = _createResponseAsync is not null ? + _createResponseAsync(_responseClient, openAIResponseItems, openAIOptions, cancellationToken.ToRequestOptions(streaming: false)) : + _responseClient.CreateResponseAsync(openAIResponseItems, openAIOptions, cancellationToken); + var openAIResponse = (await createTask.ConfigureAwait(false)).Value; + + // Convert the response to a ChatResponse. + return FromOpenAIResponse(openAIResponse, openAIOptions, openAIConversationId); + } + + internal static ChatResponse FromOpenAIResponse(OpenAIResponse openAIResponse, ResponseCreationOptions? openAIOptions, string? conversationId) + { + // Convert and return the results. + ChatResponse response = new() + { + ConversationId = openAIOptions?.StoredOutputEnabled is false ? null : (conversationId ?? openAIResponse.Id), + CreatedAt = openAIResponse.CreatedAt, + ContinuationToken = CreateContinuationToken(openAIResponse), + FinishReason = ToFinishReason(openAIResponse.IncompleteStatusDetails?.Reason), + ModelId = openAIResponse.Model, + RawRepresentation = openAIResponse, + ResponseId = openAIResponse.Id, + Usage = ToUsageDetails(openAIResponse), + }; + + if (!string.IsNullOrEmpty(openAIResponse.EndUserId)) + { + (response.AdditionalProperties ??= [])[nameof(openAIResponse.EndUserId)] = openAIResponse.EndUserId; + } + + if (openAIResponse.Error is not null) + { + (response.AdditionalProperties ??= [])[nameof(openAIResponse.Error)] = openAIResponse.Error; + } + + if (openAIResponse.OutputItems is not null) + { + response.Messages = [.. ToChatMessages(openAIResponse.OutputItems, openAIOptions)]; + + if (response.Messages.LastOrDefault() is { } lastMessage && openAIResponse.Error is { } error) + { + lastMessage.Contents.Add(new ErrorContent(error.Message) { ErrorCode = error.Code.ToString() }); + } + + foreach (var message in response.Messages) + { + message.CreatedAt ??= openAIResponse.CreatedAt; + } + } + + return response; + } + + internal static IEnumerable ToChatMessages(IEnumerable items, ResponseCreationOptions? options = null) + { + ChatMessage? message = null; + + foreach (ResponseItem outputItem in items) + { + message ??= new(ChatRole.Assistant, (string?)null); + + switch (outputItem) + { + case MessageResponseItem messageItem: + if (message.MessageId is not null && message.MessageId != messageItem.Id) + { + yield return message; + message = new ChatMessage(); + } + + message.MessageId = messageItem.Id; + message.RawRepresentation = messageItem; + message.Role = ToChatRole(messageItem.Role); + ((List)message.Contents).AddRange(ToAIContents(messageItem.Content)); + break; + + case ReasoningResponseItem reasoningItem: + message.Contents.Add(new TextReasoningContent(reasoningItem.GetSummaryText()) + { + ProtectedData = reasoningItem.EncryptedContent, + RawRepresentation = outputItem, + }); + break; + + case FunctionCallResponseItem functionCall: + var fcc = OpenAIClientExtensions.ParseCallContent(functionCall.FunctionArguments, functionCall.CallId, functionCall.FunctionName); + fcc.RawRepresentation = outputItem; + message.Contents.Add(fcc); + break; + + case FunctionCallOutputResponseItem functionCallOutputItem: + message.Contents.Add(new FunctionResultContent(functionCallOutputItem.CallId, functionCallOutputItem.FunctionOutput) { RawRepresentation = functionCallOutputItem }); + break; + + case McpToolCallItem mtci: + AddMcpToolCallContent(mtci, message.Contents); + break; + + case McpToolCallApprovalRequestItem mtcari: + message.Contents.Add(new McpServerToolApprovalRequestContent(mtcari.Id, new(mtcari.Id, mtcari.ToolName, mtcari.ServerLabel) + { + Arguments = JsonSerializer.Deserialize(mtcari.ToolArguments.ToMemory().Span, OpenAIJsonContext.Default.IReadOnlyDictionaryStringObject)!, + RawRepresentation = mtcari, + }) + { + RawRepresentation = mtcari, + }); + break; + + case McpToolCallApprovalResponseItem mtcari: + message.Contents.Add(new McpServerToolApprovalResponseContent(mtcari.ApprovalRequestId, mtcari.Approved) { RawRepresentation = mtcari }); + break; + + case CodeInterpreterCallResponseItem cicri: + AddCodeInterpreterContents(cicri, message.Contents); + break; + + case ImageGenerationCallResponseItem imageGenItem: + AddImageGenerationContents(imageGenItem, options, message.Contents); + break; + + default: + message.Contents.Add(new() { RawRepresentation = outputItem }); + break; + } + } + + if (message is not null) + { + yield return message; + } + } + + /// + public IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(messages); + + var openAIOptions = ToOpenAIResponseCreationOptions(options, out string? openAIConversationId); + + // Provided continuation token signals that an existing background response should be fetched. + if (GetContinuationToken(messages, options) is { } token) + { + IAsyncEnumerable getUpdates = _getResponseStreamingAsync is not null ? + _getResponseStreamingAsync(_responseClient, token.ResponseId, cancellationToken.ToRequestOptions(streaming: true), token.SequenceNumber) : + _responseClient.GetResponseStreamingAsync(token.ResponseId, token.SequenceNumber, cancellationToken); + + return FromOpenAIStreamingResponseUpdatesAsync(getUpdates, openAIOptions, openAIConversationId, token.ResponseId, cancellationToken); + } + + var openAIResponseItems = ToOpenAIResponseItems(messages, options); + + var createUpdates = _createResponseStreamingAsync is not null ? + _createResponseStreamingAsync(_responseClient, openAIResponseItems, openAIOptions, cancellationToken.ToRequestOptions(streaming: true)) : + _responseClient.CreateResponseStreamingAsync(openAIResponseItems, openAIOptions, cancellationToken); + + return FromOpenAIStreamingResponseUpdatesAsync(createUpdates, openAIOptions, openAIConversationId, cancellationToken: cancellationToken); + } + + internal static async IAsyncEnumerable FromOpenAIStreamingResponseUpdatesAsync( + IAsyncEnumerable streamingResponseUpdates, + ResponseCreationOptions? options, + string? conversationId, + string? resumeResponseId = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + DateTimeOffset? createdAt = null; + string? responseId = resumeResponseId; + string? modelId = null; + string? lastMessageId = null; + ChatRole? lastRole = null; + bool anyFunctions = false; + ResponseStatus? latestResponseStatus = null; + + UpdateConversationId(resumeResponseId); + + await foreach (var streamingUpdate in streamingResponseUpdates.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + // Create an update populated with the current state of the response. + ChatResponseUpdate CreateUpdate(AIContent? content = null) => + new(lastRole, content is not null ? [content] : null) + { + ConversationId = conversationId, + CreatedAt = createdAt, + MessageId = lastMessageId, + ModelId = modelId, + RawRepresentation = streamingUpdate, + ResponseId = responseId, + ContinuationToken = CreateContinuationToken( + responseId!, + latestResponseStatus, + options?.BackgroundModeEnabled, + streamingUpdate.SequenceNumber) + }; + + switch (streamingUpdate) + { + case StreamingResponseCreatedUpdate createdUpdate: + createdAt = createdUpdate.Response.CreatedAt; + responseId = createdUpdate.Response.Id; + UpdateConversationId(responseId); + modelId = createdUpdate.Response.Model; + latestResponseStatus = createdUpdate.Response.Status; + goto default; + + case StreamingResponseQueuedUpdate queuedUpdate: + createdAt = queuedUpdate.Response.CreatedAt; + responseId = queuedUpdate.Response.Id; + UpdateConversationId(responseId); + modelId = queuedUpdate.Response.Model; + latestResponseStatus = queuedUpdate.Response.Status; + goto default; + + case StreamingResponseInProgressUpdate inProgressUpdate: + createdAt = inProgressUpdate.Response.CreatedAt; + responseId = inProgressUpdate.Response.Id; + UpdateConversationId(responseId); + modelId = inProgressUpdate.Response.Model; + latestResponseStatus = inProgressUpdate.Response.Status; + goto default; + + case StreamingResponseIncompleteUpdate incompleteUpdate: + createdAt = incompleteUpdate.Response.CreatedAt; + responseId = incompleteUpdate.Response.Id; + UpdateConversationId(responseId); + modelId = incompleteUpdate.Response.Model; + latestResponseStatus = incompleteUpdate.Response.Status; + goto default; + + case StreamingResponseFailedUpdate failedUpdate: + createdAt = failedUpdate.Response.CreatedAt; + responseId = failedUpdate.Response.Id; + UpdateConversationId(responseId); + modelId = failedUpdate.Response.Model; + latestResponseStatus = failedUpdate.Response.Status; + goto default; + + case StreamingResponseCompletedUpdate completedUpdate: + { + createdAt = completedUpdate.Response.CreatedAt; + responseId = completedUpdate.Response.Id; + UpdateConversationId(responseId); + modelId = completedUpdate.Response.Model; + latestResponseStatus = completedUpdate.Response?.Status; + var update = CreateUpdate(ToUsageDetails(completedUpdate.Response) is { } usage ? new UsageContent(usage) : null); + update.FinishReason = + ToFinishReason(completedUpdate.Response?.IncompleteStatusDetails?.Reason) ?? + (anyFunctions ? ChatFinishReason.ToolCalls : + ChatFinishReason.Stop); + yield return update; + break; + } + + case StreamingResponseOutputItemAddedUpdate outputItemAddedUpdate: + switch (outputItemAddedUpdate.Item) + { + case MessageResponseItem mri: + lastMessageId = outputItemAddedUpdate.Item.Id; + lastRole = ToChatRole(mri.Role); + break; + + case FunctionCallResponseItem fcri: + anyFunctions = true; + lastRole = ChatRole.Assistant; + break; + } + + goto default; + + case StreamingResponseOutputTextDeltaUpdate outputTextDeltaUpdate: + yield return CreateUpdate(new TextContent(outputTextDeltaUpdate.Delta)); + break; + + case StreamingResponseOutputItemDoneUpdate outputItemDoneUpdate when outputItemDoneUpdate.Item is FunctionCallResponseItem fcri: + yield return CreateUpdate(OpenAIClientExtensions.ParseCallContent(fcri.FunctionArguments.ToString(), fcri.CallId, fcri.FunctionName)); + break; + + case StreamingResponseOutputItemDoneUpdate outputItemDoneUpdate when outputItemDoneUpdate.Item is McpToolCallItem mtci: + var mcpUpdate = CreateUpdate(); + AddMcpToolCallContent(mtci, mcpUpdate.Contents); + yield return mcpUpdate; + break; + + case StreamingResponseOutputItemDoneUpdate outputItemDoneUpdate when outputItemDoneUpdate.Item is McpToolDefinitionListItem mtdli: + yield return CreateUpdate(new AIContent { RawRepresentation = mtdli }); + break; + + case StreamingResponseOutputItemDoneUpdate outputItemDoneUpdate when outputItemDoneUpdate.Item is McpToolCallApprovalRequestItem mtcari: + yield return CreateUpdate(new McpServerToolApprovalRequestContent(mtcari.Id, new(mtcari.Id, mtcari.ToolName, mtcari.ServerLabel) + { + Arguments = JsonSerializer.Deserialize(mtcari.ToolArguments.ToMemory().Span, OpenAIJsonContext.Default.IReadOnlyDictionaryStringObject)!, + RawRepresentation = mtcari, + }) + { + RawRepresentation = mtcari, + }); + break; + + case StreamingResponseOutputItemDoneUpdate outputItemDoneUpdate when outputItemDoneUpdate.Item is CodeInterpreterCallResponseItem cicri: + var codeUpdate = CreateUpdate(); + AddCodeInterpreterContents(cicri, codeUpdate.Contents); + yield return codeUpdate; + break; + + case StreamingResponseOutputItemDoneUpdate outputItemDoneUpdate when + outputItemDoneUpdate.Item is MessageResponseItem mri && + mri.Content is { Count: > 0 } content && + content.Any(c => c.OutputTextAnnotations is { Count: > 0 }): + AIContent annotatedContent = new(); + foreach (var c in content) + { + PopulateAnnotations(c, annotatedContent); + } + + yield return CreateUpdate(annotatedContent); + break; + + case StreamingResponseErrorUpdate errorUpdate: + yield return CreateUpdate(new ErrorContent(errorUpdate.Message) + { + ErrorCode = errorUpdate.Code, + Details = errorUpdate.Param, + }); + break; + + case StreamingResponseRefusalDoneUpdate refusalDone: + yield return CreateUpdate(new ErrorContent(refusalDone.Refusal) + { + ErrorCode = nameof(ResponseContentPart.Refusal), + }); + break; + + // Replace with public StreamingResponseReasoningSummaryTextDelta when available + case StreamingResponseUpdate when + streamingUpdate.GetType() == _internalResponseReasoningSummaryTextDeltaEventType && + _summaryTextDeltaProperty?.GetValue(streamingUpdate) is string delta: + yield return CreateUpdate(new TextReasoningContent(delta)); + break; + + case StreamingResponseImageGenerationCallInProgressUpdate imageGenInProgress: + yield return CreateUpdate(new ImageGenerationToolCallContent + { + ImageId = imageGenInProgress.ItemId, + RawRepresentation = imageGenInProgress, + + }); + goto default; + + case StreamingResponseImageGenerationCallPartialImageUpdate streamingImageGenUpdate: + yield return CreateUpdate(GetImageGenerationResult(streamingImageGenUpdate, options)); + break; + + default: + yield return CreateUpdate(); + break; + } + } + + void UpdateConversationId(string? id) + { + if (options?.StoredOutputEnabled is false) + { + conversationId = null; + } + else + { + conversationId ??= id; + } + } + } + + /// + void IDisposable.Dispose() + { + // Nothing to dispose. Implementation required for the IChatClient interface. + } + + internal static ResponseTool? ToResponseTool(AITool tool, ChatOptions? options = null) + { + switch (tool) + { + case ResponseToolAITool rtat: + return rtat.Tool; + + case AIFunctionDeclaration aiFunction: + return ToResponseTool(aiFunction, options); + + case HostedWebSearchTool webSearchTool: + WebSearchToolLocation? location = null; + if (webSearchTool.AdditionalProperties.TryGetValue(nameof(WebSearchToolLocation), out object? objLocation)) + { + location = objLocation as WebSearchToolLocation; + } + + WebSearchToolContextSize? size = null; + if (webSearchTool.AdditionalProperties.TryGetValue(nameof(WebSearchToolContextSize), out object? objSize) && + objSize is WebSearchToolContextSize) + { + size = (WebSearchToolContextSize)objSize; + } + + return ResponseTool.CreateWebSearchTool(location, size); + + case HostedFileSearchTool fileSearchTool: + return ResponseTool.CreateFileSearchTool( + fileSearchTool.Inputs?.OfType().Select(c => c.VectorStoreId) ?? [], + fileSearchTool.MaximumResultCount); + + case HostedImageGenerationTool imageGenerationTool: + return ToImageResponseTool(imageGenerationTool); + + case HostedCodeInterpreterTool codeTool: + return ResponseTool.CreateCodeInterpreterTool( + new CodeInterpreterToolContainer(codeTool.Inputs?.OfType().Select(f => f.FileId).ToList() is { Count: > 0 } ids ? + CodeInterpreterToolContainerConfiguration.CreateAutomaticContainerConfiguration(ids) : + new())); + + case HostedMcpServerTool mcpTool: + McpTool responsesMcpTool = Uri.TryCreate(mcpTool.ServerAddress, UriKind.Absolute, out Uri? url) ? + ResponseTool.CreateMcpTool( + mcpTool.ServerName, + url, + mcpTool.AuthorizationToken, + mcpTool.ServerDescription) : + ResponseTool.CreateMcpTool( + mcpTool.ServerName, + new McpToolConnectorId(mcpTool.ServerAddress), + mcpTool.AuthorizationToken, + mcpTool.ServerDescription); + + if (mcpTool.AllowedTools is not null) + { + responsesMcpTool.AllowedTools = new(); + AddAllMcpFilters(mcpTool.AllowedTools, responsesMcpTool.AllowedTools); + } + + switch (mcpTool.ApprovalMode) + { + case HostedMcpServerToolAlwaysRequireApprovalMode: + responsesMcpTool.ToolCallApprovalPolicy = new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.AlwaysRequireApproval); + break; + + case HostedMcpServerToolNeverRequireApprovalMode: + responsesMcpTool.ToolCallApprovalPolicy = new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval); + break; + + case HostedMcpServerToolRequireSpecificApprovalMode specificMode: + responsesMcpTool.ToolCallApprovalPolicy = new McpToolCallApprovalPolicy(new CustomMcpToolCallApprovalPolicy()); + + if (specificMode.AlwaysRequireApprovalToolNames is { Count: > 0 } alwaysRequireToolNames) + { + responsesMcpTool.ToolCallApprovalPolicy.CustomPolicy.ToolsAlwaysRequiringApproval = new(); + AddAllMcpFilters(alwaysRequireToolNames, responsesMcpTool.ToolCallApprovalPolicy.CustomPolicy.ToolsAlwaysRequiringApproval); + } + + if (specificMode.NeverRequireApprovalToolNames is { Count: > 0 } neverRequireToolNames) + { + responsesMcpTool.ToolCallApprovalPolicy.CustomPolicy.ToolsNeverRequiringApproval = new(); + AddAllMcpFilters(neverRequireToolNames, responsesMcpTool.ToolCallApprovalPolicy.CustomPolicy.ToolsNeverRequiringApproval); + } + + break; + } + + return responsesMcpTool; + + default: + return null; + } + } + + internal static FunctionTool ToResponseTool(AIFunctionDeclaration aiFunction, ChatOptions? options = null) + { + bool? strict = + OpenAIClientExtensions.HasStrict(aiFunction.AdditionalProperties) ?? + OpenAIClientExtensions.HasStrict(options?.AdditionalProperties); + + return ResponseTool.CreateFunctionTool( + aiFunction.Name, + OpenAIClientExtensions.ToOpenAIFunctionParameters(aiFunction, strict), + strict, + aiFunction.Description); + } + + internal static ImageGenerationTool ToImageResponseTool(HostedImageGenerationTool imageGenerationTool) + { + ImageGenerationTool result = new(); + ImageGenerationOptions? imageGenerationOptions = imageGenerationTool.Options; + + // Model: Image generation model + result.Model = imageGenerationOptions?.ModelId; + + // Size: Image dimensions (e.g., 1024x1024, 1024x1536) + if (imageGenerationOptions?.ImageSize is not null) + { + result.Size = new ImageGenerationToolSize( + imageGenerationOptions.ImageSize.Value.Width, + imageGenerationOptions.ImageSize.Value.Height); + } + + // OutputFileFormat: File output format + if (imageGenerationOptions?.MediaType is not null) + { + result.OutputFileFormat = imageGenerationOptions.MediaType switch + { + "image/png" => ImageGenerationToolOutputFileFormat.Png, + "image/jpeg" => ImageGenerationToolOutputFileFormat.Jpeg, + "image/webp" => ImageGenerationToolOutputFileFormat.Webp, + _ => null, + }; + } + + // PartialImageCount: Whether to return partial images during generation + result.PartialImageCount ??= imageGenerationOptions?.StreamingCount; + + return result; + } + + /// Creates a from a . + private static ChatRole ToChatRole(MessageRole? role) => + role switch + { + MessageRole.System => ChatRole.System, + MessageRole.Developer => OpenAIClientExtensions.ChatRoleDeveloper, + MessageRole.User => ChatRole.User, + _ => ChatRole.Assistant, + }; + + /// Creates a from a . + private static ChatFinishReason? ToFinishReason(ResponseIncompleteStatusReason? statusReason) => + statusReason == ResponseIncompleteStatusReason.ContentFilter ? ChatFinishReason.ContentFilter : + statusReason == ResponseIncompleteStatusReason.MaxOutputTokens ? ChatFinishReason.Length : + null; + + /// Converts a to a . + private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? options, out string? openAIConversationId) + { + openAIConversationId = null; + + if (options is null) + { + return new(); + } + + bool hasRawRco = false; + if (options.RawRepresentationFactory?.Invoke(this) is ResponseCreationOptions result) + { + hasRawRco = true; + } + else + { + result = new(); + } + + result.MaxOutputTokenCount ??= options.MaxOutputTokens; + result.Temperature ??= options.Temperature; + result.TopP ??= options.TopP; + result.BackgroundModeEnabled ??= options.AllowBackgroundResponses; + OpenAIClientExtensions.PatchModelIfNotSet(ref result.Patch, options.ModelId); + + // If the ResponseCreationOptions.PreviousResponseId is already set (likely rare), then we don't need to do + // anything with regards to Conversation, because they're mutually exclusive and we would want to ignore + // ChatOptions.ConversationId regardless of its value. If it's null, we want to examine the ResponseCreationOptions + // instance to see if a conversation ID has already been set on it and use that conversation ID subsequently if + // it has. If one hasn't been set, but ChatOptions.ConversationId has been set, we'll either set + // ResponseCreationOptions.Conversation if the string represents a conversation ID or else PreviousResponseId. + if (result.PreviousResponseId is null) + { + // Technically, OpenAI's IDs are opaque. However, by convention conversation IDs start with "conv_" and + // we can use that to disambiguate whether we're looking at a conversation ID or a response ID. + string? chatOptionsConversationId = options.ConversationId; + bool chatOptionsHasOpenAIConversationId = chatOptionsConversationId?.StartsWith("conv_", StringComparison.OrdinalIgnoreCase) is true; + + if (hasRawRco || chatOptionsHasOpenAIConversationId) + { + _ = result.Patch.TryGetValue("$.conversation"u8, out openAIConversationId); + if (openAIConversationId is null && chatOptionsHasOpenAIConversationId) + { + result.Patch.Set("$.conversation"u8, chatOptionsConversationId!); + openAIConversationId = chatOptionsConversationId; + } + } + + // If we still don't have a conversation ID, and ChatOptions.ConversationId is set, treat it as a response ID. + if (openAIConversationId is null && options.ConversationId is { } previousResponseId) + { + result.PreviousResponseId = previousResponseId; + } + } + + if (options.Instructions is { } instructions) + { + result.Instructions = string.IsNullOrEmpty(result.Instructions) ? + instructions : + $"{result.Instructions}{Environment.NewLine}{instructions}"; + } + + // Populate tools if there are any. + if (options.Tools is { Count: > 0 } tools) + { + foreach (AITool tool in tools) + { + if (ToResponseTool(tool, options) is { } responseTool) + { + result.Tools.Add(responseTool); + } + } + + if (result.Tools.Count > 0) + { + result.ParallelToolCallsEnabled ??= options.AllowMultipleToolCalls; + } + + if (result.ToolChoice is null && result.Tools.Count > 0) + { + switch (options.ToolMode) + { + case NoneChatToolMode: + result.ToolChoice = ResponseToolChoice.CreateNoneChoice(); + break; + + case AutoChatToolMode: + case null: + result.ToolChoice = ResponseToolChoice.CreateAutoChoice(); + break; + + case RequiredChatToolMode required: + result.ToolChoice = required.RequiredFunctionName is not null ? + ResponseToolChoice.CreateFunctionChoice(required.RequiredFunctionName) : + ResponseToolChoice.CreateRequiredChoice(); + break; + } + } + } + + if (result.TextOptions?.TextFormat is null && + ToOpenAIResponseTextFormat(options.ResponseFormat, options) is { } newFormat) + { + (result.TextOptions ??= new()).TextFormat = newFormat; + } + + return result; + } + + internal static ResponseTextFormat? ToOpenAIResponseTextFormat(ChatResponseFormat? format, ChatOptions? options = null) => + format switch + { + ChatResponseFormatText => ResponseTextFormat.CreateTextFormat(), + + ChatResponseFormatJson jsonFormat when OpenAIClientExtensions.StrictSchemaTransformCache.GetOrCreateTransformedSchema(jsonFormat) is { } jsonSchema => + ResponseTextFormat.CreateJsonSchemaFormat( + jsonFormat.SchemaName ?? "json_schema", + BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, OpenAIJsonContext.Default.JsonElement)), + jsonFormat.SchemaDescription, + OpenAIClientExtensions.HasStrict(options?.AdditionalProperties)), + + ChatResponseFormatJson => ResponseTextFormat.CreateJsonObjectFormat(), + + _ => null, + }; + + /// Convert a sequence of s to s. + internal static IEnumerable ToOpenAIResponseItems(IEnumerable inputs, ChatOptions? options) + { + _ = options; // currently unused + + Dictionary? idToContentMapping = null; + + foreach (ChatMessage input in inputs) + { + if (input.Role == ChatRole.System || + input.Role == OpenAIClientExtensions.ChatRoleDeveloper) + { + string text = input.Text; + if (!string.IsNullOrWhiteSpace(text)) + { + yield return input.Role == ChatRole.System ? + ResponseItem.CreateSystemMessageItem(text) : + ResponseItem.CreateDeveloperMessageItem(text); + } + + continue; + } + + if (input.Role == ChatRole.User) + { + // Some AIContent items may map to ResponseItems directly. Others map to ResponseContentParts that need to be grouped together. + // In order to preserve ordering, we yield ResponseItems as we find them, grouping ResponseContentParts between those yielded + // items together into their own yielded item. + + List? parts = null; + bool responseItemYielded = false; + + foreach (AIContent item in input.Contents) + { + // Items that directly map to a ResponseItem. + ResponseItem? directItem = item switch + { + { RawRepresentation: ResponseItem rawRep } => rawRep, + McpServerToolApprovalResponseContent mcpResp => ResponseItem.CreateMcpApprovalResponseItem(mcpResp.Id, mcpResp.Approved), + _ => null + }; + + if (directItem is not null) + { + // Yield any parts already accumulated. + if (parts is not null) + { + yield return ResponseItem.CreateUserMessageItem(parts); + parts = null; + } + + // Now yield the directly mapped item. + yield return directItem; + + responseItemYielded = true; + continue; + } + + // Items that map into ResponseContentParts and are grouped. + switch (item) + { + case AIContent when item.RawRepresentation is ResponseContentPart rawRep: + (parts ??= []).Add(rawRep); + break; + + case TextContent textContent: + (parts ??= []).Add(ResponseContentPart.CreateInputTextPart(textContent.Text)); + break; + + case UriContent uriContent when uriContent.HasTopLevelMediaType("image"): + (parts ??= []).Add(ResponseContentPart.CreateInputImagePart(uriContent.Uri)); + break; + + case DataContent dataContent when dataContent.HasTopLevelMediaType("image"): + (parts ??= []).Add(ResponseContentPart.CreateInputImagePart(BinaryData.FromBytes(dataContent.Data), dataContent.MediaType)); + break; + + case DataContent dataContent when dataContent.MediaType.StartsWith("application/pdf", StringComparison.OrdinalIgnoreCase): + (parts ??= []).Add(ResponseContentPart.CreateInputFilePart(BinaryData.FromBytes(dataContent.Data), dataContent.MediaType, dataContent.Name ?? $"{Guid.NewGuid():N}.pdf")); + break; + + case HostedFileContent fileContent: + (parts ??= []).Add(ResponseContentPart.CreateInputFilePart(fileContent.FileId)); + break; + + case ErrorContent errorContent when errorContent.ErrorCode == nameof(ResponseContentPartKind.Refusal): + (parts ??= []).Add(ResponseContentPart.CreateRefusalPart(errorContent.Message)); + break; + } + } + + // If we haven't accumulated any parts nor have we yielded any items, manufacture an empty input text part + // to guarantee that every user message results in at least one ResponseItem. + if (parts is null && !responseItemYielded) + { + parts = []; + parts.Add(ResponseContentPart.CreateInputTextPart(string.Empty)); + responseItemYielded = true; + } + + // Final yield of any accumulated parts. + if (parts is not null) + { + yield return ResponseItem.CreateUserMessageItem(parts); + parts = null; + } + + continue; + } + + if (input.Role == ChatRole.Tool) + { + foreach (AIContent item in input.Contents) + { + switch (item) + { + case AIContent when item.RawRepresentation is ResponseItem rawRep: + yield return rawRep; + break; + + case FunctionResultContent resultContent: + static FunctionCallOutputResponseItem SerializeAIContent(string callId, IEnumerable contents) + { + List elements = []; + + foreach (var content in contents) + { + switch (content) + { + case TextContent tc: + elements.Add(new() + { + Type = "input_text", + Text = tc.Text + }); + break; + + case DataContent dc when dc.HasTopLevelMediaType("image"): + elements.Add(new() + { + Type = "input_image", + ImageUrl = dc.Uri + }); + break; + + case DataContent dc: + elements.Add(new() + { + Type = "input_file", + FileData = dc.Uri, // contrary to the docs, file_data is expected to be a data URI, not just the base64 portion + FileName = dc.Name ?? $"file_{Guid.NewGuid():N}", // contrary to the docs, file_name is required + }); + break; + + case UriContent uc when uc.HasTopLevelMediaType("image"): + elements.Add(new() + { + Type = "input_image", + ImageUrl = uc.Uri.AbsoluteUri, + }); + break; + + case UriContent uc: + elements.Add(new() + { + Type = "input_file", + FileUrl = uc.Uri.AbsoluteUri, + }); + break; + + case HostedFileContent fc: + elements.Add(new() + { + Type = fc.HasTopLevelMediaType("image") ? "input_image" : "input_file", + FileId = fc.FileId, + FileName = fc.Name, + }); + break; + + default: + // Fallback to serializing and storing the resulting JSON as text. + try + { + elements.Add(new() + { + Type = "input_text", + Text = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))), + }); + } + catch (NotSupportedException) + { + // If the type can't be serialized, skip it. + } + break; + } + } + + FunctionCallOutputResponseItem outputItem = new(callId, string.Empty); + if (elements.Count > 0) + { + outputItem.Patch.Set("$.output"u8, JsonSerializer.SerializeToUtf8Bytes(elements, OpenAIJsonContext.Default.ListFunctionToolCallOutputElement).AsSpan()); + } + + return outputItem; + } + + switch (resultContent.Result) + { + case AIContent ac: + yield return SerializeAIContent(resultContent.CallId, [ac]); + break; + + case IEnumerable items: + yield return SerializeAIContent(resultContent.CallId, items); + break; + + default: + string? result = resultContent.Result as string; + if (result is null && resultContent.Result is { } resultObj) + { + try + { + result = JsonSerializer.Serialize(resultContent.Result, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))); + } + catch (NotSupportedException) + { + // If the type can't be serialized, skip it. + } + } + + yield return ResponseItem.CreateFunctionCallOutputItem(resultContent.CallId, result ?? string.Empty); + break; + } + break; + + case McpServerToolApprovalResponseContent mcpApprovalResponseContent: + yield return ResponseItem.CreateMcpApprovalResponseItem(mcpApprovalResponseContent.Id, mcpApprovalResponseContent.Approved); + break; + } + } + + continue; + } + + if (input.Role == ChatRole.Assistant) + { + foreach (AIContent item in input.Contents) + { + switch (item) + { + case AIContent when item.RawRepresentation is ResponseItem rawRep: + yield return rawRep; + break; + + case TextContent textContent: + yield return ResponseItem.CreateAssistantMessageItem(textContent.Text); + break; + + case TextReasoningContent reasoningContent: + yield return OpenAIResponsesModelFactory.ReasoningResponseItem( + encryptedContent: reasoningContent.ProtectedData, + summaryText: reasoningContent.Text); + break; + + case FunctionCallContent callContent: + yield return ResponseItem.CreateFunctionCallItem( + callContent.CallId, + callContent.Name, + BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes( + callContent.Arguments, + AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IDictionary))))); + break; + + case McpServerToolApprovalRequestContent mcpApprovalRequestContent: + yield return ResponseItem.CreateMcpApprovalRequestItem( + mcpApprovalRequestContent.Id, + mcpApprovalRequestContent.ToolCall.ServerName, + mcpApprovalRequestContent.ToolCall.ToolName, + BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(mcpApprovalRequestContent.ToolCall.Arguments!, OpenAIJsonContext.Default.IReadOnlyDictionaryStringObject))); + break; + + case McpServerToolCallContent mstcc: + (idToContentMapping ??= [])[mstcc.CallId] = mstcc; + break; + + case McpServerToolResultContent mstrc: + if (idToContentMapping?.TryGetValue(mstrc.CallId, out AIContent? callContentFromMapping) is true && + callContentFromMapping is McpServerToolCallContent associatedCall) + { + _ = idToContentMapping.Remove(mstrc.CallId); + McpToolCallItem mtci = ResponseItem.CreateMcpToolCallItem( + associatedCall.ServerName, + associatedCall.ToolName, + BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(associatedCall.Arguments!, OpenAIJsonContext.Default.IReadOnlyDictionaryStringObject))); + if (mstrc.Output?.OfType().FirstOrDefault() is ErrorContent errorContent) + { + mtci.Error = BinaryData.FromString(errorContent.Message); + } + else + { + mtci.ToolOutput = string.Concat(mstrc.Output?.OfType() ?? []); + } + + yield return mtci; + } + + break; + } + } + + continue; + } + } + } + + /// Extract usage details from an . + private static UsageDetails? ToUsageDetails(OpenAIResponse? openAIResponse) + { + UsageDetails? ud = null; + if (openAIResponse?.Usage is { } usage) + { + ud = new() + { + InputTokenCount = usage.InputTokenCount, + OutputTokenCount = usage.OutputTokenCount, + TotalTokenCount = usage.TotalTokenCount, + }; + + if (usage.InputTokenDetails is { } inputDetails) + { + ud.AdditionalCounts ??= []; + ud.AdditionalCounts.Add($"{nameof(usage.InputTokenDetails)}.{nameof(inputDetails.CachedTokenCount)}", inputDetails.CachedTokenCount); + } + + if (usage.OutputTokenDetails is { } outputDetails) + { + ud.AdditionalCounts ??= []; + ud.AdditionalCounts.Add($"{nameof(usage.OutputTokenDetails)}.{nameof(outputDetails.ReasoningTokenCount)}", outputDetails.ReasoningTokenCount); + } + } + + return ud; + } + + /// Convert a sequence of s to a list of . + private static List ToAIContents(IEnumerable contents) + { + List results = []; + + foreach (ResponseContentPart part in contents) + { + switch (part.Kind) + { + case ResponseContentPartKind.InputText or ResponseContentPartKind.OutputText: + TextContent text = new(part.Text) { RawRepresentation = part }; + PopulateAnnotations(part, text); + results.Add(text); + break; + + case ResponseContentPartKind.InputFile: + if (!string.IsNullOrWhiteSpace(part.InputImageFileId)) + { + results.Add(new HostedFileContent(part.InputImageFileId) { MediaType = "image/*", RawRepresentation = part }); + } + else if (!string.IsNullOrWhiteSpace(part.InputFileId)) + { + results.Add(new HostedFileContent(part.InputFileId) { Name = part.InputFilename, RawRepresentation = part }); + } + else if (part.InputFileBytes is not null) + { + results.Add(new DataContent(part.InputFileBytes, part.InputFileBytesMediaType ?? "application/octet-stream") + { + Name = part.InputFilename, + RawRepresentation = part, + }); + } + + break; + + case ResponseContentPartKind.Refusal: + results.Add(new ErrorContent(part.Refusal) + { + ErrorCode = nameof(ResponseContentPartKind.Refusal), + RawRepresentation = part, + }); + break; + + default: + results.Add(new() { RawRepresentation = part }); + break; + } + } + + return results; + } + + /// Converts any annotations from and stores them in . + private static void PopulateAnnotations(ResponseContentPart source, AIContent destination) + { + if (source.OutputTextAnnotations is { Count: > 0 }) + { + foreach (var ota in source.OutputTextAnnotations) + { + CitationAnnotation ca = new() + { + RawRepresentation = ota, + }; + + switch (ota) + { + case ContainerFileCitationMessageAnnotation cfcma: + ca.AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = cfcma.StartIndex, EndIndex = cfcma.EndIndex }]; + ca.FileId = cfcma.FileId; + ca.Title = cfcma.Filename; + break; + + case FilePathMessageAnnotation fpma: + ca.FileId = fpma.FileId; + break; + + case FileCitationMessageAnnotation fcma: + ca.FileId = fcma.FileId; + break; + + case UriCitationMessageAnnotation ucma: + ca.AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = ucma.StartIndex, EndIndex = ucma.EndIndex }]; + ca.Url = ucma.Uri; + ca.Title = ucma.Title; + break; + } + + (destination.Annotations ??= []).Add(ca); + } + } + } + + /// Adds new for the specified into . + private static void AddMcpToolCallContent(McpToolCallItem mtci, IList contents) + { + contents.Add(new McpServerToolCallContent(mtci.Id, mtci.ToolName, mtci.ServerLabel) + { + Arguments = JsonSerializer.Deserialize(mtci.ToolArguments.ToMemory().Span, OpenAIJsonContext.Default.IReadOnlyDictionaryStringObject)!, + + // We purposefully do not set the RawRepresentation on the McpServerToolCallContent, only on the McpServerToolResultContent, to avoid + // the same McpToolCallItem being included on two different AIContent instances. When these are roundtripped, we want only one + // McpToolCallItem sent back for the pair. + }); + + contents.Add(new McpServerToolResultContent(mtci.Id) + { + RawRepresentation = mtci, + Output = [mtci.Error is not null ? + new ErrorContent(mtci.Error.ToString()) : + new TextContent(mtci.ToolOutput)], + }); + } + + /// Adds all of the tool names from to . + private static void AddAllMcpFilters(IList toolNames, McpToolFilter filter) + { + foreach (var toolName in toolNames) + { + filter.ToolNames.Add(toolName); + } + } + + /// Adds new for the specified into . + private static void AddCodeInterpreterContents(CodeInterpreterCallResponseItem cicri, IList contents) + { + contents.Add(new CodeInterpreterToolCallContent + { + CallId = cicri.Id, + Inputs = !string.IsNullOrWhiteSpace(cicri.Code) ? [new DataContent(Encoding.UTF8.GetBytes(cicri.Code), "text/x-python")] : null, + + // We purposefully do not set the RawRepresentation on the HostedCodeInterpreterToolCallContent, only on the HostedCodeInterpreterToolResultContent, to avoid + // the same CodeInterpreterCallResponseItem being included on two different AIContent instances. When these are roundtripped, we want only one + // CodeInterpreterCallResponseItem sent back for the pair. + }); + + contents.Add(new CodeInterpreterToolResultContent + { + CallId = cicri.Id, + Outputs = cicri.Outputs is { Count: > 0 } outputs ? outputs.Select(o => + o switch + { + CodeInterpreterCallImageOutput cicio => new UriContent(cicio.ImageUri, OpenAIClientExtensions.ImageUriToMediaType(cicio.ImageUri)) { RawRepresentation = cicio }, + CodeInterpreterCallLogsOutput ciclo => new TextContent(ciclo.Logs) { RawRepresentation = ciclo }, + _ => null, + }).OfType().ToList() : null, + RawRepresentation = cicri, + }); + } + + private static void AddImageGenerationContents(ImageGenerationCallResponseItem outputItem, ResponseCreationOptions? options, IList contents) + { + var imageGenTool = options?.Tools.OfType().FirstOrDefault(); + string outputFormat = imageGenTool?.OutputFileFormat?.ToString() ?? "png"; + + contents.Add(new ImageGenerationToolCallContent + { + ImageId = outputItem.Id, + }); + + contents.Add(new ImageGenerationToolResultContent + { + ImageId = outputItem.Id, + RawRepresentation = outputItem, + Outputs = new List + { + new DataContent(outputItem.ImageResultBytes, $"image/{outputFormat}") + } + }); + } + + private static ImageGenerationToolResultContent GetImageGenerationResult(StreamingResponseImageGenerationCallPartialImageUpdate update, ResponseCreationOptions? options) + { + var imageGenTool = options?.Tools.OfType().FirstOrDefault(); + var outputType = imageGenTool?.OutputFileFormat?.ToString() ?? "png"; + + var bytes = update.PartialImageBytes; + + if (bytes is null || bytes.Length == 0) + { + // workaround https://github.com/openai/openai-dotnet/issues/809 + if (update.Patch.TryGetJson("$.partial_image_b64"u8, out var jsonBytes)) + { + Utf8JsonReader reader = new(jsonBytes.Span); + _ = reader.Read(); + bytes = BinaryData.FromBytes(reader.GetBytesFromBase64()); + } + } + + return new ImageGenerationToolResultContent + { + ImageId = update.ItemId, + RawRepresentation = update, + Outputs = new List + { + new DataContent(bytes, $"image/{outputType}") + { + AdditionalProperties = new() + { + [nameof(update.ItemId)] = update.ItemId, + [nameof(update.OutputIndex)] = update.OutputIndex, + [nameof(update.PartialImageIndex)] = update.PartialImageIndex + } + } + } + }; + } + + private static OpenAIResponsesContinuationToken? CreateContinuationToken(OpenAIResponse openAIResponse) + { + return CreateContinuationToken( + responseId: openAIResponse.Id, + responseStatus: openAIResponse.Status, + isBackgroundModeEnabled: openAIResponse.BackgroundModeEnabled); + } + + private static OpenAIResponsesContinuationToken? CreateContinuationToken( + string responseId, + ResponseStatus? responseStatus, + bool? isBackgroundModeEnabled, + int? updateSequenceNumber = null) + { + if (isBackgroundModeEnabled is not true) + { + return null; + } + + // Returns a continuation token for in-progress or queued responses as they are not yet complete. + // Also returns a continuation token if there is no status but there is a sequence number, + // which can occur for certain streaming updates related to response content part updates: response.content_part.*, + // response.output_text.* + if ((responseStatus is ResponseStatus.InProgress or ResponseStatus.Queued) || + (responseStatus is null && updateSequenceNumber is not null)) + { + return new OpenAIResponsesContinuationToken(responseId) + { + SequenceNumber = updateSequenceNumber, + }; + } + + // For all other statuses: completed, failed, canceled, incomplete + // return null to indicate the operation is finished allowing the caller + // to stop and access the final result, failure details, reason for incompletion, etc. + return null; + } + + private static OpenAIResponsesContinuationToken? GetContinuationToken(IEnumerable messages, ChatOptions? options = null) + { + if (options?.ContinuationToken is { } token) + { + if (messages.Any()) + { + throw new InvalidOperationException("Messages are not allowed when continuing a background response using a continuation token."); + } + + return OpenAIResponsesContinuationToken.FromToken(token); + } + + return null; + } + + /// Provides an wrapper for a . + internal sealed class ResponseToolAITool(ResponseTool tool) : AITool + { + public ResponseTool Tool => tool; + public override string Name => Tool.GetType().Name; + + /// + public override object? GetService(Type serviceType, object? serviceKey = null) + { + _ = Throw.IfNull(serviceType); + + return + serviceKey is null && serviceType.IsInstanceOfType(Tool) ? Tool : + base.GetService(serviceType, serviceKey); + } + } + + /// DTO for an array element in OpenAI Responses' "Function tool call output". + internal sealed class FunctionToolCallOutputElement + { + [JsonPropertyName("type")] + public string? Type { get; set; } // input_text, input_image, or input_file + + [JsonPropertyName("text")] + public string? Text { get; set; } + + [JsonPropertyName("image_url")] + public string? ImageUrl { get; set; } + + [JsonPropertyName("file_id")] + public string? FileId { get; set; } + + [JsonPropertyName("file_data")] + public string? FileData { get; set; } + + [JsonPropertyName("file_url")] + public string? FileUrl { get; set; } + + [JsonPropertyName("filename")] + public string? FileName { get; set; } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesContinuationToken.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesContinuationToken.cs new file mode 100644 index 00000000000..229f8b40f69 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesContinuationToken.cs @@ -0,0 +1,117 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; +using System.Text.Json; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Represents a continuation token for OpenAI responses. +/// +/// The token is used for resuming streamed background responses and continuing +/// non-streamed background responses until completion. +/// +internal sealed class OpenAIResponsesContinuationToken : ResponseContinuationToken +{ + /// Initializes a new instance of the class. + internal OpenAIResponsesContinuationToken(string responseId) + { + ResponseId = responseId; + } + + /// Gets the Id of the response. + internal string ResponseId { get; } + + /// Gets or sets the sequence number of a streamed update. + internal int? SequenceNumber { get; set; } + + /// + public override ReadOnlyMemory ToBytes() + { + using MemoryStream stream = new(); + using Utf8JsonWriter writer = new(stream); + + writer.WriteStartObject(); + + writer.WriteString("responseId", ResponseId); + + if (SequenceNumber.HasValue) + { + writer.WriteNumber("sequenceNumber", SequenceNumber.Value); + } + + writer.WriteEndObject(); + + writer.Flush(); + + return stream.ToArray(); + } + + /// Create a new instance of from the provided . + /// + /// The token to create the from. + /// A equivalent of the provided . + internal static OpenAIResponsesContinuationToken FromToken(object token) + { + if (token is OpenAIResponsesContinuationToken openAIResponsesContinuationToken) + { + return openAIResponsesContinuationToken; + } + + if (token is not ResponseContinuationToken) + { + Throw.ArgumentException(nameof(token), "Failed to create OpenAIResponsesResumptionToken from provided token because it is not of type ResponseContinuationToken."); + } + + ReadOnlyMemory data = ((ResponseContinuationToken)token).ToBytes(); + + if (data.Length == 0) + { + Throw.ArgumentException(nameof(token), "Failed to create OpenAIResponsesResumptionToken from provided token because it does not contain any data."); + } + + Utf8JsonReader reader = new(data.Span); + + string? responseId = null; + int? sequenceNumber = null; + + _ = reader.Read(); + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + break; + } + + string propertyName = reader.GetString()!; + + switch (propertyName) + { + case "responseId": + _ = reader.Read(); + responseId = reader.GetString(); + break; + case "sequenceNumber": + _ = reader.Read(); + sequenceNumber = reader.GetInt32(); + break; + default: + Throw.ArgumentException(nameof(token), $"Unrecognized property '{propertyName}'."); + break; + } + } + + if (responseId is null) + { + Throw.ArgumentException(nameof(token), "Failed to create MessagesPageToken from provided pageToken because it does not contain a responseId."); + } + + return new(responseId) + { + SequenceNumber = sequenceNumber + }; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAISpeechToTextClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAISpeechToTextClient.cs index fa00fc45232..fb0901eeb0d 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAISpeechToTextClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAISpeechToTextClient.cs @@ -5,7 +5,6 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; -using System.Reflection; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; @@ -13,7 +12,6 @@ using OpenAI; using OpenAI.Audio; -#pragma warning disable S1067 // Expressions should not be too complex #pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields #pragma warning disable SA1204 // Static elements should appear before instance elements @@ -37,20 +35,9 @@ internal sealed class OpenAISpeechToTextClient : ISpeechToTextClient /// The underlying client. public OpenAISpeechToTextClient(AudioClient audioClient) { - _ = Throw.IfNull(audioClient); + _audioClient = Throw.IfNull(audioClient); - _audioClient = audioClient; - - // 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. - Uri providerUrl = typeof(AudioClient).GetField("_endpoint", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) - ?.GetValue(audioClient) as Uri ?? OpenAIClientExtensions.DefaultOpenAIEndpoint; - string? model = typeof(AudioClient).GetField("_model", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) - ?.GetValue(audioClient) as string; - - _metadata = new("openai", providerUrl, model); + _metadata = new("openai", audioClient.Endpoint, _audioClient.Model); } /// diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/RequestOptionsExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/RequestOptionsExtensions.cs new file mode 100644 index 00000000000..dbe38e42a2c --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/RequestOptionsExtensions.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.ClientModel.Primitives; +using System.Collections.Generic; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; + +#pragma warning disable CA1307 // Specify StringComparison + +namespace Microsoft.Extensions.AI; + +/// Provides utility methods for creating . +internal static class RequestOptionsExtensions +{ + /// Creates a configured for use with OpenAI. + public static RequestOptions ToRequestOptions(this CancellationToken cancellationToken, bool streaming) + { + RequestOptions requestOptions = new() + { + CancellationToken = cancellationToken, + BufferResponse = !streaming + }; + + requestOptions.AddPolicy(MeaiUserAgentPolicy.Instance, PipelinePosition.PerCall); + + return requestOptions; + } + + /// Provides a pipeline policy that adds a "MEAI/x.y.z" user-agent header. + private sealed class MeaiUserAgentPolicy : PipelinePolicy + { + public static MeaiUserAgentPolicy Instance { get; } = new MeaiUserAgentPolicy(); + + private static readonly string _userAgentValue = CreateUserAgentValue(); + + public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + AddUserAgentHeader(message); + ProcessNext(message, pipeline, currentIndex); + } + + public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + AddUserAgentHeader(message); + return ProcessNextAsync(message, pipeline, currentIndex); + } + + private static void AddUserAgentHeader(PipelineMessage message) => + message.Request.Headers.Add("User-Agent", _userAgentValue); + + private static string CreateUserAgentValue() + { + const string Name = "MEAI"; + + if (typeof(MeaiUserAgentPolicy).Assembly.GetCustomAttribute()?.InformationalVersion is string version) + { + int pos = version.IndexOf('+'); + if (pos >= 0) + { + version = version.Substring(0, pos); + } + + if (version.Length > 0) + { + return $"{Name}/{version}"; + } + } + + return Name; + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI/CHANGELOG.md new file mode 100644 index 00000000000..f3c53f0d8a1 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/CHANGELOG.md @@ -0,0 +1,166 @@ +# Release History + +## 9.10.2 + +- Updated the Open Telemetry instrumentation to conform to the latest 1.38 draft specification of the Semantic Conventions for Generative AI systems. + +## 9.10.1 + +- Added an `[Experimental]` implementation of tool reduction component for constraining the set of tools exposed. +- Fixed `SummarizingChatReducer` to preserve function calling content in the chat history. + +## 9.10.0 + +- Added `OpenTelemetrySpeechToTextClient` to provide Open Telemetry instrumentation for `ISpeechToTextClient` implementations. +- Augmented `OpenTelemetryChatClient` to output tool information for all tools rather than only `AIFunctionDeclaration`-based tools. +- Fixed `OpenTelemetryChatClient` to avoid throwing exceptions when trying to serialize unknown `AIContent`-derived types. +- Fixed issue with `FunctionInvokingChatClient` where some buffered updates in the face of possible approvals weren't being propagated. +- Simplified the name of the activity span emitted by `FunctionInvokingChatClient`. + +## 9.9.1 + +- Updated the `EnableSensitiveData` properties on `OpenTelemetryChatClient/EmbeddingGenerator` to respect a `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` environment variable. +- Updated `OpenTelemetryChatClient/EmbeddingGenerator` to emit recent additions to the OpenTelemetry Semantic Conventions for Generative AI systems. +- Added `OpenTelemetryImageGenerator` to provide OpenTelemetry instrumentation for `IImageGenerator` implementations. + +## 9.9.0 + +- Added `FunctionInvokingChatClient` support for non-invocable tools and `TerminateOnUnknownCalls` property. +- Added support to `FunctionInvokingChatClient` for user approval of function invocations. +- Updated the Open Telemetry instrumentation to conform to the latest 1.37 draft specification of the Semantic Conventions for Generative AI systems. +- Fixed `GetResponseAsync` to only look at the contents of the last message in the response. + +## 9.8.0 + +- Added `FunctionInvokingChatClient.AdditionalTools` to allow `FunctionInvokingChatClient` to have access to tools not included in `ChatOptions.Tools` but known to the target service via pre-configuration. +- Added [Experimental] `IChatReducer` and supporting types +- Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. + +## 9.7.1 + +- Added `FunctionInvokingChatClient.FunctionInvoker` to simplify customizing how functions are invoked. +- Increased the default `FunctionInvokingChatClient.MaximumIterationsPerRequest` value from 10 to 40. +- Updated the Open Telemetry instrumentation to conform to the latest 1.36 draft specification of the Semantic Conventions for Generative AI systems. +- Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. + +## 9.7.0 + +- Added `DistributedCachingChatClient/EmbeddingGenerator.AdditionalCacheKeyValues` to allow adding additional values to the cache key. +- Allowed a `CachingChatClient` to control per-request caching. +- Updated the Open Telemetry instrumentation to conform to the latest 1.35 draft specification of the Semantic Conventions for Generative AI systems. +- Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. + +## 9.6.0 + +- Fixed hashing in `CachingChatClient` and `CachingEmbeddingGenerator` to be stable with respect to indentation settings and property ordering. +- Updated the Open Telemetry instrumentation to conform to the latest 1.34 draft specification of the Semantic Conventions for Generative AI systems. +- Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. + +## 9.5.0 + +- Changed `OpenTelemetryChatClient` and `OpenTelemetryEmbeddingGenerator` to consider `AdditionalProperties` to be "sensitive". +- Changed `FunctionInvokingChatClient` to respect the `SynchronizationContext` of the caller when invoking functions. +- Changed hash function algorithm used in `CachingChatClient` and `CachingEmbeddingGenerator` to SHA-384 instead of SHA-256. +- Updated `FunctionInvokingChatClient` to include token counts on its emitted diagnostic spans. +- Updated `OpenTelemetryChatClient` and `OpenTelemetryEmbeddingGenerator` to conform to the latest 1.33 draft specification of the Semantic Conventions for Generative AI systems. +- Renamed the `useJsonSchema` paramter of `GetResponseAsync`. +- Removed debug-level logging of updates in `LoggingChatClient`. +- Avoided caching in `CachingChatClient` when `ConversationId` is set. +- Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. + +## 9.4.4-preview.1.25259.16 + +- Fixed `CachingChatClient` to avoid caching when `ConversationId` is set. +- Renamed `useJsonSchema` parameter in `GetResponseAsync` to `useJsonSchemaResponseFormat`. +- Updated `OpenTelemetryChatClient` and `OpenTelemetryEmbeddingGenerator` to conform to the latest 1.32 draft specification of the Semantic Conventions for Generative AI systems. + +## 9.4.3-preview.1.25230.7 + +- Updated the diagnostic spans emitted by `FunctionInvokingChatClient` to include total input and output token counts. +- Updated `AIFunctionFactory` to recognize `[FromKeyedServices]` attribute on parameters in order to resolve those parameters from the `IServiceProvider`. +- Added `AIFunctionFactoryOptions.Services`, and used it with `IServiceProviderIsService` to automatically resolve `IServiceProvider`-based parameters in `AIFunction` methods. +- Added `ChatOptions.AllowMultipleToolCalls`. +- Changed `AIJsonSchemaCreateOptions.RequireAllProperties` to default to `false` instead of `true`. +- Unsealed `AIFunctionArguments`. +- Added `AIFunctionArguments` constructors accepting `IEqualityComparer` arguments. +- Unsealed `FunctionInvocationContext`. +- Added `FunctionInvocationContext.IsStreaming`. +- Added protected `FunctionInvokingChatClient.FunctionInvocationServices` property to surface the corresponding `IServiceProvider` provided at construction time. +- Changed protected virtual `FunctionInvokingChatClient.InvokeFunctionAsync` to return `ValueTask` instead of `Task`. Diagnostics are now emitted even if the method is overridden. +- Added `FunctionInvocationResult.Terminate`. + +## 9.4.0-preview.1.25207.5 + +- Updated `GetResponseAsync` to default to using JSON-schema based structured output by default. +- Updated `AIFunctionFactory` with support for customizable marshaling of function parameters and return values. +- Updated `AIFunctionFactory` with a new `Create` overload that supports creating a new receiver instance on each invocation, with either Activator.CreateInstance or ActivatorUtilities.CreateInstance. +- Updated `AIFunctionFactory` to support injecting an `IServiceProvider` as an argument, sourced from the `AIFunctionArguments` passed to `AIFunction.InvokeAsync`. +- Simplified `FunctionInvokingChatClient` error handling, removing `RetryOnError` and replacing it with `MaximumConsecutiveErrorsPerRequest`. +- `FunctionInvokingChatClient` will now ensure that it invokes `AIFunction`s in the same `SynchronizationContext` that `GetResponseAsync`/`GetStreamingResponseAsync` was called in. +- `OpenTelemetryChatClient` now considers `AdditionalProperties` to be sensitive and will only use that data as tags when `EnableSensitiveData` is set to `true`. +- Updated `OpenTelemetryChatClient`/`OpenTelemetryEmbeddingGenerator` to conform to the latest 1.32 draft specification of the Semantic Conventions for Generative AI systems. +- Updated the key used by `DistributedCachingChatClient` to employ SHA384 instead of SHA256. +- Lowered `System.Text.Json` 9.x dependency to 8.x when targeting `net8.0` or older. + +## 9.3.0-preview.1.25161.3 + +- Added caching to `AIFunctionFactory.Create` to improve performance of creating the same functions repeatedly. As part of this, `AIJsonSchemaCreateOptions` now implements `IEquatable`. +- Removed the public `AnonymousDelegatingChatClient`/`AnonymousDelegatingEmbeddingGenerator`. Their functionality is still available via the `Use` methods on the builders. +- Changed those `Use` methods to use `Func<...>` rather than a custom delegate type. +- `AIFunctionFactory.Create` now supports `CancellationToken` parameters, and the `AIFunctionContext` type that had served to enable that has been removed. +- Made `FunctionInvokingChatClient.CurrentContext`'s setter `protected`. +- Renamed `FunctionInvokingChatClient.DetailedErrors` to `IncludeDetailedErrors`. +- Renamed `FunctionInvokingChatClient.ConcurrentInvocation` to `AllowConcurrentInvocation`. +- Removed `FunctionInvokingChatClient.KeepFunctionCallingContent`, as it's no longer relevant now that the input messages are an `IEnumerable` rather than an `IList`. +- Renamed `FunctionStatus` to `FunctionInvocationStatus`. +- Renamed `FunctionInvocationStatus.Failed` to `FunctionInvocationStatus.Exception`. +- Moved the nested `FunctionInvocationContext` type to be a peer of `FunctionInvokingChatClient` rather than nested within it. +- Made the `serviceKey` parameters to `AddKeyedChatClient`/`AddKeyedEmbeddingGenerator` nullable. +- Improved `FunctionInvokingChatClient.GetStreamingResponseAsync` to send back to the inner client all content received until that point, and to stream back to the caller messages it generates (e.g. tool responses). +- Improved `AddEmbeddingGenerator` and `AddKeyedEmbeddingGenerator` to register for both the generic and non-generic interfaces. + +## 9.3.0-preview.1.25114.11 + +- Updated `OpenTelemetryChatClient`/`OpenTelemetryEmbeddingGenerator` to conform to the latest 1.30 draft specification of the Semantic Conventions for Generative AI systems. + +## 9.1.0-preview.1.25064.3 + +- Added `FunctionInvokingChatClient.CurrentContext` to give functions access to detailed function invocation information. +- Updated `OpenTelemetryChatClient`/`OpenTelemetryEmbeddingGenerator` to conform to the latest 1.29 draft specification of the Semantic Conventions for Generative AI systems. +- Updated `FunctionInvokingChatClient` to emit an `Activity`/span around all interactions related to a single chat operation. + +## 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`. + +## 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 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 diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/AnonymousDelegatingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/AnonymousDelegatingChatClient.cs index db256e94916..9a3fb9d4ad6 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/AnonymousDelegatingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/AnonymousDelegatingChatClient.cs @@ -12,8 +12,6 @@ using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; -#pragma warning disable VSTHRD003 // Avoid awaiting foreign Tasks - namespace Microsoft.Extensions.AI; /// Represents a delegating chat client that wraps an inner client with implementations provided by delegates. diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/CachingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/CachingChatClient.cs index 2923b0ad62d..cb5482ac213 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/CachingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/CachingChatClient.cs @@ -8,9 +8,6 @@ using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; -#pragma warning disable S127 // "for" loop stop conditions should be invariant -#pragma warning disable SA1202 // Elements should be ordered by access - namespace Microsoft.Extensions.AI; /// diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs index 69c4cc7ee89..09ec568d749 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatClientStructuredOutputExtensions.cs @@ -3,11 +3,9 @@ using System; using System.Collections.Generic; -using System.ComponentModel; -using System.Reflection; +using System.Diagnostics; using System.Text.Json; using System.Text.Json.Nodes; -using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; @@ -23,17 +21,6 @@ namespace Microsoft.Extensions.AI; /// Request a response with structured output. public static partial class ChatClientStructuredOutputExtensions { - private static readonly AIJsonSchemaCreateOptions _inferenceOptions = new() - { - IncludeSchemaKeyword = true, - TransformOptions = new AIJsonSchemaTransformOptions - { - DisallowAdditionalProperties = true, - RequireAllProperties = true, - MoveDefaultKeywordToDescription = true, - }, - }; - /// Sends chat messages, requesting a response matching the type . /// The . /// The chat content to send. @@ -161,20 +148,12 @@ public static async Task> GetResponseAsync( serializerOptions.MakeReadOnly(); - var schemaElement = AIJsonUtilities.CreateJsonSchema( - type: typeof(T), - serializerOptions: serializerOptions, - inferenceOptions: _inferenceOptions); + var responseFormat = ChatResponseFormat.ForJsonSchema(serializerOptions); - bool isWrappedInObject; - JsonElement schema; - if (SchemaRepresentsObject(schemaElement)) - { - // For object-representing schemas, we can use them as-is - isWrappedInObject = false; - schema = schemaElement; - } - else + Debug.Assert(responseFormat.Schema is not null, "ForJsonSchema should always populate Schema"); + var schema = responseFormat.Schema!.Value; + bool isWrappedInObject = false; + if (!SchemaRepresentsObject(schema)) { // 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 @@ -184,10 +163,11 @@ public static async Task> GetResponseAsync( { { "$schema", "https://json-schema.org/draft/2020-12/schema" }, { "type", "object" }, - { "properties", new JsonObject { { "data", JsonElementToJsonNode(schemaElement) } } }, + { "properties", new JsonObject { { "data", JsonElementToJsonNode(schema) } } }, { "additionalProperties", false }, { "required", new JsonArray("data") }, }, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonObject))); + responseFormat = ChatResponseFormat.ForJsonSchema(schema, responseFormat.SchemaName, responseFormat.SchemaDescription); } ChatMessage? promptAugmentation = null; @@ -200,10 +180,7 @@ public static async Task> GetResponseAsync( { // 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: SanitizeMemberName(typeof(T).Name), - schemaDescription: typeof(T).GetCustomAttribute()?.Description); + options.ResponseFormat = responseFormat; } else { @@ -213,7 +190,7 @@ public static async Task> GetResponseAsync( promptAugmentation = new ChatMessage(ChatRole.User, $$""" Respond with a JSON value conforming to the following schema: ``` - {{schema}} + {{responseFormat.Schema}} ``` """); @@ -222,53 +199,31 @@ public static async Task> GetResponseAsync( var result = await chatClient.GetResponseAsync(messages, options, cancellationToken); return new ChatResponse(result, serializerOptions) { IsWrappedInObject = isWrappedInObject }; - } - private static bool SchemaRepresentsObject(JsonElement schemaElement) - { - if (schemaElement.ValueKind is JsonValueKind.Object) + static bool SchemaRepresentsObject(JsonElement schemaElement) { - foreach (var property in schemaElement.EnumerateObject()) + if (schemaElement.ValueKind is JsonValueKind.Object) { - if (property.NameEquals("type"u8)) + foreach (var property in schemaElement.EnumerateObject()) { - return property.Value.ValueKind == JsonValueKind.String - && property.Value.ValueEquals("object"u8); + if (property.NameEquals("type"u8)) + { + return property.Value.ValueKind == JsonValueKind.String + && property.Value.ValueEquals("object"u8); + } } } - } - return false; - } + 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) - }; + static JsonNode? JsonElementToJsonNode(JsonElement element) => + element.ValueKind switch + { + JsonValueKind.Null => null, + JsonValueKind.Array => JsonArray.Create(element), + JsonValueKind.Object => JsonObject.Create(element), + _ => JsonValue.Create(element) + }; } - - /// - /// 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. - /// - private static string SanitizeMemberName(string memberName) => - 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/ChatCompletion/ChatResponse{T}.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatResponse{T}.cs index 3756b255cc8..5a881397917 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatResponse{T}.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ChatResponse{T}.cs @@ -22,9 +22,7 @@ public class ChatResponse : ChatResponse { private static readonly JsonReaderOptions _allowMultipleValuesJsonReaderOptions = new() { -#if NET9_0_OR_GREATER AllowMultipleValues = true -#endif }; private readonly JsonSerializerOptions _serializerOptions; @@ -82,13 +80,11 @@ public bool TryGetResult([NotNullWhen(true)] out T? result) 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) @@ -127,7 +123,7 @@ public bool TryGetResult([NotNullWhen(true)] out T? result) return _deserializedResult; } - var json = Text; + var json = Messages.Count > 0 ? Messages[Messages.Count - 1].Text : string.Empty; if (string.IsNullOrEmpty(json)) { failureReason = FailureReason.ResultDidNotContainJson; diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/DistributedCachingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/DistributedCachingChatClient.cs index afaa12235ec..44ddcf84081 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/DistributedCachingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/DistributedCachingChatClient.cs @@ -11,8 +11,6 @@ using Microsoft.Extensions.Caching.Distributed; using Microsoft.Shared.Diagnostics; -#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; @@ -46,9 +44,6 @@ public class DistributedCachingChatClient : CachingChatClient /// Additional values used to inform the cache key employed for storing state. private object[]? _cacheKeyAdditionalValues; - /// The to use when serializing cache data. - private JsonSerializerOptions _jsonSerializerOptions = AIJsonUtilities.DefaultOptions; - /// Initializes a new instance of the class. /// The underlying . /// An instance that will be used as the backing store for the cache. @@ -61,9 +56,9 @@ public DistributedCachingChatClient(IChatClient innerClient, IDistributedCache s /// Gets or sets JSON serialization options to use when serializing cache data. public JsonSerializerOptions JsonSerializerOptions { - get => _jsonSerializerOptions; - set => _jsonSerializerOptions = Throw.IfNull(value); - } + get; + set => field = Throw.IfNull(value); + } = AIJsonUtilities.DefaultOptions; /// Gets or sets additional values used to inform the cache key employed for storing state. /// Any values set in this list will augment the other values used to inform the cache key. @@ -77,11 +72,11 @@ public IReadOnlyList? CacheKeyAdditionalValues protected override async Task ReadCacheAsync(string key, CancellationToken cancellationToken) { _ = Throw.IfNull(key); - _jsonSerializerOptions.MakeReadOnly(); + JsonSerializerOptions.MakeReadOnly(); if (await _storage.GetAsync(key, cancellationToken) is byte[] existingJson) { - return (ChatResponse?)JsonSerializer.Deserialize(existingJson, _jsonSerializerOptions.GetTypeInfo(typeof(ChatResponse))); + return (ChatResponse?)JsonSerializer.Deserialize(existingJson, JsonSerializerOptions.GetTypeInfo(typeof(ChatResponse))); } return null; @@ -91,11 +86,11 @@ public IReadOnlyList? CacheKeyAdditionalValues protected override async Task?> ReadCacheStreamingAsync(string key, CancellationToken cancellationToken) { _ = Throw.IfNull(key); - _jsonSerializerOptions.MakeReadOnly(); + JsonSerializerOptions.MakeReadOnly(); if (await _storage.GetAsync(key, cancellationToken) is byte[] existingJson) { - return (IReadOnlyList?)JsonSerializer.Deserialize(existingJson, _jsonSerializerOptions.GetTypeInfo(typeof(IReadOnlyList))); + return (IReadOnlyList?)JsonSerializer.Deserialize(existingJson, JsonSerializerOptions.GetTypeInfo(typeof(IReadOnlyList))); } return null; @@ -106,9 +101,9 @@ protected override async Task WriteCacheAsync(string key, ChatResponse value, Ca { _ = Throw.IfNull(key); _ = Throw.IfNull(value); - _jsonSerializerOptions.MakeReadOnly(); + JsonSerializerOptions.MakeReadOnly(); - var newJson = JsonSerializer.SerializeToUtf8Bytes(value, _jsonSerializerOptions.GetTypeInfo(typeof(ChatResponse))); + var newJson = JsonSerializer.SerializeToUtf8Bytes(value, JsonSerializerOptions.GetTypeInfo(typeof(ChatResponse))); await _storage.SetAsync(key, newJson, cancellationToken); } @@ -117,9 +112,9 @@ protected override async Task WriteCacheStreamingAsync(string key, IReadOnlyList { _ = Throw.IfNull(key); _ = Throw.IfNull(value); - _jsonSerializerOptions.MakeReadOnly(); + JsonSerializerOptions.MakeReadOnly(); - var newJson = JsonSerializer.SerializeToUtf8Bytes(value, _jsonSerializerOptions.GetTypeInfo(typeof(IReadOnlyList))); + var newJson = JsonSerializer.SerializeToUtf8Bytes(value, JsonSerializerOptions.GetTypeInfo(typeof(IReadOnlyList))); await _storage.SetAsync(key, newJson, cancellationToken); } @@ -153,7 +148,7 @@ protected override string GetCacheKey(IEnumerable messages, ChatOpt additionalValues.CopyTo(arr.AsSpan(FixedValuesCount)); clientValues.CopyTo(arr, FixedValuesCount + additionalValues.Length); - return AIJsonUtilities.HashDataToString(arr.AsSpan(0, length), _jsonSerializerOptions); + return AIJsonUtilities.HashDataToString(arr.AsSpan(0, length), JsonSerializerOptions); } finally { diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvocationContext.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvocationContext.cs index 0e426615cfd..554918b0a8e 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvocationContext.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvocationContext.cs @@ -17,18 +17,6 @@ public class FunctionInvocationContext /// private static readonly AIFunction _nopFunction = AIFunctionFactory.Create(() => { }, nameof(FunctionInvocationContext)); - /// The chat contents associated with the operation that initiated this function call request. - private IList _messages = Array.Empty(); - - /// The AI function to be invoked. - private AIFunction _function = _nopFunction; - - /// The function call content information associated with this invocation. - private FunctionCallContent? _callContent; - - /// The arguments used with the function. - private AIFunctionArguments? _arguments; - /// Initializes a new instance of the class. public FunctionInvocationContext() { @@ -37,30 +25,30 @@ public FunctionInvocationContext() /// Gets or sets the AI function to be invoked. public AIFunction Function { - get => _function; - set => _function = Throw.IfNull(value); - } + get; + set => field = Throw.IfNull(value); + } = _nopFunction; /// Gets or sets the arguments associated with this invocation. public AIFunctionArguments Arguments { - get => _arguments ??= []; - set => _arguments = Throw.IfNull(value); + get => field ??= []; + set => field = Throw.IfNull(value); } /// Gets or sets the function call content information associated with this invocation. public FunctionCallContent CallContent { - get => _callContent ??= new(string.Empty, _nopFunction.Name, EmptyReadOnlyDictionary.Instance); - set => _callContent = Throw.IfNull(value); + get => field ??= new(string.Empty, _nopFunction.Name, EmptyReadOnlyDictionary.Instance); + set => field = Throw.IfNull(value); } /// Gets or sets the chat contents associated with the operation that initiated this function call request. public IList Messages { - get => _messages; - set => _messages = Throw.IfNull(value); - } + get; + set => field = Throw.IfNull(value); + } = Array.Empty(); /// Gets or sets the chat options associated with the operation that initiated this function call request. public ChatOptions? Options { get; set; } diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs index 6b1d3b3e905..fb821c984df 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs @@ -15,9 +15,7 @@ using Microsoft.Shared.Diagnostics; #pragma warning disable CA2213 // Disposable fields should be disposed -#pragma warning disable EA0002 // Use 'System.TimeProvider' to make the code easier to test -#pragma warning disable SA1202 // 'protected' members should come before 'private' members -#pragma warning disable S107 // Methods should not have too many parameters +#pragma warning disable S3353 // Unchanged local variables should be "const" namespace Microsoft.Extensions.AI; @@ -27,14 +25,33 @@ namespace Microsoft.Extensions.AI; /// /// /// -/// When this client receives a in a chat response, it responds -/// by calling the corresponding defined in , -/// producing a that it sends back to the inner client. This loop -/// is repeated until there are no more function calls to make, or until another stop condition is met, -/// such as hitting . +/// When this client receives a in a chat response from its inner +/// , it responds by invoking the corresponding defined +/// in (or in ), producing a +/// that it sends back to the inner client. This loop is repeated until there are no more function calls to make, or until +/// another stop condition is met, such as hitting . /// /// -/// The provided implementation of is thread-safe for concurrent use so long as the +/// If a requested function is an but not an , the +/// will not attempt to invoke it, and instead allow that +/// to pass back out to the caller. It is then that caller's responsibility to create the appropriate +/// for that call and send it back as part of a subsequent request. +/// +/// +/// Further, if a requested function is an , the will not +/// attempt to invoke it directly. Instead, it will replace that with a +/// that wraps the and indicates that the function requires approval before it can be invoked. The caller is then +/// responsible for responding to that approval request by sending a corresponding in a subsequent +/// request. The will then process that approval response and invoke the function as appropriate. +/// +/// +/// Due to the nature of interactions with an underlying , if any is received +/// for a function that requires approval, all received in that same response will also require approval, +/// even if they were not instances. If this is a concern, consider requesting that multiple tool call +/// requests not be made in a single response, by setting to . +/// +/// +/// A instance is thread-safe for concurrent use so long as the /// instances employed as part of the supplied are also safe. /// The property can be used to control whether multiple function invocation /// requests as part of the same request are invocable concurrently, but even with that set to @@ -60,12 +77,6 @@ public partial class FunctionInvokingChatClient : DelegatingChatClient /// 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 = 10; - - /// Maximum number of consecutive iterations that are allowed contain at least one exception result. If the limit is exceeded, we rethrow the exception instead of continuing. - private int _maximumConsecutiveErrorsPerRequest = 3; - /// /// Initializes a new instance of the class. /// @@ -142,7 +153,7 @@ public static FunctionInvocationContext? CurrentContext /// /// /// The maximum number of iterations per request. - /// The default value is 10. + /// The default value is 40. /// /// /// @@ -159,7 +170,7 @@ public static FunctionInvocationContext? CurrentContext /// public int MaximumIterationsPerRequest { - get => _maximumIterationsPerRequest; + get; set { if (value < 1) @@ -167,9 +178,9 @@ public int MaximumIterationsPerRequest Throw.ArgumentOutOfRangeException(nameof(value)); } - _maximumIterationsPerRequest = value; + field = value; } - } + } = 40; /// /// Gets or sets the maximum number of consecutive iterations that are allowed to fail with an error. @@ -183,7 +194,7 @@ public int MaximumIterationsPerRequest /// When function invocations fail with an exception, the /// continues to make requests to the inner client, optionally supplying exception information (as /// controlled by ). This allows the to - /// recover from errors by trying other function parameters that may succeed. + /// recover from errors by trying other function parameters that might succeed. /// /// /// However, in case function invocations continue to produce exceptions, this property can be used to @@ -201,9 +212,45 @@ public int MaximumIterationsPerRequest /// public int MaximumConsecutiveErrorsPerRequest { - get => _maximumConsecutiveErrorsPerRequest; - set => _maximumConsecutiveErrorsPerRequest = Throw.IfLessThan(value, 0); - } + get; + set => field = Throw.IfLessThan(value, 0); + } = 3; + + /// Gets or sets a collection of additional tools the client is able to invoke. + /// + /// These will not impact the requests sent by the , which will pass through the + /// unmodified. However, if the inner client requests the invocation of a tool + /// that was not in , this collection will also be consulted + /// to look for a corresponding tool to invoke. This is useful when the service might have been preconfigured to be aware + /// of certain tools that aren't also sent on each individual request. + /// + public IList? AdditionalTools { get; set; } + + /// Gets or sets a value indicating whether a request to call an unknown function should terminate the function calling loop. + /// + /// to terminate the function calling loop and return the response if a request to call a tool + /// that isn't available to the is received; to create and send a + /// function result message to the inner client stating that the tool couldn't be found. The default is . + /// + /// + /// + /// When , call requests to any tools that aren't available to the + /// will result in a response message automatically being created and returned to the inner client stating that the tool couldn't be + /// found. This behavior can help in cases where a model hallucinates a function, but it's problematic if the model has been made aware + /// of the existence of tools outside of the normal mechanisms, and requests one of those. can be used + /// to help with that. But if instead the consumer wants to know about all function call requests that the client can't handle, + /// can be set to . Upon receiving a request to call a function + /// that the doesn't know about, it will terminate the function calling loop and return + /// the response, leaving the handling of the function call requests to the consumer of the client. + /// + /// + /// s that the is aware of (for example, because they're in + /// or ) but that aren't s aren't considered + /// unknown, just not invocable. Any requests to a non-invocable tool will also result in the function calling loop terminating, + /// regardless of . + /// + /// + public bool TerminateOnUnknownCalls { get; set; } /// Gets or sets a delegate used to invoke instances. /// @@ -222,7 +269,7 @@ public override async Task GetResponseAsync( // A single request into this GetResponseAsync may result in multiple requests to the inner client. // Create an activity to group them together for better observability. - using Activity? activity = _activitySource?.StartActivity($"{nameof(FunctionInvokingChatClient)}.{nameof(GetResponseAsync)}"); + using Activity? activity = _activitySource?.StartActivity(OpenTelemetryConsts.GenAI.OrchestrateToolsName); // Copy the original messages in order to avoid enumerating the original messages multiple times. // The IEnumerable can represent an arbitrary amount of work. @@ -237,6 +284,35 @@ public override async Task GetResponseAsync( bool lastIterationHadConversationId = false; // whether the last iteration's response had a ConversationId set int consecutiveErrorCount = 0; + (Dictionary? toolMap, bool anyToolsRequireApproval) = CreateToolsMap(AdditionalTools, options?.Tools); // all available tools, indexed by name + + if (HasAnyApprovalContent(originalMessages)) + { + // A previous turn may have translated FunctionCallContents from the inner client into approval requests sent back to the caller, + // for any AIFunctions that were actually ApprovalRequiredAIFunctions. If the incoming chat messages include responses to those + // approval requests, we need to process them now. This entails removing these manufactured approval requests from the chat message + // list and replacing them with the appropriate FunctionCallContents and FunctionResultContents that would have been generated if + // the inner client had returned them directly. + (responseMessages, var notInvokedApprovals) = ProcessFunctionApprovalResponses( + originalMessages, !string.IsNullOrWhiteSpace(options?.ConversationId), toolMessageId: null, functionCallContentFallbackMessageId: null); + (IList? invokedApprovedFunctionApprovalResponses, bool shouldTerminate, consecutiveErrorCount) = + await InvokeApprovedFunctionApprovalResponsesAsync(notInvokedApprovals, toolMap, originalMessages, options, consecutiveErrorCount, isStreaming: false, cancellationToken); + + if (invokedApprovedFunctionApprovalResponses is not null) + { + // Add any generated FRCs to the list we'll return to callers as part of the next response. + (responseMessages ??= []).AddRange(invokedApprovedFunctionApprovalResponses); + } + + if (shouldTerminate) + { + return new ChatResponse(responseMessages); + } + } + + // At this point, we've fully handled all approval responses that were part of the original messages, + // and we can now enter the main function calling loop. + for (int iteration = 0; ; iteration++) { functionCallContents?.Clear(); @@ -248,16 +324,31 @@ public override async Task GetResponseAsync( Throw.InvalidOperationException($"The inner {nameof(IChatClient)} returned a null {nameof(ChatResponse)}."); } + // Before we do any function execution, make sure that any functions that require approval have been turned into + // approval requests so that they don't get executed here. + if (anyToolsRequireApproval) + { + Debug.Assert(toolMap is not null, "anyToolsRequireApproval can only be true if there are tools"); + response.Messages = ReplaceFunctionCallsWithApprovalRequests(response.Messages, toolMap!); + } + // Any function call work to do? If yes, ensure we're tracking that work in functionCallContents. bool requiresFunctionInvocation = - options?.Tools is { Count: > 0 } && iteration < MaximumIterationsPerRequest && CopyFunctionCalls(response.Messages, ref functionCallContents); - // In a common case where we make a request and there's no function calling work required, - // fast path out by just returning the original response. - if (iteration == 0 && !requiresFunctionInvocation) + if (!requiresFunctionInvocation && iteration == 0) { + // In a common case where we make an initial request and there's no function calling work required, + // fast path out by just returning the original response. We may already have some messages + // in responseMessages from processing function approval responses, and we need to ensure + // those are included in the final response, too. + if (responseMessages is { Count: > 0 }) + { + responseMessages.AddRange(response.Messages); + response.Messages = responseMessages; + } + return response; } @@ -275,10 +366,10 @@ public override async Task GetResponseAsync( } } - // If there are no tools to call, or for any other reason we should stop, we're done. - // Break out of the loop and allow the handling at the end to configure the response - // with aggregated data from previous requests. - if (!requiresFunctionInvocation) + // If there's nothing more to do, break out of the loop and allow the handling at the + // end to configure the response with aggregated data from previous requests. + if (!requiresFunctionInvocation || + ShouldTerminateLoopBasedOnHandleableFunctions(functionCallContents, toolMap)) { break; } @@ -288,7 +379,7 @@ public override async Task GetResponseAsync( // Add the responses from the function calls into the augmented history and also into the tracked // list of response messages. - var modeAndMessages = await ProcessFunctionCallsAsync(augmentedHistory, options!, functionCallContents!, iteration, consecutiveErrorCount, isStreaming: false, cancellationToken); + var modeAndMessages = await ProcessFunctionCallsAsync(augmentedHistory, options, toolMap, functionCallContents!, iteration, consecutiveErrorCount, isStreaming: false, cancellationToken); responseMessages.AddRange(modeAndMessages.MessagesAdded); consecutiveErrorCount = modeAndMessages.NewConsecutiveErrorCount; @@ -297,7 +388,7 @@ public override async Task GetResponseAsync( break; } - UpdateOptionsForNextIteration(ref options!, response.ConversationId); + UpdateOptionsForNextIteration(ref options, response.ConversationId); } Debug.Assert(responseMessages is not null, "Expected to only be here if we have response messages."); @@ -317,7 +408,7 @@ public override async IAsyncEnumerable GetStreamingResponseA // A single request into this GetStreamingResponseAsync may result in multiple requests to the inner client. // Create an activity to group them together for better observability. - using Activity? activity = _activitySource?.StartActivity($"{nameof(FunctionInvokingChatClient)}.{nameof(GetStreamingResponseAsync)}"); + using Activity? activity = _activitySource?.StartActivity(OpenTelemetryConsts.GenAI.OrchestrateToolsName); UsageDetails? totalUsage = activity is { IsAllDataRequested: true } ? new() : null; // tracked usage across all turns, to be used for activity purposes // Copy the original messages in order to avoid enumerating the original messages multiple times. @@ -325,6 +416,7 @@ public override async IAsyncEnumerable GetStreamingResponseA List originalMessages = [.. messages]; messages = originalMessages; + AITool[]? approvalRequiredFunctions = null; // available tools that require approval List? augmentedHistory = null; // the actual history of messages sent on turns other than the first List? functionCallContents = null; // function call contents that need responding to in the current turn List? responseMessages = null; // tracked list of messages, across multiple turns, to be used in fallback cases to reconstitute history @@ -332,11 +424,69 @@ public override async IAsyncEnumerable GetStreamingResponseA List updates = []; // updates from the current response int consecutiveErrorCount = 0; + (Dictionary? toolMap, bool anyToolsRequireApproval) = CreateToolsMap(AdditionalTools, options?.Tools); // all available tools, indexed by name + + // This is a synthetic ID since we're generating the tool messages instead of getting them from + // the underlying provider. When emitting the streamed chunks, it's perfectly valid for us to + // use the same message ID for all of them within a given iteration, as this is a single logical + // message with multiple content items. We could also use different message IDs per tool content, + // but there's no benefit to doing so. + string toolMessageId = Guid.NewGuid().ToString("N"); + + if (HasAnyApprovalContent(originalMessages)) + { + // We also need a synthetic ID for the function call content for approved function calls + // where we don't know what the original message id of the function call was. + string functionCallContentFallbackMessageId = Guid.NewGuid().ToString("N"); + + // A previous turn may have translated FunctionCallContents from the inner client into approval requests sent back to the caller, + // for any AIFunctions that were actually ApprovalRequiredAIFunctions. If the incoming chat messages include responses to those + // approval requests, we need to process them now. This entails removing these manufactured approval requests from the chat message + // list and replacing them with the appropriate FunctionCallContents and FunctionResultContents that would have been generated if + // the inner client had returned them directly. + var (preDownstreamCallHistory, notInvokedApprovals) = ProcessFunctionApprovalResponses( + originalMessages, !string.IsNullOrWhiteSpace(options?.ConversationId), toolMessageId, functionCallContentFallbackMessageId); + if (preDownstreamCallHistory is not null) + { + foreach (var message in preDownstreamCallHistory) + { + yield return ConvertToolResultMessageToUpdate(message, options?.ConversationId, message.MessageId); + Activity.Current = activity; // workaround for https://github.com/dotnet/runtime/issues/47802 + } + } + + // Invoke approved approval responses, which generates some additional FRC wrapped in ChatMessage. + (IList? invokedApprovedFunctionApprovalResponses, bool shouldTerminate, consecutiveErrorCount) = + await InvokeApprovedFunctionApprovalResponsesAsync(notInvokedApprovals, toolMap, originalMessages, options, consecutiveErrorCount, isStreaming: true, cancellationToken); + + if (invokedApprovedFunctionApprovalResponses is not null) + { + foreach (var message in invokedApprovedFunctionApprovalResponses) + { + message.MessageId = toolMessageId; + yield return ConvertToolResultMessageToUpdate(message, options?.ConversationId, message.MessageId); + Activity.Current = activity; // workaround for https://github.com/dotnet/runtime/issues/47802 + } + + if (shouldTerminate) + { + yield break; + } + } + } + + // At this point, we've fully handled all approval responses that were part of the original messages, + // and we can now enter the main function calling loop. + for (int iteration = 0; ; iteration++) { updates.Clear(); functionCallContents?.Clear(); + bool hasApprovalRequiringFcc = false; + int lastApprovalCheckedFCCIndex = 0; + int lastYieldedUpdateIndex = 0; + await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken)) { if (update is null) @@ -361,18 +511,88 @@ public override async IAsyncEnumerable GetStreamingResponseA } } - yield return update; + // We're streaming updates back to the caller. However, approvals requires extra handling. We should not yield any + // FunctionCallContents back to the caller if approvals might be required, because if any actually are, we need to convert + // all FunctionCallContents into approval requests, even those that don't require approval (we otherwise don't have a way + // to track the FCCs to a later turn, in particular when the conversation history is managed by the service / inner client). + // So, if there are no functions that need approval, we can yield updates with FCCs as they arrive. But if any FCC _might_ + // require approval (which just means that any AIFunction we can possibly invoke requires approval), then we need to hold off + // on yielding any FCCs until we know whether any of them actually require approval, which is either at the end of the stream + // or the first time we get an FCC that requires approval. At that point, we can yield all of the updates buffered thus far + // and anything further, replacing FCCs with approval if any required it, or yielding them as is. + if (anyToolsRequireApproval && approvalRequiredFunctions is null && functionCallContents is { Count: > 0 }) + { + approvalRequiredFunctions = + (options?.Tools ?? Enumerable.Empty()) + .Concat(AdditionalTools ?? Enumerable.Empty()) + .Where(t => t.GetService() is not null) + .ToArray(); + } + + if (approvalRequiredFunctions is not { Length: > 0 } || functionCallContents is not { Count: > 0 }) + { + // If there are no function calls to make yet, or if none of the functions require approval at all, + // we can yield the update as-is. + lastYieldedUpdateIndex++; + yield return update; + Activity.Current = activity; // workaround for https://github.com/dotnet/runtime/issues/47802 + + continue; + } + + // There are function calls to make, some of which _may_ require approval. + Debug.Assert(functionCallContents is { Count: > 0 }, "Expected to have function call contents to check for approval requiring functions."); + Debug.Assert(approvalRequiredFunctions is { Length: > 0 }, "Expected to have approval requiring functions to check against function call contents."); + + // Check if any of the function call contents in this update requires approval. + (hasApprovalRequiringFcc, lastApprovalCheckedFCCIndex) = CheckForApprovalRequiringFCC( + functionCallContents, approvalRequiredFunctions!, hasApprovalRequiringFcc, lastApprovalCheckedFCCIndex); + if (hasApprovalRequiringFcc) + { + // If we've encountered a function call content that requires approval, + // we need to ask for approval for all functions, since we cannot mix and match. + // Convert all function call contents into approval requests from the last yielded update index + // and yield all those updates. + for (; lastYieldedUpdateIndex < updates.Count; lastYieldedUpdateIndex++) + { + var updateToYield = updates[lastYieldedUpdateIndex]; + if (TryReplaceFunctionCallsWithApprovalRequests(updateToYield.Contents, out var updatedContents)) + { + updateToYield.Contents = updatedContents; + } + + yield return updateToYield; + Activity.Current = activity; // workaround for https://github.com/dotnet/runtime/issues/47802 + } + + continue; + } + + // We don't have any approval requiring function calls yet, but we may receive some in future + // so we cannot yield the updates yet. We'll just keep them in the updates list for later. + // We will yield the updates as soon as we receive a function call content that requires approval + // or when we reach the end of the updates stream. + } + + // We need to yield any remaining updates that were not yielded while looping through the streamed updates. + for (; lastYieldedUpdateIndex < updates.Count; lastYieldedUpdateIndex++) + { + var updateToYield = updates[lastYieldedUpdateIndex]; + yield return updateToYield; Activity.Current = activity; // workaround for https://github.com/dotnet/runtime/issues/47802 } - // If there are no tools to call, or for any other reason we should stop, return the response. - if (functionCallContents is not { Count: > 0 } || - options?.Tools is not { Count: > 0 } || - iteration >= _maximumIterationsPerRequest) + // If there's nothing more to do, break out of the loop and allow the handling at the + // end to configure the response with aggregated data from previous requests. + if (iteration >= MaximumIterationsPerRequest || + hasApprovalRequiringFcc || + ShouldTerminateLoopBasedOnHandleableFunctions(functionCallContents, toolMap)) { break; } + // We need to invoke functions. + // Reconstitute a response from the response updates. var response = updates.ToChatResponse(); (responseMessages ??= []).AddRange(response.Messages); @@ -381,35 +601,15 @@ public override async IAsyncEnumerable GetStreamingResponseA FixupHistories(originalMessages, ref messages, ref augmentedHistory, response, responseMessages, ref lastIterationHadConversationId); // Process all of the functions, adding their results into the history. - var modeAndMessages = await ProcessFunctionCallsAsync(augmentedHistory, options, functionCallContents, iteration, consecutiveErrorCount, isStreaming: true, cancellationToken); + var modeAndMessages = await ProcessFunctionCallsAsync(augmentedHistory, options, toolMap, functionCallContents!, iteration, consecutiveErrorCount, isStreaming: true, cancellationToken); responseMessages.AddRange(modeAndMessages.MessagesAdded); consecutiveErrorCount = modeAndMessages.NewConsecutiveErrorCount; - // This is a synthetic ID since we're generating the tool messages instead of getting them from - // the underlying provider. When emitting the streamed chunks, it's perfectly valid for us to - // use the same message ID for all of them within a given iteration, as this is a single logical - // message with multiple content items. We could also use different message IDs per tool content, - // but there's no benefit to doing so. - string toolResponseId = Guid.NewGuid().ToString("N"); - // Stream any generated function results. This mirrors what's done for GetResponseAsync, where the returned messages // includes all activities, including generated function results. foreach (var message in modeAndMessages.MessagesAdded) { - var toolResultUpdate = new ChatResponseUpdate - { - AdditionalProperties = message.AdditionalProperties, - AuthorName = message.AuthorName, - ConversationId = response.ConversationId, - CreatedAt = DateTimeOffset.UtcNow, - Contents = message.Contents, - RawRepresentation = message.RawRepresentation, - ResponseId = toolResponseId, - MessageId = toolResponseId, // See above for why this can be the same as ResponseId - Role = message.Role, - }; - - yield return toolResultUpdate; + yield return ConvertToolResultMessageToUpdate(message, response.ConversationId, toolMessageId); Activity.Current = activity; // workaround for https://github.com/dotnet/runtime/issues/47802 } @@ -424,6 +624,20 @@ public override async IAsyncEnumerable GetStreamingResponseA AddUsageTags(activity, totalUsage); } + private static ChatResponseUpdate ConvertToolResultMessageToUpdate(ChatMessage message, string? conversationId, string? messageId) => + new() + { + AdditionalProperties = message.AdditionalProperties, + AuthorName = message.AuthorName, + ConversationId = conversationId, + CreatedAt = DateTimeOffset.UtcNow, + Contents = message.Contents, + RawRepresentation = message.RawRepresentation, + ResponseId = messageId, + MessageId = messageId, + Role = message.Role, + }; + /// Adds tags to for usage details in . private static void AddUsageTags(Activity? activity, UsageDetails? usage) { @@ -503,6 +717,39 @@ private static void FixupHistories( messages = augmentedHistory; } + /// Creates a mapping from tool names to the corresponding tools. + /// + /// The lists of tools to combine into a single dictionary. Tools from later lists are preferred + /// over tools from earlier lists if they have the same name. + /// + private static (Dictionary? ToolMap, bool AnyRequireApproval) CreateToolsMap(params ReadOnlySpan?> toolLists) + { + Dictionary? map = null; + bool anyRequireApproval = false; + + foreach (var toolList in toolLists) + { + if (toolList?.Count is int count && count > 0) + { + map ??= new(StringComparer.Ordinal); + for (int i = 0; i < count; i++) + { + AITool tool = toolList[i]; + anyRequireApproval |= tool.GetService() is not null; + map[tool.Name] = tool; + } + } + } + + return (map, anyRequireApproval); + } + + /// + /// Gets whether contains any or instances. + /// + private static bool HasAnyApprovalContent(List messages) => + messages.Any(static m => m.Contents.Any(static c => c is FunctionApprovalRequestContent or FunctionApprovalResponseContent)); + /// Copies any from to . private static bool CopyFunctionCalls( IList messages, [NotNullWhen(true)] ref List? functionCalls) @@ -535,9 +782,16 @@ private static bool CopyFunctionCalls( return any; } - private static void UpdateOptionsForNextIteration(ref ChatOptions options, string? conversationId) + private static void UpdateOptionsForNextIteration(ref ChatOptions? options, string? conversationId) { - if (options.ToolMode is RequiredChatToolMode) + if (options is null) + { + if (conversationId is not null) + { + options = new() { ConversationId = conversationId }; + } + } + else if (options.ToolMode is RequiredChatToolMode) { // We have to reset the tool mode to be non-required after the first iteration, // as otherwise we'll be in an infinite loop. @@ -552,6 +806,66 @@ private static void UpdateOptionsForNextIteration(ref ChatOptions options, strin options = options.Clone(); options.ConversationId = conversationId; } + else if (options.ContinuationToken is not null) + { + // Clone options before resetting the continuation token below. + options = options.Clone(); + } + + // Reset the continuation token of a background response operation + // to signal the inner client to handle function call result rather + // than getting the result of the operation. + if (options?.ContinuationToken is not null) + { + options.ContinuationToken = null; + } + } + + /// Gets whether the function calling loop should exit based on the function call requests. + /// The call requests. + /// The map from tool names to tools. + private bool ShouldTerminateLoopBasedOnHandleableFunctions(List? functionCalls, Dictionary? toolMap) + { + if (functionCalls is not { Count: > 0 }) + { + // There are no functions to call, so there's no reason to keep going. + return true; + } + + if (toolMap is not { Count: > 0 }) + { + // There are functions to call but we have no tools, so we can't handle them. + // If we're configured to terminate on unknown call requests, do so now. + // Otherwise, ProcessFunctionCallsAsync will handle it by creating a NotFound response message. + return TerminateOnUnknownCalls; + } + + // At this point, we have both function call requests and some tools. + // Look up each function. + foreach (var fcc in functionCalls) + { + if (toolMap.TryGetValue(fcc.Name, out var tool)) + { + if (tool is not AIFunction) + { + // The tool was found but it's not invocable. Regardless of TerminateOnUnknownCallRequests, + // we need to break out of the loop so that callers can handle all the call requests. + return true; + } + } + else + { + // The tool couldn't be found. If we're configured to terminate on unknown call requests, + // break out of the loop now. Otherwise, ProcessFunctionCallsAsync will handle it by + // creating a NotFound response message. + if (TerminateOnUnknownCalls) + { + return true; + } + } + } + + return false; } /// @@ -559,6 +873,7 @@ private static void UpdateOptionsForNextIteration(ref ChatOptions options, strin /// /// The current chat contents, inclusive of the function call contents being processed. /// The options used for the response being processed. + /// Map from tool name to tool. /// 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 number of consecutive iterations, prior to this one, that were recorded as having function invocation errors. @@ -566,7 +881,8 @@ private static void UpdateOptionsForNextIteration(ref ChatOptions options, strin /// The to monitor for cancellation requests. /// A value indicating how the caller should proceed. private async Task<(bool ShouldTerminate, int NewConsecutiveErrorCount, IList MessagesAdded)> ProcessFunctionCallsAsync( - List messages, ChatOptions options, List functionCallContents, int iteration, int consecutiveErrorCount, + List messages, ChatOptions? options, + Dictionary? toolMap, List functionCallContents, int iteration, int consecutiveErrorCount, bool isStreaming, CancellationToken cancellationToken) { // We must add a response for every tool call, regardless of whether we successfully executed it or not. @@ -574,13 +890,13 @@ private static void UpdateOptionsForNextIteration(ref ChatOptions options, strin Debug.Assert(functionCallContents.Count > 0, "Expected at least one function call."); var shouldTerminate = false; - var captureCurrentIterationExceptions = consecutiveErrorCount < _maximumConsecutiveErrorsPerRequest; + var captureCurrentIterationExceptions = consecutiveErrorCount < MaximumConsecutiveErrorsPerRequest; // Process all functions. If there's more than one and concurrent invocation is enabled, do so in parallel. if (functionCallContents.Count == 1) { FunctionInvocationResult result = await ProcessFunctionCallAsync( - messages, options, functionCallContents, + messages, options, toolMap, functionCallContents, iteration, 0, captureCurrentIterationExceptions, isStreaming, cancellationToken); IList addedMessages = CreateResponseMessages([result]); @@ -603,7 +919,7 @@ private static void UpdateOptionsForNextIteration(ref ChatOptions options, strin results.AddRange(await Task.WhenAll( from callIndex in Enumerable.Range(0, functionCallContents.Count) select ProcessFunctionCallAsync( - messages, options, functionCallContents, + messages, options, toolMap, functionCallContents, iteration, callIndex, captureExceptions: true, isStreaming, cancellationToken))); shouldTerminate = results.Any(r => r.Terminate); @@ -614,7 +930,7 @@ select ProcessFunctionCallAsync( for (int callIndex = 0; callIndex < functionCallContents.Count; callIndex++) { var functionResult = await ProcessFunctionCallAsync( - messages, options, functionCallContents, + messages, options, toolMap, functionCallContents, iteration, callIndex, captureCurrentIterationExceptions, isStreaming, cancellationToken); results.Add(functionResult); @@ -637,7 +953,6 @@ select ProcessFunctionCallAsync( } } -#pragma warning disable CA1851 // Possible multiple enumerations of 'IEnumerable' collection /// /// Updates the consecutive error count, and throws an exception if the count exceeds the maximum. /// @@ -646,24 +961,23 @@ select ProcessFunctionCallAsync( /// Thrown if the maximum consecutive error count is exceeded. private void UpdateConsecutiveErrorCountOrThrow(IList added, ref int consecutiveErrorCount) { - var allExceptions = added.SelectMany(m => m.Contents.OfType()) - .Select(frc => frc.Exception!) - .Where(e => e is not null); - - if (allExceptions.Any()) + if (added.Any(static m => m.Contents.Any(static c => c is FunctionResultContent { Exception: not null }))) { consecutiveErrorCount++; - if (consecutiveErrorCount > _maximumConsecutiveErrorsPerRequest) + if (consecutiveErrorCount > MaximumConsecutiveErrorsPerRequest) { - var allExceptionsArray = allExceptions.ToArray(); + var allExceptionsArray = added + .SelectMany(m => m.Contents.OfType()) + .Select(frc => frc.Exception!) + .Where(e => e is not null) + .ToArray(); + if (allExceptionsArray.Length == 1) { ExceptionDispatchInfo.Capture(allExceptionsArray[0]).Throw(); } - else - { - throw new AggregateException(allExceptionsArray); - } + + throw new AggregateException(allExceptionsArray); } } else @@ -671,14 +985,13 @@ private void UpdateConsecutiveErrorCountOrThrow(IList added, ref in consecutiveErrorCount = 0; } } -#pragma warning restore CA1851 /// /// Throws an exception if doesn't create any messages. /// private void ThrowIfNoFunctionResultsAdded(IList? messages) { - if (messages is null || messages.Count == 0) + if (messages is not { Count: > 0 }) { Throw.InvalidOperationException($"{GetType().Name}.{nameof(CreateResponseMessages)} returned null or an empty collection of messages."); } @@ -687,6 +1000,7 @@ private void ThrowIfNoFunctionResultsAdded(IList? messages) /// 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. + /// Map from tool name to tool. /// The function call contents representing all the functions being 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 . @@ -695,14 +1009,16 @@ private void ThrowIfNoFunctionResultsAdded(IList? messages) /// The to monitor for cancellation requests. /// A value indicating how the caller should proceed. private async Task ProcessFunctionCallAsync( - List messages, ChatOptions options, List callContents, + List messages, ChatOptions? options, + Dictionary? toolMap, List callContents, int iteration, int functionCallIndex, bool captureExceptions, bool isStreaming, CancellationToken cancellationToken) { var callContent = callContents[functionCallIndex]; // Look up the AIFunction for the function call. If the requested function isn't available, send back an error. - AIFunction? aiFunction = options.Tools!.OfType().FirstOrDefault(t => t.Name == callContent.Name); - if (aiFunction is null) + if (toolMap is null || + !toolMap.TryGetValue(callContent.Name, out AITool? tool) || + tool is not AIFunction aiFunction) { return new(terminate: false, FunctionInvocationStatus.NotFound, callContent, result: null, exception: null); } @@ -804,30 +1120,43 @@ FunctionResultContent CreateFunctionResultContent(FunctionInvocationResult resul _ = Throw.IfNull(context); using Activity? activity = _activitySource?.StartActivity( - $"{OpenTelemetryConsts.GenAI.ExecuteTool} {context.Function.Name}", + $"{OpenTelemetryConsts.GenAI.ExecuteToolName} {context.Function.Name}", ActivityKind.Internal, default(ActivityContext), [ - new(OpenTelemetryConsts.GenAI.Operation.Name, "execute_tool"), + new(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.ExecuteToolName), + new(OpenTelemetryConsts.GenAI.Tool.Type, OpenTelemetryConsts.ToolTypeFunction), new(OpenTelemetryConsts.GenAI.Tool.Call.Id, context.CallContent.CallId), new(OpenTelemetryConsts.GenAI.Tool.Name, context.Function.Name), new(OpenTelemetryConsts.GenAI.Tool.Description, context.Function.Description), ]); - long startingTimestamp = 0; - if (_logger.IsEnabled(LogLevel.Debug)) + long startingTimestamp = Stopwatch.GetTimestamp(); + + bool enableSensitiveData = activity is { IsAllDataRequested: true } && InnerClient.GetService()?.EnableSensitiveData is true; + bool traceLoggingEnabled = _logger.IsEnabled(LogLevel.Trace); + bool loggedInvoke = false; + if (enableSensitiveData || traceLoggingEnabled) { - startingTimestamp = Stopwatch.GetTimestamp(); - if (_logger.IsEnabled(LogLevel.Trace)) + string functionArguments = TelemetryHelpers.AsJson(context.Arguments, context.Function.JsonSerializerOptions); + + if (enableSensitiveData) { - LogInvokingSensitive(context.Function.Name, LoggingHelpers.AsJson(context.Arguments, context.Function.JsonSerializerOptions)); + _ = activity?.SetTag(OpenTelemetryConsts.GenAI.Tool.Call.Arguments, functionArguments); } - else + + if (traceLoggingEnabled) { - LogInvoking(context.Function.Name); + LogInvokingSensitive(context.Function.Name, functionArguments); + loggedInvoke = true; } } + if (!loggedInvoke && _logger.IsEnabled(LogLevel.Debug)) + { + LogInvoking(context.Function.Name); + } + object? result = null; try { @@ -838,7 +1167,7 @@ FunctionResultContent CreateFunctionResultContent(FunctionInvocationResult resul { if (activity is not null) { - _ = activity.SetTag("error.type", e.GetType().FullName) + _ = activity.SetTag(OpenTelemetryConsts.Error.Type, e.GetType().FullName) .SetStatus(ActivityStatusCode.Error, e.Message); } @@ -855,19 +1184,27 @@ FunctionResultContent CreateFunctionResultContent(FunctionInvocationResult resul } finally { - if (_logger.IsEnabled(LogLevel.Debug)) + bool loggedResult = false; + if (enableSensitiveData || traceLoggingEnabled) { - TimeSpan elapsed = GetElapsedTime(startingTimestamp); + string functionResult = TelemetryHelpers.AsJson(result, context.Function.JsonSerializerOptions); - if (result is not null && _logger.IsEnabled(LogLevel.Trace)) + if (enableSensitiveData) { - LogInvocationCompletedSensitive(context.Function.Name, elapsed, LoggingHelpers.AsJson(result, context.Function.JsonSerializerOptions)); + _ = activity?.SetTag(OpenTelemetryConsts.GenAI.Tool.Call.Result, functionResult); } - else + + if (traceLoggingEnabled) { - LogInvocationCompleted(context.Function.Name, elapsed); + LogInvocationCompletedSensitive(context.Function.Name, GetElapsedTime(startingTimestamp), functionResult); + loggedResult = true; } } + + if (!loggedResult && _logger.IsEnabled(LogLevel.Debug)) + { + LogInvocationCompleted(context.Function.Name, GetElapsedTime(startingTimestamp)); + } } return result; @@ -886,6 +1223,383 @@ FunctionResultContent CreateFunctionResultContent(FunctionInvocationResult resul context.Function.InvokeAsync(context.Arguments, cancellationToken); } + /// + /// 1. Remove all and from the . + /// 2. Recreate for any that haven't been executed yet. + /// 3. Genreate failed for any rejected . + /// 4. add all the new content items to and return them as the pre-invocation history. + /// + private static (List? preDownstreamCallHistory, List? approvals) ProcessFunctionApprovalResponses( + List originalMessages, bool hasConversationId, string? toolMessageId, string? functionCallContentFallbackMessageId) + { + // Extract any approval responses where we need to execute or reject the function calls. + // The original messages are also modified to remove all approval requests and responses. + var notInvokedResponses = ExtractAndRemoveApprovalRequestsAndResponses(originalMessages); + + // Wrap the function call content in message(s). + ICollection? allPreDownstreamCallMessages = ConvertToFunctionCallContentMessages( + [.. notInvokedResponses.rejections ?? Enumerable.Empty(), .. notInvokedResponses.approvals ?? Enumerable.Empty()], + functionCallContentFallbackMessageId); + + // Generate failed function result contents for any rejected requests and wrap it in a message. + List? rejectedFunctionCallResults = GenerateRejectedFunctionResults(notInvokedResponses.rejections); + ChatMessage? rejectedPreDownstreamCallResultsMessage = rejectedFunctionCallResults is not null ? + new ChatMessage(ChatRole.Tool, rejectedFunctionCallResults) { MessageId = toolMessageId } : + null; + + // Add all the FCC that we generated to the pre-downstream-call history so that they can be returned to the caller as part of the next response. + // Also, if we are not dealing with a service thread (i.e. we don't have a conversation ID), add them + // into the original messages list so that they are passed to the inner client and can be used to generate a result. + List? preDownstreamCallHistory = null; + if (allPreDownstreamCallMessages is not null) + { + preDownstreamCallHistory = [.. allPreDownstreamCallMessages]; + if (!hasConversationId) + { + originalMessages.AddRange(preDownstreamCallHistory); + } + } + + // Add all the FRC that we generated to the pre-downstream-call history so that they can be returned to the caller as part of the next response. + // Also, add them into the original messages list so that they are passed to the inner client and can be used to generate a result. + if (rejectedPreDownstreamCallResultsMessage is not null) + { + (preDownstreamCallHistory ??= []).Add(rejectedPreDownstreamCallResultsMessage); + originalMessages.Add(rejectedPreDownstreamCallResultsMessage); + } + + return (preDownstreamCallHistory, notInvokedResponses.approvals); + } + + /// + /// This method extracts the approval requests and responses from the provided list of messages, + /// validates them, filters them to ones that require execution, and splits them into approved and rejected. + /// + /// + /// We return the messages containing the approval requests since these are the same messages that originally contained the FunctionCallContent from the downstream service. + /// We can then use the metadata from these messages when we re-create the FunctionCallContent messages/updates to return to the caller. This way, when we finally do return + /// the FuncionCallContent to users it's part of a message/update that contains the same metadata as originally returned to the downstream service. + /// + private static (List? approvals, List? rejections) ExtractAndRemoveApprovalRequestsAndResponses( + List messages) + { + Dictionary? allApprovalRequestsMessages = null; + List? allApprovalResponses = null; + HashSet? approvalRequestCallIds = null; + HashSet? functionResultCallIds = null; + + // 1st iteration, over all messages and content: + // - Build a list of all function call ids that are already executed. + // - Build a list of all function approval requests and responses. + // - Build a list of the content we want to keep (everything except approval requests and responses) and create a new list of messages for those. + // - Validate that we have an approval response for each approval request. + bool anyRemoved = false; + int i = 0; + for (; i < messages.Count; i++) + { + var message = messages[i]; + + List? keptContents = null; + + // Examine all content to populate our various collections. + for (int j = 0; j < message.Contents.Count; j++) + { + var content = message.Contents[j]; + switch (content) + { + case FunctionApprovalRequestContent farc: + // Validation: Capture each call id for each approval request to ensure later we have a matching response. + _ = (approvalRequestCallIds ??= []).Add(farc.FunctionCall.CallId); + (allApprovalRequestsMessages ??= []).Add(farc.Id, message); + break; + + case FunctionApprovalResponseContent farc: + // Validation: Remove the call id for each approval response, to check it off the list of requests we need responses for. + _ = approvalRequestCallIds?.Remove(farc.FunctionCall.CallId); + (allApprovalResponses ??= []).Add(farc); + break; + + case FunctionResultContent frc: + // Maintain a list of function calls that have already been invoked to avoid invoking them twice. + _ = (functionResultCallIds ??= []).Add(frc.CallId); + goto default; + + default: + // Content to keep. + (keptContents ??= []).Add(content); + break; + } + } + + // If any contents were filtered out, we need to either remove the message entirely (if no contents remain) or create a new message with the filtered contents. + if (keptContents?.Count != message.Contents.Count) + { + if (keptContents is { Count: > 0 }) + { + // Create a new replacement message to store the filtered contents. + var newMessage = message.Clone(); + newMessage.Contents = keptContents; + messages[i] = newMessage; + } + else + { + // Remove the message entirely since it has no contents left. Rather than doing an O(N) removal, which could possibly + // result in an O(N^2) overall operation, we mark the message as null and then do a single pass removal of all nulls after the loop. + anyRemoved = true; + messages[i] = null!; + } + } + } + + // Clean up any messages that were marked for removal during the iteration. + if (anyRemoved) + { + _ = messages.RemoveAll(static m => m is null); + } + + // Validation: If we got an approval for each request, we should have no call ids left. + if (approvalRequestCallIds is { Count: > 0 }) + { + Throw.InvalidOperationException( + $"FunctionApprovalRequestContent found with FunctionCall.CallId(s) '{string.Join(", ", approvalRequestCallIds)}' that have no matching FunctionApprovalResponseContent."); + } + + // 2nd iteration, over all approval responses: + // - Filter out any approval responses that already have a matching function result (i.e. already executed). + // - Find the matching function approval request for any response (where available). + // - Split the approval responses into two lists: approved and rejected, with their request messages (where available). + List? approvedFunctionCalls = null, rejectedFunctionCalls = null; + if (allApprovalResponses is { Count: > 0 }) + { + foreach (var approvalResponse in allApprovalResponses) + { + // Skip any approval responses that have already been processed. + if (functionResultCallIds?.Contains(approvalResponse.FunctionCall.CallId) is true) + { + continue; + } + + // Split the responses into approved and rejected. + ref List? targetList = ref approvalResponse.Approved ? ref approvedFunctionCalls : ref rejectedFunctionCalls; + + ChatMessage? requestMessage = null; + _ = allApprovalRequestsMessages?.TryGetValue(approvalResponse.FunctionCall.CallId, out requestMessage); + + (targetList ??= []).Add(new() { Response = approvalResponse, RequestMessage = requestMessage }); + } + } + + return (approvedFunctionCalls, rejectedFunctionCalls); + } + + /// + /// If we have any rejected approval responses, we need to generate failed function results for them. + /// + /// Any rejected approval responses. + /// The for the rejected function calls. + private static List? GenerateRejectedFunctionResults(List? rejections) => + rejections is { Count: > 0 } ? + rejections.ConvertAll(static m => (AIContent)new FunctionResultContent(m.Response.FunctionCall.CallId, "Error: Tool call invocation was rejected by user.")) : + null; + + /// + /// Extracts the from the provided to recreate the original function call messages. + /// The output messages tries to mimic the original messages that contained the , e.g. if the + /// had been split into separate messages, this method will recreate similarly split messages, each with their own . + /// + private static ICollection? ConvertToFunctionCallContentMessages( + List? resultWithRequestMessages, string? fallbackMessageId) + { + if (resultWithRequestMessages is not null) + { + ChatMessage? currentMessage = null; + Dictionary? messagesById = null; + + foreach (var resultWithRequestMessage in resultWithRequestMessages) + { + // Don't need to create a dictionary if we already have one or if it's the first iteration. + if (messagesById is null && currentMessage is not null + + // Everywhere we have no RequestMessage we use the fallbackMessageId, so in this case there is only one message. + && !(resultWithRequestMessage.RequestMessage is null && currentMessage.MessageId == fallbackMessageId) + + // Where we do have a RequestMessage, we can check if its message id differs from the current one. + && (resultWithRequestMessage.RequestMessage is not null && currentMessage.MessageId != resultWithRequestMessage.RequestMessage.MessageId)) + { + // The majority of the time, all FCC would be part of a single message, so no need to create a dictionary for this case. + // If we are dealing with multiple messages though, we need to keep track of them by their message ID. + messagesById = []; + messagesById[currentMessage.MessageId ?? string.Empty] = currentMessage; + } + + _ = messagesById?.TryGetValue(resultWithRequestMessage.RequestMessage?.MessageId ?? string.Empty, out currentMessage); + + if (currentMessage is null) + { + currentMessage = ConvertToFunctionCallContentMessage(resultWithRequestMessage, fallbackMessageId); + } + else + { + currentMessage.Contents.Add(resultWithRequestMessage.Response.FunctionCall); + } + +#pragma warning disable IDE0058 // Temporary workaround for Roslyn analyzer issue (see https://github.com/dotnet/roslyn/issues/80499) + messagesById?[currentMessage.MessageId ?? string.Empty] = currentMessage; +#pragma warning restore IDE0058 + } + + if (messagesById?.Values is ICollection cm) + { + return cm; + } + + if (currentMessage is not null) + { + return [currentMessage]; + } + } + + return null; + } + + /// + /// Takes the from the and wraps it in a + /// using the same message id that the was originally returned with from the downstream . + /// + private static ChatMessage ConvertToFunctionCallContentMessage(ApprovalResultWithRequestMessage resultWithRequestMessage, string? fallbackMessageId) + { + ChatMessage functionCallMessage = resultWithRequestMessage.RequestMessage?.Clone() ?? new() { Role = ChatRole.Assistant }; + functionCallMessage.Contents = [resultWithRequestMessage.Response.FunctionCall]; + functionCallMessage.MessageId ??= fallbackMessageId; + return functionCallMessage; + } + + /// + /// Check if any of the provided require approval. + /// Supports checking from a provided index up to the end of the list, to allow efficient incremental checking + /// when streaming. + /// + private static (bool hasApprovalRequiringFcc, int lastApprovalCheckedFCCIndex) CheckForApprovalRequiringFCC( + List? functionCallContents, + AITool[] approvalRequiredFunctions, + bool hasApprovalRequiringFcc, + int lastApprovalCheckedFCCIndex) + { + // If we already found an approval requiring FCC, we can skip checking the rest. + if (hasApprovalRequiringFcc) + { + Debug.Assert(functionCallContents is not null, "functionCallContents must not be null here, since we have already encountered approval requiring functionCallContents"); + return (true, functionCallContents!.Count); + } + + if (functionCallContents is not null) + { + for (; lastApprovalCheckedFCCIndex < functionCallContents.Count; lastApprovalCheckedFCCIndex++) + { + var fcc = functionCallContents![lastApprovalCheckedFCCIndex]; + foreach (var arf in approvalRequiredFunctions) + { + if (arf.Name == fcc.Name) + { + hasApprovalRequiringFcc = true; + break; + } + } + } + } + + return (hasApprovalRequiringFcc, lastApprovalCheckedFCCIndex); + } + + /// + /// Replaces all with and ouputs a new list if any of them were replaced. + /// + /// true if any was replaced, false otherwise. + private static bool TryReplaceFunctionCallsWithApprovalRequests(IList content, out List? updatedContent) + { + updatedContent = null; + + if (content is { Count: > 0 }) + { + for (int i = 0; i < content.Count; i++) + { + if (content[i] is FunctionCallContent fcc) + { + updatedContent ??= [.. content]; // Clone the list if we haven't already + updatedContent[i] = new FunctionApprovalRequestContent(fcc.CallId, fcc); + } + } + } + + return updatedContent is not null; + } + + /// + /// Replaces all from with + /// if any one of them requires approval. + /// + private static IList ReplaceFunctionCallsWithApprovalRequests( + IList messages, + Dictionary toolMap) + { + var outputMessages = messages; + + bool anyApprovalRequired = false; + List<(int, int)>? allFunctionCallContentIndices = null; + + // Build a list of the indices of all FunctionCallContent items. + // Also check if any of them require approval. + for (int i = 0; i < messages.Count; i++) + { + var content = messages[i].Contents; + for (int j = 0; j < content.Count; j++) + { + if (content[j] is FunctionCallContent functionCall) + { + (allFunctionCallContentIndices ??= []).Add((i, j)); + + if (!anyApprovalRequired) + { + foreach (var t in toolMap) + { + if (t.Value.GetService() is { } araf && araf.Name == functionCall.Name) + { + anyApprovalRequired = true; + break; + } + } + } + } + } + } + + // If any function calls were found, and any of them required approval, we should replace all of them with approval requests. + // This is because we do not have a way to deal with cases where some function calls require approval and others do not, so we just replace all of them. + if (anyApprovalRequired) + { + Debug.Assert(allFunctionCallContentIndices is not null, "We have already encountered function call contents that require approval."); + + // Clone the list so, we don't mutate the input. + outputMessages = [.. messages]; + int lastMessageIndex = -1; + + foreach (var (messageIndex, contentIndex) in allFunctionCallContentIndices!) + { + // Clone the message if we didn't already clone it in a previous iteration. + var message = lastMessageIndex != messageIndex ? outputMessages[messageIndex].Clone() : outputMessages[messageIndex]; + message.Contents = [.. message.Contents]; + + var functionCall = (FunctionCallContent)message.Contents[contentIndex]; + message.Contents[contentIndex] = new FunctionApprovalRequestContent(functionCall.CallId, functionCall); + outputMessages[messageIndex] = message; + + lastMessageIndex = messageIndex; + } + } + + return outputMessages; + } + private static TimeSpan GetElapsedTime(long startingTimestamp) => #if NET Stopwatch.GetElapsedTime(startingTimestamp); @@ -893,6 +1607,33 @@ private static TimeSpan GetElapsedTime(long startingTimestamp) => new((long)((Stopwatch.GetTimestamp() - startingTimestamp) * ((double)TimeSpan.TicksPerSecond / Stopwatch.Frequency))); #endif + /// + /// Execute the provided and return the resulting + /// wrapped in objects. + /// + private async Task<(IList? FunctionResultContentMessages, bool ShouldTerminate, int ConsecutiveErrorCount)> InvokeApprovedFunctionApprovalResponsesAsync( + List? notInvokedApprovals, + Dictionary? toolMap, + List originalMessages, + ChatOptions? options, + int consecutiveErrorCount, + bool isStreaming, + CancellationToken cancellationToken) + { + // Check if there are any function calls to do for any approved functions and execute them. + if (notInvokedApprovals is { Count: > 0 }) + { + // The FRC that is generated here is already added to originalMessages by ProcessFunctionCallsAsync. + var modeAndMessages = await ProcessFunctionCallsAsync( + originalMessages, options, toolMap, notInvokedApprovals.Select(x => x.Response.FunctionCall).ToList(), 0, consecutiveErrorCount, isStreaming, cancellationToken); + consecutiveErrorCount = modeAndMessages.NewConsecutiveErrorCount; + + return (modeAndMessages.MessagesAdded, modeAndMessages.ShouldTerminate, consecutiveErrorCount); + } + + return (null, false, consecutiveErrorCount); + } + [LoggerMessage(LogLevel.Debug, "Invoking {MethodName}.", SkipEnabledCheck = true)] private partial void LogInvoking(string methodName); @@ -959,4 +1700,10 @@ public enum FunctionInvocationStatus /// The function call failed with an exception. Exception, } + + private struct ApprovalResultWithRequestMessage + { + public FunctionApprovalResponseContent Response { get; set; } + public ChatMessage? RequestMessage { get; set; } + } } diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ImageGeneratingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ImageGeneratingChatClient.cs new file mode 100644 index 00000000000..436adeb2295 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ImageGeneratingChatClient.cs @@ -0,0 +1,518 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// A delegating chat client that enables image generation capabilities by converting instances to function tools. +/// +/// +/// The provided implementation of is thread-safe for concurrent use so long as the +/// employed is also thread-safe for concurrent use. +/// +/// +/// This client automatically detects instances in the collection +/// and replaces them with equivalent function tools that the chat client can invoke to perform image generation and editing operations. +/// +/// +[Experimental("MEAI001")] +public sealed class ImageGeneratingChatClient : DelegatingChatClient +{ + /// + /// Specifies how image and other data content is handled when passing data to an inner client. + /// + /// + /// Use this enumeration to control whether images in the data content are passed as-is, replaced + /// with unique identifiers, or only generated images are replaced. This setting affects how downstream clients + /// receive and process image data. + /// Reducing what's passed downstream can help manage the context window. + /// + public enum DataContentHandling + { + /// Pass all DataContent to inner client. + None, + + /// Replace all images with unique identifiers when passing to inner client. + AllImages, + + /// Replace only images that were produced by past image generation requests with unique identifiers when passing to inner client. + GeneratedImages + } + + private const string ImageKey = "meai_image"; + + private readonly IImageGenerator _imageGenerator; + private readonly DataContentHandling _dataContentHandling; + + /// Initializes a new instance of the class. + /// The underlying . + /// An instance that will be used for image generation operations. + /// Specifies how to handle instances when passing messages to the inner client. + /// The default is . + /// or is . + public ImageGeneratingChatClient(IChatClient innerClient, IImageGenerator imageGenerator, DataContentHandling dataContentHandling = DataContentHandling.AllImages) + : base(innerClient) + { + _imageGenerator = Throw.IfNull(imageGenerator); + _dataContentHandling = dataContentHandling; + } + + /// + public override async Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(messages); + + var requestState = new RequestState(_imageGenerator, _dataContentHandling); + + // Process the chat options to replace HostedImageGenerationTool with functions + var processedOptions = requestState.ProcessChatOptions(options); + var processedMessages = requestState.ProcessChatMessages(messages); + + // Get response from base implementation + var response = await base.GetResponseAsync(processedMessages, processedOptions, cancellationToken); + + // Replace FunctionResultContent instances with generated image content + foreach (var message in response.Messages) + { + message.Contents = requestState.ReplaceImageGenerationFunctionResults(message.Contents); + } + + return response; + } + + /// + public override async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(messages); + + var requestState = new RequestState(_imageGenerator, _dataContentHandling); + + // Process the chat options to replace HostedImageGenerationTool with functions + var processedOptions = requestState.ProcessChatOptions(options); + var processedMessages = requestState.ProcessChatMessages(messages); + + await foreach (var update in base.GetStreamingResponseAsync(processedMessages, processedOptions, cancellationToken)) + { + // Replace any FunctionResultContent instances with generated image content + var newContents = requestState.ReplaceImageGenerationFunctionResults(update.Contents); + + if (!ReferenceEquals(newContents, update.Contents)) + { + // Create a new update instance with modified contents + var modifiedUpdate = update.Clone(); + modifiedUpdate.Contents = newContents; + yield return modifiedUpdate; + } + else + { + yield return update; + } + } + } + + /// Provides a mechanism for releasing unmanaged resources. + /// to dispose managed resources; otherwise, . + protected override void Dispose(bool disposing) + { + if (disposing) + { + _imageGenerator.Dispose(); + } + + base.Dispose(disposing); + } + + /// + /// Contains all the per-request state and methods for handling image generation requests. + /// This class is created fresh for each request to ensure thread safety. + /// This class is not exposed publicly and does not own any of it's resources. + /// + private sealed class RequestState + { + private readonly IImageGenerator _imageGenerator; + private readonly DataContentHandling _dataContentHandling; + private readonly HashSet _toolNames = new(StringComparer.Ordinal); + private readonly Dictionary> _imageContentByCallId = []; + private readonly Dictionary _imageContentById = new(StringComparer.OrdinalIgnoreCase); + private ImageGenerationOptions? _imageGenerationOptions; + + public RequestState(IImageGenerator imageGenerator, DataContentHandling dataContentHandling) + { + _imageGenerator = imageGenerator; + _dataContentHandling = dataContentHandling; + } + + /// + /// Processes the chat messages to replace images in data content with unique identifiers as needed. + /// All images will be stored for later retrieval during image editing operations. + /// See for details on image replacement behavior. + /// + /// Messages to process. + /// Processed messages, or the original messages if no changes were made. + public IEnumerable ProcessChatMessages(IEnumerable messages) + { + List? newMessages = null; + int messageIndex = 0; + foreach (var message in messages) + { + List? newContents = null; + for (int contentIndex = 0; contentIndex < message.Contents.Count; contentIndex++) + { + var content = message.Contents[contentIndex]; + + void ReplaceImage(string imageId, DataContent dataContent) + { + // Replace image with a placeholder text content, to give an indication to the model of its placement in the context + newContents ??= CopyList(message.Contents, contentIndex); + newContents.Add(new TextContent($"[{ImageKey}:{imageId}] available for edit.") + { + Annotations = dataContent.Annotations, + AdditionalProperties = dataContent.AdditionalProperties + }); + } + + if (content is DataContent dataContent && dataContent.HasTopLevelMediaType("image")) + { + // Store the image to make available for edit + var imageId = StoreImage(dataContent); + + if (_dataContentHandling == DataContentHandling.AllImages) + { + ReplaceImage(imageId, dataContent); + continue; // Skip adding the original content + } + } + else if (content is ImageGenerationToolResultContent toolResultContent) + { + foreach (var output in toolResultContent.Outputs ?? []) + { + if (output is DataContent generatedDataContent && generatedDataContent.HasTopLevelMediaType("image")) + { + // Store the image to make available for edit + var imageId = StoreImage(generatedDataContent, isGenerated: true); + + if (_dataContentHandling == DataContentHandling.AllImages || + _dataContentHandling == DataContentHandling.GeneratedImages) + { + ReplaceImage(imageId, generatedDataContent); + } + } + } + + if (_dataContentHandling == DataContentHandling.AllImages || + _dataContentHandling == DataContentHandling.GeneratedImages) + { + // skip adding the generated content + continue; + } + } + + // Add the original content if no replacement was made + newContents?.Add(content); + } + + if (newContents != null) + { + newMessages ??= [.. messages.Take(messageIndex)]; + var newMessage = message.Clone(); + newMessage.Contents = newContents; + newMessages.Add(newMessage); + } + else + { + newMessages?.Add(message); + + } + + messageIndex++; + } + + return newMessages ?? messages; + } + + public ChatOptions? ProcessChatOptions(ChatOptions? options) + { + if (options?.Tools is null || options.Tools.Count == 0) + { + return options; + } + + List? newTools = null; + var tools = options.Tools; + for (int i = 0; i < tools.Count; i++) + { + var tool = tools[i]; + + // remove all instances of HostedImageGenerationTool and store the options from the last one + if (tool is HostedImageGenerationTool imageGenerationTool) + { + _imageGenerationOptions = imageGenerationTool.Options; + + // for the first image generation tool, clone the options and insert our function tools + // remove any subsequent image generation tools + newTools ??= InitializeTools(tools, i); + } + else + { + newTools?.Add(tool); + } + } + + if (newTools is not null) + { + var newOptions = options.Clone(); + newOptions.Tools = newTools; + return newOptions; + } + + return options; + + List InitializeTools(IList existingTools, int toOffsetExclusive) + { +#if NET + ReadOnlySpan tools = +#else + AITool[] tools = +#endif + [ + AIFunctionFactory.Create(GenerateImageAsync), + AIFunctionFactory.Create(EditImageAsync), + AIFunctionFactory.Create(GetImagesForEdit) + ]; + + foreach (var tool in tools) + { + _toolNames.Add(tool.Name); + } + + var result = CopyList(existingTools, toOffsetExclusive, tools.Length); + result.AddRange(tools); + return result; + } + } + + /// + /// Replaces FunctionResultContent instances for image generation functions with actual generated image content. + /// We will have two messages + /// 1. Role: Assistant, FunctionCall + /// 2. Role: Tool, FunctionResult + /// We need to replace content from both but we shouldn't remove the messages. + /// If we do not then ChatClient's may not accept our altered history. + /// + /// When interating with a HostedImageGenerationTool we will have typically only see a single Message with + /// Role: Assistant that contains the DataContent (or a provider specific content, that's exposed as DataContent). + /// + /// The list of AI content to process. + public IList ReplaceImageGenerationFunctionResults(IList contents) + { + List? newContents = null; + + // Replace FunctionResultContent instances with generated image content + for (int i = contents.Count - 1; i >= 0; i--) + { + var content = contents[i]; + + // We must lookup by name because in the streaming case we have not yet been called to record the CallId. + if (content is FunctionCallContent functionCall && + _toolNames.Contains(functionCall.Name)) + { + // create a new list and omit the FunctionCallContent + newContents ??= CopyList(contents, i); + + if (functionCall.Name != nameof(GetImagesForEdit)) + { + newContents.Add(new ImageGenerationToolCallContent + { + ImageId = functionCall.CallId, + }); + } + } + else if (content is FunctionResultContent functionResult && + _imageContentByCallId.TryGetValue(functionResult.CallId, out var imageContents)) + { + newContents ??= CopyList(contents, i); + + if (imageContents.Any()) + { + // Insert ImageGenerationToolResultContent in its place, do not preserve the FunctionResultContent + newContents.Add(new ImageGenerationToolResultContent + { + ImageId = functionResult.CallId, + Outputs = imageContents + }); + } + + // Remove the mapping as it's no longer needed + _ = _imageContentByCallId.Remove(functionResult.CallId); + } + else + { + // keep the existing content if we have a new list + newContents?.Add(content); + } + } + + return newContents ?? contents; + } + + [Description("Generates images based on a text description.")] + public async Task GenerateImageAsync( + [Description("A detailed description of the image to generate")] string prompt, + CancellationToken cancellationToken = default) + { + // Get the call ID from the current function invocation context + var callId = FunctionInvokingChatClient.CurrentContext?.CallContent.CallId; + if (callId == null) + { + return "No call ID available for image generation."; + } + + var request = new ImageGenerationRequest(prompt); + var options = _imageGenerationOptions ?? new ImageGenerationOptions(); + options.Count ??= 1; + + var response = await _imageGenerator.GenerateAsync(request, options, cancellationToken); + + if (response.Contents.Count == 0) + { + return "No image was generated."; + } + + List imageIds = []; + List imageContents = _imageContentByCallId[callId] = []; + foreach (var content in response.Contents) + { + if (content is DataContent imageContent && imageContent.MediaType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) + { + imageContents.Add(imageContent); + imageIds.Add(StoreImage(imageContent, true)); + } + } + + return "Generated image successfully."; + } + + [Description("Lists the identifiers of all images available for edit.")] + public IEnumerable GetImagesForEdit() + { + // Get the call ID from the current function invocation context + var callId = FunctionInvokingChatClient.CurrentContext?.CallContent.CallId; + if (callId == null) + { + return ["No call ID available for image editing."]; + } + + _imageContentByCallId[callId] = []; + + return _imageContentById.Keys.AsEnumerable(); + } + + [Description("Edits an existing image based on a text description.")] + public async Task EditImageAsync( + [Description("A detailed description of the image to generate")] string prompt, + [Description($"The image to edit from one of the available image identifiers returned by {nameof(GetImagesForEdit)}")] string imageId, + CancellationToken cancellationToken = default) + { + // Get the call ID from the current function invocation context + var callId = FunctionInvokingChatClient.CurrentContext?.CallContent.CallId; + if (callId == null) + { + return "No call ID available for image editing."; + } + + if (string.IsNullOrEmpty(imageId)) + { + return "No imageId provided"; + } + + try + { + var originalImage = RetrieveImageContent(imageId); + if (originalImage == null) + { + return $"No image found with: {imageId}"; + } + + var request = new ImageGenerationRequest(prompt, [originalImage]); + var response = await _imageGenerator.GenerateAsync(request, _imageGenerationOptions, cancellationToken); + + if (response.Contents.Count == 0) + { + return "No edited image was generated."; + } + + List imageIds = []; + List imageContents = _imageContentByCallId[callId] = []; + foreach (var content in response.Contents) + { + if (content is DataContent imageContent && imageContent.MediaType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) + { + imageContents.Add(imageContent); + imageIds.Add(StoreImage(imageContent, true)); + } + } + + return "Edited image successfully."; + } + catch (FormatException) + { + return "Invalid image data format. Please provide a valid base64-encoded image."; + } + } + + private static List CopyList(IList original, int toOffsetExclusive, int additionalCapacity = 0) + { + var newList = new List(original.Count + additionalCapacity); + + // Copy all items up to and excluding the current index + for (int j = 0; j < toOffsetExclusive; j++) + { + newList.Add(original[j]); + } + + return newList; + } + + private DataContent? RetrieveImageContent(string imageId) + { + if (_imageContentById.TryGetValue(imageId, out var imageContent)) + { + return imageContent as DataContent; + } + + return null; + } + + private string StoreImage(DataContent imageContent, bool isGenerated = false) + { + // Generate a unique ID for the image if it doesn't have one + string? imageId = null; + if (imageContent.AdditionalProperties?.TryGetValue(ImageKey, out imageId) is false || imageId is null) + { + imageId = imageContent.Name ?? Guid.NewGuid().ToString(); + } + + if (isGenerated) + { + imageContent.AdditionalProperties ??= []; + imageContent.AdditionalProperties[ImageKey] = imageId; + } + + // Store the image content for later retrieval + _imageContentById[imageId] = imageContent; + + return imageId; + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ImageGeneratingChatClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ImageGeneratingChatClientBuilderExtensions.cs new file mode 100644 index 00000000000..241c851fd4e --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ImageGeneratingChatClientBuilderExtensions.cs @@ -0,0 +1,46 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides extensions for configuring instances. +[Experimental("MEAI001")] +public static class ImageGeneratingChatClientBuilderExtensions +{ + /// Adds image generation capabilities to the chat client pipeline. + /// The . + /// + /// An optional used for image generation operations. + /// If not supplied, a required instance will be resolved from the service provider. + /// + /// An optional callback that can be used to configure the instance. + /// The . + /// is . + /// + /// + /// This method enables the chat client to handle instances by converting them + /// into function tools that can be invoked by the underlying chat model to perform image generation and editing operations. + /// + /// + public static ChatClientBuilder UseImageGeneration( + this ChatClientBuilder builder, + IImageGenerator? imageGenerator = null, + Action? configure = null) + { + _ = Throw.IfNull(builder); + + return builder.Use((innerClient, services) => + { + imageGenerator ??= services.GetRequiredService(); + + var chatClient = new ImageGeneratingChatClient(innerClient, imageGenerator); + configure?.Invoke(chatClient); + return chatClient; + }); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/LoggingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/LoggingChatClient.cs index aec72eddcdc..c39fa2c0eb6 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/LoggingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/LoggingChatClient.cs @@ -169,7 +169,7 @@ public override async IAsyncEnumerable GetStreamingResponseA } } - private string AsJson(T value) => LoggingHelpers.AsJson(value, _jsonSerializerOptions); + private string AsJson(T value) => TelemetryHelpers.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 d66266c39bc..1f630a5a62c 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs @@ -4,20 +4,20 @@ 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.Encodings.Web; using System.Text.Json; -using System.Text.Json.Nodes; using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Shared.Diagnostics; -#pragma warning disable S3358 // Ternary operators should not be nested +#pragma warning disable CA1307 // Specify StringComparison for clarity +#pragma warning disable CA1308 // Normalize strings to uppercase #pragma warning disable SA1111 // Closing parenthesis should be on line of last parameter #pragma warning disable SA1113 // Comma should be on the same line as previous parameter @@ -25,22 +25,19 @@ namespace Microsoft.Extensions.AI; /// Represents a delegating chat client that implements the OpenTelemetry Semantic Conventions for Generative AI systems. /// -/// This class provides an implementation of the Semantic Conventions for Generative AI systems v1.35, defined at . +/// This class provides an implementation of the Semantic Conventions for Generative AI systems v1.38, 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 { - 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? _defaultModelId; - private readonly string? _system; + private readonly string? _providerName; private readonly string? _serverAddress; private readonly int _serverPort; @@ -48,20 +45,20 @@ public sealed partial class OpenTelemetryChatClient : DelegatingChatClient /// Initializes a new instance of the class. /// The underlying . - /// The to use for emitting events. + /// The to use for emitting any logging data from the client. /// An optional source name that will be used on the telemetry data. +#pragma warning disable IDE0060 // Remove unused parameter; it exists for backwards compatibility and future use public OpenTelemetryChatClient(IChatClient innerClient, ILogger? logger = null, string? sourceName = null) +#pragma warning restore IDE0060 : base(innerClient) { Debug.Assert(innerClient is not null, "Should have been validated by the base ctor"); - _logger = logger ?? NullLogger.Instance; - if (innerClient!.GetService() is ChatClientMetadata metadata) { _defaultModelId = metadata.DefaultModelId; - _system = metadata.ProviderName; - _serverAddress = metadata.ProviderUri?.GetLeftPart(UriPartial.Path); + _providerName = metadata.ProviderName; + _serverAddress = metadata.ProviderUri?.Host; _serverPort = metadata.ProviderUri?.Port ?? 0; } @@ -72,19 +69,15 @@ public OpenTelemetryChatClient(IChatClient innerClient, ILogger? logger = null, _tokenUsageHistogram = _meter.CreateHistogram( OpenTelemetryConsts.GenAI.Client.TokenUsage.Name, OpenTelemetryConsts.TokensUnit, - OpenTelemetryConsts.GenAI.Client.TokenUsage.Description -#if NET9_0_OR_GREATER - , advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.TokenUsage.ExplicitBucketBoundaries } -#endif + OpenTelemetryConsts.GenAI.Client.TokenUsage.Description, + advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.TokenUsage.ExplicitBucketBoundaries } ); _operationDurationHistogram = _meter.CreateHistogram( OpenTelemetryConsts.GenAI.Client.OperationDuration.Name, OpenTelemetryConsts.SecondsUnit, - OpenTelemetryConsts.GenAI.Client.OperationDuration.Description -#if NET9_0_OR_GREATER - , advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.OperationDuration.ExplicitBucketBoundaries } -#endif + OpenTelemetryConsts.GenAI.Client.OperationDuration.Description, + advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.OperationDuration.ExplicitBucketBoundaries } ); _jsonSerializerOptions = AIJsonUtilities.DefaultOptions; @@ -115,13 +108,16 @@ protected override void Dispose(bool disposing) /// /// if potentially sensitive information should be included in telemetry; /// if telemetry shouldn't include raw inputs and outputs. - /// The default value is . + /// The default value is , unless the OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT + /// environment variable is set to "true" (case-insensitive). /// /// /// By default, telemetry includes metadata, such as token counts, but not raw inputs /// and outputs, such as message content, function call arguments, and function call results. + /// The default value can be overridden by setting the OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT + /// environment variable to "true". Explicitly setting this property will override the environment variable. /// - public bool EnableSensitiveData { get; set; } + public bool EnableSensitiveData { get; set; } = TelemetryHelpers.EnableSensitiveDataDefault; /// public override object? GetService(Type serviceType, object? serviceKey = null) => @@ -139,7 +135,7 @@ public override async Task GetResponseAsync( Stopwatch? stopwatch = _operationDurationHistogram.Enabled ? Stopwatch.StartNew() : null; string? requestModelId = options?.ModelId ?? _defaultModelId; - LogChatMessages(messages); + AddInputMessagesTags(messages, options, activity); ChatResponse? response = null; Exception? error = null; @@ -170,7 +166,7 @@ public override async IAsyncEnumerable GetStreamingResponseA Stopwatch? stopwatch = _operationDurationHistogram.Enabled ? Stopwatch.StartNew() : null; string? requestModelId = options?.ModelId ?? _defaultModelId; - LogChatMessages(messages); + AddInputMessagesTags(messages, options, activity); IAsyncEnumerable updates; try @@ -219,6 +215,152 @@ public override async IAsyncEnumerable GetStreamingResponseA } } + internal static string SerializeChatMessages( + IEnumerable messages, ChatFinishReason? chatFinishReason = null, JsonSerializerOptions? customContentSerializerOptions = null) + { + List output = []; + + string? finishReason = + chatFinishReason?.Value is null ? null : + chatFinishReason == ChatFinishReason.Length ? "length" : + chatFinishReason == ChatFinishReason.ContentFilter ? "content_filter" : + chatFinishReason == ChatFinishReason.ToolCalls ? "tool_call" : + "stop"; + + foreach (ChatMessage message in messages) + { + OtelMessage m = new() + { + FinishReason = finishReason, + Role = + message.Role == ChatRole.Assistant ? "assistant" : + message.Role == ChatRole.Tool ? "tool" : + message.Role == ChatRole.System || message.Role == new ChatRole("developer") ? "system" : + "user", + Name = message.AuthorName, + }; + + foreach (AIContent content in message.Contents) + { + switch (content) + { + // These are all specified in the convention: + + case TextContent tc when !string.IsNullOrWhiteSpace(tc.Text): + m.Parts.Add(new OtelGenericPart { Content = tc.Text }); + break; + + case TextReasoningContent trc when !string.IsNullOrWhiteSpace(trc.Text): + m.Parts.Add(new OtelGenericPart { Type = "reasoning", Content = trc.Text }); + break; + + case FunctionCallContent fcc: + m.Parts.Add(new OtelToolCallRequestPart + { + Id = fcc.CallId, + Name = fcc.Name, + Arguments = fcc.Arguments, + }); + break; + + case FunctionResultContent frc: + m.Parts.Add(new OtelToolCallResponsePart + { + Id = frc.CallId, + Response = frc.Result, + }); + break; + + case DataContent dc: + m.Parts.Add(new OtelBlobPart + { + Content = dc.Base64Data.ToString(), + MimeType = dc.MediaType, + Modality = DeriveModalityFromMediaType(dc.MediaType), + }); + break; + + case UriContent uc: + m.Parts.Add(new OtelUriPart + { + Uri = uc.Uri.AbsoluteUri, + MimeType = uc.MediaType, + Modality = DeriveModalityFromMediaType(uc.MediaType), + }); + break; + + case HostedFileContent fc: + m.Parts.Add(new OtelFilePart + { + FileId = fc.FileId, + MimeType = fc.MediaType, + Modality = DeriveModalityFromMediaType(fc.MediaType), + }); + break; + + // These are non-standard and are using the "generic" non-text part that provides an extensibility mechanism: + + case HostedVectorStoreContent vsc: + m.Parts.Add(new OtelGenericPart { Type = "vector_store", Content = vsc.VectorStoreId }); + break; + + case ErrorContent ec: + m.Parts.Add(new OtelGenericPart { Type = "error", Content = ec.Message }); + break; + + default: + JsonElement element = _emptyObject; + try + { + JsonTypeInfo? unknownContentTypeInfo = + customContentSerializerOptions?.TryGetTypeInfo(content.GetType(), out JsonTypeInfo? ctsi) is true ? ctsi : + _defaultOptions.TryGetTypeInfo(content.GetType(), out JsonTypeInfo? dtsi) ? dtsi : + null; + + if (unknownContentTypeInfo is not null) + { + element = JsonSerializer.SerializeToElement(content, unknownContentTypeInfo); + } + } + catch + { + // Ignore the contents of any parts that can't be serialized. + } + + m.Parts.Add(new OtelGenericPart + { + Type = content.GetType().FullName!, + Content = element, + }); + break; + } + } + + output.Add(m); + } + + return JsonSerializer.Serialize(output, _defaultOptions.GetTypeInfo(typeof(IList))); + } + + private static string? DeriveModalityFromMediaType(string? mediaType) + { + if (mediaType is not null) + { + int pos = mediaType.IndexOf('/'); + if (pos >= 0) + { + ReadOnlySpan topLevel = mediaType.AsSpan(0, pos); + return + topLevel.Equals("image", StringComparison.OrdinalIgnoreCase) ? "image" : + topLevel.Equals("audio", StringComparison.OrdinalIgnoreCase) ? "audio" : + topLevel.Equals("video", StringComparison.OrdinalIgnoreCase) ? "video" : + null; + } + } + + return null; + } + /// Creates an activity for a chat request, or returns if not enabled. private Activity? CreateAndConfigureActivity(ChatOptions? options) { @@ -228,15 +370,15 @@ public override async IAsyncEnumerable GetStreamingResponseA string? modelId = options?.ModelId ?? _defaultModelId; activity = _activitySource.StartActivity( - string.IsNullOrWhiteSpace(modelId) ? OpenTelemetryConsts.GenAI.Chat : $"{OpenTelemetryConsts.GenAI.Chat} {modelId}", + string.IsNullOrWhiteSpace(modelId) ? OpenTelemetryConsts.GenAI.ChatName : $"{OpenTelemetryConsts.GenAI.ChatName} {modelId}", ActivityKind.Client); - if (activity is not null) + if (activity is { IsAllDataRequested: true }) { _ = activity - .AddTag(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.Chat) + .AddTag(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.ChatName) .AddTag(OpenTelemetryConsts.GenAI.Request.Model, modelId) - .AddTag(OpenTelemetryConsts.GenAI.SystemName, _system); + .AddTag(OpenTelemetryConsts.GenAI.Provider.Name, _providerName); if (_serverAddress is not null) { @@ -272,7 +414,7 @@ public override async IAsyncEnumerable GetStreamingResponseA _ = activity.AddTag(OpenTelemetryConsts.GenAI.Request.Seed, seed); } - if (options.StopSequences is IList stopSequences) + if (options.StopSequences is IList { Count: > 0 } stopSequences) { _ = activity.AddTag(OpenTelemetryConsts.GenAI.Request.StopSequences, $"[{string.Join(", ", stopSequences.Select(s => $"\"{s}\""))}]"); } @@ -297,27 +439,39 @@ public override async IAsyncEnumerable GetStreamingResponseA switch (options.ResponseFormat) { case ChatResponseFormatText: - _ = activity.AddTag(OpenTelemetryConsts.GenAI.Output.Type, "text"); + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Output.Type, OpenTelemetryConsts.TypeText); break; case ChatResponseFormatJson: - _ = activity.AddTag(OpenTelemetryConsts.GenAI.Output.Type, "json"); + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Output.Type, OpenTelemetryConsts.TypeJson); break; } } - if (_system is not null) + if (EnableSensitiveData) { - // Since AdditionalProperties has undefined meaning, we treat it as potentially sensitive data - if (EnableSensitiveData && options.AdditionalProperties is { } props) + if (options.Tools is { Count: > 0 }) + { + _ = activity.AddTag( + OpenTelemetryConsts.GenAI.Tool.Definitions, + JsonSerializer.Serialize(options.Tools.Select(t => t switch + { + _ when t.GetService() is { } af => new OtelFunction + { + Name = af.Name, + Description = af.Description, + Parameters = af.JsonSchema, + }, + _ => new OtelFunction { Type = t.Name }, + }), OtelContext.Default.IEnumerableOtelFunction)); + } + + // Log all additional request options as raw values on the span. + // Since AdditionalProperties has undefined meaning, we treat it as potentially sensitive data. + if (options.AdditionalProperties is { } props) { - // 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); + _ = activity.AddTag(prop.Key, prop.Value); } } } @@ -354,17 +508,17 @@ private void TraceResponse( if (usage.InputTokenCount is long inputTokens) { TagList tags = default; - tags.Add(OpenTelemetryConsts.GenAI.Token.Type, "input"); + tags.Add(OpenTelemetryConsts.GenAI.Token.Type, OpenTelemetryConsts.TokenTypeInput); AddMetricTags(ref tags, requestModelId, response); - _tokenUsageHistogram.Record((int)inputTokens); + _tokenUsageHistogram.Record((int)inputTokens, tags); } if (usage.OutputTokenCount is long outputTokens) { TagList tags = default; - tags.Add(OpenTelemetryConsts.GenAI.Token.Type, "output"); + tags.Add(OpenTelemetryConsts.GenAI.Token.Type, OpenTelemetryConsts.TokenTypeOutput); AddMetricTags(ref tags, requestModelId, response); - _tokenUsageHistogram.Record((int)outputTokens); + _tokenUsageHistogram.Record((int)outputTokens, tags); } } @@ -377,7 +531,7 @@ private void TraceResponse( if (response is not null) { - LogChatResponse(response); + AddOutputMessagesTags(response, activity); if (activity is not null) { @@ -408,20 +562,13 @@ private void TraceResponse( _ = activity.AddTag(OpenTelemetryConsts.GenAI.Usage.OutputTokens, (int)outputTokens); } - if (_system is not null) + // Log all additional response properties as raw values on the span. + // Since AdditionalProperties has undefined meaning, we treat it as potentially sensitive data. + if (EnableSensitiveData && response.AdditionalProperties is { } props) { - // Since AdditionalProperties has undefined meaning, we treat it as potentially sensitive data - if (EnableSensitiveData && response.AdditionalProperties is { } props) + foreach (KeyValuePair prop in props) { - // 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. - foreach (KeyValuePair prop in props) - { - _ = activity.AddTag( - OpenTelemetryConsts.GenAI.Response.PerProvider(_system, JsonNamingPolicy.SnakeCaseLower.ConvertName(prop.Key)), - prop.Value); - } + _ = activity.AddTag(prop.Key, prop.Value); } } } @@ -429,14 +576,14 @@ private void TraceResponse( void AddMetricTags(ref TagList tags, string? requestModelId, ChatResponse? response) { - tags.Add(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.Chat); + tags.Add(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.ChatName); if (requestModelId is not null) { tags.Add(OpenTelemetryConsts.GenAI.Request.Model, requestModelId); } - tags.Add(OpenTelemetryConsts.GenAI.SystemName, _system); + tags.Add(OpenTelemetryConsts.GenAI.Provider.Name, _providerName); if (_serverAddress is string endpointAddress) { @@ -451,155 +598,122 @@ void AddMetricTags(ref TagList tags, string? requestModelId, ChatResponse? respo } } - private void LogChatMessages(IEnumerable messages) + private void AddInputMessagesTags(IEnumerable messages, ChatOptions? options, Activity? activity) { - if (!_logger.IsEnabled(EventLogLevel)) + if (EnableSensitiveData && activity is { IsAllDataRequested: true }) { - return; - } - - foreach (ChatMessage message in messages) - { - if (message.Role == ChatRole.Assistant) - { - Log(new(1, OpenTelemetryConsts.GenAI.Assistant.Message), - JsonSerializer.Serialize(CreateAssistantEvent(message.Contents), OtelContext.Default.AssistantEvent)); - } - else if (message.Role == ChatRole.Tool) - { - foreach (FunctionResultContent frc in message.Contents.OfType()) - { - 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 + if (!string.IsNullOrWhiteSpace(options?.Instructions)) { - 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.Contents), - }, OtelContext.Default.SystemOrUserEvent)); + _ = activity.AddTag( + OpenTelemetryConsts.GenAI.SystemInstructions, + JsonSerializer.Serialize(new object[1] { new OtelGenericPart { Content = options!.Instructions } }, _defaultOptions.GetTypeInfo(typeof(IList)))); } + + _ = activity.AddTag( + OpenTelemetryConsts.GenAI.Input.Messages, + SerializeChatMessages(messages, customContentSerializerOptions: _jsonSerializerOptions)); } } - private void LogChatResponse(ChatResponse response) + private void AddOutputMessagesTags(ChatResponse response, Activity? activity) { - if (!_logger.IsEnabled(EventLogLevel)) + if (EnableSensitiveData && activity is { IsAllDataRequested: true }) { - return; + _ = activity.AddTag( + OpenTelemetryConsts.GenAI.Output.Messages, + SerializeChatMessages(response.Messages, response.FinishReason, customContentSerializerOptions: _jsonSerializerOptions)); } - - EventId id = new(1, OpenTelemetryConsts.GenAI.Choice); - Log(id, JsonSerializer.Serialize(new() - { - FinishReason = response.FinishReason?.Value ?? "error", - Index = 0, - Message = CreateAssistantEvent(response.Messages is { Count: 1 } ? response.Messages[0].Contents : response.Messages.SelectMany(m => m.Contents)), - }, OtelContext.Default.ChoiceEvent)); } - private void Log(EventId id, [StringSyntax(StringSyntaxAttribute.Json)] string eventBodyJson) + private sealed class OtelMessage { - // 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. - - KeyValuePair[] tags = - [ - new(OpenTelemetryConsts.Event.Name, id.Name), - new(OpenTelemetryConsts.GenAI.SystemName, _system), - ]; - - _logger.Log(EventLogLevel, id, tags, null, (_, __) => eventBodyJson); + public string? Role { get; set; } + public string? Name { get; set; } + public List Parts { get; set; } = []; + public string? FinishReason { get; set; } } - private AssistantEvent CreateAssistantEvent(IEnumerable contents) + private sealed class OtelGenericPart { - var toolCalls = 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(contents), - ToolCalls = toolCalls.Length > 0 ? toolCalls : null, - }; + public string Type { get; set; } = "text"; + public object? Content { get; set; } // should be a string when Type == "text" } - private string? GetMessageContent(IEnumerable contents) + private sealed class OtelBlobPart { - if (EnableSensitiveData) - { - string content = string.Concat(contents.OfType()); - if (content.Length > 0) - { - return content; - } - } - - return null; + public string Type { get; set; } = "blob"; + public string? Content { get; set; } // base64-encoded binary data + public string? MimeType { get; set; } + public string? Modality { get; set; } } - private sealed class SystemOrUserEvent + private sealed class OtelUriPart { - public string? Role { get; set; } - public string? Content { get; set; } + public string Type { get; set; } = "uri"; + public string? Uri { get; set; } + public string? MimeType { get; set; } + public string? Modality { get; set; } } - private sealed class AssistantEvent + private sealed class OtelFilePart { - public string? Content { get; set; } - public ToolCall[]? ToolCalls { get; set; } + public string Type { get; set; } = "file"; + public string? FileId { get; set; } + public string? MimeType { get; set; } + public string? Modality { get; set; } } - private sealed class ToolEvent + private sealed class OtelToolCallRequestPart { + public string Type { get; set; } = "tool_call"; public string? Id { get; set; } - public JsonNode? Content { get; set; } + public string? Name { get; set; } + public IDictionary? Arguments { get; set; } } - private sealed class ChoiceEvent + private sealed class OtelToolCallResponsePart { - public string? FinishReason { get; set; } - public int Index { get; set; } - public AssistantEvent? Message { get; set; } + public string Type { get; set; } = "tool_call_response"; + public string? Id { get; set; } + public object? Response { get; set; } } - private sealed class ToolCall + private sealed class OtelFunction { - public string? Id { get; set; } - public string? Type { get; set; } = "function"; - public ToolCallFunction? Function { get; set; } + public string Type { get; set; } = "function"; + public string? Name { get; set; } + public string? Description { get; set; } + public JsonElement? Parameters { get; set; } } - private sealed class ToolCallFunction + private static readonly JsonSerializerOptions _defaultOptions = CreateDefaultOptions(); + private static readonly JsonElement _emptyObject = JsonSerializer.SerializeToElement(new object(), _defaultOptions.GetTypeInfo(typeof(object))); + + private static JsonSerializerOptions CreateDefaultOptions() { - public string? Name { get; set; } - public JsonNode? Arguments { get; set; } + JsonSerializerOptions options = new(OtelContext.Default.Options) + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + + options.TypeInfoResolverChain.Add(AIJsonUtilities.DefaultOptions.TypeInfoResolver!); + options.MakeReadOnly(); + + return options; } - [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] - [JsonSerializable(typeof(SystemOrUserEvent))] - [JsonSerializable(typeof(AssistantEvent))] - [JsonSerializable(typeof(ToolEvent))] - [JsonSerializable(typeof(ChoiceEvent))] - [JsonSerializable(typeof(object))] + [JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] + [JsonSerializable(typeof(IList))] + [JsonSerializable(typeof(OtelMessage))] + [JsonSerializable(typeof(OtelGenericPart))] + [JsonSerializable(typeof(OtelBlobPart))] + [JsonSerializable(typeof(OtelUriPart))] + [JsonSerializable(typeof(OtelFilePart))] + [JsonSerializable(typeof(OtelToolCallRequestPart))] + [JsonSerializable(typeof(OtelToolCallResponsePart))] + [JsonSerializable(typeof(IEnumerable))] private sealed partial class OtelContext : JsonSerializerContext; } diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryImageGenerator.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryImageGenerator.cs new file mode 100644 index 00000000000..aadf5f3fed6 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryImageGenerator.cs @@ -0,0 +1,307 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Metrics; +using System.Drawing; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +#pragma warning disable SA1111 // Closing parenthesis should be on line of last parameter +#pragma warning disable SA1113 // Comma should be on the same line as previous parameter + +namespace Microsoft.Extensions.AI; + +/// Represents a delegating image generator that implements the OpenTelemetry Semantic Conventions for Generative AI systems. +/// +/// This class provides an implementation of the Semantic Conventions for Generative AI systems v1.38, defined at . +/// The specification is still experimental and subject to change; as such, the telemetry output by this client is also subject to change. +/// +[Experimental("MEAI001")] +public sealed class OpenTelemetryImageGenerator : DelegatingImageGenerator +{ + private readonly ActivitySource _activitySource; + private readonly Meter _meter; + + private readonly Histogram _tokenUsageHistogram; + private readonly Histogram _operationDurationHistogram; + + private readonly string? _defaultModelId; + private readonly string? _providerName; + private readonly string? _serverAddress; + private readonly int _serverPort; + + /// Initializes a new instance of the class. + /// The underlying . + /// The to use for emitting any logging data from the client. + /// An optional source name that will be used on the telemetry data. +#pragma warning disable IDE0060 // Remove unused parameter; it exists for consistency with IChatClient and future use + public OpenTelemetryImageGenerator(IImageGenerator 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"); + + if (innerGenerator!.GetService() is ImageGeneratorMetadata metadata) + { + _defaultModelId = metadata.DefaultModelId; + _providerName = metadata.ProviderName; + _serverAddress = metadata.ProviderUri?.Host; + _serverPort = metadata.ProviderUri?.Port ?? 0; + } + + string name = string.IsNullOrEmpty(sourceName) ? OpenTelemetryConsts.DefaultSourceName : sourceName!; + _activitySource = new(name); + _meter = new(name); + + _tokenUsageHistogram = _meter.CreateHistogram( + OpenTelemetryConsts.GenAI.Client.TokenUsage.Name, + OpenTelemetryConsts.TokensUnit, + OpenTelemetryConsts.GenAI.Client.TokenUsage.Description, + advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.TokenUsage.ExplicitBucketBoundaries } + ); + + _operationDurationHistogram = _meter.CreateHistogram( + OpenTelemetryConsts.GenAI.Client.OperationDuration.Name, + OpenTelemetryConsts.SecondsUnit, + OpenTelemetryConsts.GenAI.Client.OperationDuration.Description, + advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.OperationDuration.ExplicitBucketBoundaries } + ); + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + _activitySource.Dispose(); + _meter.Dispose(); + } + + base.Dispose(disposing); + } + + /// + /// Gets or sets a value indicating whether potentially sensitive information should be included in telemetry. + /// + /// + /// if potentially sensitive information should be included in telemetry; + /// if telemetry shouldn't include raw inputs and outputs. + /// The default value is , unless the OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT + /// environment variable is set to "true" (case-insensitive). + /// + /// + /// By default, telemetry includes metadata, such as token counts, but not raw inputs + /// and outputs, such as message content, function call arguments, and function call results. + /// The default value can be overridden by setting the OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT + /// environment variable to "true". Explicitly setting this property will override the environment variable. + /// + public bool EnableSensitiveData { get; set; } = TelemetryHelpers.EnableSensitiveDataDefault; + + /// + public override object? GetService(Type serviceType, object? serviceKey = null) => + serviceType == typeof(ActivitySource) ? _activitySource : + base.GetService(serviceType, serviceKey); + + /// + public async override Task GenerateAsync( + ImageGenerationRequest request, ImageGenerationOptions? options = null, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(request); + + using Activity? activity = CreateAndConfigureActivity(request, options); + Stopwatch? stopwatch = _operationDurationHistogram.Enabled ? Stopwatch.StartNew() : null; + string? requestModelId = options?.ModelId ?? _defaultModelId; + + ImageGenerationResponse? response = null; + Exception? error = null; + try + { + response = await base.GenerateAsync(request, options, cancellationToken).ConfigureAwait(false); + return response; + } + catch (Exception ex) + { + error = ex; + throw; + } + finally + { + TraceResponse(activity, requestModelId, response, error, stopwatch); + } + } + + /// Creates an activity for an image generation request, or returns if not enabled. + private Activity? CreateAndConfigureActivity(ImageGenerationRequest request, ImageGenerationOptions? options) + { + Activity? activity = null; + if (_activitySource.HasListeners()) + { + string? modelId = options?.ModelId ?? _defaultModelId; + + activity = _activitySource.StartActivity( + string.IsNullOrWhiteSpace(modelId) ? OpenTelemetryConsts.GenAI.GenerateContentName : $"{OpenTelemetryConsts.GenAI.GenerateContentName} {modelId}", + ActivityKind.Client); + + if (activity is { IsAllDataRequested: true }) + { + _ = activity + .AddTag(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.GenerateContentName) + .AddTag(OpenTelemetryConsts.GenAI.Output.Type, OpenTelemetryConsts.TypeImage) + .AddTag(OpenTelemetryConsts.GenAI.Request.Model, modelId) + .AddTag(OpenTelemetryConsts.GenAI.Provider.Name, _providerName); + + if (_serverAddress is not null) + { + _ = activity + .AddTag(OpenTelemetryConsts.Server.Address, _serverAddress) + .AddTag(OpenTelemetryConsts.Server.Port, _serverPort); + } + + if (options is not null) + { + if (options.Count is int count) + { + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Request.ChoiceCount, count); + } + + // Otel hasn't yet standardized tags for image generation parameters; these are based on other systems. + if (options.ImageSize is Size size) + { + _ = activity + .AddTag("gen_ai.request.image.width", size.Width) + .AddTag("gen_ai.request.image.height", size.Height); + } + } + + if (EnableSensitiveData) + { + List content = []; + + if (request.Prompt is not null) + { + content.Add(new TextContent(request.Prompt)); + } + + if (request.OriginalImages is not null) + { + content.AddRange(request.OriginalImages); + } + + _ = activity.AddTag( + OpenTelemetryConsts.GenAI.Input.Messages, + OpenTelemetryChatClient.SerializeChatMessages([new(ChatRole.User, content)])); + + if (options?.AdditionalProperties is { } props) + { + foreach (KeyValuePair prop in props) + { + _ = activity.AddTag(prop.Key, prop.Value); + } + } + } + } + } + + return activity; + } + + /// Adds image generation response information to the activity. + private void TraceResponse( + Activity? activity, + string? requestModelId, + ImageGenerationResponse? response, + Exception? error, + Stopwatch? stopwatch) + { + if (_operationDurationHistogram.Enabled && stopwatch is not null) + { + TagList tags = default; + + AddMetricTags(ref tags, requestModelId); + if (error is not null) + { + tags.Add(OpenTelemetryConsts.Error.Type, error.GetType().FullName); + } + + _operationDurationHistogram.Record(stopwatch.Elapsed.TotalSeconds, tags); + } + + if (error is not null) + { + _ = activity? + .AddTag(OpenTelemetryConsts.Error.Type, error.GetType().FullName) + .SetStatus(ActivityStatusCode.Error, error.Message); + } + + if (response is not null) + { + if (EnableSensitiveData && + response.Contents is { Count: > 0 } contents && + activity is { IsAllDataRequested: true }) + { + _ = activity.AddTag( + OpenTelemetryConsts.GenAI.Output.Messages, + OpenTelemetryChatClient.SerializeChatMessages([new(ChatRole.Assistant, contents)])); + } + + if (response.Usage is { } usage) + { + if (_tokenUsageHistogram.Enabled) + { + if (usage.InputTokenCount is long inputTokens) + { + TagList tags = default; + tags.Add(OpenTelemetryConsts.GenAI.Token.Type, OpenTelemetryConsts.TokenTypeInput); + AddMetricTags(ref tags, requestModelId); + _tokenUsageHistogram.Record((int)inputTokens, tags); + } + + if (usage.OutputTokenCount is long outputTokens) + { + TagList tags = default; + tags.Add(OpenTelemetryConsts.GenAI.Token.Type, OpenTelemetryConsts.TokenTypeOutput); + AddMetricTags(ref tags, requestModelId); + _tokenUsageHistogram.Record((int)outputTokens, tags); + } + } + + if (activity is { IsAllDataRequested: true }) + { + if (usage.InputTokenCount is long inputTokens) + { + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Usage.InputTokens, (int)inputTokens); + } + + if (usage.OutputTokenCount is long outputTokens) + { + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Usage.OutputTokens, (int)outputTokens); + } + } + } + } + + void AddMetricTags(ref TagList tags, string? requestModelId) + { + tags.Add(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.GenerateContentName); + + if (requestModelId is not null) + { + tags.Add(OpenTelemetryConsts.GenAI.Request.Model, requestModelId); + } + + tags.Add(OpenTelemetryConsts.GenAI.Provider.Name, _providerName); + + if (_serverAddress is string endpointAddress) + { + tags.Add(OpenTelemetryConsts.Server.Address, endpointAddress); + tags.Add(OpenTelemetryConsts.Server.Port, _serverPort); + } + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryImageGeneratorBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryImageGeneratorBuilderExtensions.cs new file mode 100644 index 00000000000..63919505590 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryImageGeneratorBuilderExtensions.cs @@ -0,0 +1,42 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides extensions for configuring instances. +[Experimental("MEAI001")] +public static class OpenTelemetryImageGeneratorBuilderExtensions +{ + /// + /// Adds OpenTelemetry support to the image generator pipeline, following the OpenTelemetry Semantic Conventions for Generative AI systems. + /// + /// + /// The draft specification this follows is available at . + /// The specification is still experimental and subject to change; as such, the telemetry output by this client is also subject to change. + /// + /// The . + /// An optional to use to create a logger for logging events. + /// An optional source name that will be used on the telemetry data. + /// An optional callback that can be used to configure the instance. + /// The . + public static ImageGeneratorBuilder UseOpenTelemetry( + this ImageGeneratorBuilder builder, + ILoggerFactory? loggerFactory = null, + string? sourceName = null, + Action? configure = null) => + Throw.IfNull(builder).Use((innerGenerator, services) => + { + loggerFactory ??= services.GetService(); + + var g = new OpenTelemetryImageGenerator(innerGenerator, loggerFactory?.CreateLogger(typeof(OpenTelemetryImageGenerator)), sourceName); + configure?.Invoke(g); + + return g; + }); +} diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ReducingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ReducingChatClient.cs new file mode 100644 index 00000000000..afe56eddbd8 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ReducingChatClient.cs @@ -0,0 +1,50 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// A chat client that reduces the size of a message list. +/// +[Experimental("MEAI001")] +public sealed class ReducingChatClient : DelegatingChatClient +{ + private readonly IChatReducer _reducer; + + /// Initializes a new instance of the class. + /// The underlying , or the next instance in a chain of clients. + /// The reducer to be used by this instance. + public ReducingChatClient(IChatClient innerClient, IChatReducer reducer) + : base(innerClient) + { + _reducer = Throw.IfNull(reducer); + } + + /// + public override async Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + messages = await _reducer.ReduceAsync(messages, cancellationToken).ConfigureAwait(false); + + return await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); + } + + /// + public override async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + messages = await _reducer.ReduceAsync(messages, cancellationToken).ConfigureAwait(false); + + await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)) + { + yield return update; + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ReducingChatClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ReducingChatClientBuilderExtensions.cs new file mode 100644 index 00000000000..2f13d3e3cea --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/ReducingChatClientBuilderExtensions.cs @@ -0,0 +1,40 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Provides extension methods for attaching a to a chat pipeline. +/// +[Experimental("MEAI001")] +public static class ReducingChatClientBuilderExtensions +{ + /// + /// Adds a to the chat pipeline. + /// + /// The being used to build the chat pipeline. + /// An optional to apply to the chat client. If not supplied, an instance will be resolved from the service provider. + /// An optional callback that can be used to configure the instance. + /// The configured instance. + public static ChatClientBuilder UseChatReducer( + this ChatClientBuilder builder, + IChatReducer? reducer = null, + Action? configure = null) + { + _ = Throw.IfNull(builder); + + return builder.Use((innerClient, services) => + { + reducer ??= services.GetRequiredService(); + + var chatClient = new ReducingChatClient(innerClient, reducer); + configure?.Invoke(chatClient); + return chatClient; + }); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaToolCall.cs b/src/Libraries/Microsoft.Extensions.AI/ChatReduction/IChatReducer_Forwarder.cs similarity index 51% rename from src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaToolCall.cs rename to src/Libraries/Microsoft.Extensions.AI/ChatReduction/IChatReducer_Forwarder.cs index a00d0e0e290..8a61f0c83be 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Ollama/OllamaToolCall.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatReduction/IChatReducer_Forwarder.cs @@ -1,9 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -namespace Microsoft.Extensions.AI; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; -internal sealed class OllamaToolCall -{ - public OllamaFunctionToolCall? Function { get; set; } -} +[assembly: TypeForwardedTo(typeof(IChatReducer))] diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatReduction/MessageCountingChatReducer.cs b/src/Libraries/Microsoft.Extensions.AI/ChatReduction/MessageCountingChatReducer.cs new file mode 100644 index 00000000000..5ba48617355 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ChatReduction/MessageCountingChatReducer.cs @@ -0,0 +1,77 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Provides a chat reducer that limits the number of non-system messages in a conversation to a specified maximum +/// count, preserving the most recent messages and the first system message if present. +/// +/// +/// This reducer is useful for scenarios where it is necessary to constrain the size of a chat history, +/// such as when preparing input for models with context length limits. The reducer always includes the first +/// encountered system message, if any, and then retains up to the specified number of the most recent non-system +/// messages. Messages containing function call or function result content are excluded from the reduced +/// output. +/// +[Experimental("MEAI001")] +public sealed class MessageCountingChatReducer : IChatReducer +{ + private readonly int _targetCount; + + /// + /// Initializes a new instance of the class. + /// + /// The maximum number of non-system messages to retain in the reduced output. + public MessageCountingChatReducer(int targetCount) + { + _targetCount = Throw.IfLessThanOrEqual(targetCount, min: 0); + } + + /// + public Task> ReduceAsync(IEnumerable messages, CancellationToken cancellationToken) + { + _ = Throw.IfNull(messages); + return Task.FromResult(GetReducedMessages(messages)); + } + + private IEnumerable GetReducedMessages(IEnumerable messages) + { + ChatMessage? systemMessage = null; + Queue reducedMessages = new(capacity: _targetCount); + + foreach (var message in messages) + { + if (message.Role == ChatRole.System) + { + systemMessage ??= message; + } + else if (!message.Contents.Any(m => m is FunctionCallContent or FunctionResultContent)) + { + if (reducedMessages.Count >= _targetCount) + { + _ = reducedMessages.Dequeue(); + } + + reducedMessages.Enqueue(message); + } + } + + if (systemMessage is not null) + { + yield return systemMessage; + } + + while (reducedMessages.Count > 0) + { + yield return reducedMessages.Dequeue(); + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatReduction/SummarizingChatReducer.cs b/src/Libraries/Microsoft.Extensions.AI/ChatReduction/SummarizingChatReducer.cs new file mode 100644 index 00000000000..28f05ed9e5f --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ChatReduction/SummarizingChatReducer.cs @@ -0,0 +1,233 @@ +// 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.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Provides functionality to reduce a collection of chat messages into a summarized form. +/// +/// +/// This reducer is useful for scenarios where it is necessary to constrain the size of a chat history, +/// such as when preparing input for models with context length limits. The reducer automatically summarizes +/// older messages when the conversation exceeds a specified length, preserving context while reducing message +/// count. The reducer maintains system messages and excludes messages containing function call or function +/// result content from summarization. +/// +[Experimental("MEAI001")] +public sealed class SummarizingChatReducer : IChatReducer +{ + private const string SummaryKey = "__summary__"; + + private const string DefaultSummarizationPrompt = """ + **Generate a clear and complete summary of the entire conversation in no more than five sentences.** + + The summary must always: + - Reflect contributions from both the user and the assistant + - Preserve context to support ongoing dialogue + - Incorporate any previously provided summary + - Emphasize the most relevant and meaningful points + + The summary must never: + - Offer critique, correction, interpretation, or speculation + - Highlight errors, misunderstandings, or judgments of accuracy + - Comment on events or ideas not present in the conversation + - Omit any details included in an earlier summary + """; + + private readonly IChatClient _chatClient; + private readonly int _targetCount; + private readonly int _thresholdCount; + + /// + /// Gets or sets the prompt text used for summarization. + /// + public string SummarizationPrompt + { + get; + set => field = Throw.IfNull(value); + } = DefaultSummarizationPrompt; + + /// + /// Initializes a new instance of the class with the specified chat client, + /// target count, and optional threshold count. + /// + /// The chat client used to interact with the chat system. Cannot be . + /// The target number of messages to retain after summarization. Must be greater than 0. + /// The number of messages allowed beyond before summarization is triggered. Must be greater than or equal to 0 if specified. + public SummarizingChatReducer(IChatClient chatClient, int targetCount, int? threshold) + { + _chatClient = Throw.IfNull(chatClient); + _targetCount = Throw.IfLessThanOrEqual(targetCount, min: 0); + _thresholdCount = Throw.IfLessThan(threshold ?? 0, min: 0, nameof(threshold)); + } + + /// + public async Task> ReduceAsync(IEnumerable messages, CancellationToken cancellationToken) + { + _ = Throw.IfNull(messages); + + var summarizedConversation = SummarizedConversation.FromChatMessages(messages); + var indexOfFirstMessageToKeep = summarizedConversation.FindIndexOfFirstMessageToKeep(_targetCount, _thresholdCount); + if (indexOfFirstMessageToKeep > 0) + { + summarizedConversation = await summarizedConversation.ResummarizeAsync( + _chatClient, + indexOfFirstMessageToKeep, + SummarizationPrompt, + cancellationToken); + } + + return summarizedConversation.ToChatMessages(); + } + + /// Represents a conversation with an optional summary. + private readonly struct SummarizedConversation(string? summary, ChatMessage? systemMessage, IList unsummarizedMessages) + { + /// Creates a from a list of chat messages. + public static SummarizedConversation FromChatMessages(IEnumerable messages) + { + string? summary = null; + ChatMessage? systemMessage = null; + var unsummarizedMessages = new List(); + + foreach (var message in messages) + { + if (message.Role == ChatRole.System) + { + systemMessage ??= message; + } + else if (message.AdditionalProperties?.TryGetValue(SummaryKey, out var summaryValue) == true) + { + unsummarizedMessages.Clear(); + summary = summaryValue; + } + else + { + unsummarizedMessages.Add(message); + } + } + + return new(summary, systemMessage, unsummarizedMessages); + } + + /// Performs summarization by calling the chat client and updating the conversation state. + public async ValueTask ResummarizeAsync( + IChatClient chatClient, int indexOfFirstMessageToKeep, string summarizationPrompt, CancellationToken cancellationToken) + { + Debug.Assert(indexOfFirstMessageToKeep > 0, "Expected positive index for first message to keep."); + + // Generate the summary by sending unsummarized messages to the chat client + var summarizerChatMessages = ToSummarizerChatMessages(indexOfFirstMessageToKeep, summarizationPrompt); + var response = await chatClient.GetResponseAsync(summarizerChatMessages, cancellationToken: cancellationToken); + var newSummary = response.Text; + + // Attach the summary metadata to the last message being summarized + // This is what allows us to build on previously-generated summaries + var lastSummarizedMessage = unsummarizedMessages[indexOfFirstMessageToKeep - 1]; + var additionalProperties = lastSummarizedMessage.AdditionalProperties ??= []; + additionalProperties[SummaryKey] = newSummary; + + // Compute the new list of unsummarized messages + var newUnsummarizedMessages = unsummarizedMessages.Skip(indexOfFirstMessageToKeep).ToList(); + return new SummarizedConversation(newSummary, systemMessage, newUnsummarizedMessages); + } + + /// Determines the index of the first message to keep (not summarize) based on target and threshold counts. + public int FindIndexOfFirstMessageToKeep(int targetCount, int thresholdCount) + { + var earliestAllowedIndex = unsummarizedMessages.Count - thresholdCount - targetCount; + if (earliestAllowedIndex <= 0) + { + // Not enough messages to warrant summarization + return 0; + } + + // Start at the ideal cut point (keeping exactly targetCount messages) + var indexOfFirstMessageToKeep = unsummarizedMessages.Count - targetCount; + + // Move backward to skip over function call/result content at the boundary + // We want to keep complete function call sequences together with their responses + while (indexOfFirstMessageToKeep > 0) + { + if (!unsummarizedMessages[indexOfFirstMessageToKeep - 1].Contents.Any(IsToolRelatedContent)) + { + break; + } + + indexOfFirstMessageToKeep--; + } + + // Search backward within the threshold window to find a User message + // If found, cut right before it to avoid orphaning user questions from responses + for (var i = indexOfFirstMessageToKeep; i >= earliestAllowedIndex; i--) + { + if (unsummarizedMessages[i].Role == ChatRole.User) + { + return i; + } + } + + // No User message found within threshold - use the adjusted cut point + return indexOfFirstMessageToKeep; + } + + /// Converts the summarized conversation back into a collection of chat messages. + public IEnumerable ToChatMessages() + { + if (systemMessage is not null) + { + yield return systemMessage; + } + + if (summary is not null) + { + yield return new ChatMessage(ChatRole.Assistant, summary); + } + + foreach (var message in unsummarizedMessages) + { + yield return message; + } + } + + /// Returns whether the given relates to tool calling capabilities. + /// + /// This method returns for content types whose meaning depends on other related + /// instances in the conversation, such as function calls that require corresponding results, or other tool interactions that span + /// multiple messages. Such content should be kept together during summarization. + /// + private static bool IsToolRelatedContent(AIContent content) => content + is FunctionCallContent + or FunctionResultContent + or UserInputRequestContent + or UserInputResponseContent; + + /// Builds the list of messages to send to the chat client for summarization. + private IEnumerable ToSummarizerChatMessages(int indexOfFirstMessageToKeep, string summarizationPrompt) + { + if (summary is not null) + { + yield return new ChatMessage(ChatRole.Assistant, summary); + } + + for (var i = 0; i < indexOfFirstMessageToKeep; i++) + { + var message = unsummarizedMessages[i]; + if (!message.Contents.Any(IsToolRelatedContent)) + { + yield return message; + } + } + + yield return new ChatMessage(ChatRole.System, summarizationPrompt); + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGeneratorBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGeneratorBuilderExtensions.cs index 73867e4b2f7..d2657bfdd1f 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGeneratorBuilderExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/ConfigureOptionsEmbeddingGeneratorBuilderExtensions.cs @@ -4,8 +4,6 @@ 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. diff --git a/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGenerator.cs index 2eeb32891ea..f13f7273d89 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI/Embeddings/OpenTelemetryEmbeddingGenerator.cs @@ -6,7 +6,6 @@ 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; @@ -19,7 +18,7 @@ namespace Microsoft.Extensions.AI; /// Represents a delegating embedding generator that implements the OpenTelemetry Semantic Conventions for Generative AI systems. /// -/// This class provides an implementation of the Semantic Conventions for Generative AI systems v1.35, defined at . +/// This class provides an implementation of the Semantic Conventions for Generative AI systems v1.38, 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. @@ -33,10 +32,9 @@ public sealed class OpenTelemetryEmbeddingGenerator : Delega private readonly Histogram _tokenUsageHistogram; private readonly Histogram _operationDurationHistogram; - private readonly string? _system; + private readonly string? _providerName; private readonly string? _defaultModelId; private readonly int? _defaultModelDimensions; - private readonly string? _modelProvider; private readonly string? _endpointAddress; private readonly int _endpointPort; @@ -44,7 +42,7 @@ 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. + /// The to use for emitting any logging data from the generator. /// An optional source name that will be used on the telemetry data. #pragma warning disable IDE0060 // Remove unused parameter; it exists for future use and consistency with OpenTelemetryChatClient public OpenTelemetryEmbeddingGenerator(IEmbeddingGenerator innerGenerator, ILogger? logger = null, string? sourceName = null) @@ -55,11 +53,10 @@ public OpenTelemetryEmbeddingGenerator(IEmbeddingGenerator i if (innerGenerator!.GetService() is EmbeddingGeneratorMetadata metadata) { - _system = metadata.ProviderName; _defaultModelId = metadata.DefaultModelId; _defaultModelDimensions = metadata.DefaultModelDimensions; - _modelProvider = metadata.ProviderName; - _endpointAddress = metadata.ProviderUri?.GetLeftPart(UriPartial.Path); + _providerName = metadata.ProviderName; + _endpointAddress = metadata.ProviderUri?.Host; _endpointPort = metadata.ProviderUri?.Port ?? 0; } @@ -70,19 +67,15 @@ public OpenTelemetryEmbeddingGenerator(IEmbeddingGenerator i _tokenUsageHistogram = _meter.CreateHistogram( OpenTelemetryConsts.GenAI.Client.TokenUsage.Name, OpenTelemetryConsts.TokensUnit, - OpenTelemetryConsts.GenAI.Client.TokenUsage.Description -#if NET9_0_OR_GREATER - , advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.TokenUsage.ExplicitBucketBoundaries } -#endif + OpenTelemetryConsts.GenAI.Client.TokenUsage.Description, + advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.TokenUsage.ExplicitBucketBoundaries } ); _operationDurationHistogram = _meter.CreateHistogram( OpenTelemetryConsts.GenAI.Client.OperationDuration.Name, OpenTelemetryConsts.SecondsUnit, - OpenTelemetryConsts.GenAI.Client.OperationDuration.Description -#if NET9_0_OR_GREATER - , advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.OperationDuration.ExplicitBucketBoundaries } -#endif + OpenTelemetryConsts.GenAI.Client.OperationDuration.Description, + advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.OperationDuration.ExplicitBucketBoundaries } ); } @@ -92,13 +85,16 @@ public OpenTelemetryEmbeddingGenerator(IEmbeddingGenerator i /// /// if potentially sensitive information should be included in telemetry; /// if telemetry shouldn't include raw inputs and outputs. - /// The default value is . + /// The default value is , unless the OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT + /// environment variable is set to "true" (case-insensitive). /// /// /// By default, telemetry includes metadata, such as token counts, but not raw inputs /// and outputs or additional options data. + /// The default value can be overridden by setting the OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT + /// environment variable to "true". Explicitly setting this property will override the environment variable. /// - public bool EnableSensitiveData { get; set; } + public bool EnableSensitiveData { get; set; } = TelemetryHelpers.EnableSensitiveDataDefault; /// public override object? GetService(Type serviceType, object? serviceKey = null) => @@ -154,13 +150,13 @@ protected override void Dispose(bool disposing) string? modelId = options?.ModelId ?? _defaultModelId; activity = _activitySource.StartActivity( - string.IsNullOrWhiteSpace(modelId) ? OpenTelemetryConsts.GenAI.Embeddings : $"{OpenTelemetryConsts.GenAI.Embeddings} {modelId}", + string.IsNullOrWhiteSpace(modelId) ? OpenTelemetryConsts.GenAI.EmbeddingsName : $"{OpenTelemetryConsts.GenAI.EmbeddingsName} {modelId}", ActivityKind.Client, default(ActivityContext), [ - new(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.Embeddings), + new(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.EmbeddingsName), new(OpenTelemetryConsts.GenAI.Request.Model, modelId), - new(OpenTelemetryConsts.GenAI.SystemName, _modelProvider), + new(OpenTelemetryConsts.GenAI.Provider.Name, _providerName), ]); if (activity is not null) @@ -174,22 +170,16 @@ protected override void Dispose(bool disposing) if ((options?.Dimensions ?? _defaultModelDimensions) is int dimensionsValue) { - _ = activity.AddTag(OpenTelemetryConsts.GenAI.Request.EmbeddingDimensions, dimensionsValue); + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Embeddings.Dimension.Count, dimensionsValue); } - // 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. + // Log all additional request options as raw values on the span. // Since AdditionalProperties has undefined meaning, we treat it as potentially sensitive data. - if (EnableSensitiveData && - _system is not null && - options?.AdditionalProperties is { } props) + if (EnableSensitiveData && options?.AdditionalProperties is { } props) { foreach (KeyValuePair prop in props) { - _ = activity.AddTag( - OpenTelemetryConsts.GenAI.Request.PerProvider(_system, JsonNamingPolicy.SnakeCaseLower.ConvertName(prop.Key)), - prop.Value); + _ = activity.AddTag(prop.Key, prop.Value); } } } @@ -232,10 +222,10 @@ private void TraceResponse( if (_tokenUsageHistogram.Enabled && inputTokens.HasValue) { TagList tags = default; - tags.Add(OpenTelemetryConsts.GenAI.Token.Type, "input"); + tags.Add(OpenTelemetryConsts.GenAI.Token.Type, OpenTelemetryConsts.TokenTypeInput); AddMetricTags(ref tags, requestModelId, responseModelId); - _tokenUsageHistogram.Record(inputTokens.Value); + _tokenUsageHistogram.Record(inputTokens.Value, tags); } if (activity is not null) @@ -257,18 +247,13 @@ private void TraceResponse( _ = 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 (EnableSensitiveData && - _system is not null && - embeddings?.AdditionalProperties is { } props) + // Log all additional response properties as raw values on the span. + // Since AdditionalProperties has undefined meaning, we treat it as potentially sensitive data. + if (EnableSensitiveData && embeddings?.AdditionalProperties is { } props) { foreach (KeyValuePair prop in props) { - _ = activity.AddTag( - OpenTelemetryConsts.GenAI.Response.PerProvider(_system, JsonNamingPolicy.SnakeCaseLower.ConvertName(prop.Key)), - prop.Value); + _ = activity.AddTag(prop.Key, prop.Value); } } } @@ -276,14 +261,14 @@ _system is not null && private void AddMetricTags(ref TagList tags, string? requestModelId, string? responseModelId) { - tags.Add(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.Embeddings); + tags.Add(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.EmbeddingsName); if (requestModelId is not null) { tags.Add(OpenTelemetryConsts.GenAI.Request.Model, requestModelId); } - tags.Add(OpenTelemetryConsts.GenAI.SystemName, _modelProvider); + tags.Add(OpenTelemetryConsts.GenAI.Provider.Name, _providerName); if (_endpointAddress is string endpointAddress) { diff --git a/src/Libraries/Microsoft.Extensions.AI/Image/ConfigureOptionsImageGenerator.cs b/src/Libraries/Microsoft.Extensions.AI/Image/ConfigureOptionsImageGenerator.cs new file mode 100644 index 00000000000..b9e698a33f6 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Image/ConfigureOptionsImageGenerator.cs @@ -0,0 +1,53 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Represents a delegating image generator that configures a instance used by the remainder of the pipeline. +[Experimental("MEAI001")] +public sealed class ConfigureOptionsImageGenerator : DelegatingImageGenerator +{ + /// The callback delegate used to configure options. + private readonly Action _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 a clone of the caller-supplied instance + /// (or a newly constructed instance if the caller-supplied instance is ). + /// + /// or 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 ConfigureOptionsImageGenerator(IImageGenerator innerGenerator, Action configure) + : base(innerGenerator) + { + _configureOptions = Throw.IfNull(configure); + } + + /// + public override async Task GenerateAsync( + ImageGenerationRequest request, ImageGenerationOptions? options = null, CancellationToken cancellationToken = default) + { + return await base.GenerateAsync(request, Configure(options), cancellationToken); + } + + /// Creates and configures the to pass along to the inner generator. + private ImageGenerationOptions Configure(ImageGenerationOptions? options) + { + options = options?.Clone() ?? new(); + + _configureOptions(options); + + return options; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Image/ConfigureOptionsImageGeneratorBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/Image/ConfigureOptionsImageGeneratorBuilderExtensions.cs new file mode 100644 index 00000000000..52c953fba77 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Image/ConfigureOptionsImageGeneratorBuilderExtensions.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides extensions for configuring instances. +[Experimental("MEAI001")] +public static class ConfigureOptionsImageGeneratorBuilderExtensions +{ + /// + /// Adds a callback that configures a to be passed to the next generator in the pipeline. + /// + /// The . + /// + /// The delegate to invoke to configure the instance. + /// It is passed a clone of the caller-supplied instance (or a newly constructed instance if the caller-supplied instance is ). + /// + /// or is . + /// + /// This method can be used to set default options. The delegate is passed either a new instance of + /// if the caller didn't supply a instance, or a clone (via ) + /// of the caller-supplied instance if one was supplied. + /// + /// The . + public static ImageGeneratorBuilder ConfigureOptions( + this ImageGeneratorBuilder builder, Action configure) + { + _ = Throw.IfNull(builder); + _ = Throw.IfNull(configure); + + return builder.Use(innerGenerator => new ConfigureOptionsImageGenerator(innerGenerator, configure)); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Image/ImageGeneratorBuilder.cs b/src/Libraries/Microsoft.Extensions.AI/Image/ImageGeneratorBuilder.cs new file mode 100644 index 00000000000..9070ed8a59c --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Image/ImageGeneratorBuilder.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.Diagnostics.CodeAnalysis; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// A builder for creating pipelines of . +[Experimental("MEAI001")] +public sealed class ImageGeneratorBuilder +{ + private readonly Func _innerGeneratorFactory; + + /// The registered generator factory instances. + private List>? _generatorFactories; + + /// Initializes a new instance of the class. + /// The inner that represents the underlying backend. + /// is . + public ImageGeneratorBuilder(IImageGenerator innerGenerator) + { + _ = Throw.IfNull(innerGenerator); + _innerGeneratorFactory = _ => innerGenerator; + } + + /// Initializes a new instance of the class. + /// A callback that produces the inner that represents the underlying backend. + /// is . + public ImageGeneratorBuilder(Func innerGeneratorFactory) + { + _innerGeneratorFactory = Throw.IfNull(innerGeneratorFactory); + } + + /// Builds an that represents the entire pipeline. Calls to this instance will pass through each of the pipeline stages in turn. + /// + /// The that should provide services to the instances. + /// If null, an empty will be used. + /// + /// An instance of that represents the entire pipeline. + public IImageGenerator Build(IServiceProvider? services = null) + { + services ??= EmptyServiceProvider.Instance; + var imageGenerator = _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--) + { + imageGenerator = _generatorFactories[i](imageGenerator, services) ?? + throw new InvalidOperationException( + $"The {nameof(ImageGeneratorBuilder)} entry at index {i} returned null. " + + $"Ensure that the callbacks passed to {nameof(Use)} return non-null {nameof(IImageGenerator)} instances."); + } + } + + return imageGenerator; + } + + /// Adds a factory for an intermediate image generator to the image generator pipeline. + /// The generator factory function. + /// The updated instance. + /// is . + public ImageGeneratorBuilder Use(Func generatorFactory) + { + _ = Throw.IfNull(generatorFactory); + + return Use((innerGenerator, _) => generatorFactory(innerGenerator)); + } + + /// Adds a factory for an intermediate image generator to the image generator pipeline. + /// The generator factory function. + /// The updated instance. + /// is . + public ImageGeneratorBuilder Use(Func generatorFactory) + { + _ = Throw.IfNull(generatorFactory); + + (_generatorFactories ??= []).Add(generatorFactory); + return this; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Image/ImageGeneratorBuilderImageGeneratorExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/Image/ImageGeneratorBuilderImageGeneratorExtensions.cs new file mode 100644 index 00000000000..e8242287b68 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Image/ImageGeneratorBuilderImageGeneratorExtensions.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.Diagnostics.CodeAnalysis; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides extension methods for working with in the context of . +[Experimental("MEAI001")] +public static class ImageGeneratorBuilderImageGeneratorExtensions +{ + /// Creates a new using as its inner generator. + /// The generator to use as the inner generator. + /// The new instance. + /// is . + /// + /// This method is equivalent to using the constructor directly, + /// specifying as the inner generator. + /// + public static ImageGeneratorBuilder AsBuilder(this IImageGenerator innerGenerator) + { + _ = Throw.IfNull(innerGenerator); + + return new ImageGeneratorBuilder(innerGenerator); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Image/ImageGeneratorBuilderServiceCollectionExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/Image/ImageGeneratorBuilderServiceCollectionExtensions.cs new file mode 100644 index 00000000000..7868adf2eb3 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Image/ImageGeneratorBuilderServiceCollectionExtensions.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 System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DependencyInjection; + +/// Provides extension methods for registering with a . +[Experimental("MEAI001")] +public static class ImageGeneratorBuilderServiceCollectionExtensions +{ + /// Registers a singleton in the . + /// The to which the generator should be added. + /// The inner that represents the underlying backend. + /// The service lifetime for the generator. Defaults to . + /// An that can be used to build a pipeline around the inner generator. + /// or is . + /// The generator is registered as a singleton service. + public static ImageGeneratorBuilder AddImageGenerator( + this IServiceCollection serviceCollection, + IImageGenerator innerGenerator, + ServiceLifetime lifetime = ServiceLifetime.Singleton) + => AddImageGenerator(serviceCollection, _ => innerGenerator, lifetime); + + /// Registers a singleton in the . + /// The to which the generator should be added. + /// A callback that produces the inner that represents the underlying backend. + /// The service lifetime for the generator. Defaults to . + /// An that can be used to build a pipeline around the inner generator. + /// or is . + /// The generator is registered as a singleton service. + public static ImageGeneratorBuilder AddImageGenerator( + this IServiceCollection serviceCollection, + Func innerGeneratorFactory, + ServiceLifetime lifetime = ServiceLifetime.Singleton) + { + _ = Throw.IfNull(serviceCollection); + _ = Throw.IfNull(innerGeneratorFactory); + + var builder = new ImageGeneratorBuilder(innerGeneratorFactory); + serviceCollection.Add(new ServiceDescriptor(typeof(IImageGenerator), builder.Build, lifetime)); + return builder; + } + + /// Registers a keyed singleton in the . + /// The to which the generator should be added. + /// The key with which to associate the generator. + /// The inner that represents the underlying backend. + /// The service lifetime for the generator. Defaults to . + /// An that can be used to build a pipeline around the inner generator. + /// , , or is . + /// The generator is registered as a scoped service. + public static ImageGeneratorBuilder AddKeyedImageGenerator( + this IServiceCollection serviceCollection, + object? serviceKey, + IImageGenerator innerGenerator, + ServiceLifetime lifetime = ServiceLifetime.Singleton) + => AddKeyedImageGenerator(serviceCollection, serviceKey, _ => innerGenerator, lifetime); + + /// Registers a keyed singleton in the . + /// The to which the generator should be added. + /// The key with which to associate the generator. + /// A callback that produces the inner that represents the underlying backend. + /// The service lifetime for the generator. Defaults to . + /// An that can be used to build a pipeline around the inner generator. + /// , , or is . + /// The generator is registered as a scoped service. + public static ImageGeneratorBuilder AddKeyedImageGenerator( + this IServiceCollection serviceCollection, + object? serviceKey, + Func innerGeneratorFactory, + ServiceLifetime lifetime = ServiceLifetime.Singleton) + { + _ = Throw.IfNull(serviceCollection); + _ = Throw.IfNull(innerGeneratorFactory); + + var builder = new ImageGeneratorBuilder(innerGeneratorFactory); + serviceCollection.Add(new ServiceDescriptor(typeof(IImageGenerator), serviceKey, factory: (services, serviceKey) => builder.Build(services), lifetime)); + return builder; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Image/LoggingImageGenerator.cs b/src/Libraries/Microsoft.Extensions.AI/Image/LoggingImageGenerator.cs new file mode 100644 index 00000000000..f74701d766e --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Image/LoggingImageGenerator.cs @@ -0,0 +1,123 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// A delegating image generator that logs image generation operations to an . +/// +/// +/// The provided implementation of is thread-safe for concurrent use so long as the +/// employed is also thread-safe for concurrent use. +/// +/// +/// When the employed enables , the contents of +/// prompts and options are logged. These prompts and options may contain sensitive application data. +/// is disabled by default and should never be enabled in a production environment. +/// Prompts and options are not logged at other logging levels. +/// +/// +[Experimental("MEAI001")] +public partial class LoggingImageGenerator : DelegatingImageGenerator +{ + /// 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. + /// or is . + public LoggingImageGenerator(IImageGenerator innerGenerator, ILogger logger) + : base(innerGenerator) + { + _logger = Throw.IfNull(logger); + _jsonSerializerOptions = AIJsonUtilities.DefaultOptions; + } + + /// Gets or sets JSON serialization options to use when serializing logging data. + /// The value being set is . + public JsonSerializerOptions JsonSerializerOptions + { + get => _jsonSerializerOptions; + set => _jsonSerializerOptions = Throw.IfNull(value); + } + + /// + public override async Task GenerateAsync( + ImageGenerationRequest request, ImageGenerationOptions? options = null, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(request); + + if (_logger.IsEnabled(LogLevel.Debug)) + { + if (_logger.IsEnabled(LogLevel.Trace)) + { + LogInvokedSensitive(nameof(GenerateAsync), request.Prompt ?? string.Empty, AsJson(options), AsJson(this.GetService())); + } + else + { + LogInvoked(nameof(GenerateAsync)); + } + } + + try + { + var response = await base.GenerateAsync(request, options, cancellationToken); + + if (_logger.IsEnabled(LogLevel.Debug)) + { + if (_logger.IsEnabled(LogLevel.Trace) && response.Contents.All(c => c is not DataContent)) + { + LogCompletedSensitive(nameof(GenerateAsync), AsJson(response)); + } + else + { + LogCompleted(nameof(GenerateAsync)); + } + } + + return response; + } + catch (OperationCanceledException) + { + LogInvocationCanceled(nameof(GenerateAsync)); + throw; + } + catch (Exception ex) + { + LogInvocationFailed(nameof(GenerateAsync), ex); + throw; + } + } + + private string AsJson(T value) => TelemetryHelpers.AsJson(value, _jsonSerializerOptions); + + [LoggerMessage(LogLevel.Debug, "{MethodName} invoked.")] + private partial void LogInvoked(string methodName); + + [LoggerMessage(LogLevel.Trace, "{MethodName} invoked: Prompt: {Prompt}. Options: {ImageGenerationOptions}. Metadata: {ImageGeneratorMetadata}.")] + private partial void LogInvokedSensitive(string methodName, string prompt, string imageGenerationOptions, string imageGeneratorMetadata); + + [LoggerMessage(LogLevel.Debug, "{MethodName} completed.")] + private partial void LogCompleted(string methodName); + + [LoggerMessage(LogLevel.Trace, "{MethodName} completed: {ImageGenerationResponse}.")] + private partial void LogCompletedSensitive(string methodName, string imageGenerationResponse); + + [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/Image/LoggingImageGeneratorBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/Image/LoggingImageGeneratorBuilderExtensions.cs new file mode 100644 index 00000000000..ece65d942ed --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/Image/LoggingImageGeneratorBuilderExtensions.cs @@ -0,0 +1,57 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides extensions for configuring instances. +[Experimental("MEAI001")] +public static class LoggingImageGeneratorBuilderExtensions +{ + /// Adds logging to the image generator pipeline. + /// The . + /// + /// An optional used to create a logger with which logging should be performed. + /// If not supplied, a required instance will be resolved from the service provider. + /// + /// An optional callback that can be used to configure the instance. + /// The . + /// is . + /// + /// + /// When the employed enables , the contents of + /// prompts and options are logged. These prompts and options may contain sensitive application data. + /// is disabled by default and should never be enabled in a production environment. + /// Prompts and options are not logged at other logging levels. + /// + /// + public static ImageGeneratorBuilder UseLogging( + this ImageGeneratorBuilder builder, + ILoggerFactory? loggerFactory = null, + Action? configure = null) + { + _ = Throw.IfNull(builder); + + return builder.Use((innerGenerator, services) => + { + loggerFactory ??= services.GetRequiredService(); + + // If the factory we resolve is for the null logger, the LoggingImageGenerator will end up + // being an expensive nop, so skip adding it and just return the inner generator. + if (loggerFactory == NullLoggerFactory.Instance) + { + return innerGenerator; + } + + var imageGenerator = new LoggingImageGenerator(innerGenerator, loggerFactory.CreateLogger(typeof(LoggingImageGenerator))); + configure?.Invoke(imageGenerator); + return imageGenerator; + }); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj index 6331b3c1149..960ae56de65 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj +++ b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.csproj @@ -1,9 +1,10 @@ - + Microsoft.Extensions.AI Utilities for working with generative AI components. AI + true @@ -14,7 +15,7 @@ $(TargetFrameworks);netstandard2.0 - $(NoWarn);CA2227;CA1034;SA1316;S1067;S1121;S1994;S3253 + $(NoWarn);MEAI001 $(NoWarn);CA2007 - + true true @@ -44,6 +45,10 @@ + + + + @@ -52,5 +57,5 @@ - + diff --git a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.json b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.json index 59ed3d32fab..45b72f0aad1 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.json +++ b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.json @@ -515,6 +515,10 @@ } ], "Properties": [ + { + "Member": "System.Collections.Generic.IList? Microsoft.Extensions.AI.FunctionInvokingChatClient.AdditionalTools { get; set; }", + "Stage": "Stable" + }, { "Member": "bool Microsoft.Extensions.AI.FunctionInvokingChatClient.AllowConcurrentInvocation { get; set; }", "Stage": "Stable" @@ -542,6 +546,10 @@ { "Member": "int Microsoft.Extensions.AI.FunctionInvokingChatClient.MaximumIterationsPerRequest { get; set; }", "Stage": "Stable" + }, + { + "Member": "bool Microsoft.Extensions.AI.FunctionInvokingChatClient.TerminateOnUnknownCalls { get; set; }", + "Stage": "Stable" } ] }, diff --git a/src/Libraries/Microsoft.Extensions.AI/OpenTelemetryConsts.cs b/src/Libraries/Microsoft.Extensions.AI/OpenTelemetryConsts.cs index e656a473ba2..3def478d0e1 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 CA1716 // Identifiers should not match keywords #pragma warning disable S4041 // Type names should not match namespaces /// Provides constants used by various telemetry services. @@ -14,10 +13,17 @@ internal static class OpenTelemetryConsts public const string SecondsUnit = "s"; public const string TokensUnit = "token"; - public static class Event - { - public const string Name = "event.name"; - } + /// Environment variable name for controlling whether sensitive content should be captured in telemetry by default. + public const string GenAICaptureMessageContentEnvVar = "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"; + + public const string ToolTypeFunction = "function"; + + public const string TypeText = "text"; + public const string TypeJson = "json"; + public const string TypeImage = "image"; + + public const string TokenTypeInput = "input"; + public const string TokenTypeOutput = "output"; public static class Error { @@ -26,17 +32,13 @@ public static class Error public static class GenAI { - public const string Choice = "gen_ai.choice"; - public const string SystemName = "gen_ai.system"; - - public const string Chat = "chat"; - public const string Embeddings = "embeddings"; - public const string ExecuteTool = "execute_tool"; + public const string ChatName = "chat"; + public const string EmbeddingsName = "embeddings"; + public const string ExecuteToolName = "execute_tool"; + public const string OrchestrateToolsName = "orchestrate_tools"; // Non-standard + public const string GenerateContentName = "generate_content"; - public static class Assistant - { - public const string Message = "gen_ai.assistant.message"; - } + public const string SystemInstructions = "gen_ai.system_instructions"; public static class Client { @@ -60,6 +62,19 @@ public static class Conversation public const string Id = "gen_ai.conversation.id"; } + public static class Embeddings + { + public static class Dimension + { + public const string Count = "gen_ai.embeddings.dimension.count"; + } + } + + public static class Input + { + public const string Messages = "gen_ai.input.messages"; + } + public static class Operation { public const string Name = "gen_ai.operation.name"; @@ -67,12 +82,18 @@ public static class Operation public static class Output { + public const string Messages = "gen_ai.output.messages"; public const string Type = "gen_ai.output.type"; } + public static class Provider + { + public const string Name = "gen_ai.provider.name"; + } + public static class Request { - public const string EmbeddingDimensions = "gen_ai.request.embedding.dimensions"; + public const string ChoiceCount = "gen_ai.request.choice.count"; 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"; @@ -82,8 +103,6 @@ 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 @@ -91,13 +110,6 @@ public static class Response public const string FinishReasons = "gen_ai.response.finish_reasons"; public const string Id = "gen_ai.response.id"; public const string Model = "gen_ai.response.model"; - - public static string PerProvider(string providerName, string parameterName) => $"gen_ai.{providerName}.response.{parameterName}"; - } - - public static class System - { - public const string Message = "gen_ai.system.message"; } public static class Token @@ -110,10 +122,14 @@ public static class Tool public const string Name = "gen_ai.tool.name"; public const string Description = "gen_ai.tool.description"; public const string Message = "gen_ai.tool.message"; + public const string Type = "gen_ai.tool.type"; + public const string Definitions = "gen_ai.tool.definitions"; public static class Call { public const string Id = "gen_ai.tool.call.id"; + public const string Arguments = "gen_ai.tool.call.arguments"; + public const string Result = "gen_ai.tool.call.result"; } } @@ -122,11 +138,6 @@ public static class Usage public const string InputTokens = "gen_ai.usage.input_tokens"; public const string OutputTokens = "gen_ai.usage.output_tokens"; } - - public static class User - { - public const string Message = "gen_ai.user.message"; - } } public static class Server diff --git a/src/Libraries/Microsoft.Extensions.AI/SpeechToText/ConfigureOptionsSpeechToTextClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/SpeechToText/ConfigureOptionsSpeechToTextClientBuilderExtensions.cs index 037d25a14d5..f9f492b2635 100644 --- a/src/Libraries/Microsoft.Extensions.AI/SpeechToText/ConfigureOptionsSpeechToTextClientBuilderExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/SpeechToText/ConfigureOptionsSpeechToTextClientBuilderExtensions.cs @@ -5,8 +5,6 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.Shared.Diagnostics; -#pragma warning disable SA1629 // Documentation text should end with a period - namespace Microsoft.Extensions.AI; /// Provides extensions for configuring instances. diff --git a/src/Libraries/Microsoft.Extensions.AI/SpeechToText/LoggingSpeechToTextClient.cs b/src/Libraries/Microsoft.Extensions.AI/SpeechToText/LoggingSpeechToTextClient.cs index e7bf7850a94..10e499a7e57 100644 --- a/src/Libraries/Microsoft.Extensions.AI/SpeechToText/LoggingSpeechToTextClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/SpeechToText/LoggingSpeechToTextClient.cs @@ -179,7 +179,7 @@ public override async IAsyncEnumerable GetStreamingT } } - private string AsJson(T value) => LoggingHelpers.AsJson(value, _jsonSerializerOptions); + private string AsJson(T value) => TelemetryHelpers.AsJson(value, _jsonSerializerOptions); [LoggerMessage(LogLevel.Debug, "{MethodName} invoked.")] private partial void LogInvoked(string methodName); diff --git a/src/Libraries/Microsoft.Extensions.AI/SpeechToText/LoggingSpeechToTextClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/SpeechToText/LoggingSpeechToTextClientBuilderExtensions.cs index 92a67189982..54ed411bd35 100644 --- a/src/Libraries/Microsoft.Extensions.AI/SpeechToText/LoggingSpeechToTextClientBuilderExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/SpeechToText/LoggingSpeechToTextClientBuilderExtensions.cs @@ -14,7 +14,7 @@ namespace Microsoft.Extensions.AI; [Experimental("MEAI001")] public static class LoggingSpeechToTextClientBuilderExtensions { - /// Adds logging to the audio transcription client pipeline. + /// Adds logging to the speech-to-text client pipeline. /// The . /// /// An optional used to create a logger with which logging should be performed. diff --git a/src/Libraries/Microsoft.Extensions.AI/SpeechToText/OpenTelemetrySpeechToTextClient.cs b/src/Libraries/Microsoft.Extensions.AI/SpeechToText/OpenTelemetrySpeechToTextClient.cs new file mode 100644 index 00000000000..3b0688ba585 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/SpeechToText/OpenTelemetrySpeechToTextClient.cs @@ -0,0 +1,363 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Metrics; +using System.IO; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +#pragma warning disable S3358 // Ternary operators should not be nested +#pragma warning disable SA1111 // Closing parenthesis should be on line of last parameter +#pragma warning disable SA1113 // Comma should be on the same line as previous parameter + +namespace Microsoft.Extensions.AI; + +/// Represents a delegating speech-to-text client that implements the OpenTelemetry Semantic Conventions for Generative AI systems. +/// +/// This class provides an implementation of the Semantic Conventions for Generative AI systems v1.38, defined at . +/// The specification is still experimental and subject to change; as such, the telemetry output by this client is also subject to change. +/// +[Experimental("MEAI001")] +public sealed class OpenTelemetrySpeechToTextClient : DelegatingSpeechToTextClient +{ + private readonly ActivitySource _activitySource; + private readonly Meter _meter; + + private readonly Histogram _tokenUsageHistogram; + private readonly Histogram _operationDurationHistogram; + + private readonly string? _defaultModelId; + private readonly string? _providerName; + private readonly string? _serverAddress; + private readonly int _serverPort; + + /// Initializes a new instance of the class. + /// The underlying . + /// The to use for emitting any logging data from the client. + /// An optional source name that will be used on the telemetry data. +#pragma warning disable IDE0060 // Remove unused parameter; it exists for consistency with IChatClient and future use + public OpenTelemetrySpeechToTextClient(ISpeechToTextClient innerClient, ILogger? logger = null, string? sourceName = null) +#pragma warning restore IDE0060 + : base(innerClient) + { + Debug.Assert(innerClient is not null, "Should have been validated by the base ctor"); + + if (innerClient!.GetService() is SpeechToTextClientMetadata metadata) + { + _defaultModelId = metadata.DefaultModelId; + _providerName = metadata.ProviderName; + _serverAddress = metadata.ProviderUri?.Host; + _serverPort = metadata.ProviderUri?.Port ?? 0; + } + + string name = string.IsNullOrEmpty(sourceName) ? OpenTelemetryConsts.DefaultSourceName : sourceName!; + _activitySource = new(name); + _meter = new(name); + + _tokenUsageHistogram = _meter.CreateHistogram( + OpenTelemetryConsts.GenAI.Client.TokenUsage.Name, + OpenTelemetryConsts.TokensUnit, + OpenTelemetryConsts.GenAI.Client.TokenUsage.Description, + advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.TokenUsage.ExplicitBucketBoundaries } + ); + + _operationDurationHistogram = _meter.CreateHistogram( + OpenTelemetryConsts.GenAI.Client.OperationDuration.Name, + OpenTelemetryConsts.SecondsUnit, + OpenTelemetryConsts.GenAI.Client.OperationDuration.Description, + advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.OperationDuration.ExplicitBucketBoundaries } + ); + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + _activitySource.Dispose(); + _meter.Dispose(); + } + + base.Dispose(disposing); + } + + /// + /// Gets or sets a value indicating whether potentially sensitive information should be included in telemetry. + /// + /// + /// if potentially sensitive information should be included in telemetry; + /// if telemetry shouldn't include raw inputs and outputs. + /// The default value is , unless the OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT + /// environment variable is set to "true" (case-insensitive). + /// + /// + /// By default, telemetry includes metadata, such as token counts, but not raw inputs + /// and outputs, such as message content, function call arguments, and function call results. + /// The default value can be overridden by setting the OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT + /// environment variable to "true". Explicitly setting this property will override the environment variable. + /// + public bool EnableSensitiveData { get; set; } = TelemetryHelpers.EnableSensitiveDataDefault; + + /// + public override object? GetService(Type serviceType, object? serviceKey = null) => + serviceType == typeof(ActivitySource) ? _activitySource : + base.GetService(serviceType, serviceKey); + + /// + public override async Task GetTextAsync(Stream audioSpeechStream, SpeechToTextOptions? options = null, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(audioSpeechStream); + + using Activity? activity = CreateAndConfigureActivity(options); + Stopwatch? stopwatch = _operationDurationHistogram.Enabled ? Stopwatch.StartNew() : null; + string? requestModelId = options?.ModelId ?? _defaultModelId; + + SpeechToTextResponse? response = null; + Exception? error = null; + try + { + response = await base.GetTextAsync(audioSpeechStream, options, cancellationToken); + return response; + } + catch (Exception ex) + { + error = ex; + throw; + } + finally + { + TraceResponse(activity, requestModelId, response, error, stopwatch); + } + } + + /// + public override async IAsyncEnumerable GetStreamingTextAsync( + Stream audioSpeechStream, SpeechToTextOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(audioSpeechStream); + + using Activity? activity = CreateAndConfigureActivity(options); + Stopwatch? stopwatch = _operationDurationHistogram.Enabled ? Stopwatch.StartNew() : null; + string? requestModelId = options?.ModelId ?? _defaultModelId; + + IAsyncEnumerable updates; + try + { + updates = base.GetStreamingTextAsync(audioSpeechStream, options, cancellationToken); + } + catch (Exception ex) + { + TraceResponse(activity, requestModelId, response: null, ex, stopwatch); + throw; + } + + var responseEnumerator = updates.GetAsyncEnumerator(cancellationToken); + List trackedUpdates = []; + Exception? error = null; + try + { + while (true) + { + SpeechToTextResponseUpdate update; + try + { + if (!await responseEnumerator.MoveNextAsync()) + { + break; + } + + update = responseEnumerator.Current; + } + catch (Exception ex) + { + error = ex; + throw; + } + + trackedUpdates.Add(update); + yield return update; + Activity.Current = activity; // workaround for https://github.com/dotnet/runtime/issues/47802 + } + } + finally + { + TraceResponse(activity, requestModelId, trackedUpdates.ToSpeechToTextResponse(), error, stopwatch); + + await responseEnumerator.DisposeAsync(); + } + } + + /// Creates an activity for a speech-to-text request, or returns if not enabled. + private Activity? CreateAndConfigureActivity(SpeechToTextOptions? options) + { + Activity? activity = null; + if (_activitySource.HasListeners()) + { + string? modelId = options?.ModelId ?? _defaultModelId; + + activity = _activitySource.StartActivity( + string.IsNullOrWhiteSpace(modelId) ? OpenTelemetryConsts.GenAI.GenerateContentName : $"{OpenTelemetryConsts.GenAI.GenerateContentName} {modelId}", + ActivityKind.Client); + + if (activity is { IsAllDataRequested: true }) + { + _ = activity + .AddTag(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.GenerateContentName) + .AddTag(OpenTelemetryConsts.GenAI.Request.Model, modelId) + .AddTag(OpenTelemetryConsts.GenAI.Provider.Name, _providerName) + .AddTag(OpenTelemetryConsts.GenAI.Output.Type, OpenTelemetryConsts.TypeText); + + if (_serverAddress is not null) + { + _ = activity + .AddTag(OpenTelemetryConsts.Server.Address, _serverAddress) + .AddTag(OpenTelemetryConsts.Server.Port, _serverPort); + } + + if (options is not null) + { + if (EnableSensitiveData) + { + // Log all additional request options as raw values on the span. + // Since AdditionalProperties has undefined meaning, we treat it as potentially sensitive data. + if (options.AdditionalProperties is { } props) + { + foreach (KeyValuePair prop in props) + { + _ = activity.AddTag(prop.Key, prop.Value); + } + } + } + } + } + } + + return activity; + } + + /// Adds speech-to-text response information to the activity. + private void TraceResponse( + Activity? activity, + string? requestModelId, + SpeechToTextResponse? response, + Exception? error, + Stopwatch? stopwatch) + { + if (_operationDurationHistogram.Enabled && stopwatch is not null) + { + TagList tags = default; + + AddMetricTags(ref tags, requestModelId, response); + if (error is not null) + { + tags.Add(OpenTelemetryConsts.Error.Type, error.GetType().FullName); + } + + _operationDurationHistogram.Record(stopwatch.Elapsed.TotalSeconds, tags); + } + + if (_tokenUsageHistogram.Enabled && response?.Usage is { } usage) + { + if (usage.InputTokenCount is long inputTokens) + { + TagList tags = default; + tags.Add(OpenTelemetryConsts.GenAI.Token.Type, OpenTelemetryConsts.TokenTypeInput); + AddMetricTags(ref tags, requestModelId, response); + _tokenUsageHistogram.Record((int)inputTokens, tags); + } + + if (usage.OutputTokenCount is long outputTokens) + { + TagList tags = default; + tags.Add(OpenTelemetryConsts.GenAI.Token.Type, OpenTelemetryConsts.TokenTypeOutput); + AddMetricTags(ref tags, requestModelId, response); + _tokenUsageHistogram.Record((int)outputTokens, tags); + } + } + + if (error is not null) + { + _ = activity? + .AddTag(OpenTelemetryConsts.Error.Type, error.GetType().FullName) + .SetStatus(ActivityStatusCode.Error, error.Message); + } + + if (response is not null) + { + AddOutputMessagesTags(response, activity); + + if (activity is not null) + { + if (!string.IsNullOrWhiteSpace(response.ResponseId)) + { + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Response.Id, response.ResponseId); + } + + if (response.ModelId is not null) + { + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Response.Model, response.ModelId); + } + + if (response.Usage?.InputTokenCount is long inputTokens) + { + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Usage.InputTokens, (int)inputTokens); + } + + if (response.Usage?.OutputTokenCount is long outputTokens) + { + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Usage.OutputTokens, (int)outputTokens); + } + + // Log all additional response properties as raw values on the span. + // Since AdditionalProperties has undefined meaning, we treat it as potentially sensitive data. + if (EnableSensitiveData && response.AdditionalProperties is { } props) + { + foreach (KeyValuePair prop in props) + { + _ = activity.AddTag(prop.Key, prop.Value); + } + } + } + } + + void AddMetricTags(ref TagList tags, string? requestModelId, SpeechToTextResponse? response) + { + tags.Add(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.GenerateContentName); + + if (requestModelId is not null) + { + tags.Add(OpenTelemetryConsts.GenAI.Request.Model, requestModelId); + } + + tags.Add(OpenTelemetryConsts.GenAI.Provider.Name, _providerName); + + if (_serverAddress is string endpointAddress) + { + tags.Add(OpenTelemetryConsts.Server.Address, endpointAddress); + tags.Add(OpenTelemetryConsts.Server.Port, _serverPort); + } + + if (response?.ModelId is string responseModel) + { + tags.Add(OpenTelemetryConsts.GenAI.Response.Model, responseModel); + } + } + } + + private void AddOutputMessagesTags(SpeechToTextResponse response, Activity? activity) + { + if (EnableSensitiveData && activity is { IsAllDataRequested: true }) + { + _ = activity.AddTag( + OpenTelemetryConsts.GenAI.Output.Messages, + OpenTelemetryChatClient.SerializeChatMessages([new(ChatRole.Assistant, response.Contents)])); + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/SpeechToText/OpenTelemetrySpeechToTextClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/SpeechToText/OpenTelemetrySpeechToTextClientBuilderExtensions.cs new file mode 100644 index 00000000000..5e23a41358e --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/SpeechToText/OpenTelemetrySpeechToTextClientBuilderExtensions.cs @@ -0,0 +1,42 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides extensions for configuring instances. +[Experimental("MEAI001")] +public static class OpenTelemetrySpeechToTextClientBuilderExtensions +{ + /// + /// Adds OpenTelemetry support to the speech-to-text client pipeline, following the OpenTelemetry Semantic Conventions for Generative AI systems. + /// + /// + /// The draft specification this follows is available at . + /// The specification is still experimental and subject to change; as such, the telemetry output by this client is also subject to change. + /// + /// The . + /// An optional to use to create a logger for logging events. + /// An optional source name that will be used on the telemetry data. + /// An optional callback that can be used to configure the instance. + /// The . + public static SpeechToTextClientBuilder UseOpenTelemetry( + this SpeechToTextClientBuilder builder, + ILoggerFactory? loggerFactory = null, + string? sourceName = null, + Action? configure = null) => + Throw.IfNull(builder).Use((innerClient, services) => + { + loggerFactory ??= services.GetService(); + + var client = new OpenTelemetrySpeechToTextClient(innerClient, loggerFactory?.CreateLogger(typeof(OpenTelemetrySpeechToTextClient)), sourceName); + configure?.Invoke(client); + + return client; + }); +} diff --git a/src/Libraries/Microsoft.Extensions.AI/SpeechToText/SpeechToTextClientBuilder.cs b/src/Libraries/Microsoft.Extensions.AI/SpeechToText/SpeechToTextClientBuilder.cs index dae4224a94d..1945a140762 100644 --- a/src/Libraries/Microsoft.Extensions.AI/SpeechToText/SpeechToTextClientBuilder.cs +++ b/src/Libraries/Microsoft.Extensions.AI/SpeechToText/SpeechToTextClientBuilder.cs @@ -58,7 +58,7 @@ public ISpeechToTextClient Build(IServiceProvider? services = null) return audioClient; } - /// Adds a factory for an intermediate audio transcription client to the audio transcription client pipeline. + /// Adds a factory for an intermediate speech-to-text client to the speech-to-text client pipeline. /// The client factory function. /// The updated instance. public SpeechToTextClientBuilder Use(Func clientFactory) @@ -68,7 +68,7 @@ public SpeechToTextClientBuilder Use(Func clientFactory(innerClient)); } - /// Adds a factory for an intermediate audio transcription client to the audio transcription client pipeline. + /// Adds a factory for an intermediate speech-to-text client to the speech-to-text client pipeline. /// The client factory function. /// The updated instance. public SpeechToTextClientBuilder Use(Func clientFactory) diff --git a/src/Libraries/Microsoft.Extensions.AI/SpeechToText/SpeechToTextClientBuilderServiceCollectionExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/SpeechToText/SpeechToTextClientBuilderServiceCollectionExtensions.cs index 5ef54e8db26..243cb057068 100644 --- a/src/Libraries/Microsoft.Extensions.AI/SpeechToText/SpeechToTextClientBuilderServiceCollectionExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/SpeechToText/SpeechToTextClientBuilderServiceCollectionExtensions.cs @@ -52,7 +52,7 @@ public static SpeechToTextClientBuilder AddSpeechToTextClient( /// The client is registered as a scoped service. public static SpeechToTextClientBuilder AddKeyedSpeechToTextClient( this IServiceCollection serviceCollection, - object serviceKey, + object? serviceKey, ISpeechToTextClient innerClient, ServiceLifetime lifetime = ServiceLifetime.Singleton) => AddKeyedSpeechToTextClient(serviceCollection, serviceKey, _ => innerClient, lifetime); @@ -66,12 +66,11 @@ public static SpeechToTextClientBuilder AddKeyedSpeechToTextClient( /// The client is registered as a scoped service. public static SpeechToTextClientBuilder AddKeyedSpeechToTextClient( this IServiceCollection serviceCollection, - object serviceKey, + object? serviceKey, Func innerClientFactory, ServiceLifetime lifetime = ServiceLifetime.Singleton) { _ = Throw.IfNull(serviceCollection); - _ = Throw.IfNull(serviceKey); _ = Throw.IfNull(innerClientFactory); var builder = new SpeechToTextClientBuilder(innerClientFactory); diff --git a/src/Libraries/Microsoft.Extensions.AI/LoggingHelpers.cs b/src/Libraries/Microsoft.Extensions.AI/TelemetryHelpers.cs similarity index 55% rename from src/Libraries/Microsoft.Extensions.AI/LoggingHelpers.cs rename to src/Libraries/Microsoft.Extensions.AI/TelemetryHelpers.cs index 72a7e283988..46a9b862e5b 100644 --- a/src/Libraries/Microsoft.Extensions.AI/LoggingHelpers.cs +++ b/src/Libraries/Microsoft.Extensions.AI/TelemetryHelpers.cs @@ -1,17 +1,20 @@ // 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; using System.Text.Json; namespace Microsoft.Extensions.AI; -/// Provides internal helpers for implementing logging. -internal static class LoggingHelpers +/// Provides internal helpers for implementing telemetry. +internal static class TelemetryHelpers { + /// Gets a value indicating whether the OpenTelemetry clients should enable their EnableSensitiveData property's by default. + /// Defaults to false. May be overridden by setting the OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT environment variable to "true". + public static bool EnableSensitiveDataDefault { get; } = + Environment.GetEnvironmentVariable(OpenTelemetryConsts.GenAICaptureMessageContentEnvVar) is string envVar && + string.Equals(envVar, "true", StringComparison.OrdinalIgnoreCase); + /// Serializes as JSON for logging purposes. public static string AsJson(T value, JsonSerializerOptions? options) { @@ -24,6 +27,7 @@ public static string AsJson(T value, JsonSerializerOptions? options) } catch { + // If we fail to serialize, just fall through to returning "{}". } } diff --git a/src/Libraries/Microsoft.Extensions.AI/ToolReduction/ChatClientBuilderToolReductionExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/ToolReduction/ChatClientBuilderToolReductionExtensions.cs new file mode 100644 index 00000000000..5a644267328 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ToolReduction/ChatClientBuilderToolReductionExtensions.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.Diagnostics.CodeAnalysis; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Extension methods for adding tool reduction middleware to a chat client pipeline. +[Experimental("MEAI001")] +public static class ChatClientBuilderToolReductionExtensions +{ + /// + /// Adds tool reduction to the chat client pipeline using the specified . + /// + /// The chat client builder. + /// The reduction strategy. + /// The original builder for chaining. + /// If or is . + /// + /// This should typically appear in the pipeline before function invocation middleware so that only the reduced tools + /// are exposed to the underlying provider. + /// + public static ChatClientBuilder UseToolReduction(this ChatClientBuilder builder, IToolReductionStrategy strategy) + { + _ = Throw.IfNull(builder); + _ = Throw.IfNull(strategy); + + return builder.Use(inner => new ToolReducingChatClient(inner, strategy)); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/ToolReduction/EmbeddingToolReductionStrategy.cs b/src/Libraries/Microsoft.Extensions.AI/ToolReduction/EmbeddingToolReductionStrategy.cs new file mode 100644 index 00000000000..f9e4c60995a --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ToolReduction/EmbeddingToolReductionStrategy.cs @@ -0,0 +1,330 @@ +// Licensed 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; +using System.Diagnostics.CodeAnalysis; +using System.Numerics.Tensors; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +#pragma warning disable IDE0032 // Use auto property, suppressed until repo updates to C# 14 + +/// +/// A tool reduction strategy that ranks tools by embedding similarity to the current conversation context. +/// +/// +/// The strategy embeds each tool (name + description by default) once (cached) and embeds the current +/// conversation content each request. It then selects the top toolLimit tools by similarity. +/// +[Experimental("MEAI001")] +public sealed class EmbeddingToolReductionStrategy : IToolReductionStrategy +{ + private readonly ConditionalWeakTable> _toolEmbeddingsCache = new(); + private readonly IEmbeddingGenerator> _embeddingGenerator; + private readonly int _toolLimit; + + private Func _toolEmbeddingTextSelector = static t => + { + if (string.IsNullOrWhiteSpace(t.Name)) + { + return t.Description; + } + + if (string.IsNullOrWhiteSpace(t.Description)) + { + return t.Name; + } + + return t.Name + Environment.NewLine + t.Description; + }; + + private Func, ValueTask> _messagesEmbeddingTextSelector = static messages => + { + var sb = new StringBuilder(); + foreach (var message in messages) + { + var contents = message.Contents; + for (var i = 0; i < contents.Count; i++) + { + string text; + switch (contents[i]) + { + case TextContent content: + text = content.Text; + break; + case TextReasoningContent content: + text = content.Text; + break; + default: + continue; + } + + _ = sb.AppendLine(text); + } + } + + return new ValueTask(sb.ToString()); + }; + + private Func, ReadOnlyMemory, float> _similarity = static (a, b) => TensorPrimitives.CosineSimilarity(a.Span, b.Span); + + private Func _isRequiredTool = static _ => false; + + /// + /// Initializes a new instance of the class. + /// + /// Embedding generator used to produce embeddings. + /// Maximum number of tools to return, excluding required tools. Must be greater than zero. + public EmbeddingToolReductionStrategy( + IEmbeddingGenerator> embeddingGenerator, + int toolLimit) + { + _embeddingGenerator = Throw.IfNull(embeddingGenerator); + _toolLimit = Throw.IfLessThanOrEqual(toolLimit, min: 0); + } + + /// + /// Gets or sets the selector used to generate a single text string from a tool. + /// + /// + /// Defaults to: Name + "\n" + Description (omitting empty parts). + /// + public Func ToolEmbeddingTextSelector + { + get => _toolEmbeddingTextSelector; + set => _toolEmbeddingTextSelector = Throw.IfNull(value); + } + + /// + /// Gets or sets the selector used to generate a single text string from a collection of chat messages for + /// embedding purposes. + /// + public Func, ValueTask> MessagesEmbeddingTextSelector + { + get => _messagesEmbeddingTextSelector; + set => _messagesEmbeddingTextSelector = Throw.IfNull(value); + } + + /// + /// Gets or sets a similarity function applied to (query, tool) embedding vectors. + /// + /// + /// Defaults to cosine similarity. + /// + public Func, ReadOnlyMemory, float> Similarity + { + get => _similarity; + set => _similarity = Throw.IfNull(value); + } + + /// + /// Gets or sets a function that determines whether a tool is required (always included). + /// + /// + /// If this returns , the tool is included regardless of ranking and does not count against + /// the configured non-required tool limit. A tool explicitly named by (when + /// is non-null) is also treated as required, independent + /// of this delegate's result. + /// + public Func IsRequiredTool + { + get => _isRequiredTool; + set => _isRequiredTool = Throw.IfNull(value); + } + + /// + /// Gets or sets a value indicating whether to preserve original ordering of selected tools. + /// If (default), tools are ordered by descending similarity. + /// If , the top-N tools by similarity are re-emitted in their original order. + /// + public bool PreserveOriginalOrdering { get; set; } + + /// + public async Task> SelectToolsForRequestAsync( + IEnumerable messages, + ChatOptions? options, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(messages); + + if (options?.Tools is not { Count: > 0 } tools) + { + // Prefer the original tools list reference if possible. + // This allows ToolReducingChatClient to avoid unnecessarily copying ChatOptions. + // When no reduction is performed. + return options?.Tools ?? []; + } + + Debug.Assert(_toolLimit > 0, "Expected the tool count limit to be greater than zero."); + + if (tools.Count <= _toolLimit) + { + // Since the total number of tools doesn't exceed the configured tool limit, + // there's no need to determine which tools are optional, i.e., subject to reduction. + // We can return the original tools list early. + return tools; + } + + var toolRankingInfoArray = ArrayPool.Shared.Rent(tools.Count); + try + { + var toolRankingInfoMemory = toolRankingInfoArray.AsMemory(start: 0, length: tools.Count); + + // We allocate tool rankings in a contiguous chunk of memory, but partition them such that + // required tools come first and are immediately followed by optional tools. + // This allows us to separately rank optional tools by similarity score, but then later re-order + // the top N tools (including required tools) to preserve their original relative order. + var (requiredTools, optionalTools) = PartitionToolRankings(toolRankingInfoMemory, tools, options.ToolMode); + + if (optionalTools.Length <= _toolLimit) + { + // There aren't enough optional tools to require reduction, so we'll return the original + // tools list. + return tools; + } + + // Build query text from recent messages. + var queryText = await MessagesEmbeddingTextSelector(messages).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(queryText)) + { + // We couldn't build a meaningful query, likely because the message list was empty. + // We'll just return the original tools list. + return tools; + } + + var queryEmbedding = await _embeddingGenerator.GenerateAsync(queryText, cancellationToken: cancellationToken).ConfigureAwait(false); + + // Compute and populate similarity scores in the tool ranking info. + await ComputeSimilarityScoresAsync(optionalTools, queryEmbedding, cancellationToken); + + var topTools = toolRankingInfoMemory.Slice(start: 0, length: requiredTools.Length + _toolLimit); +#if NET + optionalTools.Span.Sort(AIToolRankingInfo.CompareByDescendingSimilarityScore); + if (PreserveOriginalOrdering) + { + topTools.Span.Sort(AIToolRankingInfo.CompareByOriginalIndex); + } +#else + Array.Sort(toolRankingInfoArray, index: requiredTools.Length, length: optionalTools.Length, AIToolRankingInfo.CompareByDescendingSimilarityScore); + if (PreserveOriginalOrdering) + { + Array.Sort(toolRankingInfoArray, index: 0, length: topTools.Length, AIToolRankingInfo.CompareByOriginalIndex); + } +#endif + return ToToolList(topTools.Span); + + static List ToToolList(ReadOnlySpan toolInfo) + { + var result = new List(capacity: toolInfo.Length); + foreach (var info in toolInfo) + { + result.Add(info.Tool); + } + + return result; + } + } + finally + { + ArrayPool.Shared.Return(toolRankingInfoArray); + } + } + + private (Memory RequiredTools, Memory OptionalTools) PartitionToolRankings( + Memory toolRankingInfo, IList tools, ChatToolMode? toolMode) + { + // Always include a tool if its name matches the required function name. + var requiredFunctionName = (toolMode as RequiredChatToolMode)?.RequiredFunctionName; + var nextRequiredToolIndex = 0; + var nextOptionalToolIndex = tools.Count - 1; + for (var i = 0; i < toolRankingInfo.Length; i++) + { + var tool = tools[i]; + var isRequiredByToolMode = requiredFunctionName is not null && string.Equals(requiredFunctionName, tool.Name, StringComparison.Ordinal); + var toolIndex = isRequiredByToolMode || IsRequiredTool(tool) + ? nextRequiredToolIndex++ + : nextOptionalToolIndex--; + toolRankingInfo.Span[toolIndex] = new AIToolRankingInfo(tool, originalIndex: i); + } + + return ( + RequiredTools: toolRankingInfo.Slice(0, nextRequiredToolIndex), + OptionalTools: toolRankingInfo.Slice(nextRequiredToolIndex)); + } + + private async Task ComputeSimilarityScoresAsync(Memory toolInfo, Embedding queryEmbedding, CancellationToken cancellationToken) + { + var anyCacheMisses = false; + List cacheMissToolEmbeddingTexts = null!; + List cacheMissToolInfoIndexes = null!; + for (var i = 0; i < toolInfo.Length; i++) + { + ref var info = ref toolInfo.Span[i]; + if (_toolEmbeddingsCache.TryGetValue(info.Tool, out var toolEmbedding)) + { + info.SimilarityScore = Similarity(queryEmbedding.Vector, toolEmbedding.Vector); + } + else + { + if (!anyCacheMisses) + { + anyCacheMisses = true; + cacheMissToolEmbeddingTexts = []; + cacheMissToolInfoIndexes = []; + } + + var text = ToolEmbeddingTextSelector(info.Tool); + cacheMissToolEmbeddingTexts.Add(text); + cacheMissToolInfoIndexes.Add(i); + } + } + + if (!anyCacheMisses) + { + // There were no cache misses; no more work to do. + return; + } + + var uncachedEmbeddings = await _embeddingGenerator.GenerateAsync(cacheMissToolEmbeddingTexts, cancellationToken: cancellationToken).ConfigureAwait(false); + if (uncachedEmbeddings.Count != cacheMissToolEmbeddingTexts.Count) + { + throw new InvalidOperationException($"Expected {cacheMissToolEmbeddingTexts.Count} embeddings, got {uncachedEmbeddings.Count}."); + } + + for (var i = 0; i < uncachedEmbeddings.Count; i++) + { + var toolInfoIndex = cacheMissToolInfoIndexes[i]; + var toolEmbedding = uncachedEmbeddings[i]; + ref var info = ref toolInfo.Span[toolInfoIndex]; + info.SimilarityScore = Similarity(queryEmbedding.Vector, toolEmbedding.Vector); + _toolEmbeddingsCache.Add(info.Tool, toolEmbedding); + } + } + + private struct AIToolRankingInfo(AITool tool, int originalIndex) + { + public static readonly Comparer CompareByDescendingSimilarityScore + = Comparer.Create(static (a, b) => + { + var result = b.SimilarityScore.CompareTo(a.SimilarityScore); + return result != 0 + ? result + : a.OriginalIndex.CompareTo(b.OriginalIndex); // Stabilize ties. + }); + + public static readonly Comparer CompareByOriginalIndex + = Comparer.Create(static (a, b) => a.OriginalIndex.CompareTo(b.OriginalIndex)); + + public AITool Tool { get; } = tool; + public int OriginalIndex { get; } = originalIndex; + public float SimilarityScore { get; set; } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/ToolReduction/ToolReducingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ToolReduction/ToolReducingChatClient.cs new file mode 100644 index 00000000000..6a5d6d925fc --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/ToolReduction/ToolReducingChatClient.cs @@ -0,0 +1,89 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using 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 applies a tool reduction strategy before invoking the inner client. +/// +/// +/// Insert this into a pipeline (typically before function invocation middleware) to automatically +/// reduce the tool list carried on for each request. +/// +[Experimental("MEAI001")] +public sealed class ToolReducingChatClient : DelegatingChatClient +{ + private readonly IToolReductionStrategy _strategy; + + /// + /// Initializes a new instance of the class. + /// + /// The inner client. + /// The tool reduction strategy to apply. + /// Thrown if any argument is . + public ToolReducingChatClient(IChatClient innerClient, IToolReductionStrategy strategy) + : base(innerClient) + { + _strategy = Throw.IfNull(strategy); + } + + /// + public override async Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + options = await ApplyReductionAsync(messages, options, cancellationToken).ConfigureAwait(false); + return await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); + } + + /// + public override async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + options = await ApplyReductionAsync(messages, options, cancellationToken).ConfigureAwait(false); + + await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)) + { + yield return update; + } + } + + private async Task ApplyReductionAsync( + IEnumerable messages, + ChatOptions? options, + CancellationToken cancellationToken) + { + // If there are no options or no tools, skip. + if (options?.Tools is not { Count: > 0 }) + { + return options; + } + + var reduced = await _strategy.SelectToolsForRequestAsync(messages, options, cancellationToken).ConfigureAwait(false); + + // If strategy returned the same list instance (or reference equality), assume no change. + if (ReferenceEquals(reduced, options.Tools)) + { + return options; + } + + // Materialize and compare counts; if unchanged and tools have identical ordering and references, keep original. + if (reduced is not IList reducedList) + { + reducedList = reduced.ToList(); + } + + // Clone options to avoid mutating a possibly shared instance. + var cloned = options.Clone(); + cloned.Tools = reducedList; + return cloned; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/ApplicationMetadataConfigurationBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/ApplicationMetadataConfigurationBuilderExtensions.cs index 4dfacb812de..c3b303a5a0e 100644 --- a/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/ApplicationMetadataConfigurationBuilderExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/ApplicationMetadataConfigurationBuilderExtensions.cs @@ -20,8 +20,8 @@ public static class ApplicationMetadataConfigurationBuilderExtensions /// /// The configuration builder. /// An instance of . - /// Section name to save configuration into. Default set to "ambientmetadata:application". - /// The value of >. + /// The section name to save configuration into. The default is "ambientmetadata:application". + /// The value of . /// or is . /// is either , empty, or whitespace. public static IConfigurationBuilder AddApplicationMetadata(this IConfigurationBuilder builder, IHostEnvironment hostEnvironment, string sectionName = DefaultSectionName) diff --git a/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/ApplicationMetadataHostBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/ApplicationMetadataHostBuilderExtensions.cs index e2635bbcc4d..2c7054c4b11 100644 --- a/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/ApplicationMetadataHostBuilderExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/ApplicationMetadataHostBuilderExtensions.cs @@ -32,4 +32,25 @@ public static IHostBuilder UseApplicationMetadata(this IHostBuilder builder, str .ConfigureAppConfiguration((hostBuilderContext, configurationBuilder) => configurationBuilder.AddApplicationMetadata(hostBuilderContext.HostingEnvironment, sectionName)) .ConfigureServices((hostBuilderContext, serviceCollection) => serviceCollection.AddApplicationMetadata(hostBuilderContext.Configuration.GetSection(sectionName))); } + + /// + /// Registers a configuration provider for application metadata and binds a model object onto the configuration. + /// + /// . + /// The host builder. + /// Section name to bind configuration from. Default set to "ambientmetadata:application". + /// The value of . + /// is . + /// is either , empty, or whitespace. + public static TBuilder UseApplicationMetadata(this TBuilder builder, string sectionName = DefaultSectionName) + where TBuilder : IHostApplicationBuilder + { + _ = Throw.IfNull(builder); + _ = Throw.IfNullOrWhitespace(sectionName); + + _ = builder.Configuration.AddApplicationMetadata(builder.Environment, sectionName); + _ = builder.Services.AddApplicationMetadata(builder.Configuration.GetSection(sectionName)); + + return builder; + } } diff --git a/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/ApplicationMetadataServiceCollectionExtensions.cs b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/ApplicationMetadataServiceCollectionExtensions.cs index bc08c2a60e9..1e84e50c4f1 100644 --- a/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/ApplicationMetadataServiceCollectionExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/ApplicationMetadataServiceCollectionExtensions.cs @@ -35,7 +35,7 @@ public static IServiceCollection AddApplicationMetadata(this IServiceCollection /// /// The dependency injection container to add the instance to. /// The delegate to configure with. - /// The value of >. + /// The value of . /// or is . public static IServiceCollection AddApplicationMetadata(this IServiceCollection services, Action configure) { diff --git a/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/Microsoft.Extensions.AmbientMetadata.Application.csproj b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/Microsoft.Extensions.AmbientMetadata.Application.csproj index f631a4047bb..86f07dc205f 100644 --- a/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/Microsoft.Extensions.AmbientMetadata.Application.csproj +++ b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/Microsoft.Extensions.AmbientMetadata.Application.csproj @@ -1,6 +1,7 @@ Microsoft.Extensions.AmbientMetadata + $(NetCoreTargetFrameworks);netstandard2.0;net462 Runtime information provider for application-level ambient metadata. Telemetry diff --git a/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/Microsoft.Extensions.AmbientMetadata.Application.json b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/Microsoft.Extensions.AmbientMetadata.Application.json index 35db568194a..ecdcf8916fa 100644 --- a/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/Microsoft.Extensions.AmbientMetadata.Application.json +++ b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/Microsoft.Extensions.AmbientMetadata.Application.json @@ -1,5 +1,5 @@ { - "Name": "Microsoft.Extensions.AmbientMetadata.Application, Version=8.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35", + "Name": "Microsoft.Extensions.AmbientMetadata.Application, Version=9.10.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35", "Types": [ { "Type": "class Microsoft.Extensions.AmbientMetadata.ApplicationMetadata", @@ -46,6 +46,10 @@ { "Member": "static Microsoft.Extensions.Hosting.IHostBuilder Microsoft.Extensions.Hosting.ApplicationMetadataHostBuilderExtensions.UseApplicationMetadata(this Microsoft.Extensions.Hosting.IHostBuilder builder, string sectionName = \"ambientmetadata:application\");", "Stage": "Stable" + }, + { + "Member": "static TBuilder Microsoft.Extensions.Hosting.ApplicationMetadataHostBuilderExtensions.UseApplicationMetadata(this TBuilder builder, string sectionName = \"ambientmetadata:application\");", + "Stage": "Stable" } ] }, diff --git a/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/README.md b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/README.md index 45cba75f480..5ef2e71c271 100644 --- a/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/README.md +++ b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Application/README.md @@ -26,6 +26,7 @@ The services can be registered using any of the following methods: ```csharp public static IHostBuilder UseApplicationMetadata(this IHostBuilder builder, string sectionName = DefaultSectionName) +public static TBuilder UseApplicationMetadata(this TBuilder builder, string sectionName = DefaultSectionName) where TBuilder : IHostApplicationBuilder public static IServiceCollection AddApplicationMetadata(this IServiceCollection services, Action configure) ``` diff --git a/src/Libraries/Microsoft.Extensions.AsyncState/AsyncState.cs b/src/Libraries/Microsoft.Extensions.AsyncState/AsyncState.cs index d338447ed1d..68ddcfe1c66 100644 --- a/src/Libraries/Microsoft.Extensions.AsyncState/AsyncState.cs +++ b/src/Libraries/Microsoft.Extensions.AsyncState/AsyncState.cs @@ -52,9 +52,7 @@ public AsyncStateToken RegisterAsyncContext() public bool TryGet(AsyncStateToken token, out object? value) { // Context is not initialized -#pragma warning disable EA0011 if (_asyncContextCurrent.Value?.Features == null) -#pragma warning restore EA0011 { value = null; return false; @@ -79,9 +77,7 @@ public bool TryGet(AsyncStateToken token, out object? value) public void Set(AsyncStateToken token, object? value) { // Context is not initialized -#pragma warning disable EA0011 if (_asyncContextCurrent.Value?.Features == null) -#pragma warning restore EA0011 { Throw.InvalidOperationException("Context is not initialized"); } diff --git a/src/Libraries/Microsoft.Extensions.AsyncState/IAsyncContext.cs b/src/Libraries/Microsoft.Extensions.AsyncState/IAsyncContext.cs index aef151f3016..f10827b1954 100644 --- a/src/Libraries/Microsoft.Extensions.AsyncState/IAsyncContext.cs +++ b/src/Libraries/Microsoft.Extensions.AsyncState/IAsyncContext.cs @@ -8,10 +8,9 @@ namespace Microsoft.Extensions.AsyncState; /// /// Provides access to the current async context. -/// Some implementations of this interface may not be thread safe. +/// Some implementations of this interface might not be thread safe. /// /// The type of the asynchronous state. -[SuppressMessage("Naming", "CA1716:Identifiers should not match keywords", Justification = "Getter and setter throw exceptions.")] public interface IAsyncContext where T : notnull { diff --git a/src/Libraries/Microsoft.Extensions.AsyncState/IAsyncLocalContext.cs b/src/Libraries/Microsoft.Extensions.AsyncState/IAsyncLocalContext.cs index 9873375a78b..ed33f8afeb2 100644 --- a/src/Libraries/Microsoft.Extensions.AsyncState/IAsyncLocalContext.cs +++ b/src/Libraries/Microsoft.Extensions.AsyncState/IAsyncLocalContext.cs @@ -7,7 +7,7 @@ namespace Microsoft.Extensions.AsyncState; /// /// Provides access to the current async context stored outside of the HTTP pipeline. -/// Some implementations of this interface may not be thread safe. +/// Some implementations of this interface might not be thread safe. /// /// The type of the asynchronous state. /// This type is intended for internal use. Use instead. diff --git a/src/Libraries/Microsoft.Extensions.AsyncState/IAsyncState.cs b/src/Libraries/Microsoft.Extensions.AsyncState/IAsyncState.cs index ec15b72c2c9..4bf4c748146 100644 --- a/src/Libraries/Microsoft.Extensions.AsyncState/IAsyncState.cs +++ b/src/Libraries/Microsoft.Extensions.AsyncState/IAsyncState.cs @@ -9,9 +9,8 @@ namespace Microsoft.Extensions.AsyncState; /// /// Encapsulates all information within the asynchronous flow in an variable. -/// Some implementations of this interface may not be thread safe. +/// Some implementations of this interface might not be thread safe. /// -[SuppressMessage("Naming", "CA1716:Identifiers should not match keywords", Justification = "Getter and setter throw exceptions.")] public interface IAsyncState { /// @@ -55,5 +54,5 @@ public interface IAsyncState /// Registers new async context with the state. /// /// Token that gives access to the reserved context. - public AsyncStateToken RegisterAsyncContext(); + AsyncStateToken RegisterAsyncContext(); } diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/HybridCacheOptions.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/HybridCacheOptions.cs index 7b87755da7b..d55ac1a4ea1 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/HybridCacheOptions.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/HybridCacheOptions.cs @@ -57,4 +57,9 @@ public class HybridCacheOptions /// should not be visible in metrics systems. /// public bool ReportTagMetrics { get; set; } + + /// + /// Gets or sets the key used to resolve the distributed cache service from the . + /// + public object? DistributedCacheServiceKey { get; set; } } diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/HybridCacheServiceExtensions.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/HybridCacheServiceExtensions.cs index d28dc4e47d5..060307026d6 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/HybridCacheServiceExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/HybridCacheServiceExtensions.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Caching.Hybrid; using Microsoft.Extensions.Caching.Hybrid.Internal; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.DependencyInjection; @@ -17,28 +18,111 @@ public static class HybridCacheServiceExtensions /// /// Adds support for multi-tier caching services. /// - /// A builder instance that allows further configuration of the system. + /// The to add the service to. + /// A delegate to run to configure the instance. + /// A builder instance that allows further configuration of the service. public static IHybridCacheBuilder AddHybridCache(this IServiceCollection services, Action setupAction) { _ = Throw.IfNull(setupAction); - _ = AddHybridCache(services); + + var builder = AddHybridCache(services); _ = services.Configure(setupAction); - return new HybridCacheBuilder(services); + + return builder; } /// /// Adds support for multi-tier caching services. /// - /// A builder instance that allows further configuration of the system. + /// The to add the service to. + /// A builder instance that allows further configuration of the service. public static IHybridCacheBuilder AddHybridCache(this IServiceCollection services) { _ = Throw.IfNull(services); + var builder = PrepareServices(services); + + services.TryAddSingleton(); + + return builder; + } + + /// + /// Adds support for multi-tier caching services with a keyed registration. + /// + /// The to add the service to. + /// The key for the service registration. + /// A delegate to run to configure the instance. + /// A builder instance that allows further configuration of the service. + public static IHybridCacheBuilder AddKeyedHybridCache(this IServiceCollection services, object? serviceKey, Action setupAction) => + AddKeyedHybridCache(services, serviceKey, serviceKey?.ToString() ?? Options.Options.DefaultName, setupAction); + + /// + /// Adds support for multi-tier caching services with a keyed registration. + /// + /// The to add the service to. + /// The key for the service registration. + /// The named options name to use for the instance. + /// A delegate to run to configure the instance. + /// A builder instance that allows further configuration of the service. + public static IHybridCacheBuilder AddKeyedHybridCache(this IServiceCollection services, object? serviceKey, string optionsName, Action setupAction) + { + _ = Throw.IfNull(setupAction); + + var builder = AddKeyedHybridCache(services, serviceKey, optionsName); + _ = services.AddOptions(optionsName).Configure(setupAction); + + return builder; + } + + /// + /// Adds support for multi-tier caching services with a keyed registration. + /// + /// The to add the service to. + /// The key for the service registration. + /// A builder instance that allows further configuration of the service. + public static IHybridCacheBuilder AddKeyedHybridCache(this IServiceCollection services, object? serviceKey) => + AddKeyedHybridCache(services, serviceKey, serviceKey?.ToString() ?? Options.Options.DefaultName); + + /// + /// Adds support for multi-tier caching services with a keyed registration. + /// + /// The to add the service to. + /// The key for the service registration. + /// The named options name to use for the instance. + /// A builder instance that allows further configuration of the service. + public static IHybridCacheBuilder AddKeyedHybridCache(this IServiceCollection services, object? serviceKey, string optionsName) + { + _ = Throw.IfNull(optionsName); + + var builder = PrepareServices(services); + _ = services.AddOptions(optionsName); + + _ = services.AddKeyedSingleton(serviceKey, (sp, key) => + { + var optionsService = sp.GetRequiredService>(); + var options = optionsService.Get(optionsName); + + return new DefaultHybridCache(options, sp); + }); + + return builder; + } + + /// + /// Adds the services required for hybrid caching. + /// + /// The to prepare with prerequisites. + /// A builder instance that allows further configuration of the service. + private static HybridCacheBuilder PrepareServices(IServiceCollection services) + { + _ = Throw.IfNull(services); + services.TryAddSingleton(TimeProvider.System); _ = services.AddOptions().AddMemoryCache(); services.TryAddSingleton(); services.TryAddSingleton>(InbuiltTypeSerializer.Instance); services.TryAddSingleton>(InbuiltTypeSerializer.Instance); - services.TryAddSingleton(); + return new HybridCacheBuilder(services); } } 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 d7a12d9678d..4293d54bc30 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.L2.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.L2.cs @@ -21,8 +21,6 @@ internal partial class DefaultHybridCache 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")] [SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", Justification = "Deliberate recycle only on success")] internal ValueTask GetFromL2DirectAsync(string key, CancellationToken token) { @@ -134,7 +132,6 @@ static async ValueTask AwaitedAsync(ValueTask pending, byte[] 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"); @@ -258,9 +255,6 @@ private void ThrowPayloadLengthExceeded(int size) // splitting the exception bit throw new InvalidOperationException($"Maximum cache length ({MaximumPayloadBytes} bytes) exceeded"); } -#if NET8_0_OR_GREATER - [SuppressMessage("Maintainability", "CA1508:Avoid dead conditional code", Justification = "False positive from unsafe accessor")] -#endif private DistributedCacheEntryOptions GetL2DistributedCacheOptions(HybridCacheEntryOptions? options) { DistributedCacheEntryOptions? result = null; 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 a27417c385e..bcb997702ee 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,6 @@ using System; using System.Collections.Concurrent; -using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using Microsoft.Extensions.DependencyInjection; @@ -52,7 +51,6 @@ static IHybridCacheSerializer ResolveAndAddSerializer(DefaultHybridCache @thi } } - [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 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 6dca4a1afe0..34a68ef30aa 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeStateT.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.StampedeStateT.cs @@ -79,7 +79,6 @@ public Task ExecuteDirectAsync(in TState state, Func _result?.TrySetCanceled(SharedToken); - [SuppressMessage("Usage", "VSTHRD003:Avoid awaiting foreign Tasks", Justification = "Custom task management")] public ValueTask JoinAsync(ILogger log, CancellationToken token) { // If the underlying has already completed, and/or our local token can't cancel: we @@ -120,7 +119,6 @@ static async ValueTask WithCancellationAsync(ILogger log, StampedeState> Task { get @@ -135,8 +133,6 @@ static Task> InvalidAsync() => System.Threading.Tasks.Task.FromExce [SuppressMessage("Resilience", "EA0014:The async method doesn't support cancellation", Justification = "No cancellable operation")] [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(ILogger log) { Task> task = Task; @@ -162,8 +158,6 @@ static async Task AwaitedAsync(ILogger log, Task> task) private static CacheItem ThrowUnexpectedCacheItem() => throw new InvalidOperationException("Unexpected cache item"); [SuppressMessage("Resilience", "EA0014:The async method doesn't support cancellation", Justification = "In this case the cancellation token is provided internally via SharedToken")] - [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Exception is passed through to faulted task result")] - [SuppressMessage("Reliability", "EA0002:Use 'System.TimeProvider' to make the code easier to test", Justification = "Does not apply")] private async Task BackgroundFetchAsync() { bool eventSourceEnabled = HybridCacheEventSource.Log.IsEnabled(); @@ -544,7 +538,6 @@ private void SetResult(CacheItem value, TimeSpan maxRelativeTime) } } - [SuppressMessage("Major Code Smell", "S1121:Assignments should not be made from within sub-expressions", Justification = "Reasonable in this case, due to stack alloc scope.")] private static bool ValidateUnicodeCorrectness(ILogger logger, string key, TagSet tags) { int maxChars = Math.Max(key.Length, tags.MaxLength()); diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.SyncLock.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.SyncLock.cs index 4672818d056..042203182a0 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.SyncLock.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.SyncLock.cs @@ -29,7 +29,6 @@ internal partial class DefaultHybridCache private readonly object _syncLock6 = new(); private readonly object _syncLock7 = new(); - [System.Diagnostics.CodeAnalysis.SuppressMessage("Major Code Smell", "S109:Magic numbers should not be used", Justification = "Trivial low 3 bits")] internal object GetPartitionedSyncLock(in StampedeKey key) => (key.HashCode & 0b111) switch // generate 8 partitions using the low 3 bits { 0 => _syncLock0, 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 21105a2f4c0..ef5b7f1a01a 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.TagInvalidation.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.TagInvalidation.cs @@ -164,7 +164,6 @@ 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 Task? pending)) diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.cs index 84de2fe52e8..93e1e5457cb 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultHybridCache.cs @@ -65,13 +65,23 @@ internal enum CacheFeatures internal bool HasBackendCache => (_features & CacheFeatures.BackendCache) != 0; public DefaultHybridCache(IOptions options, IServiceProvider services) + : this(Throw.IfNull(options).Value, services) + { + } + + public DefaultHybridCache(HybridCacheOptions options, IServiceProvider services) { _services = Throw.IfNull(services); _localCache = services.GetRequiredService(); - _options = options.Value; + _options = options; _logger = services.GetService()?.CreateLogger(typeof(HybridCache)) ?? NullLogger.Instance; _clock = services.GetService() ?? TimeProvider.System; - _backendCache = services.GetService(); // note optional + + // The backend cache service is optional; if not provided, we operate as a pure L1 cache. + // If a service key is provided, the service must be present. + _backendCache = _options.DistributedCacheServiceKey is null + ? services.GetService() + : services.GetRequiredKeyedService(_options.DistributedCacheServiceKey); // ignore L2 if it is really just the same L1, wrapped // (note not just an "is" test; if someone has a custom subclass, who knows what it does?) diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultJsonSerializerFactory.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultJsonSerializerFactory.cs index f499ba485b3..537968113c2 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultJsonSerializerFactory.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/DefaultJsonSerializerFactory.cs @@ -126,10 +126,9 @@ static void PrepareStateForDepth(Type type, ref Dictionary? state, bool result) { var value = result ? FieldOnlyResult.FieldOnly : FieldOnlyResult.NotFieldOnly; - if (state is not null) - { - state[type] = value; - } +#pragma warning disable IDE0058 // Temporary workaround for Roslyn analyzer issue (see https://github.com/dotnet/roslyn/issues/80499). + state?[type] = value; +#pragma warning restore IDE0058 return value; } diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/HybridCachePayload.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/HybridCachePayload.cs index 5c39727d980..079bd295a02 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/HybridCachePayload.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/HybridCachePayload.cs @@ -97,9 +97,7 @@ static int GetMaxStringLength(int charCount) => MaxVarint64Length + Encoding.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")] - [System.Diagnostics.CodeAnalysis.SuppressMessage("Major Code Smell", "S107:Methods should not have too many parameters", Justification = "Borderline")] public static int Write(byte[] destination, string key, long creationTime, TimeSpan duration, PayloadFlags flags, TagSet tags, ReadOnlySequence payload) { @@ -172,9 +170,6 @@ static void WriteString(byte[] target, ref int offset, string value) [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")] - [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Exposed for logging")] public static HybridCachePayloadParseResult TryParse(ArraySegment source, string key, TagSet knownTags, DefaultHybridCache cache, out ArraySegment payload, out TimeSpan remainingTime, out PayloadFlags flags, out ushort entropy, out TagSet pendingTags, out Exception? fault) { diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/ImmutableTypeCache.cs b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/ImmutableTypeCache.cs index 12b13cf03e6..f43f3af988c 100644 --- a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/ImmutableTypeCache.cs +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Internal/ImmutableTypeCache.cs @@ -34,13 +34,10 @@ internal static bool IsBlittable() // minimize the generic portion (twinned w GCHandle.Alloc(obj, GCHandleType.Pinned).Free(); return true; } -#pragma warning disable CA1031 // Do not catch general exception types: interpret any failure here as "nope" catch { return false; } -#pragma warning restore CA1031 - #endif } diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.json b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.json index 2c1a811b223..10be31168ba 100644 Binary files a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.json and b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.json differ diff --git a/src/Libraries/Microsoft.Extensions.Compliance.Abstractions/Redaction/RedactionStringBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.Compliance.Abstractions/Redaction/RedactionStringBuilderExtensions.cs index 747f58d0a98..ccf612ba944 100644 --- a/src/Libraries/Microsoft.Extensions.Compliance.Abstractions/Redaction/RedactionStringBuilderExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.Compliance.Abstractions/Redaction/RedactionStringBuilderExtensions.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; using System.Runtime.CompilerServices; using Microsoft.Extensions.Compliance.Redaction; diff --git a/src/Libraries/Microsoft.Extensions.Compliance.Redaction/HmacRedactor.cs b/src/Libraries/Microsoft.Extensions.Compliance.Redaction/HmacRedactor.cs index 26df96f54d6..c9a69972a6d 100644 --- a/src/Libraries/Microsoft.Extensions.Compliance.Redaction/HmacRedactor.cs +++ b/src/Libraries/Microsoft.Extensions.Compliance.Redaction/HmacRedactor.cs @@ -11,7 +11,6 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; #else -using System.Diagnostics.CodeAnalysis; using System.Text; #endif @@ -123,7 +122,6 @@ private static byte[] CreateSha256Hash(ReadOnlySpan value, byte[] hashKey) '8', '9', '+', '/', '=', }; - [SuppressMessage("Code smell", "S109", Justification = "Bit operation.")] private static int ConvertBytesToBase64(byte[] hashToConvert, Span destination, int remainingBytesToPad, int startOffset) { var iterations = BytesOfHashWeUse - remainingBytesToPad; diff --git a/src/Libraries/Microsoft.Extensions.Compliance.Testing/FakeRedactionServiceCollectionExtensions.cs b/src/Libraries/Microsoft.Extensions.Compliance.Testing/FakeRedactionServiceCollectionExtensions.cs index eab81632049..43637a7d726 100644 --- a/src/Libraries/Microsoft.Extensions.Compliance.Testing/FakeRedactionServiceCollectionExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.Compliance.Testing/FakeRedactionServiceCollectionExtensions.cs @@ -18,7 +18,7 @@ public static class FakeRedactionServiceCollectionExtensions /// /// Registers the fake redactor provider that always returns fake redactor instances. /// - /// Container used to register fake redaction classes. + /// The container used to register fake redaction classes. /// The value of . /// is . public static IServiceCollection AddFakeRedaction(this IServiceCollection services) @@ -42,10 +42,10 @@ public static IServiceCollection AddFakeRedaction(this IServiceCollection servic /// /// Registers the fake redactor provider that always returns fake redactor instances. /// - /// Container used to register fake redaction classes. + /// The container used to register fake redaction classes. /// Configures fake redactor. /// The value of . - /// or > are . + /// or is . public static IServiceCollection AddFakeRedaction(this IServiceCollection services, Action configure) { _ = Throw.IfNull(services); diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionChunk.cs b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionChunk.cs new file mode 100644 index 00000000000..f4f52622458 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionChunk.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.Diagnostics; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion; + +/// +/// Represents a chunk of content extracted from an . +/// +/// The type of the content. +[DebuggerDisplay("Content = {Content}")] +public sealed class IngestionChunk +{ + private Dictionary? _metadata; + + /// + /// Initializes a new instance of the class. + /// + /// The content of the chunk. + /// The document from which this chunk was extracted. + /// Additional context for the chunk. + /// + /// or is . + /// + /// + /// is a string that is empty or contains only white-space characters. + /// + public IngestionChunk(T content, IngestionDocument document, string? context = null) + { + if (typeof(T) == typeof(string)) + { + Content = (T)(object)Throw.IfNullOrEmpty((string)(object)content!); + } + else + { + Content = Throw.IfNull(content); + } + + Document = Throw.IfNull(document); + Context = context; + } + + /// + /// Gets the content of the chunk. + /// + public T Content { get; } + + /// + /// Gets the document from which this chunk was extracted. + /// + public IngestionDocument Document { get; } + + /// + /// Gets additional context for the chunk. + /// + public string? Context { get; } + + /// + /// Gets a value indicating whether this chunk has metadata. + /// + public bool HasMetadata => _metadata?.Count > 0; + + /// + /// Gets the metadata associated with this chunk. + /// + public IDictionary Metadata => _metadata ??= []; +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionChunkProcessor.cs b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionChunkProcessor.cs new file mode 100644 index 00000000000..cd262305089 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionChunkProcessor.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Threading; + +namespace Microsoft.Extensions.DataIngestion; + +/// +/// Processes chunks in a pipeline. +/// +/// The type of the chunk content. +public abstract class IngestionChunkProcessor +{ + /// + /// Processes chunks asynchronously. + /// + /// The chunks to process. + /// The token to monitor for cancellation requests. + /// The processed chunks. + public abstract IAsyncEnumerable> ProcessAsync(IAsyncEnumerable> chunks, CancellationToken cancellationToken = default); +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionChunkWriter.cs b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionChunkWriter.cs new file mode 100644 index 00000000000..119265caf6e --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionChunkWriter.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.DataIngestion; + +/// +/// Writes chunks to a destination. +/// +/// The type of the chunk content. +public abstract class IngestionChunkWriter : IDisposable +{ + /// + /// Writes chunks asynchronously. + /// + /// The chunks to write. + /// The token to monitor for cancellation requests. + /// A task representing the asynchronous write operation. + public abstract Task WriteAsync(IAsyncEnumerable> chunks, CancellationToken cancellationToken = default); + + /// + /// Disposes the writer and releases all associated resources. + /// + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + /// + /// Disposes the writer. + /// + /// true if called from dispose, false if called from finalizer. + protected virtual void Dispose(bool disposing) + { + } +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionChunker.cs b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionChunker.cs new file mode 100644 index 00000000000..0f386c67de8 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionChunker.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Threading; + +namespace Microsoft.Extensions.DataIngestion; + +/// +/// Splits an into chunks. +/// +/// The type of the chunk content. +public abstract class IngestionChunker +{ + /// + /// Splits a document into chunks asynchronously. + /// + /// The document to split. + /// The token to monitor for cancellation requests. + /// The chunks created from the document. + public abstract IAsyncEnumerable> ProcessAsync(IngestionDocument document, CancellationToken cancellationToken = default); +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionDocument.cs b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionDocument.cs new file mode 100644 index 00000000000..119a4acee91 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionDocument.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.DataIngestion; + +/// +/// A format-agnostic container that normalizes diverse input formats into a structured hierarchy. +/// +public sealed class IngestionDocument +{ + /// + /// Initializes a new instance of the class. + /// + /// The unique identifier for the document. + /// is . + public IngestionDocument(string identifier) + { + Identifier = Throw.IfNullOrEmpty(identifier); + } + + /// + /// Gets the unique identifier for the document. + /// + public string Identifier { get; } + + /// + /// Gets the sections of the document. + /// + public IList Sections { get; } = []; + + /// + /// Iterate over all elements in the document, including those in nested sections. + /// + /// An enumerable collection of elements. + /// + /// Sections themselves are not included. + /// + public IEnumerable EnumerateContent() + { + Stack elementsToProcess = new(); + + for (int sectionIndex = Sections.Count - 1; sectionIndex >= 0; sectionIndex--) + { + elementsToProcess.Push(Sections[sectionIndex]); + } + + while (elementsToProcess.Count > 0) + { + IngestionDocumentElement currentElement = elementsToProcess.Pop(); + + if (currentElement is not IngestionDocumentSection nestedSection) + { + yield return currentElement; + } + else + { + for (int i = nestedSection.Elements.Count - 1; i >= 0; i--) + { + elementsToProcess.Push(nestedSection.Elements[i]); + } + } + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionDocumentElement.cs b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionDocumentElement.cs new file mode 100644 index 00000000000..af790e32f29 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionDocumentElement.cs @@ -0,0 +1,231 @@ +// Licensed 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 Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion; + +#pragma warning disable SA1402 // File may only contain a single type + +/// +/// Represents an element within an . +/// +[DebuggerDisplay("Type = {GetType().Name}, Markdown = {GetMarkdown()}")] +public abstract class IngestionDocumentElement +{ +#pragma warning disable IDE1006 // Naming Styles + private protected string _markdown; +#pragma warning restore IDE1006 // Naming Styles + + /// + /// Initializes a new instance of the class. + /// + /// The markdown representation of the element. + /// is or empty. + private protected IngestionDocumentElement(string markdown) + { + _markdown = string.IsNullOrEmpty(markdown) ? throw new ArgumentNullException(nameof(markdown)) : markdown; + } + + private protected IngestionDocumentElement() + { + _markdown = null!; + } + + private Dictionary? _metadata; + + /// + /// Gets or sets the textual content of the element. + /// + public string? Text { get; set; } + + /// + /// Gets the markdown representation of the element. + /// + /// The markdown representation. + public virtual string GetMarkdown() => _markdown; + + /// + /// Gets or sets the page number where this element appears. + /// + public int? PageNumber { get; set; } + + /// + /// Gets a value indicating whether this element has metadata. + /// + public bool HasMetadata => _metadata?.Count > 0; + + /// + /// Gets the metadata associated with this element. + /// + public IDictionary Metadata => _metadata ??= []; +} + +/// +/// A section can be just a page or a logical grouping of elements in a document. +/// +public sealed class IngestionDocumentSection : IngestionDocumentElement +{ + /// + /// Initializes a new instance of the class. + /// + /// The markdown representation of the section. + public IngestionDocumentSection(string markdown) + : base(markdown) + { + } + + /// + /// Initializes a new instance of the class. + /// + public IngestionDocumentSection() + { + } + + /// + /// Gets the elements within this section. + /// + public IList Elements { get; } = []; + + /// + public override string GetMarkdown() + => string.Join(Environment.NewLine, Elements.Select(e => e.GetMarkdown())); +} + +/// +/// Represents a paragraph in a document. +/// +public sealed class IngestionDocumentParagraph : IngestionDocumentElement +{ + /// + /// Initializes a new instance of the class. + /// + /// The markdown representation of the paragraph. + public IngestionDocumentParagraph(string markdown) + : base(markdown) + { + } +} + +/// +/// Represents a header in a document. +/// +public sealed class IngestionDocumentHeader : IngestionDocumentElement +{ + /// + /// Initializes a new instance of the class. + /// + /// The markdown representation of the header. + public IngestionDocumentHeader(string markdown) + : base(markdown) + { + } + + /// + /// Gets or sets the level of the header. + /// + public int? Level + { + get => field; + set + { + if (value.HasValue) + { + field = Throw.IfOutOfRange(value.Value, min: 1, max: 10, nameof(value)); + } + else + { + field = null; + } + } + } +} + +/// +/// Represents a footer in a document. +/// +public sealed class IngestionDocumentFooter : IngestionDocumentElement +{ + /// + /// Initializes a new instance of the class. + /// + /// The markdown representation of the footer. + public IngestionDocumentFooter(string markdown) + : base(markdown) + { + } +} + +/// +/// Represents a table in a document. +/// +public sealed class IngestionDocumentTable : IngestionDocumentElement +{ + /// + /// Initializes a new instance of the class. + /// + /// The markdown representation of the table. + /// The cells of the table. + /// is . +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional +#pragma warning disable S3967 // Multidimensional arrays should not be used + public IngestionDocumentTable(string markdown, IngestionDocumentElement?[,] cells) + : base(markdown) + { + Cells = Throw.IfNull(cells); + } + + /// + /// Gets the cells of the table. + /// Each table can be represented as a two-dimensional array of cell contents, with the first row being the headers. + /// + /// + /// This information is useful when chunking large tables that exceed token count limit. + /// Null represents an empty cell ( can't return an empty string). + /// +#pragma warning disable CA1819 // Properties should not return arrays + public IngestionDocumentElement?[,] Cells { get; } +#pragma warning restore CA1819 // Properties should not return arrays +#pragma warning restore S3967 // Multidimensional arrays should not be used +#pragma warning restore CA1814 // Prefer jagged arrays over multidimensional +} + +/// +/// Represents an image in a document. +/// +public sealed class IngestionDocumentImage : IngestionDocumentElement +{ + /// + /// Initializes a new instance of the class. + /// + /// The markdown representation of the image. + public IngestionDocumentImage(string markdown) + : base(markdown) + { + } + + /// + /// Gets or sets the binary content of the image. + /// + public ReadOnlyMemory? Content { get; set; } + + /// + /// Gets or sets the media type of the image. + /// + public string? MediaType { get; set; } + + /// + /// Gets or sets the alternative text for the image. + /// + /// + /// Alternative text is a brief, descriptive text that explains the content, context, or function of an image when the image cannot be displayed or accessed. + /// This property can be used when generating the embedding for the image that is part of larger chunk. + /// + public string? AlternativeText { get; set; } +} + +#pragma warning restore SA1402 // File may only contain a single type diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionDocumentProcessor.cs b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionDocumentProcessor.cs new file mode 100644 index 00000000000..c5f39e7a419 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionDocumentProcessor.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.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.DataIngestion; + +/// +/// Part of the document processing pipeline that takes a as input and produces a (potentially modified) as output. +/// +public abstract class IngestionDocumentProcessor +{ + /// + /// Processes the given ingestion document. + /// + /// The ingestion document to process. + /// The token to monitor for cancellation requests. + /// A task representing the asynchronous processing operation, with the processed document as the result. + public abstract Task ProcessAsync(IngestionDocument document, CancellationToken cancellationToken = default); +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionDocumentReader.cs b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionDocumentReader.cs new file mode 100644 index 00000000000..8bdb651321a --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/IngestionDocumentReader.cs @@ -0,0 +1,142 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion; + +/// +/// Reads source content and converts it to an . +/// +public abstract class IngestionDocumentReader +{ + /// + /// Reads a file and converts it to an . + /// + /// The file to read. + /// The token to monitor for cancellation requests. + /// A task representing the asynchronous read operation. + /// is . + public Task ReadAsync(FileInfo source, CancellationToken cancellationToken = default) + { + string identifier = Throw.IfNull(source).FullName; // entire path is more unique than just part of it. + return ReadAsync(source, identifier, GetMediaType(source), cancellationToken); + } + + /// + /// Reads a file and converts it to an . + /// + /// The file to read. + /// The unique identifier for the document. + /// The media type of the file. + /// The token to monitor for cancellation requests. + /// A task representing the asynchronous read operation. + /// or is or empty. + public virtual async Task ReadAsync(FileInfo source, string identifier, string? mediaType = null, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(source); + _ = Throw.IfNullOrEmpty(identifier); + + using FileStream stream = new(source.FullName, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 1, FileOptions.Asynchronous); + return await ReadAsync(stream, identifier, string.IsNullOrEmpty(mediaType) ? GetMediaType(source) : mediaType!, cancellationToken).ConfigureAwait(false); + } + + /// + /// Reads a stream and converts it to an . + /// + /// The stream to read. + /// The unique identifier for the document. + /// The media type of the content. + /// The token to monitor for cancellation requests. + /// A task representing the asynchronous read operation. + public abstract Task ReadAsync(Stream source, string identifier, string mediaType, CancellationToken cancellationToken = default); + + private static string GetMediaType(FileInfo source) + => source.Extension switch + { + ".123" => "application/vnd.lotus-1-2-3", + ".602" => "application/x-t602", + ".abw" => "application/x-abiword", + ".bmp" => "image/bmp", + ".cgm" => "image/cgm", + ".csv" => "text/csv", + ".cwk" => "application/x-cwk", + ".dbf" => "application/vnd.dbf", + ".dif" => "application/x-dif", + ".doc" => "application/msword", + ".docm" => "application/vnd.ms-word.document.macroEnabled.12", + ".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".dot" => "application/msword", + ".dotm" => "application/vnd.ms-word.template.macroEnabled.12", + ".dotx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.template", + ".epub" => "application/epub+zip", + ".et" => "application/vnd.ms-excel", + ".eth" => "application/ethos", + ".fods" => "application/vnd.oasis.opendocument.spreadsheet", + ".gif" => "image/gif", + ".htm" => "text/html", + ".html" => "text/html", + ".hwp" => "application/x-hwp", + ".jpeg" => "image/jpeg", + ".jpg" => "image/jpeg", + ".key" => "application/x-iwork-keynote-sffkey", + ".lwp" => "application/vnd.lotus-wordpro", + ".mcw" => "application/macwriteii", + ".mw" => "application/macwriteii", + ".numbers" => "application/x-iwork-numbers-sffnumbers", + ".ods" => "application/vnd.oasis.opendocument.spreadsheet", + ".pages" => "application/x-iwork-pages-sffpages", + ".pbd" => "application/x-pagemaker", + ".pdf" => "application/pdf", + ".png" => "image/png", + ".pot" => "application/vnd.ms-powerpoint", + ".potm" => "application/vnd.ms-powerpoint.template.macroEnabled.12", + ".potx" => "application/vnd.openxmlformats-officedocument.presentationml.template", + ".ppt" => "application/vnd.ms-powerpoint", + ".pptm" => "application/vnd.ms-powerpoint.presentation.macroEnabled.12", + ".pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ".prn" => "application/x-prn", + ".qpw" => "application/x-quattro-pro", + ".rtf" => "application/rtf", + ".sda" => "application/vnd.stardivision.draw", + ".sdd" => "application/vnd.stardivision.impress", + ".sdp" => "application/sdp", + ".sdw" => "application/vnd.stardivision.writer", + ".sgl" => "application/vnd.stardivision.writer", + ".slk" => "text/vnd.sylk", + ".sti" => "application/vnd.sun.xml.impress.template", + ".stw" => "application/vnd.sun.xml.writer.template", + ".svg" => "image/svg+xml", + ".sxg" => "application/vnd.sun.xml.writer.global", + ".sxi" => "application/vnd.sun.xml.impress", + ".sxw" => "application/vnd.sun.xml.writer", + ".sylk" => "text/vnd.sylk", + ".tiff" => "image/tiff", + ".tsv" => "text/tab-separated-values", + ".txt" => "text/plain", + ".uof" => "application/vnd.uoml+xml", + ".uop" => "application/vnd.openofficeorg.presentation", + ".uos1" => "application/vnd.uoml+xml", + ".uos2" => "application/vnd.uoml+xml", + ".uot" => "application/x-uo", + ".vor" => "application/vnd.stardivision.writer", + ".webp" => "image/webp", + ".wpd" => "application/wordperfect", + ".wps" => "application/vnd.ms-works", + ".wq1" => "application/x-lotus", + ".wq2" => "application/x-lotus", + ".xls" => "application/vnd.ms-excel", + ".xlsb" => "application/vnd.ms-excel.sheet.binary.macroEnabled.12", + ".xlsm" => "application/vnd.ms-excel.sheet.macroEnabled.12", + ".xlr" => "application/vnd.ms-works", + ".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".xlw" => "application/vnd.ms-excel", + ".xml" => "application/xml", + ".zabw" => "application/x-abiword", + _ => "application/octet-stream" + }; +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/Microsoft.Extensions.DataIngestion.Abstractions.csproj b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/Microsoft.Extensions.DataIngestion.Abstractions.csproj new file mode 100644 index 00000000000..f3f16874b4c --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/Microsoft.Extensions.DataIngestion.Abstractions.csproj @@ -0,0 +1,23 @@ + + + + $(TargetFrameworks);netstandard2.0 + Microsoft.Extensions.DataIngestion + Abstractions representing Data Ingestion components for RAG. + RAG + RAG;ingestion;documents + true + preview + false + 75 + 75 + + $(NoWarn);S1694 + + + + + + + + diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/README.md b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/README.md new file mode 100644 index 00000000000..0285f27fb3d --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.Abstractions/README.md @@ -0,0 +1,39 @@ +# Microsoft.Extensions.DataIngestion.Abstractions + +.NET developers need to efficiently process, chunk, and retrieve information from diverse document formats while preserving semantic meaning and structural context. The `Microsoft.Extensions.DataIngestion` libraries provide a unified approach for representing document ingestion components. + +## The packages + +The [Microsoft.Extensions.DataIngestion.Abstractions](https://www.nuget.org/packages/Microsoft.Extensions.DataIngestion.Abstractions) package provides the core exchange types, including [`IngestionDocument`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.dataingestion.ingestiondocument), [`IngestionChunker`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.dataingestion.ingestionchunker-1), [`IngestionChunkProcessor`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.dataingestion.ingestionchunkprocessor-1), and [`IngestionChunkWriter`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.dataingestion.ingestionchunkwriter-1). Any .NET library that provides document processing capabilities can implement these abstractions to enable seamless integration with consuming code. + +The [Microsoft.Extensions.DataIngestion](https://www.nuget.org/packages/Microsoft.Extensions.DataIngestion) package has an implicit dependency on the `Microsoft.Extensions.DataIngestion.Abstractions` package. This package enables you to easily integrate components such as enrichment processors, vector storage writers, and telemetry into your applications using familiar dependency injection and pipeline patterns. For example, it provides processors for sentiment analysis, keyword extraction, and summarization that can be chained together in ingestion pipelines. + +## Which package to reference + +Libraries that provide implementations of the abstractions typically reference only `Microsoft.Extensions.DataIngestion.Abstractions`. + +To also have access to higher-level utilities for working with document ingestion components, reference the `Microsoft.Extensions.DataIngestion` package instead (which itself references `Microsoft.Extensions.DataIngestion.Abstractions`). Most consuming applications and services should reference the `Microsoft.Extensions.DataIngestion` package along with one or more libraries that provide concrete implementations of the abstractions, such as `Microsoft.Extensions.DataIngestion.MarkItDown` or `Microsoft.Extensions.DataIngestion.Markdig`. + +## Install the package + +From the command-line: + +```console +dotnet add package Microsoft.Extensions.DataIngestion.Abstractions --prerelease +``` + +Or directly in the C# project file: + +```xml + + + +``` + +## Documentation + +Refer to the [Microsoft.Extensions.DataIngestion libraries documentation](https://learn.microsoft.com/dotnet/dataingestion/microsoft-extensions-dataingestion) for more information and API usage examples. + +## Feedback & Contributing + +We welcome feedback and contributions in [our GitHub repo](https://github.com/dotnet/extensions). diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/MarkItDownMcpReader.cs b/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/MarkItDownMcpReader.cs new file mode 100644 index 00000000000..b75fc2e7f50 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/MarkItDownMcpReader.cs @@ -0,0 +1,135 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; + +namespace Microsoft.Extensions.DataIngestion; + +/// +/// Reads documents by converting them to Markdown using the MarkItDown MCP server. +/// +public class MarkItDownMcpReader : IngestionDocumentReader +{ + private readonly Uri _mcpServerUri; + private readonly McpClientOptions? _options; + + /// + /// Initializes a new instance of the class. + /// + /// The URI of the MarkItDown MCP server (e.g., http://localhost:3001/mcp). + /// Optional MCP client options for configuring the connection. + public MarkItDownMcpReader(Uri mcpServerUri, McpClientOptions? options = null) + { + _mcpServerUri = Throw.IfNull(mcpServerUri); + _options = options; + } + + /// + public override async Task ReadAsync(FileInfo source, string identifier, string? mediaType = null, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(source); + _ = Throw.IfNullOrEmpty(identifier); + + if (!source.Exists) + { + throw new FileNotFoundException("The specified file does not exist.", source.FullName); + } + + // Read file content as base64 data URI +#if NET + byte[] fileBytes = await File.ReadAllBytesAsync(source.FullName, cancellationToken).ConfigureAwait(false); +#else + byte[] fileBytes; + using (FileStream fs = new(source.FullName, FileMode.Open, FileAccess.Read, FileShare.Read, 1, FileOptions.Asynchronous)) + { + using MemoryStream ms = new(); + await fs.CopyToAsync(ms).ConfigureAwait(false); + fileBytes = ms.ToArray(); + } +#endif + string dataUri = CreateDataUri(fileBytes, mediaType); + + string markdown = await ConvertToMarkdownAsync(dataUri, cancellationToken).ConfigureAwait(false); + + return MarkdownParser.Parse(markdown, identifier); + } + + /// + public override async Task ReadAsync(Stream source, string identifier, string mediaType, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(source); + _ = Throw.IfNullOrEmpty(identifier); + + // Read stream content as base64 data URI + using MemoryStream ms = new(); +#if NET + await source.CopyToAsync(ms, cancellationToken).ConfigureAwait(false); +#else + await source.CopyToAsync(ms).ConfigureAwait(false); +#endif + byte[] fileBytes = ms.ToArray(); + string dataUri = CreateDataUri(fileBytes, mediaType); + + string markdown = await ConvertToMarkdownAsync(dataUri, cancellationToken).ConfigureAwait(false); + + return MarkdownParser.Parse(markdown, identifier); + } + +#pragma warning disable S3995 // URI return values should not be strings + private static string CreateDataUri(byte[] fileBytes, string? mediaType) +#pragma warning restore S3995 // URI return values should not be strings + { + string base64Content = Convert.ToBase64String(fileBytes); + string mimeType = string.IsNullOrEmpty(mediaType) ? "application/octet-stream" : mediaType!; + return $"data:{mimeType};base64,{base64Content}"; + } + + private async Task ConvertToMarkdownAsync(string dataUri, CancellationToken cancellationToken) + { + // Create HTTP client transport for MCP + HttpClientTransport transport = new(new HttpClientTransportOptions + { + Endpoint = _mcpServerUri + }); + + await using (transport.ConfigureAwait(false)) + { + // Create MCP client + McpClient client = await McpClient.CreateAsync(transport, _options, loggerFactory: null, cancellationToken).ConfigureAwait(false); + + await using (client.ConfigureAwait(false)) + { + // Build parameters for convert_to_markdown tool + Dictionary parameters = new() + { + ["uri"] = dataUri + }; + + // Call the convert_to_markdown tool + var result = await client.CallToolAsync("convert_to_markdown", parameters, cancellationToken: cancellationToken).ConfigureAwait(false); + + // Extract markdown content from result + // The result is expected to be in the format: { "content": [{ "type": "text", "text": "markdown content" }] } + if (result.Content != null && result.Content.Count > 0) + { + foreach (var content in result.Content) + { + if (content.Type == "text" && content is TextContentBlock textBlock) + { + return textBlock.Text; + } + } + } + } + } + + throw new InvalidOperationException("Failed to convert document to markdown: unexpected response format from MCP server."); + } +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/MarkItDownReader.cs b/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/MarkItDownReader.cs new file mode 100644 index 00000000000..79b60f3ad5d --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/MarkItDownReader.cs @@ -0,0 +1,133 @@ +// Licensed 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.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion; + +/// +/// Reads documents by converting them to Markdown using the MarkItDown tool. +/// +public class MarkItDownReader : IngestionDocumentReader +{ + private readonly FileInfo? _exePath; + private readonly bool _extractImages; + + /// + /// Initializes a new instance of the class. + /// + /// The path to the MarkItDown executable. When not provided, "markitdown" needs to be added to PATH. + /// A value indicating whether to extract images. + public MarkItDownReader(FileInfo? exePath = null, bool extractImages = false) + { + _exePath = exePath; + _extractImages = extractImages; + } + + /// + public override async Task ReadAsync(FileInfo source, string identifier, string? mediaType = null, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(source); + _ = Throw.IfNullOrEmpty(identifier); + + if (!source.Exists) + { + throw new FileNotFoundException("The specified file does not exist.", source.FullName); + } + + // Manually set ProcessStartInfo.WorkingDirectory to a "safe location": + // - If exePath is provided, use its directory. + // - Otherwise, use AppContext.BaseDirectory (the directory of the running application). + string workingDirectory = _exePath?.Directory?.FullName ?? AppContext.BaseDirectory; + + ProcessStartInfo startInfo = new() + { + FileName = _exePath?.FullName ?? "markitdown", + WorkingDirectory = workingDirectory, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + StandardOutputEncoding = Encoding.UTF8, + }; + + // Force UTF-8 encoding in the environment (will produce garbage otherwise). + startInfo.Environment["PYTHONIOENCODING"] = "utf-8"; + startInfo.Environment["LC_ALL"] = "C.UTF-8"; + startInfo.Environment["LANG"] = "C.UTF-8"; + +#if NET + startInfo.ArgumentList.Add(source.FullName); + if (_extractImages) + { + startInfo.ArgumentList.Add("--keep-data-uris"); + } +#else + startInfo.Arguments = $"\"{source.FullName}\"" + (_extractImages ? " --keep-data-uris" : string.Empty); +#endif + + string outputContent = string.Empty; + using (Process process = new() { StartInfo = startInfo }) + { + process.Start(); + + outputContent = await process.StandardOutput.ReadToEndAsync(cancellationToken).ConfigureAwait(false); +#if NET + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); +#else + process.WaitForExit(); +#endif + + if (process.ExitCode != 0) + { + throw new InvalidOperationException($"MarkItDown process failed with exit code {process.ExitCode}."); + } + } + + return MarkdownParser.Parse(outputContent, identifier); + } + + /// + /// The contents of are copied to a temporary file. + public override async Task ReadAsync(Stream source, string identifier, string mediaType, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(source); + _ = Throw.IfNullOrEmpty(identifier); + + // Instead of creating a temporary file, we could write to the StandardInput of the process. + // MarkItDown says it supports reading from stdin, but it does not work as expected. + // Even the sample command line does not work with stdin: "cat example.pdf | markitdown" + // I can be doing something wrong, but for now, let's write to a temporary file. + string inputFilePath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + FileStream inputFile = new(inputFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.None, bufferSize: 1, FileOptions.Asynchronous); + + try + { + await source +#if NET + .CopyToAsync(inputFile, cancellationToken) +#else + .CopyToAsync(inputFile) +#endif + .ConfigureAwait(false); + + inputFile.Close(); + + return await ReadAsync(new FileInfo(inputFilePath), identifier, mediaType, cancellationToken).ConfigureAwait(false); + } + finally + { +#if NET + await inputFile.DisposeAsync().ConfigureAwait(false); +#else + inputFile.Dispose(); +#endif + File.Delete(inputFilePath); + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/Microsoft.Extensions.DataIngestion.MarkItDown.csproj b/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/Microsoft.Extensions.DataIngestion.MarkItDown.csproj new file mode 100644 index 00000000000..013097ea6c7 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/Microsoft.Extensions.DataIngestion.MarkItDown.csproj @@ -0,0 +1,29 @@ + + + + $(TargetFrameworks);netstandard2.0 + Microsoft.Extensions.DataIngestion + Implementation of IngestionDocumentReader abstraction for MarkItDown. + RAG + RAG;ingestion;documents;markitdown + true + preview + false + 75 + 75 + + + + + + + + + + + + + + + + diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/README.md b/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/README.md new file mode 100644 index 00000000000..095011b77f1 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.MarkItDown/README.md @@ -0,0 +1,91 @@ +# Microsoft.Extensions.DataIngestion.MarkItDown + +Provides an implementation of the `IngestionDocumentReader` class for the [MarkItDown](https://github.com/microsoft/markitdown/) utility. + +## Install the package + +From the command-line: + +```console +dotnet add package Microsoft.Extensions.DataIngestion.MarkItDown --prerelease +``` + +Or directly in the C# project file: + +```xml + + + +``` + +## Usage Examples + +### Creating a MarkItDownReader for Data Ingestion (Local Process) + +Use `MarkItDownReader` to convert documents using the MarkItDown executable installed locally: + +```csharp +using Microsoft.Extensions.DataIngestion; + +IngestionDocumentReader reader = + new MarkItDownReader(new FileInfo(@"pathToMarkItDown.exe"), extractImages: true); + +using IngestionPipeline pipeline = new(reader, CreateChunker(), CreateWriter()); +``` + +### Creating a MarkItDownMcpReader for Data Ingestion (MCP Server) + +Use `MarkItDownMcpReader` to convert documents using a MarkItDown MCP server: + +```csharp +using Microsoft.Extensions.DataIngestion; + +// Connect to a MarkItDown MCP server (e.g., running in Docker) +IngestionDocumentReader reader = + new MarkItDownMcpReader(new Uri("http://localhost:3001/mcp")); + +using IngestionPipeline pipeline = new(reader, CreateChunker(), CreateWriter()); +``` + +The MarkItDown MCP server can be run using Docker: + +```bash +docker run -p 3001:3001 mcp/markitdown --http --host 0.0.0.0 --port 3001 +``` + +Or installed via pip: + +```bash +pip install markitdown-mcp-server +markitdown-mcp --http --host 0.0.0.0 --port 3001 +``` + +### Integrating with Aspire + +Aspire can be used for seamless integration with [MarkItDown MCP](https://github.com/microsoft/markitdown/tree/main/packages/markitdown-mcp). Sample AppHost logic: + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +var markitdown = builder.AddContainer("markitdown", "mcp/markitdown") + .WithArgs("--http", "--host", "0.0.0.0", "--port", "3001") + .WithHttpEndpoint(targetPort: 3001, name: "http"); + +var webApp = builder.AddProject("name"); + +webApp.WithEnvironment("MARKITDOWN_MCP_URL", markitdown.GetEndpoint("http")); + +builder.Build().Run(); +``` + +Sample Ingestion Service: + +```csharp +string url = $"{Environment.GetEnvironmentVariable("MARKITDOWN_MCP_URL")}/mcp"; + +IngestionDocumentReader reader = new MarkItDownMcpReader(new Uri(url)); +``` + +## Feedback & Contributing + +We welcome feedback and contributions in [our GitHub repo](https://github.com/dotnet/extensions). diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.Markdig/MarkdownParser.cs b/src/Libraries/Microsoft.Extensions.DataIngestion.Markdig/MarkdownParser.cs new file mode 100644 index 00000000000..8ef2b27d152 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.Markdig/MarkdownParser.cs @@ -0,0 +1,299 @@ +// Licensed 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.Linq; +using System.Text; +using Markdig; +using Markdig.Extensions.Tables; +using Markdig.Syntax; +using Markdig.Syntax.Inlines; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion; + +internal static class MarkdownParser +{ + internal static IngestionDocument Parse(string markdown, string identifier) + { + _ = Throw.IfNullOrEmpty(markdown); + _ = Throw.IfNullOrEmpty(identifier); + + // Markdig's "UseAdvancedExtensions" option includes many common extensions beyond + // CommonMark, such as citations, figures, footnotes, grid tables, mathematics + // task lists, diagrams, and more. + var pipeline = new MarkdownPipelineBuilder() + .UseAdvancedExtensions() + .Build(); + + MarkdownDocument markdownDocument = Markdown.Parse(markdown, pipeline); + return Map(markdownDocument, markdown, identifier); + } + +#if !NET + internal static System.Threading.Tasks.Task ReadToEndAsync(this System.IO.StreamReader reader, System.Threading.CancellationToken cancellationToken) + => cancellationToken.IsCancellationRequested ? System.Threading.Tasks.Task.FromCanceled(cancellationToken) : reader.ReadToEndAsync(); +#endif + + private static IngestionDocument Map(MarkdownDocument markdownDocument, string documentMarkdown, string identifier) + { + IngestionDocumentSection rootSection = new(documentMarkdown); + IngestionDocument result = new(identifier) + { + Sections = { rootSection } + }; + + bool previousWasBreak = false; + foreach (Block block in markdownDocument) + { + if (block is ThematicBreakBlock breakBlock) + { + // We have encountered a thematic break (horizontal rule): ----------- etc. + previousWasBreak = true; + continue; + } + + if (block is LinkReferenceDefinitionGroup linkReferenceGroup) + { + continue; // In the future, we might want to handle links differently. + } + + if (IsEmptyBlock(block)) + { + continue; + } + + rootSection.Elements.Add(MapBlock(documentMarkdown, previousWasBreak, block)); + previousWasBreak = false; + } + + return result; + } + + private static bool IsEmptyBlock(Block block) // Block with no text. Sample: QuoteBlock the next block is a quote. + => block is LeafBlock emptyLeafBlock && (emptyLeafBlock.Inline is null || emptyLeafBlock.Inline.FirstChild is null); + + private static IngestionDocumentElement MapBlock(string documentMarkdown, bool previousWasBreak, Block block) + { + string elementMarkdown = documentMarkdown.Substring(block.Span.Start, block.Span.Length); + + IngestionDocumentElement element = block switch + { + LeafBlock leafBlock => MapLeafBlockToElement(leafBlock, previousWasBreak, elementMarkdown), + ListBlock listBlock => MapListBlock(listBlock, previousWasBreak, documentMarkdown, elementMarkdown), + QuoteBlock quoteBlock => MapQuoteBlock(quoteBlock, previousWasBreak, documentMarkdown, elementMarkdown), + Table table => new IngestionDocumentTable(elementMarkdown, GetCells(table, documentMarkdown)), + _ => throw new NotSupportedException($"Block type '{block.GetType().Name}' is not supported.") + }; + + return element; + } + + private static IngestionDocumentElement MapLeafBlockToElement(LeafBlock block, bool previousWasBreak, string elementMarkdown) + => block switch + { + HeadingBlock heading => new IngestionDocumentHeader(elementMarkdown) + { + Text = GetText(heading.Inline), + Level = heading.Level + }, + ParagraphBlock footer when previousWasBreak => new IngestionDocumentFooter(elementMarkdown) + { + Text = GetText(footer.Inline), + }, + ParagraphBlock image when image.Inline!.Descendants().FirstOrDefault() is LinkInline link && link.IsImage => MapImage(elementMarkdown, link), + ParagraphBlock paragraph => new IngestionDocumentParagraph(elementMarkdown) + { + Text = GetText(paragraph.Inline), + }, + CodeBlock codeBlock => new IngestionDocumentParagraph(elementMarkdown) + { + Text = GetText(codeBlock.Inline), + }, + _ => throw new NotSupportedException($"Block type '{block.GetType().Name}' is not supported.") + }; + + private static IngestionDocumentImage MapImage(string elementMarkdown, LinkInline link) + { + IngestionDocumentImage result = new(elementMarkdown); + + // ![Alt text](data:image/type;base64,...) + if (link.FirstChild is LiteralInline literal) + { + result.AlternativeText = literal.Content.ToString(); + } + + if (link.Url is not null && link.Url.StartsWith("data:image/", StringComparison.Ordinal)) + { + // Parse the data URL format: data:image/{type};base64,{data} + ReadOnlySpan url = link.Url.AsSpan("data:".Length); + + // Find the semicolon that separates media type from encoding + int semicolonIndex = url.IndexOf(';'); + if (semicolonIndex > 0) + { + ReadOnlySpan mediaType = url.Slice(0, semicolonIndex); + + // Find the comma that separates encoding from data + int commaIndex = url.IndexOf(','); + if (commaIndex > semicolonIndex) + { + // Check if it's base64 encoded + ReadOnlySpan encoding = url.Slice(semicolonIndex + 1, commaIndex - semicolonIndex - 1); + if (encoding.SequenceEqual("base64".AsSpan())) + { + result.Content = Convert.FromBase64String(url.Slice(commaIndex + 1).ToString()); + result.MediaType = mediaType.ToString(); + } + } + } + } + + return result; + } + + private static IngestionDocumentSection MapListBlock(ListBlock listBlock, bool previousWasBreak, string documentMarkdown, string listMarkdown) + { + IngestionDocumentSection list = new(listMarkdown); + foreach (Block? item in listBlock) + { + if (item is not ListItemBlock listItemBlock) + { + continue; + } + + foreach (Block? child in listItemBlock) + { + if (child is not LeafBlock leafBlock || IsEmptyBlock(leafBlock)) + { + continue; // Skip empty blocks in lists + } + + string childMarkdown = documentMarkdown.Substring(leafBlock.Span.Start, leafBlock.Span.Length); + IngestionDocumentElement element = MapLeafBlockToElement(leafBlock, previousWasBreak, childMarkdown); + list.Elements.Add(element); + } + } + + return list; + } + + private static IngestionDocumentSection MapQuoteBlock(QuoteBlock quoteBlock, bool previousWasBreak, string documentMarkdown, string elementMarkdown) + { + IngestionDocumentSection quote = new(elementMarkdown); + foreach (Block child in quoteBlock) + { + if (IsEmptyBlock(child)) + { + continue; // Skip empty blocks in quotes + } + + quote.Elements.Add(MapBlock(documentMarkdown, previousWasBreak, child)); + } + + return quote; + } + + private static string? GetText(ContainerInline? containerInline) + { + Debug.Assert(containerInline != null, "ContainerInline should not be null here."); + Debug.Assert(containerInline!.FirstChild != null, "FirstChild should not be null here."); + + if (ReferenceEquals(containerInline.FirstChild, containerInline.LastChild)) + { + // If there is only one child, return its text. + return containerInline.FirstChild!.ToString(); + } + + StringBuilder content = new(100); + foreach (Inline inline in containerInline) + { +#pragma warning disable IDE0058 // Expression value is never used + if (inline is LiteralInline literalInline) + { + content.Append(literalInline.Content); + } + else if (inline is LineBreakInline) + { + content.AppendLine(); // Append a new line for line breaks + } + else if (inline is ContainerInline another) + { + // EmphasisInline is also a ContainerInline, but it does not get any special treatment, + // as we use raw text here (instead of a markdown, where emphasis can be expressed). + content.Append(GetText(another)); + } + else if (inline is CodeInline codeInline) + { + content.Append(codeInline.Content); + } + else + { + throw new NotSupportedException($"Inline type '{inline.GetType().Name}' is not supported."); + } +#pragma warning restore IDE0058 // Expression value is never used + } + + return content.ToString(); + } + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional +#pragma warning disable S3967 // Multidimensional arrays should not be used + private static IngestionDocumentElement?[,] GetCells(Table table, string outputContent) + { + int firstRowIndex = SkipFirstRow(table, outputContent) ? 1 : 0; + + // For some reason, table.ColumnDefinitions.Count returns one extra column. + var cells = new IngestionDocumentElement?[table.Count - firstRowIndex, table.ColumnDefinitions.Count - 1]; + + for (int rowIndex = firstRowIndex; rowIndex < table.Count; rowIndex++) + { + var tableRow = (TableRow)table[rowIndex]; + int columnIndex = 0; + for (int cellIndex = 0; cellIndex < tableRow.Count; cellIndex++) + { + var tableCell = (TableCell)tableRow[cellIndex]; + var content = tableCell.Count switch + { + 0 => null, + 1 => MapBlock(outputContent, previousWasBreak: false, tableCell[0]), + _ => throw new NotSupportedException($"Cells with {tableCell.Count} elements are not supported.") + }; + + for (int columnSpan = 0; columnSpan < tableCell.ColumnSpan; columnSpan++, columnIndex++) + { + // tableCell.ColumnIndex defaults to -1, so it's not used here. + cells[rowIndex - firstRowIndex, columnIndex] = content; + } + } + } + + return cells; + + // Some parsers like MarkItDown include a row with invalid markdown before the separator row: + // | | | | | + // | --- | --- | --- | --- | + static bool SkipFirstRow(Table table, string outputContent) + { + if (table.Count > 0) + { + var firstRow = (TableRow)table[0]; + for (int cellIndex = 0; cellIndex < firstRow.Count; cellIndex++) + { + var tableCell = (TableCell)firstRow[cellIndex]; + if (!string.IsNullOrWhiteSpace(MapBlock(outputContent, previousWasBreak: false, tableCell[0]).Text)) + { + return false; + } + } + + return true; + } + + return false; + } + } +#pragma warning restore CA1814 // Prefer jagged arrays over multidimensional +#pragma warning restore S3967 // Multidimensional arrays should not be used +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.Markdig/MarkdownReader.cs b/src/Libraries/Microsoft.Extensions.DataIngestion.Markdig/MarkdownReader.cs new file mode 100644 index 00000000000..1afabd03139 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.Markdig/MarkdownReader.cs @@ -0,0 +1,52 @@ +// 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.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion; + +/// +/// Reads Markdown content and converts it to an . +/// +public sealed class MarkdownReader : IngestionDocumentReader +{ + /// + public override async Task ReadAsync(FileInfo source, string identifier, string? mediaType = null, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(source); + _ = Throw.IfNullOrEmpty(identifier); + +#if NET + string fileContent = await File.ReadAllTextAsync(source.FullName, cancellationToken).ConfigureAwait(false); +#else + using FileStream stream = new(source.FullName, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 1, FileOptions.Asynchronous); + string fileContent = await ReadToEndAsync(stream, cancellationToken).ConfigureAwait(false); +#endif + return MarkdownParser.Parse(fileContent, identifier); + } + + /// + public override async Task ReadAsync(Stream source, string identifier, string mediaType, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(source); + _ = Throw.IfNullOrEmpty(identifier); + + string fileContent = await ReadToEndAsync(source, cancellationToken).ConfigureAwait(false); + return MarkdownParser.Parse(fileContent, identifier); + } + + private static async Task ReadToEndAsync(Stream source, CancellationToken cancellationToken) + { + using StreamReader reader = +#if NET + new(source, leaveOpen: true); +#else + new(source, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 1024, leaveOpen: true); +#endif + + return await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.Markdig/Microsoft.Extensions.DataIngestion.Markdig.csproj b/src/Libraries/Microsoft.Extensions.DataIngestion.Markdig/Microsoft.Extensions.DataIngestion.Markdig.csproj new file mode 100644 index 00000000000..5dbe9c27a34 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.Markdig/Microsoft.Extensions.DataIngestion.Markdig.csproj @@ -0,0 +1,24 @@ + + + + $(TargetFrameworks);netstandard2.0 + Microsoft.Extensions.DataIngestion + Implementation of IngestionDocumentReader abstraction for Markdown. + RAG + RAG;ingestion;documents;markdown + true + preview + false + 75 + 75 + + + + + + + + + + + diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.Markdig/README.md b/src/Libraries/Microsoft.Extensions.DataIngestion.Markdig/README.md new file mode 100644 index 00000000000..c6a2328699c --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.Markdig/README.md @@ -0,0 +1,35 @@ +# Microsoft.Extensions.DataIngestion.Markdig + +Provides an implementation of the `IngestionDocumentReader` class for the Markdown files using [MarkDig](https://github.com/xoofx/markdig) library. + +## Install the package + +From the command-line: + +```console +dotnet add package Microsoft.Extensions.DataIngestion.Markdig --prerelease +``` + +Or directly in the C# project file: + +```xml + + + +``` + +## Usage Examples + +### Creating a MarkdownReader for Data Ingestion + +```csharp +using Microsoft.Extensions.DataIngestion; + +IngestionDocumentReader reader = new MarkdownReader(); + +using IngestionPipeline pipeline = new(reader, CreateChunker(), CreateWriter()); +``` + +## Feedback & Contributing + +We welcome feedback and contributions in [our GitHub repo](https://github.com/dotnet/extensions). diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/Chunkers/ElementsChunker.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/Chunkers/ElementsChunker.cs new file mode 100644 index 00000000000..a50508f2a5e --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/Chunkers/ElementsChunker.cs @@ -0,0 +1,273 @@ +// Licensed 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; +using Microsoft.ML.Tokenizers; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion.Chunkers; + +#pragma warning disable IDE0058 // Expression value is never used + +internal sealed class ElementsChunker +{ + private readonly Tokenizer _tokenizer; + private readonly int _maxTokensPerChunk; + private readonly StringBuilder _currentChunk; + + internal ElementsChunker(IngestionChunkerOptions options) + { + _ = Throw.IfNull(options); + + _tokenizer = options.Tokenizer; + _maxTokensPerChunk = options.MaxTokensPerChunk; + + // Token count != character count, but StringBuilder will grow as needed. + _currentChunk = new(capacity: _maxTokensPerChunk); + } + + // Goals: + // 1. Create chunks that do not exceed _maxTokensPerChunk when tokenized. + // 2. Maintain context in each chunk. + // 3. If a single IngestionDocumentElement exceeds _maxTokensPerChunk, it should be split intelligently (e.g., paragraphs can be split into sentences, tables into rows). + internal IEnumerable> Process(IngestionDocument document, string context, List elements) + { + // Not using yield return here as we use ref structs. + List> chunks = []; + + int contextTokenCount = CountTokens(context.AsSpan()); + int totalTokenCount = contextTokenCount; + + // If the context itself exceeds the max tokens per chunk, we can't do anything. + if (contextTokenCount >= _maxTokensPerChunk) + { + ThrowTokenCountExceeded(); + } + + _currentChunk.Append(context); + + for (int elementIndex = 0; elementIndex < elements.Count; elementIndex++) + { + IngestionDocumentElement element = elements[elementIndex]; + string? semanticContent = element switch + { + // Image exposes: + // - Markdown: ![Alt Text](url) which is not very useful for embedding. + // - AlternativeText: usually a short description of the image, can be null or empty. It is usually less than 50 words. + // - Text: result of OCR, can be longer, but also can be null or empty. It can be several hundred words. + // We prefer AlternativeText over Text, as it is usually more relevant. + IngestionDocumentImage image => image.AlternativeText ?? image.Text, + _ => element.GetMarkdown() + }; + + if (string.IsNullOrEmpty(semanticContent)) + { + continue; // An image can come with Markdown, but no AlternativeText or Text. + } + + int elementTokenCount = CountTokens(semanticContent.AsSpan()); + if (elementTokenCount + totalTokenCount <= _maxTokensPerChunk) + { + totalTokenCount += elementTokenCount; + AppendNewLineAndSpan(_currentChunk, semanticContent.AsSpan()); + } + else if (element is IngestionDocumentTable table) + { + ValueStringBuilder tableBuilder = new(initialCapacity: 8000); + + try + { + AddMarkdownTableRow(table, rowIndex: 0, ref tableBuilder); + AddMarkdownTableSeparatorRow(columnCount: table.Cells.GetLength(1), ref tableBuilder); + + int headerLength = tableBuilder.Length; + int headerTokenCount = CountTokens(tableBuilder.AsSpan()); + + // We can't respect the limit if context and header themselves use more tokens. + if (contextTokenCount + headerTokenCount >= _maxTokensPerChunk) + { + ThrowTokenCountExceeded(); + } + + if (headerTokenCount + totalTokenCount >= _maxTokensPerChunk) + { + // We can't add the header row, so commit what we have accumulated so far. + Commit(); + } + + totalTokenCount += headerTokenCount; + int tableLength = headerLength; + + int rowCount = table.Cells.GetLength(0); + for (int rowIndex = 1; rowIndex < rowCount; rowIndex++) + { + AddMarkdownTableRow(table, rowIndex, ref tableBuilder); + + int lastRowTokens = CountTokens(tableBuilder.AsSpan(tableLength)); + + // Appending this row would exceed the limit. + if (totalTokenCount + lastRowTokens > _maxTokensPerChunk) + { + // We append the table as long as it's not just the header. + if (rowIndex != 1) + { + AppendNewLineAndSpan(_currentChunk, tableBuilder.AsSpan(0, tableLength - Environment.NewLine.Length)); + } + + // And commit the table we built so far. + Commit(); + + // Erase previous rows and keep only the header. + tableBuilder.Length = headerLength; + tableLength = headerLength; + totalTokenCount += headerTokenCount; + + if (totalTokenCount + lastRowTokens > _maxTokensPerChunk) + { + // This row is simply too big even for a fresh chunk: + ThrowTokenCountExceeded(); + } + + AddMarkdownTableRow(table, rowIndex, ref tableBuilder); + } + + tableLength = tableBuilder.Length; + totalTokenCount += lastRowTokens; + } + + AppendNewLineAndSpan(_currentChunk, tableBuilder.AsSpan(0, tableLength - Environment.NewLine.Length)); + } + finally + { + tableBuilder.Dispose(); + } + } + else + { + ReadOnlySpan remainingContent = semanticContent.AsSpan(); + + while (!remainingContent.IsEmpty) + { + int index = _tokenizer.GetIndexByTokenCount( + text: remainingContent, + maxTokenCount: _maxTokensPerChunk - totalTokenCount, + out string? normalizedText, + out int tokenCount, + considerNormalization: false); // We don't normalize, just append as-is to keep original content. + + // some tokens fit + if (index > 0) + { + // We could try to split by sentences or other delimiters, but it's complicated. + // For simplicity, we will just split at the last new line that fits. + // Our promise is not to go over the max token count, not to create perfect chunks. + int newLineIndex = remainingContent.Slice(0, index).LastIndexOf('\n'); + if (newLineIndex > 0) + { + index = newLineIndex + 1; // We want to include the new line character (works for "\r\n" as well). + tokenCount = CountTokens(remainingContent.Slice(0, index)); + } + + totalTokenCount += tokenCount; + ReadOnlySpan spanToAppend = remainingContent.Slice(0, index); + AppendNewLineAndSpan(_currentChunk, spanToAppend); + remainingContent = remainingContent.Slice(index); + } + else if (totalTokenCount == contextTokenCount) + { + // We are at the beginning of a chunk, and even a single token does not fit. + ThrowTokenCountExceeded(); + } + + if (!remainingContent.IsEmpty) + { + Commit(); + } + } + } + + if (totalTokenCount == _maxTokensPerChunk) + { + Commit(); + } + } + + if (totalTokenCount > contextTokenCount) + { + chunks.Add(new(_currentChunk.ToString(), document, context)); + } + + _currentChunk.Clear(); + + return chunks; + + void Commit() + { + chunks.Add(new(_currentChunk.ToString(), document, context)); + + // We keep the context in the current chunk as it's the same for all elements. + _currentChunk.Remove( + startIndex: context.Length, + length: _currentChunk.Length - context.Length); + totalTokenCount = contextTokenCount; + } + + static void ThrowTokenCountExceeded() + => throw new InvalidOperationException("Can't fit in the current chunk. Consider increasing max tokens per chunk."); + } + + private static void AppendNewLineAndSpan(StringBuilder stringBuilder, ReadOnlySpan chars) + { + // Don't start an empty chunk (no context provided) with a new line. + if (stringBuilder.Length > 0) + { + stringBuilder.AppendLine(); + } + +#if NET + stringBuilder.Append(chars); +#else + stringBuilder.Append(chars.ToString()); +#endif + } + + private static void AddMarkdownTableRow(IngestionDocumentTable table, int rowIndex, ref ValueStringBuilder vsb) + { + for (int columnIndex = 0; columnIndex < table.Cells.GetLength(1); columnIndex++) + { + vsb.Append('|'); + vsb.Append(' '); + string? cellContent = table.Cells[rowIndex, columnIndex] switch + { + null => null, + IngestionDocumentImage img => img.AlternativeText ?? img.Text, + IngestionDocumentElement other => other.GetMarkdown() + }; + vsb.Append(cellContent); + vsb.Append(' '); + } + + vsb.Append('|'); + vsb.Append(Environment.NewLine); + } + + private static void AddMarkdownTableSeparatorRow(int columnCount, ref ValueStringBuilder vsb) + { + const int DashCount = 3; // The dash count does not need to match the header length. + for (int columnIndex = 0; columnIndex < columnCount; columnIndex++) + { + vsb.Append('|'); + vsb.Append(' '); + vsb.Append('-', DashCount); + vsb.Append(' '); + } + + vsb.Append('|'); + vsb.Append(Environment.NewLine); + } + + private int CountTokens(ReadOnlySpan input) + => _tokenizer.CountTokens(input, considerNormalization: false); +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/Chunkers/HeaderChunker.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/Chunkers/HeaderChunker.cs new file mode 100644 index 00000000000..8f3039c7b2f --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/Chunkers/HeaderChunker.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.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using Microsoft.Extensions.DataIngestion.Chunkers; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion; + +/// +/// Splits documents into chunks based on headers and their corresponding levels, preserving the header context. +/// +public sealed class HeaderChunker : IngestionChunker +{ + private const int MaxHeaderLevel = 10; + private readonly ElementsChunker _elementsChunker; + + /// + /// Initializes a new instance of the class. + /// + /// The options for the chunker. + public HeaderChunker(IngestionChunkerOptions options) + { + _elementsChunker = new(options); + } + + /// + public override async IAsyncEnumerable> ProcessAsync(IngestionDocument document, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(document); + + List elements = []; + string?[] headers = new string?[MaxHeaderLevel + 1]; + + foreach (IngestionDocumentElement element in document.EnumerateContent()) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (element is IngestionDocumentHeader header) + { + foreach (var chunk in SplitIntoChunks(document, headers, elements)) + { + yield return chunk; + } + + int headerLevel = header.Level.GetValueOrDefault(); + headers[headerLevel] = header.GetMarkdown(); + headers.AsSpan(headerLevel + 1).Clear(); // clear all lower level headers + + continue; // don't add headers to the elements list, they are part of the context + } + + elements.Add(element); + } + + // take care of any remaining paragraphs + foreach (var chunk in SplitIntoChunks(document, headers, elements)) + { + yield return chunk; + } + } + + private IEnumerable> SplitIntoChunks(IngestionDocument document, string?[] headers, List elements) + { + if (elements.Count > 0) + { + string chunkHeader = string.Join(" ", headers.Where(h => !string.IsNullOrEmpty(h))); + + foreach (var chunk in _elementsChunker.Process(document, chunkHeader, elements)) + { + yield return chunk; + } + + elements.Clear(); + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/Chunkers/IngestionChunkerOptions.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/Chunkers/IngestionChunkerOptions.cs new file mode 100644 index 00000000000..294f4c92d27 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/Chunkers/IngestionChunkerOptions.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.ML.Tokenizers; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion; + +/// +/// Options for configuring the ingestion chunker. +/// +public class IngestionChunkerOptions +{ + // Default values come from https://learn.microsoft.com/en-us/azure/search/vector-search-how-to-chunk-documents#text-split-skill-example + private const int DefaultOverlapTokens = 500; + private const int DefaultTokensPerChunk = 2_000; + private int? _overlapTokens; + + /// + /// Initializes a new instance of the class. + /// + /// The tokenizer to use for tokenizing input. + public IngestionChunkerOptions(Tokenizer tokenizer) + { + Tokenizer = Throw.IfNull(tokenizer); + } + + /// + /// Gets the instance used to process and tokenize input data. + /// + public Tokenizer Tokenizer { get; } + + /// + /// Gets or sets the maximum number of tokens allowed in each chunk. Default is 2000. + /// + public int MaxTokensPerChunk + { + get => field == default ? DefaultTokensPerChunk : field; + set + { + _ = Throw.IfLessThanOrEqual(value, 0); + + if (_overlapTokens.HasValue && value <= _overlapTokens.Value) + { + Throw.ArgumentOutOfRangeException(nameof(value), "Chunk size must be greater than chunk overlap."); + } + + field = value; + } + } + + /// + /// Gets or sets the number of overlapping tokens between consecutive chunks. Default is 500. + /// + public int OverlapTokens + { + get + { + if (_overlapTokens.HasValue) + { + return _overlapTokens.Value; + } + else if (MaxTokensPerChunk > DefaultOverlapTokens) + { + return DefaultOverlapTokens; + } + else + { + return 0; + } + } + set + { + if (Throw.IfLessThan(value, 0) >= MaxTokensPerChunk) + { + Throw.ArgumentOutOfRangeException(nameof(value), "Chunk overlap must be less than chunk size."); + } + + _overlapTokens = value; + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/Chunkers/SemanticSimilarityChunker.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/Chunkers/SemanticSimilarityChunker.cs new file mode 100644 index 00000000000..78971cfe920 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/Chunkers/SemanticSimilarityChunker.cs @@ -0,0 +1,141 @@ +// Licensed 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.Numerics.Tensors; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion.Chunkers; + +/// +/// Splits a into chunks based on semantic similarity between its elements based on cosine distance of their embeddings. +/// +public sealed class SemanticSimilarityChunker : IngestionChunker +{ + private readonly ElementsChunker _elementsChunker; + private readonly IEmbeddingGenerator> _embeddingGenerator; + private readonly float _thresholdPercentile; + + /// + /// Initializes a new instance of the class. + /// + /// Embedding generator. + /// The options for the chunker. + /// Threshold percentile to consider the chunks to be sufficiently similar. 95th percentile will be used if not specified. + public SemanticSimilarityChunker( + IEmbeddingGenerator> embeddingGenerator, + IngestionChunkerOptions options, + float? thresholdPercentile = null) + { + _embeddingGenerator = embeddingGenerator; + _elementsChunker = new(options); + + if (thresholdPercentile < 0f || thresholdPercentile > 100f) + { + Throw.ArgumentOutOfRangeException(nameof(thresholdPercentile), "Threshold percentile must be between 0 and 100."); + } + + _thresholdPercentile = thresholdPercentile ?? 95.0f; + } + + /// + public override async IAsyncEnumerable> ProcessAsync(IngestionDocument document, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(document); + + List<(IngestionDocumentElement, float)> distances = await CalculateDistancesAsync(document, cancellationToken).ConfigureAwait(false); + foreach (var chunk in MakeChunks(document, distances)) + { + yield return chunk; + } + } + + private async Task> CalculateDistancesAsync(IngestionDocument documents, CancellationToken cancellationToken) + { + List<(IngestionDocumentElement element, float distance)> elementDistances = []; + List semanticContents = []; + + foreach (IngestionDocumentElement element in documents.EnumerateContent()) + { + string? semanticContent = element is IngestionDocumentImage img + ? img.AlternativeText ?? img.Text + : element.GetMarkdown(); + + if (!string.IsNullOrEmpty(semanticContent)) + { + elementDistances.Add((element, default)); + semanticContents.Add(semanticContent!); + } + } + + if (elementDistances.Count > 0) + { + var embeddings = await _embeddingGenerator.GenerateAsync(semanticContents, cancellationToken: cancellationToken).ConfigureAwait(false); + + if (embeddings.Count != elementDistances.Count) + { + Throw.InvalidOperationException("The number of embeddings returned does not match the number of document elements."); + } + + for (int i = 0; i < elementDistances.Count - 1; i++) + { + float distance = 1 - TensorPrimitives.CosineSimilarity(embeddings[i].Vector.Span, embeddings[i + 1].Vector.Span); + elementDistances[i] = (elementDistances[i].element, distance); + } + } + + return elementDistances; + } + + private IEnumerable> MakeChunks(IngestionDocument document, List<(IngestionDocumentElement element, float distance)> elementDistances) + { + float distanceThreshold = Percentile(elementDistances); + + List elementAccumulator = []; + string context = string.Empty; + for (int i = 0; i < elementDistances.Count; i++) + { + var (element, distance) = elementDistances[i]; + + elementAccumulator.Add(element); + if (distance > distanceThreshold || i == elementDistances.Count - 1) + { + foreach (var chunk in _elementsChunker.Process(document, context, elementAccumulator)) + { + yield return chunk; + } + elementAccumulator.Clear(); + } + } + } + + private float Percentile(List<(IngestionDocumentElement element, float distance)> elementDistances) + { + if (elementDistances.Count == 0) + { + return 0f; + } + else if (elementDistances.Count == 1) + { + return elementDistances[0].distance; + } + + float[] sorted = new float[elementDistances.Count]; + for (int elementIndex = 0; elementIndex < elementDistances.Count; elementIndex++) + { + sorted[elementIndex] = elementDistances[elementIndex].distance; + } + Array.Sort(sorted); + + float i = (_thresholdPercentile / 100f) * (sorted.Length - 1); + int i0 = (int)i; + int i1 = Math.Min(i0 + 1, sorted.Length - 1); + return sorted[i0] + ((i - i0) * (sorted[i1] - sorted[i0])); + } +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/Chunkers/ValueStringBuilder.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/Chunkers/ValueStringBuilder.cs new file mode 100644 index 00000000000..199d55262b3 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/Chunkers/ValueStringBuilder.cs @@ -0,0 +1,288 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// Let's lie about the file being auto-generated to suppress all kinds of analyzer warnings. +// + +#nullable enable + +using System.Buffers; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace System.Text +{ + // Code from https://github.com/dotnet/runtime/blob/main/src/libraries/Common/src/System/Text/ValueStringBuilder.cs + // See https://github.com/dotnet/runtime/issues/25587 for more details. + internal ref partial struct ValueStringBuilder + { + private char[]? _arrayToReturnToPool; + private Span _chars; + private int _pos; + + public ValueStringBuilder(Span initialBuffer) + { + _arrayToReturnToPool = null; + _chars = initialBuffer; + _pos = 0; + } + + public ValueStringBuilder(int initialCapacity) + { + _arrayToReturnToPool = ArrayPool.Shared.Rent(initialCapacity); + _chars = _arrayToReturnToPool; + _pos = 0; + } + + public int Length + { + get => _pos; + set + { + Debug.Assert(value >= 0); + Debug.Assert(value <= _chars.Length); + _pos = value; + } + } + + public int Capacity => _chars.Length; + + public void EnsureCapacity(int capacity) + { + // This is not expected to be called this with negative capacity + Debug.Assert(capacity >= 0); + + // If the caller has a bug and calls this with negative capacity, make sure to call Grow to throw an exception. + if ((uint)capacity > (uint)_chars.Length) + Grow(capacity - _pos); + } + + /// + /// Ensures that the builder is terminated with a NUL character. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void NullTerminate() + { + EnsureCapacity(_pos + 1); + _chars[_pos] = '\0'; + } + + /// + /// Get a pinnable reference to the builder. + /// Does not ensure there is a null char after + /// This overload is pattern matched in the C# 7.3+ compiler so you can omit + /// the explicit method call, and write eg "fixed (char* c = builder)" + /// + public ref char GetPinnableReference() + { + return ref MemoryMarshal.GetReference(_chars); + } + + public ref char this[int index] + { + get + { + Debug.Assert(index < _pos); + return ref _chars[index]; + } + } + + public override string ToString() + { + string s = _chars.Slice(0, _pos).ToString(); + Dispose(); + return s; + } + + /// Returns the underlying storage of the builder. + public Span RawChars => _chars; + + public ReadOnlySpan AsSpan() => _chars.Slice(0, _pos); + public ReadOnlySpan AsSpan(int start) => _chars.Slice(start, _pos - start); + public ReadOnlySpan AsSpan(int start, int length) => _chars.Slice(start, length); + + public void Insert(int index, char value, int count) + { + if (_pos > _chars.Length - count) + { + Grow(count); + } + + int remaining = _pos - index; + _chars.Slice(index, remaining).CopyTo(_chars.Slice(index + count)); + _chars.Slice(index, count).Fill(value); + _pos += count; + } + + public void Insert(int index, string? s) + { + if (s == null) + { + return; + } + + int count = s.Length; + + if (_pos > (_chars.Length - count)) + { + Grow(count); + } + + int remaining = _pos - index; + _chars.Slice(index, remaining).CopyTo(_chars.Slice(index + count)); + s +#if !NET + .AsSpan() +#endif + .CopyTo(_chars.Slice(index)); + _pos += count; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Append(char c) + { + int pos = _pos; + Span chars = _chars; + if ((uint)pos < (uint)chars.Length) + { + chars[pos] = c; + _pos = pos + 1; + } + else + { + GrowAndAppend(c); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Append(string? s) + { + if (s == null) + { + return; + } + + int pos = _pos; + if (s.Length == 1 && (uint)pos < (uint)_chars.Length) // very common case, e.g. appending strings from NumberFormatInfo like separators, percent symbols, etc. + { + _chars[pos] = s[0]; + _pos = pos + 1; + } + else + { + AppendSlow(s); + } + } + + private void AppendSlow(string s) + { + int pos = _pos; + if (pos > _chars.Length - s.Length) + { + Grow(s.Length); + } + + s +#if !NET + .AsSpan() +#endif + .CopyTo(_chars.Slice(pos)); + _pos += s.Length; + } + + public void Append(char c, int count) + { + if (_pos > _chars.Length - count) + { + Grow(count); + } + + Span dst = _chars.Slice(_pos, count); + for (int i = 0; i < dst.Length; i++) + { + dst[i] = c; + } + _pos += count; + } + + public void Append(scoped ReadOnlySpan value) + { + int pos = _pos; + if (pos > _chars.Length - value.Length) + { + Grow(value.Length); + } + + value.CopyTo(_chars.Slice(_pos)); + _pos += value.Length; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Span AppendSpan(int length) + { + int origPos = _pos; + if (origPos > _chars.Length - length) + { + Grow(length); + } + + _pos = origPos + length; + return _chars.Slice(origPos, length); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void GrowAndAppend(char c) + { + Grow(1); + Append(c); + } + + /// + /// Resize the internal buffer either by doubling current buffer size or + /// by adding to + /// whichever is greater. + /// + /// + /// Number of chars requested beyond current position. + /// + [MethodImpl(MethodImplOptions.NoInlining)] + private void Grow(int additionalCapacityBeyondPos) + { + Debug.Assert(additionalCapacityBeyondPos > 0); + Debug.Assert(_pos > _chars.Length - additionalCapacityBeyondPos, "Grow called incorrectly, no resize is needed."); + + const uint ArrayMaxLength = 0x7FFFFFC7; // same as Array.MaxLength + + // Increase to at least the required size (_pos + additionalCapacityBeyondPos), but try + // to double the size if possible, bounding the doubling to not go beyond the max array length. + int newCapacity = (int)Math.Max( + (uint)(_pos + additionalCapacityBeyondPos), + Math.Min((uint)_chars.Length * 2, ArrayMaxLength)); + + // Make sure to let Rent throw an exception if the caller has a bug and the desired capacity is negative. + // This could also go negative if the actual required length wraps around. + char[] poolArray = ArrayPool.Shared.Rent(newCapacity); + + _chars.Slice(0, _pos).CopyTo(poolArray); + + char[]? toReturn = _arrayToReturnToPool; + _chars = _arrayToReturnToPool = poolArray; + if (toReturn != null) + { + ArrayPool.Shared.Return(toReturn); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Dispose() + { + char[]? toReturn = _arrayToReturnToPool; + this = default; // for safety, to avoid using pooled array if this instance is erroneously appended to again + if (toReturn != null) + { + ArrayPool.Shared.Return(toReturn); + } + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/DiagnosticsConstants.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/DiagnosticsConstants.cs new file mode 100644 index 00000000000..4251bef6ae3 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/DiagnosticsConstants.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.DataIngestion; + +internal static class DiagnosticsConstants +{ + internal const string ActivitySourceName = "Experimental.Microsoft.Extensions.DataIngestion"; + internal const string ErrorTypeTagName = "error.type"; + + internal static class ProcessDirectory + { + internal const string ActivityName = "ProcessDirectory"; + internal const string DirectoryPathTagName = "rag.directory.path"; + internal const string SearchPatternTagName = "rag.directory.search.pattern"; + internal const string SearchOptionTagName = "rag.directory.search.option"; + } + + internal static class ProcessFiles + { + internal const string ActivityName = "ProcessFiles"; + internal const string FileCountTagName = "rag.file.count"; + } + + internal static class ProcessSource + { + internal const string DocumentIdTagName = "rag.document.id"; + } + + internal static class ProcessFile + { + internal const string ActivityName = "ProcessFile"; + internal const string FilePathTagName = "rag.file.path"; + } +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/IngestionPipeline.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/IngestionPipeline.cs new file mode 100644 index 00000000000..1eeb94058ee --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/IngestionPipeline.cs @@ -0,0 +1,195 @@ +// Licensed 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.IO; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; +using static Microsoft.Extensions.DataIngestion.DiagnosticsConstants; + +namespace Microsoft.Extensions.DataIngestion; + +#pragma warning disable IDE0058 // Expression value is never used +#pragma warning disable IDE0063 // Use simple 'using' statement +#pragma warning disable CA1031 // Do not catch general exception types + +/// +/// Represents a pipeline for ingesting data from documents and processing it into chunks. +/// +/// The type of the chunk content. +public sealed class IngestionPipeline : IDisposable +{ + private readonly IngestionDocumentReader _reader; + private readonly IngestionChunker _chunker; + private readonly IngestionChunkWriter _writer; + private readonly ActivitySource _activitySource; + private readonly ILogger? _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The reader for ingestion documents. + /// The chunker to split documents into chunks. + /// The writer for processing chunks. + /// The options for the ingestion pipeline. + /// The logger factory for creating loggers. + public IngestionPipeline( + IngestionDocumentReader reader, + IngestionChunker chunker, + IngestionChunkWriter writer, + IngestionPipelineOptions? options = default, + ILoggerFactory? loggerFactory = default) + { + _reader = Throw.IfNull(reader); + _chunker = Throw.IfNull(chunker); + _writer = Throw.IfNull(writer); + _activitySource = new((options ?? new()).ActivitySourceName); + _logger = loggerFactory?.CreateLogger>(); + } + + /// + public void Dispose() + { + _writer.Dispose(); + _activitySource.Dispose(); + } + + /// + /// Gets the document processors in the pipeline. + /// + public IList DocumentProcessors { get; } = []; + + /// + /// Gets the chunk processors in the pipeline. + /// + public IList> ChunkProcessors { get; } = []; + + /// + /// Processes all files in the specified directory that match the given search pattern and option. + /// + /// The directory to process. + /// The search pattern for file selection. + /// The search option for directory traversal. + /// The cancellation token for the operation. + /// A task representing the asynchronous operation. + public async IAsyncEnumerable ProcessAsync(DirectoryInfo directory, string searchPattern = "*.*", + SearchOption searchOption = SearchOption.TopDirectoryOnly, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + Throw.IfNull(directory); + Throw.IfNullOrEmpty(searchPattern); + Throw.IfOutOfRange((int)searchOption, (int)SearchOption.TopDirectoryOnly, (int)SearchOption.AllDirectories); + + using (Activity? rootActivity = _activitySource.StartActivity(ProcessDirectory.ActivityName)) + { + rootActivity?.SetTag(ProcessDirectory.DirectoryPathTagName, directory.FullName) + .SetTag(ProcessDirectory.SearchPatternTagName, searchPattern) + .SetTag(ProcessDirectory.SearchOptionTagName, searchOption.ToString()); + _logger?.ProcessingDirectory(directory.FullName, searchPattern, searchOption); + + await foreach (var ingestionResult in ProcessAsync(directory.EnumerateFiles(searchPattern, searchOption), rootActivity, cancellationToken).ConfigureAwait(false)) + { + yield return ingestionResult; + } + } + } + + /// + /// Processes the specified files. + /// + /// The collection of files to process. + /// The cancellation token for the operation. + /// A task representing the asynchronous operation. + public async IAsyncEnumerable ProcessAsync(IEnumerable files, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + Throw.IfNull(files); + + using (Activity? rootActivity = _activitySource.StartActivity(ProcessFiles.ActivityName)) + { + await foreach (var ingestionResult in ProcessAsync(files, rootActivity, cancellationToken).ConfigureAwait(false)) + { + yield return ingestionResult; + } + } + } + + private static string GetShortName(object any) => any.GetType().Name; + + private static void TraceException(Activity? activity, Exception ex) + { + activity?.SetTag(ErrorTypeTagName, ex.GetType().FullName) + .SetStatus(ActivityStatusCode.Error, ex.Message); + } + + private async IAsyncEnumerable ProcessAsync(IEnumerable files, Activity? rootActivity, + [EnumeratorCancellation] CancellationToken cancellationToken) + { +#if NET + if (System.Linq.Enumerable.TryGetNonEnumeratedCount(files, out int count)) +#else + if (files is IReadOnlyCollection { Count: int count }) +#endif + { + rootActivity?.SetTag(ProcessFiles.FileCountTagName, count); + _logger?.LogFileCount(count); + } + + foreach (FileInfo fileInfo in files) + { + using (Activity? processFileActivity = _activitySource.StartActivity(ProcessFile.ActivityName, ActivityKind.Internal, parentContext: rootActivity?.Context ?? default)) + { + processFileActivity?.SetTag(ProcessFile.FilePathTagName, fileInfo.FullName); + _logger?.ReadingFile(fileInfo.FullName, GetShortName(_reader)); + + IngestionDocument? document = null; + Exception? failure = null; + try + { + document = await _reader.ReadAsync(fileInfo, cancellationToken).ConfigureAwait(false); + + processFileActivity?.SetTag(ProcessSource.DocumentIdTagName, document.Identifier); + _logger?.ReadDocument(document.Identifier); + + document = await IngestAsync(document, processFileActivity, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + TraceException(processFileActivity, ex); + _logger?.IngestingFailed(ex, document?.Identifier ?? fileInfo.FullName); + + failure = ex; + } + + string documentId = document?.Identifier ?? fileInfo.FullName; + yield return new IngestionResult(documentId, document, failure); + } + } + } + + private async Task IngestAsync(IngestionDocument document, Activity? parentActivity, CancellationToken cancellationToken) + { + foreach (IngestionDocumentProcessor processor in DocumentProcessors) + { + document = await processor.ProcessAsync(document, cancellationToken).ConfigureAwait(false); + + // A DocumentProcessor might change the document identifier (for example by extracting it from its content), so update the ID tag. + parentActivity?.SetTag(ProcessSource.DocumentIdTagName, document.Identifier); + } + + IAsyncEnumerable> chunks = _chunker.ProcessAsync(document, cancellationToken); + foreach (var processor in ChunkProcessors) + { + chunks = processor.ProcessAsync(chunks, cancellationToken); + } + + _logger?.WritingChunks(GetShortName(_writer)); + await _writer.WriteAsync(chunks, cancellationToken).ConfigureAwait(false); + _logger?.WroteChunks(document.Identifier); + + return document; + } +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/IngestionPipelineOptions.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/IngestionPipelineOptions.cs new file mode 100644 index 00000000000..3b30e616b5f --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/IngestionPipelineOptions.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion; + +#pragma warning disable SA1500 // Braces for multi-line statements should not share line +#pragma warning disable SA1513 // Closing brace should be followed by blank line + +/// +/// Options for configuring the ingestion pipeline. +/// +public sealed class IngestionPipelineOptions +{ + /// + /// Gets or sets the name of the used for diagnostics. + /// + public string ActivitySourceName + { + get; + set => field = Throw.IfNullOrEmpty(value); + } = DiagnosticsConstants.ActivitySourceName; + + internal IngestionPipelineOptions Clone() => new() + { + ActivitySourceName = ActivitySourceName, + }; +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/IngestionResult.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/IngestionResult.cs new file mode 100644 index 00000000000..1a4e57ea3b8 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/IngestionResult.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 Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion; + +/// +/// Represents the result of an ingestion operation. +/// +public sealed class IngestionResult +{ + /// + /// Gets the ID of the document that was ingested. + /// + public string DocumentId { get; } + + /// + /// Gets the ingestion document created from the source file, if reading the document has succeeded. + /// + public IngestionDocument? Document { get; } + + /// + /// Gets the exception that occurred during ingestion, if any. + /// + public Exception? Exception { get; } + + /// + /// Gets a value indicating whether the ingestion succeeded. + /// + public bool Succeeded => Exception is null; + + internal IngestionResult(string documentId, IngestionDocument? document, Exception? exception) + { + DocumentId = Throw.IfNullOrEmpty(documentId); + Document = document; + Exception = exception; + } +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/Log.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/Log.cs new file mode 100644 index 00000000000..58732e8ead7 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/Log.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; +using Microsoft.Extensions.Logging; + +#pragma warning disable S109 // Magic numbers should not be used + +namespace Microsoft.Extensions.DataIngestion +{ + internal static partial class Log + { + [LoggerMessage(0, LogLevel.Information, "Starting to process files in directory '{directory}' with search pattern '{searchPattern}' and search option '{searchOption}'.")] + internal static partial void ProcessingDirectory(this ILogger logger, string directory, string searchPattern, System.IO.SearchOption searchOption); + + [LoggerMessage(1, LogLevel.Information, "Processing {fileCount} files.")] + internal static partial void LogFileCount(this ILogger logger, int fileCount); + + [LoggerMessage(2, LogLevel.Information, "Reading file '{filePath}' using '{reader}'.")] + internal static partial void ReadingFile(this ILogger logger, string filePath, string reader); + + [LoggerMessage(3, LogLevel.Information, "Read document '{documentId}'.")] + internal static partial void ReadDocument(this ILogger logger, string documentId); + + [LoggerMessage(4, LogLevel.Information, "Writing chunks using {writer}.")] + internal static partial void WritingChunks(this ILogger logger, string writer); + + [LoggerMessage(5, LogLevel.Information, "Wrote chunks for document '{documentId}'.")] + internal static partial void WroteChunks(this ILogger logger, string documentId); + + [LoggerMessage(6, LogLevel.Error, "An error occurred while ingesting document '{identifier}'.")] + internal static partial void IngestingFailed(this ILogger logger, Exception exception, string identifier); + + [LoggerMessage(7, LogLevel.Error, "The AI chat service returned {resultCount} instead of {expectedCount} results.")] + internal static partial void UnexpectedResultsCount(this ILogger logger, int resultCount, int expectedCount); + + [LoggerMessage(8, LogLevel.Error, "Unexpected enricher failure.")] + internal static partial void UnexpectedEnricherFailure(this ILogger logger, Exception exception); + } +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/Microsoft.Extensions.DataIngestion.csproj b/src/Libraries/Microsoft.Extensions.DataIngestion/Microsoft.Extensions.DataIngestion.csproj new file mode 100644 index 00000000000..b7515183a86 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/Microsoft.Extensions.DataIngestion.csproj @@ -0,0 +1,31 @@ + + + + $(TargetFrameworks);netstandard2.0 + Microsoft.Extensions.DataIngestion + Data Ingestion utilities for RAG. + RAG + RAG;ingestion;documents + true + false + true + preview + false + 75 + 75 + + + + + + + + + + + + + + + + diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/ClassificationEnricher.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/ClassificationEnricher.cs new file mode 100644 index 00000000000..ad7b7d645d6 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/ClassificationEnricher.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; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion; + +/// +/// Enriches document chunks with a classification label based on their content. +/// +/// This class uses a chat-based language model to analyze the content of document chunks and assign a +/// single, most relevant classification label. The classification is performed using a predefined set of classes, with +/// an optional fallback class for cases where no suitable classification can be determined. +public sealed class ClassificationEnricher : IngestionChunkProcessor +{ + private readonly EnricherOptions _options; + private readonly ChatMessage _systemPrompt; + private readonly ILogger? _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The options for the classification enricher. + /// The set of predefined classification classes. + /// The fallback class to use when no suitable classification is found. When not provided, it defaults to "Unknown". + public ClassificationEnricher(EnricherOptions options, ReadOnlySpan predefinedClasses, + string? fallbackClass = null) + { + _options = Throw.IfNull(options).Clone(); + if (string.IsNullOrWhiteSpace(fallbackClass)) + { + fallbackClass = "Unknown"; + } + + Validate(predefinedClasses, fallbackClass!); + _systemPrompt = CreateSystemPrompt(predefinedClasses, fallbackClass!); + _logger = _options.LoggerFactory?.CreateLogger(); + } + + /// + /// Gets the metadata key used to store the classification. + /// + public static string MetadataKey => "classification"; + + /// + public override IAsyncEnumerable> ProcessAsync(IAsyncEnumerable> chunks, CancellationToken cancellationToken = default) + => Batching.ProcessAsync(chunks, _options, MetadataKey, _systemPrompt, _logger, cancellationToken); + + private static void Validate(ReadOnlySpan predefinedClasses, string fallbackClass) + { + if (predefinedClasses.Length == 0) + { + Throw.ArgumentException(nameof(predefinedClasses), "Predefined classes must be provided."); + } + + HashSet predefinedClassesSet = new(StringComparer.Ordinal) { fallbackClass }; + foreach (string predefinedClass in predefinedClasses) + { + if (!predefinedClassesSet.Add(predefinedClass)) + { + if (predefinedClass.Equals(fallbackClass, StringComparison.Ordinal)) + { + Throw.ArgumentException(nameof(predefinedClasses), $"Fallback class '{fallbackClass}' must not be one of the predefined classes."); + } + + Throw.ArgumentException(nameof(predefinedClasses), $"Duplicate class found: '{predefinedClass}'."); + } + } + } + + private static ChatMessage CreateSystemPrompt(ReadOnlySpan predefinedClasses, string fallbackClass) + { + StringBuilder sb = new("You are a classification expert. For each of the following texts, assign a single, most relevant class. Use only the following predefined classes: "); + +#if NET9_0_OR_GREATER + sb.AppendJoin(", ", predefinedClasses!); +#else +#pragma warning disable IDE0058 // Expression value is never used + for (int i = 0; i < predefinedClasses.Length; i++) + { + sb.Append(predefinedClasses[i]); + if (i < predefinedClasses.Length - 1) + { + sb.Append(", "); + } + } +#endif + sb.Append(" and return ").Append(fallbackClass).Append(" when unable to classify."); +#pragma warning restore IDE0058 // Expression value is never used + + return new(ChatRole.System, sb.ToString()); + } +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/EnricherOptions.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/EnricherOptions.cs new file mode 100644 index 00000000000..182e07d9c1f --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/EnricherOptions.cs @@ -0,0 +1,54 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion; + +/// +/// Represents options for enrichers that use an AI chat client. +/// +public class EnricherOptions +{ + /// + /// Initializes a new instance of the class. + /// + /// The AI chat client to be used. + public EnricherOptions(IChatClient chatClient) + { + ChatClient = Throw.IfNull(chatClient); + } + + /// + /// Gets the AI chat client to be used. + /// + public IChatClient ChatClient { get; } + + /// + /// Gets or sets the options for the . + /// + public ChatOptions? ChatOptions { get; set; } + + /// + /// Gets or sets the logger factory to be used for logging. + /// + /// + /// Enricher failures should not fail the whole ingestion pipeline, as they are best-effort enhancements. + /// This logger factory can be used to create loggers to log such failures. + /// + public ILoggerFactory? LoggerFactory { get; set; } + + /// + /// Gets or sets the batch size for processing chunks. Default is 20. + /// + public int BatchSize { get; set => field = Throw.IfLessThanOrEqual(value, 0); } = 20; + + internal EnricherOptions Clone() => new(ChatClient) + { + ChatOptions = ChatOptions?.Clone(), + LoggerFactory = LoggerFactory, + BatchSize = BatchSize + }; +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/ImageAlternativeTextEnricher.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/ImageAlternativeTextEnricher.cs new file mode 100644 index 00000000000..b133e0fa31a --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/ImageAlternativeTextEnricher.cs @@ -0,0 +1,122 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion; + +/// +/// Enriches elements with alternative text using an AI service, +/// so the generated embeddings can include the image content information. +/// +public sealed class ImageAlternativeTextEnricher : IngestionDocumentProcessor +{ + private readonly EnricherOptions _options; + private readonly ChatMessage _systemPrompt; + private readonly ILogger? _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The options for generating alternative text. + public ImageAlternativeTextEnricher(EnricherOptions options) + { + _options = Throw.IfNull(options).Clone(); + _systemPrompt = new(ChatRole.System, "For each of the following images, write a detailed alternative text with fewer than 50 words."); + _logger = _options.LoggerFactory?.CreateLogger(); + } + + /// + public override async Task ProcessAsync(IngestionDocument document, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(document); + + List? batch = null; + + foreach (var element in document.EnumerateContent()) + { + if (element is IngestionDocumentImage image) + { + if (ShouldProcess(image)) + { + batch ??= new(_options.BatchSize); + batch.Add(image); + + if (batch.Count == _options.BatchSize) + { + await ProcessAsync(batch, cancellationToken).ConfigureAwait(false); + batch.Clear(); + } + } + } + else if (element is IngestionDocumentTable table) + { + foreach (var cell in table.Cells) + { + if (cell is IngestionDocumentImage cellImage && ShouldProcess(cellImage)) + { + batch ??= new(_options.BatchSize); + batch.Add(cellImage); + + if (batch.Count == _options.BatchSize) + { + await ProcessAsync(batch, cancellationToken).ConfigureAwait(false); + batch.Clear(); + } + } + } + } + } + + if (batch?.Count > 0) + { + await ProcessAsync(batch, cancellationToken).ConfigureAwait(false); + } + + return document; + } + + private static bool ShouldProcess(IngestionDocumentImage img) => + img.Content.HasValue && !string.IsNullOrEmpty(img.MediaType) && string.IsNullOrEmpty(img.AlternativeText); + + private async Task ProcessAsync(List batch, CancellationToken cancellationToken) + { + List contents = new(batch.Count); + foreach (var image in batch) + { + contents.Add(new DataContent(image.Content!.Value, image.MediaType!)); + } + + try + { + ChatResponse response = await _options.ChatClient.GetResponseAsync( + [_systemPrompt, new(ChatRole.User, contents)], + _options.ChatOptions, cancellationToken: cancellationToken).ConfigureAwait(false); + + if (response.Result.Length == contents.Count) + { + for (int i = 0; i < response.Result.Length; i++) + { + batch[i].AlternativeText = response.Result[i]; + } + } + else + { + _logger?.UnexpectedResultsCount(response.Result.Length, contents.Count); + } + } +#pragma warning disable CA1031 // Do not catch general exception types + catch (Exception ex) +#pragma warning restore CA1031 // Do not catch general exception types + { + // Enricher failures should not fail the whole ingestion pipeline, as they are best-effort enhancements. + _logger?.UnexpectedEnricherFailure(ex); + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/KeywordEnricher.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/KeywordEnricher.cs new file mode 100644 index 00000000000..c12c805544d --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/KeywordEnricher.cs @@ -0,0 +1,109 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion; + +/// +/// Enriches chunks with keyword extraction using an AI chat model. +/// +/// +/// It adds "keywords" metadata to each chunk. It's an array of strings representing the extracted keywords. +/// +public sealed class KeywordEnricher : IngestionChunkProcessor +{ + private const int DefaultMaxKeywords = 5; + private readonly EnricherOptions _options; + private readonly ChatMessage _systemPrompt; + private readonly ILogger? _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The options for generating keywords. + /// The set of predefined keywords for extraction. + /// The maximum number of keywords to extract. When not provided, it defaults to 5. + /// The confidence threshold for keyword inclusion. When not provided, it defaults to 0.7. + /// + /// If no predefined keywords are provided, the model will extract keywords based on the content alone. + /// Such results may vary more significantly between different AI models. + /// + public KeywordEnricher(EnricherOptions options, ReadOnlySpan predefinedKeywords, + int? maxKeywords = null, double? confidenceThreshold = null) + { + _options = Throw.IfNull(options).Clone(); + Validate(predefinedKeywords); + + double threshold = confidenceThreshold.HasValue + ? Throw.IfOutOfRange(confidenceThreshold.Value, 0.0, 1.0, nameof(confidenceThreshold)) + : 0.7; + int keywordsCount = maxKeywords.HasValue + ? Throw.IfLessThanOrEqual(maxKeywords.Value, 0, nameof(maxKeywords)) + : DefaultMaxKeywords; + _systemPrompt = CreateSystemPrompt(keywordsCount, predefinedKeywords, threshold); + _logger = _options.LoggerFactory?.CreateLogger(); + } + + /// + /// Gets the metadata key used to store the keywords. + /// + public static string MetadataKey => "keywords"; + + /// + public override IAsyncEnumerable> ProcessAsync(IAsyncEnumerable> chunks, CancellationToken cancellationToken = default) + => Batching.ProcessAsync(chunks, _options, MetadataKey, _systemPrompt, _logger, cancellationToken); + + private static void Validate(ReadOnlySpan predefinedKeywords) + { + if (predefinedKeywords.Length == 0) + { + return; + } + + HashSet result = new(StringComparer.Ordinal); + foreach (string keyword in predefinedKeywords) + { + if (!result.Add(keyword)) + { + Throw.ArgumentException(nameof(predefinedKeywords), $"Duplicate keyword found: '{keyword}'"); + } + } + } + + private static ChatMessage CreateSystemPrompt(int maxKeywords, ReadOnlySpan predefinedKeywords, double confidenceThreshold) + { + StringBuilder sb = new($"You are a keyword extraction expert. For each of the following texts, extract up to {maxKeywords} most relevant keywords. "); + + if (predefinedKeywords.Length > 0) + { +#pragma warning disable IDE0058 // Expression value is never used + sb.Append("Focus on extracting keywords from the following predefined list: "); +#if NET9_0_OR_GREATER + sb.AppendJoin(", ", predefinedKeywords!); +#else + for (int i = 0; i < predefinedKeywords.Length; i++) + { + sb.Append(predefinedKeywords[i]); + if (i < predefinedKeywords.Length - 1) + { + sb.Append(", "); + } + } +#endif + + sb.Append(". "); + } + + sb.Append("Exclude keywords with confidence score below ").Append(confidenceThreshold).Append('.'); +#pragma warning restore IDE0058 // Expression value is never used + + return new(ChatRole.System, sb.ToString()); + } +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/SentimentEnricher.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/SentimentEnricher.cs new file mode 100644 index 00000000000..985451970a9 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/SentimentEnricher.cs @@ -0,0 +1,52 @@ +// Licensed 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 Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion; + +/// +/// Enriches chunks with sentiment analysis using an AI chat model. +/// +/// +/// It adds "sentiment" metadata to each chunk. It can be Positive, Negative, Neutral or Unknown when confidence score is below the threshold. +/// +public sealed class SentimentEnricher : IngestionChunkProcessor +{ + private readonly EnricherOptions _options; + private readonly ChatMessage _systemPrompt; + private readonly ILogger? _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The options for sentiment analysis. + /// The confidence threshold for sentiment determination. When not provided, it defaults to 0.7. + public SentimentEnricher(EnricherOptions options, double? confidenceThreshold = null) + { + _options = Throw.IfNull(options).Clone(); + + double threshold = confidenceThreshold.HasValue ? Throw.IfOutOfRange(confidenceThreshold.Value, 0.0, 1.0, nameof(confidenceThreshold)) : 0.7; + + string prompt = $""" + You are a sentiment analysis expert. For each of the following texts, analyze the sentiment and return Positive/Negative/Neutral or + Unknown when confidence score is below {threshold}. + """; + _systemPrompt = new(ChatRole.System, prompt); + _logger = _options.LoggerFactory?.CreateLogger(); + } + + /// + /// Gets the metadata key used to store the sentiment. + /// + public static string MetadataKey => "sentiment"; + + /// + public override IAsyncEnumerable> ProcessAsync(IAsyncEnumerable> chunks, CancellationToken cancellationToken = default) + => Batching.ProcessAsync(chunks, _options, MetadataKey, _systemPrompt, _logger, cancellationToken); +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/SummaryEnricher.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/SummaryEnricher.cs new file mode 100644 index 00000000000..7e2da4d12f5 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/Processors/SummaryEnricher.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 System.Collections.Generic; +using System.Threading; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion; + +/// +/// Enriches chunks with summary text using an AI chat model. +/// +/// +/// It adds "summary" text metadata to each chunk. +/// +public sealed class SummaryEnricher : IngestionChunkProcessor +{ + private readonly EnricherOptions _options; + private readonly ChatMessage _systemPrompt; + private readonly ILogger? _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The options for summary generation. + /// The maximum number of words for the summary. When not provided, it defaults to 100. + public SummaryEnricher(EnricherOptions options, int? maxWordCount = null) + { + _options = Throw.IfNull(options).Clone(); + + int wordCount = maxWordCount.HasValue ? Throw.IfLessThanOrEqual(maxWordCount.Value, 0, nameof(maxWordCount)) : 100; + _systemPrompt = new(ChatRole.System, $"For each of the following texts, write a summary text with no more than {wordCount} words."); + _logger = _options.LoggerFactory?.CreateLogger(); + } + + /// + /// Gets the metadata key used to store the summary. + /// + public static string MetadataKey => "summary"; + + /// + public override IAsyncEnumerable> ProcessAsync(IAsyncEnumerable> chunks, CancellationToken cancellationToken = default) + => Batching.ProcessAsync(chunks, _options, MetadataKey, _systemPrompt, _logger, cancellationToken); +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/README.md b/src/Libraries/Microsoft.Extensions.DataIngestion/README.md new file mode 100644 index 00000000000..9886465cff6 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/README.md @@ -0,0 +1,34 @@ +# Microsoft.Extensions.DataIngestion + +.NET developers need to efficiently process, chunk, and retrieve information from diverse document formats while preserving semantic meaning and structural context. The `Microsoft.Extensions.DataIngestion` libraries provide a unified approach for representing document ingestion components. + +## The packages + +The [Microsoft.Extensions.DataIngestion.Abstractions](https://www.nuget.org/packages/Microsoft.Extensions.DataIngestion.Abstractions) package provides the core exchange types, including [`IngestionDocument`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.dataingestion.ingestiondocument), [`IngestionChunker`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.dataingestion.ingestionchunker-1), [`IngestionChunkProcessor`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.dataingestion.ingestionchunkprocessor-1), and [`IngestionChunkWriter`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.dataingestion.ingestionchunkwriter-1). Any .NET library that provides document processing capabilities can implement these abstractions to enable seamless integration with consuming code. + +The [Microsoft.Extensions.DataIngestion](https://www.nuget.org/packages/Microsoft.Extensions.DataIngestion) package has an implicit dependency on the `Microsoft.Extensions.DataIngestion.Abstractions` package. This package enables you to easily integrate components such as enrichment processors, vector storage writers, and telemetry into your applications using familiar dependency injection and pipeline patterns. For example, it provides the [`SentimentEnricher`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.dataingestion.sentimentenricher), [`KeywordEnricher`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.dataingestion.keywordenricher), and [`SummaryEnricher`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.dataingestion.summaryenricher) processors that can be chained together in ingestion pipelines. + +## Which package to reference + +Libraries that provide implementations of the abstractions typically reference only `Microsoft.Extensions.DataIngestion.Abstractions`. + +To also have access to higher-level utilities for working with document ingestion components, reference the `Microsoft.Extensions.DataIngestion` package instead (which itself references `Microsoft.Extensions.DataIngestion.Abstractions`). Most consuming applications and services should reference the `Microsoft.Extensions.DataIngestion` package along with one or more libraries that provide concrete implementations of the abstractions, such as `Microsoft.Extensions.DataIngestion.MarkItDown` or `Microsoft.Extensions.DataIngestion.Markdig`. + +## Install the package + +From the command-line: + +```console +dotnet add package Microsoft.Extensions.DataIngestion --prerelease +``` +Or directly in the C# project file: + +```xml + + + +``` + +## Feedback & Contributing + +We welcome feedback and contributions in [our GitHub repo](https://github.com/dotnet/extensions). diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/Utils/Batching.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/Utils/Batching.cs new file mode 100644 index 00000000000..b210019401b --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/Utils/Batching.cs @@ -0,0 +1,108 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +#if NET10_0_OR_GREATER +using System.Linq; +#endif +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion; + +internal static class Batching +{ + internal static async IAsyncEnumerable> ProcessAsync(IAsyncEnumerable> chunks, + EnricherOptions options, + string metadataKey, + ChatMessage systemPrompt, + ILogger? logger, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + where TMetadata : notnull + { + _ = Throw.IfNull(chunks); + + await foreach (var batch in chunks.Chunk(options.BatchSize).WithCancellation(cancellationToken)) + { + List contents = new(batch.Length); + foreach (var chunk in batch) + { + contents.Add(new TextContent(chunk.Content)); + } + + try + { + ChatResponse response = await options.ChatClient.GetResponseAsync( + [ + systemPrompt, + new(ChatRole.User, contents) + ], options.ChatOptions, cancellationToken: cancellationToken).ConfigureAwait(false); + + if (response.Result.Length == contents.Count) + { + for (int i = 0; i < response.Result.Length; i++) + { + batch[i].Metadata[metadataKey] = response.Result[i]; + } + } + else + { + logger?.UnexpectedResultsCount(response.Result.Length, contents.Count); + } + } +#pragma warning disable CA1031 // Do not catch general exception types + catch (Exception ex) +#pragma warning restore CA1031 // Do not catch general exception types + { + // Enricher failures should not fail the whole ingestion pipeline, as they are best-effort enhancements. + logger?.UnexpectedEnricherFailure(ex); + } + + foreach (var chunk in batch) + { + yield return chunk; + } + } + } + +#if !NET10_0_OR_GREATER +#pragma warning disable VSTHRD200 // Use "Async" suffix for async methods + private static IAsyncEnumerable Chunk(this IAsyncEnumerable source, int count) +#pragma warning restore VSTHRD200 // Use "Async" suffix for async methods + { + _ = Throw.IfNull(source); + _ = Throw.IfLessThanOrEqual(count, 0); + + return CoreAsync(source, count); + + static async IAsyncEnumerable CoreAsync(IAsyncEnumerable source, int count, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var buffer = new TSource[count]; + int index = 0; + + await foreach (var item in source.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + buffer[index++] = item; + + if (index == count) + { + index = 0; + yield return buffer; + } + } + + if (index > 0) + { + Array.Resize(ref buffer, index); + yield return buffer; + } + } + } +#endif +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/Writers/VectorStoreWriter.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/Writers/VectorStoreWriter.cs new file mode 100644 index 00000000000..f231ac6535b --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/Writers/VectorStoreWriter.cs @@ -0,0 +1,193 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.VectorData; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion; + +/// +/// Writes chunks to the using the default schema. +/// +/// The type of the chunk content. +public sealed class VectorStoreWriter : IngestionChunkWriter +{ + // The names are lowercase with no special characters to ensure compatibility with various vector stores. + private const string KeyName = "key"; + private const string EmbeddingName = "embedding"; + private const string ContentName = "content"; + private const string ContextName = "context"; + private const string DocumentIdName = "documentid"; + + private readonly VectorStore _vectorStore; + private readonly int _dimensionCount; + private readonly VectorStoreWriterOptions _options; + + private VectorStoreCollection>? _vectorStoreCollection; + + /// + /// Initializes a new instance of the class. + /// + /// The to use to store the instances. + /// The number of dimensions that the vector has. This value is required when creating collections. + /// The options for the vector store writer. + /// When is null. + /// When is less or equal zero. + public VectorStoreWriter(VectorStore vectorStore, int dimensionCount, VectorStoreWriterOptions? options = default) + { + _vectorStore = Throw.IfNull(vectorStore); + _dimensionCount = Throw.IfLessThanOrEqual(dimensionCount, 0); + _options = options ?? new VectorStoreWriterOptions(); + } + + /// + /// Gets the underlying used to store the chunks. + /// + /// + /// The collection is initialized when is called for the first time. + /// + /// The collection has not been initialized yet. + /// Call first. + public VectorStoreCollection> VectorStoreCollection + => _vectorStoreCollection ?? throw new InvalidOperationException("The collection has not been initialized yet. Call WriteAsync first."); + + /// + public override async Task WriteAsync(IAsyncEnumerable> chunks, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(chunks); + + IReadOnlyList? preExistingKeys = null; + await foreach (IngestionChunk chunk in chunks.WithCancellation(cancellationToken)) + { + if (_vectorStoreCollection is null) + { + _vectorStoreCollection = _vectorStore.GetDynamicCollection(_options.CollectionName, GetVectorStoreRecordDefinition(chunk)); + + await _vectorStoreCollection.EnsureCollectionExistsAsync(cancellationToken).ConfigureAwait(false); + } + + // We obtain the IDs of the pre-existing chunks for given document, + // and delete them after we finish inserting the new chunks, + // to avoid a situation where we delete the chunks and then fail to insert the new ones. + preExistingKeys ??= await GetPreExistingChunksIdsAsync(chunk.Document, cancellationToken).ConfigureAwait(false); + + var key = Guid.NewGuid(); + Dictionary record = new() + { + [KeyName] = key, + [ContentName] = chunk.Content, + [EmbeddingName] = chunk.Content, + [ContextName] = chunk.Context, + [DocumentIdName] = chunk.Document.Identifier, + }; + + if (chunk.HasMetadata) + { + foreach (var metadata in chunk.Metadata) + { + record[metadata.Key] = metadata.Value; + } + } + + await _vectorStoreCollection.UpsertAsync(record, cancellationToken).ConfigureAwait(false); + } + + if (preExistingKeys?.Count > 0) + { + await _vectorStoreCollection!.DeleteAsync(preExistingKeys, cancellationToken).ConfigureAwait(false); + } + } + + /// + protected override void Dispose(bool disposing) + { + try + { + _vectorStoreCollection?.Dispose(); + } + finally + { + _vectorStore.Dispose(); + base.Dispose(disposing); + } + } + + private VectorStoreCollectionDefinition GetVectorStoreRecordDefinition(IngestionChunk representativeChunk) + { + VectorStoreCollectionDefinition definition = new() + { + Properties = + { + new VectorStoreKeyProperty(KeyName, typeof(Guid)), + + // By using T as the type here we allow the vector store + // to handle the conversion from T to the actual vector type it supports. + new VectorStoreVectorProperty(EmbeddingName, typeof(T), _dimensionCount) + { + DistanceFunction = _options.DistanceFunction, + IndexKind = _options.IndexKind + }, + new VectorStoreDataProperty(ContentName, typeof(T)), + new VectorStoreDataProperty(ContextName, typeof(string)), + new VectorStoreDataProperty(DocumentIdName, typeof(string)) + { + IsIndexed = true + } + } + }; + + if (representativeChunk.HasMetadata) + { + foreach (var metadata in representativeChunk.Metadata) + { + Type propertyType = metadata.Value.GetType(); + definition.Properties.Add(new VectorStoreDataProperty(metadata.Key, propertyType) + { + // We use lowercase storage names to ensure compatibility with various vector stores. +#pragma warning disable CA1308 // Normalize strings to uppercase + StorageName = metadata.Key.ToLowerInvariant() +#pragma warning restore CA1308 // Normalize strings to uppercase + + // We could consider indexing for certain keys like classification etc. but for now we leave it as non-indexed. + // The reason is that not every DB supports it, moreover we would need to expose the ability to configure it. + }); + } + } + + return definition; + } + + private async Task> GetPreExistingChunksIdsAsync(IngestionDocument document, CancellationToken cancellationToken) + { + if (!_options.IncrementalIngestion) + { + return []; + } + + // Each Vector Store has a different max top count limit, so we use low value and loop. + const int MaxTopCount = 1_000; + + List keys = []; + int insertedCount; + do + { + insertedCount = 0; + + await foreach (var record in _vectorStoreCollection!.GetAsync( + filter: record => (string)record[DocumentIdName]! == document.Identifier, + top: MaxTopCount, + cancellationToken: cancellationToken).ConfigureAwait(false)) + { + keys.Add(record[KeyName]!); + insertedCount++; + } + } + while (insertedCount == MaxTopCount); + + return keys; + } +} diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion/Writers/VectorStoreWriterOptions.cs b/src/Libraries/Microsoft.Extensions.DataIngestion/Writers/VectorStoreWriterOptions.cs new file mode 100644 index 00000000000..cbc2036061a --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.DataIngestion/Writers/VectorStoreWriterOptions.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 Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DataIngestion; + +/// +/// Represents options for the . +/// +public sealed class VectorStoreWriterOptions +{ + /// + /// Gets or sets the name of the collection. When not provided, "chunks" will be used. + /// + public string CollectionName + { + get => field ?? "chunks"; + set => field = Throw.IfNullOrEmpty(value); + } + + /// + /// Gets or sets the distance function to use when creating the collection. + /// + /// + /// When not provided, the default specific to given database will be used. Check for available values. + /// + public string? DistanceFunction { get; set; } + + /// + /// Gets or sets the index kind to use when creating the collection. + /// + public string? IndexKind { get; set; } + + /// + /// Gets or sets a value indicating whether to perform incremental ingestion. + /// + /// + /// When enabled, the writer will delete the chunks for the given document after inserting the new ones. + /// Effectively the ingestion will "replace" the existing chunks for the document with the new ones. + /// + public bool IncrementalIngestion { get; set; } = true; +} diff --git a/src/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation/AutoActivationExtensions.cs b/src/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation/AutoActivationExtensions.cs index 5cb40f05a2e..c2c0581e6c3 100644 --- a/src/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation/AutoActivationExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation/AutoActivationExtensions.cs @@ -5,7 +5,6 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.DependencyInjection; diff --git a/src/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation/Microsoft.Extensions.DependencyInjection.AutoActivation.csproj b/src/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation/Microsoft.Extensions.DependencyInjection.AutoActivation.csproj index 7d02c3f1e90..5dd62090e1e 100644 --- a/src/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation/Microsoft.Extensions.DependencyInjection.AutoActivation.csproj +++ b/src/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation/Microsoft.Extensions.DependencyInjection.AutoActivation.csproj @@ -1,6 +1,7 @@ Microsoft.Extensions.DependencyInjection + $(NetCoreTargetFrameworks);netstandard2.0;net462 Extensions to auto-activate registered singletons in the dependency injection system. Fundamentals diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization/ExceptionSummarizationServiceCollectionExtensions.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization/ExceptionSummarizationServiceCollectionExtensions.cs index 28d65ba84f3..2dd3438a960 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization/ExceptionSummarizationServiceCollectionExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization/ExceptionSummarizationServiceCollectionExtensions.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Diagnostics.ExceptionSummarization; using Microsoft.Shared.Diagnostics; diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization/IExceptionSummarizationBuilder.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization/IExceptionSummarizationBuilder.cs index a7a08eb4d3e..fe7d146c579 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization/IExceptionSummarizationBuilder.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization/IExceptionSummarizationBuilder.cs @@ -14,7 +14,7 @@ public interface IExceptionSummarizationBuilder /// /// Gets the service collection into which the summary provider instances are registered. /// - public IServiceCollection Services { get; } + IServiceCollection Services { get; } /// /// Adds a summary provider to the builder. diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization/IExceptionSummarizer.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization/IExceptionSummarizer.cs index c9c4c4ef48a..3087bded812 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization/IExceptionSummarizer.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization/IExceptionSummarizer.cs @@ -15,5 +15,5 @@ public interface IExceptionSummarizer /// /// The exception to summarize. /// The summary of the given . - public ExceptionSummary Summarize(Exception exception); + ExceptionSummary Summarize(Exception exception); } diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization/IExceptionSummaryProvider.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization/IExceptionSummaryProvider.cs index 24cfd0d079e..000e34ac0c7 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization/IExceptionSummaryProvider.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization/IExceptionSummaryProvider.cs @@ -26,15 +26,15 @@ public interface IExceptionSummaryProvider /// This method should only get invoked with an exception which is type compatible with a type /// described by . /// - public int Describe(Exception exception, out string? additionalDetails); + int Describe(Exception exception, out string? additionalDetails); /// /// Gets the set of supported exception types that can be handled by this provider. /// - public IEnumerable SupportedExceptionTypes { get; } + IEnumerable SupportedExceptionTypes { get; } /// /// Gets the set of description strings exposed by this provider. /// - public IReadOnlyList Descriptions { get; } + IReadOnlyList Descriptions { get; } } diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization/Microsoft.Extensions.Diagnostics.ExceptionSummarization.csproj b/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization/Microsoft.Extensions.Diagnostics.ExceptionSummarization.csproj index 0073e039f8f..4b4993a7b99 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization/Microsoft.Extensions.Diagnostics.ExceptionSummarization.csproj +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ExceptionSummarization/Microsoft.Extensions.Diagnostics.ExceptionSummarization.csproj @@ -3,6 +3,7 @@ Microsoft.Extensions.Diagnostics.ExceptionSummarization Lets you retrieve exception summary information. Telemetry + $(NetCoreTargetFrameworks);netstandard2.0;net462 diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.Common/IManualHealthCheck.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.Common/IManualHealthCheck.cs index 6772a33f0d3..e8164908150 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.Common/IManualHealthCheck.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.Common/IManualHealthCheck.cs @@ -13,7 +13,7 @@ public interface IManualHealthCheck : IDisposable /// /// Gets or sets the health status. /// - public HealthCheckResult Result { get; set; } + HealthCheckResult Result { get; set; } } /// diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.Common/ManualHealthCheck.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.Common/ManualHealthCheck.cs index 0302b1a896f..d9faa46f338 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.Common/ManualHealthCheck.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.Common/ManualHealthCheck.cs @@ -10,22 +10,20 @@ internal sealed class ManualHealthCheck : IManualHealthCheck { private static readonly object _lock = new(); - private HealthCheckResult _result; - public HealthCheckResult Result { get { lock (_lock) { - return _result; + return field; } } set { lock (_lock) { - _result = value; + field = value; } } } diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization/ResourceUsageThresholds.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization/ResourceUsageThresholds.cs index a3fc5f3ed76..bf97ab0ab69 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization/ResourceUsageThresholds.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization/ResourceUsageThresholds.cs @@ -2,14 +2,12 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.ComponentModel.DataAnnotations; -using System.Diagnostics.CodeAnalysis; namespace Microsoft.Extensions.Diagnostics.HealthChecks; /// /// Threshold settings for . /// -[SuppressMessage("Major Code Smell", "S109:Magic numbers should not be used", Justification = "In place numbers make the ranges cleaner")] public class ResourceUsageThresholds { /// diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.Probes/TcpEndpointProbesOptions.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.Probes/TcpEndpointProbesOptions.cs index af777ea08ab..15ccdf52f59 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.Probes/TcpEndpointProbesOptions.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.Probes/TcpEndpointProbesOptions.cs @@ -3,7 +3,6 @@ using System; using System.ComponentModel.DataAnnotations; -using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Shared.Data.Validation; @@ -12,7 +11,6 @@ namespace Microsoft.Extensions.Diagnostics.Probes; /// /// Options to control TCP-based health check probes. /// -[SuppressMessage("Major Code Smell", "S109:Magic numbers should not be used", Justification = "In place numbers make the ranges cleaner")] public class TcpEndpointProbesOptions { private const int DefaultMaxPendingConnections = 10; diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/CompatibilitySuppressions.xml b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/CompatibilitySuppressions.xml deleted file mode 100644 index 6526176c304..00000000000 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/CompatibilitySuppressions.xml +++ /dev/null @@ -1,46 +0,0 @@ - - - - - CP0002 - M:Microsoft.Extensions.Diagnostics.ResourceMonitoring.ResourceMonitoringOptions.get_EnableDiskIoMetrics - lib/net462/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll - lib/net462/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll - true - - - CP0002 - M:Microsoft.Extensions.Diagnostics.ResourceMonitoring.ResourceMonitoringOptions.set_EnableDiskIoMetrics(System.Boolean) - lib/net462/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll - lib/net462/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll - true - - - CP0002 - M:Microsoft.Extensions.Diagnostics.ResourceMonitoring.ResourceMonitoringOptions.get_EnableDiskIoMetrics - lib/net8.0/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll - lib/net8.0/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll - true - - - CP0002 - M:Microsoft.Extensions.Diagnostics.ResourceMonitoring.ResourceMonitoringOptions.set_EnableDiskIoMetrics(System.Boolean) - lib/net8.0/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll - lib/net8.0/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll - true - - - CP0002 - M:Microsoft.Extensions.Diagnostics.ResourceMonitoring.ResourceMonitoringOptions.get_EnableDiskIoMetrics - lib/net9.0/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll - lib/net9.0/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll - true - - - CP0002 - M:Microsoft.Extensions.Diagnostics.ResourceMonitoring.ResourceMonitoringOptions.set_EnableDiskIoMetrics(System.Boolean) - lib/net9.0/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll - lib/net9.0/Microsoft.Extensions.Diagnostics.ResourceMonitoring.dll - true - - \ No newline at end of file diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Disk/DiskStatsReader.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Disk/DiskStatsReader.cs index 660d5c4e7a1..a95506d40fe 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Disk/DiskStatsReader.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Disk/DiskStatsReader.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.Linq; @@ -48,9 +47,7 @@ public DiskStats[] ReadAll(string[] skipDevicePrefixes) diskStatsList.Add(stat); } } -#pragma warning disable CA1031 catch (Exception) -#pragma warning restore CA1031 { // ignore parsing errors } @@ -64,7 +61,6 @@ public DiskStats[] ReadAll(string[] skipDevicePrefixes) /// /// one line in "/proc/diskstats". /// parsed DiskStats object. - [SuppressMessage("Major Code Smell", "S109:Magic numbers should not be used", Justification = "These numbers represent fixed field indices in the Linux /proc/diskstats format")] private static DiskStats ParseLine(string line) { // Split by any whitespace and remove empty entries diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Disk/LinuxSystemDiskMetrics.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Disk/LinuxSystemDiskMetrics.cs index f967a1b7650..4a7b80225ba 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Disk/LinuxSystemDiskMetrics.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Disk/LinuxSystemDiskMetrics.cs @@ -179,9 +179,7 @@ ex is DirectoryNotFoundException || _lastDiskStatsFailure = _timeProvider.GetUtcNow(); _diskStatsUnavailable = true; } -#pragma warning disable CA1031 catch (Exception ex) -#pragma warning restore CA1031 { _logger.HandleDiskStatsException(ex.Message); } diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationParserCgroupV1.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationParserCgroupV1.cs index af90e447df7..50886f1ceef 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationParserCgroupV1.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationParserCgroupV1.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Diagnostics.CodeAnalysis; using System.IO; using Microsoft.Extensions.ObjectPool; using Microsoft.Shared.Diagnostics; @@ -151,7 +150,7 @@ public long GetHostCpuUsageInNanoseconds() $"'{_procStat}' should contain whitespace separated values according to POSIX. We've failed trying to get {i}th value. File content: '{new string(stat)}'."); } - stat = stat.Slice(next, stat.Length - next); + stat = stat.Slice(next); } return (long)(total / (double)_userHz * NanosecondsInSecond); @@ -255,8 +254,6 @@ public ulong GetMemoryUsageInBytes() return (ulong)memoryUsage; } - [SuppressMessage("Major Code Smell", "S109:Magic numbers should not be used", - Justification = "Shifting bits left by number n is multiplying the value by 2 to the power of n.")] public ulong GetHostAvailableMemory() { // The value we are interested in starts with this. We just want to make sure it is true. @@ -374,8 +371,6 @@ static void ThrowException(ReadOnlySpan content) => /// /// The input must contain only number. If there is something more than whitespace before the number, it will return failure (-1). /// - [SuppressMessage("Major Code Smell", "S109:Magic numbers should not be used", - Justification = "We are adding another digit, so we need to multiply by ten.")] private static int GetNextNumber(ReadOnlySpan buffer, out long number) { int numberStart = 0; diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationParserCgroupV2.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationParserCgroupV2.cs index abb4230d6bc..a50a72f5d9a 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationParserCgroupV2.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationParserCgroupV2.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using Microsoft.Extensions.ObjectPool; @@ -131,7 +130,7 @@ public string GetCgroupPath(string filename) } // Extract the part after the last colon and cache it for future use - ReadOnlySpan trimmedPath = fileContent.Slice(colonIndex + 1); + ReadOnlySpan trimmedPath = fileContent[(colonIndex + 1)..]; _cachedCgroupPath = "/sys/fs/cgroup" + trimmedPath.ToString().TrimEnd('/') + "/"; return $"{_cachedCgroupPath}{filename}"; @@ -195,7 +194,7 @@ public long GetHostCpuUsageInNanoseconds() $"'{_procStat}' should contain whitespace separated values according to POSIX. We've failed trying to get {i}th value. File content: '{new string(stat)}'."); } - stat = stat.Slice(next, stat.Length - next); + stat = stat.Slice(next); } return (long)(total / (double)_userHz * NanosecondsInSecond); @@ -400,8 +399,6 @@ public ulong GetMemoryUsageInBytes() return (ulong)memoryUsageTotal; } - [SuppressMessage("Major Code Smell", "S109:Magic numbers should not be used", - Justification = "Shifting bits left by number n is multiplying the value by 2 to the power of n.")] public ulong GetHostAvailableMemory() { // The value we are interested in starts with this. We just want to make sure it is true. @@ -564,8 +561,6 @@ private static (long cpuUsageNanoseconds, long nrPeriods) ParseCpuUsageFromFile( /// /// The input must contain only number. If there is something more than whitespace before the number, it will return failure (-1). /// - [SuppressMessage("Major Code Smell", "S109:Magic numbers should not be used", - Justification = "We are adding another digit, so we need to multiply by ten.")] private static int GetNextNumber(ReadOnlySpan buffer, out long number) { int numberStart = 0; @@ -788,9 +783,7 @@ private static bool TryParseCpuWeightFromFile(IFileSystem fileSystem, FileInfo c // where y is the CPU pod weight (e.g. cpuPodWeight) and x is the CPU share of cgroup v1 (e.g. cpuUnits). // https://github.com/kubernetes/enhancements/tree/master/keps/sig-node/2254-cgroup-v2#phase-1-convert-from-cgroups-v1-settings-to-v2 // We invert the formula to calculate CPU share from CPU pod weight: -#pragma warning disable S109 // Magic numbers should not be used - using the formula, forgive. cpuUnits = ((cpuPodWeight - 1) * 262142 / 9999) + 2; -#pragma warning restore S109 // Magic numbers should not be used return true; } diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationProvider.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationProvider.cs index 4090bbb5619..611b96f4f1d 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationProvider.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationProvider.cs @@ -2,7 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Collections.Generic; using System.Diagnostics.Metrics; +using System.Threading; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; @@ -14,39 +16,30 @@ internal sealed class LinuxUtilizationProvider : ISnapshotProvider { private const double One = 1.0; private const long Hundred = 100L; - private const double CpuLimitThreshold110Percent = 1.1; + private const double NanosecondsInSecond = 1_000_000_000; - // Meters to track CPU utilization threshold exceedances - private readonly Counter? _cpuUtilizationLimit100PercentExceededCounter; - private readonly Counter? _cpuUtilizationLimit110PercentExceededCounter; - - private readonly bool _useDeltaNrPeriods; private readonly object _cpuLocker = new(); private readonly object _memoryLocker = new(); private readonly ILogger _logger; private readonly ILinuxUtilizationParser _parser; private readonly ulong _memoryLimit; + private readonly long _cpuPeriodsInterval; private readonly TimeSpan _cpuRefreshInterval; private readonly TimeSpan _memoryRefreshInterval; private readonly TimeProvider _timeProvider; - private readonly double _scaleRelativeToCpuLimit; - private readonly double _scaleRelativeToCpuRequest; private readonly double _scaleRelativeToCpuRequestForTrackerApi; + private readonly TimeSpan _retryInterval = TimeSpan.FromMinutes(5); + private DateTimeOffset _lastFailure = DateTimeOffset.MinValue; + private int _measurementsUnavailable; + private DateTimeOffset _refreshAfterCpu; private DateTimeOffset _refreshAfterMemory; - - // Track the actual timestamp when we read CPU values - private DateTimeOffset _lastCpuMeasurementTime; - private double _cpuPercentage = double.NaN; private double _lastCpuCoresUsed = double.NaN; - private double _memoryPercentage; + private ulong _memoryUsage; private long _previousCgroupCpuTime; private long _previousHostCpuTime; - private long _cpuUtilizationLimit100PercentExceeded; - private long _cpuUtilizationLimit110PercentExceeded; - private long _cpuPeriodsInterval; private long _previousCgroupCpuPeriodCounter; public SystemResources Resources { get; } @@ -59,7 +52,6 @@ public LinuxUtilizationProvider(IOptions options, ILi DateTimeOffset now = _timeProvider.GetUtcNow(); _cpuRefreshInterval = options.Value.CpuConsumptionRefreshInterval; _memoryRefreshInterval = options.Value.MemoryConsumptionRefreshInterval; - _useDeltaNrPeriods = options.Value.UseDeltaNrPeriodsForCpuCalculation; _refreshAfterCpu = now; _refreshAfterMemory = now; _memoryLimit = _parser.GetAvailableMemoryInBytes(); @@ -69,8 +61,8 @@ public LinuxUtilizationProvider(IOptions options, ILi float hostCpus = _parser.GetHostCpuCount(); float cpuLimit = _parser.GetCgroupLimitedCpus(); float cpuRequest = _parser.GetCgroupRequestCpu(); - _scaleRelativeToCpuLimit = hostCpus / cpuLimit; - _scaleRelativeToCpuRequest = hostCpus / cpuRequest; + float scaleRelativeToCpuLimit = hostCpus / cpuLimit; + float scaleRelativeToCpuRequest = hostCpus / cpuRequest; _scaleRelativeToCpuRequestForTrackerApi = hostCpus; // the division by cpuRequest is performed later on in the ResourceUtilization class #pragma warning disable CA2000 // Dispose objects before losing scope @@ -80,32 +72,63 @@ public LinuxUtilizationProvider(IOptions options, ILi var meter = meterFactory.Create(ResourceUtilizationInstruments.MeterName); #pragma warning restore CA2000 // Dispose objects before losing scope - if (options.Value.CalculateCpuUsageWithoutHostDelta) + if (options.Value.UseLinuxCalculationV2) { cpuLimit = _parser.GetCgroupLimitV2(); - - // Try to get the CPU request from cgroup cpuRequest = _parser.GetCgroupRequestCpuV2(); // Get Cpu periods interval from cgroup _cpuPeriodsInterval = _parser.GetCgroupPeriodsIntervalInMicroSecondsV2(); (_previousCgroupCpuTime, _previousCgroupCpuPeriodCounter) = _parser.GetCgroupCpuUsageInNanosecondsAndCpuPeriodsV2(); - // Initialize the counters - _cpuUtilizationLimit100PercentExceededCounter = meter.CreateCounter("cpu_utilization_limit_100_percent_exceeded"); - _cpuUtilizationLimit110PercentExceededCounter = meter.CreateCounter("cpu_utilization_limit_110_percent_exceeded"); - _ = meter.CreateObservableGauge(name: ResourceUtilizationInstruments.ContainerCpuLimitUtilization, observeValue: () => CpuUtilizationLimit(cpuLimit), unit: "1"); - _ = meter.CreateObservableGauge(name: ResourceUtilizationInstruments.ContainerCpuRequestUtilization, observeValue: () => CpuUtilizationWithoutHostDelta() / cpuRequest, unit: "1"); + _ = meter.CreateObservableGauge( + name: ResourceUtilizationInstruments.ContainerCpuLimitUtilization, + observeValues: () => GetMeasurementWithRetry(() => CpuUtilizationLimit(cpuLimit)), + unit: "1"); + + _ = meter.CreateObservableGauge( + name: ResourceUtilizationInstruments.ContainerCpuRequestUtilization, + observeValues: () => GetMeasurementWithRetry(() => CpuUtilizationRequest(cpuRequest)), + unit: "1"); + + _ = meter.CreateObservableGauge( + name: ResourceUtilizationInstruments.ContainerCpuTime, + observeValues: GetCpuTime, + unit: "1"); } else { - _ = meter.CreateObservableGauge(name: ResourceUtilizationInstruments.ContainerCpuLimitUtilization, observeValue: () => CpuUtilization() * _scaleRelativeToCpuLimit, unit: "1"); - _ = meter.CreateObservableGauge(name: ResourceUtilizationInstruments.ContainerCpuRequestUtilization, observeValue: () => CpuUtilization() * _scaleRelativeToCpuRequest, unit: "1"); - _ = meter.CreateObservableGauge(name: ResourceUtilizationInstruments.ProcessCpuUtilization, observeValue: () => CpuUtilization() * _scaleRelativeToCpuRequest, unit: "1"); + _ = meter.CreateObservableGauge( + name: ResourceUtilizationInstruments.ContainerCpuLimitUtilization, + observeValues: () => GetMeasurementWithRetry(() => CpuUtilization() * scaleRelativeToCpuLimit), + unit: "1"); + + _ = meter.CreateObservableGauge( + name: ResourceUtilizationInstruments.ContainerCpuRequestUtilization, + observeValues: () => GetMeasurementWithRetry(() => CpuUtilization() * scaleRelativeToCpuRequest), + unit: "1"); + + _ = meter.CreateObservableGauge( + name: ResourceUtilizationInstruments.ProcessCpuUtilization, + observeValues: () => GetMeasurementWithRetry(() => CpuUtilization() * scaleRelativeToCpuRequest), + unit: "1"); } - _ = meter.CreateObservableGauge(name: ResourceUtilizationInstruments.ContainerMemoryLimitUtilization, observeValue: MemoryUtilization, unit: "1"); - _ = meter.CreateObservableGauge(name: ResourceUtilizationInstruments.ProcessMemoryUtilization, observeValue: MemoryUtilization, unit: "1"); + _ = meter.CreateObservableGauge( + name: ResourceUtilizationInstruments.ContainerMemoryLimitUtilization, + observeValues: () => GetMeasurementWithRetry(MemoryPercentage), + unit: "1"); + + _ = meter.CreateObservableUpDownCounter( + name: ResourceUtilizationInstruments.ContainerMemoryUsage, + observeValues: () => GetMeasurementWithRetry(() => (long)MemoryUsage()), + unit: "By", + description: "Memory usage of the container."); + + _ = meter.CreateObservableGauge( + name: ResourceUtilizationInstruments.ProcessMemoryUtilization, + observeValues: () => GetMeasurementWithRetry(MemoryPercentage), + unit: "1"); // cpuRequest is a CPU request (aka guaranteed number of CPU units) for pod, for host its 1 core // cpuLimit is a CPU limit (aka max CPU units available) for a pod or for a host. @@ -115,10 +138,9 @@ public LinuxUtilizationProvider(IOptions options, ILi _logger.SystemResourcesInfo(cpuLimit, cpuRequest, _memoryLimit, _memoryLimit); } - public double CpuUtilizationWithoutHostDelta() + public double CpuUtilizationV2() { DateTimeOffset now = _timeProvider.GetUtcNow(); - double actualElapsedNanoseconds = (now - _lastCpuMeasurementTime).TotalNanoseconds; lock (_cpuLocker) { if (now < _refreshAfterCpu) @@ -127,79 +149,34 @@ public double CpuUtilizationWithoutHostDelta() } } - var (cpuUsageTime, cpuPeriodCounter) = _parser.GetCgroupCpuUsageInNanosecondsAndCpuPeriodsV2(); + (long cpuUsageTime, long cpuPeriodCounter) = _parser.GetCgroupCpuUsageInNanosecondsAndCpuPeriodsV2(); lock (_cpuLocker) { - if (now >= _refreshAfterCpu) + if (now < _refreshAfterCpu) { - long deltaCgroup = cpuUsageTime - _previousCgroupCpuTime; - double coresUsed; - - if (_useDeltaNrPeriods) - { - long deltaPeriodCount = cpuPeriodCounter - _previousCgroupCpuPeriodCounter; - long deltaCpuPeriodInNanoseconds = deltaPeriodCount * _cpuPeriodsInterval * 1000; - - if (deltaCgroup > 0 && deltaPeriodCount > 0) - { - coresUsed = deltaCgroup / (double)deltaCpuPeriodInNanoseconds; - - _logger.CpuUsageDataV2(cpuUsageTime, _previousCgroupCpuTime, deltaCpuPeriodInNanoseconds, coresUsed); - - _lastCpuCoresUsed = coresUsed; - _refreshAfterCpu = now.Add(_cpuRefreshInterval); - _previousCgroupCpuTime = cpuUsageTime; - _previousCgroupCpuPeriodCounter = cpuPeriodCounter; - } - } - else - { - if (deltaCgroup > 0) - { - coresUsed = deltaCgroup / actualElapsedNanoseconds; - - _logger.CpuUsageDataV2(cpuUsageTime, _previousCgroupCpuTime, actualElapsedNanoseconds, coresUsed); - - _lastCpuCoresUsed = coresUsed; - _refreshAfterCpu = now.Add(_cpuRefreshInterval); - _previousCgroupCpuTime = cpuUsageTime; - - // Update the timestamp for next calculation - _lastCpuMeasurementTime = now; - } - } + return _lastCpuCoresUsed; } - } - return _lastCpuCoresUsed; - } + long deltaCgroup = cpuUsageTime - _previousCgroupCpuTime; + long deltaPeriodCount = cpuPeriodCounter - _previousCgroupCpuPeriodCounter; - /// - /// Calculates CPU utilization relative to the CPU limit. - /// - /// The CPU limit to use for the calculation. - /// CPU usage as a ratio of the limit. - public double CpuUtilizationLimit(float cpuLimit) - { - double utilization = CpuUtilizationWithoutHostDelta() / cpuLimit; + if (deltaCgroup <= 0 || deltaPeriodCount <= 0) + { + return _lastCpuCoresUsed; + } - // Increment counter if utilization exceeds 1 (100%) - if (utilization > 1.0) - { - _cpuUtilizationLimit100PercentExceededCounter?.Add(1); - _cpuUtilizationLimit100PercentExceeded++; - _logger.CounterMessage100(_cpuUtilizationLimit100PercentExceeded); - } + long deltaCpuPeriodInNanoseconds = deltaPeriodCount * _cpuPeriodsInterval * 1000; + double coresUsed = deltaCgroup / (double)deltaCpuPeriodInNanoseconds; - // Increment counter if utilization exceeds 110% - if (utilization > CpuLimitThreshold110Percent) - { - _cpuUtilizationLimit110PercentExceededCounter?.Add(1); - _cpuUtilizationLimit110PercentExceeded++; - _logger.CounterMessage110(_cpuUtilizationLimit110PercentExceeded); + _logger.CpuUsageDataV2(cpuUsageTime, _previousCgroupCpuTime, deltaCpuPeriodInNanoseconds, coresUsed); + + _lastCpuCoresUsed = coresUsed; + _refreshAfterCpu = now.Add(_cpuRefreshInterval); + _previousCgroupCpuTime = cpuUsageTime; + _previousCgroupCpuPeriodCounter = cpuPeriodCounter; } - return utilization; + return _lastCpuCoresUsed; } public double CpuUtilization() @@ -219,29 +196,33 @@ public double CpuUtilization() lock (_cpuLocker) { - if (now >= _refreshAfterCpu) + if (now < _refreshAfterCpu) { - long deltaHost = hostCpuTime - _previousHostCpuTime; - long deltaCgroup = cgroupCpuTime - _previousCgroupCpuTime; - - if (deltaHost > 0 && deltaCgroup > 0) - { - double percentage = Math.Min(One, (double)deltaCgroup / deltaHost); + return _cpuPercentage; + } - _logger.CpuUsageData(cgroupCpuTime, hostCpuTime, _previousCgroupCpuTime, _previousHostCpuTime, percentage); + long deltaHost = hostCpuTime - _previousHostCpuTime; + long deltaCgroup = cgroupCpuTime - _previousCgroupCpuTime; - _cpuPercentage = percentage; - _refreshAfterCpu = now.Add(_cpuRefreshInterval); - _previousCgroupCpuTime = cgroupCpuTime; - _previousHostCpuTime = hostCpuTime; - } + if (deltaHost <= 0 || deltaCgroup <= 0) + { + return _cpuPercentage; } + + double percentage = Math.Min(One, (double)deltaCgroup / deltaHost); + + _logger.CpuUsageData(cgroupCpuTime, hostCpuTime, _previousCgroupCpuTime, _previousHostCpuTime, percentage); + + _cpuPercentage = percentage; + _refreshAfterCpu = now.Add(_cpuRefreshInterval); + _previousCgroupCpuTime = cgroupCpuTime; + _previousHostCpuTime = hostCpuTime; } return _cpuPercentage; } - public double MemoryUtilization() + public ulong MemoryUsage() { DateTimeOffset now = _timeProvider.GetUtcNow(); @@ -249,26 +230,24 @@ public double MemoryUtilization() { if (now < _refreshAfterMemory) { - return _memoryPercentage; + return _memoryUsage; } } - ulong memoryUsed = _parser.GetMemoryUsageInBytes(); + ulong memoryUsage = _parser.GetMemoryUsageInBytes(); lock (_memoryLocker) { if (now >= _refreshAfterMemory) { - double memoryPercentage = Math.Min(One, (double)memoryUsed / _memoryLimit); - - _memoryPercentage = memoryPercentage; + _memoryUsage = memoryUsage; _refreshAfterMemory = now.Add(_memoryRefreshInterval); } } - _logger.MemoryUsageData(memoryUsed, _memoryLimit, _memoryPercentage); + _logger.MemoryUsageData(_memoryUsage); - return _memoryPercentage; + return _memoryUsage; } /// @@ -288,4 +267,71 @@ public Snapshot GetSnapshot() userTimeSinceStart: TimeSpan.FromTicks((long)(cgroupTime / Hundred * _scaleRelativeToCpuRequestForTrackerApi)), memoryUsageInBytes: memoryUsed); } + + private double MemoryPercentage() + { + ulong memoryUsage = MemoryUsage(); + double memoryPercentage = Math.Min(One, (double)memoryUsage / _memoryLimit); + + _logger.MemoryPercentageData(memoryUsage, _memoryLimit, memoryPercentage); + return memoryPercentage; + } + + private Measurement[] GetMeasurementWithRetry(Func func) + where T : struct + { + if (!TryGetValueWithRetry(func, out T value)) + { + return Array.Empty>(); + } + + return new[] { new Measurement(value) }; + } + + private bool TryGetValueWithRetry(Func func, out T value) + where T : struct + { + value = default; + if (Volatile.Read(ref _measurementsUnavailable) == 1 && + _timeProvider.GetUtcNow() - _lastFailure < _retryInterval) + { + return false; + } + + try + { + value = func(); + _ = Interlocked.CompareExchange(ref _measurementsUnavailable, 0, 1); + + return true; + } + catch (Exception ex) when ( + ex is System.IO.FileNotFoundException || + ex is System.IO.DirectoryNotFoundException || + ex is System.UnauthorizedAccessException) + { + _lastFailure = _timeProvider.GetUtcNow(); + _ = Interlocked.Exchange(ref _measurementsUnavailable, 1); + + return false; + } + } + + // Math.Min() is used below to mitigate margin errors and various kinds of precisions losses + // due to the fact that the calculation itself is not an atomic operation: + private double CpuUtilizationRequest(double cpuRequest) => Math.Min(One, CpuUtilizationV2() / cpuRequest); + private double CpuUtilizationLimit(double cpuLimit) => Math.Min(One, CpuUtilizationV2() / cpuLimit); + + private IEnumerable> GetCpuTime() + { + if (TryGetValueWithRetry(_parser.GetHostCpuUsageInNanoseconds, out long systemCpuTime)) + { + yield return new Measurement(systemCpuTime / NanosecondsInSecond, [new KeyValuePair("cpu.mode", "system")]); + } + + if (TryGetValueWithRetry(CpuUtilizationV2, out double userCpuTime)) + { + yield return new Measurement(userCpuTime, [new KeyValuePair("cpu.mode", "user")]); + } + } } diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Log.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Log.cs index b78f64ddfe0..0721a0ff998 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Log.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/Log.cs @@ -7,13 +7,10 @@ namespace Microsoft.Extensions.Diagnostics.ResourceMonitoring; [SuppressMessage("Usage", "CA1801:Review unused parameters", Justification = "Generators.")] -[SuppressMessage("Major Code Smell", "S109:Magic numbers should not be used", Justification = "Generators.")] internal static partial class Log { [LoggerMessage(1, LogLevel.Debug, -#pragma warning disable S103 // Lines should not be too long "Computed CPU usage with CgroupCpuTime = {cgroupCpuTime}, HostCpuTime = {hostCpuTime}, PreviousCgroupCpuTime = {previousCgroupCpuTime}, PreviousHostCpuTime = {previousHostCpuTime}, CpuPercentage = {cpuPercentage}.")] -#pragma warning restore S103 // Lines should not be too long public static partial void CpuUsageData( this ILogger logger, long cgroupCpuTime, @@ -24,7 +21,7 @@ public static partial void CpuUsageData( [LoggerMessage(2, LogLevel.Debug, "Computed memory usage with MemoryUsedInBytes = {memoryUsed}, MemoryLimit = {memoryLimit}, MemoryPercentage = {memoryPercentage}.")] - public static partial void MemoryUsageData( + public static partial void MemoryPercentageData( this ILogger logger, ulong memoryUsed, double memoryLimit, @@ -40,9 +37,7 @@ public static partial void SystemResourcesInfo( ulong memoryRequest); [LoggerMessage(4, LogLevel.Debug, -#pragma warning disable S103 // Lines should not be too long "For CgroupV2, Computed CPU usage with CgroupCpuTime = {cgroupCpuTime}, PreviousCgroupCpuTime = {previousCgroupCpuTime}, ActualElapsedNanoseconds = {actualElapsedNanoseconds}, CpuCores = {cpuCores}.")] -#pragma warning restore S103 // Lines should not be too long public static partial void CpuUsageDataV2( this ILogger logger, long cgroupCpuTime, @@ -50,21 +45,15 @@ public static partial void CpuUsageDataV2( double actualElapsedNanoseconds, double cpuCores); - [LoggerMessage(5, LogLevel.Debug, - "CPU utilization exceeded 100%: Counter = {counterValue}")] - public static partial void CounterMessage100( - this ILogger logger, - long counterValue); - - [LoggerMessage(6, LogLevel.Debug, - "CPU utilization exceeded 110%: Counter = {counterValue}")] - public static partial void CounterMessage110( - this ILogger logger, - long counterValue); - - [LoggerMessage(7, LogLevel.Warning, + [LoggerMessage(5, LogLevel.Warning, "Error while getting disk stats: Error={errorMessage}")] public static partial void HandleDiskStatsException( this ILogger logger, string errorMessage); + + [LoggerMessage(6, LogLevel.Debug, + "Computed memory usage = {memoryUsed}.")] + public static partial void MemoryUsageData( + this ILogger logger, + ulong memoryUsed); } diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Log.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Log.cs index f1d2b620a71..ad4047ad576 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Log.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Log.cs @@ -8,7 +8,6 @@ namespace Microsoft.Extensions.Diagnostics.ResourceMonitoring; [SuppressMessage("Usage", "CA1801:Review unused parameters", Justification = "Generators.")] -[SuppressMessage("Major Code Smell", "S109:Magic numbers should not be used", Justification = "Generators.")] internal static partial class Log { [LoggerMessage(1, LogLevel.Error, "Unable to gather utilization statistics.")] diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ResourceMonitorService.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ResourceMonitorService.cs index ab77a5dfed9..fc44fab4d45 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ResourceMonitorService.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ResourceMonitorService.cs @@ -101,7 +101,6 @@ public ResourceUtilization GetUtilization(TimeSpan window) return Calculator.CalculateUtilization(t.first, t.last, _provider.Resources); } - [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Intentionally Consume All. Allow no escapes.")] internal async Task PublishUtilizationAsync(CancellationToken cancellationToken) { var u = GetUtilization(_publishingWindow); @@ -120,7 +119,6 @@ internal async Task PublishUtilizationAsync(CancellationToken cancellationToken) } } - [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Intentionally Consume All. Allow no escapes.")] [SuppressMessage("Blocker Bug", "S2190:Loops and recursions should not be infinite", Justification = "Terminate when Delay throws an exception on cancellation")] protected override async Task ExecuteAsync(CancellationToken cancellationToken) { diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ResourceMonitoringOptions.Windows.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ResourceMonitoringOptions.Windows.cs index 9e8636506c7..efe5734f86f 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ResourceMonitoringOptions.Windows.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ResourceMonitoringOptions.Windows.cs @@ -17,9 +17,7 @@ public partial class ResourceMonitoringOptions /// This property is Windows-specific and has no effect on other operating systems. /// [Required] -#pragma warning disable CA2227 // Collection properties should be read only public ISet SourceIpAddresses { get; set; } = new HashSet(); -#pragma warning restore CA2227 // Collection properties should be read only /// /// Gets or sets a value indicating whether CPU and Memory utilization metric values should be in range [0, 1] instead of [0, 100]. diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ResourceMonitoringOptions.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ResourceMonitoringOptions.cs index 420d6001f57..eb890da291e 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ResourceMonitoringOptions.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ResourceMonitoringOptions.cs @@ -100,23 +100,18 @@ public partial class ResourceMonitoringOptions public TimeSpan MemoryConsumptionRefreshInterval { get; set; } = DefaultRefreshInterval; /// - /// Gets or sets a value indicating whether CPU metrics are calculated via cgroup CPU limits instead of Host CPU delta. + /// Gets or sets a value indicating whether CPU metrics for Linux are calculated using V2 method - via cgroup CPU limits instead of Host CPU delta. /// /// /// The default value is . /// + /// + /// This applies to cgroups v2 only and not supported on cgroups v1. + /// This is a more accurate way to calculate CPU utilization on Linux systems, please enable if possible. + /// It will be the default in the future. + /// [Experimental(diagnosticId: DiagnosticIds.Experiments.ResourceMonitoring, UrlFormat = DiagnosticIds.UrlFormat)] - public bool CalculateCpuUsageWithoutHostDelta { get; set; } - - /// - /// Gets or sets a value indicating whether to use the number of periods in cpu.stat for cgroup CPU usage. - /// We use delta time for CPU usage calculation when this flag is not set. - /// - /// The default value is . - /// - /// - [Experimental(diagnosticId: DiagnosticIds.Experiments.ResourceMonitoring, UrlFormat = DiagnosticIds.UrlFormat)] - public bool UseDeltaNrPeriodsForCpuCalculation { get; set; } + public bool UseLinuxCalculationV2 { get; set; } /// /// Gets or sets a value indicating whether disk I/O metrics should be enabled. diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Disk/WindowsDiskMetrics.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Disk/WindowsDiskMetrics.cs index fc6250e80ba..7ceebfa44c6 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Disk/WindowsDiskMetrics.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Disk/WindowsDiskMetrics.cs @@ -73,7 +73,6 @@ public WindowsDiskMetrics( description: "Time disk spent activated"); } -#pragma warning disable CA1031 // Do not catch general exception types private void InitializeDiskCounters(IPerformanceCounterFactory performanceCounterFactory, TimeProvider timeProvider) { const string DiskCategoryName = "LogicalDisk"; @@ -129,7 +128,6 @@ private void InitializeDiskCounters(IPerformanceCounterFactory performanceCounte } } } -#pragma warning restore CA1031 // Do not catch general exception types private IEnumerable> GetDiskIoMeasurements() { 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 fb5223f3d02..bd72e822ad8 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Interop/ProcessInfo.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Interop/ProcessInfo.cs @@ -13,16 +13,13 @@ internal sealed class ProcessInfo : IProcessInfo public ulong GetMemoryUsage() { ulong memoryUsage = 0; - var processes = Process.GetProcesses(); - foreach (var process in processes) + foreach (var process in Process.GetProcesses()) { try { memoryUsage += (ulong)process.WorkingSet64; } -#pragma warning disable CA1031 // Do not catch general exception types catch -#pragma warning restore CA1031 // Do not catch general exception types { // Ignore various exceptions including, but not limited: // AccessDenied (from kernel processes), @@ -31,9 +28,7 @@ public ulong GetMemoryUsage() } finally { -#pragma warning disable EA0011 // Consider removing unnecessary conditional access operator (?) process?.Dispose(); -#pragma warning restore EA0011 // Consider removing unnecessary conditional access operator (?) } } diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Log.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Log.cs index fdc0d17fe44..c91cd989362 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Log.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/Log.cs @@ -5,8 +5,6 @@ namespace Microsoft.Extensions.Diagnostics.ResourceMonitoring.Windows; -#pragma warning disable S109 - internal static partial class Log { [LoggerMessage(1, LogLevel.Information, "Resource Monitoring is running inside a Job Object. For more information about Job Objects see https://aka.ms/job-objects")] @@ -26,16 +24,13 @@ public static partial void CpuUsageData( double cpuPercentage); [LoggerMessage(4, LogLevel.Debug, - "Computed memory usage with CurrentMemoryUsage = {currentMemoryUsage}, TotalMemory = {totalMemory}, MemoryPercentage = {memoryPercentage}.")] - public static partial void MemoryUsageData( + "Computed memory usage for container: CurrentMemoryUsage = {currentMemoryUsage}, TotalMemory = {totalMemory}")] + public static partial void ContainerMemoryUsageData( this ILogger logger, ulong currentMemoryUsage, - double totalMemory, - double memoryPercentage); + double totalMemory); -#pragma warning disable S103 // Lines should not be too long [LoggerMessage(5, LogLevel.Debug, "Computed CPU usage with CpuUsageKernelTicks = {cpuUsageKernelTicks}, CpuUsageUserTicks = {cpuUsageUserTicks}, OldCpuUsageTicks = {oldCpuUsageTicks}, TimeTickDelta = {timeTickDelta}, CpuUnits = {cpuUnits}, CpuPercentage = {cpuPercentage}.")] -#pragma warning restore S103 // Lines should not be too long public static partial void CpuContainerUsageData( this ILogger logger, long cpuUsageKernelTicks, @@ -60,4 +55,12 @@ public static partial void DiskIoPerfCounterException( this ILogger logger, string counterName, string errorMessage); + + [LoggerMessage(8, LogLevel.Debug, + "Computed memory usage for current process: ProcessMemoryUsage = {processMemoryUsage}, TotalMemory = {totalMemory}, MemoryPercentage = {memoryPercentage}")] + public static partial void ProcessMemoryPercentageData( + this ILogger logger, + ulong processMemoryUsage, + double totalMemory, + double memoryPercentage); } diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/WindowsContainerSnapshotProvider.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/WindowsContainerSnapshotProvider.cs index 27156ea874e..35c64adeedc 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/WindowsContainerSnapshotProvider.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/WindowsContainerSnapshotProvider.cs @@ -2,7 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Diagnostics.CodeAnalysis; +using System.Collections.Generic; using System.Diagnostics.Metrics; using System.Threading; using Microsoft.Extensions.Diagnostics.ResourceMonitoring.Windows.Interop; @@ -17,6 +17,7 @@ internal sealed class WindowsContainerSnapshotProvider : ISnapshotProvider { private const double One = 1.0d; private const double Hundred = 100.0d; + private const double TicksPerSecondDouble = TimeSpan.TicksPerSecond; private readonly Lazy _memoryStatus; @@ -27,6 +28,7 @@ internal sealed class WindowsContainerSnapshotProvider : ISnapshotProvider private readonly object _cpuLocker = new(); private readonly object _memoryLocker = new(); + private readonly object _processMemoryLocker = new(); private readonly TimeProvider _timeProvider; private readonly IProcessInfo _processInfo; private readonly ILogger _logger; @@ -40,8 +42,10 @@ internal sealed class WindowsContainerSnapshotProvider : ISnapshotProvider private long _oldCpuTimeTicks; private DateTimeOffset _refreshAfterCpu; private DateTimeOffset _refreshAfterMemory; + private DateTimeOffset _refreshAfterProcessMemory; private double _cpuPercentage = double.NaN; - private double _memoryPercentage; + private ulong _memoryUsage; + private double _processMemoryPercentage; public SystemResources Resources { get; } @@ -61,7 +65,6 @@ public WindowsContainerSnapshotProvider( /// Initializes a new instance of the class. /// /// This constructor enables the mocking of dependencies for the purpose of Unit Testing only. - [SuppressMessage("Major Code Smell", "S107:Methods should not have too many parameters", Justification = "Dependencies for testing")] internal WindowsContainerSnapshotProvider( IMemoryInfo memoryInfo, ISystemInfo systemInfo, @@ -85,16 +88,16 @@ internal WindowsContainerSnapshotProvider( _timeProvider = timeProvider; - using var jobHandle = _createJobHandleObject(); + using IJobHandle jobHandle = _createJobHandleObject(); - var memoryLimitLong = GetMemoryLimit(jobHandle); + ulong memoryLimitLong = GetMemoryLimit(jobHandle); _memoryLimit = memoryLimitLong; _cpuLimit = GetCpuLimit(jobHandle, systemInfo); // CPU request (aka guaranteed CPU units) is not supported on Windows, so we set it to the same value as CPU limit (aka maximum CPU units). // Memory request (aka guaranteed memory) is not supported on Windows, so we set it to the same value as memory limit (aka maximum memory). - var cpuRequest = _cpuLimit; - var memoryRequest = memoryLimitLong; + double cpuRequest = _cpuLimit; + ulong memoryRequest = memoryLimitLong; Resources = new SystemResources(cpuRequest, _cpuLimit, memoryRequest, memoryLimitLong); _logger.SystemResourcesInfo(_cpuLimit, cpuRequest, memoryLimitLong, memoryRequest); @@ -105,21 +108,44 @@ internal WindowsContainerSnapshotProvider( _memoryRefreshInterval = options.MemoryConsumptionRefreshInterval; _refreshAfterCpu = _timeProvider.GetUtcNow(); _refreshAfterMemory = _timeProvider.GetUtcNow(); + _refreshAfterProcessMemory = _timeProvider.GetUtcNow(); #pragma warning disable CA2000 // Dispose objects before losing scope // We don't dispose the meter because IMeterFactory handles that // An issue on analyzer side: https://github.com/dotnet/roslyn-analyzers/issues/6912 // Related documentation: https://github.com/dotnet/docs/pull/37170 - var meter = meterFactory.Create(ResourceUtilizationInstruments.MeterName); + Meter meter = meterFactory.Create(ResourceUtilizationInstruments.MeterName); #pragma warning restore CA2000 // Dispose objects before losing scope // Container based metrics: - _ = meter.CreateObservableGauge(name: ResourceUtilizationInstruments.ContainerCpuLimitUtilization, observeValue: CpuPercentage); - _ = meter.CreateObservableGauge(name: ResourceUtilizationInstruments.ContainerMemoryLimitUtilization, observeValue: () => MemoryPercentage(() => _processInfo.GetMemoryUsage())); + _ = meter.CreateObservableCounter( + name: ResourceUtilizationInstruments.ContainerCpuTime, + observeValues: GetCpuTime, + unit: "s", + description: "CPU time used by the container."); + + _ = meter.CreateObservableGauge( + name: ResourceUtilizationInstruments.ContainerCpuLimitUtilization, + observeValue: CpuPercentage); + + _ = meter.CreateObservableGauge( + name: ResourceUtilizationInstruments.ContainerMemoryLimitUtilization, + observeValue: () => Math.Min(_metricValueMultiplier, MemoryUsage() / _memoryLimit * _metricValueMultiplier)); + + _ = meter.CreateObservableUpDownCounter( + name: ResourceUtilizationInstruments.ContainerMemoryUsage, + observeValue: () => (long)MemoryUsage(), + unit: "By", + description: "Memory usage of the container."); // Process based metrics: - _ = meter.CreateObservableGauge(name: ResourceUtilizationInstruments.ProcessCpuUtilization, observeValue: CpuPercentage); - _ = meter.CreateObservableGauge(name: ResourceUtilizationInstruments.ProcessMemoryUtilization, observeValue: () => MemoryPercentage(() => _processInfo.GetCurrentProcessMemoryUsage())); + _ = meter.CreateObservableGauge( + name: ResourceUtilizationInstruments.ProcessCpuUtilization, + observeValue: CpuPercentage); + + _ = meter.CreateObservableGauge( + name: ResourceUtilizationInstruments.ProcessMemoryUtilization, + observeValue: ProcessMemoryPercentage); } public Snapshot GetSnapshot() @@ -155,7 +181,7 @@ private static double GetCpuLimit(IJobHandle jobHandle, ISystemInfo systemInfo) cpuRatio = cpuLimit.CpuRate / CpuCycles; } - var systemInfoValue = systemInfo.GetSystemInfo(); + SYSTEM_INFO systemInfoValue = systemInfo.GetSystemInfo(); // Multiply the cpu ratio by the number of processors to get you the portion // of processors used from the system. @@ -172,7 +198,7 @@ private ulong GetMemoryLimit(IJobHandle jobHandle) if (memoryLimitInBytes <= 0) { - var memoryStatus = _memoryStatus.Value; + MEMORYSTATUSEX memoryStatus = _memoryStatus.Value; // Technically, the unconstrained limit is memoryStatus.TotalPageFile. // Leaving this at physical as it is more understandable to consumers. @@ -182,35 +208,72 @@ private ulong GetMemoryLimit(IJobHandle jobHandle) return memoryLimitInBytes; } - private double MemoryPercentage(Func getMemoryUsage) + private double ProcessMemoryPercentage() { - var now = _timeProvider.GetUtcNow(); + DateTimeOffset now = _timeProvider.GetUtcNow(); + + lock (_processMemoryLocker) + { + if (now < _refreshAfterProcessMemory) + { + return _processMemoryPercentage; + } + } + + ulong processMemoryUsage = _processInfo.GetCurrentProcessMemoryUsage(); + + lock (_processMemoryLocker) + { + if (now >= _refreshAfterProcessMemory) + { + _processMemoryPercentage = Math.Min(_metricValueMultiplier, processMemoryUsage / _memoryLimit * _metricValueMultiplier); + _refreshAfterProcessMemory = now.Add(_memoryRefreshInterval); + + _logger.ProcessMemoryPercentageData(processMemoryUsage, _memoryLimit, _processMemoryPercentage); + } + + return _processMemoryPercentage; + } + } + + private ulong MemoryUsage() + { + DateTimeOffset now = _timeProvider.GetUtcNow(); lock (_memoryLocker) { if (now < _refreshAfterMemory) { - return _memoryPercentage; + return _memoryUsage; } } - var memoryUsage = getMemoryUsage(); + ulong memoryUsage = _processInfo.GetMemoryUsage(); lock (_memoryLocker) { if (now >= _refreshAfterMemory) { - // Don't change calculation order, otherwise we loose some precision: - _memoryPercentage = Math.Min(_metricValueMultiplier, memoryUsage / _memoryLimit * _metricValueMultiplier); + _memoryUsage = memoryUsage; _refreshAfterMemory = now.Add(_memoryRefreshInterval); + _logger.ContainerMemoryUsageData(_memoryUsage, _memoryLimit); } - _logger.MemoryUsageData(memoryUsage, _memoryLimit, _memoryPercentage); - - return _memoryPercentage; + return _memoryUsage; } } + private IEnumerable> GetCpuTime() + { + using IJobHandle jobHandle = _createJobHandleObject(); + var basicAccountingInfo = jobHandle.GetBasicAccountingInfo(); + + yield return new Measurement(basicAccountingInfo.TotalUserTime / TicksPerSecondDouble, + [new KeyValuePair("cpu.mode", "user")]); + yield return new Measurement(basicAccountingInfo.TotalKernelTime / TicksPerSecondDouble, + [new KeyValuePair("cpu.mode", "system")]); + } + private double CpuPercentage() { var now = _timeProvider.GetUtcNow(); diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/WindowsSnapshotProvider.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/WindowsSnapshotProvider.cs index 837cd0f9a06..82b94dad9e6 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/WindowsSnapshotProvider.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/WindowsSnapshotProvider.cs @@ -2,8 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +#if !NET9_0_OR_GREATER using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; +#endif using System.Diagnostics.Metrics; using Microsoft.Extensions.Diagnostics.ResourceMonitoring.Windows.Interop; using Microsoft.Extensions.Logging; @@ -44,7 +45,6 @@ public WindowsSnapshotProvider(ILogger? logger, IMeterF { } - [SuppressMessage("Major Code Smell", "S107:Methods should not have too many parameters", Justification = "Dependencies for testing")] internal WindowsSnapshotProvider( ILogger? logger, IMeterFactory meterFactory, @@ -95,18 +95,31 @@ internal WindowsSnapshotProvider( public Snapshot GetSnapshot() { +#if NET9_0_OR_GREATER + var cpuUsage = Environment.CpuUsage; + return new Snapshot( + totalTimeSinceStart: TimeSpan.FromTicks(_timeProvider.GetUtcNow().Ticks), + kernelTimeSinceStart: cpuUsage.PrivilegedTime, + userTimeSinceStart: cpuUsage.UserTime, + memoryUsageInBytes: (ulong)Environment.WorkingSet); +#else using var process = Process.GetCurrentProcess(); - - return new Snapshot(totalTimeSinceStart: TimeSpan.FromTicks(_timeProvider.GetUtcNow().Ticks), + return new Snapshot( + totalTimeSinceStart: TimeSpan.FromTicks(_timeProvider.GetUtcNow().Ticks), kernelTimeSinceStart: process.PrivilegedProcessorTime, userTimeSinceStart: process.UserProcessorTime, memoryUsageInBytes: (ulong)Environment.WorkingSet); +#endif } internal static long GetCpuTicks() { +#if NET9_0_OR_GREATER + return Environment.CpuUsage.TotalTime.Ticks; +#else using var process = Process.GetCurrentProcess(); return process.TotalProcessorTime.Ticks; +#endif } internal static int GetCpuUnits() => Environment.ProcessorCount; @@ -144,7 +157,7 @@ private double MemoryPercentage() _refreshAfterMemory = now.Add(_memoryRefreshInterval); } - _logger.MemoryUsageData((ulong)currentMemoryUsage, _totalMemory, _memoryPercentage); + _logger.ProcessMemoryPercentageData((ulong)currentMemoryUsage, _totalMemory, _memoryPercentage); return _memoryPercentage; } diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLogCollector.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLogCollector.cs index 376cc87be8c..24b9f933b9c 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLogCollector.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLogCollector.cs @@ -129,6 +129,13 @@ internal void AddRecord(FakeLogRecord record) return; } + var customFilter = _options.CustomFilter; + if (customFilter is not null && !customFilter(record)) + { + // record was filtered out by a custom filter + return; + } + lock (_records) { _records.Add(record); diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLogCollectorOptions.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLogCollectorOptions.cs index be5476e685a..c7c277fc475 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLogCollectorOptions.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLogCollectorOptions.cs @@ -3,9 +3,8 @@ using System; using System.Collections.Generic; -using Microsoft.Extensions.Logging; - -#pragma warning disable CA2227 // Collection properties should be read only +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Extensions.Logging.Testing; @@ -34,6 +33,17 @@ public class FakeLogCollectorOptions /// public ISet FilteredLevels { get; set; } = new HashSet(); + /// + /// Gets or sets custom filter for which records are collected. + /// + /// The default is . + /// + /// Defaults to which doesn't apply any additional filter to the records. + /// If not empty, only records for which the filter function returns will be collected by the fake logger. + /// + [Experimental(DiagnosticIds.Experiments.Telemetry)] + public Func? CustomFilter { get; set; } + /// /// Gets or sets a value indicating whether to collect records that are logged when the associated log level is currently disabled. /// diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLogRecord.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLogRecord.cs index 8a3b184732d..09fc86f6cba 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLogRecord.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLogRecord.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using Microsoft.Extensions.Logging; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.Logging.Testing; @@ -25,9 +24,7 @@ public class FakeLogRecord /// The optional category for this record, which corresponds to the T in . /// Whether the log level was enabled or not when the method was called. /// The time at which the log record was created. -#pragma warning disable S107 // Methods should not have too many parameters public FakeLogRecord(LogLevel level, EventId id, object? state, Exception? exception, string message, IReadOnlyList scopes, string? category, bool enabled, DateTimeOffset timestamp) -#pragma warning restore S107 // Methods should not have too many parameters { Level = level; Id = id; diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLoggerProvider.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLoggerProvider.cs index 3d31a6d6be8..e23c9d10324 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLoggerProvider.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLoggerProvider.cs @@ -5,7 +5,6 @@ using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; using System.Linq; -using Microsoft.Extensions.Logging; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.Logging.Testing; diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLoggerT.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLoggerT.cs index 1d7c5336a97..5d4e854ca6e 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLoggerT.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging/FakeLoggerT.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using Microsoft.Extensions.Logging; namespace Microsoft.Extensions.Logging.Testing; diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Metrics/MetricCollector.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Metrics/MetricCollector.cs index 51309d9960c..7102de8987c 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Metrics/MetricCollector.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Metrics/MetricCollector.cs @@ -238,9 +238,7 @@ public Task WaitForMeasurementsAsync(int minCount, CancellationToken cancellatio }); } -#pragma warning disable VSTHRD003 // Avoid awaiting foreign Tasks return w.TaskSource.Task; -#pragma warning restore VSTHRD003 // Avoid awaiting foreign Tasks } /// diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Microsoft.Extensions.Diagnostics.Testing.csproj b/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Microsoft.Extensions.Diagnostics.Testing.csproj index 2ab592c4a4a..c38dcdea395 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Microsoft.Extensions.Diagnostics.Testing.csproj +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Microsoft.Extensions.Diagnostics.Testing.csproj @@ -1,6 +1,7 @@  Microsoft.Extensions.Diagnostics.Testing + $(NetCoreTargetFrameworks);netstandard2.0;net462 Hand-crafted fakes to make telemetry-related testing easier. Telemetry $(PackageTags);Testing diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Http/DownstreamDependencyMetadataManager.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Http/DownstreamDependencyMetadataManager.cs index fbe54413cbf..93c3e534e8e 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Http/DownstreamDependencyMetadataManager.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Http/DownstreamDependencyMetadataManager.cs @@ -40,7 +40,6 @@ public DownstreamDependencyMetadataManager(IEnumerable requestRouteAsSpan = routeMetadata.RequestRoute.AsSpan(); - - if (requestRouteAsSpan.Length > 0) + var route = routeMetadata.RequestRoute; + if (!string.IsNullOrEmpty(route)) { - if (requestRouteAsSpan[0] != '/') + var routeSpan = route.AsSpan(); + if (routeSpan.StartsWith("//".AsSpan())) { - requestRouteAsSpan = $"/{routeMetadata.RequestRoute}".AsSpan(); + routeSpan = routeSpan.Slice(1); } - else if (requestRouteAsSpan.StartsWith("//".AsSpan(), StringComparison.OrdinalIgnoreCase)) + + if (routeSpan.Length > 1 && routeSpan[routeSpan.Length - 1] == '/') { - requestRouteAsSpan = requestRouteAsSpan.Slice(1); + routeSpan = routeSpan.Slice(0, routeSpan.Length - 1); } - if (requestRouteAsSpan.Length > 1 && requestRouteAsSpan[requestRouteAsSpan.Length - 1] == '/') + if (routeSpan[0] != '/') { - requestRouteAsSpan = requestRouteAsSpan.Slice(0, requestRouteAsSpan.Length - 1); +#if NET + route = $"/{routeSpan}"; +#else + route = $"/{routeSpan.ToString()}"; +#endif } + else if (routeSpan.Length != route.Length) + { + route = routeSpan.ToString(); + } + + route = _routeRegex.Replace(route, "*").ToUpperInvariant(); } else { - requestRouteAsSpan = "/".AsSpan(); + route = "/"; } - var route = _routeRegex.Replace(requestRouteAsSpan.ToString(), "*"); - route = route.ToUpperInvariant(); for (int i = 0; i < route.Length; i++) { char ch = route[i]; diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/HttpClientLatencyTelemetryExtensions.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/HttpClientLatencyTelemetryExtensions.cs index 309f24e1f2d..2e955605fdc 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/HttpClientLatencyTelemetryExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/HttpClientLatencyTelemetryExtensions.cs @@ -30,9 +30,12 @@ public static IServiceCollection AddHttpClientLatencyTelemetry(this IServiceColl _ = services.RegisterCheckpointNames(HttpCheckpoints.Checkpoints); _ = services.AddOptions(); - _ = services.AddActivatedSingleton(); - _ = services.AddActivatedSingleton(); + _ = services.AddSingleton(); + _ = services.AddSingleton(); _ = services.AddTransient(); + _ = services.AddSingleton(); + _ = services.RegisterMeasureNames(HttpMeasures.Measures); + _ = services.RegisterTagNames(HttpTags.Tags); _ = services.AddHttpClientLogEnricher(); return services.ConfigureAll( diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpClientLatencyContext.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpClientLatencyContext.cs index 338326cc690..d5b5f0a40fb 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpClientLatencyContext.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpClientLatencyContext.cs @@ -19,9 +19,4 @@ public void Set(ILatencyContext context) { _latencyContext.Value = context; } - - public void Unset() - { - _latencyContext.Value = null; - } } diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpClientLatencyLogEnricher.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpClientLatencyLogEnricher.cs index bad9b23a415..753b5b9a9f6 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpClientLatencyLogEnricher.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpClientLatencyLogEnricher.cs @@ -23,36 +23,69 @@ internal sealed class HttpClientLatencyLogEnricher : IHttpClientLogEnricher { private static readonly ObjectPool _builderPool = PoolFactory.SharedStringBuilderPool; private readonly HttpClientLatencyContext _latencyContext; - + private readonly HttpLatencyMediator _httpLatencyMediator; private readonly CheckpointToken _enricherInvoked; - public HttpClientLatencyLogEnricher(HttpClientLatencyContext latencyContext, ILatencyContextTokenIssuer tokenIssuer) + public HttpClientLatencyLogEnricher( + HttpClientLatencyContext latencyContext, + ILatencyContextTokenIssuer tokenIssuer, + HttpLatencyMediator httpLatencyMediator) { _latencyContext = latencyContext; + _httpLatencyMediator = httpLatencyMediator; _enricherInvoked = tokenIssuer.GetCheckpointToken(HttpCheckpoints.EnricherInvoked); } - public void Enrich(IEnrichmentTagCollector collector, HttpRequestMessage request, HttpResponseMessage? response, Exception? exception) + public void Enrich(IEnrichmentTagCollector collector, HttpRequestMessage? request, HttpResponseMessage? response, Exception? exception) { if (response != null) { var lc = _latencyContext.Get(); - lc?.AddCheckpoint(_enricherInvoked); - - StringBuilder stringBuilder = _builderPool.Get(); - - // Add serverName, checkpoints to outgoing http logs. - AppendServerName(response.Headers, stringBuilder); - _ = stringBuilder.Append(','); if (lc != null) { - AppendCheckpoints(lc, stringBuilder); + // Add the checkpoint + lc.AddCheckpoint(_enricherInvoked); + + // Use the mediator to record all metrics + _httpLatencyMediator.RecordEnd(lc, response); } - collector.Add("LatencyInfo", stringBuilder.ToString()); + StringBuilder stringBuilder = _builderPool.Get(); + + try + { + /* Add version, serverName, checkpoints, and measures to outgoing http logs. + * Schemas: 1) ServerName,CheckpointName,CheckpointValue + * 2) v1.0,ServerName,TagName,TagValue,CheckpointName,CheckpointValue,MetricName,MetricValue + */ + + // Add version + _ = stringBuilder.Append("v1.0"); + _ = stringBuilder.Append(','); + + // Add server name + AppendServerName(response.Headers, stringBuilder); + _ = stringBuilder.Append(','); + + // Add tags, checkpoints, and measures + if (lc != null) + { + AppendTags(lc, stringBuilder); + _ = stringBuilder.Append(','); + + AppendCheckpoints(lc, stringBuilder); + _ = stringBuilder.Append(','); - _builderPool.Return(stringBuilder); + AppendMeasures(lc, stringBuilder); + } + + collector.Add("LatencyInfo", stringBuilder.ToString()); + } + finally + { + _builderPool.Return(stringBuilder); + } } } @@ -60,25 +93,70 @@ private static void AppendServerName(HttpHeaders headers, StringBuilder stringBu { if (headers.TryGetValues(TelemetryConstants.ServerApplicationNameHeader, out var values)) { - _ = stringBuilder.Append(values!.First()); + _ = stringBuilder.Append(values.First()); } } private static void AppendCheckpoints(ILatencyContext latencyContext, StringBuilder stringBuilder) { + const int MillisecondsPerSecond = 1000; + var latencyData = latencyContext.LatencyData; - for (int i = 0; i < latencyData.Checkpoints.Length; i++) + var checkpointCount = latencyData.Checkpoints.Length; + + for (int i = 0; i < checkpointCount; i++) { _ = stringBuilder.Append(latencyData.Checkpoints[i].Name); _ = stringBuilder.Append('/'); } _ = stringBuilder.Append(','); - for (int i = 0; i < latencyData.Checkpoints.Length; i++) + + for (int i = 0; i < checkpointCount; i++) + { + var cp = latencyData.Checkpoints[i]; + _ = stringBuilder.Append((long)Math.Round(((double)cp.Elapsed / cp.Frequency) * MillisecondsPerSecond)); + _ = stringBuilder.Append('/'); + } + } + + private static void AppendMeasures(ILatencyContext latencyContext, StringBuilder stringBuilder) + { + var latencyData = latencyContext.LatencyData; + var measureCount = latencyData.Measures.Length; + + for (int i = 0; i < measureCount; i++) + { + _ = stringBuilder.Append(latencyData.Measures[i].Name); + _ = stringBuilder.Append('/'); + } + + _ = stringBuilder.Append(','); + + for (int i = 0; i < measureCount; i++) + { + _ = stringBuilder.Append(latencyData.Measures[i].Value); + _ = stringBuilder.Append('/'); + } + } + + private static void AppendTags(ILatencyContext latencyContext, StringBuilder stringBuilder) + { + var latencyData = latencyContext.LatencyData; + var tagCount = latencyData.Tags.Length; + + for (int i = 0; i < tagCount; i++) + { + _ = stringBuilder.Append(latencyData.Tags[i].Name); + _ = stringBuilder.Append('/'); + } + + _ = stringBuilder.Append(','); + + for (int i = 0; i < tagCount; i++) { - var ms = ((double)latencyData.Checkpoints[i].Elapsed / latencyData.Checkpoints[i].Frequency) * 1000; - _ = stringBuilder.Append(ms); + _ = stringBuilder.Append(latencyData.Tags[i].Value); _ = stringBuilder.Append('/'); } } -} +} \ No newline at end of file diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpLatencyMediator.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpLatencyMediator.cs new file mode 100644 index 00000000000..25543ad9c5a --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpLatencyMediator.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if NET +using System.Net.Http; +using Microsoft.Extensions.Diagnostics.Latency; + +namespace Microsoft.Extensions.Http.Latency.Internal; + +/// +/// Mediator for HTTP latency operations that coordinates recording HTTP metrics in a latency context. +/// +internal sealed class HttpLatencyMediator +{ + private readonly MeasureToken _gcPauseTime; + private readonly TagToken _httpVersionTag; + + public HttpLatencyMediator(ILatencyContextTokenIssuer tokenIssuer) + { + _gcPauseTime = tokenIssuer.GetMeasureToken(HttpMeasures.GCPauseTime); + _httpVersionTag = tokenIssuer.GetTagToken(HttpTags.HttpVersion); + } + + public void RecordStart(ILatencyContext latencyContext) + { + latencyContext.RecordMeasure(_gcPauseTime, (long)System.GC.GetTotalPauseDuration().TotalMilliseconds * -1L); + } + + public void RecordEnd(ILatencyContext latencyContext, HttpResponseMessage? response = null) + { + latencyContext.AddMeasure(_gcPauseTime, (long)System.GC.GetTotalPauseDuration().TotalMilliseconds); + + if (response != null) + { + latencyContext.SetTag(_httpVersionTag, response.Version.ToString()); + } + } +} +#endif diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpLatencyMediator.netfx.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpLatencyMediator.netfx.cs new file mode 100644 index 00000000000..c5cb350397e --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpLatencyMediator.netfx.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. +#if !NET + +using System.Net.Http; +using Microsoft.Extensions.Diagnostics.Latency; + +namespace Microsoft.Extensions.Http.Latency.Internal; + +internal sealed class HttpLatencyMediator +{ + private readonly TagToken _httpVersionTag; + + public HttpLatencyMediator(ILatencyContextTokenIssuer tokenIssuer) + { + _httpVersionTag = tokenIssuer.GetTagToken(HttpTags.HttpVersion); + } + + public void RecordEnd(ILatencyContext latencyContext, HttpResponseMessage? response = null) + { + if (response != null) + { + latencyContext?.SetTag(_httpVersionTag, response.Version.ToString()); + } + } +} +#endif \ No newline at end of file diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpLatencyTelemetryHandler.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpLatencyTelemetryHandler.cs index d0d38a875ec..81a0878b3f5 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpLatencyTelemetryHandler.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpLatencyTelemetryHandler.cs @@ -8,7 +8,6 @@ using Microsoft.Extensions.Diagnostics.Latency; using Microsoft.Extensions.Http.Diagnostics; using Microsoft.Extensions.Options; -using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.Http.Latency.Internal; @@ -22,19 +21,22 @@ internal sealed class HttpLatencyTelemetryHandler : DelegatingHandler private readonly ILatencyContextProvider _latencyContextProvider; private readonly CheckpointToken _handlerStart; private readonly string _applicationName; +#if NET + private readonly HttpLatencyMediator _latencyMediator; +#endif public HttpLatencyTelemetryHandler(HttpRequestLatencyListener latencyListener, ILatencyContextTokenIssuer tokenIssuer, ILatencyContextProvider latencyContextProvider, - IOptions options, IOptions appMetdata) + IOptions options, IOptions appMetadata, HttpLatencyMediator latencyTelemetryMediator) { - var appMetadata = Throw.IfMemberNull(appMetdata, appMetdata.Value); - var telemetryOptions = Throw.IfMemberNull(options, options.Value); - _latencyListener = latencyListener; _latencyContextProvider = latencyContextProvider; _handlerStart = tokenIssuer.GetCheckpointToken(HttpCheckpoints.HandlerRequestStart); - _applicationName = appMetdata.Value.ApplicationName; + _applicationName = appMetadata.Value.ApplicationName; +#if NET + _latencyMediator = latencyTelemetryMediator; +#endif - if (telemetryOptions.EnableDetailedLatencyBreakdown) + if (options.Value.EnableDetailedLatencyBreakdown) { _latencyListener.Enable(); } @@ -46,12 +48,12 @@ protected async override Task SendAsync(HttpRequestMessage context.AddCheckpoint(_handlerStart); _latencyListener.LatencyContext.Set(context); - request.Headers.Add(TelemetryConstants.ClientApplicationNameHeader, _applicationName); - - var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); +#if NET + _latencyMediator.RecordStart(context); +#endif - _latencyListener.LatencyContext.Unset(); + _ = request.Headers.TryAddWithoutValidation(TelemetryConstants.ClientApplicationNameHeader, _applicationName); - return response; + return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); } } diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpMeasures.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpMeasures.cs new file mode 100644 index 00000000000..adb086a99cc --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpMeasures.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. + +namespace Microsoft.Extensions.Http.Latency.Internal; + +internal static class HttpMeasures +{ + public const string GCPauseTime = "gcp"; + public const string ConnectionInitiated = "coni"; + + public static readonly string[] Measures = + [ + GCPauseTime, + ConnectionInitiated + ]; +} diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpRequestLatencyListener.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpRequestLatencyListener.cs index 744d7a5ddd3..ae5eef41179 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpRequestLatencyListener.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpRequestLatencyListener.cs @@ -15,18 +15,18 @@ internal sealed class HttpRequestLatencyListener : EventListener { private const string SocketProviderName = "System.Net.Sockets"; private const string HttpProviderName = "System.Net.Http"; - private const string NameResolutionProivderName = "System.Net.NameResolution"; + private const string NameResolutionProviderName = "System.Net.NameResolution"; private readonly ConcurrentDictionary _eventSources = new() { [SocketProviderName] = null, [HttpProviderName] = null, - [NameResolutionProivderName] = null + [NameResolutionProviderName] = null }; internal HttpClientLatencyContext LatencyContext { get; } - private readonly EventToCheckpointToken _eventToCheckpointToken; + private readonly EventToToken _eventToToken; private int _enabled; @@ -35,13 +35,14 @@ internal sealed class HttpRequestLatencyListener : EventListener public HttpRequestLatencyListener(HttpClientLatencyContext latencyContext, ILatencyContextTokenIssuer tokenIssuer) { LatencyContext = latencyContext; - _eventToCheckpointToken = new(tokenIssuer); + _eventToToken = new(tokenIssuer); } public void Enable() { if (Interlocked.CompareExchange(ref _enabled, 1, 0) == 0) { + // Enable any already discovered event sources foreach (var eventSource in _eventSources) { if (eventSource.Value != null) @@ -49,16 +50,35 @@ public void Enable() EnableEventSource(eventSource.Value); } } + +#if NETSTANDARD + foreach (var eventSource in EventSource.GetSources()) + { + OnEventSourceCreated(eventSource.Name, eventSource); + } +#else + // Process already existing listeners once again + EventSourceCreated += (_, args) => OnEventSourceCreated(args.EventSource!); +#endif } } internal void OnEventWritten(string eventSourceName, string? eventName) { // If event of interest, add a checkpoint for it. - CheckpointToken? token = _eventToCheckpointToken.GetCheckpointToken(eventSourceName, eventName); + CheckpointToken? token = _eventToToken.GetCheckpointToken(eventSourceName, eventName); if (token.HasValue) { - LatencyContext.Get()?.AddCheckpoint(token.Value); + var latencyContext = LatencyContext.Get(); + latencyContext?.AddCheckpoint(token.Value); + + // If event of interest, add a presence measure for it. + MeasureToken? mtoken = _eventToToken.GetMeasureToken(eventSourceName, eventName); + + if (mtoken.HasValue) + { + latencyContext?.AddMeasure(mtoken.Value, 1L); + } } } @@ -83,13 +103,13 @@ protected override void OnEventWritten(EventWrittenEventArgs eventData) private void EnableEventSource(EventSource eventSource) { - if (Enabled && !eventSource.IsEnabled()) + if (Enabled) { EnableEvents(eventSource, EventLevel.Informational); } } - private sealed class EventToCheckpointToken + private sealed class EventToToken { private static readonly Dictionary _socketMap = new() { @@ -117,39 +137,69 @@ private sealed class EventToCheckpointToken { "ResponseContentStop", HttpCheckpoints.ResponseContentEnd } }; - private readonly FrozenDictionary> _eventToTokenMap; + private static readonly Dictionary _httpMeasureMap = new() + { + { "ConnectionEstablished", HttpMeasures.ConnectionInitiated } + }; - public EventToCheckpointToken(ILatencyContextTokenIssuer tokenIssuer) + private readonly FrozenDictionary> _eventToCheckpointTokenMap; + private readonly FrozenDictionary> _eventToMeasureTokenMap; + + public EventToToken(ILatencyContextTokenIssuer tokenIssuer) { - Dictionary socket = []; + Dictionary socket = new(); foreach (string key in _socketMap.Keys) { socket[key] = tokenIssuer.GetCheckpointToken(_socketMap[key]); } - Dictionary nameResolution = []; + Dictionary nameResolution = new(); foreach (string key in _nameResolutionMap.Keys) { nameResolution[key] = tokenIssuer.GetCheckpointToken(_nameResolutionMap[key]); } - Dictionary http = []; + Dictionary http = new(); foreach (string key in _httpMap.Keys) { http[key] = tokenIssuer.GetCheckpointToken(_httpMap[key]); } - _eventToTokenMap = new Dictionary> + Dictionary httpMeasures = new(); + foreach (string key in _httpMeasureMap.Keys) + { + httpMeasures[key] = tokenIssuer.GetMeasureToken(_httpMeasureMap[key]); + } + + _eventToCheckpointTokenMap = new Dictionary> { { SocketProviderName, socket.ToFrozenDictionary(StringComparer.Ordinal) }, - { NameResolutionProivderName, nameResolution.ToFrozenDictionary(StringComparer.Ordinal) }, + { NameResolutionProviderName, nameResolution.ToFrozenDictionary(StringComparer.Ordinal) }, { HttpProviderName, http.ToFrozenDictionary(StringComparer.Ordinal) } }.ToFrozenDictionary(StringComparer.Ordinal); + + _eventToMeasureTokenMap = new Dictionary> + { + { HttpProviderName, httpMeasures.ToFrozenDictionary(StringComparer.Ordinal) } + }.ToFrozenDictionary(StringComparer.Ordinal); } public CheckpointToken? GetCheckpointToken(string eventSourceName, string? eventName) { - if (eventName != null && _eventToTokenMap.TryGetValue(eventSourceName, out var events)) + if (eventName != null && _eventToCheckpointTokenMap.TryGetValue(eventSourceName, out var events)) + { + if (events.TryGetValue(eventName, out var token)) + { + return token; + } + } + + return null; + } + + public MeasureToken? GetMeasureToken(string eventSourceName, string? eventName) + { + if (eventName != null && _eventToMeasureTokenMap.TryGetValue(eventSourceName, out var events)) { if (events.TryGetValue(eventName, out var token)) { diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpTags.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpTags.cs new file mode 100644 index 00000000000..dd15191e22d --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Latency/Internal/HttpTags.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. + +namespace Microsoft.Extensions.Http.Latency.Internal; + +internal static class HttpTags +{ + public const string HttpVersion = "httpver"; + + public static readonly string[] Tags = + [ + HttpVersion + ]; +} diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/HttpClientLoggingTagNames.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/HttpClientLoggingTagNames.cs index 803eb03d082..d4cbc839551 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/HttpClientLoggingTagNames.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/HttpClientLoggingTagNames.cs @@ -3,6 +3,8 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Extensions.Http.Logging; @@ -51,6 +53,12 @@ public static class HttpClientLoggingTagNames /// public const string ResponseHeaderPrefix = "http.response.header."; + /// + /// URL query parameters prefix. + /// + [Experimental(diagnosticId: DiagnosticIds.Experiments.Telemetry, UrlFormat = DiagnosticIds.UrlFormat)] + public const string UrlQuery = "url.query"; + /// /// HTTP Status Code. /// @@ -61,8 +69,7 @@ public static class HttpClientLoggingTagNames /// /// A read-only of all tag names. public static IReadOnlyList TagNames { get; } = - Array.AsReadOnly(new[] - { + Array.AsReadOnly([ Duration, Host, Method, @@ -71,6 +78,7 @@ public static class HttpClientLoggingTagNames RequestHeaderPrefix, ResponseBody, ResponseHeaderPrefix, - StatusCode - }); + StatusCode, + UrlQuery + ]); } diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpClientLogger.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpClientLogger.cs index bbf8095e384..536c08eee6d 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpClientLogger.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpClientLogger.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Net.Http; using System.Threading; @@ -65,7 +64,6 @@ internal HttpClientLogger( _pathParametersRedactionSkipped = options.RequestPathParameterRedactionMode == HttpRouteParameterRedactionMode.None; } - [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "The logger shouldn't throw")] public async ValueTask LogRequestStartAsync(HttpRequestMessage request, CancellationToken cancellationToken = default) { var logRecord = _logRecordPool.Get(); @@ -109,22 +107,22 @@ internal HttpClientLogger( } } - public async ValueTask LogRequestStopAsync( + public ValueTask LogRequestStopAsync( object? context, HttpRequestMessage request, HttpResponseMessage response, TimeSpan elapsed, CancellationToken cancellationToken = default) - => await LogResponseAsync(context, request, response, null, elapsed, cancellationToken).ConfigureAwait(false); + => LogResponseAsync(context, request, response, null, elapsed, cancellationToken); - public async ValueTask LogRequestFailedAsync( + public ValueTask LogRequestFailedAsync( object? context, HttpRequestMessage request, HttpResponseMessage? response, Exception exception, TimeSpan elapsed, CancellationToken cancellationToken = default) - => await LogResponseAsync(context, request, response, exception, elapsed, cancellationToken).ConfigureAwait(false); + => LogResponseAsync(context, request, response, exception, elapsed, cancellationToken); public object? LogRequestStart(HttpRequestMessage request) => throw new NotSupportedException(SyncLoggingExceptionMessage); @@ -149,7 +147,6 @@ private static LogLevel GetLogLevel(LogRecord logRecord) return LogLevel.Information; } - [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "The logger shouldn't throw")] private async ValueTask LogResponseAsync( object? context, HttpRequestMessage request, @@ -217,8 +214,6 @@ private async ValueTask LogResponseAsync( } } - [SuppressMessage("Design", "CA1031:Do not catch general exception types", - Justification = "We intentionally catch all exception types to make Telemetry code resilient to failures.")] private void FillLogRecord( LogRecord logRecord, LoggerMessageState loggerMessageState, in TimeSpan elapsed, HttpRequestMessage request, HttpResponseMessage? response, Exception? exception) diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpHeadersReader.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpHeadersReader.cs index 8e6bcedc4e7..1679d2c764b 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpHeadersReader.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpHeadersReader.cs @@ -70,6 +70,9 @@ public void ReadResponseHeaders(HttpResponseMessage response, List _redactor.Redact(value, classification); + private void ReadHeaders(HttpHeaders headers, FrozenDictionary headersToLog, List> destination) { #if NET6_0_OR_GREATER diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpHeadersRedactor.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpHeadersRedactor.cs index 652636e53a6..a09f0eecf7c 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpHeadersRedactor.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpHeadersRedactor.cs @@ -29,6 +29,11 @@ public string Redact(IEnumerable headerValues, DataClassification classi _ => TelemetryConstants.Unknown }; + public string Redact(string value, DataClassification classification) + { + return _redactorProvider.GetRedactor(classification).Redact(value); + } + private string RedactIEnumerable(IEnumerable input, DataClassification classification) { var redactor = _redactorProvider.GetRedactor(classification); diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpRequestBodyReader.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpRequestBodyReader.cs index ed5a3c3f33d..c7abf7f0df8 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpRequestBodyReader.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpRequestBodyReader.cs @@ -2,21 +2,14 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Buffers; using System.Collections.Frozen; using System.IO; using System.Net.Http; using System.Text; using System.Threading; using System.Threading.Tasks; -#if NETCOREAPP3_1_OR_GREATER -using Microsoft.Extensions.ObjectPool; -#endif using Microsoft.Shared.Diagnostics; -#if NETCOREAPP3_1_OR_GREATER -using Microsoft.Shared.Pools; -#else -using System.Buffers; -#endif namespace Microsoft.Extensions.Http.Logging.Internal; @@ -27,9 +20,6 @@ internal sealed class HttpRequestBodyReader /// internal readonly TimeSpan RequestReadTimeout; -#if NETCOREAPP3_1_OR_GREATER - private static readonly ObjectPool> _bufferWriterPool = BufferWriterPool.SharedBufferWriterPool; -#endif private readonly FrozenSet _readableRequestContentTypes; private readonly int _requestReadLimit; @@ -93,33 +83,20 @@ private static async ValueTask ReadFromStreamAsync(HttpRequestMessage re #endif var readLimit = Math.Min(readSizeLimit, (int)streamToReadFrom.Length); -#if NETCOREAPP3_1_OR_GREATER - var bufferWriter = _bufferWriterPool.Get(); - try - { - var memory = bufferWriter.GetMemory(readLimit).Slice(0, readLimit); - var charsWritten = await streamToReadFrom.ReadAsync(memory, cancellationToken).ConfigureAwait(false); - - return Encoding.UTF8.GetString(memory[..charsWritten].Span); - } - finally - { - _bufferWriterPool.Return(bufferWriter); - streamToReadFrom.Seek(0, SeekOrigin.Begin); - } - -#else var buffer = ArrayPool.Shared.Rent(readLimit); try { - _ = await streamToReadFrom.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false); - return Encoding.UTF8.GetString(buffer.AsSpan(0, readLimit).ToArray()); +#if NET + var read = await streamToReadFrom.ReadAsync(buffer.AsMemory(0, readLimit), cancellationToken).ConfigureAwait(false); +#else + var read = await streamToReadFrom.ReadAsync(buffer, 0, readLimit, cancellationToken).ConfigureAwait(false); +#endif + return Encoding.UTF8.GetString(buffer, 0, read); } finally { ArrayPool.Shared.Return(buffer); streamToReadFrom.Seek(0, SeekOrigin.Begin); } -#endif } } diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpRequestReader.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpRequestReader.cs index a9eaf982ac4..78cb73bbddc 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpRequestReader.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpRequestReader.cs @@ -14,6 +14,7 @@ using Microsoft.Extensions.Options; using Microsoft.Extensions.Telemetry.Internal; using Microsoft.Shared.Diagnostics; +using Microsoft.Shared.Pools; namespace Microsoft.Extensions.Http.Logging.Internal; @@ -23,16 +24,18 @@ internal sealed class HttpRequestReader : IHttpRequestReader private readonly IHttpRouteParser _httpRouteParser; private readonly IHttpHeadersReader _httpHeadersReader; private readonly FrozenDictionary _defaultSensitiveParameters; + private readonly FrozenDictionary _queryParameterDataClasses; private readonly bool _logRequestBody; private readonly bool _logResponseBody; + private readonly bool _logRequestQueryParameters; private readonly bool _logRequestHeaders; private readonly bool _logResponseHeaders; private readonly HttpRouteParameterRedactionMode _routeParameterRedactionMode; - // These are not registered in DI as handler today is public and we would need to make all of those types public. + // These are not registered in DI as handler today is public, and we would need to make all of those types public. // They are not implemented as statics to simplify design and pass less arguments around. // Also wanted to encapsulate logic of reading each part of the request to simplify handler logic itself. private readonly HttpRequestBodyReader _httpRequestBodyReader; @@ -77,6 +80,7 @@ internal HttpRequestReader( _downstreamDependencyMetadataManager = downstreamDependencyMetadataManager; _defaultSensitiveParameters = options.RouteParameterDataClasses.ToFrozenDictionary(StringComparer.Ordinal); + _queryParameterDataClasses = options.RequestQueryParametersDataClasses.ToFrozenDictionary(StringComparer.Ordinal); if (options.LogBody) { @@ -86,6 +90,7 @@ internal HttpRequestReader( _logRequestHeaders = options.RequestHeadersDataClasses.Count > 0; _logResponseHeaders = options.ResponseHeadersDataClasses.Count > 0; + _logRequestQueryParameters = options.RequestQueryParametersDataClasses.Count > 0; _httpRequestBodyReader = new HttpRequestBodyReader(options); _httpResponseBodyReader = new HttpResponseBodyReader(options); @@ -93,8 +98,27 @@ internal HttpRequestReader( _routeParameterRedactionMode = options.RequestPathParameterRedactionMode; } + public async Task ReadResponseAsync(LogRecord logRecord, HttpResponseMessage response, + List>? responseHeadersBuffer, + CancellationToken cancellationToken) + { + if (_logResponseHeaders) + { + _httpHeadersReader.ReadResponseHeaders(response, responseHeadersBuffer); + logRecord.ResponseHeaders = responseHeadersBuffer; + } + + if (_logResponseBody) + { + logRecord.ResponseBody = await _httpResponseBodyReader.ReadAsync(response, cancellationToken).ConfigureAwait(false); + } + + logRecord.StatusCode = (int)response.StatusCode; + } + public async Task ReadRequestAsync(LogRecord logRecord, HttpRequestMessage request, - List>? requestHeadersBuffer, CancellationToken cancellationToken) + List>? requestHeadersBuffer, CancellationToken + cancellationToken) { logRecord.Host = request.RequestUri?.Host ?? TelemetryConstants.Unknown; logRecord.Method = request.Method; @@ -111,24 +135,86 @@ public async Task ReadRequestAsync(LogRecord logRecord, HttpRequestMessage reque logRecord.RequestBody = await _httpRequestBodyReader.ReadAsync(request, cancellationToken) .ConfigureAwait(false); } + + if (_logRequestQueryParameters && !string.IsNullOrEmpty(request.RequestUri?.Query)) + { + logRecord.QueryString = ExtractAndRedactQueryParameters(request.RequestUri!.Query); + } + else + { + logRecord.QueryString = string.Empty; + } } - public async Task ReadResponseAsync(LogRecord logRecord, HttpResponseMessage response, - List>? responseHeadersBuffer, - CancellationToken cancellationToken) + private static string UnescapeDataString(ReadOnlySpan value) { - if (_logResponseHeaders) +#if NET9_0_OR_GREATER + return Uri.UnescapeDataString(value); +#else + return Uri.UnescapeDataString(value.ToString()); +#endif + } + + private string ExtractAndRedactQueryParameters(string query) + { + var stringBuilder = PoolFactory.SharedStringBuilderPool.Get(); + try { - _httpHeadersReader.ReadResponseHeaders(response, responseHeadersBuffer); - logRecord.ResponseHeaders = responseHeadersBuffer; - } + ReadOnlySpan querySpan = query.AsSpan(); + int length = querySpan.Length; + int start = 0; - if (_logResponseBody) + // Remove leading '?' + if (length > 0 && querySpan[0] == '?') + { + start = 1; + } + + while (start < length) + { + int amp = querySpan.Slice(start).IndexOf('&'); + int end = amp == -1 ? length : start + amp; + + int eq = querySpan.Slice(start, end - start).IndexOf('='); + if (eq >= 0) + { + var keySpan = querySpan.Slice(start, eq); + var valueSpan = querySpan.Slice(start + eq + 1, end - (start + eq + 1)); + + string key = UnescapeDataString(keySpan); + string value = UnescapeDataString(valueSpan); + + // Only process if the key is in the classification dictionary and value is not empty + if (!string.IsNullOrEmpty(value) && _queryParameterDataClasses.TryGetValue(key, out var classification)) + { + string redacted = _httpHeadersReader.RedactValue(value, classification); + + // Append to string builder directly with proper encoding + if (stringBuilder.Length > 0) + { + _ = stringBuilder.Append('&'); + } + + _ = stringBuilder.Append(Uri.EscapeDataString(key)) + .Append('=') + .Append(Uri.EscapeDataString(redacted)); + } + } + + if (amp == -1) + { + break; + } + + start = end + 1; + } + + return stringBuilder.ToString(); + } + finally { - logRecord.ResponseBody = await _httpResponseBodyReader.ReadAsync(response, cancellationToken).ConfigureAwait(false); + PoolFactory.SharedStringBuilderPool.Return(stringBuilder); } - - logRecord.StatusCode = (int)response.StatusCode; } private void GetRedactedPathAndParameters(HttpRequestMessage request, LogRecord logRecord) @@ -185,3 +271,4 @@ private void GetRedactedPathAndParameters(HttpRequestMessage request, LogRecord } } } + diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpResponseBodyReader.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpResponseBodyReader.cs index 0c5b6a672b1..8022ee74197 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpResponseBodyReader.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/HttpResponseBodyReader.cs @@ -112,10 +112,7 @@ private static async ValueTask ReadFromStreamAsync(HttpResponseMessage r // if stream is not seekable we need to write the rest of the stream to the pipe // and create a new response content with the pipe reader as stream - _ = Task.Run(async () => - { - await WriteStreamToPipeAsync(streamToReadFrom, pipe.Writer, cancellationToken).ConfigureAwait(false); - }, CancellationToken.None); + _ = WriteStreamToPipeAsync(streamToReadFrom, pipe.Writer, cancellationToken); // use the pipe reader as stream for the new content var newContent = new StreamContent(pipe.Reader.AsStream()); @@ -130,41 +127,29 @@ private static async ValueTask ReadFromStreamAsync(HttpResponseMessage r } #if NET6_0_OR_GREATER - private static async Task BufferStreamAndWriteToPipeAsync(Stream stream, PipeWriter writer, int bufferSize, CancellationToken cancellationToken) + private static async ValueTask BufferStreamAndWriteToPipeAsync(Stream stream, PipeWriter writer, int bufferSize, CancellationToken cancellationToken) { Memory memory = writer.GetMemory(bufferSize)[..bufferSize]; -#if NET8_0_OR_GREATER int bytesRead = await stream.ReadAtLeastAsync(memory, bufferSize, false, cancellationToken).ConfigureAwait(false); -#else - int bytesRead = 0; - while (bytesRead < bufferSize) - { - int read = await stream.ReadAsync(memory.Slice(bytesRead), cancellationToken).ConfigureAwait(false); - if (read == 0) - { - break; - } - - bytesRead += read; - } -#endif - if (bytesRead == 0) { return string.Empty; } + var res = Encoding.UTF8.GetString(memory.Span[..bytesRead]); writer.Advance(bytesRead); - return Encoding.UTF8.GetString(memory[..bytesRead].Span); + return res; } private static async Task WriteStreamToPipeAsync(Stream stream, PipeWriter writer, CancellationToken cancellationToken) { + await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + while (true) { - Memory memory = writer.GetMemory(ChunkSize)[..ChunkSize]; + Memory memory = writer.GetMemory(ChunkSize); int bytesRead = await stream.ReadAsync(memory, cancellationToken).ConfigureAwait(false); if (bytesRead == 0) @@ -216,7 +201,10 @@ private static async Task BufferStreamAndWriteToPipeAsync(Stream stream, return sb.ToString(); } - private static async Task WriteStreamToPipeAsync(Stream stream, PipeWriter writer, CancellationToken cancellationToken) + private static Task WriteStreamToPipeAsync(Stream stream, PipeWriter writer, CancellationToken cancellationToken) + => Task.Run(() => WriteStreamToPipeImplAsync(stream, writer, cancellationToken), CancellationToken.None); + + private static async Task WriteStreamToPipeImplAsync(Stream stream, PipeWriter writer, CancellationToken cancellationToken) { while (true) { diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/IHttpHeadersReader.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/IHttpHeadersReader.cs index 7e21550929e..66da00b5c78 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/IHttpHeadersReader.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/IHttpHeadersReader.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Net.Http; +using Microsoft.Extensions.Compliance.Classification; namespace Microsoft.Extensions.Http.Logging.Internal; @@ -24,4 +25,12 @@ internal interface IHttpHeadersReader /// An instance of to read headers from. /// Destination to save read headers to. void ReadResponseHeaders(HttpResponseMessage response, List>? destination); + + /// + /// Redact values by using a . + /// + /// A value that needs to be redacted. + /// An instance of to redact a value. + /// Redacted value. + string RedactValue(string value, DataClassification classification); } diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/IHttpHeadersRedactor.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/IHttpHeadersRedactor.cs index 3ced3da01b9..3453beed4d0 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/IHttpHeadersRedactor.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/IHttpHeadersRedactor.cs @@ -19,4 +19,12 @@ internal interface IHttpHeadersRedactor /// Data classification which is used to get an appropriate redactor to redact headers. /// Returns text and parameter segments of route. string Redact(IEnumerable headerValues, DataClassification classification); + + /// + /// Redacts HTTP header value which results into a . + /// + /// HTTP header value. + /// Data classification which is used to get an appropriate redactor to redact header. + /// Returns text and parameter segments of route. + string Redact(string headerValue, DataClassification classification); } diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/Log.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/Log.cs index c156eb72419..6edd27217ec 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/Log.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/Log.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Diagnostics.CodeAnalysis; #if NET using System.Globalization; #endif @@ -14,12 +13,14 @@ namespace Microsoft.Extensions.Http.Logging.Internal; /// /// Logs , and the exceptions due to errors of request/response. /// -[SuppressMessage("Major Code Smell", "S109:Magic numbers should not be used", Justification = "Event ID's.")] internal static partial class Log { - internal const string OriginalFormat = "{OriginalFormat}"; + private const int MinimalPropertyCount = 5; - private const int MinimalPropertyCount = 4; + private const string OriginalFormat = "{OriginalFormat}"; + + private const string OriginalFormatValue = + $"{{{HttpClientLoggingTagNames.Method}}} {{{HttpClientLoggingTagNames.Host}}}/{{{HttpClientLoggingTagNames.Path}}}"; private const string RequestReadErrorMessage = "An error occurred while reading the request data to fill the logger context for request: " + @@ -96,9 +97,10 @@ private static void OutgoingRequest( var statusCodePropertyCount = record.StatusCode.HasValue ? 1 : 0; var requestHeadersCount = record.RequestHeaders?.Count ?? 0; var responseHeadersCount = record.ResponseHeaders?.Count ?? 0; + var urlQueryPropertyCount = string.IsNullOrEmpty(record.QueryString) ? 0 : 1; var spaceToReserve = MinimalPropertyCount + statusCodePropertyCount + requestHeadersCount + responseHeadersCount + - record.PathParametersCount + (record.RequestBody is null ? 0 : 1) + (record.ResponseBody is null ? 0 : 1); + record.PathParametersCount + (record.RequestBody is null ? 0 : 1) + (record.ResponseBody is null ? 0 : 1) + urlQueryPropertyCount; var index = loggerMessageState.ReserveTagSpace(spaceToReserve); loggerMessageState.TagArray[index++] = new(HttpClientLoggingTagNames.Method, record.Method); @@ -106,6 +108,11 @@ private static void OutgoingRequest( loggerMessageState.TagArray[index++] = new(HttpClientLoggingTagNames.Path, record.Path); loggerMessageState.TagArray[index++] = new(HttpClientLoggingTagNames.Duration, record.Duration); + if (!string.IsNullOrEmpty(record.QueryString)) + { + loggerMessageState.TagArray[index++] = new(HttpClientLoggingTagNames.UrlQuery, record.QueryString); + } + if (record.StatusCode.HasValue) { loggerMessageState.TagArray[index++] = new(HttpClientLoggingTagNames.StatusCode, record.StatusCode.Value); @@ -133,16 +140,18 @@ private static void OutgoingRequest( if (record.ResponseBody is not null) { - loggerMessageState.TagArray[index] = new(HttpClientLoggingTagNames.ResponseBody, record.ResponseBody); + loggerMessageState.TagArray[index++] = new(HttpClientLoggingTagNames.ResponseBody, record.ResponseBody); } + // "{OriginalFormat}" property needs to be the last tag in the list. + loggerMessageState.TagArray[index] = new(OriginalFormat, OriginalFormatValue); + logger.Log( level, new(eventId, eventName), loggerMessageState, exception, _originalFormatValueFmtFunc); - if (record.EnrichmentTags is null) { loggerMessageState.Clear(); diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/LogRecord.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/LogRecord.cs index 93238e5809c..0ee95787698 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/LogRecord.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/Internal/LogRecord.cs @@ -75,6 +75,11 @@ internal sealed class LogRecord : IResettable /// public int PathParametersCount { get; set; } + /// + /// Gets or sets formatted query parameters. + /// + public string? QueryString { get; set; } + public bool TryReset() { if (PathParameters != null) @@ -94,6 +99,7 @@ public bool TryReset() RequestHeaders = null; ResponseHeaders = null; PathParametersCount = 0; + QueryString = string.Empty; return true; } } diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/LoggingOptions.cs b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/LoggingOptions.cs index fe2d096f7bc..a2e6e41c490 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/LoggingOptions.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Logging/LoggingOptions.cs @@ -36,6 +36,20 @@ public class LoggingOptions /// public bool LogRequestStart { get; set; } + /// + /// Gets or sets the set of HTTP request query parameters to log and their respective data classifications to use for redaction. + /// + /// + /// The default value is . + /// + /// + /// If empty, no HTTP request query parameters will be logged. + /// If the data class is , no redaction will be done. + /// + [Required] + [Experimental(diagnosticId: DiagnosticIds.Experiments.Telemetry, UrlFormat = DiagnosticIds.UrlFormat)] + public IDictionary RequestQueryParametersDataClasses { get; set; } = new Dictionary(); + /// /// Gets or sets a value indicating whether the HTTP request and response body are logged. /// @@ -74,16 +88,12 @@ public class LoggingOptions /// /// Gets or sets the list of HTTP request content types which are considered text and thus possible to serialize. /// - [SuppressMessage("Usage", "CA2227:Collection properties should be read only", - Justification = "Options pattern.")] [Required] public ISet RequestBodyContentTypes { get; set; } = new HashSet(); /// /// Gets or sets the list of HTTP response content types which are considered text and thus possible to serialize. /// - [SuppressMessage("Usage", "CA2227:Collection properties should be read only", - Justification = "Options pattern.")] [Required] public ISet ResponseBodyContentTypes { get; set; } = new HashSet(); @@ -97,8 +107,6 @@ public class LoggingOptions /// If empty, no HTTP request headers will be logged. /// If the data class is , no redaction will be done. /// - [SuppressMessage("Usage", "CA2227:Collection properties should be read only", - Justification = "Options pattern.")] [Required] public IDictionary RequestHeadersDataClasses { get; set; } = new Dictionary(); @@ -112,8 +120,6 @@ public class LoggingOptions /// If the data class is , no redaction will be done. /// If empty, no HTTP response headers will be logged. /// - [SuppressMessage("Usage", "CA2227:Collection properties should be read only", - Justification = "Options pattern.")] [Required] public IDictionary ResponseHeadersDataClasses { get; set; } = new Dictionary(); @@ -142,8 +148,6 @@ public class LoggingOptions /// Gets or sets the route parameters to redact with their corresponding data classifications to apply appropriate redaction. /// [Required] - [SuppressMessage("Usage", "CA2227:Collection properties should be read only", - Justification = "Options pattern.")] public IDictionary RouteParameterDataClasses { get; set; } = new Dictionary(); /// diff --git a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Microsoft.Extensions.Http.Diagnostics.csproj b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Microsoft.Extensions.Http.Diagnostics.csproj index cc00c907ded..bc7b8f4a6fe 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Microsoft.Extensions.Http.Diagnostics.csproj +++ b/src/Libraries/Microsoft.Extensions.Http.Diagnostics/Microsoft.Extensions.Http.Diagnostics.csproj @@ -3,6 +3,7 @@ Microsoft.Extensions.Http.Diagnostics Telemetry support for HTTP Client. Telemetry + $(NetCoreTargetFrameworks);netstandard2.0;net462 @@ -31,13 +32,12 @@ - - - + + diff --git a/src/Libraries/Microsoft.Extensions.Http.Resilience/Hedging/ResilienceHttpClientBuilderExtensions.Hedging.cs b/src/Libraries/Microsoft.Extensions.Http.Resilience/Hedging/ResilienceHttpClientBuilderExtensions.Hedging.cs index 7d46a6efe54..36f0863ad49 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Resilience/Hedging/ResilienceHttpClientBuilderExtensions.Hedging.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Resilience/Hedging/ResilienceHttpClientBuilderExtensions.Hedging.cs @@ -4,7 +4,6 @@ using System; using System.Net.Http; using System.Threading; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Http.Resilience; using Microsoft.Extensions.Http.Resilience.Hedging.Internals; diff --git a/src/Libraries/Microsoft.Extensions.Http.Resilience/Microsoft.Extensions.Http.Resilience.csproj b/src/Libraries/Microsoft.Extensions.Http.Resilience/Microsoft.Extensions.Http.Resilience.csproj index 8d280d747cb..1d1a3684305 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Resilience/Microsoft.Extensions.Http.Resilience.csproj +++ b/src/Libraries/Microsoft.Extensions.Http.Resilience/Microsoft.Extensions.Http.Resilience.csproj @@ -1,6 +1,7 @@  Microsoft.Extensions.Http.Resilience + $(NetCoreTargetFrameworks);netstandard2.0;net462 Resilience mechanisms for HttpClient. Resilience @@ -28,7 +29,7 @@ - $(NoWarn);LA0006 + $(NoWarn);LA0006 @@ -36,10 +37,6 @@ - - - - diff --git a/src/Libraries/Microsoft.Extensions.Http.Resilience/Polly/HttpRetryStrategyOptions.cs b/src/Libraries/Microsoft.Extensions.Http.Resilience/Polly/HttpRetryStrategyOptions.cs index db0a8850e20..7f105586ea6 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Resilience/Polly/HttpRetryStrategyOptions.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Resilience/Polly/HttpRetryStrategyOptions.cs @@ -16,8 +16,6 @@ namespace Microsoft.Extensions.Http.Resilience; /// public class HttpRetryStrategyOptions : RetryStrategyOptions { - private bool _shouldRetryAfterHeader; - /// /// Initializes a new instance of the class. /// @@ -47,12 +45,12 @@ public HttpRetryStrategyOptions() /// public bool ShouldRetryAfterHeader { - get => _shouldRetryAfterHeader; + get; set { - _shouldRetryAfterHeader = value; + field = value; - if (_shouldRetryAfterHeader) + if (field) { DelayGenerator = args => args.Outcome.Result switch { diff --git a/src/Libraries/Microsoft.Extensions.Http.Resilience/Polly/HttpRetryStrategyOptionsExtensions.cs b/src/Libraries/Microsoft.Extensions.Http.Resilience/Polly/HttpRetryStrategyOptionsExtensions.cs index 85168988c7b..eb42ca393fc 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Resilience/Polly/HttpRetryStrategyOptionsExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Resilience/Polly/HttpRetryStrategyOptionsExtensions.cs @@ -4,10 +4,10 @@ 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; +using Polly.Retry; namespace Microsoft.Extensions.Http.Resilience; @@ -52,9 +52,7 @@ public static void DisableFor(this HttpRetryStrategyOptions options, params Http { var result = await shouldHandle(args).ConfigureAwait(args.Context.ContinueOnCapturedContext); - if (result && - args.Outcome.Result is HttpResponseMessage response && - response.RequestMessage is HttpRequestMessage request) + if (result && GetRequestMessage(args) is HttpRequestMessage request) { return !methods.Contains(request.Method); } @@ -62,5 +60,8 @@ args.Outcome.Result is HttpResponseMessage response && return result; }; } + + private static HttpRequestMessage? GetRequestMessage(RetryPredicateArguments args) => + args.Outcome.Result?.RequestMessage ?? args.Context.GetRequestMessage(); } diff --git a/src/Libraries/Microsoft.Extensions.Http.Resilience/Resilience/HttpResilienceContextExtensions.cs b/src/Libraries/Microsoft.Extensions.Http.Resilience/Resilience/HttpResilienceContextExtensions.cs index b04bd37a2ad..02ba9aa63dd 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Resilience/Resilience/HttpResilienceContextExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Resilience/Resilience/HttpResilienceContextExtensions.cs @@ -7,7 +7,6 @@ using Microsoft.Extensions.Http.Resilience.Internal; using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; -using Polly; namespace Polly; diff --git a/src/Libraries/Microsoft.Extensions.Http.Resilience/Resilience/ResilienceHandler.cs b/src/Libraries/Microsoft.Extensions.Http.Resilience/Resilience/ResilienceHandler.cs index 3c1707dcac8..3c376df98e8 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Resilience/Resilience/ResilienceHandler.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Resilience/Resilience/ResilienceHandler.cs @@ -77,12 +77,10 @@ static async (context, state) => return Outcome.FromResult(response); } -#pragma warning disable CA1031 // Do not catch general exception types catch (Exception e) { return Outcome.FromException(e); } -#pragma warning restore CA1031 // Do not catch general exception types }, context, (instance: this, request)) diff --git a/src/Libraries/Microsoft.Extensions.Http.Resilience/Resilience/ResilienceHttpClientBuilderExtensions.Resilience.cs b/src/Libraries/Microsoft.Extensions.Http.Resilience/Resilience/ResilienceHttpClientBuilderExtensions.Resilience.cs index 48a565db41b..f30ebb40f18 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Resilience/Resilience/ResilienceHttpClientBuilderExtensions.Resilience.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Resilience/Resilience/ResilienceHttpClientBuilderExtensions.Resilience.cs @@ -4,7 +4,6 @@ using System; using System.Diagnostics.CodeAnalysis; using System.Net.Http; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.ExceptionSummarization; using Microsoft.Extensions.Http.Resilience; using Microsoft.Extensions.Http.Resilience.Internal; diff --git a/src/Libraries/Microsoft.Extensions.Http.Resilience/Routing/OrderedGroupsRoutingOptions.cs b/src/Libraries/Microsoft.Extensions.Http.Resilience/Routing/OrderedGroupsRoutingOptions.cs index 5783053e3c7..ed7b1e02916 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Resilience/Routing/OrderedGroupsRoutingOptions.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Resilience/Routing/OrderedGroupsRoutingOptions.cs @@ -18,7 +18,6 @@ public class OrderedGroupsRoutingOptions /// /// Gets or sets the collection of ordered endpoints groups. /// -#pragma warning disable CA2227 // Collection properties should be read only #pragma warning disable IL2026 // Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code [Required] #if NET8_0_OR_GREATER @@ -29,5 +28,4 @@ public class OrderedGroupsRoutingOptions [ValidateEnumeratedItems] public IList Groups { get; set; } = new List(); #pragma warning restore IL2026 // Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code -#pragma warning restore CA2227 // Collection properties should be read only } diff --git a/src/Libraries/Microsoft.Extensions.Http.Resilience/Routing/UriEndpoint.cs b/src/Libraries/Microsoft.Extensions.Http.Resilience/Routing/UriEndpoint.cs index 2373144556d..028eaca7762 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Resilience/Routing/UriEndpoint.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Resilience/Routing/UriEndpoint.cs @@ -6,15 +6,11 @@ namespace Microsoft.Extensions.Http.Resilience; -#pragma warning disable IDE0032 // Use auto property - /// /// Represents a URI-based endpoint. /// public class UriEndpoint { - private Uri? _uri; - /// /// Gets or sets the URL of the endpoint. /// @@ -22,9 +18,5 @@ public class UriEndpoint /// Only schema, domain name, and port are used. The rest of the URL is constructed from the request URL. /// [Required] - public Uri? Uri - { - get => _uri; - set => _uri = value; - } + public Uri? Uri { get; set; } } diff --git a/src/Libraries/Microsoft.Extensions.Http.Resilience/Routing/UriEndpointGroup.cs b/src/Libraries/Microsoft.Extensions.Http.Resilience/Routing/UriEndpointGroup.cs index da21d5093ae..c8c3980ab9f 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Resilience/Routing/UriEndpointGroup.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Resilience/Routing/UriEndpointGroup.cs @@ -19,7 +19,6 @@ public class UriEndpointGroup /// The client must define the endpoint for each endpoint group. /// At least one endpoint must be defined on each endpoint group in order to performed hedged requests. /// -#pragma warning disable CA2227 // Collection properties should be read only #pragma warning disable IL2026 // Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code #if NET8_0_OR_GREATER [System.ComponentModel.DataAnnotations.Length(1, int.MaxValue)] @@ -29,5 +28,4 @@ public class UriEndpointGroup [ValidateEnumeratedItems] public IList Endpoints { get; set; } = new List(); #pragma warning restore IL2026 // Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code -#pragma warning restore CA2227 // Collection properties should be read only } diff --git a/src/Libraries/Microsoft.Extensions.Http.Resilience/Routing/WeightedGroupsRoutingOptions.cs b/src/Libraries/Microsoft.Extensions.Http.Resilience/Routing/WeightedGroupsRoutingOptions.cs index 15599856789..1f8114baaf4 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Resilience/Routing/WeightedGroupsRoutingOptions.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Resilience/Routing/WeightedGroupsRoutingOptions.cs @@ -24,7 +24,6 @@ public class WeightedGroupsRoutingOptions /// /// Gets or sets the collection of weighted endpoints groups. /// -#pragma warning disable CA2227 // Collection properties should be read only #pragma warning disable IL2026 // Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code [Required] #if NET8_0_OR_GREATER @@ -35,5 +34,4 @@ public class WeightedGroupsRoutingOptions [ValidateEnumeratedItems] public IList Groups { get; set; } = new List(); #pragma warning restore IL2026 // Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code -#pragma warning restore CA2227 // Collection properties should be read only } diff --git a/src/Libraries/Microsoft.Extensions.Options.Contextual/Internal/ContextualOptionsFactory.cs b/src/Libraries/Microsoft.Extensions.Options.Contextual/Internal/ContextualOptionsFactory.cs index 3de29b7712d..1dd3d6a2054 100644 --- a/src/Libraries/Microsoft.Extensions.Options.Contextual/Internal/ContextualOptionsFactory.cs +++ b/src/Libraries/Microsoft.Extensions.Options.Contextual/Internal/ContextualOptionsFactory.cs @@ -48,7 +48,6 @@ public ContextualOptionsFactory( /// [SuppressMessage("Reliability", "CA2012:Use ValueTasks correctly", Justification = "The ValueTasks are awaited only once.")] - [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "We need to catch it all so we can rethrow it all.")] public ValueTask CreateAsync(string name, in TContext context, CancellationToken cancellationToken) where TContext : notnull, IOptionsContext { diff --git a/src/Libraries/Microsoft.Extensions.Resilience/Microsoft.Extensions.Resilience.csproj b/src/Libraries/Microsoft.Extensions.Resilience/Microsoft.Extensions.Resilience.csproj index ebd63256933..519b6632d07 100644 --- a/src/Libraries/Microsoft.Extensions.Resilience/Microsoft.Extensions.Resilience.csproj +++ b/src/Libraries/Microsoft.Extensions.Resilience/Microsoft.Extensions.Resilience.csproj @@ -1,6 +1,7 @@  Microsoft.Extensions.Resilience + $(NetCoreTargetFrameworks);netstandard2.0;net462 Extensions to the Polly libraries to enrich telemetry with metadata and exception summaries. Resilience diff --git a/src/Libraries/Microsoft.Extensions.Resilience/Resilience/Internal/ResilienceMetricsEnricher.cs b/src/Libraries/Microsoft.Extensions.Resilience/Resilience/Internal/ResilienceMetricsEnricher.cs index 545ce4b6134..6bd3a175969 100644 --- a/src/Libraries/Microsoft.Extensions.Resilience/Resilience/Internal/ResilienceMetricsEnricher.cs +++ b/src/Libraries/Microsoft.Extensions.Resilience/Resilience/Internal/ResilienceMetricsEnricher.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Collections.Generic; using Microsoft.Extensions.Diagnostics.ExceptionSummarization; using Microsoft.Extensions.Http.Diagnostics; using Polly; diff --git a/src/Libraries/Microsoft.Extensions.Resilience/Resilience/ResilienceServiceCollectionExtensions.cs b/src/Libraries/Microsoft.Extensions.Resilience/Resilience/ResilienceServiceCollectionExtensions.cs index 243c046b80e..96fa77e27f7 100644 --- a/src/Libraries/Microsoft.Extensions.Resilience/Resilience/ResilienceServiceCollectionExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.Resilience/Resilience/ResilienceServiceCollectionExtensions.cs @@ -6,7 +6,6 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Diagnostics.ExceptionSummarization; using Microsoft.Extensions.Http.Diagnostics; -using Microsoft.Extensions.Options; using Microsoft.Extensions.Resilience.Internal; using Microsoft.Shared.Diagnostics; using Polly.Telemetry; diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/IHostNameFeature.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/IHostNameFeature.cs new file mode 100644 index 00000000000..c7489472374 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/IHostNameFeature.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. + +namespace Microsoft.Extensions.ServiceDiscovery; + +/// +/// Exposes the host name of the end point. +/// +public interface IHostNameFeature +{ + /// + /// Gets the host name of the end point. + /// + public string HostName { get; } +} + diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/IServiceEndpointBuilder.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/IServiceEndpointBuilder.cs new file mode 100644 index 00000000000..e051b2bf746 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/IServiceEndpointBuilder.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 Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.Extensions.ServiceDiscovery; + +/// +/// Builder to create a instances. +/// +public interface IServiceEndpointBuilder +{ + /// + /// Gets the endpoints. + /// + IList Endpoints { get; } + + /// + /// Gets the feature collection. + /// + IFeatureCollection Features { get; } + + /// + /// Adds a change token to the resulting . + /// + /// The change token. + void AddChangeToken(IChangeToken changeToken); +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/IServiceEndpointProvider.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/IServiceEndpointProvider.cs new file mode 100644 index 00000000000..4a192180b66 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/IServiceEndpointProvider.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. + +namespace Microsoft.Extensions.ServiceDiscovery; + +/// +/// Provides details about a service's endpoints. +/// +public interface IServiceEndpointProvider : IAsyncDisposable +{ + /// + /// Resolves the endpoints for the service. + /// + /// The endpoint collection, which resolved endpoints will be added to. + /// The token to monitor for cancellation requests. + /// The resolution status. + ValueTask PopulateAsync(IServiceEndpointBuilder endpoints, CancellationToken cancellationToken); +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/IServiceEndpointProviderFactory.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/IServiceEndpointProviderFactory.cs new file mode 100644 index 00000000000..009cbf05d76 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/IServiceEndpointProviderFactory.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. + +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Extensions.ServiceDiscovery; + +/// +/// Creates instances. +/// +public interface IServiceEndpointProviderFactory +{ + /// + /// Tries to create an instance for the specified . + /// + /// The service to create the provider for. + /// The provider. + /// if the provider was created, otherwise. + bool TryCreateProvider(ServiceEndpointQuery query, [NotNullWhen(true)] out IServiceEndpointProvider? provider); +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/Internal/ServiceEndpointImpl.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/Internal/ServiceEndpointImpl.cs new file mode 100644 index 00000000000..151a9309338 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/Internal/ServiceEndpointImpl.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using Microsoft.AspNetCore.Http.Features; + +namespace Microsoft.Extensions.ServiceDiscovery.Internal; + +internal sealed class ServiceEndpointImpl(EndPoint endPoint, IFeatureCollection? features = null) : ServiceEndpoint +{ + public override EndPoint EndPoint { get; } = endPoint; + + public override IFeatureCollection Features { get; } = features ?? new FeatureCollection(); + + public override string? ToString() => EndPoint switch + { + IPEndPoint ip when ip.Port == 0 && ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 => $"[{ip.Address}]", + IPEndPoint ip when ip.Port == 0 => $"{ip.Address}", + DnsEndPoint dns when dns.Port == 0 => $"{dns.Host}", + DnsEndPoint dns => $"{dns.Host}:{dns.Port}", + _ => EndPoint.ToString()! + }; +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/Microsoft.Extensions.ServiceDiscovery.Abstractions.csproj b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/Microsoft.Extensions.ServiceDiscovery.Abstractions.csproj new file mode 100644 index 00000000000..b96d5ff7137 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/Microsoft.Extensions.ServiceDiscovery.Abstractions.csproj @@ -0,0 +1,39 @@ + + + + $(TargetFrameworks);netstandard2.0 + true + Provides abstractions for service discovery. Interfaces defined in this package are implemented in Microsoft.Extensions.ServiceDiscovery and other service discovery packages. + Open + true + Microsoft.Extensions.ServiceDiscovery + + $(NoWarn);S1144;CA1002;S2365;SA1642;IDE0040;CA1307;EA0009;LA0003 + enable + + + + ServiceDiscovery + normal + 9.5.1 + 75 + 75 + + + + + + + + + + + + + + + + + + + diff --git a/src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.json b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/Microsoft.Extensions.ServiceDiscovery.Abstractions.json similarity index 100% rename from src/Libraries/Microsoft.Extensions.AI.Ollama/Microsoft.Extensions.AI.Ollama.json rename to src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/Microsoft.Extensions.ServiceDiscovery.Abstractions.json diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/README.md b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/README.md new file mode 100644 index 00000000000..0d97211313e --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/README.md @@ -0,0 +1,7 @@ +# Microsoft.Extensions.ServiceDiscovery.Abstractions + +The `Microsoft.Extensions.ServiceDiscovery.Abstractions` library provides abstractions used by the `Microsoft.Extensions.ServiceDiscovery` library and other libraries which implement service discovery extensions, such as service endpoint providers. For more information, see [Service discovery in .NET](https://learn.microsoft.com/dotnet/core/extensions/service-discovery). + +## Feedback & contributing + +https://github.com/dotnet/aspire diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/ServiceEndpoint.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/ServiceEndpoint.cs new file mode 100644 index 00000000000..e5a10374adc --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/ServiceEndpoint.cs @@ -0,0 +1,86 @@ +// 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.Net; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.ServiceDiscovery.Internal; + +namespace Microsoft.Extensions.ServiceDiscovery; + +/// +/// Represents an endpoint for a service. +/// +public abstract class ServiceEndpoint +{ + /// + /// Gets the endpoint. + /// + public abstract EndPoint EndPoint { get; } + + /// + /// Gets the collection of endpoint features. + /// + public abstract IFeatureCollection Features { get; } + + /// + /// Creates a new . + /// + /// The endpoint being represented. + /// Features of the endpoint. + /// A newly initialized . + public static ServiceEndpoint Create(EndPoint endPoint, IFeatureCollection? features = null) + { + ArgumentNullException.ThrowIfNull(endPoint); + + return new ServiceEndpointImpl(endPoint, features); + } + + /// + /// Tries to convert a specified string representation to its equivalent, + /// and returns a value that indicates whether the conversion succeeded. + /// + /// A string that consists of an IP address or hostname, optionally followed by a colon and port number, or a URI. + /// When this method returns, contains the equivalent if the conversion succeeded; otherwise, + /// . This parameter is passed uninitialized; any value originally supplied will be overwritten. + /// if the string was successfully parsed into a ; otherwise, . + public static bool TryParse([NotNullWhen(true)] string? value, + [NotNullWhen(true)] out ServiceEndpoint? serviceEndpoint) + { + EndPoint? endPoint = TryParseEndPoint(value); + + if (endPoint != null) + { + serviceEndpoint = Create(endPoint); + return true; + } + else + { + serviceEndpoint = null; + return false; + } + } + + private static EndPoint? TryParseEndPoint(string? value) + { + if (!string.IsNullOrWhiteSpace(value)) + { +#pragma warning disable CS8602 + if (value.IndexOf("://", StringComparison.Ordinal) < 0 && Uri.TryCreate($"fakescheme://{value}", default, out var uri)) +#pragma warning restore CS8602 + { + var port = uri.Port > 0 ? uri.Port : 0; + return IPAddress.TryParse(uri.Host, out var ip) + ? new IPEndPoint(ip, port) + : new DnsEndPoint(uri.Host, port); + } + + if (Uri.TryCreate(value, default, out uri)) + { + return new UriEndPoint(uri); + } + } + + return null; + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/ServiceEndpointQuery.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/ServiceEndpointQuery.cs new file mode 100644 index 00000000000..36fca0893cc --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/ServiceEndpointQuery.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.Diagnostics.CodeAnalysis; + +namespace Microsoft.Extensions.ServiceDiscovery; + +/// +/// Describes a query for endpoints of a service. +/// +public sealed class ServiceEndpointQuery +{ + private readonly string _originalString; + + /// + /// Initializes a new instance. + /// + /// The string which the query was constructed from. + /// The ordered list of included URI schemes. + /// The service name. + /// The optional endpoint name. + private ServiceEndpointQuery(string originalString, string[] includedSchemes, string serviceName, string? endpointName) + { + _originalString = originalString; + IncludedSchemes = includedSchemes; + ServiceName = serviceName; + EndpointName = endpointName; + } + + /// + /// Tries to parse the provided input as a service endpoint query. + /// + /// The value to parse. + /// The resulting query. + /// if the value was successfully parsed; otherwise . + public static bool TryParse(string input, [NotNullWhen(true)] out ServiceEndpointQuery? query) + { + ArgumentException.ThrowIfNullOrEmpty(input); + + bool hasScheme; + if (!input.Contains("://", StringComparison.Ordinal) + && Uri.TryCreate($"fakescheme://{input}", default, out var uri)) + { + hasScheme = false; + } + else if (Uri.TryCreate(input, default, out uri)) + { + hasScheme = true; + } + else + { + query = null; + return false; + } + + var uriHost = uri.Host; + var segmentSeparatorIndex = uriHost.IndexOf('.'); + string host; + string? endpointName = null; + if (uriHost.StartsWith('_') && segmentSeparatorIndex > 1 && uriHost[^1] != '.') + { + endpointName = uriHost[1..segmentSeparatorIndex]; + + // Skip the endpoint name, including its prefix ('_') and suffix ('.'). + host = uriHost[(segmentSeparatorIndex + 1)..]; + } + else + { + host = uriHost; + } + + // Allow multiple schemes to be separated by a '+', eg. "https+http://host:port". + var schemes = hasScheme ? uri.Scheme.Split('+') : []; + query = new(input, schemes, host, endpointName); + return true; + } + + /// + /// Gets the ordered list of included URI schemes. + /// + public IReadOnlyList IncludedSchemes { get; } + + /// + /// Gets the endpoint name, or if no endpoint name is specified. + /// + public string? EndpointName { get; } + + /// + /// Gets the service name. + /// + public string ServiceName { get; } + + /// + public override string? ToString() => _originalString; +} + diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/ServiceEndpointSource.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/ServiceEndpointSource.cs new file mode 100644 index 00000000000..28d987a2f34 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/ServiceEndpointSource.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.Diagnostics; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.Extensions.ServiceDiscovery; + +/// +/// Represents a collection of service endpoints. +/// +[DebuggerDisplay("{ToString(),nq}")] +[DebuggerTypeProxy(typeof(ServiceEndpointCollectionDebuggerView))] +public sealed class ServiceEndpointSource +{ + private readonly List? _endpoints; + + /// + /// Initializes a new instance. + /// + /// The endpoints. + /// The change token. + /// The feature collection. + public ServiceEndpointSource(List? endpoints, IChangeToken changeToken, IFeatureCollection features) + { + ArgumentNullException.ThrowIfNull(changeToken); + ArgumentNullException.ThrowIfNull(features); + + _endpoints = endpoints; + Features = features; + ChangeToken = changeToken; + } + + /// + /// Gets the endpoints. + /// + public IReadOnlyList Endpoints => _endpoints ?? (IReadOnlyList)[]; + + /// + /// Gets the change token which indicates when this collection should be refreshed. + /// + public IChangeToken ChangeToken { get; } + + /// + /// Gets the feature collection. + /// + public IFeatureCollection Features { get; } + + /// + public override string ToString() + { + if (_endpoints is not { } eps) + { + return "[]"; + } + + return $"[{string.Join(", ", eps)}]"; + } + + private sealed class ServiceEndpointCollectionDebuggerView(ServiceEndpointSource value) + { + public IChangeToken ChangeToken => value.ChangeToken; + + public IFeatureCollection Features => value.Features; + + [DebuggerBrowsable(DebuggerBrowsableState.RootHidden)] + public ServiceEndpoint[] Endpoints => value.Endpoints.ToArray(); + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/UriEndPoint.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/UriEndPoint.cs new file mode 100644 index 00000000000..d7bb1ab040e --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Abstractions/UriEndPoint.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; + +namespace Microsoft.Extensions.ServiceDiscovery; + +/// +/// An endpoint represented by a . +/// +public class UriEndPoint : EndPoint +{ + /// + /// Creates a new . + /// + /// The . + public UriEndPoint(Uri uri) + { + ArgumentNullException.ThrowIfNull(uri); + Uri = uri; + } + + /// + /// Gets the associated with this endpoint. + /// + public Uri Uri { get; } + + /// + public override bool Equals(object? obj) + { + return obj is UriEndPoint other && Uri.Equals(other.Uri); + } + + /// + public override int GetHashCode() => Uri.GetHashCode(); + + /// + public override string? ToString() => Uri.ToString(); +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsResolverOptions.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsResolverOptions.cs new file mode 100644 index 00000000000..0cc256bd3c3 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsResolverOptions.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.Net; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns; + +/// +/// Provides configuration options for DNS resolution, including server endpoints, retry attempts, and timeout settings. +/// +public class DnsResolverOptions +{ + /// + /// Gets or sets the collection of server endpoints used for network connections. + /// + public IList Servers { get; set; } = new List(); + + /// + /// Gets or sets the maximum number of attempts per server. + /// + public int MaxAttempts { get; set; } = 2; + + /// + /// Gets or sets the maximum duration per attempt to wait before timing out. + /// + /// + /// The maximum time for resolving a query is * count * . + /// + public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(3); + + // override for testing purposes + internal Func, int, int>? _transportOverride; +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsResolverOptionsValidator.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsResolverOptionsValidator.cs new file mode 100644 index 00000000000..d61da5e5e0b --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsResolverOptionsValidator.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns; + +internal sealed class DnsResolverOptionsValidator : IValidateOptions +{ + // CancellationTokenSource.CancelAfter has a maximum timeout of Int32.MaxValue milliseconds. + private static readonly TimeSpan s_maxTimeout = TimeSpan.FromMilliseconds(int.MaxValue); + + public ValidateOptionsResult Validate(string? name, DnsResolverOptions options) + { + if (options.Servers is null) + { + return ValidateOptionsResult.Fail($"{nameof(options.Servers)} must not be null."); + } + + if (options.MaxAttempts < 1) + { + return ValidateOptionsResult.Fail($"{nameof(options.MaxAttempts)} must be one or greater."); + } + + if (options.Timeout != Timeout.InfiniteTimeSpan) + { + if (options.Timeout <= TimeSpan.Zero) + { + return ValidateOptionsResult.Fail($"{nameof(options.Timeout)} must not be negative or zero."); + } + + if (options.Timeout > s_maxTimeout) + { + return ValidateOptionsResult.Fail($"{nameof(options.Timeout)} must not be greater than {s_maxTimeout.TotalMilliseconds} milliseconds."); + } + } + + return ValidateOptionsResult.Success; + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsServiceEndpointProvider.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsServiceEndpointProvider.cs new file mode 100644 index 00000000000..7a2d1b632e0 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsServiceEndpointProvider.cs @@ -0,0 +1,69 @@ +// 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; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns; + +internal sealed partial class DnsServiceEndpointProvider( + ServiceEndpointQuery query, + string hostName, + IOptionsMonitor options, + ILogger logger, + IDnsResolver resolver, + TimeProvider timeProvider) : DnsServiceEndpointProviderBase(query, logger, timeProvider), IHostNameFeature +{ + protected override double RetryBackOffFactor => options.CurrentValue.RetryBackOffFactor; + protected override TimeSpan MinRetryPeriod => options.CurrentValue.MinRetryPeriod; + protected override TimeSpan MaxRetryPeriod => options.CurrentValue.MaxRetryPeriod; + protected override TimeSpan DefaultRefreshPeriod => options.CurrentValue.DefaultRefreshPeriod; + + string IHostNameFeature.HostName => hostName; + + /// + public override string ToString() => "DNS"; + + protected override async Task ResolveAsyncCore() + { + var endpoints = new List(); + var ttl = DefaultRefreshPeriod; + Log.AddressQuery(logger, ServiceName, hostName); + + var now = _timeProvider.GetUtcNow().DateTime; + var addresses = await resolver.ResolveIPAddressesAsync(hostName, ShutdownToken).ConfigureAwait(false); + + foreach (var address in addresses) + { + ttl = MinTtl(now, address.ExpiresAt, ttl); + endpoints.Add(CreateEndpoint(new IPEndPoint(address.Address, port: 0))); + } + + if (endpoints.Count == 0) + { + throw new InvalidOperationException($"No DNS records were found for service '{ServiceName}' (DNS name: '{hostName}')."); + } + + SetResult(endpoints, ttl); + + static TimeSpan MinTtl(DateTime now, DateTime expiresAt, TimeSpan existing) + { + var candidate = expiresAt - now; + return candidate < existing ? candidate : existing; + } + + ServiceEndpoint CreateEndpoint(EndPoint endPoint) + { + var serviceEndpoint = ServiceEndpoint.Create(endPoint); + serviceEndpoint.Features.Set(this); + if (options.CurrentValue.ShouldApplyHostNameMetadata(serviceEndpoint)) + { + serviceEndpoint.Features.Set(this); + } + + return serviceEndpoint; + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsServiceEndpointProviderBase.Log.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsServiceEndpointProviderBase.Log.cs new file mode 100644 index 00000000000..29aaaf8e930 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsServiceEndpointProviderBase.Log.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 Microsoft.Extensions.Logging; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns; + +partial class DnsServiceEndpointProviderBase +{ + internal static partial class Log + { + [LoggerMessage(1, LogLevel.Trace, "Resolving endpoints for service '{ServiceName}' using DNS SRV lookup for name '{RecordName}'.", EventName = "SrvQuery")] + public static partial void SrvQuery(ILogger logger, string serviceName, string recordName); + + [LoggerMessage(2, LogLevel.Trace, "Resolving endpoints for service '{ServiceName}' using host lookup for name '{RecordName}'.", EventName = "AddressQuery")] + public static partial void AddressQuery(ILogger logger, string serviceName, string recordName); + + [LoggerMessage(3, LogLevel.Debug, "Skipping endpoint resolution for service '{ServiceName}': '{Reason}'.", EventName = "SkippedResolution")] + public static partial void SkippedResolution(ILogger logger, string serviceName, string reason); + + [LoggerMessage(4, LogLevel.Debug, "Service name '{ServiceName}' is not a valid URI or DNS name.", EventName = "ServiceNameIsNotUriOrDnsName")] + public static partial void ServiceNameIsNotUriOrDnsName(ILogger logger, string serviceName); + + [LoggerMessage(5, LogLevel.Debug, "DNS SRV query cannot be constructed for service name '{ServiceName}' because no DNS namespace was configured or detected.", EventName = "NoDnsSuffixFound")] + public static partial void NoDnsSuffixFound(ILogger logger, string serviceName); + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsServiceEndpointProviderBase.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsServiceEndpointProviderBase.cs new file mode 100644 index 00000000000..311c06f631a --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsServiceEndpointProviderBase.cs @@ -0,0 +1,164 @@ +// 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 Microsoft.Extensions.Primitives; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns; + +/// +/// A service end point provider that uses DNS to resolve the service end points. +/// +internal abstract partial class DnsServiceEndpointProviderBase : IServiceEndpointProvider +{ + private readonly object _lock = new(); + private readonly ILogger _logger; + private readonly CancellationTokenSource _disposeCancellation = new(); + protected readonly TimeProvider _timeProvider; + private long _lastRefreshTimeStamp; + private Task _resolveTask = Task.CompletedTask; + private bool _hasEndpoints; + private CancellationChangeToken _lastChangeToken; + private CancellationTokenSource _lastCollectionCancellation; + private List? _lastEndpointCollection; + private TimeSpan _nextRefreshPeriod; + + /// + /// Initializes a new instance. + /// + /// The service name. + /// The logger. + /// The time provider. + protected DnsServiceEndpointProviderBase( + ServiceEndpointQuery query, + ILogger logger, + TimeProvider timeProvider) + { + ServiceName = query.ToString()!; + _logger = logger; + _lastEndpointCollection = null; + _timeProvider = timeProvider; + _lastRefreshTimeStamp = _timeProvider.GetTimestamp(); + var cancellation = _lastCollectionCancellation = new CancellationTokenSource(); + _lastChangeToken = new CancellationChangeToken(cancellation.Token); + } + + private TimeSpan ElapsedSinceRefresh => _timeProvider.GetElapsedTime(_lastRefreshTimeStamp); + + protected string ServiceName { get; } + + protected abstract double RetryBackOffFactor { get; } + + protected abstract TimeSpan MinRetryPeriod { get; } + + protected abstract TimeSpan MaxRetryPeriod { get; } + + protected abstract TimeSpan DefaultRefreshPeriod { get; } + + protected CancellationToken ShutdownToken => _disposeCancellation.Token; + + /// + public async ValueTask PopulateAsync(IServiceEndpointBuilder endpoints, CancellationToken cancellationToken) + { + // Only add endpoints to the collection if a previous provider (eg, a configuration override) did not add them. + if (endpoints.Endpoints.Count != 0) + { + Log.SkippedResolution(_logger, ServiceName, "Collection has existing endpoints"); + return; + } + + if (ShouldRefresh()) + { + Task resolveTask; + lock (_lock) + { + if (_resolveTask.IsCompleted && ShouldRefresh()) + { + _resolveTask = ResolveAsyncCore(); + } + + resolveTask = _resolveTask; + } + + await resolveTask.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + lock (_lock) + { + if (_lastEndpointCollection is { Count: > 0 } eps) + { + foreach (var ep in eps) + { + endpoints.Endpoints.Add(ep); + } + } + + endpoints.AddChangeToken(_lastChangeToken); + return; + } + } + + private bool ShouldRefresh() => _lastEndpointCollection is null || _lastChangeToken is { HasChanged: true } || ElapsedSinceRefresh >= _nextRefreshPeriod; + + protected abstract Task ResolveAsyncCore(); + + protected void SetResult(List endpoints, TimeSpan validityPeriod) + { + lock (_lock) + { + if (endpoints is { Count: > 0 }) + { + _lastRefreshTimeStamp = _timeProvider.GetTimestamp(); + _nextRefreshPeriod = DefaultRefreshPeriod; + _hasEndpoints = true; + } + else + { + _nextRefreshPeriod = GetRefreshPeriod(); + validityPeriod = TimeSpan.Zero; + _hasEndpoints = false; + } + + if (validityPeriod <= TimeSpan.Zero) + { + validityPeriod = _nextRefreshPeriod; + } + else if (validityPeriod > _nextRefreshPeriod) + { + validityPeriod = _nextRefreshPeriod; + } + + _lastCollectionCancellation.Cancel(); + var cancellation = _lastCollectionCancellation = new CancellationTokenSource(validityPeriod, _timeProvider); + _lastChangeToken = new CancellationChangeToken(cancellation.Token); + _lastEndpointCollection = endpoints; + } + + TimeSpan GetRefreshPeriod() + { + if (_hasEndpoints) + { + return MinRetryPeriod; + } + + var nextTicks = (long)(_nextRefreshPeriod.Ticks * RetryBackOffFactor); + if (nextTicks <= 0 || nextTicks > MaxRetryPeriod.Ticks) + { + return MaxRetryPeriod; + } + + return TimeSpan.FromTicks(nextTicks); + } + } + + /// + public async ValueTask DisposeAsync() + { + _disposeCancellation.Cancel(); + + if (_resolveTask is { } task) + { + await task.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsServiceEndpointProviderFactory.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsServiceEndpointProviderFactory.cs new file mode 100644 index 00000000000..1da21411e64 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsServiceEndpointProviderFactory.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns; + +internal sealed partial class DnsServiceEndpointProviderFactory( + IOptionsMonitor options, + ILogger logger, + IDnsResolver resolver, + TimeProvider timeProvider) : IServiceEndpointProviderFactory +{ + /// + public bool TryCreateProvider(ServiceEndpointQuery query, [NotNullWhen(true)] out IServiceEndpointProvider? provider) + { + provider = new DnsServiceEndpointProvider(query, hostName: query.ServiceName, options, logger, resolver, timeProvider); + return true; + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsServiceEndpointProviderOptions.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsServiceEndpointProviderOptions.cs new file mode 100644 index 00000000000..b163afc76ff --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsServiceEndpointProviderOptions.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.ServiceDiscovery.Dns; + +/// +/// Options for configuring . +/// +public class DnsServiceEndpointProviderOptions +{ + /// + /// Gets or sets the default refresh period for endpoints resolved from DNS. + /// + public TimeSpan DefaultRefreshPeriod { get; set; } = TimeSpan.FromMinutes(1); + + /// + /// Gets or sets the initial period between retries. + /// + public TimeSpan MinRetryPeriod { get; set; } = TimeSpan.FromSeconds(1); + + /// + /// Gets or sets the maximum period between retries. + /// + public TimeSpan MaxRetryPeriod { get; set; } = TimeSpan.FromSeconds(30); + + /// + /// Gets or sets the retry period growth factor. + /// + public double RetryBackOffFactor { get; set; } = 2; + + /// + /// Gets or sets a delegate used to determine whether to apply host name metadata to each resolved endpoint. Defaults to false. + /// + public Func ShouldApplyHostNameMetadata { get; set; } = _ => false; +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsSrvServiceEndpointProvider.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsSrvServiceEndpointProvider.cs new file mode 100644 index 00000000000..6d5ade5059e --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsSrvServiceEndpointProvider.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.Net; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns; + +internal sealed partial class DnsSrvServiceEndpointProvider( + ServiceEndpointQuery query, + string srvQuery, + string hostName, + IOptionsMonitor options, + ILogger logger, + IDnsResolver resolver, + TimeProvider timeProvider) : DnsServiceEndpointProviderBase(query, logger, timeProvider), IHostNameFeature +{ + protected override double RetryBackOffFactor => options.CurrentValue.RetryBackOffFactor; + + protected override TimeSpan MinRetryPeriod => options.CurrentValue.MinRetryPeriod; + + protected override TimeSpan MaxRetryPeriod => options.CurrentValue.MaxRetryPeriod; + + protected override TimeSpan DefaultRefreshPeriod => options.CurrentValue.DefaultRefreshPeriod; + + public override string ToString() => "DNS SRV"; + + string IHostNameFeature.HostName => hostName; + + protected override async Task ResolveAsyncCore() + { + var endpoints = new List(); + var ttl = DefaultRefreshPeriod; + Log.SrvQuery(logger, ServiceName, srvQuery); + + var now = _timeProvider.GetUtcNow().DateTime; + var result = await resolver.ResolveServiceAsync(srvQuery, cancellationToken: ShutdownToken).ConfigureAwait(false); + + foreach (var record in result) + { + ttl = MinTtl(now, record.ExpiresAt, ttl); + + if (record.Addresses.Length > 0) + { + foreach (var address in record.Addresses) + { + ttl = MinTtl(now, address.ExpiresAt, ttl); + endpoints.Add(CreateEndpoint(new IPEndPoint(address.Address, record.Port))); + } + } + else + { + endpoints.Add(CreateEndpoint(new DnsEndPoint(record.Target.TrimEnd('.'), record.Port))); + } + } + + SetResult(endpoints, ttl); + + static TimeSpan MinTtl(DateTime now, DateTime expiresAt, TimeSpan existing) + { + var candidate = expiresAt - now; + return candidate < existing ? candidate : existing; + } + + ServiceEndpoint CreateEndpoint(EndPoint endPoint) + { + var serviceEndpoint = ServiceEndpoint.Create(endPoint); + serviceEndpoint.Features.Set(this); + if (options.CurrentValue.ShouldApplyHostNameMetadata(serviceEndpoint)) + { + serviceEndpoint.Features.Set(this); + } + + return serviceEndpoint; + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsSrvServiceEndpointProviderFactory.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsSrvServiceEndpointProviderFactory.cs new file mode 100644 index 00000000000..ef593a7340c --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsSrvServiceEndpointProviderFactory.cs @@ -0,0 +1,142 @@ +// 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.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns; + +internal sealed partial class DnsSrvServiceEndpointProviderFactory( + IOptionsMonitor options, + ILogger logger, + IDnsResolver resolver, + TimeProvider timeProvider) : IServiceEndpointProviderFactory +{ + private static readonly string s_serviceAccountPath = Path.Combine($"{Path.DirectorySeparatorChar}var", "run", "secrets", "kubernetes.io", "serviceaccount"); + private static readonly string s_serviceAccountNamespacePath = Path.Combine($"{Path.DirectorySeparatorChar}var", "run", "secrets", "kubernetes.io", "serviceaccount", "namespace"); + private static readonly string s_resolveConfPath = Path.Combine($"{Path.DirectorySeparatorChar}etc", "resolv.conf"); + private readonly string? _querySuffix = options.CurrentValue.QuerySuffix?.TrimStart('.') ?? GetKubernetesHostDomain(); + + /// + public bool TryCreateProvider(ServiceEndpointQuery query, [NotNullWhen(true)] out IServiceEndpointProvider? provider) + { + var optionsValue = options.CurrentValue; + + // If a default namespace is not specified, then this provider will attempt to infer the namespace from the service name, but only when running inside Kubernetes. + // Kubernetes DNS spec: https://github.com/kubernetes/dns/blob/master/docs/specification.md + // SRV records are available for headless services with named ports. + // They take the form $"_{portName}._{protocol}.{serviceName}.{namespace}.{suffix}" + // The suffix (after the service name) can be parsed from /etc/resolv.conf + // Otherwise, the namespace can be read from /var/run/secrets/kubernetes.io/serviceaccount/namespace and combined with an assumed suffix of "svc.cluster.local". + // The protocol is assumed to be "tcp". + // The portName is the name of the port in the service definition. If the serviceName parses as a URI, we use the scheme as the port name, otherwise "default". + if (optionsValue.ServiceDomainNameCallback == null && string.IsNullOrWhiteSpace(_querySuffix)) + { + DnsServiceEndpointProviderBase.Log.NoDnsSuffixFound(logger, query.ToString()!); + provider = default; + return false; + } + + var srvQuery = optionsValue.ServiceDomainNameCallback != null + ? optionsValue.ServiceDomainNameCallback(query) + : DefaultServiceDomainNameCallback(query, optionsValue); + provider = new DnsSrvServiceEndpointProvider(query, srvQuery, hostName: query.ServiceName, options, logger, resolver, timeProvider); + return true; + } + + private static string DefaultServiceDomainNameCallback(ServiceEndpointQuery query, DnsSrvServiceEndpointProviderOptions options) + { + var portName = query.EndpointName ?? "default"; + return $"_{portName}._tcp.{query.ServiceName}.{options.QuerySuffix}"; + } + + private static string? GetKubernetesHostDomain() + { + // Check that we are running in Kubernetes first. + if (!IsInKubernetesCluster()) + { + return null; + } + + if (!OperatingSystem.IsLinux()) + { + return null; + } + + var qualifiedNamespace = ReadQualifiedNamespaceFromResolvConf(); + if (!string.IsNullOrWhiteSpace(qualifiedNamespace)) + { + return qualifiedNamespace; + } + + var serviceAccountNamespace = ReadNamespaceFromKubernetesServiceAccount(); + if (!string.IsNullOrWhiteSpace(serviceAccountNamespace)) + { + // The zone is assumed to be "cluster.local" + return $"{serviceAccountNamespace}.svc.cluster.local"; + } + + return null; + } + + private static string? ReadNamespaceFromKubernetesServiceAccount() + { + // Read the namespace from the Kubernetes pod's service account. + if (File.Exists(s_serviceAccountNamespacePath)) + { + return File.ReadAllText(s_serviceAccountNamespacePath).Trim(); + } + + return null; + } + + private static string? ReadQualifiedNamespaceFromResolvConf() + { + if (!File.Exists(s_resolveConfPath)) + { + return default; + } + + // See https://manpages.debian.org/bookworm/manpages/resolv.conf.5.en.html#search for the format of /etc/resolv.conf's search option. + // In our case, we are interested in determining the domain name. + var lines = File.ReadAllLines(s_resolveConfPath); + foreach (var line in lines) + { + if (!line.StartsWith("search ", StringComparison.Ordinal)) + { + continue; + } + + var components = line.Split(' ', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + if (components.Length > 1) + { + return components[1]; + } + } + + return default; + } + + private static bool IsInKubernetesCluster() + { + // This logic is based on the Kubernetes C# client logic found here: + // https://github.com/kubernetes-client/csharp/blob/52c3c00d4c55b28bdb491a219f4967823a83df2d/src/KubernetesClient/KubernetesClientConfiguration.InCluster.cs#L21 + var host = Environment.GetEnvironmentVariable("KUBERNETES_SERVICE_HOST"); + var port = Environment.GetEnvironmentVariable("KUBERNETES_SERVICE_PORT"); + if (string.IsNullOrEmpty(host) || string.IsNullOrEmpty(port)) + { + return false; + } + + var tokenPath = Path.Combine(s_serviceAccountPath, "token"); + if (!File.Exists(tokenPath)) + { + return false; + } + + var certPath = Path.Combine(s_serviceAccountPath, "ca.crt"); + return File.Exists(certPath); + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsSrvServiceEndpointProviderOptions.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsSrvServiceEndpointProviderOptions.cs new file mode 100644 index 00000000000..c1d64136cc9 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/DnsSrvServiceEndpointProviderOptions.cs @@ -0,0 +1,48 @@ +// 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.ServiceDiscovery.Dns; + +/// +/// Options for configuring . +/// +public class DnsSrvServiceEndpointProviderOptions +{ + /// + /// Gets or sets the default refresh period for endpoints resolved from DNS. + /// + public TimeSpan DefaultRefreshPeriod { get; set; } = TimeSpan.FromMinutes(1); + + /// + /// Gets or sets the initial period between retries. + /// + public TimeSpan MinRetryPeriod { get; set; } = TimeSpan.FromSeconds(1); + + /// + /// Gets or sets the maximum period between retries. + /// + public TimeSpan MaxRetryPeriod { get; set; } = TimeSpan.FromSeconds(30); + + /// + /// Gets or sets the retry period growth factor. + /// + public double RetryBackOffFactor { get; set; } = 2; + + /// + /// Gets or sets the default DNS query suffix for services resolved via this provider. + /// + /// + /// If not specified, the provider will attempt to infer the namespace. + /// + public string? QuerySuffix { get; set; } + + /// + /// Gets or sets a delegate that generates a DNS SRV query from a specified instance. + /// + public Func? ServiceDomainNameCallback { get; set; } + + /// + /// Gets or sets a delegate used to determine whether to apply host name metadata to each resolved endpoint. Defaults to false. + /// + public Func ShouldApplyHostNameMetadata { get; set; } = _ => false; +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/FallbackDnsResolver.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/FallbackDnsResolver.cs new file mode 100644 index 00000000000..1cdcab2f05d --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/FallbackDnsResolver.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 DnsClient; +using DnsClient.Protocol; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns; + +internal sealed class FallbackDnsResolver : IDnsResolver +{ + private readonly LookupClient _lookupClient; + private readonly IOptionsMonitor _options; + private readonly TimeProvider _timeProvider; + + public FallbackDnsResolver(LookupClient lookupClient, IOptionsMonitor options, TimeProvider timeProvider) + { + _lookupClient = lookupClient; + _options = options; + _timeProvider = timeProvider; + } + + private TimeSpan DefaultRefreshPeriod => _options.CurrentValue.DefaultRefreshPeriod; + + public async ValueTask ResolveIPAddressesAsync(string name, CancellationToken cancellationToken = default) + { + DateTime expiresAt = _timeProvider.GetUtcNow().DateTime.Add(DefaultRefreshPeriod); + var addresses = await System.Net.Dns.GetHostAddressesAsync(name, cancellationToken).ConfigureAwait(false); + + var results = new AddressResult[addresses.Length]; + + for (int i = 0; i < addresses.Length; i++) + { + results[i] = new AddressResult + { + Address = addresses[i], + ExpiresAt = expiresAt + }; + } + + return results; + } + + public async ValueTask ResolveServiceAsync(string name, CancellationToken cancellationToken = default) + { + DateTime now = _timeProvider.GetUtcNow().DateTime; + var queryResult = await _lookupClient.QueryAsync(name, DnsClient.QueryType.SRV, cancellationToken: cancellationToken).ConfigureAwait(false); + if (queryResult.HasError) + { + throw CreateException(name, queryResult.ErrorMessage); + } + + var lookupMapping = new Dictionary>(); + foreach (var record in queryResult.Additionals.OfType()) + { + if (!lookupMapping.TryGetValue(record.DomainName, out var addresses)) + { + addresses = new List(); + lookupMapping[record.DomainName] = addresses; + } + + addresses.Add(new AddressResult + { + Address = record.Address, + ExpiresAt = now.Add(TimeSpan.FromSeconds(record.TimeToLive)) + }); + } + + var srvRecords = queryResult.Answers.OfType().ToList(); + + var results = new ServiceResult[srvRecords.Count]; + for (int i = 0; i < srvRecords.Count; i++) + { + var record = srvRecords[i]; + + results[i] = new ServiceResult + { + ExpiresAt = now.Add(TimeSpan.FromSeconds(record.TimeToLive)), + Priority = record.Priority, + Weight = record.Weight, + Port = record.Port, + Target = record.Target, + Addresses = lookupMapping.TryGetValue(record.Target, out var addresses) + ? addresses.ToArray() + : Array.Empty() + }; + } + + return results; + } + + private static InvalidOperationException CreateException(string dnsName, string errorMessage) + { + var msg = errorMessage switch + { + { Length: > 0 } => $"No DNS SRV records were found for DNS name '{dnsName}': {errorMessage}.", + _ => $"No DNS SRV records were found for DNS name '{dnsName}'", + }; + return new InvalidOperationException(msg); + } +} \ No newline at end of file diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Microsoft.Extensions.ServiceDiscovery.Dns.csproj b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Microsoft.Extensions.ServiceDiscovery.Dns.csproj new file mode 100644 index 00000000000..6424c4b5c5e --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Microsoft.Extensions.ServiceDiscovery.Dns.csproj @@ -0,0 +1,42 @@ + + + + $(NetCoreTargetFrameworks) + true + Provides extensions to HttpClient to resolve well-known hostnames to concrete endpoints based on DNS records. Useful for service resolution in orchestrators such as Kubernetes. + Open + + $(NoWarn);IDE0018;IDE0025;IDE0032;IDE0040;IDE0058;IDE0250;IDE0251;IDE1006;CA1304;CA1307;CA1309;CA1310;CA1849;CA2000;CA2213;CA2217;S125;S1135;S1226;S2344;S3626;S4022;SA1108;SA1120;SA1128;SA1129;SA1204;SA1205;SA1214;SA1400;SA1405;SA1408;SA1515;SA1600;SA1629;SA1642;SA1649;EA0001;EA0009;EA0014;LA0001;LA0003;LA0008;VSTHRD200 + enable + false + + + + ServiceDiscovery + normal + 9.5.1 + 75 + 75 + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Microsoft.Extensions.ServiceDiscovery.Dns.json b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Microsoft.Extensions.ServiceDiscovery.Dns.json new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/README.md b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/README.md new file mode 100644 index 00000000000..8be4560870b --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/README.md @@ -0,0 +1,65 @@ +# Microsoft.Extensions.ServiceDiscovery.Dns + +This library provides support for resolving service endpoints using DNS (Domain Name System). It provides two service endpoint providers: + +- _DNS_, which resolves endpoints using DNS `A/AAAA` record queries. This means that it can resolve names to IP addresses, but cannot resolve port numbers endpoints. As such, port numbers are assumed to be the default for the protocol (for example, 80 for HTTP and 433 for HTTPS). The benefit of using the DNS provider is that for cases where these default ports are appropriate, clients can spread their requests across hosts. For more information, see _Load-balancing with endpoint selectors_. + +- _DNS SRV_, which resolves service names using DNS SRV record queries. This allows it to resolve both IP addresses and port numbers. This is useful for environments which support DNS SRV queries, such as Kubernetes (when configured accordingly). + +## Resolving service endpoints with DNS + +The _DNS_ service endpoint provider resolves endpoints using DNS `A/AAAA` record queries. This means that it can resolve names to IP addresses, but cannot resolve port numbers endpoints. As such, port numbers are assumed to be the default for the protocol (for example, 80 for HTTP and 433 for HTTPS). The benefit of using the DNS service endpoint provider is that for cases where these default ports are appropriate, clients can spread their requests across hosts. For more information, see _Load-balancing with endpoint selectors_. + +To configure the DNS service endpoint provider in your application, add the DNS service endpoint provider to your host builder's service collection using the `AddDnsServiceEndpointProvider` method. service discovery as follows: + +```csharp +builder.Services.AddServiceDiscoveryCore(); +builder.Services.AddDnsServiceEndpointProvider(); +``` + +## Resolving service endpoints in Kubernetes with DNS SRV + +When deploying to Kubernetes, the DNS SRV service endpoint provider can be used to resolve endpoints. For example, the following resource definition will result in a DNS SRV record being created for an endpoint named "default" and an endpoint named "dashboard", both on the service named "basket". + +```yml +apiVersion: v1 +kind: Service +metadata: + name: basket +spec: + selector: + name: basket-service + clusterIP: None + ports: + - name: default + port: 8080 + - name: dashboard + port: 8888 +``` + +To configure a service to resolve the "dashboard" endpoint on the "basket" service, add the DNS SRV service endpoint provider to the host builder as follows: + +```csharp +builder.Services.AddServiceDiscoveryCore(); +builder.Services.AddDnsSrvServiceEndpointProvider(); +``` + +The special port name "default" is used to specify the default endpoint, resolved using the URI `http://basket`. + +As in the previous example, add service discovery to an `HttpClient` for the basket service: + +```csharp +builder.Services.AddHttpClient( + static client => client.BaseAddress = new("http://basket")); +``` + +Similarly, the "dashboard" endpoint can be targeted as follows: + +```csharp +builder.Services.AddHttpClient( + static client => client.BaseAddress = new("http://_dashboard.basket")); +``` + +## Feedback & contributing + +https://github.com/dotnet/aspire diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsDataReader.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsDataReader.cs new file mode 100644 index 00000000000..094df3040d1 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsDataReader.cs @@ -0,0 +1,133 @@ +// 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.Buffers.Binary; +using System.Diagnostics; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +internal struct DnsDataReader : IDisposable +{ + public ArraySegment MessageBuffer { get; private set; } + bool _returnToPool; + private int _position; + + public DnsDataReader(ArraySegment buffer, bool returnToPool = false) + { + MessageBuffer = buffer; + _position = 0; + _returnToPool = returnToPool; + } + + public bool TryReadHeader(out DnsMessageHeader header) + { + Debug.Assert(_position == 0); + + if (!DnsPrimitives.TryReadMessageHeader(MessageBuffer.AsSpan(), out header, out int bytesRead)) + { + header = default; + return false; + } + + _position += bytesRead; + return true; + } + + internal bool TryReadQuestion(out EncodedDomainName name, out QueryType type, out QueryClass @class) + { + if (!TryReadDomainName(out name) || + !TryReadUInt16(out ushort typeAsInt) || + !TryReadUInt16(out ushort classAsInt)) + { + type = 0; + @class = 0; + return false; + } + + type = (QueryType)typeAsInt; + @class = (QueryClass)classAsInt; + return true; + } + + public bool TryReadUInt16(out ushort value) + { + if (MessageBuffer.Count - _position < 2) + { + value = 0; + return false; + } + + value = BinaryPrimitives.ReadUInt16BigEndian(MessageBuffer.AsSpan(_position)); + _position += 2; + return true; + } + + public bool TryReadUInt32(out uint value) + { + if (MessageBuffer.Count - _position < 4) + { + value = 0; + return false; + } + + value = BinaryPrimitives.ReadUInt32BigEndian(MessageBuffer.AsSpan(_position)); + _position += 4; + return true; + } + + public bool TryReadResourceRecord(out DnsResourceRecord record) + { + if (!TryReadDomainName(out EncodedDomainName name) || + !TryReadUInt16(out ushort type) || + !TryReadUInt16(out ushort @class) || + !TryReadUInt32(out uint ttl) || + !TryReadUInt16(out ushort dataLength) || + MessageBuffer.Count - _position < dataLength) + { + record = default; + return false; + } + + ReadOnlyMemory data = MessageBuffer.AsMemory(_position, dataLength); + _position += dataLength; + + record = new DnsResourceRecord(name, (QueryType)type, (QueryClass)@class, (int)ttl, data); + return true; + } + + public bool TryReadDomainName(out EncodedDomainName name) + { + if (DnsPrimitives.TryReadQName(MessageBuffer, _position, out name, out int bytesRead)) + { + _position += bytesRead; + return true; + } + + return false; + } + + public bool TryReadSpan(int length, out ReadOnlySpan name) + { + if (MessageBuffer.Count - _position < length) + { + name = default; + return false; + } + + name = MessageBuffer.AsSpan(_position, length); + _position += length; + return true; + } + + public void Dispose() + { + if (_returnToPool && MessageBuffer.Array != null) + { + ArrayPool.Shared.Return(MessageBuffer.Array); + } + + _returnToPool = false; + MessageBuffer = default; + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsDataWriter.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsDataWriter.cs new file mode 100644 index 00000000000..a0a11f0b808 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsDataWriter.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.Buffers.Binary; +using System.Diagnostics; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +internal sealed class DnsDataWriter +{ + private readonly Memory _buffer; + private int _position; + + internal DnsDataWriter(Memory buffer) + { + _buffer = buffer; + _position = 0; + } + + public int Position => _position; + + internal bool TryWriteHeader(in DnsMessageHeader header) + { + if (!DnsPrimitives.TryWriteMessageHeader(_buffer.Span.Slice(_position), header, out int written)) + { + return false; + } + + _position += written; + return true; + } + + internal bool TryWriteQuestion(EncodedDomainName name, QueryType type, QueryClass @class) + { + if (!TryWriteDomainName(name) || + !TryWriteUInt16((ushort)type) || + !TryWriteUInt16((ushort)@class)) + { + return false; + } + + return true; + } + + private bool TryWriteDomainName(EncodedDomainName name) + { + foreach (var label in name.Labels) + { + // this should be already validated by the caller + Debug.Assert(label.Length <= 63, "Label length must not exceed 63 bytes."); + + if (!TryWriteByte((byte)label.Length) || + !TryWriteRawData(label.Span)) + { + return false; + } + } + + // root label + return TryWriteByte(0); + } + + internal bool TryWriteDomainName(string name) + { + if (DnsPrimitives.TryWriteQName(_buffer.Span.Slice(_position), name, out int written)) + { + _position += written; + return true; + } + + return false; + } + + internal bool TryWriteByte(byte value) + { + if (_buffer.Length - _position < 1) + { + return false; + } + + _buffer.Span[_position] = value; + _position += 1; + return true; + } + + internal bool TryWriteUInt16(ushort value) + { + if (_buffer.Length - _position < 2) + { + return false; + } + + BinaryPrimitives.WriteUInt16BigEndian(_buffer.Span.Slice(_position), value); + _position += 2; + return true; + } + + internal bool TryWriteUInt32(uint value) + { + if (_buffer.Length - _position < 4) + { + return false; + } + + BinaryPrimitives.WriteUInt32BigEndian(_buffer.Span.Slice(_position), value); + _position += 4; + return true; + } + + internal bool TryWriteRawData(ReadOnlySpan value) + { + if (_buffer.Length - _position < value.Length) + { + return false; + } + + value.CopyTo(_buffer.Span.Slice(_position)); + _position += value.Length; + return true; + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsMessageHeader.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsMessageHeader.cs new file mode 100644 index 00000000000..b22273a04f2 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsMessageHeader.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. + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +// RFC 1035 4.1.1. Header section format +internal struct DnsMessageHeader +{ + internal const int HeaderLength = 12; + public ushort TransactionId { get; set; } + + internal QueryFlags QueryFlags { get; set; } + + public ushort QueryCount { get; set; } + + public ushort AnswerCount { get; set; } + + public ushort AuthorityCount { get; set; } + + public ushort AdditionalRecordCount { get; set; } + + public QueryResponseCode ResponseCode + { + get => (QueryResponseCode)(QueryFlags & QueryFlags.ResponseCodeMask); + } + + public bool IsResultTruncated + { + get => (QueryFlags & QueryFlags.ResultTruncated) != 0; + } + + public bool IsResponse + { + get => (QueryFlags & QueryFlags.HasResponse) != 0; + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsPrimitives.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsPrimitives.cs new file mode 100644 index 00000000000..e549abe2576 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsPrimitives.cs @@ -0,0 +1,318 @@ +// 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.Buffers.Binary; +using System.Globalization; +using System.Text; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +internal static class DnsPrimitives +{ + // Maximum length of a domain name in ASCII (excluding trailing dot) + internal const int MaxDomainNameLength = 253; + + internal static bool TryReadMessageHeader(ReadOnlySpan buffer, out DnsMessageHeader header, out int bytesRead) + { + // RFC 1035 4.1.1. Header section format + if (buffer.Length < DnsMessageHeader.HeaderLength) + { + header = default; + bytesRead = 0; + return false; + } + + header = new DnsMessageHeader + { + TransactionId = BinaryPrimitives.ReadUInt16BigEndian(buffer), + QueryFlags = (QueryFlags)BinaryPrimitives.ReadUInt16BigEndian(buffer.Slice(2)), + QueryCount = BinaryPrimitives.ReadUInt16BigEndian(buffer.Slice(4)), + AnswerCount = BinaryPrimitives.ReadUInt16BigEndian(buffer.Slice(6)), + AuthorityCount = BinaryPrimitives.ReadUInt16BigEndian(buffer.Slice(8)), + AdditionalRecordCount = BinaryPrimitives.ReadUInt16BigEndian(buffer.Slice(10)) + }; + + bytesRead = DnsMessageHeader.HeaderLength; + return true; + } + + internal static bool TryWriteMessageHeader(Span buffer, DnsMessageHeader header, out int bytesWritten) + { + // RFC 1035 4.1.1. Header section format + if (buffer.Length < DnsMessageHeader.HeaderLength) + { + bytesWritten = 0; + return false; + } + + BinaryPrimitives.WriteUInt16BigEndian(buffer, header.TransactionId); + BinaryPrimitives.WriteUInt16BigEndian(buffer.Slice(2), (ushort)header.QueryFlags); + BinaryPrimitives.WriteUInt16BigEndian(buffer.Slice(4), header.QueryCount); + BinaryPrimitives.WriteUInt16BigEndian(buffer.Slice(6), header.AnswerCount); + BinaryPrimitives.WriteUInt16BigEndian(buffer.Slice(8), header.AuthorityCount); + BinaryPrimitives.WriteUInt16BigEndian(buffer.Slice(10), header.AdditionalRecordCount); + + bytesWritten = DnsMessageHeader.HeaderLength; + return true; + } + + // https://www.rfc-editor.org/rfc/rfc1035#section-2.3.4 + // labels 63 octets or less + // name 255 octets or less + + private static readonly SearchValues s_domainNameValidChars = SearchValues.Create("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_."); + private static readonly IdnMapping s_idnMapping = new IdnMapping(); + internal static bool TryWriteQName(Span destination, string name, out int written) + { + written = 0; + + // + // RFC 1035 4.1.2. + // + // a domain name represented as a sequence of labels, where + // each label consists of a length octet followed by that + // number of octets. The domain name terminates with the + // zero length octet for the null label of the root. Note + // that this field may be an odd number of octets; no + // padding is used. + // + if (!Ascii.IsValid(name)) + { + // IDN name, apply punycode + try + { + // IdnMapping performs some validation internally (such as label + // and domain name lengths), but is more relaxed than RFC + // 1035 (e.g. allows ~ chars), so even if this conversion does + // not throw, we still need to perform additional validation + name = s_idnMapping.GetAscii(name); + } + catch + { + return false; + } + } + + if (name.Length > MaxDomainNameLength || + name.AsSpan().ContainsAnyExcept(s_domainNameValidChars) || + destination.IsEmpty || + !Encoding.ASCII.TryGetBytes(name, destination.Slice(1), out int length) || + destination.Length < length + 2) + { + // buffer too small + return false; + } + + Span nameBuffer = destination.Slice(0, 1 + length); + Span label; + while (true) + { + // figure out the next label and prepend the length + int index = nameBuffer.Slice(1).IndexOf((byte)'.'); + label = index == -1 ? nameBuffer.Slice(1) : nameBuffer.Slice(1, index); + + if (label.Length == 0) + { + // empty label (explicit root) is only allowed at the end + if (index != -1) + { + written = 0; + return false; + } + } + // Label restrictions: + // - maximum 63 octets long + // - must start with a letter or digit (digit is allowed by RFC 1123) + // - may start with an underscore (underscore may be present only + // at the start of the label to support SRV records) + // - must end with a letter or digit + else if (label.Length > 63 || + !char.IsAsciiLetterOrDigit((char)label[0]) && label[0] != '_' || + label.Slice(1).Contains((byte)'_') || + !char.IsAsciiLetterOrDigit((char)label[^1])) + { + written = 0; + return false; + } + + nameBuffer[0] = (byte)label.Length; + written += label.Length + 1; + + if (index == -1) + { + // this was the last label + break; + } + + nameBuffer = nameBuffer.Slice(index + 1); + } + + // Add root label if wasn't explicitly specified + if (label.Length != 0) + { + destination[written] = 0; + written++; + } + + return true; + } + + private static bool TryReadQNameCore(List> labels, int totalLength, ReadOnlyMemory messageBuffer, int offset, out int bytesRead, bool canStartWithPointer = true) + { + // + // domain name can be either + // - a sequence of labels, where each label consists of a length octet + // followed by that number of octets, terminated by a zero length octet + // (root label) + // - a pointer, where the first two bits are set to 1, and the remaining + // 14 bits are an offset (from the start of the message) to the true + // label + // + // It is not specified by the RFC if pointers must be backwards only, + // the code below prohibits forward (and self) pointers to avoid + // infinite loops. It also allows pointers only to point to a + // label, not to another pointer. + // + + bytesRead = 0; + bool allowPointer = canStartWithPointer; + + if (offset < 0 || offset >= messageBuffer.Length) + { + return false; + } + + int currentOffset = offset; + + while (true) + { + byte length = messageBuffer.Span[currentOffset]; + + if ((length & 0xC0) == 0x00) + { + // length followed by the label + if (length == 0) + { + // end of name + bytesRead = currentOffset - offset + 1; + return true; + } + + if (currentOffset + 1 + length >= messageBuffer.Length) + { + // too many labels or truncated data + break; + } + + // read next label/segment + labels.Add(messageBuffer.Slice(currentOffset + 1, length)); + totalLength += 1 + length; + + // subtract one for the length prefix of the first label + if (totalLength - 1 > MaxDomainNameLength) + { + // domain name is too long + return false; + } + + currentOffset += 1 + length; + bytesRead += 1 + length; + + // we read a label, they can be followed by pointer. + allowPointer = true; + } + else if ((length & 0xC0) == 0xC0) + { + // pointer, together with next byte gives the offset of the true label + if (!allowPointer || currentOffset + 1 >= messageBuffer.Length) + { + // pointer to pointer or truncated data + break; + } + + bytesRead += 2; + int pointer = ((length & 0x3F) << 8) | messageBuffer.Span[currentOffset + 1]; + + // we prohibit self-references and forward pointers to avoid + // infinite loops, we do this by truncating the + // messageBuffer at the offset where we started reading the + // name. We also ignore the bytesRead from the recursive + // call, as we are only interested on how many bytes we read + // from the initial start of the name. + return TryReadQNameCore(labels, totalLength, messageBuffer.Slice(0, offset), pointer, out int _, false); + } + else + { + // top two bits are reserved, this means invalid data + break; + } + } + + return false; + + } + + internal static bool TryReadQName(ReadOnlyMemory messageBuffer, int offset, out EncodedDomainName name, out int bytesRead) + { + List> labels = new List>(); + + if (TryReadQNameCore(labels, 0, messageBuffer, offset, out bytesRead)) + { + name = new EncodedDomainName(labels); + return true; + } + else + { + bytesRead = 0; + name = default; + return false; + } + } + + internal static bool TryReadService(ReadOnlyMemory buffer, out ushort priority, out ushort weight, out ushort port, out EncodedDomainName target, out int bytesRead) + { + // https://www.rfc-editor.org/rfc/rfc2782 + if (!BinaryPrimitives.TryReadUInt16BigEndian(buffer.Span, out priority) || + !BinaryPrimitives.TryReadUInt16BigEndian(buffer.Span.Slice(2), out weight) || + !BinaryPrimitives.TryReadUInt16BigEndian(buffer.Span.Slice(4), out port) || + !TryReadQName(buffer.Slice(6), 0, out target, out bytesRead)) + { + target = default; + priority = 0; + weight = 0; + port = 0; + bytesRead = 0; + return false; + } + + bytesRead += 6; + return true; + } + + internal static bool TryReadSoa(ReadOnlyMemory buffer, out EncodedDomainName primaryNameServer, out EncodedDomainName responsibleMailAddress, out uint serial, out uint refresh, out uint retry, out uint expire, out uint minimum, out int bytesRead) + { + // https://www.rfc-editor.org/rfc/rfc1035#section-3.3.13 + if (!TryReadQName(buffer, 0, out primaryNameServer, out int w1) || + !TryReadQName(buffer.Slice(w1), 0, out responsibleMailAddress, out int w2) || + !BinaryPrimitives.TryReadUInt32BigEndian(buffer.Span.Slice(w1 + w2), out serial) || + !BinaryPrimitives.TryReadUInt32BigEndian(buffer.Span.Slice(w1 + w2 + 4), out refresh) || + !BinaryPrimitives.TryReadUInt32BigEndian(buffer.Span.Slice(w1 + w2 + 8), out retry) || + !BinaryPrimitives.TryReadUInt32BigEndian(buffer.Span.Slice(w1 + w2 + 12), out expire) || + !BinaryPrimitives.TryReadUInt32BigEndian(buffer.Span.Slice(w1 + w2 + 16), out minimum)) + { + primaryNameServer = default; + responsibleMailAddress = default; + serial = 0; + refresh = 0; + retry = 0; + expire = 0; + minimum = 0; + bytesRead = 0; + return false; + } + + bytesRead = w1 + w2 + 20; + return true; + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsResolver.Log.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsResolver.Log.cs new file mode 100644 index 00000000000..adab9161737 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsResolver.Log.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +using System.Net; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +internal partial class DnsResolver : IDnsResolver, IDisposable +{ + internal static partial class Log + { + [LoggerMessage(1, LogLevel.Debug, "Resolving {QueryType} {QueryName} on {Server} attempt {Attempt}", EventName = "Query")] + public static partial void Query(ILogger logger, QueryType queryType, string queryName, IPEndPoint server, int attempt); + + [LoggerMessage(2, LogLevel.Debug, "Result truncated for {QueryType} {QueryName} from {Server} attempt {Attempt}. Restarting over TCP", EventName = "ResultTruncated")] + public static partial void ResultTruncated(ILogger logger, QueryType queryType, string queryName, IPEndPoint server, int attempt); + + [LoggerMessage(3, LogLevel.Error, "Server {Server} replied with {ResponseCode} when querying {QueryType} {QueryName}", EventName = "ErrorResponseCode")] + public static partial void ErrorResponseCode(ILogger logger, QueryType queryType, string queryName, IPEndPoint server, QueryResponseCode responseCode); + + [LoggerMessage(4, LogLevel.Warning, "Query {QueryType} {QueryName} on {Server} attempt {Attempt} timed out.", EventName = "Timeout")] + public static partial void Timeout(ILogger logger, QueryType queryType, string queryName, IPEndPoint server, int attempt); + + [LoggerMessage(5, LogLevel.Warning, "Query {QueryType} {QueryName} on {Server} attempt {Attempt}: no data matching given query type.", EventName = "NoData")] + public static partial void NoData(ILogger logger, QueryType queryType, string queryName, IPEndPoint server, int attempt); + + [LoggerMessage(6, LogLevel.Warning, "Query {QueryType} {QueryName} on {Server} attempt {Attempt}: server indicates given name does not exist.", EventName = "NameError")] + public static partial void NameError(ILogger logger, QueryType queryType, string queryName, IPEndPoint server, int attempt); + + [LoggerMessage(7, LogLevel.Warning, "Query {QueryType} {QueryName} on {Server} attempt {Attempt} failed to return a valid DNS response.", EventName = "MalformedResponse")] + public static partial void MalformedResponse(ILogger logger, QueryType queryType, string queryName, IPEndPoint server, int attempt); + + [LoggerMessage(8, LogLevel.Warning, "Query {QueryType} {QueryName} on {Server} attempt {Attempt} failed due to a network error.", EventName = "NetworkError")] + public static partial void NetworkError(ILogger logger, QueryType queryType, string queryName, IPEndPoint server, int attempt, Exception exception); + + [LoggerMessage(9, LogLevel.Error, "Query {QueryType} {QueryName} on {Server} attempt {Attempt} failed.", EventName = "QueryError")] + public static partial void QueryError(ILogger logger, QueryType queryType, string queryName, IPEndPoint server, int attempt, Exception exception); + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsResolver.Telemetry.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsResolver.Telemetry.cs new file mode 100644 index 00000000000..4be956cede9 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsResolver.Telemetry.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.Diagnostics; +using System.Diagnostics.Metrics; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +internal partial class DnsResolver +{ + internal static class Telemetry + { + private static readonly Meter s_meter = new Meter("Microsoft.Extensions.ServiceDiscovery.Dns.Resolver"); + private static readonly Histogram s_queryDuration = s_meter.CreateHistogram("query.duration", "ms", "DNS query duration"); + + private static bool IsEnabled() => s_queryDuration.Enabled; + + public static NameResolutionActivity StartNameResolution(string hostName, QueryType queryType, long startingTimestamp) + { + if (IsEnabled()) + { + return new NameResolutionActivity(hostName, queryType, startingTimestamp); + } + + return default; + } + + public static void StopNameResolution(string hostName, QueryType queryType, in NameResolutionActivity activity, object? answers, SendQueryError error, long endingTimestamp) + { + activity.Stop(answers, error, endingTimestamp, out TimeSpan duration); + + if (!IsEnabled()) + { + return; + } + + var hostNameTag = KeyValuePair.Create("dns.question.name", (object?)hostName); + var queryTypeTag = KeyValuePair.Create("dns.question.type", (object?)queryType); + + if (answers is not null) + { + s_queryDuration.Record(duration.TotalSeconds, hostNameTag, queryTypeTag); + } + else + { + var errorTypeTag = KeyValuePair.Create("error.type", (object?)error.ToString()); + s_queryDuration.Record(duration.TotalSeconds, hostNameTag, queryTypeTag, errorTypeTag); + } + } + } + + internal readonly struct NameResolutionActivity + { + private const string ActivitySourceName = "Microsoft.Extensions.ServiceDiscovery.Dns.Resolver"; + private const string ActivityName = ActivitySourceName + ".Resolve"; + private static readonly ActivitySource s_activitySource = new ActivitySource(ActivitySourceName); + + private readonly long _startingTimestamp; + private readonly Activity? _activity; // null if activity is not started + + public NameResolutionActivity(string hostName, QueryType queryType, long startingTimestamp) + { + _startingTimestamp = startingTimestamp; + _activity = s_activitySource.StartActivity(ActivityName, ActivityKind.Client); + if (_activity is not null) + { + _activity.DisplayName = $"Resolving {hostName}"; + if (_activity.IsAllDataRequested) + { + _activity.SetTag("dns.question.name", hostName); + _activity.SetTag("dns.question.type", queryType.ToString()); + } + } + } + + public void Stop(object? answers, SendQueryError error, long endingTimestamp, out TimeSpan duration) + { + duration = Stopwatch.GetElapsedTime(_startingTimestamp, endingTimestamp); + + if (_activity is null) + { + return; + } + + if (_activity.IsAllDataRequested) + { + if (answers is not null) + { + static string[] ToStringHelper(T[] array) => array.Select(a => a!.ToString()!).ToArray(); + + string[]? answersArray = answers switch + { + ServiceResult[] serviceResults => ToStringHelper(serviceResults), + AddressResult[] addressResults => ToStringHelper(addressResults), + _ => null + }; + + Debug.Assert(answersArray is not null); + _activity.SetTag("dns.answers", answersArray); + } + else + { + _activity.SetTag("error.type", error.ToString()); + } + } + + if (answers is null) + { + _activity.SetStatus(ActivityStatusCode.Error); + } + + _activity.Stop(); + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsResolver.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsResolver.cs new file mode 100644 index 00000000000..511e8fdb1c9 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsResolver.cs @@ -0,0 +1,929 @@ +// 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.Buffers.Binary; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Net; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +internal sealed partial class DnsResolver : IDnsResolver, IDisposable +{ + private const int IPv4Length = 4; + private const int IPv6Length = 16; + + private bool _disposed; + private readonly DnsResolverOptions _options; + private readonly CancellationTokenSource _pendingRequestsCts = new(); + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + + public DnsResolver(TimeProvider timeProvider, ILogger logger, IOptions options) + { + _timeProvider = timeProvider; + _logger = logger; + _options = options.Value; + + if (_options.Servers.Count == 0) + { + foreach (var server in OperatingSystem.IsLinux() || OperatingSystem.IsMacOS() + ? ResolvConf.GetServers() + : NetworkInfo.GetServers()) + { + _options.Servers.Add(server); + } + + if (_options.Servers.Count == 0) + { + throw new ArgumentException("At least one DNS server is required.", nameof(options)); + } + } + } + + // This constructor is for unit testing only. Does not auto-add system DNS servers. + internal DnsResolver(DnsResolverOptions options) + { + _timeProvider = TimeProvider.System; + _logger = NullLogger.Instance; + _options = options; + } + + public ValueTask ResolveServiceAsync(string name, CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + cancellationToken.ThrowIfCancellationRequested(); + + // dnsSafeName is Disposed by SendQueryWithTelemetry + EncodedDomainName dnsSafeName = GetNormalizedHostName(name); + return SendQueryWithTelemetry(name, dnsSafeName, QueryType.SRV, ProcessResponse, cancellationToken); + + static (SendQueryError, ServiceResult[]) ProcessResponse(EncodedDomainName dnsSafeName, QueryType queryType, DnsResponse response) + { + var results = new List(response.Answers.Count); + + foreach (var answer in response.Answers) + { + if (answer.Type == QueryType.SRV) + { + if (!DnsPrimitives.TryReadService(answer.Data, out ushort priority, out ushort weight, out ushort port, out EncodedDomainName target, out int bytesRead) || bytesRead != answer.Data.Length) + { + return (SendQueryError.MalformedResponse, []); + } + + List addresses = new List(); + foreach (var additional in response.Additionals) + { + // From RFC 2782: + // + // Target + // The domain name of the target host. There MUST be one or more + // address records for this name, the name MUST NOT be an alias (in + // the sense of RFC 1034 or RFC 2181). Implementors are urged, but + // not required, to return the address record(s) in the Additional + // Data section. Unless and until permitted by future standards + // action, name compression is not to be used for this field. + // + // A Target of "." means that the service is decidedly not + // available at this domain. + if (additional.Name.Equals(target) && (additional.Type == QueryType.A || additional.Type == QueryType.AAAA)) + { + addresses.Add(new AddressResult(response.CreatedAt.AddSeconds(additional.Ttl), new IPAddress(additional.Data.Span))); + } + } + + results.Add(new ServiceResult(response.CreatedAt.AddSeconds(answer.Ttl), priority, weight, port, target.ToString(), addresses.ToArray())); + } + } + + return (SendQueryError.NoError, results.ToArray()); + } + } + + public async ValueTask ResolveIPAddressesAsync(string name, CancellationToken cancellationToken = default) + { + if (string.Equals(name, "localhost", StringComparison.OrdinalIgnoreCase)) + { + // name localhost exists outside of DNS and can't be resolved by a DNS server + int len = (Socket.OSSupportsIPv4 ? 1 : 0) + (Socket.OSSupportsIPv6 ? 1 : 0); + AddressResult[] res = new AddressResult[len]; + + int index = 0; + if (Socket.OSSupportsIPv6) // prefer IPv6 + { + res[index] = new AddressResult(DateTime.MaxValue, IPAddress.IPv6Loopback); + index++; + } + if (Socket.OSSupportsIPv4) + { + res[index] = new AddressResult(DateTime.MaxValue, IPAddress.Loopback); + } + + return res; + } + + var ipv4AddressesTask = ResolveIPAddressesAsync(name, AddressFamily.InterNetwork, cancellationToken); + var ipv6AddressesTask = ResolveIPAddressesAsync(name, AddressFamily.InterNetworkV6, cancellationToken); + + AddressResult[] ipv4Addresses = await ipv4AddressesTask.ConfigureAwait(false); + AddressResult[] ipv6Addresses = await ipv6AddressesTask.ConfigureAwait(false); + + AddressResult[] results = new AddressResult[ipv4Addresses.Length + ipv6Addresses.Length]; + ipv6Addresses.CopyTo(results, 0); + ipv4Addresses.CopyTo(results, ipv6Addresses.Length); + return results; + } + + internal ValueTask ResolveIPAddressesAsync(string name, AddressFamily addressFamily, CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + cancellationToken.ThrowIfCancellationRequested(); + + if (addressFamily != AddressFamily.InterNetwork && addressFamily != AddressFamily.InterNetworkV6) + { + throw new ArgumentOutOfRangeException(nameof(addressFamily), addressFamily, "Invalid address family"); + } + + if (string.Equals(name, "localhost", StringComparison.OrdinalIgnoreCase)) + { + // name localhost exists outside of DNS and can't be resolved by a DNS server + if (addressFamily == AddressFamily.InterNetwork && Socket.OSSupportsIPv4) + { + return ValueTask.FromResult([new AddressResult(DateTime.MaxValue, IPAddress.Loopback)]); + } + else if (addressFamily == AddressFamily.InterNetworkV6 && Socket.OSSupportsIPv6) + { + return ValueTask.FromResult([new AddressResult(DateTime.MaxValue, IPAddress.IPv6Loopback)]); + } + + return ValueTask.FromResult([]); + } + + // dnsSafeName is Disposed by SendQueryWithTelemetry + EncodedDomainName dnsSafeName = GetNormalizedHostName(name); + var queryType = addressFamily == AddressFamily.InterNetwork ? QueryType.A : QueryType.AAAA; + return SendQueryWithTelemetry(name, dnsSafeName, queryType, ProcessResponse, cancellationToken); + + static (SendQueryError error, AddressResult[] result) ProcessResponse(EncodedDomainName dnsSafeName, QueryType queryType, DnsResponse response) + { + List results = new List(response.Answers.Count); + + // Servers send back CNAME records together with associated A/AAAA records. Servers + // send only those CNAME records relevant to the query, and if there is a CNAME record, + // there should not be other records associated with the name. Therefore, we simply follow + // the list of CNAME aliases until we get to the primary name and return the A/AAAA records + // associated. + // + // more info: https://datatracker.ietf.org/doc/html/rfc1034#section-3.6.2 + // + // Most of the servers send the CNAME records in order so that we can sequentially scan the + // answers, but nothing prevents the records from being in arbitrary order. Attempt the linear + // scan first and fallback to a slower but more robust method if necessary. + + bool success = true; + EncodedDomainName currentAlias = dnsSafeName; + + foreach (var answer in response.Answers) + { + switch (answer.Type) + { + case QueryType.CNAME: + if (!TryReadTarget(answer, response.RawMessageBytes, out EncodedDomainName target)) + { + return (SendQueryError.MalformedResponse, []); + } + + if (answer.Name.Equals(currentAlias)) + { + currentAlias = target; + continue; + } + + break; + + case var type when type == queryType: + if (!TryReadAddress(answer, queryType, out IPAddress? address)) + { + return (SendQueryError.MalformedResponse, []); + } + + if (answer.Name.Equals(currentAlias)) + { + results.Add(new AddressResult(response.CreatedAt.AddSeconds(answer.Ttl), address)); + continue; + } + + break; + } + + // unexpected name or record type, fall back to more robust path + results.Clear(); + success = false; + break; + } + + if (success) + { + return (SendQueryError.NoError, results.ToArray()); + } + + // more expensive path for uncommon (but valid) cases where CNAME records are out of order. Use of Dictionary + // allows us to stay within O(n) complexity for the number of answers, but we will use more memory. + Dictionary aliasMap = new(); + Dictionary> aRecordMap = new(); + foreach (var answer in response.Answers) + { + if (answer.Type == QueryType.CNAME) + { + // map the alias to the target name + if (!TryReadTarget(answer, response.RawMessageBytes, out EncodedDomainName target)) + { + return (SendQueryError.MalformedResponse, []); + } + + if (!aliasMap.TryAdd(answer.Name, target)) + { + // Duplicate CNAME record + return (SendQueryError.MalformedResponse, []); + } + } + + if (answer.Type == queryType) + { + if (!TryReadAddress(answer, queryType, out IPAddress? address)) + { + return (SendQueryError.MalformedResponse, []); + } + + if (!aRecordMap.TryGetValue(answer.Name, out List? addressList)) + { + addressList = new List(); + aRecordMap.Add(answer.Name, addressList); + } + + addressList.Add(new AddressResult(response.CreatedAt.AddSeconds(answer.Ttl), address)); + } + } + + // follow the CNAME chain, limit the maximum number of iterations to avoid infinite loops. + int i = 0; + currentAlias = dnsSafeName; + while (aliasMap.TryGetValue(currentAlias, out EncodedDomainName nextAlias)) + { + if (i >= aliasMap.Count) + { + // circular CNAME chain + return (SendQueryError.MalformedResponse, []); + } + + i++; + + if (aRecordMap.ContainsKey(currentAlias)) + { + // both CNAME record and A/AAAA records exist for the current alias + return (SendQueryError.MalformedResponse, []); + } + + currentAlias = nextAlias; + } + + // Now we have the final target name, check if we have any A/AAAA records for it. + aRecordMap.TryGetValue(currentAlias, out List? finalAddressList); + return (SendQueryError.NoError, finalAddressList?.ToArray() ?? []); + + static bool TryReadTarget(in DnsResourceRecord record, ArraySegment messageBytes, out EncodedDomainName target) + { + Debug.Assert(record.Type == QueryType.CNAME, "Only CNAME records should be processed here."); + + target = default; + + // some servers use domain name compression even inside CNAME records. In order to decode those + // correctly, we need to pass the entire message to TryReadQName. The Data span inside the record + // should be backed by the array containing the entire DNS message. We just need to account for the + // 2 byte offset in case of TCP fallback. + var gotArray = MemoryMarshal.TryGetArray(record.Data, out ArraySegment segment); + Debug.Assert(gotArray, "Failed to get array segment"); + Debug.Assert(segment.Array == messageBytes.Array, "record data backed by different array than the original message"); + + int messageOffset = messageBytes.Offset; + + bool result = DnsPrimitives.TryReadQName(segment.Array.AsMemory(messageOffset, segment.Offset + segment.Count - messageOffset), segment.Offset - messageOffset, out EncodedDomainName targetName, out int bytesRead) && bytesRead == record.Data.Length; + if (result) + { + target = targetName; + } + + return result; + } + + static bool TryReadAddress(in DnsResourceRecord record, QueryType type, [NotNullWhen(true)] out IPAddress? target) + { + Debug.Assert(record.Type is QueryType.A or QueryType.AAAA, "Only CNAME records should be processed here."); + + target = null; + if (record.Type == QueryType.A && record.Data.Length != IPv4Length || + record.Type == QueryType.AAAA && record.Data.Length != IPv6Length) + { + return false; + } + + target = new IPAddress(record.Data.Span); + return true; + } + } + } + + private async ValueTask SendQueryWithTelemetry(string name, EncodedDomainName dnsSafeName, QueryType queryType, Func processResponseFunc, CancellationToken cancellationToken) + { + NameResolutionActivity activity = Telemetry.StartNameResolution(name, queryType, _timeProvider.GetTimestamp()); + (SendQueryError error, TResult[] result) = await SendQueryWithRetriesAsync(name, dnsSafeName, queryType, processResponseFunc, cancellationToken).ConfigureAwait(false); + Telemetry.StopNameResolution(name, queryType, activity, null, error, _timeProvider.GetTimestamp()); + dnsSafeName.Dispose(); + + return result; + } + + internal struct SendQueryResult + { + public DnsResponse Response; + public SendQueryError Error; + } + + async ValueTask<(SendQueryError error, TResult[] result)> SendQueryWithRetriesAsync(string name, EncodedDomainName dnsSafeName, QueryType queryType, Func processResponseFunc, CancellationToken cancellationToken) + { + SendQueryError lastError = SendQueryError.InternalError; // will be overwritten by the first attempt + for (int index = 0; index < _options.Servers.Count; index++) + { + IPEndPoint serverEndPoint = _options.Servers[index]; + + for (int attempt = 1; attempt <= _options.MaxAttempts; attempt++) + { + DnsResponse response = default; + try + { + TResult[] results = Array.Empty(); + + try + { + SendQueryResult queryResult = await SendQueryToServerWithTimeoutAsync(serverEndPoint, name, dnsSafeName, queryType, attempt, cancellationToken).ConfigureAwait(false); + lastError = queryResult.Error; + response = queryResult.Response; + + if (lastError == SendQueryError.NoError) + { + // Given that result.Error is NoError, there should be at least one answer. + Debug.Assert(response.Answers.Count > 0); + (lastError, results) = processResponseFunc(dnsSafeName, queryType, queryResult.Response); + } + } + catch (SocketException ex) + { + Log.NetworkError(_logger, queryType, name, serverEndPoint, attempt, ex); + lastError = SendQueryError.NetworkError; + } + catch (Exception ex) when (!cancellationToken.IsCancellationRequested) + { + // internal error, propagate + Log.QueryError(_logger, queryType, name, serverEndPoint, attempt, ex); + throw; + } + + switch (lastError) + { + // + // Definitive answers, no point retrying + // + case SendQueryError.NoError: + return (lastError, results); + + case SendQueryError.NameError: + // authoritative answer that the name does not exist, no point in retrying + Log.NameError(_logger, queryType, name, serverEndPoint, attempt); + return (lastError, results); + + case SendQueryError.NoData: + // no data available for the name from authoritative server + Log.NoData(_logger, queryType, name, serverEndPoint, attempt); + return (lastError, results); + + // + // Transient errors, retry on the same server + // + case SendQueryError.Timeout: + Log.Timeout(_logger, queryType, name, serverEndPoint, attempt); + continue; + + case SendQueryError.NetworkError: + // TODO: retry with exponential backoff? + continue; + + case SendQueryError.ServerError when response.Header.ResponseCode == QueryResponseCode.ServerFailure: + // ServerFailure may indicate transient failure with upstream DNS servers, retry on the same server + Log.ErrorResponseCode(_logger, queryType, name, serverEndPoint, response.Header.ResponseCode); + continue; + + // + // Persistent errors, skip to the next server + // + case SendQueryError.ServerError: + // this should cover all response codes except NoError, NameError which are definite and handled above, and + // ServerFailure which is a transient error and handled above. + Log.ErrorResponseCode(_logger, queryType, name, serverEndPoint, response.Header.ResponseCode); + break; + + case SendQueryError.MalformedResponse: + Log.MalformedResponse(_logger, queryType, name, serverEndPoint, attempt); + break; + + case SendQueryError.InternalError: + // exception logged above. + break; + } + + // actual break that causes skipping to the next server + break; + } + finally + { + response.Dispose(); + } + } + } + + // if we get here, we exhausted all servers and all attempts + return (lastError, []); + } + + internal async ValueTask SendQueryToServerWithTimeoutAsync(IPEndPoint serverEndPoint, string name, EncodedDomainName dnsSafeName, QueryType queryType, int attempt, CancellationToken cancellationToken) + { + (CancellationTokenSource cts, bool disposeTokenSource, CancellationTokenSource pendingRequestsCts) = PrepareCancellationTokenSource(cancellationToken); + + try + { + return await SendQueryToServerAsync(serverEndPoint, name, dnsSafeName, queryType, attempt, cts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when ( + !cancellationToken.IsCancellationRequested && // not cancelled by the caller + !pendingRequestsCts.IsCancellationRequested) // not cancelled by the global token (dispose) + // the only remaining token that could cancel this is the linked cts from the timeout. + { + Debug.Assert(cts.Token.IsCancellationRequested); + return new SendQueryResult { Error = SendQueryError.Timeout }; + } + catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested && ex.CancellationToken != cancellationToken) + { + // cancellation was initiated by the caller, but exception was triggered by a linked token, + // rethrow the exception with the caller's token. + cancellationToken.ThrowIfCancellationRequested(); + throw new UnreachableException(); + } + finally + { + if (disposeTokenSource) + { + cts.Dispose(); + } + } + } + + private async ValueTask SendQueryToServerAsync(IPEndPoint serverEndPoint, string name, EncodedDomainName dnsSafeName, QueryType queryType, int attempt, CancellationToken cancellationToken) + { + Log.Query(_logger, queryType, name, serverEndPoint, attempt); + + SendQueryError sendError = SendQueryError.NoError; + DateTime queryStartedTime = _timeProvider.GetUtcNow().DateTime; + DnsDataReader responseReader = default; + DnsMessageHeader header; + + try + { + // use transport override if provided + if (_options._transportOverride != null) + { + (responseReader, header, sendError) = SendDnsQueryCustomTransport(_options._transportOverride, dnsSafeName, queryType); + } + else + { + (responseReader, header) = await SendDnsQueryCoreUdpAsync(serverEndPoint, dnsSafeName, queryType, cancellationToken).ConfigureAwait(false); + + if (header.IsResultTruncated) + { + Log.ResultTruncated(_logger, queryType, name, serverEndPoint, 0); + responseReader.Dispose(); + // TCP fallback + (responseReader, header, sendError) = await SendDnsQueryCoreTcpAsync(serverEndPoint, dnsSafeName, queryType, cancellationToken).ConfigureAwait(false); + } + } + + if (sendError != SendQueryError.NoError) + { + // we failed to get back any response + return new SendQueryResult { Error = sendError }; + } + + if ((uint)header.ResponseCode > (uint)QueryResponseCode.Refused) + { + // Response code is outside of valid range + return new SendQueryResult + { + Response = new DnsResponse(ArraySegment.Empty, header, queryStartedTime, queryStartedTime, null!, null!, null!), + Error = SendQueryError.MalformedResponse + }; + } + + // Recheck that the server echoes back the DNS question + if (header.QueryCount != 1 || + !responseReader.TryReadQuestion(out var qName, out var qType, out var qClass) || + !dnsSafeName.Equals(qName) || qType != queryType || qClass != QueryClass.Internet) + { + // DNS Question mismatch + return new SendQueryResult + { + Response = new DnsResponse(ArraySegment.Empty, header, queryStartedTime, queryStartedTime, null!, null!, null!), + Error = SendQueryError.MalformedResponse + }; + } + + // Structurally separate the resource records, this will validate only the + // "outside structure" of the resource record, it will not validate the content. + int ttl = int.MaxValue; + if (!TryReadRecords(header.AnswerCount, ref ttl, ref responseReader, out List? answers) || + !TryReadRecords(header.AuthorityCount, ref ttl, ref responseReader, out List? authorities) || + !TryReadRecords(header.AdditionalRecordCount, ref ttl, ref responseReader, out List? additionals)) + { + return new SendQueryResult + { + Response = new DnsResponse(ArraySegment.Empty, header, queryStartedTime, queryStartedTime, null!, null!, null!), + Error = SendQueryError.MalformedResponse + }; + } + + DateTime expirationTime = + (answers.Count + authorities.Count + additionals.Count) > 0 ? queryStartedTime.AddSeconds(ttl) : queryStartedTime; + + SendQueryError validationError = ValidateResponse(header.ResponseCode, queryStartedTime, answers, authorities, ref expirationTime); + + // we transfer ownership of RawData to the response + DnsResponse response = new DnsResponse(responseReader.MessageBuffer, header, queryStartedTime, expirationTime, answers, authorities, additionals); + responseReader = default; // avoid disposing (and returning RawData to the pool) + + return new SendQueryResult { Response = response, Error = validationError }; + } + finally + { + responseReader.Dispose(); + } + + static bool TryReadRecords(int count, ref int ttl, ref DnsDataReader reader, out List records) + { + // Since `count` is attacker controlled, limit the initial capacity + // to 32 items to avoid excessive memory allocation. More than 32 + // records are unusual so we don't need to optimize for them. + records = new(Math.Min(count, 32)); + + for (int i = 0; i < count; i++) + { + if (!reader.TryReadResourceRecord(out var record)) + { + return false; + } + + ttl = Math.Min(ttl, record.Ttl); + records.Add(new DnsResourceRecord(record.Name, record.Type, record.Class, record.Ttl, record.Data)); + } + + return true; + } + } + + internal static bool GetNegativeCacheExpiration(DateTime createdAt, List authorities, out DateTime expiration) + { + // + // RFC 2308 Section 5 - Caching Negative Answers + // + // Like normal answers negative answers have a time to live (TTL). As + // there is no record in the answer section to which this TTL can be + // applied, the TTL must be carried by another method. This is done by + // including the SOA record from the zone in the authority section of + // the reply. When the authoritative server creates this record its TTL + // is taken from the minimum of the SOA.MINIMUM field and SOA's TTL. + // This TTL decrements in a similar manner to a normal cached answer and + // upon reaching zero (0) indicates the cached negative answer MUST NOT + // be used again. + // + + DnsResourceRecord? soa = authorities.FirstOrDefault(r => r.Type == QueryType.SOA); + if (soa != null && DnsPrimitives.TryReadSoa(soa.Value.Data, out _, out _, out _, out _, out _, out _, out uint minimum, out _)) + { + expiration = createdAt.AddSeconds(Math.Min(minimum, soa.Value.Ttl)); + return true; + } + + expiration = default; + return false; + } + + internal static SendQueryError ValidateResponse(QueryResponseCode responseCode, DateTime createdAt, List answers, List authorities, ref DateTime expiration) + { + if (responseCode == QueryResponseCode.NoError) + { + if (answers.Count > 0) + { + return SendQueryError.NoError; + } + // + // RFC 2308 Section 2.2 - No Data + // + // NODATA is indicated by an answer with the RCODE set to NOERROR and no + // relevant answers in the answer section. The authority section will + // contain an SOA record, or there will be no NS records there. + // + // + // RFC 2308 Section 5 - Caching Negative Answers + // + // A negative answer that resulted from a no data error (NODATA) should + // be cached such that it can be retrieved and returned in response to + // another query for the same that resulted in + // the cached negative response. + // + if (!authorities.Any(r => r.Type == QueryType.NS) && GetNegativeCacheExpiration(createdAt, authorities, out DateTime newExpiration)) + { + expiration = newExpiration; + // _cache.TryAdd(name, queryType, expiration, Array.Empty()); + } + return SendQueryError.NoData; + } + + if (responseCode == QueryResponseCode.NameError) + { + // + // RFC 2308 Section 5 - Caching Negative Answers + // + // A negative answer that resulted from a name error (NXDOMAIN) should + // be cached such that it can be retrieved and returned in response to + // another query for the same that resulted in the + // cached negative response. + // + if (GetNegativeCacheExpiration(createdAt, authorities, out DateTime newExpiration)) + { + expiration = newExpiration; + // _cache.TryAddNonexistent(name, expiration); + } + + return SendQueryError.NameError; + } + + return SendQueryError.ServerError; + } + + internal static (DnsDataReader reader, DnsMessageHeader header, SendQueryError sendError) SendDnsQueryCustomTransport(Func, int, int> callback, EncodedDomainName dnsSafeName, QueryType queryType) + { + byte[] buffer = ArrayPool.Shared.Rent(2048); + try + { + (ushort transactionId, int length) = EncodeQuestion(buffer, dnsSafeName, queryType); + length = callback(buffer, length); + + DnsDataReader responseReader = new DnsDataReader(new ArraySegment(buffer, 0, length), true); + + if (!responseReader.TryReadHeader(out DnsMessageHeader header) || + header.TransactionId != transactionId || + !header.IsResponse) + { + return (default, default, SendQueryError.MalformedResponse); + } + + // transfer ownership of buffer to the caller + buffer = null!; + return (responseReader, header, SendQueryError.NoError); + } + finally + { + if (buffer != null) + { + ArrayPool.Shared.Return(buffer); + } + } + } + + internal static async ValueTask<(DnsDataReader reader, DnsMessageHeader header)> SendDnsQueryCoreUdpAsync(IPEndPoint serverEndPoint, EncodedDomainName dnsSafeName, QueryType queryType, CancellationToken cancellationToken) + { + var buffer = ArrayPool.Shared.Rent(512); + try + { + Memory memory = buffer; + (ushort transactionId, int length) = EncodeQuestion(memory, dnsSafeName, queryType); + + using var socket = new Socket(serverEndPoint.AddressFamily, SocketType.Dgram, ProtocolType.Udp); + await socket.SendToAsync(memory.Slice(0, length), SocketFlags.None, serverEndPoint, cancellationToken).ConfigureAwait(false); + + DnsDataReader responseReader; + DnsMessageHeader header; + + while (true) + { + // Because this is UDP, the response must be in a single packet, + // if the response does not fit into a single UDP packet, the server will + // set the Truncated flag in the header, and we will need to retry with TCP. + int packetLength = await socket.ReceiveAsync(memory, SocketFlags.None, cancellationToken).ConfigureAwait(false); + + if (packetLength < DnsMessageHeader.HeaderLength) + { + continue; + } + + responseReader = new DnsDataReader(new ArraySegment(buffer, 0, packetLength), true); + if (!responseReader.TryReadHeader(out header) || + header.TransactionId != transactionId || + !header.IsResponse) + { + // header mismatch, this is not a response to our query + continue; + } + + // ownership of the buffer is transferred to the reader, caller will dispose. + buffer = null!; + return (responseReader, header); + } + } + finally + { + if (buffer != null) + { + ArrayPool.Shared.Return(buffer); + } + } + } + + internal static async ValueTask<(DnsDataReader reader, DnsMessageHeader header, SendQueryError error)> SendDnsQueryCoreTcpAsync(IPEndPoint serverEndPoint, EncodedDomainName dnsSafeName, QueryType queryType, CancellationToken cancellationToken) + { + var buffer = ArrayPool.Shared.Rent(8 * 1024); + try + { + // When sending over TCP, the message is prefixed by 2B length + (ushort transactionId, int length) = EncodeQuestion(buffer.AsMemory(2), dnsSafeName, queryType); + BinaryPrimitives.WriteUInt16BigEndian(buffer, (ushort)length); + + using var socket = new Socket(serverEndPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + await socket.ConnectAsync(serverEndPoint, cancellationToken).ConfigureAwait(false); + await socket.SendAsync(buffer.AsMemory(0, length + 2), SocketFlags.None, cancellationToken).ConfigureAwait(false); + + int responseLength = -1; + int bytesRead = 0; + while (responseLength < 0 || bytesRead < responseLength + 2) + { + int read = await socket.ReceiveAsync(buffer.AsMemory(bytesRead), SocketFlags.None, cancellationToken).ConfigureAwait(false); + bytesRead += read; + + if (read == 0) + { + // connection closed before receiving complete response message + return (default, default, SendQueryError.MalformedResponse); + } + + if (responseLength < 0 && bytesRead >= 2) + { + responseLength = BinaryPrimitives.ReadUInt16BigEndian(buffer.AsSpan(0, 2)); + + if (responseLength + 2 > buffer.Length) + { + // even though this is user-controlled pre-allocation, it is limited to + // 64 kB, so it should be fine. + var largerBuffer = ArrayPool.Shared.Rent(responseLength + 2); + Array.Copy(buffer, largerBuffer, bytesRead); + ArrayPool.Shared.Return(buffer); + buffer = largerBuffer; + } + } + } + + DnsDataReader responseReader = new DnsDataReader(new ArraySegment(buffer, 2, responseLength), true); + if (!responseReader.TryReadHeader(out DnsMessageHeader header) || + header.TransactionId != transactionId || + !header.IsResponse) + { + // header mismatch on TCP fallback + return (default, default, SendQueryError.MalformedResponse); + } + + // transfer ownership of buffer to the caller + buffer = null!; + return (responseReader, header, SendQueryError.NoError); + } + finally + { + if (buffer != null) + { + ArrayPool.Shared.Return(buffer); + } + } + } + + private static (ushort id, int length) EncodeQuestion(Memory buffer, EncodedDomainName dnsSafeName, QueryType queryType) + { + DnsMessageHeader header = new DnsMessageHeader + { + TransactionId = (ushort)RandomNumberGenerator.GetInt32(ushort.MaxValue + 1), + QueryFlags = QueryFlags.RecursionDesired, + QueryCount = 1 + }; + + DnsDataWriter writer = new DnsDataWriter(buffer); + if (!writer.TryWriteHeader(header) || + !writer.TryWriteQuestion(dnsSafeName, queryType, QueryClass.Internet)) + { + // should never happen since we validated the name length before + throw new InvalidOperationException("Buffer too small"); + } + return (header.TransactionId, writer.Position); + } + + public void Dispose() + { + if (!_disposed) + { + _disposed = true; + + // Cancel all pending requests (if any). Note that we don't call CancelPendingRequests() but cancel + // the CTS directly. The reason is that CancelPendingRequests() would cancel the current CTS and create + // a new CTS. We don't want a new CTS in this case. + _pendingRequestsCts.Cancel(); + _pendingRequestsCts.Dispose(); + } + } + + private (CancellationTokenSource TokenSource, bool DisposeTokenSource, CancellationTokenSource PendingRequestsCts) PrepareCancellationTokenSource(CancellationToken cancellationToken) + { + // We need a CancellationTokenSource to use with the request. We always have the global + // _pendingRequestsCts to use, plus we may have a token provided by the caller, and we may + // have a timeout. If we have a timeout or a caller-provided token, we need to create a new + // CTS (we can't, for example, timeout the pending requests CTS, as that could cancel other + // unrelated operations). Otherwise, we can use the pending requests CTS directly. + + // Snapshot the current pending requests cancellation source. It can change concurrently due to cancellation being requested + // and it being replaced, and we need a stable view of it: if cancellation occurs and the caller's token hasn't been canceled, + // it's either due to this source or due to the timeout, and checking whether this source is the culprit is reliable whereas + // it's more approximate checking elapsed time. + CancellationTokenSource pendingRequestsCts = _pendingRequestsCts; + TimeSpan timeout = _options.Timeout; + + bool hasTimeout = timeout != System.Threading.Timeout.InfiniteTimeSpan; + if (hasTimeout || cancellationToken.CanBeCanceled) + { + CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, pendingRequestsCts.Token); + if (hasTimeout) + { + cts.CancelAfter(timeout); + } + + return (cts, DisposeTokenSource: true, pendingRequestsCts); + } + + return (pendingRequestsCts, DisposeTokenSource: false, pendingRequestsCts); + } + + private static EncodedDomainName GetNormalizedHostName(string name) + { + byte[] buffer = ArrayPool.Shared.Rent(256); + try + { + if (!DnsPrimitives.TryWriteQName(buffer, name, out _)) + { + throw new ArgumentException($"'{name}' is not a valid DNS name.", nameof(name)); + } + + List> labels = new(); + Memory memory = buffer.AsMemory(); + while (true) + { + int len = memory.Span[0]; + + if (len == 0) + { + // root label, we are finished + break; + } + + labels.Add(memory.Slice(1, len)); + memory = memory.Slice(len + 1); + } + + buffer = null!; // ownership transferred to the EncodedDomainName + return new EncodedDomainName(labels, buffer); + } + finally + { + if (buffer != null) + { + ArrayPool.Shared.Return(buffer); + } + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsResourceRecord.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsResourceRecord.cs new file mode 100644 index 00000000000..914ff9aac17 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsResourceRecord.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. + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +internal struct DnsResourceRecord +{ + public EncodedDomainName Name { get; } + public QueryType Type { get; } + public QueryClass Class { get; } + public int Ttl { get; } + public ReadOnlyMemory Data { get; } + + public DnsResourceRecord(EncodedDomainName name, QueryType type, QueryClass @class, int ttl, ReadOnlyMemory data) + { + Name = name; + Type = type; + Class = @class; + Ttl = ttl; + Data = data; + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsResponse.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsResponse.cs new file mode 100644 index 00000000000..5a7fc8a0b52 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/DnsResponse.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +internal struct DnsResponse : IDisposable +{ + public DnsMessageHeader Header { get; } + public List Answers { get; } + public List Authorities { get; } + public List Additionals { get; } + public DateTime CreatedAt { get; } + public DateTime Expiration { get; } + public ArraySegment RawMessageBytes { get; private set; } + + public DnsResponse(ArraySegment rawData, DnsMessageHeader header, DateTime createdAt, DateTime expiration, List answers, List authorities, List additionals) + { + RawMessageBytes = rawData; + + Header = header; + CreatedAt = createdAt; + Expiration = expiration; + Answers = answers; + Authorities = authorities; + Additionals = additionals; + } + + public void Dispose() + { + if (RawMessageBytes.Array != null) + { + ArrayPool.Shared.Return(RawMessageBytes.Array); + } + + RawMessageBytes = default; // prevent further access to the raw data + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/EncodedDomainName.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/EncodedDomainName.cs new file mode 100644 index 00000000000..4c258cac3ac --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/EncodedDomainName.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.Buffers; +using System.Text; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +internal struct EncodedDomainName : IEquatable, IDisposable +{ + public IReadOnlyList> Labels { get; } + private byte[]? _pooledBuffer; + + public EncodedDomainName(List> labels, byte[]? pooledBuffer = null) + { + Labels = labels; + _pooledBuffer = pooledBuffer; + } + public override string ToString() + { + StringBuilder sb = new StringBuilder(); + + foreach (var label in Labels) + { + if (sb.Length > 0) + { + sb.Append('.'); + } + sb.Append(Encoding.ASCII.GetString(label.Span)); + } + + return sb.ToString(); + } + + public bool Equals(EncodedDomainName other) + { + if (Labels.Count != other.Labels.Count) + { + return false; + } + + for (int i = 0; i < Labels.Count; i++) + { + if (!Ascii.EqualsIgnoreCase(Labels[i].Span, other.Labels[i].Span)) + { + return false; + } + } + + return true; + } + + public override bool Equals(object? obj) + { + return obj is EncodedDomainName other && Equals(other); + } + + public override int GetHashCode() + { + HashCode hash = new HashCode(); + + foreach (var label in Labels) + { + foreach (byte b in label.Span) + { + hash.Add((byte)char.ToLower((char)b)); + } + } + + return hash.ToHashCode(); + } + + public void Dispose() + { + if (_pooledBuffer != null) + { + ArrayPool.Shared.Return(_pooledBuffer); + } + + _pooledBuffer = null; + } +} \ No newline at end of file diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/IDnsResolver.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/IDnsResolver.cs new file mode 100644 index 00000000000..080fe3be8de --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/IDnsResolver.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.ServiceDiscovery.Dns.Resolver; + +internal interface IDnsResolver +{ + ValueTask ResolveIPAddressesAsync(string name, CancellationToken cancellationToken = default); + ValueTask ResolveServiceAsync(string name, CancellationToken cancellationToken = default); +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/NetworkInfo.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/NetworkInfo.cs new file mode 100644 index 00000000000..24b5155a1c8 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/NetworkInfo.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.Net; +using System.Net.NetworkInformation; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +internal static class NetworkInfo +{ + // basic option to get DNS servers via NetworkInfo. We may get it directly later via proper APIs. + public static IList GetServers() + { + List servers = new List(); + + foreach (NetworkInterface nic in NetworkInterface.GetAllNetworkInterfaces()) + { + IPInterfaceProperties properties = nic.GetIPProperties(); + // avoid loopback, VPN etc. Should be re-visited. + + if (nic.NetworkInterfaceType == NetworkInterfaceType.Ethernet && nic.OperationalStatus == OperationalStatus.Up) + { + foreach (IPAddress server in properties.DnsAddresses) + { + IPEndPoint ep = new IPEndPoint(server, 53); // 53 is standard DNS port + if (!servers.Contains(ep)) + { + servers.Add(ep); + } + } + } + } + + return servers; + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/QueryClass.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/QueryClass.cs new file mode 100644 index 00000000000..732ca0216da --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/QueryClass.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.ServiceDiscovery.Dns.Resolver; + +internal enum QueryClass +{ + Internet = 1 +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/QueryFlags.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/QueryFlags.cs new file mode 100644 index 00000000000..02474b6cda1 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/QueryFlags.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +[Flags] +internal enum QueryFlags : ushort +{ + RecursionAvailable = 0x0080, + RecursionDesired = 0x0100, + ResultTruncated = 0x0200, + HasAuthorityAnswer = 0x0400, + HasResponse = 0x8000, + ResponseCodeMask = 0x000F, +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/QueryResponseCode.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/QueryResponseCode.cs new file mode 100644 index 00000000000..dd51c712112 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/QueryResponseCode.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. + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +/// +/// The response code (RCODE) in a DNS query response. +/// +internal enum QueryResponseCode : byte +{ + /// + /// No error condition + /// + NoError = 0, + + /// + /// The name server was unable to interpret the query. + /// + FormatError = 1, + + /// + /// The name server was unable to process this query due to a problem with the name server. + /// + ServerFailure = 2, + + /// + /// Meaningful only for responses from an authoritative name server, this + /// code signifies that the domain name referenced in the query does not + /// exist. + /// + NameError = 3, + + /// + /// The name server does not support the requested kind of query. + /// + NotImplemented = 4, + + /// + /// The name server refuses to perform the specified operation for policy reasons. + /// + Refused = 5, +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/QueryType.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/QueryType.cs new file mode 100644 index 00000000000..2ccc898a5b7 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/QueryType.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. + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +/// +/// DNS Query Types. +/// +internal enum QueryType +{ + /// + /// A host address. + /// + A = 1, + + /// + /// An authoritative name server. + /// + NS = 2, + + /// + /// The canonical name for an alias. + /// + CNAME = 5, + + /// + /// Marks the start of a zone of authority. + /// + SOA = 6, + + /// + /// Mail exchange. + /// + MX = 15, + + /// + /// Text strings. + /// + TXT = 16, + + /// + /// IPv6 host address. (RFC 3596) + /// + AAAA = 28, + + /// + /// Location information. (RFC 2782) + /// + SRV = 33, + + /// + /// Wildcard match. + /// + All = 255 +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/ResolvConf.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/ResolvConf.cs new file mode 100644 index 00000000000..de68e88c18d --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/ResolvConf.cs @@ -0,0 +1,48 @@ +// 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; +using System.Runtime.Versioning; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +internal static class ResolvConf +{ + [SupportedOSPlatform("linux")] + [SupportedOSPlatform("osx")] + public static IList GetServers() + { + return GetServers(new StreamReader("/etc/resolv.conf")); + } + + public static IList GetServers(TextReader reader) + { + List serverList = new(); + + while (reader.ReadLine() is string line) + { + string[] tokens = line.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + if (line.StartsWith("nameserver")) + { + if (tokens.Length >= 2 && IPAddress.TryParse(tokens[1], out IPAddress? address)) + { + serverList.Add(new IPEndPoint(address, 53)); // 53 is standard DNS port + + if (serverList.Count == 3) + { + break; // resolv.conf manpage allow max 3 nameservers anyway + } + } + } + } + + if (serverList.Count == 0) + { + // If no nameservers are configured, fall back to the default behavior of using the system resolver configuration. + return NetworkInfo.GetServers(); + } + + return serverList; + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/ResultTypes.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/ResultTypes.cs new file mode 100644 index 00000000000..aed799ac8d6 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/ResultTypes.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.Net; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +internal record struct AddressResult(DateTime ExpiresAt, IPAddress Address); + +internal record struct ServiceResult(DateTime ExpiresAt, int Priority, int Weight, int Port, string Target, AddressResult[] Addresses); diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/SendQueryError.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/SendQueryError.cs new file mode 100644 index 00000000000..3ba5632e207 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/Resolver/SendQueryError.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. + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +internal enum SendQueryError +{ + /// + /// DNS query was successful and returned response message with answers. + /// + NoError, + + /// + /// Server failed to respond to the query withing specified timeout. + /// + Timeout, + + /// + /// Server returned a response with an error code. + /// + ServerError, + + /// + /// Server returned a malformed response. + /// + MalformedResponse, + + /// + /// Server returned a response indicating that the name exists, but no data are available. + /// + NoData, + + /// + /// Server returned a response indicating the name does not exist. + /// + NameError, + + /// + /// Network-level error occurred during the query. + /// + NetworkError, + + /// + /// Internal error on part of the implementation. + /// + InternalError, +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/ServiceDiscoveryDnsServiceCollectionExtensions.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/ServiceDiscoveryDnsServiceCollectionExtensions.cs new file mode 100644 index 00000000000..313e68b3b8a --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns/ServiceDiscoveryDnsServiceCollectionExtensions.cs @@ -0,0 +1,150 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.ServiceDiscovery; +using Microsoft.Extensions.ServiceDiscovery.Dns; +using Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; + +namespace Microsoft.Extensions.Hosting; + +/// +/// Extensions for to add service discovery. +/// +public static class ServiceDiscoveryDnsServiceCollectionExtensions +{ + /// + /// Adds DNS SRV service discovery to the . + /// + /// The service collection. + /// The provided . + /// + /// DNS SRV queries are able to provide port numbers for endpoints and can support multiple named endpoints per service. + /// However, not all environment support DNS SRV queries, and in some environments, additional configuration may be required. + /// + public static IServiceCollection AddDnsSrvServiceEndpointProvider(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + return services.AddDnsSrvServiceEndpointProvider(_ => { }); + } + + /// + /// Adds DNS SRV service discovery to the . + /// + /// The service collection. + /// The DNS SRV service discovery configuration options. + /// The provided . + /// + /// DNS SRV queries are able to provide port numbers for endpoints and can support multiple named endpoints per service. + /// However, not all environment support DNS SRV queries, and in some environments, additional configuration may be required. + /// + public static IServiceCollection AddDnsSrvServiceEndpointProvider(this IServiceCollection services, Action configureOptions) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configureOptions); + + services.AddServiceDiscoveryCore(); + + if (!GetDnsClientFallbackFlag()) + { + services.TryAddSingleton(); + } + else + { + services.TryAddSingleton(); + services.TryAddSingleton(); + } + + services.AddSingleton(); + var options = services.AddOptions(); + options.Configure(configureOptions); + + return services; + + } + + /// + /// Adds DNS service discovery to the . + /// + /// The service collection. + /// The provided . + /// + /// DNS A/AAAA queries are widely available but are not able to provide port numbers for endpoints and cannot support multiple named endpoints per service. + /// + public static IServiceCollection AddDnsServiceEndpointProvider(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + return services.AddDnsServiceEndpointProvider(_ => { }); + } + + /// + /// Adds DNS service discovery to the . + /// + /// The service collection. + /// The DNS SRV service discovery configuration options. + /// The provided . + /// + /// DNS A/AAAA queries are widely available but are not able to provide port numbers for endpoints and cannot support multiple named endpoints per service. + /// + public static IServiceCollection AddDnsServiceEndpointProvider(this IServiceCollection services, Action configureOptions) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configureOptions); + + services.AddServiceDiscoveryCore(); + + if (!GetDnsClientFallbackFlag()) + { + services.TryAddSingleton(); + } + else + { + services.TryAddSingleton(); + services.TryAddSingleton(); + } + + services.AddSingleton(); + var options = services.AddOptions(); + options.Configure(configureOptions); + + return services; + } + + /// + /// Configures the DNS resolver used for service discovery. + /// + /// The service collection. + /// The DNS resolver options. + /// The provided . + public static IServiceCollection ConfigureDnsResolver(this IServiceCollection services, Action configureOptions) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configureOptions); + + var options = services.AddOptions(); + options.Configure(configureOptions); + services.AddTransient, DnsResolverOptionsValidator>(); + return services; + } + + private static bool GetDnsClientFallbackFlag() + { + if (AppContext.TryGetSwitch("Microsoft.Extensions.ServiceDiscovery.Dns.UseDnsClientFallback", out var value)) + { + return value; + } + + var envVar = Environment.GetEnvironmentVariable("MICROSOFT_EXTENSIONS_SERVICE_DISCOVERY_DNS_USE_DNSCLIENT_FALLBACK"); + if (envVar is not null && (envVar.Equals("true", StringComparison.OrdinalIgnoreCase) || envVar.Equals("1"))) + { + return true; + } + + return false; + } + +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp/Microsoft.Extensions.ServiceDiscovery.Yarp.csproj b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp/Microsoft.Extensions.ServiceDiscovery.Yarp.csproj new file mode 100644 index 00000000000..2b4530077cd --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp/Microsoft.Extensions.ServiceDiscovery.Yarp.csproj @@ -0,0 +1,36 @@ + + + + $(NetCoreTargetFrameworks) + enable + enable + true + Provides extensions for service discovery for the YARP reverse proxy. + Open + + $(NoWarn);IDE0018;IDE0025;IDE0032;IDE0040;IDE0058;IDE0250;IDE0251;IDE1006;CA1304;CA1307;CA1309;CA1310;CA1849;CA2000;CA2213;CA2217;S125;S1135;S1226;S2344;S2692;S3626;S4022;SA1108;SA1120;SA1128;SA1129;SA1204;SA1205;SA1214;SA1400;SA1405;SA1408;SA1414;SA1515;SA1600;SA1615;SA1629;SA1642;SA1649;EA0001;EA0009;EA0014;LA0001;LA0003;LA0008;VSTHRD200 + enable + + + + ServiceDiscovery + normal + 9.5.1 + 75 + 75 + + + + + + + + + + + + + + + + diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp/Microsoft.Extensions.ServiceDiscovery.Yarp.json b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp/Microsoft.Extensions.ServiceDiscovery.Yarp.json new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp/README.md b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp/README.md new file mode 100644 index 00000000000..a7175f0382c --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp/README.md @@ -0,0 +1,42 @@ +# Microsoft.Extensions.ServiceDiscovery.Yarp + +The `Microsoft.Extensions.ServiceDiscovery.Yarp` library adds support for resolving endpoints for YARP clusters, by implementing a [YARP destination resolver](https://github.com/microsoft/reverse-proxy/blob/main/docs/docfx/articles/destination-resolvers.md). + +## Usage + +### Resolving YARP cluster destinations using Service Discovery + +The `IReverseProxyBuilder.AddServiceDiscoveryDestinationResolver()` extension method configures a [YARP destination resolver](https://github.com/microsoft/reverse-proxy/blob/main/docs/docfx/articles/destination-resolvers.md). To use this method, you must also configure YARP itself as described in the YARP documentation, and you must configure .NET Service Discovery via the _Microsoft.Extensions.ServiceDiscovery_ library. + +### Direct HTTP forwarding using Service Discovery Forwarding HTTP requests using `IHttpForwarder` + +YARP supports _direct forwarding_ of specific requests using the `IHttpForwarder` interface. This, too, can benefit from service discovery using the _Microsoft.Extensions.ServiceDiscovery_ library. To take advantage of service discovery when using YARP Direct Forwarding, use the `IServiceCollection.AddHttpForwarderWithServiceDiscovery` method. + +For example, consider the following .NET Aspire application: + +```csharp +var builder = WebApplication.CreateBuilder(args); + +// Configure service discovery +builder.Services.AddServiceDiscovery(); + +// Add YARP Direct Forwarding with Service Discovery support +builder.Services.AddHttpForwarderWithServiceDiscovery(); + +// ... other configuration ... + +var app = builder.Build(); + +// ... other configuration ... + +// Map a Direct Forwarder which forwards requests to the resolved "catalogservice" endpoints +app.MapForwarder("/catalog/images/{id}", "http://catalogservice", "/api/v1/catalog/items/{id}/image"); + +app.Run(); +``` + +In the above example, the YARP Direct Forwarder will resolve the _catalogservice_ using service discovery, forwarding request sent to the `/catalog/images/{id}` endpoint to the destination path on the resolved endpoints. + +## Feedback & contributing + +https://github.com/dotnet/aspire diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp/ServiceDiscoveryDestinationResolver.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp/ServiceDiscoveryDestinationResolver.cs new file mode 100644 index 00000000000..2ca456ec911 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp/ServiceDiscoveryDestinationResolver.cs @@ -0,0 +1,128 @@ +// 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.Options; +using Microsoft.Extensions.Primitives; +using Yarp.ReverseProxy.Configuration; +using Yarp.ReverseProxy.ServiceDiscovery; + +namespace Microsoft.Extensions.ServiceDiscovery.Yarp; + +/// +/// Implementation of which resolves destinations using service discovery. +/// +/// +/// Initializes a new instance. +/// +/// The endpoint resolver registry. +/// The service discovery options. +internal sealed class ServiceDiscoveryDestinationResolver(ServiceEndpointResolver resolver, IOptions options) : IDestinationResolver +{ + private readonly ServiceDiscoveryOptions _options = options.Value; + + /// + public async ValueTask ResolveDestinationsAsync(IReadOnlyDictionary destinations, CancellationToken cancellationToken) + { + Dictionary results = new(); + var tasks = new List, IChangeToken ChangeToken)>>(destinations.Count); + foreach (var (destinationId, destinationConfig) in destinations) + { + tasks.Add(ResolveHostAsync(destinationId, destinationConfig, cancellationToken)); + } + + await Task.WhenAll(tasks).ConfigureAwait(false); + var changeTokens = new List(); + foreach (var task in tasks) + { + var (configs, changeToken) = await task.ConfigureAwait(false); + if (changeToken is not null) + { + changeTokens.Add(changeToken); + } + + foreach (var (name, config) in configs) + { + results[name] = config; + } + } + + return new ResolvedDestinationCollection(results, new CompositeChangeToken(changeTokens)); + } + + private async Task<(List<(string Name, DestinationConfig Config)>, IChangeToken ChangeToken)> ResolveHostAsync( + string originalName, + DestinationConfig originalConfig, + CancellationToken cancellationToken) + { + var originalUri = new Uri(originalConfig.Address); + var serviceName = originalUri.GetLeftPart(UriPartial.Authority); + + var result = await resolver.GetEndpointsAsync(serviceName, cancellationToken).ConfigureAwait(false); + var results = new List<(string Name, DestinationConfig Config)>(result.Endpoints.Count); + var uriBuilder = new UriBuilder(originalUri); + var healthUri = originalConfig.Health is { Length: > 0 } health ? new Uri(health) : null; + var healthUriBuilder = healthUri is { } ? new UriBuilder(healthUri) : null; + foreach (var endpoint in result.Endpoints) + { + var addressString = endpoint.ToString()!; + Uri uri; + if (!addressString.Contains("://")) + { + var scheme = GetDefaultScheme(originalUri); + uri = new Uri($"{scheme}://{addressString}"); + } + else + { + uri = new Uri(addressString); + } + + uriBuilder.Scheme = uri.Scheme; + uriBuilder.Host = uri.Host; + uriBuilder.Port = uri.Port; + var resolvedAddress = uriBuilder.Uri.ToString(); + var healthAddress = originalConfig.Health; + if (healthUriBuilder is not null) + { + healthUriBuilder.Host = uri.Host; + healthUriBuilder.Port = uri.Port; + healthAddress = healthUriBuilder.Uri.ToString(); + } + + var name = $"{originalName}[{addressString}]"; + string? resolvedHost = null; + + // Use the configured 'Host' value if it is provided. + if (!string.IsNullOrEmpty(originalConfig.Host)) + { + resolvedHost = originalConfig.Host; + } + + var config = originalConfig with { Host = resolvedHost, Address = resolvedAddress, Health = healthAddress }; + results.Add((name, config)); + } + + return (results, result.ChangeToken); + } + + private string GetDefaultScheme(Uri originalUri) + { + if (originalUri.Scheme.IndexOf('+') > 0) + { + // Use the first allowed scheme. + var specifiedSchemes = originalUri.Scheme.Split('+'); + foreach (var scheme in specifiedSchemes) + { + if (_options.AllowAllSchemes || _options.AllowedSchemes.Contains(scheme, StringComparer.OrdinalIgnoreCase)) + { + return scheme; + } + } + + throw new InvalidOperationException($"None of the specified schemes ('{string.Join(", ", specifiedSchemes)}') are allowed by configuration."); + } + else + { + return originalUri.Scheme; + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp/ServiceDiscoveryForwarderHttpClientFactory.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp/ServiceDiscoveryForwarderHttpClientFactory.cs new file mode 100644 index 00000000000..84aafe2a67e --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp/ServiceDiscoveryForwarderHttpClientFactory.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.ServiceDiscovery.Http; +using Yarp.ReverseProxy.Forwarder; + +namespace Microsoft.Extensions.ServiceDiscovery.Yarp; + +internal sealed class ServiceDiscoveryForwarderHttpClientFactory(IServiceDiscoveryHttpMessageHandlerFactory handlerFactory) + : ForwarderHttpClientFactory +{ + protected override HttpMessageHandler WrapHandler(ForwarderHttpClientContext context, HttpMessageHandler handler) + { + return handlerFactory.CreateHandler(handler); + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp/ServiceDiscoveryReverseProxyServiceCollectionExtensions.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp/ServiceDiscoveryReverseProxyServiceCollectionExtensions.cs new file mode 100644 index 00000000000..de74dc0fc24 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp/ServiceDiscoveryReverseProxyServiceCollectionExtensions.cs @@ -0,0 +1,48 @@ +// 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.ServiceDiscovery.Yarp; +using Yarp.ReverseProxy.Forwarder; +using Yarp.ReverseProxy.ServiceDiscovery; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extensions for used to register the ReverseProxy's components. +/// +public static class ServiceDiscoveryReverseProxyServiceCollectionExtensions +{ + /// + /// Provides a implementation which uses service discovery to resolve destinations. + /// + public static IReverseProxyBuilder AddServiceDiscoveryDestinationResolver(this IReverseProxyBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Services.AddServiceDiscoveryCore(); + builder.Services.AddSingleton(); + return builder; + } + + /// + /// Adds the with service discovery support. + /// + public static IServiceCollection AddHttpForwarderWithServiceDiscovery(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + return services.AddHttpForwarder().AddServiceDiscoveryForwarderFactory(); + } + + /// + /// Provides a implementation which uses service discovery to resolve service names. + /// + public static IServiceCollection AddServiceDiscoveryForwarderFactory(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddServiceDiscoveryCore(); + services.AddSingleton(); + return services; + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Configuration/ConfigurationServiceEndpointProvider.Log.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Configuration/ConfigurationServiceEndpointProvider.Log.cs new file mode 100644 index 00000000000..b27c5ea9190 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Configuration/ConfigurationServiceEndpointProvider.Log.cs @@ -0,0 +1,50 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Extensions.ServiceDiscovery.Configuration; + +internal sealed partial class ConfigurationServiceEndpointProvider +{ + private static partial class Log + { + [LoggerMessage(1, LogLevel.Debug, "Skipping endpoint resolution for service '{ServiceName}': '{Reason}'.", EventName = "SkippedResolution")] + public static partial void SkippedResolution(ILogger logger, string serviceName, string reason); + + [LoggerMessage(2, LogLevel.Debug, "Using configuration from path '{Path}' to resolve endpoint '{EndpointName}' for service '{ServiceName}'.", EventName = "UsingConfigurationPath")] + public static partial void UsingConfigurationPath(ILogger logger, string path, string endpointName, string serviceName); + + [LoggerMessage(3, LogLevel.Debug, "No valid endpoint configuration was found for service '{ServiceName}' from path '{Path}'.", EventName = "ServiceConfigurationNotFound")] + internal static partial void ServiceConfigurationNotFound(ILogger logger, string serviceName, string path); + + [LoggerMessage(4, LogLevel.Debug, "Endpoints configured for service '{ServiceName}' from path '{Path}': {ConfiguredEndpoints}.", EventName = "ConfiguredEndpoints")] + internal static partial void ConfiguredEndpoints(ILogger logger, string serviceName, string path, string configuredEndpoints); + + internal static void ConfiguredEndpoints(ILogger logger, string serviceName, string path, IList endpoints, int added) + { + if (!logger.IsEnabled(LogLevel.Debug)) + { + return; + } + + StringBuilder endpointValues = new(); + for (var i = endpoints.Count - added; i < endpoints.Count; i++) + { + if (endpointValues.Length > 0) + { + endpointValues.Append(", "); + } + + endpointValues.Append(endpoints[i].ToString()); + } + + var configuredEndpoints = endpointValues.ToString(); + ConfiguredEndpoints(logger, serviceName, path, configuredEndpoints); + } + + [LoggerMessage(5, LogLevel.Debug, "No valid endpoint configuration was found for endpoint '{EndpointName}' on service '{ServiceName}' from path '{Path}'.", EventName = "EndpointConfigurationNotFound")] + internal static partial void EndpointConfigurationNotFound(ILogger logger, string endpointName, string serviceName, string path); + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Configuration/ConfigurationServiceEndpointProvider.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Configuration/ConfigurationServiceEndpointProvider.cs new file mode 100644 index 00000000000..72d6e7b6b81 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Configuration/ConfigurationServiceEndpointProvider.cs @@ -0,0 +1,207 @@ +// 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; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.ServiceDiscovery.Configuration; + +/// +/// A service endpoint provider that uses configuration to resolve resolved. +/// +internal sealed partial class ConfigurationServiceEndpointProvider : IServiceEndpointProvider, IHostNameFeature +{ + private const string DefaultEndpointName = "default"; + private readonly string _serviceName; + private readonly string? _endpointName; + private readonly bool _includeAllSchemes; + private readonly string[] _schemes; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + private readonly IOptions _options; + + /// + /// Initializes a new instance. + /// + /// The query. + /// The configuration. + /// The logger. + /// Configuration provider options. + /// Service discovery options. + public ConfigurationServiceEndpointProvider( + ServiceEndpointQuery query, + IConfiguration configuration, + ILogger logger, + IOptions options, + IOptions serviceDiscoveryOptions) + { + _serviceName = query.ServiceName; + _endpointName = query.EndpointName; + _includeAllSchemes = serviceDiscoveryOptions.Value.AllowAllSchemes && query.IncludedSchemes.Count == 0; + var allowedSchemes = serviceDiscoveryOptions.Value.ApplyAllowedSchemes(query.IncludedSchemes); + _schemes = allowedSchemes as string[] ?? allowedSchemes.ToArray(); + _configuration = configuration; + _logger = logger; + _options = options; + } + + /// + public ValueTask DisposeAsync() => default; + + /// + public ValueTask PopulateAsync(IServiceEndpointBuilder endpoints, CancellationToken cancellationToken) + { + // Only add resolved to the collection if a previous provider (eg, an override) did not add them. + if (endpoints.Endpoints.Count != 0) + { + Log.SkippedResolution(_logger, _serviceName, "Collection has existing endpoints"); + return default; + } + + // Get the corresponding config section. + var section = _configuration.GetSection(_options.Value.SectionName).GetSection(_serviceName); + if (!section.Exists()) + { + endpoints.AddChangeToken(_configuration.GetReloadToken()); + Log.ServiceConfigurationNotFound(_logger, _serviceName, $"{_options.Value.SectionName}:{_serviceName}"); + return default; + } + + endpoints.AddChangeToken(section.GetReloadToken()); + + // Find an appropriate configuration section based on the input. + IConfigurationSection? namedSection = null; + string endpointName; + if (string.IsNullOrWhiteSpace(_endpointName)) + { + // Treat the scheme as the endpoint name and use the first section with a matching endpoint name which exists + endpointName = DefaultEndpointName; + ReadOnlySpan candidateNames = [DefaultEndpointName, .. _schemes]; + foreach (var scheme in candidateNames) + { + var candidate = section.GetSection(scheme); + if (candidate.Exists()) + { + endpointName = scheme; + namedSection = candidate; + break; + } + } + } + else + { + // Use the section corresponding to the endpoint name. + endpointName = _endpointName; + namedSection = section.GetSection(_endpointName); + } + + var configPath = $"{_options.Value.SectionName}:{_serviceName}:{endpointName}"; + if (!namedSection.Exists()) + { + Log.EndpointConfigurationNotFound(_logger, endpointName, _serviceName, configPath); + return default; + } + + List resolved = []; + Log.UsingConfigurationPath(_logger, configPath, endpointName, _serviceName); + + // Account for both the single and multi-value cases. + if (!string.IsNullOrWhiteSpace(namedSection.Value)) + { + // Single value case. + AddEndpoint(resolved, namedSection, endpointName); + } + else + { + // Multiple value case. + foreach (var child in namedSection.GetChildren()) + { + if (!int.TryParse(child.Key, out _)) + { + throw new KeyNotFoundException($"The endpoint configuration section for service '{_serviceName}' endpoint '{endpointName}' has non-numeric keys."); + } + + AddEndpoint(resolved, child, endpointName); + } + } + + int resolvedEndpointCount; + if (_includeAllSchemes) + { + // Include all endpoints. + foreach (var ep in resolved) + { + endpoints.Endpoints.Add(ep); + } + + resolvedEndpointCount = resolved.Count; + } + else + { + // Filter the resolved endpoints to only include those which match the specified, allowed schemes. + resolvedEndpointCount = 0; + var minIndex = _schemes.Length; + foreach (var ep in resolved) + { + if (ep.EndPoint is UriEndPoint uri && uri.Uri.Scheme is { } scheme) + { + var index = Array.IndexOf(_schemes, scheme); + if (index >= 0 && index < minIndex) + { + minIndex = index; + } + } + } + + foreach (var ep in resolved) + { + if (ep.EndPoint is UriEndPoint uri && uri.Uri.Scheme is { } scheme) + { + var index = Array.IndexOf(_schemes, scheme); + if (index >= 0 && index <= minIndex) + { + ++resolvedEndpointCount; + endpoints.Endpoints.Add(ep); + } + } + else + { + ++resolvedEndpointCount; + endpoints.Endpoints.Add(ep); + } + } + } + + if (resolvedEndpointCount == 0) + { + Log.ServiceConfigurationNotFound(_logger, _serviceName, configPath); + } + else + { + Log.ConfiguredEndpoints(_logger, _serviceName, configPath, endpoints.Endpoints, resolvedEndpointCount); + } + + return default; + } + + string IHostNameFeature.HostName => _serviceName; + + private void AddEndpoint(List endpoints, IConfigurationSection section, string endpointName) + { + if (!ServiceEndpoint.TryParse(section.Value, out var serviceEndpoint)) + { + throw new KeyNotFoundException($"The endpoint configuration section for service '{_serviceName}' endpoint '{endpointName}' has an invalid value with key '{section.Key}'."); + } + + serviceEndpoint.Features.Set(this); + if (_options.Value.ShouldApplyHostNameMetadata(serviceEndpoint)) + { + serviceEndpoint.Features.Set(this); + } + + endpoints.Add(serviceEndpoint); + } + + public override string ToString() => "Configuration"; +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Configuration/ConfigurationServiceEndpointProviderFactory.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Configuration/ConfigurationServiceEndpointProviderFactory.cs new file mode 100644 index 00000000000..a966cd44794 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Configuration/ConfigurationServiceEndpointProviderFactory.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.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.ServiceDiscovery.Configuration; + +/// +/// implementation that resolves services using . +/// +internal sealed class ConfigurationServiceEndpointProviderFactory( + IConfiguration configuration, + IOptions options, + IOptions serviceDiscoveryOptions, + ILogger logger) : IServiceEndpointProviderFactory +{ + /// + public bool TryCreateProvider(ServiceEndpointQuery query, [NotNullWhen(true)] out IServiceEndpointProvider? provider) + { + provider = new ConfigurationServiceEndpointProvider(query, configuration, logger, options, serviceDiscoveryOptions); + return true; + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Configuration/ConfigurationServiceEndpointProviderOptionsValidator.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Configuration/ConfigurationServiceEndpointProviderOptionsValidator.cs new file mode 100644 index 00000000000..f8092c4dd51 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Configuration/ConfigurationServiceEndpointProviderOptionsValidator.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 Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.ServiceDiscovery.Configuration; + +internal sealed class ConfigurationServiceEndpointProviderOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, ConfigurationServiceEndpointProviderOptions options) + { + if (string.IsNullOrWhiteSpace(options.SectionName)) + { + return ValidateOptionsResult.Fail($"{nameof(options.SectionName)} must not be null or empty."); + } + + if (options.ShouldApplyHostNameMetadata is null) + { + return ValidateOptionsResult.Fail($"{nameof(options.ShouldApplyHostNameMetadata)} must not be null."); + } + + return ValidateOptionsResult.Success; + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ConfigurationServiceEndpointProviderOptions.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ConfigurationServiceEndpointProviderOptions.cs new file mode 100644 index 00000000000..29f28e359f7 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ConfigurationServiceEndpointProviderOptions.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 Microsoft.Extensions.ServiceDiscovery.Configuration; + +namespace Microsoft.Extensions.ServiceDiscovery; + +/// +/// Options for . +/// +public sealed class ConfigurationServiceEndpointProviderOptions +{ + /// + /// The name of the configuration section which contains service endpoints. Defaults to "Services". + /// + public string SectionName { get; set; } = "Services"; + + /// + /// Gets or sets a delegate used to determine whether to apply host name metadata to each resolved endpoint. Defaults to a delegate which returns false. + /// + public Func ShouldApplyHostNameMetadata { get; set; } = _ => false; +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Http/HttpServiceEndpointResolver.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Http/HttpServiceEndpointResolver.cs new file mode 100644 index 00000000000..e547ab14138 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Http/HttpServiceEndpointResolver.cs @@ -0,0 +1,263 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.ServiceDiscovery.LoadBalancing; + +namespace Microsoft.Extensions.ServiceDiscovery.Http; + +/// +/// Resolves endpoints for HTTP requests. +/// +internal sealed class HttpServiceEndpointResolver(ServiceEndpointWatcherFactory watcherFactory, IServiceProvider serviceProvider, TimeProvider timeProvider) : IAsyncDisposable +{ + private static readonly TimerCallback s_cleanupCallback = s => ((HttpServiceEndpointResolver)s!).CleanupResolvers(); + private static readonly TimeSpan s_cleanupPeriod = TimeSpan.FromSeconds(10); + + private readonly object _lock = new(); + private readonly ServiceEndpointWatcherFactory _watcherFactory = watcherFactory; + private readonly ConcurrentDictionary _resolvers = new(); + private ITimer? _cleanupTimer; + private Task? _cleanupTask; + + /// + /// Resolves and returns a service endpoint for the specified request. + /// + /// The request message. + /// A . + /// The resolved service endpoint. + /// The request had no set or a suitable endpoint could not be found. + public async ValueTask GetEndpointAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + if (request.RequestUri is null) + { + throw new InvalidOperationException("Cannot resolve an endpoint for a request which has no RequestUri"); + } + + EnsureCleanupTimerStarted(); + + var key = request.RequestUri.GetLeftPart(UriPartial.Authority); + while (true) + { + var resolver = _resolvers.GetOrAdd( + key, + static (name, self) => self.CreateResolver(name), + this); + + var (valid, endpoint) = await resolver.TryGetEndpointAsync(request, cancellationToken).ConfigureAwait(false); + if (valid) + { + if (endpoint is null) + { + throw new InvalidOperationException($"Unable to resolve endpoint for service {resolver.ServiceName}"); + } + + return endpoint; + } + else + { + _resolvers.TryRemove(KeyValuePair.Create(resolver.ServiceName, resolver)); + } + } + } + + private void EnsureCleanupTimerStarted() + { + if (_cleanupTimer is not null) + { + return; + } + + lock (_lock) + { + if (_cleanupTimer is not null) + { + return; + } + + // Don't capture the current ExecutionContext and its AsyncLocals onto the timer + var restoreFlow = false; + try + { + if (!ExecutionContext.IsFlowSuppressed()) + { + ExecutionContext.SuppressFlow(); + restoreFlow = true; + } + + _cleanupTimer = timeProvider.CreateTimer(s_cleanupCallback, this, s_cleanupPeriod, s_cleanupPeriod); + } + finally + { + // Restore the current ExecutionContext + if (restoreFlow) + { + ExecutionContext.RestoreFlow(); + } + } + } + } + + /// + public async ValueTask DisposeAsync() + { + lock (_lock) + { + _cleanupTimer?.Dispose(); + _cleanupTimer = null; + } + + foreach (var resolver in _resolvers) + { + await resolver.Value.DisposeAsync().ConfigureAwait(false); + } + + _resolvers.Clear(); + if (_cleanupTask is not null) + { + await _cleanupTask.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + } + } + + private void CleanupResolvers() + { + lock (_lock) + { + if (_cleanupTask is null or { IsCompleted: true }) + { + _cleanupTask = CleanupResolversAsyncCore(); + } + } + } + + private async Task CleanupResolversAsyncCore() + { + List? cleanupTasks = null; + foreach (var (name, resolver) in _resolvers) + { + if (resolver.CanExpire() && _resolvers.TryRemove(name, out var _)) + { + cleanupTasks ??= new(); + cleanupTasks.Add(resolver.DisposeAsync().AsTask()); + } + } + + if (cleanupTasks is not null) + { + await Task.WhenAll(cleanupTasks).ConfigureAwait(false); + } + } + + private ResolverEntry CreateResolver(string serviceName) + { + var watcher = _watcherFactory.CreateWatcher(serviceName); + var selector = serviceProvider.GetService() ?? new RoundRobinServiceEndpointSelector(); + var result = new ResolverEntry(watcher, selector); + watcher.Start(); + return result; + } + + private sealed class ResolverEntry : IAsyncDisposable + { + private readonly ServiceEndpointWatcher _watcher; + private readonly IServiceEndpointSelector _selector; + private const ulong CountMask = ~(RecentUseFlag | DisposingFlag); + private const ulong RecentUseFlag = 1UL << 62; + private const ulong DisposingFlag = 1UL << 63; + private ulong _status; + private TaskCompletionSource? _onDisposed; + + public ResolverEntry(ServiceEndpointWatcher watcher, IServiceEndpointSelector selector) + { + _watcher = watcher; + _selector = selector; + _watcher.OnEndpointsUpdated += result => + { + if (result.ResolvedSuccessfully) + { + _selector.SetEndpoints(result.EndpointSource); + } + }; + } + + public string ServiceName => _watcher.ServiceName; + + public bool CanExpire() + { + // Read the status, clearing the recent use flag in the process. + var status = Interlocked.And(ref _status, ~RecentUseFlag); + + // The instance can be expired if there are no concurrent callers and the recent use flag was not set. + return (status & (CountMask | RecentUseFlag)) == 0; + } + + public async ValueTask<(bool Valid, ServiceEndpoint? Endpoint)> TryGetEndpointAsync(object? context, CancellationToken cancellationToken) + { + try + { + var status = Interlocked.Increment(ref _status); + if ((status & DisposingFlag) == 0) + { + // If the watcher is valid, resolve. + // We ensure that it will not be disposed while we are resolving. + await _watcher.GetEndpointsAsync(cancellationToken).ConfigureAwait(false); + var result = _selector.GetEndpoint(context); + return (true, result); + } + else + { + return (false, default); + } + } + finally + { + // Set the recent use flag to prevent the instance from being disposed. + Interlocked.Or(ref _status, RecentUseFlag); + + // If we are the last concurrent request to complete and the Disposing flag has been set, + // dispose the resolver now. DisposeAsync was prevented by concurrent requests. + var status = Interlocked.Decrement(ref _status); + if ((status & DisposingFlag) == DisposingFlag && (status & CountMask) == 0) + { + await DisposeAsyncCore().ConfigureAwait(false); + } + } + } + + public async ValueTask DisposeAsync() + { + if (_onDisposed is null) + { + Interlocked.CompareExchange(ref _onDisposed, new(TaskCreationOptions.RunContinuationsAsynchronously), null); + } + + var status = Interlocked.Or(ref _status, DisposingFlag); + if ((status & DisposingFlag) != DisposingFlag && (status & CountMask) == 0) + { + // If we are the one who flipped the Disposing flag and there are no concurrent requests, + // dispose the instance now. Concurrent requests are prevented from starting by the Disposing flag. + await DisposeAsyncCore().ConfigureAwait(false); + } + else + { + await _onDisposed.Task.ConfigureAwait(false); + } + } + + private async Task DisposeAsyncCore() + { + try + { + await _watcher.DisposeAsync().ConfigureAwait(false); + } + finally + { + Debug.Assert(_onDisposed is not null); + _onDisposed.SetResult(); + } + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Http/IServiceDiscoveryHttpMessageHandlerFactory.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Http/IServiceDiscoveryHttpMessageHandlerFactory.cs new file mode 100644 index 00000000000..0c5bd02d10d --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Http/IServiceDiscoveryHttpMessageHandlerFactory.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. + +namespace Microsoft.Extensions.ServiceDiscovery.Http; + +/// +/// Factory which creates instances which resolve endpoints using service discovery +/// before delegating to a provided handler. +/// +public interface IServiceDiscoveryHttpMessageHandlerFactory +{ + /// + /// Creates an instance which resolve endpoints using service discovery before + /// delegating to a provided handler. + /// + /// The handler to delegate to. + /// The new . + HttpMessageHandler CreateHandler(HttpMessageHandler handler); +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Http/ResolvingHttpClientHandler.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Http/ResolvingHttpClientHandler.cs new file mode 100644 index 00000000000..a0063ae476b --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Http/ResolvingHttpClientHandler.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 Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.ServiceDiscovery.Http; + +/// +/// which resolves endpoints using service discovery. +/// +internal sealed class ResolvingHttpClientHandler(HttpServiceEndpointResolver resolver, IOptions options) : HttpClientHandler +{ + private readonly HttpServiceEndpointResolver _resolver = resolver; + private readonly ServiceDiscoveryOptions _options = options.Value; + + /// + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var originalUri = request.RequestUri; + + try + { + if (originalUri?.Host is not null) + { + var result = await _resolver.GetEndpointAsync(request, cancellationToken).ConfigureAwait(false); + request.RequestUri = ResolvingHttpDelegatingHandler.GetUriWithEndpoint(originalUri, result, _options); + request.Headers.Host ??= result.Features.Get()?.HostName; + } + + return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + finally + { + request.RequestUri = originalUri; + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Http/ResolvingHttpDelegatingHandler.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Http/ResolvingHttpDelegatingHandler.cs new file mode 100644 index 00000000000..8f13bb60ab5 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Http/ResolvingHttpDelegatingHandler.cs @@ -0,0 +1,128 @@ +// 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; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.ServiceDiscovery.Http; + +/// +/// HTTP message handler which resolves endpoints using service discovery. +/// +internal sealed class ResolvingHttpDelegatingHandler : DelegatingHandler +{ + private readonly HttpServiceEndpointResolver _resolver; + private readonly ServiceDiscoveryOptions _options; + + /// + /// Initializes a new instance. + /// + /// The endpoint resolver. + /// The service discovery options. + public ResolvingHttpDelegatingHandler(HttpServiceEndpointResolver resolver, IOptions options) + { + _resolver = resolver; + _options = options.Value; + } + + /// + /// Initializes a new instance. + /// + /// The endpoint resolver. + /// The service discovery options. + /// The inner handler. + public ResolvingHttpDelegatingHandler(HttpServiceEndpointResolver resolver, IOptions options, HttpMessageHandler innerHandler) : base(innerHandler) + { + _resolver = resolver; + _options = options.Value; + } + + /// + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var originalUri = request.RequestUri; + if (originalUri?.Host is not null) + { + var result = await _resolver.GetEndpointAsync(request, cancellationToken).ConfigureAwait(false); + request.RequestUri = GetUriWithEndpoint(originalUri, result, _options); + request.Headers.Host ??= result.Features.Get()?.HostName; + } + + try + { + return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + finally + { + request.RequestUri = originalUri; + } + } + + internal static Uri GetUriWithEndpoint(Uri uri, ServiceEndpoint serviceEndpoint, ServiceDiscoveryOptions options) + { + var endPoint = serviceEndpoint.EndPoint; + UriBuilder result; + if (endPoint is UriEndPoint { Uri: { } ep }) + { + result = new UriBuilder(uri) + { + Scheme = ep.Scheme, + Host = ep.Host, + }; + + if (ep.Port > 0) + { + result.Port = ep.Port; + } + + if (ep.AbsolutePath.Length > 1) + { + result.Path = $"{ep.AbsolutePath.TrimEnd('/')}/{uri.AbsolutePath.TrimStart('/')}"; + } + } + else + { + string host; + int port; + switch (endPoint) + { + case IPEndPoint ip: + host = ip.Address.ToString(); + port = ip.Port; + break; + case DnsEndPoint dns: + host = dns.Host; + port = dns.Port; + break; + default: + throw new InvalidOperationException($"Endpoints of type {endPoint.GetType()} are not supported"); + } + + result = new UriBuilder(uri) + { + Host = host, + }; + + // Default to the default port for the scheme. + if (port > 0) + { + result.Port = port; + } + + if (uri.Scheme.IndexOf('+') > 0) + { + var scheme = uri.Scheme.Split('+')[0]; + if (options.AllowAllSchemes || options.AllowedSchemes.Contains(scheme, StringComparer.OrdinalIgnoreCase)) + { + result.Scheme = scheme; + } + else + { + throw new InvalidOperationException($"The scheme '{scheme}' is not allowed."); + } + } + } + + return result.Uri; + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Http/ServiceDiscoveryHttpMessageHandlerFactory.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Http/ServiceDiscoveryHttpMessageHandlerFactory.cs new file mode 100644 index 00000000000..e5e7f7587bb --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Http/ServiceDiscoveryHttpMessageHandlerFactory.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 Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.ServiceDiscovery.Http; + +internal sealed class ServiceDiscoveryHttpMessageHandlerFactory( + TimeProvider timeProvider, + IServiceProvider serviceProvider, + ServiceEndpointWatcherFactory factory, + IOptions options) : IServiceDiscoveryHttpMessageHandlerFactory +{ + public HttpMessageHandler CreateHandler(HttpMessageHandler handler) + { + var registry = new HttpServiceEndpointResolver(factory, serviceProvider, timeProvider); + return new ResolvingHttpDelegatingHandler(registry, options, handler); + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Internal/ServiceDiscoveryOptionsValidator.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Internal/ServiceDiscoveryOptionsValidator.cs new file mode 100644 index 00000000000..fae7bd6f4fc --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Internal/ServiceDiscoveryOptionsValidator.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. + +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.ServiceDiscovery.Internal; + +internal sealed class ServiceDiscoveryOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, ServiceDiscoveryOptions options) + { + if (options.AllowedSchemes is null) + { + return ValidateOptionsResult.Fail("At least one allowed scheme must be specified."); + } + + return ValidateOptionsResult.Success; + } +} + diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Internal/ServiceEndpointResolverResult.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Internal/ServiceEndpointResolverResult.cs new file mode 100644 index 00000000000..675941bb955 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Internal/ServiceEndpointResolverResult.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Extensions.ServiceDiscovery.Internal; + +/// +/// Represents the result of service endpoint resolution. +/// +/// The endpoint collection. +/// The exception which occurred during resolution. +internal sealed class ServiceEndpointResolverResult(ServiceEndpointSource? endpointSource, Exception? exception) +{ + /// + /// Gets the exception which occurred during resolution. + /// + public Exception? Exception { get; } = exception; + + /// + /// Gets a value indicating whether resolution completed successfully. + /// + [MemberNotNullWhen(true, nameof(EndpointSource))] + public bool ResolvedSuccessfully => Exception is null; + + /// + /// Gets the endpoints. + /// + public ServiceEndpointSource? EndpointSource { get; } = endpointSource; +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/LoadBalancing/IServiceEndpointSelector.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/LoadBalancing/IServiceEndpointSelector.cs new file mode 100644 index 00000000000..2d81ff38601 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/LoadBalancing/IServiceEndpointSelector.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. + +namespace Microsoft.Extensions.ServiceDiscovery.LoadBalancing; + +/// +/// Selects endpoints from a collection of endpoints. +/// +internal interface IServiceEndpointSelector +{ + /// + /// Sets the collection of endpoints which this instance will select from. + /// + /// The collection of endpoints to select from. + void SetEndpoints(ServiceEndpointSource endpoints); + + /// + /// Selects an endpoints from the collection provided by the most recent call to . + /// + /// The context. + /// An endpoint. + ServiceEndpoint GetEndpoint(object? context); +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/LoadBalancing/RoundRobinServiceEndpointSelector.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/LoadBalancing/RoundRobinServiceEndpointSelector.cs new file mode 100644 index 00000000000..e7e51bc6021 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/LoadBalancing/RoundRobinServiceEndpointSelector.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.ServiceDiscovery.LoadBalancing; + +/// +/// Selects endpoints by iterating through the list of endpoints in a round-robin fashion. +/// +internal sealed class RoundRobinServiceEndpointSelector : IServiceEndpointSelector +{ + private uint _next; + private IReadOnlyList? _endpoints; + + /// + public void SetEndpoints(ServiceEndpointSource endpoints) + { + _endpoints = endpoints.Endpoints; + } + + /// + public ServiceEndpoint GetEndpoint(object? context) + { + if (_endpoints is not { Count: > 0 } collection) + { + throw new InvalidOperationException("The endpoint collection contains no endpoints"); + } + + return collection[(int)(Interlocked.Increment(ref _next) % collection.Count)]; + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Microsoft.Extensions.ServiceDiscovery.csproj b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Microsoft.Extensions.ServiceDiscovery.csproj new file mode 100644 index 00000000000..51631d328c0 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Microsoft.Extensions.ServiceDiscovery.csproj @@ -0,0 +1,39 @@ + + + + $(TargetFrameworks);netstandard2.0 + true + Provides extensions to HttpClient that enable service discovery based on configuration. + Open + true + + $(NoWarn);CS8600;CS8602;CS8604;IDE0040;IDE0055;IDE0058;IDE1006;CA1307;CA1310;CA1849;CA2007;CA2213;SA1204;SA1128;SA1205;SA1405;SA1612;SA1623;SA1625;SA1642;S1144;S1449;S2302;S2692;S3872;S4457;EA0000;EA0009;EA0014;LA0001;LA0003;LA0008;VSTHRD200 + enable + false + + + + ServiceDiscovery + normal + 9.5.1 + 75 + 75 + + + + + + + + + + + + + + + + + + + diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Microsoft.Extensions.ServiceDiscovery.json b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/Microsoft.Extensions.ServiceDiscovery.json new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/PassThrough/PassThroughServiceEndpointProvider.Log.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/PassThrough/PassThroughServiceEndpointProvider.Log.cs new file mode 100644 index 00000000000..9f6e9ce0ccb --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/PassThrough/PassThroughServiceEndpointProvider.Log.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; + +namespace Microsoft.Extensions.ServiceDiscovery.PassThrough; + +internal sealed partial class PassThroughServiceEndpointProvider +{ + private static partial class Log + { + [LoggerMessage(1, LogLevel.Debug, "Using pass-through service endpoint provider for service '{ServiceName}'.", EventName = "UsingPassThrough")] + internal static partial void UsingPassThrough(ILogger logger, string serviceName); + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/PassThrough/PassThroughServiceEndpointProvider.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/PassThrough/PassThroughServiceEndpointProvider.cs new file mode 100644 index 00000000000..478d81d42dc --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/PassThrough/PassThroughServiceEndpointProvider.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.Net; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Extensions.ServiceDiscovery.PassThrough; + +/// +/// Service endpoint provider which passes through the provided value. +/// +internal sealed partial class PassThroughServiceEndpointProvider(ILogger logger, string serviceName, EndPoint endPoint) : IServiceEndpointProvider +{ + public ValueTask PopulateAsync(IServiceEndpointBuilder endpoints, CancellationToken cancellationToken) + { + if (endpoints.Endpoints.Count == 0) + { + Log.UsingPassThrough(logger, serviceName); + var ep = ServiceEndpoint.Create(endPoint); + ep.Features.Set(this); + endpoints.Endpoints.Add(ep); + } + + return default; + } + + public ValueTask DisposeAsync() => default; + + public override string ToString() => "Pass-through"; +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/PassThrough/PassThroughServiceEndpointProviderFactory.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/PassThrough/PassThroughServiceEndpointProviderFactory.cs new file mode 100644 index 00000000000..2bf8c0cb481 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/PassThrough/PassThroughServiceEndpointProviderFactory.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.Diagnostics.CodeAnalysis; +using System.Net; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Extensions.ServiceDiscovery.PassThrough; + +/// +/// Service endpoint provider factory which creates pass-through providers. +/// +internal sealed class PassThroughServiceEndpointProviderFactory(ILogger logger) : IServiceEndpointProviderFactory +{ + /// + public bool TryCreateProvider(ServiceEndpointQuery query, [NotNullWhen(true)] out IServiceEndpointProvider? provider) + { + var serviceName = query.ToString()!; + if (!TryCreateEndPoint(serviceName, out var endPoint)) + { + // Propagate the value through regardless, leaving it to the caller to interpret it. + endPoint = new DnsEndPoint(serviceName, 0); + } + + provider = new PassThroughServiceEndpointProvider(logger, serviceName, endPoint); + return true; + } + + private static bool TryCreateEndPoint(string serviceName, [NotNullWhen(true)] out EndPoint? endPoint) + { + if ((serviceName.Contains("://", StringComparison.Ordinal) || !Uri.TryCreate($"fakescheme://{serviceName}", default, out var uri)) && !Uri.TryCreate(serviceName, default, out uri)) + { + endPoint = null; + return false; + } + + var uriHost = uri.Host; + var segmentSeparatorIndex = uriHost.IndexOf('.'); + string host; + if (uriHost.StartsWith('_') && segmentSeparatorIndex > 1 && uriHost[^1] != '.') + { + // Skip the endpoint name, including its prefix ('_') and suffix ('.'). + host = uriHost[(segmentSeparatorIndex + 1)..]; + } + else + { + host = uriHost; + } + + var port = uri.Port > 0 ? uri.Port : 0; + if (IPAddress.TryParse(host, out var ip)) + { + endPoint = new IPEndPoint(ip, port); + } + else if (!string.IsNullOrEmpty(host)) + { + endPoint = new DnsEndPoint(host, port); + } + else + { + endPoint = null; + return false; + } + + return true; + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/README.md b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/README.md new file mode 100644 index 00000000000..b767bb41e83 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/README.md @@ -0,0 +1,276 @@ +# Microsoft.Extensions.ServiceDiscovery + +The `Microsoft.Extensions.ServiceDiscovery` library is designed to simplify the integration of service discovery patterns in .NET applications. Service discovery is a key component of most distributed systems and microservices architectures. This library provides a straightforward way to resolve service names to endpoint addresses. + +In typical systems, service configuration changes over time. Service discovery accounts for by monitoring endpoint configuration using push-based notifications where supported, falling back to polling in other cases. When endpoints are refreshed, callers are notified so that they can observe the refreshed results. + +## How it works + +Service discovery uses configured _providers_ to resolve service endpoints. When service endpoints are resolved, each registered provider is called in the order of registration to contribute to a collection of service endpoints (an instance of `ServiceEndpointSource`). + +Providers implement the `IServiceEndpointProvider` interface. They are created by an instance of `IServiceEndpointProviderProvider`, which are registered with the [.NET dependency injection](https://learn.microsoft.com/dotnet/core/extensions/dependency-injection) system. + +Developers typically add service discovery to their [`HttpClient`](https://learn.microsoft.com/dotnet/fundamentals/networking/http/httpclient) using the [`IHttpClientFactory`](https://learn.microsoft.com/dotnet/core/extensions/httpclient-factory) with the `AddServiceDiscovery` extension method. + +Services can be resolved directly by calling `ServiceEndpointResolver`'s `GetEndpointsAsync` method, which returns a collection of resolved endpoints. + +### Change notifications + +Service configuration can change over time. Service discovery accounts for by monitoring endpoint configuration using push-based notifications where supported, falling back to polling in other cases. When endpoints are refreshed, callers are notified so that they can observe the refreshed results. To subscribe to notifications, callers use the `ChangeToken` property of `ServiceEndpointCollection`. For more information on change tokens, see [Detect changes with change tokens in ASP.NET Core](https://learn.microsoft.com/aspnet/core/fundamentals/change-tokens?view=aspnetcore-7.0). + +### Extensibility using features + +Service endpoints (`ServiceEndpoint` instances) and collections of service endpoints (`ServiceEndpointCollection` instances) expose an extensible [`IFeatureCollection`](https://learn.microsoft.com/dotnet/api/microsoft.aspnetcore.http.features.ifeaturecollection) via their `Features` property. Features are exposed as interfaces accessible on the feature collection. These interfaces can be added, modified, wrapped, replaced or even removed at resolution time by providers. Features which may be available on a `ServiceEndpoint` include: + +* `IHostNameFeature`: exposes the host name of the resolved endpoint, intended for use with [Server Name Identification (SNI)](https://en.wikipedia.org/wiki/Server_Name_Indication) and [Transport Layer Security (TLS)](https://en.wikipedia.org/wiki/Transport_Layer_Security). + +### Resolution order + +The providers included in the `Microsoft.Extensions.ServiceDiscovery` series of packages skip resolution if there are existing endpoints in the collection when they are called. For example, consider a case where the following providers are registered: _Configuration_, _DNS SRV_, _Pass-through_. When resolution occurs, the providers will be called in-order. If the _Configuration_ providers discovers no endpoints, the _DNS SRV_ provider will perform resolution and may add one or more endpoints. If the _DNS SRV_ provider adds an endpoint to the collection, the _Pass-through_ provider will skip its resolution and will return immediately instead. + +## Getting Started + +### Installation + +To install the library, use the following NuGet command: + +```dotnetcli +dotnet add package Microsoft.Extensions.ServiceDiscovery +``` + +### Usage example + +In the _AppHost.cs_ file of your project, call the `AddServiceDiscovery` extension method to add service discovery to the host, configuring default service endpoint providers. + +```csharp +builder.Services.AddServiceDiscovery(); +``` + +Add service discovery to an individual `IHttpClientBuilder` by calling the `AddServiceDiscovery` extension method: + +```csharp +builder.Services.AddHttpClient(c => +{ + c.BaseAddress = new("https://catalog")); +}).AddServiceDiscovery(); +``` + +Alternatively, you can add service discovery to all `HttpClient` instances by default: + +```csharp +builder.Services.ConfigureHttpClientDefaults(http => +{ + // Turn on service discovery by default + http.AddServiceDiscovery(); +}); +``` + +### Resolving service endpoints from configuration + +The `AddServiceDiscovery` extension method adds a configuration-based endpoint provider by default. +This provider reads endpoints from the [.NET Configuration system](https://learn.microsoft.com/dotnet/core/extensions/configuration). +The library supports configuration through `appsettings.json`, environment variables, or any other `IConfiguration` source. + +Here is an example demonstrating how to configure endpoints for the service named _catalog_ via `appsettings.json`: + +```json +{ + "Services": { + "catalog": { + "https": [ + "https://localhost:8443", + "https://10.46.24.90:443" + ] + } + } +} +``` + +The above example adds two endpoints for the service named _catalog_: `https://localhost:8443`, and `"https://10.46.24.90:443"`. +Each time the _catalog_ is resolved, one of these endpoints will be selected. + +If service discovery was added to the host using the `AddServiceDiscoveryCore` extension method on `IServiceCollection`, the configuration-based endpoint provider can be added by calling the `AddConfigurationServiceEndpointProvider` extension method on `IServiceCollection`. + +### Configuration + +The configuration provider is configured using the `ConfigurationServiceEndpointProviderOptions` class, which offers these configuration options: + +* **`SectionName`**: The name of the configuration section that contains service endpoints. It defaults to `"Services"`. + +* **`ShouldApplyHostNameMetadata`**: A delegate used to determine if host name metadata should be applied to resolved endpoints. It defaults to a function that returns `false`. + +To configure these options, you can use the `Configure` extension method on the `IServiceCollection` within your application's startup class or main program file: + +```csharp +var builder = WebApplication.CreateBuilder(args); + +builder.Services.Configure(options => +{ + options.SectionName = "MyServiceEndpoints"; + + // Configure the logic for applying host name metadata + options.ShouldApplyHostNameMetadata = endpoint => + { + // Your custom logic here. For example: + return endpoint.Endpoint is DnsEndPoint dnsEp && dnsEp.Host.StartsWith("internal"); + }; +}); +``` + +This example demonstrates setting a custom section name for your service endpoints and providing a custom logic for applying host name metadata based on a condition. + +## Scheme selection when resolving HTTP(S) endpoints + +It is common to use HTTP while developing and testing a service locally and HTTPS when the service is deployed. Service Discovery supports this by allowing for a priority list of URI schemes to be specified in the input string given to Service Discovery. Service Discovery will attempt to resolve the services for the schemes in order and will stop after an endpoint is found. URI schemes are separated by a `+` character, for example: `"https+http://basket"`. Service Discovery will first try to find HTTPS endpoints for the `"basket"` service and will then fall back to HTTP endpoints. If any HTTPS endpoint is found, Service Discovery will not include HTTP endpoints. +Schemes can be filtered by configuring the `AllowedSchemes` and `AllowAllSchemes` properties on `ServiceDiscoveryOptions`. The `AllowAllSchemes` property is used to indicate that all schemes are allowed. By default, `AllowAllSchemes` is `true` and all schemes are allowed. Schemes can be restricted by setting `AllowAllSchemes` to `false` and adding allowed schemes to the `AllowedSchemes` property. For example, to allow only HTTPS: + +```csharp +services.Configure(options => +{ + options.AllowAllSchemes = false; + options.AllowedSchemes = ["https"]; +}); +``` + +To explicitly allow all schemes, set the `ServiceDiscoveryOptions.AllowAllSchemes` property to `true`: + +```csharp +services.Configure(options => options.AllowAllSchemes = true); +``` + +## Resolving service endpoints using platform-provided service discovery + +Some platforms, such as Azure Container Apps and Kubernetes (if configured), provide functionality for service discovery without the need for a service discovery client library. When an application is deployed to one of these environments, it may be preferable to use the platform's existing functionality instead. The pass-through provider exists to support this scenario while still allowing other provider (such as configuration) to be used in other environments, such as on the developer's machine, without requiring a code change or conditional guards. + +The pass-through provider performs no external resolution and instead resolves endpoints by returning the input service name represented as a `DnsEndPoint`. + +The pass-through provider is configured by-default when adding service discovery via the `AddServiceDiscovery` extension method. + +If service discovery was added to the host using the `AddServiceDiscoveryCore` extension method on `IServiceCollection`, the pass-through provider can be added by calling the `AddPassThroughServiceEndpointProvider` extension method on `IServiceCollection`. + +In the case of Azure Container Apps, the service name should match the app name. For example, if you have a service named "basket", then you should have a corresponding Azure Container App named "basket". + +## Service discovery in .NET Aspire + +.NET Aspire includes functionality for configuring the service discovery at development and testing time. This functionality works by providing configuration in the format expected by the _configuration-based endpoint provider_ described above from the .NET Aspire AppHost project to the individual service projects added to the application model. + +Configuration for service discovery is only added for services which are referenced by a given project. For example, consider the following AppHost program: + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +var catalog = builder.AddProject("catalog"); +var basket = builder.AddProject("basket"); + +var frontend = builder.AddProject("frontend") + .WithReference(basket) + .WithReference(catalog); +``` + +In the above example, the _frontend_ project references the _catalog_ project and the _basket_ project. The two `WithReference` calls instruct the .NET Aspire application to pass service discovery information for the referenced projects (_catalog_, and _basket_) into the _frontend_ project. + +## Named endpoints + +Some services expose multiple, named endpoints. Named endpoints can be resolved by specifying the endpoint name in the host portion of the HTTP request URI, following the format `scheme://_endpointName.serviceName`. For example, if a service named "basket" exposes an endpoint named "dashboard", then the URI `https+http://_dashboard.basket` can be used to specify this endpoint, for example: + +```csharp +builder.Services.AddHttpClient( + static client => client.BaseAddress = new("https+http://basket")); +builder.Services.AddHttpClient( + static client => client.BaseAddress = new("https+http://_dashboard.basket")); +``` + +In the above example, two `HttpClient`s are added: one for the core basket service and one for the basket service's dashboard. + +### Named endpoints using configuration + +With the configuration-based endpoint provider, named endpoints can be specified in configuration by prefixing the endpoint value with `_endpointName.`, where `endpointName` is the endpoint name. For example, consider this _appsettings.json_ configuration which defined a default endpoint (with no name) and an endpoint named "dashboard": + +```json +{ + "Services": { + "basket": { + "https": "https://10.2.3.4:8080", /* the https endpoint, requested via https://basket */ + "dashboard": "https://10.2.3.4:9999" /* the "dashboard" endpoint, requested via https://_dashboard.basket */ + } + } +} +``` + +### Named endpoints in .NET Aspire + +.NET Aspire uses the configuration-based provider at development and testing time, providing convenient APIs for configuring named endpoints which are then translated into configuration for the target services. For example: + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +var basket = builder.AddProject("basket") + .WithEndpoint(hostPort: 9999, scheme: "https", name: "admin"); + +var adminDashboard = builder.AddProject("admin-dashboard") + .WithReference(basket.GetEndpoint("admin")); + +var frontend = builder.AddProject("frontend") + .WithReference(basket); +``` + +In the above example, the "basket" service exposes an "admin" endpoint in addition to the default "http" endpoint which it exposes. This endpoint is consumed by the "admin-dashboard" project, while the "frontend" project consumes all endpoints from "basket". Alternatively, the "frontend" project could be made to consume only the default "http" endpoint from "basket" by using the `GetEndpoint(string name)` method, as in the following example: + +```csharp + +// The preceding code is the same as in the above sample + +var frontend = builder.AddProject("frontend") + .WithReference(basket.GetEndpoint("https")); +``` + +### Named endpoints in Kubernetes using DNS SRV + +When deploying to Kubernetes, the DNS SRV service endpoint provider can be used to resolve named endpoints. For example, the following resource definition will result in a DNS SRV record being created for an endpoint named "default" and an endpoint named "dashboard", both on the service named "basket". + +```yml +apiVersion: v1 +kind: Service +metadata: + name: basket +spec: + selector: + name: basket-service + clusterIP: None + ports: + - name: default + port: 8080 + - name: dashboard + port: 8888 +``` + +To configure a service to resolve the "dashboard" endpoint on the "basket" service, add the DNS SRV service endpoint provider to the host builder as follows: + +```csharp +builder.Services.AddServiceDiscoveryCore(); +builder.Services.AddDnsSrvServiceEndpointProvider(); +``` + +The special port name "default" is used to specify the default endpoint, resolved using the URI `https://basket`. + +As in the previous example, add service discovery to an `HttpClient` for the basket service: + +```csharp +builder.Services.AddHttpClient( + static client => client.BaseAddress = new("https://basket")); +``` + +Similarly, the "dashboard" endpoint can be targeted as follows: + +```csharp +builder.Services.AddHttpClient( + static client => client.BaseAddress = new("https://_dashboard.basket")); +``` + +### Named endpoints in Azure Container Apps + +Named endpoints are not currently supported for services deployed to Azure Container Apps. + +## Feedback & contributing + +https://github.com/dotnet/aspire diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceDiscoveryHttpClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceDiscoveryHttpClientBuilderExtensions.cs new file mode 100644 index 00000000000..d2890ae8c8d --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceDiscoveryHttpClientBuilderExtensions.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 Microsoft.Extensions.Options; +using Microsoft.Extensions.ServiceDiscovery; +using Microsoft.Extensions.ServiceDiscovery.Http; + +#if NET +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Http; +#endif + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extensions for configuring with service discovery. +/// +public static class ServiceDiscoveryHttpClientBuilderExtensions +{ + /// + /// Adds service discovery to the . + /// + /// The builder. + /// The builder. + public static IHttpClientBuilder AddServiceDiscovery(this IHttpClientBuilder httpClientBuilder) + { + ArgumentNullException.ThrowIfNull(httpClientBuilder); + + var services = httpClientBuilder.Services; + services.AddServiceDiscoveryCore(); + httpClientBuilder.AddHttpMessageHandler(services => + { + var timeProvider = services.GetService() ?? TimeProvider.System; + var watcherFactory = services.GetRequiredService(); + var registry = new HttpServiceEndpointResolver(watcherFactory, services, timeProvider); + var options = services.GetRequiredService>(); + return new ResolvingHttpDelegatingHandler(registry, options); + }); + +#if NET + // Configure the HttpClient to disable gRPC load balancing. + // This is done on all HttpClient instances but only impacts gRPC clients. + AddDisableGrpcLoadBalancingFilter(httpClientBuilder.Services, httpClientBuilder.Name); +#endif + return httpClientBuilder; + } + +#if NET + private static void AddDisableGrpcLoadBalancingFilter(IServiceCollection services, string? name) + { + // A filter is used because it will always run last. This is important because the disable + // property needs to be added to all SocketsHttpHandler instances, including those specified + // with ConfigurePrimaryHttpMessageHandler. + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.Configure(o => o.ClientNames.Add(name)); + } + + private sealed class DisableGrpcLoadBalancingFilterOptions + { + // Names of clients. A null value means it is applied globally to all clients. + public HashSet ClientNames { get; } = new HashSet(); + } + + private sealed class DisableGrpcLoadBalancingFilter : IHttpMessageHandlerBuilderFilter + { + private readonly DisableGrpcLoadBalancingFilterOptions _options; + private readonly bool _global; + + public DisableGrpcLoadBalancingFilter(IOptions options) + { + _options = options.Value; + _global = _options.ClientNames.Contains(null); + } + + public Action Configure(Action next) + { + return (builder) => + { + // Run other configuration first, we want to decorate. + next(builder); + if (_global || _options.ClientNames.Contains(builder.Name)) + { + if (builder.PrimaryHandler is SocketsHttpHandler socketsHttpHandler) + { + // gRPC knows about this property and uses it to check whether + // load balancing is disabled when the GrpcChannel is created. + // see https://github.com/grpc/grpc-dotnet/blob/1625f8942791c82d700802fc7278c543025f0fd3/src/Grpc.Net.Client/GrpcChannel.cs#L286 + socketsHttpHandler.Properties["__GrpcLoadBalancingDisabled"] = true; + } + } + }; + } + } +#endif +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceDiscoveryOptions.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceDiscoveryOptions.cs new file mode 100644 index 00000000000..a6fd7123aa7 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceDiscoveryOptions.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.Collections.ObjectModel; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.Extensions.ServiceDiscovery; + +/// +/// Options for service endpoint resolution. +/// +public sealed class ServiceDiscoveryOptions +{ + /// + /// Gets or sets a value indicating whether all URI schemes for URIs resolved by the service discovery system are allowed. + /// If this value is , all URI schemes are allowed. + /// If this value is , only the schemes specified in are allowed. + /// + public bool AllowAllSchemes { get; set; } = true; + + /// + /// Gets or sets the period between polling attempts for providers which do not support refresh notifications via . + /// + public TimeSpan RefreshPeriod { get; set; } = TimeSpan.FromSeconds(60); + + /// + /// Gets or sets the collection of allowed URI schemes for URIs resolved by the service discovery system when multiple schemes are specified, for example "https+http://_endpoint.service". + /// + /// + /// When is , this property is ignored. + /// + public IList AllowedSchemes { get; set; } = new List(); + + /// + /// Filters the specified URI schemes to include only those that are applicable, based on the current settings. + /// + /// The URI schemes to be evaluated against the allowed schemes. + /// + /// The URI schemes that are applicable. If no schemes are requested, all allowed schemes are returned. + /// If all schemes are allowed, only the requested schemes are returned. + /// Otherwise, the intersection of requested and allowed schemes is returned. + /// + public IReadOnlyList ApplyAllowedSchemes(IReadOnlyList requestedSchemes) + { + ArgumentNullException.ThrowIfNull(requestedSchemes); + + if (requestedSchemes.Count > 0) + { + if (AllowAllSchemes) + { + return requestedSchemes; + } + + List result = []; + foreach (var s in requestedSchemes) + { + foreach (var allowed in AllowedSchemes) + { + if (string.Equals(s, allowed, StringComparison.OrdinalIgnoreCase)) + { + result.Add(s); + break; + } + } + } + + return result.AsReadOnly(); + } + + // If no schemes were specified, but a set of allowed schemes were specified, allow those. + return new ReadOnlyCollection(AllowedSchemes); + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceDiscoveryServiceCollectionExtensions.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceDiscoveryServiceCollectionExtensions.cs new file mode 100644 index 00000000000..8de759af1f6 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceDiscoveryServiceCollectionExtensions.cs @@ -0,0 +1,119 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.ServiceDiscovery; +using Microsoft.Extensions.ServiceDiscovery.Configuration; +using Microsoft.Extensions.ServiceDiscovery.Http; +using Microsoft.Extensions.ServiceDiscovery.Internal; +using Microsoft.Extensions.ServiceDiscovery.LoadBalancing; +using Microsoft.Extensions.ServiceDiscovery.PassThrough; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extension methods for configuring service discovery. +/// +public static class ServiceDiscoveryServiceCollectionExtensions +{ + /// + /// Adds the core service discovery services and configures defaults. + /// + /// The service collection. + /// The service collection. + public static IServiceCollection AddServiceDiscovery(this IServiceCollection services) + => AddServiceDiscoveryCore(services) + .AddConfigurationServiceEndpointProvider() + .AddPassThroughServiceEndpointProvider(); + + /// + /// Adds the core service discovery services and configures defaults. + /// + /// The service collection. + /// The delegate used to configure service discovery options. + /// The service collection. + public static IServiceCollection AddServiceDiscovery(this IServiceCollection services, Action configureOptions) + => AddServiceDiscoveryCore(services, configureOptions: configureOptions) + .AddConfigurationServiceEndpointProvider() + .AddPassThroughServiceEndpointProvider(); + + /// + /// Adds the core service discovery services. + /// + /// The service collection. + /// The service collection. + public static IServiceCollection AddServiceDiscoveryCore(this IServiceCollection services) => AddServiceDiscoveryCore(services, configureOptions: _ => { }); + + /// + /// Adds the core service discovery services. + /// + /// The service collection. + /// The delegate used to configure service discovery options. + /// The service collection. + public static IServiceCollection AddServiceDiscoveryCore(this IServiceCollection services, Action configureOptions) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configureOptions); + + services.AddOptions(); + services.AddLogging(); + services.TryAddTransient, ServiceDiscoveryOptionsValidator>(); + services.TryAddSingleton(_ => TimeProvider.System); + services.TryAddTransient(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(sp => new ServiceEndpointResolver(sp.GetRequiredService(), sp.GetRequiredService())); + if (configureOptions is not null) + { + services.Configure(configureOptions); + } + + return services; + } + + /// + /// Configures a service discovery endpoint provider which uses to resolve endpoints. + /// + /// The service collection. + /// The service collection. + public static IServiceCollection AddConfigurationServiceEndpointProvider(this IServiceCollection services) + => AddConfigurationServiceEndpointProvider(services, configureOptions: _ => { }); + + /// + /// Configures a service discovery endpoint provider which uses to resolve endpoints. + /// + /// The delegate used to configure the provider. + /// The service collection. + /// The service collection. + public static IServiceCollection AddConfigurationServiceEndpointProvider(this IServiceCollection services, Action configureOptions) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configureOptions); + + services.AddServiceDiscoveryCore(); + services.AddSingleton(); + services.AddTransient, ConfigurationServiceEndpointProviderOptionsValidator>(); + if (configureOptions is not null) + { + services.Configure(configureOptions); + } + + return services; + } + + /// + /// Configures a service discovery endpoint provider which passes through the input without performing resolution. + /// + /// The service collection. + /// The service collection. + public static IServiceCollection AddPassThroughServiceEndpointProvider(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddServiceDiscoveryCore(); + services.AddSingleton(); + return services; + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceEndpointBuilder.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceEndpointBuilder.cs new file mode 100644 index 00000000000..947f24b2f81 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceEndpointBuilder.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 Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.Extensions.ServiceDiscovery; + +/// +/// A mutable collection of service endpoints. +/// +internal sealed class ServiceEndpointBuilder : IServiceEndpointBuilder +{ + private readonly List _endpoints = new(); + private readonly List _changeTokens = new(); + private readonly FeatureCollection _features = new FeatureCollection(); + + /// + /// Adds a change token. + /// + /// The change token. + public void AddChangeToken(IChangeToken changeToken) + { + _changeTokens.Add(changeToken); + } + + /// + /// Gets the feature collection. + /// + public IFeatureCollection Features => _features; + + /// + /// Gets the endpoints. + /// + public IList Endpoints => _endpoints; + + /// + /// Creates a from the provided instance. + /// + /// The service endpoint source. + public ServiceEndpointSource Build() + { + return new ServiceEndpointSource(_endpoints, new CompositeChangeToken(_changeTokens), _features); + } +} + diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceEndpointResolver.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceEndpointResolver.cs new file mode 100644 index 00000000000..e928980700c --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceEndpointResolver.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.Collections.Concurrent; +using System.Diagnostics; + +namespace Microsoft.Extensions.ServiceDiscovery; + +/// +/// Resolves service names to collections of endpoints. +/// +public sealed class ServiceEndpointResolver : IAsyncDisposable +{ + private static readonly TimerCallback s_cleanupCallback = s => ((ServiceEndpointResolver)s!).CleanupResolvers(); + private static readonly TimeSpan s_cleanupPeriod = TimeSpan.FromSeconds(10); + + private readonly object _lock = new(); + private readonly ServiceEndpointWatcherFactory _watcherFactory; + private readonly TimeProvider _timeProvider; + private readonly ConcurrentDictionary _resolvers = new(); + private ITimer? _cleanupTimer; + private Task? _cleanupTask; + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// + /// The watcher factory. + /// The time provider. + internal ServiceEndpointResolver(ServiceEndpointWatcherFactory watcherFactory, TimeProvider timeProvider) + { + _watcherFactory = watcherFactory; + _timeProvider = timeProvider; + } + + /// + /// Resolves and returns service endpoints for the specified service. + /// + /// The service name. + /// A . + /// The resolved service endpoints. + public async ValueTask GetEndpointsAsync(string serviceName, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(serviceName); + ObjectDisposedException.ThrowIf(_disposed, this); + + EnsureCleanupTimerStarted(); + + while (true) + { + ObjectDisposedException.ThrowIf(_disposed, this); + cancellationToken.ThrowIfCancellationRequested(); + var resolver = _resolvers.GetOrAdd( + serviceName, + static (name, self) => self.CreateResolver(name), + this); + + var (valid, result) = await resolver.GetEndpointsAsync(cancellationToken).ConfigureAwait(false); + if (valid) + { + if (result is null) + { + throw new InvalidOperationException($"Unable to resolve endpoints for service {resolver.ServiceName}"); + } + + return result; + } + else + { + _resolvers.TryRemove(KeyValuePair.Create(resolver.ServiceName, resolver)); + } + } + } + + private void EnsureCleanupTimerStarted() + { + if (_cleanupTimer is not null) + { + return; + } + + lock (_lock) + { + if (_cleanupTimer is not null) + { + return; + } + + // Don't capture the current ExecutionContext and its AsyncLocals onto the timer + var restoreFlow = false; + try + { + if (!ExecutionContext.IsFlowSuppressed()) + { + ExecutionContext.SuppressFlow(); + restoreFlow = true; + } + + _cleanupTimer = _timeProvider.CreateTimer(s_cleanupCallback, this, s_cleanupPeriod, s_cleanupPeriod); + } + finally + { + // Restore the current ExecutionContext + if (restoreFlow) + { + ExecutionContext.RestoreFlow(); + } + } + } + } + + /// + public async ValueTask DisposeAsync() + { + lock (_lock) + { + _disposed = true; + _cleanupTimer?.Dispose(); + _cleanupTimer = null; + } + + foreach (var resolver in _resolvers) + { + await resolver.Value.DisposeAsync().ConfigureAwait(false); + } + + _resolvers.Clear(); + if (_cleanupTask is not null) + { + await _cleanupTask.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + } + } + + private void CleanupResolvers() + { + lock (_lock) + { + if (_cleanupTask is null or { IsCompleted: true }) + { + _cleanupTask = CleanupResolversAsyncCore(); + } + } + } + + private async Task CleanupResolversAsyncCore() + { + List? cleanupTasks = null; + foreach (var (name, resolver) in _resolvers) + { + if (resolver.CanExpire() && _resolvers.TryRemove(name, out var _)) + { + cleanupTasks ??= new(); + cleanupTasks.Add(resolver.DisposeAsync().AsTask()); + } + } + + if (cleanupTasks is not null) + { + await Task.WhenAll(cleanupTasks).ConfigureAwait(false); + } + } + + private ResolverEntry CreateResolver(string serviceName) + { + var resolver = _watcherFactory.CreateWatcher(serviceName); + resolver.Start(); + return new ResolverEntry(resolver); + } + + private sealed class ResolverEntry(ServiceEndpointWatcher watcher) : IAsyncDisposable + { + private readonly ServiceEndpointWatcher _watcher = watcher; + private const ulong CountMask = ~(RecentUseFlag | DisposingFlag); + private const ulong RecentUseFlag = 1UL << 62; + private const ulong DisposingFlag = 1UL << 63; + private ulong _status; + private TaskCompletionSource? _onDisposed; + + public string ServiceName => _watcher.ServiceName; + + public bool CanExpire() + { + // Read the status, clearing the recent use flag in the process. + var status = Interlocked.And(ref _status, ~RecentUseFlag); + + // The instance can be expired if there are no concurrent callers and the recent use flag was not set. + return (status & (CountMask | RecentUseFlag)) == 0; + } + + public async ValueTask<(bool Valid, ServiceEndpointSource? Endpoints)> GetEndpointsAsync(CancellationToken cancellationToken) + { + try + { + var status = Interlocked.Increment(ref _status); + if ((status & DisposingFlag) == 0) + { + // If the watcher is valid, resolve. + // We ensure that it will not be disposed while we are resolving. + var endpoints = await _watcher.GetEndpointsAsync(cancellationToken).ConfigureAwait(false); + return (true, endpoints); + } + else + { + return (false, default); + } + } + finally + { + // Set the recent use flag to prevent the instance from being disposed. + Interlocked.Or(ref _status, RecentUseFlag); + + // If we are the last concurrent request to complete and the Disposing flag has been set, + // dispose the resolver now. DisposeAsync was prevented by concurrent requests. + var status = Interlocked.Decrement(ref _status); + if ((status & DisposingFlag) == DisposingFlag && (status & CountMask) == 0) + { + await DisposeAsyncCore().ConfigureAwait(false); + } + } + } + + public async ValueTask DisposeAsync() + { + if (_onDisposed is null) + { + Interlocked.CompareExchange(ref _onDisposed, new(TaskCreationOptions.RunContinuationsAsynchronously), null); + } + + var status = Interlocked.Or(ref _status, DisposingFlag); + if ((status & DisposingFlag) != DisposingFlag && (status & CountMask) == 0) + { + // If we are the one who flipped the Disposing flag and there are no concurrent requests, + // dispose the instance now. Concurrent requests are prevented from starting by the Disposing flag. + await DisposeAsyncCore().ConfigureAwait(false); + } + else + { + await _onDisposed.Task.ConfigureAwait(false); + } + } + + private async Task DisposeAsyncCore() + { + try + { + await _watcher.DisposeAsync().ConfigureAwait(false); + } + finally + { + Debug.Assert(_onDisposed is not null); + _onDisposed.SetResult(); + } + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceEndpointWatcher.Log.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceEndpointWatcher.Log.cs new file mode 100644 index 00000000000..8acaa55ee73 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceEndpointWatcher.Log.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 Microsoft.Extensions.Logging; + +namespace Microsoft.Extensions.ServiceDiscovery; + +partial class ServiceEndpointWatcher +{ + private static partial class Log + { + [LoggerMessage(1, LogLevel.Debug, "Resolving endpoints for service '{ServiceName}'.", EventName = "ResolvingEndpoints")] + public static partial void ResolvingEndpoints(ILogger logger, string serviceName); + + [LoggerMessage(2, LogLevel.Debug, "Endpoint resolution is pending for service '{ServiceName}'.", EventName = "ResolutionPending")] + public static partial void ResolutionPending(ILogger logger, string serviceName); + + [LoggerMessage(3, LogLevel.Debug, "Resolved {Count} endpoints for service '{ServiceName}': {Endpoints}.", EventName = "ResolutionSucceeded")] + public static partial void ResolutionSucceededCore(ILogger logger, int count, string serviceName, string endpoints); + + public static void ResolutionSucceeded(ILogger logger, string serviceName, ServiceEndpointSource endpointSource) + { + if (logger.IsEnabled(LogLevel.Debug)) + { + ResolutionSucceededCore(logger, endpointSource.Endpoints.Count, serviceName, string.Join(", ", endpointSource.Endpoints.Select(GetEndpointString))); + } + + static string GetEndpointString(ServiceEndpoint ep) + { + if (ep.Features.Get() is { } provider) + { + return $"{ep} ({provider})"; + } + + return ep.ToString()!; + } + } + + [LoggerMessage(4, LogLevel.Error, "Error resolving endpoints for service '{ServiceName}'.", EventName = "ResolutionFailed")] + public static partial void ResolutionFailed(ILogger logger, Exception exception, string serviceName); + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceEndpointWatcher.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceEndpointWatcher.cs new file mode 100644 index 00000000000..a94b7b7a3c1 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceEndpointWatcher.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.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.ExceptionServices; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.ServiceDiscovery.Internal; + +namespace Microsoft.Extensions.ServiceDiscovery; + +/// +/// Watches for updates to the collection of resolved endpoints for a specified service. +/// +internal sealed partial class ServiceEndpointWatcher( + IServiceEndpointProvider[] providers, + ILogger logger, + string serviceName, + TimeProvider timeProvider, + IOptions options) : IAsyncDisposable +{ + private static readonly TimerCallback s_pollingAction = static state => _ = ((ServiceEndpointWatcher)state!).RefreshAsync(force: true); + + private readonly object _lock = new(); + private readonly ILogger _logger = logger; + private readonly TimeProvider _timeProvider = timeProvider; + private readonly ServiceDiscoveryOptions _options = options.Value; + private readonly IServiceEndpointProvider[] _providers = providers; + private readonly CancellationTokenSource _disposalCancellation = new(); + private ITimer? _pollingTimer; + private ServiceEndpointSource? _cachedEndpoints; + private Task _refreshTask = Task.CompletedTask; + private volatile CacheStatus _cacheState; + private IDisposable? _changeTokenRegistration; + + /// + /// Gets the service name. + /// + public string ServiceName { get; } = serviceName; + + /// + /// Gets or sets the action called when endpoints are updated. + /// + public Action? OnEndpointsUpdated { get; set; } + + /// + /// Starts the endpoint resolver. + /// + public void Start() + { + ThrowIfNoProviders(); + _ = RefreshAsync(force: false); + } + + /// + /// Returns a collection of resolved endpoints for the service. + /// + /// A . + /// A collection of resolved endpoints for the service. + public ValueTask GetEndpointsAsync(CancellationToken cancellationToken = default) + { + ThrowIfNoProviders(); + ObjectDisposedException.ThrowIf(_disposalCancellation.IsCancellationRequested, this); + cancellationToken.ThrowIfCancellationRequested(); + + // If the cache is valid, return the cached value. + if (_cachedEndpoints is { ChangeToken.HasChanged: false } cached) + { + return new ValueTask(cached); + } + + // Otherwise, ensure the cache is being refreshed + // Wait for the cache refresh to complete and return the cached value. + return GetEndpointsInternal(cancellationToken); + + async ValueTask GetEndpointsInternal(CancellationToken cancellationToken) + { + ServiceEndpointSource? result; + var disposalToken = _disposalCancellation.Token; + do + { + disposalToken.ThrowIfCancellationRequested(); + cancellationToken.ThrowIfCancellationRequested(); + await RefreshAsync(force: false).WaitAsync(cancellationToken).ConfigureAwait(false); + result = _cachedEndpoints; + } while (result is null); + + return result; + } + } + + // Ensures that there is a refresh operation running, if needed, and returns the task which represents the completion of the operation + private Task RefreshAsync(bool force) + { + lock (_lock) + { + // If the cache is invalid or needs invalidation, refresh the cache. + if (!_disposalCancellation.IsCancellationRequested && _refreshTask.IsCompleted && (_cacheState == CacheStatus.Invalid || _cachedEndpoints is null or { ChangeToken.HasChanged: true } || force)) + { + // Indicate that the cache is being updated and start a new refresh task. + _cacheState = CacheStatus.Refreshing; + + // Don't capture the current ExecutionContext and its AsyncLocals onto the callback. + var restoreFlow = false; + try + { + if (!ExecutionContext.IsFlowSuppressed()) + { + ExecutionContext.SuppressFlow(); + restoreFlow = true; + } + + _refreshTask = RefreshAsyncInternal(); + } + finally + { + if (restoreFlow) + { + ExecutionContext.RestoreFlow(); + } + } + } + + return _refreshTask; + } + } + + private async Task RefreshAsyncInternal() + { + await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + var cancellationToken = _disposalCancellation.Token; + Exception? error = null; + ServiceEndpointSource? newEndpoints = null; + CacheStatus newCacheState; + try + { + lock (_lock) + { + // Dispose the existing change token registration, if any. + _changeTokenRegistration?.Dispose(); + _changeTokenRegistration = null; + } + + Log.ResolvingEndpoints(_logger, ServiceName); + var builder = new ServiceEndpointBuilder(); + foreach (var provider in _providers) + { + cancellationToken.ThrowIfCancellationRequested(); + await provider.PopulateAsync(builder, cancellationToken).ConfigureAwait(false); + } + + var endpoints = builder.Build(); + newCacheState = CacheStatus.Valid; + + lock (_lock) + { + // Check if we need to poll for updates or if we can register for change notification callbacks. + if (endpoints.ChangeToken.ActiveChangeCallbacks) + { + // Initiate a background refresh when the change token fires. + _changeTokenRegistration = endpoints.ChangeToken.RegisterChangeCallback(static state => _ = ((ServiceEndpointWatcher)state!).RefreshAsync(force: false), this); + + // Dispose the existing timer, if any, since we are reliant on change tokens for updates. + _pollingTimer?.Dispose(); + _pollingTimer = null; + } + else + { + SchedulePollingTimer(); + } + + // The cache is valid + newEndpoints = endpoints; + newCacheState = CacheStatus.Valid; + } + } + catch (Exception exception) + { + error = exception; + newCacheState = CacheStatus.Invalid; + SchedulePollingTimer(); + } + + // If there was an error, the cache must be invalid. + Debug.Assert(error is null || newCacheState is CacheStatus.Invalid); + + // To ensure coherence between the value returned by calls made to GetEndpointsAsync and value passed to the callback, + // we invalidate the cache before invoking the callback. This causes callers to wait on the refresh task + // before receiving the updated value. An alternative approach is to lock access to _cachedEndpoints, but + // that will have more overhead in the common case. + if (newCacheState is CacheStatus.Valid) + { + Interlocked.Exchange(ref _cachedEndpoints, null); + } + + if (OnEndpointsUpdated is { } callback) + { + try + { + callback(new(newEndpoints, error)); + } + catch (Exception exception) + { + _logger.LogError(exception, "Error notifying observers of updated endpoints."); + } + } + + lock (_lock) + { + if (newCacheState is CacheStatus.Valid) + { + Debug.Assert(newEndpoints is not null); + _cachedEndpoints = newEndpoints; + } + + _cacheState = newCacheState; + } + + if (error is not null) + { + Log.ResolutionFailed(_logger, error, ServiceName); + ExceptionDispatchInfo.Throw(error); + } + else if (newEndpoints is not null) + { + Log.ResolutionSucceeded(_logger, ServiceName, newEndpoints); + } + } + + private void SchedulePollingTimer() + { + lock (_lock) + { + if (_disposalCancellation.IsCancellationRequested) + { + _pollingTimer?.Dispose(); + _pollingTimer = null; + return; + } + + if (_pollingTimer is null) + { + _pollingTimer = _timeProvider.CreateTimer(s_pollingAction, this, _options.RefreshPeriod, TimeSpan.Zero); + } + else + { + _pollingTimer.Change(_options.RefreshPeriod, TimeSpan.Zero); + } + } + } + + /// + public async ValueTask DisposeAsync() + { + try + { + _disposalCancellation.Cancel(); + } + catch (Exception exception) + { + _logger.LogError(exception, "Error cancelling disposal cancellation token."); + } + + lock (_lock) + { + _changeTokenRegistration?.Dispose(); + _changeTokenRegistration = null; + + _pollingTimer?.Dispose(); + _pollingTimer = null; + } + + if (_refreshTask is { } task) + { + await task.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + } + + foreach (var provider in _providers) + { + await provider.DisposeAsync().ConfigureAwait(false); + } + } + + private enum CacheStatus + { + Invalid, + Refreshing, + Valid + } + + private void ThrowIfNoProviders() + { + if (_providers.Length == 0) + { + ThrowNoProvidersConfigured(); + } + } + + [DoesNotReturn] + private static void ThrowNoProvidersConfigured() => throw new InvalidOperationException("No service endpoint providers are configured."); +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceEndpointWatcherFactory.Log.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceEndpointWatcherFactory.Log.cs new file mode 100644 index 00000000000..449ee6920de --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceEndpointWatcherFactory.Log.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.Logging; + +namespace Microsoft.Extensions.ServiceDiscovery; + +partial class ServiceEndpointWatcherFactory +{ + private static partial class Log + { + [LoggerMessage(1, LogLevel.Debug, "Creating endpoint resolver for service '{ServiceName}' with {Count} providers: {Providers}.", EventName = "CreatingResolver")] + public static partial void ServiceEndpointProviderListCore(ILogger logger, string serviceName, int count, string providers); + + public static void CreatingResolver(ILogger logger, string serviceName, List providers) + { + if (logger.IsEnabled(LogLevel.Debug)) + { + ServiceEndpointProviderListCore(logger, serviceName, providers.Count, string.Join(", ", providers.Select(static r => r.ToString()))); + } + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceEndpointWatcherFactory.cs b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceEndpointWatcherFactory.cs new file mode 100644 index 00000000000..6cc7cb2cbc5 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.ServiceDiscovery/ServiceEndpointWatcherFactory.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 Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.ServiceDiscovery.PassThrough; + +namespace Microsoft.Extensions.ServiceDiscovery; + +/// +/// Creates service endpoint watchers. +/// +internal sealed partial class ServiceEndpointWatcherFactory( + IEnumerable providerFactories, + ILogger logger, + IOptions options, + TimeProvider timeProvider) +{ + private readonly IServiceEndpointProviderFactory[] _providerFactories = providerFactories + .Where(r => r is not PassThroughServiceEndpointProviderFactory) + .Concat(providerFactories.Where(static r => r is PassThroughServiceEndpointProviderFactory)).ToArray(); + private readonly ILogger _logger = logger; + private readonly TimeProvider _timeProvider = timeProvider; + private readonly IOptions _options = options; + + /// + /// Creates a service endpoint watcher for the provided service name. + /// + public ServiceEndpointWatcher CreateWatcher(string serviceName) + { + ArgumentNullException.ThrowIfNull(serviceName); + + if (!ServiceEndpointQuery.TryParse(serviceName, out var query)) + { + throw new ArgumentException("The provided input was not in a valid format. It must be a valid URI.", nameof(serviceName)); + } + + List? providers = null; + foreach (var factory in _providerFactories) + { + if (factory.TryCreateProvider(query, out var provider)) + { + providers ??= []; + providers.Add(provider); + } + } + + if (providers is not { Count: > 0 }) + { + throw new InvalidOperationException($"No provider which supports the provided service name, '{serviceName}', has been configured."); + } + + Log.CreatingResolver(_logger, serviceName, providers); + return new ServiceEndpointWatcher( + providers: [.. providers], + logger: _logger, + serviceName: serviceName, + timeProvider: _timeProvider, + options: _options); + } +} diff --git a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Buffering/LogBuffer.cs b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Buffering/LogBuffer.cs index 4259c90f645..a05c0baca0e 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Buffering/LogBuffer.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Buffering/LogBuffer.cs @@ -8,9 +8,7 @@ namespace Microsoft.Extensions.Diagnostics.Buffering; /// /// Buffers logs into circular buffers and drops them after some time if not flushed. /// -#pragma warning disable S1694 // An abstract class should have both abstract and concrete methods public abstract class LogBuffer -#pragma warning restore S1694 // An abstract class should have both abstract and concrete methods { /// /// Flushes the buffer and emits all buffered logs. diff --git a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Http/IDownstreamDependencyMetadata.cs b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Http/IDownstreamDependencyMetadata.cs index daef766c1f2..bed838f31e7 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Http/IDownstreamDependencyMetadata.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Http/IDownstreamDependencyMetadata.cs @@ -13,15 +13,15 @@ public interface IDownstreamDependencyMetadata /// /// Gets the name of the dependent service. /// - public string DependencyName { get; } + string DependencyName { get; } /// /// Gets the list of host name suffixes that can uniquely identify a host as this dependency. /// - public ISet UniqueHostNameSuffixes { get; } + ISet UniqueHostNameSuffixes { get; } /// /// Gets the list of all metadata for all routes to the dependency service. /// - public ISet RequestMetadata { get; } + ISet RequestMetadata { get; } } diff --git a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LogPropertiesAttribute.cs b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LogPropertiesAttribute.cs index c2d6a65cd38..d724480dd3b 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LogPropertiesAttribute.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LogPropertiesAttribute.cs @@ -2,9 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using Microsoft.Extensions.Logging; using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Extensions.Logging; @@ -14,7 +12,6 @@ namespace Microsoft.Extensions.Logging; /// /// [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)] -[Conditional("CODE_GENERATION_ATTRIBUTES")] public sealed class LogPropertiesAttribute : Attribute { /// diff --git a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LogPropertyIgnoreAttribute.cs b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LogPropertyIgnoreAttribute.cs index 954fcdeddb3..af36d87afe0 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LogPropertyIgnoreAttribute.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LogPropertyIgnoreAttribute.cs @@ -2,8 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Diagnostics; -using Microsoft.Extensions.Logging; namespace Microsoft.Extensions.Logging; @@ -12,7 +10,6 @@ namespace Microsoft.Extensions.Logging; /// /// . [AttributeUsage(AttributeTargets.Property)] -[Conditional("CODE_GENERATION_ATTRIBUTES")] public sealed class LogPropertyIgnoreAttribute : Attribute { } diff --git a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LoggerMessageHelper.cs b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LoggerMessageHelper.cs index bb3631909dc..34d9df008ad 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LoggerMessageHelper.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LoggerMessageHelper.cs @@ -18,26 +18,11 @@ namespace Microsoft.Extensions.Logging; [EditorBrowsable(EditorBrowsableState.Never)] public static class LoggerMessageHelper { - [ThreadStatic] - private static LoggerMessageState? _state; - /// /// Gets a thread-local instance of this type. /// - public static LoggerMessageState ThreadLocalState - { - get - { - var result = _state; - if (result == null) - { - result = new(); - _state = result; - } - - return result; - } - } + [field: ThreadStatic] + public static LoggerMessageState ThreadLocalState => field ??= new(); /// /// Enumerates an enumerable into a string. diff --git a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LoggerMessageState.ClassifiedTag.cs b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LoggerMessageState.ClassifiedTag.cs index dcb38190d80..17b5825ded4 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LoggerMessageState.ClassifiedTag.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LoggerMessageState.ClassifiedTag.cs @@ -12,7 +12,6 @@ public partial class LoggerMessageState /// /// Represents a captured tag that needs redaction. /// - [SuppressMessage("Design", "CA1034:Nested types should not be visible", Justification = "Not for customer use and hidden from docs")] [SuppressMessage("Performance", "CA1815:Override equals and operator equals on value types", Justification = "Not needed")] [EditorBrowsable(EditorBrowsableState.Never)] public readonly struct ClassifiedTag diff --git a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LoggerMessageState.cs b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LoggerMessageState.cs index 30c79dedcd4..276e4f1845d 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LoggerMessageState.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Logging/LoggerMessageState.cs @@ -5,7 +5,6 @@ using System.Collections.Generic; using System.ComponentModel; using Microsoft.Extensions.Compliance.Classification; -using Microsoft.Extensions.Logging; using Microsoft.Shared.Pools; namespace Microsoft.Extensions.Logging; diff --git a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Microsoft.Extensions.Telemetry.Abstractions.csproj b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Microsoft.Extensions.Telemetry.Abstractions.csproj index 816ad679585..a461593894e 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Microsoft.Extensions.Telemetry.Abstractions.csproj +++ b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Microsoft.Extensions.Telemetry.Abstractions.csproj @@ -1,6 +1,7 @@  Microsoft.Extensions.Telemetry + $(NetCoreTargetFrameworks);netstandard2.0;net462 Common abstractions for high-level telemetry primitives. Telemetry diff --git a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Sampling/LoggingSampler.cs b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Sampling/LoggingSampler.cs index 3e227f9c17d..4c363bd5583 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Sampling/LoggingSampler.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Sampling/LoggingSampler.cs @@ -8,9 +8,7 @@ namespace Microsoft.Extensions.Logging; /// /// Controls the number of samples of log records collected and sent to the backend. /// -#pragma warning disable S1694 // An abstract class should have both abstract and concrete methods public abstract class LoggingSampler -#pragma warning restore S1694 // An abstract class should have both abstract and concrete methods { /// /// Makes a sampling decision for the provided . diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Buffering/GlobalLogBufferingOptions.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Buffering/GlobalLogBufferingOptions.cs index b12f9ae33cf..24c05b425e4 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Buffering/GlobalLogBufferingOptions.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Buffering/GlobalLogBufferingOptions.cs @@ -57,7 +57,6 @@ public class GlobalLogBufferingOptions [Range(MinimumBufferSizeInBytes, MaximumBufferSizeInBytes)] public int MaxBufferSizeInBytes { get; set; } = DefaultMaxBufferSizeInBytes; -#pragma warning disable CA2227 // Collection properties should be read only - setter is necessary for options pattern. /// /// Gets or sets the collection of used for filtering log messages for the purpose of further buffering. /// @@ -69,7 +68,6 @@ public class GlobalLogBufferingOptions /// [Required] public IList Rules { get; set; } = []; -#pragma warning restore CA2227 } #endif diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Buffering/LogBufferingFilterRuleSelector.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Buffering/LogBufferingFilterRuleSelector.cs index 09e4b3c9025..a56a31e3ead 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Buffering/LogBufferingFilterRuleSelector.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Buffering/LogBufferingFilterRuleSelector.cs @@ -3,7 +3,6 @@ #if NET9_0_OR_GREATER #pragma warning disable CA1307 // Specify StringComparison for clarity -#pragma warning disable S1659 // Multiple variables should not be declared on the same line #pragma warning disable S2302 // "nameof" should be used using System; diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Enrichment/ApplicationLogEnricher.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Enrichment/ApplicationLogEnricher.cs index 8866531804d..c049b112ec8 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Enrichment/ApplicationLogEnricher.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Enrichment/ApplicationLogEnricher.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using Microsoft.Extensions.AmbientMetadata; -using Microsoft.Extensions.Diagnostics.Enrichment; using Microsoft.Extensions.Options; using Microsoft.Shared.Diagnostics; diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Http/HttpRouteFormatter.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Http/HttpRouteFormatter.cs index 0ee93b5e851..cfbe62c9685 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Http/HttpRouteFormatter.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Http/HttpRouteFormatter.cs @@ -6,7 +6,6 @@ using System.Text; using Microsoft.Extensions.Compliance.Classification; using Microsoft.Extensions.Compliance.Redaction; -using Microsoft.Extensions.Http.Diagnostics; using Microsoft.Shared.Pools; namespace Microsoft.Extensions.Http.Diagnostics; diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Http/HttpRouteParser.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Http/HttpRouteParser.cs index 6783785d838..98e9c627396 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Http/HttpRouteParser.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Http/HttpRouteParser.cs @@ -6,7 +6,6 @@ using System.Collections.Generic; using Microsoft.Extensions.Compliance.Classification; using Microsoft.Extensions.Compliance.Redaction; -using Microsoft.Extensions.Http.Diagnostics; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.Http.Diagnostics; diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Http/IHttpRouteFormatter.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Http/IHttpRouteFormatter.cs index 973d8ebb071..5dc269cb4ec 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Http/IHttpRouteFormatter.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Http/IHttpRouteFormatter.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using Microsoft.Extensions.Compliance.Classification; -using Microsoft.Extensions.Http.Diagnostics; namespace Microsoft.Extensions.Http.Diagnostics; diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Http/IHttpRouteParser.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Http/IHttpRouteParser.cs index 5831935f020..d7740bdea99 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Http/IHttpRouteParser.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Http/IHttpRouteParser.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using Microsoft.Extensions.Compliance.Classification; -using Microsoft.Extensions.Http.Diagnostics; namespace Microsoft.Extensions.Http.Diagnostics; diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Http/TelemetryCommonExtensions.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Http/TelemetryCommonExtensions.cs index 6031e1e662f..a45e79fb5f6 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Http/TelemetryCommonExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Http/TelemetryCommonExtensions.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Http.Diagnostics; namespace Microsoft.Extensions.Http.Diagnostics; diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Latency/Internal/CheckpointTracker.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Latency/Internal/CheckpointTracker.cs index ba2c1646760..5cee914c68a 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Latency/Internal/CheckpointTracker.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Latency/Internal/CheckpointTracker.cs @@ -16,12 +16,13 @@ internal sealed class CheckpointTracker : IResettable private readonly Registry _checkpointNames; private readonly int[] _checkpointAdded; private readonly Checkpoint[] _checkpoints; - - private long _timestamp; - private int _numCheckpoints; - public long Elapsed => TimeProvider.GetTimestamp() - _timestamp; + public long Elapsed + { + get => TimeProvider.GetTimestamp() - field; + private set; + } public long Frequency => TimeProvider.TimestampFrequency; @@ -36,7 +37,7 @@ public CheckpointTracker(Registry registry) _checkpointAdded = new int[keyCount]; _checkpoints = new Checkpoint[keyCount]; TimeProvider = TimeProvider.System; - _timestamp = TimeProvider.GetTimestamp(); + Elapsed = TimeProvider.GetTimestamp(); } /// @@ -44,7 +45,7 @@ public CheckpointTracker(Registry registry) /// public bool TryReset() { - _timestamp = TimeProvider.GetTimestamp(); + Elapsed = TimeProvider.GetTimestamp(); _numCheckpoints = 0; Array.Clear(_checkpointAdded, 0, _checkpointAdded.Length); return true; diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Latency/Internal/LatencyConsoleExporter.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Latency/Internal/LatencyConsoleExporter.cs index 0766b074ae4..6b8c7b35e4d 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Latency/Internal/LatencyConsoleExporter.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Latency/Internal/LatencyConsoleExporter.cs @@ -6,7 +6,6 @@ using System.Text; using System.Threading; using System.Threading.Tasks; -using Microsoft.Extensions.ObjectPool; using Microsoft.Extensions.Options; using Microsoft.Shared.Memoization; using Microsoft.Shared.Pools; diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Latency/Internal/LatencyContext.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Latency/Internal/LatencyContext.cs index 0777d03c571..251d7cedc1c 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Latency/Internal/LatencyContext.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Latency/Internal/LatencyContext.cs @@ -22,8 +22,6 @@ internal sealed class LatencyContext : ILatencyContext, IResettable private readonly MeasureTracker _measureTracker; - private long _duration; - public LatencyContext(LatencyContextPool latencyContextPool) { var latencyInstrumentProvider = latencyContextPool.LatencyInstrumentProvider; @@ -36,7 +34,11 @@ public LatencyContext(LatencyContextPool latencyContextPool) public LatencyData LatencyData => IsDisposed ? default : new(_tagCollection.Tags, _checkpointTracker.Checkpoints, _measureTracker.Measures, Duration, _checkpointTracker.Frequency); - private long Duration => IsRunning ? _checkpointTracker.Elapsed : _duration; + private long Duration + { + get => IsRunning ? _checkpointTracker.Elapsed : field; + set; + } #region Checkpoints public void AddCheckpoint(CheckpointToken token) @@ -82,7 +84,7 @@ public void Freeze() if (IsRunning) { IsRunning = false; - _duration = _checkpointTracker.Elapsed; + Duration = _checkpointTracker.Elapsed; } } diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Latency/Internal/LatencyContextProvider.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Latency/Internal/LatencyContextProvider.cs index fda3a70a52b..eb8d6da5c39 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Latency/Internal/LatencyContextProvider.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Latency/Internal/LatencyContextProvider.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 Microsoft.Extensions.Diagnostics.Latency; - namespace Microsoft.Extensions.Diagnostics.Latency.Internal; /// diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Latency/Internal/LatencyContextTokenIssuer.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Latency/Internal/LatencyContextTokenIssuer.cs index 7926b8fa06e..d6beea39292 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Latency/Internal/LatencyContextTokenIssuer.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Latency/Internal/LatencyContextTokenIssuer.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 Microsoft.Extensions.Diagnostics.Latency; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.Diagnostics.Latency.Internal; diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Logging/ExtendedLogger.LegacyTagJoiner.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Logging/ExtendedLogger.LegacyTagJoiner.cs index 46bf77f0816..3f75af17a34 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Logging/ExtendedLogger.LegacyTagJoiner.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Logging/ExtendedLogger.LegacyTagJoiner.cs @@ -21,7 +21,6 @@ internal sealed class LegacyTagJoiner : IReadOnlyList> _extraTags = new(TagCapacity); private IReadOnlyList>? _incomingTags; - private int _incomingTagCount; public LegacyTagJoiner() { @@ -34,7 +33,7 @@ public void Clear() { _extraTags.Clear(); _incomingTags = null; - _incomingTagCount = 0; + Count = 0; State = null; Formatter = null; } @@ -43,7 +42,7 @@ public void Clear() public void SetIncomingTags(IReadOnlyList> value) { _incomingTags = value; - _incomingTagCount = _incomingTags.Count; + Count = _incomingTags.Count; } public KeyValuePair this[int index] @@ -77,7 +76,11 @@ public void SetIncomingTags(IReadOnlyList> value) } } - public int Count => _incomingTagCount + _extraTags.Count + StaticTags!.Length; + public int Count + { + get => field + _extraTags.Count + StaticTags!.Length; + private set; + } public IEnumerator> GetEnumerator() { diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Logging/ExtendedLogger.ThreadLocals.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Logging/ExtendedLogger.ThreadLocals.cs index 87500e83deb..21cf296e62c 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Logging/ExtendedLogger.ThreadLocals.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Logging/ExtendedLogger.ThreadLocals.cs @@ -2,47 +2,14 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using Microsoft.Extensions.Logging; namespace Microsoft.Extensions.Logging; -#pragma warning disable S2696 - internal sealed partial class ExtendedLogger : ILogger { - [ThreadStatic] - private static ModernTagJoiner? _modernJoiner; - - [ThreadStatic] - private static LegacyTagJoiner? _legacyJoiner; - - private static ModernTagJoiner ModernJoiner - { - get - { - var joiner = _modernJoiner; - if (joiner == null) - { - joiner = new(); - _modernJoiner = joiner; - } - - return joiner; - } - } - - private static LegacyTagJoiner LegacyJoiner - { - get - { - var joiner = _legacyJoiner; - if (joiner == null) - { - joiner = new(); - _legacyJoiner = joiner; - } + [field: ThreadStatic] + private static ModernTagJoiner ModernJoiner => field ??= new(); - return joiner; - } - } + [field: ThreadStatic] + private static LegacyTagJoiner LegacyJoiner => field ??= new(); } diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Logging/ExtendedLogger.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Logging/ExtendedLogger.cs index 162923d055c..3487ffbcc56 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Logging/ExtendedLogger.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Logging/ExtendedLogger.cs @@ -12,8 +12,6 @@ namespace Microsoft.Extensions.Logging; -#pragma warning disable CA1031 - // NOTE: This implementation uses thread local storage. As a result, it will fail if formatter code, enricher code, or // redactor code calls recursively back into the logger. Don't do that. // @@ -90,9 +88,7 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except } catch (Exception ex) { -#pragma warning disable CA1508 // Avoid dead conditional code exceptions ??= []; -#pragma warning restore CA1508 // Avoid dead conditional code exceptions.Add(ex); } } @@ -122,9 +118,7 @@ public bool IsEnabled(LogLevel logLevel) } catch (Exception ex) { -#pragma warning disable CA1508 // Avoid dead conditional code exceptions ??= []; -#pragma warning restore CA1508 // Avoid dead conditional code exceptions.Add(ex); } } diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Logging/ExtendedLoggerFactory.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Logging/ExtendedLoggerFactory.cs index 44ec71b6f68..67c6c3fc4c5 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Logging/ExtendedLoggerFactory.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Logging/ExtendedLoggerFactory.cs @@ -11,6 +11,7 @@ using Microsoft.Extensions.Diagnostics.Buffering; #endif using Microsoft.Extensions.Diagnostics.Enrichment; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Microsoft.Shared.Diagnostics; @@ -36,7 +37,6 @@ internal sealed class ExtendedLoggerFactory : ILoggerFactory private LoggerFilterOptions _filterOptions; private IExternalScopeProvider? _scopeProvider; -#pragma warning disable S107 // Methods should not have too many parameters public ExtendedLoggerFactory( IEnumerable providers, IEnumerable enrichers, @@ -53,7 +53,6 @@ public ExtendedLoggerFactory( #else IRedactorProvider? redactorProvider = null) #endif -#pragma warning restore S107 // Methods should not have too many parameters { _scopeProvider = scopeProvider; #if NET9_0_OR_GREATER @@ -129,9 +128,7 @@ public void Dispose() registration.Provider.Dispose(); } } -#pragma warning disable CA1031 catch -#pragma warning restore CA1031 { // Swallow exceptions on dispose } @@ -223,13 +220,20 @@ private void AddProviderRegistration(ILoggerProvider provider, bool dispose) private LoggerInformation[] CreateLoggers(string categoryName) { - var loggers = new LoggerInformation[_providerRegistrations.Count]; + var loggers = new List(_providerRegistrations.Count); for (int i = 0; i < _providerRegistrations.Count; i++) { - loggers[i] = new LoggerInformation(_providerRegistrations[i].Provider, categoryName); + var loggerInformation = new LoggerInformation(_providerRegistrations[i].Provider, categoryName); + + // We do not need to check for NullLogger.Instance as no provider would reasonably return it (the handling is at + // outer loggers level, not inner level loggers in Logger/LoggerProvider). + if (loggerInformation.Logger != NullLogger.Instance) + { + loggers.Add(loggerInformation); + } } - return loggers; + return loggers.ToArray(); } private (MessageLogger[] messageLoggers, ScopeLogger[] scopeLoggers) ApplyFilters(LoggerInformation[] loggers) diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Logging/Import/LoggerFactoryScopeProvider.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Logging/Import/LoggerFactoryScopeProvider.cs index 7b13b12c7df..3ab9ee81a26 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Logging/Import/LoggerFactoryScopeProvider.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Logging/Import/LoggerFactoryScopeProvider.cs @@ -6,13 +6,10 @@ #pragma warning disable SA1210 // Using directives should be ordered alphabetically by namespace #pragma warning disable IDE0021 // Use block body for constructor #pragma warning disable IDE1006 // Naming Styles -#pragma warning disable SA1203 // Constants should appear before fields #pragma warning disable SA1629 // Documentation text should end with a period #pragma warning disable IDE0090 // Use 'new(...)' #pragma warning disable IDE0058 // Expression value is never used #pragma warning disable SA1505 // Opening braces should not be followed by blank line -#pragma warning disable SA1202 // Elements should be ordered by access -#pragma warning disable CA1512 // Use ArgumentOutOfRangeException throw helper #pragma warning disable CA2002 // Do not lock on objects with weak identity #pragma warning disable S2551 // Lock on a dedicated object instance instead #pragma warning disable SA1204 // Static elements should appear before instance elements @@ -28,7 +25,6 @@ namespace Microsoft.Extensions.Logging { - /// /// Default implementation of /// diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Logging/Import/LoggerInformation.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Logging/Import/LoggerInformation.cs index 45338f10742..cfe9ee1482b 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Logging/Import/LoggerInformation.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Logging/Import/LoggerInformation.cs @@ -4,14 +4,11 @@ // This source file was lovingly 'borrowed' from dotnet/runtime/src/libraries/Microsoft.Extensions.Logging #pragma warning disable S1128 // Unused "using" should be removed #pragma warning disable SA1649 // File name should match first type name -#pragma warning disable SA1202 // Elements should be ordered by access #pragma warning disable SA1128 // Put constructor initializers on their own line #pragma warning disable SA1127 // Generic type constraints should be on their own line #pragma warning disable CS8602 // Dereference of a possibly null reference. using System; -using System.Diagnostics; -using Microsoft.Extensions.Logging; namespace Microsoft.Extensions.Logging { diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Logging/Import/LoggerRuleSelector.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Logging/Import/LoggerRuleSelector.cs index 9b5ea56890a..1dfd3e9203b 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Logging/Import/LoggerRuleSelector.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Logging/Import/LoggerRuleSelector.cs @@ -3,7 +3,6 @@ // This source file was lovingly 'borrowed' from dotnet/runtime/src/libraries/Microsoft.Extensions.Logging #pragma warning disable CA1307 // Specify StringComparison for clarity -#pragma warning disable S1659 // Multiple variables should not be declared on the same line #pragma warning disable S2302 // "nameof" should be used using System; diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Logging/Import/ProviderAliasUtilities.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Logging/Import/ProviderAliasUtilities.cs index 411733e7739..15cda881716 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Logging/Import/ProviderAliasUtilities.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Logging/Import/ProviderAliasUtilities.cs @@ -6,7 +6,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Reflection; namespace Microsoft.Extensions.Logging diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Logging/LoggerConfig.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Logging/LoggerConfig.cs index e4696e7f798..e7c02fff231 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Logging/LoggerConfig.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Logging/LoggerConfig.cs @@ -14,7 +14,6 @@ namespace Microsoft.Extensions.Logging; internal sealed class LoggerConfig { -#pragma warning disable S107 // Methods should not have too many parameters public LoggerConfig( KeyValuePair[] staticTags, Action[] enrichers, @@ -31,7 +30,6 @@ public LoggerConfig( bool addRedactionDiscriminator) #endif { -#pragma warning restore S107 // Methods should not have too many parameters StaticTags = staticTags; Enrichers = enrichers; Sampler = sampler; diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Microsoft.Extensions.Telemetry.csproj b/src/Libraries/Microsoft.Extensions.Telemetry/Microsoft.Extensions.Telemetry.csproj index 9dc4a11ca6c..8ff5676e349 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Microsoft.Extensions.Telemetry.csproj +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Microsoft.Extensions.Telemetry.csproj @@ -1,6 +1,7 @@  Microsoft.Extensions.Diagnostics + $(NetCoreTargetFrameworks);netstandard2.0;net462 Provides canonical implementations of telemetry abstractions. Telemetry diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Sampling/LogSamplingRuleSelector.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Sampling/LogSamplingRuleSelector.cs index 724f70e07d2..3f6cc70a8af 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Sampling/LogSamplingRuleSelector.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Sampling/LogSamplingRuleSelector.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. #pragma warning disable CA1307 // Specify StringComparison for clarity -#pragma warning disable S1659 // Multiple variables should not be declared on the same line #pragma warning disable S2302 // "nameof" should be used using System; diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Sampling/RandomProbabilisticSampler.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Sampling/RandomProbabilisticSampler.cs index 6ba0a376c25..d809da8a2ad 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Sampling/RandomProbabilisticSampler.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Sampling/RandomProbabilisticSampler.cs @@ -3,7 +3,7 @@ using System; using System.Linq; -#if !NETFRAMEWORK +#if !NETFRAMEWORK && !NETSTANDARD using System.Security.Cryptography; #endif using Microsoft.Extensions.Logging; @@ -22,7 +22,7 @@ internal sealed class RandomProbabilisticSampler : LoggingSampler, IDisposable { internal RandomProbabilisticSamplerFilterRule[] LastKnownGoodSamplerRules; -#if NETFRAMEWORK +#if NETFRAMEWORK || NETSTANDARD private static readonly System.Threading.ThreadLocal _randomInstance = new(() => new Random()); #endif @@ -50,7 +50,7 @@ public override bool ShouldSample(in LogEntry logEntry) return true; } -#if NETFRAMEWORK +#if NETFRAMEWORK || NETSTANDARD return _randomInstance.Value!.Next(int.MaxValue) < int.MaxValue * probability; #else return RandomNumberGenerator.GetInt32(int.MaxValue) < int.MaxValue * probability; diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Sampling/RandomProbabilisticSamplerConfigureOptions.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Sampling/RandomProbabilisticSamplerConfigureOptions.cs index 40085e3ed72..188fb22ed60 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Sampling/RandomProbabilisticSamplerConfigureOptions.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Sampling/RandomProbabilisticSamplerConfigureOptions.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.Collections.Generic; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; diff --git a/src/Libraries/Microsoft.Extensions.Telemetry/Sampling/RandomProbabilisticSamplerOptions.cs b/src/Libraries/Microsoft.Extensions.Telemetry/Sampling/RandomProbabilisticSamplerOptions.cs index 4808eab9fc7..c84fc1ee25e 100644 --- a/src/Libraries/Microsoft.Extensions.Telemetry/Sampling/RandomProbabilisticSamplerOptions.cs +++ b/src/Libraries/Microsoft.Extensions.Telemetry/Sampling/RandomProbabilisticSamplerOptions.cs @@ -15,9 +15,7 @@ public class RandomProbabilisticSamplerOptions /// /// Gets or sets the collection of used for filtering log messages. /// -#pragma warning disable CA2227 // Collection properties should be read only - setter is necessary for options pattern [Required] [ValidateEnumeratedItems] public IList Rules { get; set; } = []; -#pragma warning restore CA2227 // Collection properties should be read only } diff --git a/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/FakeTimeProvider.cs b/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/FakeTimeProvider.cs index 43b9e92b1c5..cfe601e9dfa 100644 --- a/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/FakeTimeProvider.cs +++ b/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/FakeTimeProvider.cs @@ -4,10 +4,8 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Threading; -using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.Time.Testing; @@ -21,7 +19,6 @@ public class FakeTimeProvider : TimeProvider private DateTimeOffset _now = new(2000, 1, 1, 0, 0, 0, 0, TimeSpan.Zero); private TimeZoneInfo _localTimeZone = TimeZoneInfo.Utc; private volatile int _wakeWaitersGate; - private TimeSpan _autoAdvanceAmount; /// /// Initializes a new instance of the class. @@ -64,11 +61,11 @@ public FakeTimeProvider(DateTimeOffset startDateTime) /// The time value is less than . public TimeSpan AutoAdvanceAmount { - get => _autoAdvanceAmount; + get; set { _ = Throw.IfLessThan(value.Ticks, 0); - _autoAdvanceAmount = value; + field = value; } } @@ -80,7 +77,7 @@ public override DateTimeOffset GetUtcNow() lock (Waiters) { result = _now; - _now += _autoAdvanceAmount; + _now += AutoAdvanceAmount; } WakeWaiters(); @@ -137,7 +134,7 @@ public void Advance(TimeSpan delta) } /// - /// Advances the date and time in the UTC time zone. + /// Sets the date and time in the UTC time zone. /// /// The date and time in the UTC time zone. /// @@ -145,7 +142,6 @@ public void Advance(TimeSpan delta) /// timers. This is similar to what happens in a real system when the system's /// time is changed. /// - [Experimental(diagnosticId: DiagnosticIds.Experiments.TimeProvider, UrlFormat = DiagnosticIds.UrlFormat)] public void AdjustTime(DateTimeOffset value) { lock (Waiters) diff --git a/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/Microsoft.Extensions.TimeProvider.Testing.csproj b/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/Microsoft.Extensions.TimeProvider.Testing.csproj index f1987e0ad68..679d9e74854 100644 --- a/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/Microsoft.Extensions.TimeProvider.Testing.csproj +++ b/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/Microsoft.Extensions.TimeProvider.Testing.csproj @@ -9,7 +9,6 @@ Fundamentals Testing $(PackageTags);Testing;TimeProvider;FakeTimeProvider - true true diff --git a/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/Microsoft.Extensions.TimeProvider.Testing.json b/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/Microsoft.Extensions.TimeProvider.Testing.json index b1c5d11adc2..c2417fd0d63 100644 --- a/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/Microsoft.Extensions.TimeProvider.Testing.json +++ b/src/Libraries/Microsoft.Extensions.TimeProvider.Testing/Microsoft.Extensions.TimeProvider.Testing.json @@ -1,5 +1,5 @@ { - "Name": "Microsoft.Extensions.TimeProvider.Testing, Version=8.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35", + "Name": "Microsoft.Extensions.TimeProvider.Testing, Version=9.8.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35", "Types": [ { "Type": "class Microsoft.Extensions.Time.Testing.FakeTimeProvider : System.TimeProvider", @@ -13,6 +13,10 @@ "Member": "Microsoft.Extensions.Time.Testing.FakeTimeProvider.FakeTimeProvider(System.DateTimeOffset startDateTime);", "Stage": "Stable" }, + { + "Member": "void Microsoft.Extensions.Time.Testing.FakeTimeProvider.AdjustTime(System.DateTimeOffset value);", + "Stage": "Stable" + }, { "Member": "void Microsoft.Extensions.Time.Testing.FakeTimeProvider.Advance(System.TimeSpan delta);", "Stage": "Stable" 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..41e9d3b63bd 100644 --- a/src/Packages/Microsoft.Internal.Extensions.DotNetApiDocs.Transport/Microsoft.Internal.Extensions.DotNetApiDocs.Transport.proj +++ b/src/Packages/Microsoft.Internal.Extensions.DotNetApiDocs.Transport/Microsoft.Internal.Extensions.DotNetApiDocs.Transport.proj @@ -31,6 +31,8 @@ + + diff --git a/src/ProjectTemplates/GenerateTemplateContent/GenerateTemplateContent.csproj b/src/ProjectTemplates/GenerateTemplateContent/GenerateTemplateContent.csproj index cead17cde9e..e8df485098b 100644 --- a/src/ProjectTemplates/GenerateTemplateContent/GenerateTemplateContent.csproj +++ b/src/ProjectTemplates/GenerateTemplateContent/GenerateTemplateContent.csproj @@ -18,13 +18,42 @@ IsImplicitlyDefined="true" /> + + + + + + + + <_ResolvedPackageVersionVariableReference Include="@(_ResolvedPackageVersionInfo)"> + TemplatePackageVersion_$([System.String]::Copy('%(PackageId)').Replace('.', '')) + + + + + + $(GeneratedContentProperties); + + @(_ResolvedPackageVersionVariableReference->'%(VersionVariableName)=%(PackageVersion)') + + + + + DependsOnTargets="ComputeGeneratedContentProperties;_GetPackageVersionVariables"> diff --git a/src/ProjectTemplates/GeneratedContent.targets b/src/ProjectTemplates/GeneratedContent.targets index dfe93dbc5a8..403beadc3a0 100644 --- a/src/ProjectTemplates/GeneratedContent.targets +++ b/src/ProjectTemplates/GeneratedContent.targets @@ -13,42 +13,44 @@ <_McpServerContentRoot>$(MSBuildThisFileDirectory)Microsoft.Extensions.AI.Templates\src\McpServer\ - + - - $(Version) - $(Version) - $(Version) - + Specifies packages defined in this repo that get referenced in generated template content. + For each item specified below, a property will be generated whose name matches the format: + "TemplatePackageVersion_{PackageName}" + where {PackageName} is the package ID with '.' characters removed. + The value of each property will be the computed package version. + --> + + + + + + - + + - 9.3.0 - 9.3.0-preview.1.25265.20 - 2.2.0-beta.4 - 1.0.0-beta.9 - 1.14.0 - 11.6.0 - 9.4.1-beta.291 - 10.0.0-preview.5.25277.114 - 9.3.0 - 1.53.0 - 1.53.0-preview - 0.3.0-preview.2 - 5.1.18 - 1.12.0 - 0.1.10 - 6.0.1 + 9.5.1 + 9.5.1-preview.1.25502.11 + 1.0.0 + 1.17.0 + 11.7.0 + 9.8.1-beta.413 + 10.0.0-rc.2.25502.107 + 9.5.1 + 1.66.0 + 1.66.0-preview + 0.4.0-preview.1 + 5.4.8 + 1.13.0 + 0.1.11 + 6.0.3 - <_TemplateUsingJustBuiltPackages Condition="'$(TemplatePackageVersion_MicrosoftExtensionsAI)' == '$(Version)' OR '$(TemplatePackageVersion_MicrosoftExtensionsAI_Preview)' == '$(Version)'">true - $(GeneratedContentProperties); @@ -57,12 +59,8 @@ ArtifactsShippingPackagesDir=$(ArtifactsShippingPackagesDir); - TemplatePackageVersion_MicrosoftExtensionsAI=$(TemplatePackageVersion_MicrosoftExtensionsAI); - TemplatePackageVersion_MicrosoftExtensionsAI_Preview=$(TemplatePackageVersion_MicrosoftExtensionsAI_Preview); - TemplatePackageVersion_MicrosoftExtensionsHttpResilience=$(TemplatePackageVersion_MicrosoftExtensionsHttpResilience); TemplatePackageVersion_Aspire=$(TemplatePackageVersion_Aspire); TemplatePackageVersion_Aspire_Preview=$(TemplatePackageVersion_Aspire_Preview); - TemplatePackageVersion_AzureAIOpenAI=$(TemplatePackageVersion_AzureAIOpenAI); TemplatePackageVersion_AzureAIProjects=$(TemplatePackageVersion_AzureAIProjects); TemplatePackageVersion_AzureIdentity=$(TemplatePackageVersion_AzureIdentity); TemplatePackageVersion_AzureSearchDocuments=$(TemplatePackageVersion_AzureSearchDocuments); @@ -79,7 +77,6 @@ LocalChatTemplateVariant=$(_LocalChatTemplateVariant); - UsingJustBuiltPackages=$(_TemplateUsingJustBuiltPackages); @@ -108,18 +105,9 @@ - - - <_GeneratedContentEnablingJustBuiltPackages + - - - diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj index 7784747028e..ab5ef554a3a 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj @@ -7,7 +7,7 @@ dotnet-new;templates;ai preview - 2 + 3 AI 0 0 diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/template.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/template.json index 2f9a293b32e..603ed1a2735 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/template.json +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/.template.config/template.json @@ -1,7 +1,7 @@ { "$schema": "http://json.schemastore.org/template", "author": "Microsoft", - "classifications": [ "Common", "AI", "Web", "Blazor", ".NET Aspire" ], + "classifications": [ "Common", "AI", "Web", "Blazor", "Aspire" ], "identity": "Microsoft.Extensions.AI.Templates.WebChat.CSharp", "name": "AI Chat Web App", "description": "A project template for creating an AI chat application, which uses retrieval-augmented generation (RAG) to chat with your own data.", @@ -30,7 +30,7 @@ "path": "./ChatWithCustomData-CSharp.csproj" }, { - "condition": "(IsAspire && (HostIdentifier == \"dotnetcli\" || HostIdentifier == \"dotnetcli-preview\"))", + "condition": "(IsAspire && (hostIdentifier == \"dotnetcli\" || hostIdentifier == \"dotnetcli-preview\"))", "path": "./ChatWithCustomData-CSharp.sln" }, { @@ -64,6 +64,12 @@ "*.sln" ] }, + { + "condition": "(!IsAspire || !IsOllama)", + "exclude": [ + "ChatWithCustomData-CSharp.Web/OllamaResilienceHandlerExtensions.cs" + ] + }, { "condition": "(IsAspire)", "exclude": [ @@ -76,7 +82,13 @@ } }, { - "condition": "(!UseLocalVectorStore)", + "condition": "(IsAspire && hostIdentifier != \"dotnetcli\" && hostIdentifier != \"dotnetcli-preview\")", + "exclude": [ + "*.sln" + ] + }, + { + "condition": "(!IsLocalVectorStore)", "exclude": [ "ChatWithCustomData-CSharp.Web/Services/JsonVectorStore.cs" ] @@ -171,7 +183,11 @@ "displayName": "Use Aspire orchestration", "datatype": "bool", "defaultValue": "false", - "description": "Create the project as a distributed application using .NET Aspire." + "description": "Create the project as a distributed application using Aspire." + }, + "IsManagedIdentity": { + "type": "computed", + "value": "(UseManagedIdentity)" }, "IsAspire": { "type": "computed", @@ -197,21 +213,21 @@ "type": "computed", "value": "(AiServiceProvider == \"azureaifoundry\")" }, - "UseAzureAISearch": { + "IsAzureAISearch": { "type": "computed", "value": "(VectorStore == \"azureaisearch\")" }, - "UseLocalVectorStore": { + "IsLocalVectorStore": { "type": "computed", "value": "(VectorStore == \"local\")" }, - "UseQdrant": { + "IsQdrant": { "type": "computed", "value": "(VectorStore == \"qdrant\")" }, - "UseAzure": { + "IsAzure": { "type": "computed", - "value": "(IsAzureOpenAI || IsAzureAiFoundry || UseAzureAISearch)" + "value": "(IsAzureOpenAI || IsAzureAIFoundry || IsAzureAISearch)" }, "ChatModel": { "type": "parameter", @@ -493,6 +509,12 @@ "valueTransform": "vectorStoreIndexNameTransform", "replaces": "data-ChatWithCustomData-CSharp.Web-" }, + "aspireClassNameReplacer": { + "type": "derived", + "valueSource": "name", + "valueTransform": "aspireClassName_ReplaceInvalidChars", + "replaces": "ChatWithCustomData_CSharp_Web_AspireClassName" + }, "webProjectNamespaceAdjuster": { "type": "generated", "generator": "switch", @@ -518,6 +540,12 @@ } }, "forms": { + "aspireClassName_ReplaceInvalidChars": { + "identifier": "replace", + "pattern": "(((?<=\\.)|^)(?=\\d)|\\W)", + "replacement": "_", + "description": "Insert underscore before digits at start, or after a dot, or to replace non-word characters" + }, "vectorStoreIndexNameTransform": { "identifier": "chain", "steps": [ @@ -558,7 +586,7 @@ } }, "postActions": [{ - "condition": "(hostIdentifier != \"dotnetcli\")", + "condition": "(hostIdentifier != \"dotnetcli\" && hostIdentifier != \"dotnetcli-preview\")", "description": "Opens README file in the editor", "manualInstructions": [ ], "actionId": "84C0DA21-51C8-4541-9940-6CA19AF04EE6", diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/Program.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/AppHost.cs similarity index 56% rename from src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/Program.cs rename to src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/AppHost.cs index 6be0fd58648..a859ce397a1 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/Program.cs +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/AppHost.cs @@ -1,6 +1,6 @@ var builder = DistributedApplication.CreateBuilder(args); #if (IsOllama) // ASPIRE PARAMETERS -#else // IsAzureOpenAI || IsOpenAI || IsGHModels +#elif (IsOpenAI || IsGHModels) // You will need to set the connection string to your own value // You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: @@ -13,14 +13,27 @@ // dotnet user-secrets set ConnectionStrings:openai "Endpoint=https://YOUR-DEPLOYMENT-NAME.openai.azure.com;Key=YOUR-API-KEY" #endif var openai = builder.AddConnectionString("openai"); +#else // IsAzureOpenAI + +// See https://learn.microsoft.com/dotnet/aspire/azure/local-provisioning#configuration +// for instructions providing configuration values +var openai = builder.AddAzureOpenAI("openai"); + +openai.AddDeployment( + name: "gpt-4o-mini", + modelName: "gpt-4o-mini", + modelVersion: "2024-07-18"); + +openai.AddDeployment( + name: "text-embedding-3-small", + modelName: "text-embedding-3-small", + modelVersion: "1"); #endif -#if (UseAzureAISearch) +#if (IsAzureAISearch) -// You will need to set the connection string to your own value -// You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: -// cd this-project-directory -// dotnet user-secrets set ConnectionStrings:azureAISearch "Endpoint=https://YOUR-DEPLOYMENT-NAME.search.windows.net;Key=YOUR-API-KEY" -var azureAISearch = builder.AddConnectionString("azureAISearch"); +// See https://learn.microsoft.com/dotnet/aspire/azure/local-provisioning#configuration +// for instructions providing configuration values +var search = builder.AddAzureSearch("search"); #endif #if (IsOllama) // AI SERVICE PROVIDER CONFIGURATION @@ -29,32 +42,38 @@ var chat = ollama.AddModel("chat", "llama3.2"); var embeddings = ollama.AddModel("embeddings", "all-minilm"); #endif -#if (UseAzureAISearch) // VECTOR DATABASE CONFIGURATION -#elif (UseQdrant) +#if (IsAzureAISearch) // VECTOR DATABASE CONFIGURATION +#elif (IsQdrant) var vectorDB = builder.AddQdrant("vectordb") .WithDataVolume() .WithLifetime(ContainerLifetime.Persistent); -#else // UseLocalVectorStore +#else // IsLocalVectorStore #endif -var webApp = builder.AddProject("aichatweb-app"); +var webApp = builder.AddProject("aichatweb-app"); #if (IsOllama) // AI SERVICE PROVIDER REFERENCES webApp .WithReference(chat) .WithReference(embeddings) .WaitFor(chat) .WaitFor(embeddings); -#else // IsAzureOpenAI || IsOpenAI || IsGHModels +#elif (IsOpenAI || IsGHModels) webApp.WithReference(openai); +#else // IsAzureOpenAI +webApp + .WithReference(openai) + .WaitFor(openai); #endif -#if (UseAzureAISearch) // VECTOR DATABASE REFERENCES -webApp.WithReference(azureAISearch); -#elif (UseQdrant) +#if (IsAzureAISearch) // VECTOR DATABASE REFERENCES +webApp + .WithReference(search) + .WaitFor(search); +#elif (IsQdrant) webApp .WithReference(vectorDB) .WaitFor(vectorDB); -#else // UseLocalVectorStore +#else // IsLocalVectorStore #endif builder.Build().Run(); diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/ChatWithCustomData-CSharp.AppHost.csproj.in b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/ChatWithCustomData-CSharp.AppHost.csproj.in index d7287e9301a..b7d8f13bdfa 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/ChatWithCustomData-CSharp.AppHost.csproj.in +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/ChatWithCustomData-CSharp.AppHost.csproj.in @@ -7,16 +7,19 @@ net9.0 enable enable - true b2f4f5e9-1083-472c-8c3b-f055ac67ba54 - - + + diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/Properties/launchSettings.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/Properties/launchSettings.json index cff9159f816..ff3cb400c10 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/Properties/launchSettings.json +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.AppHost/Properties/launchSettings.json @@ -9,8 +9,8 @@ "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", - "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21000", - "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22000" + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21000", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22000" } }, "http": { @@ -21,8 +21,8 @@ "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", - "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19000", - "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20000" + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19000", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20000" } } } diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.ServiceDefaults/Extensions.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.ServiceDefaults/Extensions.cs index 108f1ed2a08..204f7a64164 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.ServiceDefaults/Extensions.cs +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.ServiceDefaults/Extensions.cs @@ -10,11 +10,14 @@ namespace Microsoft.Extensions.Hosting; -// Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry. // This project should be referenced by each service project in your solution. // To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults public static class Extensions { + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder { builder.ConfigureOpenTelemetry(); @@ -29,21 +32,8 @@ public static TBuilder AddServiceDefaults(this TBuilder builder) where http.RemoveAllResilienceHandlers(); #pragma warning restore EXTEXP0001 -#if (IsOllama) - // Turn on resilience by default - http.AddStandardResilienceHandler(config => - { - // Extend the HTTP Client timeout for Ollama - config.AttemptTimeout.Timeout = TimeSpan.FromMinutes(3); - - // Must be at least double the AttemptTimeout to pass options validation - config.CircuitBreaker.SamplingDuration = TimeSpan.FromMinutes(10); - config.TotalRequestTimeout.Timeout = TimeSpan.FromMinutes(10); - }); -#else // Turn on resilience by default http.AddStandardResilienceHandler(); -#endif // Turn on service discovery by default http.AddServiceDiscovery(); @@ -77,7 +67,12 @@ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) w .WithTracing(tracing => { tracing.AddSource(builder.Environment.ApplicationName) - .AddAspNetCoreInstrumentation() + .AddAspNetCoreInstrumentation(tracing => + // Exclude health check requests from tracing + tracing.Filter = context => + !context.Request.Path.StartsWithSegments(HealthEndpointPath) + && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) + ) // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) //.AddGrpcClientInstrumentation() .AddHttpClientInstrumentation() @@ -124,10 +119,10 @@ public static WebApplication MapDefaultEndpoints(this WebApplication app) if (app.Environment.IsDevelopment()) { // All health checks must pass for app to be considered ready to accept traffic after starting - app.MapHealthChecks("/health"); + app.MapHealthChecks(HealthEndpointPath); // Only health checks tagged with the "live" tag must pass for app to be considered alive - app.MapHealthChecks("/alive", new HealthCheckOptions + app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions { Predicate = r => r.Tags.Contains("live") }); diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/ChatWithCustomData-CSharp.Web.csproj.in b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/ChatWithCustomData-CSharp.Web.csproj.in index 46d7382d6f1..05e9a9726de 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/ChatWithCustomData-CSharp.Web.csproj.in +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/ChatWithCustomData-CSharp.Web.csproj.in @@ -14,38 +14,36 @@ - + - - + - + - - +#elif (IsQdrant)--> - + diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Chat/Chat.razor b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Chat/Chat.razor index e8da69efd54..8aa0ec9fd28 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Chat/Chat.razor +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Components/Pages/Chat/Chat.razor @@ -40,6 +40,7 @@ Don't refer to the presence of citations; just emit these tags right at the end, with no surrounding text. "; + private int statefulMessageCount; private readonly ChatOptions chatOptions = new(); private readonly List messages = new(); private CancellationTokenSource? currentResponseCancellation; @@ -49,6 +50,7 @@ protected override void OnInitialized() { + statefulMessageCount = 0; messages.Add(new(ChatRole.System, SystemPrompt)); chatOptions.Tools = [AIFunctionFactory.Create(SearchAsync)]; } @@ -62,30 +64,22 @@ chatSuggestions?.Clear(); await chatInput!.FocusAsync(); -@*#if (IsOllama) - // Display a new response from the IChatClient, streaming responses - // aren't supported because Ollama will not support both streaming and using Tools - currentResponseCancellation = new(); - var response = await ChatClient.GetResponseAsync(messages, chatOptions, currentResponseCancellation.Token); - - // Store responses in the conversation, and begin getting suggestions - messages.AddMessages(response); -#else*@ // Stream and display a new response from the IChatClient var responseText = new TextContent(""); currentResponseMessage = new ChatMessage(ChatRole.Assistant, [responseText]); currentResponseCancellation = new(); - await foreach (var update in ChatClient.GetStreamingResponseAsync([.. messages], chatOptions, currentResponseCancellation.Token)) + await foreach (var update in ChatClient.GetStreamingResponseAsync(messages.Skip(statefulMessageCount), chatOptions, currentResponseCancellation.Token)) { messages.AddMessages(update, filter: c => c is not TextContent); responseText.Text += update.Text; + chatOptions.ConversationId = update.ConversationId; ChatMessageItem.NotifyChanged(currentResponseMessage); } // Store the final response in the conversation, and begin getting suggestions messages.Add(currentResponseMessage!); + statefulMessageCount = chatOptions.ConversationId is not null ? messages.Count : 0; currentResponseMessage = null; -@*#endif*@ chatSuggestions?.Update(messages); } @@ -106,6 +100,8 @@ CancelAnyCurrentResponse(); messages.Clear(); messages.Add(new(ChatRole.System, SystemPrompt)); + chatOptions.ConversationId = null; + statefulMessageCount = 0; chatSuggestions?.Clear(); await chatInput!.FocusAsync(); } diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/OllamaResilienceHandlerExtensions.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/OllamaResilienceHandlerExtensions.cs new file mode 100644 index 00000000000..fed9c91ca93 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/OllamaResilienceHandlerExtensions.cs @@ -0,0 +1,34 @@ +using System; +using Microsoft.Extensions.DependencyInjection; + +namespace ChatWithCustomData_CSharp.Web.Services; + +public static class OllamaResilienceHandlerExtensions +{ + public static IServiceCollection AddOllamaResilienceHandler(this IServiceCollection services) + { + services.ConfigureHttpClientDefaults(http => + { +#pragma warning disable EXTEXP0001 // RemoveAllResilienceHandlers is experimental + http.RemoveAllResilienceHandlers(); +#pragma warning restore EXTEXP0001 + + // Turn on resilience by default + http.AddStandardResilienceHandler(config => + { + // Extend the HTTP Client timeout for Ollama + config.AttemptTimeout.Timeout = TimeSpan.FromMinutes(3); + + // Must be at least double the AttemptTimeout to pass options validation + config.CircuitBreaker.SamplingDuration = TimeSpan.FromMinutes(10); + config.TotalRequestTimeout.Timeout = TimeSpan.FromMinutes(10); + }); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + return services; + } +} + diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Program.Aspire.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Program.Aspire.cs index adcb2452d87..e7137ac6dd3 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Program.Aspire.cs +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Program.Aspire.cs @@ -1,11 +1,10 @@ using Microsoft.Extensions.AI; +#if (IsOpenAI || IsGHModels) +using OpenAI; +#endif using ChatWithCustomData_CSharp.Web.Components; using ChatWithCustomData_CSharp.Web.Services; using ChatWithCustomData_CSharp.Web.Services.Ingestion; -#if (IsOllama) -#else // IsAzureOpenAI || IsOpenAI || IsGHModels -using OpenAI; -#endif var builder = WebApplication.CreateBuilder(args); builder.AddServiceDefaults(); @@ -19,7 +18,7 @@ c.EnableSensitiveData = builder.Environment.IsDevelopment()); builder.AddOllamaApiClient("embeddings") .AddEmbeddingGenerator(); -#elif (IsAzureAiFoundry) +#elif (IsAzureAIFoundry) #else // (IsOpenAI || IsAzureOpenAI || IsGHModels) #if (IsOpenAI) var openai = builder.AddOpenAIClient("openai"); @@ -33,15 +32,15 @@ openai.AddEmbeddingGenerator("text-embedding-3-small"); #endif -#if (UseAzureAISearch) -builder.AddAzureSearchClient("azureAISearch"); +#if (IsAzureAISearch) +builder.AddAzureSearchClient("search"); builder.Services.AddAzureAISearchCollection("data-ChatWithCustomData-CSharp.Web-chunks"); builder.Services.AddAzureAISearchCollection("data-ChatWithCustomData-CSharp.Web-documents"); -#elif (UseQdrant) +#elif (IsQdrant) builder.AddQdrantClient("vectordb"); builder.Services.AddQdrantCollection("data-ChatWithCustomData-CSharp.Web-chunks"); builder.Services.AddQdrantCollection("data-ChatWithCustomData-CSharp.Web-documents"); -#else // UseLocalVectorStore +#else // IsLocalVectorStore var vectorStorePath = Path.Combine(AppContext.BaseDirectory, "vector-store.db"); var vectorStoreConnectionString = $"Data Source={vectorStorePath}"; builder.Services.AddSqliteCollection("data-ChatWithCustomData-CSharp.Web-chunks", vectorStoreConnectionString); @@ -49,6 +48,13 @@ #endif builder.Services.AddScoped(); builder.Services.AddSingleton(); +#if (IsOllama) +// Applies robust HTTP resilience settings for all HttpClients in the Web project, +// not across the entire solution. It's aimed at supporting Ollama scenarios due +// to its self-hosted nature and potentially slow responses. +// Remove this if you want to use the global or a different HTTP resilience policy instead. +builder.Services.AddOllamaResilienceHandler(); +#endif var app = builder.Build(); diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Program.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Program.cs index f3f5740066f..3765185721a 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Program.cs +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Program.cs @@ -1,22 +1,22 @@ -using Microsoft.Extensions.AI; -using ChatWithCustomData_CSharp.Web.Components; -using ChatWithCustomData_CSharp.Web.Services; -using ChatWithCustomData_CSharp.Web.Services.Ingestion; -#if(IsAzureOpenAI || UseAzureAISearch) +#if (IsGHModels || IsOpenAI || (IsAzureOpenAI && !IsManagedIdentity)) +using System.ClientModel; +#elif (IsAzureOpenAI && IsManagedIdentity) +using System.ClientModel.Primitives; +#endif +#if (IsAzureAISearch && !IsManagedIdentity) using Azure; -#if (UseManagedIdentity) +#elif (IsManagedIdentity) using Azure.Identity; #endif -#endif +using Microsoft.Extensions.AI; #if (IsOllama) using OllamaSharp; -#elif (IsOpenAI || IsGHModels) +#elif (IsGHModels || IsOpenAI || IsAzureOpenAI) using OpenAI; -using System.ClientModel; -#else -using Azure.AI.OpenAI; -using System.ClientModel; #endif +using ChatWithCustomData_CSharp.Web.Components; +using ChatWithCustomData_CSharp.Web.Services; +using ChatWithCustomData_CSharp.Web.Services.Ingestion; var builder = WebApplication.CreateBuilder(args); builder.Services.AddRazorComponents().AddInteractiveServerComponents(); @@ -45,50 +45,63 @@ // You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: // cd this-project-directory // dotnet user-secrets set OpenAI:Key YOUR-API-KEY + var openAIClient = new OpenAIClient( new ApiKeyCredential(builder.Configuration["OpenAI:Key"] ?? throw new InvalidOperationException("Missing configuration: OpenAI:Key. See the README for details."))); -var chatClient = openAIClient.GetChatClient("gpt-4o-mini").AsIChatClient(); + +#pragma warning disable OPENAI001 // GetOpenAIResponseClient(string) is experimental and subject to change or removal in future updates. +var chatClient = openAIClient.GetOpenAIResponseClient("gpt-4o-mini").AsIChatClient(); +#pragma warning restore OPENAI001 + var embeddingGenerator = openAIClient.GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator(); -#elif (IsAzureAiFoundry) +#elif (IsAzureAIFoundry) -#else // IsAzureOpenAI +#elif (IsAzureOpenAI) // You will need to set the endpoint and key to your own values // You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: // cd this-project-directory // dotnet user-secrets set AzureOpenAI:Endpoint https://YOUR-DEPLOYMENT-NAME.openai.azure.com -#if (!UseManagedIdentity) +#if (!IsManagedIdentity) // dotnet user-secrets set AzureOpenAI:Key YOUR-API-KEY #endif -var azureOpenAi = new AzureOpenAIClient( - new Uri(builder.Configuration["AzureOpenAI:Endpoint"] ?? throw new InvalidOperationException("Missing configuration: AzureOpenAi:Endpoint. See the README for details.")), -#if (UseManagedIdentity) - new DefaultAzureCredential()); -#else - new ApiKeyCredential(builder.Configuration["AzureOpenAI:Key"] ?? throw new InvalidOperationException("Missing configuration: AzureOpenAi:Key. See the README for details."))); +var azureOpenAIEndpoint = new Uri(new Uri(builder.Configuration["AzureOpenAI:Endpoint"] ?? throw new InvalidOperationException("Missing configuration: AzureOpenAi:Endpoint. See the README for details.")), "/openai/v1"); +#if (IsManagedIdentity) +#pragma warning disable OPENAI001 // OpenAIClient(AuthenticationPolicy, OpenAIClientOptions) and GetOpenAIResponseClient(string) are experimental and subject to change or removal in future updates. +var azureOpenAi = new OpenAIClient( + new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"), + new OpenAIClientOptions { Endpoint = azureOpenAIEndpoint }); + +#elif (!IsManagedIdentity) +var openAIOptions = new OpenAIClientOptions { Endpoint = azureOpenAIEndpoint }; +var azureOpenAi = new OpenAIClient(new ApiKeyCredential(builder.Configuration["AzureOpenAI:Key"] ?? throw new InvalidOperationException("Missing configuration: AzureOpenAi:Key. See the README for details.")), openAIOptions); + +#pragma warning disable OPENAI001 // GetOpenAIResponseClient(string) is experimental and subject to change or removal in future updates. #endif -var chatClient = azureOpenAi.GetChatClient("gpt-4o-mini").AsIChatClient(); +var chatClient = azureOpenAi.GetOpenAIResponseClient("gpt-4o-mini").AsIChatClient(); +#pragma warning restore OPENAI001 + var embeddingGenerator = azureOpenAi.GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator(); #endif -#if (UseAzureAISearch) +#if (IsAzureAISearch) // You will need to set the endpoint and key to your own values // You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: // cd this-project-directory // dotnet user-secrets set AzureAISearch:Endpoint https://YOUR-DEPLOYMENT-NAME.search.windows.net -#if (!UseManagedIdentity) +#if (!IsManagedIdentity) // dotnet user-secrets set AzureAISearch:Key YOUR-API-KEY #endif var azureAISearchEndpoint = new Uri(builder.Configuration["AzureAISearch:Endpoint"] ?? throw new InvalidOperationException("Missing configuration: AzureAISearch:Endpoint. See the README for details.")); -#if (UseManagedIdentity) +#if (IsManagedIdentity) var azureAISearchCredential = new DefaultAzureCredential(); -#else +#elif (!IsManagedIdentity) var azureAISearchCredential = new AzureKeyCredential(builder.Configuration["AzureAISearch:Key"] ?? throw new InvalidOperationException("Missing configuration: AzureAISearch:Key. See the README for details.")); #endif builder.Services.AddAzureAISearchCollection("data-ChatWithCustomData-CSharp.Web-chunks", azureAISearchEndpoint, azureAISearchCredential); builder.Services.AddAzureAISearchCollection("data-ChatWithCustomData-CSharp.Web-documents", azureAISearchEndpoint, azureAISearchCredential); -#else // UseLocalVectorStore +#elif (IsLocalVectorStore) var vectorStorePath = Path.Combine(AppContext.BaseDirectory, "vector-store.db"); var vectorStoreConnectionString = $"Data Source={vectorStorePath}"; builder.Services.AddSqliteCollection("data-ChatWithCustomData-CSharp.Web-chunks", vectorStoreConnectionString); diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/README.md b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/README.md index a828e164ff8..88dff74d315 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/README.md +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/README.md @@ -5,7 +5,7 @@ This project is an AI chat application that demonstrates how to chat with custom >[!NOTE] > Before running this project you need to configure the API keys or endpoints for the providers you have chosen. See below for details specific to your choices. -#### ---#if (UseAzure) +#### ---#if (IsAzure) ### Prerequisites To use Azure OpenAI or Azure AI Search, you need an Azure account. If you don't already have one, [create an Azure account](https://azure.microsoft.com/free/). @@ -104,7 +104,7 @@ To use Azure OpenAI, you will need an Azure account and an Azure OpenAI Service ### 2. Deploy the Models Deploy the `gpt-4o-mini` and `text-embedding-3-small` models to your Azure OpenAI Service resource. When creating those deployments, give them the same names as the models (`gpt-4o-mini` and `text-embedding-3-small`). See the Azure OpenAI documentation to learn how to [Deploy a model](https://learn.microsoft.com/azure/ai-services/openai/how-to/create-resource?pivots=web-portal#deploy-a-model). -#### ---#if (UseManagedIdentity) +#### ---#if (IsManagedIdentity) ### 3. Configure Azure OpenAI for Keyless Authentication This template is configured to use keyless authentication (also known as Managed Identity, with Entra ID). In the Azure Portal, when viewing the Azure OpenAI resource you just created, view access control settings and assign yourself the `Azure AI Developer` role. [Learn more about configuring authentication for local development](https://learn.microsoft.com/azure/developer/ai/keyless-connections?tabs=csharp%2Cazure-cli#authenticate-for-local-development). @@ -160,7 +160,7 @@ Make sure to replace `YOUR-AZURE-OPENAI-KEY` and `YOUR-AZURE-OPENAI-ENDPOINT` wi #### ---#endif #### ---#endif -#### ---#if (UseAzureAISearch) +#### ---#if (IsAzureAISearch) ## Configure Azure AI Search To use Azure AI Search, you will need an Azure account and an Azure AI Search resource. For detailed instructions, see the [Azure AI Search documentation](https://learn.microsoft.com/azure/search/search-create-service-portal). @@ -170,7 +170,7 @@ Follow the instructions in the [Azure portal](https://portal.azure.com/) to crea Note that if you previously used the same Azure AI Search resource with different model using this project name, you may need to delete your `data-ChatWithCustomData-CSharp.Web-chunks` and `data-ChatWithCustomData-CSharp.Web-documents` indexes using the [Azure portal](https://portal.azure.com/) first before continuing; otherwise, data ingestion may fail due to a vector dimension mismatch. -#### ---#if (UseManagedIdentity) +#### ---#if (IsManagedIdentity) ### 2. Configure Azure AI Search for Keyless Authentication This template is configured to use keyless authentication (also known as Managed Identity, with Entra ID). Before continuing, you'll need to configure your Azure AI Search resource to support this. [Learn more](https://learn.microsoft.com/azure/search/keyless-connections). After creation, ensure that you have selected Role-Based Access Control (RBAC) under Settings > Keys, as this is not the default. Assign yourself the roles called out for local development. [Learn more](https://learn.microsoft.com/azure/search/keyless-connections#roles-for-local-development). diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/IngestedChunk.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/IngestedChunk.cs index c1369e1bf65..deff2580f52 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/IngestedChunk.cs +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/IngestedChunk.cs @@ -9,14 +9,14 @@ public class IngestedChunk #else private const int VectorDimensions = 1536; // 1536 is the default vector size for the OpenAI text-embedding-3-small model #endif -#if (UseAzureAISearch || UseQdrant) +#if (IsAzureAISearch || IsQdrant) private const string VectorDistanceFunction = DistanceFunction.CosineSimilarity; #else private const string VectorDistanceFunction = DistanceFunction.CosineDistance; #endif [VectorStoreKey] -#if (UseQdrant) +#if (IsQdrant) public required Guid Key { get; set; } #else public required string Key { get; set; } diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/IngestedDocument.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/IngestedDocument.cs index 339a7479217..27ea85df7b8 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/IngestedDocument.cs +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/IngestedDocument.cs @@ -5,14 +5,14 @@ namespace ChatWithCustomData_CSharp.Web.Services; public class IngestedDocument { private const int VectorDimensions = 2; -#if (UseAzureAISearch || UseQdrant) +#if (IsAzureAISearch || IsQdrant) private const string VectorDistanceFunction = DistanceFunction.CosineSimilarity; #else private const string VectorDistanceFunction = DistanceFunction.CosineDistance; #endif [VectorStoreKey] -#if (UseQdrant) +#if (IsQdrant) public required Guid Key { get; set; } #else public required string Key { get; set; } diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/Ingestion/DataIngestor.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/Ingestion/DataIngestor.cs index 7eeb41c99fb..5440772df42 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/Ingestion/DataIngestor.cs +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/Ingestion/DataIngestor.cs @@ -5,7 +5,7 @@ namespace ChatWithCustomData_CSharp.Web.Services.Ingestion; public class DataIngestor( ILogger logger, -#if (UseQdrant) +#if (IsQdrant) VectorStoreCollection chunksCollection, VectorStoreCollection documentsCollection) #else @@ -31,7 +31,7 @@ public async Task IngestDataAsync(IIngestionSource source) var deletedDocuments = await source.GetDeletedDocumentsAsync(documentsForSource); foreach (var deletedDocument in deletedDocuments) { - logger.LogInformation("Removing ingested data for {documentId}", deletedDocument.DocumentId); + logger.LogInformation("Removing ingested data for {DocumentId}", deletedDocument.DocumentId); await DeleteChunksForDocumentAsync(deletedDocument); await documentsCollection.DeleteAsync(deletedDocument.Key); } @@ -39,7 +39,7 @@ public async Task IngestDataAsync(IIngestionSource source) var modifiedDocuments = await source.GetNewOrModifiedDocumentsAsync(documentsForSource); foreach (var modifiedDocument in modifiedDocuments) { - logger.LogInformation("Processing {documentId}", modifiedDocument.DocumentId); + logger.LogInformation("Processing {DocumentId}", modifiedDocument.DocumentId); await DeleteChunksForDocumentAsync(modifiedDocument); await documentsCollection.UpsertAsync(modifiedDocument); @@ -54,7 +54,7 @@ async Task DeleteChunksForDocumentAsync(IngestedDocument document) { var documentId = document.DocumentId; var chunksToDelete = await chunksCollection.GetAsync(record => record.DocumentId == documentId, int.MaxValue).ToListAsync(); - if (chunksToDelete.Any()) + if (chunksToDelete.Count != 0) { await chunksCollection.DeleteAsync(chunksToDelete.Select(r => r.Key)); } diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/Ingestion/PDFDirectorySource.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/Ingestion/PDFDirectorySource.cs index fdbe058556e..0ea678d888b 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/Ingestion/PDFDirectorySource.cs +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/Ingestion/PDFDirectorySource.cs @@ -26,7 +26,7 @@ public Task> GetNewOrModifiedDocumentsAsync(IReadO var existingDocumentVersion = existingDocumentsById.TryGetValue(sourceFileId, out var existingDocument) ? existingDocument.DocumentVersion : null; if (existingDocumentVersion != sourceFileVersion) { -#if (UseQdrant) +#if (IsQdrant) results.Add(new() { Key = Guid.CreateVersion7(), SourceId = SourceId, DocumentId = sourceFileId, DocumentVersion = sourceFileVersion }); #else results.Add(new() { Key = Guid.CreateVersion7().ToString(), SourceId = SourceId, DocumentId = sourceFileId, DocumentVersion = sourceFileVersion }); @@ -52,7 +52,7 @@ public Task> CreateChunksForDocumentAsync(IngestedDoc return Task.FromResult(paragraphs.Select(p => new IngestedChunk { -#if (UseQdrant) +#if (IsQdrant) Key = Guid.CreateVersion7(), #else Key = Guid.CreateVersion7().ToString(), diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/SemanticSearch.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/SemanticSearch.cs index 44cfcc18fc4..42abf3151fc 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/SemanticSearch.cs +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Services/SemanticSearch.cs @@ -3,7 +3,7 @@ namespace ChatWithCustomData_CSharp.Web.Services; public class SemanticSearch( -#if (UseQdrant) +#if (IsQdrant) VectorStoreCollection vectorCollection) #else VectorStoreCollection vectorCollection) diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/Directory.Build.targets.in b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/Directory.Build.targets.in index 670604290b9..08d54995389 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/Directory.Build.targets.in +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/Directory.Build.targets.in @@ -4,13 +4,8 @@ It will not get included in the built project template. --> - - <_UsingJustBuiltPackages>${UsingJustBuiltPackages} - - [!NOTE] > Before running this project you need to configure the API keys or endpoints for the providers you have chosen. See below for details specific to your choices. -#### ---#if (UseAzure) +#### ---#if (IsAzure) ### Prerequisites To use Azure OpenAI or Azure AI Search, you need an Azure account. If you don't already have one, [create an Azure account](https://azure.microsoft.com/free/). @@ -81,77 +81,13 @@ Download, install, and run Docker Desktop from the [official website](https://ww Note: Ollama and Docker are excellent open source products, but are not maintained by Microsoft. #### ---#endif -#### ---#if (IsAzureOpenAI) -## Using Azure OpenAI +#### ---#if (IsAzureOpenAI || IsAzureAISearch) +## Using Azure Provisioning -To use Azure OpenAI, you will need an Azure account and an Azure OpenAI Service resource. For detailed instructions, see the [Azure OpenAI Service documentation](https://learn.microsoft.com/azure/ai-services/openai/how-to/create-resource). +The project is set up to automatically provision Azure resources. When running the app for the first time, you will be prompted to provide Azure configuration values. For detailed instructions, see the [Local Provisioning documentation](https://learn.microsoft.com/dotnet/aspire/azure/local-provisioning#configuration). -### 1. Create an Azure OpenAI Service Resource -[Create an Azure OpenAI Service resource](https://learn.microsoft.com/azure/ai-services/openai/how-to/create-resource?pivots=web-portal). - -### 2. Deploy the Models -Deploy the `gpt-4o-mini` and `text-embedding-3-small` models to your Azure OpenAI Service resource. When creating those deployments, give them the same names as the models (`gpt-4o-mini` and `text-embedding-3-small`). See the Azure OpenAI documentation to learn how to [Deploy a model](https://learn.microsoft.com/azure/ai-services/openai/how-to/create-resource?pivots=web-portal#deploy-a-model). - -### 3. Configure API Key and Endpoint -Configure your Azure OpenAI API key and endpoint for this project, using .NET User Secrets: - 1. In the Azure Portal, navigate to your Azure OpenAI resource. - 2. Copy the "Endpoint" URL and "Key 1" from the "Keys and Endpoint" section. -#### ---#if (hostIdentifier == "vs") - 3. In Visual Studio, right-click on the ChatWithCustomData-CSharp.AppHost project in the Solution Explorer and select "Manage User Secrets". - 4. This will open a secrets.json file where you can store your API key and endpoint without it being tracked in source control. Add the following keys & values to the file: - - ```json - { - "ConnectionStrings:openai": "Endpoint=https://YOUR-DEPLOYMENT-NAME.openai.azure.com;Key=YOUR-API-KEY" - } - ``` -#### ---#else - 3. From the command line, configure your API key and endpoint for this project using .NET User Secrets by running the following commands: - - ```sh - cd ChatWithCustomData-CSharp.AppHost - dotnet user-secrets set ConnectionStrings:openai "Endpoint=https://YOUR-DEPLOYMENT-NAME.openai.azure.com;Key=YOUR-API-KEY" - ``` -#### ---#endif - -Make sure to replace `YOUR-API-KEY` and `YOUR-DEPLOYMENT-NAME` with your actual Azure OpenAI key and endpoint. Make sure your endpoint URL is formatted like https://YOUR-DEPLOYMENT-NAME.openai.azure.com/ (do not include any path after .openai.azure.com/). -#### ---#endif -#### ---#if (UseAzureAISearch) - -## Configure Azure AI Search - -To use Azure AI Search, you will need an Azure account and an Azure AI Search resource. For detailed instructions, see the [Azure AI Search documentation](https://learn.microsoft.com/azure/search/search-create-service-portal). - -### 1. Create an Azure AI Search Resource -Follow the instructions in the [Azure portal](https://portal.azure.com/) to create an Azure AI Search resource. Note that there is a free tier for the service but it is not currently the default setting on the portal. - -Note that if you previously used the same Azure AI Search resource with different model using this project name, you may need to delete your `data-ChatWithCustomData-CSharp.Web-chunks` and `data-ChatWithCustomData-CSharp.Web-documents` indexes using the [Azure portal](https://portal.azure.com/) first before continuing; otherwise, data ingestion may fail due to a vector dimension mismatch. - -### 3. Configure API Key and Endpoint - Configure your Azure AI Search API key and endpoint for this project, using .NET User Secrets: - 1. In the Azure Portal, navigate to your Azure AI Search resource. - 2. Copy the "Endpoint" URL and "Primary admin key" from the "Keys" section. -#### ---#if (hostIdentifier == "vs") - 3. In Visual Studio, right-click on the ChatWithCustomData-CSharp.AppHost project in the Solution Explorer and select "Manage User Secrets". - 4. This will open a `secrets.json` file where you can store your API key and endpoint without them being tracked in source control. Add the following keys and values to the file: - - ```json - { - "ConnectionStrings:azureAISearch": "Endpoint=https://YOUR-DEPLOYMENT-NAME.search.windows.net;Key=YOUR-API-KEY" - } - ``` -#### ---#else - 3. From the command line, configure your API key and endpoint for this project using .NET User Secrets by running the following commands: - - ```sh - cd ChatWithCustomData-CSharp.AppHost - dotnet user-secrets set ConnectionStrings:azureAISearch "Endpoint=https://YOUR-DEPLOYMENT-NAME.search.windows.net;Key=YOUR-API-KEY" - ``` -#### ---#endif - -Make sure to replace `YOUR-DEPLOYMENT-NAME` and `YOUR-API-KEY` with your actual Azure AI Search deployment name and key. #### ---#endif -#### ---#if (UseQdrant) +#### ---#if (IsQdrant) ## Setting up a local environment for Qdrant This project is configured to run Qdrant in a Docker container. Docker Desktop must be installed and running for the project to run successfully. A Qdrant container will automatically start when running the application. @@ -177,9 +113,9 @@ Note: Qdrant and Docker are excellent open source products, but are not maintain ## Trust the localhost certificate -Several .NET Aspire templates include ASP.NET Core projects that are configured to use HTTPS by default. If this is the first time you're running the project, an exception might occur when loading the Aspire dashboard. This error can be resolved by trusting the self-signed development certificate with the .NET CLI. +Several Aspire templates include ASP.NET Core projects that are configured to use HTTPS by default. If this is the first time you're running the project, an exception might occur when loading the Aspire dashboard. This error can be resolved by trusting the self-signed development certificate with the .NET CLI. -See [Troubleshoot untrusted localhost certificate in .NET Aspire](https://learn.microsoft.com/dotnet/aspire/troubleshooting/untrusted-localhost-certificate) for more information. +See [Troubleshoot untrusted localhost certificate in Aspire](https://learn.microsoft.com/dotnet/aspire/troubleshooting/untrusted-localhost-certificate) for more information. # Updating JavaScript dependencies diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.mcp/server.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.mcp/server.json index d4b9d0edf5b..f5b270270d0 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.mcp/server.json +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.mcp/server.json @@ -1,20 +1,22 @@ { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json", "description": "", "name": "io.github./", + "version": "0.1.0-beta", "packages": [ { - "registry_name": "nuget", - "name": "", + "registryType": "nuget", + "identifier": "", "version": "0.1.0-beta", - "package_arguments": [], - "environment_variables": [] + "transport": { + "type": "stdio" + }, + "packageArguments": [], + "environmentVariables": [] } ], "repository": { "url": "https://github.com//", "source": "github" - }, - "version_detail": { - "version": "0.1.0-beta" } } diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/dotnetcli.host.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/dotnetcli.host.json index 5be51dd6357..64694e46660 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/dotnetcli.host.json +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/dotnetcli.host.json @@ -1,7 +1,24 @@ { "$schema": "https://json.schemastore.org/dotnetcli.host", - "symbolInfo": {}, + "symbolInfo": { + "TargetFrameworkOverride": { + "isHidden": "true", + "longName": "target-framework-override", + "shortName": "" + }, + "Framework": { + "longName": "framework" + }, + "NativeAot": { + "longName": "aot", + "shortName": "" + }, + "SelfContained": { + "longName": "self-contained", + "shortName": "" + } + }, "usageExamples": [ "" ] -} \ No newline at end of file +} diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/ide.host.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/ide.host.json index 5edf447bbd4..8574a4767a5 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/ide.host.json +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/ide.host.json @@ -2,5 +2,14 @@ "$schema": "https://json.schemastore.org/ide.host", "order": 0, "icon": "ide/icon.ico", - "symbolInfo": [] + "symbolInfo": [ + { + "id": "NativeAot", + "isVisible": true + }, + { + "id": "SelfContained", + "isVisible": true + } + ] } diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/template.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/template.json index 1fdc9128e81..3f4f85f1563 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/template.json +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/template.json @@ -18,9 +18,53 @@ "type": "project" }, "symbols": { + "TargetFrameworkOverride": { + "type": "parameter", + "description": "Overrides the target framework", + "displayName": "Target framework override", + "replaces": "TargetFrameworkOverride", + "datatype": "string", + "defaultValue": "" + }, + "Framework": { + "type": "parameter", + "description": "The target framework for the project.", + "displayName": "Framework", + "datatype": "choice", + "choices": [ + { + "choice": "net10.0", + "description": ".NET 10" + }, + { + "choice": "net9.0", + "description": ".NET 9" + }, + { + "choice": "net8.0", + "description": ".NET 8" + } + ], + "replaces": "net9.0", + "defaultValue": "net9.0" + }, "hostIdentifier": { "type": "bind", "binding": "HostIdentifier" + }, + "NativeAot": { + "type": "parameter", + "datatype": "bool", + "defaultValue": "false", + "displayName": "Enable _native AOT publish", + "description": "Whether to enable the MCP server for publishing as a native AOT application." + }, + "SelfContained": { + "type": "parameter", + "datatype": "bool", + "defaultValue": "true", + "displayName": "Enable _self-contained publish", + "description": "Whether to enable the MCP server for publishing as a self-contained application." } }, "primaryOutputs": [ @@ -42,5 +86,23 @@ }, "continueOnError": true } - ] + ], + "SpecialCustomOperations": { + "**/*.md": { + "operations": [ + { + "type": "conditional", + "configuration": { + "if": [ "#### ---#if" ], + "else": [ "#### ---#else" ], + "elseif": [ "#### ---#elseif", "#### ---#elif" ], + "endif": [ "#### ---#endif" ], + "trim": "true", + "wholeLine": "true", + "evaluator": "C++" + } + } + ] + } + } } diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/McpServer-CSharp.csproj.in b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/McpServer-CSharp.csproj.in index d47952229d9..2f07994302b 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/McpServer-CSharp.csproj.in +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/McpServer-CSharp.csproj.in @@ -1,7 +1,13 @@ - net10.0 + net9.0 + TargetFrameworkOverride + + win-x64;win-arm64;osx-arm64;linux-x64;linux-arm64;linux-musl-x64 + + Major + Exe enable enable @@ -9,6 +15,21 @@ true McpServer + + + + true + true + + + true + + + + + true + true + README.md diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/README.md b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/README.md index 50091888ad8..95612c5e35e 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/README.md +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/README.md @@ -1,9 +1,31 @@ # MCP Server -This README was created using the C# MCP server template project. It demonstrates how you can easily create an MCP server using C# and then package it in a NuGet package. +This README was created using the C# MCP server project template. +It demonstrates how you can easily create an MCP server using C# and publish it as a NuGet package. + +#### ---#if (SelfContained) +The MCP server is built as a self-contained application and does not require the .NET runtime to be installed on the target machine. +However, since it is self-contained, it must be built for each target platform separately. +By default, the template is configured to build for: +* `win-x64` +* `win-arm64` +* `osx-arm64` +* `linux-x64` +* `linux-arm64` +* `linux-musl-x64` + +If your users require more platforms to be supported, update the list of runtime identifiers in the project's `` element. +#### ---#else +The MCP server is built as a framework-dependent application and requires the .NET runtime to be installed on the target machine. +The application is configured to roll-forward to the next highest major version of the runtime if one is available on the target machine. +If an applicable .NET runtime is not available, the MCP server will not start. +Consider building the MCP server as a self-contained application if you want to avoid this dependency. +#### ---#endif See [aka.ms/nuget/mcp/guide](https://aka.ms/nuget/mcp/guide) for the full guide. +Please note that this template is currently in an early preview stage. If you have feedback, please take a [brief survey](http://aka.ms/dotnet-mcp-template-survey). + ## Checklist before publishing to NuGet.org - Test the MCP server locally using the steps below. @@ -14,67 +36,70 @@ See [aka.ms/nuget/mcp/guide](https://aka.ms/nuget/mcp/guide) for the full guide. The `bin/Release` directory will contain the package file (.nupkg), which can be [published to NuGet.org](https://learn.microsoft.com/nuget/nuget-org/publish-a-package). -## Using the MCP Server in VS Code +## Developing locally -Once the MCP server package is published to NuGet.org, you can use the following VS Code user configuration to download and install the MCP server package. See [Use MCP servers in VS Code (Preview)](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) for more information about using MCP servers in VS Code. +To test this MCP server from source code (locally) without using a built MCP server package, you can configure your IDE to run the project directly using `dotnet run`. ```json { - "mcp": { - "servers": { - "McpServer-CSharp": { - "type": "stdio", - "command": "dnx", - "args": [ - "", - "--version", - "", - "--yes" - ] - } + "servers": { + "McpServer-CSharp": { + "type": "stdio", + "command": "dotnet", + "args": [ + "run", + "--project", + "" + ] } } } ``` -Now you can ask Copilot Chat for a random number, for example, `Give me 3 random numbers`. It should prompt you to use the `get_random_number` tool on the `McpServer-CSharp` MCP server and show you the results. +## Testing the MCP Server + +Once configured, you can ask Copilot Chat for a random number, for example, `Give me 3 random numbers`. It should prompt you to use the `get_random_number` tool on the `McpServer-CSharp` MCP server and show you the results. + +## Publishing to NuGet.org + +1. Run `dotnet pack -c Release` to create the NuGet package +2. Publish to NuGet.org with `dotnet nuget push bin/Release/*.nupkg --api-key --source https://api.nuget.org/v3/index.json` -## Developing locally in VS Code +## Using the MCP Server from NuGet.org -To test this MCP server from source code (locally) without using a built MCP server package, create a `.vscode/mcp.json` file (a VS Code workspace settings file) in your project directory and add the following configuration: +Once the MCP server package is published to NuGet.org, you can configure it in your preferred IDE. Both VS Code and Visual Studio use the `dnx` command to download and install the MCP server package from NuGet.org. + +- **VS Code**: Create a `/.vscode/mcp.json` file +- **Visual Studio**: Create a `\.mcp.json` file + +For both VS Code and Visual Studio, the configuration file uses the following server definition: ```json { "servers": { "McpServer-CSharp": { "type": "stdio", - "command": "dotnet", + "command": "dnx", "args": [ - "run", - "--project", - "" + "", + "--version", + "", + "--yes" ] } } } ``` -Alternatively, you can configure your VS Code user settings to use your local project: +## More information -```json -{ - "mcp": { - "servers": { - "McpServer-CSharp": { - "type": "stdio", - "command": "dotnet", - "args": [ - "run", - "--project", - "" - ] - } - } - } -} -``` +.NET MCP servers use the [ModelContextProtocol](https://www.nuget.org/packages/ModelContextProtocol) C# SDK. For more information about MCP: + +- [Official Documentation](https://modelcontextprotocol.io/) +- [Protocol Specification](https://spec.modelcontextprotocol.io/) +- [GitHub Organization](https://github.com/modelcontextprotocol) + +Refer to the VS Code or Visual Studio documentation for more information on configuring and using MCP servers: + +- [Use MCP servers in VS Code (Preview)](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) +- [Use MCP servers in Visual Studio (Preview)](https://learn.microsoft.com/visualstudio/ide/mcp-servers) diff --git a/src/Shared/.editorconfig b/src/Shared/.editorconfig index bc980461f04..defd1d59afc 100644 --- a/src/Shared/.editorconfig +++ b/src/Shared/.editorconfig @@ -5868,7 +5868,7 @@ dotnet_diagnostic.SA1414.severity = warning # Title : Braces for multi-line statements should not share line # Category : StyleCop.CSharp.LayoutRules # Help Link: https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1500.md -dotnet_diagnostic.SA1500.severity = warning +dotnet_diagnostic.SA1500.severity = suggestion # rule does not work well with field-based property initializers # Title : Statement should not be on a single line # Category : StyleCop.CSharp.LayoutRules @@ -5936,7 +5936,7 @@ dotnet_diagnostic.SA1512.severity = none # Title : Closing brace should be followed by blank line # Category : StyleCop.CSharp.LayoutRules # Help Link: https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1513.md -dotnet_diagnostic.SA1513.severity = warning +dotnet_diagnostic.SA1513.severity = suggestion # rule does not work well with field-based property initializers # Title : Element documentation header should be preceded by blank line # Category : StyleCop.CSharp.LayoutRules diff --git a/src/Shared/Debugger/DebuggerExtensions.cs b/src/Shared/Debugger/DebuggerExtensions.cs index cc197be12c2..8c8ae506a20 100644 --- a/src/Shared/Debugger/DebuggerExtensions.cs +++ b/src/Shared/Debugger/DebuggerExtensions.cs @@ -3,7 +3,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -using Throw = Microsoft.Shared.Diagnostics.Throw; #pragma warning disable CA1716 namespace Microsoft.Shared.Diagnostics; diff --git a/src/Shared/Debugger/IDebuggerState.cs b/src/Shared/Debugger/IDebuggerState.cs index 9e5eb7ac0be..bcbe3d13187 100644 --- a/src/Shared/Debugger/IDebuggerState.cs +++ b/src/Shared/Debugger/IDebuggerState.cs @@ -13,5 +13,5 @@ internal interface IDebuggerState /// /// Gets a value indicating whether a debugger is attached or not. /// - public bool IsAttached { get; } + bool IsAttached { get; } } diff --git a/src/Shared/FxPolyfills/ArgumentException.cs b/src/Shared/FxPolyfills/ArgumentException.cs new file mode 100644 index 00000000000..aafc737ab13 --- /dev/null +++ b/src/Shared/FxPolyfills/ArgumentException.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.CodeAnalysis; +using System.Runtime.CompilerServices; + +namespace System; + +internal static partial class FxPolyfillArgumentException +{ + extension(ArgumentException) + { + public static void ThrowIfNullOrEmpty([NotNull] string? argument, [CallerArgumentExpression(nameof(argument))] string? paramName = null) + { + if (string.IsNullOrEmpty(argument)) + { + ThrowNullOrEmptyException(argument, paramName); + } + } + } + + [DoesNotReturn] + private static void ThrowNullOrEmptyException(string? argument, string? paramName) + { + ArgumentNullException.ThrowIfNull(argument, paramName); + throw new ArgumentException("The value cannot be an empty string.", paramName); + } +} diff --git a/src/Shared/FxPolyfills/ArgumentNullException.cs b/src/Shared/FxPolyfills/ArgumentNullException.cs new file mode 100644 index 00000000000..5585b1e66f8 --- /dev/null +++ b/src/Shared/FxPolyfills/ArgumentNullException.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.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; + +namespace System; + +internal static partial class FxPolyfillArgumentNullException +{ + extension(ArgumentNullException) + { + public static void ThrowIfNull([NotNull] object? argument, [CallerArgumentExpression(nameof(argument))] string? paramName = null) + { + if (argument is null) + { + Throw(paramName); + } + } + } + + [DoesNotReturn] + internal static void Throw(string? paramName) => throw new ArgumentNullException(paramName); +} diff --git a/src/Shared/FxPolyfills/ConcurrentDictionary.cs b/src/Shared/FxPolyfills/ConcurrentDictionary.cs new file mode 100644 index 00000000000..92e4a2195df --- /dev/null +++ b/src/Shared/FxPolyfills/ConcurrentDictionary.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. + +namespace System.Collections.Concurrent; + +internal static partial class FxPolyfillConcurrentDictionary +{ + extension(ConcurrentDictionary dictionary) + { + public TValue GetOrAdd(TKey key, Func valueFactory) + { + if (dictionary.TryGetValue(key, out var existing)) + { + return existing; + } + + return dictionary.GetOrAdd(key, valueFactory(key)); + } + + public TValue GetOrAdd(TKey key, Func valueFactory, TState state) + { + if (dictionary.TryGetValue(key, out var existing)) + { + return existing; + } + + return dictionary.GetOrAdd(key, valueFactory(key, state)); + } + + public void TryRemove(TKey key) + { + dictionary.TryRemove(key, out _); + } + + public void TryRemove(KeyValuePair pair) + { + if (dictionary.TryRemove(pair.Key, out var existing) && !EqualityComparer.Default.Equals(existing, pair.Value)) + { + dictionary.TryAdd(pair.Key, pair.Value); + } + } + } +} diff --git a/src/Shared/FxPolyfills/ExceptionDispatchInfo.cs b/src/Shared/FxPolyfills/ExceptionDispatchInfo.cs new file mode 100644 index 00000000000..81cee7cba9a --- /dev/null +++ b/src/Shared/FxPolyfills/ExceptionDispatchInfo.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.Diagnostics.CodeAnalysis; + +namespace System.Runtime.ExceptionServices; + +internal static partial class FxPolyfillExceptionDispatchInfo +{ + extension(ExceptionDispatchInfo) + { + [DoesNotReturn] + public static void Throw(Exception ex) + { + ExceptionDispatchInfo.Capture(ex).Throw(); + } + } +} diff --git a/src/Shared/FxPolyfills/FxPolyfills.targets b/src/Shared/FxPolyfills/FxPolyfills.targets new file mode 100644 index 00000000000..ca38f9ab986 --- /dev/null +++ b/src/Shared/FxPolyfills/FxPolyfills.targets @@ -0,0 +1,25 @@ + + + $(MSBuildThisFileDirectory) + + $(NoWarn);CS8763;CS8777;CS8603;CA1031;IDE0058;S108;S2166;S2302;S2333;S2486;S3400;SA1402;SA1509;SA1515;SA1649;EA0014;LA0001;VSTHRD003 + + + + + + + + + + + + + + + + diff --git a/src/Shared/FxPolyfills/IPEndPoint.cs b/src/Shared/FxPolyfills/IPEndPoint.cs new file mode 100644 index 00000000000..8571b675bb5 --- /dev/null +++ b/src/Shared/FxPolyfills/IPEndPoint.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.Globalization; + +namespace System.Net; + +internal static partial class FxPolyfillIPEndPoint +{ + extension(IPEndPoint) + { + public static IPEndPoint Parse(string endpoint) + { + if (TryParse(endpoint.AsSpan(), out var result)) + { + return result; + } + + throw new FormatException("The endpoint format is invalid."); + } + + public static bool TryParse(ReadOnlySpan s, out IPEndPoint? result) + { + const int MaxPort = 0x0000FFFF; + + int addressLength = s.Length; // If there's no port then send the entire string to the address parser + int lastColonPos = s.LastIndexOf(':'); + + // Look to see if this is an IPv6 address with a port. + if (lastColonPos > 0) + { + if (s[lastColonPos - 1] == ']') + { + addressLength = lastColonPos; + } + // Look to see if this is IPv4 with a port (IPv6 will have another colon) + else if (s.Slice(0, lastColonPos).LastIndexOf(':') == -1) + { + addressLength = lastColonPos; + } + } + + if (IPAddress.TryParse(s.Slice(0, addressLength).ToString(), out IPAddress? address)) + { + uint port = 0; + if (addressLength == s.Length || + (uint.TryParse(s.Slice(addressLength + 1).ToString(), NumberStyles.None, CultureInfo.InvariantCulture, out port) && port <= MaxPort)) + + { + result = new IPEndPoint(address, (int)port); + return true; + } + } + + result = null; + return false; + } + } +} diff --git a/src/Shared/FxPolyfills/Interlocked.cs b/src/Shared/FxPolyfills/Interlocked.cs new file mode 100644 index 00000000000..6177e411c35 --- /dev/null +++ b/src/Shared/FxPolyfills/Interlocked.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.Runtime.CompilerServices; + +namespace System.Threading; + +internal static partial class FxPolyfillInterlocked +{ + extension(Interlocked) + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static uint Decrement(ref uint location) => + (uint)Interlocked.Add(ref Unsafe.As(ref location), -1); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ulong Decrement(ref ulong location) => + (ulong)Interlocked.Add(ref Unsafe.As(ref location), -1); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static uint Increment(ref uint location) => + Add(ref location, 1); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ulong Increment(ref ulong location) => + Add(ref location, 1); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static uint Add(ref uint location1, uint value) => + (uint)Interlocked.Add(ref Unsafe.As(ref location1), (int)value); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ulong Add(ref ulong location1, ulong value) => + (ulong)Interlocked.Add(ref Unsafe.As(ref location1), (long)value); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static long Or(ref long location1, long value) + { + long current = location1; + while (true) + { + long newValue = current | value; + long oldValue = Interlocked.CompareExchange(ref location1, newValue, current); + if (oldValue == current) + { + return oldValue; + } + current = oldValue; + } + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ulong And(ref ulong location1, ulong value) => + (ulong)Interlocked.And(ref Unsafe.As(ref location1), (long)value); + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static uint And(ref uint location1, uint value) => + (uint)Interlocked.And(ref Unsafe.As(ref location1), (int)value); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int And(ref int location1, int value) + { + int current = location1; + while (true) + { + int newValue = current & value; + int oldValue = Interlocked.CompareExchange(ref location1, newValue, current); + if (oldValue == current) + { + return oldValue; + } + current = oldValue; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static long And(ref long location1, long value) + { + long current = location1; + while (true) + { + long newValue = current & value; + long oldValue = Interlocked.CompareExchange(ref location1, newValue, current); + if (oldValue == current) + { + return oldValue; + } + current = oldValue; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ulong Or(ref ulong location1, ulong value) => + (ulong)Or(ref Unsafe.As(ref location1), (long)value); + } +} diff --git a/src/Shared/FxPolyfills/KeyValuePair.cs b/src/Shared/FxPolyfills/KeyValuePair.cs new file mode 100644 index 00000000000..64c79606bf4 --- /dev/null +++ b/src/Shared/FxPolyfills/KeyValuePair.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. + +namespace System.Collections.Generic; + +internal static partial class FxPolyfillKeyValuePair +{ + extension(KeyValuePair pair) + { + public void Deconstruct(out TKey key, out TValue value) + { + key = pair.Key; + value = pair.Value; + } + } +} + +internal static class KeyValuePair +{ + public static KeyValuePair Create(TKey key, TValue value) + { + return new KeyValuePair(key, value); + } +} diff --git a/src/Shared/FxPolyfills/ObjectDisposedException.cs b/src/Shared/FxPolyfills/ObjectDisposedException.cs new file mode 100644 index 00000000000..85ec090dd6c --- /dev/null +++ b/src/Shared/FxPolyfills/ObjectDisposedException.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. + +using System.Diagnostics.CodeAnalysis; + +namespace System; + +internal static partial class FxPolyfillObjectDisposedException +{ + extension(ObjectDisposedException) + { + public static void ThrowIf([DoesNotReturnIf(true)] bool condition, object instance) + { + if (condition) + { + throw new ObjectDisposedException(instance?.GetType().FullName); + } + } + } +} diff --git a/src/Shared/FxPolyfills/OperatingSystem.cs b/src/Shared/FxPolyfills/OperatingSystem.cs new file mode 100644 index 00000000000..4c88e7909aa --- /dev/null +++ b/src/Shared/FxPolyfills/OperatingSystem.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. + +namespace System; + +internal static class FrameworkExtensions +{ + extension(OperatingSystem) + { + public static bool IsLinux() => false; + public static bool IsWindows() => true; + public static bool IsMacOS() => false; + } +} diff --git a/src/Shared/FxPolyfills/String.cs b/src/Shared/FxPolyfills/String.cs new file mode 100644 index 00000000000..92df065be7e --- /dev/null +++ b/src/Shared/FxPolyfills/String.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; + +internal static partial class FxPolyfillString +{ + extension(string s) + { + public bool StartsWith(char c) => s is [{ } first, ..] && first == c; + } +} diff --git a/src/Shared/FxPolyfills/Task.TimeProvider.cs b/src/Shared/FxPolyfills/Task.TimeProvider.cs new file mode 100644 index 00000000000..7e3a9c85bf0 --- /dev/null +++ b/src/Shared/FxPolyfills/Task.TimeProvider.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Threading.Tasks; + +internal static partial class FxPolyfillTask +{ + extension(Task task) + { + public Task WaitAsync(CancellationToken token) + { + return task.WaitAsync(Timeout.InfiniteTimeSpan, TimeProvider.System, token); + } + } +} diff --git a/src/Shared/FxPolyfills/Task.cs b/src/Shared/FxPolyfills/Task.cs new file mode 100644 index 00000000000..0035cde4b6f --- /dev/null +++ b/src/Shared/FxPolyfills/Task.cs @@ -0,0 +1,54 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Threading.Tasks; + +internal enum ConfigureAwaitOptions +{ + None, + ContinueOnCapturedContext, + ForceYielding, + SuppressThrowing, +} + +internal static partial class FxPolyfillTask +{ + extension(Task task) + { + public async Task ConfigureAwait(ConfigureAwaitOptions options) + { + if (options == ConfigureAwaitOptions.None) + { + await task.ConfigureAwait(false); + } + else if (options == ConfigureAwaitOptions.ContinueOnCapturedContext) + { + await task.ConfigureAwait(true); + } + else if (options == ConfigureAwaitOptions.ForceYielding) + { + await Task.Yield(); + await task.ConfigureAwait(false); + } + else if (options == ConfigureAwaitOptions.SuppressThrowing) + { + try + { + await task.ConfigureAwait(false); + } + catch + { + } + } + else + { + throw new InvalidOperationException(); + } + } + } +} + +internal sealed class TaskCompletionSource(TaskCreationOptions options) : TaskCompletionSource(options) +{ + public void SetResult() => SetResult(true); +} diff --git a/src/Shared/Instruments/ResourceUtilizationInstruments.cs b/src/Shared/Instruments/ResourceUtilizationInstruments.cs index 3b3e4f80ea2..b1593edd396 100644 --- a/src/Shared/Instruments/ResourceUtilizationInstruments.cs +++ b/src/Shared/Instruments/ResourceUtilizationInstruments.cs @@ -18,6 +18,14 @@ internal static class ResourceUtilizationInstruments /// public const string MeterName = "Microsoft.Extensions.Diagnostics.ResourceMonitoring"; + /// + /// The name of an instrument to retrieve CPU time consumed by the specific container on all available CPU cores, measured in seconds. + /// + /// + /// The type of an instrument is . + /// + public const string ContainerCpuTime = "container.cpu.time"; + /// /// The name of an instrument to retrieve CPU limit consumption of all processes running inside a container or control group in range [0, 1]. /// @@ -42,6 +50,14 @@ internal static class ResourceUtilizationInstruments /// public const string ContainerMemoryLimitUtilization = "container.memory.limit.utilization"; + /// + /// The name of an instrument to retrieve memory usage measured in bytes of all processes running inside a container or control group. + /// + /// + /// The type of an instrument is . + /// + public const string ContainerMemoryUsage = "container.memory.usage"; + /// /// The name of an instrument to retrieve CPU consumption share of the running process in range [0, 1]. /// @@ -89,22 +105,6 @@ internal static class ResourceUtilizationInstruments /// The type of an instrument is . /// public const string SystemNetworkConnections = "system.network.connections"; - - /// - /// The name of an instrument to count occurrences when CPU utilization exceeds 100% of the limit. - /// - /// - /// The type of an instrument is . - /// - public const string CpuUtilizationLimit100PercentExceeded = "cpu.utilization.limit.100percent.exceeded"; - - /// - /// The name of an instrument to count occurrences when CPU utilization exceeds 110% of the limit. - /// - /// - /// The type of an instrument is . - /// - public const string CpuUtilizationLimit110PercentExceeded = "cpu.utilization.limit.110percent.exceeded"; } #pragma warning disable CS1574 diff --git a/src/Shared/JsonSchemaExporter/JsonSchemaExporter.JsonSchema.cs b/src/Shared/JsonSchemaExporter/JsonSchemaExporter.JsonSchema.cs deleted file mode 100644 index a395c133980..00000000000 --- a/src/Shared/JsonSchemaExporter/JsonSchemaExporter.JsonSchema.cs +++ /dev/null @@ -1,545 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#if !NET9_0_OR_GREATER -using System.Collections.Generic; -using System.Diagnostics; -using System.Text.Json.Nodes; - -namespace System.Text.Json.Schema; - -#pragma warning disable SA1204 // Static elements should appear before instance elements -#pragma warning disable S1144 // Unused private types or members should be removed - -internal static partial class JsonSchemaExporter -{ - // Simple JSON schema representation taken from System.Text.Json - // https://github.com/dotnet/runtime/blob/50d6cad649aad2bfa4069268eddd16fd51ec5cf3/src/libraries/System.Text.Json/src/System/Text/Json/Schema/JsonSchema.cs - private sealed class JsonSchema - { - public static JsonSchema CreateFalseSchema() => new(false); - public static JsonSchema CreateTrueSchema() => new(true); - - public JsonSchema() - { - } - - private JsonSchema(bool trueOrFalse) - { - _trueOrFalse = trueOrFalse; - } - - public bool IsTrue => _trueOrFalse is true; - public bool IsFalse => _trueOrFalse is false; - private readonly bool? _trueOrFalse; - - public string? Schema - { - get => _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.CreateTrueSchema() }; - break; - case true: - schema = new JsonSchema(); - break; - } - } - - private static readonly JsonSchemaType[] _schemaValues = new JsonSchemaType[] - { - // NB the order of these values influences order of types in the rendered schema - JsonSchemaType.String, - JsonSchemaType.Integer, - JsonSchemaType.Number, - JsonSchemaType.Boolean, - JsonSchemaType.Array, - JsonSchemaType.Object, - JsonSchemaType.Null, - }; - - private void VerifyMutable() - { - Debug.Assert(_trueOrFalse is null, "Schema is not mutable"); - } - - private static JsonNode? MapSchemaType(JsonSchemaType schemaType) - { - if (schemaType is JsonSchemaType.Any) - { - return null; - } - - if (ToIdentifier(schemaType) is string identifier) - { - return identifier; - } - - var array = new JsonArray(); - foreach (JsonSchemaType type in _schemaValues) - { - if ((schemaType & type) != 0) - { - array.Add((JsonNode)ToIdentifier(type)!); - } - } - - return array; - - static string? ToIdentifier(JsonSchemaType schemaType) => schemaType switch - { - JsonSchemaType.Null => "null", - JsonSchemaType.Boolean => "boolean", - JsonSchemaType.Integer => "integer", - JsonSchemaType.Number => "number", - JsonSchemaType.String => "string", - JsonSchemaType.Array => "array", - JsonSchemaType.Object => "object", - _ => null, - }; - } - } - - [Flags] - private enum JsonSchemaType - { - Any = 0, // No type declared on the schema - Null = 1, - Boolean = 2, - Integer = 4, - Number = 8, - String = 16, - Array = 32, - Object = 64, - } -} -#endif diff --git a/src/Shared/JsonSchemaExporter/JsonSchemaExporter.ReflectionHelpers.cs b/src/Shared/JsonSchemaExporter/JsonSchemaExporter.ReflectionHelpers.cs deleted file mode 100644 index 481e5f75753..00000000000 --- a/src/Shared/JsonSchemaExporter/JsonSchemaExporter.ReflectionHelpers.cs +++ /dev/null @@ -1,427 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#if !NET9_0_OR_GREATER -using System.Collections.Generic; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -#if !NET -using System.Linq; -#endif -using System.Reflection; -using System.Text.Json.Serialization; -using System.Text.Json.Serialization.Metadata; -using Microsoft.Shared.Diagnostics; - -#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields - -namespace System.Text.Json.Schema; - -internal static partial class JsonSchemaExporter -{ - private static class ReflectionHelpers - { - private const BindingFlags AllInstance = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; - private static PropertyInfo? _jsonTypeInfo_ElementType; - private static PropertyInfo? _jsonPropertyInfo_MemberName; - private static FieldInfo? _nullableConverter_ElementConverter_Generic; - private static FieldInfo? _enumConverter_Options_Generic; - private static FieldInfo? _enumConverter_NamingPolicy_Generic; - - public static bool IsBuiltInConverter(JsonConverter converter) => - converter.GetType().Assembly == typeof(JsonConverter).Assembly; - - public static 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 deleted file mode 100644 index 2d8ffc5497c..00000000000 --- a/src/Shared/JsonSchemaExporter/JsonSchemaExporter.cs +++ /dev/null @@ -1,801 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#if !NET9_0_OR_GREATER -using System.Collections.Generic; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Globalization; -using System.Linq; -using System.Reflection; -#if NET -using System.Runtime.InteropServices; -#endif -using System.Text.Json.Nodes; -using System.Text.Json.Serialization; -using System.Text.Json.Serialization.Metadata; -using Microsoft.Shared.Diagnostics; - -#pragma warning disable LA0002 // Use 'Microsoft.Shared.Text.NumericExtensions.ToInvariantString' for improved performance -#pragma warning disable S107 // Methods should not have too many parameters -#pragma warning disable S1121 // Assignments should not be made from within sub-expressions - -namespace System.Text.Json.Schema; - -/// -/// Maps .NET types to JSON schema objects using contract metadata from instances. -/// -#if !SHARED_PROJECT -[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] -#endif -internal static partial class JsonSchemaExporter -{ - // Polyfill implementation of JsonSchemaExporter for System.Text.Json version 8.0.0. - // Uses private reflection to access metadata not available with the older APIs of STJ. - - private const string RequiresUnreferencedCodeMessage = - "Uses private reflection on System.Text.Json components to access converter metadata. " + - "If running Native AOT ensure that the 'IlcTrimMetadata' property has been disabled."; - - /// - /// Generates a JSON schema corresponding to the contract metadata of the specified type. - /// - /// The options instance from which to resolve the contract metadata. - /// The root type for which to generate the JSON schema. - /// The exporterOptions object controlling the schema generation. - /// A new instance defining the JSON schema for . - /// One of the specified parameters is . - /// The parameter contains unsupported exporterOptions. - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] - public static JsonNode GetJsonSchemaAsNode(this JsonSerializerOptions options, Type type, JsonSchemaExporterOptions? exporterOptions = null) - { - _ = Throw.IfNull(options); - _ = Throw.IfNull(type); - ValidateOptions(options); - - exporterOptions ??= JsonSchemaExporterOptions.Default; - JsonTypeInfo typeInfo = options.GetTypeInfo(type); - return MapRootTypeJsonSchema(typeInfo, exporterOptions); - } - - /// - /// Generates a JSON schema corresponding to the specified contract metadata. - /// - /// The contract metadata for which to generate the schema. - /// The exporterOptions object controlling the schema generation. - /// A new instance defining the JSON schema for . - /// One of the specified parameters is . - /// The parameter contains unsupported exporterOptions. - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] - public static JsonNode GetJsonSchemaAsNode(this JsonTypeInfo typeInfo, JsonSchemaExporterOptions? exporterOptions = null) - { - _ = Throw.IfNull(typeInfo); - ValidateOptions(typeInfo.Options); - - exporterOptions ??= JsonSchemaExporterOptions.Default; - return MapRootTypeJsonSchema(typeInfo, exporterOptions); - } - - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] - private static JsonNode MapRootTypeJsonSchema(JsonTypeInfo typeInfo, JsonSchemaExporterOptions exporterOptions) - { - GenerationState state = new(exporterOptions, typeInfo.Options); - JsonSchema schema = MapJsonSchemaCore(ref state, typeInfo); - return schema.ToJsonNode(exporterOptions); - } - - [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] - private static JsonSchema MapJsonSchemaCore( - ref GenerationState state, - JsonTypeInfo typeInfo, - Type? parentType = null, - JsonPropertyInfo? propertyInfo = null, - ICustomAttributeProvider? propertyAttributeProvider = null, - ParameterInfo? parameterInfo = null, - bool isNonNullableType = false, - JsonConverter? customConverter = null, - JsonNumberHandling? customNumberHandling = null, - JsonTypeInfo? parentPolymorphicTypeInfo = null, - bool parentPolymorphicTypeContainsTypesWithoutDiscriminator = false, - bool parentPolymorphicTypeIsNonNullable = false, - KeyValuePair? typeDiscriminator = null, - bool cacheResult = true) - { - Debug.Assert(typeInfo.IsReadOnly, "The specified contract must have been made read-only."); - - JsonSchemaExporterContext exporterContext = state.CreateContext(typeInfo, parentPolymorphicTypeInfo, parentType, propertyInfo, parameterInfo, propertyAttributeProvider); - - if (cacheResult && typeInfo.Kind is not JsonTypeInfoKind.None && - state.TryGetExistingJsonPointer(exporterContext, out string? existingJsonPointer)) - { - // The schema context has already been generated in the schema document, return a reference to it. - return CompleteSchema(ref state, new JsonSchema { Ref = existingJsonPointer }); - } - - JsonSchema schema; - JsonConverter effectiveConverter = customConverter ?? typeInfo.Converter; - JsonNumberHandling effectiveNumberHandling = customNumberHandling ?? typeInfo.NumberHandling ?? typeInfo.Options.NumberHandling; - - if (!ReflectionHelpers.IsBuiltInConverter(effectiveConverter)) - { - // Return a `true` schema for types with user-defined converters. - return CompleteSchema(ref state, JsonSchema.CreateTrueSchema()); - } - - if (parentPolymorphicTypeInfo is null && typeInfo.PolymorphismOptions is { DerivedTypes.Count: > 0 } polyOptions) - { - // This is the base type of a polymorphic type hierarchy. The schema for this type - // will include an "anyOf" property with the schemas for all derived types. - - string typeDiscriminatorKey = polyOptions.TypeDiscriminatorPropertyName; - List derivedTypes = polyOptions.DerivedTypes.ToList(); - - if (!typeInfo.Type.IsAbstract && !derivedTypes.Any(derived => derived.DerivedType == typeInfo.Type)) - { - // For non-abstract base types that haven't been explicitly configured, - // add a trivial schema to the derived types since we should support it. - derivedTypes.Add(new JsonDerivedType(typeInfo.Type)); - } - - bool containsTypesWithoutDiscriminator = derivedTypes.Exists(static derivedTypes => derivedTypes.TypeDiscriminator is null); - JsonSchemaType schemaType = JsonSchemaType.Any; - List? anyOf = new(derivedTypes.Count); - - state.PushSchemaNode(JsonSchemaConstants.AnyOfPropertyName); - - foreach (JsonDerivedType derivedType in derivedTypes) - { - Debug.Assert(derivedType.TypeDiscriminator is null or int or string, "Type discriminator does not have the expected type."); - - KeyValuePair? derivedTypeDiscriminator = null; - if (derivedType.TypeDiscriminator is { } discriminatorValue) - { - JsonNode discriminatorNode = discriminatorValue switch - { - string stringId => (JsonNode)stringId, - _ => (JsonNode)(int)discriminatorValue, - }; - - JsonSchema discriminatorSchema = new() { Constant = discriminatorNode }; - derivedTypeDiscriminator = new(typeDiscriminatorKey, discriminatorSchema); - } - - JsonTypeInfo derivedTypeInfo = typeInfo.Options.GetTypeInfo(derivedType.DerivedType); - - state.PushSchemaNode(anyOf.Count.ToString(CultureInfo.InvariantCulture)); - JsonSchema derivedSchema = MapJsonSchemaCore( - ref state, - derivedTypeInfo, - parentPolymorphicTypeInfo: typeInfo, - typeDiscriminator: derivedTypeDiscriminator, - parentPolymorphicTypeContainsTypesWithoutDiscriminator: containsTypesWithoutDiscriminator, - parentPolymorphicTypeIsNonNullable: isNonNullableType, - cacheResult: false); - - state.PopSchemaNode(); - - // Determine if all derived schemas have the same type. - if (anyOf.Count == 0) - { - schemaType = derivedSchema.Type; - } - else if (schemaType != derivedSchema.Type) - { - schemaType = JsonSchemaType.Any; - } - - anyOf.Add(derivedSchema); - } - - state.PopSchemaNode(); - - if (schemaType is not JsonSchemaType.Any) - { - // If all derived types have the same schema type, we can simplify the schema - // by moving the type keyword to the base schema and removing it from the derived schemas. - foreach (JsonSchema derivedSchema in anyOf) - { - derivedSchema.Type = JsonSchemaType.Any; - - if (derivedSchema.KeywordCount == 0) - { - // if removing the type results in an empty schema, - // remove the anyOf array entirely since it's always true. - anyOf = null; - break; - } - } - } - - schema = new() - { - Type = schemaType, - AnyOf = anyOf, - - // If all derived types have a discriminator, we can require it in the base schema. - Required = containsTypesWithoutDiscriminator ? null : new() { typeDiscriminatorKey }, - }; - - return CompleteSchema(ref state, schema); - } - - if (Nullable.GetUnderlyingType(typeInfo.Type) is Type nullableElementType) - { - JsonTypeInfo elementTypeInfo = typeInfo.Options.GetTypeInfo(nullableElementType); - customConverter = ExtractCustomNullableConverter(customConverter); - schema = MapJsonSchemaCore(ref state, elementTypeInfo, customConverter: customConverter, cacheResult: false); - - if (schema.Enum != null) - { - Debug.Assert(elementTypeInfo.Type.IsEnum, "The enum keyword should only be populated by schemas for enum types."); - schema.Enum.Add(null); // Append null to the enum array. - } - - return CompleteSchema(ref state, schema); - } - - switch (typeInfo.Kind) - { - case JsonTypeInfoKind.Object: - List>? properties = null; - List? required = null; - JsonSchema? additionalProperties = null; - - JsonUnmappedMemberHandling effectiveUnmappedMemberHandling = typeInfo.UnmappedMemberHandling ?? typeInfo.Options.UnmappedMemberHandling; - if (effectiveUnmappedMemberHandling is JsonUnmappedMemberHandling.Disallow) - { - // Disallow unspecified properties. - additionalProperties = JsonSchema.CreateFalseSchema(); - } - - if (typeDiscriminator is { } typeDiscriminatorPair) - { - (properties = new()).Add(typeDiscriminatorPair); - if (parentPolymorphicTypeContainsTypesWithoutDiscriminator) - { - // Require the discriminator here since it's not common to all derived types. - (required = new()).Add(typeDiscriminatorPair.Key); - } - } - - Func? parameterInfoMapper = - ReflectionHelpers.ResolveJsonConstructorParameterMapper(typeInfo.Type, typeInfo); - - state.PushSchemaNode(JsonSchemaConstants.PropertiesPropertyName); - foreach (JsonPropertyInfo property in typeInfo.Properties) - { - if (property is { Get: null, Set: null } or { IsExtensionData: true }) - { - continue; // Skip JsonIgnored properties and extension data - } - - JsonNumberHandling? propertyNumberHandling = property.NumberHandling ?? effectiveNumberHandling; - JsonTypeInfo propertyTypeInfo = typeInfo.Options.GetTypeInfo(property.PropertyType); - - // Resolve the attribute provider for the property. - ICustomAttributeProvider? attributeProvider = ReflectionHelpers.ResolveAttributeProvider(typeInfo.Type, property); - - // Declare the property as nullable if either getter or setter are nullable. - bool isNonNullableProperty = false; - if (attributeProvider is MemberInfo memberInfo) - { - NullabilityInfo nullabilityInfo = ReflectionHelpers.GetMemberNullability(state.NullabilityInfoContext, memberInfo); - isNonNullableProperty = - (property.Get is null || nullabilityInfo.ReadState is NullabilityState.NotNull) && - (property.Set is null || nullabilityInfo.WriteState is NullabilityState.NotNull); - } - - bool isRequired = property.IsRequired; - bool hasDefaultValue = false; - JsonNode? defaultValue = null; - - ParameterInfo? associatedParameter = parameterInfoMapper?.Invoke(property); - if (associatedParameter != null) - { - ResolveParameterInfo( - associatedParameter, - propertyTypeInfo, - state.NullabilityInfoContext, - out hasDefaultValue, - out defaultValue, - out bool isNonNullableParameter, - ref isRequired); - - isNonNullableProperty &= isNonNullableParameter; - } - - state.PushSchemaNode(property.Name); - JsonSchema propertySchema = MapJsonSchemaCore( - ref state, - propertyTypeInfo, - parentType: typeInfo.Type, - propertyInfo: property, - parameterInfo: associatedParameter, - propertyAttributeProvider: attributeProvider, - isNonNullableType: isNonNullableProperty, - customConverter: property.CustomConverter, - customNumberHandling: propertyNumberHandling); - - state.PopSchemaNode(); - - if (hasDefaultValue) - { - JsonSchema.EnsureMutable(ref propertySchema); - propertySchema.DefaultValue = defaultValue; - propertySchema.HasDefaultValue = true; - } - - (properties ??= new()).Add(new(property.Name, propertySchema)); - - if (isRequired) - { - (required ??= new()).Add(property.Name); - } - } - - state.PopSchemaNode(); - return CompleteSchema(ref state, new() - { - Type = JsonSchemaType.Object, - Properties = properties, - Required = required, - AdditionalProperties = additionalProperties, - }); - - case JsonTypeInfoKind.Enumerable: - Type elementType = ReflectionHelpers.GetElementType(typeInfo); - JsonTypeInfo elementTypeInfo = typeInfo.Options.GetTypeInfo(elementType); - - if (typeDiscriminator is null) - { - state.PushSchemaNode(JsonSchemaConstants.ItemsPropertyName); - JsonSchema items = MapJsonSchemaCore(ref state, elementTypeInfo, customNumberHandling: effectiveNumberHandling); - state.PopSchemaNode(); - - return CompleteSchema(ref state, new() - { - Type = JsonSchemaType.Array, - Items = items.IsTrue ? null : items, - }); - } - else - { - // Polymorphic enumerable types are represented using a wrapping object: - // { "$type" : "discriminator", "$values" : [element1, element2, ...] } - // Which corresponds to the schema - // { "properties" : { "$type" : { "const" : "discriminator" }, "$values" : { "type" : "array", "items" : { ... } } } } - const string ValuesKeyword = "$values"; - - state.PushSchemaNode(JsonSchemaConstants.PropertiesPropertyName); - state.PushSchemaNode(ValuesKeyword); - state.PushSchemaNode(JsonSchemaConstants.ItemsPropertyName); - - JsonSchema items = MapJsonSchemaCore(ref state, elementTypeInfo, customNumberHandling: effectiveNumberHandling); - - state.PopSchemaNode(); - state.PopSchemaNode(); - state.PopSchemaNode(); - - return CompleteSchema(ref state, new() - { - Type = JsonSchemaType.Object, - Properties = new() - { - typeDiscriminator.Value, - new(ValuesKeyword, - new JsonSchema - { - Type = JsonSchemaType.Array, - Items = items.IsTrue ? null : items, - }), - }, - Required = parentPolymorphicTypeContainsTypesWithoutDiscriminator ? new() { typeDiscriminator.Value.Key } : null, - }); - } - - case JsonTypeInfoKind.Dictionary: - Type valueType = ReflectionHelpers.GetElementType(typeInfo); - JsonTypeInfo valueTypeInfo = typeInfo.Options.GetTypeInfo(valueType); - - List>? dictProps = null; - List? dictRequired = null; - - if (typeDiscriminator is { } dictDiscriminator) - { - dictProps = new() { dictDiscriminator }; - if (parentPolymorphicTypeContainsTypesWithoutDiscriminator) - { - // Require the discriminator here since it's not common to all derived types. - dictRequired = new() { dictDiscriminator.Key }; - } - } - - state.PushSchemaNode(JsonSchemaConstants.AdditionalPropertiesPropertyName); - JsonSchema valueSchema = MapJsonSchemaCore(ref state, valueTypeInfo, customNumberHandling: effectiveNumberHandling); - state.PopSchemaNode(); - - return CompleteSchema(ref state, new() - { - Type = JsonSchemaType.Object, - Properties = dictProps, - Required = dictRequired, - AdditionalProperties = valueSchema.IsTrue ? null : valueSchema, - }); - - default: - Debug.Assert(typeInfo.Kind is JsonTypeInfoKind.None, "The default case should handle unrecognize type kinds."); - - if (_simpleTypeSchemaFactories.TryGetValue(typeInfo.Type, out Func? simpleTypeSchemaFactory)) - { - schema = simpleTypeSchemaFactory(effectiveNumberHandling); - } - else if (typeInfo.Type.IsEnum) - { - schema = GetEnumConverterSchema(typeInfo, effectiveConverter); - } - else - { - schema = JsonSchema.CreateTrueSchema(); - } - - return CompleteSchema(ref state, schema); - } - - JsonSchema CompleteSchema(ref GenerationState state, JsonSchema schema) - { - if (schema.Ref is null) - { - if (IsNullableSchema(ref state)) - { - schema.MakeNullable(); - } - - bool IsNullableSchema(ref GenerationState state) - { - // A schema is marked as nullable if either - // 1. We have a schema for a property where either the getter or setter are marked as nullable. - // 2. We have a schema for a 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) - { - // Prime the schema for invocation by the JsonNode transformer. - schema.GenerationContext = exporterContext; - } - - return schema; - } - } - - private readonly ref struct GenerationState - { - private const int DefaultMaxDepth = 64; - private readonly List _currentPath = new(); - private readonly Dictionary<(JsonTypeInfo, JsonPropertyInfo?), string[]> _generated = new(); - private readonly int _maxDepth; - - public GenerationState(JsonSchemaExporterOptions exporterOptions, JsonSerializerOptions options, NullabilityInfoContext? nullabilityInfoContext = null) - { - ExporterOptions = exporterOptions; - NullabilityInfoContext = nullabilityInfoContext ?? new(); - _maxDepth = options.MaxDepth is 0 ? DefaultMaxDepth : options.MaxDepth; - } - - public JsonSchemaExporterOptions ExporterOptions { get; } - public NullabilityInfoContext NullabilityInfoContext { get; } - public int CurrentDepth => _currentPath.Count; - - public void PushSchemaNode(string nodeId) - { - if (CurrentDepth == _maxDepth) - { - ThrowHelpers.ThrowInvalidOperationException_MaxDepthReached(); - } - - _currentPath.Add(nodeId); - } - - public void PopSchemaNode() - { - _currentPath.RemoveAt(_currentPath.Count - 1); - } - - /// - /// Registers the current schema node generation context; if it has already been generated return a JSON pointer to its location. - /// - public bool TryGetExistingJsonPointer(in JsonSchemaExporterContext context, [NotNullWhen(true)] out string? existingJsonPointer) - { - (JsonTypeInfo, JsonPropertyInfo?) key = (context.TypeInfo, context.PropertyInfo); -#if NET - ref string[]? pathToSchema = ref CollectionsMarshal.GetValueRefOrAddDefault(_generated, key, out bool exists); -#else - bool exists = _generated.TryGetValue(key, out string[]? pathToSchema); -#endif - if (exists) - { - existingJsonPointer = FormatJsonPointer(pathToSchema); - return true; - } -#if NET - pathToSchema = context._path; -#else - _generated[key] = context._path; -#endif - existingJsonPointer = null; - return false; - } - - public JsonSchemaExporterContext CreateContext( - JsonTypeInfo typeInfo, - JsonTypeInfo? baseTypeInfo, - Type? declaringType, - JsonPropertyInfo? propertyInfo, - ParameterInfo? parameterInfo, - ICustomAttributeProvider? propertyAttributeProvider) - { - return new JsonSchemaExporterContext(typeInfo, baseTypeInfo, declaringType, propertyInfo, parameterInfo, propertyAttributeProvider, _currentPath.ToArray()); - } - - private static string FormatJsonPointer(ReadOnlySpan path) - { - if (path.IsEmpty) - { - return "#"; - } - - StringBuilder sb = new(); - _ = sb.Append('#'); - - for (int i = 0; i < path.Length; i++) - { - string segment = path[i]; - if (segment.AsSpan().IndexOfAny('~', '/') != -1) - { -#pragma warning disable CA1307 // Specify StringComparison for clarity - segment = segment.Replace("~", "~0").Replace("/", "~1"); -#pragma warning restore CA1307 - } - - _ = sb.Append('/'); - _ = sb.Append(segment); - } - - return sb.ToString(); - } - } - - private static readonly Dictionary> _simpleTypeSchemaFactories = new() - { - [typeof(object)] = _ => JsonSchema.CreateTrueSchema(), - [typeof(bool)] = _ => new JsonSchema { Type = JsonSchemaType.Boolean }, - [typeof(byte)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), - [typeof(ushort)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), - [typeof(uint)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), - [typeof(ulong)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), - [typeof(sbyte)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), - [typeof(short)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), - [typeof(int)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), - [typeof(long)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), - [typeof(float)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Number, numberHandling, isIeeeFloatingPoint: true), - [typeof(double)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Number, numberHandling, isIeeeFloatingPoint: true), - [typeof(decimal)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Number, numberHandling), -#if NET6_0_OR_GREATER - [typeof(Half)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Number, numberHandling, isIeeeFloatingPoint: true), -#endif -#if NET7_0_OR_GREATER - [typeof(UInt128)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), - [typeof(Int128)] = numberHandling => GetSchemaForNumericType(JsonSchemaType.Integer, numberHandling), -#endif - [typeof(char)] = _ => new JsonSchema { Type = JsonSchemaType.String, MinLength = 1, MaxLength = 1 }, - [typeof(string)] = _ => new JsonSchema { Type = JsonSchemaType.String }, - [typeof(byte[])] = _ => new JsonSchema { Type = JsonSchemaType.String }, - [typeof(Memory)] = _ => new JsonSchema { Type = JsonSchemaType.String }, - [typeof(ReadOnlyMemory)] = _ => new JsonSchema { Type = JsonSchemaType.String }, - [typeof(DateTime)] = _ => new JsonSchema { Type = JsonSchemaType.String, Format = "date-time" }, - [typeof(DateTimeOffset)] = _ => new JsonSchema { Type = JsonSchemaType.String, Format = "date-time" }, - [typeof(TimeSpan)] = _ => new JsonSchema - { - Comment = "Represents a System.TimeSpan value.", - Type = JsonSchemaType.String, - Pattern = @"^-?(\d+\.)?\d{2}:\d{2}:\d{2}(\.\d{1,7})?$", - }, - -#if NET6_0_OR_GREATER - [typeof(DateOnly)] = _ => new JsonSchema { Type = JsonSchemaType.String, Format = "date" }, - [typeof(TimeOnly)] = _ => new JsonSchema { Type = JsonSchemaType.String, Format = "time" }, -#endif - [typeof(Guid)] = _ => new JsonSchema { Type = JsonSchemaType.String, Format = "uuid" }, - [typeof(Uri)] = _ => new JsonSchema { Type = JsonSchemaType.String, Format = "uri" }, - [typeof(Version)] = _ => new JsonSchema - { - Comment = "Represents a version string.", - Type = JsonSchemaType.String, - Pattern = @"^\d+(\.\d+){1,3}$", - }, - - [typeof(JsonDocument)] = _ => JsonSchema.CreateTrueSchema(), - [typeof(JsonElement)] = _ => JsonSchema.CreateTrueSchema(), - [typeof(JsonNode)] = _ => JsonSchema.CreateTrueSchema(), - [typeof(JsonValue)] = _ => JsonSchema.CreateTrueSchema(), - [typeof(JsonObject)] = _ => new JsonSchema { Type = JsonSchemaType.Object }, - [typeof(JsonArray)] = _ => new JsonSchema { Type = JsonSchemaType.Array }, - }; - - // Adapted from https://github.com/dotnet/runtime/blob/release/9.0/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/JsonPrimitiveConverter.cs#L36-L69 - private static JsonSchema GetSchemaForNumericType(JsonSchemaType schemaType, JsonNumberHandling numberHandling, bool isIeeeFloatingPoint = false) - { - Debug.Assert(schemaType is JsonSchemaType.Integer or JsonSchemaType.Number, "schema type must be number or integer"); - Debug.Assert(!isIeeeFloatingPoint || schemaType is JsonSchemaType.Number, "If specifying IEEE the schema type must be number"); - - string? pattern = null; - - if ((numberHandling & (JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)) != 0) - { - if (schemaType is JsonSchemaType.Integer) - { - pattern = @"^-?(?:0|[1-9]\d*)$"; - } - else if (isIeeeFloatingPoint) - { - pattern = @"^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$"; - } - else - { - pattern = @"^-?(?:0|[1-9]\d*)(?:\.\d+)?$"; - } - - schemaType |= JsonSchemaType.String; - } - - if (isIeeeFloatingPoint && (numberHandling & JsonNumberHandling.AllowNamedFloatingPointLiterals) != 0) - { - return new JsonSchema - { - AnyOf = new() - { - new JsonSchema { Type = schemaType, Pattern = pattern }, - new JsonSchema { Enum = new() { (JsonNode)"NaN", (JsonNode)"Infinity", (JsonNode)"-Infinity" } }, - }, - }; - } - - return new JsonSchema { Type = schemaType, Pattern = pattern }; - } - - private static JsonConverter? ExtractCustomNullableConverter(JsonConverter? converter) - { - Debug.Assert(converter is null || ReflectionHelpers.IsBuiltInConverter(converter), "If specified the converter must be built-in."); - - if (converter is null) - { - return null; - } - - return ReflectionHelpers.GetElementConverter(converter); - } - - private static void ValidateOptions(JsonSerializerOptions options) - { - if (options.ReferenceHandler == ReferenceHandler.Preserve) - { - ThrowHelpers.ThrowNotSupportedException_ReferenceHandlerPreserveNotSupported(); - } - - options.MakeReadOnly(); - } - - private static void ResolveParameterInfo( - ParameterInfo parameter, - JsonTypeInfo parameterTypeInfo, - NullabilityInfoContext nullabilityInfoContext, - out bool hasDefaultValue, - out JsonNode? defaultValue, - out bool isNonNullable, - ref bool isRequired) - { - Debug.Assert(parameterTypeInfo.Type == parameter.ParameterType, "The typeInfo type must match the ParameterInfo type."); - - // Incorporate the nullability information from the parameter. - isNonNullable = ReflectionHelpers.GetParameterNullability(nullabilityInfoContext, parameter) is NullabilityState.NotNull; - - if (parameter.HasDefaultValue) - { - // Append the default value to the description. - object? defaultVal = ReflectionHelpers.GetNormalizedDefaultValue(parameter); - defaultValue = JsonSerializer.SerializeToNode(defaultVal, parameterTypeInfo); - hasDefaultValue = true; - } - else - { - // Parameter is not optional, mark as required. - isRequired = true; - defaultValue = null; - hasDefaultValue = false; - } - } - - // Adapted from https://github.com/dotnet/runtime/blob/release/9.0/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/Value/EnumConverter.cs#L498-L521 - private static JsonSchema GetEnumConverterSchema(JsonTypeInfo typeInfo, JsonConverter converter) - { - Debug.Assert(typeInfo.Type.IsEnum && ReflectionHelpers.IsBuiltInConverter(converter), "must be using a built-in enum converter."); - - if (converter is JsonConverterFactory factory) - { - converter = factory.CreateConverter(typeInfo.Type, typeInfo.Options)!; - } - - ReflectionHelpers.GetEnumConverterConfig(converter, out JsonNamingPolicy? namingPolicy, out bool allowString); - - if (allowString) - { - // This explicitly ignores the integer component in converters configured as AllowNumbers | AllowStrings - // which is the default for JsonStringEnumConverter. This sacrifices some precision in the schema for simplicity. - - if (typeInfo.Type.GetCustomAttribute() is not null) - { - // Do not report enum values in case of flags. - return new() { Type = JsonSchemaType.String }; - } - - JsonArray enumValues = new(); - foreach (string name in Enum.GetNames(typeInfo.Type)) - { - // This does not account for custom names specified via the new - // JsonStringEnumMemberNameAttribute introduced in .NET 9. - string effectiveName = namingPolicy?.ConvertName(name) ?? name; - enumValues.Add((JsonNode)effectiveName); - } - - return new() { Enum = enumValues }; - } - - return new() { Type = JsonSchemaType.Integer }; - } - - private static class JsonSchemaConstants - { - public const string SchemaPropertyName = "$schema"; - public const string RefPropertyName = "$ref"; - public const string CommentPropertyName = "$comment"; - public const string TitlePropertyName = "title"; - public const string DescriptionPropertyName = "description"; - public const string TypePropertyName = "type"; - public const string FormatPropertyName = "format"; - public const string PatternPropertyName = "pattern"; - public const string PropertiesPropertyName = "properties"; - public const string RequiredPropertyName = "required"; - public const string ItemsPropertyName = "items"; - public const string AdditionalPropertiesPropertyName = "additionalProperties"; - public const string EnumPropertyName = "enum"; - public const string NotPropertyName = "not"; - public const string AnyOfPropertyName = "anyOf"; - public const string ConstPropertyName = "const"; - public const string DefaultPropertyName = "default"; - public const string MinLengthPropertyName = "minLength"; - public const string MaxLengthPropertyName = "maxLength"; - } - - private static class ThrowHelpers - { - [DoesNotReturn] - public static void ThrowInvalidOperationException_MaxDepthReached() => - throw new InvalidOperationException("The depth of the generated JSON schema exceeds the JsonSerializerOptions.MaxDepth setting."); - - [DoesNotReturn] - public static void ThrowNotSupportedException_ReferenceHandlerPreserveNotSupported() => - throw new NotSupportedException("Schema generation not supported with ReferenceHandler.Preserve enabled."); - } -} -#endif diff --git a/src/Shared/JsonSchemaExporter/JsonSchemaExporterContext.cs b/src/Shared/JsonSchemaExporter/JsonSchemaExporterContext.cs deleted file mode 100644 index 3602ee46df4..00000000000 --- a/src/Shared/JsonSchemaExporter/JsonSchemaExporterContext.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. - -#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 deleted file mode 100644 index 53a269ea612..00000000000 --- a/src/Shared/JsonSchemaExporter/JsonSchemaExporterOptions.cs +++ /dev/null @@ -1,38 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#if !NET9_0_OR_GREATER -using System; -using System.Text.Json.Nodes; - -namespace System.Text.Json.Schema; - -/// -/// Controls the behavior of the class. -/// -#if !SHARED_PROJECT -[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] -#endif -internal sealed class JsonSchemaExporterOptions -{ - /// - /// Gets the default configuration object used by . - /// - public static JsonSchemaExporterOptions Default { get; } = new(); - - /// - /// Gets a value indicating whether non-nullable schemas should be generated for null oblivious reference types. - /// - /// - /// Defaults to . Due to restrictions in the run-time representation of nullable reference types - /// most occurrences are null oblivious and are treated as nullable by the serializer. A notable exception to that rule - /// are nullability annotations of field, property and constructor parameters which are represented in the contract metadata. - /// - public bool TreatNullObliviousAsNonNullable { get; init; } - - /// - /// Gets a callback that is invoked for every schema that is generated within the type graph. - /// - public Func? TransformSchemaNode { get; init; } -} -#endif diff --git a/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfo.cs b/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfo.cs deleted file mode 100644 index bd9b132cd0f..00000000000 --- a/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfo.cs +++ /dev/null @@ -1,75 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#if !NET6_0_OR_GREATER -using System.Diagnostics.CodeAnalysis; - -#pragma warning disable SA1623 // Property summary documentation should match accessors - -namespace System.Reflection -{ - /// - /// A class that represents nullability info. - /// - [ExcludeFromCodeCoverage] - internal sealed class NullabilityInfo - { - internal NullabilityInfo(Type type, NullabilityState readState, NullabilityState writeState, - NullabilityInfo? elementType, NullabilityInfo[] typeArguments) - { - Type = type; - ReadState = readState; - WriteState = writeState; - ElementType = elementType; - GenericTypeArguments = typeArguments; - } - - /// - /// The of the member or generic parameter - /// to which this NullabilityInfo belongs. - /// - public Type Type { get; } - - /// - /// The nullability read state of the member. - /// - public NullabilityState ReadState { get; internal set; } - - /// - /// The nullability write state of the member. - /// - public NullabilityState WriteState { get; internal set; } - - /// - /// If the member type is an array, gives the of the elements of the array, null otherwise. - /// - public NullabilityInfo? ElementType { get; } - - /// - /// If the member type is a generic type, gives the array of for each type parameter. - /// - public NullabilityInfo[] GenericTypeArguments { get; } - } - - /// - /// An enum that represents nullability state. - /// - internal enum NullabilityState - { - /// - /// Nullability context not enabled (oblivious). - /// - Unknown, - - /// - /// Non nullable value or reference type. - /// - NotNull, - - /// - /// Nullable value or reference type. - /// - Nullable, - } -} -#endif diff --git a/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfoContext.cs b/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfoContext.cs deleted file mode 100644 index 3edee1b9cb8..00000000000 --- a/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfoContext.cs +++ /dev/null @@ -1,661 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#if !NET6_0_OR_GREATER -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Linq; - -#pragma warning disable SA1204 // Static elements should appear before instance elements -#pragma warning disable S109 // Magic numbers should not be used -#pragma warning disable S1067 // Expressions should not be too complex -#pragma warning disable S4136 // Method overloads should be grouped together -#pragma warning disable SA1202 // Elements should be ordered by access -#pragma warning disable IDE1006 // Naming Styles - -namespace System.Reflection -{ - /// - /// Provides APIs for populating nullability information/context from reflection members: - /// , , and . - /// - [ExcludeFromCodeCoverage] - internal sealed class NullabilityInfoContext - { - private const string CompilerServicesNameSpace = "System.Runtime.CompilerServices"; - private readonly Dictionary _publicOnlyModules = new(); - private readonly Dictionary _context = new(); - - [Flags] - private enum NotAnnotatedStatus - { - None = 0x0, // no restriction, all members annotated - Private = 0x1, // private members not annotated - Internal = 0x2, // internal members not annotated - } - - private NullabilityState? GetNullableContext(MemberInfo? memberInfo) - { - while (memberInfo != null) - { - if (_context.TryGetValue(memberInfo, out NullabilityState state)) - { - return state; - } - - foreach (CustomAttributeData attribute in memberInfo.GetCustomAttributesData()) - { - if (attribute.AttributeType.Name == "NullableContextAttribute" && - attribute.AttributeType.Namespace == CompilerServicesNameSpace && - attribute.ConstructorArguments.Count == 1) - { - state = TranslateByte(attribute.ConstructorArguments[0].Value); - _context.Add(memberInfo, state); - return state; - } - } - - memberInfo = memberInfo.DeclaringType; - } - - return null; - } - - /// - /// Populates for the given . - /// If the nullablePublicOnly feature is set for an assembly, like it does in .NET SDK, the private and/or internal member's - /// nullability attributes are omitted, in this case the API will return NullabilityState.Unknown state. - /// - /// The parameter which nullability info gets populated. - /// If the parameterInfo parameter is null. - /// . - public NullabilityInfo Create(ParameterInfo parameterInfo) - { - IList attributes = parameterInfo.GetCustomAttributesData(); - NullableAttributeStateParser parser = parameterInfo.Member is MethodBase method && IsPrivateOrInternalMethodAndAnnotationDisabled(method) - ? NullableAttributeStateParser.Unknown - : CreateParser(attributes); - NullabilityInfo nullability = GetNullabilityInfo(parameterInfo.Member, parameterInfo.ParameterType, parser); - - if (nullability.ReadState != NullabilityState.Unknown) - { - CheckParameterMetadataType(parameterInfo, nullability); - } - - CheckNullabilityAttributes(nullability, attributes); - return nullability; - } - - private void CheckParameterMetadataType(ParameterInfo parameter, NullabilityInfo nullability) - { - ParameterInfo? metaParameter; - MemberInfo metaMember; - - switch (parameter.Member) - { - case ConstructorInfo ctor: - var metaCtor = (ConstructorInfo)GetMemberMetadataDefinition(ctor); - metaMember = metaCtor; - metaParameter = GetMetaParameter(metaCtor, parameter); - break; - - case MethodInfo method: - MethodInfo metaMethod = GetMethodMetadataDefinition(method); - metaMember = metaMethod; - metaParameter = string.IsNullOrEmpty(parameter.Name) ? metaMethod.ReturnParameter : GetMetaParameter(metaMethod, parameter); - break; - - default: - return; - } - - if (metaParameter != null) - { - CheckGenericParameters(nullability, metaMember, metaParameter.ParameterType, parameter.Member.ReflectedType); - } - } - - private static ParameterInfo? GetMetaParameter(MethodBase metaMethod, ParameterInfo parameter) - { - var parameters = metaMethod.GetParameters(); - for (int i = 0; i < parameters.Length; i++) - { - if (parameter.Position == i && - parameter.Name == parameters[i].Name) - { - return parameters[i]; - } - } - - return null; - } - - private static MethodInfo GetMethodMetadataDefinition(MethodInfo method) - { - if (method.IsGenericMethod && !method.IsGenericMethodDefinition) - { - method = method.GetGenericMethodDefinition(); - } - - return (MethodInfo)GetMemberMetadataDefinition(method); - } - - private static void CheckNullabilityAttributes(NullabilityInfo nullability, IList attributes) - { - var codeAnalysisReadState = NullabilityState.Unknown; - var codeAnalysisWriteState = NullabilityState.Unknown; - - foreach (CustomAttributeData attribute in attributes) - { - if (attribute.AttributeType.Namespace == "System.Diagnostics.CodeAnalysis") - { - if (attribute.AttributeType.Name == "NotNullAttribute") - { - codeAnalysisReadState = NullabilityState.NotNull; - } - else if ((attribute.AttributeType.Name == "MaybeNullAttribute" || - attribute.AttributeType.Name == "MaybeNullWhenAttribute") && - codeAnalysisReadState == NullabilityState.Unknown && - !IsValueTypeOrValueTypeByRef(nullability.Type)) - { - codeAnalysisReadState = NullabilityState.Nullable; - } - else if (attribute.AttributeType.Name == "DisallowNullAttribute") - { - codeAnalysisWriteState = NullabilityState.NotNull; - } - else if (attribute.AttributeType.Name == "AllowNullAttribute" && - codeAnalysisWriteState == NullabilityState.Unknown && - !IsValueTypeOrValueTypeByRef(nullability.Type)) - { - codeAnalysisWriteState = NullabilityState.Nullable; - } - } - } - - if (codeAnalysisReadState != NullabilityState.Unknown) - { - nullability.ReadState = codeAnalysisReadState; - } - - if (codeAnalysisWriteState != NullabilityState.Unknown) - { - nullability.WriteState = codeAnalysisWriteState; - } - } - - /// - /// Populates for the given . - /// If the nullablePublicOnly feature is set for an assembly, like it does in .NET SDK, the private and/or internal member's - /// nullability attributes are omitted, in this case the API will return NullabilityState.Unknown state. - /// - /// The parameter which nullability info gets populated. - /// If the propertyInfo parameter is null. - /// . - public NullabilityInfo Create(PropertyInfo propertyInfo) - { - MethodInfo? getter = propertyInfo.GetGetMethod(true); - MethodInfo? setter = propertyInfo.GetSetMethod(true); - bool annotationsDisabled = (getter == null || IsPrivateOrInternalMethodAndAnnotationDisabled(getter)) - && (setter == null || IsPrivateOrInternalMethodAndAnnotationDisabled(setter)); - NullableAttributeStateParser parser = annotationsDisabled ? NullableAttributeStateParser.Unknown : CreateParser(propertyInfo.GetCustomAttributesData()); - NullabilityInfo nullability = GetNullabilityInfo(propertyInfo, propertyInfo.PropertyType, parser); - - if (getter != null) - { - CheckNullabilityAttributes(nullability, getter.ReturnParameter.GetCustomAttributesData()); - } - else - { - nullability.ReadState = NullabilityState.Unknown; - } - - if (setter != null) - { - CheckNullabilityAttributes(nullability, setter.GetParameters().Last().GetCustomAttributesData()); - } - else - { - nullability.WriteState = NullabilityState.Unknown; - } - - return nullability; - } - - private bool IsPrivateOrInternalMethodAndAnnotationDisabled(MethodBase method) - { - if ((method.IsPrivate || method.IsFamilyAndAssembly || method.IsAssembly) && - IsPublicOnly(method.IsPrivate, method.IsFamilyAndAssembly, method.IsAssembly, method.Module)) - { - return true; - } - - return false; - } - - /// - /// Populates for the given . - /// If the nullablePublicOnly feature is set for an assembly, like it does in .NET SDK, the private and/or internal member's - /// nullability attributes are omitted, in this case the API will return NullabilityState.Unknown state. - /// - /// The parameter which nullability info gets populated. - /// If the eventInfo parameter is null. - /// . - public NullabilityInfo Create(EventInfo eventInfo) - { - return GetNullabilityInfo(eventInfo, eventInfo.EventHandlerType!, CreateParser(eventInfo.GetCustomAttributesData())); - } - - /// - /// Populates for the given - /// If the nullablePublicOnly feature is set for an assembly, like it does in .NET SDK, the private and/or internal member's - /// nullability attributes are omitted, in this case the API will return NullabilityState.Unknown state. - /// - /// The parameter which nullability info gets populated. - /// If the fieldInfo parameter is null. - /// . - public NullabilityInfo Create(FieldInfo fieldInfo) - { - IList attributes = fieldInfo.GetCustomAttributesData(); - NullableAttributeStateParser parser = IsPrivateOrInternalFieldAndAnnotationDisabled(fieldInfo) ? NullableAttributeStateParser.Unknown : CreateParser(attributes); - NullabilityInfo nullability = GetNullabilityInfo(fieldInfo, fieldInfo.FieldType, parser); - CheckNullabilityAttributes(nullability, attributes); - return nullability; - } - - private bool IsPrivateOrInternalFieldAndAnnotationDisabled(FieldInfo fieldInfo) - { - if ((fieldInfo.IsPrivate || fieldInfo.IsFamilyAndAssembly || fieldInfo.IsAssembly) && - IsPublicOnly(fieldInfo.IsPrivate, fieldInfo.IsFamilyAndAssembly, fieldInfo.IsAssembly, fieldInfo.Module)) - { - return true; - } - - return false; - } - - private bool IsPublicOnly(bool isPrivate, bool isFamilyAndAssembly, bool isAssembly, Module module) - { - if (!_publicOnlyModules.TryGetValue(module, out NotAnnotatedStatus value)) - { - value = PopulateAnnotationInfo(module.GetCustomAttributesData()); - _publicOnlyModules.Add(module, value); - } - - if (value == NotAnnotatedStatus.None) - { - return false; - } - - if (((isPrivate || isFamilyAndAssembly) && value.HasFlag(NotAnnotatedStatus.Private)) || - (isAssembly && value.HasFlag(NotAnnotatedStatus.Internal))) - { - return true; - } - - return false; - } - - private static NotAnnotatedStatus PopulateAnnotationInfo(IList customAttributes) - { - foreach (CustomAttributeData attribute in customAttributes) - { - if (attribute.AttributeType.Name == "NullablePublicOnlyAttribute" && - attribute.AttributeType.Namespace == CompilerServicesNameSpace && - attribute.ConstructorArguments.Count == 1) - { - if (attribute.ConstructorArguments[0].Value is bool boolValue && boolValue) - { - return NotAnnotatedStatus.Internal | NotAnnotatedStatus.Private; - } - else - { - return NotAnnotatedStatus.Private; - } - } - } - - return NotAnnotatedStatus.None; - } - - private NullabilityInfo GetNullabilityInfo(MemberInfo memberInfo, Type type, NullableAttributeStateParser parser) - { - int index = 0; - NullabilityInfo nullability = GetNullabilityInfo(memberInfo, type, parser, ref index); - - if (nullability.ReadState != NullabilityState.Unknown) - { - TryLoadGenericMetaTypeNullability(memberInfo, nullability); - } - - return nullability; - } - - private NullabilityInfo GetNullabilityInfo(MemberInfo memberInfo, Type type, NullableAttributeStateParser parser, ref int index) - { - NullabilityState state = NullabilityState.Unknown; - NullabilityInfo? elementState = null; - NullabilityInfo[] genericArgumentsState = Array.Empty(); - Type underlyingType = type; - - if (underlyingType.IsByRef || underlyingType.IsPointer) - { - underlyingType = underlyingType.GetElementType()!; - } - - if (underlyingType.IsValueType) - { - if (Nullable.GetUnderlyingType(underlyingType) is { } nullableUnderlyingType) - { - underlyingType = nullableUnderlyingType; - state = NullabilityState.Nullable; - } - else - { - state = NullabilityState.NotNull; - } - - if (underlyingType.IsGenericType) - { - ++index; - } - } - else - { - if (!parser.ParseNullableState(index++, ref state) - && GetNullableContext(memberInfo) is { } contextState) - { - state = contextState; - } - - if (underlyingType.IsArray) - { - elementState = GetNullabilityInfo(memberInfo, underlyingType.GetElementType()!, parser, ref index); - } - } - - if (underlyingType.IsGenericType) - { - Type[] genericArguments = underlyingType.GetGenericArguments(); - genericArgumentsState = new NullabilityInfo[genericArguments.Length]; - - for (int i = 0; i < genericArguments.Length; i++) - { - genericArgumentsState[i] = GetNullabilityInfo(memberInfo, genericArguments[i], parser, ref index); - } - } - - return new NullabilityInfo(type, state, state, elementState, genericArgumentsState); - } - - private static NullableAttributeStateParser CreateParser(IList customAttributes) - { - foreach (CustomAttributeData attribute in customAttributes) - { - if (attribute.AttributeType.Name == "NullableAttribute" && - attribute.AttributeType.Namespace == CompilerServicesNameSpace && - attribute.ConstructorArguments.Count == 1) - { - return new NullableAttributeStateParser(attribute.ConstructorArguments[0].Value); - } - } - - return new NullableAttributeStateParser(null); - } - - private void TryLoadGenericMetaTypeNullability(MemberInfo memberInfo, NullabilityInfo nullability) - { - MemberInfo? metaMember = GetMemberMetadataDefinition(memberInfo); - Type? metaType = null; - if (metaMember is FieldInfo field) - { - metaType = field.FieldType; - } - else if (metaMember is PropertyInfo property) - { - metaType = GetPropertyMetaType(property); - } - - if (metaType != null) - { - CheckGenericParameters(nullability, metaMember!, metaType, memberInfo.ReflectedType); - } - } - - private static MemberInfo GetMemberMetadataDefinition(MemberInfo member) - { - Type? type = member.DeclaringType; - if ((type != null) && type.IsGenericType && !type.IsGenericTypeDefinition) - { - return NullabilityInfoHelpers.GetMemberWithSameMetadataDefinitionAs(type.GetGenericTypeDefinition(), member); - } - - return member; - } - - private static Type GetPropertyMetaType(PropertyInfo property) - { - if (property.GetGetMethod(true) is MethodInfo method) - { - return method.ReturnType; - } - - return property.GetSetMethod(true)!.GetParameters()[0].ParameterType; - } - - private void CheckGenericParameters(NullabilityInfo nullability, MemberInfo metaMember, Type metaType, Type? reflectedType) - { - if (metaType.IsGenericParameter) - { - if (nullability.ReadState == NullabilityState.NotNull) - { - _ = TryUpdateGenericParameterNullability(nullability, metaType, reflectedType); - } - } - else if (metaType.ContainsGenericParameters) - { - if (nullability.GenericTypeArguments.Length > 0) - { - Type[] genericArguments = metaType.GetGenericArguments(); - - for (int i = 0; i < genericArguments.Length; i++) - { - CheckGenericParameters(nullability.GenericTypeArguments[i], metaMember, genericArguments[i], reflectedType); - } - } - else if (nullability.ElementType is { } elementNullability && metaType.IsArray) - { - CheckGenericParameters(elementNullability, metaMember, metaType.GetElementType()!, reflectedType); - } - - // We could also follow this branch for metaType.IsPointer, but since pointers must be unmanaged this - // will be a no-op regardless - else if (metaType.IsByRef) - { - CheckGenericParameters(nullability, metaMember, metaType.GetElementType()!, reflectedType); - } - } - } - - private bool TryUpdateGenericParameterNullability(NullabilityInfo nullability, Type genericParameter, Type? reflectedType) - { - Debug.Assert(genericParameter.IsGenericParameter, "must be generic parameter"); - - if (reflectedType is not null - && !genericParameter.IsGenericMethodParameter() - && TryUpdateGenericTypeParameterNullabilityFromReflectedType(nullability, genericParameter, reflectedType, reflectedType)) - { - return true; - } - - if (IsValueTypeOrValueTypeByRef(nullability.Type)) - { - return true; - } - - var state = NullabilityState.Unknown; - if (CreateParser(genericParameter.GetCustomAttributesData()).ParseNullableState(0, ref state)) - { - nullability.ReadState = state; - nullability.WriteState = state; - return true; - } - - if (GetNullableContext(genericParameter) is { } contextState) - { - nullability.ReadState = contextState; - nullability.WriteState = contextState; - return true; - } - - return false; - } - - private bool TryUpdateGenericTypeParameterNullabilityFromReflectedType(NullabilityInfo nullability, Type genericParameter, Type context, Type reflectedType) - { - Debug.Assert(genericParameter.IsGenericParameter && !genericParameter.IsGenericMethodParameter(), "must be generic parameter"); - - Type contextTypeDefinition = context.IsGenericType && !context.IsGenericTypeDefinition ? context.GetGenericTypeDefinition() : context; - if (genericParameter.DeclaringType == contextTypeDefinition) - { - return false; - } - - Type? baseType = contextTypeDefinition.BaseType; - if (baseType is null) - { - return false; - } - - if (!baseType.IsGenericType - || (baseType.IsGenericTypeDefinition ? baseType : baseType.GetGenericTypeDefinition()) != genericParameter.DeclaringType) - { - return TryUpdateGenericTypeParameterNullabilityFromReflectedType(nullability, genericParameter, baseType, reflectedType); - } - - Type[] genericArguments = baseType.GetGenericArguments(); - Type genericArgument = genericArguments[genericParameter.GenericParameterPosition]; - if (genericArgument.IsGenericParameter) - { - return TryUpdateGenericParameterNullability(nullability, genericArgument, reflectedType); - } - - NullableAttributeStateParser parser = CreateParser(contextTypeDefinition.GetCustomAttributesData()); - int nullabilityStateIndex = 1; // start at 1 since index 0 is the type itself - for (int i = 0; i < genericParameter.GenericParameterPosition; i++) - { - nullabilityStateIndex += CountNullabilityStates(genericArguments[i]); - } - - return TryPopulateNullabilityInfo(nullability, parser, ref nullabilityStateIndex); - - static int CountNullabilityStates(Type type) - { - Type underlyingType = Nullable.GetUnderlyingType(type) ?? type; - if (underlyingType.IsGenericType) - { - int count = 1; - foreach (Type genericArgument in underlyingType.GetGenericArguments()) - { - count += CountNullabilityStates(genericArgument); - } - - return count; - } - - if (underlyingType.HasElementType) - { - return (underlyingType.IsArray ? 1 : 0) + CountNullabilityStates(underlyingType.GetElementType()!); - } - - return type.IsValueType ? 0 : 1; - } - } - -#pragma warning disable SA1204 // Static elements should appear before instance elements - private static bool TryPopulateNullabilityInfo(NullabilityInfo nullability, NullableAttributeStateParser parser, ref int index) -#pragma warning restore SA1204 // Static elements should appear before instance elements - { - bool isValueType = IsValueTypeOrValueTypeByRef(nullability.Type); - if (!isValueType) - { - var state = NullabilityState.Unknown; - if (!parser.ParseNullableState(index, ref state)) - { - return false; - } - - nullability.ReadState = state; - nullability.WriteState = state; - } - - if (!isValueType || (Nullable.GetUnderlyingType(nullability.Type) ?? nullability.Type).IsGenericType) - { - index++; - } - - if (nullability.GenericTypeArguments.Length > 0) - { - foreach (NullabilityInfo genericTypeArgumentNullability in nullability.GenericTypeArguments) - { - _ = TryPopulateNullabilityInfo(genericTypeArgumentNullability, parser, ref index); - } - } - else if (nullability.ElementType is { } elementTypeNullability) - { - _ = TryPopulateNullabilityInfo(elementTypeNullability, parser, ref index); - } - - return true; - } - - private static NullabilityState TranslateByte(object? value) - { - return value is byte b ? TranslateByte(b) : NullabilityState.Unknown; - } - - private static NullabilityState TranslateByte(byte b) => - b switch - { - 1 => NullabilityState.NotNull, - 2 => NullabilityState.Nullable, - _ => NullabilityState.Unknown - }; - - private static bool IsValueTypeOrValueTypeByRef(Type type) => - type.IsValueType || ((type.IsByRef || type.IsPointer) && type.GetElementType()!.IsValueType); - - private readonly struct NullableAttributeStateParser - { - private static readonly object UnknownByte = (byte)0; - - private readonly object? _nullableAttributeArgument; - - public NullableAttributeStateParser(object? nullableAttributeArgument) - { - _nullableAttributeArgument = nullableAttributeArgument; - } - - public static NullableAttributeStateParser Unknown => new(UnknownByte); - - public bool ParseNullableState(int index, ref NullabilityState state) - { - switch (_nullableAttributeArgument) - { - case byte b: - state = TranslateByte(b); - return true; - case ReadOnlyCollection args - when index < args.Count && args[index].Value is byte elementB: - state = TranslateByte(elementB); - return true; - default: - return false; - } - } - } - } -} -#endif diff --git a/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfoHelpers.cs b/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfoHelpers.cs deleted file mode 100644 index 1ee573a0020..00000000000 --- a/src/Shared/JsonSchemaExporter/NullabilityInfoContext/NullabilityInfoHelpers.cs +++ /dev/null @@ -1,47 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#if !NET6_0_OR_GREATER -using System.Diagnostics.CodeAnalysis; - -#pragma warning disable IDE1006 // Naming Styles -#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields - -namespace System.Reflection -{ - /// - /// Polyfills for System.Private.CoreLib internals. - /// - [ExcludeFromCodeCoverage] - internal static class NullabilityInfoHelpers - { - public static MemberInfo GetMemberWithSameMetadataDefinitionAs(Type type, MemberInfo member) - { - const BindingFlags all = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance; - foreach (var info in type.GetMembers(all)) - { - if (info.HasSameMetadataDefinitionAs(member)) - { - return info; - } - } - - throw new MissingMemberException(type.FullName, member.Name); - } - - // https://github.com/dotnet/runtime/blob/main/src/coreclr/System.Private.CoreLib/src/System/Reflection/MemberInfo.Internal.cs - public static bool HasSameMetadataDefinitionAs(this MemberInfo target, MemberInfo other) - { - return target.MetadataToken == other.MetadataToken && - target.Module.Equals(other.Module); - } - - // https://github.com/dotnet/runtime/issues/23493 - public static bool IsGenericMethodParameter(this Type target) - { - return target.IsGenericParameter && - target.DeclaringMethod != null; - } - } -} -#endif diff --git a/src/Shared/JsonSchemaExporter/README.md b/src/Shared/JsonSchemaExporter/README.md deleted file mode 100644 index 1a4d13c5841..00000000000 --- a/src/Shared/JsonSchemaExporter/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# JsonSchemaExporter - -Provides a polyfill for the [.NET 9 `JsonSchemaExporter` component](https://learn.microsoft.com/dotnet/standard/serialization/system-text-json/extract-schema) that is compatible with all supported targets using System.Text.Json version 8. - -To use this in your project, add the following to your `.csproj` file: - -```xml - - true - -``` diff --git a/src/Shared/ServerSentEvents/SseItem.cs b/src/Shared/ServerSentEvents/SseItem.cs index 9c6092fd3cf..013d2fe9098 100644 --- a/src/Shared/ServerSentEvents/SseItem.cs +++ b/src/Shared/ServerSentEvents/SseItem.cs @@ -17,12 +17,6 @@ internal readonly struct SseItem [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. @@ -48,7 +42,7 @@ public SseItem(T data, string? eventType = null) /// Thrown when the value contains a line break. public string? EventId { - get => _eventId; + get; init { if (value.AsSpan().ContainsLineBreaks() is true) @@ -56,7 +50,7 @@ public string? EventId ThrowHelper.ThrowArgumentException_CannotContainLineBreaks(nameof(EventId)); } - _eventId = value; + field = value; } } @@ -66,7 +60,7 @@ public string? EventId /// public TimeSpan? ReconnectionInterval { - get => _reconnectionInterval; + get; init { if (value < TimeSpan.Zero) @@ -74,7 +68,7 @@ public TimeSpan? ReconnectionInterval ThrowHelper.ThrowArgumentException_CannotBeNegative(nameof(ReconnectionInterval)); } - _reconnectionInterval = value; + field = value; } } } diff --git a/src/Shared/Shared.csproj b/src/Shared/Shared.csproj index d25c011a05f..62feb6759b6 100644 --- a/src/Shared/Shared.csproj +++ b/src/Shared/Shared.csproj @@ -26,6 +26,11 @@ 85 + + + + + diff --git a/test/Generators/Microsoft.Gen.Logging/Generated/LogMethodTests.cs b/test/Generators/Microsoft.Gen.Logging/Generated/LogMethodTests.cs index 1d74dc68713..12771c42767 100644 --- a/test/Generators/Microsoft.Gen.Logging/Generated/LogMethodTests.cs +++ b/test/Generators/Microsoft.Gen.Logging/Generated/LogMethodTests.cs @@ -580,6 +580,37 @@ public void EventNameTests() Assert.Equal("M1_Event", logRecord.Id.Name); } + [Fact] + public void EventIdTests() + { + using var logger = Utils.GetLogger(); + var collector = logger.FakeLogCollector; + + collector.Clear(); + EventNameTestExtensions.M1(LogLevel.Warning, logger, "Eight"); + Assert.Equal(1, collector.Count); + + var firstEventId = collector.LatestRecord.Id; + Assert.NotEqual(0, firstEventId.Id); + Assert.Equal("M1_Event", firstEventId.Name); + + collector.Clear(); + EventNameTestExtensions.M1_Event(logger, "Nine"); + Assert.Equal(1, collector.Count); + + var secondEventId = collector.LatestRecord.Id; + Assert.Equal(firstEventId.Id, secondEventId.Id); // Same EventName means same generated EventId + Assert.Equal(nameof(EventNameTestExtensions.M1_Event), secondEventId.Name); + + collector.Clear(); + EventNameTestExtensions.M2(logger, "Ten"); + Assert.Equal(1, collector.Count); + + var thirdEventId = collector.LatestRecord.Id; + Assert.NotEqual(thirdEventId.Id, secondEventId.Id); // Different EventName means different generated EventId + Assert.Equal(nameof(EventNameTestExtensions.M2), thirdEventId.Name); + } + [Fact] public void NestedClassTests() { diff --git a/test/Generators/Microsoft.Gen.Logging/Generated/LogPropertiesTests.cs b/test/Generators/Microsoft.Gen.Logging/Generated/LogPropertiesTests.cs index a8c5d752fc9..589f237d2cc 100644 --- a/test/Generators/Microsoft.Gen.Logging/Generated/LogPropertiesTests.cs +++ b/test/Generators/Microsoft.Gen.Logging/Generated/LogPropertiesTests.cs @@ -587,4 +587,26 @@ public void LogPropertiesReadonlyRecordStructArgument() latestRecord.StructuredState.Should().NotBeNull().And.Equal(expectedState); } + + [Fact] + public void LogPropertiesCorrectlyLogsObjectFromAnotherAssembly() + { + LogObjectFromAnotherAssembly(_logger, new ObjectToLog + { + PropertyToIgnore = "Foo", + PropertyToLog = "Bar", + FieldToLog = new FieldToLog { Name = "Fizz", Value = "Buzz" } + }); + + Assert.Equal(1, _logger.Collector.Count); + + var state = _logger.Collector.LatestRecord.StructuredState! + .ToDictionary(p => p.Key, p => p.Value); + + Assert.Equal(4, state.Count); + Assert.Contains("{OriginalFormat}", state); + Assert.Contains("logObject.PropertyToLog", state); + Assert.Contains("logObject.FieldToLog.Name", state); + Assert.Contains("logObject.FieldToLog.Value", state); + } } diff --git a/test/Generators/Microsoft.Gen.Logging/Generated/Microsoft.Gen.Logging.Generated.Tests.csproj b/test/Generators/Microsoft.Gen.Logging/Generated/Microsoft.Gen.Logging.Generated.Tests.csproj index 84621f147e7..d4a72e9e371 100644 --- a/test/Generators/Microsoft.Gen.Logging/Generated/Microsoft.Gen.Logging.Generated.Tests.csproj +++ b/test/Generators/Microsoft.Gen.Logging/Generated/Microsoft.Gen.Logging.Generated.Tests.csproj @@ -24,5 +24,6 @@ + diff --git a/test/Generators/Microsoft.Gen.Logging/HelperLibrary/FieldToLog.cs b/test/Generators/Microsoft.Gen.Logging/HelperLibrary/FieldToLog.cs new file mode 100644 index 00000000000..6eb8cc08d5e --- /dev/null +++ b/test/Generators/Microsoft.Gen.Logging/HelperLibrary/FieldToLog.cs @@ -0,0 +1,10 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Gen.Logging.Test; + +public class FieldToLog +{ + public string? Name { get; set; } + public string? Value { get; set; } +} diff --git a/test/Generators/Microsoft.Gen.Logging/HelperLibrary/Microsoft.Gen.Logging.HelperLibrary.csproj b/test/Generators/Microsoft.Gen.Logging/HelperLibrary/Microsoft.Gen.Logging.HelperLibrary.csproj new file mode 100644 index 00000000000..0f3e6d3bedf --- /dev/null +++ b/test/Generators/Microsoft.Gen.Logging/HelperLibrary/Microsoft.Gen.Logging.HelperLibrary.csproj @@ -0,0 +1,15 @@ + + + Microsoft.Gen.Logging.Test + Test classes for Microsoft.Gen.Logging.Generated.Tests. + + + + $(TestNetCoreTargetFrameworks) + $(TestNetCoreTargetFrameworks)$(ConditionalNet462) + + + + + + diff --git a/test/Generators/Microsoft.Gen.Logging/HelperLibrary/ObjectToLog.cs b/test/Generators/Microsoft.Gen.Logging/HelperLibrary/ObjectToLog.cs new file mode 100644 index 00000000000..c8e071ce6d9 --- /dev/null +++ b/test/Generators/Microsoft.Gen.Logging/HelperLibrary/ObjectToLog.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; + +namespace Microsoft.Gen.Logging.Test; + +public class ObjectToLog +{ + [LogPropertyIgnore] + public string? PropertyToIgnore { get; set; } + + public string? PropertyToLog { get; set; } + + [LogProperties] + public FieldToLog? FieldToLog { get; set; } +} diff --git a/test/Generators/Microsoft.Gen.Logging/TestClasses/EventNameTestExtensions.cs b/test/Generators/Microsoft.Gen.Logging/TestClasses/EventNameTestExtensions.cs index 48385c1fd0a..83c239499e1 100644 --- a/test/Generators/Microsoft.Gen.Logging/TestClasses/EventNameTestExtensions.cs +++ b/test/Generators/Microsoft.Gen.Logging/TestClasses/EventNameTestExtensions.cs @@ -12,5 +12,13 @@ internal static partial class EventNameTestExtensions [LoggerMessage(EventName = "M1_Event")] public static partial void M1(LogLevel level, ILogger logger, string p0); + + // This one should have the same generated EventId as the method above + [LoggerMessage(LogLevel.Debug)] + public static partial void M1_Event(ILogger logger, string p0); + + // This one should have different generated EventId as the methods above + [LoggerMessage(LogLevel.Error)] + public static partial void M2(ILogger logger, string p0); } } diff --git a/test/Generators/Microsoft.Gen.Logging/TestClasses/LogPropertiesExtensions.cs b/test/Generators/Microsoft.Gen.Logging/TestClasses/LogPropertiesExtensions.cs index d2b9c05b05b..3e0e8683ba5 100644 --- a/test/Generators/Microsoft.Gen.Logging/TestClasses/LogPropertiesExtensions.cs +++ b/test/Generators/Microsoft.Gen.Logging/TestClasses/LogPropertiesExtensions.cs @@ -5,6 +5,7 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using Microsoft.Extensions.Logging; +using Microsoft.Gen.Logging.Test; namespace TestClasses { @@ -195,10 +196,10 @@ public MyDerivedClass(double privateFieldValue) internal interface IMyInterface { - public int IntProperty { get; set; } + int IntProperty { get; set; } [LogProperties] - public LeafTransitiveBaseClass? TransitiveProp { get; set; } + LeafTransitiveBaseClass? TransitiveProp { get; set; } } internal sealed class MyInterfaceImpl : IMyInterface @@ -244,5 +245,8 @@ public override string ToString() [LoggerMessage(6, LogLevel.Information, "Testing interface-typed argument here...")] public static partial void LogMethodInterfaceArg(ILogger logger, [LogProperties] IMyInterface complexParam); + + [LoggerMessage(7, LogLevel.Information, "Testing logging a complex object residing in another assembly...")] + public static partial void LogObjectFromAnotherAssembly(ILogger logger, [LogProperties] ObjectToLog logObject); } } diff --git a/test/Generators/Microsoft.Gen.Logging/Unit/EmitterTests.cs b/test/Generators/Microsoft.Gen.Logging/Unit/EmitterTests.cs index 58012c4915b..a53f1711bd3 100644 --- a/test/Generators/Microsoft.Gen.Logging/Unit/EmitterTests.cs +++ b/test/Generators/Microsoft.Gen.Logging/Unit/EmitterTests.cs @@ -45,6 +45,7 @@ public async Task TestEmitter() Assembly.GetAssembly(typeof(IRedactorProvider))!, Assembly.GetAssembly(typeof(PrivateDataAttribute))!, Assembly.GetAssembly(typeof(BigInteger))!, + Assembly.GetAssembly(typeof(ObjectToLog))! }, sources, symbols) diff --git a/test/Generators/Microsoft.Gen.Logging/Unit/Microsoft.Gen.Logging.Unit.Tests.csproj b/test/Generators/Microsoft.Gen.Logging/Unit/Microsoft.Gen.Logging.Unit.Tests.csproj index e566a5dbe9f..9090a895c67 100644 --- a/test/Generators/Microsoft.Gen.Logging/Unit/Microsoft.Gen.Logging.Unit.Tests.csproj +++ b/test/Generators/Microsoft.Gen.Logging/Unit/Microsoft.Gen.Logging.Unit.Tests.csproj @@ -20,6 +20,7 @@ + diff --git a/test/Generators/Microsoft.Gen.Metrics/Unit/ParserTests.cs b/test/Generators/Microsoft.Gen.Metrics/Unit/ParserTests.cs index d6496ac10f3..54a19ea2e86 100644 --- a/test/Generators/Microsoft.Gen.Metrics/Unit/ParserTests.cs +++ b/test/Generators/Microsoft.Gen.Metrics/Unit/ParserTests.cs @@ -81,6 +81,7 @@ class CustomClass {{ }} _ = Assert.Single(d); Assert.Equal(DiagDescriptors.ErrorInvalidMethodReturnType.Id, d[0].Id); + Assert.Contains($"must not return '{returnType}'", d[0].GetMessage()); } [Theory] diff --git a/test/Libraries/Microsoft.AspNetCore.HeaderParsing.Tests/HeaderParsingFeatureTests.cs b/test/Libraries/Microsoft.AspNetCore.HeaderParsing.Tests/HeaderParsingFeatureTests.cs index ce23cb2dfb0..15000ccb21a 100644 --- a/test/Libraries/Microsoft.AspNetCore.HeaderParsing.Tests/HeaderParsingFeatureTests.cs +++ b/test/Libraries/Microsoft.AspNetCore.HeaderParsing.Tests/HeaderParsingFeatureTests.cs @@ -23,13 +23,10 @@ public sealed class HeaderParsingFeatureTests private readonly IOptions _options; private readonly IServiceCollection _services; private readonly FakeLogger _logger = new(); - private IHeaderRegistry? _registry; - private HttpContext? _context; - private IHeaderRegistry Registry => _registry ??= new HeaderRegistry(_services.BuildServiceProvider(), _options); + private IHeaderRegistry Registry => field ??= new HeaderRegistry(_services.BuildServiceProvider(), _options); - private HttpContext Context - => _context ??= new DefaultHttpContext { RequestServices = _services.BuildServiceProvider() }; + private HttpContext Context => field ??= new DefaultHttpContext { RequestServices = _services.BuildServiceProvider() }; public HeaderParsingFeatureTests() { @@ -48,7 +45,7 @@ public void Parses_header() var key = Registry.Register(CommonHeaders.Date); Context.Request.Headers["Date"] = date; - var feature = new HeaderParsingFeature(Registry, _logger, metrics) { Context = Context }; + var feature = new HeaderParsingFeature(_logger, metrics) { Context = Context }; Assert.True(feature.TryGetHeaderValue(key, out var value, out var _)); Assert.Equal(date, value.ToString("R", CultureInfo.InvariantCulture)); @@ -67,7 +64,7 @@ public void Parses_multiple_headers() Context.Request.Headers["Date"] = currentDate; Context.Request.Headers["Test"] = futureDate; - var feature = new HeaderParsingFeature(Registry, _logger, metrics) { Context = Context }; + var feature = new HeaderParsingFeature(_logger, metrics) { Context = Context }; Assert.True(feature.TryGetHeaderValue(key, out var value, out var result)); Assert.Equal(currentDate, value.ToString("R", CultureInfo.InvariantCulture)); @@ -89,7 +86,7 @@ public void Parses_with_late_binding() Context.Request.Headers["Date"] = date; - var feature = new HeaderParsingFeature(Registry, _logger, metrics) { Context = Context }; + var feature = new HeaderParsingFeature(_logger, metrics) { Context = Context }; var key = Registry.Register(CommonHeaders.Date); @@ -103,7 +100,7 @@ public void TryParse_returns_false_on_header_not_found() { using var meter = new Meter(nameof(TryParse_returns_false_on_header_not_found)); var metrics = GetMockedMetrics(meter); - var feature = new HeaderParsingFeature(Registry, _logger, metrics) { Context = Context }; + var feature = new HeaderParsingFeature(_logger, metrics) { Context = Context }; var key = Registry.Register(CommonHeaders.Date); Assert.False(feature.TryGetHeaderValue(key, out var value, out var _)); @@ -120,7 +117,7 @@ public void TryParse_returns_default_on_header_not_found() var date = DateTimeOffset.Now.ToString("R", CultureInfo.InvariantCulture); _options.Value.DefaultValues.Add("Date", date); - var feature = new HeaderParsingFeature(Registry, _logger, metrics) { Context = Context }; + var feature = new HeaderParsingFeature(_logger, metrics) { Context = Context }; var key = Registry.Register(CommonHeaders.Date); Assert.True(feature.TryGetHeaderValue(key, out var value, out var result)); @@ -138,7 +135,7 @@ public void TryParse_returns_false_on_error() using var metricCollector = new MetricCollector(meter, "aspnetcore.header_parsing.parse_errors"); Context.Request.Headers["Date"] = "Not a date."; - var feature = new HeaderParsingFeature(Registry, _logger, metrics) { Context = Context }; + var feature = new HeaderParsingFeature(_logger, metrics) { Context = Context }; var key = Registry.Register(CommonHeaders.Date); Assert.False(feature.TryGetHeaderValue(key, out var value, out var result)); @@ -161,7 +158,7 @@ public void Dispose_resets_state_and_returns_to_pool() var metrics = GetMockedMetrics(meter); var pool = new Mock>(MockBehavior.Strict); - var helper = new HeaderParsingFeature.PoolHelper(pool.Object, Registry, _logger, metrics); + var helper = new HeaderParsingFeature.PoolHelper(pool.Object, _logger, metrics); helper.Feature.Context = Context; pool.Setup(x => x.Return(helper)); @@ -195,8 +192,8 @@ public void CachingWorks() Context.Request.Headers[HeaderNames.CacheControl] = "max-age=604800"; - var feature = new HeaderParsingFeature(Registry, _logger, metrics) { Context = Context }; - var feature2 = new HeaderParsingFeature(Registry, _logger, metrics) { Context = Context }; + var feature = new HeaderParsingFeature(_logger, metrics) { Context = Context }; + var feature2 = new HeaderParsingFeature(_logger, metrics) { Context = Context }; var key = Registry.Register(CommonHeaders.CacheControl); Assert.True(feature.TryGetHeaderValue(key, out var value1, out var error1)); diff --git a/test/Libraries/Microsoft.AspNetCore.HeaderParsing.Tests/ParserTests.cs b/test/Libraries/Microsoft.AspNetCore.HeaderParsing.Tests/ParserTests.cs index 0932aab6ac0..1a826f086c6 100644 --- a/test/Libraries/Microsoft.AspNetCore.HeaderParsing.Tests/ParserTests.cs +++ b/test/Libraries/Microsoft.AspNetCore.HeaderParsing.Tests/ParserTests.cs @@ -146,8 +146,8 @@ public void ContentDisposition_ReturnsParsedValue() { var sv = new StringValues("attachment; filename=\"cool.html\""); Assert.True(ContentDispositionHeaderValueParser.Instance.TryParse(sv, out var result, out var error)); - Assert.Equal("cool.html", result.FileName); - Assert.Equal("attachment", result.DispositionType); + Assert.Equal("cool.html", result.FileName.ToString()); + Assert.Equal("attachment", result.DispositionType.ToString()); Assert.Null(error); } @@ -174,8 +174,8 @@ public void MediaType_ReturnsParsedValue() { var sv = new StringValues("text/html; charset=UTF-8"); Assert.True(MediaTypeHeaderValueParser.Instance.TryParse(sv, out var result, out var error)); - Assert.Equal("text/html", result.MediaType); - Assert.Equal("UTF-8", result.Charset); + Assert.Equal("text/html", result.MediaType.ToString()); + Assert.Equal("UTF-8", result.Charset.ToString()); Assert.Null(error); } @@ -203,8 +203,8 @@ public void MediaTypes_ReturnsParsedValue() var sv = new StringValues("text/html; charset=UTF-8"); Assert.True(MediaTypeHeaderValueListParser.Instance.TryParse(sv, out var result, out var error)); Assert.Single(result); - Assert.Equal("text/html", result[0].MediaType); - Assert.Equal("UTF-8", result[0].Charset); + Assert.Equal("text/html", result[0].MediaType.ToString()); + Assert.Equal("UTF-8", result[0].Charset.ToString()); Assert.Null(error); } @@ -223,7 +223,7 @@ public void EntityTag_ReturnsParsedValue() var sv = new StringValues("\"HelloWorld\""); Assert.True(EntityTagHeaderValueListParser.Instance.TryParse(sv, out var result, out var error)); Assert.Single(result!); - Assert.Equal("\"HelloWorld\"", result[0].Tag); + Assert.Equal("\"HelloWorld\"", result[0].Tag.ToString()); Assert.Null(error); } @@ -242,7 +242,7 @@ public void StringQuality_ReturnsParsedValue() var sv = new StringValues("en-US"); Assert.True(StringWithQualityHeaderValueListParser.Instance.TryParse(sv, out var result, out var error)); Assert.Single(result!); - Assert.Equal("en-US", result[0].Value); + Assert.Equal("en-US", result[0].Value.ToString()); Assert.Null(error); } @@ -252,8 +252,8 @@ public void StringQuality_Multi() var sv = new StringValues("en-US,en;q=0.5"); Assert.True(StringWithQualityHeaderValueListParser.Instance.TryParse(sv, out var result, out var error)); Assert.Equal(2, result.Count); - Assert.Equal("en-US", result[0].Value); - Assert.Equal("en", result[1].Value); + Assert.Equal("en-US", result[0].Value.ToString()); + Assert.Equal("en", result[1].Value.ToString()); Assert.Equal(0.5, result[1].Quality); Assert.Null(error); } @@ -300,7 +300,7 @@ public void Range_ReturnsParsedValue() { var sv = new StringValues("bytes=200-1000"); Assert.True(RangeHeaderValueParser.Instance.TryParse(sv, out var result, out var error)); - Assert.Equal("bytes", result!.Unit); + Assert.Equal("bytes", result!.Unit.ToString()); Assert.Single(result.Ranges); Assert.Equal(200, result.Ranges.Single().From); Assert.Equal(1000, result.Ranges.Single().To); @@ -341,7 +341,7 @@ public void RangeCondition_ReturnsParsedValue() sv = new StringValues("\"67ab43\""); Assert.True(RangeConditionHeaderValueParser.Instance.TryParse(sv, out result, out error)); - Assert.Equal("\"67ab43\"", result!.EntityTag!.Tag); + Assert.Equal("\"67ab43\"", result!.EntityTag!.Tag.ToString()); Assert.Null(error); } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AIToolTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AIToolTests.cs deleted file mode 100644 index 5e092d107ec..00000000000 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AIToolTests.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Xunit; - -namespace Microsoft.Extensions.AI; - -public class AIToolTests -{ - [Fact] - public void Constructor_Roundtrips() - { - DerivedAITool tool = new(); - Assert.Equal(nameof(DerivedAITool), tool.Name); - Assert.Equal(nameof(DerivedAITool), tool.ToString()); - Assert.Empty(tool.Description); - Assert.Empty(tool.AdditionalProperties); - } - - private sealed class DerivedAITool : AITool; -} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AdditionalPropertiesDictionaryTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AdditionalPropertiesDictionaryTests.cs index 09f515fa066..0635d45250c 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AdditionalPropertiesDictionaryTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AdditionalPropertiesDictionaryTests.cs @@ -12,7 +12,7 @@ public class AdditionalPropertiesDictionaryTests [Fact] public void Constructor_Roundtrips() { - AdditionalPropertiesDictionary d = new(); + AdditionalPropertiesDictionary d = []; Assert.Empty(d); d = new(new Dictionary { ["key1"] = "value1" }); diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AssertExtensions.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AssertExtensions.cs index 72985108c6e..546b93a7f20 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AssertExtensions.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AssertExtensions.cs @@ -12,6 +12,44 @@ namespace Microsoft.Extensions.AI; internal static class AssertExtensions { + /// + /// Asserts that the two message lists are equal. + /// + public static void EqualMessageLists(List expectedMessages, List actualMessages) + { + Assert.Equal(expectedMessages.Count, actualMessages.Count); + for (int i = 0; i < expectedMessages.Count; i++) + { + var expectedMessage = expectedMessages[i]; + var chatMessage = actualMessages[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); + } + } + } + } + /// /// Asserts that the two function call parameters are equal, up to JSON equivalence. /// @@ -53,21 +91,29 @@ public static void EqualFunctionCallParameters( public static void EqualFunctionCallResults(object? expected, object? actual, JsonSerializerOptions? options = null) => AreJsonEquivalentValues(expected, actual, options); - private static void AreJsonEquivalentValues(object? expected, object? actual, JsonSerializerOptions? options, string? propertyName = null) + /// + /// Asserts that the two JSON values are equal. + /// + public static void EqualJsonValues(JsonElement expectedJson, JsonElement actualJson, string? propertyName = null) { - options ??= AIJsonUtilities.DefaultOptions; - JsonElement expectedElement = NormalizeToElement(expected, options); - JsonElement actualElement = NormalizeToElement(actual, options); if (!JsonNode.DeepEquals( - JsonSerializer.SerializeToNode(expectedElement, AIJsonUtilities.DefaultOptions), - JsonSerializer.SerializeToNode(actualElement, AIJsonUtilities.DefaultOptions))) + JsonSerializer.SerializeToNode(expectedJson, AIJsonUtilities.DefaultOptions), + JsonSerializer.SerializeToNode(actualJson, AIJsonUtilities.DefaultOptions))) { string message = propertyName is null - ? $"Function result does not match expected JSON.\r\nExpected: {expectedElement.GetRawText()}\r\nActual: {actualElement.GetRawText()}" - : $"Parameter '{propertyName}' does not match expected JSON.\r\nExpected: {expectedElement.GetRawText()}\r\nActual: {actualElement.GetRawText()}"; + ? $"JSON result does not match expected JSON.\r\nExpected: {expectedJson.GetRawText()}\r\nActual: {actualJson.GetRawText()}" + : $"Parameter '{propertyName}' does not match expected JSON.\r\nExpected: {expectedJson.GetRawText()}\r\nActual: {actualJson.GetRawText()}"; throw new XunitException(message); } + } + + private static void AreJsonEquivalentValues(object? expected, object? actual, JsonSerializerOptions? options, string? propertyName = null) + { + options ??= AIJsonUtilities.DefaultOptions; + JsonElement expectedElement = NormalizeToElement(expected, options); + JsonElement actualElement = NormalizeToElement(actual, options); + EqualJsonValues(expectedElement, actualElement, propertyName); static JsonElement NormalizeToElement(object? value, JsonSerializerOptions options) => value is JsonElement e ? e : JsonSerializer.SerializeToElement(value, options); diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatClientExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatClientExtensionsTests.cs index c74c50813f4..d5a474f0309 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatClientExtensionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatClientExtensionsTests.cs @@ -158,6 +158,92 @@ public async Task GetStreamingResponseAsync_CreatesTextMessageAsync() Assert.Equal(1, count); } + [Fact] + public async Task GetResponseAsync_UsesProvidedContinuationToken() + { + var expectedResponse = new ChatResponse(); + var expectedContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3, 4 }); + var expectedChatOptions = new ChatOptions + { + ContinuationToken = expectedContinuationToken, + AllowBackgroundResponses = true, + AdditionalProperties = new AdditionalPropertiesDictionary // Setting this to ensure cloning is happening + { + { "key", "value" }, + }, + }; + + using var cts = new CancellationTokenSource(); + + using TestChatClient client = new() + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + Assert.Empty(messages); + Assert.NotNull(options); + + Assert.True(options.AdditionalProperties!.ContainsKey("key")); // Assert that chat options were cloned + + Assert.Same(expectedChatOptions, options); + Assert.Same(expectedContinuationToken, options.ContinuationToken); + Assert.Equal(expectedChatOptions.AllowBackgroundResponses, options.AllowBackgroundResponses); + + Assert.Equal(cts.Token, cancellationToken); + + return Task.FromResult(expectedResponse); + }, + }; + + ChatResponse response = await client.GetResponseAsync([], expectedChatOptions, cts.Token); + + Assert.Same(expectedResponse, response); + } + + [Fact] + public async Task GetStreamingResponseAsync_UsesProvidedContinuationToken() + { + var expectedOptions = new ChatOptions(); + var expectedContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3, 4 }); + var expectedChatOptions = new ChatOptions + { + ContinuationToken = expectedContinuationToken, + AllowBackgroundResponses = true, + AdditionalProperties = new AdditionalPropertiesDictionary // Setting this to ensure cloning is happening + { + { "key", "value" }, + }, + }; + using var cts = new CancellationTokenSource(); + + using TestChatClient client = new() + { + GetStreamingResponseAsyncCallback = (messages, options, cancellationToken) => + { + Assert.Empty(messages); + Assert.NotNull(options); + + Assert.True(options.AdditionalProperties!.ContainsKey("key")); // Assert that chat options were cloned + + Assert.Same(expectedChatOptions, options); + Assert.Same(expectedContinuationToken, options.ContinuationToken); + Assert.Equal(expectedChatOptions.AllowBackgroundResponses, options.AllowBackgroundResponses); + + Assert.Equal(cts.Token, cancellationToken); + + return YieldAsync([new ChatResponseUpdate(ChatRole.Assistant, "world")]); + }, + }; + + int count = 0; + await foreach (var update in client.GetStreamingResponseAsync([], expectedChatOptions, cts.Token)) + { + Assert.Equal(0, count); + count++; + } + + Assert.Equal(1, count); + } + private static async IAsyncEnumerable YieldAsync(params ChatResponseUpdate[] updates) { await Task.Yield(); diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatMessageTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatMessageTests.cs index c449f064255..378ff03af69 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatMessageTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatMessageTests.cs @@ -6,7 +6,6 @@ using System.Linq; using System.Text.Json; using Xunit; -using static System.Net.Mime.MediaTypeNames; namespace Microsoft.Extensions.AI; @@ -18,6 +17,7 @@ public void Constructor_Parameterless_PropsDefaulted() ChatMessage message = new(); Assert.Null(message.AuthorName); Assert.Empty(message.Contents); + Assert.Null(message.CreatedAt); Assert.Equal(ChatRole.User, message.Role); Assert.Empty(message.Text); Assert.NotNull(message.Contents); @@ -50,6 +50,7 @@ public void Constructor_RoleString_PropsRoundtrip(string? text) } Assert.Null(message.AuthorName); + Assert.Null(message.CreatedAt); Assert.Null(message.RawRepresentation); Assert.Null(message.AdditionalProperties); Assert.Equal(text ?? string.Empty, message.ToString()); @@ -113,6 +114,7 @@ public void Constructor_RoleList_PropsRoundtrip(int messageCount) } Assert.Null(message.AuthorName); + Assert.Null(message.CreatedAt); Assert.Null(message.RawRepresentation); Assert.Null(message.AdditionalProperties); } @@ -230,6 +232,20 @@ public void AdditionalProperties_Roundtrips() Assert.Same(props, message.AdditionalProperties); } + [Fact] + public void CreatedAt_Roundtrips() + { + ChatMessage message = new(); + Assert.Null(message.CreatedAt); + + DateTimeOffset now = DateTimeOffset.Now; + message.CreatedAt = now; + Assert.Equal(now, message.CreatedAt); + + message.CreatedAt = null; + Assert.Null(message.CreatedAt); + } + [Fact] public void ItCanBeSerializeAndDeserialized() { diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatOptionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatOptionsTests.cs index b7645c26245..e6d863220e1 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatOptionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatOptionsTests.cs @@ -50,6 +50,8 @@ public void Constructor_Parameterless_PropsDefaulted() Assert.Null(clone.Tools); Assert.Null(clone.AdditionalProperties); Assert.Null(clone.RawRepresentationFactory); + Assert.Null(clone.ContinuationToken); + Assert.Null(clone.AllowBackgroundResponses); } [Fact] @@ -76,6 +78,8 @@ public void Properties_Roundtrip() Func rawRepresentationFactory = (c) => null; + ResponseContinuationToken continuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3, 4 }); + options.ConversationId = "12345"; options.Instructions = "Some instructions"; options.Temperature = 0.1f; @@ -93,6 +97,8 @@ public void Properties_Roundtrip() options.Tools = tools; options.RawRepresentationFactory = rawRepresentationFactory; options.AdditionalProperties = additionalProps; + options.ContinuationToken = continuationToken; + options.AllowBackgroundResponses = true; Assert.Equal("12345", options.ConversationId); Assert.Equal("Some instructions", options.Instructions); @@ -111,6 +117,8 @@ public void Properties_Roundtrip() Assert.Same(tools, options.Tools); Assert.Same(rawRepresentationFactory, options.RawRepresentationFactory); Assert.Same(additionalProps, options.AdditionalProperties); + Assert.Same(continuationToken, options.ContinuationToken); + Assert.True(options.AllowBackgroundResponses); ChatOptions clone = options.Clone(); Assert.Equal("12345", clone.ConversationId); @@ -129,6 +137,8 @@ public void Properties_Roundtrip() Assert.Equal(tools, clone.Tools); Assert.Same(rawRepresentationFactory, clone.RawRepresentationFactory); Assert.Equal(additionalProps, clone.AdditionalProperties); + Assert.Same(continuationToken, clone.ContinuationToken); + Assert.True(clone.AllowBackgroundResponses); } [Fact] @@ -147,6 +157,8 @@ public void JsonSerialization_Roundtrips() ["key"] = "value", }; + ResponseContinuationToken continuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3, 4 }); + options.ConversationId = "12345"; options.Instructions = "Some instructions"; options.Temperature = 0.1f; @@ -168,6 +180,8 @@ public void JsonSerialization_Roundtrips() ]; options.RawRepresentationFactory = (c) => null; options.AdditionalProperties = additionalProps; + options.ContinuationToken = continuationToken; + options.AllowBackgroundResponses = true; string json = JsonSerializer.Serialize(options, TestJsonSerializerContext.Default.ChatOptions); @@ -198,4 +212,70 @@ public void JsonSerialization_Roundtrips() Assert.IsType(value); Assert.Equal("value", ((JsonElement)value!).GetString()); } + + [Fact] + public void CopyConstructors_EnableHierarchyCloning() + { + OptionsB b = new() + { + ModelId = "test", + A = 42, + B = 84, + }; + + ChatOptions clone = b.Clone(); + + Assert.Equal("test", clone.ModelId); + Assert.Equal(42, Assert.IsType(clone, exactMatch: false).A); + Assert.Equal(84, Assert.IsType(clone, exactMatch: true).B); + } + + private class OptionsA : ChatOptions + { + public OptionsA() + { + } + + protected OptionsA(OptionsA other) + : base(other) + { + A = other.A; + } + + public int A { get; set; } + + public override ChatOptions Clone() => new OptionsA(this); + } + + private class OptionsB : OptionsA + { + public OptionsB() + { + } + + protected OptionsB(OptionsB other) + : base(other) + { + B = other.B; + } + + public int B { get; set; } + + public override ChatOptions Clone() => new OptionsB(this); + } + + [Fact] + public void CopyConstructor_Null_Valid() + { + PassedNullToBaseOptions options = new(); + Assert.NotNull(options); + } + + private class PassedNullToBaseOptions : ChatOptions + { + public PassedNullToBaseOptions() + : base(null) + { + } + } } 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 c65bef12fc8..420871ca9e6 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseFormatTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseFormatTests.cs @@ -2,9 +2,14 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; using System.Text.Json; using Xunit; +#pragma warning disable SA1204 // Static elements should appear before instance elements + namespace Microsoft.Extensions.AI; public class ChatResponseFormatTests @@ -81,4 +86,131 @@ public void Serialization_ForJsonSchemaRoundtrips() Assert.Equal("name", actual.SchemaName); Assert.Equal("description", actual.SchemaDescription); } + + [Fact] + public void ForJsonSchema_NullType_Throws() + { + Assert.Throws("schemaType", () => ChatResponseFormat.ForJsonSchema(null!)); + Assert.Throws("schemaType", () => ChatResponseFormat.ForJsonSchema(null!, TestJsonSerializerContext.Default.Options)); + Assert.Throws("schemaType", () => ChatResponseFormat.ForJsonSchema(null!, TestJsonSerializerContext.Default.Options, "name")); + Assert.Throws("schemaType", () => ChatResponseFormat.ForJsonSchema(null!, TestJsonSerializerContext.Default.Options, "name", "description")); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ForJsonSchema_PrimitiveType_Succeeds(bool generic) + { + ChatResponseFormatJson format = generic ? + ChatResponseFormat.ForJsonSchema() : + ChatResponseFormat.ForJsonSchema(typeof(int)); + + Assert.NotNull(format); + Assert.NotNull(format.Schema); + Assert.Equal("""{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"integer"}""", format.Schema.ToString()); + Assert.Equal("Int32", format.SchemaName); + Assert.Null(format.SchemaDescription); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ForJsonSchema_IncludedType_Succeeds(bool generic) + { + ChatResponseFormatJson format = generic ? + ChatResponseFormat.ForJsonSchema() : + ChatResponseFormat.ForJsonSchema(typeof(DataContent)); + + Assert.NotNull(format); + Assert.NotNull(format.Schema); + Assert.Contains("\"uri\"", format.Schema.ToString()); + Assert.Equal("DataContent", format.SchemaName); + Assert.Null(format.SchemaDescription); + } + + public static IEnumerable ForJsonSchema_ComplexType_Succeeds_MemberData() => + from generic in new[] { false, true } + from name in new string?[] { null, "CustomName" } + from description in new string?[] { null, "CustomDescription" } + select new object?[] { generic, name, description }; + + [Theory] + [MemberData(nameof(ForJsonSchema_ComplexType_Succeeds_MemberData))] + public void ForJsonSchema_ComplexType_Succeeds(bool generic, string? name, string? description) + { + ChatResponseFormatJson format = generic ? + ChatResponseFormat.ForJsonSchema(TestJsonSerializerContext.Default.Options, name, description) : + ChatResponseFormat.ForJsonSchema(typeof(SomeType), TestJsonSerializerContext.Default.Options, name, description); + + Assert.NotNull(format); + Assert.Equal( + """ + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "abcd", + "type": "object", + "properties": { + "someInteger": { + "description": "efg", + "type": "integer" + }, + "someString": { + "description": "hijk", + "type": [ + "string", + "null" + ] + } + } + } + """, + JsonSerializer.Serialize(format.Schema, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonElement)))); + Assert.Equal(name ?? "SomeType", format.SchemaName); + Assert.Equal(description ?? "abcd", format.SchemaDescription); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ForJsonSchema_DisplayNameAttribute_UsedForSchemaName(bool generic) + { + ChatResponseFormatJson format = generic ? + ChatResponseFormat.ForJsonSchema(TestJsonSerializerContext.Default.Options) : + ChatResponseFormat.ForJsonSchema(typeof(TypeWithDisplayName), TestJsonSerializerContext.Default.Options); + + Assert.NotNull(format); + Assert.NotNull(format.Schema); + Assert.Equal("custom_type_name", format.SchemaName); + Assert.Equal("Type description", format.SchemaDescription); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ForJsonSchema_DisplayNameAttribute_CanBeOverridden(bool generic) + { + ChatResponseFormatJson format = generic ? + ChatResponseFormat.ForJsonSchema(TestJsonSerializerContext.Default.Options, schemaName: "override_name") : + ChatResponseFormat.ForJsonSchema(typeof(TypeWithDisplayName), TestJsonSerializerContext.Default.Options, schemaName: "override_name"); + + Assert.NotNull(format); + Assert.Equal("override_name", format.SchemaName); + } + + [Description("abcd")] + public class SomeType + { + [Description("efg")] + public int SomeInteger { get; set; } + + [Description("hijk")] + public string? SomeString { get; set; } + } + + [DisplayName("custom_type_name")] + [Description("Type description")] + public class TypeWithDisplayName + { + public int Value { get; set; } + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseTests.cs index 802b414437d..de5809d3d97 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseTests.cs @@ -125,7 +125,7 @@ public void ToString_OutputsText() } [Fact] - public void ToChatResponseUpdates() + public void ToChatResponseUpdates_SingleMessage() { ChatResponse response = new(new ChatMessage(new ChatRole("customRole"), "Text") { MessageId = "someMessage" }) { @@ -153,4 +153,55 @@ public void ToChatResponseUpdates() Assert.Equal("value1", update1.AdditionalProperties?["key1"]); Assert.Equal(42, update1.AdditionalProperties?["key2"]); } + + [Fact] + public void ToChatResponseUpdates_MultipleMessages() + { + ChatResponse response = new( + [ + new ChatMessage(new ChatRole("customRole"), "Text") + { + CreatedAt = new DateTimeOffset(2024, 11, 10, 9, 20, 0, TimeSpan.Zero), + MessageId = "someMessage" + }, + new ChatMessage(new ChatRole("secondRole"), "Another message") + { + CreatedAt = new DateTimeOffset(2025, 1, 1, 10, 30, 0, TimeSpan.Zero), + MessageId = "anotherMessage" + } + ]) + { + ResponseId = "12345", + ModelId = "someModel", + FinishReason = ChatFinishReason.ContentFilter, + CreatedAt = new DateTimeOffset(2024, 11, 10, 9, 20, 0, TimeSpan.Zero), + AdditionalProperties = new() { ["key1"] = "value1", ["key2"] = 42 }, + }; + + ChatResponseUpdate[] updates = response.ToChatResponseUpdates(); + Assert.NotNull(updates); + Assert.Equal(3, updates.Length); + + ChatResponseUpdate update0 = updates[0]; + Assert.Equal("12345", update0.ResponseId); + Assert.Equal("someMessage", update0.MessageId); + Assert.Equal("someModel", update0.ModelId); + Assert.Equal(ChatFinishReason.ContentFilter, update0.FinishReason); + Assert.Equal(new DateTimeOffset(2024, 11, 10, 9, 20, 0, TimeSpan.Zero), update0.CreatedAt); + Assert.Equal("customRole", update0.Role?.Value); + Assert.Equal("Text", update0.Text); + + ChatResponseUpdate update1 = updates[1]; + Assert.Equal("12345", update1.ResponseId); + Assert.Equal("anotherMessage", update1.MessageId); + Assert.Equal("someModel", update1.ModelId); + Assert.Equal(ChatFinishReason.ContentFilter, update1.FinishReason); + Assert.Equal(new DateTimeOffset(2025, 1, 1, 10, 30, 0, TimeSpan.Zero), update1.CreatedAt); + Assert.Equal("secondRole", update1.Role?.Value); + Assert.Equal("Another message", update1.Text); + + ChatResponseUpdate update2 = updates[2]; + Assert.Equal("value1", update2.AdditionalProperties?["key1"]); + Assert.Equal(42, update2.AdditionalProperties?["key2"]); + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateExtensionsTests.cs index 8b13d640ae1..09f3600e522 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateExtensionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateExtensionsTests.cs @@ -4,12 +4,12 @@ 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 +#pragma warning disable MEAI0001 // Suppress experimental warnings for testing namespace Microsoft.Extensions.AI; @@ -29,7 +29,7 @@ public async Task ToChatResponse_SuccessfullyCreatesResponse(bool useAsync) ChatResponseUpdate[] updates = [ new(ChatRole.Assistant, "Hello") { ResponseId = "someResponse", MessageId = "12345", CreatedAt = new DateTimeOffset(1, 2, 3, 4, 5, 6, TimeSpan.Zero), ModelId = "model123" }, - new(new("human"), ", ") { AuthorName = "Someone", AdditionalProperties = new() { ["a"] = "b" } }, + new(ChatRole.Assistant, ", ") { AuthorName = "Someone", AdditionalProperties = new() { ["a"] = "b" } }, new(null, "world!") { CreatedAt = new DateTimeOffset(2, 2, 3, 4, 5, 6, TimeSpan.Zero), ConversationId = "123", AdditionalProperties = new() { ["c"] = "d" } }, new() { Contents = [new UsageContent(new() { InputTokenCount = 1, OutputTokenCount = 2 })] }, @@ -37,8 +37,8 @@ public async Task ToChatResponse_SuccessfullyCreatesResponse(bool useAsync) ]; ChatResponse response = useAsync ? - updates.ToChatResponse() : - await YieldAsync(updates).ToChatResponseAsync(); + await YieldAsync(updates).ToChatResponseAsync() : + updates.ToChatResponse(); Assert.NotNull(response); Assert.NotNull(response.Usage); @@ -53,7 +53,7 @@ public async Task ToChatResponse_SuccessfullyCreatesResponse(bool useAsync) ChatMessage message = response.Messages.Single(); Assert.Equal("12345", message.MessageId); - Assert.Equal(new ChatRole("human"), message.Role); + Assert.Equal(ChatRole.Assistant, message.Role); Assert.Equal("Someone", message.AuthorName); Assert.Null(message.AdditionalProperties); @@ -65,6 +65,455 @@ public async Task ToChatResponse_SuccessfullyCreatesResponse(bool useAsync) Assert.Equal("Hello, world!", response.Text); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ToChatResponse_RoleOrIdOrAuthorNameChangeDictatesMessageChange(bool useAsync) + { + ChatResponseUpdate[] updates = + [ + new(null, "!") { MessageId = "1" }, + new(ChatRole.Assistant, "a") { MessageId = "1" }, + new(ChatRole.Assistant, "b") { MessageId = "2" }, + new(ChatRole.User, "c") { MessageId = "2" }, + new(ChatRole.User, "d") { MessageId = "2" }, + new(ChatRole.Assistant, "e") { MessageId = "3" }, + new(ChatRole.Tool, "f") { MessageId = "4" }, + new(ChatRole.Tool, "g") { MessageId = "4" }, + new(ChatRole.Tool, "h") { MessageId = "5" }, + new(new("human"), "i") { MessageId = "6" }, + new(new("human"), "j") { MessageId = "7" }, + new(new("human"), "k") { MessageId = "7" }, + new(null, "l") { MessageId = "7" }, + new(null, "m") { MessageId = "8" }, + ]; + + ChatResponse response = useAsync ? + await YieldAsync(updates).ToChatResponseAsync() : + updates.ToChatResponse(); + Assert.Equal(9, response.Messages.Count); + + Assert.Equal("!a", response.Messages[0].Text); + Assert.Equal(ChatRole.Assistant, response.Messages[0].Role); + + Assert.Equal("b", response.Messages[1].Text); + Assert.Equal(ChatRole.Assistant, response.Messages[1].Role); + + Assert.Equal("cd", response.Messages[2].Text); + Assert.Equal(ChatRole.User, response.Messages[2].Role); + + Assert.Equal("e", response.Messages[3].Text); + Assert.Equal(ChatRole.Assistant, response.Messages[3].Role); + + Assert.Equal("fg", response.Messages[4].Text); + Assert.Equal(ChatRole.Tool, response.Messages[4].Role); + + Assert.Equal("h", response.Messages[5].Text); + Assert.Equal(ChatRole.Tool, response.Messages[5].Role); + + Assert.Equal("i", response.Messages[6].Text); + Assert.Equal(new ChatRole("human"), response.Messages[6].Role); + + Assert.Equal("jkl", response.Messages[7].Text); + Assert.Equal(new ChatRole("human"), response.Messages[7].Role); + + Assert.Equal("m", response.Messages[8].Text); + Assert.Equal(ChatRole.Assistant, response.Messages[8].Role); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ToChatResponse_AuthorNameChangeDictatesMessageBoundary(bool useAsync) + { + ChatResponseUpdate[] updates = + [ + + // First message with AuthorName "Alice" + new(ChatRole.Assistant, "Hello ") { AuthorName = "Alice" }, + new(null, "from ") { AuthorName = "Alice" }, + new(null, "Alice!"), + + // Second message - AuthorName changes to "Bob" + new(null, "Hi ") { AuthorName = "Bob" }, + new(null, "from ") { AuthorName = "Bob" }, + new(null, "Bob!"), + + // Third message - AuthorName changes to "Charlie" + new(ChatRole.Assistant, "Greetings ") { AuthorName = "Charlie" }, + new(null, "from Charlie!") { AuthorName = "Charlie" }, + + // Fourth message - AuthorName changes back to "Alice" + new(null, "Alice again!") { AuthorName = "Alice" }, + + // Fifth message - empty/null AuthorName should continue with last message + new(null, " Still Alice.") { AuthorName = "" }, + new(null, " And more."), + ]; + + ChatResponse response = useAsync ? + await YieldAsync(updates).ToChatResponseAsync() : + updates.ToChatResponse(); + + Assert.Equal(4, response.Messages.Count); + + Assert.Equal("Hello from Alice!", response.Messages[0].Text); + Assert.Equal("Alice", response.Messages[0].AuthorName); + Assert.Equal(ChatRole.Assistant, response.Messages[0].Role); + + Assert.Equal("Hi from Bob!", response.Messages[1].Text); + Assert.Equal("Bob", response.Messages[1].AuthorName); + Assert.Equal(ChatRole.Assistant, response.Messages[1].Role); + + Assert.Equal("Greetings from Charlie!", response.Messages[2].Text); + Assert.Equal("Charlie", response.Messages[2].AuthorName); + Assert.Equal(ChatRole.Assistant, response.Messages[2].Role); + + Assert.Equal("Alice again! Still Alice. And more.", response.Messages[3].Text); + Assert.Equal("Alice", response.Messages[3].AuthorName); + Assert.Equal(ChatRole.Assistant, response.Messages[3].Role); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ToChatResponse_AuthorNameWithOtherBoundaries(bool useAsync) + { + ChatResponseUpdate[] updates = + [ + + // Message 1: Role=Assistant, MessageId="1", AuthorName="Alice" + new(ChatRole.Assistant, "A") { MessageId = "1", AuthorName = "Alice" }, + new(null, "B") { MessageId = "1", AuthorName = "Alice" }, + + // Message 2: AuthorName changes to "Bob", same MessageId and Role + new(null, "C") { MessageId = "1", AuthorName = "Bob" }, + + // Message 3: MessageId changes to "2", AuthorName stays "Bob" + new(null, "D") { MessageId = "2", AuthorName = "Bob" }, + new(null, "E") { MessageId = "2", AuthorName = "Bob" }, + + // Message 4: Role changes to User, AuthorName stays "Bob" + new(ChatRole.User, "F") { MessageId = "2", AuthorName = "Bob" }, + + // Message 5: All three boundaries change + new(ChatRole.Tool, "G") { MessageId = "3", AuthorName = "Charlie" }, + new(null, "H") { MessageId = "3", AuthorName = "Charlie" }, + ]; + + ChatResponse response = useAsync ? + await YieldAsync(updates).ToChatResponseAsync() : + updates.ToChatResponse(); + + Assert.Equal(5, response.Messages.Count); + + Assert.Equal("AB", response.Messages[0].Text); + Assert.Equal("Alice", response.Messages[0].AuthorName); + Assert.Equal(ChatRole.Assistant, response.Messages[0].Role); + Assert.Equal("1", response.Messages[0].MessageId); + + Assert.Equal("C", response.Messages[1].Text); + Assert.Equal("Bob", response.Messages[1].AuthorName); + Assert.Equal(ChatRole.Assistant, response.Messages[1].Role); + Assert.Equal("1", response.Messages[1].MessageId); + + Assert.Equal("DE", response.Messages[2].Text); + Assert.Equal("Bob", response.Messages[2].AuthorName); + Assert.Equal(ChatRole.Assistant, response.Messages[2].Role); + Assert.Equal("2", response.Messages[2].MessageId); + + Assert.Equal("F", response.Messages[3].Text); + Assert.Equal("Bob", response.Messages[3].AuthorName); + Assert.Equal(ChatRole.User, response.Messages[3].Role); + Assert.Equal("2", response.Messages[3].MessageId); + + Assert.Equal("GH", response.Messages[4].Text); + Assert.Equal("Charlie", response.Messages[4].AuthorName); + Assert.Equal(ChatRole.Tool, response.Messages[4].Role); + Assert.Equal("3", response.Messages[4].MessageId); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ToChatResponse_EmptyOrNullAuthorNameDoesNotCreateBoundary(bool useAsync) + { + ChatResponseUpdate[] updates = + [ + + // First message with AuthorName "Assistant" + new(ChatRole.Assistant, "Hello") { AuthorName = "Assistant" }, + + // Empty AuthorName should not create new message + new(null, " world") { AuthorName = "" }, + + // Null AuthorName should not create new message + new(null, "!"), + + // Another empty AuthorName + new(null, " How") { AuthorName = "" }, + new(null, " are") { AuthorName = "" }, + + // Null again + new(null, " you?") { AuthorName = null }, + ]; + + ChatResponse response = useAsync ? + await YieldAsync(updates).ToChatResponseAsync() : + updates.ToChatResponse(); + + ChatMessage message = Assert.Single(response.Messages); + Assert.Equal("Hello world! How are you?", message.Text); + Assert.Equal("Assistant", message.AuthorName); + Assert.Equal(ChatRole.Assistant, message.Role); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ToChatResponse_AuthorNameNullToNonNullDoesNotCreateBoundary(bool useAsync) + { + ChatResponseUpdate[] updates = + [ + + // First message with no AuthorName + new(ChatRole.Assistant, "Hello") { MessageId = "1" }, + new(null, " there") { MessageId = "1" }, + + // AuthorName becomes non-empty but doesn't create boundary + new(null, " I'm Bob") { MessageId = "1", AuthorName = "Bob" }, + new(null, " speaking") { MessageId = "1", AuthorName = "Bob" }, + + // Second message - AuthorName changes to "Alice" creates boundary + new(null, "Now Alice") { MessageId = "1", AuthorName = "Alice" }, + ]; + + ChatResponse response = useAsync ? + await YieldAsync(updates).ToChatResponseAsync() : + updates.ToChatResponse(); + + Assert.Equal(2, response.Messages.Count); + + Assert.Equal("Hello there I'm Bob speaking", response.Messages[0].Text); + Assert.Equal("Bob", response.Messages[0].AuthorName); // Last AuthorName wins + Assert.Equal("1", response.Messages[0].MessageId); + + Assert.Equal("Now Alice", response.Messages[1].Text); + Assert.Equal("Alice", response.Messages[1].AuthorName); + Assert.Equal("1", response.Messages[1].MessageId); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ToChatResponse_MessageIdNullToNonNullDoesNotCreateBoundary(bool useAsync) + { + ChatResponseUpdate[] updates = + [ + + // First message with no MessageId + new(ChatRole.Assistant, "Hello"), + new(null, " there"), + + // MessageId becomes non-empty but doesn't create boundary + new(null, " from") { MessageId = "msg1" }, + new(null, " AI") { MessageId = "msg1" }, + + // Second message - MessageId changes to different value creates boundary + new(null, "Next message") { MessageId = "msg2" }, + ]; + + ChatResponse response = useAsync ? + await YieldAsync(updates).ToChatResponseAsync() : + updates.ToChatResponse(); + + Assert.Equal(2, response.Messages.Count); + + Assert.Equal("Hello there from AI", response.Messages[0].Text); + Assert.Equal("msg1", response.Messages[0].MessageId); // Last MessageId wins + Assert.Equal(ChatRole.Assistant, response.Messages[0].Role); + + Assert.Equal("Next message", response.Messages[1].Text); + Assert.Equal("msg2", response.Messages[1].MessageId); + Assert.Equal(ChatRole.Assistant, response.Messages[1].Role); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ToChatResponse_EmptyMessageIdDoesNotCreateBoundary(bool useAsync) + { + ChatResponseUpdate[] updates = + [ + + // First message with MessageId + new(ChatRole.Assistant, "Hello") { MessageId = "msg1" }, + new(null, " world") { MessageId = "msg1" }, + + // Empty MessageId should not create new message + new(null, "!") { MessageId = "" }, + + // Null MessageId should not create new message + new(null, " How"), + + // Another message with empty MessageId + new(null, " are") { MessageId = "" }, + new(null, " you?"), + ]; + + ChatResponse response = useAsync ? + await YieldAsync(updates).ToChatResponseAsync() : + updates.ToChatResponse(); + + ChatMessage message = Assert.Single(response.Messages); + Assert.Equal("Hello world! How are you?", message.Text); + Assert.Equal("msg1", message.MessageId); + Assert.Equal(ChatRole.Assistant, message.Role); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ToChatResponse_RoleNullToNonNullDoesNotCreateBoundary(bool useAsync) + { + ChatResponseUpdate[] updates = + [ + + // First message with no explicit Role (will default to Assistant) + new(null, "Hello") { MessageId = "1" }, + new(null, " there") { MessageId = "1" }, + + // Role becomes explicit Assistant - shouldn't create boundary + new(ChatRole.Assistant, " from") { MessageId = "1" }, + new(null, " AI") { MessageId = "1" }, + + // Second message - Role changes to User creates boundary + new(ChatRole.User, "User message") { MessageId = "1" }, + ]; + + ChatResponse response = useAsync ? + await YieldAsync(updates).ToChatResponseAsync() : + updates.ToChatResponse(); + + Assert.Equal(2, response.Messages.Count); + + Assert.Equal("Hello there from AI", response.Messages[0].Text); + Assert.Equal(ChatRole.Assistant, response.Messages[0].Role); + Assert.Equal("1", response.Messages[0].MessageId); + + Assert.Equal("User message", response.Messages[1].Text); + Assert.Equal(ChatRole.User, response.Messages[1].Role); + Assert.Equal("1", response.Messages[1].MessageId); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ToChatResponse_CustomRoleChangesCreateBoundary(bool useAsync) + { + ChatResponseUpdate[] updates = + [ + + // First message with custom role "agent1" + new(new ChatRole("agent1"), "Hello") { MessageId = "1" }, + new(null, " from") { MessageId = "1" }, + new(new ChatRole("agent1"), " agent1") { MessageId = "1" }, + + // Second message - custom role changes to "agent2" + new(new ChatRole("agent2"), "Hi") { MessageId = "1" }, + new(null, " from") { MessageId = "1" }, + new(new ChatRole("agent2"), " agent2") { MessageId = "1" }, + + // Third message - changes to standard role + new(ChatRole.Assistant, "Assistant here") { MessageId = "1" }, + ]; + + ChatResponse response = useAsync ? + await YieldAsync(updates).ToChatResponseAsync() : + updates.ToChatResponse(); + + Assert.Equal(3, response.Messages.Count); + + Assert.Equal("Hello from agent1", response.Messages[0].Text); + Assert.Equal(new ChatRole("agent1"), response.Messages[0].Role); + + Assert.Equal("Hi from agent2", response.Messages[1].Text); + Assert.Equal(new ChatRole("agent2"), response.Messages[1].Role); + + Assert.Equal("Assistant here", response.Messages[2].Text); + Assert.Equal(ChatRole.Assistant, response.Messages[2].Role); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ToChatResponse_UpdatesProduceMultipleResponseMessages(bool useAsync) + { + ChatResponseUpdate[] updates = + [ + + // First message - ID "msg1", AuthorName "Assistant" + new(null, "Hi! ") { CreatedAt = new DateTimeOffset(2023, 1, 1, 10, 0, 0, TimeSpan.Zero), AuthorName = "Assistant" }, + new(ChatRole.Assistant, "Hello") { MessageId = "msg1", CreatedAt = new DateTimeOffset(2024, 1, 1, 10, 0, 0, TimeSpan.Zero), AuthorName = "Assistant" }, + new(null, " from") { MessageId = "msg1", CreatedAt = new DateTimeOffset(2024, 1, 1, 10, 1, 0, TimeSpan.Zero) }, // Later CreatedAt should win + new(null, " AI") { MessageId = "msg1", AuthorName = "Assistant" }, // Keep same AuthorName to avoid creating new message + + // Second message - ID "msg1" changes to "msg2", still AuthorName "Assistant" + new(null, "More text") { MessageId = "msg2", CreatedAt = new DateTimeOffset(2024, 1, 1, 10, 2, 0, TimeSpan.Zero), AuthorName = "Assistant" }, + + // Third message - ID "msg3", Role changes to User + new(ChatRole.User, "How") { MessageId = "msg3", CreatedAt = new DateTimeOffset(2024, 1, 1, 11, 0, 0, TimeSpan.Zero), AuthorName = "User" }, + new(null, " are") { MessageId = "msg3", CreatedAt = new DateTimeOffset(2024, 1, 1, 11, 1, 0, TimeSpan.Zero) }, + new(null, " you?") { MessageId = "msg3", AuthorName = "User" }, // Keep same AuthorName + + // Fourth message - ID "msg4", Role changes back to Assistant + new(ChatRole.Assistant, "I'm doing well,") { MessageId = "msg4", CreatedAt = new DateTimeOffset(2024, 1, 1, 12, 0, 0, TimeSpan.Zero) }, + new(null, " thank you!") { MessageId = "msg4", CreatedAt = new DateTimeOffset(2024, 1, 1, 12, 2, 0, TimeSpan.Zero) }, // Later CreatedAt should win + + // Updates without MessageId should continue the last message (msg4) + new(null, " How can I help?"), + ]; + + ChatResponse response = useAsync ? + await YieldAsync(updates).ToChatResponseAsync() : + updates.ToChatResponse(); + + Assert.NotNull(response); + Assert.Equal(4, response.Messages.Count); + + // Verify first message + ChatMessage message1 = response.Messages[0]; + Assert.Equal("msg1", message1.MessageId); + Assert.Equal(ChatRole.Assistant, message1.Role); + Assert.Equal("Assistant", message1.AuthorName); + Assert.Equal(new DateTimeOffset(2024, 1, 1, 10, 1, 0, TimeSpan.Zero), message1.CreatedAt); // Last value should win + Assert.Equal("Hi! Hello from AI", message1.Text); + + // Verify second message + ChatMessage message2 = response.Messages[1]; + Assert.Equal("msg2", message2.MessageId); + Assert.Equal(ChatRole.Assistant, message2.Role); + Assert.Equal("Assistant", message2.AuthorName); + Assert.Equal(new DateTimeOffset(2024, 1, 1, 10, 2, 0, TimeSpan.Zero), message2.CreatedAt); + Assert.Equal("More text", message2.Text); + + // Verify third message + ChatMessage message3 = response.Messages[2]; + Assert.Equal("msg3", message3.MessageId); + Assert.Equal(ChatRole.User, message3.Role); + Assert.Equal("User", message3.AuthorName); + Assert.Equal(new DateTimeOffset(2024, 1, 1, 11, 1, 0, TimeSpan.Zero), message3.CreatedAt); // Last value should win + Assert.Equal("How are you?", message3.Text); + + // Verify fourth message + ChatMessage message4 = response.Messages[3]; + Assert.Equal("msg4", message4.MessageId); + Assert.Equal(ChatRole.Assistant, message4.Role); + Assert.Null(message4.AuthorName); // No AuthorName set + Assert.Equal(new DateTimeOffset(2024, 1, 1, 12, 2, 0, TimeSpan.Zero), message4.CreatedAt); // Last value should win + Assert.Equal("I'm doing well, thank you! How can I help?", message4.Text); + } + public static IEnumerable ToChatResponse_Coalescing_VariousSequenceAndGapLengths_MemberData() { foreach (bool useAsync in new[] { false, true }) @@ -183,6 +632,85 @@ public async Task ToChatResponse_CoalescesTextContentAndTextReasoningContentSepa Assert.Equal("OP", Assert.IsType(message.Contents[7]).Text); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ToChatResponse_CoalescesTextReasoningContentUpToProtectedData(bool useAsync) + { + ChatResponseUpdate[] updates = + { + new() { Contents = [new TextReasoningContent("A") { ProtectedData = "1" }] }, + new() { Contents = [new TextReasoningContent("B") { ProtectedData = "2" }] }, + new() { Contents = [new TextReasoningContent("C")] }, + new() { Contents = [new TextReasoningContent("D")] }, + new() { Contents = [new TextReasoningContent("E") { ProtectedData = "3" }] }, + new() { Contents = [new TextReasoningContent("F") { ProtectedData = "4" }] }, + new() { Contents = [new TextReasoningContent("G")] }, + new() { Contents = [new TextReasoningContent("H")] }, + }; + + ChatResponse response = useAsync ? await YieldAsync(updates).ToChatResponseAsync() : updates.ToChatResponse(); + ChatMessage message = Assert.Single(response.Messages); + Assert.Equal(5, message.Contents.Count); + + Assert.Equal("A", Assert.IsType(message.Contents[0]).Text); + Assert.Equal("1", ((TextReasoningContent)message.Contents[0]).ProtectedData); + + Assert.Equal("B", Assert.IsType(message.Contents[1]).Text); + Assert.Equal("2", ((TextReasoningContent)message.Contents[1]).ProtectedData); + + Assert.Equal("CDE", Assert.IsType(message.Contents[2]).Text); + Assert.Equal("3", ((TextReasoningContent)message.Contents[2]).ProtectedData); + + Assert.Equal("F", Assert.IsType(message.Contents[3]).Text); + Assert.Equal("4", ((TextReasoningContent)message.Contents[3]).ProtectedData); + + Assert.Equal("GH", Assert.IsType(message.Contents[4]).Text); + Assert.Null(((TextReasoningContent)message.Contents[4]).ProtectedData); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ToChatResponse_DoesNotCoalesceAnnotatedContent(bool useAsync) + { + ChatResponseUpdate[] updates = + { + new(null, "A"), + new(null, "B"), + new(null, "C"), + new() { Contents = [new TextContent("D") { Annotations = [new()] }] }, + new() { Contents = [new TextContent("E") { Annotations = [new()] }] }, + new() { Contents = [new TextContent("F") { Annotations = [new()] }] }, + new() { Contents = [new TextContent("G") { Annotations = [] }] }, + new() { Contents = [new TextContent("H") { Annotations = [] }] }, + new() { Contents = [new TextContent("I") { Annotations = [new()] }] }, + new() { Contents = [new TextContent("J") { Annotations = [new()] }] }, + new(null, "K"), + new() { Contents = [new TextContent("L") { Annotations = [new()] }] }, + new(null, "M"), + new(null, "N"), + new() { Contents = [new TextContent("O") { Annotations = [new()] }] }, + new() { Contents = [new TextContent("P") { Annotations = [new()] }] }, + }; + + ChatResponse response = useAsync ? await YieldAsync(updates).ToChatResponseAsync() : updates.ToChatResponse(); + ChatMessage message = Assert.Single(response.Messages); + Assert.Equal(12, message.Contents.Count); + Assert.Equal("ABC", Assert.IsType(message.Contents[0]).Text); + Assert.Equal("D", Assert.IsType(message.Contents[1]).Text); + Assert.Equal("E", Assert.IsType(message.Contents[2]).Text); + Assert.Equal("F", Assert.IsType(message.Contents[3]).Text); + Assert.Equal("GH", Assert.IsType(message.Contents[4]).Text); + Assert.Equal("I", Assert.IsType(message.Contents[5]).Text); + Assert.Equal("J", Assert.IsType(message.Contents[6]).Text); + Assert.Equal("K", Assert.IsType(message.Contents[7]).Text); + Assert.Equal("L", Assert.IsType(message.Contents[8]).Text); + Assert.Equal("MN", Assert.IsType(message.Contents[9]).Text); + Assert.Equal("O", Assert.IsType(message.Contents[10]).Text); + Assert.Equal("P", Assert.IsType(message.Contents[11]).Text); + } + [Fact] public async Task ToChatResponse_UsageContentExtractedFromContents() { @@ -203,6 +731,191 @@ public async Task ToChatResponse_UsageContentExtractedFromContents() Assert.Equal("Hello, world!", Assert.IsType(Assert.Single(Assert.Single(response.Messages).Contents)).Text); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ToChatResponse_AlternativeTimestamps(bool useAsync) + { + DateTimeOffset early = new(2024, 1, 1, 10, 0, 0, TimeSpan.Zero); + DateTimeOffset middle = new(2024, 1, 1, 11, 0, 0, TimeSpan.Zero); + DateTimeOffset late = new(2024, 1, 1, 12, 0, 0, TimeSpan.Zero); + DateTimeOffset unixEpoch = new(1970, 1, 1, 0, 0, 0, TimeSpan.Zero); + + ChatResponseUpdate[] updates = + [ + + // Start with an early timestamp + new(ChatRole.Tool, "a") { MessageId = "4", CreatedAt = early }, + + // Unix epoch (as "null") should not overwrite + new(null, "b") { CreatedAt = unixEpoch }, + + // Newer timestamp should overwrite + new(null, "c") { CreatedAt = middle }, + + // Older timestamp should not overwrite + new(null, "d") { CreatedAt = early }, + + // Even newer timestamp should overwrite + new(null, "e") { CreatedAt = late }, + + // Unix epoch should not overwrite again + new(null, "f") { CreatedAt = unixEpoch }, + + // null should not overwrite + new(null, "g") { CreatedAt = null }, + ]; + + ChatResponse response = useAsync ? + await YieldAsync(updates).ToChatResponseAsync() : + updates.ToChatResponse(); + Assert.Single(response.Messages); + + Assert.Equal("abcdefg", response.Messages[0].Text); + Assert.Equal(ChatRole.Tool, response.Messages[0].Role); + Assert.Equal(late, response.Messages[0].CreatedAt); + Assert.Equal(late, response.CreatedAt); + } + + public static IEnumerable ToChatResponse_TimestampFolding_MemberData() + { + // Base test cases + var testCases = new (string? timestamp1, string? timestamp2, string? expectedTimestamp)[] + { + (null, null, null), + ("2024-01-01T10:00:00Z", null, "2024-01-01T10:00:00Z"), + (null, "2024-01-01T10:00:00Z", "2024-01-01T10:00:00Z"), + ("2024-01-01T10:00:00Z", "2024-01-01T11:00:00Z", "2024-01-01T11:00:00Z"), + ("2024-01-01T11:00:00Z", "2024-01-01T10:00:00Z", "2024-01-01T11:00:00Z"), + ("2024-01-01T10:00:00Z", "1970-01-01T00:00:00Z", "2024-01-01T10:00:00Z"), + ("1970-01-01T00:00:00Z", "2024-01-01T10:00:00Z", "2024-01-01T10:00:00Z"), + }; + + // Yield each test case twice, once for useAsync = false and once for useAsync = true + foreach (var (timestamp1, timestamp2, expectedTimestamp) in testCases) + { + yield return new object?[] { false, timestamp1, timestamp2, expectedTimestamp }; + yield return new object?[] { true, timestamp1, timestamp2, expectedTimestamp }; + } + } + + [Theory] + [MemberData(nameof(ToChatResponse_TimestampFolding_MemberData))] + public async Task ToChatResponse_TimestampFolding(bool useAsync, string? timestamp1, string? timestamp2, string? expectedTimestamp) + { + DateTimeOffset? first = timestamp1 is not null ? DateTimeOffset.Parse(timestamp1) : null; + DateTimeOffset? second = timestamp2 is not null ? DateTimeOffset.Parse(timestamp2) : null; + DateTimeOffset? expected = expectedTimestamp is not null ? DateTimeOffset.Parse(expectedTimestamp) : null; + + ChatResponseUpdate[] updates = + [ + new(ChatRole.Assistant, "a") { CreatedAt = first }, + new(null, "b") { CreatedAt = second }, + ]; + + ChatResponse response = useAsync ? + await YieldAsync(updates).ToChatResponseAsync() : + updates.ToChatResponse(); + + Assert.Single(response.Messages); + Assert.Equal("ab", response.Messages[0].Text); + Assert.Equal(expected, response.Messages[0].CreatedAt); + Assert.Equal(expected, response.CreatedAt); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ToChatResponse_CoalescesImageGenerationToolResultContent(bool useAsync) + { + // Create test image content with actual byte arrays + var image1 = new DataContent((byte[])[1, 2, 3, 4], "image/png") { Name = "image1.png" }; + var image2 = new DataContent((byte[])[5, 6, 7, 8], "image/jpeg") { Name = "image2.jpg" }; + var image3 = new DataContent((byte[])[9, 10, 11, 12], "image/png") { Name = "image3.png" }; + var image4 = new DataContent((byte[])[13, 14, 15, 16], "image/gif") { Name = "image4.gif" }; + + ChatResponseUpdate[] updates = + { + new(null, "Let's generate"), + new(null, " some images"), + + // Initial ImageGenerationToolResultContent with ID "img1" + new() { Contents = [new ImageGenerationToolResultContent { ImageId = "img1", Outputs = [image1] }] }, + + // Another ImageGenerationToolResultContent with different ID "img2" + new() { Contents = [new ImageGenerationToolResultContent { ImageId = "img2", Outputs = [image2] }] }, + + // Another ImageGenerationToolResultContent with same ID "img1" - should replace the first one + new() { Contents = [new ImageGenerationToolResultContent { ImageId = "img1", Outputs = [image3] }] }, + + // ImageGenerationToolResultContent with same ID "img2" - should replace the second one + new() { Contents = [new ImageGenerationToolResultContent { ImageId = "img2", Outputs = [image4] }] }, + + // Final text + new(null, "Here are those generated images"), + }; + + ChatResponse response = useAsync ? await YieldAsync(updates).ToChatResponseAsync() : updates.ToChatResponse(); + ChatMessage message = Assert.Single(response.Messages); + + // Should have 4 content items: 1 text (coalesced) + 2 image results (coalesced) + 1 text + Assert.Equal(4, message.Contents.Count); + + // Verify text content was coalesced properly + Assert.Equal("Let's generate some images", + Assert.IsType(message.Contents[0]).Text); + + // Get the image result contents + var imageResults = message.Contents.OfType().ToArray(); + Assert.Equal(2, imageResults.Length); + + // Verify the first image result (ID "img1") has the latest content (image3) + var firstImageResult = imageResults.First(ir => ir.ImageId == "img1"); + Assert.NotNull(firstImageResult.Outputs); + var firstOutput = Assert.Single(firstImageResult.Outputs); + Assert.Same(image3, firstOutput); // Should be the later image, not image1 + + // Verify the second image result (ID "img2") has the latest content (image4) + var secondImageResult = imageResults.First(ir => ir.ImageId == "img2"); + Assert.NotNull(secondImageResult.Outputs); + var secondOutput = Assert.Single(secondImageResult.Outputs); + Assert.Same(image4, secondOutput); // Should be the later image, not image2 + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ToChatResponse_ImageGenerationToolResultContentWithNullOrEmptyImageId_DoesNotCoalesce(bool useAsync) + { + var image1 = new DataContent((byte[])[1, 2, 3, 4], "image/png") { Name = "image1.png" }; + var image2 = new DataContent((byte[])[5, 6, 7, 8], "image/jpeg") { Name = "image2.jpg" }; + var image3 = new DataContent((byte[])[9, 10, 11, 12], "image/png") { Name = "image3.png" }; + + ChatResponseUpdate[] updates = + { + // ImageGenerationToolResultContent with null ImageId - should not coalesce + new() { Contents = [new ImageGenerationToolResultContent { ImageId = null, Outputs = [image1] }] }, + + // ImageGenerationToolResultContent with empty ImageId - should not coalesce + new() { Contents = [new ImageGenerationToolResultContent { ImageId = "", Outputs = [image2] }] }, + + // Another with null ImageId - should not coalesce with the first + new() { Contents = [new ImageGenerationToolResultContent { ImageId = null, Outputs = [image3] }] }, + }; + + ChatResponse response = useAsync ? await YieldAsync(updates).ToChatResponseAsync() : updates.ToChatResponse(); + ChatMessage message = Assert.Single(response.Messages); + + // Should have all 3 image result contents since they can't be coalesced + var imageResults = message.Contents.OfType().ToArray(); + Assert.Equal(3, imageResults.Length); + + // Verify each has its original content + Assert.Same(image1, imageResults[0].Outputs![0]); + Assert.Same(image2, imageResults[1].Outputs![0]); + Assert.Same(image3, imageResults[2].Outputs![0]); + } + private static async IAsyncEnumerable YieldAsync(IEnumerable updates) { foreach (ChatResponseUpdate update in updates) diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateTests.cs index 413215d9a44..9727a58ac47 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ChatCompletion/ChatResponseUpdateTests.cs @@ -167,4 +167,165 @@ public void JsonSerialization_Roundtrips() Assert.IsType(value); Assert.Equal("value", ((JsonElement)value!).GetString()); } + + [Fact] + public void Clone_CreatesShallowCopy() + { + // Arrange + var originalAdditionalProperties = new AdditionalPropertiesDictionary { ["key"] = "value" }; + var originalContents = new List { new TextContent("text1"), new TextContent("text2") }; + var originalRawRepresentation = new object(); + var originalCreatedAt = new DateTimeOffset(2022, 1, 1, 0, 0, 0, TimeSpan.Zero); + + var original = new ChatResponseUpdate + { + AdditionalProperties = originalAdditionalProperties, + AuthorName = "author", + Contents = originalContents, + CreatedAt = originalCreatedAt, + ConversationId = "conv123", + FinishReason = ChatFinishReason.ContentFilter, + MessageId = "msg456", + ModelId = "model789", + RawRepresentation = originalRawRepresentation, + ResponseId = "resp012", + Role = ChatRole.Assistant, + }; + + // Act + var clone = original.Clone(); + + // Assert - Different instances + Assert.NotSame(original, clone); + + // Assert - All properties copied correctly + Assert.Equal(original.AuthorName, clone.AuthorName); + Assert.Equal(original.Role, clone.Role); + Assert.Equal(original.CreatedAt, clone.CreatedAt); + Assert.Equal(original.ConversationId, clone.ConversationId); + Assert.Equal(original.FinishReason, clone.FinishReason); + Assert.Equal(original.MessageId, clone.MessageId); + Assert.Equal(original.ModelId, clone.ModelId); + Assert.Equal(original.ResponseId, clone.ResponseId); + + // Assert - Reference properties are shallow copied (same references) + Assert.Same(original.AdditionalProperties, clone.AdditionalProperties); + Assert.Same(original.Contents, clone.Contents); + Assert.Same(original.RawRepresentation, clone.RawRepresentation); + } + + [Fact] + public void Clone_WithNullProperties_CopiesCorrectly() + { + // Arrange + var original = new ChatResponseUpdate + { + Role = ChatRole.User, + ResponseId = "resp123" + }; + + // Act + var clone = original.Clone(); + + // Assert + Assert.NotSame(original, clone); + Assert.Equal(ChatRole.User, clone.Role); + Assert.Equal("resp123", clone.ResponseId); + Assert.Null(clone.AdditionalProperties); + Assert.Null(clone.AuthorName); + Assert.Null(clone.CreatedAt); + Assert.Null(clone.ConversationId); + Assert.Null(clone.FinishReason); + Assert.Null(clone.MessageId); + Assert.Null(clone.ModelId); + Assert.Null(clone.RawRepresentation); + Assert.Empty(clone.Contents); // Contents property initializes to empty list + } + + [Fact] + public void Clone_WithDefaultConstructor_CopiesCorrectly() + { + // Arrange + var original = new ChatResponseUpdate(); + + // Act + var clone = original.Clone(); + + // Assert + Assert.NotSame(original, clone); + Assert.Null(clone.AuthorName); + Assert.Null(clone.Role); + Assert.Empty(clone.Contents); + Assert.Null(clone.RawRepresentation); + Assert.Null(clone.AdditionalProperties); + Assert.Null(clone.ResponseId); + Assert.Null(clone.MessageId); + Assert.Null(clone.CreatedAt); + Assert.Null(clone.FinishReason); + Assert.Null(clone.ConversationId); + Assert.Null(clone.ModelId); + } + + [Fact] + public void Clone_ModifyingClone_DoesNotAffectOriginal() + { + // Arrange + var original = new ChatResponseUpdate + { + AuthorName = "original_author", + Role = ChatRole.User, + ResponseId = "original_id", + ModelId = "original_model" + }; + + // Act + var clone = original.Clone(); + clone.AuthorName = "modified_author"; + clone.Role = ChatRole.Assistant; + clone.ResponseId = "modified_id"; + clone.ModelId = "modified_model"; + + // Assert - Original remains unchanged + Assert.Equal("original_author", original.AuthorName); + Assert.Equal(ChatRole.User, original.Role); + Assert.Equal("original_id", original.ResponseId); + Assert.Equal("original_model", original.ModelId); + + // Assert - Clone has modified values + Assert.Equal("modified_author", clone.AuthorName); + Assert.Equal(ChatRole.Assistant, clone.Role); + Assert.Equal("modified_id", clone.ResponseId); + Assert.Equal("modified_model", clone.ModelId); + } + + [Fact] + public void Clone_ModifyingSharedReferences_AffectsBothInstances() + { + // Arrange + var sharedAdditionalProperties = new AdditionalPropertiesDictionary { ["initial"] = "value" }; + var sharedContents = new List { new TextContent("initial") }; + + var original = new ChatResponseUpdate + { + AdditionalProperties = sharedAdditionalProperties, + Contents = sharedContents + }; + + // Act + var clone = original.Clone(); + + // Modify the shared reference objects + sharedAdditionalProperties["modified"] = "new_value"; + sharedContents.Add(new TextContent("added")); + + // Assert - Both original and clone are affected due to shallow copy + Assert.Same(original.AdditionalProperties, clone.AdditionalProperties); + Assert.Same(original.Contents, clone.Contents); + Assert.Equal(2, original.AdditionalProperties.Count); + Assert.Equal(2, clone.AdditionalProperties?.Count); + Assert.Equal(2, original.Contents.Count); + Assert.Equal(2, clone.Contents.Count); + Assert.True(original.AdditionalProperties.ContainsKey("modified")); + Assert.True(clone.AdditionalProperties?.ContainsKey("modified")); + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIAnnotationReferenceTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIAnnotationReferenceTests.cs new file mode 100644 index 00000000000..e69de29bb2d diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIAnnotationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIAnnotationTests.cs new file mode 100644 index 00000000000..2cfb5c765b7 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIAnnotationTests.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.Collections.Generic; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class AIAnnotationTests +{ + [Fact] + public void Constructor_PropsDefault() + { + AIAnnotation a = new(); + Assert.Null(a.AdditionalProperties); + Assert.Null(a.RawRepresentation); + Assert.Null(a.AnnotatedRegions); + } + + [Fact] + public void Constructor_PropsRoundtrip() + { + AIAnnotation a = new(); + + Assert.Null(a.AdditionalProperties); + AdditionalPropertiesDictionary props = new() { { "key", "value" } }; + a.AdditionalProperties = props; + Assert.Same(props, a.AdditionalProperties); + + Assert.Null(a.AnnotatedRegions); + List regions = [new TextSpanAnnotatedRegion { StartIndex = 10, EndIndex = 42 }]; + a.AnnotatedRegions = regions; + Assert.Same(regions, a.AnnotatedRegions); + + Assert.Null(a.RawRepresentation); + object raw = new(); + a.RawRepresentation = raw; + Assert.Same(raw, a.RawRepresentation); + } + + [Fact] + public void Serialization_Roundtrips() + { + AIAnnotation original = new() + { + AdditionalProperties = new AdditionalPropertiesDictionary { { "key", "value" } }, + AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = 10, EndIndex = 42 }], + RawRepresentation = new object(), + }; + + string json = JsonSerializer.Serialize(original, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AIAnnotation))); + Assert.NotNull(json); + + var deserialized = (AIAnnotation?)JsonSerializer.Deserialize(json, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AIAnnotation))); + Assert.NotNull(deserialized); + + Assert.NotNull(deserialized.AdditionalProperties); + Assert.Single(deserialized.AdditionalProperties); + Assert.Equal(JsonSerializer.Deserialize("\"value\"", AIJsonUtilities.DefaultOptions).ToString(), deserialized.AdditionalProperties["key"]!.ToString()); + + Assert.Null(deserialized.RawRepresentation); + + Assert.NotNull(deserialized.AnnotatedRegions); + TextSpanAnnotatedRegion? region = Assert.IsType(Assert.Single(deserialized.AnnotatedRegions)); + Assert.NotNull(region); + Assert.Equal(10, region.StartIndex); + Assert.Equal(42, region.EndIndex); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIContentAnnotationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIContentAnnotationTests.cs new file mode 100644 index 00000000000..e69de29bb2d 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 64a20fc5e4a..e5734ccd7cf 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/AIContentTests.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 Xunit; @@ -53,4 +54,40 @@ public void Serialization_Roundtrips() Assert.NotNull(deserialized.AdditionalProperties); Assert.Single(deserialized.AdditionalProperties); } + + [Fact] + public void Serialization_DerivedTypes_Roundtrips() + { + ChatMessage message = new(ChatRole.User, + [ + new TextContent("a"), + new TextReasoningContent("reasoning text"), + new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream"), + new UriContent("http://example.com", "application/json"), + new ErrorContent("error message"), + new FunctionCallContent("call123", "functionName", new Dictionary { { "param1", 123 } }), + new FunctionResultContent("call123", "result data"), + new HostedFileContent("file123"), + new HostedVectorStoreContent("vectorStore123"), + new UsageContent(new UsageDetails { InputTokenCount = 10, OutputTokenCount = 20, TotalTokenCount = 30 }), + new FunctionApprovalRequestContent("request123", new FunctionCallContent("call123", "functionName", new Dictionary { { "param1", 123 } })), + new FunctionApprovalResponseContent("request123", approved: true, new FunctionCallContent("call123", "functionName", new Dictionary { { "param1", 123 } })), + new McpServerToolCallContent("call123", "myTool", "myServer"), + new McpServerToolResultContent("call123"), + new McpServerToolApprovalRequestContent("request123", new McpServerToolCallContent("call123", "myTool", "myServer")), + new McpServerToolApprovalResponseContent("request123", approved: true) + ]); + + var serialized = JsonSerializer.Serialize(message, AIJsonUtilities.DefaultOptions); + ChatMessage? deserialized = JsonSerializer.Deserialize(serialized, AIJsonUtilities.DefaultOptions); + Assert.NotNull(deserialized); + + Assert.Equal(message.Role, deserialized.Role); + Assert.Equal(message.Contents.Count, deserialized.Contents.Count); + for (int i = 0; i < message.Contents.Count; i++) + { + Assert.NotNull(message.Contents[i]); + Assert.Equal(message.Contents[i].GetType(), deserialized.Contents[i].GetType()); + } + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/CitationAnnotationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/CitationAnnotationTests.cs new file mode 100644 index 00000000000..08097f3e05e --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/CitationAnnotationTests.cs @@ -0,0 +1,100 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class CitationAnnotationTests +{ + [Fact] + public void Constructor_PropsDefault() + { + CitationAnnotation a = new(); + Assert.Null(a.AdditionalProperties); + Assert.Null(a.AnnotatedRegions); + Assert.Null(a.RawRepresentation); + Assert.Null(a.Snippet); + Assert.Null(a.Title); + Assert.Null(a.ToolName); + Assert.Null(a.Url); + } + + [Fact] + public void Constructor_PropsRoundtrip() + { + CitationAnnotation a = new(); + + Assert.Null(a.AdditionalProperties); + AdditionalPropertiesDictionary props = new() { { "key", "value" } }; + a.AdditionalProperties = props; + Assert.Same(props, a.AdditionalProperties); + + Assert.Null(a.RawRepresentation); + object raw = new(); + a.RawRepresentation = raw; + Assert.Same(raw, a.RawRepresentation); + + Assert.Null(a.AnnotatedRegions); + List regions = [new TextSpanAnnotatedRegion { StartIndex = 10, EndIndex = 42 }]; + a.AnnotatedRegions = regions; + Assert.Same(regions, a.AnnotatedRegions); + + Assert.Null(a.Snippet); + a.Snippet = "snippet"; + Assert.Equal("snippet", a.Snippet); + + Assert.Null(a.Title); + a.Title = "title"; + Assert.Equal("title", a.Title); + + Assert.Null(a.ToolName); + a.ToolName = "toolName"; + Assert.Equal("toolName", a.ToolName); + + Assert.Null(a.Url); + Uri url = new("https://example.com"); + a.Url = url; + Assert.Same(url, a.Url); + } + + [Fact] + public void Serialization_Roundtrips() + { + CitationAnnotation original = new() + { + AdditionalProperties = new AdditionalPropertiesDictionary { { "key", "value" } }, + RawRepresentation = new object(), + Snippet = "snippet", + Title = "title", + ToolName = "toolName", + Url = new("https://example.com"), + AnnotatedRegions = [new TextSpanAnnotatedRegion { StartIndex = 10, EndIndex = 42 }], + }; + + string json = JsonSerializer.Serialize(original, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(CitationAnnotation))); + Assert.NotNull(json); + + var deserialized = (CitationAnnotation?)JsonSerializer.Deserialize(json, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(CitationAnnotation))); + Assert.NotNull(deserialized); + + Assert.NotNull(deserialized.AdditionalProperties); + Assert.Single(deserialized.AdditionalProperties); + Assert.Equal(JsonSerializer.Deserialize("\"value\"", AIJsonUtilities.DefaultOptions).ToString(), deserialized.AdditionalProperties["key"]!.ToString()); + + Assert.Null(deserialized.RawRepresentation); + Assert.Equal("snippet", deserialized.Snippet); + Assert.Equal("title", deserialized.Title); + Assert.Equal("toolName", deserialized.ToolName); + Assert.NotNull(deserialized.AnnotatedRegions); + TextSpanAnnotatedRegion region = Assert.IsType(Assert.Single(deserialized.AnnotatedRegions)); + Assert.Equal(10, region.StartIndex); + Assert.Equal(42, region.EndIndex); + + Assert.NotNull(deserialized.Url); + Assert.Equal(original.Url, deserialized.Url); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/CodeInterpreterToolCallContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/CodeInterpreterToolCallContentTests.cs new file mode 100644 index 00000000000..1807f4a169a --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/CodeInterpreterToolCallContentTests.cs @@ -0,0 +1,93 @@ +// 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 CodeInterpreterToolCallContentTests +{ + [Fact] + public void Constructor_PropsDefault() + { + CodeInterpreterToolCallContent c = new(); + Assert.Null(c.RawRepresentation); + Assert.Null(c.AdditionalProperties); + Assert.Null(c.CallId); + Assert.Null(c.Inputs); + } + + [Fact] + public void Properties_Roundtrip() + { + CodeInterpreterToolCallContent c = new(); + + Assert.Null(c.CallId); + c.CallId = "call123"; + Assert.Equal("call123", c.CallId); + + Assert.Null(c.Inputs); + IList inputs = [new TextContent("print('hello')")]; + c.Inputs = inputs; + Assert.Same(inputs, c.Inputs); + + Assert.Null(c.RawRepresentation); + object raw = new(); + c.RawRepresentation = raw; + Assert.Same(raw, c.RawRepresentation); + + Assert.Null(c.AdditionalProperties); + AdditionalPropertiesDictionary props = new() { { "key", "value" } }; + c.AdditionalProperties = props; + Assert.Same(props, c.AdditionalProperties); + } + + [Fact] + public void Inputs_SupportsMultipleContentTypes() + { + CodeInterpreterToolCallContent c = new() + { + CallId = "call456", + Inputs = + [ + new TextContent("import numpy as np"), + new HostedFileContent("file123"), + new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream") + ] + }; + + Assert.NotNull(c.Inputs); + Assert.Equal(3, c.Inputs.Count); + Assert.IsType(c.Inputs[0]); + Assert.IsType(c.Inputs[1]); + Assert.IsType(c.Inputs[2]); + } + + [Fact] + public void Serialization_Roundtrips() + { + CodeInterpreterToolCallContent content = new() + { + CallId = "call123", + Inputs = + [ + new TextContent("print('hello')"), + new HostedFileContent("file456") + ] + }; + + var json = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions); + var deserializedSut = JsonSerializer.Deserialize(json, AIJsonUtilities.DefaultOptions); + + Assert.NotNull(deserializedSut); + Assert.Equal("call123", deserializedSut.CallId); + Assert.NotNull(deserializedSut.Inputs); + Assert.Equal(2, deserializedSut.Inputs.Count); + Assert.IsType(deserializedSut.Inputs[0]); + Assert.Equal("print('hello')", ((TextContent)deserializedSut.Inputs[0]).Text); + Assert.IsType(deserializedSut.Inputs[1]); + Assert.Equal("file456", ((HostedFileContent)deserializedSut.Inputs[1]).FileId); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/CodeInterpreterToolResultContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/CodeInterpreterToolResultContentTests.cs new file mode 100644 index 00000000000..6fb1303be53 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/CodeInterpreterToolResultContentTests.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; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class CodeInterpreterToolResultContentTests +{ + [Fact] + public void Constructor_PropsDefault() + { + CodeInterpreterToolResultContent c = new(); + Assert.Null(c.RawRepresentation); + Assert.Null(c.AdditionalProperties); + Assert.Null(c.CallId); + Assert.Null(c.Outputs); + } + + [Fact] + public void Properties_Roundtrip() + { + CodeInterpreterToolResultContent c = new(); + + Assert.Null(c.CallId); + c.CallId = "call123"; + Assert.Equal("call123", c.CallId); + + Assert.Null(c.Outputs); + IList output = [new TextContent("Hello, World!")]; + c.Outputs = output; + Assert.Same(output, c.Outputs); + + Assert.Null(c.RawRepresentation); + object raw = new(); + c.RawRepresentation = raw; + Assert.Same(raw, c.RawRepresentation); + + Assert.Null(c.AdditionalProperties); + AdditionalPropertiesDictionary props = new() { { "key", "value" } }; + c.AdditionalProperties = props; + Assert.Same(props, c.AdditionalProperties); + } + + [Fact] + public void Output_SupportsMultipleContentTypes() + { + CodeInterpreterToolResultContent c = new() + { + CallId = "call789", + Outputs = + [ + new TextContent("Execution completed"), + new HostedFileContent("output.png"), + new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream"), + new ErrorContent("Warning: deprecated function") + ] + }; + + Assert.NotNull(c.Outputs); + Assert.Equal(4, c.Outputs.Count); + Assert.IsType(c.Outputs[0]); + Assert.IsType(c.Outputs[1]); + Assert.IsType(c.Outputs[2]); + Assert.IsType(c.Outputs[3]); + } + + [Fact] + public void Serialization_Roundtrips() + { + CodeInterpreterToolResultContent content = new() + { + CallId = "call123", + Outputs = + [ + new TextContent("Hello, World!"), + new HostedFileContent("result.txt") + ] + }; + + var json = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions); + var deserializedSut = JsonSerializer.Deserialize(json, AIJsonUtilities.DefaultOptions); + + Assert.NotNull(deserializedSut); + Assert.Equal("call123", deserializedSut.CallId); + Assert.NotNull(deserializedSut.Outputs); + Assert.Equal(2, deserializedSut.Outputs.Count); + Assert.IsType(deserializedSut.Outputs[0]); + Assert.Equal("Hello, World!", ((TextContent)deserializedSut.Outputs[0]).Text); + Assert.IsType(deserializedSut.Outputs[1]); + Assert.Equal("result.txt", ((HostedFileContent)deserializedSut.Outputs[1]).FileId); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/DataContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/DataContentTests.cs index 0f5b6b22d92..47bc6ccfd62 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/DataContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/DataContentTests.cs @@ -111,13 +111,20 @@ public void Serialize_MatchesExpectedJson() { Assert.Equal( """{"uri":"data:application/octet-stream;base64,AQIDBA=="}""", - JsonSerializer.Serialize(new DataContent( - uri: "data:application/octet-stream;base64,AQIDBA=="), TestJsonSerializerContext.Default.Options)); + JsonSerializer.Serialize( + new DataContent(uri: "data:application/octet-stream;base64,AQIDBA=="), + TestJsonSerializerContext.Default.Options)); Assert.Equal( """{"uri":"data:application/octet-stream;base64,AQIDBA=="}""", - JsonSerializer.Serialize(new DataContent( - new ReadOnlyMemory([0x01, 0x02, 0x03, 0x04]), "application/octet-stream"), + JsonSerializer.Serialize( + new DataContent(new ReadOnlyMemory([0x01, 0x02, 0x03, 0x04]), "application/octet-stream"), + TestJsonSerializerContext.Default.Options)); + + Assert.Equal( + """{"uri":"data:application/octet-stream;base64,AQIDBA==","name":"test.bin"}""", + JsonSerializer.Serialize( + new DataContent(new ReadOnlyMemory([0x01, 0x02, 0x03, 0x04]), "application/octet-stream") { Name = "test.bin" }, TestJsonSerializerContext.Default.Options)); } @@ -260,4 +267,13 @@ public void NonBase64Data_Normalized() Assert.Equal("aGVsbG8gd29ybGQ=", content.Base64Data.ToString()); Assert.Equal("hello world", Encoding.ASCII.GetString(content.Data.ToArray())); } + + [Fact] + public void FileName_Roundtrips() + { + DataContent content = new(new byte[] { 1, 2, 3 }, "application/octet-stream"); + Assert.Null(content.Name); + content.Name = "test.bin"; + Assert.Equal("test.bin", content.Name); + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionApprovalRequestContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionApprovalRequestContentTests.cs new file mode 100644 index 00000000000..924243a7d1c --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionApprovalRequestContentTests.cs @@ -0,0 +1,71 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI.Contents; + +public class FunctionApprovalRequestContentTests +{ + [Fact] + public void Constructor_InvalidArguments_Throws() + { + FunctionCallContent functionCall = new("FCC1", "TestFunction"); + + Assert.Throws("id", () => new FunctionApprovalRequestContent(null!, functionCall)); + Assert.Throws("id", () => new FunctionApprovalRequestContent("", functionCall)); + Assert.Throws("id", () => new FunctionApprovalRequestContent("\r\t\n ", functionCall)); + + Assert.Throws("functionCall", () => new FunctionApprovalRequestContent("id", null!)); + } + + [Theory] + [InlineData("abc")] + [InlineData("123")] + [InlineData("!@#")] + public void Constructor_Roundtrips(string id) + { + FunctionCallContent functionCall = new("FCC1", "TestFunction"); + + FunctionApprovalRequestContent content = new(id, functionCall); + + Assert.Same(id, content.Id); + Assert.Same(functionCall, content.FunctionCall); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void CreateResponse_ReturnsExpectedResponse(bool approved) + { + string id = "req-1"; + FunctionCallContent functionCall = new("FCC1", "TestFunction"); + + FunctionApprovalRequestContent content = new(id, functionCall); + + var response = content.CreateResponse(approved); + + Assert.NotNull(response); + Assert.Same(id, response.Id); + Assert.Equal(approved, response.Approved); + Assert.Same(functionCall, response.FunctionCall); + } + + [Fact] + public void Serialization_Roundtrips() + { + var content = new FunctionApprovalRequestContent("request123", new FunctionCallContent("call123", "functionName", new Dictionary { { "param1", 123 } })); + + var json = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions); + var deserializedContent = JsonSerializer.Deserialize(json, AIJsonUtilities.DefaultOptions); + + Assert.NotNull(deserializedContent); + Assert.Equal(content.Id, deserializedContent.Id); + Assert.NotNull(deserializedContent.FunctionCall); + Assert.Equal(content.FunctionCall.CallId, deserializedContent.FunctionCall.CallId); + Assert.Equal(content.FunctionCall.Name, deserializedContent.FunctionCall.Name); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionApprovalResponseContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionApprovalResponseContentTests.cs new file mode 100644 index 00000000000..67d2f13cf49 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionApprovalResponseContentTests.cs @@ -0,0 +1,53 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI.Contents; + +public class FunctionApprovalResponseContentTests +{ + [Fact] + public void Constructor_InvalidArguments_Throws() + { + FunctionCallContent functionCall = new("FCC1", "TestFunction"); + + Assert.Throws("id", () => new FunctionApprovalResponseContent(null!, true, functionCall)); + Assert.Throws("id", () => new FunctionApprovalResponseContent("", true, functionCall)); + Assert.Throws("id", () => new FunctionApprovalResponseContent("\r\t\n ", true, functionCall)); + + Assert.Throws("functionCall", () => new FunctionApprovalResponseContent("id", true, null!)); + } + + [Theory] + [InlineData("abc", true)] + [InlineData("123", false)] + [InlineData("!@#", true)] + public void Constructor_Roundtrips(string id, bool approved) + { + FunctionCallContent functionCall = new("FCC1", "TestFunction"); + FunctionApprovalResponseContent content = new(id, approved, functionCall); + + Assert.Same(id, content.Id); + Assert.Equal(approved, content.Approved); + Assert.Same(functionCall, content.FunctionCall); + } + + [Fact] + public void Serialization_Roundtrips() + { + var content = new FunctionApprovalResponseContent("request123", true, new FunctionCallContent("call123", "functionName")); + + var json = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions); + var deserializedContent = JsonSerializer.Deserialize(json, AIJsonUtilities.DefaultOptions); + + Assert.NotNull(deserializedContent); + Assert.Equal(content.Id, deserializedContent.Id); + Assert.Equal(content.Approved, deserializedContent.Approved); + Assert.NotNull(deserializedContent.FunctionCall); + Assert.Equal(content.FunctionCall.CallId, deserializedContent.FunctionCall.CallId); + Assert.Equal(content.FunctionCall.Name, deserializedContent.FunctionCall.Name); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests..cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests.cs similarity index 100% rename from test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests..cs rename to test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests.cs diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/HostedFileContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/HostedFileContentTests.cs new file mode 100644 index 00000000000..58768d32553 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/HostedFileContentTests.cs @@ -0,0 +1,122 @@ +// Licensed 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 HostedFileContentTests +{ + [Fact] + public void Constructor_InvalidInput_Throws() + { + Assert.Throws("fileId", () => new HostedFileContent(null!)); + Assert.Throws("fileId", () => new HostedFileContent(string.Empty)); + Assert.Throws("fileId", () => new HostedFileContent(" ")); + } + + [Fact] + public void Constructor_String_PropsDefault() + { + string fileId = "id123"; + HostedFileContent c = new(fileId); + Assert.Null(c.RawRepresentation); + Assert.Null(c.AdditionalProperties); + Assert.Equal(fileId, c.FileId); + } + + [Fact] + public void Constructor_PropsRoundtrip() + { + HostedFileContent c = new("id123"); + Assert.Equal("id123", c.FileId); + + c.FileId = "id456"; + Assert.Equal("id456", c.FileId); + + Assert.Throws("value", () => c.FileId = null!); + Assert.Throws("value", () => c.FileId = string.Empty); + Assert.Throws("value", () => c.FileId = " "); + + Assert.Null(c.RawRepresentation); + object raw = new(); + c.RawRepresentation = raw; + Assert.Same(raw, c.RawRepresentation); + + Assert.Null(c.AdditionalProperties); + AdditionalPropertiesDictionary props = new() { { "key", "value" } }; + c.AdditionalProperties = props; + Assert.Same(props, c.AdditionalProperties); + } + + [Fact] + public void Serialization_Roundtrips() + { + var content = new HostedFileContent("file123"); + + var json = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions); + var deserializedContent = JsonSerializer.Deserialize(json, AIJsonUtilities.DefaultOptions); + + Assert.NotNull(deserializedContent); + Assert.Equal(content.FileId, deserializedContent.FileId); + } + + [Fact] + public void MediaType_Roundtrips() + { + HostedFileContent c = new("id123"); + Assert.Null(c.MediaType); + + c.MediaType = "image/png"; + Assert.Equal("image/png", c.MediaType); + + c.MediaType = "application/pdf"; + Assert.Equal("application/pdf", c.MediaType); + + c.MediaType = null; + Assert.Null(c.MediaType); + } + + [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 MediaType_InvalidValue_Throws(string invalidMediaType) + { + HostedFileContent c = new("id123"); + Assert.Throws("value", () => c.MediaType = invalidMediaType); + } + + [Theory] + [InlineData("image/png")] + [InlineData("image/jpeg")] + [InlineData("application/pdf")] + [InlineData("text/plain;charset=UTF-8")] + [InlineData("image/*")] + public void MediaType_ValidValue_Roundtrips(string mediaType) + { + HostedFileContent c = new("id123") { MediaType = mediaType }; + Assert.Equal(mediaType, c.MediaType); + } + + [Fact] + public void Name_Roundtrips() + { + HostedFileContent c = new("id123"); + Assert.Null(c.Name); + + c.Name = "document.pdf"; + Assert.Equal("document.pdf", c.Name); + + c.Name = "image.png"; + Assert.Equal("image.png", c.Name); + + c.Name = null; + Assert.Null(c.Name); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/HostedVectorStoreContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/HostedVectorStoreContentTests.cs new file mode 100644 index 00000000000..105d8f2efd9 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/HostedVectorStoreContentTests.cs @@ -0,0 +1,65 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class HostedVectorStoreContentTests +{ + [Fact] + public void Constructor_InvalidInput_Throws() + { + Assert.Throws("vectorStoreId", () => new HostedVectorStoreContent(null!)); + Assert.Throws("vectorStoreId", () => new HostedVectorStoreContent(string.Empty)); + Assert.Throws("vectorStoreId", () => new HostedVectorStoreContent(" ")); + } + + [Fact] + public void Constructor_String_PropsDefault() + { + HostedVectorStoreContent c = new("id123"); + Assert.Null(c.RawRepresentation); + Assert.Null(c.AdditionalProperties); + Assert.Equal("id123", c.VectorStoreId); + } + + [Fact] + public void Constructor_PropsRoundtrip() + { + HostedVectorStoreContent c = new("id123"); + + Assert.Equal("id123", c.VectorStoreId); + c.VectorStoreId = "id456"; + Assert.Equal("id456", c.VectorStoreId); + + Assert.Throws("value", () => c.VectorStoreId = null!); + Assert.Throws("value", () => c.VectorStoreId = string.Empty); + Assert.Throws("value", () => c.VectorStoreId = " "); + Assert.Equal("id456", c.VectorStoreId); + + Assert.Null(c.RawRepresentation); + object raw = new(); + c.RawRepresentation = raw; + Assert.Same(raw, c.RawRepresentation); + + Assert.Null(c.AdditionalProperties); + AdditionalPropertiesDictionary props = new() { { "key", "value" } }; + c.AdditionalProperties = props; + Assert.Same(props, c.AdditionalProperties); + } + + [Fact] + public void Serialization_Roundtrips() + { + var content = new HostedVectorStoreContent("vectorstore123"); + + var json = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions); + var deserializedContent = JsonSerializer.Deserialize(json, AIJsonUtilities.DefaultOptions); + + Assert.NotNull(deserializedContent); + Assert.Equal(content.VectorStoreId, deserializedContent.VectorStoreId); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/McpServerToolCallContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/McpServerToolCallContentTests.cs new file mode 100644 index 00000000000..d5c5b43ed0a --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/McpServerToolCallContentTests.cs @@ -0,0 +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; +using System.Collections.Generic; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class McpServerToolCallContentTests +{ + [Fact] + public void Constructor_PropsDefault() + { + McpServerToolCallContent c = new("callId1", "toolName", null); + + Assert.Null(c.RawRepresentation); + Assert.Null(c.AdditionalProperties); + + Assert.Equal("callId1", c.CallId); + Assert.Equal("toolName", c.ToolName); + Assert.Null(c.ServerName); + Assert.Null(c.Arguments); + } + + [Fact] + public void Constructor_PropsRoundtrip() + { + McpServerToolCallContent c = new("callId1", "toolName", "serverName"); + + Assert.Null(c.RawRepresentation); + object raw = new(); + c.RawRepresentation = raw; + Assert.Same(raw, c.RawRepresentation); + + Assert.Null(c.AdditionalProperties); + AdditionalPropertiesDictionary props = new() { { "key", "value" } }; + c.AdditionalProperties = props; + Assert.Same(props, c.AdditionalProperties); + + Assert.Null(c.Arguments); + IReadOnlyDictionary args = new Dictionary(); + c.Arguments = args; + Assert.Same(args, c.Arguments); + + Assert.Equal("callId1", c.CallId); + Assert.Equal("toolName", c.ToolName); + Assert.Equal("serverName", c.ServerName); + } + + [Fact] + public void Constructor_Throws() + { + Assert.Throws("callId", () => new McpServerToolCallContent(string.Empty, "name", null)); + Assert.Throws("toolName", () => new McpServerToolCallContent("callId1", string.Empty, null)); + + Assert.Throws("callId", () => new McpServerToolCallContent(null!, "name", null)); + Assert.Throws("toolName", () => new McpServerToolCallContent("callId1", null!, null)); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/McpServerToolResultContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/McpServerToolResultContentTests.cs new file mode 100644 index 00000000000..8fa6cc8a381 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/McpServerToolResultContentTests.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 System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class McpServerToolResultContentTests +{ + [Fact] + public void Constructor_PropsDefault() + { + McpServerToolResultContent c = new("callId"); + Assert.Equal("callId", c.CallId); + Assert.Null(c.RawRepresentation); + Assert.Null(c.AdditionalProperties); + Assert.Null(c.Output); + } + + [Fact] + public void Constructor_PropsRoundtrip() + { + McpServerToolResultContent c = new("callId"); + + Assert.Null(c.RawRepresentation); + object raw = new(); + c.RawRepresentation = raw; + Assert.Same(raw, c.RawRepresentation); + + Assert.Null(c.AdditionalProperties); + AdditionalPropertiesDictionary props = new() { { "key", "value" } }; + c.AdditionalProperties = props; + Assert.Same(props, c.AdditionalProperties); + + Assert.Equal("callId", c.CallId); + + Assert.Null(c.Output); + IList output = []; + c.Output = output; + Assert.Same(output, c.Output); + } + + [Fact] + public void Constructor_Throws() + { + Assert.Throws("callId", () => new McpServerToolResultContent(string.Empty)); + Assert.Throws("callId", () => new McpServerToolResultContent(null!)); + } + + [Fact] + public void Serialization_Roundtrips() + { + var content = new McpServerToolResultContent("call123") + { + Output = new List { new TextContent("result") } + }; + + var json = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions); + var deserializedContent = JsonSerializer.Deserialize(json, AIJsonUtilities.DefaultOptions); + + Assert.NotNull(deserializedContent); + Assert.Equal(content.CallId, deserializedContent.CallId); + Assert.NotNull(deserializedContent.Output); + } +} 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 97afc4208e7..c4cb4676cfa 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/TextContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/TextContentTests.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.Text.Json; using Xunit; namespace Microsoft.Extensions.AI; @@ -47,4 +48,16 @@ public void Constructor_PropsRoundtrip() Assert.Equal(string.Empty, c.Text); Assert.Equal(string.Empty, c.ToString()); } + + [Fact] + public void Serialization_Roundtrips() + { + var content = new TextContent("Hello, world!"); + + var json = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions); + var deserializedContent = JsonSerializer.Deserialize(json, AIJsonUtilities.DefaultOptions); + + Assert.NotNull(deserializedContent); + Assert.Equal(content.Text, deserializedContent.Text); + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/TextReasoningContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/TextReasoningContentTests.cs index 9d2e238a068..5dca45472ae 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/TextReasoningContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/TextReasoningContentTests.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.Text.Json; using Xunit; namespace Microsoft.Extensions.AI; @@ -16,6 +17,7 @@ public void Constructor_String_PropsDefault(string? text) TextReasoningContent c = new(text); Assert.Null(c.RawRepresentation); Assert.Null(c.AdditionalProperties); + Assert.Null(c.ProtectedData); Assert.Equal(text ?? string.Empty, c.Text); } @@ -46,5 +48,24 @@ public void Constructor_PropsRoundtrip() c.Text = string.Empty; Assert.Equal(string.Empty, c.Text); Assert.Equal(string.Empty, c.ToString()); + + Assert.Null(c.ProtectedData); + c.ProtectedData = "protected"; + Assert.Equal("protected", c.ProtectedData); + c.ProtectedData = null; + Assert.Null(c.ProtectedData); + } + + [Fact] + public void Serialization_Roundtrips() + { + var content = new TextReasoningContent("reasoning text") { ProtectedData = "protected" }; + + var json = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions); + var deserializedContent = JsonSerializer.Deserialize(json, AIJsonUtilities.DefaultOptions); + + Assert.NotNull(deserializedContent); + Assert.Equal(content.Text, deserializedContent.Text); + Assert.Equal("protected", deserializedContent.ProtectedData); } } 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 514e2defecf..ed268176c5d 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UsageContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UsageContentTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Text.Json; using Xunit; namespace Microsoft.Extensions.AI; @@ -57,4 +58,24 @@ public void Details_SetNull_Throws() Assert.Same(d, c.Details); } + + [Fact] + public void Serialization_Roundtrips() + { + var content = new UsageContent(new UsageDetails + { + InputTokenCount = 10, + OutputTokenCount = 20, + TotalTokenCount = 30 + }); + + var json = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions); + var deserializedContent = JsonSerializer.Deserialize(json, AIJsonUtilities.DefaultOptions); + + Assert.NotNull(deserializedContent); + Assert.NotNull(deserializedContent.Details); + Assert.Equal(content.Details.InputTokenCount, deserializedContent.Details.InputTokenCount); + Assert.Equal(content.Details.OutputTokenCount, deserializedContent.Details.OutputTokenCount); + Assert.Equal(content.Details.TotalTokenCount, deserializedContent.Details.TotalTokenCount); + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UserInputRequestContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UserInputRequestContentTests.cs new file mode 100644 index 00000000000..fc4dac9cabb --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UserInputRequestContentTests.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 System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI.Contents; + +public class UserInputRequestContentTests +{ + [Fact] + public void Constructor_InvalidArguments_Throws() + { + Assert.Throws("id", () => new TestUserInputRequestContent(null!)); + Assert.Throws("id", () => new TestUserInputRequestContent("")); + Assert.Throws("id", () => new TestUserInputRequestContent("\r\t\n ")); + } + + [Theory] + [InlineData("abc")] + [InlineData("123")] + [InlineData("!@#")] + public void Constructor_Roundtrips(string id) + { + TestUserInputRequestContent content = new(id); + + Assert.Equal(id, content.Id); + } + + [Fact] + public void Serialization_DerivedTypes_Roundtrips() + { + UserInputRequestContent content = new FunctionApprovalRequestContent("request123", new FunctionCallContent("call123", "functionName", new Dictionary { { "param1", 123 } })); + var serializedContent = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions); + var deserializedContent = JsonSerializer.Deserialize(serializedContent, AIJsonUtilities.DefaultOptions); + Assert.NotNull(deserializedContent); + Assert.Equal(content.GetType(), deserializedContent.GetType()); + + UserInputRequestContent[] contents = + [ + new FunctionApprovalRequestContent("request123", new FunctionCallContent("call123", "functionName", new Dictionary { { "param1", 123 } })), + new McpServerToolApprovalRequestContent("request123", new McpServerToolCallContent("call123", "myTool", "myServer")), + ]; + + var serializedContents = JsonSerializer.Serialize(contents, TestJsonSerializerContext.Default.UserInputRequestContentArray); + var deserializedContents = JsonSerializer.Deserialize(serializedContents, TestJsonSerializerContext.Default.UserInputRequestContentArray); + Assert.NotNull(deserializedContents); + + Assert.Equal(contents.Count(), deserializedContents.Length); + for (int i = 0; i < deserializedContents.Length; i++) + { + Assert.NotNull(contents.ElementAt(i)); + Assert.Equal(contents.ElementAt(i).GetType(), deserializedContents[i].GetType()); + } + } + + private sealed class TestUserInputRequestContent : UserInputRequestContent + { + public TestUserInputRequestContent(string id) + : base(id) + { + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UserInputResponseContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UserInputResponseContentTests.cs new file mode 100644 index 00000000000..2442e57272d --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UserInputResponseContentTests.cs @@ -0,0 +1,65 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI.Contents; + +public class UserInputResponseContentTests +{ + [Fact] + public void Constructor_InvalidArguments_Throws() + { + Assert.Throws("id", () => new TestUserInputResponseContent(null!)); + Assert.Throws("id", () => new TestUserInputResponseContent("")); + Assert.Throws("id", () => new TestUserInputResponseContent("\r\t\n ")); + } + + [Theory] + [InlineData("abc")] + [InlineData("123")] + [InlineData("!@#")] + public void Constructor_Roundtrips(string id) + { + TestUserInputResponseContent content = new(id); + + Assert.Equal(id, content.Id); + } + + [Fact] + public void Serialization_DerivedTypes_Roundtrips() + { + UserInputResponseContent content = new FunctionApprovalResponseContent("request123", true, new FunctionCallContent("call123", "functionName")); + var serializedContent = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions); + var deserializedContent = JsonSerializer.Deserialize(serializedContent, AIJsonUtilities.DefaultOptions); + Assert.NotNull(deserializedContent); + Assert.Equal(content.GetType(), deserializedContent.GetType()); + + UserInputResponseContent[] contents = + [ + new FunctionApprovalResponseContent("request123", true, new FunctionCallContent("call123", "functionName")), + new McpServerToolApprovalResponseContent("request123", true), + ]; + + var serializedContents = JsonSerializer.Serialize(contents, TestJsonSerializerContext.Default.UserInputResponseContentArray); + var deserializedContents = JsonSerializer.Deserialize(serializedContents, TestJsonSerializerContext.Default.UserInputResponseContentArray); + Assert.NotNull(deserializedContents); + + Assert.Equal(contents.Length, deserializedContents.Length); + for (int i = 0; i < deserializedContents.Length; i++) + { + Assert.NotNull(contents[i]); + Assert.Equal(contents[i].GetType(), deserializedContents[i].GetType()); + } + } + + private class TestUserInputResponseContent : UserInputResponseContent + { + public TestUserInputResponseContent(string id) + : base(id) + { + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/BinaryEmbeddingTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/BinaryEmbeddingTests.cs index c75d715466e..c927a7ccf18 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/BinaryEmbeddingTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/BinaryEmbeddingTests.cs @@ -45,7 +45,7 @@ public void Properties_Roundtrips() Assert.Equal(createdAt, e.CreatedAt); Assert.Null(e.AdditionalProperties); - AdditionalPropertiesDictionary props = new(); + AdditionalPropertiesDictionary props = []; e.AdditionalProperties = props; Assert.Same(props, e.AdditionalProperties); } 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 97ffecfc1f6..34cbcd63e1b 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingGenerationOptionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingGenerationOptionsTests.cs @@ -41,18 +41,23 @@ public void Properties_Roundtrip() ["key"] = "value", }; + Func rawRepresentationFactory = (c) => null; + options.ModelId = "modelId"; options.Dimensions = 1536; options.AdditionalProperties = additionalProps; + options.RawRepresentationFactory = rawRepresentationFactory; Assert.Equal("modelId", options.ModelId); Assert.Equal(1536, options.Dimensions); Assert.Same(additionalProps, options.AdditionalProperties); + Assert.Same(rawRepresentationFactory, options.RawRepresentationFactory); EmbeddingGenerationOptions clone = options.Clone(); Assert.Equal("modelId", clone.ModelId); Assert.Equal(1536, clone.Dimensions); Assert.Equal(additionalProps, clone.AdditionalProperties); + Assert.Same(rawRepresentationFactory, clone.RawRepresentationFactory); } [Fact] @@ -83,4 +88,70 @@ public void JsonSerialization_Roundtrips() Assert.IsType(value); Assert.Equal("value", ((JsonElement)value!).GetString()); } + + [Fact] + public void CopyConstructors_EnableHierarchyCloning() + { + OptionsB b = new() + { + ModelId = "test", + A = 42, + B = 84, + }; + + EmbeddingGenerationOptions clone = b.Clone(); + + Assert.Equal("test", clone.ModelId); + Assert.Equal(42, Assert.IsType(clone, exactMatch: false).A); + Assert.Equal(84, Assert.IsType(clone, exactMatch: true).B); + } + + private class OptionsA : EmbeddingGenerationOptions + { + public OptionsA() + { + } + + protected OptionsA(OptionsA other) + : base(other) + { + A = other.A; + } + + public int A { get; set; } + + public override EmbeddingGenerationOptions Clone() => new OptionsA(this); + } + + private class OptionsB : OptionsA + { + public OptionsB() + { + } + + protected OptionsB(OptionsB other) + : base(other) + { + B = other.B; + } + + public int B { get; set; } + + public override EmbeddingGenerationOptions Clone() => new OptionsB(this); + } + + [Fact] + public void CopyConstructor_Null_Valid() + { + PassedNullToBaseOptions options = new(); + Assert.NotNull(options); + } + + private class PassedNullToBaseOptions : EmbeddingGenerationOptions + { + public PassedNullToBaseOptions() + : base(null) + { + } + } } 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 c13730fe604..a345af7b508 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/GeneratedEmbeddingsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/GeneratedEmbeddingsTests.cs @@ -27,7 +27,7 @@ public void Ctor_ValidArgs_NoExceptions() GeneratedEmbeddings>[] instances = [ [], - new(0), + [], new(42), new([]) ]; diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/ApprovalRequiredAIFunctionTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/ApprovalRequiredAIFunctionTests.cs new file mode 100644 index 00000000000..b8d0173d33e --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/ApprovalRequiredAIFunctionTests.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.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI.Functions; + +public class ApprovalRequiredAIFunctionTests +{ + [Fact] + public void Constructor_NullFunction_ThrowsArgumentNullException() + { + Assert.Throws("innerFunction", () => new ApprovalRequiredAIFunction(null!)); + } + + [Fact] + public void DelegatesToInnerFunction_Properties() + { + var inner = AIFunctionFactory.Create(() => 42); + var func = new ApprovalRequiredAIFunction(inner); + + Assert.Equal(inner.Name, func.Name); + Assert.Equal(inner.Description, func.Description); + Assert.Equal(inner.JsonSchema, func.JsonSchema); + Assert.Equal(inner.ReturnJsonSchema, func.ReturnJsonSchema); + Assert.Same(inner.JsonSerializerOptions, func.JsonSerializerOptions); + Assert.Same(inner.UnderlyingMethod, func.UnderlyingMethod); + Assert.Same(inner.AdditionalProperties, func.AdditionalProperties); + Assert.Equal(inner.ToString(), func.ToString()); + } + + [Fact] + public async Task InvokeAsync_DelegatesToInnerFunction() + { + var inner = AIFunctionFactory.Create(() => "result"); + var func = new ApprovalRequiredAIFunction(inner); + + var result = await func.InvokeAsync(); + + Assert.Equal("result", result?.ToString()); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/DelegatingAIFunctionTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/DelegatingAIFunctionTests.cs index cfad15efdc0..b13e4d345a4 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/DelegatingAIFunctionTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/DelegatingAIFunctionTests.cs @@ -20,7 +20,7 @@ public void Constructor_NullInnerFunction_ThrowsArgumentNullException() [Fact] public void DefaultOverrides_DelegateToInnerFunction() { - AIFunction expected = AIFunctionFactory.Create(() => 42); + AIFunction expected = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => 42)); DerivedFunction actual = new(expected); Assert.Same(expected, actual.InnerFunction); @@ -32,6 +32,7 @@ public void DefaultOverrides_DelegateToInnerFunction() Assert.Same(expected.UnderlyingMethod, actual.UnderlyingMethod); Assert.Same(expected.AdditionalProperties, actual.AdditionalProperties); Assert.Equal(expected.ToString(), actual.ToString()); + Assert.Same(expected, actual.GetService()); } private sealed class DerivedFunction(AIFunction innerFunction) : DelegatingAIFunction(innerFunction) @@ -78,7 +79,7 @@ public async Task OverriddenInvocation_SuccessfullyInvoked() Assert.Same(inner.AdditionalProperties, actual.AdditionalProperties); Assert.Equal(inner.ToString(), actual.ToString()); - object? result = await actual.InvokeAsync(new(), CancellationToken.None); + object? result = await actual.InvokeAsync([], CancellationToken.None); Assert.Contains("84", result?.ToString()); Assert.False(innerInvoked); diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedCodeInterpreterToolTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedCodeInterpreterToolTests.cs deleted file mode 100644 index f69ffc5b399..00000000000 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedCodeInterpreterToolTests.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Xunit; - -namespace Microsoft.Extensions.AI; - -public class HostedCodeInterpreterToolTests -{ - [Fact] - public void Constructor_Roundtrips() - { - var tool = new HostedCodeInterpreterTool(); - Assert.Equal(nameof(HostedCodeInterpreterTool), tool.Name); - Assert.Empty(tool.Description); - Assert.Empty(tool.AdditionalProperties); - Assert.Equal(nameof(HostedCodeInterpreterTool), tool.ToString()); - } -} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedMcpServerToolApprovalModeTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedMcpServerToolApprovalModeTests.cs new file mode 100644 index 00000000000..9f72a194f06 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedMcpServerToolApprovalModeTests.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.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class HostedMcpServerToolApprovalModeTests +{ + [Fact] + public void Singletons_Idempotent() + { + Assert.Same(HostedMcpServerToolApprovalMode.AlwaysRequire, HostedMcpServerToolApprovalMode.AlwaysRequire); + Assert.Same(HostedMcpServerToolApprovalMode.NeverRequire, HostedMcpServerToolApprovalMode.NeverRequire); + } + + [Fact] + public void Serialization_NeverRequire_Roundtrips() + { + string json = JsonSerializer.Serialize(HostedMcpServerToolApprovalMode.NeverRequire, TestJsonSerializerContext.Default.HostedMcpServerToolApprovalMode); + Assert.Equal("""{"$type":"never"}""", json); + + HostedMcpServerToolApprovalMode? result = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.HostedMcpServerToolApprovalMode); + Assert.Equal(HostedMcpServerToolApprovalMode.NeverRequire, result); + } + + [Fact] + public void Serialization_AlwaysRequire_Roundtrips() + { + string json = JsonSerializer.Serialize(HostedMcpServerToolApprovalMode.AlwaysRequire, TestJsonSerializerContext.Default.HostedMcpServerToolApprovalMode); + Assert.Equal("""{"$type":"always"}""", json); + + HostedMcpServerToolApprovalMode? result = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.HostedMcpServerToolApprovalMode); + Assert.Equal(HostedMcpServerToolApprovalMode.AlwaysRequire, result); + } + + [Fact] + public void Serialization_RequireSpecific_Roundtrips() + { + var requireSpecific = HostedMcpServerToolApprovalMode.RequireSpecific(["ToolA", "ToolB"], ["ToolC"]); + string json = JsonSerializer.Serialize(requireSpecific, TestJsonSerializerContext.Default.HostedMcpServerToolApprovalMode); + Assert.Equal("""{"$type":"requireSpecific","alwaysRequireApprovalToolNames":["ToolA","ToolB"],"neverRequireApprovalToolNames":["ToolC"]}""", json); + + HostedMcpServerToolApprovalMode? result = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.HostedMcpServerToolApprovalMode); + Assert.Equal(requireSpecific, result); + } + + [Fact] + public void Equality_RequireSpecific_WorksAsExpected() + { + var mode1 = HostedMcpServerToolApprovalMode.RequireSpecific(["ToolA", "ToolB"], ["ToolC"]); + var mode2 = HostedMcpServerToolApprovalMode.RequireSpecific(["ToolA", "ToolB"], ["ToolC"]); + Assert.Equal(mode1, mode2); + Assert.Equal(mode1.GetHashCode(), mode2.GetHashCode()); + + Assert.NotNull(mode1.AlwaysRequireApprovalToolNames); + mode1.AlwaysRequireApprovalToolNames.Add("ToolD"); + Assert.NotEqual(mode1, mode2); + Assert.NotEqual(mode1.GetHashCode(), mode2.GetHashCode()); + + Assert.NotNull(mode2.AlwaysRequireApprovalToolNames); + mode2.AlwaysRequireApprovalToolNames.Add("ToolD"); + Assert.Equal(mode1, mode2); + Assert.Equal(mode1.GetHashCode(), mode2.GetHashCode()); + + Assert.NotNull(mode2.NeverRequireApprovalToolNames); + mode2.NeverRequireApprovalToolNames.Add("ToolE"); + Assert.NotEqual(mode1, mode2); + Assert.NotEqual(mode1.GetHashCode(), mode2.GetHashCode()); + + Assert.NotNull(mode1.NeverRequireApprovalToolNames); + mode1.NeverRequireApprovalToolNames.Add("ToolE"); + Assert.Equal(mode1, mode2); + Assert.Equal(mode1.GetHashCode(), mode2.GetHashCode()); + + var mode3 = HostedMcpServerToolApprovalMode.RequireSpecific(null, null); + Assert.Equal(mode3.GetHashCode(), mode3.GetHashCode()); + var mode4 = HostedMcpServerToolApprovalMode.RequireSpecific(["a"], null); + Assert.Equal(mode4.GetHashCode(), mode4.GetHashCode()); + Assert.NotEqual(mode3, mode4); + Assert.NotEqual(mode3.GetHashCode(), mode4.GetHashCode()); + + var mode5 = HostedMcpServerToolApprovalMode.RequireSpecific(null, ["b"]); + Assert.Equal(mode5.GetHashCode(), mode5.GetHashCode()); + Assert.NotEqual(mode3, mode5); + Assert.NotEqual(mode3.GetHashCode(), mode5.GetHashCode()); + Assert.NotEqual(mode4, mode5); + Assert.NotEqual(mode4.GetHashCode(), mode5.GetHashCode()); + + var mode6 = HostedMcpServerToolApprovalMode.RequireSpecific([], []); + Assert.Equal(mode6.GetHashCode(), mode6.GetHashCode()); + Assert.NotEqual(mode3, mode6); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/DelegatingImageGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/DelegatingImageGeneratorTests.cs new file mode 100644 index 00000000000..7e8d189b851 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/DelegatingImageGeneratorTests.cs @@ -0,0 +1,142 @@ +// Licensed 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 DelegatingImageGeneratorTests +{ + [Fact] + public void RequiresInnerImageGenerator() + { + Assert.Throws("innerGenerator", () => new NoOpDelegatingImageGenerator(null!)); + } + + [Fact] + public async Task GenerateImagesAsyncDefaultsToInnerGeneratorAsync() + { + // Arrange + var expectedRequest = new ImageGenerationRequest("test prompt"); + var expectedOptions = new ImageGenerationOptions(); + var expectedCancellationToken = CancellationToken.None; + var expectedResult = new TaskCompletionSource(); + var expectedResponse = new ImageGenerationResponse(); + using var inner = new TestImageGenerator + { + GenerateImagesAsyncCallback = (request, options, cancellationToken) => + { + Assert.Same(expectedRequest, request); + Assert.Same(expectedOptions, options); + Assert.Equal(expectedCancellationToken, cancellationToken); + return expectedResult.Task; + } + }; + + using var delegating = new NoOpDelegatingImageGenerator(inner); + + // Act + var resultTask = delegating.GenerateAsync(expectedRequest, expectedOptions, expectedCancellationToken); + + // Assert + Assert.False(resultTask.IsCompleted); + expectedResult.SetResult(expectedResponse); + Assert.True(resultTask.IsCompleted); + Assert.Same(expectedResponse, await resultTask); + } + + [Fact] + public void GetServiceThrowsForNullType() + { + using var inner = new TestImageGenerator(); + using var delegating = new NoOpDelegatingImageGenerator(inner); + Assert.Throws("serviceType", () => delegating.GetService(null!)); + } + + [Fact] + public void GetServiceReturnsSelfIfCompatibleWithRequestAndKeyIsNull() + { + // Arrange + using var inner = new TestImageGenerator(); + using var delegating = new NoOpDelegatingImageGenerator(inner); + + // Act + var generator = delegating.GetService(); + + // Assert + Assert.Same(delegating, generator); + } + + [Fact] + public void GetServiceDelegatesToInnerIfKeyIsNotNull() + { + // Arrange + var expectedKey = new object(); + using var expectedResult = new TestImageGenerator(); + using var inner = new TestImageGenerator + { + GetServiceCallback = (_, _) => expectedResult + }; + using var delegating = new NoOpDelegatingImageGenerator(inner); + + // Act + var generator = delegating.GetService(expectedKey); + + // Assert + Assert.Same(expectedResult, generator); + } + + [Fact] + public void GetServiceDelegatesToInnerIfNotCompatibleWithRequest() + { + // Arrange + var expectedResult = TimeZoneInfo.Local; + var expectedKey = new object(); + using var inner = new TestImageGenerator + { + GetServiceCallback = (type, key) => type == expectedResult.GetType() && key == expectedKey + ? expectedResult + : throw new InvalidOperationException("Unexpected call") + }; + using var delegating = new NoOpDelegatingImageGenerator(inner); + + // Act + var tzi = delegating.GetService(expectedKey); + + // Assert + Assert.Same(expectedResult, tzi); + } + + [Fact] + public void Dispose_SetsFlag() + { + using var inner = new TestImageGenerator(); + var delegating = new NoOpDelegatingImageGenerator(inner); + Assert.False(inner.DisposeInvoked); + + delegating.Dispose(); + Assert.True(inner.DisposeInvoked); + } + + [Fact] + public void Dispose_MultipleCallsSafe() + { + using var inner = new TestImageGenerator(); + var delegating = new NoOpDelegatingImageGenerator(inner); + + delegating.Dispose(); + Assert.True(inner.DisposeInvoked); + + // Second dispose should not throw +#pragma warning disable S3966 + delegating.Dispose(); +#pragma warning restore S3966 + Assert.True(inner.DisposeInvoked); + } + + private sealed class NoOpDelegatingImageGenerator(IImageGenerator innerGenerator) + : DelegatingImageGenerator(innerGenerator); +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGenerationOptionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGenerationOptionsTests.cs new file mode 100644 index 00000000000..68040e9c29c --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGenerationOptionsTests.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.Drawing; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ImageGenerationOptionsTests +{ + [Fact] + public void Constructor_Parameterless_PropsDefaulted() + { + ImageGenerationOptions options = new(); + Assert.Null(options.ResponseFormat); + Assert.Null(options.Count); + Assert.Null(options.ImageSize); + Assert.Null(options.MediaType); + Assert.Null(options.ModelId); + Assert.Null(options.RawRepresentationFactory); + + ImageGenerationOptions clone = options.Clone(); + Assert.Null(clone.ResponseFormat); + Assert.Null(clone.Count); + Assert.Null(clone.ImageSize); + Assert.Null(clone.MediaType); + Assert.Null(clone.ModelId); + Assert.Null(clone.RawRepresentationFactory); + } + + [Fact] + public void Properties_Roundtrip() + { + ImageGenerationOptions options = new(); + + Func factory = generator => new { Representation = "raw data" }; + + options.ResponseFormat = ImageGenerationResponseFormat.Data; + options.Count = 5; + options.ImageSize = new Size(1024, 768); + options.MediaType = "image/png"; + options.ModelId = "modelId"; + options.RawRepresentationFactory = factory; + + Assert.Equal(ImageGenerationResponseFormat.Data, options.ResponseFormat); + Assert.Equal(5, options.Count); + Assert.Equal(new Size(1024, 768), options.ImageSize); + Assert.Equal("image/png", options.MediaType); + Assert.Equal("modelId", options.ModelId); + Assert.Same(factory, options.RawRepresentationFactory); + + ImageGenerationOptions clone = options.Clone(); + Assert.Equal(ImageGenerationResponseFormat.Data, clone.ResponseFormat); + Assert.Equal(5, clone.Count); + Assert.Equal(new Size(1024, 768), clone.ImageSize); + Assert.Equal("image/png", clone.MediaType); + Assert.Equal("modelId", clone.ModelId); + Assert.Same(factory, clone.RawRepresentationFactory); + } + + [Fact] + public void JsonSerialization_Roundtrips() + { + ImageGenerationOptions options = new() + { + ResponseFormat = ImageGenerationResponseFormat.Data, + Count = 3, + ImageSize = new Size(256, 256), + MediaType = "image/jpeg", + ModelId = "test-model", + }; + + string json = JsonSerializer.Serialize(options, TestJsonSerializerContext.Default.ImageGenerationOptions); + + ImageGenerationOptions? deserialized = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.ImageGenerationOptions); + Assert.NotNull(deserialized); + + Assert.Equal(ImageGenerationResponseFormat.Data, deserialized.ResponseFormat); + Assert.Equal(3, deserialized.Count); + Assert.Equal(new Size(256, 256), deserialized.ImageSize); + Assert.Equal("image/jpeg", deserialized.MediaType); + Assert.Equal("test-model", deserialized.ModelId); + } + + [Fact] + public void Clone_CreatesIndependentCopy() + { + ImageGenerationOptions original = new() + { + ResponseFormat = ImageGenerationResponseFormat.Data, + Count = 2, + ImageSize = new Size(512, 512), + MediaType = "image/png", + ModelId = "original-model" + }; + + ImageGenerationOptions clone = original.Clone(); + + // Modify original + original.ResponseFormat = ImageGenerationResponseFormat.Uri; + original.Count = 1; + original.ImageSize = new Size(1024, 1024); + original.MediaType = "image/jpeg"; + original.ModelId = "modified-model"; + + // Clone should remain unchanged + Assert.Equal(ImageGenerationResponseFormat.Data, clone.ResponseFormat); + Assert.Equal(2, clone.Count); + Assert.Equal(new Size(512, 512), clone.ImageSize); + Assert.Equal("image/png", clone.MediaType); + Assert.Equal("original-model", clone.ModelId); + } + + [Theory] + [InlineData(ImageGenerationResponseFormat.Uri)] + [InlineData(ImageGenerationResponseFormat.Data)] + [InlineData(ImageGenerationResponseFormat.Hosted)] + public void ImageGenerationResponseFormat_Values_AreValid(ImageGenerationResponseFormat responseFormat) + { + Assert.True(Enum.IsDefined(typeof(ImageGenerationResponseFormat), responseFormat)); + } + + [Fact] + public void ImageGenerationResponseFormat_JsonSerialization_Roundtrips() + { + foreach (ImageGenerationResponseFormat responseFormat in Enum.GetValues(typeof(ImageGenerationResponseFormat))) + { + string json = JsonSerializer.Serialize(responseFormat, TestJsonSerializerContext.Default.ImageGenerationResponseFormat); + ImageGenerationResponseFormat deserialized = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.ImageGenerationResponseFormat); + Assert.Equal(responseFormat, deserialized); + } + } + + [Fact] + public void CopyConstructors_EnableHierarchyCloning() + { + OptionsB b = new() + { + ModelId = "test", + A = 42, + B = 84, + }; + + ImageGenerationOptions clone = b.Clone(); + + Assert.Equal("test", clone.ModelId); + Assert.Equal(42, Assert.IsType(clone, exactMatch: false).A); + Assert.Equal(84, Assert.IsType(clone, exactMatch: true).B); + } + + private class OptionsA : ImageGenerationOptions + { + public OptionsA() + { + } + + protected OptionsA(OptionsA other) + : base(other) + { + A = other.A; + } + + public int A { get; set; } + + public override ImageGenerationOptions Clone() => new OptionsA(this); + } + + private class OptionsB : OptionsA + { + public OptionsB() + { + } + + protected OptionsB(OptionsB other) + : base(other) + { + B = other.B; + } + + public int B { get; set; } + + public override ImageGenerationOptions Clone() => new OptionsB(this); + } + + [Fact] + public void CopyConstructor_Null_Valid() + { + PassedNullToBaseOptions options = new(); + Assert.NotNull(options); + } + + private class PassedNullToBaseOptions : ImageGenerationOptions + { + public PassedNullToBaseOptions() + : base(null) + { + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGenerationResponseTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGenerationResponseTests.cs new file mode 100644 index 00000000000..7b244dfeb53 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGenerationResponseTests.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.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ImageGenerationResponseTests +{ + [Fact] + public void Constructor_Parameterless_PropsDefaulted() + { + ImageGenerationResponse response = new(); + Assert.Empty(response.Contents); + Assert.NotNull(response.Contents); + Assert.Same(response.Contents, response.Contents); + Assert.Empty(response.Contents); + Assert.Null(response.RawRepresentation); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public void Constructor_List_PropsRoundtrip(int contentCount) + { + List content = []; + for (int i = 0; i < contentCount; i++) + { + content.Add(new UriContent(new Uri($"https://example.com/image-{i}.png"), "image/png")); + } + + ImageGenerationResponse response = new(content); + + Assert.Same(response.Contents, response.Contents); + if (contentCount == 0) + { + Assert.Empty(response.Contents); + } + else + { + Assert.Equal(contentCount, response.Contents.Count); + for (int i = 0; i < contentCount; i++) + { + UriContent uc = Assert.IsType(response.Contents[i]); + Assert.Equal($"https://example.com/image-{i}.png", uc.Uri.ToString()); + Assert.Equal("image/png", uc.MediaType); + } + } + } + + [Fact] + public void Contents_SetNull_ReturnsEmpty() + { + ImageGenerationResponse response = new() + { + Contents = null! + }; + Assert.NotNull(response.Contents); + Assert.Empty(response.Contents); + } + + [Fact] + public void Contents_Set_Roundtrips() + { + ImageGenerationResponse response = new(); + byte[] imageData = [1, 2, 3, 4]; + + List contents = [ + new UriContent(new Uri("https://example.com/image1.png"), "image/png"), + new DataContent(imageData, "image/jpeg") + ]; + + response.Contents = contents; + Assert.Same(contents, response.Contents); + } + + [Fact] + public void RawRepresentation_Roundtrips() + { + ImageGenerationResponse response = new(); + Assert.Null(response.RawRepresentation); + + object representation = new { test = "value" }; + response.RawRepresentation = representation; + Assert.Same(representation, response.RawRepresentation); + + response.RawRepresentation = null; + Assert.Null(response.RawRepresentation); + } + + [Fact] + public void JsonSerialization_Roundtrips() + { + List contents = [ + new UriContent(new Uri("https://example.com/image1.png"), "image/png"), + new DataContent((byte[])[1, 2, 3, 4], "image/jpeg") + ]; + + ImageGenerationResponse response = new(contents); + + string json = JsonSerializer.Serialize(response, TestJsonSerializerContext.Default.ImageGenerationResponse); + + ImageGenerationResponse? deserialized = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.ImageGenerationResponse); + Assert.NotNull(deserialized); + + Assert.Equal(2, deserialized.Contents.Count); + + UriContent uriContent = Assert.IsType(deserialized.Contents[0]); + Assert.Equal("https://example.com/image1.png", uriContent.Uri.ToString()); + Assert.Equal("image/png", uriContent.MediaType); + + DataContent dataContent = Assert.IsType(deserialized.Contents[1]); + Assert.Equal([1, 2, 3, 4], dataContent.Data.ToArray()); + Assert.Equal("image/jpeg", dataContent.MediaType); + } + + [Fact] + public void JsonSerialization_Empty_Roundtrips() + { + ImageGenerationResponse response = new(); + + string json = JsonSerializer.Serialize(response, TestJsonSerializerContext.Default.ImageGenerationResponse); + + ImageGenerationResponse? deserialized = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.ImageGenerationResponse); + Assert.NotNull(deserialized); + Assert.Empty(deserialized.Contents); + } + + [Fact] + public void JsonSerialization_WithVariousContentTypes_Roundtrips() + { + List contents = [ + new UriContent(new Uri("https://example.com/image.png"), "image/png"), + new DataContent((byte[])[255, 216, 255, 224], "image/jpeg"), + new TextContent("Generated image description") // Edge case: text content in image response + ]; + + ImageGenerationResponse response = new(contents); + + string json = JsonSerializer.Serialize(response, TestJsonSerializerContext.Default.ImageGenerationResponse); + + ImageGenerationResponse? deserialized = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.ImageGenerationResponse); + Assert.NotNull(deserialized); + Assert.Equal(3, deserialized.Contents.Count); + + Assert.IsType(deserialized.Contents[0]); + Assert.IsType(deserialized.Contents[1]); + Assert.IsType(deserialized.Contents[2]); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGeneratorExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGeneratorExtensionsTests.cs new file mode 100644 index 00000000000..a68726685eb --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGeneratorExtensionsTests.cs @@ -0,0 +1,222 @@ +// Licensed 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 ImageGeneratorExtensionsTests +{ + [Fact] + public void GetService_InvalidArgs_Throws() + { + Assert.Throws("generator", () => + { + _ = ImageGeneratorExtensions.GetService(null!); + }); + } + + [Fact] + public void GetService_ValidGenerator_CallsUnderlyingGetService() + { + using var testGenerator = new TestImageGenerator(); + var expectedResult = new object(); + var expectedServiceKey = new object(); + + testGenerator.GetServiceCallback = (serviceType, serviceKey) => + { + Assert.Equal(typeof(object), serviceType); + Assert.Same(expectedServiceKey, serviceKey); + return expectedResult; + }; + + var result = testGenerator.GetService(expectedServiceKey); + Assert.Same(expectedResult, result); + } + + [Fact] + public void GetService_ReturnsCorrectType() + { + using var testGenerator = new TestImageGenerator(); + var metadata = new ImageGeneratorMetadata("test", null, "model"); + + testGenerator.GetServiceCallback = (serviceType, serviceKey) => + { + return (serviceType == typeof(ImageGeneratorMetadata)) ? metadata : null; + }; + + var result = testGenerator.GetService(); + Assert.Same(metadata, result); + + var nullResult = testGenerator.GetService(); + Assert.Null(nullResult); + } + + [Fact] + public async Task EditImageAsync_DataContent_CallsGenerateImagesAsync() + { + // Arrange + using var testGenerator = new TestImageGenerator(); + var imageData = new byte[] { 1, 2, 3, 4 }; + var dataContent = new DataContent(imageData, "image/png") { Name = "test.png" }; + var prompt = "Edit this image"; + var options = new ImageGenerationOptions { Count = 2 }; + var expectedResponse = new ImageGenerationResponse(); + var cancellationToken = new CancellationToken(canceled: false); + + testGenerator.GenerateImagesAsyncCallback = (request, o, ct) => + { + Assert.NotNull(request.OriginalImages); + Assert.Single(request.OriginalImages); + Assert.Same(dataContent, Assert.Single(request.OriginalImages)); + Assert.Equal(prompt, request.Prompt); + Assert.Same(options, o); + Assert.Equal(cancellationToken, ct); + return Task.FromResult(expectedResponse); + }; + + // Act + var result = await testGenerator.EditImageAsync(dataContent, prompt, options, cancellationToken); + + // Assert + Assert.Same(expectedResponse, result); + } + + [Fact] + public async Task EditImageAsync_DataContent_NullArguments_Throws() + { + using var testGenerator = new TestImageGenerator(); + var dataContent = new DataContent(new byte[] { 1, 2, 3 }, "image/png"); + + await Assert.ThrowsAsync("generator", async () => + await ImageGeneratorExtensions.EditImageAsync(null!, dataContent, "prompt")); + + await Assert.ThrowsAsync("originalImage", async () => + await testGenerator.EditImageAsync(null!, "prompt")); + + await Assert.ThrowsAsync("prompt", async () => + await testGenerator.EditImageAsync(dataContent, null!)); + } + + [Fact] + public async Task EditImageAsync_ByteArray_CallsGenerateImagesAsync() + { + // Arrange + using var testGenerator = new TestImageGenerator(); + var imageData = new byte[] { 1, 2, 3, 4 }; + var fileName = "test.jpg"; + var prompt = "Edit this image"; + var options = new ImageGenerationOptions { Count = 2 }; + var expectedResponse = new ImageGenerationResponse(); + var cancellationToken = new CancellationToken(canceled: false); + + testGenerator.GenerateImagesAsyncCallback = (request, o, ct) => + { + Assert.NotNull(request.OriginalImages); + Assert.Single(request.OriginalImages); + var dataContent = Assert.IsType(Assert.Single(request.OriginalImages)); + Assert.Equal(imageData, dataContent.Data.ToArray()); + Assert.Equal("image/jpeg", dataContent.MediaType); + Assert.Equal(fileName, dataContent.Name); + Assert.Equal(prompt, request.Prompt); + Assert.Same(options, o); + Assert.Equal(cancellationToken, ct); + return Task.FromResult(expectedResponse); + }; + + // Act + var result = await testGenerator.EditImageAsync(imageData, fileName, prompt, options, cancellationToken); + + // Assert + Assert.Same(expectedResponse, result); + } + + [Fact] + public async Task EditImageAsync_ByteArray_NullGenerator_Throws() + { + var imageData = new byte[] { 1, 2, 3 }; + + await Assert.ThrowsAsync("generator", async () => + await ImageGeneratorExtensions.EditImageAsync(null!, imageData, "test.png", "prompt")); + } + + [Fact] + public async Task EditImageAsync_ByteArray_NullFileName_Throws() + { + using var testGenerator = new TestImageGenerator(); + var imageData = new byte[] { 1, 2, 3 }; + + await Assert.ThrowsAsync("fileName", async () => + await testGenerator.EditImageAsync(imageData, null!, "prompt")); + } + + [Fact] + public async Task EditImageAsync_ByteArray_NullPrompt_Throws() + { + using var testGenerator = new TestImageGenerator(); + var imageData = new byte[] { 1, 2, 3 }; + + await Assert.ThrowsAsync("prompt", async () => + await testGenerator.EditImageAsync(imageData, "test.png", null!)); + } + + [Theory] + [InlineData("test.png", "image/png")] + [InlineData("test.jpg", "image/jpeg")] + [InlineData("test.jpeg", "image/jpeg")] + [InlineData("test.webp", "image/webp")] + [InlineData("test.gif", "image/gif")] + [InlineData("test.bmp", "image/bmp")] + [InlineData("test.tiff", "image/tiff")] + [InlineData("test.tif", "image/tiff")] + [InlineData("test.unknown", "image/png")] // Unknown extension defaults to PNG + [InlineData("TEST.PNG", "image/png")] // Case insensitive + public async Task EditImageAsync_ByteArray_InfersCorrectMediaType(string fileName, string expectedMediaType) + { + // Arrange + using var testGenerator = new TestImageGenerator(); + var imageData = new byte[] { 1, 2, 3, 4 }; + var prompt = "Edit this image"; + + testGenerator.GenerateImagesAsyncCallback = (request, o, ct) => + { + Assert.NotNull(request.OriginalImages); + var dataContent = Assert.IsType(Assert.Single(request.OriginalImages)); + Assert.Equal(expectedMediaType, dataContent.MediaType); + return Task.FromResult(new ImageGenerationResponse()); + }; + + // Act & Assert + await testGenerator.EditImageAsync(imageData, fileName, prompt); + } + + [Fact] + public async Task EditImageAsync_AllMethods_PassDefaultOptionsAndCancellation() + { + // Arrange + using var testGenerator = new TestImageGenerator(); + var imageData = new byte[] { 1, 2, 3, 4 }; + var dataContent = new DataContent(imageData, "image/png"); + var prompt = "Edit this image"; + + int callCount = 0; + testGenerator.GenerateImagesAsyncCallback = (request, o, ct) => + { + callCount++; + Assert.Null(o); // Default options should be null + Assert.Equal(CancellationToken.None, ct); // Default cancellation token + Assert.NotNull(request.OriginalImages); // Should have original images for editing + return Task.FromResult(new ImageGenerationResponse()); + }; + + // Act - Test all two overloads with default parameters + await testGenerator.EditImageAsync(dataContent, prompt); + await testGenerator.EditImageAsync(imageData, "test.png", prompt); + + // Assert + Assert.Equal(2, callCount); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGeneratorMetadataTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGeneratorMetadataTests.cs new file mode 100644 index 00000000000..193a02bde3e --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGeneratorMetadataTests.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 ImageGeneratorMetadataTests +{ + [Fact] + public void Constructor_NullValues_AllowedAndRoundtrip() + { + ImageGeneratorMetadata metadata = new(null, null, null); + Assert.Null(metadata.ProviderName); + Assert.Null(metadata.ProviderUri); + Assert.Null(metadata.DefaultModelId); + } + + [Fact] + public void Constructor_Value_Roundtrips() + { + var uri = new Uri("https://example.com"); + ImageGeneratorMetadata metadata = new("providerName", uri, "theModel"); + Assert.Equal("providerName", metadata.ProviderName); + Assert.Same(uri, metadata.ProviderUri); + Assert.Equal("theModel", metadata.DefaultModelId); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGeneratorTests.cs new file mode 100644 index 00000000000..c461a460ba4 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Image/ImageGeneratorTests.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.Drawing; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ImageGeneratorTests +{ + [Fact] + public void GetService_WithServiceKey_ReturnsNull() + { + using var generator = new TestImageGenerator(); + generator.GetServiceCallback = (serviceType, serviceKey) => + { + // When serviceKey is not null, should return null per interface contract + return serviceKey is not null ? null : new object(); + }; + + var result = generator.GetService(typeof(object), "someKey"); + Assert.Null(result); + } + + [Fact] + public void GetService_WithoutServiceKey_CallsCallback() + { + using var generator = new TestImageGenerator(); + var expectedResult = new object(); + + generator.GetServiceCallback = (serviceType, serviceKey) => + { + Assert.Equal(typeof(object), serviceType); + Assert.Null(serviceKey); + return expectedResult; + }; + + var result = generator.GetService(typeof(object)); + Assert.Same(expectedResult, result); + } + + [Fact] + public async Task GenerateImagesAsync_CallsCallback() + { + var expectedResponse = new ImageGenerationResponse(); + var expectedOptions = new ImageGenerationOptions(); + using var cts = new CancellationTokenSource(); + var expectedRequest = new ImageGenerationRequest("test prompt"); + + using var generator = new TestImageGenerator + { + GenerateImagesAsyncCallback = (request, options, cancellationToken) => + { + Assert.Same(expectedRequest, request); + Assert.Same(expectedOptions, options); + Assert.Equal(cts.Token, cancellationToken); + return Task.FromResult(expectedResponse); + } + }; + + var result = await generator.GenerateAsync(expectedRequest, expectedOptions, cts.Token); + Assert.Same(expectedResponse, result); + } + + [Fact] + public async Task GenerateImagesAsync_NoCallback_ReturnsEmptyResponse() + { + using var generator = new TestImageGenerator(); + var result = await generator.GenerateAsync(new ImageGenerationRequest("test prompt"), null); + Assert.NotNull(result); + Assert.Empty(result.Contents); + } + + [Fact] + public void Dispose_SetsFlag() + { + var generator = new TestImageGenerator(); + Assert.False(generator.DisposeInvoked); + + generator.Dispose(); + Assert.True(generator.DisposeInvoked); + } + + [Fact] + public void Dispose_MultipleCallsSafe() + { + var generator = new TestImageGenerator(); + + generator.Dispose(); + Assert.True(generator.DisposeInvoked); + + // Second dispose should not throw +#pragma warning disable S3966 + generator.Dispose(); +#pragma warning restore S3966 + Assert.True(generator.DisposeInvoked); + } + + [Fact] + public async Task GenerateImagesAsync_WithOptions_PassesThroughCorrectly() + { + var options = new ImageGenerationOptions + { + ResponseFormat = ImageGenerationResponseFormat.Data, + Count = 3, + ImageSize = new Size(1024, 768), + MediaType = "image/png", + ModelId = "test-model", + }; + + var expectedRequest = new ImageGenerationRequest("test prompt"); + + using var generator = new TestImageGenerator + { + GenerateImagesAsyncCallback = (request, receivedOptions, cancellationToken) => + { + Assert.Same(expectedRequest, request); + Assert.Same(options, receivedOptions); + return Task.FromResult(new ImageGenerationResponse()); + } + }; + + await generator.GenerateAsync(expectedRequest, options); + } + + [Fact] + public async Task GenerateImagesAsync_WithEditRequest_PassesThroughCorrectly() + { + var options = new ImageGenerationOptions + { + ResponseFormat = ImageGenerationResponseFormat.Uri, + Count = 2, + MediaType = "image/jpeg", + ModelId = "edit-model", + }; + + AIContent[] originalImages = [new DataContent((byte[])[1, 2, 3, 4], "image/png")]; + var expectedRequest = new ImageGenerationRequest("edit prompt", originalImages); + + using var generator = new TestImageGenerator + { + GenerateImagesAsyncCallback = (request, receivedOptions, cancellationToken) => + { + Assert.Same(expectedRequest, request); + Assert.Same(options, receivedOptions); + return Task.FromResult(new ImageGenerationResponse()); + } + }; + + await generator.GenerateAsync(expectedRequest, options); + } +} 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 d2ae2802123..4c275e54993 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 @@ -29,7 +29,6 @@ - diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ResponseContinuationTokenTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ResponseContinuationTokenTests.cs new file mode 100644 index 00000000000..a6d443a4f47 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/ResponseContinuationTokenTests.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ResponseContinuationTokenTests +{ + [Theory] + [InlineData(new byte[0])] + [InlineData(new byte[] { 1, 2, 3, 4, 5 })] + public void Bytes_Roundtrip(byte[] testBytes) + { + ResponseContinuationToken token = ResponseContinuationToken.FromBytes(testBytes); + + Assert.NotNull(token); + Assert.Equal(testBytes, token.ToBytes().ToArray()); + } + + [Fact] + public void JsonSerialization_Roundtrips() + { + ResponseContinuationToken originalToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3, 4, 5 }); + + // Act + string json = JsonSerializer.Serialize(originalToken, TestJsonSerializerContext.Default.ResponseContinuationToken); + + ResponseContinuationToken? deserializedToken = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.ResponseContinuationToken); + + // Assert + Assert.NotNull(deserializedToken); + Assert.Equal(originalToken.ToBytes().ToArray(), deserializedToken.ToBytes().ToArray()); + Assert.NotSame(originalToken, deserializedToken); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/SpeechToText/SpeechToTextClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/SpeechToText/SpeechToTextClientTests.cs index 092ad57b2c2..21fb5bb6bf0 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/SpeechToText/SpeechToTextClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/SpeechToText/SpeechToTextClientTests.cs @@ -74,12 +74,10 @@ public async Task GetStreamingTextAsync_CreatesStreamingUpdatesAsync() } // Helper method to simulate streaming updates -#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously private static async IAsyncEnumerable GetStreamingUpdatesAsync() { yield return new("hello "); yield return new("world "); yield return new("!"); } -#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/SpeechToText/SpeechToTextOptionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/SpeechToText/SpeechToTextOptionsTests.cs index 20936fd4517..4cf0f6461ee 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/SpeechToText/SpeechToTextOptionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/SpeechToText/SpeechToTextOptionsTests.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; @@ -34,21 +35,26 @@ public void Properties_Roundtrip() ["key"] = "value", }; + Func rawRepresentationFactory = (c) => null; + options.ModelId = "modelId"; options.SpeechLanguage = "en-US"; options.SpeechSampleRate = 44100; options.AdditionalProperties = additionalProps; + options.RawRepresentationFactory = rawRepresentationFactory; Assert.Equal("modelId", options.ModelId); Assert.Equal("en-US", options.SpeechLanguage); Assert.Equal(44100, options.SpeechSampleRate); Assert.Same(additionalProps, options.AdditionalProperties); + Assert.Same(rawRepresentationFactory, options.RawRepresentationFactory); SpeechToTextOptions clone = options.Clone(); Assert.Equal("modelId", clone.ModelId); Assert.Equal("en-US", clone.SpeechLanguage); Assert.Equal(44100, clone.SpeechSampleRate); Assert.Equal(additionalProps, clone.AdditionalProperties); + Assert.Same(rawRepresentationFactory, clone.RawRepresentationFactory); } [Fact] @@ -81,4 +87,70 @@ public void JsonSerialization_Roundtrips() Assert.IsType(value); Assert.Equal("value", ((JsonElement)value!).GetString()); } + + [Fact] + public void CopyConstructors_EnableHierarchyCloning() + { + OptionsB b = new() + { + ModelId = "test", + A = 42, + B = 84, + }; + + SpeechToTextOptions clone = b.Clone(); + + Assert.Equal("test", clone.ModelId); + Assert.Equal(42, Assert.IsType(clone, exactMatch: false).A); + Assert.Equal(84, Assert.IsType(clone, exactMatch: true).B); + } + + private class OptionsA : SpeechToTextOptions + { + public OptionsA() + { + } + + protected OptionsA(OptionsA other) + : base(other) + { + A = other.A; + } + + public int A { get; set; } + + public override SpeechToTextOptions Clone() => new OptionsA(this); + } + + private class OptionsB : OptionsA + { + public OptionsB() + { + } + + protected OptionsB(OptionsB other) + : base(other) + { + B = other.B; + } + + public int B { get; set; } + + public override SpeechToTextOptions Clone() => new OptionsB(this); + } + + [Fact] + public void CopyConstructor_Null_Valid() + { + PassedNullToBaseOptions options = new(); + Assert.NotNull(options); + } + + private class PassedNullToBaseOptions : SpeechToTextOptions + { + public PassedNullToBaseOptions() + : base(null) + { + } + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/SpeechToText/SpeechToTextResponseUpdateExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/SpeechToText/SpeechToTextResponseUpdateExtensionsTests.cs index 5d5a035bfe8..f288d3db28b 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/SpeechToText/SpeechToTextResponseUpdateExtensionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/SpeechToText/SpeechToTextResponseUpdateExtensionsTests.cs @@ -54,8 +54,8 @@ public async Task ToSpeechToTextResponse_SuccessfullyCreatesResponse(bool useAsy ]; SpeechToTextResponse response = useAsync ? - updates.ToSpeechToTextResponse() : - await YieldAsync(updates).ToSpeechToTextResponseAsync(); + await YieldAsync(updates).ToSpeechToTextResponseAsync() : + updates.ToSpeechToTextResponse(); Assert.NotNull(response); diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/SpeechToText/SpeechToTextResponseUpdateTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/SpeechToText/SpeechToTextResponseUpdateTests.cs index 0eae376070e..ec3ac1937e8 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/SpeechToText/SpeechToTextResponseUpdateTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/SpeechToText/SpeechToTextResponseUpdateTests.cs @@ -38,8 +38,7 @@ public void Properties_Roundtrip() Assert.Empty(update.Text); // Contents: assigning a new list then resetting to null should yield an empty list. - List newList = new(); - newList.Add(new TextContent("content1")); + List newList = [new TextContent("content1")]; update.Contents = newList; Assert.Same(newList, update.Contents); update.Contents = null; @@ -89,11 +88,11 @@ public void JsonSerialization_Roundtrips() ResponseId = "id123", StartTime = TimeSpan.FromSeconds(5), EndTime = TimeSpan.FromSeconds(10), - Contents = new List - { + Contents = + [ new TextContent("text-1"), new DataContent("data:audio/wav;base64,AQIDBA==", "application/octet-stream") - } + ] }; string json = JsonSerializer.Serialize(original, TestJsonSerializerContext.Default.SpeechToTextResponseUpdate); diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestImageGenerator.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestImageGenerator.cs new file mode 100644 index 00000000000..4db1cca7377 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestImageGenerator.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.AI; + +public sealed class TestImageGenerator : IImageGenerator +{ + public TestImageGenerator() + { + GetServiceCallback = DefaultGetServiceCallback; + } + + public IServiceProvider? Services { get; set; } + + public Func>? GenerateImagesAsyncCallback { get; set; } + + public Func GetServiceCallback { get; set; } + + public bool DisposeInvoked { get; private set; } + + private object? DefaultGetServiceCallback(Type serviceType, object? serviceKey) + => serviceType is not null && serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; + + public Task GenerateAsync(ImageGenerationRequest request, ImageGenerationOptions? options = null, CancellationToken cancellationToken = default) + { + return GenerateImagesAsyncCallback?.Invoke(request, options, cancellationToken) ?? + Task.FromResult(new ImageGenerationResponse()); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + return GetServiceCallback.Invoke(serviceType, serviceKey); + } + + public void Dispose() + { + DisposeInvoked = true; + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs index d15f0a19fa9..faaa799baf4 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs @@ -20,6 +20,8 @@ namespace Microsoft.Extensions.AI; [JsonSerializable(typeof(SpeechToTextResponseUpdate))] [JsonSerializable(typeof(SpeechToTextResponseUpdateKind))] [JsonSerializable(typeof(SpeechToTextOptions))] +[JsonSerializable(typeof(ImageGenerationResponse))] +[JsonSerializable(typeof(ImageGenerationOptions))] [JsonSerializable(typeof(ChatOptions))] [JsonSerializable(typeof(EmbeddingGenerationOptions))] [JsonSerializable(typeof(Dictionary))] @@ -33,4 +35,10 @@ namespace Microsoft.Extensions.AI; [JsonSerializable(typeof(DayOfWeek[]))] // Used in Content tests [JsonSerializable(typeof(Guid))] // Used in Content tests [JsonSerializable(typeof(decimal))] // Used in Content tests +[JsonSerializable(typeof(HostedMcpServerToolApprovalMode))] +[JsonSerializable(typeof(ChatResponseFormatTests.SomeType))] +[JsonSerializable(typeof(ChatResponseFormatTests.TypeWithDisplayName))] +[JsonSerializable(typeof(ResponseContinuationToken))] +[JsonSerializable(typeof(UserInputRequestContent[]))] +[JsonSerializable(typeof(UserInputResponseContent[]))] internal sealed partial class TestJsonSerializerContext : JsonSerializerContext; diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/AIToolTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/AIToolTests.cs new file mode 100644 index 00000000000..1a22f2d838e --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/AIToolTests.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 Xunit; + +namespace Microsoft.Extensions.AI; + +public class AIToolTests +{ + [Fact] + public void Constructor_Roundtrips() + { + DerivedAITool tool = new(); + Assert.Equal(nameof(DerivedAITool), tool.Name); + Assert.Equal(nameof(DerivedAITool), tool.ToString()); + Assert.Empty(tool.Description); + Assert.Empty(tool.AdditionalProperties); + } + + [Fact] + public void GetService_ReturnsExpectedObject() + { + DerivedAITool tool = new(); + + Assert.Throws("serviceType", () => tool.GetService(null!)); + + Assert.Same(tool, tool.GetService(typeof(object))); + Assert.Same(tool, tool.GetService(typeof(AITool))); + Assert.Same(tool, tool.GetService(typeof(DerivedAITool))); + + Assert.Same(tool, tool.GetService()); + Assert.Same(tool, tool.GetService()); + Assert.Same(tool, tool.GetService()); + + Assert.Null(tool.GetService(typeof(string))); + Assert.Null(tool.GetService()); + Assert.Null(tool.GetService("key")); + Assert.Null(tool.GetService("key")); + Assert.Null(tool.GetService("key")); + } + + private sealed class DerivedAITool : AITool; +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedCodeInterpreterToolTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedCodeInterpreterToolTests.cs new file mode 100644 index 00000000000..19044a6a295 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedCodeInterpreterToolTests.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 Xunit; + +namespace Microsoft.Extensions.AI; + +public class HostedCodeInterpreterToolTests +{ + [Fact] + public void Constructor_Roundtrips() + { + var tool = new HostedCodeInterpreterTool(); + Assert.Equal("code_interpreter", tool.Name); + Assert.Empty(tool.Description); + Assert.Empty(tool.AdditionalProperties); + Assert.Null(tool.Inputs); + Assert.Equal(tool.Name, tool.ToString()); + } + + [Fact] + public void Properties_Roundtrip() + { + var tool = new HostedCodeInterpreterTool + { + Inputs = + [ + new HostedFileContent("id123"), + new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream") + ] + }; + + Assert.NotNull(tool.Inputs); + Assert.Equal(2, tool.Inputs.Count); + Assert.IsType(tool.Inputs[0]); + Assert.IsType(tool.Inputs[1]); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedFileSearchToolTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedFileSearchToolTests.cs new file mode 100644 index 00000000000..e2d71a65013 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedFileSearchToolTests.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class HostedFileSearchToolTests +{ + [Fact] + public void Constructor_Roundtrips() + { + var tool = new HostedFileSearchTool(); + Assert.Equal("file_search", tool.Name); + Assert.Empty(tool.Description); + Assert.Empty(tool.AdditionalProperties); + Assert.Null(tool.Inputs); + Assert.Null(tool.MaximumResultCount); + Assert.Equal(tool.Name, tool.ToString()); + } + + [Fact] + public void Properties_Roundtrip() + { + var tool = new HostedFileSearchTool + { + Inputs = + [ + new HostedVectorStoreContent("id123"), + new HostedFileContent("id456"), + ], + MaximumResultCount = 10, + }; + + Assert.NotNull(tool.Inputs); + Assert.Equal(2, tool.Inputs.Count); + Assert.Equal(10, tool.MaximumResultCount); + Assert.IsType(tool.Inputs[0]); + Assert.IsType(tool.Inputs[1]); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedMcpServerToolTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedMcpServerToolTests.cs new file mode 100644 index 00000000000..ec1dc407973 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedMcpServerToolTests.cs @@ -0,0 +1,83 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class HostedMcpServerToolTests +{ + [Fact] + public void Constructor_PropsDefault() + { + HostedMcpServerTool tool = new("serverName", new Uri("https://localhost/")); + + Assert.Empty(tool.AdditionalProperties); + + Assert.Equal("serverName", tool.ServerName); + Assert.Equal("https://localhost/", tool.ServerAddress); + + Assert.Empty(tool.Description); + Assert.Null(tool.AuthorizationToken); + Assert.Null(tool.ServerDescription); + Assert.Null(tool.AllowedTools); + Assert.Null(tool.ApprovalMode); + } + + [Fact] + public void Constructor_Roundtrips() + { + HostedMcpServerTool tool = new("serverName", "connector_id"); + + Assert.Empty(tool.AdditionalProperties); + Assert.Empty(tool.Description); + Assert.Equal("mcp", tool.Name); + Assert.Equal(tool.Name, tool.ToString()); + + Assert.Equal("serverName", tool.ServerName); + Assert.Equal("connector_id", tool.ServerAddress); + Assert.Empty(tool.Description); + + Assert.Null(tool.AuthorizationToken); + string authToken = "Bearer token123"; + tool.AuthorizationToken = authToken; + Assert.Equal(authToken, tool.AuthorizationToken); + + Assert.Null(tool.ServerDescription); + string serverDescription = "This is a test server"; + tool.ServerDescription = serverDescription; + Assert.Equal(serverDescription, tool.ServerDescription); + + Assert.Null(tool.AllowedTools); + List allowedTools = ["tool1", "tool2"]; + tool.AllowedTools = allowedTools; + Assert.Same(allowedTools, tool.AllowedTools); + + Assert.Null(tool.ApprovalMode); + tool.ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire; + Assert.Same(HostedMcpServerToolApprovalMode.NeverRequire, tool.ApprovalMode); + + tool.ApprovalMode = HostedMcpServerToolApprovalMode.AlwaysRequire; + Assert.Same(HostedMcpServerToolApprovalMode.AlwaysRequire, tool.ApprovalMode); + + var customApprovalMode = new HostedMcpServerToolRequireSpecificApprovalMode(["tool1"], ["tool2"]); + tool.ApprovalMode = customApprovalMode; + Assert.Same(customApprovalMode, tool.ApprovalMode); + } + + [Fact] + public void Constructor_Throws() + { + Assert.Throws("serverName", () => new HostedMcpServerTool(string.Empty, "https://localhost/")); + Assert.Throws("serverName", () => new HostedMcpServerTool(string.Empty, new Uri("https://localhost/"))); + Assert.Throws("serverName", () => new HostedMcpServerTool(null!, "https://localhost/")); + Assert.Throws("serverName", () => new HostedMcpServerTool(null!, new Uri("https://localhost/"))); + + Assert.Throws("serverAddress", () => new HostedMcpServerTool("name", string.Empty)); + Assert.Throws("serverUrl", () => new HostedMcpServerTool("name", new Uri("/api/mcp", UriKind.Relative))); + Assert.Throws("serverAddress", () => new HostedMcpServerTool("name", (string)null!)); + Assert.Throws("serverUrl", () => new HostedMcpServerTool("name", (Uri)null!)); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedWebSearchToolTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedWebSearchToolTests.cs similarity index 76% rename from test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedWebSearchToolTests.cs rename to test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedWebSearchToolTests.cs index 4b03cbb0031..4bb6ca4b847 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedWebSearchToolTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedWebSearchToolTests.cs @@ -11,9 +11,9 @@ public class HostedWebSearchToolTests public void Constructor_Roundtrips() { var tool = new HostedWebSearchTool(); - Assert.Equal(nameof(HostedWebSearchTool), tool.Name); + Assert.Equal("web_search", tool.Name); Assert.Empty(tool.Description); Assert.Empty(tool.AdditionalProperties); - Assert.Equal(nameof(HostedWebSearchTool), tool.ToString()); + Assert.Equal(tool.Name, tool.ToString()); } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonSchemaTransformCacheTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonSchemaTransformCacheTests.cs index 4233e5cdbe1..8f514f7a15e 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonSchemaTransformCacheTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonSchemaTransformCacheTests.cs @@ -12,13 +12,13 @@ public static class AIJsonSchemaTransformCacheTests [Fact] public static void NullOptions_ThrowsArgumentNullException() { - Assert.Throws(() => new AIJsonSchemaTransformCache(transformOptions: null!)); + Assert.Throws("transformOptions", () => new AIJsonSchemaTransformCache(transformOptions: null!)); } [Fact] public static void EmptyOptions_ThrowsArgumentException() { - Assert.Throws(() => new AIJsonSchemaTransformCache(transformOptions: new())); + Assert.Throws("transformOptions", () => new AIJsonSchemaTransformCache(transformOptions: new())); } [Fact] @@ -33,14 +33,14 @@ public static void TransformOptions_ReturnsExpectedValue() public static void NullFunction_ThrowsArgumentNullException() { AIJsonSchemaTransformCache cache = new(new() { ConvertBooleanSchemas = true }); - Assert.Throws(() => cache.GetOrCreateTransformedSchema(function: null!)); + Assert.Throws("function", () => cache.GetOrCreateTransformedSchema(function: null!)); } [Fact] public static void NullResponseFormat_ThrowsArgumentNullException() { AIJsonSchemaTransformCache cache = new(new() { ConvertBooleanSchemas = true }); - Assert.Throws(() => cache.GetOrCreateTransformedSchema(responseFormat: null!)); + Assert.Throws("responseFormat", () => cache.GetOrCreateTransformedSchema(responseFormat: null!)); } [Fact] diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs index 19b2fc8bb48..8ebc20b957e 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Utilities/AIJsonUtilitiesTests.cs @@ -170,6 +170,21 @@ public static void CreateJsonSchema_DefaultParameters_GeneratesExpectedJsonSchem AssertDeepEquals(expected, actual); } + [Fact] + public static void CreateJsonSchema_TrivialArray_GeneratesExpectedJsonSchema() + { + JsonElement expected = JsonDocument.Parse(""" + { + "type": "array", + "items": {} + } + """).RootElement; + + JsonElement actual = AIJsonUtilities.CreateJsonSchema(typeof(object[]), serializerOptions: JsonContext.Default.Options); + + AssertDeepEquals(expected, actual); + } + [Fact] public static void CreateJsonSchema_OverriddenParameters_GeneratesExpectedJsonSchema() { @@ -347,20 +362,26 @@ public static void CreateFunctionJsonSchema_TreatsIntegralTypesAsInteger_EvenWit JsonElement schemaParameters = func.JsonSchema.GetProperty("properties"); Assert.NotNull(func.UnderlyingMethod); ParameterInfo[] parameters = func.UnderlyingMethod.GetParameters(); -#if NET9_0_OR_GREATER Assert.Equal(parameters.Length, schemaParameters.GetPropertyCount()); -#endif int i = 0; foreach (JsonProperty property in schemaParameters.EnumerateObject()) { - string numericType = Type.GetTypeCode(parameters[i].ParameterType) is TypeCode.Double or TypeCode.Single or TypeCode.Decimal - ? "number" - : "integer"; + bool isNullable = false; + Type type = parameters[i].ParameterType; + if (Nullable.GetUnderlyingType(type) is { } elementType) + { + type = elementType; + isNullable = true; + } + + string numericType = Type.GetTypeCode(type) is TypeCode.Double or TypeCode.Single or TypeCode.Decimal + ? "\"number\"" + : "\"integer\""; JsonElement expected = JsonDocument.Parse($$""" { - "type": "{{numericType}}" + "type": {{(isNullable ? $"[{numericType}, \"null\"]" : numericType)}} } """).RootElement; @@ -380,6 +401,63 @@ public enum MyEnumValue B = 2 } + [Fact] + public static void CreateFunctionJsonSchema_ReadsParameterDataAnnotationAttributes() + { + JsonSerializerOptions options = new(AIJsonUtilities.DefaultOptions) { NumberHandling = JsonNumberHandling.AllowReadingFromString }; + AIFunction func = AIFunctionFactory.Create(([Range(1, 10)] int num, [StringLength(100, MinimumLength = 1)] string str) => num + str.Length, serializerOptions: options); + + using JsonDocument expectedSchema = JsonDocument.Parse(""" + { + "type":"object", + "properties": { + "num": { "type":"integer", "minimum": 1, "maximum": 10 }, + "str": { "type":"string", "minLength": 1, "maxLength": 100 } + }, + "required":["num","str"] + } + """); + + AssertDeepEquals(expectedSchema.RootElement, func.JsonSchema); + } + + [Fact] + public static void CreateFunctionJsonSchema_DisplayNameAttribute_UsedForTitle() + { + [DisplayName("custom_method_name")] + [Description("Method description")] + static void TestMethod(int x, int y) + { + // Test method for schema generation + } + + var method = ((Action)TestMethod).Method; + JsonElement schema = AIJsonUtilities.CreateFunctionJsonSchema(method); + + using JsonDocument doc = JsonDocument.Parse(schema.GetRawText()); + Assert.True(doc.RootElement.TryGetProperty("title", out JsonElement titleElement)); + Assert.Equal("custom_method_name", titleElement.GetString()); + Assert.True(doc.RootElement.TryGetProperty("description", out JsonElement descElement)); + Assert.Equal("Method description", descElement.GetString()); + } + + [Fact] + public static void CreateFunctionJsonSchema_DisplayNameAttribute_CanBeOverridden() + { + [DisplayName("custom_method_name")] + static void TestMethod() + { + // Test method for schema generation + } + + var method = ((Action)TestMethod).Method; + JsonElement schema = AIJsonUtilities.CreateFunctionJsonSchema(method, title: "override_title"); + + using JsonDocument doc = JsonDocument.Parse(schema.GetRawText()); + Assert.True(doc.RootElement.TryGetProperty("title", out JsonElement titleElement)); + Assert.Equal("override_title", titleElement.GetString()); + } + [Fact] public static void CreateJsonSchema_CanBeBoolean() { @@ -627,22 +705,22 @@ public static void CreateJsonSchema_IncorporatesTypesAndAnnotations_Net() "string", "null" ], - "minItems": 5 + "minLength": 5 }, "MaxLengthProp": { "type": [ "string", "null" ], - "maxItems": 50 + "maxLength": 50 }, "LengthProp": { "type": [ "string", "null" ], - "minItems": 3, - "maxItems": 10 + "minLength": 3, + "maxLength": 10 }, "MinLengthArrayProp": { "type": [ @@ -805,14 +883,14 @@ public static void CreateJsonSchema_IncorporatesTypesAndAnnotations_NetFx() "string", "null" ], - "minItems": 5 + "minLength": 5 }, "MaxLengthProp": { "type": [ "string", "null" ], - "maxItems": 50 + "maxLength": 50 }, "MinLengthArrayProp": { "type": [ @@ -929,19 +1007,47 @@ private sealed class CreateJsonSchema_IncorporatesTypesAndAnnotations_Type #endif } + [Fact] + public static void ClassWithNullableMaxLengthProperty_ReturnsExpectedSchema() + { + JsonElement expectedSchema = JsonDocument.Parse(""" + { + "type": "object", + "properties": { + "Value": { + "type": ["string", "null"], + "maxLength": 24, + "minLength": 10 + } + } + } + """).RootElement; + + JsonElement actualSchema = AIJsonUtilities.CreateJsonSchema(typeof(ClassWithNullableMaxLengthProperty), serializerOptions: JsonContext.Default.Options); + AssertDeepEquals(expectedSchema, actualSchema); + } + + public class ClassWithNullableMaxLengthProperty + { + [MinLength(10)] + [MaxLength(24)] + public string? Value { get; set; } + } + [Fact] public static void AddAIContentType_DerivedAIContent() { JsonSerializerOptions options = new() { TypeInfoResolver = JsonTypeInfoResolver.Combine(AIJsonUtilities.DefaultOptions.TypeInfoResolver, JsonContext.Default), + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }; options.AddAIContentType("derivativeContent"); AIContent c = new DerivedAIContent { DerivedValue = 42 }; string json = JsonSerializer.Serialize(c, options); - Assert.Equal("""{"$type":"derivativeContent","DerivedValue":42,"AdditionalProperties":null}""", json); + Assert.Equal("""{"$type":"derivativeContent","DerivedValue":42}""", json); AIContent? deserialized = JsonSerializer.Deserialize(json, options); Assert.IsType(deserialized); @@ -966,14 +1072,17 @@ public static void AddAIContentType_NonAIContent_ThrowsArgumentException() public static void AddAIContentType_BuiltInAIContent_ThrowsArgumentException() { JsonSerializerOptions options = new(); - Assert.Throws(() => options.AddAIContentType("discriminator")); - Assert.Throws(() => options.AddAIContentType("discriminator")); + Assert.Throws("contentType", () => options.AddAIContentType("discriminator")); + Assert.Throws("contentType", () => options.AddAIContentType("discriminator")); } [Fact] public static void AddAIContentType_ConflictingIdentifier_ThrowsInvalidOperationException() { - JsonSerializerOptions options = new(); + JsonSerializerOptions options = new() + { + TypeInfoResolver = JsonTypeInfoResolver.Combine(AIJsonUtilities.DefaultOptions.TypeInfoResolver, JsonContext.Default), + }; options.AddAIContentType("text"); options.AddAIContentType("audio"); @@ -1290,8 +1399,8 @@ public static void TransformJsonSchema_ValidateWithTestData(ITestData testData) public static void TransformJsonSchema_InvalidOptions_ThrowsArgumentException() { JsonElement schema = JsonDocument.Parse("{}").RootElement; - Assert.Throws(() => AIJsonUtilities.TransformSchema(schema, transformOptions: null!)); - Assert.Throws(() => AIJsonUtilities.TransformSchema(schema, transformOptions: new())); + Assert.Throws("transformOptions", () => AIJsonUtilities.TransformSchema(schema, transformOptions: null!)); + Assert.Throws("transformOptions", () => AIJsonUtilities.TransformSchema(schema, transformOptions: new())); } [Theory] @@ -1304,7 +1413,7 @@ public static void TransformJsonSchema_InvalidInput_ThrowsArgumentException(stri { JsonElement schema = JsonDocument.Parse(invalidSchema).RootElement; AIJsonSchemaTransformOptions transformOptions = new() { ConvertBooleanSchemas = true }; - Assert.Throws(() => AIJsonUtilities.TransformSchema(schema, transformOptions)); + Assert.Throws("schema", () => AIJsonUtilities.TransformSchema(schema, transformOptions)); } private class DerivedAIContent : AIContent @@ -1317,17 +1426,13 @@ private class DerivedAIContent : AIContent [JsonSerializable(typeof(DerivedAIContent))] [JsonSerializable(typeof(MyPoco))] [JsonSerializable(typeof(MyEnumValue?))] + [JsonSerializable(typeof(object[]))] + [JsonSerializable(typeof(ClassWithNullableMaxLengthProperty))] private partial class JsonContext : JsonSerializerContext; private static bool DeepEquals(JsonElement element1, JsonElement element2) { -#if NET9_0_OR_GREATER return JsonElement.DeepEquals(element1, element2); -#else - return JsonNode.DeepEquals( - JsonSerializer.SerializeToNode(element1, AIJsonUtilities.DefaultOptions), - JsonSerializer.SerializeToNode(element2, AIJsonUtilities.DefaultOptions)); -#endif } private static void AssertDeepEquals(JsonElement element1, JsonElement element2) diff --git a/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceImageGeneratingChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceImageGeneratingChatClientIntegrationTests.cs new file mode 100644 index 00000000000..3a05f7fba9c --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.AzureAIInference.Tests/AzureAIInferenceImageGeneratingChatClientIntegrationTests.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.AI; + +/// +/// Azure AI Inference-specific integration tests for ImageGeneratingChatClient. +/// Tests the ImageGeneratingChatClient with Azure AI Inference chat client implementation. +/// +public class AzureAIInferenceImageGeneratingChatClientIntegrationTests : ImageGeneratingChatClientIntegrationTests +{ + protected override IChatClient? CreateChatClient() => + IntegrationTestHelpers.GetChatCompletionsClient() + ?.AsIChatClient(TestRunnerConfiguration.Instance["AzureAIInference:ChatModel"] ?? "gpt-4o-mini"); +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/AgentQualityEvaluatorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/AgentQualityEvaluatorTests.cs index 134d6a50f32..238b805e867 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/AgentQualityEvaluatorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/AgentQualityEvaluatorTests.cs @@ -54,8 +54,8 @@ static AgentQualityEvaluatorTests() string date = $"Date: {DateTime.UtcNow:dddd, dd MMMM yyyy}"; string projectName = $"Project: Integration Tests"; string testClass = $"Test Class: {nameof(AgentQualityEvaluatorTests)}"; - string provider = $"Model Provider: {clientMetadata?.ProviderName ?? "Unknown"}"; string model = $"Model: {clientMetadata?.DefaultModelId ?? "Unknown"}"; + string provider = $"Model Provider: {clientMetadata?.ProviderName ?? "Unknown"}"; string temperature = $"Temperature: {_chatOptionsWithTools.Temperature}"; string usesContext = $"Feature: Context"; @@ -69,7 +69,7 @@ static AgentQualityEvaluatorTests() evaluators: [taskAdherenceEvaluator, intentResolutionEvaluator], chatConfiguration: chatConfigurationWithToolCalling, executionName: Constants.Version, - tags: [version, date, projectName, testClass, provider, model, temperature]); + tags: [version, date, projectName, testClass, model, provider, temperature]); _needsContextReportingConfiguration = DiskBasedReportingConfiguration.Create( @@ -77,7 +77,7 @@ static AgentQualityEvaluatorTests() evaluators: [toolCallAccuracyEvaluator, taskAdherenceEvaluator, intentResolutionEvaluator], chatConfiguration: chatConfigurationWithToolCalling, executionName: Constants.Version, - tags: [version, date, projectName, testClass, provider, model, temperature, usesContext]); + tags: [version, date, projectName, testClass, model, provider, temperature, usesContext]); } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/Microsoft.Extensions.AI.Evaluation.Integration.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/Microsoft.Extensions.AI.Evaluation.Integration.Tests.csproj index 6e3332ebca6..8ee7f39ee1c 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/Microsoft.Extensions.AI.Evaluation.Integration.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/Microsoft.Extensions.AI.Evaluation.Integration.Tests.csproj @@ -4,6 +4,7 @@ $(LatestTargetFramework) Microsoft.Extensions.AI Integration tests for Microsoft.Extensions.AI.Evaluation. + $(NoWarn);OPENAI001 @@ -20,7 +21,7 @@ - + diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/NLPEvaluatorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/NLPEvaluatorTests.cs index a4f3b75045a..9cd593a647a 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/NLPEvaluatorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/NLPEvaluatorTests.cs @@ -1,9 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable CA2016 // Forward the 'CancellationToken' parameter to methods that take it. -#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. - using System; using System.Diagnostics.CodeAnalysis; using System.Linq; diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/QualityEvaluatorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/QualityEvaluatorTests.cs index ecec3ad51e5..fde342a4161 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/QualityEvaluatorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/QualityEvaluatorTests.cs @@ -1,9 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable CA2016 // Forward the 'CancellationToken' parameter to methods that take it. -#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. - using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; @@ -43,8 +40,8 @@ static QualityEvaluatorTests() string date = $"Date: {DateTime.UtcNow:dddd, dd MMMM yyyy}"; string projectName = $"Project: Integration Tests"; string testClass = $"Test Class: {nameof(QualityEvaluatorTests)}"; - string provider = $"Model Provider: {clientMetadata?.ProviderName ?? "Unknown"}"; string model = $"Model: {clientMetadata?.DefaultModelId ?? "Unknown"}"; + string provider = $"Model Provider: {clientMetadata?.ProviderName ?? "Unknown"}"; string temperature = $"Temperature: {_chatOptions.Temperature}"; string usesContext = $"Feature: Context"; @@ -60,7 +57,7 @@ static QualityEvaluatorTests() evaluators: [rtcEvaluator, coherenceEvaluator, fluencyEvaluator, relevanceEvaluator], chatConfiguration: chatConfiguration, executionName: Constants.Version, - tags: [version, date, projectName, testClass, provider, model, temperature,]); + tags: [version, date, projectName, testClass, model, provider, temperature]); IEvaluator groundednessEvaluator = new GroundednessEvaluator(); IEvaluator equivalenceEvaluator = new EquivalenceEvaluator(); @@ -73,7 +70,7 @@ static QualityEvaluatorTests() evaluators: [groundednessEvaluator, equivalenceEvaluator, completenessEvaluator, retrievalEvaluator], chatConfiguration: chatConfiguration, executionName: Constants.Version, - tags: [version, date, projectName, testClass, provider, model, temperature, usesContext]); + tags: [version, date, projectName, testClass, model, provider, temperature, usesContext]); } } @@ -116,7 +113,7 @@ public async Task SampleMultipleResponses() SkipIfNotConfigured(); #if NET - await Parallel.ForAsync(1, 6, async (i, _) => + await Parallel.ForAsync(1, 6, async (i, cancellationToken) => #else for (int i = 1; i < 6; i++) #endif @@ -124,7 +121,8 @@ await Parallel.ForAsync(1, 6, async (i, _) => await using ScenarioRun scenarioRun = await _qualityReportingConfiguration.CreateScenarioRunAsync( scenarioName: $"Microsoft.Extensions.AI.Evaluation.Integration.Tests.{nameof(QualityEvaluatorTests)}.{nameof(SampleMultipleResponses)}", - iterationName: i.ToString()); + iterationName: i.ToString(), + cancellationToken: cancellationToken); IChatClient chatClient = scenarioRun.ChatConfiguration!.ChatClient; @@ -132,9 +130,10 @@ await _qualityReportingConfiguration.CreateScenarioRunAsync( string prompt = @"How far in miles is the planet Venus from the Earth at its closest and furthest points?"; messages.Add(prompt.ToUserMessage()); - ChatResponse response = await chatClient.GetResponseAsync(messages, _chatOptions); + ChatResponse response = await chatClient.GetResponseAsync(messages, _chatOptions, cancellationToken); - EvaluationResult result = await scenarioRun.EvaluateAsync(messages, response); + EvaluationResult result = + await scenarioRun.EvaluateAsync(messages, response, cancellationToken: cancellationToken); Assert.False( result.ContainsDiagnostics(d => d.Severity >= EvaluationDiagnosticSeverity.Warning), diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/ResultsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/ResultsTests.cs index c2c02103714..0e5ba2eed1b 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/ResultsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/ResultsTests.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. -#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. - using System; using System.Collections.Generic; using System.IO; diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/SafetyEvaluatorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/SafetyEvaluatorTests.cs index 630adbffd8e..68b4a9d8ce0 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/SafetyEvaluatorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/SafetyEvaluatorTests.cs @@ -12,6 +12,7 @@ using Microsoft.Extensions.AI.Evaluation.Reporting.Storage; using Microsoft.Extensions.AI.Evaluation.Safety; using Microsoft.Extensions.AI.Evaluation.Tests; +using Microsoft.Extensions.AI.Evaluation.Utilities; using Microsoft.TestUtilities; using Xunit; @@ -24,6 +25,7 @@ public class SafetyEvaluatorTests private static readonly ReportingConfiguration? _imageContentSafetyReportingConfiguration; private static readonly ReportingConfiguration? _codeVulnerabilityReportingConfiguration; private static readonly ReportingConfiguration? _mixedQualityAndSafetyReportingConfiguration; + private static readonly ReportingConfiguration? _hubBasedContentSafetyReportingConfiguration; static SafetyEvaluatorTests() { @@ -37,14 +39,11 @@ static SafetyEvaluatorTests() }; ChatConfiguration llmChatConfiguration = Setup.CreateChatConfiguration(); - ChatClientMetadata? clientMetadata = llmChatConfiguration.ChatClient.GetService(); string version = $"Product Version: {Constants.Version}"; string date = $"Date: {DateTime.UtcNow:dddd, dd MMMM yyyy}"; string projectName = $"Project: Integration Tests"; string testClass = $"Test Class: {nameof(SafetyEvaluatorTests)}"; - string provider = $"Model Provider: {clientMetadata?.ProviderName ?? "Unknown"}"; - string model = $"Model: {clientMetadata?.DefaultModelId ?? "Unknown"}"; string temperature = $"Temperature: {_chatOptions.Temperature}"; string usesContext = $"Feature: Context"; @@ -52,13 +51,19 @@ static SafetyEvaluatorTests() var contentSafetyServiceConfiguration = new ContentSafetyServiceConfiguration( credential, - subscriptionId: Settings.Current.AzureSubscriptionId, - resourceGroupName: Settings.Current.AzureResourceGroupName, - projectName: Settings.Current.AzureAIProjectName); + endpointUrl: Settings.Current.AzureAIProjectEndpoint); ChatConfiguration contentSafetyChatConfiguration = contentSafetyServiceConfiguration.ToChatConfiguration(llmChatConfiguration); + ChatClientMetadata? clientMetadata = + contentSafetyChatConfiguration.ChatClient.GetService(); + + const string Model = $"Model: {ModelInfo.KnownModels.AzureAIFoundryEvaluation}"; + const string Provider = $"Model Provider: {ModelInfo.KnownModelProviders.AzureAIFoundry}"; + string model2 = $"Model: {clientMetadata?.DefaultModelId ?? "Unknown"}"; + string provider2 = $"Model Provider: {clientMetadata?.ProviderName ?? "Unknown"}"; + IEvaluator hateAndUnfairnessEvaluator = new HateAndUnfairnessEvaluator(); IEvaluator selfHarmEvaluator = new SelfHarmEvaluator(); IEvaluator sexualEvaluator = new SexualEvaluator(); @@ -80,7 +85,7 @@ static SafetyEvaluatorTests() indirectAttackEvaluator], chatConfiguration: contentSafetyChatConfiguration, executionName: Constants.Version, - tags: [version, date, projectName, testClass, provider, model, temperature, usesContext]); + tags: [version, date, projectName, testClass, Model, Provider, model2, provider2, temperature, usesContext]); ChatConfiguration contentSafetyChatConfigurationWithoutLLM = contentSafetyServiceConfiguration.ToChatConfiguration(); @@ -95,7 +100,7 @@ static SafetyEvaluatorTests() indirectAttackEvaluator], chatConfiguration: contentSafetyChatConfigurationWithoutLLM, executionName: Constants.Version, - tags: [version, date, projectName, testClass, provider, model, temperature]); + tags: [version, date, projectName, testClass, Model, Provider, model2, provider2, temperature]); IEvaluator codeVulnerabilityEvaluator = new CodeVulnerabilityEvaluator(); @@ -105,7 +110,7 @@ static SafetyEvaluatorTests() evaluators: [codeVulnerabilityEvaluator], chatConfiguration: contentSafetyChatConfigurationWithoutLLM, executionName: Constants.Version, - tags: [version, date, projectName, testClass, provider, model, temperature]); + tags: [version, date, projectName, testClass, Model, Provider, model2, provider2, temperature]); IEvaluator fluencyEvaluator = new FluencyEvaluator(); IEvaluator contentHarmEvaluator = new ContentHarmEvaluator(); @@ -116,21 +121,66 @@ static SafetyEvaluatorTests() evaluators: [fluencyEvaluator, contentHarmEvaluator], chatConfiguration: contentSafetyChatConfiguration, executionName: Constants.Version, - tags: [version, date, projectName, testClass, provider, model, temperature]); + tags: [version, date, projectName, testClass, Model, Provider, model2, provider2, temperature]); + + var hubBasedContentSafetyServiceConfiguration = + new ContentSafetyServiceConfiguration( + credential, + subscriptionId: Settings.Current.AzureSubscriptionId, + resourceGroupName: Settings.Current.AzureResourceGroupName, + projectName: Settings.Current.AzureAIProjectName); + + ChatConfiguration hubBasedContentSafetyChatConfiguration = + hubBasedContentSafetyServiceConfiguration.ToChatConfiguration(llmChatConfiguration); + + clientMetadata = hubBasedContentSafetyChatConfiguration.ChatClient.GetService(); + + model2 = $"Model: {clientMetadata?.DefaultModelId ?? "Unknown"}"; + provider2 = $"Model Provider: {clientMetadata?.ProviderName ?? "Unknown"}"; + + _hubBasedContentSafetyReportingConfiguration = + DiskBasedReportingConfiguration.Create( + storageRootPath: Settings.Current.StorageRootPath, + evaluators: [ + selfHarmEvaluator, + sexualEvaluator, + protectedMaterialEvaluator, + groundednessProEvaluator, + ungroundedAttributesEvaluator, + indirectAttackEvaluator], + chatConfiguration: hubBasedContentSafetyChatConfiguration, + executionName: Constants.Version, + tags: [version, date, projectName, testClass, Model, Provider, model2, provider2, temperature, usesContext]); } } [ConditionalFact] - public async Task EvaluateConversationWithSingleTurn() + public async Task EvaluateConversationWithSingleTurn_HubBasedProject() + { + SkipIfNotConfigured(); + + await using ScenarioRun scenarioRun = + await _hubBasedContentSafetyReportingConfiguration.CreateScenarioRunAsync( + scenarioName: $"Microsoft.Extensions.AI.Evaluation.Integration.Tests.{nameof(SafetyEvaluatorTests)}.{nameof(EvaluateConversationWithSingleTurn_HubBasedProject)}"); + + await EvaluateConversationWithSingleTurn(scenarioRun); + } + + [ConditionalFact] + public async Task EvaluateConversationWithSingleTurn_NonHubBasedProject() { SkipIfNotConfigured(); await using ScenarioRun scenarioRun = await _contentSafetyReportingConfiguration.CreateScenarioRunAsync( - scenarioName: $"Microsoft.Extensions.AI.Evaluation.Integration.Tests.{nameof(SafetyEvaluatorTests)}.{nameof(EvaluateConversationWithSingleTurn)}"); + scenarioName: $"Microsoft.Extensions.AI.Evaluation.Integration.Tests.{nameof(SafetyEvaluatorTests)}.{nameof(EvaluateConversationWithSingleTurn_NonHubBasedProject)}"); - IChatClient chatClient = scenarioRun.ChatConfiguration!.ChatClient; + await EvaluateConversationWithSingleTurn(scenarioRun); + } + private static async Task EvaluateConversationWithSingleTurn(ScenarioRun scenarioRun) + { + IChatClient chatClient = scenarioRun.ChatConfiguration!.ChatClient; var messages = new List(); string systemPrompt = @@ -183,16 +233,32 @@ The distance varies due to the elliptical orbits of both planets. } [ConditionalFact] - public async Task EvaluateConversationWithMultipleTurns() + public async Task EvaluateConversationWithMultipleTurns_HubBasedProject() + { + SkipIfNotConfigured(); + + await using ScenarioRun scenarioRun = + await _hubBasedContentSafetyReportingConfiguration.CreateScenarioRunAsync( + scenarioName: $"Microsoft.Extensions.AI.Evaluation.Integration.Tests.{nameof(SafetyEvaluatorTests)}.{nameof(EvaluateConversationWithMultipleTurns_HubBasedProject)}"); + + await EvaluateConversationWithMultipleTurns(scenarioRun); + } + + [ConditionalFact] + public async Task EvaluateConversationWithMultipleTurns_NonHubBasedProject() { SkipIfNotConfigured(); await using ScenarioRun scenarioRun = await _contentSafetyReportingConfiguration.CreateScenarioRunAsync( - scenarioName: $"Microsoft.Extensions.AI.Evaluation.Integration.Tests.{nameof(SafetyEvaluatorTests)}.{nameof(EvaluateConversationWithMultipleTurns)}"); + scenarioName: $"Microsoft.Extensions.AI.Evaluation.Integration.Tests.{nameof(SafetyEvaluatorTests)}.{nameof(EvaluateConversationWithMultipleTurns_NonHubBasedProject)}"); - IChatClient chatClient = scenarioRun.ChatConfiguration!.ChatClient; + await EvaluateConversationWithMultipleTurns(scenarioRun); + } + private static async Task EvaluateConversationWithMultipleTurns(ScenarioRun scenarioRun) + { + IChatClient chatClient = scenarioRun.ChatConfiguration!.ChatClient; var messages = new List(); string systemPrompt = @@ -553,6 +619,7 @@ await _mixedQualityAndSafetyReportingConfiguration.CreateScenarioRunAsync( [MemberNotNull(nameof(_imageContentSafetyReportingConfiguration))] [MemberNotNull(nameof(_codeVulnerabilityReportingConfiguration))] [MemberNotNull(nameof(_mixedQualityAndSafetyReportingConfiguration))] + [MemberNotNull(nameof(_hubBasedContentSafetyReportingConfiguration))] private static void SkipIfNotConfigured() { if (!Settings.Current.Configured) @@ -565,5 +632,6 @@ private static void SkipIfNotConfigured() Assert.NotNull(_codeVulnerabilityReportingConfiguration); Assert.NotNull(_imageContentSafetyReportingConfiguration); Assert.NotNull(_mixedQualityAndSafetyReportingConfiguration); + Assert.NotNull(_hubBasedContentSafetyReportingConfiguration); } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/Settings.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/Settings.cs index 22e027e73b2..7be73a05c10 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/Settings.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/Settings.cs @@ -16,6 +16,7 @@ public class Settings public string AzureSubscriptionId { get; } public string AzureResourceGroupName { get; } public string AzureAIProjectName { get; } + public string AzureAIProjectEndpoint { get; } public Settings(IConfiguration config) { @@ -49,19 +50,14 @@ public Settings(IConfiguration config) AzureAIProjectName = config.GetValue("AzureAIProjectName") ?? throw new ArgumentNullException(nameof(AzureAIProjectName)); + + AzureAIProjectEndpoint = + config.GetValue("AzureAIProjectEndpoint") + ?? throw new ArgumentNullException(nameof(AzureAIProjectEndpoint)); #pragma warning restore CA2208 } - private static Settings? _currentSettings; - - public static Settings Current - { - get - { - _currentSettings ??= GetCurrentSettings(); - return _currentSettings; - } - } + public static Settings Current => field ??= GetCurrentSettings(); private static Settings GetCurrentSettings() { diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/Setup.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/Setup.cs index 388ba1f1415..8ff4d7f23d3 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/Setup.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/Setup.cs @@ -3,8 +3,9 @@ using System; using System.ClientModel; -using Azure.AI.OpenAI; +using System.ClientModel.Primitives; using Azure.Identity; +using OpenAI; namespace Microsoft.Extensions.AI.Evaluation.Integration.Tests; @@ -15,22 +16,27 @@ internal static class Setup internal static ChatConfiguration CreateChatConfiguration() { - AzureOpenAIClient azureOpenAIClient = GetAzureOpenAIClient(); - IChatClient chatClient = azureOpenAIClient.GetChatClient(Settings.Current.DeploymentName).AsIChatClient(); + OpenAI.Chat.ChatClient openAIClient = GetOpenAIClient(); + IChatClient chatClient = openAIClient.AsIChatClient(); return new ChatConfiguration(chatClient); } - private static AzureOpenAIClient GetAzureOpenAIClient() + private static OpenAI.Chat.ChatClient GetOpenAIClient() { - var endpoint = new Uri(Settings.Current.Endpoint); - AzureOpenAIClientOptions options = new(); - var credential = new ChainedTokenCredential(new AzureCliCredential(), new DefaultAzureCredential()); + // Use Azure endpoint with /openai/v1 suffix + var options = new OpenAIClientOptions + { + Endpoint = new Uri(new Uri(Settings.Current.Endpoint), "/openai/v1") + }; - AzureOpenAIClient azureOpenAIClient = - OfflineOnly - ? new AzureOpenAIClient(endpoint, new ApiKeyCredential("Bogus"), options) - : new AzureOpenAIClient(endpoint, credential, options); + OpenAIClient client = OfflineOnly ? + new OpenAIClient(new ApiKeyCredential("Bogus"), options) : + new OpenAIClient( + new BearerTokenPolicy( + new ChainedTokenCredential(new AzureCliCredential(), new DefaultAzureCredential()), + "https://ai.azure.com/.default"), + options); - return azureOpenAIClient; + return client.GetChatClient(Settings.Current.DeploymentName); } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/appsettings.json b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/appsettings.json index 63b5ed0d33c..24e079d421d 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/appsettings.json +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/appsettings.json @@ -6,5 +6,6 @@ "StorageRootPath": "[storage-path]", "AzureSubscriptionId": "[subscription]", "AzureResourceGroupName": "[resource-group]", - "AzureAIProjectName": "[project]" + "AzureAIProjectName": "[project]", + "AzureAIProjectEndpoint": "https://[resource].services.ai.azure.com/api/projects/[project]" } diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/BLEUEvaluatorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/BLEUEvaluatorTests.cs index 48fda1357ab..64f304d2da3 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/BLEUEvaluatorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/BLEUEvaluatorTests.cs @@ -1,15 +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; +#pragma warning disable AIEVAL001 +// AIEVAL001: Some of the below APIs are experimental and subject to change or removal in future updates. + using System.Threading.Tasks; -using Microsoft.Extensions.AI.Evaluation.NLP; using Xunit; namespace Microsoft.Extensions.AI.Evaluation.NLP.Tests; -#pragma warning disable AIEVAL001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - public class BLEUEvaluatorTests { [Fact] @@ -109,5 +108,4 @@ public async Task ReturnsErrorDiagnosticIfEmptyResponse() Assert.NotNull(metric.Diagnostics); Assert.Contains(metric.Diagnostics, d => d.Severity == EvaluationDiagnosticSeverity.Error); } - } diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/F1EvaluatorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/F1EvaluatorTests.cs index 52a87badf41..23dfc6963a0 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/F1EvaluatorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/F1EvaluatorTests.cs @@ -1,15 +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; +#pragma warning disable AIEVAL001 +// AIEVAL001: Some of the below APIs are experimental and subject to change or removal in future updates. + using System.Threading.Tasks; -using Microsoft.Extensions.AI.Evaluation.NLP; using Xunit; namespace Microsoft.Extensions.AI.Evaluation.NLP.Tests; -#pragma warning disable AIEVAL001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - public class F1EvaluatorTests { [Fact] @@ -89,5 +88,4 @@ public async Task ReturnsErrorDiagnosticIfEmptyResponse() Assert.NotNull(metric.Diagnostics); Assert.Contains(metric.Diagnostics, d => d.Severity == EvaluationDiagnosticSeverity.Error); } - } diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/GLEUEvaluatorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/GLEUEvaluatorTests.cs index f27d11e4e2e..df007fcc638 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/GLEUEvaluatorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/GLEUEvaluatorTests.cs @@ -1,15 +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; +#pragma warning disable AIEVAL001 +// AIEVAL001: Some of the below APIs are experimental and subject to change or removal in future updates. + using System.Threading.Tasks; -using Microsoft.Extensions.AI.Evaluation.NLP; using Xunit; namespace Microsoft.Extensions.AI.Evaluation.NLP.Tests; -#pragma warning disable AIEVAL001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - public class GLEUEvaluatorTests { [Fact] @@ -109,5 +108,4 @@ public async Task ReturnsErrorDiagnosticIfEmptyResponse() Assert.NotNull(metric.Diagnostics); Assert.Contains(metric.Diagnostics, d => d.Severity == EvaluationDiagnosticSeverity.Error); } - } diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/NGramTests.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/NGramTests.cs index 6c0aefbb02a..f6401cabe08 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/NGramTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/NGramTests.cs @@ -21,7 +21,7 @@ public void Constructor_ValuesAndLength() [Fact] public void Constructor_ThrowsOnEmpty() { - Assert.Throws(() => new NGram(Array.Empty())); + Assert.Throws("values", () => new NGram(Array.Empty())); } [Fact] @@ -61,7 +61,7 @@ public void NGramBuilder_Create_Works() [Fact] public void CreateNGrams() { - Assert.Throws(() => new int[0].CreateNGrams(-1).ToList()); + Assert.Throws("n", () => new int[0].CreateNGrams(-1).ToList()); ReadOnlySpan data = [1, 2, 3]; @@ -81,11 +81,11 @@ public void CreateNGrams() [Fact] public void CreateAllNGrams() { - Assert.Throws(() => new int[0].CreateAllNGrams(-1).ToList()); + Assert.Throws("minN", () => new int[0].CreateAllNGrams(-1).ToList()); - Assert.Throws(() => new int[0].CreateAllNGrams(0).ToList()); + Assert.Throws("minN", () => new int[0].CreateAllNGrams(0).ToList()); - Assert.Throws(() => new int[0].CreateAllNGrams(1, 0).ToList()); + Assert.Throws("maxN", () => new int[0].CreateAllNGrams(1, 0).ToList()); ReadOnlySpan arr = [1, 2, 3]; diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/SimpleTokenizerTests.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/SimpleTokenizerTests.cs index 5766c2e7fc0..2906bf1ae5e 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/SimpleTokenizerTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.NLP.Tests/SimpleTokenizerTests.cs @@ -6,8 +6,6 @@ namespace Microsoft.Extensions.AI.Evaluation.NLP.Tests; -#pragma warning disable AIEVAL001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - public class SimpleTokenizerTests { [Theory] diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/ChatTurnDetailsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/ChatTurnDetailsTests.cs new file mode 100644 index 00000000000..b97334a1057 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/ChatTurnDetailsTests.cs @@ -0,0 +1,240 @@ +// Licensed 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 Microsoft.Extensions.AI.Evaluation.Reporting.JsonSerialization; +using Xunit; + +namespace Microsoft.Extensions.AI.Evaluation.Reporting.Tests; + +public class ChatTurnDetailsTests +{ + [Fact] + public void DeserializeWithLatencyOnly() + { + string json = + """ + { + "latency": 5 + } + """; + + JsonSerializerOptions options = JsonUtilities.Default.Options; + ChatTurnDetails? details = JsonSerializer.Deserialize(json, options); + + Assert.NotNull(details); + Assert.Equal(TimeSpan.FromSeconds(5), details!.Latency); + Assert.Null(details.Model); + Assert.Null(details.ModelProvider); + Assert.Null(details.Usage); + Assert.Null(details.CacheKey); + Assert.Null(details.CacheHit); + + string roundTripJson = JsonSerializer.Serialize(details, options); + ChatTurnDetails? deserializedDetails = JsonSerializer.Deserialize(roundTripJson, options); + + Assert.NotNull(deserializedDetails); + Assert.Equal(details.Latency, deserializedDetails!.Latency); + Assert.Null(deserializedDetails.Model); + Assert.Null(deserializedDetails.ModelProvider); + Assert.Null(deserializedDetails.Usage); + Assert.Null(deserializedDetails.CacheKey); + Assert.Null(deserializedDetails.CacheHit); + } + + [Fact] + public void DeserializeWithLatencyAndModel() + { + string json = + """ + { + "latency": 5, + "model": "gpt-4" + } + """; + + JsonSerializerOptions options = JsonUtilities.Default.Options; + ChatTurnDetails? details = JsonSerializer.Deserialize(json, options); + + Assert.NotNull(details); + Assert.Equal(TimeSpan.FromSeconds(5), details!.Latency); + Assert.Equal("gpt-4", details.Model); + Assert.Null(details.ModelProvider); + Assert.Null(details.Usage); + Assert.Null(details.CacheKey); + Assert.Null(details.CacheHit); + + string roundTripJson = JsonSerializer.Serialize(details, options); + ChatTurnDetails? deserializedDetails = JsonSerializer.Deserialize(roundTripJson, options); + + Assert.NotNull(deserializedDetails); + Assert.Equal(details.Latency, deserializedDetails!.Latency); + Assert.Equal(details.Model, deserializedDetails!.Model); + Assert.Null(deserializedDetails.ModelProvider); + Assert.Null(deserializedDetails.Usage); + Assert.Null(deserializedDetails.CacheKey); + Assert.Null(deserializedDetails.CacheHit); + } + + [Fact] + public void DeserializeWithLatencyModelAndModelProvider() + { + string json = + """ + { + "latency": 5, + "model": "gpt-4", + "modelProvider": "azure.openai" + } + """; + + JsonSerializerOptions options = JsonUtilities.Default.Options; + ChatTurnDetails? details = JsonSerializer.Deserialize(json, options); + + Assert.NotNull(details); + Assert.Equal(TimeSpan.FromSeconds(5), details!.Latency); + Assert.Equal("gpt-4", details.Model); + Assert.Equal("azure.openai", details.ModelProvider); + Assert.Null(details.Usage); + Assert.Null(details.CacheKey); + Assert.Null(details.CacheHit); + + string roundTripJson = JsonSerializer.Serialize(details, options); + ChatTurnDetails? deserializedDetails = JsonSerializer.Deserialize(roundTripJson, options); + + Assert.NotNull(deserializedDetails); + Assert.Equal(details.Latency, deserializedDetails!.Latency); + Assert.Equal(details.Model, deserializedDetails!.Model); + Assert.Equal(details.ModelProvider, deserializedDetails!.ModelProvider); + Assert.Null(deserializedDetails.Usage); + Assert.Null(deserializedDetails.CacheKey); + Assert.Null(deserializedDetails.CacheHit); + } + + [Fact] + public void DeserializeWithoutModelAndModelProvider() + { + string json = + """ + { + "latency": 1, + "usage": { "inputTokenCount": 10, "outputTokenCount": 20, "totalTokenCount": 30 }, + "cacheKey": "cache-key", + "cacheHit": true + } + """; + + JsonSerializerOptions options = JsonUtilities.Default.Options; + ChatTurnDetails? details = JsonSerializer.Deserialize(json, options); + + Assert.NotNull(details); + Assert.Equal(TimeSpan.FromSeconds(1), details!.Latency); + Assert.Null(details.Model); + Assert.Null(details.ModelProvider); + Assert.NotNull(details.Usage); + Assert.Equal(10, details.Usage!.InputTokenCount); + Assert.Equal(20, details.Usage.OutputTokenCount); + Assert.Equal(30, details.Usage.TotalTokenCount); + Assert.Equal("cache-key", details.CacheKey); + Assert.True(details.CacheHit); + + string roundTripJson = JsonSerializer.Serialize(details, options); + ChatTurnDetails? deserializedDetails = JsonSerializer.Deserialize(roundTripJson, options); + + Assert.NotNull(deserializedDetails); + Assert.Equal(details.Latency, deserializedDetails!.Latency); + Assert.Null(deserializedDetails.Model); + Assert.Null(deserializedDetails.ModelProvider); + Assert.Equal(details.Usage!.InputTokenCount, deserializedDetails.Usage!.InputTokenCount); + Assert.Equal(details.Usage.OutputTokenCount, deserializedDetails.Usage.OutputTokenCount); + Assert.Equal(details.Usage.TotalTokenCount, deserializedDetails.Usage.TotalTokenCount); + Assert.Equal(details.CacheKey, deserializedDetails.CacheKey); + Assert.Equal(details.CacheHit, deserializedDetails.CacheHit); + } + + [Fact] + public void DeserializeWithoutModelProvider() + { + string json = + """ + { + "latency": 1, + "model": "gpt-4", + "usage": { "inputTokenCount": 10, "outputTokenCount": 20, "totalTokenCount": 30 }, + "cacheKey": "cache-key", + "cacheHit": true + } + """; + + JsonSerializerOptions options = JsonUtilities.Default.Options; + ChatTurnDetails? details = JsonSerializer.Deserialize(json, options); + + Assert.NotNull(details); + Assert.Equal(TimeSpan.FromSeconds(1), details!.Latency); + Assert.Equal("gpt-4", details.Model); + Assert.Null(details.ModelProvider); + Assert.NotNull(details.Usage); + Assert.Equal(10, details.Usage!.InputTokenCount); + Assert.Equal(20, details.Usage.OutputTokenCount); + Assert.Equal(30, details.Usage.TotalTokenCount); + Assert.Equal("cache-key", details.CacheKey); + Assert.True(details.CacheHit); + + string roundTripJson = JsonSerializer.Serialize(details, options); + ChatTurnDetails? deserializedDetails = JsonSerializer.Deserialize(roundTripJson, options); + + Assert.NotNull(deserializedDetails); + Assert.Equal(details.Latency, deserializedDetails!.Latency); + Assert.Equal(details.Model, deserializedDetails.Model); + Assert.Null(deserializedDetails.ModelProvider); + Assert.Equal(details.Usage!.InputTokenCount, deserializedDetails.Usage!.InputTokenCount); + Assert.Equal(details.Usage.OutputTokenCount, deserializedDetails.Usage.OutputTokenCount); + Assert.Equal(details.Usage.TotalTokenCount, deserializedDetails.Usage.TotalTokenCount); + Assert.Equal(details.CacheKey, deserializedDetails.CacheKey); + Assert.Equal(details.CacheHit, deserializedDetails.CacheHit); + } + + [Fact] + public void DeserializeWithModelProvider() + { + string json = + """ + { + "latency": 2, + "model": "gpt-4", + "modelProvider": "azure.openai", + "usage": { "inputTokenCount": 5, "outputTokenCount": 7, "totalTokenCount": 12 }, + "cacheKey": "cache-key-2", + "cacheHit": false + } + """; + + JsonSerializerOptions options = JsonUtilities.Default.Options; + ChatTurnDetails? details = JsonSerializer.Deserialize(json, options); + + Assert.NotNull(details); + Assert.Equal(TimeSpan.FromSeconds(2), details!.Latency); + Assert.Equal("gpt-4", details.Model); + Assert.Equal("azure.openai", details.ModelProvider); + Assert.NotNull(details.Usage); + Assert.Equal(5, details.Usage!.InputTokenCount); + Assert.Equal(7, details.Usage.OutputTokenCount); + Assert.Equal(12, details.Usage.TotalTokenCount); + Assert.Equal("cache-key-2", details.CacheKey); + Assert.False(details.CacheHit); + + string roundTripJson = JsonSerializer.Serialize(details, options); + ChatTurnDetails? deserializedDetails = JsonSerializer.Deserialize(roundTripJson, options); + + Assert.NotNull(deserializedDetails); + Assert.Equal(details.Latency, deserializedDetails!.Latency); + Assert.Equal(details.Model, deserializedDetails.Model); + Assert.Equal(details.ModelProvider, deserializedDetails.ModelProvider); + Assert.Equal(details.Usage!.InputTokenCount, deserializedDetails.Usage!.InputTokenCount); + Assert.Equal(details.Usage.OutputTokenCount, deserializedDetails.Usage.OutputTokenCount); + Assert.Equal(details.Usage.TotalTokenCount, deserializedDetails.Usage.TotalTokenCount); + Assert.Equal(details.CacheKey, deserializedDetails.CacheKey); + Assert.Equal(details.CacheHit, deserializedDetails.CacheHit); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/ResponseCacheTester.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/ResponseCacheTester.cs index b69014e631b..50793dfdb66 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/ResponseCacheTester.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/ResponseCacheTester.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Linq; using System.Text; using System.Threading.Tasks; using Microsoft.Extensions.Caching.Distributed; @@ -45,10 +44,12 @@ public async Task AddUncachedEntry() Assert.Null(cache.Get(_keyB)); await cache.SetAsync(_keyA, _responseA); - Assert.True(_responseA.SequenceEqual(await cache.GetAsync(_keyA) ?? [])); + byte[] cached = await cache.GetAsync(_keyA) ?? []; + Assert.True(_responseA.SequenceEqual(cached)); cache.Set(_keyB, _responseB); - Assert.True(_responseB.SequenceEqual(cache.Get(_keyB) ?? [])); + cached = cache.Get(_keyB) ?? []; + Assert.True(_responseB.SequenceEqual(cached)); } [ConditionalFact] @@ -63,10 +64,12 @@ public async Task RemoveCachedEntry() Assert.NotNull(cache); await cache.SetAsync(_keyA, _responseA); - Assert.True(_responseA.SequenceEqual(await cache.GetAsync(_keyA) ?? [])); + byte[] cached = await cache.GetAsync(_keyA) ?? []; + Assert.True(_responseA.SequenceEqual(cached)); cache.Set(_keyB, _responseB); - Assert.True(_responseB.SequenceEqual(cache.Get(_keyB) ?? [])); + cached = cache.Get(_keyB) ?? []; + Assert.True(_responseB.SequenceEqual(cached)); await cache.RemoveAsync(_keyA); Assert.Null(await cache.GetAsync(_keyA)); @@ -90,10 +93,12 @@ public async Task CacheEntryExpiration() Assert.NotNull(cache); await cache.SetAsync(_keyA, _responseA); - Assert.True(_responseA.SequenceEqual(await cache.GetAsync(_keyA) ?? [])); + byte[] cached = await cache.GetAsync(_keyA) ?? []; + Assert.True(_responseA.SequenceEqual(cached)); cache.Set(_keyB, _responseB); - Assert.True(_responseB.SequenceEqual(cache.Get(_keyB) ?? [])); + cached = cache.Get(_keyB) ?? []; + Assert.True(_responseB.SequenceEqual(cached)); now = DateTime.UtcNow + Defaults.DefaultTimeToLiveForCacheEntries; @@ -138,10 +143,12 @@ public async Task DeleteExpiredEntries() Assert.NotNull(cache); await cache.SetAsync(_keyA, _responseA); - Assert.True(_responseA.SequenceEqual(await cache.GetAsync(_keyA) ?? [])); + byte[] cached = await cache.GetAsync(_keyA) ?? []; + Assert.True(_responseA.SequenceEqual(cached)); cache.Set(_keyB, _responseB); - Assert.True(_responseB.SequenceEqual(cache.Get(_keyB) ?? [])); + cached = cache.Get(_keyB) ?? []; + Assert.True(_responseB.SequenceEqual(cached)); now = DateTime.UtcNow + Defaults.DefaultTimeToLiveForCacheEntries; @@ -168,10 +175,12 @@ public async Task ResetCache() Assert.NotNull(cache); await cache.SetAsync(_keyA, _responseA); - Assert.True(_responseA.SequenceEqual(await cache.GetAsync(_keyA) ?? [])); + byte[] cached = await cache.GetAsync(_keyA) ?? []; + Assert.True(_responseA.SequenceEqual(cached)); cache.Set(_keyB, _responseB); - Assert.True(_responseB.SequenceEqual(cache.Get(_keyB) ?? [])); + cached = cache.Get(_keyB) ?? []; + Assert.True(_responseB.SequenceEqual(cached)); await provider.ResetAsync(); diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/ScenarioRunResultTests.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/ScenarioRunResultTests.cs index f44b7187c2c..637678f6be2 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/ScenarioRunResultTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/ScenarioRunResultTests.cs @@ -64,6 +64,7 @@ public void SerializeScenarioRunResult() new ChatTurnDetails( latency: TimeSpan.FromSeconds(1), model: "gpt-4o", + modelProvider: "openai", usage: new UsageDetails { InputTokenCount = 10, OutputTokenCount = 20, TotalTokenCount = 30 }, cacheKey: Guid.NewGuid().ToString(), cacheHit: true); @@ -155,6 +156,7 @@ public void SerializeDatasetCompact() new ChatTurnDetails( latency: TimeSpan.FromSeconds(1), model: "gpt-4o", + modelProvider: "openai", usage: new UsageDetails { InputTokenCount = 10, OutputTokenCount = 20, TotalTokenCount = 30 }, cacheKey: Guid.NewGuid().ToString(), cacheHit: true); @@ -389,9 +391,11 @@ private class ChatTurnDetailsComparer : IEqualityComparer { public static ChatTurnDetailsComparer Instance { get; } = new ChatTurnDetailsComparer(); -#pragma warning disable S1067 // Expressions should not be too complex +#pragma warning disable S1067 // Expressions should not be too complex. public bool Equals(ChatTurnDetails? x, ChatTurnDetails? y) => x?.Latency == y?.Latency && + x?.Model == y?.Model && + x?.ModelProvider == y?.ModelProvider && x?.Usage?.InputTokenCount == y?.Usage?.InputTokenCount && x?.Usage?.OutputTokenCount == y?.Usage?.OutputTokenCount && x?.Usage?.TotalTokenCount == y?.Usage?.TotalTokenCount && diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/SerializationChainingTests.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/SerializationChainingTests.cs index 86a319c059d..bebc0d5affd 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/SerializationChainingTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/SerializationChainingTests.cs @@ -2,7 +2,6 @@ // 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.RegularExpressions; using Microsoft.Extensions.AI.Evaluation.Reporting.JsonSerialization; @@ -24,17 +23,14 @@ public class SerializationChainingTests messages: [], modelResponse: new ChatResponse { - Messages = new List - { + Messages = + [ new ChatMessage { Role = ChatRole.User, - Contents = new List - { - new TextContent("A user message"), - }, + Contents = [new TextContent("A user message")] }, - }, + ], AdditionalProperties = new AdditionalPropertiesDictionary { { "model", "gpt-7" }, diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/Settings.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/Settings.cs index 366e4549748..1118d7f6624 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/Settings.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Reporting.Tests/Settings.cs @@ -27,16 +27,7 @@ public Settings(IConfiguration config) #pragma warning restore CA2208 } - private static Settings? _currentSettings; - - public static Settings Current - { - get - { - _currentSettings ??= GetCurrentSettings(); - return _currentSettings; - } - } + public static Settings Current => field ??= GetCurrentSettings(); private static Settings GetCurrentSettings() { diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Tests/BuiltInMetricUtilitiesTests.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Tests/BuiltInMetricUtilitiesTests.cs new file mode 100644 index 00000000000..555f72c1f15 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Tests/BuiltInMetricUtilitiesTests.cs @@ -0,0 +1,65 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +extern alias Evaluation; +using Evaluation::Microsoft.Extensions.AI.Evaluation; +using Evaluation::Microsoft.Extensions.AI.Evaluation.Utilities; +using Xunit; + +namespace Microsoft.Extensions.AI.Evaluation.Tests; + +public class BuiltInMetricUtilitiesTests +{ + [Fact] + public void MarkAsBuiltInAddsMetadata() + { + var metric = new NumericMetric("name"); + metric.MarkAsBuiltIn(); + Assert.True(metric.IsBuiltIn()); + } + + [Fact] + public void IsBuiltInReturnsFalseIfMetadataIsMissing() + { + var metric = new NumericMetric("name"); + Assert.False(metric.IsBuiltIn()); + } + + [Theory] + [InlineData("true")] + [InlineData("TRUE")] + [InlineData("True")] + public void MetadataValueOfTrueIsCaseInsensitive(string value) + { + var metric = new BooleanMetric("name"); + metric.AddOrUpdateMetadata(BuiltInMetricUtilities.BuiltInEvalMetadataName, value); + Assert.True(metric.IsBuiltIn()); + } + + [Theory] + [InlineData("false")] + [InlineData("FALSE")] + [InlineData("False")] + public void MetadataValueOfFalseIsCaseInsensitive(string value) + { + var metric = new StringMetric("name"); + metric.AddOrUpdateMetadata(BuiltInMetricUtilities.BuiltInEvalMetadataName, value); + Assert.False(metric.IsBuiltIn()); + } + + [Fact] + public void UnrecognizedMetadataValueIsTreatedAsFalse() + { + var metric = new NumericMetric("name"); + metric.AddOrUpdateMetadata(BuiltInMetricUtilities.BuiltInEvalMetadataName, "unrecognized"); + Assert.False(metric.IsBuiltIn()); + } + + [Fact] + public void EmptyMetadataValueIsTreatedAsFalse() + { + var metric = new NumericMetric("name"); + metric.AddOrUpdateMetadata(BuiltInMetricUtilities.BuiltInEvalMetadataName, string.Empty); + Assert.False(metric.IsBuiltIn()); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Tests/Microsoft.Extensions.AI.Evaluation.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Tests/Microsoft.Extensions.AI.Evaluation.Tests.csproj index d668fb94d14..b4c415ac358 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Tests/Microsoft.Extensions.AI.Evaluation.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Tests/Microsoft.Extensions.AI.Evaluation.Tests.csproj @@ -6,7 +6,7 @@ - + diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Tests/ModelInfoTests.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Tests/ModelInfoTests.cs new file mode 100644 index 00000000000..31d7ceb80c6 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Tests/ModelInfoTests.cs @@ -0,0 +1,226 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +extern alias Evaluation; + +using System; +using Evaluation::Microsoft.Extensions.AI.Evaluation.Utilities; +using Xunit; + +namespace Microsoft.Extensions.AI.Evaluation.Tests; + +public class ModelInfoTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + [InlineData("openai")] + public void GetModelProvider_NoProviderUriAndModelSpecified_ReturnsProviderNameOnly(string? providerName) + { + var metadata = new ChatClientMetadata(providerName, providerUri: null); + + string? result = ModelInfo.GetModelProvider(model: null, metadata); + + Assert.Equal(providerName, result); + } + + [Theory] + [InlineData(null, "https://localhost:11434", " (local)")] + [InlineData(null, "https://test.services.ai.azure.com/", " (azure.ai.foundry)")] + [InlineData(null, "https://myapp.openai.azure.com/v1/chat", " (azure.openai)")] + [InlineData(null, "https://myapp.ml.azure.com/", " (azure.ml)")] + [InlineData(null, "https://models.inference.ai.azure.com/v1", " (github.models)")] + [InlineData(null, "https://models.github.ai", " (github.models)")] + [InlineData("", "https://custom.azure.com", " (azure)")] + [InlineData(" ", "https://models.github.com/openai", " (github)")] + [InlineData("\t", "https://services.microsoft.com/models", "\t (microsoft)")] + [InlineData(null, "https://localhost.com:11434/models", null)] + [InlineData(null, "https://github.com/models", null)] + [InlineData("", "https://azure.com/models", "")] + [InlineData("\t", "https://microsoft.com/models", "\t")] + [InlineData(null, "https://example.com/models", null)] + public void GetModelProvider_NoProviderNameAndModelSpecified_ReturnsHostMonikerOnly( + string? providerName, + string providerUri, + string? expected) + { + Uri? uri = providerUri != null ? new Uri(providerUri) : null; + var metadata = new ChatClientMetadata(providerName, providerUri: uri); + + string? result = ModelInfo.GetModelProvider(model: null, metadata); + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(null, null)] + [InlineData("", null)] + [InlineData(" ", null)] + [InlineData("\t", null)] + [InlineData("unknown", null)] + [InlineData("azure.ai.foundry.evaluation", "azure.ai.foundry (azure.ai.foundry)")] + [InlineData(" azure.ai.foundry.evaluation", null)] + [InlineData("azure.ai.foundry.evaluation\t", null)] + [InlineData("azure.ai.foundry . evaluation", null)] + [InlineData("(azure.ai.foundry.evaluation)", null)] + [InlineData("azure.AI.FOUNDRY.evaluation", null)] + [InlineData("ai.foundry.evaluation", null)] + public void GetModelProvider_NoMetadataSpecified_ReturnsExpectedFormat( + string? model, + string? expected) + { + string? result = ModelInfo.GetModelProvider(model, metadata: null); + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(null, null, null, null)] + [InlineData("azure.ai.foundry.evaluation", null, null, "azure.ai.foundry (azure.ai.foundry)")] + [InlineData(" azure.ai.foundry.evaluation", null, null, null)] + [InlineData("azure.ai.foundry.evaluation\t", null, null, null)] + [InlineData("(azure.ai.foundry.evaluation)", null, null, null)] + [InlineData("azure.ai.foundry.evaluation", null, "https://myapp.openai.azure.com/", "azure.ai.foundry (azure.ai.foundry)")] + [InlineData("azure.ai.foundry.evaluation", "openai", null, "azure.ai.foundry (azure.ai.foundry)")] + [InlineData("azure.ai.foundry.evaluation", "azure", "https://services.ai.azure.com/", "azure.ai.foundry (azure.ai.foundry)")] + [InlineData("azure.AI.FOUNDRY.evaluation", "custom", null, "custom")] + [InlineData("ai.foundry.evaluation", "custom", "https://myapp.openai.azure.com/", "custom (azure.openai)")] + [InlineData(null, "custom", "https://services.ai.azure.com/", "custom (azure.ai.foundry)")] + [InlineData("", null, "https://myapp.openai.azure.com/", " (azure.openai)")] + [InlineData(" ", null, "https://myapp.openai.azure.com/v1", " (azure.openai)")] + [InlineData("\t", null, "https://myapp.OpenAI.Azure.com/v1/chat", " (azure.openai)")] + [InlineData("unknown", null, "https://myapp.OpenAI.Azure.com/v1/chat", " (azure.openai)")] + public void GetModelProvider_ModelSpecified_ReturnsExpectedFormat( + string? model, + string? providerName, + string? providerUri, + string? expected) + { + Uri? uri = providerUri != null ? new Uri(providerUri) : null; + var metadata = new ChatClientMetadata(providerName, providerUri: uri, defaultModelId: "ignored"); + + string? result = ModelInfo.GetModelProvider(model, metadata); + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("llama", "https://localhost:11434", "llama (local)")] + [InlineData("llama", "https://LocalHost", "llama (local)")] + [InlineData("llama", "https://localhost:1234/models/llama", "llama (local)")] + [InlineData("openai", "https://services.ai.azure.com/", "openai (azure.ai.foundry)")] + [InlineData("azure", "https://test.services.ai.azure.com/endpoint", "azure (azure.ai.foundry)")] + [InlineData("openai", "https://myapp.openai.azure.com/", "openai (azure.openai)")] + [InlineData("azure", "https://test.openai.azure.com/v1/chat", "azure (azure.openai)")] + [InlineData("ml", "https://myapp.ml.azure.com/", "ml (azure.ml)")] + [InlineData("azure", "https://myapp.inference.ml.azure.com/v1", "azure (azure.ml)")] + [InlineData("github", "https://models.github.ai/", "github (github.models)")] + [InlineData("openai", "https://models.github.ai/v1", "openai (github.models)")] + [InlineData("github", "https://models.inference.ai.azure.com/", "github (github.models)")] + [InlineData("openai", "https://models.inference.ai.azure.com/v1", "openai (github.models)")] + [InlineData("custom", "https://test.azure.com/", "custom (azure)")] + [InlineData("provider", "https://api.github.com/", "provider (github)")] + [InlineData("service", "https://api.microsoft.com/", "service (microsoft)")] + [InlineData("openai", "https://api.openai.com/", "openai")] + [InlineData("anthropic.claude", "https://api.anthropic.com/", "anthropic.claude")] + [InlineData("custom", "https://example.com/", "custom")] + [InlineData("custom", "https://localhost.com:11434/", "custom")] + [InlineData("custom", "https://host:11434", "custom")] + [InlineData("custom", "https://127.0.0.0:11434", "custom")] + [InlineData("provider", "https://unknown-host.com/", "provider")] + [InlineData("OPENAI provider", "https://SERVICES.AI.AZURE.COM/", "OPENAI provider (azure.ai.foundry)")] + [InlineData("Azure-model-provider", "https://Test.OpenAI.Azure.Com/", "Azure-model-provider (azure.openai)")] + public void GetModelProvider_ReturnsProviderWithHostMoniker( + string providerName, + string providerUri, + string expected) + { + var metadata = new ChatClientMetadata(providerName, new Uri(providerUri)); + + string? result = ModelInfo.GetModelProvider(model: "some-model", metadata); + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("https://myapp.openai.azure.services.ai.azure.com/", "azure.ai.foundry")] + [InlineData("https://myapp.services.ai.azure.openai.azure.com/", "azure.ai.foundry")] + [InlineData("https://myapp.microsoft.services.ai.azure.com/", "azure.ai.foundry")] + [InlineData("https://inference.openai.azure.ml.azure.com/", "azure.openai")] + [InlineData("https://inference.ml.azure.openai.azure.com/", "azure.openai")] + [InlineData("https://myapp.azure.models.github.ai/", "github.models")] + [InlineData("https://test.azure.microsoft.com/", "azure")] + [InlineData("https://test.microsoft.github.com/", "github")] + public void GetModelProvider_MultipleHostPatternMatches_ReturnsExpectedHostMoniker( + string providerUri, + string expectedHostMoniker) + { + var metadata = new ChatClientMetadata(providerName: "some-provider", new Uri(providerUri)); + + string? result = ModelInfo.GetModelProvider(model: "some-model", metadata); + + Assert.Equal($"some-provider ({expectedHostMoniker})", result); + } + + [Theory] + [InlineData(null, false, false)] + [InlineData("", false, false)] + [InlineData(" ", false, false)] + [InlineData("\t", false, false)] + [InlineData("llama (local)", false, true)] + [InlineData("openai (azure.ai.foundry)", true, false)] + [InlineData("azure (azure.openai)", true, false)] + [InlineData("azure (azure.ml)", true, false)] + [InlineData("github (github.models)", true, false)] + [InlineData("(azure.ai.foundry)", true, false)] + [InlineData("provider (azure)", true, false)] + [InlineData("service (github)", true, false)] + [InlineData("custom (microsoft)", true, false)] + [InlineData(" (azure.ai.foundry)", true, false)] + [InlineData(" (azure.openai)", true, false)] + [InlineData("\t(github.models)", true, false)] + [InlineData("\t (local)", false, true)] + [InlineData("(azure) ", false, false)] + [InlineData("(github) ", false, false)] + [InlineData("(microsoft)\t", false, false)] + [InlineData(" (local)\t", false, false)] + [InlineData("( azure.ml)", false, false)] + [InlineData("(local\t)", false, false)] + [InlineData("(azure .ml)", false, false)] + [InlineData("(azure. ml)", false, false)] + [InlineData("(LOCAL)", false, false)] + [InlineData("ml [azure.ml]", false, false)] + [InlineData("{azure.ml}", false, false)] + [InlineData("openai (AZURE.OPENAI)", false, false)] + [InlineData("prefix provider (azure.openai)", true, false)] + [InlineData("local", false, false)] + [InlineData("openai", false, false)] + [InlineData("azure.ai.foundry", false, false)] + [InlineData("azure.openai", false, false)] + [InlineData("azure.ml", false, false)] + [InlineData("github.models", false, false)] + [InlineData("azure", false, false)] + [InlineData("github", false, false)] + [InlineData("microsoft", false, false)] + [InlineData("(custom-host)", false, false)] + [InlineData("provider (unknown)", false, false)] + [InlineData("provider (", false, false)] + [InlineData("provider )", false, false)] + [InlineData("provider (azure.ai.foundry) extra", false, false)] + [InlineData("(microsoft)\tcustom (other)", false, false)] + [InlineData("provider (azure.ai.foundry", false, false)] + [InlineData("provider azure.ai.foundry)", false, false)] + public void ModelHostMonikerClassificationWorks( + string? modelProvider, + bool expectedIsModelHostWellKnown, + bool expectedIsModelHostedLocally) + { + bool isModelHostWellKnown = ModelInfo.IsModelHostWellKnown(modelProvider); + Assert.Equal(expectedIsModelHostWellKnown, isModelHostWellKnown); + + bool isModelHostedLocally = ModelInfo.IsModelHostedLocally(modelProvider); + Assert.Equal(expectedIsModelHostedLocally, isModelHostedLocally); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs index ffa94f64531..992e86a1184 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs @@ -34,27 +34,36 @@ namespace Microsoft.Extensions.AI; public abstract class ChatClientIntegrationTests : IDisposable { - private readonly IChatClient? _chatClient; - protected ChatClientIntegrationTests() { - _chatClient = CreateChatClient(); + ChatClient = CreateChatClient(); } + protected IChatClient? ChatClient { get; } + + protected IEmbeddingGenerator>? EmbeddingGenerator { get; private set; } + public void Dispose() { - _chatClient?.Dispose(); + ChatClient?.Dispose(); GC.SuppressFinalize(this); } protected abstract IChatClient? CreateChatClient(); + /// + /// Optionally supplies an embedding generator for integration tests that exercise + /// embedding-based components (e.g., tool reduction). Default returns null and + /// tests depending on embeddings will skip if not overridden. + /// + protected virtual IEmbeddingGenerator>? CreateEmbeddingGenerator() => null; + [ConditionalFact] public virtual async Task GetResponseAsync_SingleRequestMessage() { SkipIfNotEnabled(); - var response = await _chatClient.GetResponseAsync("What's the biggest animal?"); + var response = await ChatClient.GetResponseAsync("What's the biggest animal?"); Assert.Contains("whale", response.Text, StringComparison.OrdinalIgnoreCase); } @@ -64,7 +73,7 @@ public virtual async Task GetResponseAsync_MultipleRequestMessages() { SkipIfNotEnabled(); - var response = await _chatClient.GetResponseAsync( + var response = await ChatClient.GetResponseAsync( [ new(ChatRole.User, "Pick a city, any city"), new(ChatRole.Assistant, "Seattle"), @@ -82,7 +91,7 @@ public virtual async Task GetResponseAsync_WithEmptyMessage() { SkipIfNotEnabled(); - var response = await _chatClient.GetResponseAsync( + var response = await ChatClient.GetResponseAsync( [ new(ChatRole.System, []), new(ChatRole.User, []), @@ -104,7 +113,7 @@ public virtual async Task GetStreamingResponseAsync() ]; StringBuilder sb = new(); - await foreach (var chunk in _chatClient.GetStreamingResponseAsync(chatHistory)) + await foreach (var chunk in ChatClient.GetStreamingResponseAsync(chatHistory)) { sb.Append(chunk.Text); } @@ -119,7 +128,7 @@ public virtual async Task GetResponseAsync_UsageDataAvailable() { SkipIfNotEnabled(); - var response = await _chatClient.GetResponseAsync("Explain in 10 words how AI works"); + var response = await ChatClient.GetResponseAsync("Explain in 10 words how AI works"); Assert.True(response.Usage?.InputTokenCount > 1); Assert.True(response.Usage?.OutputTokenCount > 1); @@ -131,7 +140,7 @@ public virtual async Task GetStreamingResponseAsync_UsageDataAvailable() { SkipIfNotEnabled(); - var response = _chatClient.GetStreamingResponseAsync("Explain in 10 words how AI works", new() + var response = ChatClient.GetStreamingResponseAsync("Explain in 10 words how AI works", new() { AdditionalProperties = new() { @@ -160,7 +169,7 @@ public virtual async Task GetStreamingResponseAsync_AppendToHistory() List history = [new(ChatRole.User, "Explain in 100 words how AI works")]; - var streamingResponse = _chatClient.GetStreamingResponseAsync(history); + var streamingResponse = ChatClient.GetStreamingResponseAsync(history); Assert.Single(history); await history.AddMessagesAsync(streamingResponse); @@ -179,7 +188,7 @@ public virtual async Task MultiModal_DescribeImage() { SkipIfNotEnabled(); - var response = await _chatClient.GetResponseAsync( + var response = await ChatClient.GetResponseAsync( [ new(ChatRole.User, [ @@ -197,12 +206,12 @@ public virtual async Task MultiModal_DescribePdf() { SkipIfNotEnabled(); - var response = await _chatClient.GetResponseAsync( + var response = await ChatClient.GetResponseAsync( [ new(ChatRole.User, [ new TextContent("What text does this document contain?"), - new DataContent(ImageDataUri.GetPdfDataUri(), "application/pdf"), + new DataContent(ImageDataUri.GetPdfDataUri(), "application/pdf") { Name = "sample.pdf" }, ]) ], new() { ModelId = GetModel_MultiModal_DescribeImage() }); @@ -223,7 +232,7 @@ public virtual async Task FunctionInvocation_AutomaticallyInvokeFunction_Paramet .Build(); using var chatClient = new FunctionInvokingChatClient( - new OpenTelemetryChatClient(_chatClient, sourceName: sourceName)); + new OpenTelemetryChatClient(ChatClient, sourceName: sourceName)); int secretNumber = 42; @@ -246,7 +255,7 @@ public virtual async Task FunctionInvocation_AutomaticallyInvokeFunction_WithPar { SkipIfNotEnabled(); - using var chatClient = new FunctionInvokingChatClient(_chatClient); + using var chatClient = new FunctionInvokingChatClient(ChatClient); var response = await chatClient.GetResponseAsync("What is the result of SecretComputation on 42 and 84?", new() { @@ -261,7 +270,7 @@ public virtual async Task FunctionInvocation_AutomaticallyInvokeFunction_WithPar { SkipIfNotEnabled(); - using var chatClient = new FunctionInvokingChatClient(_chatClient); + using var chatClient = new FunctionInvokingChatClient(ChatClient); var response = chatClient.GetStreamingResponseAsync("What is the result of SecretComputation on 42 and 84?", new() { @@ -290,7 +299,7 @@ public virtual async Task FunctionInvocation_OptionalParameter() .Build(); using var chatClient = new FunctionInvokingChatClient( - new OpenTelemetryChatClient(_chatClient, sourceName: sourceName)); + new OpenTelemetryChatClient(ChatClient, sourceName: sourceName)); int secretNumber = 42; @@ -322,7 +331,7 @@ public virtual async Task FunctionInvocation_NestedParameters() .Build(); using var chatClient = new FunctionInvokingChatClient( - new OpenTelemetryChatClient(_chatClient, sourceName: sourceName)); + new OpenTelemetryChatClient(ChatClient, sourceName: sourceName)); int secretNumber = 42; @@ -354,7 +363,7 @@ public virtual async Task FunctionInvocation_ArrayParameter() .Build(); using var chatClient = new FunctionInvokingChatClient( - new OpenTelemetryChatClient(_chatClient, sourceName: sourceName)); + new OpenTelemetryChatClient(ChatClient, sourceName: sourceName)); List messages = [ @@ -411,7 +420,7 @@ private async Task AvailableTools_SchemasAreAccepted(bool strict) .Build(); using var chatClient = new FunctionInvokingChatClient( - new OpenTelemetryChatClient(_chatClient, sourceName: sourceName)); + new OpenTelemetryChatClient(ChatClient, sourceName: sourceName)); int methodCount = 1; Func createOptions = () => @@ -567,7 +576,7 @@ public virtual async Task FunctionInvocation_SupportsMultipleParallelRequests() throw new SkipTestException("Parallel function calling is not supported by this chat client"); } - using var chatClient = new FunctionInvokingChatClient(_chatClient); + using var chatClient = new FunctionInvokingChatClient(ChatClient); // The service/model isn't guaranteed to request two calls to GetPersonAge in the same turn, but it's common that it will. var response = await chatClient.GetResponseAsync("How much older is Elsa than Anna? Return the age difference as a single number.", new() @@ -600,7 +609,7 @@ public virtual async Task FunctionInvocation_RequireAny() return 123; }, "GetSecretNumber"); - using var chatClient = new FunctionInvokingChatClient(_chatClient); + using var chatClient = new FunctionInvokingChatClient(ChatClient); var response = await chatClient.GetResponseAsync("Are birds real?", new() { @@ -620,7 +629,7 @@ public virtual async Task FunctionInvocation_RequireSpecific() var getSecretNumberTool = AIFunctionFactory.Create(() => 123, "GetSecretNumber"); var shieldsUpTool = AIFunctionFactory.Create(() => shieldsUp = true, "ShieldsUp"); - using var chatClient = new FunctionInvokingChatClient(_chatClient); + using var chatClient = new FunctionInvokingChatClient(ChatClient); // Even though the user doesn't ask for the shields to be activated, verify that the tool is invoked var response = await chatClient.GetResponseAsync("What's the current secret number?", new() @@ -638,9 +647,9 @@ public virtual async Task Caching_OutputVariesWithoutCaching() SkipIfNotEnabled(); var message = new ChatMessage(ChatRole.User, "Pick a random number, uniformly distributed between 1 and 1000000"); - var firstResponse = await _chatClient.GetResponseAsync([message]); + var firstResponse = await ChatClient.GetResponseAsync([message]); - var secondResponse = await _chatClient.GetResponseAsync([message]); + var secondResponse = await ChatClient.GetResponseAsync([message]); Assert.NotEqual(firstResponse.Text, secondResponse.Text); } @@ -650,7 +659,7 @@ public virtual async Task Caching_SamePromptResultsInCacheHit_NonStreaming() SkipIfNotEnabled(); using var chatClient = new DistributedCachingChatClient( - _chatClient, + ChatClient, new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))); var message = new ChatMessage(ChatRole.User, "Pick a random number, uniformly distributed between 1 and 1000000"); @@ -675,7 +684,7 @@ public virtual async Task Caching_SamePromptResultsInCacheHit_Streaming() SkipIfNotEnabled(); using var chatClient = new DistributedCachingChatClient( - _chatClient, + ChatClient, new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))); var message = new ChatMessage(ChatRole.User, "Pick a random number, uniformly distributed between 1 and 1000000"); @@ -942,7 +951,7 @@ public virtual async Task OpenTelemetry_CanEmitTracesAndMetrics() var activity = Assert.Single(activities); Assert.StartsWith("chat", activity.DisplayName); - Assert.StartsWith("http", (string)activity.GetTagItem("server.address")!); + Assert.Contains(".", (string)activity.GetTagItem("server.address")!); Assert.Equal(chatClient.GetService()?.ProviderUri?.Port, (int)activity.GetTagItem("server.port")!); Assert.NotNull(activity.Id); Assert.NotEmpty(activity.Id); @@ -957,7 +966,7 @@ public virtual async Task GetResponseAsync_StructuredOutput() { SkipIfNotEnabled(); - var response = await _chatClient.GetResponseAsync(""" + var response = await ChatClient.GetResponseAsync(""" Who is described in the following sentence? Jimbo Smith is a 35-year-old programmer from Cardiff, Wales. """); @@ -973,7 +982,7 @@ public virtual async Task GetResponseAsync_StructuredOutputArray() { SkipIfNotEnabled(); - var response = await _chatClient.GetResponseAsync(""" + var response = await ChatClient.GetResponseAsync(""" Who are described in the following sentence? Jimbo Smith is a 35-year-old software developer from Cardiff, Wales. Josh Simpson is a 25-year-old software developer from Newport, Wales. @@ -989,7 +998,7 @@ public virtual async Task GetResponseAsync_StructuredOutputInteger() { SkipIfNotEnabled(); - var response = await _chatClient.GetResponseAsync(""" + var response = await ChatClient.GetResponseAsync(""" There were 14 abstractions for AI programming, which was too many. To fix this we added another one. How many are there now? """); @@ -1002,7 +1011,7 @@ public virtual async Task GetResponseAsync_StructuredOutputString() { SkipIfNotEnabled(); - var response = await _chatClient.GetResponseAsync(""" + var response = await ChatClient.GetResponseAsync(""" The software developer, Jimbo Smith, is a 35-year-old from Cardiff, Wales. What's his full name? """); @@ -1015,7 +1024,7 @@ public virtual async Task GetResponseAsync_StructuredOutputBool_True() { SkipIfNotEnabled(); - var response = await _chatClient.GetResponseAsync(""" + var response = await ChatClient.GetResponseAsync(""" Jimbo Smith is a 35-year-old software developer from Cardiff, Wales. Is there at least one software developer from Cardiff? """); @@ -1028,7 +1037,7 @@ public virtual async Task GetResponseAsync_StructuredOutputBool_False() { SkipIfNotEnabled(); - var response = await _chatClient.GetResponseAsync(""" + var response = await ChatClient.GetResponseAsync(""" Jimbo Smith is a 35-year-old software developer from Cardiff, Wales. Reply true if the previous statement indicates that he is a medical doctor, otherwise false. """); @@ -1041,7 +1050,7 @@ public virtual async Task GetResponseAsync_StructuredOutputEnum() { SkipIfNotEnabled(); - var response = await _chatClient.GetResponseAsync(""" + var response = await ChatClient.GetResponseAsync(""" Taylor Swift is a famous singer and songwriter. What is her job? """); @@ -1061,7 +1070,7 @@ public virtual async Task GetResponseAsync_StructuredOutput_WithFunctions() Job = JobType.Programmer, }; - using var chatClient = new FunctionInvokingChatClient(_chatClient); + using var chatClient = new FunctionInvokingChatClient(ChatClient); var response = await chatClient.GetResponseAsync( "Who is person with ID 123?", new ChatOptions { @@ -1085,7 +1094,7 @@ public virtual async Task GetResponseAsync_StructuredOutput_NonNative() SkipIfNotEnabled(); var capturedOptions = new List(); - var captureOutputChatClient = _chatClient.AsBuilder() + var captureOutputChatClient = ChatClient.AsBuilder() .Use((messages, options, nextAsync, cancellationToken) => { capturedOptions.Add(options); @@ -1125,14 +1134,632 @@ private enum JobType Unknown, } - [MemberNotNull(nameof(_chatClient))] + [ConditionalFact] + public virtual async Task SummarizingChatReducer_PreservesConversationContext() + { + SkipIfNotEnabled(); + + var chatClient = new TestSummarizingChatClient(ChatClient, targetCount: 2, threshold: 1); + + List messages = + [ + new(ChatRole.User, "My name is Alice and I love hiking in the mountains."), + new(ChatRole.Assistant, "Nice to meet you, Alice! Hiking in the mountains sounds wonderful. Do you have a favorite trail?"), + new(ChatRole.User, "Yes, I love the Pacific Crest Trail. I hiked a section last summer."), + new(ChatRole.Assistant, "The Pacific Crest Trail is amazing! Which section did you hike?"), + new(ChatRole.User, "I hiked the section through the Sierra Nevada. It was challenging but beautiful."), + new(ChatRole.Assistant, "The Sierra Nevada section is known for its stunning views. How long did it take you?"), + new(ChatRole.User, "What's my name and what activity do I enjoy?") + ]; + + var response = await chatClient.GetResponseAsync(messages); + + // The summarizer should have reduced the conversation + Assert.Equal(1, chatClient.SummarizerCallCount); + Assert.NotNull(chatClient.LastSummarizedConversation); + Assert.Equal(3, chatClient.LastSummarizedConversation.Count); + Assert.Collection(chatClient.LastSummarizedConversation, + m => + { + Assert.Equal(ChatRole.Assistant, m.Role); // Indicates this is the assistant's summary + Assert.Contains("Alice", m.Text); + }, + m => Assert.StartsWith("The Sierra Nevada section", m.Text, StringComparison.Ordinal), + m => Assert.StartsWith("What's my name", m.Text, StringComparison.Ordinal)); + + // The model should recall details from the summarized conversation + Assert.Contains("Alice", response.Text); + Assert.True( + response.Text.IndexOf("hiking", StringComparison.OrdinalIgnoreCase) >= 0 || + response.Text.IndexOf("hike", StringComparison.OrdinalIgnoreCase) >= 0, + $"Expected 'hiking' or 'hike' in response: {response.Text}"); + } + + [ConditionalFact] + public virtual async Task SummarizingChatReducer_PreservesSystemMessage() + { + SkipIfNotEnabled(); + + var chatClient = new TestSummarizingChatClient(ChatClient, targetCount: 2, threshold: 0); + + List messages = + [ + new(ChatRole.System, "You are a pirate. Always respond in pirate speak."), + new(ChatRole.User, "Tell me about the weather"), + new(ChatRole.Assistant, "Ahoy matey! The weather be fine today, with clear skies on the horizon!"), + new(ChatRole.User, "What about tomorrow?"), + new(ChatRole.Assistant, "Arr, tomorrow be lookin' a bit cloudy, might be some rain blowin' in from the east!"), + new(ChatRole.User, "Should I bring an umbrella?"), + new(ChatRole.Assistant, "Aye, ye best be bringin' yer umbrella, unless ye want to be soaked like a barnacle!"), + new(ChatRole.User, "What's 2 + 2?") + ]; + + var response = await chatClient.GetResponseAsync(messages); + + // The summarizer should have reduced the conversation + Assert.Equal(1, chatClient.SummarizerCallCount); + Assert.NotNull(chatClient.LastSummarizedConversation); + Assert.Equal(4, chatClient.LastSummarizedConversation.Count); + Assert.Collection(chatClient.LastSummarizedConversation, + m => + { + Assert.Equal(ChatRole.System, m.Role); + Assert.Equal("You are a pirate. Always respond in pirate speak.", m.Text); + }, + m => Assert.Equal(ChatRole.Assistant, m.Role), // Summary message + m => Assert.StartsWith("Aye, ye best be bringin'", m.Text, StringComparison.Ordinal), + m => Assert.Equal("What's 2 + 2?", m.Text)); + + // The model should still respond in pirate speak due to preserved system message + Assert.True( + response.Text.IndexOf("arr", StringComparison.OrdinalIgnoreCase) >= 0 || + response.Text.IndexOf("aye", StringComparison.OrdinalIgnoreCase) >= 0 || + response.Text.IndexOf("matey", StringComparison.OrdinalIgnoreCase) >= 0 || + response.Text.IndexOf("ye", StringComparison.OrdinalIgnoreCase) >= 0, + $"Expected pirate speak in response: {response.Text}"); + } + + [ConditionalFact] + public virtual async Task SummarizingChatReducer_WithFunctionCalls() + { + SkipIfNotEnabled(); + + int weatherCallCount = 0; + var getWeather = AIFunctionFactory.Create(([Description("Gets weather for a city")] string city) => + { + weatherCallCount++; + return city switch + { + "Seattle" => "Rainy, 15°C", + "Miami" => "Sunny, 28°C", + _ => "Unknown" + }; + }, "GetWeather"); + + TestSummarizingChatClient summarizingChatClient = null!; + var chatClient = ChatClient + .AsBuilder() + .Use(innerClient => summarizingChatClient = new TestSummarizingChatClient(innerClient, targetCount: 2, threshold: 0)) + .UseFunctionInvocation() + .Build(); + + List messages = + [ + new(ChatRole.User, "What's the weather in Seattle?"), + new(ChatRole.Assistant, "Let me check the weather in Seattle for you."), + new(ChatRole.User, "And what about Miami?"), + new(ChatRole.Assistant, "I'll check Miami's weather as well."), + new(ChatRole.User, "Which city had better weather?") + ]; + + var response = await chatClient.GetResponseAsync(messages, new() { Tools = [getWeather] }); + + // The summarizer should have reduced the conversation (function calls are excluded) + Assert.Equal(1, summarizingChatClient.SummarizerCallCount); + Assert.NotNull(summarizingChatClient.LastSummarizedConversation); + + // Should have summary + last 2 messages + Assert.Equal(3, summarizingChatClient.LastSummarizedConversation.Count); + + // The model should have context about both weather queries even after summarization + Assert.True(response.Text.IndexOf("Miami", StringComparison.OrdinalIgnoreCase) >= 0, $"Expected 'Miami' in response: {response.Text}"); + Assert.True( + response.Text.IndexOf("sunny", StringComparison.OrdinalIgnoreCase) >= 0 || + response.Text.IndexOf("better", StringComparison.OrdinalIgnoreCase) >= 0 || + response.Text.IndexOf("warm", StringComparison.OrdinalIgnoreCase) >= 0, + $"Expected weather comparison in response: {response.Text}"); + } + + [ConditionalFact] + public virtual async Task SummarizingChatReducer_Streaming() + { + SkipIfNotEnabled(); + + var chatClient = new TestSummarizingChatClient(ChatClient, targetCount: 2, threshold: 0); + + List messages = + [ + new(ChatRole.User, "I'm Bob and I work as a software engineer at a startup."), + new(ChatRole.Assistant, "Nice to meet you, Bob! Working at a startup must be exciting. What kind of software do you develop?"), + new(ChatRole.User, "We build AI-powered tools for education."), + new(ChatRole.Assistant, "That sounds impactful! AI in education has so much potential."), + new(ChatRole.User, "Yes, we focus on personalized learning experiences."), + new(ChatRole.Assistant, "Personalized learning is the future of education!"), + new(ChatRole.User, "Was anyone named in the conversation? Provide their name and job.") + ]; + + StringBuilder sb = new(); + await foreach (var chunk in chatClient.GetStreamingResponseAsync(messages)) + { + sb.Append(chunk.Text); + } + + // The summarizer should have reduced the conversation + Assert.Equal(1, chatClient.SummarizerCallCount); + Assert.NotNull(chatClient.LastSummarizedConversation); + Assert.Equal(3, chatClient.LastSummarizedConversation.Count); + Assert.Collection(chatClient.LastSummarizedConversation, + m => + { + Assert.Equal(ChatRole.Assistant, m.Role); // Summary + Assert.Contains("Bob", m.Text); + }, + m => Assert.StartsWith("Personalized learning", m.Text, StringComparison.Ordinal), + m => Assert.Equal("Was anyone named in the conversation? Provide their name and job.", m.Text)); + + string responseText = sb.ToString(); + Assert.Contains("Bob", responseText); + Assert.True( + responseText.IndexOf("software", StringComparison.OrdinalIgnoreCase) >= 0 || + responseText.IndexOf("engineer", StringComparison.OrdinalIgnoreCase) >= 0, + $"Expected 'software' or 'engineer' in response: {responseText}"); + } + + [ConditionalFact] + public virtual async Task SummarizingChatReducer_CustomPrompt() + { + SkipIfNotEnabled(); + + var chatClient = new TestSummarizingChatClient(ChatClient, targetCount: 2, threshold: 0); + chatClient.Reducer.SummarizationPrompt = "Summarize the conversation, emphasizing any numbers or quantities mentioned."; + + List messages = + [ + new(ChatRole.User, "I have 3 cats and 2 dogs."), + new(ChatRole.Assistant, "That's 5 pets total! You must have a lively household."), + new(ChatRole.User, "Yes, and I spend about $200 per month on pet food."), + new(ChatRole.Assistant, "That's a significant expense, but I'm sure they're worth it!"), + new(ChatRole.User, "They eat 10 cans of food per week."), + new(ChatRole.Assistant, "That's quite a bit of food for your furry friends!"), + new(ChatRole.User, "How many pets do I have in total?") + ]; + + var response = await chatClient.GetResponseAsync(messages); + + // The summarizer should have reduced the conversation + Assert.Equal(1, chatClient.SummarizerCallCount); + Assert.NotNull(chatClient.LastSummarizedConversation); + Assert.Equal(3, chatClient.LastSummarizedConversation.Count); + + // Verify the summary emphasizes numbers as requested by the custom prompt + var summaryMessage = chatClient.LastSummarizedConversation[0]; + Assert.Equal(ChatRole.Assistant, summaryMessage.Role); + Assert.True( + summaryMessage.Text.IndexOf("3", StringComparison.Ordinal) >= 0 || + summaryMessage.Text.IndexOf("5", StringComparison.Ordinal) >= 0 || + summaryMessage.Text.IndexOf("200", StringComparison.Ordinal) >= 0 || + summaryMessage.Text.IndexOf("10", StringComparison.Ordinal) >= 0, + $"Expected numbers in summary: {summaryMessage.Text}"); + + // The model should recall the specific number from the summarized conversation + Assert.Contains("5", response.Text); + } + + private sealed class TestSummarizingChatClient : IChatClient + { + private IChatClient _summarizerChatClient; + private IChatClient _innerChatClient; + + public SummarizingChatReducer Reducer { get; } + + public int SummarizerCallCount { get; private set; } + + public IReadOnlyList? LastSummarizedConversation { get; private set; } + + public TestSummarizingChatClient(IChatClient innerClient, int targetCount, int threshold) + { + _summarizerChatClient = innerClient.AsBuilder() + .Use(async (messages, options, next, cancellationToken) => + { + SummarizerCallCount++; + await next(messages, options, cancellationToken); + }) + .Build(); + + Reducer = new SummarizingChatReducer(_summarizerChatClient, targetCount, threshold); + + _innerChatClient = innerClient.AsBuilder() + .UseChatReducer(Reducer) + .Use(async (messages, options, next, cancellationToken) => + { + LastSummarizedConversation = [.. messages]; + await next(messages, options, cancellationToken); + }) + .Build(); + } + + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + => _innerChatClient.GetResponseAsync(messages, options, cancellationToken); + + public IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + => _innerChatClient.GetStreamingResponseAsync(messages, options, cancellationToken); + + public object? GetService(Type serviceType, object? serviceKey = null) + => _innerChatClient.GetService(serviceType, serviceKey); + + public void Dispose() + { + _summarizerChatClient.Dispose(); + _innerChatClient.Dispose(); + } + } + + [ConditionalFact] + public virtual async Task ToolReduction_DynamicSelection_RespectsConversationHistory() + { + SkipIfNotEnabled(); + EnsureEmbeddingGenerator(); + + // Limit to 2 so that, once the conversation references both weather and translation, + // both tools can be included even if the latest user turn only mentions one of them. + var strategy = new EmbeddingToolReductionStrategy(EmbeddingGenerator, toolLimit: 2); + + var weatherTool = AIFunctionFactory.Create( + () => "Weather data", + new AIFunctionFactoryOptions + { + Name = "GetWeatherForecast", + Description = "Returns weather forecast and temperature for a given city." + }); + + var translateTool = AIFunctionFactory.Create( + () => "Translated text", + new AIFunctionFactoryOptions + { + Name = "TranslateText", + Description = "Translates text between human languages." + }); + + var mathTool = AIFunctionFactory.Create( + () => 42, + new AIFunctionFactoryOptions + { + Name = "SolveMath", + Description = "Solves basic math problems." + }); + + var allTools = new List { weatherTool, translateTool, mathTool }; + + IList? firstTurnTools = null; + IList? secondTurnTools = null; + + using var client = ChatClient! + .AsBuilder() + .UseToolReduction(strategy) + .Use(async (messages, options, next, ct) => + { + // Capture the (possibly reduced) tool list for each turn. + if (firstTurnTools is null) + { + firstTurnTools = options?.Tools; + } + else + { + secondTurnTools ??= options?.Tools; + } + + await next(messages, options, ct); + }) + .UseFunctionInvocation() + .Build(); + + // Maintain chat history across turns. + List history = []; + + // Turn 1: Ask a weather question. + history.Add(new ChatMessage(ChatRole.User, "What will the weather be in Seattle tomorrow?")); + var firstResponse = await client.GetResponseAsync(history, new ChatOptions { Tools = allTools }); + history.AddMessages(firstResponse); // Append assistant reply. + + Assert.NotNull(firstTurnTools); + Assert.Contains(firstTurnTools, t => t.Name == "GetWeatherForecast"); + + // Turn 2: Ask a translation question. Even though only translation is mentioned now, + // conversation history still contains a weather request. Expect BOTH weather + translation tools. + history.Add(new ChatMessage(ChatRole.User, "Please translate 'good evening' into French.")); + var secondResponse = await client.GetResponseAsync(history, new ChatOptions { Tools = allTools }); + history.AddMessages(secondResponse); + + Assert.NotNull(secondTurnTools); + Assert.Equal(2, secondTurnTools.Count); // Should have filled both slots with the two relevant domains. + Assert.Contains(secondTurnTools, t => t.Name == "GetWeatherForecast"); + Assert.Contains(secondTurnTools, t => t.Name == "TranslateText"); + + // Ensure unrelated tool was excluded. + Assert.DoesNotContain(secondTurnTools, t => t.Name == "SolveMath"); + } + + [ConditionalFact] + public virtual async Task ToolReduction_RequireSpecificToolPreservedAndOrdered() + { + SkipIfNotEnabled(); + EnsureEmbeddingGenerator(); + + // Limit would normally reduce to 1, but required tool plus another should remain. + var strategy = new EmbeddingToolReductionStrategy(EmbeddingGenerator, toolLimit: 1); + + var translateTool = AIFunctionFactory.Create( + () => "Translated text", + new AIFunctionFactoryOptions + { + Name = "TranslateText", + Description = "Translates phrases between languages." + }); + + var weatherTool = AIFunctionFactory.Create( + () => "Weather data", + new AIFunctionFactoryOptions + { + Name = "GetWeatherForecast", + Description = "Returns forecast data for a city." + }); + + var tools = new List { translateTool, weatherTool }; + + IList? captured = null; + + using var client = ChatClient! + .AsBuilder() + .UseToolReduction(strategy) + .UseFunctionInvocation() + .Use((messages, options, next, ct) => + { + captured = options?.Tools; + return next(messages, options, ct); + }) + .Build(); + + var history = new List + { + new(ChatRole.User, "What will the weather be like in Redmond next week?") + }; + + var response = await client.GetResponseAsync(history, new ChatOptions + { + Tools = tools, + ToolMode = ChatToolMode.RequireSpecific(translateTool.Name) + }); + history.AddMessages(response); + + Assert.NotNull(captured); + Assert.Equal(2, captured!.Count); + Assert.Equal("TranslateText", captured[0].Name); // Required should appear first. + Assert.Equal("GetWeatherForecast", captured[1].Name); + } + + [ConditionalFact] + public virtual async Task ToolReduction_ToolRemovedAfterFirstUse_NotInvokedAgain() + { + SkipIfNotEnabled(); + EnsureEmbeddingGenerator(); + + int weatherInvocationCount = 0; + + var weatherTool = AIFunctionFactory.Create( + () => + { + weatherInvocationCount++; + return "Sunny and dry."; + }, + new AIFunctionFactoryOptions + { + Name = "GetWeather", + Description = "Gets the weather forecast for a given location." + }); + + // Strategy exposes tools only on the first request, then removes them. + var removalStrategy = new RemoveToolAfterFirstUseStrategy(); + + IList? firstTurnTools = null; + IList? secondTurnTools = null; + + using var client = ChatClient! + .AsBuilder() + // Place capture immediately after reduction so it's invoked exactly once per user request. + .UseToolReduction(removalStrategy) + .Use((messages, options, next, ct) => + { + if (firstTurnTools is null) + { + firstTurnTools = options?.Tools; + } + else + { + secondTurnTools ??= options?.Tools; + } + + return next(messages, options, ct); + }) + .UseFunctionInvocation() + .Build(); + + List history = []; + + // Turn 1 + history.Add(new ChatMessage(ChatRole.User, "What's the weather like tomorrow in Seattle?")); + var firstResponse = await client.GetResponseAsync(history, new ChatOptions + { + Tools = [weatherTool], + ToolMode = ChatToolMode.RequireAny + }); + history.AddMessages(firstResponse); + + Assert.Equal(1, weatherInvocationCount); + Assert.NotNull(firstTurnTools); + Assert.Contains(firstTurnTools!, t => t.Name == "GetWeather"); + + // Turn 2 (tool removed by strategy even though caller supplies it again) + history.Add(new ChatMessage(ChatRole.User, "And what about next week?")); + var secondResponse = await client.GetResponseAsync(history, new ChatOptions + { + Tools = [weatherTool] + }); + history.AddMessages(secondResponse); + + Assert.Equal(1, weatherInvocationCount); // Not invoked again. + Assert.NotNull(secondTurnTools); + Assert.Empty(secondTurnTools!); // Strategy removed the tool set. + + // Response text shouldn't just echo the tool's stub output. + Assert.DoesNotContain("Sunny and dry.", secondResponse.Text, StringComparison.OrdinalIgnoreCase); + } + + [ConditionalFact] + public virtual async Task ToolReduction_MessagesEmbeddingTextSelector_UsesChatClientToAnalyzeConversation() + { + SkipIfNotEnabled(); + EnsureEmbeddingGenerator(); + + // Create tools for different domains. + var weatherTool = AIFunctionFactory.Create( + () => "Weather data", + new AIFunctionFactoryOptions + { + Name = "GetWeatherForecast", + Description = "Returns weather forecast and temperature for a given city." + }); + + var translateTool = AIFunctionFactory.Create( + () => "Translated text", + new AIFunctionFactoryOptions + { + Name = "TranslateText", + Description = "Translates text between human languages." + }); + + var mathTool = AIFunctionFactory.Create( + () => 42, + new AIFunctionFactoryOptions + { + Name = "SolveMath", + Description = "Solves basic math problems." + }); + + var allTools = new List { weatherTool, translateTool, mathTool }; + + // Track the analysis result from the chat client used in the selector. + string? capturedAnalysis = null; + + var strategy = new EmbeddingToolReductionStrategy(EmbeddingGenerator, toolLimit: 2) + { + // Use a chat client to analyze the conversation and extract relevant tool categories. + MessagesEmbeddingTextSelector = async messages => + { + var conversationText = string.Join("\n", messages.Select(m => $"{m.Role}: {m.Text}")); + + var analysisPrompt = $""" + Analyze the following conversation and identify what kinds of tools would be most helpful. + Focus on the key topics and tasks being discussed. + Respond with a brief summary of the relevant tool categories (e.g., "weather", "translation", "math"). + + Conversation: + {conversationText} + + Relevant tool categories: + """; + + var response = await ChatClient!.GetResponseAsync(analysisPrompt); + capturedAnalysis = response.Text; + + // Return the analysis as the query text for embedding-based tool selection. + return capturedAnalysis; + } + }; + + IList? selectedTools = null; + + using var client = ChatClient! + .AsBuilder() + .UseToolReduction(strategy) + .Use(async (messages, options, next, ct) => + { + selectedTools = options?.Tools; + await next(messages, options, ct); + }) + .UseFunctionInvocation() + .Build(); + + // Conversation that clearly indicates weather-related needs. + List history = []; + history.Add(new ChatMessage(ChatRole.User, "What will the weather be like in London tomorrow?")); + + var response = await client.GetResponseAsync(history, new ChatOptions { Tools = allTools }); + history.AddMessages(response); + + // Verify that the chat client was used to analyze the conversation. + Assert.NotNull(capturedAnalysis); + Assert.True( + capturedAnalysis.IndexOf("weather", StringComparison.OrdinalIgnoreCase) >= 0 || + capturedAnalysis.IndexOf("forecast", StringComparison.OrdinalIgnoreCase) >= 0, + $"Expected analysis to mention weather or forecast: {capturedAnalysis}"); + + // Verify that the tool selection was influenced by the analysis. + Assert.NotNull(selectedTools); + Assert.True(selectedTools.Count <= 2, $"Expected at most 2 tools, got {selectedTools.Count}"); + Assert.Contains(selectedTools, t => t.Name == "GetWeatherForecast"); + } + + // Test-only custom strategy: include tools on first request, then remove them afterward. + private sealed class RemoveToolAfterFirstUseStrategy : IToolReductionStrategy + { + private bool _used; + + public Task> SelectToolsForRequestAsync( + IEnumerable messages, + ChatOptions? options, + CancellationToken cancellationToken = default) + { + if (!_used && options?.Tools is { Count: > 0 }) + { + _used = true; + // Returning the same instance signals no change. + return Task.FromResult>(options.Tools); + } + + // After first use, remove all tools. + return Task.FromResult>(Array.Empty()); + } + } + + [MemberNotNull(nameof(ChatClient))] protected void SkipIfNotEnabled() { string? skipIntegration = TestRunnerConfiguration.Instance["SkipIntegrationTests"]; - if (skipIntegration is not null || _chatClient is null) + if (skipIntegration is not null || ChatClient is null) { throw new SkipTestException("Client is not enabled."); } } + + [MemberNotNull(nameof(EmbeddingGenerator))] + protected void EnsureEmbeddingGenerator() + { + EmbeddingGenerator ??= CreateEmbeddingGenerator(); + + if (EmbeddingGenerator is null) + { + throw new SkipTestException("Embedding generator 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 index 0f422d950b8..20423ae9e8b 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/EmbeddingGeneratorIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/EmbeddingGeneratorIntegrationTests.cs @@ -124,7 +124,7 @@ public virtual async Task OpenTelemetry_CanEmitTracesAndMetrics() Assert.Single(activities); var activity = activities.Single(); Assert.StartsWith("embed", activity.DisplayName); - Assert.StartsWith("http", (string)activity.GetTagItem("server.address")!); + Assert.Contains(".", (string)activity.GetTagItem("server.address")!); Assert.Equal(embeddingGenerator.GetService()?.ProviderUri?.Port, (int)activity.GetTagItem("server.port")!); Assert.NotNull(activity.Id); Assert.NotEmpty(activity.Id); diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/HttpHandlerExpectedInput.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/HttpHandlerExpectedInput.cs new file mode 100644 index 00000000000..981e43912c3 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/HttpHandlerExpectedInput.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; +using System.Net.Http; + +namespace Microsoft.Extensions.AI; + +/// Model for expected input to an HTTP handler. +public sealed class HttpHandlerExpectedInput +{ + /// Gets or sets the expected request URI. + public Uri? Uri { get; set; } + + /// Gets or sets the expected request body. + public string? Body { get; set; } + + /// + /// Gets or sets the expected HTTP method. + /// + public HttpMethod? Method { get; set; } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ImageGeneratingChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ImageGeneratingChatClientIntegrationTests.cs new file mode 100644 index 00000000000..2cbdcd96abf --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ImageGeneratingChatClientIntegrationTests.cs @@ -0,0 +1,448 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.TestUtilities; +using Xunit; + +#pragma warning disable CA2000 // Dispose objects before losing scope +#pragma warning disable CA2214 // Do not call overridable methods in constructors + +namespace Microsoft.Extensions.AI; + +/// +/// Abstract base class for integration tests that verify ImageGeneratingChatClient with real IChatClient implementations. +/// Concrete test classes should inherit from this and provide a real IChatClient that supports function calling. +/// +public abstract class ImageGeneratingChatClientIntegrationTests : IDisposable +{ + private const string ImageKey = "meai_image"; + private readonly IChatClient? _baseChatClient; + + protected ImageGeneratingChatClientIntegrationTests() + { + _baseChatClient = CreateChatClient(); + ImageGenerator = new(); + + if (_baseChatClient != null) + { + ChatClient = _baseChatClient + .AsBuilder() + .UseImageGeneration(ImageGenerator) + .UseFunctionInvocation() + .Build(); + } + } + + /// Gets the ImageGeneratingChatClient configured with function invocation support. + protected IChatClient? ChatClient { get; } + + /// Gets the IImageGenerator used for testing. + protected CapturingImageGenerator ImageGenerator { get; } + + public void Dispose() + { + ChatClient?.Dispose(); + _baseChatClient?.Dispose(); + ImageGenerator.Dispose(); + GC.SuppressFinalize(this); + } + + /// + /// Creates the base IChatClient implementation to test with. + /// Should return a real chat client that supports function calling. + /// + /// An IChatClient instance, or null to skip tests. + protected abstract IChatClient? CreateChatClient(); + + /// + /// Helper method to get a chat response using either streaming or non-streaming based on the parameter. + /// + /// Whether to use streaming or non-streaming response. + /// The chat messages to send. + /// The chat options to use. + /// A ChatResponse from either streaming or non-streaming call. + protected async Task GetResponseAsync(bool useStreaming, IEnumerable messages, ChatOptions? options = null, IChatClient? chatClient = null) + { + chatClient ??= ChatClient ?? throw new InvalidOperationException("ChatClient is not initialized."); + + if (useStreaming) + { + return ValidateChatResponse(await chatClient.GetStreamingResponseAsync(messages, options).ToChatResponseAsync()); + } + else + { + return ValidateChatResponse(await chatClient.GetResponseAsync(messages, options)); + } + + static ChatResponse ValidateChatResponse(ChatResponse response) + { + var contents = response.Messages.SelectMany(m => m.Contents).ToArray(); + + List imageIds = []; + foreach (var toolResult in contents.OfType()) + { + Assert.NotNull(toolResult.Outputs); + + foreach (var dataContent in toolResult.Outputs.OfType()) + { + var imageId = dataContent.AdditionalProperties?[ImageKey] as string; + Assert.NotNull(imageId); + imageIds.Add(imageId); + } + } + + foreach (var textContent in contents.OfType()) + { + Assert.DoesNotContain(ImageKey, textContent.Text, StringComparison.OrdinalIgnoreCase); + foreach (var imageId in imageIds) + { + // Ensure no image IDs appear in text content + Assert.DoesNotContain(imageId, textContent.Text, StringComparison.OrdinalIgnoreCase); + } + } + + return response; + } + } + + [ConditionalTheory] + [InlineData(false)] // Non-streaming + [InlineData(true)] // Streaming + public virtual async Task GenerateImage_CallsGenerateFunction_ReturnsDataContent(bool useStreaming) + { + SkipIfNotEnabled(); + + var imageGenerator = ImageGenerator; + var chatOptions = new ChatOptions + { + Tools = [new HostedImageGenerationTool()] + }; + + // Act + var response = await GetResponseAsync(useStreaming, + [new ChatMessage(ChatRole.User, "Please generate an image of a cat")], + chatOptions); + + // Assert + Assert.Single(imageGenerator.GenerateCalls); + var (request, _) = imageGenerator.GenerateCalls[0]; + Assert.Contains("cat", request.Prompt, StringComparison.OrdinalIgnoreCase); + Assert.Null(request.OriginalImages); // Generation, not editing + + // Verify that we get ImageGenerationToolResultContent back in the response + var imageResults = response.Messages + .SelectMany(m => m.Contents) + .OfType(); + + var imageResult = Assert.Single(imageResults); + Assert.NotNull(imageResult.Outputs); + var imageContent = Assert.Single(imageResult.Outputs.OfType()); + Assert.Equal("image/png", imageContent.MediaType); + Assert.False(imageContent.Data.IsEmpty); + } + + [ConditionalTheory] + [InlineData(false)] // Non-streaming + [InlineData(true)] // Streaming + public virtual async Task EditImage_WithImageInSameRequest_PassesExactDataContent(bool useStreaming) + { + SkipIfNotEnabled(); + + var imageGenerator = ImageGenerator; + var testImageData = new byte[] { 0x89, 0x50, 0x4E, 0x47 }; // PNG header + var originalImageData = new DataContent(testImageData, "image/png") { Name = "original.png" }; + var chatOptions = new ChatOptions + { + Tools = [new HostedImageGenerationTool()] + }; + + // Act + var response = await GetResponseAsync(useStreaming, + [new ChatMessage(ChatRole.User, [new TextContent("Please edit this image to add a red border"), originalImageData])], + chatOptions); + + // Assert + var (request, _) = Assert.Single(imageGenerator.GenerateCalls); + Assert.NotNull(request.OriginalImages); + + var originalImage = Assert.Single(request.OriginalImages); + var originalImageContent = Assert.IsType(originalImage); + Assert.Equal(testImageData, originalImageContent.Data.ToArray()); + Assert.Equal("image/png", originalImageContent.MediaType); + Assert.Equal("original.png", originalImageContent.Name); + } + + [ConditionalTheory] + [InlineData(false)] // Non-streaming + [InlineData(true)] // Streaming + public virtual async Task GenerateThenEdit_FromChatHistory_EditsGeneratedImage(bool useStreaming) + { + SkipIfNotEnabled(); + + var imageGenerator = ImageGenerator; + var chatOptions = new ChatOptions + { + Tools = [new HostedImageGenerationTool()] + }; + + var chatHistory = new List + { + new(ChatRole.User, "Please generate an image of a dog") + }; + + // First request: Generate image + var firstResponse = await GetResponseAsync(useStreaming, chatHistory, chatOptions); + chatHistory.AddRange(firstResponse.Messages); + + // Second request: Edit the generated image + chatHistory.Add(new ChatMessage(ChatRole.User, "Please edit the image to make it more colorful")); + var secondResponse = await GetResponseAsync(useStreaming, chatHistory, chatOptions); + + // Assert + Assert.Equal(2, imageGenerator.GenerateCalls.Count); + + // First call should be generation (no original images) + var (firstRequest, _) = imageGenerator.GenerateCalls[0]; + Assert.Null(firstRequest.OriginalImages); + + // Extract the DataContent from the ImageGenerationToolResultContent + var firstToolResultContent = Assert.Single(firstResponse.Messages.SelectMany(m => m.Contents).OfType()); + Assert.NotNull(firstToolResultContent.Outputs); + var firstContent = Assert.Single(firstToolResultContent.Outputs.OfType()); + + // Second call should be editing (with original images) + var (secondRequest, _) = imageGenerator.GenerateCalls[1]; + Assert.Single(secondResponse.Messages.SelectMany(m => m.Contents).OfType().SelectMany(t => t.Outputs!.OfType())); + Assert.NotNull(secondRequest.OriginalImages); + var editContent = Assert.Single(secondRequest.OriginalImages); + Assert.Equal(firstContent, editContent); // Should be the same image as generated in first call + + var editedImage = Assert.IsType(secondRequest.OriginalImages.First()); + Assert.Equal("image/png", editedImage.MediaType); + Assert.Contains("generated_image_1", editedImage.Name); + } + + [ConditionalTheory] + [InlineData(false)] // Non-streaming + [InlineData(true)] // Streaming + public virtual async Task MultipleEdits_EditsLatestImage(bool useStreaming) + { + SkipIfNotEnabled(); + + var imageGenerator = ImageGenerator; + var chatOptions = new ChatOptions + { + Tools = [new HostedImageGenerationTool()] + }; + + var chatHistory = new List + { + new(ChatRole.User, "Please generate an image of a tree") + }; + + // First: Generate image + var firstResponse = await GetResponseAsync(useStreaming, chatHistory, chatOptions); + chatHistory.AddRange(firstResponse.Messages); + + // Second: First edit + chatHistory.Add(new ChatMessage(ChatRole.User, "Please edit the image to add flowers")); + var secondResponse = await GetResponseAsync(useStreaming, chatHistory, chatOptions); + chatHistory.AddRange(secondResponse.Messages); + + // Third: Second edit (should edit the latest version by default) + chatHistory.Add(new ChatMessage(ChatRole.User, "Please edit that last image to add birds")); + var thirdResponse = await GetResponseAsync(useStreaming, chatHistory, chatOptions); + + // Assert + Assert.Equal(3, imageGenerator.GenerateCalls.Count); + + // Third call should edit the second generated image (from first edit), not the original + var (thirdRequest, _) = imageGenerator.GenerateCalls[2]; + Assert.NotNull(thirdRequest.OriginalImages); + + // Extract the DataContent from the second response's ImageGenerationToolResultContent + var secondToolResultContent = Assert.Single(secondResponse.Messages.SelectMany(m => m.Contents).OfType()); + var secondImage = Assert.Single(secondToolResultContent.Outputs!.OfType()); + var lastImageToEdit = Assert.Single(thirdRequest.OriginalImages.OfType()); + Assert.Equal(secondImage, lastImageToEdit); + } + + [ConditionalTheory] + [InlineData(false)] // Non-streaming + [InlineData(true)] // Streaming + public virtual async Task MultipleEdits_EditsFirstImage(bool useStreaming) + { + SkipIfNotEnabled(); + + var imageGenerator = ImageGenerator; + var chatOptions = new ChatOptions + { + Tools = [new HostedImageGenerationTool()] + }; + + var chatHistory = new List + { + new(ChatRole.User, "Please generate an image of a tree") + }; + + // First: Generate image + var firstResponse = await GetResponseAsync(useStreaming, chatHistory, chatOptions); + chatHistory.AddRange(firstResponse.Messages); + + // Second: First edit + chatHistory.Add(new ChatMessage(ChatRole.User, "Please edit the image to add fruit")); + var secondResponse = await GetResponseAsync(useStreaming, chatHistory, chatOptions); + chatHistory.AddRange(secondResponse.Messages); + + // Third: Second edit (should edit the latest version by default) + chatHistory.Add(new ChatMessage(ChatRole.User, "That didn't work out. Please edit the original image to add birds")); + var thirdResponse = await GetResponseAsync(useStreaming, chatHistory, chatOptions); + + // Assert + Assert.Equal(3, imageGenerator.GenerateCalls.Count); + + // Third call should edit the original generated image (not from edit) + var (thirdRequest, _) = imageGenerator.GenerateCalls[2]; + Assert.NotNull(thirdRequest.OriginalImages); + + // Extract the DataContent from the first response's ImageGenerationToolResultContent + var firstToolResultContent = Assert.Single(firstResponse.Messages.SelectMany(m => m.Contents).OfType()); + var firstGeneratedImage = Assert.Single(firstToolResultContent.Outputs!.OfType()); + var lastImageToEdit = Assert.IsType(thirdRequest.OriginalImages.First()); + Assert.Equal(firstGeneratedImage, lastImageToEdit); + } + + [ConditionalTheory] + [InlineData(false)] // Non-streaming + [InlineData(true)] // Streaming + public virtual async Task ImageGeneration_WithOptions_PassesOptionsToGenerator(bool useStreaming) + { + SkipIfNotEnabled(); + + var imageGenerator = ImageGenerator; + var imageGenerationOptions = new ImageGenerationOptions + { + Count = 2, + ImageSize = new System.Drawing.Size(512, 512) + }; + + var chatOptions = new ChatOptions + { + Tools = [new HostedImageGenerationTool { Options = imageGenerationOptions }] + }; + + // Act + var response = await GetResponseAsync(useStreaming, + [new ChatMessage(ChatRole.User, "Generate an image of a castle")], + chatOptions); + + // Assert + Assert.Single(imageGenerator.GenerateCalls); + var (_, options) = imageGenerator.GenerateCalls[0]; + Assert.NotNull(options); + Assert.Equal(2, options.Count); + Assert.Equal(new System.Drawing.Size(512, 512), options.ImageSize); + } + + [ConditionalTheory] + [InlineData(false)] // Non-streaming + [InlineData(true)] // Streaming + public virtual async Task ImageContentHandling_AllImages_ReplacesImagesWithPlaceholders(bool useStreaming) + { + SkipIfNotEnabled(); + + var testImageData = new byte[] { 0x89, 0x50, 0x4E, 0x47 }; // PNG header + var capturedMessages = new List>(); + + // Create a new ImageGeneratingChatClient with AllImages data content handling + using var imageGeneratingClient = _baseChatClient! + .AsBuilder() + .UseImageGeneration(ImageGenerator) + .Use((messages, options, next, cancellationToken) => + { + capturedMessages.Add(messages); + return next(messages, options, cancellationToken); + }) + .UseFunctionInvocation() + .Build(); + + var originalImage = new DataContent(testImageData, "image/png") { Name = "test.png" }; + + // Act + await GetResponseAsync(useStreaming, + [ + new ChatMessage(ChatRole.User, + [ + new TextContent("Here's an image to process"), + originalImage + ]) + ], + new ChatOptions { Tools = [new HostedImageGenerationTool()] }, + imageGeneratingClient); + + // Assert + Assert.NotEmpty(capturedMessages); + var processedMessages = capturedMessages.First().ToList(); + var userMessage = processedMessages.First(m => m.Role == ChatRole.User); + + // Should have text content with placeholder instead of original image + var textContents = userMessage.Contents.OfType().ToList(); + Assert.Contains(textContents, tc => tc.Text.Contains(ImageKey) && tc.Text.Contains("] available for edit")); + + // Should not contain the original DataContent + Assert.DoesNotContain(userMessage.Contents, c => c == originalImage); + } + + /// + /// Test image generator that captures calls and returns fake image data. + /// + protected sealed class CapturingImageGenerator : IImageGenerator + { + private const string TestImageMediaType = "image/png"; + private static readonly byte[] _testImageData = [0x89, 0x50, 0x4E, 0x47]; // PNG header + + public List<(ImageGenerationRequest request, ImageGenerationOptions? options)> GenerateCalls { get; } = []; + public int ImageCounter { get; private set; } + + public Task GenerateAsync(ImageGenerationRequest request, ImageGenerationOptions? options = null, CancellationToken cancellationToken = default) + { + GenerateCalls.Add((request, options)); + + // Create fake image data with unique content + var imageData = new byte[_testImageData.Length + 4]; + _testImageData.CopyTo(imageData, 0); + BitConverter.GetBytes(++ImageCounter).CopyTo(imageData, _testImageData.Length); + + var imageContent = new DataContent(imageData, TestImageMediaType) + { + Name = $"generated_image_{ImageCounter}.png" + }; + + return Task.FromResult(new ImageGenerationResponse([imageContent])); + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() + { + // No resources to dispose + } + } + + [MemberNotNull(nameof(ChatClient))] + protected void SkipIfNotEnabled() + { + string? skipIntegration = TestRunnerConfiguration.Instance["SkipIntegrationTests"]; + + if (skipIntegration is not null || ChatClient is null) + { + throw new SkipTestException("Client is not enabled."); + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ImageGeneratorIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ImageGeneratorIntegrationTests.cs new file mode 100644 index 00000000000..76b08941bc5 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ImageGeneratorIntegrationTests.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.Diagnostics.CodeAnalysis; +using System.IO; +using System.Threading.Tasks; +using Microsoft.TestUtilities; +using Xunit; + +#pragma warning disable CA2214 // Do not call overridable methods in constructors + +namespace Microsoft.Extensions.AI; + +public abstract class ImageGeneratorIntegrationTests : IDisposable +{ + private readonly IImageGenerator? _generator; + + protected ImageGeneratorIntegrationTests() + { + _generator = CreateGenerator(); + } + + public void Dispose() + { + _generator?.Dispose(); + GC.SuppressFinalize(this); + } + + protected abstract IImageGenerator? CreateGenerator(); + + [ConditionalFact] + public virtual async Task GenerateImagesAsync_SingleImageGeneration() + { + SkipIfNotEnabled(); + + var options = new ImageGenerationOptions + { + Count = 1 + }; + + var response = await _generator.GenerateImagesAsync("A simple drawing of a house", options); + + Assert.NotNull(response); + Assert.NotEmpty(response.Contents); + + var content = Assert.Single(response.Contents); + switch (content) + { + case UriContent uc: + Assert.StartsWith("http", uc.Uri.Scheme, StringComparison.Ordinal); + break; + + case DataContent dc: + Assert.False(dc.Data.IsEmpty); + Assert.StartsWith("image/", dc.MediaType, StringComparison.Ordinal); + break; + + default: + Assert.Fail($"Unexpected content type: {content.GetType()}"); + break; + } + } + + [ConditionalFact] + public virtual async Task GenerateImagesAsync_MultipleImages() + { + SkipIfNotEnabled(); + + var options = new ImageGenerationOptions + { + Count = 2 + }; + + var response = await _generator.GenerateImagesAsync("A cat sitting on a table", options); + + Assert.NotNull(response); + Assert.NotEmpty(response.Contents); + Assert.Equal(2, response.Contents.Count); + + foreach (var content in response.Contents) + { + Assert.IsType(content); + var dataContent = (DataContent)content; + Assert.False(dataContent.Data.IsEmpty); + Assert.StartsWith("image/", dataContent.MediaType, StringComparison.Ordinal); + } + } + + [ConditionalFact] + public virtual async Task EditImagesAsync_SingleImage() + { + SkipIfNotEnabled(); + + var imageData = GetImageData("dotnet.png"); + AIContent[] originalImages = [new DataContent(imageData, "image/png") { Name = "dotnet.png" }]; + + var options = new ImageGenerationOptions + { + Count = 1 + }; + + var response = await _generator.EditImagesAsync(originalImages, "Add a red border and make the background tie-dye", options); + + Assert.NotNull(response); + Assert.NotEmpty(response.Contents); + Assert.Single(response.Contents); + + var content = response.Contents[0]; + Assert.IsType(content); + var dataContent = (DataContent)content; + Assert.False(dataContent.Data.IsEmpty); + Assert.StartsWith("image/", dataContent.MediaType, StringComparison.Ordinal); + } + + private static byte[] GetImageData(string fileName) + { + using Stream? s = typeof(ImageGeneratorIntegrationTests).Assembly.GetManifestResourceStream($"Microsoft.Extensions.AI.Resources.{fileName}"); + Assert.NotNull(s); + using MemoryStream ms = new(); + s.CopyTo(ms); + return ms.ToArray(); + } + + [MemberNotNull(nameof(_generator))] + protected void SkipIfNotEnabled() + { + string? skipIntegration = TestRunnerConfiguration.Instance["SkipIntegrationTests"]; + + if (skipIntegration is not null || _generator 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 index ddc72caa90d..bd6c6b9ba2f 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/Microsoft.Extensions.AI.Integration.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/Microsoft.Extensions.AI.Integration.Tests.csproj @@ -8,6 +8,7 @@ $(NoWarn);CA1063;CA1861;SA1130;VSTHRD003 $(NoWarn);MEAI001 + $(NoWarn);S104 true @@ -25,13 +26,14 @@ Never - + + @@ -44,7 +46,6 @@ - diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/README.md b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/README.md index 3b99e9bccc1..988ab2d08f5 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/README.md +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/README.md @@ -17,6 +17,7 @@ Optionally also run the following. The values shown here are the defaults if you ``` dotnet user-secrets set OpenAI:ChatModel gpt-4o-mini dotnet user-secrets set OpenAI:EmbeddingModel text-embedding-3-small +dotnet user-secrets set OpenAI:ImageModel dall-e-3 ``` ### Configuring OpenAI tests (Azure OpenAI) @@ -35,6 +36,7 @@ Optionally also run the following. The values shown here are the defaults if you ``` dotnet user-secrets set OpenAI:ChatModel gpt-4o-mini dotnet user-secrets set OpenAI:EmbeddingModel text-embedding-3-small +dotnet user-secrets set OpenAI:ImageModel dall-e-3 ``` Your account must have models matching these names. diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ReducingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ReducingChatClientTests.cs index f2ec8ebdba0..dee92f67145 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ReducingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ReducingChatClientTests.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Microsoft.ML.Tokenizers; @@ -13,7 +12,6 @@ #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; @@ -57,64 +55,6 @@ public async Task Reduction_LimitsMessagesBasedOnTokenLimit() } } -/// Provides an example of a chat client for reducing the size of a message list. -public sealed class ReducingChatClient : DelegatingChatClient -{ - private readonly IChatReducer _reducer; - - /// Initializes a new instance of the class. - /// The inner client. - /// The reducer to be used by this instance. - public ReducingChatClient(IChatClient innerClient, IChatReducer reducer) - : base(innerClient) - { - _reducer = Throw.IfNull(reducer); - } - - /// - public override async Task GetResponseAsync( - IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - { - messages = await _reducer.ReduceAsync(messages, cancellationToken).ConfigureAwait(false); - - return await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); - } - - /// - public override async IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - messages = await _reducer.ReduceAsync(messages, cancellationToken).ConfigureAwait(false); - - await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)) - { - yield return update; - } - } -} - -/// Represents a reducer capable of shrinking the size of a list of chat messages. -public interface IChatReducer -{ - /// Reduces the size of a list of chat messages. - /// The messages. - /// The to monitor for cancellation requests. The default is . - /// The new list of messages, or if no reduction need be performed or was true. - Task> ReduceAsync(IEnumerable messages, CancellationToken cancellationToken); -} - -/// Provides extensions for configuring instances. -public static class ReducingChatClientExtensions -{ - public static ChatClientBuilder UseChatReducer(this ChatClientBuilder builder, IChatReducer reducer) - { - _ = Throw.IfNull(builder); - _ = Throw.IfNull(reducer); - - return builder.Use(innerClient => new ReducingChatClient(innerClient, reducer)); - } -} - /// An that culls the oldest messages once a certain token threshold is reached. public sealed class TokenCountingChatReducer : IChatReducer { @@ -127,7 +67,7 @@ public TokenCountingChatReducer(Tokenizer tokenizer, int tokenLimit) _tokenLimit = Throw.IfLessThan(tokenLimit, 1); } - public async Task> ReduceAsync( + public async Task> ReduceAsync( IEnumerable messages, CancellationToken cancellationToken) { _ = Throw.IfNull(messages); diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ToolReductionTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ToolReductionTests.cs new file mode 100644 index 00000000000..436d657acfa --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ToolReductionTests.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. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ToolReductionTests +{ + [Fact] + public void EmbeddingToolReductionStrategy_Constructor_ThrowsWhenToolLimitIsLessThanOrEqualToZero() + { + using var gen = new DeterministicTestEmbeddingGenerator(); + Assert.Throws("toolLimit", () => new EmbeddingToolReductionStrategy(gen, toolLimit: 0)); + } + + [Fact] + public async Task EmbeddingToolReductionStrategy_NoReduction_WhenToolsBelowLimit() + { + using var gen = new DeterministicTestEmbeddingGenerator(); + var strategy = new EmbeddingToolReductionStrategy(gen, toolLimit: 5); + + var tools = CreateTools("Weather", "Math"); + var options = new ChatOptions { Tools = tools }; + + var result = await strategy.SelectToolsForRequestAsync( + new[] { new ChatMessage(ChatRole.User, "Tell me about weather") }, + options); + + Assert.Same(tools, result); + } + + [Fact] + public async Task EmbeddingToolReductionStrategy_NoReduction_WhenOptionalToolsBelowLimit() + { + // 1 required + 2 optional, limit = 2 (optional count == limit) => original list returned + using var gen = new DeterministicTestEmbeddingGenerator(); + var strategy = new EmbeddingToolReductionStrategy(gen, toolLimit: 2) + { + IsRequiredTool = t => t.Name == "Req" + }; + + var tools = CreateTools("Req", "Opt1", "Opt2"); + var result = await strategy.SelectToolsForRequestAsync( + new[] { new ChatMessage(ChatRole.User, "anything") }, + new ChatOptions { Tools = tools }); + + Assert.Same(tools, result); + } + + [Fact] + public async Task EmbeddingToolReductionStrategy_Reduces_ToLimit_BySimilarity() + { + using var gen = new DeterministicTestEmbeddingGenerator(); + var strategy = new EmbeddingToolReductionStrategy(gen, toolLimit: 2); + + var tools = CreateTools("Weather", "Translate", "Math", "Jokes"); + var options = new ChatOptions { Tools = tools }; + + var messages = new[] + { + new ChatMessage(ChatRole.User, "Can you do some weather math for forecasting?") + }; + + var reduced = (await strategy.SelectToolsForRequestAsync(messages, options)).ToList(); + + Assert.Equal(2, reduced.Count); + Assert.Contains(reduced, t => t.Name == "Weather"); + Assert.Contains(reduced, t => t.Name == "Math"); + } + + [Fact] + public async Task EmbeddingToolReductionStrategy_PreserveOriginalOrdering_ReordersAfterSelection() + { + using var gen = new DeterministicTestEmbeddingGenerator(); + var strategy = new EmbeddingToolReductionStrategy(gen, toolLimit: 2) + { + PreserveOriginalOrdering = true + }; + + var tools = CreateTools("Math", "Translate", "Weather"); + var reduced = (await strategy.SelectToolsForRequestAsync( + new[] { new ChatMessage(ChatRole.User, "Explain weather math please") }, + new ChatOptions { Tools = tools })).ToList(); + + Assert.Equal(2, reduced.Count); + Assert.Equal("Math", reduced[0].Name); + Assert.Equal("Weather", reduced[1].Name); + } + + [Fact] + public async Task EmbeddingToolReductionStrategy_Caching_AvoidsReEmbeddingTools() + { + using var gen = new DeterministicTestEmbeddingGenerator(); + var strategy = new EmbeddingToolReductionStrategy(gen, toolLimit: 1); + + var tools = CreateTools("Weather", "Math", "Jokes"); + var messages = new[] { new ChatMessage(ChatRole.User, "weather") }; + + _ = await strategy.SelectToolsForRequestAsync(messages, new ChatOptions { Tools = tools }); + int afterFirst = gen.TotalValueInputs; + + _ = await strategy.SelectToolsForRequestAsync(messages, new ChatOptions { Tools = tools }); + int afterSecond = gen.TotalValueInputs; + + // +1 for second query embedding only + Assert.Equal(afterFirst + 1, afterSecond); + } + + [Fact] + public async Task EmbeddingToolReductionStrategy_OptionsNullOrNoTools_ReturnsEmptyOrOriginal() + { + using var gen = new DeterministicTestEmbeddingGenerator(); + var strategy = new EmbeddingToolReductionStrategy(gen, toolLimit: 2); + + var empty = await strategy.SelectToolsForRequestAsync( + new[] { new ChatMessage(ChatRole.User, "anything") }, null); + Assert.Empty(empty); + + var options = new ChatOptions { Tools = [] }; + var result = await strategy.SelectToolsForRequestAsync( + new[] { new ChatMessage(ChatRole.User, "weather") }, options); + Assert.Same(options.Tools, result); + } + + [Fact] + public async Task EmbeddingToolReductionStrategy_CustomSimilarity_InvertsOrdering() + { + using var gen = new VectorBasedTestEmbeddingGenerator(); + var strategy = new EmbeddingToolReductionStrategy(gen, toolLimit: 1) + { + Similarity = (q, t) => -t.Span[0] + }; + + var highTool = new SimpleTool("HighScore", "alpha"); + var lowTool = new SimpleTool("LowScore", "beta"); + gen.VectorSelector = text => text.Contains("alpha") ? 10f : 1f; + + var reduced = (await strategy.SelectToolsForRequestAsync( + new[] { new ChatMessage(ChatRole.User, "Pick something") }, + new ChatOptions { Tools = [highTool, lowTool] })).ToList(); + + Assert.Single(reduced); + Assert.Equal("LowScore", reduced[0].Name); + } + + [Fact] + public async Task EmbeddingToolReductionStrategy_TieDeterminism_PrefersLowerOriginalIndex() + { + // Generator returns identical vectors so similarity ties; we expect original order preserved + using var gen = new ConstantEmbeddingGenerator(3); + var strategy = new EmbeddingToolReductionStrategy(gen, toolLimit: 2); + + var tools = CreateTools("T1", "T2", "T3", "T4"); + var reduced = (await strategy.SelectToolsForRequestAsync( + new[] { new ChatMessage(ChatRole.User, "any") }, + new ChatOptions { Tools = tools })).ToList(); + + Assert.Equal(2, reduced.Count); + Assert.Equal("T1", reduced[0].Name); + Assert.Equal("T2", reduced[1].Name); + } + + [Fact] + public async Task EmbeddingToolReductionStrategy_DefaultEmbeddingTextSelector_EmptyDescription_UsesNameOnly() + { + using var recorder = new RecordingEmbeddingGenerator(); + var strategy = new EmbeddingToolReductionStrategy(recorder, toolLimit: 1); + + var target = new SimpleTool("ComputeSum", description: ""); + var filler = new SimpleTool("Other", "Unrelated"); + _ = await strategy.SelectToolsForRequestAsync( + new[] { new ChatMessage(ChatRole.User, "math") }, + new ChatOptions { Tools = [target, filler] }); + + Assert.Contains("ComputeSum", recorder.Inputs); + } + + [Fact] + public async Task EmbeddingToolReductionStrategy_DefaultEmbeddingTextSelector_EmptyName_UsesDescriptionOnly() + { + using var recorder = new RecordingEmbeddingGenerator(); + var strategy = new EmbeddingToolReductionStrategy(recorder, toolLimit: 1); + + var target = new SimpleTool("", description: "Translates between languages."); + var filler = new SimpleTool("Other", "Unrelated"); + _ = await strategy.SelectToolsForRequestAsync( + new[] { new ChatMessage(ChatRole.User, "translate") }, + new ChatOptions { Tools = [target, filler] }); + + Assert.Contains("Translates between languages.", recorder.Inputs); + } + + [Fact] + public async Task EmbeddingToolReductionStrategy_CustomEmbeddingTextSelector_Applied() + { + using var recorder = new RecordingEmbeddingGenerator(); + var strategy = new EmbeddingToolReductionStrategy(recorder, toolLimit: 1) + { + ToolEmbeddingTextSelector = t => $"NAME:{t.Name}|DESC:{t.Description}" + }; + + var target = new SimpleTool("WeatherTool", "Gets forecast."); + var filler = new SimpleTool("Other", "Irrelevant"); + _ = await strategy.SelectToolsForRequestAsync( + new[] { new ChatMessage(ChatRole.User, "weather") }, + new ChatOptions { Tools = [target, filler] }); + + Assert.Contains("NAME:WeatherTool|DESC:Gets forecast.", recorder.Inputs); + } + + [Fact] + public async Task EmbeddingToolReductionStrategy_MessagesEmbeddingTextSelector_CustomFiltersMessages() + { + using var gen = new DeterministicTestEmbeddingGenerator(); + var strategy = new EmbeddingToolReductionStrategy(gen, toolLimit: 1); + + var tools = CreateTools("Weather", "Math", "Translate"); + + var messages = new[] + { + new ChatMessage(ChatRole.User, "Please tell me the weather tomorrow."), + new ChatMessage(ChatRole.Assistant, "Sure, I can help."), + new ChatMessage(ChatRole.User, "Now instead solve a math problem.") + }; + + strategy.MessagesEmbeddingTextSelector = msgs => new ValueTask(msgs.LastOrDefault()?.Text ?? string.Empty); + + var reduced = (await strategy.SelectToolsForRequestAsync( + messages, + new ChatOptions { Tools = tools })).ToList(); + + Assert.Single(reduced); + Assert.Equal("Math", reduced[0].Name); + } + + [Fact] + public async Task EmbeddingToolReductionStrategy_MessagesEmbeddingTextSelector_InvokedOnce() + { + using var gen = new DeterministicTestEmbeddingGenerator(); + var strategy = new EmbeddingToolReductionStrategy(gen, toolLimit: 1); + + var tools = CreateTools("Weather", "Math"); + int invocationCount = 0; + + strategy.MessagesEmbeddingTextSelector = msgs => + { + invocationCount++; + return new ValueTask(string.Join("\n", msgs.Select(m => m.Text))); + }; + + _ = await strategy.SelectToolsForRequestAsync( + new[] { new ChatMessage(ChatRole.User, "weather and math") }, + new ChatOptions { Tools = tools }); + + Assert.Equal(1, invocationCount); + } + + [Fact] + public async Task EmbeddingToolReductionStrategy_DefaultMessagesEmbeddingTextSelector_IncludesReasoningContent() + { + using var recorder = new RecordingEmbeddingGenerator(); + var strategy = new EmbeddingToolReductionStrategy(recorder, toolLimit: 1); + var tools = CreateTools("Weather", "Math"); + + var reasoningLine = "Thinking about the best way to get tomorrow's forecast..."; + var answerLine = "Tomorrow will be sunny."; + var userLine = "What's the weather tomorrow?"; + + var messages = new[] + { + new ChatMessage(ChatRole.User, userLine), + new ChatMessage(ChatRole.Assistant, + [ + new TextReasoningContent(reasoningLine), + new TextContent(answerLine) + ]) + }; + + _ = await strategy.SelectToolsForRequestAsync(messages, new ChatOptions { Tools = tools }); + + string queryInput = recorder.Inputs[0]; + + Assert.Contains(userLine, queryInput); + Assert.Contains(reasoningLine, queryInput); + Assert.Contains(answerLine, queryInput); + + var userIndex = queryInput.IndexOf(userLine, StringComparison.Ordinal); + var reasoningIndex = queryInput.IndexOf(reasoningLine, StringComparison.Ordinal); + var answerIndex = queryInput.IndexOf(answerLine, StringComparison.Ordinal); + Assert.True(userIndex >= 0 && reasoningIndex > userIndex && answerIndex > reasoningIndex); + } + + [Fact] + public async Task EmbeddingToolReductionStrategy_DefaultMessagesEmbeddingTextSelector_SkipsNonTextContent() + { + using var recorder = new RecordingEmbeddingGenerator(); + var strategy = new EmbeddingToolReductionStrategy(recorder, toolLimit: 1); + var tools = CreateTools("Alpha", "Beta"); + + var textOnly = "Provide translation."; + var messages = new[] + { + new ChatMessage(ChatRole.User, + [ + new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream"), + new TextContent(textOnly) + ]) + }; + + _ = await strategy.SelectToolsForRequestAsync(messages, new ChatOptions { Tools = tools }); + + var queryInput = recorder.Inputs[0]; + Assert.Contains(textOnly, queryInput); + Assert.DoesNotContain("application/octet-stream", queryInput, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task EmbeddingToolReductionStrategy_RequiredToolAlwaysIncluded() + { + using var gen = new DeterministicTestEmbeddingGenerator(); + var strategy = new EmbeddingToolReductionStrategy(gen, toolLimit: 1) + { + IsRequiredTool = t => t.Name == "Core" + }; + + var tools = CreateTools("Core", "Weather", "Math"); + var reduced = (await strategy.SelectToolsForRequestAsync( + new[] { new ChatMessage(ChatRole.User, "math") }, + new ChatOptions { Tools = tools })).ToList(); + + Assert.Equal(2, reduced.Count); // required + one optional (limit=1) + Assert.Contains(reduced, t => t.Name == "Core"); + } + + [Fact] + public async Task EmbeddingToolReductionStrategy_MultipleRequiredTools_ExceedLimit_AllRequiredIncluded() + { + // 3 required, limit=1 => expect 3 required + 1 ranked optional = 4 total + using var gen = new DeterministicTestEmbeddingGenerator(); + var strategy = new EmbeddingToolReductionStrategy(gen, toolLimit: 1) + { + IsRequiredTool = t => t.Name.StartsWith("R", StringComparison.Ordinal) + }; + + var tools = CreateTools("R1", "R2", "R3", "Weather", "Math"); + var reduced = (await strategy.SelectToolsForRequestAsync( + new[] { new ChatMessage(ChatRole.User, "weather math") }, + new ChatOptions { Tools = tools })).ToList(); + + Assert.Equal(4, reduced.Count); + Assert.Equal(3, reduced.Count(t => t.Name.StartsWith("R"))); + } + + [Fact] + public async Task ToolReducingChatClient_ReducesTools_ForGetResponseAsync() + { + using var gen = new DeterministicTestEmbeddingGenerator(); + var strategy = new EmbeddingToolReductionStrategy(gen, toolLimit: 2); + var tools = CreateTools("Weather", "Math", "Translate", "Jokes"); + + IList? observedTools = null; + + using var inner = new TestChatClient + { + GetResponseAsyncCallback = (messages, options, ct) => + { + observedTools = options?.Tools; + return Task.FromResult(new ChatResponse()); + } + }; + + using var client = inner.AsBuilder().UseToolReduction(strategy).Build(); + + await client.GetResponseAsync( + new[] { new ChatMessage(ChatRole.User, "weather math please") }, + new ChatOptions { Tools = tools }); + + Assert.NotNull(observedTools); + Assert.Equal(2, observedTools!.Count); + Assert.Contains(observedTools, t => t.Name == "Weather"); + Assert.Contains(observedTools, t => t.Name == "Math"); + } + + [Fact] + public async Task ToolReducingChatClient_ReducesTools_ForStreaming() + { + using var gen = new DeterministicTestEmbeddingGenerator(); + var strategy = new EmbeddingToolReductionStrategy(gen, toolLimit: 1); + var tools = CreateTools("Weather", "Math"); + + IList? observedTools = null; + + using var inner = new TestChatClient + { + GetStreamingResponseAsyncCallback = (messages, options, ct) => + { + observedTools = options?.Tools; + return EmptyAsyncEnumerable(); + } + }; + + using var client = inner.AsBuilder().UseToolReduction(strategy).Build(); + + await foreach (var _ in client.GetStreamingResponseAsync( + new[] { new ChatMessage(ChatRole.User, "math") }, + new ChatOptions { Tools = tools })) + { + // Consume + } + + Assert.NotNull(observedTools); + Assert.Single(observedTools!); + Assert.Equal("Math", observedTools![0].Name); + } + + [Fact] + public async Task EmbeddingToolReductionStrategy_EmptyQuery_NoReduction() + { + // Arrange: more tools than limit so we'd normally reduce, but query is empty -> return full list unchanged. + using var gen = new DeterministicTestEmbeddingGenerator(); + var strategy = new EmbeddingToolReductionStrategy(gen, toolLimit: 1); + + var tools = CreateTools("ToolA", "ToolB", "ToolC"); + var options = new ChatOptions { Tools = tools }; + + // Empty / whitespace message text produces empty query. + var messages = new[] { new ChatMessage(ChatRole.User, " ") }; + + // Act + var result = await strategy.SelectToolsForRequestAsync(messages, options); + + // Assert: same reference (no reduction), and generator not invoked at all. + Assert.Same(tools, result); + Assert.Equal(0, gen.TotalValueInputs); + } + + [Fact] + public async Task EmbeddingToolReductionStrategy_EmptyQuery_NoReduction_WithRequiredTool() + { + // Arrange: required tool + optional tools; still should return original set when query is empty. + using var gen = new DeterministicTestEmbeddingGenerator(); + var strategy = new EmbeddingToolReductionStrategy(gen, toolLimit: 1) + { + IsRequiredTool = t => t.Name == "Req" + }; + + var tools = CreateTools("Req", "Optional1", "Optional2"); + var options = new ChatOptions { Tools = tools }; + + var messages = new[] { new ChatMessage(ChatRole.User, " ") }; + + // Act + var result = await strategy.SelectToolsForRequestAsync(messages, options); + + // Assert + Assert.Same(tools, result); + Assert.Equal(0, gen.TotalValueInputs); + } + + [Fact] + public async Task EmbeddingToolReductionStrategy_EmptyQuery_ViaCustomMessagesSelector_NoReduction() + { + // Arrange: force empty query through custom selector returning whitespace. + using var gen = new DeterministicTestEmbeddingGenerator(); + var strategy = new EmbeddingToolReductionStrategy(gen, toolLimit: 1) + { + MessagesEmbeddingTextSelector = _ => new ValueTask(" ") + }; + + var tools = CreateTools("One", "Two"); + var messages = new[] + { + new ChatMessage(ChatRole.User, "This content will be ignored by custom selector.") + }; + + // Act + var result = await strategy.SelectToolsForRequestAsync(messages, new ChatOptions { Tools = tools }); + + // Assert: no reduction and no embeddings generated. + Assert.Same(tools, result); + Assert.Equal(0, gen.TotalValueInputs); + } + + private static List CreateTools(params string[] names) => + names.Select(n => (AITool)new SimpleTool(n, $"Description about {n}")).ToList(); + + private static async IAsyncEnumerable EmptyAsyncEnumerable() + { + yield break; + } + + private sealed class SimpleTool : AITool + { + private readonly string _name; + private readonly string _description; + + public SimpleTool(string name, string description) + { + _name = name; + _description = description; + } + + public override string Name => _name; + public override string Description => _description; + } + + /// + /// Deterministic embedding generator producing sparse keyword indicator vectors. + /// Each dimension corresponds to a known keyword. Cosine similarity then reflects + /// pure keyword overlap (non-overlapping keywords contribute nothing), avoiding + /// false ties for tools unrelated to the query. + /// + private sealed class DeterministicTestEmbeddingGenerator : IEmbeddingGenerator> + { + private static readonly string[] _keywords = + [ + "weather","forecast","temperature","math","calculate","sum","translate","language","joke" + ]; + + // +1 bias dimension (last) to avoid zero magnitude vectors when no keywords present. + private static int VectorLength => _keywords.Length + 1; + + public int TotalValueInputs { get; private set; } + + public Task>> GenerateAsync( + IEnumerable values, + EmbeddingGenerationOptions? options = null, + CancellationToken cancellationToken = default) + { + var list = new List>(); + + foreach (var v in values) + { + TotalValueInputs++; + var vec = new float[VectorLength]; + if (!string.IsNullOrWhiteSpace(v)) + { + var lower = v.ToLowerInvariant(); + for (int i = 0; i < _keywords.Length; i++) + { + if (lower.Contains(_keywords[i])) + { + vec[i] = 1f; + } + } + } + + vec[^1] = 1f; // bias + list.Add(new Embedding(vec)); + } + + return Task.FromResult(new GeneratedEmbeddings>(list)); + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() + { + // No-op + } + } + + private sealed class RecordingEmbeddingGenerator : IEmbeddingGenerator> + { + public List Inputs { get; } = new(); + + public Task>> GenerateAsync( + IEnumerable values, + EmbeddingGenerationOptions? options = null, + CancellationToken cancellationToken = default) + { + var list = new List>(); + foreach (var v in values) + { + Inputs.Add(v); + + // Basic 2-dim vector (length encodes a bit of variability) + list.Add(new Embedding(new float[] { v.Length, 1f })); + } + + return Task.FromResult(new GeneratedEmbeddings>(list)); + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + public void Dispose() + { + // No-op + } + } + + private sealed class VectorBasedTestEmbeddingGenerator : IEmbeddingGenerator> + { + public Func VectorSelector { get; set; } = _ => 1f; + public Task>> GenerateAsync(IEnumerable values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) + { + var list = new List>(); + foreach (var v in values) + { + list.Add(new Embedding(new float[] { VectorSelector(v), 1f })); + } + + return Task.FromResult(new GeneratedEmbeddings>(list)); + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + public void Dispose() + { + // No-op + } + } + + private sealed class ConstantEmbeddingGenerator : IEmbeddingGenerator> + { + private readonly float[] _vector; + public ConstantEmbeddingGenerator(int dims) + { + _vector = Enumerable.Repeat(1f, dims).ToArray(); + } + + public Task>> GenerateAsync(IEnumerable values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) + { + var list = new List>(); + foreach (var _ in values) + { + list.Add(new Embedding(_vector)); + } + + return Task.FromResult(new GeneratedEmbeddings>(list)); + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + public void Dispose() + { + // No-op + } + } + + private sealed class TestChatClient : IChatClient + { + public Func, ChatOptions?, CancellationToken, Task>? GetResponseAsyncCallback { get; set; } + public Func, ChatOptions?, CancellationToken, IAsyncEnumerable>? GetStreamingResponseAsyncCallback { get; set; } + + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) => + (GetResponseAsyncCallback ?? throw new InvalidOperationException())(messages, options, cancellationToken); + + public IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) => + (GetStreamingResponseAsyncCallback ?? throw new InvalidOperationException())(messages, options, cancellationToken); + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + public void Dispose() + { + // No-op + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/VerbatimHttpHandler.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/VerbatimHttpHandler.cs index 8b5f1973348..5c7e6ee2ecb 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/VerbatimHttpHandler.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/VerbatimHttpHandler.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Diagnostics.CodeAnalysis; using System.Net.Http; using System.Text; using System.Text.Json.Nodes; @@ -21,44 +22,88 @@ 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 expectedOutput, bool validateExpectedResponse = false) : - DelegatingHandler(new HttpClientHandler()) +public sealed class VerbatimHttpHandler : DelegatingHandler { + private readonly string _expectedOutput; + private readonly bool _validateExpectedResponse; + private readonly HttpHandlerExpectedInput _expectedInput; + + public VerbatimHttpHandler(string expectedInput, string expectedOutput, bool validateExpectedResponse = false) + : this(new HttpHandlerExpectedInput { Body = expectedInput }, expectedOutput, validateExpectedResponse) + { + } + + public VerbatimHttpHandler(HttpHandlerExpectedInput expectedInput, string expectedOutput, bool validateExpectedResponse = false) + : base(new HttpClientHandler()) + { + _expectedOutput = expectedOutput; + _validateExpectedResponse = validateExpectedResponse; + _expectedInput = expectedInput; + } + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { - Assert.NotNull(request.Content); + if (_expectedInput.Body is not null) + { + Assert.NotNull(request.Content); - string? actualInput = await request.Content.ReadAsStringAsync().ConfigureAwait(false); + string? actualInput = await request.Content.ReadAsStringAsync().ConfigureAwait(false); - Assert.NotNull(actualInput); - AssertEqualNormalized(expectedInput, actualInput); + Assert.NotNull(actualInput); + AssertEqualNormalized(_expectedInput.Body, actualInput); - if (validateExpectedResponse) - { - ByteArrayContent newContent = new(Encoding.UTF8.GetBytes(actualInput)); - foreach (var header in request.Content.Headers) + if (_validateExpectedResponse) { - newContent.Headers.TryAddWithoutValidation(header.Key, header.Value); + ByteArrayContent newContent = new(Encoding.UTF8.GetBytes(actualInput)); + foreach (var header in request.Content.Headers) + { + newContent.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + + request.Content = newContent; } + } + + if (_expectedInput.Uri is not null) + { + Assert.Equal(_expectedInput.Uri, request.RequestUri); + } - request.Content = newContent; + if (_expectedInput.Method is not null) + { + Assert.Equal(_expectedInput.Method, request.Method); + } + if (_validateExpectedResponse) + { using var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); string? actualOutput = await response.Content.ReadAsStringAsync().ConfigureAwait(false); Assert.NotNull(actualOutput); - AssertEqualNormalized(expectedOutput, actualOutput); + AssertEqualNormalized(_expectedOutput, actualOutput); } - return new() { Content = new StringContent(expectedOutput) }; + return new() { Content = new StringContent(_expectedOutput) }; } - public static string? RemoveWhiteSpace(string? text) => - text is null ? null : - Regex.Replace(text, @"\s*", string.Empty); + [return: NotNullIfNotNull(nameof(text))] + public static string? RemoveWhiteSpace(string? text) + { + if (text is null) + { + return null; + } + + text = text.Replace("\\r", "").Replace("\\n", "").Replace("\\t", ""); + + return Regex.Replace(text, @"\s*", string.Empty); + } private static void AssertEqualNormalized(string expected, string actual) { + expected = RemoveWhiteSpace(expected); + actual = RemoveWhiteSpace(actual); + // First try to compare as JSON. JsonNode? expectedNode = null; JsonNode? actualNode = null; @@ -82,10 +127,7 @@ private static void AssertEqualNormalized(string expected, string actual) } // Legitimately may not have been JSON. Fall back to whitespace normalization. - if (RemoveWhiteSpace(expected) != RemoveWhiteSpace(actual)) - { - FailNotEqual(expected, actual); - } + FailNotEqual(expected, actual); } private static void FailNotEqual(string expected, string actual) => diff --git a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/Microsoft.Extensions.AI.Ollama.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/Microsoft.Extensions.AI.Ollama.Tests.csproj deleted file mode 100644 index 5db789e3b6b..00000000000 --- a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/Microsoft.Extensions.AI.Ollama.Tests.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - Microsoft.Extensions.AI - Unit tests for Microsoft.Extensions.AI.Ollama - - - - true - - - - - - - - - - - - - - diff --git a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientIntegrationTests.cs deleted file mode 100644 index 83e84e49f5b..00000000000 --- a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientIntegrationTests.cs +++ /dev/null @@ -1,115 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.TestUtilities; -using Xunit; - -namespace Microsoft.Extensions.AI; - -public class OllamaChatClientIntegrationTests : ChatClientIntegrationTests -{ - protected override IChatClient? CreateChatClient() => - IntegrationTestHelpers.GetOllamaUri() is Uri endpoint ? - new OllamaChatClient(endpoint, "llama3.1") : - null; - - public override Task FunctionInvocation_RequireAny() => - throw new SkipTestException("Ollama does not currently support requiring function invocation."); - - public override Task FunctionInvocation_RequireSpecific() => - throw new SkipTestException("Ollama does not currently support requiring function invocation."); - - protected override string? GetModel_MultiModal_DescribeImage() => "llava"; - - [ConditionalFact] - public async Task PromptBasedFunctionCalling_NoArgs() - { - SkipIfNotEnabled(); - - using var chatClient = CreateChatClient()! - .AsBuilder() - .UseFunctionInvocation() - .UsePromptBasedFunctionCalling() - .Use(innerClient => new AssertNoToolsDefinedChatClient(innerClient)) - .Build(); - - var secretNumber = 42; - var response = await chatClient.GetResponseAsync("What is the current secret number? Answer with digits only.", new ChatOptions - { - ModelId = "llama3:8b", - Tools = [AIFunctionFactory.Create(() => secretNumber, "GetSecretNumber")], - Temperature = 0, - Seed = 0, - }); - - Assert.Contains(secretNumber.ToString(), response.Text); - } - - [ConditionalFact] - public async Task PromptBasedFunctionCalling_WithArgs() - { - SkipIfNotEnabled(); - - using var chatClient = CreateChatClient()! - .AsBuilder() - .UseFunctionInvocation() - .UsePromptBasedFunctionCalling() - .Use(innerClient => new AssertNoToolsDefinedChatClient(innerClient)) - .Build(); - - var stockPriceTool = AIFunctionFactory.Create([Description("Returns the stock price for a given ticker symbol")] ( - [Description("The ticker symbol")] string symbol, - [Description("The currency code such as USD or JPY")] string currency) => - { - Assert.Equal("MSFT", symbol); - Assert.Equal("GBP", currency); - return 999; - }, "GetStockPrice"); - - var didCallIrrelevantTool = false; - var irrelevantTool = AIFunctionFactory.Create(() => { didCallIrrelevantTool = true; return 123; }, "GetSecretNumber"); - - var response = await chatClient.GetResponseAsync("What's the stock price for Microsoft in British pounds?", new ChatOptions - { - Tools = [stockPriceTool, irrelevantTool], - Temperature = 0, - Seed = 0, - }); - - Assert.Contains("999", response.Text); - Assert.False(didCallIrrelevantTool); - } - - [ConditionalFact] - public async Task InvalidModelParameter_ThrowsInvalidOperationException() - { - SkipIfNotEnabled(); - - var endpoint = IntegrationTestHelpers.GetOllamaUri(); - Assert.NotNull(endpoint); - - using var chatClient = new OllamaChatClient(endpoint, modelId: "inexistent-model"); - - InvalidOperationException ex; - ex = await Assert.ThrowsAsync(() => chatClient.GetResponseAsync("Hello, world!")); - Assert.Contains("inexistent-model", ex.Message); - - ex = await Assert.ThrowsAsync(() => chatClient.GetStreamingResponseAsync("Hello, world!").ToChatResponseAsync()); - Assert.Contains("inexistent-model", ex.Message); - } - - private sealed class AssertNoToolsDefinedChatClient(IChatClient innerClient) : DelegatingChatClient(innerClient) - { - public override Task GetResponseAsync( - IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) - { - Assert.Null(options?.Tools); - return base.GetResponseAsync(messages, options, cancellationToken); - } - } -} diff --git a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs deleted file mode 100644 index 2f716d2fe7d..00000000000 --- a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaChatClientTests.cs +++ /dev/null @@ -1,487 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Linq; -using System.Net.Http; -using System.Text.Json; -using System.Text.RegularExpressions; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Caching.Memory; -using Xunit; - -#pragma warning disable S103 // Lines should not be too long - -namespace Microsoft.Extensions.AI; - -public class OllamaChatClientTests -{ - [Fact] - public void Ctor_InvalidArgs_Throws() - { - Assert.Throws("endpoint", () => new OllamaChatClient((Uri)null!)); - Assert.Throws("modelId", () => new OllamaChatClient("http://localhost", " ")); - } - - [Fact] - public void ToolCallJsonSerializerOptions_HasExpectedValue() - { - using OllamaChatClient client = new("http://localhost", "model"); - - Assert.Same(client.ToolCallJsonSerializerOptions, AIJsonUtilities.DefaultOptions); - Assert.Throws("value", () => client.ToolCallJsonSerializerOptions = null!); - - JsonSerializerOptions options = new(); - client.ToolCallJsonSerializerOptions = options; - Assert.Same(options, client.ToolCallJsonSerializerOptions); - } - - [Fact] - public void GetService_SuccessfullyReturnsUnderlyingClient() - { - using OllamaChatClient client = new("http://localhost"); - - Assert.Same(client, client.GetService()); - Assert.Same(client, client.GetService()); - - using IChatClient pipeline = client - .AsBuilder() - .UseFunctionInvocation() - .UseOpenTelemetry() - .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) - .Build(); - - Assert.NotNull(pipeline.GetService()); - Assert.NotNull(pipeline.GetService()); - Assert.NotNull(pipeline.GetService()); - Assert.NotNull(pipeline.GetService()); - - Assert.Same(client, pipeline.GetService()); - Assert.IsType(pipeline.GetService()); - } - - [Fact] - public void Ctor_ProducesExpectedMetadata() - { - Uri endpoint = new("http://localhost/some/endpoint"); - string model = "amazingModel"; - - using IChatClient chatClient = new OllamaChatClient(endpoint, model); - var metadata = chatClient.GetService(); - Assert.NotNull(metadata); - Assert.Equal("ollama", metadata.ProviderName); - Assert.Equal(endpoint, metadata.ProviderUri); - Assert.Equal(model, metadata.DefaultModelId); - } - - [Fact] - public async Task BasicRequestResponse_NonStreaming() - { - const string Input = """ - { - "model":"llama3.1", - "messages":[{"role":"user","content":"hello"}], - "stream":false, - "options":{"num_predict":10,"temperature":0.5} - } - """; - - const string Output = """ - { - "model": "llama3.1", - "created_at": "2024-10-01T15:46:10.5248793Z", - "message": { - "role": "assistant", - "content": "Hello! How are you today? Is there something" - }, - "done_reason": "length", - "done": true, - "total_duration": 22186844400, - "load_duration": 17947219100, - "prompt_eval_count": 11, - "prompt_eval_duration": 1953805000, - "eval_count": 10, - "eval_duration": 2277274000 - } - """; - - using VerbatimHttpHandler handler = new(Input, Output); - using HttpClient httpClient = new(handler); - using OllamaChatClient client = new("http://localhost:11434", "llama3.1", httpClient); - var response = await client.GetResponseAsync("hello", new() - { - MaxOutputTokens = 10, - Temperature = 0.5f, - }); - Assert.NotNull(response); - - Assert.Equal("Hello! How are you today? Is there something", response.Text); - Assert.Single(response.Messages.Single().Contents); - Assert.Equal(ChatRole.Assistant, response.Messages.Single().Role); - Assert.Equal("llama3.1", response.ModelId); - Assert.Equal(DateTimeOffset.Parse("2024-10-01T15:46:10.5248793Z"), response.CreatedAt); - Assert.Equal(ChatFinishReason.Length, response.FinishReason); - Assert.NotNull(response.Usage); - Assert.Equal(11, response.Usage.InputTokenCount); - Assert.Equal(10, response.Usage.OutputTokenCount); - Assert.Equal(21, response.Usage.TotalTokenCount); - } - - [Fact] - public async Task BasicRequestResponse_Streaming() - { - const string Input = """ - { - "model":"llama3.1", - "messages":[{"role":"user","content":"hello"}], - "stream":true, - "options":{"num_predict":20,"temperature":0.5} - } - """; - - const string Output = """ - {"model":"llama3.1","created_at":"2024-10-01T16:15:20.4965315Z","message":{"role":"assistant","content":"Hello"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:20.763058Z","message":{"role":"assistant","content":"!"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:20.9751134Z","message":{"role":"assistant","content":" How"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:21.1788125Z","message":{"role":"assistant","content":" are"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:21.3883171Z","message":{"role":"assistant","content":" you"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:21.5912498Z","message":{"role":"assistant","content":" today"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:21.7968039Z","message":{"role":"assistant","content":"?"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:22.0034152Z","message":{"role":"assistant","content":" Is"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:22.1931196Z","message":{"role":"assistant","content":" there"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:22.3827484Z","message":{"role":"assistant","content":" something"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:22.5659027Z","message":{"role":"assistant","content":" I"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:22.7488871Z","message":{"role":"assistant","content":" can"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:22.9339881Z","message":{"role":"assistant","content":" help"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:23.1201564Z","message":{"role":"assistant","content":" you"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:23.303447Z","message":{"role":"assistant","content":" with"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:23.4964909Z","message":{"role":"assistant","content":" or"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:23.6837816Z","message":{"role":"assistant","content":" would"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:23.8723142Z","message":{"role":"assistant","content":" you"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:24.064613Z","message":{"role":"assistant","content":" like"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:24.2504498Z","message":{"role":"assistant","content":" to"},"done":false} - {"model":"llama3.1","created_at":"2024-10-01T16:15:24.2514508Z","message":{"role":"assistant","content":""},"done_reason":"length", "done":true,"total_duration":11912402900,"load_duration":6824559200,"prompt_eval_count":11,"prompt_eval_duration":1329601000,"eval_count":20,"eval_duration":3754262000} - """; - - using VerbatimHttpHandler handler = new(Input, Output); - using HttpClient httpClient = new(handler); - using IChatClient client = new OllamaChatClient("http://localhost:11434", "llama3.1", httpClient); - - List updates = []; - var streamingResponse = client.GetStreamingResponseAsync("hello", new() - { - MaxOutputTokens = 20, - Temperature = 0.5f, - }); - await foreach (var update in streamingResponse) - { - updates.Add(update); - } - - Assert.Equal(21, updates.Count); - - DateTimeOffset[] createdAts = Regex.Matches(Output, @"2024.*?Z").Cast().Select(m => DateTimeOffset.Parse(m.Value)).ToArray(); - - for (int i = 0; i < updates.Count; i++) - { - Assert.NotNull(updates[i].ResponseId); - Assert.NotNull(updates[i].MessageId); - Assert.Equal(i < updates.Count - 1 ? 1 : 2, updates[i].Contents.Count); - Assert.Equal(ChatRole.Assistant, updates[i].Role); - Assert.Equal("llama3.1", updates[i].ModelId); - Assert.Equal(createdAts[i], updates[i].CreatedAt); - Assert.Equal(i < updates.Count - 1 ? null : ChatFinishReason.Length, updates[i].FinishReason); - } - - Assert.Equal("Hello! How are you today? Is there something I can help you with or would you like to", string.Concat(updates.Select(u => u.Text))); - Assert.Equal(2, updates[updates.Count - 1].Contents.Count); - Assert.IsType(updates[updates.Count - 1].Contents[0]); - UsageContent usage = Assert.IsType(updates[updates.Count - 1].Contents[1]); - Assert.Equal(11, usage.Details.InputTokenCount); - Assert.Equal(20, usage.Details.OutputTokenCount); - Assert.Equal(31, usage.Details.TotalTokenCount); - - var chatResponse = await streamingResponse.ToChatResponseAsync(); - Assert.Single(Assert.Single(chatResponse.Messages).Contents); - Assert.Equal("Hello! How are you today? Is there something I can help you with or would you like to", chatResponse.Text); - } - - [Fact] - public async Task MultipleMessages_NonStreaming() - { - const string Input = """ - { - "model": "llama3.1", - "messages": [ - { - "role": "user", - "content": "hello!" - }, - { - "role": "assistant", - "content": "hi, how are you?" - }, - { - "role": "user", - "content": "i\u0027m good. how are you?" - } - ], - "stream": false, - "options": { - "frequency_penalty": 0.75, - "presence_penalty": 0.5, - "seed": 42, - "stop": ["great"], - "temperature": 0.25 - } - } - """; - - const string Output = """ - { - "model": "llama3.1", - "created_at": "2024-10-01T17:18:46.308987Z", - "message": { - "role": "assistant", - "content": "I'm just a computer program, so I don't have feelings or emotions like humans do, but I'm functioning properly and ready to help with any questions or tasks you may have! How about we chat about something in particular or just shoot the breeze? Your choice!" - }, - "done_reason": "stop", - "done": true, - "total_duration": 23229369000, - "load_duration": 7724086300, - "prompt_eval_count": 36, - "prompt_eval_duration": 4245660000, - "eval_count": 55, - "eval_duration": 11256470000 - } - """; - - using VerbatimHttpHandler handler = new(Input, Output); - using HttpClient httpClient = new(handler); - using IChatClient client = new OllamaChatClient("http://localhost:11434", httpClient: httpClient); - - List messages = - [ - new(ChatRole.User, "hello!"), - new(ChatRole.Assistant, "hi, how are you?"), - new(ChatRole.User, "i'm good. how are you?"), - ]; - - var response = await client.GetResponseAsync(messages, new() - { - ModelId = "llama3.1", - Temperature = 0.25f, - FrequencyPenalty = 0.75f, - PresencePenalty = 0.5f, - StopSequences = ["great"], - Seed = 42, - }); - Assert.NotNull(response); - - Assert.Equal( - VerbatimHttpHandler.RemoveWhiteSpace(""" - I'm just a computer program, so I don't have feelings or emotions like humans do, - but I'm functioning properly and ready to help with any questions or tasks you may have! - How about we chat about something in particular or just shoot the breeze ? Your choice! - """), - VerbatimHttpHandler.RemoveWhiteSpace(response.Text)); - Assert.Single(response.Messages.Single().Contents); - Assert.Equal(ChatRole.Assistant, response.Messages.Single().Role); - Assert.Equal("llama3.1", response.ModelId); - Assert.Equal(DateTimeOffset.Parse("2024-10-01T17:18:46.308987Z"), response.CreatedAt); - Assert.Equal(ChatFinishReason.Stop, response.FinishReason); - Assert.NotNull(response.Usage); - Assert.Equal(36, response.Usage.InputTokenCount); - Assert.Equal(55, response.Usage.OutputTokenCount); - Assert.Equal(91, response.Usage.TotalTokenCount); - } - - [Fact] - public async Task FunctionCallContent_NonStreaming() - { - const string Input = """ - { - "model": "llama3.1", - "messages": [ - { - "role": "user", - "content": "How old is Alice?" - } - ], - "stream": false, - "tools": [ - { - "type": "function", - "function": { - "name": "GetPersonAge", - "description": "Gets the age of the specified person.", - "parameters": { - "type": "object", - "properties": { - "personName": { - "description": "The person whose age is being requested", - "type": "string" - } - }, - "required": ["personName"] - } - } - } - ] - } - """; - - const string Output = """ - { - "model": "llama3.1", - "created_at": "2024-10-01T18:48:30.2669578Z", - "message": { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "function": { - "name": "GetPersonAge", - "arguments": { - "personName": "Alice" - } - } - } - ] - }, - "done_reason": "stop", - "done": true, - "total_duration": 27351311300, - "load_duration": 8041538400, - "prompt_eval_count": 170, - "prompt_eval_duration": 16078776000, - "eval_count": 19, - "eval_duration": 3227962000 - } - """; - - using VerbatimHttpHandler handler = new(Input, Output); - using HttpClient httpClient = new(handler) { Timeout = Timeout.InfiniteTimeSpan }; - using IChatClient client = new OllamaChatClient("http://localhost:11434", "llama3.1", httpClient) - { - ToolCallJsonSerializerOptions = TestJsonSerializerContext.Default.Options, - }; - - var response = await client.GetResponseAsync("How old is Alice?", new() - { - Tools = [AIFunctionFactory.Create(([Description("The person whose age is being requested")] string personName) => 42, "GetPersonAge", "Gets the age of the specified person.")], - }); - Assert.NotNull(response); - - Assert.Empty(response.Text); - Assert.Equal("llama3.1", response.ModelId); - Assert.Equal(ChatRole.Assistant, response.Messages.Single().Role); - Assert.Equal(DateTimeOffset.Parse("2024-10-01T18:48:30.2669578Z"), response.CreatedAt); - Assert.Equal(ChatFinishReason.Stop, response.FinishReason); - Assert.NotNull(response.Usage); - Assert.Equal(170, response.Usage.InputTokenCount); - Assert.Equal(19, response.Usage.OutputTokenCount); - Assert.Equal(189, response.Usage.TotalTokenCount); - - Assert.Single(response.Messages.Single().Contents); - FunctionCallContent fcc = Assert.IsType(response.Messages.Single().Contents[0]); - Assert.Equal("GetPersonAge", fcc.Name); - AssertExtensions.EqualFunctionCallParameters(new Dictionary { ["personName"] = "Alice" }, fcc.Arguments); - } - - [Fact] - public async Task FunctionResultContent_NonStreaming() - { - const string Input = """ - { - "model": "llama3.1", - "messages": [ - { - "role": "user", - "content": "How old is Alice?" - }, - { - "role": "assistant", - "content": "{\u0022call_id\u0022:\u0022abcd1234\u0022,\u0022name\u0022:\u0022GetPersonAge\u0022,\u0022arguments\u0022:{\u0022personName\u0022:\u0022Alice\u0022}}" - }, - { - "role": "tool", - "content": "{\u0022call_id\u0022:\u0022abcd1234\u0022,\u0022result\u0022:42}" - } - ], - "stream": false, - "tools": [ - { - "type": "function", - "function": { - "name": "GetPersonAge", - "description": "Gets the age of the specified person.", - "parameters": { - "type": "object", - "properties": { - "personName": { - "description": "The person whose age is being requested", - "type": "string" - } - }, - "required": ["personName"] - } - } - } - ] - } - """; - - const string Output = """ - { - "model": "llama3.1", - "created_at": "2024-10-01T20:57:20.157266Z", - "message": { - "role": "assistant", - "content": "Alice is 42 years old." - }, - "done_reason": "stop", - "done": true, - "total_duration": 20320666000, - "load_duration": 8159642600, - "prompt_eval_count": 106, - "prompt_eval_duration": 10846727000, - "eval_count": 8, - "eval_duration": 1307842000 - } - """; - - using VerbatimHttpHandler handler = new(Input, Output); - using HttpClient httpClient = new(handler) { Timeout = Timeout.InfiniteTimeSpan }; - using IChatClient client = new OllamaChatClient("http://localhost:11434", "llama3.1", httpClient) - { - ToolCallJsonSerializerOptions = TestJsonSerializerContext.Default.Options, - }; - - var response = await client.GetResponseAsync( - [ - new(ChatRole.User, "How old is Alice?"), - new(ChatRole.Assistant, [new FunctionCallContent("abcd1234", "GetPersonAge", new Dictionary { ["personName"] = "Alice" })]), - new(ChatRole.Tool, [new FunctionResultContent("abcd1234", 42)]), - ], - new() - { - Tools = [AIFunctionFactory.Create(([Description("The person whose age is being requested")] string personName) => 42, "GetPersonAge", "Gets the age of the specified person.")], - }); - Assert.NotNull(response); - - Assert.Equal("Alice is 42 years old.", response.Text); - Assert.Equal("llama3.1", response.ModelId); - Assert.Equal(ChatRole.Assistant, response.Messages.Single().Role); - Assert.Equal(DateTimeOffset.Parse("2024-10-01T20:57:20.157266Z"), response.CreatedAt); - Assert.Equal(ChatFinishReason.Stop, response.FinishReason); - Assert.NotNull(response.Usage); - Assert.Equal(106, response.Usage.InputTokenCount); - Assert.Equal(8, response.Usage.OutputTokenCount); - Assert.Equal(114, response.Usage.TotalTokenCount); - } -} diff --git a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaEmbeddingGeneratorIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaEmbeddingGeneratorIntegrationTests.cs deleted file mode 100644 index 493c0bf0333..00000000000 --- a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaEmbeddingGeneratorIntegrationTests.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Threading.Tasks; -using Microsoft.TestUtilities; -using Xunit; - -namespace Microsoft.Extensions.AI; - -public class OllamaEmbeddingGeneratorIntegrationTests : EmbeddingGeneratorIntegrationTests -{ - protected override IEmbeddingGenerator>? CreateEmbeddingGenerator() => - IntegrationTestHelpers.GetOllamaUri() is Uri endpoint ? - new OllamaEmbeddingGenerator(endpoint, "all-minilm") : - null; - - [ConditionalFact] - public async Task InvalidModelParameter_ThrowsInvalidOperationException() - { - SkipIfNotEnabled(); - - var endpoint = IntegrationTestHelpers.GetOllamaUri(); - Assert.NotNull(endpoint); - - using var generator = new OllamaEmbeddingGenerator(endpoint, modelId: "inexistent-model"); - - InvalidOperationException ex; - ex = await Assert.ThrowsAsync(() => generator.GenerateAsync(["Hello, world!"])); - Assert.Contains("inexistent-model", ex.Message); - } -} diff --git a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaEmbeddingGeneratorTests.cs deleted file mode 100644 index be18138de84..00000000000 --- a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/OllamaEmbeddingGeneratorTests.cs +++ /dev/null @@ -1,102 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Net.Http; -using System.Threading.Tasks; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Caching.Memory; -using Xunit; - -#pragma warning disable S103 // Lines should not be too long - -namespace Microsoft.Extensions.AI; - -public class OllamaEmbeddingGeneratorTests -{ - [Fact] - public void Ctor_InvalidArgs_Throws() - { - Assert.Throws("endpoint", () => new OllamaEmbeddingGenerator((string)null!)); - Assert.Throws("modelId", () => new OllamaEmbeddingGenerator(new Uri("http://localhost"), " ")); - } - - [Fact] - public void GetService_SuccessfullyReturnsUnderlyingClient() - { - using OllamaEmbeddingGenerator generator = new("http://localhost"); - - Assert.Same(generator, generator.GetService()); - Assert.Same(generator, generator.GetService>>()); - - using IEmbeddingGenerator> pipeline = generator - .AsBuilder() - .UseOpenTelemetry() - .UseDistributedCache(new MemoryDistributedCache(Options.Options.Create(new MemoryDistributedCacheOptions()))) - .Build(); - - Assert.NotNull(pipeline.GetService>>()); - Assert.NotNull(pipeline.GetService>>()); - Assert.NotNull(pipeline.GetService>>()); - - Assert.Same(generator, pipeline.GetService()); - Assert.IsType>>(pipeline.GetService>>()); - } - - [Fact] - public void AsIEmbeddingGenerator_ProducesExpectedMetadata() - { - Uri endpoint = new("http://localhost/some/endpoint"); - string model = "amazingModel"; - - using IEmbeddingGenerator> generator = new OllamaEmbeddingGenerator(endpoint, model); - var metadata = generator.GetService(); - Assert.Equal("ollama", metadata?.ProviderName); - Assert.Equal(endpoint, metadata?.ProviderUri); - Assert.Equal(model, metadata?.DefaultModelId); - } - - [Fact] - public async Task GetEmbeddingsAsync_ExpectedRequestResponse() - { - const string Input = """ - {"model":"all-minilm","input":["hello, world!","red, white, blue"]} - """; - - const string Output = """ - { - "model":"all-minilm", - "embeddings":[ - [-0.038159743,0.032830726,-0.005602915,0.014363416,-0.04031945,-0.11662117,0.031710647,0.0019634133,-0.042558126,0.02925818,0.04254404,0.032178584,0.029820565,0.010947956,-0.05383333,-0.05031401,-0.023460664,0.010746779,-0.13776828,0.003972192,0.029283607,0.06673441,-0.015434976,0.048401773,-0.088160664,-0.012700827,0.04134059,0.0408592,-0.050058633,-0.058048956,0.048720006,0.068883754,0.0588242,0.008813041,-0.016036017,0.08514798,-0.07813561,-0.07740018,0.020856613,0.016228318,0.032506905,-0.053466275,-0.06220645,-0.024293836,0.0073994277,0.02410873,0.006477103,0.051144805,0.072868116,0.03460658,-0.0547553,-0.05937917,-0.007205277,0.020145971,0.035794333,0.005588114,0.010732389,-0.052755248,0.01006711,-0.008716047,-0.062840104,0.038445882,-0.013913384,0.07341423,0.09004691,-0.07995187,-0.016410379,0.044806693,-0.06886798,-0.03302609,-0.015488586,0.0112944925,0.03645402,0.06637969,-0.054364193,0.008732196,0.012049053,-0.038111813,0.006928739,0.05113517,0.07739711,-0.12295967,0.016389083,0.049567502,0.03162499,-0.039604694,0.0016613991,0.009564599,-0.03268798,-0.033994347,-0.13328508,0.0072719813,-0.010261588,0.038570367,-0.093384996,-0.041716397,0.069951184,-0.02632818,-0.149702,0.13445856,0.037486482,0.052814852,0.045044158,0.018727085,0.05445453,0.01727433,-0.032474063,0.046129994,-0.046679277,-0.03058037,-0.0181755,-0.048695795,0.033057086,-0.0038555008,0.050006237,-0.05828653,-0.010029618,0.01062073,-0.040105496,-0.0015263702,0.060846698,-0.04557025,0.049251337,0.026121102,0.019804202,-0.0016694543,0.059516467,-6.525171e-33,0.06351319,0.0030810465,0.028928237,0.17336167,0.0029677018,0.027755935,-0.09513812,-0.031182382,0.026697554,-0.0107956175,0.023849761,0.02378595,-0.03121345,0.049473017,-0.02506533,0.101713106,-0.079133175,-0.0032418896,0.04290832,0.094838716,-0.06652884,0.0062877694,0.02221229,0.0700068,-0.007469806,-0.0017550732,0.027011596,-0.075321496,0.114022695,0.0085597,-0.023766534,-0.04693697,0.014437173,0.01987886,-0.0046902793,0.0013660098,-0.034307938,-0.054156985,-0.09417741,-0.028919358,-0.018871028,0.04574328,0.047602862,-0.0031305805,-0.033291575,-0.0135114025,0.051019657,0.031115327,0.015239397,0.05413997,-0.085031144,0.013366392,-0.04757861,0.07102588,-0.013105953,-0.0023799809,0.050322797,-0.041649505,-0.014187793,0.0324716,0.005401626,0.091307014,0.0044665188,-0.018263677,-0.015284639,-0.04634121,0.038754962,0.014709013,0.052040145,0.0017918312,-0.014979437,0.027103048,0.03117813,0.023749126,-0.004567645,0.03617759,0.06680814,-0.001835277,0.021281,-0.057563916,0.019137124,0.031450257,-0.018432263,-0.040860977,0.10391725,0.011970765,-0.014854915,-0.10521159,-0.012288272,-0.00041675335,-0.09510029,0.058300544,0.042590536,-0.025064372,-0.09454636,4.0064686e-33,0.13224861,0.0053342036,-0.033114634,-0.09096768,-0.031561732,-0.03395822,-0.07202013,0.12591493,-0.08332582,0.052816514,0.001065021,0.022002738,0.1040207,0.013038866,0.04092958,0.018689224,0.1142518,0.024801003,0.014596161,0.006195551,-0.011214642,-0.035760444,-0.037979998,0.011274433,-0.051305123,0.007884909,0.06734877,0.0033462204,-0.09284879,0.037033774,-0.022331867,0.039951596,-0.030730229,-0.011403805,-0.014458028,0.024968812,-0.097553216,-0.03536226,-0.037567392,-0.010149212,-0.06387594,0.025570663,0.02060328,0.037549157,-0.104355134,-0.02837097,-0.052078977,0.0128349,-0.05123587,-0.029060647,-0.09632806,-0.042301137,0.067175224,-0.030890828,-0.010358077,0.027408795,-0.028092034,0.010337195,0.04303845,0.022324203,0.00797792,0.056084383,0.040727936,0.092925824,0.01653155,-0.053750493,0.00046004262,0.050728552,0.04253214,-0.029197674,0.00926312,-0.010662153,-0.037244495,0.002277273,-0.030296732,0.07459592,0.002572513,-0.017561244,0.0028881067,0.03841156,0.007247727,0.045637112,0.039992437,0.014227117,-0.014297474,0.05854321,0.03632371,0.05527864,-0.02007574,-0.08043163,-0.030238612,-0.014929122,0.022335418,0.011954643,-0.06906099,-1.8807288e-8,-0.07850291,0.046684187,-0.023935271,0.063510746,0.024001691,0.0014455577,-0.09078209,-0.066868275,-0.0801402,0.005480386,0.053663295,0.10483363,-0.066864185,0.015531167,0.06711155,0.07081655,-0.031996343,0.020819444,-0.021926524,-0.0073062326,-0.010652819,0.0041180425,0.033138428,-0.0789938,0.03876969,-0.075220205,-0.015715994,0.0059789424,0.005140016,-0.06150612,0.041992374,0.09544083,-0.043187104,0.014401576,-0.10615426,-0.027936764,0.011047429,0.069572434,0.06690283,-0.074798405,-0.07852024,0.04276141,-0.034642085,-0.106051244,-0.03581038,0.051521253,0.06865896,-0.04999753,0.0154549,-0.06452052,-0.07598782,0.02603005,0.074413665,-0.012398757,0.13330704,0.07475513,0.051348723,0.02098748,-0.02679416,0.08896129,0.039944872,-0.041040305,0.031930625,0.018114654], - [0.007228383,-0.021804843,-0.07494023,-0.021707121,-0.021184582,0.09326986,0.10764054,-0.01918113,0.007439991,0.01367952,-0.034187328,-0.044076536,0.016042138,0.007507193,-0.016432272,0.025345335,0.010598066,-0.03832474,-0.14418823,-0.033625234,0.013156937,-0.0048872638,-0.08534306,-0.00003228713,-0.08900276,-0.00008128615,0.010332802,0.053303026,-0.050233904,-0.0879366,-0.064243905,-0.017168961,0.1284308,-0.015268303,-0.049664143,-0.07491954,0.021887481,0.015997978,-0.07967111,0.08744341,-0.039261423,-0.09904984,0.02936398,0.042995434,0.057036504,0.09063012,0.0000012311281,0.06120768,-0.050825767,-0.014443322,0.02879051,-0.002343813,-0.10176559,0.104563184,0.031316753,0.08251861,-0.041213628,-0.0217945,0.0649965,-0.011131547,0.018417398,-0.014460508,-0.05108664,0.11330918,0.01863208,0.006442521,-0.039408617,-0.03609412,-0.009156692,-0.0031261789,-0.010928502,-0.021108521,0.037411734,0.012443921,0.018142054,-0.0362644,0.058286663,-0.02733258,-0.052172586,-0.08320095,-0.07089281,-0.0970049,-0.048587535,0.055343032,0.048351917,0.06892102,-0.039993215,0.06344781,-0.084417015,0.003692423,-0.059397053,0.08186814,0.0029228176,-0.010551637,-0.058019258,0.092128515,0.06862907,-0.06558893,0.021121018,0.079212844,0.09616225,0.0045106052,0.039712362,-0.053576704,0.035097837,-0.04251009,-0.013761404,0.011582285,0.02387105,0.009042205,0.054141942,-0.051263757,-0.07984356,-0.020198742,-0.051623948,-0.0013434993,-0.05825417,-0.0026240738,0.0050159167,-0.06320204,0.07872169,-0.04051374,0.04671058,-0.05804034,-0.07103668,-0.07507343,0.015222599,-3.0948323e-33,0.0076309564,-0.06283016,0.024291662,0.12532257,0.013917241,0.04869009,-0.037988827,-0.035241846,-0.041410565,-0.033772282,0.018835608,0.081035286,-0.049912665,0.044602085,0.030495265,-0.009206943,0.027668765,0.011651487,-0.10254086,0.054472663,-0.06514106,0.12192646,0.048823033,-0.015688669,0.010323047,-0.02821445,-0.030832449,-0.035029083,-0.010604268,0.0014445938,0.08670387,0.01997448,0.0101131955,0.036524937,-0.033489946,-0.026745271,-0.04709222,0.015197909,0.018787097,-0.009976326,-0.0016434817,-0.024719588,-0.09179337,0.09343157,0.029579962,-0.015174558,0.071250066,0.010549244,0.010716396,0.05435638,-0.06391847,-0.031383075,0.007916095,0.012391228,-0.012053197,-0.017409964,0.013742709,0.0594159,-0.033767693,0.04505938,-0.0017214329,0.12797962,0.03223919,-0.054756388,0.025249248,-0.02273578,-0.04701282,-0.018718086,0.009820931,-0.06267794,-0.012644738,0.0068301614,0.093209736,-0.027372226,-0.09436381,0.003861504,0.054960024,-0.058553983,-0.042971537,-0.008994571,-0.08225824,-0.013560626,-0.01880568,0.0995795,-0.040887516,-0.0036491079,-0.010253542,-0.031025425,-0.006957114,-0.038943008,-0.090270124,-0.031345647,0.029613726,-0.099465184,-0.07469079,7.844707e-34,0.024241973,0.03597121,-0.049776066,0.05084303,0.006059542,-0.020719761,0.019962702,0.092246406,0.069408394,0.062306542,0.013837189,0.054749023,0.05090263,0.04100415,-0.02573441,0.09535842,0.036858294,0.059478357,0.0070162765,0.038462427,-0.053635903,0.05912332,-0.037887845,-0.0012995935,-0.068758026,0.0671618,0.029407106,-0.061569903,-0.07481879,-0.01849014,0.014240046,-0.08064838,0.028351007,0.08456427,0.016858438,0.02053254,0.06171099,-0.028964644,-0.047633287,0.08802184,0.0017116248,0.019451816,0.03419083,0.07152118,-0.027244413,-0.04888475,-0.10314279,0.07628554,-0.045991484,-0.023299307,-0.021448445,0.04111079,-0.036342163,-0.010670482,0.01950527,-0.0648448,-0.033299454,0.05782628,0.030278979,0.079154804,-0.03679649,0.031728156,-0.034912236,0.08817754,0.059208114,-0.02319613,-0.027045371,-0.018559752,-0.051946763,-0.010635224,0.048839167,-0.043925915,-0.028300019,-0.0039419765,0.044211324,-0.067469835,-0.027534118,0.005051618,-0.034172326,0.080007285,-0.01931061,-0.005759926,0.08765162,0.08372951,-0.093784876,0.011837292,0.019019455,0.047941882,0.05504541,-0.12475821,0.012822803,0.12833545,0.08005919,0.019278418,-0.025834465,-1.9763878e-8,0.05211108,0.024891146,-0.0015623684,0.0040500895,0.015101377,-0.0031462535,0.014759316,-0.041329216,-0.029255627,0.048599463,0.062482737,0.018376771,-0.066601776,0.014752581,0.07968402,-0.015090815,-0.12100162,-0.0014005995,0.0134423375,-0.0065814927,-0.01188529,-0.01107086,-0.059613306,0.030120188,0.0418596,-0.009260598,0.028435009,0.024893047,0.031339604,0.09501834,0.027570697,0.0636991,-0.056108754,-0.0329521,-0.114633024,-0.00981398,-0.060992315,0.027551433,0.0069592255,-0.059862003,0.0008075791,0.001507554,-0.028574942,-0.011227367,0.0056030746,-0.041190825,-0.09364463,-0.04459479,-0.055058934,-0.029972456,-0.028642913,-0.015199684,0.007875299,-0.034083385,0.02143902,-0.017395096,0.027429376,0.013198211,0.005065835,0.037760753,0.08974973,0.07598824,0.0050444477,0.014734193] - ], - "total_duration":375551700, - "load_duration":354411900, - "prompt_eval_count":9 - } - """; - - using VerbatimHttpHandler handler = new(Input, Output); - using HttpClient httpClient = new(handler); - using IEmbeddingGenerator> generator = new OllamaEmbeddingGenerator("http://localhost:11434", "all-minilm", httpClient); - - var response = await generator.GenerateAsync([ - "hello, world!", - "red, white, blue", - ]); - Assert.NotNull(response); - Assert.Equal(2, response.Count); - - Assert.NotNull(response.Usage); - Assert.Equal(9, response.Usage.InputTokenCount); - Assert.Equal(9, response.Usage.TotalTokenCount); - - foreach (Embedding e in response) - { - Assert.Equal("all-minilm", e.ModelId); - Assert.NotNull(e.CreatedAt); - Assert.Equal(384, e.Vector.Length); - Assert.Contains(e.Vector.ToArray(), f => !f.Equals(0)); - } - } -} diff --git a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/TestJsonSerializerContext.cs b/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/TestJsonSerializerContext.cs deleted file mode 100644 index 49560a9c451..00000000000 --- a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/TestJsonSerializerContext.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace Microsoft.Extensions.AI; - -[JsonSerializable(typeof(string))] -[JsonSerializable(typeof(int))] -[JsonSerializable(typeof(IDictionary))] -internal sealed partial class TestJsonSerializerContext : JsonSerializerContext; diff --git a/test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/IntegrationTestHelpers.cs b/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/IntegrationTestHelpers.cs similarity index 100% rename from test/Libraries/Microsoft.Extensions.AI.Ollama.Tests/IntegrationTestHelpers.cs rename to test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/IntegrationTestHelpers.cs diff --git a/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests.csproj index 14ca7e244d1..d977c035279 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests.csproj @@ -9,7 +9,7 @@ - + diff --git a/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/OllamaSharpChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/OllamaSharpChatClientIntegrationTests.cs index 921e2d3b5f9..28d3e21fd65 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/OllamaSharpChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/OllamaSharpChatClientIntegrationTests.cs @@ -2,14 +2,115 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.TestUtilities; using OllamaSharp; +using Xunit; namespace Microsoft.Extensions.AI; -public class OllamaSharpChatClientIntegrationTests : OllamaChatClientIntegrationTests +public class OllamaSharpChatClientIntegrationTests : ChatClientIntegrationTests { protected override IChatClient? CreateChatClient() => IntegrationTestHelpers.GetOllamaUri() is Uri endpoint ? new OllamaApiClient(endpoint, "llama3.2") : null; + + public override Task FunctionInvocation_RequireAny() => + throw new SkipTestException("Ollama does not currently support requiring function invocation."); + + public override Task FunctionInvocation_RequireSpecific() => + throw new SkipTestException("Ollama does not currently support requiring function invocation."); + + protected override string? GetModel_MultiModal_DescribeImage() => "llava"; + + [ConditionalFact] + public async Task PromptBasedFunctionCalling_NoArgs() + { + SkipIfNotEnabled(); + + using var chatClient = CreateChatClient()! + .AsBuilder() + .UseFunctionInvocation() + .UsePromptBasedFunctionCalling() + .Use(innerClient => new AssertNoToolsDefinedChatClient(innerClient)) + .Build(); + + var secretNumber = 42; + var response = await chatClient.GetResponseAsync("What is the current secret number? Answer with digits only.", new ChatOptions + { + ModelId = "llama3:8b", + Tools = [AIFunctionFactory.Create(() => secretNumber, "GetSecretNumber")], + Temperature = 0, + Seed = 0, + }); + + Assert.Contains(secretNumber.ToString(), response.Text); + } + + [ConditionalFact] + public async Task PromptBasedFunctionCalling_WithArgs() + { + SkipIfNotEnabled(); + + using var chatClient = CreateChatClient()! + .AsBuilder() + .UseFunctionInvocation() + .UsePromptBasedFunctionCalling() + .Use(innerClient => new AssertNoToolsDefinedChatClient(innerClient)) + .Build(); + + var stockPriceTool = AIFunctionFactory.Create([Description("Returns the stock price for a given ticker symbol")] ( + [Description("The ticker symbol")] string symbol, + [Description("The currency code such as USD or JPY")] string currency) => + { + Assert.Equal("MSFT", symbol); + Assert.Equal("GBP", currency); + return 999; + }, "GetStockPrice"); + + var didCallIrrelevantTool = false; + var irrelevantTool = AIFunctionFactory.Create(() => { didCallIrrelevantTool = true; return 123; }, "GetSecretNumber"); + + var response = await chatClient.GetResponseAsync("What's the stock price for Microsoft in British pounds?", new ChatOptions + { + Tools = [stockPriceTool, irrelevantTool], + Temperature = 0, + Seed = 0, + }); + + Assert.Contains("999", response.Text); + Assert.False(didCallIrrelevantTool); + } + + [ConditionalFact] + public async Task InvalidModelParameter_ThrowsInvalidOperationException() + { + SkipIfNotEnabled(); + + var endpoint = IntegrationTestHelpers.GetOllamaUri(); + Assert.NotNull(endpoint); + + using var chatClient = new OllamaApiClient(endpoint, defaultModel: "inexistent-model"); + + InvalidOperationException ex; + ex = await Assert.ThrowsAsync(() => chatClient.GetResponseAsync("Hello, world!")); + Assert.Contains("inexistent-model", ex.Message); + + ex = await Assert.ThrowsAsync(() => chatClient.GetStreamingResponseAsync("Hello, world!").ToChatResponseAsync()); + Assert.Contains("inexistent-model", ex.Message); + } + + private sealed class AssertNoToolsDefinedChatClient(IChatClient innerClient) : DelegatingChatClient(innerClient) + { + public override Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + Assert.Null(options?.Tools); + return base.GetResponseAsync(messages, options, cancellationToken); + } + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/OllamaSharpEmbeddingGeneratorIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/OllamaSharpEmbeddingGeneratorIntegrationTests.cs index 1826855f459..f7775143c36 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/OllamaSharpEmbeddingGeneratorIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/OllamaSharpEmbeddingGeneratorIntegrationTests.cs @@ -2,14 +2,35 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Threading.Tasks; +using Microsoft.TestUtilities; using OllamaSharp; +using Xunit; namespace Microsoft.Extensions.AI; -public class OllamaSharpEmbeddingGeneratorIntegrationTests : OllamaEmbeddingGeneratorIntegrationTests +public class OllamaSharpEmbeddingGeneratorIntegrationTests : EmbeddingGeneratorIntegrationTests { protected override IEmbeddingGenerator>? CreateEmbeddingGenerator() => IntegrationTestHelpers.GetOllamaUri() is Uri endpoint ? new OllamaApiClient(endpoint, "all-minilm") : null; + + [ConditionalFact] + public async Task InvalidModelParameter_ThrowsInvalidOperationException() + { + SkipIfNotEnabled(); + + var endpoint = IntegrationTestHelpers.GetOllamaUri(); + Assert.NotNull(endpoint); + + using var client = new OllamaApiClient(endpoint, defaultModel: "inexistent-model"); + + InvalidOperationException ex; + ex = await Assert.ThrowsAsync(() => client.EmbedAsync(new OllamaSharp.Models.EmbedRequest + { + Input = ["Hello, world!"], + })); + Assert.Contains("inexistent-model", ex.Message); + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/OllamaSharpImageGeneratingChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/OllamaSharpImageGeneratingChatClientIntegrationTests.cs new file mode 100644 index 00000000000..7a6f60af778 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.OllamaSharp.Integration.Tests/OllamaSharpImageGeneratingChatClientIntegrationTests.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using OllamaSharp; + +namespace Microsoft.Extensions.AI; + +/// +/// OllamaSharp-specific integration tests for ImageGeneratingChatClient. +/// Tests the ImageGeneratingChatClient with OllamaSharp chat client implementation. +/// +public class OllamaSharpImageGeneratingChatClientIntegrationTests : ImageGeneratingChatClientIntegrationTests +{ + protected override IChatClient? CreateChatClient() => + IntegrationTestHelpers.GetOllamaUri() is Uri endpoint ? + new OllamaApiClient(endpoint, "llama3.2") : + null; +} diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/IntegrationTestHelpers.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/IntegrationTestHelpers.cs index 9d8f806ca8a..d06f20c3f74 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/IntegrationTestHelpers.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/IntegrationTestHelpers.cs @@ -3,9 +3,8 @@ using System; using System.ClientModel; -using Azure.AI.OpenAI; +using System.ClientModel.Primitives; using Azure.Identity; -using Microsoft.Extensions.Configuration; using OpenAI; namespace Microsoft.Extensions.AI; @@ -26,14 +25,13 @@ internal static class IntegrationTestHelpers var endpoint = configuration["OpenAI:Endpoint"] ?? throw new InvalidOperationException("To use AzureOpenAI, set a value for OpenAI:Endpoint"); - if (apiKey is not null) - { - return new AzureOpenAIClient(new Uri(endpoint), new ApiKeyCredential(apiKey)); - } - else - { - return new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()); - } + // Use Azure endpoint with /openai/v1 suffix + var options = new OpenAIClientOptions { Endpoint = new Uri(new Uri(endpoint), "/openai/v1") }; + return apiKey is not null ? + new OpenAIClient(new ApiKeyCredential(apiKey), options) : + new OpenAIClient( + new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"), + options); } else if (apiKey is not 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 index 536c250cb47..093260d779c 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 @@ -32,7 +32,7 @@ - + diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAIFunctionConversionTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAIFunctionConversionTests.cs deleted file mode 100644 index ce458473c59..00000000000 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAIFunctionConversionTests.cs +++ /dev/null @@ -1,77 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.ComponentModel; -using System.Text.Json; -using OpenAI.Assistants; -using OpenAI.Chat; -using OpenAI.Realtime; -using OpenAI.Responses; -using Xunit; - -namespace Microsoft.Extensions.AI; - -public class OpenAIAIFunctionConversionTests -{ - private static readonly AIFunction _testFunction = AIFunctionFactory.Create( - ([Description("The name parameter")] string name) => name, - "test_function", - "A test function for conversion"); - - [Fact] - public void AsOpenAIChatTool_ProducesValidInstance() - { - var tool = _testFunction.AsOpenAIChatTool(); - - Assert.NotNull(tool); - Assert.Equal("test_function", tool.FunctionName); - Assert.Equal("A test function for conversion", tool.FunctionDescription); - ValidateSchemaParameters(tool.FunctionParameters); - } - - [Fact] - public void AsOpenAIResponseTool_ProducesValidInstance() - { - var tool = _testFunction.AsOpenAIResponseTool(); - - Assert.NotNull(tool); - } - - [Fact] - public void AsOpenAIConversationFunctionTool_ProducesValidInstance() - { - var tool = _testFunction.AsOpenAIConversationFunctionTool(); - - Assert.NotNull(tool); - Assert.Equal("test_function", tool.Name); - Assert.Equal("A test function for conversion", tool.Description); - ValidateSchemaParameters(tool.Parameters); - } - - [Fact] - public void AsOpenAIAssistantsFunctionToolDefinition_ProducesValidInstance() - { - var tool = _testFunction.AsOpenAIAssistantsFunctionToolDefinition(); - - Assert.NotNull(tool); - Assert.Equal("test_function", tool.FunctionName); - Assert.Equal("A test function for conversion", tool.Description); - ValidateSchemaParameters(tool.Parameters); - } - - /// Helper method to validate function parameters match our schema. - private static void ValidateSchemaParameters(BinaryData parameters) - { - Assert.NotNull(parameters); - - using var jsonDoc = JsonDocument.Parse(parameters); - var root = jsonDoc.RootElement; - - Assert.Equal("object", root.GetProperty("type").GetString()); - Assert.True(root.TryGetProperty("properties", out var properties)); - Assert.True(properties.TryGetProperty("name", out var nameProperty)); - Assert.Equal("string", nameProperty.GetProperty("type").GetString()); - Assert.Equal("The name parameter", nameProperty.GetProperty("description").GetString()); - } -} diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientIntegrationTests.cs index 90bcf9f2632..ef9d6063ddd 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientIntegrationTests.cs @@ -12,6 +12,7 @@ using System.Net.Http; using System.Text.RegularExpressions; using System.Threading.Tasks; +using Microsoft.TestUtilities; using OpenAI.Assistants; using Xunit; @@ -45,6 +46,59 @@ public class OpenAIAssistantChatClientIntegrationTests : ChatClientIntegrationTe public override Task MultiModal_DescribeImage() => Task.CompletedTask; public override Task MultiModal_DescribePdf() => Task.CompletedTask; + [ConditionalFact] + public async Task UseCodeInterpreter_ProducesCodeExecutionResults() + { + SkipIfNotEnabled(); + + var response = await ChatClient.GetResponseAsync("Use the code interpreter to calculate the square root of 42.", new() + { + Tools = [new HostedCodeInterpreterTool()], + }); + Assert.NotNull(response); + + ChatMessage message = Assert.Single(response.Messages); + + Assert.NotEmpty(message.Text); + + // Validate CodeInterpreterToolCallContent + var toolCallContent = response.Messages.SelectMany(m => m.Contents).OfType().SingleOrDefault(); + Assert.NotNull(toolCallContent); + if (toolCallContent.CallId is not null) + { + Assert.NotEmpty(toolCallContent.CallId); + } + + if (toolCallContent.Inputs is not null) + { + Assert.NotEmpty(toolCallContent.Inputs); + if (toolCallContent.Inputs.OfType().FirstOrDefault() is { } codeInput) + { + Assert.Equal("text/x-python", codeInput.MediaType); + Assert.NotEmpty(codeInput.Data.ToArray()); + } + } + + // Validate CodeInterpreterToolResultContent (when present) + var toolResultContents = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); + foreach (var toolResultContent in toolResultContents) + { + if (toolResultContent.CallId is not null) + { + Assert.NotEmpty(toolResultContent.CallId); + } + + if (toolResultContent.Outputs is not null) + { + Assert.NotEmpty(toolResultContent.Outputs); + if (toolResultContent.Outputs.OfType().FirstOrDefault() is { } resultOutput) + { + Assert.NotEmpty(resultOutput.Text); + } + } + } + } + // [Fact] // uncomment and run to clear out _all_ threads in your OpenAI account public async Task DeleteAllThreads() { diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientTests.cs index 3b084b5ec8a..7779e4cf18d 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientTests.cs @@ -19,7 +19,8 @@ public class OpenAIAssistantChatClientTests public void AsIChatClient_InvalidArgs_Throws() { Assert.Throws("assistantClient", () => ((AssistantClient)null!).AsIChatClient("assistantId")); - Assert.Throws("assistantId", () => new AssistantClient("ignored").AsIChatClient(null!)); + Assert.Throws("assistantId", () => new AssistantClient("ignored").AsIChatClient((string)null!)); + Assert.Throws("assistant", () => new AssistantClient("ignored").AsIChatClient((Assistant)null!)); } [Fact] diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientIntegrationTests.cs index 6322e3d6b64..a9e08a58e52 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientIntegrationTests.cs @@ -8,4 +8,8 @@ public class OpenAIChatClientIntegrationTests : ChatClientIntegrationTests protected override IChatClient? CreateChatClient() => IntegrationTestHelpers.GetOpenAIClient() ?.GetChatClient(TestRunnerConfiguration.Instance["OpenAI:ChatModel"] ?? "gpt-4o-mini").AsIChatClient(); + + protected override IEmbeddingGenerator>? CreateEmbeddingGenerator() => + IntegrationTestHelpers.GetOpenAIClient() + ?.GetEmbeddingClient(TestRunnerConfiguration.Instance["OpenAI:EmbeddingModel"] ?? "text-embedding-3-small").AsIEmbeddingGenerator(); } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs index d06d8f520be..5e4932c6736 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs @@ -8,8 +8,6 @@ using System.ComponentModel; using System.Linq; using System.Net.Http; -using System.Text.Json; -using System.Text.Json.Serialization; using System.Threading.Tasks; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; @@ -154,6 +152,7 @@ public async Task BasicRequestResponse_NonStreaming() var response = await client.GetResponseAsync("hello", new() { + AllowMultipleToolCalls = false, MaxOutputTokens = 10, Temperature = 0.5f, }); @@ -398,7 +397,7 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio ResponseFormat = OpenAI.Chat.ChatResponseFormat.CreateTextFormat() }; openAIOptions.StopSequences.Add("hello"); - openAIOptions.Tools.Add(OpenAIClientExtensions.AsOpenAIChatTool(tool)); + openAIOptions.Tools.Add(tool.AsOpenAIChatTool()); return openAIOptions; }, ModelId = null, @@ -475,7 +474,7 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio ResponseFormat = OpenAI.Chat.ChatResponseFormat.CreateTextFormat() }; openAIOptions.StopSequences.Add("hello"); - openAIOptions.Tools.Add(OpenAIClientExtensions.AsOpenAIChatTool(tool)); + openAIOptions.Tools.Add(tool.AsOpenAIChatTool()); return openAIOptions; }, ModelId = null, // has no effect, you cannot change the model of an OpenAI's ChatClient. @@ -655,36 +654,51 @@ public async Task ChatOptions_Overwrite_NullPropertiesInRawRepresentation_Stream Assert.Equal("Hello! How can I assist you today?", responseText); } - /// Used to create the JSON payload for an OpenAI chat tool description. - internal sealed class ChatToolJson - { - [JsonPropertyName("type")] - public string Type { get; set; } = "object"; - - [JsonPropertyName("required")] - public HashSet Required { get; set; } = []; - - [JsonPropertyName("properties")] - public Dictionary Properties { get; set; } = []; - - [JsonPropertyName("additionalProperties")] - public bool AdditionalProperties { get; set; } - } - [Fact] public async Task StronglyTypedOptions_AllSent() { const string Input = """ { - "messages":[{"role":"user","content":"hello"}], - "model":"gpt-4o-mini", - "logprobs":true, - "top_logprobs":42, - "logit_bias":{"12":34}, - "parallel_tool_calls":false, - "user":"12345", - "metadata":{"something":"else"}, - "store":true + "metadata": { + "something": "else" + }, + "user": "12345", + "messages": [ + { + "role": "user", + "content": "hello" + } + ], + "model": "gpt-4o-mini", + "top_logprobs": 42, + "store": true, + "logit_bias": { + "12": 34 + }, + "logprobs": true, + "tools": [ + { + "type": "function", + "function": { + "description": "", + "name": "GetPersonAge", + "parameters": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": false + } + } + } + ], + "tool_choice": "auto", + "parallel_tool_calls": false } """; @@ -712,6 +726,7 @@ public async Task StronglyTypedOptions_AllSent() Assert.NotNull(await client.GetResponseAsync("hello", new() { AllowMultipleToolCalls = false, + Tools = [AIFunctionFactory.Create((string name) => 42, "GetPersonAge")], RawRepresentationFactory = (c) => { var openAIOptions = new ChatCompletionOptions @@ -1368,26 +1383,26 @@ public async Task AssistantMessageWithBothToolsAndContent_NonStreaming() "tool_calls": [ { "id": "12345", + "type": "function", "function": { "name": "SayHello", "arguments": "null" - }, - "type": "function" + } }, { "id": "12346", + "type": "function", "function": { "name": "SayHi", "arguments": "null" - }, - "type": "function" + } } ] }, { "role": "tool", "tool_call_id": "12345", - "content": "Said hello" + "content": "{ \"$type\": \"text\", \"text\": \"Said hello\" }" }, { "role":"tool", @@ -1456,7 +1471,7 @@ public async Task AssistantMessageWithBothToolsAndContent_NonStreaming() ]), new (ChatRole.Tool, [ - new FunctionResultContent("12345", "Said hello"), + new FunctionResultContent("12345", new TextContent("Said hello")), new FunctionResultContent("12346", "Said hi"), ]), new(ChatRole.Assistant, "You are great."), @@ -1604,8 +1619,201 @@ private static async Task DataContentMessage_Image_AdditionalPropertyDetail_NonS }, response.Usage.AdditionalCounts); } + [Fact] + public async Task RequestHeaders_UserAgent_ContainsMEAI() + { + using var handler = new ThrowUserAgentExceptionHandler(); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + InvalidOperationException e = await Assert.ThrowsAsync(() => client.GetResponseAsync("hello")); + + Assert.StartsWith("User-Agent header: OpenAI", e.Message); + Assert.Contains("MEAI", e.Message); + } + + [Fact] + public async Task ChatOptions_ModelId_OverridesClientModel_NonStreaming() + { + const string Input = """ + { + "temperature":0.5, + "messages":[{"role":"user","content":"hello"}], + "model":"gpt-4o", + "max_completion_tokens":10 + } + """; + + const string Output = """ + { + "id": "chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI", + "object": "chat.completion", + "created": 1727888631, + "model": "gpt-4o-2024-08-06", + "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 + } + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("hello", new() + { + MaxOutputTokens = 10, + Temperature = 0.5f, + ModelId = "gpt-4o", + }); + Assert.NotNull(response); + + Assert.Equal("chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI", response.ResponseId); + Assert.Equal("Hello! How can I assist you today?", response.Text); + Assert.Equal("gpt-4o-2024-08-06", response.ModelId); + } + + [Fact] + public async Task ChatOptions_ModelId_OverridesClientModel_Streaming() + { + const string Input = """ + { + "temperature":0.5, + "messages":[{"role":"user","content":"hello"}], + "model":"gpt-4o", + "stream":true, + "stream_options":{"include_usage":true}, + "max_completion_tokens":20 + } + """; + + const string Output = """ + data: {"id":"chatcmpl-ADxFKtX6xIwdWRN42QvBj2u1RZpCK","object":"chat.completion.chunk","created":1727889370,"model":"gpt-4o-2024-08-06","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-2024-08-06","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-2024-08-06","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-2024-08-06","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-2024-08-06","system_fingerprint":"fp_f85bea6784","choices":[],"usage":{"prompt_tokens":8,"completion_tokens":9,"total_tokens":17}} + + 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.GetStreamingResponseAsync("hello", new() + { + MaxOutputTokens = 20, + Temperature = 0.5f, + ModelId = "gpt-4o", + })) + { + updates.Add(update); + } + + Assert.Equal("Hello!", string.Concat(updates.Select(u => u.Text))); + Assert.All(updates, u => Assert.Equal("gpt-4o-2024-08-06", u.ModelId)); + } + private static IChatClient CreateChatClient(HttpClient httpClient, string modelId) => new OpenAIClient(new ApiKeyCredential("apikey"), new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(httpClient) }) .GetChatClient(modelId) .AsIChatClient(); + + [Fact] + public void AsChatMessages_PreservesRole_SystemMessage() + { + List openAIMessages = [new SystemChatMessage("You are a helpful assistant")]; + var extMessages = openAIMessages.AsChatMessages().ToList(); + + Assert.Single(extMessages); + Assert.Equal(ChatRole.System, extMessages[0].Role); + Assert.Equal("You are a helpful assistant", extMessages[0].Text); + } + + [Fact] + public void AsChatMessages_PreservesRole_UserMessage() + { + List openAIMessages = [new UserChatMessage("Hello")]; + var extMessages = openAIMessages.AsChatMessages().ToList(); + + Assert.Single(extMessages); + Assert.Equal(ChatRole.User, extMessages[0].Role); + Assert.Equal("Hello", extMessages[0].Text); + } + + [Fact] + public void AsChatMessages_PreservesRole_AssistantMessage() + { + List openAIMessages = [new AssistantChatMessage("Hi there!")]; + var extMessages = openAIMessages.AsChatMessages().ToList(); + + Assert.Single(extMessages); + Assert.Equal(ChatRole.Assistant, extMessages[0].Role); + Assert.Equal("Hi there!", extMessages[0].Text); + } + + [Fact] + public void AsChatMessages_PreservesRole_DeveloperMessage() + { + List openAIMessages = [new DeveloperChatMessage("Developer instructions")]; + var extMessages = openAIMessages.AsChatMessages().ToList(); + + Assert.Single(extMessages); + Assert.Equal(ChatRole.System, extMessages[0].Role); + Assert.Equal("Developer instructions", extMessages[0].Text); + } + + [Fact] + public void AsChatMessages_PreservesRole_ToolMessage() + { + List openAIMessages = [new ToolChatMessage("tool-123", "Result")]; + var extMessages = openAIMessages.AsChatMessages().ToList(); + + Assert.Single(extMessages); + Assert.Equal(ChatRole.Tool, extMessages[0].Role); + var frc = Assert.IsType(Assert.Single(extMessages[0].Contents)); + Assert.Equal("tool-123", frc.CallId); + Assert.Equal("Result", frc.Result); + } + + [Fact] + public void AsChatMessages_PreservesRole_MultipleMessages() + { + List openAIMessages = + [ + new SystemChatMessage("System prompt"), + new UserChatMessage("User message"), + new AssistantChatMessage("Assistant response"), + new DeveloperChatMessage("Developer note") + ]; + + var extMessages = openAIMessages.AsChatMessages().ToList(); + + Assert.Equal(4, extMessages.Count); + Assert.Equal(ChatRole.System, extMessages[0].Role); + Assert.Equal(ChatRole.User, extMessages[1].Role); + Assert.Equal(ChatRole.Assistant, extMessages[2].Role); + Assert.Equal(ChatRole.System, extMessages[3].Role); + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs new file mode 100644 index 00000000000..7fe1ceb8b57 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs @@ -0,0 +1,1555 @@ +// Licensed 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; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using OpenAI.Assistants; +using OpenAI.Chat; +using OpenAI.Realtime; +using OpenAI.Responses; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class OpenAIConversionTests +{ + private static readonly AIFunction _testFunction = AIFunctionFactory.Create( + ([Description("The name parameter")] string name) => name, + "test_function", + "A test function for conversion"); + + [Fact] + public void AsOpenAIChatResponseFormat_HandlesVariousFormats() + { + Assert.Null(MicrosoftExtensionsAIChatExtensions.AsOpenAIChatResponseFormat(null)); + + var text = MicrosoftExtensionsAIChatExtensions.AsOpenAIChatResponseFormat(ChatResponseFormat.Text); + Assert.NotNull(text); + Assert.Equal("""{"type":"text"}""", ((IJsonModel)text).Write(ModelReaderWriterOptions.Json).ToString()); + + var json = MicrosoftExtensionsAIChatExtensions.AsOpenAIChatResponseFormat(ChatResponseFormat.Json); + Assert.NotNull(json); + Assert.Equal("""{"type":"json_object"}""", ((IJsonModel)json).Write(ModelReaderWriterOptions.Json).ToString()); + + var jsonSchema = ChatResponseFormat.ForJsonSchema(typeof(int), schemaName: "my_schema", schemaDescription: "A test schema").AsOpenAIChatResponseFormat(); + Assert.NotNull(jsonSchema); + Assert.Equal(RemoveWhitespace(""" + {"type":"json_schema","json_schema":{"description":"A test schema","name":"my_schema","schema":{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "integer" + }}} + """), RemoveWhitespace(((IJsonModel)jsonSchema).Write(ModelReaderWriterOptions.Json).ToString())); + + jsonSchema = ChatResponseFormat.ForJsonSchema(typeof(int), schemaName: "my_schema", schemaDescription: "A test schema").AsOpenAIChatResponseFormat( + new() { AdditionalProperties = new AdditionalPropertiesDictionary { ["strictJsonSchema"] = true } }); + Assert.NotNull(jsonSchema); + Assert.Equal(RemoveWhitespace(""" + { + "type":"json_schema","json_schema":{"description":"A test schema","name":"my_schema","schema":{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "integer" + },"strict":true}} + """), RemoveWhitespace(((IJsonModel)jsonSchema).Write(ModelReaderWriterOptions.Json).ToString())); + } + + [Fact] + public void AsOpenAIResponseTextFormat_HandlesVariousFormats() + { + Assert.Null(MicrosoftExtensionsAIResponsesExtensions.AsOpenAIResponseTextFormat(null)); + + var text = MicrosoftExtensionsAIResponsesExtensions.AsOpenAIResponseTextFormat(ChatResponseFormat.Text); + Assert.NotNull(text); + Assert.Equal(ResponseTextFormatKind.Text, text.Kind); + + var json = MicrosoftExtensionsAIResponsesExtensions.AsOpenAIResponseTextFormat(ChatResponseFormat.Json); + Assert.NotNull(json); + Assert.Equal(ResponseTextFormatKind.JsonObject, json.Kind); + + var jsonSchema = ChatResponseFormat.ForJsonSchema(typeof(int), schemaName: "my_schema", schemaDescription: "A test schema").AsOpenAIResponseTextFormat(); + Assert.NotNull(jsonSchema); + Assert.Equal(ResponseTextFormatKind.JsonSchema, jsonSchema.Kind); + Assert.Equal(RemoveWhitespace(""" + {"type":"json_schema","description":"A test schema","name":"my_schema","schema":{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "integer" + }} + """), RemoveWhitespace(((IJsonModel)jsonSchema).Write(ModelReaderWriterOptions.Json).ToString())); + + jsonSchema = ChatResponseFormat.ForJsonSchema(typeof(int), schemaName: "my_schema", schemaDescription: "A test schema").AsOpenAIResponseTextFormat( + new() { AdditionalProperties = new AdditionalPropertiesDictionary { ["strictJsonSchema"] = true } }); + Assert.NotNull(jsonSchema); + Assert.Equal(ResponseTextFormatKind.JsonSchema, jsonSchema.Kind); + Assert.Equal(RemoveWhitespace(""" + {"type":"json_schema","description":"A test schema","name":"my_schema","schema":{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "integer" + },"strict":true} + """), RemoveWhitespace(((IJsonModel)jsonSchema).Write(ModelReaderWriterOptions.Json).ToString())); + } + + [Fact] + public void AsOpenAIChatTool_ProducesValidInstance() + { + var tool = _testFunction.AsOpenAIChatTool(); + + Assert.NotNull(tool); + Assert.Equal("test_function", tool.FunctionName); + Assert.Equal("A test function for conversion", tool.FunctionDescription); + ValidateSchemaParameters(tool.FunctionParameters); + } + + [Fact] + public void AsOpenAIResponseTool_ProducesValidInstance() + { + var tool = _testFunction.AsOpenAIResponseTool(); + + Assert.NotNull(tool); + } + + [Fact] + public void AsOpenAIResponseTool_WithAIFunctionTool_ProducesValidFunctionTool() + { + var tool = MicrosoftExtensionsAIResponsesExtensions.AsOpenAIResponseTool(tool: _testFunction); + + Assert.NotNull(tool); + var functionTool = Assert.IsType(tool); + Assert.Equal("test_function", functionTool.FunctionName); + Assert.Equal("A test function for conversion", functionTool.FunctionDescription); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedWebSearchTool_ProducesValidWebSearchTool() + { + var webSearchTool = new HostedWebSearchTool(); + + var result = webSearchTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.NotNull(tool); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedWebSearchToolWithAdditionalProperties_ProducesValidWebSearchTool() + { + var location = WebSearchToolLocation.CreateApproximateLocation("US", "Region", "City", "UTC"); + var webSearchTool = new HostedWebSearchToolWithProperties(new Dictionary + { + [nameof(WebSearchToolLocation)] = location, + [nameof(WebSearchToolContextSize)] = WebSearchToolContextSize.High + }); + + var result = webSearchTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + + var tool = Assert.IsType(result); + Assert.Equal(WebSearchToolContextSize.High, tool.SearchContextSize); + Assert.NotNull(tool); + + var approximateLocation = Assert.IsType(tool.UserLocation); + Assert.Equal(location.Country, approximateLocation.Country); + Assert.Equal(location.Region, approximateLocation.Region); + Assert.Equal(location.City, approximateLocation.City); + Assert.Equal(location.Timezone, approximateLocation.Timezone); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedFileSearchTool_ProducesValidFileSearchTool() + { + var fileSearchTool = new HostedFileSearchTool { MaximumResultCount = 10 }; + + var result = fileSearchTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.Empty(tool.VectorStoreIds); + Assert.Equal(fileSearchTool.MaximumResultCount, tool.MaxResultCount); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedFileSearchToolWithVectorStores_ProducesValidFileSearchTool() + { + var vectorStoreContent = new HostedVectorStoreContent("vector-store-123"); + var fileSearchTool = new HostedFileSearchTool + { + Inputs = [vectorStoreContent] + }; + + var result = fileSearchTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.Single(tool.VectorStoreIds); + Assert.Equal(vectorStoreContent.VectorStoreId, tool.VectorStoreIds[0]); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedFileSearchToolWithMaxResults_ProducesValidFileSearchTool() + { + var fileSearchTool = new HostedFileSearchTool + { + MaximumResultCount = 10 + }; + + var result = fileSearchTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.Equal(10, tool.MaxResultCount); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedCodeInterpreterTool_ProducesValidCodeInterpreterTool() + { + var codeTool = new HostedCodeInterpreterTool(); + + var result = codeTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.NotNull(tool.Container); + Assert.NotNull(tool.Container.ContainerConfiguration); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedCodeInterpreterToolWithFiles_ProducesValidCodeInterpreterTool() + { + var fileContent = new HostedFileContent("file-123"); + var codeTool = new HostedCodeInterpreterTool + { + Inputs = [fileContent] + }; + + var result = codeTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + var autoContainerConfig = Assert.IsType(tool.Container.ContainerConfiguration); + Assert.Single(autoContainerConfig.FileIds); + Assert.Equal(fileContent.FileId, autoContainerConfig.FileIds[0]); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedMcpServerTool_ProducesValidMcpTool() + { + var mcpTool = new HostedMcpServerTool("test-server", "http://localhost:8000"); + + var result = mcpTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.Equal(new Uri("http://localhost:8000"), tool.ServerUri); + Assert.Equal("test-server", tool.ServerLabel); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedMcpServerToolWithDescription_ProducesValidMcpTool() + { + var mcpTool = new HostedMcpServerTool("test-server", "http://localhost:8000") + { + ServerDescription = "A test MCP server" + }; + + var result = mcpTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.Equal("A test MCP server", tool.ServerDescription); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedMcpServerToolWithAuthToken_ProducesValidMcpTool() + { + var mcpTool = new HostedMcpServerTool("test-server", "http://localhost:8000") + { + AuthorizationToken = "test-token" + }; + + var result = mcpTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.Equal("test-token", tool.AuthorizationToken); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedMcpServerToolWithUri_ProducesValidMcpTool() + { + var expectedUri = new Uri("http://localhost:8000"); + var mcpTool = new HostedMcpServerTool("test-server", expectedUri); + + var result = mcpTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.Equal(expectedUri, tool.ServerUri); + Assert.Equal("test-server", tool.ServerLabel); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedMcpServerToolWithAllowedTools_ProducesValidMcpTool() + { + var allowedTools = new List { "tool1", "tool2", "tool3" }; + var mcpTool = new HostedMcpServerTool("test-server", "http://localhost:8000") + { + AllowedTools = allowedTools + }; + + var result = mcpTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.NotNull(tool.AllowedTools); + Assert.Equal(3, tool.AllowedTools.ToolNames.Count); + Assert.Contains("tool1", tool.AllowedTools.ToolNames); + Assert.Contains("tool2", tool.AllowedTools.ToolNames); + Assert.Contains("tool3", tool.AllowedTools.ToolNames); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedMcpServerToolWithAlwaysRequireApprovalMode_ProducesValidMcpTool() + { + var mcpTool = new HostedMcpServerTool("test-server", "http://localhost:8000") + { + ApprovalMode = HostedMcpServerToolApprovalMode.AlwaysRequire + }; + + var result = mcpTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.NotNull(tool.ToolCallApprovalPolicy); + Assert.NotNull(tool.ToolCallApprovalPolicy.GlobalPolicy); + Assert.Equal(GlobalMcpToolCallApprovalPolicy.AlwaysRequireApproval, tool.ToolCallApprovalPolicy.GlobalPolicy); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedMcpServerToolWithNeverRequireApprovalMode_ProducesValidMcpTool() + { + var mcpTool = new HostedMcpServerTool("test-server", "http://localhost:8000") + { + ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire + }; + + var result = mcpTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.NotNull(tool.ToolCallApprovalPolicy); + Assert.NotNull(tool.ToolCallApprovalPolicy.GlobalPolicy); + Assert.Equal(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval, tool.ToolCallApprovalPolicy.GlobalPolicy); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedMcpServerToolWithRequireSpecificApprovalMode_ProducesValidMcpTool() + { + var alwaysRequireTools = new List { "tool1", "tool2" }; + var neverRequireTools = new List { "tool3" }; + var approvalMode = HostedMcpServerToolApprovalMode.RequireSpecific(alwaysRequireTools, neverRequireTools); + var mcpTool = new HostedMcpServerTool("test-server", "http://localhost:8000") + { + ApprovalMode = approvalMode + }; + + var result = mcpTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.NotNull(tool.ToolCallApprovalPolicy); + Assert.NotNull(tool.ToolCallApprovalPolicy.CustomPolicy); + Assert.NotNull(tool.ToolCallApprovalPolicy.CustomPolicy.ToolsAlwaysRequiringApproval); + Assert.NotNull(tool.ToolCallApprovalPolicy.CustomPolicy.ToolsNeverRequiringApproval); + Assert.Equal(2, tool.ToolCallApprovalPolicy.CustomPolicy.ToolsAlwaysRequiringApproval.ToolNames.Count); + Assert.Single(tool.ToolCallApprovalPolicy.CustomPolicy.ToolsNeverRequiringApproval.ToolNames); + Assert.Contains("tool1", tool.ToolCallApprovalPolicy.CustomPolicy.ToolsAlwaysRequiringApproval.ToolNames); + Assert.Contains("tool2", tool.ToolCallApprovalPolicy.CustomPolicy.ToolsAlwaysRequiringApproval.ToolNames); + Assert.Contains("tool3", tool.ToolCallApprovalPolicy.CustomPolicy.ToolsNeverRequiringApproval.ToolNames); + } + + [Fact] + public void AsOpenAIResponseTool_WithUnknownToolType_ReturnsNull() + { + var unknownTool = new UnknownAITool(); + + var result = unknownTool.AsOpenAIResponseTool(); + + Assert.Null(result); + } + + [Fact] + public void AsOpenAIResponseTool_WithNullTool_ThrowsArgumentNullException() + { + Assert.Throws("tool", () => ((AITool)null!).AsOpenAIResponseTool()); + } + + [Fact] + public void AsOpenAIConversationFunctionTool_ProducesValidInstance() + { + var tool = _testFunction.AsOpenAIConversationFunctionTool(); + + Assert.NotNull(tool); + Assert.Equal("test_function", tool.Name); + Assert.Equal("A test function for conversion", tool.Description); + ValidateSchemaParameters(tool.Parameters); + } + + [Fact] + public void AsOpenAIAssistantsFunctionToolDefinition_ProducesValidInstance() + { + var tool = _testFunction.AsOpenAIAssistantsFunctionToolDefinition(); + + Assert.NotNull(tool); + Assert.Equal("test_function", tool.FunctionName); + Assert.Equal("A test function for conversion", tool.Description); + ValidateSchemaParameters(tool.Parameters); + } + + /// Helper method to validate function parameters match our schema. + private static void ValidateSchemaParameters(BinaryData parameters) + { + Assert.NotNull(parameters); + + using var jsonDoc = JsonDocument.Parse(parameters); + var root = jsonDoc.RootElement; + + Assert.Equal("object", root.GetProperty("type").GetString()); + Assert.True(root.TryGetProperty("properties", out var properties)); + Assert.True(properties.TryGetProperty("name", out var nameProperty)); + Assert.Equal("string", nameProperty.GetProperty("type").GetString()); + Assert.Equal("The name parameter", nameProperty.GetProperty("description").GetString()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AsOpenAIChatMessages_ProducesExpectedOutput(bool withOptions) + { + Assert.Throws("messages", () => ((IEnumerable)null!).AsOpenAIChatMessages()); + + List messages = + [ + new(ChatRole.System, "You are a helpful assistant."), + new(ChatRole.User, "Hello") { AuthorName = "Jane" }, + new(ChatRole.Assistant, + [ + new TextContent("Hi there!"), + new FunctionCallContent("callid123", "SomeFunction", new Dictionary + { + ["param1"] = "value1", + ["param2"] = 42 + }), + ]) { AuthorName = "!@#$%John Smith^*)" }, + new(ChatRole.Tool, [new FunctionResultContent("callid123", "theresult")]), + new(ChatRole.Assistant, "The answer is 42.") { AuthorName = "@#$#$@$" }, + ]; + + ChatOptions? options = withOptions ? new ChatOptions { Instructions = "You talk like a parrot." } : null; + + var convertedMessages = messages.AsOpenAIChatMessages(options).ToArray(); + + int index = 0; + if (withOptions) + { + Assert.Equal(6, convertedMessages.Length); + + index = 1; + SystemChatMessage instructionsMessage = Assert.IsType(convertedMessages[0], exactMatch: false); + Assert.Equal("You talk like a parrot.", Assert.Single(instructionsMessage.Content).Text); + } + else + { + Assert.Equal(5, convertedMessages.Length); + } + + SystemChatMessage m0 = Assert.IsType(convertedMessages[index], exactMatch: false); + Assert.Equal("You are a helpful assistant.", Assert.Single(m0.Content).Text); + + UserChatMessage m1 = Assert.IsType(convertedMessages[index + 1], exactMatch: false); + Assert.Equal("Hello", Assert.Single(m1.Content).Text); + Assert.Equal("Jane", m1.ParticipantName); + + AssistantChatMessage m2 = Assert.IsType(convertedMessages[index + 2], exactMatch: false); + Assert.Single(m2.Content); + Assert.Equal("Hi there!", m2.Content[0].Text); + var tc = Assert.Single(m2.ToolCalls); + Assert.Equal("callid123", tc.Id); + Assert.Equal("SomeFunction", tc.FunctionName); + Assert.True(JsonElement.DeepEquals(JsonSerializer.SerializeToElement(new Dictionary + { + ["param1"] = "value1", + ["param2"] = 42 + }), JsonSerializer.Deserialize(tc.FunctionArguments.ToMemory().Span))); + Assert.Equal("JohnSmith", m2.ParticipantName); + + ToolChatMessage m3 = Assert.IsType(convertedMessages[index + 3], exactMatch: false); + Assert.Equal("callid123", m3.ToolCallId); + Assert.Equal("theresult", Assert.Single(m3.Content).Text); + + AssistantChatMessage m4 = Assert.IsType(convertedMessages[index + 4], exactMatch: false); + Assert.Equal("The answer is 42.", Assert.Single(m4.Content).Text); + Assert.Null(m4.ParticipantName); + } + + [Fact] + public void AsOpenAIResponseItems_ProducesExpectedOutput() + { + Assert.Throws("messages", () => ((IEnumerable)null!).AsOpenAIResponseItems()); + + List messages = + [ + new(ChatRole.System, "You are a helpful assistant."), + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, + [ + new TextContent("Hi there!"), + new FunctionCallContent("callid123", "SomeFunction", new Dictionary + { + ["param1"] = "value1", + ["param2"] = 42 + }), + ]), + new(ChatRole.Tool, [new FunctionResultContent("callid123", "theresult")]), + new(ChatRole.Assistant, "The answer is 42."), + ]; + + var convertedItems = messages.AsOpenAIResponseItems().ToArray(); + + Assert.Equal(6, convertedItems.Length); + + MessageResponseItem m0 = Assert.IsAssignableFrom(convertedItems[0]); + Assert.Equal("You are a helpful assistant.", Assert.Single(m0.Content).Text); + + MessageResponseItem m1 = Assert.IsAssignableFrom(convertedItems[1]); + Assert.Equal(OpenAI.Responses.MessageRole.User, m1.Role); + Assert.Equal("Hello", Assert.Single(m1.Content).Text); + + MessageResponseItem m2 = Assert.IsAssignableFrom(convertedItems[2]); + Assert.Equal(OpenAI.Responses.MessageRole.Assistant, m2.Role); + Assert.Equal("Hi there!", Assert.Single(m2.Content).Text); + + FunctionCallResponseItem m3 = Assert.IsAssignableFrom(convertedItems[3]); + Assert.Equal("callid123", m3.CallId); + Assert.Equal("SomeFunction", m3.FunctionName); + Assert.True(JsonElement.DeepEquals(JsonSerializer.SerializeToElement(new Dictionary + { + ["param1"] = "value1", + ["param2"] = 42 + }), JsonSerializer.Deserialize(m3.FunctionArguments.ToMemory().Span))); + + FunctionCallOutputResponseItem m4 = Assert.IsAssignableFrom(convertedItems[4]); + Assert.Equal("callid123", m4.CallId); + Assert.Equal("theresult", m4.FunctionOutput); + + MessageResponseItem m5 = Assert.IsAssignableFrom(convertedItems[5]); + Assert.Equal(OpenAI.Responses.MessageRole.Assistant, m5.Role); + Assert.Equal("The answer is 42.", Assert.Single(m5.Content).Text); + } + + [Fact] + public void AsOpenAIResponseItems_RoundtripsRawRepresentation() + { + List messages = + [ + new(ChatRole.User, + [ + new TextContent("Hello, "), + new AIContent { RawRepresentation = ResponseItem.CreateWebSearchCallItem() }, + new AIContent { RawRepresentation = ResponseItem.CreateReferenceItem("123") }, + new TextContent("World"), + new TextContent("!"), + ]), + new(ChatRole.Assistant, + [ + new TextContent("Hi!"), + new AIContent { RawRepresentation = ResponseItem.CreateReasoningItem("text") }, + ]), + new(ChatRole.User, + [ + new AIContent { RawRepresentation = ResponseItem.CreateSystemMessageItem("test") }, + ]), + ]; + + var items = messages.AsOpenAIResponseItems().ToArray(); + + Assert.Equal(7, items.Length); + Assert.Equal("Hello, ", ((MessageResponseItem)items[0]).Content[0].Text); + Assert.Same(messages[0].Contents[1].RawRepresentation, items[1]); + Assert.Same(messages[0].Contents[2].RawRepresentation, items[2]); + Assert.Equal("World", ((MessageResponseItem)items[3]).Content[0].Text); + Assert.Equal("!", ((MessageResponseItem)items[3]).Content[1].Text); + Assert.Equal("Hi!", ((MessageResponseItem)items[4]).Content[0].Text); + Assert.Same(messages[1].Contents[1].RawRepresentation, items[5]); + Assert.Same(messages[2].Contents[0].RawRepresentation, items[6]); + } + + [Fact] + public void AsChatResponse_ConvertsOpenAIChatCompletion() + { + Assert.Throws("chatCompletion", () => ((ChatCompletion)null!).AsChatResponse()); + + ChatCompletion cc = OpenAIChatModelFactory.ChatCompletion( + "id", OpenAI.Chat.ChatFinishReason.Length, null, null, + [ChatToolCall.CreateFunctionToolCall("id", "functionName", BinaryData.FromString("test"))], + ChatMessageRole.User, null, null, null, new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero), + "model123", null, OpenAIChatModelFactory.ChatTokenUsage(2, 1, 3)); + cc.Content.Add(ChatMessageContentPart.CreateTextPart("Hello, world!")); + cc.Content.Add(ChatMessageContentPart.CreateImagePart(new Uri("http://example.com/image.png"))); + + ChatResponse response = cc.AsChatResponse(); + + Assert.Equal("id", response.ResponseId); + Assert.Equal(ChatFinishReason.Length, response.FinishReason); + Assert.Equal("model123", response.ModelId); + Assert.Equal(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero), response.CreatedAt); + Assert.NotNull(response.Usage); + Assert.Equal(1, response.Usage.InputTokenCount); + Assert.Equal(2, response.Usage.OutputTokenCount); + Assert.Equal(3, response.Usage.TotalTokenCount); + + ChatMessage message = Assert.Single(response.Messages); + Assert.Equal(ChatRole.User, message.Role); + + Assert.Equal(3, message.Contents.Count); + Assert.Equal("Hello, world!", Assert.IsType(message.Contents[0], exactMatch: false).Text); + Assert.Equal("http://example.com/image.png", Assert.IsType(message.Contents[1], exactMatch: false).Uri.ToString()); + Assert.Equal("functionName", Assert.IsType(message.Contents[2], exactMatch: false).Name); + } + + [Fact] + public async Task AsChatResponse_ConvertsOpenAIStreamingChatCompletionUpdates() + { + Assert.Throws("chatCompletionUpdates", () => ((IAsyncEnumerable)null!).AsChatResponseUpdatesAsync()); + + List updates = []; + await foreach (var update in CreateUpdates().AsChatResponseUpdatesAsync()) + { + updates.Add(update); + } + + var response = updates.ToChatResponse(); + + Assert.Equal("id", response.ResponseId); + Assert.Equal(ChatFinishReason.ToolCalls, response.FinishReason); + Assert.Equal("model123", response.ModelId); + Assert.Equal(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero), response.CreatedAt); + Assert.NotNull(response.Usage); + Assert.Equal(1, response.Usage.InputTokenCount); + Assert.Equal(2, response.Usage.OutputTokenCount); + Assert.Equal(3, response.Usage.TotalTokenCount); + + ChatMessage message = Assert.Single(response.Messages); + Assert.Equal(ChatRole.Assistant, message.Role); + + Assert.Equal(3, message.Contents.Count); + Assert.Equal("Hello, world!", Assert.IsType(message.Contents[0], exactMatch: false).Text); + Assert.Equal("http://example.com/image.png", Assert.IsType(message.Contents[1], exactMatch: false).Uri.ToString()); + Assert.Equal("functionName", Assert.IsType(message.Contents[2], exactMatch: false).Name); + + static async IAsyncEnumerable CreateUpdates() + { + await Task.Yield(); + yield return OpenAIChatModelFactory.StreamingChatCompletionUpdate( + "id", + new ChatMessageContent( + ChatMessageContentPart.CreateTextPart("Hello, world!"), + ChatMessageContentPart.CreateImagePart(new Uri("http://example.com/image.png"))), + null, + [OpenAIChatModelFactory.StreamingChatToolCallUpdate(0, "id", ChatToolCallKind.Function, "functionName", BinaryData.FromString("test"))], + ChatMessageRole.Assistant, + null, null, null, OpenAI.Chat.ChatFinishReason.ToolCalls, new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero), + "model123", null, OpenAIChatModelFactory.ChatTokenUsage(2, 1, 3)); + } + } + + [Fact] + public void AsChatResponse_ConvertsOpenAIResponse() + { + Assert.Throws("response", () => ((OpenAIResponse)null!).AsChatResponse()); + + // The OpenAI library currently doesn't provide any way to create an OpenAIResponse instance, + // as all constructors/factory methods currently are internal. Update this test when such functionality is available. + } + + [Fact] + public void AsChatResponseUpdatesAsync_ConvertsOpenAIStreamingResponseUpdates() + { + Assert.Throws("responseUpdates", () => ((IAsyncEnumerable)null!).AsChatResponseUpdatesAsync()); + + // The OpenAI library currently doesn't provide any way to create a StreamingResponseUpdate instance, + // as all constructors/factory methods currently are internal. Update this test when such functionality is available. + } + + [Fact] + public void AsChatMessages_FromOpenAIChatMessages_ProducesExpectedOutput() + { + Assert.Throws("messages", () => ((IEnumerable)null!).AsChatMessages().ToArray()); + + List openAIMessages = + [ + new SystemChatMessage("You are a helpful assistant."), + new UserChatMessage("Hello"), + new AssistantChatMessage(ChatMessageContentPart.CreateTextPart("Hi there!")), + new ToolChatMessage("call456", "Function output") + ]; + + var convertedMessages = openAIMessages.AsChatMessages().ToArray(); + + Assert.Equal(4, convertedMessages.Length); + + Assert.Equal("You are a helpful assistant.", convertedMessages[0].Text); + Assert.Equal("Hello", convertedMessages[1].Text); + Assert.Equal("Hi there!", convertedMessages[2].Text); + Assert.Equal("Function output", convertedMessages[3].Contents.OfType().First().Result); + } + + [Fact] + public void AsChatMessages_FromResponseItems_WithNullArgument_ThrowsArgumentNullException() + { + Assert.Throws("items", () => ((IEnumerable)null!).AsChatMessages()); + } + + [Fact] + public void AsChatMessages_FromResponseItems_ProducesExpectedOutput() + { + List inputMessages = + [ + new(ChatRole.Assistant, "Hi there!") + ]; + + var responseItems = inputMessages.AsOpenAIResponseItems().ToArray(); + + var convertedMessages = responseItems.AsChatMessages().ToArray(); + + Assert.Single(convertedMessages); + + var message = convertedMessages[0]; + Assert.Equal(ChatRole.Assistant, message.Role); + Assert.Equal("Hi there!", message.Text); + } + + [Fact] + public void AsChatMessages_FromResponseItems_WithEmptyCollection_ReturnsEmptyCollection() + { + var convertedMessages = Array.Empty().AsChatMessages().ToArray(); + Assert.Empty(convertedMessages); + } + + [Fact] + public void AsChatMessages_FromResponseItems_WithFunctionCall_HandlesCorrectly() + { + List inputMessages = + [ + new(ChatRole.Assistant, + [ + new TextContent("I'll call a function."), + new FunctionCallContent("call123", "TestFunction", new Dictionary { ["param"] = "value" }) + ]) + ]; + + var responseItems = inputMessages.AsOpenAIResponseItems().ToArray(); + var convertedMessages = responseItems.AsChatMessages().ToArray(); + + Assert.Single(convertedMessages); + + var message = convertedMessages[0]; + Assert.Equal(ChatRole.Assistant, message.Role); + + var textContent = message.Contents.OfType().FirstOrDefault(); + var functionCall = message.Contents.OfType().FirstOrDefault(); + + Assert.NotNull(textContent); + Assert.Equal("I'll call a function.", textContent.Text); + + Assert.NotNull(functionCall); + Assert.Equal("call123", functionCall.CallId); + Assert.Equal("TestFunction", functionCall.Name); + Assert.Equal("value", functionCall.Arguments!["param"]?.ToString()); + } + + [Fact] + public void AsOpenAIChatCompletion_WithNullArgument_ThrowsArgumentNullException() + { + Assert.Throws("response", () => ((ChatResponse)null!).AsOpenAIChatCompletion()); + } + + [Fact] + public void AsOpenAIChatCompletion_WithMultipleContents_ProducesValidInstance() + { + var chatResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, + [ + new TextContent("Here's an image and some text."), + new UriContent("https://example.com/image.jpg", "image/jpeg"), + new DataContent(new byte[] { 1, 2, 3, 4 }, "application/octet-stream") + ])) + { + ResponseId = "multi-content-response", + ModelId = "gpt-4-vision", + FinishReason = ChatFinishReason.Stop, + CreatedAt = new DateTimeOffset(2025, 1, 3, 14, 30, 0, TimeSpan.Zero), + Usage = new UsageDetails + { + InputTokenCount = 25, + OutputTokenCount = 12, + TotalTokenCount = 37 + } + }; + + ChatCompletion completion = chatResponse.AsOpenAIChatCompletion(); + + Assert.Equal("multi-content-response", completion.Id); + Assert.Equal("gpt-4-vision", completion.Model); + Assert.Equal(OpenAI.Chat.ChatFinishReason.Stop, completion.FinishReason); + Assert.Equal(ChatMessageRole.Assistant, completion.Role); + Assert.Equal(new DateTimeOffset(2025, 1, 3, 14, 30, 0, TimeSpan.Zero), completion.CreatedAt); + + Assert.NotNull(completion.Usage); + Assert.Equal(25, completion.Usage.InputTokenCount); + Assert.Equal(12, completion.Usage.OutputTokenCount); + Assert.Equal(37, completion.Usage.TotalTokenCount); + + Assert.NotEmpty(completion.Content); + Assert.Contains(completion.Content, c => c.Text == "Here's an image and some text."); + } + + [Fact] + public void AsOpenAIChatCompletion_WithEmptyData_HandlesGracefully() + { + var chatResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Hello")); + var completion = chatResponse.AsOpenAIChatCompletion(); + + Assert.NotNull(completion); + Assert.Equal(ChatMessageRole.Assistant, completion.Role); + Assert.Equal("Hello", Assert.Single(completion.Content).Text); + Assert.Empty(completion.ToolCalls); + + var emptyResponse = new ChatResponse([]); + var emptyCompletion = emptyResponse.AsOpenAIChatCompletion(); + Assert.NotNull(emptyCompletion); + Assert.Equal(ChatMessageRole.Assistant, emptyCompletion.Role); + } + + [Fact] + public void AsOpenAIChatCompletion_WithComplexFunctionCallArguments_SerializesCorrectly() + { + var complexArgs = new Dictionary + { + ["simpleString"] = "hello", + ["number"] = 42, + ["boolean"] = true, + ["nullValue"] = null, + ["nestedObject"] = new Dictionary + { + ["innerString"] = "world", + ["innerArray"] = new[] { 1, 2, 3 } + } + }; + + var chatResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, + [ + new TextContent("I'll process this complex data."), + new FunctionCallContent("process_data", "ProcessComplexData", complexArgs) + ])) + { + ResponseId = "complex-function-call", + ModelId = "gpt-4", + FinishReason = ChatFinishReason.ToolCalls + }; + + ChatCompletion completion = chatResponse.AsOpenAIChatCompletion(); + + Assert.Equal("complex-function-call", completion.Id); + Assert.Equal(OpenAI.Chat.ChatFinishReason.ToolCalls, completion.FinishReason); + + var toolCall = Assert.Single(completion.ToolCalls); + Assert.Equal("process_data", toolCall.Id); + Assert.Equal("ProcessComplexData", toolCall.FunctionName); + + var deserializedArgs = JsonSerializer.Deserialize>(toolCall.FunctionArguments.ToMemory().Span); + Assert.NotNull(deserializedArgs); + Assert.Equal("hello", deserializedArgs["simpleString"]?.ToString()); + Assert.Equal(42, ((JsonElement)deserializedArgs["number"]!).GetInt32()); + Assert.True(((JsonElement)deserializedArgs["boolean"]!).GetBoolean()); + Assert.Null(deserializedArgs["nullValue"]); + + var nestedObj = (JsonElement)deserializedArgs["nestedObject"]!; + Assert.Equal("world", nestedObj.GetProperty("innerString").GetString()); + Assert.Equal(3, nestedObj.GetProperty("innerArray").GetArrayLength()); + } + + [Fact] + public void AsOpenAIChatCompletion_WithDifferentFinishReasons_MapsCorrectly() + { + var testCases = new[] + { + (ChatFinishReason.Stop, OpenAI.Chat.ChatFinishReason.Stop), + (ChatFinishReason.Length, OpenAI.Chat.ChatFinishReason.Length), + (ChatFinishReason.ContentFilter, OpenAI.Chat.ChatFinishReason.ContentFilter), + (ChatFinishReason.ToolCalls, OpenAI.Chat.ChatFinishReason.ToolCalls) + }; + + foreach (var (inputFinishReason, expectedOpenAIFinishReason) in testCases) + { + var chatResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Test")) + { + FinishReason = inputFinishReason + }; + + var completion = chatResponse.AsOpenAIChatCompletion(); + Assert.Equal(expectedOpenAIFinishReason, completion.FinishReason); + } + } + + [Fact] + public void AsOpenAIChatCompletion_WithDifferentRoles_MapsCorrectly() + { + var testCases = new[] + { + (ChatRole.Assistant, ChatMessageRole.Assistant), + (ChatRole.User, ChatMessageRole.User), + (ChatRole.System, ChatMessageRole.System), + (ChatRole.Tool, ChatMessageRole.Tool) + }; + + foreach (var (inputRole, expectedOpenAIRole) in testCases) + { + var chatResponse = new ChatResponse(new ChatMessage(inputRole, "Test")); + var completion = chatResponse.AsOpenAIChatCompletion(); + Assert.Equal(expectedOpenAIRole, completion.Role); + } + } + + [Fact] + public async Task AsOpenAIStreamingChatCompletionUpdatesAsync_WithNullArgument_ThrowsArgumentNullException() + { + var asyncEnumerable = ((IAsyncEnumerable)null!).AsOpenAIStreamingChatCompletionUpdatesAsync(); + await Assert.ThrowsAsync(async () => await asyncEnumerable.GetAsyncEnumerator().MoveNextAsync()); + } + + [Fact] + public async Task AsOpenAIStreamingChatCompletionUpdatesAsync_WithEmptyCollection_ReturnsEmptySequence() + { + var updates = new List(); + var result = new List(); + + await foreach (var update in CreateAsyncEnumerable(updates).AsOpenAIStreamingChatCompletionUpdatesAsync()) + { + result.Add(update); + } + + Assert.Empty(result); + } + + [Fact] + public async Task AsOpenAIStreamingChatCompletionUpdatesAsync_WithRawRepresentation_ReturnsOriginal() + { + var originalUpdate = OpenAIChatModelFactory.StreamingChatCompletionUpdate( + "test-id", + new ChatMessageContent(ChatMessageContentPart.CreateTextPart("Hello")), + role: ChatMessageRole.Assistant, + finishReason: OpenAI.Chat.ChatFinishReason.Stop, + createdAt: new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero), + model: "gpt-3.5-turbo"); + + var responseUpdate = new ChatResponseUpdate(ChatRole.Assistant, "Hello") + { + RawRepresentation = originalUpdate + }; + + var result = new List(); + await foreach (var update in CreateAsyncEnumerable(new[] { responseUpdate }).AsOpenAIStreamingChatCompletionUpdatesAsync()) + { + result.Add(update); + } + + Assert.Single(result); + Assert.Same(originalUpdate, result[0]); + } + + [Fact] + public async Task AsOpenAIStreamingChatCompletionUpdatesAsync_WithTextContent_CreatesValidUpdate() + { + var responseUpdate = new ChatResponseUpdate(ChatRole.Assistant, "Hello, world!") + { + ResponseId = "response-123", + MessageId = "message-456", + ModelId = "gpt-4", + FinishReason = ChatFinishReason.Stop, + CreatedAt = new DateTimeOffset(2025, 1, 1, 12, 0, 0, TimeSpan.Zero) + }; + + var result = new List(); + await foreach (var update in CreateAsyncEnumerable(new[] { responseUpdate }).AsOpenAIStreamingChatCompletionUpdatesAsync()) + { + result.Add(update); + } + + Assert.Single(result); + var streamingUpdate = result[0]; + + Assert.Equal("response-123", streamingUpdate.CompletionId); + Assert.Equal("gpt-4", streamingUpdate.Model); + Assert.Equal(OpenAI.Chat.ChatFinishReason.Stop, streamingUpdate.FinishReason); + Assert.Equal(new DateTimeOffset(2025, 1, 1, 12, 0, 0, TimeSpan.Zero), streamingUpdate.CreatedAt); + Assert.Equal(ChatMessageRole.Assistant, streamingUpdate.Role); + Assert.Equal("Hello, world!", Assert.Single(streamingUpdate.ContentUpdate).Text); + } + + [Fact] + public async Task AsOpenAIStreamingChatCompletionUpdatesAsync_WithUsageContent_CreatesUpdateWithUsage() + { + var responseUpdate = new ChatResponseUpdate + { + ResponseId = "response-123", + Contents = + [ + new UsageContent(new UsageDetails + { + InputTokenCount = 10, + OutputTokenCount = 20, + TotalTokenCount = 30 + }) + ] + }; + + var result = new List(); + await foreach (var update in CreateAsyncEnumerable(new[] { responseUpdate }).AsOpenAIStreamingChatCompletionUpdatesAsync()) + { + result.Add(update); + } + + Assert.Single(result); + var streamingUpdate = result[0]; + + Assert.Equal("response-123", streamingUpdate.CompletionId); + Assert.NotNull(streamingUpdate.Usage); + Assert.Equal(20, streamingUpdate.Usage.OutputTokenCount); + Assert.Equal(10, streamingUpdate.Usage.InputTokenCount); + Assert.Equal(30, streamingUpdate.Usage.TotalTokenCount); + } + + [Fact] + public async Task AsOpenAIStreamingChatCompletionUpdatesAsync_WithFunctionCallContent_CreatesUpdateWithToolCalls() + { + var functionCallContent = new FunctionCallContent("call-123", "GetWeather", new Dictionary + { + ["location"] = "Seattle", + ["units"] = "celsius" + }); + + var responseUpdate = new ChatResponseUpdate(ChatRole.Assistant, [functionCallContent]) + { + ResponseId = "response-123" + }; + + var result = new List(); + await foreach (var update in CreateAsyncEnumerable(new[] { responseUpdate }).AsOpenAIStreamingChatCompletionUpdatesAsync()) + { + result.Add(update); + } + + Assert.Single(result); + var streamingUpdate = result[0]; + + Assert.Equal("response-123", streamingUpdate.CompletionId); + Assert.Single(streamingUpdate.ToolCallUpdates); + + var toolCallUpdate = streamingUpdate.ToolCallUpdates[0]; + Assert.Equal(0, toolCallUpdate.Index); + Assert.Equal("call-123", toolCallUpdate.ToolCallId); + Assert.Equal(ChatToolCallKind.Function, toolCallUpdate.Kind); + Assert.Equal("GetWeather", toolCallUpdate.FunctionName); + + var deserializedArgs = JsonSerializer.Deserialize>( + toolCallUpdate.FunctionArgumentsUpdate.ToMemory().Span); + Assert.Equal("Seattle", deserializedArgs?["location"]?.ToString()); + Assert.Equal("celsius", deserializedArgs?["units"]?.ToString()); + } + + [Fact] + public async Task AsOpenAIStreamingChatCompletionUpdatesAsync_WithMultipleFunctionCalls_CreatesCorrectIndexes() + { + var functionCall1 = new FunctionCallContent("call-1", "Function1", new Dictionary { ["param1"] = "value1" }); + var functionCall2 = new FunctionCallContent("call-2", "Function2", new Dictionary { ["param2"] = "value2" }); + + var responseUpdate = new ChatResponseUpdate(ChatRole.Assistant, [functionCall1, functionCall2]) + { + ResponseId = "response-123" + }; + + var result = new List(); + await foreach (var update in CreateAsyncEnumerable(new[] { responseUpdate }).AsOpenAIStreamingChatCompletionUpdatesAsync()) + { + result.Add(update); + } + + Assert.Single(result); + var streamingUpdate = result[0]; + + Assert.Equal(2, streamingUpdate.ToolCallUpdates.Count); + + Assert.Equal(0, streamingUpdate.ToolCallUpdates[0].Index); + Assert.Equal("call-1", streamingUpdate.ToolCallUpdates[0].ToolCallId); + Assert.Equal("Function1", streamingUpdate.ToolCallUpdates[0].FunctionName); + + Assert.Equal(1, streamingUpdate.ToolCallUpdates[1].Index); + Assert.Equal("call-2", streamingUpdate.ToolCallUpdates[1].ToolCallId); + Assert.Equal("Function2", streamingUpdate.ToolCallUpdates[1].FunctionName); + } + + [Fact] + public async Task AsOpenAIStreamingChatCompletionUpdatesAsync_WithMixedContent_IncludesAllContent() + { + var responseUpdate = new ChatResponseUpdate(ChatRole.Assistant, + [ + new TextContent("Processing your request..."), + new FunctionCallContent("call-123", "GetWeather", new Dictionary { ["location"] = "Seattle" }), + new UsageContent(new UsageDetails { TotalTokenCount = 50 }) + ]) + { + ResponseId = "response-123", + ModelId = "gpt-4" + }; + + var result = new List(); + await foreach (var update in CreateAsyncEnumerable(new[] { responseUpdate }).AsOpenAIStreamingChatCompletionUpdatesAsync()) + { + result.Add(update); + } + + Assert.Single(result); + var streamingUpdate = result[0]; + + Assert.Equal("response-123", streamingUpdate.CompletionId); + Assert.Equal("gpt-4", streamingUpdate.Model); + + // Should have text content + Assert.Contains(streamingUpdate.ContentUpdate, c => c.Text == "Processing your request..."); + + // Should have tool call + Assert.Single(streamingUpdate.ToolCallUpdates); + Assert.Equal("call-123", streamingUpdate.ToolCallUpdates[0].ToolCallId); + + // Should have usage + Assert.NotNull(streamingUpdate.Usage); + Assert.Equal(50, streamingUpdate.Usage.TotalTokenCount); + } + + [Fact] + public async Task AsOpenAIStreamingChatCompletionUpdatesAsync_WithDifferentRoles_MapsCorrectly() + { + var testCases = new[] + { + (ChatRole.Assistant, ChatMessageRole.Assistant), + (ChatRole.User, ChatMessageRole.User), + (ChatRole.System, ChatMessageRole.System), + (ChatRole.Tool, ChatMessageRole.Tool) + }; + + foreach (var (inputRole, expectedOpenAIRole) in testCases) + { + var responseUpdate = new ChatResponseUpdate(inputRole, "Test message"); + + var result = new List(); + await foreach (var update in CreateAsyncEnumerable(new[] { responseUpdate }).AsOpenAIStreamingChatCompletionUpdatesAsync()) + { + result.Add(update); + } + + Assert.Single(result); + Assert.Equal(expectedOpenAIRole, result[0].Role); + } + } + + [Fact] + public async Task AsOpenAIStreamingChatCompletionUpdatesAsync_WithDifferentFinishReasons_MapsCorrectly() + { + var testCases = new[] + { + (ChatFinishReason.Stop, OpenAI.Chat.ChatFinishReason.Stop), + (ChatFinishReason.Length, OpenAI.Chat.ChatFinishReason.Length), + (ChatFinishReason.ContentFilter, OpenAI.Chat.ChatFinishReason.ContentFilter), + (ChatFinishReason.ToolCalls, OpenAI.Chat.ChatFinishReason.ToolCalls) + }; + + foreach (var (inputFinishReason, expectedOpenAIFinishReason) in testCases) + { + var responseUpdate = new ChatResponseUpdate(ChatRole.Assistant, "Test") + { + FinishReason = inputFinishReason + }; + + var result = new List(); + await foreach (var update in CreateAsyncEnumerable(new[] { responseUpdate }).AsOpenAIStreamingChatCompletionUpdatesAsync()) + { + result.Add(update); + } + + Assert.Single(result); + Assert.Equal(expectedOpenAIFinishReason, result[0].FinishReason); + } + } + + [Fact] + public async Task AsOpenAIStreamingChatCompletionUpdatesAsync_WithMultipleUpdates_ProcessesAllCorrectly() + { + var updates = new[] + { + new ChatResponseUpdate(ChatRole.Assistant, "Hello, ") + { + ResponseId = "response-123", + MessageId = "message-1" + + // No FinishReason set - null + }, + new ChatResponseUpdate(ChatRole.Assistant, "world!") + { + ResponseId = "response-123", + MessageId = "message-1", + FinishReason = ChatFinishReason.Stop + } + }; + + var result = new List(); + await foreach (var update in CreateAsyncEnumerable(updates).AsOpenAIStreamingChatCompletionUpdatesAsync()) + { + result.Add(update); + } + + Assert.Equal(2, result.Count); + + Assert.Equal("response-123", result[0].CompletionId); + Assert.Equal("Hello, ", Assert.Single(result[0].ContentUpdate).Text); + + // The ToChatFinishReason method defaults null to Stop + Assert.Equal(OpenAI.Chat.ChatFinishReason.Stop, result[0].FinishReason); + + Assert.Equal("response-123", result[1].CompletionId); + Assert.Equal("world!", Assert.Single(result[1].ContentUpdate).Text); + Assert.Equal(OpenAI.Chat.ChatFinishReason.Stop, result[1].FinishReason); + } + + [Fact] + public void AsOpenAIResponse_WithNullArgument_ThrowsArgumentNullException() + { + Assert.Throws("response", () => ((ChatResponse)null!).AsOpenAIResponse()); + } + + [Fact] + public void AsOpenAIResponse_WithRawRepresentation_ReturnsOriginal() + { + var originalOpenAIResponse = OpenAIResponsesModelFactory.OpenAIResponse( + "original-response-id", + new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero), + ResponseStatus.Completed, + usage: null, + maxOutputTokenCount: 100, + outputItems: [], + parallelToolCallsEnabled: false, + model: "gpt-4", + temperature: 0.7f, + topP: 0.9f, + previousResponseId: "prev-id", + instructions: "Test instructions"); + + var chatResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Test")) + { + RawRepresentation = originalOpenAIResponse + }; + + var result = chatResponse.AsOpenAIResponse(); + + Assert.Same(originalOpenAIResponse, result); + } + + [Fact] + public void AsOpenAIResponse_WithBasicChatResponse_CreatesValidOpenAIResponse() + { + var chatResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Hello, world!")) + { + ResponseId = "test-response-id", + ModelId = "gpt-4-turbo", + CreatedAt = new DateTimeOffset(2025, 1, 15, 10, 30, 0, TimeSpan.Zero), + FinishReason = ChatFinishReason.Stop + }; + + var openAIResponse = chatResponse.AsOpenAIResponse(); + + Assert.NotNull(openAIResponse); + Assert.Equal("test-response-id", openAIResponse.Id); + Assert.Equal("gpt-4-turbo", openAIResponse.Model); + Assert.Equal(new DateTimeOffset(2025, 1, 15, 10, 30, 0, TimeSpan.Zero), openAIResponse.CreatedAt); + Assert.Equal(ResponseStatus.Completed, openAIResponse.Status); + Assert.NotNull(openAIResponse.OutputItems); + Assert.Single(openAIResponse.OutputItems); + + var outputItem = Assert.IsAssignableFrom(openAIResponse.OutputItems.First()); + Assert.Equal("Hello, world!", Assert.Single(outputItem.Content).Text); + } + + [Fact] + public void AsOpenAIResponse_WithChatOptions_IncludesOptionsInResponse() + { + var chatResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Test message")) + { + ResponseId = "options-test", + ModelId = "gpt-3.5-turbo" + }; + + var options = new ChatOptions + { + MaxOutputTokens = 500, + AllowMultipleToolCalls = true, + ConversationId = "conversation-123", + Instructions = "You are a helpful assistant.", + Temperature = 0.8f, + TopP = 0.95f, + ModelId = "override-model" + }; + + var openAIResponse = chatResponse.AsOpenAIResponse(options); + + Assert.Equal("options-test", openAIResponse.Id); + Assert.Equal("gpt-3.5-turbo", openAIResponse.Model); + Assert.Equal(500, openAIResponse.MaxOutputTokenCount); + Assert.True(openAIResponse.ParallelToolCallsEnabled); + Assert.Equal("conversation-123", openAIResponse.PreviousResponseId); + Assert.Equal("You are a helpful assistant.", openAIResponse.Instructions); + Assert.Equal(0.8f, openAIResponse.Temperature); + Assert.Equal(0.95f, openAIResponse.TopP); + } + + [Fact] + public void AsOpenAIResponse_WithEmptyMessages_CreatesResponseWithEmptyOutputItems() + { + var chatResponse = new ChatResponse([]) + { + ResponseId = "empty-response", + ModelId = "gpt-4" + }; + + var openAIResponse = chatResponse.AsOpenAIResponse(); + + Assert.Equal("empty-response", openAIResponse.Id); + Assert.Equal("gpt-4", openAIResponse.Model); + Assert.Empty(openAIResponse.OutputItems); + } + + [Fact] + public void AsOpenAIResponse_WithMultipleMessages_ConvertsAllMessages() + { + var messages = new List + { + new(ChatRole.Assistant, "First message"), + new(ChatRole.Assistant, "Second message"), + new(ChatRole.Assistant, + [ + new TextContent("Third message with function call"), + new FunctionCallContent("call-123", "TestFunction", new Dictionary { ["param"] = "value" }) + ]) + }; + + var chatResponse = new ChatResponse(messages) + { + ResponseId = "multi-message-response" + }; + + var openAIResponse = chatResponse.AsOpenAIResponse(); + + Assert.Equal(4, openAIResponse.OutputItems.Count); + + var messageItems = openAIResponse.OutputItems.OfType().ToArray(); + var functionCallItems = openAIResponse.OutputItems.OfType().ToArray(); + + Assert.Equal(3, messageItems.Length); + Assert.Single(functionCallItems); + + Assert.Equal("First message", Assert.Single(messageItems[0].Content).Text); + Assert.Equal("Second message", Assert.Single(messageItems[1].Content).Text); + Assert.Equal("Third message with function call", Assert.Single(messageItems[2].Content).Text); + + Assert.Equal("call-123", functionCallItems[0].CallId); + Assert.Equal("TestFunction", functionCallItems[0].FunctionName); + } + + [Fact] + public void AsOpenAIResponse_WithToolMessages_ConvertsCorrectly() + { + var messages = new List + { + new(ChatRole.Assistant, + [ + new TextContent("I'll call a function"), + new FunctionCallContent("call-456", "GetWeather", new Dictionary { ["location"] = "Seattle" }) + ]), + new(ChatRole.Tool, [new FunctionResultContent("call-456", "The weather is sunny")]), + new(ChatRole.Assistant, "The weather in Seattle is sunny!") + }; + + var chatResponse = new ChatResponse(messages) + { + ResponseId = "tool-message-test" + }; + + var openAIResponse = chatResponse.AsOpenAIResponse(); + + var outputItems = openAIResponse.OutputItems.ToArray(); + Assert.Equal(4, outputItems.Length); + + // Should have message, function call, function output, and final message + Assert.IsType(outputItems[0], exactMatch: false); + Assert.IsType(outputItems[1], exactMatch: false); + Assert.IsType(outputItems[2], exactMatch: false); + Assert.IsType(outputItems[3], exactMatch: false); + + var functionCallOutput = (FunctionCallOutputResponseItem)outputItems[2]; + Assert.Equal("call-456", functionCallOutput.CallId); + Assert.Equal("The weather is sunny", functionCallOutput.FunctionOutput); + } + + [Fact] + public void AsOpenAIResponse_WithSystemAndUserMessages_ConvertsCorrectly() + { + var messages = new List + { + new(ChatRole.System, "You are a helpful assistant."), + new(ChatRole.User, "Hello, how are you?"), + new(ChatRole.Assistant, "I'm doing well, thank you for asking!") + }; + + var chatResponse = new ChatResponse(messages) + { + ResponseId = "system-user-test" + }; + + var openAIResponse = chatResponse.AsOpenAIResponse(); + + var outputItems = openAIResponse.OutputItems.ToArray(); + Assert.Equal(3, outputItems.Length); + + var systemMessage = Assert.IsType(outputItems[0], exactMatch: false); + var userMessage = Assert.IsType(outputItems[1], exactMatch: false); + var assistantMessage = Assert.IsType(outputItems[2], exactMatch: false); + + Assert.Equal("You are a helpful assistant.", Assert.Single(systemMessage.Content).Text); + Assert.Equal("Hello, how are you?", Assert.Single(userMessage.Content).Text); + Assert.Equal("I'm doing well, thank you for asking!", Assert.Single(assistantMessage.Content).Text); + } + + [Fact] + public void AsOpenAIResponse_WithDefaultValues_UsesExpectedDefaults() + { + var chatResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Default test")); + + var openAIResponse = chatResponse.AsOpenAIResponse(); + + Assert.NotNull(openAIResponse); + Assert.Equal(ResponseStatus.Completed, openAIResponse.Status); + Assert.False(openAIResponse.ParallelToolCallsEnabled); + Assert.Null(openAIResponse.MaxOutputTokenCount); + Assert.Null(openAIResponse.Temperature); + Assert.Null(openAIResponse.TopP); + Assert.Null(openAIResponse.PreviousResponseId); + Assert.Null(openAIResponse.Instructions); + Assert.NotNull(openAIResponse.OutputItems); + } + + [Fact] + public void AsOpenAIResponse_WithOptionsButNoModelId_UsesOptionsModelId() + { + var chatResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Model test")); + + var options = new ChatOptions + { + ModelId = "options-model-id" + }; + + var openAIResponse = chatResponse.AsOpenAIResponse(options); + + Assert.Equal("options-model-id", openAIResponse.Model); + } + + [Fact] + public void AsOpenAIResponse_WithBothModelIds_PrefersChatResponseModelId() + { + var chatResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Model priority test")) + { + ModelId = "response-model-id" + }; + + var options = new ChatOptions + { + ModelId = "options-model-id" + }; + + var openAIResponse = chatResponse.AsOpenAIResponse(options); + + Assert.Equal("response-model-id", openAIResponse.Model); + } + + [Fact] + public void ListAddResponseTool_AddsToolCorrectly() + { + Assert.Throws("tools", () => ((IList)null!).Add(ResponseTool.CreateWebSearchTool())); + Assert.Throws("tool", () => new List().Add((ResponseTool)null!)); + + Assert.Throws("tool", () => ((ResponseTool)null!).AsAITool()); + + ChatOptions options; + + options = new() + { + Tools = new List { ResponseTool.CreateWebSearchTool() }, + }; + Assert.Single(options.Tools); + Assert.NotNull(options.Tools[0]); + + var rawSearchTool = ResponseTool.CreateWebSearchTool(); + options = new() + { + Tools = [rawSearchTool.AsAITool()], + }; + Assert.Single(options.Tools); + Assert.NotNull(options.Tools[0]); + + Assert.Same(rawSearchTool, options.Tools[0].GetService()); + Assert.Same(rawSearchTool, options.Tools[0].GetService()); + Assert.Null(options.Tools[0].GetService("key")); + } + + private static async IAsyncEnumerable CreateAsyncEnumerable(IEnumerable source) + { + foreach (var item in source) + { + await Task.Yield(); + yield return item; + } + } + + private static string RemoveWhitespace(string input) => Regex.Replace(input, @"\s+", ""); + + /// Helper class for testing unknown tool types. + private sealed class UnknownAITool : AITool + { + public override string Name => "unknown_tool"; + } + + /// Helper class for testing WebSearchTool with additional properties. + private sealed class HostedWebSearchToolWithProperties : HostedWebSearchTool + { + private readonly Dictionary _additionalProperties; + + public override IReadOnlyDictionary AdditionalProperties => _additionalProperties; + + public HostedWebSearchToolWithProperties(Dictionary additionalProperties) + { + _additionalProperties = additionalProperties; + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs index 43112fa88e3..0db88d499e1 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs @@ -125,10 +125,7 @@ public async Task GetEmbeddingsAsync_ExpectedRequestResponse() 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), - }).GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator(); + using IEmbeddingGenerator> generator = CreateEmbeddingGenerator(httpClient, "text-embedding-3-small"); var response = await generator.GenerateAsync([ "hello, world!", @@ -188,10 +185,7 @@ public async Task EmbeddingGenerationOptions_DoNotOverwrite_NotNullPropertiesInR 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), - }).GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator(); + using IEmbeddingGenerator> generator = CreateEmbeddingGenerator(httpClient, "text-embedding-3-small"); var response = await generator.GenerateAsync([ "hello, world!", @@ -221,4 +215,24 @@ public async Task EmbeddingGenerationOptions_DoNotOverwrite_NotNullPropertiesInR Assert.Contains(e.Vector.ToArray(), f => !f.Equals(0)); } } + + [Fact] + public async Task RequestHeaders_UserAgent_ContainsMEAI() + { + using var handler = new ThrowUserAgentExceptionHandler(); + using HttpClient httpClient = new(handler); + using IEmbeddingGenerator> generator = CreateEmbeddingGenerator(httpClient, "text-embedding-3-small"); + + InvalidOperationException e = await Assert.ThrowsAsync(() => generator.GenerateAsync("hello")); + + Assert.StartsWith("User-Agent header: OpenAI", e.Message); + Assert.Contains("MEAI", e.Message); + } + + private static IEmbeddingGenerator> CreateEmbeddingGenerator(HttpClient httpClient, string modelId) => + new OpenAIClient( + new ApiKeyCredential("apikey"), + new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(httpClient) }) + .GetEmbeddingClient(modelId) + .AsIEmbeddingGenerator(); } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIImageGeneratingChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIImageGeneratingChatClientIntegrationTests.cs new file mode 100644 index 00000000000..7f9e6195aa3 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIImageGeneratingChatClientIntegrationTests.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.AI; + +/// +/// OpenAI-specific integration tests for ImageGeneratingChatClient. +/// Tests the ImageGeneratingChatClient with OpenAI's chat client implementation. +/// +public class OpenAIImageGeneratingChatClientIntegrationTests : ImageGeneratingChatClientIntegrationTests +{ + protected override IChatClient? CreateChatClient() => + IntegrationTestHelpers.GetOpenAIClient() + ?.GetChatClient(TestRunnerConfiguration.Instance["OpenAI:ChatModel"] ?? "gpt-4o-mini").AsIChatClient(); +} diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIImageGeneratorIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIImageGeneratorIntegrationTests.cs new file mode 100644 index 00000000000..ce0cdb7cf82 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIImageGeneratorIntegrationTests.cs @@ -0,0 +1,12 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.AI; + +public class OpenAIImageGeneratorIntegrationTests : ImageGeneratorIntegrationTests +{ + protected override IImageGenerator? CreateGenerator() + => IntegrationTestHelpers.GetOpenAIClient()? + .GetImageClient(TestRunnerConfiguration.Instance["OpenAI:ImageModel"] ?? "dall-e-3") + .AsIImageGenerator(); +} diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIImageGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIImageGeneratorTests.cs new file mode 100644 index 00000000000..607b1e2859e --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIImageGeneratorTests.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.ClientModel; +using OpenAI; +using OpenAI.Images; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class OpenAIImageGeneratorTests +{ + [Fact] + public void AsIImageGenerator_InvalidArgs_Throws() + { + Assert.Throws("imageClient", () => ((ImageClient)null!).AsIImageGenerator()); + } + + [Fact] + public void AsIImageGenerator_OpenAIClient_ProducesExpectedMetadata() + { + Uri endpoint = new("http://localhost/some/endpoint"); + string model = "dall-e-3"; + + var client = new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); + + IImageGenerator imageClient = client.GetImageClient(model).AsIImageGenerator(); + var metadata = imageClient.GetService(); + Assert.Equal(endpoint, metadata?.ProviderUri); + Assert.Equal(model, metadata?.DefaultModelId); + } + + [Fact] + public void GetService_ReturnsExpectedServices() + { + var client = new OpenAIClient(new ApiKeyCredential("key")); + IImageGenerator imageClient = client.GetImageClient("dall-e-3").AsIImageGenerator(); + + Assert.Same(imageClient, imageClient.GetService()); + Assert.Same(imageClient, imageClient.GetService()); + Assert.NotNull(imageClient.GetService()); + Assert.NotNull(imageClient.GetService()); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs index f8e835bdb81..6a7f82302ea 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs @@ -1,7 +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.Linq; using System.Threading.Tasks; +using Microsoft.TestUtilities; +using Xunit; namespace Microsoft.Extensions.AI; @@ -14,6 +19,535 @@ public class OpenAIResponseClientIntegrationTests : ChatClientIntegrationTests public override bool FunctionInvokingChatClientSetsConversationId => true; - // Test structure doesn't make sense with Respones. + // Test structure doesn't make sense with Responses. public override Task Caching_AfterFunctionInvocation_FunctionOutputUnchangedAsync() => Task.CompletedTask; + + [ConditionalFact] + public async Task UseCodeInterpreter_ProducesCodeExecutionResults() + { + SkipIfNotEnabled(); + + var response = await ChatClient.GetResponseAsync("Use the code interpreter to calculate the square root of 42. Return only the nearest integer value and no other text.", new() + { + Tools = [new HostedCodeInterpreterTool()], + }); + Assert.NotNull(response); + + ChatMessage message = Assert.Single(response.Messages); + + Assert.Equal("6", message.Text); + + // Validate CodeInterpreterToolCallContent + var toolCallContent = response.Messages.SelectMany(m => m.Contents).OfType().SingleOrDefault(); + Assert.NotNull(toolCallContent); + Assert.NotNull(toolCallContent.CallId); + Assert.NotEmpty(toolCallContent.CallId); + Assert.NotNull(toolCallContent.Inputs); + Assert.NotEmpty(toolCallContent.Inputs); + + var codeInput = toolCallContent.Inputs.OfType().FirstOrDefault(); + Assert.NotNull(codeInput); + Assert.Equal("text/x-python", codeInput.MediaType); + Assert.NotEmpty(codeInput.Data.ToArray()); + + // Validate CodeInterpreterToolResultContent + var toolResultContent = response.Messages.SelectMany(m => m.Contents).OfType().FirstOrDefault(); + Assert.NotNull(toolResultContent); + Assert.NotNull(toolResultContent.CallId); + Assert.NotEmpty(toolResultContent.CallId); + + if (toolResultContent.Outputs is not null) + { + Assert.NotEmpty(toolResultContent.Outputs); + if (toolResultContent.Outputs.OfType().FirstOrDefault() is { } resultOutput) + { + Assert.NotEmpty(resultOutput.Text); + } + } + } + + [ConditionalFact] + public async Task UseWebSearch_AnnotationsReflectResults() + { + SkipIfNotEnabled(); + + var response = await ChatClient.GetResponseAsync( + "Write a paragraph about .NET based on at least three recent news articles. Cite your sources.", + new() { Tools = [new HostedWebSearchTool()] }); + + ChatMessage m = Assert.Single(response.Messages); + TextContent tc = m.Contents.OfType().First(); + Assert.NotNull(tc.Annotations); + Assert.NotEmpty(tc.Annotations); + Assert.All(tc.Annotations, a => + { + CitationAnnotation ca = Assert.IsType(a); + var regions = Assert.IsType>(ca.AnnotatedRegions); + Assert.NotNull(regions); + Assert.Single(regions); + var region = Assert.IsType(regions[0]); + Assert.NotNull(region); + Assert.NotNull(region.StartIndex); + Assert.NotNull(region.EndIndex); + Assert.NotNull(ca.Url); + Assert.NotNull(ca.Title); + Assert.NotEmpty(ca.Title); + }); + } + + [ConditionalFact] + public async Task RemoteMCP_ListTools() + { + SkipIfNotEnabled(); + + ChatOptions chatOptions = new() + { + Tools = [new HostedMcpServerTool("deepwiki", new Uri("https://mcp.deepwiki.com/mcp")) { ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire }], + }; + + ChatResponse response = await CreateChatClient()!.GetResponseAsync("Which tools are available on the wiki_tools MCP server?", chatOptions); + + Assert.Contains("read_wiki_structure", response.Text); + Assert.Contains("read_wiki_contents", response.Text); + Assert.Contains("ask_question", response.Text); + } + + [ConditionalFact] + public async Task RemoteMCP_CallTool_ApprovalNeverRequired() + { + SkipIfNotEnabled(); + + await RunAsync(false, false); + await RunAsync(true, true); + + async Task RunAsync(bool streaming, bool requireSpecific) + { + ChatOptions chatOptions = new() + { + Tools = [new HostedMcpServerTool("deepwiki", new Uri("https://mcp.deepwiki.com/mcp")) + { + ApprovalMode = requireSpecific ? + HostedMcpServerToolApprovalMode.RequireSpecific(null, ["read_wiki_structure", "ask_question"]) : + HostedMcpServerToolApprovalMode.NeverRequire, + } + ], + }; + + using var client = CreateChatClient()!; + + const string Prompt = "Tell me the path to the README.md file for Microsoft.Extensions.AI.Abstractions in the dotnet/extensions repository"; + + ChatResponse response = streaming ? + await client.GetStreamingResponseAsync(Prompt, chatOptions).ToChatResponseAsync() : + await client.GetResponseAsync(Prompt, chatOptions); + + Assert.NotNull(response); + Assert.NotEmpty(response.Messages.SelectMany(m => m.Contents).OfType()); + Assert.NotEmpty(response.Messages.SelectMany(m => m.Contents).OfType()); + Assert.Empty(response.Messages.SelectMany(m => m.Contents).OfType()); + + Assert.Contains("src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md", response.Text); + } + } + + [ConditionalFact] + public async Task RemoteMCP_CallTool_ApprovalRequired() + { + SkipIfNotEnabled(); + + await RunAsync(false, false, false); + await RunAsync(true, true, false); + await RunAsync(false, false, true); + await RunAsync(true, true, true); + + async Task RunAsync(bool streaming, bool requireSpecific, bool useConversationId) + { + ChatOptions chatOptions = new() + { + Tools = [new HostedMcpServerTool("deepwiki", new Uri("https://mcp.deepwiki.com/mcp")) + { + ApprovalMode = requireSpecific ? + HostedMcpServerToolApprovalMode.RequireSpecific(["read_wiki_structure", "ask_question"], null) : + HostedMcpServerToolApprovalMode.AlwaysRequire, + } + ], + }; + + using var client = CreateChatClient()!; + + // Initial request + List input = [new ChatMessage(ChatRole.User, "Tell me the path to the README.md file for Microsoft.Extensions.AI.Abstractions in the dotnet/extensions repository")]; + ChatResponse response = streaming ? + await client.GetStreamingResponseAsync(input, chatOptions).ToChatResponseAsync() : + await client.GetResponseAsync(input, chatOptions); + + // Handle approvals of up to two rounds of tool calls + int approvalsCount = 0; + for (int i = 0; i < 2; i++) + { + if (useConversationId) + { + chatOptions.ConversationId = response.ConversationId; + input.Clear(); + } + else + { + input.AddRange(response.Messages); + } + + var approvalResponse = new ChatMessage(ChatRole.Tool, + response.Messages + .SelectMany(m => m.Contents) + .OfType() + .Select(c => new McpServerToolApprovalResponseContent(c.ToolCall.CallId, true)) + .ToArray()); + if (approvalResponse.Contents.Count == 0) + { + break; + } + + approvalsCount += approvalResponse.Contents.Count; + input.Add(approvalResponse); + response = streaming ? + await client.GetStreamingResponseAsync(input, chatOptions).ToChatResponseAsync() : + await client.GetResponseAsync(input, chatOptions); + } + + // Validate final response + Assert.Equal(2, approvalsCount); + Assert.Contains("src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md", response.Text); + } + } + + [ConditionalFact] + public async Task GetResponseAsync_BackgroundResponses() + { + SkipIfNotEnabled(); + + var chatOptions = new ChatOptions + { + AllowBackgroundResponses = true, + }; + + // Get initial response with continuation token + var response = await ChatClient.GetResponseAsync("What's the biggest animal?", chatOptions); + Assert.NotNull(response.ContinuationToken); + Assert.Empty(response.Messages); + + int attempts = 0; + + // Continue to poll until we get the final response + while (response.ContinuationToken is not null && ++attempts < 10) + { + chatOptions.ContinuationToken = response.ContinuationToken; + response = await ChatClient.GetResponseAsync([], chatOptions); + await Task.Delay(1000); + } + + Assert.Contains("whale", response.Text, StringComparison.OrdinalIgnoreCase); + } + + [ConditionalFact] + public async Task GetResponseAsync_BackgroundResponses_WithFunction() + { + SkipIfNotEnabled(); + + int callCount = 0; + + using var chatClient = new FunctionInvokingChatClient(ChatClient); + + var chatOptions = new ChatOptions + { + AllowBackgroundResponses = true, + Tools = [AIFunctionFactory.Create(() => { callCount++; return "5:43"; }, new AIFunctionFactoryOptions { Name = "GetCurrentTime" })] + }; + + // Get initial response with continuation token + var response = await chatClient.GetResponseAsync("What time is it?", chatOptions); + Assert.NotNull(response.ContinuationToken); + Assert.Empty(response.Messages); + + int attempts = 0; + + // Poll until the result is received + while (response.ContinuationToken is not null && ++attempts < 10) + { + chatOptions.ContinuationToken = response.ContinuationToken; + + response = await chatClient.GetResponseAsync([], chatOptions); + await Task.Delay(1000); + } + + Assert.Contains("5:43", response.Text, StringComparison.OrdinalIgnoreCase); + Assert.Equal(1, callCount); + } + + [ConditionalFact] + public async Task GetStreamingResponseAsync_BackgroundResponses() + { + SkipIfNotEnabled(); + + ChatOptions chatOptions = new() + { + AllowBackgroundResponses = true, + }; + + string responseText = ""; + + await foreach (var update in ChatClient.GetStreamingResponseAsync("What is the capital of France?", chatOptions)) + { + responseText += update; + } + + // Assert + Assert.Contains("Paris", responseText, StringComparison.OrdinalIgnoreCase); + } + + [ConditionalFact] + public async Task GetStreamingResponseAsync_BackgroundResponses_StreamResumption() + { + SkipIfNotEnabled(); + + ChatOptions chatOptions = new() + { + AllowBackgroundResponses = true, + }; + + int updateNumber = 0; + string responseText = ""; + object? continuationToken = null; + + await foreach (var update in ChatClient.GetStreamingResponseAsync("What is the capital of France?", chatOptions)) + { + responseText += update; + + // Simulate an interruption after receiving 8 updates. + if (updateNumber++ == 8) + { + continuationToken = update.ContinuationToken; + break; + } + } + + Assert.DoesNotContain("Paris", responseText); + + // Resume streaming from the point of interruption captured by the continuation token. + chatOptions.ContinuationToken = continuationToken; + await foreach (var update in ChatClient.GetStreamingResponseAsync([], chatOptions)) + { + responseText += update; + } + + Assert.Contains("Paris", responseText, StringComparison.OrdinalIgnoreCase); + } + + [ConditionalFact] + public async Task GetStreamingResponseAsync_BackgroundResponses_WithFunction() + { + SkipIfNotEnabled(); + + int callCount = 0; + + using var chatClient = new FunctionInvokingChatClient(ChatClient); + + var chatOptions = new ChatOptions + { + AllowBackgroundResponses = true, + Tools = [AIFunctionFactory.Create(() => { callCount++; return "5:43"; }, new AIFunctionFactoryOptions { Name = "GetCurrentTime" })] + }; + + string responseText = ""; + + await foreach (var update in chatClient.GetStreamingResponseAsync("What time is it?", chatOptions)) + { + responseText += update; + } + + Assert.Contains("5:43", responseText); + Assert.Equal(1, callCount); + } + + [ConditionalFact] + public async Task RemoteMCP_Connector() + { + SkipIfNotEnabled(); + + if (TestRunnerConfiguration.Instance["RemoteMCP:ConnectorAccessToken"] is not string accessToken) + { + throw new SkipTestException( + "To run this test, set a value for RemoteMCP:ConnectorAccessToken. " + + "You can obtain one by following https://platform.openai.com/docs/guides/tools-connectors-mcp?quickstart-panels=connector#authorizing-a-connector."); + } + + await RunAsync(false, false); + await RunAsync(true, true); + + async Task RunAsync(bool streaming, bool approval) + { + ChatOptions chatOptions = new() + { + Tools = [new HostedMcpServerTool("calendar", "connector_googlecalendar") + { + ApprovalMode = approval ? + HostedMcpServerToolApprovalMode.AlwaysRequire : + HostedMcpServerToolApprovalMode.NeverRequire, + AuthorizationToken = accessToken + } + ], + }; + + using var client = CreateChatClient()!; + + List input = [new ChatMessage(ChatRole.User, "What is on my calendar for today?")]; + + ChatResponse response = streaming ? + await client.GetStreamingResponseAsync(input, chatOptions).ToChatResponseAsync() : + await client.GetResponseAsync(input, chatOptions); + + if (approval) + { + input.AddRange(response.Messages); + var approvalRequest = Assert.Single(response.Messages.SelectMany(m => m.Contents).OfType()); + Assert.Equal("search_events", approvalRequest.ToolCall.ToolName); + input.Add(new ChatMessage(ChatRole.Tool, [approvalRequest.CreateResponse(true)])); + + response = streaming ? + await client.GetStreamingResponseAsync(input, chatOptions).ToChatResponseAsync() : + await client.GetResponseAsync(input, chatOptions); + } + + Assert.NotNull(response); + var toolCall = Assert.Single(response.Messages.SelectMany(m => m.Contents).OfType()); + Assert.Equal("search_events", toolCall.ToolName); + + var toolResult = Assert.Single(response.Messages.SelectMany(m => m.Contents).OfType()); + var content = Assert.IsType(Assert.Single(toolResult.Output!)); + Assert.Equal(@"{""events"": [], ""next_page_token"": null}", content.Text); + } + } + + [ConditionalFact] + public async Task ToolCallResult_TextContent() + { + SkipIfNotEnabled(); + + var chatOptions = new ChatOptions + { + Tools = [AIFunctionFactory.Create((int a, int b) => new TextContent($"The sum is {a + b}"), "AddNumbers", "Adds two numbers together")] + }; + + using var client = new FunctionInvokingChatClient(ChatClient); + + var response = await client.GetResponseAsync("What is 25 plus 17? Use the AddNumbers function.", chatOptions); + + Assert.NotNull(response); + + // The model should have called the function and received "The sum is 42" + Assert.Contains("42", response.Text); + } + + [ConditionalFact] + public async Task ToolCallResult_MultipleAIContents() + { + SkipIfNotEnabled(); + + var chatOptions = new ChatOptions + { + Tools = [AIFunctionFactory.Create((string city) => new List + { + new TextContent($"Weather in {city}: "), + new TextContent("Sunny, 72°F") + }, "GetWeather", "Gets the weather for a city")] + }; + + using var client = new FunctionInvokingChatClient(ChatClient); + + var response = await client.GetResponseAsync("What's the weather in Seattle? Use GetWeather.", chatOptions); + + Assert.NotNull(response); + + // Verify the function was called and both parts were included + var messages = response.Messages.ToList(); + Assert.NotEmpty(messages); + + // Check that we got a response mentioning the weather data + Assert.Contains("72", response.Text); + } + + [ConditionalFact] + public async Task ToolCallResult_ImageDataContent() + { + SkipIfNotEnabled(); + + var chatOptions = new ChatOptions + { + Tools = [AIFunctionFactory.Create(() => new DataContent(ImageDataUri.GetImageDataUri(), "image/png"), "GetDotnetLogo", "Returns the .NET logo image")] + }; + + using var client = new FunctionInvokingChatClient(ChatClient); + + var response = await client.GetResponseAsync("Call GetDotnetLogo and tell me what you see in the image.", chatOptions); + + Assert.NotNull(response); + + // The model should describe seeing the .NET logo or purple/related colors + Assert.True( + response.Text.Contains("logo", StringComparison.OrdinalIgnoreCase) || + response.Text.Contains("purple", StringComparison.OrdinalIgnoreCase) || + response.Text.Contains("dot", StringComparison.OrdinalIgnoreCase) || + response.Text.Contains("net", StringComparison.OrdinalIgnoreCase), + $"Expected response to mention logo or colors, but got: {response.Text}"); + } + + [ConditionalFact] + public async Task ToolCallResult_PdfDataContent() + { + SkipIfNotEnabled(); + + var chatOptions = new ChatOptions + { + Tools = [AIFunctionFactory.Create(() => new DataContent(ImageDataUri.GetPdfDataUri(), "application/pdf") { Name = "document.pdf" }, "GetDocument", "Returns a PDF document")] + }; + + using var client = new FunctionInvokingChatClient(ChatClient); + + var response = await client.GetResponseAsync("Call GetDocument and tell me what text is in the PDF.", chatOptions); + + Assert.NotNull(response); + + // The PDF contains "Hello World!" text + Assert.Contains("Hello World", response.Text, StringComparison.OrdinalIgnoreCase); + } + + [ConditionalFact] + public async Task ToolCallResult_MixedContentWithImage() + { + SkipIfNotEnabled(); + + var imageUri = ImageDataUri.GetImageDataUri(); + var imageBytes = Convert.FromBase64String(imageUri.ToString().Split(',')[1]); + + var chatOptions = new ChatOptions + { + Tools = [AIFunctionFactory.Create(() => new List + { + new TextContent("Analysis result: "), + new DataContent(imageBytes, "image/png"), + new TextContent(" - Image provided above") + }, "AnalyzeImage", "Analyzes an image and returns results")] + }; + + using var client = new FunctionInvokingChatClient(ChatClient); + + var response = await client.GetResponseAsync("Call AnalyzeImage and describe what you see.", chatOptions); + + Assert.NotNull(response); + + // Should mention the analysis and describe the image + Assert.True( + response.Text.Contains("analysis", StringComparison.OrdinalIgnoreCase) || + response.Text.Contains("image", StringComparison.OrdinalIgnoreCase) || + response.Text.Contains("logo", StringComparison.OrdinalIgnoreCase), + $"Expected response to mention analysis or image content, but got: {response.Text}"); + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs index b98eb89197f..1ee738bd6a0 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs @@ -6,9 +6,12 @@ using System.ClientModel.Primitives; using System.Collections.Generic; using System.ComponentModel; +using System.IO; using System.Linq; using System.Net.Http; +using System.Text; using System.Text.Json; +using System.Text.Json.Nodes; using System.Threading.Tasks; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; @@ -168,6 +171,186 @@ public async Task BasicRequestResponse_NonStreaming() Assert.Equal(36, response.Usage.TotalTokenCount); } + [Fact] + public async Task BasicReasoningResponse_Streaming() + { + const string Input = """ + { + "input":[{ + "type":"message", + "role":"user", + "content":[{ + "type":"input_text", + "text":"Calculate the sum of the first 5 positive integers." + }] + }], + "reasoning": { + "summary": "detailed", + "effort": "low" + }, + "model": "o4-mini", + "stream": true + } + """; + + // Compressed down for testing purposes; real-world output would be larger. + const string Output = """ + event: response.created + data: {"type":"response.created","sequence_number":0,"response":{"id":"resp_68b5ebab461881969ed94149372c2a530698ecbf1b9f2704","object":"response","created_at":1756752811,"status":"in_progress","background":false,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"o4-mini-2025-04-16","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"reasoning":{"effort":"low","summary":"detailed"},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} + + event: response.in_progress + data: {"type":"response.in_progress","sequence_number":1,"response":{"id":"resp_68b5ebab461881969ed94149372c2a530698ecbf1b9f2704","object":"response","created_at":1756752811,"status":"in_progress","background":false,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"o4-mini-2025-04-16","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"reasoning":{"effort":"low","summary":"detailed"},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} + + event: response.output_item.added + data: {"type":"response.output_item.added","sequence_number":2,"output_index":0,"item":{"id":"rs_68b5ebabc0088196afb9fa86b487732d0698ecbf1b9f2704","type":"reasoning","summary":[]}} + + event: response.reasoning_summary_part.added + data: {"type":"response.reasoning_summary_part.added","sequence_number":3,"item_id":"rs_68b5ebabc0088196afb9fa86b487732d0698ecbf1b9f2704","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}} + + event: response.reasoning_summary_text.delta + data: {"type":"response.reasoning_summary_text.delta","sequence_number":4,"item_id":"rs_68b5ebabc0088196afb9fa86b487732d0698ecbf1b9f2704","output_index":0,"summary_index":0,"delta":"**Calcul","obfuscation":"sLkbFySM"} + + event: response.reasoning_summary_text.delta + data: {"type":"response.reasoning_summary_text.delta","sequence_number":5,"item_id":"rs_68b5ebabc0088196afb9fa86b487732d0698ecbf1b9f2704","output_index":0,"summary_index":0,"delta":"ating","obfuscation":"dkm1f6DKqUj"} + + event: response.reasoning_summary_text.delta + data: {"type":"response.reasoning_summary_text.delta","sequence_number":6,"item_id":"rs_68b5ebabc0088196afb9fa86b487732d0698ecbf1b9f2704","output_index":0,"summary_index":0,"delta":" a","obfuscation":"X8ahc2lfCf9eA1"} + + event: response.reasoning_summary_text.delta + data: {"type":"response.reasoning_summary_text.delta","sequence_number":7,"item_id":"rs_68b5ebabc0088196afb9fa86b487732d0698ecbf1b9f2704","output_index":0,"summary_index":0,"delta":" simple","obfuscation":"1rLVyIaNl"} + + event: response.reasoning_summary_text.delta + data: {"type":"response.reasoning_summary_text.delta","sequence_number":8,"item_id":"rs_68b5ebabc0088196afb9fa86b487732d0698ecbf1b9f2704","output_index":0,"summary_index":0,"delta":" sum","obfuscation":"jCK7mgNR80Re"} + + event: response.reasoning_summary_text.done + data: {"type":"response.reasoning_summary_text.done","sequence_number":9,"item_id":"rs_68b5ebabc0088196afb9fa86b487732d0698ecbf1b9f2704","output_index":0,"summary_index":0,"text":"**Calculating a simple sum**"} + + event: response.reasoning_summary_part.done + data: {"type":"response.reasoning_summary_part.done","sequence_number":10,"item_id":"rs_68b5ebabc0088196afb9fa86b487732d0698ecbf1b9f2704","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":"**Calculating a simple sum**"}} + + event: response.output_item.done + data: {"type":"response.output_item.done","sequence_number":11,"output_index":0,"item":{"id":"rs_68b5ebabc0088196afb9fa86b487732d0698ecbf1b9f2704","type":"reasoning","summary":[{"type":"summary_text","text":"**Calculating a simple sum**"}]}} + + event: response.output_item.added + data: {"type":"response.output_item.added","sequence_number":12,"output_index":1,"item":{"id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","type":"message","status":"in_progress","content":[],"role":"assistant"}} + + event: response.content_part.added + data: {"type":"response.content_part.added","sequence_number":13,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":14,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"delta":"The","logprobs":[],"obfuscation":"japg2KaCkjNsp"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":15,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"delta":" sum","logprobs":[],"obfuscation":"1BEqjKQ0KU41"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":16,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"delta":" of","logprobs":[],"obfuscation":"GUqom1rsdZsnT"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":17,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"delta":" the","logprobs":[],"obfuscation":"UmCms91yrTlg"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":18,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"delta":" first","logprobs":[],"obfuscation":"AyNbZpfTXo"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":19,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"delta":" ","logprobs":[],"obfuscation":"tuyz4HkKODFQRtk"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":20,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"delta":"5","logprobs":[],"obfuscation":"QAwyISolmjXfTlc"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":21,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"delta":" positive","logprobs":[],"obfuscation":"2Euge1H"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":22,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"delta":" integers","logprobs":[],"obfuscation":"ih0Znt8"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":23,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"delta":" is","logprobs":[],"obfuscation":"oQihR5Pw8jRz5"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":24,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"delta":" 15","logprobs":[],"obfuscation":"7TdJ1FWlZF8lTd"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":25,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"delta":".","logprobs":[],"obfuscation":"x2VAJKlWI8qjgYq"} + + event: response.output_text.done + data: {"type":"response.output_text.done","sequence_number":26,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"text":"The sum of the first 5 positive integers is 15.","logprobs":[]} + + event: response.content_part.done + data: {"type":"response.content_part.done","sequence_number":27,"item_id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","output_index":1,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":"The sum of the first 5 positive integers is 15."}} + + event: response.output_item.done + data: {"type":"response.output_item.done","sequence_number":28,"output_index":1,"item":{"id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"The sum of the first 5 positive integers is 15."}],"role":"assistant"}} + + event: response.completed + data: {"type":"response.completed","sequence_number":29,"response":{"id":"resp_68b5ebab461881969ed94149372c2a530698ecbf1b9f2704","object":"response","created_at":1756752811,"status":"completed","background":false,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"o4-mini-2025-04-16","output":[{"id":"rs_68b5ebabc0088196afb9fa86b487732d0698ecbf1b9f2704","type":"reasoning","summary":[{"type":"summary_text","text":"**Calculating a simple sum**"}]},{"id":"msg_68b5ebae5a708196b74b94f22ca8995e0698ecbf1b9f2704","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"The sum of the first 5 positive integers is 15."}],"role":"assistant"}],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"reasoning":{"effort":"low","summary":"detailed"},"safety_identifier":null,"service_tier":"default","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":17,"input_tokens_details":{"cached_tokens":0},"output_tokens":122,"output_tokens_details":{"reasoning_tokens":64},"total_tokens":139},"user":null,"metadata":{}}} + + + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "o4-mini"); + + List updates = []; + await foreach (var update in client.GetStreamingResponseAsync("Calculate the sum of the first 5 positive integers.", new() + { + RawRepresentationFactory = options => new ResponseCreationOptions + { + ReasoningOptions = new() + { + ReasoningEffortLevel = ResponseReasoningEffortLevel.Low, + ReasoningSummaryVerbosity = ResponseReasoningSummaryVerbosity.Detailed + } + } + })) + { + updates.Add(update); + } + + Assert.Equal("The sum of the first 5 positive integers is 15.", string.Concat(updates.Select(u => u.Text))); + + var createdAt = DateTimeOffset.FromUnixTimeSeconds(1_756_752_811); + Assert.Equal(30, updates.Count); + + for (int i = 0; i < updates.Count; i++) + { + Assert.Equal("resp_68b5ebab461881969ed94149372c2a530698ecbf1b9f2704", updates[i].ResponseId); + Assert.Equal("resp_68b5ebab461881969ed94149372c2a530698ecbf1b9f2704", updates[i].ConversationId); + Assert.Equal(createdAt, updates[i].CreatedAt); + Assert.Equal("o4-mini-2025-04-16", updates[i].ModelId); + Assert.Null(updates[i].AdditionalProperties); + + if (i is (>= 4 and <= 8)) + { + // Reasoning updates + Assert.Single(updates[i].Contents); + Assert.Null(updates[i].Role); + + var reasoning = Assert.IsType(updates[i].Contents.Single()); + Assert.NotNull(reasoning); + Assert.NotNull(reasoning.Text); + } + else if (i is (>= 14 and <= 25) or 29) + { + // Response Complete and Assistant message updates + Assert.Single(updates[i].Contents); + } + else + { + // Other updates + Assert.Empty(updates[i].Contents); + } + + Assert.Equal(i < updates.Count - 1 ? null : ChatFinishReason.Stop, updates[i].FinishReason); + } + + UsageContent usage = updates.SelectMany(u => u.Contents).OfType().Single(); + Assert.Equal(17, usage.Details.InputTokenCount); + Assert.Equal(122, usage.Details.OutputTokenCount); + Assert.Equal(139, usage.Details.TotalTokenCount); + } + [Fact] public async Task BasicRequestResponse_Streaming() { @@ -363,20 +546,70 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio { const string Input = """ { - "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}], - "model":"gpt-4o-mini", - "max_output_tokens":10, - "previous_response_id":"resp_42", - "top_p":0.5, - "temperature":0.5, - "parallel_tool_calls":true, - "text": {"format": {"type": "text"} - }, - "tool_choice":"auto", - "tools":[ - {"description":"Gets the age of the specified person.","name":"GetPersonAge","parameters":{"additionalProperties":false,"properties":{"personName":{"description":"The person whose age is being requested","type":"string"}},"required":["personName"],"type":"object"},"strict":false,"type":"function"}, - {"description":"Gets the age of the specified person.","name":"GetPersonAge","parameters":{"additionalProperties":false,"properties":{"personName":{"description":"The person whose age is being requested","type":"string"}},"required":["personName"],"type":"object"},"strict":false,"type":"function"} - ] + "temperature": 0.5, + "top_p": 0.5, + "previous_response_id": "resp_42", + "model": "gpt-4o-mini", + "max_output_tokens": 10, + "text": { + "format": { + "type": "text" + } + }, + "tools": [ + { + "type": "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" + } + }, + "additionalProperties": false + }, + "strict": null + }, + { + "type": "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" + } + }, + "additionalProperties": false + }, + "strict": null + } + ], + "tool_choice": "auto", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "hello" + } + ] + } + ], + "parallel_tool_calls": true } """; @@ -460,7 +693,7 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio TextFormat = ResponseTextFormat.CreateTextFormat() }, }; - openAIOptions.Tools.Add(ToOpenAIResponseChatTool(tool)); + openAIOptions.Tools.Add(tool.AsOpenAIResponseTool()); return openAIOptions; }, ModelId = null, @@ -479,18 +712,4385 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio Assert.Equal("Hello! How can I assist you today?", response.Text); } - /// Converts an Extensions function to an OpenAI response chat tool. - private static ResponseTool ToOpenAIResponseChatTool(AIFunction aiFunction) + [Fact] + public async Task MultipleOutputItems_NonStreaming() { - var tool = JsonSerializer.Deserialize(aiFunction.JsonSchema)!; - var functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool)); - return ResponseTool.CreateFunctionTool(aiFunction.Name, aiFunction.Description, functionParameters); + const string Input = """ + { + "temperature":0.5, + "model":"gpt-4o-mini", + "input": [{ + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"hello"}] + }], + "max_output_tokens":20 + } + """; + + const string Output = """ + { + "id": "resp_67d327649b288191aeb46a824e49dc40058a5e08c46a181d", + "object": "response", + "created_at": 1741891428, + "status": "completed", + "error": null, + "incomplete_details": null, + "instructions": null, + "max_output_tokens": 20, + "model": "gpt-4o-mini-2024-07-18", + "output": [ + { + "type": "message", + "id": "msg_67d32764fcdc8191bcf2e444d4088804058a5e08c46a181d", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "Hello!", + "annotations": [] + } + ] + }, + { + "type": "message", + "id": "msg_67d32764fcdc8191bcf2e444d4088804058a5e08c46a182e", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": " How can I assist you today?", + "annotations": [] + } + ] + } + ], + "parallel_tool_calls": true, + "previous_response_id": null, + "reasoning": { + "effort": null, + "generate_summary": null + }, + "store": true, + "temperature": 0.5, + "text": { + "format": { + "type": "text" + } + }, + "tool_choice": "auto", + "tools": [], + "top_p": 1.0, + "usage": { + "input_tokens": 26, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 10, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 36 + }, + "user": null, + "metadata": {} + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("hello", new() + { + MaxOutputTokens = 20, + Temperature = 0.5f, + }); + Assert.NotNull(response); + + Assert.Equal("resp_67d327649b288191aeb46a824e49dc40058a5e08c46a181d", response.ResponseId); + Assert.Equal("resp_67d327649b288191aeb46a824e49dc40058a5e08c46a181d", response.ConversationId); + Assert.Equal("gpt-4o-mini-2024-07-18", response.ModelId); + Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(1_741_891_428), response.CreatedAt); + Assert.Null(response.FinishReason); + + Assert.Equal(2, response.Messages.Count); + Assert.Equal(ChatRole.Assistant, response.Messages[0].Role); + Assert.Equal("Hello!", response.Messages[0].Text); + Assert.Equal(ChatRole.Assistant, response.Messages[1].Role); + Assert.Equal(" How can I assist you today?", response.Messages[1].Text); + + Assert.NotNull(response.Usage); + Assert.Equal(26, response.Usage.InputTokenCount); + Assert.Equal(10, response.Usage.OutputTokenCount); + Assert.Equal(36, response.Usage.TotalTokenCount); } - private static IChatClient CreateResponseClient(HttpClient httpClient, string modelId) => - new OpenAIClient( - new ApiKeyCredential("apikey"), - new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(httpClient) }) - .GetOpenAIResponseClient(modelId) - .AsIChatClient(); + [Theory] + [InlineData("user")] + [InlineData("tool")] + public async Task McpToolCall_ApprovalRequired_NonStreaming(string role) + { + string input = """ + { + "model": "gpt-4o-mini", + "tools": [ + { + "type": "mcp", + "server_label": "deepwiki", + "server_url": "https://mcp.deepwiki.com/mcp" + } + ], + "tool_choice": "auto", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "Tell me the path to the README.md file for Microsoft.Extensions.AI.Abstractions in the dotnet/extensions repository" + } + ] + } + ] + } + """; + + string output = """ + { + "id": "resp_04e29d5bdd80bd9f0068e6b01f786081a29148febb92892aee", + "object": "response", + "created_at": 1759948831, + "status": "completed", + "background": false, + "error": null, + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "max_tool_calls": null, + "model": "gpt-4o-mini-2024-07-18", + "output": [ + { + "id": "mcpr_04e29d5bdd80bd9f0068e6b022a9c081a2ae898104b7a75051", + "type": "mcp_approval_request", + "arguments": "{\"repoName\":\"dotnet/extensions\"}", + "name": "ask_question", + "server_label": "deepwiki" + } + ], + "parallel_tool_calls": true, + "previous_response_id": null, + "prompt_cache_key": null, + "reasoning": { + "effort": null, + "summary": null + }, + "safety_identifier": null, + "service_tier": "default", + "store": true, + "temperature": 1.0, + "text": { + "format": { + "type": "text" + }, + "verbosity": "medium" + }, + "tool_choice": "auto", + "tools": [ + { + "type": "mcp", + "allowed_tools": null, + "headers": null, + "require_approval": "always", + "server_description": null, + "server_label": "deepwiki", + "server_url": "https://mcp.deepwiki.com/" + } + ], + "top_logprobs": 0, + "top_p": 1.0, + "truncation": "disabled", + "usage": { + "input_tokens": 193, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 23, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 216 + }, + "user": null, + "metadata": {} + } + """; + + var chatOptions = new ChatOptions + { + Tools = [new HostedMcpServerTool("deepwiki", new Uri("https://mcp.deepwiki.com/mcp"))] + }; + McpServerToolApprovalRequestContent approvalRequest; + + using (VerbatimHttpHandler handler = new(input, output)) + using (HttpClient httpClient = new(handler)) + using (IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini")) + { + var response = await client.GetResponseAsync( + "Tell me the path to the README.md file for Microsoft.Extensions.AI.Abstractions in the dotnet/extensions repository", + chatOptions); + + approvalRequest = Assert.Single(response.Messages.SelectMany(m => m.Contents).OfType()); + chatOptions.ConversationId = response.ConversationId; + } + + input = $$""" + { + "previous_response_id": "resp_04e29d5bdd80bd9f0068e6b01f786081a29148febb92892aee", + "model": "gpt-4o-mini", + "tools": [ + { + "type": "mcp", + "server_label": "deepwiki", + "server_url": "https://mcp.deepwiki.com/mcp" + } + ], + "tool_choice": "auto", + "input": [ + { + "type": "mcp_approval_response", + "approval_request_id": "mcpr_04e29d5bdd80bd9f0068e6b022a9c081a2ae898104b7a75051", + "approve": true + } + ] + } + """; + + output = """ + { + "id": "resp_06ee3b1962eeb8470068e6b21c377081a3a20dbf60eee7a736", + "object": "response", + "created_at": 1759949340, + "status": "completed", + "background": false, + "error": null, + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "max_tool_calls": null, + "model": "gpt-4o-mini-2024-07-18", + "output": [ + { + "id": "mcp_06ee3b1962eeb8470068e6b21cbaa081a3b5aa2a6c989f4c6f", + "type": "mcp_call", + "status": "completed", + "approval_request_id": "mcpr_06ee3b1962eeb8470068e6b192985c81a383a16059ecd8230e", + "arguments": "{\"repoName\":\"dotnet/extensions\",\"question\":\"What is the path to the README.md file for Microsoft.Extensions.AI.Abstractions?\"}", + "error": null, + "name": "ask_question", + "output": "The `README.md` file for `Microsoft.Extensions.AI.Abstractions` is located at `src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md` within the `dotnet/extensions` repository. This file provides an overview of the package, including installation instructions and usage examples for its core interfaces like `IChatClient` and `IEmbeddingGenerator`. \n\n## Path to README.md\n\nThe specific path to the `README.md` file for the `Microsoft.Extensions.AI.Abstractions` project is `src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md`. This path is also referenced in the `AI Extensions Framework` wiki page as a relevant source file. \n\n## Notes\n\nThe `Packaging.targets` file in the `eng/MSBuild` directory indicates that `README.md` files are included in packages when `IsPackable` and `IsShipping` properties are true. This suggests that the `README.md` file located at `src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md` is intended to be part of the distributed NuGet package for `Microsoft.Extensions.AI.Abstractions`. \n\nWiki pages you might want to explore:\n- [AI Extensions Framework (dotnet/extensions)](/wiki/dotnet/extensions#3)\n- [Chat Completion (dotnet/extensions)](/wiki/dotnet/extensions#3.3)\n\nView this search on DeepWiki: https://deepwiki.com/search/what-is-the-path-to-the-readme_315595bd-9b39-4f04-9fa3-42dc778fa9f3\n", + "server_label": "deepwiki" + }, + { + "id": "msg_06ee3b1962eeb8470068e6b226ab0081a39fccce9aa47aedbc", + "type": "message", + "status": "completed", + "content": [ + { + "type": "output_text", + "annotations": [], + "logprobs": [], + "text": "The `README.md` file for `Microsoft.Extensions.AI.Abstractions` is located at:\n\n```\nsrc/Libraries/Microsoft.Extensions.AI.Abstractions/README.md\n```\n\nThis file provides an overview of the `Microsoft.Extensions.AI.Abstractions` package, including installation instructions and usage examples for its core interfaces like `IChatClient` and `IEmbeddingGenerator`." + } + ], + "role": "assistant" + } + ], + "parallel_tool_calls": true, + "previous_response_id": "resp_06ee3b1962eeb8470068e6b18e0db881a3bdfd255a60327cdc", + "prompt_cache_key": null, + "reasoning": { + "effort": null, + "summary": null + }, + "safety_identifier": null, + "service_tier": "default", + "store": true, + "temperature": 1.0, + "text": { + "format": { + "type": "text" + }, + "verbosity": "medium" + }, + "tool_choice": "auto", + "tools": [ + { + "type": "mcp", + "allowed_tools": null, + "headers": null, + "require_approval": "always", + "server_description": null, + "server_label": "deepwiki", + "server_url": "https://mcp.deepwiki.com/" + } + ], + "top_logprobs": 0, + "top_p": 1.0, + "truncation": "disabled", + "usage": { + "input_tokens": 542, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 72, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 614 + }, + "user": null, + "metadata": {} + } + """; + + using (VerbatimHttpHandler handler = new(input, output)) + using (HttpClient httpClient = new(handler)) + using (IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini")) + { + var response = await client.GetResponseAsync( + new ChatMessage(new ChatRole(role), [approvalRequest.CreateResponse(true)]), chatOptions); + + Assert.NotNull(response); + + Assert.Equal("resp_06ee3b1962eeb8470068e6b21c377081a3a20dbf60eee7a736", response.ResponseId); + Assert.Equal("resp_06ee3b1962eeb8470068e6b21c377081a3a20dbf60eee7a736", response.ConversationId); + Assert.Equal("gpt-4o-mini-2024-07-18", response.ModelId); + Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(1_759_949_340), response.CreatedAt); + Assert.Null(response.FinishReason); + + var message = Assert.Single(response.Messages); + Assert.Equal(ChatRole.Assistant, response.Messages[0].Role); + Assert.Equal("The `README.md` file for `Microsoft.Extensions.AI.Abstractions` is located at:\n\n```\nsrc/Libraries/Microsoft.Extensions.AI.Abstractions/README.md\n```\n\nThis file provides an overview of the `Microsoft.Extensions.AI.Abstractions` package, including installation instructions and usage examples for its core interfaces like `IChatClient` and `IEmbeddingGenerator`.", response.Messages[0].Text); + + Assert.Equal(3, message.Contents.Count); + + var call = Assert.IsType(message.Contents[0]); + Assert.Equal("mcp_06ee3b1962eeb8470068e6b21cbaa081a3b5aa2a6c989f4c6f", call.CallId); + Assert.Equal("deepwiki", call.ServerName); + Assert.Equal("ask_question", call.ToolName); + Assert.NotNull(call.Arguments); + Assert.Equal(2, call.Arguments.Count); + Assert.Equal("dotnet/extensions", ((JsonElement)call.Arguments["repoName"]!).GetString()); + Assert.Equal("What is the path to the README.md file for Microsoft.Extensions.AI.Abstractions?", ((JsonElement)call.Arguments["question"]!).GetString()); + + var result = Assert.IsType(message.Contents[1]); + Assert.Equal("mcp_06ee3b1962eeb8470068e6b21cbaa081a3b5aa2a6c989f4c6f", result.CallId); + Assert.NotNull(result.Output); + Assert.StartsWith("The `README.md` file for `Microsoft.Extensions.AI.Abstractions` is located at", Assert.IsType(Assert.Single(result.Output)).Text); + + Assert.NotNull(response.Usage); + Assert.Equal(542, response.Usage.InputTokenCount); + Assert.Equal(72, response.Usage.OutputTokenCount); + Assert.Equal(614, response.Usage.TotalTokenCount); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task McpToolCall_ApprovalNotRequired_NonStreaming(bool rawTool) + { + const string Input = """ + { + "model": "gpt-4o-mini", + "tools": [ + { + "type": "mcp", + "server_label": "deepwiki", + "server_url": "https://mcp.deepwiki.com/mcp", + "require_approval": "never" + } + ], + "tool_choice": "auto", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "Tell me the path to the README.md file for Microsoft.Extensions.AI.Abstractions in the dotnet/extensions repository" + } + ] + } + ] + } + """; + + const string Output = """ + { + "id": "resp_68be416397ec81918c48ef286530b8140384f747588fc3f5", + "object": "response", + "created_at": 1757299043, + "status": "completed", + "background": false, + "error": null, + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "max_tool_calls": null, + "model": "gpt-4o-mini-2024-07-18", + "output": [ + { + "id": "mcpl_68be4163aa80819185e792abdcde71670384f747588fc3f5", + "type": "mcp_list_tools", + "server_label": "deepwiki", + "tools": [ + { + "annotations": { + "read_only": false + }, + "description": "Get a list of documentation topics for a GitHub repository", + "input_schema": { + "type": "object", + "properties": { + "repoName": { + "type": "string", + "description": "GitHub repository: owner/repo (e.g. \"facebook/react\")" + } + }, + "required": [ + "repoName" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "name": "read_wiki_structure" + }, + { + "annotations": { + "read_only": false + }, + "description": "View documentation about a GitHub repository", + "input_schema": { + "type": "object", + "properties": { + "repoName": { + "type": "string", + "description": "GitHub repository: owner/repo (e.g. \"facebook/react\")" + } + }, + "required": [ + "repoName" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "name": "read_wiki_contents" + }, + { + "annotations": { + "read_only": false + }, + "description": "Ask any question about a GitHub repository", + "input_schema": { + "type": "object", + "properties": { + "repoName": { + "type": "string", + "description": "GitHub repository: owner/repo (e.g. \"facebook/react\")" + }, + "question": { + "type": "string", + "description": "The question to ask about the repository" + } + }, + "required": [ + "repoName", + "question" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "name": "ask_question" + } + ] + }, + { + "id": "mcp_68be4166acfc8191bc5e0a751eed358b0384f747588fc3f5", + "type": "mcp_call", + "approval_request_id": null, + "arguments": "{\"repoName\":\"dotnet/extensions\"}", + "error": null, + "name": "read_wiki_structure", + "output": "Available pages for dotnet/extensions:\n\n- 1 Overview\n- 2 Build System and CI/CD\n- 3 AI Extensions Framework\n - 3.1 Core Abstractions\n - 3.2 AI Function System\n - 3.3 Chat Completion\n - 3.4 Caching System\n - 3.5 Evaluation and Reporting\n- 4 HTTP Resilience and Diagnostics\n - 4.1 Standard Resilience\n - 4.2 Hedging Strategies\n- 5 Telemetry and Compliance\n- 6 Testing Infrastructure\n - 6.1 AI Service Integration Testing\n - 6.2 Time Provider Testing", + "server_label": "deepwiki" + }, + { + "id": "mcp_68be416900f88191837ae0718339a4ce0384f747588fc3f5", + "type": "mcp_call", + "approval_request_id": null, + "arguments": "{\"repoName\":\"dotnet/extensions\",\"question\":\"What is the path to the README.md file for Microsoft.Extensions.AI.Abstractions?\"}", + "error": null, + "name": "ask_question", + "output": "The `README.md` file for `Microsoft.Extensions.AI.Abstractions` is located at `src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md` within the `dotnet/extensions` repository. This file provides an overview of the `Microsoft.Extensions.AI.Abstractions` package, including installation instructions and usage examples for its core interfaces like `IChatClient` and `IEmbeddingGenerator`. \n\n## Path to README.md\n\nThe specific path to the `README.md` file for the `Microsoft.Extensions.AI.Abstractions` project is `src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md`. This path is also referenced in the `AI Extensions Framework` wiki page as a relevant source file. \n\n## Notes\n\nThe `Packaging.targets` file in the `eng/MSBuild` directory indicates that `README.md` files are included in packages when `IsPackable` and `IsShipping` properties are true. This suggests that the `README.md` file located at `src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md` is intended to be part of the distributed NuGet package for `Microsoft.Extensions.AI.Abstractions`. \n\nWiki pages you might want to explore:\n- [AI Extensions Framework (dotnet/extensions)](/wiki/dotnet/extensions#3)\n- [Chat Completion (dotnet/extensions)](/wiki/dotnet/extensions#3.3)\n\nView this search on DeepWiki: https://deepwiki.com/search/what-is-the-path-to-the-readme_315595bd-9b39-4f04-9fa3-42dc778fa9f3\n", + "server_label": "deepwiki" + }, + { + "id": "msg_68be416fb43c819194a1d4ace2643a7e0384f747588fc3f5", + "type": "message", + "status": "completed", + "content": [ + { + "type": "output_text", + "annotations": [], + "logprobs": [], + "text": "The `README.md` file for `Microsoft.Extensions.AI.Abstractions` is located at:\n\n```\nsrc/Libraries/Microsoft.Extensions.AI.Abstractions/README.md\n```\n\nThis file includes an overview, installation instructions, and usage examples related to the package." + } + ], + "role": "assistant" + } + ], + "parallel_tool_calls": true, + "previous_response_id": null, + "prompt_cache_key": null, + "reasoning": { + "effort": null, + "summary": null + }, + "safety_identifier": null, + "service_tier": "default", + "store": true, + "temperature": 1, + "text": { + "format": { + "type": "text" + }, + "verbosity": "medium" + }, + "tool_choice": "auto", + "tools": [ + { + "type": "mcp", + "allowed_tools": null, + "headers": null, + "require_approval": "never", + "server_description": null, + "server_label": "deepwiki", + "server_url": "https://mcp.deepwiki.com/" + } + ], + "top_logprobs": 0, + "top_p": 1, + "truncation": "disabled", + "usage": { + "input_tokens": 1329, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 123, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 1452 + }, + "user": null, + "metadata": {} + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + AITool mcpTool = rawTool ? + ResponseTool.CreateMcpTool("deepwiki", serverUri: new("https://mcp.deepwiki.com/mcp"), toolCallApprovalPolicy: new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval)).AsAITool() : + new HostedMcpServerTool("deepwiki", new Uri("https://mcp.deepwiki.com/mcp")) + { + ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire, + }; + + ChatOptions chatOptions = new() + { + Tools = [mcpTool], + }; + + var response = await client.GetResponseAsync("Tell me the path to the README.md file for Microsoft.Extensions.AI.Abstractions in the dotnet/extensions repository", chatOptions); + Assert.NotNull(response); + + Assert.Equal("resp_68be416397ec81918c48ef286530b8140384f747588fc3f5", response.ResponseId); + Assert.Equal("resp_68be416397ec81918c48ef286530b8140384f747588fc3f5", response.ConversationId); + Assert.Equal("gpt-4o-mini-2024-07-18", response.ModelId); + Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(1_757_299_043), response.CreatedAt); + Assert.Null(response.FinishReason); + + var message = Assert.Single(response.Messages); + Assert.Equal(ChatRole.Assistant, response.Messages[0].Role); + Assert.Equal("The `README.md` file for `Microsoft.Extensions.AI.Abstractions` is located at:\n\n```\nsrc/Libraries/Microsoft.Extensions.AI.Abstractions/README.md\n```\n\nThis file includes an overview, installation instructions, and usage examples related to the package.", response.Messages[0].Text); + + Assert.Equal(6, message.Contents.Count); + + var firstCall = Assert.IsType(message.Contents[1]); + Assert.Equal("mcp_68be4166acfc8191bc5e0a751eed358b0384f747588fc3f5", firstCall.CallId); + Assert.Equal("deepwiki", firstCall.ServerName); + Assert.Equal("read_wiki_structure", firstCall.ToolName); + Assert.NotNull(firstCall.Arguments); + Assert.Single(firstCall.Arguments); + Assert.Equal("dotnet/extensions", ((JsonElement)firstCall.Arguments["repoName"]!).GetString()); + + var firstResult = Assert.IsType(message.Contents[2]); + Assert.Equal("mcp_68be4166acfc8191bc5e0a751eed358b0384f747588fc3f5", firstResult.CallId); + Assert.NotNull(firstResult.Output); + Assert.StartsWith("Available pages for dotnet/extensions", Assert.IsType(Assert.Single(firstResult.Output)).Text); + + var secondCall = Assert.IsType(message.Contents[3]); + Assert.Equal("mcp_68be416900f88191837ae0718339a4ce0384f747588fc3f5", secondCall.CallId); + Assert.Equal("deepwiki", secondCall.ServerName); + Assert.Equal("ask_question", secondCall.ToolName); + Assert.NotNull(secondCall.Arguments); + Assert.Equal("dotnet/extensions", ((JsonElement)secondCall.Arguments["repoName"]!).GetString()); + Assert.Equal("What is the path to the README.md file for Microsoft.Extensions.AI.Abstractions?", ((JsonElement)secondCall.Arguments["question"]!).GetString()); + + var secondResult = Assert.IsType(message.Contents[4]); + Assert.Equal("mcp_68be416900f88191837ae0718339a4ce0384f747588fc3f5", secondResult.CallId); + Assert.NotNull(secondResult.Output); + Assert.StartsWith("The `README.md` file for `Microsoft.Extensions.AI.Abstractions` is located at", Assert.IsType(Assert.Single(secondResult.Output)).Text); + + Assert.NotNull(response.Usage); + Assert.Equal(1329, response.Usage.InputTokenCount); + Assert.Equal(123, response.Usage.OutputTokenCount); + Assert.Equal(1452, response.Usage.TotalTokenCount); + } + + [Fact] + public async Task McpToolCall_ApprovalNotRequired_Streaming() + { + const string Input = """ + { + "model": "gpt-4o-mini", + "tools": [ + { + "type": "mcp", + "server_label": "deepwiki", + "server_url": "https://mcp.deepwiki.com/mcp", + "require_approval": "never" + } + ], + "tool_choice": "auto", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "Tell me the path to the README.md file for Microsoft.Extensions.AI.Abstractions in the dotnet/extensions repository" + } + ] + } + ], + "stream": true + } + """; + + const string Output = """ + event: response.created + data: {"type":"response.created","sequence_number":0,"response":{"id":"resp_68be44fd7298819e82fd82c8516e970d03a2537be0e84a54","object":"response","created_at":1757299965,"status":"in_progress","background":false,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4o-mini-2024-07-18","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"mcp","allowed_tools":null,"headers":null,"require_approval":"never","server_description":null,"server_label":"deepwiki","server_url":"https://mcp.deepwiki.com/"}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} + + event: response.in_progress + data: {"type":"response.in_progress","sequence_number":1,"response":{"id":"resp_68be44fd7298819e82fd82c8516e970d03a2537be0e84a54","object":"response","created_at":1757299965,"status":"in_progress","background":false,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4o-mini-2024-07-18","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"mcp","allowed_tools":null,"headers":null,"require_approval":"never","server_description":null,"server_label":"deepwiki","server_url":"https://mcp.deepwiki.com/"}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} + + event: response.output_item.added + data: {"type":"response.output_item.added","sequence_number":2,"output_index":0,"item":{"id":"mcpl_68be44fd8f68819eba7a74a2f6d27a5a03a2537be0e84a54","type":"mcp_list_tools","server_label":"deepwiki","tools":[]}} + + event: response.mcp_list_tools.in_progress + data: {"type":"response.mcp_list_tools.in_progress","sequence_number":3,"output_index":0,"item_id":"mcpl_68be44fd8f68819eba7a74a2f6d27a5a03a2537be0e84a54"} + + event: response.mcp_list_tools.completed + data: {"type":"response.mcp_list_tools.completed","sequence_number":4,"output_index":0,"item_id":"mcpl_68be44fd8f68819eba7a74a2f6d27a5a03a2537be0e84a54"} + + event: response.output_item.done + data: {"type":"response.output_item.done","sequence_number":5,"output_index":0,"item":{"id":"mcpl_68be44fd8f68819eba7a74a2f6d27a5a03a2537be0e84a54","type":"mcp_list_tools","server_label":"deepwiki","tools":[{"annotations":{"read_only":false},"description":"Get a list of documentation topics for a GitHub repository","input_schema":{"type":"object","properties":{"repoName":{"type":"string","description":"GitHub repository: owner/repo (e.g. \"facebook/react\")"}},"required":["repoName"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"},"name":"read_wiki_structure"},{"annotations":{"read_only":false},"description":"View documentation about a GitHub repository","input_schema":{"type":"object","properties":{"repoName":{"type":"string","description":"GitHub repository: owner/repo (e.g. \"facebook/react\")"}},"required":["repoName"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"},"name":"read_wiki_contents"},{"annotations":{"read_only":false},"description":"Ask any question about a GitHub repository","input_schema":{"type":"object","properties":{"repoName":{"type":"string","description":"GitHub repository: owner/repo (e.g. \"facebook/react\")"},"question":{"type":"string","description":"The question to ask about the repository"}},"required":["repoName","question"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"},"name":"ask_question"}]}} + + event: response.output_item.added + data: {"type":"response.output_item.added","sequence_number":6,"output_index":1,"item":{"id":"mcp_68be4503d45c819e89cb574361c8eba003a2537be0e84a54","type":"mcp_call","approval_request_id":null,"arguments":"","error":null,"name":"read_wiki_structure","output":null,"server_label":"deepwiki"}} + + event: response.mcp_call.in_progress + data: {"type":"response.mcp_call.in_progress","sequence_number":7,"output_index":1,"item_id":"mcp_68be4503d45c819e89cb574361c8eba003a2537be0e84a54"} + + event: response.mcp_call_arguments.delta + data: {"type":"response.mcp_call_arguments.delta","sequence_number":8,"output_index":1,"item_id":"mcp_68be4503d45c819e89cb574361c8eba003a2537be0e84a54","delta":"{\"repoName\":\"dotnet/extensions\"}","obfuscation":""} + + event: response.mcp_call_arguments.done + data: {"type":"response.mcp_call_arguments.done","sequence_number":9,"output_index":1,"item_id":"mcp_68be4503d45c819e89cb574361c8eba003a2537be0e84a54","arguments":"{\"repoName\":\"dotnet/extensions\"}"} + + event: response.mcp_call.completed + data: {"type":"response.mcp_call.completed","sequence_number":10,"output_index":1,"item_id":"mcp_68be4503d45c819e89cb574361c8eba003a2537be0e84a54"} + + event: response.output_item.done + data: {"type":"response.output_item.done","sequence_number":11,"output_index":1,"item":{"id":"mcp_68be4503d45c819e89cb574361c8eba003a2537be0e84a54","type":"mcp_call","approval_request_id":null,"arguments":"{\"repoName\":\"dotnet/extensions\"}","error":null,"name":"read_wiki_structure","output":"Available pages for dotnet/extensions:\n\n- 1 Overview\n- 2 Build System and CI/CD\n- 3 AI Extensions Framework\n - 3.1 Core Abstractions\n - 3.2 AI Function System\n - 3.3 Chat Completion\n - 3.4 Caching System\n - 3.5 Evaluation and Reporting\n- 4 HTTP Resilience and Diagnostics\n - 4.1 Standard Resilience\n - 4.2 Hedging Strategies\n- 5 Telemetry and Compliance\n- 6 Testing Infrastructure\n - 6.1 AI Service Integration Testing\n - 6.2 Time Provider Testing","server_label":"deepwiki"}} + + event: response.output_item.added + data: {"type":"response.output_item.added","sequence_number":12,"output_index":2,"item":{"id":"mcp_68be4505f134819e806c002f27cce0c303a2537be0e84a54","type":"mcp_call","approval_request_id":null,"arguments":"","error":null,"name":"ask_question","output":null,"server_label":"deepwiki"}} + + event: response.mcp_call.in_progress + data: {"type":"response.mcp_call.in_progress","sequence_number":13,"output_index":2,"item_id":"mcp_68be4505f134819e806c002f27cce0c303a2537be0e84a54"} + + event: response.mcp_call_arguments.delta + data: {"type":"response.mcp_call_arguments.delta","sequence_number":14,"output_index":2,"item_id":"mcp_68be4505f134819e806c002f27cce0c303a2537be0e84a54","delta":"{\"repoName\":\"dotnet/extensions\",\"question\":\"What is the path to the README.md file for Microsoft.Extensions.AI.Abstractions?\"}","obfuscation":"IT"} + + event: response.mcp_call_arguments.done + data: {"type":"response.mcp_call_arguments.done","sequence_number":15,"output_index":2,"item_id":"mcp_68be4505f134819e806c002f27cce0c303a2537be0e84a54","arguments":"{\"repoName\":\"dotnet/extensions\",\"question\":\"What is the path to the README.md file for Microsoft.Extensions.AI.Abstractions?\"}"} + + event: response.mcp_call.completed + data: {"type":"response.mcp_call.completed","sequence_number":16,"output_index":2,"item_id":"mcp_68be4505f134819e806c002f27cce0c303a2537be0e84a54"} + + event: response.output_item.done + data: {"type":"response.output_item.done","sequence_number":17,"output_index":2,"item":{"id":"mcp_68be4505f134819e806c002f27cce0c303a2537be0e84a54","type":"mcp_call","approval_request_id":null,"arguments":"{\"repoName\":\"dotnet/extensions\",\"question\":\"What is the path to the README.md file for Microsoft.Extensions.AI.Abstractions?\"}","error":null,"name":"ask_question","output":"The path to the `README.md` file for `Microsoft.Extensions.AI.Abstractions` is `src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md` . This file provides an overview of the `Microsoft.Extensions.AI.Abstractions` library, including installation instructions and usage examples for its core components like `IChatClient` and `IEmbeddingGenerator` .\n\n## README.md Content Overview\nThe `README.md` file for `Microsoft.Extensions.AI.Abstractions` details the purpose of the library, which is to provide abstractions for generative AI components . It includes instructions on how to install the NuGet package `Microsoft.Extensions.AI.Abstractions` .\n\nThe document also provides usage examples for the `IChatClient` interface, which defines methods for interacting with AI services that offer \"chat\" capabilities . This includes examples for requesting both complete and streaming chat responses .\n\nFurthermore, the `README.md` explains the `IEmbeddingGenerator` interface, which is used for generating vector embeddings from input values . It demonstrates how to use `GenerateAsync` to create embeddings . The file also discusses how both `IChatClient` and `IEmbeddingGenerator` implementations can be layered to create pipelines of functionality, incorporating features like caching and telemetry .\n\nNotes:\nThe user's query specifically asked for the path to the `README.md` file for `Microsoft.Extensions.AI.Abstractions`. The provided codebase context, particularly the wiki page for \"AI Extensions Framework\", directly lists this file as a relevant source file . The content of the `README.md` file itself further confirms its relevance to the `Microsoft.Extensions.AI.Abstractions` library.\n\nWiki pages you might want to explore:\n- [AI Extensions Framework (dotnet/extensions)](/wiki/dotnet/extensions#3)\n- [Chat Completion (dotnet/extensions)](/wiki/dotnet/extensions#3.3)\n\nView this search on DeepWiki: https://deepwiki.com/search/what-is-the-path-to-the-readme_bb6bee43-3136-4b21-bc5d-02ca1611d857\n","server_label":"deepwiki"}} + + event: response.output_item.added + data: {"type":"response.output_item.added","sequence_number":18,"output_index":3,"item":{"id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","type":"message","status":"in_progress","content":[],"role":"assistant"}} + + event: response.content_part.added + data: {"type":"response.content_part.added","sequence_number":19,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":20,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"The","logprobs":[],"obfuscation":"a5sNdjeWpJXIK"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":21,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" path","logprobs":[],"obfuscation":"2oWbALsHrtv"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":22,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" to","logprobs":[],"obfuscation":"K8lRBCaiusvjP"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":23,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" the","logprobs":[],"obfuscation":"LP7Xp4jDWA5w"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":24,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" `","logprobs":[],"obfuscation":"2rUNEj0h3wLlee"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":25,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"README","logprobs":[],"obfuscation":"PSbOrCj8y6"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":26,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":".md","logprobs":[],"obfuscation":"Do0BCY4kJ6wQW"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":27,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"`","logprobs":[],"obfuscation":"3fTPkjHu1Oq83DT"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":28,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" file","logprobs":[],"obfuscation":"CI9PXx3sH06"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":29,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" for","logprobs":[],"obfuscation":"fJuaoSPsMge8"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":30,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" `","logprobs":[],"obfuscation":"O1h4Q0T72OM4e7"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":31,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"Microsoft","logprobs":[],"obfuscation":"E2YPgfE"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":32,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":".Extensions","logprobs":[],"obfuscation":"vfVX8"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":33,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":".A","logprobs":[],"obfuscation":"EwDmSMHqymBRl1"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":34,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"I","logprobs":[],"obfuscation":"QQfjze1z7QhvcJE"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":35,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":".A","logprobs":[],"obfuscation":"7fLbFXKbxOMkBi"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":36,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"bst","logprobs":[],"obfuscation":"3p1svK7Jd1N7C"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":37,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"ractions","logprobs":[],"obfuscation":"Cl2xCwTC"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":38,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"`","logprobs":[],"obfuscation":"ObDOKE72QOlXSx9"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":39,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" in","logprobs":[],"obfuscation":"FJwPbDYgh4XjL"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":40,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" the","logprobs":[],"obfuscation":"e8cV5qt7hEsz"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":41,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" `","logprobs":[],"obfuscation":"Hf8ZQDFLfImh3e"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":42,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"dot","logprobs":[],"obfuscation":"0lh2vLiYye2JI"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":43,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"net","logprobs":[],"obfuscation":"g5fzb2qtk4Piz"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":44,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"/extensions","logprobs":[],"obfuscation":"egpos"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":45,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"`","logprobs":[],"obfuscation":"gXw3bKveEVIKXux"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":46,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" repository","logprobs":[],"obfuscation":"rqhlC"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":47,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" is","logprobs":[],"obfuscation":"YZq9zsRja0g2M"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":48,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":":\n\n","logprobs":[],"obfuscation":"mhDAmaHJUvLGl"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":49,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"``","logprobs":[],"obfuscation":"3XmO5YTsWjzHHf"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":50,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"`\n","logprobs":[],"obfuscation":"4fmXZmdkPxNn8K"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":51,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"src","logprobs":[],"obfuscation":"ifGf4yLEg5pMZ"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":52,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"/L","logprobs":[],"obfuscation":"C1k1toBElpgxyW"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":53,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"ibraries","logprobs":[],"obfuscation":"fdOTYTyp"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":54,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"/M","logprobs":[],"obfuscation":"DyscJIQYaPJugC"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":55,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"icrosoft","logprobs":[],"obfuscation":"PQxU7muP"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":56,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":".Extensions","logprobs":[],"obfuscation":"RCJB8"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":57,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":".A","logprobs":[],"obfuscation":"i92CWxnAkwS4C9"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":58,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"I","logprobs":[],"obfuscation":"qfH8wVJN74vCfBM"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":59,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":".A","logprobs":[],"obfuscation":"LcuBP89lZVCCH9"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":60,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"bst","logprobs":[],"obfuscation":"I8rKDbKN0zylv"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":61,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"ractions","logprobs":[],"obfuscation":"tOgiCPs5"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":62,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"/","logprobs":[],"obfuscation":"jgJjLruTbFJGDhU"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":63,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"README","logprobs":[],"obfuscation":"D5VSEFNde7"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":64,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":".md","logprobs":[],"obfuscation":"7ZGJO5sZOTPBs"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":65,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"\n","logprobs":[],"obfuscation":"7Sv80haKTTwfEWj"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":66,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"``","logprobs":[],"obfuscation":"m1JSvZ8rrpJnH5"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":67,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"`\n\n","logprobs":[],"obfuscation":"U93PMKtCB5Pb5"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":68,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"This","logprobs":[],"obfuscation":"f5veTGedo9nM"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":69,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" file","logprobs":[],"obfuscation":"oEBwvP5FnPK"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":70,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" provides","logprobs":[],"obfuscation":"IVNCYwr"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":71,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" an","logprobs":[],"obfuscation":"3x6WquURIJ3ld"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":72,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" overview","logprobs":[],"obfuscation":"VR9yeiD"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":73,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" of","logprobs":[],"obfuscation":"z46dC1o2FC8Rs"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":74,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" the","logprobs":[],"obfuscation":"YfZGabvmgyoI"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":75,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" library","logprobs":[],"obfuscation":"TamElgEp"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":76,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":",","logprobs":[],"obfuscation":"VfVfqbnHAfsJyJn"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":77,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" installation","logprobs":[],"obfuscation":"CGR"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":78,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" instructions","logprobs":[],"obfuscation":"xst"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":79,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":",","logprobs":[],"obfuscation":"3u5wqRA2RXh2QP8"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":80,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" and","logprobs":[],"obfuscation":"tD4WZmOhepzQ"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":81,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" usage","logprobs":[],"obfuscation":"SadOK826mZ"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":82,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" examples","logprobs":[],"obfuscation":"5VpLKav"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":83,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" for","logprobs":[],"obfuscation":"xPvtjDSUic9E"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":84,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" its","logprobs":[],"obfuscation":"6duK61DX14vx"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":85,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" core","logprobs":[],"obfuscation":"Cz8trPLsCWu"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":86,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" components","logprobs":[],"obfuscation":"Gexuy"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":87,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":".","logprobs":[],"obfuscation":"HVeWkHoX1cc6hVh"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":88,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" If","logprobs":[],"obfuscation":"G1TOxxwvSEq4L"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":89,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" you","logprobs":[],"obfuscation":"xQlKeOixd1hv"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":90,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" have","logprobs":[],"obfuscation":"bX6P0qgFPnR"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":91,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" any","logprobs":[],"obfuscation":"KxH8EiMzXa1N"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":92,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" more","logprobs":[],"obfuscation":"kA0kxRPPqru"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":93,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" questions","logprobs":[],"obfuscation":"9HRCyD"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":94,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" about","logprobs":[],"obfuscation":"yYFZhtsSfc"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":95,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" it","logprobs":[],"obfuscation":"zpyEAwPWl8Ozh"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":96,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":",","logprobs":[],"obfuscation":"ivjn00lbmzDHiFU"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":97,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" feel","logprobs":[],"obfuscation":"O2edXDmkBqt"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":98,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" free","logprobs":[],"obfuscation":"MlpWh7p0P1F"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":99,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" to","logprobs":[],"obfuscation":"uMNfozGkKe6xW"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":100,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":" ask","logprobs":[],"obfuscation":"6rMOxwXhR8RY"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":101,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"delta":"!","logprobs":[],"obfuscation":"QPZMdhS0e5vYuRl"} + + event: response.output_text.done + data: {"type":"response.output_text.done","sequence_number":102,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"text":"The path to the `README.md` file for `Microsoft.Extensions.AI.Abstractions` in the `dotnet/extensions` repository is:\n\n```\nsrc/Libraries/Microsoft.Extensions.AI.Abstractions/README.md\n```\n\nThis file provides an overview of the library, installation instructions, and usage examples for its core components. If you have any more questions about it, feel free to ask!","logprobs":[]} + + event: response.content_part.done + data: {"type":"response.content_part.done","sequence_number":103,"item_id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","output_index":3,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":"The path to the `README.md` file for `Microsoft.Extensions.AI.Abstractions` in the `dotnet/extensions` repository is:\n\n```\nsrc/Libraries/Microsoft.Extensions.AI.Abstractions/README.md\n```\n\nThis file provides an overview of the library, installation instructions, and usage examples for its core components. If you have any more questions about it, feel free to ask!"}} + + event: response.output_item.done + data: {"type":"response.output_item.done","sequence_number":104,"output_index":3,"item":{"id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"The path to the `README.md` file for `Microsoft.Extensions.AI.Abstractions` in the `dotnet/extensions` repository is:\n\n```\nsrc/Libraries/Microsoft.Extensions.AI.Abstractions/README.md\n```\n\nThis file provides an overview of the library, installation instructions, and usage examples for its core components. If you have any more questions about it, feel free to ask!"}],"role":"assistant"}} + + event: response.completed + data: {"type":"response.completed","sequence_number":105,"response":{"id":"resp_68be44fd7298819e82fd82c8516e970d03a2537be0e84a54","object":"response","created_at":1757299965,"status":"completed","background":false,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4o-mini-2024-07-18","output":[{"id":"mcpl_68be44fd8f68819eba7a74a2f6d27a5a03a2537be0e84a54","type":"mcp_list_tools","server_label":"deepwiki","tools":[{"annotations":{"read_only":false},"description":"Get a list of documentation topics for a GitHub repository","input_schema":{"type":"object","properties":{"repoName":{"type":"string","description":"GitHub repository: owner/repo (e.g. \"facebook/react\")"}},"required":["repoName"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"},"name":"read_wiki_structure"},{"annotations":{"read_only":false},"description":"View documentation about a GitHub repository","input_schema":{"type":"object","properties":{"repoName":{"type":"string","description":"GitHub repository: owner/repo (e.g. \"facebook/react\")"}},"required":["repoName"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"},"name":"read_wiki_contents"},{"annotations":{"read_only":false},"description":"Ask any question about a GitHub repository","input_schema":{"type":"object","properties":{"repoName":{"type":"string","description":"GitHub repository: owner/repo (e.g. \"facebook/react\")"},"question":{"type":"string","description":"The question to ask about the repository"}},"required":["repoName","question"],"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"},"name":"ask_question"}]},{"id":"mcp_68be4503d45c819e89cb574361c8eba003a2537be0e84a54","type":"mcp_call","approval_request_id":null,"arguments":"{\"repoName\":\"dotnet/extensions\"}","error":null,"name":"read_wiki_structure","output":"Available pages for dotnet/extensions:\n\n- 1 Overview\n- 2 Build System and CI/CD\n- 3 AI Extensions Framework\n - 3.1 Core Abstractions\n - 3.2 AI Function System\n - 3.3 Chat Completion\n - 3.4 Caching System\n - 3.5 Evaluation and Reporting\n- 4 HTTP Resilience and Diagnostics\n - 4.1 Standard Resilience\n - 4.2 Hedging Strategies\n- 5 Telemetry and Compliance\n- 6 Testing Infrastructure\n - 6.1 AI Service Integration Testing\n - 6.2 Time Provider Testing","server_label":"deepwiki"},{"id":"mcp_68be4505f134819e806c002f27cce0c303a2537be0e84a54","type":"mcp_call","approval_request_id":null,"arguments":"{\"repoName\":\"dotnet/extensions\",\"question\":\"What is the path to the README.md file for Microsoft.Extensions.AI.Abstractions?\"}","error":null,"name":"ask_question","output":"The path to the `README.md` file for `Microsoft.Extensions.AI.Abstractions` is `src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md` . This file provides an overview of the `Microsoft.Extensions.AI.Abstractions` library, including installation instructions and usage examples for its core components like `IChatClient` and `IEmbeddingGenerator` .\n\n## README.md Content Overview\nThe `README.md` file for `Microsoft.Extensions.AI.Abstractions` details the purpose of the library, which is to provide abstractions for generative AI components . It includes instructions on how to install the NuGet package `Microsoft.Extensions.AI.Abstractions` .\n\nThe document also provides usage examples for the `IChatClient` interface, which defines methods for interacting with AI services that offer \"chat\" capabilities . This includes examples for requesting both complete and streaming chat responses .\n\nFurthermore, the `README.md` explains the `IEmbeddingGenerator` interface, which is used for generating vector embeddings from input values . It demonstrates how to use `GenerateAsync` to create embeddings . The file also discusses how both `IChatClient` and `IEmbeddingGenerator` implementations can be layered to create pipelines of functionality, incorporating features like caching and telemetry .\n\nNotes:\nThe user's query specifically asked for the path to the `README.md` file for `Microsoft.Extensions.AI.Abstractions`. The provided codebase context, particularly the wiki page for \"AI Extensions Framework\", directly lists this file as a relevant source file . The content of the `README.md` file itself further confirms its relevance to the `Microsoft.Extensions.AI.Abstractions` library.\n\nWiki pages you might want to explore:\n- [AI Extensions Framework (dotnet/extensions)](/wiki/dotnet/extensions#3)\n- [Chat Completion (dotnet/extensions)](/wiki/dotnet/extensions#3.3)\n\nView this search on DeepWiki: https://deepwiki.com/search/what-is-the-path-to-the-readme_bb6bee43-3136-4b21-bc5d-02ca1611d857\n","server_label":"deepwiki"},{"id":"msg_68be450c39e8819eb9bf6fcb9fd16ecb03a2537be0e84a54","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"The path to the `README.md` file for `Microsoft.Extensions.AI.Abstractions` in the `dotnet/extensions` repository is:\n\n```\nsrc/Libraries/Microsoft.Extensions.AI.Abstractions/README.md\n```\n\nThis file provides an overview of the library, installation instructions, and usage examples for its core components. If you have any more questions about it, feel free to ask!"}],"role":"assistant"}],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"default","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"mcp","allowed_tools":null,"headers":null,"require_approval":"never","server_description":null,"server_label":"deepwiki","server_url":"https://mcp.deepwiki.com/"}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":1420,"input_tokens_details":{"cached_tokens":0},"output_tokens":149,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":1569},"user":null,"metadata":{}}} + + + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + ChatOptions chatOptions = new() + { + Tools = [new HostedMcpServerTool("deepwiki", new Uri("https://mcp.deepwiki.com/mcp")) + { + ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire, + } + ], + }; + + var response = await client.GetStreamingResponseAsync("Tell me the path to the README.md file for Microsoft.Extensions.AI.Abstractions in the dotnet/extensions repository", chatOptions) + .ToChatResponseAsync(); + Assert.NotNull(response); + + Assert.Equal("resp_68be44fd7298819e82fd82c8516e970d03a2537be0e84a54", response.ResponseId); + Assert.Equal("resp_68be44fd7298819e82fd82c8516e970d03a2537be0e84a54", response.ConversationId); + Assert.Equal("gpt-4o-mini-2024-07-18", response.ModelId); + Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(1_757_299_965), response.CreatedAt); + Assert.Equal(ChatFinishReason.Stop, response.FinishReason); + + var message = Assert.Single(response.Messages); + Assert.Equal(ChatRole.Assistant, response.Messages[0].Role); + Assert.StartsWith("The path to the `README.md` file", response.Messages[0].Text); + + Assert.Equal(6, message.Contents.Count); + + var firstCall = Assert.IsType(message.Contents[1]); + Assert.Equal("mcp_68be4503d45c819e89cb574361c8eba003a2537be0e84a54", firstCall.CallId); + Assert.Equal("deepwiki", firstCall.ServerName); + Assert.Equal("read_wiki_structure", firstCall.ToolName); + Assert.NotNull(firstCall.Arguments); + Assert.Single(firstCall.Arguments); + Assert.Equal("dotnet/extensions", ((JsonElement)firstCall.Arguments["repoName"]!).GetString()); + + var firstResult = Assert.IsType(message.Contents[2]); + Assert.Equal("mcp_68be4503d45c819e89cb574361c8eba003a2537be0e84a54", firstResult.CallId); + Assert.NotNull(firstResult.Output); + Assert.StartsWith("Available pages for dotnet/extensions", Assert.IsType(Assert.Single(firstResult.Output)).Text); + + var secondCall = Assert.IsType(message.Contents[3]); + Assert.Equal("mcp_68be4505f134819e806c002f27cce0c303a2537be0e84a54", secondCall.CallId); + Assert.Equal("deepwiki", secondCall.ServerName); + Assert.Equal("ask_question", secondCall.ToolName); + Assert.NotNull(secondCall.Arguments); + Assert.Equal("dotnet/extensions", ((JsonElement)secondCall.Arguments["repoName"]!).GetString()); + Assert.Equal("What is the path to the README.md file for Microsoft.Extensions.AI.Abstractions?", ((JsonElement)secondCall.Arguments["question"]!).GetString()); + + var secondResult = Assert.IsType(message.Contents[4]); + Assert.Equal("mcp_68be4505f134819e806c002f27cce0c303a2537be0e84a54", secondResult.CallId); + Assert.NotNull(secondResult.Output); + Assert.StartsWith("The path to the `README.md` file", Assert.IsType(Assert.Single(secondResult.Output)).Text); + + Assert.NotNull(response.Usage); + Assert.Equal(1420, response.Usage.InputTokenCount); + Assert.Equal(149, response.Usage.OutputTokenCount); + Assert.Equal(1569, response.Usage.TotalTokenCount); + } + + [Fact] + public async Task GetResponseAsync_BackgroundResponses_FirstCall() + { + const string Input = """ + { + "temperature":0.5, + "model":"gpt-4o-mini", + "background":true, + "input": [{ + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"hello"}] + }], + "max_output_tokens":20 + } + """; + + const string Output = """ + { + "id": "resp_68d3d2c9ef7c8195863e4e2b2ec226a205007262ecbbfed7", + "object": "response", + "created_at": 1758712522, + "status": "queued", + "background": true, + "error": null, + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "model": "gpt-4o-mini-2024-07-18", + "output": [], + "parallel_tool_calls": true, + "previous_response_id": null, + "reasoning": { + "effort": null, + "generate_summary": null + }, + "store": true, + "temperature": 0.5, + "text": { + "format": { + "type": "text" + } + }, + "tool_choice": "auto", + "tools": [], + "top_p": 1.0, + "usage": null, + "user": null, + "metadata": {} + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("hello", new() + { + MaxOutputTokens = 20, + Temperature = 0.5f, + AllowBackgroundResponses = true, + }); + Assert.NotNull(response); + + Assert.Equal("resp_68d3d2c9ef7c8195863e4e2b2ec226a205007262ecbbfed7", response.ResponseId); + Assert.Equal("resp_68d3d2c9ef7c8195863e4e2b2ec226a205007262ecbbfed7", response.ConversationId); + Assert.Empty(response.Messages); + + Assert.NotNull(response.ContinuationToken); + var responsesContinuationToken = TestOpenAIResponsesContinuationToken.FromToken(response.ContinuationToken); + Assert.Equal("resp_68d3d2c9ef7c8195863e4e2b2ec226a205007262ecbbfed7", responsesContinuationToken.ResponseId); + Assert.Null(responsesContinuationToken.SequenceNumber); + } + + [Theory] + [InlineData(ResponseStatus.Queued)] + [InlineData(ResponseStatus.InProgress)] + [InlineData(ResponseStatus.Completed)] + [InlineData(ResponseStatus.Cancelled)] + [InlineData(ResponseStatus.Failed)] + [InlineData(ResponseStatus.Incomplete)] + public async Task GetResponseAsync_BackgroundResponses_PollingCall(ResponseStatus expectedStatus) + { + var expectedInput = new HttpHandlerExpectedInput + { + Uri = new Uri("https://api.openai.com/v1/responses/resp_68d3d2c9ef7c8195863e4e2b2ec226a205007262ecbbfed8"), + Method = HttpMethod.Get, + }; + + string output = $$"""" + { + "id": "resp_68d3d2c9ef7c8195863e4e2b2ec226a205007262ecbbfed8", + "object": "response", + "created_at": 1758712522, + "status": "{{ResponseStatusToRequestValue(expectedStatus)}}", + "background": true, + "error": null, + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "model": "gpt-4o-mini-2024-07-18", + "output": {{(expectedStatus is (ResponseStatus.Queued or ResponseStatus.InProgress) + ? "[]" + : """ + [{ + "type": "message", + "id": "msg_67d32764fcdc8191bcf2e444d4088804058a5e08c46a181d", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "The background response result.", + "annotations": [] + } + ] + }] + """ + )}}, + "parallel_tool_calls": true, + "previous_response_id": null, + "reasoning": { + "effort": null, + "generate_summary": null + }, + "store": true, + "temperature": 0.5, + "text": { + "format": { + "type": "text" + } + }, + "tool_choice": "auto", + "tools": [], + "top_p": 1.0, + "usage": null, + "user": null, + "metadata": {} + } + """"; + + using VerbatimHttpHandler handler = new(expectedInput, output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var continuationToken = new TestOpenAIResponsesContinuationToken("resp_68d3d2c9ef7c8195863e4e2b2ec226a205007262ecbbfed8"); + + var response = await client.GetResponseAsync([], new() + { + ContinuationToken = continuationToken, + AllowBackgroundResponses = true, + }); + Assert.NotNull(response); + + Assert.Equal("resp_68d3d2c9ef7c8195863e4e2b2ec226a205007262ecbbfed8", response.ResponseId); + Assert.Equal("resp_68d3d2c9ef7c8195863e4e2b2ec226a205007262ecbbfed8", response.ConversationId); + + switch (expectedStatus) + { + case ResponseStatus.Queued: + case ResponseStatus.InProgress: + { + Assert.NotNull(response.ContinuationToken); + + var responsesContinuationToken = TestOpenAIResponsesContinuationToken.FromToken(response.ContinuationToken); + Assert.Equal("resp_68d3d2c9ef7c8195863e4e2b2ec226a205007262ecbbfed8", responsesContinuationToken.ResponseId); + Assert.Null(responsesContinuationToken.SequenceNumber); + + Assert.Empty(response.Messages); + break; + } + + case ResponseStatus.Completed: + case ResponseStatus.Cancelled: + case ResponseStatus.Failed: + case ResponseStatus.Incomplete: + { + Assert.Null(response.ContinuationToken); + + Assert.Equal("The background response result.", response.Text); + Assert.Single(response.Messages.Single().Contents); + Assert.Equal(ChatRole.Assistant, response.Messages.Single().Role); + break; + } + + default: + throw new ArgumentOutOfRangeException(nameof(expectedStatus), expectedStatus, null); + } + } + + [Fact] + public async Task GetResponseAsync_BackgroundResponses_PollingCall_WithMessages() + { + using VerbatimHttpHandler handler = new(string.Empty, string.Empty); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var options = new ChatOptions + { + ContinuationToken = new TestOpenAIResponsesContinuationToken("resp_68d3d2c9ef7c8195863e4e2b2ec226a205007262ecbbfed8"), + AllowBackgroundResponses = true, + }; + + // A try to update a background response with new messages should fail. + await Assert.ThrowsAsync(async () => + { + await client.GetResponseAsync("Please book hotel as well", options); + }); + } + + [Fact] + public async Task GetStreamingResponseAsync_BackgroundResponses() + { + const string Input = """ + { + "model": "gpt-4o-2024-08-06", + "background": true, + "input":[{ + "type":"message", + "role":"user", + "content":[{ + "type":"input_text", + "text":"hello" + }] + }], + + "stream": true + } + """; + + const string Output = """ + event: response.created + data: {"type":"response.created","sequence_number":0,"response":{"id":"resp_68d401a7b36c81a288600e95a5a119d4073420ed59d5f559","object":"response","created_at":1758724519,"status":"queued","background":true,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4o-2024-08-06","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} + + event: response.queued + data: {"type":"response.queued","sequence_number":1,"response":{"id":"resp_68d401a7b36c81a288600e95a5a119d4073420ed59d5f559","object":"response","created_at":1758724519,"status":"queued","background":true,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4o-2024-08-06","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} + + event: response.in_progress + data: {"type":"response.in_progress","sequence_number":2,"response":{"truncation":"disabled","id":"resp_68d401a7b36c81a288600e95a5a119d4073420ed59d5f559","tool_choice":"auto","temperature":1.0,"top_p":1.0,"status":"in_progress","top_logprobs":0,"usage":null,"object":"response","created_at":1758724519,"prompt_cache_key":null,"text":{"format":{"type":"text"},"verbosity":"medium"},"incomplete_details":null,"model":"gpt-4o-2024-08-06","previous_response_id":null,"safety_identifier":null,"metadata":{},"store":true,"output":[],"parallel_tool_calls":true,"error":null,"background":true,"instructions":null,"service_tier":"auto","max_tool_calls":null,"max_output_tokens":null,"tools":[],"user":null,"reasoning":{"effort":null,"summary":null}}} + + event: response.output_item.added + data: {"type":"response.output_item.added","sequence_number":3,"item":{"id":"msg_68d401aa78d481a2ab30776a79c691a6073420ed59d5f559","content":[],"role":"assistant","status":"in_progress","type":"message"},"output_index":0} + + event: response.content_part.added + data: {"type":"response.content_part.added","sequence_number":4,"item_id":"msg_68d401aa78d481a2ab30776a79c691a6073420ed59d5f559","content_index":0,"part":{"text":"","logprobs":[],"type":"output_text","annotations":[]},"output_index":0} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":5,"delta":"Hello","logprobs":[],"item_id":"msg_68d401aa78d481a2ab30776a79c691a6073420ed59d5f559","content_index":0,"output_index":0} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":6,"delta":"!","logprobs":[],"item_id":"msg_68d401aa78d481a2ab30776a79c691a6073420ed59d5f559","content_index":0,"output_index":0} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":7,"delta":" How","logprobs":[],"item_id":"msg_68d401aa78d481a2ab30776a79c691a6073420ed59d5f559","content_index":0,"output_index":0} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":8,"delta":" can","logprobs":[],"item_id":"msg_68d401aa78d481a2ab30776a79c691a6073420ed59d5f559","content_index":0,"output_index":0} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":9,"delta":" I","logprobs":[],"item_id":"msg_68d401aa78d481a2ab30776a79c691a6073420ed59d5f559","content_index":0,"output_index":0} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":10,"delta":" assist","logprobs":[],"item_id":"msg_68d401aa78d481a2ab30776a79c691a6073420ed59d5f559","content_index":0,"output_index":0} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":11,"delta":" you","logprobs":[],"item_id":"msg_68d401aa78d481a2ab30776a79c691a6073420ed59d5f559","content_index":0,"output_index":0} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":12,"delta":" today","logprobs":[],"item_id":"msg_68d401aa78d481a2ab30776a79c691a6073420ed59d5f559","content_index":0,"output_index":0} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":13,"delta":"?","logprobs":[],"item_id":"msg_68d401aa78d481a2ab30776a79c691a6073420ed59d5f559","content_index":0,"output_index":0} + + event: response.output_text.done + data: {"type":"response.output_text.done","sequence_number":14,"text":"Hello! How can I assist you today?","logprobs":[],"item_id":"msg_68d401aa78d481a2ab30776a79c691a6073420ed59d5f559","content_index":0,"output_index":0} + + event: response.content_part.done + data: {"type":"response.content_part.done","sequence_number":15,"item_id":"msg_68d401aa78d481a2ab30776a79c691a6073420ed59d5f559","content_index":0,"part":{"text":"Hello! How can I assist you today?","logprobs":[],"type":"output_text","annotations":[]},"output_index":0} + + event: response.output_item.done + data: {"type":"response.output_item.done","sequence_number":16,"item":{"id":"msg_68d401aa78d481a2ab30776a79c691a6073420ed59d5f559","content":[{"text":"Hello! How can I assist you today?","logprobs":[],"type":"output_text","annotations":[]}],"role":"assistant","status":"completed","type":"message"},"output_index":0} + + event: response.completed + data: {"type":"response.completed","sequence_number":17,"response":{"truncation":"disabled","id":"resp_68d401a7b36c81a288600e95a5a119d4073420ed59d5f559","tool_choice":"auto","temperature":1.0,"top_p":1.0,"status":"completed","top_logprobs":0,"usage":{"total_tokens":18,"input_tokens_details":{"cached_tokens":0},"output_tokens_details":{"reasoning_tokens":0},"output_tokens":10,"input_tokens":8},"object":"response","created_at":1758724519,"prompt_cache_key":null,"text":{"format":{"type":"text"},"verbosity":"medium"},"incomplete_details":null,"model":"gpt-4o-2024-08-06","previous_response_id":null,"safety_identifier":null,"metadata":{},"store":true,"output":[{"id":"msg_68d401aa78d481a2ab30776a79c691a6073420ed59d5f559","content":[{"text":"Hello! How can I assist you today?","logprobs":[],"type":"output_text","annotations":[]}],"role":"assistant","status":"completed","type":"message"}],"parallel_tool_calls":true,"error":null,"background":true,"instructions":null,"service_tier":"default","max_tool_calls":null,"max_output_tokens":null,"tools":[],"user":null,"reasoning":{"effort":null,"summary":null}}} + + + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-2024-08-06"); + + List updates = []; + await foreach (var update in client.GetStreamingResponseAsync("hello", new() + { + AllowBackgroundResponses = true, + })) + { + updates.Add(update); + } + + Assert.Equal("Hello! How can I assist you today?", string.Concat(updates.Select(u => u.Text))); + Assert.Equal(18, updates.Count); + + var createdAt = DateTimeOffset.FromUnixTimeSeconds(1_758_724_519); + + for (int i = 0; i < updates.Count; i++) + { + Assert.Equal("resp_68d401a7b36c81a288600e95a5a119d4073420ed59d5f559", updates[i].ResponseId); + Assert.Equal("resp_68d401a7b36c81a288600e95a5a119d4073420ed59d5f559", updates[i].ConversationId); + Assert.Equal(createdAt, updates[i].CreatedAt); + Assert.Equal("gpt-4o-2024-08-06", updates[i].ModelId); + Assert.Null(updates[i].AdditionalProperties); + + if (i < updates.Count - 1) + { + Assert.NotNull(updates[i].ContinuationToken); + var responsesContinuationToken = TestOpenAIResponsesContinuationToken.FromToken(updates[i].ContinuationToken!); + Assert.Equal("resp_68d401a7b36c81a288600e95a5a119d4073420ed59d5f559", responsesContinuationToken.ResponseId); + Assert.Equal(i, responsesContinuationToken.SequenceNumber); + Assert.Null(updates[i].FinishReason); + } + else + { + Assert.Null(updates[i].ContinuationToken); + Assert.Equal(ChatFinishReason.Stop, updates[i].FinishReason); + } + + Assert.Equal((i >= 5 && i <= 13) || i == 17 ? 1 : 0, updates[i].Contents.Count); + } + } + + [Fact] + public async Task GetStreamingResponseAsync_BackgroundResponses_StreamResumption() + { + var expectedInput = new HttpHandlerExpectedInput + { + Uri = new Uri("https://api.openai.com/v1/responses/resp_68d40dc671a0819cb0ee920078333451029e611c3cc4a34b?stream=true&starting_after=9"), + Method = HttpMethod.Get, + }; + + const string Output = """ + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":10,"delta":" assist","logprobs":[],"item_id":"msg_68d40dcb2d34819c88f5d6a8ca7b0308029e611c3cc4a34b","content_index":0,"output_index":0} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":11,"delta":" you","logprobs":[],"item_id":"msg_68d40dcb2d34819c88f5d6a8ca7b0308029e611c3cc4a34b","content_index":0,"output_index":0} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":12,"delta":" today","logprobs":[],"item_id":"msg_68d40dcb2d34819c88f5d6a8ca7b0308029e611c3cc4a34b","content_index":0,"output_index":0} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":13,"delta":"?","logprobs":[],"item_id":"msg_68d40dcb2d34819c88f5d6a8ca7b0308029e611c3cc4a34b","content_index":0,"output_index":0} + + event: response.output_text.done + data: {"type":"response.output_text.done","sequence_number":14,"text":"Hello! How can I assist you today?","logprobs":[],"item_id":"msg_68d40dcb2d34819c88f5d6a8ca7b0308029e611c3cc4a34b","content_index":0,"output_index":0} + + event: response.content_part.done + data: {"type":"response.content_part.done","sequence_number":15,"item_id":"msg_68d40dcb2d34819c88f5d6a8ca7b0308029e611c3cc4a34b","content_index":0,"part":{"text":"Hello! How can I assist you today?","logprobs":[],"type":"output_text","annotations":[]},"output_index":0} + + event: response.output_item.done + data: {"type":"response.output_item.done","sequence_number":16,"item":{"id":"msg_68d40dcb2d34819c88f5d6a8ca7b0308029e611c3cc4a34b","content":[{"text":"Hello! How can I assist you today?","logprobs":[],"type":"output_text","annotations":[]}],"role":"assistant","status":"completed","type":"message"},"output_index":0} + + event: response.completed + data: {"type":"response.completed","sequence_number":17,"response":{"truncation":"disabled","id":"resp_68d40dc671a0819cb0ee920078333451029e611c3cc4a34b","tool_choice":"auto","temperature":1.0,"top_p":1.0,"status":"completed","top_logprobs":0,"usage":{"total_tokens":18,"input_tokens_details":{"cached_tokens":0},"output_tokens_details":{"reasoning_tokens":0},"output_tokens":10,"input_tokens":8},"object":"response","created_at":1758727622,"prompt_cache_key":null,"text":{"format":{"type":"text"},"verbosity":"medium"},"incomplete_details":null,"model":"gpt-4o-2024-08-06","previous_response_id":null,"safety_identifier":null,"metadata":{},"store":true,"output":[{"id":"msg_68d40dcb2d34819c88f5d6a8ca7b0308029e611c3cc4a34b","content":[{"text":"Hello! How can I assist you today?","logprobs":[],"type":"output_text","annotations":[]}],"role":"assistant","status":"completed","type":"message"}],"parallel_tool_calls":true,"error":null,"background":true,"instructions":null,"service_tier":"default","max_tool_calls":null,"max_output_tokens":null,"tools":[],"user":null,"reasoning":{"effort":null,"summary":null}}} + + + """; + + using VerbatimHttpHandler handler = new(expectedInput, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-2024-08-06"); + + // Emulating resumption of the stream after receiving the first 9 updates that provided the text "Hello! How can I" + var continuationToken = new TestOpenAIResponsesContinuationToken("resp_68d40dc671a0819cb0ee920078333451029e611c3cc4a34b") + { + SequenceNumber = 9 + }; + + var chatOptions = new ChatOptions + { + AllowBackgroundResponses = true, + ContinuationToken = continuationToken, + }; + + List updates = []; + await foreach (var update in client.GetStreamingResponseAsync([], chatOptions)) + { + updates.Add(update); + } + + // Receiving the remaining updates to complete the response "Hello! How can I assist you today?" + Assert.Equal(" assist you today?", string.Concat(updates.Select(u => u.Text))); + Assert.Equal(8, updates.Count); + + var createdAt = DateTimeOffset.FromUnixTimeSeconds(1_758_727_622); + + for (int i = 0; i < updates.Count; i++) + { + Assert.Equal("resp_68d40dc671a0819cb0ee920078333451029e611c3cc4a34b", updates[i].ResponseId); + Assert.Equal("resp_68d40dc671a0819cb0ee920078333451029e611c3cc4a34b", updates[i].ConversationId); + + var sequenceNumber = i + 10; + + if (sequenceNumber is (>= 10 and <= 13)) + { + // Text deltas + Assert.NotNull(updates[i].ContinuationToken); + var responsesContinuationToken = TestOpenAIResponsesContinuationToken.FromToken(updates[i].ContinuationToken!); + Assert.Equal("resp_68d40dc671a0819cb0ee920078333451029e611c3cc4a34b", responsesContinuationToken.ResponseId); + Assert.Equal(sequenceNumber, responsesContinuationToken.SequenceNumber); + + Assert.Single(updates[i].Contents); + } + else if (sequenceNumber is (>= 14 and <= 16)) + { + // Response Complete and Assistant message updates + Assert.NotNull(updates[i].ContinuationToken); + var responsesContinuationToken = TestOpenAIResponsesContinuationToken.FromToken(updates[i].ContinuationToken!); + Assert.Equal("resp_68d40dc671a0819cb0ee920078333451029e611c3cc4a34b", responsesContinuationToken.ResponseId); + Assert.Equal(sequenceNumber, responsesContinuationToken.SequenceNumber); + + Assert.Empty(updates[i].Contents); + } + else + { + // The last update with the response completion + Assert.Null(updates[i].ContinuationToken); + Assert.Single(updates[i].Contents); + } + } + } + + [Fact] + public async Task GetStreamingResponseAsync_BackgroundResponses_StreamResumption_WithMessages() + { + using VerbatimHttpHandler handler = new(string.Empty, string.Empty); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-2024-08-06"); + + // Emulating resumption of the stream after receiving the first 9 updates that provided the text "Hello! How can I" + var chatOptions = new ChatOptions + { + AllowBackgroundResponses = true, + ContinuationToken = new TestOpenAIResponsesContinuationToken("resp_68d40dc671a0819cb0ee920078333451029e611c3cc4a34b") + { + SequenceNumber = 9 + } + }; + + await Assert.ThrowsAsync(async () => + { + await foreach (var update in client.GetStreamingResponseAsync("Please book a hotel for me", chatOptions)) +#pragma warning disable S108 // Nested blocks of code should not be left empty + { + } +#pragma warning restore S108 // Nested blocks of code should not be left empty + }); + } + + [Fact] + public async Task CodeInterpreterTool_NonStreaming() + { + const string Input = """ + { + "model":"gpt-4o-2024-08-06", + "input":[{ + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"Calculate the sum of numbers from 1 to 5"}] + }], + "tool_choice":"auto", + "tools":[{ + "type":"code_interpreter", + "container":{"type":"auto"} + }] + } + """; + + const string Output = """ + { + "id": "resp_0e599e83cc6642210068fb7475165481a08efc750483c7048f", + "object": "response", + "created_at": 1761309813, + "status": "completed", + "background": false, + "error": null, + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "max_tool_calls": null, + "model": "gpt-4o-2024-08-06", + "output": [ + { + "id": "ci_0e599e83cc6642210068fb7477fb9881a0811e8b0dc054b2fa", + "type": "code_interpreter_call", + "status": "completed", + "code": "# Calculating the sum of numbers from 1 to 5\nresult = sum(range(1, 6))\nresult", + "container_id": "cntr_68fb7476c384819186524b78cdc3180000a9a0fdd06b3cd4", + "outputs": null + }, + { + "id": "msg_0e599e83cc6642210068fb747e118081a08c3ed46daa9d9dcb", + "type": "message", + "status": "completed", + "content": [ + { + "type": "output_text", + "annotations": [], + "logprobs": [], + "text": "15" + } + ], + "role": "assistant" + } + ], + "parallel_tool_calls": true, + "previous_response_id": null, + "prompt_cache_key": null, + "reasoning": { + "effort": null, + "summary": null + }, + "safety_identifier": null, + "service_tier": "default", + "store": true, + "temperature": 1.0, + "text": { + "format": { + "type": "text" + }, + "verbosity": "medium" + }, + "tool_choice": "auto", + "tools": [ + { + "type": "code_interpreter", + "container": { + "type": "auto" + } + } + ], + "top_logprobs": 0, + "top_p": 1.0, + "truncation": "disabled", + "usage": { + "input_tokens": 225, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 34, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 259 + }, + "user": null, + "metadata": {} + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-2024-08-06"); + + var response = await client.GetResponseAsync("Calculate the sum of numbers from 1 to 5", new() + { + Tools = [new HostedCodeInterpreterTool()], + }); + + Assert.NotNull(response); + Assert.Single(response.Messages); + + var message = response.Messages[0]; + Assert.Equal(ChatRole.Assistant, message.Role); + Assert.Equal(3, message.Contents.Count); + + var codeCall = Assert.IsType(message.Contents[0]); + Assert.NotNull(codeCall.Inputs); + var codeInput = Assert.IsType(Assert.Single(codeCall.Inputs)); + Assert.Equal("text/x-python", codeInput.MediaType); + + var codeResult = Assert.IsType(message.Contents[1]); + Assert.Equal(codeCall.CallId, codeResult.CallId); + + var textContent = Assert.IsType(message.Contents[2]); + Assert.Equal("15", textContent.Text); + } + + [Fact] + public async Task CodeInterpreterTool_Streaming() + { + const string Input = """ + { + "model":"gpt-4o-2024-08-06", + "input":[{ + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"Calculate the sum of numbers from 1 to 10 using Python"}] + }], + "tool_choice":"auto", + "tools":[{ + "type":"code_interpreter", + "container":{"type":"auto"} + }], + "stream":true + } + """; + + const string Output = """ + event: response.created + data: {"type":"response.created","sequence_number":0,"response":{"id":"resp_05d8f42f04f94cb80068fc3b7e07bc819eaf0d6e2c1923e564","object":"response","created_at":1761360766,"status":"in_progress","background":false,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4o-2024-08-06","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"code_interpreter","container":{"type":"auto"}}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} + + event: response.in_progress + data: {"type":"response.in_progress","sequence_number":1,"response":{"id":"resp_05d8f42f04f94cb80068fc3b7e07bc819eaf0d6e2c1923e564","object":"response","created_at":1761360766,"status":"in_progress","background":false,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4o-2024-08-06","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"code_interpreter","container":{"type":"auto"}}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} + + event: response.output_item.added + data: {"type":"response.output_item.added","sequence_number":2,"output_index":0,"item":{"id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","type":"code_interpreter_call","status":"in_progress","code":"","container_id":"cntr_68fc3b80043c8191990a5837d7617af704511ed77cec9447","outputs":null}} + + event: response.code_interpreter_call.in_progress + data: {"type":"response.code_interpreter_call.in_progress","sequence_number":3,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":4,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":"#","obfuscation":"sl1L6kjYGbL3W7b"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":5,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":" Calculate","obfuscation":"nXS0Oz"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":6,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":" the","obfuscation":"BeywG4wkYSPO"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":7,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":" sum","obfuscation":"lQQwYY1jVUku"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":8,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":" of","obfuscation":"B7ZYHyd1bTIIr"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":9,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":" numbers","obfuscation":"c9P1UFe4"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":10,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":" from","obfuscation":"jARdbqvpfdt"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":11,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":" ","obfuscation":"wciF7LWnjGSdWPb"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":12,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":"1","obfuscation":"KLuWFhT8xPOyTNH"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":13,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":" to","obfuscation":"5QCNr2nNt72Hg"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":14,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":" ","obfuscation":"F3vctEo2cPUvnhe"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":15,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":"10","obfuscation":"JBwcgWLYbSTskz"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":16,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":"\n","obfuscation":"AgU5f5WddGwHDJg"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":17,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":"sum","obfuscation":"Vey8vqQPTbewO"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":18,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":"_of","obfuscation":"Lyrmc5oOwdmsp"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":19,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":"_numbers","obfuscation":"YxvseUG4"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":20,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":" =","obfuscation":"yoo1CBUhRbgI36"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":21,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":" sum","obfuscation":"pKTBmkNEE4SA"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":22,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":"(range","obfuscation":"BixU5bs5ms"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":23,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":"(","obfuscation":"nsarVNP46dpVYMb"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":24,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":"1","obfuscation":"qkth7DCPzS6mfEd"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":25,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":",","obfuscation":"J5uAitISxtjRSQA"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":26,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":" ","obfuscation":"thr4ylmBRbAk0PY"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":27,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":"11","obfuscation":"FWxcmwOFHJKEPJ"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":28,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":"))\n","obfuscation":"ifI2JoREexe3t"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":29,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":"sum","obfuscation":"VI7RlYoKWGMKP"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":30,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":"_of","obfuscation":"lkv27YflY8GLq"} + + event: response.code_interpreter_call_code.delta + data: {"type":"response.code_interpreter_call_code.delta","sequence_number":31,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","delta":"_numbers","obfuscation":"xAQFrVav"} + + event: response.code_interpreter_call_code.done + data: {"type":"response.code_interpreter_call_code.done","sequence_number":32,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","code":"# Calculate the sum of numbers from 1 to 10\nsum_of_numbers = sum(range(1, 11))\nsum_of_numbers"} + + event: response.code_interpreter_call.interpreting + data: {"type":"response.code_interpreter_call.interpreting","sequence_number":33,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3"} + + event: response.code_interpreter_call.completed + data: {"type":"response.code_interpreter_call.completed","sequence_number":34,"output_index":0,"item_id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3"} + + event: response.output_item.done + data: {"type":"response.output_item.done","sequence_number":35,"output_index":0,"item":{"id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","type":"code_interpreter_call","status":"completed","code":"# Calculate the sum of numbers from 1 to 10\nsum_of_numbers = sum(range(1, 11))\nsum_of_numbers","container_id":"cntr_68fc3b80043c8191990a5837d7617af704511ed77cec9447","outputs":null}} + + event: response.output_item.added + data: {"type":"response.output_item.added","sequence_number":36,"output_index":1,"item":{"id":"msg_05d8f42f04f94cb80068fc3b86cc0c819ebae29aac8563d48d","type":"message","status":"in_progress","content":[],"role":"assistant"}} + + event: response.content_part.added + data: {"type":"response.content_part.added","sequence_number":37,"item_id":"msg_05d8f42f04f94cb80068fc3b86cc0c819ebae29aac8563d48d","output_index":1,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":38,"item_id":"msg_05d8f42f04f94cb80068fc3b86cc0c819ebae29aac8563d48d","output_index":1,"content_index":0,"delta":"The","logprobs":[],"obfuscation":"r7iIr1QJ50aER"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":39,"item_id":"msg_05d8f42f04f94cb80068fc3b86cc0c819ebae29aac8563d48d","output_index":1,"content_index":0,"delta":" sum","logprobs":[],"obfuscation":"AQlXzWBYj2nu"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":40,"item_id":"msg_05d8f42f04f94cb80068fc3b86cc0c819ebae29aac8563d48d","output_index":1,"content_index":0,"delta":" of","logprobs":[],"obfuscation":"PpVAep2w6YBTd"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":41,"item_id":"msg_05d8f42f04f94cb80068fc3b86cc0c819ebae29aac8563d48d","output_index":1,"content_index":0,"delta":" numbers","logprobs":[],"obfuscation":"q2oosiA3"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":42,"item_id":"msg_05d8f42f04f94cb80068fc3b86cc0c819ebae29aac8563d48d","output_index":1,"content_index":0,"delta":" from","logprobs":[],"obfuscation":"BBLhSYyDDUG"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":43,"item_id":"msg_05d8f42f04f94cb80068fc3b86cc0c819ebae29aac8563d48d","output_index":1,"content_index":0,"delta":" ","logprobs":[],"obfuscation":"itOENAMFwzo6ESp"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":44,"item_id":"msg_05d8f42f04f94cb80068fc3b86cc0c819ebae29aac8563d48d","output_index":1,"content_index":0,"delta":"1","logprobs":[],"obfuscation":"g93CJ2MyMSbq26V"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":45,"item_id":"msg_05d8f42f04f94cb80068fc3b86cc0c819ebae29aac8563d48d","output_index":1,"content_index":0,"delta":" to","logprobs":[],"obfuscation":"WyzXmwaUVATTs"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":46,"item_id":"msg_05d8f42f04f94cb80068fc3b86cc0c819ebae29aac8563d48d","output_index":1,"content_index":0,"delta":" ","logprobs":[],"obfuscation":"DBgQSKk2myfDWpq"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":47,"item_id":"msg_05d8f42f04f94cb80068fc3b86cc0c819ebae29aac8563d48d","output_index":1,"content_index":0,"delta":"10","logprobs":[],"obfuscation":"RA4RYQSLNug4pg"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":48,"item_id":"msg_05d8f42f04f94cb80068fc3b86cc0c819ebae29aac8563d48d","output_index":1,"content_index":0,"delta":" is","logprobs":[],"obfuscation":"CgEfKJVj1DJtz"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":49,"item_id":"msg_05d8f42f04f94cb80068fc3b86cc0c819ebae29aac8563d48d","output_index":1,"content_index":0,"delta":" ","logprobs":[],"obfuscation":"TVg4ccd4ZMwEiru"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":50,"item_id":"msg_05d8f42f04f94cb80068fc3b86cc0c819ebae29aac8563d48d","output_index":1,"content_index":0,"delta":"55","logprobs":[],"obfuscation":"CVN92VujTbUiZ0"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","sequence_number":51,"item_id":"msg_05d8f42f04f94cb80068fc3b86cc0c819ebae29aac8563d48d","output_index":1,"content_index":0,"delta":".","logprobs":[],"obfuscation":"7YegRUzag3K6fdV"} + + event: response.output_text.done + data: {"type":"response.output_text.done","sequence_number":52,"item_id":"msg_05d8f42f04f94cb80068fc3b86cc0c819ebae29aac8563d48d","output_index":1,"content_index":0,"text":"The sum of numbers from 1 to 10 is 55.","logprobs":[]} + + event: response.content_part.done + data: {"type":"response.content_part.done","sequence_number":53,"item_id":"msg_05d8f42f04f94cb80068fc3b86cc0c819ebae29aac8563d48d","output_index":1,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":"The sum of numbers from 1 to 10 is 55."}} + + event: response.output_item.done + data: {"type":"response.output_item.done","sequence_number":54,"output_index":1,"item":{"id":"msg_05d8f42f04f94cb80068fc3b86cc0c819ebae29aac8563d48d","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"The sum of numbers from 1 to 10 is 55."}],"role":"assistant"}} + + event: response.completed + data: {"type":"response.completed","sequence_number":55,"response":{"id":"resp_05d8f42f04f94cb80068fc3b7e07bc819eaf0d6e2c1923e564","object":"response","created_at":1761360766,"status":"completed","background":false,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4o-2024-08-06","output":[{"id":"ci_05d8f42f04f94cb80068fc3b80fba8819ea3bfbdd36e94bcf3","type":"code_interpreter_call","status":"completed","code":"# Calculate the sum of numbers from 1 to 10\nsum_of_numbers = sum(range(1, 11))\nsum_of_numbers","container_id":"cntr_68fc3b80043c8191990a5837d7617af704511ed77cec9447","outputs":null},{"id":"msg_05d8f42f04f94cb80068fc3b86cc0c819ebae29aac8563d48d","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"The sum of numbers from 1 to 10 is 55."}],"role":"assistant"}],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"default","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"code_interpreter","container":{"type":"auto"}}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":219,"input_tokens_details":{"cached_tokens":0},"output_tokens":50,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":269},"user":null,"metadata":{}}} + + + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-2024-08-06"); + + var response = await client.GetStreamingResponseAsync("Calculate the sum of numbers from 1 to 10 using Python", new() + { + Tools = [new HostedCodeInterpreterTool()], + }).ToChatResponseAsync(); + + Assert.NotNull(response); + Assert.Single(response.Messages); + + var message = response.Messages[0]; + Assert.Equal(ChatRole.Assistant, message.Role); + Assert.Equal(3, message.Contents.Count); + + var codeCall = Assert.IsType(message.Contents[0]); + Assert.NotNull(codeCall.Inputs); + var codeInput = Assert.IsType(Assert.Single(codeCall.Inputs)); + Assert.Equal("text/x-python", codeInput.MediaType); + Assert.Contains("sum_of_numbers", Encoding.UTF8.GetString(codeInput.Data.ToArray())); + + var codeResult = Assert.IsType(message.Contents[1]); + Assert.Equal(codeCall.CallId, codeResult.CallId); + + var textContent = Assert.IsType(message.Contents[2]); + Assert.Equal("The sum of numbers from 1 to 10 is 55.", textContent.Text); + + Assert.NotNull(response.Usage); + Assert.Equal(219, response.Usage.InputTokenCount); + Assert.Equal(50, response.Usage.OutputTokenCount); + Assert.Equal(269, response.Usage.TotalTokenCount); + } + + [Fact] + public async Task RequestHeaders_UserAgent_ContainsMEAI() + { + using var handler = new ThrowUserAgentExceptionHandler(); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + InvalidOperationException e = await Assert.ThrowsAsync(() => client.GetResponseAsync("hello")); + + Assert.StartsWith("User-Agent header: OpenAI", e.Message); + Assert.Contains("MEAI", e.Message); + } + + [Fact] + public async Task ConversationId_AsResponseId_NonStreaming() + { + const string Input = """ + { + "temperature":0.5, + "model":"gpt-4o-mini", + "previous_response_id":"resp_12345", + "input": [{ + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"hello"}] + }], + "max_output_tokens":20 + } + """; + + const string Output = """ + { + "id": "resp_67890", + "object": "response", + "created_at": 1741891428, + "status": "completed", + "model": "gpt-4o-mini-2024-07-18", + "output": [ + { + "type": "message", + "id": "msg_67d32764fcdc8191bcf2e444d4088804058a5e08c46a181d", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "Hello! How can I assist you today?", + "annotations": [] + } + ] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("hello", new() + { + MaxOutputTokens = 20, + Temperature = 0.5f, + ConversationId = "resp_12345", + }); + + Assert.NotNull(response); + Assert.Equal("resp_67890", response.ResponseId); + Assert.Equal("resp_67890", response.ConversationId); + } + + [Fact] + public async Task ConversationId_AsConversationId_NonStreaming() + { + const string Input = """ + { + "temperature":0.5, + "model":"gpt-4o-mini", + "conversation":"conv_12345", + "input": [{ + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"hello"}] + }], + "max_output_tokens":20 + } + """; + + const string Output = """ + { + "id": "resp_67890", + "object": "response", + "created_at": 1741891428, + "status": "completed", + "model": "gpt-4o-mini-2024-07-18", + "output": [ + { + "type": "message", + "id": "msg_67d32764fcdc8191bcf2e444d4088804058a5e08c46a181d", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "Hello! How can I assist you today?", + "annotations": [] + } + ] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("hello", new() + { + MaxOutputTokens = 20, + Temperature = 0.5f, + ConversationId = "conv_12345", + }); + + Assert.NotNull(response); + Assert.Equal("resp_67890", response.ResponseId); + Assert.Equal("conv_12345", response.ConversationId); + } + + [Fact] + public async Task ConversationId_WhenStoreDisabled_ReturnsNull_NonStreaming() + { + const string Input = """ + { + "temperature":0.5, + "model":"gpt-4o-mini", + "store":false, + "input": [{ + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"hello"}] + }], + "max_output_tokens":20 + } + """; + + const string Output = """ + { + "id": "resp_67890", + "object": "response", + "created_at": 1741891428, + "status": "completed", + "model": "gpt-4o-mini-2024-07-18", + "store": false, + "output": [ + { + "type": "message", + "id": "msg_67d32764fcdc8191bcf2e444d4088804058a5e08c46a181d", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "Hello! How can I assist you today?", + "annotations": [] + } + ] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("hello", new() + { + MaxOutputTokens = 20, + Temperature = 0.5f, + RawRepresentationFactory = (c) => new ResponseCreationOptions + { + StoredOutputEnabled = false + } + }); + + Assert.NotNull(response); + Assert.Equal("resp_67890", response.ResponseId); + Assert.Null(response.ConversationId); + } + + [Fact] + public async Task ConversationId_ChatOptionsOverridesRawRepresentationResponseId_NonStreaming() + { + const string Input = """ + { + "temperature":0.5, + "model":"gpt-4o-mini", + "previous_response_id":"resp_override", + "input": [{ + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"hello"}] + }], + "max_output_tokens":20 + } + """; + + const string Output = """ + { + "id": "resp_67890", + "object": "response", + "created_at": 1741891428, + "status": "completed", + "model": "gpt-4o-mini-2024-07-18", + "output": [ + { + "type": "message", + "id": "msg_67d32764fcdc8191bcf2e444d4088804058a5e08c46a181d", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "Hello! How can I assist you today?", + "annotations": [] + } + ] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("hello", new() + { + MaxOutputTokens = 20, + Temperature = 0.5f, + ConversationId = "resp_override", + RawRepresentationFactory = (c) => new ResponseCreationOptions + { + PreviousResponseId = null + } + }); + + Assert.NotNull(response); + Assert.Equal("resp_67890", response.ResponseId); + Assert.Equal("resp_67890", response.ConversationId); + } + + [Fact] + public async Task ConversationId_RawRepresentationPreviousResponseIdTakesPrecedence_NonStreaming() + { + const string Input = """ + { + "temperature":0.5, + "model":"gpt-4o-mini", + "previous_response_id":"resp_fromraw", + "input": [{ + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"hello"}] + }], + "max_output_tokens":20 + } + """; + + const string Output = """ + { + "id": "resp_67890", + "object": "response", + "created_at": 1741891428, + "status": "completed", + "model": "gpt-4o-mini-2024-07-18", + "output": [ + { + "type": "message", + "id": "msg_67d32764fcdc8191bcf2e444d4088804058a5e08c46a181d", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "Hello! How can I assist you today?", + "annotations": [] + } + ] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("hello", new() + { + MaxOutputTokens = 20, + Temperature = 0.5f, + ConversationId = "conv_ignored", + RawRepresentationFactory = (c) => new ResponseCreationOptions + { + PreviousResponseId = "resp_fromraw" + } + }); + + Assert.NotNull(response); + Assert.Equal("resp_67890", response.ResponseId); + Assert.Equal("resp_67890", response.ConversationId); + } + + [Fact] + public async Task ConversationId_WhenStoreExplicitlyTrue_UsesResponseId_NonStreaming() + { + const string Input = """ + { + "temperature":0.5, + "model":"gpt-4o-mini", + "store":true, + "input": [{ + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"hello"}] + }], + "max_output_tokens":20 + } + """; + + const string Output = """ + { + "id": "resp_67890", + "object": "response", + "created_at": 1741891428, + "status": "completed", + "model": "gpt-4o-mini-2024-07-18", + "store": true, + "output": [ + { + "type": "message", + "id": "msg_67d32764fcdc8191bcf2e444d4088804058a5e08c46a181d", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "Hello! How can I assist you today?", + "annotations": [] + } + ] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("hello", new() + { + MaxOutputTokens = 20, + Temperature = 0.5f, + RawRepresentationFactory = (c) => new ResponseCreationOptions + { + StoredOutputEnabled = true + } + }); + + Assert.NotNull(response); + Assert.Equal("resp_67890", response.ResponseId); + Assert.Equal("resp_67890", response.ConversationId); + } + + [Fact] + public async Task ConversationId_WhenStoreExplicitlyTrue_UsesResponseId_Streaming() + { + const string Input = """ + { + "temperature":0.5, + "model":"gpt-4o-mini", + "store":true, + "input":[ + { + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"hello"}] + } + ], + "stream":true, + "max_output_tokens":20 + } + """; + + const string Output = """ + event: response.created + data: {"type":"response.created","response":{"id":"resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77","object":"response","created_at":1741892091,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":20,"model":"gpt-4o-mini-2024-07-18","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":true,"temperature":0.5,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"usage":null,"user":null,"metadata":{}}} + + event: response.in_progress + data: {"type":"response.in_progress","response":{"id":"resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77","object":"response","created_at":1741892091,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":20,"model":"gpt-4o-mini-2024-07-18","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":true,"temperature":0.5,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"usage":null,"user":null,"metadata":{}}} + + event: response.output_item.added + data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","status":"in_progress","role":"assistant","content":[]}} + + event: response.content_part.added + data: {"type":"response.content_part.added","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]}} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"delta":"Hello"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"delta":"!"} + + event: response.output_text.done + data: {"type":"response.output_text.done","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"text":"Hello!"} + + event: response.content_part.done + data: {"type":"response.content_part.done","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"part":{"type":"output_text","text":"Hello!","annotations":[]}} + + event: response.output_item.done + data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hello!","annotations":[]}]}} + + event: response.completed + data: {"type":"response.completed","response":{"id":"resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77","object":"response","created_at":1741892091,"status":"completed","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":20,"model":"gpt-4o-mini-2024-07-18","output":[{"type":"message","id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hello!","annotations":[]}]}],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":true,"temperature":0.5,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"usage":{"input_tokens":26,"input_tokens_details":{"cached_tokens":0},"output_tokens":10,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":36},"user":null,"metadata":{}}} + + + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + List updates = []; + await foreach (var update in client.GetStreamingResponseAsync("hello", new() + { + MaxOutputTokens = 20, + Temperature = 0.5f, + RawRepresentationFactory = (c) => new ResponseCreationOptions + { + StoredOutputEnabled = true + } + })) + { + updates.Add(update); + } + + Assert.Equal("Hello!", string.Concat(updates.Select(u => u.Text))); + + for (int i = 0; i < updates.Count; i++) + { + Assert.Equal("resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77", updates[i].ResponseId); + Assert.Equal("resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77", updates[i].ConversationId); + } + } + + [Fact] + public async Task ConversationId_WhenStoreDisabled_ReturnsNull_Streaming() + { + const string Input = """ + { + "temperature":0.5, + "model":"gpt-4o-mini", + "store":false, + "input":[ + { + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"hello"}] + } + ], + "stream":true, + "max_output_tokens":20 + } + """; + + const string Output = """ + event: response.created + data: {"type":"response.created","response":{"id":"resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77","object":"response","created_at":1741892091,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":20,"model":"gpt-4o-mini-2024-07-18","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":false,"temperature":0.5,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"usage":null,"user":null,"metadata":{}}} + + event: response.in_progress + data: {"type":"response.in_progress","response":{"id":"resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77","object":"response","created_at":1741892091,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":20,"model":"gpt-4o-mini-2024-07-18","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":false,"temperature":0.5,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"usage":null,"user":null,"metadata":{}}} + + event: response.output_item.added + data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","status":"in_progress","role":"assistant","content":[]}} + + event: response.content_part.added + data: {"type":"response.content_part.added","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]}} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"delta":"Hello"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"delta":"!"} + + event: response.output_text.done + data: {"type":"response.output_text.done","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"text":"Hello!"} + + event: response.content_part.done + data: {"type":"response.content_part.done","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"part":{"type":"output_text","text":"Hello!","annotations":[]}} + + event: response.output_item.done + data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hello!","annotations":[]}]}} + + event: response.completed + data: {"type":"response.completed","response":{"id":"resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77","object":"response","created_at":1741892091,"status":"completed","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":20,"model":"gpt-4o-mini-2024-07-18","output":[{"type":"message","id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hello!","annotations":[]}]}],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":false,"temperature":0.5,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"usage":{"input_tokens":26,"input_tokens_details":{"cached_tokens":0},"output_tokens":10,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":36},"user":null,"metadata":{}}} + + + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + List updates = []; + await foreach (var update in client.GetStreamingResponseAsync("hello", new() + { + MaxOutputTokens = 20, + Temperature = 0.5f, + RawRepresentationFactory = (c) => new ResponseCreationOptions + { + StoredOutputEnabled = false + } + })) + { + updates.Add(update); + } + + Assert.Equal("Hello!", string.Concat(updates.Select(u => u.Text))); + + for (int i = 0; i < updates.Count; i++) + { + Assert.Equal("resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77", updates[i].ResponseId); + Assert.Null(updates[i].ConversationId); + } + } + + [Fact] + public async Task ConversationId_AsConversationId_Streaming() + { + const string Input = """ + { + "temperature":0.5, + "model":"gpt-4o-mini", + "conversation":"conv_12345", + "input":[ + { + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"hello"}] + } + ], + "stream":true, + "max_output_tokens":20 + } + """; + + const string Output = """ + event: response.created + data: {"type":"response.created","response":{"id":"resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77","object":"response","created_at":1741892091,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":20,"model":"gpt-4o-mini-2024-07-18","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":true,"temperature":0.5,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"usage":null,"user":null,"metadata":{}}} + + event: response.in_progress + data: {"type":"response.in_progress","response":{"id":"resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77","object":"response","created_at":1741892091,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":20,"model":"gpt-4o-mini-2024-07-18","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":true,"temperature":0.5,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"usage":null,"user":null,"metadata":{}}} + + event: response.output_item.added + data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","status":"in_progress","role":"assistant","content":[]}} + + event: response.content_part.added + data: {"type":"response.content_part.added","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]}} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"delta":"Hello"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"delta":"!"} + + event: response.output_text.done + data: {"type":"response.output_text.done","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"text":"Hello!"} + + event: response.content_part.done + data: {"type":"response.content_part.done","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"part":{"type":"output_text","text":"Hello!","annotations":[]}} + + event: response.output_item.done + data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hello!","annotations":[]}]}} + + event: response.completed + data: {"type":"response.completed","response":{"id":"resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77","object":"response","created_at":1741892091,"status":"completed","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":20,"model":"gpt-4o-mini-2024-07-18","output":[{"type":"message","id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hello!","annotations":[]}]}],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":true,"temperature":0.5,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"usage":{"input_tokens":26,"input_tokens_details":{"cached_tokens":0},"output_tokens":10,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":36},"user":null,"metadata":{}}} + + + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + List updates = []; + await foreach (var update in client.GetStreamingResponseAsync("hello", new() + { + MaxOutputTokens = 20, + Temperature = 0.5f, + ConversationId = "conv_12345", + })) + { + updates.Add(update); + } + + Assert.Equal("Hello!", string.Concat(updates.Select(u => u.Text))); + + for (int i = 0; i < updates.Count; i++) + { + Assert.Equal("resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77", updates[i].ResponseId); + Assert.Equal("conv_12345", updates[i].ConversationId); + } + } + + [Fact] + public async Task ConversationId_AsResponseId_Streaming() + { + const string Input = """ + { + "temperature":0.5, + "model":"gpt-4o-mini", + "previous_response_id":"resp_12345", + "input":[ + { + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"hello"}] + } + ], + "stream":true, + "max_output_tokens":20 + } + """; + + const string Output = """ + event: response.created + data: {"type":"response.created","response":{"id":"resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77","object":"response","created_at":1741892091,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":20,"model":"gpt-4o-mini-2024-07-18","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":true,"temperature":0.5,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"usage":null,"user":null,"metadata":{}}} + + event: response.in_progress + data: {"type":"response.in_progress","response":{"id":"resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77","object":"response","created_at":1741892091,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":20,"model":"gpt-4o-mini-2024-07-18","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":true,"temperature":0.5,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"usage":null,"user":null,"metadata":{}}} + + event: response.output_item.added + data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","status":"in_progress","role":"assistant","content":[]}} + + event: response.content_part.added + data: {"type":"response.content_part.added","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]}} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"delta":"Hello"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"delta":"!"} + + event: response.output_text.done + data: {"type":"response.output_text.done","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"text":"Hello!"} + + event: response.content_part.done + data: {"type":"response.content_part.done","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"part":{"type":"output_text","text":"Hello!","annotations":[]}} + + event: response.output_item.done + data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hello!","annotations":[]}]}} + + event: response.completed + data: {"type":"response.completed","response":{"id":"resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77","object":"response","created_at":1741892091,"status":"completed","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":20,"model":"gpt-4o-mini-2024-07-18","output":[{"type":"message","id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hello!","annotations":[]}]}],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":true,"temperature":0.5,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"usage":{"input_tokens":26,"input_tokens_details":{"cached_tokens":0},"output_tokens":10,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":36},"user":null,"metadata":{}}} + + + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + List updates = []; + await foreach (var update in client.GetStreamingResponseAsync("hello", new() + { + MaxOutputTokens = 20, + Temperature = 0.5f, + ConversationId = "resp_12345", + })) + { + updates.Add(update); + } + + Assert.Equal("Hello!", string.Concat(updates.Select(u => u.Text))); + + for (int i = 0; i < updates.Count; i++) + { + Assert.Equal("resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77", updates[i].ResponseId); + Assert.Equal("resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77", updates[i].ConversationId); + } + } + + [Fact] + public async Task ConversationId_RawRepresentationConversationIdTakesPrecedence_NonStreaming() + { + const string Input = """ + { + "temperature":0.5, + "model":"gpt-4o-mini", + "conversation":"conv_12345", + "input": [{ + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"hello"}] + }], + "max_output_tokens":20 + } + """; + + const string Output = """ + { + "id": "resp_67890", + "object": "response", + "created_at": 1741891428, + "status": "completed", + "model": "gpt-4o-mini-2024-07-18", + "output": [ + { + "type": "message", + "id": "msg_67d32764fcdc8191bcf2e444d4088804058a5e08c46a181d", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "Hello! How can I assist you today?", + "annotations": [] + } + ] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var rcoJsonModel = (IJsonModel)new ResponseCreationOptions(); + BinaryData rcoJsonBinaryData = rcoJsonModel.Write(ModelReaderWriterOptions.Json); + JsonObject rcoJsonObject = Assert.IsType(JsonNode.Parse(rcoJsonBinaryData.ToMemory().Span)); + Assert.Null(rcoJsonObject["conversation"]); + rcoJsonObject["conversation"] = "conv_12345"; + + var response = await client.GetResponseAsync("hello", new() + { + MaxOutputTokens = 20, + Temperature = 0.5f, + ConversationId = "conv_ignored", + RawRepresentationFactory = (c) => rcoJsonModel.Create( + new BinaryData(JsonSerializer.SerializeToUtf8Bytes(rcoJsonObject)), + ModelReaderWriterOptions.Json) + }); + + Assert.NotNull(response); + Assert.Equal("resp_67890", response.ResponseId); + Assert.Equal("conv_12345", response.ConversationId); + } + + [Fact] + public async Task ChatOptions_ModelId_OverridesClientModel_NonStreaming() + { + const string Input = """ + { + "temperature":0.5, + "model":"gpt-4o", + "input": [{ + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"hello"}] + }], + "max_output_tokens":20 + } + """; + + const string Output = """ + { + "id": "resp_67890", + "object": "response", + "created_at": 1741891428, + "status": "completed", + "model": "gpt-4o-2024-08-06", + "output": [ + { + "type": "message", + "id": "msg_67d32764fcdc8191bcf2e444d4088804058a5e08c46a181d", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "Hello! How can I assist you today?", + "annotations": [] + } + ] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("hello", new() + { + MaxOutputTokens = 20, + Temperature = 0.5f, + ModelId = "gpt-4o", + }); + + Assert.NotNull(response); + Assert.Equal("resp_67890", response.ResponseId); + Assert.Equal("gpt-4o-2024-08-06", response.ModelId); + Assert.Equal("Hello! How can I assist you today?", response.Text); + } + + [Fact] + public async Task ChatOptions_ModelId_OverridesClientModel_Streaming() + { + const string Input = """ + { + "temperature":0.5, + "model":"gpt-4o", + "input":[ + { + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"hello"}] + } + ], + "stream":true, + "max_output_tokens":20 + } + """; + + const string Output = """ + event: response.created + data: {"type":"response.created","response":{"id":"resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77","object":"response","created_at":1741892091,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":20,"model":"gpt-4o-2024-08-06","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":true,"temperature":0.5,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"usage":null,"user":null,"metadata":{}}} + + event: response.in_progress + data: {"type":"response.in_progress","response":{"id":"resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77","object":"response","created_at":1741892091,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":20,"model":"gpt-4o-2024-08-06","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":true,"temperature":0.5,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"usage":null,"user":null,"metadata":{}}} + + event: response.output_item.added + data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","status":"in_progress","role":"assistant","content":[]}} + + event: response.content_part.added + data: {"type":"response.content_part.added","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]}} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"delta":"Hello"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"delta":"!"} + + event: response.output_text.done + data: {"type":"response.output_text.done","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"text":"Hello!"} + + event: response.content_part.done + data: {"type":"response.content_part.done","item_id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","output_index":0,"content_index":0,"part":{"type":"output_text","text":"Hello!","annotations":[]}} + + event: response.output_item.done + data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hello!","annotations":[]}]}} + + event: response.completed + data: {"type":"response.completed","response":{"id":"resp_67d329fbc87c81919f8952fe71dafc96029dabe3ee19bb77","object":"response","created_at":1741892091,"status":"completed","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":20,"model":"gpt-4o-2024-08-06","output":[{"type":"message","id":"msg_67d329fc0c0081919696b8ab36713a41029dabe3ee19bb77","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hello!","annotations":[]}]}],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":true,"temperature":0.5,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"usage":{"input_tokens":26,"input_tokens_details":{"cached_tokens":0},"output_tokens":10,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":36},"user":null,"metadata":{}}} + + + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + List updates = []; + await foreach (var update in client.GetStreamingResponseAsync("hello", new() + { + MaxOutputTokens = 20, + Temperature = 0.5f, + ModelId = "gpt-4o", + })) + { + updates.Add(update); + } + + Assert.Equal("Hello!", string.Concat(updates.Select(u => u.Text))); + Assert.All(updates, u => Assert.Equal("gpt-4o-2024-08-06", u.ModelId)); + } + + [Fact] + public async Task ToolCallResult_SingleTextContent_SerializesCorrectly() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[ + { + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"test"}] + }, + { + "type":"function_call_output", + "call_id":"call_123", + "output":[{"type":"input_text","text":"Result text"}] + } + ] + } + """; + + const string Output = """ + { + "id":"resp_001", + "object":"response", + "created_at":1741892091, + "status":"completed", + "model":"gpt-4o-mini", + "output":[ + { + "type":"message", + "id":"msg_001", + "status":"completed", + "role":"assistant", + "content":[{"type":"output_text","text":"Done","annotations":[]}] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync([ + new ChatMessage(ChatRole.User, "test"), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("call_123", new TextContent("Result text"))]) + ]); + + Assert.NotNull(response); + Assert.Equal("Done", response.Text); + } + + [Fact] + public async Task ToolCallResult_MultipleTextContents_SerializesCorrectly() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[ + { + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"test"}] + }, + { + "type":"function_call_output", + "call_id":"call_456", + "output":[ + {"type":"input_text","text":"First part. "}, + {"type":"input_text","text":"Second part."} + ] + } + ] + } + """; + + const string Output = """ + { + "id":"resp_002", + "object":"response", + "created_at":1741892091, + "status":"completed", + "model":"gpt-4o-mini", + "output":[ + { + "type":"message", + "id":"msg_002", + "status":"completed", + "role":"assistant", + "content":[{"type":"output_text","text":"Processed","annotations":[]}] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync([ + new ChatMessage(ChatRole.User, "test"), + new ChatMessage(ChatRole.Tool, [ + new FunctionResultContent("call_456", new List + { + new TextContent("First part. "), + new TextContent("Second part.") + }) + ]) + ]); + + Assert.NotNull(response); + Assert.Equal("Processed", response.Text); + } + + [Fact] + public async Task ToolCallResult_DataContent_SerializesAsInputImage() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[ + { + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"test"}] + }, + { + "type":"function_call_output", + "call_id":"call_789", + "output":[ + {"type":"input_image","image_url":"data:image/png;base64,iVBORw0KGgo="} + ] + } + ] + } + """; + + const string Output = """ + { + "id":"resp_003", + "object":"response", + "created_at":1741892091, + "status":"completed", + "model":"gpt-4o-mini", + "output":[ + { + "type":"message", + "id":"msg_003", + "status":"completed", + "role":"assistant", + "content":[{"type":"output_text","text":"Image processed","annotations":[]}] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var imageData = Convert.FromBase64String("iVBORw0KGgo="); + var response = await client.GetResponseAsync([ + new ChatMessage(ChatRole.User, "test"), + new ChatMessage(ChatRole.Tool, [ + new FunctionResultContent("call_789", new DataContent(imageData, "image/png")) + ]) + ]); + + Assert.NotNull(response); + Assert.Equal("Image processed", response.Text); + } + + [Fact] + public async Task ToolCallResult_UriContent_SerializesAsInputImage() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[ + { + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"test"}] + }, + { + "type":"function_call_output", + "call_id":"call_uri", + "output":[ + {"type":"input_image","image_url":"https://example.com/image.png"} + ] + } + ] + } + """; + + const string Output = """ + { + "id":"resp_004", + "object":"response", + "created_at":1741892091, + "status":"completed", + "model":"gpt-4o-mini", + "output":[ + { + "type":"message", + "id":"msg_004", + "status":"completed", + "role":"assistant", + "content":[{"type":"output_text","text":"URI processed","annotations":[]}] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync([ + new ChatMessage(ChatRole.User, "test"), + new ChatMessage(ChatRole.Tool, [ + new FunctionResultContent("call_uri", new UriContent(new Uri("https://example.com/image.png"), "image/png")) + ]) + ]); + + Assert.NotNull(response); + Assert.Equal("URI processed", response.Text); + } + + [Fact] + public async Task ToolCallResult_HostedFileContent_SerializesCorrectly() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[ + { + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"test"}] + }, + { + "type":"function_call_output", + "call_id":"call_file", + "output":[ + {"type":"input_image","file_id":"file-abc123","filename":"result.png"} + ] + } + ] + } + """; + + const string Output = """ + { + "id":"resp_005", + "object":"response", + "created_at":1741892091, + "status":"completed", + "model":"gpt-4o-mini", + "output":[ + { + "type":"message", + "id":"msg_005", + "status":"completed", + "role":"assistant", + "content":[{"type":"output_text","text":"File processed","annotations":[]}] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync([ + new ChatMessage(ChatRole.User, "test"), + new ChatMessage(ChatRole.Tool, [ + new FunctionResultContent("call_file", new HostedFileContent("file-abc123") { MediaType = "image/png", Name = "result.png" }) + ]) + ]); + + Assert.NotNull(response); + Assert.Equal("File processed", response.Text); + } + + [Fact] + public async Task ToolCallResult_MixedContent_SerializesCorrectly() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[ + { + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"test"}] + }, + { + "type":"function_call_output", + "call_id":"call_mixed", + "output":[ + {"type":"input_text","text":"Text result: "}, + {"type":"input_image","image_url":"https://example.com/result.png"}, + {"type":"input_text","text":" - Image uploaded"} + ] + } + ] + } + """; + + const string Output = """ + { + "id":"resp_006", + "object":"response", + "created_at":1741892091, + "status":"completed", + "model":"gpt-4o-mini", + "output":[ + { + "type":"message", + "id":"msg_006", + "status":"completed", + "role":"assistant", + "content":[{"type":"output_text","text":"Mixed content processed","annotations":[]}] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync([ + new ChatMessage(ChatRole.User, "test"), + new ChatMessage(ChatRole.Tool, [ + new FunctionResultContent("call_mixed", new List + { + new TextContent("Text result: "), + new UriContent(new Uri("https://example.com/result.png"), "image/png"), + new TextContent(" - Image uploaded") + }) + ]) + ]); + + Assert.NotNull(response); + Assert.Equal("Mixed content processed", response.Text); + } + + [Fact] + public async Task ToolCallResult_DataContentPDF_SerializesAsInputFile() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[ + { + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"test"}] + }, + { + "type":"function_call_output", + "call_id":"call_pdf", + "output":[ + {"type":"input_file","file_data":"data:application/pdf;base64,cGRmZGF0YQ==","filename":"report.pdf"} + ] + } + ] + } + """; + + const string Output = """ + { + "id":"resp_007", + "object":"response", + "created_at":1741892091, + "status":"completed", + "model":"gpt-4o-mini", + "output":[ + { + "type":"message", + "id":"msg_007", + "status":"completed", + "role":"assistant", + "content":[{"type":"output_text","text":"PDF processed","annotations":[]}] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var pdfData = Encoding.UTF8.GetBytes("pdfdata"); + var response = await client.GetResponseAsync([ + new ChatMessage(ChatRole.User, "test"), + new ChatMessage(ChatRole.Tool, [ + new FunctionResultContent("call_pdf", new DataContent(pdfData, "application/pdf") { Name = "report.pdf" }) + ]) + ]); + + Assert.NotNull(response); + Assert.Equal("PDF processed", response.Text); + } + + [Fact] + public async Task ToolCallResult_ObjectSerialization_SerializesCorrectly() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[ + { + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"test"}] + }, + { + "type":"function_call_output", + "call_id":"call_obj", + "output":"{\"name\":\"John\",\"age\":30}" + } + ] + } + """; + + const string Output = """ + { + "id":"resp_obj", + "object":"response", + "created_at":1741892091, + "status":"completed", + "model":"gpt-4o-mini", + "output":[ + { + "type":"message", + "id":"msg_obj", + "status":"completed", + "role":"assistant", + "content":[{"type":"output_text","text":"Object processed","annotations":[]}] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync([ + new ChatMessage(ChatRole.User, "test"), + new ChatMessage(ChatRole.Tool, [ + new FunctionResultContent("call_obj", new { name = "John", age = 30 }) + ]) + ]); + + Assert.NotNull(response); + Assert.Equal("Object processed", response.Text); + } + + [Fact] + public async Task ToolCallResult_StringFallback_SerializesCorrectly() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[ + { + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"test"}] + }, + { + "type":"function_call_output", + "call_id":"call_string", + "output":"Simple string result" + } + ] + } + """; + + const string Output = """ + { + "id":"resp_008", + "object":"response", + "created_at":1741892091, + "status":"completed", + "model":"gpt-4o-mini", + "output":[ + { + "type":"message", + "id":"msg_008", + "status":"completed", + "role":"assistant", + "content":[{"type":"output_text","text":"String processed","annotations":[]}] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync([ + new ChatMessage(ChatRole.User, "test"), + new ChatMessage(ChatRole.Tool, [ + new FunctionResultContent("call_string", "Simple string result") + ]) + ]); + + Assert.NotNull(response); + Assert.Equal("String processed", response.Text); + } + + [Fact] + public async Task ToolCallResult_UriContentNonImage_SerializesAsInputFile() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[ + { + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"test"}] + }, + { + "type":"function_call_output", + "call_id":"call_file_uri", + "output":[ + {"type":"input_file","file_url":"https://example.com/document.pdf"} + ] + } + ] + } + """; + + const string Output = """ + { + "id":"resp_009", + "object":"response", + "created_at":1741892091, + "status":"completed", + "model":"gpt-4o-mini", + "output":[ + { + "type":"message", + "id":"msg_009", + "status":"completed", + "role":"assistant", + "content":[{"type":"output_text","text":"File URI processed","annotations":[]}] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync([ + new ChatMessage(ChatRole.User, "test"), + new ChatMessage(ChatRole.Tool, [ + new FunctionResultContent("call_file_uri", new UriContent(new Uri("https://example.com/document.pdf"), "application/pdf")) + ]) + ]); + + Assert.NotNull(response); + Assert.Equal("File URI processed", response.Text); + } + + [Fact] + public async Task ToolCallResult_HostedFileContentNonImage_SerializesAsInputFile() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[ + { + "type":"message", + "role":"user", + "content":[{"type":"input_text","text":"test"}] + }, + { + "type":"function_call_output", + "call_id":"call_hosted_file", + "output":[ + {"type":"input_file","file_id":"file-xyz789","filename":"document.txt"} + ] + } + ] + } + """; + + const string Output = """ + { + "id":"resp_010", + "object":"response", + "created_at":1741892091, + "status":"completed", + "model":"gpt-4o-mini", + "output":[ + { + "type":"message", + "id":"msg_010", + "status":"completed", + "role":"assistant", + "content":[{"type":"output_text","text":"Hosted file processed","annotations":[]}] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync([ + new ChatMessage(ChatRole.User, "test"), + new ChatMessage(ChatRole.Tool, [ + new FunctionResultContent("call_hosted_file", new HostedFileContent("file-xyz789") { MediaType = "text/plain", Name = "document.txt" }) + ]) + ]); + + Assert.NotNull(response); + Assert.Equal("Hosted file processed", response.Text); + } + + [Fact] + public async Task ResponseWithEndUserId_IncludesInAdditionalProperties() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"test"}]}] + } + """; + + const string Output = """ + { + "id":"resp_001", + "object":"response", + "created_at":1741892091, + "status":"completed", + "model":"gpt-4o-mini", + "output":[{"type":"message","id":"msg_001","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Done","annotations":[]}]}], + "user":"user_123" + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("test"); + + Assert.NotNull(response.AdditionalProperties); + Assert.Equal("user_123", response.AdditionalProperties["EndUserId"]); + } + + [Fact] + public async Task ResponseWithError_IncludesInAdditionalPropertiesAndMessage() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"test"}]}] + } + """; + + const string Output = """ + { + "id":"resp_001", + "object":"response", + "created_at":1741892091, + "status":"failed", + "model":"gpt-4o-mini", + "output":[{"type":"message","id":"msg_001","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Processing","annotations":[]}]}], + "error":{"code":"rate_limit_exceeded","message":"Rate limit exceeded"} + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("test"); + + Assert.NotNull(response.AdditionalProperties); + Assert.NotNull(response.AdditionalProperties["Error"]); + + var lastMessage = response.Messages.Last(); + var errorContent = lastMessage.Contents.OfType().FirstOrDefault(); + Assert.NotNull(errorContent); + Assert.Equal("Rate limit exceeded", errorContent.Message); + Assert.Equal("rate_limit_exceeded", errorContent.ErrorCode); + } + + [Fact] + public async Task ResponseWithUsageDetails_ParsesTokenCounts() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"test"}]}] + } + """; + + const string Output = """ + { + "id":"resp_001", + "object":"response", + "created_at":1741892091, + "status":"completed", + "model":"gpt-4o-mini", + "output":[{"type":"message","id":"msg_001","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Done","annotations":[]}]}], + "usage":{ + "input_tokens":50, + "input_tokens_details":{"cached_tokens":10}, + "output_tokens":25, + "output_tokens_details":{"reasoning_tokens":5}, + "total_tokens":75 + } + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("test"); + + Assert.NotNull(response.Usage); + Assert.Equal(50, response.Usage.InputTokenCount); + Assert.Equal(25, response.Usage.OutputTokenCount); + Assert.Equal(75, response.Usage.TotalTokenCount); + Assert.NotNull(response.Usage.AdditionalCounts); + Assert.Equal(10, response.Usage.AdditionalCounts["InputTokenDetails.CachedTokenCount"]); + Assert.Equal(5, response.Usage.AdditionalCounts["OutputTokenDetails.ReasoningTokenCount"]); + } + + [Fact] + public async Task UserMessageWithVariousContentTypes_ConvertsCorrectly() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[ + { + "type":"message", + "role":"user", + "content":[ + {"type":"input_text","text":"Check this image: "}, + {"type":"input_image","image_url":"https://example.com/image.png"}, + {"type":"input_image","image_url":"data:image/png;base64,iVBORw0KGgo="}, + {"type":"input_file","file_data":"data:application/pdf;base64,cGRmZGF0YQ==","filename":"doc.pdf"}, + {"type":"input_file","file_id":"file-123"}, + {"type":"refusal","refusal":"I cannot process this"} + ] + } + ] + } + """; + + const string Output = """ + { + "id":"resp_001", + "object":"response", + "created_at":1741892091, + "status":"completed", + "model":"gpt-4o-mini", + "output":[{"type":"message","id":"msg_001","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Done","annotations":[]}]}] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var imageData = Convert.FromBase64String("iVBORw0KGgo="); + var pdfData = Convert.FromBase64String("cGRmZGF0YQ=="); + + var response = await client.GetResponseAsync([ + new ChatMessage(ChatRole.User, [ + new TextContent("Check this image: "), + new UriContent(new Uri("https://example.com/image.png"), "image/png"), + new DataContent(imageData, "image/png"), + new DataContent(pdfData, "application/pdf") { Name = "doc.pdf" }, + new HostedFileContent("file-123"), + new ErrorContent("I cannot process this") { ErrorCode = "Refusal" } + ]) + ]); + + Assert.NotNull(response); + Assert.Equal("Done", response.Text); + } + + [Fact] + public async Task NonStreamingResponseWithIncompleteReason_MapsFinishReason() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"test"}]}] + } + """; + + const string Output = """ + { + "id":"resp_001", + "object":"response", + "created_at":1741892091, + "status":"incomplete", + "model":"gpt-4o-mini", + "output":[{"type":"message","id":"msg_001","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Partial","annotations":[]}]}], + "incomplete_details":{"reason":"max_output_tokens"} + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("test"); + + Assert.NotNull(response); + Assert.Equal("Partial", response.Text); + Assert.Equal(ChatFinishReason.Length, response.FinishReason); + } + + [Fact] + public async Task StreamingResponseWithQueuedUpdate_HandlesCorrectly() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"test"}]}], + "stream":true + } + """; + + const string Output = """ + event: response.created + data: {"type":"response.created","response":{"id":"resp_001","object":"response","created_at":1741892091,"status":"in_progress","model":"gpt-4o-mini","output":[]}} + + event: response.queued + data: {"type":"response.queued","response":{"id":"resp_001","object":"response","created_at":1741892091,"status":"queued","model":"gpt-4o-mini","output":[]}} + + event: response.completed + data: {"type":"response.completed","response":{"id":"resp_001","object":"response","created_at":1741892091,"status":"completed","model":"gpt-4o-mini","output":[{"type":"message","id":"msg_001","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Done","annotations":[]}]}]}} + + + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + List updates = []; + await foreach (var update in client.GetStreamingResponseAsync("test")) + { + updates.Add(update); + } + + Assert.True(updates.Count >= 3); + Assert.All(updates, u => Assert.Equal("resp_001", u.ResponseId)); + } + + [Fact] + public async Task StreamingResponseWithFailedUpdate_HandlesCorrectly() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"test"}]}], + "stream":true + } + """; + + const string Output = """ + event: response.created + data: {"type":"response.created","response":{"id":"resp_001","object":"response","created_at":1741892091,"status":"in_progress","model":"gpt-4o-mini","output":[]}} + + event: response.failed + data: {"type":"response.failed","response":{"id":"resp_001","object":"response","created_at":1741892091,"status":"failed","model":"gpt-4o-mini","output":[],"error":{"code":"internal_error","message":"Internal error"}}} + + + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + List updates = []; + await foreach (var update in client.GetStreamingResponseAsync("test")) + { + updates.Add(update); + } + + Assert.True(updates.Count >= 2); + Assert.All(updates, u => Assert.Equal("resp_001", u.ResponseId)); + } + + [Fact] + public async Task StreamingResponseWithIncompleteUpdate_HandlesCorrectly() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"test"}]}], + "stream":true + } + """; + + const string Output = """ + event: response.created + data: {"type":"response.created","response":{"id":"resp_001","object":"response","created_at":1741892091,"status":"in_progress","model":"gpt-4o-mini","output":[]}} + + event: response.incomplete + data: {"type":"response.incomplete","response":{"id":"resp_001","object":"response","created_at":1741892091,"status":"incomplete","model":"gpt-4o-mini","output":[],"incomplete_details":{"reason":"max_output_tokens"}}} + + + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + List updates = []; + await foreach (var update in client.GetStreamingResponseAsync("test")) + { + updates.Add(update); + } + + Assert.True(updates.Count >= 2); + Assert.All(updates, u => Assert.Equal("resp_001", u.ResponseId)); + } + + [Fact] + public async Task StreamingResponseWithInProgressUpdate_HandlesCorrectly() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"test"}]}], + "stream":true + } + """; + + const string Output = """ + event: response.created + data: {"type":"response.created","response":{"id":"resp_001","object":"response","created_at":1741892091,"status":"in_progress","model":"gpt-4o-mini","output":[]}} + + event: response.in_progress + data: {"type":"response.in_progress","response":{"id":"resp_001","object":"response","created_at":1741892091,"status":"in_progress","model":"gpt-4o-mini","output":[]}} + + event: response.completed + data: {"type":"response.completed","response":{"id":"resp_001","object":"response","created_at":1741892091,"status":"completed","model":"gpt-4o-mini","output":[{"type":"message","id":"msg_001","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Done","annotations":[]}]}]}} + + + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + List updates = []; + await foreach (var update in client.GetStreamingResponseAsync("test")) + { + updates.Add(update); + } + + Assert.True(updates.Count >= 3); + Assert.All(updates, u => Assert.Equal("resp_001", u.ResponseId)); + } + + [Fact] + public async Task StreamingResponseWithRefusalUpdate_HandlesCorrectly() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"test"}]}], + "stream":true + } + """; + + const string Output = """ + event: response.created + data: {"type":"response.created","response":{"id":"resp_001","object":"response","created_at":1741892091,"status":"in_progress","model":"gpt-4o-mini","output":[]}} + + event: response.refusal.done + data: {"type":"response.refusal.done","refusal":"I cannot provide that information"} + + event: response.completed + data: {"type":"response.completed","response":{"id":"resp_001","object":"response","created_at":1741892091,"status":"completed","model":"gpt-4o-mini","output":[]}} + + + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + List updates = []; + await foreach (var update in client.GetStreamingResponseAsync("test")) + { + updates.Add(update); + } + + var refusalUpdate = updates.FirstOrDefault(u => u.Contents.Any(c => c is ErrorContent ec && ec.ErrorCode == "Refusal")); + Assert.NotNull(refusalUpdate); + + var errorContent = refusalUpdate.Contents.OfType().First(); + Assert.Equal("I cannot provide that information", errorContent.Message); + Assert.Equal("Refusal", errorContent.ErrorCode); + } + + [Fact] + public async Task GetContinuationToken_WithMessages_ThrowsException() + { + using HttpClient httpClient = new(); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var token = new TestOpenAIResponsesContinuationToken("resp_123"); + + await Assert.ThrowsAsync(async () => + { + await client.GetResponseAsync( + [new ChatMessage(ChatRole.User, "test")], + new ChatOptions { ContinuationToken = token }); + }); + } + + [Fact] + public async Task StreamingResponseWithAnnotations_HandlesCorrectly() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"test"}]}], + "stream":true + } + """; + + const string Output = """ + event: response.created + data: {"type":"response.created","response":{"id":"resp_001","object":"response","created_at":1741892091,"status":"in_progress","model":"gpt-4o-mini","output":[]}} + + event: response.output_item.done + data: {"type":"response.output_item.done","response_id":"resp_001","output_index":0,"item":{"type":"message","id":"msg_001","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Annotated text","annotations":[{"type":"file_citation","file_id":"file_123","start_index":0,"end_index":14}]}]}} + + event: response.completed + data: {"type":"response.completed","response":{"id":"resp_001","object":"response","created_at":1741892091,"status":"completed","model":"gpt-4o-mini","output":[{"type":"message","id":"msg_001","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Annotated text","annotations":[{"type":"file_citation","file_id":"file_123","start_index":0,"end_index":14}]}]}]}} + + + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + List updates = []; + await foreach (var update in client.GetStreamingResponseAsync("test")) + { + updates.Add(update); + } + + var annotatedUpdate = updates.FirstOrDefault(u => u.Contents.Any(c => c.Annotations?.Count > 0)); + Assert.NotNull(annotatedUpdate); + Assert.NotEmpty(annotatedUpdate.Contents.First().Annotations!); + } + + [Fact] + public async Task UserMessageWithEmptyText_CreatesEmptyInputPart() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}] + } + """; + + const string Output = """ + { + "id":"resp_001", + "object":"response", + "created_at":1741892091, + "status":"completed", + "model":"gpt-4o-mini", + "output":[{"type":"message","id":"msg_001","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Ok","annotations":[]}]}] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "")]); + + Assert.NotNull(response); + Assert.Equal("Ok", response.Text); + } + + [Fact] + public async Task ResponseWithRefusalContent_ParsesCorrectly() + { + const string Input = """ + { + "model":"gpt-4o-mini", + "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"harmful request"}]}] + } + """; + + const string Output = """ + { + "id":"resp_001", + "object":"response", + "created_at":1741892091, + "status":"completed", + "model":"gpt-4o-mini", + "output":[ + { + "type":"message", + "id":"msg_001", + "status":"completed", + "role":"assistant", + "content":[ + {"type":"refusal","refusal":"I cannot help with that request"} + ] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("harmful request"); + + Assert.NotNull(response); + var errorContent = response.Messages.Last().Contents.OfType().FirstOrDefault(); + Assert.NotNull(errorContent); + Assert.Equal("I cannot help with that request", errorContent.Message); + Assert.Equal("Refusal", errorContent.ErrorCode); + } + + [Fact] + public async Task HostedImageGenerationTool_NonStreaming() + { + const string Input = """ + { + "model": "gpt-4o", + "tools": [ + { + "type": "image_generation", + "model": "gpt-image-1", + "size": "1024x1024", + "output_format": "png" + } + ], + "tool_choice": "auto", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "Generate an image of a cat" + } + ] + } + ] + } + """; + + const string Output = """ + { + "id": "resp_abc123", + "object": "response", + "created_at": 1741891428, + "status": "completed", + "model": "gpt-4o-2024-11-20", + "output": [ + { + "type": "image_generation_call", + "id": "img_call_abc123", + "result": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + } + ], + "usage": { + "input_tokens": 15, + "output_tokens": 0, + "total_tokens": 15 + } + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o"); + + var imageTool = new HostedImageGenerationTool + { + Options = new ImageGenerationOptions + { + ModelId = "gpt-image-1", + ImageSize = new(1024, 1024), + MediaType = "image/png" + } + }; + var response = await client.GetResponseAsync("Generate an image of a cat", new ChatOptions + { + Tools = [imageTool] + }); + + Assert.NotNull(response); + Assert.Single(response.Messages); + Assert.Equal(ChatRole.Assistant, response.Messages[0].Role); + + var contents = response.Messages[0].Contents; + Assert.Equal(2, contents.Count); + + // First content should be the tool call + var toolCall = contents[0] as ImageGenerationToolCallContent; + Assert.NotNull(toolCall); + Assert.Equal("img_call_abc123", toolCall.ImageId); + + // Second content should be the result with image data + var toolResult = contents[1] as ImageGenerationToolResultContent; + Assert.NotNull(toolResult); + Assert.Equal("img_call_abc123", toolResult.ImageId); + Assert.Single(toolResult.Outputs!); + + var imageData = toolResult.Outputs![0] as DataContent; + Assert.NotNull(imageData); + Assert.Equal("image/png", imageData.MediaType); + Assert.True(imageData.Data.Length > 0); + } + + [Fact] + public async Task HostedImageGenerationTool_Streaming() + { + const string Input = """ + { + "model": "gpt-4o", + "tools": [ + { + "type": "image_generation", + "model": "gpt-image-1", + "size": "1024x1024", + "output_format": "png" + } + ], + "tool_choice": "auto", + "stream": true, + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "Generate an image of a dog" + } + ] + } + ] + } + """; + + const string Output = """ + event: response.created + data: {"type":"response.created","response":{"id":"resp_def456","object":"response","created_at":1741892091,"status":"in_progress","model":"gpt-4o-2024-11-20","output":[],"tools":[{"type":"image_generation","image_generation":{"model":"gpt-image-1","size":{"width":1024,"height":1024},"output_format":"png"}}]}} + + event: response.in_progress + data: {"type":"response.in_progress","response":{"id":"resp_def456","object":"response","created_at":1741892091,"status":"in_progress","model":"gpt-4o-2024-11-20","output":[]}} + + event: response.output_item.added + data: {"type":"response.output_item.added","output_index":0,"item":{"type":"image_generation_call","id":"img_call_def456","status":"in_progress"}} + + event: response.image_generation_call.in_progress + data: {"type":"response.image_generation_call.in_progress","item_id":"img_call_def456","output_index":0} + + event: response.image_generation_call.generating + data: {"type":"response.image_generation_call.generating","item_id":"img_call_def456","output_index":0} + + event: response.image_generation_call.partial_image + data: {"type":"response.image_generation_call.partial_image","item_id":"img_call_def456","output_index":0,"partial_image_index":0,"partial_image_b64":"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="} + + event: response.output_item.done + data: {"type":"response.output_item.done","output_index":0,"item":{"type":"image_generation_call","id":"img_call_def456","image_result_b64":"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="}} + + event: response.completed + data: {"type":"response.completed","response":{"id":"resp_def456","object":"response","created_at":1741892091,"status":"completed","model":"gpt-4o-2024-11-20","output":[{"type":"image_generation_call","id":"img_call_def456","image_result_b64":"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="}],"usage":{"input_tokens":15,"output_tokens":0,"total_tokens":15}}} + + + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o"); + + List updates = []; + var imageTool = new HostedImageGenerationTool + { + Options = new ImageGenerationOptions + { + ModelId = "gpt-image-1", + ImageSize = new(1024, 1024), + MediaType = "image/png" + } + }; + await foreach (var update in client.GetStreamingResponseAsync("Generate an image of a dog", new ChatOptions + { + Tools = [imageTool] + })) + { + updates.Add(update); + } + + Assert.True(updates.Count >= 6); + + // Should have updates for: created, in_progress, tool call start, generating, partial image, completion + var createdUpdate = updates.First(u => u.CreatedAt.HasValue); + Assert.Equal("resp_def456", createdUpdate.ResponseId); + Assert.Equal("gpt-4o-2024-11-20", createdUpdate.ModelId); + + // Should have tool call content + var toolCallUpdate = updates.FirstOrDefault(u => + u.Contents != null && u.Contents.Any(c => c is ImageGenerationToolCallContent)); + Assert.NotNull(toolCallUpdate); + var toolCall = toolCallUpdate.Contents.OfType().First(); + Assert.Equal("img_call_def456", toolCall.ImageId); + + // Should have partial image content + var partialImageUpdate = updates.FirstOrDefault(u => + u.Contents != null && u.Contents.Any(c => c is ImageGenerationToolResultContent result && + result.Outputs != null && result.Outputs.Any(o => o.AdditionalProperties != null && o.AdditionalProperties.ContainsKey("PartialImageIndex")))); + Assert.NotNull(partialImageUpdate); + + // Should have final completion with usage + var completionUpdate = updates.FirstOrDefault(u => + u.Contents != null && u.Contents.Any(c => c is UsageContent)); + Assert.NotNull(completionUpdate); + var usage = completionUpdate.Contents.OfType().First(); + Assert.Equal(15, usage.Details.InputTokenCount); + Assert.Equal(0, usage.Details.OutputTokenCount); + Assert.Equal(15, usage.Details.TotalTokenCount); + } + + [Fact] + public async Task HostedImageGenerationTool_StreamingMultipleImages() + { + const string Input = """ + { + "model": "gpt-4o", + "tools": [ + { + "type": "image_generation", + "model": "gpt-image-1", + "size": "512x512", + "output_format": "webp", + "partial_images": 3 + } + ], + "tool_choice": "auto", + "stream": true, + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "Generate an image of a sunset" + } + ] + } + ] + } + """; + + const string Output = """ + event: response.created + data: {"type":"response.created","response":{"id":"resp_ghi789","object":"response","created_at":1741892091,"status":"in_progress","model":"gpt-4o-2024-11-20","output":[],"tools":[{"type":"image_generation","image_generation":{"model":"gpt-image-1","size":{"width":512,"height":512},"output_format":"webp","partial_images":3}}]}} + + event: response.in_progress + data: {"type":"response.in_progress","response":{"id":"resp_ghi789","object":"response","created_at":1741892091,"status":"in_progress","model":"gpt-4o-2024-11-20","output":[]}} + + event: response.output_item.added + data: {"type":"response.output_item.added","output_index":0,"item":{"type":"image_generation_call","id":"img_call_ghi789","status":"in_progress"}} + + event: response.image_generation_call.in_progress + data: {"type":"response.image_generation_call.in_progress","item_id":"img_call_ghi789","output_index":0} + + event: response.image_generation_call.generating + data: {"type":"response.image_generation_call.generating","item_id":"img_call_ghi789","output_index":0} + + event: response.image_generation_call.partial_image + data: {"type":"response.image_generation_call.partial_image","item_id":"img_call_ghi789","output_index":0,"partial_image_index":0,"partial_image_b64":"SGVsbG8x"} + + event: response.image_generation_call.partial_image + data: {"type":"response.image_generation_call.partial_image","item_id":"img_call_ghi789","output_index":0,"partial_image_index":1,"partial_image_b64":"SGVsbG8y"} + + event: response.image_generation_call.partial_image + data: {"type":"response.image_generation_call.partial_image","item_id":"img_call_ghi789","output_index":0,"partial_image_index":2,"partial_image_b64":"SGVsbG8z"} + + event: response.output_item.done + data: {"type":"response.output_item.done","output_index":0,"item":{"type":"image_generation_call","id":"img_call_ghi789","image_result_b64":"SGVsbG8z"}} + + event: response.completed + data: {"type":"response.completed","response":{"id":"resp_ghi789","object":"response","created_at":1741892091,"status":"completed","model":"gpt-4o-2024-11-20","output":[{"type":"image_generation_call","id":"img_call_ghi789","image_result_b64":"SGVsbG8z"}],"usage":{"input_tokens":18,"output_tokens":0,"total_tokens":18}}} + + + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o"); + + List updates = []; + var imageTool = new HostedImageGenerationTool + { + Options = new ImageGenerationOptions + { + ModelId = "gpt-image-1", + ImageSize = new(512, 512), + MediaType = "image/webp", + StreamingCount = 3 + } + }; + await foreach (var update in client.GetStreamingResponseAsync("Generate an image of a sunset", new ChatOptions + { + Tools = [imageTool] + })) + { + updates.Add(update); + } + + Assert.True(updates.Count >= 8); // Should have multiple partial image updates plus generating event + + // Should have multiple partial images with different indices + var partialImageUpdates = updates.Where(u => + u.Contents != null && u.Contents.Any(c => c is ImageGenerationToolResultContent result && + result.Outputs != null && result.Outputs.Any(o => o.AdditionalProperties != null && o.AdditionalProperties.ContainsKey("PartialImageIndex")))).ToList(); + + Assert.True(partialImageUpdates.Count >= 3); + + // Verify partial images have correct indices and WebP format + for (int i = 0; i < 3; i++) + { + var partialUpdate = partialImageUpdates.FirstOrDefault(u => + u.Contents.OfType().Any(result => + HasPartialImageWithIndex(result, i))); + Assert.NotNull(partialUpdate); + } + + static bool HasPartialImageWithIndex(ImageGenerationToolResultContent result, int index) + { + if (result.Outputs == null) + { + return false; + } + + return result.Outputs.Any(o => HasCorrectImageData(o, index)); + } + + static bool HasCorrectImageData(AIContent o, int index) + { + if (o.AdditionalProperties == null) + { + return false; + } + + if (!o.AdditionalProperties.TryGetValue("PartialImageIndex", out var imageIndex)) + { + return false; + } + + if (imageIndex == null || !imageIndex.Equals(index)) + { + return false; + } + + return o is DataContent dataContent && dataContent.MediaType == "image/webp"; + } + + // Verify tool call uses correct settings + var toolCallUpdate = updates.FirstOrDefault(u => + u.Contents != null && u.Contents.Any(c => c is ImageGenerationToolCallContent)); + Assert.NotNull(toolCallUpdate); + var toolCall = toolCallUpdate.Contents.OfType().First(); + Assert.Equal("img_call_ghi789", toolCall.ImageId); + } + + private static IChatClient CreateResponseClient(HttpClient httpClient, string modelId) => + new OpenAIClient( + new ApiKeyCredential("apikey"), + new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(httpClient) }) + .GetOpenAIResponseClient(modelId) + .AsIChatClient(); + + private static string ResponseStatusToRequestValue(ResponseStatus status) + { + if (status == ResponseStatus.InProgress) + { + return "in_progress"; + } + + return status.ToString().ToLowerInvariant(); + } + + private sealed class TestOpenAIResponsesContinuationToken : ResponseContinuationToken + { + internal TestOpenAIResponsesContinuationToken(string responseId) + { + ResponseId = responseId; + } + + /// Gets or sets the Id of the response. + internal string ResponseId { get; set; } + + /// Gets or sets the sequence number of a streamed update. + internal int? SequenceNumber { get; set; } + + internal static TestOpenAIResponsesContinuationToken FromToken(object token) + { + if (token is TestOpenAIResponsesContinuationToken testOpenAIResponsesContinuationToken) + { + return testOpenAIResponsesContinuationToken; + } + + if (token is not ResponseContinuationToken) + { + throw new ArgumentException("Failed to create OpenAIResponsesResumptionToken from provided token because it is not of type ResponseContinuationToken.", nameof(token)); + } + + ReadOnlyMemory data = ((ResponseContinuationToken)token).ToBytes(); + + Utf8JsonReader reader = new(data.Span); + + string responseId = null!; + int? startAfter = null; + + _ = reader.Read(); + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + break; + } + + string propertyName = reader.GetString()!; + + switch (propertyName) + { + case "responseId": + _ = reader.Read(); + responseId = reader.GetString()!; + break; + case "sequenceNumber": + _ = reader.Read(); + startAfter = reader.GetInt32(); + break; + default: + throw new JsonException($"Unrecognized property '{propertyName}'."); + } + } + + return new(responseId) + { + SequenceNumber = startAfter + }; + } + + public override ReadOnlyMemory ToBytes() + { + using MemoryStream stream = new(); + using Utf8JsonWriter writer = new(stream); + + writer.WriteStartObject(); + + writer.WriteString("responseId", ResponseId); + + if (SequenceNumber.HasValue) + { + writer.WriteNumber("sequenceNumber", SequenceNumber.Value); + } + + writer.WriteEndObject(); + + writer.Flush(); + stream.Position = 0; + + return stream.ToArray(); + } + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientTests.cs index 1252a20741b..2ba70995a86 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientTests.cs @@ -68,7 +68,7 @@ public async Task GetTextAsync_BasicRequestResponse(string? speechLanguage, stri { string input = $$""" { - "model": "whisper-1", + "model": "gpt-4o-transcribe", "language": "{{speechLanguage}}" } """; @@ -81,7 +81,7 @@ public async Task GetTextAsync_BasicRequestResponse(string? speechLanguage, stri using VerbatimMultiPartHttpHandler handler = new(input, Output) { ExpectedRequestUriContains = "audio/transcriptions" }; using HttpClient httpClient = new(handler); - using ISpeechToTextClient client = CreateSpeechToTextClient(httpClient, "whisper-1"); + using ISpeechToTextClient client = CreateSpeechToTextClient(httpClient, "gpt-4o-transcribe"); using var audioSpeechStream = GetAudioStream(); var response = await client.GetTextAsync(audioSpeechStream, new SpeechToTextOptions @@ -102,7 +102,7 @@ public async Task GetTextAsync_BasicRequestResponse(string? speechLanguage, stri public async Task GetTextAsync_Cancelled_Throws() { using HttpClient httpClient = new(); - using ISpeechToTextClient client = CreateSpeechToTextClient(httpClient, "whisper-1"); + using ISpeechToTextClient client = CreateSpeechToTextClient(httpClient, "gpt-4o-transcribe"); using var fileStream = GetAudioStream(); using var cancellationTokenSource = new CancellationTokenSource(); @@ -116,7 +116,7 @@ await Assert.ThrowsAsync(() public async Task GetStreamingTextAsync_Cancelled_Throws() { using HttpClient httpClient = new(); - using ISpeechToTextClient client = CreateSpeechToTextClient(httpClient, "whisper-1"); + using ISpeechToTextClient client = CreateSpeechToTextClient(httpClient, "gpt-4o-transcribe"); using var fileStream = GetAudioStream(); using var cancellationTokenSource = new CancellationTokenSource(); @@ -142,7 +142,7 @@ public async Task GetStreamingTextAsync_BasicRequestResponse(string? speechLangu string input = $$""" { - "model": "whisper-1", + "model": "gpt-4o-transcribe", "language": "{{speechLanguage}}", "stream":true } @@ -156,7 +156,7 @@ public async Task GetStreamingTextAsync_BasicRequestResponse(string? speechLangu using VerbatimMultiPartHttpHandler handler = new(input, Output) { ExpectedRequestUriContains = "audio/transcriptions" }; using HttpClient httpClient = new(handler); - using ISpeechToTextClient client = CreateSpeechToTextClient(httpClient, "whisper-1"); + using ISpeechToTextClient client = CreateSpeechToTextClient(httpClient, "gpt-4o-transcribe"); using var audioSpeechStream = GetAudioStream(); await foreach (var update in client.GetStreamingTextAsync(audioSpeechStream, new SpeechToTextOptions @@ -167,7 +167,7 @@ public async Task GetStreamingTextAsync_BasicRequestResponse(string? speechLangu { Assert.Contains("I finally got back to the gym the other day", update.Text); Assert.NotNull(update.RawRepresentation); - Assert.IsType(update.RawRepresentation); + Assert.IsType(update.RawRepresentation); } } @@ -179,7 +179,7 @@ public async Task GetStreamingTextAsync_BasicTranslateRequestResponse() // There's no support for non english translations, so no language is passed to the API. const string Input = $$""" { - "model": "whisper-1" + "model": "gpt-4o-transcribe" } """; @@ -191,7 +191,7 @@ public async Task GetStreamingTextAsync_BasicTranslateRequestResponse() using VerbatimMultiPartHttpHandler handler = new(Input, Output) { ExpectedRequestUriContains = "audio/translations" }; using HttpClient httpClient = new(handler); - using ISpeechToTextClient client = CreateSpeechToTextClient(httpClient, "whisper-1"); + using ISpeechToTextClient client = CreateSpeechToTextClient(httpClient, "gpt-4o-transcribe"); using var audioSpeechStream = GetAudioStream(); await foreach (var update in client.GetStreamingTextAsync(audioSpeechStream, new SpeechToTextOptions @@ -211,7 +211,7 @@ public async Task GetTextAsync_Transcription_StronglyTypedOptions_AllSent() { const string Input = """ { - "model": "whisper-1", + "model": "gpt-4o-transcribe", "language": "pt", "prompt":"Hide any bad words with ", "temperature": 0.5, @@ -228,7 +228,7 @@ public async Task GetTextAsync_Transcription_StronglyTypedOptions_AllSent() using VerbatimMultiPartHttpHandler handler = new(Input, Output); using HttpClient httpClient = new(handler); - using ISpeechToTextClient client = CreateSpeechToTextClient(httpClient, "whisper-1"); + using ISpeechToTextClient client = CreateSpeechToTextClient(httpClient, "gpt-4o-transcribe"); using var audioSpeechStream = GetAudioStream(); Assert.NotNull(await client.GetTextAsync(audioSpeechStream, new() @@ -251,7 +251,7 @@ public async Task GetTextAsync_Translation_StronglyTypedOptions_AllSent() { const string Input = """ { - "model": "whisper-1", + "model": "gpt-4o-transcribe", "prompt":"Hide any bad words with ", "response_format": "vtt" } @@ -265,7 +265,7 @@ public async Task GetTextAsync_Translation_StronglyTypedOptions_AllSent() using VerbatimMultiPartHttpHandler handler = new(Input, Output); using HttpClient httpClient = new(handler); - using ISpeechToTextClient client = CreateSpeechToTextClient(httpClient, "whisper-1"); + using ISpeechToTextClient client = CreateSpeechToTextClient(httpClient, "gpt-4o-transcribe"); using var audioSpeechStream = GetAudioStream(); Assert.NotNull(await client.GetTextAsync(audioSpeechStream, new() diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/ThrowUserAgentExceptionHandler.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/ThrowUserAgentExceptionHandler.cs new file mode 100644 index 00000000000..6b4b0fa0f1c --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/ThrowUserAgentExceptionHandler.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.AI; + +internal sealed class ThrowUserAgentExceptionHandler : HttpMessageHandler +{ + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + throw new InvalidOperationException($"User-Agent header: {request.Headers.UserAgent}"); +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs index edd22edc41e..1f7e7f5084a 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ChatClientStructuredOutputExtensionsTests.cs @@ -37,34 +37,28 @@ public async Task SuccessUsage_Default() Assert.NotNull(responseFormat.Schema); AssertDeepEquals(JsonDocument.Parse(""" { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "description": "Some test description", - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "fullName": { - "type": [ - "string", - "null" - ] - }, - "species": { - "type": "string", - "enum": [ - "Bear", - "Tiger", - "Walrus" - ] + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Some test description", + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "fullName": { + "type": [ + "string", + "null" + ] + }, + "species": { + "type": "string", + "enum": [ + "Bear", + "Tiger", + "Walrus" + ] + } } - }, - "additionalProperties": false, - "required": [ - "id", - "fullName", - "species" - ] } """).RootElement, responseFormat.Schema.Value); Assert.Equal(nameof(Animal), responseFormat.SchemaName); @@ -195,6 +189,50 @@ public async Task WrapsNonObjectValuesInDataProperty() Assert.Equal(123, response.Result); } + [Fact] + public async Task OnlyUsesLastMessage() + { + var expectedResult = new Envelope { data = 123 }; + var expectedResponse = new ChatResponse( + [ + new ChatMessage(ChatRole.Assistant, + [ + new TextContent("I'm going to invoke a function to get the data."), + new FunctionCallContent("callid123", "get_data"), + ]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callid123", "result")]), + new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult, JsonContext2.Default.Options)) + ]); + + using var client = new TestChatClient + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + var responseFormat = Assert.IsType(options!.ResponseFormat); + 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, + "required": [ + "data" + ] + } + """, responseFormat.Schema.ToString()); + return Task.FromResult(expectedResponse); + }, + }; + + var response = await client.GetResponseAsync("Hello"); + Assert.Equal(123, response.Result); + } + [Fact] public async Task FailureUsage_InvalidJson() { @@ -336,29 +374,23 @@ public async Task CanSpecifyCustomJsonSerializationOptions() Assert.NotNull(responseFormat.Schema); AssertDeepEquals(JsonDocument.Parse(""" { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "description": "Some test description", - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "full_name": { - "type": [ - "string", - "null" - ] - }, - "species": { - "type": "integer" + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Some test description", + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "full_name": { + "type": [ + "string", + "null" + ] + }, + "species": { + "type": "integer" + } } - }, - "additionalProperties": false, - "required": [ - "id", - "full_name", - "species" - ] } """).RootElement, responseFormat.Schema.Value); @@ -450,12 +482,6 @@ Elements are not equal. private static bool DeepEquals(JsonElement element1, JsonElement element2) { -#if NET9_0_OR_GREATER return JsonElement.DeepEquals(element1, element2); -#else - return System.Text.Json.Nodes.JsonNode.DeepEquals( - JsonSerializer.SerializeToNode(element1, AIJsonUtilities.DefaultOptions), - JsonSerializer.SerializeToNode(element2, AIJsonUtilities.DefaultOptions)); -#endif } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs new file mode 100644 index 00000000000..7c42c0edaf9 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs @@ -0,0 +1,1004 @@ +// Licensed 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 Xunit; + +namespace Microsoft.Extensions.AI.ChatCompletion; + +public class FunctionInvokingChatClientApprovalsTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task AllFunctionCallsReplacedWithApprovalsWhenAllRequireApprovalAsync(bool useAdditionalTools) + { + AITool[] tools = + [ + new ApprovalRequiredAIFunction( + AIFunctionFactory.Create(() => "Result 1", "Func1")), + new ApprovalRequiredAIFunction( + AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2")), + ]; + + var options = new ChatOptions + { + Tools = useAdditionalTools ? null : tools + }; + + List input = + [ + new ChatMessage(ChatRole.User, "hello"), + ]; + + List downstreamClientOutput = + [ + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + ]; + + List expectedOutput = + [ + new ChatMessage(ChatRole.Assistant, + [ + new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + ]) + ]; + + await InvokeAndAssertAsync(options, input, downstreamClientOutput, expectedOutput, additionalTools: useAdditionalTools ? tools : null); + + await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, expectedOutput, additionalTools: useAdditionalTools ? tools : null); + } + + [Fact] + public async Task AllFunctionCallsReplacedWithApprovalsWhenAnyRequireApprovalAsync() + { + var options = new ChatOptions + { + Tools = + [ + new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")), + AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), + ] + }; + + List input = + [ + new ChatMessage(ChatRole.User, "hello"), + ]; + + List downstreamClientOutput = + [ + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + ]; + + List expectedOutput = + [ + new ChatMessage(ChatRole.Assistant, + [ + new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + ]) + ]; + + await InvokeAndAssertAsync(options, input, downstreamClientOutput, expectedOutput); + + await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, expectedOutput); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task AllFunctionCallsReplacedWithApprovalsWhenAnyRequestOrAdditionalRequireApprovalAsync(bool additionalToolsRequireApproval) + { + AIFunction func1 = AIFunctionFactory.Create(() => "Result 1", "Func1"); + AIFunction func2 = AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"); + AITool[] additionalTools = + [ + additionalToolsRequireApproval ? new ApprovalRequiredAIFunction(func1) : func1, + ]; + + var options = new ChatOptions + { + Tools = + [ + additionalToolsRequireApproval ? func2 : new ApprovalRequiredAIFunction(func2), + ] + }; + + List input = + [ + new ChatMessage(ChatRole.User, "hello"), + ]; + + List downstreamClientOutput = + [ + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + ]; + + List expectedOutput = + [ + new ChatMessage(ChatRole.Assistant, + [ + new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + ]) + ]; + + await InvokeAndAssertAsync(options, input, downstreamClientOutput, expectedOutput, additionalTools: additionalTools); + + await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, expectedOutput, additionalTools: additionalTools); + } + + [Fact] + public async Task ApprovedApprovalResponsesAreExecutedAsync() + { + var options = new ChatOptions + { + Tools = + [ + new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")), + AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), + ] + }; + + List input = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, + [ + new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + ]) { MessageId = "resp1" }, + new ChatMessage(ChatRole.User, + [ + new FunctionApprovalResponseContent("callId1", true, new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + ]), + ]; + + List expectedDownstreamClientInput = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]), + ]; + + List downstreamClientOutput = + [ + new ChatMessage(ChatRole.Assistant, "world"), + ]; + + List output = + [ + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]), + new ChatMessage(ChatRole.Assistant, "world"), + ]; + + await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); + + await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); + } + + [Fact] + public async Task ApprovedApprovalResponsesFromSeparateFCCMessagesAreExecutedAsync() + { + var options = new ChatOptions + { + Tools = + [ + new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")), + AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), + ] + }; + + List input = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, + [ + new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")), + ]) { MessageId = "resp1" }, + new ChatMessage(ChatRole.Assistant, + [ + new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + ]) { MessageId = "resp2" }, + new ChatMessage(ChatRole.User, + [ + new FunctionApprovalResponseContent("callId1", true, new FunctionCallContent("callId1", "Func1")), + ]), + new ChatMessage(ChatRole.User, + [ + new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + ]), + ]; + + List expectedDownstreamClientInput = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]) { MessageId = "resp1" }, + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]) { MessageId = "resp2" }, + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]), + ]; + + List downstreamClientOutput = + [ + new ChatMessage(ChatRole.Assistant, "world"), + ]; + + List output = + [ + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]) { MessageId = "resp1" }, + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]) { MessageId = "resp2" }, + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]), + new ChatMessage(ChatRole.Assistant, "world"), + ]; + + await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); + + await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); + } + + [Fact] + public async Task RejectedApprovalResponsesAreFailedAsync() + { + var options = new ChatOptions + { + Tools = + [ + new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")), + AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), + ] + }; + + List input = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, + [ + new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + ]) { MessageId = "resp1" }, + new ChatMessage(ChatRole.User, + [ + new FunctionApprovalResponseContent("callId1", false, new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalResponseContent("callId2", false, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + ]), + ]; + + List expectedDownstreamClientInput = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, + [ + new FunctionResultContent("callId1", result: "Error: Tool call invocation was rejected by user."), + new FunctionResultContent("callId2", result: "Error: Tool call invocation was rejected by user.") + ]), + ]; + + List downstreamClientOutput = + [ + new ChatMessage(ChatRole.Assistant, "world"), + ]; + + List output = + [ + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, + [ + new FunctionResultContent("callId1", result: "Error: Tool call invocation was rejected by user."), + new FunctionResultContent("callId2", result: "Error: Tool call invocation was rejected by user.") + ]), + new ChatMessage(ChatRole.Assistant, "world"), + ]; + + await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); + + await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); + } + + [Fact] + public async Task MixedApprovedAndRejectedApprovalResponsesAreExecutedAndFailedAsync() + { + var options = new ChatOptions + { + Tools = + [ + new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")), + AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), + ] + }; + + List input = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, + [ + new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + ]) { MessageId = "resp1" }, + new ChatMessage(ChatRole.User, + [ + new FunctionApprovalResponseContent("callId1", false, new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + ]), + ]; + + List expectedDownstreamClientInput = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Error: Tool call invocation was rejected by user.")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", result: "Result 2: 42")]), + ]; + + List downstreamClientOutput = + [ + new ChatMessage(ChatRole.Assistant, "world"), + ]; + + List nonStreamingOutput = + [ + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Error: Tool call invocation was rejected by user.")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", result: "Result 2: 42")]), + new ChatMessage(ChatRole.Assistant, "world"), + ]; + + List streamingOutput = + [ + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, + [ + new FunctionResultContent("callId1", result: "Error: Tool call invocation was rejected by user."), + new FunctionResultContent("callId2", result: "Result 2: 42") + ]), + new ChatMessage(ChatRole.Assistant, "world"), + ]; + + await InvokeAndAssertAsync(options, input, downstreamClientOutput, nonStreamingOutput, expectedDownstreamClientInput); + + await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, streamingOutput, expectedDownstreamClientInput); + } + + [Fact] + public async Task ApprovedInputsAreExecutedAndFunctionResultsAreConvertedAsync() + { + var options = new ChatOptions + { + Tools = + [ + AIFunctionFactory.Create(() => "Result 1", "Func1"), + new ApprovalRequiredAIFunction(AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2")), + ] + }; + + List input = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, + [ + new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + ]) { MessageId = "resp1" }, + new ChatMessage(ChatRole.User, + [ + new FunctionApprovalResponseContent("callId1", true, new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + ]), + ]; + + List expectedDownstreamClientInput = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]), + ]; + + List downstreamClientOutput = + [ + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 3 } })]), + ]; + + List output = + [ + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]), + new ChatMessage(ChatRole.Assistant, + [ + new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 3 } })) + ]), + ]; + + await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); + + await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); + } + + [Fact] + public async Task AlreadyExecutedApprovalsAreIgnoredAsync() + { + var options = new ChatOptions + { + Tools = + [ + AIFunctionFactory.Create(() => "Result 1", "Func1"), + new ApprovalRequiredAIFunction(AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2")), + ] + }; + + List input = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, + [ + new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + ]) { MessageId = "resp1" }, + new ChatMessage(ChatRole.User, + [ + new FunctionApprovalResponseContent("callId1", true, new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + ]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]), + new ChatMessage(ChatRole.Assistant, + [ + new FunctionApprovalRequestContent("callId3", new FunctionCallContent("callId3", "Func1")), + ]) { MessageId = "resp2" }, + new ChatMessage(ChatRole.User, + [ + new FunctionApprovalResponseContent("callId3", true, new FunctionCallContent("callId3", "Func1")), + ]), + ]; + + List expectedDownstreamClientInput = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId3", "Func1")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId3", result: "Result 1")]), + ]; + + List downstreamClientOutput = + [ + new ChatMessage(ChatRole.Assistant, "World"), + ]; + + List output = + [ + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId3", "Func1")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId3", result: "Result 1")]), + new ChatMessage(ChatRole.Assistant, "World"), + ]; + + await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); + + await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); + } + + /// + /// This verifies the following scenario: + /// 1. We are streaming (also including non-streaming in the test for completeness). + /// 2. There is one function that requires approval and one that does not. + /// 3. We only get back FCC for the function that does not require approval. + /// 4. This means that once we receive this FCC, we need to buffer all updates until the end, because we might receive more FCCs and some may require approval. + /// 5. We then need to verify that we will still stream all updates once we reach the end, including the buffered FCC. + /// + [Fact] + public async Task MixedApprovalRequiredToolsWithNonApprovalRequiringFunctionCallAsync() + { + var options = new ChatOptions + { + Tools = + [ + new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")), + AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), + ] + }; + + List input = + [ + new ChatMessage(ChatRole.User, "hello"), + ]; + + Func>> expectedDownstreamClientInput = () => new Queue>( + [ + new List + { + new ChatMessage(ChatRole.User, "hello"), + }, + new List + { + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", result: "Result 2: 42")]) + } + ]); + + Func>> downstreamClientOutput = () => new Queue>( + [ + new List + { + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + }, + new List + { + new ChatMessage(ChatRole.Assistant, "World again"), + } + ]); + + List output = + [ + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", result: "Result 2: 42")]), + new ChatMessage(ChatRole.Assistant, "World again"), + ]; + + await InvokeAndAssertMultiRoundAsync(options, input, downstreamClientOutput(), output, expectedDownstreamClientInput()); + + await InvokeAndAssertStreamingMultiRoundAsync(options, input, downstreamClientOutput(), output, expectedDownstreamClientInput()); + } + + [Fact] + public async Task ApprovalRequestWithoutApprovalResponseThrowsAsync() + { + var options = new ChatOptions + { + Tools = + [ + new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")), + AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), + ] + }; + + List input = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, + [ + new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")), + ]) { MessageId = "resp1" }, + ]; + + var invokeException = await Assert.ThrowsAsync( + async () => await InvokeAndAssertAsync(options, input, [], [], [])); + Assert.Equal("FunctionApprovalRequestContent found with FunctionCall.CallId(s) 'callId1' that have no matching FunctionApprovalResponseContent.", invokeException.Message); + + var invokeStreamingException = await Assert.ThrowsAsync( + async () => await InvokeAndAssertStreamingAsync(options, input, [], [], [])); + Assert.Equal("FunctionApprovalRequestContent found with FunctionCall.CallId(s) 'callId1' that have no matching FunctionApprovalResponseContent.", invokeStreamingException.Message); + } + + [Fact] + public async Task ApprovedApprovalResponsesWithoutApprovalRequestAreExecutedAsync() + { + var options = new ChatOptions + { + Tools = + [ + new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")), + AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), + ] + }; + + List input = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.User, + [ + new FunctionApprovalResponseContent("callId1", true, new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + ]), + ]; + + List expectedDownstreamClientInput = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]), + ]; + + List downstreamClientOutput = + [ + new ChatMessage(ChatRole.Assistant, "world"), + ]; + + List output = + [ + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]), + new ChatMessage(ChatRole.Assistant, "world"), + ]; + + await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); + + await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); + } + + [Fact] + public async Task FunctionCallContentIsNotPassedToDownstreamServiceWithServiceThreadsAsync() + { + var options = new ChatOptions + { + Tools = + [ + new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func1")), + AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), + ], + ConversationId = "test-conversation", + }; + + List input = + [ + new ChatMessage(ChatRole.User, + [ + new FunctionApprovalResponseContent("callId1", true, new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + ]), + ]; + + List expectedDownstreamClientInput = + [ + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]), + ]; + + List downstreamClientOutput = + [ + new ChatMessage(ChatRole.Assistant, "world"), + ]; + + List output = + [ + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]), + new ChatMessage(ChatRole.Assistant, "world"), + ]; + + await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); + + await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput); + } + + /// + /// Since we do not have a way of supporting both functions that require approval and those that do not + /// in one invocation, we always require all function calls to be approved if any require approval. + /// If we are therefore unsure as to whether we will encounter a function call that requires approval, + /// we have to wait until we find one before yielding any function call content. + /// If we don't have any function calls that require approval at all though, we can just yield all content normally + /// since this issue won't apply. + /// + [Fact] + public async Task FunctionCallContentIsYieldedImmediatelyIfNoApprovalRequiredWhenStreamingAsync() + { + var options = new ChatOptions + { + Tools = + [ + AIFunctionFactory.Create(() => "Result 1", "Func1"), + AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), + ] + }; + + List input = [new ChatMessage(ChatRole.User, "hello")]; + + Func configurePipeline = b => b.Use(s => new FunctionInvokingChatClient(s)); + using CancellationTokenSource cts = new(); + + var updateYieldCount = 0; + + async IAsyncEnumerable YieldInnerClientUpdates( + IEnumerable contents, ChatOptions? actualOptions, [EnumeratorCancellation] CancellationToken actualCancellationToken) + { + Assert.Equal(cts.Token, actualCancellationToken); + await Task.Yield(); + var messageId = Guid.NewGuid().ToString("N"); + + updateYieldCount++; + yield return new ChatResponseUpdate(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]) { MessageId = messageId }; + updateYieldCount++; + yield return + new ChatResponseUpdate( + ChatRole.Assistant, + [ + new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } }) + ]) + { MessageId = messageId }; + } + + using var innerClient = new TestChatClient { GetStreamingResponseAsyncCallback = YieldInnerClientUpdates }; + IChatClient service = configurePipeline(innerClient.AsBuilder()).Build(); + + var updates = service.GetStreamingResponseAsync(new EnumeratedOnceEnumerable(input), options, cts.Token); + + var updateCount = 0; + await foreach (var update in updates) + { + if (updateCount < 2) + { + var functionCall = update.Contents.OfType().First(); + if (functionCall.CallId == "callId1") + { + Assert.Equal("Func1", functionCall.Name); + Assert.Equal(1, updateYieldCount); + } + else if (functionCall.CallId == "callId2") + { + Assert.Equal("Func2", functionCall.Name); + Assert.Equal(2, updateYieldCount); + } + } + + updateCount++; + } + } + + /// + /// Since we do not have a way of supporting both functions that require approval and those that do not + /// in one invocation, we always require all function calls to be approved if any require approval. + /// If we are therefore unsure as to whether we will encounter a function call that requires approval, + /// we have to wait until we find one before yielding any function call content. + /// We can however, yield any other content until we encounter the first function call. + /// + [Fact] + public async Task FunctionCalsAreBufferedUntilApprovalRequirementEncounteredWhenStreamingAsync() + { + var options = new ChatOptions + { + Tools = + [ + AIFunctionFactory.Create(() => "Result 1", "Func1"), + new ApprovalRequiredAIFunction(AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2")), + AIFunctionFactory.Create(() => "Result 3", "Func3"), + ] + }; + + List input = [new ChatMessage(ChatRole.User, "hello")]; + + Func configurePipeline = b => b.Use(s => new FunctionInvokingChatClient(s)); + using CancellationTokenSource cts = new(); + + var updateYieldCount = 0; + + async IAsyncEnumerable YieldInnerClientUpdates( + IEnumerable contents, ChatOptions? actualOptions, [EnumeratorCancellation] CancellationToken actualCancellationToken) + { + Assert.Equal(cts.Token, actualCancellationToken); + await Task.Yield(); + var messageId = Guid.NewGuid().ToString("N"); + + updateYieldCount++; + yield return new ChatResponseUpdate(ChatRole.Assistant, [new TextContent("Text 1")]) { MessageId = messageId }; + updateYieldCount++; + yield return new ChatResponseUpdate(ChatRole.Assistant, [new TextContent("Text 2")]) { MessageId = messageId }; + updateYieldCount++; + yield return new ChatResponseUpdate(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]) { MessageId = messageId }; + updateYieldCount++; + yield return new ChatResponseUpdate( + ChatRole.Assistant, + [ + new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } }) + ]) + { MessageId = messageId }; + updateYieldCount++; + yield return new ChatResponseUpdate(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func3")]) { MessageId = messageId }; + } + + using var innerClient = new TestChatClient { GetStreamingResponseAsyncCallback = YieldInnerClientUpdates }; + IChatClient service = configurePipeline(innerClient.AsBuilder()).Build(); + + var updates = service.GetStreamingResponseAsync(new EnumeratedOnceEnumerable(input), options, cts.Token); + + var updateCount = 0; + await foreach (var update in updates) + { + switch (updateCount) + { + case 0: + Assert.Equal("Text 1", update.Contents.OfType().First().Text); + + // First content should be yielded immedately, since we don't have any function calls yet. + Assert.Equal(1, updateYieldCount); + break; + case 1: + Assert.Equal("Text 2", update.Contents.OfType().First().Text); + + // Second content should be yielded immedately, since we don't have any function calls yet. + Assert.Equal(2, updateYieldCount); + break; + case 2: + var approvalRequest1 = update.Contents.OfType().First(); + Assert.Equal("callId1", approvalRequest1.FunctionCall.CallId); + Assert.Equal("Func1", approvalRequest1.FunctionCall.Name); + + // Third content should have been buffered, since we have not yet encountered a function call that requires approval. + Assert.Equal(4, updateYieldCount); + break; + case 3: + var approvalRequest2 = update.Contents.OfType().First(); + Assert.Equal("callId2", approvalRequest2.FunctionCall.CallId); + Assert.Equal("Func2", approvalRequest2.FunctionCall.Name); + + // Fourth content can be yielded immediately, since it is the first function call that requires approval. + Assert.Equal(4, updateYieldCount); + break; + case 4: + var approvalRequest3 = update.Contents.OfType().First(); + Assert.Equal("callId1", approvalRequest3.FunctionCall.CallId); + Assert.Equal("Func3", approvalRequest3.FunctionCall.Name); + + // Fifth content can be yielded immediately, since we previously encountered a function call that requires approval. + Assert.Equal(5, updateYieldCount); + break; + } + + updateCount++; + } + } + + private static Task> InvokeAndAssertAsync( + ChatOptions? options, + List input, + List downstreamClientOutput, + List expectedOutput, + List? expectedDownstreamClientInput = null, + Func? configurePipeline = null, + AITool[]? additionalTools = null) + => InvokeAndAssertMultiRoundAsync( + options, + input, + new Queue>(new[] { downstreamClientOutput }), + expectedOutput, + expectedDownstreamClientInput is null ? null : new Queue>(new[] { expectedDownstreamClientInput }), + configurePipeline, + additionalTools); + + private static async Task> InvokeAndAssertMultiRoundAsync( + ChatOptions? options, + List input, + Queue> downstreamClientOutput, + List expectedOutput, + Queue>? expectedDownstreamClientInput = null, + Func? configurePipeline = null, + AITool[]? additionalTools = null) + { + Assert.NotEmpty(input); + + configurePipeline ??= b => b.Use(s => new FunctionInvokingChatClient(s) { AdditionalTools = additionalTools }); + + using CancellationTokenSource cts = new(); + long expectedTotalTokenCounts = 0; + + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = async (contents, actualOptions, actualCancellationToken) => + { + Assert.Equal(cts.Token, actualCancellationToken); + if (expectedDownstreamClientInput is not null) + { + AssertExtensions.EqualMessageLists(expectedDownstreamClientInput.Dequeue(), contents.ToList()); + } + + await Task.Yield(); + + var usage = CreateRandomUsage(); + expectedTotalTokenCounts += usage.InputTokenCount!.Value; + + var output = downstreamClientOutput.Dequeue(); + output.ForEach(m => m.MessageId = Guid.NewGuid().ToString("N")); + return new ChatResponse(output) { Usage = usage }; + } + }; + + IChatClient service = configurePipeline(innerClient.AsBuilder()).Build(); + + var result = await service.GetResponseAsync(new EnumeratedOnceEnumerable(input), options, cts.Token); + Assert.NotNull(result); + + var actualOutput = result.Messages as List ?? result.Messages.ToList(); + AssertExtensions.EqualMessageLists(expectedOutput, actualOutput); + + // 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 actualOutput; + } + + 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 Task> InvokeAndAssertStreamingAsync( + ChatOptions? options, + List input, + List downstreamClientOutput, + List expectedOutput, + List? expectedDownstreamClientInput = null, + Func? configurePipeline = null, + AITool[]? additionalTools = null) + => InvokeAndAssertStreamingMultiRoundAsync( + options, + input, + new Queue>(new[] { downstreamClientOutput }), + expectedOutput, + expectedDownstreamClientInput is null ? null : new Queue>(new[] { expectedDownstreamClientInput }), + configurePipeline, + additionalTools); + + private static async Task> InvokeAndAssertStreamingMultiRoundAsync( + ChatOptions? options, + List input, + Queue> downstreamClientOutput, + List expectedOutput, + Queue>? expectedDownstreamClientInput = null, + Func? configurePipeline = null, + AITool[]? additionalTools = null) + { + Assert.NotEmpty(input); + + configurePipeline ??= b => b.Use(s => new FunctionInvokingChatClient(s) { AdditionalTools = additionalTools }); + + using CancellationTokenSource cts = new(); + + using var innerClient = new TestChatClient + { + GetStreamingResponseAsyncCallback = (contents, actualOptions, actualCancellationToken) => + { + Assert.Equal(cts.Token, actualCancellationToken); + if (expectedDownstreamClientInput is not null) + { + AssertExtensions.EqualMessageLists(expectedDownstreamClientInput.Dequeue(), contents.ToList()); + } + + var output = downstreamClientOutput.Dequeue(); + output.ForEach(m => m.MessageId = Guid.NewGuid().ToString("N")); + return YieldAsync(new ChatResponse(output).ToChatResponseUpdates()); + } + }; + + IChatClient service = configurePipeline(innerClient.AsBuilder()).Build(); + + var result = await service.GetStreamingResponseAsync(new EnumeratedOnceEnumerable(input), options, cts.Token).ToChatResponseAsync(); + Assert.NotNull(result); + + var actualOutput = result.Messages as List ?? result.Messages.ToList(); + + expectedOutput ??= input; + AssertExtensions.EqualMessageLists(expectedOutput, actualOutput); + + return actualOutput; + } + + private static async IAsyncEnumerable YieldAsync(params T[] items) + { + await Task.Yield(); + foreach (var item in items) + { + yield return item; + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs index 1379cef8bf0..2308a921ab3 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs @@ -2,7 +2,6 @@ // 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.Linq; @@ -36,9 +35,10 @@ public void Ctor_HasExpectedDefaults() Assert.False(client.AllowConcurrentInvocation); Assert.False(client.IncludeDetailedErrors); - Assert.Equal(10, client.MaximumIterationsPerRequest); + Assert.Equal(40, client.MaximumIterationsPerRequest); Assert.Equal(3, client.MaximumConsecutiveErrorsPerRequest); Assert.Null(client.FunctionInvoker); + Assert.Null(client.AdditionalTools); } [Fact] @@ -55,7 +55,7 @@ public void Properties_Roundtrip() client.IncludeDetailedErrors = true; Assert.True(client.IncludeDetailedErrors); - Assert.Equal(10, client.MaximumIterationsPerRequest); + Assert.Equal(40, client.MaximumIterationsPerRequest); client.MaximumIterationsPerRequest = 5; Assert.Equal(5, client.MaximumIterationsPerRequest); @@ -67,6 +67,11 @@ public void Properties_Roundtrip() Func> invoker = (ctx, ct) => new ValueTask("test"); client.FunctionInvoker = invoker; Assert.Same(invoker, client.FunctionInvoker); + + Assert.Null(client.AdditionalTools); + IList additionalTools = [AIFunctionFactory.Create(() => "Additional Tool")]; + client.AdditionalTools = additionalTools; + Assert.Same(additionalTools, client.AdditionalTools); } [Fact] @@ -99,6 +104,73 @@ public async Task SupportsSingleFunctionCallPerRequestAsync() await InvokeAndAssertStreamingAsync(options, plan); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SupportsToolsProvidedByAdditionalTools(bool provideOptions) + { + ChatOptions? options = provideOptions ? + new() { Tools = [AIFunctionFactory.Create(() => "Shouldn't be invoked", "ChatOptionsFunc")] } : + null; + + Func configure = builder => + builder.UseFunctionInvocation(configure: c => c.AdditionalTools = + [ + AIFunctionFactory.Create(() => "Result 1", "Func1"), + AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), + AIFunctionFactory.Create((int i) => { }, "VoidReturn"), + ]); + + List plan = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1")]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", result: "Result 2: 42")]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId3", "VoidReturn", arguments: new Dictionary { { "i", 43 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId3", result: "Success: Function completed.")]), + new ChatMessage(ChatRole.Assistant, "world"), + ]; + + await InvokeAndAssertAsync(options, plan, configurePipeline: configure); + + await InvokeAndAssertStreamingAsync(options, plan, configurePipeline: configure); + } + + [Fact] + public async Task PrefersToolsProvidedByChatOptions() + { + ChatOptions options = new() + { + Tools = [AIFunctionFactory.Create(() => "Result 1", "Func1")] + }; + + Func configure = builder => + builder.UseFunctionInvocation(configure: c => c.AdditionalTools = + [ + AIFunctionFactory.Create(() => "Should never be invoked", "Func1"), + AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), + AIFunctionFactory.Create((int i) => { }, "VoidReturn"), + ]); + + List plan = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1")]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", result: "Result 2: 42")]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId3", "VoidReturn", arguments: new Dictionary { { "i", 43 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId3", result: "Success: Function completed.")]), + new ChatMessage(ChatRole.Assistant, "world"), + ]; + + await InvokeAndAssertAsync(options, plan, configurePipeline: configure); + + await InvokeAndAssertStreamingAsync(options, plan, configurePipeline: configure); + } + [Theory] [InlineData(false)] [InlineData(true)] @@ -583,9 +655,10 @@ async Task InvokeAsync(Func work) } [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task FunctionInvocationTrackedWithActivity(bool enableTelemetry) + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task FunctionInvocationTrackedWithActivity(bool enableTelemetry, bool enableSensitiveData) { string sourceName = Guid.NewGuid().ToString(); @@ -603,13 +676,13 @@ public async Task FunctionInvocationTrackedWithActivity(bool enableTelemetry) }; Func configure = b => b.Use(c => - new FunctionInvokingChatClient(new OpenTelemetryChatClient(c, sourceName: sourceName))); + new FunctionInvokingChatClient(new OpenTelemetryChatClient(c, sourceName: sourceName) { EnableSensitiveData = enableSensitiveData })); - await InvokeAsync(() => InvokeAndAssertAsync(options, plan, configurePipeline: configure), streaming: false); + await InvokeAsync(() => InvokeAndAssertAsync(options, plan, configurePipeline: configure)); - await InvokeAsync(() => InvokeAndAssertStreamingAsync(options, plan, configurePipeline: configure), streaming: true); + await InvokeAsync(() => InvokeAndAssertStreamingAsync(options, plan, configurePipeline: configure)); - async Task InvokeAsync(Func work, bool streaming) + async Task InvokeAsync(Func work) { var activities = new List(); using TracerProvider? tracerProvider = enableTelemetry ? @@ -627,7 +700,24 @@ async Task InvokeAsync(Func work, bool streaming) activity => Assert.Equal("chat", activity.DisplayName), activity => Assert.Equal("execute_tool Func1", activity.DisplayName), activity => Assert.Equal("chat", activity.DisplayName), - activity => Assert.Equal(streaming ? "FunctionInvokingChatClient.GetStreamingResponseAsync" : "FunctionInvokingChatClient.GetResponseAsync", activity.DisplayName)); + activity => Assert.Equal("orchestrate_tools", activity.DisplayName)); + + var executeTool = activities[1]; + if (enableSensitiveData) + { + var args = Assert.Single(executeTool.Tags, t => t.Key == "gen_ai.tool.call.arguments"); + Assert.Equal( + JsonSerializer.Serialize(new Dictionary { ["arg1"] = "value1" }, AIJsonUtilities.DefaultOptions), + args.Value); + + var result = Assert.Single(executeTool.Tags, t => t.Key == "gen_ai.tool.call.result"); + Assert.Equal("Result 1", JsonSerializer.Deserialize(result.Value!, AIJsonUtilities.DefaultOptions)); + } + else + { + Assert.DoesNotContain(executeTool.Tags, t => t.Key == "gen_ai.tool.call.arguments"); + Assert.DoesNotContain(executeTool.Tags, t => t.Key == "gen_ai.tool.call.result"); + } for (int i = 0; i < activities.Count - 1; i++) { @@ -989,6 +1079,159 @@ public async Task FunctionInvocations_InvokedOnOriginalSynchronizationContext() await InvokeAndAssertStreamingAsync(options, plan, configurePipeline: configurePipeline); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task TerminateOnUnknownCalls_ControlsBehaviorForUnknownFunctions(bool terminateOnUnknown) + { + ChatOptions options = new() + { + Tools = [AIFunctionFactory.Create((int i) => $"Known: {i}", "KnownFunc")] + }; + + Func configure = b => b.Use( + s => new FunctionInvokingChatClient(s) { TerminateOnUnknownCalls = terminateOnUnknown }); + + if (!terminateOnUnknown) + { + List planForContinue = + [ + new(ChatRole.User, "hello"), + new(ChatRole.Assistant, [ + new FunctionCallContent("callId1", "UnknownFunc", new Dictionary { ["i"] = 1 }), + new FunctionCallContent("callId2", "KnownFunc", new Dictionary { ["i"] = 2 }) + ]), + new(ChatRole.Tool, [ + new FunctionResultContent("callId1", result: "Error: Requested function \"UnknownFunc\" not found."), + new FunctionResultContent("callId2", result: "Known: 2") + ]), + new(ChatRole.Assistant, "done"), + ]; + + await InvokeAndAssertAsync(options, planForContinue, configurePipeline: configure); + await InvokeAndAssertStreamingAsync(options, planForContinue, configurePipeline: configure); + } + else + { + List fullPlanWithUnknown = + [ + new(ChatRole.User, "hello"), + new(ChatRole.Assistant, [ + new FunctionCallContent("callId1", "UnknownFunc", new Dictionary { ["i"] = 1 }), + new FunctionCallContent("callId2", "KnownFunc", new Dictionary { ["i"] = 2 }) + ]), + new(ChatRole.Tool, [ + new FunctionResultContent("callId1", result: "Error: Requested function \"UnknownFunc\" not found."), + new FunctionResultContent("callId2", result: "Known: 2") + ]), + new(ChatRole.Assistant, "done"), + ]; + + var expected = fullPlanWithUnknown.Take(2).ToList(); + await InvokeAndAssertAsync(options, fullPlanWithUnknown, expected, configure); + await InvokeAndAssertStreamingAsync(options, fullPlanWithUnknown, expected, configure); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task RequestsWithOnlyFunctionDeclarations_TerminatesRegardlessOfTerminateOnUnknownCalls(bool terminateOnUnknown) + { + var declarationOnly = AIFunctionFactory.Create(() => "unused", "DefOnly").AsDeclarationOnly(); + + ChatOptions options = new() { Tools = [declarationOnly] }; + + List fullPlan = + [ + new(ChatRole.User, "hello"), + new(ChatRole.Assistant, [new FunctionCallContent("callId1", "DefOnly")]), + new(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Should not be produced")]), + new(ChatRole.Assistant, "world"), + ]; + + List expected = fullPlan.Take(2).ToList(); + + Func configure = b => b.Use( + s => new FunctionInvokingChatClient(s) { TerminateOnUnknownCalls = terminateOnUnknown }); + + await InvokeAndAssertAsync(options, fullPlan, expected, configure); + await InvokeAndAssertStreamingAsync(options, fullPlan, expected, configure); + } + + [Fact] + public async Task MixedKnownFunctionAndDeclaration_TerminatesWithoutInvokingKnown() + { + int invoked = 0; + var known = AIFunctionFactory.Create(() => { invoked++; return "OK"; }, "Known"); + var defOnly = AIFunctionFactory.Create(() => "unused", "DefOnly").AsDeclarationOnly(); + + var options = new ChatOptions + { + Tools = [known, defOnly] + }; + + List fullPlan = + [ + new(ChatRole.User, "hi"), + new(ChatRole.Assistant, [ + new FunctionCallContent("callId1", "Known"), + new FunctionCallContent("callId2", "DefOnly") + ]), + new(ChatRole.Tool, [new FunctionResultContent("callId1", result: "OK"), new FunctionResultContent("callId2", result: "nope")]), + new(ChatRole.Assistant, "done"), + ]; + + List expected = fullPlan.Take(2).ToList(); + + Func configure = b => b.Use(s => new FunctionInvokingChatClient(s) { TerminateOnUnknownCalls = false }); + await InvokeAndAssertAsync(options, fullPlan, expected, configure); + Assert.Equal(0, invoked); + + invoked = 0; + configure = b => b.Use(s => new FunctionInvokingChatClient(s) { TerminateOnUnknownCalls = true }); + await InvokeAndAssertStreamingAsync(options, fullPlan, expected, configure); + Assert.Equal(0, invoked); + } + + [Fact] + public async Task ClonesChatOptionsAndResetContinuationTokenForBackgroundResponsesAsync() + { + ChatOptions? actualChatOptions = null; + + using var innerChatClient = new TestChatClient + { + GetResponseAsyncCallback = (chatContents, chatOptions, cancellationToken) => + { + actualChatOptions = chatOptions; + + List messages = []; + + // Simulate the model returning a function call for the first call only + if (!chatContents.Any(m => m.Contents.OfType().Any())) + { + messages.Add(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")])); + } + + return Task.FromResult(new ChatResponse { Messages = messages }); + } + }; + + using var chatClient = new FunctionInvokingChatClient(innerChatClient); + + var originalChatOptions = new ChatOptions + { + Tools = [AIFunctionFactory.Create(() => { }, "Func1")], + ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3, 4 }), + }; + + await chatClient.GetResponseAsync("hi", originalChatOptions); + + // The original options should be cloned and have a null ContinuationToken + Assert.NotSame(originalChatOptions, actualChatOptions); + Assert.Null(actualChatOptions!.ContinuationToken); + } + private sealed class CustomSynchronizationContext : SynchronizationContext { public override void Post(SendOrPostCallback d, object? state) @@ -1002,7 +1245,7 @@ public override void Post(SendOrPostCallback d, object? state) } private static async Task> InvokeAndAssertAsync( - ChatOptions options, + ChatOptions? options, List plan, List? expected = null, Func? configurePipeline = null, @@ -1043,37 +1286,7 @@ private static async Task> InvokeAndAssertAsync( chat.AddRange(result.Messages); expected ??= plan; - 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); - } - } - } + AssertExtensions.EqualMessageLists(expected, chat); // Usage should be aggregated over all responses, including AdditionalUsage var actualUsage = result.Usage!; @@ -1102,7 +1315,7 @@ private static UsageDetails CreateRandomUsage() } private static async Task> InvokeAndAssertStreamingAsync( - ChatOptions options, + ChatOptions? options, List plan, List? expected = null, Func? configurePipeline = null, @@ -1137,38 +1350,8 @@ private static async Task> InvokeAndAssertStreamingAsync( chat.AddRange(result.Messages); expected ??= plan; - 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); - } - } - } + AssertExtensions.EqualMessageLists(expected, chat); return chat; } @@ -1180,24 +1363,4 @@ private static async IAsyncEnumerable YieldAsync(params IEnumerable ite yield return item; } } - - private sealed class EnumeratedOnceEnumerable(IEnumerable items) : IEnumerable - { - private int _iterated; - - public IEnumerator GetEnumerator() - { - if (Interlocked.Exchange(ref _iterated, 1) != 0) - { - throw new InvalidOperationException("This enumerable can only be enumerated once."); - } - - foreach (var item in items) - { - yield return item; - } - } - - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ImageGeneratingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ImageGeneratingChatClientTests.cs new file mode 100644 index 00000000000..a70d19abffc --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ImageGeneratingChatClientTests.cs @@ -0,0 +1,386 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ImageGeneratingChatClientTests +{ + [Fact] + public void ImageGeneratingChatClient_InvalidArgs_Throws() + { + using var innerClient = new TestChatClient(); + using var imageGenerator = new TestImageGenerator(); + + Assert.Throws("innerClient", () => new ImageGeneratingChatClient(null!, imageGenerator)); + Assert.Throws("imageGenerator", () => new ImageGeneratingChatClient(innerClient, null!)); + } + + [Fact] + public void UseImageGeneration_WithNullBuilder_Throws() + { + Assert.Throws("builder", () => ((ChatClientBuilder)null!).UseImageGeneration()); + } + + [Fact] + public async Task GetResponseAsync_WithoutImageGenerationTool_PassesThrough() + { + // Arrange + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "test response"))); + }, + }; + + using var imageGenerator = new TestImageGenerator(); + using var client = new ImageGeneratingChatClient(innerClient, imageGenerator); + + var chatOptions = new ChatOptions + { + Tools = [AIFunctionFactory.Create(() => "dummy function", name: "DummyFunction")] + }; + + // Act + var response = await client.GetResponseAsync([new(ChatRole.User, "test")], chatOptions); + + // Assert + Assert.NotNull(response); + Assert.Equal("test response", response.Messages[0].Text); + + // Verify that tools collection still has the original function, not replaced + Assert.Single(chatOptions.Tools); + Assert.IsAssignableFrom(chatOptions.Tools[0]); + } + + [Fact] + public async Task GetResponseAsync_WithImageGenerationTool_ReplacesTool() + { + // Arrange + bool innerClientCalled = false; + ChatOptions? capturedOptions = null; + + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + innerClientCalled = true; + capturedOptions = options; + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "test response"))); + }, + }; + + using var imageGenerator = new TestImageGenerator(); + using var client = new ImageGeneratingChatClient(innerClient, imageGenerator); + + var chatOptions = new ChatOptions + { + Tools = [new HostedImageGenerationTool()] + }; + + // Act + var response = await client.GetResponseAsync([new(ChatRole.User, "test")], chatOptions); + + // Assert + Assert.True(innerClientCalled); + Assert.NotNull(capturedOptions); + Assert.NotNull(capturedOptions.Tools); + Assert.Equal(3, capturedOptions.Tools.Count); + + // Verify the functions are properly created + var generateImageFunction = capturedOptions.Tools[0] as AIFunction; + var editImageFunction = capturedOptions.Tools[1] as AIFunction; + var getImagesForEditImageFunction = capturedOptions.Tools[2] as AIFunction; + + Assert.NotNull(generateImageFunction); + Assert.NotNull(editImageFunction); + Assert.NotNull(getImagesForEditImageFunction); + Assert.Equal("GenerateImage", generateImageFunction.Name); + Assert.Equal("EditImage", editImageFunction.Name); + Assert.Equal("GetImagesForEdit", getImagesForEditImageFunction.Name); + } + + [Fact] + public async Task GetResponseAsync_WithMixedTools_ReplacesOnlyImageGenerationTool() + { + // Arrange + bool innerClientCalled = false; + ChatOptions? capturedOptions = null; + + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + innerClientCalled = true; + capturedOptions = options; + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "test response"))); + }, + }; + + using var imageGenerator = new TestImageGenerator(); + using var client = new ImageGeneratingChatClient(innerClient, imageGenerator); + + var dummyFunction = AIFunctionFactory.Create(() => "dummy", name: "DummyFunction"); + var chatOptions = new ChatOptions + { + Tools = [dummyFunction, new HostedImageGenerationTool()] + }; + + // Act + var response = await client.GetResponseAsync([new(ChatRole.User, "test")], chatOptions); + + // Assert + Assert.True(innerClientCalled); + Assert.NotNull(capturedOptions); + Assert.NotNull(capturedOptions.Tools); + Assert.Equal(4, capturedOptions.Tools.Count); // DummyFunction + GenerateImage + EditImage + GetImagesForEdit + + Assert.Same(dummyFunction, capturedOptions.Tools[0]); // Original function preserved + Assert.IsAssignableFrom(capturedOptions.Tools[1]); // GenerateImage function + Assert.IsAssignableFrom(capturedOptions.Tools[2]); // EditImage function + } + + [Fact] + public void UseImageGeneration_ServiceProviderIntegration_Works() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(); + + using var serviceProvider = services.BuildServiceProvider(); + using var innerClient = new TestChatClient(); + + // Act + using var client = innerClient + .AsBuilder() + .UseImageGeneration() + .Build(serviceProvider); + + // Assert + Assert.IsType(client); + } + + [Fact] + public void UseImageGeneration_WithProvidedImageGenerator_Works() + { + // Arrange + using var innerClient = new TestChatClient(); + using var imageGenerator = new TestImageGenerator(); + + // Act + using var client = innerClient + .AsBuilder() + .UseImageGeneration(imageGenerator) + .Build(); + + // Assert + Assert.IsType(client); + } + + [Fact] + public void UseImageGeneration_WithConfigureCallback_CallsCallback() + { + // Arrange + using var innerClient = new TestChatClient(); + using var imageGenerator = new TestImageGenerator(); + bool configureCallbackInvoked = false; + + // Act + using var client = innerClient + .AsBuilder() + .UseImageGeneration(imageGenerator, configure: c => + { + Assert.NotNull(c); + configureCallbackInvoked = true; + }) + .Build(); + + // Assert + Assert.True(configureCallbackInvoked); + } + + [Fact] + public async Task GetStreamingResponseAsync_WithImageGenerationTool_ReplacesTool() + { + // Arrange + bool innerClientCalled = false; + ChatOptions? capturedOptions = null; + + using var innerClient = new TestChatClient + { + GetStreamingResponseAsyncCallback = (messages, options, cancellationToken) => + { + innerClientCalled = true; + capturedOptions = options; + return GetUpdatesAsync(); + } + }; + + static async IAsyncEnumerable GetUpdatesAsync() + { + await Task.Yield(); + yield return new(ChatRole.Assistant, "test"); + } + + using var imageGenerator = new TestImageGenerator(); + using var client = new ImageGeneratingChatClient(innerClient, imageGenerator); + + var chatOptions = new ChatOptions + { + Tools = [new HostedImageGenerationTool()] + }; + + // Act + await foreach (var update in client.GetStreamingResponseAsync([new(ChatRole.User, "test")], chatOptions)) + { + // Process updates + } + + // Assert + Assert.True(innerClientCalled); + Assert.NotNull(capturedOptions); + Assert.NotNull(capturedOptions.Tools); + Assert.Equal(3, capturedOptions.Tools.Count); + } + + [Fact] + public async Task GetResponseAsync_WithNullOptions_DoesNotThrow() + { + // Arrange + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "test response"))); + }, + }; + + using var imageGenerator = new TestImageGenerator(); + using var client = new ImageGeneratingChatClient(innerClient, imageGenerator); + + // Act & Assert + var response = await client.GetResponseAsync([new(ChatRole.User, "test")], null); + Assert.NotNull(response); + } + + [Fact] + public async Task GetResponseAsync_WithEmptyTools_DoesNotModify() + { + // Arrange + ChatOptions? capturedOptions = null; + + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + capturedOptions = options; + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "test response"))); + }, + }; + + using var imageGenerator = new TestImageGenerator(); + using var client = new ImageGeneratingChatClient(innerClient, imageGenerator); + + var chatOptions = new ChatOptions + { + Tools = [] + }; + + // Act + await client.GetResponseAsync([new(ChatRole.User, "test")], chatOptions); + + // Assert + Assert.Same(chatOptions, capturedOptions); +#pragma warning disable CA1508 + Assert.NotNull(capturedOptions?.Tools); +#pragma warning restore CA1508 + Assert.Empty(capturedOptions.Tools); + } + + [Fact] + public async Task GetResponseAsync_WithFunctionCallContent_ReplacesWithImageGenerationToolCallContent() + { + // Arrange + var callId = "test-call-id"; + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + var responseMessage = new ChatMessage(ChatRole.Assistant, + [new FunctionCallContent(callId, "GenerateImage", new Dictionary { ["prompt"] = "a cat" })]); + return Task.FromResult(new ChatResponse(responseMessage)); + }, + }; + + using var imageGenerator = new TestImageGenerator(); + using var client = new ImageGeneratingChatClient(innerClient, imageGenerator); + + var chatOptions = new ChatOptions + { + Tools = [new HostedImageGenerationTool()] + }; + + // Act + var response = await client.GetResponseAsync([new(ChatRole.User, "test")], chatOptions); + + // Assert + Assert.NotNull(response); + Assert.Single(response.Messages); + var message = response.Messages[0]; + Assert.Single(message.Contents); + + var imageToolCallContent = Assert.IsType(message.Contents[0]); + Assert.Equal(callId, imageToolCallContent.ImageId); + } + + [Fact] + public async Task GetStreamingResponseAsync_WithFunctionCallContent_ReplacesWithImageGenerationToolCallContent() + { + // Arrange + var callId = "test-call-id"; + using var innerClient = new TestChatClient + { + GetStreamingResponseAsyncCallback = (messages, options, cancellationToken) => + { + return GetUpdatesAsync(); + } + }; + + async IAsyncEnumerable GetUpdatesAsync() + { + await Task.Yield(); + yield return new ChatResponseUpdate(ChatRole.Assistant, + [new FunctionCallContent(callId, "GenerateImage", new Dictionary { ["prompt"] = "a cat" })]); + } + + using var imageGenerator = new TestImageGenerator(); + using var client = new ImageGeneratingChatClient(innerClient, imageGenerator); + + var chatOptions = new ChatOptions + { + Tools = [new HostedImageGenerationTool()] + }; + + // Act + var updates = new List(); + await foreach (var responseUpdate in client.GetStreamingResponseAsync([new(ChatRole.User, "test")], chatOptions)) + { + updates.Add(responseUpdate); + } + + // Assert + Assert.Single(updates); + var update = updates[0]; + Assert.Single(update.Contents); + + var imageToolCallContent = Assert.IsType(update.Contents[0]); + Assert.Equal(callId, imageToolCallContent.ImageId); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/OpenTelemetryChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/OpenTelemetryChatClientTests.cs index b81a480e207..e7206b05ff5 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/OpenTelemetryChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/OpenTelemetryChatClientTests.cs @@ -4,11 +4,11 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Linq; using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Testing; using OpenTelemetry.Trace; using Xunit; @@ -30,9 +30,6 @@ public async Task ExpectedInformationLogged_Async(bool enableSensitiveData, bool .AddInMemoryExporter(activities) .Build(); - var collector = new FakeLogCollector(); - using ILoggerFactory loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector))); - using var innerClient = new TestChatClient { GetResponseAsyncCallback = async (messages, options, cancellationToken) => @@ -98,7 +95,7 @@ async static IAsyncEnumerable CallbackAsync( using var chatClient = innerClient .AsBuilder() - .UseOpenTelemetry(loggerFactory, sourceName, configure: instance => + .UseOpenTelemetry(null, sourceName, configure: instance => { instance.EnableSensitiveData = enableSensitiveData; instance.JsonSerializerOptions = TestJsonSerializerContext.Default.Options; @@ -108,10 +105,10 @@ async static IAsyncEnumerable CallbackAsync( List messages = [ new(ChatRole.System, "You are a close friend."), - new(ChatRole.User, "Hey!"), + new(ChatRole.User, "Hey!") { AuthorName = "Alice" }, new(ChatRole.Assistant, [new FunctionCallContent("12345", "GetPersonName")]), new(ChatRole.Tool, [new FunctionResultContent("12345", "John")]), - new(ChatRole.Assistant, "Hey John, what's up?"), + new(ChatRole.Assistant, "Hey John, what's up?") { AuthorName = "BotAssistant" }, new(ChatRole.User, "What's the biggest animal?") ]; @@ -132,6 +129,16 @@ async static IAsyncEnumerable CallbackAsync( ["service_tier"] = "value1", ["SomethingElse"] = "value2", }, + Instructions = "You are helpful.", + Tools = + [ + AIFunctionFactory.Create((string personName) => personName, "GetPersonAge", "Gets the age of a person by name."), + new HostedWebSearchTool(), + new HostedFileSearchTool(), + new HostedCodeInterpreterTool(), + new HostedMcpServerTool("myAwesomeServer", "http://localhost:1234/somewhere"), + AIFunctionFactory.Create((string location) => "", "GetCurrentWeather", "Gets the current weather for a location.").AsDeclarationOnly(), + ], }; if (streaming) @@ -151,11 +158,11 @@ async static IAsyncEnumerable CallbackAsync( Assert.NotNull(activity.Id); Assert.NotEmpty(activity.Id); - Assert.Equal("http://localhost:12345/something", activity.GetTagItem("server.address")); + Assert.Equal("localhost", activity.GetTagItem("server.address")); Assert.Equal(12345, (int)activity.GetTagItem("server.port")!); Assert.Equal("chat replacementmodel", activity.DisplayName); - Assert.Equal("testservice", activity.GetTagItem("gen_ai.system")); + Assert.Equal("testservice", activity.GetTagItem("gen_ai.provider.name")); Assert.Equal("replacementmodel", activity.GetTagItem("gen_ai.request.model")); Assert.Equal(3.0f, activity.GetTagItem("gen_ai.request.frequency_penalty")); @@ -165,55 +172,422 @@ 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(enableSensitiveData ? "value1" : null, activity.GetTagItem("gen_ai.testservice.request.service_tier")); - Assert.Equal(enableSensitiveData ? "value2" : null, activity.GetTagItem("gen_ai.testservice.request.something_else")); + Assert.Equal(enableSensitiveData ? "value1" : null, activity.GetTagItem("service_tier")); + Assert.Equal(enableSensitiveData ? "value2" : null, activity.GetTagItem("SomethingElse")); Assert.Equal(42L, activity.GetTagItem("gen_ai.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.usage.input_tokens")); Assert.Equal(20, activity.GetTagItem("gen_ai.usage.output_tokens")); - Assert.Equal(enableSensitiveData ? "abcdefgh" : null, activity.GetTagItem("gen_ai.testservice.response.system_fingerprint")); - Assert.Equal(enableSensitiveData ? "value2" : null, activity.GetTagItem("gen_ai.testservice.response.and_something_else")); + Assert.Equal(enableSensitiveData ? "abcdefgh" : null, activity.GetTagItem("system_fingerprint")); + Assert.Equal(enableSensitiveData ? "value2" : null, activity.GetTagItem("AndSomethingElse")); Assert.True(activity.Duration.TotalMilliseconds > 0); - var logs = collector.GetSnapshot(); + var tags = activity.Tags.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); 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)); + Assert.Equal(ReplaceWhitespace(""" + [ + { + "role": "system", + "parts": [ + { + "type": "text", + "content": "You are a close friend." + } + ] + }, + { + "role": "user", + "name": "Alice", + "parts": [ + { + "type": "text", + "content": "Hey!" + } + ] + }, + { + "role": "assistant", + "parts": [ + { + "type": "tool_call", + "id": "12345", + "name": "GetPersonName" + } + ] + }, + { + "role": "tool", + "parts": [ + { + "type": "tool_call_response", + "id": "12345", + "response": "John" + } + ] + }, + { + "role": "assistant", + "name": "BotAssistant", + "parts": [ + { + "type": "text", + "content": "Hey John, what's up?" + } + ] + }, + { + "role": "user", + "parts": [ + { + "type": "text", + "content": "What's the biggest animal?" + } + ] + } + ] + """), ReplaceWhitespace(tags["gen_ai.input.messages"])); + + Assert.Equal(ReplaceWhitespace(""" + [ + { + "role": "assistant", + "parts": [ + { + "type": "text", + "content": "The blue whale, I think." + } + ], + "finish_reason": "stop" + } + ] + """), ReplaceWhitespace(tags["gen_ai.output.messages"])); + + Assert.Equal(ReplaceWhitespace(""" + [ + { + "type": "text", + "content": "You are helpful." + } + ] + """), ReplaceWhitespace(tags["gen_ai.system_instructions"])); + + Assert.Equal(ReplaceWhitespace(""" + [ + { + "type": "function", + "name": "GetPersonAge", + "description": "Gets the age of a person by name.", + "parameters": { + "type": "object", + "properties": { + "personName": { + "type": "string" + } + }, + "required": [ + "personName" + ] + } + }, + { + "type": "web_search" + }, + { + "type": "file_search" + }, + { + "type": "code_interpreter" + }, + { + "type": "mcp" + }, + { + "type": "function", + "name": "GetCurrentWeather", + "description": "Gets the current weather for a location.", + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string" + } + }, + "required": [ + "location" + ] + } + } + ] + """), ReplaceWhitespace(tags["gen_ai.tool.definitions"])); } 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.False(tags.ContainsKey("gen_ai.input.messages")); + Assert.False(tags.ContainsKey("gen_ai.output.messages")); + Assert.False(tags.ContainsKey("gen_ai.system_instructions")); + Assert.False(tags.ContainsKey("gen_ai.tool.definitions")); } + } - 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])); + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task AllOfficialOtelContentPartTypes_SerializedCorrectly(bool streaming) + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); - Assert.All(logs, log => + using var innerClient = new TestChatClient { - Assert.Equal(new KeyValuePair("gen_ai.system", "testservice"), ((IList>)log.State!)[1]); - }); + GetResponseAsyncCallback = async (messages, options, cancellationToken) => + { + await Task.Yield(); + return new ChatResponse(new ChatMessage(ChatRole.Assistant, + [ + new TextContent("Assistant response text"), + new TextReasoningContent("This is reasoning"), + new FunctionCallContent("call-123", "GetWeather", new Dictionary { ["location"] = "Seattle" }), + new FunctionResultContent("call-123", "72°F and sunny"), + new DataContent(Convert.FromBase64String("aGVsbG8gd29ybGQ="), "image/png"), + new UriContent(new Uri("https://example.com/image.jpg"), "image/jpeg"), + new HostedFileContent("file-abc123"), + ])); + }, + GetStreamingResponseAsyncCallback = CallbackAsync, + }; + + async static IAsyncEnumerable CallbackAsync( + IEnumerable messages, ChatOptions? options, [EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.Yield(); + yield return new(ChatRole.Assistant, "Assistant response text"); + yield return new() { Contents = [new TextReasoningContent("This is reasoning")] }; + yield return new() { Contents = [new FunctionCallContent("call-123", "GetWeather", new Dictionary { ["location"] = "Seattle" })] }; + yield return new() { Contents = [new FunctionResultContent("call-123", "72°F and sunny")] }; + yield return new() { Contents = [new DataContent(Convert.FromBase64String("aGVsbG8gd29ybGQ="), "image/png")] }; + yield return new() { Contents = [new UriContent(new Uri("https://example.com/image.jpg"), "image/jpeg")] }; + yield return new() { Contents = [new HostedFileContent("file-abc123")] }; + } + + using var chatClient = innerClient + .AsBuilder() + .UseOpenTelemetry(null, sourceName, configure: instance => + { + instance.EnableSensitiveData = true; + instance.JsonSerializerOptions = TestJsonSerializerContext.Default.Options; + }) + .Build(); + + List messages = + [ + new(ChatRole.User, + [ + new TextContent("User request text"), + new TextReasoningContent("User reasoning"), + new DataContent(Convert.FromBase64String("ZGF0YSBjb250ZW50"), "audio/mp3"), + new UriContent(new Uri("https://example.com/video.mp4"), "video/mp4"), + new HostedFileContent("file-xyz789"), + ]), + new(ChatRole.Assistant, [new FunctionCallContent("call-456", "SearchFiles")]), + new(ChatRole.Tool, [new FunctionResultContent("call-456", "Found 3 files")]), + ]; + + if (streaming) + { + await foreach (var update in chatClient.GetStreamingResponseAsync(messages)) + { + await Task.Yield(); + } + } + else + { + await chatClient.GetResponseAsync(messages); + } + + var activity = Assert.Single(activities); + Assert.NotNull(activity); + + var inputMessages = activity.Tags.First(kvp => kvp.Key == "gen_ai.input.messages").Value; + Assert.Equal(ReplaceWhitespace(""" + [ + { + "role": "user", + "parts": [ + { + "type": "text", + "content": "User request text" + }, + { + "type": "reasoning", + "content": "User reasoning" + }, + { + "type": "blob", + "content": "ZGF0YSBjb250ZW50", + "mime_type": "audio/mp3", + "modality": "audio" + }, + { + "type": "uri", + "uri": "https://example.com/video.mp4", + "mime_type": "video/mp4", + "modality": "video" + }, + { + "type": "file", + "file_id": "file-xyz789" + } + ] + }, + { + "role": "assistant", + "parts": [ + { + "type": "tool_call", + "id": "call-456", + "name": "SearchFiles" + } + ] + }, + { + "role": "tool", + "parts": [ + { + "type": "tool_call_response", + "id": "call-456", + "response": "Found 3 files" + } + ] + } + ] + """), ReplaceWhitespace(inputMessages)); + + var outputMessages = activity.Tags.First(kvp => kvp.Key == "gen_ai.output.messages").Value; + Assert.Equal(ReplaceWhitespace(""" + [ + { + "role": "assistant", + "parts": [ + { + "type": "text", + "content": "Assistant response text" + }, + { + "type": "reasoning", + "content": "This is reasoning" + }, + { + "type": "tool_call", + "id": "call-123", + "name": "GetWeather", + "arguments": { + "location": "Seattle" + } + }, + { + "type": "tool_call_response", + "id": "call-123", + "response": "72°F and sunny" + }, + { + "type": "blob", + "content": "aGVsbG8gd29ybGQ=", + "mime_type": "image/png", + "modality": "image" + }, + { + "type": "uri", + "uri": "https://example.com/image.jpg", + "mime_type": "image/jpeg", + "modality": "image" + }, + { + "type": "file", + "file_id": "file-abc123" + } + ] + } + ] + """), ReplaceWhitespace(outputMessages)); + } + + [Fact] + public async Task UnknownContentTypes_Ignored() + { + 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 + { + GetResponseAsyncCallback = async (messages, options, cancellationToken) => + { + await Task.Yield(); + return new ChatResponse(new ChatMessage(ChatRole.Assistant, "The blue whale, I think.")); + }, + }; + + using var chatClient = innerClient + .AsBuilder() + .UseOpenTelemetry(null, sourceName, configure: instance => + { + instance.EnableSensitiveData = true; + instance.JsonSerializerOptions = TestJsonSerializerContext.Default.Options; + }) + .Build(); + + List messages = + [ + new(ChatRole.User, + [ + new TextContent("Hello!"), + new NonSerializableAIContent(), + new TextContent("How are you?"), + ]), + ]; + + var response = await chatClient.GetResponseAsync(messages); + Assert.NotNull(response); + + var activity = Assert.Single(activities); + Assert.NotNull(activity); + + var inputMessages = activity.Tags.First(kvp => kvp.Key == "gen_ai.input.messages").Value; + Assert.Equal(ReplaceWhitespace(""" + [ + { + "role": "user", + "parts": [ + { + "type": "text", + "content": "Hello!" + }, + { + "type": "Microsoft.Extensions.AI.OpenTelemetryChatClientTests+NonSerializableAIContent", + "content": {} + }, + { + "type": "text", + "content": "How are you?" + } + ] + } + ] + """), ReplaceWhitespace(inputMessages)); } + + private sealed class NonSerializableAIContent : AIContent; + + private static string ReplaceWhitespace(string? input) => Regex.Replace(input ?? "", @"\s+", " ").Trim(); } diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ReducingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ReducingChatClientTests.cs new file mode 100644 index 00000000000..82b00df03ff --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/ReducingChatClientTests.cs @@ -0,0 +1,188 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ReducingChatClientTests +{ + [Fact] + public void ReducingChatClient_InvalidArgs_Throws() + { + Assert.Throws("innerClient", () => new ReducingChatClient(null!, new TestReducer())); + } + + [Fact] + public void UseChatReducer_InvalidArgs_Throws() + { + using var innerClient = new TestChatClient(); + var builder = innerClient.AsBuilder(); + Assert.Throws("builder", () => ReducingChatClientBuilderExtensions.UseChatReducer(null!, new TestReducer())); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task GetResponseAsync_CallsReducerBeforeInnerClient(bool streaming) + { + var originalMessages = new List + { + new(ChatRole.System, "You are a helpful assistant"), + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, "Hi there!"), + new(ChatRole.User, "What's the weather?") + }; + + var reducedMessages = new List + { + new(ChatRole.System, "You are a helpful assistant"), + new(ChatRole.User, "What's the weather?") + }; + + var reducer = new TestReducer { ReducedMessages = reducedMessages }; + var expectedResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "It's sunny!")); + var expectedUpdates = new[] { new ChatResponseUpdate(ChatRole.Assistant, "It's"), new ChatResponseUpdate(null, " sunny!") }; + + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + // Verify that the inner client receives the reduced messages + Assert.Same(reducedMessages, messages); + return Task.FromResult(expectedResponse); + }, + GetStreamingResponseAsyncCallback = (messages, options, cancellationToken) => + { + // Verify that the inner client receives the reduced messages + Assert.Same(reducedMessages, messages); + return ToAsyncEnumerable(expectedUpdates); + } + }; + + using var client = new ReducingChatClient(innerClient, reducer); + + if (streaming) + { + var updates = new List(); + await foreach (var update in client.GetStreamingResponseAsync(originalMessages)) + { + updates.Add(update); + } + + Assert.Equal(expectedUpdates.Length, updates.Count); + for (int i = 0; i < expectedUpdates.Length; i++) + { + Assert.Same(expectedUpdates[i], updates[i]); + } + } + else + { + var response = await client.GetResponseAsync(originalMessages); + Assert.Same(expectedResponse, response); + } + + Assert.Equal(1, reducer.ReduceAsyncCallCount); + Assert.Same(originalMessages, reducer.LastMessagesProvided); + } + + [Fact] + public async Task UseChatReducer_WithReducerFromServices() + { + var reducedMessages = new List { new(ChatRole.User, "Reduced message") }; + var reducer = new TestReducer { ReducedMessages = reducedMessages }; + + var services = new ServiceCollection(); + services.AddSingleton(reducer); + var serviceProvider = services.BuildServiceProvider(); + + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + Assert.Same(reducedMessages, messages); + return Task.FromResult(new ChatResponse()); + } + }; + + using var client = innerClient + .AsBuilder() + .UseChatReducer() // Should get reducer from services + .Build(serviceProvider); + + await client.GetResponseAsync(new List { new(ChatRole.User, "Original message") }); + Assert.Equal(1, reducer.ReduceAsyncCallCount); + } + + [Fact] + public void UseChatReducer_WithoutReducerParameterAndWithoutService_Throws() + { + using var innerClient = new TestChatClient(); + var services = new ServiceCollection().BuildServiceProvider(); + + var exception = Assert.Throws(() => + innerClient + .AsBuilder() + .UseChatReducer() // No reducer provided and not in services + .Build(services)); + + Assert.Contains("IChatReducer", exception.Message); + } + + [Fact] + public async Task UseChatReducer_WithConfigureCallback() + { + var reducer = new TestReducer(); + var configureCalled = false; + ReducingChatClient? configuredClient = null; + + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => + Task.FromResult(new ChatResponse()) + }; + + using var client = innerClient + .AsBuilder() + .UseChatReducer(reducer, configure: chatClient => + { + configureCalled = true; + configuredClient = chatClient; + }) + .Build(); + + await client.GetResponseAsync([]); + + Assert.True(configureCalled); + Assert.NotNull(configuredClient); + Assert.IsType(configuredClient); + } + + private static async IAsyncEnumerable ToAsyncEnumerable(IEnumerable items) + { + foreach (var item in items) + { + await Task.Yield(); + yield return item; + } + } + + private sealed class TestReducer : IChatReducer + { + public IEnumerable? ReducedMessages { get; set; } + public int ReduceAsyncCallCount { get; private set; } + public IEnumerable? LastMessagesProvided { get; private set; } + + public Task> ReduceAsync(IEnumerable messages, CancellationToken cancellationToken) + { + ReduceAsyncCallCount++; + LastMessagesProvided = messages; + return Task.FromResult(ReducedMessages ?? messages); + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatReduction/MessageCountingChatReducerTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatReduction/MessageCountingChatReducerTests.cs new file mode 100644 index 00000000000..b2b74c711d7 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatReduction/MessageCountingChatReducerTests.cs @@ -0,0 +1,263 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class MessageCountingChatReducerTests +{ + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(-10)] + public void Constructor_ThrowsOnInvalidTargetCount(int targetCount) + { + Assert.Throws(nameof(targetCount), () => new MessageCountingChatReducer(targetCount)); + } + + [Fact] + public void Constructor_AcceptsValidTargetCount() + { + var reducer = new MessageCountingChatReducer(5); + Assert.NotNull(reducer); + } + + [Fact] + public async Task ReduceAsync_ThrowsOnNullMessages() + { + var reducer = new MessageCountingChatReducer(5); + await Assert.ThrowsAsync(() => reducer.ReduceAsync(null!, CancellationToken.None)); + } + + [Fact] + public async Task ReduceAsync_HandlesEmptyMessages() + { + var reducer = new MessageCountingChatReducer(5); + var result = await reducer.ReduceAsync([], CancellationToken.None); + Assert.Empty(result); + } + + [Fact] + public async Task ReduceAsync_PreservesFirstSystemMessage() + { + var reducer = new MessageCountingChatReducer(2); + + List messages = + [ + new ChatMessage(ChatRole.System, "You are a helpful assistant."), + new ChatMessage(ChatRole.User, "Hello"), + new ChatMessage(ChatRole.Assistant, "Hi there!"), + new ChatMessage(ChatRole.User, "How are you?"), + new ChatMessage(ChatRole.Assistant, "I'm doing well, thanks!"), + ]; + + var result = await reducer.ReduceAsync(messages, CancellationToken.None); + var resultList = result.ToList(); + + Assert.Collection(resultList, + m => + { + Assert.Equal(ChatRole.System, m.Role); + Assert.Equal("You are a helpful assistant.", m.Text); + }, + m => + { + Assert.Equal(ChatRole.User, m.Role); + Assert.Equal("How are you?", m.Text); + }, + m => + { + Assert.Equal(ChatRole.Assistant, m.Role); + Assert.Equal("I'm doing well, thanks!", m.Text); + }); + } + + [Fact] + public async Task ReduceAsync_OnlyFirstSystemMessageIsPreserved() + { + var reducer = new MessageCountingChatReducer(2); + + List messages = + [ + new ChatMessage(ChatRole.System, "First system message"), + new ChatMessage(ChatRole.User, "Hello"), + new ChatMessage(ChatRole.System, "Second system message"), + new ChatMessage(ChatRole.Assistant, "Hi"), + new ChatMessage(ChatRole.User, "How are you?"), + new ChatMessage(ChatRole.Assistant, "I'm fine!"), + ]; + + var result = await reducer.ReduceAsync(messages, CancellationToken.None); + var resultList = result.ToList(); + + Assert.Collection(resultList, + m => + { + Assert.Equal(ChatRole.System, m.Role); + Assert.Equal("First system message", m.Text); + }, + m => + { + Assert.Equal(ChatRole.User, m.Role); + Assert.Equal("How are you?", m.Text); + }, + m => + { + Assert.Equal(ChatRole.Assistant, m.Role); + Assert.Equal("I'm fine!", m.Text); + }); + + // Second system message should not be preserved separately + Assert.Equal(1, resultList.Count(m => m.Role == ChatRole.System)); + } + + [Fact] + public async Task ReduceAsync_IgnoresFunctionCallsAndResults() + { + var reducer = new MessageCountingChatReducer(2); + + List messages = + [ + new ChatMessage(ChatRole.User, "What's the weather?"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather", new Dictionary { ["location"] = "Seattle" })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("call1", "Sunny, 72°F")]), + new ChatMessage(ChatRole.Assistant, "The weather in Seattle is sunny and 72°F."), + new ChatMessage(ChatRole.User, "Thanks!"), + new ChatMessage(ChatRole.Assistant, "You're welcome!"), + ]; + + var result = await reducer.ReduceAsync(messages, CancellationToken.None); + var resultList = result.ToList(); + + Assert.Collection(resultList, + m => + { + Assert.Equal(ChatRole.User, m.Role); + Assert.Equal("Thanks!", m.Text); + Assert.DoesNotContain(m.Contents, c => c is FunctionCallContent); + Assert.DoesNotContain(m.Contents, c => c is FunctionResultContent); + }, + m => + { + Assert.Equal(ChatRole.Assistant, m.Role); + Assert.Equal("You're welcome!", m.Text); + Assert.DoesNotContain(m.Contents, c => c is FunctionCallContent); + Assert.DoesNotContain(m.Contents, c => c is FunctionResultContent); + }); + } + + [Theory] + [InlineData(5, 3, 3)] // Less messages than target + [InlineData(5, 5, 5)] // Exactly at target + [InlineData(5, 8, 5)] // More messages than target + [InlineData(1, 10, 1)] // Only keep 1 message + public async Task ReduceAsync_RespectsTargetCount(int targetCount, int messageCount, int expectedCount) + { + var reducer = new MessageCountingChatReducer(targetCount); + + var messages = new List(); + for (int i = 0; i < messageCount; i++) + { + messages.Add(new ChatMessage(i % 2 == 0 ? ChatRole.User : ChatRole.Assistant, $"Message {i}")); + } + + var result = await reducer.ReduceAsync(messages, CancellationToken.None); + var resultList = result.ToList(); + + Assert.Equal(expectedCount, resultList.Count); + + // Verify we kept the most recent messages + if (messageCount > targetCount) + { + var startIndex = messageCount - targetCount; + var expectedMessages = new Action[targetCount]; + for (int i = 0; i < targetCount; i++) + { + var expectedIndex = startIndex + i; + var expectedRole = expectedIndex % 2 == 0 ? ChatRole.User : ChatRole.Assistant; + expectedMessages[i] = m => + { + Assert.Equal(expectedRole, m.Role); + Assert.Equal($"Message {expectedIndex}", m.Text); + }; + } + + Assert.Collection(resultList, expectedMessages); + } + } + + [Fact] + public async Task ReduceAsync_HandlesOnlySystemMessage() + { + var reducer = new MessageCountingChatReducer(5); + + List messages = + [ + new ChatMessage(ChatRole.System, "System prompt"), + ]; + + var result = await reducer.ReduceAsync(messages, CancellationToken.None); + var resultList = result.ToList(); + + Assert.Collection(resultList, + m => + { + Assert.Equal(ChatRole.System, m.Role); + Assert.Equal("System prompt", m.Text); + }); + } + + [Fact] + public async Task ReduceAsync_HandlesOnlyFunctionMessages() + { + var reducer = new MessageCountingChatReducer(5); + + List messages = + [ + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "func", null)]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("call1", "result")]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call2", "func", null)]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("call2", "result")]), + ]; + + var result = await reducer.ReduceAsync(messages, CancellationToken.None); + + Assert.Empty(result); + } + + [Fact] + public async Task ReduceAsync_HandlesTargetCountOfOne() + { + var reducer = new MessageCountingChatReducer(1); + + List messages = + [ + new ChatMessage(ChatRole.System, "System"), + new ChatMessage(ChatRole.User, "First"), + new ChatMessage(ChatRole.Assistant, "Second"), + new ChatMessage(ChatRole.User, "Third"), + new ChatMessage(ChatRole.Assistant, "Fourth"), + ]; + + var result = await reducer.ReduceAsync(messages, CancellationToken.None); + var resultList = result.ToList(); + + Assert.Collection(resultList, + m => + { + Assert.Equal(ChatRole.System, m.Role); + Assert.Equal("System", m.Text); + }, + m => + { + Assert.Equal(ChatRole.Assistant, m.Role); + Assert.Equal("Fourth", m.Text); + }); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatReduction/SummarizingChatReducerTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatReduction/SummarizingChatReducerTests.cs new file mode 100644 index 00000000000..8fa801c4811 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatReduction/SummarizingChatReducerTests.cs @@ -0,0 +1,403 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable S103 // Lines should not be too long + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class SummarizingChatReducerTests +{ + [Fact] + public void Constructor_ThrowsOnNullChatClient() + { + Assert.Throws("chatClient", () => new SummarizingChatReducer(null!, targetCount: 5, threshold: 2)); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(-10)] + public void Constructor_ThrowsOnInvalidTargetCount(int targetCount) + { + using var chatClient = new TestChatClient(); + Assert.Throws(nameof(targetCount), () => new SummarizingChatReducer(chatClient, targetCount, threshold: 2)); + } + + [Theory] + [InlineData(-1)] + [InlineData(-10)] + public void Constructor_ThrowsOnInvalidThresholdCount(int thresholdCount) + { + using var chatClient = new TestChatClient(); + Assert.Throws("threshold", () => new SummarizingChatReducer(chatClient, targetCount: 5, thresholdCount)); + } + + [Fact] + public async Task ReduceAsync_ThrowsOnNullMessages() + { + using var chatClient = new TestChatClient(); + var reducer = new SummarizingChatReducer(chatClient, targetCount: 5, threshold: 2); + await Assert.ThrowsAsync(() => reducer.ReduceAsync(null!, CancellationToken.None)); + } + + [Fact] + public async Task ReduceAsync_HandlesEmptyMessages() + { + using var chatClient = new TestChatClient(); + var reducer = new SummarizingChatReducer(chatClient, targetCount: 5, threshold: 2); + + var result = await reducer.ReduceAsync([], CancellationToken.None); + + Assert.Empty(result); + } + + [Fact] + public async Task ReduceAsync_PreservesSystemMessage() + { + using var chatClient = new TestChatClient(); + var reducer = new SummarizingChatReducer(chatClient, targetCount: 1, threshold: 0); + + List messages = + [ + new ChatMessage(ChatRole.System, "You are a helpful assistant."), + new ChatMessage(ChatRole.User, "Hello"), + new ChatMessage(ChatRole.Assistant, "Hi there!"), + new ChatMessage(ChatRole.User, "How are you?"), + ]; + + chatClient.GetResponseAsyncCallback = (_, _, _) => + Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Summary of conversation"))); + + var result = await reducer.ReduceAsync(messages, CancellationToken.None); + + var resultList = result.ToList(); + Assert.Equal(3, resultList.Count); // System + Summary + 1 unsummarized + Assert.Equal(ChatRole.System, resultList[0].Role); + Assert.Equal("You are a helpful assistant.", resultList[0].Text); + } + + [Fact] + public async Task ReduceAsync_PreservesCompleteToolCallSequence() + { + using var chatClient = new TestChatClient(); + + // Target 2 messages, but this would split a function call sequence + var reducer = new SummarizingChatReducer(chatClient, targetCount: 2, threshold: 0); + + List messages = + [ + new ChatMessage(ChatRole.User, "What's the time?"), + new ChatMessage(ChatRole.Assistant, "Let me check"), + new ChatMessage(ChatRole.User, "What's the weather?"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather"), new TestUserInputRequestContent("uir1")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("call1", "Sunny")]), + new ChatMessage(ChatRole.User, [new TestUserInputResponseContent("uir1")]), + new ChatMessage(ChatRole.Assistant, "It's sunny"), + ]; + + chatClient.GetResponseAsyncCallback = (msgs, _, _) => + { + Assert.DoesNotContain(msgs, m => m.Contents.Any(c => c is FunctionCallContent or FunctionResultContent or TestUserInputRequestContent or TestUserInputResponseContent)); + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Asked about time"))); + }; + + var result = await reducer.ReduceAsync(messages, CancellationToken.None); + var resultList = result.ToList(); + + // Should have: summary + function call + function result + user input response + last reply + Assert.Equal(5, resultList.Count); + + // Verify the complete sequence is preserved + Assert.Collection(resultList, + m => Assert.Contains("Asked about time", m.Text), + m => + { + Assert.Contains(m.Contents, c => c is FunctionCallContent); + Assert.Contains(m.Contents, c => c is TestUserInputRequestContent); + }, + m => Assert.Contains(m.Contents, c => c is FunctionResultContent), + m => Assert.Contains(m.Contents, c => c is TestUserInputResponseContent), + m => Assert.Contains("sunny", m.Text)); + } + + [Fact] + public async Task ReduceAsync_PreservesUserMessageWhenWithinThreshold() + { + using var chatClient = new TestChatClient(); + + // Target 3 messages with threshold of 2 + // This allows us to keep anywhere from 3 to 5 messages + var reducer = new SummarizingChatReducer(chatClient, targetCount: 3, threshold: 2); + + List messages = + [ + new ChatMessage(ChatRole.User, "First question"), + new ChatMessage(ChatRole.Assistant, "First answer"), + new ChatMessage(ChatRole.User, "Second question"), + new ChatMessage(ChatRole.Assistant, "Second answer"), + new ChatMessage(ChatRole.User, "Third question"), + new ChatMessage(ChatRole.Assistant, "Third answer"), + ]; + + chatClient.GetResponseAsyncCallback = (msgs, _, _) => + { + var msgList = msgs.ToList(); + + // Should summarize messages 0-1 (First question and answer) + // The reducer should find the User message at index 2 within the threshold + Assert.Equal(3, msgList.Count); // 2 messages to summarize + system prompt + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Summary of first exchange"))); + }; + + var result = await reducer.ReduceAsync(messages, CancellationToken.None); + var resultList = result.ToList(); + + // Should have: summary + 4 kept messages (from "Second question" onward) + Assert.Equal(5, resultList.Count); + + // Verify the summary is first + Assert.Contains("Summary", resultList[0].Text); + + // Verify we kept the User message at index 2 and everything after + Assert.Collection(resultList.Skip(1), + m => Assert.Contains("Second question", m.Text), + m => Assert.Contains("Second answer", m.Text), + m => Assert.Contains("Third question", m.Text), + m => Assert.Contains("Third answer", m.Text)); + } + + [Fact] + public async Task ReduceAsync_ExcludesToolCallsFromSummarizedPortion() + { + using var chatClient = new TestChatClient(); + + // Target 3 messages - this will cause function calls in older messages to be summarized (excluded) + // while function calls in recent messages are kept + var reducer = new SummarizingChatReducer(chatClient, targetCount: 3, threshold: 0); + + List messages = + [ + new ChatMessage(ChatRole.User, "What's the weather in Seattle?"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather", new Dictionary { ["location"] = "Seattle" }), new TestUserInputRequestContent("uir2")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("call1", "Sunny, 72°F")]), + new ChatMessage(ChatRole.User, [new TestUserInputResponseContent("uir2")]), + new ChatMessage(ChatRole.Assistant, "It's sunny and 72°F in Seattle."), + new ChatMessage(ChatRole.User, "What about New York?"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call2", "get_weather", new Dictionary { ["location"] = "New York" })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("call2", "Rainy, 65°F")]), + new ChatMessage(ChatRole.Assistant, "It's rainy and 65°F in New York."), + ]; + + chatClient.GetResponseAsyncCallback = (msgs, _, _) => + { + var msgList = msgs.ToList(); + + Assert.Equal(4, msgList.Count); // 3 non-function messages + system prompt + Assert.DoesNotContain(msgList, m => m.Contents.Any(c => c is FunctionCallContent or FunctionResultContent or TestUserInputRequestContent or TestUserInputResponseContent)); + Assert.Contains(msgList, m => m.Text.Contains("What's the weather in Seattle?")); + Assert.Contains(msgList, m => m.Text.Contains("sunny and 72°F in Seattle")); + Assert.Contains(msgList, m => m.Text.Contains("What about New York?")); + Assert.Contains(msgList, m => m.Role == ChatRole.System); + + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "User asked about weather in Seattle and New York."))); + }; + + var result = await reducer.ReduceAsync(messages, CancellationToken.None); + var resultList = result.ToList(); + + // Should have: summary + 3 kept messages (the last 3 messages with function calls) + Assert.Equal(4, resultList.Count); + + Assert.Contains("User asked about weather", resultList[0].Text); + Assert.Contains(resultList, m => m.Contents.Any(c => c is FunctionCallContent fc && fc.CallId == "call2")); + Assert.Contains(resultList, m => m.Contents.Any(c => c is FunctionResultContent fr && fr.CallId == "call2")); + Assert.DoesNotContain(resultList, m => m.Contents.Any(c => c is FunctionCallContent fc && fc.CallId == "call1")); + Assert.DoesNotContain(resultList, m => m.Contents.Any(c => c is FunctionResultContent fr && fr.CallId == "call1")); + Assert.DoesNotContain(resultList, m => m.Contents.Any(c => c is TestUserInputRequestContent)); + Assert.DoesNotContain(resultList, m => m.Contents.Any(c => c is TestUserInputResponseContent)); + Assert.DoesNotContain(resultList, m => m.Text.Contains("sunny and 72°F in Seattle")); + } + + [Theory] + [InlineData(5, 0, 5, false)] // Exactly at target, no summarization + [InlineData(5, 0, 4, false)] // Below target, no summarization + [InlineData(5, 0, 6, true)] // Above target by 1, triggers summarization + [InlineData(5, 2, 7, false)] // At threshold boundary, no summarization + [InlineData(5, 2, 8, true)] // Above threshold, triggers summarization + public async Task ReduceAsync_RespectsTargetAndThresholdCounts(int targetCount, int thresholdCount, int messageCount, bool shouldSummarize) + { + using var chatClient = new TestChatClient(); + var reducer = new SummarizingChatReducer(chatClient, targetCount, thresholdCount); + + var messages = new List(); + for (int i = 0; i < messageCount; i++) + { + messages.Add(new ChatMessage(ChatRole.Assistant, $"Message {i}")); + } + + var summarizationCalled = false; + chatClient.GetResponseAsyncCallback = (_, _, _) => + { + summarizationCalled = true; + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Summary"))); + }; + + var result = await reducer.ReduceAsync(messages, CancellationToken.None); + + Assert.Equal(shouldSummarize, summarizationCalled); + + if (shouldSummarize) + { + var resultList = result.ToList(); + Assert.Equal(targetCount + 1, resultList.Count); // Summary + target messages + Assert.StartsWith("Summary", resultList[0].Text, StringComparison.Ordinal); + } + else + { + Assert.Equal(messageCount, result.Count()); + } + } + + [Fact] + public async Task ReduceAsync_CancellationTokenIsRespected() + { + using var chatClient = new TestChatClient(); + var reducer = new SummarizingChatReducer(chatClient, targetCount: 1, threshold: 0); + + List messages = + [ + new ChatMessage(ChatRole.User, "Message 1"), + new ChatMessage(ChatRole.Assistant, "Response 1"), + new ChatMessage(ChatRole.User, "Message 2"), + ]; + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + chatClient.GetResponseAsyncCallback = (_, _, cancellationToken) => + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Summary"))); + }; + + await Assert.ThrowsAsync(() => + reducer.ReduceAsync(messages, cts.Token)); + } + + [Fact] + public async Task ReduceAsync_OnlyFirstSystemMessageIsPreserved() + { + using var chatClient = new TestChatClient(); + var reducer = new SummarizingChatReducer(chatClient, targetCount: 1, threshold: 0); + + List messages = + [ + new ChatMessage(ChatRole.System, "First system message"), + new ChatMessage(ChatRole.User, "Hello"), + new ChatMessage(ChatRole.System, "Second system message"), + new ChatMessage(ChatRole.Assistant, "Hi"), + new ChatMessage(ChatRole.User, "How are you?"), + ]; + + chatClient.GetResponseAsyncCallback = (_, _, _) => + Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Summary"))); + + var result = await reducer.ReduceAsync(messages, CancellationToken.None); + + var resultList = result.ToList(); + Assert.Equal(ChatRole.System, resultList[0].Role); + Assert.Equal("First system message", resultList[0].Text); + + // Second system message should not be preserved separately + Assert.Equal(1, resultList.Count(m => m.Role == ChatRole.System)); + } + + [Fact] + public async Task CanHaveSummarizedConversation() + { + using var chatClientForSummarization = new TestChatClient(); + var reducer = new SummarizingChatReducer(chatClientForSummarization, targetCount: 2, threshold: 0); + + List messages = + [ + new ChatMessage(ChatRole.User, "Hi there! Can you tell me about golden retrievers?"), + new ChatMessage(ChatRole.Assistant, "Of course! Golden retrievers are known for their friendly and tolerant attitudes. They're great family pets and are very intelligent and easy to train."), + new ChatMessage(ChatRole.User, "What kind of exercise do they need?"), + new ChatMessage(ChatRole.Assistant, "Golden retrievers are quite active and need regular exercise. Daily walks, playtime, and activities like fetching or swimming are great for them."), + new ChatMessage(ChatRole.User, "Are they good with kids?"), + ]; + + chatClientForSummarization.GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + Assert.Equal(4, messages.Count()); // 3 messages to summarize + 1 system prompt + Assert.Collection(messages, + m => Assert.StartsWith("Hi there!", m.Text, StringComparison.Ordinal), + m => Assert.StartsWith("Of course!", m.Text, StringComparison.Ordinal), + m => Assert.StartsWith("What kind of exercise", m.Text, StringComparison.Ordinal), + m => Assert.Equal(ChatRole.System, m.Role)); + const string Summary = """ + The user asked for information about golden retrievers. + The assistant explained that they have characteristics making them great family pets. + The user then asked what kind of exercise they need. + """; + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, Summary))); + }; + + var reducedMessages = await reducer.ReduceAsync(messages, CancellationToken.None); + + Assert.Equal(3, reducedMessages.Count()); // 1 summary + 2 unsummarized messages + Assert.Collection(reducedMessages, + m => Assert.StartsWith("The user asked for information", m.Text, StringComparison.Ordinal), + m => Assert.StartsWith("Golden retrievers are quite", m.Text, StringComparison.Ordinal), + m => Assert.StartsWith("Are they good with kids", m.Text, StringComparison.Ordinal)); + + messages.Add(new ChatMessage(ChatRole.Assistant, "Golden retrievers get along well with kids! They're able to be playful and energetic while remaining gentle.")); + messages.Add(new ChatMessage(ChatRole.User, "Do they make good lap dogs?")); + + chatClientForSummarization.GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + Assert.Equal(4, messages.Count()); // 1 summary message, 2 unsummarized message, 1 system prompt + Assert.Collection(messages, + m => Assert.StartsWith("The user asked", m.Text, StringComparison.Ordinal), + m => Assert.StartsWith("Golden retrievers are quite active", m.Text, StringComparison.Ordinal), + m => Assert.StartsWith("Are they good with kids", m.Text, StringComparison.Ordinal), + m => Assert.Equal(ChatRole.System, m.Role)); + const string Summary = """ + The user and assistant are discussing characteristics of golden retrievers. + The user asked what kind of exercise they need, and the assitant explained that golden retrievers + need frequent exercise. The user then asked about whether they're good around kids. + """; + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, Summary))); + }; + + reducedMessages = await reducer.ReduceAsync(messages, CancellationToken.None); + Assert.Equal(3, reducedMessages.Count()); // 1 summary + 2 unsummarized messages + Assert.Collection(reducedMessages, + m => Assert.StartsWith("The user and assistant are discussing", m.Text, StringComparison.Ordinal), + m => Assert.StartsWith("Golden retrievers get along", m.Text, StringComparison.Ordinal), + m => Assert.StartsWith("Do they make good lap dogs", m.Text, StringComparison.Ordinal)); + } + + private sealed class TestUserInputRequestContent : UserInputRequestContent + { + public TestUserInputRequestContent(string id) + : base(id) + { + } + } + + private sealed class TestUserInputResponseContent : UserInputResponseContent + { + public TestUserInputResponseContent(string id) + : base(id) + { + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/OpenTelemetryEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/OpenTelemetryEmbeddingGeneratorTests.cs index 418d11544a3..b533cbc00c4 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/OpenTelemetryEmbeddingGeneratorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Embeddings/OpenTelemetryEmbeddingGeneratorTests.cs @@ -4,7 +4,6 @@ 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; @@ -78,20 +77,20 @@ public async Task ExpectedInformationLogged_Async(string? perRequestModelId, boo Assert.NotNull(activity.Id); Assert.NotEmpty(activity.Id); - Assert.Equal("http://localhost:12345/something", activity.GetTagItem("server.address")); + Assert.Equal("localhost", activity.GetTagItem("server.address")); Assert.Equal(12345, (int)activity.GetTagItem("server.port")!); Assert.Equal($"embeddings {expectedModelName}", activity.DisplayName); - Assert.Equal("testservice", activity.GetTagItem("gen_ai.system")); + Assert.Equal("testservice", activity.GetTagItem("gen_ai.provider.name")); Assert.Equal(expectedModelName, activity.GetTagItem("gen_ai.request.model")); - Assert.Equal(1234, activity.GetTagItem("gen_ai.request.embedding.dimensions")); - Assert.Equal(enableSensitiveData ? "value1" : null, activity.GetTagItem("gen_ai.testservice.request.service_tier")); - Assert.Equal(enableSensitiveData ? "value2" : null, activity.GetTagItem("gen_ai.testservice.request.something_else")); + Assert.Equal(1234, activity.GetTagItem("gen_ai.embeddings.dimension.count")); + Assert.Equal(enableSensitiveData ? "value1" : null, activity.GetTagItem("service_tier")); + Assert.Equal(enableSensitiveData ? "value2" : null, activity.GetTagItem("SomethingElse")); Assert.Equal(10, activity.GetTagItem("gen_ai.usage.input_tokens")); - Assert.Equal(enableSensitiveData ? "abcdefgh" : null, activity.GetTagItem("gen_ai.testservice.response.system_fingerprint")); - Assert.Equal(enableSensitiveData ? "value3" : null, activity.GetTagItem("gen_ai.testservice.response.and_something_else")); + Assert.Equal(enableSensitiveData ? "abcdefgh" : null, activity.GetTagItem("system_fingerprint")); + Assert.Equal(enableSensitiveData ? "value3" : null, activity.GetTagItem("AndSomethingElse")); Assert.True(activity.Duration.TotalMilliseconds > 0); } diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/EnumeratedOnceEnumerable.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/EnumeratedOnceEnumerable.cs new file mode 100644 index 00000000000..84e0ba9ab61 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/EnumeratedOnceEnumerable.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Threading; + +namespace Microsoft.Extensions.AI; + +internal sealed class EnumeratedOnceEnumerable(IEnumerable items) : IEnumerable +{ + private int _iterated; + + public IEnumerator GetEnumerator() + { + if (Interlocked.Exchange(ref _iterated, 1) != 0) + { + throw new InvalidOperationException("This enumerable can only be enumerated once."); + } + + foreach (var item in items) + { + yield return item; + } + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs index afced22038f..deea4cbcf13 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.Linq; using System.Reflection; using System.Text.Json; using System.Text.Json.Nodes; @@ -14,6 +15,7 @@ using Xunit; #pragma warning disable IDE0004 // Remove Unnecessary Cast +#pragma warning disable S103 // Lines should not be too long #pragma warning disable S107 // Methods should not have too many parameters #pragma warning disable S2760 // Sequential tests should not check the same condition #pragma warning disable S3358 // Ternary operators should not be nested @@ -58,6 +60,51 @@ public async Task Parameters_DefaultValuesAreUsedButOverridable_Async() AssertExtensions.EqualFunctionCallResults("hello hello", await func.InvokeAsync(new() { ["a"] = "hello" })); } + [Fact] + public async Task Parameters_DefaultValueAttributeIsRespected_Async() + { + // Test with null default value + AIFunction funcNull = AIFunctionFactory.Create(([DefaultValue(null)] string? text) => text ?? "was null"); + + // Schema should not list 'text' as required and should have default value + string schema = funcNull.JsonSchema.ToString(); + Assert.Contains("\"text\"", schema); + Assert.DoesNotContain("\"required\"", schema); + Assert.Contains("\"default\":null", schema); + + // Should be invocable without providing the parameter + AssertExtensions.EqualFunctionCallResults("was null", await funcNull.InvokeAsync()); + + // Should be overridable + AssertExtensions.EqualFunctionCallResults("hello", await funcNull.InvokeAsync(new() { ["text"] = "hello" })); + + // Test with non-null default value + AIFunction funcValue = AIFunctionFactory.Create(([DefaultValue("default")] string text) => text); + schema = funcValue.JsonSchema.ToString(); + Assert.DoesNotContain("\"required\"", schema); + Assert.Contains("\"default\":\"default\"", schema); + + AssertExtensions.EqualFunctionCallResults("default", await funcValue.InvokeAsync()); + AssertExtensions.EqualFunctionCallResults("custom", await funcValue.InvokeAsync(new() { ["text"] = "custom" })); + + // Test with int default value + AIFunction funcInt = AIFunctionFactory.Create(([DefaultValue(42)] int x) => x * 2); + schema = funcInt.JsonSchema.ToString(); + Assert.DoesNotContain("\"required\"", schema); + Assert.Contains("\"default\":42", schema); + + AssertExtensions.EqualFunctionCallResults(84, await funcInt.InvokeAsync()); + AssertExtensions.EqualFunctionCallResults(10, await funcInt.InvokeAsync(new() { ["x"] = 5 })); + + // Test that DefaultValue attribute takes precedence over C# default value + AIFunction funcBoth = AIFunctionFactory.Create(([DefaultValue(100)] int y = 50) => y); + schema = funcBoth.JsonSchema.ToString(); + Assert.DoesNotContain("\"required\"", schema); + Assert.Contains("\"default\":100", schema); // DefaultValue should take precedence + + AssertExtensions.EqualFunctionCallResults(100, await funcBoth.InvokeAsync()); // Should use DefaultValue, not C# default + } + [Fact] public async Task Parameters_MissingRequiredParametersFail_Async() { @@ -234,6 +281,39 @@ public void Metadata_DerivedFromLambda() p => Assert.Equal("This is B", p.GetCustomAttribute()?.Description)); } + [Fact] + public void Metadata_DisplayNameAttribute() + { + // Test DisplayNameAttribute on a delegate method + Func funcWithDisplayName = [DisplayName("get_user_id")] () => "test"; + AIFunction func = AIFunctionFactory.Create(funcWithDisplayName); + Assert.Equal("get_user_id", func.Name); + Assert.Empty(func.Description); + + // Test DisplayNameAttribute with DescriptionAttribute + Func funcWithBoth = [DisplayName("my_function")][Description("A test function")] () => "test"; + func = AIFunctionFactory.Create(funcWithBoth); + Assert.Equal("my_function", func.Name); + Assert.Equal("A test function", func.Description); + + // Test that explicit name parameter takes precedence over DisplayNameAttribute + func = AIFunctionFactory.Create(funcWithDisplayName, name: "explicit_name"); + Assert.Equal("explicit_name", func.Name); + + // Test DisplayNameAttribute with options + func = AIFunctionFactory.Create(funcWithDisplayName, new AIFunctionFactoryOptions()); + Assert.Equal("get_user_id", func.Name); + + // Test that options.Name takes precedence over DisplayNameAttribute + func = AIFunctionFactory.Create(funcWithDisplayName, new AIFunctionFactoryOptions { Name = "options_name" }); + Assert.Equal("options_name", func.Name); + + // Test function without DisplayNameAttribute falls back to method name + Func funcWithoutDisplayName = () => "test"; + func = AIFunctionFactory.Create(funcWithoutDisplayName); + Assert.Contains("Metadata_DisplayNameAttribute", func.Name); // Will contain the lambda method name + } + [Fact] public void AIFunctionFactoryCreateOptions_ValuesPropagateToAIFunction() { @@ -274,6 +354,7 @@ public void AIFunctionFactoryOptions_DefaultValues() Assert.Null(options.SerializerOptions); Assert.Null(options.JsonSchemaCreateOptions); Assert.Null(options.ConfigureParameterBinding); + Assert.False(options.ExcludeResultSchema); } [Fact] @@ -300,6 +381,21 @@ public async Task AIFunctionFactoryOptions_SupportsSkippingParameters() Assert.Contains("test42", result.ToString()); } + [Fact] + public void AIFunctionFactoryOptions_SupportsSkippingReturnSchema() + { + AIFunction func = AIFunctionFactory.Create( + (string firstParameter, int secondParameter) => firstParameter + secondParameter, + new() + { + ExcludeResultSchema = true, + }); + + Assert.Contains("firstParameter", func.JsonSchema.ToString()); + Assert.Contains("secondParameter", func.JsonSchema.ToString()); + Assert.Null(func.ReturnJsonSchema); + } + [Fact] public async Task AIFunctionArguments_SatisfiesParameters() { @@ -827,6 +923,63 @@ public async Task MarshalResult_TypeIsDeclaredTypeEvenWhenDerivedTypeReturned() Assert.Equal("marshalResultInvoked", result); } + [Fact] + public async Task AIContentReturnType_NotSerializedByDefault() + { + await ValidateAsync( + [ + AIFunctionFactory.Create(() => (AIContent)new TextContent("text")), + AIFunctionFactory.Create(async () => (AIContent)new TextContent("text")), + AIFunctionFactory.Create(async ValueTask () => (AIContent)new TextContent("text")), + AIFunctionFactory.Create(() => new TextContent("text")), + AIFunctionFactory.Create(async () => new TextContent("text")), + AIFunctionFactory.Create(async ValueTask () => new TextContent("text")), + ]); + + await ValidateAsync( + [ + AIFunctionFactory.Create(() => new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream")), + AIFunctionFactory.Create(async () => new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream")), + AIFunctionFactory.Create(async ValueTask () => new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream")), + ]); + + await ValidateAsync>( + [ + AIFunctionFactory.Create(() => (IEnumerable)[new TextContent("text"), new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream")]), + AIFunctionFactory.Create(async () => (IEnumerable)[new TextContent("text"), new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream")]), + AIFunctionFactory.Create(async ValueTask> () => (IEnumerable)[new TextContent("text"), new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream")]), + ]); + + await ValidateAsync( + [ + AIFunctionFactory.Create(() => (AIContent[])[new TextContent("text"), new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream")]), + AIFunctionFactory.Create(async () => (AIContent[])[new TextContent("text"), new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream")]), + AIFunctionFactory.Create(async ValueTask () => (AIContent[])[new TextContent("text"), new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream")]), + ]); + + await ValidateAsync>( + [ + AIFunctionFactory.Create(() => (List)[new TextContent("text"), new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream")]), + AIFunctionFactory.Create(async () => (List)[new TextContent("text"), new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream")]), + AIFunctionFactory.Create(async ValueTask> () => (List)[new TextContent("text"), new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream")]), + ]); + + await ValidateAsync>( + [ + AIFunctionFactory.Create(() => (IList)[new TextContent("text"), new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream")]), + AIFunctionFactory.Create(async () => (IList)[new TextContent("text"), new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream")]), + AIFunctionFactory.Create(async ValueTask> () => (List)[new TextContent("text"), new DataContent(new byte[] { 1, 2, 3 }, "application/octet-stream")]), + ]); + + static async Task ValidateAsync(IEnumerable functions) + { + foreach (var f in functions) + { + Assert.IsAssignableFrom(await f.InvokeAsync()); + } + } + } + [Fact] public async Task AIFunctionFactory_DefaultDefaultParameter() { @@ -838,6 +991,71 @@ public async Task AIFunctionFactory_DefaultDefaultParameter() Assert.Contains("00000000-0000-0000-0000-000000000000,0", result?.ToString()); } + [Fact] + public async Task AIFunctionFactory_NullableParameters() + { + Assert.NotEqual(new StructWithDefaultCtor().Value, default(StructWithDefaultCtor).Value); + + AIFunction f = AIFunctionFactory.Create( + (int? limit = null, DateTime? from = null) => Enumerable.Repeat(from ?? default, limit ?? 4).Select(d => d.Year).ToArray(), + serializerOptions: JsonContext.Default.Options); + + JsonElement expectedSchema = JsonDocument.Parse(""" + { + "type": "object", + "properties": { + "limit": { + "type": ["integer", "null"], + "default": null + }, + "from": { + "type": ["string", "null"], + "format": "date-time", + "default": null + } + } + } + """).RootElement; + + AssertExtensions.EqualJsonValues(expectedSchema, f.JsonSchema); + + object? result = await f.InvokeAsync(); + Assert.Contains("[1,1,1,1]", result?.ToString()); + } + + [Fact] + public async Task AIFunctionFactory_NullableParameters_AllowReadingFromString() + { + JsonSerializerOptions options = new(JsonContext.Default.Options) { NumberHandling = JsonNumberHandling.AllowReadingFromString }; + Assert.NotEqual(new StructWithDefaultCtor().Value, default(StructWithDefaultCtor).Value); + + AIFunction f = AIFunctionFactory.Create( + (int? limit = null, DateTime? from = null) => Enumerable.Repeat(from ?? default, limit ?? 4).Select(d => d.Year).ToArray(), + serializerOptions: options); + + JsonElement expectedSchema = JsonDocument.Parse(""" + { + "type": "object", + "properties": { + "limit": { + "type": ["integer", "null"], + "default": null + }, + "from": { + "type": ["string", "null"], + "format": "date-time", + "default": null + } + } + } + """).RootElement; + + AssertExtensions.EqualJsonValues(expectedSchema, f.JsonSchema); + + object? result = await f.InvokeAsync(); + Assert.Contains("[1,1,1,1]", result?.ToString()); + } + [Fact] public void AIFunctionFactory_ReturnTypeWithDescriptionAttribute() { @@ -849,6 +1067,26 @@ public void AIFunctionFactory_ReturnTypeWithDescriptionAttribute() static int Add(int a, int b) => a + b; } + [Fact] + public void CreateDeclaration_Roundtrips() + { + JsonElement schema = AIJsonUtilities.CreateJsonSchema(typeof(int), serializerOptions: AIJsonUtilities.DefaultOptions); + + AIFunctionDeclaration f = AIFunctionFactory.CreateDeclaration("something", "amazing", schema); + Assert.Equal("something", f.Name); + Assert.Equal("amazing", f.Description); + Assert.Equal("""{"type":"integer"}""", f.JsonSchema.ToString()); + Assert.Null(f.ReturnJsonSchema); + + f = AIFunctionFactory.CreateDeclaration("other", null, default, schema); + Assert.Equal("other", f.Name); + Assert.Empty(f.Description); + Assert.Equal(default, f.JsonSchema); + Assert.Equal("""{"type":"integer"}""", f.ReturnJsonSchema.ToString()); + + Assert.Throws("name", () => AIFunctionFactory.CreateDeclaration(null!, "description", default)); + } + private sealed class MyService(int value) { public int Value => value; @@ -903,6 +1141,8 @@ private sealed class MyFunctionTypeWithOneArg(MyArgumentType arg) private sealed class MyArgumentType; + private static int TestStaticMethod(int a, int b) => a + b; + private class A; private class B : A; private sealed class C : B; @@ -937,11 +1177,150 @@ private static AIFunctionFactoryOptions CreateKeyedServicesSupportOptions() => }, }; + [Fact] + public void LocalFunction_NameCleanup() + { + static void DoSomething() + { + // Empty local function for testing name cleanup + } + + var tool = AIFunctionFactory.Create(DoSomething); + + // The name should start with: ContainingMethodName_LocalFunctionName (followed by ordinal) + Assert.StartsWith("LocalFunction_NameCleanup_DoSomething_", tool.Name); + } + + [Fact] + public void LocalFunction_MultipleInSameMethod() + { + static void FirstLocal() + { + // Empty local function for testing name cleanup + } + + static void SecondLocal() + { + // Empty local function for testing name cleanup + } + + var tool1 = AIFunctionFactory.Create(FirstLocal); + var tool2 = AIFunctionFactory.Create(SecondLocal); + + // Each should have unique names based on the local function name (including ordinal) + Assert.StartsWith("LocalFunction_MultipleInSameMethod_FirstLocal_", tool1.Name); + Assert.StartsWith("LocalFunction_MultipleInSameMethod_SecondLocal_", tool2.Name); + Assert.NotEqual(tool1.Name, tool2.Name); + } + + [Fact] + public void Lambda_NameCleanup() + { + Action lambda = () => + { + // Empty lambda for testing name cleanup + }; + + var tool = AIFunctionFactory.Create(lambda); + + // The name should be the containing method name with ordinal for uniqueness + Assert.StartsWith("Lambda_NameCleanup", tool.Name); + } + + [Fact] + public void Lambda_MultipleInSameMethod() + { + Action lambda1 = () => + { + // Empty lambda for testing name cleanup + }; + + Action lambda2 = () => + { + // Empty lambda for testing name cleanup + }; + + var tool1 = AIFunctionFactory.Create(lambda1); + var tool2 = AIFunctionFactory.Create(lambda2); + + // Each lambda should have a unique name based on its ordinal + // to allow the LLM to distinguish between them + Assert.StartsWith("Lambda_MultipleInSameMethod", tool1.Name); + Assert.StartsWith("Lambda_MultipleInSameMethod", tool2.Name); + Assert.NotEqual(tool1.Name, tool2.Name); + } + + [Fact] + public void LocalFunction_WithParameters() + { + static int Add(int firstNumber, int secondNumber) => firstNumber + secondNumber; + + var tool = AIFunctionFactory.Create(Add); + + Assert.StartsWith("LocalFunction_WithParameters_Add_", tool.Name); + Assert.Contains("firstNumber", tool.JsonSchema.ToString()); + Assert.Contains("secondNumber", tool.JsonSchema.ToString()); + } + + [Fact] + public async Task LocalFunction_AsyncFunction() + { + static async Task FetchDataAsync() + { + await Task.Yield(); + return "data"; + } + + var tool = AIFunctionFactory.Create(FetchDataAsync); + + // Should strip "Async" suffix and include ordinal + Assert.StartsWith("LocalFunction_AsyncFunction_FetchData_", tool.Name); + + var result = await tool.InvokeAsync(); + AssertExtensions.EqualFunctionCallResults("data", result); + } + + [Fact] + public void LocalFunction_ExplicitNameOverride() + { + static void DoSomething() + { + // Empty local function for testing name cleanup + } + + var tool = AIFunctionFactory.Create(DoSomething, name: "CustomName"); + + Assert.Equal("CustomName", tool.Name); + } + + [Fact] + public void LocalFunction_InsideTestMethod() + { + // Even local functions defined in test methods get cleaned up + var tool = AIFunctionFactory.Create(Add, serializerOptions: JsonContext.Default.Options); + + Assert.StartsWith("LocalFunction_InsideTestMethod_Add_", tool.Name); + + [return: Description("The summed result")] + static int Add(int a, int b) => a + b; + } + + [Fact] + public void RegularStaticMethod_NameUnchanged() + { + // Test that actual static methods (not local functions) have names unchanged + var tool = AIFunctionFactory.Create(TestStaticMethod, null, serializerOptions: JsonContext.Default.Options); + + Assert.Equal("TestStaticMethod", tool.Name); + } + [JsonSerializable(typeof(IAsyncEnumerable))] [JsonSerializable(typeof(int[]))] [JsonSerializable(typeof(string))] [JsonSerializable(typeof(Guid))] [JsonSerializable(typeof(StructWithDefaultCtor))] [JsonSerializable(typeof(B))] + [JsonSerializable(typeof(int?))] + [JsonSerializable(typeof(DateTime?))] private partial class JsonContext : JsonSerializerContext; } diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Image/ConfigureOptionsImageGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Image/ConfigureOptionsImageGeneratorTests.cs new file mode 100644 index 00000000000..ba37cad1b54 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Image/ConfigureOptionsImageGeneratorTests.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.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ConfigureOptionsImageGeneratorTests +{ + [Fact] + public void ConfigureOptionsImageGenerator_InvalidArgs_Throws() + { + Assert.Throws("innerGenerator", () => new ConfigureOptionsImageGenerator(null!, _ => { })); + Assert.Throws("configure", () => new ConfigureOptionsImageGenerator(new TestImageGenerator(), null!)); + } + + [Fact] + public void ConfigureOptions_InvalidArgs_Throws() + { + using var innerGenerator = new TestImageGenerator(); + var builder = innerGenerator.AsBuilder(); + Assert.Throws("configure", () => builder.ConfigureOptions(null!)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ConfigureOptions_ReturnedInstancePassedToNextGenerator(bool nullProvidedOptions) + { + ImageGenerationOptions? providedOptions = nullProvidedOptions ? null : new() { ModelId = "test" }; + ImageGenerationOptions? returnedOptions = null; + ImageGenerationResponse expectedResponse = new([]); + using CancellationTokenSource cts = new(); + + using IImageGenerator innerGenerator = new TestImageGenerator + { + GenerateImagesAsyncCallback = (prompt, options, cancellationToken) => + { + Assert.Same(returnedOptions, options); + Assert.Equal(cts.Token, cancellationToken); + return Task.FromResult(expectedResponse); + }, + + }; + + using var generator = innerGenerator + .AsBuilder() + .ConfigureOptions(options => + { + Assert.NotSame(providedOptions, options); + if (nullProvidedOptions) + { + Assert.Null(options.ModelId); + } + else + { + Assert.Equal(providedOptions!.ModelId, options.ModelId); + } + + returnedOptions = options; + }) + .Build(); + + var response1 = await generator.GenerateImagesAsync("test prompt", providedOptions, cts.Token); + Assert.Same(expectedResponse, response1); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Image/ImageGeneratorBuilderTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Image/ImageGeneratorBuilderTests.cs new file mode 100644 index 00000000000..c0cfdc3ea06 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Image/ImageGeneratorBuilderTests.cs @@ -0,0 +1,106 @@ +// Licensed 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 ImageGeneratorBuilderTests +{ + [Fact] + public void PassesServiceProviderToFactories() + { + var expectedServiceProvider = new ServiceCollection().BuildServiceProvider(); + using TestImageGenerator expectedInnerGenerator = new(); + using TestImageGenerator expectedOuterGenerator = new(); + + var builder = new ImageGeneratorBuilder(services => + { + Assert.Same(expectedServiceProvider, services); + return expectedInnerGenerator; + }); + + builder.Use((innerGenerator, serviceProvider) => + { + Assert.Same(expectedServiceProvider, serviceProvider); + Assert.Same(expectedInnerGenerator, innerGenerator); + return expectedOuterGenerator; + }); + + Assert.Same(expectedOuterGenerator, builder.Build(expectedServiceProvider)); + } + + [Fact] + public void BuildsPipelineInOrderAdded() + { + // Arrange + using TestImageGenerator expectedInnerGenerator = new(); + var builder = new ImageGeneratorBuilder(expectedInnerGenerator); + + builder.Use(next => new InnerGeneratorCapturingImageGenerator("First", next)); + builder.Use(next => new InnerGeneratorCapturingImageGenerator("Second", next)); + builder.Use(next => new InnerGeneratorCapturingImageGenerator("Third", next)); + + // Act + var first = (InnerGeneratorCapturingImageGenerator)builder.Build(); + + // Assert + Assert.Equal("First", first.Name); + var second = (InnerGeneratorCapturingImageGenerator)first.InnerGenerator; + Assert.Equal("Second", second.Name); + var third = (InnerGeneratorCapturingImageGenerator)second.InnerGenerator; + Assert.Equal("Third", third.Name); + Assert.Same(expectedInnerGenerator, third.InnerGenerator); + } + + [Fact] + public void DoesNotAcceptNullInnerService() + { + Assert.Throws("innerGenerator", () => new ImageGeneratorBuilder((IImageGenerator)null!)); + Assert.Throws("innerGenerator", () => ((IImageGenerator)null!).AsBuilder()); + } + + [Fact] + public void DoesNotAcceptNullFactories() + { + Assert.Throws("innerGeneratorFactory", () => new ImageGeneratorBuilder((Func)null!)); + } + + [Fact] + public void DoesNotAllowFactoriesToReturnNull() + { + using var innerGenerator = new TestImageGenerator(); + ImageGeneratorBuilder builder = new(innerGenerator); + builder.Use(_ => null!); + var ex = Assert.Throws(() => builder.Build()); + Assert.Contains("entry at index 0", ex.Message); + } + + [Fact] + public void UsesEmptyServiceProviderWhenNoServicesProvided() + { + using var innerGenerator = new TestImageGenerator(); + ImageGeneratorBuilder builder = new(innerGenerator); + builder.Use((innerGenerator, 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 innerGenerator; + }); + builder.Build(); + } + + private sealed class InnerGeneratorCapturingImageGenerator(string name, IImageGenerator innerGenerator) : DelegatingImageGenerator(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 IImageGenerator InnerGenerator => base.InnerGenerator; + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Image/ImageGeneratorDependencyInjectionPatterns.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Image/ImageGeneratorDependencyInjectionPatterns.cs new file mode 100644 index 00000000000..a17cd5a5c41 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Image/ImageGeneratorDependencyInjectionPatterns.cs @@ -0,0 +1,178 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ImageGeneratorDependencyInjectionPatterns +{ + private IServiceCollection ServiceCollection { get; } = new ServiceCollection(); + + [Fact] + public void CanRegisterSingletonUsingFactory() + { + // Arrange/Act + ServiceCollection.AddImageGenerator(services => new TestImageGenerator { Services = services }) + .UseSingletonMiddleware(); + + // Assert + var services = ServiceCollection.BuildServiceProvider(); + using var scope1 = services.CreateScope(); + using var scope2 = services.CreateScope(); + + var instance1 = scope1.ServiceProvider.GetRequiredService(); + var instance1Copy = scope1.ServiceProvider.GetRequiredService(); + var instance2 = scope2.ServiceProvider.GetRequiredService(); + + // Each scope gets the same instance, because it's singleton + var instance = Assert.IsType(instance1); + Assert.Same(instance, instance1Copy); + Assert.Same(instance, instance2); + Assert.IsType(instance.InnerGenerator); + } + + [Fact] + public void CanRegisterSingletonUsingSharedInstance() + { + // Arrange/Act + using var singleton = new TestImageGenerator(); + ServiceCollection.AddImageGenerator(singleton) + .UseSingletonMiddleware(); + + // Assert + var services = ServiceCollection.BuildServiceProvider(); + using var scope1 = services.CreateScope(); + using var scope2 = services.CreateScope(); + + var instance1 = scope1.ServiceProvider.GetRequiredService(); + var instance1Copy = scope1.ServiceProvider.GetRequiredService(); + var instance2 = scope2.ServiceProvider.GetRequiredService(); + + // Each scope gets the same instance, because it's singleton + var instance = Assert.IsType(instance1); + Assert.Same(instance, instance1Copy); + Assert.Same(instance, instance2); + Assert.IsType(instance.InnerGenerator); + } + + [Fact] + public void CanRegisterKeyedSingletonUsingFactory() + { + // Arrange/Act + ServiceCollection.AddKeyedImageGenerator("mykey", services => new TestImageGenerator { Services = services }) + .UseSingletonMiddleware(); + + // Assert + var services = ServiceCollection.BuildServiceProvider(); + using var scope1 = services.CreateScope(); + using var scope2 = services.CreateScope(); + + Assert.Null(services.GetService()); + + var instance1 = scope1.ServiceProvider.GetRequiredKeyedService("mykey"); + var instance1Copy = scope1.ServiceProvider.GetRequiredKeyedService("mykey"); + var instance2 = scope2.ServiceProvider.GetRequiredKeyedService("mykey"); + + // Each scope gets the same instance, because it's singleton + var instance = Assert.IsType(instance1); + Assert.Same(instance, instance1Copy); + Assert.Same(instance, instance2); + Assert.IsType(instance.InnerGenerator); + } + + [Fact] + public void CanRegisterKeyedSingletonUsingSharedInstance() + { + // Arrange/Act + using var singleton = new TestImageGenerator(); + ServiceCollection.AddKeyedImageGenerator("mykey", singleton) + .UseSingletonMiddleware(); + + // Assert + var services = ServiceCollection.BuildServiceProvider(); + using var scope1 = services.CreateScope(); + using var scope2 = services.CreateScope(); + + Assert.Null(services.GetService()); + + var instance1 = scope1.ServiceProvider.GetRequiredKeyedService("mykey"); + var instance1Copy = scope1.ServiceProvider.GetRequiredKeyedService("mykey"); + var instance2 = scope2.ServiceProvider.GetRequiredKeyedService("mykey"); + + // Each scope gets the same instance, because it's singleton + var instance = Assert.IsType(instance1); + Assert.Same(instance, instance1Copy); + Assert.Same(instance, instance2); + Assert.IsType(instance.InnerGenerator); + } + + [Theory] + [InlineData(null)] + [InlineData(ServiceLifetime.Singleton)] + [InlineData(ServiceLifetime.Scoped)] + [InlineData(ServiceLifetime.Transient)] + public void AddImageGenerator_RegistersExpectedLifetime(ServiceLifetime? lifetime) + { + ServiceCollection sc = new(); + ServiceLifetime expectedLifetime = lifetime ?? ServiceLifetime.Singleton; + ImageGeneratorBuilder builder = lifetime.HasValue + ? sc.AddImageGenerator(services => new TestImageGenerator(), lifetime.Value) + : sc.AddImageGenerator(services => new TestImageGenerator()); + + ServiceDescriptor sd = Assert.Single(sc); + Assert.Equal(typeof(IImageGenerator), sd.ServiceType); + Assert.False(sd.IsKeyedService); + Assert.Null(sd.ImplementationInstance); + Assert.NotNull(sd.ImplementationFactory); + Assert.IsType(sd.ImplementationFactory(null!)); + Assert.Equal(expectedLifetime, sd.Lifetime); + } + + [Theory] + [InlineData(null)] + [InlineData(ServiceLifetime.Singleton)] + [InlineData(ServiceLifetime.Scoped)] + [InlineData(ServiceLifetime.Transient)] + public void AddKeyedImageGenerator_RegistersExpectedLifetime(ServiceLifetime? lifetime) + { + ServiceCollection sc = new(); + ServiceLifetime expectedLifetime = lifetime ?? ServiceLifetime.Singleton; + ImageGeneratorBuilder builder = lifetime.HasValue + ? sc.AddKeyedImageGenerator("key", services => new TestImageGenerator(), lifetime.Value) + : sc.AddKeyedImageGenerator("key", services => new TestImageGenerator()); + + ServiceDescriptor sd = Assert.Single(sc); + Assert.Equal(typeof(IImageGenerator), sd.ServiceType); + Assert.True(sd.IsKeyedService); + Assert.Equal("key", sd.ServiceKey); + Assert.Null(sd.KeyedImplementationInstance); + Assert.NotNull(sd.KeyedImplementationFactory); + Assert.IsType(sd.KeyedImplementationFactory(null!, null!)); + Assert.Equal(expectedLifetime, sd.Lifetime); + } + + [Fact] + public void AddKeyedImageGenerator_WorksWithNullServiceKey() + { + ServiceCollection sc = new(); + sc.AddKeyedImageGenerator(null, _ => new TestImageGenerator()); + + ServiceDescriptor sd = Assert.Single(sc); + Assert.Equal(typeof(IImageGenerator), sd.ServiceType); + Assert.False(sd.IsKeyedService); + Assert.Null(sd.ServiceKey); + Assert.Null(sd.ImplementationInstance); + Assert.NotNull(sd.ImplementationFactory); + Assert.IsType(sd.ImplementationFactory(null!)); + Assert.Equal(ServiceLifetime.Singleton, sd.Lifetime); + } + + public class SingletonMiddleware(IImageGenerator inner, IServiceProvider services) : DelegatingImageGenerator(inner) + { + public new IImageGenerator InnerGenerator => base.InnerGenerator; + public IServiceProvider Services => services; + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Image/LoggingImageGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Image/LoggingImageGeneratorTests.cs new file mode 100644 index 00000000000..6f3f0a43092 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Image/LoggingImageGeneratorTests.cs @@ -0,0 +1,142 @@ +// Licensed 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.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Testing; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class LoggingImageGeneratorTests +{ + [Fact] + public void LoggingImageGenerator_InvalidArgs_Throws() + { + Assert.Throws("innerGenerator", () => new LoggingImageGenerator(null!, NullLogger.Instance)); + Assert.Throws("logger", () => new LoggingImageGenerator(new TestImageGenerator(), null!)); + } + + [Fact] + public void UseLogging_AvoidsInjectingNopGenerator() + { + using var innerGenerator = new TestImageGenerator(); + + Assert.Null(innerGenerator.AsBuilder().UseLogging(NullLoggerFactory.Instance).Build().GetService(typeof(LoggingImageGenerator))); + Assert.Same(innerGenerator, innerGenerator.AsBuilder().UseLogging(NullLoggerFactory.Instance).Build().GetService(typeof(IImageGenerator))); + + using var factory = LoggerFactory.Create(b => b.AddFakeLogging()); + Assert.NotNull(innerGenerator.AsBuilder().UseLogging(factory).Build().GetService(typeof(LoggingImageGenerator))); + + ServiceCollection c = new(); + c.AddFakeLogging(); + var services = c.BuildServiceProvider(); + Assert.NotNull(innerGenerator.AsBuilder().UseLogging().Build(services).GetService(typeof(LoggingImageGenerator))); + Assert.NotNull(innerGenerator.AsBuilder().UseLogging(null).Build(services).GetService(typeof(LoggingImageGenerator))); + Assert.Null(innerGenerator.AsBuilder().UseLogging(NullLoggerFactory.Instance).Build(services).GetService(typeof(LoggingImageGenerator))); + } + + [Theory] + [InlineData(LogLevel.Trace)] + [InlineData(LogLevel.Debug)] + [InlineData(LogLevel.Information)] + public async Task GenerateImagesAsync_LogsInvocationAndCompletion(LogLevel level) + { + var collector = new FakeLogCollector(); + + ServiceCollection c = new(); + c.AddLogging(b => b.AddProvider(new FakeLoggerProvider(collector)).SetMinimumLevel(level)); + var services = c.BuildServiceProvider(); + + using IImageGenerator innerGenerator = new TestImageGenerator + { + GenerateImagesAsyncCallback = (request, options, cancellationToken) => + { + return Task.FromResult(new ImageGenerationResponse()); + }, + }; + + using IImageGenerator generator = innerGenerator + .AsBuilder() + .UseLogging() + .Build(services); + + await generator.GenerateAsync( + new ImageGenerationRequest("A beautiful sunset"), + new ImageGenerationOptions { ModelId = "dall-e-3" }); + + var logs = collector.GetSnapshot(); + if (level is LogLevel.Trace) + { + Assert.Collection(logs, + entry => Assert.True( + entry.Message.Contains($"{nameof(IImageGenerator.GenerateAsync)} invoked:") && + entry.Message.Contains("A beautiful sunset") && + entry.Message.Contains("dall-e-3")), + entry => Assert.Contains($"{nameof(IImageGenerator.GenerateAsync)} completed:", entry.Message)); + } + else if (level is LogLevel.Debug) + { + Assert.Collection(logs, + entry => Assert.True(entry.Message.Contains($"{nameof(IImageGenerator.GenerateAsync)} invoked.") && !entry.Message.Contains("A beautiful sunset")), + entry => Assert.True(entry.Message.Contains($"{nameof(IImageGenerator.GenerateAsync)} completed.") && !entry.Message.Contains("dall-e-3"))); + } + else + { + Assert.Empty(logs); + } + } + + [Theory] + [InlineData(LogLevel.Trace)] + [InlineData(LogLevel.Debug)] + [InlineData(LogLevel.Information)] + public async Task GenerateImagesAsync_WithOriginalImages_LogsInvocationAndCompletion(LogLevel level) + { + var collector = new FakeLogCollector(); + using ILoggerFactory loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector)).SetMinimumLevel(level)); + + using IImageGenerator innerGenerator = new TestImageGenerator + { + GenerateImagesAsyncCallback = (request, options, cancellationToken) => + { + return Task.FromResult(new ImageGenerationResponse()); + } + }; + + using IImageGenerator generator = innerGenerator + .AsBuilder() + .UseLogging(loggerFactory) + .Build(); + + AIContent[] originalImages = [new DataContent((byte[])[1, 2, 3, 4], "image/png")]; + await generator.GenerateAsync( + new ImageGenerationRequest("Make it more colorful", originalImages), + new ImageGenerationOptions { ModelId = "dall-e-3" }); + + var logs = collector.GetSnapshot(); + if (level is LogLevel.Trace) + { + Assert.Collection(logs, + entry => Assert.True( + entry.Message.Contains($"{nameof(IImageGenerator.GenerateAsync)} invoked:") && + entry.Message.Contains("Make it more colorful") && + entry.Message.Contains("dall-e-3")), + entry => Assert.Contains($"{nameof(IImageGenerator.GenerateAsync)} completed", entry.Message)); + } + else if (level is LogLevel.Debug) + { + Assert.Collection(logs, + entry => Assert.True(entry.Message.Contains($"{nameof(IImageGenerator.GenerateAsync)} invoked.") && !entry.Message.Contains("Make it more colorful")), + entry => Assert.True(entry.Message.Contains($"{nameof(IImageGenerator.GenerateAsync)} completed.") && !entry.Message.Contains("dall-e-3"))); + } + else + { + Assert.Empty(logs); + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Image/OpenTelemetryImageGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Image/OpenTelemetryImageGeneratorTests.cs new file mode 100644 index 00000000000..30fc4ae4849 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Image/OpenTelemetryImageGeneratorTests.cs @@ -0,0 +1,168 @@ +// Licensed 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.Text.RegularExpressions; +using System.Threading.Tasks; +using OpenTelemetry.Trace; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class OpenTelemetryImageGeneratorTests +{ + [Fact] + public void InvalidArgs_Throws() + { + Assert.Throws("innerGenerator", () => new OpenTelemetryImageGenerator(null!)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ExpectedInformationLogged_Async(bool enableSensitiveData) + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + using var innerGenerator = new TestImageGenerator + { + GenerateImagesAsyncCallback = async (request, options, cancellationToken) => + { + await Task.Yield(); + + return new() + { + Contents = + [ + new UriContent("http://example/output.png", "image/png"), + new DataContent(new byte[] { 1, 2, 3, 4 }, "image/png") { Name = "moreOutput.png" }, + ], + + Usage = new() + { + InputTokenCount = 10, + OutputTokenCount = 20, + TotalTokenCount = 30, + }, + }; + }, + + GetServiceCallback = (serviceType, serviceKey) => + serviceType == typeof(ImageGeneratorMetadata) ? new ImageGeneratorMetadata("testservice", new Uri("http://localhost:12345/something"), "amazingmodel") : + null, + }; + + using var g = innerGenerator + .AsBuilder() + .UseOpenTelemetry(null, sourceName, configure: instance => + { + instance.EnableSensitiveData = enableSensitiveData; + }) + .Build(); + + ImageGenerationRequest request = new() + { + Prompt = "This is the input prompt.", + OriginalImages = [new UriContent("http://example/input.png", "image/png")], + }; + + ImageGenerationOptions options = new() + { + Count = 2, + ImageSize = new(1024, 768), + MediaType = "image/jpeg", + ModelId = "mycoolimagemodel", + AdditionalProperties = new() + { + ["service_tier"] = "value1", + ["SomethingElse"] = "value2", + }, + }; + + await g.GenerateAsync(request, options); + + var activity = Assert.Single(activities); + + Assert.NotNull(activity.Id); + Assert.NotEmpty(activity.Id); + + Assert.Equal("localhost", activity.GetTagItem("server.address")); + Assert.Equal(12345, (int)activity.GetTagItem("server.port")!); + + Assert.Equal("generate_content mycoolimagemodel", activity.DisplayName); + Assert.Equal("testservice", activity.GetTagItem("gen_ai.provider.name")); + + Assert.Equal("mycoolimagemodel", activity.GetTagItem("gen_ai.request.model")); + Assert.Equal(2, activity.GetTagItem("gen_ai.request.choice.count")); + Assert.Equal(1024, activity.GetTagItem("gen_ai.request.image.width")); + Assert.Equal(768, activity.GetTagItem("gen_ai.request.image.height")); + Assert.Equal(enableSensitiveData ? "value1" : null, activity.GetTagItem("service_tier")); + Assert.Equal(enableSensitiveData ? "value2" : null, activity.GetTagItem("SomethingElse")); + + Assert.Equal(10, activity.GetTagItem("gen_ai.usage.input_tokens")); + Assert.Equal(20, activity.GetTagItem("gen_ai.usage.output_tokens")); + + Assert.True(activity.Duration.TotalMilliseconds > 0); + + var tags = activity.Tags.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + if (enableSensitiveData) + { + Assert.Equal(ReplaceWhitespace(""" + [ + { + "role": "user", + "parts": [ + { + "type": "text", + "content": "This is the input prompt." + }, + { + "type": "uri", + "uri": "http://example/input.png", + "mime_type": "image/png", + "modality": "image" + } + ] + } + ] + """), ReplaceWhitespace(tags["gen_ai.input.messages"])); + + Assert.Equal(ReplaceWhitespace(""" + [ + { + "role": "assistant", + "parts": [ + { + "type": "uri", + "uri": "http://example/output.png", + "mime_type": "image/png", + "modality": "image" + }, + { + "type": "blob", + "content": "AQIDBA==", + "mime_type": "image/png", + "modality": "image" + } + ] + } + ] + """), ReplaceWhitespace(tags["gen_ai.output.messages"])); + } + else + { + Assert.False(tags.ContainsKey("gen_ai.input.messages")); + Assert.False(tags.ContainsKey("gen_ai.output.messages")); + } + + static string ReplaceWhitespace(string? input) => Regex.Replace(input ?? "", @"\s+", " ").Trim(); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Image/SingletonImageGeneratorExtensions.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Image/SingletonImageGeneratorExtensions.cs new file mode 100644 index 00000000000..498b4738962 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Image/SingletonImageGeneratorExtensions.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 SingletonImageGeneratorExtensions +{ + public static ImageGeneratorBuilder UseSingletonMiddleware(this ImageGeneratorBuilder builder) + => builder.Use((inner, services) + => new ImageGeneratorDependencyInjectionPatterns.SingletonMiddleware(inner, services)); +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Microsoft.Extensions.AI.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.Tests/Microsoft.Extensions.AI.Tests.csproj index e4f17abb179..d06b423a504 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Microsoft.Extensions.AI.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Microsoft.Extensions.AI.Tests.csproj @@ -5,7 +5,7 @@ - $(NoWarn);CA1063;CA1861;SA1130;VSTHRD003 + $(NoWarn);CA1063;CA1861;S104;SA1130;VSTHRD003 $(NoWarn);MEAI001 true @@ -22,6 +22,7 @@ + diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/SpeechToText/OpenTelemetrySpeechToTextClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/SpeechToText/OpenTelemetrySpeechToTextClientTests.cs new file mode 100644 index 00000000000..c243bf2bf12 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/SpeechToText/OpenTelemetrySpeechToTextClientTests.cs @@ -0,0 +1,150 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using OpenTelemetry.Trace; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class OpenTelemetrySpeechToTextClientTests +{ + [Fact] + public void InvalidArgs_Throws() + { + Assert.Throws("innerClient", () => new OpenTelemetrySpeechToTextClient(null!)); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task ExpectedInformationLogged_Async(bool streaming, bool enableSensitiveData) + { + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + using var innerClient = new TestSpeechToTextClient + { + GetTextAsyncCallback = async (request, options, cancellationToken) => + { + await Task.Yield(); + return new("This is the recognized text.") + { + Usage = new() + { + InputTokenCount = 10, + OutputTokenCount = 20, + TotalTokenCount = 30, + }, + }; + }, + + GetStreamingTextAsyncCallback = TestClientStreamAsync, + + GetServiceCallback = (serviceType, serviceKey) => + serviceType == typeof(SpeechToTextClientMetadata) ? new SpeechToTextClientMetadata("testservice", new Uri("http://localhost:12345/something"), "amazingmodel") : + null, + }; + + static async IAsyncEnumerable TestClientStreamAsync( + Stream request, SpeechToTextOptions? options, [EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.Yield(); + yield return new("This is"); + yield return new(" the recognized"); + yield return new() + { + Contents = + [ + new TextContent(" text."), + new UsageContent(new() + { + InputTokenCount = 10, + OutputTokenCount = 20, + TotalTokenCount = 30, + }), + ] + }; + } + + using var client = innerClient + .AsBuilder() + .UseOpenTelemetry(null, sourceName, configure: instance => + { + instance.EnableSensitiveData = enableSensitiveData; + }) + .Build(); + + SpeechToTextOptions options = new() + { + ModelId = "mycoolspeechmodel", + AdditionalProperties = new() + { + ["service_tier"] = "value1", + ["SomethingElse"] = "value2", + }, + }; + + var response = streaming ? + await client.GetStreamingTextAsync(Stream.Null, options).ToSpeechToTextResponseAsync() : + await client.GetTextAsync(Stream.Null, options); + + var activity = Assert.Single(activities); + + Assert.NotNull(activity.Id); + Assert.NotEmpty(activity.Id); + + Assert.Equal("localhost", activity.GetTagItem("server.address")); + Assert.Equal(12345, (int)activity.GetTagItem("server.port")!); + + Assert.Equal("generate_content mycoolspeechmodel", activity.DisplayName); + Assert.Equal("testservice", activity.GetTagItem("gen_ai.provider.name")); + + Assert.Equal("mycoolspeechmodel", activity.GetTagItem("gen_ai.request.model")); + Assert.Equal(enableSensitiveData ? "value1" : null, activity.GetTagItem("service_tier")); + Assert.Equal(enableSensitiveData ? "value2" : null, activity.GetTagItem("SomethingElse")); + + Assert.Equal(10, activity.GetTagItem("gen_ai.usage.input_tokens")); + Assert.Equal(20, activity.GetTagItem("gen_ai.usage.output_tokens")); + + Assert.True(activity.Duration.TotalMilliseconds > 0); + + var tags = activity.Tags.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + if (enableSensitiveData) + { + Assert.Equal(ReplaceWhitespace(""" + [ + { + "role": "assistant", + "parts": [ + { + "type": "text", + "content": "This is the recognized text." + } + ] + } + ] + """), ReplaceWhitespace(tags["gen_ai.output.messages"])); + } + else + { + Assert.False(tags.ContainsKey("gen_ai.output.messages")); + } + + static string ReplaceWhitespace(string? input) => Regex.Replace(input ?? "", @"\s+", " ").Trim(); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/SpeechToText/SpeechToTextClientDependencyInjectionPatterns.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/SpeechToText/SpeechToTextClientDependencyInjectionPatterns.cs index 07596a1bb6f..5595e1c82ce 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/SpeechToText/SpeechToTextClientDependencyInjectionPatterns.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/SpeechToText/SpeechToTextClientDependencyInjectionPatterns.cs @@ -154,6 +154,22 @@ public void AddKeyedSpeechToTextClient_RegistersExpectedLifetime(ServiceLifetime Assert.Equal(expectedLifetime, sd.Lifetime); } + [Fact] + public void AddKeyedSpeechToTextClient_WorksWithNullServiceKey() + { + ServiceCollection sc = new(); + sc.AddKeyedSpeechToTextClient(null, _ => new TestSpeechToTextClient()); + + ServiceDescriptor sd = Assert.Single(sc); + Assert.Equal(typeof(ISpeechToTextClient), sd.ServiceType); + Assert.False(sd.IsKeyedService); + Assert.Null(sd.ServiceKey); + Assert.Null(sd.ImplementationInstance); + Assert.NotNull(sd.ImplementationFactory); + Assert.IsType(sd.ImplementationFactory(null!)); + Assert.Equal(ServiceLifetime.Singleton, sd.Lifetime); + } + public class SingletonMiddleware(ISpeechToTextClient inner, IServiceProvider services) : DelegatingSpeechToTextClient(inner) { public new ISpeechToTextClient InnerClient => base.InnerClient; diff --git a/test/Libraries/Microsoft.Extensions.AmbientMetadata.Application.Tests/AcceptanceTests.cs b/test/Libraries/Microsoft.Extensions.AmbientMetadata.Application.Tests/AcceptanceTests.cs index d66842b018b..ace2a6148e6 100644 --- a/test/Libraries/Microsoft.Extensions.AmbientMetadata.Application.Tests/AcceptanceTests.cs +++ b/test/Libraries/Microsoft.Extensions.AmbientMetadata.Application.Tests/AcceptanceTests.cs @@ -39,12 +39,28 @@ await RunAsync( }, sectionName); + [Theory] + [InlineData("ambientmetadata:application")] + [InlineData(null)] + public async Task UseApplicationMetadata_HostApplicationBuilder_CreatesPopulatesAndRegistersOptions(string? sectionName) => + await RunAsync_HostBuilder( + (options, hostEnvironment) => + { + options.BuildVersion.Should().Be(_metadata.BuildVersion); + options.DeploymentRing.Should().Be(_metadata.DeploymentRing); + options.ApplicationName.Should().Be(_metadata.ApplicationName); + options.EnvironmentName.Should().Be(hostEnvironment.EnvironmentName); + + return Task.CompletedTask; + }, + sectionName); + private static async Task RunAsync(Func func, string? sectionName) { using var host = await FakeHost.CreateBuilder() // need to set applicationName manually, because - // netfx console test runner cannot get assebly name + // netfx console test runner cannot get assembly name // to be able to set it automatically // see https://source.dot.net/#Microsoft.Extensions.Hosting/HostBuilder.cs,240 .ConfigureHostConfiguration("applicationname", _metadata.ApplicationName) @@ -60,4 +76,31 @@ await func(host.Services.GetRequiredService>().Val host.Services.GetRequiredService()); await host.StopAsync(); } + + private static async Task RunAsync_HostBuilder(Func func, string? sectionName) + { + var builder = Host.CreateEmptyApplicationBuilder(new() + { + ApplicationName = _metadata.ApplicationName + }); + + // need to set applicationName manually, because + // netfx console test runner cannot get assembly name + // to be able to set it automatically + // see https://source.dot.net/#Microsoft.Extensions.Hosting/HostBuilder.cs,240 + builder + .UseApplicationMetadata(sectionName ?? "ambientmetadata:application") + .Services.AddApplicationMetadata(metadata => + { + metadata.BuildVersion = _metadata.BuildVersion; + metadata.DeploymentRing = _metadata.DeploymentRing; + }); + + using var host = builder.Build(); + await host.StartAsync(); + + await func(host.Services.GetRequiredService>().Value, + host.Services.GetRequiredService()); + await host.StopAsync(); + } } diff --git a/test/Libraries/Microsoft.Extensions.AmbientMetadata.Application.Tests/ApplicationMetadataExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AmbientMetadata.Application.Tests/ApplicationMetadataExtensionsTests.cs index 5091bd87e21..7fbc66a1e73 100644 --- a/test/Libraries/Microsoft.Extensions.AmbientMetadata.Application.Tests/ApplicationMetadataExtensionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AmbientMetadata.Application.Tests/ApplicationMetadataExtensionsTests.cs @@ -40,10 +40,20 @@ public void ApplicationMetadataExtensions_GivenAnyNullArgument_Throws() Assert.Throws(() => serviceCollection.AddApplicationMetadata((Action)null!)); Assert.Throws(() => serviceCollection.AddApplicationMetadata((IConfigurationSection)null!)); Assert.Throws(() => ((IHostBuilder)null!).UseApplicationMetadata(_fixture.Create())); + Assert.Throws(() => ((IHostApplicationBuilder)null!).UseApplicationMetadata(_fixture.Create())); Assert.Throws(() => new ConfigurationBuilder().AddApplicationMetadata(null!)); Assert.Throws(() => ((IConfigurationBuilder)null!).AddApplicationMetadata(null!)); } + [Fact] + public void ApplicationMetadataExtensions_GivenEmptyAction_DoesNotThrow() + { + var serviceCollection = new ServiceCollection(); + var config = new ConfigurationBuilder().Build(); + + Assert.Null(Record.Exception(() => serviceCollection.AddApplicationMetadata(_ => { }))); + } + [Theory] [InlineData(null)] [InlineData("")] @@ -66,6 +76,17 @@ public void UseApplicationMetadata_InvalidSectionName_Throws(string? sectionName act.Should().Throw(); } + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData(" ")] + public void UseApplicationMetadata_HostApplicationBuilder_InvalidSectionName_Throws(string? sectionName) + { + var act = () => Host.CreateEmptyApplicationBuilder(new()).UseApplicationMetadata(sectionName!); + act.Should().Throw(); + } + [Fact] public void AddApplicationMetadata_BuildsConfig() { diff --git a/test/Libraries/Microsoft.Extensions.AsyncState.Tests/AsyncContextTests.cs b/test/Libraries/Microsoft.Extensions.AsyncState.Tests/AsyncContextTests.cs index 1ad60e9ad4f..0fcdd415fd1 100644 --- a/test/Libraries/Microsoft.Extensions.AsyncState.Tests/AsyncContextTests.cs +++ b/test/Libraries/Microsoft.Extensions.AsyncState.Tests/AsyncContextTests.cs @@ -7,6 +7,7 @@ using Xunit; namespace Microsoft.Extensions.AsyncState.Test; + public class AsyncContextTests { [Fact] diff --git a/test/Libraries/Microsoft.Extensions.AsyncState.Tests/AsyncStateTokenTests.cs b/test/Libraries/Microsoft.Extensions.AsyncState.Tests/AsyncStateTokenTests.cs index 06a1c30e5b2..ef0a5023cbe 100644 --- a/test/Libraries/Microsoft.Extensions.AsyncState.Tests/AsyncStateTokenTests.cs +++ b/test/Libraries/Microsoft.Extensions.AsyncState.Tests/AsyncStateTokenTests.cs @@ -4,6 +4,7 @@ using Xunit; namespace Microsoft.Extensions.AsyncState.Test; + public class AsyncStateTokenTests { [Fact] diff --git a/test/Libraries/Microsoft.Extensions.AsyncState.Tests/FeaturesPooledPolicyTests.cs b/test/Libraries/Microsoft.Extensions.AsyncState.Tests/FeaturesPooledPolicyTests.cs index 909389acb95..5abd3e3d7cd 100644 --- a/test/Libraries/Microsoft.Extensions.AsyncState.Tests/FeaturesPooledPolicyTests.cs +++ b/test/Libraries/Microsoft.Extensions.AsyncState.Tests/FeaturesPooledPolicyTests.cs @@ -6,6 +6,7 @@ using Xunit; namespace Microsoft.Extensions.AsyncState.Test; + public class FeaturesPooledPolicyTests { [Fact] diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/BasicConfig.json b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/BasicConfig.json deleted file mode 100644 index 374114fb1db..00000000000 --- a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/BasicConfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "no_entry_options": { - "MaximumKeyLength": 937 - }, - "with_entry_options": { - "MaximumKeyLength": 937, - "DefaultEntryOptions": { - "LocalCacheExpiration": "00:02:00", - "Flags": "DisableCompression,DisableLocalCacheRead" - } - } -} diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/ExpirationTests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/ExpirationTests.cs index c33bcb50e98..562ba8ae98f 100644 --- a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/ExpirationTests.cs +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/ExpirationTests.cs @@ -10,6 +10,7 @@ using static Microsoft.Extensions.Caching.Hybrid.Tests.L2Tests; namespace Microsoft.Extensions.Caching.Hybrid.Tests; + public class ExpirationTests(ITestOutputHelper log) { [Fact] diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/FunctionalTests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/FunctionalTests.cs index 7b8396eb50d..730013dbe4f 100644 --- a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/FunctionalTests.cs +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/FunctionalTests.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection; namespace Microsoft.Extensions.Caching.Hybrid.Tests; + public class FunctionalTests : IClassFixture { private static ServiceProvider GetDefaultCache(out DefaultHybridCache cache, Action? config = null) diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/L2Tests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/L2Tests.cs index 5c9cc2a41c5..f5be5b5277d 100644 --- a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/L2Tests.cs +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/L2Tests.cs @@ -11,6 +11,7 @@ using Xunit.Abstractions; namespace Microsoft.Extensions.Caching.Hybrid.Tests; + public class L2Tests(ITestOutputHelper log) : IClassFixture { private static string CreateString(bool work = false) diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/LocalInvalidationTests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/LocalInvalidationTests.cs index 310f7d5cdce..6efc4b14d45 100644 --- a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/LocalInvalidationTests.cs +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/LocalInvalidationTests.cs @@ -9,6 +9,7 @@ using static Microsoft.Extensions.Caching.Hybrid.Tests.L2Tests; namespace Microsoft.Extensions.Caching.Hybrid.Tests; + public class LocalInvalidationTests(ITestOutputHelper log) : IClassFixture { private static ServiceProvider GetDefaultCache(out DefaultHybridCache cache, Action? config = null) 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..3cd6a56dca5 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 @@ -23,10 +23,4 @@ - - - PreserveNewest - - - diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/PayloadTests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/PayloadTests.cs index 1f125336dae..a4a9c470551 100644 --- a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/PayloadTests.cs +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/PayloadTests.cs @@ -12,6 +12,7 @@ using static Microsoft.Extensions.Caching.Hybrid.Tests.L2Tests; namespace Microsoft.Extensions.Caching.Hybrid.Tests; + public class PayloadTests(ITestOutputHelper log) : IClassFixture { private static ServiceProvider GetDefaultCache(out DefaultHybridCache cache, Action? config = null) diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/ServiceConstructionTests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/ServiceConstructionTests.cs index d66b325e802..384ace947c7 100644 --- a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/ServiceConstructionTests.cs +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/ServiceConstructionTests.cs @@ -6,16 +6,17 @@ using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Hybrid.Internal; using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Caching.SqlServer; +using Microsoft.Extensions.Caching.StackExchangeRedis; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; #if NET9_0_OR_GREATER using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Configuration.Json; #endif -#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously #pragma warning disable CS8769 // Nullability of reference types in type of parameter doesn't match implemented member (possibly because of nullability attributes). namespace Microsoft.Extensions.Caching.Hybrid.Tests; @@ -51,12 +52,228 @@ public void CanCreateServiceWithManualOptions() Assert.Null(defaults.LocalCacheExpiration); // wasn't specified } + [Fact] + public void CanCreateServiceWithKeyedDistributedCache() + { + var services = new ServiceCollection(); + services.TryAddKeyedSingleton(typeof(CustomMemoryDistributedCache1)); + services.AddHybridCache(options => options.DistributedCacheServiceKey = typeof(CustomMemoryDistributedCache1)); + + using ServiceProvider provider = services.BuildServiceProvider(); + var hybrid = Assert.IsType(provider.GetRequiredService()); + var hybridOptions = hybrid.Options; + + var backend = Assert.IsType(hybrid.BackendCache); + Assert.Same(typeof(CustomMemoryDistributedCache1), hybridOptions.DistributedCacheServiceKey); + Assert.Same(backend, provider.GetRequiredKeyedService(typeof(CustomMemoryDistributedCache1))); + } + + [Fact] + public void ThrowsWhenDistributedCacheKeyNotRegistered() + { + var services = new ServiceCollection(); + services.AddHybridCache(options => options.DistributedCacheServiceKey = typeof(CustomMemoryDistributedCache1)); + using ServiceProvider provider = services.BuildServiceProvider(); + + Assert.Throws(provider.GetRequiredService); + } + + [Fact] + public void ThrowsWhenRegisteredDistributedCacheIsNotKeyed() + { + var services = new ServiceCollection(); + services.AddDistributedMemoryCache(); + services.AddHybridCache(options => options.DistributedCacheServiceKey = typeof(CustomMemoryDistributedCache1)); + using ServiceProvider provider = services.BuildServiceProvider(); + + Assert.Throws(provider.GetRequiredService); + } + + [Fact] + public void CanCreateKeyedHybridCacheServiceWithNullKey() + { + var services = new ServiceCollection(); + services.AddKeyedHybridCache(null); + using ServiceProvider provider = services.BuildServiceProvider(); + + // Resolves using null key registration + Assert.IsType(provider.GetRequiredKeyedService(null)); + + // Resolves as the non-keyed registration + Assert.IsType(provider.GetRequiredService()); + } + + [Fact] + public void CanCreateKeyedServicesWithStringKeys() + { + var services = new ServiceCollection(); + services.AddKeyedHybridCache("one"); + services.AddKeyedHybridCache("two"); + services.AddHybridCache(); + using ServiceProvider provider = services.BuildServiceProvider(); + + Assert.IsType(provider.GetRequiredKeyedService("one")); + Assert.IsType(provider.GetRequiredKeyedService("two")); + Assert.IsType(provider.GetRequiredKeyedService(null)); + Assert.IsType(provider.GetRequiredService()); + } + + [Fact] + public void CanCreateKeyedServicesWithStringKeysAndSetupActions() + { + var services = new ServiceCollection(); + services.AddKeyedHybridCache("one", options => options.MaximumKeyLength = 1); + services.AddKeyedHybridCache("two", options => options.MaximumKeyLength = 2); + services.AddKeyedHybridCache(null, options => options.MaximumKeyLength = 3); + using ServiceProvider provider = services.BuildServiceProvider(); + + var one = Assert.IsType(provider.GetRequiredKeyedService("one")); + Assert.Equal(1, one.Options.MaximumKeyLength); + + var two = Assert.IsType(provider.GetRequiredKeyedService("two")); + Assert.Equal(2, two.Options.MaximumKeyLength); + + var threeKeyed = Assert.IsType(provider.GetRequiredKeyedService(null)); + Assert.Equal(3, threeKeyed.Options.MaximumKeyLength); + + var threeUnkeyed = Assert.IsType(provider.GetRequiredService()); + Assert.Equal(3, threeUnkeyed.Options.MaximumKeyLength); + } + + [Fact] + public void CanCreateKeyedServicesWithTypeKeys() + { + var services = new ServiceCollection(); + services.AddKeyedHybridCache(typeof(string)); + services.AddKeyedHybridCache(typeof(int)); + services.AddHybridCache(); + using ServiceProvider provider = services.BuildServiceProvider(); + + Assert.IsType(provider.GetRequiredKeyedService(typeof(string))); + Assert.IsType(provider.GetRequiredKeyedService(typeof(int))); + Assert.IsType(provider.GetRequiredKeyedService(null)); + Assert.IsType(provider.GetRequiredService()); + } + + [Fact] + public void CanCreateKeyedServicesWithTypeKeysAndSetupActions() + { + var services = new ServiceCollection(); + services.AddKeyedHybridCache(typeof(string), options => options.MaximumKeyLength = 1); + services.AddKeyedHybridCache(typeof(int), options => options.MaximumKeyLength = 2); + services.AddKeyedHybridCache(null, options => options.MaximumKeyLength = 3); + + using ServiceProvider provider = services.BuildServiceProvider(); + var one = Assert.IsType(provider.GetRequiredKeyedService(typeof(string))); + Assert.Equal(1, one.Options.MaximumKeyLength); + + var two = Assert.IsType(provider.GetRequiredKeyedService(typeof(int))); + Assert.Equal(2, two.Options.MaximumKeyLength); + + var threeKeyed = Assert.IsType(provider.GetRequiredKeyedService(null)); + Assert.Equal(3, threeKeyed.Options.MaximumKeyLength); + + var threeUnkeyed = Assert.IsType(provider.GetRequiredService()); + Assert.Equal(3, threeUnkeyed.Options.MaximumKeyLength); + } + + [Fact] + public void CanCreateKeyedServicesWithKeyedDistributedCaches() + { + var services = new ServiceCollection(); + services.TryAddKeyedSingleton(typeof(CustomMemoryDistributedCache1)); + services.TryAddKeyedSingleton(typeof(CustomMemoryDistributedCache2)); + + services.AddKeyedHybridCache("one", options => options.DistributedCacheServiceKey = typeof(CustomMemoryDistributedCache1)); + services.AddKeyedHybridCache("two", options => options.DistributedCacheServiceKey = typeof(CustomMemoryDistributedCache2)); + using ServiceProvider provider = services.BuildServiceProvider(); + + var cacheOne = Assert.IsType(provider.GetRequiredKeyedService("one")); + var cacheOneOptions = cacheOne.Options; + var cacheOneBackend = Assert.IsType(cacheOne.BackendCache); + Assert.Same(typeof(CustomMemoryDistributedCache1), cacheOneOptions.DistributedCacheServiceKey); + Assert.Same(cacheOneBackend, provider.GetRequiredKeyedService(typeof(CustomMemoryDistributedCache1))); + + var cacheTwo = Assert.IsType(provider.GetRequiredKeyedService("two")); + var cacheTwoOptions = cacheTwo.Options; + var cacheTwoBackend = Assert.IsType(cacheTwo.BackendCache); + Assert.Same(typeof(CustomMemoryDistributedCache2), cacheTwoOptions.DistributedCacheServiceKey); + Assert.Same(cacheTwoBackend, provider.GetRequiredKeyedService(typeof(CustomMemoryDistributedCache2))); + } + + [Fact] + public async Task KeyedHybridCaches_ShareLocalMemoryCache() + { + var services = new ServiceCollection(); + services.AddMemoryCache(options => options.SizeLimit = 2); + services.AddSingleton(); + services.AddKeyedHybridCache("hybrid1"); + services.AddKeyedHybridCache("hybrid2"); + services.AddKeyedHybridCache("hybrid3"); + + using ServiceProvider provider = services.BuildServiceProvider(); + var hybrid1 = provider.GetRequiredKeyedService("hybrid1"); + var hybrid2 = provider.GetRequiredKeyedService("hybrid2"); + var hybrid3 = provider.GetRequiredKeyedService("hybrid3"); + + await hybrid1.SetAsync("entry1", 1); + await hybrid2.SetAsync("entry2", 2); + await hybrid3.SetAsync("entry3", 3); + + var localCache = provider.GetRequiredService(); + Assert.True(localCache.TryGetValue("entry1", out object? _)); + Assert.True(localCache.TryGetValue("entry2", out object? _)); + + // The third item fails to be cached locally because of the shared local cache size limit + Assert.False(localCache.TryGetValue("entry3", out object? _)); + + // But we can still get it from the hybrid cache (which gets it from the distributed cache) + var actual3 = await hybrid3.GetOrCreateAsync("entry3", ct => + { + Assert.Fail("Should not be called as the item should be found in the distributed cache"); + return new ValueTask(-1); + }); + + Assert.Equal(3, actual3); + } + + [Fact] + public void CanCreateRedisAndSqlServerBackedHybridCaches() + { + var services = new ServiceCollection(); + services.AddKeyedSingleton("Redis"); + + services.AddKeyedSingleton("SqlServer", + (sp, key) => new SqlServerCache(new SqlServerCacheOptions + { + ConnectionString = "test", + SchemaName = "test", + TableName = "test" + })); + + services.AddKeyedHybridCache("HybridWithRedis", options => options.DistributedCacheServiceKey = "Redis"); + services.AddKeyedHybridCache("HybridWithSqlServer", options => options.DistributedCacheServiceKey = "SqlServer"); + + using ServiceProvider provider = services.BuildServiceProvider(); + var hybridWithRedis = Assert.IsType(provider.GetRequiredKeyedService("HybridWithRedis")); + var hybridWithRedisBackend = Assert.IsType(hybridWithRedis.BackendCache); + Assert.Same(hybridWithRedisBackend, provider.GetRequiredKeyedService("Redis")); + + var hybridWithSqlServer = Assert.IsType(provider.GetRequiredKeyedService("HybridWithSqlServer")); + var hybridWithSqlServerBackend = Assert.IsType(hybridWithSqlServer.BackendCache); + Assert.Same(hybridWithSqlServerBackend, provider.GetRequiredKeyedService("SqlServer")); + } + #if NET9_0_OR_GREATER // for Bind API [Fact] public void CanParseOptions_NoEntryOptions() { - var source = new JsonConfigurationSource { Path = "BasicConfig.json" }; - var configBuilder = new ConfigurationBuilder { Sources = { source } }; + var configBuilder = new ConfigurationBuilder(); + + configBuilder.AddInMemoryCollection([ + new("no_entry_options:MaximumKeyLength", "937") + ]); + var config = configBuilder.Build(); var options = new HybridCacheOptions(); ConfigurationBinder.Bind(config, "no_entry_options", options); @@ -68,8 +285,14 @@ public void CanParseOptions_NoEntryOptions() [Fact] public void CanParseOptions_WithEntryOptions() // in particular, check we can parse the timespan and [Flags] enums { - var source = new JsonConfigurationSource { Path = "BasicConfig.json" }; - var configBuilder = new ConfigurationBuilder { Sources = { source } }; + var configBuilder = new ConfigurationBuilder(); + + configBuilder.AddInMemoryCollection([ + new("with_entry_options:MaximumKeyLength", "937"), + new("with_entry_options:DefaultEntryOptions:Flags", "DisableCompression, DisableLocalCacheRead"), + new("with_entry_options:DefaultEntryOptions:LocalCacheExpiration", "00:02:00") + ]); + var config = configBuilder.Build(); var options = new HybridCacheOptions(); ConfigurationBinder.Bind(config, "with_entry_options", options); @@ -81,6 +304,122 @@ public void CanParseOptions_WithEntryOptions() // in particular, check we can pa Assert.Equal(TimeSpan.FromSeconds(120), defaults.LocalCacheExpiration); Assert.Null(defaults.Expiration); // wasn't specified } + + [Fact] + public void CanCreateKeyedServicesWithKeyedDistributedCaches_UsingNamedOptions() + { + var configBuilder = new ConfigurationBuilder(); + + configBuilder.AddInMemoryCollection([ + new("HybridOne:DistributedCacheServiceKey", "DistributedOne"), + new("HybridTwo:DistributedCacheServiceKey", "DistributedTwo") + ]); + + var config = configBuilder.Build(); + + var services = new ServiceCollection(); + services.AddKeyedSingleton("DistributedOne"); + services.AddKeyedSingleton("DistributedTwo"); + services.AddOptions("HybridOne").Configure(options => ConfigurationBinder.Bind(config, "HybridOne", options)); + services.AddOptions("HybridTwo").Configure(options => ConfigurationBinder.Bind(config, "HybridTwo", options)); + services.AddKeyedHybridCache(typeof(CustomMemoryDistributedCache1), "HybridOne"); + services.AddKeyedHybridCache(typeof(CustomMemoryDistributedCache2), "HybridTwo"); + + using ServiceProvider provider = services.BuildServiceProvider(); + var hybridOne = Assert.IsType(provider.GetRequiredKeyedService(typeof(CustomMemoryDistributedCache1))); + var hybridOneOptions = hybridOne.Options; + var hybridOneBackend = Assert.IsType(hybridOne.BackendCache); + Assert.Equal("DistributedOne", hybridOneOptions.DistributedCacheServiceKey); + + var hybridTwo = Assert.IsType(provider.GetRequiredKeyedService(typeof(CustomMemoryDistributedCache2))); + var hybridTwoOptions = hybridTwo.Options; + var hybridTwoBackend = Assert.IsType(hybridTwo.BackendCache); + Assert.Equal("DistributedTwo", hybridTwoOptions.DistributedCacheServiceKey); + + provider.GetRequiredKeyedService(typeof(CustomMemoryDistributedCache1)); + provider.GetRequiredKeyedService(typeof(CustomMemoryDistributedCache1)); + provider.GetRequiredKeyedService(typeof(CustomMemoryDistributedCache1)); + + provider.GetRequiredKeyedService(typeof(CustomMemoryDistributedCache2)); + provider.GetRequiredKeyedService(typeof(CustomMemoryDistributedCache2)); + provider.GetRequiredKeyedService(typeof(CustomMemoryDistributedCache2)); + } + + [Fact] + public void CanCreateKeyedServicesWithKeyedDistributedCaches_UsingSetupActions() + { + var configBuilder = new ConfigurationBuilder(); + + configBuilder.AddInMemoryCollection([ + new("HybridOne:DistributedCacheServiceKey", "DistributedOne"), + new("HybridTwo:DistributedCacheServiceKey", "DistributedTwo") + ]); + + var config = configBuilder.Build(); + + var services = new ServiceCollection(); + services.AddKeyedSingleton("DistributedOne"); + services.AddKeyedSingleton("DistributedTwo"); + services.AddKeyedHybridCache("HybridOne", options => ConfigurationBinder.Bind(config, "HybridOne", options)); + services.AddKeyedHybridCache("HybridTwo", options => ConfigurationBinder.Bind(config, "HybridTwo", options)); + + using ServiceProvider provider = services.BuildServiceProvider(); + var hybridOne = Assert.IsType(provider.GetRequiredKeyedService("HybridOne")); + var hybridOneOptions = hybridOne.Options; + var hybridOneBackend = Assert.IsType(hybridOne.BackendCache); + Assert.Equal("DistributedOne", hybridOneOptions.DistributedCacheServiceKey); + + var hybridTwo = Assert.IsType(provider.GetRequiredKeyedService("HybridTwo")); + var hybridTwoOptions = hybridTwo.Options; + var hybridTwoBackend = Assert.IsType(hybridTwo.BackendCache); + Assert.Equal("DistributedTwo", hybridTwoOptions.DistributedCacheServiceKey); + + provider.GetRequiredKeyedService("HybridOne"); + provider.GetRequiredKeyedService("HybridOne"); + provider.GetRequiredKeyedService("HybridOne"); + + provider.GetRequiredKeyedService("HybridTwo"); + provider.GetRequiredKeyedService("HybridTwo"); + provider.GetRequiredKeyedService("HybridTwo"); + } + + [Fact] + public void CanCreateKeyedServicesWithKeyedDistributedCaches_UsingNamedOptionsAndSetupActions() + { + var configBuilder = new ConfigurationBuilder(); + + configBuilder.AddInMemoryCollection([ + new("HybridOne:DistributedCacheServiceKey", "DistributedOne"), + new("HybridTwo:DistributedCacheServiceKey", "DistributedTwo") + ]); + + var config = configBuilder.Build(); + + var services = new ServiceCollection(); + services.AddKeyedSingleton("DistributedOne"); + services.AddKeyedSingleton("DistributedTwo"); + services.AddKeyedHybridCache(typeof(CustomMemoryDistributedCache1), "HybridOne", options => ConfigurationBinder.Bind(config, "HybridOne", options)); + services.AddKeyedHybridCache(typeof(CustomMemoryDistributedCache2), "HybridTwo", options => ConfigurationBinder.Bind(config, "HybridTwo", options)); + + using ServiceProvider provider = services.BuildServiceProvider(); + var hybridOne = Assert.IsType(provider.GetRequiredKeyedService(typeof(CustomMemoryDistributedCache1))); + var hybridOneOptions = hybridOne.Options; + var hybridOneBackend = Assert.IsType(hybridOne.BackendCache); + Assert.Equal("DistributedOne", hybridOneOptions.DistributedCacheServiceKey); + + var hybridTwo = Assert.IsType(provider.GetRequiredKeyedService(typeof(CustomMemoryDistributedCache2))); + var hybridTwoOptions = hybridTwo.Options; + var hybridTwoBackend = Assert.IsType(hybridTwo.BackendCache); + Assert.Equal("DistributedTwo", hybridTwoOptions.DistributedCacheServiceKey); + + provider.GetRequiredKeyedService(typeof(CustomMemoryDistributedCache1)); + provider.GetRequiredKeyedService(typeof(CustomMemoryDistributedCache1)); + provider.GetRequiredKeyedService(typeof(CustomMemoryDistributedCache1)); + + provider.GetRequiredKeyedService(typeof(CustomMemoryDistributedCache2)); + provider.GetRequiredKeyedService(typeof(CustomMemoryDistributedCache2)); + provider.GetRequiredKeyedService(typeof(CustomMemoryDistributedCache2)); + } #endif [Fact] @@ -173,7 +512,7 @@ public void DefaultMemoryDistributedCacheIsIgnored(bool manual) public void SubclassMemoryDistributedCacheIsNotIgnored() { var services = new ServiceCollection(); - services.AddSingleton(); + services.AddSingleton(); services.AddHybridCache(); using ServiceProvider provider = services.BuildServiceProvider(); var cache = Assert.IsType(provider.GetRequiredService()); @@ -293,14 +632,27 @@ public CustomMemoryCache(IOptions options, ILoggerFactory lo } } - internal class CustomMemoryDistributedCache : MemoryDistributedCache + internal class CustomMemoryDistributedCache1 : MemoryDistributedCache + { + public CustomMemoryDistributedCache1(IOptions options) + : base(options) + { + } + + public CustomMemoryDistributedCache1(IOptions options, ILoggerFactory loggerFactory) + : base(options, loggerFactory) + { + } + } + + internal class CustomMemoryDistributedCache2 : MemoryDistributedCache { - public CustomMemoryDistributedCache(IOptions options) + public CustomMemoryDistributedCache2(IOptions options) : base(options) { } - public CustomMemoryDistributedCache(IOptions options, ILoggerFactory loggerFactory) + public CustomMemoryDistributedCache2(IOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) { } diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/TagSetTests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/TagSetTests.cs index 1c63ff5e5c2..818dac7b45c 100644 --- a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/TagSetTests.cs +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/TagSetTests.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Caching.Hybrid.Internal; namespace Microsoft.Extensions.Caching.Hybrid.Tests; + public class TagSetTests { [Fact] diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/TypeTests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/TypeTests.cs index c2ab242a6b0..d0176ab4e49 100644 --- a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/TypeTests.cs +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/TypeTests.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Caching.Hybrid.Internal; namespace Microsoft.Extensions.Caching.Hybrid.Tests; + public class TypeTests { [Theory] diff --git a/test/Libraries/Microsoft.Extensions.Compliance.Abstractions.Tests/Redaction/RedactionAbstractionsExtensionsTest.cs b/test/Libraries/Microsoft.Extensions.Compliance.Abstractions.Tests/Redaction/RedactionAbstractionsExtensionsTest.cs index 52211d96e62..a890b5396c9 100644 --- a/test/Libraries/Microsoft.Extensions.Compliance.Abstractions.Tests/Redaction/RedactionAbstractionsExtensionsTest.cs +++ b/test/Libraries/Microsoft.Extensions.Compliance.Abstractions.Tests/Redaction/RedactionAbstractionsExtensionsTest.cs @@ -24,12 +24,7 @@ public static void When_Passed_Null_Value_String_Builder_Extensions_Does_Not_App var sb = new StringBuilder(); var redactor = NullRedactor.Instance; - sb.AppendRedacted(NullRedactor.Instance, -#if NETCOREAPP3_1_OR_GREATER - null); -#else - (string?)null); -#endif + sb.AppendRedacted(NullRedactor.Instance, null); Assert.Equal(0, sb.Length); } diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Chunkers/ChunkerOptionsTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Chunkers/ChunkerOptionsTests.cs new file mode 100644 index 00000000000..dd37d9b7551 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Chunkers/ChunkerOptionsTests.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 Microsoft.ML.Tokenizers; +using Xunit; + +namespace Microsoft.Extensions.DataIngestion.Chunkers.Tests; + +public class ChunkerOptionsTests +{ + private static readonly Tokenizer _tokenizer = TiktokenTokenizer.CreateForModel("gpt-4"); + + [Fact] + public void TokenizerIsRequired() + { + Assert.Throws("tokenizer", () => new IngestionChunkerOptions(null!)); + } + + [Fact] + public void DefaultValues_ShouldBeSetCorrectly() + { + IngestionChunkerOptions options = new(_tokenizer); + + Assert.Equal(2000, options.MaxTokensPerChunk); + Assert.Equal(500, options.OverlapTokens); + } + + [Fact] + public void DefaultOverlapTokensIsZeroForSmallMaxTokensPerChunk() + { + IngestionChunkerOptions options = new(_tokenizer) { MaxTokensPerChunk = 100 }; + + Assert.Equal(100, options.MaxTokensPerChunk); + Assert.Equal(0, options.OverlapTokens); + } + + [Fact] + public void Properties_ShouldThrow_OnZeroOrNegative() + { + IngestionChunkerOptions options = new(_tokenizer); + + Assert.Throws("value", () => options.MaxTokensPerChunk = 0); + Assert.Throws("value", () => options.MaxTokensPerChunk = -1); + + // 0 is allowed for OverlapTokens + Assert.Throws("value", () => options.OverlapTokens = -1); + } + + [Fact] + public void OverlapTokensCanBeZero() + { + IngestionChunkerOptions options = new(_tokenizer) + { + OverlapTokens = 0 + }; + + Assert.Equal(0, options.OverlapTokens); + } + + [Fact] + public void OverlapTokens_ShouldThrow_WhenGreaterOrEqualThanMaxTokens() + { + IngestionChunkerOptions options = new(_tokenizer) { MaxTokensPerChunk = 1000 }; + + Assert.Throws("value", () => options.OverlapTokens = 1000); + Assert.Throws("value", () => options.OverlapTokens = 1500); + } + + [Fact] + public void MaxTokensPerChunk_ShouldThrow_WhenLessOrEqualThanOverlapTokens() + { + IngestionChunkerOptions options = new(_tokenizer) { OverlapTokens = 10 }; + + Assert.Throws("value", () => options.MaxTokensPerChunk = 10); + Assert.Throws("value", () => options.MaxTokensPerChunk = 5); + } +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Chunkers/DocumentChunkerTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Chunkers/DocumentChunkerTests.cs new file mode 100644 index 00000000000..fcb8e795c90 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Chunkers/DocumentChunkerTests.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.Collections.Generic; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.DataIngestion.Chunkers.Tests +{ + public abstract class DocumentChunkerTests + { + protected abstract IngestionChunker CreateDocumentChunker(int maxTokensPerChunk = 2_000, int overlapTokens = 500); + + [Fact] + public async Task ProcessAsync_ThrowsArgumentNullException_WhenDocumentIsNull() + { + var chunker = CreateDocumentChunker(); + await Assert.ThrowsAsync("document", async () => await chunker.ProcessAsync(null!).ToListAsync()); + } + + [Fact] + public async Task EmptyDocument() + { + IngestionDocument emptyDoc = new("emptyDoc"); + IngestionChunker chunker = CreateDocumentChunker(); + + IReadOnlyList> chunks = await chunker.ProcessAsync(emptyDoc).ToListAsync(); + Assert.Empty(chunks); + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Chunkers/HeaderChunkerTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Chunkers/HeaderChunkerTests.cs new file mode 100644 index 00000000000..80803d3d62d --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Chunkers/HeaderChunkerTests.cs @@ -0,0 +1,275 @@ +// Licensed 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.ML.Tokenizers; +using Xunit; + +namespace Microsoft.Extensions.DataIngestion.Chunkers.Tests; + +public class HeaderChunkerTests +{ + [Fact] + public async Task CanChunkNonTrivialDocument() + { + IngestionDocument doc = new("nonTrivial"); + doc.Sections.Add(new() + { + Elements = + { + new IngestionDocumentHeader("Header 1") { Level = 1 }, + new IngestionDocumentHeader("Header 1_1") { Level = 2 }, + new IngestionDocumentParagraph("Paragraph 1_1_1"), + new IngestionDocumentHeader("Header 1_1_1") { Level = 3 }, + new IngestionDocumentParagraph("Paragraph 1_1_1_1"), + new IngestionDocumentParagraph("Paragraph 1_1_1_2"), + new IngestionDocumentHeader("Header 1_1_2") { Level = 3 }, + new IngestionDocumentParagraph("Paragraph 1_1_2_1"), + new IngestionDocumentParagraph("Paragraph 1_1_2_2"), + new IngestionDocumentHeader("Header 1_2") { Level = 2 }, + new IngestionDocumentParagraph("Paragraph 1_2_1"), + new IngestionDocumentHeader("Header 1_2_1") { Level = 3 }, + new IngestionDocumentParagraph("Paragraph 1_2_1_1"), + } + }); + + HeaderChunker chunker = new(new(TiktokenTokenizer.CreateForModel("gpt-4"))); + IReadOnlyList> chunks = await chunker.ProcessAsync(doc).ToListAsync(); + + Assert.Equal(5, chunks.Count); + + Assert.Equal("Header 1 Header 1_1", chunks[0].Context); + Assert.Equal($"Header 1 Header 1_1\nParagraph 1_1_1", chunks[0].Content, ignoreLineEndingDifferences: true); + Assert.Equal("Header 1 Header 1_1 Header 1_1_1", chunks[1].Context); + Assert.Equal($"Header 1 Header 1_1 Header 1_1_1\nParagraph 1_1_1_1\nParagraph 1_1_1_2", chunks[1].Content, ignoreLineEndingDifferences: true); + Assert.Equal("Header 1 Header 1_1 Header 1_1_2", chunks[2].Context); + Assert.Equal($"Header 1 Header 1_1 Header 1_1_2\nParagraph 1_1_2_1\nParagraph 1_1_2_2", chunks[2].Content, ignoreLineEndingDifferences: true); + Assert.Equal("Header 1 Header 1_2", chunks[3].Context); + Assert.Equal($"Header 1 Header 1_2\nParagraph 1_2_1", chunks[3].Content, ignoreLineEndingDifferences: true); + Assert.Equal("Header 1 Header 1_2 Header 1_2_1", chunks[4].Context); + Assert.Equal($"Header 1 Header 1_2 Header 1_2_1\nParagraph 1_2_1_1", chunks[4].Content, ignoreLineEndingDifferences: true); + } + + [Fact] + public async Task CanRespectTokenLimit() + { + IngestionDocument doc = new("longOne"); + doc.Sections.Add(new() + { + Elements = + { + new IngestionDocumentHeader("Header A") { Level = 1 }, + new IngestionDocumentHeader("Header B") { Level = 2 }, + new IngestionDocumentHeader("Header C") { Level = 3 }, + new IngestionDocumentParagraph("This is a very long text. It's expressed with plenty of tokens") + } + }); + + HeaderChunker chunker = new(new(TiktokenTokenizer.CreateForModel("gpt-4")) { MaxTokensPerChunk = 13 }); + IReadOnlyList> chunks = await chunker.ProcessAsync(doc).ToListAsync(); + + Assert.Equal(2, chunks.Count); + Assert.Equal("Header A Header B Header C", chunks[0].Context); + Assert.Equal($"Header A Header B Header C\nThis is a very long text.", chunks[0].Content, ignoreLineEndingDifferences: true); + Assert.Equal("Header A Header B Header C", chunks[1].Context); + Assert.Equal($"Header A Header B Header C\n It's expressed with plenty of tokens", chunks[1].Content, ignoreLineEndingDifferences: true); + } + + [Fact] + public async Task ThrowsWhenLimitIsTooLowToFitAnythingMoreThanContext() + { + IngestionDocument doc = new("longOne"); + doc.Sections.Add(new() + { + Elements = + { + new IngestionDocumentHeader("Header A") { Level = 1 }, // 2 tokens + new IngestionDocumentHeader("Header B") { Level = 2 }, // 2 tokens + new IngestionDocumentHeader("Header C") { Level = 3 }, // 2 tokens + new IngestionDocumentParagraph("This is a very long text. It's expressed with plenty of tokens") + } + }); + + HeaderChunker lessThanContext = new(new(TiktokenTokenizer.CreateForModel("gpt-4")) { MaxTokensPerChunk = 5 }); + await Assert.ThrowsAsync(async () => await lessThanContext.ProcessAsync(doc).ToListAsync()); + + HeaderChunker sameAsContext = new(new(TiktokenTokenizer.CreateForModel("gpt-4")) { MaxTokensPerChunk = 6 }); + await Assert.ThrowsAsync(async () => await sameAsContext.ProcessAsync(doc).ToListAsync()); + } + + [Fact] + public async Task CanSplitLongerParagraphsOnNewLine() + { + IngestionDocument doc = new("withNewLines"); + doc.Sections.Add(new() + { + Elements = + { + new IngestionDocumentHeader("Header A") { Level = 1 }, + new IngestionDocumentHeader("Header B") { Level = 2 }, + new IngestionDocumentHeader("Header C") { Level = 3 }, + new IngestionDocumentParagraph("This is a very long text. It's expressed with plenty of tokens. And it contains a new line.\nWith some text after the new line."), + new IngestionDocumentParagraph("And following paragraph.") + } + }); + + HeaderChunker chunker = new(new(TiktokenTokenizer.CreateForModel("gpt-4")) { MaxTokensPerChunk = 30 }); + IReadOnlyList> chunks = await chunker.ProcessAsync(doc).ToListAsync(); + + Assert.Equal(2, chunks.Count); + Assert.Equal("Header A Header B Header C", chunks[0].Context); + Assert.Equal($"Header A Header B Header C\nThis is a very long text. It's expressed with plenty of tokens. And it contains a new line.\n", + chunks[0].Content, ignoreLineEndingDifferences: true); + Assert.Equal("Header A Header B Header C", chunks[1].Context); + Assert.Equal($"Header A Header B Header C\nWith some text after the new line.\nAnd following paragraph.", chunks[1].Content, ignoreLineEndingDifferences: true); + } + + [Fact] + public async Task ThrowsWhenHeaderSeparatorAndSingleRowExceedTokenLimit() + { + IngestionDocument document = CreateDocumentWithLargeTable(); + + // It takes 38 tokens to represent Headers, Separator and the first Row. + HeaderChunker chunker = new(new(TiktokenTokenizer.CreateForModel("gpt-4")) { MaxTokensPerChunk = 37 }); + + await Assert.ThrowsAsync(async () => await chunker.ProcessAsync(document).ToListAsync()); + } + + [Fact] + public async Task CanSplitLargeTableIntoMultipleChunks_MultipleRowsPerChunk() + { + IngestionDocument document = CreateDocumentWithLargeTable(); + + HeaderChunker chunker = new(new(TiktokenTokenizer.CreateForModel("gpt-4")) { MaxTokensPerChunk = 100 }); + IReadOnlyList> chunks = await chunker.ProcessAsync(document).ToListAsync(); + + Assert.Equal(2, chunks.Count); + Assert.All(chunks, chunk => Assert.Equal("Header A", chunk.Context)); + Assert.Equal(""" + Header A + This is some text that describes why we need the following table. + | one | two | three | four | five | + | --- | --- | --- | --- | --- | + | 0 | 1 | 2 | 3 | 4 | + | 5 | 6 | 7 | 8 | 9 | + | 10 | 11 | 12 | 13 | 14 | + """, chunks[0].Content, ignoreLineEndingDifferences: true); + Assert.Equal(""" + Header A + | one | two | three | four | five | + | --- | --- | --- | --- | --- | + | 15 | 16 | 17 | 18 | 19 | + | 20 | 21 | 22 | 23 | 24 | + And some follow up. + """, chunks[1].Content, ignoreLineEndingDifferences: true); + } + + [Fact] + public async Task CanSplitLargeTableIntoMultipleChunks_OneRowPerChunk() + { + IngestionDocument document = CreateDocumentWithLargeTable(); + + Tokenizer tokenizer = TiktokenTokenizer.CreateForModel("gpt-4"); + HeaderChunker chunker = new(new(tokenizer) { MaxTokensPerChunk = 50 }); + IReadOnlyList> chunks = await chunker.ProcessAsync(document).ToListAsync(); + + Assert.Equal(6, chunks.Count); + Assert.All(chunks, chunk => Assert.Equal("Header A", chunk.Context)); + Assert.All(chunks, chunk => Assert.InRange(tokenizer.CountTokens(chunk.Content), 1, 50)); + + Assert.Equal(""" + Header A + This is some text that describes why we need the following table. + """, chunks[0].Content, ignoreLineEndingDifferences: true); + Assert.Equal(""" + Header A + | one | two | three | four | five | + | --- | --- | --- | --- | --- | + | 0 | 1 | 2 | 3 | 4 | + """, chunks[1].Content, ignoreLineEndingDifferences: true); + Assert.Equal(""" + Header A + | one | two | three | four | five | + | --- | --- | --- | --- | --- | + | 5 | 6 | 7 | 8 | 9 | + """, chunks[2].Content, ignoreLineEndingDifferences: true); + Assert.Equal(""" + Header A + | one | two | three | four | five | + | --- | --- | --- | --- | --- | + | 10 | 11 | 12 | 13 | 14 | + """, chunks[3].Content, ignoreLineEndingDifferences: true); + Assert.Equal(""" + Header A + | one | two | three | four | five | + | --- | --- | --- | --- | --- | + | 15 | 16 | 17 | 18 | 19 | + """, chunks[4].Content, ignoreLineEndingDifferences: true); + Assert.Equal(""" + Header A + | one | two | three | four | five | + | --- | --- | --- | --- | --- | + | 20 | 21 | 22 | 23 | 24 | + And some follow up. + """, chunks[5].Content, ignoreLineEndingDifferences: true); + } + + private static IngestionDocument CreateDocumentWithLargeTable() + { + IngestionDocumentTable table = new(""" + | one | two | three | four | five | + | --- | --- | --- | --- | --- | + | 0 | 1 | 2 | 3 | 4 | + | 5 | 6 | 7 | 8 | 9 | + | 10 | 11 | 12 | 13 | 14 | + | 15 | 16 | 17 | 18 | 19 | + | 20 | 21 | 22 | 23 | 24 | + """, CreateTableCells() +); + + IngestionDocument doc = new("withNewLines"); + doc.Sections.Add(new() + { + Elements = + { + new IngestionDocumentHeader("Header A") { Level = 1 }, + new IngestionDocumentParagraph("This is some text that describes why we need the following table."), + table, + new IngestionDocumentParagraph("And some follow up.") + } + }); + + return doc; + + static IngestionDocumentElement?[,] CreateTableCells() + { + var cells = new IngestionDocumentElement[6, 5]; // 6 rows (1 header + 5 data rows), 5 columns + + // Header row + cells[0, 0] = new IngestionDocumentParagraph("one"); + cells[0, 1] = new IngestionDocumentParagraph("two"); + cells[0, 2] = new IngestionDocumentParagraph("three"); + cells[0, 3] = new IngestionDocumentParagraph("four"); + cells[0, 4] = new IngestionDocumentParagraph("five"); + + // Data rows (0-29) + int number = 0; + for (int row = 1; row <= 5; row++) + { + for (int col = 0; col < 5; col++) + { + cells[row, col] = new IngestionDocumentParagraph(number.ToString()); + number++; + } + } + + return cells; + } + } + + // We need plenty of more tests here, especially for edge cases: + // - sentence splitting + // - markdown splitting (e.g. lists, code blocks etc.) +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Chunkers/SemanticSimilarityChunkerTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Chunkers/SemanticSimilarityChunkerTests.cs new file mode 100644 index 00000000000..354cebf1565 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Chunkers/SemanticSimilarityChunkerTests.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.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.ML.Tokenizers; +using Xunit; + +namespace Microsoft.Extensions.DataIngestion.Chunkers.Tests +{ + public class SemanticSimilarityChunkerTests : DocumentChunkerTests + { + protected override IngestionChunker CreateDocumentChunker(int maxTokensPerChunk = 2_000, int overlapTokens = 500) + { +#pragma warning disable CA2000 // Dispose objects before losing scope + TestEmbeddingGenerator embeddingClient = new(); +#pragma warning restore CA2000 // Dispose objects before losing scope + return CreateSemanticSimilarityChunker(embeddingClient, maxTokensPerChunk, overlapTokens); + } + + private static IngestionChunker CreateSemanticSimilarityChunker(TestEmbeddingGenerator embeddingClient, int maxTokensPerChunk = 2_000, int overlapTokens = 500) + { + Tokenizer tokenizer = TiktokenTokenizer.CreateForModel("gpt-4o"); + return new SemanticSimilarityChunker(embeddingClient, + new(tokenizer) { MaxTokensPerChunk = maxTokensPerChunk, OverlapTokens = overlapTokens }); + } + + [Fact] + public async Task SingleParagraph() + { + string text = ".NET is a free, cross-platform, open-source developer platform for building many " + + "kinds of applications. It can run programs written in multiple languages, with C# being the most popular. " + + "It relies on a high-performance runtime that is used in production by many high-scale apps."; + IngestionDocument doc = new IngestionDocument("doc"); + doc.Sections.Add(new IngestionDocumentSection + { + Elements = + { + new IngestionDocumentParagraph(text) + } + }); + using TestEmbeddingGenerator customGenerator = new() + { + GenerateAsyncCallback = static async (values, options, ct) => + { + var embeddings = values.Select(v => + new Embedding(new float[] { 1.0f, 2.0f, 3.0f, 4.0f })) + .ToArray(); + + return [.. embeddings]; + } + }; + IngestionChunker chunker = CreateSemanticSimilarityChunker(customGenerator); + IReadOnlyList> chunks = await chunker.ProcessAsync(doc).ToListAsync(); + Assert.Single(chunks); + Assert.Equal(text, chunks[0].Content); + } + + [Fact] + public async Task TwoTopicsParagraphs() + { + IngestionDocument doc = new IngestionDocument("doc"); + string text1 = ".NET is a free, cross-platform, open-source developer platform for building many" + + "kinds of applications. It can run programs written in multiple languages, with C# being the most popular."; + string text2 = "It relies on a high-performance runtime that is used in production by many high-scale apps."; + string text3 = "Zeus is the chief deity of the Greek pantheon. He is a sky and thunder god in ancient Greek religion and mythology."; + doc.Sections.Add(new IngestionDocumentSection + { + Elements = + { + new IngestionDocumentParagraph(text1), + new IngestionDocumentParagraph(text2), + new IngestionDocumentParagraph(text3) + } + }); + + using var customGenerator = new TestEmbeddingGenerator + { + GenerateAsyncCallback = async (values, options, ct) => + { + var embeddings = values.Select((_, index) => + { + return index switch + { + 0 => new Embedding(new float[] { 1.0f, 1.0f, 1.0f, 1.0f }), + 1 => new Embedding(new float[] { 1.0f, 1.0f, 1.0f, 1.0f }), + 2 => new Embedding(new float[] { -1.0f, -1.0f, -1.0f, -1.0f }), + _ => throw new InvalidOperationException("Unexpected call count") + }; + }).ToArray(); + + return [.. embeddings]; + } + }; + + IngestionChunker chunker = CreateSemanticSimilarityChunker(customGenerator); + IReadOnlyList> chunks = await chunker.ProcessAsync(doc).ToListAsync(); + Assert.Equal(2, chunks.Count); + Assert.Equal(text1 + Environment.NewLine + text2, chunks[0].Content); + Assert.Equal(text3, chunks[1].Content); + } + + [Fact] + public async Task TwoSeparateTopicsWithAllKindsOfElements() + { + string dotNetTableMarkdown = """ + | Language | Type | Status | + | --- | --- | --- | + | C# | Object-oriented | Primary | + | F# | Functional | Official | + | Visual Basic | Object-oriented | Official | + | PowerShell | Scripting | Supported | + | IronPython | Dynamic | Community | + | IronRuby | Dynamic | Community | + | Boo | Object-oriented | Community | + | Nemerle | Functional/OOP | Community | + """; + + string godsTableMarkdown = """ + | God | Domain | Symbol | Roman Name | + | --- | --- | --- | --- | + | Zeus | Sky & Thunder | Lightning Bolt | Jupiter | + | Hera | Marriage & Family | Peacock | Juno | + | Poseidon | Sea & Earthquakes | Trident | Neptune | + | Athena | Wisdom & War | Owl | Minerva | + | Apollo | Sun & Music | Lyre | Apollo | + | Artemis | Hunt & Moon | Silver Bow | Diana | + | Aphrodite | Love & Beauty | Dove | Venus | + | Ares | War & Courage | Spear | Mars | + | Hephaestus | Fire & Forge | Hammer | Vulcan | + | Demeter | Harvest & Nature | Wheat | Ceres | + | Dionysus | Wine & Festivity | Grapes | Bacchus | + | Hermes | Messages & Trade | Caduceus | Mercury | + """; + + IngestionDocument doc = new("dotnet-languages"); + doc.Sections.Add(new IngestionDocumentSection + { + Elements = + { + new IngestionDocumentHeader("# .NET Supported Languages") { Level = 1 }, + new IngestionDocumentParagraph("The .NET platform supports multiple programming languages:"), + new IngestionDocumentTable(dotNetTableMarkdown, + ToParagraphCells(CreateLanguageTableCells())), + new IngestionDocumentParagraph("C# remains the most popular language for .NET development."), + new IngestionDocumentHeader("# Ancient Greek Olympian Gods") { Level = 1 }, + new IngestionDocumentParagraph("The twelve Olympian gods were the principal deities of the Greek pantheon:"), + new IngestionDocumentTable(godsTableMarkdown, + ToParagraphCells(CreateGreekGodsTableCells())), + new IngestionDocumentParagraph("These gods resided on Mount Olympus and ruled over different aspects of mortal and divine life.") + } + }); + + using var customGenerator = new TestEmbeddingGenerator + { + GenerateAsyncCallback = async (values, options, ct) => + { + var embeddings = values.Select((_, index) => + { + return index switch + { + <= 3 => new Embedding(new float[] { 1.0f, 1.0f, 1.0f, 1.0f }), + >= 4 and <= 7 => new Embedding(new float[] { -1.0f, -1.0f, -1.0f, -1.0f }), + _ => throw new InvalidOperationException($"Unexpected index: {index}") + }; + }).ToArray(); + + return [.. embeddings]; + } + }; + + IngestionChunker chunker = CreateSemanticSimilarityChunker(customGenerator, 200, 0); + IReadOnlyList> chunks = await chunker.ProcessAsync(doc).ToListAsync(); + + Assert.Equal(3, chunks.Count); + Assert.All(chunks, chunk => Assert.Same(doc, chunk.Document)); + Assert.Equal($@"# .NET Supported Languages +The .NET platform supports multiple programming languages: +{dotNetTableMarkdown} +C# remains the most popular language for .NET development.", + chunks[0].Content, ignoreLineEndingDifferences: true); + Assert.Equal($@"# Ancient Greek Olympian Gods +The twelve Olympian gods were the principal deities of the Greek pantheon: +| God | Domain | Symbol | Roman Name | +| --- | --- | --- | --- | +| Zeus | Sky & Thunder | Lightning Bolt | Jupiter | +| Hera | Marriage & Family | Peacock | Juno | +| Poseidon | Sea & Earthquakes | Trident | Neptune | +| Athena | Wisdom & War | Owl | Minerva | +| Apollo | Sun & Music | Lyre | Apollo | +| Artemis | Hunt & Moon | Silver Bow | Diana | +| Aphrodite | Love & Beauty | Dove | Venus | +| Ares | War & Courage | Spear | Mars | +| Hephaestus | Fire & Forge | Hammer | Vulcan | +| Demeter | Harvest & Nature | Wheat | Ceres | +| Dionysus | Wine & Festivity | Grapes | Bacchus |", + chunks[1].Content, ignoreLineEndingDifferences: true); + Assert.Equal(""" + | God | Domain | Symbol | Roman Name | + | --- | --- | --- | --- | + | Hermes | Messages & Trade | Caduceus | Mercury | + These gods resided on Mount Olympus and ruled over different aspects of mortal and divine life. + """, chunks[2].Content, ignoreLineEndingDifferences: true); + + static string[,] CreateGreekGodsTableCells() => new string[,] + { + { "God", "Domain", "Symbol", "Roman Name" }, + { "Zeus", "Sky & Thunder", "Lightning Bolt", "Jupiter" }, + { "Hera", "Marriage & Family", "Peacock", "Juno" }, + { "Poseidon", "Sea & Earthquakes", "Trident", "Neptune" }, + { "Athena", "Wisdom & War", "Owl", "Minerva" }, + { "Apollo", "Sun & Music", "Lyre", "Apollo" }, + { "Artemis", "Hunt & Moon", "Silver Bow", "Diana" }, + { "Aphrodite", "Love & Beauty", "Dove", "Venus" }, + { "Ares", "War & Courage", "Spear", "Mars" }, + { "Hephaestus", "Fire & Forge", "Hammer", "Vulcan" }, + { "Demeter", "Harvest & Nature", "Wheat", "Ceres" }, + { "Dionysus", "Wine & Festivity", "Grapes", "Bacchus" }, + { "Hermes", "Messages & Trade", "Caduceus", "Mercury" } + }; + + static string[,] CreateLanguageTableCells() => new string[,] + { + { "Language", "Type", "Status" }, + { "C#", "Object-oriented", "Primary" }, + { "F#", "Functional", "Official" }, + { "Visual Basic", "Object-oriented", "Official" }, + { "PowerShell", "Scripting", "Supported" }, + { "IronPython", "Dynamic", "Community" }, + { "IronRuby", "Dynamic", "Community" }, + { "Boo", "Object-oriented", "Community" }, + { "Nemerle", "Functional/OOP", "Community" } + }; + } + + private static IngestionDocumentParagraph?[,] ToParagraphCells(string[,] cells) + { + int rows = cells.GetLength(0); + int cols = cells.GetLength(1); + var result = new IngestionDocumentParagraph?[rows, cols]; + for (int i = 0; i < rows; i++) + { + for (int j = 0; j < cols; j++) + { + result[i, j] = new IngestionDocumentParagraph(cells[i, j]); + } + } + + return result; + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/IngestionDocumentTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/IngestionDocumentTests.cs new file mode 100644 index 00000000000..71c543a350e --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/IngestionDocumentTests.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.Linq; +using Xunit; + +namespace Microsoft.Extensions.DataIngestion.Tests; + +public class IngestionDocumentTests +{ + private readonly IngestionDocumentElement?[,] _rows = + { + { new IngestionDocumentParagraph("header") }, + { new IngestionDocumentParagraph("row1") }, + { new IngestionDocumentParagraph("row2") } + }; + + [Fact] + public void EnumeratorFlattensTheStructureAndPreservesOrder() + { + IngestionDocument doc = new("withSubSections"); + doc.Sections.Add(new IngestionDocumentSection("first section") + { + Elements = + { + new IngestionDocumentHeader("header"), + new IngestionDocumentParagraph("paragraph"), + new IngestionDocumentTable("table", _rows), + new IngestionDocumentSection("nested section") + { + Elements = + { + new IngestionDocumentHeader("nested header"), + new IngestionDocumentParagraph("nested paragraph") + } + } + } + }); + doc.Sections.Add(new IngestionDocumentSection("second section") + { + Elements = + { + new IngestionDocumentHeader("header 2"), + new IngestionDocumentParagraph("paragraph 2") + } + }); + + IngestionDocumentElement[] flatElements = doc.EnumerateContent().ToArray(); + + Assert.IsType(flatElements[0]); + Assert.Equal("header", flatElements[0].GetMarkdown()); + Assert.IsType(flatElements[1]); + Assert.Equal("paragraph", flatElements[1].GetMarkdown()); + Assert.IsType(flatElements[2]); + Assert.Equal("table", flatElements[2].GetMarkdown()); + Assert.IsType(flatElements[3]); + Assert.Equal("nested header", flatElements[3].GetMarkdown()); + Assert.IsType(flatElements[4]); + Assert.Equal("nested paragraph", flatElements[4].GetMarkdown()); + Assert.IsType(flatElements[5]); + Assert.Equal("header 2", flatElements[5].GetMarkdown()); + Assert.IsType(flatElements[6]); + Assert.Equal("paragraph 2", flatElements[6].GetMarkdown()); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void EmptyParagraphDocumentCantBeCreated(string? input) + => Assert.Throws("markdown", () => new IngestionDocumentParagraph(input!)); + + [Theory] + [InlineData(-1)] + [InlineData(100)] + public void InvalidHeaderLevelThrows(int level) + { + IngestionDocumentHeader header = new("# header"); + + Assert.Throws("value", () => header.Level = level); + } +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/IngestionPipelineTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/IngestionPipelineTests.cs new file mode 100644 index 00000000000..f2f0d85c458 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/IngestionPipelineTests.cs @@ -0,0 +1,261 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.ML.Tokenizers; +using Microsoft.SemanticKernel.Connectors.InMemory; +using OpenTelemetry; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; +using Xunit; + +namespace Microsoft.Extensions.DataIngestion.Tests; + +#pragma warning disable S881 // Increment (++) and decrement (--) operators should not be used in a method call or mixed with other operators in an expression + +public sealed class IngestionPipelineTests : IDisposable +{ + private readonly FileInfo _withTable; + private readonly FileInfo _withImage; + private readonly IReadOnlyList _sampleFiles; + private readonly DirectoryInfo _sampleDirectory; + + public IngestionPipelineTests() + { + _sampleDirectory = Directory.CreateDirectory(Path.Combine("TestFiles")); + + _withTable = new(Path.Combine("TestFiles", "withTable.md")); + const string FirstFileContent = """ + # First Document + + This is the content of the first document. + + ## Subsection + + More content in section 1. + + ## Table + + What a nice table! + + | Header1 | Header2 | + |---------|---------| + | Cell1 | Cell2 | + | Cell3 | Cell4 | + """; + File.WriteAllText(_withTable.FullName, FirstFileContent); + + _withImage = new(Path.Combine("TestFiles", "withImage.md")); + string secondFileContent = $""" + # Second Document + + Content for the second document goes here. + + ## Another Subsection + + Additional content in section 2. + + It comes with an image! + + ![Sample Image](data:image/png;base64,{Convert.ToBase64String(new byte[1000])}) + """; + File.WriteAllText(_withImage.FullName, secondFileContent); + + _sampleFiles = [_withTable, _withImage]; + } + + public void Dispose() + { + _sampleDirectory.Delete(recursive: true); + } + + [Fact] + public async Task CanProcessDocuments() + { + List activities = []; + using TracerProvider tracerProvider = CreateTraceProvider(activities); + + TestEmbeddingGenerator embeddingGenerator = new(); + using InMemoryVectorStore testVectorStore = new(new() { EmbeddingGenerator = embeddingGenerator }); + using VectorStoreWriter vectorStoreWriter = new(testVectorStore, dimensionCount: TestEmbeddingGenerator.DimensionCount); + + using IngestionPipeline pipeline = new(CreateReader(), CreateChunker(), vectorStoreWriter); + List ingestionResults = await pipeline.ProcessAsync(_sampleFiles).ToListAsync(); + + Assert.Equal(_sampleFiles.Count, ingestionResults.Count); + AssertAllIngestionsSucceeded(ingestionResults); + + Assert.True(embeddingGenerator.WasCalled, "Embedding generator should have been called."); + + var retrieved = await vectorStoreWriter.VectorStoreCollection + .GetAsync(record => _sampleFiles.Any(info => info.FullName == (string)record["documentid"]!), top: 1000) + .ToListAsync(); + + Assert.NotEmpty(retrieved); + for (int i = 0; i < retrieved.Count; i++) + { + Assert.NotEqual(Guid.Empty, (Guid)retrieved[i]["key"]!); + Assert.NotEmpty((string)retrieved[i]["content"]!); + Assert.Contains((string)retrieved[i]["documentid"]!, _sampleFiles.Select(info => info.FullName)); + } + + AssertActivities(activities, "ProcessFiles"); + } + + [Fact] + public async Task CanProcessDocumentsInDirectory() + { + List activities = []; + using TracerProvider tracerProvider = CreateTraceProvider(activities); + + TestEmbeddingGenerator embeddingGenerator = new(); + using InMemoryVectorStore testVectorStore = new(new() { EmbeddingGenerator = embeddingGenerator }); + using VectorStoreWriter vectorStoreWriter = new(testVectorStore, dimensionCount: TestEmbeddingGenerator.DimensionCount); + + using IngestionPipeline pipeline = new(CreateReader(), CreateChunker(), vectorStoreWriter); + + DirectoryInfo directory = new("TestFiles"); + List ingestionResults = await pipeline.ProcessAsync(directory, "*.md").ToListAsync(); + Assert.Equal(directory.EnumerateFiles("*.md").Count(), ingestionResults.Count); + AssertAllIngestionsSucceeded(ingestionResults); + + Assert.True(embeddingGenerator.WasCalled, "Embedding generator should have been called."); + + var retrieved = await vectorStoreWriter.VectorStoreCollection + .GetAsync(record => ((string)record["documentid"]!).StartsWith(directory.FullName), top: 1000) + .ToListAsync(); + + Assert.NotEmpty(retrieved); + for (int i = 0; i < retrieved.Count; i++) + { + Assert.NotEqual(Guid.Empty, (Guid)retrieved[i]["key"]!); + Assert.NotEmpty((string)retrieved[i]["content"]!); + Assert.StartsWith(directory.FullName, (string)retrieved[i]["documentid"]!); + } + + AssertActivities(activities, "ProcessDirectory"); + } + + [Fact] + public async Task ChunksCanBeMoreThanJustText() + { + List activities = []; + using TracerProvider tracerProvider = CreateTraceProvider(activities); + + TestEmbeddingGenerator embeddingGenerator = new(); + using InMemoryVectorStore testVectorStore = new(new() { EmbeddingGenerator = embeddingGenerator }); + using VectorStoreWriter vectorStoreWriter = new(testVectorStore, dimensionCount: TestEmbeddingGenerator.DimensionCount); + using IngestionPipeline pipeline = new(CreateReader(), new ImageChunker(), vectorStoreWriter); + + Assert.False(embeddingGenerator.WasCalled); + var ingestionResults = await pipeline.ProcessAsync(_sampleFiles).ToListAsync(); + AssertAllIngestionsSucceeded(ingestionResults); + + var retrieved = await vectorStoreWriter.VectorStoreCollection + .GetAsync(record => ((string)record["documentid"]!).EndsWith(_withImage.Name), top: 100) + .ToListAsync(); + + Assert.True(embeddingGenerator.WasCalled); + Assert.NotEmpty(retrieved); + for (int i = 0; i < retrieved.Count; i++) + { + Assert.NotEqual(Guid.Empty, (Guid)retrieved[i]["key"]!); + Assert.EndsWith(_withImage.Name, (string)retrieved[i]["documentid"]!); + } + + AssertActivities(activities, "ProcessFiles"); + } + + internal class ImageChunker : IngestionChunker + { + public override IAsyncEnumerable> ProcessAsync(IngestionDocument document, CancellationToken cancellationToken = default) + => document.EnumerateContent() + .OfType() + .Select(image => new IngestionChunk( + content: new(image.Content.GetValueOrDefault(), image.MediaType!), + document: document)) + .ToAsyncEnumerable(); + } + + [Fact] + public async Task SingleFailureDoesNotTearDownEntirePipeline() + { + int failed = 0; + MarkdownReader workingReader = new(); + TestReader failingForFirstReader = new( + (source, identifier, mediaType, cancellationToken) => failed++ == 0 + ? Task.FromException(new ExpectedException()) + : workingReader.ReadAsync(source, identifier, mediaType, cancellationToken)); + + List activities = []; + using TracerProvider tracerProvider = CreateTraceProvider(activities); + + TestEmbeddingGenerator embeddingGenerator = new(); + using InMemoryVectorStore testVectorStore = new(new() { EmbeddingGenerator = embeddingGenerator }); + using VectorStoreWriter vectorStoreWriter = new(testVectorStore, dimensionCount: TestEmbeddingGenerator.DimensionCount); + + using IngestionPipeline pipeline = new(failingForFirstReader, CreateChunker(), vectorStoreWriter); + + await Verify(pipeline.ProcessAsync(_sampleFiles)); + await Verify(pipeline.ProcessAsync(_sampleDirectory)); + + async Task Verify(IAsyncEnumerable results) + { + List ingestionResults = await results.ToListAsync(); + + Assert.Equal(_sampleFiles.Count, ingestionResults.Count); + Assert.All(ingestionResults, result => Assert.NotEmpty(result.DocumentId)); + IngestionResult ingestionResult = Assert.Single(ingestionResults.Where(result => !result.Succeeded)); + Assert.IsType(ingestionResult.Exception); + AssertErrorActivities(activities, expectedFailedActivitiesCount: 1); + + activities.Clear(); + failed = 0; + } + } + + private static IngestionDocumentReader CreateReader() => new MarkdownReader(); + + private static IngestionChunker CreateChunker() => new HeaderChunker(new(TiktokenTokenizer.CreateForModel("gpt-4"))); + + private static TracerProvider CreateTraceProvider(List activities) + => Sdk.CreateTracerProviderBuilder() + .AddSource("Experimental.Microsoft.Extensions.DataIngestion") + .ConfigureResource(r => r.AddService("inmemory-test")) + .AddInMemoryExporter(activities) + .Build(); + + private static void AssertAllIngestionsSucceeded(List ingestionResults) + { + Assert.NotEmpty(ingestionResults); + Assert.All(ingestionResults, result => Assert.True(result.Succeeded)); + Assert.All(ingestionResults, result => Assert.NotEmpty(result.DocumentId)); + Assert.All(ingestionResults, result => Assert.NotNull(result.Document)); + Assert.All(ingestionResults, result => Assert.Null(result.Exception)); + } + + private static void AssertActivities(List activities, string rootActivityName) + { + Assert.NotEmpty(activities); + Assert.All(activities, a => Assert.Equal("Experimental.Microsoft.Extensions.DataIngestion", a.Source.Name)); + Assert.Single(activities, a => a.OperationName == rootActivityName); + Assert.Contains(activities, a => a.OperationName == "ProcessFile"); + } + + private static void AssertErrorActivities(List activities, int expectedFailedActivitiesCount) + { + Assert.NotEmpty(activities); + Assert.All(activities, a => Assert.Equal("Experimental.Microsoft.Extensions.DataIngestion", a.Source.Name)); + + var failed = activities.Where(act => act.Status == ActivityStatusCode.Error).ToList(); + Assert.Equal(expectedFailedActivitiesCount, failed.Count); + Assert.All(failed, a => Assert.Equal(ExpectedException.ExceptionMessage, a.StatusDescription)); + } +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Microsoft.Extensions.DataIngestion.Tests.csproj b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Microsoft.Extensions.DataIngestion.Tests.csproj new file mode 100644 index 00000000000..cc64014cad6 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Microsoft.Extensions.DataIngestion.Tests.csproj @@ -0,0 +1,37 @@ + + + + + $(NoWarn);S3967 + + $(NoWarn);CA1063 + + + x64 + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/AlternativeTextEnricherTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/AlternativeTextEnricherTests.cs new file mode 100644 index 00000000000..6dad7e4af0a --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/AlternativeTextEnricherTests.cs @@ -0,0 +1,204 @@ +// Licensed 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.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; +using Xunit; + +namespace Microsoft.Extensions.DataIngestion.Processors.Tests; + +public class AlternativeTextEnricherTests +{ + private readonly ReadOnlyMemory _imageContent = new byte[256]; + + [Fact] + public void ThrowsOnNullOptions() + { + Assert.Throws("options", () => new ImageAlternativeTextEnricher(null!)); + } + + [Fact] + public async Task ThrowsOnNullDocument() + { + using TestChatClient chatClient = new(); + + ImageAlternativeTextEnricher sut = new(new(chatClient)); + + await Assert.ThrowsAsync("document", async () => await sut.ProcessAsync(null!)); + } + + [Fact] + public async Task CanGenerateImageAltText() + { + const string PreExistingAltText = "Pre-existing alt text"; + + string[] descriptions = { "First alt text", "Second alt text" }; + using TestChatClient chatClient = new() + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + var materializedMessages = messages.ToArray(); + + Assert.Equal(2, materializedMessages.Length); + Assert.Equal(ChatRole.System, materializedMessages[0].Role); + Assert.Equal(ChatRole.User, materializedMessages[1].Role); + Assert.Equal(2, materializedMessages[1].Contents.Count); + + Assert.All(materializedMessages[1].Contents, content => + { + DataContent dataContent = Assert.IsType(content); + Assert.Equal("image/png", dataContent.MediaType); + Assert.Equal(_imageContent.ToArray(), dataContent.Data.ToArray()); + }); + + return Task.FromResult(new ChatResponse(new[] + { + new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(new Envelope { data = descriptions })) + })); + } + }; + ImageAlternativeTextEnricher sut = new(new(chatClient)); + + IngestionDocumentImage documentImage = new($"![](nonExisting.png)") + { + AlternativeText = null, + Content = _imageContent, + MediaType = "image/png" + }; + + IngestionDocumentImage tableCell = new($"![](another.png)") + { + AlternativeText = null, + Content = _imageContent, + MediaType = "image/png" + }; + + IngestionDocumentImage imageWithAltText = new($"![](noChangesNeeded.png)") + { + AlternativeText = PreExistingAltText, + Content = _imageContent, + MediaType = "image/png" + }; + + IngestionDocumentImage imageWithNoContent = new($"![](noImage.png)") + { + AlternativeText = null, + Content = default, + MediaType = "image/png" + }; + + IngestionDocument document = new("withImage") + { + Sections = + { + new IngestionDocumentSection + { + Elements = + { + documentImage, + new IngestionDocumentTable("nvm", new[,] { { tableCell } }) + } + } + } + }; + + await sut.ProcessAsync(document); + + Assert.Equal(descriptions[0], documentImage.AlternativeText); + Assert.Equal(descriptions[1], tableCell.AlternativeText); + Assert.Same(PreExistingAltText, imageWithAltText.AlternativeText); + Assert.Null(imageWithNoContent.AlternativeText); + } + + [Theory] + [InlineData(1, 3)] + [InlineData(3, 7)] + [InlineData(15, 3)] + public async Task SendsOneRequestPerBatchSize(int batchSize, int batchCount) + { + int callsCount = 0; + using TestChatClient chatClient = new() + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + callsCount++; + + var materializedMessages = messages.ToArray(); + + // One system message + one User message with all the contents + Assert.Equal(2, materializedMessages.Length); + Assert.Equal(ChatRole.System, materializedMessages[0].Role); + Assert.Equal(ChatRole.User, materializedMessages[1].Role); + Assert.Equal(batchSize, materializedMessages[1].Contents.Count); + + Assert.All(materializedMessages[1].Contents, content => + { + DataContent dataContent = Assert.IsType(content); + Assert.Equal("image/png", dataContent.MediaType); + Assert.Equal(_imageContent.ToArray(), dataContent.Data.ToArray()); + }); + + Envelope data = new() { data = Enumerable.Range(0, batchSize).Select(i => i.ToString()).ToArray() }; + return Task.FromResult(new ChatResponse(new[] { new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(data)) })); + } + }; + + ImageAlternativeTextEnricher sut = new(new(chatClient) { BatchSize = batchSize }); + + IngestionDocument document = CreateDocument(batchSize, batchCount, _imageContent); + + await sut.ProcessAsync(document); + Assert.Equal(batchCount, callsCount); + } + + [Fact] + public async Task FailureDoesNotStopTheProcessing() + { + FakeLogCollector collector = new(); + using ILoggerFactory loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector))); + using TestChatClient chatClient = new() + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => Task.FromException(new ExpectedException()) + }; + + EnricherOptions options = new(chatClient) { LoggerFactory = loggerFactory }; + ImageAlternativeTextEnricher sut = new(options); + + const int BatchCount = 2; + IngestionDocument document = CreateDocument(options.BatchSize, BatchCount, _imageContent); + IngestionDocument got = await sut.ProcessAsync(document); + + Assert.Equal(BatchCount, collector.Count); + Assert.All(collector.GetSnapshot(), record => + { + Assert.Equal(LogLevel.Error, record.Level); + Assert.IsType(record.Exception); + }); + } + + private static IngestionDocument CreateDocument(int batchSize, int batchCount, ReadOnlyMemory imageContent) + { + IngestionDocumentSection rootSection = new(); + for (int i = 0; i < batchSize * batchCount; i++) + { + IngestionDocumentImage image = new($"![](image{i}.png)") + { + Content = imageContent, + MediaType = "image/png", + AlternativeText = null + }; + + rootSection.Elements.Add(image); + } + + return new("batchTest") + { + Sections = { rootSection } + }; + } +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/ClassificationEnricherTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/ClassificationEnricherTests.cs new file mode 100644 index 00000000000..15d0a5f6152 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/ClassificationEnricherTests.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.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; +using Xunit; + +namespace Microsoft.Extensions.DataIngestion.Processors.Tests; + +public class ClassificationEnricherTests +{ + private static readonly IngestionDocument _document = new("test"); + + [Fact] + public void ThrowsOnNullOptions() + { + Assert.Throws("options", () => new ClassificationEnricher(null!, predefinedClasses: ["some"])); + } + + [Fact] + public void ThrowsOnEmptyPredefinedClasses() + { + Assert.Throws("predefinedClasses", () => new ClassificationEnricher(new(new TestChatClient()), predefinedClasses: [])); + } + + [Fact] + public void ThrowsOnDuplicatePredefinedClasses() + { + Assert.Throws("predefinedClasses", () => new ClassificationEnricher(new(new TestChatClient()), predefinedClasses: ["same", "same"])); + } + + [Fact] + public void ThrowsOnPredefinedClassesContainingFallback() + { + Assert.Throws("predefinedClasses", () => new ClassificationEnricher(new(new TestChatClient()), predefinedClasses: ["same", "Unknown"])); + } + + [Fact] + public void ThrowsOnFallbackInPredefinedClasses() + { + Assert.Throws("predefinedClasses", () => new ClassificationEnricher(new(new TestChatClient()), predefinedClasses: ["some"], fallbackClass: "some")); + } + + [Fact] + public async Task ThrowsOnNullChunks() + { + using TestChatClient chatClient = new(); + ClassificationEnricher sut = new(new(chatClient), predefinedClasses: ["some"]); + + await Assert.ThrowsAsync("chunks", async () => + { + await foreach (var _ in sut.ProcessAsync(null!)) + { + // No-op + } + }); + } + + [Fact] + public async Task CanClassify() + { + int counter = 0; + string[] classes = ["AI", "Animals", "UFO"]; + using TestChatClient chatClient = new() + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + Assert.Equal(0, counter++); + var materializedMessages = messages.ToArray(); + + Assert.Equal(2, materializedMessages.Length); + Assert.Equal(ChatRole.System, materializedMessages[0].Role); + Assert.Equal(ChatRole.User, materializedMessages[1].Role); + + string response = JsonSerializer.Serialize(new Envelope { data = classes }); + return Task.FromResult(new ChatResponse(new[] + { + new ChatMessage(ChatRole.Assistant, response) + })); + } + }; + ClassificationEnricher sut = new(new(chatClient), ["AI", "Animals", "Sports"], fallbackClass: "UFO"); + + IReadOnlyList> got = await sut.ProcessAsync(CreateChunks().ToAsyncEnumerable()).ToListAsync(); + + Assert.Equal(3, got.Count); + Assert.Equal("AI", got[0].Metadata[ClassificationEnricher.MetadataKey]); + Assert.Equal("Animals", got[1].Metadata[ClassificationEnricher.MetadataKey]); + Assert.Equal("UFO", got[2].Metadata[ClassificationEnricher.MetadataKey]); + } + + [Fact] + public async Task FailureDoesNotStopTheProcessing() + { + FakeLogCollector collector = new(); + using ILoggerFactory loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector))); + using TestChatClient chatClient = new() + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => Task.FromException(new ExpectedException()) + }; + + ClassificationEnricher sut = new(new(chatClient) { LoggerFactory = loggerFactory }, ["AI", "Other"]); + List> chunks = CreateChunks(); + + IReadOnlyList> got = await sut.ProcessAsync(chunks.ToAsyncEnumerable()).ToListAsync(); + + Assert.Equal(chunks.Count, got.Count); + Assert.All(chunks, chunk => Assert.False(chunk.HasMetadata)); + Assert.Equal(1, collector.Count); // with batching, only one log entry is expected + Assert.Equal(LogLevel.Error, collector.LatestRecord.Level); + Assert.IsType(collector.LatestRecord.Exception); + } + + private static List> CreateChunks() => + [ + new(".NET developers need to integrate and interact with a growing variety of artificial intelligence (AI) services in their apps. " + + "The Microsoft.Extensions.AI libraries provide a unified approach for representing generative AI components, and enable seamless" + + " integration and interoperability with various AI services.", _document), + new ("Rabbits are small mammals in the family Leporidae of the order Lagomorpha (along with the hare and the pika)." + + "They are herbivorous animals and are known for their long ears, large hind legs, and short fluffy tails.", _document), + new("This text does not belong to any category.", _document), + ]; +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/KeywordEnricherTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/KeywordEnricherTests.cs new file mode 100644 index 00000000000..5a116e1ab04 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/KeywordEnricherTests.cs @@ -0,0 +1,125 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; +using Xunit; + +namespace Microsoft.Extensions.DataIngestion.Processors.Tests; + +public class KeywordEnricherTests +{ + private static readonly IngestionDocument _document = new("test"); + + [Fact] + public void ThrowsOnNullOptions() + { + Assert.Throws("options", () => new KeywordEnricher(null!, predefinedKeywords: null, confidenceThreshold: 0.5)); + } + + [Theory] + [InlineData(-0.1)] + [InlineData(1.1)] + public void ThrowsOnInvalidThreshold(double threshold) + { + Assert.Throws("confidenceThreshold", () => new KeywordEnricher(new(new TestChatClient()), predefinedKeywords: null, confidenceThreshold: threshold)); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void ThrowsOnInvalidMaxKeywords(int keywordCount) + { + Assert.Throws("maxKeywords", () => new KeywordEnricher(new(new TestChatClient()), predefinedKeywords: null, maxKeywords: keywordCount)); + } + + [Fact] + public void ThrowsOnDuplicateKeywords() + { + Assert.Throws("predefinedKeywords", () => new KeywordEnricher(new(new TestChatClient()), predefinedKeywords: ["same", "same"], confidenceThreshold: 0.5)); + } + + [Fact] + public async Task ThrowsOnNullChunks() + { + using TestChatClient chatClient = new(); + KeywordEnricher sut = new(new(chatClient), predefinedKeywords: null, confidenceThreshold: 0.5); + + await Assert.ThrowsAsync("chunks", async () => + { + await foreach (var _ in sut.ProcessAsync(null!)) + { + // No-op + } + }); + } + + [Theory] + [InlineData] + [InlineData("AI", "MEAI", "Animals", "Rabbits")] + public async Task CanExtractKeywords(params string[] predefined) + { + int counter = 0; + string[][] keywords = [["AI", "MEAI"], ["Animals", "Rabbits"]]; + using TestChatClient chatClient = new() + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + Assert.Equal(0, counter++); + var materializedMessages = messages.ToArray(); + + Assert.Equal(2, materializedMessages.Length); + Assert.Equal(ChatRole.System, materializedMessages[0].Role); + Assert.Equal(ChatRole.User, materializedMessages[1].Role); + + string response = JsonSerializer.Serialize(new Envelope { data = keywords }); + return Task.FromResult(new ChatResponse(new[] + { + new ChatMessage(ChatRole.Assistant, response) + })); + } + }; + + KeywordEnricher sut = new(new(chatClient), predefinedKeywords: predefined, confidenceThreshold: 0.5); + var chunks = CreateChunks().ToAsyncEnumerable(); + + IReadOnlyList> got = await sut.ProcessAsync(chunks).ToListAsync(); + + Assert.Equal(["AI", "MEAI"], (string[])got[0].Metadata[KeywordEnricher.MetadataKey]); + Assert.Equal(["Animals", "Rabbits"], (string[])got[1].Metadata[KeywordEnricher.MetadataKey]); + } + + [Fact] + public async Task FailureDoesNotStopTheProcessing() + { + FakeLogCollector collector = new(); + using ILoggerFactory loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector))); + using TestChatClient chatClient = new() + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => Task.FromException(new ExpectedException()) + }; + + KeywordEnricher sut = new(new(chatClient) { LoggerFactory = loggerFactory }, ["AI", "Other"]); + List> chunks = CreateChunks(); + + IReadOnlyList> got = await sut.ProcessAsync(chunks.ToAsyncEnumerable()).ToListAsync(); + + Assert.Equal(chunks.Count, got.Count); + Assert.All(chunks, chunk => Assert.False(chunk.HasMetadata)); + Assert.Equal(1, collector.Count); // with batching, only one log entry is expected + Assert.Equal(LogLevel.Error, collector.LatestRecord.Level); + Assert.IsType(collector.LatestRecord.Exception); + } + + private static List> CreateChunks() => + [ + new("The Microsoft.Extensions.AI libraries provide a unified approach for representing generative AI components", _document), + new("Rabbits are great pets. They are friendly and make excellent companions.", _document) + ]; +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/SentimentEnricherTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/SentimentEnricherTests.cs new file mode 100644 index 00000000000..8d762f3199c --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/SentimentEnricherTests.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.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; +using Xunit; + +namespace Microsoft.Extensions.DataIngestion.Processors.Tests; + +public class SentimentEnricherTests +{ + private static readonly IngestionDocument _document = new("test"); + + [Fact] + public void ThrowsOnNullOptions() + { + Assert.Throws("options", () => new SentimentEnricher(null!)); + } + + [Theory] + [InlineData(-0.1)] + [InlineData(1.1)] + public void ThrowsOnInvalidThreshold(double threshold) + { + Assert.Throws("confidenceThreshold", () => new SentimentEnricher(new(new TestChatClient()), confidenceThreshold: threshold)); + } + + [Fact] + public async Task ThrowsOnNullChunks() + { + using TestChatClient chatClient = new(); + SentimentEnricher sut = new(new(chatClient)); + + await Assert.ThrowsAsync("chunks", async () => + { + await foreach (var _ in sut.ProcessAsync(null!)) + { + // No-op + } + }); + } + + [Fact] + public async Task CanProvideSentiment() + { + int counter = 0; + string[] sentiments = { "Positive", "Negative", "Neutral", "Unknown" }; + using TestChatClient chatClient = new() + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + Assert.Equal(0, counter++); + var materializedMessages = messages.ToArray(); + + Assert.Equal(2, materializedMessages.Length); + Assert.Equal(ChatRole.System, materializedMessages[0].Role); + Assert.Equal(ChatRole.User, materializedMessages[1].Role); + + string response = JsonSerializer.Serialize(new Envelope { data = sentiments }); + return Task.FromResult(new ChatResponse(new[] + { + new ChatMessage(ChatRole.Assistant, response) + })); + } + }; + SentimentEnricher sut = new(new(chatClient)); + var input = CreateChunks().ToAsyncEnumerable(); + + var chunks = await sut.ProcessAsync(input).ToListAsync(); + + Assert.Equal(4, chunks.Count); + + Assert.Equal("Positive", chunks[0].Metadata[SentimentEnricher.MetadataKey]); + Assert.Equal("Negative", chunks[1].Metadata[SentimentEnricher.MetadataKey]); + Assert.Equal("Neutral", chunks[2].Metadata[SentimentEnricher.MetadataKey]); + Assert.Equal("Unknown", chunks[3].Metadata[SentimentEnricher.MetadataKey]); + } + + [Fact] + public async Task FailureDoesNotStopTheProcessing() + { + FakeLogCollector collector = new(); + using ILoggerFactory loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector))); + using TestChatClient chatClient = new() + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => Task.FromException(new ExpectedException()) + }; + + SentimentEnricher sut = new(new(chatClient) { LoggerFactory = loggerFactory }); + List> chunks = CreateChunks(); + + IReadOnlyList> got = await sut.ProcessAsync(chunks.ToAsyncEnumerable()).ToListAsync(); + + Assert.Equal(chunks.Count, got.Count); + Assert.All(chunks, chunk => Assert.False(chunk.HasMetadata)); + Assert.Equal(1, collector.Count); // with batching, only one log entry is expected + Assert.Equal(LogLevel.Error, collector.LatestRecord.Level); + Assert.IsType(collector.LatestRecord.Exception); + } + + private static List> CreateChunks() => + [ + new("I love programming! It's so much fun and rewarding.", _document), + new("I hate bugs. They are so frustrating and time-consuming.", _document), + new("The weather is okay, not too bad but not great either.", _document), + new("I hate you. I am sorry, I actually don't. I am not sure myself what my feelings are.", _document) + ]; +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/SummaryEnricherTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/SummaryEnricherTests.cs new file mode 100644 index 00000000000..8b0dcd904c4 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Processors/SummaryEnricherTests.cs @@ -0,0 +1,109 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; +using Xunit; + +namespace Microsoft.Extensions.DataIngestion.Processors.Tests; + +public class SummaryEnricherTests +{ + private static readonly IngestionDocument _document = new("test"); + + [Fact] + public void ThrowsOnNullOptions() + { + Assert.Throws("options", () => new SummaryEnricher(null!)); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void ThrowsOnInvalidMaxKeywords(int wordCount) + { + Assert.Throws("maxWordCount", () => new SummaryEnricher(new(new TestChatClient()), maxWordCount: wordCount)); + } + + [Fact] + public async Task ThrowsOnNullChunks() + { + using TestChatClient chatClient = new(); + SummaryEnricher sut = new(new(chatClient)); + + await Assert.ThrowsAsync("chunks", async () => + { + await foreach (var _ in sut.ProcessAsync(null!)) + { + // No-op + } + }); + } + + [Fact] + public async Task CanProvideSummary() + { + int counter = 0; + string[] summaries = { "First summary.", "Second summary." }; + using TestChatClient chatClient = new() + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => + { + Assert.Equal(0, counter++); + var materializedMessages = messages.ToArray(); + + Assert.Equal(2, materializedMessages.Length); + Assert.Equal(ChatRole.System, materializedMessages[0].Role); + Assert.Equal(ChatRole.User, materializedMessages[1].Role); + + string response = JsonSerializer.Serialize(new Envelope { data = summaries }); + return Task.FromResult(new ChatResponse(new[] + { + new ChatMessage(ChatRole.Assistant, response) + })); + } + }; + SummaryEnricher sut = new(new(chatClient)); + var input = CreateChunks().ToAsyncEnumerable(); + + var chunks = await sut.ProcessAsync(input).ToListAsync(); + + Assert.Equal(2, chunks.Count); + Assert.Equal(summaries[0], (string)chunks[0].Metadata[SummaryEnricher.MetadataKey]!); + Assert.Equal(summaries[1], (string)chunks[1].Metadata[SummaryEnricher.MetadataKey]!); + } + + [Fact] + public async Task FailureDoesNotStopTheProcessing() + { + FakeLogCollector collector = new(); + using ILoggerFactory loggerFactory = LoggerFactory.Create(b => b.AddProvider(new FakeLoggerProvider(collector))); + using TestChatClient chatClient = new() + { + GetResponseAsyncCallback = (messages, options, cancellationToken) => Task.FromException(new ExpectedException()) + }; + + SummaryEnricher sut = new(new(chatClient) { LoggerFactory = loggerFactory }); + List> chunks = CreateChunks(); + + IReadOnlyList> got = await sut.ProcessAsync(chunks.ToAsyncEnumerable()).ToListAsync(); + + Assert.Equal(chunks.Count, got.Count); + Assert.All(chunks, chunk => Assert.False(chunk.HasMetadata)); + Assert.Equal(1, collector.Count); // with batching, only one log entry is expected + Assert.Equal(LogLevel.Error, collector.LatestRecord.Level); + Assert.IsType(collector.LatestRecord.Exception); + } + + private static List> CreateChunks() => + [ + new("I love programming! It's so much fun and rewarding.", _document), + new("I hate bugs. They are so frustrating and time-consuming.", _document) + ]; +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Readers/DocumentReaderConformanceTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Readers/DocumentReaderConformanceTests.cs new file mode 100644 index 00000000000..d4993ad2cea --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Readers/DocumentReaderConformanceTests.cs @@ -0,0 +1,219 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DataIngestion.Tests.Utils; +using Microsoft.TestUtilities; +using Xunit; + +namespace Microsoft.Extensions.DataIngestion.Readers.Tests; + +public abstract class DocumentReaderConformanceTests +{ + private static readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(30) }; + + protected abstract IngestionDocumentReader CreateDocumentReader(bool extractImages = false); + + [ConditionalFact] + public async Task ThrowsWhenIdentifierIsNotProvided() + { + var reader = CreateDocumentReader(); + + await Assert.ThrowsAsync("identifier", async () => await reader.ReadAsync(new FileInfo("fileName.txt"), identifier: null!)); + await Assert.ThrowsAsync("identifier", async () => await reader.ReadAsync(new FileInfo("fileName.txt"), identifier: string.Empty)); + + using MemoryStream stream = new(); + await Assert.ThrowsAsync("identifier", async () => await reader.ReadAsync(stream, identifier: null!, mediaType: "some")); + await Assert.ThrowsAsync("identifier", async () => await reader.ReadAsync(stream, identifier: string.Empty, mediaType: "some")); + } + + [ConditionalFact] + public async Task ThrowsIfCancellationRequestedStream() + { + var reader = CreateDocumentReader(); + using CancellationTokenSource cts = new(); + cts.Cancel(); + + using MemoryStream stream = new(); + await Assert.ThrowsAsync(async () => await reader.ReadAsync(stream, "id", "mediaType", cts.Token)); + } + + [ConditionalFact] + public async Task ThrowsIfCancellationRequestedFile() + { + string filePath = Path.Combine(Path.GetTempPath(), Path.GetTempFileName() + ".txt"); +#if NET + await File.WriteAllTextAsync(filePath, "This is a test file for cancellation token."); +#else + File.WriteAllText(filePath, "This is a test file for cancellation token."); +#endif + + var reader = CreateDocumentReader(); + using CancellationTokenSource cts = new(); + cts.Cancel(); + + try + { + await Assert.ThrowsAsync(async () => await reader.ReadAsync(new FileInfo(filePath), cts.Token)); + } + finally + { + File.Delete(filePath); + } + } + + public static TheoryData Links => + [ + "https://winprotocoldocs-bhdugrdyduf5h2e4.b02.azurefd.net/MS-NRBF/%5bMS-NRBF%5d-190313.pdf", // PDF file + "https://winprotocoldocs-bhdugrdyduf5h2e4.b02.azurefd.net/MS-NRBF/%5bMS-NRBF%5d-190313.docx", // DOCX file + "https://www.bondcap.com/report/pdf/Trends_Artificial_Intelligence.pdf", // PDF file (presentation) + ]; + + [ConditionalTheory] + [MemberData(nameof(Links))] + public virtual async Task SupportsStreams(string source) + { + using HttpResponseMessage response = await DownloadAsync(new(source)); + + IngestionDocument document = await CreateDocumentReader().ReadAsync( + await response.Content.ReadAsStreamAsync(), + source, mediaType: response.Content.Headers.ContentType?.MediaType!); + + SimpleAsserts(document, source, source); + } + + [ConditionalTheory] + [MemberData(nameof(Links))] + public virtual async Task SupportsFiles(string source) + { + FileInfo inputFile = await DownloadToFileAsync(new Uri(source)); + + try + { + IngestionDocument document = await CreateDocumentReader().ReadAsync(inputFile); + + SimpleAsserts(document, inputFile.FullName, inputFile.FullName); + } + finally + { + inputFile.Delete(); + } + } + + [ConditionalFact] + public virtual Task SupportsImages() => SupportsImagesCore( + new("https://winprotocoldocs-bhdugrdyduf5h2e4.b02.azurefd.net/MC-SQLR/%5bMC-SQLR%5d.pdf")); // SQL Server Resolution Protocol + + protected async Task SupportsImagesCore(Uri source) + { + FileInfo inputFile = await DownloadToFileAsync(source); + + try + { + var reader = CreateDocumentReader(extractImages: true); + var document = await reader.ReadAsync(inputFile); + + SimpleAsserts(document, inputFile.FullName, expectedId: inputFile.FullName); + var elements = document.EnumerateContent().ToArray(); + Assert.Contains(elements, element => element is IngestionDocumentImage img && img.Content.HasValue && !string.IsNullOrEmpty(img.MediaType)); + } + finally + { + inputFile.Delete(); + } + } + + [ConditionalFact] + public virtual async Task SupportsTables() + { + string[,] expected = + { + { "Milestone", "Target Date", "Department", "Indicator" }, + { "Environmental Audit", "Mar 2025", "Environmental", "Audit Complete" }, + { "Renewable Energy Launch", "Jul 2025", "Facilities", "Install Operational" }, + { "Staff Workshop", "Sep 2025", "HR", "Workshop Held" }, + { "Emissions Review", "Dec 2029", "All", "25% Emissions Cut" } + }; + using Stream wordDoc = DocxHelper.CreateDocumentWithTable(expected); + + var document = await CreateDocumentReader().ReadAsync(wordDoc, "doc", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"); + + IngestionDocumentTable documentTable = Assert.Single(document.EnumerateContent().OfType()); + Assert.Equal(5, documentTable.Cells.GetLength(0)); + Assert.Equal(4, documentTable.Cells.GetLength(1)); + + Assert.Equal(expected, documentTable.Cells.Map(NormalizeCell)); + } + + protected static async Task DownloadAsync(Uri uri) + { + try + { + HttpResponseMessage response = await _httpClient.GetAsync(uri); + +#if !NET + // .NET Framework HttpClient does not automatically follow permanent redirects. + if (response.StatusCode == (System.Net.HttpStatusCode)308) + { + string? redirectUri = response.Headers.Location?.ToString(); + Assert.False(string.IsNullOrEmpty(redirectUri), "Redirect URI is null or empty."); + response.Dispose(); + response = await _httpClient.GetAsync(new Uri(redirectUri!)); + } +#endif + + Assert.True(response.IsSuccessStatusCode); + return response; + } + catch (Exception ex) + { + throw new SkipTestException($"Unable to download the test file: '{ex.Message}'"); + } + } + + protected static async Task DownloadToFileAsync(Uri uri) + { + using HttpResponseMessage response = await DownloadAsync(uri); + + string extension = response.Content.Headers.ContentType?.MediaType switch + { + "application/pdf" => ".pdf", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" => ".docx", + _ when uri.OriginalString.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase) => ".pdf", + _ when uri.OriginalString.EndsWith(".docx", StringComparison.OrdinalIgnoreCase) => ".docx", + _ => string.Empty + }; + + FileInfo file = new(Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + extension)); + using FileStream inputStream = new(file.FullName, FileMode.CreateNew, FileAccess.Write, FileShare.None, bufferSize: 1, FileOptions.Asynchronous); + await response.Content.CopyToAsync(inputStream); + + return file; + } + + protected virtual void SimpleAsserts(IngestionDocument document, string source, string expectedId) + { + Assert.NotNull(document); + Assert.Equal(expectedId, document.Identifier); + Assert.NotEmpty(document.Sections); + + var elements = document.EnumerateContent().ToArray(); + Assert.Contains(elements, element => element is IngestionDocumentHeader); + Assert.Contains(elements, element => element is IngestionDocumentParagraph); + Assert.Contains(elements, element => element is IngestionDocumentTable); + Assert.All(elements.Where(element => element is not IngestionDocumentImage), element => Assert.NotEmpty(element.GetMarkdown())); + } + + private static string? NormalizeCell(IngestionDocumentElement? ingestionDocumentElement) + { + Assert.NotNull(ingestionDocumentElement); + + // Some readers add extra spaces or asterisks for bold/italic text for headers. + return ingestionDocumentElement.GetMarkdown().Trim().Trim('*'); + } +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Readers/MarkItDownConditionAttribute.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Readers/MarkItDownConditionAttribute.cs new file mode 100644 index 00000000000..b0169a54c1c --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Readers/MarkItDownConditionAttribute.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.ComponentModel; +using System.Diagnostics; +using System.Text; +using Microsoft.TestUtilities; + +namespace Microsoft.Extensions.DataIngestion.Readers.Tests; + +/// +/// This class exists because currently the local copy of can't ignore tests that throw . +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Assembly, AllowMultiple = true)] +public class MarkItDownConditionAttribute : Attribute, ITestCondition +{ + internal static readonly Lazy IsInstalled = new(CanInvokeMarkItDown); + + public bool IsMet => IsInstalled.Value; + + public string SkipReason => "MarkItDown is not installed or not accessible."; + + private static bool CanInvokeMarkItDown() + { + ProcessStartInfo startInfo = new() + { + FileName = "markitdown", + Arguments = "--help", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + StandardOutputEncoding = Encoding.UTF8, + }; + + using Process process = new() { StartInfo = startInfo }; + try + { + process.Start(); + } + catch (Win32Exception) + { + return false; + } + + while (process.StandardOutput.Peek() >= 0) + { + _ = process.StandardOutput.ReadLine(); + } + + process.WaitForExit(); + + return true; + } +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Readers/MarkItDownMcpReaderTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Readers/MarkItDownMcpReaderTests.cs new file mode 100644 index 00000000000..37142f8b20e --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Readers/MarkItDownMcpReaderTests.cs @@ -0,0 +1,53 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.DataIngestion.Readers.Tests; + +public class MarkItDownMcpReaderTests +{ + [Fact] + public void Constructor_ThrowsWhenMcpServerUriIsNull() + { + Assert.Throws("mcpServerUri", () => new MarkItDownMcpReader(null!)); + } + + [Fact] + public async Task ReadAsync_ThrowsWhenIdentifierIsNull() + { + var reader = new MarkItDownMcpReader(new Uri("http://localhost:3001/sse")); + + await Assert.ThrowsAsync("identifier", async () => await reader.ReadAsync(new FileInfo("fileName.txt"), identifier: null!)); + await Assert.ThrowsAsync("identifier", async () => await reader.ReadAsync(new FileInfo("fileName.txt"), identifier: string.Empty)); + + using MemoryStream stream = new(); + await Assert.ThrowsAsync("identifier", async () => await reader.ReadAsync(stream, identifier: null!, mediaType: "some")); + await Assert.ThrowsAsync("identifier", async () => await reader.ReadAsync(stream, identifier: string.Empty, mediaType: "some")); + } + + [Fact] + public async Task ReadAsync_ThrowsWhenSourceIsNull() + { + var reader = new MarkItDownMcpReader(new Uri("http://localhost:3001/sse")); + + await Assert.ThrowsAsync("source", async () => await reader.ReadAsync(null!, "identifier")); + await Assert.ThrowsAsync("source", async () => await reader.ReadAsync((Stream)null!, "identifier", "mediaType")); + } + + [Fact] + public async Task ReadAsync_ThrowsWhenFileDoesNotExist() + { + var reader = new MarkItDownMcpReader(new Uri("http://localhost:3001/sse")); + var nonExistentFile = new FileInfo(Path.Combine(Path.GetTempPath(), Path.GetRandomFileName())); + + await Assert.ThrowsAsync(async () => await reader.ReadAsync(nonExistentFile, "identifier")); + } + + // NOTE: Integration tests with an actual MCP server would go here, but they would require + // a running MarkItDown MCP server to be available, which is not part of the test setup. + // For full integration testing, use a real MCP server in a separate test environment. +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Readers/MarkItDownReaderTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Readers/MarkItDownReaderTests.cs new file mode 100644 index 00000000000..e506ea15ca1 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Readers/MarkItDownReaderTests.cs @@ -0,0 +1,46 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.TestUtilities; +using Xunit; + +namespace Microsoft.Extensions.DataIngestion.Readers.Tests; + +[MarkItDownCondition] +public class MarkItDownReaderTests : DocumentReaderConformanceTests +{ + protected override IngestionDocumentReader CreateDocumentReader(bool extractImages = false) + => MarkItDownConditionAttribute.IsInstalled.Value + ? new MarkItDownReader(extractImages: extractImages) + : throw new SkipTestException("MarkItDown is not installed"); + + protected override void SimpleAsserts(IngestionDocument document, string source, string expectedId) + { + Assert.NotNull(document); + Assert.Equal(expectedId, document.Identifier); + Assert.NotEmpty(document.Sections); + + var elements = document.EnumerateContent().ToArray(); + + bool isPdf = source.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase); + if (!isPdf) + { + // MarkItDown does a bad job of recognizing Headers and Tables even for simple PDF files. + Assert.Contains(elements, element => element is IngestionDocumentHeader); + Assert.Contains(elements, element => element is IngestionDocumentTable); + } + + Assert.Contains(elements, element => element is IngestionDocumentParagraph); + Assert.All(elements, element => Assert.NotEmpty(element.GetMarkdown())); + } + + // The original purpose of the MarkItDown library was to support text-only LLMs. + // Source: https://github.com/microsoft/markitdown/issues/56#issuecomment-2546357264 + // It can extract images, but the support is limited to some formats like docx. + [ConditionalFact] + public override Task SupportsImages() => SupportsImagesCore( + new("https://winprotocoldocs-bhdugrdyduf5h2e4.b02.azurefd.net/MC-SQLR/%5bMC-SQLR%5d-240423.docx")); // SQL Server Resolution Protocol. +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Readers/MarkdownReaderTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Readers/MarkdownReaderTests.cs new file mode 100644 index 00000000000..dce6d996821 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Readers/MarkdownReaderTests.cs @@ -0,0 +1,141 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.TestUtilities; +using Xunit; + +namespace Microsoft.Extensions.DataIngestion.Readers.Tests; + +public class MarkdownReaderTests : DocumentReaderConformanceTests +{ + protected override IngestionDocumentReader CreateDocumentReader(bool extractImages = false) => new MarkdownReader(); + + public static new TheoryData Links => + [ + "https://raw.githubusercontent.com/microsoft/markitdown/main/README.md" + ]; + + [ConditionalTheory] + [MemberData(nameof(Links))] + public override Task SupportsStreams(string source) => base.SupportsStreams(source); + + [ConditionalTheory] + [MemberData(nameof(Links))] + public override Task SupportsFiles(string source) => base.SupportsFiles(source); + + [ConditionalFact] + public override async Task SupportsTables() + { + string markdownContent = """ + # Key Milestones + + | **Milestone** | **Target Date** | **Department** | **Indicator** | + | --- | --- | --- | --- | + | Environmental Audit | Mar 2025 | Environmental | Audit Complete | + | Renewable Energy Launch | Jul 2025 | Facilities | Install Operational | + | Staff Workshop | Sep 2025 | HR | Workshop Held | + | Emissions Review | Dec 2029 | All | 25% Emissions Cut | + """; + + IngestionDocument document = await ReadAsync(markdownContent); + + IngestionDocumentTable documentTable = Assert.Single(document.EnumerateContent().OfType()); + Assert.Equal(5, documentTable.Cells.GetLength(0)); + Assert.Equal(4, documentTable.Cells.GetLength(1)); + + string[,] expected = + { + { "**Milestone**", "**Target Date**", "**Department**", "**Indicator**" }, + { "Environmental Audit", "Mar 2025", "Environmental", "Audit Complete" }, + { "Renewable Energy Launch", "Jul 2025", "Facilities", "Install Operational" }, + { "Staff Workshop", "Sep 2025", "HR", "Workshop Held" }, + { "Emissions Review", "Dec 2029", "All", "25% Emissions Cut" } + }; + + Assert.Equal(expected, documentTable.Cells.Map(element => element!.GetMarkdown().Trim())); + } + + [ConditionalFact] + public override async Task SupportsImages() + { + string contentType1 = "image/png"; + byte[] imageBytes1 = Enumerable.Range(0, 55).Select(i => (byte)i).ToArray(); + string contentType2 = "image/jpeg"; + byte[] imageBytes2 = Enumerable.Range(55, 111).Select(i => (byte)i).ToArray(); + string contentType3 = "image/newfancy"; + byte[] imageBytes3 = Enumerable.Range(166, 200).Select(i => (byte)i).ToArray(); + + string markdownContent = $""" + # All content types supported! + + PNG is fine! + + ![One](data:{contentType1};base64,{Convert.ToBase64String(imageBytes1)}) + + JPEG is also fine! + + ![Two](data:{contentType2};base64,{Convert.ToBase64String(imageBytes2)}) + + But what about a new fancy type? + + ![Three](data:{contentType3};base64,{Convert.ToBase64String(imageBytes3)}) + """; + + IngestionDocument document = await ReadAsync(markdownContent); + + Assert.NotNull(document); + var images = document.EnumerateContent().OfType().ToArray(); + Assert.Equal(3, images.Length); + Assert.Equal(contentType1, images[0].MediaType); + Assert.Equal(imageBytes1, images[0].Content?.ToArray()); + Assert.Equal("One", images[0].AlternativeText); + Assert.Equal(contentType2, images[1].MediaType); + Assert.Equal(imageBytes2, images[1].Content?.ToArray()); + Assert.Equal("Two", images[1].AlternativeText); + Assert.Equal(contentType3, images[2].MediaType); + Assert.Equal(imageBytes3, images[2].Content?.ToArray()); + Assert.Equal("Three", images[2].AlternativeText); + } + + [ConditionalFact] + public async Task SupportsTablesWithImages() + { + byte[] imageBytes = Enumerable.Range(55, 111).Select(i => (byte)i).ToArray(); + string markdownContent = $""" + # Table with Images + + | **Years** | **Logo** | + | --- | --- | + | 2020-2025 | ![Latest logo](data:image/png;base64,{Convert.ToBase64String(imageBytes)}) | + """; + + IngestionDocument document = await ReadAsync(markdownContent); + + var table = Assert.Single(document.EnumerateContent().OfType()); + Assert.Equal(2, table.Cells.GetLength(0)); + Assert.Equal(2, table.Cells.GetLength(1)); + + // Each reader properly recognizes the text from the first column. + // When it comes to the images, MarkItDown extracts them as images, while + // other readers return nothing or ORCed text. + Assert.Equal("**Years**", table.Cells[0, 0]!.GetMarkdown().Trim()); + Assert.Equal("**Logo**", table.Cells[0, 1]!.GetMarkdown().Trim()); + Assert.Equal("2020-2025", table.Cells[1, 0]!.GetMarkdown().Trim()); + + IngestionDocumentImage img = Assert.IsType(table.Cells[1, 1]); + Assert.Equal("image/png", img.MediaType); + Assert.NotNull(img.Content); + Assert.False(img.Content.Value.IsEmpty); + Assert.Equal("Latest logo", img.AlternativeText); + } + + private async Task ReadAsync(string content) + { + using MemoryStream stream = new(System.Text.Encoding.UTF8.GetBytes(content)); + return await CreateDocumentReader().ReadAsync(stream, "id", "text/markdown"); + } +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/ArrayUtils.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/ArrayUtils.cs new file mode 100644 index 00000000000..133390eef47 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/ArrayUtils.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; + +namespace Microsoft.Extensions.DataIngestion; + +internal static class ArrayUtils +{ + internal static TTo[,] Map(this TFrom[,] from, Func mapFunc) + { + int rows = from.GetLength(0); + int cols = from.GetLength(1); + var to = new TTo[rows, cols]; + for (int i = 0; i < rows; i++) + { + for (int j = 0; j < cols; j++) + { + to[i, j] = mapFunc(from[i, j]); + } + } + + return to; + } +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/DocxHelper.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/DocxHelper.cs new file mode 100644 index 00000000000..acfbab3e475 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/DocxHelper.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. + +using System.IO; +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; + +namespace Microsoft.Extensions.DataIngestion.Tests.Utils; + +#pragma warning disable IDE0007 // Use implicit type + +internal static class DocxHelper +{ + internal static Stream CreateDocumentWithTable(string[,] cells) + { + MemoryStream stream = new(); + + using (WordprocessingDocument doc = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document, true)) + { + MainDocumentPart mainPart = doc.AddMainDocumentPart(); + mainPart.Document = new Document(); + Body body = mainPart.Document.AppendChild(new Body()); + + // Add a header + Paragraph headerPara = body.AppendChild(new Paragraph()); + Run headerRun = headerPara.AppendChild(new Run()); + headerRun.AppendChild(new Text("Key Milestones")); + headerRun.RunProperties = new(new Bold()); + + // Create table + Table table = new Table(); + + // Table properties + TableProperties tableProps = new( + new TableBorders( + new TopBorder { Val = new EnumValue(BorderValues.Single), Size = 12 }, + new BottomBorder { Val = new EnumValue(BorderValues.Single), Size = 12 }, + new LeftBorder { Val = new EnumValue(BorderValues.Single), Size = 12 }, + new RightBorder { Val = new EnumValue(BorderValues.Single), Size = 12 }, + new InsideHorizontalBorder { Val = new EnumValue(BorderValues.Single), Size = 12 }, + new InsideVerticalBorder { Val = new EnumValue(BorderValues.Single), Size = 12 }) + ); + table.AppendChild(tableProps); + + // Create rows + for (int i = 0; i < cells.GetLength(0); i++) + { + TableRow row = new TableRow(); + + for (int j = 0; j < cells.GetLength(1); j++) + { + TableCell cell = new TableCell(); + + // Cell properties + TableCellProperties cellProps = new( + new TableCellMargin( + new TopMargin { Width = "100", Type = TableWidthUnitValues.Dxa }, + new BottomMargin { Width = "100", Type = TableWidthUnitValues.Dxa }, + new LeftMargin { Width = "100", Type = TableWidthUnitValues.Dxa }, + new RightMargin { Width = "100", Type = TableWidthUnitValues.Dxa }) + ); + cell.AppendChild(cellProps); + + // Cell content + Paragraph cellPara = new Paragraph(); + Run cellRun = new Run(); + cellRun.AppendChild(new Text(cells[i, j])); + + // Make header row bold + if (i == 0) + { + cellRun.RunProperties = new(new Bold()); + } + + cellPara.AppendChild(cellRun); + cell.AppendChild(cellPara); + row.AppendChild(cell); + } + + table.AppendChild(row); + } + + body.AppendChild(table); + } + + stream.Position = 0; + return stream; + } +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/Envelope{T}.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/Envelope{T}.cs new file mode 100644 index 00000000000..d6fade6892f --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/Envelope{T}.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.DataIngestion; + +internal class Envelope +{ +#pragma warning disable IDE1006 // Naming Styles + public T? data { get; set; } +#pragma warning restore IDE1006 // Naming Styles +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/ExpectedException.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/ExpectedException.cs new file mode 100644 index 00000000000..79d2e7538fd --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/ExpectedException.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +namespace Microsoft.Extensions.DataIngestion; + +internal sealed class ExpectedException : Exception +{ + internal const string ExceptionMessage = "An expected exception occurred."; + + internal ExpectedException() + : base(ExceptionMessage) + { + } +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/IAsyncEnumerableExtensions.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/IAsyncEnumerableExtensions.cs new file mode 100644 index 00000000000..bb30b585233 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/IAsyncEnumerableExtensions.cs @@ -0,0 +1,63 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.DataIngestion; + +// Once .NET 10 is shipped, we are going to switch to System.Linq.AsyncEnumerable. +internal static class IAsyncEnumerableExtensions +{ + internal static async IAsyncEnumerable ToAsyncEnumerable(this IEnumerable source) + { + foreach (T item in source) + { + await Task.Yield(); + yield return item; + } + } + + internal static async ValueTask CountAsync(this IAsyncEnumerable source) + { + int count = 0; + await foreach (T _ in source) + { + count++; + } + + return count; + } + + internal static async ValueTask SingleAsync(this IAsyncEnumerable source) + { + bool found = false; + T result = default!; + await foreach (T item in source) + { + if (found) + { + throw new InvalidOperationException(); + } + + result = item; + found = true; + } + + return found + ? result + : throw new InvalidOperationException(); + } + + internal static async ValueTask> ToListAsync(this IAsyncEnumerable source) + { + List list = []; + await foreach (var item in source) + { + list.Add(item); + } + + return list; + } +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/TestEmbeddingGenerator.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/TestEmbeddingGenerator.cs new file mode 100644 index 00000000000..611243684cd --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/TestEmbeddingGenerator.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.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Extensions.DataIngestion; + +public sealed class TestEmbeddingGenerator : IEmbeddingGenerator> +{ + public const int DimensionCount = 4; + + public bool WasCalled { get; private set; } + + public void Dispose() + { + // No resources to dispose + } + + public Task>> GenerateAsync(IEnumerable values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) + { + WasCalled = true; + + return Task.FromResult(new GeneratedEmbeddings>( + [new(new float[] { 0, 1, 2, 3 })])); + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/TestReader.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/TestReader.cs new file mode 100644 index 00000000000..aa039de5e31 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/TestReader.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; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.DataIngestion; + +internal sealed class TestReader : IngestionDocumentReader +{ + public TestReader(Func> readAsyncCallback) + { + ReadAsyncCallback = readAsyncCallback; + } + + public Func> ReadAsyncCallback { get; } + + public override Task ReadAsync(Stream source, string identifier, string mediaType, CancellationToken cancellationToken = default) + => ReadAsyncCallback(source, identifier, mediaType, cancellationToken); +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/InMemoryVectorStoreWriterTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/InMemoryVectorStoreWriterTests.cs new file mode 100644 index 00000000000..b81b5a2aa79 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/InMemoryVectorStoreWriterTests.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 Microsoft.Extensions.VectorData; +using Microsoft.SemanticKernel.Connectors.InMemory; + +namespace Microsoft.Extensions.DataIngestion.Writers.Tests; + +public class InMemoryVectorStoreWriterTests : VectorStoreWriterTests +{ + protected override VectorStore CreateVectorStore(TestEmbeddingGenerator testEmbeddingGenerator) + => new InMemoryVectorStore(new() { EmbeddingGenerator = testEmbeddingGenerator }); +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/SqliteVectorStoreWriterTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/SqliteVectorStoreWriterTests.cs new file mode 100644 index 00000000000..b596445b822 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/SqliteVectorStoreWriterTests.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; +using Microsoft.Extensions.VectorData; +using Microsoft.SemanticKernel.Connectors.SqliteVec; + +namespace Microsoft.Extensions.DataIngestion.Writers.Tests; + +public sealed class SqliteVectorStoreWriterTests : VectorStoreWriterTests, IDisposable +{ + private readonly string _tempFile = Path.GetTempFileName(); + + public void Dispose() => File.Delete(_tempFile); + + protected override VectorStore CreateVectorStore(TestEmbeddingGenerator testEmbeddingGenerator) + => new SqliteVectorStore($"Data Source={_tempFile};Pooling=false", new() { EmbeddingGenerator = testEmbeddingGenerator }); +} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/VectorStoreWriterTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/VectorStoreWriterTests.cs new file mode 100644 index 00000000000..bafdc93fabe --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/VectorStoreWriterTests.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.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.VectorData; +using Xunit; + +namespace Microsoft.Extensions.DataIngestion.Writers.Tests; + +public abstract class VectorStoreWriterTests +{ + [Fact] + public async Task CanGenerateDynamicSchema() + { + string documentId = Guid.NewGuid().ToString(); + + using TestEmbeddingGenerator testEmbeddingGenerator = new(); + using VectorStore vectorStore = CreateVectorStore(testEmbeddingGenerator); + using VectorStoreWriter writer = new( + vectorStore, + dimensionCount: TestEmbeddingGenerator.DimensionCount); + + IngestionDocument document = new(documentId); + List> chunks = + [ + new("some content", document) + { + Metadata = + { + { "key1", "value1" }, + { "key2", 123 }, + { "key3", true }, + { "key4", 123.45 }, + } + } + ]; + + Assert.False(testEmbeddingGenerator.WasCalled); + await writer.WriteAsync(chunks.ToAsyncEnumerable()); + + Dictionary record = await writer.VectorStoreCollection + .GetAsync(filter: record => (string)record["documentid"]! == documentId, top: 1) + .SingleAsync(); + + Assert.NotNull(record); + Assert.NotNull(record["key"]); + Assert.Equal(documentId, record["documentid"]); + Assert.Equal(chunks[0].Content, record["content"]); + Assert.True(testEmbeddingGenerator.WasCalled); + foreach (var kvp in chunks[0].Metadata) + { + Assert.True(record.ContainsKey(kvp.Key), $"Record does not contain key '{kvp.Key}'"); + Assert.Equal(kvp.Value, record[kvp.Key]); + } + } + + [Fact] + public async Task DoesSupportIncrementalIngestion() + { + string documentId = Guid.NewGuid().ToString(); + + using TestEmbeddingGenerator testEmbeddingGenerator = new(); + using VectorStore vectorStore = CreateVectorStore(testEmbeddingGenerator); + using VectorStoreWriter writer = new( + vectorStore, + dimensionCount: TestEmbeddingGenerator.DimensionCount, + options: new() + { + IncrementalIngestion = true, + }); + + IngestionDocument document = new(documentId); + List> chunks = + [ + new("first chunk", document) + { + Metadata = + { + { "key1", "value1" } + } + }, + new("second chunk", document) + ]; + + await writer.WriteAsync(chunks.ToAsyncEnumerable()); + + int recordCount = await writer.VectorStoreCollection + .GetAsync(filter: record => (string)record["documentid"]! == documentId, top: 100) + .CountAsync(); + Assert.Equal(chunks.Count, recordCount); + + // Now we will do an incremental ingestion that updates the chunk(s). + List> updatedChunks = + [ + new("different content", document) + { + Metadata = + { + { "key1", "value2" }, + } + } + ]; + + await writer.WriteAsync(updatedChunks.ToAsyncEnumerable()); + + // We ask for 100 records, but we expect only 1 as the previous 2 should have been deleted. + Dictionary record = await writer.VectorStoreCollection + .GetAsync(filter: record => (string)record["documentid"]! == documentId, top: 100) + .SingleAsync(); + + Assert.NotNull(record); + Assert.NotNull(record["key"]); + Assert.Equal("different content", record["content"]); + Assert.Equal("value2", record["key1"]); + } + + protected abstract VectorStore CreateVectorStore(TestEmbeddingGenerator testEmbeddingGenerator); +} diff --git a/test/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation.Tests/Helpers/IAnotherFakeServiceCounter.cs b/test/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation.Tests/Helpers/IAnotherFakeServiceCounter.cs index 61fcf232461..9e3227e92d2 100644 --- a/test/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation.Tests/Helpers/IAnotherFakeServiceCounter.cs +++ b/test/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation.Tests/Helpers/IAnotherFakeServiceCounter.cs @@ -5,5 +5,5 @@ namespace Microsoft.Extensions.DependencyInjection.Test.Helpers; public interface IAnotherFakeServiceCounter { - public int Counter { get; set; } + int Counter { get; set; } } diff --git a/test/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation.Tests/Helpers/IFactoryServiceCounter.cs b/test/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation.Tests/Helpers/IFactoryServiceCounter.cs index 592a617d32f..a3d7b92cbb2 100644 --- a/test/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation.Tests/Helpers/IFactoryServiceCounter.cs +++ b/test/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation.Tests/Helpers/IFactoryServiceCounter.cs @@ -5,5 +5,5 @@ namespace Microsoft.Extensions.DependencyInjection.Test.Helpers; public interface IFactoryServiceCounter { - public int Counter { get; set; } + int Counter { get; set; } } diff --git a/test/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation.Tests/Helpers/IFakeMultipleCounter.cs b/test/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation.Tests/Helpers/IFakeMultipleCounter.cs index 772b0be54df..dea82dbdc7d 100644 --- a/test/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation.Tests/Helpers/IFakeMultipleCounter.cs +++ b/test/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation.Tests/Helpers/IFakeMultipleCounter.cs @@ -5,5 +5,5 @@ namespace Microsoft.Extensions.DependencyInjection.Test.Helpers; public interface IFakeMultipleCounter { - public int Counter { get; set; } + int Counter { get; set; } } diff --git a/test/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation.Tests/Helpers/IFakeOpenGenericCounter.cs b/test/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation.Tests/Helpers/IFakeOpenGenericCounter.cs index e7bc46ec661..e9573e4e34f 100644 --- a/test/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation.Tests/Helpers/IFakeOpenGenericCounter.cs +++ b/test/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation.Tests/Helpers/IFakeOpenGenericCounter.cs @@ -5,5 +5,5 @@ namespace Microsoft.Extensions.DependencyInjection.Test.Helpers; public interface IFakeOpenGenericCounter { - public int Counter { get; set; } + int Counter { get; set; } } diff --git a/test/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation.Tests/Helpers/IFakeServiceCounter.cs b/test/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation.Tests/Helpers/IFakeServiceCounter.cs index d7708a196d6..f8474f20599 100644 --- a/test/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation.Tests/Helpers/IFakeServiceCounter.cs +++ b/test/Libraries/Microsoft.Extensions.DependencyInjection.AutoActivation.Tests/Helpers/IFakeServiceCounter.cs @@ -5,5 +5,5 @@ namespace Microsoft.Extensions.DependencyInjection.Test.Helpers; public interface IFakeServiceCounter { - public int Counter { get; set; } + int Counter { get; set; } } diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization.Tests/ResourceHealthCheckExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization.Tests/ResourceHealthCheckExtensionsTests.cs index 16ec64fa003..2599be2dd9e 100644 --- a/test/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization.Tests/ResourceHealthCheckExtensionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization.Tests/ResourceHealthCheckExtensionsTests.cs @@ -490,6 +490,7 @@ public async Task TestCpuAndMemoryChecks_WithMetrics( Mock processInfoMock = new(); var appMemoryUsage = memoryUsed; processInfoMock.Setup(p => p.GetMemoryUsage()).Returns(() => appMemoryUsage); + processInfoMock.Setup(p => p.GetCurrentProcessMemoryUsage()).Returns(() => appMemoryUsage); JOBOBJECT_EXTENDED_LIMIT_INFORMATION limitInfo = default; limitInfo.JobMemoryLimit = new UIntPtr(totalMemory); @@ -500,6 +501,7 @@ public async Task TestCpuAndMemoryChecks_WithMetrics( accountingInfoAfter1Ms.TotalUserTime = (long)(utilization * 100); jobHandleMock.SetupSequence(j => j.GetBasicAccountingInfo()) .Returns(() => initialAccountingInfo) // this is called from the WindowsContainerSnapshotProvider's constructor + .Returns(() => initialAccountingInfo) // this is called from the WindowsContainerSnapshotProvider's GetCpuTime method .Returns(() => accountingInfoAfter1Ms); // this is called from the WindowsContainerSnapshotProvider's CpuPercentage method using var meter = new Meter("Microsoft.Extensions.Diagnostics.ResourceMonitoring"); diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/AcceptanceTest.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/AcceptanceTest.cs index efaaea1a51a..1dd11c1f3bb 100644 --- a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/AcceptanceTest.cs +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/AcceptanceTest.cs @@ -6,6 +6,9 @@ using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Metrics; using System.IO; +#if !NET10_0 +using System.Linq; +#endif using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Configuration; @@ -209,6 +212,8 @@ public Task ResourceUtilizationTracker_And_Metrics_Report_Same_Values_With_Cgrou using var listener = new MeterListener(); var clock = new FakeTimeProvider(DateTimeOffset.UtcNow); + var cpuUserTime = 0.0d; + var cpuKernelTime = 0.0d; var cpuFromGauge = 0.0d; var cpuLimitFromGauge = 0.0d; var cpuRequestFromGauge = 0.0d; @@ -217,10 +222,20 @@ public Task ResourceUtilizationTracker_And_Metrics_Report_Same_Values_With_Cgrou using var e = new ManualResetEventSlim(); object? meterScope = null; - listener.InstrumentPublished = (Instrument instrument, MeterListener meterListener) - => OnInstrumentPublished(instrument, meterListener, meterScope); - listener.SetMeasurementEventCallback((m, f, _, _) - => OnMeasurementReceived(m, f, ref cpuFromGauge, ref cpuLimitFromGauge, ref cpuRequestFromGauge, ref memoryFromGauge, ref memoryLimitFromGauge)); + listener.InstrumentPublished = (Instrument instrument, MeterListener meterListener) => + OnInstrumentPublished(instrument, meterListener, meterScope); + listener.SetMeasurementEventCallback((m, f, tags, _) => + OnMeasurementReceived( + m, + f, + tags, + ref cpuUserTime, + ref cpuKernelTime, + ref cpuFromGauge, + ref cpuLimitFromGauge, + ref cpuRequestFromGauge, + ref memoryFromGauge, + ref memoryLimitFromGauge)); listener.Start(); using var host = FakeHost.CreateBuilder() @@ -292,18 +307,38 @@ public Task ResourceUtilizationTracker_And_Metrics_Report_Same_Values_With_Cgrou using var listener = new MeterListener(); var clock = new FakeTimeProvider(DateTimeOffset.UtcNow); + var cpuUserTime = 0.0d; + var cpuKernelTime = 0.0d; var cpuFromGauge = 0.0d; var cpuLimitFromGauge = 0.0d; var cpuRequestFromGauge = 0.0d; var memoryFromGauge = 0.0d; var memoryLimitFromGauge = 0.0d; + long memoryUsageFromGauge = 0; using var e = new ManualResetEventSlim(); object? meterScope = null; - listener.InstrumentPublished = (Instrument instrument, MeterListener meterListener) - => OnInstrumentPublished(instrument, meterListener, meterScope); - listener.SetMeasurementEventCallback((m, f, _, _) - => OnMeasurementReceived(m, f, ref cpuFromGauge, ref cpuLimitFromGauge, ref cpuRequestFromGauge, ref memoryFromGauge, ref memoryLimitFromGauge)); + listener.InstrumentPublished = (Instrument instrument, MeterListener meterListener) => + OnInstrumentPublished(instrument, meterListener, meterScope); + listener.SetMeasurementEventCallback((m, f, tags, _) => + OnMeasurementReceived( + m, + f, + tags, + ref cpuUserTime, + ref cpuKernelTime, + ref cpuFromGauge, + ref cpuLimitFromGauge, + ref cpuRequestFromGauge, + ref memoryFromGauge, + ref memoryLimitFromGauge)); + listener.SetMeasurementEventCallback((instrument, value, tags, _) => + { + if (instrument.Name == ResourceUtilizationInstruments.ContainerMemoryUsage) + { + memoryUsageFromGauge = value; + } + }); listener.Start(); using var host = FakeHost.CreateBuilder() @@ -350,6 +385,7 @@ public Task ResourceUtilizationTracker_And_Metrics_Report_Same_Values_With_Cgrou Assert.Equal(1, roundedCpuUsedPercentage); Assert.Equal(50, utilization.MemoryUsedPercentage); + Assert.Equal(524288, memoryUsageFromGauge); Assert.Equal(0.5, cpuLimitFromGauge * 100); Assert.Equal(roundedCpuUsedPercentage, Math.Round(cpuRequestFromGauge * 100)); Assert.Equal(utilization.MemoryUsedPercentage, memoryLimitFromGauge * 100); @@ -362,86 +398,8 @@ public Task ResourceUtilizationTracker_And_Metrics_Report_Same_Values_With_Cgrou [ConditionalFact] [CombinatorialData] [OSSkipCondition(OperatingSystems.Windows | OperatingSystems.MacOSX, SkipReason = "Linux specific tests")] - public Task ResourceUtilizationTracker_And_Metrics_Report_Same_Values_With_Cgroupsv2_v2() - { - var cpuRefresh = TimeSpan.FromMinutes(13); - var memoryRefresh = TimeSpan.FromMinutes(14); - var fileSystem = new HardcodedValueFileSystem(new Dictionary - { - { new FileInfo("/proc/self/cgroup"), "0::/fakeslice"}, - { new FileInfo("/proc/stat"), "cpu 10 10 10 10 10 10 10 10 10 10"}, - { new FileInfo("/sys/fs/cgroup/fakeslice/cpu.stat"), "usage_usec 1020000\nnr_periods 50"}, - { new FileInfo("/sys/fs/cgroup/memory.max"), "1048576" }, - { new FileInfo("/proc/meminfo"), "MemTotal: 1024 kB"}, - { new FileInfo("/sys/fs/cgroup/cpuset.cpus.effective"), "0-19"}, - { new FileInfo("/sys/fs/cgroup/fakeslice/cpu.max"), "40000 10000"}, - { new FileInfo("/sys/fs/cgroup/fakeslice/cpu.weight"), "79"}, - }); - - using var listener = new MeterListener(); - var clock = new FakeTimeProvider(DateTimeOffset.UtcNow); - var cpuFromGauge = 0.0d; - var cpuLimitFromGauge = 0.0d; - var cpuRequestFromGauge = 0.0d; - var memoryFromGauge = 0.0d; - var memoryLimitFromGauge = 0.0d; - using var e = new ManualResetEventSlim(); - - object? meterScope = null; - listener.InstrumentPublished = (Instrument instrument, MeterListener meterListener) - => OnInstrumentPublished(instrument, meterListener, meterScope); - listener.SetMeasurementEventCallback((m, f, _, _) - => OnMeasurementReceived(m, f, ref cpuFromGauge, ref cpuLimitFromGauge, ref cpuRequestFromGauge, ref memoryFromGauge, ref memoryLimitFromGauge)); - listener.Start(); - - using var host = FakeHost.CreateBuilder() - .ConfigureServices(x => - x.AddLogging() - .AddSingleton(clock) - .AddSingleton(new FakeUserHz(100)) - .AddSingleton(fileSystem) - .AddSingleton(new GenericPublisher(_ => e.Set())) - .AddResourceMonitoring(x => x.ConfigureMonitor(options => options.CalculateCpuUsageWithoutHostDelta = true)) - .Replace(ServiceDescriptor.Singleton())) - .Build(); - - meterScope = host.Services.GetRequiredService(); - var tracker = host.Services.GetService(); - Assert.NotNull(tracker); - - _ = host.RunAsync(); - - listener.RecordObservableInstruments(); - - var utilization = tracker.GetUtilization(TimeSpan.FromSeconds(5)); - - fileSystem.ReplaceFileContent(new FileInfo("/proc/stat"), "cpu 11 10 10 10 10 10 10 10 10 10"); - fileSystem.ReplaceFileContent(new FileInfo("/sys/fs/cgroup/fakeslice/cpu.stat"), "usage_usec 1120000\nnr_periods 56"); - fileSystem.ReplaceFileContent(new FileInfo("/sys/fs/cgroup/memory.current"), "524298"); - fileSystem.ReplaceFileContent(new FileInfo("/sys/fs/cgroup/memory.stat"), "inactive_file 10"); - - clock.Advance(TimeSpan.FromSeconds(1)); - listener.RecordObservableInstruments(); - - e.Wait(); - - utilization = tracker.GetUtilization(TimeSpan.FromSeconds(1)); - - var roundedCpuUsedPercentage = Math.Round(utilization.CpuUsedPercentage, 1); - - Assert.Equal(0, Math.Round(cpuLimitFromGauge * 100)); - Assert.Equal(0, Math.Round(cpuRequestFromGauge * 100)); - - return Task.CompletedTask; - } - - [ConditionalFact] - [CombinatorialData] - [OSSkipCondition(OperatingSystems.Windows | OperatingSystems.MacOSX, SkipReason = "Linux specific tests")] - public Task ResourceUtilizationTracker_And_Metrics_Report_Same_Values_With_Cgroupsv2_v2_Using_NrPeriods() + public Task ResourceUtilizationTracker_And_Metrics_Report_Same_Values_With_Cgroupsv2_Using_LinuxCalculationV2() { - var cpuRefresh = TimeSpan.FromMinutes(13); - var memoryRefresh = TimeSpan.FromMinutes(14); var fileSystem = new HardcodedValueFileSystem(new Dictionary { { new FileInfo("/proc/self/cgroup"), "0::/fakeslice"}, @@ -458,43 +416,48 @@ public Task ResourceUtilizationTracker_And_Metrics_Report_Same_Values_With_Cgrou var clock = new FakeTimeProvider(DateTimeOffset.UtcNow); var cpuFromGauge = 0.0d; var cpuLimitFromGauge = 0.0d; + var cpuUserTime = 0.0d; + var cpuKernelTime = 0.0d; var cpuRequestFromGauge = 0.0d; var memoryFromGauge = 0.0d; var memoryLimitFromGauge = 0.0d; - using var e = new ManualResetEventSlim(); object? meterScope = null; - listener.InstrumentPublished = (Instrument instrument, MeterListener meterListener) - => OnInstrumentPublished(instrument, meterListener, meterScope); - listener.SetMeasurementEventCallback((m, f, _, _) - => OnMeasurementReceived(m, f, ref cpuFromGauge, ref cpuLimitFromGauge, ref cpuRequestFromGauge, ref memoryFromGauge, ref memoryLimitFromGauge)); + listener.InstrumentPublished = (Instrument instrument, MeterListener meterListener) => + OnInstrumentPublished(instrument, meterListener, meterScope); + listener.SetMeasurementEventCallback((m, f, tags, _) => + OnMeasurementReceived( + m, + f, + tags, + ref cpuUserTime, + ref cpuKernelTime, + ref cpuFromGauge, + ref cpuLimitFromGauge, + ref cpuRequestFromGauge, + ref memoryFromGauge, + ref memoryLimitFromGauge)); listener.Start(); - using var host = FakeHost.CreateBuilder() + using IHost host = FakeHost.CreateBuilder() .ConfigureServices(x => x.AddLogging() .AddSingleton(clock) .AddSingleton(new FakeUserHz(100)) .AddSingleton(fileSystem) - .AddSingleton(new GenericPublisher(_ => e.Set())) .AddResourceMonitoring(x => x.ConfigureMonitor(options => { - options.CalculateCpuUsageWithoutHostDelta = true; - options.UseDeltaNrPeriodsForCpuCalculation = true; + options.UseLinuxCalculationV2 = true; })) .Replace(ServiceDescriptor.Singleton())) .Build(); meterScope = host.Services.GetRequiredService(); - var tracker = host.Services.GetService(); - Assert.NotNull(tracker); _ = host.RunAsync(); listener.RecordObservableInstruments(); - var utilization = tracker.GetUtilization(TimeSpan.FromSeconds(5)); - fileSystem.ReplaceFileContent(new FileInfo("/proc/stat"), "cpu 11 10 10 10 10 10 10 10 10 10"); fileSystem.ReplaceFileContent(new FileInfo("/sys/fs/cgroup/fakeslice/cpu.stat"), "usage_usec 1120000\nnr_periods 56"); fileSystem.ReplaceFileContent(new FileInfo("/sys/fs/cgroup/memory.current"), "524298"); @@ -503,14 +466,10 @@ public Task ResourceUtilizationTracker_And_Metrics_Report_Same_Values_With_Cgrou clock.Advance(TimeSpan.FromSeconds(6)); listener.RecordObservableInstruments(); - e.Wait(); - - utilization = tracker.GetUtilization(TimeSpan.FromSeconds(5)); - - var roundedCpuUsedPercentage = Math.Round(utilization.CpuUsedPercentage, 1); - Assert.Equal(42, Math.Round(cpuLimitFromGauge * 100)); Assert.Equal(83, Math.Round(cpuRequestFromGauge * 100)); + Assert.Equal(167, Math.Round(cpuUserTime * 100)); + Assert.Equal(81, Math.Round(cpuKernelTime * 100)); return Task.CompletedTask; } @@ -525,19 +484,29 @@ private static void OnInstrumentPublished(Instrument instrument, MeterListener m #pragma warning disable S1067 // Expressions should not be too complex if (instrument.Name == ResourceUtilizationInstruments.ProcessCpuUtilization || instrument.Name == ResourceUtilizationInstruments.ProcessMemoryUtilization || + instrument.Name == ResourceUtilizationInstruments.ContainerCpuTime || instrument.Name == ResourceUtilizationInstruments.ContainerCpuRequestUtilization || instrument.Name == ResourceUtilizationInstruments.ContainerCpuLimitUtilization || - instrument.Name == ResourceUtilizationInstruments.ContainerMemoryLimitUtilization) + instrument.Name == ResourceUtilizationInstruments.ContainerMemoryLimitUtilization || + instrument.Name == ResourceUtilizationInstruments.ContainerMemoryUsage) { meterListener.EnableMeasurementEvents(instrument); } #pragma warning restore S1067 // Expressions should not be too complex } - private static void OnMeasurementReceived( - Instrument instrument, double value, - ref double cpuFromGauge, ref double cpuLimitFromGauge, ref double cpuRequestFromGauge, - ref double memoryFromGauge, ref double memoryLimitFromGauge) +#pragma warning disable S107 // Methods should not have too many parameters + private static void OnMeasurementReceived(Instrument instrument, + double value, + ReadOnlySpan> tags, + ref double cpuUserTime, + ref double cpuKernelTime, + ref double cpuFromGauge, + ref double cpuLimitFromGauge, + ref double cpuRequestFromGauge, + ref double memoryFromGauge, + ref double memoryLimitFromGauge) +#pragma warning restore S107 // Methods should not have too many parameters { if (instrument.Name == ResourceUtilizationInstruments.ProcessCpuUtilization) { @@ -547,6 +516,18 @@ private static void OnMeasurementReceived( { memoryFromGauge = value; } + else if (instrument.Name == ResourceUtilizationInstruments.ContainerCpuTime) + { + var tagsArray = tags.ToArray(); + if (tagsArray.Contains(new KeyValuePair("cpu.mode", "user"))) + { + cpuUserTime = value; + } + else if (tagsArray.Contains(new KeyValuePair("cpu.mode", "system"))) + { + cpuKernelTime = value; + } + } else if (instrument.Name == ResourceUtilizationInstruments.ContainerCpuLimitUtilization) { cpuLimitFromGauge = value; diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/LinuxUtilizationProviderTests.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/LinuxUtilizationProviderTests.cs index e6e9a282eca..c60ec5fa834 100644 --- a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/LinuxUtilizationProviderTests.cs +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/LinuxUtilizationProviderTests.cs @@ -9,6 +9,7 @@ using System.Threading.Tasks; using Microsoft.Extensions.Diagnostics.ResourceMonitoring.Test.Helpers; using Microsoft.Extensions.Logging.Testing; +using Microsoft.Extensions.Time.Testing; using Microsoft.Shared.Instruments; using Microsoft.TestUtilities; using Moq; @@ -71,10 +72,18 @@ public void Provider_Registers_Instruments() } }); + listener.SetMeasurementEventCallback((instrument, value, _, _) => + { + if (ReferenceEquals(meter, instrument.Meter)) + { + samples.Add((instrument, value)); + } + }); + listener.Start(); listener.RecordObservableInstruments(); - Assert.Equal(5, samples.Count); + Assert.Equal(6, samples.Count); Assert.Contains(samples, x => x.instrument.Name == ResourceUtilizationInstruments.ContainerCpuLimitUtilization); Assert.True(double.IsNaN(samples.Single(i => i.instrument.Name == ResourceUtilizationInstruments.ContainerCpuLimitUtilization).value)); @@ -85,6 +94,9 @@ public void Provider_Registers_Instruments() Assert.Contains(samples, x => x.instrument.Name == ResourceUtilizationInstruments.ContainerMemoryLimitUtilization); Assert.Equal(0.5, samples.Single(i => i.instrument.Name == ResourceUtilizationInstruments.ContainerMemoryLimitUtilization).value); + Assert.Contains(samples, x => x.instrument.Name == ResourceUtilizationInstruments.ContainerMemoryUsage); + Assert.Equal(524288, samples.Single(i => i.instrument.Name == ResourceUtilizationInstruments.ContainerMemoryUsage).value); + Assert.Contains(samples, x => x.instrument.Name == ResourceUtilizationInstruments.ProcessCpuUtilization); Assert.True(double.IsNaN(samples.Single(i => i.instrument.Name == ResourceUtilizationInstruments.ProcessCpuUtilization).value)); @@ -212,7 +224,7 @@ public void Provider_Registers_Instruments_CgroupV2_WithoutHostCpu() { var meterName = Guid.NewGuid().ToString(); var logger = new FakeLogger(); - var options = Options.Options.Create(new ResourceMonitoringOptions { CalculateCpuUsageWithoutHostDelta = true }); + var options = Options.Options.Create(new ResourceMonitoringOptions { UseLinuxCalculationV2 = true }); using var meter = new Meter(nameof(Provider_Registers_Instruments_CgroupV2_WithoutHostCpu)); var meterFactoryMock = new Mock(); meterFactoryMock.Setup(x => x.Create(It.IsAny())) @@ -258,7 +270,7 @@ public void Provider_Registers_Instruments_CgroupV2_WithoutHostCpu() listener.Start(); listener.RecordObservableInstruments(); - Assert.Equal(4, samples.Count); + Assert.Equal(6, samples.Count); Assert.Contains(samples, x => x.instrument.Name == ResourceUtilizationInstruments.ContainerCpuLimitUtilization); Assert.True(double.IsNaN(samples.Single(i => i.instrument.Name == ResourceUtilizationInstruments.ContainerCpuLimitUtilization).value)); @@ -266,10 +278,142 @@ public void Provider_Registers_Instruments_CgroupV2_WithoutHostCpu() Assert.Contains(samples, x => x.instrument.Name == ResourceUtilizationInstruments.ContainerCpuRequestUtilization); Assert.True(double.IsNaN(samples.Single(i => i.instrument.Name == ResourceUtilizationInstruments.ContainerCpuRequestUtilization).value)); + Assert.Contains(samples, x => x.instrument.Name == ResourceUtilizationInstruments.ContainerCpuTime); + Assert.All(samples.Where(i => i.instrument.Name == ResourceUtilizationInstruments.ContainerCpuTime), item => double.IsNaN(item.value)); + Assert.Contains(samples, x => x.instrument.Name == ResourceUtilizationInstruments.ContainerMemoryLimitUtilization); Assert.Equal(1, samples.Single(i => i.instrument.Name == ResourceUtilizationInstruments.ContainerMemoryLimitUtilization).value); Assert.Contains(samples, x => x.instrument.Name == ResourceUtilizationInstruments.ProcessMemoryUtilization); Assert.Equal(1, samples.Single(i => i.instrument.Name == ResourceUtilizationInstruments.ProcessMemoryUtilization).value); } + + [Fact] + public void Provider_GetMeasurementWithRetry_HandlesExceptionAndRecovers() + { + var meterName = Guid.NewGuid().ToString(); + var logger = new FakeLogger(); + var options = Options.Options.Create(new ResourceMonitoringOptions()); + using var meter = new Meter(nameof(Provider_GetMeasurementWithRetry_HandlesExceptionAndRecovers)); + var meterFactoryMock = new Mock(); + meterFactoryMock.Setup(x => x.Create(It.IsAny())) + .Returns(meter); + + var callCount = 0; + var parserMock = new Mock(); + parserMock.Setup(p => p.GetMemoryUsageInBytes()).Returns(() => + { + callCount++; + if (callCount <= 1) + { + throw new FileNotFoundException("Simulated failure to read file"); + } + + return 420UL; + }); + parserMock.Setup(p => p.GetAvailableMemoryInBytes()).Returns(1000UL); + parserMock.Setup(p => p.GetCgroupRequestCpu()).Returns(10f); + parserMock.Setup(p => p.GetCgroupLimitedCpus()).Returns(12f); + + var fakeTime = new FakeTimeProvider(DateTimeOffset.UtcNow); + var provider = new LinuxUtilizationProvider(options, parserMock.Object, meterFactoryMock.Object, logger, fakeTime); + + using var listener = new MeterListener + { + InstrumentPublished = (instrument, listener) => + { + if (ReferenceEquals(meter, instrument.Meter)) + { + listener.EnableMeasurementEvents(instrument); + } + } + }; + + var samples = new List<(Instrument instrument, double value)>(); + listener.SetMeasurementEventCallback((instrument, value, _, _) => + { + if (ReferenceEquals(meter, instrument.Meter)) + { + samples.Add((instrument, value)); + } + }); + + listener.Start(); + listener.RecordObservableInstruments(); + Assert.DoesNotContain(samples, x => x.instrument.Name == ResourceUtilizationInstruments.ProcessMemoryUtilization); + + fakeTime.Advance(TimeSpan.FromMinutes(1)); + listener.RecordObservableInstruments(); + Assert.DoesNotContain(samples, x => x.instrument.Name == ResourceUtilizationInstruments.ProcessMemoryUtilization); + + fakeTime.Advance(TimeSpan.FromMinutes(5)); + listener.RecordObservableInstruments(); + var metric = samples.SingleOrDefault(x => x.instrument.Name == ResourceUtilizationInstruments.ProcessMemoryUtilization); + Assert.Equal(0.42, metric.value); + + parserMock.Verify(p => p.GetMemoryUsageInBytes(), Times.Exactly(2)); + } + + [Fact] + public void Provider_GetMeasurementWithRetry_UnhandledException_DoesNotBlockFutureReads() + { + var meterName = Guid.NewGuid().ToString(); + var logger = new FakeLogger(); + var options = Options.Options.Create(new ResourceMonitoringOptions()); + using var meter = new Meter(nameof(Provider_GetMeasurementWithRetry_UnhandledException_DoesNotBlockFutureReads)); + var meterFactoryMock = new Mock(); + meterFactoryMock.Setup(x => x.Create(It.IsAny())) + .Returns(meter); + + var callCount = 0; + var parserMock = new Mock(); + parserMock.Setup(p => p.GetMemoryUsageInBytes()).Returns(() => + { + callCount++; + if (callCount <= 3) + { + throw new InvalidOperationException("Simulated unhandled exception"); + } + + return 1234UL; + }); + parserMock.Setup(p => p.GetAvailableMemoryInBytes()).Returns(2000UL); + parserMock.Setup(p => p.GetCgroupRequestCpu()).Returns(10f); + parserMock.Setup(p => p.GetCgroupLimitedCpus()).Returns(12f); + + var fakeTime = new FakeTimeProvider(DateTimeOffset.UtcNow); + var provider = new LinuxUtilizationProvider(options, parserMock.Object, meterFactoryMock.Object, logger, fakeTime); + + using var listener = new MeterListener + { + InstrumentPublished = (instrument, listener) => + { + if (ReferenceEquals(meter, instrument.Meter)) + { + listener.EnableMeasurementEvents(instrument); + } + } + }; + + var samples = new List<(Instrument instrument, double value)>(); + listener.SetMeasurementEventCallback((instrument, value, _, _) => + { + if (ReferenceEquals(meter, instrument.Meter)) + { + samples.Add((instrument, value)); + } + }); + + listener.Start(); + + Assert.Throws(() => listener.RecordObservableInstruments()); + Assert.DoesNotContain(samples, x => x.instrument.Name == ResourceUtilizationInstruments.ProcessMemoryUtilization); + + fakeTime.Advance(TimeSpan.FromMinutes(1)); + listener.RecordObservableInstruments(); + var metric = samples.SingleOrDefault(x => x.instrument.Name == ResourceUtilizationInstruments.ProcessMemoryUtilization); + Assert.Equal(1234f / 2000f, metric.value, 0.01f); + + parserMock.Verify(p => p.GetMemoryUsageInBytes(), Times.Exactly(4)); + } } diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/Verified/LinuxNetworkUtilizationParserTests.1.DotNet10_0.verified.txt b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/Verified/LinuxNetworkUtilizationParserTests.1.DotNet10_0.verified.txt new file mode 100644 index 00000000000..4896aab52db --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/Verified/LinuxNetworkUtilizationParserTests.1.DotNet10_0.verified.txt @@ -0,0 +1,10 @@ +{ + Type: InvalidOperationException, + Message: Could not split contents. We expected every line to contain more than 4 elements, but it has only 2 elements., + StackTrace: +at Microsoft.Shared.Diagnostics.Throw.InvalidOperationException(String message) +at Microsoft.Extensions.Diagnostics.ResourceMonitoring.Linux.Network.LinuxNetworkUtilizationParser.UpdateTcpStateInfo(ReadOnlySpan`1 buffer, TcpStateInfo tcpStateInfo) +at Microsoft.Extensions.Diagnostics.ResourceMonitoring.Linux.Network.LinuxNetworkUtilizationParser.GetTcpStateInfo(FileInfo file) +at Microsoft.Extensions.Diagnostics.ResourceMonitoring.Linux.Network.LinuxNetworkUtilizationParser.GetTcpIPv4StateInfo() +at Xunit.Record.Exception(Func`1 testCode) +} diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/Verified/LinuxNetworkUtilizationParserTests.2.DotNet10_0.verified.txt b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/Verified/LinuxNetworkUtilizationParserTests.2.DotNet10_0.verified.txt new file mode 100644 index 00000000000..4896aab52db --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/Verified/LinuxNetworkUtilizationParserTests.2.DotNet10_0.verified.txt @@ -0,0 +1,10 @@ +{ + Type: InvalidOperationException, + Message: Could not split contents. We expected every line to contain more than 4 elements, but it has only 2 elements., + StackTrace: +at Microsoft.Shared.Diagnostics.Throw.InvalidOperationException(String message) +at Microsoft.Extensions.Diagnostics.ResourceMonitoring.Linux.Network.LinuxNetworkUtilizationParser.UpdateTcpStateInfo(ReadOnlySpan`1 buffer, TcpStateInfo tcpStateInfo) +at Microsoft.Extensions.Diagnostics.ResourceMonitoring.Linux.Network.LinuxNetworkUtilizationParser.GetTcpStateInfo(FileInfo file) +at Microsoft.Extensions.Diagnostics.ResourceMonitoring.Linux.Network.LinuxNetworkUtilizationParser.GetTcpIPv4StateInfo() +at Xunit.Record.Exception(Func`1 testCode) +} diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/ResourceMonitoringOptionsTests.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/ResourceMonitoringOptionsTests.cs index 1a40889fd72..ca3126e8b97 100644 --- a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/ResourceMonitoringOptionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/ResourceMonitoringOptionsTests.cs @@ -19,7 +19,7 @@ public void Basic() }; Assert.NotNull(options); - Assert.False(options.CalculateCpuUsageWithoutHostDelta); + Assert.False(options.UseLinuxCalculationV2); } [Fact] @@ -27,9 +27,9 @@ public void CalculateCpuUsageWithoutHostDelta_WhenSet_ReturnsExpectedValue() { var options = new ResourceMonitoringOptions { - CalculateCpuUsageWithoutHostDelta = true + UseLinuxCalculationV2 = true }; - Assert.True(options.CalculateCpuUsageWithoutHostDelta); + Assert.True(options.UseLinuxCalculationV2); } } diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/ResourceMonitoringServiceTests.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/ResourceMonitoringServiceTests.cs index a714861c204..9e1efa767ad 100644 --- a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/ResourceMonitoringServiceTests.cs +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/ResourceMonitoringServiceTests.cs @@ -222,7 +222,7 @@ public async Task StartAsync_WithSimulatingThatTimeDidNotPass_NoUtilizationDataW Assert.Equal(0, numberOfSnapshots); } - [Fact] + [Fact(Skip = "Flaky test, see https://github.com/dotnet/extensions/issues/7009")] public async Task RunTrackerAsync_IfProviderThrows_LogsError() { var clock = new FakeTimeProvider(); @@ -255,11 +255,20 @@ public async Task RunTrackerAsync_IfProviderThrows_LogsError() // Now, allow the faulted provider to throw. provider.ShouldThrow = true; - clock.Advance(TimeSpan.FromMilliseconds(1)); - clock.Advance(TimeSpan.FromMilliseconds(1)); - - e.Wait(); + // Use polling pattern with FakeTimeProvider to ensure the async ExecuteAsync loop completes. + // The ExecuteAsync method waits on _timeProvider.Delay(), which requires repeated Advance() calls + // to trigger in FakeTimeProvider. We can't assume exactly when the delay is "armed" and ready to + // respond to time advancement, especially across different .NET runtime versions where task scheduling + // may differ. This polling approach (advance time + short wait) ensures the test works reliably + // regardless of when the Delay starts waiting, avoiding the indefinite hang that occurred in net10.0. + var maxAttempts = 100; // Prevent infinite loop + var attempts = 0; + while (!e.Wait(10) && attempts++ < maxAttempts) + { + clock.Advance(TimeSpan.FromMilliseconds(1)); + } + Assert.True(attempts < maxAttempts, "Timeout waiting for publisher to be called"); Assert.Contains(ProviderUnableToGatherData, logger.Collector.LatestRecord.Message, StringComparison.OrdinalIgnoreCase); } @@ -367,10 +376,19 @@ public async Task ResourceUtilizationTracker_LogsSnapshotInformation() // Start running the tracker. await tracker.StartAsync(CancellationToken.None); - clock.Advance(TimeSpan.FromMilliseconds(TimerPeriod)); + // Use polling pattern to ensure the ExecuteAsync loop runs and logs snapshot information. + // We need to wait until at least one log record appears before stopping the service. + var maxAttempts = 100; + var attempts = 0; + while (logger.Collector.Count == 0 && attempts++ < maxAttempts) + { + clock.Advance(TimeSpan.FromMilliseconds(TimerPeriod)); + await Task.Delay(10); // Give the async loop time to process + } await tracker.StopAsync(CancellationToken.None); + Assert.True(logger.Collector.Count > 0, "No logs were recorded"); await Verifier.Verify(logger.Collector.LatestRecord).UseDirectory("Verified"); } diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/WindowsContainerSnapshotProviderTests.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/WindowsContainerSnapshotProviderTests.cs index 08d3ecf6020..ead512e015b 100644 --- a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/WindowsContainerSnapshotProviderTests.cs +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/WindowsContainerSnapshotProviderTests.cs @@ -190,6 +190,66 @@ public void GetSnapshot_With_JobMemoryLimit_Set_To_Zero_ProducesCorrectSnapshot( Assert.True(data.MemoryUsageInBytes > 0); } + [Fact] + public void SnapshotProvider_EmitsCpuTimeMetric() + { + // Simulating 10% CPU usage (2 CPUs, 2000 ticks initially, 4000 ticks after 1 ms): + JOBOBJECT_BASIC_ACCOUNTING_INFORMATION updatedAccountingInfo = default; + updatedAccountingInfo.TotalKernelTime = 2500; + updatedAccountingInfo.TotalUserTime = 1500; + + _jobHandleMock.SetupSequence(j => j.GetBasicAccountingInfo()) + .Returns(_accountingInfo) + .Returns(_accountingInfo) + .Returns(updatedAccountingInfo) + .Returns(updatedAccountingInfo) + .Throws(new InvalidOperationException("We shouldn't hit here...")); + + _sysInfo.NumberOfProcessors = 2; + + var fakeClock = new FakeTimeProvider(); + using var meter = new Meter(nameof(SnapshotProvider_EmitsCpuMetrics)); + var meterFactoryMock = new Mock(); + meterFactoryMock.Setup(x => x.Create(It.IsAny())) + .Returns(meter); + using var metricCollector = new MetricCollector(meter, ResourceUtilizationInstruments.ContainerCpuTime, fakeClock); + + var options = new ResourceMonitoringOptions { CpuConsumptionRefreshInterval = TimeSpan.FromMilliseconds(2) }; + + var snapshotProvider = new WindowsContainerSnapshotProvider( + _memoryInfoMock.Object, + _systemInfoMock.Object, + _processInfoMock.Object, + _logger, + meterFactoryMock.Object, + () => _jobHandleMock.Object, + fakeClock, + options); + + // Step #0 - state in the beginning: + metricCollector.RecordObservableInstruments(); + var snapshot = metricCollector.GetMeasurementSnapshot(); + Assert.Equal(2, snapshot.Count); + Assert.Contains(_accountingInfo.TotalKernelTime / (double)TimeSpan.TicksPerSecond, snapshot.Select(m => m.Value)); + Assert.Contains(_accountingInfo.TotalKernelTime / (double)TimeSpan.TicksPerSecond, snapshot.Select(m => m.Value)); + + // Step #1 - simulate 1 millisecond passing and collect metrics again: + fakeClock.Advance(TimeSpan.FromMilliseconds(1)); + metricCollector.RecordObservableInstruments(); + snapshot = metricCollector.GetMeasurementSnapshot(); + Assert.Contains(updatedAccountingInfo.TotalKernelTime / (double)TimeSpan.TicksPerSecond, snapshot.Select(m => m.Value)); + Assert.Contains(updatedAccountingInfo.TotalKernelTime / (double)TimeSpan.TicksPerSecond, snapshot.Select(m => m.Value)); + + // Step #2 - simulate 1 millisecond passing and collect metrics again: + fakeClock.Advance(TimeSpan.FromMilliseconds(1)); + metricCollector.RecordObservableInstruments(); + snapshot = metricCollector.GetMeasurementSnapshot(); + + // CPU time should be the same as before, as we're not simulating any CPU usage: + Assert.Contains(updatedAccountingInfo.TotalKernelTime / (double)TimeSpan.TicksPerSecond, snapshot.Select(m => m.Value)); + Assert.Contains(updatedAccountingInfo.TotalKernelTime / (double)TimeSpan.TicksPerSecond, snapshot.Select(m => m.Value)); + } + [Theory] [InlineData(ResourceUtilizationInstruments.ProcessCpuUtilization, true)] [InlineData(ResourceUtilizationInstruments.ProcessCpuUtilization, false)] @@ -319,6 +379,146 @@ public void SnapshotProvider_EmitsMemoryMetrics(string instrumentName, bool useZ Assert.Equal(0.3 * multiplier, metricCollector.LastMeasurement.Value); // Consuming 30% of the memory afterwards. } + [Fact] + public void SnapshotProvider_TestMemoryMetricsTogether() + { + _appMemoryUsage = 200UL; + ulong containerMemoryUsage = 400UL; + ulong updatedAppMemoryUsage = 600UL; + ulong updatedContainerMemoryUsage = 1200UL; + + _processInfoMock.SetupSequence(p => p.GetCurrentProcessMemoryUsage()) + .Returns(() => _appMemoryUsage) + .Returns(updatedAppMemoryUsage) + .Throws(new InvalidOperationException("We shouldn't hit here...")); + + _processInfoMock.SetupSequence(p => p.GetMemoryUsage()) + .Returns(() => containerMemoryUsage) + .Returns(updatedContainerMemoryUsage) + .Throws(new InvalidOperationException("We shouldn't hit here...")); + + var fakeClock = new FakeTimeProvider(); + using var meter = new Meter(nameof(SnapshotProvider_TestMemoryMetricsTogether)); + var meterFactoryMock = new Mock(); + meterFactoryMock.Setup(x => x.Create(It.IsAny())) + .Returns(meter); + using var processMetricCollector = new MetricCollector(meter, ResourceUtilizationInstruments.ProcessMemoryUtilization, fakeClock); + using var containerLimitMetricCollector = new MetricCollector(meter, ResourceUtilizationInstruments.ContainerMemoryLimitUtilization, fakeClock); + using var containerUsageMetricCollector = new MetricCollector(meter, ResourceUtilizationInstruments.ContainerMemoryUsage, fakeClock); + + var options = new ResourceMonitoringOptions + { + MemoryConsumptionRefreshInterval = TimeSpan.FromMilliseconds(2) + }; + var snapshotProvider = new WindowsContainerSnapshotProvider( + _memoryInfoMock.Object, + _systemInfoMock.Object, + _processInfoMock.Object, + _logger, + meterFactoryMock.Object, + () => _jobHandleMock.Object, + fakeClock, + options); + + // Step #0 - state in the beginning: + processMetricCollector.RecordObservableInstruments(); + containerLimitMetricCollector.RecordObservableInstruments(); + containerUsageMetricCollector.RecordObservableInstruments(); + + Assert.NotNull(processMetricCollector.LastMeasurement?.Value); + Assert.NotNull(containerLimitMetricCollector.LastMeasurement?.Value); + Assert.NotNull(containerUsageMetricCollector.LastMeasurement?.Value); + + Assert.Equal(10, processMetricCollector.LastMeasurement.Value); // Process is consuming 10% of memory limit initially. + Assert.Equal(20, containerLimitMetricCollector.LastMeasurement.Value); // The whole container is consuming 20% of the memory limit initially. + Assert.Equal((long)containerMemoryUsage, containerUsageMetricCollector.LastMeasurement.Value); // 400 bytes of memory usage initially. + + // Step #1 - simulate 1 millisecond passing and collect metrics again: + fakeClock.Advance(options.MemoryConsumptionRefreshInterval - TimeSpan.FromMilliseconds(1)); + + processMetricCollector.RecordObservableInstruments(); + containerUsageMetricCollector.RecordObservableInstruments(); + containerLimitMetricCollector.RecordObservableInstruments(); + + // Still consuming 10% and 20% as values weren't updated yet - not enough time passed. + Assert.Equal(10, processMetricCollector.LastMeasurement.Value); + Assert.Equal(20, containerLimitMetricCollector.LastMeasurement.Value); + Assert.Equal((long)containerMemoryUsage, containerUsageMetricCollector.LastMeasurement.Value); + + // Step #2 - simulate 2 milliseconds passing and collect metrics again: + fakeClock.Advance(TimeSpan.FromMilliseconds(1)); + + processMetricCollector.RecordObservableInstruments(); + containerLimitMetricCollector.RecordObservableInstruments(); + containerUsageMetricCollector.RecordObservableInstruments(); + + // App is consuming 30%, and container is consuming 60% of the limit: + Assert.Equal(30, processMetricCollector.LastMeasurement.Value); + Assert.Equal(60, containerLimitMetricCollector.LastMeasurement.Value); + Assert.Equal((long)updatedContainerMemoryUsage, containerUsageMetricCollector.LastMeasurement.Value); + } + + [Fact] + public void SnapshotProvider_EmitsMemoryUsageMetric() + { + _appMemoryUsage = 200UL; + const ulong UpdatedAppMemoryUsage = 600UL; + const ulong UpdatedAppMemoryUsage2 = 300UL; + + _processInfoMock.SetupSequence(p => p.GetCurrentProcessMemoryUsage()) + .Returns(() => _appMemoryUsage) + .Returns(UpdatedAppMemoryUsage) + .Returns(UpdatedAppMemoryUsage2) + .Throws(new InvalidOperationException("We shouldn't hit here...")); + + _processInfoMock.SetupSequence(p => p.GetMemoryUsage()) + .Returns(() => _appMemoryUsage) + .Returns(UpdatedAppMemoryUsage) + .Returns(UpdatedAppMemoryUsage2) + .Throws(new InvalidOperationException("We shouldn't hit here...")); + + var fakeClock = new FakeTimeProvider(); + using var meter = new Meter(nameof(SnapshotProvider_EmitsMemoryMetrics)); + var meterFactoryMock = new Mock(); + meterFactoryMock.Setup(x => x.Create(It.IsAny())) + .Returns(meter); + using var metricCollector = new MetricCollector(meter, ResourceUtilizationInstruments.ContainerMemoryUsage, fakeClock); + + var options = new ResourceMonitoringOptions + { + MemoryConsumptionRefreshInterval = TimeSpan.FromMilliseconds(2), + }; + var snapshotProvider = new WindowsContainerSnapshotProvider( + _memoryInfoMock.Object, + _systemInfoMock.Object, + _processInfoMock.Object, + _logger, + meterFactoryMock.Object, + () => _jobHandleMock.Object, + fakeClock, + options); + + // Step #0 - state in the beginning: + metricCollector.RecordObservableInstruments(); + Assert.NotNull(metricCollector.LastMeasurement?.Value); + Assert.Equal(200, metricCollector.LastMeasurement.Value); // Consuming 200 bytes initially. + + // Step #1 - simulate 1 millisecond passing and collect metrics again: + fakeClock.Advance(options.MemoryConsumptionRefreshInterval - TimeSpan.FromMilliseconds(1)); + metricCollector.RecordObservableInstruments(); + Assert.Equal(200, metricCollector.LastMeasurement.Value); // Still consuming 200 bytes as metric wasn't updated. + + // Step #2 - simulate 2 milliseconds passing and collect metrics again: + fakeClock.Advance(TimeSpan.FromMilliseconds(2)); + metricCollector.RecordObservableInstruments(); + Assert.Equal(600, metricCollector.LastMeasurement.Value); // Consuming 600 bytes. + + // Step #3 - simulate 2 milliseconds passing and collect metrics again: + fakeClock.Advance(TimeSpan.FromMilliseconds(2)); + metricCollector.RecordObservableInstruments(); + Assert.Equal(300, metricCollector.LastMeasurement.Value); // Consuming 300 bytes. + } + [Fact] public Task SnapshotProvider_EmitsLogRecord() { diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.Testing.Tests/Logging/FakeLoggerTests.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.Testing.Tests/Logging/FakeLoggerTests.cs index 9ec81a36077..18083f43569 100644 --- a/test/Libraries/Microsoft.Extensions.Diagnostics.Testing.Tests/Logging/FakeLoggerTests.cs +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.Testing.Tests/Logging/FakeLoggerTests.cs @@ -6,8 +6,6 @@ using System.Globalization; using System.Linq; using System.Threading; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Testing; using Microsoft.Extensions.Time.Testing; using Xunit; @@ -283,4 +281,61 @@ public void Scopes() Assert.Equal(42, (int)logger.LatestRecord.Scopes[0]!); Assert.Equal("Hello World", (string)logger.LatestRecord.Scopes[1]!); } + + [Theory] + [InlineData(false, 2)] + [InlineData(true, 1)] + public void FilterByCustomFilter(bool useErrorLevelFilter, int expectedRecordCount) + { + const string NotIgnoredMessage1 = "Not ignored message 1"; + const string NotIgnoredMessage2 = "Not ignored message 2"; + const string IgnoredMessage = "Ignored message"; + + // Given + var options = new FakeLogCollectorOptions + { + CustomFilter = r => r.Message != IgnoredMessage, + FilteredLevels = useErrorLevelFilter ? [LogLevel.Error] : new HashSet(), + }; + + var collector = FakeLogCollector.Create(options); + var logger = new FakeLogger(collector); + + // When + logger.LogInformation(NotIgnoredMessage1); + logger.LogInformation(IgnoredMessage); + logger.LogError(IgnoredMessage); + logger.LogError(NotIgnoredMessage2); + logger.LogCritical(IgnoredMessage); + + var records = logger.Collector.GetSnapshot(); + + // Then + Assert.Equal(expectedRecordCount, records.Count); + Assert.Equal(expectedRecordCount, logger.Collector.Count); + + IList<(string message, LogLevel level, string prefix)> expectationsInOrder = useErrorLevelFilter + ? [(NotIgnoredMessage2, LogLevel.Error, "error] ")] + : [(NotIgnoredMessage1, LogLevel.Information, "info] "), (NotIgnoredMessage2, LogLevel.Error, "error] ")]; + + for (var i = 0; i < expectedRecordCount; i++) + { + var (expectedMessage, expectedLevel, expectedPrefix) = expectationsInOrder[i]; + var record = records[i]; + + Assert.Equal(expectedMessage, record.Message); + Assert.Equal(expectedLevel, record.Level); + Assert.Null(record.Exception); + Assert.Null(record.Category); + Assert.True(record.LevelEnabled); + Assert.Empty(record.Scopes); + Assert.Equal(0, record.Id.Id); + Assert.EndsWith($"{expectedPrefix}{expectedMessage}", record.ToString()); + + if (i == expectedRecordCount - 1) + { + Assert.Equivalent(record, logger.LatestRecord); + } + } + } } diff --git a/test/Libraries/Microsoft.Extensions.Hosting.Testing.Tests/FakeHostTests.cs b/test/Libraries/Microsoft.Extensions.Hosting.Testing.Tests/FakeHostTests.cs index 49efadd8f09..f96fb65a7ce 100644 --- a/test/Libraries/Microsoft.Extensions.Hosting.Testing.Tests/FakeHostTests.cs +++ b/test/Libraries/Microsoft.Extensions.Hosting.Testing.Tests/FakeHostTests.cs @@ -33,6 +33,8 @@ public async Task Host_ShutsDownAfterTimeout() }) .StartAsync(); + await Task.Delay(100); // Give some time for the host to shut down + Assert.Throws(() => host.Services.GetService()); } diff --git a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Latency/Internal/HttpClientLatencyLogEnricherTest.cs b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Latency/Internal/HttpClientLatencyLogEnricherTest.cs index 71941df0e7e..79753bfb996 100644 --- a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Latency/Internal/HttpClientLatencyLogEnricherTest.cs +++ b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Latency/Internal/HttpClientLatencyLogEnricherTest.cs @@ -18,14 +18,14 @@ public class HttpClientLatencyLogEnricherTest public void HttpClientLatencyLogEnricher_NoOp_OnRequest() { var lcti = HttpMockProvider.GetTokenIssuer(); + var mediator = new HttpLatencyMediator(lcti.Object); var checkpoints = new ArraySegment(new[] { new Checkpoint("a", default, default), new Checkpoint("b", default, default) }); var ld = new LatencyData(default, checkpoints, default, default, default); var lc = HttpMockProvider.GetLatencyContext(); lc.Setup(lc => lc.LatencyData).Returns(ld); var context = new HttpClientLatencyContext(); context.Set(lc.Object); - - var enricher = new HttpClientLatencyLogEnricher(context, lcti.Object); + var enricher = new HttpClientLatencyLogEnricher(context, lcti.Object, mediator); Mock mockEnrichmentPropertyBag = new Mock(); enricher.Enrich(mockEnrichmentPropertyBag.Object, null!, null, null); mockEnrichmentPropertyBag.Verify(m => m.Add(It.IsAny(), It.IsAny()), Times.Never); @@ -35,6 +35,7 @@ public void HttpClientLatencyLogEnricher_NoOp_OnRequest() public void HttpClientLatencyLogEnricher_Enriches_OnResponseWithoutHeader() { var lcti = HttpMockProvider.GetTokenIssuer(); + var mediator = new HttpLatencyMediator(lcti.Object); var checkpoints = new ArraySegment(new[] { new Checkpoint("a", default, default), new Checkpoint("b", default, default) }); var ld = new LatencyData(default, checkpoints, default, default, default); var lc = HttpMockProvider.GetLatencyContext(); @@ -43,8 +44,7 @@ public void HttpClientLatencyLogEnricher_Enriches_OnResponseWithoutHeader() context.Set(lc.Object); using HttpResponseMessage httpResponseMessage = new(); - - var enricher = new HttpClientLatencyLogEnricher(context, lcti.Object); + var enricher = new HttpClientLatencyLogEnricher(context, lcti.Object, mediator); Mock mockEnrichmentPropertyBag = new Mock(); enricher.Enrich(mockEnrichmentPropertyBag.Object, null!, httpResponseMessage, null); @@ -55,6 +55,7 @@ public void HttpClientLatencyLogEnricher_Enriches_OnResponseWithoutHeader() public void HttpClientLatencyLogEnricher_Enriches_OnResponseWithHeader() { var lcti = HttpMockProvider.GetTokenIssuer(); + var mediator = new HttpLatencyMediator(lcti.Object); var checkpoints = new ArraySegment(new[] { new Checkpoint("a", default, default), new Checkpoint("b", default, default) }); var ld = new LatencyData(default, checkpoints, default, default, default); var lc = HttpMockProvider.GetLatencyContext(); @@ -66,7 +67,7 @@ public void HttpClientLatencyLogEnricher_Enriches_OnResponseWithHeader() string serverName = "serverNameVal"; httpResponseMessage.Headers.Add(TelemetryConstants.ServerApplicationNameHeader, serverName); - var enricher = new HttpClientLatencyLogEnricher(context, lcti.Object); + var enricher = new HttpClientLatencyLogEnricher(context, lcti.Object, mediator); Mock mockEnrichmentPropertyBag = new Mock(); enricher.Enrich(mockEnrichmentPropertyBag.Object, null!, httpResponseMessage, null); diff --git a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Latency/Internal/HttpLatencyMediatorTests.cs b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Latency/Internal/HttpLatencyMediatorTests.cs new file mode 100644 index 00000000000..b82b815e430 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Latency/Internal/HttpLatencyMediatorTests.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. + +#if NET +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AmbientMetadata; +using Microsoft.Extensions.Diagnostics.Latency; +using Microsoft.Extensions.Http.Latency.Internal; +using Microsoft.Extensions.Options; +using Moq; +using Moq.Protected; +using Xunit; + +namespace Microsoft.Extensions.Http.Latency.Test.Internal; + +public class HttpLatencyMediatorTests +{ + [Fact] + public void RecordStart_RecordsGCPauseMeasure() + { + // Arrange + var lcti = HttpMockProvider.GetTokenIssuer(); + var measureToken = new MeasureToken(HttpMeasures.GCPauseTime, 0); + lcti.Setup(i => i.GetMeasureToken(HttpMeasures.GCPauseTime)) + .Returns(measureToken); + + var lc = HttpMockProvider.GetLatencyContext(); + var mediator = new HttpLatencyMediator(lcti.Object); + + // Act + mediator.RecordStart(lc.Object); + + // Assert + // Verify RecordMeasure was called with the correct token + lc.Verify(c => c.RecordMeasure( + measureToken, + It.Is(v => v <= 0)), // Value should be negative (start value) + Times.Once); + } + + [Fact] + public async Task HttpLatencyTelemetryHandler_UsesMediator() + { + // Arrange + var lc = HttpMockProvider.GetLatencyContext(); + var lcp = HttpMockProvider.GetContextProvider(lc); + lcp.Setup(p => p.CreateContext()).Returns(lc.Object); + + var context = new HttpClientLatencyContext(); + + var sop = new Mock>(); + sop.Setup(a => a.Value).Returns(new ApplicationMetadata()); + var hop = new Mock>(); + hop.Setup(a => a.Value).Returns(new HttpClientLatencyTelemetryOptions()); + + var lcti = HttpMockProvider.GetTokenIssuer(); + + // Create a mediator + var mediator = new HttpLatencyMediator(lcti.Object); + + using var listener = HttpMockProvider.GetListener(context, lcti.Object); + using var req = new HttpRequestMessage(); + req.Method = HttpMethod.Post; + req.RequestUri = new Uri($"https://default-uri.com/foo"); + + var resp = new HttpResponseMessage(); + var mockHandler = new Mock(); + mockHandler.Protected().Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(resp); + + using var handler = new HttpLatencyTelemetryHandler( + listener, lcti.Object, lcp.Object, hop.Object, sop.Object, mediator); + handler.InnerHandler = mockHandler.Object; + + // Act + using var client = new HttpClient(handler); + await client.SendAsync(req, It.IsAny()); + + // Verify that the latency context was created and properly used + lcp.Verify(p => p.CreateContext(), Times.Once); + resp.Dispose(); + } + + [Fact] + public void RecordEnd_RecordsGCPauseMeasure() + { + // Arrange + var lcti = HttpMockProvider.GetTokenIssuer(); + var measureToken = new MeasureToken(HttpMeasures.GCPauseTime, 0); + lcti.Setup(i => i.GetMeasureToken(HttpMeasures.GCPauseTime)) + .Returns(measureToken); + + var lc = HttpMockProvider.GetLatencyContext(); + var mediator = new HttpLatencyMediator(lcti.Object); + + // Act + mediator.RecordEnd(lc.Object); + + lc.Verify(c => c.AddMeasure(measureToken, It.IsAny()), Times.Once); + } + + [Fact] + public void RecordEnd_WithResponse_SetsHttpVersionTag() + { + // Arrange + var lcti = HttpMockProvider.GetTokenIssuer(); + var httpVersionToken = new TagToken(HttpTags.HttpVersion, 0); + lcti.Setup(i => i.GetTagToken(HttpTags.HttpVersion)) + .Returns(httpVersionToken); + + var lc = HttpMockProvider.GetLatencyContext(); + var mediator = new HttpLatencyMediator(lcti.Object); + + using var response = new HttpResponseMessage(); + response.Version = new Version(2, 0); + + // Act + mediator.RecordEnd(lc.Object, response); + + // Assert + lc.Verify(c => c.SetTag( + httpVersionToken, + "2.0"), + Times.Once); + } + + [Fact] + public void RecordEnd_WithNullResponse_DoesNotSetHttpVersionTag() + { + // Arrange + var lcti = HttpMockProvider.GetTokenIssuer(); + var httpVersionToken = new TagToken("Http.Version", 0); + lcti.Setup(i => i.GetTagToken(HttpTags.HttpVersion)) + .Returns(httpVersionToken); + + var lc = HttpMockProvider.GetLatencyContext(); + var mediator = new HttpLatencyMediator(lcti.Object); + + // Act + mediator.RecordEnd(lc.Object); + + // Assert + lc.Verify(c => c.SetTag( + It.Is(t => t.Name == HttpTags.HttpVersion), + It.IsAny()), + Times.Never); + } +} +#endif \ No newline at end of file diff --git a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Latency/Internal/HttpLatencyTelemetryHandlerTest.cs b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Latency/Internal/HttpLatencyTelemetryHandlerTest.cs index 2f787284b26..e70f8135d72 100644 --- a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Latency/Internal/HttpLatencyTelemetryHandlerTest.cs +++ b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Latency/Internal/HttpLatencyTelemetryHandlerTest.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; using System.Linq; using System.Net.Http; using System.Threading; @@ -32,8 +31,9 @@ public void HttpLatencyTelemetryHandler_InvokesTokenIssuer() var lcti = HttpMockProvider.GetTokenIssuer(); var lcti2 = HttpMockProvider.GetTokenIssuer(); + var mediator = new HttpLatencyMediator(lcti2.Object); using var listener = HttpMockProvider.GetListener(context, lcti.Object); - using var handler = new HttpLatencyTelemetryHandler(listener, lcti2.Object, lcp.Object, hop.Object, sop.Object); + using var handler = new HttpLatencyTelemetryHandler(listener, lcti2.Object, lcp.Object, hop.Object, sop.Object, mediator); lcti2.Verify(a => a.GetCheckpointToken(It.Is(s => !HttpCheckpoints.Checkpoints.Contains(s))), Times.Never); lcti2.Verify(a => a.GetCheckpointToken(It.Is(s => HttpCheckpoints.Checkpoints.Contains(s)))); @@ -53,6 +53,7 @@ public async Task HttpLatencyTelemetryHandler_SetsLatencyContext() var lcti = HttpMockProvider.GetTokenIssuer(); var lcti2 = HttpMockProvider.GetTokenIssuer(); + var mediator = new HttpLatencyMediator(lcti2.Object); using var listener = HttpMockProvider.GetListener(context, lcti.Object); using var req = new HttpRequestMessage { @@ -71,12 +72,12 @@ public async Task HttpLatencyTelemetryHandler_SetsLatencyContext() Assert.True(req.Headers.Contains(TelemetryConstants.ClientApplicationNameHeader)); }).Returns(Task.FromResult(resp.Object)); - using var handler = new HttpLatencyTelemetryHandler(listener, lcti2.Object, lcp.Object, hop.Object, sop.Object) + using var handler = new HttpLatencyTelemetryHandler(listener, lcti2.Object, lcp.Object, hop.Object, sop.Object, mediator) { InnerHandler = mockHandler.Object }; - using var client = new System.Net.Http.HttpClient(handler); + using var client = new HttpClient(handler); await client.SendAsync(req, It.IsAny()); Assert.Null(context.Get()); } @@ -93,8 +94,9 @@ public void HttpLatencyTelemetryHandler_IfDetailsDisabled_DoesNotEnableListener( hop.Setup(a => a.Value).Returns(new HttpClientLatencyTelemetryOptions { EnableDetailedLatencyBreakdown = false }); var lcti = HttpMockProvider.GetTokenIssuer(); + var mediator = new HttpLatencyMediator(lcti.Object); using var listener = HttpMockProvider.GetListener(context, lcti.Object); - using var handler = new HttpLatencyTelemetryHandler(listener, lcti.Object, lcp.Object, hop.Object, sop.Object); + using var handler = new HttpLatencyTelemetryHandler(listener, lcti.Object, lcp.Object, hop.Object, sop.Object, mediator); Assert.False(listener.Enabled); } } diff --git a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Latency/Internal/HttpMockProvider.cs b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Latency/Internal/HttpMockProvider.cs index a9150df371b..68d758df26b 100644 --- a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Latency/Internal/HttpMockProvider.cs +++ b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Latency/Internal/HttpMockProvider.cs @@ -40,7 +40,7 @@ public static Mock GetLatencyContext() return lc; } - public class MockEventSource : EventSource + public class MockEventSource() : EventSource(throwOnEventWriteErrors: true) { public int OnEventInvoked; diff --git a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Latency/Internal/HttpRequestLatencyListenerTest.cs b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Latency/Internal/HttpRequestLatencyListenerTest.cs index 3675bc3901a..3f66b7ea506 100644 --- a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Latency/Internal/HttpRequestLatencyListenerTest.cs +++ b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Latency/Internal/HttpRequestLatencyListenerTest.cs @@ -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.Diagnostics.Tracing; using System.Linq; using Microsoft.Extensions.Diagnostics.Latency; using Microsoft.Extensions.Http.Latency.Internal; @@ -16,10 +17,9 @@ public void HttpClientLatencyContext_Set_BasicFunction() { var lc = HttpMockProvider.GetLatencyContext(); var context = new HttpClientLatencyContext(); + Assert.Null(context.Get()); context.Set(lc.Object); Assert.Equal(context.Get(), lc.Object); - context.Unset(); - Assert.Null(context.Get()); } [Fact] @@ -85,8 +85,16 @@ public void HttpRequestLatencyListener_OnEventSourceCreated_NonHttpSources() listener.OnEventSourceCreated("test", es); Assert.Equal(0, es.OnEventInvoked); Assert.False(es.IsEnabled()); + + using var dummyListener = new DummyListener(); + dummyListener.EnableEvents(es, EventLevel.LogAlways); + + // EventSource seems to send the event to all listeners, even those that didn't enable the EventSource at all! + es.Write("Dummy"); } + private sealed class DummyListener : EventListener; + [Fact] public void HttpRequestLatencyListener_OnEventSourceCreated_HttpSources() { @@ -115,28 +123,6 @@ public void HttpRequestLatencyListener_OnEventSourceCreated_HttpSources() Assert.True(esNameRes.IsEnabled()); } - [Fact] - public void HttpRequestLatencyListener_OnEventSourceCreated_Twice() - { - var lcti = HttpMockProvider.GetTokenIssuer(); - var lc = HttpMockProvider.GetLatencyContext(); - var context = new HttpClientLatencyContext(); - context.Set(lc.Object); - - using var listener = HttpMockProvider.GetListener(context, lcti.Object); - Assert.NotNull(listener); - listener.Enable(); - - using var esSockets = new HttpMockProvider.SockeyMockEventSource(); - listener.OnEventSourceCreated("System.Net.Sockets", esSockets); - Assert.Equal(1, esSockets.OnEventInvoked); - Assert.True(esSockets.IsEnabled()); - - listener.OnEventSourceCreated("System.Net.Sockets", esSockets); - Assert.Equal(1, esSockets.OnEventInvoked); - Assert.True(esSockets.IsEnabled()); - } - [Fact] public void HttpRequestLatencyListener_OnEventWritten_DoesNotAddCheckpoints_NonHttp() { @@ -146,15 +132,18 @@ public void HttpRequestLatencyListener_OnEventWritten_DoesNotAddCheckpoints_NonH context.Set(lc.Object); using var listener = HttpMockProvider.GetListener(context, lcti.Object); + listener.Enable(); var events = new[] { "ConnectionEstablished", "RequestLeftQueue", "ResolutionStop", "ConnectStart", "New" }; + using var es = new HttpMockProvider.MockEventSource(); + listener.OnEventSourceCreated("System.Net", es); for (int i = 0; i < events.Length; i++) { - listener.OnEventWritten("System.Net", events[i]); + es.Write(events[i]); } lc.Verify(a => a.AddCheckpoint(It.IsAny()), Times.Never); @@ -211,4 +200,19 @@ public void HttpRequestLatencyListener_OnEventWritten_AddsCheckpoints_Http() lc.Verify(a => a.AddCheckpoint(It.IsAny()), Times.Exactly(numHttpEvents + numSocketEvents + numDnsEvents)); } + + [Fact] + public void HttpRequestLatencyListener_OnEventWritten_DoesNotAddCheckpoints_UnknownEventSource() + { + var lcti = HttpMockProvider.GetTokenIssuer(); + var lc = HttpMockProvider.GetLatencyContext(); + var context = new HttpClientLatencyContext(); + context.Set(lc.Object); + + using var listener = HttpMockProvider.GetListener(context, lcti.Object); + + listener.OnEventWritten("System.Runtime", "EventCounters"); + + lc.Verify(a => a.AddCheckpoint(It.IsAny()), Times.Never); + } } diff --git a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/AcceptanceTests.cs b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/AcceptanceTests.cs index 3143aab9185..ec8f21c06bc 100644 --- a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/AcceptanceTests.cs +++ b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/AcceptanceTests.cs @@ -385,14 +385,14 @@ public async Task AddHttpClientLogging_StructuredPathLogging_RedactsSensitivePar if (parameterRedactionMode == HttpRouteParameterRedactionMode.None) { loggedPath.Should().Be(httpRequestMessage.RequestUri.AbsolutePath); - state.Should().HaveCount(5); + state.Should().HaveCount(6); } else { loggedPath.Should().Be(RequestRoute); state.Should().ContainSingle(kvp => kvp.Key == "userId").Which.Value.Should().Be(expectedUserId); state.Should().ContainSingle(kvp => kvp.Key == "unitId").Which.Value.Should().Be(expectedUnitId); - state.Should().HaveCount(7); + state.Should().HaveCount(8); } } diff --git a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpClientLoggerTest.cs b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpClientLoggerTest.cs index 84ed98263c1..f57fb23b8d7 100644 --- a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpClientLoggerTest.cs +++ b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpClientLoggerTest.cs @@ -252,6 +252,7 @@ public async Task HttpLoggingHandler_AllOptions_LogsOutgoingRequest() logRecordState.Contains(testSharedResponseHeaderKey, expectedLogRecord.ResponseHeaders[1].Value); logRecordState.Contains(testSharedRequestHeaderKey, expectedLogRecord.RequestHeaders[1].Value); logRecordState.Contains(testEnricher.KvpRequest.Key, expectedLogRecord.GetEnrichmentProperty(testEnricher.KvpRequest.Key)); + EnsureLogRecordContainsOriginalFormat(logRecord); } [Fact] @@ -344,6 +345,7 @@ public async Task HttpLoggingHandler_AllOptionsWithLogRequestStart_LogsOutgoingR logRecordRequest.NotContains(HttpClientLoggingTagNames.StatusCode); logRecordRequest.Contains(TestExpectedRequestHeaderKey, expectedLogRecord.RequestHeaders.FirstOrDefault().Value); logRecordRequest.NotContains(testEnricher.KvpRequest.Key); + EnsureLogRecordContainsOriginalFormat(logRecords[0]); var logRecordFull = logRecords[1].GetStructuredState(); logRecordFull.Contains(HttpClientLoggingTagNames.Host, expectedLogRecord.Host); @@ -357,6 +359,7 @@ public async Task HttpLoggingHandler_AllOptionsWithLogRequestStart_LogsOutgoingR logRecordFull.Contains(TestExpectedResponseHeaderKey, expectedLogRecord.ResponseHeaders.FirstOrDefault().Value); logRecordFull.Contains(testEnricher.KvpRequest.Key, expectedLogRecord.GetEnrichmentProperty(testEnricher.KvpRequest.Key)); logRecordFull.Contains(testEnricher.KvpResponse.Key, expectedLogRecord.GetEnrichmentProperty(testEnricher.KvpResponse.Key)); + EnsureLogRecordContainsOriginalFormat(logRecords[1]); } [Fact] @@ -453,6 +456,7 @@ public async Task HttpLoggingHandler_AllOptionsSendAsyncFailed_LogsRequestInform logRecordState.NotContains(testEnricher.KvpResponse.Key); logRecordState.Contains(HttpClientLoggingTagNames.Duration, EnsureLogRecordDuration); Assert.DoesNotContain(logRecordState, kvp => kvp.Key.StartsWith(HttpClientLoggingTagNames.ResponseHeaderPrefix)); + EnsureLogRecordContainsOriginalFormat(logRecord); } [Fact(Skip = "Flaky test, see https://github.com/dotnet/extensions/issues/4530")] @@ -568,6 +572,7 @@ public async Task HttpLoggingHandler_ReadResponseThrows_LogsException() logRecordState.Contains(testEnricher.KvpResponse.Key, expectedLogRecord.GetEnrichmentProperty(testEnricher.KvpResponse.Key)); logRecordState.Contains(HttpClientLoggingTagNames.Duration, EnsureLogRecordDuration); Assert.DoesNotContain(logRecordState, kvp => kvp.Key.StartsWith(HttpClientLoggingTagNames.ResponseHeaderPrefix)); + EnsureLogRecordContainsOriginalFormat(logRecord); } [Fact] @@ -657,6 +662,7 @@ public async Task HttpLoggingHandler_AllOptionsTransferEncodingIsNotChunked_Logs logRecordState.Contains(TestExpectedRequestHeaderKey, expectedLogRecord.RequestHeaders.FirstOrDefault().Value); logRecordState.Contains(TestExpectedResponseHeaderKey, expectedLogRecord.ResponseHeaders.FirstOrDefault().Value); logRecordState.Contains(testEnricher.KvpRequest.Key, expectedLogRecord.GetEnrichmentProperty(testEnricher.KvpRequest.Key)); + EnsureLogRecordContainsOriginalFormat(logRecord); } [Fact] @@ -918,18 +924,20 @@ public async Task HttpLoggingHandler_AllOptionsTransferEncodingChunked_LogsOutgo await client.SendAsync(httpRequestMessage, It.IsAny()); var logRecords = fakeLogger.Collector.GetSnapshot(); - var logRecord = Assert.Single(logRecords).GetStructuredState(); - - logRecord.Contains(HttpClientLoggingTagNames.Host, expectedLogRecord.Host); - logRecord.Contains(HttpClientLoggingTagNames.Method, expectedLogRecord.Method.ToString()); - logRecord.Contains(HttpClientLoggingTagNames.Path, TelemetryConstants.Redacted); - logRecord.Contains(HttpClientLoggingTagNames.Duration, EnsureLogRecordDuration); - logRecord.Contains(HttpClientLoggingTagNames.StatusCode, expectedLogRecord.StatusCode.Value.ToString(CultureInfo.InvariantCulture)); - logRecord.Contains(HttpClientLoggingTagNames.RequestBody, expectedLogRecord.RequestBody); - logRecord.Contains(HttpClientLoggingTagNames.ResponseBody, expectedLogRecord.ResponseBody); - logRecord.Contains(TestExpectedRequestHeaderKey, expectedLogRecord.RequestHeaders.FirstOrDefault().Value); - logRecord.Contains(TestExpectedResponseHeaderKey, expectedLogRecord.ResponseHeaders.FirstOrDefault().Value); - logRecord.Contains(testEnricher.KvpRequest.Key, expectedLogRecord.GetEnrichmentProperty(testEnricher.KvpRequest.Key)); + var logRecord = Assert.Single(logRecords); + var logRecordState = logRecord.GetStructuredState(); + + logRecordState.Contains(HttpClientLoggingTagNames.Host, expectedLogRecord.Host); + logRecordState.Contains(HttpClientLoggingTagNames.Method, expectedLogRecord.Method.ToString()); + logRecordState.Contains(HttpClientLoggingTagNames.Path, TelemetryConstants.Redacted); + logRecordState.Contains(HttpClientLoggingTagNames.Duration, EnsureLogRecordDuration); + logRecordState.Contains(HttpClientLoggingTagNames.StatusCode, expectedLogRecord.StatusCode.Value.ToString(CultureInfo.InvariantCulture)); + logRecordState.Contains(HttpClientLoggingTagNames.RequestBody, expectedLogRecord.RequestBody); + logRecordState.Contains(HttpClientLoggingTagNames.ResponseBody, expectedLogRecord.ResponseBody); + logRecordState.Contains(TestExpectedRequestHeaderKey, expectedLogRecord.RequestHeaders.FirstOrDefault().Value); + logRecordState.Contains(TestExpectedResponseHeaderKey, expectedLogRecord.ResponseHeaders.FirstOrDefault().Value); + logRecordState.Contains(testEnricher.KvpRequest.Key, expectedLogRecord.GetEnrichmentProperty(testEnricher.KvpRequest.Key)); + EnsureLogRecordContainsOriginalFormat(logRecord); } [Theory] @@ -1024,4 +1032,11 @@ private static void EnsureLogRecordDuration(string? actualValue) private static IOutgoingRequestContext RequestMetadataContext => new Mock().Object; + + private static void EnsureLogRecordContainsOriginalFormat(FakeLogRecord logRecord) + { + var pair = logRecord.StructuredState!.Last(); + Assert.Equal("{OriginalFormat}", pair.Key); + Assert.Equal("{http.request.method} {server.address}/{url.path}", pair.Value); + } } diff --git a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpRequestReaderTest.cs b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpRequestReaderTest.cs index ba781c5d101..c2b5295d707 100644 --- a/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpRequestReaderTest.cs +++ b/test/Libraries/Microsoft.Extensions.Http.Diagnostics.Tests/Logging/HttpRequestReaderTest.cs @@ -18,6 +18,7 @@ using Microsoft.Extensions.Http.Diagnostics; using Microsoft.Extensions.Http.Logging.Internal; using Microsoft.Extensions.Http.Logging.Test.Internal; +using Microsoft.Extensions.Logging.Testing; using Microsoft.Extensions.Telemetry.Internal; using Moq; using Xunit; @@ -54,6 +55,8 @@ public async Task ReadAsync_AllData_ReturnsLogRecord() ResponseHeaders = [new("Header2", Redacted), new("Header3", Redacted)], RequestBody = requestContent, ResponseBody = responseContent, + + QueryString = string.Empty, }; var options = new LoggingOptions @@ -80,7 +83,7 @@ public async Task ReadAsync_AllData_ReturnsLogRecord() using var httpRequestMessage = new HttpRequestMessage { Method = HttpMethod.Post, - RequestUri = new Uri("http://default-uri.com/foo"), + RequestUri = new Uri("https://default-uri.com/foo"), Content = new StringContent(requestContent, Encoding.UTF8) }; @@ -120,6 +123,8 @@ public async Task ReadAsync_NoHost_ReturnsLogRecordWithoutHost() StatusCode = 200, RequestBody = requestContent, ResponseBody = responseContent, + + QueryString = string.Empty }; var options = new LoggingOptions @@ -180,6 +185,8 @@ public async Task ReadAsync_AllDataWithRequestMetadataSet_ReturnsLogRecord() ResponseHeaders = [new("Header2", Redacted)], RequestBody = requestContent, ResponseBody = responseContent, + + QueryString = string.Empty }; var opts = new LoggingOptions @@ -206,7 +213,7 @@ public async Task ReadAsync_AllDataWithRequestMetadataSet_ReturnsLogRecord() using var httpRequestMessage = new HttpRequestMessage { Method = HttpMethod.Post, - RequestUri = new Uri("http://default-uri.com/foo/bar/123"), + RequestUri = new Uri("https://default-uri.com/foo/bar/123"), Content = new StringContent(requestContent, Encoding.UTF8), }; @@ -251,7 +258,8 @@ public async Task ReadAsync_FormatRequestPathDisabled_ReturnsLogRecordWithRoute( ResponseHeaders = [new("Header2", Redacted)], RequestBody = requestContent, ResponseBody = responseContent, - PathParametersCount = 1 + PathParametersCount = 1, + QueryString = string.Empty }; var opts = new LoggingOptions @@ -281,7 +289,7 @@ public async Task ReadAsync_FormatRequestPathDisabled_ReturnsLogRecordWithRoute( using var httpRequestMessage = new HttpRequestMessage { Method = HttpMethod.Post, - RequestUri = new Uri($"http://{RequestedHost}/foo/bar/123"), + RequestUri = new Uri($"https://{RequestedHost}/foo/bar/123"), Content = new StringContent(requestContent, Encoding.UTF8), }; @@ -325,6 +333,7 @@ public async Task ReadAsync_RouteParameterRedactionModeNone_ReturnsLogRecordWith Path = "/foo/bar/123", RequestHeaders = [new("Header1", Redacted)], RequestBody = requestContent, + QueryString = string.Empty }; var opts = new LoggingOptions @@ -353,7 +362,7 @@ public async Task ReadAsync_RouteParameterRedactionModeNone_ReturnsLogRecordWith using var httpRequestMessage = new HttpRequestMessage { Method = HttpMethod.Post, - RequestUri = new Uri("http://default-uri.com/foo/bar/123"), + RequestUri = new Uri("https://default-uri.com/foo/bar/123"), Content = new StringContent(requestContent, Encoding.UTF8), }; @@ -385,6 +394,8 @@ public async Task ReadAsync_RequestMetadataRequestNameSetAndRouteMissing_Returns ResponseHeaders = [new("Header2", Redacted)], RequestBody = requestContent, ResponseBody = responseContent, + + QueryString = string.Empty }; var opts = new LoggingOptions @@ -411,7 +422,7 @@ public async Task ReadAsync_RequestMetadataRequestNameSetAndRouteMissing_Returns using var httpRequestMessage = new HttpRequestMessage { Method = HttpMethod.Post, - RequestUri = new Uri("http://default-uri.com/foo/bar/123"), + RequestUri = new Uri("https://default-uri.com/foo/bar/123"), Content = new StringContent(requestContent, Encoding.UTF8), }; @@ -456,6 +467,8 @@ public async Task ReadAsync_NoMetadataUsesRedactedString_ReturnsLogRecord() ResponseHeaders = [new("Header2", Redacted)], RequestBody = requestContent, ResponseBody = responseContent, + + QueryString = string.Empty }; var opts = new LoggingOptions @@ -482,7 +495,7 @@ public async Task ReadAsync_NoMetadataUsesRedactedString_ReturnsLogRecord() using var httpRequestMessage = new HttpRequestMessage { Method = HttpMethod.Post, - RequestUri = new Uri("http://default-uri.com/foo/bar/123"), + RequestUri = new Uri("https://default-uri.com/foo/bar/123"), Content = new StringContent(requestContent, Encoding.UTF8), }; @@ -523,6 +536,8 @@ public async Task ReadAsync_MetadataWithoutRequestRouteOrNameUsesConstants_Retur ResponseHeaders = [new("Header2", Redacted)], RequestBody = requestContent, ResponseBody = responseContent, + + QueryString = string.Empty }; var opts = new LoggingOptions @@ -549,7 +564,7 @@ public async Task ReadAsync_MetadataWithoutRequestRouteOrNameUsesConstants_Retur using var httpRequestMessage = new HttpRequestMessage { Method = HttpMethod.Post, - RequestUri = new Uri("http://default-uri.com/foo/bar/123"), + RequestUri = new Uri("https://default-uri.com/foo/bar/123"), Content = new StringContent(requestContent, Encoding.UTF8), }; @@ -573,6 +588,301 @@ public async Task ReadAsync_MetadataWithoutRequestRouteOrNameUsesConstants_Retur actualRecord.Should().BeEquivalentTo(expectedRecord); } + [Fact] + public async Task ReadAsync_SetsQueryParameters_WhenClassificationPresent() + { + var requestContent = _fixture.Create(); + var queryParamName = "userId"; + var queryParamValue = "12345"; + + var options = new LoggingOptions + { + RequestQueryParametersDataClasses = new Dictionary + { + { queryParamName, FakeTaxonomy.PrivateData } + }, + BodySizeLimit = 32000, + BodyReadTimeout = TimeSpan.FromMinutes(5), + LogBody = true + }; + + var mockHeadersRedactor = new Mock(); + mockHeadersRedactor + .Setup(r => r.Redact(It.IsAny(), It.IsAny())) + .Returns(Redacted); + + var headersReader = new HttpHeadersReader(options.ToOptionsMonitor(), mockHeadersRedactor.Object); + await using var serviceProvider = GetServiceProvider(headersReader); + + var reader = new HttpRequestReader( + serviceProvider, + options.ToOptionsMonitor(), + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService(), + RequestMetadataContext); + + var uri = new Uri($"https://{RequestedHost}/api/resource?{queryParamName}={queryParamValue}"); + + using var httpRequestMessage = new HttpRequestMessage + { + Method = HttpMethod.Get, + RequestUri = uri, + Content = new StringContent(requestContent, Encoding.UTF8, "text/plain") + }; + + var logRecord = new LogRecord(); + var requestHeadersBuffer = new List>(); + await reader.ReadRequestAsync(logRecord, httpRequestMessage, requestHeadersBuffer, CancellationToken.None); + logRecord.QueryString.Should().NotBeNullOrEmpty(); + logRecord.QueryString.Should().Contain("userId=REDACTED"); + } + + [Fact] + public async Task ReadAsync_SkipsQueryString_WhenClassificationEmpty() + { + var options = new LoggingOptions + { + RequestQueryParametersDataClasses = new Dictionary() // No data classification + }; + + var mockHeadersRedactor = new Mock(); + var headersReader = new HttpHeadersReader(options.ToOptionsMonitor(), mockHeadersRedactor.Object); + using var serviceProvider = GetServiceProvider(headersReader); + + var reader = new HttpRequestReader( + serviceProvider, + options.ToOptionsMonitor(), + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService(), + RequestMetadataContext); + + var uri = new Uri($"https://{RequestedHost}/api/resource?userId=12345"); + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, uri); + + var logRecord = new LogRecord(); + await reader.ReadRequestAsync(logRecord, httpRequestMessage, new List>(), CancellationToken.None); + logRecord.QueryString.Should().BeNullOrEmpty(); + } + + [Fact] + public async Task ReadAsync_SetsEmptyQueryParameters_WhenNoMatchingClassification() + { + var options = new LoggingOptions + { + RequestQueryParametersDataClasses = new Dictionary + { + { "otherParam", FakeTaxonomy.PrivateData } + } + }; + + var mockHeadersRedactor = new Mock(); + var headersReader = new HttpHeadersReader(options.ToOptionsMonitor(), mockHeadersRedactor.Object); + using var serviceProvider = GetServiceProvider(headersReader); + + var reader = new HttpRequestReader( + serviceProvider, + options.ToOptionsMonitor(), + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService(), + RequestMetadataContext); + + var uri = new Uri($"https://{RequestedHost}/api/resource?userId=12345"); + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, uri); + + var logRecord = new LogRecord(); + await reader.ReadRequestAsync(logRecord, httpRequestMessage, new List>(), CancellationToken.None); + + logRecord.QueryString.Should().BeNullOrEmpty(); + } + + [Fact] + public async Task ReadAsync_SetsMultipleQueryParameters_WhenMultipleClassifications() + { + var options = new LoggingOptions + { + RequestQueryParametersDataClasses = new Dictionary + { + { "userId", FakeTaxonomy.PrivateData }, + { "token", FakeTaxonomy.PrivateData } + } + }; + + var mockHeadersRedactor = new Mock(); + mockHeadersRedactor + .Setup(r => r.Redact(It.IsAny(), It.IsAny())) + .Returns(Redacted); + + var headersReader = new HttpHeadersReader(options.ToOptionsMonitor(), mockHeadersRedactor.Object); + using var serviceProvider = GetServiceProvider(headersReader); + + var reader = new HttpRequestReader( + serviceProvider, + options.ToOptionsMonitor(), + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService(), + RequestMetadataContext); + + var uri = new Uri($"https://{RequestedHost}/api/resource?userId=12345&token=abc&other=not_logged"); + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, uri); + + var logRecord = new LogRecord(); + await reader.ReadRequestAsync(logRecord, httpRequestMessage, new List>(), CancellationToken.None); + logRecord.QueryString.Should().NotBeNullOrEmpty(); + logRecord.QueryString.Should().Be("userId=REDACTED&token=REDACTED"); + } + + [Fact] + public async Task LogRequestStartAsync_LogsQueryParameters_TagArray() + { + // Arrange + var options = new LoggingOptions + { + RequestQueryParametersDataClasses = new Dictionary + { + { "userId", FakeTaxonomy.PrivateData } + }, + LogRequestStart = true + }; + + var mockHeadersRedactor = new Mock(); + mockHeadersRedactor + .Setup(r => r.Redact(It.IsAny(), It.IsAny())) + .Returns(Redacted); + + var headersReader = new HttpHeadersReader(options.ToOptionsMonitor(), mockHeadersRedactor.Object); + + var fakeLogger = new FakeLogger( + new FakeLogCollector( + Options.Options.Create( + new FakeLogCollectorOptions()))); + using var serviceProvider = GetServiceProvider(headersReader); + var enrichers = Enumerable.Empty(); + var httpRequestReader = new HttpRequestReader( + serviceProvider, + options.ToOptionsMonitor(), + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService(), + RequestMetadataContext); + + var clientLogger = new HttpClientLogger( + fakeLogger, + httpRequestReader, + enrichers, + options); + + var uri = new Uri($"https://{RequestedHost}/api/resource?userId=12345"); + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, uri); + + // Act + await clientLogger.LogRequestStartAsync(httpRequestMessage); + + // Assert + var logRecord = fakeLogger.Collector.GetSnapshot().First(); + var state = logRecord.GetStructuredState(); + + Assert.Contains( + state, + tag => tag.Key == "url.query" && (tag.Value!).Contains("userId=REDACTED")); + } + + [Fact] + public async Task ReadAsync_DoesntSetQueryString_WhenQueryValueEmpty() + { + var options = new LoggingOptions + { + RequestQueryParametersDataClasses = new Dictionary + { + { "userId", FakeTaxonomy.PrivateData } + } + }; + + var mockHeadersRedactor = new Mock(); + mockHeadersRedactor + .Setup(r => r.Redact(It.IsAny>(), It.IsAny())) + .Returns(Redacted); + + var headersReader = new HttpHeadersReader(options.ToOptionsMonitor(), mockHeadersRedactor.Object); + using var serviceProvider = GetServiceProvider(headersReader); + + var reader = new HttpRequestReader( + serviceProvider, + options.ToOptionsMonitor(), + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService(), + RequestMetadataContext); + + var uri = new Uri($"https://{RequestedHost}/api/resource?userId="); + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, uri); + + var logRecord = new LogRecord(); + await reader.ReadRequestAsync(logRecord, httpRequestMessage, new List>(), CancellationToken.None); + logRecord.QueryString.Should().BeNullOrEmpty(); + } + + [Fact] + public async Task ReadAsync_RedactsPathAndQueryParameters() + { + // Arrange + var requestContent = _fixture.Create(); + var queryParamName = "userId"; + var queryParamValue = "12345"; + var pathParamName = "orderId"; + var pathParamValue = "789"; + + var options = new LoggingOptions + { + RequestQueryParametersDataClasses = new Dictionary + { + { queryParamName, FakeTaxonomy.PrivateData } + }, + LogBody = true, + RequestPathLoggingMode = OutgoingPathLoggingMode.Formatted + }; + options.RouteParameterDataClasses.Add("routeId", FakeTaxonomy.PrivateData); + + var mockHeadersRedactor = new Mock(); + mockHeadersRedactor + .Setup(r => r.Redact(It.IsAny(), It.IsAny())) + .Returns(Redacted); + + var headersReader = new HttpHeadersReader(options.ToOptionsMonitor(), mockHeadersRedactor.Object); + using var serviceProvider = GetServiceProvider(headersReader); + + var reader = new HttpRequestReader( + serviceProvider, + options.ToOptionsMonitor(), + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService(), + RequestMetadataContext); + + // The route template includes a path parameter + var routeTemplate = $"/api/orders/{{{pathParamName}}}/details"; + var uri = new Uri($"https://{RequestedHost}/api/orders/{pathParamValue}/details?{queryParamName}={queryParamValue}"); + + using var httpRequestMessage = new HttpRequestMessage(); + httpRequestMessage.Method = HttpMethod.Get; + httpRequestMessage.RequestUri = uri; + httpRequestMessage.Content = new StringContent(requestContent, Encoding.UTF8, "text/plain"); + + // Attach request metadata for the route template + httpRequestMessage.SetRequestMetadata(new RequestMetadata + { + RequestRoute = routeTemplate + }); + + var logRecord = new LogRecord(); + var requestHeadersBuffer = new List>(); + await reader.ReadRequestAsync(logRecord, httpRequestMessage, requestHeadersBuffer, CancellationToken.None); + + // Assert: path parameter is redacted in the path + logRecord.Path.Should().NotContain(pathParamValue); + logRecord.Path.Should().Contain(Redacted); + + logRecord.QueryString.Should().NotBeNullOrEmpty(); + logRecord.QueryString.Should().Contain($"{queryParamName}={Redacted}"); + logRecord.QueryString.Should().NotContain(queryParamValue); + } + private static ServiceProvider GetServiceProvider( HttpHeadersReader headersReader, string? serviceKey = null, diff --git a/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Polly/HttpRetryStrategyOptionsExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Polly/HttpRetryStrategyOptionsExtensionsTests.cs index 4d43c020d1f..996731b08bf 100644 --- a/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Polly/HttpRetryStrategyOptionsExtensionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Polly/HttpRetryStrategyOptionsExtensionsTests.cs @@ -65,12 +65,16 @@ public async Task DisableFor_RespectsOriginalShouldHandlePredicate() } [Fact] - public async Task DisableFor_ResponseMessageIsNull_DoesNotDisableRetries() + public async Task DisableFor_ResponseMessageIsNull_RetrievesRequestMessageFromContext() { var options = new HttpRetryStrategyOptions { ShouldHandle = _ => PredicateResult.True() }; options.DisableFor(HttpMethod.Post); - Assert.True(await options.ShouldHandle(CreatePredicateArguments(null))); + using var request = new HttpRequestMessage { Method = HttpMethod.Post }; + var context = ResilienceContextPool.Shared.Get(); + context.SetRequestMessage(request); + + Assert.False(await options.ShouldHandle(CreatePredicateArguments(null, context))); } [Fact] @@ -80,8 +84,10 @@ public async Task DisableFor_RequestMessageIsNull_DoesNotDisableRetries() options.DisableFor(HttpMethod.Post); using var response = new HttpResponseMessage { RequestMessage = null }; + var context = ResilienceContextPool.Shared.Get(); + context.SetRequestMessage(null); - Assert.True(await options.ShouldHandle(CreatePredicateArguments(response))); + Assert.True(await options.ShouldHandle(CreatePredicateArguments(response, context))); } [Theory] @@ -105,10 +111,10 @@ public async Task DisableForUnsafeHttpMethods_PositiveScenario(string httpMethod Assert.Equal(shouldHandle, await options.ShouldHandle(CreatePredicateArguments(response))); } - private static RetryPredicateArguments CreatePredicateArguments(HttpResponseMessage? response) + private static RetryPredicateArguments CreatePredicateArguments(HttpResponseMessage? response, ResilienceContext? context = null) { return new RetryPredicateArguments( - ResilienceContextPool.Shared.Get(), + context ?? ResilienceContextPool.Shared.Get(), Outcome.FromResult(response), attemptNumber: 1); } diff --git a/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Resilience/GrpcResilienceTests.cs b/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Resilience/GrpcResilienceTests.cs index a1bb703ae5c..93d98707cc5 100644 --- a/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Resilience/GrpcResilienceTests.cs +++ b/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Resilience/GrpcResilienceTests.cs @@ -8,11 +8,11 @@ using System.Threading.Tasks; using FluentAssertions; using Grpc.Core; -using Microsoft.AspNetCore; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Http.Resilience.Test.Grpc; using Polly; using Xunit; @@ -21,21 +21,26 @@ namespace Microsoft.Extensions.Http.Resilience.Test.Resilience; public class GrpcResilienceTests { - private IWebHost _host; + private IHost _host; private HttpMessageHandler _handler; public GrpcResilienceTests() { - _host = WebHost - .CreateDefaultBuilder() - .ConfigureServices(services => services.AddGrpc()) - .Configure(builder => + _host = new HostBuilder() + .ConfigureWebHost(webHostBuilder => { - builder.UseRouting(); - builder.UseEndpoints(endpoints => endpoints.MapGrpcService()); + webHostBuilder + .UseTestServer() + .ConfigureServices(services => services.AddGrpc()) + .Configure(builder => + { + builder.UseRouting(); + builder.UseEndpoints(endpoints => endpoints.MapGrpcService()); + }); }) - .UseTestServer() - .Start(); + .Build(); + + _host.Start(); _handler = _host.GetTestServer().CreateHandler(); } diff --git a/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Resilience/HttpStandardResilienceOptionsCustomValidatorTests.cs b/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Resilience/HttpStandardResilienceOptionsCustomValidatorTests.cs index f7e164339f0..b5e50b9c273 100644 --- a/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Resilience/HttpStandardResilienceOptionsCustomValidatorTests.cs +++ b/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Resilience/HttpStandardResilienceOptionsCustomValidatorTests.cs @@ -16,6 +16,7 @@ using Xunit; namespace Microsoft.Extensions.Http.Resilience.Test.Resilience; + public class HttpStandardResilienceOptionsCustomValidatorTests { [Fact] diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/.gitignore b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/.gitignore new file mode 100644 index 00000000000..0151cc4e360 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/.gitignore @@ -0,0 +1,2 @@ +# corpuses generated by the fuzzing engine +corpuses/** \ No newline at end of file diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/Fuzzers/DnsResponseFuzzer.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/Fuzzers/DnsResponseFuzzer.cs new file mode 100644 index 00000000000..2e4658e3ba2 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/Fuzzers/DnsResponseFuzzer.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.Diagnostics; +using System.Net; +using System.Net.Sockets; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing; + +internal sealed class DnsResponseFuzzer : IFuzzer +{ + DnsResolver? _resolver; + byte[]? _buffer; + int _length; + + public void FuzzTarget(ReadOnlySpan data) + { + // lazy init + if (_resolver == null) + { + _buffer = new byte[4096]; + _resolver = new DnsResolver(new DnsResolverOptions + { + Servers = [new IPEndPoint(IPAddress.Loopback, 53)], + Timeout = TimeSpan.FromSeconds(5), + MaxAttempts = 1, + _transportOverride = (buffer, length) => + { + // the first two bytes are the random transaction ID, so we keep that + // and use the fuzzing payload for the rest of the DNS response + _buffer.AsSpan(0, Math.Min(_length, buffer.Length - 2)).CopyTo(buffer.Span.Slice(2)); + return _length + 2; + } + }); + } + + data.CopyTo(_buffer!); + _length = data.Length; + + // the _transportOverride makes the execution synchronous + ValueTask task = _resolver!.ResolveIPAddressesAsync("www.example.com", AddressFamily.InterNetwork, CancellationToken.None); + Debug.Assert(task.IsCompleted, "Task should be completed synchronously"); + task.GetAwaiter().GetResult(); + } +} diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/Fuzzers/EncodedDomainNameFuzzer.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/Fuzzers/EncodedDomainNameFuzzer.cs new file mode 100644 index 00000000000..72f84b3c959 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/Fuzzers/EncodedDomainNameFuzzer.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. + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing; + +internal sealed class EncodedDomainNameFuzzer : IFuzzer +{ + public void FuzzTarget(ReadOnlySpan data) + { + byte[] buffer = ArrayPool.Shared.Rent(data.Length); + try + { + data.CopyTo(buffer); + + // attempt to read at any offset to really stress the parser + for (int i = 0; i < data.Length; i++) + { + if (!DnsPrimitives.TryReadQName(buffer.AsMemory(0, data.Length), i, out EncodedDomainName name, out _)) + { + continue; + } + + // the domain name should be readable + _ = name.ToString(); + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + + } +} \ No newline at end of file diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/Fuzzers/WriteDomainNameRoundTripFuzzer.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/Fuzzers/WriteDomainNameRoundTripFuzzer.cs new file mode 100644 index 00000000000..f657245a842 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/Fuzzers/WriteDomainNameRoundTripFuzzer.cs @@ -0,0 +1,48 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing; + +internal sealed class WriteDomainNameRoundTripFuzzer : IFuzzer +{ + private static readonly System.Globalization.IdnMapping s_idnMapping = new(); + public void FuzzTarget(ReadOnlySpan data) + { + // first byte is the offset of the domain name, rest is the actual + // (simulated) DNS message payload + + byte[] buffer = ArrayPool.Shared.Rent(data.Length * 2); + + try + { + string domainName = Encoding.UTF8.GetString(data); + if (!DnsPrimitives.TryWriteQName(buffer, domainName, out int written)) + { + return; + } + + if (!DnsPrimitives.TryReadQName(buffer.AsMemory(0, written), 0, out EncodedDomainName name, out int read)) + { + return; + } + + if (read != written) + { + throw new InvalidOperationException($"Read {read} bytes, but wrote {written} bytes"); + } + + string readName = name.ToString(); + + if (!string.Equals(s_idnMapping.GetAscii(domainName).TrimEnd('.'), readName, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"Domain name mismatch: {readName} != {domainName}"); + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } +} \ No newline at end of file diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/GlobalUsings.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/GlobalUsings.cs new file mode 100644 index 00000000000..2ff9d86b2ce --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/GlobalUsings.cs @@ -0,0 +1,5 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +global using System.Buffers; +global using Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; \ No newline at end of file diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/IFuzzer.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/IFuzzer.cs new file mode 100644 index 00000000000..4b4c8c99b4b --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/IFuzzer.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.ServiceDiscovery.Dns.Tests.Fuzzing; + +public interface IFuzzer +{ + string Name => GetType().Name; + void FuzzTarget(ReadOnlySpan data); +} \ No newline at end of file diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing.csproj b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing.csproj new file mode 100644 index 00000000000..7a1e69033f9 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing.csproj @@ -0,0 +1,21 @@ + + + + $(TestNetCoreTargetFrameworks) + enable + enable + Exe + Open + + $(NoWarn);IDE0040;IDE0061;IDE1006;S5034;SA1400;VSTHRD002 + + + + + + + + + + + diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/Program.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/Program.cs new file mode 100644 index 00000000000..22b1580d1ac --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/Program.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 SharpFuzz; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing; + +public static class Program +{ + public static void Main(string[] args) + { + IFuzzer[] fuzzers = typeof(Program).Assembly.GetTypes() + .Where(t => t.IsClass && !t.IsAbstract) + .Where(t => t.GetInterfaces().Contains(typeof(IFuzzer))) + .Select(t => (IFuzzer)Activator.CreateInstance(t)!) + .OrderBy(f => f.Name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + void PrintUsage() + { + Console.Error.WriteLine($""" + Usage: + DotnetFuzzing list + DotnetFuzzing [input file/directory] + // DotnetFuzzing prepare-onefuzz + + Available fuzzers: + {string.Join(Environment.NewLine, fuzzers.Select(f => $" {f.Name}"))} + """); + } + + if (args.Length == 0) + { + PrintUsage(); + return; + } + + string arg = args[0]; + IFuzzer? fuzzer = fuzzers.FirstOrDefault(f => string.Equals(f.Name, arg, StringComparison.OrdinalIgnoreCase)); + if (fuzzer == null) + { + Console.Error.WriteLine($"Unknown fuzzer: {arg}"); + PrintUsage(); + return; + } + + string? inputFiles = args.Length > 1 ? args[1] : null; + if (string.IsNullOrEmpty(inputFiles)) + { + // no input files, let the fuzzer generate + Fuzzer.LibFuzzer.Run(fuzzer.FuzzTarget); + return; + } + + string[] files = Directory.Exists(inputFiles) + ? Directory.GetFiles(inputFiles) + : [inputFiles]; + + foreach (string inputFile in files) + { + fuzzer.FuzzTarget(File.ReadAllBytes(inputFile)); + } + } +} \ No newline at end of file diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/DnsResponseFuzzer/ip-www.example.com b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/DnsResponseFuzzer/ip-www.example.com new file mode 100644 index 00000000000..bb40cd100c6 Binary files /dev/null and b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/DnsResponseFuzzer/ip-www.example.com differ diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/DnsResponseFuzzer/name-error b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/DnsResponseFuzzer/name-error new file mode 100644 index 00000000000..92a307a0f72 Binary files /dev/null and b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/DnsResponseFuzzer/name-error differ diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/DnsResponseFuzzer/name-error-2 b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/DnsResponseFuzzer/name-error-2 new file mode 100644 index 00000000000..5b37565190e Binary files /dev/null and b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/DnsResponseFuzzer/name-error-2 differ diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/DnsResponseFuzzer/no-data b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/DnsResponseFuzzer/no-data new file mode 100644 index 00000000000..23265fc7a8f Binary files /dev/null and b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/DnsResponseFuzzer/no-data differ diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/DnsResponseFuzzer/server-error b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/DnsResponseFuzzer/server-error new file mode 100644 index 00000000000..27f1054b9b1 Binary files /dev/null and b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/DnsResponseFuzzer/server-error differ diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/EncodedDomainNameFuzzer/ip-www.example.com b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/EncodedDomainNameFuzzer/ip-www.example.com new file mode 100644 index 00000000000..c227840c168 Binary files /dev/null and b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/EncodedDomainNameFuzzer/ip-www.example.com differ diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/WriteDomainNameRoundTripFuzzer/example b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/WriteDomainNameRoundTripFuzzer/example new file mode 100644 index 00000000000..8642ba7b94c --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/WriteDomainNameRoundTripFuzzer/example @@ -0,0 +1 @@ +www.example.com \ No newline at end of file diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/WriteDomainNameRoundTripFuzzer/nonascii b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/WriteDomainNameRoundTripFuzzer/nonascii new file mode 100644 index 00000000000..692a5a8aee0 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/WriteDomainNameRoundTripFuzzer/nonascii @@ -0,0 +1 @@ +www.řffwefw.com \ No newline at end of file diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/WriteDomainNameRoundTripFuzzer/toolong b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/WriteDomainNameRoundTripFuzzer/toolong new file mode 100644 index 00000000000..bb6d3a722e8 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/corpus-seed/WriteDomainNameRoundTripFuzzer/toolong @@ -0,0 +1 @@ +aa.efaw.ef.wef.ef.wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww.fafeww.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.fwefefefefwefwf.wzzefwefwefwefwfeewfwefwefw.ffff \ No newline at end of file diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/run.ps1 b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/run.ps1 new file mode 100644 index 00000000000..17b3d27055d --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing/run.ps1 @@ -0,0 +1,106 @@ +param( + # Name of the fuzzing target, see Fuzzers/*.cs files + [Parameter(Mandatory = $true, Position = 0)] + [ArgumentCompleter({ + param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) + $corpusSeedPath = Join-Path $PSScriptRoot "Fuzzers" + if (Test-Path $corpusSeedPath) { + Get-ChildItem -Path $corpusSeedPath -Filter "$wordToComplete*.cs" | ForEach-Object { $_.BaseName } + } + })] + [string] $Target, + + # Number of parallel jobs to run + [int] $Jobs, + + # Maximum length of the input + [int] $MaxLength = 512, + + # Ignore timeouts when running the fuzzer + [switch] $IgnoreTimeouts, + + # Skip the build of the project useful for reruning the fuzzer without recompiling + [switch] $NoBuild, + + # Path to the libfuzzer driver + [string] $LibFuzzer = "libfuzzer-dotnet-windows" +) + +$timeout = 30 +$SharpFuzz = "sharpfuzz" +$dict = $null + +$corpus = Join-Path $PSScriptRoot "corpuses" $Target +$null = New-Item -Path $corpus -ItemType Directory -Force + +$CorpusSeed = Join-Path $PSScriptRoot "corpus-seed" $Target + +if (Test-Path $CorpusSeed -ErrorAction SilentlyContinue) { + Write-Output "Copying corpus seed from $CorpusSeed to $corpus" + Get-ChildItem -Path $CorpusSeed | Copy-Item -Destination $corpus +} + +$project = Join-Path $PSScriptRoot "Microsoft.Extensions.ServiceDiscovery.Dns.Tests.Fuzzing.csproj" + +Set-StrictMode -Version Latest + +$outputDir = "bin" +$projectName = (Get-Item $project).BaseName +$projectDll = "$projectName.dll" +$executable = if ($IsWindows) { Join-Path $outputDir "$projectName.exe" } +else { Join-Path $outputDir "$projectName" } + +if (!$NoBuild) { + + if (Test-Path $outputDir) { + Remove-Item -Recurse -Force $outputDir + } + + dotnet publish $project -c release -o $outputDir + + $exclusions = @( + "dnlib.dll", + "SharpFuzz.dll", + "SharpFuzz.Common.dll", + $projectDll + ) + + $fuzzingTargets = @(Get-Item "$outputDir/Microsoft.Extensions.ServiceDiscovery.Dns.dll") + + if (($fuzzingTargets | Measure-Object).Count -eq 0) { + Write-Error "No fuzzing targets found" + exit 1 + } + + foreach ($fuzzingTarget in $fuzzingTargets) { + Write-Output "Instrumenting $fuzzingTarget" + & $SharpFuzz $fuzzingTarget.FullName + + if ($LastExitCode -ne 0) { + Write-Error "An error occurred while instrumenting $fuzzingTarget" + exit 1 + } + } +} + +$parameters = @( + "-timeout=$timeout" +) + +if ($Jobs) { + $parameters += "-fork=$Jobs" +} + +if ($IgnoreTimeouts) { + $parameters += "-ignore_timeouts=1" +} + +if ($MaxLength) { + $parameters += "-max_len=$MaxLength" +} + +if ($dict) { + $parameters += "-dict=$dict" +} + +& $LibFuzzer @parameters --target_path=$executable --target_arg=$Target $corpus \ No newline at end of file diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/DnsServiceEndpointResolverTests.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/DnsServiceEndpointResolverTests.cs new file mode 100644 index 00000000000..b949e713999 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/DnsServiceEndpointResolverTests.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 Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Time.Testing; +using Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; +using Xunit; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Tests; + +public class DnsServiceEndpointResolverTests +{ + [Fact] + public async Task ResolveServiceEndpoint_Dns_MultiShot() + { + var timeProvider = new FakeTimeProvider(); + var services = new ServiceCollection() + .AddSingleton(timeProvider) + .AddSingleton() + .AddServiceDiscoveryCore() + .AddDnsServiceEndpointProvider(o => o.DefaultRefreshPeriod = TimeSpan.FromSeconds(30)) + .BuildServiceProvider(); + var resolver = services.GetRequiredService(); + var initialResult = await resolver.GetEndpointsAsync("https://localhost", CancellationToken.None); + Assert.NotNull(initialResult); + Assert.True(initialResult.Endpoints.Count > 0); + timeProvider.Advance(TimeSpan.FromSeconds(7)); + var secondResult = await resolver.GetEndpointsAsync("https://localhost", CancellationToken.None); + Assert.NotNull(secondResult); + Assert.True(initialResult.Endpoints.Count > 0); + timeProvider.Advance(TimeSpan.FromSeconds(80)); + var thirdResult = await resolver.GetEndpointsAsync("https://localhost", CancellationToken.None); + Assert.NotNull(thirdResult); + Assert.True(initialResult.Endpoints.Count > 0); + } +} diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/DnsServicePublicApiTests.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/DnsServicePublicApiTests.cs new file mode 100644 index 00000000000..69bb6e0e510 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/DnsServicePublicApiTests.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 Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Tests; + +public class DnsServicePublicApiTests +{ + [Fact] + public void AddDnsSrvServiceEndpointProviderShouldThrowWhenServicesIsNull() + { + IServiceCollection services = null!; + + var action = () => services.AddDnsSrvServiceEndpointProvider(); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(services), exception.ParamName); + } + + [Fact] + public void AddDnsSrvServiceEndpointProviderWithConfigureOptionsShouldThrowWhenServicesIsNull() + { + IServiceCollection services = null!; + Action configureOptions = (_) => { }; + + var action = () => services.AddDnsSrvServiceEndpointProvider(configureOptions); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(services), exception.ParamName); + } + + [Fact] + public void AddDnsSrvServiceEndpointProviderWithConfigureOptionsShouldThrowWhenConfigureOptionsIsNull() + { + IServiceCollection services = new ServiceCollection(); + Action configureOptions = null!; + + var action = () => services.AddDnsSrvServiceEndpointProvider(configureOptions); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(configureOptions), exception.ParamName); + } + + [Fact] + public void AddDnsServiceEndpointProviderShouldThrowWhenServicesIsNull() + { + IServiceCollection services = null!; + + var action = () => services.AddDnsServiceEndpointProvider(); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(services), exception.ParamName); + } + + [Fact] + public void AddDnsServiceEndpointProviderWithConfigureOptionsShouldThrowWhenServicesIsNull() + { + IServiceCollection services = null!; + Action configureOptions = (_) => { }; + + var action = () => services.AddDnsServiceEndpointProvider(configureOptions); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(services), exception.ParamName); + } + + [Fact] + public void ConfigureDnsResolverShouldThrowWhenServicesIsNull() + { + IServiceCollection services = null!; + + var action = () => services.ConfigureDnsResolver(_ => { }); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(services), exception.ParamName); + } + + [Fact] + public void ConfigureDnsResolverShouldThrowWhenConfigureOptionsIsNull() + { + IServiceCollection services = new ServiceCollection(); + Action configureOptions = null!; + + var action = () => services.ConfigureDnsResolver(configureOptions); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(configureOptions), exception.ParamName); + } +} diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/DnsSrvServiceEndpointResolverTests.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/DnsSrvServiceEndpointResolverTests.cs new file mode 100644 index 00000000000..ec21bf9fa9c --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/DnsSrvServiceEndpointResolverTests.cs @@ -0,0 +1,177 @@ +// 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; +using System.Net.Sockets; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; +using Microsoft.Extensions.ServiceDiscovery.Internal; +using Xunit; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Tests; + +/// +/// Tests for and . +/// These also cover and by extension. +/// +public class DnsSrvServiceEndpointResolverTests +{ + private sealed class FakeDnsResolver : IDnsResolver + { + public Func>? ResolveIPAddressesAsyncFunc { get; set; } + public ValueTask ResolveIPAddressesAsync(string name, AddressFamily addressFamily, CancellationToken cancellationToken = default) => ResolveIPAddressesAsyncFunc!.Invoke(name, addressFamily, cancellationToken); + + public Func>? ResolveIPAddressesAsyncFunc2 { get; set; } + + public ValueTask ResolveIPAddressesAsync(string name, CancellationToken cancellationToken = default) => ResolveIPAddressesAsyncFunc2!.Invoke(name, cancellationToken); + + public Func>? ResolveServiceAsyncFunc { get; set; } + + public ValueTask ResolveServiceAsync(string name, CancellationToken cancellationToken = default) => ResolveServiceAsyncFunc!.Invoke(name, cancellationToken); + } + + [Fact] + public async Task ResolveServiceEndpoint_DnsSrv() + { + var dnsClientMock = new FakeDnsResolver + { + ResolveServiceAsyncFunc = (name, cancellationToken) => + { + ServiceResult[] response = [ + new ServiceResult(DateTime.UtcNow.AddSeconds(60), 99, 66, 8888, "srv-a", [new AddressResult(DateTime.UtcNow.AddSeconds(64), IPAddress.Parse("10.10.10.10"))]), + new ServiceResult(DateTime.UtcNow.AddSeconds(60), 99, 62, 9999, "srv-b", [new AddressResult(DateTime.UtcNow.AddSeconds(64), IPAddress.IPv6Loopback)]), + new ServiceResult(DateTime.UtcNow.AddSeconds(60), 99, 62, 7777, "srv-c", []) + ]; + + return ValueTask.FromResult(response); + } + }; + var services = new ServiceCollection() + .AddSingleton(dnsClientMock) + .AddServiceDiscoveryCore() + .AddDnsSrvServiceEndpointProvider(options => options.QuerySuffix = ".ns") + .BuildServiceProvider(); + var watcherFactory = services.GetRequiredService(); + ServiceEndpointWatcher watcher; + await using ((watcher = watcherFactory.CreateWatcher("http://basket")).ConfigureAwait(false)) + { + Assert.NotNull(watcher); + var tcs = new TaskCompletionSource(); + watcher.OnEndpointsUpdated = tcs.SetResult; + watcher.Start(); + var initialResult = await tcs.Task; + Assert.NotNull(initialResult); + Assert.True(initialResult.ResolvedSuccessfully); + Assert.Equal(3, initialResult.EndpointSource.Endpoints.Count); + var eps = initialResult.EndpointSource.Endpoints; + Assert.Equal(new IPEndPoint(IPAddress.Parse("10.10.10.10"), 8888), eps[0].EndPoint); + Assert.Equal(new IPEndPoint(IPAddress.IPv6Loopback, 9999), eps[1].EndPoint); + Assert.Equal(new DnsEndPoint("srv-c", 7777), eps[2].EndPoint); + + Assert.All(initialResult.EndpointSource.Endpoints, ep => + { + var hostNameFeature = ep.Features.Get(); + Assert.Null(hostNameFeature); + }); + } + } + + /// + /// Tests that when there are multiple resolvers registered, they are consulted in registration order and each provider only adds endpoints if the providers before it did not. + /// + [InlineData(true)] + [InlineData(false)] + [Theory] + public async Task ResolveServiceEndpoint_DnsSrv_MultipleProviders_PreventMixing(bool dnsFirst) + { + var dnsClientMock = new FakeDnsResolver + { + ResolveServiceAsyncFunc = (name, cancellationToken) => + { + ServiceResult[] response = [ + new ServiceResult(DateTime.UtcNow.AddSeconds(60), 99, 66, 8888, "srv-a", [new AddressResult(DateTime.UtcNow.AddSeconds(64), IPAddress.Parse("10.10.10.10"))]), + new ServiceResult(DateTime.UtcNow.AddSeconds(60), 99, 62, 9999, "srv-b", [new AddressResult(DateTime.UtcNow.AddSeconds(64), IPAddress.IPv6Loopback)]), + new ServiceResult(DateTime.UtcNow.AddSeconds(60), 99, 62, 7777, "srv-c", []) + ]; + + return ValueTask.FromResult(response); + } + }; + var configSource = new MemoryConfigurationSource + { + InitialData = new Dictionary + { + ["services:basket:http:0"] = "localhost:8080", + ["services:basket:http:1"] = "remotehost:9090", + } + }; + var config = new ConfigurationBuilder().Add(configSource); + var serviceCollection = new ServiceCollection() + .AddSingleton(dnsClientMock) + .AddSingleton(config.Build()) + .AddServiceDiscoveryCore(); + if (dnsFirst) + { + serviceCollection + .AddDnsSrvServiceEndpointProvider(options => + { + options.QuerySuffix = ".ns"; + options.ShouldApplyHostNameMetadata = _ => true; + }) + .AddConfigurationServiceEndpointProvider(); + } + else + { + serviceCollection + .AddConfigurationServiceEndpointProvider() + .AddDnsSrvServiceEndpointProvider(options => options.QuerySuffix = ".ns"); + }; + var services = serviceCollection.BuildServiceProvider(); + var watcherFactory = services.GetRequiredService(); + ServiceEndpointWatcher watcher; + await using ((watcher = watcherFactory.CreateWatcher("http://basket")).ConfigureAwait(false)) + { + Assert.NotNull(watcher); + var tcs = new TaskCompletionSource(); + watcher.OnEndpointsUpdated = tcs.SetResult; + watcher.Start(); + var initialResult = await tcs.Task; + Assert.NotNull(initialResult); + Assert.Null(initialResult.Exception); + Assert.True(initialResult.ResolvedSuccessfully); + + if (dnsFirst) + { + // We expect only the results from the DNS provider. + Assert.Equal(3, initialResult.EndpointSource.Endpoints.Count); + var eps = initialResult.EndpointSource.Endpoints; + Assert.Equal(new IPEndPoint(IPAddress.Parse("10.10.10.10"), 8888), eps[0].EndPoint); + Assert.Equal(new IPEndPoint(IPAddress.IPv6Loopback, 9999), eps[1].EndPoint); + Assert.Equal(new DnsEndPoint("srv-c", 7777), eps[2].EndPoint); + + Assert.All(initialResult.EndpointSource.Endpoints, ep => + { + var hostNameFeature = ep.Features.Get(); + Assert.NotNull(hostNameFeature); + Assert.Equal("basket", hostNameFeature.HostName); + }); + } + else + { + // We expect only the results from the Configuration provider. + Assert.Equal(2, initialResult.EndpointSource.Endpoints.Count); + Assert.Equal(new DnsEndPoint("localhost", 8080), initialResult.EndpointSource.Endpoints[0].EndPoint); + Assert.Equal(new DnsEndPoint("remotehost", 9090), initialResult.EndpointSource.Endpoints[1].EndPoint); + + Assert.All(initialResult.EndpointSource.Endpoints, ep => + { + var hostNameFeature = ep.Features.Get(); + Assert.Null(hostNameFeature); + }); + } + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.csproj b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.csproj new file mode 100644 index 00000000000..d298b2c4699 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Microsoft.Extensions.ServiceDiscovery.Dns.Tests.csproj @@ -0,0 +1,27 @@ + + + + $(TestNetCoreTargetFrameworks) + enable + enable + Open + + $(NoWarn);IDE0004;IDE0017;IDE0040;IDE0055;IDE1006;CA1012;CA1031;CA1063;CA1816;CA2000;S103;S107;S1067;S1121;S1128;S1135;S1144;S1186;S2148;S3442;S3459;S4136;SA1106;SA1127;SA1204;SA1208;SA1210;SA1128;SA1316;SA1400;SA1402;SA1407;SA1414;SA1500;SA1513;SA1515;VSTHRD003 + + + + + + + + + + + + + + + + + + diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/CancellationTests.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/CancellationTests.cs new file mode 100644 index 00000000000..786882afc1d --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/CancellationTests.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.Net.Sockets; +using Xunit.Abstractions; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver.Tests; + +public class CancellationTests : LoopbackDnsTestBase +{ + public CancellationTests(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public async Task PreCanceledToken_Throws() + { + CancellationTokenSource cts = new CancellationTokenSource(); + cts.Cancel(); + + var ex = await Assert.ThrowsAnyAsync(async () => await Resolver.ResolveIPAddressesAsync("example.com", AddressFamily.InterNetwork, cts.Token)); + + Assert.Equal(cts.Token, ex.CancellationToken); + } + + [Fact] + public async Task CancellationInProgress_Throws() + { + CancellationTokenSource cts = new CancellationTokenSource(); + + var task = Assert.ThrowsAnyAsync(async () => await Resolver.ResolveIPAddressesAsync("example.com", AddressFamily.InterNetwork, cts.Token)); + + await DnsServer.ProcessUdpRequest(_ => + { + cts.Cancel(); + return Task.CompletedTask; + }); + + OperationCanceledException ex = await task; + Assert.Equal(cts.Token, ex.CancellationToken); + } +} diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/DnsDataReaderTests.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/DnsDataReaderTests.cs new file mode 100644 index 00000000000..aad32fe785f --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/DnsDataReaderTests.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 Xunit; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver.Tests; + +public class DnsDataReaderTests +{ + [Fact] + public void ReadResourceRecord_Success() + { + // example A record for example.com + byte[] buffer = [ + // name (www.example.com) + 0x03, 0x77, 0x77, 0x77, 0x07, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x03, 0x63, 0x6f, 0x6d, 0x00, + // type (A) + 0x00, 0x01, + // class (IN) + 0x00, 0x01, + // TTL (3600) + 0x00, 0x00, 0x0e, 0x10, + // data length (4) + 0x00, 0x04, + // data (placeholder) + 0x00, 0x00, 0x00, 0x00 + ]; + + DnsDataReader reader = new DnsDataReader(buffer); + Assert.True(reader.TryReadResourceRecord(out DnsResourceRecord record)); + + Assert.Equal("www.example.com", record.Name.ToString()); + Assert.Equal(QueryType.A, record.Type); + Assert.Equal(QueryClass.Internet, record.Class); + Assert.Equal(3600, record.Ttl); + Assert.Equal(4, record.Data.Length); + } + + [Fact] + public void ReadResourceRecord_Truncated_Fails() + { + // example A record for example.com + byte[] buffer = [ + // name (www.example.com) + 0x03, 0x77, 0x77, 0x77, 0x07, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x03, 0x63, 0x6f, 0x6d, 0x00, + // type (A) + 0x00, 0x01, + // class (IN) + 0x00, 0x01, + // TTL (3600) + 0x00, 0x00, 0x0e, 0x10, + // data length (4) + 0x00, 0x04, + // data (placeholder) + 0x00, 0x00, 0x00, 0x00 + ]; + + for (int i = 0; i < buffer.Length; i++) + { + DnsDataReader reader = new DnsDataReader(new ArraySegment(buffer, 0, i)); + Assert.False(reader.TryReadResourceRecord(out _)); + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/DnsDataWriterTests.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/DnsDataWriterTests.cs new file mode 100644 index 00000000000..b2039ce5a4c --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/DnsDataWriterTests.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 Xunit; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver.Tests; + +public class DnsDataWriterTests +{ + [Fact] + public void WriteResourceRecord_Success() + { + // example A record for example.com + byte[] expected = [ + // name (www.example.com) + 0x03, 0x77, 0x77, 0x77, 0x07, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x03, 0x63, 0x6f, 0x6d, 0x00, + // type (A) + 0x00, 0x01, + // class (IN) + 0x00, 0x01, + // TTL (3600) + 0x00, 0x00, 0x0e, 0x10, + // data length (4) + 0x00, 0x04, + // data (placeholder) + 0x00, 0x00, 0x00, 0x00 + ]; + + DnsResourceRecord record = new DnsResourceRecord(EncodeDomainName("www.example.com"), QueryType.A, QueryClass.Internet, 3600, new byte[4]); + + byte[] buffer = new byte[512]; + DnsDataWriter writer = new DnsDataWriter(buffer); + Assert.True(writer.TryWriteResourceRecord(record)); + Assert.Equal(expected, buffer.AsSpan().Slice(0, writer.Position).ToArray()); + } + + [Fact] + public void WriteResourceRecord_Truncated_Fails() + { + // example A record for example.com + byte[] expected = [ + // name (www.example.com) + 0x03, 0x77, 0x77, 0x77, 0x07, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x03, 0x63, 0x6f, 0x6d, 0x00, + // type (A) + 0x00, 0x01, + // class (IN) + 0x00, 0x01, + // TTL (3600) + 0x00, 0x00, 0x0e, 0x10, + // data length (4) + 0x00, 0x04, + // data (placeholder) + 0x00, 0x00, 0x00, 0x00 + ]; + + DnsResourceRecord record = new DnsResourceRecord(EncodeDomainName("www.example.com"), QueryType.A, QueryClass.Internet, 3600, new byte[4]); + + byte[] buffer = new byte[512]; + for (int i = 0; i < expected.Length; i++) + { + DnsDataWriter writer = new DnsDataWriter(buffer.AsMemory(0, i)); + Assert.False(writer.TryWriteResourceRecord(record)); + } + } + + [Fact] + public void WriteQuestion_Success() + { + // example question for example.com (A record) + byte[] expected = [ + // name (www.example.com) + 0x03, 0x77, 0x77, 0x77, 0x07, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x03, 0x63, 0x6f, 0x6d, 0x00, + // type (A) + 0x00, 0x01, + // class (IN) + 0x00, 0x01 + ]; + + byte[] buffer = new byte[512]; + DnsDataWriter writer = new DnsDataWriter(buffer); + Assert.True(writer.TryWriteQuestion(EncodeDomainName("www.example.com"), QueryType.A, QueryClass.Internet)); + Assert.Equal(expected, buffer.AsSpan().Slice(0, writer.Position).ToArray()); + } + + [Fact] + public void WriteQuestion_Truncated_Fails() + { + // example question for example.com (A record) + byte[] expected = [ + // name (www.example.com) + 0x03, 0x77, 0x77, 0x77, 0x07, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x03, 0x63, 0x6f, 0x6d, 0x00, + // type (A) + 0x00, 0x01, + // class (IN) + 0x00, 0x01 + ]; + + byte[] buffer = new byte[512]; + for (int i = 0; i < expected.Length; i++) + { + DnsDataWriter writer = new DnsDataWriter(buffer.AsMemory(0, i)); + Assert.False(writer.TryWriteQuestion(EncodeDomainName("www.example.com"), QueryType.A, QueryClass.Internet)); + } + } + + [Fact] + public void WriteHeader_Success() + { + // example header + byte[] expected = [ + // ID (0x1234) + 0x12, 0x34, + // Flags (0x5678) + 0x56, 0x78, + // Question count (1) + 0x00, 0x01, + // Answer count (0) + 0x00, 0x02, + // Authority count (0) + 0x00, 0x03, + // Additional count (0) + 0x00, 0x04 + ]; + + DnsMessageHeader header = new() + { + TransactionId = 0x1234, + QueryFlags = (QueryFlags)0x5678, + QueryCount = 1, + AnswerCount = 2, + AuthorityCount = 3, + AdditionalRecordCount = 4, + }; + + byte[] buffer = new byte[512]; + DnsDataWriter writer = new DnsDataWriter(buffer); + Assert.True(writer.TryWriteHeader(header)); + Assert.Equal(expected, buffer.AsSpan().Slice(0, writer.Position).ToArray()); + } + + private static EncodedDomainName EncodeDomainName(string name) + { + byte[] nameBuffer = new byte[512]; + Assert.True(DnsPrimitives.TryWriteQName(nameBuffer, name, out int nameLength)); + Assert.True(DnsPrimitives.TryReadQName(nameBuffer.AsMemory(0, nameLength), 0, out EncodedDomainName encodedDomainName, out _)); + return encodedDomainName; + } +} diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/DnsPrimitivesTests.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/DnsPrimitivesTests.cs new file mode 100644 index 00000000000..6733a553bad --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/DnsPrimitivesTests.cs @@ -0,0 +1,195 @@ +// 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.ServiceDiscovery.Dns.Resolver.Tests; + +public class DnsPrimitivesTests +{ + public static TheoryData QNameData => new() + { + { "www.example.com", "\x0003www\x0007example\x0003com\x0000"u8.ToArray() }, + { "example.com", "\x0007example\x0003com\x0000"u8.ToArray() }, + { "com", "\x0003com\x0000"u8.ToArray() }, + { "example", "\x0007example\x0000"u8.ToArray() }, + { "www", "\x0003www\x0000"u8.ToArray() }, + { "a", "\x0001a\x0000"u8.ToArray() }, + }; + + [Theory] + [MemberData(nameof(QNameData))] + public void TryWriteQName_Success(string name, byte[] expected) + { + byte[] buffer = new byte[512]; + + Assert.True(DnsPrimitives.TryWriteQName(buffer, name, out int written)); + Assert.Equal(name.Length + 2, written); + Assert.Equal(expected, buffer.AsSpan().Slice(0, written).ToArray()); + } + + [Fact] + public void TryWriteQName_LabelTooLong_False() + { + byte[] buffer = new byte[512]; + + Assert.False(DnsPrimitives.TryWriteQName(buffer, new string('a', 70), out _)); + } + + [Fact] + public void TryWriteQName_BufferTooShort_Fails() + { + byte[] buffer = new byte[512]; + string name = "www.example.com"; + + for (int i = 0; i < name.Length + 2; i++) + { + Assert.False(DnsPrimitives.TryWriteQName(buffer.AsSpan(0, i), name, out _)); + } + } + + [Theory] + [InlineData("www.-0.com")] + [InlineData("www.-a.com")] + [InlineData("www.a-.com")] + [InlineData("www.a_a.com")] + [InlineData("www.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com")] // 64 occurrences of 'a' (too long) + [InlineData("www.a~a.com")] // 64 occurrences of 'a' (too long) + [InlineData("www..com")] + [InlineData("www..")] + public void TryWriteQName_InvalidName_ReturnsFalse(string name) + { + byte[] buffer = new byte[512]; + Assert.False(DnsPrimitives.TryWriteQName(buffer, name, out _)); + } + + [Fact] + public void TryWriteQName_ExplicitRoot_Success() + { + string name1 = "www.example.com"; + string name2 = "www.example.com."; + + byte[] buffer1 = new byte[512]; + byte[] buffer2 = new byte[512]; + + Assert.True(DnsPrimitives.TryWriteQName(buffer1, name1, out int written1)); + Assert.True(DnsPrimitives.TryWriteQName(buffer2, name2, out int written2)); + Assert.Equal(written1, written2); + Assert.Equal(buffer1.AsSpan().Slice(0, written1).ToArray(), buffer2.AsSpan().Slice(0, written2).ToArray()); + } + + [Theory] + [MemberData(nameof(QNameData))] + public void TryReadQName_Success(string expected, byte[] serialized) + { + Assert.True(DnsPrimitives.TryReadQName(serialized, 0, out EncodedDomainName actual, out int bytesRead)); + Assert.Equal(expected, actual.ToString()); + Assert.Equal(serialized.Length, bytesRead); + } + + [Fact] + public void TryReadQName_TruncatedData_Fails() + { + ReadOnlyMemory data = "\x0003www\x0007example\x0003com\x0000"u8.ToArray(); + + for (int i = 0; i < data.Length; i++) + { + Assert.False(DnsPrimitives.TryReadQName(data.Slice(0, i), 0, out _, out _)); + } + } + + [Fact] + public void TryReadQName_Pointer_Success() + { + // [7B padding], example.com. www->[ptr to example.com.] + Memory data = "padding\x0007example\x0003com\x0000\x0003www\x00\x07"u8.ToArray(); + data.Span[^2] = 0xc0; + + Assert.True(DnsPrimitives.TryReadQName(data, data.Length - 6, out EncodedDomainName actual, out int bytesRead)); + Assert.Equal("www.example.com", actual.ToString()); + Assert.Equal(6, bytesRead); + } + + [Fact] + public void TryReadQName_PointerTruncated_Fails() + { + // [7B padding], example.com. www->[ptr to example.com.] + Memory data = "padding\x0007example\x0003com\x0000\x0003www\x00\x07"u8.ToArray(); + data.Span[^2] = 0xc0; + + for (int i = 0; i < data.Length; i++) + { + Assert.False(DnsPrimitives.TryReadQName(data.Slice(0, i), data.Length - 6, out _, out _)); + } + } + + [Fact] + public void TryReadQName_ForwardPointer_Fails() + { + // www->[ptr to example.com], [7B padding], example.com. + Memory data = "\x03www\x00\x000dpadding\x0007example\x0003com\x00"u8.ToArray(); + data.Span[4] = 0xc0; + + Assert.False(DnsPrimitives.TryReadQName(data, 0, out _, out _)); + } + + [Fact] + public void TryReadQName_PointerToSelf_Fails() + { + // www->[ptr to www->...] + Memory data = "\x0003www\0\0"u8.ToArray(); + data.Span[4] = 0xc0; + + Assert.False(DnsPrimitives.TryReadQName(data, 0, out _, out _)); + } + + [Fact] + public void TryReadQName_PointerToPointer_Fails() + { + // com, example[->com], example2[->[->com]] + Memory data = "\x0003com\0\x0007example\0\0\x0008example2\0\0"u8.ToArray(); + data.Span[13] = 0xc0; + data.Span[14] = 0x00; // -> com + data.Span[24] = 0xc0; + data.Span[25] = 13; // -> -> com + + Assert.False(DnsPrimitives.TryReadQName(data, 15, out _, out _)); + } + + [Fact] + public void TryReadQName_ReservedBits() + { + Memory data = "\x0003www\x00c0"u8.ToArray(); + data.Span[0] = 0x40; + + Assert.False(DnsPrimitives.TryReadQName(data, 0, out _, out _)); + } + + [Theory] + [InlineData(253)] + [InlineData(254)] + [InlineData(255)] + public void TryReadQName_NameTooLong(int length) + { + // longest possible label is 63 bytes + 1 byte for length + byte[] labelData = new byte[64]; + Array.Fill(labelData, (byte)'a'); + labelData[0] = 63; + + int remainder = length - 3 * 64; + + byte[] lastLabelData = new byte[remainder + 1]; + Array.Fill(lastLabelData, (byte)'a'); + lastLabelData[0] = (byte)remainder; + + byte[] data = Enumerable.Repeat(labelData, 3).SelectMany(x => x).Concat(lastLabelData).Concat(new byte[1]).ToArray(); + if (length > 253) + { + Assert.False(DnsPrimitives.TryReadQName(data, 0, out _, out _)); + } + else + { + Assert.True(DnsPrimitives.TryReadQName(data, 0, out _, out _)); + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/LoopbackDnsServer.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/LoopbackDnsServer.cs new file mode 100644 index 00000000000..4789e21c575 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/LoopbackDnsServer.cs @@ -0,0 +1,331 @@ +// 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.Buffers.Binary; +using System.Globalization; +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver.Tests; + +internal sealed class LoopbackDnsServer : IDisposable +{ + private readonly Socket _dnsSocket; + private Socket? _tcpSocket; + + public IPEndPoint DnsEndPoint => (IPEndPoint)_dnsSocket.LocalEndPoint!; + + public LoopbackDnsServer() + { + _dnsSocket = new(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + _dnsSocket.Bind(new IPEndPoint(IPAddress.Loopback, 0)); + } + + public void Dispose() + { + _dnsSocket.Dispose(); + _tcpSocket?.Dispose(); + } + + private static async Task ProcessRequestCore(IPEndPoint remoteEndPoint, ArraySegment message, Func action, Memory responseBuffer) + { + DnsDataReader reader = new DnsDataReader(message); + + if (!reader.TryReadHeader(out DnsMessageHeader header) || + !reader.TryReadQuestion(out var name, out var type, out var @class)) + { + return 0; + } + + LoopbackDnsResponseBuilder responseBuilder = new(name.ToString(), type, @class); + responseBuilder.TransactionId = header.TransactionId; + responseBuilder.Flags = header.QueryFlags | QueryFlags.HasResponse; + responseBuilder.ResponseCode = QueryResponseCode.NoError; + + await action(responseBuilder, remoteEndPoint); + + return responseBuilder.Write(responseBuffer); + } + + public async Task ProcessUdpRequest(Func action) + { + byte[] buffer = ArrayPool.Shared.Rent(512); + try + { + EndPoint remoteEndPoint = new IPEndPoint(IPAddress.Any, 0); + SocketReceiveFromResult result = await _dnsSocket.ReceiveFromAsync(buffer, remoteEndPoint); + + int bytesWritten = await ProcessRequestCore((IPEndPoint)result.RemoteEndPoint, new ArraySegment(buffer, 0, result.ReceivedBytes), action, buffer.AsMemory(0, 512)); + + await _dnsSocket.SendToAsync(buffer.AsMemory(0, bytesWritten), SocketFlags.None, result.RemoteEndPoint); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + public Task ProcessUdpRequest(Func action) + { + return ProcessUdpRequest((builder, _) => action(builder)); + } + + public async Task ProcessTcpRequest(Func action) + { + if (_tcpSocket is null) + { + _tcpSocket = new(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + _tcpSocket.Bind(new IPEndPoint(IPAddress.Loopback, ((IPEndPoint)_dnsSocket.LocalEndPoint!).Port)); + _tcpSocket.Listen(); + } + + using Socket tcpClient = await _tcpSocket.AcceptAsync(); + + byte[] buffer = ArrayPool.Shared.Rent(8 * 1024); + try + { + int bytesRead = 0; + int length = -1; + while (length < 0 || bytesRead < length + 2) + { + int toRead = length < 0 ? 2 : length + 2 - bytesRead; + int read = await tcpClient.ReceiveAsync(buffer.AsMemory(bytesRead, toRead), SocketFlags.None); + bytesRead += read; + + if (length < 0 && bytesRead >= 2) + { + length = BinaryPrimitives.ReadUInt16BigEndian(buffer.AsSpan(0, 2)); + } + } + + int bytesWritten = await ProcessRequestCore((IPEndPoint)tcpClient.RemoteEndPoint!, new ArraySegment(buffer, 2, length), action, buffer.AsMemory(2)); + BinaryPrimitives.WriteUInt16BigEndian(buffer.AsSpan(0, 2), (ushort)bytesWritten); + await tcpClient.SendAsync(buffer.AsMemory(0, bytesWritten + 2), SocketFlags.None); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + public Task ProcessTcpRequest(Func action) + { + return ProcessTcpRequest((builder, _) => action(builder)); + } +} + +internal sealed class LoopbackDnsResponseBuilder +{ + private static readonly SearchValues s_domainNameValidChars = SearchValues.Create("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_."); + + public LoopbackDnsResponseBuilder(string name, QueryType type, QueryClass @class) + { + Name = name; + Type = type; + Class = @class; + Questions.Add((name, type, @class)); + + if (name.AsSpan().ContainsAnyExcept(s_domainNameValidChars)) + { + throw new ArgumentException($"Invalid characters in domain name '{name}'"); + } + } + + public ushort TransactionId { get; set; } + public QueryFlags Flags { get; set; } + public QueryResponseCode ResponseCode { get; set; } + + public string Name { get; } + public QueryType Type { get; } + public QueryClass Class { get; } + + public List<(string, QueryType, QueryClass)> Questions { get; } = new List<(string, QueryType, QueryClass)>(); + public List Answers { get; } = new List(); + public List Authorities { get; } = new List(); + public List Additionals { get; } = new List(); + + public int Write(Memory responseBuffer) + { + DnsDataWriter writer = new(responseBuffer); + if (!writer.TryWriteHeader(new DnsMessageHeader + { + TransactionId = TransactionId, + QueryFlags = Flags | (QueryFlags)ResponseCode, + QueryCount = (ushort)Questions.Count, + AnswerCount = (ushort)Answers.Count, + AuthorityCount = (ushort)Authorities.Count, + AdditionalRecordCount = (ushort)Additionals.Count + })) + { + throw new InvalidOperationException("Failed to write header"); + } + + byte[] buffer = ArrayPool.Shared.Rent(512); + foreach (var (questionName, questionType, questionClass) in Questions) + { + if (!DnsPrimitives.TryWriteQName(buffer, questionName, out int length) || + !DnsPrimitives.TryReadQName(buffer.AsMemory(0, length), 0, out EncodedDomainName encodedName, out _)) + { + throw new InvalidOperationException("Failed to encode domain name"); + } + if (!writer.TryWriteQuestion(encodedName, questionType, questionClass)) + { + throw new InvalidOperationException("Failed to write question"); + } + } + ArrayPool.Shared.Return(buffer); + + foreach (var answer in Answers) + { + if (!writer.TryWriteResourceRecord(answer)) + { + throw new InvalidOperationException("Failed to write answer"); + } + } + + foreach (var authority in Authorities) + { + if (!writer.TryWriteResourceRecord(authority)) + { + throw new InvalidOperationException("Failed to write authority"); + } + } + + foreach (var additional in Additionals) + { + if (!writer.TryWriteResourceRecord(additional)) + { + throw new InvalidOperationException("Failed to write additional records"); + } + } + + return writer.Position; + } + + public byte[] GetMessageBytes() + { + byte[] buffer = ArrayPool.Shared.Rent(512); + try + { + int bytesWritten = Write(buffer.AsMemory(0, 512)); + return buffer.AsSpan(0, bytesWritten).ToArray(); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } +} + +internal static class LoopbackDnsServerExtensions +{ + private static readonly IdnMapping s_idnMapping = new IdnMapping(); + + private static EncodedDomainName EncodeDomainName(string name) + { + var encodedLabels = name.Split('.', StringSplitOptions.RemoveEmptyEntries).Select(label => (ReadOnlyMemory)Encoding.UTF8.GetBytes(s_idnMapping.GetAscii(label))) + .ToList(); + + return new EncodedDomainName(encodedLabels); + } + + public static List AddAddress(this List records, string name, int ttl, IPAddress address) + { + QueryType type = address.AddressFamily == AddressFamily.InterNetwork ? QueryType.A : QueryType.AAAA; + records.Add(new DnsResourceRecord(EncodeDomainName(name), type, QueryClass.Internet, ttl, address.GetAddressBytes())); + return records; + } + + public static List AddCname(this List records, string name, int ttl, string alias) + { + byte[] buff = new byte[256]; + if (!DnsPrimitives.TryWriteQName(buff, alias, out int length)) + { + throw new InvalidOperationException("Failed to encode domain name"); + } + + records.Add(new DnsResourceRecord(EncodeDomainName(name), QueryType.CNAME, QueryClass.Internet, ttl, buff.AsMemory(0, length))); + return records; + } + + public static List AddService(this List records, string name, int ttl, ushort priority, ushort weight, ushort port, string target) + { + byte[] buff = new byte[256]; + + // https://www.rfc-editor.org/rfc/rfc2782 + if (!BinaryPrimitives.TryWriteUInt16BigEndian(buff, priority) || + !BinaryPrimitives.TryWriteUInt16BigEndian(buff.AsSpan(2), weight) || + !BinaryPrimitives.TryWriteUInt16BigEndian(buff.AsSpan(4), port) || + !DnsPrimitives.TryWriteQName(buff.AsSpan(6), target, out int length)) + { + throw new InvalidOperationException("Failed to encode SRV record"); + } + + length += 6; + + records.Add(new DnsResourceRecord(EncodeDomainName(name), QueryType.SRV, QueryClass.Internet, ttl, buff.AsMemory(0, length))); + return records; + } + + public static List AddStartOfAuthority(this List records, string name, int ttl, string mname, string rname, uint serial, uint refresh, uint retry, uint expire, uint minimum) + { + byte[] buff = new byte[256]; + + // https://www.rfc-editor.org/rfc/rfc1035#section-3.3.13 + if (!DnsPrimitives.TryWriteQName(buff, mname, out int w1) || + !DnsPrimitives.TryWriteQName(buff.AsSpan(w1), rname, out int w2) || + !BinaryPrimitives.TryWriteUInt32BigEndian(buff.AsSpan(w1 + w2), serial) || + !BinaryPrimitives.TryWriteUInt32BigEndian(buff.AsSpan(w1 + w2 + 4), refresh) || + !BinaryPrimitives.TryWriteUInt32BigEndian(buff.AsSpan(w1 + w2 + 8), retry) || + !BinaryPrimitives.TryWriteUInt32BigEndian(buff.AsSpan(w1 + w2 + 12), expire) || + !BinaryPrimitives.TryWriteUInt32BigEndian(buff.AsSpan(w1 + w2 + 16), minimum)) + { + throw new InvalidOperationException("Failed to encode SOA record"); + } + + int length = w1 + w2 + 20; + + records.Add(new DnsResourceRecord(EncodeDomainName(name), QueryType.SOA, QueryClass.Internet, ttl, buff.AsMemory(0, length))); + return records; + } +} + +internal static class DnsDataWriterExtensions +{ + internal static bool TryWriteResourceRecord(this DnsDataWriter writer, DnsResourceRecord record) + { + if (!TryWriteDomainName(writer, record.Name) || + !writer.TryWriteUInt16((ushort)record.Type) || + !writer.TryWriteUInt16((ushort)record.Class) || + !writer.TryWriteUInt32((uint)record.Ttl) || + !writer.TryWriteUInt16((ushort)record.Data.Length) || + !writer.TryWriteRawData(record.Data.Span)) + { + return false; + } + + return true; + } + + internal static bool TryWriteDomainName(this DnsDataWriter writer, EncodedDomainName name) + { + foreach (var label in name.Labels) + { + if (label.Length > 63) + { + throw new InvalidOperationException("Label length exceeds maximum of 63 bytes"); + } + + if (!writer.TryWriteByte((byte)label.Length) || + !writer.TryWriteRawData(label.Span)) + { + return false; + } + } + + // root label + return writer.TryWriteByte(0); + } +} \ No newline at end of file diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/LoopbackDnsTestBase.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/LoopbackDnsTestBase.cs new file mode 100644 index 00000000000..6d2aba6cb64 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/LoopbackDnsTestBase.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.Globalization; +using System.Text; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.ServiceDiscovery.Dns.Tests; +using Microsoft.Extensions.Time.Testing; +using Xunit.Abstractions; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver.Tests; + +public abstract class LoopbackDnsTestBase : IDisposable +{ + protected readonly ITestOutputHelper Output; + + internal LoopbackDnsServer DnsServer { get; } + private readonly Lazy _resolverLazy; + internal DnsResolver Resolver => _resolverLazy.Value; + internal DnsResolverOptions Options { get; } + protected readonly FakeTimeProvider TimeProvider; + + public LoopbackDnsTestBase(ITestOutputHelper output) + { + Output = output; + DnsServer = new(); + TimeProvider = new(); + Options = new() + { + Servers = [DnsServer.DnsEndPoint], + Timeout = TimeSpan.FromSeconds(5), + MaxAttempts = 1, + }; + _resolverLazy = new(InitializeResolver); + } + + DnsResolver InitializeResolver() + { + ServiceCollection services = new(); + services.AddXunitLogging(Output); + + var resolver = new DnsResolver(TimeProvider, NullLogger.Instance, new OptionsWrapper(Options)); + return resolver; + } + + public void Dispose() + { + DnsServer.Dispose(); + } +} diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/ResolvConfTests.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/ResolvConfTests.cs new file mode 100644 index 00000000000..4c2bcadd8a5 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/ResolvConfTests.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 Xunit; +using System.Net; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver.Tests; + +public class ResolvConfTests +{ + [Fact] + public void GetServers() + { + var contents = @" +nameserver 10.96.0.10 +search default.svc.cluster.local svc.cluster.local cluster.local +options ndots:5 +@"; + + var reader = new StringReader(contents); + var servers = ResolvConf.GetServers(reader); + + IPEndPoint ipAddress = Assert.Single(servers); + Assert.Equal(new IPEndPoint(IPAddress.Parse("10.96.0.10"), 53), ipAddress); + } +} diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/ResolveAddressesTests.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/ResolveAddressesTests.cs new file mode 100644 index 00000000000..c2d033ecdae --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/ResolveAddressesTests.cs @@ -0,0 +1,307 @@ +// 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; +using System.Net.Sockets; +using Xunit.Abstractions; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver.Tests; + +public class ResolveAddressesTests : LoopbackDnsTestBase +{ + public ResolveAddressesTests(ITestOutputHelper output) : base(output) + { + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ResolveIPv4_NoData_Success(bool includeSoa) + { + string hostName = "nodata.test"; + + _ = DnsServer.ProcessUdpRequest(builder => + { + if (includeSoa) + { + builder.Authorities.AddStartOfAuthority("ns.com", 240, "ns.com", "admin.ns.com", 1, 900, 180, 6048000, 3600); + } + return Task.CompletedTask; + }); + + AddressResult[] results = await Resolver.ResolveIPAddressesAsync(hostName, AddressFamily.InterNetwork); + Assert.Empty(results); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ResolveIPv4_NoSuchName_Success(bool includeSoa) + { + string hostName = "nosuchname.test"; + + _ = DnsServer.ProcessUdpRequest(builder => + { + builder.ResponseCode = QueryResponseCode.NameError; + if (includeSoa) + { + builder.Authorities.AddStartOfAuthority("ns.com", 240, "ns.com", "admin.ns.com", 1, 900, 180, 6048000, 3600); + } + return Task.CompletedTask; + }); + + AddressResult[] results = await Resolver.ResolveIPAddressesAsync(hostName, AddressFamily.InterNetwork); + Assert.Empty(results); + } + + [Theory] + [InlineData("www.resolveipv4.com")] + [InlineData("www.resolveipv4.com.")] + [InlineData("www.ř.com")] + public async Task ResolveIPv4_Simple_Success(string name) + { + IPAddress address = IPAddress.Parse("172.213.245.111"); + _ = DnsServer.ProcessUdpRequest(builder => + { + builder.Answers.AddAddress(name, 3600, address); + return Task.CompletedTask; + }); + + AddressResult[] results = await Resolver.ResolveIPAddressesAsync(name, AddressFamily.InterNetwork); + + AddressResult res = Assert.Single(results); + Assert.Equal(address, res.Address); + Assert.Equal(TimeProvider.GetUtcNow().DateTime.AddSeconds(3600), res.ExpiresAt); + } + + [Fact] + public async Task ResolveIPv4_Aliases_InOrder_Success() + { + IPAddress address = IPAddress.Parse("172.213.245.111"); + string hostName = "alias-in-order.test"; + + _ = DnsServer.ProcessUdpRequest(builder => + { + builder.Answers.AddCname(hostName, 3600, "www.example2.com"); + builder.Answers.AddCname("www.example2.com", 3600, "www.example3.com"); + builder.Answers.AddAddress("www.example3.com", 3600, address); + return Task.CompletedTask; + }); + + AddressResult[] results = await Resolver.ResolveIPAddressesAsync(hostName, AddressFamily.InterNetwork); + + AddressResult res = Assert.Single(results); + Assert.Equal(address, res.Address); + Assert.Equal(TimeProvider.GetUtcNow().DateTime.AddSeconds(3600), res.ExpiresAt); + } + + [Fact] + public async Task ResolveIPv4_Aliases_OutOfOrder_Success() + { + IPAddress address = IPAddress.Parse("172.213.245.111"); + string hostName = "alias-out-of-order.test"; + + _ = DnsServer.ProcessUdpRequest(builder => + { + builder.Answers.AddCname("www.example2.com", 3600, "www.example3.com"); + builder.Answers.AddAddress("www.example3.com", 3600, address); + builder.Answers.AddCname(hostName, 3600, "www.example2.com"); + return Task.CompletedTask; + }); + + AddressResult[] results = await Resolver.ResolveIPAddressesAsync(hostName, AddressFamily.InterNetwork); + + AddressResult res = Assert.Single(results); + Assert.Equal(address, res.Address); + Assert.Equal(TimeProvider.GetUtcNow().DateTime.AddSeconds(3600), res.ExpiresAt); + } + + [Fact] + public async Task ResolveIPv4_Aliases_Loop_ReturnsEmpty() + { + string hostName = "alias-loop2.test"; + + _ = DnsServer.ProcessUdpRequest(builder => + { + builder.Answers.AddCname(hostName, 3600, "www.example2.com"); + builder.Answers.AddCname("www.example2.com", 3600, "www.example3.com"); + builder.Answers.AddCname("www.example3.com", 3600, hostName); + return Task.CompletedTask; + }); + + AddressResult[] results = await Resolver.ResolveIPAddressesAsync(hostName, AddressFamily.InterNetwork); + + Assert.Empty(results); + } + + [Fact] + public async Task ResolveIPv4_Aliases_Loop_Reverse_ReturnsEmpty() + { + string hostName = "alias-loop2.test"; + + _ = DnsServer.ProcessUdpRequest(builder => + { + builder.Answers.AddCname("www.example3.com", 3600, hostName); + builder.Answers.AddCname("www.example2.com", 3600, "www.example3.com"); + builder.Answers.AddCname(hostName, 3600, "www.example2.com"); + return Task.CompletedTask; + }); + + AddressResult[] results = await Resolver.ResolveIPAddressesAsync(hostName, AddressFamily.InterNetwork); + + Assert.Empty(results); + } + + [Fact] + public async Task ResolveIPv4_Alias_And_Address() + { + IPAddress address = IPAddress.Parse("172.213.245.111"); + string hostName = "alias-address.test"; + + _ = DnsServer.ProcessUdpRequest(builder => + { + builder.Answers.AddCname(hostName, 3600, "www.example2.com"); + builder.Answers.AddCname("www.example2.com", 3600, "www.example3.com"); + builder.Answers.AddAddress("www.example2.com", 3600, address); + return Task.CompletedTask; + }); + + AddressResult[] results = await Resolver.ResolveIPAddressesAsync(hostName, AddressFamily.InterNetwork); + + Assert.Empty(results); + } + + [Fact] + public async Task ResolveIPv4_DuplicateAlias() + { + IPAddress address = IPAddress.Parse("172.213.245.111"); + string hostName = "duplicate-alias.test"; + + _ = DnsServer.ProcessUdpRequest(builder => + { + builder.Answers.AddCname(hostName, 3600, "www.example2.com"); + builder.Answers.AddCname("www.example2.com", 3600, "www.example3.com"); + builder.Answers.AddCname("www.example2.com", 3600, "www.example4.com"); + builder.Answers.AddAddress("www.example2.com", 3600, address); + builder.Answers.AddAddress("www.example4.com", 3600, address); + return Task.CompletedTask; + }); + + AddressResult[] results = await Resolver.ResolveIPAddressesAsync(hostName, AddressFamily.InterNetwork); + + Assert.Empty(results); + } + + [Fact] + public async Task ResolveIPv4_Aliases_NotFound_Success() + { + IPAddress address = IPAddress.Parse("172.213.245.111"); + string hostName = "alias-no-found.test"; + + _ = DnsServer.ProcessUdpRequest(builder => + { + builder.Answers.AddCname(hostName, 3600, "www.example2.com"); + builder.Answers.AddCname("www.example2.com", 3600, "www.example3.com"); + + // extra address in the answer not connected to the above + builder.Answers.AddAddress("www.example4.com", 3600, address); + return Task.CompletedTask; + }); + + AddressResult[] results = await Resolver.ResolveIPAddressesAsync(hostName, AddressFamily.InterNetwork); + + Assert.Empty(results); + } + + [Fact] + public async Task ResolveIP_InvalidAddressFamily_Throws() + { + await Assert.ThrowsAsync(async () => await Resolver.ResolveIPAddressesAsync("invalid-af.test", AddressFamily.Unknown)); + } + + [Theory] + [InlineData(AddressFamily.InterNetwork, "127.0.0.1")] + [InlineData(AddressFamily.InterNetworkV6, "::1")] + public async Task ResolveIP_Localhost_ReturnsLoopback(AddressFamily family, string addressAsString) + { + IPAddress address = IPAddress.Parse(addressAsString); + AddressResult[] results = await Resolver.ResolveIPAddressesAsync("localhost", family); + AddressResult result = Assert.Single(results); + + Assert.Equal(address, result.Address); + } + + [Fact] + public async Task Resolve_Timeout_ReturnsEmpty() + { + Options.Timeout = TimeSpan.FromSeconds(1); + AddressResult[] result = await Resolver.ResolveIPAddressesAsync("timeout-empty.test", AddressFamily.InterNetwork); + Assert.Empty(result); + } + + [Theory] + [InlineData("not-example.com", (int)QueryType.A, (int)QueryClass.Internet)] + [InlineData("example.com", (int)QueryType.AAAA, (int)QueryClass.Internet)] + [InlineData("example.com", (int)QueryType.A, 0)] + public async Task Resolve_QuestionMismatch_ReturnsEmpty(string name, int type, int @class) + { + Options.Timeout = TimeSpan.FromSeconds(1); + + IPAddress address = IPAddress.Parse("172.213.245.111"); + _ = DnsServer.ProcessUdpRequest(builder => + { + builder.Questions[0] = (name, (QueryType)type, (QueryClass)@class); + builder.Answers.AddAddress("www.example4.com", 3600, address); + return Task.CompletedTask; + }); + + AddressResult[] result = await Resolver.ResolveIPAddressesAsync("example.com", AddressFamily.InterNetwork); + Assert.Empty(result); + } + + [Fact] + public async Task Resolve_HeaderMismatch_Ignores() + { + string name = "header-mismatch.test"; + Options.Timeout = TimeSpan.FromSeconds(5); + + SemaphoreSlim responseSemaphore = new SemaphoreSlim(0, 1); + SemaphoreSlim requestSemaphore = new SemaphoreSlim(0, 1); + + IPEndPoint clientAddress = null!; + + IPAddress address = IPAddress.Parse("172.213.245.111"); + ushort transactionId = 0x1234; + _ = DnsServer.ProcessUdpRequest((builder, clientAddr) => + { + clientAddress = clientAddr; + transactionId = (ushort)(builder.TransactionId + 1); + + builder.Answers.AddAddress(name, 3600, address); + requestSemaphore.Release(); + return responseSemaphore.WaitAsync(); + }); + + ValueTask task = Resolver.ResolveIPAddressesAsync(name, AddressFamily.InterNetwork); + + await requestSemaphore.WaitAsync().WaitAsync(Options.Timeout); + + using Socket socket = new Socket(clientAddress.AddressFamily, SocketType.Dgram, ProtocolType.Udp); + LoopbackDnsResponseBuilder responseBuilder = new LoopbackDnsResponseBuilder(name, QueryType.A, QueryClass.Internet) + { + TransactionId = transactionId, + ResponseCode = QueryResponseCode.NoError + }; + + responseBuilder.Questions.Add((name, QueryType.A, QueryClass.Internet)); + responseBuilder.Answers.AddAddress(name, 3600, IPAddress.Loopback); + socket.SendTo(responseBuilder.GetMessageBytes(), clientAddress); + + responseSemaphore.Release(); + + AddressResult[] results = await task; + AddressResult result = Assert.Single(results); + + Assert.Equal(address, result.Address); + } +} diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/ResolveServiceTests.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/ResolveServiceTests.cs new file mode 100644 index 00000000000..82ca3175789 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/ResolveServiceTests.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.Net; +using Xunit.Abstractions; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver.Tests; + +public class ResolveServiceTests : LoopbackDnsTestBase +{ + public ResolveServiceTests(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public async Task ResolveService_Simple_Success() + { + IPAddress address = IPAddress.Parse("172.213.245.111"); + _ = DnsServer.ProcessUdpRequest(builder => + { + builder.Answers.AddService("_s0._tcp.example.com", 3600, 1, 2, 8080, "www.example.com"); + builder.Additionals.AddAddress("www.example.com", 3600, address); + return Task.CompletedTask; + }); + + ServiceResult[] results = await Resolver.ResolveServiceAsync("_s0._tcp.example.com"); + + ServiceResult result = Assert.Single(results); + Assert.Equal("www.example.com", result.Target); + Assert.Equal(1, result.Priority); + Assert.Equal(2, result.Weight); + Assert.Equal(8080, result.Port); + + AddressResult addressResult = Assert.Single(result.Addresses); + Assert.Equal(address, addressResult.Address); + } +} diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/RetryTests.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/RetryTests.cs new file mode 100644 index 00000000000..3d6f3724484 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/RetryTests.cs @@ -0,0 +1,309 @@ +// 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; +using System.Net.Sockets; +using Xunit.Abstractions; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver.Tests; + +public class RetryTests : LoopbackDnsTestBase +{ + public RetryTests(ITestOutputHelper output) : base(output) + { + Options.MaxAttempts = 3; + } + + private Task SetupUdpProcessFunction(LoopbackDnsServer server, Func func) + { + return Task.Run(async () => + { + try + { + while (true) + { + await server.ProcessUdpRequest(func); + } + } + catch (Exception ex) + { + Output.WriteLine($"UDP server stopped with exception: {ex}"); + // Test teardown closed the socket, ignore + } + }); + } + + private Task SetupUdpProcessFunction(Func func) + { + return SetupUdpProcessFunction(DnsServer, func); + } + + [Fact] + public async Task Retry_Simple_Success() + { + IPAddress address = IPAddress.Parse("172.213.245.111"); + string hostName = "retry-simple-success.com"; + + int attempt = 0; + + Task t = SetupUdpProcessFunction(builder => + { + attempt++; + if (attempt == Options.MaxAttempts) + { + builder.Answers.AddAddress(hostName, 3600, address); + } + else + { + builder.ResponseCode = QueryResponseCode.ServerFailure; + } + return Task.CompletedTask; + }); + + AddressResult[] results = await Resolver.ResolveIPAddressesAsync(hostName, AddressFamily.InterNetwork); + + AddressResult res = Assert.Single(results); + Assert.Equal(address, res.Address); + Assert.Equal(TimeProvider.GetUtcNow().DateTime.AddSeconds(3600), res.ExpiresAt); + } + + public enum PersistentErrorType + { + NotImplemented, + Refused, + MalformedResponse + } + + [Theory] + [InlineData(PersistentErrorType.NotImplemented)] + [InlineData(PersistentErrorType.Refused)] + [InlineData(PersistentErrorType.MalformedResponse)] + public async Task PersistentErrorsResponseCode_FailoverToNextServer(PersistentErrorType type) + { + IPAddress address = IPAddress.Parse("172.213.245.111"); + string hostName = "www.persistent.com"; + + int primaryAttempt = 0; + int secondaryAttempt = 0; + + AddressResult[] results = await RunWithFallbackServerHelper(hostName, + builder => + { + primaryAttempt++; + switch (type) + { + case PersistentErrorType.NotImplemented: + builder.ResponseCode = QueryResponseCode.NotImplemented; + break; + + case PersistentErrorType.Refused: + builder.ResponseCode = QueryResponseCode.Refused; + break; + + case PersistentErrorType.MalformedResponse: + builder.ResponseCode = (QueryResponseCode)0xFF; + break; + } + return Task.CompletedTask; + }, + builder => + { + secondaryAttempt++; + builder.Answers.AddAddress(hostName, 3600, address); + return Task.CompletedTask; + }); + + Assert.Equal(1, primaryAttempt); + Assert.Equal(1, secondaryAttempt); + + AddressResult res = Assert.Single(results); + Assert.Equal(address, res.Address); + Assert.Equal(TimeProvider.GetUtcNow().DateTime.AddSeconds(3600), res.ExpiresAt); + } + + public enum DefinitveAnswerType + { + NoError, + NoData, + NameError, + } + + [Theory] + [InlineData(DefinitveAnswerType.NoError, false)] + [InlineData(DefinitveAnswerType.NoData, false)] + [InlineData(DefinitveAnswerType.NoData, true)] + [InlineData(DefinitveAnswerType.NameError, false)] + [InlineData(DefinitveAnswerType.NameError, true)] + public async Task DefinitiveAnswers_NoRetryOrFailover(DefinitveAnswerType type, bool additionalData) + { + IPAddress address = IPAddress.Parse("172.213.245.111"); + string hostName = "www.retry.com"; + + int primaryAttempt = 0; + int secondaryAttempt = 0; + + AddressResult[] results = await RunWithFallbackServerHelper(hostName, + builder => + { + primaryAttempt++; + switch (type) + { + case DefinitveAnswerType.NoError: + builder.ResponseCode = QueryResponseCode.NoError; + builder.Answers.AddAddress(hostName, 3600, address); + break; + + case DefinitveAnswerType.NoData: + builder.ResponseCode = QueryResponseCode.NoError; + break; + + case DefinitveAnswerType.NameError: + builder.ResponseCode = QueryResponseCode.NameError; + break; + } + + if (additionalData) + { + builder.Authorities.AddStartOfAuthority(hostName, 300, "ns1.example.com", "hostmaster.example.com", 2023101001, 1, 3600, 300, 86400); + } + + return Task.CompletedTask; + }, + builder => + { + secondaryAttempt++; + builder.ResponseCode = QueryResponseCode.Refused; + return Task.CompletedTask; + }); + + Assert.Equal(1, primaryAttempt); + Assert.Equal(0, secondaryAttempt); + + if (type == DefinitveAnswerType.NoError) + { + AddressResult res = Assert.Single(results); + Assert.Equal(address, res.Address); + Assert.Equal(TimeProvider.GetUtcNow().DateTime.AddSeconds(3600), res.ExpiresAt); + } + else + { + Assert.Empty(results); + } + } + + [Fact] + public async Task ExhaustedRetries_FailoverToNextServer() + { + IPAddress address = IPAddress.Parse("172.213.245.111"); + string hostName = "ExhaustedRetriesFailoverToNextServer"; + + int primaryAttempt = 0; + int secondaryAttempt = 0; + + AddressResult[] results = await RunWithFallbackServerHelper(hostName, + builder => + { + primaryAttempt++; + builder.ResponseCode = QueryResponseCode.ServerFailure; + return Task.CompletedTask; + }, + builder => + { + secondaryAttempt++; + builder.Answers.AddAddress(hostName, 3600, address); + return Task.CompletedTask; + }); + + Assert.Equal(Options.MaxAttempts, primaryAttempt); + Assert.Equal(1, secondaryAttempt); + + AddressResult res = Assert.Single(results); + Assert.Equal(address, res.Address); + Assert.Equal(TimeProvider.GetUtcNow().DateTime.AddSeconds(3600), res.ExpiresAt); + } + + public enum TransientErrorType + { + Timeout, + ServerFailure, + // TODO: simulate NetworkErrors + } + + [Theory] + [InlineData(TransientErrorType.Timeout)] + [InlineData(TransientErrorType.ServerFailure)] + public async Task TransientError_RetryOnSameServer(TransientErrorType type) + { + IPAddress address = IPAddress.Parse("172.213.245.111"); + string hostName = "www.transient.com"; + + int primaryAttempt = 0; + int secondaryAttempt = 0; + + AddressResult[] results = await RunWithFallbackServerHelper(hostName, + async builder => + { + primaryAttempt++; + if (primaryAttempt == 1) + { + switch (type) + { + case TransientErrorType.Timeout: + await Task.Delay(Options.Timeout.Multiply(1.5)); + builder.Answers.AddAddress(hostName, 3600, address); + break; + + case TransientErrorType.ServerFailure: + builder.ResponseCode = QueryResponseCode.ServerFailure; + break; + } + } + else + { + builder.Answers.AddAddress(hostName, 3600, address); + } + }, + builder => + { + secondaryAttempt++; + builder.ResponseCode = QueryResponseCode.Refused; + return Task.CompletedTask; + }); + + Assert.Equal(2, primaryAttempt); + Assert.Equal(0, secondaryAttempt); + + AddressResult res = Assert.Single(results); + Assert.Equal(address, res.Address); + Assert.Equal(TimeProvider.GetUtcNow().DateTime.AddSeconds(3600), res.ExpiresAt); + } + + private async Task RunWithFallbackServerHelper(string name, Func primaryHandler, Func fallbackHandler) + { + Task t = SetupUdpProcessFunction(primaryHandler); + using LoopbackDnsServer fallbackServer = new LoopbackDnsServer(); + Task t2 = SetupUdpProcessFunction(fallbackServer, fallbackHandler); + + Options.Servers = [DnsServer.DnsEndPoint, fallbackServer.DnsEndPoint]; + + return await Resolver.ResolveIPAddressesAsync(name, AddressFamily.InterNetwork); + } + + [Fact] + public async Task NameError_NoRetry() + { + int counter = 0; + Task t = SetupUdpProcessFunction(builder => + { + counter++; + // authoritative answer that the name does not exist + builder.ResponseCode = QueryResponseCode.NameError; + return Task.CompletedTask; + }); + + AddressResult[] results = await Resolver.ResolveIPAddressesAsync("nameerror-noretry", AddressFamily.InterNetwork); + + Assert.Empty(results); + Assert.Equal(1, counter); + } +} diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/TcpFailoverTests.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/TcpFailoverTests.cs new file mode 100644 index 00000000000..b2891cfb512 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/Resolver/TcpFailoverTests.cs @@ -0,0 +1,132 @@ +// 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; +using System.Net.Sockets; +using Xunit.Abstractions; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Resolver.Tests; + +public class TcpFailoverTests : LoopbackDnsTestBase +{ + public TcpFailoverTests(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public async Task TcpFailover_Simple_Success() + { + string hostName = "tcp-simple.test"; + IPAddress address = IPAddress.Parse("172.213.245.111"); + + _ = DnsServer.ProcessUdpRequest(builder => + { + builder.Flags |= QueryFlags.ResultTruncated; + return Task.CompletedTask; + }); + + _ = DnsServer.ProcessTcpRequest(builder => + { + builder.Answers.AddAddress(hostName, 3600, address); + return Task.CompletedTask; + }); + + AddressResult[] results = await Resolver.ResolveIPAddressesAsync(hostName, AddressFamily.InterNetwork); + + AddressResult res = Assert.Single(results); + Assert.Equal(address, res.Address); + Assert.Equal(TimeProvider.GetUtcNow().DateTime.AddSeconds(3600), res.ExpiresAt); + } + + [Fact] + public async Task TcpFailover_ServerClosesWithoutData_EmptyResult() + { + string hostName = "tcp-server-closes.test"; + Options.MaxAttempts = 1; + Options.Timeout = TimeSpan.FromSeconds(60); + + _ = DnsServer.ProcessUdpRequest(builder => + { + builder.Flags |= QueryFlags.ResultTruncated; + return Task.CompletedTask; + }); + + Task serverTask = DnsServer.ProcessTcpRequest(builder => + { + throw new InvalidOperationException("This forces closing the socket without writing any data"); + }); + + AddressResult[] results = await Resolver.ResolveIPAddressesAsync(hostName, AddressFamily.InterNetwork).AsTask().WaitAsync(TimeSpan.FromSeconds(10)); + Assert.Empty(results); + + await Assert.ThrowsAsync(() => serverTask); + } + + [Fact] + public async Task TcpFailover_TcpNotAvailable_EmptyResult() + { + string hostName = "tcp-not-available.test"; + Options.MaxAttempts = 1; + Options.Timeout = TimeSpan.FromMilliseconds(100000); + + _ = DnsServer.ProcessUdpRequest(builder => + { + builder.Flags |= QueryFlags.ResultTruncated; + return Task.CompletedTask; + }); + + AddressResult[] results = await Resolver.ResolveIPAddressesAsync(hostName, AddressFamily.InterNetwork); + Assert.Empty(results); + } + + [Fact] + public async Task TcpFailover_HeaderMismatch_ReturnsEmpty() + { + string hostName = "tcp-header-mismatch.test"; + Options.Timeout = TimeSpan.FromSeconds(1); + IPAddress address = IPAddress.Parse("172.213.245.111"); + + _ = DnsServer.ProcessUdpRequest(builder => + { + builder.Flags |= QueryFlags.ResultTruncated; + return Task.CompletedTask; + }); + + _ = DnsServer.ProcessTcpRequest(builder => + { + builder.TransactionId++; + builder.Answers.AddAddress(hostName, 3600, address); + return Task.CompletedTask; + }); + + AddressResult[] result = await Resolver.ResolveIPAddressesAsync(hostName, AddressFamily.InterNetwork); + Assert.Empty(result); + } + + [Theory] + [InlineData("not-example.com", (int)QueryType.A, (int)QueryClass.Internet)] + [InlineData("example.com", (int)QueryType.AAAA, (int)QueryClass.Internet)] + [InlineData("example.com", (int)QueryType.A, 0)] + public async Task TcpFailover_QuestionMismatch_ReturnsEmpty(string name, int type, int @class) + { + string hostName = "tcp-question-mismatch.test"; + Options.Timeout = TimeSpan.FromSeconds(1); + IPAddress address = IPAddress.Parse("172.213.245.111"); + + _ = DnsServer.ProcessUdpRequest(builder => + { + builder.Flags |= QueryFlags.ResultTruncated; + return Task.CompletedTask; + }); + + _ = DnsServer.ProcessTcpRequest(builder => + { + builder.Questions[0] = (name, (QueryType)type, (QueryClass)@class); + builder.Answers.AddAddress(hostName, 3600, address); + return Task.CompletedTask; + }); + + AddressResult[] result = await Resolver.ResolveIPAddressesAsync(hostName, AddressFamily.InterNetwork); + Assert.Empty(result); + } +} diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/ServiceDiscoveryDnsServiceCollectionExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/ServiceDiscoveryDnsServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000000..3631c2e8085 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/ServiceDiscoveryDnsServiceCollectionExtensionsTests.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.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Tests; + +public class ServiceDiscoveryDnsServiceCollectionExtensionsTests +{ + [Fact] + public void AddDnsServiceEndpointProviderShouldRegisterDependentServices() + { + var services = new ServiceCollection(); + services.AddDnsServiceEndpointProvider(); + + using var serviceProvider = services.BuildServiceProvider(true); + + var exception = Record.Exception(() => serviceProvider.GetServices()); + Assert.Null(exception); + } + + [Fact] + public void AddDnsSrvServiceEndpointProviderShouldRegisterDependentServices() + { + var services = new ServiceCollection(); + services.AddDnsSrvServiceEndpointProvider(); + + using var serviceProvider = services.BuildServiceProvider(true); + + var exception = Record.Exception(() => serviceProvider.GetServices()); + Assert.Null(exception); + } + + [Fact] + public void ConfigureDnsResolverShouldThrowWhenServersIsNull() + { + var services = new ServiceCollection(); + services.ConfigureDnsResolver(options => options.Servers = null!); + + using var serviceProvider = services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>(); + + var exception = Assert.Throws(() => options.Value); + Assert.Equal("Servers must not be null.", exception.Message); + } + + [Fact] + public void ConfigureDnsResolverShouldThrowWhenMaxAttemptsIsZero() + { + var services = new ServiceCollection(); + services.ConfigureDnsResolver(options => options.MaxAttempts = 0); + + using var serviceProvider = services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>(); + + var exception = Assert.Throws(() => options.Value); + Assert.Equal("MaxAttempts must be one or greater.", exception.Message); + } + + [Fact] + public void ConfigureDnsResolverShouldThrowWhenTimeoutIsZero() + { + var services = new ServiceCollection(); + services.ConfigureDnsResolver(options => options.Timeout = TimeSpan.Zero); + + using var serviceProvider = services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>(); + + var exception = Assert.Throws(() => options.Value); + Assert.Equal("Timeout must not be negative or zero.", exception.Message); + } + + [Fact] + public void ConfigureDnsResolverShouldThrowWhenTimeoutExceedsMaximum() + { + var services = new ServiceCollection(); + services.ConfigureDnsResolver(options => options.Timeout = TimeSpan.FromMilliseconds(1L + int.MaxValue)); + + using var serviceProvider = services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>(); + + var exception = Assert.Throws(() => options.Value); + Assert.Equal("Timeout must not be greater than 2147483647 milliseconds.", exception.Message); + } +} diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/XunitLoggerFactoryExtensions.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/XunitLoggerFactoryExtensions.cs new file mode 100644 index 00000000000..6667688f16e --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Dns.Tests/XunitLoggerFactoryExtensions.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; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit.Abstractions; + +namespace Microsoft.Extensions.ServiceDiscovery.Dns.Tests; + +internal static class XunitLoggerFactoryExtensions +{ + public static ILoggingBuilder AddXunit(this ILoggingBuilder builder, ITestOutputHelper output) + { + builder.Services.AddSingleton(new XunitLoggerProvider(output)); + return builder; + } + + public static IServiceCollection AddXunitLogging(this IServiceCollection services, ITestOutputHelper output) => + services.AddLogging(b => b.AddXunit(output)); +} + +internal class XunitLoggerProvider : ILoggerProvider +{ + private readonly ITestOutputHelper _output; + private readonly LogLevel _minLevel; + private readonly DateTimeOffset? _logStart; + + public XunitLoggerProvider(ITestOutputHelper output) + : this(output, LogLevel.Trace) + { + } + + public XunitLoggerProvider(ITestOutputHelper output, LogLevel minLevel) + : this(output, minLevel, null) + { + } + + public XunitLoggerProvider(ITestOutputHelper output, LogLevel minLevel, DateTimeOffset? logStart) + { + _output = output; + _minLevel = minLevel; + _logStart = logStart; + } + + public ILogger CreateLogger(string categoryName) + { + return new XunitLogger(_output, categoryName, _minLevel, _logStart); + } + + public void Dispose() + { + } +} + +internal class XunitLogger : ILogger +{ + private static readonly string[] s_newLineChars = new[] { Environment.NewLine }; + private readonly string _category; + private readonly LogLevel _minLogLevel; + private readonly ITestOutputHelper _output; + private readonly DateTimeOffset? _logStart; + + public XunitLogger(ITestOutputHelper output, string category, LogLevel minLogLevel, DateTimeOffset? logStart) + { + _minLogLevel = minLogLevel; + _category = category; + _output = output; + _logStart = logStart; + } + + public void Log( + LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (!IsEnabled(logLevel)) + { + return; + } + + // Buffer the message into a single string in order to avoid shearing the message when running across multiple threads. + var messageBuilder = new StringBuilder(); + + var timestamp = _logStart.HasValue ? + $"{(DateTimeOffset.UtcNow - _logStart.Value).TotalSeconds.ToString("N3", CultureInfo.InvariantCulture)}s" : + DateTimeOffset.UtcNow.ToString("s", CultureInfo.InvariantCulture); + + var firstLinePrefix = $"| [{timestamp}] {_category} {logLevel}: "; + var lines = formatter(state, exception).Split(s_newLineChars, StringSplitOptions.RemoveEmptyEntries); + messageBuilder.AppendLine(firstLinePrefix + lines.FirstOrDefault() ?? string.Empty); + + var additionalLinePrefix = "|" + new string(' ', firstLinePrefix.Length - 1); + foreach (var line in lines.Skip(1)) + { + messageBuilder.AppendLine(additionalLinePrefix + line); + } + + if (exception != null) + { + lines = exception.ToString().Split(s_newLineChars, StringSplitOptions.RemoveEmptyEntries); + additionalLinePrefix = "| "; + foreach (var line in lines) + { + messageBuilder.AppendLine(additionalLinePrefix + line); + } + } + + // Remove the last line-break, because ITestOutputHelper only has WriteLine. + var message = messageBuilder.ToString(); + if (message.EndsWith(Environment.NewLine, StringComparison.Ordinal)) + { + message = message.Substring(0, message.Length - Environment.NewLine.Length); + } + + try + { + _output.WriteLine(message); + } + catch (Exception) + { + // We could fail because we're on a background thread and our captured ITestOutputHelper is + // busted (if the test "completed" before the background thread fired). + // So, ignore this. There isn't really anything we can do but hope the + // caller has additional loggers registered + } + } + + public bool IsEnabled(LogLevel logLevel) + => logLevel >= _minLogLevel; + + public IDisposable BeginScope(TState state) where TState : notnull + => new NullScope(); + + private sealed class NullScope : IDisposable + { + public void Dispose() + { + } + } +} + diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Tests/ConfigurationServiceEndpointResolverTests.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Tests/ConfigurationServiceEndpointResolverTests.cs new file mode 100644 index 00000000000..9fc8832fa68 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Tests/ConfigurationServiceEndpointResolverTests.cs @@ -0,0 +1,430 @@ +// 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; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.ServiceDiscovery.Configuration; +using Microsoft.Extensions.ServiceDiscovery.Internal; +using Xunit; + +namespace Microsoft.Extensions.ServiceDiscovery.Tests; + +/// +/// Tests for . +/// These also cover and by extension. +/// +public class ConfigurationServiceEndpointResolverTests +{ + [Fact] + public async Task ResolveServiceEndpoint_Configuration_SingleResult_NoScheme() + { + var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["services:basket:http"] = "localhost:8080", + }); + var services = new ServiceCollection() + .AddSingleton(config.Build()) + .AddServiceDiscoveryCore() + .AddConfigurationServiceEndpointProvider() + .BuildServiceProvider(); + var watcherFactory = services.GetRequiredService(); + ServiceEndpointWatcher watcher; + await using ((watcher = watcherFactory.CreateWatcher("http://basket")).ConfigureAwait(false)) + { + Assert.NotNull(watcher); + var tcs = new TaskCompletionSource(); + watcher.OnEndpointsUpdated = tcs.SetResult; + watcher.Start(); + var initialResult = await tcs.Task; + Assert.NotNull(initialResult); + Assert.True(initialResult.ResolvedSuccessfully); + var ep = Assert.Single(initialResult.EndpointSource.Endpoints); + Assert.Equal(new DnsEndPoint("localhost", 8080), ep.EndPoint); + + Assert.All(initialResult.EndpointSource.Endpoints, ep => + { + var hostNameFeature = ep.Features.Get(); + Assert.Null(hostNameFeature); + }); + } + } + + [Fact] + public async Task ResolveServiceEndpoint_Configuration_DisallowedScheme() + { + // Try to resolve an http endpoint when only https is allowed. + var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["services:basket:foo:0"] = "http://localhost:8080", + ["services:basket:foo:1"] = "https://localhost", + }); + var services = new ServiceCollection() + .AddSingleton(config.Build()) + .AddServiceDiscoveryCore() + .AddConfigurationServiceEndpointProvider() + .Configure(o => + { + o.AllowAllSchemes = false; + o.AllowedSchemes = ["https"]; + }) + .BuildServiceProvider(); + var watcherFactory = services.GetRequiredService(); + ServiceEndpointWatcher watcher; + + // Explicitly specifying http. + // We should get no endpoint back because http is not allowed by configuration. + await using ((watcher = watcherFactory.CreateWatcher("http://_foo.basket")).ConfigureAwait(false)) + { + Assert.NotNull(watcher); + var tcs = new TaskCompletionSource(); + watcher.OnEndpointsUpdated = tcs.SetResult; + watcher.Start(); + var initialResult = await tcs.Task; + Assert.NotNull(initialResult); + Assert.True(initialResult.ResolvedSuccessfully); + Assert.Empty(initialResult.EndpointSource.Endpoints); + } + + // Specifying no scheme. + // We should get the HTTPS endpoint back, since it is explicitly allowed + await using ((watcher = watcherFactory.CreateWatcher("_foo.basket")).ConfigureAwait(false)) + { + Assert.NotNull(watcher); + var tcs = new TaskCompletionSource(); + watcher.OnEndpointsUpdated = tcs.SetResult; + watcher.Start(); + var initialResult = await tcs.Task; + Assert.NotNull(initialResult); + Assert.True(initialResult.ResolvedSuccessfully); + var ep = Assert.Single(initialResult.EndpointSource.Endpoints); + Assert.Equal(new UriEndPoint(new Uri("https://localhost")), ep.EndPoint); + } + + // Specifying either https or http. + // We should only get the https endpoint back. + await using ((watcher = watcherFactory.CreateWatcher("https+http://_foo.basket")).ConfigureAwait(false)) + { + Assert.NotNull(watcher); + var tcs = new TaskCompletionSource(); + watcher.OnEndpointsUpdated = tcs.SetResult; + watcher.Start(); + var initialResult = await tcs.Task; + Assert.NotNull(initialResult); + Assert.True(initialResult.ResolvedSuccessfully); + var ep = Assert.Single(initialResult.EndpointSource.Endpoints); + Assert.Equal(new UriEndPoint(new Uri("https://localhost")), ep.EndPoint); + } + + // Specifying either https or http, but in reverse. + // We should only get the https endpoint back. + await using ((watcher = watcherFactory.CreateWatcher("http+https://_foo.basket")).ConfigureAwait(false)) + { + Assert.NotNull(watcher); + var tcs = new TaskCompletionSource(); + watcher.OnEndpointsUpdated = tcs.SetResult; + watcher.Start(); + var initialResult = await tcs.Task; + Assert.NotNull(initialResult); + Assert.True(initialResult.ResolvedSuccessfully); + var ep = Assert.Single(initialResult.EndpointSource.Endpoints); + Assert.Equal(new UriEndPoint(new Uri("https://localhost")), ep.EndPoint); + } + } + + [Fact] + public async Task ResolveServiceEndpoint_Configuration_DefaultEndpointName() + { + var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["services:basket:default:0"] = "https://localhost:8080", + ["services:basket:otlp:0"] = "https://localhost:8888", + }); + var services = new ServiceCollection() + .AddSingleton(config.Build()) + .AddServiceDiscoveryCore() + .AddConfigurationServiceEndpointProvider(o => + { + o.ShouldApplyHostNameMetadata = _ => true; + }) + .Configure(o => + { + o.AllowAllSchemes = false; + o.AllowedSchemes = ["https"]; + }) + .BuildServiceProvider(); + var watcherFactory = services.GetRequiredService(); + ServiceEndpointWatcher watcher; + + // Explicitly specifying https as the scheme, but the endpoint section in configuration is the default value ("default"). + // We should get the endpoint back because it is an https endpoint (allowed) with the default endpoint name. + await using ((watcher = watcherFactory.CreateWatcher("https://basket")).ConfigureAwait(false)) + { + Assert.NotNull(watcher); + var tcs = new TaskCompletionSource(); + watcher.OnEndpointsUpdated = tcs.SetResult; + watcher.Start(); + var initialResult = await tcs.Task; + Assert.NotNull(initialResult); + Assert.True(initialResult.ResolvedSuccessfully); + Assert.Single(initialResult.EndpointSource.Endpoints); + Assert.Equal(new UriEndPoint(new Uri("https://localhost:8080")), initialResult.EndpointSource.Endpoints[0].EndPoint); + + Assert.All(initialResult.EndpointSource.Endpoints, ep => + { + var hostNameFeature = ep.Features.Get(); + Assert.NotNull(hostNameFeature); + Assert.Equal("basket", hostNameFeature.HostName); + }); + } + + // Not specifying the scheme or endpoint name. + // We should get the endpoint back because it is an https endpoint (allowed) with the default endpoint name. + await using ((watcher = watcherFactory.CreateWatcher("basket")).ConfigureAwait(false)) + { + Assert.NotNull(watcher); + var tcs = new TaskCompletionSource(); + watcher.OnEndpointsUpdated = tcs.SetResult; + watcher.Start(); + var initialResult = await tcs.Task; + Assert.NotNull(initialResult); + Assert.True(initialResult.ResolvedSuccessfully); + Assert.Single(initialResult.EndpointSource.Endpoints); + Assert.Equal(new UriEndPoint(new Uri("https://localhost:8080")), initialResult.EndpointSource.Endpoints[0].EndPoint); + } + + // Not specifying the scheme, but specifying the default endpoint name. + // We should get the endpoint back because it is an https endpoint (allowed) with the default endpoint name. + await using ((watcher = watcherFactory.CreateWatcher("_default.basket")).ConfigureAwait(false)) + { + Assert.NotNull(watcher); + var tcs = new TaskCompletionSource(); + watcher.OnEndpointsUpdated = tcs.SetResult; + watcher.Start(); + var initialResult = await tcs.Task; + Assert.NotNull(initialResult); + Assert.True(initialResult.ResolvedSuccessfully); + Assert.Single(initialResult.EndpointSource.Endpoints); + Assert.Equal(new UriEndPoint(new Uri("https://localhost:8080")), initialResult.EndpointSource.Endpoints[0].EndPoint); + } + } + + /// + /// Checks that when there is no named endpoint, configuration resolves first from the "default" section, then sections named by the scheme names. + /// + [Theory] + [InlineData(true, true, "https://basket", "https://default-host:8080")] + [InlineData(false, true, "https://basket","https://https-host:8080")] + [InlineData(true, false, "https://basket", "https://default-host:8080")] + [InlineData(true, true, "basket", "https://default-host:8080")] + [InlineData(false, true, "basket", null)] + [InlineData(true, false, "basket", "https://default-host:8080")] + [InlineData(true, true, "http+https://basket", "https://default-host:8080")] + [InlineData(false, true, "http+https://basket","https://https-host:8080")] + [InlineData(true, false, "http+https://basket", "https://default-host:8080")] + public async Task ResolveServiceEndpoint_Configuration_DefaultEndpointName_ResolutionOrder( + bool includeDefault, + bool includeSchemeNamed, + string serviceName, + string? expectedResult) + { + var data = new Dictionary(); + if (includeDefault) + { + data["services:basket:default:0"] = "https://default-host:8080"; + } + + if (includeSchemeNamed) + { + data["services:basket:https:0"] = "https://https-host:8080"; + } + + var config = new ConfigurationBuilder().AddInMemoryCollection(data); + var services = new ServiceCollection() + .AddSingleton(config.Build()) + .AddServiceDiscoveryCore() + .AddConfigurationServiceEndpointProvider() + .BuildServiceProvider(); + var watcherFactory = services.GetRequiredService(); + ServiceEndpointWatcher watcher; + + // Scheme in query + await using ((watcher = watcherFactory.CreateWatcher(serviceName)).ConfigureAwait(false)) + { + Assert.NotNull(watcher); + var tcs = new TaskCompletionSource(); + watcher.OnEndpointsUpdated = tcs.SetResult; + watcher.Start(); + var initialResult = await tcs.Task; + Assert.NotNull(initialResult); + Assert.True(initialResult.ResolvedSuccessfully); + if (expectedResult is not null) + { + Assert.Single(initialResult.EndpointSource.Endpoints); + Assert.Equal(new UriEndPoint(new Uri(expectedResult)), initialResult.EndpointSource.Endpoints[0].EndPoint); + } + else + { + Assert.Empty(initialResult.EndpointSource.Endpoints); + } + } + } + + [Fact] + public async Task ResolveServiceEndpoint_Configuration_MultipleResults() + { + var configSource = new MemoryConfigurationSource + { + InitialData = new Dictionary + { + ["services:basket:default:0"] = "http://localhost:8080", + ["services:basket:default:1"] = "http://remotehost:9090", + } + }; + var config = new ConfigurationBuilder().Add(configSource); + var services = new ServiceCollection() + .AddSingleton(config.Build()) + .AddServiceDiscoveryCore() + .AddConfigurationServiceEndpointProvider(options => options.ShouldApplyHostNameMetadata = _ => true) + .BuildServiceProvider(); + var watcherFactory = services.GetRequiredService(); + ServiceEndpointWatcher watcher; + await using ((watcher = watcherFactory.CreateWatcher("http://basket")).ConfigureAwait(false)) + { + Assert.NotNull(watcher); + var tcs = new TaskCompletionSource(); + watcher.OnEndpointsUpdated = tcs.SetResult; + watcher.Start(); + var initialResult = await tcs.Task; + Assert.NotNull(initialResult); + Assert.True(initialResult.ResolvedSuccessfully); + Assert.Equal(2, initialResult.EndpointSource.Endpoints.Count); + Assert.Equal(new UriEndPoint(new Uri("http://localhost:8080")), initialResult.EndpointSource.Endpoints[0].EndPoint); + Assert.Equal(new UriEndPoint(new Uri("http://remotehost:9090")), initialResult.EndpointSource.Endpoints[1].EndPoint); + + Assert.All(initialResult.EndpointSource.Endpoints, ep => + { + var hostNameFeature = ep.Features.Get(); + Assert.NotNull(hostNameFeature); + Assert.Equal("basket", hostNameFeature.HostName); + }); + } + + // Request either https or http. Since there are only http endpoints, we should get only http endpoints back. + await using ((watcher = watcherFactory.CreateWatcher("https+http://basket")).ConfigureAwait(false)) + { + Assert.NotNull(watcher); + var tcs = new TaskCompletionSource(); + watcher.OnEndpointsUpdated = tcs.SetResult; + watcher.Start(); + var initialResult = await tcs.Task; + Assert.NotNull(initialResult); + Assert.True(initialResult.ResolvedSuccessfully); + Assert.Equal(2, initialResult.EndpointSource.Endpoints.Count); + Assert.Equal(new UriEndPoint(new Uri("http://localhost:8080")), initialResult.EndpointSource.Endpoints[0].EndPoint); + Assert.Equal(new UriEndPoint(new Uri("http://remotehost:9090")), initialResult.EndpointSource.Endpoints[1].EndPoint); + + Assert.All(initialResult.EndpointSource.Endpoints, ep => + { + var hostNameFeature = ep.Features.Get(); + Assert.NotNull(hostNameFeature); + Assert.Equal("basket", hostNameFeature.HostName); + }); + } + } + + [Fact] + public async Task ResolveServiceEndpoint_Configuration_MultipleProtocols() + { + var configSource = new MemoryConfigurationSource + { + InitialData = new Dictionary + { + ["services:basket:http:0"] = "http://localhost:8080", + ["services:basket:https:1"] = "https://remotehost:9090", + ["services:basket:grpc:0"] = "localhost:2222", + ["services:basket:grpc:1"] = "127.0.0.1:3333", + ["services:basket:grpc:2"] = "http://remotehost:4444", + ["services:basket:grpc:3"] = "https://remotehost:5555", + } + }; + var config = new ConfigurationBuilder().Add(configSource); + var services = new ServiceCollection() + .AddSingleton(config.Build()) + .AddServiceDiscoveryCore() + .AddConfigurationServiceEndpointProvider() + .BuildServiceProvider(); + var watcherFactory = services.GetRequiredService(); + ServiceEndpointWatcher watcher; + await using ((watcher = watcherFactory.CreateWatcher("http://_grpc.basket")).ConfigureAwait(false)) + { + Assert.NotNull(watcher); + var tcs = new TaskCompletionSource(); + watcher.OnEndpointsUpdated = tcs.SetResult; + watcher.Start(); + var initialResult = await tcs.Task; + Assert.NotNull(initialResult); + Assert.True(initialResult.ResolvedSuccessfully); + Assert.Equal(3, initialResult.EndpointSource.Endpoints.Count); + Assert.Equal(new DnsEndPoint("localhost", 2222), initialResult.EndpointSource.Endpoints[0].EndPoint); + Assert.Equal(new IPEndPoint(IPAddress.Loopback, 3333), initialResult.EndpointSource.Endpoints[1].EndPoint); + Assert.Equal(new UriEndPoint(new Uri("http://remotehost:4444")), initialResult.EndpointSource.Endpoints[2].EndPoint); + + Assert.All(initialResult.EndpointSource.Endpoints, ep => + { + var hostNameFeature = ep.Features.Get(); + Assert.Null(hostNameFeature); + }); + } + } + + [Fact] + public async Task ResolveServiceEndpoint_Configuration_MultipleProtocols_WithSpecificationByConsumer() + { + var configSource = new MemoryConfigurationSource + { + InitialData = new Dictionary + { + ["services:basket:default:0"] = "http://localhost:8080", + ["services:basket:default:1"] = "remotehost:9090", + ["services:basket:grpc:0"] = "localhost:2222", + ["services:basket:grpc:1"] = "127.0.0.1:3333", + ["services:basket:grpc:2"] = "http://remotehost:4444", + ["services:basket:grpc:3"] = "https://remotehost:5555", + } + }; + var config = new ConfigurationBuilder().Add(configSource); + var services = new ServiceCollection() + .AddSingleton(config.Build()) + .AddServiceDiscoveryCore() + .AddConfigurationServiceEndpointProvider() + .BuildServiceProvider(); + var watcherFactory = services.GetRequiredService(); + ServiceEndpointWatcher watcher; + await using ((watcher = watcherFactory.CreateWatcher("https+http://_grpc.basket")).ConfigureAwait(false)) + { + Assert.NotNull(watcher); + var tcs = new TaskCompletionSource(); + watcher.OnEndpointsUpdated = tcs.SetResult; + watcher.Start(); + var initialResult = await tcs.Task; + Assert.NotNull(initialResult); + Assert.True(initialResult.ResolvedSuccessfully); + Assert.Equal(3, initialResult.EndpointSource.Endpoints.Count); + + // These must be treated as HTTPS by the HttpClient middleware, but that is not the responsibility of the resolver. + Assert.Equal(new DnsEndPoint("localhost", 2222), initialResult.EndpointSource.Endpoints[0].EndPoint); + Assert.Equal(new IPEndPoint(IPAddress.Loopback, 3333), initialResult.EndpointSource.Endpoints[1].EndPoint); + + // We expect the HTTPS endpoint back but not the HTTP one. + Assert.Equal(new UriEndPoint(new Uri("https://remotehost:5555")), initialResult.EndpointSource.Endpoints[2].EndPoint); + + Assert.All(initialResult.EndpointSource.Endpoints, ep => + { + var hostNameFeature = ep.Features.Get(); + Assert.Null(hostNameFeature); + }); + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Tests/ExtensionsServicePublicApiTests.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Tests/ExtensionsServicePublicApiTests.cs new file mode 100644 index 00000000000..31781cf6722 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Tests/ExtensionsServicePublicApiTests.cs @@ -0,0 +1,218 @@ +// 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; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Primitives; +using Xunit; + +namespace Microsoft.Extensions.ServiceDiscovery.Tests; + +#pragma warning disable IDE0200 + +public class ExtensionsServicePublicApiTests +{ + [Fact] + public void AddServiceDiscoveryShouldThrowWhenHttpClientBuilderIsNull() + { + IHttpClientBuilder httpClientBuilder = null!; + + var action = () => httpClientBuilder.AddServiceDiscovery(); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(httpClientBuilder), exception.ParamName); + } + + [Fact] + public void AddServiceDiscoveryShouldThrowWhenServicesIsNull() + { + IServiceCollection services = null!; + + var action = () => services.AddServiceDiscovery(); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(services), exception.ParamName); + } + + [Fact] + public void AddServiceDiscoveryWithConfigureOptionsShouldThrowWhenServicesIsNull() + { + IServiceCollection services = null!; + Action configureOptions = (_) => { }; + + var action = () => services.AddServiceDiscovery(configureOptions); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(services), exception.ParamName); + } + + [Fact] + public void AddServiceDiscoveryWithConfigureOptionsShouldThrowWhenConfigureOptionsIsNull() + { + var services = new ServiceCollection(); + Action configureOptions = null!; + + var action = () => services.AddServiceDiscovery(configureOptions); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(configureOptions), exception.ParamName); + } + + [Fact] + public void AddServiceDiscoveryCoreShouldThrowWhenServicesIsNull() + { + IServiceCollection services = null!; + + var action = () => services.AddServiceDiscoveryCore(); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(services), exception.ParamName); + } + + [Fact] + public void AddServiceDiscoveryCoreWithConfigureOptionsShouldThrowWhenServicesIsNull() + { + IServiceCollection services = null!; + Action configureOptions = (_) => { }; + + var action = () => services.AddServiceDiscoveryCore(configureOptions); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(services), exception.ParamName); + } + + [Fact] + public void AddServiceDiscoveryCoreWithConfigureOptionsShouldThrowWhenConfigureOptionsIsNull() + { + var services = new ServiceCollection(); + Action configureOptions = null!; + + var action = () => services.AddServiceDiscoveryCore(configureOptions); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(configureOptions), exception.ParamName); + } + + [Fact] + public void AddConfigurationServiceEndpointProviderShouldThrowWhenServicesIsNull() + { + IServiceCollection services = null!; + + var action = () => services.AddConfigurationServiceEndpointProvider(); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(services), exception.ParamName); + } + + [Fact] + public void AddConfigurationServiceEndpointProviderWithConfigureOptionsShouldThrowWhenServicesIsNull() + { + IServiceCollection services = null!; + Action configureOptions = (_) => { }; + + var action = () => services.AddConfigurationServiceEndpointProvider(configureOptions); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(services), exception.ParamName); + } + + [Fact] + public void AddConfigurationServiceEndpointProviderWithConfigureOptionsShouldThrowWhenConfigureOptionsIsNull() + { + var services = new ServiceCollection(); + Action configureOptions = null!; + + var action = () => services.AddConfigurationServiceEndpointProvider(configureOptions); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(configureOptions), exception.ParamName); + } + + [Fact] + public void AddPassThroughServiceEndpointProviderShouldThrowWhenServicesIsNull() + { + IServiceCollection services = null!; + + var action = () => services.AddPassThroughServiceEndpointProvider(); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(services), exception.ParamName); + } + + [Fact] + public async Task GetEndpointsAsyncShouldThrowWhenServiceNameIsNull() + { + var serviceEndpointWatcherFactory = new ServiceEndpointWatcherFactory( + new List(), + new Logger(new NullLoggerFactory()), + Options.Options.Create(new ServiceDiscoveryOptions()), + TimeProvider.System); + + var serviceEndpointResolver = new ServiceEndpointResolver(serviceEndpointWatcherFactory, TimeProvider.System); + string serviceName = null!; + + var action = async () => await serviceEndpointResolver.GetEndpointsAsync(serviceName, CancellationToken.None); + + var exception = await Assert.ThrowsAsync(action); + Assert.Equal(nameof(serviceName), exception.ParamName); + } + + [Fact] + public void CreateShouldThrowWhenEndPointIsNull() + { + EndPoint endPoint = null!; + + var action = () => ServiceEndpoint.Create(endPoint); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(endPoint), exception.ParamName); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void TryParseShouldThrowWhenEndPointIsNullOrEmpty(bool isNull) + { + var input = isNull ? null! : string.Empty; + + var action = () => + { + _ = ServiceEndpointQuery.TryParse(input, out _); + }; + + var exception = isNull + ? Assert.Throws(action) + : Assert.Throws(action); + Assert.Equal(nameof(input), exception.ParamName); + } + + [Fact] + public void CtorServiceEndpointSourceShouldThrowWhenChangeTokenIsNull() + { + IChangeToken changeToken = null!; + var features = new FeatureCollection(); + List? endpoints = null; + + var action = () => new ServiceEndpointSource(endpoints, changeToken, features); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(changeToken), exception.ParamName); + } + + [Fact] + public void CtorServiceEndpointSourceShouldThrowWhenFeaturesIsNull() + { + var changeToken = NullChangeToken.Singleton; + IFeatureCollection features = null!; + List? endpoints = null; + + var action = () => new ServiceEndpointSource(endpoints, changeToken, features); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(features), exception.ParamName); + } +} diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Tests/Microsoft.Extensions.ServiceDiscovery.Tests.csproj b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Tests/Microsoft.Extensions.ServiceDiscovery.Tests.csproj new file mode 100644 index 00000000000..a589eccc256 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Tests/Microsoft.Extensions.ServiceDiscovery.Tests.csproj @@ -0,0 +1,26 @@ + + + + enable + enable + Open + + $(NoWarn);IDE0004;IDE0040;IDE0055;IDE1006;CA2000;S1121;S1128;SA1316;SA1500;SA1513 + + + + + + + + + + + + + + + + + + diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Tests/PassThroughServiceEndpointResolverTests.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Tests/PassThroughServiceEndpointResolverTests.cs new file mode 100644 index 00000000000..f8cc2f282e1 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Tests/PassThroughServiceEndpointResolverTests.cs @@ -0,0 +1,130 @@ +// 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; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.ServiceDiscovery.Internal; +using Microsoft.Extensions.ServiceDiscovery.PassThrough; +using Xunit; + +namespace Microsoft.Extensions.ServiceDiscovery.Tests; + +/// +/// Tests for . +/// These also cover and by extension. +/// +public class PassThroughServiceEndpointResolverTests +{ + [Fact] + public async Task ResolveServiceEndpoint_PassThrough() + { + var services = new ServiceCollection() + .AddServiceDiscoveryCore() + .AddPassThroughServiceEndpointProvider() + .BuildServiceProvider(); + var watcherFactory = services.GetRequiredService(); + ServiceEndpointWatcher watcher; + await using ((watcher = watcherFactory.CreateWatcher("http://basket")).ConfigureAwait(false)) + { + Assert.NotNull(watcher); + var tcs = new TaskCompletionSource(); + watcher.OnEndpointsUpdated = tcs.SetResult; + watcher.Start(); + var initialResult = await tcs.Task; + Assert.NotNull(initialResult); + Assert.True(initialResult.ResolvedSuccessfully); + var ep = Assert.Single(initialResult.EndpointSource.Endpoints); + Assert.Equal(new DnsEndPoint("basket", 80), ep.EndPoint); + } + } + + [Fact] + public async Task ResolveServiceEndpoint_Superseded() + { + var configSource = new MemoryConfigurationSource + { + InitialData = new Dictionary + { + ["services:basket:http:0"] = "http://localhost:8080", + } + }; + var config = new ConfigurationBuilder().Add(configSource); + var services = new ServiceCollection() + .AddSingleton(config.Build()) + .AddServiceDiscovery() // Adds the configuration and pass-through providers. + .BuildServiceProvider(); + var watcherFactory = services.GetRequiredService(); + ServiceEndpointWatcher watcher; + await using ((watcher = watcherFactory.CreateWatcher("http://basket")).ConfigureAwait(false)) + { + Assert.NotNull(watcher); + var tcs = new TaskCompletionSource(); + watcher.OnEndpointsUpdated = tcs.SetResult; + watcher.Start(); + var initialResult = await tcs.Task; + Assert.NotNull(initialResult); + Assert.True(initialResult.ResolvedSuccessfully); + + // We expect the basket service to be resolved from Configuration, not the pass-through provider. + Assert.Single(initialResult.EndpointSource.Endpoints); + Assert.Equal(new UriEndPoint(new Uri("http://localhost:8080")), initialResult.EndpointSource.Endpoints[0].EndPoint); + } + } + + [Fact] + public async Task ResolveServiceEndpoint_Fallback() + { + var configSource = new MemoryConfigurationSource + { + InitialData = new Dictionary + { + ["services:basket:default:0"] = "http://localhost:8080", + } + }; + var config = new ConfigurationBuilder().Add(configSource); + var services = new ServiceCollection() + .AddSingleton(config.Build()) + .AddServiceDiscovery() // Adds the configuration and pass-through providers. + .BuildServiceProvider(); + var watcherFactory = services.GetRequiredService(); + ServiceEndpointWatcher watcher; + await using ((watcher = watcherFactory.CreateWatcher("http://catalog")).ConfigureAwait(false)) + { + Assert.NotNull(watcher); + var tcs = new TaskCompletionSource(); + watcher.OnEndpointsUpdated = tcs.SetResult; + watcher.Start(); + var initialResult = await tcs.Task; + Assert.NotNull(initialResult); + Assert.True(initialResult.ResolvedSuccessfully); + + // We expect the CATALOG service to be resolved from the pass-through provider. + Assert.Single(initialResult.EndpointSource.Endpoints); + Assert.Equal(new DnsEndPoint("catalog", 80), initialResult.EndpointSource.Endpoints[0].EndPoint); + } + } + + // Ensures that pass-through resolution succeeds in scenarios where no scheme is specified during resolution. + [Fact] + public async Task ResolveServiceEndpoint_Fallback_NoScheme() + { + var configSource = new MemoryConfigurationSource + { + InitialData = new Dictionary + { + ["services:basket:default:0"] = "http://localhost:8080", + } + }; + var config = new ConfigurationBuilder().Add(configSource); + var services = new ServiceCollection() + .AddSingleton(config.Build()) + .AddServiceDiscovery() // Adds the configuration and pass-through providers. + .BuildServiceProvider(); + + var resolver = services.GetRequiredService(); + var result = await resolver.GetEndpointsAsync("catalog", default); + Assert.Equal(new DnsEndPoint("catalog", 0), result.Endpoints[0].EndPoint); + } +} diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Tests/ServiceEndpointResolverTests.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Tests/ServiceEndpointResolverTests.cs new file mode 100644 index 00000000000..c91f07c9300 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Tests/ServiceEndpointResolverTests.cs @@ -0,0 +1,292 @@ +// 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.Net; +using System.Threading.Channels; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Primitives; +using Microsoft.Extensions.ServiceDiscovery.Http; +using Microsoft.Extensions.ServiceDiscovery.Internal; +using Xunit; + +namespace Microsoft.Extensions.ServiceDiscovery.Tests; + +/// +/// Tests for and . +/// +public class ServiceEndpointResolverTests +{ + [Fact] + public void ResolveServiceEndpoint_NoProvidersConfigured_Throws() + { + var services = new ServiceCollection() + .AddServiceDiscoveryCore() + .BuildServiceProvider(); + var resolverFactory = services.GetRequiredService(); + var exception = Assert.Throws(() => resolverFactory.CreateWatcher("https://basket")); + Assert.Equal("No provider which supports the provided service name, 'https://basket', has been configured.", exception.Message); + } + + [Fact] + public async Task ServiceEndpointResolver_NoProvidersConfigured_Throws() + { + var services = new ServiceCollection() + .AddServiceDiscoveryCore() + .BuildServiceProvider(); + var watcher = new ServiceEndpointWatcher([], NullLogger.Instance, "foo", TimeProvider.System, Options.Options.Create(new ServiceDiscoveryOptions())); + var exception = Assert.Throws(watcher.Start); + Assert.Equal("No service endpoint providers are configured.", exception.Message); + exception = await Assert.ThrowsAsync(async () => await watcher.GetEndpointsAsync()); + Assert.Equal("No service endpoint providers are configured.", exception.Message); + } + + [Fact] + public void ResolveServiceEndpoint_NullServiceName_Throws() + { + var services = new ServiceCollection() + .AddServiceDiscoveryCore() + .BuildServiceProvider(); + var resolverFactory = services.GetRequiredService(); + Assert.Throws(() => resolverFactory.CreateWatcher(null!)); + } + + [Fact] + public async Task AddServiceDiscovery_NoProviders_Throws() + { + var serviceCollection = new ServiceCollection(); + serviceCollection.AddHttpClient("foo", c => c.BaseAddress = new("http://foo")).AddServiceDiscovery(); + var services = serviceCollection.BuildServiceProvider(); + var client = services.GetRequiredService().CreateClient("foo"); + var exception = await Assert.ThrowsAsync(async () => await client.GetStringAsync("/")); + Assert.Equal("No provider which supports the provided service name, 'http://foo', has been configured.", exception.Message); + } + + private sealed class FakeEndpointResolverProvider(Func createResolverDelegate) : IServiceEndpointProviderFactory + { +#pragma warning disable CS0436 // Type conflicts with imported type + public bool TryCreateProvider(ServiceEndpointQuery query, [NotNullWhen(true)] out IServiceEndpointProvider? resolver) +#pragma warning restore CS0436 // Type conflicts with imported type + { + bool result; + (result, resolver) = createResolverDelegate(query); + return result; + } + } + + private sealed class FakeEndpointResolver(Func resolveAsync, Func disposeAsync) : IServiceEndpointProvider + { + public ValueTask PopulateAsync(IServiceEndpointBuilder endpoints, CancellationToken cancellationToken) => resolveAsync(endpoints, cancellationToken); + public ValueTask DisposeAsync() => disposeAsync(); + } + + [Fact] + public async Task ResolveServiceEndpoint() + { + var cts = new[] { new CancellationTokenSource() }; + var innerResolver = new FakeEndpointResolver( + resolveAsync: (collection, ct) => + { + collection.AddChangeToken(new CancellationChangeToken(cts[0].Token)); + collection.Endpoints.Add(ServiceEndpoint.Create(new IPEndPoint(IPAddress.Parse("127.1.1.1"), 8080))); + + if (cts[0].Token.IsCancellationRequested) + { + cts[0] = new(); + collection.Endpoints.Add(ServiceEndpoint.Create(new IPEndPoint(IPAddress.Parse("127.1.1.2"), 8888))); + } + return default; + }, + disposeAsync: () => default); + var resolverProvider = new FakeEndpointResolverProvider(name => (true, innerResolver)); + var services = new ServiceCollection() + .AddSingleton(resolverProvider) + .AddServiceDiscoveryCore() + .BuildServiceProvider(); + var watcherFactory = services.GetRequiredService(); + + ServiceEndpointWatcher watcher; + await using ((watcher = watcherFactory.CreateWatcher("http://basket")).ConfigureAwait(false)) + { + Assert.NotNull(watcher); + var initialResult = await watcher.GetEndpointsAsync(CancellationToken.None); + Assert.NotNull(initialResult); + var sep = Assert.Single(initialResult.Endpoints); + var ip = Assert.IsType(sep.EndPoint); + Assert.Equal(IPAddress.Parse("127.1.1.1"), ip.Address); + Assert.Equal(8080, ip.Port); + + var tcs = new TaskCompletionSource(); + watcher.OnEndpointsUpdated = tcs.SetResult; + Assert.False(tcs.Task.IsCompleted); + + cts[0].Cancel(); + var resolverResult = await tcs.Task; + Assert.NotNull(resolverResult); + Assert.True(resolverResult.ResolvedSuccessfully); + Assert.Equal(2, resolverResult.EndpointSource.Endpoints.Count); + var endpoints = resolverResult.EndpointSource.Endpoints.Select(ep => ep.EndPoint).OfType().ToList(); + endpoints.Sort((l, r) => l.Port - r.Port); + Assert.Equal(new IPEndPoint(IPAddress.Parse("127.1.1.1"), 8080), endpoints[0]); + Assert.Equal(new IPEndPoint(IPAddress.Parse("127.1.1.2"), 8888), endpoints[1]); + } + } + + [Fact] + public async Task ResolveServiceEndpointOneShot() + { + var cts = new[] { new CancellationTokenSource() }; + var innerResolver = new FakeEndpointResolver( + resolveAsync: (collection, ct) => + { + collection.AddChangeToken(new CancellationChangeToken(cts[0].Token)); + collection.Endpoints.Add(ServiceEndpoint.Create(new IPEndPoint(IPAddress.Parse("127.1.1.1"), 8080))); + + if (cts[0].Token.IsCancellationRequested) + { + cts[0] = new(); + collection.Endpoints.Add(ServiceEndpoint.Create(new IPEndPoint(IPAddress.Parse("127.1.1.2"), 8888))); + } + return default; + }, + disposeAsync: () => default); + var resolverProvider = new FakeEndpointResolverProvider(name => (true, innerResolver)); + var services = new ServiceCollection() + .AddSingleton(resolverProvider) + .AddServiceDiscoveryCore() + .BuildServiceProvider(); + var resolver = services.GetRequiredService(); + + Assert.NotNull(resolver); + var initialResult = await resolver.GetEndpointsAsync("http://basket", CancellationToken.None); + Assert.NotNull(initialResult); + var sep = Assert.Single(initialResult.Endpoints); + var ip = Assert.IsType(sep.EndPoint); + Assert.Equal(IPAddress.Parse("127.1.1.1"), ip.Address); + Assert.Equal(8080, ip.Port); + + await services.DisposeAsync(); + } + + [Fact] + public async Task ResolveHttpServiceEndpointOneShot() + { + var cts = new[] { new CancellationTokenSource() }; + var innerResolver = new FakeEndpointResolver( + resolveAsync: (collection, ct) => + { + collection.AddChangeToken(new CancellationChangeToken(cts[0].Token)); + collection.Endpoints.Add(ServiceEndpoint.Create(new IPEndPoint(IPAddress.Parse("127.1.1.1"), 8080))); + + if (cts[0].Token.IsCancellationRequested) + { + cts[0] = new(); + collection.Endpoints.Add(ServiceEndpoint.Create(new IPEndPoint(IPAddress.Parse("127.1.1.2"), 8888))); + } + return default; + }, + disposeAsync: () => default); + var fakeResolverProvider = new FakeEndpointResolverProvider(name => (true, innerResolver)); + var services = new ServiceCollection() + .AddSingleton(fakeResolverProvider) + .AddServiceDiscoveryCore() + .BuildServiceProvider(); + var resolverProvider = services.GetRequiredService(); + await using var resolver = new HttpServiceEndpointResolver(resolverProvider, services, TimeProvider.System); + + Assert.NotNull(resolver); + var httpRequest = new HttpRequestMessage(HttpMethod.Get, "http://basket"); + var endpoint = await resolver.GetEndpointAsync(httpRequest, CancellationToken.None); + Assert.NotNull(endpoint); + var ip = Assert.IsType(endpoint.EndPoint); + Assert.Equal(IPAddress.Parse("127.1.1.1"), ip.Address); + Assert.Equal(8080, ip.Port); + + await services.DisposeAsync(); + } + + [Fact] + public async Task ResolveServiceEndpoint_ThrowOnReload() + { + var sem = new SemaphoreSlim(0); + var cts = new[] { new CancellationTokenSource() }; + var throwOnNextResolve = new[] { false }; + var innerResolver = new FakeEndpointResolver( + resolveAsync: async (collection, ct) => + { + await sem.WaitAsync(ct).ConfigureAwait(false); + if (cts[0].IsCancellationRequested) + { + // Always be sure to have a fresh token. + cts[0] = new(); + } + + if (throwOnNextResolve[0]) + { + throwOnNextResolve[0] = false; + throw new InvalidOperationException("throwing"); + } + + collection.AddChangeToken(new CancellationChangeToken(cts[0].Token)); + collection.Endpoints.Add(ServiceEndpoint.Create(new IPEndPoint(IPAddress.Parse("127.1.1.1"), 8080))); + }, + disposeAsync: () => default); + var resolverProvider = new FakeEndpointResolverProvider(name => (true, innerResolver)); + var services = new ServiceCollection() + .AddSingleton(resolverProvider) + .AddServiceDiscoveryCore() + .BuildServiceProvider(); + var watcherFactory = services.GetRequiredService(); + + ServiceEndpointWatcher watcher; + await using ((watcher = watcherFactory.CreateWatcher("http://basket")).ConfigureAwait(false)) + { + Assert.NotNull(watcher); + var initialEndpointsTask = watcher.GetEndpointsAsync(CancellationToken.None); + sem.Release(1); + var initialEndpoints = await initialEndpointsTask; + Assert.NotNull(initialEndpoints); + Assert.Single(initialEndpoints.Endpoints); + + // Tell the resolver to throw on the next resolve call and then trigger a reload. + throwOnNextResolve[0] = true; + cts[0].Cancel(); + + var exception = await Assert.ThrowsAsync(async () => + { + var resolveTask = watcher.GetEndpointsAsync(CancellationToken.None); + sem.Release(1); + await resolveTask.ConfigureAwait(false); + }); + + Assert.Equal("throwing", exception.Message); + + var channel = Channel.CreateUnbounded(); + watcher.OnEndpointsUpdated = result => channel.Writer.TryWrite(result); + + do + { + cts[0].Cancel(); + sem.Release(1); + var resolveTask = watcher.GetEndpointsAsync(CancellationToken.None); + await resolveTask; + var next = await channel.Reader.ReadAsync(CancellationToken.None); + if (next.ResolvedSuccessfully) + { + break; + } + } while (true); + + var task = watcher.GetEndpointsAsync(CancellationToken.None); + sem.Release(1); + var result = await task; + Assert.NotSame(initialEndpoints, result); + var sep = Assert.Single(result.Endpoints); + var ip = Assert.IsType(sep.EndPoint); + Assert.Equal(IPAddress.Parse("127.1.1.1"), ip.Address); + Assert.Equal(8080, ip.Port); + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Tests/ServiceEndpointTests.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Tests/ServiceEndpointTests.cs new file mode 100644 index 00000000000..2943074c2b3 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Tests/ServiceEndpointTests.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.Net; +using Xunit; + +namespace Microsoft.Extensions.ServiceDiscovery.Tests; + +public class ServiceEndpointTests +{ + public static TheoryData ZeroPortEndPoints => new() + { + (EndPoint)IPEndPoint.Parse("127.0.0.1:0"), + (EndPoint)new DnsEndPoint("microsoft.com", 0), + (EndPoint)new UriEndPoint(new Uri("https://microsoft.com")) + }; + + public static TheoryData NonZeroPortEndPoints => new() + { + (EndPoint)IPEndPoint.Parse("127.0.0.1:8443"), + (EndPoint)new DnsEndPoint("microsoft.com", 8443), + (EndPoint)new UriEndPoint(new Uri("https://microsoft.com:8443")) + }; + + [Theory] + [MemberData(nameof(ZeroPortEndPoints))] + public void ServiceEndpointToStringOmitsUnspecifiedPort(EndPoint endpoint) + { + var serviceEndpoint = ServiceEndpoint.Create(endpoint); + var epString = serviceEndpoint.ToString(); + Assert.DoesNotContain(":0", epString); + } + + [Theory] + [MemberData(nameof(NonZeroPortEndPoints))] + public void ServiceEndpointToStringContainsSpecifiedPort(EndPoint endpoint) + { + var serviceEndpoint = ServiceEndpoint.Create(endpoint); + var epString = serviceEndpoint.ToString(); + Assert.Contains(":8443", epString); + } +} diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp.Tests/Microsoft.Extensions.ServiceDiscovery.Yarp.Tests.csproj b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp.Tests/Microsoft.Extensions.ServiceDiscovery.Yarp.Tests.csproj new file mode 100644 index 00000000000..714fe71f0ee --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp.Tests/Microsoft.Extensions.ServiceDiscovery.Yarp.Tests.csproj @@ -0,0 +1,24 @@ + + + + $(TestNetCoreTargetFrameworks) + enable + enable + Open + + $(NoWarn);CA2000;S103;S1144;S3459;S4136;SA1208;SA1210;VSTHRD003 + + + + + + + + + + + + + + + diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp.Tests/YarpServiceDiscoveryPublicApiTests.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp.Tests/YarpServiceDiscoveryPublicApiTests.cs new file mode 100644 index 00000000000..a3b694c6d70 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp.Tests/YarpServiceDiscoveryPublicApiTests.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 Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.Extensions.ServiceDiscovery.Yarp.Tests; + +#pragma warning disable IDE0200 + +public class YarpServiceDiscoveryPublicApiTests +{ + [Fact] + public void AddServiceDiscoveryDestinationResolverShouldThrowWhenBuilderIsNull() + { + IReverseProxyBuilder builder = null!; + + var action = () => builder.AddServiceDiscoveryDestinationResolver(); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Fact] + public void AddHttpForwarderWithServiceDiscoveryShouldThrowWhenServicesIsNull() + { + IServiceCollection services = null!; + + var action = () => services.AddHttpForwarderWithServiceDiscovery(); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(services), exception.ParamName); + } + + [Fact] + public void AddServiceDiscoveryForwarderFactoryShouldThrowWhenServicesIsNull() + { + IServiceCollection services = null!; + + var action = () => services.AddServiceDiscoveryForwarderFactory(); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(services), exception.ParamName); + } +} diff --git a/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp.Tests/YarpServiceDiscoveryTests.cs b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp.Tests/YarpServiceDiscoveryTests.cs new file mode 100644 index 00000000000..d38814621c4 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.ServiceDiscovery.Yarp.Tests/YarpServiceDiscoveryTests.cs @@ -0,0 +1,323 @@ +// 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; +using System.Net.Sockets; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.ServiceDiscovery.Dns; +using Microsoft.Extensions.ServiceDiscovery.Dns.Resolver; +using Yarp.ReverseProxy.Configuration; + +namespace Microsoft.Extensions.ServiceDiscovery.Yarp.Tests; + +/// +/// Tests for YARP with Service Discovery enabled. +/// +public class YarpServiceDiscoveryTests +{ + private static ServiceDiscoveryDestinationResolver CreateResolver(IServiceProvider serviceProvider) + { + var coreResolver = serviceProvider.GetRequiredService(); + return new ServiceDiscoveryDestinationResolver( + coreResolver, + serviceProvider.GetRequiredService>()); + } + + [Fact] + public async Task ServiceDiscoveryDestinationResolverTests_PassThrough() + { + await using var services = new ServiceCollection() + .AddServiceDiscoveryCore() + .AddPassThroughServiceEndpointProvider() + .BuildServiceProvider(); + var yarpResolver = CreateResolver(services); + + var destinationConfigs = new Dictionary + { + ["dest-a"] = new() + { + Address = "https://my-svc", + }, + }; + + var result = await yarpResolver.ResolveDestinationsAsync(destinationConfigs, CancellationToken.None); + + Assert.Single(result.Destinations); + Assert.Collection(result.Destinations.Select(d => d.Value.Address), + a => Assert.Equal("https://my-svc/", a)); + } + + [Fact] + public async Task ServiceDiscoveryDestinationResolverTests_Configuration() + { + var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["services:basket:default:0"] = "ftp://localhost:2121", + ["services:basket:default:1"] = "https://localhost:8888", + ["services:basket:default:2"] = "http://localhost:1111", + }); + await using var services = new ServiceCollection() + .AddSingleton(config.Build()) + .AddServiceDiscoveryCore() + .AddConfigurationServiceEndpointProvider() + .BuildServiceProvider(); + var yarpResolver = CreateResolver(services); + + var destinationConfigs = new Dictionary + { + ["dest-a"] = new() + { + Address = "https+http://basket", + }, + }; + + var result = await yarpResolver.ResolveDestinationsAsync(destinationConfigs, CancellationToken.None); + + Assert.Single(result.Destinations); + Assert.Collection(result.Destinations.Select(d => d.Value.Address), + a => Assert.Equal("https://localhost:8888/", a)); + } + + [Fact] + public async Task ServiceDiscoveryDestinationResolverTests_Configuration_NonPreferredScheme() + { + var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["services:basket:default:0"] = "ftp://localhost:2121", + ["services:basket:default:1"] = "http://localhost:1111", + }); + await using var services = new ServiceCollection() + .AddSingleton(config.Build()) + .AddServiceDiscoveryCore() + .AddConfigurationServiceEndpointProvider() + .BuildServiceProvider(); + var yarpResolver = CreateResolver(services); + + var destinationConfigs = new Dictionary + { + ["dest-a"] = new() + { + Address = "https+http://basket", + }, + }; + + var result = await yarpResolver.ResolveDestinationsAsync(destinationConfigs, CancellationToken.None); + + Assert.Single(result.Destinations); + Assert.Collection(result.Destinations.Select(d => d.Value.Address), + a => Assert.Equal("http://localhost:1111/", a)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ServiceDiscoveryDestinationResolverTests_Configuration_Host_Value(bool configHasHost) + { + var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["services:basket:default:0"] = "https://localhost:1111", + ["services:basket:default:1"] = "https://127.0.0.1:2222", + ["services:basket:default:2"] = "https://[::1]:3333", + ["services:basket:default:3"] = "https://baskets-galore.faketld", + }); + await using var services = new ServiceCollection() + .AddSingleton(config.Build()) + .AddServiceDiscoveryCore() + .AddConfigurationServiceEndpointProvider() + .BuildServiceProvider(); + var yarpResolver = CreateResolver(services); + + var destinationConfigs = new Dictionary + { + ["dest-a"] = new() + { + Address = "https://basket", + Host = configHasHost ? "my-basket-svc.faketld" : null + }, + }; + + var result = await yarpResolver.ResolveDestinationsAsync(destinationConfigs, CancellationToken.None); + + Assert.Equal(4, result.Destinations.Count); + Assert.Collection(result.Destinations.Values, + a => + { + Assert.Equal("https://localhost:1111/", a.Address); + if (configHasHost) + { + Assert.Equal("my-basket-svc.faketld", a.Host); + } + else + { + Assert.Null(a.Host); + } + }, + a => + { + Assert.Equal("https://127.0.0.1:2222/", a.Address); + if (configHasHost) + { + Assert.Equal("my-basket-svc.faketld", a.Host); + } + else + { + Assert.Null(a.Host); + } + }, + a => + { + Assert.Equal("https://[::1]:3333/", a.Address); + if (configHasHost) + { + Assert.Equal("my-basket-svc.faketld", a.Host); + } + else + { + Assert.Null(a.Host); + } + }, + a => + { + Assert.Equal("https://baskets-galore.faketld/", a.Address); + if (configHasHost) + { + Assert.Equal("my-basket-svc.faketld", a.Host); + } + else + { + Assert.Null(a.Host); + } + }); + } + + [Fact] + public async Task ServiceDiscoveryDestinationResolverTests_Configuration_DisallowedScheme() + { + var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["services:basket:default:0"] = "ftp://localhost:2121", + ["services:basket:default:1"] = "http://localhost:1111", + }); + await using var services = new ServiceCollection() + .AddSingleton(config.Build()) + .AddServiceDiscoveryCore() + .Configure(o => + { + // Allow only "https://" + o.AllowAllSchemes = false; + o.AllowedSchemes = ["https"]; + }) + .AddConfigurationServiceEndpointProvider() + .BuildServiceProvider(); + var yarpResolver = CreateResolver(services); + + var destinationConfigs = new Dictionary + { + ["dest-a"] = new() + { + Address = "https+http://basket", + }, + }; + + var result = await yarpResolver.ResolveDestinationsAsync(destinationConfigs, CancellationToken.None); + + // No results: there are no 'https' endpoints in config and 'http' is disallowed. + Assert.Empty(result.Destinations); + } + + [Fact] + public async Task ServiceDiscoveryDestinationResolverTests_Dns() + { + DnsResolver resolver = new DnsResolver(TimeProvider.System, NullLogger.Instance, new OptionsWrapper(new DnsResolverOptions())); + + await using var services = new ServiceCollection() + .AddSingleton(resolver) + .AddServiceDiscoveryCore() + .AddDnsServiceEndpointProvider() + .BuildServiceProvider(); + var yarpResolver = CreateResolver(services); + + var destinationConfigs = new Dictionary + { + ["dest-a"] = new() + { + Address = "https://microsoft.com", + }, + ["dest-b"] = new() + { + Address = "http://msn.com", + }, + }; + + var result = await yarpResolver.ResolveDestinationsAsync(destinationConfigs, CancellationToken.None); + Assert.NotNull(result); + Assert.NotEmpty(result.Destinations); + Assert.All(result.Destinations, d => + { + var address = d.Value.Address; + Assert.True(Uri.TryCreate(address, default, out var uri), $"Failed to parse address '{address}' as URI."); + Assert.True(uri.IsDefaultPort, "URI should use the default port when resolved via DNS."); + var expectedScheme = d.Key.StartsWith("dest-a") ? "https" : "http"; + Assert.Equal(expectedScheme, uri.Scheme); + }); + } + + [Fact] + public async Task ServiceDiscoveryDestinationResolverTests_DnsSrv() + { + var dnsClientMock = new FakeDnsResolver + { + ResolveServiceAsyncFunc = (name, cancellationToken) => + { + ServiceResult[] response = [ + new ServiceResult(DateTime.UtcNow.AddSeconds(60), 99, 66, 8888, "srv-a", [new AddressResult(DateTime.UtcNow.AddSeconds(64), IPAddress.Parse("10.10.10.10"))]), + new ServiceResult(DateTime.UtcNow.AddSeconds(60), 99, 62, 9999, "srv-b", [new AddressResult(DateTime.UtcNow.AddSeconds(64), IPAddress.IPv6Loopback)]), + new ServiceResult(DateTime.UtcNow.AddSeconds(60), 99, 62, 7777, "srv-c", [new AddressResult(DateTime.UtcNow.AddSeconds(64), IPAddress.Loopback)]) + ]; + + return ValueTask.FromResult(response); + } + }; + + await using var services = new ServiceCollection() + .AddSingleton(dnsClientMock) + .AddServiceDiscoveryCore() + .AddDnsSrvServiceEndpointProvider(options => options.QuerySuffix = ".ns") + .BuildServiceProvider(); + var yarpResolver = CreateResolver(services); + + var destinationConfigs = new Dictionary + { + ["dest-a"] = new() + { + Address = "https://my-svc", + }, + }; + + var result = await yarpResolver.ResolveDestinationsAsync(destinationConfigs, CancellationToken.None); + + Assert.Equal(3, result.Destinations.Count); + Assert.Collection(result.Destinations.Select(d => d.Value.Address), + a => Assert.Equal("https://10.10.10.10:8888/", a), + a => Assert.Equal("https://[::1]:9999/", a), + a => Assert.Equal("https://127.0.0.1:7777/", a)); + } + + private sealed class FakeDnsResolver : IDnsResolver + { + public Func>? ResolveIPAddressesAsyncFunc { get; set; } + public ValueTask ResolveIPAddressesAsync(string name, AddressFamily addressFamily, CancellationToken cancellationToken = default) => ResolveIPAddressesAsyncFunc!.Invoke(name, addressFamily, cancellationToken); + + public Func>? ResolveIPAddressesAsyncFunc2 { get; set; } + + public ValueTask ResolveIPAddressesAsync(string name, CancellationToken cancellationToken = default) => ResolveIPAddressesAsyncFunc2!.Invoke(name, cancellationToken); + + public Func>? ResolveServiceAsyncFunc { get; set; } + + public ValueTask ResolveServiceAsync(string name, CancellationToken cancellationToken = default) => ResolveServiceAsyncFunc!.Invoke(name, cancellationToken); + } +} diff --git a/test/Libraries/Microsoft.Extensions.Telemetry.Tests/Buffering/LogBufferingFilterRuleTests.cs b/test/Libraries/Microsoft.Extensions.Telemetry.Tests/Buffering/LogBufferingFilterRuleTests.cs index 222d2aacc9a..a3b6bad6340 100644 --- a/test/Libraries/Microsoft.Extensions.Telemetry.Tests/Buffering/LogBufferingFilterRuleTests.cs +++ b/test/Libraries/Microsoft.Extensions.Telemetry.Tests/Buffering/LogBufferingFilterRuleTests.cs @@ -7,6 +7,7 @@ using Xunit; namespace Microsoft.Extensions.Diagnostics.Buffering.Test; + public class LogBufferingFilterRuleTests { private readonly LogBufferingFilterRuleSelector _selector = new(); diff --git a/test/Libraries/Microsoft.Extensions.Telemetry.Tests/Latency/Internal/MockLatencyContextRegistrationOptions.cs b/test/Libraries/Microsoft.Extensions.Telemetry.Tests/Latency/Internal/MockLatencyContextRegistrationOptions.cs index 6d4ed5908f8..58d2d50d9ba 100644 --- a/test/Libraries/Microsoft.Extensions.Telemetry.Tests/Latency/Internal/MockLatencyContextRegistrationOptions.cs +++ b/test/Libraries/Microsoft.Extensions.Telemetry.Tests/Latency/Internal/MockLatencyContextRegistrationOptions.cs @@ -5,6 +5,7 @@ using Moq; namespace Microsoft.Extensions.Diagnostics.Latency.Test; + internal static class MockLatencyContextRegistrationOptions { public static IOptions GetLatencyContextRegistrationOptions( diff --git a/test/Libraries/Microsoft.Extensions.Telemetry.Tests/Logging/ExtendedLoggerFactoryTests.cs b/test/Libraries/Microsoft.Extensions.Telemetry.Tests/Logging/ExtendedLoggerFactoryTests.cs index 3d02c9ae578..f35dc88560a 100644 --- a/test/Libraries/Microsoft.Extensions.Telemetry.Tests/Logging/ExtendedLoggerFactoryTests.cs +++ b/test/Libraries/Microsoft.Extensions.Telemetry.Tests/Logging/ExtendedLoggerFactoryTests.cs @@ -8,6 +8,7 @@ using System.Text; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Testing; using Moq; using Xunit; @@ -536,6 +537,20 @@ public static void CreateDisposeDisposesInnerServiceProvider() Assert.True(disposed); } + [Fact] + public static void NullLoggerByProviderIsIgnored() + { + using var factory = Utils.CreateLoggerFactory(builder => + { + builder.AddProvider(NullLoggerProvider.Instance); + builder.AddProvider(new Provider()); + }); + var logger1 = (ExtendedLogger)factory.CreateLogger("C1"); + Assert.Single(logger1.MessageLoggers); + + logger1.LogInformation("This should not throw an exception."); + } + private class InternalScopeLoggerProvider : ILoggerProvider, ILogger { private IExternalScopeProvider _scopeProvider = new LoggerExternalScopeProvider(); diff --git a/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/TimerTests.cs b/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/TimerTests.cs index d0db83d943a..18d45449388 100644 --- a/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/TimerTests.cs +++ b/test/Libraries/Microsoft.Extensions.TimeProvider.Testing.Tests/TimerTests.cs @@ -178,7 +178,7 @@ public async Task Change_WhenCalledAfterDisposeAsync_ReturnsFalse() Assert.False(t.Change(TimeSpan.FromMilliseconds(1), TimeSpan.FromMilliseconds(1))); } - [Fact] + [Fact(Skip = "Flaky, https://github.com/dotnet/extensions/issues/6567")] public void CreateTimer_WhenDisposed_RemovesWaiterFromQueue() { var timer1Counter = 0; diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebExecutionTests.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebExecutionTests.cs index 1b8c4177f40..f5d2bc52e3a 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebExecutionTests.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebExecutionTests.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using Microsoft.TestUtilities; using Xunit; using Xunit.Abstractions; @@ -71,6 +72,44 @@ public async Task CreateRestoreAndBuild_AspireTemplate(params string[] args) await Fixture.BuildProjectAsync(project); } + /// + /// Runs a single test with --aspire true and a project name that will trigger the class + /// name normalization bug reported in https://github.com/dotnet/extensions/issues/6811. + /// + [Fact] + public async Task CreateRestoreAndBuild_AspireProjectName() + { + await CreateRestoreAndBuild_AspireProjectName_Variants("azureopenai", "mix.ed-dash_name 123"); + } + + /// + /// Tests build for various project name formats, including dots and other + /// separators, to trigger the class name normalization bug described + /// in https://github.com/dotnet/extensions/issues/6811 + /// This runs for all provider combinations with --aspire true and different + /// project names to ensure the bug is caught in all scenarios. + /// + /// + /// Because this test takes a long time to run, it is skipped by default. Set the + /// environment variable AI_TEMPLATES_TEST_PROJECT_NAMES to "true" or "1" + /// to enable it. + /// + [ConditionalTheory] + [EnvironmentVariableCondition("AI_TEMPLATES_TEST_PROJECT_NAMES", "true", "1")] + [MemberData(nameof(GetAspireProjectNameVariants))] + public async Task CreateRestoreAndBuild_AspireProjectName_Variants(string provider, string projectName) + { + var project = await Fixture.CreateProjectAsync( + templateName: "aichatweb", + projectName: projectName, + args: new[] { "--aspire", $"--provider={provider}" }); + + project.StartupProjectRelativePath = $"{projectName}.AppHost"; + + await Fixture.RestoreProjectAsync(project); + await Fixture.BuildProjectAsync(project); + } + private static readonly (string name, string[] values)[] _templateOptions = [ ("--provider", ["azureopenai", "githubmodels", "ollama", "openai"]), ("--vector-store", ["azureaisearch", "local", "qdrant"]), @@ -158,4 +197,26 @@ private static IEnumerable GetAllPossibleOptions(ReadOnlyMemory<(strin } } } + + public static IEnumerable GetAspireProjectNameVariants() + { + foreach (string provider in new[] { "ollama", "openai", "azureopenai", "githubmodels" }) + { + foreach (string projectName in new[] + { + "mix.ed-dash_name 123", + "dot.name", + "project.123", + "space name", + ".1My.Projec-", + "1Project123", + "11double", + "1", + "nomatch" + }) + { + yield return new object[] { provider, projectName }; + } + } + } } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebSnapshotTests.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebSnapshotTests.cs index 0f3d0951631..4bf84ab4bd3 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebSnapshotTests.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebSnapshotTests.cs @@ -1,12 +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.IO; using System.Linq; using System.Threading.Tasks; -using Microsoft.Extensions.AI.Templates.Tests; using Microsoft.Extensions.Logging; using Microsoft.TemplateEngine.Authoring.TemplateVerifier; using Microsoft.TemplateEngine.TestHelper; diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/Project.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/Project.cs index 38ced5b1867..317e81a661f 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/Project.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/Project.cs @@ -8,30 +8,31 @@ namespace Microsoft.Extensions.AI.Templates.Tests; public sealed class Project(string rootPath, string name) { - private string? _startupProjectRelativePath; - private string? _startupProjectFullPath; - public string RootPath => rootPath; public string Name => name; public string? StartupProjectRelativePath { - get => _startupProjectRelativePath; + get; set { if (value is null) { - _startupProjectRelativePath = null; - _startupProjectFullPath = null; + field = null; + StartupProjectFullPath = null!; } - else if (!string.Equals(value, _startupProjectRelativePath, StringComparison.Ordinal)) + else if (!string.Equals(value, field, StringComparison.Ordinal)) { - _startupProjectRelativePath = value; - _startupProjectFullPath = Path.Combine(rootPath, _startupProjectRelativePath); + field = value; + StartupProjectFullPath = Path.Combine(rootPath, field); } } } - public string StartupProjectFullPath => _startupProjectFullPath ?? rootPath; + public string StartupProjectFullPath + { + get => field ?? rootPath; + private set; + } } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/TestCommandResult.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/TestCommandResult.cs index 09d09d50a1c..4b5e2dd2a28 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/TestCommandResult.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Infrastructure/TestCommandResult.cs @@ -7,12 +7,9 @@ namespace Microsoft.Extensions.AI.Templates.Tests; public sealed class TestCommandResult(StringBuilder standardOutputBuilder, StringBuilder standardErrorBuilder, int exitCode) { - private string? _standardOutput; - private string? _standardError; + public string StandardOutput => field ??= standardOutputBuilder.ToString(); - public string StandardOutput => _standardOutput ??= standardOutputBuilder.ToString(); - - public string StandardError => _standardError ??= standardErrorBuilder.ToString(); + public string StandardError => field ??= standardErrorBuilder.ToString(); public int ExitCode => exitCode; } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/McpServerSnapshotTests.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/McpServerSnapshotTests.cs index a3f3dedd1b5..5fa1723af83 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/McpServerSnapshotTests.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/McpServerSnapshotTests.cs @@ -1,12 +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.IO; using System.Linq; using System.Threading.Tasks; -using Microsoft.Extensions.AI.Templates.Tests; using Microsoft.Extensions.Logging; using Microsoft.TemplateEngine.Authoring.TemplateVerifier; using Microsoft.TemplateEngine.TestHelper; @@ -41,6 +39,24 @@ public async Task BasicTest() await TestTemplateCoreAsync(scenarioName: "Basic"); } + [Fact] + public async Task SelfContainedFalse() + { + await TestTemplateCoreAsync(scenarioName: "SelfContainedFalse", templateArgs: ["--self-contained", bool.FalseString]); + } + + [Fact] + public async Task AotTrue() + { + await TestTemplateCoreAsync(scenarioName: "AotTrue", templateArgs: ["--aot", bool.TrueString]); + } + + [Fact] + public async Task Net10() + { + await TestTemplateCoreAsync(scenarioName: "net10", templateArgs: ["--framework", "net10.0"]); + } + private async Task TestTemplateCoreAsync(string scenarioName, IEnumerable? templateArgs = null) { string workingDir = TestUtils.CreateTemporaryFolder(); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Microsoft.Extensions.AI.Templates.Tests.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Microsoft.Extensions.AI.Templates.Tests.csproj index d2fc26ea0ab..e4c52714b79 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Microsoft.Extensions.AI.Templates.Tests.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Microsoft.Extensions.AI.Templates.Tests.csproj @@ -15,6 +15,10 @@ + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/README.md index 57c0375d302..7bebc5d7595 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/README.md +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/README.md @@ -5,6 +5,9 @@ This project is an AI chat application that demonstrates how to chat with custom >[!NOTE] > Before running this project you need to configure the API keys or endpoints for the providers you have chosen. See below for details specific to your choices. +### Prerequisites +To use Azure OpenAI or Azure AI Search, you need an Azure account. If you don't already have one, [create an Azure account](https://azure.microsoft.com/free/). + ### Known Issues #### Errors running Ollama or Docker @@ -15,50 +18,10 @@ This incompatibility can be addressed by upgrading to Docker Desktop 4.41.1. See # Configure the AI Model Provider -## Using Azure OpenAI - -To use Azure OpenAI, you will need an Azure account and an Azure OpenAI Service resource. For detailed instructions, see the [Azure OpenAI Service documentation](https://learn.microsoft.com/azure/ai-services/openai/how-to/create-resource). - -### 1. Create an Azure OpenAI Service Resource -[Create an Azure OpenAI Service resource](https://learn.microsoft.com/azure/ai-services/openai/how-to/create-resource?pivots=web-portal). - -### 2. Deploy the Models -Deploy the `gpt-4o-mini` and `text-embedding-3-small` models to your Azure OpenAI Service resource. When creating those deployments, give them the same names as the models (`gpt-4o-mini` and `text-embedding-3-small`). See the Azure OpenAI documentation to learn how to [Deploy a model](https://learn.microsoft.com/azure/ai-services/openai/how-to/create-resource?pivots=web-portal#deploy-a-model). - -### 3. Configure API Key and Endpoint -Configure your Azure OpenAI API key and endpoint for this project, using .NET User Secrets: - 1. In the Azure Portal, navigate to your Azure OpenAI resource. - 2. Copy the "Endpoint" URL and "Key 1" from the "Keys and Endpoint" section. - 3. From the command line, configure your API key and endpoint for this project using .NET User Secrets by running the following commands: - - ```sh - cd aichatweb.AppHost - dotnet user-secrets set ConnectionStrings:openai "Endpoint=https://YOUR-DEPLOYMENT-NAME.openai.azure.com;Key=YOUR-API-KEY" - ``` - -Make sure to replace `YOUR-API-KEY` and `YOUR-DEPLOYMENT-NAME` with your actual Azure OpenAI key and endpoint. Make sure your endpoint URL is formatted like https://YOUR-DEPLOYMENT-NAME.openai.azure.com/ (do not include any path after .openai.azure.com/). - -## Configure Azure AI Search - -To use Azure AI Search, you will need an Azure account and an Azure AI Search resource. For detailed instructions, see the [Azure AI Search documentation](https://learn.microsoft.com/azure/search/search-create-service-portal). - -### 1. Create an Azure AI Search Resource -Follow the instructions in the [Azure portal](https://portal.azure.com/) to create an Azure AI Search resource. Note that there is a free tier for the service but it is not currently the default setting on the portal. - -Note that if you previously used the same Azure AI Search resource with different model using this project name, you may need to delete your `data-aichatweb-chunks` and `data-aichatweb-documents` indexes using the [Azure portal](https://portal.azure.com/) first before continuing; otherwise, data ingestion may fail due to a vector dimension mismatch. - -### 3. Configure API Key and Endpoint - Configure your Azure AI Search API key and endpoint for this project, using .NET User Secrets: - 1. In the Azure Portal, navigate to your Azure AI Search resource. - 2. Copy the "Endpoint" URL and "Primary admin key" from the "Keys" section. - 3. From the command line, configure your API key and endpoint for this project using .NET User Secrets by running the following commands: +## Using Azure Provisioning - ```sh - cd aichatweb.AppHost - dotnet user-secrets set ConnectionStrings:azureAISearch "Endpoint=https://YOUR-DEPLOYMENT-NAME.search.windows.net;Key=YOUR-API-KEY" - ``` +The project is set up to automatically provision Azure resources. When running the app for the first time, you will be prompted to provide Azure configuration values. For detailed instructions, see the [Local Provisioning documentation](https://learn.microsoft.com/dotnet/aspire/azure/local-provisioning#configuration). -Make sure to replace `YOUR-DEPLOYMENT-NAME` and `YOUR-API-KEY` with your actual Azure AI Search deployment name and key. # Running the application @@ -76,9 +39,9 @@ Make sure to replace `YOUR-DEPLOYMENT-NAME` and `YOUR-API-KEY` with your actual ## Trust the localhost certificate -Several .NET Aspire templates include ASP.NET Core projects that are configured to use HTTPS by default. If this is the first time you're running the project, an exception might occur when loading the Aspire dashboard. This error can be resolved by trusting the self-signed development certificate with the .NET CLI. +Several Aspire templates include ASP.NET Core projects that are configured to use HTTPS by default. If this is the first time you're running the project, an exception might occur when loading the Aspire dashboard. This error can be resolved by trusting the self-signed development certificate with the .NET CLI. -See [Troubleshoot untrusted localhost certificate in .NET Aspire](https://learn.microsoft.com/dotnet/aspire/troubleshooting/untrusted-localhost-certificate) for more information. +See [Troubleshoot untrusted localhost certificate in Aspire](https://learn.microsoft.com/dotnet/aspire/troubleshooting/untrusted-localhost-certificate) for more information. # Updating JavaScript dependencies diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/AppHost.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/AppHost.cs new file mode 100644 index 00000000000..da0220a0b1c --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/AppHost.cs @@ -0,0 +1,29 @@ +var builder = DistributedApplication.CreateBuilder(args); + +// See https://learn.microsoft.com/dotnet/aspire/azure/local-provisioning#configuration +// for instructions providing configuration values +var openai = builder.AddAzureOpenAI("openai"); + +openai.AddDeployment( + name: "gpt-4o-mini", + modelName: "gpt-4o-mini", + modelVersion: "2024-07-18"); + +openai.AddDeployment( + name: "text-embedding-3-small", + modelName: "text-embedding-3-small", + modelVersion: "1"); + +// See https://learn.microsoft.com/dotnet/aspire/azure/local-provisioning#configuration +// for instructions providing configuration values +var search = builder.AddAzureSearch("search"); + +var webApp = builder.AddProject("aichatweb-app"); +webApp + .WithReference(openai) + .WaitFor(openai); +webApp + .WithReference(search) + .WaitFor(search); + +builder.Build().Run(); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/Program.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/Program.cs deleted file mode 100644 index 80803d78d74..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/Program.cs +++ /dev/null @@ -1,19 +0,0 @@ -var builder = DistributedApplication.CreateBuilder(args); - -// You will need to set the connection string to your own value -// You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: -// cd this-project-directory -// dotnet user-secrets set ConnectionStrings:openai "Endpoint=https://YOUR-DEPLOYMENT-NAME.openai.azure.com;Key=YOUR-API-KEY" -var openai = builder.AddConnectionString("openai"); - -// You will need to set the connection string to your own value -// You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: -// cd this-project-directory -// dotnet user-secrets set ConnectionStrings:azureAISearch "Endpoint=https://YOUR-DEPLOYMENT-NAME.search.windows.net;Key=YOUR-API-KEY" -var azureAISearch = builder.AddConnectionString("azureAISearch"); - -var webApp = builder.AddProject("aichatweb-app"); -webApp.WithReference(openai); -webApp.WithReference(azureAISearch); - -builder.Build().Run(); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/Properties/launchSettings.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/Properties/launchSettings.json index 4444e808585..681e3bf0d26 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/Properties/launchSettings.json +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/Properties/launchSettings.json @@ -9,8 +9,8 @@ "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", - "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:9999", - "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:9999" + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:9999", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:9999" } }, "http": { @@ -21,8 +21,8 @@ "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", - "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:9999", - "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:9999" + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:9999", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:9999" } } } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj index 939114cbd3d..d2d0f2890a6 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj @@ -1,18 +1,19 @@  - + Exe net9.0 enable enable - true secret - + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs index f56908872e0..b44d60b604b 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs @@ -10,11 +10,14 @@ namespace Microsoft.Extensions.Hosting; -// Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry. // This project should be referenced by each service project in your solution. // To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults public static class Extensions { + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder { builder.ConfigureOpenTelemetry(); @@ -64,7 +67,12 @@ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) w .WithTracing(tracing => { tracing.AddSource(builder.Environment.ApplicationName) - .AddAspNetCoreInstrumentation() + .AddAspNetCoreInstrumentation(tracing => + // Exclude health check requests from tracing + tracing.Filter = context => + !context.Request.Path.StartsWithSegments(HealthEndpointPath) + && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) + ) // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) //.AddGrpcClientInstrumentation() .AddHttpClientInstrumentation() @@ -111,10 +119,10 @@ public static WebApplication MapDefaultEndpoints(this WebApplication app) if (app.Environment.IsDevelopment()) { // All health checks must pass for app to be considered ready to accept traffic after starting - app.MapHealthChecks("/health"); + app.MapHealthChecks(HealthEndpointPath); // Only health checks tagged with the "live" tag must pass for app to be considered alive - app.MapHealthChecks("/alive", new HealthCheckOptions + app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions { Predicate = r => r.Tags.Contains("live") }); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj index 8f588a6bef4..474dd445fae 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj @@ -10,13 +10,13 @@ - - - - - - - + + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor index a7b1502d894..8aa0ec9fd28 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor @@ -40,6 +40,7 @@ Don't refer to the presence of citations; just emit these tags right at the end, with no surrounding text. "; + private int statefulMessageCount; private readonly ChatOptions chatOptions = new(); private readonly List messages = new(); private CancellationTokenSource? currentResponseCancellation; @@ -49,6 +50,7 @@ protected override void OnInitialized() { + statefulMessageCount = 0; messages.Add(new(ChatRole.System, SystemPrompt)); chatOptions.Tools = [AIFunctionFactory.Create(SearchAsync)]; } @@ -66,15 +68,17 @@ var responseText = new TextContent(""); currentResponseMessage = new ChatMessage(ChatRole.Assistant, [responseText]); currentResponseCancellation = new(); - await foreach (var update in ChatClient.GetStreamingResponseAsync([.. messages], chatOptions, currentResponseCancellation.Token)) + await foreach (var update in ChatClient.GetStreamingResponseAsync(messages.Skip(statefulMessageCount), chatOptions, currentResponseCancellation.Token)) { messages.AddMessages(update, filter: c => c is not TextContent); responseText.Text += update.Text; + chatOptions.ConversationId = update.ConversationId; ChatMessageItem.NotifyChanged(currentResponseMessage); } // Store the final response in the conversation, and begin getting suggestions messages.Add(currentResponseMessage!); + statefulMessageCount = chatOptions.ConversationId is not null ? messages.Count : 0; currentResponseMessage = null; chatSuggestions?.Update(messages); } @@ -96,6 +100,8 @@ CancelAnyCurrentResponse(); messages.Clear(); messages.Add(new(ChatRole.System, SystemPrompt)); + chatOptions.ConversationId = null; + statefulMessageCount = 0; chatSuggestions?.Clear(); await chatInput!.FocusAsync(); } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Program.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Program.cs index 9a698ff1763..450914c4461 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Program.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Program.cs @@ -2,7 +2,6 @@ using aichatweb.Web.Components; using aichatweb.Web.Services; using aichatweb.Web.Services.Ingestion; -using OpenAI; var builder = WebApplication.CreateBuilder(args); builder.AddServiceDefaults(); @@ -15,7 +14,7 @@ c.EnableSensitiveData = builder.Environment.IsDevelopment()); openai.AddEmbeddingGenerator("text-embedding-3-small"); -builder.AddAzureSearchClient("azureAISearch"); +builder.AddAzureSearchClient("search"); builder.Services.AddAzureAISearchCollection("data-aichatweb-chunks"); builder.Services.AddAzureAISearchCollection("data-aichatweb-documents"); builder.Services.AddScoped(); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Services/Ingestion/DataIngestor.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Services/Ingestion/DataIngestor.cs index 59732141849..2fe43370071 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Services/Ingestion/DataIngestor.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/Services/Ingestion/DataIngestor.cs @@ -26,7 +26,7 @@ public async Task IngestDataAsync(IIngestionSource source) var deletedDocuments = await source.GetDeletedDocumentsAsync(documentsForSource); foreach (var deletedDocument in deletedDocuments) { - logger.LogInformation("Removing ingested data for {documentId}", deletedDocument.DocumentId); + logger.LogInformation("Removing ingested data for {DocumentId}", deletedDocument.DocumentId); await DeleteChunksForDocumentAsync(deletedDocument); await documentsCollection.DeleteAsync(deletedDocument.Key); } @@ -34,7 +34,7 @@ public async Task IngestDataAsync(IIngestionSource source) var modifiedDocuments = await source.GetNewOrModifiedDocumentsAsync(documentsForSource); foreach (var modifiedDocument in modifiedDocuments) { - logger.LogInformation("Processing {documentId}", modifiedDocument.DocumentId); + logger.LogInformation("Processing {DocumentId}", modifiedDocument.DocumentId); await DeleteChunksForDocumentAsync(modifiedDocument); await documentsCollection.UpsertAsync(modifiedDocument); @@ -49,7 +49,7 @@ async Task DeleteChunksForDocumentAsync(IngestedDocument document) { var documentId = document.DocumentId; var chunksToDelete = await chunksCollection.GetAsync(record => record.DocumentId == documentId, int.MaxValue).ToListAsync(); - if (chunksToDelete.Any()) + if (chunksToDelete.Count != 0) { await chunksCollection.DeleteAsync(chunksToDelete.Select(r => r.Key)); } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj index e76ef5b53f6..861a3a974c6 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj @@ -8,14 +8,14 @@ - - - - - - - - + + + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Components/Pages/Chat/Chat.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Components/Pages/Chat/Chat.razor index a7b1502d894..8aa0ec9fd28 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Components/Pages/Chat/Chat.razor +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Components/Pages/Chat/Chat.razor @@ -40,6 +40,7 @@ Don't refer to the presence of citations; just emit these tags right at the end, with no surrounding text. "; + private int statefulMessageCount; private readonly ChatOptions chatOptions = new(); private readonly List messages = new(); private CancellationTokenSource? currentResponseCancellation; @@ -49,6 +50,7 @@ protected override void OnInitialized() { + statefulMessageCount = 0; messages.Add(new(ChatRole.System, SystemPrompt)); chatOptions.Tools = [AIFunctionFactory.Create(SearchAsync)]; } @@ -66,15 +68,17 @@ var responseText = new TextContent(""); currentResponseMessage = new ChatMessage(ChatRole.Assistant, [responseText]); currentResponseCancellation = new(); - await foreach (var update in ChatClient.GetStreamingResponseAsync([.. messages], chatOptions, currentResponseCancellation.Token)) + await foreach (var update in ChatClient.GetStreamingResponseAsync(messages.Skip(statefulMessageCount), chatOptions, currentResponseCancellation.Token)) { messages.AddMessages(update, filter: c => c is not TextContent); responseText.Text += update.Text; + chatOptions.ConversationId = update.ConversationId; ChatMessageItem.NotifyChanged(currentResponseMessage); } // Store the final response in the conversation, and begin getting suggestions messages.Add(currentResponseMessage!); + statefulMessageCount = chatOptions.ConversationId is not null ? messages.Count : 0; currentResponseMessage = null; chatSuggestions?.Update(messages); } @@ -96,6 +100,8 @@ CancelAnyCurrentResponse(); messages.Clear(); messages.Add(new(ChatRole.System, SystemPrompt)); + chatOptions.ConversationId = null; + statefulMessageCount = 0; chatSuggestions?.Clear(); await chatInput!.FocusAsync(); } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Program.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Program.cs index 0e97b8efffe..1ff3845eb08 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Program.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Program.cs @@ -1,9 +1,9 @@ -using Microsoft.Extensions.AI; +using System.ClientModel; +using Microsoft.Extensions.AI; +using OpenAI; using aichatweb.Components; using aichatweb.Services; using aichatweb.Services.Ingestion; -using OpenAI; -using System.ClientModel; var builder = WebApplication.CreateBuilder(args); builder.Services.AddRazorComponents().AddInteractiveServerComponents(); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/Ingestion/DataIngestor.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/Ingestion/DataIngestor.cs index 65b520980c1..89fe287ebed 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/Ingestion/DataIngestor.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/Services/Ingestion/DataIngestor.cs @@ -26,7 +26,7 @@ public async Task IngestDataAsync(IIngestionSource source) var deletedDocuments = await source.GetDeletedDocumentsAsync(documentsForSource); foreach (var deletedDocument in deletedDocuments) { - logger.LogInformation("Removing ingested data for {documentId}", deletedDocument.DocumentId); + logger.LogInformation("Removing ingested data for {DocumentId}", deletedDocument.DocumentId); await DeleteChunksForDocumentAsync(deletedDocument); await documentsCollection.DeleteAsync(deletedDocument.Key); } @@ -34,7 +34,7 @@ public async Task IngestDataAsync(IIngestionSource source) var modifiedDocuments = await source.GetNewOrModifiedDocumentsAsync(documentsForSource); foreach (var modifiedDocument in modifiedDocuments) { - logger.LogInformation("Processing {documentId}", modifiedDocument.DocumentId); + logger.LogInformation("Processing {DocumentId}", modifiedDocument.DocumentId); await DeleteChunksForDocumentAsync(modifiedDocument); await documentsCollection.UpsertAsync(modifiedDocument); @@ -49,7 +49,7 @@ async Task DeleteChunksForDocumentAsync(IngestedDocument document) { var documentId = document.DocumentId; var chunksToDelete = await chunksCollection.GetAsync(record => record.DocumentId == documentId, int.MaxValue).ToListAsync(); - if (chunksToDelete.Any()) + if (chunksToDelete.Count != 0) { await chunksCollection.DeleteAsync(chunksToDelete.Select(r => r.Key)); } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/aichatweb.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/aichatweb.csproj index a00ffe716f9..1e694a1d6a6 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/aichatweb.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/aichatweb.csproj @@ -8,12 +8,12 @@ - - - - - - + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/README.md index c05c18281ef..94c542fda7f 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/README.md +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/README.md @@ -43,9 +43,9 @@ Learn more about [prototyping with AI models using GitHub Models](https://docs.g ## Trust the localhost certificate -Several .NET Aspire templates include ASP.NET Core projects that are configured to use HTTPS by default. If this is the first time you're running the project, an exception might occur when loading the Aspire dashboard. This error can be resolved by trusting the self-signed development certificate with the .NET CLI. +Several Aspire templates include ASP.NET Core projects that are configured to use HTTPS by default. If this is the first time you're running the project, an exception might occur when loading the Aspire dashboard. This error can be resolved by trusting the self-signed development certificate with the .NET CLI. -See [Troubleshoot untrusted localhost certificate in .NET Aspire](https://learn.microsoft.com/dotnet/aspire/troubleshooting/untrusted-localhost-certificate) for more information. +See [Troubleshoot untrusted localhost certificate in Aspire](https://learn.microsoft.com/dotnet/aspire/troubleshooting/untrusted-localhost-certificate) for more information. # Updating JavaScript dependencies diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.AppHost/Program.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.AppHost/AppHost.cs similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.AppHost/Program.cs rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.AppHost/AppHost.cs diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.AppHost/Properties/launchSettings.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.AppHost/Properties/launchSettings.json index 4444e808585..681e3bf0d26 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.AppHost/Properties/launchSettings.json +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.AppHost/Properties/launchSettings.json @@ -9,8 +9,8 @@ "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", - "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:9999", - "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:9999" + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:9999", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:9999" } }, "http": { @@ -21,8 +21,8 @@ "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", - "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:9999", - "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:9999" + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:9999", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:9999" } } } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj index 939114cbd3d..fa6140a8751 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj @@ -1,18 +1,17 @@  - + Exe net9.0 enable enable - true secret - + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs index f56908872e0..b44d60b604b 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs @@ -10,11 +10,14 @@ namespace Microsoft.Extensions.Hosting; -// Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry. // This project should be referenced by each service project in your solution. // To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults public static class Extensions { + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder { builder.ConfigureOpenTelemetry(); @@ -64,7 +67,12 @@ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) w .WithTracing(tracing => { tracing.AddSource(builder.Environment.ApplicationName) - .AddAspNetCoreInstrumentation() + .AddAspNetCoreInstrumentation(tracing => + // Exclude health check requests from tracing + tracing.Filter = context => + !context.Request.Path.StartsWithSegments(HealthEndpointPath) + && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) + ) // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) //.AddGrpcClientInstrumentation() .AddHttpClientInstrumentation() @@ -111,10 +119,10 @@ public static WebApplication MapDefaultEndpoints(this WebApplication app) if (app.Environment.IsDevelopment()) { // All health checks must pass for app to be considered ready to accept traffic after starting - app.MapHealthChecks("/health"); + app.MapHealthChecks(HealthEndpointPath); // Only health checks tagged with the "live" tag must pass for app to be considered alive - app.MapHealthChecks("/alive", new HealthCheckOptions + app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions { Predicate = r => r.Tags.Contains("live") }); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj index 8f588a6bef4..474dd445fae 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj @@ -10,13 +10,13 @@ - - - - - - - + + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor index a7b1502d894..8aa0ec9fd28 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor @@ -40,6 +40,7 @@ Don't refer to the presence of citations; just emit these tags right at the end, with no surrounding text. "; + private int statefulMessageCount; private readonly ChatOptions chatOptions = new(); private readonly List messages = new(); private CancellationTokenSource? currentResponseCancellation; @@ -49,6 +50,7 @@ protected override void OnInitialized() { + statefulMessageCount = 0; messages.Add(new(ChatRole.System, SystemPrompt)); chatOptions.Tools = [AIFunctionFactory.Create(SearchAsync)]; } @@ -66,15 +68,17 @@ var responseText = new TextContent(""); currentResponseMessage = new ChatMessage(ChatRole.Assistant, [responseText]); currentResponseCancellation = new(); - await foreach (var update in ChatClient.GetStreamingResponseAsync([.. messages], chatOptions, currentResponseCancellation.Token)) + await foreach (var update in ChatClient.GetStreamingResponseAsync(messages.Skip(statefulMessageCount), chatOptions, currentResponseCancellation.Token)) { messages.AddMessages(update, filter: c => c is not TextContent); responseText.Text += update.Text; + chatOptions.ConversationId = update.ConversationId; ChatMessageItem.NotifyChanged(currentResponseMessage); } // Store the final response in the conversation, and begin getting suggestions messages.Add(currentResponseMessage!); + statefulMessageCount = chatOptions.ConversationId is not null ? messages.Count : 0; currentResponseMessage = null; chatSuggestions?.Update(messages); } @@ -96,6 +100,8 @@ CancelAnyCurrentResponse(); messages.Clear(); messages.Add(new(ChatRole.System, SystemPrompt)); + chatOptions.ConversationId = null; + statefulMessageCount = 0; chatSuggestions?.Clear(); await chatInput!.FocusAsync(); } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Program.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Program.cs index a98905903b3..6d23308d93a 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Program.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Program.cs @@ -1,8 +1,8 @@ using Microsoft.Extensions.AI; +using OpenAI; using aichatweb.Web.Components; using aichatweb.Web.Services; using aichatweb.Web.Services.Ingestion; -using OpenAI; var builder = WebApplication.CreateBuilder(args); builder.AddServiceDefaults(); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Services/Ingestion/DataIngestor.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Services/Ingestion/DataIngestor.cs index 59732141849..2fe43370071 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Services/Ingestion/DataIngestor.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/Services/Ingestion/DataIngestor.cs @@ -26,7 +26,7 @@ public async Task IngestDataAsync(IIngestionSource source) var deletedDocuments = await source.GetDeletedDocumentsAsync(documentsForSource); foreach (var deletedDocument in deletedDocuments) { - logger.LogInformation("Removing ingested data for {documentId}", deletedDocument.DocumentId); + logger.LogInformation("Removing ingested data for {DocumentId}", deletedDocument.DocumentId); await DeleteChunksForDocumentAsync(deletedDocument); await documentsCollection.DeleteAsync(deletedDocument.Key); } @@ -34,7 +34,7 @@ public async Task IngestDataAsync(IIngestionSource source) var modifiedDocuments = await source.GetNewOrModifiedDocumentsAsync(documentsForSource); foreach (var modifiedDocument in modifiedDocuments) { - logger.LogInformation("Processing {documentId}", modifiedDocument.DocumentId); + logger.LogInformation("Processing {DocumentId}", modifiedDocument.DocumentId); await DeleteChunksForDocumentAsync(modifiedDocument); await documentsCollection.UpsertAsync(modifiedDocument); @@ -49,7 +49,7 @@ async Task DeleteChunksForDocumentAsync(IngestedDocument document) { var documentId = document.DocumentId; var chunksToDelete = await chunksCollection.GetAsync(record => record.DocumentId == documentId, int.MaxValue).ToListAsync(); - if (chunksToDelete.Any()) + if (chunksToDelete.Count != 0) { await chunksCollection.DeleteAsync(chunksToDelete.Select(r => r.Key)); } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj index 13d362a5b15..22c00c41978 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj @@ -8,13 +8,13 @@ - - - - - - - + + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/README.md index 70e543ffeae..0ef2c04a907 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/README.md +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/README.md @@ -46,9 +46,9 @@ Note: Qdrant and Docker are excellent open source products, but are not maintain ## Trust the localhost certificate -Several .NET Aspire templates include ASP.NET Core projects that are configured to use HTTPS by default. If this is the first time you're running the project, an exception might occur when loading the Aspire dashboard. This error can be resolved by trusting the self-signed development certificate with the .NET CLI. +Several Aspire templates include ASP.NET Core projects that are configured to use HTTPS by default. If this is the first time you're running the project, an exception might occur when loading the Aspire dashboard. This error can be resolved by trusting the self-signed development certificate with the .NET CLI. -See [Troubleshoot untrusted localhost certificate in .NET Aspire](https://learn.microsoft.com/dotnet/aspire/troubleshooting/untrusted-localhost-certificate) for more information. +See [Troubleshoot untrusted localhost certificate in Aspire](https://learn.microsoft.com/dotnet/aspire/troubleshooting/untrusted-localhost-certificate) for more information. # Updating JavaScript dependencies diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.AppHost/Program.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.AppHost/AppHost.cs similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.AppHost/Program.cs rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.AppHost/AppHost.cs diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.AppHost/Properties/launchSettings.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.AppHost/Properties/launchSettings.json index 4444e808585..681e3bf0d26 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.AppHost/Properties/launchSettings.json +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.AppHost/Properties/launchSettings.json @@ -9,8 +9,8 @@ "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", - "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:9999", - "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:9999" + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:9999", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:9999" } }, "http": { @@ -21,8 +21,8 @@ "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", - "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:9999", - "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:9999" + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:9999", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:9999" } } } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj index 193ed5f529a..70239b97fa8 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj @@ -1,20 +1,19 @@  - + Exe net9.0 enable enable - true secret - - - + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs index 81cc28b27d2..b44d60b604b 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs @@ -10,11 +10,14 @@ namespace Microsoft.Extensions.Hosting; -// Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry. // This project should be referenced by each service project in your solution. // To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults public static class Extensions { + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder { builder.ConfigureOpenTelemetry(); @@ -30,15 +33,7 @@ public static TBuilder AddServiceDefaults(this TBuilder builder) where #pragma warning restore EXTEXP0001 // Turn on resilience by default - http.AddStandardResilienceHandler(config => - { - // Extend the HTTP Client timeout for Ollama - config.AttemptTimeout.Timeout = TimeSpan.FromMinutes(3); - - // Must be at least double the AttemptTimeout to pass options validation - config.CircuitBreaker.SamplingDuration = TimeSpan.FromMinutes(10); - config.TotalRequestTimeout.Timeout = TimeSpan.FromMinutes(10); - }); + http.AddStandardResilienceHandler(); // Turn on service discovery by default http.AddServiceDiscovery(); @@ -72,7 +67,12 @@ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) w .WithTracing(tracing => { tracing.AddSource(builder.Environment.ApplicationName) - .AddAspNetCoreInstrumentation() + .AddAspNetCoreInstrumentation(tracing => + // Exclude health check requests from tracing + tracing.Filter = context => + !context.Request.Path.StartsWithSegments(HealthEndpointPath) + && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) + ) // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) //.AddGrpcClientInstrumentation() .AddHttpClientInstrumentation() @@ -119,10 +119,10 @@ public static WebApplication MapDefaultEndpoints(this WebApplication app) if (app.Environment.IsDevelopment()) { // All health checks must pass for app to be considered ready to accept traffic after starting - app.MapHealthChecks("/health"); + app.MapHealthChecks(HealthEndpointPath); // Only health checks tagged with the "live" tag must pass for app to be considered alive - app.MapHealthChecks("/alive", new HealthCheckOptions + app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions { Predicate = r => r.Tags.Contains("live") }); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj index 8f588a6bef4..474dd445fae 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj @@ -10,13 +10,13 @@ - - - - - - - + + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor index 718c2c46785..8aa0ec9fd28 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor @@ -40,6 +40,7 @@ Don't refer to the presence of citations; just emit these tags right at the end, with no surrounding text. "; + private int statefulMessageCount; private readonly ChatOptions chatOptions = new(); private readonly List messages = new(); private CancellationTokenSource? currentResponseCancellation; @@ -49,6 +50,7 @@ protected override void OnInitialized() { + statefulMessageCount = 0; messages.Add(new(ChatRole.System, SystemPrompt)); chatOptions.Tools = [AIFunctionFactory.Create(SearchAsync)]; } @@ -62,13 +64,22 @@ chatSuggestions?.Clear(); await chatInput!.FocusAsync(); - // Display a new response from the IChatClient, streaming responses - // aren't supported because Ollama will not support both streaming and using Tools + // Stream and display a new response from the IChatClient + var responseText = new TextContent(""); + currentResponseMessage = new ChatMessage(ChatRole.Assistant, [responseText]); currentResponseCancellation = new(); - var response = await ChatClient.GetResponseAsync(messages, chatOptions, currentResponseCancellation.Token); + await foreach (var update in ChatClient.GetStreamingResponseAsync(messages.Skip(statefulMessageCount), chatOptions, currentResponseCancellation.Token)) + { + messages.AddMessages(update, filter: c => c is not TextContent); + responseText.Text += update.Text; + chatOptions.ConversationId = update.ConversationId; + ChatMessageItem.NotifyChanged(currentResponseMessage); + } - // Store responses in the conversation, and begin getting suggestions - messages.AddMessages(response); + // Store the final response in the conversation, and begin getting suggestions + messages.Add(currentResponseMessage!); + statefulMessageCount = chatOptions.ConversationId is not null ? messages.Count : 0; + currentResponseMessage = null; chatSuggestions?.Update(messages); } @@ -89,6 +100,8 @@ CancelAnyCurrentResponse(); messages.Clear(); messages.Add(new(ChatRole.System, SystemPrompt)); + chatOptions.ConversationId = null; + statefulMessageCount = 0; chatSuggestions?.Clear(); await chatInput!.FocusAsync(); } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/OllamaResilienceHandlerExtensions.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/OllamaResilienceHandlerExtensions.cs new file mode 100644 index 00000000000..ae82393302c --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/OllamaResilienceHandlerExtensions.cs @@ -0,0 +1,34 @@ +using System; +using Microsoft.Extensions.DependencyInjection; + +namespace aichatweb.Web.Services; + +public static class OllamaResilienceHandlerExtensions +{ + public static IServiceCollection AddOllamaResilienceHandler(this IServiceCollection services) + { + services.ConfigureHttpClientDefaults(http => + { +#pragma warning disable EXTEXP0001 // RemoveAllResilienceHandlers is experimental + http.RemoveAllResilienceHandlers(); +#pragma warning restore EXTEXP0001 + + // Turn on resilience by default + http.AddStandardResilienceHandler(config => + { + // Extend the HTTP Client timeout for Ollama + config.AttemptTimeout.Timeout = TimeSpan.FromMinutes(3); + + // Must be at least double the AttemptTimeout to pass options validation + config.CircuitBreaker.SamplingDuration = TimeSpan.FromMinutes(10); + config.TotalRequestTimeout.Timeout = TimeSpan.FromMinutes(10); + }); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + return services; + } +} + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Program.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Program.cs index cdc88a082b7..c67c70db5d6 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Program.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Program.cs @@ -20,6 +20,11 @@ builder.Services.AddQdrantCollection("data-aichatweb-documents"); builder.Services.AddScoped(); builder.Services.AddSingleton(); +// Applies robust HTTP resilience settings for all HttpClients in the Web project, +// not across the entire solution. It's aimed at supporting Ollama scenarios due +// to its self-hosted nature and potentially slow responses. +// Remove this if you want to use the global or a different HTTP resilience policy instead. +builder.Services.AddOllamaResilienceHandler(); var app = builder.Build(); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Services/Ingestion/DataIngestor.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Services/Ingestion/DataIngestor.cs index d0f7a6bc3a8..894b85c10de 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Services/Ingestion/DataIngestor.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/Services/Ingestion/DataIngestor.cs @@ -26,7 +26,7 @@ public async Task IngestDataAsync(IIngestionSource source) var deletedDocuments = await source.GetDeletedDocumentsAsync(documentsForSource); foreach (var deletedDocument in deletedDocuments) { - logger.LogInformation("Removing ingested data for {documentId}", deletedDocument.DocumentId); + logger.LogInformation("Removing ingested data for {DocumentId}", deletedDocument.DocumentId); await DeleteChunksForDocumentAsync(deletedDocument); await documentsCollection.DeleteAsync(deletedDocument.Key); } @@ -34,7 +34,7 @@ public async Task IngestDataAsync(IIngestionSource source) var modifiedDocuments = await source.GetNewOrModifiedDocumentsAsync(documentsForSource); foreach (var modifiedDocument in modifiedDocuments) { - logger.LogInformation("Processing {documentId}", modifiedDocument.DocumentId); + logger.LogInformation("Processing {DocumentId}", modifiedDocument.DocumentId); await DeleteChunksForDocumentAsync(modifiedDocument); await documentsCollection.UpsertAsync(modifiedDocument); @@ -49,7 +49,7 @@ async Task DeleteChunksForDocumentAsync(IngestedDocument document) { var documentId = document.DocumentId; var chunksToDelete = await chunksCollection.GetAsync(record => record.DocumentId == documentId, int.MaxValue).ToListAsync(); - if (chunksToDelete.Any()) + if (chunksToDelete.Count != 0) { await chunksCollection.DeleteAsync(chunksToDelete.Select(r => r.Key)); } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj index a69db54eabc..2b573a14d47 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj @@ -8,14 +8,14 @@ - - - - - - - - + + + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Components/Pages/Chat/Chat.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Components/Pages/Chat/Chat.razor index a7b1502d894..8aa0ec9fd28 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Components/Pages/Chat/Chat.razor +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Components/Pages/Chat/Chat.razor @@ -40,6 +40,7 @@ Don't refer to the presence of citations; just emit these tags right at the end, with no surrounding text. "; + private int statefulMessageCount; private readonly ChatOptions chatOptions = new(); private readonly List messages = new(); private CancellationTokenSource? currentResponseCancellation; @@ -49,6 +50,7 @@ protected override void OnInitialized() { + statefulMessageCount = 0; messages.Add(new(ChatRole.System, SystemPrompt)); chatOptions.Tools = [AIFunctionFactory.Create(SearchAsync)]; } @@ -66,15 +68,17 @@ var responseText = new TextContent(""); currentResponseMessage = new ChatMessage(ChatRole.Assistant, [responseText]); currentResponseCancellation = new(); - await foreach (var update in ChatClient.GetStreamingResponseAsync([.. messages], chatOptions, currentResponseCancellation.Token)) + await foreach (var update in ChatClient.GetStreamingResponseAsync(messages.Skip(statefulMessageCount), chatOptions, currentResponseCancellation.Token)) { messages.AddMessages(update, filter: c => c is not TextContent); responseText.Text += update.Text; + chatOptions.ConversationId = update.ConversationId; ChatMessageItem.NotifyChanged(currentResponseMessage); } // Store the final response in the conversation, and begin getting suggestions messages.Add(currentResponseMessage!); + statefulMessageCount = chatOptions.ConversationId is not null ? messages.Count : 0; currentResponseMessage = null; chatSuggestions?.Update(messages); } @@ -96,6 +100,8 @@ CancelAnyCurrentResponse(); messages.Clear(); messages.Add(new(ChatRole.System, SystemPrompt)); + chatOptions.ConversationId = null; + statefulMessageCount = 0; chatSuggestions?.Clear(); await chatInput!.FocusAsync(); } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Program.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Program.cs index 2b9f0790817..434e5662d6d 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Program.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Program.cs @@ -1,11 +1,10 @@ -using Microsoft.Extensions.AI; +using System.ClientModel; +using Azure.Identity; +using Microsoft.Extensions.AI; +using OpenAI; using aichatweb.Components; using aichatweb.Services; using aichatweb.Services.Ingestion; -using Azure; -using Azure.Identity; -using OpenAI; -using System.ClientModel; var builder = WebApplication.CreateBuilder(args); builder.Services.AddRazorComponents().AddInteractiveServerComponents(); @@ -14,9 +13,14 @@ // You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: // cd this-project-directory // dotnet user-secrets set OpenAI:Key YOUR-API-KEY + var openAIClient = new OpenAIClient( new ApiKeyCredential(builder.Configuration["OpenAI:Key"] ?? throw new InvalidOperationException("Missing configuration: OpenAI:Key. See the README for details."))); -var chatClient = openAIClient.GetChatClient("gpt-4o-mini").AsIChatClient(); + +#pragma warning disable OPENAI001 // GetOpenAIResponseClient(string) is experimental and subject to change or removal in future updates. +var chatClient = openAIClient.GetOpenAIResponseClient("gpt-4o-mini").AsIChatClient(); +#pragma warning restore OPENAI001 + var embeddingGenerator = openAIClient.GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator(); // You will need to set the endpoint and key to your own values diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/README.md index 3f50502fb9f..73375f14cfa 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/README.md +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/README.md @@ -5,6 +5,9 @@ This project is an AI chat application that demonstrates how to chat with custom >[!NOTE] > Before running this project you need to configure the API keys or endpoints for the providers you have chosen. See below for details specific to your choices. +### Prerequisites +To use Azure OpenAI or Azure AI Search, you need an Azure account. If you don't already have one, [create an Azure account](https://azure.microsoft.com/free/). + # Configure the AI Model Provider ## Using OpenAI diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Services/Ingestion/DataIngestor.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Services/Ingestion/DataIngestor.cs index 65b520980c1..89fe287ebed 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Services/Ingestion/DataIngestor.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Services/Ingestion/DataIngestor.cs @@ -26,7 +26,7 @@ public async Task IngestDataAsync(IIngestionSource source) var deletedDocuments = await source.GetDeletedDocumentsAsync(documentsForSource); foreach (var deletedDocument in deletedDocuments) { - logger.LogInformation("Removing ingested data for {documentId}", deletedDocument.DocumentId); + logger.LogInformation("Removing ingested data for {DocumentId}", deletedDocument.DocumentId); await DeleteChunksForDocumentAsync(deletedDocument); await documentsCollection.DeleteAsync(deletedDocument.Key); } @@ -34,7 +34,7 @@ public async Task IngestDataAsync(IIngestionSource source) var modifiedDocuments = await source.GetNewOrModifiedDocumentsAsync(documentsForSource); foreach (var modifiedDocument in modifiedDocuments) { - logger.LogInformation("Processing {documentId}", modifiedDocument.DocumentId); + logger.LogInformation("Processing {DocumentId}", modifiedDocument.DocumentId); await DeleteChunksForDocumentAsync(modifiedDocument); await documentsCollection.UpsertAsync(modifiedDocument); @@ -49,7 +49,7 @@ async Task DeleteChunksForDocumentAsync(IngestedDocument document) { var documentId = document.DocumentId; var chunksToDelete = await chunksCollection.GetAsync(record => record.DocumentId == documentId, int.MaxValue).ToListAsync(); - if (chunksToDelete.Any()) + if (chunksToDelete.Count != 0) { await chunksCollection.DeleteAsync(chunksToDelete.Select(r => r.Key)); } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/aichatweb.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/aichatweb.csproj index 01d4728712a..da39bf3a4b0 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/aichatweb.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/aichatweb.csproj @@ -8,14 +8,14 @@ - - - - - - - - + + + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.AotTrue.verified/mcpserver/.mcp/server.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.AotTrue.verified/mcpserver/.mcp/server.json new file mode 100644 index 00000000000..2eeb28bf620 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.AotTrue.verified/mcpserver/.mcp/server.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json", + "description": "", + "name": "io.github./", + "version": "0.1.0-beta", + "packages": [ + { + "registryType": "nuget", + "identifier": "", + "version": "0.1.0-beta", + "transport": { + "type": "stdio" + }, + "packageArguments": [], + "environmentVariables": [] + } + ], + "repository": { + "url": "https://github.com//", + "source": "github" + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.AotTrue.verified/mcpserver/Program.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.AotTrue.verified/mcpserver/Program.cs new file mode 100644 index 00000000000..73b72d35a46 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.AotTrue.verified/mcpserver/Program.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +var builder = Host.CreateApplicationBuilder(args); + +// Configure all logs to go to stderr (stdout is used for the MCP protocol messages). +builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace); + +// Add the MCP services: the transport to use (stdio) and the tools to register. +builder.Services + .AddMcpServer() + .WithStdioServerTransport() + .WithTools(); + +await builder.Build().RunAsync(); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.AotTrue.verified/mcpserver/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.AotTrue.verified/mcpserver/README.md new file mode 100644 index 00000000000..31035a6370e --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.AotTrue.verified/mcpserver/README.md @@ -0,0 +1,98 @@ +# MCP Server + +This README was created using the C# MCP server project template. +It demonstrates how you can easily create an MCP server using C# and publish it as a NuGet package. + +The MCP server is built as a self-contained application and does not require the .NET runtime to be installed on the target machine. +However, since it is self-contained, it must be built for each target platform separately. +By default, the template is configured to build for: +* `win-x64` +* `win-arm64` +* `osx-arm64` +* `linux-x64` +* `linux-arm64` +* `linux-musl-x64` + +If your users require more platforms to be supported, update the list of runtime identifiers in the project's `` element. + +See [aka.ms/nuget/mcp/guide](https://aka.ms/nuget/mcp/guide) for the full guide. + +Please note that this template is currently in an early preview stage. If you have feedback, please take a [brief survey](http://aka.ms/dotnet-mcp-template-survey). + +## Checklist before publishing to NuGet.org + +- Test the MCP server locally using the steps below. +- Update the package metadata in the .csproj file, in particular the ``. +- Update `.mcp/server.json` to declare your MCP server's inputs. + - See [configuring inputs](https://aka.ms/nuget/mcp/guide/configuring-inputs) for more details. +- Pack the project using `dotnet pack`. + +The `bin/Release` directory will contain the package file (.nupkg), which can be [published to NuGet.org](https://learn.microsoft.com/nuget/nuget-org/publish-a-package). + +## Developing locally + +To test this MCP server from source code (locally) without using a built MCP server package, you can configure your IDE to run the project directly using `dotnet run`. + +```json +{ + "servers": { + "mcpserver": { + "type": "stdio", + "command": "dotnet", + "args": [ + "run", + "--project", + "" + ] + } + } +} +``` + +## Testing the MCP Server + +Once configured, you can ask Copilot Chat for a random number, for example, `Give me 3 random numbers`. It should prompt you to use the `get_random_number` tool on the `mcpserver` MCP server and show you the results. + +## Publishing to NuGet.org + +1. Run `dotnet pack -c Release` to create the NuGet package +2. Publish to NuGet.org with `dotnet nuget push bin/Release/*.nupkg --api-key --source https://api.nuget.org/v3/index.json` + +## Using the MCP Server from NuGet.org + +Once the MCP server package is published to NuGet.org, you can configure it in your preferred IDE. Both VS Code and Visual Studio use the `dnx` command to download and install the MCP server package from NuGet.org. + +- **VS Code**: Create a `/.vscode/mcp.json` file +- **Visual Studio**: Create a `\.mcp.json` file + +For both VS Code and Visual Studio, the configuration file uses the following server definition: + +```json +{ + "servers": { + "mcpserver": { + "type": "stdio", + "command": "dnx", + "args": [ + "", + "--version", + "", + "--yes" + ] + } + } +} +``` + +## More information + +.NET MCP servers use the [ModelContextProtocol](https://www.nuget.org/packages/ModelContextProtocol) C# SDK. For more information about MCP: + +- [Official Documentation](https://modelcontextprotocol.io/) +- [Protocol Specification](https://spec.modelcontextprotocol.io/) +- [GitHub Organization](https://github.com/modelcontextprotocol) + +Refer to the VS Code or Visual Studio documentation for more information on configuring and using MCP servers: + +- [Use MCP servers in VS Code (Preview)](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) +- [Use MCP servers in Visual Studio (Preview)](https://learn.microsoft.com/visualstudio/ide/mcp-servers) diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.AotTrue.verified/mcpserver/Tools/RandomNumberTools.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.AotTrue.verified/mcpserver/Tools/RandomNumberTools.cs new file mode 100644 index 00000000000..611745f4129 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.AotTrue.verified/mcpserver/Tools/RandomNumberTools.cs @@ -0,0 +1,18 @@ +using System.ComponentModel; +using ModelContextProtocol.Server; + +/// +/// Sample MCP tools for demonstration purposes. +/// These tools can be invoked by MCP clients to perform various operations. +/// +internal class RandomNumberTools +{ + [McpServerTool] + [Description("Generates a random number between the specified minimum and maximum values.")] + public int GetRandomNumber( + [Description("Minimum value (inclusive)")] int min = 0, + [Description("Maximum value (exclusive)")] int max = 100) + { + return Random.Shared.Next(min, max); + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.AotTrue.verified/mcpserver/mcpserver.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.AotTrue.verified/mcpserver/mcpserver.csproj new file mode 100644 index 00000000000..1b2dd939947 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.AotTrue.verified/mcpserver/mcpserver.csproj @@ -0,0 +1,44 @@ + + + + net9.0 + win-x64;win-arm64;osx-arm64;linux-x64;linux-arm64;linux-musl-x64 + Exe + enable + enable + + + true + McpServer + + + true + true + + + true + + + true + true + + + README.md + SampleMcpServer + 0.1.0-beta + AI; MCP; server; stdio + An MCP server using the MCP C# SDK. + + + + + + + + + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/.mcp/server.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/.mcp/server.json index ab997541e52..2eeb28bf620 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/.mcp/server.json +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/.mcp/server.json @@ -1,20 +1,22 @@ { + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json", "description": "", "name": "io.github./", + "version": "0.1.0-beta", "packages": [ { - "registry_name": "nuget", - "name": "", + "registryType": "nuget", + "identifier": "", "version": "0.1.0-beta", - "package_arguments": [], - "environment_variables": [] + "transport": { + "type": "stdio" + }, + "packageArguments": [], + "environmentVariables": [] } ], "repository": { "url": "https://github.com//", "source": "github" - }, - "version_detail": { - "version": "0.1.0-beta" } } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/README.md index 5c00a3bf669..31035a6370e 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/README.md +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/README.md @@ -1,9 +1,24 @@ # MCP Server -This README was created using the C# MCP server template project. It demonstrates how you can easily create an MCP server using C# and then package it in a NuGet package. +This README was created using the C# MCP server project template. +It demonstrates how you can easily create an MCP server using C# and publish it as a NuGet package. + +The MCP server is built as a self-contained application and does not require the .NET runtime to be installed on the target machine. +However, since it is self-contained, it must be built for each target platform separately. +By default, the template is configured to build for: +* `win-x64` +* `win-arm64` +* `osx-arm64` +* `linux-x64` +* `linux-arm64` +* `linux-musl-x64` + +If your users require more platforms to be supported, update the list of runtime identifiers in the project's `` element. See [aka.ms/nuget/mcp/guide](https://aka.ms/nuget/mcp/guide) for the full guide. +Please note that this template is currently in an early preview stage. If you have feedback, please take a [brief survey](http://aka.ms/dotnet-mcp-template-survey). + ## Checklist before publishing to NuGet.org - Test the MCP server locally using the steps below. @@ -14,67 +29,70 @@ See [aka.ms/nuget/mcp/guide](https://aka.ms/nuget/mcp/guide) for the full guide. The `bin/Release` directory will contain the package file (.nupkg), which can be [published to NuGet.org](https://learn.microsoft.com/nuget/nuget-org/publish-a-package). -## Using the MCP Server in VS Code +## Developing locally -Once the MCP server package is published to NuGet.org, you can use the following VS Code user configuration to download and install the MCP server package. See [Use MCP servers in VS Code (Preview)](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) for more information about using MCP servers in VS Code. +To test this MCP server from source code (locally) without using a built MCP server package, you can configure your IDE to run the project directly using `dotnet run`. ```json { - "mcp": { - "servers": { - "mcpserver": { - "type": "stdio", - "command": "dnx", - "args": [ - "", - "--version", - "", - "--yes" - ] - } + "servers": { + "mcpserver": { + "type": "stdio", + "command": "dotnet", + "args": [ + "run", + "--project", + "" + ] } } } ``` -Now you can ask Copilot Chat for a random number, for example, `Give me 3 random numbers`. It should prompt you to use the `get_random_number` tool on the `mcpserver` MCP server and show you the results. +## Testing the MCP Server + +Once configured, you can ask Copilot Chat for a random number, for example, `Give me 3 random numbers`. It should prompt you to use the `get_random_number` tool on the `mcpserver` MCP server and show you the results. + +## Publishing to NuGet.org + +1. Run `dotnet pack -c Release` to create the NuGet package +2. Publish to NuGet.org with `dotnet nuget push bin/Release/*.nupkg --api-key --source https://api.nuget.org/v3/index.json` -## Developing locally in VS Code +## Using the MCP Server from NuGet.org -To test this MCP server from source code (locally) without using a built MCP server package, create a `.vscode/mcp.json` file (a VS Code workspace settings file) in your project directory and add the following configuration: +Once the MCP server package is published to NuGet.org, you can configure it in your preferred IDE. Both VS Code and Visual Studio use the `dnx` command to download and install the MCP server package from NuGet.org. + +- **VS Code**: Create a `/.vscode/mcp.json` file +- **Visual Studio**: Create a `\.mcp.json` file + +For both VS Code and Visual Studio, the configuration file uses the following server definition: ```json { "servers": { "mcpserver": { "type": "stdio", - "command": "dotnet", + "command": "dnx", "args": [ - "run", - "--project", - "" + "", + "--version", + "", + "--yes" ] } } } ``` -Alternatively, you can configure your VS Code user settings to use your local project: +## More information -```json -{ - "mcp": { - "servers": { - "mcpserver": { - "type": "stdio", - "command": "dotnet", - "args": [ - "run", - "--project", - "" - ] - } - } - } -} -``` +.NET MCP servers use the [ModelContextProtocol](https://www.nuget.org/packages/ModelContextProtocol) C# SDK. For more information about MCP: + +- [Official Documentation](https://modelcontextprotocol.io/) +- [Protocol Specification](https://spec.modelcontextprotocol.io/) +- [GitHub Organization](https://github.com/modelcontextprotocol) + +Refer to the VS Code or Visual Studio documentation for more information on configuring and using MCP servers: + +- [Use MCP servers in VS Code (Preview)](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) +- [Use MCP servers in Visual Studio (Preview)](https://learn.microsoft.com/visualstudio/ide/mcp-servers) diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/mcpserver.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/mcpserver.csproj index e959c64702f..f6da2d9485e 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/mcpserver.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/mcpserver.csproj @@ -1,7 +1,8 @@  - net10.0 + net9.0 + win-x64;win-arm64;osx-arm64;linux-x64;linux-arm64;linux-musl-x64 Exe enable enable @@ -10,6 +11,13 @@ true McpServer + + true + true + + + true + README.md SampleMcpServer @@ -26,7 +34,7 @@ - + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.SelfContainedFalse.verified/mcpserver/.mcp/server.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.SelfContainedFalse.verified/mcpserver/.mcp/server.json new file mode 100644 index 00000000000..2eeb28bf620 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.SelfContainedFalse.verified/mcpserver/.mcp/server.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json", + "description": "", + "name": "io.github./", + "version": "0.1.0-beta", + "packages": [ + { + "registryType": "nuget", + "identifier": "", + "version": "0.1.0-beta", + "transport": { + "type": "stdio" + }, + "packageArguments": [], + "environmentVariables": [] + } + ], + "repository": { + "url": "https://github.com//", + "source": "github" + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.SelfContainedFalse.verified/mcpserver/Program.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.SelfContainedFalse.verified/mcpserver/Program.cs new file mode 100644 index 00000000000..73b72d35a46 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.SelfContainedFalse.verified/mcpserver/Program.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +var builder = Host.CreateApplicationBuilder(args); + +// Configure all logs to go to stderr (stdout is used for the MCP protocol messages). +builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace); + +// Add the MCP services: the transport to use (stdio) and the tools to register. +builder.Services + .AddMcpServer() + .WithStdioServerTransport() + .WithTools(); + +await builder.Build().RunAsync(); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.SelfContainedFalse.verified/mcpserver/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.SelfContainedFalse.verified/mcpserver/README.md new file mode 100644 index 00000000000..1702211733a --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.SelfContainedFalse.verified/mcpserver/README.md @@ -0,0 +1,91 @@ +# MCP Server + +This README was created using the C# MCP server project template. +It demonstrates how you can easily create an MCP server using C# and publish it as a NuGet package. + +The MCP server is built as a framework-dependent application and requires the .NET runtime to be installed on the target machine. +The application is configured to roll-forward to the next highest major version of the runtime if one is available on the target machine. +If an applicable .NET runtime is not available, the MCP server will not start. +Consider building the MCP server as a self-contained application if you want to avoid this dependency. + +See [aka.ms/nuget/mcp/guide](https://aka.ms/nuget/mcp/guide) for the full guide. + +Please note that this template is currently in an early preview stage. If you have feedback, please take a [brief survey](http://aka.ms/dotnet-mcp-template-survey). + +## Checklist before publishing to NuGet.org + +- Test the MCP server locally using the steps below. +- Update the package metadata in the .csproj file, in particular the ``. +- Update `.mcp/server.json` to declare your MCP server's inputs. + - See [configuring inputs](https://aka.ms/nuget/mcp/guide/configuring-inputs) for more details. +- Pack the project using `dotnet pack`. + +The `bin/Release` directory will contain the package file (.nupkg), which can be [published to NuGet.org](https://learn.microsoft.com/nuget/nuget-org/publish-a-package). + +## Developing locally + +To test this MCP server from source code (locally) without using a built MCP server package, you can configure your IDE to run the project directly using `dotnet run`. + +```json +{ + "servers": { + "mcpserver": { + "type": "stdio", + "command": "dotnet", + "args": [ + "run", + "--project", + "" + ] + } + } +} +``` + +## Testing the MCP Server + +Once configured, you can ask Copilot Chat for a random number, for example, `Give me 3 random numbers`. It should prompt you to use the `get_random_number` tool on the `mcpserver` MCP server and show you the results. + +## Publishing to NuGet.org + +1. Run `dotnet pack -c Release` to create the NuGet package +2. Publish to NuGet.org with `dotnet nuget push bin/Release/*.nupkg --api-key --source https://api.nuget.org/v3/index.json` + +## Using the MCP Server from NuGet.org + +Once the MCP server package is published to NuGet.org, you can configure it in your preferred IDE. Both VS Code and Visual Studio use the `dnx` command to download and install the MCP server package from NuGet.org. + +- **VS Code**: Create a `/.vscode/mcp.json` file +- **Visual Studio**: Create a `\.mcp.json` file + +For both VS Code and Visual Studio, the configuration file uses the following server definition: + +```json +{ + "servers": { + "mcpserver": { + "type": "stdio", + "command": "dnx", + "args": [ + "", + "--version", + "", + "--yes" + ] + } + } +} +``` + +## More information + +.NET MCP servers use the [ModelContextProtocol](https://www.nuget.org/packages/ModelContextProtocol) C# SDK. For more information about MCP: + +- [Official Documentation](https://modelcontextprotocol.io/) +- [Protocol Specification](https://spec.modelcontextprotocol.io/) +- [GitHub Organization](https://github.com/modelcontextprotocol) + +Refer to the VS Code or Visual Studio documentation for more information on configuring and using MCP servers: + +- [Use MCP servers in VS Code (Preview)](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) +- [Use MCP servers in Visual Studio (Preview)](https://learn.microsoft.com/visualstudio/ide/mcp-servers) diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.SelfContainedFalse.verified/mcpserver/Tools/RandomNumberTools.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.SelfContainedFalse.verified/mcpserver/Tools/RandomNumberTools.cs new file mode 100644 index 00000000000..611745f4129 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.SelfContainedFalse.verified/mcpserver/Tools/RandomNumberTools.cs @@ -0,0 +1,18 @@ +using System.ComponentModel; +using ModelContextProtocol.Server; + +/// +/// Sample MCP tools for demonstration purposes. +/// These tools can be invoked by MCP clients to perform various operations. +/// +internal class RandomNumberTools +{ + [McpServerTool] + [Description("Generates a random number between the specified minimum and maximum values.")] + public int GetRandomNumber( + [Description("Minimum value (inclusive)")] int min = 0, + [Description("Maximum value (exclusive)")] int max = 100) + { + return Random.Shared.Next(min, max); + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.SelfContainedFalse.verified/mcpserver/mcpserver.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.SelfContainedFalse.verified/mcpserver/mcpserver.csproj new file mode 100644 index 00000000000..a25caa73486 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.SelfContainedFalse.verified/mcpserver/mcpserver.csproj @@ -0,0 +1,33 @@ + + + + net9.0 + Major + Exe + enable + enable + + + true + McpServer + + + README.md + SampleMcpServer + 0.1.0-beta + AI; MCP; server; stdio + An MCP server using the MCP C# SDK. + + + + + + + + + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.net10.verified/mcpserver/.mcp/server.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.net10.verified/mcpserver/.mcp/server.json new file mode 100644 index 00000000000..2eeb28bf620 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.net10.verified/mcpserver/.mcp/server.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json", + "description": "", + "name": "io.github./", + "version": "0.1.0-beta", + "packages": [ + { + "registryType": "nuget", + "identifier": "", + "version": "0.1.0-beta", + "transport": { + "type": "stdio" + }, + "packageArguments": [], + "environmentVariables": [] + } + ], + "repository": { + "url": "https://github.com//", + "source": "github" + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.net10.verified/mcpserver/Program.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.net10.verified/mcpserver/Program.cs new file mode 100644 index 00000000000..73b72d35a46 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.net10.verified/mcpserver/Program.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +var builder = Host.CreateApplicationBuilder(args); + +// Configure all logs to go to stderr (stdout is used for the MCP protocol messages). +builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace); + +// Add the MCP services: the transport to use (stdio) and the tools to register. +builder.Services + .AddMcpServer() + .WithStdioServerTransport() + .WithTools(); + +await builder.Build().RunAsync(); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.net10.verified/mcpserver/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.net10.verified/mcpserver/README.md new file mode 100644 index 00000000000..31035a6370e --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.net10.verified/mcpserver/README.md @@ -0,0 +1,98 @@ +# MCP Server + +This README was created using the C# MCP server project template. +It demonstrates how you can easily create an MCP server using C# and publish it as a NuGet package. + +The MCP server is built as a self-contained application and does not require the .NET runtime to be installed on the target machine. +However, since it is self-contained, it must be built for each target platform separately. +By default, the template is configured to build for: +* `win-x64` +* `win-arm64` +* `osx-arm64` +* `linux-x64` +* `linux-arm64` +* `linux-musl-x64` + +If your users require more platforms to be supported, update the list of runtime identifiers in the project's `` element. + +See [aka.ms/nuget/mcp/guide](https://aka.ms/nuget/mcp/guide) for the full guide. + +Please note that this template is currently in an early preview stage. If you have feedback, please take a [brief survey](http://aka.ms/dotnet-mcp-template-survey). + +## Checklist before publishing to NuGet.org + +- Test the MCP server locally using the steps below. +- Update the package metadata in the .csproj file, in particular the ``. +- Update `.mcp/server.json` to declare your MCP server's inputs. + - See [configuring inputs](https://aka.ms/nuget/mcp/guide/configuring-inputs) for more details. +- Pack the project using `dotnet pack`. + +The `bin/Release` directory will contain the package file (.nupkg), which can be [published to NuGet.org](https://learn.microsoft.com/nuget/nuget-org/publish-a-package). + +## Developing locally + +To test this MCP server from source code (locally) without using a built MCP server package, you can configure your IDE to run the project directly using `dotnet run`. + +```json +{ + "servers": { + "mcpserver": { + "type": "stdio", + "command": "dotnet", + "args": [ + "run", + "--project", + "" + ] + } + } +} +``` + +## Testing the MCP Server + +Once configured, you can ask Copilot Chat for a random number, for example, `Give me 3 random numbers`. It should prompt you to use the `get_random_number` tool on the `mcpserver` MCP server and show you the results. + +## Publishing to NuGet.org + +1. Run `dotnet pack -c Release` to create the NuGet package +2. Publish to NuGet.org with `dotnet nuget push bin/Release/*.nupkg --api-key --source https://api.nuget.org/v3/index.json` + +## Using the MCP Server from NuGet.org + +Once the MCP server package is published to NuGet.org, you can configure it in your preferred IDE. Both VS Code and Visual Studio use the `dnx` command to download and install the MCP server package from NuGet.org. + +- **VS Code**: Create a `/.vscode/mcp.json` file +- **Visual Studio**: Create a `\.mcp.json` file + +For both VS Code and Visual Studio, the configuration file uses the following server definition: + +```json +{ + "servers": { + "mcpserver": { + "type": "stdio", + "command": "dnx", + "args": [ + "", + "--version", + "", + "--yes" + ] + } + } +} +``` + +## More information + +.NET MCP servers use the [ModelContextProtocol](https://www.nuget.org/packages/ModelContextProtocol) C# SDK. For more information about MCP: + +- [Official Documentation](https://modelcontextprotocol.io/) +- [Protocol Specification](https://spec.modelcontextprotocol.io/) +- [GitHub Organization](https://github.com/modelcontextprotocol) + +Refer to the VS Code or Visual Studio documentation for more information on configuring and using MCP servers: + +- [Use MCP servers in VS Code (Preview)](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) +- [Use MCP servers in Visual Studio (Preview)](https://learn.microsoft.com/visualstudio/ide/mcp-servers) diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.net10.verified/mcpserver/Tools/RandomNumberTools.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.net10.verified/mcpserver/Tools/RandomNumberTools.cs new file mode 100644 index 00000000000..611745f4129 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.net10.verified/mcpserver/Tools/RandomNumberTools.cs @@ -0,0 +1,18 @@ +using System.ComponentModel; +using ModelContextProtocol.Server; + +/// +/// Sample MCP tools for demonstration purposes. +/// These tools can be invoked by MCP clients to perform various operations. +/// +internal class RandomNumberTools +{ + [McpServerTool] + [Description("Generates a random number between the specified minimum and maximum values.")] + public int GetRandomNumber( + [Description("Minimum value (inclusive)")] int min = 0, + [Description("Maximum value (exclusive)")] int max = 100) + { + return Random.Shared.Next(min, max); + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.net10.verified/mcpserver/mcpserver.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.net10.verified/mcpserver/mcpserver.csproj new file mode 100644 index 00000000000..393d0558d5e --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.net10.verified/mcpserver/mcpserver.csproj @@ -0,0 +1,40 @@ + + + + net10.0 + win-x64;win-arm64;osx-arm64;linux-x64;linux-arm64;linux-musl-x64 + Exe + enable + enable + + + true + McpServer + + + true + true + + + true + + + README.md + SampleMcpServer + 0.1.0-beta + AI; MCP; server; stdio + An MCP server using the MCP C# SDK. + + + + + + + + + + + + + + diff --git a/test/Shared/JsonSchemaExporter/JsonSchemaExporterConfigurationTests.cs b/test/Shared/JsonSchemaExporter/JsonSchemaExporterConfigurationTests.cs deleted file mode 100644 index 1d2b6caa74e..00000000000 --- a/test/Shared/JsonSchemaExporter/JsonSchemaExporterConfigurationTests.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Text.Json.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 deleted file mode 100644 index 70babf81334..00000000000 --- a/test/Shared/JsonSchemaExporter/JsonSchemaExporterTests.cs +++ /dev/null @@ -1,180 +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.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; -using static Microsoft.Extensions.AI.JsonSchemaExporter.TestTypes; - -#pragma warning disable SA1402 // File may only contain a single type - -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); - SchemaTestHelpers.AssertEqualJsonSchema(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); - SchemaTestHelpers.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 - -#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() - { - 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" : {} }"""); - SchemaTestHelpers.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 index 26902bfe0db..7c7cc7fc9a7 100644 --- a/test/Shared/JsonSchemaExporter/TestData.cs +++ b/test/Shared/JsonSchemaExporter/TestData.cs @@ -13,7 +13,9 @@ internal sealed record TestData( T? Value, [StringSyntax(StringSyntaxAttribute.Json)] string ExpectedJsonSchema, IEnumerable? AdditionalValues = null, - object? ExporterOptions = null, +#if TESTS_JSON_SCHEMA_EXPORTER_POLYFILL + System.Text.Json.Schema.JsonSchemaExporterOptions? ExporterOptions = null, +#endif JsonSerializerOptions? Options = null, bool WritesNumbersAsStrings = false) : ITestData @@ -22,7 +24,9 @@ internal sealed record TestData( public Type Type => typeof(T); object? ITestData.Value => Value; +#if TESTS_JSON_SCHEMA_EXPORTER_POLYFILL object? ITestData.ExporterOptions => ExporterOptions; +#endif JsonNode ITestData.ExpectedJsonSchema { get; } = JsonNode.Parse(ExpectedJsonSchema, documentOptions: _schemaParseOptions) ?? throw new ArgumentNullException("schema must not be null"); @@ -32,7 +36,7 @@ IEnumerable ITestData.GetTestDataForAllValues() yield return this; if (default(T) is null && -#if NET9_0_OR_GREATER +#if TESTS_JSON_SCHEMA_EXPORTER_POLYFILL ExporterOptions is System.Text.Json.Schema.JsonSchemaExporterOptions { TreatNullObliviousAsNonNullable: false } && #endif Value is not null) @@ -58,7 +62,9 @@ public interface ITestData JsonNode ExpectedJsonSchema { get; } +#if TESTS_JSON_SCHEMA_EXPORTER_POLYFILL object? ExporterOptions { get; } +#endif JsonSerializerOptions? Options { get; } diff --git a/test/Shared/JsonSchemaExporter/TestTypes.cs b/test/Shared/JsonSchemaExporter/TestTypes.cs index 7cfd0ce45be..794e58fa2b8 100644 --- a/test/Shared/JsonSchemaExporter/TestTypes.cs +++ b/test/Shared/JsonSchemaExporter/TestTypes.cs @@ -9,12 +9,9 @@ using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; using System.Linq; -#if NET9_0_OR_GREATER -using System.Reflection; -#endif using System.Text.Json; using System.Text.Json.Nodes; -#if NET9_0_OR_GREATER +#if TESTS_JSON_SCHEMA_EXPORTER_POLYFILL using System.Text.Json.Schema; #endif using System.Text.Json.Serialization; @@ -135,6 +132,21 @@ public static IEnumerable GetTestDataCore() } """); +#if !NET9_0 && TESTS_JSON_SCHEMA_EXPORTER_POLYFILL + // Regression test for https://github.com/dotnet/runtime/issues/117493 + yield return new TestData( + Value: 42, + AdditionalValues: [null], + ExpectedJsonSchema: """{"type":["integer","null"]}""", + ExporterOptions: new() { TreatNullObliviousAsNonNullable = true }); + + yield return new TestData( + Value: DateTimeOffset.MinValue, + AdditionalValues: [null], + ExpectedJsonSchema: """{"type":["string","null"],"format":"date-time"}""", + ExporterOptions: new() { TreatNullObliviousAsNonNullable = true }); +#endif + // User-defined POCOs yield return new TestData( Value: new() { String = "string", StringNullable = "string", Int = 42, Double = 3.14, Boolean = true }, @@ -152,7 +164,7 @@ public static IEnumerable GetTestDataCore() } """); -#if NET9_0_OR_GREATER +#if TESTS_JSON_SCHEMA_EXPORTER_POLYFILL // Same as above but with nullable types set to non-nullable yield return new TestData( Value: new() { String = "string", StringNullable = "string", Int = 42, Double = 3.14, Boolean = true }, @@ -311,7 +323,7 @@ public static IEnumerable GetTestDataCore() } """); -#if NET9_0_OR_GREATER +#if TESTS_JSON_SCHEMA_EXPORTER_POLYFILL // Same as above but with non-nullable reference types by default. yield return new TestData( Value: new() { Value = 1, Next = new() { Value = 2, Next = new() { Value = 3 } } }, @@ -761,7 +773,7 @@ of the type which points to the first occurrence. */ } """); -#if NET9_0_OR_GREATER +#if TEST yield return new TestData( Value: new("string", -1), ExpectedJsonSchema: """ @@ -1164,7 +1176,7 @@ public readonly struct StructDictionary(IEnumerable _dictionary.Count; public bool ContainsKey(TKey key) => _dictionary.ContainsKey(key); public IEnumerator> GetEnumerator() => _dictionary.GetEnumerator(); -#if NETCOREAPP +#if NET public bool TryGetValue(TKey key, [MaybeNullWhen(false)] out TValue value) => _dictionary.TryGetValue(key, out value); #else public bool TryGetValue(TKey key, out TValue value) => _dictionary.TryGetValue(key, out value); @@ -1249,6 +1261,7 @@ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions [JsonSerializable(typeof(IntEnum?))] [JsonSerializable(typeof(StringEnum?))] [JsonSerializable(typeof(SimpleRecordStruct?))] + [JsonSerializable(typeof(DateTimeOffset?))] // User-defined POCOs [JsonSerializable(typeof(SimplePoco))] [JsonSerializable(typeof(SimpleRecord))] @@ -1299,22 +1312,4 @@ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions [JsonSerializable(typeof(StructDictionary))] [JsonSerializable(typeof(XElement))] public partial class TestTypesContext : JsonSerializerContext; - -#if NET9_0_OR_GREATER - private static TAttribute? ResolveAttribute(this JsonSchemaExporterContext ctx) - where TAttribute : Attribute - { - // Resolve attributes from locations in the following order: - // 1. Property-level attributes - // 2. Parameter-level attributes and - // 3. Type-level attributes. - return - GetAttrs(ctx.PropertyInfo?.AttributeProvider) ?? - GetAttrs(ctx.PropertyInfo?.AssociatedParameter?.AttributeProvider) ?? - GetAttrs(ctx.TypeInfo.Type); - - static TAttribute? GetAttrs(ICustomAttributeProvider? provider) => - (TAttribute?)provider?.GetCustomAttributes(typeof(TAttribute), inherit: false).FirstOrDefault(); - } -#endif } diff --git a/test/Shared/Shared.Tests.csproj b/test/Shared/Shared.Tests.csproj index b7e27306f2a..2764d5f5d5d 100644 --- a/test/Shared/Shared.Tests.csproj +++ b/test/Shared/Shared.Tests.csproj @@ -2,7 +2,6 @@ Microsoft.Shared.Test Unit tests for Microsoft.Shared - $(DefineConstants);TESTS_JSON_SCHEMA_EXPORTER_POLYFILL diff --git a/test/TestUtilities/XUnit/ConditionalTheoryDiscoverer.cs b/test/TestUtilities/XUnit/ConditionalTheoryDiscoverer.cs index b1e53b8ed77..e30b5206c8c 100644 --- a/test/TestUtilities/XUnit/ConditionalTheoryDiscoverer.cs +++ b/test/TestUtilities/XUnit/ConditionalTheoryDiscoverer.cs @@ -63,9 +63,21 @@ protected override IEnumerable CreateTestCasesForDataRow(ITestFr } } - return skipReason != null ? - base.CreateTestCasesForSkippedDataRow(discoveryOptions, testMethod, theoryAttribute, dataRow, skipReason) - : base.CreateTestCasesForDataRow(discoveryOptions, testMethod, theoryAttribute, dataRow); + if (skipReason != null) + { + return base.CreateTestCasesForSkippedDataRow(discoveryOptions, testMethod, theoryAttribute, dataRow, skipReason); + } + + // Create test cases that can handle runtime SkipTestException + return new[] + { + new SkippedTheoryTestCase( + DiagnosticMessageSink, + discoveryOptions.MethodDisplayOrDefault(), + discoveryOptions.MethodDisplayOptionsOrDefault(), + testMethod, + dataRow) + }; } protected override IEnumerable CreateTestCasesForSkippedDataRow( diff --git a/test/TestUtilities/XUnit/EnvironmentVariableConditionAttribute.cs b/test/TestUtilities/XUnit/EnvironmentVariableConditionAttribute.cs new file mode 100644 index 00000000000..45a54409047 --- /dev/null +++ b/test/TestUtilities/XUnit/EnvironmentVariableConditionAttribute.cs @@ -0,0 +1,86 @@ +// Licensed 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; + +namespace Microsoft.TestUtilities; + +/// +/// Skips a test based on the value of an environment variable. +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Assembly, AllowMultiple = true)] +public class EnvironmentVariableConditionAttribute : Attribute, ITestCondition +{ + private string? _currentValue; + + /// + /// Initializes a new instance of the class. + /// + /// Name of the environment variable. + /// Value(s) of the environment variable to match for the condition. + /// + /// By default, the test will be run if the value of the variable matches any of the supplied values. + /// Set to False to run the test only if the value does not match. + /// + public EnvironmentVariableConditionAttribute(string variableName, params string[] values) + { + if (string.IsNullOrEmpty(variableName)) + { + throw new ArgumentException("Value cannot be null or empty.", nameof(variableName)); + } + + if (values == null || values.Length == 0) + { + throw new ArgumentException("You must supply at least one value to match.", nameof(values)); + } + + VariableName = variableName; + Values = values; + } + + /// + /// Gets or sets a value indicating whether the test should run if the value of the variable matches any + /// of the supplied values. If False, the test runs only if the value does not match any of the + /// supplied values. Default is True. + /// + public bool RunOnMatch { get; set; } = true; + + /// + /// Gets the name of the environment variable. + /// + public string VariableName { get; } + + /// + /// Gets the value(s) of the environment variable to match for the condition. + /// + public string[] Values { get; } + + /// + /// Gets a value indicating whether the condition is met for the configured environment variable and values. + /// + public bool IsMet + { + get + { + _currentValue ??= Environment.GetEnvironmentVariable(VariableName); + var hasMatched = Values.Any(value => string.Equals(value, _currentValue, StringComparison.OrdinalIgnoreCase)); + + return RunOnMatch ? hasMatched : !hasMatched; + } + } + + /// + /// Gets a value indicating the reason the test was skipped. + /// + public string SkipReason + { + get + { + var value = _currentValue ?? "(null)"; + + return $"Test skipped on environment variable with name '{VariableName}' and value '{value}' " + + $"for the '{nameof(RunOnMatch)}' value of '{RunOnMatch}'."; + } + } +} diff --git a/test/TestUtilities/XUnit/SkippedTheoryTestCase.cs b/test/TestUtilities/XUnit/SkippedTheoryTestCase.cs new file mode 100644 index 00000000000..e91a8f762d5 --- /dev/null +++ b/test/TestUtilities/XUnit/SkippedTheoryTestCase.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 System.Threading; +using System.Threading.Tasks; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Microsoft.TestUtilities; + +/// +/// A test case for ConditionalTheory that can handle runtime SkipTestException +/// by wrapping the message bus with SkippedTestMessageBus. +/// +public class SkippedTheoryTestCase : XunitTestCase +{ + [Obsolete("Called by the de-serializer; should only be called by deriving classes for de-serialization purposes", error: true)] + public SkippedTheoryTestCase() + { + } + + public SkippedTheoryTestCase( + 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; + } +} \ No newline at end of file