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
2 changes: 1 addition & 1 deletion eng/Versions.props
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<PropertyGroup Label="Version settings">
<MajorVersion>10</MajorVersion>
<MinorVersion>8</MinorVersion>
<PatchVersion>0</PatchVersion>
<PatchVersion>1</PatchVersion>
<PreReleaseVersionLabel>preview</PreReleaseVersionLabel>
<PreReleaseVersionIteration>1</PreReleaseVersionIteration>
<VersionPrefix>$(MajorVersion).$(MinorVersion).$(PatchVersion)</VersionPrefix>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ namespace Microsoft.Extensions.AI;
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
internal sealed class OpenAIResponsesChatClient : IChatClient
{
// AdditionalProperties key used to roundtrip a reasoning item's service-assigned id. Unlike
// RawRepresentation, AdditionalProperties survives both content coalescing and JSON serialization of the
// chat history, so the id is available when reconstructing the reasoning item for a subsequent request.
// Stateless (store=false) resume of encrypted reasoning requires this id or the service rejects the item.
private const string ReasoningItemIdKey = "reasoningItemId";

// These delegate instances are used to call the internal overloads of CreateResponseAsync and CreateResponseStreamingAsync that accept
// a RequestOptions. These should be replaced once a better way to pass RequestOptions is available.

Expand Down Expand Up @@ -198,11 +204,7 @@ internal static IEnumerable<ChatMessage> ToChatMessages(IEnumerable<ResponseItem
break;

case ReasoningResponseItem reasoningItem:
message.Contents.Add(new TextReasoningContent(reasoningItem.GetSummaryText())
{
ProtectedData = reasoningItem.EncryptedContent,
RawRepresentation = outputItem,
});
message.Contents.Add(CreateReasoningContent(reasoningItem.GetSummaryText(), reasoningItem.EncryptedContent, reasoningItem.Id, outputItem));
break;

case FunctionCallResponseItem functionCall:
Expand Down Expand Up @@ -475,11 +477,11 @@ ChatResponseUpdate CreateUpdate(AIContent? content = null) =>
break;

case StreamingResponseReasoningSummaryTextDeltaUpdate reasoningSummaryTextDeltaUpdate:
yield return CreateUpdate(new TextReasoningContent(reasoningSummaryTextDeltaUpdate.Delta));
yield return CreateUpdate(CreateReasoningContent(reasoningSummaryTextDeltaUpdate.Delta, protectedData: null, reasoningSummaryTextDeltaUpdate.ItemId));
break;

case StreamingResponseReasoningTextDeltaUpdate reasoningTextDeltaUpdate:
yield return CreateUpdate(new TextReasoningContent(reasoningTextDeltaUpdate.Delta));
yield return CreateUpdate(CreateReasoningContent(reasoningTextDeltaUpdate.Delta, protectedData: null, reasoningTextDeltaUpdate.ItemId));
break;

case StreamingResponseImageGenerationCallInProgressUpdate imageGenInProgress:
Expand Down Expand Up @@ -595,9 +597,12 @@ ChatResponseUpdate CreateUpdate(AIContent? content = null) =>
// so that it can be coalesced with the streamed text deltas and roundtripped.
// Since we may have already yielded reasoning deltas, we explicitly avoid setting
// the RawRepresentation here to avoid duplication, as when roundtripping that
// raw representation will be preferred.
// raw representation will be preferred. The reasoning item's id is stashed in
// AdditionalProperties (which survives coalescing and serialization, unlike
// RawRepresentation) so that it can be sent back on a subsequent request; stateless
// (store=false) resume of encrypted reasoning is rejected by the service without it.
case ReasoningResponseItem rri when rri.EncryptedContent is { Length: > 0 } encryptedContent:
yield return CreateUpdate(new TextReasoningContent(null) { ProtectedData = encryptedContent });
yield return CreateUpdate(CreateReasoningContent(text: null, protectedData: encryptedContent, itemId: rri.Id));
break;

// For ResponseItems where we've already yielded partial deltas for the whole content,
Expand Down Expand Up @@ -917,6 +922,24 @@ private static ChatRole AsChatRole(MessageRole? role) =>
_ => ChatRole.Assistant,
};

/// <summary>Creates a <see cref="TextReasoningContent"/>, stashing the reasoning item's id (when available)
/// in <see cref="AIContent.AdditionalProperties"/> so it roundtrips back to the service.</summary>
private static TextReasoningContent CreateReasoningContent(string? text, string? protectedData, string? itemId, object? rawRepresentation = null)
{
TextReasoningContent content = new(text)
{
ProtectedData = protectedData,
RawRepresentation = rawRepresentation,
};

if (!string.IsNullOrEmpty(itemId))
{
(content.AdditionalProperties ??= [])[ReasoningItemIdKey] = itemId;
}

return content;
}

/// <summary>Creates a <see cref="ChatFinishReason"/> from a <see cref="ResponseIncompleteStatusReason"/>.</summary>
private static ChatFinishReason? AsFinishReason(ResponseIncompleteStatusReason? statusReason) =>
statusReason == ResponseIncompleteStatusReason.ContentFilter ? ChatFinishReason.ContentFilter :
Expand Down Expand Up @@ -1523,10 +1546,29 @@ static FunctionCallOutputResponseItem SerializeAIContent(string callId, IEnumera
break;

case TextReasoningContent reasoningContent:
yield return new ReasoningResponseItem(reasoningContent.Text)
ReasoningResponseItem reasoningResponseItem = new(reasoningContent.Text)
{
EncryptedContent = reasoningContent.ProtectedData,
};

// Restore the reasoning item's id (stashed in AdditionalProperties on the way in)
// so that encrypted reasoning roundtrips in stateless (store=false) scenarios,
// where the service rejects a reasoning item that is missing its id. The value may
// be a string (in-process) or a JsonElement (after the history has been serialized
// and rehydrated, e.g. a persisted human-in-the-loop approval session).
if (reasoningContent.AdditionalProperties?.TryGetValue(ReasoningItemIdKey, out object? reasoningItemIdValue) is true)
{
string? reasoningItemId =
reasoningItemIdValue as string ??
(reasoningItemIdValue is JsonElement { ValueKind: JsonValueKind.String } jsonElement ? jsonElement.GetString() : null);

if (!string.IsNullOrEmpty(reasoningItemId))
{
reasoningResponseItem.Id = reasoningItemId;
}
}

yield return reasoningResponseItem;
break;

case McpServerToolCallContent mstcc:
Expand Down
Loading
Loading