Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions src/Agents/Agents.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.1" />

<PackageReference Include="Microsoft.Agents.AI" Version="1.0.0-preview.251104.1" />
<PackageReference Include="Microsoft.Agents.AI.Hosting" Version="1.0.0-preview.251016.1" />
Expand Down
3 changes: 2 additions & 1 deletion src/Agents/ConfigurableAIAgent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -118,7 +119,7 @@ public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnum
var provider = client.GetService<ChatClientMetadata>()?.ProviderName;
ChatOptions? chat = provider == "xai"
? configSection.GetSection("options").Get<GrokChatOptions>()
: configSection.GetSection("options").Get<ExtendedChatOptions>();
: configSection.GetSection("options").Get<OpenAIChatOptions>();

if (chat is not null)
options.ChatOptions = chat;
Expand Down
56 changes: 0 additions & 56 deletions src/Extensions/ChatExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,61 +29,5 @@ public string? EndUserId
get => (options.AdditionalProperties ??= []).TryGetValue("EndUserId", out var value) ? value as string : null;
set => (options.AdditionalProperties ??= [])["EndUserId"] = value;
}

/// <summary>Sets the effort level for a reasoning AI model when generating responses, if supported by the model.</summary>
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");
}
}
}

/// <summary>Sets the <see cref="Verbosity"/> level for a GPT-5 model when generating responses, if supported</summary>
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.
/// <summary>
/// Defines extended <see cref="ChatOptions"/> we provide via extension properties.
/// </summary>
/// <devdoc>This should ideally even be auto-generated from the available extensions so it's always in sync.</devdoc>
[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;
}
}
1 change: 1 addition & 0 deletions src/Extensions/Extensions.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
<None Update="Devlooped.Extensions.AI.props" PackFolder="build" />
<None Include="..\..\osmfeula.txt" Link="osmfeula.txt" PackagePath="OSMFEULA.txt" />
<InternalsVisibleTo Include="Devlooped.Agents.AI" />
<InternalsVisibleTo Include="Tests" />
</ItemGroup>

