From 0c3da9885c68616d203de6025e24010c596112af Mon Sep 17 00:00:00 2001 From: maanavd Date: Tue, 10 Mar 2026 12:38:12 -0500 Subject: [PATCH] feat(sdk/cs): add Responses API client for OpenAI/OpenResponses compatibility Add OpenAIResponsesClient to the C# SDK v2 with full CRUD support for the Responses API served by Foundry Local's embedded web service. New files: - src/OpenAI/ResponsesClient.cs: HTTP-based client with SSE streaming - src/OpenAI/ResponsesTypes.cs: Request/response DTOs, items, streaming events - src/OpenAI/ResponsesJsonContext.cs: AOT-compatible source-generated JSON context Modified files: - src/IModel.cs: GetResponsesClientAsync() on IModel interface - src/ModelVariant.cs: Implementation with web service URL validation - src/Model.cs: Delegation to SelectedVariant - src/FoundryLocalManager.cs: GetResponsesClient() factory method Key design decisions: - HTTP-based (HttpClient + SSE), not FFI, since no CoreInterop command exists - AOT-compatible: all serialization uses source-generated JsonSerializerContext - IDisposable: HttpClient properly disposed - Follows existing patterns: Utils.CallWithExceptionHandling, ConfigureAwait(false) - Factory on FoundryLocalManager + convenience on IModel - ResponseObject.OutputText convenience property (matches OpenAI Python SDK) - Full CRUD: Create, CreateStreaming, Get, Delete, Cancel, GetInputItems --- sdk_v2/cs/src/FoundryLocalManager.cs | 18 + sdk_v2/cs/src/IModel.cs | 9 + sdk_v2/cs/src/Model.cs | 5 + sdk_v2/cs/src/ModelVariant.cs | 24 + sdk_v2/cs/src/OpenAI/ResponsesClient.cs | 455 ++++++++++++++ sdk_v2/cs/src/OpenAI/ResponsesJsonContext.cs | 44 ++ sdk_v2/cs/src/OpenAI/ResponsesTypes.cs | 628 +++++++++++++++++++ 7 files changed, 1183 insertions(+) create mode 100644 sdk_v2/cs/src/OpenAI/ResponsesClient.cs create mode 100644 sdk_v2/cs/src/OpenAI/ResponsesJsonContext.cs create mode 100644 sdk_v2/cs/src/OpenAI/ResponsesTypes.cs diff --git a/sdk_v2/cs/src/FoundryLocalManager.cs b/sdk_v2/cs/src/FoundryLocalManager.cs index 639be3a2..4437183f 100644 --- a/sdk_v2/cs/src/FoundryLocalManager.cs +++ b/sdk_v2/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_v2/cs/src/IModel.cs b/sdk_v2/cs/src/IModel.cs index c3acba61..772daeac 100644 --- a/sdk_v2/cs/src/IModel.cs +++ b/sdk_v2/cs/src/IModel.cs @@ -67,4 +67,13 @@ 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); } diff --git a/sdk_v2/cs/src/Model.cs b/sdk_v2/cs/src/Model.cs index bbbbcb5b..dab396ba 100644 --- a/sdk_v2/cs/src/Model.cs +++ b/sdk_v2/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_v2/cs/src/ModelVariant.cs b/sdk_v2/cs/src/ModelVariant.cs index 6ca7cda7..5593b18f 100644 --- a/sdk_v2/cs/src/ModelVariant.cs +++ b/sdk_v2/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_v2/cs/src/OpenAI/ResponsesClient.cs b/sdk_v2/cs/src/OpenAI/ResponsesClient.cs new file mode 100644 index 00000000..bf7240ac --- /dev/null +++ b/sdk_v2/cs/src/OpenAI/ResponsesClient.cs @@ -0,0 +1,455 @@ +// -------------------------------------------------------------------------------------------------------------------- +// +// 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 System.Threading.Channels; + +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); + + 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")); + + // Cannot use 'using' on response in an async iterator — the response stream's lifetime + // is tied to the IAsyncEnumerable consumer. The StreamReader disposal will close the stream. +#pragma warning disable IDISP001 // Dispose created + var response = await _httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, ct) + .ConfigureAwait(false); +#pragma warning restore IDISP001 + 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 + } + } + + 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 GetResponsesClientAsync(modelId)."); + } + + // 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_v2/cs/src/OpenAI/ResponsesJsonContext.cs b/sdk_v2/cs/src/OpenAI/ResponsesJsonContext.cs new file mode 100644 index 00000000..bc890ef8 --- /dev/null +++ b/sdk_v2/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_v2/cs/src/OpenAI/ResponsesTypes.cs b/sdk_v2/cs/src/OpenAI/ResponsesTypes.cs new file mode 100644 index 00000000..937cff2a --- /dev/null +++ b/sdk_v2/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