Skip to content

Commit d789e9c

Browse files
committed
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.
1 parent 6a16433 commit d789e9c

7 files changed

Lines changed: 112 additions & 105 deletions

File tree

src/Extensions/OpenAI/AzureInferenceChatClient.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ namespace Devlooped.Extensions.AI.OpenAI;
88
/// <summary>
99
/// An <see cref="IChatClient"/> implementation for Azure AI Inference that supports per-request model selection.
1010
/// </summary>
11-
internal class AzureInferenceChatClient : IChatClient
11+
class AzureInferenceChatClient : IChatClient
1212
{
1313
readonly ConcurrentDictionary<string, IChatClient> clients = new();
1414

src/Extensions/OpenAI/AzureOpenAIChatClient.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ namespace Devlooped.Extensions.AI.OpenAI;
99
/// <summary>
1010
/// An <see cref="IChatClient"/> implementation for Azure OpenAI that supports per-request model selection.
1111
/// </summary>
12-
internal class AzureOpenAIChatClient : IChatClient
12+
class AzureOpenAIChatClient : IChatClient
1313
{
1414
readonly ConcurrentDictionary<string, IChatClient> clients = new();
1515

src/Extensions/OpenAI/OpenAIChatClient.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ namespace Devlooped.Extensions.AI.OpenAI;
99
/// <summary>
1010
/// An <see cref="IChatClient"/> implementation for OpenAI that supports per-request model selection.
1111
/// </summary>
12-
internal class OpenAIChatClient : IChatClient
12+
class OpenAIChatClient : IChatClient
1313
{
1414
readonly ConcurrentDictionary<string, IChatClient> clients = new();
1515
readonly string modelId;
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
using System.ComponentModel;
2+
using Microsoft.Extensions.AI;
3+
4+
namespace Devlooped.Extensions.AI.OpenAI;
5+
6+
/// <summary>
7+
/// Extended <see cref="ChatOptions"/> that includes OpenAI Responses API specific properties.
8+
/// </summary>
9+
/// <remarks>
10+
/// This class is provided for configuration binding scenarios. The <see cref="ReasoningEffort"/>
11+
/// and <see cref="Verbosity"/> properties are specific to the OpenAI Responses API.
12+
/// </remarks>
13+
public class OpenAIChatOptions : ChatOptions
14+
{
15+
/// <summary>
16+
/// Gets or sets the effort level for a reasoning AI model when generating responses.
17+
/// </summary>
18+
/// <remarks>
19+
/// This property is specific to the OpenAI Responses API.
20+
/// </remarks>
21+
public ReasoningEffort? ReasoningEffort
22+
{
23+
get => ((ChatOptions)this).ReasoningEffort;
24+
set => ((ChatOptions)this).ReasoningEffort = value;
25+
}
26+
27+
/// <summary>
28+
/// Gets or sets the verbosity level for a GPT-5+ model when generating responses.
29+
/// </summary>
30+
/// <remarks>
31+
/// This property is specific to the OpenAI Responses API and only supported by GPT-5+ models.
32+
/// </remarks>
33+
public Verbosity? Verbosity
34+
{
35+
get => ((ChatOptions)this).Verbosity;
36+
set => ((ChatOptions)this).Verbosity = value;
37+
}
38+
}

src/Extensions/OpenAI/OpenAIExtensions.cs

Lines changed: 7 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,6 @@ namespace Devlooped.Extensions.AI.OpenAI;
1313
[EditorBrowsable(EditorBrowsableState.Never)]
1414
public static class OpenAIExtensions
1515
{
16-
// Key used to mark that our factory has been installed
17-
const string FactoryInstalledKey = "__OpenAIResponsesFactory";
18-
1916
/// <summary>
2017
/// Gets or sets the effort level for a reasoning AI model when generating responses.
2118
/// </summary>
@@ -38,7 +35,7 @@ public ReasoningEffort? ReasoningEffort
3835
{
3936
options.AdditionalProperties ??= [];
4037
options.AdditionalProperties["reasoning_effort"] = value;
41-
EnsureFactoryInstalled(options);
38+
EnsureFactory(options);
4239
}
4340
else
4441
{
@@ -67,7 +64,7 @@ public Verbosity? Verbosity
6764
{
6865
options.AdditionalProperties ??= [];
6966
options.AdditionalProperties["verbosity"] = value;
70-
EnsureFactoryInstalled(options);
67+
EnsureFactory(options);
7168
}
7269
else
7370
{
@@ -77,93 +74,17 @@ public Verbosity? Verbosity
7774
}
7875
}
7976

80-
static void EnsureFactoryInstalled(ChatOptions options)
77+
static void EnsureFactory(ChatOptions options)
8178
{
82-
options.AdditionalProperties ??= [];
83-
84-
// Check if our factory is already installed
85-
if (options.AdditionalProperties.TryGetValue(FactoryInstalledKey, out var _))
86-
return;
87-
88-
// Check if a different factory has been set
89-
if (options.RawRepresentationFactory is not null)
79+
if (options.RawRepresentationFactory is not null &&
80+
options.RawRepresentationFactory.Target is not ResponseOptionsFactory)
9081
{
9182
throw new InvalidOperationException(
9283
"Cannot use OpenAI Responses API extension properties (ReasoningEffort, Verbosity) when " +
9384
"RawRepresentationFactory has already been set to a custom factory. These extension " +
9485
"properties automatically configure the factory for the OpenAI Responses API.");
9586
}
9687

97-
// Install our factory
98-
options.RawRepresentationFactory = _ => CreateResponseCreationOptions(options);
99-
options.AdditionalProperties[FactoryInstalledKey] = true;
100-
}
101-
102-
static ResponseCreationOptions CreateResponseCreationOptions(ChatOptions options)
103-
{
104-
var creation = new ResponseCreationOptions();
105-
106-
if (options.ReasoningEffort is { } effort)
107-
creation.ReasoningOptions = new ReasoningEffortOptions(effort);
108-
109-
if (options.Verbosity is { } verbosity)
110-
creation.TextOptions = new VerbosityOptions(verbosity);
111-
112-
return creation;
113-
}
114-
115-
class ReasoningEffortOptions(ReasoningEffort effort) : ResponseReasoningOptions
116-
{
117-
protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options)
118-
{
119-
writer.WritePropertyName("effort"u8);
120-
writer.WriteStringValue(effort.ToString().ToLowerInvariant());
121-
base.JsonModelWriteCore(writer, options);
122-
}
123-
}
124-
125-
class VerbosityOptions(Verbosity verbosity) : ResponseTextOptions
126-
{
127-
protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options)
128-
{
129-
writer.WritePropertyName("verbosity"u8);
130-
writer.WriteStringValue(verbosity.ToString().ToLowerInvariant());
131-
base.JsonModelWriteCore(writer, options);
132-
}
133-
}
134-
}
135-
136-
/// <summary>
137-
/// Extended <see cref="ChatOptions"/> that includes OpenAI Responses API specific properties.
138-
/// </summary>
139-
/// <remarks>
140-
/// This class is provided for configuration binding scenarios. The <see cref="ReasoningEffort"/>
141-
/// and <see cref="Verbosity"/> properties are specific to the OpenAI Responses API.
142-
/// </remarks>
143-
[EditorBrowsable(EditorBrowsableState.Never)]
144-
public class OpenAIChatOptions : ChatOptions
145-
{
146-
/// <summary>
147-
/// Gets or sets the effort level for a reasoning AI model when generating responses.
148-
/// </summary>
149-
/// <remarks>
150-
/// This property is specific to the OpenAI Responses API.
151-
/// </remarks>
152-
public ReasoningEffort? ReasoningEffort
153-
{
154-
get => ((ChatOptions)this).ReasoningEffort;
155-
set => ((ChatOptions)this).ReasoningEffort = value;
156-
}
157-
158-
/// <summary>
159-
/// Gets or sets the verbosity level for a GPT-5+ model when generating responses.
160-
/// </summary>
161-
/// <remarks>
162-
/// This property is specific to the OpenAI Responses API and only supported by GPT-5+ models.
163-
/// </remarks>
164-
public Verbosity? Verbosity
165-
{
166-
get => ((ChatOptions)this).Verbosity;
167-
set => ((ChatOptions)this).Verbosity = value;
88+
options.RawRepresentationFactory ??= new ResponseOptionsFactory(options).CreateResponseCreationOptions;
16889
}
169-
}
90+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
using System.ClientModel.Primitives;
2+
using System.Text.Json;
3+
using Microsoft.Extensions.AI;
4+
using OpenAI.Responses;
5+
6+
namespace Devlooped.Extensions.AI.OpenAI;
7+
8+
class ResponseOptionsFactory(ChatOptions options)
9+
{
10+
public ResponseCreationOptions CreateResponseCreationOptions(IChatClient client)
11+
{
12+
var creation = new ResponseCreationOptions();
13+
14+
if (options.ReasoningEffort is { } effort)
15+
creation.ReasoningOptions = new ReasoningEffortOptions(effort);
16+
17+
if (options.Verbosity is { } verbosity)
18+
creation.TextOptions = new VerbosityOptions(verbosity);
19+
20+
return creation;
21+
}
22+
23+
class ReasoningEffortOptions(ReasoningEffort effort) : ResponseReasoningOptions
24+
{
25+
protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options)
26+
{
27+
writer.WritePropertyName("effort"u8);
28+
writer.WriteStringValue(effort.ToString().ToLowerInvariant());
29+
base.JsonModelWriteCore(writer, options);
30+
}
31+
}
32+
33+
class VerbosityOptions(Verbosity verbosity) : ResponseTextOptions
34+
{
35+
protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options)
36+
{
37+
writer.WritePropertyName("verbosity"u8);
38+
writer.WriteStringValue(verbosity.ToString().ToLowerInvariant());
39+
base.JsonModelWriteCore(writer, options);
40+
}
41+
}
42+
}

