From 64e78f201530fe72534b1854f7f0788188a92d75 Mon Sep 17 00:00:00 2001 From: MaanavD Date: Tue, 28 Apr 2026 02:15:51 -0400 Subject: [PATCH 1/4] Add OpenAI Responses API client to C# SDK Implements `OpenAIResponsesClient` for the OpenAI Responses API surface in the C# SDK, mirroring the Python (#670) and JS (#671) SDKs and incorporating their resolved review feedback up front. - New `Microsoft.AI.Foundry.Local.OpenAI.OpenAIResponsesClient` (HTTP-only, `HttpClient`-based, no FFI) - Non-streaming + IAsyncEnumerable streaming (`Channel` SSE pipeline) - Full CRUD: `GetAsync`, `DeleteAsync`, `CancelAsync`, `GetInputItemsAsync`, `ListAsync(limit, order, after)` - Polymorphic content parts and response items via `[JsonPolymorphic]` + source-gen context - Streaming events for lifecycle, output, text deltas, function calls, and reasoning - Vision helpers: `InputImageContent.FromFile/FromUrl/FromBytes` - `FoundryLocalManager.GetResponsesClient(modelId?)` and `IModel.GetResponsesClientAsync` Pre-applied PR review feedback from the Python and JS PRs: - `Settings.Store` defaults to `null` (omit) instead of forcing `store=true` - `InputImageContent.MediaType` is optional; unknown extensions omit the field so the server infers - `InputImageContent.FromFile` throws `FileNotFoundException` on missing path - `HttpClient.Timeout = Timeout.InfiniteTimeSpan`; callers use `CancellationToken` for deadlines (avoids 100s default cutting off SSE) - `ListAsync` accepts `limit`, `order`, `after`; `ListResponsesResult` exposes `first_id`, `last_id`, `has_more` - `InputImageContent.Validate()` enforces mutual exclusivity of `ImageUrl` / `ImageData` at request build time - BMP supported in `DetectMediaType` alongside png/jpg/jpeg/gif/webp - Uses shared `FoundryLocalException` for transport/parse errors, no dedicated `ResponsesException` Tests: 22 unit tests (mocked HTTP) + integration tests gated on a running service. `dotnet build sdk/cs/src` clean; all Responses tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk/cs/src/Detail/Model.cs | 5 + sdk/cs/src/Detail/ModelVariant.cs | 28 + sdk/cs/src/FoundryLocalManager.cs | 19 + sdk/cs/src/IModel.cs | 7 + sdk/cs/src/OpenAI/ResponsesClient.cs | 552 +++++++ .../OpenAI/ResponsesSerializationContext.cs | 82 + sdk/cs/src/OpenAI/ResponsesTypes.cs | 1363 +++++++++++++++++ .../ResponsesClientTests.cs | 439 ++++++ .../ResponsesIntegrationTests.cs | 177 +++ sdk/cs/test/FoundryLocal.Tests/Utils.cs | 3 +- 10 files changed, 2674 insertions(+), 1 deletion(-) create mode 100644 sdk/cs/src/OpenAI/ResponsesClient.cs create mode 100644 sdk/cs/src/OpenAI/ResponsesSerializationContext.cs create mode 100644 sdk/cs/src/OpenAI/ResponsesTypes.cs create mode 100644 sdk/cs/test/FoundryLocal.Tests/ResponsesClientTests.cs create mode 100644 sdk/cs/test/FoundryLocal.Tests/ResponsesIntegrationTests.cs diff --git a/sdk/cs/src/Detail/Model.cs b/sdk/cs/src/Detail/Model.cs index 03e9321b..0dded439 100644 --- a/sdk/cs/src/Detail/Model.cs +++ b/sdk/cs/src/Detail/Model.cs @@ -104,6 +104,11 @@ public async Task GetEmbeddingClientAsync(CancellationTok return await SelectedVariant.GetEmbeddingClientAsync(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/Detail/ModelVariant.cs b/sdk/cs/src/Detail/ModelVariant.cs index 250c601a..284a7bfd 100644 --- a/sdk/cs/src/Detail/ModelVariant.cs +++ b/sdk/cs/src/Detail/ModelVariant.cs @@ -109,6 +109,13 @@ public async Task GetEmbeddingClientAsync(CancellationTok .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); @@ -210,6 +217,27 @@ private async Task GetEmbeddingClientImplAsync(Cancellati return new OpenAIEmbeddingClient(Id); } + private async Task GetResponsesClientImplAsync(CancellationToken? ct = null) + { + if (!await IsLoadedAsync(ct)) + { + throw new FoundryLocalException($"Model {Id} is not loaded. Call LoadAsync first."); + } + + var manager = FoundryLocalManager.Instance; + if (manager.Urls == null || manager.Urls.Length == 0) + { + await manager.StartWebServiceAsync(ct).ConfigureAwait(false); + } + + if (manager.Urls == null || manager.Urls.Length == 0) + { + throw new FoundryLocalException("Web service is not running. Call StartWebServiceAsync first."); + } + + return new OpenAIResponsesClient(manager.Urls[0], Id); + } + public void SelectVariant(IModel variant) { throw new FoundryLocalException( diff --git a/sdk/cs/src/FoundryLocalManager.cs b/sdk/cs/src/FoundryLocalManager.cs index 10b51285..3e19e144 100644 --- a/sdk/cs/src/FoundryLocalManager.cs +++ b/sdk/cs/src/FoundryLocalManager.cs @@ -460,4 +460,23 @@ public void Dispose() Dispose(disposing: true); GC.SuppressFinalize(this); } + + /// + /// Get an HTTP client for the OpenAI Responses API. + /// + /// + /// The web service must be started first (see ). + /// + /// Optional default model id used when callers don't supply one. + /// A new . + /// If the web service has not been started. + public OpenAIResponsesClient GetResponsesClient(string? modelId = null) + { + if (Urls == null || Urls.Length == 0) + { + throw new FoundryLocalException("Web service is not running. Call StartWebServiceAsync first."); + } + + return new OpenAIResponsesClient(Urls[0], modelId); + } } diff --git a/sdk/cs/src/IModel.cs b/sdk/cs/src/IModel.cs index 37249782..953d7b1b 100644 --- a/sdk/cs/src/IModel.cs +++ b/sdk/cs/src/IModel.cs @@ -77,6 +77,13 @@ Task DownloadAsync(Action? downloadProgress = null, /// OpenAI.EmbeddingClient Task GetEmbeddingClientAsync(CancellationToken? ct = null); + /// + /// Get an HTTP client for the OpenAI Responses API. + /// + /// Optional cancellation token. + /// An bound to this model. + Task GetResponsesClientAsync(CancellationToken? ct = null); + /// /// Variants of the model that are available. Variants of the model are optimized for different devices. /// diff --git a/sdk/cs/src/OpenAI/ResponsesClient.cs b/sdk/cs/src/OpenAI/ResponsesClient.cs new file mode 100644 index 00000000..a8fe2908 --- /dev/null +++ b/sdk/cs/src/OpenAI/ResponsesClient.cs @@ -0,0 +1,552 @@ +// -------------------------------------------------------------------------------------------------------------------- +// +// Copyright (c) Microsoft. All rights reserved. +// +// -------------------------------------------------------------------------------------------------------------------- + +namespace Microsoft.AI.Foundry.Local; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; + +using Microsoft.AI.Foundry.Local.OpenAI.Responses; + +/// +/// Default-value container for . +/// Any non-null settings here are applied to every request before the +/// per-call configure callback runs. +/// +public class ResponsesClientSettings +{ + public string? Instructions { get; set; } + + public float? Temperature { get; set; } + + public float? TopP { get; set; } + + public int? MaxOutputTokens { get; set; } + + public bool? ParallelToolCalls { get; set; } + + public TruncationValue? Truncation { get; set; } + + /// + /// Server-side storage of responses. When null (default), the field is omitted + /// from the request and the server applies its default. Set to true to persist + /// responses for later retrieval via , + /// , and . + /// + public bool? Store { get; set; } + + public Dictionary? Metadata { get; set; } + + public ReasoningConfig? Reasoning { get; set; } + + public TextConfig? Text { get; set; } + + public string? User { get; set; } + + internal void ApplyTo(ResponseCreateRequest request) + { + request.Instructions ??= Instructions; + request.Temperature ??= Temperature; + request.TopP ??= TopP; + request.MaxOutputTokens ??= MaxOutputTokens; + request.ParallelToolCalls ??= ParallelToolCalls; + request.Truncation ??= Truncation; + request.Store ??= Store; + request.Metadata ??= Metadata; + request.Reasoning ??= Reasoning; + request.Text ??= Text; + request.User ??= User; + } +} + +/// +/// HTTP client for the OpenAI Responses API served by Foundry Local. +/// Uses directly — no FFI/native interop. +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP007:Don't dispose injected", Justification = "Client only disposes HttpClient when it owns it (ownsClient flag tracked at construction).")] +[System.Diagnostics.CodeAnalysis.SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP008:Don't assign member with injected and created disposables", Justification = "Client owns HttpClient when constructed without one.")] +[System.Diagnostics.CodeAnalysis.SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP014:Use a single instance of HttpClient", Justification = "Short-lived per-client HttpClient matches SDK pattern; callers share via FoundryLocalManager.")] +public sealed class OpenAIResponsesClient : IDisposable +{ + private readonly HttpClient _httpClient; + private readonly string _baseUrl; + private readonly string? _modelId; + private bool _disposed; + + /// Default settings applied to every request. + public ResponsesClientSettings Settings { get; } = new(); + + /// Gets the underlying base URL (without trailing slash). + public string BaseUrl => _baseUrl; + + /// Gets the default model id (if any) supplied at construction. + public string? ModelId => _modelId; + + /// + /// Create a new client for the given base URL. + /// + /// Base URL of the Foundry Local service (e.g., http://localhost:5273). + /// Default model id to use when callers do not set one explicitly. + public OpenAIResponsesClient(string baseUrl, string? modelId = null) + : this(CreateDefaultHttpClient(), baseUrl, modelId, ownsClient: true) + { + } + + internal OpenAIResponsesClient(HttpClient httpClient, string baseUrl, string? modelId = null, bool ownsClient = true) + { + ArgumentNullException.ThrowIfNull(httpClient); + + if (string.IsNullOrWhiteSpace(baseUrl)) + { + throw new ArgumentException("baseUrl must be non-empty.", nameof(baseUrl)); + } + + _httpClient = httpClient; + _baseUrl = baseUrl.TrimEnd('/'); + _modelId = modelId; + _ownsClient = ownsClient; + } + + // Streaming SSE connections stay open until the server finishes producing events, + // which can exceed HttpClient's 100s default. Disable the built-in timeout and let + // callers enforce request-scoped deadlines via CancellationToken. + private static HttpClient CreateDefaultHttpClient() => new() { Timeout = Timeout.InfiniteTimeSpan }; + + private readonly bool _ownsClient; + + // ----------------------------------------------------------------------------------------------------------------- + // Create (non-streaming) + // ----------------------------------------------------------------------------------------------------------------- + + public Task CreateAsync(string input, CancellationToken ct = default) + => CreateAsync(input, configure: null, ct); + + public Task CreateAsync(string input, Action? configure, CancellationToken ct = default) + { + ValidateStringInput(input); + return CreateAsync(BuildRequest(input, configure), ct); + } + + public Task CreateAsync(List input, CancellationToken ct = default) + => CreateAsync(input, configure: null, ct); + + public Task CreateAsync(List input, Action? configure, CancellationToken ct = default) + { + ValidateListInput(input); + return CreateAsync(BuildRequest(input, configure), ct); + } + + /// Submit a raw request object. + public async Task CreateAsync(ResponseCreateRequest request, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(request); + + request.Stream = false; + using var content = SerializeRequest(request); + using var response = await _httpClient.PostAsync(Url("/v1/responses"), content, ct).ConfigureAwait(false); + await EnsureSuccessAsync(response, ct).ConfigureAwait(false); + + await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); + var parsed = await JsonSerializer.DeserializeAsync(stream, ResponsesSerializationContext.Default.ResponseObject, ct) + .ConfigureAwait(false); + return parsed ?? throw new FoundryLocalException("Server returned an empty response body."); + } + + // ----------------------------------------------------------------------------------------------------------------- + // Create (streaming) + // ----------------------------------------------------------------------------------------------------------------- + + public IAsyncEnumerable CreateStreamingAsync(string input, CancellationToken ct = default) + => CreateStreamingAsync(input, configure: null, ct); + + public IAsyncEnumerable CreateStreamingAsync(string input, Action? configure, CancellationToken ct = default) + { + ValidateStringInput(input); + return CreateStreamingAsync(BuildRequest(input, configure), ct); + } + + public IAsyncEnumerable CreateStreamingAsync(List input, CancellationToken ct = default) + => CreateStreamingAsync(input, configure: null, ct); + + public IAsyncEnumerable CreateStreamingAsync(List input, Action? configure, CancellationToken ct = default) + { + ValidateListInput(input); + return CreateStreamingAsync(BuildRequest(input, configure), ct); + } + + /// Stream events for a raw request object. + public async IAsyncEnumerable CreateStreamingAsync(ResponseCreateRequest request, [EnumeratorCancellation] CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(request); + + request.Stream = true; + + var channel = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = true, + }); + + var pumpTask = Task.Run(() => PumpSseAsync(request, channel.Writer, ct), ct); + + await foreach (var ev in channel.Reader.ReadAllAsync(ct).ConfigureAwait(false)) + { + yield return ev; + } + + // Surface any exception that happened on the background pump. + await pumpTask.ConfigureAwait(false); + } + + private async Task PumpSseAsync(ResponseCreateRequest request, ChannelWriter writer, CancellationToken ct) + { + try + { + using var req = new HttpRequestMessage(HttpMethod.Post, Url("/v1/responses")) + { + Content = SerializeRequest(request), + }; + req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); + + using var response = await _httpClient.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); + await EnsureSuccessAsync(response, ct).ConfigureAwait(false); + + await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); + using var reader = new StreamReader(stream, Encoding.UTF8); + + var dataBuilder = new StringBuilder(); + while (!reader.EndOfStream) + { + ct.ThrowIfCancellationRequested(); + var line = await reader.ReadLineAsync(ct).ConfigureAwait(false); + if (line == null) + { + break; + } + + if (line.Length == 0) + { + // End of an SSE event block. + if (dataBuilder.Length > 0) + { + var payload = dataBuilder.ToString(); + dataBuilder.Clear(); + + if (payload == "[DONE]") + { + break; + } + + var ev = ParseStreamingEvent(payload); + if (ev != null) + { + await writer.WriteAsync(ev, ct).ConfigureAwait(false); + } + } + + continue; + } + + if (line.StartsWith("data:", StringComparison.Ordinal)) + { + var data = line.Length > 5 && line[5] == ' ' ? line.Substring(6) : line.Substring(5); + if (dataBuilder.Length > 0) + { + dataBuilder.Append('\n'); + } + + dataBuilder.Append(data); + } + + // `event:`, `id:`, and comment lines (`:`) are ignored — the type is in the JSON payload. + } + + // If stream ended without a blank-line terminator, flush any pending data. + if (dataBuilder.Length > 0) + { + var payload = dataBuilder.ToString(); + if (payload != "[DONE]") + { + var ev = ParseStreamingEvent(payload); + if (ev != null) + { + await writer.WriteAsync(ev, ct).ConfigureAwait(false); + } + } + } + + writer.TryComplete(); + } + catch (OperationCanceledException) + { + writer.TryComplete(); + throw; + } + catch (Exception ex) + { + writer.TryComplete(ex); + } + } + + internal static StreamingEvent? ParseStreamingEvent(string payload) + { + try + { + return JsonSerializer.Deserialize(payload, ResponsesSerializationContext.Default.StreamingEvent); + } + catch (JsonException) + { + return null; + } + } + + // ----------------------------------------------------------------------------------------------------------------- + // CRUD + // ----------------------------------------------------------------------------------------------------------------- + + public async Task GetAsync(string responseId, CancellationToken ct = default) + { + ValidateId(responseId); + using var response = await _httpClient.GetAsync(Url($"/v1/responses/{responseId}"), ct).ConfigureAwait(false); + await EnsureSuccessAsync(response, ct).ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); + var result = await JsonSerializer.DeserializeAsync(stream, ResponsesSerializationContext.Default.ResponseObject, ct).ConfigureAwait(false); + return result ?? throw new FoundryLocalException("Server returned an empty response body."); + } + + public async Task DeleteAsync(string responseId, CancellationToken ct = default) + { + ValidateId(responseId); + using var response = await _httpClient.DeleteAsync(Url($"/v1/responses/{responseId}"), ct).ConfigureAwait(false); + await EnsureSuccessAsync(response, ct).ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); + var result = await JsonSerializer.DeserializeAsync(stream, ResponsesSerializationContext.Default.DeleteResponseResult, ct).ConfigureAwait(false); + return result ?? throw new FoundryLocalException("Server returned an empty delete response body."); + } + + public async Task CancelAsync(string responseId, CancellationToken ct = default) + { + ValidateId(responseId); + using var content = new StringContent(string.Empty, Encoding.UTF8, "application/json"); + using var response = await _httpClient.PostAsync(Url($"/v1/responses/{responseId}/cancel"), content, ct).ConfigureAwait(false); + await EnsureSuccessAsync(response, ct).ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); + var result = await JsonSerializer.DeserializeAsync(stream, ResponsesSerializationContext.Default.ResponseObject, ct).ConfigureAwait(false); + return result ?? throw new FoundryLocalException("Server returned an empty cancel response body."); + } + + public async Task GetInputItemsAsync(string responseId, CancellationToken ct = default) + { + ValidateId(responseId); + using var response = await _httpClient.GetAsync(Url($"/v1/responses/{responseId}/input_items"), ct).ConfigureAwait(false); + await EnsureSuccessAsync(response, ct).ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); + var result = await JsonSerializer.DeserializeAsync(stream, ResponsesSerializationContext.Default.InputItemsListResponse, ct).ConfigureAwait(false); + return result ?? throw new FoundryLocalException("Server returned an empty input-items response body."); + } + + public async Task ListAsync( + int? limit = null, + string? order = null, + string? after = null, + CancellationToken ct = default) + { + var query = new List(); + if (limit.HasValue) + { + query.Add($"limit={limit.Value}"); + } + if (!string.IsNullOrWhiteSpace(order)) + { + query.Add($"order={Uri.EscapeDataString(order)}"); + } + if (!string.IsNullOrWhiteSpace(after)) + { + query.Add($"after={Uri.EscapeDataString(after)}"); + } + var url = Url("/v1/responses") + (query.Count > 0 ? "?" + string.Join("&", query) : ""); + using var response = await _httpClient.GetAsync(url, ct).ConfigureAwait(false); + await EnsureSuccessAsync(response, ct).ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); + var result = await JsonSerializer.DeserializeAsync(stream, ResponsesSerializationContext.Default.ListResponsesResult, ct).ConfigureAwait(false); + return result ?? throw new FoundryLocalException("Server returned an empty list response body."); + } + + // ----------------------------------------------------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------------------------------------------------- + + private string Url(string relative) => _baseUrl + relative; + + private ResponseCreateRequest BuildRequest(string input, Action? configure) + { + var request = new ResponseCreateRequest + { + Model = _modelId ?? string.Empty, + Input = input, + }; + Settings.ApplyTo(request); + configure?.Invoke(request); + EnsureModel(request); + ValidateTools(request); + ValidateImageContents(request); + return request; + } + + private ResponseCreateRequest BuildRequest(List input, Action? configure) + { + var request = new ResponseCreateRequest + { + Model = _modelId ?? string.Empty, + Input = input, + }; + Settings.ApplyTo(request); + configure?.Invoke(request); + EnsureModel(request); + ValidateTools(request); + ValidateImageContents(request); + return request; + } + + private static void ValidateImageContents(ResponseCreateRequest request) + { + var items = request.Input?.Items; + if (items is null) + { + return; + } + foreach (var item in items) + { + if (item is MessageItem msg && msg.Content?.Parts is { } parts) + { + foreach (var part in parts) + { + if (part is InputImageContent img) + { + img.Validate(); + } + } + } + } + } + + private static void EnsureModel(ResponseCreateRequest request) + { + if (string.IsNullOrWhiteSpace(request.Model)) + { + throw new ArgumentException("A model id must be provided via constructor or configure callback."); + } + } + + private static void ValidateStringInput(string input) + { + ArgumentNullException.ThrowIfNull(input); + + if (input.Length == 0) + { + throw new ArgumentException("Input string must be non-empty.", nameof(input)); + } + } + + private static void ValidateListInput(List input) + { + ArgumentNullException.ThrowIfNull(input); + + if (input.Count == 0) + { + throw new ArgumentException("Input list must contain at least one item.", nameof(input)); + } + } + + private static void ValidateId(string id) + { + if (string.IsNullOrWhiteSpace(id)) + { + throw new ArgumentException("Response id must be non-empty.", nameof(id)); + } + } + + private static void ValidateTools(ResponseCreateRequest request) + { + if (request.Tools == null) + { + return; + } + + foreach (var tool in request.Tools) + { + if (string.IsNullOrWhiteSpace(tool.Name)) + { + throw new ArgumentException("Tool definition name must be non-empty."); + } + } + } + + private static StringContent SerializeRequest(ResponseCreateRequest request) + { + var json = JsonSerializer.Serialize(request, ResponsesSerializationContext.Default.ResponseCreateRequest); + return new StringContent(json, Encoding.UTF8, "application/json"); + } + + private static async Task EnsureSuccessAsync(HttpResponseMessage response, CancellationToken ct) + { + if (response.IsSuccessStatusCode) + { + return; + } + + string body = string.Empty; + try + { + body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + } + catch + { + // ignore read failure — we still raise for status. + } + + // Try to parse the OpenAI-style error envelope for a nicer message. + string? serverMessage = null; + if (!string.IsNullOrWhiteSpace(body)) + { + try + { + var parsed = JsonSerializer.Deserialize(body, ResponsesSerializationContext.Default.ApiErrorResponse); + serverMessage = parsed?.Error?.Message; + } + catch (JsonException) + { + // ignore parse failure — fall through to raw body. + } + } + + var message = serverMessage ?? (string.IsNullOrWhiteSpace(body) ? response.ReasonPhrase : body); + throw new FoundryLocalException($"Responses API request failed ({(int)response.StatusCode} {response.ReasonPhrase}): {message}"); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + if (_ownsClient) + { + _httpClient.Dispose(); + } + } +} diff --git a/sdk/cs/src/OpenAI/ResponsesSerializationContext.cs b/sdk/cs/src/OpenAI/ResponsesSerializationContext.cs new file mode 100644 index 00000000..41e3f511 --- /dev/null +++ b/sdk/cs/src/OpenAI/ResponsesSerializationContext.cs @@ -0,0 +1,82 @@ +// -------------------------------------------------------------------------------------------------------------------- +// +// Copyright (c) Microsoft. All rights reserved. +// +// -------------------------------------------------------------------------------------------------------------------- + +namespace Microsoft.AI.Foundry.Local.OpenAI.Responses; + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +/// +/// Source-generated JSON serialization context for the Responses API types. +/// This keeps the SDK AOT-compatible and trimming-safe. +/// +[JsonSourceGenerationOptions( + WriteIndented = false, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNameCaseInsensitive = true, + UseStringEnumConverter = true)] +[JsonSerializable(typeof(ResponseCreateRequest))] +[JsonSerializable(typeof(ResponseObject))] +[JsonSerializable(typeof(StreamingEvent))] +[JsonSerializable(typeof(ResponseItem))] +[JsonSerializable(typeof(ContentPart))] +[JsonSerializable(typeof(Annotation))] +[JsonSerializable(typeof(DeleteResponseResult))] +[JsonSerializable(typeof(InputItemsListResponse))] +[JsonSerializable(typeof(ListResponsesResult))] +[JsonSerializable(typeof(MessageItem))] +[JsonSerializable(typeof(FunctionCallItem))] +[JsonSerializable(typeof(FunctionCallOutputItem))] +[JsonSerializable(typeof(ReasoningItem))] +[JsonSerializable(typeof(ItemReference))] +[JsonSerializable(typeof(InputTextContent))] +[JsonSerializable(typeof(OutputTextContent))] +[JsonSerializable(typeof(RefusalContent))] +[JsonSerializable(typeof(InputImageContent))] +[JsonSerializable(typeof(InputFileContent))] +[JsonSerializable(typeof(UrlCitationAnnotation))] +[JsonSerializable(typeof(FunctionToolDefinition))] +[JsonSerializable(typeof(SpecificToolChoice))] +[JsonSerializable(typeof(ToolChoice))] +[JsonSerializable(typeof(ReasoningConfig))] +[JsonSerializable(typeof(TextConfig))] +[JsonSerializable(typeof(TextFormat))] +[JsonSerializable(typeof(ResponseUsage))] +[JsonSerializable(typeof(ResponseError))] +[JsonSerializable(typeof(IncompleteDetails))] +[JsonSerializable(typeof(ApiErrorResponse))] +[JsonSerializable(typeof(ResponseCreatedEvent))] +[JsonSerializable(typeof(ResponseQueuedEvent))] +[JsonSerializable(typeof(ResponseInProgressEvent))] +[JsonSerializable(typeof(ResponseCompletedEvent))] +[JsonSerializable(typeof(ResponseFailedEvent))] +[JsonSerializable(typeof(ResponseIncompleteEvent))] +[JsonSerializable(typeof(OutputItemAddedEvent))] +[JsonSerializable(typeof(OutputItemDoneEvent))] +[JsonSerializable(typeof(ContentPartAddedEvent))] +[JsonSerializable(typeof(ContentPartDoneEvent))] +[JsonSerializable(typeof(OutputTextDeltaEvent))] +[JsonSerializable(typeof(OutputTextDoneEvent))] +[JsonSerializable(typeof(RefusalDeltaEvent))] +[JsonSerializable(typeof(RefusalDoneEvent))] +[JsonSerializable(typeof(FunctionCallArgumentsDeltaEvent))] +[JsonSerializable(typeof(FunctionCallArgumentsDoneEvent))] +[JsonSerializable(typeof(ReasoningSummaryPartAddedEvent))] +[JsonSerializable(typeof(ReasoningSummaryPartDoneEvent))] +[JsonSerializable(typeof(ReasoningDeltaEvent))] +[JsonSerializable(typeof(ReasoningDoneEvent))] +[JsonSerializable(typeof(ReasoningSummaryDeltaEvent))] +[JsonSerializable(typeof(ReasoningSummaryDoneEvent))] +[JsonSerializable(typeof(OutputTextAnnotationAddedEvent))] +[JsonSerializable(typeof(ErrorEvent))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(Dictionary))] +internal partial class ResponsesSerializationContext : JsonSerializerContext +{ +} diff --git a/sdk/cs/src/OpenAI/ResponsesTypes.cs b/sdk/cs/src/OpenAI/ResponsesTypes.cs new file mode 100644 index 00000000..608af758 --- /dev/null +++ b/sdk/cs/src/OpenAI/ResponsesTypes.cs @@ -0,0 +1,1363 @@ +// -------------------------------------------------------------------------------------------------------------------- +// +// Copyright (c) Microsoft. All rights reserved. +// +// -------------------------------------------------------------------------------------------------------------------- + +namespace Microsoft.AI.Foundry.Local.OpenAI.Responses; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; + +// ===================================================================================================================== +// Enums +// ===================================================================================================================== + +/// The status of a Response object lifecycle. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ResponseStatus +{ + [JsonStringEnumMemberName("queued")] Queued, + [JsonStringEnumMemberName("in_progress")] InProgress, + [JsonStringEnumMemberName("completed")] Completed, + [JsonStringEnumMemberName("failed")] Failed, + [JsonStringEnumMemberName("incomplete")] Incomplete, + [JsonStringEnumMemberName("cancelled")] Cancelled, +} + +/// The status of an item within a Response. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ResponseItemStatus +{ + [JsonStringEnumMemberName("in_progress")] InProgress, + [JsonStringEnumMemberName("completed")] Completed, + [JsonStringEnumMemberName("incomplete")] Incomplete, +} + +/// The role of a message in a conversation. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum MessageRole +{ + [JsonStringEnumMemberName("system")] System, + [JsonStringEnumMemberName("user")] User, + [JsonStringEnumMemberName("assistant")] Assistant, + [JsonStringEnumMemberName("developer")] Developer, + [JsonStringEnumMemberName("tool")] Tool, + [JsonStringEnumMemberName("tool_response")] ToolResponse, +} + +/// Controls whether the model may call tools. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ToolChoiceValue +{ + [JsonStringEnumMemberName("auto")] Auto, + [JsonStringEnumMemberName("none")] None, + [JsonStringEnumMemberName("required")] Required, +} + +/// Controls truncation behavior when context exceeds limits. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum TruncationValue +{ + [JsonStringEnumMemberName("auto")] Auto, + [JsonStringEnumMemberName("disabled")] Disabled, +} + +// ===================================================================================================================== +// Content Parts (polymorphic) +// ===================================================================================================================== + +/// Base class for all content parts within items. +[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] +[JsonDerivedType(typeof(InputTextContent), "input_text")] +[JsonDerivedType(typeof(OutputTextContent), "output_text")] +[JsonDerivedType(typeof(RefusalContent), "refusal")] +[JsonDerivedType(typeof(InputImageContent), "input_image")] +[JsonDerivedType(typeof(InputFileContent), "input_file")] +public abstract class ContentPart +{ + /// The type discriminator for the content part. + [JsonIgnore] + public abstract string Kind { get; } +} + +/// Interface for content parts that carry text. +public interface ITextContent +{ + /// The text content. + string Text { get; } +} + +/// Text content provided as input. +public sealed class InputTextContent : ContentPart, ITextContent +{ + /// + [JsonIgnore] + public override string Kind => "input_text"; + + [JsonPropertyName("text")] + public required string Text { get; set; } +} + +/// Text content generated by the model. +public sealed class OutputTextContent : ContentPart, ITextContent +{ + /// + [JsonIgnore] + public override string Kind => "output_text"; + + [JsonPropertyName("text")] + public required string Text { get; set; } + + [JsonPropertyName("annotations")] + public List Annotations { get; set; } = []; + + [JsonPropertyName("logprobs")] + public List Logprobs { get; set; } = []; +} + +/// Content when the model refuses to respond. +public sealed class RefusalContent : ContentPart +{ + /// + [JsonIgnore] + public override string Kind => "refusal"; + + [JsonPropertyName("refusal")] + public required string Refusal { get; set; } +} + +/// Image content provided as input. +public sealed class InputImageContent : ContentPart +{ + /// + [JsonIgnore] + public override string Kind => "input_image"; + + /// Public URL of an image. + [JsonPropertyName("image_url")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ImageUrl { get; set; } + + /// Base64-encoded image data. + [JsonPropertyName("image_data")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ImageData { get; set; } + + /// MIME type of the image (e.g., image/png, image/jpeg). Optional — the server will + /// infer the type from the data when omitted. + [JsonPropertyName("media_type")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? MediaType { get; set; } + + /// Detail level for image processing: "low", "high", or "auto". + [JsonPropertyName("detail")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Detail { get; set; } + + /// Validates that exactly one of or is set. + /// Called by the client before serialization to surface caller errors close to the call site rather than + /// returning a server-side 4xx. The factory methods (, , + /// ) already produce valid instances; this guards against direct property mutation. + /// If both or neither of / are set. + public void Validate() + { + var hasUrl = !string.IsNullOrEmpty(ImageUrl); + var hasData = !string.IsNullOrEmpty(ImageData); + if (hasUrl == hasData) + { + throw new InvalidOperationException( + "InputImageContent requires exactly one of ImageUrl or ImageData to be set."); + } + } + + /// Load an image from a local file; base64 encode and detect media type from extension. + /// If is null/empty. + /// If the file does not exist at . + public static InputImageContent FromFile(string path, string? detail = null) + { + if (string.IsNullOrWhiteSpace(path)) + { + throw new ArgumentException("path must be non-empty.", nameof(path)); + } + + if (!File.Exists(path)) + { + throw new FileNotFoundException($"Image file not found: {path}", path); + } + + var bytes = File.ReadAllBytes(path); + var mediaType = DetectMediaType(path); + return FromBytes(bytes, mediaType, detail); + } + + /// Create an image content referencing a URL. is optional; + /// when null the server will infer it. + public static InputImageContent FromUrl(string url, string? detail = null, string? mediaType = null) + { + if (string.IsNullOrWhiteSpace(url)) + { + throw new ArgumentException("url must be non-empty.", nameof(url)); + } + + return new InputImageContent + { + ImageUrl = url, + MediaType = mediaType, + Detail = detail, + }; + } + + /// Create an image content from raw bytes. is optional; + /// when null the server will infer it from the data. + public static InputImageContent FromBytes(byte[] data, string? mediaType = null, string? detail = null) + { + if (data == null || data.Length == 0) + { + throw new ArgumentException("data must be non-empty.", nameof(data)); + } + + return new InputImageContent + { + ImageData = Convert.ToBase64String(data), + MediaType = mediaType, + Detail = detail, + }; + } + + private static string? DetectMediaType(string path) + { + var ext = Path.GetExtension(path).ToLowerInvariant(); + return ext switch + { + ".png" => "image/png", + ".jpg" or ".jpeg" => "image/jpeg", + ".gif" => "image/gif", + ".webp" => "image/webp", + ".bmp" => "image/bmp", + _ => null, + }; + } +} + +/// File content provided as input. +public sealed class InputFileContent : ContentPart +{ + /// + [JsonIgnore] + public override string Kind => "input_file"; + + [JsonPropertyName("filename")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Filename { get; set; } + + [JsonPropertyName("file_url")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? FileUrl { get; set; } +} + +// ===================================================================================================================== +// Annotations and LogProbs +// ===================================================================================================================== + +[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] +[JsonDerivedType(typeof(UrlCitationAnnotation), "url_citation")] +public class Annotation +{ + [JsonIgnore] + public virtual string Kind { get; set; } = "annotation"; + + [JsonPropertyName("start_index")] + public int StartIndex { get; set; } + + [JsonPropertyName("end_index")] + public int EndIndex { get; set; } +} + +public sealed class UrlCitationAnnotation : Annotation +{ + [JsonIgnore] + public override string Kind => "url_citation"; + + [JsonPropertyName("url")] + public required string Url { get; set; } + + [JsonPropertyName("title")] + public required string Title { get; set; } +} + +public class LogProb +{ + [JsonPropertyName("token")] + public required string Token { get; set; } + + [JsonPropertyName("logprob")] + public double Logprob { get; set; } + + [JsonPropertyName("bytes")] + public List Bytes { get; set; } = []; + + [JsonPropertyName("top_logprobs")] + public List TopLogprobs { get; set; } = []; +} + +public class TopLogProb +{ + [JsonPropertyName("token")] + public required string Token { get; set; } + + [JsonPropertyName("logprob")] + public double Logprob { get; set; } + + [JsonPropertyName("bytes")] + public List Bytes { get; set; } = []; +} + +// ===================================================================================================================== +// MessageContent union (string | ContentPart[]) +// ===================================================================================================================== + +/// +/// Represents message content — either a simple string or an array of content parts. +/// Use implicit conversions or factory methods for convenience. +/// +[JsonConverter(typeof(MessageContentConverter))] +public class MessageContent +{ + /// The text content if this is a simple string message. + public string? Text { get; set; } + + /// The content parts if this is a structured multimodal message. + public List? Parts { get; set; } + + public static implicit operator MessageContent(string text) => new() { Text = text }; + + public static implicit operator MessageContent(List parts) => new() { Parts = parts }; + + public static MessageContent FromText(string text) => new() { Text = text }; + + public static MessageContent FromParts(params ContentPart[] parts) => new() { Parts = [.. parts] }; + + /// Get the concatenated text representation. + public string GetText() + { + if (Text != null) + { + return Text; + } + + if (Parts == null) + { + return string.Empty; + } + + var sb = new System.Text.StringBuilder(); + foreach (var part in Parts) + { + if (part is ITextContent t) + { + sb.Append(t.Text); + } + } + + return sb.ToString(); + } +} + +/// JSON converter for supporting string or array form. +public class MessageContentConverter : JsonConverter +{ + public override MessageContent? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + { + return new MessageContent { Text = reader.GetString() }; + } + + if (reader.TokenType == JsonTokenType.StartArray) + { + var parts = new List(); + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + parts.Add(ReadContentPart(ref reader)); + } + + return new MessageContent { Parts = parts }; + } + + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + throw new JsonException("MessageContent must be a string or array."); + } + + private static ContentPart ReadContentPart(ref Utf8JsonReader reader) + { + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException($"Expected ContentPart object, found {reader.TokenType}."); + } + + using var doc = JsonDocument.ParseValue(ref reader); + var root = doc.RootElement; + if (!root.TryGetProperty("type", out var typeEl)) + { + throw new JsonException("ContentPart must have a 'type' property."); + } + + var type = typeEl.GetString(); + var json = root.GetRawText(); + var ctx = ResponsesSerializationContext.Default; + + ContentPart? result = type switch + { + "input_text" => JsonSerializer.Deserialize(json, ctx.InputTextContent), + "output_text" => JsonSerializer.Deserialize(json, ctx.OutputTextContent), + "refusal" => JsonSerializer.Deserialize(json, ctx.RefusalContent), + "input_image" => JsonSerializer.Deserialize(json, ctx.InputImageContent), + "input_file" => JsonSerializer.Deserialize(json, ctx.InputFileContent), + _ => throw new JsonException($"Unsupported content part type: '{type}'."), + }; + + return result ?? throw new JsonException($"Failed to deserialize content part of type '{type}'."); + } + + public override void Write(Utf8JsonWriter writer, MessageContent value, JsonSerializerOptions options) + { + if (value.Text != null) + { + writer.WriteStringValue(value.Text); + } + else if (value.Parts != null) + { + writer.WriteStartArray(); + foreach (var part in value.Parts) + { + JsonSerializer.Serialize(writer, part, ResponsesSerializationContext.Default.ContentPart); + } + + writer.WriteEndArray(); + } + else + { + writer.WriteStringValue(string.Empty); + } + } +} + +// ===================================================================================================================== +// Response Items (polymorphic) +// ===================================================================================================================== + +/// Base class for items in input/output arrays. +[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] +[JsonDerivedType(typeof(MessageItem), "message")] +[JsonDerivedType(typeof(FunctionCallItem), "function_call")] +[JsonDerivedType(typeof(FunctionCallOutputItem), "function_call_output")] +[JsonDerivedType(typeof(ReasoningItem), "reasoning")] +[JsonDerivedType(typeof(ItemReference), "item_reference")] +public abstract class ResponseItem +{ + /// The type discriminator for the item (client-side constant). + [JsonIgnore] + public abstract string Kind { get; } + + [JsonPropertyName("id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Id { get; set; } + + [JsonPropertyName("status")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ResponseItemStatus? Status { get; set; } +} + +public sealed class MessageItem : ResponseItem +{ + [JsonIgnore] + public override string Kind => "message"; + + [JsonPropertyName("role")] + public required MessageRole Role { get; set; } + + [JsonPropertyName("content")] + public required MessageContent Content { get; set; } +} + +public sealed class FunctionCallItem : ResponseItem +{ + [JsonIgnore] + public override string Kind => "function_call"; + + [JsonPropertyName("call_id")] + public required string CallId { get; set; } + + [JsonPropertyName("name")] + public required string Name { get; set; } + + [JsonPropertyName("arguments")] + public required string Arguments { get; set; } +} + +public sealed class FunctionCallOutputItem : ResponseItem +{ + [JsonIgnore] + public override string Kind => "function_call_output"; + + [JsonPropertyName("call_id")] + public required string CallId { get; set; } + + [JsonPropertyName("output")] + public required MessageContent Output { get; set; } +} + +public sealed class ReasoningItem : ResponseItem +{ + [JsonIgnore] + public override string Kind => "reasoning"; + + [JsonPropertyName("content")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Content { get; set; } + + [JsonPropertyName("encrypted_content")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? EncryptedContent { get; set; } + + [JsonPropertyName("summary")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Summary { get; set; } +} + +public sealed class ItemReference : ResponseItem +{ + [JsonIgnore] + public override string Kind => "item_reference"; + + [JsonPropertyName("item_id")] + public required string ItemId { get; set; } +} + +// ===================================================================================================================== +// ResponseInput union (string | ResponseItem[]) +// ===================================================================================================================== + +/// +/// Represents the input field of a Response create request. +/// Supports either a simple string or an array of structured 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 }; +} + +public 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 = new List(); + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + var item = ReadResponseItem(ref reader); + if (item != null) + { + items.Add(item); + } + } + + return new ResponseInput { Items = items }; + } + + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + throw new JsonException("Input must be a string or array of items."); + } + + private static ResponseItem? ReadResponseItem(ref Utf8JsonReader reader) + { + if (reader.TokenType != JsonTokenType.StartObject) + { + reader.Skip(); + return null; + } + + using var doc = JsonDocument.ParseValue(ref reader); + var root = doc.RootElement; + if (!root.TryGetProperty("type", out var typeEl)) + { + return null; + } + + var type = typeEl.GetString(); + var json = root.GetRawText(); + var ctx = ResponsesSerializationContext.Default; + + return type switch + { + "message" => JsonSerializer.Deserialize(json, ctx.MessageItem), + "function_call" => JsonSerializer.Deserialize(json, ctx.FunctionCallItem), + "function_call_output" => JsonSerializer.Deserialize(json, ctx.FunctionCallOutputItem), + "reasoning" => JsonSerializer.Deserialize(json, ctx.ReasoningItem), + "item_reference" => JsonSerializer.Deserialize(json, ctx.ItemReference), + _ => null, + }; + } + + public override void Write(Utf8JsonWriter writer, ResponseInput value, JsonSerializerOptions options) + { + if (value.Text != null) + { + writer.WriteStringValue(value.Text); + } + else if (value.Items != null) + { + var ctx = ResponsesSerializationContext.Default; + writer.WriteStartArray(); + foreach (var item in value.Items) + { + JsonSerializer.Serialize(writer, item, ctx.ResponseItem); + } + + writer.WriteEndArray(); + } + else + { + writer.WriteNullValue(); + } + } +} + +// ===================================================================================================================== +// Tool Definition & Tool Choice +// ===================================================================================================================== + +/// A function tool definition. +public class FunctionToolDefinition +{ + [JsonPropertyName("type")] + public string Type => "function"; + + [JsonPropertyName("name")] + public required string Name { get; set; } + + [JsonPropertyName("description")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; set; } + + [JsonPropertyName("parameters")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Parameters { get; set; } + + [JsonPropertyName("strict")] + public bool Strict { get; set; } +} + +public class SpecificToolChoice +{ + [JsonPropertyName("type")] + public string Type => "function"; + + [JsonPropertyName("name")] + public required string Name { get; set; } +} + +/// Tool choice — either a known string value or a specific function. +[JsonConverter(typeof(ToolChoiceConverter))] +public class ToolChoice +{ + public ToolChoiceValue? Value { get; set; } + + public SpecificToolChoice? Specific { get; set; } + + public static implicit operator ToolChoice(ToolChoiceValue value) => new() { Value = value }; + + public static ToolChoice ForFunction(string name) => new() { Specific = new SpecificToolChoice { Name = name } }; +} + +public class ToolChoiceConverter : JsonConverter +{ + public override ToolChoice? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + { + var str = reader.GetString(); + return str switch + { + "auto" => new ToolChoice { Value = ToolChoiceValue.Auto }, + "none" => new ToolChoice { Value = ToolChoiceValue.None }, + "required" => new ToolChoice { Value = ToolChoiceValue.Required }, + _ => throw new JsonException($"Unknown tool_choice value: {str}"), + }; + } + + if (reader.TokenType == JsonTokenType.StartObject) + { + using var doc = JsonDocument.ParseValue(ref reader); + var json = doc.RootElement.GetRawText(); + var specific = JsonSerializer.Deserialize(json, ResponsesSerializationContext.Default.SpecificToolChoice); + return new ToolChoice { Specific = specific }; + } + + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + throw new JsonException("tool_choice must be a string or object."); + } + + public override void Write(Utf8JsonWriter writer, ToolChoice value, JsonSerializerOptions options) + { + if (value.Value.HasValue) + { + var str = value.Value.Value switch + { + ToolChoiceValue.Auto => "auto", + ToolChoiceValue.None => "none", + ToolChoiceValue.Required => "required", + _ => throw new JsonException($"Unknown ToolChoiceValue: {value.Value}"), + }; + writer.WriteStringValue(str); + } + else if (value.Specific != null) + { + JsonSerializer.Serialize(writer, value.Specific, ResponsesSerializationContext.Default.SpecificToolChoice); + } + else + { + writer.WriteNullValue(); + } + } +} + +// ===================================================================================================================== +// Configuration objects +// ===================================================================================================================== + +public class ReasoningConfig +{ + [JsonPropertyName("effort")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Effort { get; set; } + + [JsonPropertyName("summary")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Summary { get; set; } +} + +public class TextConfig +{ + [JsonPropertyName("format")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TextFormat? Format { get; set; } + + [JsonPropertyName("verbosity")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Verbosity { get; set; } +} + +public class TextFormat +{ + /// One of: text, json_object, json_schema, lark_grammar, regex. + [JsonPropertyName("type")] + public string Type { get; set; } = "text"; + + [JsonPropertyName("name")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Name { get; set; } + + [JsonPropertyName("description")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; set; } + + [JsonPropertyName("schema")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Schema { get; set; } + + [JsonPropertyName("strict")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Strict { get; set; } + + [JsonPropertyName("grammar")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Grammar { get; set; } +} + +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; } + + [JsonPropertyName("input_tokens_details")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public InputTokensDetails? InputTokensDetails { get; set; } + + [JsonPropertyName("output_tokens_details")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public OutputTokensDetails? OutputTokensDetails { get; set; } +} + +public class InputTokensDetails +{ + [JsonPropertyName("cached_tokens")] + public int CachedTokens { get; set; } +} + +public class OutputTokensDetails +{ + [JsonPropertyName("reasoning_tokens")] + public int ReasoningTokens { get; set; } +} + +public class ResponseError +{ + [JsonPropertyName("code")] + public required string Code { get; set; } + + [JsonPropertyName("message")] + public required string Message { get; set; } +} + +public class IncompleteDetails +{ + [JsonPropertyName("reason")] + public required string Reason { get; set; } +} + +// ===================================================================================================================== +// Request & Response objects +// ===================================================================================================================== + +/// Request body for POST /v1/responses. +public class ResponseCreateRequest +{ + [JsonPropertyName("model")] + public required string Model { get; set; } + + [JsonPropertyName("input")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ResponseInput? Input { get; set; } + + [JsonPropertyName("instructions")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Instructions { get; set; } + + [JsonPropertyName("previous_response_id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? PreviousResponseId { get; set; } + + [JsonPropertyName("tools")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Tools { get; set; } + + [JsonPropertyName("tool_choice")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ToolChoice? ToolChoice { get; set; } + + [JsonPropertyName("temperature")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public float? Temperature { get; set; } + + [JsonPropertyName("top_p")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public float? TopP { get; set; } + + [JsonPropertyName("presence_penalty")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public float? PresencePenalty { get; set; } + + [JsonPropertyName("frequency_penalty")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public float? FrequencyPenalty { get; set; } + + [JsonPropertyName("max_output_tokens")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? MaxOutputTokens { get; set; } + + [JsonPropertyName("parallel_tool_calls")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? ParallelToolCalls { get; set; } + + [JsonPropertyName("truncation")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TruncationValue? Truncation { get; set; } + + [JsonPropertyName("store")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Store { get; set; } + + [JsonPropertyName("metadata")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Metadata { get; set; } + + [JsonPropertyName("stream")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Stream { get; set; } + + [JsonPropertyName("reasoning")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ReasoningConfig? Reasoning { get; set; } + + [JsonPropertyName("text")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TextConfig? Text { get; set; } + + [JsonPropertyName("seed")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? Seed { get; set; } + + [JsonPropertyName("user")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? User { get; set; } +} + +/// The Response resource 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 ResponseStatus Status { get; set; } + + [JsonPropertyName("incomplete_details")] + public IncompleteDetails? 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")] + public ToolChoice? ToolChoice { get; set; } + + [JsonPropertyName("truncation")] + public TruncationValue? Truncation { get; set; } + + [JsonPropertyName("parallel_tool_calls")] + public bool ParallelToolCalls { get; set; } + + [JsonPropertyName("text")] + public TextConfig? Text { get; set; } + + [JsonPropertyName("top_p")] + public float? TopP { get; set; } + + [JsonPropertyName("presence_penalty")] + public float? PresencePenalty { get; set; } + + [JsonPropertyName("frequency_penalty")] + public float? FrequencyPenalty { get; set; } + + [JsonPropertyName("temperature")] + public float? Temperature { get; set; } + + [JsonPropertyName("reasoning")] + public ReasoningConfig? Reasoning { get; set; } + + [JsonPropertyName("usage")] + public ResponseUsage? Usage { get; set; } + + [JsonPropertyName("max_output_tokens")] + public int? MaxOutputTokens { get; set; } + + [JsonPropertyName("store")] + public bool Store { get; set; } + + [JsonPropertyName("metadata")] + public Dictionary? Metadata { get; set; } + + [JsonPropertyName("user")] + public string? User { get; set; } + + /// + /// Convenience: concatenates text from assistant instances in . + /// Returns empty string if no assistant message is found. + /// + [JsonIgnore] + public string OutputText + { + get + { + var sb = new System.Text.StringBuilder(); + foreach (var item in Output) + { + if (item is MessageItem msg && msg.Role == MessageRole.Assistant) + { + sb.Append(msg.Content.GetText()); + } + } + + return sb.ToString(); + } + } +} + +// ===================================================================================================================== +// Listing/Delete results +// ===================================================================================================================== + +public class DeleteResponseResult +{ + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("object")] + public string ObjectType { get; set; } = "response"; + + [JsonPropertyName("deleted")] + public bool Deleted { get; set; } +} + +public class InputItemsListResponse +{ + [JsonPropertyName("object")] + public string ObjectType { get; set; } = "list"; + + [JsonPropertyName("data")] + public List Data { get; set; } = []; + + [JsonPropertyName("first_id")] + public string? FirstId { get; set; } + + [JsonPropertyName("last_id")] + public string? LastId { get; set; } + + [JsonPropertyName("has_more")] + public bool HasMore { get; set; } +} + +public class ListResponsesResult +{ + [JsonPropertyName("object")] + public string ObjectType { get; set; } = "list"; + + [JsonPropertyName("data")] + public List Data { get; set; } = []; + + [JsonPropertyName("first_id")] + public string? FirstId { get; set; } + + [JsonPropertyName("last_id")] + public string? LastId { get; set; } + + [JsonPropertyName("has_more")] + public bool HasMore { get; set; } +} + +// ===================================================================================================================== +// Streaming Events (polymorphic) +// ===================================================================================================================== + +[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] +[JsonDerivedType(typeof(ResponseCreatedEvent), "response.created")] +[JsonDerivedType(typeof(ResponseQueuedEvent), "response.queued")] +[JsonDerivedType(typeof(ResponseInProgressEvent), "response.in_progress")] +[JsonDerivedType(typeof(ResponseCompletedEvent), "response.completed")] +[JsonDerivedType(typeof(ResponseFailedEvent), "response.failed")] +[JsonDerivedType(typeof(ResponseIncompleteEvent), "response.incomplete")] +[JsonDerivedType(typeof(OutputItemAddedEvent), "response.output_item.added")] +[JsonDerivedType(typeof(OutputItemDoneEvent), "response.output_item.done")] +[JsonDerivedType(typeof(ReasoningSummaryPartAddedEvent), "response.reasoning_summary_part.added")] +[JsonDerivedType(typeof(ReasoningSummaryPartDoneEvent), "response.reasoning_summary_part.done")] +[JsonDerivedType(typeof(ContentPartAddedEvent), "response.content_part.added")] +[JsonDerivedType(typeof(ContentPartDoneEvent), "response.content_part.done")] +[JsonDerivedType(typeof(OutputTextDeltaEvent), "response.output_text.delta")] +[JsonDerivedType(typeof(OutputTextDoneEvent), "response.output_text.done")] +[JsonDerivedType(typeof(RefusalDeltaEvent), "response.refusal.delta")] +[JsonDerivedType(typeof(RefusalDoneEvent), "response.refusal.done")] +[JsonDerivedType(typeof(ReasoningDeltaEvent), "response.reasoning.delta")] +[JsonDerivedType(typeof(ReasoningDoneEvent), "response.reasoning.done")] +[JsonDerivedType(typeof(ReasoningSummaryDeltaEvent), "response.reasoning_summary_text.delta")] +[JsonDerivedType(typeof(ReasoningSummaryDoneEvent), "response.reasoning_summary_text.done")] +[JsonDerivedType(typeof(OutputTextAnnotationAddedEvent), "response.output_text.annotation.added")] +[JsonDerivedType(typeof(FunctionCallArgumentsDeltaEvent), "response.function_call_arguments.delta")] +[JsonDerivedType(typeof(FunctionCallArgumentsDoneEvent), "response.function_call_arguments.done")] +[JsonDerivedType(typeof(ErrorEvent), "error")] +public abstract class StreamingEvent +{ + /// The event type discriminator (e.g. "response.created"). + [JsonIgnore] + public abstract string EventType { get; } + + [JsonPropertyName("sequence_number")] + public int SequenceNumber { get; set; } +} + +public sealed class ResponseCreatedEvent : StreamingEvent +{ + [JsonIgnore] public override string EventType => "response.created"; + [JsonPropertyName("response")] public required ResponseObject Response { get; set; } +} + +public sealed class ResponseQueuedEvent : StreamingEvent +{ + [JsonIgnore] public override string EventType => "response.queued"; + [JsonPropertyName("response")] public required ResponseObject Response { get; set; } +} + +public sealed class ResponseInProgressEvent : StreamingEvent +{ + [JsonIgnore] public override string EventType => "response.in_progress"; + [JsonPropertyName("response")] public required ResponseObject Response { get; set; } +} + +public sealed class ResponseCompletedEvent : StreamingEvent +{ + [JsonIgnore] public override string EventType => "response.completed"; + [JsonPropertyName("response")] public required ResponseObject Response { get; set; } +} + +public sealed class ResponseFailedEvent : StreamingEvent +{ + [JsonIgnore] public override string EventType => "response.failed"; + [JsonPropertyName("response")] public required ResponseObject Response { get; set; } +} + +public sealed class ResponseIncompleteEvent : StreamingEvent +{ + [JsonIgnore] public override string EventType => "response.incomplete"; + [JsonPropertyName("response")] public required ResponseObject Response { get; set; } +} + +public sealed class OutputItemAddedEvent : StreamingEvent +{ + [JsonIgnore] public override string EventType => "response.output_item.added"; + [JsonPropertyName("item_id")] public required string ItemId { get; set; } + [JsonPropertyName("output_index")] public int OutputIndex { get; set; } + [JsonPropertyName("item")] public required ResponseItem Item { get; set; } +} + +public sealed class OutputItemDoneEvent : StreamingEvent +{ + [JsonIgnore] public override string EventType => "response.output_item.done"; + [JsonPropertyName("item_id")] public required string ItemId { get; set; } + [JsonPropertyName("output_index")] public int OutputIndex { get; set; } + [JsonPropertyName("item")] public required ResponseItem Item { get; set; } +} + +public sealed class ContentPartAddedEvent : StreamingEvent +{ + [JsonIgnore] public override string EventType => "response.content_part.added"; + [JsonPropertyName("item_id")] public required string ItemId { get; set; } + [JsonPropertyName("output_index")] public int OutputIndex { get; set; } + [JsonPropertyName("content_index")] public int ContentIndex { get; set; } + [JsonPropertyName("part")] public required ContentPart Part { get; set; } +} + +public sealed class ContentPartDoneEvent : StreamingEvent +{ + [JsonIgnore] public override string EventType => "response.content_part.done"; + [JsonPropertyName("item_id")] public required string ItemId { get; set; } + [JsonPropertyName("output_index")] public int OutputIndex { get; set; } + [JsonPropertyName("content_index")] public int ContentIndex { get; set; } + [JsonPropertyName("part")] public required ContentPart Part { get; set; } +} + +public sealed class OutputTextDeltaEvent : StreamingEvent +{ + [JsonIgnore] public override string EventType => "response.output_text.delta"; + [JsonPropertyName("item_id")] public required string ItemId { get; set; } + [JsonPropertyName("output_index")] public int OutputIndex { get; set; } + [JsonPropertyName("content_index")] public int ContentIndex { get; set; } + [JsonPropertyName("delta")] public required string Delta { get; set; } +} + +public sealed class OutputTextDoneEvent : StreamingEvent +{ + [JsonIgnore] public override string EventType => "response.output_text.done"; + [JsonPropertyName("item_id")] public required string ItemId { get; set; } + [JsonPropertyName("output_index")] public int OutputIndex { get; set; } + [JsonPropertyName("content_index")] public int ContentIndex { get; set; } + [JsonPropertyName("text")] public required string Text { get; set; } +} + +public sealed class RefusalDeltaEvent : StreamingEvent +{ + [JsonIgnore] public override string EventType => "response.refusal.delta"; + [JsonPropertyName("item_id")] public required string ItemId { get; set; } + [JsonPropertyName("output_index")] public int OutputIndex { get; set; } + [JsonPropertyName("content_index")] public int ContentIndex { get; set; } + [JsonPropertyName("delta")] public required string Delta { get; set; } +} + +public sealed class RefusalDoneEvent : StreamingEvent +{ + [JsonIgnore] public override string EventType => "response.refusal.done"; + [JsonPropertyName("item_id")] public required string ItemId { get; set; } + [JsonPropertyName("output_index")] public int OutputIndex { get; set; } + [JsonPropertyName("content_index")] public int ContentIndex { get; set; } + [JsonPropertyName("refusal")] public required string Refusal { get; set; } +} + +public sealed class FunctionCallArgumentsDeltaEvent : StreamingEvent +{ + [JsonIgnore] public override string EventType => "response.function_call_arguments.delta"; + [JsonPropertyName("item_id")] public required string ItemId { get; set; } + [JsonPropertyName("output_index")] public int OutputIndex { get; set; } + [JsonPropertyName("call_id")] public string? CallId { get; set; } + [JsonPropertyName("delta")] public required string Delta { get; set; } +} + +public sealed class FunctionCallArgumentsDoneEvent : StreamingEvent +{ + [JsonIgnore] public override string EventType => "response.function_call_arguments.done"; + [JsonPropertyName("item_id")] public required string ItemId { get; set; } + [JsonPropertyName("output_index")] public int OutputIndex { get; set; } + [JsonPropertyName("call_id")] public string? CallId { get; set; } + [JsonPropertyName("arguments")] public required string Arguments { get; set; } + [JsonPropertyName("name")] public string? Name { get; set; } +} + +public sealed class ReasoningSummaryPartAddedEvent : StreamingEvent +{ + [JsonIgnore] public override string EventType => "response.reasoning_summary_part.added"; + [JsonPropertyName("item_id")] public required string ItemId { get; set; } + [JsonPropertyName("output_index")] public int OutputIndex { get; set; } + [JsonPropertyName("summary_index")] public int SummaryIndex { get; set; } + [JsonPropertyName("part")] public required ContentPart Part { get; set; } +} + +public sealed class ReasoningSummaryPartDoneEvent : StreamingEvent +{ + [JsonIgnore] public override string EventType => "response.reasoning_summary_part.done"; + [JsonPropertyName("item_id")] public required string ItemId { get; set; } + [JsonPropertyName("output_index")] public int OutputIndex { get; set; } + [JsonPropertyName("summary_index")] public int SummaryIndex { get; set; } + [JsonPropertyName("part")] public required ContentPart Part { get; set; } +} + +public sealed class ReasoningDeltaEvent : StreamingEvent +{ + [JsonIgnore] public override string EventType => "response.reasoning.delta"; + [JsonPropertyName("item_id")] public required string ItemId { get; set; } + [JsonPropertyName("output_index")] public int OutputIndex { get; set; } + [JsonPropertyName("content_index")] public int ContentIndex { get; set; } + [JsonPropertyName("delta")] public required string Delta { get; set; } +} + +public sealed class ReasoningDoneEvent : StreamingEvent +{ + [JsonIgnore] public override string EventType => "response.reasoning.done"; + [JsonPropertyName("item_id")] public required string ItemId { get; set; } + [JsonPropertyName("output_index")] public int OutputIndex { get; set; } + [JsonPropertyName("content_index")] public int ContentIndex { get; set; } + [JsonPropertyName("text")] public required string Text { get; set; } +} + +public sealed class ReasoningSummaryDeltaEvent : StreamingEvent +{ + [JsonIgnore] public override string EventType => "response.reasoning_summary_text.delta"; + [JsonPropertyName("item_id")] public required string ItemId { get; set; } + [JsonPropertyName("output_index")] public int OutputIndex { get; set; } + [JsonPropertyName("summary_index")] public int SummaryIndex { get; set; } + [JsonPropertyName("delta")] public required string Delta { get; set; } +} + +public sealed class ReasoningSummaryDoneEvent : StreamingEvent +{ + [JsonIgnore] public override string EventType => "response.reasoning_summary_text.done"; + [JsonPropertyName("item_id")] public required string ItemId { get; set; } + [JsonPropertyName("output_index")] public int OutputIndex { get; set; } + [JsonPropertyName("summary_index")] public int SummaryIndex { get; set; } + [JsonPropertyName("text")] public required string Text { get; set; } +} + +public sealed class OutputTextAnnotationAddedEvent : StreamingEvent +{ + [JsonIgnore] public override string EventType => "response.output_text.annotation.added"; + [JsonPropertyName("item_id")] public required string ItemId { get; set; } + [JsonPropertyName("output_index")] public int OutputIndex { get; set; } + [JsonPropertyName("content_index")] public int ContentIndex { get; set; } + [JsonPropertyName("annotation_index")] public int AnnotationIndex { get; set; } + [JsonPropertyName("annotation")] public Annotation? Annotation { get; set; } +} + +public sealed class ErrorEvent : StreamingEvent +{ + [JsonIgnore] public override string EventType => "error"; + + [JsonPropertyName("code")] public string? Code { get; set; } + [JsonPropertyName("message")] public string? Message { get; set; } + [JsonPropertyName("param")] public string? Param { get; set; } +} + +// ===================================================================================================================== +// API error envelope +// ===================================================================================================================== + +public class ApiErrorDetail +{ + [JsonPropertyName("message")] + public string? Message { get; set; } + + [JsonPropertyName("type")] + public string? Type { get; set; } + + [JsonPropertyName("param")] + public string? Param { get; set; } + + [JsonPropertyName("code")] + public string? Code { get; set; } +} + +public class ApiErrorResponse +{ + [JsonPropertyName("error")] + public ApiErrorDetail? Error { get; set; } +} diff --git a/sdk/cs/test/FoundryLocal.Tests/ResponsesClientTests.cs b/sdk/cs/test/FoundryLocal.Tests/ResponsesClientTests.cs new file mode 100644 index 00000000..a7c0c975 --- /dev/null +++ b/sdk/cs/test/FoundryLocal.Tests/ResponsesClientTests.cs @@ -0,0 +1,439 @@ +// -------------------------------------------------------------------------------------------------------------------- +// +// Copyright (c) Microsoft. All rights reserved. +// +// -------------------------------------------------------------------------------------------------------------------- + +namespace Microsoft.AI.Foundry.Local.Tests; + +using System; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; + +using Microsoft.AI.Foundry.Local.OpenAI.Responses; + +using RichardSzalay.MockHttp; + +internal sealed class ResponsesClientTests +{ + private const string BaseUrl = "http://localhost:5273"; + + // ----------------------------------------------------------------------------------------------------------------- + // Settings / defaults + // ----------------------------------------------------------------------------------------------------------------- + + [Test] + public async Task Settings_Store_Defaults_To_Null() + { + var settings = new ResponsesClientSettings(); + await Assert.That(settings.Store).IsNull(); + } + + [Test] + public async Task Settings_Apply_Fills_Only_Unset_Fields() + { + var settings = new ResponsesClientSettings + { + Temperature = 0.3f, + MaxOutputTokens = 64, + Store = false, + }; + + var request = new ResponseCreateRequest + { + Model = "m", + Temperature = 0.9f, // already set; settings must NOT override + }; + settings.ApplyTo(request); + + await Assert.That(request.Temperature).IsEqualTo(0.9f); + await Assert.That(request.MaxOutputTokens).IsEqualTo(64); + await Assert.That(request.Store).IsEqualTo(false); + } + + // ----------------------------------------------------------------------------------------------------------------- + // Input validation + // ----------------------------------------------------------------------------------------------------------------- + + [Test] + public async Task CreateAsync_Empty_String_Throws() + { + using var client = new OpenAIResponsesClient(BaseUrl, "m"); + await Assert.That(async () => await client.CreateAsync(string.Empty)) + .Throws(); + } + + [Test] + public async Task CreateAsync_Null_String_Throws() + { + using var client = new OpenAIResponsesClient(BaseUrl, "m"); + await Assert.That(async () => await client.CreateAsync((string)null!)) + .Throws(); + } + + [Test] + public async Task CreateAsync_Empty_List_Throws() + { + using var client = new OpenAIResponsesClient(BaseUrl, "m"); + await Assert.That(async () => await client.CreateAsync(new List())) + .Throws(); + } + + [Test] + public async Task GetAsync_Empty_Id_Throws() + { + using var client = new OpenAIResponsesClient(BaseUrl, "m"); + await Assert.That(async () => await client.GetAsync("")) + .Throws(); + } + + // ----------------------------------------------------------------------------------------------------------------- + // OutputText convenience + // ----------------------------------------------------------------------------------------------------------------- + + [Test] + public async Task OutputText_Concatenates_Assistant_Messages() + { + var response = new ResponseObject + { + Output = + [ + new MessageItem + { + Role = MessageRole.Assistant, + Content = new MessageContent + { + Parts = + [ + new OutputTextContent { Text = "hello " }, + new OutputTextContent { Text = "world" }, + ], + }, + }, + ], + }; + + await Assert.That(response.OutputText).IsEqualTo("hello world"); + } + + [Test] + public async Task OutputText_Empty_For_No_Assistant_Message() + { + var response = new ResponseObject + { + Output = + [ + new MessageItem + { + Role = MessageRole.User, + Content = MessageContent.FromText("hi"), + }, + ], + }; + + await Assert.That(response.OutputText).IsEqualTo(string.Empty); + } + + // ----------------------------------------------------------------------------------------------------------------- + // InputImageContent factories + // ----------------------------------------------------------------------------------------------------------------- + + [Test] + public async Task InputImageContent_FromBytes_Sets_Data_And_Type() + { + var bytes = new byte[] { 1, 2, 3, 4 }; + var img = InputImageContent.FromBytes(bytes, "image/png", "low"); + + await Assert.That(img.MediaType).IsEqualTo("image/png"); + await Assert.That(img.Detail).IsEqualTo("low"); + await Assert.That(img.ImageData).IsEqualTo(Convert.ToBase64String(bytes)); + await Assert.That(img.Kind).IsEqualTo("input_image"); + } + + [Test] + public async Task InputImageContent_FromFile_Reads_And_Detects_Png() + { + var path = Path.Combine(Path.GetTempPath(), $"test-{Guid.NewGuid():N}.png"); + var bytes = new byte[] { 0x89, 0x50, 0x4E, 0x47 }; + await File.WriteAllBytesAsync(path, bytes); + try + { + var img = InputImageContent.FromFile(path); + await Assert.That(img.MediaType).IsEqualTo("image/png"); + await Assert.That(img.ImageData).IsEqualTo(Convert.ToBase64String(bytes)); + } + finally + { + File.Delete(path); + } + } + + [Test] + public async Task InputImageContent_FromUrl_Sets_Url() + { + var img = InputImageContent.FromUrl("https://example.com/x.png"); + await Assert.That(img.ImageUrl).IsEqualTo("https://example.com/x.png"); + await Assert.That(img.ImageData).IsNull(); + } + + [Test] + public async Task InputImageContent_FromFile_Throws_When_Missing() + { + var missing = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.png"); + await Assert.That(() => InputImageContent.FromFile(missing)).Throws(); + } + + [Test] + public async Task InputImageContent_Validate_Throws_When_Both_Set() + { + var img = new InputImageContent { ImageUrl = "https://x/y.png", ImageData = "AAAA" }; + await Assert.That(() => img.Validate()).Throws(); + } + + [Test] + public async Task InputImageContent_Validate_Throws_When_Neither_Set() + { + var img = new InputImageContent(); + await Assert.That(() => img.Validate()).Throws(); + } + + // ----------------------------------------------------------------------------------------------------------------- + // Serialization: snake_case wire format + // ----------------------------------------------------------------------------------------------------------------- + + [Test] + public async Task ResponseCreateRequest_Serializes_SnakeCase() + { + var req = new ResponseCreateRequest + { + Model = "phi", + Input = "hi", + MaxOutputTokens = 10, + TopP = 0.5f, + ParallelToolCalls = true, + Store = false, + }; + var json = JsonSerializer.Serialize(req, ResponsesSerializationContext.Default.ResponseCreateRequest); + await Assert.That(json).Contains("\"max_output_tokens\":10"); + await Assert.That(json).Contains("\"top_p\":0.5"); + await Assert.That(json).Contains("\"parallel_tool_calls\":true"); + await Assert.That(json).Contains("\"store\":false"); + await Assert.That(json).Contains("\"input\":\"hi\""); + } + + [Test] + public async Task ResponseObject_Deserializes_Polymorphic_Output() + { + var json = """ + { + "id": "resp_1", + "object": "response", + "created_at": 0, + "status": "completed", + "model": "m", + "output": [ + { + "type": "message", + "role": "assistant", + "content": [ { "type": "output_text", "text": "42" } ] + } + ], + "store": true, + "parallel_tool_calls": false + } + """; + + var obj = JsonSerializer.Deserialize(json, ResponsesSerializationContext.Default.ResponseObject); + await Assert.That(obj).IsNotNull(); + await Assert.That(obj!.OutputText).IsEqualTo("42"); + await Assert.That(obj.Output[0]).IsTypeOf(); + } + + [Test] + public async Task MessageContent_Serializes_String_Form() + { + var req = new ResponseCreateRequest + { + Model = "m", + Input = "plain", + }; + var json = JsonSerializer.Serialize(req, ResponsesSerializationContext.Default.ResponseCreateRequest); + await Assert.That(json).Contains("\"input\":\"plain\""); + } + + [Test] + public async Task StreamingEvent_Deserializes_Known_Types() + { + var json = """{"type":"response.output_text.delta","sequence_number":3,"item_id":"i1","output_index":0,"content_index":0,"delta":"hi"}"""; + var ev = JsonSerializer.Deserialize(json, ResponsesSerializationContext.Default.StreamingEvent); + await Assert.That(ev).IsTypeOf(); + var delta = (OutputTextDeltaEvent)ev!; + await Assert.That(delta.Delta).IsEqualTo("hi"); + await Assert.That(delta.SequenceNumber).IsEqualTo(3); + } + + // ----------------------------------------------------------------------------------------------------------------- + // SSE parsing (via CreateStreamingAsync) + // ----------------------------------------------------------------------------------------------------------------- + + [Test] + public async Task Streaming_Parses_Events_And_Stops_On_Done() + { + var sse = new StringBuilder(); + sse.Append("event: response.created\n"); + sse.Append("data: {\"type\":\"response.created\",\"sequence_number\":0,\"response\":{\"id\":\"r1\",\"object\":\"response\",\"created_at\":0,\"status\":\"in_progress\",\"model\":\"m\",\"output\":[],\"tools\":[],\"parallel_tool_calls\":false,\"store\":true}}\n\n"); + sse.Append("event: response.output_text.delta\n"); + sse.Append("data: {\"type\":\"response.output_text.delta\",\"sequence_number\":1,\"item_id\":\"i1\",\"output_index\":0,\"content_index\":0,\"delta\":\"hi\"}\n\n"); + sse.Append("data: [DONE]\n\n"); + + var mock = new MockHttpMessageHandler(); + mock.When(HttpMethod.Post, BaseUrl + "/v1/responses") + .Respond(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(sse.ToString(), Encoding.UTF8, "text/event-stream"), + }); + + using var http = mock.ToHttpClient(); + using var client = new OpenAIResponsesClient(http, BaseUrl, "m", ownsClient: false); + + var events = new List(); + await foreach (var ev in client.CreateStreamingAsync("hello")) + { + events.Add(ev); + } + + await Assert.That(events.Count).IsEqualTo(2); + await Assert.That(events[0]).IsTypeOf(); + await Assert.That(events[1]).IsTypeOf(); + } + + [Test] + public async Task Streaming_Handles_Multiline_Data() + { + // A payload split across two data: lines should be re-joined with \n. + var sse = "data: {\"type\":\"response.output_text.delta\",\n" + + "data: \"sequence_number\":1,\"item_id\":\"i1\",\"output_index\":0,\"content_index\":0,\"delta\":\"x\"}\n\n" + + "data: [DONE]\n\n"; + + var mock = new MockHttpMessageHandler(); + mock.When(HttpMethod.Post, BaseUrl + "/v1/responses") + .Respond(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(sse, Encoding.UTF8, "text/event-stream"), + }); + + using var http = mock.ToHttpClient(); + using var client = new OpenAIResponsesClient(http, BaseUrl, "m", ownsClient: false); + + var events = new List(); + await foreach (var ev in client.CreateStreamingAsync("hi")) + { + events.Add(ev); + } + + await Assert.That(events.Count).IsEqualTo(1); + await Assert.That(events[0]).IsTypeOf(); + } + + // ----------------------------------------------------------------------------------------------------------------- + // Non-streaming round-trip via mocked HTTP + // ----------------------------------------------------------------------------------------------------------------- + + [Test] + public async Task CreateAsync_Serializes_Request_And_Parses_Response() + { + string? capturedBody = null; + var mock = new MockHttpMessageHandler(); + mock.When(HttpMethod.Post, BaseUrl + "/v1/responses") + .With(req => + { + capturedBody = req.Content?.ReadAsStringAsync().GetAwaiter().GetResult(); + return true; + }) + .Respond("application/json", """ + {"id":"r1","object":"response","created_at":1,"status":"completed","model":"phi","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"42"}]}],"tools":[],"parallel_tool_calls":false,"store":true} + """); + + using var http = mock.ToHttpClient(); + using var client = new OpenAIResponsesClient(http, BaseUrl, "phi", ownsClient: false); + client.Settings.Temperature = 0.1f; + + var result = await client.CreateAsync("What is 7*6?", req => req.MaxOutputTokens = 20); + + await Assert.That(result.Id).IsEqualTo("r1"); + await Assert.That(result.OutputText).IsEqualTo("42"); + await Assert.That(capturedBody).IsNotNull(); + await Assert.That(capturedBody!).Contains("\"stream\":false"); + await Assert.That(capturedBody!).Contains("\"max_output_tokens\":20"); + await Assert.That(capturedBody!).Contains("\"temperature\":0.1"); + await Assert.That(capturedBody!).Contains("\"model\":\"phi\""); + } + + [Test] + public async Task Error_Response_Throws_FoundryLocalException_With_Message() + { + var mock = new MockHttpMessageHandler(); + mock.When(HttpMethod.Post, BaseUrl + "/v1/responses") + .Respond(HttpStatusCode.BadRequest, "application/json", + """{"error":{"message":"bad model","type":"invalid_request_error"}}"""); + + using var http = mock.ToHttpClient(); + using var client = new OpenAIResponsesClient(http, BaseUrl, "phi", ownsClient: false); + + var ex = await Assert.That(async () => await client.CreateAsync("hi")) + .Throws(); + await Assert.That(ex!.Message).Contains("bad model"); + } + + // ----------------------------------------------------------------------------------------------------------------- + // CRUD methods + // ----------------------------------------------------------------------------------------------------------------- + + [Test] + public async Task DeleteAsync_Returns_Result() + { + var mock = new MockHttpMessageHandler(); + mock.When(HttpMethod.Delete, BaseUrl + "/v1/responses/r1") + .Respond("application/json", """{"id":"r1","object":"response","deleted":true}"""); + + using var http = mock.ToHttpClient(); + using var client = new OpenAIResponsesClient(http, BaseUrl, "m", ownsClient: false); + var result = await client.DeleteAsync("r1"); + await Assert.That(result.Deleted).IsTrue(); + await Assert.That(result.Id).IsEqualTo("r1"); + } + + [Test] + public async Task GetInputItemsAsync_Returns_List() + { + var mock = new MockHttpMessageHandler(); + mock.When(HttpMethod.Get, BaseUrl + "/v1/responses/r1/input_items") + .Respond("application/json", + """{"object":"list","data":[{"type":"message","role":"user","content":"hi"}],"has_more":false}"""); + + using var http = mock.ToHttpClient(); + using var client = new OpenAIResponsesClient(http, BaseUrl, "m", ownsClient: false); + var list = await client.GetInputItemsAsync("r1"); + await Assert.That(list.Data.Count).IsEqualTo(1); + await Assert.That(list.Data[0]).IsTypeOf(); + } + + [Test] + public async Task CancelAsync_Posts_To_Cancel_Endpoint() + { + var mock = new MockHttpMessageHandler(); + mock.When(HttpMethod.Post, BaseUrl + "/v1/responses/r1/cancel") + .Respond("application/json", + """{"id":"r1","object":"response","created_at":0,"status":"cancelled","model":"m","output":[],"tools":[],"parallel_tool_calls":false,"store":true}"""); + + using var http = mock.ToHttpClient(); + using var client = new OpenAIResponsesClient(http, BaseUrl, "m", ownsClient: false); + var result = await client.CancelAsync("r1"); + await Assert.That(result.Id).IsEqualTo("r1"); + await Assert.That(result.Status).IsEqualTo(ResponseStatus.Cancelled); + } +} diff --git a/sdk/cs/test/FoundryLocal.Tests/ResponsesIntegrationTests.cs b/sdk/cs/test/FoundryLocal.Tests/ResponsesIntegrationTests.cs new file mode 100644 index 00000000..f2eab2e2 --- /dev/null +++ b/sdk/cs/test/FoundryLocal.Tests/ResponsesIntegrationTests.cs @@ -0,0 +1,177 @@ +// -------------------------------------------------------------------------------------------------------------------- +// +// Copyright (c) Microsoft. All rights reserved. +// +// -------------------------------------------------------------------------------------------------------------------- + +namespace Microsoft.AI.Foundry.Local.Tests; + +using System.Text; +using System.Threading.Tasks; + +using Microsoft.AI.Foundry.Local.OpenAI.Responses; + +/// +/// End-to-end integration tests for . Requires the Foundry Local +/// service to be able to load and serve the configured model. Runs are category-tagged so they can +/// be skipped in CI environments that don't have the model cache available. +/// +internal sealed class ResponsesIntegrationTests +{ + private const string ModelId = "qwen2.5-0.5b-instruct-generic-cpu:4"; + + private static IModel? model; + + [Before(Class)] + public static async Task Setup() + { + var manager = FoundryLocalManager.Instance; + var catalog = await manager.GetCatalogAsync(); + + var m = await catalog.GetModelVariantAsync(ModelId).ConfigureAwait(false); + await Assert.That(m).IsNotNull(); + + await m!.LoadAsync().ConfigureAwait(false); + await Assert.That(await m.IsLoadedAsync()).IsTrue(); + + model = m; + } + + [Test] + public async Task NonStreaming_SimpleString() + { + using var client = await model!.GetResponsesClientAsync(); + var response = await client.CreateAsync("Say the single word: ready").ConfigureAwait(false); + + await Assert.That(response).IsNotNull(); + await Assert.That(response.OutputText).IsNotNull().And.IsNotEmpty(); + Console.WriteLine($"[NonStreaming_SimpleString] {response.OutputText}"); + } + + [Test] + public async Task NonStreaming_WithOptions() + { + using var client = await model!.GetResponsesClientAsync(); + var response = await client.CreateAsync( + "What is 7 * 6? Respond only with the number.", + r => + { + r.MaxOutputTokens = 32; + r.Temperature = 0.0f; + r.Instructions = "You are a calculator. Respond precisely."; + }).ConfigureAwait(false); + + await Assert.That(response.OutputText).Contains("42"); + } + + [Test] + public async Task NonStreaming_StructuredInput() + { + using var client = await model!.GetResponsesClientAsync(); + + var items = new List + { + new MessageItem + { + Role = MessageRole.User, + Content = MessageContent.FromParts(new InputTextContent { Text = "Say: hello" }), + }, + }; + + var response = await client.CreateAsync(items, r => r.MaxOutputTokens = 16).ConfigureAwait(false); + await Assert.That(response.OutputText).IsNotEmpty(); + } + + [Test] + public async Task MultiTurn_PreviousResponseId() + { + using var client = await model!.GetResponsesClientAsync(); + var first = await client.CreateAsync( + "Remember the number 17.", + r => { r.MaxOutputTokens = 32; r.Store = true; }).ConfigureAwait(false); + + await Assert.That(first.Id).IsNotNull().And.IsNotEmpty(); + + var second = await client.CreateAsync( + "What was the number?", + r => + { + r.PreviousResponseId = first.Id; + r.MaxOutputTokens = 32; + r.Temperature = 0.0f; + }).ConfigureAwait(false); + + // The small qwen model may or may not recall the exact number — what we really + // validate is that the multi-turn wiring (previous_response_id) produces a response + // that continues the conversation. Don't assert on model content. + await Assert.That(second.Id).IsNotNull().And.IsNotEmpty(); + await Assert.That(second.OutputText).IsNotEmpty(); + } + + [Test] + public async Task Streaming_ReceivesDeltaEvents() + { + using var client = await model!.GetResponsesClientAsync(); + + var sawDelta = false; + var sawCompleted = false; + var aggregate = new StringBuilder(); + + await foreach (var evt in client.CreateStreamingAsync( + "Count from 1 to 3.", + r => { r.MaxOutputTokens = 64; r.Temperature = 0.0f; })) + { + if (evt is OutputTextDeltaEvent delta) + { + sawDelta = true; + aggregate.Append(delta.Delta); + } + else if (evt is ResponseCompletedEvent) + { + sawCompleted = true; + } + } + + await Assert.That(sawDelta).IsTrue(); + await Assert.That(sawCompleted).IsTrue(); + Console.WriteLine($"[Streaming] aggregated: {aggregate}"); + } + + [Test] + public async Task GetStoredResponse() + { + using var client = await model!.GetResponsesClientAsync(); + var created = await client.CreateAsync("Say: stored", r => { r.Store = true; r.MaxOutputTokens = 16; }); + var fetched = await client.GetAsync(created.Id); + await Assert.That(fetched.Id).IsEqualTo(created.Id); + } + + [Test] + public async Task DeleteResponse() + { + using var client = await model!.GetResponsesClientAsync(); + var created = await client.CreateAsync("Say: delete-me", r => { r.Store = true; r.MaxOutputTokens = 16; }); + var result = await client.DeleteAsync(created.Id); + await Assert.That(result.Deleted).IsTrue(); + } + + [Test] + public async Task ListResponses() + { + using var client = await model!.GetResponsesClientAsync(); + _ = await client.CreateAsync("Hello", r => { r.Store = true; r.MaxOutputTokens = 8; }); + var list = await client.ListAsync(); + await Assert.That(list).IsNotNull(); + await Assert.That(list.Data).IsNotNull(); + } + + [Test] + public async Task GetInputItems() + { + using var client = await model!.GetResponsesClientAsync(); + var created = await client.CreateAsync("Hi", r => { r.Store = true; r.MaxOutputTokens = 8; }); + var items = await client.GetInputItemsAsync(created.Id); + await Assert.That(items).IsNotNull(); + await Assert.That(items.Data).IsNotNull(); + } +} diff --git a/sdk/cs/test/FoundryLocal.Tests/Utils.cs b/sdk/cs/test/FoundryLocal.Tests/Utils.cs index a289011b..fe968df1 100644 --- a/sdk/cs/test/FoundryLocal.Tests/Utils.cs +++ b/sdk/cs/test/FoundryLocal.Tests/Utils.cs @@ -443,7 +443,8 @@ private static string GetRepoRoot() while (dir != null) { - if (Directory.Exists(Path.Combine(dir.FullName, ".git"))) + var gitPath = Path.Combine(dir.FullName, ".git"); + if (Directory.Exists(gitPath) || File.Exists(gitPath)) return dir.FullName; dir = dir.Parent; From 072a4bbd6cea90ee34c11b2baa14b5b08a0de216 Mon Sep 17 00:00:00 2001 From: MaanavD Date: Wed, 29 Apr 2026 15:21:04 -0400 Subject: [PATCH 2/4] Switch C# Responses client to official OpenAI package Replaces the hand-rolled Responses DTO/SSE/serialization layer with the official `OpenAI` 2.10.0 NuGet package's `OpenAI.Responses.ResponsesClient` while keeping `Microsoft.AI.Foundry.Local.OpenAIResponsesClient` as the public Foundry Local-shaped wrapper. - Public surface unchanged: `OpenAIResponsesClient` ctor, `Settings`, `CreateAsync` / `CreateStreamingAsync` / `GetAsync` / `DeleteAsync` / `CancelAsync` / `GetInputItemsAsync` / `ListAsync` - Endpoints come from `OpenAI.Responses.ResponsesClient` configured with the Foundry Local web service URL via `OpenAIClientOptions.Endpoint`; only `ListAsync` keeps a small `HttpClient` shim for Foundry Local's list-responses extension (the official client doesn't expose that yet) - `ResponsesClientSettings` keeps the same Foundry Local-shaped knobs but applies them onto official `CreateResponseOptions` (`MaxOutputTokenCount`, `StoredOutputEnabled`, `ParallelToolCallsEnabled`, etc.) - New `ResponseContentPartHelpers` produces official `ResponseContentPart` instances for vision: file (auto-detect MIME, throws on missing/unknown extension), bytes (data URI), and URL - Vision now uses official OpenAI shape (`input_image` + `image_url` data URI) rather than Foundry Local's `image_data` + `media_type` extension - Deleted the hand-rolled `ResponsesTypes.cs` polymorphic DTO surface and the source-gen `ResponsesSerializationContext` Bug fixes: - Avoid `op_Implicit(null)` on `ResponseImageDetailLevel` and `ResponseItemCollectionOrder` value-type structs when callers pass null/empty options - Map `ClientResultException` -> `FoundryLocalException` consistently across CRUD methods Tests: - Unit tests rewritten for the official surface; cover settings defaults, input validation, image helper factories, MIME detection, and `ListResponsesResult.FromJson` parsing - Integration tests updated to use `CreateResponseOptions` / `ResponseItem` / `StreamingResponseUpdate` types - `dotnet build` clean; all Responses tests pass (51/61 overall, the 10 failures are pre-existing `EmbeddingClientTests` infra) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk/cs/src/Microsoft.AI.Foundry.Local.csproj | 12 +- sdk/cs/src/OpenAI/ResponsesClient.cs | 463 +++--- .../OpenAI/ResponsesSerializationContext.cs | 82 - sdk/cs/src/OpenAI/ResponsesTypes.cs | 1357 +---------------- .../ResponsesClientTests.cs | 354 +---- .../ResponsesIntegrationTests.cs | 47 +- 6 files changed, 349 insertions(+), 1966 deletions(-) delete mode 100644 sdk/cs/src/OpenAI/ResponsesSerializationContext.cs diff --git a/sdk/cs/src/Microsoft.AI.Foundry.Local.csproj b/sdk/cs/src/Microsoft.AI.Foundry.Local.csproj index df8fc2cf..56050e65 100644 --- a/sdk/cs/src/Microsoft.AI.Foundry.Local.csproj +++ b/sdk/cs/src/Microsoft.AI.Foundry.Local.csproj @@ -72,8 +72,7 @@ - + @@ -121,14 +120,13 @@ $(NoWarn);NU1604 - - + + - + + diff --git a/sdk/cs/src/OpenAI/ResponsesClient.cs b/sdk/cs/src/OpenAI/ResponsesClient.cs index a8fe2908..8671dd99 100644 --- a/sdk/cs/src/OpenAI/ResponsesClient.cs +++ b/sdk/cs/src/OpenAI/ResponsesClient.cs @@ -4,21 +4,22 @@ // // -------------------------------------------------------------------------------------------------------------------- +#pragma warning disable OPENAI001 // OpenAI Responses APIs are experimental in the official OpenAI package. + namespace Microsoft.AI.Foundry.Local; using System; using System.Collections.Generic; using System.IO; using System.Net.Http; -using System.Net.Http.Headers; -using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; using System.Threading; -using System.Threading.Channels; using System.Threading.Tasks; using Microsoft.AI.Foundry.Local.OpenAI.Responses; +using OfficialResponses = global::OpenAI.Responses; +using System.ClientModel; /// /// Default-value container for . @@ -37,7 +38,7 @@ public class ResponsesClientSettings public bool? ParallelToolCalls { get; set; } - public TruncationValue? Truncation { get; set; } + public OfficialResponses.ResponseTruncationMode? Truncation { get; set; } /// /// Server-side storage of responses. When null (default), the field is omitted @@ -49,40 +50,52 @@ public class ResponsesClientSettings public Dictionary? Metadata { get; set; } - public ReasoningConfig? Reasoning { get; set; } + public OfficialResponses.ResponseReasoningOptions? Reasoning { get; set; } - public TextConfig? Text { get; set; } + public OfficialResponses.ResponseTextOptions? Text { get; set; } public string? User { get; set; } - internal void ApplyTo(ResponseCreateRequest request) + internal void ApplyTo(OfficialResponses.CreateResponseOptions request) { request.Instructions ??= Instructions; request.Temperature ??= Temperature; request.TopP ??= TopP; - request.MaxOutputTokens ??= MaxOutputTokens; - request.ParallelToolCalls ??= ParallelToolCalls; - request.Truncation ??= Truncation; - request.Store ??= Store; - request.Metadata ??= Metadata; - request.Reasoning ??= Reasoning; - request.Text ??= Text; - request.User ??= User; + request.MaxOutputTokenCount ??= MaxOutputTokens; + request.ParallelToolCallsEnabled ??= ParallelToolCalls; + request.TruncationMode ??= Truncation; + request.StoredOutputEnabled ??= Store; + request.ReasoningOptions ??= Reasoning; + request.TextOptions ??= Text; + request.EndUserId ??= User; + + if (Metadata is not null) + { + foreach (var (key, value) in Metadata) + { + request.Metadata.TryAdd(key, value); + } + } } } /// -/// HTTP client for the OpenAI Responses API served by Foundry Local. -/// Uses directly — no FFI/native interop. +/// Client for the OpenAI Responses API served by Foundry Local. +/// Uses the official for standard Responses endpoints and a small HTTP +/// shim for Foundry Local's list-responses extension. /// [System.Diagnostics.CodeAnalysis.SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP007:Don't dispose injected", Justification = "Client only disposes HttpClient when it owns it (ownsClient flag tracked at construction).")] [System.Diagnostics.CodeAnalysis.SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP008:Don't assign member with injected and created disposables", Justification = "Client owns HttpClient when constructed without one.")] [System.Diagnostics.CodeAnalysis.SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP014:Use a single instance of HttpClient", Justification = "Short-lived per-client HttpClient matches SDK pattern; callers share via FoundryLocalManager.")] public sealed class OpenAIResponsesClient : IDisposable { + private const string LocalApiKey = "foundry-local"; + private readonly HttpClient _httpClient; + private readonly OfficialResponses.ResponsesClient _responsesClient; private readonly string _baseUrl; private readonly string? _modelId; + private readonly bool _ownsClient; private bool _disposed; /// Default settings applied to every request. @@ -100,13 +113,19 @@ public sealed class OpenAIResponsesClient : IDisposable /// Base URL of the Foundry Local service (e.g., http://localhost:5273). /// Default model id to use when callers do not set one explicitly. public OpenAIResponsesClient(string baseUrl, string? modelId = null) - : this(CreateDefaultHttpClient(), baseUrl, modelId, ownsClient: true) + : this(CreateDefaultHttpClient(), CreateOfficialClient(baseUrl), baseUrl, modelId, ownsClient: true) { } - internal OpenAIResponsesClient(HttpClient httpClient, string baseUrl, string? modelId = null, bool ownsClient = true) + internal OpenAIResponsesClient( + HttpClient httpClient, + OfficialResponses.ResponsesClient responsesClient, + string baseUrl, + string? modelId = null, + bool ownsClient = true) { ArgumentNullException.ThrowIfNull(httpClient); + ArgumentNullException.ThrowIfNull(responsesClient); if (string.IsNullOrWhiteSpace(baseUrl)) { @@ -114,249 +133,209 @@ internal OpenAIResponsesClient(HttpClient httpClient, string baseUrl, string? mo } _httpClient = httpClient; + _responsesClient = responsesClient; _baseUrl = baseUrl.TrimEnd('/'); _modelId = modelId; _ownsClient = ownsClient; } - // Streaming SSE connections stay open until the server finishes producing events, - // which can exceed HttpClient's 100s default. Disable the built-in timeout and let - // callers enforce request-scoped deadlines via CancellationToken. + // Kept for tests that only exercise Foundry Local extension endpoints. + internal OpenAIResponsesClient(HttpClient httpClient, string baseUrl, string? modelId = null, bool ownsClient = true) + : this(httpClient, CreateOfficialClient(baseUrl), baseUrl, modelId, ownsClient) + { + } + + // Foundry Local responses streaming may exceed HttpClient's 100s default. Disable the built-in + // timeout for the list-extension client and let callers use CancellationToken for deadlines. private static HttpClient CreateDefaultHttpClient() => new() { Timeout = Timeout.InfiniteTimeSpan }; - private readonly bool _ownsClient; + private static OfficialResponses.ResponsesClient CreateOfficialClient(string baseUrl) + { + if (string.IsNullOrWhiteSpace(baseUrl)) + { + throw new ArgumentException("baseUrl must be non-empty.", nameof(baseUrl)); + } + + var endpoint = new Uri(baseUrl.TrimEnd('/') + "/v1"); + return new OfficialResponses.ResponsesClient( + new ApiKeyCredential(LocalApiKey), + new global::OpenAI.OpenAIClientOptions { Endpoint = endpoint }); + } // ----------------------------------------------------------------------------------------------------------------- // Create (non-streaming) // ----------------------------------------------------------------------------------------------------------------- - public Task CreateAsync(string input, CancellationToken ct = default) + public Task CreateAsync(string input, CancellationToken ct = default) => CreateAsync(input, configure: null, ct); - public Task CreateAsync(string input, Action? configure, CancellationToken ct = default) + public Task CreateAsync(string input, Action? configure, CancellationToken ct = default) { ValidateStringInput(input); - return CreateAsync(BuildRequest(input, configure), ct); + return CreateAsync(BuildRequest([OfficialResponses.ResponseItem.CreateUserMessageItem(input)], configure), ct); } - public Task CreateAsync(List input, CancellationToken ct = default) + public Task CreateAsync(IEnumerable input, CancellationToken ct = default) => CreateAsync(input, configure: null, ct); - public Task CreateAsync(List input, Action? configure, CancellationToken ct = default) + public Task CreateAsync(IEnumerable input, Action? configure, CancellationToken ct = default) { ValidateListInput(input); return CreateAsync(BuildRequest(input, configure), ct); } - /// Submit a raw request object. - public async Task CreateAsync(ResponseCreateRequest request, CancellationToken ct = default) + /// Submit a raw official OpenAI request object. + public async Task CreateAsync(OfficialResponses.CreateResponseOptions request, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(request); - request.Stream = false; - using var content = SerializeRequest(request); - using var response = await _httpClient.PostAsync(Url("/v1/responses"), content, ct).ConfigureAwait(false); - await EnsureSuccessAsync(response, ct).ConfigureAwait(false); + request.StreamingEnabled = false; + EnsureModel(request); - await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); - var parsed = await JsonSerializer.DeserializeAsync(stream, ResponsesSerializationContext.Default.ResponseObject, ct) - .ConfigureAwait(false); - return parsed ?? throw new FoundryLocalException("Server returned an empty response body."); + try + { + var result = await _responsesClient.CreateResponseAsync(request, ct).ConfigureAwait(false); + return result.Value; + } + catch (ClientResultException ex) + { + throw ToFoundryLocalException(ex); + } } // ----------------------------------------------------------------------------------------------------------------- // Create (streaming) // ----------------------------------------------------------------------------------------------------------------- - public IAsyncEnumerable CreateStreamingAsync(string input, CancellationToken ct = default) + public IAsyncEnumerable CreateStreamingAsync(string input, CancellationToken ct = default) => CreateStreamingAsync(input, configure: null, ct); - public IAsyncEnumerable CreateStreamingAsync(string input, Action? configure, CancellationToken ct = default) + public IAsyncEnumerable CreateStreamingAsync(string input, Action? configure, CancellationToken ct = default) { ValidateStringInput(input); - return CreateStreamingAsync(BuildRequest(input, configure), ct); + return CreateStreamingAsync(BuildRequest([OfficialResponses.ResponseItem.CreateUserMessageItem(input)], configure), ct); } - public IAsyncEnumerable CreateStreamingAsync(List input, CancellationToken ct = default) + public IAsyncEnumerable CreateStreamingAsync(IEnumerable input, CancellationToken ct = default) => CreateStreamingAsync(input, configure: null, ct); - public IAsyncEnumerable CreateStreamingAsync(List input, Action? configure, CancellationToken ct = default) + public IAsyncEnumerable CreateStreamingAsync(IEnumerable input, Action? configure, CancellationToken ct = default) { ValidateListInput(input); return CreateStreamingAsync(BuildRequest(input, configure), ct); } - /// Stream events for a raw request object. - public async IAsyncEnumerable CreateStreamingAsync(ResponseCreateRequest request, [EnumeratorCancellation] CancellationToken ct = default) + /// Stream events for a raw official OpenAI request object. + public async IAsyncEnumerable CreateStreamingAsync( + OfficialResponses.CreateResponseOptions request, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(request); - request.Stream = true; + request.StreamingEnabled = true; + EnsureModel(request); - var channel = Channel.CreateUnbounded(new UnboundedChannelOptions + AsyncCollectionResult updates; + try { - SingleReader = true, - SingleWriter = true, - }); - - var pumpTask = Task.Run(() => PumpSseAsync(request, channel.Writer, ct), ct); - - await foreach (var ev in channel.Reader.ReadAllAsync(ct).ConfigureAwait(false)) + updates = _responsesClient.CreateResponseStreamingAsync(request, ct); + } + catch (ClientResultException ex) { - yield return ev; + throw ToFoundryLocalException(ex); } - // Surface any exception that happened on the background pump. - await pumpTask.ConfigureAwait(false); - } - - private async Task PumpSseAsync(ResponseCreateRequest request, ChannelWriter writer, CancellationToken ct) - { - try + await foreach (var update in updates) { - using var req = new HttpRequestMessage(HttpMethod.Post, Url("/v1/responses")) - { - Content = SerializeRequest(request), - }; - req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); - - using var response = await _httpClient.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); - await EnsureSuccessAsync(response, ct).ConfigureAwait(false); - - await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); - using var reader = new StreamReader(stream, Encoding.UTF8); + yield return update; + } + } - var dataBuilder = new StringBuilder(); - while (!reader.EndOfStream) - { - ct.ThrowIfCancellationRequested(); - var line = await reader.ReadLineAsync(ct).ConfigureAwait(false); - if (line == null) - { - break; - } - - if (line.Length == 0) - { - // End of an SSE event block. - if (dataBuilder.Length > 0) - { - var payload = dataBuilder.ToString(); - dataBuilder.Clear(); - - if (payload == "[DONE]") - { - break; - } - - var ev = ParseStreamingEvent(payload); - if (ev != null) - { - await writer.WriteAsync(ev, ct).ConfigureAwait(false); - } - } - - continue; - } - - if (line.StartsWith("data:", StringComparison.Ordinal)) - { - var data = line.Length > 5 && line[5] == ' ' ? line.Substring(6) : line.Substring(5); - if (dataBuilder.Length > 0) - { - dataBuilder.Append('\n'); - } - - dataBuilder.Append(data); - } - - // `event:`, `id:`, and comment lines (`:`) are ignored — the type is in the JSON payload. - } + // ----------------------------------------------------------------------------------------------------------------- + // CRUD + // ----------------------------------------------------------------------------------------------------------------- - // If stream ended without a blank-line terminator, flush any pending data. - if (dataBuilder.Length > 0) - { - var payload = dataBuilder.ToString(); - if (payload != "[DONE]") - { - var ev = ParseStreamingEvent(payload); - if (ev != null) - { - await writer.WriteAsync(ev, ct).ConfigureAwait(false); - } - } - } + public async Task GetAsync(string responseId, CancellationToken ct = default) + { + ValidateId(responseId); - writer.TryComplete(); - } - catch (OperationCanceledException) + try { - writer.TryComplete(); - throw; + var result = await _responsesClient.GetResponseAsync(responseId, ct).ConfigureAwait(false); + return result.Value; } - catch (Exception ex) + catch (ClientResultException ex) { - writer.TryComplete(ex); + throw ToFoundryLocalException(ex); } } - internal static StreamingEvent? ParseStreamingEvent(string payload) + public async Task DeleteAsync(string responseId, CancellationToken ct = default) { + ValidateId(responseId); + try { - return JsonSerializer.Deserialize(payload, ResponsesSerializationContext.Default.StreamingEvent); + var result = await _responsesClient.DeleteResponseAsync(responseId, ct).ConfigureAwait(false); + return result.Value; } - catch (JsonException) + catch (ClientResultException ex) { - return null; + throw ToFoundryLocalException(ex); } } - // ----------------------------------------------------------------------------------------------------------------- - // CRUD - // ----------------------------------------------------------------------------------------------------------------- - - public async Task GetAsync(string responseId, CancellationToken ct = default) + public async Task CancelAsync(string responseId, CancellationToken ct = default) { ValidateId(responseId); - using var response = await _httpClient.GetAsync(Url($"/v1/responses/{responseId}"), ct).ConfigureAwait(false); - await EnsureSuccessAsync(response, ct).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); - var result = await JsonSerializer.DeserializeAsync(stream, ResponsesSerializationContext.Default.ResponseObject, ct).ConfigureAwait(false); - return result ?? throw new FoundryLocalException("Server returned an empty response body."); - } - public async Task DeleteAsync(string responseId, CancellationToken ct = default) - { - ValidateId(responseId); - using var response = await _httpClient.DeleteAsync(Url($"/v1/responses/{responseId}"), ct).ConfigureAwait(false); - await EnsureSuccessAsync(response, ct).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); - var result = await JsonSerializer.DeserializeAsync(stream, ResponsesSerializationContext.Default.DeleteResponseResult, ct).ConfigureAwait(false); - return result ?? throw new FoundryLocalException("Server returned an empty delete response body."); + try + { + var result = await _responsesClient.CancelResponseAsync(responseId, ct).ConfigureAwait(false); + return result.Value; + } + catch (ClientResultException ex) + { + throw ToFoundryLocalException(ex); + } } - public async Task CancelAsync(string responseId, CancellationToken ct = default) + public async Task GetInputItemsAsync( + string responseId, + int? limit = null, + string? order = null, + string? after = null, + string? before = null, + CancellationToken ct = default) { ValidateId(responseId); - using var content = new StringContent(string.Empty, Encoding.UTF8, "application/json"); - using var response = await _httpClient.PostAsync(Url($"/v1/responses/{responseId}/cancel"), content, ct).ConfigureAwait(false); - await EnsureSuccessAsync(response, ct).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); - var result = await JsonSerializer.DeserializeAsync(stream, ResponsesSerializationContext.Default.ResponseObject, ct).ConfigureAwait(false); - return result ?? throw new FoundryLocalException("Server returned an empty cancel response body."); - } - public async Task GetInputItemsAsync(string responseId, CancellationToken ct = default) - { - ValidateId(responseId); - using var response = await _httpClient.GetAsync(Url($"/v1/responses/{responseId}/input_items"), ct).ConfigureAwait(false); - await EnsureSuccessAsync(response, ct).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); - var result = await JsonSerializer.DeserializeAsync(stream, ResponsesSerializationContext.Default.InputItemsListResponse, ct).ConfigureAwait(false); - return result ?? throw new FoundryLocalException("Server returned an empty input-items response body."); + var options = new OfficialResponses.ResponseItemCollectionOptions(responseId) + { + PageSizeLimit = limit, + AfterId = after, + BeforeId = before, + }; + if (!string.IsNullOrWhiteSpace(order)) + { + options.Order = new OfficialResponses.ResponseItemCollectionOrder(order); + } + + try + { + var result = await _responsesClient.GetResponseInputItemCollectionPageAsync(options, ct).ConfigureAwait(false); + return result.Value; + } + catch (ClientResultException ex) + { + throw ToFoundryLocalException(ex); + } } + /// + /// Lists stored responses using Foundry Local's extension endpoint. The official OpenAI .NET + /// Responses client does not currently expose a typed list-responses method. + /// public async Task ListAsync( int? limit = null, string? order = null, @@ -376,12 +355,13 @@ public async Task ListAsync( { query.Add($"after={Uri.EscapeDataString(after)}"); } + var url = Url("/v1/responses") + (query.Count > 0 ? "?" + string.Join("&", query) : ""); using var response = await _httpClient.GetAsync(url, ct).ConfigureAwait(false); await EnsureSuccessAsync(response, ct).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); - var result = await JsonSerializer.DeserializeAsync(stream, ResponsesSerializationContext.Default.ListResponsesResult, ct).ConfigureAwait(false); - return result ?? throw new FoundryLocalException("Server returned an empty list response body."); + + var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + return ListResponsesResult.FromJson(json); } // ----------------------------------------------------------------------------------------------------------------- @@ -390,59 +370,16 @@ public async Task ListAsync( private string Url(string relative) => _baseUrl + relative; - private ResponseCreateRequest BuildRequest(string input, Action? configure) - { - var request = new ResponseCreateRequest - { - Model = _modelId ?? string.Empty, - Input = input, - }; - Settings.ApplyTo(request); - configure?.Invoke(request); - EnsureModel(request); - ValidateTools(request); - ValidateImageContents(request); - return request; - } - - private ResponseCreateRequest BuildRequest(List input, Action? configure) + private OfficialResponses.CreateResponseOptions BuildRequest(IEnumerable input, Action? configure) { - var request = new ResponseCreateRequest - { - Model = _modelId ?? string.Empty, - Input = input, - }; + var request = new OfficialResponses.CreateResponseOptions(_modelId ?? string.Empty, input); Settings.ApplyTo(request); configure?.Invoke(request); EnsureModel(request); - ValidateTools(request); - ValidateImageContents(request); return request; } - private static void ValidateImageContents(ResponseCreateRequest request) - { - var items = request.Input?.Items; - if (items is null) - { - return; - } - foreach (var item in items) - { - if (item is MessageItem msg && msg.Content?.Parts is { } parts) - { - foreach (var part in parts) - { - if (part is InputImageContent img) - { - img.Validate(); - } - } - } - } - } - - private static void EnsureModel(ResponseCreateRequest request) + private static void EnsureModel(OfficialResponses.CreateResponseOptions request) { if (string.IsNullOrWhiteSpace(request.Model)) { @@ -460,11 +397,12 @@ private static void ValidateStringInput(string input) } } - private static void ValidateListInput(List input) + private static void ValidateListInput(IEnumerable input) { ArgumentNullException.ThrowIfNull(input); - if (input.Count == 0) + using var enumerator = input.GetEnumerator(); + if (!enumerator.MoveNext()) { throw new ArgumentException("Input list must contain at least one item.", nameof(input)); } @@ -474,66 +412,50 @@ private static void ValidateId(string id) { if (string.IsNullOrWhiteSpace(id)) { - throw new ArgumentException("Response id must be non-empty.", nameof(id)); + throw new ArgumentException("responseId must be non-empty.", nameof(id)); } } - private static void ValidateTools(ResponseCreateRequest request) + private static async Task EnsureSuccessAsync(HttpResponseMessage response, CancellationToken ct) { - if (request.Tools == null) + if (response.IsSuccessStatusCode) { return; } - foreach (var tool in request.Tools) - { - if (string.IsNullOrWhiteSpace(tool.Name)) - { - throw new ArgumentException("Tool definition name must be non-empty."); - } - } + var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + var message = TryReadErrorMessage(body) ?? $"{(int)response.StatusCode} {response.ReasonPhrase}"; + throw new FoundryLocalException($"Responses API request failed: {message}"); } - private static StringContent SerializeRequest(ResponseCreateRequest request) + private static string? TryReadErrorMessage(string body) { - var json = JsonSerializer.Serialize(request, ResponsesSerializationContext.Default.ResponseCreateRequest); - return new StringContent(json, Encoding.UTF8, "application/json"); - } - - private static async Task EnsureSuccessAsync(HttpResponseMessage response, CancellationToken ct) - { - if (response.IsSuccessStatusCode) + if (string.IsNullOrWhiteSpace(body)) { - return; + return null; } - string body = string.Empty; try { - body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + using var doc = JsonDocument.Parse(body); + if (doc.RootElement.TryGetProperty("error", out var error) + && error.TryGetProperty("message", out var message)) + { + return message.GetString(); + } } - catch + catch (JsonException) { - // ignore read failure — we still raise for status. + return null; } - // Try to parse the OpenAI-style error envelope for a nicer message. - string? serverMessage = null; - if (!string.IsNullOrWhiteSpace(body)) - { - try - { - var parsed = JsonSerializer.Deserialize(body, ResponsesSerializationContext.Default.ApiErrorResponse); - serverMessage = parsed?.Error?.Message; - } - catch (JsonException) - { - // ignore parse failure — fall through to raw body. - } - } + return null; + } - var message = serverMessage ?? (string.IsNullOrWhiteSpace(body) ? response.ReasonPhrase : body); - throw new FoundryLocalException($"Responses API request failed ({(int)response.StatusCode} {response.ReasonPhrase}): {message}"); + private static FoundryLocalException ToFoundryLocalException(ClientResultException ex) + { + var message = string.IsNullOrWhiteSpace(ex.Message) ? $"HTTP {ex.Status}" : ex.Message; + return new FoundryLocalException($"Responses API request failed: {message}", ex); } public void Dispose() @@ -543,10 +465,13 @@ public void Dispose() return; } - _disposed = true; if (_ownsClient) { _httpClient.Dispose(); } + + _disposed = true; } } + +#pragma warning restore OPENAI001 diff --git a/sdk/cs/src/OpenAI/ResponsesSerializationContext.cs b/sdk/cs/src/OpenAI/ResponsesSerializationContext.cs deleted file mode 100644 index 41e3f511..00000000 --- a/sdk/cs/src/OpenAI/ResponsesSerializationContext.cs +++ /dev/null @@ -1,82 +0,0 @@ -// -------------------------------------------------------------------------------------------------------------------- -// -// Copyright (c) Microsoft. All rights reserved. -// -// -------------------------------------------------------------------------------------------------------------------- - -namespace Microsoft.AI.Foundry.Local.OpenAI.Responses; - -using System.Collections.Generic; -using System.Text.Json.Serialization; - -/// -/// Source-generated JSON serialization context for the Responses API types. -/// This keeps the SDK AOT-compatible and trimming-safe. -/// -[JsonSourceGenerationOptions( - WriteIndented = false, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - PropertyNameCaseInsensitive = true, - UseStringEnumConverter = true)] -[JsonSerializable(typeof(ResponseCreateRequest))] -[JsonSerializable(typeof(ResponseObject))] -[JsonSerializable(typeof(StreamingEvent))] -[JsonSerializable(typeof(ResponseItem))] -[JsonSerializable(typeof(ContentPart))] -[JsonSerializable(typeof(Annotation))] -[JsonSerializable(typeof(DeleteResponseResult))] -[JsonSerializable(typeof(InputItemsListResponse))] -[JsonSerializable(typeof(ListResponsesResult))] -[JsonSerializable(typeof(MessageItem))] -[JsonSerializable(typeof(FunctionCallItem))] -[JsonSerializable(typeof(FunctionCallOutputItem))] -[JsonSerializable(typeof(ReasoningItem))] -[JsonSerializable(typeof(ItemReference))] -[JsonSerializable(typeof(InputTextContent))] -[JsonSerializable(typeof(OutputTextContent))] -[JsonSerializable(typeof(RefusalContent))] -[JsonSerializable(typeof(InputImageContent))] -[JsonSerializable(typeof(InputFileContent))] -[JsonSerializable(typeof(UrlCitationAnnotation))] -[JsonSerializable(typeof(FunctionToolDefinition))] -[JsonSerializable(typeof(SpecificToolChoice))] -[JsonSerializable(typeof(ToolChoice))] -[JsonSerializable(typeof(ReasoningConfig))] -[JsonSerializable(typeof(TextConfig))] -[JsonSerializable(typeof(TextFormat))] -[JsonSerializable(typeof(ResponseUsage))] -[JsonSerializable(typeof(ResponseError))] -[JsonSerializable(typeof(IncompleteDetails))] -[JsonSerializable(typeof(ApiErrorResponse))] -[JsonSerializable(typeof(ResponseCreatedEvent))] -[JsonSerializable(typeof(ResponseQueuedEvent))] -[JsonSerializable(typeof(ResponseInProgressEvent))] -[JsonSerializable(typeof(ResponseCompletedEvent))] -[JsonSerializable(typeof(ResponseFailedEvent))] -[JsonSerializable(typeof(ResponseIncompleteEvent))] -[JsonSerializable(typeof(OutputItemAddedEvent))] -[JsonSerializable(typeof(OutputItemDoneEvent))] -[JsonSerializable(typeof(ContentPartAddedEvent))] -[JsonSerializable(typeof(ContentPartDoneEvent))] -[JsonSerializable(typeof(OutputTextDeltaEvent))] -[JsonSerializable(typeof(OutputTextDoneEvent))] -[JsonSerializable(typeof(RefusalDeltaEvent))] -[JsonSerializable(typeof(RefusalDoneEvent))] -[JsonSerializable(typeof(FunctionCallArgumentsDeltaEvent))] -[JsonSerializable(typeof(FunctionCallArgumentsDoneEvent))] -[JsonSerializable(typeof(ReasoningSummaryPartAddedEvent))] -[JsonSerializable(typeof(ReasoningSummaryPartDoneEvent))] -[JsonSerializable(typeof(ReasoningDeltaEvent))] -[JsonSerializable(typeof(ReasoningDoneEvent))] -[JsonSerializable(typeof(ReasoningSummaryDeltaEvent))] -[JsonSerializable(typeof(ReasoningSummaryDoneEvent))] -[JsonSerializable(typeof(OutputTextAnnotationAddedEvent))] -[JsonSerializable(typeof(ErrorEvent))] -[JsonSerializable(typeof(List))] -[JsonSerializable(typeof(List))] -[JsonSerializable(typeof(List))] -[JsonSerializable(typeof(List))] -[JsonSerializable(typeof(Dictionary))] -internal partial class ResponsesSerializationContext : JsonSerializerContext -{ -} diff --git a/sdk/cs/src/OpenAI/ResponsesTypes.cs b/sdk/cs/src/OpenAI/ResponsesTypes.cs index 608af758..a7fd75c0 100644 --- a/sdk/cs/src/OpenAI/ResponsesTypes.cs +++ b/sdk/cs/src/OpenAI/ResponsesTypes.cs @@ -4,1360 +4,139 @@ // // -------------------------------------------------------------------------------------------------------------------- +#pragma warning disable OPENAI001 // OpenAI Responses APIs are experimental in the official OpenAI package. + namespace Microsoft.AI.Foundry.Local.OpenAI.Responses; using System; using System.Collections.Generic; using System.IO; using System.Text.Json; -using System.Text.Json.Serialization; - -// ===================================================================================================================== -// Enums -// ===================================================================================================================== - -/// The status of a Response object lifecycle. -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum ResponseStatus -{ - [JsonStringEnumMemberName("queued")] Queued, - [JsonStringEnumMemberName("in_progress")] InProgress, - [JsonStringEnumMemberName("completed")] Completed, - [JsonStringEnumMemberName("failed")] Failed, - [JsonStringEnumMemberName("incomplete")] Incomplete, - [JsonStringEnumMemberName("cancelled")] Cancelled, -} - -/// The status of an item within a Response. -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum ResponseItemStatus -{ - [JsonStringEnumMemberName("in_progress")] InProgress, - [JsonStringEnumMemberName("completed")] Completed, - [JsonStringEnumMemberName("incomplete")] Incomplete, -} - -/// The role of a message in a conversation. -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum MessageRole -{ - [JsonStringEnumMemberName("system")] System, - [JsonStringEnumMemberName("user")] User, - [JsonStringEnumMemberName("assistant")] Assistant, - [JsonStringEnumMemberName("developer")] Developer, - [JsonStringEnumMemberName("tool")] Tool, - [JsonStringEnumMemberName("tool_response")] ToolResponse, -} - -/// Controls whether the model may call tools. -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum ToolChoiceValue -{ - [JsonStringEnumMemberName("auto")] Auto, - [JsonStringEnumMemberName("none")] None, - [JsonStringEnumMemberName("required")] Required, -} - -/// Controls truncation behavior when context exceeds limits. -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum TruncationValue -{ - [JsonStringEnumMemberName("auto")] Auto, - [JsonStringEnumMemberName("disabled")] Disabled, -} - -// ===================================================================================================================== -// Content Parts (polymorphic) -// ===================================================================================================================== - -/// Base class for all content parts within items. -[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] -[JsonDerivedType(typeof(InputTextContent), "input_text")] -[JsonDerivedType(typeof(OutputTextContent), "output_text")] -[JsonDerivedType(typeof(RefusalContent), "refusal")] -[JsonDerivedType(typeof(InputImageContent), "input_image")] -[JsonDerivedType(typeof(InputFileContent), "input_file")] -public abstract class ContentPart -{ - /// The type discriminator for the content part. - [JsonIgnore] - public abstract string Kind { get; } -} - -/// Interface for content parts that carry text. -public interface ITextContent -{ - /// The text content. - string Text { get; } -} - -/// Text content provided as input. -public sealed class InputTextContent : ContentPart, ITextContent -{ - /// - [JsonIgnore] - public override string Kind => "input_text"; - - [JsonPropertyName("text")] - public required string Text { get; set; } -} - -/// Text content generated by the model. -public sealed class OutputTextContent : ContentPart, ITextContent -{ - /// - [JsonIgnore] - public override string Kind => "output_text"; - - [JsonPropertyName("text")] - public required string Text { get; set; } - - [JsonPropertyName("annotations")] - public List Annotations { get; set; } = []; - - [JsonPropertyName("logprobs")] - public List Logprobs { get; set; } = []; -} - -/// Content when the model refuses to respond. -public sealed class RefusalContent : ContentPart -{ - /// - [JsonIgnore] - public override string Kind => "refusal"; - - [JsonPropertyName("refusal")] - public required string Refusal { get; set; } -} - -/// Image content provided as input. -public sealed class InputImageContent : ContentPart -{ - /// - [JsonIgnore] - public override string Kind => "input_image"; - - /// Public URL of an image. - [JsonPropertyName("image_url")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? ImageUrl { get; set; } - - /// Base64-encoded image data. - [JsonPropertyName("image_data")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? ImageData { get; set; } - - /// MIME type of the image (e.g., image/png, image/jpeg). Optional — the server will - /// infer the type from the data when omitted. - [JsonPropertyName("media_type")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? MediaType { get; set; } - - /// Detail level for image processing: "low", "high", or "auto". - [JsonPropertyName("detail")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Detail { get; set; } - - /// Validates that exactly one of or is set. - /// Called by the client before serialization to surface caller errors close to the call site rather than - /// returning a server-side 4xx. The factory methods (, , - /// ) already produce valid instances; this guards against direct property mutation. - /// If both or neither of / are set. - public void Validate() - { - var hasUrl = !string.IsNullOrEmpty(ImageUrl); - var hasData = !string.IsNullOrEmpty(ImageData); - if (hasUrl == hasData) - { - throw new InvalidOperationException( - "InputImageContent requires exactly one of ImageUrl or ImageData to be set."); - } - } - - /// Load an image from a local file; base64 encode and detect media type from extension. - /// If is null/empty. - /// If the file does not exist at . - public static InputImageContent FromFile(string path, string? detail = null) - { - if (string.IsNullOrWhiteSpace(path)) - { - throw new ArgumentException("path must be non-empty.", nameof(path)); - } - - if (!File.Exists(path)) - { - throw new FileNotFoundException($"Image file not found: {path}", path); - } - - var bytes = File.ReadAllBytes(path); - var mediaType = DetectMediaType(path); - return FromBytes(bytes, mediaType, detail); - } - - /// Create an image content referencing a URL. is optional; - /// when null the server will infer it. - public static InputImageContent FromUrl(string url, string? detail = null, string? mediaType = null) - { - if (string.IsNullOrWhiteSpace(url)) - { - throw new ArgumentException("url must be non-empty.", nameof(url)); - } - - return new InputImageContent - { - ImageUrl = url, - MediaType = mediaType, - Detail = detail, - }; - } - - /// Create an image content from raw bytes. is optional; - /// when null the server will infer it from the data. - public static InputImageContent FromBytes(byte[] data, string? mediaType = null, string? detail = null) - { - if (data == null || data.Length == 0) - { - throw new ArgumentException("data must be non-empty.", nameof(data)); - } - - return new InputImageContent - { - ImageData = Convert.ToBase64String(data), - MediaType = mediaType, - Detail = detail, - }; - } - - private static string? DetectMediaType(string path) - { - var ext = Path.GetExtension(path).ToLowerInvariant(); - return ext switch - { - ".png" => "image/png", - ".jpg" or ".jpeg" => "image/jpeg", - ".gif" => "image/gif", - ".webp" => "image/webp", - ".bmp" => "image/bmp", - _ => null, - }; - } -} - -/// File content provided as input. -public sealed class InputFileContent : ContentPart -{ - /// - [JsonIgnore] - public override string Kind => "input_file"; - - [JsonPropertyName("filename")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Filename { get; set; } - - [JsonPropertyName("file_url")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? FileUrl { get; set; } -} - -// ===================================================================================================================== -// Annotations and LogProbs -// ===================================================================================================================== - -[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] -[JsonDerivedType(typeof(UrlCitationAnnotation), "url_citation")] -public class Annotation -{ - [JsonIgnore] - public virtual string Kind { get; set; } = "annotation"; - - [JsonPropertyName("start_index")] - public int StartIndex { get; set; } - - [JsonPropertyName("end_index")] - public int EndIndex { get; set; } -} - -public sealed class UrlCitationAnnotation : Annotation -{ - [JsonIgnore] - public override string Kind => "url_citation"; - - [JsonPropertyName("url")] - public required string Url { get; set; } - - [JsonPropertyName("title")] - public required string Title { get; set; } -} - -public class LogProb -{ - [JsonPropertyName("token")] - public required string Token { get; set; } - - [JsonPropertyName("logprob")] - public double Logprob { get; set; } - - [JsonPropertyName("bytes")] - public List Bytes { get; set; } = []; - - [JsonPropertyName("top_logprobs")] - public List TopLogprobs { get; set; } = []; -} - -public class TopLogProb -{ - [JsonPropertyName("token")] - public required string Token { get; set; } - - [JsonPropertyName("logprob")] - public double Logprob { get; set; } - - [JsonPropertyName("bytes")] - public List Bytes { get; set; } = []; -} -// ===================================================================================================================== -// MessageContent union (string | ContentPart[]) -// ===================================================================================================================== +using OfficialResponses = global::OpenAI.Responses; +using System.ClientModel.Primitives; /// -/// Represents message content — either a simple string or an array of content parts. -/// Use implicit conversions or factory methods for convenience. +/// Result returned by Foundry Local's list-responses extension endpoint. /// -[JsonConverter(typeof(MessageContentConverter))] -public class MessageContent +public sealed class ListResponsesResult { - /// The text content if this is a simple string message. - public string? Text { get; set; } + public string ObjectType { get; init; } = "list"; - /// The content parts if this is a structured multimodal message. - public List? Parts { get; set; } + public IReadOnlyList Data { get; init; } = []; - public static implicit operator MessageContent(string text) => new() { Text = text }; + public string? FirstId { get; init; } - public static implicit operator MessageContent(List parts) => new() { Parts = parts }; + public string? LastId { get; init; } - public static MessageContent FromText(string text) => new() { Text = text }; + public bool HasMore { get; init; } - public static MessageContent FromParts(params ContentPart[] parts) => new() { Parts = [.. parts] }; - - /// Get the concatenated text representation. - public string GetText() + internal static ListResponsesResult FromJson(string json) { - if (Text != null) - { - return Text; - } - - if (Parts == null) - { - return string.Empty; - } + using var document = JsonDocument.Parse(json); + var root = document.RootElement; - var sb = new System.Text.StringBuilder(); - foreach (var part in Parts) + var data = new List(); + if (root.TryGetProperty("data", out var dataElement) && dataElement.ValueKind == JsonValueKind.Array) { - if (part is ITextContent t) + for (var i = 0; i < dataElement.GetArrayLength(); i++) { - sb.Append(t.Text); - } - } - - return sb.ToString(); - } -} - -/// JSON converter for supporting string or array form. -public class MessageContentConverter : JsonConverter -{ - public override MessageContent? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - if (reader.TokenType == JsonTokenType.String) - { - return new MessageContent { Text = reader.GetString() }; - } - - if (reader.TokenType == JsonTokenType.StartArray) - { - var parts = new List(); - while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) - { - parts.Add(ReadContentPart(ref reader)); + var item = dataElement[i]; + var parsed = ModelReaderWriter.Read( + new BinaryData(item.GetRawText()), + ModelReaderWriterOptions.Json, + global::OpenAI.OpenAIContext.Default); + if (parsed is not null) + { + data.Add(parsed); + } } - - return new MessageContent { Parts = parts }; } - if (reader.TokenType == JsonTokenType.Null) + return new ListResponsesResult { - return null; - } - - throw new JsonException("MessageContent must be a string or array."); - } - - private static ContentPart ReadContentPart(ref Utf8JsonReader reader) - { - if (reader.TokenType != JsonTokenType.StartObject) - { - throw new JsonException($"Expected ContentPart object, found {reader.TokenType}."); - } - - using var doc = JsonDocument.ParseValue(ref reader); - var root = doc.RootElement; - if (!root.TryGetProperty("type", out var typeEl)) - { - throw new JsonException("ContentPart must have a 'type' property."); - } - - var type = typeEl.GetString(); - var json = root.GetRawText(); - var ctx = ResponsesSerializationContext.Default; - - ContentPart? result = type switch - { - "input_text" => JsonSerializer.Deserialize(json, ctx.InputTextContent), - "output_text" => JsonSerializer.Deserialize(json, ctx.OutputTextContent), - "refusal" => JsonSerializer.Deserialize(json, ctx.RefusalContent), - "input_image" => JsonSerializer.Deserialize(json, ctx.InputImageContent), - "input_file" => JsonSerializer.Deserialize(json, ctx.InputFileContent), - _ => throw new JsonException($"Unsupported content part type: '{type}'."), + ObjectType = root.TryGetProperty("object", out var objectElement) ? objectElement.GetString() ?? "list" : "list", + Data = data, + FirstId = root.TryGetProperty("first_id", out var firstIdElement) ? firstIdElement.GetString() : null, + LastId = root.TryGetProperty("last_id", out var lastIdElement) ? lastIdElement.GetString() : null, + HasMore = root.TryGetProperty("has_more", out var hasMoreElement) && hasMoreElement.GetBoolean(), }; - - return result ?? throw new JsonException($"Failed to deserialize content part of type '{type}'."); - } - - public override void Write(Utf8JsonWriter writer, MessageContent value, JsonSerializerOptions options) - { - if (value.Text != null) - { - writer.WriteStringValue(value.Text); - } - else if (value.Parts != null) - { - writer.WriteStartArray(); - foreach (var part in value.Parts) - { - JsonSerializer.Serialize(writer, part, ResponsesSerializationContext.Default.ContentPart); - } - - writer.WriteEndArray(); - } - else - { - writer.WriteStringValue(string.Empty); - } } } -// ===================================================================================================================== -// Response Items (polymorphic) -// ===================================================================================================================== - -/// Base class for items in input/output arrays. -[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] -[JsonDerivedType(typeof(MessageItem), "message")] -[JsonDerivedType(typeof(FunctionCallItem), "function_call")] -[JsonDerivedType(typeof(FunctionCallOutputItem), "function_call_output")] -[JsonDerivedType(typeof(ReasoningItem), "reasoning")] -[JsonDerivedType(typeof(ItemReference), "item_reference")] -public abstract class ResponseItem -{ - /// The type discriminator for the item (client-side constant). - [JsonIgnore] - public abstract string Kind { get; } - - [JsonPropertyName("id")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Id { get; set; } - - [JsonPropertyName("status")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public ResponseItemStatus? Status { get; set; } -} - -public sealed class MessageItem : ResponseItem -{ - [JsonIgnore] - public override string Kind => "message"; - - [JsonPropertyName("role")] - public required MessageRole Role { get; set; } - - [JsonPropertyName("content")] - public required MessageContent Content { get; set; } -} - -public sealed class FunctionCallItem : ResponseItem -{ - [JsonIgnore] - public override string Kind => "function_call"; - - [JsonPropertyName("call_id")] - public required string CallId { get; set; } - - [JsonPropertyName("name")] - public required string Name { get; set; } - - [JsonPropertyName("arguments")] - public required string Arguments { get; set; } -} - -public sealed class FunctionCallOutputItem : ResponseItem -{ - [JsonIgnore] - public override string Kind => "function_call_output"; - - [JsonPropertyName("call_id")] - public required string CallId { get; set; } - - [JsonPropertyName("output")] - public required MessageContent Output { get; set; } -} - -public sealed class ReasoningItem : ResponseItem -{ - [JsonIgnore] - public override string Kind => "reasoning"; - - [JsonPropertyName("content")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public List? Content { get; set; } - - [JsonPropertyName("encrypted_content")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? EncryptedContent { get; set; } - - [JsonPropertyName("summary")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Summary { get; set; } -} - -public sealed class ItemReference : ResponseItem -{ - [JsonIgnore] - public override string Kind => "item_reference"; - - [JsonPropertyName("item_id")] - public required string ItemId { get; set; } -} - -// ===================================================================================================================== -// ResponseInput union (string | ResponseItem[]) -// ===================================================================================================================== - /// -/// Represents the input field of a Response create request. -/// Supports either a simple string or an array of structured items. +/// Convenience helpers that produce official instances for Foundry Local samples. /// -[JsonConverter(typeof(ResponseInputConverter))] -public class ResponseInput +public static class ResponseContentPartHelpers { - 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 }; -} - -public class ResponseInputConverter : JsonConverter -{ - public override ResponseInput? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + public static OfficialResponses.ResponseContentPart CreateInputImagePartFromFile(string path, string? detail = null) { - if (reader.TokenType == JsonTokenType.String) + if (string.IsNullOrWhiteSpace(path)) { - return new ResponseInput { Text = reader.GetString() }; + throw new ArgumentException("path must be non-empty.", nameof(path)); } - if (reader.TokenType == JsonTokenType.StartArray) + if (!File.Exists(path)) { - var items = new List(); - while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) - { - var item = ReadResponseItem(ref reader); - if (item != null) - { - items.Add(item); - } - } - - return new ResponseInput { Items = items }; + throw new FileNotFoundException($"Image file not found: {path}", path); } - if (reader.TokenType == JsonTokenType.Null) + var mediaType = DetectImageMediaType(path); + if (string.IsNullOrWhiteSpace(mediaType)) { - return null; + throw new ArgumentException($"Unable to infer image media type from file extension: {path}", nameof(path)); } - throw new JsonException("Input must be a string or array of items."); + return CreateInputImagePartFromBytes(File.ReadAllBytes(path), mediaType, detail); } - private static ResponseItem? ReadResponseItem(ref Utf8JsonReader reader) + public static OfficialResponses.ResponseContentPart CreateInputImagePartFromBytes(byte[] data, string mediaType, string? detail = null) { - if (reader.TokenType != JsonTokenType.StartObject) + if (data == null || data.Length == 0) { - reader.Skip(); - return null; + throw new ArgumentException("data must be non-empty.", nameof(data)); } - using var doc = JsonDocument.ParseValue(ref reader); - var root = doc.RootElement; - if (!root.TryGetProperty("type", out var typeEl)) + if (string.IsNullOrWhiteSpace(mediaType)) { - return null; + throw new ArgumentException("mediaType must be non-empty when using the official OpenAI binary image helper.", nameof(mediaType)); } - var type = typeEl.GetString(); - var json = root.GetRawText(); - var ctx = ResponsesSerializationContext.Default; - - return type switch - { - "message" => JsonSerializer.Deserialize(json, ctx.MessageItem), - "function_call" => JsonSerializer.Deserialize(json, ctx.FunctionCallItem), - "function_call_output" => JsonSerializer.Deserialize(json, ctx.FunctionCallOutputItem), - "reasoning" => JsonSerializer.Deserialize(json, ctx.ReasoningItem), - "item_reference" => JsonSerializer.Deserialize(json, ctx.ItemReference), - _ => null, - }; + return OfficialResponses.ResponseContentPart.CreateInputImagePart(BinaryData.FromBytes(data, mediaType), ToImageDetailLevel(detail)); } - public override void Write(Utf8JsonWriter writer, ResponseInput value, JsonSerializerOptions options) + public static OfficialResponses.ResponseContentPart CreateInputImagePartFromUrl(string url, string? detail = null) { - if (value.Text != null) - { - writer.WriteStringValue(value.Text); - } - else if (value.Items != null) - { - var ctx = ResponsesSerializationContext.Default; - writer.WriteStartArray(); - foreach (var item in value.Items) - { - JsonSerializer.Serialize(writer, item, ctx.ResponseItem); - } - - writer.WriteEndArray(); - } - else - { - writer.WriteNullValue(); - } - } -} - -// ===================================================================================================================== -// Tool Definition & Tool Choice -// ===================================================================================================================== - -/// A function tool definition. -public class FunctionToolDefinition -{ - [JsonPropertyName("type")] - public string Type => "function"; - - [JsonPropertyName("name")] - public required string Name { get; set; } - - [JsonPropertyName("description")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Description { get; set; } - - [JsonPropertyName("parameters")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public JsonElement? Parameters { get; set; } - - [JsonPropertyName("strict")] - public bool Strict { get; set; } -} - -public class SpecificToolChoice -{ - [JsonPropertyName("type")] - public string Type => "function"; - - [JsonPropertyName("name")] - public required string Name { get; set; } -} - -/// Tool choice — either a known string value or a specific function. -[JsonConverter(typeof(ToolChoiceConverter))] -public class ToolChoice -{ - public ToolChoiceValue? Value { get; set; } - - public SpecificToolChoice? Specific { get; set; } - - public static implicit operator ToolChoice(ToolChoiceValue value) => new() { Value = value }; - - public static ToolChoice ForFunction(string name) => new() { Specific = new SpecificToolChoice { Name = name } }; -} - -public class ToolChoiceConverter : JsonConverter -{ - public override ToolChoice? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - if (reader.TokenType == JsonTokenType.String) - { - var str = reader.GetString(); - return str switch - { - "auto" => new ToolChoice { Value = ToolChoiceValue.Auto }, - "none" => new ToolChoice { Value = ToolChoiceValue.None }, - "required" => new ToolChoice { Value = ToolChoiceValue.Required }, - _ => throw new JsonException($"Unknown tool_choice value: {str}"), - }; - } - - if (reader.TokenType == JsonTokenType.StartObject) - { - using var doc = JsonDocument.ParseValue(ref reader); - var json = doc.RootElement.GetRawText(); - var specific = JsonSerializer.Deserialize(json, ResponsesSerializationContext.Default.SpecificToolChoice); - return new ToolChoice { Specific = specific }; - } - - if (reader.TokenType == JsonTokenType.Null) + if (string.IsNullOrWhiteSpace(url)) { - return null; + throw new ArgumentException("url must be non-empty.", nameof(url)); } - throw new JsonException("tool_choice must be a string or object."); + return OfficialResponses.ResponseContentPart.CreateInputImagePart(new Uri(url), ToImageDetailLevel(detail)); } - public override void Write(Utf8JsonWriter writer, ToolChoice value, JsonSerializerOptions options) + public static string? DetectImageMediaType(string path) { - if (value.Value.HasValue) - { - var str = value.Value.Value switch - { - ToolChoiceValue.Auto => "auto", - ToolChoiceValue.None => "none", - ToolChoiceValue.Required => "required", - _ => throw new JsonException($"Unknown ToolChoiceValue: {value.Value}"), - }; - writer.WriteStringValue(str); - } - else if (value.Specific != null) - { - JsonSerializer.Serialize(writer, value.Specific, ResponsesSerializationContext.Default.SpecificToolChoice); - } - else + var ext = Path.GetExtension(path).ToLowerInvariant(); + return ext switch { - writer.WriteNullValue(); - } + ".png" => "image/png", + ".jpg" or ".jpeg" => "image/jpeg", + ".gif" => "image/gif", + ".webp" => "image/webp", + ".bmp" => "image/bmp", + _ => null, + }; } -} - -// ===================================================================================================================== -// Configuration objects -// ===================================================================================================================== - -public class ReasoningConfig -{ - [JsonPropertyName("effort")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Effort { get; set; } - - [JsonPropertyName("summary")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Summary { get; set; } -} - -public class TextConfig -{ - [JsonPropertyName("format")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public TextFormat? Format { get; set; } - - [JsonPropertyName("verbosity")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Verbosity { get; set; } -} - -public class TextFormat -{ - /// One of: text, json_object, json_schema, lark_grammar, regex. - [JsonPropertyName("type")] - public string Type { get; set; } = "text"; - - [JsonPropertyName("name")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Name { get; set; } - - [JsonPropertyName("description")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Description { get; set; } - - [JsonPropertyName("schema")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public JsonElement? Schema { get; set; } - - [JsonPropertyName("strict")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public bool? Strict { get; set; } - - [JsonPropertyName("grammar")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Grammar { get; set; } -} - -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; } - - [JsonPropertyName("input_tokens_details")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public InputTokensDetails? InputTokensDetails { get; set; } - - [JsonPropertyName("output_tokens_details")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public OutputTokensDetails? OutputTokensDetails { get; set; } -} - -public class InputTokensDetails -{ - [JsonPropertyName("cached_tokens")] - public int CachedTokens { get; set; } -} - -public class OutputTokensDetails -{ - [JsonPropertyName("reasoning_tokens")] - public int ReasoningTokens { get; set; } -} - -public class ResponseError -{ - [JsonPropertyName("code")] - public required string Code { get; set; } - - [JsonPropertyName("message")] - public required string Message { get; set; } -} - -public class IncompleteDetails -{ - [JsonPropertyName("reason")] - public required string Reason { get; set; } -} - -// ===================================================================================================================== -// Request & Response objects -// ===================================================================================================================== - -/// Request body for POST /v1/responses. -public class ResponseCreateRequest -{ - [JsonPropertyName("model")] - public required string Model { get; set; } - - [JsonPropertyName("input")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public ResponseInput? Input { get; set; } - - [JsonPropertyName("instructions")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Instructions { get; set; } - - [JsonPropertyName("previous_response_id")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? PreviousResponseId { get; set; } - - [JsonPropertyName("tools")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public List? Tools { get; set; } - - [JsonPropertyName("tool_choice")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public ToolChoice? ToolChoice { get; set; } - - [JsonPropertyName("temperature")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public float? Temperature { get; set; } - - [JsonPropertyName("top_p")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public float? TopP { get; set; } - - [JsonPropertyName("presence_penalty")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public float? PresencePenalty { get; set; } - - [JsonPropertyName("frequency_penalty")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public float? FrequencyPenalty { get; set; } - - [JsonPropertyName("max_output_tokens")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public int? MaxOutputTokens { get; set; } - - [JsonPropertyName("parallel_tool_calls")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public bool? ParallelToolCalls { get; set; } - - [JsonPropertyName("truncation")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public TruncationValue? Truncation { get; set; } - - [JsonPropertyName("store")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public bool? Store { get; set; } - - [JsonPropertyName("metadata")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public Dictionary? Metadata { get; set; } - - [JsonPropertyName("stream")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public bool? Stream { get; set; } - - [JsonPropertyName("reasoning")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public ReasoningConfig? Reasoning { get; set; } - - [JsonPropertyName("text")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public TextConfig? Text { get; set; } - - [JsonPropertyName("seed")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public int? Seed { get; set; } - - [JsonPropertyName("user")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? User { get; set; } -} - -/// The Response resource 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 ResponseStatus Status { get; set; } - - [JsonPropertyName("incomplete_details")] - public IncompleteDetails? 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")] - public ToolChoice? ToolChoice { get; set; } - - [JsonPropertyName("truncation")] - public TruncationValue? Truncation { get; set; } - - [JsonPropertyName("parallel_tool_calls")] - public bool ParallelToolCalls { get; set; } - - [JsonPropertyName("text")] - public TextConfig? Text { get; set; } - - [JsonPropertyName("top_p")] - public float? TopP { get; set; } - [JsonPropertyName("presence_penalty")] - public float? PresencePenalty { get; set; } - - [JsonPropertyName("frequency_penalty")] - public float? FrequencyPenalty { get; set; } - - [JsonPropertyName("temperature")] - public float? Temperature { get; set; } - - [JsonPropertyName("reasoning")] - public ReasoningConfig? Reasoning { get; set; } - - [JsonPropertyName("usage")] - public ResponseUsage? Usage { get; set; } - - [JsonPropertyName("max_output_tokens")] - public int? MaxOutputTokens { get; set; } - - [JsonPropertyName("store")] - public bool Store { get; set; } - - [JsonPropertyName("metadata")] - public Dictionary? Metadata { get; set; } - - [JsonPropertyName("user")] - public string? User { get; set; } - - /// - /// Convenience: concatenates text from assistant instances in . - /// Returns empty string if no assistant message is found. - /// - [JsonIgnore] - public string OutputText + private static OfficialResponses.ResponseImageDetailLevel? ToImageDetailLevel(string? detail) { - get + if (string.IsNullOrWhiteSpace(detail)) { - var sb = new System.Text.StringBuilder(); - foreach (var item in Output) - { - if (item is MessageItem msg && msg.Role == MessageRole.Assistant) - { - sb.Append(msg.Content.GetText()); - } - } - - return sb.ToString(); + return null; } + return new OfficialResponses.ResponseImageDetailLevel(detail); } } -// ===================================================================================================================== -// Listing/Delete results -// ===================================================================================================================== - -public class DeleteResponseResult -{ - [JsonPropertyName("id")] - public string Id { get; set; } = string.Empty; - - [JsonPropertyName("object")] - public string ObjectType { get; set; } = "response"; - - [JsonPropertyName("deleted")] - public bool Deleted { get; set; } -} - -public class InputItemsListResponse -{ - [JsonPropertyName("object")] - public string ObjectType { get; set; } = "list"; - - [JsonPropertyName("data")] - public List Data { get; set; } = []; - - [JsonPropertyName("first_id")] - public string? FirstId { get; set; } - - [JsonPropertyName("last_id")] - public string? LastId { get; set; } - - [JsonPropertyName("has_more")] - public bool HasMore { get; set; } -} - -public class ListResponsesResult -{ - [JsonPropertyName("object")] - public string ObjectType { get; set; } = "list"; - - [JsonPropertyName("data")] - public List Data { get; set; } = []; - - [JsonPropertyName("first_id")] - public string? FirstId { get; set; } - - [JsonPropertyName("last_id")] - public string? LastId { get; set; } - - [JsonPropertyName("has_more")] - public bool HasMore { get; set; } -} - -// ===================================================================================================================== -// Streaming Events (polymorphic) -// ===================================================================================================================== - -[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] -[JsonDerivedType(typeof(ResponseCreatedEvent), "response.created")] -[JsonDerivedType(typeof(ResponseQueuedEvent), "response.queued")] -[JsonDerivedType(typeof(ResponseInProgressEvent), "response.in_progress")] -[JsonDerivedType(typeof(ResponseCompletedEvent), "response.completed")] -[JsonDerivedType(typeof(ResponseFailedEvent), "response.failed")] -[JsonDerivedType(typeof(ResponseIncompleteEvent), "response.incomplete")] -[JsonDerivedType(typeof(OutputItemAddedEvent), "response.output_item.added")] -[JsonDerivedType(typeof(OutputItemDoneEvent), "response.output_item.done")] -[JsonDerivedType(typeof(ReasoningSummaryPartAddedEvent), "response.reasoning_summary_part.added")] -[JsonDerivedType(typeof(ReasoningSummaryPartDoneEvent), "response.reasoning_summary_part.done")] -[JsonDerivedType(typeof(ContentPartAddedEvent), "response.content_part.added")] -[JsonDerivedType(typeof(ContentPartDoneEvent), "response.content_part.done")] -[JsonDerivedType(typeof(OutputTextDeltaEvent), "response.output_text.delta")] -[JsonDerivedType(typeof(OutputTextDoneEvent), "response.output_text.done")] -[JsonDerivedType(typeof(RefusalDeltaEvent), "response.refusal.delta")] -[JsonDerivedType(typeof(RefusalDoneEvent), "response.refusal.done")] -[JsonDerivedType(typeof(ReasoningDeltaEvent), "response.reasoning.delta")] -[JsonDerivedType(typeof(ReasoningDoneEvent), "response.reasoning.done")] -[JsonDerivedType(typeof(ReasoningSummaryDeltaEvent), "response.reasoning_summary_text.delta")] -[JsonDerivedType(typeof(ReasoningSummaryDoneEvent), "response.reasoning_summary_text.done")] -[JsonDerivedType(typeof(OutputTextAnnotationAddedEvent), "response.output_text.annotation.added")] -[JsonDerivedType(typeof(FunctionCallArgumentsDeltaEvent), "response.function_call_arguments.delta")] -[JsonDerivedType(typeof(FunctionCallArgumentsDoneEvent), "response.function_call_arguments.done")] -[JsonDerivedType(typeof(ErrorEvent), "error")] -public abstract class StreamingEvent -{ - /// The event type discriminator (e.g. "response.created"). - [JsonIgnore] - public abstract string EventType { get; } - - [JsonPropertyName("sequence_number")] - public int SequenceNumber { get; set; } -} - -public sealed class ResponseCreatedEvent : StreamingEvent -{ - [JsonIgnore] public override string EventType => "response.created"; - [JsonPropertyName("response")] public required ResponseObject Response { get; set; } -} - -public sealed class ResponseQueuedEvent : StreamingEvent -{ - [JsonIgnore] public override string EventType => "response.queued"; - [JsonPropertyName("response")] public required ResponseObject Response { get; set; } -} - -public sealed class ResponseInProgressEvent : StreamingEvent -{ - [JsonIgnore] public override string EventType => "response.in_progress"; - [JsonPropertyName("response")] public required ResponseObject Response { get; set; } -} - -public sealed class ResponseCompletedEvent : StreamingEvent -{ - [JsonIgnore] public override string EventType => "response.completed"; - [JsonPropertyName("response")] public required ResponseObject Response { get; set; } -} - -public sealed class ResponseFailedEvent : StreamingEvent -{ - [JsonIgnore] public override string EventType => "response.failed"; - [JsonPropertyName("response")] public required ResponseObject Response { get; set; } -} - -public sealed class ResponseIncompleteEvent : StreamingEvent -{ - [JsonIgnore] public override string EventType => "response.incomplete"; - [JsonPropertyName("response")] public required ResponseObject Response { get; set; } -} - -public sealed class OutputItemAddedEvent : StreamingEvent -{ - [JsonIgnore] public override string EventType => "response.output_item.added"; - [JsonPropertyName("item_id")] public required string ItemId { get; set; } - [JsonPropertyName("output_index")] public int OutputIndex { get; set; } - [JsonPropertyName("item")] public required ResponseItem Item { get; set; } -} - -public sealed class OutputItemDoneEvent : StreamingEvent -{ - [JsonIgnore] public override string EventType => "response.output_item.done"; - [JsonPropertyName("item_id")] public required string ItemId { get; set; } - [JsonPropertyName("output_index")] public int OutputIndex { get; set; } - [JsonPropertyName("item")] public required ResponseItem Item { get; set; } -} - -public sealed class ContentPartAddedEvent : StreamingEvent -{ - [JsonIgnore] public override string EventType => "response.content_part.added"; - [JsonPropertyName("item_id")] public required string ItemId { get; set; } - [JsonPropertyName("output_index")] public int OutputIndex { get; set; } - [JsonPropertyName("content_index")] public int ContentIndex { get; set; } - [JsonPropertyName("part")] public required ContentPart Part { get; set; } -} - -public sealed class ContentPartDoneEvent : StreamingEvent -{ - [JsonIgnore] public override string EventType => "response.content_part.done"; - [JsonPropertyName("item_id")] public required string ItemId { get; set; } - [JsonPropertyName("output_index")] public int OutputIndex { get; set; } - [JsonPropertyName("content_index")] public int ContentIndex { get; set; } - [JsonPropertyName("part")] public required ContentPart Part { get; set; } -} - -public sealed class OutputTextDeltaEvent : StreamingEvent -{ - [JsonIgnore] public override string EventType => "response.output_text.delta"; - [JsonPropertyName("item_id")] public required string ItemId { get; set; } - [JsonPropertyName("output_index")] public int OutputIndex { get; set; } - [JsonPropertyName("content_index")] public int ContentIndex { get; set; } - [JsonPropertyName("delta")] public required string Delta { get; set; } -} - -public sealed class OutputTextDoneEvent : StreamingEvent -{ - [JsonIgnore] public override string EventType => "response.output_text.done"; - [JsonPropertyName("item_id")] public required string ItemId { get; set; } - [JsonPropertyName("output_index")] public int OutputIndex { get; set; } - [JsonPropertyName("content_index")] public int ContentIndex { get; set; } - [JsonPropertyName("text")] public required string Text { get; set; } -} - -public sealed class RefusalDeltaEvent : StreamingEvent -{ - [JsonIgnore] public override string EventType => "response.refusal.delta"; - [JsonPropertyName("item_id")] public required string ItemId { get; set; } - [JsonPropertyName("output_index")] public int OutputIndex { get; set; } - [JsonPropertyName("content_index")] public int ContentIndex { get; set; } - [JsonPropertyName("delta")] public required string Delta { get; set; } -} - -public sealed class RefusalDoneEvent : StreamingEvent -{ - [JsonIgnore] public override string EventType => "response.refusal.done"; - [JsonPropertyName("item_id")] public required string ItemId { get; set; } - [JsonPropertyName("output_index")] public int OutputIndex { get; set; } - [JsonPropertyName("content_index")] public int ContentIndex { get; set; } - [JsonPropertyName("refusal")] public required string Refusal { get; set; } -} - -public sealed class FunctionCallArgumentsDeltaEvent : StreamingEvent -{ - [JsonIgnore] public override string EventType => "response.function_call_arguments.delta"; - [JsonPropertyName("item_id")] public required string ItemId { get; set; } - [JsonPropertyName("output_index")] public int OutputIndex { get; set; } - [JsonPropertyName("call_id")] public string? CallId { get; set; } - [JsonPropertyName("delta")] public required string Delta { get; set; } -} - -public sealed class FunctionCallArgumentsDoneEvent : StreamingEvent -{ - [JsonIgnore] public override string EventType => "response.function_call_arguments.done"; - [JsonPropertyName("item_id")] public required string ItemId { get; set; } - [JsonPropertyName("output_index")] public int OutputIndex { get; set; } - [JsonPropertyName("call_id")] public string? CallId { get; set; } - [JsonPropertyName("arguments")] public required string Arguments { get; set; } - [JsonPropertyName("name")] public string? Name { get; set; } -} - -public sealed class ReasoningSummaryPartAddedEvent : StreamingEvent -{ - [JsonIgnore] public override string EventType => "response.reasoning_summary_part.added"; - [JsonPropertyName("item_id")] public required string ItemId { get; set; } - [JsonPropertyName("output_index")] public int OutputIndex { get; set; } - [JsonPropertyName("summary_index")] public int SummaryIndex { get; set; } - [JsonPropertyName("part")] public required ContentPart Part { get; set; } -} - -public sealed class ReasoningSummaryPartDoneEvent : StreamingEvent -{ - [JsonIgnore] public override string EventType => "response.reasoning_summary_part.done"; - [JsonPropertyName("item_id")] public required string ItemId { get; set; } - [JsonPropertyName("output_index")] public int OutputIndex { get; set; } - [JsonPropertyName("summary_index")] public int SummaryIndex { get; set; } - [JsonPropertyName("part")] public required ContentPart Part { get; set; } -} - -public sealed class ReasoningDeltaEvent : StreamingEvent -{ - [JsonIgnore] public override string EventType => "response.reasoning.delta"; - [JsonPropertyName("item_id")] public required string ItemId { get; set; } - [JsonPropertyName("output_index")] public int OutputIndex { get; set; } - [JsonPropertyName("content_index")] public int ContentIndex { get; set; } - [JsonPropertyName("delta")] public required string Delta { get; set; } -} - -public sealed class ReasoningDoneEvent : StreamingEvent -{ - [JsonIgnore] public override string EventType => "response.reasoning.done"; - [JsonPropertyName("item_id")] public required string ItemId { get; set; } - [JsonPropertyName("output_index")] public int OutputIndex { get; set; } - [JsonPropertyName("content_index")] public int ContentIndex { get; set; } - [JsonPropertyName("text")] public required string Text { get; set; } -} - -public sealed class ReasoningSummaryDeltaEvent : StreamingEvent -{ - [JsonIgnore] public override string EventType => "response.reasoning_summary_text.delta"; - [JsonPropertyName("item_id")] public required string ItemId { get; set; } - [JsonPropertyName("output_index")] public int OutputIndex { get; set; } - [JsonPropertyName("summary_index")] public int SummaryIndex { get; set; } - [JsonPropertyName("delta")] public required string Delta { get; set; } -} - -public sealed class ReasoningSummaryDoneEvent : StreamingEvent -{ - [JsonIgnore] public override string EventType => "response.reasoning_summary_text.done"; - [JsonPropertyName("item_id")] public required string ItemId { get; set; } - [JsonPropertyName("output_index")] public int OutputIndex { get; set; } - [JsonPropertyName("summary_index")] public int SummaryIndex { get; set; } - [JsonPropertyName("text")] public required string Text { get; set; } -} - -public sealed class OutputTextAnnotationAddedEvent : StreamingEvent -{ - [JsonIgnore] public override string EventType => "response.output_text.annotation.added"; - [JsonPropertyName("item_id")] public required string ItemId { get; set; } - [JsonPropertyName("output_index")] public int OutputIndex { get; set; } - [JsonPropertyName("content_index")] public int ContentIndex { get; set; } - [JsonPropertyName("annotation_index")] public int AnnotationIndex { get; set; } - [JsonPropertyName("annotation")] public Annotation? Annotation { get; set; } -} - -public sealed class ErrorEvent : StreamingEvent -{ - [JsonIgnore] public override string EventType => "error"; - - [JsonPropertyName("code")] public string? Code { get; set; } - [JsonPropertyName("message")] public string? Message { get; set; } - [JsonPropertyName("param")] public string? Param { get; set; } -} - -// ===================================================================================================================== -// API error envelope -// ===================================================================================================================== - -public class ApiErrorDetail -{ - [JsonPropertyName("message")] - public string? Message { get; set; } - - [JsonPropertyName("type")] - public string? Type { get; set; } - - [JsonPropertyName("param")] - public string? Param { get; set; } - - [JsonPropertyName("code")] - public string? Code { get; set; } -} - -public class ApiErrorResponse -{ - [JsonPropertyName("error")] - public ApiErrorDetail? Error { get; set; } -} +#pragma warning restore OPENAI001 diff --git a/sdk/cs/test/FoundryLocal.Tests/ResponsesClientTests.cs b/sdk/cs/test/FoundryLocal.Tests/ResponsesClientTests.cs index a7c0c975..4a0993e4 100644 --- a/sdk/cs/test/FoundryLocal.Tests/ResponsesClientTests.cs +++ b/sdk/cs/test/FoundryLocal.Tests/ResponsesClientTests.cs @@ -4,19 +4,17 @@ // // -------------------------------------------------------------------------------------------------------------------- +#pragma warning disable OPENAI001 // OpenAI Responses APIs are experimental in the official OpenAI package. + namespace Microsoft.AI.Foundry.Local.Tests; using System; using System.IO; -using System.Net; -using System.Net.Http; -using System.Text; -using System.Text.Json; using System.Threading.Tasks; using Microsoft.AI.Foundry.Local.OpenAI.Responses; -using RichardSzalay.MockHttp; +using OfficialResponses = global::OpenAI.Responses; internal sealed class ResponsesClientTests { @@ -43,16 +41,15 @@ public async Task Settings_Apply_Fills_Only_Unset_Fields() Store = false, }; - var request = new ResponseCreateRequest + var request = new OfficialResponses.CreateResponseOptions("m", new[] { OfficialResponses.ResponseItem.CreateUserMessageItem("hi") }) { - Model = "m", Temperature = 0.9f, // already set; settings must NOT override }; settings.ApplyTo(request); await Assert.That(request.Temperature).IsEqualTo(0.9f); - await Assert.That(request.MaxOutputTokens).IsEqualTo(64); - await Assert.That(request.Store).IsEqualTo(false); + await Assert.That(request.MaxOutputTokenCount).IsEqualTo(64); + await Assert.That(request.StoredOutputEnabled).IsEqualTo(false); } // ----------------------------------------------------------------------------------------------------------------- @@ -79,7 +76,7 @@ await Assert.That(async () => await client.CreateAsync((string)null!)) public async Task CreateAsync_Empty_List_Throws() { using var client = new OpenAIResponsesClient(BaseUrl, "m"); - await Assert.That(async () => await client.CreateAsync(new List())) + await Assert.That(async () => await client.CreateAsync(Array.Empty())) .Throws(); } @@ -92,79 +89,32 @@ await Assert.That(async () => await client.GetAsync("")) } // ----------------------------------------------------------------------------------------------------------------- - // OutputText convenience - // ----------------------------------------------------------------------------------------------------------------- - - [Test] - public async Task OutputText_Concatenates_Assistant_Messages() - { - var response = new ResponseObject - { - Output = - [ - new MessageItem - { - Role = MessageRole.Assistant, - Content = new MessageContent - { - Parts = - [ - new OutputTextContent { Text = "hello " }, - new OutputTextContent { Text = "world" }, - ], - }, - }, - ], - }; - - await Assert.That(response.OutputText).IsEqualTo("hello world"); - } - - [Test] - public async Task OutputText_Empty_For_No_Assistant_Message() - { - var response = new ResponseObject - { - Output = - [ - new MessageItem - { - Role = MessageRole.User, - Content = MessageContent.FromText("hi"), - }, - ], - }; - - await Assert.That(response.OutputText).IsEqualTo(string.Empty); - } - - // ----------------------------------------------------------------------------------------------------------------- - // InputImageContent factories + // Image content helper factories // ----------------------------------------------------------------------------------------------------------------- [Test] - public async Task InputImageContent_FromBytes_Sets_Data_And_Type() + public async Task CreateInputImagePartFromBytes_Builds_DataUri() { var bytes = new byte[] { 1, 2, 3, 4 }; - var img = InputImageContent.FromBytes(bytes, "image/png", "low"); + var part = ResponseContentPartHelpers.CreateInputImagePartFromBytes(bytes, "image/png", "low"); - await Assert.That(img.MediaType).IsEqualTo("image/png"); - await Assert.That(img.Detail).IsEqualTo("low"); - await Assert.That(img.ImageData).IsEqualTo(Convert.ToBase64String(bytes)); - await Assert.That(img.Kind).IsEqualTo("input_image"); + await Assert.That(part).IsNotNull(); + await Assert.That(part.Kind).IsEqualTo(OfficialResponses.ResponseContentPartKind.InputImage); + await Assert.That(part.InputImageUri).IsNotNull().And.StartsWith("data:image/png;base64,"); + await Assert.That(part.InputImageDetailLevel?.ToString()).IsEqualTo("low"); } [Test] - public async Task InputImageContent_FromFile_Reads_And_Detects_Png() + public async Task CreateInputImagePartFromFile_Reads_And_Detects_Png() { var path = Path.Combine(Path.GetTempPath(), $"test-{Guid.NewGuid():N}.png"); var bytes = new byte[] { 0x89, 0x50, 0x4E, 0x47 }; await File.WriteAllBytesAsync(path, bytes); try { - var img = InputImageContent.FromFile(path); - await Assert.That(img.MediaType).IsEqualTo("image/png"); - await Assert.That(img.ImageData).IsEqualTo(Convert.ToBase64String(bytes)); + var part = ResponseContentPartHelpers.CreateInputImagePartFromFile(path); + await Assert.That(part.Kind).IsEqualTo(OfficialResponses.ResponseContentPartKind.InputImage); + await Assert.That(part.InputImageUri).IsNotNull().And.StartsWith("data:image/png;base64,"); } finally { @@ -173,267 +123,77 @@ public async Task InputImageContent_FromFile_Reads_And_Detects_Png() } [Test] - public async Task InputImageContent_FromUrl_Sets_Url() + public async Task CreateInputImagePartFromUrl_Sets_Url() { - var img = InputImageContent.FromUrl("https://example.com/x.png"); - await Assert.That(img.ImageUrl).IsEqualTo("https://example.com/x.png"); - await Assert.That(img.ImageData).IsNull(); + var part = ResponseContentPartHelpers.CreateInputImagePartFromUrl("https://example.com/x.png"); + await Assert.That(part.InputImageUri).IsEqualTo("https://example.com/x.png"); } [Test] - public async Task InputImageContent_FromFile_Throws_When_Missing() + public async Task CreateInputImagePartFromFile_Throws_When_Missing() { var missing = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.png"); - await Assert.That(() => InputImageContent.FromFile(missing)).Throws(); - } - - [Test] - public async Task InputImageContent_Validate_Throws_When_Both_Set() - { - var img = new InputImageContent { ImageUrl = "https://x/y.png", ImageData = "AAAA" }; - await Assert.That(() => img.Validate()).Throws(); + await Assert.That(() => ResponseContentPartHelpers.CreateInputImagePartFromFile(missing)) + .Throws(); } [Test] - public async Task InputImageContent_Validate_Throws_When_Neither_Set() + public async Task CreateInputImagePartFromFile_Throws_When_Extension_Unknown() { - var img = new InputImageContent(); - await Assert.That(() => img.Validate()).Throws(); - } - - // ----------------------------------------------------------------------------------------------------------------- - // Serialization: snake_case wire format - // ----------------------------------------------------------------------------------------------------------------- - - [Test] - public async Task ResponseCreateRequest_Serializes_SnakeCase() - { - var req = new ResponseCreateRequest - { - Model = "phi", - Input = "hi", - MaxOutputTokens = 10, - TopP = 0.5f, - ParallelToolCalls = true, - Store = false, - }; - var json = JsonSerializer.Serialize(req, ResponsesSerializationContext.Default.ResponseCreateRequest); - await Assert.That(json).Contains("\"max_output_tokens\":10"); - await Assert.That(json).Contains("\"top_p\":0.5"); - await Assert.That(json).Contains("\"parallel_tool_calls\":true"); - await Assert.That(json).Contains("\"store\":false"); - await Assert.That(json).Contains("\"input\":\"hi\""); - } - - [Test] - public async Task ResponseObject_Deserializes_Polymorphic_Output() - { - var json = """ - { - "id": "resp_1", - "object": "response", - "created_at": 0, - "status": "completed", - "model": "m", - "output": [ - { - "type": "message", - "role": "assistant", - "content": [ { "type": "output_text", "text": "42" } ] - } - ], - "store": true, - "parallel_tool_calls": false - } - """; - - var obj = JsonSerializer.Deserialize(json, ResponsesSerializationContext.Default.ResponseObject); - await Assert.That(obj).IsNotNull(); - await Assert.That(obj!.OutputText).IsEqualTo("42"); - await Assert.That(obj.Output[0]).IsTypeOf(); - } - - [Test] - public async Task MessageContent_Serializes_String_Form() - { - var req = new ResponseCreateRequest - { - Model = "m", - Input = "plain", - }; - var json = JsonSerializer.Serialize(req, ResponsesSerializationContext.Default.ResponseCreateRequest); - await Assert.That(json).Contains("\"input\":\"plain\""); - } - - [Test] - public async Task StreamingEvent_Deserializes_Known_Types() - { - var json = """{"type":"response.output_text.delta","sequence_number":3,"item_id":"i1","output_index":0,"content_index":0,"delta":"hi"}"""; - var ev = JsonSerializer.Deserialize(json, ResponsesSerializationContext.Default.StreamingEvent); - await Assert.That(ev).IsTypeOf(); - var delta = (OutputTextDeltaEvent)ev!; - await Assert.That(delta.Delta).IsEqualTo("hi"); - await Assert.That(delta.SequenceNumber).IsEqualTo(3); - } - - // ----------------------------------------------------------------------------------------------------------------- - // SSE parsing (via CreateStreamingAsync) - // ----------------------------------------------------------------------------------------------------------------- - - [Test] - public async Task Streaming_Parses_Events_And_Stops_On_Done() - { - var sse = new StringBuilder(); - sse.Append("event: response.created\n"); - sse.Append("data: {\"type\":\"response.created\",\"sequence_number\":0,\"response\":{\"id\":\"r1\",\"object\":\"response\",\"created_at\":0,\"status\":\"in_progress\",\"model\":\"m\",\"output\":[],\"tools\":[],\"parallel_tool_calls\":false,\"store\":true}}\n\n"); - sse.Append("event: response.output_text.delta\n"); - sse.Append("data: {\"type\":\"response.output_text.delta\",\"sequence_number\":1,\"item_id\":\"i1\",\"output_index\":0,\"content_index\":0,\"delta\":\"hi\"}\n\n"); - sse.Append("data: [DONE]\n\n"); - - var mock = new MockHttpMessageHandler(); - mock.When(HttpMethod.Post, BaseUrl + "/v1/responses") - .Respond(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(sse.ToString(), Encoding.UTF8, "text/event-stream"), - }); - - using var http = mock.ToHttpClient(); - using var client = new OpenAIResponsesClient(http, BaseUrl, "m", ownsClient: false); - - var events = new List(); - await foreach (var ev in client.CreateStreamingAsync("hello")) + var path = Path.Combine(Path.GetTempPath(), $"test-{Guid.NewGuid():N}.unknownext"); + await File.WriteAllBytesAsync(path, new byte[] { 1, 2, 3 }); + try { - events.Add(ev); + await Assert.That(() => ResponseContentPartHelpers.CreateInputImagePartFromFile(path)) + .Throws(); } - - await Assert.That(events.Count).IsEqualTo(2); - await Assert.That(events[0]).IsTypeOf(); - await Assert.That(events[1]).IsTypeOf(); - } - - [Test] - public async Task Streaming_Handles_Multiline_Data() - { - // A payload split across two data: lines should be re-joined with \n. - var sse = "data: {\"type\":\"response.output_text.delta\",\n" - + "data: \"sequence_number\":1,\"item_id\":\"i1\",\"output_index\":0,\"content_index\":0,\"delta\":\"x\"}\n\n" - + "data: [DONE]\n\n"; - - var mock = new MockHttpMessageHandler(); - mock.When(HttpMethod.Post, BaseUrl + "/v1/responses") - .Respond(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(sse, Encoding.UTF8, "text/event-stream"), - }); - - using var http = mock.ToHttpClient(); - using var client = new OpenAIResponsesClient(http, BaseUrl, "m", ownsClient: false); - - var events = new List(); - await foreach (var ev in client.CreateStreamingAsync("hi")) + finally { - events.Add(ev); + File.Delete(path); } - - await Assert.That(events.Count).IsEqualTo(1); - await Assert.That(events[0]).IsTypeOf(); } - // ----------------------------------------------------------------------------------------------------------------- - // Non-streaming round-trip via mocked HTTP - // ----------------------------------------------------------------------------------------------------------------- - [Test] - public async Task CreateAsync_Serializes_Request_And_Parses_Response() + public async Task DetectImageMediaType_Returns_Null_For_Unknown() { - string? capturedBody = null; - var mock = new MockHttpMessageHandler(); - mock.When(HttpMethod.Post, BaseUrl + "/v1/responses") - .With(req => - { - capturedBody = req.Content?.ReadAsStringAsync().GetAwaiter().GetResult(); - return true; - }) - .Respond("application/json", """ - {"id":"r1","object":"response","created_at":1,"status":"completed","model":"phi","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"42"}]}],"tools":[],"parallel_tool_calls":false,"store":true} - """); - - using var http = mock.ToHttpClient(); - using var client = new OpenAIResponsesClient(http, BaseUrl, "phi", ownsClient: false); - client.Settings.Temperature = 0.1f; - - var result = await client.CreateAsync("What is 7*6?", req => req.MaxOutputTokens = 20); - - await Assert.That(result.Id).IsEqualTo("r1"); - await Assert.That(result.OutputText).IsEqualTo("42"); - await Assert.That(capturedBody).IsNotNull(); - await Assert.That(capturedBody!).Contains("\"stream\":false"); - await Assert.That(capturedBody!).Contains("\"max_output_tokens\":20"); - await Assert.That(capturedBody!).Contains("\"temperature\":0.1"); - await Assert.That(capturedBody!).Contains("\"model\":\"phi\""); + await Assert.That(ResponseContentPartHelpers.DetectImageMediaType("foo.unknownext")).IsNull(); } [Test] - public async Task Error_Response_Throws_FoundryLocalException_With_Message() + public async Task DetectImageMediaType_Recognizes_Common_Formats() { - var mock = new MockHttpMessageHandler(); - mock.When(HttpMethod.Post, BaseUrl + "/v1/responses") - .Respond(HttpStatusCode.BadRequest, "application/json", - """{"error":{"message":"bad model","type":"invalid_request_error"}}"""); - - using var http = mock.ToHttpClient(); - using var client = new OpenAIResponsesClient(http, BaseUrl, "phi", ownsClient: false); - - var ex = await Assert.That(async () => await client.CreateAsync("hi")) - .Throws(); - await Assert.That(ex!.Message).Contains("bad model"); + await Assert.That(ResponseContentPartHelpers.DetectImageMediaType("a.png")).IsEqualTo("image/png"); + await Assert.That(ResponseContentPartHelpers.DetectImageMediaType("a.JPG")).IsEqualTo("image/jpeg"); + await Assert.That(ResponseContentPartHelpers.DetectImageMediaType("a.jpeg")).IsEqualTo("image/jpeg"); + await Assert.That(ResponseContentPartHelpers.DetectImageMediaType("a.gif")).IsEqualTo("image/gif"); + await Assert.That(ResponseContentPartHelpers.DetectImageMediaType("a.webp")).IsEqualTo("image/webp"); + await Assert.That(ResponseContentPartHelpers.DetectImageMediaType("a.bmp")).IsEqualTo("image/bmp"); } // ----------------------------------------------------------------------------------------------------------------- - // CRUD methods + // ListResponsesResult JSON parsing // ----------------------------------------------------------------------------------------------------------------- [Test] - public async Task DeleteAsync_Returns_Result() - { - var mock = new MockHttpMessageHandler(); - mock.When(HttpMethod.Delete, BaseUrl + "/v1/responses/r1") - .Respond("application/json", """{"id":"r1","object":"response","deleted":true}"""); - - using var http = mock.ToHttpClient(); - using var client = new OpenAIResponsesClient(http, BaseUrl, "m", ownsClient: false); - var result = await client.DeleteAsync("r1"); - await Assert.That(result.Deleted).IsTrue(); - await Assert.That(result.Id).IsEqualTo("r1"); - } - - [Test] - public async Task GetInputItemsAsync_Returns_List() + public async Task ListResponsesResult_FromJson_Parses_Empty_List() { - var mock = new MockHttpMessageHandler(); - mock.When(HttpMethod.Get, BaseUrl + "/v1/responses/r1/input_items") - .Respond("application/json", - """{"object":"list","data":[{"type":"message","role":"user","content":"hi"}],"has_more":false}"""); - - using var http = mock.ToHttpClient(); - using var client = new OpenAIResponsesClient(http, BaseUrl, "m", ownsClient: false); - var list = await client.GetInputItemsAsync("r1"); - await Assert.That(list.Data.Count).IsEqualTo(1); - await Assert.That(list.Data[0]).IsTypeOf(); + const string json = """{"object":"list","data":[],"first_id":null,"last_id":null,"has_more":false}"""; + var result = ListResponsesResult.FromJson(json); + await Assert.That(result).IsNotNull(); + await Assert.That(result.ObjectType).IsEqualTo("list"); + await Assert.That(result.Data.Count).IsEqualTo(0); + await Assert.That(result.HasMore).IsFalse(); } [Test] - public async Task CancelAsync_Posts_To_Cancel_Endpoint() + public async Task ListResponsesResult_FromJson_Parses_Pagination_Fields() { - var mock = new MockHttpMessageHandler(); - mock.When(HttpMethod.Post, BaseUrl + "/v1/responses/r1/cancel") - .Respond("application/json", - """{"id":"r1","object":"response","created_at":0,"status":"cancelled","model":"m","output":[],"tools":[],"parallel_tool_calls":false,"store":true}"""); - - using var http = mock.ToHttpClient(); - using var client = new OpenAIResponsesClient(http, BaseUrl, "m", ownsClient: false); - var result = await client.CancelAsync("r1"); - await Assert.That(result.Id).IsEqualTo("r1"); - await Assert.That(result.Status).IsEqualTo(ResponseStatus.Cancelled); + const string json = """{"object":"list","data":[],"first_id":"r_first","last_id":"r_last","has_more":true}"""; + var result = ListResponsesResult.FromJson(json); + await Assert.That(result.FirstId).IsEqualTo("r_first"); + await Assert.That(result.LastId).IsEqualTo("r_last"); + await Assert.That(result.HasMore).IsTrue(); } } + +#pragma warning restore OPENAI001 diff --git a/sdk/cs/test/FoundryLocal.Tests/ResponsesIntegrationTests.cs b/sdk/cs/test/FoundryLocal.Tests/ResponsesIntegrationTests.cs index f2eab2e2..3ad260c7 100644 --- a/sdk/cs/test/FoundryLocal.Tests/ResponsesIntegrationTests.cs +++ b/sdk/cs/test/FoundryLocal.Tests/ResponsesIntegrationTests.cs @@ -4,6 +4,8 @@ // // -------------------------------------------------------------------------------------------------------------------- +#pragma warning disable OPENAI001 // OpenAI Responses APIs are experimental in the official OpenAI package. + namespace Microsoft.AI.Foundry.Local.Tests; using System.Text; @@ -11,6 +13,8 @@ namespace Microsoft.AI.Foundry.Local.Tests; using Microsoft.AI.Foundry.Local.OpenAI.Responses; +using OfficialResponses = global::OpenAI.Responses; + /// /// End-to-end integration tests for . Requires the Foundry Local /// service to be able to load and serve the configured model. Runs are category-tagged so they can @@ -44,8 +48,9 @@ public async Task NonStreaming_SimpleString() var response = await client.CreateAsync("Say the single word: ready").ConfigureAwait(false); await Assert.That(response).IsNotNull(); - await Assert.That(response.OutputText).IsNotNull().And.IsNotEmpty(); - Console.WriteLine($"[NonStreaming_SimpleString] {response.OutputText}"); + var text = response.GetOutputText(); + await Assert.That(text).IsNotNull().And.IsNotEmpty(); + Console.WriteLine($"[NonStreaming_SimpleString] {text}"); } [Test] @@ -56,12 +61,12 @@ public async Task NonStreaming_WithOptions() "What is 7 * 6? Respond only with the number.", r => { - r.MaxOutputTokens = 32; + r.MaxOutputTokenCount = 32; r.Temperature = 0.0f; r.Instructions = "You are a calculator. Respond precisely."; }).ConfigureAwait(false); - await Assert.That(response.OutputText).Contains("42"); + await Assert.That(response.GetOutputText()).Contains("42"); } [Test] @@ -69,17 +74,13 @@ public async Task NonStreaming_StructuredInput() { using var client = await model!.GetResponsesClientAsync(); - var items = new List + var items = new[] { - new MessageItem - { - Role = MessageRole.User, - Content = MessageContent.FromParts(new InputTextContent { Text = "Say: hello" }), - }, + OfficialResponses.ResponseItem.CreateUserMessageItem("Say: hello"), }; - var response = await client.CreateAsync(items, r => r.MaxOutputTokens = 16).ConfigureAwait(false); - await Assert.That(response.OutputText).IsNotEmpty(); + var response = await client.CreateAsync(items, r => r.MaxOutputTokenCount = 16).ConfigureAwait(false); + await Assert.That(response.GetOutputText()).IsNotEmpty(); } [Test] @@ -88,7 +89,7 @@ public async Task MultiTurn_PreviousResponseId() using var client = await model!.GetResponsesClientAsync(); var first = await client.CreateAsync( "Remember the number 17.", - r => { r.MaxOutputTokens = 32; r.Store = true; }).ConfigureAwait(false); + r => { r.MaxOutputTokenCount = 32; r.StoredOutputEnabled = true; }).ConfigureAwait(false); await Assert.That(first.Id).IsNotNull().And.IsNotEmpty(); @@ -97,7 +98,7 @@ public async Task MultiTurn_PreviousResponseId() r => { r.PreviousResponseId = first.Id; - r.MaxOutputTokens = 32; + r.MaxOutputTokenCount = 32; r.Temperature = 0.0f; }).ConfigureAwait(false); @@ -105,7 +106,7 @@ public async Task MultiTurn_PreviousResponseId() // validate is that the multi-turn wiring (previous_response_id) produces a response // that continues the conversation. Don't assert on model content. await Assert.That(second.Id).IsNotNull().And.IsNotEmpty(); - await Assert.That(second.OutputText).IsNotEmpty(); + await Assert.That(second.GetOutputText()).IsNotEmpty(); } [Test] @@ -119,14 +120,14 @@ public async Task Streaming_ReceivesDeltaEvents() await foreach (var evt in client.CreateStreamingAsync( "Count from 1 to 3.", - r => { r.MaxOutputTokens = 64; r.Temperature = 0.0f; })) + r => { r.MaxOutputTokenCount = 64; r.Temperature = 0.0f; })) { - if (evt is OutputTextDeltaEvent delta) + if (evt is OfficialResponses.StreamingResponseOutputTextDeltaUpdate delta) { sawDelta = true; aggregate.Append(delta.Delta); } - else if (evt is ResponseCompletedEvent) + else if (evt is OfficialResponses.StreamingResponseCompletedUpdate) { sawCompleted = true; } @@ -141,7 +142,7 @@ public async Task Streaming_ReceivesDeltaEvents() public async Task GetStoredResponse() { using var client = await model!.GetResponsesClientAsync(); - var created = await client.CreateAsync("Say: stored", r => { r.Store = true; r.MaxOutputTokens = 16; }); + var created = await client.CreateAsync("Say: stored", r => { r.StoredOutputEnabled = true; r.MaxOutputTokenCount = 16; }); var fetched = await client.GetAsync(created.Id); await Assert.That(fetched.Id).IsEqualTo(created.Id); } @@ -150,7 +151,7 @@ public async Task GetStoredResponse() public async Task DeleteResponse() { using var client = await model!.GetResponsesClientAsync(); - var created = await client.CreateAsync("Say: delete-me", r => { r.Store = true; r.MaxOutputTokens = 16; }); + var created = await client.CreateAsync("Say: delete-me", r => { r.StoredOutputEnabled = true; r.MaxOutputTokenCount = 16; }); var result = await client.DeleteAsync(created.Id); await Assert.That(result.Deleted).IsTrue(); } @@ -159,7 +160,7 @@ public async Task DeleteResponse() public async Task ListResponses() { using var client = await model!.GetResponsesClientAsync(); - _ = await client.CreateAsync("Hello", r => { r.Store = true; r.MaxOutputTokens = 8; }); + _ = await client.CreateAsync("Hello", r => { r.StoredOutputEnabled = true; r.MaxOutputTokenCount = 8; }); var list = await client.ListAsync(); await Assert.That(list).IsNotNull(); await Assert.That(list.Data).IsNotNull(); @@ -169,9 +170,11 @@ public async Task ListResponses() public async Task GetInputItems() { using var client = await model!.GetResponsesClientAsync(); - var created = await client.CreateAsync("Hi", r => { r.Store = true; r.MaxOutputTokens = 8; }); + var created = await client.CreateAsync("Hi", r => { r.StoredOutputEnabled = true; r.MaxOutputTokenCount = 8; }); var items = await client.GetInputItemsAsync(created.Id); await Assert.That(items).IsNotNull(); await Assert.That(items.Data).IsNotNull(); } } + +#pragma warning restore OPENAI001 From 473115721ab395c677dc6a63c729e78569809d6c Mon Sep 17 00:00:00 2001 From: MaanavD Date: Fri, 1 May 2026 16:19:26 -0400 Subject: [PATCH 3/4] Replace SDK Responses API surface with sample + integration tests This PR pivots away from adding a Responses API client to the C# SDK and instead adds a focused sample and integration tests that exercise the OpenAI Responses API against the Foundry Local web service, mirroring how `samples/cs/foundry-local-web-server` calls chat completions. Reverted on this branch: - `sdk/cs/src/OpenAI/ResponsesClient.cs` and `ResponsesTypes.cs` (deleted) - `ResponsesClientSettings`, factory methods, `IModel.GetResponsesClientAsync`, `Detail/Model.cs` and `Detail/ModelVariant.cs` additions - `OpenAI` PackageReference on the SDK project - `Utils.cs` test-side change - Earlier `ResponsesClientTests.cs` and `ResponsesIntegrationTests.cs` Added: - `samples/cs/responses-foundry-local-web-server/` (Program.cs + csproj) - Loads `qwen2.5-0.5b` via `FoundryLocalManager`, starts the web service - Uses `OpenAI.Responses.ResponsesClient` (official OpenAI .NET package, 2.10.0) pointed at `/v1` - Demonstrates non-streaming, streaming (`StreamingResponseOutputTextDeltaUpdate`), and a full function-calling round-trip via `previous_response_id` - `sdk/cs/test/FoundryLocal.Tests/ResponsesIntegrationTests.cs` - Three integration tests: NonStreaming, Streaming, and FunctionCalling round-trip - Skips automatically when `qwen2.5-0.5b` is not in the local cache - Cleans up: stops web service and unloads model in `[After(Class)]` - Bumped centrally-managed `OpenAI` package version 2.5.0 -> 2.10.0 (needed for stable `ResponsesClient`); added `OpenAI 2.10.0` to test project - Updated `samples/cs/README.md` with a row for the new sample Validation: - `dotnet build samples/cs/responses-foundry-local-web-server -c Release`: 0 warnings, 0 errors - `dotnet build sdk/cs/test/FoundryLocal.Tests -c Release`: 0 errors (2 unrelated pre-existing nullable warnings) - Local test run is currently blocked by a pre-existing `GetRepoRoot()` issue in worktrees (`Utils.AssemblyInit` throws when `.git` is a file rather than a directory); affects every test in the project, not Responses-specific Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- samples/cs/Directory.Packages.props | 2 +- samples/cs/README.md | 1 + .../Program.cs | 180 +++++++ .../ResponsesFoundryLocalWebServer.csproj | 54 ++ sdk/cs/src/Detail/Model.cs | 5 - sdk/cs/src/Detail/ModelVariant.cs | 28 - sdk/cs/src/FoundryLocalManager.cs | 19 - sdk/cs/src/IModel.cs | 7 - sdk/cs/src/Microsoft.AI.Foundry.Local.csproj | 12 +- sdk/cs/src/OpenAI/ResponsesClient.cs | 477 ------------------ sdk/cs/src/OpenAI/ResponsesTypes.cs | 142 ------ .../Microsoft.AI.Foundry.Local.Tests.csproj | 3 + .../ResponsesClientTests.cs | 199 -------- .../ResponsesIntegrationTests.cs | 272 +++++----- sdk/cs/test/FoundryLocal.Tests/Utils.cs | 3 +- 15 files changed, 404 insertions(+), 1000 deletions(-) create mode 100644 samples/cs/responses-foundry-local-web-server/Program.cs create mode 100644 samples/cs/responses-foundry-local-web-server/ResponsesFoundryLocalWebServer.csproj delete mode 100644 sdk/cs/src/OpenAI/ResponsesClient.cs delete mode 100644 sdk/cs/src/OpenAI/ResponsesTypes.cs delete mode 100644 sdk/cs/test/FoundryLocal.Tests/ResponsesClientTests.cs diff --git a/samples/cs/Directory.Packages.props b/samples/cs/Directory.Packages.props index d799c4cd..db615d6c 100644 --- a/samples/cs/Directory.Packages.props +++ b/samples/cs/Directory.Packages.props @@ -10,6 +10,6 @@ - + diff --git a/samples/cs/README.md b/samples/cs/README.md index ad10a3c6..aeb02f25 100644 --- a/samples/cs/README.md +++ b/samples/cs/README.md @@ -15,6 +15,7 @@ Both packages provide the same APIs, so the same source code works on all platfo | [embeddings](embeddings/) | Generate single and batch text embeddings using the Foundry Local SDK. | | [audio-transcription-example](audio-transcription-example/) | Transcribe audio files using the Foundry Local SDK. | | [foundry-local-web-server](foundry-local-web-server/) | Set up a local OpenAI-compliant web server. | +| [responses-foundry-local-web-server](responses-foundry-local-web-server/) | Use the OpenAI Responses API (non-streaming, streaming, tool calling) against the local web server. | | [tool-calling-foundry-local-sdk](tool-calling-foundry-local-sdk/) | Use tool calling with native chat completions. | | [tool-calling-foundry-local-web-server](tool-calling-foundry-local-web-server/) | Use tool calling with the local web server. | | [model-management-example](model-management-example/) | Manage models, variant selection, and updates. | diff --git a/samples/cs/responses-foundry-local-web-server/Program.cs b/samples/cs/responses-foundry-local-web-server/Program.cs new file mode 100644 index 00000000..43a63141 --- /dev/null +++ b/samples/cs/responses-foundry-local-web-server/Program.cs @@ -0,0 +1,180 @@ +// +// Demonstrates the OpenAI Responses API against the Foundry Local OpenAI-compatible web service. +// +// SDK responsibilities (Foundry Local): +// - SDK initialization +// - EP download/registration +// - model lookup, download, load +// - starting/stopping the local web service +// +// Responses API calls go through the official OpenAI .NET package's `ResponsesClient` +// pointed at the local web service, mirroring how `foundry-local-web-server` uses +// `OpenAIClient.GetChatClient(...)`. + +using System.ClientModel; +using System.Text; +using System.Text.Json; + +using Microsoft.AI.Foundry.Local; + +using OpenAI; +using OpenAI.Responses; + +var config = new Configuration +{ + AppName = "foundry_local_samples", + LogLevel = Microsoft.AI.Foundry.Local.LogLevel.Information, + Web = new Configuration.WebService + { + Urls = "http://127.0.0.1:52495" + } +}; + +// Initialize the singleton instance. +await FoundryLocalManager.CreateAsync(config, Utils.GetAppLogger()); +var mgr = FoundryLocalManager.Instance; + +// Download and register all execution providers. +var currentEp = ""; +await mgr.DownloadAndRegisterEpsAsync((epName, percent) => +{ + if (epName != currentEp) + { + if (currentEp != "") Console.WriteLine(); + currentEp = epName; + } + Console.Write($"\r {epName.PadRight(30)} {percent,6:F1}%"); +}); +if (currentEp != "") Console.WriteLine(); + +// Get the model catalog +var catalog = await mgr.GetCatalogAsync(); + +// Get a model using an alias +var model = await catalog.GetModelAsync("qwen2.5-0.5b") ?? throw new Exception("Model not found"); + +// Download the model (the method skips download if already cached) +await model.DownloadAsync(progress => +{ + Console.Write($"\rDownloading model: {progress:F2}%"); + if (progress >= 100f) + { + Console.WriteLine(); + } +}); + +// Load the model +Console.Write($"Loading model {model.Id}..."); +await model.LoadAsync(); +Console.WriteLine("done."); + +// Start the web service +Console.Write($"Starting web service on {config.Web.Urls}..."); +await mgr.StartWebServiceAsync(); +Console.WriteLine("done."); + +// <<<<<< OPEN AI RESPONSES SDK USAGE >>>>>> +// Use the OpenAI Responses client to call the local Foundry web service. +ApiKeyCredential key = new ApiKeyCredential("notneeded"); +OpenAIClient openai = new OpenAIClient(key, new OpenAIClientOptions +{ + Endpoint = new Uri(config.Web.Urls + "/v1"), +}); +ResponsesClient responses = openai.GetResponsesClient(); + +// 1) Non-streaming +Console.WriteLine("\n=== Non-streaming ==="); +ResponseResult simple = await responses.CreateResponseAsync(model.Id, "What is 2 + 2? Respond with just the number."); +Console.WriteLine($"[ASSISTANT]: {simple.GetOutputText()}"); + +// 2) Streaming +Console.WriteLine("\n=== Streaming ==="); +Console.Write("[ASSISTANT]: "); +await foreach (StreamingResponseUpdate update in responses.CreateResponseStreamingAsync(model.Id, "Count from 1 to 3.")) +{ + if (update is StreamingResponseOutputTextDeltaUpdate delta && !string.IsNullOrEmpty(delta.Delta)) + { + Console.Write(delta.Delta); + } +} +Console.WriteLine(); + +// 3) Function/tool calling — full round-trip using previous_response_id. +Console.WriteLine("\n=== Function calling ==="); +var weatherSchema = BinaryData.FromString(""" + { + "type": "object", + "properties": { + "city": { "type": "string", "description": "The city to look up" } + }, + "required": ["city"] + } + """); + +var toolOptions = new CreateResponseOptions( + model.Id, + new[] { ResponseItem.CreateUserMessageItem("Use get_weather to look up the weather in Seattle, then summarize it.") }) +{ + StoredOutputEnabled = true, + ToolChoice = ResponseToolChoice.CreateRequiredChoice(), +}; +toolOptions.Tools.Add(ResponseTool.CreateFunctionTool( + functionName: "get_weather", + functionParameters: weatherSchema, + strictModeEnabled: true, + functionDescription: "Get the current weather for a given city.")); + +ResponseResult toolCallResponse = await responses.CreateResponseAsync(toolOptions); + +// Find the function-call output item the model produced. +FunctionCallResponseItem? functionCall = null; +foreach (var item in toolCallResponse.OutputItems) +{ + if (item is FunctionCallResponseItem fc && fc.FunctionName == "get_weather") + { + functionCall = fc; + break; + } +} + +if (functionCall is null) +{ + Console.WriteLine("Model did not produce a function call; skipping tool round-trip."); +} +else +{ + var argsJson = functionCall.FunctionArguments?.ToString() ?? "{}"; + var city = "unknown"; + try + { + city = JsonDocument.Parse(argsJson).RootElement.GetProperty("city").GetString() ?? "unknown"; + } + catch (KeyNotFoundException) { /* model gave us no city */ } + + Console.WriteLine($"Tool call: get_weather(city=\"{city}\")"); + var toolOutput = $$$"""{"city": "{{{city}}}", "temperatureF": 68, "summary": "partly cloudy"}"""; + Console.WriteLine($"Tool output: {toolOutput}"); + + // Submit the tool's output and ask the model to continue using `previous_response_id`. + var followUpOptions = new CreateResponseOptions( + model.Id, + new[] { ResponseItem.CreateFunctionCallOutputItem(functionCall.CallId, toolOutput) }) + { + PreviousResponseId = toolCallResponse.Id, + StoredOutputEnabled = true, + }; + followUpOptions.Tools.Add(ResponseTool.CreateFunctionTool( + functionName: "get_weather", + functionParameters: weatherSchema, + strictModeEnabled: true, + functionDescription: "Get the current weather for a given city.")); + + ResponseResult finalResponse = await responses.CreateResponseAsync(followUpOptions); + Console.WriteLine($"[ASSISTANT]: {finalResponse.GetOutputText()}"); +} +// <<<<<< END OPEN AI RESPONSES SDK USAGE >>>>>> + +// Tidy up +await mgr.StopWebServiceAsync(); +await model.UnloadAsync(); +// diff --git a/samples/cs/responses-foundry-local-web-server/ResponsesFoundryLocalWebServer.csproj b/samples/cs/responses-foundry-local-web-server/ResponsesFoundryLocalWebServer.csproj new file mode 100644 index 00000000..a37de5c1 --- /dev/null +++ b/samples/cs/responses-foundry-local-web-server/ResponsesFoundryLocalWebServer.csproj @@ -0,0 +1,54 @@ + + + + Exe + enable + enable + + $(NoWarn);OPENAI001 + + + + + net9.0-windows10.0.26100 + false + ARM64;x64 + None + false + + + + + net9.0 + + + + $(NETCoreSdkRuntimeIdentifier) + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sdk/cs/src/Detail/Model.cs b/sdk/cs/src/Detail/Model.cs index 0dded439..03e9321b 100644 --- a/sdk/cs/src/Detail/Model.cs +++ b/sdk/cs/src/Detail/Model.cs @@ -104,11 +104,6 @@ public async Task GetEmbeddingClientAsync(CancellationTok return await SelectedVariant.GetEmbeddingClientAsync(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/Detail/ModelVariant.cs b/sdk/cs/src/Detail/ModelVariant.cs index 284a7bfd..250c601a 100644 --- a/sdk/cs/src/Detail/ModelVariant.cs +++ b/sdk/cs/src/Detail/ModelVariant.cs @@ -109,13 +109,6 @@ public async Task GetEmbeddingClientAsync(CancellationTok .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); @@ -217,27 +210,6 @@ private async Task GetEmbeddingClientImplAsync(Cancellati return new OpenAIEmbeddingClient(Id); } - private async Task GetResponsesClientImplAsync(CancellationToken? ct = null) - { - if (!await IsLoadedAsync(ct)) - { - throw new FoundryLocalException($"Model {Id} is not loaded. Call LoadAsync first."); - } - - var manager = FoundryLocalManager.Instance; - if (manager.Urls == null || manager.Urls.Length == 0) - { - await manager.StartWebServiceAsync(ct).ConfigureAwait(false); - } - - if (manager.Urls == null || manager.Urls.Length == 0) - { - throw new FoundryLocalException("Web service is not running. Call StartWebServiceAsync first."); - } - - return new OpenAIResponsesClient(manager.Urls[0], Id); - } - public void SelectVariant(IModel variant) { throw new FoundryLocalException( diff --git a/sdk/cs/src/FoundryLocalManager.cs b/sdk/cs/src/FoundryLocalManager.cs index 3e19e144..10b51285 100644 --- a/sdk/cs/src/FoundryLocalManager.cs +++ b/sdk/cs/src/FoundryLocalManager.cs @@ -460,23 +460,4 @@ public void Dispose() Dispose(disposing: true); GC.SuppressFinalize(this); } - - /// - /// Get an HTTP client for the OpenAI Responses API. - /// - /// - /// The web service must be started first (see ). - /// - /// Optional default model id used when callers don't supply one. - /// A new . - /// If the web service has not been started. - public OpenAIResponsesClient GetResponsesClient(string? modelId = null) - { - if (Urls == null || Urls.Length == 0) - { - throw new FoundryLocalException("Web service is not running. Call StartWebServiceAsync first."); - } - - return new OpenAIResponsesClient(Urls[0], modelId); - } } diff --git a/sdk/cs/src/IModel.cs b/sdk/cs/src/IModel.cs index 953d7b1b..37249782 100644 --- a/sdk/cs/src/IModel.cs +++ b/sdk/cs/src/IModel.cs @@ -77,13 +77,6 @@ Task DownloadAsync(Action? downloadProgress = null, /// OpenAI.EmbeddingClient Task GetEmbeddingClientAsync(CancellationToken? ct = null); - /// - /// Get an HTTP client for the OpenAI Responses API. - /// - /// Optional cancellation token. - /// An bound to this model. - Task GetResponsesClientAsync(CancellationToken? ct = null); - /// /// Variants of the model that are available. Variants of the model are optimized for different devices. /// diff --git a/sdk/cs/src/Microsoft.AI.Foundry.Local.csproj b/sdk/cs/src/Microsoft.AI.Foundry.Local.csproj index 56050e65..df8fc2cf 100644 --- a/sdk/cs/src/Microsoft.AI.Foundry.Local.csproj +++ b/sdk/cs/src/Microsoft.AI.Foundry.Local.csproj @@ -72,7 +72,8 @@ - + @@ -120,13 +121,14 @@ $(NoWarn);NU1604 - - + + - - + diff --git a/sdk/cs/src/OpenAI/ResponsesClient.cs b/sdk/cs/src/OpenAI/ResponsesClient.cs deleted file mode 100644 index 8671dd99..00000000 --- a/sdk/cs/src/OpenAI/ResponsesClient.cs +++ /dev/null @@ -1,477 +0,0 @@ -// -------------------------------------------------------------------------------------------------------------------- -// -// Copyright (c) Microsoft. All rights reserved. -// -// -------------------------------------------------------------------------------------------------------------------- - -#pragma warning disable OPENAI001 // OpenAI Responses APIs are experimental in the official OpenAI package. - -namespace Microsoft.AI.Foundry.Local; - -using System; -using System.Collections.Generic; -using System.IO; -using System.Net.Http; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; - -using Microsoft.AI.Foundry.Local.OpenAI.Responses; -using OfficialResponses = global::OpenAI.Responses; -using System.ClientModel; - -/// -/// Default-value container for . -/// Any non-null settings here are applied to every request before the -/// per-call configure callback runs. -/// -public class ResponsesClientSettings -{ - public string? Instructions { get; set; } - - public float? Temperature { get; set; } - - public float? TopP { get; set; } - - public int? MaxOutputTokens { get; set; } - - public bool? ParallelToolCalls { get; set; } - - public OfficialResponses.ResponseTruncationMode? Truncation { get; set; } - - /// - /// Server-side storage of responses. When null (default), the field is omitted - /// from the request and the server applies its default. Set to true to persist - /// responses for later retrieval via , - /// , and . - /// - public bool? Store { get; set; } - - public Dictionary? Metadata { get; set; } - - public OfficialResponses.ResponseReasoningOptions? Reasoning { get; set; } - - public OfficialResponses.ResponseTextOptions? Text { get; set; } - - public string? User { get; set; } - - internal void ApplyTo(OfficialResponses.CreateResponseOptions request) - { - request.Instructions ??= Instructions; - request.Temperature ??= Temperature; - request.TopP ??= TopP; - request.MaxOutputTokenCount ??= MaxOutputTokens; - request.ParallelToolCallsEnabled ??= ParallelToolCalls; - request.TruncationMode ??= Truncation; - request.StoredOutputEnabled ??= Store; - request.ReasoningOptions ??= Reasoning; - request.TextOptions ??= Text; - request.EndUserId ??= User; - - if (Metadata is not null) - { - foreach (var (key, value) in Metadata) - { - request.Metadata.TryAdd(key, value); - } - } - } -} - -/// -/// Client for the OpenAI Responses API served by Foundry Local. -/// Uses the official for standard Responses endpoints and a small HTTP -/// shim for Foundry Local's list-responses extension. -/// -[System.Diagnostics.CodeAnalysis.SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP007:Don't dispose injected", Justification = "Client only disposes HttpClient when it owns it (ownsClient flag tracked at construction).")] -[System.Diagnostics.CodeAnalysis.SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP008:Don't assign member with injected and created disposables", Justification = "Client owns HttpClient when constructed without one.")] -[System.Diagnostics.CodeAnalysis.SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP014:Use a single instance of HttpClient", Justification = "Short-lived per-client HttpClient matches SDK pattern; callers share via FoundryLocalManager.")] -public sealed class OpenAIResponsesClient : IDisposable -{ - private const string LocalApiKey = "foundry-local"; - - private readonly HttpClient _httpClient; - private readonly OfficialResponses.ResponsesClient _responsesClient; - private readonly string _baseUrl; - private readonly string? _modelId; - private readonly bool _ownsClient; - private bool _disposed; - - /// Default settings applied to every request. - public ResponsesClientSettings Settings { get; } = new(); - - /// Gets the underlying base URL (without trailing slash). - public string BaseUrl => _baseUrl; - - /// Gets the default model id (if any) supplied at construction. - public string? ModelId => _modelId; - - /// - /// Create a new client for the given base URL. - /// - /// Base URL of the Foundry Local service (e.g., http://localhost:5273). - /// Default model id to use when callers do not set one explicitly. - public OpenAIResponsesClient(string baseUrl, string? modelId = null) - : this(CreateDefaultHttpClient(), CreateOfficialClient(baseUrl), baseUrl, modelId, ownsClient: true) - { - } - - internal OpenAIResponsesClient( - HttpClient httpClient, - OfficialResponses.ResponsesClient responsesClient, - string baseUrl, - string? modelId = null, - bool ownsClient = true) - { - ArgumentNullException.ThrowIfNull(httpClient); - ArgumentNullException.ThrowIfNull(responsesClient); - - if (string.IsNullOrWhiteSpace(baseUrl)) - { - throw new ArgumentException("baseUrl must be non-empty.", nameof(baseUrl)); - } - - _httpClient = httpClient; - _responsesClient = responsesClient; - _baseUrl = baseUrl.TrimEnd('/'); - _modelId = modelId; - _ownsClient = ownsClient; - } - - // Kept for tests that only exercise Foundry Local extension endpoints. - internal OpenAIResponsesClient(HttpClient httpClient, string baseUrl, string? modelId = null, bool ownsClient = true) - : this(httpClient, CreateOfficialClient(baseUrl), baseUrl, modelId, ownsClient) - { - } - - // Foundry Local responses streaming may exceed HttpClient's 100s default. Disable the built-in - // timeout for the list-extension client and let callers use CancellationToken for deadlines. - private static HttpClient CreateDefaultHttpClient() => new() { Timeout = Timeout.InfiniteTimeSpan }; - - private static OfficialResponses.ResponsesClient CreateOfficialClient(string baseUrl) - { - if (string.IsNullOrWhiteSpace(baseUrl)) - { - throw new ArgumentException("baseUrl must be non-empty.", nameof(baseUrl)); - } - - var endpoint = new Uri(baseUrl.TrimEnd('/') + "/v1"); - return new OfficialResponses.ResponsesClient( - new ApiKeyCredential(LocalApiKey), - new global::OpenAI.OpenAIClientOptions { Endpoint = endpoint }); - } - - // ----------------------------------------------------------------------------------------------------------------- - // Create (non-streaming) - // ----------------------------------------------------------------------------------------------------------------- - - public Task CreateAsync(string input, CancellationToken ct = default) - => CreateAsync(input, configure: null, ct); - - public Task CreateAsync(string input, Action? configure, CancellationToken ct = default) - { - ValidateStringInput(input); - return CreateAsync(BuildRequest([OfficialResponses.ResponseItem.CreateUserMessageItem(input)], configure), ct); - } - - public Task CreateAsync(IEnumerable input, CancellationToken ct = default) - => CreateAsync(input, configure: null, ct); - - public Task CreateAsync(IEnumerable input, Action? configure, CancellationToken ct = default) - { - ValidateListInput(input); - return CreateAsync(BuildRequest(input, configure), ct); - } - - /// Submit a raw official OpenAI request object. - public async Task CreateAsync(OfficialResponses.CreateResponseOptions request, CancellationToken ct = default) - { - ArgumentNullException.ThrowIfNull(request); - - request.StreamingEnabled = false; - EnsureModel(request); - - try - { - var result = await _responsesClient.CreateResponseAsync(request, ct).ConfigureAwait(false); - return result.Value; - } - catch (ClientResultException ex) - { - throw ToFoundryLocalException(ex); - } - } - - // ----------------------------------------------------------------------------------------------------------------- - // Create (streaming) - // ----------------------------------------------------------------------------------------------------------------- - - public IAsyncEnumerable CreateStreamingAsync(string input, CancellationToken ct = default) - => CreateStreamingAsync(input, configure: null, ct); - - public IAsyncEnumerable CreateStreamingAsync(string input, Action? configure, CancellationToken ct = default) - { - ValidateStringInput(input); - return CreateStreamingAsync(BuildRequest([OfficialResponses.ResponseItem.CreateUserMessageItem(input)], configure), ct); - } - - public IAsyncEnumerable CreateStreamingAsync(IEnumerable input, CancellationToken ct = default) - => CreateStreamingAsync(input, configure: null, ct); - - public IAsyncEnumerable CreateStreamingAsync(IEnumerable input, Action? configure, CancellationToken ct = default) - { - ValidateListInput(input); - return CreateStreamingAsync(BuildRequest(input, configure), ct); - } - - /// Stream events for a raw official OpenAI request object. - public async IAsyncEnumerable CreateStreamingAsync( - OfficialResponses.CreateResponseOptions request, - [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default) - { - ArgumentNullException.ThrowIfNull(request); - - request.StreamingEnabled = true; - EnsureModel(request); - - AsyncCollectionResult updates; - try - { - updates = _responsesClient.CreateResponseStreamingAsync(request, ct); - } - catch (ClientResultException ex) - { - throw ToFoundryLocalException(ex); - } - - await foreach (var update in updates) - { - yield return update; - } - } - - // ----------------------------------------------------------------------------------------------------------------- - // CRUD - // ----------------------------------------------------------------------------------------------------------------- - - public async Task GetAsync(string responseId, CancellationToken ct = default) - { - ValidateId(responseId); - - try - { - var result = await _responsesClient.GetResponseAsync(responseId, ct).ConfigureAwait(false); - return result.Value; - } - catch (ClientResultException ex) - { - throw ToFoundryLocalException(ex); - } - } - - public async Task DeleteAsync(string responseId, CancellationToken ct = default) - { - ValidateId(responseId); - - try - { - var result = await _responsesClient.DeleteResponseAsync(responseId, ct).ConfigureAwait(false); - return result.Value; - } - catch (ClientResultException ex) - { - throw ToFoundryLocalException(ex); - } - } - - public async Task CancelAsync(string responseId, CancellationToken ct = default) - { - ValidateId(responseId); - - try - { - var result = await _responsesClient.CancelResponseAsync(responseId, ct).ConfigureAwait(false); - return result.Value; - } - catch (ClientResultException ex) - { - throw ToFoundryLocalException(ex); - } - } - - public async Task GetInputItemsAsync( - string responseId, - int? limit = null, - string? order = null, - string? after = null, - string? before = null, - CancellationToken ct = default) - { - ValidateId(responseId); - - var options = new OfficialResponses.ResponseItemCollectionOptions(responseId) - { - PageSizeLimit = limit, - AfterId = after, - BeforeId = before, - }; - if (!string.IsNullOrWhiteSpace(order)) - { - options.Order = new OfficialResponses.ResponseItemCollectionOrder(order); - } - - try - { - var result = await _responsesClient.GetResponseInputItemCollectionPageAsync(options, ct).ConfigureAwait(false); - return result.Value; - } - catch (ClientResultException ex) - { - throw ToFoundryLocalException(ex); - } - } - - /// - /// Lists stored responses using Foundry Local's extension endpoint. The official OpenAI .NET - /// Responses client does not currently expose a typed list-responses method. - /// - public async Task ListAsync( - int? limit = null, - string? order = null, - string? after = null, - CancellationToken ct = default) - { - var query = new List(); - if (limit.HasValue) - { - query.Add($"limit={limit.Value}"); - } - if (!string.IsNullOrWhiteSpace(order)) - { - query.Add($"order={Uri.EscapeDataString(order)}"); - } - if (!string.IsNullOrWhiteSpace(after)) - { - query.Add($"after={Uri.EscapeDataString(after)}"); - } - - var url = Url("/v1/responses") + (query.Count > 0 ? "?" + string.Join("&", query) : ""); - using var response = await _httpClient.GetAsync(url, ct).ConfigureAwait(false); - await EnsureSuccessAsync(response, ct).ConfigureAwait(false); - - var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); - return ListResponsesResult.FromJson(json); - } - - // ----------------------------------------------------------------------------------------------------------------- - // Helpers - // ----------------------------------------------------------------------------------------------------------------- - - private string Url(string relative) => _baseUrl + relative; - - private OfficialResponses.CreateResponseOptions BuildRequest(IEnumerable input, Action? configure) - { - var request = new OfficialResponses.CreateResponseOptions(_modelId ?? string.Empty, input); - Settings.ApplyTo(request); - configure?.Invoke(request); - EnsureModel(request); - return request; - } - - private static void EnsureModel(OfficialResponses.CreateResponseOptions request) - { - if (string.IsNullOrWhiteSpace(request.Model)) - { - throw new ArgumentException("A model id must be provided via constructor or configure callback."); - } - } - - private static void ValidateStringInput(string input) - { - ArgumentNullException.ThrowIfNull(input); - - if (input.Length == 0) - { - throw new ArgumentException("Input string must be non-empty.", nameof(input)); - } - } - - private static void ValidateListInput(IEnumerable input) - { - ArgumentNullException.ThrowIfNull(input); - - using var enumerator = input.GetEnumerator(); - if (!enumerator.MoveNext()) - { - throw new ArgumentException("Input list must contain at least one item.", nameof(input)); - } - } - - private static void ValidateId(string id) - { - if (string.IsNullOrWhiteSpace(id)) - { - throw new ArgumentException("responseId must be non-empty.", nameof(id)); - } - } - - private static async Task EnsureSuccessAsync(HttpResponseMessage response, CancellationToken ct) - { - if (response.IsSuccessStatusCode) - { - return; - } - - var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); - var message = TryReadErrorMessage(body) ?? $"{(int)response.StatusCode} {response.ReasonPhrase}"; - throw new FoundryLocalException($"Responses API request failed: {message}"); - } - - private static string? TryReadErrorMessage(string body) - { - if (string.IsNullOrWhiteSpace(body)) - { - return null; - } - - try - { - using var doc = JsonDocument.Parse(body); - if (doc.RootElement.TryGetProperty("error", out var error) - && error.TryGetProperty("message", out var message)) - { - return message.GetString(); - } - } - catch (JsonException) - { - return null; - } - - return null; - } - - private static FoundryLocalException ToFoundryLocalException(ClientResultException ex) - { - var message = string.IsNullOrWhiteSpace(ex.Message) ? $"HTTP {ex.Status}" : ex.Message; - return new FoundryLocalException($"Responses API request failed: {message}", ex); - } - - public void Dispose() - { - if (_disposed) - { - return; - } - - if (_ownsClient) - { - _httpClient.Dispose(); - } - - _disposed = true; - } -} - -#pragma warning restore OPENAI001 diff --git a/sdk/cs/src/OpenAI/ResponsesTypes.cs b/sdk/cs/src/OpenAI/ResponsesTypes.cs deleted file mode 100644 index a7fd75c0..00000000 --- a/sdk/cs/src/OpenAI/ResponsesTypes.cs +++ /dev/null @@ -1,142 +0,0 @@ -// -------------------------------------------------------------------------------------------------------------------- -// -// Copyright (c) Microsoft. All rights reserved. -// -// -------------------------------------------------------------------------------------------------------------------- - -#pragma warning disable OPENAI001 // OpenAI Responses APIs are experimental in the official OpenAI package. - -namespace Microsoft.AI.Foundry.Local.OpenAI.Responses; - -using System; -using System.Collections.Generic; -using System.IO; -using System.Text.Json; - -using OfficialResponses = global::OpenAI.Responses; -using System.ClientModel.Primitives; - -/// -/// Result returned by Foundry Local's list-responses extension endpoint. -/// -public sealed class ListResponsesResult -{ - public string ObjectType { get; init; } = "list"; - - public IReadOnlyList Data { get; init; } = []; - - public string? FirstId { get; init; } - - public string? LastId { get; init; } - - public bool HasMore { get; init; } - - internal static ListResponsesResult FromJson(string json) - { - using var document = JsonDocument.Parse(json); - var root = document.RootElement; - - var data = new List(); - if (root.TryGetProperty("data", out var dataElement) && dataElement.ValueKind == JsonValueKind.Array) - { - for (var i = 0; i < dataElement.GetArrayLength(); i++) - { - var item = dataElement[i]; - var parsed = ModelReaderWriter.Read( - new BinaryData(item.GetRawText()), - ModelReaderWriterOptions.Json, - global::OpenAI.OpenAIContext.Default); - if (parsed is not null) - { - data.Add(parsed); - } - } - } - - return new ListResponsesResult - { - ObjectType = root.TryGetProperty("object", out var objectElement) ? objectElement.GetString() ?? "list" : "list", - Data = data, - FirstId = root.TryGetProperty("first_id", out var firstIdElement) ? firstIdElement.GetString() : null, - LastId = root.TryGetProperty("last_id", out var lastIdElement) ? lastIdElement.GetString() : null, - HasMore = root.TryGetProperty("has_more", out var hasMoreElement) && hasMoreElement.GetBoolean(), - }; - } -} - -/// -/// Convenience helpers that produce official instances for Foundry Local samples. -/// -public static class ResponseContentPartHelpers -{ - public static OfficialResponses.ResponseContentPart CreateInputImagePartFromFile(string path, string? detail = null) - { - if (string.IsNullOrWhiteSpace(path)) - { - throw new ArgumentException("path must be non-empty.", nameof(path)); - } - - if (!File.Exists(path)) - { - throw new FileNotFoundException($"Image file not found: {path}", path); - } - - var mediaType = DetectImageMediaType(path); - if (string.IsNullOrWhiteSpace(mediaType)) - { - throw new ArgumentException($"Unable to infer image media type from file extension: {path}", nameof(path)); - } - - return CreateInputImagePartFromBytes(File.ReadAllBytes(path), mediaType, detail); - } - - public static OfficialResponses.ResponseContentPart CreateInputImagePartFromBytes(byte[] data, string mediaType, string? detail = null) - { - if (data == null || data.Length == 0) - { - throw new ArgumentException("data must be non-empty.", nameof(data)); - } - - if (string.IsNullOrWhiteSpace(mediaType)) - { - throw new ArgumentException("mediaType must be non-empty when using the official OpenAI binary image helper.", nameof(mediaType)); - } - - return OfficialResponses.ResponseContentPart.CreateInputImagePart(BinaryData.FromBytes(data, mediaType), ToImageDetailLevel(detail)); - } - - public static OfficialResponses.ResponseContentPart CreateInputImagePartFromUrl(string url, string? detail = null) - { - if (string.IsNullOrWhiteSpace(url)) - { - throw new ArgumentException("url must be non-empty.", nameof(url)); - } - - return OfficialResponses.ResponseContentPart.CreateInputImagePart(new Uri(url), ToImageDetailLevel(detail)); - } - - public static string? DetectImageMediaType(string path) - { - var ext = Path.GetExtension(path).ToLowerInvariant(); - return ext switch - { - ".png" => "image/png", - ".jpg" or ".jpeg" => "image/jpeg", - ".gif" => "image/gif", - ".webp" => "image/webp", - ".bmp" => "image/bmp", - _ => null, - }; - } - - private static OfficialResponses.ResponseImageDetailLevel? ToImageDetailLevel(string? detail) - { - if (string.IsNullOrWhiteSpace(detail)) - { - return null; - } - return new OfficialResponses.ResponseImageDetailLevel(detail); - } -} - -#pragma warning restore OPENAI001 diff --git a/sdk/cs/test/FoundryLocal.Tests/Microsoft.AI.Foundry.Local.Tests.csproj b/sdk/cs/test/FoundryLocal.Tests/Microsoft.AI.Foundry.Local.Tests.csproj index fe0dfcd2..2c41305b 100644 --- a/sdk/cs/test/FoundryLocal.Tests/Microsoft.AI.Foundry.Local.Tests.csproj +++ b/sdk/cs/test/FoundryLocal.Tests/Microsoft.AI.Foundry.Local.Tests.csproj @@ -8,6 +8,8 @@ true false false + + $(NoWarn);OPENAI001 @@ -47,6 +49,7 @@ + diff --git a/sdk/cs/test/FoundryLocal.Tests/ResponsesClientTests.cs b/sdk/cs/test/FoundryLocal.Tests/ResponsesClientTests.cs deleted file mode 100644 index 4a0993e4..00000000 --- a/sdk/cs/test/FoundryLocal.Tests/ResponsesClientTests.cs +++ /dev/null @@ -1,199 +0,0 @@ -// -------------------------------------------------------------------------------------------------------------------- -// -// Copyright (c) Microsoft. All rights reserved. -// -// -------------------------------------------------------------------------------------------------------------------- - -#pragma warning disable OPENAI001 // OpenAI Responses APIs are experimental in the official OpenAI package. - -namespace Microsoft.AI.Foundry.Local.Tests; - -using System; -using System.IO; -using System.Threading.Tasks; - -using Microsoft.AI.Foundry.Local.OpenAI.Responses; - -using OfficialResponses = global::OpenAI.Responses; - -internal sealed class ResponsesClientTests -{ - private const string BaseUrl = "http://localhost:5273"; - - // ----------------------------------------------------------------------------------------------------------------- - // Settings / defaults - // ----------------------------------------------------------------------------------------------------------------- - - [Test] - public async Task Settings_Store_Defaults_To_Null() - { - var settings = new ResponsesClientSettings(); - await Assert.That(settings.Store).IsNull(); - } - - [Test] - public async Task Settings_Apply_Fills_Only_Unset_Fields() - { - var settings = new ResponsesClientSettings - { - Temperature = 0.3f, - MaxOutputTokens = 64, - Store = false, - }; - - var request = new OfficialResponses.CreateResponseOptions("m", new[] { OfficialResponses.ResponseItem.CreateUserMessageItem("hi") }) - { - Temperature = 0.9f, // already set; settings must NOT override - }; - settings.ApplyTo(request); - - await Assert.That(request.Temperature).IsEqualTo(0.9f); - await Assert.That(request.MaxOutputTokenCount).IsEqualTo(64); - await Assert.That(request.StoredOutputEnabled).IsEqualTo(false); - } - - // ----------------------------------------------------------------------------------------------------------------- - // Input validation - // ----------------------------------------------------------------------------------------------------------------- - - [Test] - public async Task CreateAsync_Empty_String_Throws() - { - using var client = new OpenAIResponsesClient(BaseUrl, "m"); - await Assert.That(async () => await client.CreateAsync(string.Empty)) - .Throws(); - } - - [Test] - public async Task CreateAsync_Null_String_Throws() - { - using var client = new OpenAIResponsesClient(BaseUrl, "m"); - await Assert.That(async () => await client.CreateAsync((string)null!)) - .Throws(); - } - - [Test] - public async Task CreateAsync_Empty_List_Throws() - { - using var client = new OpenAIResponsesClient(BaseUrl, "m"); - await Assert.That(async () => await client.CreateAsync(Array.Empty())) - .Throws(); - } - - [Test] - public async Task GetAsync_Empty_Id_Throws() - { - using var client = new OpenAIResponsesClient(BaseUrl, "m"); - await Assert.That(async () => await client.GetAsync("")) - .Throws(); - } - - // ----------------------------------------------------------------------------------------------------------------- - // Image content helper factories - // ----------------------------------------------------------------------------------------------------------------- - - [Test] - public async Task CreateInputImagePartFromBytes_Builds_DataUri() - { - var bytes = new byte[] { 1, 2, 3, 4 }; - var part = ResponseContentPartHelpers.CreateInputImagePartFromBytes(bytes, "image/png", "low"); - - await Assert.That(part).IsNotNull(); - await Assert.That(part.Kind).IsEqualTo(OfficialResponses.ResponseContentPartKind.InputImage); - await Assert.That(part.InputImageUri).IsNotNull().And.StartsWith("data:image/png;base64,"); - await Assert.That(part.InputImageDetailLevel?.ToString()).IsEqualTo("low"); - } - - [Test] - public async Task CreateInputImagePartFromFile_Reads_And_Detects_Png() - { - var path = Path.Combine(Path.GetTempPath(), $"test-{Guid.NewGuid():N}.png"); - var bytes = new byte[] { 0x89, 0x50, 0x4E, 0x47 }; - await File.WriteAllBytesAsync(path, bytes); - try - { - var part = ResponseContentPartHelpers.CreateInputImagePartFromFile(path); - await Assert.That(part.Kind).IsEqualTo(OfficialResponses.ResponseContentPartKind.InputImage); - await Assert.That(part.InputImageUri).IsNotNull().And.StartsWith("data:image/png;base64,"); - } - finally - { - File.Delete(path); - } - } - - [Test] - public async Task CreateInputImagePartFromUrl_Sets_Url() - { - var part = ResponseContentPartHelpers.CreateInputImagePartFromUrl("https://example.com/x.png"); - await Assert.That(part.InputImageUri).IsEqualTo("https://example.com/x.png"); - } - - [Test] - public async Task CreateInputImagePartFromFile_Throws_When_Missing() - { - var missing = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.png"); - await Assert.That(() => ResponseContentPartHelpers.CreateInputImagePartFromFile(missing)) - .Throws(); - } - - [Test] - public async Task CreateInputImagePartFromFile_Throws_When_Extension_Unknown() - { - var path = Path.Combine(Path.GetTempPath(), $"test-{Guid.NewGuid():N}.unknownext"); - await File.WriteAllBytesAsync(path, new byte[] { 1, 2, 3 }); - try - { - await Assert.That(() => ResponseContentPartHelpers.CreateInputImagePartFromFile(path)) - .Throws(); - } - finally - { - File.Delete(path); - } - } - - [Test] - public async Task DetectImageMediaType_Returns_Null_For_Unknown() - { - await Assert.That(ResponseContentPartHelpers.DetectImageMediaType("foo.unknownext")).IsNull(); - } - - [Test] - public async Task DetectImageMediaType_Recognizes_Common_Formats() - { - await Assert.That(ResponseContentPartHelpers.DetectImageMediaType("a.png")).IsEqualTo("image/png"); - await Assert.That(ResponseContentPartHelpers.DetectImageMediaType("a.JPG")).IsEqualTo("image/jpeg"); - await Assert.That(ResponseContentPartHelpers.DetectImageMediaType("a.jpeg")).IsEqualTo("image/jpeg"); - await Assert.That(ResponseContentPartHelpers.DetectImageMediaType("a.gif")).IsEqualTo("image/gif"); - await Assert.That(ResponseContentPartHelpers.DetectImageMediaType("a.webp")).IsEqualTo("image/webp"); - await Assert.That(ResponseContentPartHelpers.DetectImageMediaType("a.bmp")).IsEqualTo("image/bmp"); - } - - // ----------------------------------------------------------------------------------------------------------------- - // ListResponsesResult JSON parsing - // ----------------------------------------------------------------------------------------------------------------- - - [Test] - public async Task ListResponsesResult_FromJson_Parses_Empty_List() - { - const string json = """{"object":"list","data":[],"first_id":null,"last_id":null,"has_more":false}"""; - var result = ListResponsesResult.FromJson(json); - await Assert.That(result).IsNotNull(); - await Assert.That(result.ObjectType).IsEqualTo("list"); - await Assert.That(result.Data.Count).IsEqualTo(0); - await Assert.That(result.HasMore).IsFalse(); - } - - [Test] - public async Task ListResponsesResult_FromJson_Parses_Pagination_Fields() - { - const string json = """{"object":"list","data":[],"first_id":"r_first","last_id":"r_last","has_more":true}"""; - var result = ListResponsesResult.FromJson(json); - await Assert.That(result.FirstId).IsEqualTo("r_first"); - await Assert.That(result.LastId).IsEqualTo("r_last"); - await Assert.That(result.HasMore).IsTrue(); - } -} - -#pragma warning restore OPENAI001 diff --git a/sdk/cs/test/FoundryLocal.Tests/ResponsesIntegrationTests.cs b/sdk/cs/test/FoundryLocal.Tests/ResponsesIntegrationTests.cs index 3ad260c7..c4416d0c 100644 --- a/sdk/cs/test/FoundryLocal.Tests/ResponsesIntegrationTests.cs +++ b/sdk/cs/test/FoundryLocal.Tests/ResponsesIntegrationTests.cs @@ -4,177 +4,219 @@ // // -------------------------------------------------------------------------------------------------------------------- -#pragma warning disable OPENAI001 // OpenAI Responses APIs are experimental in the official OpenAI package. - namespace Microsoft.AI.Foundry.Local.Tests; +using System; +using System.ClientModel; +using System.Linq; using System.Text; +using System.Text.Json; using System.Threading.Tasks; -using Microsoft.AI.Foundry.Local.OpenAI.Responses; - +using OfficialOpenAI = global::OpenAI; using OfficialResponses = global::OpenAI.Responses; /// -/// End-to-end integration tests for . Requires the Foundry Local -/// service to be able to load and serve the configured model. Runs are category-tagged so they can -/// be skipped in CI environments that don't have the model cache available. +/// Integration tests for the OpenAI Responses API served by the Foundry Local web service. +/// +/// The Foundry Local SDK is responsible only for model lifecycle and starting the local +/// web service. The Responses API is exercised through the official OpenAI .NET package +/// pointed at the local /v1 endpoint. +/// +/// These tests require a cached qwen2.5-0.5b model and a working Foundry Local +/// runtime; they are skipped automatically if the model can't be loaded locally +/// (e.g. CI without a model cache). /// internal sealed class ResponsesIntegrationTests { - private const string ModelId = "qwen2.5-0.5b-instruct-generic-cpu:4"; + private const string ModelAlias = "qwen2.5-0.5b"; + private const string ModelVariant = "qwen2.5-0.5b-instruct-generic-cpu:4"; private static IModel? model; + private static OfficialOpenAI.OpenAIClient? openAiClient; + private static OfficialResponses.ResponsesClient? responses; [Before(Class)] public static async Task Setup() { - var manager = FoundryLocalManager.Instance; + var manager = FoundryLocalManager.Instance; // initialized by Utils + var catalog = await manager.GetCatalogAsync(); + var modelVariant = await catalog.GetModelVariantAsync(ModelVariant).ConfigureAwait(false); + if (modelVariant is null) + { + // Model isn't in this environment's catalog; skip the suite. + return; + } - var m = await catalog.GetModelVariantAsync(ModelId).ConfigureAwait(false); - await Assert.That(m).IsNotNull(); + if (!await modelVariant.IsCachedAsync().ConfigureAwait(false)) + { + // Don't download in tests — leave it to the developer/CI to pre-cache. + return; + } - await m!.LoadAsync().ConfigureAwait(false); - await Assert.That(await m.IsLoadedAsync()).IsTrue(); + await modelVariant.LoadAsync().ConfigureAwait(false); + await Assert.That(await modelVariant.IsLoadedAsync()).IsTrue(); + model = modelVariant; - model = m; - } + await manager.StartWebServiceAsync().ConfigureAwait(false); + await Assert.That(manager.Urls).IsNotNull().And.IsNotEmpty(); - [Test] - public async Task NonStreaming_SimpleString() - { - using var client = await model!.GetResponsesClientAsync(); - var response = await client.CreateAsync("Say the single word: ready").ConfigureAwait(false); - - await Assert.That(response).IsNotNull(); - var text = response.GetOutputText(); - await Assert.That(text).IsNotNull().And.IsNotEmpty(); - Console.WriteLine($"[NonStreaming_SimpleString] {text}"); + var endpoint = new Uri(manager.Urls![0].TrimEnd('/') + "/v1"); + openAiClient = new OfficialOpenAI.OpenAIClient(new ApiKeyCredential("notneeded"), new OfficialOpenAI.OpenAIClientOptions { Endpoint = endpoint }); + responses = openAiClient.GetResponsesClient(); } - [Test] - public async Task NonStreaming_WithOptions() + [After(Class)] + public static async Task TearDown() { - using var client = await model!.GetResponsesClientAsync(); - var response = await client.CreateAsync( - "What is 7 * 6? Respond only with the number.", - r => - { - r.MaxOutputTokenCount = 32; - r.Temperature = 0.0f; - r.Instructions = "You are a calculator. Respond precisely."; - }).ConfigureAwait(false); + var manager = FoundryLocalManager.Instance; + try + { + await manager.StopWebServiceAsync().ConfigureAwait(false); + } + catch + { + // best-effort cleanup + } - await Assert.That(response.GetOutputText()).Contains("42"); + if (model is not null) + { + try + { + await model.UnloadAsync().ConfigureAwait(false); + } + catch + { + // best-effort cleanup + } + } } - [Test] - public async Task NonStreaming_StructuredInput() + private static (OfficialResponses.ResponsesClient Client, IModel Model) RequireSetup() { - using var client = await model!.GetResponsesClientAsync(); - - var items = new[] + if (responses is null || model is null) { - OfficialResponses.ResponseItem.CreateUserMessageItem("Say: hello"), - }; + Skip.Test($"Skipping: '{ModelAlias}' is not cached locally; run the SDK once to download it."); + } - var response = await client.CreateAsync(items, r => r.MaxOutputTokenCount = 16).ConfigureAwait(false); - await Assert.That(response.GetOutputText()).IsNotEmpty(); + return (responses!, model!); } [Test] - public async Task MultiTurn_PreviousResponseId() + public async Task NonStreaming_SimplePrompt_ReturnsText() { - using var client = await model!.GetResponsesClientAsync(); - var first = await client.CreateAsync( - "Remember the number 17.", - r => { r.MaxOutputTokenCount = 32; r.StoredOutputEnabled = true; }).ConfigureAwait(false); - - await Assert.That(first.Id).IsNotNull().And.IsNotEmpty(); + var (client, m) = RequireSetup(); + OfficialResponses.ResponseResult response = await client.CreateResponseAsync(m.Id, "What is 2 + 2? Respond with just the number.") + .ConfigureAwait(false); - var second = await client.CreateAsync( - "What was the number?", - r => - { - r.PreviousResponseId = first.Id; - r.MaxOutputTokenCount = 32; - r.Temperature = 0.0f; - }).ConfigureAwait(false); - - // The small qwen model may or may not recall the exact number — what we really - // validate is that the multi-turn wiring (previous_response_id) produces a response - // that continues the conversation. Don't assert on model content. - await Assert.That(second.Id).IsNotNull().And.IsNotEmpty(); - await Assert.That(second.GetOutputText()).IsNotEmpty(); + await Assert.That(response).IsNotNull(); + var text = response.GetOutputText(); + Console.WriteLine($"[NonStreaming] {text}"); + await Assert.That(text).IsNotNull().And.IsNotEmpty(); } [Test] - public async Task Streaming_ReceivesDeltaEvents() + public async Task Streaming_EmitsTextDeltaAndCompletionEvents() { - using var client = await model!.GetResponsesClientAsync(); + var (client, m) = RequireSetup(); - var sawDelta = false; + var sawTextDelta = false; var sawCompleted = false; var aggregate = new StringBuilder(); - await foreach (var evt in client.CreateStreamingAsync( - "Count from 1 to 3.", - r => { r.MaxOutputTokenCount = 64; r.Temperature = 0.0f; })) + await foreach (OfficialResponses.StreamingResponseUpdate update in client.CreateResponseStreamingAsync(m.Id, "Count from 1 to 3.")) { - if (evt is OfficialResponses.StreamingResponseOutputTextDeltaUpdate delta) + switch (update) { - sawDelta = true; - aggregate.Append(delta.Delta); - } - else if (evt is OfficialResponses.StreamingResponseCompletedUpdate) - { - sawCompleted = true; + case OfficialResponses.StreamingResponseOutputTextDeltaUpdate delta when !string.IsNullOrEmpty(delta.Delta): + sawTextDelta = true; + aggregate.Append(delta.Delta); + break; + case OfficialResponses.StreamingResponseCompletedUpdate: + sawCompleted = true; + break; } } - await Assert.That(sawDelta).IsTrue(); - await Assert.That(sawCompleted).IsTrue(); Console.WriteLine($"[Streaming] aggregated: {aggregate}"); + await Assert.That(sawTextDelta).IsTrue(); + await Assert.That(sawCompleted).IsTrue(); } [Test] - public async Task GetStoredResponse() + public async Task FunctionCalling_FullRoundTrip_ProducesAssistantText() { - using var client = await model!.GetResponsesClientAsync(); - var created = await client.CreateAsync("Say: stored", r => { r.StoredOutputEnabled = true; r.MaxOutputTokenCount = 16; }); - var fetched = await client.GetAsync(created.Id); - await Assert.That(fetched.Id).IsEqualTo(created.Id); - } + var (client, m) = RequireSetup(); - [Test] - public async Task DeleteResponse() - { - using var client = await model!.GetResponsesClientAsync(); - var created = await client.CreateAsync("Say: delete-me", r => { r.StoredOutputEnabled = true; r.MaxOutputTokenCount = 16; }); - var result = await client.DeleteAsync(created.Id); - await Assert.That(result.Deleted).IsTrue(); - } + var weatherSchema = BinaryData.FromString(""" + { + "type": "object", + "properties": { + "city": { "type": "string", "description": "The city to look up" } + }, + "required": ["city"] + } + """); - [Test] - public async Task ListResponses() - { - using var client = await model!.GetResponsesClientAsync(); - _ = await client.CreateAsync("Hello", r => { r.StoredOutputEnabled = true; r.MaxOutputTokenCount = 8; }); - var list = await client.ListAsync(); - await Assert.That(list).IsNotNull(); - await Assert.That(list.Data).IsNotNull(); - } + var initialOptions = new OfficialResponses.CreateResponseOptions( + m.Id, + new[] { OfficialResponses.ResponseItem.CreateUserMessageItem("Use get_weather to look up the weather in Seattle, then summarize it.") }) + { + StoredOutputEnabled = true, + ToolChoice = OfficialResponses.ResponseToolChoice.CreateRequiredChoice(), + MaxOutputTokenCount = 256, + Temperature = 0.0f, + }; + initialOptions.Tools.Add(OfficialResponses.ResponseTool.CreateFunctionTool( + functionName: "get_weather", + functionParameters: weatherSchema, + strictModeEnabled: true, + functionDescription: "Get the current weather for a given city.")); + + OfficialResponses.ResponseResult firstResponse = await client.CreateResponseAsync(initialOptions).ConfigureAwait(false); + + var functionCall = firstResponse.OutputItems + .OfType() + .FirstOrDefault(item => item.FunctionName == "get_weather"); + await Assert.That(functionCall).IsNotNull(); + + var argsJson = functionCall!.FunctionArguments?.ToString() ?? "{}"; + string? city = null; + try + { + using var doc = JsonDocument.Parse(argsJson); + if (doc.RootElement.TryGetProperty("city", out var cityElement)) + { + city = cityElement.GetString(); + } + } + catch (JsonException) + { + // small models occasionally emit malformed args; treat as unknown + } - [Test] - public async Task GetInputItems() - { - using var client = await model!.GetResponsesClientAsync(); - var created = await client.CreateAsync("Hi", r => { r.StoredOutputEnabled = true; r.MaxOutputTokenCount = 8; }); - var items = await client.GetInputItemsAsync(created.Id); - await Assert.That(items).IsNotNull(); - await Assert.That(items.Data).IsNotNull(); + var toolOutput = $$$"""{"city": "{{{city ?? "unknown"}}}", "temperatureF": 68, "summary": "partly cloudy"}"""; + + var followUpOptions = new OfficialResponses.CreateResponseOptions( + m.Id, + new[] { OfficialResponses.ResponseItem.CreateFunctionCallOutputItem(functionCall.CallId, toolOutput) }) + { + PreviousResponseId = firstResponse.Id, + StoredOutputEnabled = true, + MaxOutputTokenCount = 256, + Temperature = 0.0f, + }; + followUpOptions.Tools.Add(OfficialResponses.ResponseTool.CreateFunctionTool( + functionName: "get_weather", + functionParameters: weatherSchema, + strictModeEnabled: true, + functionDescription: "Get the current weather for a given city.")); + + OfficialResponses.ResponseResult finalResponse = await client.CreateResponseAsync(followUpOptions).ConfigureAwait(false); + + var finalText = finalResponse.GetOutputText(); + Console.WriteLine($"[FunctionCalling] tool_call_id={functionCall.CallId}, city={city}, final={finalText}"); + await Assert.That(finalText).IsNotNull().And.IsNotEmpty(); } } - -#pragma warning restore OPENAI001 diff --git a/sdk/cs/test/FoundryLocal.Tests/Utils.cs b/sdk/cs/test/FoundryLocal.Tests/Utils.cs index fe968df1..a289011b 100644 --- a/sdk/cs/test/FoundryLocal.Tests/Utils.cs +++ b/sdk/cs/test/FoundryLocal.Tests/Utils.cs @@ -443,8 +443,7 @@ private static string GetRepoRoot() while (dir != null) { - var gitPath = Path.Combine(dir.FullName, ".git"); - if (Directory.Exists(gitPath) || File.Exists(gitPath)) + if (Directory.Exists(Path.Combine(dir.FullName, ".git"))) return dir.FullName; dir = dir.Parent; From 39c848889b56cd8c7a8a4c6f854884251a3b6a60 Mon Sep 17 00:00:00 2001 From: MaanavD Date: Mon, 4 May 2026 01:39:58 +0200 Subject: [PATCH 4/4] Align Responses sample/tests with JS PR #671 patterns; all tests pass locally Cross-references the JS Responses sample/tests (PR #671) to keep the C# pattern consistent. Sample (samples/cs/responses-foundry-local-web-server): - Added README.md mirroring the JS sample (prereqs, run, expected output, troubleshooting) - Tool now uses an empty-params schema (matches JS PR), which the small qwen2.5-0.5b reliably calls - Single ResponseTool reused on the follow-up call; deterministic options (Temperature=0, MaxOutputTokenCount=64) - Cleanup wrapped in try/finally so StopWebService/Unload run even on exceptions Integration tests (sdk/cs/test/FoundryLocal.Tests/ResponsesIntegrationTests.cs): - Mirrors the JS suite responsesWebService.test.ts (NonStreaming, Streaming, FunctionCalling) - Skips when Utils.IsRunningInCI() is true and when qwen2.5-0.5b is not pre-cached - Streaming asserts response.created, response.output_text.delta, and response.completed events (parity with JS) - Tool-calling test reuses the same get_weather empty-params definition - Streaming options include StreamingEnabled = true so the official ResponsesClient allows the call Pre-existing fix (test infra only): - Utils.GetRepoRoot() previously failed in git worktrees because .git is a file, not a directory; now accepts either form. This unblocked test execution in worktree checkouts. Validation: - dotnet build samples/cs/responses-foundry-local-web-server -c Release: 0 warnings, 0 errors - dotnet build sdk/cs/test/FoundryLocal.Tests -c Release: 0 errors - dotnet test --filter ResponsesIntegration: all 3 Responses tests pass end-to-end against a real local model - The 10 remaining failures across the project are pre-existing EmbeddingClientTests infra (different model not cached), unrelated to this PR Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Program.cs | 182 +++++++++--------- .../README.md | 55 ++++++ .../ResponsesIntegrationTests.cs | 127 ++++++------ sdk/cs/test/FoundryLocal.Tests/Utils.cs | 7 +- 4 files changed, 221 insertions(+), 150 deletions(-) create mode 100644 samples/cs/responses-foundry-local-web-server/README.md diff --git a/samples/cs/responses-foundry-local-web-server/Program.cs b/samples/cs/responses-foundry-local-web-server/Program.cs index 43a63141..e850bf9c 100644 --- a/samples/cs/responses-foundry-local-web-server/Program.cs +++ b/samples/cs/responses-foundry-local-web-server/Program.cs @@ -8,12 +8,10 @@ // - starting/stopping the local web service // // Responses API calls go through the official OpenAI .NET package's `ResponsesClient` -// pointed at the local web service, mirroring how `foundry-local-web-server` uses -// `OpenAIClient.GetChatClient(...)`. +// pointed at the local web service, mirroring how `samples/cs/foundry-local-web-server` +// uses `OpenAIClient.GetChatClient(...)` for chat completions. using System.ClientModel; -using System.Text; -using System.Text.Json; using Microsoft.AI.Foundry.Local; @@ -73,108 +71,104 @@ await model.DownloadAsync(progress => await mgr.StartWebServiceAsync(); Console.WriteLine("done."); -// <<<<<< OPEN AI RESPONSES SDK USAGE >>>>>> -// Use the OpenAI Responses client to call the local Foundry web service. -ApiKeyCredential key = new ApiKeyCredential("notneeded"); -OpenAIClient openai = new OpenAIClient(key, new OpenAIClientOptions +try { - Endpoint = new Uri(config.Web.Urls + "/v1"), -}); -ResponsesClient responses = openai.GetResponsesClient(); - -// 1) Non-streaming -Console.WriteLine("\n=== Non-streaming ==="); -ResponseResult simple = await responses.CreateResponseAsync(model.Id, "What is 2 + 2? Respond with just the number."); -Console.WriteLine($"[ASSISTANT]: {simple.GetOutputText()}"); - -// 2) Streaming -Console.WriteLine("\n=== Streaming ==="); -Console.Write("[ASSISTANT]: "); -await foreach (StreamingResponseUpdate update in responses.CreateResponseStreamingAsync(model.Id, "Count from 1 to 3.")) -{ - if (update is StreamingResponseOutputTextDeltaUpdate delta && !string.IsNullOrEmpty(delta.Delta)) + // <<<<<< OPEN AI RESPONSES SDK USAGE >>>>>> + // Use the OpenAI Responses client to call the local Foundry web service. + ApiKeyCredential key = new("notneeded"); + OpenAIClient openai = new(key, new OpenAIClientOptions { - Console.Write(delta.Delta); - } -} -Console.WriteLine(); - -// 3) Function/tool calling — full round-trip using previous_response_id. -Console.WriteLine("\n=== Function calling ==="); -var weatherSchema = BinaryData.FromString(""" + Endpoint = new Uri(config.Web.Urls + "/v1"), + }); + ResponsesClient responses = openai.GetResponsesClient(); + + // 1) Non-streaming + Console.WriteLine("\n=== Non-streaming ==="); + ResponseResult simple = await responses.CreateResponseAsync(model.Id, "Reply with one short sentence about local AI."); + Console.WriteLine($"[ASSISTANT]: {simple.GetOutputText()}"); + + // 2) Streaming + Console.WriteLine("\n=== Streaming ==="); + Console.Write("[ASSISTANT]: "); + await foreach (StreamingResponseUpdate update in responses.CreateResponseStreamingAsync(model.Id, "Count from 1 to 3.")) { - "type": "object", - "properties": { - "city": { "type": "string", "description": "The city to look up" } - }, - "required": ["city"] + if (update is StreamingResponseOutputTextDeltaUpdate delta && !string.IsNullOrEmpty(delta.Delta)) + { + Console.Write(delta.Delta); + } } - """); + Console.WriteLine(); + + // 3) Function/tool calling — full round-trip via previous_response_id. + // The function takes no arguments, which matches the pattern small models handle reliably. + Console.WriteLine("\n=== Function calling ==="); + var emptyParamsSchema = BinaryData.FromString(""" + { + "type": "object", + "properties": {}, + "additionalProperties": false + } + """); + + ResponseTool getWeatherTool = ResponseTool.CreateFunctionTool( + functionName: "get_weather", + functionParameters: emptyParamsSchema, + strictModeEnabled: true, + functionDescription: "Get the current weather. This sample always returns Seattle weather."); -var toolOptions = new CreateResponseOptions( - model.Id, - new[] { ResponseItem.CreateUserMessageItem("Use get_weather to look up the weather in Seattle, then summarize it.") }) -{ - StoredOutputEnabled = true, - ToolChoice = ResponseToolChoice.CreateRequiredChoice(), -}; -toolOptions.Tools.Add(ResponseTool.CreateFunctionTool( - functionName: "get_weather", - functionParameters: weatherSchema, - strictModeEnabled: true, - functionDescription: "Get the current weather for a given city.")); + var toolCallOptions = new CreateResponseOptions( + model.Id, + new[] { ResponseItem.CreateUserMessageItem("Use the get_weather tool and then answer with the weather.") }) + { + StoredOutputEnabled = true, + ToolChoice = ResponseToolChoice.CreateRequiredChoice(), + MaxOutputTokenCount = 64, + Temperature = 0.0f, + }; + toolCallOptions.Tools.Add(getWeatherTool); -ResponseResult toolCallResponse = await responses.CreateResponseAsync(toolOptions); + ResponseResult toolResponse = await responses.CreateResponseAsync(toolCallOptions); -// Find the function-call output item the model produced. -FunctionCallResponseItem? functionCall = null; -foreach (var item in toolCallResponse.OutputItems) -{ - if (item is FunctionCallResponseItem fc && fc.FunctionName == "get_weather") + FunctionCallResponseItem? functionCall = null; + foreach (var item in toolResponse.OutputItems) { - functionCall = fc; - break; + if (item is FunctionCallResponseItem fc && fc.FunctionName == "get_weather") + { + functionCall = fc; + break; + } } -} -if (functionCall is null) -{ - Console.WriteLine("Model did not produce a function call; skipping tool round-trip."); -} -else -{ - var argsJson = functionCall.FunctionArguments?.ToString() ?? "{}"; - var city = "unknown"; - try + if (functionCall is null) { - city = JsonDocument.Parse(argsJson).RootElement.GetProperty("city").GetString() ?? "unknown"; + Console.WriteLine("Model did not produce a function call; skipping tool round-trip."); } - catch (KeyNotFoundException) { /* model gave us no city */ } - - Console.WriteLine($"Tool call: get_weather(city=\"{city}\")"); - var toolOutput = $$$"""{"city": "{{{city}}}", "temperatureF": 68, "summary": "partly cloudy"}"""; - Console.WriteLine($"Tool output: {toolOutput}"); - - // Submit the tool's output and ask the model to continue using `previous_response_id`. - var followUpOptions = new CreateResponseOptions( - model.Id, - new[] { ResponseItem.CreateFunctionCallOutputItem(functionCall.CallId, toolOutput) }) + else { - PreviousResponseId = toolCallResponse.Id, - StoredOutputEnabled = true, - }; - followUpOptions.Tools.Add(ResponseTool.CreateFunctionTool( - functionName: "get_weather", - functionParameters: weatherSchema, - strictModeEnabled: true, - functionDescription: "Get the current weather for a given city.")); - - ResponseResult finalResponse = await responses.CreateResponseAsync(followUpOptions); - Console.WriteLine($"[ASSISTANT]: {finalResponse.GetOutputText()}"); + Console.WriteLine($"[TOOL CALL]: {functionCall.FunctionName}({functionCall.FunctionArguments})"); + + const string toolOutput = """{"location": "Seattle", "weather": "72 degrees F and sunny"}"""; + + var followUpOptions = new CreateResponseOptions( + model.Id, + new[] { ResponseItem.CreateFunctionCallOutputItem(functionCall.CallId, toolOutput) }) + { + PreviousResponseId = toolResponse.Id, + StoredOutputEnabled = true, + MaxOutputTokenCount = 64, + Temperature = 0.0f, + }; + followUpOptions.Tools.Add(getWeatherTool); + + ResponseResult finalResponse = await responses.CreateResponseAsync(followUpOptions); + Console.WriteLine($"[ASSISTANT FINAL]: {finalResponse.GetOutputText()}"); + } + // <<<<<< END OPEN AI RESPONSES SDK USAGE >>>>>> +} +finally +{ + // Tidy up + await mgr.StopWebServiceAsync(); + await model.UnloadAsync(); } -// <<<<<< END OPEN AI RESPONSES SDK USAGE >>>>>> - -// Tidy up -await mgr.StopWebServiceAsync(); -await model.UnloadAsync(); // diff --git a/samples/cs/responses-foundry-local-web-server/README.md b/samples/cs/responses-foundry-local-web-server/README.md new file mode 100644 index 00000000..1cf285bb --- /dev/null +++ b/samples/cs/responses-foundry-local-web-server/README.md @@ -0,0 +1,55 @@ +# Foundry Local Responses web service sample (C#) + +This sample starts the Foundry Local OpenAI-compatible web service, then uses the official OpenAI .NET SDK to call the Responses API. + +The pattern is: + +1. `FoundryLocalManager` handles Foundry Local setup, model download/load, web service startup, and cleanup. +1. `OpenAI.Responses.ResponsesClient` (from the official `OpenAI` NuGet package) handles the actual `/v1/responses` calls. + +## Prerequisites + +- .NET 9 SDK +- Internet access on first run to download the sample model + +## What the sample does + +1. Initializes `FoundryLocalManager`. +1. Downloads and registers execution providers. +1. Downloads and loads `qwen2.5-0.5b`. +1. Starts the local web service at `http://127.0.0.1:52495`. +1. Creates an `OpenAIClient` pointed at `http://127.0.0.1:52495/v1`. +1. Runs a non-streaming Responses call. +1. Runs a streaming Responses call (`StreamingResponseOutputTextDeltaUpdate` events). +1. Runs a Responses function-calling flow with a sample `get_weather` tool, then submits a tool result back via `previous_response_id`. +1. Stops the web service and unloads the model. + +## Run the sample + +```powershell +cd samples/cs/responses-foundry-local-web-server +dotnet run +``` + +## Expected output + +```text +=== Non-streaming === +[ASSISTANT]: 4 + +=== Streaming === +[ASSISTANT]: 1, 2, 3. + +=== Function calling === +Tool call: get_weather() +Tool output: {"location": "Seattle", "weather": "72 degrees F and sunny"} +[ASSISTANT]: It's 72 degrees F and sunny in Seattle. +``` + +The exact model text varies. + +## Troubleshooting + +If the sample fails while creating `FoundryLocalManager` with a native symbol error such as `Failed to resolve 'execute_command_with_binary' symbol`, the installed Foundry Local Core runtime is older than the native bits expect. Try the latest stable `Microsoft.AI.Foundry.Local[.WinML]` package, or a recent ORT-Nightly package if needed. + +If port `52495` is already in use, edit `Program.cs` and change `config.Web.Urls`. diff --git a/sdk/cs/test/FoundryLocal.Tests/ResponsesIntegrationTests.cs b/sdk/cs/test/FoundryLocal.Tests/ResponsesIntegrationTests.cs index c4416d0c..3fb150c0 100644 --- a/sdk/cs/test/FoundryLocal.Tests/ResponsesIntegrationTests.cs +++ b/sdk/cs/test/FoundryLocal.Tests/ResponsesIntegrationTests.cs @@ -10,7 +10,6 @@ namespace Microsoft.AI.Foundry.Local.Tests; using System.ClientModel; using System.Linq; using System.Text; -using System.Text.Json; using System.Threading.Tasks; using OfficialOpenAI = global::OpenAI; @@ -23,9 +22,10 @@ namespace Microsoft.AI.Foundry.Local.Tests; /// web service. The Responses API is exercised through the official OpenAI .NET package /// pointed at the local /v1 endpoint. /// -/// These tests require a cached qwen2.5-0.5b model and a working Foundry Local -/// runtime; they are skipped automatically if the model can't be loaded locally -/// (e.g. CI without a model cache). +/// Mirrors sdk/js/test/openai/responsesWebService.test.ts from the JS PR: +/// - Skips when running in CI. +/// - Skips when qwen2.5-0.5b is not in the local cache. +/// - Covers non-streaming, streaming, and a full function-calling round-trip. /// internal sealed class ResponsesIntegrationTests { @@ -35,23 +35,30 @@ internal sealed class ResponsesIntegrationTests private static IModel? model; private static OfficialOpenAI.OpenAIClient? openAiClient; private static OfficialResponses.ResponsesClient? responses; + private static string? skipReason; [Before(Class)] public static async Task Setup() { + if (Utils.IsRunningInCI()) + { + skipReason = "Responses integration tests require a local model cache; skipped in CI."; + return; + } + var manager = FoundryLocalManager.Instance; // initialized by Utils var catalog = await manager.GetCatalogAsync(); var modelVariant = await catalog.GetModelVariantAsync(ModelVariant).ConfigureAwait(false); if (modelVariant is null) { - // Model isn't in this environment's catalog; skip the suite. + skipReason = $"Model variant '{ModelVariant}' is not in the catalog."; return; } if (!await modelVariant.IsCachedAsync().ConfigureAwait(false)) { - // Don't download in tests — leave it to the developer/CI to pre-cache. + skipReason = $"Model '{ModelAlias}' is not cached locally; pre-cache via the SDK to enable these tests."; return; } @@ -63,7 +70,9 @@ public static async Task Setup() await Assert.That(manager.Urls).IsNotNull().And.IsNotEmpty(); var endpoint = new Uri(manager.Urls![0].TrimEnd('/') + "/v1"); - openAiClient = new OfficialOpenAI.OpenAIClient(new ApiKeyCredential("notneeded"), new OfficialOpenAI.OpenAIClientOptions { Endpoint = endpoint }); + openAiClient = new OfficialOpenAI.OpenAIClient( + new ApiKeyCredential("notneeded"), + new OfficialOpenAI.OpenAIClientOptions { Endpoint = endpoint }); responses = openAiClient.GetResponsesClient(); } @@ -95,40 +104,65 @@ public static async Task TearDown() private static (OfficialResponses.ResponsesClient Client, IModel Model) RequireSetup() { - if (responses is null || model is null) + if (skipReason is not null || responses is null || model is null) { - Skip.Test($"Skipping: '{ModelAlias}' is not cached locally; run the SDK once to download it."); + Skip.Test(skipReason ?? "Responses integration setup did not complete."); } return (responses!, model!); } [Test] - public async Task NonStreaming_SimplePrompt_ReturnsText() + public async Task NonStreaming_SimplePrompt_ReturnsCompletedResponseWithText() { var (client, m) = RequireSetup(); - OfficialResponses.ResponseResult response = await client.CreateResponseAsync(m.Id, "What is 2 + 2? Respond with just the number.") - .ConfigureAwait(false); + + var options = new OfficialResponses.CreateResponseOptions( + m.Id, + new[] { OfficialResponses.ResponseItem.CreateUserMessageItem("What is 2 + 2? Answer with just the number.") }) + { + Temperature = 0.0f, + MaxOutputTokenCount = 64, + StoredOutputEnabled = false, + }; + + OfficialResponses.ResponseResult response = await client.CreateResponseAsync(options).ConfigureAwait(false); await Assert.That(response).IsNotNull(); + await Assert.That(response.Status).IsEqualTo(OfficialResponses.ResponseStatus.Completed); + var text = response.GetOutputText(); Console.WriteLine($"[NonStreaming] {text}"); await Assert.That(text).IsNotNull().And.IsNotEmpty(); } [Test] - public async Task Streaming_EmitsTextDeltaAndCompletionEvents() + public async Task Streaming_EmitsCreatedDeltaAndCompletedEvents() { var (client, m) = RequireSetup(); + var options = new OfficialResponses.CreateResponseOptions( + m.Id, + new[] { OfficialResponses.ResponseItem.CreateUserMessageItem("Count from 1 to 3.") }) + { + Temperature = 0.0f, + MaxOutputTokenCount = 64, + StoredOutputEnabled = false, + StreamingEnabled = true, + }; + + var sawCreated = false; var sawTextDelta = false; var sawCompleted = false; var aggregate = new StringBuilder(); - await foreach (OfficialResponses.StreamingResponseUpdate update in client.CreateResponseStreamingAsync(m.Id, "Count from 1 to 3.")) + await foreach (OfficialResponses.StreamingResponseUpdate update in client.CreateResponseStreamingAsync(options).ConfigureAwait(false)) { switch (update) { + case OfficialResponses.StreamingResponseCreatedUpdate: + sawCreated = true; + break; case OfficialResponses.StreamingResponseOutputTextDeltaUpdate delta when !string.IsNullOrEmpty(delta.Delta): sawTextDelta = true; aggregate.Append(delta.Delta); @@ -140,6 +174,7 @@ public async Task Streaming_EmitsTextDeltaAndCompletionEvents() } Console.WriteLine($"[Streaming] aggregated: {aggregate}"); + await Assert.That(sawCreated).IsTrue(); await Assert.That(sawTextDelta).IsTrue(); await Assert.That(sawCompleted).IsTrue(); } @@ -149,74 +184,58 @@ public async Task FunctionCalling_FullRoundTrip_ProducesAssistantText() { var (client, m) = RequireSetup(); - var weatherSchema = BinaryData.FromString(""" + var emptyParamsSchema = BinaryData.FromString(""" { "type": "object", - "properties": { - "city": { "type": "string", "description": "The city to look up" } - }, - "required": ["city"] + "properties": {}, + "additionalProperties": false } """); + OfficialResponses.ResponseTool getWeatherTool = OfficialResponses.ResponseTool.CreateFunctionTool( + functionName: "get_weather", + functionParameters: emptyParamsSchema, + strictModeEnabled: true, + functionDescription: "Get the current weather. This test always returns Seattle weather."); + var initialOptions = new OfficialResponses.CreateResponseOptions( m.Id, - new[] { OfficialResponses.ResponseItem.CreateUserMessageItem("Use get_weather to look up the weather in Seattle, then summarize it.") }) + new[] { OfficialResponses.ResponseItem.CreateUserMessageItem("Use the get_weather tool and then answer with the weather.") }) { - StoredOutputEnabled = true, ToolChoice = OfficialResponses.ResponseToolChoice.CreateRequiredChoice(), - MaxOutputTokenCount = 256, Temperature = 0.0f, + MaxOutputTokenCount = 64, + StoredOutputEnabled = true, }; - initialOptions.Tools.Add(OfficialResponses.ResponseTool.CreateFunctionTool( - functionName: "get_weather", - functionParameters: weatherSchema, - strictModeEnabled: true, - functionDescription: "Get the current weather for a given city.")); + initialOptions.Tools.Add(getWeatherTool); - OfficialResponses.ResponseResult firstResponse = await client.CreateResponseAsync(initialOptions).ConfigureAwait(false); + OfficialResponses.ResponseResult toolResponse = await client.CreateResponseAsync(initialOptions).ConfigureAwait(false); - var functionCall = firstResponse.OutputItems + var functionCall = toolResponse.OutputItems .OfType() .FirstOrDefault(item => item.FunctionName == "get_weather"); await Assert.That(functionCall).IsNotNull(); + await Assert.That(functionCall!.CallId).IsNotNull().And.IsNotEmpty(); - var argsJson = functionCall!.FunctionArguments?.ToString() ?? "{}"; - string? city = null; - try - { - using var doc = JsonDocument.Parse(argsJson); - if (doc.RootElement.TryGetProperty("city", out var cityElement)) - { - city = cityElement.GetString(); - } - } - catch (JsonException) - { - // small models occasionally emit malformed args; treat as unknown - } - - var toolOutput = $$$"""{"city": "{{{city ?? "unknown"}}}", "temperatureF": 68, "summary": "partly cloudy"}"""; + const string toolOutput = """{"location": "Seattle", "weather": "72 degrees F and sunny"}"""; var followUpOptions = new OfficialResponses.CreateResponseOptions( m.Id, new[] { OfficialResponses.ResponseItem.CreateFunctionCallOutputItem(functionCall.CallId, toolOutput) }) { - PreviousResponseId = firstResponse.Id, - StoredOutputEnabled = true, - MaxOutputTokenCount = 256, + PreviousResponseId = toolResponse.Id, Temperature = 0.0f, + MaxOutputTokenCount = 64, + StoredOutputEnabled = false, }; - followUpOptions.Tools.Add(OfficialResponses.ResponseTool.CreateFunctionTool( - functionName: "get_weather", - functionParameters: weatherSchema, - strictModeEnabled: true, - functionDescription: "Get the current weather for a given city.")); + followUpOptions.Tools.Add(getWeatherTool); OfficialResponses.ResponseResult finalResponse = await client.CreateResponseAsync(followUpOptions).ConfigureAwait(false); + await Assert.That(finalResponse.Status).IsEqualTo(OfficialResponses.ResponseStatus.Completed); + var finalText = finalResponse.GetOutputText(); - Console.WriteLine($"[FunctionCalling] tool_call_id={functionCall.CallId}, city={city}, final={finalText}"); + Console.WriteLine($"[FunctionCalling] tool_call_id={functionCall.CallId} final={finalText}"); await Assert.That(finalText).IsNotNull().And.IsNotEmpty(); } } diff --git a/sdk/cs/test/FoundryLocal.Tests/Utils.cs b/sdk/cs/test/FoundryLocal.Tests/Utils.cs index a289011b..539cbf44 100644 --- a/sdk/cs/test/FoundryLocal.Tests/Utils.cs +++ b/sdk/cs/test/FoundryLocal.Tests/Utils.cs @@ -435,7 +435,9 @@ private static List BuildTestCatalog(bool includeCuda = true) private static string GetSourceFilePath([CallerFilePath] string path = "") => path; - // Gets the root directory of the foundry-local-sdk repository by finding the .git directory. + // Gets the root directory of the foundry-local-sdk repository by finding the .git entry. + // In a regular clone the .git entry is a directory; in a worktree it is a file containing + // a `gitdir:` pointer. Accept either so tests can run from worktrees. private static string GetRepoRoot() { var sourceFile = GetSourceFilePath(); @@ -443,7 +445,8 @@ private static string GetRepoRoot() while (dir != null) { - if (Directory.Exists(Path.Combine(dir.FullName, ".git"))) + var gitPath = Path.Combine(dir.FullName, ".git"); + if (Directory.Exists(gitPath) || File.Exists(gitPath)) return dir.FullName; dir = dir.Parent;