Skip to content

Commit aa6e9af

Browse files
committed
Enable specifying "strict" for OpenAI clients via ChatOptions (#6552)
* Enable specifying "strict" for OpenAI clients via ChatOptions * Address PR feedback
1 parent 93b8616 commit aa6e9af

File tree

7 files changed

+195
-45
lines changed

7 files changed

+195
-45
lines changed

src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIAssistantChatClient.cs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -237,14 +237,16 @@ void IDisposable.Dispose()
237237
}
238238

239239
/// <summary>Converts an Extensions function to an OpenAI assistants function tool.</summary>
240-
internal static FunctionToolDefinition ToOpenAIAssistantsFunctionToolDefinition(AIFunction aiFunction)
240+
internal static FunctionToolDefinition ToOpenAIAssistantsFunctionToolDefinition(AIFunction aiFunction, ChatOptions? options = null)
241241
{
242-
(BinaryData parameters, bool? strict) = OpenAIClientExtensions.ToOpenAIFunctionParameters(aiFunction);
242+
bool? strict =
243+
OpenAIClientExtensions.HasStrict(aiFunction.AdditionalProperties) ??
244+
OpenAIClientExtensions.HasStrict(options?.AdditionalProperties);
243245

244246
return new FunctionToolDefinition(aiFunction.Name)
245247
{
246248
Description = aiFunction.Description,
247-
Parameters = parameters,
249+
Parameters = OpenAIClientExtensions.ToOpenAIFunctionParameters(aiFunction, strict),
248250
StrictParameterSchemaEnabled = strict,
249251
};
250252
}
@@ -296,7 +298,7 @@ internal static FunctionToolDefinition ToOpenAIAssistantsFunctionToolDefinition(
296298
switch (tool)
297299
{
298300
case AIFunction aiFunction:
299-
runOptions.ToolsOverride.Add(ToOpenAIAssistantsFunctionToolDefinition(aiFunction));
301+
runOptions.ToolsOverride.Add(ToOpenAIAssistantsFunctionToolDefinition(aiFunction, options));
300302
break;
301303

302304
case HostedCodeInterpreterTool:
@@ -342,7 +344,8 @@ internal static FunctionToolDefinition ToOpenAIAssistantsFunctionToolDefinition(
342344
runOptions.ResponseFormat = AssistantResponseFormat.CreateJsonSchemaFormat(
343345
jsonFormat.SchemaName,
344346
BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, OpenAIJsonContext.Default.JsonElement)),
345-
jsonFormat.SchemaDescription);
347+
jsonFormat.SchemaDescription,
348+
OpenAIClientExtensions.HasStrict(options.AdditionalProperties));
346349
break;
347350

348351
case ChatResponseFormatJson jsonFormat:

src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -101,11 +101,17 @@ void IDisposable.Dispose()
101101
}
102102

103103
/// <summary>Converts an Extensions function to an OpenAI chat tool.</summary>
104-
internal static ChatTool ToOpenAIChatTool(AIFunction aiFunction)
104+
internal static ChatTool ToOpenAIChatTool(AIFunction aiFunction, ChatOptions? options = null)
105105
{
106-
(BinaryData parameters, bool? strict) = OpenAIClientExtensions.ToOpenAIFunctionParameters(aiFunction);
107-
108-
return ChatTool.CreateFunctionTool(aiFunction.Name, aiFunction.Description, parameters, strict);
106+
bool? strict =
107+
OpenAIClientExtensions.HasStrict(aiFunction.AdditionalProperties) ??
108+
OpenAIClientExtensions.HasStrict(options?.AdditionalProperties);
109+
110+
return ChatTool.CreateFunctionTool(
111+
aiFunction.Name,
112+
aiFunction.Description,
113+
OpenAIClientExtensions.ToOpenAIFunctionParameters(aiFunction, strict),
114+
strict);
109115
}
110116

111117
/// <summary>Converts an Extensions chat message enumerable to an OpenAI chat message enumerable.</summary>
@@ -517,7 +523,7 @@ private ChatCompletionOptions ToOpenAIOptions(ChatOptions? options)
517523
{
518524
if (tool is AIFunction af)
519525
{
520-
result.Tools.Add(ToOpenAIChatTool(af));
526+
result.Tools.Add(ToOpenAIChatTool(af, options));
521527
}
522528
}
523529

