Skip to content

Commit 44f6484

Browse files
stephentoubCopilot
andauthored
Allow ChatOptions.ConversationId to be an OpenAI conversation ID with Responses (#6960)
* Allow ChatOptions.ConversationId to be an OpenAI conversation ID with Responses * Update src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent a0e6496 commit 44f6484

File tree

6 files changed

+883
-30
lines changed

6 files changed

+883
-30
lines changed

src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

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

810
## 9.10.1
911

src/Libraries/Microsoft.Extensions.AI.OpenAI/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Release History
22

3+
## NOT YET RELEASED
4+
5+
- Updated the `IChatClient` for the OpenAI Responses API to allow either conversation or response ID for `ChatOptions.ConversationId`.
6+
- Added an `AITool` to `ResponseTool` conversion utility.
7+
- Updated to accommodate the additions in `Microsoft.Extensions.AI.Abstractions`.
8+
39
## 9.10.1-preview.1.25521.4
410

511
- Updated the `IChatClient` for the OpenAI Responses API to support connectors with `HostedMcpServerTool`.

src/Libraries/Microsoft.Extensions.AI.OpenAI/MicrosoftExtensionsAIResponsesExtensions.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ public static IEnumerable<ChatMessage> AsChatMessages(this IEnumerable<ResponseI
6262
/// <returns>A converted <see cref="ChatResponse"/>.</returns>
6363
/// <exception cref="ArgumentNullException"><paramref name="response"/> is <see langword="null"/>.</exception>
6464
public static ChatResponse AsChatResponse(this OpenAIResponse response, ResponseCreationOptions? options = null) =>
65-
OpenAIResponsesChatClient.FromOpenAIResponse(Throw.IfNull(response), options);
65+
OpenAIResponsesChatClient.FromOpenAIResponse(Throw.IfNull(response), options, conversationId: null);
6666

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

8080
/// <summary>Creates an OpenAI <see cref="OpenAIResponse"/> from a <see cref="ChatResponse"/>.</summary>
8181
/// <param name="response">The response to convert.</param>

src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs

Lines changed: 110 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@
1111
using System.Runtime.CompilerServices;
1212
using System.Text;
1313
using System.Text.Json;
14+
using System.Text.Json.Nodes;
1415
using System.Threading;
1516
using System.Threading.Tasks;
1617
using Microsoft.Shared.Diagnostics;
1718
using OpenAI.Responses;
1819

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

8789
// Convert the inputs into what OpenAIResponseClient expects.
88-
var openAIOptions = ToOpenAIResponseCreationOptions(options);
90+
var openAIOptions = ToOpenAIResponseCreationOptions(options, out string? openAIConversationId);
8991

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

95-
return FromOpenAIResponse(response, openAIOptions);
97+
return FromOpenAIResponse(response, openAIOptions, openAIConversationId);
9698
}
9799

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

106108
// Convert the response to a ChatResponse.
107-
return FromOpenAIResponse(openAIResponse, openAIOptions);
109+
return FromOpenAIResponse(openAIResponse, openAIOptions, openAIConversationId);
108110
}
109111

