From 5f0477ea61ca88ef800ad6f46a85fa64e8ece55a Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Fri, 13 Mar 2026 12:49:53 +0000 Subject: [PATCH 1/3] Add IHostedConversationClient abstraction for hosted conversation lifecycle management Adds a new IHostedConversationClient interface for managing server-side conversations (create, get, delete, add messages, list messages), with full middleware stack (logging, OpenTelemetry, ConfigureOptions, builder, DI registration) and OpenAI implementation wrapping ConversationClient. Key components: - IHostedConversationClient interface with 5 CRUD operations + GetService - HostedConversation, HostedConversationCreationOptions, metadata types - DelegatingHostedConversationClient for middleware chains - HostedConversationClientBuilder with Use()/Build() pipeline - LoggingHostedConversationClient, OpenTelemetryHostedConversationClient, ConfigureOptionsHostedConversationClient middleware - HostedConversationChatClient bridge enabling chatClient.GetService() via UseHostedConversations() builder extension - OpenAIHostedConversationClient wrapping OpenAI Conversations API - RawRepresentation/RawRepresentationFactory escape hatches - Provider mapping report documenting OpenAI, Azure, Bedrock, Gemini, Anthropic, and Ollama support with gap analysis - 63 unit tests across abstractions, middleware, and OpenAI layers --- docs/HostedConversation-ProviderMapping.md | 115 +++++ .../DelegatingHostedConversationClient.cs | 95 ++++ .../HostedConversation/HostedConversation.cs | 35 ++ .../HostedConversationClientExtensions.cs | 82 ++++ .../HostedConversationClientMetadata.cs | 35 ++ .../HostedConversationCreationOptions.cs | 69 +++ .../IHostedConversationClient.cs | 87 ++++ .../OpenAIClientExtensions.cs | 10 + .../OpenAIHostedConversationClient.cs | 444 ++++++++++++++++++ ...onfigureOptionsHostedConversationClient.cs | 53 +++ ...stedConversationClientBuilderExtensions.cs | 37 ++ .../HostedConversationChatClient.cs | 47 ++ ...ConversationChatClientBuilderExtensions.cs | 47 ++ .../HostedConversationClientBuilder.cs | 82 ++++ ...lientBuilderServiceCollectionExtensions.cs | 91 ++++ .../LoggingHostedConversationClient.cs | 304 ++++++++++++ ...stedConversationClientBuilderExtensions.cs | 57 +++ .../OpenTelemetryHostedConversationClient.cs | 310 ++++++++++++ ...stedConversationClientBuilderExtensions.cs | 43 ++ src/Shared/DiagnosticIds/DiagnosticIds.cs | 1 + ...DelegatingHostedConversationClientTests.cs | 315 +++++++++++++ .../HostedConversationCreationOptionsTests.cs | 212 +++++++++ .../HostedConversationTests.cs | 99 ++++ .../OpenAIHostedConversationClientTests.cs | 78 +++ ...ureOptionsHostedConversationClientTests.cs | 157 +++++++ .../HostedConversationChatClientTests.cs | 170 +++++++ .../HostedConversationClientBuilderTest.cs | 152 ++++++ .../LoggingHostedConversationClientTests.cs | 205 ++++++++ 28 files changed, 3432 insertions(+) create mode 100644 docs/HostedConversation-ProviderMapping.md create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/DelegatingHostedConversationClient.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/HostedConversation.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/HostedConversationClientExtensions.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/HostedConversationClientMetadata.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/HostedConversationCreationOptions.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/IHostedConversationClient.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIHostedConversationClient.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/HostedConversation/ConfigureOptionsHostedConversationClient.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/HostedConversation/ConfigureOptionsHostedConversationClientBuilderExtensions.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/HostedConversation/HostedConversationChatClient.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/HostedConversation/HostedConversationChatClientBuilderExtensions.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/HostedConversation/HostedConversationClientBuilder.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/HostedConversation/HostedConversationClientBuilderServiceCollectionExtensions.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/HostedConversation/LoggingHostedConversationClient.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/HostedConversation/LoggingHostedConversationClientBuilderExtensions.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/HostedConversation/OpenTelemetryHostedConversationClient.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI/HostedConversation/OpenTelemetryHostedConversationClientBuilderExtensions.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedConversation/DelegatingHostedConversationClientTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedConversation/HostedConversationCreationOptionsTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedConversation/HostedConversationTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIHostedConversationClientTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Tests/HostedConversation/ConfigureOptionsHostedConversationClientTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Tests/HostedConversation/HostedConversationChatClientTests.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Tests/HostedConversation/HostedConversationClientBuilderTest.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Tests/HostedConversation/LoggingHostedConversationClientTests.cs diff --git a/docs/HostedConversation-ProviderMapping.md b/docs/HostedConversation-ProviderMapping.md new file mode 100644 index 00000000000..91d2b129a52 --- /dev/null +++ b/docs/HostedConversation-ProviderMapping.md @@ -0,0 +1,115 @@ +# IHostedConversationClient — Provider Mapping Report + +## Overview + +`IHostedConversationClient` is an abstraction for managing server-side (hosted) conversation state across AI providers. It provides a common interface for creating, retrieving, deleting, and managing messages within persistent conversations, decoupling application code from provider-specific conversation/thread/session APIs. Each provider maps these operations to its native primitives, with escape hatches (`RawRepresentation`, `RawRepresentationFactory`, `AdditionalProperties`) for accessing provider-specific features. + +## Interface Operations + +| Operation | Description | Return Type | +|-----------|-------------|-------------| +| `CreateAsync` | Creates a new hosted conversation | `HostedConversation` | +| `GetAsync` | Retrieves conversation by ID | `HostedConversation` | +| `DeleteAsync` | Deletes a conversation | `void` (Task) | +| `AddMessagesAsync` | Adds messages to a conversation | `void` (Task) | +| `GetMessagesAsync` | Lists messages in a conversation | `IAsyncEnumerable` | + +## Provider Mapping + +### OpenAI (Implemented) + +- Maps to `ConversationClient` in `OpenAI.Conversations` namespace +- Full CRUD support via protocol-level APIs +- `RawRepresentation` set to `ClientResult` objects +- ConversationId integrates with `ChatOptions.ConversationId` for inference via `OpenAIResponsesChatClient` +- Metadata limited to 16 key-value pairs (max 64 char keys, 512 char values) + +### Azure AI Foundry + +- Maps to Thread/Message APIs in Agent Service SDK +- `CreateAsync` → `threads.create()` +- `GetAsync` → `threads.get()` +- `DeleteAsync` → `threads.delete()` +- `AddMessagesAsync` → `messages.create()` (one per message) +- `GetMessagesAsync` → `messages.list()` +- **Gaps**: Thread model includes Run/Agent concepts not in our abstraction; use `AdditionalProperties` for agent-specific metadata + +### AWS Bedrock + +- Maps to Session Management APIs +- `CreateAsync` → `CreateSession` with optional encryption/metadata +- `GetAsync` → `GetSession` +- `DeleteAsync` → `DeleteSession` +- `AddMessagesAsync` → `PutInvocationStep` (different item model) +- `GetMessagesAsync` → `GetInvocationSteps` (requires translation) +- **Gaps**: Session status (ACTIVE/EXPIRED/ENDED) not in abstraction; encryption config is provider-specific; use `AdditionalProperties` or `RawRepresentationFactory` + +### Google Gemini + +- Maps to Interactions API +- `CreateAsync` → `interactions.create()` (creates an interaction, not a "conversation" per se) +- `GetAsync` → `interactions.get()` +- `DeleteAsync` → `interactions.delete()` +- `AddMessagesAsync` → No direct equivalent; use `interactions.create()` with `previous_interaction_id` chain +- `GetMessagesAsync` → `interactions.get().outputs` (retrieves outputs, not full message history) +- **Gaps**: Interactions are individual turns, not conversation containers. AddMessages requires creating new interactions chained via `previous_interaction_id`. Provider adapter would need to manage this mapping. + +### Anthropic + +- **No native conversation CRUD API** — requires local adapter +- Server-side features that CAN assist: + - **Prompt Caching** (`cache_control`): Stores KV cache of message prefixes (5min/1hr TTL). Adapter should auto-apply cache breakpoints. + - **Context Compaction** (beta): Server-side summarization when conversations exceed token threshold + - **Files API** (beta): Store documents server-side for reference across requests + - **Containers** (beta): Server-side execution state with reusable IDs +- Implementation approach: `LocalHostedConversationClient` using local storage (in-memory, SQLite, Redis) with automatic prompt caching optimization +- **Gaps**: All operations are simulated client-side. No server-side conversation persistence. + +### Ollama / Local Models + +- **No server-side state** at all +- Implementation: Same local adapter pattern as Anthropic but without prompt caching optimization +- **Gaps**: Same as Anthropic — entirely client-side simulation + +## Escape Hatches for Provider-Specific Features + +### RawRepresentation + +Every `HostedConversation` response carries `RawRepresentation` (the underlying provider object). This gives access to 100% of provider functionality: + +```csharp +var conversation = await client.CreateAsync(); +var openAIResult = (ClientResult)conversation.RawRepresentation; // Access any OpenAI-specific data +``` + +### RawRepresentationFactory + +`HostedConversationCreationOptions.RawRepresentationFactory` allows passing provider-specific creation options: + +```csharp +var options = new HostedConversationCreationOptions +{ + RawRepresentationFactory = client => new ConversationCreationOptions + { + // Any provider-specific settings + } +}; +``` + +### AdditionalProperties + +`HostedConversation.AdditionalProperties` and `HostedConversationCreationOptions.AdditionalProperties` carry provider-specific data that doesn't fit the common abstraction. + +## Feature Coverage Matrix + +| Feature | OpenAI | Azure | Bedrock | Gemini | Anthropic | Ollama | +|---------|--------|-------|---------|--------|-----------|--------| +| Create | ✅ Native | ✅ Native | ✅ Native | ✅ Native | ⚠️ Local | ⚠️ Local | +| Get | ✅ Native | ✅ Native | ✅ Native | ✅ Native | ⚠️ Local | ⚠️ Local | +| Delete | ✅ Native | ✅ Native | ✅ Native | ✅ Native | ⚠️ Local | ⚠️ Local | +| AddMessages | ✅ Native | ✅ Native | ⚠️ Translated | ⚠️ Chained | ⚠️ Local | ⚠️ Local | +| GetMessages | ✅ Native | ✅ Native | ⚠️ Translated | ⚠️ Partial | ⚠️ Local | ⚠️ Local | +| Metadata | ✅ 16 KV | ✅ | ✅ | ✅ | ⚠️ Local | ⚠️ Local | +| RawRepresentation | ✅ ClientResult | ✅ AgentThread | ✅ Session | ✅ Interaction | N/A | N/A | + +Legend: ✅ = Direct mapping, ⚠️ = Requires translation/local adapter diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/DelegatingHostedConversationClient.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/DelegatingHostedConversationClient.cs new file mode 100644 index 00000000000..b25287da449 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/DelegatingHostedConversationClient.cs @@ -0,0 +1,95 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Provides an optional base class for an that passes through calls to another instance. +/// +/// +/// This is recommended as a base type when building clients that can be chained around an underlying . +/// The default implementation simply passes each call to the inner client instance. +/// +[Experimental(DiagnosticIds.Experiments.AIHostedConversation, UrlFormat = DiagnosticIds.UrlFormat)] +public class DelegatingHostedConversationClient : IHostedConversationClient +{ + /// + /// Initializes a new instance of the class. + /// + /// The wrapped client instance. + /// is . + protected DelegatingHostedConversationClient(IHostedConversationClient innerClient) + { + InnerClient = Throw.IfNull(innerClient); + } + + /// + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + /// Gets the inner . + protected IHostedConversationClient InnerClient { get; } + + /// + public virtual Task CreateAsync( + HostedConversationCreationOptions? options = null, + CancellationToken cancellationToken = default) => + InnerClient.CreateAsync(options, cancellationToken); + + /// + public virtual Task GetAsync( + string conversationId, + CancellationToken cancellationToken = default) => + InnerClient.GetAsync(conversationId, cancellationToken); + + /// + public virtual Task DeleteAsync( + string conversationId, + CancellationToken cancellationToken = default) => + InnerClient.DeleteAsync(conversationId, cancellationToken); + + /// + public virtual Task AddMessagesAsync( + string conversationId, + IEnumerable messages, + CancellationToken cancellationToken = default) => + InnerClient.AddMessagesAsync(conversationId, messages, cancellationToken); + + /// + public virtual IAsyncEnumerable GetMessagesAsync( + string conversationId, + CancellationToken cancellationToken = default) => + InnerClient.GetMessagesAsync(conversationId, cancellationToken); + + /// + public virtual object? GetService(Type serviceType, object? serviceKey = null) + { + _ = Throw.IfNull(serviceType); + + // If the key is non-null, we don't know what it means so pass through to the inner service. + return + serviceKey is null && serviceType.IsInstanceOfType(this) ? this : + InnerClient.GetService(serviceType, serviceKey); + } + + /// Provides a mechanism for releasing unmanaged resources. + /// if being called from ; otherwise, . + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + InnerClient.Dispose(); + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/HostedConversation.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/HostedConversation.cs new file mode 100644 index 00000000000..000d923d790 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/HostedConversation.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Extensions.AI; + +/// Represents a hosted conversation. +[Experimental(DiagnosticIds.Experiments.AIHostedConversation, UrlFormat = DiagnosticIds.UrlFormat)] +public class HostedConversation +{ + /// Gets or sets the conversation identifier. + public string? ConversationId { get; set; } + + /// Gets or sets the creation timestamp. + public DateTimeOffset? CreatedAt { get; set; } + + /// Gets or sets metadata associated with the conversation. + public AdditionalPropertiesDictionary? Metadata { get; set; } + + /// Gets or sets the raw representation of the conversation from the underlying provider. + /// + /// If a is created to represent some underlying object from another object + /// model, this property can be used to store that original object. This can be useful for debugging or + /// for enabling a consumer to access the underlying object model if needed. + /// + [JsonIgnore] + public object? RawRepresentation { get; set; } + + /// Gets or sets any additional properties associated with the conversation. + public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/HostedConversationClientExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/HostedConversationClientExtensions.cs new file mode 100644 index 00000000000..0e783303d3a --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/HostedConversationClientExtensions.cs @@ -0,0 +1,82 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides a collection of static methods for extending instances. +[Experimental(DiagnosticIds.Experiments.AIHostedConversation, UrlFormat = DiagnosticIds.UrlFormat)] +public static class HostedConversationClientExtensions +{ + /// Asks the for an object of type . + /// The type of the object to be retrieved. + /// The client. + /// An optional key that can be used to help identify the target service. + /// The found object, otherwise . + /// is . + /// + /// The purpose of this method is to allow for the retrieval of strongly typed services that may be provided by the , + /// including itself or any services it might be wrapping. + /// + public static TService? GetService(this IHostedConversationClient client, object? serviceKey = null) + { + _ = Throw.IfNull(client); + + return client.GetService(typeof(TService), serviceKey) is TService service ? service : default; + } + + /// + /// Asks the for an object of the specified type + /// and throws an exception if one isn't available. + /// + /// The client. + /// The type of object being requested. + /// An optional key that can be used to help identify the target service. + /// The found object. + /// is . + /// is . + /// No service of the requested type for the specified key is available. + /// + /// The purpose of this method is to allow for the retrieval of services that are required to be provided by the , + /// including itself or any services it might be wrapping. + /// + public static object GetRequiredService(this IHostedConversationClient client, Type serviceType, object? serviceKey = null) + { + _ = Throw.IfNull(client); + _ = Throw.IfNull(serviceType); + + return + client.GetService(serviceType, serviceKey) ?? + throw Throw.CreateMissingServiceException(serviceType, serviceKey); + } + + /// + /// Asks the for an object of type + /// and throws an exception if one isn't available. + /// + /// The type of the object to be retrieved. + /// The client. + /// An optional key that can be used to help identify the target service. + /// The found object. + /// is . + /// No service of the requested type for the specified key is available. + /// + /// The purpose of this method is to allow for the retrieval of strongly typed services that are required to be provided by the , + /// including itself or any services it might be wrapping. + /// + public static TService GetRequiredService(this IHostedConversationClient client, object? serviceKey = null) + { + _ = Throw.IfNull(client); + + if (client.GetService(typeof(TService), serviceKey) is not TService service) + { + throw Throw.CreateMissingServiceException(typeof(TService), serviceKey); + } + + return service; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/HostedConversationClientMetadata.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/HostedConversationClientMetadata.cs new file mode 100644 index 00000000000..5c746dd2c9c --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/HostedConversationClientMetadata.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Extensions.AI; + +/// Provides metadata about an . +[Experimental(DiagnosticIds.Experiments.AIHostedConversation, UrlFormat = DiagnosticIds.UrlFormat)] +public class HostedConversationClientMetadata +{ + /// Initializes a new instance of the class. + /// + /// The name of the hosted conversation provider, if applicable. Where possible, this should map to the + /// appropriate name defined in the OpenTelemetry Semantic Conventions for Generative AI systems. + /// + /// The URL for accessing the hosted conversation provider, if applicable. + public HostedConversationClientMetadata(string? providerName = null, Uri? providerUri = null) + { + ProviderName = providerName; + ProviderUri = providerUri; + } + + /// Gets the name of the hosted conversation provider. + /// + /// Where possible, this maps to the appropriate name defined in the + /// OpenTelemetry Semantic Conventions for Generative AI systems. + /// + public string? ProviderName { get; } + + /// Gets the URL for accessing the hosted conversation provider. + public Uri? ProviderUri { get; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/HostedConversationCreationOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/HostedConversationCreationOptions.cs new file mode 100644 index 00000000000..ce719d584c6 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/HostedConversationCreationOptions.cs @@ -0,0 +1,69 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Extensions.AI; + +/// Represents the options for creating a hosted conversation. +[Experimental(DiagnosticIds.Experiments.AIHostedConversation, UrlFormat = DiagnosticIds.UrlFormat)] +public class HostedConversationCreationOptions +{ + /// Initializes a new instance of the class. + public HostedConversationCreationOptions() + { + } + + /// Initializes a new instance of the class, performing a shallow copy of all properties from . + protected HostedConversationCreationOptions(HostedConversationCreationOptions? other) + { + if (other is null) + { + return; + } + + AdditionalProperties = other.AdditionalProperties?.Clone(); + Metadata = other.Metadata is not null ? new(other.Metadata) : null; + RawRepresentationFactory = other.RawRepresentationFactory; + + if (other.Messages is not null) + { + Messages = [.. other.Messages]; + } + } + + /// Gets or sets metadata to associate with the conversation. + public AdditionalPropertiesDictionary? Metadata { get; set; } + + /// Gets or sets initial messages to populate the conversation. + public IList? Messages { get; set; } + + /// + /// Gets or sets a callback responsible for creating the raw representation of the conversation creation options from an underlying implementation. + /// + /// + /// The underlying implementation may have its own representation of options. + /// When operations are invoked with a , + /// that implementation may convert the provided options into its own representation in order to use it while performing the operation. + /// For situations where a consumer knows which concrete is being used and how it represents options, + /// a new instance of that implementation-specific options type may be returned by this callback, for the + /// implementation to use instead of creating a new instance. Such implementations may mutate the supplied options + /// instance further based on other settings supplied on this instance or from other inputs, + /// therefore, it is strongly recommended to not return shared instances and instead make the callback return a new instance on each call. + /// This is typically used to set an implementation-specific setting that isn't otherwise exposed from the strongly typed + /// properties on . + /// + [JsonIgnore] + public Func? RawRepresentationFactory { get; set; } + + /// Gets or sets any additional properties associated with the options. + public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } + + /// Produces a clone of the current instance. + /// A clone of the current instance. + public virtual HostedConversationCreationOptions Clone() => new(this); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/IHostedConversationClient.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/IHostedConversationClient.cs new file mode 100644 index 00000000000..cc99cff0fae --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/IHostedConversationClient.cs @@ -0,0 +1,87 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Extensions.AI; + +/// Represents a client for managing hosted conversations. +/// +/// +/// Unless otherwise specified, all members of are thread-safe for concurrent use. +/// It is expected that all implementations of support being used by multiple requests concurrently. +/// +/// +/// However, implementations of might mutate the arguments supplied to +/// and , such as by configuring the options or messages instances. +/// Thus, consumers of the interface either should avoid using shared instances of these arguments for concurrent +/// invocations or should otherwise ensure by construction that no instances +/// are used which might employ such mutation. +/// +/// +[Experimental(DiagnosticIds.Experiments.AIHostedConversation, UrlFormat = DiagnosticIds.UrlFormat)] +public interface IHostedConversationClient : IDisposable +{ + /// Creates a new hosted conversation. + /// The options to configure the conversation creation. + /// The to monitor for cancellation requests. The default is . + /// The created . + Task CreateAsync( + HostedConversationCreationOptions? options = null, + CancellationToken cancellationToken = default); + + /// Retrieves an existing hosted conversation by its identifier. + /// The unique identifier of the conversation to retrieve. + /// The to monitor for cancellation requests. The default is . + /// The matching the specified . + /// is . + Task GetAsync( + string conversationId, + CancellationToken cancellationToken = default); + + /// Deletes an existing hosted conversation. + /// The unique identifier of the conversation to delete. + /// The to monitor for cancellation requests. The default is . + /// A representing the asynchronous operation. + /// is . + Task DeleteAsync( + string conversationId, + CancellationToken cancellationToken = default); + + /// Adds messages to an existing hosted conversation. + /// The unique identifier of the conversation to add messages to. + /// The sequence of chat messages to add to the conversation. + /// The to monitor for cancellation requests. The default is . + /// A representing the asynchronous operation. + /// is . + /// is . + Task AddMessagesAsync( + string conversationId, + IEnumerable messages, + CancellationToken cancellationToken = default); + + /// Lists the messages in an existing hosted conversation. + /// The unique identifier of the conversation to list messages from. + /// The to monitor for cancellation requests. The default is . + /// An asynchronous sequence of instances from the conversation. + /// is . + IAsyncEnumerable GetMessagesAsync( + string conversationId, + CancellationToken cancellationToken = default); + + /// Asks the for an object of the specified type . + /// The type of object being requested. + /// An optional key that can be used to help identify the target service. + /// The found object, otherwise . + /// is . + /// + /// The purpose of this method is to allow for the retrieval of strongly typed services that might be provided by the , + /// including itself or any services it might be wrapping. + /// + object? GetService(Type serviceType, object? serviceKey = null); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs index 9a5ebd0d06a..85220bf8cc8 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs @@ -12,11 +12,13 @@ using System.Text.Json.Nodes; using System.Text.Json.Serialization; using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; using OpenAI; using OpenAI.Assistants; using OpenAI.Audio; using OpenAI.Chat; using OpenAI.Containers; +using OpenAI.Conversations; using OpenAI.Embeddings; using OpenAI.Files; using OpenAI.Images; @@ -230,6 +232,14 @@ public static IHostedFileClient AsIHostedFileClient(this OpenAIFileClient fileCl public static IHostedFileClient AsIHostedFileClient(this ContainerClient containerClient, string? defaultScope = null) => new OpenAIHostedFileClient(containerClient, defaultScope); + /// Gets an for use with this . + /// The client. + /// An that can be used to manage hosted conversations via the . + /// is . + [Experimental(DiagnosticIds.Experiments.AIHostedConversation, UrlFormat = DiagnosticIds.UrlFormat)] + public static IHostedConversationClient AsIHostedConversationClient(this ConversationClient conversationClient) => + new OpenAIHostedConversationClient(Throw.IfNull(conversationClient)); + /// Gets whether the properties specify that strict schema handling is desired. internal static bool? HasStrict(IReadOnlyDictionary? additionalProperties) => additionalProperties?.TryGetValue(StrictKey, out object? strictObj) is true && diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIHostedConversationClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIHostedConversationClient.cs new file mode 100644 index 00000000000..a004bd167e4 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIHostedConversationClient.cs @@ -0,0 +1,444 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; +using OpenAI.Conversations; + +#pragma warning disable S1006 // Add the default parameter value defined in the overridden method +#pragma warning disable S3254 // Remove this default value assigned to parameter + +namespace Microsoft.Extensions.AI; + +/// Represents an for an OpenAI . +[Experimental(DiagnosticIds.Experiments.AIHostedConversation, UrlFormat = DiagnosticIds.UrlFormat)] +internal sealed class OpenAIHostedConversationClient : IHostedConversationClient +{ + /// Metadata about the client. + private readonly HostedConversationClientMetadata _metadata; + + /// The underlying . + private readonly ConversationClient _conversationClient; + + /// Initializes a new instance of the class. + /// The underlying client. + /// is . + public OpenAIHostedConversationClient(ConversationClient conversationClient) + { + _conversationClient = Throw.IfNull(conversationClient); + _metadata = new("openai", conversationClient.Endpoint); + } + + /// + public async Task CreateAsync( + HostedConversationCreationOptions? options = null, + CancellationToken cancellationToken = default) + { + using BinaryContent content = CreateOrGetCreatePayload(options); + RequestOptions requestOptions = cancellationToken.ToRequestOptions(streaming: false); + + ClientResult result = await _conversationClient.CreateConversationAsync(content, requestOptions).ConfigureAwait(false); + + return ParseConversation(result); + } + + /// + public async Task GetAsync( + string conversationId, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(conversationId); + + RequestOptions requestOptions = cancellationToken.ToRequestOptions(streaming: false); + + ClientResult result = await _conversationClient.GetConversationAsync(conversationId, requestOptions).ConfigureAwait(false); + + return ParseConversation(result); + } + + /// + public async Task DeleteAsync( + string conversationId, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(conversationId); + + RequestOptions requestOptions = cancellationToken.ToRequestOptions(streaming: false); + _ = await _conversationClient.DeleteConversationAsync(conversationId, requestOptions).ConfigureAwait(false); + } + + /// + public async Task AddMessagesAsync( + string conversationId, + IEnumerable messages, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(conversationId); + _ = Throw.IfNull(messages); + + using BinaryContent content = CreateBinaryContent(static (writer, msgs) => WriteItemsPayload(writer, msgs), messages); + RequestOptions requestOptions = cancellationToken.ToRequestOptions(streaming: false); + + _ = await _conversationClient.CreateConversationItemsAsync(conversationId, content, null, requestOptions).ConfigureAwait(false); + } + + /// + public async IAsyncEnumerable GetMessagesAsync( + string conversationId, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(conversationId); + + RequestOptions requestOptions = cancellationToken.ToRequestOptions(streaming: false); + + // Manual pagination: the SDK's GetRawPagesAsync() only yields a single page because + // GetContinuationToken returns null in the generated collection result class. + // We loop using the cursor-based 'after' parameter until all items are retrieved. + string? after = null; + + do + { + AsyncCollectionResult pages = _conversationClient.GetConversationItemsAsync( + conversationId, limit: null, order: null, after: after, include: null, options: requestOptions); + + bool hasMore = false; + string? lastId = null; + + await foreach (ClientResult page in pages.GetRawPagesAsync().ConfigureAwait(false)) + { + using JsonDocument doc = JsonDocument.Parse(page.GetRawResponse().Content); + JsonElement root = doc.RootElement; + + if (root.TryGetProperty("has_more", out JsonElement hasMoreElement) && + hasMoreElement.ValueKind == JsonValueKind.True) + { + hasMore = true; + } + + if (root.TryGetProperty("last_id", out JsonElement lastIdElement)) + { + lastId = lastIdElement.GetString(); + } + + if (!root.TryGetProperty("data", out JsonElement dataElement)) + { + continue; + } + + foreach (JsonElement item in dataElement.EnumerateArray()) + { + if (TryConvertItemToChatMessage(item) is { } message) + { + yield return message; + } + } + } + + after = hasMore ? lastId : null; + } + while (after is not null); + } + + /// + public object? GetService(Type serviceType, object? serviceKey = null) + { + _ = Throw.IfNull(serviceType); + + return + serviceKey is not null ? null : + serviceType == typeof(HostedConversationClientMetadata) ? _metadata : + serviceType == typeof(ConversationClient) ? _conversationClient : + serviceType.IsInstanceOfType(this) ? this : + null; + } + + /// Creates a for the create conversation request, using the raw representation factory if available. + private BinaryContent CreateOrGetCreatePayload(HostedConversationCreationOptions? options) + { + if (options?.RawRepresentationFactory?.Invoke(this) is BinaryContent rawContent) + { + return rawContent; + } + + return CreateBinaryContent(static (writer, opts) => WriteCreatePayload(writer, opts), options); + } + + /// + void IDisposable.Dispose() + { + // Nothing to dispose. Implementation required for the IHostedConversationClient interface. + } + + /// Creates a from a JSON writing action. + private static BinaryContent CreateBinaryContent(Action writeAction, TState state) + { + using MemoryStream stream = new(); + using (var writer = new Utf8JsonWriter(stream)) + { + writeAction(writer, state); + } + + return BinaryContent.Create(new BinaryData(stream.ToArray())); + } + + /// Writes the JSON payload for creating a conversation. + private static void WriteCreatePayload(Utf8JsonWriter writer, HostedConversationCreationOptions? options) + { + writer.WriteStartObject(); + + if (options?.Metadata is { Count: > 0 } metadata) + { + writer.WritePropertyName("metadata"); + writer.WriteStartObject(); + foreach (var kvp in metadata) + { + writer.WriteString(kvp.Key, kvp.Value); + } + + writer.WriteEndObject(); + } + + if (options?.Messages is { Count: > 0 } messages) + { + writer.WritePropertyName("items"); + WriteMessagesArray(writer, messages); + } + + writer.WriteEndObject(); + } + + /// Writes the JSON payload for adding items to a conversation. + private static void WriteItemsPayload(Utf8JsonWriter writer, IEnumerable messages) + { + writer.WriteStartObject(); + writer.WritePropertyName("items"); + WriteMessagesArray(writer, messages); + writer.WriteEndObject(); + } + + /// Writes a JSON array of conversation items from a collection of instances. + private static void WriteMessagesArray(Utf8JsonWriter writer, IEnumerable messages) + { + writer.WriteStartArray(); + + foreach (ChatMessage message in messages) + { + string role = message.Role == ChatRole.Assistant ? "assistant" : + message.Role == ChatRole.System ? "system" : + message.Role == OpenAIClientExtensions.ChatRoleDeveloper ? "developer" : + "user"; + + writer.WriteStartObject(); + writer.WriteString("type", "message"); + writer.WriteString("role", role); + writer.WritePropertyName("content"); + writer.WriteStartArray(); + + bool hasContent = false; + foreach (AIContent aiContent in message.Contents) + { + switch (aiContent) + { + case TextContent textContent: + writer.WriteStartObject(); + writer.WriteString("type", message.Role == ChatRole.Assistant ? "output_text" : "input_text"); + writer.WriteString("text", textContent.Text); + writer.WriteEndObject(); + hasContent = true; + break; + + case UriContent uriContent when uriContent.HasTopLevelMediaType("image"): + writer.WriteStartObject(); + writer.WriteString("type", "input_image"); + writer.WriteString("image_url", uriContent.Uri.ToString()); + writer.WriteEndObject(); + hasContent = true; + break; + + case DataContent dataContent when dataContent.HasTopLevelMediaType("image"): + writer.WriteStartObject(); + writer.WriteString("type", "input_image"); + writer.WriteString("image_url", dataContent.Uri); + writer.WriteEndObject(); + hasContent = true; + break; + + case HostedFileContent fileContent: + writer.WriteStartObject(); + if (fileContent.HasTopLevelMediaType("image")) + { + writer.WriteString("type", "input_image"); + writer.WritePropertyName("image_file"); + writer.WriteStartObject(); + writer.WriteString("file_id", fileContent.FileId); + writer.WriteEndObject(); + } + else + { + writer.WriteString("type", "input_file"); + writer.WriteString("file_id", fileContent.FileId); + } + + writer.WriteEndObject(); + hasContent = true; + break; + } + } + + // If no structured content parts were produced, fall back to the text property. + if (!hasContent) + { + string? text = message.Text; + if (text is not null) + { + writer.WriteStartObject(); + writer.WriteString("type", message.Role == ChatRole.Assistant ? "output_text" : "input_text"); + writer.WriteString("text", text); + writer.WriteEndObject(); + } + } + + writer.WriteEndArray(); + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + } + + /// Parses a from a . + private static HostedConversation ParseConversation(ClientResult result) + { + using JsonDocument doc = JsonDocument.Parse(result.GetRawResponse().Content); + JsonElement root = doc.RootElement; + + return new HostedConversation + { + ConversationId = root.TryGetProperty("id", out JsonElement idElement) ? idElement.GetString() : null, + CreatedAt = root.TryGetProperty("created_at", out JsonElement createdAtElement) && createdAtElement.ValueKind == JsonValueKind.Number ? DateTimeOffset.FromUnixTimeSeconds(createdAtElement.GetInt64()) : null, + Metadata = ParseMetadata(root), + RawRepresentation = result, + }; + } + + /// Attempts to convert a JSON element representing a conversation item to a . + private static ChatMessage? TryConvertItemToChatMessage(JsonElement item) + { + if (!item.TryGetProperty("type", out JsonElement typeElement)) + { + return null; + } + + string? type = typeElement.GetString(); + if (type != "message") + { + return null; + } + + ChatRole role = ChatRole.User; + if (item.TryGetProperty("role", out JsonElement roleElement)) + { + string? roleStr = roleElement.GetString(); + role = roleStr switch + { + "assistant" => ChatRole.Assistant, + "system" => ChatRole.System, + "developer" => OpenAIClientExtensions.ChatRoleDeveloper, + "tool" => ChatRole.Tool, + _ => ChatRole.User, + }; + } + + var message = new ChatMessage(role, (string?)null); + + if (item.TryGetProperty("id", out JsonElement idElement)) + { + message.MessageId = idElement.GetString(); + } + + if (item.TryGetProperty("content", out JsonElement contentElement) && contentElement.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement part in contentElement.EnumerateArray()) + { + if (!part.TryGetProperty("type", out JsonElement partType)) + { + continue; + } + + string? partTypeStr = partType.GetString(); + switch (partTypeStr) + { + case "input_text" or "output_text" or "text": + if (part.TryGetProperty("text", out JsonElement textElement)) + { + message.Contents.Add(new TextContent(textElement.GetString())); + } + + break; + + case "refusal": + if (part.TryGetProperty("refusal", out JsonElement refusalElement)) + { + message.Contents.Add(new ErrorContent(refusalElement.GetString()) { ErrorCode = "Refusal" }); + } + + break; + + case "input_image": + if (part.TryGetProperty("image_url", out JsonElement imageUrlElement) && + imageUrlElement.GetString() is { } imageUrl && + Uri.TryCreate(imageUrl, UriKind.Absolute, out Uri? imageUri)) + { + message.Contents.Add(new UriContent(imageUri, OpenAIClientExtensions.ImageUriToMediaType(imageUri))); + } + else if (part.TryGetProperty("file_id", out JsonElement fileIdElement) && + fileIdElement.GetString() is { } fileId) + { + message.Contents.Add(new HostedFileContent(fileId) { MediaType = "image/*" }); + } + + break; + + case "input_file": + if (part.TryGetProperty("file_id", out JsonElement inputFileIdElement) && + inputFileIdElement.GetString() is { } inputFileId) + { + string? filename = part.TryGetProperty("filename", out JsonElement filenameElement) ? filenameElement.GetString() : null; + message.Contents.Add(new HostedFileContent(inputFileId) { Name = filename }); + } + + break; + } + } + } + + return message; + } + + /// Parses metadata from a JSON element. + private static AdditionalPropertiesDictionary? ParseMetadata(JsonElement root) + { + if (!root.TryGetProperty("metadata", out JsonElement metadataElement) || + metadataElement.ValueKind != JsonValueKind.Object) + { + return null; + } + + var metadata = new AdditionalPropertiesDictionary(); + foreach (JsonProperty property in metadataElement.EnumerateObject()) + { + metadata[property.Name] = property.Value.GetString() ?? string.Empty; + } + + return metadata.Count > 0 ? metadata : null; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/HostedConversation/ConfigureOptionsHostedConversationClient.cs b/src/Libraries/Microsoft.Extensions.AI/HostedConversation/ConfigureOptionsHostedConversationClient.cs new file mode 100644 index 00000000000..37683644858 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/HostedConversation/ConfigureOptionsHostedConversationClient.cs @@ -0,0 +1,53 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Represents a delegating hosted conversation client that configures a instance used by the remainder of the pipeline. +[Experimental(DiagnosticIds.Experiments.AIHostedConversation, UrlFormat = DiagnosticIds.UrlFormat)] +public sealed class ConfigureOptionsHostedConversationClient : DelegatingHostedConversationClient +{ + /// The callback delegate used to configure options. + private readonly Action _configureOptions; + + /// Initializes a new instance of the class with the specified callback. + /// The inner client. + /// + /// The delegate to invoke to configure the instance. It is passed a clone of the caller-supplied instance + /// (or a newly constructed instance if the caller-supplied instance is ). + /// + /// + /// The delegate is passed either a new instance of if + /// the caller didn't supply a instance, or a clone (via ) of the caller-supplied + /// instance if one was supplied. + /// + public ConfigureOptionsHostedConversationClient(IHostedConversationClient innerClient, Action configure) + : base(innerClient) + { + _configureOptions = Throw.IfNull(configure); + } + + /// + public override async Task CreateAsync( + HostedConversationCreationOptions? options = null, CancellationToken cancellationToken = default) + { + return await base.CreateAsync(Configure(options), cancellationToken); + } + + /// Creates and configures the to pass along to the inner client. + private HostedConversationCreationOptions Configure(HostedConversationCreationOptions? options) + { + options = options?.Clone() ?? new(); + + _configureOptions(options); + + return options; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/HostedConversation/ConfigureOptionsHostedConversationClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/HostedConversation/ConfigureOptionsHostedConversationClientBuilderExtensions.cs new file mode 100644 index 00000000000..0033e326255 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/HostedConversation/ConfigureOptionsHostedConversationClientBuilderExtensions.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides extensions for configuring instances. +[Experimental(DiagnosticIds.Experiments.AIHostedConversation, UrlFormat = DiagnosticIds.UrlFormat)] +public static class ConfigureOptionsHostedConversationClientBuilderExtensions +{ + /// + /// Adds a callback that configures a to be passed to the next client in the pipeline. + /// + /// The . + /// + /// The delegate to invoke to configure the instance. + /// It is passed a clone of the caller-supplied instance (or a newly constructed instance if the caller-supplied instance is ). + /// + /// + /// This method can be used to set default options. The delegate is passed either a new instance of + /// if the caller didn't supply a instance, or a clone (via ) + /// of the caller-supplied instance if one was supplied. + /// + /// The . + public static HostedConversationClientBuilder ConfigureOptions( + this HostedConversationClientBuilder builder, Action configure) + { + _ = Throw.IfNull(builder); + _ = Throw.IfNull(configure); + + return builder.Use(innerClient => new ConfigureOptionsHostedConversationClient(innerClient, configure)); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/HostedConversation/HostedConversationChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/HostedConversation/HostedConversationChatClient.cs new file mode 100644 index 00000000000..1d63b0c9391 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/HostedConversation/HostedConversationChatClient.cs @@ -0,0 +1,47 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// A delegating chat client that makes an discoverable +/// via . +/// +/// +/// This middleware passes through all chat operations unchanged. Its sole purpose is to hold a reference +/// to an and return it when requested through the service discovery mechanism. +/// +[Experimental(DiagnosticIds.Experiments.AIHostedConversation, UrlFormat = DiagnosticIds.UrlFormat)] +public sealed class HostedConversationChatClient : DelegatingChatClient +{ +#pragma warning disable CA2213 // Disposable fields should be disposed - not owned by this instance + private readonly IHostedConversationClient _hostedConversationClient; +#pragma warning restore CA2213 + + /// Initializes a new instance of the class. + /// The inner . + /// The to make discoverable. + public HostedConversationChatClient(IChatClient innerClient, IHostedConversationClient hostedConversationClient) + : base(innerClient) + { + _hostedConversationClient = Throw.IfNull(hostedConversationClient); + } + + /// + public override object? GetService(Type serviceType, object? serviceKey = null) + { + _ = Throw.IfNull(serviceType); + + if (serviceKey is null && serviceType.IsInstanceOfType(_hostedConversationClient)) + { + return _hostedConversationClient; + } + + return base.GetService(serviceType, serviceKey); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/HostedConversation/HostedConversationChatClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/HostedConversation/HostedConversationChatClientBuilderExtensions.cs new file mode 100644 index 00000000000..dbd7bd7c717 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/HostedConversation/HostedConversationChatClientBuilderExtensions.cs @@ -0,0 +1,47 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides extensions for adding to a . +[Experimental(DiagnosticIds.Experiments.AIHostedConversation, UrlFormat = DiagnosticIds.UrlFormat)] +public static class HostedConversationChatClientBuilderExtensions +{ + /// + /// Adds a to the chat client pipeline, making an discoverable via + /// . + /// + /// The . + /// The to make discoverable. + /// The . + public static ChatClientBuilder UseHostedConversations( + this ChatClientBuilder builder, + IHostedConversationClient hostedConversationClient) + { + _ = Throw.IfNull(builder); + _ = Throw.IfNull(hostedConversationClient); + + return builder.Use(innerClient => new HostedConversationChatClient(innerClient, hostedConversationClient)); + } + + /// + /// Adds a to the chat client pipeline, making an discoverable via + /// . The is resolved from the service provider. + /// + /// The . + /// The . + public static ChatClientBuilder UseHostedConversations( + this ChatClientBuilder builder) + { + _ = Throw.IfNull(builder); + + return builder.Use((innerClient, services) => + new HostedConversationChatClient(innerClient, services.GetRequiredService())); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/HostedConversation/HostedConversationClientBuilder.cs b/src/Libraries/Microsoft.Extensions.AI/HostedConversation/HostedConversationClientBuilder.cs new file mode 100644 index 00000000000..3fd720a8895 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/HostedConversation/HostedConversationClientBuilder.cs @@ -0,0 +1,82 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// A builder for creating pipelines of . +[Experimental(DiagnosticIds.Experiments.AIHostedConversation, UrlFormat = DiagnosticIds.UrlFormat)] +public sealed class HostedConversationClientBuilder +{ + private readonly Func _innerClientFactory; + + /// The registered client factory instances. + private List>? _clientFactories; + + /// Initializes a new instance of the class. + /// The inner that represents the underlying backend. + public HostedConversationClientBuilder(IHostedConversationClient innerClient) + { + _ = Throw.IfNull(innerClient); + _innerClientFactory = _ => innerClient; + } + + /// Initializes a new instance of the class. + /// A callback that produces the inner that represents the underlying backend. + public HostedConversationClientBuilder(Func innerClientFactory) + { + _innerClientFactory = Throw.IfNull(innerClientFactory); + } + + /// Builds an that represents the entire pipeline. Calls to this instance will pass through each of the pipeline stages in turn. + /// + /// The that should provide services to the instances. + /// If null, an empty will be used. + /// + /// An instance of that represents the entire pipeline. + public IHostedConversationClient Build(IServiceProvider? services = null) + { + services ??= EmptyServiceProvider.Instance; + var client = _innerClientFactory(services); + + // To match intuitive expectations, apply the factories in reverse order, so that the first factory added is the outermost. + if (_clientFactories is not null) + { + for (var i = _clientFactories.Count - 1; i >= 0; i--) + { + client = _clientFactories[i](client, services) ?? + throw new InvalidOperationException( + $"The {nameof(HostedConversationClientBuilder)} entry at index {i} returned null. " + + $"Ensure that the callbacks passed to {nameof(Use)} return non-null {nameof(IHostedConversationClient)} instances."); + } + } + + return client; + } + + /// Adds a factory for an intermediate hosted conversation client to the hosted conversation client pipeline. + /// The client factory function. + /// The updated instance. + public HostedConversationClientBuilder Use(Func clientFactory) + { + _ = Throw.IfNull(clientFactory); + + return Use((innerClient, _) => clientFactory(innerClient)); + } + + /// Adds a factory for an intermediate hosted conversation client to the hosted conversation client pipeline. + /// The client factory function. + /// The updated instance. + public HostedConversationClientBuilder Use(Func clientFactory) + { + _ = Throw.IfNull(clientFactory); + + (_clientFactories ??= []).Add(clientFactory); + return this; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/HostedConversation/HostedConversationClientBuilderServiceCollectionExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/HostedConversation/HostedConversationClientBuilderServiceCollectionExtensions.cs new file mode 100644 index 00000000000..94096907084 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/HostedConversation/HostedConversationClientBuilderServiceCollectionExtensions.cs @@ -0,0 +1,91 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DependencyInjection; + +/// Provides extension methods for registering with a . +[Experimental(DiagnosticIds.Experiments.AIHostedConversation, UrlFormat = DiagnosticIds.UrlFormat)] +public static class HostedConversationClientBuilderServiceCollectionExtensions +{ + /// Registers a singleton in the . + /// The to which the client should be added. + /// The inner that represents the underlying backend. + /// The service lifetime for the client. Defaults to . + /// A that can be used to build a pipeline around the inner client. + /// The client is registered as a singleton service. + public static HostedConversationClientBuilder AddHostedConversationClient( + this IServiceCollection serviceCollection, + IHostedConversationClient innerClient, + ServiceLifetime lifetime = ServiceLifetime.Singleton) + { + _ = Throw.IfNull(serviceCollection); + _ = Throw.IfNull(innerClient); + + return AddHostedConversationClient(serviceCollection, _ => innerClient, lifetime); + } + + /// Registers a singleton in the . + /// The to which the client should be added. + /// A callback that produces the inner that represents the underlying backend. + /// The service lifetime for the client. Defaults to . + /// A that can be used to build a pipeline around the inner client. + /// The client is registered as a singleton service. + public static HostedConversationClientBuilder AddHostedConversationClient( + this IServiceCollection serviceCollection, + Func innerClientFactory, + ServiceLifetime lifetime = ServiceLifetime.Singleton) + { + _ = Throw.IfNull(serviceCollection); + _ = Throw.IfNull(innerClientFactory); + + var builder = new HostedConversationClientBuilder(innerClientFactory); + serviceCollection.Add(new ServiceDescriptor(typeof(IHostedConversationClient), builder.Build, lifetime)); + return builder; + } + + /// Registers a keyed singleton in the . + /// The to which the client should be added. + /// The key with which to associate the client. + /// The inner that represents the underlying backend. + /// The service lifetime for the client. Defaults to . + /// A that can be used to build a pipeline around the inner client. + /// The client is registered as a singleton service. + public static HostedConversationClientBuilder AddKeyedHostedConversationClient( + this IServiceCollection serviceCollection, + object? serviceKey, + IHostedConversationClient innerClient, + ServiceLifetime lifetime = ServiceLifetime.Singleton) + { + _ = Throw.IfNull(serviceCollection); + _ = Throw.IfNull(innerClient); + + return AddKeyedHostedConversationClient(serviceCollection, serviceKey, _ => innerClient, lifetime); + } + + /// Registers a keyed singleton in the . + /// The to which the client should be added. + /// The key with which to associate the client. + /// A callback that produces the inner that represents the underlying backend. + /// The service lifetime for the client. Defaults to . + /// A that can be used to build a pipeline around the inner client. + /// The client is registered as a singleton service. + public static HostedConversationClientBuilder AddKeyedHostedConversationClient( + this IServiceCollection serviceCollection, + object? serviceKey, + Func innerClientFactory, + ServiceLifetime lifetime = ServiceLifetime.Singleton) + { + _ = Throw.IfNull(serviceCollection); + _ = Throw.IfNull(innerClientFactory); + + var builder = new HostedConversationClientBuilder(innerClientFactory); + serviceCollection.Add(new ServiceDescriptor(typeof(IHostedConversationClient), serviceKey, factory: (services, serviceKey) => builder.Build(services), lifetime)); + return builder; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/HostedConversation/LoggingHostedConversationClient.cs b/src/Libraries/Microsoft.Extensions.AI/HostedConversation/LoggingHostedConversationClient.cs new file mode 100644 index 00000000000..46cbd885049 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/HostedConversation/LoggingHostedConversationClient.cs @@ -0,0 +1,304 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// A delegating hosted conversation client that logs operations to an . +/// +/// +/// The provided implementation of is thread-safe for concurrent use so long as the +/// employed is also thread-safe for concurrent use. +/// +/// +/// When the employed enables , the contents of +/// messages and options are logged. These messages and options may contain sensitive application data. +/// is disabled by default and should never be enabled in a production environment. +/// Messages and options are not logged at other logging levels. +/// +/// +[Experimental(DiagnosticIds.Experiments.AIHostedConversation, UrlFormat = DiagnosticIds.UrlFormat)] +public partial class LoggingHostedConversationClient : DelegatingHostedConversationClient +{ + /// An instance used for all logging. + private readonly ILogger _logger; + + /// The to use for serialization of state written to the logger. + private JsonSerializerOptions _jsonSerializerOptions; + + /// Initializes a new instance of the class. + /// The underlying . + /// An instance that will be used for all logging. + public LoggingHostedConversationClient(IHostedConversationClient innerClient, ILogger logger) + : base(innerClient) + { + _logger = Throw.IfNull(logger); + _jsonSerializerOptions = AIJsonUtilities.DefaultOptions; + } + + /// Gets or sets JSON serialization options to use when serializing logging data. + public JsonSerializerOptions JsonSerializerOptions + { + get => _jsonSerializerOptions; + set => _jsonSerializerOptions = Throw.IfNull(value); + } + + /// + public override async Task CreateAsync( + HostedConversationCreationOptions? options = null, CancellationToken cancellationToken = default) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + if (_logger.IsEnabled(LogLevel.Trace)) + { + LogInvokedSensitive(nameof(CreateAsync), AsJson(options), AsJson(this.GetService())); + } + else + { + LogInvoked(nameof(CreateAsync)); + } + } + + try + { + var conversation = await base.CreateAsync(options, cancellationToken); + + if (_logger.IsEnabled(LogLevel.Debug)) + { + if (_logger.IsEnabled(LogLevel.Trace)) + { + LogCompletedSensitive(nameof(CreateAsync), AsJson(conversation)); + } + else + { + LogCompleted(nameof(CreateAsync)); + } + } + + return conversation; + } + catch (OperationCanceledException) + { + LogInvocationCanceled(nameof(CreateAsync)); + throw; + } + catch (Exception ex) + { + LogInvocationFailed(nameof(CreateAsync), ex); + throw; + } + } + + /// + public override async Task GetAsync( + string conversationId, CancellationToken cancellationToken = default) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + LogInvokedWithConversationId(nameof(GetAsync), conversationId); + } + + try + { + var conversation = await base.GetAsync(conversationId, cancellationToken); + + if (_logger.IsEnabled(LogLevel.Debug)) + { + if (_logger.IsEnabled(LogLevel.Trace)) + { + LogCompletedSensitive(nameof(GetAsync), AsJson(conversation)); + } + else + { + LogCompleted(nameof(GetAsync)); + } + } + + return conversation; + } + catch (OperationCanceledException) + { + LogInvocationCanceled(nameof(GetAsync)); + throw; + } + catch (Exception ex) + { + LogInvocationFailed(nameof(GetAsync), ex); + throw; + } + } + + /// + public override async Task DeleteAsync( + string conversationId, CancellationToken cancellationToken = default) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + LogInvokedWithConversationId(nameof(DeleteAsync), conversationId); + } + + try + { + await base.DeleteAsync(conversationId, cancellationToken); + LogCompleted(nameof(DeleteAsync)); + } + catch (OperationCanceledException) + { + LogInvocationCanceled(nameof(DeleteAsync)); + throw; + } + catch (Exception ex) + { + LogInvocationFailed(nameof(DeleteAsync), ex); + throw; + } + } + + /// + public override async Task AddMessagesAsync( + string conversationId, IEnumerable messages, CancellationToken cancellationToken = default) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + if (_logger.IsEnabled(LogLevel.Trace)) + { + LogAddMessagesInvokedSensitive(conversationId, AsJson(messages)); + } + else + { + LogInvokedWithConversationId(nameof(AddMessagesAsync), conversationId); + } + } + + try + { + await base.AddMessagesAsync(conversationId, messages, cancellationToken); + LogCompleted(nameof(AddMessagesAsync)); + } + catch (OperationCanceledException) + { + LogInvocationCanceled(nameof(AddMessagesAsync)); + throw; + } + catch (Exception ex) + { + LogInvocationFailed(nameof(AddMessagesAsync), ex); + throw; + } + } + + /// + public override async IAsyncEnumerable GetMessagesAsync( + string conversationId, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + LogInvokedWithConversationId(nameof(GetMessagesAsync), conversationId); + } + + IAsyncEnumerator e; + try + { + e = base.GetMessagesAsync(conversationId, cancellationToken).GetAsyncEnumerator(cancellationToken); + } + catch (OperationCanceledException) + { + LogInvocationCanceled(nameof(GetMessagesAsync)); + throw; + } + catch (Exception ex) + { + LogInvocationFailed(nameof(GetMessagesAsync), ex); + throw; + } + + try + { + ChatMessage? message = null; + while (true) + { + try + { + if (!await e.MoveNextAsync()) + { + break; + } + + message = e.Current; + } + catch (OperationCanceledException) + { + LogInvocationCanceled(nameof(GetMessagesAsync)); + throw; + } + catch (Exception ex) + { + LogInvocationFailed(nameof(GetMessagesAsync), ex); + throw; + } + + if (_logger.IsEnabled(LogLevel.Debug)) + { + if (_logger.IsEnabled(LogLevel.Trace)) + { + LogMessageReceivedSensitive(AsJson(message)); + } + else + { + LogMessageReceived(); + } + } + + yield return message; + } + + LogCompleted(nameof(GetMessagesAsync)); + } + finally + { + await e.DisposeAsync(); + } + } + + private string AsJson(T value) => TelemetryHelpers.AsJson(value, _jsonSerializerOptions); + + [LoggerMessage(LogLevel.Debug, "{MethodName} invoked.")] + private partial void LogInvoked(string methodName); + + [LoggerMessage(LogLevel.Debug, "{MethodName} invoked. ConversationId: {ConversationId}.")] + private partial void LogInvokedWithConversationId(string methodName, string conversationId); + + [LoggerMessage(LogLevel.Trace, "{MethodName} invoked: Options: {HostedConversationCreationOptions}. Metadata: {HostedConversationClientMetadata}.")] + private partial void LogInvokedSensitive(string methodName, string hostedConversationCreationOptions, string hostedConversationClientMetadata); + + [LoggerMessage(LogLevel.Trace, "AddMessagesAsync invoked. ConversationId: {ConversationId}. Messages: {Messages}.")] + private partial void LogAddMessagesInvokedSensitive(string conversationId, string messages); + + [LoggerMessage(LogLevel.Debug, "{MethodName} completed.")] + private partial void LogCompleted(string methodName); + + [LoggerMessage(LogLevel.Trace, "{MethodName} completed: {HostedConversationResponse}.")] + private partial void LogCompletedSensitive(string methodName, string hostedConversationResponse); + + [LoggerMessage(LogLevel.Debug, "GetMessagesAsync received message.")] + private partial void LogMessageReceived(); + + [LoggerMessage(LogLevel.Trace, "GetMessagesAsync received message: {ChatMessage}")] + private partial void LogMessageReceivedSensitive(string chatMessage); + + [LoggerMessage(LogLevel.Debug, "{MethodName} canceled.")] + private partial void LogInvocationCanceled(string methodName); + + [LoggerMessage(LogLevel.Error, "{MethodName} failed.")] + private partial void LogInvocationFailed(string methodName, Exception error); +} diff --git a/src/Libraries/Microsoft.Extensions.AI/HostedConversation/LoggingHostedConversationClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/HostedConversation/LoggingHostedConversationClientBuilderExtensions.cs new file mode 100644 index 00000000000..65268b6bf6f --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/HostedConversation/LoggingHostedConversationClientBuilderExtensions.cs @@ -0,0 +1,57 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides extensions for configuring instances. +[Experimental(DiagnosticIds.Experiments.AIHostedConversation, UrlFormat = DiagnosticIds.UrlFormat)] +public static class LoggingHostedConversationClientBuilderExtensions +{ + /// Adds logging to the hosted conversation client pipeline. + /// The . + /// + /// An optional used to create a logger with which logging should be performed. + /// If not supplied, a required instance will be resolved from the service provider. + /// + /// An optional callback that can be used to configure the instance. + /// The . + /// + /// + /// When the employed enables , the contents of + /// messages and options are logged. These messages and options may contain sensitive application data. + /// is disabled by default and should never be enabled in a production environment. + /// Messages and options are not logged at other logging levels. + /// + /// + public static HostedConversationClientBuilder UseLogging( + this HostedConversationClientBuilder builder, + ILoggerFactory? loggerFactory = null, + Action? configure = null) + { + _ = Throw.IfNull(builder); + + return builder.Use((innerClient, services) => + { + loggerFactory ??= services.GetRequiredService(); + + // If the factory we resolve is for the null logger, the LoggingHostedConversationClient will end up + // being an expensive nop, so skip adding it and just return the inner client. + if (loggerFactory == NullLoggerFactory.Instance) + { + return innerClient; + } + + var client = new LoggingHostedConversationClient(innerClient, loggerFactory.CreateLogger(typeof(LoggingHostedConversationClient))); + configure?.Invoke(client); + return client; + }); + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/HostedConversation/OpenTelemetryHostedConversationClient.cs b/src/Libraries/Microsoft.Extensions.AI/HostedConversation/OpenTelemetryHostedConversationClient.cs new file mode 100644 index 00000000000..93a02334a81 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/HostedConversation/OpenTelemetryHostedConversationClient.cs @@ -0,0 +1,310 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Metrics; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.DiagnosticIds; + +#pragma warning disable SA1111 // Closing parenthesis should be on line of last parameter +#pragma warning disable SA1113 // Comma should be on the same line as previous parameter + +namespace Microsoft.Extensions.AI; + +/// Represents a delegating hosted conversation client that implements the OpenTelemetry Semantic Conventions for Generative AI systems. +/// +/// This class provides an implementation of the Semantic Conventions for Generative AI systems v1.40, defined at . +/// The specification is still experimental and subject to change; as such, the telemetry output by this client is also subject to change. +/// +[Experimental(DiagnosticIds.Experiments.AIHostedConversation, UrlFormat = DiagnosticIds.UrlFormat)] +public sealed class OpenTelemetryHostedConversationClient : DelegatingHostedConversationClient +{ + private const string HostedConversationCreateName = "hosted_conversation create"; + private const string HostedConversationGetName = "hosted_conversation get"; + private const string HostedConversationDeleteName = "hosted_conversation delete"; + private const string HostedConversationAddMessagesName = "hosted_conversation add_messages"; + private const string HostedConversationGetMessagesName = "hosted_conversation get_messages"; + + private readonly ActivitySource _activitySource; + private readonly Meter _meter; + + private readonly Histogram _operationDurationHistogram; + + private readonly string? _providerName; + private readonly string? _serverAddress; + private readonly int _serverPort; + + /// Initializes a new instance of the class. + /// The underlying . + /// The to use for emitting any logging data from the client. + /// An optional source name that will be used on the telemetry data. +#pragma warning disable IDE0060 // Remove unused parameter; it exists for consistency with other OTel clients and future use + public OpenTelemetryHostedConversationClient(IHostedConversationClient innerClient, ILogger? logger = null, string? sourceName = null) +#pragma warning restore IDE0060 + : base(innerClient) + { + Debug.Assert(innerClient is not null, "Should have been validated by the base ctor"); + + if (innerClient!.GetService() is HostedConversationClientMetadata metadata) + { + _providerName = metadata.ProviderName; + _serverAddress = metadata.ProviderUri?.Host; + _serverPort = metadata.ProviderUri?.Port ?? 0; + } + + string name = string.IsNullOrEmpty(sourceName) ? OpenTelemetryConsts.DefaultSourceName : sourceName!; + _activitySource = new(name); + _meter = new(name); + + _operationDurationHistogram = _meter.CreateHistogram( + OpenTelemetryConsts.GenAI.Client.OperationDuration.Name, + OpenTelemetryConsts.SecondsUnit, + OpenTelemetryConsts.GenAI.Client.OperationDuration.Description, + advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.OperationDuration.ExplicitBucketBoundaries } + ); + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + _activitySource.Dispose(); + _meter.Dispose(); + } + + base.Dispose(disposing); + } + + /// + /// Gets or sets a value indicating whether potentially sensitive information should be included in telemetry. + /// + /// + /// if potentially sensitive information should be included in telemetry; + /// if telemetry shouldn't include raw inputs and outputs. + /// The default value is , unless the OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT + /// environment variable is set to "true" (case-insensitive). + /// + public bool EnableSensitiveData { get; set; } = TelemetryHelpers.EnableSensitiveDataDefault; + + /// + public override object? GetService(Type serviceType, object? serviceKey = null) => + serviceType == typeof(ActivitySource) ? _activitySource : + base.GetService(serviceType, serviceKey); + + /// + public override async Task CreateAsync( + HostedConversationCreationOptions? options = null, CancellationToken cancellationToken = default) + { + using Activity? activity = CreateAndConfigureActivity(HostedConversationCreateName); + Stopwatch? stopwatch = _operationDurationHistogram.Enabled ? Stopwatch.StartNew() : null; + + Exception? error = null; + try + { + return await base.CreateAsync(options, cancellationToken); + } + catch (Exception ex) + { + error = ex; + throw; + } + finally + { + TraceResponse(activity, HostedConversationCreateName, error, stopwatch); + } + } + + /// + public override async Task GetAsync( + string conversationId, CancellationToken cancellationToken = default) + { + using Activity? activity = CreateAndConfigureActivity(HostedConversationGetName, conversationId); + Stopwatch? stopwatch = _operationDurationHistogram.Enabled ? Stopwatch.StartNew() : null; + + Exception? error = null; + try + { + return await base.GetAsync(conversationId, cancellationToken); + } + catch (Exception ex) + { + error = ex; + throw; + } + finally + { + TraceResponse(activity, HostedConversationGetName, error, stopwatch); + } + } + + /// + public override async Task DeleteAsync( + string conversationId, CancellationToken cancellationToken = default) + { + using Activity? activity = CreateAndConfigureActivity(HostedConversationDeleteName, conversationId); + Stopwatch? stopwatch = _operationDurationHistogram.Enabled ? Stopwatch.StartNew() : null; + + Exception? error = null; + try + { + await base.DeleteAsync(conversationId, cancellationToken); + } + catch (Exception ex) + { + error = ex; + throw; + } + finally + { + TraceResponse(activity, HostedConversationDeleteName, error, stopwatch); + } + } + + /// + public override async Task AddMessagesAsync( + string conversationId, IEnumerable messages, CancellationToken cancellationToken = default) + { + using Activity? activity = CreateAndConfigureActivity(HostedConversationAddMessagesName, conversationId); + Stopwatch? stopwatch = _operationDurationHistogram.Enabled ? Stopwatch.StartNew() : null; + + Exception? error = null; + try + { + await base.AddMessagesAsync(conversationId, messages, cancellationToken); + } + catch (Exception ex) + { + error = ex; + throw; + } + finally + { + TraceResponse(activity, HostedConversationAddMessagesName, error, stopwatch); + } + } + + /// + public override async IAsyncEnumerable GetMessagesAsync( + string conversationId, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + using Activity? activity = CreateAndConfigureActivity(HostedConversationGetMessagesName, conversationId); + Stopwatch? stopwatch = _operationDurationHistogram.Enabled ? Stopwatch.StartNew() : null; + + IAsyncEnumerable messages; + try + { + messages = base.GetMessagesAsync(conversationId, cancellationToken); + } + catch (Exception ex) + { + TraceResponse(activity, HostedConversationGetMessagesName, ex, stopwatch); + throw; + } + + var enumerator = messages.GetAsyncEnumerator(cancellationToken); + Exception? error = null; + try + { + while (true) + { + ChatMessage message; + try + { + if (!await enumerator.MoveNextAsync()) + { + break; + } + + message = enumerator.Current; + } + catch (Exception ex) + { + error = ex; + throw; + } + + yield return message; + Activity.Current = activity; // workaround for https://github.com/dotnet/runtime/issues/47802 + } + } + finally + { + TraceResponse(activity, HostedConversationGetMessagesName, error, stopwatch); + + await enumerator.DisposeAsync(); + } + } + + /// Creates an activity for a hosted conversation operation, or returns if not enabled. + private Activity? CreateAndConfigureActivity(string operationName, string? conversationId = null) + { + Activity? activity = null; + if (_activitySource.HasListeners()) + { + activity = _activitySource.StartActivity(operationName, ActivityKind.Client); + + if (activity is { IsAllDataRequested: true }) + { + _ = activity + .AddTag(OpenTelemetryConsts.GenAI.Operation.Name, operationName) + .AddTag(OpenTelemetryConsts.GenAI.Provider.Name, _providerName); + + if (conversationId is not null) + { + _ = activity.AddTag(OpenTelemetryConsts.GenAI.Conversation.Id, conversationId); + } + + if (_serverAddress is not null) + { + _ = activity + .AddTag(OpenTelemetryConsts.Server.Address, _serverAddress) + .AddTag(OpenTelemetryConsts.Server.Port, _serverPort); + } + } + } + + return activity; + } + + /// Records response information to the activity and metrics. + private void TraceResponse( + Activity? activity, + string operationName, + Exception? error, + Stopwatch? stopwatch) + { + if (_operationDurationHistogram.Enabled && stopwatch is not null) + { + TagList tags = default; + tags.Add(OpenTelemetryConsts.GenAI.Operation.Name, operationName); + tags.Add(OpenTelemetryConsts.GenAI.Provider.Name, _providerName); + + if (_serverAddress is string endpointAddress) + { + tags.Add(OpenTelemetryConsts.Server.Address, endpointAddress); + tags.Add(OpenTelemetryConsts.Server.Port, _serverPort); + } + + if (error is not null) + { + tags.Add(OpenTelemetryConsts.Error.Type, error.GetType().FullName); + } + + _operationDurationHistogram.Record(stopwatch.Elapsed.TotalSeconds, tags); + } + + if (error is not null) + { + _ = activity? + .AddTag(OpenTelemetryConsts.Error.Type, error.GetType().FullName) + .SetStatus(ActivityStatusCode.Error, error.Message); + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI/HostedConversation/OpenTelemetryHostedConversationClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/HostedConversation/OpenTelemetryHostedConversationClientBuilderExtensions.cs new file mode 100644 index 00000000000..22ef4ccc5f1 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI/HostedConversation/OpenTelemetryHostedConversationClientBuilderExtensions.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// Provides extensions for configuring instances. +[Experimental(DiagnosticIds.Experiments.AIHostedConversation, UrlFormat = DiagnosticIds.UrlFormat)] +public static class OpenTelemetryHostedConversationClientBuilderExtensions +{ + /// + /// Adds OpenTelemetry support to the hosted conversation client pipeline, following the OpenTelemetry Semantic Conventions for Generative AI systems. + /// + /// + /// The draft specification this follows is available at . + /// The specification is still experimental and subject to change; as such, the telemetry output by this client is also subject to change. + /// + /// The . + /// An optional to use to create a logger for logging events. + /// An optional source name that will be used on the telemetry data. + /// An optional callback that can be used to configure the instance. + /// The . + public static HostedConversationClientBuilder UseOpenTelemetry( + this HostedConversationClientBuilder builder, + ILoggerFactory? loggerFactory = null, + string? sourceName = null, + Action? configure = null) => + Throw.IfNull(builder).Use((innerClient, services) => + { + loggerFactory ??= services.GetService(); + + var client = new OpenTelemetryHostedConversationClient(innerClient, loggerFactory?.CreateLogger(typeof(OpenTelemetryHostedConversationClient)), sourceName); + configure?.Invoke(client); + + return client; + }); +} diff --git a/src/Shared/DiagnosticIds/DiagnosticIds.cs b/src/Shared/DiagnosticIds/DiagnosticIds.cs index 94cc1a1f04a..9b2130880bf 100644 --- a/src/Shared/DiagnosticIds/DiagnosticIds.cs +++ b/src/Shared/DiagnosticIds/DiagnosticIds.cs @@ -60,6 +60,7 @@ internal static class Experiments internal const string AIWebSearch = AIExperiments; internal const string AIRealTime = AIExperiments; internal const string AIFiles = AIExperiments; + internal const string AIHostedConversation = AIExperiments; // These diagnostic IDs are defined by the OpenAI package for its experimental APIs. // We use the same IDs so consumers do not need to suppress additional diagnostics diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedConversation/DelegatingHostedConversationClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedConversation/DelegatingHostedConversationClientTests.cs new file mode 100644 index 00000000000..f51d17bbabf --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedConversation/DelegatingHostedConversationClientTests.cs @@ -0,0 +1,315 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable MEAI001 + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class DelegatingHostedConversationClientTests +{ + [Fact] + public void RequiresInnerClient() + { + Assert.Throws("innerClient", () => new NoOpDelegatingHostedConversationClient(null!)); + } + + [Fact] + public async Task CreateAsyncDefaultsToInnerClientAsync() + { + // Arrange + var expectedOptions = new HostedConversationCreationOptions(); + var expectedCancellationToken = CancellationToken.None; + var expectedResult = new TaskCompletionSource(); + var expectedConversation = new HostedConversation { ConversationId = "conv-1" }; + using var inner = new TestHostedConversationClient + { + CreateAsyncCallback = (options, cancellationToken) => + { + Assert.Same(expectedOptions, options); + Assert.Equal(expectedCancellationToken, cancellationToken); + return expectedResult.Task; + } + }; + + using var delegating = new NoOpDelegatingHostedConversationClient(inner); + + // Act + var resultTask = delegating.CreateAsync(expectedOptions, expectedCancellationToken); + + // Assert + Assert.False(resultTask.IsCompleted); + expectedResult.SetResult(expectedConversation); + Assert.True(resultTask.IsCompleted); + Assert.Same(expectedConversation, await resultTask); + } + + [Fact] + public async Task GetAsyncDefaultsToInnerClientAsync() + { + // Arrange + var expectedConversationId = "conv-123"; + var expectedCancellationToken = CancellationToken.None; + var expectedResult = new TaskCompletionSource(); + var expectedConversation = new HostedConversation { ConversationId = expectedConversationId }; + using var inner = new TestHostedConversationClient + { + GetAsyncCallback = (conversationId, cancellationToken) => + { + Assert.Equal(expectedConversationId, conversationId); + Assert.Equal(expectedCancellationToken, cancellationToken); + return expectedResult.Task; + } + }; + + using var delegating = new NoOpDelegatingHostedConversationClient(inner); + + // Act + var resultTask = delegating.GetAsync(expectedConversationId, expectedCancellationToken); + + // Assert + Assert.False(resultTask.IsCompleted); + expectedResult.SetResult(expectedConversation); + Assert.True(resultTask.IsCompleted); + Assert.Same(expectedConversation, await resultTask); + } + + [Fact] + public async Task DeleteAsyncDefaultsToInnerClientAsync() + { + // Arrange + var expectedConversationId = "conv-123"; + var expectedCancellationToken = CancellationToken.None; + var expectedResult = new TaskCompletionSource(); + using var inner = new TestHostedConversationClient + { + DeleteAsyncCallback = (conversationId, cancellationToken) => + { + Assert.Equal(expectedConversationId, conversationId); + Assert.Equal(expectedCancellationToken, cancellationToken); + return expectedResult.Task; + } + }; + + using var delegating = new NoOpDelegatingHostedConversationClient(inner); + + // Act + var resultTask = delegating.DeleteAsync(expectedConversationId, expectedCancellationToken); + + // Assert + Assert.False(resultTask.IsCompleted); + expectedResult.SetResult(true); + Assert.True(resultTask.IsCompleted); + await resultTask; + } + + [Fact] + public async Task AddMessagesAsyncDefaultsToInnerClientAsync() + { + // Arrange + var expectedConversationId = "conv-123"; + var expectedMessages = new List { new(ChatRole.User, "Hello") }; + var expectedCancellationToken = CancellationToken.None; + var expectedResult = new TaskCompletionSource(); + using var inner = new TestHostedConversationClient + { + AddMessagesAsyncCallback = (conversationId, messages, cancellationToken) => + { + Assert.Equal(expectedConversationId, conversationId); + Assert.Same(expectedMessages, messages); + Assert.Equal(expectedCancellationToken, cancellationToken); + return expectedResult.Task; + } + }; + + using var delegating = new NoOpDelegatingHostedConversationClient(inner); + + // Act + var resultTask = delegating.AddMessagesAsync(expectedConversationId, expectedMessages, expectedCancellationToken); + + // Assert + Assert.False(resultTask.IsCompleted); + expectedResult.SetResult(true); + Assert.True(resultTask.IsCompleted); + await resultTask; + } + + [Fact] + public async Task GetMessagesAsyncDefaultsToInnerClientAsync() + { + // Arrange + var expectedConversationId = "conv-123"; + var expectedCancellationToken = CancellationToken.None; + ChatMessage[] expectedMessages = + [ + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, "Hi"), + ]; + + using var inner = new TestHostedConversationClient + { + GetMessagesAsyncCallback = (conversationId, cancellationToken) => + { + Assert.Equal(expectedConversationId, conversationId); + Assert.Equal(expectedCancellationToken, cancellationToken); + return YieldAsync(expectedMessages); + } + }; + + using var delegating = new NoOpDelegatingHostedConversationClient(inner); + + // Act + var resultAsyncEnumerable = delegating.GetMessagesAsync(expectedConversationId, expectedCancellationToken); + + // Assert + var enumerator = resultAsyncEnumerable.GetAsyncEnumerator(); + Assert.True(await enumerator.MoveNextAsync()); + Assert.Same(expectedMessages[0], enumerator.Current); + Assert.True(await enumerator.MoveNextAsync()); + Assert.Same(expectedMessages[1], enumerator.Current); + Assert.False(await enumerator.MoveNextAsync()); + } + + [Fact] + public void GetServiceThrowsForNullType() + { + using var inner = new TestHostedConversationClient(); + using var delegating = new NoOpDelegatingHostedConversationClient(inner); + Assert.Throws("serviceType", () => delegating.GetService(null!)); + } + + [Fact] + public void GetServiceReturnsSelfIfCompatibleWithRequestAndKeyIsNull() + { + // Arrange + using var inner = new TestHostedConversationClient(); + using var delegating = new NoOpDelegatingHostedConversationClient(inner); + + // Act + var client = delegating.GetService(); + + // Assert + Assert.Same(delegating, client); + } + + [Fact] + public void GetServiceDelegatesToInnerIfKeyIsNotNull() + { + // Arrange + var expectedKey = new object(); + using var expectedResult = new TestHostedConversationClient(); + using var inner = new TestHostedConversationClient + { + GetServiceCallback = (_, _) => expectedResult + }; + using var delegating = new NoOpDelegatingHostedConversationClient(inner); + + // Act + var client = delegating.GetService(expectedKey); + + // Assert + Assert.Same(expectedResult, client); + } + + [Fact] + public void GetServiceDelegatesToInnerIfNotCompatibleWithRequest() + { + // Arrange + var expectedResult = TimeZoneInfo.Local; + var expectedKey = new object(); + using var inner = new TestHostedConversationClient + { + GetServiceCallback = (type, key) => type == expectedResult.GetType() && key == expectedKey + ? expectedResult + : throw new InvalidOperationException("Unexpected call") + }; + using var delegating = new NoOpDelegatingHostedConversationClient(inner); + + // Act + var tzi = delegating.GetService(expectedKey); + + // Assert + Assert.Same(expectedResult, tzi); + } + + [Fact] + public void DisposeDisposesInnerClient() + { + // Arrange + using var inner = new TestHostedConversationClient(); + using var delegating = new NoOpDelegatingHostedConversationClient(inner); + + Assert.False(inner.Disposed); + + // Act + delegating.Dispose(); + + // Assert + Assert.True(inner.Disposed); + } + + private static async IAsyncEnumerable YieldAsync(IEnumerable input) + { + await Task.Yield(); + foreach (var item in input) + { + yield return item; + } + } + + private sealed class NoOpDelegatingHostedConversationClient(IHostedConversationClient innerClient) + : DelegatingHostedConversationClient(innerClient); + + private sealed class TestHostedConversationClient : IHostedConversationClient + { + public TestHostedConversationClient() + { + GetServiceCallback = DefaultGetServiceCallback; + } + + public bool Disposed { get; private set; } + + public Func>? CreateAsyncCallback { get; set; } + + public Func>? GetAsyncCallback { get; set; } + + public Func? DeleteAsyncCallback { get; set; } + + public Func, CancellationToken, Task>? AddMessagesAsyncCallback { get; set; } + + public Func>? GetMessagesAsyncCallback { get; set; } + + public Func GetServiceCallback { get; set; } + + private object? DefaultGetServiceCallback(Type serviceType, object? serviceKey) => + serviceType is not null && serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; + + public Task CreateAsync(HostedConversationCreationOptions? options = null, CancellationToken cancellationToken = default) + => CreateAsyncCallback!.Invoke(options, cancellationToken); + + public Task GetAsync(string conversationId, CancellationToken cancellationToken = default) + => GetAsyncCallback!.Invoke(conversationId, cancellationToken); + + public Task DeleteAsync(string conversationId, CancellationToken cancellationToken = default) + => DeleteAsyncCallback!.Invoke(conversationId, cancellationToken); + + public Task AddMessagesAsync(string conversationId, IEnumerable messages, CancellationToken cancellationToken = default) + => AddMessagesAsyncCallback!.Invoke(conversationId, messages, cancellationToken); + + public IAsyncEnumerable GetMessagesAsync(string conversationId, CancellationToken cancellationToken = default) + => GetMessagesAsyncCallback!.Invoke(conversationId, cancellationToken); + + public object? GetService(Type serviceType, object? serviceKey = null) + => GetServiceCallback(serviceType, serviceKey); + + public void Dispose() + { + Disposed = true; + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedConversation/HostedConversationCreationOptionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedConversation/HostedConversationCreationOptionsTests.cs new file mode 100644 index 00000000000..1fbe3d068d6 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedConversation/HostedConversationCreationOptionsTests.cs @@ -0,0 +1,212 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable MEAI001 + +using System; +using System.Collections.Generic; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class HostedConversationCreationOptionsTests +{ + [Fact] + public void Constructor_Parameterless_PropsDefaulted() + { + HostedConversationCreationOptions options = new(); + Assert.Null(options.Metadata); + Assert.Null(options.Messages); + Assert.Null(options.RawRepresentationFactory); + Assert.Null(options.AdditionalProperties); + + HostedConversationCreationOptions clone = options.Clone(); + Assert.Null(clone.Metadata); + Assert.Null(clone.Messages); + Assert.Null(clone.RawRepresentationFactory); + Assert.Null(clone.AdditionalProperties); + } + + [Fact] + public void Properties_Roundtrip() + { + HostedConversationCreationOptions options = new(); + + AdditionalPropertiesDictionary metadata = new() + { + ["key1"] = "value1", + }; + + List messages = + [ + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, "Hi there"), + ]; + + Func rawRepresentationFactory = (c) => null; + + AdditionalPropertiesDictionary additionalProps = new() + { + ["key"] = "value", + }; + + options.Metadata = metadata; + options.Messages = messages; + options.RawRepresentationFactory = rawRepresentationFactory; + options.AdditionalProperties = additionalProps; + + Assert.Same(metadata, options.Metadata); + Assert.Same(messages, options.Messages); + Assert.Same(rawRepresentationFactory, options.RawRepresentationFactory); + Assert.Same(additionalProps, options.AdditionalProperties); + } + + [Fact] + public void Clone_CreatesIndependentCopy() + { + HostedConversationCreationOptions original = new() + { + Metadata = new() { ["key1"] = "value1" }, + Messages = [new(ChatRole.User, "Hello")], + RawRepresentationFactory = (c) => null, + AdditionalProperties = new() { ["prop"] = "val" }, + }; + + HostedConversationCreationOptions clone = original.Clone(); + + Assert.NotSame(original, clone); + Assert.NotNull(clone.Metadata); + Assert.NotNull(clone.Messages); + Assert.NotNull(clone.RawRepresentationFactory); + Assert.NotNull(clone.AdditionalProperties); + } + + [Fact] + public void Clone_DeepCopiesMetadata() + { + HostedConversationCreationOptions original = new() + { + Metadata = new() { ["key1"] = "value1" }, + }; + + HostedConversationCreationOptions clone = original.Clone(); + + Assert.NotSame(original.Metadata, clone.Metadata); + Assert.Equal("value1", clone.Metadata!["key1"]); + + // Modifying clone should not affect original + clone.Metadata["key1"] = "modified"; + Assert.Equal("value1", original.Metadata["key1"]); + + clone.Metadata["key2"] = "newvalue"; + Assert.False(original.Metadata.ContainsKey("key2")); + } + + [Fact] + public void Clone_DeepCopiesMessages() + { + List messages = + [ + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, "Hi"), + ]; + + HostedConversationCreationOptions original = new() + { + Messages = messages, + }; + + HostedConversationCreationOptions clone = original.Clone(); + + Assert.NotSame(original.Messages, clone.Messages); + Assert.Equal(2, clone.Messages!.Count); + + // Adding to clone should not affect original + clone.Messages.Add(new(ChatRole.User, "Another message")); + Assert.Equal(2, original.Messages.Count); + } + + [Fact] + public void Clone_CopiesRawRepresentationFactoryByReference() + { + Func factory = (c) => "test"; + + HostedConversationCreationOptions original = new() + { + RawRepresentationFactory = factory, + }; + + HostedConversationCreationOptions clone = original.Clone(); + + Assert.Same(original.RawRepresentationFactory, clone.RawRepresentationFactory); + } + + [Fact] + public void Clone_DeepCopiesAdditionalProperties() + { + HostedConversationCreationOptions original = new() + { + AdditionalProperties = new() { ["key"] = "value" }, + }; + + HostedConversationCreationOptions clone = original.Clone(); + + Assert.NotSame(original.AdditionalProperties, clone.AdditionalProperties); + Assert.Equal("value", clone.AdditionalProperties!["key"]); + + // Modifying clone should not affect original + clone.AdditionalProperties["key"] = "modified"; + Assert.Equal("value", original.AdditionalProperties["key"]); + + clone.AdditionalProperties["newkey"] = "newval"; + Assert.False(original.AdditionalProperties.ContainsKey("newkey")); + } + + [Fact] + public void CopyConstructor_Null_Valid() + { + PassedNullToBaseOptions options = new(); + Assert.NotNull(options); + } + + [Fact] + public void CopyConstructors_EnableHierarchyCloning() + { + DerivedOptions derived = new() + { + Metadata = new() { ["key"] = "value" }, + CustomProperty = 42, + }; + + HostedConversationCreationOptions clone = derived.Clone(); + + Assert.NotNull(clone.Metadata); + Assert.Equal("value", clone.Metadata["key"]); + Assert.Equal(42, Assert.IsType(clone).CustomProperty); + } + + private class PassedNullToBaseOptions : HostedConversationCreationOptions + { + public PassedNullToBaseOptions() + : base(null) + { + } + } + + private class DerivedOptions : HostedConversationCreationOptions + { + public DerivedOptions() + { + } + + protected DerivedOptions(DerivedOptions other) + : base(other) + { + CustomProperty = other.CustomProperty; + } + + public int CustomProperty { get; set; } + + public override HostedConversationCreationOptions Clone() => new DerivedOptions(this); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedConversation/HostedConversationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedConversation/HostedConversationTests.cs new file mode 100644 index 00000000000..cf2ba988749 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedConversation/HostedConversationTests.cs @@ -0,0 +1,99 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable MEAI001 + +using System; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class HostedConversationTests +{ + [Fact] + public void Constructor_Parameterless_PropsDefaulted() + { + HostedConversation conversation = new(); + Assert.Null(conversation.ConversationId); + Assert.Null(conversation.CreatedAt); + Assert.Null(conversation.Metadata); + Assert.Null(conversation.RawRepresentation); + Assert.Null(conversation.AdditionalProperties); + } + + [Fact] + public void ConversationId_Roundtrips() + { + HostedConversation conversation = new(); + Assert.Null(conversation.ConversationId); + + conversation.ConversationId = "conv-123"; + Assert.Equal("conv-123", conversation.ConversationId); + + conversation.ConversationId = null; + Assert.Null(conversation.ConversationId); + } + + [Fact] + public void CreatedAt_Roundtrips() + { + HostedConversation conversation = new(); + Assert.Null(conversation.CreatedAt); + + DateTimeOffset createdAt = new(2024, 6, 15, 10, 30, 0, TimeSpan.Zero); + conversation.CreatedAt = createdAt; + Assert.Equal(createdAt, conversation.CreatedAt); + + conversation.CreatedAt = null; + Assert.Null(conversation.CreatedAt); + } + + [Fact] + public void Metadata_Roundtrips() + { + HostedConversation conversation = new(); + Assert.Null(conversation.Metadata); + + AdditionalPropertiesDictionary metadata = new() + { + ["key1"] = "value1", + ["key2"] = "value2", + }; + conversation.Metadata = metadata; + Assert.Same(metadata, conversation.Metadata); + + conversation.Metadata = null; + Assert.Null(conversation.Metadata); + } + + [Fact] + public void RawRepresentation_Roundtrips() + { + HostedConversation conversation = new(); + Assert.Null(conversation.RawRepresentation); + + object raw = new(); + conversation.RawRepresentation = raw; + Assert.Same(raw, conversation.RawRepresentation); + + conversation.RawRepresentation = null; + Assert.Null(conversation.RawRepresentation); + } + + [Fact] + public void AdditionalProperties_Roundtrips() + { + HostedConversation conversation = new(); + Assert.Null(conversation.AdditionalProperties); + + AdditionalPropertiesDictionary additionalProps = new() + { + ["key"] = "value", + }; + conversation.AdditionalProperties = additionalProps; + Assert.Same(additionalProps, conversation.AdditionalProperties); + + conversation.AdditionalProperties = null; + Assert.Null(conversation.AdditionalProperties); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIHostedConversationClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIHostedConversationClientTests.cs new file mode 100644 index 00000000000..b77bacffb99 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIHostedConversationClientTests.cs @@ -0,0 +1,78 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.ClientModel; +using OpenAI; +using OpenAI.Conversations; +using Xunit; + +#pragma warning disable MEAI001 +#pragma warning disable OPENAI001 + +namespace Microsoft.Extensions.AI; + +public class OpenAIHostedConversationClientTests +{ + [Fact] + public void AsIHostedConversationClient_NullClient_Throws() + { + Assert.Throws("conversationClient", () => ((ConversationClient)null!).AsIHostedConversationClient()); + } + + [Fact] + public void GetService_ReturnsMetadata() + { + Uri endpoint = new("http://localhost/some/endpoint"); + ConversationClient conversationClient = new(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); + + IHostedConversationClient client = conversationClient.AsIHostedConversationClient(); + + var metadata = client.GetService(typeof(HostedConversationClientMetadata)) as HostedConversationClientMetadata; + Assert.NotNull(metadata); + Assert.Equal("openai", metadata.ProviderName); + Assert.Equal(endpoint, metadata.ProviderUri); + } + + [Fact] + public void GetService_ReturnsConversationClient() + { + ConversationClient conversationClient = new(new ApiKeyCredential("key")); + + IHostedConversationClient client = conversationClient.AsIHostedConversationClient(); + + Assert.Same(conversationClient, client.GetService(typeof(ConversationClient))); + } + + [Fact] + public void GetService_ReturnsSelf() + { + ConversationClient conversationClient = new(new ApiKeyCredential("key")); + + IHostedConversationClient client = conversationClient.AsIHostedConversationClient(); + + Assert.Same(client, client.GetService(typeof(IHostedConversationClient))); + } + + [Fact] + public void GetService_ReturnsNull_ForUnknownType() + { + ConversationClient conversationClient = new(new ApiKeyCredential("key")); + + IHostedConversationClient client = conversationClient.AsIHostedConversationClient(); + + Assert.Null(client.GetService(typeof(string))); + } + + [Fact] + public void GetService_ReturnsNull_ForServiceKey() + { + ConversationClient conversationClient = new(new ApiKeyCredential("key")); + + IHostedConversationClient client = conversationClient.AsIHostedConversationClient(); + + Assert.Null(client.GetService(typeof(IHostedConversationClient), "someKey")); + Assert.Null(client.GetService(typeof(HostedConversationClientMetadata), "someKey")); + Assert.Null(client.GetService(typeof(ConversationClient), "someKey")); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/HostedConversation/ConfigureOptionsHostedConversationClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/HostedConversation/ConfigureOptionsHostedConversationClientTests.cs new file mode 100644 index 00000000000..aef6f389cd4 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/HostedConversation/ConfigureOptionsHostedConversationClientTests.cs @@ -0,0 +1,157 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable MEAI001 + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class ConfigureOptionsHostedConversationClientTests +{ + [Fact] + public async Task ConfigureOptions_CallbackIsCalled() + { + // Arrange + var callbackInvoked = false; + HostedConversationCreationOptions? receivedByInner = null; + + using var innerClient = new TestHostedConversationClient + { + CreateAsyncCallback = (options, _) => + { + receivedByInner = options; + return Task.FromResult(new HostedConversation { ConversationId = "conv-1" }); + } + }; + + using var client = new ConfigureOptionsHostedConversationClient(innerClient, options => + { + callbackInvoked = true; + options.Metadata ??= new(); + options.Metadata["configured"] = "true"; + }); + + // Act + await client.CreateAsync(new HostedConversationCreationOptions()); + + // Assert + Assert.True(callbackInvoked); + Assert.NotNull(receivedByInner); + Assert.Equal("true", receivedByInner!.Metadata!["configured"]); + } + + [Fact] + public async Task ConfigureOptions_OptionsAreCloned_OriginalNotModified() + { + // Arrange + HostedConversationCreationOptions? receivedByInner = null; + var originalOptions = new HostedConversationCreationOptions + { + Metadata = new() { ["key"] = "original" } + }; + + using var innerClient = new TestHostedConversationClient + { + CreateAsyncCallback = (options, _) => + { + receivedByInner = options; + return Task.FromResult(new HostedConversation { ConversationId = "conv-1" }); + } + }; + + using var client = new ConfigureOptionsHostedConversationClient(innerClient, options => + { + options.Metadata!["key"] = "modified"; + }); + + // Act + await client.CreateAsync(originalOptions); + + // Assert - original should not be modified + Assert.Equal("original", originalOptions.Metadata["key"]); + + // Assert - inner received modified clone + Assert.NotNull(receivedByInner); + Assert.NotSame(originalOptions, receivedByInner); + Assert.Equal("modified", receivedByInner!.Metadata!["key"]); + } + + [Fact] + public async Task ConfigureOptions_NullOptions_CreatesNew() + { + // Arrange + HostedConversationCreationOptions? receivedByInner = null; + + using var innerClient = new TestHostedConversationClient + { + CreateAsyncCallback = (options, _) => + { + receivedByInner = options; + return Task.FromResult(new HostedConversation { ConversationId = "conv-1" }); + } + }; + + using var client = new ConfigureOptionsHostedConversationClient(innerClient, options => + { + options.Metadata = new() { ["new"] = "value" }; + }); + + // Act + await client.CreateAsync(options: null); + + // Assert - a new options instance should have been created + Assert.NotNull(receivedByInner); + Assert.Equal("value", receivedByInner!.Metadata!["new"]); + } + + [Fact] + public void Constructor_NullCallback_Throws() + { + using var innerClient = new TestHostedConversationClient(); + Assert.Throws("configure", () => new ConfigureOptionsHostedConversationClient(innerClient, null!)); + } + + [Fact] + public void Constructor_NullInnerClient_Throws() + { + Assert.Throws("innerClient", () => new ConfigureOptionsHostedConversationClient(null!, _ => { })); + } + + private sealed class TestHostedConversationClient : IHostedConversationClient + { + public Func>? CreateAsyncCallback { get; set; } + + public Task CreateAsync(HostedConversationCreationOptions? options = null, CancellationToken cancellationToken = default) + => CreateAsyncCallback?.Invoke(options, cancellationToken) ?? Task.FromResult(new HostedConversation { ConversationId = "test" }); + + public Task GetAsync(string conversationId, CancellationToken cancellationToken = default) + => Task.FromResult(new HostedConversation { ConversationId = conversationId }); + + public Task DeleteAsync(string conversationId, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task AddMessagesAsync(string conversationId, IEnumerable messages, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public IAsyncEnumerable GetMessagesAsync(string conversationId, CancellationToken cancellationToken = default) + => EmptyAsync(); + + private static async IAsyncEnumerable EmptyAsync() + { + await Task.CompletedTask; + yield break; + } + + public object? GetService(Type serviceType, object? serviceKey = null) + => serviceType is not null && serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; + + public void Dispose() + { + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/HostedConversation/HostedConversationChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/HostedConversation/HostedConversationChatClientTests.cs new file mode 100644 index 00000000000..5191859fe80 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/HostedConversation/HostedConversationChatClientTests.cs @@ -0,0 +1,170 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable MEAI001 + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class HostedConversationChatClientTests +{ + [Fact] + public void GetService_IHostedConversationClient_ReturnsConversationClient() + { + // Arrange + using var innerChatClient = new TestChatClient(); + using var conversationClient = new TestHostedConversationClient(); + using var client = new HostedConversationChatClient(innerChatClient, conversationClient); + + // Act + var result = client.GetService(typeof(IHostedConversationClient)); + + // Assert + Assert.Same(conversationClient, result); + } + + [Fact] + public void GetService_OtherTypes_DelegatesToInner() + { + // Arrange + using var innerChatClient = new TestChatClient(); + using var conversationClient = new TestHostedConversationClient(); + using var client = new HostedConversationChatClient(innerChatClient, conversationClient); + + // Act - ask for the inner chat client type + var result = client.GetService(typeof(TestChatClient)); + + // Assert - DelegatingChatClient base dispatches to inner + Assert.Same(innerChatClient, result); + } + + [Fact] + public void GetService_WithServiceKey_DelegatesToInner() + { + // Arrange + var serviceKey = new object(); + var expectedResult = new object(); + using var innerChatClient = new TestChatClient + { + GetServiceCallback = (type, key) => key == serviceKey ? expectedResult : null + }; + using var conversationClient = new TestHostedConversationClient(); + using var client = new HostedConversationChatClient(innerChatClient, conversationClient); + + // Act - when a key is provided, it should delegate even for IHostedConversationClient + var result = client.GetService(typeof(IHostedConversationClient), serviceKey); + + // Assert + Assert.Same(expectedResult, result); + } + + [Fact] + public async Task GetResponseAsync_PassesThroughUnchanged() + { + // Arrange + var expectedMessages = new List { new(ChatRole.User, "Hello") }; + var expectedOptions = new ChatOptions(); + var expectedResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Hi")); + + using var innerChatClient = new TestChatClient + { + GetResponseAsyncCallback = (messages, options, _) => + { + Assert.Same(expectedMessages, messages); + Assert.Same(expectedOptions, options); + return Task.FromResult(expectedResponse); + } + }; + using var conversationClient = new TestHostedConversationClient(); + using var client = new HostedConversationChatClient(innerChatClient, conversationClient); + + // Act + var response = await client.GetResponseAsync(expectedMessages, expectedOptions); + + // Assert + Assert.Same(expectedResponse, response); + } + + [Fact] + public async Task GetStreamingResponseAsync_PassesThroughUnchanged() + { + // Arrange + var expectedUpdate = new ChatResponseUpdate(ChatRole.Assistant, "streaming"); + using var innerChatClient = new TestChatClient + { + GetStreamingResponseAsyncCallback = (_, _, _) => YieldAsync(expectedUpdate) + }; + using var conversationClient = new TestHostedConversationClient(); + using var client = new HostedConversationChatClient(innerChatClient, conversationClient); + + // Act + var updates = new List(); + await foreach (var update in client.GetStreamingResponseAsync([new(ChatRole.User, "test")])) + { + updates.Add(update); + } + + // Assert + Assert.Single(updates); + Assert.Same(expectedUpdate, updates[0]); + } + + [Fact] + public void Constructor_NullInnerChatClient_Throws() + { + using var conversationClient = new TestHostedConversationClient(); + Assert.Throws("innerClient", () => new HostedConversationChatClient(null!, conversationClient)); + } + + [Fact] + public void Constructor_NullConversationClient_Throws() + { + using var innerChatClient = new TestChatClient(); + Assert.Throws("hostedConversationClient", () => new HostedConversationChatClient(innerChatClient, null!)); + } + + private static async IAsyncEnumerable YieldAsync(params ChatResponseUpdate[] updates) + { + await Task.Yield(); + foreach (var update in updates) + { + yield return update; + } + } + + private sealed class TestHostedConversationClient : IHostedConversationClient + { + public Task CreateAsync(HostedConversationCreationOptions? options = null, CancellationToken cancellationToken = default) + => Task.FromResult(new HostedConversation { ConversationId = "test" }); + + public Task GetAsync(string conversationId, CancellationToken cancellationToken = default) + => Task.FromResult(new HostedConversation { ConversationId = conversationId }); + + public Task DeleteAsync(string conversationId, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task AddMessagesAsync(string conversationId, IEnumerable messages, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public IAsyncEnumerable GetMessagesAsync(string conversationId, CancellationToken cancellationToken = default) + => EmptyAsync(); + + private static async IAsyncEnumerable EmptyAsync() + { + await Task.CompletedTask; + yield break; + } + + public object? GetService(Type serviceType, object? serviceKey = null) + => serviceType is not null && serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; + + public void Dispose() + { + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/HostedConversation/HostedConversationClientBuilderTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/HostedConversation/HostedConversationClientBuilderTest.cs new file mode 100644 index 00000000000..2006148d607 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/HostedConversation/HostedConversationClientBuilderTest.cs @@ -0,0 +1,152 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable MEAI001 + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class HostedConversationClientBuilderTest +{ + [Fact] + public void Build_WithSimpleInnerClient_Works() + { + // Arrange + using var innerClient = new TestHostedConversationClient(); + var builder = new HostedConversationClientBuilder(innerClient); + + // Act + var result = builder.Build(); + + // Assert + Assert.Same(innerClient, result); + } + + [Fact] + public void Use_AddsMiddlewareInCorrectOrder() + { + // Arrange + using var innerClient = new TestHostedConversationClient(); + var builder = new HostedConversationClientBuilder(innerClient); + + builder.Use(next => new NamedDelegatingHostedConversationClient("First", next)); + builder.Use(next => new NamedDelegatingHostedConversationClient("Second", next)); + builder.Use(next => new NamedDelegatingHostedConversationClient("Third", next)); + + // Act + var first = (NamedDelegatingHostedConversationClient)builder.Build(); + + // Assert - outermost is first added + Assert.Equal("First", first.Name); + var second = (NamedDelegatingHostedConversationClient)first.GetInnerClient(); + Assert.Equal("Second", second.Name); + var third = (NamedDelegatingHostedConversationClient)second.GetInnerClient(); + Assert.Equal("Third", third.Name); + } + + [Fact] + public void Build_WithIServiceProvider_PassesServiceProvider() + { + // Arrange + var expectedServiceProvider = new ServiceCollection().BuildServiceProvider(); + using var expectedInnerClient = new TestHostedConversationClient(); + using var expectedOuterClient = new TestHostedConversationClient(); + + var builder = new HostedConversationClientBuilder(services => + { + Assert.Same(expectedServiceProvider, services); + return expectedInnerClient; + }); + + builder.Use((innerClient, serviceProvider) => + { + Assert.Same(expectedServiceProvider, serviceProvider); + Assert.Same(expectedInnerClient, innerClient); + return expectedOuterClient; + }); + + // Act & Assert + Assert.Same(expectedOuterClient, builder.Build(expectedServiceProvider)); + } + + [Fact] + public void Constructor_NullInnerClient_Throws() + { + Assert.Throws("innerClient", () => new HostedConversationClientBuilder((IHostedConversationClient)null!)); + } + + [Fact] + public void Constructor_NullFactory_Throws() + { + Assert.Throws("innerClientFactory", () => new HostedConversationClientBuilder((Func)null!)); + } + + [Fact] + public void Use_NullFactory_Throws() + { + using var innerClient = new TestHostedConversationClient(); + var builder = new HostedConversationClientBuilder(innerClient); + Assert.Throws("clientFactory", () => builder.Use((Func)null!)); + Assert.Throws("clientFactory", () => builder.Use((Func)null!)); + } + + [Fact] + public void Build_FactoryReturnsNull_Throws() + { + using var innerClient = new TestHostedConversationClient(); + var builder = new HostedConversationClientBuilder(innerClient); + builder.Use(_ => null!); + var ex = Assert.Throws(() => builder.Build()); + Assert.Contains("entry at index 0", ex.Message); + } + + private sealed class NamedDelegatingHostedConversationClient : DelegatingHostedConversationClient + { + public NamedDelegatingHostedConversationClient(string name, IHostedConversationClient innerClient) + : base(innerClient) + { + Name = name; + } + + public string Name { get; } + + public IHostedConversationClient GetInnerClient() => InnerClient; + } + + private sealed class TestHostedConversationClient : IHostedConversationClient + { + public Task CreateAsync(HostedConversationCreationOptions? options = null, CancellationToken cancellationToken = default) + => Task.FromResult(new HostedConversation { ConversationId = "test" }); + + public Task GetAsync(string conversationId, CancellationToken cancellationToken = default) + => Task.FromResult(new HostedConversation { ConversationId = conversationId }); + + public Task DeleteAsync(string conversationId, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task AddMessagesAsync(string conversationId, IEnumerable messages, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public IAsyncEnumerable GetMessagesAsync(string conversationId, CancellationToken cancellationToken = default) + => EmptyAsync(); + + private static async IAsyncEnumerable EmptyAsync() + { + await Task.CompletedTask; + yield break; + } + + public object? GetService(Type serviceType, object? serviceKey = null) + => serviceType is not null && serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; + + public void Dispose() + { + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/HostedConversation/LoggingHostedConversationClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/HostedConversation/LoggingHostedConversationClientTests.cs new file mode 100644 index 00000000000..0dde3d8117a --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/HostedConversation/LoggingHostedConversationClientTests.cs @@ -0,0 +1,205 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable MEAI001 + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Testing; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class LoggingHostedConversationClientTests +{ + [Fact] + public void Constructor_InvalidArgs_Throws() + { + using var innerClient = new TestHostedConversationClient(); + Assert.Throws("innerClient", () => new LoggingHostedConversationClient(null!, NullLogger.Instance)); + Assert.Throws("logger", () => new LoggingHostedConversationClient(innerClient, null!)); + } + + [Fact] + public void Constructor_ValidArgs_CreatesWithoutError() + { + using var innerClient = new TestHostedConversationClient(); + using var client = new LoggingHostedConversationClient(innerClient, NullLogger.Instance); + Assert.NotNull(client); + } + + [Theory] + [InlineData(LogLevel.Trace)] + [InlineData(LogLevel.Debug)] + [InlineData(LogLevel.Information)] + public async Task CreateAsync_LogsInvocationAndCompletion(LogLevel level) + { + // Arrange + var collector = new FakeLogCollector(); + ServiceCollection c = new(); + c.AddLogging(b => b.AddProvider(new FakeLoggerProvider(collector)).SetMinimumLevel(level)); + var services = c.BuildServiceProvider(); + + using var innerClient = new TestHostedConversationClient + { + CreateAsyncCallback = (_, _) => + Task.FromResult(new HostedConversation { ConversationId = "conv-1" }) + }; + + var builder = new HostedConversationClientBuilder(innerClient); + builder.UseLogging(services.GetRequiredService()); + using var client = builder.Build(services); + + // Act + await client.CreateAsync(new HostedConversationCreationOptions()); + + // Assert + var logs = collector.GetSnapshot(); + if (level is LogLevel.Trace) + { + Assert.True(logs.Count >= 2); + Assert.Contains(logs, e => e.Message.Contains("CreateAsync") && e.Message.Contains("invoked")); + Assert.Contains(logs, e => e.Message.Contains("CreateAsync") && e.Message.Contains("completed")); + } + else if (level is LogLevel.Debug) + { + Assert.True(logs.Count >= 2); + Assert.Contains(logs, e => e.Message.Contains("CreateAsync") && e.Message.Contains("invoked")); + Assert.Contains(logs, e => e.Message.Contains("CreateAsync") && e.Message.Contains("completed")); + } + else + { + Assert.Empty(logs); + } + } + + [Theory] + [InlineData(LogLevel.Trace)] + [InlineData(LogLevel.Debug)] + [InlineData(LogLevel.Information)] + public async Task GetAsync_LogsInvocationAndCompletion(LogLevel level) + { + // Arrange + var collector = new FakeLogCollector(); + ServiceCollection c = new(); + c.AddLogging(b => b.AddProvider(new FakeLoggerProvider(collector)).SetMinimumLevel(level)); + var services = c.BuildServiceProvider(); + + using var innerClient = new TestHostedConversationClient + { + GetAsyncCallback = (id, _) => + Task.FromResult(new HostedConversation { ConversationId = id }) + }; + + var builder = new HostedConversationClientBuilder(innerClient); + builder.UseLogging(services.GetRequiredService()); + using var client = builder.Build(services); + + // Act + await client.GetAsync("conv-42"); + + // Assert + var logs = collector.GetSnapshot(); + if (level <= LogLevel.Debug) + { + Assert.True(logs.Count >= 2); + Assert.Contains(logs, e => e.Message.Contains("GetAsync") && e.Message.Contains("conv-42")); + Assert.Contains(logs, e => e.Message.Contains("GetAsync") && e.Message.Contains("completed")); + } + else + { + Assert.Empty(logs); + } + } + + [Theory] + [InlineData(LogLevel.Trace)] + [InlineData(LogLevel.Debug)] + [InlineData(LogLevel.Information)] + public async Task DeleteAsync_LogsInvocationAndCompletion(LogLevel level) + { + // Arrange + var collector = new FakeLogCollector(); + ServiceCollection c = new(); + c.AddLogging(b => b.AddProvider(new FakeLoggerProvider(collector)).SetMinimumLevel(level)); + var services = c.BuildServiceProvider(); + + using var innerClient = new TestHostedConversationClient + { + DeleteAsyncCallback = (_, _) => Task.CompletedTask + }; + + var builder = new HostedConversationClientBuilder(innerClient); + builder.UseLogging(services.GetRequiredService()); + using var client = builder.Build(services); + + // Act + await client.DeleteAsync("conv-99"); + + // Assert + var logs = collector.GetSnapshot(); + if (level <= LogLevel.Debug) + { + Assert.True(logs.Count >= 2); + Assert.Contains(logs, e => e.Message.Contains("DeleteAsync") && e.Message.Contains("conv-99")); + Assert.Contains(logs, e => e.Message.Contains("DeleteAsync") && e.Message.Contains("completed")); + } + else + { + Assert.Empty(logs); + } + } + + [Fact] + public void UseLogging_AvoidsInjectingNopClient() + { + using var innerClient = new TestHostedConversationClient(); + + var builder = new HostedConversationClientBuilder(innerClient); + builder.UseLogging(NullLoggerFactory.Instance); + using var built = builder.Build(); + + // When NullLoggerFactory is used, LoggingHostedConversationClient should be skipped + Assert.Null(built.GetService(typeof(LoggingHostedConversationClient))); + } + + private sealed class TestHostedConversationClient : IHostedConversationClient + { + public Func>? CreateAsyncCallback { get; set; } + public Func>? GetAsyncCallback { get; set; } + public Func? DeleteAsyncCallback { get; set; } + + public Task CreateAsync(HostedConversationCreationOptions? options = null, CancellationToken cancellationToken = default) + => CreateAsyncCallback?.Invoke(options, cancellationToken) ?? Task.FromResult(new HostedConversation { ConversationId = "test" }); + + public Task GetAsync(string conversationId, CancellationToken cancellationToken = default) + => GetAsyncCallback?.Invoke(conversationId, cancellationToken) ?? Task.FromResult(new HostedConversation { ConversationId = conversationId }); + + public Task DeleteAsync(string conversationId, CancellationToken cancellationToken = default) + => DeleteAsyncCallback?.Invoke(conversationId, cancellationToken) ?? Task.CompletedTask; + + public Task AddMessagesAsync(string conversationId, IEnumerable messages, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public IAsyncEnumerable GetMessagesAsync(string conversationId, CancellationToken cancellationToken = default) + => EmptyAsync(); + + private static async IAsyncEnumerable EmptyAsync() + { + await Task.CompletedTask; + yield break; + } + + public object? GetService(Type serviceType, object? serviceKey = null) + => serviceType is not null && serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; + + public void Dispose() + { + } + } +} From 8a3d2e884a48885f43a2859dc59eacee645c7216 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Wed, 18 Mar 2026 12:39:01 +0000 Subject: [PATCH 2/3] Address PR review feedback from @stephentoub and @qubitron - Refactor HostedConversationCreationOptions to HostedConversationClientOptions (single shared options type across all operations, matching IHostedFileClient pattern with Limit, RawRepresentationFactory, AdditionalProperties) - Remove Metadata property from HostedConversation and options (use AdditionalProperties only, same as IHostedFileClient) - Remove Messages from creation options (OpenAI-only feature; use RawRepresentationFactory or AddMessagesAsync instead) - Rewrite OpenTelemetryHostedConversationClient to follow files.* pattern (conversations.* namespace with disclaimer, no gen_ai.* since no OTel semantic convention exists for conversation operations) - Remove HostedConversationChatClient bridge and UseHostedConversations() builder extension per Stephen's feedback - Update Azure Foundry section in provider mapping doc (v2 uses OpenAI directly, not deprecated Threads API per qubitron's feedback) --- docs/HostedConversation-ProviderMapping.md | 10 +- .../DelegatingHostedConversationClient.cs | 14 +- .../HostedConversation/HostedConversation.cs | 3 - .../HostedConversationClientOptions.cs | 71 ++++++ .../HostedConversationCreationOptions.cs | 69 ------ .../IHostedConversationClient.cs | 10 +- .../OpenAIHostedConversationClient.cs | 50 +++-- ...onfigureOptionsHostedConversationClient.cs | 47 +++- ...stedConversationClientBuilderExtensions.cs | 10 +- .../HostedConversationChatClient.cs | 47 ---- ...ConversationChatClientBuilderExtensions.cs | 47 ---- .../LoggingHostedConversationClient.cs | 22 +- .../OpenTelemetryHostedConversationClient.cs | 207 +++++++++++------- ...stedConversationClientBuilderExtensions.cs | 7 +- ...DelegatingHostedConversationClientTests.cs | 46 ++-- ...> HostedConversationClientOptionsTests.cs} | 127 ++++------- .../HostedConversationTests.cs | 19 -- ...ureOptionsHostedConversationClientTests.cs | 40 ++-- .../HostedConversationChatClientTests.cs | 170 -------------- .../HostedConversationClientBuilderTest.cs | 10 +- .../LoggingHostedConversationClientTests.cs | 26 +-- 21 files changed, 410 insertions(+), 642 deletions(-) create mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/HostedConversationClientOptions.cs delete mode 100644 src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/HostedConversationCreationOptions.cs delete mode 100644 src/Libraries/Microsoft.Extensions.AI/HostedConversation/HostedConversationChatClient.cs delete mode 100644 src/Libraries/Microsoft.Extensions.AI/HostedConversation/HostedConversationChatClientBuilderExtensions.cs rename test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedConversation/{HostedConversationCreationOptionsTests.cs => HostedConversationClientOptionsTests.cs} (50%) delete mode 100644 test/Libraries/Microsoft.Extensions.AI.Tests/HostedConversation/HostedConversationChatClientTests.cs diff --git a/docs/HostedConversation-ProviderMapping.md b/docs/HostedConversation-ProviderMapping.md index 91d2b129a52..a80776b8054 100644 --- a/docs/HostedConversation-ProviderMapping.md +++ b/docs/HostedConversation-ProviderMapping.md @@ -26,13 +26,9 @@ ### Azure AI Foundry -- Maps to Thread/Message APIs in Agent Service SDK -- `CreateAsync` → `threads.create()` -- `GetAsync` → `threads.get()` -- `DeleteAsync` → `threads.delete()` -- `AddMessagesAsync` → `messages.create()` (one per message) -- `GetMessagesAsync` → `messages.list()` -- **Gaps**: Thread model includes Run/Agent concepts not in our abstraction; use `AdditionalProperties` for agent-specific metadata +- **Azure Foundry v2** uses the OpenAI Responses API directly, so the OpenAI `IHostedConversationClient` implementation works for Azure Foundry v2 without a separate adapter +- The deprecated v1 Agent Service SDK mapped to Thread/Message APIs (`threads.create()`, `threads.get()`, `threads.delete()`, `messages.create()`, `messages.list()`), but this is no longer the recommended path +- **Gaps**: Agent-specific concepts (Runs, Agents) are not in our abstraction; use `AdditionalProperties` for agent-specific metadata ### AWS Bedrock diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/DelegatingHostedConversationClient.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/DelegatingHostedConversationClient.cs index b25287da449..58fc1b08384 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/DelegatingHostedConversationClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/DelegatingHostedConversationClient.cs @@ -43,34 +43,38 @@ public void Dispose() /// public virtual Task CreateAsync( - HostedConversationCreationOptions? options = null, + HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default) => InnerClient.CreateAsync(options, cancellationToken); /// public virtual Task GetAsync( string conversationId, + HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default) => - InnerClient.GetAsync(conversationId, cancellationToken); + InnerClient.GetAsync(conversationId, options, cancellationToken); /// public virtual Task DeleteAsync( string conversationId, + HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default) => - InnerClient.DeleteAsync(conversationId, cancellationToken); + InnerClient.DeleteAsync(conversationId, options, cancellationToken); /// public virtual Task AddMessagesAsync( string conversationId, IEnumerable messages, + HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default) => - InnerClient.AddMessagesAsync(conversationId, messages, cancellationToken); + InnerClient.AddMessagesAsync(conversationId, messages, options, cancellationToken); /// public virtual IAsyncEnumerable GetMessagesAsync( string conversationId, + HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default) => - InnerClient.GetMessagesAsync(conversationId, cancellationToken); + InnerClient.GetMessagesAsync(conversationId, options, cancellationToken); /// public virtual object? GetService(Type serviceType, object? serviceKey = null) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/HostedConversation.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/HostedConversation.cs index 000d923d790..6d5b3e4df7b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/HostedConversation.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/HostedConversation.cs @@ -18,9 +18,6 @@ public class HostedConversation /// Gets or sets the creation timestamp. public DateTimeOffset? CreatedAt { get; set; } - /// Gets or sets metadata associated with the conversation. - public AdditionalPropertiesDictionary? Metadata { get; set; } - /// Gets or sets the raw representation of the conversation from the underlying provider. /// /// If a is created to represent some underlying object from another object diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/HostedConversationClientOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/HostedConversationClientOptions.cs new file mode 100644 index 00000000000..bbf8228d2d6 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/HostedConversationClientOptions.cs @@ -0,0 +1,71 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Extensions.AI; + +/// Represents the options for a hosted conversation client request. +[Experimental(DiagnosticIds.Experiments.AIHostedConversation, UrlFormat = DiagnosticIds.UrlFormat)] +public class HostedConversationClientOptions +{ + /// + /// Initializes a new instance of the class. + /// + public HostedConversationClientOptions() + { + } + + /// + /// Initializes a new instance of the class + /// by cloning the properties of another instance. + /// + /// The instance to clone. + protected HostedConversationClientOptions(HostedConversationClientOptions? other) + { + if (other is null) + { + return; + } + + Limit = other.Limit; + RawRepresentationFactory = other.RawRepresentationFactory; + AdditionalProperties = other.AdditionalProperties?.Clone(); + } + + /// Gets or sets the maximum number of items to return in a list operation. + /// + /// If not specified, the provider's default limit will be used. + /// + public int? Limit { get; set; } + + /// + /// Gets or sets a callback responsible for creating the raw representation of the conversation client options from an underlying implementation. + /// + /// + /// The underlying implementation may have its own representation of options. + /// When an operation is invoked with a , that implementation may convert + /// the provided options into its own representation in order to use it while performing the operation. + /// For situations where a consumer knows which concrete is being used + /// and how it represents options, a new instance of that implementation-specific options type may be returned + /// by this callback, for the implementation to use instead of creating a new + /// instance. Such implementations may mutate the supplied options instance further based on other settings + /// supplied on this instance or from other inputs, + /// therefore, it is strongly recommended to not return shared instances and instead make the callback + /// return a new instance on each call. + /// This is typically used to set an implementation-specific setting that isn't otherwise exposed from the strongly typed + /// properties on . + /// + [JsonIgnore] + public Func? RawRepresentationFactory { get; set; } + + /// Gets or sets additional properties for the request. + public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } + + /// Creates a shallow clone of the current instance. + /// A shallow clone of the current instance. + public virtual HostedConversationClientOptions Clone() => new(this); +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/HostedConversationCreationOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/HostedConversationCreationOptions.cs deleted file mode 100644 index ce719d584c6..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/HostedConversationCreationOptions.cs +++ /dev/null @@ -1,69 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Text.Json.Serialization; -using Microsoft.Shared.DiagnosticIds; - -namespace Microsoft.Extensions.AI; - -/// Represents the options for creating a hosted conversation. -[Experimental(DiagnosticIds.Experiments.AIHostedConversation, UrlFormat = DiagnosticIds.UrlFormat)] -public class HostedConversationCreationOptions -{ - /// Initializes a new instance of the class. - public HostedConversationCreationOptions() - { - } - - /// Initializes a new instance of the class, performing a shallow copy of all properties from . - protected HostedConversationCreationOptions(HostedConversationCreationOptions? other) - { - if (other is null) - { - return; - } - - AdditionalProperties = other.AdditionalProperties?.Clone(); - Metadata = other.Metadata is not null ? new(other.Metadata) : null; - RawRepresentationFactory = other.RawRepresentationFactory; - - if (other.Messages is not null) - { - Messages = [.. other.Messages]; - } - } - - /// Gets or sets metadata to associate with the conversation. - public AdditionalPropertiesDictionary? Metadata { get; set; } - - /// Gets or sets initial messages to populate the conversation. - public IList? Messages { get; set; } - - /// - /// Gets or sets a callback responsible for creating the raw representation of the conversation creation options from an underlying implementation. - /// - /// - /// The underlying implementation may have its own representation of options. - /// When operations are invoked with a , - /// that implementation may convert the provided options into its own representation in order to use it while performing the operation. - /// For situations where a consumer knows which concrete is being used and how it represents options, - /// a new instance of that implementation-specific options type may be returned by this callback, for the - /// implementation to use instead of creating a new instance. Such implementations may mutate the supplied options - /// instance further based on other settings supplied on this instance or from other inputs, - /// therefore, it is strongly recommended to not return shared instances and instead make the callback return a new instance on each call. - /// This is typically used to set an implementation-specific setting that isn't otherwise exposed from the strongly typed - /// properties on . - /// - [JsonIgnore] - public Func? RawRepresentationFactory { get; set; } - - /// Gets or sets any additional properties associated with the options. - public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } - - /// Produces a clone of the current instance. - /// A clone of the current instance. - public virtual HostedConversationCreationOptions Clone() => new(this); -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/IHostedConversationClient.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/IHostedConversationClient.cs index cc99cff0fae..8ed56abe2ad 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/IHostedConversationClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/IHostedConversationClient.cs @@ -32,30 +32,35 @@ public interface IHostedConversationClient : IDisposable /// The to monitor for cancellation requests. The default is . /// The created . Task CreateAsync( - HostedConversationCreationOptions? options = null, + HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default); /// Retrieves an existing hosted conversation by its identifier. /// The unique identifier of the conversation to retrieve. + /// The options for the request. /// The to monitor for cancellation requests. The default is . /// The matching the specified . /// is . Task GetAsync( string conversationId, + HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default); /// Deletes an existing hosted conversation. /// The unique identifier of the conversation to delete. + /// The options for the request. /// The to monitor for cancellation requests. The default is . /// A representing the asynchronous operation. /// is . Task DeleteAsync( string conversationId, + HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default); /// Adds messages to an existing hosted conversation. /// The unique identifier of the conversation to add messages to. /// The sequence of chat messages to add to the conversation. + /// The options for the request. /// The to monitor for cancellation requests. The default is . /// A representing the asynchronous operation. /// is . @@ -63,15 +68,18 @@ Task DeleteAsync( Task AddMessagesAsync( string conversationId, IEnumerable messages, + HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default); /// Lists the messages in an existing hosted conversation. /// The unique identifier of the conversation to list messages from. + /// The options for the request. /// The to monitor for cancellation requests. The default is . /// An asynchronous sequence of instances from the conversation. /// is . IAsyncEnumerable GetMessagesAsync( string conversationId, + HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default); /// Asks the for an object of the specified type . diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIHostedConversationClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIHostedConversationClient.cs index a004bd167e4..9ba556dc950 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIHostedConversationClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIHostedConversationClient.cs @@ -41,7 +41,7 @@ public OpenAIHostedConversationClient(ConversationClient conversationClient) /// public async Task CreateAsync( - HostedConversationCreationOptions? options = null, + HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default) { using BinaryContent content = CreateOrGetCreatePayload(options); @@ -55,6 +55,7 @@ public async Task CreateAsync( /// public async Task GetAsync( string conversationId, + HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default) { _ = Throw.IfNull(conversationId); @@ -69,6 +70,7 @@ public async Task GetAsync( /// public async Task DeleteAsync( string conversationId, + HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default) { _ = Throw.IfNull(conversationId); @@ -81,6 +83,7 @@ public async Task DeleteAsync( public async Task AddMessagesAsync( string conversationId, IEnumerable messages, + HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default) { _ = Throw.IfNull(conversationId); @@ -95,10 +98,12 @@ public async Task AddMessagesAsync( /// public async IAsyncEnumerable GetMessagesAsync( string conversationId, + HostedConversationClientOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { _ = Throw.IfNull(conversationId); + int? limit = options?.Limit; RequestOptions requestOptions = cancellationToken.ToRequestOptions(streaming: false); // Manual pagination: the SDK's GetRawPagesAsync() only yields a single page because @@ -109,7 +114,7 @@ public async IAsyncEnumerable GetMessagesAsync( do { AsyncCollectionResult pages = _conversationClient.GetConversationItemsAsync( - conversationId, limit: null, order: null, after: after, include: null, options: requestOptions); + conversationId, limit: limit, order: null, after: after, include: null, options: requestOptions); bool hasMore = false; string? lastId = null; @@ -163,7 +168,7 @@ public async IAsyncEnumerable GetMessagesAsync( } /// Creates a for the create conversation request, using the raw representation factory if available. - private BinaryContent CreateOrGetCreatePayload(HostedConversationCreationOptions? options) + private BinaryContent CreateOrGetCreatePayload(HostedConversationClientOptions? options) { if (options?.RawRepresentationFactory?.Invoke(this) is BinaryContent rawContent) { @@ -192,26 +197,26 @@ private static BinaryContent CreateBinaryContent(ActionWrites the JSON payload for creating a conversation. - private static void WriteCreatePayload(Utf8JsonWriter writer, HostedConversationCreationOptions? options) + private static void WriteCreatePayload(Utf8JsonWriter writer, HostedConversationClientOptions? options) { writer.WriteStartObject(); - if (options?.Metadata is { Count: > 0 } metadata) + if (options?.AdditionalProperties is { Count: > 0 } additionalProperties) { - writer.WritePropertyName("metadata"); - writer.WriteStartObject(); - foreach (var kvp in metadata) + // Map "metadata" from AdditionalProperties if present as a dictionary of string values. + if (additionalProperties.TryGetValue("metadata", out object? metadataObj) && + metadataObj is AdditionalPropertiesDictionary metadata && + metadata.Count > 0) { - writer.WriteString(kvp.Key, kvp.Value); - } - - writer.WriteEndObject(); - } + writer.WritePropertyName("metadata"); + writer.WriteStartObject(); + foreach (var kvp in metadata) + { + writer.WriteString(kvp.Key, kvp.Value); + } - if (options?.Messages is { Count: > 0 } messages) - { - writer.WritePropertyName("items"); - WriteMessagesArray(writer, messages); + writer.WriteEndObject(); + } } writer.WriteEndObject(); @@ -321,13 +326,20 @@ private static HostedConversation ParseConversation(ClientResult result) using JsonDocument doc = JsonDocument.Parse(result.GetRawResponse().Content); JsonElement root = doc.RootElement; - return new HostedConversation + var conversation = new HostedConversation { ConversationId = root.TryGetProperty("id", out JsonElement idElement) ? idElement.GetString() : null, CreatedAt = root.TryGetProperty("created_at", out JsonElement createdAtElement) && createdAtElement.ValueKind == JsonValueKind.Number ? DateTimeOffset.FromUnixTimeSeconds(createdAtElement.GetInt64()) : null, - Metadata = ParseMetadata(root), RawRepresentation = result, }; + + if (ParseMetadata(root) is { } metadata) + { + conversation.AdditionalProperties ??= new(); + conversation.AdditionalProperties["metadata"] = metadata; + } + + return conversation; } /// Attempts to convert a JSON element representing a conversation item to a . diff --git a/src/Libraries/Microsoft.Extensions.AI/HostedConversation/ConfigureOptionsHostedConversationClient.cs b/src/Libraries/Microsoft.Extensions.AI/HostedConversation/ConfigureOptionsHostedConversationClient.cs index 37683644858..7df9aa0ef24 100644 --- a/src/Libraries/Microsoft.Extensions.AI/HostedConversation/ConfigureOptionsHostedConversationClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/HostedConversation/ConfigureOptionsHostedConversationClient.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; @@ -10,25 +11,25 @@ namespace Microsoft.Extensions.AI; -/// Represents a delegating hosted conversation client that configures a instance used by the remainder of the pipeline. +/// Represents a delegating hosted conversation client that configures a instance used by the remainder of the pipeline. [Experimental(DiagnosticIds.Experiments.AIHostedConversation, UrlFormat = DiagnosticIds.UrlFormat)] public sealed class ConfigureOptionsHostedConversationClient : DelegatingHostedConversationClient { /// The callback delegate used to configure options. - private readonly Action _configureOptions; + private readonly Action _configureOptions; /// Initializes a new instance of the class with the specified callback. /// The inner client. /// - /// The delegate to invoke to configure the instance. It is passed a clone of the caller-supplied instance + /// The delegate to invoke to configure the instance. It is passed a clone of the caller-supplied instance /// (or a newly constructed instance if the caller-supplied instance is ). /// /// - /// The delegate is passed either a new instance of if - /// the caller didn't supply a instance, or a clone (via ) of the caller-supplied + /// The delegate is passed either a new instance of if + /// the caller didn't supply a instance, or a clone (via ) of the caller-supplied /// instance if one was supplied. /// - public ConfigureOptionsHostedConversationClient(IHostedConversationClient innerClient, Action configure) + public ConfigureOptionsHostedConversationClient(IHostedConversationClient innerClient, Action configure) : base(innerClient) { _configureOptions = Throw.IfNull(configure); @@ -36,13 +37,41 @@ public ConfigureOptionsHostedConversationClient(IHostedConversationClient innerC /// public override async Task CreateAsync( - HostedConversationCreationOptions? options = null, CancellationToken cancellationToken = default) + HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default) { return await base.CreateAsync(Configure(options), cancellationToken); } - /// Creates and configures the to pass along to the inner client. - private HostedConversationCreationOptions Configure(HostedConversationCreationOptions? options) + /// + public override async Task GetAsync( + string conversationId, HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default) + { + return await base.GetAsync(conversationId, Configure(options), cancellationToken); + } + + /// + public override async Task DeleteAsync( + string conversationId, HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default) + { + await base.DeleteAsync(conversationId, Configure(options), cancellationToken); + } + + /// + public override async Task AddMessagesAsync( + string conversationId, IEnumerable messages, HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default) + { + await base.AddMessagesAsync(conversationId, messages, Configure(options), cancellationToken); + } + + /// + public override IAsyncEnumerable GetMessagesAsync( + string conversationId, HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default) + { + return base.GetMessagesAsync(conversationId, Configure(options), cancellationToken); + } + + /// Creates and configures the to pass along to the inner client. + private HostedConversationClientOptions Configure(HostedConversationClientOptions? options) { options = options?.Clone() ?? new(); diff --git a/src/Libraries/Microsoft.Extensions.AI/HostedConversation/ConfigureOptionsHostedConversationClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/HostedConversation/ConfigureOptionsHostedConversationClientBuilderExtensions.cs index 0033e326255..7046aec6490 100644 --- a/src/Libraries/Microsoft.Extensions.AI/HostedConversation/ConfigureOptionsHostedConversationClientBuilderExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/HostedConversation/ConfigureOptionsHostedConversationClientBuilderExtensions.cs @@ -13,21 +13,21 @@ namespace Microsoft.Extensions.AI; public static class ConfigureOptionsHostedConversationClientBuilderExtensions { /// - /// Adds a callback that configures a to be passed to the next client in the pipeline. + /// Adds a callback that configures a to be passed to the next client in the pipeline. /// /// The . /// - /// The delegate to invoke to configure the instance. - /// It is passed a clone of the caller-supplied instance (or a newly constructed instance if the caller-supplied instance is ). + /// The delegate to invoke to configure the instance. + /// It is passed a clone of the caller-supplied instance (or a newly constructed instance if the caller-supplied instance is ). /// /// /// This method can be used to set default options. The delegate is passed either a new instance of - /// if the caller didn't supply a instance, or a clone (via ) + /// if the caller didn't supply a instance, or a clone (via ) /// of the caller-supplied instance if one was supplied. /// /// The . public static HostedConversationClientBuilder ConfigureOptions( - this HostedConversationClientBuilder builder, Action configure) + this HostedConversationClientBuilder builder, Action configure) { _ = Throw.IfNull(builder); _ = Throw.IfNull(configure); diff --git a/src/Libraries/Microsoft.Extensions.AI/HostedConversation/HostedConversationChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/HostedConversation/HostedConversationChatClient.cs deleted file mode 100644 index 1d63b0c9391..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI/HostedConversation/HostedConversationChatClient.cs +++ /dev/null @@ -1,47 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Diagnostics.CodeAnalysis; -using Microsoft.Shared.DiagnosticIds; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Extensions.AI; - -/// -/// A delegating chat client that makes an discoverable -/// via . -/// -/// -/// This middleware passes through all chat operations unchanged. Its sole purpose is to hold a reference -/// to an and return it when requested through the service discovery mechanism. -/// -[Experimental(DiagnosticIds.Experiments.AIHostedConversation, UrlFormat = DiagnosticIds.UrlFormat)] -public sealed class HostedConversationChatClient : DelegatingChatClient -{ -#pragma warning disable CA2213 // Disposable fields should be disposed - not owned by this instance - private readonly IHostedConversationClient _hostedConversationClient; -#pragma warning restore CA2213 - - /// Initializes a new instance of the class. - /// The inner . - /// The to make discoverable. - public HostedConversationChatClient(IChatClient innerClient, IHostedConversationClient hostedConversationClient) - : base(innerClient) - { - _hostedConversationClient = Throw.IfNull(hostedConversationClient); - } - - /// - public override object? GetService(Type serviceType, object? serviceKey = null) - { - _ = Throw.IfNull(serviceType); - - if (serviceKey is null && serviceType.IsInstanceOfType(_hostedConversationClient)) - { - return _hostedConversationClient; - } - - return base.GetService(serviceType, serviceKey); - } -} diff --git a/src/Libraries/Microsoft.Extensions.AI/HostedConversation/HostedConversationChatClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/HostedConversation/HostedConversationChatClientBuilderExtensions.cs deleted file mode 100644 index dbd7bd7c717..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI/HostedConversation/HostedConversationChatClientBuilderExtensions.cs +++ /dev/null @@ -1,47 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Diagnostics.CodeAnalysis; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Shared.DiagnosticIds; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Extensions.AI; - -/// Provides extensions for adding to a . -[Experimental(DiagnosticIds.Experiments.AIHostedConversation, UrlFormat = DiagnosticIds.UrlFormat)] -public static class HostedConversationChatClientBuilderExtensions -{ - /// - /// Adds a to the chat client pipeline, making an discoverable via - /// . - /// - /// The . - /// The to make discoverable. - /// The . - public static ChatClientBuilder UseHostedConversations( - this ChatClientBuilder builder, - IHostedConversationClient hostedConversationClient) - { - _ = Throw.IfNull(builder); - _ = Throw.IfNull(hostedConversationClient); - - return builder.Use(innerClient => new HostedConversationChatClient(innerClient, hostedConversationClient)); - } - - /// - /// Adds a to the chat client pipeline, making an discoverable via - /// . The is resolved from the service provider. - /// - /// The . - /// The . - public static ChatClientBuilder UseHostedConversations( - this ChatClientBuilder builder) - { - _ = Throw.IfNull(builder); - - return builder.Use((innerClient, services) => - new HostedConversationChatClient(innerClient, services.GetRequiredService())); - } -} diff --git a/src/Libraries/Microsoft.Extensions.AI/HostedConversation/LoggingHostedConversationClient.cs b/src/Libraries/Microsoft.Extensions.AI/HostedConversation/LoggingHostedConversationClient.cs index 46cbd885049..a958be67cdb 100644 --- a/src/Libraries/Microsoft.Extensions.AI/HostedConversation/LoggingHostedConversationClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/HostedConversation/LoggingHostedConversationClient.cs @@ -55,7 +55,7 @@ public JsonSerializerOptions JsonSerializerOptions /// public override async Task CreateAsync( - HostedConversationCreationOptions? options = null, CancellationToken cancellationToken = default) + HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default) { if (_logger.IsEnabled(LogLevel.Debug)) { @@ -101,7 +101,7 @@ public override async Task CreateAsync( /// public override async Task GetAsync( - string conversationId, CancellationToken cancellationToken = default) + string conversationId, HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default) { if (_logger.IsEnabled(LogLevel.Debug)) { @@ -110,7 +110,7 @@ public override async Task GetAsync( try { - var conversation = await base.GetAsync(conversationId, cancellationToken); + var conversation = await base.GetAsync(conversationId, options, cancellationToken); if (_logger.IsEnabled(LogLevel.Debug)) { @@ -140,7 +140,7 @@ public override async Task GetAsync( /// public override async Task DeleteAsync( - string conversationId, CancellationToken cancellationToken = default) + string conversationId, HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default) { if (_logger.IsEnabled(LogLevel.Debug)) { @@ -149,7 +149,7 @@ public override async Task DeleteAsync( try { - await base.DeleteAsync(conversationId, cancellationToken); + await base.DeleteAsync(conversationId, options, cancellationToken); LogCompleted(nameof(DeleteAsync)); } catch (OperationCanceledException) @@ -166,7 +166,7 @@ public override async Task DeleteAsync( /// public override async Task AddMessagesAsync( - string conversationId, IEnumerable messages, CancellationToken cancellationToken = default) + string conversationId, IEnumerable messages, HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default) { if (_logger.IsEnabled(LogLevel.Debug)) { @@ -182,7 +182,7 @@ public override async Task AddMessagesAsync( try { - await base.AddMessagesAsync(conversationId, messages, cancellationToken); + await base.AddMessagesAsync(conversationId, messages, options, cancellationToken); LogCompleted(nameof(AddMessagesAsync)); } catch (OperationCanceledException) @@ -199,7 +199,7 @@ public override async Task AddMessagesAsync( /// public override async IAsyncEnumerable GetMessagesAsync( - string conversationId, [EnumeratorCancellation] CancellationToken cancellationToken = default) + string conversationId, HostedConversationClientOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { if (_logger.IsEnabled(LogLevel.Debug)) { @@ -209,7 +209,7 @@ public override async IAsyncEnumerable GetMessagesAsync( IAsyncEnumerator e; try { - e = base.GetMessagesAsync(conversationId, cancellationToken).GetAsyncEnumerator(cancellationToken); + e = base.GetMessagesAsync(conversationId, options, cancellationToken).GetAsyncEnumerator(cancellationToken); } catch (OperationCanceledException) { @@ -278,8 +278,8 @@ public override async IAsyncEnumerable GetMessagesAsync( [LoggerMessage(LogLevel.Debug, "{MethodName} invoked. ConversationId: {ConversationId}.")] private partial void LogInvokedWithConversationId(string methodName, string conversationId); - [LoggerMessage(LogLevel.Trace, "{MethodName} invoked: Options: {HostedConversationCreationOptions}. Metadata: {HostedConversationClientMetadata}.")] - private partial void LogInvokedSensitive(string methodName, string hostedConversationCreationOptions, string hostedConversationClientMetadata); + [LoggerMessage(LogLevel.Trace, "{MethodName} invoked: Options: {HostedConversationClientOptions}. Metadata: {HostedConversationClientMetadata}.")] + private partial void LogInvokedSensitive(string methodName, string hostedConversationClientOptions, string hostedConversationClientMetadata); [LoggerMessage(LogLevel.Trace, "AddMessagesAsync invoked. ConversationId: {ConversationId}. Messages: {Messages}.")] private partial void LogAddMessagesInvokedSensitive(string conversationId, string messages); diff --git a/src/Libraries/Microsoft.Extensions.AI/HostedConversation/OpenTelemetryHostedConversationClient.cs b/src/Libraries/Microsoft.Extensions.AI/HostedConversation/OpenTelemetryHostedConversationClient.cs index 93a02334a81..be652e7a855 100644 --- a/src/Libraries/Microsoft.Extensions.AI/HostedConversation/OpenTelemetryHostedConversationClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/HostedConversation/OpenTelemetryHostedConversationClient.cs @@ -17,40 +17,55 @@ namespace Microsoft.Extensions.AI; -/// Represents a delegating hosted conversation client that implements the OpenTelemetry Semantic Conventions for Generative AI systems. +/// Represents a delegating hosted conversation client that implements OpenTelemetry-compatible tracing and metrics for conversation operations. /// -/// This class provides an implementation of the Semantic Conventions for Generative AI systems v1.40, defined at . -/// The specification is still experimental and subject to change; as such, the telemetry output by this client is also subject to change. +/// +/// Since there is currently no OpenTelemetry Semantic Convention for hosted conversation operations, this implementation +/// uses general client span conventions alongside standard conversations.* registry attributes where applicable. +/// +/// +/// The specification is subject to change as relevant OpenTelemetry conventions emerge; as such, the telemetry +/// output by this client is also subject to change. +/// /// [Experimental(DiagnosticIds.Experiments.AIHostedConversation, UrlFormat = DiagnosticIds.UrlFormat)] public sealed class OpenTelemetryHostedConversationClient : DelegatingHostedConversationClient { - private const string HostedConversationCreateName = "hosted_conversation create"; - private const string HostedConversationGetName = "hosted_conversation get"; - private const string HostedConversationDeleteName = "hosted_conversation delete"; - private const string HostedConversationAddMessagesName = "hosted_conversation add_messages"; - private const string HostedConversationGetMessagesName = "hosted_conversation get_messages"; + private const string CreateOperationName = "conversations.create"; + private const string GetOperationName = "conversations.get"; + private const string DeleteOperationName = "conversations.delete"; + private const string AddMessagesOperationName = "conversations.add_messages"; + private const string GetMessagesOperationName = "conversations.get_messages"; + + private const string OperationDurationMetricName = "conversations.client.operation.duration"; + private const string OperationDurationMetricDescription = "Measures the duration of a conversation operation"; + + private const string ConversationsOperationNameAttribute = "conversations.operation.name"; + private const string ConversationsProviderNameAttribute = "conversations.provider.name"; + private const string ConversationsIdAttribute = "conversations.id"; + private const string ConversationsMessagesCountAttribute = "conversations.messages.count"; private readonly ActivitySource _activitySource; private readonly Meter _meter; - private readonly Histogram _operationDurationHistogram; private readonly string? _providerName; private readonly string? _serverAddress; private readonly int _serverPort; + private readonly ILogger? _logger; + /// Initializes a new instance of the class. /// The underlying . /// The to use for emitting any logging data from the client. /// An optional source name that will be used on the telemetry data. -#pragma warning disable IDE0060 // Remove unused parameter; it exists for consistency with other OTel clients and future use public OpenTelemetryHostedConversationClient(IHostedConversationClient innerClient, ILogger? logger = null, string? sourceName = null) -#pragma warning restore IDE0060 : base(innerClient) { Debug.Assert(innerClient is not null, "Should have been validated by the base ctor"); + _logger = logger; + if (innerClient!.GetService() is HostedConversationClientMetadata metadata) { _providerName = metadata.ProviderName; @@ -63,25 +78,13 @@ public OpenTelemetryHostedConversationClient(IHostedConversationClient innerClie _meter = new(name); _operationDurationHistogram = _meter.CreateHistogram( - OpenTelemetryConsts.GenAI.Client.OperationDuration.Name, + OperationDurationMetricName, OpenTelemetryConsts.SecondsUnit, - OpenTelemetryConsts.GenAI.Client.OperationDuration.Description, + OperationDurationMetricDescription, advice: new() { HistogramBucketBoundaries = OpenTelemetryConsts.GenAI.Client.OperationDuration.ExplicitBucketBoundaries } ); } - /// - protected override void Dispose(bool disposing) - { - if (disposing) - { - _activitySource.Dispose(); - _meter.Dispose(); - } - - base.Dispose(disposing); - } - /// /// Gets or sets a value indicating whether potentially sensitive information should be included in telemetry. /// @@ -91,24 +94,51 @@ protected override void Dispose(bool disposing) /// The default value is , unless the OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT /// environment variable is set to "true" (case-insensitive). /// + /// + /// + /// By default, telemetry includes operation metadata such as provider name, duration, + /// conversation IDs, and message counts. + /// + /// + /// When enabled, telemetry will additionally include message content, which may contain sensitive information. + /// + /// + /// The default value can be overridden by setting the OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT + /// environment variable to "true". Explicitly setting this property will override the environment variable. + /// + /// public bool EnableSensitiveData { get; set; } = TelemetryHelpers.EnableSensitiveDataDefault; /// public override object? GetService(Type serviceType, object? serviceKey = null) => - serviceType == typeof(ActivitySource) ? _activitySource : + serviceKey is null && serviceType == typeof(ActivitySource) ? _activitySource : base.GetService(serviceType, serviceKey); + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + _activitySource.Dispose(); + _meter.Dispose(); + } + + base.Dispose(disposing); + } + /// public override async Task CreateAsync( - HostedConversationCreationOptions? options = null, CancellationToken cancellationToken = default) + HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default) { - using Activity? activity = CreateAndConfigureActivity(HostedConversationCreateName); + using Activity? activity = StartActivity(CreateOperationName); Stopwatch? stopwatch = _operationDurationHistogram.Enabled ? Stopwatch.StartNew() : null; + HostedConversation? result = null; Exception? error = null; try { - return await base.CreateAsync(options, cancellationToken); + result = await base.CreateAsync(options, cancellationToken).ConfigureAwait(false); + return result; } catch (Exception ex) { @@ -117,21 +147,27 @@ public override async Task CreateAsync( } finally { - TraceResponse(activity, HostedConversationCreateName, error, stopwatch); + if (result is not null && activity is { IsAllDataRequested: true }) + { + _ = activity.AddTag(ConversationsIdAttribute, result.ConversationId); + } + + RecordDuration(stopwatch, CreateOperationName, error); + SetErrorStatus(activity, error); } } /// public override async Task GetAsync( - string conversationId, CancellationToken cancellationToken = default) + string conversationId, HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default) { - using Activity? activity = CreateAndConfigureActivity(HostedConversationGetName, conversationId); + using Activity? activity = StartActivity(GetOperationName, conversationId); Stopwatch? stopwatch = _operationDurationHistogram.Enabled ? Stopwatch.StartNew() : null; Exception? error = null; try { - return await base.GetAsync(conversationId, cancellationToken); + return await base.GetAsync(conversationId, options, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { @@ -140,21 +176,22 @@ public override async Task GetAsync( } finally { - TraceResponse(activity, HostedConversationGetName, error, stopwatch); + RecordDuration(stopwatch, GetOperationName, error); + SetErrorStatus(activity, error); } } /// public override async Task DeleteAsync( - string conversationId, CancellationToken cancellationToken = default) + string conversationId, HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default) { - using Activity? activity = CreateAndConfigureActivity(HostedConversationDeleteName, conversationId); + using Activity? activity = StartActivity(DeleteOperationName, conversationId); Stopwatch? stopwatch = _operationDurationHistogram.Enabled ? Stopwatch.StartNew() : null; Exception? error = null; try { - await base.DeleteAsync(conversationId, cancellationToken); + await base.DeleteAsync(conversationId, options, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { @@ -163,21 +200,22 @@ public override async Task DeleteAsync( } finally { - TraceResponse(activity, HostedConversationDeleteName, error, stopwatch); + RecordDuration(stopwatch, DeleteOperationName, error); + SetErrorStatus(activity, error); } } /// public override async Task AddMessagesAsync( - string conversationId, IEnumerable messages, CancellationToken cancellationToken = default) + string conversationId, IEnumerable messages, HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default) { - using Activity? activity = CreateAndConfigureActivity(HostedConversationAddMessagesName, conversationId); + using Activity? activity = StartActivity(AddMessagesOperationName, conversationId); Stopwatch? stopwatch = _operationDurationHistogram.Enabled ? Stopwatch.StartNew() : null; Exception? error = null; try { - await base.AddMessagesAsync(conversationId, messages, cancellationToken); + await base.AddMessagesAsync(conversationId, messages, options, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { @@ -186,43 +224,43 @@ public override async Task AddMessagesAsync( } finally { - TraceResponse(activity, HostedConversationAddMessagesName, error, stopwatch); + RecordDuration(stopwatch, AddMessagesOperationName, error); + SetErrorStatus(activity, error); } } /// public override async IAsyncEnumerable GetMessagesAsync( - string conversationId, [EnumeratorCancellation] CancellationToken cancellationToken = default) + string conversationId, HostedConversationClientOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - using Activity? activity = CreateAndConfigureActivity(HostedConversationGetMessagesName, conversationId); + using Activity? activity = StartActivity(GetMessagesOperationName, conversationId); Stopwatch? stopwatch = _operationDurationHistogram.Enabled ? Stopwatch.StartNew() : null; - IAsyncEnumerable messages; + IAsyncEnumerator e; + Exception? error = null; try { - messages = base.GetMessagesAsync(conversationId, cancellationToken); + e = base.GetMessagesAsync(conversationId, options, cancellationToken).GetAsyncEnumerator(cancellationToken); } catch (Exception ex) { - TraceResponse(activity, HostedConversationGetMessagesName, ex, stopwatch); + error = ex; + RecordDuration(stopwatch, GetMessagesOperationName, error); + SetErrorStatus(activity, error); throw; } - var enumerator = messages.GetAsyncEnumerator(cancellationToken); - Exception? error = null; + int count = 0; try { while (true) { - ChatMessage message; try { - if (!await enumerator.MoveNextAsync()) + if (!await e.MoveNextAsync().ConfigureAwait(false)) { break; } - - message = enumerator.Current; } catch (Exception ex) { @@ -230,35 +268,58 @@ public override async IAsyncEnumerable GetMessagesAsync( throw; } - yield return message; + count++; + yield return e.Current; Activity.Current = activity; // workaround for https://github.com/dotnet/runtime/issues/47802 } } finally { - TraceResponse(activity, HostedConversationGetMessagesName, error, stopwatch); + if (activity is { IsAllDataRequested: true }) + { + _ = activity.AddTag(ConversationsMessagesCountAttribute, count); + } + + RecordDuration(stopwatch, GetMessagesOperationName, error); + SetErrorStatus(activity, error); - await enumerator.DisposeAsync(); + await e.DisposeAsync().ConfigureAwait(false); } } - /// Creates an activity for a hosted conversation operation, or returns if not enabled. - private Activity? CreateAndConfigureActivity(string operationName, string? conversationId = null) + private void SetErrorStatus(Activity? activity, Exception? error) + { + if (error is not null) + { + _ = activity? + .AddTag(OpenTelemetryConsts.Error.Type, error.GetType().FullName) + .SetStatus(ActivityStatusCode.Error, error.Message); + + if (_logger is not null) + { + OpenTelemetryLog.OperationException(_logger, error); + } + } + } + + private Activity? StartActivity(string operationName, string? conversationId = null) { Activity? activity = null; if (_activitySource.HasListeners()) { - activity = _activitySource.StartActivity(operationName, ActivityKind.Client); + activity = _activitySource.StartActivity( + operationName, + ActivityKind.Client); if (activity is { IsAllDataRequested: true }) { _ = activity - .AddTag(OpenTelemetryConsts.GenAI.Operation.Name, operationName) - .AddTag(OpenTelemetryConsts.GenAI.Provider.Name, _providerName); + .AddTag(ConversationsOperationNameAttribute, operationName) + .AddTag(ConversationsProviderNameAttribute, _providerName); if (conversationId is not null) { - _ = activity.AddTag(OpenTelemetryConsts.GenAI.Conversation.Id, conversationId); + _ = activity.AddTag(ConversationsIdAttribute, conversationId); } if (_serverAddress is not null) @@ -273,22 +334,17 @@ public override async IAsyncEnumerable GetMessagesAsync( return activity; } - /// Records response information to the activity and metrics. - private void TraceResponse( - Activity? activity, - string operationName, - Exception? error, - Stopwatch? stopwatch) + private void RecordDuration(Stopwatch? stopwatch, string operationName, Exception? error) { if (_operationDurationHistogram.Enabled && stopwatch is not null) { TagList tags = default; - tags.Add(OpenTelemetryConsts.GenAI.Operation.Name, operationName); - tags.Add(OpenTelemetryConsts.GenAI.Provider.Name, _providerName); + tags.Add(ConversationsOperationNameAttribute, operationName); + tags.Add(ConversationsProviderNameAttribute, _providerName); - if (_serverAddress is string endpointAddress) + if (_serverAddress is string address) { - tags.Add(OpenTelemetryConsts.Server.Address, endpointAddress); + tags.Add(OpenTelemetryConsts.Server.Address, address); tags.Add(OpenTelemetryConsts.Server.Port, _serverPort); } @@ -299,12 +355,5 @@ private void TraceResponse( _operationDurationHistogram.Record(stopwatch.Elapsed.TotalSeconds, tags); } - - if (error is not null) - { - _ = activity? - .AddTag(OpenTelemetryConsts.Error.Type, error.GetType().FullName) - .SetStatus(ActivityStatusCode.Error, error.Message); - } } } diff --git a/src/Libraries/Microsoft.Extensions.AI/HostedConversation/OpenTelemetryHostedConversationClientBuilderExtensions.cs b/src/Libraries/Microsoft.Extensions.AI/HostedConversation/OpenTelemetryHostedConversationClientBuilderExtensions.cs index 22ef4ccc5f1..ef4cef6302e 100644 --- a/src/Libraries/Microsoft.Extensions.AI/HostedConversation/OpenTelemetryHostedConversationClientBuilderExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.AI/HostedConversation/OpenTelemetryHostedConversationClientBuilderExtensions.cs @@ -15,11 +15,12 @@ namespace Microsoft.Extensions.AI; public static class OpenTelemetryHostedConversationClientBuilderExtensions { /// - /// Adds OpenTelemetry support to the hosted conversation client pipeline, following the OpenTelemetry Semantic Conventions for Generative AI systems. + /// Adds OpenTelemetry support to the hosted conversation client pipeline. /// /// - /// The draft specification this follows is available at . - /// The specification is still experimental and subject to change; as such, the telemetry output by this client is also subject to change. + /// Since there is currently no OpenTelemetry Semantic Convention for hosted conversation operations, this implementation + /// uses general client span conventions alongside standard conversations.* registry attributes where applicable. + /// The telemetry output is subject to change as relevant conventions emerge. /// /// The . /// An optional to use to create a logger for logging events. diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedConversation/DelegatingHostedConversationClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedConversation/DelegatingHostedConversationClientTests.cs index f51d17bbabf..7063872d429 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedConversation/DelegatingHostedConversationClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedConversation/DelegatingHostedConversationClientTests.cs @@ -23,7 +23,7 @@ public void RequiresInnerClient() public async Task CreateAsyncDefaultsToInnerClientAsync() { // Arrange - var expectedOptions = new HostedConversationCreationOptions(); + var expectedOptions = new HostedConversationClientOptions(); var expectedCancellationToken = CancellationToken.None; var expectedResult = new TaskCompletionSource(); var expectedConversation = new HostedConversation { ConversationId = "conv-1" }; @@ -59,7 +59,7 @@ public async Task GetAsyncDefaultsToInnerClientAsync() var expectedConversation = new HostedConversation { ConversationId = expectedConversationId }; using var inner = new TestHostedConversationClient { - GetAsyncCallback = (conversationId, cancellationToken) => + GetAsyncCallback = (conversationId, options, cancellationToken) => { Assert.Equal(expectedConversationId, conversationId); Assert.Equal(expectedCancellationToken, cancellationToken); @@ -70,7 +70,7 @@ public async Task GetAsyncDefaultsToInnerClientAsync() using var delegating = new NoOpDelegatingHostedConversationClient(inner); // Act - var resultTask = delegating.GetAsync(expectedConversationId, expectedCancellationToken); + var resultTask = delegating.GetAsync(expectedConversationId, cancellationToken: expectedCancellationToken); // Assert Assert.False(resultTask.IsCompleted); @@ -88,7 +88,7 @@ public async Task DeleteAsyncDefaultsToInnerClientAsync() var expectedResult = new TaskCompletionSource(); using var inner = new TestHostedConversationClient { - DeleteAsyncCallback = (conversationId, cancellationToken) => + DeleteAsyncCallback = (conversationId, options, cancellationToken) => { Assert.Equal(expectedConversationId, conversationId); Assert.Equal(expectedCancellationToken, cancellationToken); @@ -99,7 +99,7 @@ public async Task DeleteAsyncDefaultsToInnerClientAsync() using var delegating = new NoOpDelegatingHostedConversationClient(inner); // Act - var resultTask = delegating.DeleteAsync(expectedConversationId, expectedCancellationToken); + var resultTask = delegating.DeleteAsync(expectedConversationId, cancellationToken: expectedCancellationToken); // Assert Assert.False(resultTask.IsCompleted); @@ -118,7 +118,7 @@ public async Task AddMessagesAsyncDefaultsToInnerClientAsync() var expectedResult = new TaskCompletionSource(); using var inner = new TestHostedConversationClient { - AddMessagesAsyncCallback = (conversationId, messages, cancellationToken) => + AddMessagesAsyncCallback = (conversationId, messages, options, cancellationToken) => { Assert.Equal(expectedConversationId, conversationId); Assert.Same(expectedMessages, messages); @@ -130,7 +130,7 @@ public async Task AddMessagesAsyncDefaultsToInnerClientAsync() using var delegating = new NoOpDelegatingHostedConversationClient(inner); // Act - var resultTask = delegating.AddMessagesAsync(expectedConversationId, expectedMessages, expectedCancellationToken); + var resultTask = delegating.AddMessagesAsync(expectedConversationId, expectedMessages, cancellationToken: expectedCancellationToken); // Assert Assert.False(resultTask.IsCompleted); @@ -153,7 +153,7 @@ public async Task GetMessagesAsyncDefaultsToInnerClientAsync() using var inner = new TestHostedConversationClient { - GetMessagesAsyncCallback = (conversationId, cancellationToken) => + GetMessagesAsyncCallback = (conversationId, options, cancellationToken) => { Assert.Equal(expectedConversationId, conversationId); Assert.Equal(expectedCancellationToken, cancellationToken); @@ -164,7 +164,7 @@ public async Task GetMessagesAsyncDefaultsToInnerClientAsync() using var delegating = new NoOpDelegatingHostedConversationClient(inner); // Act - var resultAsyncEnumerable = delegating.GetMessagesAsync(expectedConversationId, expectedCancellationToken); + var resultAsyncEnumerable = delegating.GetMessagesAsync(expectedConversationId, cancellationToken: expectedCancellationToken); // Assert var enumerator = resultAsyncEnumerable.GetAsyncEnumerator(); @@ -274,35 +274,35 @@ public TestHostedConversationClient() public bool Disposed { get; private set; } - public Func>? CreateAsyncCallback { get; set; } + public Func>? CreateAsyncCallback { get; set; } - public Func>? GetAsyncCallback { get; set; } + public Func>? GetAsyncCallback { get; set; } - public Func? DeleteAsyncCallback { get; set; } + public Func? DeleteAsyncCallback { get; set; } - public Func, CancellationToken, Task>? AddMessagesAsyncCallback { get; set; } + public Func, HostedConversationClientOptions?, CancellationToken, Task>? AddMessagesAsyncCallback { get; set; } - public Func>? GetMessagesAsyncCallback { get; set; } + public Func>? GetMessagesAsyncCallback { get; set; } public Func GetServiceCallback { get; set; } private object? DefaultGetServiceCallback(Type serviceType, object? serviceKey) => serviceType is not null && serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; - public Task CreateAsync(HostedConversationCreationOptions? options = null, CancellationToken cancellationToken = default) + public Task CreateAsync(HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default) => CreateAsyncCallback!.Invoke(options, cancellationToken); - public Task GetAsync(string conversationId, CancellationToken cancellationToken = default) - => GetAsyncCallback!.Invoke(conversationId, cancellationToken); + public Task GetAsync(string conversationId, HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default) + => GetAsyncCallback!.Invoke(conversationId, options, cancellationToken); - public Task DeleteAsync(string conversationId, CancellationToken cancellationToken = default) - => DeleteAsyncCallback!.Invoke(conversationId, cancellationToken); + public Task DeleteAsync(string conversationId, HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default) + => DeleteAsyncCallback!.Invoke(conversationId, options, cancellationToken); - public Task AddMessagesAsync(string conversationId, IEnumerable messages, CancellationToken cancellationToken = default) - => AddMessagesAsyncCallback!.Invoke(conversationId, messages, cancellationToken); + public Task AddMessagesAsync(string conversationId, IEnumerable messages, HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default) + => AddMessagesAsyncCallback!.Invoke(conversationId, messages, options, cancellationToken); - public IAsyncEnumerable GetMessagesAsync(string conversationId, CancellationToken cancellationToken = default) - => GetMessagesAsyncCallback!.Invoke(conversationId, cancellationToken); + public IAsyncEnumerable GetMessagesAsync(string conversationId, HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default) + => GetMessagesAsyncCallback!.Invoke(conversationId, options, cancellationToken); public object? GetService(Type serviceType, object? serviceKey = null) => GetServiceCallback(serviceType, serviceKey); diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedConversation/HostedConversationCreationOptionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedConversation/HostedConversationClientOptionsTests.cs similarity index 50% rename from test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedConversation/HostedConversationCreationOptionsTests.cs rename to test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedConversation/HostedConversationClientOptionsTests.cs index 1fbe3d068d6..d536865f1c8 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedConversation/HostedConversationCreationOptionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedConversation/HostedConversationClientOptionsTests.cs @@ -1,28 +1,25 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. #pragma warning disable MEAI001 using System; -using System.Collections.Generic; using Xunit; namespace Microsoft.Extensions.AI; -public class HostedConversationCreationOptionsTests +public class HostedConversationClientOptionsTests { [Fact] public void Constructor_Parameterless_PropsDefaulted() { - HostedConversationCreationOptions options = new(); - Assert.Null(options.Metadata); - Assert.Null(options.Messages); + HostedConversationClientOptions options = new(); + Assert.Null(options.Limit); Assert.Null(options.RawRepresentationFactory); Assert.Null(options.AdditionalProperties); - HostedConversationCreationOptions clone = options.Clone(); - Assert.Null(clone.Metadata); - Assert.Null(clone.Messages); + HostedConversationClientOptions clone = options.Clone(); + Assert.Null(clone.Limit); Assert.Null(clone.RawRepresentationFactory); Assert.Null(clone.AdditionalProperties); } @@ -30,18 +27,7 @@ public void Constructor_Parameterless_PropsDefaulted() [Fact] public void Properties_Roundtrip() { - HostedConversationCreationOptions options = new(); - - AdditionalPropertiesDictionary metadata = new() - { - ["key1"] = "value1", - }; - - List messages = - [ - new(ChatRole.User, "Hello"), - new(ChatRole.Assistant, "Hi there"), - ]; + HostedConversationClientOptions options = new(); Func rawRepresentationFactory = (c) => null; @@ -50,13 +36,11 @@ public void Properties_Roundtrip() ["key"] = "value", }; - options.Metadata = metadata; - options.Messages = messages; + options.Limit = 42; options.RawRepresentationFactory = rawRepresentationFactory; options.AdditionalProperties = additionalProps; - Assert.Same(metadata, options.Metadata); - Assert.Same(messages, options.Messages); + Assert.Equal(42, options.Limit); Assert.Same(rawRepresentationFactory, options.RawRepresentationFactory); Assert.Same(additionalProps, options.AdditionalProperties); } @@ -64,79 +48,32 @@ public void Properties_Roundtrip() [Fact] public void Clone_CreatesIndependentCopy() { - HostedConversationCreationOptions original = new() + HostedConversationClientOptions original = new() { - Metadata = new() { ["key1"] = "value1" }, - Messages = [new(ChatRole.User, "Hello")], + Limit = 10, RawRepresentationFactory = (c) => null, AdditionalProperties = new() { ["prop"] = "val" }, }; - HostedConversationCreationOptions clone = original.Clone(); + HostedConversationClientOptions clone = original.Clone(); Assert.NotSame(original, clone); - Assert.NotNull(clone.Metadata); - Assert.NotNull(clone.Messages); + Assert.Equal(10, clone.Limit); Assert.NotNull(clone.RawRepresentationFactory); Assert.NotNull(clone.AdditionalProperties); } - [Fact] - public void Clone_DeepCopiesMetadata() - { - HostedConversationCreationOptions original = new() - { - Metadata = new() { ["key1"] = "value1" }, - }; - - HostedConversationCreationOptions clone = original.Clone(); - - Assert.NotSame(original.Metadata, clone.Metadata); - Assert.Equal("value1", clone.Metadata!["key1"]); - - // Modifying clone should not affect original - clone.Metadata["key1"] = "modified"; - Assert.Equal("value1", original.Metadata["key1"]); - - clone.Metadata["key2"] = "newvalue"; - Assert.False(original.Metadata.ContainsKey("key2")); - } - - [Fact] - public void Clone_DeepCopiesMessages() - { - List messages = - [ - new(ChatRole.User, "Hello"), - new(ChatRole.Assistant, "Hi"), - ]; - - HostedConversationCreationOptions original = new() - { - Messages = messages, - }; - - HostedConversationCreationOptions clone = original.Clone(); - - Assert.NotSame(original.Messages, clone.Messages); - Assert.Equal(2, clone.Messages!.Count); - - // Adding to clone should not affect original - clone.Messages.Add(new(ChatRole.User, "Another message")); - Assert.Equal(2, original.Messages.Count); - } - [Fact] public void Clone_CopiesRawRepresentationFactoryByReference() { Func factory = (c) => "test"; - HostedConversationCreationOptions original = new() + HostedConversationClientOptions original = new() { RawRepresentationFactory = factory, }; - HostedConversationCreationOptions clone = original.Clone(); + HostedConversationClientOptions clone = original.Clone(); Assert.Same(original.RawRepresentationFactory, clone.RawRepresentationFactory); } @@ -144,12 +81,12 @@ public void Clone_CopiesRawRepresentationFactoryByReference() [Fact] public void Clone_DeepCopiesAdditionalProperties() { - HostedConversationCreationOptions original = new() + HostedConversationClientOptions original = new() { AdditionalProperties = new() { ["key"] = "value" }, }; - HostedConversationCreationOptions clone = original.Clone(); + HostedConversationClientOptions clone = original.Clone(); Assert.NotSame(original.AdditionalProperties, clone.AdditionalProperties); Assert.Equal("value", clone.AdditionalProperties!["key"]); @@ -162,6 +99,23 @@ public void Clone_DeepCopiesAdditionalProperties() Assert.False(original.AdditionalProperties.ContainsKey("newkey")); } + [Fact] + public void Clone_CopiesLimit() + { + HostedConversationClientOptions original = new() + { + Limit = 25, + }; + + HostedConversationClientOptions clone = original.Clone(); + + Assert.Equal(25, clone.Limit); + + // Modifying clone should not affect original + clone.Limit = 50; + Assert.Equal(25, original.Limit); + } + [Fact] public void CopyConstructor_Null_Valid() { @@ -174,18 +128,17 @@ public void CopyConstructors_EnableHierarchyCloning() { DerivedOptions derived = new() { - Metadata = new() { ["key"] = "value" }, + Limit = 5, CustomProperty = 42, }; - HostedConversationCreationOptions clone = derived.Clone(); + HostedConversationClientOptions clone = derived.Clone(); - Assert.NotNull(clone.Metadata); - Assert.Equal("value", clone.Metadata["key"]); + Assert.Equal(5, clone.Limit); Assert.Equal(42, Assert.IsType(clone).CustomProperty); } - private class PassedNullToBaseOptions : HostedConversationCreationOptions + private class PassedNullToBaseOptions : HostedConversationClientOptions { public PassedNullToBaseOptions() : base(null) @@ -193,7 +146,7 @@ public PassedNullToBaseOptions() } } - private class DerivedOptions : HostedConversationCreationOptions + private class DerivedOptions : HostedConversationClientOptions { public DerivedOptions() { @@ -207,6 +160,6 @@ protected DerivedOptions(DerivedOptions other) public int CustomProperty { get; set; } - public override HostedConversationCreationOptions Clone() => new DerivedOptions(this); + public override HostedConversationClientOptions Clone() => new DerivedOptions(this); } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedConversation/HostedConversationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedConversation/HostedConversationTests.cs index cf2ba988749..9178550bee7 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedConversation/HostedConversationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/HostedConversation/HostedConversationTests.cs @@ -16,7 +16,6 @@ public void Constructor_Parameterless_PropsDefaulted() HostedConversation conversation = new(); Assert.Null(conversation.ConversationId); Assert.Null(conversation.CreatedAt); - Assert.Null(conversation.Metadata); Assert.Null(conversation.RawRepresentation); Assert.Null(conversation.AdditionalProperties); } @@ -48,24 +47,6 @@ public void CreatedAt_Roundtrips() Assert.Null(conversation.CreatedAt); } - [Fact] - public void Metadata_Roundtrips() - { - HostedConversation conversation = new(); - Assert.Null(conversation.Metadata); - - AdditionalPropertiesDictionary metadata = new() - { - ["key1"] = "value1", - ["key2"] = "value2", - }; - conversation.Metadata = metadata; - Assert.Same(metadata, conversation.Metadata); - - conversation.Metadata = null; - Assert.Null(conversation.Metadata); - } - [Fact] public void RawRepresentation_Roundtrips() { diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/HostedConversation/ConfigureOptionsHostedConversationClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/HostedConversation/ConfigureOptionsHostedConversationClientTests.cs index aef6f389cd4..d5063013520 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/HostedConversation/ConfigureOptionsHostedConversationClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/HostedConversation/ConfigureOptionsHostedConversationClientTests.cs @@ -18,7 +18,7 @@ public async Task ConfigureOptions_CallbackIsCalled() { // Arrange var callbackInvoked = false; - HostedConversationCreationOptions? receivedByInner = null; + HostedConversationClientOptions? receivedByInner = null; using var innerClient = new TestHostedConversationClient { @@ -32,27 +32,27 @@ public async Task ConfigureOptions_CallbackIsCalled() using var client = new ConfigureOptionsHostedConversationClient(innerClient, options => { callbackInvoked = true; - options.Metadata ??= new(); - options.Metadata["configured"] = "true"; + options.AdditionalProperties ??= new(); + options.AdditionalProperties["configured"] = "true"; }); // Act - await client.CreateAsync(new HostedConversationCreationOptions()); + await client.CreateAsync(new HostedConversationClientOptions()); // Assert Assert.True(callbackInvoked); Assert.NotNull(receivedByInner); - Assert.Equal("true", receivedByInner!.Metadata!["configured"]); + Assert.Equal("true", receivedByInner!.AdditionalProperties!["configured"]); } [Fact] public async Task ConfigureOptions_OptionsAreCloned_OriginalNotModified() { // Arrange - HostedConversationCreationOptions? receivedByInner = null; - var originalOptions = new HostedConversationCreationOptions + HostedConversationClientOptions? receivedByInner = null; + var originalOptions = new HostedConversationClientOptions { - Metadata = new() { ["key"] = "original" } + AdditionalProperties = new() { ["key"] = "original" } }; using var innerClient = new TestHostedConversationClient @@ -66,26 +66,26 @@ public async Task ConfigureOptions_OptionsAreCloned_OriginalNotModified() using var client = new ConfigureOptionsHostedConversationClient(innerClient, options => { - options.Metadata!["key"] = "modified"; + options.AdditionalProperties!["key"] = "modified"; }); // Act await client.CreateAsync(originalOptions); // Assert - original should not be modified - Assert.Equal("original", originalOptions.Metadata["key"]); + Assert.Equal("original", originalOptions.AdditionalProperties["key"]); // Assert - inner received modified clone Assert.NotNull(receivedByInner); Assert.NotSame(originalOptions, receivedByInner); - Assert.Equal("modified", receivedByInner!.Metadata!["key"]); + Assert.Equal("modified", receivedByInner!.AdditionalProperties!["key"]); } [Fact] public async Task ConfigureOptions_NullOptions_CreatesNew() { // Arrange - HostedConversationCreationOptions? receivedByInner = null; + HostedConversationClientOptions? receivedByInner = null; using var innerClient = new TestHostedConversationClient { @@ -98,7 +98,7 @@ public async Task ConfigureOptions_NullOptions_CreatesNew() using var client = new ConfigureOptionsHostedConversationClient(innerClient, options => { - options.Metadata = new() { ["new"] = "value" }; + options.AdditionalProperties = new() { ["new"] = "value" }; }); // Act @@ -106,7 +106,7 @@ public async Task ConfigureOptions_NullOptions_CreatesNew() // Assert - a new options instance should have been created Assert.NotNull(receivedByInner); - Assert.Equal("value", receivedByInner!.Metadata!["new"]); + Assert.Equal("value", receivedByInner!.AdditionalProperties!["new"]); } [Fact] @@ -124,21 +124,21 @@ public void Constructor_NullInnerClient_Throws() private sealed class TestHostedConversationClient : IHostedConversationClient { - public Func>? CreateAsyncCallback { get; set; } + public Func>? CreateAsyncCallback { get; set; } - public Task CreateAsync(HostedConversationCreationOptions? options = null, CancellationToken cancellationToken = default) + public Task CreateAsync(HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default) => CreateAsyncCallback?.Invoke(options, cancellationToken) ?? Task.FromResult(new HostedConversation { ConversationId = "test" }); - public Task GetAsync(string conversationId, CancellationToken cancellationToken = default) + public Task GetAsync(string conversationId, HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default) => Task.FromResult(new HostedConversation { ConversationId = conversationId }); - public Task DeleteAsync(string conversationId, CancellationToken cancellationToken = default) + public Task DeleteAsync(string conversationId, HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default) => Task.CompletedTask; - public Task AddMessagesAsync(string conversationId, IEnumerable messages, CancellationToken cancellationToken = default) + public Task AddMessagesAsync(string conversationId, IEnumerable messages, HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default) => Task.CompletedTask; - public IAsyncEnumerable GetMessagesAsync(string conversationId, CancellationToken cancellationToken = default) + public IAsyncEnumerable GetMessagesAsync(string conversationId, HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default) => EmptyAsync(); private static async IAsyncEnumerable EmptyAsync() diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/HostedConversation/HostedConversationChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/HostedConversation/HostedConversationChatClientTests.cs deleted file mode 100644 index 5191859fe80..00000000000 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/HostedConversation/HostedConversationChatClientTests.cs +++ /dev/null @@ -1,170 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#pragma warning disable MEAI001 - -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Xunit; - -namespace Microsoft.Extensions.AI; - -public class HostedConversationChatClientTests -{ - [Fact] - public void GetService_IHostedConversationClient_ReturnsConversationClient() - { - // Arrange - using var innerChatClient = new TestChatClient(); - using var conversationClient = new TestHostedConversationClient(); - using var client = new HostedConversationChatClient(innerChatClient, conversationClient); - - // Act - var result = client.GetService(typeof(IHostedConversationClient)); - - // Assert - Assert.Same(conversationClient, result); - } - - [Fact] - public void GetService_OtherTypes_DelegatesToInner() - { - // Arrange - using var innerChatClient = new TestChatClient(); - using var conversationClient = new TestHostedConversationClient(); - using var client = new HostedConversationChatClient(innerChatClient, conversationClient); - - // Act - ask for the inner chat client type - var result = client.GetService(typeof(TestChatClient)); - - // Assert - DelegatingChatClient base dispatches to inner - Assert.Same(innerChatClient, result); - } - - [Fact] - public void GetService_WithServiceKey_DelegatesToInner() - { - // Arrange - var serviceKey = new object(); - var expectedResult = new object(); - using var innerChatClient = new TestChatClient - { - GetServiceCallback = (type, key) => key == serviceKey ? expectedResult : null - }; - using var conversationClient = new TestHostedConversationClient(); - using var client = new HostedConversationChatClient(innerChatClient, conversationClient); - - // Act - when a key is provided, it should delegate even for IHostedConversationClient - var result = client.GetService(typeof(IHostedConversationClient), serviceKey); - - // Assert - Assert.Same(expectedResult, result); - } - - [Fact] - public async Task GetResponseAsync_PassesThroughUnchanged() - { - // Arrange - var expectedMessages = new List { new(ChatRole.User, "Hello") }; - var expectedOptions = new ChatOptions(); - var expectedResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Hi")); - - using var innerChatClient = new TestChatClient - { - GetResponseAsyncCallback = (messages, options, _) => - { - Assert.Same(expectedMessages, messages); - Assert.Same(expectedOptions, options); - return Task.FromResult(expectedResponse); - } - }; - using var conversationClient = new TestHostedConversationClient(); - using var client = new HostedConversationChatClient(innerChatClient, conversationClient); - - // Act - var response = await client.GetResponseAsync(expectedMessages, expectedOptions); - - // Assert - Assert.Same(expectedResponse, response); - } - - [Fact] - public async Task GetStreamingResponseAsync_PassesThroughUnchanged() - { - // Arrange - var expectedUpdate = new ChatResponseUpdate(ChatRole.Assistant, "streaming"); - using var innerChatClient = new TestChatClient - { - GetStreamingResponseAsyncCallback = (_, _, _) => YieldAsync(expectedUpdate) - }; - using var conversationClient = new TestHostedConversationClient(); - using var client = new HostedConversationChatClient(innerChatClient, conversationClient); - - // Act - var updates = new List(); - await foreach (var update in client.GetStreamingResponseAsync([new(ChatRole.User, "test")])) - { - updates.Add(update); - } - - // Assert - Assert.Single(updates); - Assert.Same(expectedUpdate, updates[0]); - } - - [Fact] - public void Constructor_NullInnerChatClient_Throws() - { - using var conversationClient = new TestHostedConversationClient(); - Assert.Throws("innerClient", () => new HostedConversationChatClient(null!, conversationClient)); - } - - [Fact] - public void Constructor_NullConversationClient_Throws() - { - using var innerChatClient = new TestChatClient(); - Assert.Throws("hostedConversationClient", () => new HostedConversationChatClient(innerChatClient, null!)); - } - - private static async IAsyncEnumerable YieldAsync(params ChatResponseUpdate[] updates) - { - await Task.Yield(); - foreach (var update in updates) - { - yield return update; - } - } - - private sealed class TestHostedConversationClient : IHostedConversationClient - { - public Task CreateAsync(HostedConversationCreationOptions? options = null, CancellationToken cancellationToken = default) - => Task.FromResult(new HostedConversation { ConversationId = "test" }); - - public Task GetAsync(string conversationId, CancellationToken cancellationToken = default) - => Task.FromResult(new HostedConversation { ConversationId = conversationId }); - - public Task DeleteAsync(string conversationId, CancellationToken cancellationToken = default) - => Task.CompletedTask; - - public Task AddMessagesAsync(string conversationId, IEnumerable messages, CancellationToken cancellationToken = default) - => Task.CompletedTask; - - public IAsyncEnumerable GetMessagesAsync(string conversationId, CancellationToken cancellationToken = default) - => EmptyAsync(); - - private static async IAsyncEnumerable EmptyAsync() - { - await Task.CompletedTask; - yield break; - } - - public object? GetService(Type serviceType, object? serviceKey = null) - => serviceType is not null && serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; - - public void Dispose() - { - } - } -} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/HostedConversation/HostedConversationClientBuilderTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/HostedConversation/HostedConversationClientBuilderTest.cs index 2006148d607..d3fce6431eb 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/HostedConversation/HostedConversationClientBuilderTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/HostedConversation/HostedConversationClientBuilderTest.cs @@ -121,19 +121,19 @@ public NamedDelegatingHostedConversationClient(string name, IHostedConversationC private sealed class TestHostedConversationClient : IHostedConversationClient { - public Task CreateAsync(HostedConversationCreationOptions? options = null, CancellationToken cancellationToken = default) + public Task CreateAsync(HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default) => Task.FromResult(new HostedConversation { ConversationId = "test" }); - public Task GetAsync(string conversationId, CancellationToken cancellationToken = default) + public Task GetAsync(string conversationId, HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default) => Task.FromResult(new HostedConversation { ConversationId = conversationId }); - public Task DeleteAsync(string conversationId, CancellationToken cancellationToken = default) + public Task DeleteAsync(string conversationId, HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default) => Task.CompletedTask; - public Task AddMessagesAsync(string conversationId, IEnumerable messages, CancellationToken cancellationToken = default) + public Task AddMessagesAsync(string conversationId, IEnumerable messages, HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default) => Task.CompletedTask; - public IAsyncEnumerable GetMessagesAsync(string conversationId, CancellationToken cancellationToken = default) + public IAsyncEnumerable GetMessagesAsync(string conversationId, HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default) => EmptyAsync(); private static async IAsyncEnumerable EmptyAsync() diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/HostedConversation/LoggingHostedConversationClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/HostedConversation/LoggingHostedConversationClientTests.cs index 0dde3d8117a..32fb7cbd79c 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/HostedConversation/LoggingHostedConversationClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/HostedConversation/LoggingHostedConversationClientTests.cs @@ -56,7 +56,7 @@ public async Task CreateAsync_LogsInvocationAndCompletion(LogLevel level) using var client = builder.Build(services); // Act - await client.CreateAsync(new HostedConversationCreationOptions()); + await client.CreateAsync(new HostedConversationClientOptions()); // Assert var logs = collector.GetSnapshot(); @@ -92,7 +92,7 @@ public async Task GetAsync_LogsInvocationAndCompletion(LogLevel level) using var innerClient = new TestHostedConversationClient { - GetAsyncCallback = (id, _) => + GetAsyncCallback = (id, opts, _) => Task.FromResult(new HostedConversation { ConversationId = id }) }; @@ -131,7 +131,7 @@ public async Task DeleteAsync_LogsInvocationAndCompletion(LogLevel level) using var innerClient = new TestHostedConversationClient { - DeleteAsyncCallback = (_, _) => Task.CompletedTask + DeleteAsyncCallback = (_, opts, _) => Task.CompletedTask }; var builder = new HostedConversationClientBuilder(innerClient); @@ -170,23 +170,23 @@ public void UseLogging_AvoidsInjectingNopClient() private sealed class TestHostedConversationClient : IHostedConversationClient { - public Func>? CreateAsyncCallback { get; set; } - public Func>? GetAsyncCallback { get; set; } - public Func? DeleteAsyncCallback { get; set; } + public Func>? CreateAsyncCallback { get; set; } + public Func>? GetAsyncCallback { get; set; } + public Func? DeleteAsyncCallback { get; set; } - public Task CreateAsync(HostedConversationCreationOptions? options = null, CancellationToken cancellationToken = default) + public Task CreateAsync(HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default) => CreateAsyncCallback?.Invoke(options, cancellationToken) ?? Task.FromResult(new HostedConversation { ConversationId = "test" }); - public Task GetAsync(string conversationId, CancellationToken cancellationToken = default) - => GetAsyncCallback?.Invoke(conversationId, cancellationToken) ?? Task.FromResult(new HostedConversation { ConversationId = conversationId }); + public Task GetAsync(string conversationId, HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default) + => GetAsyncCallback?.Invoke(conversationId, options, cancellationToken) ?? Task.FromResult(new HostedConversation { ConversationId = conversationId }); - public Task DeleteAsync(string conversationId, CancellationToken cancellationToken = default) - => DeleteAsyncCallback?.Invoke(conversationId, cancellationToken) ?? Task.CompletedTask; + public Task DeleteAsync(string conversationId, HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default) + => DeleteAsyncCallback?.Invoke(conversationId, options, cancellationToken) ?? Task.CompletedTask; - public Task AddMessagesAsync(string conversationId, IEnumerable messages, CancellationToken cancellationToken = default) + public Task AddMessagesAsync(string conversationId, IEnumerable messages, HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default) => Task.CompletedTask; - public IAsyncEnumerable GetMessagesAsync(string conversationId, CancellationToken cancellationToken = default) + public IAsyncEnumerable GetMessagesAsync(string conversationId, HostedConversationClientOptions? options = null, CancellationToken cancellationToken = default) => EmptyAsync(); private static async IAsyncEnumerable EmptyAsync() From 74ebbaefe20b0584cd8d5af1eb68d8f8a99993c8 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:03:20 +0000 Subject: [PATCH 3/3] Move HostedConversation abstractions to Conversations folder --- .../DelegatingHostedConversationClient.cs | 0 .../{HostedConversation => Conversations}/HostedConversation.cs | 0 .../HostedConversationClientExtensions.cs | 0 .../HostedConversationClientMetadata.cs | 0 .../HostedConversationClientOptions.cs | 0 .../IHostedConversationClient.cs | 0 6 files changed, 0 insertions(+), 0 deletions(-) rename src/Libraries/Microsoft.Extensions.AI.Abstractions/{HostedConversation => Conversations}/DelegatingHostedConversationClient.cs (100%) rename src/Libraries/Microsoft.Extensions.AI.Abstractions/{HostedConversation => Conversations}/HostedConversation.cs (100%) rename src/Libraries/Microsoft.Extensions.AI.Abstractions/{HostedConversation => Conversations}/HostedConversationClientExtensions.cs (100%) rename src/Libraries/Microsoft.Extensions.AI.Abstractions/{HostedConversation => Conversations}/HostedConversationClientMetadata.cs (100%) rename src/Libraries/Microsoft.Extensions.AI.Abstractions/{HostedConversation => Conversations}/HostedConversationClientOptions.cs (100%) rename src/Libraries/Microsoft.Extensions.AI.Abstractions/{HostedConversation => Conversations}/IHostedConversationClient.cs (100%) diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/DelegatingHostedConversationClient.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Conversations/DelegatingHostedConversationClient.cs similarity index 100% rename from src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/DelegatingHostedConversationClient.cs rename to src/Libraries/Microsoft.Extensions.AI.Abstractions/Conversations/DelegatingHostedConversationClient.cs diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/HostedConversation.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Conversations/HostedConversation.cs similarity index 100% rename from src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/HostedConversation.cs rename to src/Libraries/Microsoft.Extensions.AI.Abstractions/Conversations/HostedConversation.cs diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/HostedConversationClientExtensions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Conversations/HostedConversationClientExtensions.cs similarity index 100% rename from src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/HostedConversationClientExtensions.cs rename to src/Libraries/Microsoft.Extensions.AI.Abstractions/Conversations/HostedConversationClientExtensions.cs diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/HostedConversationClientMetadata.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Conversations/HostedConversationClientMetadata.cs similarity index 100% rename from src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/HostedConversationClientMetadata.cs rename to src/Libraries/Microsoft.Extensions.AI.Abstractions/Conversations/HostedConversationClientMetadata.cs diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/HostedConversationClientOptions.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Conversations/HostedConversationClientOptions.cs similarity index 100% rename from src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/HostedConversationClientOptions.cs rename to src/Libraries/Microsoft.Extensions.AI.Abstractions/Conversations/HostedConversationClientOptions.cs diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/IHostedConversationClient.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Conversations/IHostedConversationClient.cs similarity index 100% rename from src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedConversation/IHostedConversationClient.cs rename to src/Libraries/Microsoft.Extensions.AI.Abstractions/Conversations/IHostedConversationClient.cs