1111using System . Runtime . CompilerServices ;
1212using System . Text ;
1313using System . Text . Json ;
14+ using System . Text . Json . Nodes ;
1415using System . Threading ;
1516using System . Threading . Tasks ;
1617using Microsoft . Shared . Diagnostics ;
1718using 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