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);
}
}