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