Skip to content

NotSupportedException under PublishAot for any structured-output schema with more than one property (Transformers.ProcessJsonNode → JsonArray.Add<string>) #421

Description

@Metalnib

Summary

Google.GenAI declares IsAotCompatible=true, but a structured-output request whose response schema contains an object with more than one property throws NotSupportedException before the request leaves the process when the application is published with PublishAot=true.

A single-property schema is unaffected and reaches the network normally. The failure does not reproduce on CoreCLR, so it passes dotnet run and unit tests, and appears only in an AOT-published build.

Environment

  • Google.GenAI 1.18.0
  • Microsoft.Extensions.AI 10.9.0
  • .NET SDK 10.0.201, runtime .NET 10.0.5
  • PublishAot=true, -r osx-arm64

The compiler predicts it

ILC flags the exact call site during publish:

ILC : Trim analysis warning IL2026: Google.GenAI.Transformers.ProcessJsonNode(JsonNode,HashSet`1<Object>):
Using member 'System.Text.Json.Nodes.JsonArray.Add<String>(String)' which has
'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code.
Creating JsonValue instances with non-primitive types is not compatible with trimming.

Runtime result

Published binary, dummy API key (the failure is pre-transport, so no valid key is needed to observe it):

--- ONE property ---
  RESULT: Google.GenAI.ClientError
  API key not valid. Please pass a valid API key.        <-- reached the network

--- TWO properties ---
  RESULT: NotSupportedException (pre-transport)
  JsonTypeInfo metadata for type 'System.String' was not provided by TypeInfoResolver of type
  'System.Text.Json.Serialization.Metadata.EmptyJsonTypeInfoResolver'. ...

   at System.Text.Json.ThrowHelper.ThrowNotSupportedException_NoMetadataForType(Type, IJsonTypeInfoResolver)
   at System.Text.Json.JsonSerializerOptions.GetTypeInfoInternal(Type, Boolean, Nullable`1, Boolean, Boolean)
   at System.Text.Json.Nodes.JsonNode.ConvertFromValue[T](T, Nullable`1)
   at Google.GenAI.Transformers.ProcessJsonNode(JsonNode, HashSet`1)
   at Google.GenAI.Models.GenerateContentConfigToMldev(ApiClient, JsonNode, JsonObject, JsonNode)
   at Google.GenAI.Models.GenerateContentParametersToMldev(ApiClient, JsonNode, JsonObject, JsonNode)
   at Google.GenAI.Models.PrivateGenerateContentAsync(...)
   at Google.GenAI.Models.GenerateContentAsync(...)
   at Microsoft.Extensions.AI.GoogleGenAIChatClient.GetResponseAsync(...)

The same binary run on CoreCLR (dotnet run, no PublishAot) reaches the network for both cases.

Cause

Transformers.ProcessJsonNode injects a propertyOrdering array into any schema object with more than one property, appending each key via JsonArray.Add(string). That generic overload routes through JsonNode.ConvertFromValue<T>, which uses JsonSerializerOptions.Default. Under PublishAot, reflection-based serialisation is disabled and JsonSerializerOptions.Default resolves to EmptyJsonTypeInfoResolver, which has no metadata for System.String.

Because the injection is conditional on property count, one-property schemas never enter the path.

Repro project

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <NoWarn>$(NoWarn);MEAI001</NoWarn>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Google.GenAI" Version="1.18.0" />
    <PackageReference Include="Microsoft.Extensions.AI" Version="10.9.0" />
    <PackageReference Include="Microsoft.Extensions.AI.Abstractions" Version="10.9.0" />
  </ItemGroup>
</Project>
using System.Text.Json;
using Google.GenAI;
using Microsoft.Extensions.AI;

var apiKey = Environment.GetEnvironmentVariable("GEMINI_API_KEY") ?? "dummy-key";

await Probe("ONE property",   """{"type":"object","properties":{"a":{"type":"string"}},"required":["a"]}""");
await Probe("TWO properties", """{"type":"object","properties":{"a":{"type":"string"},"b":{"type":"string"}},"required":["a","b"]}""");

async Task Probe(string label, string schemaJson)
{
    Console.WriteLine($"--- {label} ---");
    try
    {
        var chat = new Client(apiKey: apiKey).AsIChatClient("gemini-3.1-flash-lite");
        using var doc = JsonDocument.Parse(schemaJson);
        var options = new ChatOptions { ResponseFormat = ChatResponseFormat.ForJsonSchema(doc.RootElement.Clone()) };
        var response = await chat.GetResponseAsync([new ChatMessage(ChatRole.User, "Return any JSON matching the schema.")], options);
        Console.WriteLine($"  succeeded: {response.Text}");
    }
    catch (Exception ex) { Console.WriteLine($"  {ex.GetType().Name}: {ex.Message}"); }
}
dotnet publish -c Release -r osx-arm64 -p:PublishAot=true
./bin/Release/net10.0/osx-arm64/publish/<app>

Suggested fix

Avoid the generic conversion when building the ordering array — append pre-constructed string JsonValue nodes, or supply options with a resolver that covers System.String.

Secondary observation (separate concern)

A clean AOT publish of this minimal console app emits 138 trim/AOT warnings with TrimmerSingleWarn=false. 135 of them originate in Newtonsoft.Json, which is not used by Google.GenAI itself — it arrives transitively through Google.Apis.Auth 1.69.0 (the OAuth2/credential stack). Only 3 originate in Google assemblies.

To be explicit, since the two are easily conflated: Google.GenAI serialises API payloads with System.Text.Json (a direct dependency, System.Text.Json 10.0.8; the assembly contains no reference to Newtonsoft), which is why the crash above sits in System.Text.Json. Newtonsoft.Json is confined to the auth dependency and is a separate obstacle — it is what prevents a warning-clean AOT publish for any consumer. Happy to file that separately.

Metadata

Metadata

Assignees

Labels

status:awaiting user responsetype: bugError or flaw in code with unintended results or allowing sub-optimal usage patterns.

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions