Skip to content

Commit 3f07308

Browse files
committed
Stop suppressing existing format handling, and allow most annotations on netfx
1 parent e838066 commit 3f07308

File tree

5 files changed

+234
-145
lines changed

5 files changed

+234
-145
lines changed

src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636

3737
<ItemGroup Condition="'$(TargetFramework)' == 'net462'">
3838
<Reference Include="System.Net.Http" />
39+
<Reference Include="System.ComponentModel.DataAnnotations" />
3940
</ItemGroup>
4041

4142
</Project>

src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Schema.Create.cs

Lines changed: 53 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
using System;
55
using System.ComponentModel;
6-
#if NET
6+
#if NET || NETFRAMEWORK
77
using System.ComponentModel.DataAnnotations;
88
#endif
99
using System.Diagnostics;
@@ -21,6 +21,7 @@
2121
#pragma warning disable S109 // Magic numbers should not be used
2222
#pragma warning disable S1075 // URIs should not be hardcoded
2323
#pragma warning disable S1121 // Assignments should not be made from within sub-expressions
24+
#pragma warning disable S1199 // Nested block
2425
#pragma warning disable SA1118 // Parameter should not span multiple lines
2526

2627
namespace Microsoft.Extensions.AI;
@@ -41,27 +42,25 @@ public static partial class AIJsonUtilities
4142
private const string AdditionalPropertiesPropertyName = "additionalProperties";
4243
private const string DefaultPropertyName = "default";
4344
private const string RefPropertyName = "$ref";
45+
#if NET || NETFRAMEWORK
4446
private const string FormatPropertyName = "format";
45-
#if NET
46-
private const string ContentEncodingPropertyName = "contentEncoding";
47-
private const string ContentMediaTypePropertyName = "contentMediaType";
4847
private const string MinLengthStringPropertyName = "minLength";
4948
private const string MaxLengthStringPropertyName = "maxLength";
5049
private const string MinLengthCollectionPropertyName = "minItems";
5150
private const string MaxLengthCollectionPropertyName = "maxItems";
5251
private const string MinRangePropertyName = "minimum";
5352
private const string MaxRangePropertyName = "maximum";
53+
#endif
54+
#if NET
55+
private const string ContentEncodingPropertyName = "contentEncoding";
56+
private const string ContentMediaTypePropertyName = "contentMediaType";
5457
private const string MinExclusiveRangePropertyName = "exclusiveMinimum";
5558
private const string MaxExclusiveRangePropertyName = "exclusiveMaximum";
5659
#endif
5760

5861
/// <summary>The uri used when populating the $schema keyword in created schemas.</summary>
5962
private const string SchemaKeywordUri = "https://json-schema.org/draft/2020-12/schema";
6063

61-
// List of keywords used by JsonSchemaExporter but explicitly disallowed by some AI vendors.
62-
// cf. https://platform.openai.com/docs/guides/structured-outputs#some-type-specific-keywords-are-not-yet-supported
63-
private static readonly string[] _schemaKeywordsDisallowedByAIVendors = ["minLength", "maxLength", "pattern", "format"];
64-
6564
/// <summary>
6665
/// Determines a JSON schema for the provided method.
6766
/// </summary>
@@ -296,12 +295,6 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext schemaExporterContext, Js
296295
objSchema.InsertAtStart(TypePropertyName, new JsonArray { (JsonNode)"string", (JsonNode)"null" });
297296
}
298297

299-
// Filter potentially disallowed keywords.
300-
foreach (string keyword in _schemaKeywordsDisallowedByAIVendors)
301-
{
302-
_ = objSchema.Remove(keyword);
303-
}
304-
305298
// Some consumers of the JSON schema, including Ollama as of v0.3.13, don't understand
306299
// schemas with "type": [...], and only understand "type" being a single value.
307300
// In certain configurations STJ represents .NET numeric types as ["string", "number"], which will then lead to an error.
@@ -334,7 +327,6 @@ JsonNode TransformSchemaNode(JsonSchemaExporterContext schemaExporterContext, Js
334327
ConvertSchemaToObject(ref schema).InsertAtStart(SchemaPropertyName, (JsonNode)SchemaKeywordUri);
335328
}
336329

