Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

- Updated `AIFunctionFactory` to respect `[DisplayName(...)]` on functions as a way to override the function name.
- Updated `AIFunctionFactory` to respect `[DefaultValue(...)]` on function parameters as a way to specify default values.
- Added `CodeInterpreterToolCallContent`/`CodeInterpreterToolResultContent` for representing code interpreter tool calls and results.
- Fixed the serialization/deserialization of variables typed as `UserInputRequestContent`/`UserInputResponseContent`.

## 9.10.1

Expand Down
6 changes: 6 additions & 0 deletions src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Release History

## NOT YET RELEASED

- Updated the `IChatClient` for the OpenAI Responses API to allow either conversation or response ID for `ChatOptions.ConversationId`.
- Added an `AITool` to `ResponseTool` conversion utility.
- Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`.

## 9.10.1-preview.1.25521.4

- Updated the `IChatClient` for the OpenAI Responses API to support connectors with `HostedMcpServerTool`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ public static IEnumerable<ChatMessage> AsChatMessages(this IEnumerable<ResponseI
/// <returns>A converted <see cref="ChatResponse"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="response"/> is <see langword="null"/>.</exception>
public static ChatResponse AsChatResponse(this OpenAIResponse response, ResponseCreationOptions? options = null) =>
OpenAIResponsesChatClient.FromOpenAIResponse(Throw.IfNull(response), options);
OpenAIResponsesChatClient.FromOpenAIResponse(Throw.IfNull(response), options, conversationId: null);

/// <summary>
/// Creates a sequence of Microsoft.Extensions.AI <see cref="ChatResponseUpdate"/> instances from the specified
Expand All @@ -75,7 +75,7 @@ public static ChatResponse AsChatResponse(this OpenAIResponse response, Response
/// <exception cref="ArgumentNullException"><paramref name="responseUpdates"/> is <see langword="null"/>.</exception>
public static IAsyncEnumerable<ChatResponseUpdate> AsChatResponseUpdatesAsync(
this IAsyncEnumerable<StreamingResponseUpdate> responseUpdates, ResponseCreationOptions? options = null, CancellationToken cancellationToken = default) =>
OpenAIResponsesChatClient.FromOpenAIStreamingResponseUpdatesAsync(Throw.IfNull(responseUpdates), options, cancellationToken: cancellationToken);
OpenAIResponsesChatClient.FromOpenAIStreamingResponseUpdatesAsync(Throw.IfNull(responseUpdates), options, conversationId: null, cancellationToken: cancellationToken);

/// <summary>Creates an OpenAI <see cref="OpenAIResponse"/> from a <see cref="ChatResponse"/>.</summary>
/// <param name="response">The response to convert.</param>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.Diagnostics;
using OpenAI.Responses;

#pragma warning disable S1226 // Method parameters, caught exceptions and foreach variables' initial values should not be ignored
#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields
#pragma warning disable S3254 // Default parameter values should not be passed as arguments
#pragma warning disable SA1204 // Static elements should appear before instance elements
Expand Down Expand Up @@ -85,14 +87,14 @@ public async Task<ChatResponse> GetResponseAsync(
_ = Throw.IfNull(messages);

// Convert the inputs into what OpenAIResponseClient expects.
var openAIOptions = ToOpenAIResponseCreationOptions(options);
var openAIOptions = ToOpenAIResponseCreationOptions(options, out string? openAIConversationId);

// Provided continuation token signals that an existing background response should be fetched.
if (GetContinuationToken(messages, options) is { } token)
{
var response = await _responseClient.GetResponseAsync(token.ResponseId, cancellationToken).ConfigureAwait(false);

return FromOpenAIResponse(response, openAIOptions);
return FromOpenAIResponse(response, openAIOptions, openAIConversationId);
}

var openAIResponseItems = ToOpenAIResponseItems(messages, options);
Expand All @@ -104,15 +106,15 @@ public async Task<ChatResponse> GetResponseAsync(
var openAIResponse = (await task.ConfigureAwait(false)).Value;

// Convert the response to a ChatResponse.
return FromOpenAIResponse(openAIResponse, openAIOptions);
return FromOpenAIResponse(openAIResponse, openAIOptions, openAIConversationId);
}

internal static ChatResponse FromOpenAIResponse(OpenAIResponse openAIResponse, ResponseCreationOptions? openAIOptions)
internal static ChatResponse FromOpenAIResponse(OpenAIResponse openAIResponse, ResponseCreationOptions? openAIOptions, string? conversationId)
{
// Convert and return the results.
ChatResponse response = new()
{
ConversationId = openAIOptions?.StoredOutputEnabled is false ? null : openAIResponse.Id,
ConversationId = openAIOptions?.StoredOutputEnabled is false ? null : (conversationId ?? openAIResponse.Id),
CreatedAt = openAIResponse.CreatedAt,
ContinuationToken = CreateContinuationToken(openAIResponse),
FinishReason = ToFinishReason(openAIResponse.IncompleteStatusDetails?.Reason),
Expand Down Expand Up @@ -232,14 +234,14 @@ public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
{
_ = Throw.IfNull(messages);

var openAIOptions = ToOpenAIResponseCreationOptions(options);
var openAIOptions = ToOpenAIResponseCreationOptions(options, out string? openAIConversationId);

// Provided continuation token signals that an existing background response should be fetched.
if (GetContinuationToken(messages, options) is { } token)
{
IAsyncEnumerable<StreamingResponseUpdate> updates = _responseClient.GetResponseStreamingAsync(token.ResponseId, token.SequenceNumber, cancellationToken);

return FromOpenAIStreamingResponseUpdatesAsync(updates, openAIOptions, token.ResponseId, cancellationToken);
return FromOpenAIStreamingResponseUpdatesAsync(updates, openAIOptions, openAIConversationId, token.ResponseId, cancellationToken);
}

var openAIResponseItems = ToOpenAIResponseItems(messages, options);
Expand All @@ -248,24 +250,26 @@ public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
_createResponseStreamingAsync(_responseClient, openAIResponseItems, openAIOptions, cancellationToken.ToRequestOptions(streaming: true)) :
_responseClient.CreateResponseStreamingAsync(openAIResponseItems, openAIOptions, cancellationToken);

return FromOpenAIStreamingResponseUpdatesAsync(streamingUpdates, openAIOptions, cancellationToken: cancellationToken);
return FromOpenAIStreamingResponseUpdatesAsync(streamingUpdates, openAIOptions, openAIConversationId, cancellationToken: cancellationToken);
}

internal static async IAsyncEnumerable<ChatResponseUpdate> FromOpenAIStreamingResponseUpdatesAsync(
IAsyncEnumerable<StreamingResponseUpdate> streamingResponseUpdates,
ResponseCreationOptions? options,
string? conversationId,
string? resumeResponseId = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
DateTimeOffset? createdAt = null;
string? responseId = resumeResponseId;
string? conversationId = options?.StoredOutputEnabled is false ? null : resumeResponseId;
string? modelId = null;
string? lastMessageId = null;
ChatRole? lastRole = null;
bool anyFunctions = false;
ResponseStatus? latestResponseStatus = null;

UpdateConversationId(resumeResponseId);

await foreach (var streamingUpdate in streamingResponseUpdates.WithCancellation(cancellationToken).ConfigureAwait(false))
{
// Create an update populated with the current state of the response.
Expand All @@ -290,39 +294,39 @@ ChatResponseUpdate CreateUpdate(AIContent? content = null) =>
case StreamingResponseCreatedUpdate createdUpdate:
createdAt = createdUpdate.Response.CreatedAt;
responseId = createdUpdate.Response.Id;
conversationId = options?.StoredOutputEnabled is false ? null : responseId;
UpdateConversationId(responseId);
modelId = createdUpdate.Response.Model;
latestResponseStatus = createdUpdate.Response.Status;
goto default;

case StreamingResponseQueuedUpdate queuedUpdate:
createdAt = queuedUpdate.Response.CreatedAt;
responseId = queuedUpdate.Response.Id;
conversationId = options?.StoredOutputEnabled is false ? null : responseId;
UpdateConversationId(responseId);
modelId = queuedUpdate.Response.Model;
latestResponseStatus = queuedUpdate.Response.Status;
goto default;

case StreamingResponseInProgressUpdate inProgressUpdate:
createdAt = inProgressUpdate.Response.CreatedAt;
responseId = inProgressUpdate.Response.Id;
conversationId = options?.StoredOutputEnabled is false ? null : responseId;
UpdateConversationId(responseId);
modelId = inProgressUpdate.Response.Model;
latestResponseStatus = inProgressUpdate.Response.Status;
goto default;

case StreamingResponseIncompleteUpdate incompleteUpdate:
createdAt = incompleteUpdate.Response.CreatedAt;
responseId = incompleteUpdate.Response.Id;
conversationId = options?.StoredOutputEnabled is false ? null : responseId;
UpdateConversationId(responseId);
modelId = incompleteUpdate.Response.Model;
latestResponseStatus = incompleteUpdate.Response.Status;
goto default;

case StreamingResponseFailedUpdate failedUpdate:
createdAt = failedUpdate.Response.CreatedAt;
responseId = failedUpdate.Response.Id;
conversationId = options?.StoredOutputEnabled is false ? null : responseId;
UpdateConversationId(responseId);
modelId = failedUpdate.Response.Model;
latestResponseStatus = failedUpdate.Response.Status;
goto default;
Expand All @@ -331,7 +335,7 @@ ChatResponseUpdate CreateUpdate(AIContent? content = null) =>
{
createdAt = completedUpdate.Response.CreatedAt;
responseId = completedUpdate.Response.Id;
conversationId = options?.StoredOutputEnabled is false ? null : responseId;
UpdateConversationId(responseId);
modelId = completedUpdate.Response.Model;
latestResponseStatus = completedUpdate.Response?.Status;
var update = CreateUpdate(ToUsageDetails(completedUpdate.Response) is { } usage ? new UsageContent(usage) : null);
Expand Down Expand Up @@ -434,6 +438,18 @@ outputItemDoneUpdate.Item is MessageResponseItem mri &&
break;
}
}

void UpdateConversationId(string? id)
{
if (options?.StoredOutputEnabled is false)
{
conversationId = null;
}
else
{
conversationId ??= id;
}
}
}

/// <inheritdoc />
Expand Down Expand Up @@ -563,25 +579,100 @@ private static ChatRole ToChatRole(MessageRole? role) =>
null;

/// <summary>Converts a <see cref="ChatOptions"/> to a <see cref="ResponseCreationOptions"/>.</summary>
private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? options)
private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? options, out string? openAIConversationId)
{
openAIConversationId = null;

if (options is null)
{
return new ResponseCreationOptions();
}

if (options.RawRepresentationFactory?.Invoke(this) is not ResponseCreationOptions result)
bool hasRawRco = false;
if (options.RawRepresentationFactory?.Invoke(this) is ResponseCreationOptions result)
{
hasRawRco = true;
}
else
{
result = new ResponseCreationOptions();
}

// Handle strongly-typed properties.
result.MaxOutputTokenCount ??= options.MaxOutputTokens;
result.PreviousResponseId ??= options.ConversationId;
result.Temperature ??= options.Temperature;
result.TopP ??= options.TopP;
result.BackgroundModeEnabled ??= options.AllowBackgroundResponses;

// If the ResponseCreationOptions.PreviousResponseId is already set (likely rare), then we don't need to do
// anything with regards to Conversation, because they're mutually exclusive and we would want to ignore
// ChatOptions.ConversationId regardless of its value. If it's null, we want to examine the ResponseCreationOptions
// instance to see if a conversation ID has already been set on it and use that conversation ID subsequently if
// it has. If one hasn't been set, but ChatOptions.ConversationId has been set, we'll either set
// ResponseCreationOptions.Conversation if the string represents a conversation ID or else PreviousResponseId.
if (result.PreviousResponseId is null)
{
// Technically, OpenAI's IDs are opaque. However, by convention conversation IDs start with "conv_" and
// we can use that to disambiguate whether we're looking at a conversation ID or a response ID.
string? chatOptionsConversationId = options.ConversationId;
bool chatOptionsHasOpenAIConversationId = chatOptionsConversationId?.StartsWith("conv_", StringComparison.OrdinalIgnoreCase) is true;

if (hasRawRco || chatOptionsHasOpenAIConversationId)
{
const string ConversationPropertyName = "conversation";
try
{
// ResponseCreationOptions currently doesn't expose either Conversation nor JSON Path for accessing
// arbitrary properties publicly. Until it does, we need to serialize the RCO and examine
// and possibly mutate/deserialize the resulting JSON.
var rcoJsonModel = (IJsonModel<ResponseCreationOptions>)result;
var rcoJsonBinaryData = rcoJsonModel.Write(ModelReaderWriterOptions.Json);
if (JsonNode.Parse(rcoJsonBinaryData.ToMemory().Span) is JsonObject rcoJsonObject)
{
// Check if a conversation ID is already set on the RCO. If one is, store it for later.
if (rcoJsonObject.TryGetPropertyValue(ConversationPropertyName, out JsonNode? existingConversationNode))
{
switch (existingConversationNode?.GetValueKind())
{
case JsonValueKind.String:
openAIConversationId = existingConversationNode.GetValue<string>();
break;

case JsonValueKind.Object:
openAIConversationId =
existingConversationNode.AsObject().TryGetPropertyValue("id", out JsonNode? idNode) && idNode?.GetValueKind() == JsonValueKind.String ?
idNode.GetValue<string>() :
null;
break;
}
}

// If one isn't set, and ChatOptions.ConversationId is set to a conversation ID, set it now.
if (openAIConversationId is null && chatOptionsHasOpenAIConversationId)
{
rcoJsonObject[ConversationPropertyName] = JsonValue.Create(chatOptionsConversationId);
rcoJsonBinaryData = new(JsonSerializer.SerializeToUtf8Bytes(rcoJsonObject, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonNode))));
if (rcoJsonModel.Create(rcoJsonBinaryData, ModelReaderWriterOptions.Json) is ResponseCreationOptions newRco)
{
result = newRco;
openAIConversationId = chatOptionsConversationId;
}
}
}
}
catch
{
// Ignore any JSON formatting / parsing failures
}
}

// If after all that we still don't have a conversation ID, and ChatOptions.ConversationId is set,
// treat it as a response ID.
if (openAIConversationId is null && options.ConversationId is { } previousResponseId)
{
result.PreviousResponseId = previousResponseId;
}
}

if (options.Instructions is { } instructions)
{
result.Instructions = string.IsNullOrEmpty(result.Instructions) ?
Expand Down
Loading
Loading