@@ -555,7 +561,8 @@ private ChatCompletionOptions ToOpenAIOptions(ChatOptions? options)
555561
OpenAI.Chat.ChatResponseFormat.CreateJsonSchemaFormat(
556562
jsonFormat.SchemaName ?? "json_schema",
557563
BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, OpenAIJsonContext.Default.JsonElement)),
558-
jsonFormat.SchemaDescription) :
564+
jsonFormat.SchemaDescription,
565+
OpenAIClientExtensions.HasStrict(options.AdditionalProperties)) :
559566
OpenAI.Chat.ChatResponseFormat.CreateJsonObjectFormat();
560567
}
561568
}

src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIClientExtensions.cs

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
#pragma warning disable S1067 // Expressions should not be too complex
2222
#pragma warning disable SA1515 // Single-line comment should be preceded by blank line
2323
#pragma warning disable CA1305 // Specify IFormatProvider
24+
#pragma warning disable S1135 // Track uses of "TODO" tags
2425

2526
namespace Microsoft.Extensions.AI;
2627

@@ -182,15 +183,17 @@ public static ResponseTool AsOpenAIResponseTool(this AIFunction function) =>
182183
public static ConversationFunctionTool AsOpenAIConversationFunctionTool(this AIFunction function) =>
183184
OpenAIRealtimeConversationClient.ToOpenAIConversationFunctionTool(Throw.IfNull(function));
184185

186+
// TODO: Once we're ready to rely on C# 14 features, add an extension property ChatOptions.Strict.
187+
188+
/// <summary>Gets whether the properties specify that strict schema handling is desired.</summary>
189+
internal static bool? HasStrict(IReadOnlyDictionary<string, object?>? additionalProperties) =>
190+
additionalProperties?.TryGetValue(StrictKey, out object? strictObj) is true &&
191+
strictObj is bool strictValue ?
192+
strictValue : null;
193+
185194
/// <summary>Extracts from an <see cref="AIFunction"/> the parameters and strictness setting for use with OpenAI's APIs.</summary>
186-
internal static (BinaryData Parameters, bool? Strict) ToOpenAIFunctionParameters(AIFunction aiFunction)
195+
internal static BinaryData ToOpenAIFunctionParameters(AIFunction aiFunction, bool? strict)
187196
{
188-
// Extract any strict setting from AdditionalProperties.
189-
bool? strict =
190-
aiFunction.AdditionalProperties.TryGetValue(OpenAIClientExtensions.StrictKey, out object? strictObj) &&
191-
strictObj is bool strictValue ?
192-
strictValue : null;
193-
194197
// Perform any desirable transformations on the function's JSON schema, if it'll be used in a strict setting.
195198
JsonElement jsonSchema = strict is true ?
196199
StrictSchemaTransformCache.GetOrCreateTransformedSchema(aiFunction) :
@@ -201,7 +204,7 @@ strictObj is bool strictValue ?
201204
var tool = JsonSerializer.Deserialize(jsonSchema, OpenAIJsonContext.Default.ToolJson)!;
202205
var functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool, OpenAIJsonContext.Default.ToolJson));
203206

204-
return (functionParameters, strict);
207+
return functionParameters;
205208
}
206209

207210
/// <summary>Used to create the JSON payload for an OpenAI tool description.</summary>
Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,23 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4-
using System;
54
using OpenAI.RealtimeConversation;
65

76
namespace Microsoft.Extensions.AI;
87

98
/// <summary>Provides helpers for interacting with OpenAI Realtime.</summary>
109
internal sealed class OpenAIRealtimeConversationClient
1110
{
12-
public static ConversationFunctionTool ToOpenAIConversationFunctionTool(AIFunction aiFunction)
11+
public static ConversationFunctionTool ToOpenAIConversationFunctionTool(AIFunction aiFunction, ChatOptions? options = null)
1312
{
14-
(BinaryData parameters, _) = OpenAIClientExtensions.ToOpenAIFunctionParameters(aiFunction);
13+
bool? strict =
14+
OpenAIClientExtensions.HasStrict(aiFunction.AdditionalProperties) ??
15+
OpenAIClientExtensions.HasStrict(options?.AdditionalProperties);
1516

1617
return new ConversationFunctionTool(aiFunction.Name)
1718
{
1819
Description = aiFunction.Description,
19-
Parameters = parameters,
20+
Parameters = OpenAIClientExtensions.ToOpenAIFunctionParameters(aiFunction, strict),
2021
};
2122
}
2223
}

