From 5efad04295766987e4527541cf3609e12a77938d Mon Sep 17 00:00:00 2001 From: Eric StJohn Date: Thu, 11 Dec 2025 12:03:36 -0800 Subject: [PATCH 1/9] Bump MEAI version to 10.1.1 --- eng/Versions.props | 2 +- .../aichatweb/aichatweb.Web/aichatweb.Web.csproj | 4 ++-- .../aichatweb.Basic.verified/aichatweb/aichatweb.csproj | 4 ++-- .../aichatweb/aichatweb.Web/aichatweb.Web.csproj | 4 ++-- .../aichatweb/aichatweb.Web/aichatweb.Web.csproj | 2 +- .../aichatweb/aichatweb.csproj | 4 ++-- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/eng/Versions.props b/eng/Versions.props index aed94a55e30..5c3ea3f8b74 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -2,7 +2,7 @@ 10 1 - 0 + 1 preview 1 $(MajorVersion).$(MinorVersion).$(PatchVersion) diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj index dfbeed45dbf..486828a8e5f 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj @@ -9,8 +9,8 @@ - - + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/aichatweb.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/aichatweb.csproj index 34a192db6cd..ef5a0a0bd64 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/aichatweb.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/aichatweb.csproj @@ -8,8 +8,8 @@ - - + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj index 55455cd7afe..462a5148046 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj @@ -9,8 +9,8 @@ - - + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj index 4f1a5c24e25..25d917f6219 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj @@ -10,7 +10,7 @@ - + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/aichatweb.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/aichatweb.csproj index c22478b3496..eb9265a3a69 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/aichatweb.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/aichatweb.csproj @@ -8,9 +8,9 @@ - + - + From cc76fa2554d149ac39cbc57cc474dcf35eb09bfc Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Wed, 10 Dec 2025 08:58:57 -0500 Subject: [PATCH 2/9] Update AI changelogs for 10.1.0 (#7123) --- .../Microsoft.Extensions.AI.Abstractions/CHANGELOG.md | 2 +- src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md | 3 ++- src/Libraries/Microsoft.Extensions.AI/CHANGELOG.md | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md index b108a2503af..8e1b571967e 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md @@ -1,6 +1,6 @@ # Microsoft.Extensions.AI.Abstractions Release History -## NOT YET RELEASED +## 10.1.0 - Fixed package references for net10.0 asset. - Added `AIJsonSchemaCreateOptions.ParameterDescriptions`. diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md index 6dbf0d1facb..607fe6228d6 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md @@ -1,10 +1,11 @@ # Microsoft.Extensions.AI.OpenAI Release History -## NOT YET RELEASED +## 10.1.0-preview.1.25608.1 - Fixed package references for net10.0 asset. - Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. - Updated the OpenAI Responses `IChatClient` to ensure all `ResponseItem`s are yielded in `AIContent`. +- Added workaround to the OpenAI Responses `IChatClient` for OpenAI service sometimes sending error data in a manner different from how it's documented. ## 10.0.1-preview.1.25571.5 diff --git a/src/Libraries/Microsoft.Extensions.AI/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI/CHANGELOG.md index 5d918798c86..4f06605b47f 100644 --- a/src/Libraries/Microsoft.Extensions.AI/CHANGELOG.md +++ b/src/Libraries/Microsoft.Extensions.AI/CHANGELOG.md @@ -1,6 +1,6 @@ # Microsoft.Extensions.AI Release History -## NOT YET RELEASED +## 10.1.0 - Fixed package references for net10.0 asset. - Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. From 2a872c878cab6caf49329e45b08fbca2dfa5bffc Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 11 Dec 2025 08:10:32 -0500 Subject: [PATCH 3/9] Fix markdown parser crash on inline HTML elements (#7131) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com> --- .../MarkdownParser.cs | 4 +++ .../Readers/MarkdownReaderTests.cs | 26 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.Markdig/MarkdownParser.cs b/src/Libraries/Microsoft.Extensions.DataIngestion.Markdig/MarkdownParser.cs index 8ef2b27d152..5cb1b810e71 100644 --- a/src/Libraries/Microsoft.Extensions.DataIngestion.Markdig/MarkdownParser.cs +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.Markdig/MarkdownParser.cs @@ -228,6 +228,10 @@ private static IngestionDocumentSection MapQuoteBlock(QuoteBlock quoteBlock, boo { content.Append(codeInline.Content); } + else if (inline is HtmlInline htmlInline) + { + content.Append(htmlInline.Tag); + } else { throw new NotSupportedException($"Inline type '{inline.GetType().Name}' is not supported."); diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Readers/MarkdownReaderTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Readers/MarkdownReaderTests.cs index dce6d996821..583bad91d83 100644 --- a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Readers/MarkdownReaderTests.cs +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Readers/MarkdownReaderTests.cs @@ -133,6 +133,32 @@ public async Task SupportsTablesWithImages() Assert.Equal("Latest logo", img.AlternativeText); } + [ConditionalFact] + public async Task SupportsInlineHtml() + { + string markdownContent = "This has [1] inline HTML."; + + IngestionDocument document = await ReadAsync(markdownContent); + + var paragraph = Assert.Single(document.EnumerateContent().OfType()); + Assert.Equal("This has [1] inline HTML.", paragraph.Text); + Assert.Equal(markdownContent, paragraph.GetMarkdown()); + } + + [ConditionalFact] + public async Task SupportsMultipleInlineHtmlElements() + { + string markdownContent = """ + Text with bold, italic, subscript, and superscript tags. + """; + + IngestionDocument document = await ReadAsync(markdownContent); + + var paragraph = Assert.Single(document.EnumerateContent().OfType()); + Assert.Equal("Text with bold, italic, subscript, and superscript tags.", paragraph.Text); + Assert.Equal(markdownContent, paragraph.GetMarkdown()); + } + private async Task ReadAsync(string content) { using MemoryStream stream = new(System.Text.Encoding.UTF8.GetBytes(content)); From be3641e91d69908e1e025f973550203db0f2256c Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 11 Dec 2025 08:11:03 -0500 Subject: [PATCH 4/9] Fix IndexOutOfRangeException when parsing markdown tables without trailing pipes (#7133) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com> --- .../MarkdownParser.cs | 26 +++++++++++- .../Readers/MarkdownReaderTests.cs | 41 +++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.DataIngestion.Markdig/MarkdownParser.cs b/src/Libraries/Microsoft.Extensions.DataIngestion.Markdig/MarkdownParser.cs index 5cb1b810e71..5b02f917147 100644 --- a/src/Libraries/Microsoft.Extensions.DataIngestion.Markdig/MarkdownParser.cs +++ b/src/Libraries/Microsoft.Extensions.DataIngestion.Markdig/MarkdownParser.cs @@ -248,8 +248,11 @@ private static IngestionDocumentSection MapQuoteBlock(QuoteBlock quoteBlock, boo { int firstRowIndex = SkipFirstRow(table, outputContent) ? 1 : 0; - // For some reason, table.ColumnDefinitions.Count returns one extra column. - var cells = new IngestionDocumentElement?[table.Count - firstRowIndex, table.ColumnDefinitions.Count - 1]; + // Calculate the actual number of columns by examining the rows. + // table.ColumnDefinitions.Count can vary: for tables WITH trailing pipes it's (columns + 1), + // but for tables WITHOUT trailing pipes it's equal to the actual column count. + int columnCount = GetColumnCount(table, firstRowIndex); + var cells = new IngestionDocumentElement?[table.Count - firstRowIndex, columnCount]; for (int rowIndex = firstRowIndex; rowIndex < table.Count; rowIndex++) { @@ -275,6 +278,25 @@ private static IngestionDocumentSection MapQuoteBlock(QuoteBlock quoteBlock, boo return cells; + static int GetColumnCount(Table table, int firstRowIndex) + { + int maxColumns = 0; + for (int rowIndex = firstRowIndex; rowIndex < table.Count; rowIndex++) + { + var tableRow = (TableRow)table[rowIndex]; + int columnCount = 0; + for (int cellIndex = 0; cellIndex < tableRow.Count; cellIndex++) + { + var tableCell = (TableCell)tableRow[cellIndex]; + columnCount += tableCell.ColumnSpan; + } + + maxColumns = Math.Max(maxColumns, columnCount); + } + + return maxColumns; + } + // Some parsers like MarkItDown include a row with invalid markdown before the separator row: // | | | | | // | --- | --- | --- | --- | diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Readers/MarkdownReaderTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Readers/MarkdownReaderTests.cs index 583bad91d83..0e0ac10ca91 100644 --- a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Readers/MarkdownReaderTests.cs +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Readers/MarkdownReaderTests.cs @@ -59,6 +59,47 @@ public override async Task SupportsTables() Assert.Equal(expected, documentTable.Cells.Map(element => element!.GetMarkdown().Trim())); } + [ConditionalFact] + public async Task SupportsTablesWithoutTrailingPipes() + { + // Markdown tables without trailing pipes (|) at the end of each row should be parsed correctly. + // This was causing IndexOutOfRangeException before the fix. + string markdownContent = """ + # ReadyToRun Flags + + | Flag | Value | Description + |:-------------------------------------------|-----------:|:----------- + | READYTORUN_FLAG_PLATFORM_NEUTRAL_SOURCE | 0x00000001 | Set if the original IL image was platform neutral. + | READYTORUN_FLAG_COMPOSITE | 0x00000002 | The image represents a composite R2R file. + | READYTORUN_FLAG_PARTIAL | 0x00000004 | + | READYTORUN_FLAG_NONSHARED_PINVOKE_STUBS | 0x00000008 | PInvoke stubs compiled into image are non-shareable. + | READYTORUN_FLAG_EMBEDDED_MSIL | 0x00000010 | Input MSIL is embedded in the R2R image. + | READYTORUN_FLAG_COMPONENT | 0x00000020 | This is a component assembly of a composite R2R image + | READYTORUN_FLAG_MULTIMODULE_VERSION_BUBBLE | 0x00000040 | This R2R module has multiple modules within its version bubble. + | READYTORUN_FLAG_UNRELATED_R2R_CODE | 0x00000080 | This R2R module has code in it that would not be naturally encoded. + | READYTORUN_FLAG_PLATFORM_NATIVE_IMAGE | 0x00000100 | The owning composite executable is in the platform native format + """; + + IngestionDocument document = await ReadAsync(markdownContent); + + IngestionDocumentTable documentTable = Assert.Single(document.EnumerateContent().OfType()); + Assert.Equal(10, documentTable.Cells.GetLength(0)); // 10 rows (1 header + 9 data rows) + Assert.Equal(3, documentTable.Cells.GetLength(1)); // 3 columns + + // Verify a few key cells + Assert.Equal("Flag", documentTable.Cells[0, 0]!.GetMarkdown().Trim()); + Assert.Equal("Value", documentTable.Cells[0, 1]!.GetMarkdown().Trim()); + Assert.Equal("Description", documentTable.Cells[0, 2]!.GetMarkdown().Trim()); + + Assert.Equal("READYTORUN_FLAG_PLATFORM_NEUTRAL_SOURCE", documentTable.Cells[1, 0]!.GetMarkdown().Trim()); + Assert.Equal("0x00000001", documentTable.Cells[1, 1]!.GetMarkdown().Trim()); + Assert.Contains("platform neutral", documentTable.Cells[1, 2]!.GetMarkdown().Trim()); + + Assert.Equal("READYTORUN_FLAG_PARTIAL", documentTable.Cells[3, 0]!.GetMarkdown().Trim()); + Assert.Equal("0x00000004", documentTable.Cells[3, 1]!.GetMarkdown().Trim()); + Assert.Null(documentTable.Cells[3, 2]); // Empty description cell is null + } + [ConditionalFact] public override async Task SupportsImages() { From 9dd2a7d6f3a2d2a2ea621c01c8ee9f62385ef573 Mon Sep 17 00:00:00 2001 From: Eric StJohn Date: Thu, 11 Dec 2025 12:24:35 -0800 Subject: [PATCH 5/9] Bump template references to DataIngestion to 10.1.1 --- .../aichatweb/aichatweb.Web/aichatweb.Web.csproj | 6 +++--- .../aichatweb.Basic.verified/aichatweb/aichatweb.csproj | 4 ++-- .../aichatweb/aichatweb.Web/aichatweb.Web.csproj | 6 +++--- .../aichatweb/aichatweb.Web/aichatweb.Web.csproj | 6 +++--- .../aichatweb/aichatweb.csproj | 4 ++-- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj index 486828a8e5f..47cf239e07b 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.AzureOpenAI_Qdrant_Aspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj @@ -11,9 +11,9 @@ - - - + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/aichatweb.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/aichatweb.csproj index ef5a0a0bd64..77ead7c69ea 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/aichatweb.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Basic.verified/aichatweb/aichatweb.csproj @@ -10,8 +10,8 @@ - - + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj index 462a5148046..07b23118444 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.BasicAspire.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj @@ -11,9 +11,9 @@ - - - + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj index 25d917f6219..58262329c83 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.Ollama_Qdrant.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj @@ -11,9 +11,9 @@ - - - + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/aichatweb.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/aichatweb.csproj index eb9265a3a69..383a6012b2c 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/aichatweb.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/aichatweb.csproj @@ -11,8 +11,8 @@ - - + + From d0d7beb8547bb8a28014c0c695bc507215f32e1e Mon Sep 17 00:00:00 2001 From: "Eric St. John" Date: Thu, 11 Dec 2025 17:13:36 -0800 Subject: [PATCH 6/9] Omit M.E.Http.Reslience update from templates --- src/ProjectTemplates/GeneratedContent.targets | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/ProjectTemplates/GeneratedContent.targets b/src/ProjectTemplates/GeneratedContent.targets index 0aa5afc9b1a..97870dcc6b7 100644 --- a/src/ProjectTemplates/GeneratedContent.targets +++ b/src/ProjectTemplates/GeneratedContent.targets @@ -21,11 +21,14 @@ "TemplatePackageVersion_{PackageName}" where {PackageName} is the package ID with '.' characters removed. The value of each property will be the computed package version. + + IMPORTANT: Internal packages that ship at a different cadence than these project templates should + be referenced using explicit package versions instead (see the "ComputeGeneratedContentProperties" + target below). --> - @@ -46,6 +49,7 @@ 11.7.0 13.0.0-beta.444 10.0.0 + 10.1.0 10.0.0 2.0.0 1.67.1 @@ -75,6 +79,7 @@ TemplatePackageVersion_AzureSearchDocuments=$(TemplatePackageVersion_AzureSearchDocuments); TemplatePackageVersion_CommunityToolkitAspire=$(TemplatePackageVersion_CommunityToolkitAspire); TemplatePackageVersion_MicrosoftExtensionsHosting=$(TemplatePackageVersion_MicrosoftExtensionsHosting); + TemplatePackageVersion_MicrosoftExtensionsHttpResilience=$(TemplatePackageVersion_MicrosoftExtensionsHttpResilience); TemplatePackageVersion_MicrosoftExtensionsServiceDiscovery=$(TemplatePackageVersion_MicrosoftExtensionsServiceDiscovery); TemplatePackageVersion_MicrosoftMLTokenizers=$(TemplatePackageVersion_MicrosoftMLTokenizers); TemplatePackageVersion_MicrosoftSemanticKernel=$(TemplatePackageVersion_MicrosoftSemanticKernel); From 9085192b1a714e5c4de8b309519e5eca4bd7da48 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 11 Dec 2025 21:48:57 -0500 Subject: [PATCH 7/9] Augment UsageDetails with cached / reasoning token counts (#7122) Cached tokens are currently reported by Anthropic, Gemini, OpenAI, and AWS. Reasoning tokens are currently reported by OpenAI and Gemini. --- .../Microsoft.Extensions.AI.Abstractions.json | 8 + .../UsageDetails.cs | 29 +++ .../OpenAIChatClient.cs | 4 +- .../OpenAIResponsesChatClient.cs | 14 +- .../Contents/UsageContentTests.cs | 6 +- .../UsageDetailsTests.cs | 190 ++++++++++++++++++ .../OpenAIChatClientTests.cs | 40 ++-- .../OpenAIResponseClientTests.cs | 6 +- 8 files changed, 259 insertions(+), 38 deletions(-) create mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/UsageDetailsTests.cs diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json index 32faa8a1f4d..b0b47c4ef6a 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json @@ -2236,6 +2236,14 @@ { "Member": "long? Microsoft.Extensions.AI.UsageDetails.TotalTokenCount { get; set; }", "Stage": "Stable" + }, + { + "Member": "long? Microsoft.Extensions.AI.UsageDetails.CachedInputTokenCount { get; set; }", + "Stage": "Stable" + }, + { + "Member": "long? Microsoft.Extensions.AI.UsageDetails.ReasoningTokenCount { get; set; }", + "Stage": "Stable" } ] } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/UsageDetails.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/UsageDetails.cs index b3c62cb67e0..b3edbad5e99 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/UsageDetails.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/UsageDetails.cs @@ -21,6 +21,23 @@ public class UsageDetails /// Gets or sets the total number of tokens used to produce the response. public long? TotalTokenCount { get; set; } + /// + /// Gets or sets the number of input tokens that were read from a cache. + /// + /// + /// Cached input tokens should be counted as part of . + /// + public long? CachedInputTokenCount { get; set; } + + /// + /// Gets or sets the number of "reasoning" / "thinking" tokens used internally + /// by the model. + /// + /// + /// Reasoning tokens should be counted as part of . + /// + public long? ReasoningTokenCount { get; set; } + /// Gets or sets a dictionary of additional usage counts. /// /// All values set here are assumed to be summable. For example, when middleware makes multiple calls to an underlying @@ -38,6 +55,8 @@ public void Add(UsageDetails usage) InputTokenCount = NullableSum(InputTokenCount, usage.InputTokenCount); OutputTokenCount = NullableSum(OutputTokenCount, usage.OutputTokenCount); TotalTokenCount = NullableSum(TotalTokenCount, usage.TotalTokenCount); + CachedInputTokenCount = NullableSum(CachedInputTokenCount, usage.CachedInputTokenCount); + ReasoningTokenCount = NullableSum(ReasoningTokenCount, usage.ReasoningTokenCount); if (usage.AdditionalCounts is { } countsToAdd) { @@ -80,6 +99,16 @@ internal string DebuggerDisplay parts.Add($"{nameof(TotalTokenCount)} = {total}"); } + if (CachedInputTokenCount is { } cached) + { + parts.Add($"{nameof(CachedInputTokenCount)} = {cached}"); + } + + if (ReasoningTokenCount is { } reasoning) + { + parts.Add($"{nameof(ReasoningTokenCount)} = {reasoning}"); + } + if (AdditionalCounts is { } additionalCounts) { foreach (var entry in additionalCounts) diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index 70ef6674bd4..a7ca8c08d95 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -644,6 +644,8 @@ private static UsageDetails FromOpenAIUsage(ChatTokenUsage tokenUsage) InputTokenCount = tokenUsage.InputTokenCount, OutputTokenCount = tokenUsage.OutputTokenCount, TotalTokenCount = tokenUsage.TotalTokenCount, + CachedInputTokenCount = tokenUsage.InputTokenDetails?.CachedTokenCount, + ReasoningTokenCount = tokenUsage.OutputTokenDetails?.ReasoningTokenCount, AdditionalCounts = [], }; @@ -653,13 +655,11 @@ private static UsageDetails FromOpenAIUsage(ChatTokenUsage tokenUsage) { const string InputDetails = nameof(ChatTokenUsage.InputTokenDetails); counts.Add($"{InputDetails}.{nameof(ChatInputTokenUsageDetails.AudioTokenCount)}", inputDetails.AudioTokenCount); - counts.Add($"{InputDetails}.{nameof(ChatInputTokenUsageDetails.CachedTokenCount)}", inputDetails.CachedTokenCount); } if (tokenUsage.OutputTokenDetails is ChatOutputTokenUsageDetails outputDetails) { const string OutputDetails = nameof(ChatTokenUsage.OutputTokenDetails); - counts.Add($"{OutputDetails}.{nameof(ChatOutputTokenUsageDetails.ReasoningTokenCount)}", outputDetails.ReasoningTokenCount); counts.Add($"{OutputDetails}.{nameof(ChatOutputTokenUsageDetails.AudioTokenCount)}", outputDetails.AudioTokenCount); counts.Add($"{OutputDetails}.{nameof(ChatOutputTokenUsageDetails.AcceptedPredictionTokenCount)}", outputDetails.AcceptedPredictionTokenCount); counts.Add($"{OutputDetails}.{nameof(ChatOutputTokenUsageDetails.RejectedPredictionTokenCount)}", outputDetails.RejectedPredictionTokenCount); diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs index eb39754d5fd..1c172db283a 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -1143,19 +1143,9 @@ static FunctionCallOutputResponseItem SerializeAIContent(string callId, IEnumera InputTokenCount = usage.InputTokenCount, OutputTokenCount = usage.OutputTokenCount, TotalTokenCount = usage.TotalTokenCount, + CachedInputTokenCount = usage.InputTokenDetails?.CachedTokenCount, + ReasoningTokenCount = usage.OutputTokenDetails?.ReasoningTokenCount, }; - - if (usage.InputTokenDetails is { } inputDetails) - { - ud.AdditionalCounts ??= []; - ud.AdditionalCounts.Add($"{nameof(usage.InputTokenDetails)}.{nameof(inputDetails.CachedTokenCount)}", inputDetails.CachedTokenCount); - } - - if (usage.OutputTokenDetails is { } outputDetails) - { - ud.AdditionalCounts ??= []; - ud.AdditionalCounts.Add($"{nameof(usage.OutputTokenDetails)}.{nameof(outputDetails.ReasoningTokenCount)}", outputDetails.ReasoningTokenCount); - } } return ud; diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UsageContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UsageContentTests.cs index ed268176c5d..d3bf0889821 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UsageContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UsageContentTests.cs @@ -66,7 +66,9 @@ public void Serialization_Roundtrips() { InputTokenCount = 10, OutputTokenCount = 20, - TotalTokenCount = 30 + TotalTokenCount = 30, + CachedInputTokenCount = 5, + ReasoningTokenCount = 8 }); var json = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions); @@ -77,5 +79,7 @@ public void Serialization_Roundtrips() Assert.Equal(content.Details.InputTokenCount, deserializedContent.Details.InputTokenCount); Assert.Equal(content.Details.OutputTokenCount, deserializedContent.Details.OutputTokenCount); Assert.Equal(content.Details.TotalTokenCount, deserializedContent.Details.TotalTokenCount); + Assert.Equal(content.Details.CachedInputTokenCount, deserializedContent.Details.CachedInputTokenCount); + Assert.Equal(content.Details.ReasoningTokenCount, deserializedContent.Details.ReasoningTokenCount); } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/UsageDetailsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/UsageDetailsTests.cs new file mode 100644 index 00000000000..d7fcd2545f0 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/UsageDetailsTests.cs @@ -0,0 +1,190 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class UsageDetailsTests +{ + [Fact] + public void Constructor_PropsDefault() + { + UsageDetails details = new(); + Assert.Null(details.InputTokenCount); + Assert.Null(details.OutputTokenCount); + Assert.Null(details.TotalTokenCount); + Assert.Null(details.CachedInputTokenCount); + Assert.Null(details.ReasoningTokenCount); + Assert.Null(details.AdditionalCounts); + } + + [Fact] + public void Properties_Roundtrip() + { + UsageDetails details = new() + { + InputTokenCount = 10, + OutputTokenCount = 20, + TotalTokenCount = 30, + CachedInputTokenCount = 5, + ReasoningTokenCount = 8, + AdditionalCounts = new() { ["custom"] = 100 } + }; + + Assert.Equal(10, details.InputTokenCount); + Assert.Equal(20, details.OutputTokenCount); + Assert.Equal(30, details.TotalTokenCount); + Assert.Equal(5, details.CachedInputTokenCount); + Assert.Equal(8, details.ReasoningTokenCount); + Assert.NotNull(details.AdditionalCounts); + Assert.Equal(100, details.AdditionalCounts["custom"]); + } + + [Fact] + public void Add_NullUsage_Throws() + { + UsageDetails details = new(); + Assert.Throws("usage", () => details.Add(null!)); + } + + [Fact] + public void Add_SumsAllProperties() + { + UsageDetails details1 = new() + { + InputTokenCount = 10, + OutputTokenCount = 20, + TotalTokenCount = 30, + CachedInputTokenCount = 5, + ReasoningTokenCount = 8, + }; + + UsageDetails details2 = new() + { + InputTokenCount = 15, + OutputTokenCount = 25, + TotalTokenCount = 40, + CachedInputTokenCount = 7, + ReasoningTokenCount = 12, + }; + + details1.Add(details2); + + Assert.Equal(25, details1.InputTokenCount); + Assert.Equal(45, details1.OutputTokenCount); + Assert.Equal(70, details1.TotalTokenCount); + Assert.Equal(12, details1.CachedInputTokenCount); + Assert.Equal(20, details1.ReasoningTokenCount); + } + + [Fact] + public void Add_WithNullValues_HandlesCorrectly() + { + UsageDetails details1 = new() + { + InputTokenCount = 10, + CachedInputTokenCount = 5, + }; + + UsageDetails details2 = new() + { + OutputTokenCount = 25, + ReasoningTokenCount = 12, + }; + + details1.Add(details2); + + Assert.Equal(10, details1.InputTokenCount); + Assert.Equal(25, details1.OutputTokenCount); + Assert.Null(details1.TotalTokenCount); + Assert.Equal(5, details1.CachedInputTokenCount); + Assert.Equal(12, details1.ReasoningTokenCount); + } + + [Fact] + public void Add_FromNullToValue_SetsValue() + { + UsageDetails details1 = new(); + + UsageDetails details2 = new() + { + CachedInputTokenCount = 5, + ReasoningTokenCount = 10, + }; + + details1.Add(details2); + + Assert.Equal(5, details1.CachedInputTokenCount); + Assert.Equal(10, details1.ReasoningTokenCount); + } + + [Fact] + public void Add_AdditionalCounts_MergesCorrectly() + { + UsageDetails details1 = new() + { + AdditionalCounts = new() { ["key1"] = 10, ["key2"] = 20 } + }; + + UsageDetails details2 = new() + { + AdditionalCounts = new() { ["key2"] = 30, ["key3"] = 40 } + }; + + details1.Add(details2); + + Assert.NotNull(details1.AdditionalCounts); + Assert.Equal(10, details1.AdditionalCounts["key1"]); + Assert.Equal(50, details1.AdditionalCounts["key2"]); + Assert.Equal(40, details1.AdditionalCounts["key3"]); + } + + [Fact] + public void Serialization_Roundtrips() + { + UsageDetails details = new() + { + InputTokenCount = 10, + OutputTokenCount = 20, + TotalTokenCount = 30, + CachedInputTokenCount = 5, + ReasoningTokenCount = 8, + AdditionalCounts = new() { ["custom"] = 100 } + }; + + string json = JsonSerializer.Serialize(details, AIJsonUtilities.DefaultOptions); + UsageDetails? deserialized = JsonSerializer.Deserialize(json, AIJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.Equal(details.InputTokenCount, deserialized.InputTokenCount); + Assert.Equal(details.OutputTokenCount, deserialized.OutputTokenCount); + Assert.Equal(details.TotalTokenCount, deserialized.TotalTokenCount); + Assert.Equal(details.CachedInputTokenCount, deserialized.CachedInputTokenCount); + Assert.Equal(details.ReasoningTokenCount, deserialized.ReasoningTokenCount); + Assert.NotNull(deserialized.AdditionalCounts); + Assert.Equal(100, deserialized.AdditionalCounts["custom"]); + } + + [Fact] + public void Serialization_WithNullProperties_Roundtrips() + { + UsageDetails details = new() + { + InputTokenCount = 10, + OutputTokenCount = 20, + }; + + string json = JsonSerializer.Serialize(details, AIJsonUtilities.DefaultOptions); + UsageDetails? deserialized = JsonSerializer.Deserialize(json, AIJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.Equal(10, deserialized.InputTokenCount); + Assert.Equal(20, deserialized.OutputTokenCount); + Assert.Null(deserialized.TotalTokenCount); + Assert.Null(deserialized.CachedInputTokenCount); + Assert.Null(deserialized.ReasoningTokenCount); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs index 5e4932c6736..458256523e4 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs @@ -171,11 +171,11 @@ public async Task BasicRequestResponse_NonStreaming() Assert.Equal(8, response.Usage.InputTokenCount); Assert.Equal(9, response.Usage.OutputTokenCount); Assert.Equal(17, response.Usage.TotalTokenCount); + Assert.Equal(13, response.Usage.CachedInputTokenCount); + Assert.Equal(90, response.Usage.ReasoningTokenCount); Assert.Equal(new Dictionary { { "InputTokenDetails.AudioTokenCount", 0 }, - { "InputTokenDetails.CachedTokenCount", 13 }, - { "OutputTokenDetails.ReasoningTokenCount", 90 }, { "OutputTokenDetails.AudioTokenCount", 0 }, { "OutputTokenDetails.AcceptedPredictionTokenCount", 0 }, { "OutputTokenDetails.RejectedPredictionTokenCount", 0 }, @@ -258,12 +258,12 @@ public async Task BasicRequestResponse_Streaming() Assert.Equal(8, usage.Details.InputTokenCount); Assert.Equal(9, usage.Details.OutputTokenCount); Assert.Equal(17, usage.Details.TotalTokenCount); + Assert.Equal(5, usage.Details.CachedInputTokenCount); + Assert.Equal(90, usage.Details.ReasoningTokenCount); Assert.Equal(new AdditionalPropertiesDictionary { { "InputTokenDetails.AudioTokenCount", 123 }, - { "InputTokenDetails.CachedTokenCount", 5 }, - { "OutputTokenDetails.ReasoningTokenCount", 90 }, { "OutputTokenDetails.AudioTokenCount", 456 }, { "OutputTokenDetails.AcceptedPredictionTokenCount", 0 }, { "OutputTokenDetails.RejectedPredictionTokenCount", 0 }, @@ -845,11 +845,11 @@ public async Task MultipleMessages_NonStreaming() Assert.Equal(42, response.Usage.InputTokenCount); Assert.Equal(15, response.Usage.OutputTokenCount); Assert.Equal(57, response.Usage.TotalTokenCount); + Assert.Equal(13, response.Usage.CachedInputTokenCount); + Assert.Equal(90, response.Usage.ReasoningTokenCount); Assert.Equal(new Dictionary { { "InputTokenDetails.AudioTokenCount", 123 }, - { "InputTokenDetails.CachedTokenCount", 13 }, - { "OutputTokenDetails.ReasoningTokenCount", 90 }, { "OutputTokenDetails.AudioTokenCount", 456 }, { "OutputTokenDetails.AcceptedPredictionTokenCount", 0 }, { "OutputTokenDetails.RejectedPredictionTokenCount", 0 }, @@ -942,11 +942,11 @@ public async Task MultiPartSystemMessage_NonStreaming() Assert.Equal(42, response.Usage.InputTokenCount); Assert.Equal(15, response.Usage.OutputTokenCount); Assert.Equal(57, response.Usage.TotalTokenCount); + Assert.Equal(13, response.Usage.CachedInputTokenCount); + Assert.Equal(90, response.Usage.ReasoningTokenCount); Assert.Equal(new Dictionary { { "InputTokenDetails.AudioTokenCount", 0 }, - { "InputTokenDetails.CachedTokenCount", 13 }, - { "OutputTokenDetails.ReasoningTokenCount", 90 }, { "OutputTokenDetails.AudioTokenCount", 0 }, { "OutputTokenDetails.AcceptedPredictionTokenCount", 0 }, { "OutputTokenDetails.RejectedPredictionTokenCount", 0 }, @@ -1040,11 +1040,11 @@ public async Task EmptyAssistantMessage_NonStreaming() Assert.Equal(42, response.Usage.InputTokenCount); Assert.Equal(15, response.Usage.OutputTokenCount); Assert.Equal(57, response.Usage.TotalTokenCount); + Assert.Equal(13, response.Usage.CachedInputTokenCount); + Assert.Equal(90, response.Usage.ReasoningTokenCount); Assert.Equal(new Dictionary { { "InputTokenDetails.AudioTokenCount", 0 }, - { "InputTokenDetails.CachedTokenCount", 13 }, - { "OutputTokenDetails.ReasoningTokenCount", 90 }, { "OutputTokenDetails.AudioTokenCount", 0 }, { "OutputTokenDetails.AcceptedPredictionTokenCount", 0 }, { "OutputTokenDetails.RejectedPredictionTokenCount", 0 }, @@ -1151,12 +1151,12 @@ public async Task FunctionCallContent_NonStreaming() Assert.Equal(61, response.Usage.InputTokenCount); Assert.Equal(16, response.Usage.OutputTokenCount); Assert.Equal(77, response.Usage.TotalTokenCount); + Assert.Equal(13, response.Usage.CachedInputTokenCount); + Assert.Equal(90, response.Usage.ReasoningTokenCount); Assert.Equal(new Dictionary { { "InputTokenDetails.AudioTokenCount", 0 }, - { "InputTokenDetails.CachedTokenCount", 13 }, - { "OutputTokenDetails.ReasoningTokenCount", 90 }, { "OutputTokenDetails.AudioTokenCount", 0 }, { "OutputTokenDetails.AcceptedPredictionTokenCount", 0 }, { "OutputTokenDetails.RejectedPredictionTokenCount", 0 }, @@ -1235,12 +1235,12 @@ public async Task UnavailableBuiltInFunctionCall_NonStreaming() Assert.Equal(61, response.Usage.InputTokenCount); Assert.Equal(16, response.Usage.OutputTokenCount); Assert.Equal(77, response.Usage.TotalTokenCount); + Assert.Equal(13, response.Usage.CachedInputTokenCount); + Assert.Equal(90, response.Usage.ReasoningTokenCount); Assert.Equal(new Dictionary { { "InputTokenDetails.AudioTokenCount", 0 }, - { "InputTokenDetails.CachedTokenCount", 13 }, - { "OutputTokenDetails.ReasoningTokenCount", 90 }, { "OutputTokenDetails.AudioTokenCount", 0 }, { "OutputTokenDetails.AcceptedPredictionTokenCount", 0 }, { "OutputTokenDetails.RejectedPredictionTokenCount", 0 }, @@ -1351,12 +1351,12 @@ public async Task FunctionCallContent_Streaming() Assert.Equal(61, usage.Details.InputTokenCount); Assert.Equal(16, usage.Details.OutputTokenCount); Assert.Equal(77, usage.Details.TotalTokenCount); + Assert.Equal(0, usage.Details.CachedInputTokenCount); + Assert.Equal(90, usage.Details.ReasoningTokenCount); Assert.Equal(new Dictionary { { "InputTokenDetails.AudioTokenCount", 0 }, - { "InputTokenDetails.CachedTokenCount", 0 }, - { "OutputTokenDetails.ReasoningTokenCount", 90 }, { "OutputTokenDetails.AudioTokenCount", 0 }, { "OutputTokenDetails.AcceptedPredictionTokenCount", 0 }, { "OutputTokenDetails.RejectedPredictionTokenCount", 0 }, @@ -1493,11 +1493,11 @@ public async Task AssistantMessageWithBothToolsAndContent_NonStreaming() Assert.Equal(42, response.Usage.InputTokenCount); Assert.Equal(15, response.Usage.OutputTokenCount); Assert.Equal(57, response.Usage.TotalTokenCount); + Assert.Equal(20, response.Usage.CachedInputTokenCount); + Assert.Equal(90, response.Usage.ReasoningTokenCount); Assert.Equal(new Dictionary { { "InputTokenDetails.AudioTokenCount", 0 }, - { "InputTokenDetails.CachedTokenCount", 20 }, - { "OutputTokenDetails.ReasoningTokenCount", 90 }, { "OutputTokenDetails.AudioTokenCount", 0 }, { "OutputTokenDetails.AcceptedPredictionTokenCount", 0 }, { "OutputTokenDetails.RejectedPredictionTokenCount", 0 }, @@ -1608,11 +1608,11 @@ private static async Task DataContentMessage_Image_AdditionalPropertyDetail_NonS Assert.Equal(8513, response.Usage.InputTokenCount); Assert.Equal(56, response.Usage.OutputTokenCount); Assert.Equal(8569, response.Usage.TotalTokenCount); + Assert.Equal(0, response.Usage.CachedInputTokenCount); + Assert.Equal(0, response.Usage.ReasoningTokenCount); Assert.Equal(new Dictionary { { "InputTokenDetails.AudioTokenCount", 0 }, - { "InputTokenDetails.CachedTokenCount", 0 }, - { "OutputTokenDetails.ReasoningTokenCount", 0 }, { "OutputTokenDetails.AudioTokenCount", 0 }, { "OutputTokenDetails.AcceptedPredictionTokenCount", 0 }, { "OutputTokenDetails.RejectedPredictionTokenCount", 0 }, diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs index 94d767f67d4..34526b683bd 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs @@ -4584,12 +4584,12 @@ public async Task ResponseWithUsageDetails_ParsesTokenCounts() var response = await client.GetResponseAsync("test"); Assert.NotNull(response.Usage); + Assert.Null(response.Usage.AdditionalCounts); Assert.Equal(50, response.Usage.InputTokenCount); Assert.Equal(25, response.Usage.OutputTokenCount); Assert.Equal(75, response.Usage.TotalTokenCount); - Assert.NotNull(response.Usage.AdditionalCounts); - Assert.Equal(10, response.Usage.AdditionalCounts["InputTokenDetails.CachedTokenCount"]); - Assert.Equal(5, response.Usage.AdditionalCounts["OutputTokenDetails.ReasoningTokenCount"]); + Assert.Equal(10, response.Usage.CachedInputTokenCount); + Assert.Equal(5, response.Usage.ReasoningTokenCount); } [Fact] From a8672875450dfbc648926a7e3bbf014bc36d0752 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 11 Dec 2025 23:40:45 -0500 Subject: [PATCH 8/9] Expose ctors for setting AdditionalProperties on Hosted tools (#7120) * Expose ctors for setting AdditionalProperties on Hosted tools * Address feedback --- .../CHANGELOG.md | 6 + .../Microsoft.Extensions.AI.Abstractions.json | 24 ++++ .../Tools/HostedCodeInterpreterTool.cs | 13 ++ .../Tools/HostedFileSearchTool.cs | 13 ++ .../Tools/HostedImageGenerationTool.cs | 17 +++ .../Tools/HostedMcpServerTool.cs | 35 +++++ .../Tools/HostedWebSearchTool.cs | 15 +++ .../CHANGELOG.md | 7 + .../OpenAIAssistantsChatClient.cs | 5 +- .../OpenAIClientExtensions.cs | 4 + .../OpenAIResponsesChatClient.cs | 73 +++++------ .../Microsoft.Extensions.AI/CHANGELOG.md | 4 + .../Tools/HostedCodeInterpreterToolTests.cs | 19 +++ .../Tools/HostedFileSearchToolTests.cs | 19 +++ .../Tools/HostedImageGenerationToolTests.cs | 51 ++++++++ .../Tools/HostedMcpServerToolTests.cs | 30 +++++ .../Tools/HostedWebSearchToolTests.cs | 19 +++ .../OpenAIConversionTests.cs | 121 +++++++++++++++++- 18 files changed, 429 insertions(+), 46 deletions(-) create mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedImageGenerationToolTests.cs diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md index 8e1b571967e..a586329aa33 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md @@ -1,5 +1,11 @@ # Microsoft.Extensions.AI.Abstractions Release History +## 10.1.1 (NOT YET RELEASED) + +- Added `InputCachedTokenCount` and `ReasoningTokenCount` to `UsageDetails`. +- Added constructors to `HostedCodeInterpreterTool`, `HostedFileSearchTool`, `HostedImageGeneratorTool`, `HostedMcpServerTool`, + and `HostedWebSearchTool` that accept a dictionary for `AdditionalProperties`. + ## 10.1.0 - Fixed package references for net10.0 asset. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json index b0b47c4ef6a..e401502d82b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json @@ -1918,9 +1918,17 @@ { "Member": "Microsoft.Extensions.AI.HostedCodeInterpreterTool.HostedCodeInterpreterTool();", "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.HostedCodeInterpreterTool.HostedCodeInterpreterTool(System.Collections.Generic.IReadOnlyDictionary? additionalProperties);", + "Stage": "Stable" } ], "Properties": [ + { + "Member": "override System.Collections.Generic.IReadOnlyDictionary Microsoft.Extensions.AI.HostedCodeInterpreterTool.AdditionalProperties { get; }", + "Stage": "Stable" + }, { "Member": "System.Collections.Generic.IList? Microsoft.Extensions.AI.HostedCodeInterpreterTool.Inputs { get; set; }", "Stage": "Stable" @@ -1938,9 +1946,17 @@ { "Member": "Microsoft.Extensions.AI.HostedFileSearchTool.HostedFileSearchTool();", "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.HostedFileSearchTool.HostedFileSearchTool(System.Collections.Generic.IReadOnlyDictionary? additionalProperties);", + "Stage": "Stable" } ], "Properties": [ + { + "Member": "override System.Collections.Generic.IReadOnlyDictionary Microsoft.Extensions.AI.HostedFileSearchTool.AdditionalProperties { get; }", + "Stage": "Stable" + }, { "Member": "System.Collections.Generic.IList? Microsoft.Extensions.AI.HostedFileSearchTool.Inputs { get; set; }", "Stage": "Stable" @@ -1962,9 +1978,17 @@ { "Member": "Microsoft.Extensions.AI.HostedWebSearchTool.HostedWebSearchTool();", "Stage": "Stable" + }, + { + "Member": "Microsoft.Extensions.AI.HostedWebSearchTool.HostedWebSearchTool(System.Collections.Generic.IReadOnlyDictionary? additionalProperties);", + "Stage": "Stable" } ], "Properties": [ + { + "Member": "override System.Collections.Generic.IReadOnlyDictionary Microsoft.Extensions.AI.HostedWebSearchTool.AdditionalProperties { get; }", + "Stage": "Stable" + }, { "Member": "override string Microsoft.Extensions.AI.HostedWebSearchTool.Name { get; }", "Stage": "Stable" diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedCodeInterpreterTool.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedCodeInterpreterTool.cs index 4bd63a0df75..f0ab845a110 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedCodeInterpreterTool.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedCodeInterpreterTool.cs @@ -12,14 +12,27 @@ namespace Microsoft.Extensions.AI; /// public class HostedCodeInterpreterTool : AITool { + /// Any additional properties associated with the tool. + private IReadOnlyDictionary? _additionalProperties; + /// Initializes a new instance of the class. public HostedCodeInterpreterTool() { } + /// Initializes a new instance of the class. + /// Any additional properties associated with the tool. + public HostedCodeInterpreterTool(IReadOnlyDictionary? additionalProperties) + { + _additionalProperties = additionalProperties; + } + /// public override string Name => "code_interpreter"; + /// + public override IReadOnlyDictionary AdditionalProperties => _additionalProperties ?? base.AdditionalProperties; + /// Gets or sets a collection of to be used as input to the code interpreter tool. /// /// Services support different varied kinds of inputs. Most support the IDs of files that are hosted by the service, diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedFileSearchTool.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedFileSearchTool.cs index b130e26b647..3456c301f17 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedFileSearchTool.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedFileSearchTool.cs @@ -12,14 +12,27 @@ namespace Microsoft.Extensions.AI; /// public class HostedFileSearchTool : AITool { + /// Any additional properties associated with the tool. + private IReadOnlyDictionary? _additionalProperties; + /// Initializes a new instance of the class. public HostedFileSearchTool() { } + /// Initializes a new instance of the class. + /// Any additional properties associated with the tool. + public HostedFileSearchTool(IReadOnlyDictionary? additionalProperties) + { + _additionalProperties = additionalProperties; + } + /// public override string Name => "file_search"; + /// + public override IReadOnlyDictionary AdditionalProperties => _additionalProperties ?? base.AdditionalProperties; + /// Gets or sets a collection of to be used as input to the file search tool. /// /// If no explicit inputs are provided, the service determines what inputs should be searched. Different services diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedImageGenerationTool.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedImageGenerationTool.cs index aca072653ab..4b75d2d5f08 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedImageGenerationTool.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedImageGenerationTool.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; namespace Microsoft.Extensions.AI; @@ -13,6 +14,9 @@ namespace Microsoft.Extensions.AI; [Experimental("MEAI001")] public class HostedImageGenerationTool : AITool { + /// Any additional properties associated with the tool. + private IReadOnlyDictionary? _additionalProperties; + /// /// Initializes a new instance of the class with the specified options. /// @@ -20,6 +24,19 @@ public HostedImageGenerationTool() { } + /// Initializes a new instance of the class. + /// Any additional properties associated with the tool. + public HostedImageGenerationTool(IReadOnlyDictionary? additionalProperties) + { + _additionalProperties = additionalProperties; + } + + /// + public override string Name => "image_generation"; + + /// + public override IReadOnlyDictionary AdditionalProperties => _additionalProperties ?? base.AdditionalProperties; + /// /// Gets or sets the options used to configure image generation. /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs index aa33a581710..fbc80fe4d59 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs @@ -14,6 +14,9 @@ namespace Microsoft.Extensions.AI; [Experimental("MEAI001")] public class HostedMcpServerTool : AITool { + /// Any additional properties associated with the tool. + private IReadOnlyDictionary? _additionalProperties; + /// /// Initializes a new instance of the class. /// @@ -27,6 +30,20 @@ public HostedMcpServerTool(string serverName, string serverAddress) ServerAddress = Throw.IfNullOrWhitespace(serverAddress); } + /// + /// Initializes a new instance of the class. + /// + /// The name of the remote MCP server. + /// The address of the remote MCP server. This may be a URL, or in the case of a service providing built-in MCP servers with known names, it can be such a name. + /// Any additional properties associated with the tool. + /// or is . + /// or is empty or composed entirely of whitespace. + public HostedMcpServerTool(string serverName, string serverAddress, IReadOnlyDictionary? additionalProperties) + : this(serverName, serverAddress) + { + _additionalProperties = additionalProperties; + } + /// /// Initializes a new instance of the class. /// @@ -40,6 +57,21 @@ public HostedMcpServerTool(string serverName, Uri serverUrl) { } + /// + /// Initializes a new instance of the class. + /// + /// The name of the remote MCP server. + /// The URL of the remote MCP server. + /// Any additional properties associated with the tool. + /// or is . + /// is empty or composed entirely of whitespace. + /// is not an absolute URL. + public HostedMcpServerTool(string serverName, Uri serverUrl, IReadOnlyDictionary? additionalProperties) + : this(serverName, ValidateUrl(serverUrl)) + { + _additionalProperties = additionalProperties; + } + private static string ValidateUrl(Uri serverUrl) { _ = Throw.IfNull(serverUrl); @@ -55,6 +87,9 @@ private static string ValidateUrl(Uri serverUrl) /// public override string Name => "mcp"; + /// + public override IReadOnlyDictionary AdditionalProperties => _additionalProperties ?? base.AdditionalProperties; + /// /// Gets the name of the remote MCP server that is used to identify it. /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedWebSearchTool.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedWebSearchTool.cs index 19d25510d19..c107473eb49 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedWebSearchTool.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedWebSearchTool.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Generic; + namespace Microsoft.Extensions.AI; /// Represents a hosted tool that can be specified to an AI service to enable it to perform web searches. @@ -10,11 +12,24 @@ namespace Microsoft.Extensions.AI; /// public class HostedWebSearchTool : AITool { + /// Any additional properties associated with the tool. + private IReadOnlyDictionary? _additionalProperties; + /// Initializes a new instance of the class. public HostedWebSearchTool() { } + /// Initializes a new instance of the class. + /// Any additional properties associated with the tool. + public HostedWebSearchTool(IReadOnlyDictionary? additionalProperties) + { + _additionalProperties = additionalProperties; + } + /// public override string Name => "web_search"; + + /// + public override IReadOnlyDictionary AdditionalProperties => _additionalProperties ?? base.AdditionalProperties; } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md index 607fe6228d6..6e087263cca 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md @@ -1,5 +1,12 @@ # Microsoft.Extensions.AI.OpenAI Release History +## 10.1.1-preview.1.? (NOT YET RELEASED) + +- Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. +- Updated the OpenAI Responses and Chat Completion `IChatClient`s to populate `UsageDetails`'s `InputCachedTokenCount` and `ReasoningTokenCount`. +- Updated handling of `HostedWebSearchTool`, `HostedFileSearchTool`, and `HostedImageGenerationTool` to pull OpenAI-specific + options from `AdditionalProperties`. + ## 10.1.0-preview.1.25608.1 - Fixed package references for net10.0 asset. diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantsChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantsChatClient.cs index 065ad80d23a..96f3d9113c2 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantsChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantsChatClient.cs @@ -409,7 +409,10 @@ internal static FunctionToolDefinition ToOpenAIAssistantsFunctionToolDefinition( break; case HostedFileSearchTool fileSearchTool: - _ = toolsOverride.Add(ToolDefinition.CreateFileSearch(fileSearchTool.MaximumResultCount)); + var fst = ToolDefinition.CreateFileSearch(fileSearchTool.MaximumResultCount); + fst.RankingOptions = fileSearchTool.GetProperty(nameof(FileSearchToolDefinition.RankingOptions)); + _ = toolsOverride.Add(fst); + if (fileSearchTool.Inputs is { Count: > 0 } fileSearchInputs) { foreach (var input in fileSearchInputs) diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs index 285b2c1e7ae..f696e394b44 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs @@ -242,6 +242,10 @@ internal static void PatchModelIfNotSet(ref JsonPatch patch, string? modelId) } } + /// Gets the typed property of the specified name from the tool's . + internal static T? GetProperty(this AITool tool, string name) => + tool.AdditionalProperties?.TryGetValue(name, out object? value) is true && value is T tValue ? tValue : default; + /// Used to create the JSON payload for an OpenAI tool description. internal sealed class ToolJson { diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs index 1c172db283a..98c30b73526 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -544,25 +544,17 @@ void IDisposable.Dispose() return ToResponseTool(aiFunction, options); case HostedWebSearchTool webSearchTool: - WebSearchToolLocation? location = null; - if (webSearchTool.AdditionalProperties.TryGetValue(nameof(WebSearchToolLocation), out object? objLocation)) - { - location = objLocation as WebSearchToolLocation; - } - - WebSearchToolContextSize? size = null; - if (webSearchTool.AdditionalProperties.TryGetValue(nameof(WebSearchToolContextSize), out object? objSize) && - objSize is WebSearchToolContextSize) - { - size = (WebSearchToolContextSize)objSize; - } - - return ResponseTool.CreateWebSearchTool(location, size); + return ResponseTool.CreateWebSearchTool( + webSearchTool.GetProperty(nameof(WebSearchTool.UserLocation)), + webSearchTool.GetProperty(nameof(WebSearchTool.SearchContextSize)), + webSearchTool.GetProperty(nameof(WebSearchTool.Filters))); case HostedFileSearchTool fileSearchTool: return ResponseTool.CreateFileSearchTool( fileSearchTool.Inputs?.OfType().Select(c => c.VectorStoreId) ?? [], - fileSearchTool.MaximumResultCount); + fileSearchTool.MaximumResultCount, + fileSearchTool.GetProperty(nameof(FileSearchTool.RankingOptions)), + fileSearchTool.GetProperty(nameof(FileSearchTool.Filters))); case HostedImageGenerationTool imageGenerationTool: return ToImageResponseTool(imageGenerationTool); @@ -642,36 +634,31 @@ internal static FunctionTool ToResponseTool(AIFunctionDeclaration aiFunction, Ch internal static ImageGenerationTool ToImageResponseTool(HostedImageGenerationTool imageGenerationTool) { - ImageGenerationTool result = new(); - ImageGenerationOptions? imageGenerationOptions = imageGenerationTool.Options; - - // Model: Image generation model - result.Model = imageGenerationOptions?.ModelId; - - // Size: Image dimensions (e.g., 1024x1024, 1024x1536) - if (imageGenerationOptions?.ImageSize is not null) - { - result.Size = new ImageGenerationToolSize( - imageGenerationOptions.ImageSize.Value.Width, - imageGenerationOptions.ImageSize.Value.Height); - } + ImageGenerationOptions? options = imageGenerationTool.Options; - // OutputFileFormat: File output format - if (imageGenerationOptions?.MediaType is not null) + return new() { - result.OutputFileFormat = imageGenerationOptions.MediaType switch - { - "image/png" => ImageGenerationToolOutputFileFormat.Png, - "image/jpeg" => ImageGenerationToolOutputFileFormat.Jpeg, - "image/webp" => ImageGenerationToolOutputFileFormat.Webp, - _ => null, - }; - } - - // PartialImageCount: Whether to return partial images during generation - result.PartialImageCount ??= imageGenerationOptions?.StreamingCount; - - return result; + Background = imageGenerationTool.GetProperty(nameof(ImageGenerationTool.Background)), + InputFidelity = imageGenerationTool.GetProperty(nameof(ImageGenerationTool.InputFidelity)), + InputImageMask = imageGenerationTool.GetProperty(nameof(ImageGenerationTool.InputImageMask)), + Model = options?.ModelId, + ModerationLevel = imageGenerationTool.GetProperty(nameof(ImageGenerationTool.ModerationLevel)), + OutputCompressionFactor = imageGenerationTool.GetProperty(nameof(ImageGenerationTool.OutputCompressionFactor)), + OutputFileFormat = options?.MediaType is not null ? + options.MediaType switch + { + "image/png" => ImageGenerationToolOutputFileFormat.Png, + "image/jpeg" => ImageGenerationToolOutputFileFormat.Jpeg, + "image/webp" => ImageGenerationToolOutputFileFormat.Webp, + _ => null, + } : + null, + PartialImageCount = options?.StreamingCount, + Quality = imageGenerationTool.GetProperty(nameof(ImageGenerationTool.Quality)), + Size = options?.ImageSize is not null ? + new ImageGenerationToolSize(options.ImageSize.Value.Width, options.ImageSize.Value.Height) : + null + }; } /// Creates a from a . diff --git a/src/Libraries/Microsoft.Extensions.AI/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI/CHANGELOG.md index 4f06605b47f..816e8b92267 100644 --- a/src/Libraries/Microsoft.Extensions.AI/CHANGELOG.md +++ b/src/Libraries/Microsoft.Extensions.AI/CHANGELOG.md @@ -1,5 +1,9 @@ # Microsoft.Extensions.AI Release History +## 10.1.1 (NOT YET RELEASED) + +- Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. + ## 10.1.0 - Fixed package references for net10.0 asset. diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedCodeInterpreterToolTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedCodeInterpreterToolTests.cs index 19044a6a295..34f6dd32f1e 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedCodeInterpreterToolTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedCodeInterpreterToolTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Generic; using Xunit; namespace Microsoft.Extensions.AI; @@ -18,6 +19,24 @@ public void Constructor_Roundtrips() Assert.Equal(tool.Name, tool.ToString()); } + [Fact] + public void Constructor_AdditionalProperties_Roundtrips() + { + var props = new Dictionary { ["key"] = "value" }; + var tool = new HostedCodeInterpreterTool(props); + + Assert.Equal("code_interpreter", tool.Name); + Assert.Same(props, tool.AdditionalProperties); + } + + [Fact] + public void Constructor_NullAdditionalProperties_UsesEmpty() + { + var tool = new HostedCodeInterpreterTool(null); + + Assert.Empty(tool.AdditionalProperties); + } + [Fact] public void Properties_Roundtrip() { diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedFileSearchToolTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedFileSearchToolTests.cs index e2d71a65013..cffa5b418b1 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedFileSearchToolTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedFileSearchToolTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Generic; using Xunit; namespace Microsoft.Extensions.AI; @@ -19,6 +20,24 @@ public void Constructor_Roundtrips() Assert.Equal(tool.Name, tool.ToString()); } + [Fact] + public void Constructor_AdditionalProperties_Roundtrips() + { + var props = new Dictionary { ["key"] = "value" }; + var tool = new HostedFileSearchTool(props); + + Assert.Equal("file_search", tool.Name); + Assert.Same(props, tool.AdditionalProperties); + } + + [Fact] + public void Constructor_NullAdditionalProperties_UsesEmpty() + { + var tool = new HostedFileSearchTool(null); + + Assert.Empty(tool.AdditionalProperties); + } + [Fact] public void Properties_Roundtrip() { diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedImageGenerationToolTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedImageGenerationToolTests.cs new file mode 100644 index 00000000000..1f14ca7175d --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedImageGenerationToolTests.cs @@ -0,0 +1,51 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class HostedImageGenerationToolTests +{ + [Fact] + public void Constructor_Roundtrips() + { + var tool = new HostedImageGenerationTool(); + Assert.Equal("image_generation", tool.Name); + Assert.Empty(tool.Description); + Assert.Empty(tool.AdditionalProperties); + Assert.Null(tool.Options); + Assert.Equal(tool.Name, tool.ToString()); + } + + [Fact] + public void Constructor_AdditionalProperties_Roundtrips() + { + var props = new Dictionary { ["key"] = "value" }; + var tool = new HostedImageGenerationTool(props); + + Assert.Equal("image_generation", tool.Name); + Assert.Same(props, tool.AdditionalProperties); + } + + [Fact] + public void Constructor_NullAdditionalProperties_UsesEmpty() + { + var tool = new HostedImageGenerationTool(null); + + Assert.Empty(tool.AdditionalProperties); + } + + [Fact] + public void Options_Roundtrip() + { + var options = new ImageGenerationOptions(); + var tool = new HostedImageGenerationTool + { + Options = options + }; + + Assert.Same(options, tool.Options); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedMcpServerToolTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedMcpServerToolTests.cs index ec1dc407973..aa23cfd3ff4 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedMcpServerToolTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedMcpServerToolTests.cs @@ -26,6 +26,36 @@ public void Constructor_PropsDefault() Assert.Null(tool.ApprovalMode); } + [Fact] + public void Constructor_AdditionalProperties_String_Roundtrips() + { + var props = new Dictionary { ["key"] = "value" }; + HostedMcpServerTool tool = new("serverName", "connector_id", props); + + Assert.Equal("serverName", tool.ServerName); + Assert.Equal("connector_id", tool.ServerAddress); + Assert.Same(props, tool.AdditionalProperties); + } + + [Fact] + public void Constructor_AdditionalProperties_Uri_Roundtrips() + { + var props = new Dictionary { ["key"] = "value" }; + HostedMcpServerTool tool = new("serverName", new Uri("https://localhost/"), props); + + Assert.Equal("serverName", tool.ServerName); + Assert.Equal("https://localhost/", tool.ServerAddress); + Assert.Same(props, tool.AdditionalProperties); + } + + [Fact] + public void Constructor_NullAdditionalProperties_UsesEmpty() + { + HostedMcpServerTool tool = new("serverName", "connector_id", null); + + Assert.Empty(tool.AdditionalProperties); + } + [Fact] public void Constructor_Roundtrips() { diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedWebSearchToolTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedWebSearchToolTests.cs index 4bb6ca4b847..7040289a210 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedWebSearchToolTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedWebSearchToolTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Generic; using Xunit; namespace Microsoft.Extensions.AI; @@ -16,4 +17,22 @@ public void Constructor_Roundtrips() Assert.Empty(tool.AdditionalProperties); Assert.Equal(tool.Name, tool.ToString()); } + + [Fact] + public void Constructor_AdditionalProperties_Roundtrips() + { + var props = new Dictionary { ["key"] = "value" }; + var tool = new HostedWebSearchTool(props); + + Assert.Equal("web_search", tool.Name); + Assert.Same(props, tool.AdditionalProperties); + } + + [Fact] + public void Constructor_NullAdditionalProperties_UsesEmpty() + { + var tool = new HostedWebSearchTool(null); + + Assert.Empty(tool.AdditionalProperties); + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs index 9c4ffeefdcd..0ab4ea2c6b1 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs @@ -141,8 +141,8 @@ public void AsOpenAIResponseTool_WithHostedWebSearchToolWithAdditionalProperties var location = WebSearchToolLocation.CreateApproximateLocation("US", "Region", "City", "UTC"); var webSearchTool = new HostedWebSearchToolWithProperties(new Dictionary { - [nameof(WebSearchToolLocation)] = location, - [nameof(WebSearchToolContextSize)] = WebSearchToolContextSize.High + [nameof(WebSearchTool.UserLocation)] = location, + [nameof(WebSearchTool.SearchContextSize)] = WebSearchToolContextSize.High }); var result = webSearchTool.AsOpenAIResponseTool(); @@ -205,6 +205,30 @@ public void AsOpenAIResponseTool_WithHostedFileSearchToolWithMaxResults_Produces Assert.Equal(10, tool.MaxResultCount); } + [Fact] + public void AsOpenAIResponseTool_WithHostedFileSearchToolWithAdditionalProperties_ProducesValidFileSearchTool() + { + var rankingOptions = new FileSearchToolRankingOptions { ScoreThreshold = 0.5f }; + var filters = BinaryData.FromString("{\"type\":\"eq\",\"key\":\"status\",\"value\":\"published\"}"); + var fileSearchTool = new HostedFileSearchTool(new Dictionary + { + [nameof(FileSearchTool.RankingOptions)] = rankingOptions, + [nameof(FileSearchTool.Filters)] = filters + }) + { + MaximumResultCount = 15 + }; + + var result = fileSearchTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.NotNull(tool.RankingOptions); + Assert.Equal(0.5f, tool.RankingOptions.ScoreThreshold); + Assert.NotNull(tool.Filters); + Assert.Equal(15, tool.MaxResultCount); + } + [Fact] public void AsOpenAIResponseTool_WithHostedCodeInterpreterTool_ProducesValidCodeInterpreterTool() { @@ -236,6 +260,99 @@ public void AsOpenAIResponseTool_WithHostedCodeInterpreterToolWithFiles_Produces Assert.Equal(fileContent.FileId, autoContainerConfig.FileIds[0]); } + [Fact] + public void AsOpenAIResponseTool_WithHostedImageGenerationTool_ProducesValidImageGenerationTool() + { + var imageGenTool = new HostedImageGenerationTool + { + Options = new ImageGenerationOptions { MediaType = "image/png" } + }; + + var result = imageGenTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.NotNull(tool); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedImageGenerationToolWithOptions_ProducesValidImageGenerationTool() + { + var imageGenTool = new HostedImageGenerationTool + { + Options = new ImageGenerationOptions + { + ModelId = "gpt-image-1", + MediaType = "image/png", + ImageSize = new System.Drawing.Size(1024, 1024), + StreamingCount = 2 + } + }; + + var result = imageGenTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.Equal("gpt-image-1", tool.Model); + Assert.Equal(ImageGenerationToolOutputFileFormat.Png, tool.OutputFileFormat); + Assert.NotNull(tool.Size); + Assert.Equal(2, tool.PartialImageCount); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedImageGenerationToolWithAdditionalProperties_ProducesValidImageGenerationTool() + { + var imageGenTool = new HostedImageGenerationTool(new Dictionary + { + [nameof(ImageGenerationTool.Background)] = ImageGenerationToolBackground.Transparent, + [nameof(ImageGenerationTool.InputFidelity)] = ImageGenerationToolInputFidelity.High, + [nameof(ImageGenerationTool.ModerationLevel)] = ImageGenerationToolModerationLevel.Low, + [nameof(ImageGenerationTool.OutputCompressionFactor)] = 50, + [nameof(ImageGenerationTool.Quality)] = ImageGenerationToolQuality.High + }) + { + Options = new ImageGenerationOptions + { + ModelId = "gpt-image-1", + MediaType = "image/jpeg", + } + }; + + var result = imageGenTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.Equal("gpt-image-1", tool.Model); + Assert.Equal(ImageGenerationToolOutputFileFormat.Jpeg, tool.OutputFileFormat); + Assert.Equal(ImageGenerationToolBackground.Transparent, tool.Background); + Assert.Equal(ImageGenerationToolInputFidelity.High, tool.InputFidelity); + Assert.Equal(ImageGenerationToolModerationLevel.Low, tool.ModerationLevel); + Assert.Equal(50, tool.OutputCompressionFactor); + Assert.Equal(ImageGenerationToolQuality.High, tool.Quality); + } + + [Fact] + public void AsOpenAIResponseTool_WithHostedImageGenerationToolWithInputImageMask_ProducesValidImageGenerationTool() + { + var inputImageMask = new ImageGenerationToolInputImageMask( + BinaryData.FromBytes([0x89, 0x50, 0x4E, 0x47]), + "image/png"); + + var imageGenTool = new HostedImageGenerationTool(new Dictionary + { + [nameof(ImageGenerationTool.InputImageMask)] = inputImageMask + }) + { + Options = new ImageGenerationOptions { MediaType = "image/png" } + }; + + var result = imageGenTool.AsOpenAIResponseTool(); + + Assert.NotNull(result); + var tool = Assert.IsType(result); + Assert.NotNull(tool.InputImageMask); + } + [Fact] public void AsOpenAIResponseTool_WithHostedMcpServerTool_ProducesValidMcpTool() { From b85d8c55758c6ee348ed203effe2a40ddc7b4a4a Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Fri, 12 Dec 2025 01:08:00 -0500 Subject: [PATCH 9/9] Update to OpenAI 2.8.0 (#7136) * Update to OpenAI 2.8.0 * Fix merge and address feedback --- eng/packages/General.props | 2 +- .../CHANGELOG.md | 2 + ...icrosoftExtensionsAIResponsesExtensions.cs | 56 ++- .../OpenAIClientExtensions.cs | 16 +- .../OpenAIResponsesChatClient.cs | 416 +++++++++--------- ...cs => ResponsesClientContinuationToken.cs} | 16 +- .../ChatWithCustomData-CSharp.Web/Program.cs | 10 +- .../ChatClientIntegrationTests.cs | 4 +- .../OpenAIChatClientTests.cs | 2 +- .../OpenAIConversionTests.cs | 62 +-- .../OpenAIResponseClientIntegrationTests.cs | 2 +- .../OpenAIResponseClientTests.cs | 69 ++- .../aichatweb/Program.cs | 4 +- 13 files changed, 336 insertions(+), 325 deletions(-) rename src/Libraries/Microsoft.Extensions.AI.OpenAI/{OpenAIResponsesContinuationToken.cs => ResponsesClientContinuationToken.cs} (81%) diff --git a/eng/packages/General.props b/eng/packages/General.props index a8de843a353..7565d36bac0 100644 --- a/eng/packages/General.props +++ b/eng/packages/General.props @@ -20,7 +20,7 @@ - + diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md index 6e087263cca..43238de166c 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md @@ -2,6 +2,8 @@ ## 10.1.1-preview.1.? (NOT YET RELEASED) +- Updated to depend on OpenAI 2.8.0. +- Updated public API signatures in `OpenAIClientExtensions` and `MicrosoftExtensionsAIResponsesExtensions` to match the corresponding breaking changes in OpenAI's Responses APIs. - Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`. - Updated the OpenAI Responses and Chat Completion `IChatClient`s to populate `UsageDetails`'s `InputCachedTokenCount` and `ReasoningTokenCount`. - Updated handling of `HostedWebSearchTool`, `HostedFileSearchTool`, and `HostedImageGenerationTool` to pull OpenAI-specific diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs index 6d989c0b56d..9c2ed0348fb 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs @@ -56,12 +56,12 @@ public static IEnumerable AsOpenAIResponseItems(this IEnumerable AsChatMessages(this IEnumerable items) => OpenAIResponsesChatClient.ToChatMessages(Throw.IfNull(items)); - /// Creates a Microsoft.Extensions.AI from an . - /// The to convert to a . + /// Creates a Microsoft.Extensions.AI from an . + /// The to convert to a . /// The options employed in the creation of the response. /// A converted . /// is . - public static ChatResponse AsChatResponse(this OpenAIResponse response, ResponseCreationOptions? options = null) => + public static ChatResponse AsChatResponse(this ResponseResult response, CreateResponseOptions? options = null) => OpenAIResponsesChatClient.FromOpenAIResponse(Throw.IfNull(response), options, conversationId: null); /// @@ -74,35 +74,43 @@ public static ChatResponse AsChatResponse(this OpenAIResponse response, Response /// A sequence of converted instances. /// is . public static IAsyncEnumerable AsChatResponseUpdatesAsync( - this IAsyncEnumerable responseUpdates, ResponseCreationOptions? options = null, CancellationToken cancellationToken = default) => + this IAsyncEnumerable responseUpdates, CreateResponseOptions? options = null, CancellationToken cancellationToken = default) => OpenAIResponsesChatClient.FromOpenAIStreamingResponseUpdatesAsync(Throw.IfNull(responseUpdates), options, conversationId: null, cancellationToken: cancellationToken); - /// Creates an OpenAI from a . + /// Creates an OpenAI from a . /// The response to convert. /// The options employed in the creation of the response. - /// The created . - public static OpenAIResponse AsOpenAIResponse(this ChatResponse response, ChatOptions? options = null) + /// The created . + public static ResponseResult AsOpenAIResponseResult(this ChatResponse response, ChatOptions? options = null) { _ = Throw.IfNull(response); - if (response.RawRepresentation is OpenAIResponse openAIResponse) + if (response.RawRepresentation is ResponseResult openAIResponse) { return openAIResponse; } - return OpenAIResponsesModelFactory.OpenAIResponse( - response.ResponseId, - response.CreatedAt ?? default, - ResponseStatus.Completed, - usage: null, // No way to construct a ResponseTokenUsage right now from external to the OpenAI library - maxOutputTokenCount: options?.MaxOutputTokens, - outputItems: OpenAIResponsesChatClient.ToOpenAIResponseItems(response.Messages, options), - parallelToolCallsEnabled: options?.AllowMultipleToolCalls ?? false, - model: response.ModelId ?? options?.ModelId, - temperature: options?.Temperature, - topP: options?.TopP, - previousResponseId: options?.ConversationId, - instructions: options?.Instructions); + ResponseResult result = new() + { + ConversationOptions = OpenAIClientExtensions.IsConversationId(response.ConversationId) ? new(response.ConversationId) : null, + CreatedAt = response.CreatedAt ?? default, + Id = response.ResponseId, + Instructions = options?.Instructions, + MaxOutputTokenCount = options?.MaxOutputTokens, + Model = response.ModelId ?? options?.ModelId, + ParallelToolCallsEnabled = options?.AllowMultipleToolCalls ?? true, + Status = ResponseStatus.Completed, + Temperature = options?.Temperature, + TopP = options?.TopP, + Usage = OpenAIResponsesChatClient.ToResponseTokenUsage(response.Usage), + }; + + foreach (var responseItem in OpenAIResponsesChatClient.ToOpenAIResponseItems(response.Messages, options)) + { + result.OutputItems.Add(responseItem); + } + + return result; } /// Adds the to the list of s. @@ -111,7 +119,7 @@ public static OpenAIResponse AsOpenAIResponse(this ChatResponse response, ChatOp /// /// does not derive from , so it cannot be added directly to a list of s. /// Instead, this method wraps the provided in an and adds that to the list. - /// The returned by will + /// The returned by will /// be able to unwrap the when it processes the list of tools and use the provided as-is. /// public static void Add(this IList tools, ResponseTool tool) @@ -127,7 +135,7 @@ public static void Add(this IList tools, ResponseTool tool) /// /// /// The returned tool is only suitable for use with the returned by - /// (or s that delegate + /// (or s that delegate /// to such an instance). It is likely to be ignored by any other implementation. /// /// @@ -136,7 +144,7 @@ public static void Add(this IList tools, ResponseTool tool) /// , those types should be preferred instead of this method, as they are more portable, /// capable of being respected by any implementation. This method does not attempt to /// map the supplied to any of those types, it simply wraps it as-is: - /// the returned by will + /// the returned by will /// be able to unwrap the when it processes the list of tools. /// /// diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs index f696e394b44..36d8677e70a 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs @@ -25,7 +25,7 @@ namespace Microsoft.Extensions.AI; public static class OpenAIClientExtensions { /// Key into AdditionalProperties used to store a strict option. - private const string StrictKey = "strictJsonSchema"; + private const string StrictKey = "strict"; /// Gets the default OpenAI endpoint. internal static Uri DefaultOpenAIEndpoint { get; } = new("https://api.openai.com/v1"); @@ -111,11 +111,11 @@ static void AppendLine(ref StringBuilder? sb, string propName, JsonNode propNode public static IChatClient AsIChatClient(this ChatClient chatClient) => new OpenAIChatClient(chatClient); - /// Gets an for use with this . + /// Gets an for use with this . /// The client. - /// An that can be used to converse via the . + /// An that can be used to converse via the . /// is . - public static IChatClient AsIChatClient(this OpenAIResponseClient responseClient) => + public static IChatClient AsIChatClient(this ResponsesClient responseClient) => new OpenAIResponsesChatClient(responseClient); /// Gets an for use with this . @@ -246,6 +246,14 @@ internal static void PatchModelIfNotSet(ref JsonPatch patch, string? modelId) internal static T? GetProperty(this AITool tool, string name) => tool.AdditionalProperties?.TryGetValue(name, out object? value) is true && value is T tValue ? tValue : default; + /// Gets whether an ID is an OpenAI conversation ID. + /// + /// Technically, OpenAI's IDs are opaque. However, by convention conversation IDs start with "conv_" and + /// we can use that to disambiguate whether we're looking at a conversation ID or something else, like a response ID. + /// + internal static bool IsConversationId(string? id) => + id?.StartsWith("conv_", StringComparison.OrdinalIgnoreCase) is true; + /// Used to create the JSON payload for an OpenAI tool description. internal sealed class ToolJson { diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs index 98c30b73526..e6359cbdd7a 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -5,6 +5,7 @@ using System.ClientModel; using System.ClientModel.Primitives; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; @@ -23,53 +24,38 @@ namespace Microsoft.Extensions.AI; -/// Represents an for an . +/// Represents an for an . internal sealed class OpenAIResponsesChatClient : IChatClient { // These delegate instances are used to call the internal overloads of CreateResponseAsync and CreateResponseStreamingAsync that accept // a RequestOptions. These should be replaced once a better way to pass RequestOptions is available. - private static readonly Func, ResponseCreationOptions, RequestOptions, Task>>? - _createResponseAsync = - (Func, ResponseCreationOptions, RequestOptions, Task>>?) - typeof(OpenAIResponseClient).GetMethod( - nameof(OpenAIResponseClient.CreateResponseAsync), BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, - null, [typeof(IEnumerable), typeof(ResponseCreationOptions), typeof(RequestOptions)], null) - ?.CreateDelegate(typeof(Func, ResponseCreationOptions, RequestOptions, Task>>)); - - private static readonly Func, ResponseCreationOptions, RequestOptions, AsyncCollectionResult>? + + private static readonly Func>? _createResponseStreamingAsync = - (Func, ResponseCreationOptions, RequestOptions, AsyncCollectionResult>?) - typeof(OpenAIResponseClient).GetMethod( - nameof(OpenAIResponseClient.CreateResponseStreamingAsync), BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, - null, [typeof(IEnumerable), typeof(ResponseCreationOptions), typeof(RequestOptions)], null) - ?.CreateDelegate(typeof(Func, ResponseCreationOptions, RequestOptions, AsyncCollectionResult>)); - - private static readonly Func>>? - _getResponseAsync = - (Func>>?) - typeof(OpenAIResponseClient).GetMethod( - nameof(OpenAIResponseClient.GetResponseAsync), BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, - null, [typeof(string), typeof(RequestOptions)], null) - ?.CreateDelegate(typeof(Func>>)); - - private static readonly Func>? + (Func>?) + typeof(ResponsesClient).GetMethod( + nameof(ResponsesClient.CreateResponseStreamingAsync), BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, + null, [typeof(CreateResponseOptions), typeof(RequestOptions)], null) + ?.CreateDelegate(typeof(Func>)); + + private static readonly Func>? _getResponseStreamingAsync = - (Func>?) - typeof(OpenAIResponseClient).GetMethod( - nameof(OpenAIResponseClient.GetResponseStreamingAsync), BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, - null, [typeof(string), typeof(RequestOptions), typeof(int?)], null) - ?.CreateDelegate(typeof(Func>)); + (Func>?) + typeof(ResponsesClient).GetMethod( + nameof(ResponsesClient.GetResponseStreamingAsync), BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, + null, [typeof(GetResponseOptions), typeof(RequestOptions)], null) + ?.CreateDelegate(typeof(Func>)); /// Metadata about the client. private readonly ChatClientMetadata _metadata; - /// The underlying . - private readonly OpenAIResponseClient _responseClient; + /// The underlying . + private readonly ResponsesClient _responseClient; - /// Initializes a new instance of the class for the specified . + /// Initializes a new instance of the class for the specified . /// The underlying client. /// is . - public OpenAIResponsesChatClient(OpenAIResponseClient responseClient) + public OpenAIResponsesChatClient(ResponsesClient responseClient) { _ = Throw.IfNull(responseClient); @@ -86,7 +72,7 @@ public OpenAIResponsesChatClient(OpenAIResponseClient responseClient) return serviceKey is not null ? null : serviceType == typeof(ChatClientMetadata) ? _metadata : - serviceType == typeof(OpenAIResponseClient) ? _responseClient : + serviceType == typeof(ResponsesClient) ? _responseClient : serviceType.IsInstanceOfType(this) ? this : null; } @@ -97,76 +83,79 @@ public async Task GetResponseAsync( { _ = Throw.IfNull(messages); - // Convert the inputs into what OpenAIResponseClient expects. - var openAIOptions = ToOpenAIResponseCreationOptions(options, out string? openAIConversationId); + // Convert the inputs into what ResponsesClient expects. + var openAIOptions = AsCreateResponseOptions(options, out string? openAIConversationId); // Provided continuation token signals that an existing background response should be fetched. if (GetContinuationToken(messages, options) is { } token) { - var getTask = _getResponseAsync is not null ? - _getResponseAsync(_responseClient, token.ResponseId, cancellationToken.ToRequestOptions(streaming: false)) : - _responseClient.GetResponseAsync(token.ResponseId, cancellationToken); - var response = (await getTask.ConfigureAwait(false)).Value; - + var getTask = _responseClient.GetResponseAsync(token.ResponseId, include: null, stream: null, startingAfter: null, includeObfuscation: null, cancellationToken.ToRequestOptions(streaming: false)); + var response = (ResponseResult)await getTask.ConfigureAwait(false); return FromOpenAIResponse(response, openAIOptions, openAIConversationId); } - var openAIResponseItems = ToOpenAIResponseItems(messages, options); + foreach (var responseItem in ToOpenAIResponseItems(messages, options)) + { + openAIOptions.InputItems.Add(responseItem); + } - // Make the call to the OpenAIResponseClient. - var createTask = _createResponseAsync is not null ? - _createResponseAsync(_responseClient, openAIResponseItems, openAIOptions, cancellationToken.ToRequestOptions(streaming: false)) : - _responseClient.CreateResponseAsync(openAIResponseItems, openAIOptions, cancellationToken); - var openAIResponse = (await createTask.ConfigureAwait(false)).Value; + // Make the call to the ResponsesClient. + var createTask = _responseClient.CreateResponseAsync((BinaryContent)openAIOptions, cancellationToken.ToRequestOptions(streaming: false)); + var openAIResponsesResult = (ResponseResult)await createTask.ConfigureAwait(false); // Convert the response to a ChatResponse. - return FromOpenAIResponse(openAIResponse, openAIOptions, openAIConversationId); + return FromOpenAIResponse(openAIResponsesResult, openAIOptions, openAIConversationId); } - internal static ChatResponse FromOpenAIResponse(OpenAIResponse openAIResponse, ResponseCreationOptions? openAIOptions, string? conversationId) + internal static ChatResponse FromOpenAIResponse(ResponseResult responseResult, CreateResponseOptions? openAIOptions, string? conversationId) { // Convert and return the results. ChatResponse response = new() { - ConversationId = openAIOptions?.StoredOutputEnabled is false ? null : (conversationId ?? openAIResponse.Id), - CreatedAt = openAIResponse.CreatedAt, - ContinuationToken = CreateContinuationToken(openAIResponse), - FinishReason = ToFinishReason(openAIResponse.IncompleteStatusDetails?.Reason), - ModelId = openAIResponse.Model, - RawRepresentation = openAIResponse, - ResponseId = openAIResponse.Id, - Usage = ToUsageDetails(openAIResponse), + ConversationId = openAIOptions?.StoredOutputEnabled is false ? null : (conversationId ?? responseResult.Id), + CreatedAt = responseResult.CreatedAt, + ContinuationToken = CreateContinuationToken(responseResult), + FinishReason = AsFinishReason(responseResult.IncompleteStatusDetails?.Reason), + ModelId = responseResult.Model, + RawRepresentation = responseResult, + ResponseId = responseResult.Id, + Usage = ToUsageDetails(responseResult), }; - if (!string.IsNullOrEmpty(openAIResponse.EndUserId)) + if (!string.IsNullOrEmpty(responseResult.EndUserId)) { - (response.AdditionalProperties ??= [])[nameof(openAIResponse.EndUserId)] = openAIResponse.EndUserId; + (response.AdditionalProperties ??= [])[nameof(responseResult.EndUserId)] = responseResult.EndUserId; } - if (openAIResponse.Error is not null) + if (responseResult.Error is not null) { - (response.AdditionalProperties ??= [])[nameof(openAIResponse.Error)] = openAIResponse.Error; + (response.AdditionalProperties ??= [])[nameof(responseResult.Error)] = responseResult.Error; } - if (openAIResponse.OutputItems is not null) + if (responseResult.OutputItems is not null) { - response.Messages = [.. ToChatMessages(openAIResponse.OutputItems, openAIOptions)]; + response.Messages = [.. ToChatMessages(responseResult.OutputItems, openAIOptions)]; - if (response.Messages.LastOrDefault() is { } lastMessage && openAIResponse.Error is { } error) + if (response.Messages.LastOrDefault() is { } lastMessage && responseResult.Error is { } error) { lastMessage.Contents.Add(new ErrorContent(error.Message) { ErrorCode = error.Code.ToString() }); } foreach (var message in response.Messages) { - message.CreatedAt ??= openAIResponse.CreatedAt; + message.CreatedAt ??= responseResult.CreatedAt; } } + if (responseResult.SafetyIdentifier is not null) + { + (response.AdditionalProperties ??= [])[nameof(responseResult.SafetyIdentifier)] = responseResult.SafetyIdentifier; + } + return response; } - internal static IEnumerable ToChatMessages(IEnumerable items, ResponseCreationOptions? options = null) + internal static IEnumerable ToChatMessages(IEnumerable items, CreateResponseOptions? options = null) { ChatMessage? message = null; @@ -185,7 +174,7 @@ internal static IEnumerable ToChatMessages(IEnumerable)message.Contents).AddRange(ToAIContents(messageItem.Content)); break; @@ -252,30 +241,38 @@ public IAsyncEnumerable GetStreamingResponseAsync( { _ = Throw.IfNull(messages); - var openAIOptions = ToOpenAIResponseCreationOptions(options, out string? openAIConversationId); + var openAIOptions = AsCreateResponseOptions(options, out string? openAIConversationId); + openAIOptions.StreamingEnabled = true; // Provided continuation token signals that an existing background response should be fetched. if (GetContinuationToken(messages, options) is { } token) { + GetResponseOptions getOptions = new(token.ResponseId) { StartingAfter = token.SequenceNumber, StreamingEnabled = true }; + + Debug.Assert(_getResponseStreamingAsync is not null, $"Unable to find {nameof(_getResponseStreamingAsync)} method"); IAsyncEnumerable getUpdates = _getResponseStreamingAsync is not null ? - _getResponseStreamingAsync(_responseClient, token.ResponseId, cancellationToken.ToRequestOptions(streaming: true), token.SequenceNumber) : - _responseClient.GetResponseStreamingAsync(token.ResponseId, token.SequenceNumber, cancellationToken); + _getResponseStreamingAsync(_responseClient, getOptions, cancellationToken.ToRequestOptions(streaming: true)) : + _responseClient.GetResponseStreamingAsync(getOptions, cancellationToken); return FromOpenAIStreamingResponseUpdatesAsync(getUpdates, openAIOptions, openAIConversationId, token.ResponseId, cancellationToken); } - var openAIResponseItems = ToOpenAIResponseItems(messages, options); + foreach (var responseItem in ToOpenAIResponseItems(messages, options)) + { + openAIOptions.InputItems.Add(responseItem); + } - var createUpdates = _createResponseStreamingAsync is not null ? - _createResponseStreamingAsync(_responseClient, openAIResponseItems, openAIOptions, cancellationToken.ToRequestOptions(streaming: true)) : - _responseClient.CreateResponseStreamingAsync(openAIResponseItems, openAIOptions, cancellationToken); + Debug.Assert(_createResponseStreamingAsync is not null, $"Unable to find {nameof(_createResponseStreamingAsync)} method"); + AsyncCollectionResult createUpdates = _createResponseStreamingAsync is not null ? + _createResponseStreamingAsync(_responseClient, openAIOptions, cancellationToken.ToRequestOptions(streaming: true)) : + _responseClient.CreateResponseStreamingAsync(openAIOptions, cancellationToken); return FromOpenAIStreamingResponseUpdatesAsync(createUpdates, openAIOptions, openAIConversationId, cancellationToken: cancellationToken); } internal static async IAsyncEnumerable FromOpenAIStreamingResponseUpdatesAsync( IAsyncEnumerable streamingResponseUpdates, - ResponseCreationOptions? options, + CreateResponseOptions? options, string? conversationId, string? resumeResponseId = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) @@ -360,7 +357,7 @@ ChatResponseUpdate CreateUpdate(AIContent? content = null) => latestResponseStatus = completedUpdate.Response?.Status; var update = CreateUpdate(ToUsageDetails(completedUpdate.Response) is { } usage ? new UsageContent(usage) : null); update.FinishReason = - ToFinishReason(completedUpdate.Response?.IncompleteStatusDetails?.Reason) ?? + AsFinishReason(completedUpdate.Response?.IncompleteStatusDetails?.Reason) ?? (anyFunctions ? ChatFinishReason.ToolCalls : ChatFinishReason.Stop); yield return update; @@ -372,7 +369,7 @@ ChatResponseUpdate CreateUpdate(AIContent? content = null) => { case MessageResponseItem mri: lastMessageId = outputItemAddedUpdate.Item.Id; - lastRole = ToChatRole(mri.Role); + lastRole = AsChatRole(mri.Role); break; case FunctionCallResponseItem fcri: @@ -530,7 +527,7 @@ void UpdateConversationId(string? id) /// void IDisposable.Dispose() { - // Nothing to dispose. Implementation required for the IChatClient interface. + // Nothing to dispose. } internal static ResponseTool? ToResponseTool(AITool tool, ChatOptions? options = null) @@ -544,39 +541,60 @@ void IDisposable.Dispose() return ToResponseTool(aiFunction, options); case HostedWebSearchTool webSearchTool: - return ResponseTool.CreateWebSearchTool( - webSearchTool.GetProperty(nameof(WebSearchTool.UserLocation)), - webSearchTool.GetProperty(nameof(WebSearchTool.SearchContextSize)), - webSearchTool.GetProperty(nameof(WebSearchTool.Filters))); + return new WebSearchTool + { + Filters = webSearchTool.GetProperty(nameof(WebSearchTool.Filters)), + SearchContextSize = webSearchTool.GetProperty(nameof(WebSearchTool.SearchContextSize)), + UserLocation = webSearchTool.GetProperty(nameof(WebSearchTool.UserLocation)), + }; case HostedFileSearchTool fileSearchTool: - return ResponseTool.CreateFileSearchTool( - fileSearchTool.Inputs?.OfType().Select(c => c.VectorStoreId) ?? [], - fileSearchTool.MaximumResultCount, - fileSearchTool.GetProperty(nameof(FileSearchTool.RankingOptions)), - fileSearchTool.GetProperty(nameof(FileSearchTool.Filters))); - - case HostedImageGenerationTool imageGenerationTool: - return ToImageResponseTool(imageGenerationTool); + return new FileSearchTool(fileSearchTool.Inputs?.OfType().Select(c => c.VectorStoreId) ?? []) + { + Filters = fileSearchTool.GetProperty(nameof(FileSearchTool.Filters)), + MaxResultCount = fileSearchTool.MaximumResultCount, + RankingOptions = fileSearchTool.GetProperty(nameof(FileSearchTool.RankingOptions)), + }; case HostedCodeInterpreterTool codeTool: - return ResponseTool.CreateCodeInterpreterTool( - new CodeInterpreterToolContainer(codeTool.Inputs?.OfType().Select(f => f.FileId).ToList() is { Count: > 0 } ids ? + return new CodeInterpreterTool( + new(codeTool.Inputs?.OfType().Select(f => f.FileId).ToList() is { Count: > 0 } ids ? CodeInterpreterToolContainerConfiguration.CreateAutomaticContainerConfiguration(ids) : new())); + case HostedImageGenerationTool imageGenerationTool: + ImageGenerationOptions? igo = imageGenerationTool.Options; + return new ImageGenerationTool + { + Background = imageGenerationTool.GetProperty(nameof(ImageGenerationTool.Background)), + InputFidelity = imageGenerationTool.GetProperty(nameof(ImageGenerationTool.InputFidelity)), + InputImageMask = imageGenerationTool.GetProperty(nameof(ImageGenerationTool.InputImageMask)), + Model = igo?.ModelId, + ModerationLevel = imageGenerationTool.GetProperty(nameof(ImageGenerationTool.ModerationLevel)), + OutputCompressionFactor = imageGenerationTool.GetProperty(nameof(ImageGenerationTool.OutputCompressionFactor)), + OutputFileFormat = igo?.MediaType is { } mediaType ? + mediaType switch + { + "image/png" => ImageGenerationToolOutputFileFormat.Png, + "image/jpeg" => ImageGenerationToolOutputFileFormat.Jpeg, + "image/webp" => ImageGenerationToolOutputFileFormat.Webp, + _ => null, + } : + null, + PartialImageCount = igo?.StreamingCount, + Quality = imageGenerationTool.GetProperty(nameof(ImageGenerationTool.Quality)), + Size = igo?.ImageSize is { } size ? + new ImageGenerationToolSize(size.Width, size.Height) : + null, + }; + case HostedMcpServerTool mcpTool: - McpTool responsesMcpTool = Uri.TryCreate(mcpTool.ServerAddress, UriKind.Absolute, out Uri? url) ? - ResponseTool.CreateMcpTool( - mcpTool.ServerName, - url, - mcpTool.AuthorizationToken, - mcpTool.ServerDescription) : - ResponseTool.CreateMcpTool( - mcpTool.ServerName, - new McpToolConnectorId(mcpTool.ServerAddress), - mcpTool.AuthorizationToken, - mcpTool.ServerDescription); + McpTool responsesMcpTool = Uri.TryCreate(mcpTool.ServerAddress, UriKind.Absolute, out Uri? serverAddressUrl) ? + new McpTool(mcpTool.ServerName, serverAddressUrl) : + new McpTool(mcpTool.ServerName, new McpToolConnectorId(mcpTool.ServerAddress)); + + responsesMcpTool.ServerDescription = mcpTool.ServerDescription; + responsesMcpTool.AuthorizationToken = mcpTool.AuthorizationToken; if (mcpTool.AllowedTools is not null) { @@ -621,48 +639,21 @@ void IDisposable.Dispose() internal static FunctionTool ToResponseTool(AIFunctionDeclaration aiFunction, ChatOptions? options = null) { - bool? strict = + bool? strictModeEnabled = OpenAIClientExtensions.HasStrict(aiFunction.AdditionalProperties) ?? OpenAIClientExtensions.HasStrict(options?.AdditionalProperties); - return ResponseTool.CreateFunctionTool( + return new FunctionTool( aiFunction.Name, - OpenAIClientExtensions.ToOpenAIFunctionParameters(aiFunction, strict), - strict, - aiFunction.Description); - } - - internal static ImageGenerationTool ToImageResponseTool(HostedImageGenerationTool imageGenerationTool) - { - ImageGenerationOptions? options = imageGenerationTool.Options; - - return new() + OpenAIClientExtensions.ToOpenAIFunctionParameters(aiFunction, strictModeEnabled), + strictModeEnabled) { - Background = imageGenerationTool.GetProperty(nameof(ImageGenerationTool.Background)), - InputFidelity = imageGenerationTool.GetProperty(nameof(ImageGenerationTool.InputFidelity)), - InputImageMask = imageGenerationTool.GetProperty(nameof(ImageGenerationTool.InputImageMask)), - Model = options?.ModelId, - ModerationLevel = imageGenerationTool.GetProperty(nameof(ImageGenerationTool.ModerationLevel)), - OutputCompressionFactor = imageGenerationTool.GetProperty(nameof(ImageGenerationTool.OutputCompressionFactor)), - OutputFileFormat = options?.MediaType is not null ? - options.MediaType switch - { - "image/png" => ImageGenerationToolOutputFileFormat.Png, - "image/jpeg" => ImageGenerationToolOutputFileFormat.Jpeg, - "image/webp" => ImageGenerationToolOutputFileFormat.Webp, - _ => null, - } : - null, - PartialImageCount = options?.StreamingCount, - Quality = imageGenerationTool.GetProperty(nameof(ImageGenerationTool.Quality)), - Size = options?.ImageSize is not null ? - new ImageGenerationToolSize(options.ImageSize.Value.Width, options.ImageSize.Value.Height) : - null + FunctionDescription = aiFunction.Description, }; } /// Creates a from a . - private static ChatRole ToChatRole(MessageRole? role) => + private static ChatRole AsChatRole(MessageRole? role) => role switch { MessageRole.System => ChatRole.System, @@ -672,23 +663,26 @@ private static ChatRole ToChatRole(MessageRole? role) => }; /// Creates a from a . - private static ChatFinishReason? ToFinishReason(ResponseIncompleteStatusReason? statusReason) => + private static ChatFinishReason? AsFinishReason(ResponseIncompleteStatusReason? statusReason) => statusReason == ResponseIncompleteStatusReason.ContentFilter ? ChatFinishReason.ContentFilter : statusReason == ResponseIncompleteStatusReason.MaxOutputTokens ? ChatFinishReason.Length : null; - /// Converts a to a . - private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? options, out string? openAIConversationId) + /// Converts a to a . + private CreateResponseOptions AsCreateResponseOptions(ChatOptions? options, out string? openAIConversationId) { openAIConversationId = null; if (options is null) { - return new(); + return new() + { + Model = _responseClient.Model, + }; } bool hasRawRco = false; - if (options.RawRepresentationFactory?.Invoke(this) is ResponseCreationOptions result) + if (options.RawRepresentationFactory?.Invoke(this) is CreateResponseOptions result) { hasRawRco = true; } @@ -697,32 +691,29 @@ private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? opt result = new(); } + result.BackgroundModeEnabled ??= options.AllowBackgroundResponses; result.MaxOutputTokenCount ??= options.MaxOutputTokens; + result.Model ??= options.ModelId ?? _responseClient.Model; result.Temperature ??= options.Temperature; result.TopP ??= options.TopP; - result.BackgroundModeEnabled ??= options.AllowBackgroundResponses; - OpenAIClientExtensions.PatchModelIfNotSet(ref result.Patch, options.ModelId); - // If the ResponseCreationOptions.PreviousResponseId is already set (likely rare), then we don't need to do + // If the CreateResponseOptions.PreviousResponseId is already set (likely rare), then we don't need to do // anything with regards to Conversation, because they're mutually exclusive and we would want to ignore - // ChatOptions.ConversationId regardless of its value. If it's null, we want to examine the ResponseCreationOptions + // ChatOptions.ConversationId regardless of its value. If it's null, we want to examine the CreateResponseOptions // instance to see if a conversation ID has already been set on it and use that conversation ID subsequently if // it has. If one hasn't been set, but ChatOptions.ConversationId has been set, we'll either set - // ResponseCreationOptions.Conversation if the string represents a conversation ID or else PreviousResponseId. + // CreateResponseOptions.Conversation if the string represents a conversation ID or else PreviousResponseId. if (result.PreviousResponseId is null) { - // Technically, OpenAI's IDs are opaque. However, by convention conversation IDs start with "conv_" and - // we can use that to disambiguate whether we're looking at a conversation ID or a response ID. - string? chatOptionsConversationId = options.ConversationId; - bool chatOptionsHasOpenAIConversationId = chatOptionsConversationId?.StartsWith("conv_", StringComparison.OrdinalIgnoreCase) is true; + bool chatOptionsHasOpenAIConversationId = OpenAIClientExtensions.IsConversationId(options.ConversationId); if (hasRawRco || chatOptionsHasOpenAIConversationId) { - _ = result.Patch.TryGetValue("$.conversation"u8, out openAIConversationId); + openAIConversationId = result.ConversationOptions?.ConversationId; if (openAIConversationId is null && chatOptionsHasOpenAIConversationId) { - result.Patch.Set("$.conversation"u8, chatOptionsConversationId!); - openAIConversationId = chatOptionsConversationId; + result.ConversationOptions = new(options.ConversationId); + openAIConversationId = options.ConversationId; } } @@ -765,7 +756,6 @@ private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? opt break; case AutoChatToolMode: - case null: result.ToolChoice = ResponseToolChoice.CreateAutoChoice(); break; @@ -1063,9 +1053,10 @@ static FunctionCallOutputResponseItem SerializeAIContent(string callId, IEnumera break; case TextReasoningContent reasoningContent: - yield return OpenAIResponsesModelFactory.ReasoningResponseItem( - encryptedContent: reasoningContent.ProtectedData, - summaryText: reasoningContent.Text); + yield return new ReasoningResponseItem(reasoningContent.Text) + { + EncryptedContent = reasoningContent.ProtectedData, + }; break; case FunctionCallContent callContent: @@ -1119,11 +1110,11 @@ static FunctionCallOutputResponseItem SerializeAIContent(string callId, IEnumera } } - /// Extract usage details from an . - private static UsageDetails? ToUsageDetails(OpenAIResponse? openAIResponse) + /// Extract usage details from a into a . + private static UsageDetails? ToUsageDetails(ResponseResult? responseResult) { UsageDetails? ud = null; - if (openAIResponse?.Usage is { } usage) + if (responseResult?.Usage is { } usage) { ud = new() { @@ -1138,6 +1129,38 @@ static FunctionCallOutputResponseItem SerializeAIContent(string callId, IEnumera return ud; } + /// Converts a to a . + internal static ResponseTokenUsage? ToResponseTokenUsage(UsageDetails? usageDetails) + { + ResponseTokenUsage? rtu = null; + if (usageDetails is not null) + { + rtu = new() + { + InputTokenCount = (int?)usageDetails.InputTokenCount ?? 0, + OutputTokenCount = (int?)usageDetails.OutputTokenCount ?? 0, + TotalTokenCount = (int?)usageDetails.TotalTokenCount ?? 0, + InputTokenDetails = new(), + OutputTokenDetails = new(), + }; + + if (usageDetails.AdditionalCounts is { } additionalCounts) + { + if (additionalCounts.TryGetValue($"{nameof(ResponseTokenUsage.InputTokenDetails)}.{nameof(ResponseInputTokenUsageDetails.CachedTokenCount)}", out int? cachedTokenCount)) + { + rtu.InputTokenDetails.CachedTokenCount = cachedTokenCount.GetValueOrDefault(); + } + + if (additionalCounts.TryGetValue($"{nameof(ResponseTokenUsage.OutputTokenDetails)}.{nameof(ResponseOutputTokenUsageDetails.ReasoningTokenCount)}", out int? reasoningTokenCount)) + { + rtu.OutputTokenDetails.ReasoningTokenCount = reasoningTokenCount.GetValueOrDefault(); + } + } + } + + return rtu; + } + /// Convert a sequence of s to a list of . private static List ToAIContents(IEnumerable contents) { @@ -1145,46 +1168,40 @@ private static List ToAIContents(IEnumerable con foreach (ResponseContentPart part in contents) { + AIContent? content; switch (part.Kind) { case ResponseContentPartKind.InputText or ResponseContentPartKind.OutputText: - TextContent text = new(part.Text) { RawRepresentation = part }; + TextContent text = new(part.Text); PopulateAnnotations(part, text); - results.Add(text); + content = text; break; - case ResponseContentPartKind.InputFile: - if (!string.IsNullOrWhiteSpace(part.InputImageFileId)) - { - results.Add(new HostedFileContent(part.InputImageFileId) { MediaType = "image/*", RawRepresentation = part }); - } - else if (!string.IsNullOrWhiteSpace(part.InputFileId)) - { - results.Add(new HostedFileContent(part.InputFileId) { Name = part.InputFilename, RawRepresentation = part }); - } - else if (part.InputFileBytes is not null) - { - results.Add(new DataContent(part.InputFileBytes, part.InputFileBytesMediaType ?? "application/octet-stream") - { - Name = part.InputFilename, - RawRepresentation = part, - }); - } - + case ResponseContentPartKind.InputFile or ResponseContentPartKind.InputImage: + content = + !string.IsNullOrWhiteSpace(part.InputImageFileId) ? new HostedFileContent(part.InputImageFileId) { MediaType = "image/*" } : + !string.IsNullOrWhiteSpace(part.InputFileId) ? new HostedFileContent(part.InputFileId) { Name = part.InputFilename } : + part.InputFileBytes is not null ? new DataContent(part.InputFileBytes, part.InputFileBytesMediaType ?? "application/octet-stream") { Name = part.InputFilename } : + null; break; case ResponseContentPartKind.Refusal: - results.Add(new ErrorContent(part.Refusal) + content = new ErrorContent(part.Refusal) { ErrorCode = nameof(ResponseContentPartKind.Refusal), - RawRepresentation = part, - }); + }; break; default: - results.Add(new() { RawRepresentation = part }); + content = new(); break; } + + if (content is not null) + { + content.RawRepresentation = part; + results.Add(content); + } } return results; @@ -1288,7 +1305,7 @@ private static void AddCodeInterpreterContents(CodeInterpreterCallResponseItem c }); } - private static void AddImageGenerationContents(ImageGenerationCallResponseItem outputItem, ResponseCreationOptions? options, IList contents) + private static void AddImageGenerationContents(ImageGenerationCallResponseItem outputItem, CreateResponseOptions? options, IList contents) { var imageGenTool = options?.Tools.OfType().FirstOrDefault(); string outputFormat = imageGenTool?.OutputFileFormat?.ToString() ?? "png"; @@ -1302,14 +1319,11 @@ private static void AddImageGenerationContents(ImageGenerationCallResponseItem o { ImageId = outputItem.Id, RawRepresentation = outputItem, - Outputs = new List - { - new DataContent(outputItem.ImageResultBytes, $"image/{outputFormat}") - } + Outputs = [new DataContent(outputItem.ImageResultBytes, $"image/{outputFormat}")] }); } - private static ImageGenerationToolResultContent GetImageGenerationResult(StreamingResponseImageGenerationCallPartialImageUpdate update, ResponseCreationOptions? options) + private static ImageGenerationToolResultContent GetImageGenerationResult(StreamingResponseImageGenerationCallPartialImageUpdate update, CreateResponseOptions? options) { var imageGenTool = options?.Tools.OfType().FirstOrDefault(); var outputType = imageGenTool?.OutputFileFormat?.ToString() ?? "png"; @@ -1333,15 +1347,13 @@ private static ImageGenerationToolResultContent GetImageGenerationResult(Streami }; } - private static OpenAIResponsesContinuationToken? CreateContinuationToken(OpenAIResponse openAIResponse) - { - return CreateContinuationToken( - responseId: openAIResponse.Id, - responseStatus: openAIResponse.Status, - isBackgroundModeEnabled: openAIResponse.BackgroundModeEnabled); - } + private static ResponsesClientContinuationToken? CreateContinuationToken(ResponseResult responseResult) => + CreateContinuationToken( + responseId: responseResult.Id, + responseStatus: responseResult.Status, + isBackgroundModeEnabled: responseResult.BackgroundModeEnabled); - private static OpenAIResponsesContinuationToken? CreateContinuationToken( + private static ResponsesClientContinuationToken? CreateContinuationToken( string responseId, ResponseStatus? responseStatus, bool? isBackgroundModeEnabled, @@ -1359,7 +1371,7 @@ private static ImageGenerationToolResultContent GetImageGenerationResult(Streami if ((responseStatus is ResponseStatus.InProgress or ResponseStatus.Queued) || (responseStatus is null && updateSequenceNumber is not null)) { - return new OpenAIResponsesContinuationToken(responseId) + return new ResponsesClientContinuationToken(responseId) { SequenceNumber = updateSequenceNumber, }; @@ -1371,7 +1383,7 @@ private static ImageGenerationToolResultContent GetImageGenerationResult(Streami return null; } - private static OpenAIResponsesContinuationToken? GetContinuationToken(IEnumerable messages, ChatOptions? options = null) + private static ResponsesClientContinuationToken? GetContinuationToken(IEnumerable messages, ChatOptions? options = null) { if (options?.ContinuationToken is { } token) { @@ -1380,7 +1392,7 @@ private static ImageGenerationToolResultContent GetImageGenerationResult(Streami throw new InvalidOperationException("Messages are not allowed when continuing a background response using a continuation token."); } - return OpenAIResponsesContinuationToken.FromToken(token); + return ResponsesClientContinuationToken.FromToken(token); } return null; diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesContinuationToken.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/ResponsesClientContinuationToken.cs similarity index 81% rename from src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesContinuationToken.cs rename to src/Libraries/Microsoft.Extensions.AI.OpenAI/ResponsesClientContinuationToken.cs index 8e6f5ffd71c..770f5b10afa 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesContinuationToken.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/ResponsesClientContinuationToken.cs @@ -13,10 +13,10 @@ namespace Microsoft.Extensions.AI; /// The token is used for resuming streamed background responses and continuing /// non-streamed background responses until completion. /// -internal sealed class OpenAIResponsesContinuationToken : ResponseContinuationToken +internal sealed class ResponsesClientContinuationToken : ResponseContinuationToken { - /// Initializes a new instance of the class. - internal OpenAIResponsesContinuationToken(string responseId) + /// Initializes a new instance of the class. + internal ResponsesClientContinuationToken(string responseId) { ResponseId = responseId; } @@ -49,13 +49,13 @@ public override ReadOnlyMemory ToBytes() return stream.ToArray(); } - /// Create a new instance of from the provided . + /// Create a new instance of from the provided . /// - /// The token to create the from. - /// A equivalent of the provided . - internal static OpenAIResponsesContinuationToken FromToken(ResponseContinuationToken token) + /// The token to create the from. + /// A equivalent of the provided . + internal static ResponsesClientContinuationToken FromToken(ResponseContinuationToken token) { - if (token is OpenAIResponsesContinuationToken openAIResponsesContinuationToken) + if (token is ResponsesClientContinuationToken openAIResponsesContinuationToken) { return openAIResponsesContinuationToken; } diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Program.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Program.cs index e9984318254..a51a674cf71 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Program.cs +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/ChatWithCustomData/ChatWithCustomData-CSharp.Web/Program.cs @@ -49,8 +49,8 @@ var openAIClient = new OpenAIClient( new ApiKeyCredential(builder.Configuration["OpenAI:Key"] ?? throw new InvalidOperationException("Missing configuration: OpenAI:Key. See the README for details."))); -#pragma warning disable OPENAI001 // GetOpenAIResponseClient(string) is experimental and subject to change or removal in future updates. -var chatClient = openAIClient.GetOpenAIResponseClient("gpt-4o-mini").AsIChatClient(); +#pragma warning disable OPENAI001 // GetResponsesClient(string) is experimental and subject to change or removal in future updates. +var chatClient = openAIClient.GetResponsesClient("gpt-4o-mini").AsIChatClient(); #pragma warning restore OPENAI001 var embeddingGenerator = openAIClient.GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator(); @@ -66,7 +66,7 @@ #endif var azureOpenAIEndpoint = new Uri(new Uri(builder.Configuration["AzureOpenAI:Endpoint"] ?? throw new InvalidOperationException("Missing configuration: AzureOpenAi:Endpoint. See the README for details.")), "/openai/v1"); #if (IsManagedIdentity) -#pragma warning disable OPENAI001 // OpenAIClient(AuthenticationPolicy, OpenAIClientOptions) and GetOpenAIResponseClient(string) are experimental and subject to change or removal in future updates. +#pragma warning disable OPENAI001 // OpenAIClient(AuthenticationPolicy, OpenAIClientOptions) and GetResponsesClient(string) are experimental and subject to change or removal in future updates. var azureOpenAi = new OpenAIClient( new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"), new OpenAIClientOptions { Endpoint = azureOpenAIEndpoint }); @@ -75,9 +75,9 @@ var openAIOptions = new OpenAIClientOptions { Endpoint = azureOpenAIEndpoint }; var azureOpenAi = new OpenAIClient(new ApiKeyCredential(builder.Configuration["AzureOpenAI:Key"] ?? throw new InvalidOperationException("Missing configuration: AzureOpenAi:Key. See the README for details.")), openAIOptions); -#pragma warning disable OPENAI001 // GetOpenAIResponseClient(string) is experimental and subject to change or removal in future updates. +#pragma warning disable OPENAI001 // GetResponsesClient(string) is experimental and subject to change or removal in future updates. #endif -var chatClient = azureOpenAi.GetOpenAIResponseClient("gpt-4o-mini").AsIChatClient(); +var chatClient = azureOpenAi.GetResponsesClient("gpt-4o-mini").AsIChatClient(); #pragma warning restore OPENAI001 var embeddingGenerator = azureOpenAi.GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator(); diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs index 7b1dd10a2bd..ce31abe8bc9 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs @@ -432,7 +432,7 @@ private async Task AvailableTools_SchemasAreAccepted(bool strict) if (strict) { - aiFuncOptions.AdditionalProperties = new Dictionary { ["strictJsonSchema"] = true }; + aiFuncOptions.AdditionalProperties = new Dictionary { ["strict"] = true }; } return aiFuncOptions; @@ -444,7 +444,7 @@ private async Task AvailableTools_SchemasAreAccepted(bool strict) if (strict) { - additionalProperties["strictJsonSchema"] = true; + additionalProperties["strict"] = true; } return new CustomAIFunction($"CustomMethod{methodCount++}", schema, additionalProperties); diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs index 458256523e4..99aa44cdd0f 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs @@ -332,7 +332,7 @@ public async Task ChatOptions_StrictRespected() Tools = [AIFunctionFactory.Create(() => 42, "GetPersonAge", "Gets the age of the specified person.")], AdditionalProperties = new() { - ["strictJsonSchema"] = true, + ["strict"] = true, }, }); Assert.NotNull(response); diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs index 0ab4ea2c6b1..1a711b7417c 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs @@ -47,7 +47,7 @@ public void AsOpenAIChatResponseFormat_HandlesVariousFormats() """), RemoveWhitespace(((IJsonModel)jsonSchema).Write(ModelReaderWriterOptions.Json).ToString())); jsonSchema = ChatResponseFormat.ForJsonSchema(typeof(int), schemaName: "my_schema", schemaDescription: "A test schema").AsOpenAIChatResponseFormat( - new() { AdditionalProperties = new AdditionalPropertiesDictionary { ["strictJsonSchema"] = true } }); + new() { AdditionalProperties = new AdditionalPropertiesDictionary { ["strict"] = true } }); Assert.NotNull(jsonSchema); Assert.Equal(RemoveWhitespace(""" { @@ -82,7 +82,7 @@ public void AsOpenAIResponseTextFormat_HandlesVariousFormats() """), RemoveWhitespace(((IJsonModel)jsonSchema).Write(ModelReaderWriterOptions.Json).ToString())); jsonSchema = ChatResponseFormat.ForJsonSchema(typeof(int), schemaName: "my_schema", schemaDescription: "A test schema").AsOpenAIResponseTextFormat( - new() { AdditionalProperties = new AdditionalPropertiesDictionary { ["strictJsonSchema"] = true } }); + new() { AdditionalProperties = new AdditionalPropertiesDictionary { ["strict"] = true } }); Assert.NotNull(jsonSchema); Assert.Equal(ResponseTextFormatKind.JsonSchema, jsonSchema.Kind); Assert.Equal(RemoveWhitespace(""" @@ -788,7 +788,7 @@ static async IAsyncEnumerable CreateUpdates() [Fact] public void AsChatResponse_ConvertsOpenAIResponse() { - Assert.Throws("response", () => ((OpenAIResponse)null!).AsChatResponse()); + Assert.Throws("response", () => ((ResponseResult)null!).AsChatResponse()); // The OpenAI library currently doesn't provide any way to create an OpenAIResponse instance, // as all constructors/factory methods currently are internal. Update this test when such functionality is available. @@ -1355,32 +1355,32 @@ public async Task AsOpenAIStreamingChatCompletionUpdatesAsync_WithMultipleUpdate [Fact] public void AsOpenAIResponse_WithNullArgument_ThrowsArgumentNullException() { - Assert.Throws("response", () => ((ChatResponse)null!).AsOpenAIResponse()); + Assert.Throws("response", () => ((ChatResponse)null!).AsOpenAIResponseResult()); } [Fact] public void AsOpenAIResponse_WithRawRepresentation_ReturnsOriginal() { - var originalOpenAIResponse = OpenAIResponsesModelFactory.OpenAIResponse( - "original-response-id", - new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero), - ResponseStatus.Completed, - usage: null, - maxOutputTokenCount: 100, - outputItems: [], - parallelToolCallsEnabled: false, - model: "gpt-4", - temperature: 0.7f, - topP: 0.9f, - previousResponseId: "prev-id", - instructions: "Test instructions"); + ResponseResult originalOpenAIResponse = new() + { + Id = "original-response-id", + CreatedAt = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero), + Status = ResponseStatus.Completed, + MaxOutputTokenCount = 100, + ParallelToolCallsEnabled = false, + Model = "gpt-4", + Temperature = 0.7f, + TopP = 0.9f, + PreviousResponseId = "prev-id", + Instructions = "Test instructions" + }; var chatResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Test")) { RawRepresentation = originalOpenAIResponse }; - var result = chatResponse.AsOpenAIResponse(); + var result = chatResponse.AsOpenAIResponseResult(); Assert.Same(originalOpenAIResponse, result); } @@ -1396,7 +1396,7 @@ public void AsOpenAIResponse_WithBasicChatResponse_CreatesValidOpenAIResponse() FinishReason = ChatFinishReason.Stop }; - var openAIResponse = chatResponse.AsOpenAIResponse(); + var openAIResponse = chatResponse.AsOpenAIResponseResult(); Assert.NotNull(openAIResponse); Assert.Equal("test-response-id", openAIResponse.Id); @@ -1415,6 +1415,7 @@ public void AsOpenAIResponse_WithChatOptions_IncludesOptionsInResponse() { var chatResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Test message")) { + ConversationId = "conv_123", ResponseId = "options-test", ModelId = "gpt-3.5-turbo" }; @@ -1423,20 +1424,19 @@ public void AsOpenAIResponse_WithChatOptions_IncludesOptionsInResponse() { MaxOutputTokens = 500, AllowMultipleToolCalls = true, - ConversationId = "conversation-123", Instructions = "You are a helpful assistant.", Temperature = 0.8f, TopP = 0.95f, ModelId = "override-model" }; - var openAIResponse = chatResponse.AsOpenAIResponse(options); + var openAIResponse = chatResponse.AsOpenAIResponseResult(options); Assert.Equal("options-test", openAIResponse.Id); Assert.Equal("gpt-3.5-turbo", openAIResponse.Model); Assert.Equal(500, openAIResponse.MaxOutputTokenCount); Assert.True(openAIResponse.ParallelToolCallsEnabled); - Assert.Equal("conversation-123", openAIResponse.PreviousResponseId); + Assert.Equal("conv_123", openAIResponse.ConversationOptions?.ConversationId); Assert.Equal("You are a helpful assistant.", openAIResponse.Instructions); Assert.Equal(0.8f, openAIResponse.Temperature); Assert.Equal(0.95f, openAIResponse.TopP); @@ -1451,7 +1451,7 @@ public void AsOpenAIResponse_WithEmptyMessages_CreatesResponseWithEmptyOutputIte ModelId = "gpt-4" }; - var openAIResponse = chatResponse.AsOpenAIResponse(); + var openAIResponse = chatResponse.AsOpenAIResponseResult(); Assert.Equal("empty-response", openAIResponse.Id); Assert.Equal("gpt-4", openAIResponse.Model); @@ -1477,7 +1477,7 @@ public void AsOpenAIResponse_WithMultipleMessages_ConvertsAllMessages() ResponseId = "multi-message-response" }; - var openAIResponse = chatResponse.AsOpenAIResponse(); + var openAIResponse = chatResponse.AsOpenAIResponseResult(); Assert.Equal(4, openAIResponse.OutputItems.Count); @@ -1514,7 +1514,7 @@ public void AsOpenAIResponse_WithToolMessages_ConvertsCorrectly() ResponseId = "tool-message-test" }; - var openAIResponse = chatResponse.AsOpenAIResponse(); + var openAIResponse = chatResponse.AsOpenAIResponseResult(); var outputItems = openAIResponse.OutputItems.ToArray(); Assert.Equal(4, outputItems.Length); @@ -1545,7 +1545,7 @@ public void AsOpenAIResponse_WithSystemAndUserMessages_ConvertsCorrectly() ResponseId = "system-user-test" }; - var openAIResponse = chatResponse.AsOpenAIResponse(); + var openAIResponse = chatResponse.AsOpenAIResponseResult(); var outputItems = openAIResponse.OutputItems.ToArray(); Assert.Equal(3, outputItems.Length); @@ -1564,15 +1564,15 @@ public void AsOpenAIResponse_WithDefaultValues_UsesExpectedDefaults() { var chatResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Default test")); - var openAIResponse = chatResponse.AsOpenAIResponse(); + var openAIResponse = chatResponse.AsOpenAIResponseResult(); Assert.NotNull(openAIResponse); Assert.Equal(ResponseStatus.Completed, openAIResponse.Status); - Assert.False(openAIResponse.ParallelToolCallsEnabled); + Assert.True(openAIResponse.ParallelToolCallsEnabled); Assert.Null(openAIResponse.MaxOutputTokenCount); Assert.Null(openAIResponse.Temperature); Assert.Null(openAIResponse.TopP); - Assert.Null(openAIResponse.PreviousResponseId); + Assert.Null(openAIResponse.ConversationOptions); Assert.Null(openAIResponse.Instructions); Assert.NotNull(openAIResponse.OutputItems); } @@ -1587,7 +1587,7 @@ public void AsOpenAIResponse_WithOptionsButNoModelId_UsesOptionsModelId() ModelId = "options-model-id" }; - var openAIResponse = chatResponse.AsOpenAIResponse(options); + var openAIResponse = chatResponse.AsOpenAIResponseResult(options); Assert.Equal("options-model-id", openAIResponse.Model); } @@ -1605,7 +1605,7 @@ public void AsOpenAIResponse_WithBothModelIds_PrefersChatResponseModelId() ModelId = "options-model-id" }; - var openAIResponse = chatResponse.AsOpenAIResponse(options); + var openAIResponse = chatResponse.AsOpenAIResponseResult(options); Assert.Equal("response-model-id", openAIResponse.Model); } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs index 830563a60e1..1421e780dca 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs @@ -14,7 +14,7 @@ public class OpenAIResponseClientIntegrationTests : ChatClientIntegrationTests { protected override IChatClient? CreateChatClient() => IntegrationTestHelpers.GetOpenAIClient() - ?.GetOpenAIResponseClient(TestRunnerConfiguration.Instance["OpenAI:ChatModel"] ?? "gpt-4o-mini") + ?.GetResponsesClient(TestRunnerConfiguration.Instance["OpenAI:ChatModel"] ?? "gpt-4o-mini") .AsIChatClient(); public override bool FunctionInvokingChatClientSetsConversationId => true; diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs index 34526b683bd..1e19466ee7f 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs @@ -11,7 +11,6 @@ using System.Net.Http; using System.Text; using System.Text.Json; -using System.Text.Json.Nodes; using System.Threading.Tasks; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; @@ -28,7 +27,7 @@ public class OpenAIResponseClientTests [Fact] public void AsIChatClient_InvalidArgs_Throws() { - Assert.Throws("responseClient", () => ((OpenAIResponseClient)null!).AsIChatClient()); + Assert.Throws("responseClient", () => ((ResponsesClient)null!).AsIChatClient()); } [Fact] @@ -39,7 +38,7 @@ public void AsIChatClient_ProducesExpectedMetadata() var client = new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); - IChatClient chatClient = client.GetOpenAIResponseClient(model).AsIChatClient(); + IChatClient chatClient = client.GetResponsesClient(model).AsIChatClient(); var metadata = chatClient.GetService(); Assert.Equal("openai", metadata?.ProviderName); Assert.Equal(endpoint, metadata?.ProviderUri); @@ -49,11 +48,11 @@ public void AsIChatClient_ProducesExpectedMetadata() [Fact] public void GetService_SuccessfullyReturnsUnderlyingClient() { - OpenAIResponseClient openAIClient = new OpenAIClient(new ApiKeyCredential("key")).GetOpenAIResponseClient("model"); + ResponsesClient openAIClient = new OpenAIClient(new ApiKeyCredential("key")).GetResponsesClient("model"); IChatClient chatClient = openAIClient.AsIChatClient(); Assert.Same(chatClient, chatClient.GetService()); - Assert.Same(openAIClient, chatClient.GetService()); + Assert.Same(openAIClient, chatClient.GetService()); using IChatClient pipeline = chatClient .AsBuilder() @@ -67,7 +66,7 @@ public void GetService_SuccessfullyReturnsUnderlyingClient() Assert.NotNull(pipeline.GetService()); Assert.NotNull(pipeline.GetService()); - Assert.Same(openAIClient, pipeline.GetService()); + Assert.Same(openAIClient, pipeline.GetService()); Assert.IsType(pipeline.GetService()); } @@ -295,7 +294,7 @@ public async Task BasicReasoningResponse_Streaming() List updates = []; await foreach (var update in client.GetStreamingResponseAsync("Calculate the sum of the first 5 positive integers.", new() { - RawRepresentationFactory = options => new ResponseCreationOptions + RawRepresentationFactory = options => new CreateResponseOptions { ReasoningOptions = new() { @@ -428,7 +427,7 @@ public async Task ReasoningTextDelta_Streaming() List updates = []; await foreach (var update in client.GetStreamingResponseAsync("Solve this problem step by step.", new() { - RawRepresentationFactory = options => new ResponseCreationOptions + RawRepresentationFactory = options => new CreateResponseOptions { ReasoningOptions = new() { @@ -609,7 +608,6 @@ public async Task MissingAbstractionResponse_NonStreaming() "display_height": 768 } ], - "tool_choice": "auto", "input": [ { "type": "message", @@ -700,7 +698,7 @@ public async Task MissingAbstractionResponse_NonStreaming() ChatOptions chatOptions = new() { Tools = [ResponseTool.CreateComputerTool(ComputerToolEnvironment.Browser, 1024, 768).AsAITool()], - RawRepresentationFactory = options => new ResponseCreationOptions + RawRepresentationFactory = options => new CreateResponseOptions { ReasoningOptions = new() { ReasoningSummaryVerbosity = ResponseReasoningSummaryVerbosity.Concise }, } @@ -745,7 +743,6 @@ public async Task MissingAbstractionResponse_Streaming() "display_height": 768 } ], - "tool_choice": "auto", "input": [ { "type": "message", @@ -789,7 +786,7 @@ public async Task MissingAbstractionResponse_Streaming() ChatOptions chatOptions = new() { Tools = [ResponseTool.CreateComputerTool(ComputerToolEnvironment.Browser, 1024, 768).AsAITool()], - RawRepresentationFactory = options => new ResponseCreationOptions + RawRepresentationFactory = options => new CreateResponseOptions { ReasoningOptions = new() { ReasoningSummaryVerbosity = ResponseReasoningSummaryVerbosity.Concise }, } @@ -843,7 +840,6 @@ public async Task ChatOptions_StrictRespected() ] } ], - "tool_choice": "auto", "tools": [ { "type": "function", @@ -894,7 +890,7 @@ public async Task ChatOptions_StrictRespected() Tools = [AIFunctionFactory.Create(() => 42, "GetPersonAge", "Gets the age of the specified person.")], AdditionalProperties = new() { - ["strictJsonSchema"] = true, + ["strict"] = true, }, }); Assert.NotNull(response); @@ -1039,7 +1035,7 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio { RawRepresentationFactory = (c) => { - ResponseCreationOptions openAIOptions = new() + CreateResponseOptions openAIOptions = new() { MaxOutputTokenCount = 10, PreviousResponseId = "resp_42", @@ -1202,7 +1198,6 @@ public async Task McpToolCall_ApprovalRequired_NonStreaming(string role) "server_url": "https://mcp.deepwiki.com/mcp" } ], - "tool_choice": "auto", "input": [ { "type": "message", @@ -1257,7 +1252,6 @@ public async Task McpToolCall_ApprovalRequired_NonStreaming(string role) }, "verbosity": "medium" }, - "tool_choice": "auto", "tools": [ { "type": "mcp", @@ -1317,7 +1311,6 @@ public async Task McpToolCall_ApprovalRequired_NonStreaming(string role) "server_url": "https://mcp.deepwiki.com/mcp" } ], - "tool_choice": "auto", "input": [ { "type": "mcp_approval_response", @@ -1474,7 +1467,6 @@ public async Task McpToolCall_ApprovalNotRequired_NonStreaming(bool rawTool) "require_approval": "never" } ], - "tool_choice": "auto", "input": [ { "type": "message", @@ -1739,7 +1731,6 @@ public async Task McpToolCall_ApprovalNotRequired_Streaming() "require_approval": "never" } ], - "tool_choice": "auto", "input": [ { "type": "message", @@ -2610,7 +2601,6 @@ public async Task CodeInterpreterTool_NonStreaming() "role":"user", "content":[{"type":"input_text","text":"Calculate the sum of numbers from 1 to 5"}] }], - "tool_choice":"auto", "tools":[{ "type":"code_interpreter", "container":{"type":"auto"} @@ -2739,7 +2729,6 @@ public async Task CodeInterpreterTool_Streaming() "role":"user", "content":[{"type":"input_text","text":"Calculate the sum of numbers from 1 to 10 using Python"}] }], - "tool_choice":"auto", "tools":[{ "type":"code_interpreter", "container":{"type":"auto"} @@ -3032,7 +3021,7 @@ public async Task ConversationId_AsConversationId_NonStreaming() { "temperature":0.5, "model":"gpt-4o-mini", - "conversation":"conv_12345", + "conversation":{"id":"conv_12345"}, "input": [{ "type":"message", "role":"user", @@ -3134,7 +3123,7 @@ public async Task ConversationId_WhenStoreDisabled_ReturnsNull_NonStreaming() { MaxOutputTokens = 20, Temperature = 0.5f, - RawRepresentationFactory = (c) => new ResponseCreationOptions + RawRepresentationFactory = (c) => new CreateResponseOptions { StoredOutputEnabled = false } @@ -3196,7 +3185,7 @@ public async Task ConversationId_ChatOptionsOverridesRawRepresentationResponseId MaxOutputTokens = 20, Temperature = 0.5f, ConversationId = "resp_override", - RawRepresentationFactory = (c) => new ResponseCreationOptions + RawRepresentationFactory = (c) => new CreateResponseOptions { PreviousResponseId = null } @@ -3258,7 +3247,7 @@ public async Task ConversationId_RawRepresentationPreviousResponseIdTakesPrecede MaxOutputTokens = 20, Temperature = 0.5f, ConversationId = "conv_ignored", - RawRepresentationFactory = (c) => new ResponseCreationOptions + RawRepresentationFactory = (c) => new CreateResponseOptions { PreviousResponseId = "resp_fromraw" } @@ -3320,7 +3309,7 @@ public async Task ConversationId_WhenStoreExplicitlyTrue_UsesResponseId_NonStrea { MaxOutputTokens = 20, Temperature = 0.5f, - RawRepresentationFactory = (c) => new ResponseCreationOptions + RawRepresentationFactory = (c) => new CreateResponseOptions { StoredOutputEnabled = true } @@ -3394,7 +3383,7 @@ public async Task ConversationId_WhenStoreExplicitlyTrue_UsesResponseId_Streamin { MaxOutputTokens = 20, Temperature = 0.5f, - RawRepresentationFactory = (c) => new ResponseCreationOptions + RawRepresentationFactory = (c) => new CreateResponseOptions { StoredOutputEnabled = true } @@ -3475,7 +3464,7 @@ public async Task ConversationId_WhenStoreDisabled_ReturnsNull_Streaming() { MaxOutputTokens = 20, Temperature = 0.5f, - RawRepresentationFactory = (c) => new ResponseCreationOptions + RawRepresentationFactory = (c) => new CreateResponseOptions { StoredOutputEnabled = false } @@ -3500,7 +3489,7 @@ public async Task ConversationId_AsConversationId_Streaming() { "temperature":0.5, "model":"gpt-4o-mini", - "conversation":"conv_12345", + "conversation":{"id":"conv_12345"}, "input":[ { "type":"message", @@ -3656,7 +3645,7 @@ public async Task ConversationId_RawRepresentationConversationIdTakesPrecedence_ { "temperature":0.5, "model":"gpt-4o-mini", - "conversation":"conv_12345", + "conversation":{"id":"conv_12345"}, "input": [{ "type":"message", "role":"user", @@ -3695,20 +3684,15 @@ public async Task ConversationId_RawRepresentationConversationIdTakesPrecedence_ using HttpClient httpClient = new(handler); using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); - var rcoJsonModel = (IJsonModel)new ResponseCreationOptions(); - BinaryData rcoJsonBinaryData = rcoJsonModel.Write(ModelReaderWriterOptions.Json); - JsonObject rcoJsonObject = Assert.IsType(JsonNode.Parse(rcoJsonBinaryData.ToMemory().Span)); - Assert.Null(rcoJsonObject["conversation"]); - rcoJsonObject["conversation"] = "conv_12345"; - var response = await client.GetResponseAsync("hello", new() { MaxOutputTokens = 20, Temperature = 0.5f, ConversationId = "conv_ignored", - RawRepresentationFactory = (c) => rcoJsonModel.Create( - new BinaryData(JsonSerializer.SerializeToUtf8Bytes(rcoJsonObject)), - ModelReaderWriterOptions.Json) + RawRepresentationFactory = _ => new CreateResponseOptions + { + ConversationOptions = new("conv_12345"), + } }); Assert.NotNull(response); @@ -5146,7 +5130,6 @@ public async Task HostedImageGenerationTool_NonStreaming() "output_format": "png" } ], - "tool_choice": "auto", "input": [ { "type": "message", @@ -5240,7 +5223,6 @@ public async Task HostedImageGenerationTool_Streaming() "output_format": "png" } ], - "tool_choice": "auto", "stream": true, "input": [ { @@ -5352,7 +5334,6 @@ public async Task HostedImageGenerationTool_StreamingMultipleImages() "partial_images": 3 } ], - "tool_choice": "auto", "stream": true, "input": [ { @@ -5486,7 +5467,7 @@ private static IChatClient CreateResponseClient(HttpClient httpClient, string mo new OpenAIClient( new ApiKeyCredential("apikey"), new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(httpClient) }) - .GetOpenAIResponseClient(modelId) + .GetResponsesClient(modelId) .AsIChatClient(); private static string ResponseStatusToRequestValue(ResponseStatus status) diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Program.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Program.cs index d7e25135462..4bfb1ca7796 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Program.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb.OpenAI_AzureAISearch.verified/aichatweb/Program.cs @@ -17,8 +17,8 @@ var openAIClient = new OpenAIClient( new ApiKeyCredential(builder.Configuration["OpenAI:Key"] ?? throw new InvalidOperationException("Missing configuration: OpenAI:Key. See the README for details."))); -#pragma warning disable OPENAI001 // GetOpenAIResponseClient(string) is experimental and subject to change or removal in future updates. -var chatClient = openAIClient.GetOpenAIResponseClient("gpt-4o-mini").AsIChatClient(); +#pragma warning disable OPENAI001 // GetResponsesClient(string) is experimental and subject to change or removal in future updates. +var chatClient = openAIClient.GetResponsesClient("gpt-4o-mini").AsIChatClient(); #pragma warning restore OPENAI001 var embeddingGenerator = openAIClient.GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator();