Skip to content
Open
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 @@ -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, encryptedContent, 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
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,95 @@ public async Task ReasoningTextDelta_Streaming_PreservesEncryptedContent()
Assert.Equal("secret-encrypted-data-abc123", coalescedReasoning.ProtectedData);
}

[Fact]
public async Task EncryptedReasoning_Streaming_RoundTripsReasoningItemId()
{
// Streamed response containing a reasoning item (text deltas + encrypted_content + id),
// followed by an assistant message. Mirrors a stateless (store=false) turn that included
// reasoning.encrypted_content.
const string FirstResponse = """
event: response.created
data: {"type":"response.created","sequence_number":0,"response":{"id":"resp_reasoning123","object":"response","created_at":1756752900,"status":"in_progress","model":"o4-mini-2025-04-16","output":[],"reasoning":{"effort":"medium"}}}

event: response.output_item.added
data: {"type":"response.output_item.added","sequence_number":1,"output_index":0,"item":{"id":"rs_reasoning123","type":"reasoning","text":""}}

event: response.reasoning_text.delta
data: {"type":"response.reasoning_text.delta","sequence_number":2,"item_id":"rs_reasoning123","output_index":0,"delta":"First, "}

event: response.reasoning_text.delta
data: {"type":"response.reasoning_text.delta","sequence_number":3,"item_id":"rs_reasoning123","output_index":0,"delta":"let's analyze the problem."}

event: response.reasoning_text.done
data: {"type":"response.reasoning_text.done","sequence_number":4,"item_id":"rs_reasoning123","output_index":0,"text":"First, let's analyze the problem."}

event: response.output_item.done
data: {"type":"response.output_item.done","sequence_number":5,"output_index":0,"item":{"id":"rs_reasoning123","type":"reasoning","text":"First, let's analyze the problem.","encrypted_content":"secret-encrypted-data-abc123"}}

event: response.output_item.added
data: {"type":"response.output_item.added","sequence_number":6,"output_index":1,"item":{"id":"msg_reasoning123","type":"message","status":"in_progress","content":[],"role":"assistant"}}

event: response.output_text.delta
data: {"type":"response.output_text.delta","sequence_number":7,"item_id":"msg_reasoning123","output_index":1,"content_index":0,"delta":"The solution is 42."}

event: response.output_item.done
data: {"type":"response.output_item.done","sequence_number":8,"output_index":1,"item":{"id":"msg_reasoning123","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"text":"The solution is 42."}],"role":"assistant"}}

event: response.completed
data: {"type":"response.completed","sequence_number":9,"response":{"id":"resp_reasoning123","object":"response","created_at":1756752900,"status":"completed","model":"o4-mini-2025-04-16","output":[{"id":"rs_reasoning123","type":"reasoning","text":"First, let's analyze the problem.","encrypted_content":"secret-encrypted-data-abc123"},{"id":"msg_reasoning123","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"text":"The solution is 42."}],"role":"assistant"}],"usage":{"input_tokens":10,"output_tokens":25,"total_tokens":35}}}

""";

const string SecondResponse = """
{"id":"resp_followup","object":"response","created_at":1756752901,"status":"completed","model":"o4-mini-2025-04-16","output":[{"id":"msg_followup","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"text":"Confirmed."}],"role":"assistant"}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}
""";

using CapturingHttpHandler handler = new([FirstResponse, SecondResponse]);
using HttpClient httpClient = new(handler);
using IChatClient client = CreateResponseClient(httpClient, "o4-mini");

// Turn 1: stream a response with encrypted reasoning and collect the history.
List<ChatResponseUpdate> updates = [];
await foreach (var update in client.GetStreamingResponseAsync("Solve this problem step by step.", new()
{
RawRepresentationFactory = _ => new CreateResponseOptions
{
StoredOutputEnabled = false,
ReasoningOptions = new() { ReasoningEffortLevel = ResponseReasoningEffortLevel.Medium },
},
}))
{
updates.Add(update);
}

List<ChatMessage> chatHistory = [];
chatHistory.AddMessages(updates);

// Simulate persisting and rehydrating the session between turns (as a hosted, human-in-the-loop
// approval flow does). This drops RawRepresentation (which is [JsonIgnore]), so the reasoning item's
// id must survive via a serialized field (AdditionalProperties) to roundtrip.
string serializedHistory = JsonSerializer.Serialize(chatHistory, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(List<ChatMessage>)));
chatHistory = (List<ChatMessage>)JsonSerializer.Deserialize(serializedHistory, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(List<ChatMessage>)))!;
chatHistory.Add(new ChatMessage(ChatRole.User, "Are you sure?"));

// Turn 2: send the history back. The reasoning item must roundtrip with its id, otherwise
// a stateless (store=false) service rejects the request (see dotnet/extensions issue and
// microsoft/agent-framework#7067).
await client.GetResponseAsync(chatHistory, new()
{
RawRepresentationFactory = _ => new CreateResponseOptions { StoredOutputEnabled = false },
});

Assert.Equal(2, handler.RequestBodies.Count);

using JsonDocument secondRequest = JsonDocument.Parse(handler.RequestBodies[1]);
JsonElement reasoningItem = secondRequest.RootElement.GetProperty("input").EnumerateArray()
.Single(item => item.TryGetProperty("type", out var t) && t.GetString() == "reasoning");

Assert.Equal("rs_reasoning123", reasoningItem.GetProperty("id").GetString());
Assert.Equal("secret-encrypted-data-abc123", reasoningItem.GetProperty("encrypted_content").GetString());
}

[Fact]
public async Task BasicRequestResponse_Streaming()
{
Expand Down Expand Up @@ -6779,6 +6868,23 @@ private static IChatClient CreateResponseClient(HttpClient httpClient, string mo
.GetResponsesClient()
.AsIChatClient(modelId);

// Returns the supplied responses in order, one per request, capturing each request body.
private sealed class CapturingHttpHandler(IReadOnlyList<string> responses) : HttpMessageHandler
{
private int _index;

public List<string> RequestBodies { get; } = [];

protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, System.Threading.CancellationToken cancellationToken)
{
#pragma warning disable CA2016 // ReadAsStringAsync(CancellationToken) is unavailable on all target frameworks
RequestBodies.Add(request.Content is null ? string.Empty : await request.Content.ReadAsStringAsync().ConfigureAwait(false));
#pragma warning restore CA2016
string body = responses[Math.Min(_index++, responses.Count - 1)];
return new HttpResponseMessage { Content = new StringContent(body) };
}
}

private static string ResponseStatusToRequestValue(ResponseStatus status)
{
if (status == ResponseStatus.InProgress)
Expand Down
Loading