</Project>
2 changes: 1 addition & 1 deletion src/Extensions/OpenAI/AzureInferenceChatClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ namespace Devlooped.Extensions.AI.OpenAI;
/// <summary>
/// An <see cref="IChatClient"/> implementation for Azure AI Inference that supports per-request model selection.
/// </summary>
public class AzureInferenceChatClient : IChatClient
class AzureInferenceChatClient : IChatClient
{
readonly ConcurrentDictionary<string, IChatClient> clients = new();

Expand Down
6 changes: 3 additions & 3 deletions src/Extensions/OpenAI/AzureOpenAIChatClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ namespace Devlooped.Extensions.AI.OpenAI;
/// <summary>
/// An <see cref="IChatClient"/> implementation for Azure OpenAI that supports per-request model selection.
/// </summary>
public class AzureOpenAIChatClient : IChatClient
class AzureOpenAIChatClient : IChatClient
{
readonly ConcurrentDictionary<string, IChatClient> clients = new();

Expand Down Expand Up @@ -45,11 +45,11 @@ public AzureOpenAIChatClient(Uri endpoint, ApiKeyCredential credential, string m

/// <inheritdoc/>
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellation = default)
=> GetChatClient(options?.ModelId ?? modelId).GetResponseAsync(messages, options.ApplyExtensions(), cancellation);
=> GetChatClient(options?.ModelId ?? modelId).GetResponseAsync(messages, options, cancellation);

/// <inheritdoc/>
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> 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());
Expand Down
6 changes: 3 additions & 3 deletions src/Extensions/OpenAI/OpenAIChatClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ namespace Devlooped.Extensions.AI.OpenAI;
/// <summary>
/// An <see cref="IChatClient"/> implementation for OpenAI that supports per-request model selection.
/// </summary>
public class OpenAIChatClient : IChatClient
class OpenAIChatClient : IChatClient
{
readonly ConcurrentDictionary<string, IChatClient> clients = new();
readonly string modelId;
Expand Down Expand Up @@ -37,11 +37,11 @@ public OpenAIChatClient(string apiKey, string modelId, OpenAIClientOptions? opti

/// <inheritdoc/>
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellation = default)
=> GetChatClient(options?.ModelId ?? modelId).GetResponseAsync(messages, options.ApplyExtensions(), cancellation);
=> GetChatClient(options?.ModelId ?? modelId).GetResponseAsync(messages, options, cancellation);

/// <inheritdoc/>
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> 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());
Expand Down
38 changes: 38 additions & 0 deletions src/Extensions/OpenAI/OpenAIChatOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using System.ComponentModel;
using Microsoft.Extensions.AI;

namespace Devlooped.Extensions.AI.OpenAI;

/// <summary>
/// Extended <see cref="ChatOptions"/> that includes OpenAI Responses API specific properties.
/// </summary>
/// <remarks>
/// This class is provided for configuration binding scenarios. The <see cref="ReasoningEffort"/>
/// and <see cref="Verbosity"/> properties are specific to the OpenAI Responses API.
/// </remarks>
public class OpenAIChatOptions : ChatOptions
{
/// <summary>
/// Gets or sets the effort level for a reasoning AI model when generating responses.
/// </summary>
/// <remarks>
/// This property is specific to the OpenAI Responses API.
/// </remarks>
public ReasoningEffort? ReasoningEffort
{
get => ((ChatOptions)this).ReasoningEffort;
set => ((ChatOptions)this).ReasoningEffort = value;
}

/// <summary>
/// Gets or sets the verbosity level for a GPT-5+ model when generating responses.
/// </summary>
/// <remarks>
/// This property is specific to the OpenAI Responses API and only supported by GPT-5+ models.
/// </remarks>
public Verbosity? Verbosity
{
get => ((ChatOptions)this).Verbosity;
set => ((ChatOptions)this).Verbosity = value;
}
}
100 changes: 62 additions & 38 deletions src/Extensions/OpenAI/OpenAIExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,66 +1,90 @@
using System.ClientModel.Primitives;
using System.ComponentModel;
using System.Text.Json;
using Microsoft.Extensions.AI;
using OpenAI.Responses;

namespace Devlooped.Extensions.AI.OpenAI;

/// <summary>
/// Allows applying extension properties to the <see cref="ChatOptions"/> when using
/// them with an OpenAI client.
/// Provides OpenAI-specific extension properties for <see cref="ChatOptions"/> when using
/// the OpenAI Responses API.
/// </summary>
[EditorBrowsable(EditorBrowsableState.Never)]
public static class OpenAIExtensions
{
/// <summary>
/// Applies the extension properties to the <paramref name="options"/> 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.
/// </summary>
/// <remarks>
/// Only use this if you are not using <see cref="OpenAIChatClient"/>, which already applies
/// extensions before sending requests.
/// This property is specific to the OpenAI Responses API. Setting this property automatically
/// configures the <see cref="ChatOptions.RawRepresentationFactory"/> to properly forward the
/// value to the OpenAI endpoint. Do not manually set <see cref="ChatOptions.RawRepresentationFactory"/>
/// when using this property.
/// </remarks>
/// <returns>An options with the right <see cref="ChatOptions.RawRepresentationFactory"/> replaced
/// so it can forward extensions to the underlying OpenAI API.</returns>
public static ChatOptions? ApplyExtensions(this ChatOptions? options)
/// <exception cref="InvalidOperationException">Thrown when <see cref="ChatOptions.RawRepresentationFactory"/>
/// has been set to a non-OpenAI factory.</exception>
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)
/// <summary>
/// Gets or sets the <see cref="AI.Verbosity"/> level for a GPT-5 model when generating responses.
/// </summary>
/// <remarks>
/// This property is specific to the OpenAI Responses API and only supported by GPT-5+ models.
/// Setting this property automatically configures the <see cref="ChatOptions.RawRepresentationFactory"/>
/// to properly forward the value to the OpenAI endpoint. Do not manually set
/// <see cref="ChatOptions.RawRepresentationFactory"/> when using this property.
/// </remarks>
/// <exception cref="InvalidOperationException">Thrown when <see cref="ChatOptions.RawRepresentationFactory"/>
/// has been set to a non-OpenAI factory.</exception>
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;
}
}
}
42 changes: 42 additions & 0 deletions src/Extensions/OpenAI/ResponseOptionsFactory.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Loading