src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponseChatClient.cs

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -323,11 +323,17 @@ void IDisposable.Dispose()
323323
// Nothing to dispose. Implementation required for the IChatClient interface.
324324
}
325325

326-
internal static ResponseTool ToResponseTool(AIFunction aiFunction)
326+
internal static ResponseTool ToResponseTool(AIFunction aiFunction, ChatOptions? options = null)
327327
{
328-
(BinaryData parameters, bool? strict) = OpenAIClientExtensions.ToOpenAIFunctionParameters(aiFunction);
329-
330-
return ResponseTool.CreateFunctionTool(aiFunction.Name, aiFunction.Description, parameters, strict ?? false);
328+
bool? strict =
329+
OpenAIClientExtensions.HasStrict(aiFunction.AdditionalProperties) ??
330+
OpenAIClientExtensions.HasStrict(options?.AdditionalProperties);
331+
332+
return ResponseTool.CreateFunctionTool(
333+
aiFunction.Name,
334+
aiFunction.Description,
335+
OpenAIClientExtensions.ToOpenAIFunctionParameters(aiFunction, strict),
336+
strict ?? false);
331337
}
332338

333339
/// <summary>Creates a <see cref="ChatRole"/> from a <see cref="MessageRole"/>.</summary>
@@ -380,7 +386,7 @@ private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? opt
380386
switch (tool)
381387
{
382388
case AIFunction aiFunction:
383-
ResponseTool rtool = ToResponseTool(aiFunction);
389+
ResponseTool rtool = ToResponseTool(aiFunction, options);
384390
result.Tools.Add(rtool);
385391
break;
386392

@@ -442,7 +448,8 @@ private ResponseCreationOptions ToOpenAIResponseCreationOptions(ChatOptions? opt
442448
ResponseTextFormat.CreateJsonSchemaFormat(
443449
jsonFormat.SchemaName ?? "json_schema",
444450
BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, OpenAIJsonContext.Default.JsonElement)),
445-
jsonFormat.SchemaDescription) :
451+
jsonFormat.SchemaDescription,
452+
OpenAIClientExtensions.HasStrict(options.AdditionalProperties)) :
446453
ResponseTextFormat.CreateJsonObjectFormat(),
447454
};
448455
}

test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs

Lines changed: 70 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,74 @@ public async Task BasicRequestResponse_Streaming()
276276
}, usage.Details.AdditionalCounts);
277277
}
278278

279+
[Fact]
280+
public async Task ChatOptions_StrictRespected()
281+
{
282+
const string Input = """
283+
{
284+
"tools": [
285+
{
286+
"function": {
287+
"description": "Gets the age of the specified person.",
288+
"name": "GetPersonAge",
289+
"strict": true,
290+
"parameters": {
291+
"type": "object",
292+
"required": [],
293+
"properties": {},
294+
"additionalProperties": false
295+
}
296+
},
297+
"type": "function"
298+
}
299+
],
300+
"messages": [
301+
{
302+
"role": "user",
303+
"content": "hello"
304+
}
305+
],
306+
"model": "gpt-4o-mini",
307+
"tool_choice": "auto"
308+
}
309+
""";
310+
311+
const string Output = """
312+
{
313+
"id": "chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI",
314+
"object": "chat.completion",
315+
"created": 1727888631,
316+
"model": "gpt-4o-mini-2024-07-18",
317+
"choices": [
318+
{
319+
"index": 0,
320+
"message": {
321+
"role": "assistant",
322+
"content": "Hello! How can I assist you today?",
323+
"refusal": null
324+
},
325+
"logprobs": null,
326+
"finish_reason": "stop"
327+
}
328+
]
329+
}
330+
""";
331+
332+
using VerbatimHttpHandler handler = new(Input, Output);
333+
using HttpClient httpClient = new(handler);
334+
using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini");
335+
336+
var response = await client.GetResponseAsync("hello", new()
337+
{
338+
Tools = [AIFunctionFactory.Create(() => 42, "GetPersonAge", "Gets the age of the specified person.")],
339+
AdditionalProperties = new()
340+
{
341+
["strictJsonSchema"] = true,
342+
},
343+
});
344+
Assert.NotNull(response);
345+
}
346+
279347
[Fact]
280348
public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentation_NonStreaming()
281349
{
@@ -337,7 +405,7 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio
337405
ResponseFormat = OpenAI.Chat.ChatResponseFormat.CreateTextFormat()
338406
};
339407
openAIOptions.StopSequences.Add("hello");
340-
openAIOptions.Tools.Add(ToOpenAIChatTool(tool));
408+
openAIOptions.Tools.Add(OpenAIClientExtensions.AsOpenAIChatTool(tool));
341409
return openAIOptions;
342410
},
343411
ModelId = null,
@@ -416,7 +484,7 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio
416484
ResponseFormat = OpenAI.Chat.ChatResponseFormat.CreateTextFormat()
417485
};
418486
openAIOptions.StopSequences.Add("hello");
419-
openAIOptions.Tools.Add(ToOpenAIChatTool(tool));
487+
openAIOptions.Tools.Add(OpenAIClientExtensions.AsOpenAIChatTool(tool));
420488
return openAIOptions;
421489
},
422490
ModelId = null, // has no effect, you cannot change the model of an OpenAI's ChatClient.
@@ -600,20 +668,6 @@ public async Task ChatOptions_Overwrite_NullPropertiesInRawRepresentation_Stream
600668
Assert.Equal("Hello! How can I assist you today?", responseText);
601669
}
602670