337-
ApplyDataTypeFormats(parameterName, ref schema, ctx);
338330
ApplyDataAnnotations(parameterName, ref schema, ctx);
339331

340332
// Finally, apply any user-defined transformations if specified.
@@ -365,56 +357,14 @@ static JsonObject ConvertSchemaToObject(ref JsonNode schema)
365357
}
366358
}
367359

368-
static void ApplyDataTypeFormats(string? parameterName, ref JsonNode schema, AIJsonSchemaCreateContext ctx)
369-
{
370-
Type t = ctx.TypeInfo.Type;
371-
372-
if (Nullable.GetUnderlyingType(t) is { } underlyingType)
373-
{
374-
t = underlyingType;
375-
}
376-
377-
if (t == typeof(DateTime) || t == typeof(DateTimeOffset))
378-
{
379-
ConvertSchemaToObject(ref schema)[FormatPropertyName] = "date-time";
380-
}
381-
#if NET
382-
else if (t == typeof(DateOnly))
383-
{
384-
ConvertSchemaToObject(ref schema)[FormatPropertyName] = "date";
385-
}
386-
else if (t == typeof(TimeOnly))
387-
{
388-
ConvertSchemaToObject(ref schema)[FormatPropertyName] = "time";
389-
}
390-
#endif
391-
else if (t == typeof(TimeSpan))
392-
{
393-
ConvertSchemaToObject(ref schema)[FormatPropertyName] = "duration";
394-
}
395-
else if (t == typeof(Guid))
396-
{
397-
ConvertSchemaToObject(ref schema)[FormatPropertyName] = "uuid";
398-
}
399-
else if (t == typeof(Uri))
400-
{
401-
ConvertSchemaToObject(ref schema)[FormatPropertyName] = "uri";
402-
}
403-
}
404-
405360
void ApplyDataAnnotations(string? parameterName, ref JsonNode schema, AIJsonSchemaCreateContext ctx)
406361
{
407362
if (ctx.GetCustomAttribute<DisplayNameAttribute>() is { } displayNameAttribute)
408363
{
409364
ConvertSchemaToObject(ref schema)[TitlePropertyName] ??= displayNameAttribute.DisplayName;
410365
}
411366

412-
#if NET
413-
if (ctx.GetCustomAttribute<Base64StringAttribute>() is { } base64Attribute)
414-
{
415-
ConvertSchemaToObject(ref schema)[ContentEncodingPropertyName] ??= "base64";
416-
}
417-
367+
#if NET || NETFRAMEWORK
418368
if (ctx.GetCustomAttribute<EmailAddressAttribute>() is { } emailAttribute)
419369
{
420370
ConvertSchemaToObject(ref schema)[FormatPropertyName] ??= "email";
@@ -442,30 +392,6 @@ void ApplyDataAnnotations(string? parameterName, ref JsonNode schema, AIJsonSche
442392
obj[MaxLengthStringPropertyName] ??= stringLengthAttribute.MaximumLength;
443393
}
444394

445-
if (ctx.GetCustomAttribute<LengthAttribute>() is { } lengthAttribute)
446-
{
447-
JsonObject obj = ConvertSchemaToObject(ref schema);
448-
449-
if (obj[TypePropertyName] is JsonNode typeNode && typeNode.GetValueKind() is JsonValueKind.String && typeNode.GetValue<string>() is "string")
450-
{
451-
if (lengthAttribute.MinimumLength > 0)
452-
{
453-
obj[MinLengthStringPropertyName] ??= lengthAttribute.MinimumLength;
454-
}
455-
456-
obj[MaxLengthStringPropertyName] ??= lengthAttribute.MaximumLength;
457-
}
458-
else
459-
{
460-
if (lengthAttribute.MinimumLength > 0)
461-
{
462-
obj[MinLengthCollectionPropertyName] ??= lengthAttribute.MinimumLength;
463-
}
464-
465-
obj[MaxLengthCollectionPropertyName] ??= lengthAttribute.MaximumLength;
466-
}
467-
}
468-
469395
if (ctx.GetCustomAttribute<MinLengthAttribute>() is { } minLengthAttribute)
470396
{
471397
JsonObject obj = ConvertSchemaToObject(ref schema);
@@ -502,7 +428,11 @@ void ApplyDataAnnotations(string? parameterName, ref JsonNode schema, AIJsonSche
502428
{
503429
case int minInt32 when rangeAttribute.Maximum is int maxInt32:
504430
maxNode = maxInt32;
505-
if (!rangeAttribute.MinimumIsExclusive || minInt32 > 0)
431+
if (
432+
#if NET
433+
!rangeAttribute.MinimumIsExclusive ||
434+
#endif
435+
minInt32 > 0)
506436
{
507437
minNode = minInt32;
508438
}
@@ -511,7 +441,11 @@ void ApplyDataAnnotations(string? parameterName, ref JsonNode schema, AIJsonSche
511441

512442
case double minDouble when rangeAttribute.Maximum is double maxDouble:
513443
maxNode = maxDouble;
514-
if (!rangeAttribute.MinimumIsExclusive || minDouble > 0)
444+
if (
445+
#if NET
446+
!rangeAttribute.MinimumIsExclusive ||
447+
#endif
448+
minDouble > 0)
515449
{
516450
minNode = minDouble;
517451
}
@@ -526,28 +460,63 @@ void ApplyDataAnnotations(string? parameterName, ref JsonNode schema, AIJsonSche
526460

527461
if (minNode is not null)
528462
{
463+
#if NET
529464
if (rangeAttribute.MinimumIsExclusive)
530465
{
531466
obj[MinExclusiveRangePropertyName] ??= minNode;
532467
}
533468
else
469+
#endif
534470
{
535471
obj[MinRangePropertyName] ??= minNode;
536472
}
537473
}
538474

539475
if (maxNode is not null)
540476
{
477+
#if NET
541478
if (rangeAttribute.MaximumIsExclusive)
542479
{
543480
obj[MaxExclusiveRangePropertyName] ??= maxNode;
544481
}
545482
else
483+
#endif
546484
{
547485
obj[MaxRangePropertyName] ??= maxNode;
548486
}
549487
}
550488
}
489+
#endif
490+
491+
#if NET
492+
if (ctx.GetCustomAttribute<Base64StringAttribute>() is { } base64Attribute)
493+
{
494+
ConvertSchemaToObject(ref schema)[ContentEncodingPropertyName] ??= "base64";
495+
}
496+
497+
if (ctx.GetCustomAttribute<LengthAttribute>() is { } lengthAttribute)
498+
{
499+
JsonObject obj = ConvertSchemaToObject(ref schema);
500+
501+
if (obj[TypePropertyName] is JsonNode typeNode && typeNode.GetValueKind() is JsonValueKind.String && typeNode.GetValue<string>() is "string")
502+
{
503+
if (lengthAttribute.MinimumLength > 0)
504+
{
505+
obj[MinLengthStringPropertyName] ??= lengthAttribute.MinimumLength;
506+
}
507+
508+
obj[MaxLengthStringPropertyName] ??= lengthAttribute.MaximumLength;
509+
}
510+
else
511+
{
512+
if (lengthAttribute.MinimumLength > 0)
513+
{
514+
obj[MinLengthCollectionPropertyName] ??= lengthAttribute.MinimumLength;
515+
}
516+
517+
obj[MaxLengthCollectionPropertyName] ??= lengthAttribute.MaximumLength;
518+
}
519+
}
551520

552521
if (ctx.GetCustomAttribute<AllowedValuesAttribute>() is { } allowedValuesAttribute)
553522
{
@@ -613,10 +582,6 @@ static JsonArray CreateJsonArray(object?[] values, JsonSerializerOptions seriali
613582
obj[FormatPropertyName] ??= "time";
614583
break;
615584

616-
case DataType.Duration:
617-
obj[FormatPropertyName] ??= "duration";
618-
break;
619-
620585
case DataType.EmailAddress:
621586
obj[FormatPropertyName] ??= "email";
622587
break;

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

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313
using OpenAI.Embeddings;
1414
using OpenAI.Responses;
1515

16+
#pragma warning disable S103 // Lines should not be too long
1617
#pragma warning disable S1067 // Expressions should not be too complex
18+
#pragma warning disable SA1515 // Single-line comment should be preceded by blank line
1719
#pragma warning disable CA1305 // Specify IFormatProvider
1820

1921
namespace Microsoft.Extensions.AI;
@@ -31,7 +33,8 @@ public static class OpenAIClientExtensions
3133
internal static ChatRole ChatRoleDeveloper { get; } = new ChatRole("developer");
3234

3335
/// <summary>
34-
/// Gets the JSON schema transformer cache conforming to OpenAI <b>strict</b> restrictions per https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#supported-schemas.
36+
/// Gets the JSON schema transformer cache conforming to OpenAI <b>strict</b> / structured output restrictions per
37+
/// https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#supported-schemas.
3538
/// </summary>
3639
internal static AIJsonSchemaTransformCache StrictSchemaTransformCache { get; } = new(new()
3740
{
@@ -42,15 +45,30 @@ public static class OpenAIClientExtensions
4245
TransformSchemaNode = (ctx, node) =>
4346
{
4447
// Move content from common but unsupported properties to description. In particular, we focus on properties that
45-
// the AIJsonUtilities schema generator might produce.
46-
// Based on guidance at:
47-
// https://platform.openai.com/docs/guides/structured-outputs#supported-properties
48+
// the AIJsonUtilities schema generator might produce and/or that are explicitly mentioned in the OpenAI documentation.
4849

4950
if (node is JsonObject schemaObj)
5051
{
5152
StringBuilder? additionalDescription = null;
5253

53-
foreach (string propName in (ReadOnlySpan<string>)["contentEncoding", "contentMediaType", "minLength", "maxLength", "not"])
54+
ReadOnlySpan<string> unsupportedProperties =
55+
[
56+
// Produced by AIJsonUtilities but not in allow list at https://platform.openai.com/docs/guides/structured-outputs#supported-properties:
57+
"contentEncoding", "contentMediaType", "not",
58+
59+
// Explicitly mentioned at https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#key-ordering as being unsupported with some models:
60+
"minLength", "maxLength", "pattern", "format",
61+
"minimum", "maximum", "multipleOf",
62+
"patternProperties",
63+
"minItems", "maxItems",
64+
65+
// Explicitly mentioned at https://learn.microsoft.com/azure/ai-services/openai/how-to/structured-outputs?pivots=programming-language-csharp&tabs=python-secure%2Cdotnet-entra-id#unsupported-type-specific-keywords
66+
// as being unsupported with Azure OpenAI:
67+
"unevaluatedProperties", "propertyNames", "minProperties", "maxProperties",
68+
"unevaluatedItems", "contains", "minContains", "maxContains", "uniqueItems",
69+
];
70+
71+
foreach (string propName in unsupportedProperties)
5472
{
5573
if (schemaObj[propName] is { } propNode)
5674
{
@@ -59,17 +77,6 @@ public static class OpenAIClientExtensions
5977
}
6078
}
6179

62-
if (schemaObj["format"] is { } formatNode)
63-
{
64-
if (formatNode.GetValueKind() != JsonValueKind.String ||
65-
formatNode.GetValue<string>() is not string format ||
66-
format is not ("date-time" or "date" or "time" or "duration" or "email" or "hostname" or "ipv4" or "ipv6" or "uuid"))
67-
{
68-
_ = schemaObj.Remove("format");
69-
AppendLine(ref additionalDescription, "format", formatNode);
70-
}
71-
}
72-
7380
if (additionalDescription is not null)
7481
{
7582
schemaObj["description"] = schemaObj["description"] is { } descriptionNode && descriptionNode.GetValueKind() == JsonValueKind.String ?

test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,5 +38,6 @@
3838
<ItemGroup>
3939
<ProjectReference Include="..\..\..\src\Libraries\Microsoft.Extensions.AI.Abstractions\Microsoft.Extensions.AI.Abstractions.csproj" ProjectUnderTest="true" />
4040
<ProjectReference Include="..\..\..\src\Libraries\Microsoft.Extensions.AI\Microsoft.Extensions.AI.csproj" />
41+
<ProjectReference Include="..\..\TestUtilities\TestUtilities.csproj" />
4142
</ItemGroup>
4243
</Project>

0 commit comments

Comments
 (0)