From 527dab37180454483c8d813a6e8940e2569054a2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 19:46:09 +0000 Subject: [PATCH 1/3] Initial plan From 6a16433ce053dd9a092557f50fabffb68062aba2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 19:54:56 +0000 Subject: [PATCH 2/3] Refactor: Make IChatClient implementations internal and auto-configure RawRepresentationFactory Co-authored-by: kzu <169707+kzu@users.noreply.github.com> --- src/Agents/Agents.csproj | 8 +- src/Agents/ConfigurableAIAgent.cs | 3 +- src/Extensions/ChatExtensions.cs | 56 ------- src/Extensions/Extensions.csproj | 1 + .../OpenAI/AzureInferenceChatClient.cs | 2 +- .../OpenAI/AzureOpenAIChatClient.cs | 6 +- src/Extensions/OpenAI/OpenAIChatClient.cs | 6 +- src/Extensions/OpenAI/OpenAIExtensions.cs | 145 +++++++++++++++--- src/Tests/ChatExtensionsTests.cs | 87 +++++++++++ src/Tests/OpenAITests.cs | 4 +- 10 files changed, 227 insertions(+), 91 deletions(-) diff --git a/src/Agents/Agents.csproj b/src/Agents/Agents.csproj index b084dad..3fb6b4d 100644 --- a/src/Agents/Agents.csproj +++ b/src/Agents/Agents.csproj @@ -18,10 +18,10 @@ - - - - + + + + diff --git a/src/Agents/ConfigurableAIAgent.cs b/src/Agents/ConfigurableAIAgent.cs index e5e28a6..4fffe3a 100644 --- a/src/Agents/ConfigurableAIAgent.cs +++ b/src/Agents/ConfigurableAIAgent.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using System.Text.Json; using Devlooped.Extensions.AI; +using Devlooped.Extensions.AI.OpenAI; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; @@ -118,7 +119,7 @@ public override IAsyncEnumerable RunStreamingAsync(IEnum var provider = client.GetService()?.ProviderName; ChatOptions? chat = provider == "xai" ? configSection.GetSection("options").Get() - : configSection.GetSection("options").Get(); + : configSection.GetSection("options").Get(); if (chat is not null) options.ChatOptions = chat; diff --git a/src/Extensions/ChatExtensions.cs b/src/Extensions/ChatExtensions.cs index c1581a9..5d070fc 100644 --- a/src/Extensions/ChatExtensions.cs +++ b/src/Extensions/ChatExtensions.cs @@ -29,61 +29,5 @@ public string? EndUserId get => (options.AdditionalProperties ??= []).TryGetValue("EndUserId", out var value) ? value as string : null; set => (options.AdditionalProperties ??= [])["EndUserId"] = value; } - - /// Sets the effort level for a reasoning AI model when generating responses, if supported by the model. - public ReasoningEffort? ReasoningEffort - { - get => options.AdditionalProperties?.TryGetValue("reasoning_effort", out var value) == true && value is ReasoningEffort effort ? effort : null; - set - { - if (value is not null) - { - options.AdditionalProperties ??= []; - options.AdditionalProperties["reasoning_effort"] = value; - } - else - { - options.AdditionalProperties?.Remove("reasoning_effort"); - } - } - } - - /// Sets the level for a GPT-5 model when generating responses, if supported - public Verbosity? Verbosity - { - get => options.AdditionalProperties?.TryGetValue("verbosity", out var value) == true && value is Verbosity verbosity ? verbosity : null; - set - { - if (value is not null) - { - options.AdditionalProperties ??= []; - options.AdditionalProperties["verbosity"] = value; - } - else - { - options.AdditionalProperties?.Remove("verbosity"); - } - } - } - } -} - -// Workaround to get the config binder to set these extension properties. -/// -/// Defines extended we provide via extension properties. -/// -/// This should ideally even be auto-generated from the available extensions so it's always in sync. -[EditorBrowsable(EditorBrowsableState.Never)] -public class ExtendedChatOptions : ChatOptions -{ - public ReasoningEffort? ReasoningEffort - { - get => ((ChatOptions)this).ReasoningEffort; - set => ((ChatOptions)this).ReasoningEffort = value; - } - public Verbosity? Verbosity - { - get => ((ChatOptions)this).Verbosity; - set => ((ChatOptions)this).Verbosity = value; } } \ No newline at end of file diff --git a/src/Extensions/Extensions.csproj b/src/Extensions/Extensions.csproj index ada167c..6e19107 100644 --- a/src/Extensions/Extensions.csproj +++ b/src/Extensions/Extensions.csproj @@ -41,6 +41,7 @@ + \ No newline at end of file diff --git a/src/Extensions/OpenAI/AzureInferenceChatClient.cs b/src/Extensions/OpenAI/AzureInferenceChatClient.cs index ad85740..3e1b900 100644 --- a/src/Extensions/OpenAI/AzureInferenceChatClient.cs +++ b/src/Extensions/OpenAI/AzureInferenceChatClient.cs @@ -8,7 +8,7 @@ namespace Devlooped.Extensions.AI.OpenAI; /// /// An implementation for Azure AI Inference that supports per-request model selection. /// -public class AzureInferenceChatClient : IChatClient +internal class AzureInferenceChatClient : IChatClient { readonly ConcurrentDictionary clients = new(); diff --git a/src/Extensions/OpenAI/AzureOpenAIChatClient.cs b/src/Extensions/OpenAI/AzureOpenAIChatClient.cs index d1c615b..c1af2ca 100644 --- a/src/Extensions/OpenAI/AzureOpenAIChatClient.cs +++ b/src/Extensions/OpenAI/AzureOpenAIChatClient.cs @@ -9,7 +9,7 @@ namespace Devlooped.Extensions.AI.OpenAI; /// /// An implementation for Azure OpenAI that supports per-request model selection. /// -public class AzureOpenAIChatClient : IChatClient +internal class AzureOpenAIChatClient : IChatClient { readonly ConcurrentDictionary clients = new(); @@ -45,11 +45,11 @@ public AzureOpenAIChatClient(Uri endpoint, ApiKeyCredential credential, string m /// public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellation = default) - => GetChatClient(options?.ModelId ?? modelId).GetResponseAsync(messages, options.ApplyExtensions(), cancellation); + => GetChatClient(options?.ModelId ?? modelId).GetResponseAsync(messages, options, cancellation); /// public IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellation = default) - => GetChatClient(options?.ModelId ?? modelId).GetStreamingResponseAsync(messages, options.ApplyExtensions(), cancellation); + => GetChatClient(options?.ModelId ?? modelId).GetStreamingResponseAsync(messages, options, cancellation); IChatClient GetChatClient(string modelId) => clients.GetOrAdd(modelId, model => new PipelineClient(pipeline, endpoint, options).GetOpenAIResponseClient(modelId).AsIChatClient()); diff --git a/src/Extensions/OpenAI/OpenAIChatClient.cs b/src/Extensions/OpenAI/OpenAIChatClient.cs index 33a08e0..8f9d26b 100644 --- a/src/Extensions/OpenAI/OpenAIChatClient.cs +++ b/src/Extensions/OpenAI/OpenAIChatClient.cs @@ -9,7 +9,7 @@ namespace Devlooped.Extensions.AI.OpenAI; /// /// An implementation for OpenAI that supports per-request model selection. /// -public class OpenAIChatClient : IChatClient +internal class OpenAIChatClient : IChatClient { readonly ConcurrentDictionary clients = new(); readonly string modelId; @@ -37,11 +37,11 @@ public OpenAIChatClient(string apiKey, string modelId, OpenAIClientOptions? opti /// public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellation = default) - => GetChatClient(options?.ModelId ?? modelId).GetResponseAsync(messages, options.ApplyExtensions(), cancellation); + => GetChatClient(options?.ModelId ?? modelId).GetResponseAsync(messages, options, cancellation); /// public IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellation = default) - => GetChatClient(options?.ModelId ?? modelId).GetStreamingResponseAsync(messages, options.ApplyExtensions(), cancellation); + => GetChatClient(options?.ModelId ?? modelId).GetStreamingResponseAsync(messages, options, cancellation); IChatClient GetChatClient(string modelId) => clients.GetOrAdd(modelId, model => new PipelineClient(pipeline, options).GetOpenAIResponseClient(modelId).AsIChatClient()); diff --git a/src/Extensions/OpenAI/OpenAIExtensions.cs b/src/Extensions/OpenAI/OpenAIExtensions.cs index 13e7c56..61d24e0 100644 --- a/src/Extensions/OpenAI/OpenAIExtensions.cs +++ b/src/Extensions/OpenAI/OpenAIExtensions.cs @@ -1,4 +1,5 @@ using System.ClientModel.Primitives; +using System.ComponentModel; using System.Text.Json; using Microsoft.Extensions.AI; using OpenAI.Responses; @@ -6,42 +7,109 @@ namespace Devlooped.Extensions.AI.OpenAI; /// -/// Allows applying extension properties to the when using -/// them with an OpenAI client. +/// Provides OpenAI-specific extension properties for when using +/// the OpenAI Responses API. /// +[EditorBrowsable(EditorBrowsableState.Never)] public static class OpenAIExtensions { + // Key used to mark that our factory has been installed + const string FactoryInstalledKey = "__OpenAIResponsesFactory"; + /// - /// Applies the extension properties to the so that - /// the underlying OpenAI client can properly forward them to the endpoint. + /// Gets or sets the effort level for a reasoning AI model when generating responses. /// /// - /// Only use this if you are not using , which already applies - /// extensions before sending requests. + /// This property is specific to the OpenAI Responses API. Setting this property automatically + /// configures the to properly forward the + /// value to the OpenAI endpoint. Do not manually set + /// when using this property. /// - /// An options with the right replaced - /// so it can forward extensions to the underlying OpenAI API. - public static ChatOptions? ApplyExtensions(this ChatOptions? options) + /// Thrown when + /// has been set to a non-OpenAI factory. + extension(ChatOptions options) { - if (options is null) - return null; + public ReasoningEffort? ReasoningEffort + { + get => options.AdditionalProperties?.TryGetValue("reasoning_effort", out var value) == true && value is ReasoningEffort effort ? effort : null; + set + { + if (value is not null) + { + options.AdditionalProperties ??= []; + options.AdditionalProperties["reasoning_effort"] = value; + EnsureFactoryInstalled(options); + } + else + { + options.AdditionalProperties?.Remove("reasoning_effort"); + } + } + } - if (options.ReasoningEffort.HasValue || options.Verbosity.HasValue) + /// + /// Gets or sets the level for a GPT-5 model when generating responses. + /// + /// + /// This property is specific to the OpenAI Responses API and only supported by GPT-5+ models. + /// Setting this property automatically configures the + /// to properly forward the value to the OpenAI endpoint. Do not manually set + /// when using this property. + /// + /// Thrown when + /// has been set to a non-OpenAI factory. + public Verbosity? Verbosity { - options.RawRepresentationFactory = _ => + get => options.AdditionalProperties?.TryGetValue("verbosity", out var value) == true && value is Verbosity verbosity ? verbosity : null; + set { - var creation = new ResponseCreationOptions(); - if (options.ReasoningEffort.HasValue) - creation.ReasoningOptions = new ReasoningEffortOptions(options.ReasoningEffort!.Value); + if (value is not null) + { + options.AdditionalProperties ??= []; + options.AdditionalProperties["verbosity"] = value; + EnsureFactoryInstalled(options); + } + else + { + options.AdditionalProperties?.Remove("verbosity"); + } + } + } + } + + static void EnsureFactoryInstalled(ChatOptions options) + { + options.AdditionalProperties ??= []; - if (options.Verbosity.HasValue) - creation.TextOptions = new VerbosityOptions(options.Verbosity!.Value); + // Check if our factory is already installed + if (options.AdditionalProperties.TryGetValue(FactoryInstalledKey, out var _)) + return; - return creation; - }; + // Check if a different factory has been set + if (options.RawRepresentationFactory is not null) + { + throw new InvalidOperationException( + "Cannot use OpenAI Responses API extension properties (ReasoningEffort, Verbosity) when " + + "RawRepresentationFactory has already been set to a custom factory. These extension " + + "properties automatically configure the factory for the OpenAI Responses API."); } - return options; + // Install our factory + options.RawRepresentationFactory = _ => CreateResponseCreationOptions(options); + options.AdditionalProperties[FactoryInstalledKey] = true; + } + + static ResponseCreationOptions CreateResponseCreationOptions(ChatOptions options) + { + var creation = new ResponseCreationOptions(); + + if (options.ReasoningEffort is { } effort) + creation.ReasoningOptions = new ReasoningEffortOptions(effort); + + if (options.Verbosity is { } verbosity) + creation.TextOptions = new VerbosityOptions(verbosity); + + return creation; } class ReasoningEffortOptions(ReasoningEffort effort) : ResponseReasoningOptions @@ -64,3 +132,38 @@ protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWri } } } + +/// +/// Extended that includes OpenAI Responses API specific properties. +/// +/// +/// This class is provided for configuration binding scenarios. The +/// and properties are specific to the OpenAI Responses API. +/// +[EditorBrowsable(EditorBrowsableState.Never)] +public class OpenAIChatOptions : ChatOptions +{ + /// + /// Gets or sets the effort level for a reasoning AI model when generating responses. + /// + /// + /// This property is specific to the OpenAI Responses API. + /// + public ReasoningEffort? ReasoningEffort + { + get => ((ChatOptions)this).ReasoningEffort; + set => ((ChatOptions)this).ReasoningEffort = value; + } + + /// + /// Gets or sets the verbosity level for a GPT-5+ model when generating responses. + /// + /// + /// This property is specific to the OpenAI Responses API and only supported by GPT-5+ models. + /// + public Verbosity? Verbosity + { + get => ((ChatOptions)this).Verbosity; + set => ((ChatOptions)this).Verbosity = value; + } +} diff --git a/src/Tests/ChatExtensionsTests.cs b/src/Tests/ChatExtensionsTests.cs index 80b88ac..c797c3a 100644 --- a/src/Tests/ChatExtensionsTests.cs +++ b/src/Tests/ChatExtensionsTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Text; using Devlooped.Extensions.AI; +using Devlooped.Extensions.AI.OpenAI; using Microsoft.Extensions.AI; using static Devlooped.Extensions.AI.Chat; @@ -27,4 +28,90 @@ public void FactoryMethods() Assert.Equal(ChatRole.System, message.Role); Assert.Equal("hello", message.Text); } + + [Fact] + public void ReasoningEffort_AutoSetsFactory() + { + var options = new ChatOptions(); + + Assert.Null(options.RawRepresentationFactory); + + options.ReasoningEffort = ReasoningEffort.High; + + // Factory should now be auto-configured + Assert.NotNull(options.RawRepresentationFactory); + Assert.Equal(ReasoningEffort.High, options.ReasoningEffort); + } + + [Fact] + public void Verbosity_AutoSetsFactory() + { + var options = new ChatOptions(); + + Assert.Null(options.RawRepresentationFactory); + + options.Verbosity = Verbosity.Low; + + // Factory should now be auto-configured + Assert.NotNull(options.RawRepresentationFactory); + Assert.Equal(Verbosity.Low, options.Verbosity); + } + + [Fact] + public void ReasoningEffortAndVerbosity_ShareFactory() + { + var options = new ChatOptions(); + + options.ReasoningEffort = ReasoningEffort.Medium; + var factory1 = options.RawRepresentationFactory; + + options.Verbosity = Verbosity.High; + var factory2 = options.RawRepresentationFactory; + + // Factory should be the same - not replaced + Assert.Same(factory1, factory2); + + Assert.Equal(ReasoningEffort.Medium, options.ReasoningEffort); + Assert.Equal(Verbosity.High, options.Verbosity); + } + + [Fact] + public void ThrowsWhenCustomFactoryAlreadySet() + { + var options = new ChatOptions(); + + // Set a custom factory first + options.RawRepresentationFactory = _ => new object(); + + // Should throw when trying to use extension properties + Assert.Throws(() => options.ReasoningEffort = ReasoningEffort.High); + Assert.Throws(() => options.Verbosity = Verbosity.Low); + } + + [Fact] + public void SettingNullDoesNotConfigureFactory() + { + var options = new ChatOptions(); + + options.ReasoningEffort = null; + + // Factory should not be configured + Assert.Null(options.RawRepresentationFactory); + } + + [Fact] + public void OpenAIChatOptions_BindingClass_Works() + { + var options = new OpenAIChatOptions + { + ReasoningEffort = ReasoningEffort.Low, + Verbosity = Verbosity.Medium + }; + + Assert.Equal(ReasoningEffort.Low, options.ReasoningEffort); + Assert.Equal(Verbosity.Medium, options.Verbosity); + + // Factory should be auto-configured via extension property setters + Assert.NotNull(options.RawRepresentationFactory); + } } diff --git a/src/Tests/OpenAITests.cs b/src/Tests/OpenAITests.cs index a98ab14..a5ac732 100644 --- a/src/Tests/OpenAITests.cs +++ b/src/Tests/OpenAITests.cs @@ -186,7 +186,7 @@ public async Task GPT5_NoReasoningTokens() { ModelId = "gpt-5.1", ReasoningEffort = ReasoningEffort.Low - }.ApplyExtensions()); + }); Assert.StartsWith("gpt-5.1", reasoned.ModelId); Assert.NotNull(reasoned.Usage?.AdditionalCounts); @@ -199,7 +199,7 @@ public async Task GPT5_NoReasoningTokens() { ModelId = "gpt-5.1", ReasoningEffort = ReasoningEffort.None - }.ApplyExtensions()); + }); Assert.NotNull(nonreasoned.Usage?.AdditionalCounts); Assert.True(nonreasoned.Usage.AdditionalCounts.ContainsKey("OutputTokenDetails.ReasoningTokenCount")); From d789e9c3263b63ff5fb549e22414626949ea4321 Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Sat, 17 Jan 2026 17:35:06 -0300 Subject: [PATCH 3/3] Improve options factory usage and detection Rather than opaque key in the additional properties, use a more determininstic approach by checking the factory target instance being our own implementation of the factory. --- .../OpenAI/AzureInferenceChatClient.cs | 2 +- .../OpenAI/AzureOpenAIChatClient.cs | 2 +- src/Extensions/OpenAI/OpenAIChatClient.cs | 2 +- src/Extensions/OpenAI/OpenAIChatOptions.cs | 38 ++++++++ src/Extensions/OpenAI/OpenAIExtensions.cs | 93 ++----------------- .../OpenAI/ResponseOptionsFactory.cs | 42 +++++++++ src/Tests/ChatExtensionsTests.cs | 38 ++++---- 7 files changed, 112 insertions(+), 105 deletions(-) create mode 100644 src/Extensions/OpenAI/OpenAIChatOptions.cs create mode 100644 src/Extensions/OpenAI/ResponseOptionsFactory.cs diff --git a/src/Extensions/OpenAI/AzureInferenceChatClient.cs b/src/Extensions/OpenAI/AzureInferenceChatClient.cs index 3e1b900..b613aef 100644 --- a/src/Extensions/OpenAI/AzureInferenceChatClient.cs +++ b/src/Extensions/OpenAI/AzureInferenceChatClient.cs @@ -8,7 +8,7 @@ namespace Devlooped.Extensions.AI.OpenAI; /// /// An implementation for Azure AI Inference that supports per-request model selection. /// -internal class AzureInferenceChatClient : IChatClient +class AzureInferenceChatClient : IChatClient { readonly ConcurrentDictionary clients = new(); diff --git a/src/Extensions/OpenAI/AzureOpenAIChatClient.cs b/src/Extensions/OpenAI/AzureOpenAIChatClient.cs index c1af2ca..c575c2c 100644 --- a/src/Extensions/OpenAI/AzureOpenAIChatClient.cs +++ b/src/Extensions/OpenAI/AzureOpenAIChatClient.cs @@ -9,7 +9,7 @@ namespace Devlooped.Extensions.AI.OpenAI; /// /// An implementation for Azure OpenAI that supports per-request model selection. /// -internal class AzureOpenAIChatClient : IChatClient +class AzureOpenAIChatClient : IChatClient { readonly ConcurrentDictionary clients = new(); diff --git a/src/Extensions/OpenAI/OpenAIChatClient.cs b/src/Extensions/OpenAI/OpenAIChatClient.cs index 8f9d26b..5fc36d4 100644 --- a/src/Extensions/OpenAI/OpenAIChatClient.cs +++ b/src/Extensions/OpenAI/OpenAIChatClient.cs @@ -9,7 +9,7 @@ namespace Devlooped.Extensions.AI.OpenAI; /// /// An implementation for OpenAI that supports per-request model selection. /// -internal class OpenAIChatClient : IChatClient +class OpenAIChatClient : IChatClient { readonly ConcurrentDictionary clients = new(); readonly string modelId; diff --git a/src/Extensions/OpenAI/OpenAIChatOptions.cs b/src/Extensions/OpenAI/OpenAIChatOptions.cs new file mode 100644 index 0000000..907388a --- /dev/null +++ b/src/Extensions/OpenAI/OpenAIChatOptions.cs @@ -0,0 +1,38 @@ +using System.ComponentModel; +using Microsoft.Extensions.AI; + +namespace Devlooped.Extensions.AI.OpenAI; + +/// +/// Extended that includes OpenAI Responses API specific properties. +/// +/// +/// This class is provided for configuration binding scenarios. The +/// and properties are specific to the OpenAI Responses API. +/// +public class OpenAIChatOptions : ChatOptions +{ + /// + /// Gets or sets the effort level for a reasoning AI model when generating responses. + /// + /// + /// This property is specific to the OpenAI Responses API. + /// + public ReasoningEffort? ReasoningEffort + { + get => ((ChatOptions)this).ReasoningEffort; + set => ((ChatOptions)this).ReasoningEffort = value; + } + + /// + /// Gets or sets the verbosity level for a GPT-5+ model when generating responses. + /// + /// + /// This property is specific to the OpenAI Responses API and only supported by GPT-5+ models. + /// + public Verbosity? Verbosity + { + get => ((ChatOptions)this).Verbosity; + set => ((ChatOptions)this).Verbosity = value; + } +} diff --git a/src/Extensions/OpenAI/OpenAIExtensions.cs b/src/Extensions/OpenAI/OpenAIExtensions.cs index 61d24e0..dfa96c8 100644 --- a/src/Extensions/OpenAI/OpenAIExtensions.cs +++ b/src/Extensions/OpenAI/OpenAIExtensions.cs @@ -13,9 +13,6 @@ namespace Devlooped.Extensions.AI.OpenAI; [EditorBrowsable(EditorBrowsableState.Never)] public static class OpenAIExtensions { - // Key used to mark that our factory has been installed - const string FactoryInstalledKey = "__OpenAIResponsesFactory"; - /// /// Gets or sets the effort level for a reasoning AI model when generating responses. /// @@ -38,7 +35,7 @@ public ReasoningEffort? ReasoningEffort { options.AdditionalProperties ??= []; options.AdditionalProperties["reasoning_effort"] = value; - EnsureFactoryInstalled(options); + EnsureFactory(options); } else { @@ -67,7 +64,7 @@ public Verbosity? Verbosity { options.AdditionalProperties ??= []; options.AdditionalProperties["verbosity"] = value; - EnsureFactoryInstalled(options); + EnsureFactory(options); } else { @@ -77,16 +74,10 @@ public Verbosity? Verbosity } } - static void EnsureFactoryInstalled(ChatOptions options) + static void EnsureFactory(ChatOptions options) { - options.AdditionalProperties ??= []; - - // Check if our factory is already installed - if (options.AdditionalProperties.TryGetValue(FactoryInstalledKey, out var _)) - return; - - // Check if a different factory has been set - if (options.RawRepresentationFactory is not null) + if (options.RawRepresentationFactory is not null && + options.RawRepresentationFactory.Target is not ResponseOptionsFactory) { throw new InvalidOperationException( "Cannot use OpenAI Responses API extension properties (ReasoningEffort, Verbosity) when " + @@ -94,76 +85,6 @@ static void EnsureFactoryInstalled(ChatOptions options) "properties automatically configure the factory for the OpenAI Responses API."); } - // Install our factory - options.RawRepresentationFactory = _ => CreateResponseCreationOptions(options); - options.AdditionalProperties[FactoryInstalledKey] = true; - } - - static ResponseCreationOptions CreateResponseCreationOptions(ChatOptions options) - { - var creation = new ResponseCreationOptions(); - - if (options.ReasoningEffort is { } effort) - creation.ReasoningOptions = new ReasoningEffortOptions(effort); - - if (options.Verbosity is { } verbosity) - creation.TextOptions = new VerbosityOptions(verbosity); - - return creation; - } - - class ReasoningEffortOptions(ReasoningEffort effort) : ResponseReasoningOptions - { - protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) - { - writer.WritePropertyName("effort"u8); - writer.WriteStringValue(effort.ToString().ToLowerInvariant()); - base.JsonModelWriteCore(writer, options); - } - } - - class VerbosityOptions(Verbosity verbosity) : ResponseTextOptions - { - protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) - { - writer.WritePropertyName("verbosity"u8); - writer.WriteStringValue(verbosity.ToString().ToLowerInvariant()); - base.JsonModelWriteCore(writer, options); - } - } -} - -/// -/// Extended that includes OpenAI Responses API specific properties. -/// -/// -/// This class is provided for configuration binding scenarios. The -/// and properties are specific to the OpenAI Responses API. -/// -[EditorBrowsable(EditorBrowsableState.Never)] -public class OpenAIChatOptions : ChatOptions -{ - /// - /// Gets or sets the effort level for a reasoning AI model when generating responses. - /// - /// - /// This property is specific to the OpenAI Responses API. - /// - public ReasoningEffort? ReasoningEffort - { - get => ((ChatOptions)this).ReasoningEffort; - set => ((ChatOptions)this).ReasoningEffort = value; - } - - /// - /// Gets or sets the verbosity level for a GPT-5+ model when generating responses. - /// - /// - /// This property is specific to the OpenAI Responses API and only supported by GPT-5+ models. - /// - public Verbosity? Verbosity - { - get => ((ChatOptions)this).Verbosity; - set => ((ChatOptions)this).Verbosity = value; + options.RawRepresentationFactory ??= new ResponseOptionsFactory(options).CreateResponseCreationOptions; } -} +} \ No newline at end of file diff --git a/src/Extensions/OpenAI/ResponseOptionsFactory.cs b/src/Extensions/OpenAI/ResponseOptionsFactory.cs new file mode 100644 index 0000000..b3f5b0e --- /dev/null +++ b/src/Extensions/OpenAI/ResponseOptionsFactory.cs @@ -0,0 +1,42 @@ +using System.ClientModel.Primitives; +using System.Text.Json; +using Microsoft.Extensions.AI; +using OpenAI.Responses; + +namespace Devlooped.Extensions.AI.OpenAI; + +class ResponseOptionsFactory(ChatOptions options) +{ + public ResponseCreationOptions CreateResponseCreationOptions(IChatClient client) + { + var creation = new ResponseCreationOptions(); + + if (options.ReasoningEffort is { } effort) + creation.ReasoningOptions = new ReasoningEffortOptions(effort); + + if (options.Verbosity is { } verbosity) + creation.TextOptions = new VerbosityOptions(verbosity); + + return creation; + } + + class ReasoningEffortOptions(ReasoningEffort effort) : ResponseReasoningOptions + { + protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WritePropertyName("effort"u8); + writer.WriteStringValue(effort.ToString().ToLowerInvariant()); + base.JsonModelWriteCore(writer, options); + } + } + + class VerbosityOptions(Verbosity verbosity) : ResponseTextOptions + { + protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WritePropertyName("verbosity"u8); + writer.WriteStringValue(verbosity.ToString().ToLowerInvariant()); + base.JsonModelWriteCore(writer, options); + } + } +} diff --git a/src/Tests/ChatExtensionsTests.cs b/src/Tests/ChatExtensionsTests.cs index c797c3a..874c2fa 100644 --- a/src/Tests/ChatExtensionsTests.cs +++ b/src/Tests/ChatExtensionsTests.cs @@ -4,6 +4,8 @@ using Devlooped.Extensions.AI; using Devlooped.Extensions.AI.OpenAI; using Microsoft.Extensions.AI; +using Moq; +using OpenAI.Responses; using static Devlooped.Extensions.AI.Chat; namespace Devlooped; @@ -33,11 +35,11 @@ public void FactoryMethods() public void ReasoningEffort_AutoSetsFactory() { var options = new ChatOptions(); - + Assert.Null(options.RawRepresentationFactory); - + options.ReasoningEffort = ReasoningEffort.High; - + // Factory should now be auto-configured Assert.NotNull(options.RawRepresentationFactory); Assert.Equal(ReasoningEffort.High, options.ReasoningEffort); @@ -47,11 +49,11 @@ public void ReasoningEffort_AutoSetsFactory() public void Verbosity_AutoSetsFactory() { var options = new ChatOptions(); - + Assert.Null(options.RawRepresentationFactory); - + options.Verbosity = Verbosity.Low; - + // Factory should now be auto-configured Assert.NotNull(options.RawRepresentationFactory); Assert.Equal(Verbosity.Low, options.Verbosity); @@ -61,16 +63,16 @@ public void Verbosity_AutoSetsFactory() public void ReasoningEffortAndVerbosity_ShareFactory() { var options = new ChatOptions(); - + options.ReasoningEffort = ReasoningEffort.Medium; var factory1 = options.RawRepresentationFactory; - + options.Verbosity = Verbosity.High; var factory2 = options.RawRepresentationFactory; - + // Factory should be the same - not replaced Assert.Same(factory1, factory2); - + Assert.Equal(ReasoningEffort.Medium, options.ReasoningEffort); Assert.Equal(Verbosity.High, options.Verbosity); } @@ -79,10 +81,10 @@ public void ReasoningEffortAndVerbosity_ShareFactory() public void ThrowsWhenCustomFactoryAlreadySet() { var options = new ChatOptions(); - + // Set a custom factory first options.RawRepresentationFactory = _ => new object(); - + // Should throw when trying to use extension properties Assert.Throws(() => options.ReasoningEffort = ReasoningEffort.High); Assert.Throws(() => options.Verbosity = Verbosity.Low); @@ -92,9 +94,9 @@ public void ThrowsWhenCustomFactoryAlreadySet() public void SettingNullDoesNotConfigureFactory() { var options = new ChatOptions(); - + options.ReasoningEffort = null; - + // Factory should not be configured Assert.Null(options.RawRepresentationFactory); } @@ -107,11 +109,15 @@ public void OpenAIChatOptions_BindingClass_Works() ReasoningEffort = ReasoningEffort.Low, Verbosity = Verbosity.Medium }; - + Assert.Equal(ReasoningEffort.Low, options.ReasoningEffort); Assert.Equal(Verbosity.Medium, options.Verbosity); - + // Factory should be auto-configured via extension property setters Assert.NotNull(options.RawRepresentationFactory); + + var responseOptions = options.RawRepresentationFactory(Mock.Of()); + + Assert.IsType(responseOptions); } }