110-
internal static ChatResponse FromOpenAIResponse(OpenAIResponse openAIResponse, ResponseCreationOptions? openAIOptions)
112+
internal static ChatResponse FromOpenAIResponse(OpenAIResponse openAIResponse, ResponseCreationOptions? openAIOptions, string? conversationId)
111113
{
112114
// Convert and return the results.
113115
ChatResponse response = new()
114116
{
115-
ConversationId = openAIOptions?.StoredOutputEnabled is false ? null : openAIResponse.Id,
117+
ConversationId = openAIOptions?.StoredOutputEnabled is false ? null : (conversationId ?? openAIResponse.Id),
116118
CreatedAt = openAIResponse.CreatedAt,
117119
ContinuationToken = CreateContinuationToken(openAIResponse),
118120
FinishReason = ToFinishReason(openAIResponse.IncompleteStatusDetails?.Reason),
@@ -232,14 +234,14 @@ public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
232234
{
233235
_ = Throw.IfNull(messages);
234236

235-
var openAIOptions = ToOpenAIResponseCreationOptions(options);
237+
var openAIOptions = ToOpenAIResponseCreationOptions(options, out string? openAIConversationId);
236238

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

242-
return FromOpenAIStreamingResponseUpdatesAsync(updates, openAIOptions, token.ResponseId, cancellationToken);
244+
return FromOpenAIStreamingResponseUpdatesAsync(updates, openAIOptions, openAIConversationId, token.ResponseId, cancellationToken);
243245
}
244246

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

251-
return FromOpenAIStreamingResponseUpdatesAsync(streamingUpdates, openAIOptions, cancellationToken: cancellationToken);
253+
return FromOpenAIStreamingResponseUpdatesAsync(streamingUpdates, openAIOptions, openAIConversationId, cancellationToken: cancellationToken);
252254
}
253255

254256
internal static async IAsyncEnumerable<ChatResponseUpdate> FromOpenAIStreamingResponseUpdatesAsync(
255257
IAsyncEnumerable<StreamingResponseUpdate> streamingResponseUpdates,
256258
ResponseCreationOptions? options,
259+
string? conversationId,
257260
string? resumeResponseId = null,
258261
[EnumeratorCancellation] CancellationToken cancellationToken = default)
259262
{
260263
DateTimeOffset? createdAt = null;
261264
string? responseId = resumeResponseId;
262-
string? conversationId = options?.StoredOutputEnabled is false ? null : resumeResponseId;
263265
string? modelId = null;
264266
string? lastMessageId = null;
265267
ChatRole? lastRole = null;
266268
bool anyFunctions = false;
267269
ResponseStatus? latestResponseStatus = null;
268270

271+
UpdateConversationId(resumeResponseId);
272+
269273
await foreach (var streamingUpdate in streamingResponseUpdates.WithCancellation(cancellationToken).ConfigureAwait(false))
270274
{
271275
// Create an update populated with the current state of the response.
@@ -290,39 +294,39 @@ ChatResponseUpdate CreateUpdate(AIContent? content = null) =>
290294
case StreamingResponseCreatedUpdate createdUpdate:
291295
createdAt = createdUpdate.Response.CreatedAt;
292296
responseId = createdUpdate.Response.Id;
293-
conversationId = options?.StoredOutputEnabled is false ? null : responseId;
297+
UpdateConversationId(responseId);
294298
modelId = createdUpdate.Response.Model;
295299
latestResponseStatus = createdUpdate.Response.Status;
296300
goto default;
297301

298302
case StreamingResponseQueuedUpdate queuedUpdate:
299303
createdAt = queuedUpdate.Response.CreatedAt;
300304
responseId = queuedUpdate.Response.Id;
301-
conversationId = options?.StoredOutputEnabled is false ? null : responseId;
305+
UpdateConversationId(responseId);
302306
modelId = queuedUpdate.Response.Model;
303307
latestResponseStatus = queuedUpdate.Response.Status;
304308
goto default;
305309

306310
case StreamingResponseInProgressUpdate inProgressUpdate:
307311
createdAt = inProgressUpdate.Response.CreatedAt;
308312
responseId = inProgressUpdate.Response.Id;
309-
conversationId = options?.StoredOutputEnabled is false ? null : responseId;
313+
UpdateConversationId(responseId);
310314
modelId = inProgressUpdate.Response.Model;
311315
latestResponseStatus = inProgressUpdate.Response.Status;
312316
goto default;
313317

314318
case StreamingResponseIncompleteUpdate incompleteUpdate:
315319
createdAt = incompleteUpdate.Response.CreatedAt;
316320
responseId = incompleteUpdate.Response.Id;
317-
conversationId = options?.StoredOutputEnabled is false ? null : responseId;
321+
UpdateConversationId(responseId);
318322
modelId = incompleteUpdate.Response.Model;
319323
latestResponseStatus = incompleteUpdate.Response.Status;
320324
goto default;
321325

322326
case StreamingResponseFailedUpdate failedUpdate:
323327
createdAt = failedUpdate.Response.CreatedAt;
324328
responseId = failedUpdate.Response.Id;
325-
conversationId = options?.StoredOutputEnabled is false ? null : responseId;
329+
UpdateConversationId(responseId);
326330
modelId = failedUpdate.Response.Model;
327331
latestResponseStatus = failedUpdate.Response.Status;
328332
goto default;
@@ -331,7 +335,7 @@ ChatResponseUpdate CreateUpdate(AIContent? content = null) =>
331335
{
332336
createdAt = completedUpdate.Response.CreatedAt;
333337
responseId = completedUpdate.Response.Id;
334-
conversationId = options?.StoredOutputEnabled is false ? null : responseId;
338+
UpdateConversationId(responseId);
335339
modelId = completedUpdate.Response.Model;
336340
latestResponseStatus = completedUpdate.Response?.Status;
337341
var update = CreateUpdate(ToUsageDetails(completedUpdate.Response) is { } usage ? new UsageContent(usage) : null);
@@ -434,6 +438,18 @@ outputItemDoneUpdate.Item is MessageResponseItem mri &&
434438
break;
435439
}
436440
}
441+
442+
void UpdateConversationId(string? id)
443+
{
444+
if (options?.StoredOutputEnabled is false)
445+
{
446+
conversationId = null;
447+
}
448+
else
449+
{
450+
conversationId ??= id;
451+
}
452+
}
437453
}
438454

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

565581
/// <summary>Converts a <see cref="ChatOptions"/> to a <see cref="ResponseCreationOptions"/>.</summary>
566-
private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? options)
582+
private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? options, out string? openAIConversationId)
567583
{
584+
openAIConversationId = null;
585+
568586
if (options is null)
569587
{
570588
return new ResponseCreationOptions();
571589
}
572590

573-
if (options.RawRepresentationFactory?.Invoke(this) is not ResponseCreationOptions result)
591+
bool hasRawRco = false;
592+
if (options.RawRepresentationFactory?.Invoke(this) is ResponseCreationOptions result)
593+
{
594+
hasRawRco = true;
595+
}
596+
else
574597
{
575598
result = new ResponseCreationOptions();
576599
}
577600

578-
// Handle strongly-typed properties.
579601
result.MaxOutputTokenCount ??= options.MaxOutputTokens;
580-
result.PreviousResponseId ??= options.ConversationId;
581602
result.Temperature ??= options.Temperature;
582603
result.TopP ??= options.TopP;
583604
result.BackgroundModeEnabled ??= options.AllowBackgroundResponses;
584605

606+
// If the ResponseCreationOptions.PreviousResponseId is already set (likely rare), then we don't need to do
607+
// anything with regards to Conversation, because they're mutually exclusive and we would want to ignore
608+
// ChatOptions.ConversationId regardless of its value. If it's null, we want to examine the ResponseCreationOptions
609+
// instance to see if a conversation ID has already been set on it and use that conversation ID subsequently if
610+
// it has. If one hasn't been set, but ChatOptions.ConversationId has been set, we'll either set
611+
// ResponseCreationOptions.Conversation if the string represents a conversation ID or else PreviousResponseId.
612+
if (result.PreviousResponseId is null)
613+
{
614+
// Technically, OpenAI's IDs are opaque. However, by convention conversation IDs start with "conv_" and
615+
// we can use that to disambiguate whether we're looking at a conversation ID or a response ID.
616+
string? chatOptionsConversationId = options.ConversationId;
617+
bool chatOptionsHasOpenAIConversationId = chatOptionsConversationId?.StartsWith("conv_", StringComparison.OrdinalIgnoreCase) is true;
618+
619+
if (hasRawRco || chatOptionsHasOpenAIConversationId)
620+
{
621+
const string ConversationPropertyName = "conversation";
622+
try
623+
{
624+
// ResponseCreationOptions currently doesn't expose either Conversation nor JSON Path for accessing
625+
// arbitrary properties publicly. Until it does, we need to serialize the RCO and examine
626+
// and possibly mutate/deserialize the resulting JSON.
627+
var rcoJsonModel = (IJsonModel<ResponseCreationOptions>)result;
628+
var rcoJsonBinaryData = rcoJsonModel.Write(ModelReaderWriterOptions.Json);
629+
if (JsonNode.Parse(rcoJsonBinaryData.ToMemory().Span) is JsonObject rcoJsonObject)
630+
{
631+
// Check if a conversation ID is already set on the RCO. If one is, store it for later.
632+
if (rcoJsonObject.TryGetPropertyValue(ConversationPropertyName, out JsonNode? existingConversationNode))
633+
{
634+
switch (existingConversationNode?.GetValueKind())
635+
{
636+
case JsonValueKind.String:
637+
openAIConversationId = existingConversationNode.GetValue<string>();
638+
break;
639+
640+
case JsonValueKind.Object:
641+
openAIConversationId =
642+
existingConversationNode.AsObject().TryGetPropertyValue("id", out JsonNode? idNode) && idNode?.GetValueKind() == JsonValueKind.String ?
643+
idNode.GetValue<string>() :
644+
null;
645+
break;
646+
}
647+
}
648+
649+
// If one isn't set, and ChatOptions.ConversationId is set to a conversation ID, set it now.
650+
if (openAIConversationId is null && chatOptionsHasOpenAIConversationId)
651+
{
652+
rcoJsonObject[ConversationPropertyName] = JsonValue.Create(chatOptionsConversationId);
653+
rcoJsonBinaryData = new(JsonSerializer.SerializeToUtf8Bytes(rcoJsonObject, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonNode))));
654+
if (rcoJsonModel.Create(rcoJsonBinaryData, ModelReaderWriterOptions.Json) is ResponseCreationOptions newRco)
655+
{
656+
result = newRco;
657+
openAIConversationId = chatOptionsConversationId;
658+
}
659+
}
660+
}
661+
}
662+
catch
663+
{
664+
// Ignore any JSON formatting / parsing failures
665+
}
666+
}
667+
668+
// If after all that we still don't have a conversation ID, and ChatOptions.ConversationId is set,
669+
// treat it as a response ID.
670+
if (openAIConversationId is null && options.ConversationId is { } previousResponseId)
671+
{
672+
result.PreviousResponseId = previousResponseId;
673+
}
674+
}
675+
585676
if (options.Instructions is { } instructions)
586677
{
587678
result.Instructions = string.IsNullOrEmpty(result.Instructions) ?

0 commit comments

Comments
 (0)