From bbd9db7adb83df7ad5489bc028dfce553068f702 Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Wed, 2 Jul 2025 23:30:25 +0000 Subject: [PATCH 01/13] Merged PR 51321: Flow .NET Servicing versions Flow .NET Servicing versions ---- #### AI description (iteration 1) #### PR Classification This PR performs a servicing update by bumping .NET dependency versions and refining CI pipeline configurations for stable releases. #### PR Summary The changes update dependency versions from 9.0.6 to 9.0.7 (and corresponding LTS versions) and adjust build settings to support release stability while streamlining internal feed configurations. - `eng/Version.Details.xml`: Updated multiple dependency version numbers and SHA values from 9.0.6 to 9.0.7. - `eng/Versions.props`: Bumped version properties (including LTS versions from 8.0.17 to 8.0.18) and enabled stable release settings by setting package stabilization to true and DotNetFinalVersionKind to release. - `NuGet.config`: Modified package source settings by adding new internal feed mappings and removing preexisting package source mapping blocks. - `azure-pipelines.yml` & `eng/pipelines/templates/BuildAndTest.yml`: Removed the CodeCoverage stage and added tasks for setting up private feed credentials, with integration tests commented out due to authentication requirements. - `Directory.Build.props`: Suppressed NU1507 warnings to accommodate internal feeds without package source mapping. --- Directory.Build.props | 5 + NuGet.config | 76 ++++++--- azure-pipelines.yml | 46 ------ eng/Version.Details.xml | 188 +++++++++++------------ eng/Versions.props | 124 +++++++-------- eng/pipelines/templates/BuildAndTest.yml | 32 +++- 6 files changed, 240 insertions(+), 231 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 0af806af628..0c0fcf22bfd 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -34,6 +34,11 @@ $(NetCoreTargetFrameworks) + + + $(NoWarn);NU1507 + + false latest diff --git a/NuGet.config b/NuGet.config index 0fedd015e82..5080151679a 100644 --- a/NuGet.config +++ b/NuGet.config @@ -4,10 +4,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -18,35 +45,40 @@ - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 3ec5e3d1cdb..0052dc9f706 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -239,51 +239,6 @@ extends: isWindows: false warnAsError: 0 - # ---------------------------------------------------------------- - # This stage performs quality gates enforcements - # ---------------------------------------------------------------- - - stage: codecoverage - displayName: CodeCoverage - dependsOn: - - build - condition: and(succeeded('build'), ne(variables['SkipQualityGates'], 'true')) - variables: - - template: /eng/common/templates-official/variables/pool-providers.yml@self - jobs: - - template: /eng/common/templates-official/jobs/jobs.yml@self - parameters: - enableMicrobuild: true - enableTelemetry: true - runAsPublic: ${{ variables['runAsPublic'] }} - workspace: - clean: all - - # ---------------------------------------------------------------- - # This stage downloads the code coverage reports from the build jobs, - # merges those and validates the combined test coverage. - # ---------------------------------------------------------------- - jobs: - - job: CodeCoverageReport - timeoutInMinutes: 180 - - pool: - name: NetCore1ESPool-Internal - image: 1es-mariner-2 - os: linux - - preSteps: - - checkout: self - clean: true - persistCredentials: true - fetchDepth: 1 - - steps: - - script: $(Build.SourcesDirectory)/build.sh --ci --restore - displayName: Init toolset - - - template: /eng/pipelines/templates/VerifyCoverageReport.yml - - # ---------------------------------------------------------------- # This stage only performs a build treating warnings as errors # to detect any kind of code style violations @@ -339,7 +294,6 @@ extends: parameters: validateDependsOn: - build - - codecoverage - correctness publishingInfraVersion: 3 enableSymbolValidation: false diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 3a746194f1b..cb6ead9fa88 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -1,196 +1,196 @@ - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 379bfc7b2559e7cc9f42f997a497b2f2dd8e12d2 + f6b3a5da75eb405046889a5447ec9b14cc29d285 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 379bfc7b2559e7cc9f42f997a497b2f2dd8e12d2 + f6b3a5da75eb405046889a5447ec9b14cc29d285 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 379bfc7b2559e7cc9f42f997a497b2f2dd8e12d2 + f6b3a5da75eb405046889a5447ec9b14cc29d285 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 379bfc7b2559e7cc9f42f997a497b2f2dd8e12d2 + f6b3a5da75eb405046889a5447ec9b14cc29d285 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 379bfc7b2559e7cc9f42f997a497b2f2dd8e12d2 + f6b3a5da75eb405046889a5447ec9b14cc29d285 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 379bfc7b2559e7cc9f42f997a497b2f2dd8e12d2 + f6b3a5da75eb405046889a5447ec9b14cc29d285 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 379bfc7b2559e7cc9f42f997a497b2f2dd8e12d2 + f6b3a5da75eb405046889a5447ec9b14cc29d285 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 379bfc7b2559e7cc9f42f997a497b2f2dd8e12d2 + f6b3a5da75eb405046889a5447ec9b14cc29d285 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 379bfc7b2559e7cc9f42f997a497b2f2dd8e12d2 + f6b3a5da75eb405046889a5447ec9b14cc29d285 - + https://dev.azure.com/dnceng/internal/_git/dotnet-efcore - 8751e6d519fda94d5154187358765311ed4a4e84 + 67d253c17619e6ba325e5390905ea2a13cc7f532 diff --git a/eng/Versions.props b/eng/Versions.props index 03fa0e24fd4..ac0cc576af8 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -11,14 +11,14 @@ - false + true - + release true @@ -34,55 +34,55 @@ --> - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 + 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.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 + 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.6 + 9.0.7 9.0.0-beta.25325.4 @@ -108,8 +108,8 @@ 8.0.1 8.0.0 8.0.2 - 8.0.17 - 8.0.17 + 8.0.18 + 8.0.18 8.0.0 8.0.1 8.0.1 @@ -123,20 +123,20 @@ 8.0.2 8.0.0 8.0.0 - 8.0.5 + 8.0.6 8.0.0 - 8.0.17 - 8.0.17 - 8.0.17 - 8.0.17 - 8.0.17 - 8.0.17 - 8.0.17 - 8.0.17 - 8.0.17 + 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.17 + 8.0.18 <_GeneratedContentEnablingJustBuiltPackages diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj index 2827734c794..7784747028e 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj @@ -61,6 +61,16 @@ **\NuGet.config; **\Directory.Build.targets; **\Directory.Build.props;" /> + + + diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.mcp/server.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.mcp/server.json new file mode 100644 index 00000000000..d4b9d0edf5b --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.mcp/server.json @@ -0,0 +1,20 @@ +{ + "description": "", + "name": "io.github./", + "packages": [ + { + "registry_name": "nuget", + "name": "", + "version": "0.1.0-beta", + "package_arguments": [], + "environment_variables": [] + } + ], + "repository": { + "url": "https://github.com//", + "source": "github" + }, + "version_detail": { + "version": "0.1.0-beta" + } +} diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/dotnetcli.host.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/dotnetcli.host.json new file mode 100644 index 00000000000..5be51dd6357 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/dotnetcli.host.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json.schemastore.org/dotnetcli.host", + "symbolInfo": {}, + "usageExamples": [ + "" + ] +} \ No newline at end of file diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/ide.host.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/ide.host.json new file mode 100644 index 00000000000..5edf447bbd4 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/ide.host.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://json.schemastore.org/ide.host", + "order": 0, + "icon": "ide/icon.ico", + "symbolInfo": [] +} diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/ide/icon.ico b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/ide/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..954709ffd6b9d360fbc0b121a63a29657e0fe768 GIT binary patch literal 38045 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 + + + net10.0 + Exe + enable + enable + + + true + McpServer + + + README.md + SampleMcpServer + 0.1.0-beta + AI; MCP; server; stdio + An MCP server using the MCP C# SDK. + + + + + + + + + + + + + + diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/Program.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/Program.cs new file mode 100644 index 00000000000..f320c93fd88 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/Program.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +var builder = Host.CreateApplicationBuilder(args); + +// Configure all logs to go to stderr (stdout is used for the MCP protocol messages). +builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace); + +// Add the MCP services: the transport to use (stdio) and the tools to register. +builder.Services + .AddMcpServer() + .WithStdioServerTransport() + .WithTools(); + +await builder.Build().RunAsync(); diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/README.md b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/README.md new file mode 100644 index 00000000000..dc6f5038b61 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/README.md @@ -0,0 +1,82 @@ +# MCP Server + +This README was created using the C# MCP server template project. It demonstrates how you can easily create an MCP server using C# and then package it in a NuGet package. + +See [aka.ms/nuget/mcp/guide](https://aka.ms/nuget/mcp/guide) for the full guide. + +## Checklist before publishing to NuGet.org + +- Test the MCP server locally using the steps below. +- Update the package metadata in the .csproj file, in particular the ``. +- Update `.mcp/server.json` to declare your MCP server's inputs. + - See [configuring inputs](https://aka.ms/nuget/mcp/guide/configuring-inputs) for more details. +- Pack the project using `dotnet pack`. + +The `bin/Release` directory will contain the package file (.nupkg), which can be [published to NuGet.org](https://learn.microsoft.com/nuget/nuget-org/publish-a-package). + +## Using the MCP Server in VS Code + +Once the MCP server package is published to NuGet.org, you can use the following VS Code user configuration to download and install the MCP server package. See [Use MCP servers in VS Code (Preview)](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) for more information about using MCP servers in VS Code. + +```json +{ + "mcp": { + "servers": { + "McpServer-CSharp": { + "type": "stdio", + "command": "dotnet", + "args": [ + "tool", + "exec", + "", + "--version", + "", + "--yes" + ] + } + } + } +} +``` + +Now you can ask Copilot Chat for a random number, for example, `Give me 3 random numbers`. It should prompt you to use the `get_random_number` tool on the `McpServer-CSharp` MCP server and show you the results. + +## Developing locally in VS Code + +To test this MCP server from source code (locally) without using a built MCP server package, create a `.vscode/mcp.json` file (a VS Code workspace settings file) in your project directory and add the following configuration: + +```json +{ + "servers": { + "McpServer-CSharp": { + "type": "stdio", + "command": "dotnet", + "args": [ + "run", + "--project", + "" + ] + } + } +} +``` + +Alternatively, you can configure your VS Code user settings to use your local project: + +```json +{ + "mcp": { + "servers": { + "McpServer-CSharp": { + "type": "stdio", + "command": "dotnet", + "args": [ + "run", + "--project", + "" + ] + } + } + } +} +``` diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/Tools/RandomNumberTools.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/Tools/RandomNumberTools.cs new file mode 100644 index 00000000000..4542f8505a5 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/Tools/RandomNumberTools.cs @@ -0,0 +1,18 @@ +using System.ComponentModel; +using ModelContextProtocol.Server; + +/// +/// Sample MCP tools for demonstration purposes. +/// These tools can be invoked by MCP clients to perform various operations. +/// +internal class RandomNumberTools +{ + [McpServerTool(Name = "get_random_number")] + [Description("Generates a random number between the specified minimum and maximum values.")] + public int GetRandomNumber( + [Description("Minimum value (inclusive)")] int min = 0, + [Description("Maximum value (exclusive)")] int max = 100) + { + return Random.Shared.Next(min, max); + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/McpServerSnapshotTests.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/McpServerSnapshotTests.cs new file mode 100644 index 00000000000..a3f3dedd1b5 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/McpServerSnapshotTests.cs @@ -0,0 +1,95 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.AI.Templates.Tests; +using Microsoft.Extensions.Logging; +using Microsoft.TemplateEngine.Authoring.TemplateVerifier; +using Microsoft.TemplateEngine.TestHelper; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Extensions.AI.Templates.Tests; + +public class McpServerSnapshotTests +{ + // Keep the exclude patterns below in sync with those in Microsoft.Extensions.AI.Templates.csproj. + private static readonly string[] _verificationExcludePatterns = [ + "**/bin/**", + "**/obj/**", + "**/.vs/**", + "**/*.sln", + "**/*.in", + ]; + + private readonly ILogger _log; + + public McpServerSnapshotTests(ITestOutputHelper log) + { +#pragma warning disable CA2000 // Dispose objects before losing scope + _log = new XunitLoggerProvider(log).CreateLogger("TestRun"); +#pragma warning restore CA2000 // Dispose objects before losing scope + } + + [Fact] + public async Task BasicTest() + { + await TestTemplateCoreAsync(scenarioName: "Basic"); + } + + private async Task TestTemplateCoreAsync(string scenarioName, IEnumerable? templateArgs = null) + { + string workingDir = TestUtils.CreateTemporaryFolder(); + string templateShortName = "mcpserver"; + + // Get the template location + string templateLocation = Path.Combine(WellKnownPaths.TemplateFeedLocation, "Microsoft.Extensions.AI.Templates", "src", "McpServer"); + + var verificationExcludePatterns = Path.DirectorySeparatorChar is '/' + ? _verificationExcludePatterns + : _verificationExcludePatterns.Select(p => p.Replace('/', Path.DirectorySeparatorChar)).ToArray(); + + TemplateVerifierOptions options = new TemplateVerifierOptions(templateName: templateShortName) + { + TemplatePath = templateLocation, + TemplateSpecificArgs = templateArgs, + SnapshotsDirectory = "Snapshots", + OutputDirectory = workingDir, + DoNotPrependCallerMethodNameToScenarioName = true, + DoNotAppendTemplateArgsToScenarioName = true, + ScenarioName = scenarioName, + VerificationExcludePatterns = verificationExcludePatterns, + } + .WithCustomScrubbers( + ScrubbersDefinition.Empty.AddScrubber((path, content) => + { + string filePath = path.UnixifyDirSeparators(); + + if (filePath.EndsWith(".csproj")) + { + // Scrub references to just-built packages and remove the suffix, if it exists. + // This allows the snapshots to remain the same regardless of where the repo is built (e.g., locally, public CI, internal CI). + var pattern = @"(?<=)"; + content.ScrubByRegex(pattern, replacement: "$1"); + } + })); + + VerificationEngine engine = new VerificationEngine(_log); + await engine.Execute(options); + +#pragma warning disable CA1031 // Do not catch general exception types + try + { + Directory.Delete(workingDir, recursive: true); + } + catch + { + /* don't care */ + } +#pragma warning restore CA1031 // Do not catch general exception types + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/.mcp/server.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/.mcp/server.json new file mode 100644 index 00000000000..ab997541e52 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/.mcp/server.json @@ -0,0 +1,20 @@ +{ + "description": "", + "name": "io.github./", + "packages": [ + { + "registry_name": "nuget", + "name": "", + "version": "0.1.0-beta", + "package_arguments": [], + "environment_variables": [] + } + ], + "repository": { + "url": "https://github.com//", + "source": "github" + }, + "version_detail": { + "version": "0.1.0-beta" + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/Program.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/Program.cs new file mode 100644 index 00000000000..73b72d35a46 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/Program.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +var builder = Host.CreateApplicationBuilder(args); + +// Configure all logs to go to stderr (stdout is used for the MCP protocol messages). +builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace); + +// Add the MCP services: the transport to use (stdio) and the tools to register. +builder.Services + .AddMcpServer() + .WithStdioServerTransport() + .WithTools(); + +await builder.Build().RunAsync(); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/README.md new file mode 100644 index 00000000000..25704e5d135 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/README.md @@ -0,0 +1,82 @@ +# MCP Server + +This README was created using the C# MCP server template project. It demonstrates how you can easily create an MCP server using C# and then package it in a NuGet package. + +See [aka.ms/nuget/mcp/guide](https://aka.ms/nuget/mcp/guide) for the full guide. + +## Checklist before publishing to NuGet.org + +- Test the MCP server locally using the steps below. +- Update the package metadata in the .csproj file, in particular the ``. +- Update `.mcp/server.json` to declare your MCP server's inputs. + - See [configuring inputs](https://aka.ms/nuget/mcp/guide/configuring-inputs) for more details. +- Pack the project using `dotnet pack`. + +The `bin/Release` directory will contain the package file (.nupkg), which can be [published to NuGet.org](https://learn.microsoft.com/nuget/nuget-org/publish-a-package). + +## Using the MCP Server in VS Code + +Once the MCP server package is published to NuGet.org, you can use the following VS Code user configuration to download and install the MCP server package. See [Use MCP servers in VS Code (Preview)](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) for more information about using MCP servers in VS Code. + +```json +{ + "mcp": { + "servers": { + "mcpserver": { + "type": "stdio", + "command": "dotnet", + "args": [ + "tool", + "exec", + "", + "--version", + "", + "--yes" + ] + } + } + } +} +``` + +Now you can ask Copilot Chat for a random number, for example, `Give me 3 random numbers`. It should prompt you to use the `get_random_number` tool on the `mcpserver` MCP server and show you the results. + +## Developing locally in VS Code + +To test this MCP server from source code (locally) without using a built MCP server package, create a `.vscode/mcp.json` file (a VS Code workspace settings file) in your project directory and add the following configuration: + +```json +{ + "servers": { + "mcpserver": { + "type": "stdio", + "command": "dotnet", + "args": [ + "run", + "--project", + "" + ] + } + } +} +``` + +Alternatively, you can configure your VS Code user settings to use your local project: + +```json +{ + "mcp": { + "servers": { + "mcpserver": { + "type": "stdio", + "command": "dotnet", + "args": [ + "run", + "--project", + "" + ] + } + } + } +} +``` diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/Tools/RandomNumberTools.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/Tools/RandomNumberTools.cs new file mode 100644 index 00000000000..72af767e320 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/Tools/RandomNumberTools.cs @@ -0,0 +1,18 @@ +using System.ComponentModel; +using ModelContextProtocol.Server; + +/// +/// Sample MCP tools for demonstration purposes. +/// These tools can be invoked by MCP clients to perform various operations. +/// +internal class RandomNumberTools +{ + [McpServerTool(Name = "get_random_number")] + [Description("Generates a random number between the specified minimum and maximum values.")] + public int GetRandomNumber( + [Description("Minimum value (inclusive)")] int min = 0, + [Description("Maximum value (exclusive)")] int max = 100) + { + return Random.Shared.Next(min, max); + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/mcpserver.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/mcpserver.csproj new file mode 100644 index 00000000000..a71ac148e6f --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/mcpserver.csproj @@ -0,0 +1,32 @@ + + + + net10.0 + Exe + enable + enable + + + true + McpServer + + + README.md + SampleMcpServer + 0.1.0-beta + AI; MCP; server; stdio + An MCP server using the MCP C# SDK. + + + + + + + + + + + + + + From 7780060c1f13f9c2fd35a68ac486d7d4f74ca7c4 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Wed, 2 Jul 2025 13:27:21 -0400 Subject: [PATCH 04/13] Add FunctionInvokingChatClient.FunctionInvoker delegate (#6564) We've had a bunch of requests to be able to customize how function invocation is handled, and while it's already possible today by deriving from FunctionInvokingChatClient and overriding its InvokeFunctionAsync, there's a lot of ceremony involved in that. By having a property on the client instance, that behavior can instead be configured as part of a UseFunctionInvocation call. --- .../FunctionInvokingChatClient.cs | 13 +++- .../Microsoft.Extensions.AI.json | 4 + .../FunctionInvokingChatClientTests.cs | 73 +++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs index 293ff1d98f1..6b1d3b3e905 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs @@ -205,6 +205,15 @@ public int MaximumConsecutiveErrorsPerRequest set => _maximumConsecutiveErrorsPerRequest = Throw.IfLessThan(value, 0); } + /// Gets or sets a delegate used to invoke instances. + /// + /// By default, the protected method is called for each to be invoked, + /// invoking the instance and returning its result. If this delegate is set to a non- value, + /// will replace its normal invocation with a call to this delegate, enabling + /// this delegate to assume all invocation handling of the function. + /// + public Func>? FunctionInvoker { get; set; } + /// public override async Task GetResponseAsync( IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) @@ -872,7 +881,9 @@ FunctionResultContent CreateFunctionResultContent(FunctionInvocationResult resul { _ = Throw.IfNull(context); - return context.Function.InvokeAsync(context.Arguments, cancellationToken); + return FunctionInvoker is { } invoker ? + invoker(context, cancellationToken) : + context.Function.InvokeAsync(context.Arguments, cancellationToken); } private static TimeSpan GetElapsedTime(long startingTimestamp) => diff --git a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.json b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.json index 4f4317c9978..59ed3d32fab 100644 --- a/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.json +++ b/src/Libraries/Microsoft.Extensions.AI/Microsoft.Extensions.AI.json @@ -527,6 +527,10 @@ "Member": "System.IServiceProvider? Microsoft.Extensions.AI.FunctionInvokingChatClient.FunctionInvocationServices { get; }", "Stage": "Stable" }, + { + "Member": "System.Func>? Microsoft.Extensions.AI.FunctionInvokingChatClient.FunctionInvoker { get; set; }", + "Stage": "Stable" + }, { "Member": "bool Microsoft.Extensions.AI.FunctionInvokingChatClient.IncludeDetailedErrors { get; set; }", "Stage": "Stable" diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs index 26554946dca..1379cef8bf0 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; @@ -37,6 +38,35 @@ public void Ctor_HasExpectedDefaults() Assert.False(client.IncludeDetailedErrors); Assert.Equal(10, client.MaximumIterationsPerRequest); Assert.Equal(3, client.MaximumConsecutiveErrorsPerRequest); + Assert.Null(client.FunctionInvoker); + } + + [Fact] + public void Properties_Roundtrip() + { + using TestChatClient innerClient = new(); + using FunctionInvokingChatClient client = new(innerClient); + + Assert.False(client.AllowConcurrentInvocation); + client.AllowConcurrentInvocation = true; + Assert.True(client.AllowConcurrentInvocation); + + Assert.False(client.IncludeDetailedErrors); + client.IncludeDetailedErrors = true; + Assert.True(client.IncludeDetailedErrors); + + Assert.Equal(10, client.MaximumIterationsPerRequest); + client.MaximumIterationsPerRequest = 5; + Assert.Equal(5, client.MaximumIterationsPerRequest); + + Assert.Equal(3, client.MaximumConsecutiveErrorsPerRequest); + client.MaximumConsecutiveErrorsPerRequest = 1; + Assert.Equal(1, client.MaximumConsecutiveErrorsPerRequest); + + Assert.Null(client.FunctionInvoker); + Func> invoker = (ctx, ct) => new ValueTask("test"); + client.FunctionInvoker = invoker; + Assert.Same(invoker, client.FunctionInvoker); } [Fact] @@ -208,6 +238,49 @@ public async Task ConcurrentInvocationOfParallelCallsDisabledByDefaultAsync() await InvokeAndAssertStreamingAsync(options, plan); } + [Fact] + public async Task FunctionInvokerDelegateOverridesHandlingAsync() + { + var options = new ChatOptions + { + Tools = + [ + AIFunctionFactory.Create(() => "Result 1", "Func1"), + AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), + AIFunctionFactory.Create((int i) => { }, "VoidReturn"), + ] + }; + + List plan = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1 from delegate")]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", result: "Result 2: 42 from delegate")]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId3", "VoidReturn", arguments: new Dictionary { { "i", 43 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId3", result: "Success: Function completed.")]), + new ChatMessage(ChatRole.Assistant, "world"), + ]; + + Func configure = b => b.Use( + s => new FunctionInvokingChatClient(s) + { + FunctionInvoker = async (ctx, cancellationToken) => + { + Assert.NotNull(ctx); + var result = await ctx.Function.InvokeAsync(ctx.Arguments, cancellationToken); + return result is JsonElement e ? + JsonSerializer.SerializeToElement($"{e.GetString()} from delegate", AIJsonUtilities.DefaultOptions) : + result; + } + }); + + await InvokeAndAssertAsync(options, plan, configurePipeline: configure); + + await InvokeAndAssertStreamingAsync(options, plan, configurePipeline: configure); + } + [Fact] public async Task ContinuesWithSuccessfulCallsUntilMaximumIterations() { From 1a35757910d32601a458c1749de71164138e87ac Mon Sep 17 00:00:00 2001 From: Joel Verhagen Date: Wed, 2 Jul 2025 13:28:50 -0400 Subject: [PATCH 05/13] Use dnx instead of dotnet tool exec in template README (#6571) --- .../src/McpServer/McpServer-CSharp/README.md | 4 +--- .../Snapshots/mcpserver.Basic.verified/mcpserver/README.md | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/README.md b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/README.md index dc6f5038b61..50091888ad8 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/README.md +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/README.md @@ -24,10 +24,8 @@ Once the MCP server package is published to NuGet.org, you can use the following "servers": { "McpServer-CSharp": { "type": "stdio", - "command": "dotnet", + "command": "dnx", "args": [ - "tool", - "exec", "", "--version", "", diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/README.md index 25704e5d135..5c00a3bf669 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/README.md +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/README.md @@ -24,10 +24,8 @@ Once the MCP server package is published to NuGet.org, you can use the following "servers": { "mcpserver": { "type": "stdio", - "command": "dotnet", + "command": "dnx", "args": [ - "tool", - "exec", "", "--version", "", From 93b8616aa132856df0e31cc3b996de7c10e25c37 Mon Sep 17 00:00:00 2001 From: Peter Waldschmidt Date: Wed, 2 Jul 2025 17:15:40 -0400 Subject: [PATCH 06/13] Add reporting tests that show NLP results. (#6574) * Add reporting tests that show NLP results. * Cleanup analyzer errors. * Add global tags for NLP * Add more precision to the evaluator timing * More tags * Add another partial match test --- .../BLEUEvaluator.cs | 2 +- .../F1Evaluator.cs | 2 +- .../GLEUEvaluator.cs | 2 +- .../EvaluationMetricExtensions.cs | 2 +- ...ons.AI.Evaluation.Integration.Tests.csproj | 1 + .../NLPEvaluatorTests.cs | 162 ++++++++++++++++++ 6 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/NLPEvaluatorTests.cs diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/BLEUEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/BLEUEvaluator.cs index e1419bd630e..f3030ec7cfb 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/BLEUEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/BLEUEvaluator.cs @@ -86,7 +86,7 @@ public ValueTask EvaluateAsync( }); metric.Value = score; - string durationText = $"{duration.TotalSeconds.ToString("F2", CultureInfo.InvariantCulture)} s"; + string durationText = $"{duration.TotalSeconds.ToString("F4", CultureInfo.InvariantCulture)} s"; metric.AddOrUpdateMetadata(name: "evaluation-duration", value: durationText); metric.AddOrUpdateContext(context); metric.Interpretation = metric.Interpret(); diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/F1Evaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/F1Evaluator.cs index b0806be6d66..e070577c448 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/F1Evaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/F1Evaluator.cs @@ -77,7 +77,7 @@ public ValueTask EvaluateAsync( }); metric.Value = score; - string durationText = $"{duration.TotalSeconds.ToString("F2", CultureInfo.InvariantCulture)} s"; + string durationText = $"{duration.TotalSeconds.ToString("F4", CultureInfo.InvariantCulture)} s"; metric.AddOrUpdateMetadata(name: "evaluation-duration", value: durationText); metric.AddOrUpdateContext(context); metric.Interpretation = metric.Interpret(); diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/GLEUEvaluator.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/GLEUEvaluator.cs index 0c9805ee108..60df30879a4 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/GLEUEvaluator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation.NLP/GLEUEvaluator.cs @@ -86,7 +86,7 @@ public ValueTask EvaluateAsync( }); metric.Value = score; - string durationText = $"{duration.TotalSeconds.ToString("F2", CultureInfo.InvariantCulture)} s"; + string durationText = $"{duration.TotalSeconds.ToString("F4", CultureInfo.InvariantCulture)} s"; metric.AddOrUpdateMetadata(name: "evaluation-duration", value: durationText); metric.AddOrUpdateContext(context); metric.Interpretation = metric.Interpret(); diff --git a/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationMetricExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationMetricExtensions.cs index d3012030cec..534f5e300f7 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationMetricExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Evaluation/EvaluationMetricExtensions.cs @@ -177,7 +177,7 @@ public static void AddOrUpdateChatMetadata( if (duration is not null) { - string durationText = $"{duration.Value.TotalSeconds.ToString("F2", CultureInfo.InvariantCulture)} s"; + string durationText = $"{duration.Value.TotalSeconds.ToString("F4", CultureInfo.InvariantCulture)} s"; metric.AddOrUpdateMetadata(name: "evaluation-duration", value: durationText); } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/Microsoft.Extensions.AI.Evaluation.Integration.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/Microsoft.Extensions.AI.Evaluation.Integration.Tests.csproj index c08667ff421..6e3332ebca6 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/Microsoft.Extensions.AI.Evaluation.Integration.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/Microsoft.Extensions.AI.Evaluation.Integration.Tests.csproj @@ -28,6 +28,7 @@ + diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/NLPEvaluatorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/NLPEvaluatorTests.cs new file mode 100644 index 00000000000..a4f3b75045a --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/NLPEvaluatorTests.cs @@ -0,0 +1,162 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable CA2016 // Forward the 'CancellationToken' parameter to methods that take it. +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.AI.Evaluation.NLP; +using Microsoft.Extensions.AI.Evaluation.Reporting; +using Microsoft.Extensions.AI.Evaluation.Reporting.Storage; +using Microsoft.TestUtilities; +using Xunit; + +namespace Microsoft.Extensions.AI.Evaluation.Integration.Tests; + +[Experimental("AIEVAL001")] +public class NLPEvaluatorTests +{ + private static readonly ReportingConfiguration? _nlpReportingConfiguration; + + static NLPEvaluatorTests() + { + if (Settings.Current.Configured) + { + string version = $"Product Version: {Constants.Version}"; + string date = $"Date: {DateTime.UtcNow:dddd, dd MMMM yyyy}"; + string projectName = $"Project: Integration Tests"; + string testClass = $"Test Class: {nameof(NLPEvaluatorTests)}"; + string usesContext = $"Feature: Context"; + + IEvaluator bleuEvaluator = new BLEUEvaluator(); + IEvaluator gleuEvaluator = new GLEUEvaluator(); + IEvaluator f1Evaluator = new F1Evaluator(); + + _nlpReportingConfiguration = + DiskBasedReportingConfiguration.Create( + storageRootPath: Settings.Current.StorageRootPath, + evaluators: [bleuEvaluator, gleuEvaluator, f1Evaluator], + executionName: Constants.Version, + tags: [version, date, projectName, testClass, usesContext]); + } + } + + [ConditionalFact] + public async Task ExactMatch() + { + SkipIfNotConfigured(); + + await using ScenarioRun scenarioRun = + await _nlpReportingConfiguration.CreateScenarioRunAsync( + scenarioName: $"Microsoft.Extensions.AI.Evaluation.Integration.Tests.{nameof(NLPEvaluatorTests)}.{nameof(ExactMatch)}"); + + var referenceText = "The quick brown fox jumps over the lazy dog."; + var bleuContext = new BLEUEvaluatorContext(referenceText); + var gleuContext = new GLEUEvaluatorContext(referenceText); + var f1Context = new F1EvaluatorContext(referenceText); + + EvaluationResult result = await scenarioRun.EvaluateAsync(referenceText, [bleuContext, gleuContext, f1Context]); + + Assert.False( + result.ContainsDiagnostics(d => d.Severity >= EvaluationDiagnosticSeverity.Warning), + string.Join("\r\n\r\n", result.Metrics.Values.SelectMany(m => m.Diagnostics ?? []).Select(d => d.ToString()))); + + Assert.Equal(3, result.Metrics.Count); + Assert.True(result.TryGet(BLEUEvaluator.BLEUMetricName, out NumericMetric? _)); + Assert.True(result.TryGet(GLEUEvaluator.GLEUMetricName, out NumericMetric? _)); + Assert.True(result.TryGet(F1Evaluator.F1MetricName, out NumericMetric? _)); + } + + [ConditionalFact] + public async Task PartialMatch() + { + SkipIfNotConfigured(); + + await using ScenarioRun scenarioRun = + await _nlpReportingConfiguration.CreateScenarioRunAsync( + scenarioName: $"Microsoft.Extensions.AI.Evaluation.Integration.Tests.{nameof(NLPEvaluatorTests)}.{nameof(PartialMatch)}"); + + var referenceText = "The quick brown fox jumps over the lazy dog."; + var bleuContext = new BLEUEvaluatorContext(referenceText); + var gleuContext = new GLEUEvaluatorContext(referenceText); + var f1Context = new F1EvaluatorContext(referenceText); + + var similarText = "The brown fox quickly jumps over a lazy dog."; + EvaluationResult result = await scenarioRun.EvaluateAsync(similarText, [bleuContext, gleuContext, f1Context]); + + Assert.False( + result.ContainsDiagnostics(d => d.Severity >= EvaluationDiagnosticSeverity.Warning), + string.Join("\r\n\r\n", result.Metrics.Values.SelectMany(m => m.Diagnostics ?? []).Select(d => d.ToString()))); + + Assert.Equal(3, result.Metrics.Count); + Assert.True(result.TryGet(BLEUEvaluator.BLEUMetricName, out NumericMetric? _)); + Assert.True(result.TryGet(GLEUEvaluator.GLEUMetricName, out NumericMetric? _)); + Assert.True(result.TryGet(F1Evaluator.F1MetricName, out NumericMetric? _)); + } + + [ConditionalFact] + public async Task Unmatched() + { + SkipIfNotConfigured(); + + await using ScenarioRun scenarioRun = + await _nlpReportingConfiguration.CreateScenarioRunAsync( + scenarioName: $"Microsoft.Extensions.AI.Evaluation.Integration.Tests.{nameof(NLPEvaluatorTests)}.{nameof(Unmatched)}"); + + var referenceText = "The quick brown fox jumps over the lazy dog."; + var bleuContext = new BLEUEvaluatorContext(referenceText); + var gleuContext = new GLEUEvaluatorContext(referenceText); + var f1Context = new F1EvaluatorContext(referenceText); + + EvaluationResult result = await scenarioRun.EvaluateAsync("What is life's meaning?", [bleuContext, gleuContext, f1Context]); + + Assert.False( + result.ContainsDiagnostics(d => d.Severity >= EvaluationDiagnosticSeverity.Warning), + string.Join("\r\n\r\n", result.Metrics.Values.SelectMany(m => m.Diagnostics ?? []).Select(d => d.ToString()))); + + Assert.Equal(3, result.Metrics.Count); + Assert.True(result.TryGet(BLEUEvaluator.BLEUMetricName, out NumericMetric? _)); + Assert.True(result.TryGet(GLEUEvaluator.GLEUMetricName, out NumericMetric? _)); + Assert.True(result.TryGet(F1Evaluator.F1MetricName, out NumericMetric? _)); + } + + [ConditionalFact] + public async Task AdditionalContextIsNotPassed() + { + SkipIfNotConfigured(); + + await using ScenarioRun scenarioRun = + await _nlpReportingConfiguration.CreateScenarioRunAsync( + scenarioName: $"Microsoft.Extensions.AI.Evaluation.Integration.Tests.{nameof(NLPEvaluatorTests)}.{nameof(AdditionalContextIsNotPassed)}"); + + EvaluationResult result = await scenarioRun.EvaluateAsync("What is the meaning of life?"); + + Assert.True( + result.Metrics.Values.All(m => m.ContainsDiagnostics(d => d.Severity is EvaluationDiagnosticSeverity.Error)), + string.Join("\r\n\r\n", result.Metrics.Values.SelectMany(m => m.Diagnostics ?? []).Select(d => d.ToString()))); + + Assert.Equal(3, result.Metrics.Count); + Assert.True(result.TryGet(BLEUEvaluator.BLEUMetricName, out NumericMetric? bleu)); + Assert.True(result.TryGet(GLEUEvaluator.GLEUMetricName, out NumericMetric? gleu)); + Assert.True(result.TryGet(F1Evaluator.F1MetricName, out NumericMetric? f1)); + + Assert.Null(bleu.Context); + Assert.Null(gleu.Context); + Assert.Null(f1.Context); + + } + + [MemberNotNull(nameof(_nlpReportingConfiguration))] + private static void SkipIfNotConfigured() + { + if (!Settings.Current.Configured) + { + throw new SkipTestException("Test is not configured"); + } + + Assert.NotNull(_nlpReportingConfiguration); + } +} From aa6e9af7dd70e3ab2cb1667854a9291d682097ea Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Wed, 2 Jul 2025 23:53:52 -0400 Subject: [PATCH 07/13] Enable specifying "strict" for OpenAI clients via ChatOptions (#6552) * Enable specifying "strict" for OpenAI clients via ChatOptions * Address PR feedback --- .../OpenAIAssistantChatClient.cs | 13 +-- .../OpenAIChatClient.cs | 19 ++-- .../OpenAIClientExtensions.cs | 19 ++-- .../OpenAIRealtimeConversationClient.cs | 9 +- .../OpenAIResponseChatClient.cs | 19 ++-- .../OpenAIChatClientTests.cs | 86 +++++++++++++++---- .../OpenAIResponseClientTests.cs | 75 ++++++++++++++++ 7 files changed, 195 insertions(+), 45 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantChatClient.cs index 3547483da10..0b6c5f5122f 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantChatClient.cs @@ -237,14 +237,16 @@ void IDisposable.Dispose() } /// Converts an Extensions function to an OpenAI assistants function tool. - internal static FunctionToolDefinition ToOpenAIAssistantsFunctionToolDefinition(AIFunction aiFunction) + internal static FunctionToolDefinition ToOpenAIAssistantsFunctionToolDefinition(AIFunction aiFunction, ChatOptions? options = null) { - (BinaryData parameters, bool? strict) = OpenAIClientExtensions.ToOpenAIFunctionParameters(aiFunction); + bool? strict = + OpenAIClientExtensions.HasStrict(aiFunction.AdditionalProperties) ?? + OpenAIClientExtensions.HasStrict(options?.AdditionalProperties); return new FunctionToolDefinition(aiFunction.Name) { Description = aiFunction.Description, - Parameters = parameters, + Parameters = OpenAIClientExtensions.ToOpenAIFunctionParameters(aiFunction, strict), StrictParameterSchemaEnabled = strict, }; } @@ -296,7 +298,7 @@ internal static FunctionToolDefinition ToOpenAIAssistantsFunctionToolDefinition( switch (tool) { case AIFunction aiFunction: - runOptions.ToolsOverride.Add(ToOpenAIAssistantsFunctionToolDefinition(aiFunction)); + runOptions.ToolsOverride.Add(ToOpenAIAssistantsFunctionToolDefinition(aiFunction, options)); break; case HostedCodeInterpreterTool: @@ -342,7 +344,8 @@ internal static FunctionToolDefinition ToOpenAIAssistantsFunctionToolDefinition( runOptions.ResponseFormat = AssistantResponseFormat.CreateJsonSchemaFormat( jsonFormat.SchemaName, BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, OpenAIJsonContext.Default.JsonElement)), - jsonFormat.SchemaDescription); + jsonFormat.SchemaDescription, + OpenAIClientExtensions.HasStrict(options.AdditionalProperties)); break; case ChatResponseFormatJson jsonFormat: diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index abbcb0ed0ae..c051550d493 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -101,11 +101,17 @@ void IDisposable.Dispose() } /// Converts an Extensions function to an OpenAI chat tool. - internal static ChatTool ToOpenAIChatTool(AIFunction aiFunction) + internal static ChatTool ToOpenAIChatTool(AIFunction aiFunction, ChatOptions? options = null) { - (BinaryData parameters, bool? strict) = OpenAIClientExtensions.ToOpenAIFunctionParameters(aiFunction); - - return ChatTool.CreateFunctionTool(aiFunction.Name, aiFunction.Description, parameters, strict); + bool? strict = + OpenAIClientExtensions.HasStrict(aiFunction.AdditionalProperties) ?? + OpenAIClientExtensions.HasStrict(options?.AdditionalProperties); + + return ChatTool.CreateFunctionTool( + aiFunction.Name, + aiFunction.Description, + OpenAIClientExtensions.ToOpenAIFunctionParameters(aiFunction, strict), + strict); } /// Converts an Extensions chat message enumerable to an OpenAI chat message enumerable. @@ -517,7 +523,7 @@ private ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) { if (tool is AIFunction af) { - result.Tools.Add(ToOpenAIChatTool(af)); + result.Tools.Add(ToOpenAIChatTool(af, options)); } } @@ -555,7 +561,8 @@ private ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) OpenAI.Chat.ChatResponseFormat.CreateJsonSchemaFormat( jsonFormat.SchemaName ?? "json_schema", BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, OpenAIJsonContext.Default.JsonElement)), - jsonFormat.SchemaDescription) : + jsonFormat.SchemaDescription, + OpenAIClientExtensions.HasStrict(options.AdditionalProperties)) : OpenAI.Chat.ChatResponseFormat.CreateJsonObjectFormat(); } } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs index 24fd93ccb65..b20769c0dc4 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs @@ -21,6 +21,7 @@ #pragma warning disable S1067 // Expressions should not be too complex #pragma warning disable SA1515 // Single-line comment should be preceded by blank line #pragma warning disable CA1305 // Specify IFormatProvider +#pragma warning disable S1135 // Track uses of "TODO" tags namespace Microsoft.Extensions.AI; @@ -182,15 +183,17 @@ public static ResponseTool AsOpenAIResponseTool(this AIFunction function) => public static ConversationFunctionTool AsOpenAIConversationFunctionTool(this AIFunction function) => OpenAIRealtimeConversationClient.ToOpenAIConversationFunctionTool(Throw.IfNull(function)); + // TODO: Once we're ready to rely on C# 14 features, add an extension property ChatOptions.Strict. + + /// Gets whether the properties specify that strict schema handling is desired. + internal static bool? HasStrict(IReadOnlyDictionary? additionalProperties) => + additionalProperties?.TryGetValue(StrictKey, out object? strictObj) is true && + strictObj is bool strictValue ? + strictValue : null; + /// Extracts from an the parameters and strictness setting for use with OpenAI's APIs. - internal static (BinaryData Parameters, bool? Strict) ToOpenAIFunctionParameters(AIFunction aiFunction) + internal static BinaryData ToOpenAIFunctionParameters(AIFunction aiFunction, bool? strict) { - // Extract any strict setting from AdditionalProperties. - bool? strict = - aiFunction.AdditionalProperties.TryGetValue(OpenAIClientExtensions.StrictKey, out object? strictObj) && - strictObj is bool strictValue ? - strictValue : null; - // Perform any desirable transformations on the function's JSON schema, if it'll be used in a strict setting. JsonElement jsonSchema = strict is true ? StrictSchemaTransformCache.GetOrCreateTransformedSchema(aiFunction) : @@ -201,7 +204,7 @@ strictObj is bool strictValue ? var tool = JsonSerializer.Deserialize(jsonSchema, OpenAIJsonContext.Default.ToolJson)!; var functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool, OpenAIJsonContext.Default.ToolJson)); - return (functionParameters, strict); + return functionParameters; } /// Used to create the JSON payload for an OpenAI tool description. diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeConversationClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeConversationClient.cs index 892a9e9aa2a..abfebd99f34 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeConversationClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeConversationClient.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; using OpenAI.RealtimeConversation; namespace Microsoft.Extensions.AI; @@ -9,14 +8,16 @@ namespace Microsoft.Extensions.AI; /// Provides helpers for interacting with OpenAI Realtime. internal sealed class OpenAIRealtimeConversationClient { - public static ConversationFunctionTool ToOpenAIConversationFunctionTool(AIFunction aiFunction) + public static ConversationFunctionTool ToOpenAIConversationFunctionTool(AIFunction aiFunction, ChatOptions? options = null) { - (BinaryData parameters, _) = OpenAIClientExtensions.ToOpenAIFunctionParameters(aiFunction); + bool? strict = + OpenAIClientExtensions.HasStrict(aiFunction.AdditionalProperties) ?? + OpenAIClientExtensions.HasStrict(options?.AdditionalProperties); return new ConversationFunctionTool(aiFunction.Name) { Description = aiFunction.Description, - Parameters = parameters, + Parameters = OpenAIClientExtensions.ToOpenAIFunctionParameters(aiFunction, strict), }; } } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs index a5f68e10365..46019166719 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs @@ -323,11 +323,17 @@ void IDisposable.Dispose() // Nothing to dispose. Implementation required for the IChatClient interface. } - internal static ResponseTool ToResponseTool(AIFunction aiFunction) + internal static ResponseTool ToResponseTool(AIFunction aiFunction, ChatOptions? options = null) { - (BinaryData parameters, bool? strict) = OpenAIClientExtensions.ToOpenAIFunctionParameters(aiFunction); - - return ResponseTool.CreateFunctionTool(aiFunction.Name, aiFunction.Description, parameters, strict ?? false); + bool? strict = + OpenAIClientExtensions.HasStrict(aiFunction.AdditionalProperties) ?? + OpenAIClientExtensions.HasStrict(options?.AdditionalProperties); + + return ResponseTool.CreateFunctionTool( + aiFunction.Name, + aiFunction.Description, + OpenAIClientExtensions.ToOpenAIFunctionParameters(aiFunction, strict), + strict ?? false); } /// Creates a from a . @@ -380,7 +386,7 @@ private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? opt switch (tool) { case AIFunction aiFunction: - ResponseTool rtool = ToResponseTool(aiFunction); + ResponseTool rtool = ToResponseTool(aiFunction, options); result.Tools.Add(rtool); break; @@ -442,7 +448,8 @@ private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? opt ResponseTextFormat.CreateJsonSchemaFormat( jsonFormat.SchemaName ?? "json_schema", BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, OpenAIJsonContext.Default.JsonElement)), - jsonFormat.SchemaDescription) : + jsonFormat.SchemaDescription, + OpenAIClientExtensions.HasStrict(options.AdditionalProperties)) : ResponseTextFormat.CreateJsonObjectFormat(), }; } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs index 30d03b6eee3..edb5d9fab07 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs @@ -276,6 +276,74 @@ public async Task BasicRequestResponse_Streaming() }, usage.Details.AdditionalCounts); } + [Fact] + public async Task ChatOptions_StrictRespected() + { + const string Input = """ + { + "tools": [ + { + "function": { + "description": "Gets the age of the specified person.", + "name": "GetPersonAge", + "strict": true, + "parameters": { + "type": "object", + "required": [], + "properties": {}, + "additionalProperties": false + } + }, + "type": "function" + } + ], + "messages": [ + { + "role": "user", + "content": "hello" + } + ], + "model": "gpt-4o-mini", + "tool_choice": "auto" + } + """; + + const string Output = """ + { + "id": "chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI", + "object": "chat.completion", + "created": 1727888631, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Hello! How can I assist you today?", + "refusal": null + }, + "logprobs": null, + "finish_reason": "stop" + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("hello", new() + { + Tools = [AIFunctionFactory.Create(() => 42, "GetPersonAge", "Gets the age of the specified person.")], + AdditionalProperties = new() + { + ["strictJsonSchema"] = true, + }, + }); + Assert.NotNull(response); + } + [Fact] public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentation_NonStreaming() { @@ -337,7 +405,7 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio ResponseFormat = OpenAI.Chat.ChatResponseFormat.CreateTextFormat() }; openAIOptions.StopSequences.Add("hello"); - openAIOptions.Tools.Add(ToOpenAIChatTool(tool)); + openAIOptions.Tools.Add(OpenAIClientExtensions.AsOpenAIChatTool(tool)); return openAIOptions; }, ModelId = null, @@ -416,7 +484,7 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio ResponseFormat = OpenAI.Chat.ChatResponseFormat.CreateTextFormat() }; openAIOptions.StopSequences.Add("hello"); - openAIOptions.Tools.Add(ToOpenAIChatTool(tool)); + openAIOptions.Tools.Add(OpenAIClientExtensions.AsOpenAIChatTool(tool)); return openAIOptions; }, ModelId = null, // has no effect, you cannot change the model of an OpenAI's ChatClient. @@ -600,20 +668,6 @@ public async Task ChatOptions_Overwrite_NullPropertiesInRawRepresentation_Stream Assert.Equal("Hello! How can I assist you today?", responseText); } - /// Converts an Extensions function to an OpenAI chat tool. - private static ChatTool ToOpenAIChatTool(AIFunction aiFunction) - { - bool? strict = - aiFunction.AdditionalProperties.TryGetValue("strictJsonSchema", out object? strictObj) && - strictObj is bool strictValue ? - strictValue : null; - - // Map to an intermediate model so that redundant properties are skipped. - var tool = JsonSerializer.Deserialize(aiFunction.JsonSchema)!; - var functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool)); - return ChatTool.CreateFunctionTool(aiFunction.Name, aiFunction.Description, functionParameters, strict); - } - /// Used to create the JSON payload for an OpenAI chat tool description. internal sealed class ChatToolJson { diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs index 8b27cd918a7..28125e462b7 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs @@ -288,6 +288,81 @@ public async Task BasicRequestResponse_Streaming() Assert.Equal(36, usage.Details.TotalTokenCount); } + [Fact] + public async Task ChatOptions_StrictRespected() + { + const string Input = """ + { + "model": "gpt-4o-mini", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "hello" + } + ] + } + ], + "tool_choice": "auto", + "tools": [ + { + "type": "function", + "name": "GetPersonAge", + "description": "Gets the age of the specified person.", + "parameters": { + "type": "object", + "required": [], + "properties": {}, + "additionalProperties": false + }, + "strict": true + } + ] + } + """; + + const string Output = """ + { + "id": "resp_67d327649b288191aeb46a824e49dc40058a5e08c46a181d", + "object": "response", + "status": "completed", + "model": "gpt-4o-mini-2024-07-18", + "output": [ + { + "type": "message", + "id": "msg_67d32764fcdc8191bcf2e444d4088804058a5e08c46a181d", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "Hello! How can I assist you today?", + "annotations": [] + } + ] + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("hello", new() + { + Tools = [AIFunctionFactory.Create(() => 42, "GetPersonAge", "Gets the age of the specified person.")], + AdditionalProperties = new() + { + ["strictJsonSchema"] = true, + }, + }); + Assert.NotNull(response); + } + [Fact] public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentation_NonStreaming() { From 61dc5a90a979eb5d36df0d46379db9a4f58333d9 Mon Sep 17 00:00:00 2001 From: Peter Waldschmidt Date: Thu, 3 Jul 2025 01:59:20 -0400 Subject: [PATCH 08/13] Fix ConfigureEvaluationTests script when is not supplied (#6575) --- scripts/ConfigureEvaluationTests.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ConfigureEvaluationTests.ps1 b/scripts/ConfigureEvaluationTests.ps1 index d58cb1352db..b00624ac29d 100644 --- a/scripts/ConfigureEvaluationTests.ps1 +++ b/scripts/ConfigureEvaluationTests.ps1 @@ -31,7 +31,7 @@ if ($Configure -and $Unconfigure) { Exit 1 } -if (!(Test-Path $ConfigRoot)) { +if (-not $ConfigRoot -or -not (Test-Path $ConfigRoot)) { $ConfigRoot = "$HOME/.config/dotnet-extensions" } From e4ba7e8d1ecdd99f8fa0818e0dc35aa837af757c Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Thu, 3 Jul 2025 14:09:56 +0300 Subject: [PATCH 09/13] AIFunctionFactory: tolerate JSON string function parameters. (#6572) * AIFunctionFactory: tolerate JSON string function parameters. * Add debug assertion. * Update src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs Co-authored-by: Stephen Toub * Add regex-based JSON string recognition and add more tests. --------- Co-authored-by: Stephen Toub --- .../Functions/AIFunctionFactory.cs | 47 ++++++++++++++ .../ChatClientIntegrationTests.cs | 33 ++++++++++ .../Functions/AIFunctionFactoryTest.cs | 61 +++++++++++++++++++ 3 files changed, 141 insertions(+) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs index 320df4098a3..e864923883e 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs @@ -26,6 +26,7 @@ #pragma warning disable S2333 // Redundant modifiers should not be used #pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields #pragma warning disable SA1202 // Public members should come before private members +#pragma warning disable SA1203 // Constants should appear before fields namespace Microsoft.Extensions.AI; @@ -825,6 +826,23 @@ static bool IsAsyncMethod(MethodInfo method) { try { + if (value is string text && IsPotentiallyJson(text)) + { + Debug.Assert(typeInfo.Type != typeof(string), "string parameters should not enter this branch."); + + // Account for the parameter potentially being a JSON string. + // The value is a string but the type is not. Try to deserialize it under the assumption that it's JSON. + // If it's not, we'll fall through to the default path that makes it valid JSON and then tries to deserialize. + try + { + return JsonSerializer.Deserialize(text, typeInfo); + } + catch (JsonException) + { + // If the string is not valid JSON, fall through to the round-trip. + } + } + string json = JsonSerializer.Serialize(value, serializerOptions.GetTypeInfo(value.GetType())); return JsonSerializer.Deserialize(json, typeInfo); } @@ -1021,6 +1039,35 @@ private record struct DescriptorKey( AIJsonSchemaCreateOptions SchemaOptions); } + /// + /// Quickly checks if the specified string is potentially JSON + /// by checking if the first non-whitespace characters are valid JSON start tokens. + /// + /// The string to check. + /// If then the string is definitely not valid JSON. + private static bool IsPotentiallyJson(string value) => PotentiallyJsonRegex().IsMatch(value); +#if NET + [GeneratedRegex(PotentiallyJsonRegexString, RegexOptions.IgnorePatternWhitespace)] + private static partial Regex PotentiallyJsonRegex(); +#else + private static Regex PotentiallyJsonRegex() => _potentiallyJsonRegex; + private static readonly Regex _potentiallyJsonRegex = new(PotentiallyJsonRegexString, RegexOptions.IgnorePatternWhitespace | RegexOptions.Compiled); +#endif + private const string PotentiallyJsonRegexString = """ + ^\s* # Optional whitespace at the start of the string + ( null # null literal + | false # false literal + | true # true literal + | \d # positive number + | -\d # negative number + | " # string + | \[ # start array + | { # start object + | // # Start of single-line comment + | /\* # Start of multi-line comment + ) + """; + /// /// Removes characters from a .NET member name that shouldn't be used in an AI function name. /// diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs index d84d767fd4c..ffa94f64531 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs @@ -341,6 +341,39 @@ public virtual async Task FunctionInvocation_NestedParameters() AssertUsageAgainstActivities(response, activities); } + [ConditionalFact] + public virtual async Task FunctionInvocation_ArrayParameter() + { + SkipIfNotEnabled(); + + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + using var chatClient = new FunctionInvokingChatClient( + new OpenTelemetryChatClient(_chatClient, sourceName: sourceName)); + + List messages = + [ + new(ChatRole.User, "Can you add bacon, lettuce, and tomatoes to Peter's shopping cart?") + ]; + + string? shopperName = null; + List shoppingCart = []; + AIFunction func = AIFunctionFactory.Create((string[] items, string shopperId) => { shoppingCart.AddRange(items); shopperName = shopperId; }, "AddItemsToShoppingCart"); + var response = await chatClient.GetResponseAsync(messages, new() + { + Tools = [func] + }); + + Assert.Equal("Peter", shopperName); + Assert.Equal(["bacon", "lettuce", "tomatoes"], shoppingCart); + AssertUsageAgainstActivities(response, activities); + } + private static void AssertUsageAgainstActivities(ChatResponse response, List activities) { // If the underlying IChatClient provides usage data, function invocation should aggregate the diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs index 84298788e8c..b15d200a39a 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs @@ -6,6 +6,7 @@ using System.ComponentModel; using System.Reflection; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; @@ -75,6 +76,66 @@ public async Task Parameters_MissingRequiredParametersFail_Async() } } + [Fact] + public async Task Parameters_ToleratesJsonEncodedParameters() + { + AIFunction func = AIFunctionFactory.Create((int x, int y, int z, int w, int u) => x + y + z + w + u); + + var result = await func.InvokeAsync(new() + { + ["x"] = "1", + ["y"] = JsonNode.Parse("2"), + ["z"] = JsonDocument.Parse("3"), + ["w"] = JsonDocument.Parse("4").RootElement, + ["u"] = 5M, // boxed decimal cannot be cast to int, requires conversion + }); + + AssertExtensions.EqualFunctionCallResults(15, result); + } + + [Theory] + [InlineData(" null")] + [InlineData(" false ")] + [InlineData("true ")] + [InlineData("42")] + [InlineData("0.0")] + [InlineData("-1e15")] + [InlineData(" \"I am a string!\" ")] + [InlineData(" {}")] + [InlineData("[]")] + public async Task Parameters_ToleratesJsonStringParameters(string jsonStringParam) + { + AIFunction func = AIFunctionFactory.Create((JsonElement param) => param); + JsonElement expectedResult = JsonDocument.Parse(jsonStringParam).RootElement; + + var result = await func.InvokeAsync(new() + { + ["param"] = jsonStringParam + }); + + AssertExtensions.EqualFunctionCallResults(expectedResult, result); + } + + [Theory] + [InlineData("")] + [InlineData(" \r\n")] + [InlineData("I am a string!")] + [InlineData("/* Code snippet */ int main(void) { return 0; }")] + [InlineData("let rec Y F x = F (Y F) x")] + [InlineData("+3")] + public async Task Parameters_ToleratesInvalidJsonStringParameters(string invalidJsonParam) + { + AIFunction func = AIFunctionFactory.Create((JsonElement param) => param); + JsonElement expectedResult = JsonDocument.Parse(JsonSerializer.Serialize(invalidJsonParam, JsonContext.Default.String)).RootElement; + + var result = await func.InvokeAsync(new() + { + ["param"] = invalidJsonParam + }); + + AssertExtensions.EqualFunctionCallResults(expectedResult, result); + } + [Fact] public async Task Parameters_MappedByType_Async() { From dd5fa173e3eb1c5a459ba50fb32b8dd20d5cb25d Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Thu, 3 Jul 2025 18:14:36 +0300 Subject: [PATCH 10/13] AIFunctionFactory: add test coverage for JSON comments. (#6576) --- .../Functions/AIFunctionFactory.cs | 3 +-- .../Functions/AIFunctionFactoryTest.cs | 7 +++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs index e864923883e..5ad178e7bc8 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/AIFunctionFactory.cs @@ -1058,8 +1058,7 @@ private record struct DescriptorKey( ( null # null literal | false # false literal | true # true literal - | \d # positive number - | -\d # negative number + | -?[0-9]# number | " # string | \[ # start array | { # start object diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs index b15d200a39a..afced22038f 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs @@ -103,10 +103,13 @@ public async Task Parameters_ToleratesJsonEncodedParameters() [InlineData(" \"I am a string!\" ")] [InlineData(" {}")] [InlineData("[]")] + [InlineData("// single-line comment\r\nnull")] + [InlineData("/* multi-line\r\ncomment */\r\nnull")] public async Task Parameters_ToleratesJsonStringParameters(string jsonStringParam) { - AIFunction func = AIFunctionFactory.Create((JsonElement param) => param); - JsonElement expectedResult = JsonDocument.Parse(jsonStringParam).RootElement; + JsonSerializerOptions options = new(AIJsonUtilities.DefaultOptions) { ReadCommentHandling = JsonCommentHandling.Skip }; + AIFunction func = AIFunctionFactory.Create((JsonElement param) => param, serializerOptions: options); + JsonElement expectedResult = JsonDocument.Parse(jsonStringParam, new() { CommentHandling = JsonCommentHandling.Skip }).RootElement; var result = await func.InvokeAsync(new() { From c21e89d7911b62b05e1eae6272bcb31729f62919 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Fri, 4 Jul 2025 19:56:19 -0400 Subject: [PATCH 11/13] Update M.E.AI.OpenAI for latest OpenAI release (#6577) --- eng/packages/General.props | 2 +- .../SpeechToText/SpeechToTextOptions.cs | 14 +- .../Microsoft.Extensions.AI.OpenAI.csproj | 6 +- .../OpenAIAssistantChatClient.cs | 2 - .../OpenAIChatClient.cs | 47 +++-- .../OpenAIClientExtensions.cs | 4 +- .../OpenAIRealtimeConversationClient.cs | 2 +- .../OpenAIResponseChatClient.cs | 48 +++-- .../OpenAISpeechToTextClient.cs | 199 ++++++++---------- ...icrosoft.Extensions.AI.OpenAI.Tests.csproj | 3 +- .../OpenAIAIFunctionConversionTests.cs | 14 +- ...enAIAssistantChatClientIntegrationTests.cs | 3 +- .../OpenAIAssistantChatClientTests.cs | 12 +- .../OpenAIChatClientTests.cs | 19 +- .../OpenAIEmbeddingGeneratorTests.cs | 11 +- .../OpenAIResponseClientTests.cs | 11 +- ...penAISpeechToTextClientIntegrationTests.cs | 2 +- .../OpenAISpeechToTextClientTests.cs | 14 +- 18 files changed, 186 insertions(+), 227 deletions(-) diff --git a/eng/packages/General.props b/eng/packages/General.props index fa2d51de886..aa9771bfb4a 100644 --- a/eng/packages/General.props +++ b/eng/packages/General.props @@ -16,7 +16,7 @@ - + diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/SpeechToTextOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/SpeechToTextOptions.cs index 5ff0135cec7..8efbf510164 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/SpeechToTextOptions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/SpeechToText/SpeechToTextOptions.cs @@ -11,20 +11,20 @@ namespace Microsoft.Extensions.AI; [Experimental("MEAI001")] public class SpeechToTextOptions { + /// Gets or sets any additional properties associated with the options. + public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } + /// Gets or sets the model ID for the speech to text. public string? ModelId { get; set; } /// Gets or sets the language of source speech. public string? SpeechLanguage { get; set; } - /// Gets or sets the language for the target generated text. - public string? TextLanguage { get; set; } - /// Gets or sets the sample rate of the speech input audio. public int? SpeechSampleRate { get; set; } - /// Gets or sets any additional properties associated with the options. - public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } + /// Gets or sets the language for the target generated text. + public string? TextLanguage { get; set; } /// /// Gets or sets a callback responsible for creating the raw representation of the embedding generation options from an underlying implementation. @@ -51,11 +51,11 @@ public virtual SpeechToTextOptions Clone() { SpeechToTextOptions options = new() { + AdditionalProperties = AdditionalProperties?.Clone(), ModelId = ModelId, SpeechLanguage = SpeechLanguage, - TextLanguage = TextLanguage, SpeechSampleRate = SpeechSampleRate, - AdditionalProperties = AdditionalProperties?.Clone(), + TextLanguage = TextLanguage, }; return options; diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.csproj b/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.csproj index 552d45f0fc6..a135ee011ea 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.csproj +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/Microsoft.Extensions.AI.OpenAI.csproj @@ -1,4 +1,4 @@ - + Microsoft.Extensions.AI @@ -15,8 +15,8 @@ $(TargetFrameworks);netstandard2.0 - $(NoWarn);CA1063;CA1508;CA2227;SA1316;S1121;S3358;EA0002;OPENAI002 - $(NoWarn);MEAI001 + $(NoWarn);CA1063;CA1508;CA2227;SA1316;S1121;S3358;EA0002 + $(NoWarn);OPENAI001;OPENAI002;MEAI001 true true true diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantChatClient.cs index 0b6c5f5122f..c1d59e30a68 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantChatClient.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; @@ -28,7 +27,6 @@ namespace Microsoft.Extensions.AI; /// Represents an for an Azure.AI.Agents.Persistent . -[Experimental("OPENAI001")] internal sealed class OpenAIAssistantChatClient : IChatClient { /// The underlying . diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index c051550d493..3be0a1cc1ee 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -91,7 +91,7 @@ public IAsyncEnumerable GetStreamingResponseAsync( // Make the call to OpenAI. var chatCompletionUpdates = _chatClient.CompleteChatStreamingAsync(openAIChatMessages, openAIOptions, cancellationToken); - return FromOpenAIStreamingChatCompletionAsync(chatCompletionUpdates, cancellationToken); + return FromOpenAIStreamingChatCompletionAsync(chatCompletionUpdates, openAIOptions, cancellationToken); } /// @@ -290,7 +290,8 @@ private static List ToOpenAIChatContent(IList private static async IAsyncEnumerable FromOpenAIStreamingChatCompletionAsync( IAsyncEnumerable updates, - [EnumeratorCancellation] CancellationToken cancellationToken = default) + ChatCompletionOptions? options, + [EnumeratorCancellation] CancellationToken cancellationToken) { Dictionary? functionCallInfos = null; ChatRole? streamedRole = null; @@ -334,6 +335,14 @@ private static async IAsyncEnumerable FromOpenAIStreamingCha } } + if (update.OutputAudioUpdate is { } audioUpdate) + { + responseUpdate.Contents.Add(new DataContent(audioUpdate.AudioBytesUpdate.ToMemory(), GetOutputAudioMimeType(options)) + { + RawRepresentation = audioUpdate, + }); + } + // Transfer over refusal updates. if (update.RefusalUpdate is not null) { @@ -363,8 +372,10 @@ private static async IAsyncEnumerable FromOpenAIStreamingCha // Transfer over usage updates. if (update.Usage is ChatTokenUsage tokenUsage) { - var usageDetails = FromOpenAIUsage(tokenUsage); - responseUpdate.Contents.Add(new UsageContent(usageDetails)); + responseUpdate.Contents.Add(new UsageContent(FromOpenAIUsage(tokenUsage)) + { + RawRepresentation = tokenUsage, + }); } // Now yield the item. @@ -408,6 +419,17 @@ private static async IAsyncEnumerable FromOpenAIStreamingCha } } + private static string GetOutputAudioMimeType(ChatCompletionOptions? options) => + options?.AudioOptions?.OutputAudioFormat.ToString()?.ToLowerInvariant() switch + { + "opus" => "audio/opus", + "aac" => "audio/aac", + "flac" => "audio/flac", + "wav" => "audio/wav", + "pcm" => "audio/pcm", + "mp3" or _ => "audio/mpeg", + }; + private static ChatResponse FromOpenAIChatCompletion(ChatCompletion openAICompletion, ChatOptions? options, ChatCompletionOptions chatCompletionOptions) { _ = Throw.IfNull(openAICompletion); @@ -432,19 +454,10 @@ private static ChatResponse FromOpenAIChatCompletion(ChatCompletion openAIComple // Output audio is handled separately from message content parts. if (openAICompletion.OutputAudio is ChatOutputAudio audio) { - string mimeType = chatCompletionOptions?.AudioOptions?.OutputAudioFormat.ToString()?.ToLowerInvariant() switch + returnMessage.Contents.Add(new DataContent(audio.AudioBytes.ToMemory(), GetOutputAudioMimeType(chatCompletionOptions)) { - "opus" => "audio/opus", - "aac" => "audio/aac", - "flac" => "audio/flac", - "wav" => "audio/wav", - "pcm" => "audio/pcm", - "mp3" or _ => "audio/mpeg", - }; - - var dc = new DataContent(audio.AudioBytes.ToMemory(), mimeType); - - returnMessage.Contents.Add(dc); + RawRepresentation = audio, + }); } // Also manufacture function calling content items from any tool calls in the response. @@ -505,9 +518,7 @@ private ChatCompletionOptions ToOpenAIOptions(ChatOptions? options) result.PresencePenalty ??= options.PresencePenalty; result.Temperature ??= options.Temperature; result.AllowParallelToolCalls ??= options.AllowMultipleToolCalls; -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. result.Seed ??= options.Seed; -#pragma warning restore OPENAI001 if (options.StopSequences is { Count: > 0 } stopSequences) { diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs index b20769c0dc4..dccddf3038e 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs @@ -14,7 +14,7 @@ using OpenAI.Audio; using OpenAI.Chat; using OpenAI.Embeddings; -using OpenAI.RealtimeConversation; +using OpenAI.Realtime; using OpenAI.Responses; #pragma warning disable S103 // Lines should not be too long @@ -134,7 +134,6 @@ public static IChatClient AsIChatClient(this OpenAIResponseClient responseClient /// is . /// is . /// is empty or composed entirely of whitespace. - [Experimental("OPENAI001")] // AssistantClient itself is experimental with this ID public static IChatClient AsIChatClient(this AssistantClient assistantClient, string assistantId, string? threadId = null) => new OpenAIAssistantChatClient(assistantClient, assistantId, threadId); @@ -165,7 +164,6 @@ public static ChatTool AsOpenAIChatTool(this AIFunction function) => /// The function to convert. /// An OpenAI representing . /// is . - [Experimental("OPENAI001")] // AssistantClient itself is experimental with this ID public static FunctionToolDefinition AsOpenAIAssistantsFunctionToolDefinition(this AIFunction function) => OpenAIAssistantChatClient.ToOpenAIAssistantsFunctionToolDefinition(Throw.IfNull(function)); diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeConversationClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeConversationClient.cs index abfebd99f34..7c944ac5edb 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeConversationClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIRealtimeConversationClient.cs @@ -1,7 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using OpenAI.RealtimeConversation; +using OpenAI.Realtime; namespace Microsoft.Extensions.AI; diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs index 46019166719..6aee4bc77e4 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs @@ -117,6 +117,13 @@ public async Task GetResponseAsync( ((List)message.Contents).AddRange(ToAIContents(messageItem.Content)); break; + case ReasoningResponseItem reasoningItem when reasoningItem.GetSummaryText() is string summary && !string.IsNullOrWhiteSpace(summary): + message.Contents.Add(new TextReasoningContent(summary) + { + RawRepresentation = reasoningItem + }); + break; + case FunctionCallResponseItem functionCall: response.FinishReason ??= ChatFinishReason.ToolCalls; var fcc = FunctionCallContent.CreateFromParsedArguments( @@ -139,7 +146,7 @@ public async Task GetResponseAsync( if (openAIResponse.Error is { } error) { - message.Contents.Add(new ErrorContent(error.Message) { ErrorCode = error.Code }); + message.Contents.Add(new ErrorContent(error.Message) { ErrorCode = error.Code.ToString() }); } } @@ -367,10 +374,11 @@ private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? opt // Handle strongly-typed properties. result.MaxOutputTokenCount ??= options.MaxOutputTokens; + result.ParallelToolCallsEnabled ??= options.AllowMultipleToolCalls; result.PreviousResponseId ??= options.ConversationId; - result.TopP ??= options.TopP; result.Temperature ??= options.Temperature; - result.ParallelToolCallsEnabled ??= options.AllowMultipleToolCalls; + result.TopP ??= options.TopP; + if (options.Instructions is { } instructions) { result.Instructions = string.IsNullOrEmpty(result.Instructions) ? @@ -386,22 +394,21 @@ private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? opt switch (tool) { case AIFunction aiFunction: - ResponseTool rtool = ToResponseTool(aiFunction, options); - result.Tools.Add(rtool); + result.Tools.Add(ToResponseTool(aiFunction, options)); break; case HostedWebSearchTool: - WebSearchToolLocation? location = null; - if (tool.AdditionalProperties.TryGetValue(nameof(WebSearchToolLocation), out object? objLocation)) + WebSearchUserLocation? location = null; + if (tool.AdditionalProperties.TryGetValue(nameof(WebSearchUserLocation), out object? objLocation)) { - location = objLocation as WebSearchToolLocation; + location = objLocation as WebSearchUserLocation; } - WebSearchToolContextSize? size = null; - if (tool.AdditionalProperties.TryGetValue(nameof(WebSearchToolContextSize), out object? objSize) && - objSize is WebSearchToolContextSize) + WebSearchContextSize? size = null; + if (tool.AdditionalProperties.TryGetValue(nameof(WebSearchContextSize), out object? objSize) && + objSize is WebSearchContextSize) { - size = (WebSearchToolContextSize)objSize; + size = (WebSearchContextSize)objSize; } result.Tools.Add(ResponseTool.CreateWebSearchTool(location, size)); @@ -522,6 +529,10 @@ private static IEnumerable ToOpenAIResponseItems( yield return ResponseItem.CreateAssistantMessageItem(textContent.Text); break; + case TextReasoningContent reasoningContent: + yield return ResponseItem.CreateReasoningItem(reasoningContent.Text); + break; + case FunctionCallContent callContent: yield return ResponseItem.CreateFunctionCallItem( callContent.CallId, @@ -555,12 +566,16 @@ private static IEnumerable ToOpenAIResponseItems( TotalTokenCount = usage.TotalTokenCount, }; - if (usage.OutputTokenDetails is { } outputDetails) + if (usage.InputTokenDetails is { } inputDetails) { ud.AdditionalCounts ??= []; + ud.AdditionalCounts.Add($"{nameof(usage.InputTokenDetails)}.{nameof(inputDetails.CachedTokenCount)}", inputDetails.CachedTokenCount); + } - const string OutputDetails = nameof(usage.OutputTokenDetails); - ud.AdditionalCounts.Add($"{OutputDetails}.{nameof(outputDetails.ReasoningTokenCount)}", outputDetails.ReasoningTokenCount); + if (usage.OutputTokenDetails is { } outputDetails) + { + ud.AdditionalCounts ??= []; + ud.AdditionalCounts.Add($"{nameof(usage.OutputTokenDetails)}.{nameof(outputDetails.ReasoningTokenCount)}", outputDetails.ReasoningTokenCount); } } @@ -624,8 +639,7 @@ private static List ToOpenAIResponsesContent(IListDefault OpenAI endpoint. - private static readonly Uri _defaultOpenAIEndpoint = new("https://api.openai.com/v1"); + /// Filename to use when audio lacks a name. + /// This information internally is required but is only being used to create a header name in the multipart request. + private const string Filename = "audio.mp3"; /// Metadata about the client. private readonly SpeechToTextClientMetadata _metadata; @@ -45,7 +46,7 @@ public OpenAISpeechToTextClient(AudioClient audioClient) // implement the abstractions directly rather than providing adapters on top of the public APIs, // the package can provide such implementations separate from what's exposed in the public API. Uri providerUrl = typeof(AudioClient).GetField("_endpoint", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) - ?.GetValue(audioClient) as Uri ?? _defaultOpenAIEndpoint; + ?.GetValue(audioClient) as Uri ?? OpenAIClientExtensions.DefaultOpenAIEndpoint; string? model = typeof(AudioClient).GetField("_model", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) ?.GetValue(audioClient) as string; @@ -65,20 +66,6 @@ public OpenAISpeechToTextClient(AudioClient audioClient) null; } - /// - public async IAsyncEnumerable GetStreamingTextAsync( - Stream audioSpeechStream, SpeechToTextOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - _ = Throw.IfNull(audioSpeechStream); - - var speechResponse = await GetTextAsync(audioSpeechStream, options, cancellationToken).ConfigureAwait(false); - - foreach (var update in speechResponse.ToSpeechToTextResponseUpdates()) - { - yield return update; - } - } - /// public async Task GetTextAsync( Stream audioSpeechStream, SpeechToTextOptions? options = null, CancellationToken cancellationToken = default) @@ -87,140 +74,126 @@ public async Task GetTextAsync( SpeechToTextResponse response = new(); - // A translation is triggered when the target text language is specified and the source language is not provided or different. - static bool IsTranslationRequest(SpeechToTextOptions? options) - => options is not null && options.TextLanguage is not null - && (options.SpeechLanguage is null || options.SpeechLanguage != options.TextLanguage); + string filename = audioSpeechStream is FileStream fileStream ? + Path.GetFileName(fileStream.Name) : // Use the file name if we can get one from the stream. + Filename; // Otherwise, use a default name; this is only used to create a header name in the multipart request. if (IsTranslationRequest(options)) { - _ = Throw.IfNull(options); + var translation = (await _audioClient.TranslateAudioAsync(audioSpeechStream, filename, ToOpenAITranslationOptions(options), cancellationToken).ConfigureAwait(false)).Value; - var openAIOptions = ToOpenAITranslationOptions(options); - AudioTranslation translationResult; + response.Contents = [new TextContent(translation.Text)]; + response.RawRepresentation = translation; -#if NET - await using (audioSpeechStream.ConfigureAwait(false)) -#else - using (audioSpeechStream) -#endif + int segmentCount = translation.Segments.Count; + if (segmentCount > 0) { - translationResult = (await _audioClient.TranslateAudioAsync( - audioSpeechStream, - "file.wav", // this information internally is required but is only being used to create a header name in the multipart request. - openAIOptions, cancellationToken).ConfigureAwait(false)).Value; + response.StartTime = translation.Segments[0].StartTime; + response.EndTime = translation.Segments[segmentCount - 1].EndTime; } - - UpdateResponseFromOpenAIAudioTranslation(response, translationResult); } else { - var openAIOptions = ToOpenAITranscriptionOptions(options); + var transcription = (await _audioClient.TranscribeAudioAsync(audioSpeechStream, filename, ToOpenAITranscriptionOptions(options), cancellationToken).ConfigureAwait(false)).Value; - // Transcription request - AudioTranscription transcriptionResult; + response.Contents = [new TextContent(transcription.Text)]; + response.RawRepresentation = transcription; -#if NET - await using (audioSpeechStream.ConfigureAwait(false)) -#else - using (audioSpeechStream) -#endif + int segmentCount = transcription.Segments.Count; + if (segmentCount > 0) { - transcriptionResult = (await _audioClient.TranscribeAudioAsync( - audioSpeechStream, - "file.wav", // this information internally is required but is only being used to create a header name in the multipart request. - openAIOptions, cancellationToken).ConfigureAwait(false)).Value; + response.StartTime = transcription.Segments[0].StartTime; + response.EndTime = transcription.Segments[segmentCount - 1].EndTime; + } + else + { + int wordCount = transcription.Words.Count; + if (wordCount > 0) + { + response.StartTime = transcription.Words[0].StartTime; + response.EndTime = transcription.Words[wordCount - 1].EndTime; + } } - - UpdateResponseFromOpenAIAudioTranscription(response, transcriptionResult); } return response; } /// - void IDisposable.Dispose() - { - // Nothing to dispose. Implementation required for the IAudioTranscriptionClient interface. - } - - /// Updates a from an OpenAI . - /// The response to update. - /// The OpenAI audio transcription. - private static void UpdateResponseFromOpenAIAudioTranscription(SpeechToTextResponse response, AudioTranscription audioTranscription) + public async IAsyncEnumerable GetStreamingTextAsync( + Stream audioSpeechStream, SpeechToTextOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - _ = Throw.IfNull(audioTranscription); + _ = Throw.IfNull(audioSpeechStream); - var segmentCount = audioTranscription.Segments.Count; - var wordCount = audioTranscription.Words.Count; + string filename = audioSpeechStream is FileStream fileStream ? + Path.GetFileName(fileStream.Name) : // Use the file name if we can get one from the stream. + Filename; // Otherwise, use a default name; this is only used to create a header name in the multipart request. - TimeSpan? endTime = null; - TimeSpan? startTime = null; - if (segmentCount > 0) + if (IsTranslationRequest(options)) { - endTime = audioTranscription.Segments[segmentCount - 1].EndTime; - startTime = audioTranscription.Segments[0].StartTime; + foreach (var update in (await GetTextAsync(audioSpeechStream, options, cancellationToken).ConfigureAwait(false)).ToSpeechToTextResponseUpdates()) + { + yield return update; + } } - else if (wordCount > 0) + else { - endTime = audioTranscription.Words[wordCount - 1].EndTime; - startTime = audioTranscription.Words[0].StartTime; + await foreach (var update in _audioClient.TranscribeAudioStreamingAsync( + audioSpeechStream, + filename, + ToOpenAITranscriptionOptions(options), + cancellationToken).ConfigureAwait(false)) + { + SpeechToTextResponseUpdate result = new() + { + ModelId = options?.ModelId, + RawRepresentation = update, + }; + + switch (update) + { + case StreamingAudioTranscriptionTextDeltaUpdate deltaUpdate: + result.Kind = SpeechToTextResponseUpdateKind.TextUpdated; + result.Contents = [new TextContent(deltaUpdate.Delta)]; + break; + + case StreamingAudioTranscriptionTextDoneUpdate doneUpdate: + result.Kind = SpeechToTextResponseUpdateKind.SessionClose; + break; + } + + yield return result; + } } + } - // Update the response - response.RawRepresentation = audioTranscription; - response.Contents = [new TextContent(audioTranscription.Text)]; - response.StartTime = startTime; - response.EndTime = endTime; - response.AdditionalProperties = new AdditionalPropertiesDictionary - { - [nameof(audioTranscription.Language)] = audioTranscription.Language, - [nameof(audioTranscription.Duration)] = audioTranscription.Duration - }; + /// + void IDisposable.Dispose() + { + // Nothing to dispose. Implementation required for the IAudioTranscriptionClient interface. } - /// Converts an extensions options instance to an OpenAI options instance. + // A translation is triggered when the target text language is specified and the source language is not provided or different. + private static bool IsTranslationRequest(SpeechToTextOptions? options) => + options is not null && + options.TextLanguage is not null && + (options.SpeechLanguage is null || options.SpeechLanguage != options.TextLanguage); + + /// Converts an extensions options instance to an OpenAI transcription options instance. private AudioTranscriptionOptions ToOpenAITranscriptionOptions(SpeechToTextOptions? options) { - if (options?.RawRepresentationFactory?.Invoke(this) is not AudioTranscriptionOptions result) - { - result = new AudioTranscriptionOptions(); - } + AudioTranscriptionOptions result = options?.RawRepresentationFactory?.Invoke(this) as AudioTranscriptionOptions ?? new(); result.Language ??= options?.SpeechLanguage; + return result; } - /// Updates a from an OpenAI . - /// The response to update. - /// The OpenAI audio translation. - private static void UpdateResponseFromOpenAIAudioTranslation(SpeechToTextResponse response, AudioTranslation audioTranslation) + /// Converts an extensions options instance to an OpenAI translation options instance. + private AudioTranslationOptions ToOpenAITranslationOptions(SpeechToTextOptions? options) { - _ = Throw.IfNull(audioTranslation); - - var segmentCount = audioTranslation.Segments.Count; - - TimeSpan? endTime = null; - TimeSpan? startTime = null; - if (segmentCount > 0) - { - endTime = audioTranslation.Segments[segmentCount - 1].EndTime; - startTime = audioTranslation.Segments[0].StartTime; - } + AudioTranslationOptions result = options?.RawRepresentationFactory?.Invoke(this) as AudioTranslationOptions ?? new(); - // Update the response - response.RawRepresentation = audioTranslation; - response.Contents = [new TextContent(audioTranslation.Text)]; - response.StartTime = startTime; - response.EndTime = endTime; - response.AdditionalProperties = new AdditionalPropertiesDictionary - { - [nameof(audioTranslation.Language)] = audioTranslation.Language, - [nameof(audioTranslation.Duration)] = audioTranslation.Duration - }; + return result; } - - /// Converts an extensions options instance to an OpenAI options instance. - private AudioTranslationOptions ToOpenAITranslationOptions(SpeechToTextOptions? options) - => options?.RawRepresentationFactory?.Invoke(this) as AudioTranslationOptions ?? new AudioTranslationOptions(); } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/Microsoft.Extensions.AI.OpenAI.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/Microsoft.Extensions.AI.OpenAI.Tests.csproj index 60b6f8c84ab..536c250cb47 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/Microsoft.Extensions.AI.OpenAI.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/Microsoft.Extensions.AI.OpenAI.Tests.csproj @@ -2,7 +2,8 @@ Microsoft.Extensions.AI Unit tests for Microsoft.Extensions.AI.OpenAI - $(NoWarn);OPENAI002;MEAI001;S104 + $(NoWarn);S104 + $(NoWarn);OPENAI001;MEAI001 diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAIFunctionConversionTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAIFunctionConversionTests.cs index f2f0c9d8a3f..ce458473c59 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAIFunctionConversionTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAIFunctionConversionTests.cs @@ -6,12 +6,10 @@ using System.Text.Json; using OpenAI.Assistants; using OpenAI.Chat; -using OpenAI.RealtimeConversation; +using OpenAI.Realtime; using OpenAI.Responses; using Xunit; -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - namespace Microsoft.Extensions.AI; public class OpenAIAIFunctionConversionTests @@ -24,7 +22,7 @@ public class OpenAIAIFunctionConversionTests [Fact] public void AsOpenAIChatTool_ProducesValidInstance() { - ChatTool tool = _testFunction.AsOpenAIChatTool(); + var tool = _testFunction.AsOpenAIChatTool(); Assert.NotNull(tool); Assert.Equal("test_function", tool.FunctionName); @@ -35,7 +33,7 @@ public void AsOpenAIChatTool_ProducesValidInstance() [Fact] public void AsOpenAIResponseTool_ProducesValidInstance() { - ResponseTool tool = _testFunction.AsOpenAIResponseTool(); + var tool = _testFunction.AsOpenAIResponseTool(); Assert.NotNull(tool); } @@ -43,7 +41,7 @@ public void AsOpenAIResponseTool_ProducesValidInstance() [Fact] public void AsOpenAIConversationFunctionTool_ProducesValidInstance() { - ConversationFunctionTool tool = _testFunction.AsOpenAIConversationFunctionTool(); + var tool = _testFunction.AsOpenAIConversationFunctionTool(); Assert.NotNull(tool); Assert.Equal("test_function", tool.Name); @@ -54,7 +52,7 @@ public void AsOpenAIConversationFunctionTool_ProducesValidInstance() [Fact] public void AsOpenAIAssistantsFunctionToolDefinition_ProducesValidInstance() { - FunctionToolDefinition tool = _testFunction.AsOpenAIAssistantsFunctionToolDefinition(); + var tool = _testFunction.AsOpenAIAssistantsFunctionToolDefinition(); Assert.NotNull(tool); Assert.Equal("test_function", tool.FunctionName); @@ -62,7 +60,7 @@ public void AsOpenAIAssistantsFunctionToolDefinition_ProducesValidInstance() ValidateSchemaParameters(tool.Parameters); } - /// Helper method to validate function parameters match our schema + /// Helper method to validate function parameters match our schema. private static void ValidateSchemaParameters(BinaryData parameters) { Assert.NotNull(parameters); diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientIntegrationTests.cs index e616d5fb87b..90bcf9f2632 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientIntegrationTests.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. #pragma warning disable CA1822 // Mark members as static #pragma warning disable CA2000 // Dispose objects before losing scope #pragma warning disable S1135 // Track uses of "TODO" tags @@ -62,7 +61,7 @@ public async Task DeleteAllThreads() client.DefaultRequestHeaders.Add("openai-organization", "org-ENTERYOURORGID"); client.DefaultRequestHeaders.Add("openai-project", "proj_ENTERYOURPROJECTID"); - AssistantClient ac = new AssistantClient(Environment.GetEnvironmentVariable("AI:OpenAI:ApiKey")!); + AssistantClient ac = new(Environment.GetEnvironmentVariable("AI:OpenAI:ApiKey")!); while (true) { string listing = await client.GetStringAsync("https://api.openai.com/v1/threads?limit=100"); diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientTests.cs index 6d3a02a08ec..3b084b5ec8a 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientTests.cs @@ -3,7 +3,6 @@ using System; using System.ClientModel; -using Azure.AI.OpenAI; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; using OpenAI; @@ -11,7 +10,6 @@ using Xunit; #pragma warning disable S103 // Lines should not be too long -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. namespace Microsoft.Extensions.AI; @@ -24,16 +22,12 @@ public void AsIChatClient_InvalidArgs_Throws() Assert.Throws("assistantId", () => new AssistantClient("ignored").AsIChatClient(null!)); } - [Theory] - [InlineData(false)] - [InlineData(true)] - public void AsIChatClient_OpenAIClient_ProducesExpectedMetadata(bool useAzureOpenAI) + [Fact] + public void AsIChatClient_OpenAIClient_ProducesExpectedMetadata() { Uri endpoint = new("http://localhost/some/endpoint"); - var client = useAzureOpenAI ? - new AzureOpenAIClient(endpoint, new ApiKeyCredential("key")) : - new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); + var client = new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); IChatClient[] clients = [ diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs index edb5d9fab07..d06d8f520be 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs @@ -11,7 +11,6 @@ using System.Text.Json; using System.Text.Json.Serialization; using System.Threading.Tasks; -using Azure.AI.OpenAI; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; using OpenAI; @@ -30,17 +29,13 @@ public void AsIChatClient_InvalidArgs_Throws() Assert.Throws("chatClient", () => ((ChatClient)null!).AsIChatClient()); } - [Theory] - [InlineData(false)] - [InlineData(true)] - public void AsIChatClient_OpenAIClient_ProducesExpectedMetadata(bool useAzureOpenAI) + [Fact] + public void AsIChatClient_OpenAIClient_ProducesExpectedMetadata() { Uri endpoint = new("http://localhost/some/endpoint"); string model = "amazingModel"; - var client = useAzureOpenAI ? - new AzureOpenAIClient(endpoint, new ApiKeyCredential("key")) : - new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); + var client = new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); IChatClient chatClient = client.GetChatClient(model).AsIChatClient(); var metadata = chatClient.GetService(); @@ -398,9 +393,7 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio TopP = 0.5f, PresencePenalty = 0.5f, Temperature = 0.5f, -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Seed = 42, -#pragma warning restore OPENAI001 ToolChoice = ChatToolChoice.CreateAutoChoice(), ResponseFormat = OpenAI.Chat.ChatResponseFormat.CreateTextFormat() }; @@ -477,9 +470,7 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio TopP = 0.5f, PresencePenalty = 0.5f, Temperature = 0.5f, -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Seed = 42, -#pragma warning restore OPENAI001 ToolChoice = ChatToolChoice.CreateAutoChoice(), ResponseFormat = OpenAI.Chat.ChatResponseFormat.CreateTextFormat() }; @@ -561,9 +552,7 @@ public async Task ChatOptions_Overwrite_NullPropertiesInRawRepresentation_NonStr Assert.Null(openAIOptions.TopP); Assert.Null(openAIOptions.PresencePenalty); Assert.Null(openAIOptions.Temperature); -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Assert.Null(openAIOptions.Seed); -#pragma warning restore OPENAI001 Assert.Empty(openAIOptions.StopSequences); Assert.Empty(openAIOptions.Tools); Assert.Null(openAIOptions.ToolChoice); @@ -637,9 +626,7 @@ public async Task ChatOptions_Overwrite_NullPropertiesInRawRepresentation_Stream Assert.Null(openAIOptions.TopP); Assert.Null(openAIOptions.PresencePenalty); Assert.Null(openAIOptions.Temperature); -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Assert.Null(openAIOptions.Seed); -#pragma warning restore OPENAI001 Assert.Empty(openAIOptions.StopSequences); Assert.Empty(openAIOptions.Tools); Assert.Null(openAIOptions.ToolChoice); diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs index 9d8a1219ea7..43112fa88e3 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs @@ -6,7 +6,6 @@ using System.ClientModel.Primitives; using System.Net.Http; using System.Threading.Tasks; -using Azure.AI.OpenAI; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; using OpenAI; @@ -25,17 +24,13 @@ public void AsIEmbeddingGenerator_InvalidArgs_Throws() Assert.Throws("embeddingClient", () => ((EmbeddingClient)null!).AsIEmbeddingGenerator()); } - [Theory] - [InlineData(false)] - [InlineData(true)] - public void AsIEmbeddingGenerator_OpenAIClient_ProducesExpectedMetadata(bool useAzureOpenAI) + [Fact] + public void AsIEmbeddingGenerator_OpenAIClient_ProducesExpectedMetadata() { Uri endpoint = new("http://localhost/some/endpoint"); string model = "amazingModel"; - var client = useAzureOpenAI ? - new AzureOpenAIClient(endpoint, new ApiKeyCredential("key")) : - new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); + var client = new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); IEmbeddingGenerator> embeddingGenerator = client.GetEmbeddingClient(model).AsIEmbeddingGenerator(); var metadata = embeddingGenerator.GetService(); diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs index 28125e462b7..b98eb89197f 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs @@ -10,7 +10,6 @@ using System.Net.Http; using System.Text.Json; using System.Threading.Tasks; -using Azure.AI.OpenAI; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; using OpenAI; @@ -29,17 +28,13 @@ public void AsIChatClient_InvalidArgs_Throws() Assert.Throws("responseClient", () => ((OpenAIResponseClient)null!).AsIChatClient()); } - [Theory] - [InlineData(false)] - [InlineData(true)] - public void AsIChatClient_ProducesExpectedMetadata(bool useAzureOpenAI) + [Fact] + public void AsIChatClient_ProducesExpectedMetadata() { Uri endpoint = new("http://localhost/some/endpoint"); string model = "amazingModel"; - var client = useAzureOpenAI ? - new AzureOpenAIClient(endpoint, new ApiKeyCredential("key")) : - new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); + var client = new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); IChatClient chatClient = client.GetOpenAIResponseClient(model).AsIChatClient(); var metadata = chatClient.GetService(); diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientIntegrationTests.cs index c80b37c865e..bb721806ff6 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientIntegrationTests.cs @@ -7,6 +7,6 @@ public class OpenAISpeechToTextClientIntegrationTests : SpeechToTextClientIntegr { protected override ISpeechToTextClient? CreateClient() => IntegrationTestHelpers.GetOpenAIClient()? - .GetAudioClient(TestRunnerConfiguration.Instance["OpenAI:AudioTranscriptionModel"] ?? "whisper-1") + .GetAudioClient(TestRunnerConfiguration.Instance["OpenAI:AudioTranscriptionModel"] ?? "gpt-4o-mini-transcribe") .AsISpeechToTextClient(); } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientTests.cs index c92d9627968..1252a20741b 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientTests.cs @@ -8,7 +8,6 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; -using Azure.AI.OpenAI; using Microsoft.Extensions.Logging; using OpenAI; using OpenAI.Audio; @@ -26,17 +25,13 @@ public void AsISpeechToTextClient_InvalidArgs_Throws() Assert.Throws("audioClient", () => ((AudioClient)null!).AsISpeechToTextClient()); } - [Theory] - [InlineData(false)] - [InlineData(true)] - public void AsISpeechToTextClient_AudioClient_ProducesExpectedMetadata(bool useAzureOpenAI) + [Fact] + public void AsISpeechToTextClient_AudioClient_ProducesExpectedMetadata() { Uri endpoint = new("http://localhost/some/endpoint"); string model = "amazingModel"; - var client = useAzureOpenAI ? - new AzureOpenAIClient(endpoint, new ApiKeyCredential("key")) : - new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); + var client = new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); ISpeechToTextClient speechToTextClient = client.GetAudioClient(model).AsISpeechToTextClient(); var metadata = speechToTextClient.GetService(); @@ -148,7 +143,8 @@ public async Task GetStreamingTextAsync_BasicRequestResponse(string? speechLangu string input = $$""" { "model": "whisper-1", - "language": "{{speechLanguage}}" + "language": "{{speechLanguage}}", + "stream":true } """; From 0e90bf3d09eff30dfa29cdc54b9d89622d4e1cb2 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Fri, 4 Jul 2025 19:56:30 -0400 Subject: [PATCH 12/13] Update McpServer template for 0.3.0-preview.2 (#6578) --- src/ProjectTemplates/GeneratedContent.targets | 2 +- .../src/McpServer/McpServer-CSharp/Tools/RandomNumberTools.cs | 2 +- .../mcpserver/Tools/RandomNumberTools.cs | 2 +- .../mcpserver.Basic.verified/mcpserver/mcpserver.csproj | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ProjectTemplates/GeneratedContent.targets b/src/ProjectTemplates/GeneratedContent.targets index ce6ba2bc502..dfe93dbc5a8 100644 --- a/src/ProjectTemplates/GeneratedContent.targets +++ b/src/ProjectTemplates/GeneratedContent.targets @@ -39,7 +39,7 @@ 9.3.0 1.53.0 1.53.0-preview - 0.3.0-preview.1 + 0.3.0-preview.2 5.1.18 1.12.0 0.1.10 diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/Tools/RandomNumberTools.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/Tools/RandomNumberTools.cs index 4542f8505a5..568574f47d9 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/Tools/RandomNumberTools.cs +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/Tools/RandomNumberTools.cs @@ -7,7 +7,7 @@ /// internal class RandomNumberTools { - [McpServerTool(Name = "get_random_number")] + [McpServerTool] [Description("Generates a random number between the specified minimum and maximum values.")] public int GetRandomNumber( [Description("Minimum value (inclusive)")] int min = 0, diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/Tools/RandomNumberTools.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/Tools/RandomNumberTools.cs index 72af767e320..611745f4129 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/Tools/RandomNumberTools.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/Tools/RandomNumberTools.cs @@ -7,7 +7,7 @@ /// internal class RandomNumberTools { - [McpServerTool(Name = "get_random_number")] + [McpServerTool] [Description("Generates a random number between the specified minimum and maximum values.")] public int GetRandomNumber( [Description("Minimum value (inclusive)")] int min = 0, diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/mcpserver.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/mcpserver.csproj index a71ac148e6f..e959c64702f 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/mcpserver.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/mcpserver.csproj @@ -26,7 +26,7 @@ - + From e6b555c147e47c7b848cae4aa708468deb28b889 Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Tue, 8 Jul 2025 16:46:11 -0700 Subject: [PATCH 13/13] Fix template tests --- src/ProjectTemplates/GeneratedContent.targets | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ProjectTemplates/GeneratedContent.targets b/src/ProjectTemplates/GeneratedContent.targets index dfe93dbc5a8..e37794c3c0e 100644 --- a/src/ProjectTemplates/GeneratedContent.targets +++ b/src/ProjectTemplates/GeneratedContent.targets @@ -21,9 +21,9 @@ - Use specific version numbers to pin to already-released packages --> - $(Version) - $(Version) - $(Version) + 9.7.0 + 9.7.0-preview.1.25356.2 + 9.7.0