Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions docs/HostedConversation-ProviderMapping.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# 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<ChatMessage>` |

## 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

- **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

- 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<TStore>` 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;

namespace Microsoft.Extensions.AI;

/// <summary>
/// Provides an optional base class for an <see cref="IHostedConversationClient"/> that passes through calls to another instance.
/// </summary>
/// <remarks>
/// This is recommended as a base type when building clients that can be chained around an underlying <see cref="IHostedConversationClient"/>.
/// The default implementation simply passes each call to the inner client instance.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIHostedConversation, UrlFormat = DiagnosticIds.UrlFormat)]
public class DelegatingHostedConversationClient : IHostedConversationClient
{
/// <summary>
/// Initializes a new instance of the <see cref="DelegatingHostedConversationClient"/> class.
/// </summary>
/// <param name="innerClient">The wrapped client instance.</param>
/// <exception cref="ArgumentNullException"><paramref name="innerClient"/> is <see langword="null"/>.</exception>
protected DelegatingHostedConversationClient(IHostedConversationClient innerClient)
{
InnerClient = Throw.IfNull(innerClient);
}

/// <inheritdoc />
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}

/// <summary>Gets the inner <see cref="IHostedConversationClient" />.</summary>
protected IHostedConversationClient InnerClient { get; }

/// <inheritdoc />
public virtual Task<HostedConversation> CreateAsync(
HostedConversationClientOptions? options = null,
CancellationToken cancellationToken = default) =>
InnerClient.CreateAsync(options, cancellationToken);

/// <inheritdoc />
public virtual Task<HostedConversation> GetAsync(
string conversationId,
HostedConversationClientOptions? options = null,
CancellationToken cancellationToken = default) =>
InnerClient.GetAsync(conversationId, options, cancellationToken);

/// <inheritdoc />
public virtual Task DeleteAsync(
string conversationId,
HostedConversationClientOptions? options = null,
CancellationToken cancellationToken = default) =>
InnerClient.DeleteAsync(conversationId, options, cancellationToken);

/// <inheritdoc />
public virtual Task AddMessagesAsync(
string conversationId,
IEnumerable<ChatMessage> messages,
HostedConversationClientOptions? options = null,
CancellationToken cancellationToken = default) =>
InnerClient.AddMessagesAsync(conversationId, messages, options, cancellationToken);

/// <inheritdoc />
public virtual IAsyncEnumerable<ChatMessage> GetMessagesAsync(
string conversationId,
HostedConversationClientOptions? options = null,
CancellationToken cancellationToken = default) =>
InnerClient.GetMessagesAsync(conversationId, options, cancellationToken);

/// <inheritdoc />
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);
}

/// <summary>Provides a mechanism for releasing unmanaged resources.</summary>
/// <param name="disposing"><see langword="true"/> if being called from <see cref="Dispose()"/>; otherwise, <see langword="false"/>.</param>
protected virtual void Dispose(bool disposing)
{
if (disposing)
{
InnerClient.Dispose();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using Microsoft.Shared.DiagnosticIds;

namespace Microsoft.Extensions.AI;

/// <summary>Represents a hosted conversation.</summary>
[Experimental(DiagnosticIds.Experiments.AIHostedConversation, UrlFormat = DiagnosticIds.UrlFormat)]
public class HostedConversation
{
/// <summary>Gets or sets the conversation identifier.</summary>
public string? ConversationId { get; set; }

/// <summary>Gets or sets the creation timestamp.</summary>
public DateTimeOffset? CreatedAt { get; set; }

/// <summary>Gets or sets the raw representation of the conversation from the underlying provider.</summary>
/// <remarks>
/// If a <see cref="HostedConversation"/> 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.
/// </remarks>
[JsonIgnore]
public object? RawRepresentation { get; set; }

/// <summary>Gets or sets any additional properties associated with the conversation.</summary>
public AdditionalPropertiesDictionary? AdditionalProperties { get; set; }
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Provides a collection of static methods for extending <see cref="IHostedConversationClient"/> instances.</summary>
[Experimental(DiagnosticIds.Experiments.AIHostedConversation, UrlFormat = DiagnosticIds.UrlFormat)]
public static class HostedConversationClientExtensions
{
/// <summary>Asks the <see cref="IHostedConversationClient"/> for an object of type <typeparamref name="TService"/>.</summary>
/// <typeparam name="TService">The type of the object to be retrieved.</typeparam>
/// <param name="client">The client.</param>
/// <param name="serviceKey">An optional key that can be used to help identify the target service.</param>
/// <returns>The found object, otherwise <see langword="null"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="client"/> is <see langword="null"/>.</exception>
/// <remarks>
/// The purpose of this method is to allow for the retrieval of strongly typed services that may be provided by the <see cref="IHostedConversationClient"/>,
/// including itself or any services it might be wrapping.
/// </remarks>
public static TService? GetService<TService>(this IHostedConversationClient client, object? serviceKey = null)
{
_ = Throw.IfNull(client);

return client.GetService(typeof(TService), serviceKey) is TService service ? service : default;
}

/// <summary>
/// Asks the <see cref="IHostedConversationClient"/> for an object of the specified type <paramref name="serviceType"/>
/// and throws an exception if one isn't available.
/// </summary>
/// <param name="client">The client.</param>
/// <param name="serviceType">The type of object being requested.</param>
/// <param name="serviceKey">An optional key that can be used to help identify the target service.</param>
/// <returns>The found object.</returns>
/// <exception cref="ArgumentNullException"><paramref name="client"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentNullException"><paramref name="serviceType"/> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">No service of the requested type for the specified key is available.</exception>
/// <remarks>
/// The purpose of this method is to allow for the retrieval of services that are required to be provided by the <see cref="IHostedConversationClient"/>,
/// including itself or any services it might be wrapping.
/// </remarks>
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);
}

/// <summary>
/// Asks the <see cref="IHostedConversationClient"/> for an object of type <typeparamref name="TService"/>
/// and throws an exception if one isn't available.
/// </summary>
/// <typeparam name="TService">The type of the object to be retrieved.</typeparam>
/// <param name="client">The client.</param>
/// <param name="serviceKey">An optional key that can be used to help identify the target service.</param>
/// <returns>The found object.</returns>
/// <exception cref="ArgumentNullException"><paramref name="client"/> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">No service of the requested type for the specified key is available.</exception>
/// <remarks>
/// The purpose of this method is to allow for the retrieval of strongly typed services that are required to be provided by the <see cref="IHostedConversationClient"/>,
/// including itself or any services it might be wrapping.
/// </remarks>
public static TService GetRequiredService<TService>(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;
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Provides metadata about an <see cref="IHostedConversationClient"/>.</summary>
[Experimental(DiagnosticIds.Experiments.AIHostedConversation, UrlFormat = DiagnosticIds.UrlFormat)]
public class HostedConversationClientMetadata
{
/// <summary>Initializes a new instance of the <see cref="HostedConversationClientMetadata"/> class.</summary>
/// <param name="providerName">
/// 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.
/// </param>
/// <param name="providerUri">The URL for accessing the hosted conversation provider, if applicable.</param>
public HostedConversationClientMetadata(string? providerName = null, Uri? providerUri = null)
{
ProviderName = providerName;
ProviderUri = providerUri;
}

/// <summary>Gets the name of the hosted conversation provider.</summary>
/// <remarks>
/// Where possible, this maps to the appropriate name defined in the
/// OpenTelemetry Semantic Conventions for Generative AI systems.
/// </remarks>
public string? ProviderName { get; }

/// <summary>Gets the URL for accessing the hosted conversation provider.</summary>
public Uri? ProviderUri { get; }
}
Loading
Loading