603-
/// <summary>Converts an Extensions function to an OpenAI chat tool.</summary>
604-
private static ChatTool ToOpenAIChatTool(AIFunction aiFunction)
605-
{
606-
bool? strict =
607-
aiFunction.AdditionalProperties.TryGetValue("strictJsonSchema", out object? strictObj) &&
608-
strictObj is bool strictValue ?
609-
strictValue : null;
610-
611-
// Map to an intermediate model so that redundant properties are skipped.
612-
var tool = JsonSerializer.Deserialize<ChatToolJson>(aiFunction.JsonSchema)!;
613-
var functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool));
614-
return ChatTool.CreateFunctionTool(aiFunction.Name, aiFunction.Description, functionParameters, strict);
615-
}
616-
617671
/// <summary>Used to create the JSON payload for an OpenAI chat tool description.</summary>
618672
internal sealed class ChatToolJson
619673
{

test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,81 @@ public async Task BasicRequestResponse_Streaming()
288288
Assert.Equal(36, usage.Details.TotalTokenCount);
289289
}
290290

291+
[Fact]
292+
public async Task ChatOptions_StrictRespected()
293+
{
294+
const string Input = """
295+
{
296+
"model": "gpt-4o-mini",
297+
"input": [
298+
{
299+
"type": "message",
300+
"role": "user",
301+
"content": [
302+
{
303+
"type": "input_text",
304+
"text": "hello"
305+
}
306+
]
307+
}
308+
],
309+
"tool_choice": "auto",
310+
"tools": [
311+
{
312+
"type": "function",
313+
"name": "GetPersonAge",
314+
"description": "Gets the age of the specified person.",
315+
"parameters": {
316+
"type": "object",
317+
"required": [],
318+
"properties": {},
319+
"additionalProperties": false
320+
},
321+
"strict": true
322+
}
323+
]
324+
}
325+
""";
326+
327+
const string Output = """
328+
{
329+
"id": "resp_67d327649b288191aeb46a824e49dc40058a5e08c46a181d",
330+
"object": "response",
331+
"status": "completed",
332+
"model": "gpt-4o-mini-2024-07-18",
333+
"output": [
334+
{
335+
"type": "message",
336+
"id": "msg_67d32764fcdc8191bcf2e444d4088804058a5e08c46a181d",
337+
"status": "completed",
338+
"role": "assistant",
339+
"content": [
340+
{
341+
"type": "output_text",
342+
"text": "Hello! How can I assist you today?",
343+
"annotations": []
344+
}
345+
]
346+
}
347+
]
348+
}
349+
""";
350+
351+
using VerbatimHttpHandler handler = new(Input, Output);
352+
using HttpClient httpClient = new(handler);
353+
using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini");
354+
355+
var response = await client.GetResponseAsync("hello", new()
356+
{
357+
Tools = [AIFunctionFactory.Create(() => 42, "GetPersonAge", "Gets the age of the specified person.")],
358+
AdditionalProperties = new()
359+
{
360+
["strictJsonSchema"] = true,
361+
},
362+
});
363+
Assert.NotNull(response);
364+
}
365+
291366
[Fact]
292367
public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentation_NonStreaming()
293368
{

0 commit comments

Comments
 (0)