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..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.
///
-public 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 d1c615b..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.
///
-public class AzureOpenAIChatClient : IChatClient
+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..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.
///
-public class OpenAIChatClient : IChatClient
+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/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 13e7c56..dfa96c8 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,61 +7,84 @@
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
{
///
- /// 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;
-
- if (options.ReasoningEffort.HasValue || options.Verbosity.HasValue)
+ public ReasoningEffort? ReasoningEffort
{
- options.RawRepresentationFactory = _ =>
+ get => options.AdditionalProperties?.TryGetValue("reasoning_effort", out var value) == true && value is ReasoningEffort effort ? effort : null;
+ set
{
- var creation = new ResponseCreationOptions();
- if (options.ReasoningEffort.HasValue)
- creation.ReasoningOptions = new ReasoningEffortOptions(options.ReasoningEffort!.Value);
-
- if (options.Verbosity.HasValue)
- creation.TextOptions = new VerbosityOptions(options.Verbosity!.Value);
-
- return creation;
- };
+ if (value is not null)
+ {
+ options.AdditionalProperties ??= [];
+ options.AdditionalProperties["reasoning_effort"] = value;
+ EnsureFactory(options);
+ }
+ else
+ {
+ options.AdditionalProperties?.Remove("reasoning_effort");
+ }
+ }
}
- return options;
- }
-
- class ReasoningEffortOptions(ReasoningEffort effort) : ResponseReasoningOptions
- {
- protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options)
+ ///
+ /// 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
{
- writer.WritePropertyName("effort"u8);
- writer.WriteStringValue(effort.ToString().ToLowerInvariant());
- base.JsonModelWriteCore(writer, options);
+ 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;
+ EnsureFactory(options);
+ }
+ else
+ {
+ options.AdditionalProperties?.Remove("verbosity");
+ }
+ }
}
}
- class VerbosityOptions(Verbosity verbosity) : ResponseTextOptions
+ static void EnsureFactory(ChatOptions options)
{
- protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options)
+ if (options.RawRepresentationFactory is not null &&
+ options.RawRepresentationFactory.Target is not ResponseOptionsFactory)
{
- writer.WritePropertyName("verbosity"u8);
- writer.WriteStringValue(verbosity.ToString().ToLowerInvariant());
- base.JsonModelWriteCore(writer, options);
+ 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.");
}
+
+ 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 80b88ac..874c2fa 100644
--- a/src/Tests/ChatExtensionsTests.cs
+++ b/src/Tests/ChatExtensionsTests.cs
@@ -2,7 +2,10 @@
using System.Collections.Generic;
using System.Text;
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;
@@ -27,4 +30,94 @@ 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);
+
+ var responseOptions = options.RawRepresentationFactory(Mock.Of());
+
+ Assert.IsType(responseOptions);
+ }
}
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"));