diff --git a/sdk/cs/src/FoundryLocalManager.cs b/sdk/cs/src/FoundryLocalManager.cs
index 639be3a2..4437183f 100644
--- a/sdk/cs/src/FoundryLocalManager.cs
+++ b/sdk/cs/src/FoundryLocalManager.cs
@@ -150,6 +150,24 @@ await Utils.CallWithExceptionHandling(() => EnsureEpsDownloadedImplAsync(ct),
.ConfigureAwait(false);
}
+ ///
+ /// Creates an OpenAI Responses API client.
+ /// The web service must be started first via .
+ ///
+ /// Optional default model ID for requests.
+ /// An instance.
+ /// If the web service is not running.
+ public OpenAIResponsesClient GetResponsesClient(string? modelId = null)
+ {
+ if (Urls == null || Urls.Length == 0)
+ {
+ throw new FoundryLocalException(
+ "Web service is not running. Call StartWebServiceAsync before creating a ResponsesClient.", _logger);
+ }
+
+ return new OpenAIResponsesClient(Urls[0], modelId);
+ }
+
private FoundryLocalManager(Configuration configuration, ILogger logger)
{
_config = configuration ?? throw new ArgumentNullException(nameof(configuration));
diff --git a/sdk/cs/src/IModel.cs b/sdk/cs/src/IModel.cs
index c3acba61..8b05af87 100644
--- a/sdk/cs/src/IModel.cs
+++ b/sdk/cs/src/IModel.cs
@@ -67,4 +67,14 @@ Task DownloadAsync(Action? downloadProgress = null,
/// Optional cancellation token.
/// OpenAI.AudioClient
Task GetAudioClientAsync(CancellationToken? ct = null);
+
+ ///
+ /// Get an OpenAI Responses API client.
+ /// Unlike Chat/Audio clients (which use FFI), the Responses API is HTTP-based,
+ /// so the web service must be started first via .
+ ///
+ /// Optional cancellation token.
+ /// OpenAI.ResponsesClient
+ Task GetResponsesClientAsync(CancellationToken? ct = null)
+ => throw new NotImplementedException("GetResponsesClientAsync is not implemented by this IModel provider.");
}
diff --git a/sdk/cs/src/Model.cs b/sdk/cs/src/Model.cs
index bbbbcb5b..dab396ba 100644
--- a/sdk/cs/src/Model.cs
+++ b/sdk/cs/src/Model.cs
@@ -113,6 +113,11 @@ public async Task GetAudioClientAsync(CancellationToken? ct =
return await SelectedVariant.GetAudioClientAsync(ct).ConfigureAwait(false);
}
+ public async Task GetResponsesClientAsync(CancellationToken? ct = null)
+ {
+ return await SelectedVariant.GetResponsesClientAsync(ct).ConfigureAwait(false);
+ }
+
public async Task UnloadAsync(CancellationToken? ct = null)
{
await SelectedVariant.UnloadAsync(ct).ConfigureAwait(false);
diff --git a/sdk/cs/src/ModelVariant.cs b/sdk/cs/src/ModelVariant.cs
index 6ca7cda7..5593b18f 100644
--- a/sdk/cs/src/ModelVariant.cs
+++ b/sdk/cs/src/ModelVariant.cs
@@ -100,6 +100,13 @@ public async Task GetAudioClientAsync(CancellationToken? ct =
.ConfigureAwait(false);
}
+ public async Task GetResponsesClientAsync(CancellationToken? ct = null)
+ {
+ return await Utils.CallWithExceptionHandling(() => GetResponsesClientImplAsync(ct),
+ "Error getting responses client for model", _logger)
+ .ConfigureAwait(false);
+ }
+
private async Task IsLoadedImplAsync(CancellationToken? ct = null)
{
var loadedModels = await _modelLoadManager.ListLoadedModelsAsync(ct).ConfigureAwait(false);
@@ -190,4 +197,21 @@ private async Task GetAudioClientImplAsync(CancellationToken?
return new OpenAIAudioClient(Id);
}
+
+ private async Task GetResponsesClientImplAsync(CancellationToken? ct = null)
+ {
+ if (!await IsLoadedAsync(ct))
+ {
+ throw new FoundryLocalException($"Model {Id} is not loaded. Call LoadAsync first.");
+ }
+
+ var urls = FoundryLocalManager.Instance.Urls;
+ if (urls == null || urls.Length == 0)
+ {
+ throw new FoundryLocalException(
+ "Web service is not running. Call StartWebServiceAsync before creating a ResponsesClient.");
+ }
+
+ return new OpenAIResponsesClient(urls[0], Id);
+ }
}
diff --git a/sdk/cs/src/OpenAI/ResponsesClient.cs b/sdk/cs/src/OpenAI/ResponsesClient.cs
new file mode 100644
index 00000000..3f29d5d6
--- /dev/null
+++ b/sdk/cs/src/OpenAI/ResponsesClient.cs
@@ -0,0 +1,460 @@
+// --------------------------------------------------------------------------------------------------------------------
+//
+// Copyright (c) Microsoft. All rights reserved.
+//
+// --------------------------------------------------------------------------------------------------------------------
+
+namespace Microsoft.AI.Foundry.Local;
+
+using System.Net.Http;
+using System.Runtime.CompilerServices;
+using System.Text;
+using System.Text.Json;
+
+using Microsoft.AI.Foundry.Local.OpenAI;
+using Microsoft.Extensions.Logging;
+
+///
+/// Client for the OpenAI Responses API served by Foundry Local's embedded web service.
+///
+/// Unlike and (which use FFI via CoreInterop),
+/// the Responses API is HTTP-only. This client uses HttpClient for all operations and parses
+/// Server-Sent Events for streaming.
+///
+/// Create via or
+/// .
+///
+public class OpenAIResponsesClient : IDisposable
+{
+ private readonly string _baseUrl;
+ private readonly string? _modelId;
+ private readonly HttpClient _httpClient;
+ private readonly ILogger _logger = FoundryLocalManager.Instance.Logger;
+ private bool _disposed;
+
+ ///
+ /// Settings for the Responses API client.
+ ///
+ public ResponsesSettings Settings { get; } = new();
+
+ internal OpenAIResponsesClient(string baseUrl, string? modelId)
+ {
+ if (string.IsNullOrWhiteSpace(baseUrl))
+ {
+ throw new ArgumentException("baseUrl must be a non-empty string.", nameof(baseUrl));
+ }
+
+ _baseUrl = baseUrl.TrimEnd('/');
+ _modelId = modelId;
+#pragma warning disable IDISP014 // Use a single instance of HttpClient — lifetime is tied to this client
+ _httpClient = new HttpClient();
+#pragma warning restore IDISP014
+ }
+
+ ///
+ /// Creates a model response (non-streaming).
+ ///
+ /// A string prompt or structured input.
+ /// Optional cancellation token.
+ /// The completed Response object. Check Status and Error even on HTTP 200.
+ public async Task CreateAsync(string input, CancellationToken? ct = null)
+ {
+ return await Utils.CallWithExceptionHandling(
+ () => CreateImplAsync(new ResponseInput { Text = input }, options: null, ct),
+ "Error creating response.", _logger).ConfigureAwait(false);
+ }
+
+ ///
+ /// Creates a model response (non-streaming) with additional options.
+ ///
+ public async Task CreateAsync(string input, Action? options,
+ CancellationToken? ct = null)
+ {
+ return await Utils.CallWithExceptionHandling(
+ () => CreateImplAsync(new ResponseInput { Text = input }, options, ct),
+ "Error creating response.", _logger).ConfigureAwait(false);
+ }
+
+ ///
+ /// Creates a model response (non-streaming) from structured input items.
+ ///
+ public async Task CreateAsync(List input, CancellationToken? ct = null)
+ {
+ return await Utils.CallWithExceptionHandling(
+ () => CreateImplAsync(new ResponseInput { Items = input }, options: null, ct),
+ "Error creating response.", _logger).ConfigureAwait(false);
+ }
+
+ ///
+ /// Creates a model response (non-streaming) from structured input items with options.
+ ///
+ public async Task CreateAsync(List input, Action? options,
+ CancellationToken? ct = null)
+ {
+ return await Utils.CallWithExceptionHandling(
+ () => CreateImplAsync(new ResponseInput { Items = input }, options, ct),
+ "Error creating response.", _logger).ConfigureAwait(false);
+ }
+
+ ///
+ /// Creates a streaming response, returning events as an async enumerable.
+ ///
+ /// A string prompt.
+ /// Cancellation token.
+ /// Async enumerable of streaming events.
+ public IAsyncEnumerable CreateStreamingAsync(string input,
+ CancellationToken ct)
+ {
+ return CreateStreamingAsync(input, options: null, ct);
+ }
+
+ ///
+ /// Creates a streaming response with options.
+ ///
+ public async IAsyncEnumerable CreateStreamingAsync(string input,
+ Action? options,
+ [EnumeratorCancellation] CancellationToken ct)
+ {
+ var enumerable = Utils.CallWithExceptionHandling(
+ () => StreamingImplAsync(new ResponseInput { Text = input }, options, ct),
+ "Error during streaming response.", _logger).ConfigureAwait(false);
+
+ await foreach (var item in enumerable)
+ {
+ yield return item;
+ }
+ }
+
+ ///
+ /// Creates a streaming response from structured input items.
+ ///
+ public IAsyncEnumerable CreateStreamingAsync(List input,
+ CancellationToken ct)
+ {
+ return CreateStreamingAsync(input, options: null, ct);
+ }
+
+ ///
+ /// Creates a streaming response from structured input items with options.
+ ///
+ public async IAsyncEnumerable CreateStreamingAsync(List input,
+ Action? options,
+ [EnumeratorCancellation] CancellationToken ct)
+ {
+ var enumerable = Utils.CallWithExceptionHandling(
+ () => StreamingImplAsync(new ResponseInput { Items = input }, options, ct),
+ "Error during streaming response.", _logger).ConfigureAwait(false);
+
+ await foreach (var item in enumerable)
+ {
+ yield return item;
+ }
+ }
+
+ ///
+ /// Retrieves a stored response by ID.
+ ///
+ public async Task GetAsync(string responseId, CancellationToken? ct = null)
+ {
+ return await Utils.CallWithExceptionHandling(
+ () => GetImplAsync(responseId, ct),
+ "Error retrieving response.", _logger).ConfigureAwait(false);
+ }
+
+ ///
+ /// Deletes a stored response by ID.
+ ///
+ public async Task DeleteAsync(string responseId, CancellationToken? ct = null)
+ {
+ return await Utils.CallWithExceptionHandling(
+ () => DeleteImplAsync(responseId, ct),
+ "Error deleting response.", _logger).ConfigureAwait(false);
+ }
+
+ ///
+ /// Cancels an in-progress response.
+ ///
+ public async Task CancelAsync(string responseId, CancellationToken? ct = null)
+ {
+ return await Utils.CallWithExceptionHandling(
+ () => CancelImplAsync(responseId, ct),
+ "Error cancelling response.", _logger).ConfigureAwait(false);
+ }
+
+ ///
+ /// Retrieves the input items for a stored response.
+ ///
+ public async Task GetInputItemsAsync(string responseId,
+ CancellationToken? ct = null)
+ {
+ return await Utils.CallWithExceptionHandling(
+ () => GetInputItemsImplAsync(responseId, ct),
+ "Error retrieving input items.", _logger).ConfigureAwait(false);
+ }
+
+ // ========================================================================
+ // Implementation methods
+ // ========================================================================
+
+ private async Task CreateImplAsync(ResponseInput input,
+ Action? options,
+ CancellationToken? ct)
+ {
+ var request = BuildRequest(input, stream: false);
+ options?.Invoke(request);
+
+ var json = JsonSerializer.Serialize(request, ResponsesJsonContext.Default.ResponseCreateRequest);
+ using var content = new StringContent(json, Encoding.UTF8, "application/json");
+
+ using var response = await _httpClient.PostAsync($"{_baseUrl}/v1/responses", content,
+ ct ?? CancellationToken.None).ConfigureAwait(false);
+ await EnsureSuccessAsync(response, ct ?? CancellationToken.None).ConfigureAwait(false);
+
+ var body = await response.Content.ReadAsStringAsync(ct ?? CancellationToken.None).ConfigureAwait(false);
+ return JsonSerializer.Deserialize(body, ResponsesJsonContext.Default.ResponseObject)
+ ?? throw new FoundryLocalException($"Failed to deserialize response: {body[..Math.Min(body.Length, 200)]}", _logger);
+ }
+
+ private async IAsyncEnumerable StreamingImplAsync(
+ ResponseInput input,
+ Action? options,
+ [EnumeratorCancellation] CancellationToken ct)
+ {
+ var request = BuildRequest(input, stream: true);
+ options?.Invoke(request);
+ // Ensure streaming stays enabled even if options attempts to override it.
+ request.Stream = true;
+
+ var json = JsonSerializer.Serialize(request, ResponsesJsonContext.Default.ResponseCreateRequest);
+ using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"{_baseUrl}/v1/responses")
+ {
+ Content = new StringContent(json, Encoding.UTF8, "application/json")
+ };
+ httpRequest.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("text/event-stream"));
+
+ HttpResponseMessage? response = null;
+ try
+ {
+ response = await _httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, ct)
+ .ConfigureAwait(false);
+ await EnsureSuccessAsync(response, ct).ConfigureAwait(false);
+
+ var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
+ using var reader = new StreamReader(stream);
+
+ var dataLines = new List();
+
+ while (!reader.EndOfStream && !ct.IsCancellationRequested)
+ {
+ var line = await reader.ReadLineAsync(ct).ConfigureAwait(false);
+
+ if (line == null)
+ {
+ break;
+ }
+
+ // Empty line = end of SSE block
+ if (line.Length == 0)
+ {
+ if (dataLines.Count > 0)
+ {
+ var eventData = string.Join("\n", dataLines);
+ dataLines.Clear();
+
+ // Terminal signal
+ if (eventData == "[DONE]")
+ {
+ yield break;
+ }
+
+ ResponseStreamingEvent? evt;
+ try
+ {
+ evt = JsonSerializer.Deserialize(eventData, ResponsesJsonContext.Default.ResponseStreamingEvent);
+ }
+ catch (JsonException ex)
+ {
+ _logger.LogWarning(ex, "Failed to parse SSE event: {Data}", eventData);
+ continue;
+ }
+
+ if (evt != null)
+ {
+ yield return evt;
+ }
+ }
+
+ continue;
+ }
+
+ // Collect data lines
+ if (line.StartsWith("data: ", StringComparison.Ordinal))
+ {
+ dataLines.Add(line[6..]);
+ }
+ else if (line == "data:")
+ {
+ dataLines.Add(string.Empty);
+ }
+ // 'event:' lines are informational; type is inside the JSON
+ }
+ }
+ finally
+ {
+ response?.Dispose();
+ }
+ }
+
+ private async Task GetImplAsync(string responseId, CancellationToken? ct)
+ {
+ ValidateId(responseId, nameof(responseId));
+ using var response = await _httpClient.GetAsync(
+ $"{_baseUrl}/v1/responses/{Uri.EscapeDataString(responseId)}",
+ ct ?? CancellationToken.None).ConfigureAwait(false);
+ await EnsureSuccessAsync(response, ct ?? CancellationToken.None).ConfigureAwait(false);
+
+ var body = await response.Content.ReadAsStringAsync(ct ?? CancellationToken.None).ConfigureAwait(false);
+ return JsonSerializer.Deserialize(body, ResponsesJsonContext.Default.ResponseObject)
+ ?? throw new FoundryLocalException($"Failed to deserialize response: {body[..Math.Min(body.Length, 200)]}", _logger);
+ }
+
+ private async Task DeleteImplAsync(string responseId, CancellationToken? ct)
+ {
+ ValidateId(responseId, nameof(responseId));
+ using var response = await _httpClient.DeleteAsync(
+ $"{_baseUrl}/v1/responses/{Uri.EscapeDataString(responseId)}",
+ ct ?? CancellationToken.None).ConfigureAwait(false);
+ await EnsureSuccessAsync(response, ct ?? CancellationToken.None).ConfigureAwait(false);
+
+ var body = await response.Content.ReadAsStringAsync(ct ?? CancellationToken.None).ConfigureAwait(false);
+ return JsonSerializer.Deserialize(body, ResponsesJsonContext.Default.ResponseDeleteResult)
+ ?? throw new FoundryLocalException($"Failed to deserialize delete result: {body[..Math.Min(body.Length, 200)]}", _logger);
+ }
+
+ private async Task CancelImplAsync(string responseId, CancellationToken? ct)
+ {
+ ValidateId(responseId, nameof(responseId));
+ using var cancelResponse = await _httpClient.PostAsync(
+ $"{_baseUrl}/v1/responses/{Uri.EscapeDataString(responseId)}/cancel",
+ null, ct ?? CancellationToken.None).ConfigureAwait(false);
+ await EnsureSuccessAsync(cancelResponse, ct ?? CancellationToken.None).ConfigureAwait(false);
+
+ var body = await cancelResponse.Content.ReadAsStringAsync(ct ?? CancellationToken.None).ConfigureAwait(false);
+ return JsonSerializer.Deserialize(body, ResponsesJsonContext.Default.ResponseObject)
+ ?? throw new FoundryLocalException($"Failed to deserialize response: {body[..Math.Min(body.Length, 200)]}", _logger);
+ }
+
+ private async Task GetInputItemsImplAsync(string responseId,
+ CancellationToken? ct)
+ {
+ ValidateId(responseId, nameof(responseId));
+ using var response = await _httpClient.GetAsync(
+ $"{_baseUrl}/v1/responses/{Uri.EscapeDataString(responseId)}/input_items",
+ ct ?? CancellationToken.None).ConfigureAwait(false);
+ await EnsureSuccessAsync(response, ct ?? CancellationToken.None).ConfigureAwait(false);
+
+ var body = await response.Content.ReadAsStringAsync(ct ?? CancellationToken.None).ConfigureAwait(false);
+ return JsonSerializer.Deserialize(body, ResponsesJsonContext.Default.ResponseInputItemsList)
+ ?? throw new FoundryLocalException($"Failed to deserialize input items: {body[..Math.Min(body.Length, 200)]}", _logger);
+ }
+
+ // ========================================================================
+ // Helpers
+ // ========================================================================
+
+ private ResponseCreateRequest BuildRequest(ResponseInput input, bool stream)
+ {
+ var model = _modelId;
+ if (string.IsNullOrWhiteSpace(model))
+ {
+ throw new FoundryLocalException(
+ "Model must be specified either in the constructor or via GetResponsesClient(modelId).", _logger);
+ }
+
+ // Merge order: model+input → settings defaults → per-call overrides (via Action)
+ return new ResponseCreateRequest
+ {
+ Model = model,
+ Input = input,
+ Stream = stream,
+ Instructions = Settings.Instructions,
+ Temperature = Settings.Temperature,
+ TopP = Settings.TopP,
+ MaxOutputTokens = Settings.MaxOutputTokens,
+ FrequencyPenalty = Settings.FrequencyPenalty,
+ PresencePenalty = Settings.PresencePenalty,
+ ToolChoice = Settings.ToolChoice,
+ Truncation = Settings.Truncation,
+ ParallelToolCalls = Settings.ParallelToolCalls,
+ Store = Settings.Store,
+ Metadata = Settings.Metadata,
+ Reasoning = Settings.Reasoning,
+ Text = Settings.Text,
+ Seed = Settings.Seed,
+ };
+ }
+
+ private static void ValidateId(string id, string paramName)
+ {
+ if (string.IsNullOrWhiteSpace(id))
+ {
+ throw new ArgumentException($"{paramName} must be a non-empty string.", paramName);
+ }
+
+ if (id.Length > 1024)
+ {
+ throw new ArgumentException($"{paramName} exceeds maximum length (1024).", paramName);
+ }
+ }
+
+ private async Task EnsureSuccessAsync(HttpResponseMessage response, CancellationToken ct = default)
+ {
+ if (!response.IsSuccessStatusCode)
+ {
+ var errorBody = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
+ throw new FoundryLocalException(
+ $"Responses API error ({(int)response.StatusCode}): {errorBody}", _logger);
+ }
+ }
+
+ protected virtual void Dispose(bool disposing)
+ {
+ if (!_disposed)
+ {
+ if (disposing)
+ {
+ _httpClient.Dispose();
+ }
+
+ _disposed = true;
+ }
+ }
+
+ public void Dispose()
+ {
+ Dispose(disposing: true);
+ GC.SuppressFinalize(this);
+ }
+}
+
+///
+/// Settings for the Responses API.
+///
+public record ResponsesSettings
+{
+ /// System-level instructions to guide the model.
+ public string? Instructions { get; set; }
+ public float? Temperature { get; set; }
+ public float? TopP { get; set; }
+ public int? MaxOutputTokens { get; set; }
+ public float? FrequencyPenalty { get; set; }
+ public float? PresencePenalty { get; set; }
+ public ResponseToolChoice? ToolChoice { get; set; }
+ public string? Truncation { get; set; }
+ public bool? ParallelToolCalls { get; set; }
+ public bool? Store { get; set; }
+ public Dictionary? Metadata { get; set; }
+ public ResponseReasoningConfig? Reasoning { get; set; }
+ public ResponseTextConfig? Text { get; set; }
+ public int? Seed { get; set; }
+}
diff --git a/sdk/cs/src/OpenAI/ResponsesJsonContext.cs b/sdk/cs/src/OpenAI/ResponsesJsonContext.cs
new file mode 100644
index 00000000..bc890ef8
--- /dev/null
+++ b/sdk/cs/src/OpenAI/ResponsesJsonContext.cs
@@ -0,0 +1,44 @@
+// --------------------------------------------------------------------------------------------------------------------
+//
+// Copyright (c) Microsoft. All rights reserved.
+//
+// --------------------------------------------------------------------------------------------------------------------
+
+namespace Microsoft.AI.Foundry.Local.OpenAI;
+
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+[JsonSerializable(typeof(ResponseCreateRequest))]
+[JsonSerializable(typeof(ResponseObject))]
+[JsonSerializable(typeof(ResponseDeleteResult))]
+[JsonSerializable(typeof(ResponseInputItemsList))]
+[JsonSerializable(typeof(ResponseStreamingEvent))]
+[JsonSerializable(typeof(ResponseItem))]
+[JsonSerializable(typeof(List))]
+[JsonSerializable(typeof(ResponseMessageItem))]
+[JsonSerializable(typeof(ResponseFunctionCallItem))]
+[JsonSerializable(typeof(ResponseFunctionCallOutputItem))]
+[JsonSerializable(typeof(ResponseContentPart))]
+[JsonSerializable(typeof(List))]
+[JsonSerializable(typeof(ResponseInputTextContent))]
+[JsonSerializable(typeof(ResponseOutputTextContent))]
+[JsonSerializable(typeof(ResponseRefusalContent))]
+[JsonSerializable(typeof(ResponseFunctionTool))]
+[JsonSerializable(typeof(List))]
+[JsonSerializable(typeof(ResponseSpecificToolChoice))]
+[JsonSerializable(typeof(ResponseUsage))]
+[JsonSerializable(typeof(ResponseError))]
+[JsonSerializable(typeof(ResponseIncompleteDetails))]
+[JsonSerializable(typeof(ResponseReasoningConfig))]
+[JsonSerializable(typeof(ResponseTextConfig))]
+[JsonSerializable(typeof(ResponseTextFormat))]
+[JsonSerializable(typeof(ResponseMessageContent))]
+[JsonSerializable(typeof(ResponseInput))]
+[JsonSerializable(typeof(ResponseToolChoice))]
+[JsonSerializable(typeof(JsonElement))]
+[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ WriteIndented = false)]
+internal partial class ResponsesJsonContext : JsonSerializerContext
+{
+}
diff --git a/sdk/cs/src/OpenAI/ResponsesTypes.cs b/sdk/cs/src/OpenAI/ResponsesTypes.cs
new file mode 100644
index 00000000..937cff2a
--- /dev/null
+++ b/sdk/cs/src/OpenAI/ResponsesTypes.cs
@@ -0,0 +1,628 @@
+// --------------------------------------------------------------------------------------------------------------------
+//
+// Copyright (c) Microsoft. All rights reserved.
+//
+// --------------------------------------------------------------------------------------------------------------------
+
+namespace Microsoft.AI.Foundry.Local.OpenAI;
+
+using System.Collections.Generic;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+// ============================================================================
+// Responses API Types
+// Aligned with OpenAI Responses API / OpenResponses spec.
+// ============================================================================
+
+#region Request Types
+
+///
+/// Request body for POST /v1/responses.
+///
+public class ResponseCreateRequest
+{
+ [JsonPropertyName("model")]
+ public required string Model { get; set; }
+
+ [JsonPropertyName("input")]
+ public ResponseInput? Input { get; set; }
+
+ [JsonPropertyName("instructions")]
+ public string? Instructions { get; set; }
+
+ [JsonPropertyName("previous_response_id")]
+ public string? PreviousResponseId { get; set; }
+
+ [JsonPropertyName("tools")]
+ public List? Tools { get; set; }
+
+ [JsonPropertyName("tool_choice")]
+ [JsonConverter(typeof(ResponseToolChoiceConverter))]
+ public ResponseToolChoice? ToolChoice { get; set; }
+
+ [JsonPropertyName("temperature")]
+ public float? Temperature { get; set; }
+
+ [JsonPropertyName("top_p")]
+ public float? TopP { get; set; }
+
+ [JsonPropertyName("max_output_tokens")]
+ public int? MaxOutputTokens { get; set; }
+
+ [JsonPropertyName("frequency_penalty")]
+ public float? FrequencyPenalty { get; set; }
+
+ [JsonPropertyName("presence_penalty")]
+ public float? PresencePenalty { get; set; }
+
+ [JsonPropertyName("truncation")]
+ public string? Truncation { get; set; }
+
+ [JsonPropertyName("parallel_tool_calls")]
+ public bool? ParallelToolCalls { get; set; }
+
+ [JsonPropertyName("store")]
+ public bool? Store { get; set; }
+
+ [JsonPropertyName("metadata")]
+ public Dictionary? Metadata { get; set; }
+
+ [JsonPropertyName("stream")]
+ public bool? Stream { get; set; }
+
+ [JsonPropertyName("reasoning")]
+ public ResponseReasoningConfig? Reasoning { get; set; }
+
+ [JsonPropertyName("text")]
+ public ResponseTextConfig? Text { get; set; }
+
+ [JsonPropertyName("seed")]
+ public int? Seed { get; set; }
+
+ [JsonPropertyName("user")]
+ public string? User { get; set; }
+}
+
+///
+/// Union type for input: either a plain string or an array of items.
+///
+[JsonConverter(typeof(ResponseInputConverter))]
+public class ResponseInput
+{
+ public string? Text { get; set; }
+ public List? Items { get; set; }
+
+ public static implicit operator ResponseInput(string text) => new() { Text = text };
+ public static implicit operator ResponseInput(List items) => new() { Items = items };
+}
+
+#endregion
+
+#region Response Object
+
+///
+/// The Response object returned by the Responses API.
+///
+public class ResponseObject
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = string.Empty;
+
+ [JsonPropertyName("object")]
+ public string ObjectType { get; set; } = "response";
+
+ [JsonPropertyName("created_at")]
+ public long CreatedAt { get; set; }
+
+ [JsonPropertyName("completed_at")]
+ public long? CompletedAt { get; set; }
+
+ [JsonPropertyName("failed_at")]
+ public long? FailedAt { get; set; }
+
+ [JsonPropertyName("cancelled_at")]
+ public long? CancelledAt { get; set; }
+
+ [JsonPropertyName("status")]
+ public string Status { get; set; } = string.Empty;
+
+ [JsonPropertyName("incomplete_details")]
+ public ResponseIncompleteDetails? IncompleteDetails { get; set; }
+
+ [JsonPropertyName("model")]
+ public string Model { get; set; } = string.Empty;
+
+ [JsonPropertyName("previous_response_id")]
+ public string? PreviousResponseId { get; set; }
+
+ [JsonPropertyName("instructions")]
+ public string? Instructions { get; set; }
+
+ [JsonPropertyName("output")]
+ public List Output { get; set; } = [];
+
+ [JsonPropertyName("error")]
+ public ResponseError? Error { get; set; }
+
+ [JsonPropertyName("tools")]
+ public List Tools { get; set; } = [];
+
+ [JsonPropertyName("tool_choice")]
+ [JsonConverter(typeof(ResponseToolChoiceConverter))]
+ public ResponseToolChoice? ToolChoice { get; set; }
+
+ [JsonPropertyName("truncation")]
+ public string? Truncation { get; set; }
+
+ [JsonPropertyName("parallel_tool_calls")]
+ public bool ParallelToolCalls { get; set; }
+
+ [JsonPropertyName("text")]
+ public ResponseTextConfig? Text { get; set; }
+
+ [JsonPropertyName("top_p")]
+ public float TopP { get; set; }
+
+ [JsonPropertyName("temperature")]
+ public float Temperature { get; set; }
+
+ [JsonPropertyName("presence_penalty")]
+ public float PresencePenalty { get; set; }
+
+ [JsonPropertyName("frequency_penalty")]
+ public float FrequencyPenalty { get; set; }
+
+ [JsonPropertyName("max_output_tokens")]
+ public int? MaxOutputTokens { get; set; }
+
+ [JsonPropertyName("reasoning")]
+ public ResponseReasoningConfig? Reasoning { get; set; }
+
+ [JsonPropertyName("store")]
+ public bool Store { get; set; }
+
+ [JsonPropertyName("metadata")]
+ public Dictionary? Metadata { get; set; }
+
+ [JsonPropertyName("usage")]
+ public ResponseUsage? Usage { get; set; }
+
+ [JsonPropertyName("user")]
+ public string? User { get; set; }
+
+ ///
+ /// Extracts the text from the first assistant message in the output.
+ /// Equivalent to OpenAI Python SDK's response.output_text.
+ ///
+ [JsonIgnore]
+ public string OutputText
+ {
+ get
+ {
+ foreach (var item in Output)
+ {
+ if (item is ResponseMessageItem msg && msg.Role == "assistant")
+ {
+ return msg.GetText();
+ }
+ }
+ return string.Empty;
+ }
+ }
+}
+
+#endregion
+
+#region Items (input & output)
+
+///
+/// Base class for all response items using polymorphic serialization.
+///
+[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]
+[JsonDerivedType(typeof(ResponseMessageItem), "message")]
+[JsonDerivedType(typeof(ResponseFunctionCallItem), "function_call")]
+[JsonDerivedType(typeof(ResponseFunctionCallOutputItem), "function_call_output")]
+public class ResponseItem
+{
+ [JsonPropertyName("id")]
+ public string? Id { get; set; }
+
+ [JsonPropertyName("status")]
+ public string? Status { get; set; }
+}
+
+public sealed class ResponseMessageItem : ResponseItem
+{
+ [JsonPropertyName("role")]
+ public string Role { get; set; } = string.Empty;
+
+ [JsonPropertyName("content")]
+ [JsonConverter(typeof(MessageContentConverter))]
+ public ResponseMessageContent Content { get; set; } = new();
+
+ public string GetText()
+ {
+ if (Content.Text != null)
+ {
+ return Content.Text;
+ }
+
+ if (Content.Parts != null)
+ {
+ return string.Concat(Content.Parts
+ .Where(p => p is ResponseOutputTextContent)
+ .Cast()
+ .Select(p => p.Text));
+ }
+
+ return string.Empty;
+ }
+}
+
+public sealed class ResponseFunctionCallItem : ResponseItem
+{
+ [JsonPropertyName("call_id")]
+ public string CallId { get; set; } = string.Empty;
+
+ [JsonPropertyName("name")]
+ public string Name { get; set; } = string.Empty;
+
+ [JsonPropertyName("arguments")]
+ public string Arguments { get; set; } = string.Empty;
+}
+
+public sealed class ResponseFunctionCallOutputItem : ResponseItem
+{
+ [JsonPropertyName("call_id")]
+ public string CallId { get; set; } = string.Empty;
+
+ [JsonPropertyName("output")]
+ public string Output { get; set; } = string.Empty;
+}
+
+#endregion
+
+#region Content Parts
+
+[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]
+[JsonDerivedType(typeof(ResponseInputTextContent), "input_text")]
+[JsonDerivedType(typeof(ResponseOutputTextContent), "output_text")]
+[JsonDerivedType(typeof(ResponseRefusalContent), "refusal")]
+public class ResponseContentPart
+{
+}
+
+public sealed class ResponseInputTextContent : ResponseContentPart
+{
+ [JsonPropertyName("text")]
+ public string Text { get; set; } = string.Empty;
+}
+
+public sealed class ResponseOutputTextContent : ResponseContentPart
+{
+ [JsonPropertyName("text")]
+ public string Text { get; set; } = string.Empty;
+
+ [JsonPropertyName("annotations")]
+ public List? Annotations { get; set; }
+}
+
+public sealed class ResponseRefusalContent : ResponseContentPart
+{
+ [JsonPropertyName("refusal")]
+ public string Refusal { get; set; } = string.Empty;
+}
+
+///
+/// Union type for message content: either a plain string or an array of content parts.
+///
+[JsonConverter(typeof(MessageContentConverter))]
+public class ResponseMessageContent
+{
+ public string? Text { get; set; }
+ public List? Parts { get; set; }
+}
+
+#endregion
+
+#region Tool Types
+
+public class ResponseFunctionTool
+{
+ [JsonPropertyName("type")]
+ public string Type { get; set; } = "function";
+
+ [JsonPropertyName("name")]
+ public required string Name { get; set; }
+
+ [JsonPropertyName("description")]
+ public string? Description { get; set; }
+
+ [JsonPropertyName("parameters")]
+ public JsonElement? Parameters { get; set; }
+
+ [JsonPropertyName("strict")]
+ public bool? Strict { get; set; }
+}
+
+///
+/// Tool choice: either a string value ("none", "auto", "required") or a specific function reference.
+///
+public class ResponseToolChoice
+{
+ public string? Value { get; set; }
+ public ResponseSpecificToolChoice? Specific { get; set; }
+
+ public static implicit operator ResponseToolChoice(string value) => new() { Value = value };
+
+ public static readonly ResponseToolChoice Auto = new() { Value = "auto" };
+ public static readonly ResponseToolChoice None = new() { Value = "none" };
+ public static readonly ResponseToolChoice Required = new() { Value = "required" };
+}
+
+public class ResponseSpecificToolChoice
+{
+ [JsonPropertyName("type")]
+ public string Type { get; set; } = "function";
+
+ [JsonPropertyName("name")]
+ public required string Name { get; set; }
+}
+
+#endregion
+
+#region Supporting Types
+
+public class ResponseUsage
+{
+ [JsonPropertyName("input_tokens")]
+ public int InputTokens { get; set; }
+
+ [JsonPropertyName("output_tokens")]
+ public int OutputTokens { get; set; }
+
+ [JsonPropertyName("total_tokens")]
+ public int TotalTokens { get; set; }
+}
+
+public class ResponseError
+{
+ [JsonPropertyName("code")]
+ public string Code { get; set; } = string.Empty;
+
+ [JsonPropertyName("message")]
+ public string Message { get; set; } = string.Empty;
+}
+
+public class ResponseIncompleteDetails
+{
+ [JsonPropertyName("reason")]
+ public string Reason { get; set; } = string.Empty;
+}
+
+public class ResponseReasoningConfig
+{
+ [JsonPropertyName("effort")]
+ public string? Effort { get; set; }
+
+ [JsonPropertyName("summary")]
+ public string? Summary { get; set; }
+}
+
+public class ResponseTextConfig
+{
+ [JsonPropertyName("format")]
+ public ResponseTextFormat? Format { get; set; }
+
+ [JsonPropertyName("verbosity")]
+ public string? Verbosity { get; set; }
+}
+
+public class ResponseTextFormat
+{
+ [JsonPropertyName("type")]
+ public string Type { get; set; } = "text";
+
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ [JsonPropertyName("schema")]
+ public JsonElement? Schema { get; set; }
+
+ [JsonPropertyName("strict")]
+ public bool? Strict { get; set; }
+}
+
+public class ResponseDeleteResult
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = string.Empty;
+
+ [JsonPropertyName("object")]
+ public string ObjectType { get; set; } = string.Empty;
+
+ [JsonPropertyName("deleted")]
+ public bool Deleted { get; set; }
+}
+
+public class ResponseInputItemsList
+{
+ [JsonPropertyName("object")]
+ public string ObjectType { get; set; } = "list";
+
+ [JsonPropertyName("data")]
+ public List Data { get; set; } = [];
+}
+
+#endregion
+
+#region Streaming Events
+
+///
+/// A streaming event from the Responses API SSE stream.
+///
+public class ResponseStreamingEvent
+{
+ [JsonPropertyName("type")]
+ public string Type { get; set; } = string.Empty;
+
+ [JsonPropertyName("sequence_number")]
+ public int SequenceNumber { get; set; }
+
+ // Lifecycle events carry the full response
+ [JsonPropertyName("response")]
+ public ResponseObject? Response { get; set; }
+
+ // Item events
+ [JsonPropertyName("item_id")]
+ public string? ItemId { get; set; }
+
+ [JsonPropertyName("output_index")]
+ public int? OutputIndex { get; set; }
+
+ [JsonPropertyName("content_index")]
+ public int? ContentIndex { get; set; }
+
+ [JsonPropertyName("item")]
+ public ResponseItem? Item { get; set; }
+
+ [JsonPropertyName("part")]
+ public ResponseContentPart? Part { get; set; }
+
+ // Text delta/done
+ [JsonPropertyName("delta")]
+ public string? Delta { get; set; }
+
+ [JsonPropertyName("text")]
+ public string? Text { get; set; }
+
+ // Function call args
+ [JsonPropertyName("arguments")]
+ public string? Arguments { get; set; }
+
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ // Refusal
+ [JsonPropertyName("refusal")]
+ public string? Refusal { get; set; }
+
+ // Error
+ [JsonPropertyName("code")]
+ public string? Code { get; set; }
+
+ [JsonPropertyName("message")]
+ public string? Message { get; set; }
+}
+
+#endregion
+
+#region JSON Converters
+
+internal class ResponseInputConverter : JsonConverter
+{
+ public override ResponseInput? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.TokenType == JsonTokenType.String)
+ {
+ return new ResponseInput { Text = reader.GetString() };
+ }
+
+ if (reader.TokenType == JsonTokenType.StartArray)
+ {
+ var items = JsonSerializer.Deserialize(ref reader, ResponsesJsonContext.Default.ListResponseItem);
+ return new ResponseInput { Items = items };
+ }
+
+ return null;
+ }
+
+ public override void Write(Utf8JsonWriter writer, ResponseInput value, JsonSerializerOptions options)
+ {
+ if (value.Text != null)
+ {
+ writer.WriteStringValue(value.Text);
+ }
+ else if (value.Items != null)
+ {
+ JsonSerializer.Serialize(writer, value.Items, ResponsesJsonContext.Default.ListResponseItem);
+ }
+ else
+ {
+ writer.WriteNullValue();
+ }
+ }
+}
+
+internal class ResponseToolChoiceConverter : JsonConverter
+{
+ public override ResponseToolChoice? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.TokenType == JsonTokenType.String)
+ {
+ return new ResponseToolChoice { Value = reader.GetString() };
+ }
+
+ if (reader.TokenType == JsonTokenType.StartObject)
+ {
+ var specific = JsonSerializer.Deserialize(ref reader, ResponsesJsonContext.Default.ResponseSpecificToolChoice);
+ return new ResponseToolChoice { Specific = specific };
+ }
+
+ return null;
+ }
+
+ public override void Write(Utf8JsonWriter writer, ResponseToolChoice value, JsonSerializerOptions options)
+ {
+ if (value.Value != null)
+ {
+ writer.WriteStringValue(value.Value);
+ }
+ else if (value.Specific != null)
+ {
+ JsonSerializer.Serialize(writer, value.Specific, ResponsesJsonContext.Default.ResponseSpecificToolChoice);
+ }
+ else
+ {
+ writer.WriteNullValue();
+ }
+ }
+}
+
+internal class MessageContentConverter : JsonConverter
+{
+ public override ResponseMessageContent? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.TokenType == JsonTokenType.String)
+ {
+ return new ResponseMessageContent { Text = reader.GetString() };
+ }
+
+ if (reader.TokenType == JsonTokenType.StartArray)
+ {
+ var parts = JsonSerializer.Deserialize(ref reader, ResponsesJsonContext.Default.ListResponseContentPart);
+ return new ResponseMessageContent { Parts = parts };
+ }
+
+ return null;
+ }
+
+ public override void Write(Utf8JsonWriter writer, ResponseMessageContent value, JsonSerializerOptions options)
+ {
+ if (value.Text != null)
+ {
+ writer.WriteStringValue(value.Text);
+ }
+ else if (value.Parts != null)
+ {
+ JsonSerializer.Serialize(writer, value.Parts, ResponsesJsonContext.Default.ListResponseContentPart);
+ }
+ else
+ {
+ writer.WriteNullValue();
+ }
+ }
+}
+
+#endregion
diff --git a/sdk/cs/test/FoundryLocal.Tests/ResponsesTypesTests.cs b/sdk/cs/test/FoundryLocal.Tests/ResponsesTypesTests.cs
new file mode 100644
index 00000000..6aaac1ca
--- /dev/null
+++ b/sdk/cs/test/FoundryLocal.Tests/ResponsesTypesTests.cs
@@ -0,0 +1,514 @@
+// --------------------------------------------------------------------------------------------------------------------
+//
+// Copyright (c) Microsoft. All rights reserved.
+//
+// --------------------------------------------------------------------------------------------------------------------
+
+namespace Microsoft.AI.Foundry.Local.Tests;
+
+using System.Text.Json;
+
+using Microsoft.AI.Foundry.Local.OpenAI;
+
+///
+/// Unit tests for Responses API types, JSON converters, and serialization.
+/// These tests exercise the DTOs and custom converters without requiring
+/// the Foundry Local runtime.
+///
+internal sealed class ResponsesTypesTests
+{
+ // ========================================================================
+ // ResponseInput serialization (string vs array)
+ // ========================================================================
+
+ [Test]
+ public async Task ResponseInput_StringInput_SerializesAsString()
+ {
+ var input = new ResponseInput { Text = "Hello, world!" };
+ var json = JsonSerializer.Serialize(input, ResponsesJsonContext.Default.ResponseInput);
+
+ await Assert.That(json).IsEqualTo("\"Hello, world!\"");
+ }
+
+ [Test]
+ public async Task ResponseInput_StringInput_DeserializesFromString()
+ {
+ var json = "\"What is the capital of France?\"";
+ var input = JsonSerializer.Deserialize(json, ResponsesJsonContext.Default.ResponseInput);
+
+ await Assert.That(input).IsNotNull();
+ await Assert.That(input!.Text).IsEqualTo("What is the capital of France?");
+ await Assert.That(input.Items).IsNull();
+ }
+
+ [Test]
+ public async Task ResponseInput_ItemsInput_SerializesAsArray()
+ {
+ var input = new ResponseInput
+ {
+ Items =
+ [
+ new ResponseMessageItem
+ {
+ Role = "user",
+ Content = new ResponseMessageContent { Text = "Hi" }
+ }
+ ]
+ };
+
+ var json = JsonSerializer.Serialize(input, ResponsesJsonContext.Default.ResponseInput);
+ await Assert.That(json).Contains("\"type\":\"message\"");
+ await Assert.That(json).Contains("\"role\":\"user\"");
+ }
+
+ [Test]
+ public async Task ResponseInput_ItemsInput_DeserializesFromArray()
+ {
+ var json = """[{"type":"message","role":"user","content":"Hi there"}]""";
+ var input = JsonSerializer.Deserialize(json, ResponsesJsonContext.Default.ResponseInput);
+
+ await Assert.That(input).IsNotNull();
+ await Assert.That(input!.Text).IsNull();
+ await Assert.That(input.Items).IsNotNull().And.HasCount().EqualTo(1);
+
+ var msg = input.Items![0] as ResponseMessageItem;
+ await Assert.That(msg).IsNotNull();
+ await Assert.That(msg!.Role).IsEqualTo("user");
+ await Assert.That(msg.Content.Text).IsEqualTo("Hi there");
+ }
+
+ [Test]
+ public async Task ResponseInput_ImplicitConversionFromString()
+ {
+ ResponseInput input = "Hello";
+ await Assert.That(input.Text).IsEqualTo("Hello");
+ await Assert.That(input.Items).IsNull();
+ }
+
+ // ========================================================================
+ // ResponseToolChoice serialization (string vs object)
+ // These are tested via ResponseCreateRequest since the converter is
+ // applied at property level, not on the type directly.
+ // ========================================================================
+
+ [Test]
+ public async Task ToolChoice_StringValue_SerializesViaRequest()
+ {
+ var request = new ResponseCreateRequest
+ {
+ Model = "phi-4",
+ ToolChoice = ResponseToolChoice.Auto
+ };
+
+ var json = JsonSerializer.Serialize(request, ResponsesJsonContext.Default.ResponseCreateRequest);
+ await Assert.That(json).Contains("\"tool_choice\":\"auto\"");
+ }
+
+ [Test]
+ public async Task ToolChoice_StringValue_DeserializesViaResponse()
+ {
+ var json = """{"id":"r1","object":"response","status":"completed","model":"phi-4","output":[],"tools":[],"tool_choice":"required","parallel_tool_calls":false,"top_p":1,"temperature":0.7,"presence_penalty":0,"frequency_penalty":0,"store":false,"created_at":0}""";
+ var response = JsonSerializer.Deserialize(json, ResponsesJsonContext.Default.ResponseObject);
+
+ await Assert.That(response).IsNotNull();
+ await Assert.That(response!.ToolChoice).IsNotNull();
+ await Assert.That(response.ToolChoice!.Value).IsEqualTo("required");
+ await Assert.That(response.ToolChoice.Specific).IsNull();
+ }
+
+ [Test]
+ public async Task ToolChoice_SpecificFunction_SerializesViaRequest()
+ {
+ var request = new ResponseCreateRequest
+ {
+ Model = "phi-4",
+ ToolChoice = new ResponseToolChoice
+ {
+ Specific = new ResponseSpecificToolChoice { Name = "get_weather" }
+ }
+ };
+
+ var json = JsonSerializer.Serialize(request, ResponsesJsonContext.Default.ResponseCreateRequest);
+ await Assert.That(json).Contains("\"type\":\"function\"");
+ await Assert.That(json).Contains("\"name\":\"get_weather\"");
+ }
+
+ [Test]
+ public async Task ToolChoice_SpecificFunction_DeserializesViaResponse()
+ {
+ var json = """{"id":"r1","object":"response","status":"completed","model":"phi-4","output":[],"tools":[],"tool_choice":{"type":"function","name":"get_weather"},"parallel_tool_calls":false,"top_p":1,"temperature":0.7,"presence_penalty":0,"frequency_penalty":0,"store":false,"created_at":0}""";
+ var response = JsonSerializer.Deserialize(json, ResponsesJsonContext.Default.ResponseObject);
+
+ await Assert.That(response).IsNotNull();
+ await Assert.That(response!.ToolChoice).IsNotNull();
+ await Assert.That(response.ToolChoice!.Value).IsNull();
+ await Assert.That(response.ToolChoice.Specific).IsNotNull();
+ await Assert.That(response.ToolChoice.Specific!.Name).IsEqualTo("get_weather");
+ }
+
+ [Test]
+ public async Task ToolChoice_ImplicitConversionFromString()
+ {
+ ResponseToolChoice choice = "none";
+ await Assert.That(choice.Value).IsEqualTo("none");
+ }
+
+ // ========================================================================
+ // ResponseMessageContent serialization (string vs parts array)
+ // ========================================================================
+
+ [Test]
+ public async Task MessageContent_String_SerializesAsString()
+ {
+ var content = new ResponseMessageContent { Text = "Hello" };
+ var json = JsonSerializer.Serialize(content, ResponsesJsonContext.Default.ResponseMessageContent);
+
+ await Assert.That(json).IsEqualTo("\"Hello\"");
+ }
+
+ [Test]
+ public async Task MessageContent_String_DeserializesFromString()
+ {
+ var json = "\"Hello, world!\"";
+ var content = JsonSerializer.Deserialize(json, ResponsesJsonContext.Default.ResponseMessageContent);
+
+ await Assert.That(content).IsNotNull();
+ await Assert.That(content!.Text).IsEqualTo("Hello, world!");
+ await Assert.That(content.Parts).IsNull();
+ }
+
+ [Test]
+ public async Task MessageContent_Parts_DeserializesFromArray()
+ {
+ var json = """[{"type":"output_text","text":"Generated text"}]""";
+ var content = JsonSerializer.Deserialize(json, ResponsesJsonContext.Default.ResponseMessageContent);
+
+ await Assert.That(content).IsNotNull();
+ await Assert.That(content!.Text).IsNull();
+ await Assert.That(content.Parts).IsNotNull().And.HasCount().EqualTo(1);
+
+ var textPart = content.Parts![0] as ResponseOutputTextContent;
+ await Assert.That(textPart).IsNotNull();
+ await Assert.That(textPart!.Text).IsEqualTo("Generated text");
+ }
+
+ // ========================================================================
+ // ResponseObject.OutputText property
+ // ========================================================================
+
+ [Test]
+ public async Task OutputText_ReturnsAssistantMessageText()
+ {
+ var response = new ResponseObject
+ {
+ Output =
+ [
+ new ResponseMessageItem
+ {
+ Role = "assistant",
+ Content = new ResponseMessageContent { Text = "The answer is 42." }
+ }
+ ]
+ };
+
+ await Assert.That(response.OutputText).IsEqualTo("The answer is 42.");
+ }
+
+ [Test]
+ public async Task OutputText_ReturnsEmptyWhenNoAssistantMessage()
+ {
+ var response = new ResponseObject
+ {
+ Output =
+ [
+ new ResponseMessageItem
+ {
+ Role = "user",
+ Content = new ResponseMessageContent { Text = "A question" }
+ }
+ ]
+ };
+
+ await Assert.That(response.OutputText).IsEqualTo(string.Empty);
+ }
+
+ [Test]
+ public async Task OutputText_ReturnsEmptyWhenOutputIsEmpty()
+ {
+ var response = new ResponseObject { Output = [] };
+ await Assert.That(response.OutputText).IsEqualTo(string.Empty);
+ }
+
+ [Test]
+ public async Task OutputText_ConcatenatesTextParts()
+ {
+ var response = new ResponseObject
+ {
+ Output =
+ [
+ new ResponseMessageItem
+ {
+ Role = "assistant",
+ Content = new ResponseMessageContent
+ {
+ Parts =
+ [
+ new ResponseOutputTextContent { Text = "Part one. " },
+ new ResponseOutputTextContent { Text = "Part two." }
+ ]
+ }
+ }
+ ]
+ };
+
+ await Assert.That(response.OutputText).IsEqualTo("Part one. Part two.");
+ }
+
+ [Test]
+ public async Task OutputText_IgnoresRefusalParts()
+ {
+ var response = new ResponseObject
+ {
+ Output =
+ [
+ new ResponseMessageItem
+ {
+ Role = "assistant",
+ Content = new ResponseMessageContent
+ {
+ Parts =
+ [
+ new ResponseRefusalContent { Refusal = "I can't do that" },
+ new ResponseOutputTextContent { Text = "But here is something else." }
+ ]
+ }
+ }
+ ]
+ };
+
+ await Assert.That(response.OutputText).IsEqualTo("But here is something else.");
+ }
+
+ // ========================================================================
+ // ResponseItem polymorphic serialization
+ // ========================================================================
+
+ [Test]
+ public async Task ResponseItem_MessageItem_RoundTrips()
+ {
+ var item = new ResponseMessageItem
+ {
+ Id = "msg_001",
+ Role = "assistant",
+ Status = "completed",
+ Content = new ResponseMessageContent { Text = "Hello" }
+ };
+
+ var json = JsonSerializer.Serialize(item, ResponsesJsonContext.Default.ResponseItem);
+ await Assert.That(json).Contains("\"type\":\"message\"");
+
+ var deserialized = JsonSerializer.Deserialize(json, ResponsesJsonContext.Default.ResponseItem);
+ await Assert.That(deserialized).IsTypeOf();
+ var msg = (ResponseMessageItem)deserialized!;
+ await Assert.That(msg.Id).IsEqualTo("msg_001");
+ await Assert.That(msg.Role).IsEqualTo("assistant");
+ }
+
+ [Test]
+ public async Task ResponseItem_FunctionCall_RoundTrips()
+ {
+ var item = new ResponseFunctionCallItem
+ {
+ Id = "fc_001",
+ CallId = "call_123",
+ Name = "get_weather",
+ Arguments = """{"city":"Seattle"}"""
+ };
+
+ var json = JsonSerializer.Serialize(item, ResponsesJsonContext.Default.ResponseItem);
+ await Assert.That(json).Contains("\"type\":\"function_call\"");
+ await Assert.That(json).Contains("\"call_id\":\"call_123\"");
+
+ var deserialized = JsonSerializer.Deserialize(json, ResponsesJsonContext.Default.ResponseItem);
+ await Assert.That(deserialized).IsTypeOf();
+ var fc = (ResponseFunctionCallItem)deserialized!;
+ await Assert.That(fc.Name).IsEqualTo("get_weather");
+ }
+
+ [Test]
+ public async Task ResponseItem_FunctionCallOutput_RoundTrips()
+ {
+ var item = new ResponseFunctionCallOutputItem
+ {
+ CallId = "call_123",
+ Output = """{"temp":72}"""
+ };
+
+ var json = JsonSerializer.Serialize(item, ResponsesJsonContext.Default.ResponseItem);
+ await Assert.That(json).Contains("\"type\":\"function_call_output\"");
+
+ var deserialized = JsonSerializer.Deserialize(json, ResponsesJsonContext.Default.ResponseItem);
+ await Assert.That(deserialized).IsTypeOf();
+ }
+
+ // ========================================================================
+ // ResponseCreateRequest serialization
+ // ========================================================================
+
+ [Test]
+ public async Task CreateRequest_OmitsNullFields()
+ {
+ var request = new ResponseCreateRequest
+ {
+ Model = "phi-4",
+ Input = new ResponseInput { Text = "Hello" },
+ Stream = false
+ };
+
+ var json = JsonSerializer.Serialize(request, ResponsesJsonContext.Default.ResponseCreateRequest);
+
+ await Assert.That(json).Contains("\"model\":\"phi-4\"");
+ await Assert.That(json).Contains("\"input\":\"Hello\"");
+ await Assert.That(json).DoesNotContain("\"instructions\"");
+ await Assert.That(json).DoesNotContain("\"tools\"");
+ await Assert.That(json).DoesNotContain("\"temperature\"");
+ }
+
+ [Test]
+ public async Task CreateRequest_WithTools_Serializes()
+ {
+ var request = new ResponseCreateRequest
+ {
+ Model = "phi-4",
+ Input = new ResponseInput { Text = "What's the weather?" },
+ Tools =
+ [
+ new ResponseFunctionTool
+ {
+ Name = "get_weather",
+ Description = "Get weather for a city",
+ Strict = true
+ }
+ ],
+ ToolChoice = ResponseToolChoice.Auto
+ };
+
+ var json = JsonSerializer.Serialize(request, ResponsesJsonContext.Default.ResponseCreateRequest);
+
+ await Assert.That(json).Contains("\"name\":\"get_weather\"");
+ await Assert.That(json).Contains("\"tool_choice\":\"auto\"");
+ }
+
+ // ========================================================================
+ // Streaming event deserialization
+ // ========================================================================
+
+ [Test]
+ public async Task StreamingEvent_TextDelta_Deserializes()
+ {
+ var json = """{"type":"response.output_text.delta","sequence_number":5,"delta":"Hello","output_index":0,"content_index":0}""";
+ var evt = JsonSerializer.Deserialize(json, ResponsesJsonContext.Default.ResponseStreamingEvent);
+
+ await Assert.That(evt).IsNotNull();
+ await Assert.That(evt!.Type).IsEqualTo("response.output_text.delta");
+ await Assert.That(evt.Delta).IsEqualTo("Hello");
+ await Assert.That(evt.SequenceNumber).IsEqualTo(5);
+ }
+
+ [Test]
+ public async Task StreamingEvent_ResponseCompleted_Deserializes()
+ {
+ var json = """{"type":"response.completed","sequence_number":10,"response":{"id":"resp_001","object":"response","status":"completed","model":"phi-4","output":[],"created_at":0,"tools":[]}}""";
+ var evt = JsonSerializer.Deserialize(json, ResponsesJsonContext.Default.ResponseStreamingEvent);
+
+ await Assert.That(evt).IsNotNull();
+ await Assert.That(evt!.Type).IsEqualTo("response.completed");
+ await Assert.That(evt.Response).IsNotNull();
+ await Assert.That(evt.Response!.Id).IsEqualTo("resp_001");
+ await Assert.That(evt.Response.Status).IsEqualTo("completed");
+ }
+
+ // ========================================================================
+ // Full response deserialization (simulating server response)
+ // ========================================================================
+
+ [Test]
+ public async Task ResponseObject_FullJson_Deserializes()
+ {
+ var json = """
+ {
+ "id": "resp_abc123",
+ "object": "response",
+ "created_at": 1710000000,
+ "completed_at": 1710000001,
+ "status": "completed",
+ "model": "phi-4",
+ "output": [
+ {
+ "type": "message",
+ "id": "msg_001",
+ "role": "assistant",
+ "status": "completed",
+ "content": [
+ {"type": "output_text", "text": "42 is the answer."}
+ ]
+ }
+ ],
+ "tools": [],
+ "parallel_tool_calls": false,
+ "temperature": 0.7,
+ "top_p": 1.0,
+ "presence_penalty": 0.0,
+ "frequency_penalty": 0.0,
+ "store": false,
+ "usage": {
+ "input_tokens": 10,
+ "output_tokens": 5,
+ "total_tokens": 15
+ }
+ }
+ """;
+
+ var response = JsonSerializer.Deserialize(json, ResponsesJsonContext.Default.ResponseObject);
+
+ await Assert.That(response).IsNotNull();
+ await Assert.That(response!.Id).IsEqualTo("resp_abc123");
+ await Assert.That(response.Status).IsEqualTo("completed");
+ await Assert.That(response.OutputText).IsEqualTo("42 is the answer.");
+ await Assert.That(response.Usage).IsNotNull();
+ await Assert.That(response.Usage!.TotalTokens).IsEqualTo(15);
+ }
+
+ // ========================================================================
+ // DeleteResult deserialization
+ // ========================================================================
+
+ [Test]
+ public async Task ResponseDeleteResult_Deserializes()
+ {
+ var json = """{"id":"resp_abc","object":"response","deleted":true}""";
+ var result = JsonSerializer.Deserialize(json, ResponsesJsonContext.Default.ResponseDeleteResult);
+
+ await Assert.That(result).IsNotNull();
+ await Assert.That(result!.Deleted).IsTrue();
+ await Assert.That(result.Id).IsEqualTo("resp_abc");
+ }
+
+ // ========================================================================
+ // InputItemsList deserialization
+ // ========================================================================
+
+ [Test]
+ public async Task ResponseInputItemsList_Deserializes()
+ {
+ var json = """{"object":"list","data":[{"type":"message","role":"user","content":"Hi"}]}""";
+ var list = JsonSerializer.Deserialize(json, ResponsesJsonContext.Default.ResponseInputItemsList);
+
+ await Assert.That(list).IsNotNull();
+ await Assert.That(list!.Data).HasCount().EqualTo(1);
+
+ var msg = list.Data[0] as ResponseMessageItem;
+ await Assert.That(msg).IsNotNull();
+ await Assert.That(msg!.Role).IsEqualTo("user");
+ }
+}