src/Tests/ChatExtensionsTests.cs

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
using Devlooped.Extensions.AI;
55
using Devlooped.Extensions.AI.OpenAI;
66
using Microsoft.Extensions.AI;
7+
using Moq;
8+
using OpenAI.Responses;
79
using static Devlooped.Extensions.AI.Chat;
810

911
namespace Devlooped;
@@ -33,11 +35,11 @@ public void FactoryMethods()
3335
public void ReasoningEffort_AutoSetsFactory()
3436
{
3537
var options = new ChatOptions();
36-
38+
3739
Assert.Null(options.RawRepresentationFactory);
38-
40+
3941
options.ReasoningEffort = ReasoningEffort.High;
40-
42+
4143
// Factory should now be auto-configured
4244
Assert.NotNull(options.RawRepresentationFactory);
4345
Assert.Equal(ReasoningEffort.High, options.ReasoningEffort);
@@ -47,11 +49,11 @@ public void ReasoningEffort_AutoSetsFactory()
4749
public void Verbosity_AutoSetsFactory()
4850
{
4951
var options = new ChatOptions();
50-
52+
5153
Assert.Null(options.RawRepresentationFactory);
52-
54+
5355
options.Verbosity = Verbosity.Low;
54-
56+
5557
// Factory should now be auto-configured
5658
Assert.NotNull(options.RawRepresentationFactory);
5759
Assert.Equal(Verbosity.Low, options.Verbosity);
@@ -61,16 +63,16 @@ public void Verbosity_AutoSetsFactory()
6163
public void ReasoningEffortAndVerbosity_ShareFactory()
6264
{
6365
var options = new ChatOptions();
64-
66+
6567
options.ReasoningEffort = ReasoningEffort.Medium;
6668
var factory1 = options.RawRepresentationFactory;
67-
69+
6870
options.Verbosity = Verbosity.High;
6971
var factory2 = options.RawRepresentationFactory;
70-
72+
7173
// Factory should be the same - not replaced
7274
Assert.Same(factory1, factory2);
73-
75+
7476
Assert.Equal(ReasoningEffort.Medium, options.ReasoningEffort);
7577
Assert.Equal(Verbosity.High, options.Verbosity);
7678
}
@@ -79,10 +81,10 @@ public void ReasoningEffortAndVerbosity_ShareFactory()
7981
public void ThrowsWhenCustomFactoryAlreadySet()
8082
{
8183
var options = new ChatOptions();
82-
84+
8385
// Set a custom factory first
8486
options.RawRepresentationFactory = _ => new object();
85-
87+
8688
// Should throw when trying to use extension properties
8789
Assert.Throws<InvalidOperationException>(() => options.ReasoningEffort = ReasoningEffort.High);
8890
Assert.Throws<InvalidOperationException>(() => options.Verbosity = Verbosity.Low);
@@ -92,9 +94,9 @@ public void ThrowsWhenCustomFactoryAlreadySet()
9294
public void SettingNullDoesNotConfigureFactory()
9395
{
9496
var options = new ChatOptions();
95-
97+
9698
options.ReasoningEffort = null;
97-
99+
98100
// Factory should not be configured
99101
Assert.Null(options.RawRepresentationFactory);
100102
}
@@ -107,11 +109,15 @@ public void OpenAIChatOptions_BindingClass_Works()
107109
ReasoningEffort = ReasoningEffort.Low,
108110
Verbosity = Verbosity.Medium
109111
};
110-
112+
111113
Assert.Equal(ReasoningEffort.Low, options.ReasoningEffort);
112114
Assert.Equal(Verbosity.Medium, options.Verbosity);
113-
115+
114116
// Factory should be auto-configured via extension property setters
115117
Assert.NotNull(options.RawRepresentationFactory);
118+
119+
var responseOptions = options.RawRepresentationFactory(Mock.Of<IChatClient>());
120+
121+
Assert.IsType<ResponseCreationOptions>(responseOptions);
116122
}
117123
}

0 commit comments

Comments
 (0)