Structured output makes a model answer in JSON that follows a schema, so your code gets data rather than prose. OneLLM builds the schema from a C# record and parses the answer back into it.
dotnet add package RecursiveAI.OneLlmRequires .NET 10 (net10.0); .NET 9 and earlier aren’t supported.
using System.Text.Json.Serialization; using OneLlm; Invoice inv = await Llm.AskAsync( "gpt-6-luna", "ACME Ltd billed 120.50 in fees.", AppJson.Default.Invoice); Console.WriteLine(inv); public sealed record Invoice( string Customer, decimal Total); [JsonSerializable(typeof(Invoice))] public sealed partial class AppJson : JsonSerializerContext;
Download this example (.zip): unzip, then dotnet run.
Prints Invoice { Customer = ACME Ltd, Total = 120.50 }. To try it without a key, download the example: it runs as is, with OneLLM’s scripted provider answering in the model’s place, and that is how we ran everything on this page. To call OpenAI, set your key first, then run the snippet:
export OPENAI_API_KEY="sk-..." # macOS, Linux $env:OPENAI_API_KEY = "sk-..." # Windows PowerShell
gpt-6-luna, claude-sonnet and gemini-flash are names and aliases from OneLLM’s model catalog, which maps each one to a provider’s model and its prices.
The full program
This is the Program.cs in the download. It extracts an order from a customer’s email (email.txt) into a record with a nested list. The scripted provider answers with the JSON in answer.json, and the program then prints the schema OneLLM sent.
using System.Text.Json.Serialization; using OneLlm; using OneLlm.Testing; // Runs without a key: the scripted provider answers as gpt-6-luna would, priced from the catalog. // To call OpenAI, set OPENAI_API_KEY and delete these five lines and the Enqueue line. var openai = new ScriptedProvider("openai"); var runtime = new Runtime(); runtime.Providers.Set("openai", openai); Llm.Default = runtime; openai.Enqueue(ScriptStep.TextWithUsage(File.ReadAllText("answer.json"), 412, 61)); string email = File.ReadAllText("email.txt"); AskResult<Order> result = await Llm.GenerateAsync("gpt-6-luna", $"Extract the order from this email.\n\n{email}", [], AppJson.Default.Order); Order order = result.Value; Console.WriteLine($"{order.Customer}, {order.Lines.Count} lines, {order.Currency} {order.Total}"); foreach (OrderLine line in order.Lines) Console.WriteLine($" {line.Quantity} x {line.Item} @ {line.UnitPrice}"); Console.WriteLine($"Rush: {order.Rush}, cost of the call: ${result.Cost.Total}"); // The JSON Schema OneLLM built from the record and sent with the request: var format = openai.Requests[0].ResponseFormat!; Console.WriteLine($"{format.Kind} '{format.SchemaName}', strict: {format.Strict}"); Console.WriteLine(format.Schema!.ToJsonString()); public sealed record Order(string Customer, string Currency, List<OrderLine> Lines, decimal Total, bool Rush); public sealed record OrderLine(string Item, int Quantity, decimal UnitPrice); [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] [JsonSerializable(typeof(Order))] public sealed partial class AppJson : JsonSerializerContext;
dotnet run prints:
Harbor Cafe, 2 lines, USD 192.00 12 x house blend coffee, bag @ 14.50 3 x paper filters, box @ 6.00 Rush: True, cost of the call: $0.0000717 JsonSchema 'Order', strict: True {"type":"object","properties":{"customer":{"type":"string"},"currency":{"type":"string"},"lines":{"type":"array","items":{"type":"object","properties":{"item":{"type":"string"},"quantity":{"type":"integer"},"unitPrice":{"type":"number"}},"required":["item","quantity","unitPrice"],"additionalProperties":false}},"total":{"type":"number"},"rush":{"type":"boolean"}},"required":["customer","currency","lines","total","rush"],"additionalProperties":false}
GenerateAsync returns the value with its usage and cost. Its third argument is a list of attachments, such as images or PDFs (Asset.File("scan.png")); [] sends none. Here the cost is 412 input and 61 output tokens of gpt-6-luna, priced from the catalog. The schema names every property required and closes each object with "additionalProperties": false, and the request asks for strict mode. The JSON names start with a lowercase letter (customer for the Customer property) because AppJson sets PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase.
If the answer isn’t valid JSON for the type, the call throws StructuredOutputException naming the type (see the variations). To call OpenAI for real, set OPENAI_API_KEY and delete the five scripted lines at the top.
Variations
Add each snippet to the full program: its statements go after the program’s last statement, and any record or class goes at the end of the file. The output shown is what the snippet added.
Without a JsonSerializerContext. AskAsync<T> finds the type by reflection. It’s shorter, but the docs say it “is not trim or AOT safe”:
openai.EnqueueText(File.ReadAllText("answer.json")); Order viaReflection = await Llm.AskAsync<Order>("gpt-6-luna", $"Extract the order.\n\n{email}"); Console.WriteLine(viaReflection.Total);
192.00The same call on Claude or Gemini. Only the model name changes, and each provider gets the schema through its own structured output feature. The first two lines register scripted providers for both; with real keys (ANTHROPIC_API_KEY, GEMINI_API_KEY), leave them out:
runtime.Providers.Set("anthropic", new ScriptedProvider("anthropic").EnqueueText(File.ReadAllText("answer.json"))); runtime.Providers.Set("gemini", new ScriptedProvider("gemini").EnqueueText(File.ReadAllText("answer.json"))); foreach (string model in new[] { "claude-sonnet", "gemini-flash" }) { AskResult<Order> r = await Llm.GenerateAsync(model, $"Extract the order.\n\n{email}", [], AppJson.Default.Order); Console.WriteLine($"{model}: {r.Value.Customer}, {r.Value.Total}"); }
claude-sonnet: Harbor Cafe, 192.00 gemini-flash: Harbor Cafe, 192.00
An answer that isn’t JSON, for example when the model can’t do the task:
openai.EnqueueText("I can't find an order in this email."); try { await Llm.AskAsync("gpt-6-luna", $"Extract the order.\n\n{email}", AppJson.Default.Order); } catch (StructuredOutputException e) { Console.WriteLine(e.Message); }
The model's answer is not a valid Order: 'I' is an invalid start of a value. Path: $ | LineNumber: 0 | BytePositionInLine: 0.Enums, nullable values and dates. With UseStringEnumConverter, an enum becomes a list of allowed names; DateOnly? becomes a date string that may be null:
openai.EnqueueText("{\"title\":\"Charged twice\",\"priority\":\"High\",\"due\":null}"); Ticket t = await Llm.AskAsync("gpt-6-luna", "Triage: I was charged twice!", TicketJson.Default.Ticket); Console.WriteLine(t); Console.WriteLine(openai.Requests[^1].ResponseFormat!.Schema!["properties"]!.ToJsonString()); public enum Priority { Low, Normal, High } public sealed record Ticket(string Title, Priority Priority, DateOnly? Due); [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, UseStringEnumConverter = true)] [JsonSerializable(typeof(Ticket))] public sealed partial class TicketJson : JsonSerializerContext;
Ticket { Title = Charged twice, Priority = High, Due = } {"title":{"type":"string"},"priority":{"enum":["Low","Normal","High"]},"due":{"type":["string","null"],"format":"date"}}
Related recipes
- Unit test LLM calls in C# without an API key: the scripted provider used here, in tests.
- Build an AI agent with tool calling in C#
- Track OpenAI API cost per call in C#
- Call Ollama from C#
The short answer
To get structured output from an LLM in C#, declare a record for the answer, list it in a source-generated JsonSerializerContext, and call Llm.AskAsync(model, prompt, AppJson.Default.YourRecord) from the RecursiveAI.OneLlm package. OneLLM builds a JSON Schema from the record, sends it through the provider’s native structured output, and deserializes the answer into your record. Llm.GenerateAsync does the same and also returns the usage and cost. An answer that isn’t valid JSON throws StructuredOutputException.
OneLLM is free and open source under MIT or Apache 2.0, with no native dependencies. The project’s getting started guide puts it this way: “The schema comes from the type and is sent through the provider’s native structured output. Passing the source-generated type info keeps the call trim and NativeAOT safe.” The rest of this page answers the questions people ask next.
What is LLM structured output?
It’s a request option that tells the model to answer with JSON matching a schema you give it, rather than free text. You describe the shape once (field names, types, which fields are required, allowed values) and read the answer as data. Without it, you ask for JSON in the prompt and hope, and a model may wrap the JSON in prose, rename a field or leave one out.
OpenAI’s, Anthropic’s and Gemini’s APIs each have their own form of this feature, with their own limits on which schema keywords they accept. OneLLM’s tool schema notes link each vendor’s list of supported schemas. In C#, the schema has a natural source: the record you want the answer in.
How does OneLLM turn a C# record into a JSON Schema?
From the record’s properties, by the rules in its tool signature notes. The full program above prints a real example. In short:
stringbecomes"string",intbecomes"integer", anddecimalanddoublebecome"number".boolbecomes"boolean", and lists and arrays become"array"withitems.- A nested record becomes a nested object, closed with
"additionalProperties": false. - A nullable type adds
"null"(DateOnly?became{"type":["string","null"],"format":"date"}). - An enum becomes its list of names when the context uses the string enum converter.
- Property names follow the context’s naming policy. With
JsonKnownNamingPolicy.CamelCase,UnitPricebecomesunitPrice; with no policy, it staysUnitPrice.
The T you pass must be a JSON object type. The happy-path docs say so (“T must be a JSON object type”), so wrap a list in a record rather than asking for a bare array.
How do you get JSON output from an LLM reliably?
Use the provider’s structured output rather than a prompt, and still validate what comes back. In OneLLM that means:
- Ask with a type.
AskAsync<T>sends the schema on every call. The request in the full program asked for strict mode (strict: True). - Catch
StructuredOutputException. It’s thrown when the answer doesn’t parse as your type, for example when a model declines in plain words. The message names the type and the JSON error. - Check values your code depends on. OneLLM deserializes the answer with System.Text.Json. In our run, a scripted answer that left fields out parsed without an error, with those fields at their defaults. Strict mode asks the provider to follow the schema, but a check like
order.Lines.Count > 0costs nothing. - Pick a model that supports it. OneLLM checks the catalog before sending. Its pricing and request notes say it refuses with
UnsupportedParameterException“a JSON schema for a model without structured output”, rather than sending a request that would be ignored.
Which LLM is best for structured output?
That depends on your data, and the honest test is to run your own examples. OneLLM makes switching cheap: the same record and the same call work across providers, and only the model name changes (gpt-6-luna, claude-sonnet, gemini-flash, as in the variation above).
What we can say is what was tested. OneLLM’s results list structured output among the cases of a live smoke test at release that passed against OpenAI (8 of 8 cases) and Gemini (7 of 7). Anthropic, Azure OpenAI / Foundry, Amazon Bedrock and Vertex AI were not run live, and are covered by recorded fixtures only.
For a structured answer at the end of a multi-step job, an agent definition can carry an outputSchema, and a decider answers typed choice, score and yes/no questions. Both are in the agents and deciders docs.