From 49acb38fae118e30126bbfa7a7e41a5a21a78f4a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Feb 2026 20:43:22 +0000 Subject: [PATCH 1/3] Initial plan From 8af00d8921e8e537cda5ba07eb8331565a64c747 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Feb 2026 21:02:58 +0000 Subject: [PATCH 2/3] Add openai.api.type tag to OpenAI IChatClient implementations Implements the OpenTelemetry semantic convention for distinguishing OpenAI API types (open-telemetry/semantic-conventions#3337). - OpenAIChatClient adds openai.api.type=chat_completions - OpenAIResponsesChatClient adds openai.api.type=responses - Both check Activity.Current for a matching "chat" span from OpenTelemetryChatClient - Added tests for both streaming and non-streaming scenarios Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../OpenAIChatClient.cs | 32 +++++++++ .../OpenAIResponsesChatClient.cs | 31 ++++++++ .../OpenAIChatClientTests.cs | 65 +++++++++++++++++ .../OpenAIResponseClientTests.cs | 70 +++++++++++++++++++ 4 files changed, 198 insertions(+) diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index bd583018bc8..83cc8a89ab4 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -5,6 +5,7 @@ using System.ClientModel; using System.ClientModel.Primitives; using System.Collections.Generic; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection; @@ -87,6 +88,8 @@ public async Task GetResponseAsync( { _ = Throw.IfNull(messages); + AddOpenAIApiType(OpenAIApiTypeChatCompletions); + var openAIChatMessages = ToOpenAIChatMessages(messages, options); var openAIOptions = ToOpenAIOptions(options); @@ -106,6 +109,8 @@ public IAsyncEnumerable GetStreamingResponseAsync( { _ = Throw.IfNull(messages); + AddOpenAIApiType(OpenAIApiTypeChatCompletions); + var openAIChatMessages = ToOpenAIChatMessages(messages, options); var openAIOptions = ToOpenAIOptions(options); @@ -854,4 +859,31 @@ private static bool TryGetReasoningMessage(ChatCompletion completion, [NotNullWh private static Regex InvalidAuthorNameRegex() => _invalidAuthorNameRegex; private static readonly Regex _invalidAuthorNameRegex = new(InvalidAuthorNamePattern, RegexOptions.Compiled); #endif + + /// The "openai.api.type" tag name per the OpenTelemetry semantic conventions for OpenAI. + private const string OpenAIApiTypeTag = "openai.api.type"; + + /// The "chat_completions" value for the "openai.api.type" tag. + private const string OpenAIApiTypeChatCompletions = "chat_completions"; + + /// The "chat" operation name used by the OpenTelemetry chat client. + private const string ChatOperationName = "chat"; + + /// + /// If the current represents a "chat" operation span, + /// adds the "openai.api.type" tag with the specified value. + /// + private static void AddOpenAIApiType(string apiType) + { + Activity? activity = Activity.Current; + if (activity is { IsAllDataRequested: true }) + { + string name = activity.DisplayName; + if (name.StartsWith(ChatOperationName, StringComparison.Ordinal) && + (name.Length == ChatOperationName.Length || name[ChatOperationName.Length] == ' ')) + { + _ = activity.AddTag(OpenAIApiTypeTag, apiType); + } + } + } } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs index af13a52328d..6a4339e24b6 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -92,6 +92,8 @@ public async Task GetResponseAsync( { _ = Throw.IfNull(messages); + AddOpenAIApiType(OpenAIApiTypeResponses); + // Convert the inputs into what ResponsesClient expects. var openAIOptions = AsCreateResponseOptions(options, out string? openAIConversationId); @@ -260,6 +262,8 @@ public IAsyncEnumerable GetStreamingResponseAsync( { _ = Throw.IfNull(messages); + AddOpenAIApiType(OpenAIApiTypeResponses); + var openAIOptions = AsCreateResponseOptions(options, out string? openAIConversationId); openAIOptions.StreamingEnabled = true; @@ -1515,6 +1519,33 @@ private static ImageGenerationToolResultContent GetImageGenerationResult(Streami return null; } + /// The "openai.api.type" tag name per the OpenTelemetry semantic conventions for OpenAI. + private const string OpenAIApiTypeTag = "openai.api.type"; + + /// The "responses" value for the "openai.api.type" tag. + private const string OpenAIApiTypeResponses = "responses"; + + /// The "chat" operation name used by the OpenTelemetry chat client. + private const string ChatOperationName = "chat"; + + /// + /// If the current represents a "chat" operation span, + /// adds the "openai.api.type" tag with the specified value. + /// + private static void AddOpenAIApiType(string apiType) + { + Activity? activity = Activity.Current; + if (activity is { IsAllDataRequested: true }) + { + string name = activity.DisplayName; + if (name.StartsWith(ChatOperationName, StringComparison.Ordinal) && + (name.Length == ChatOperationName.Length || name[ChatOperationName.Length] == ' ')) + { + _ = activity.AddTag(OpenAIApiTypeTag, apiType); + } + } + } + /// Provides an wrapper for a . internal sealed class ResponseToolAITool(ResponseTool tool) : AITool { diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs index 3de603cf809..60609887b23 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs @@ -6,6 +6,7 @@ using System.ClientModel.Primitives; using System.Collections.Generic; using System.ComponentModel; +using System.Diagnostics; using System.Linq; using System.Net.Http; using System.Threading.Tasks; @@ -2010,4 +2011,68 @@ public async Task ReasoningContent_Streaming_SurfacedAsTextReasoningContent() // Verify regular content was also captured from the content deltas Assert.Equal("9.8 is larger.", string.Concat(updates.Select(u => u.Text))); } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task OpenAIApiTypeTag_SetToChatCompletions(bool streaming) + { + const string Output = """ + { + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 1727888631, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Hello!" + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 8, + "completion_tokens": 2, + "total_tokens": 10 + } + } + """; + + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == sourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStopped = activity => activities.Add(activity), + }; + ActivitySource.AddActivityListener(listener); + + using VerbatimHttpHandler handler = new(new HttpHandlerExpectedInput(), Output); + using HttpClient httpClient = new(handler); + using IChatClient client = new OpenAIClient(new ApiKeyCredential("apikey"), new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(httpClient) }) + .GetChatClient("gpt-4o-mini") + .AsIChatClient() + .AsBuilder() + .UseOpenTelemetry(sourceName: sourceName) + .Build(); + + if (streaming) + { + await foreach (var update in client.GetStreamingResponseAsync("hello")) + { + // Drain the stream. + } + } + else + { + await client.GetResponseAsync("hello"); + } + + var activity = Assert.Single(activities); + Assert.Equal("chat_completions", activity.GetTagItem("openai.api.type")); + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs index abd1414d85f..5d254b4329b 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs @@ -6,6 +6,7 @@ using System.ClientModel.Primitives; using System.Collections.Generic; using System.ComponentModel; +using System.Diagnostics; using System.IO; using System.Linq; using System.Net.Http; @@ -6010,6 +6011,75 @@ public async Task ReasoningOptions_NoneValues_ProducesNoReasoningInJson() })); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task OpenAIApiTypeTag_SetToResponses(bool streaming) + { + const string Output = """ + { + "id": "resp_test", + "object": "response", + "created_at": 1741891428, + "status": "completed", + "model": "gpt-4o-mini", + "output": [ + { + "id": "msg_test", + "type": "message", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "Hello!" + } + ] + } + ], + "usage": { + "input_tokens": 8, + "output_tokens": 2, + "total_tokens": 10 + } + } + """; + + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == sourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStopped = activity => activities.Add(activity), + }; + ActivitySource.AddActivityListener(listener); + + using VerbatimHttpHandler handler = new(new HttpHandlerExpectedInput(), Output); + using HttpClient httpClient = new(handler); + using IChatClient client = new OpenAIClient(new ApiKeyCredential("apikey"), new OpenAIClientOptions { Transport = new HttpClientPipelineTransport(httpClient) }) + .GetResponsesClient("gpt-4o-mini") + .AsIChatClient() + .AsBuilder() + .UseOpenTelemetry(sourceName: sourceName) + .Build(); + + if (streaming) + { + await foreach (var update in client.GetStreamingResponseAsync("hello")) + { + // Drain the stream. + } + } + else + { + await client.GetResponseAsync("hello"); + } + + var activity = Assert.Single(activities); + Assert.Equal("responses", activity.GetTagItem("openai.api.type")); + } + private static IChatClient CreateResponseClient(HttpClient httpClient, string modelId) => new OpenAIClient( new ApiKeyCredential("apikey"), From c2b880a463dce89183875bc191d9a234019e615f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Feb 2026 21:09:33 +0000 Subject: [PATCH 3/3] Refactor: extract shared AddOpenAIApiType helper to OpenAIClientExtensions Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../OpenAIChatClient.cs | 32 ++----------------- .../OpenAIClientExtensions.cs | 31 ++++++++++++++++++ .../OpenAIResponsesChatClient.cs | 31 ++---------------- 3 files changed, 35 insertions(+), 59 deletions(-) diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs index 83cc8a89ab4..cf0b30b0e89 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs @@ -5,7 +5,6 @@ using System.ClientModel; using System.ClientModel.Primitives; using System.Collections.Generic; -using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection; @@ -88,7 +87,7 @@ public async Task GetResponseAsync( { _ = Throw.IfNull(messages); - AddOpenAIApiType(OpenAIApiTypeChatCompletions); + OpenAIClientExtensions.AddOpenAIApiType(OpenAIClientExtensions.OpenAIApiTypeChatCompletions); var openAIChatMessages = ToOpenAIChatMessages(messages, options); var openAIOptions = ToOpenAIOptions(options); @@ -109,7 +108,7 @@ public IAsyncEnumerable GetStreamingResponseAsync( { _ = Throw.IfNull(messages); - AddOpenAIApiType(OpenAIApiTypeChatCompletions); + OpenAIClientExtensions.AddOpenAIApiType(OpenAIClientExtensions.OpenAIApiTypeChatCompletions); var openAIChatMessages = ToOpenAIChatMessages(messages, options); var openAIOptions = ToOpenAIOptions(options); @@ -859,31 +858,4 @@ private static bool TryGetReasoningMessage(ChatCompletion completion, [NotNullWh private static Regex InvalidAuthorNameRegex() => _invalidAuthorNameRegex; private static readonly Regex _invalidAuthorNameRegex = new(InvalidAuthorNamePattern, RegexOptions.Compiled); #endif - - /// The "openai.api.type" tag name per the OpenTelemetry semantic conventions for OpenAI. - private const string OpenAIApiTypeTag = "openai.api.type"; - - /// The "chat_completions" value for the "openai.api.type" tag. - private const string OpenAIApiTypeChatCompletions = "chat_completions"; - - /// The "chat" operation name used by the OpenTelemetry chat client. - private const string ChatOperationName = "chat"; - - /// - /// If the current represents a "chat" operation span, - /// adds the "openai.api.type" tag with the specified value. - /// - private static void AddOpenAIApiType(string apiType) - { - Activity? activity = Activity.Current; - if (activity is { IsAllDataRequested: true }) - { - string name = activity.DisplayName; - if (name.StartsWith(ChatOperationName, StringComparison.Ordinal) && - (name.Length == ChatOperationName.Length || name[ChatOperationName.Length] == ' ')) - { - _ = activity.AddTag(OpenAIApiTypeTag, apiType); - } - } - } } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs index 19c82b16901..e4008e4380f 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs @@ -4,6 +4,7 @@ using System; using System.ClientModel.Primitives; using System.Collections.Generic; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Net.Mime; using System.Text; @@ -279,4 +280,34 @@ internal sealed class ToolJson [JsonExtensionData] public Dictionary? ExtensionData { get; set; } } + + /// The "openai.api.type" tag name per the OpenTelemetry semantic conventions for OpenAI. + internal const string OpenAIApiTypeTag = "openai.api.type"; + + /// The "chat_completions" value for the "openai.api.type" tag. + internal const string OpenAIApiTypeChatCompletions = "chat_completions"; + + /// The "responses" value for the "openai.api.type" tag. + internal const string OpenAIApiTypeResponses = "responses"; + + /// The "chat" operation name used by the OpenTelemetry chat client. + private const string ChatOperationName = "chat"; + + /// + /// If the current represents a "chat" operation span, + /// adds the "openai.api.type" tag with the specified value. + /// + internal static void AddOpenAIApiType(string apiType) + { + Activity? activity = Activity.Current; + if (activity is { IsAllDataRequested: true }) + { + string name = activity.DisplayName; + if (name.StartsWith(ChatOperationName, StringComparison.Ordinal) && + (name.Length == ChatOperationName.Length || name[ChatOperationName.Length] == ' ')) + { + _ = activity.AddTag(OpenAIApiTypeTag, apiType); + } + } + } } diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs index 6a4339e24b6..5813326fe68 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -92,7 +92,7 @@ public async Task GetResponseAsync( { _ = Throw.IfNull(messages); - AddOpenAIApiType(OpenAIApiTypeResponses); + OpenAIClientExtensions.AddOpenAIApiType(OpenAIClientExtensions.OpenAIApiTypeResponses); // Convert the inputs into what ResponsesClient expects. var openAIOptions = AsCreateResponseOptions(options, out string? openAIConversationId); @@ -262,7 +262,7 @@ public IAsyncEnumerable GetStreamingResponseAsync( { _ = Throw.IfNull(messages); - AddOpenAIApiType(OpenAIApiTypeResponses); + OpenAIClientExtensions.AddOpenAIApiType(OpenAIClientExtensions.OpenAIApiTypeResponses); var openAIOptions = AsCreateResponseOptions(options, out string? openAIConversationId); openAIOptions.StreamingEnabled = true; @@ -1519,33 +1519,6 @@ private static ImageGenerationToolResultContent GetImageGenerationResult(Streami return null; } - /// The "openai.api.type" tag name per the OpenTelemetry semantic conventions for OpenAI. - private const string OpenAIApiTypeTag = "openai.api.type"; - - /// The "responses" value for the "openai.api.type" tag. - private const string OpenAIApiTypeResponses = "responses"; - - /// The "chat" operation name used by the OpenTelemetry chat client. - private const string ChatOperationName = "chat"; - - /// - /// If the current represents a "chat" operation span, - /// adds the "openai.api.type" tag with the specified value. - /// - private static void AddOpenAIApiType(string apiType) - { - Activity? activity = Activity.Current; - if (activity is { IsAllDataRequested: true }) - { - string name = activity.DisplayName; - if (name.StartsWith(ChatOperationName, StringComparison.Ordinal) && - (name.Length == ChatOperationName.Length || name[ChatOperationName.Length] == ' ')) - { - _ = activity.AddTag(OpenAIApiTypeTag, apiType); - } - } - } - /// Provides an wrapper for a . internal sealed class ResponseToolAITool(ResponseTool tool) : AITool {