Structured output¶
Problem — You need a dict, not prose. Every provider can constrain
output to a JSON schema, but each calls the knob something different
(response_format, output_config, generationConfig) and each
accepts a slightly different schema dialect. lm15 gives you one field
and maps it; you still own parsing and validation.
Keys loaded as in recipe 01.
Recipe¶
Write the schema once, in the canonical lm15 shape: a dict with
type: "json_schema" and a schema key holding plain JSON Schema. Put
it on Config.response_format.
import json
from lm15 import Config, LMRouter, Message, Request, ToolChoice, tool
invoice_schema = {
"type": "json_schema",
"name": "invoice",
"strict": True,
"schema": {
"type": "object",
"properties": {
"vendor": {"type": "string"},
"total": {"type": "number"},
"currency": {"type": "string", "enum": ["USD", "EUR", "CAD"]},
"line_items": {"type": "array", "items": {"type": "string"}},
},
"required": ["vendor", "total", "currency", "line_items"],
"additionalProperties": False,
},
}
email = (
"From: billing@northwind.ca\n"
"Your order is confirmed. 3x Ergo keyboard ($89 ea), 1x USB hub ($35).\n"
"Total: $302.00 CAD. Thanks for shopping with Northwind Supply."
)
router = LMRouter()
response = router.complete(Request(
model="gpt-4.1-mini",
messages=(Message.user(f"Extract the invoice.\n\n{email}"),),
config=Config(response_format=invoice_schema),
))
print(response.text)
{"vendor":"Northwind Supply","total":302,"currency":"CAD","line_items":["3x Ergo keyboard ($89 ea)","1x USB hub ($35)"]}
response.text is a JSON string. parse_json() turns it into Python
and raises ValueError with the raw text in the message if the model
sent anything else.
data = response.parse_json()
print(type(data))
print(data["vendor"], data["total"], data["currency"])
<class 'dict'>
Northwind Supply 302 CAD
The same schema dict travels to every provider. lm15 rewrites it per wire format; your code does not change.
for model in ("gpt-4.1-mini", "gemini-3-flash-preview", "claude-sonnet-4-5"):
r = router.complete(Request(
model=model,
messages=(Message.user(f"Extract the invoice.\n\n{email}"),),
config=Config(response_format=invoice_schema),
))
d = r.parse_json()
print(f"{model:24} -> {d['vendor']!r}, {d['total']} {d['currency']}, {len(d['line_items'])} items")
gpt-4.1-mini -> 'Northwind Supply', 302.0 CAD, 2 items
gemini-3-flash-preview -> 'Northwind Supply', 302.0 CAD, 2 items
claude-sonnet-4-5 -> 'Northwind Supply', 302.0 CAD, 2 items
Schema enforcement constrains shape, not sense. A model can emit a
total of -5 in perfectly valid JSON. lm15 is stdlib-only and ships no
validator; write the checks that matter to you and run them on every
parse.
def check_invoice(d):
problems = []
for key in ("vendor", "total", "currency", "line_items"):
if key not in d:
problems.append(f"missing {key}")
if not isinstance(d.get("total"), (int, float)) or d.get("total", 0) <= 0:
problems.append(f"bad total: {d.get('total')!r}")
if d.get("currency") not in ("USD", "EUR", "CAD"):
problems.append(f"bad currency: {d.get('currency')!r}")
return problems
print(check_invoice(data))
print(check_invoice({"vendor": "x", "total": -5, "currency": "GBP"}))
[]
['missing line_items', 'bad total: -5', "bad currency: 'GBP'"]
The other route: tools-as-extraction. Define the target shape as a
function with lm15.tool(), force a call with
ToolChoice(mode="required"), and read tool_calls[0].input — already
a dict, no string parsing. The function body can be empty; nothing
executes it.
def record_invoice(vendor: str, total: float, currency: str, line_items: list[str]) -> None:
"""Record one extracted invoice.
Args:
vendor: Company that issued the invoice.
total: Grand total, numeric.
currency: ISO 4217 code.
line_items: One string per line item.
"""
extract = tool(record_invoice)
r = router.complete(Request(
model="claude-sonnet-4-5",
messages=(Message.user(f"Record this invoice.\n\n{email}"),),
tools=(extract,),
config=Config(tool_choice=ToolChoice(mode="required")),
))
call = r.tool_calls[0]
print(call.name)
print(json.dumps(call.input, indent=2))
record_invoice
{
"vendor": "Northwind Supply",
"total": 302.0,
"currency": "CAD",
"line_items": [
"3x Ergo keyboard ($89 ea)",
…
]
}
How it works¶
Config.response_format is a plain JsonObject; lm15 validates that
it is JSON-serializable and otherwise leaves it to the provider
adapter. On the wire:
- OpenAI (Responses) gets
text.formatwith{"type": "json_schema", "name": …, "schema": …}. The chat-completions dialect (Groq, Ollama, vLLM viaOpenAIChatLM) gets the nestedresponse_format.json_schemashape instead. - Anthropic gets
output_config.formatwith the same schema. - Gemini gets
generationConfigwithresponseMimeType: "application/json"plus the schema. Gemini has two schema fields:responseSchema(OpenAPI-ish, rejectsadditionalProperties) andresponseJsonSchema(real JSON Schema). lm15 picksresponseJsonSchemawhen your schema containsadditionalProperties,responseSchemaotherwise.
If you already hold a provider-native config, pass it through: a dict
with a text, output_config, or generationConfig key is forwarded
verbatim to that provider. That escapes portability — use it only when
you need a dialect feature the canonical shape can't express.
Response.parse_json() is json.loads on response.text plus honest
errors: it refuses non-text responses (tool calls, images) by listing
the part types, and includes a preview of unparseable text. The
response.json property is the soft version — None on any failure,
indistinguishable from a JSON null. Prefer parse_json() in
pipelines; parse_json(default=…) when you have a fallback.
When to prefer tools-as-extraction:
- The model also has real tools in the request — one mechanism, not two.
- You want the schema derived from a typed Python signature (see recipe 06) instead of hand-written.
- The provider's structured-output support is weaker than its tool-calling (common on open models behind chat-completions servers).
When to prefer response_format: pure extraction with no tool
machinery, strict: True guarantees on OpenAI, or when you need the
text to be the JSON document (logging, piping onward).
Variations¶
- Async mirror. Same field, same parsing:
from lm15 import AsyncLMRouter
response = await AsyncLMRouter().complete(req) # req as above
data = response.parse_json()
- JSON mode without a schema.
Config(response_format={"type": "json_object"})asks for some valid JSON object. All three providers honor it; you get no field guarantees, socheck_invoice-style validation is mandatory. Mention JSON in the prompt — OpenAI rejectsjson_objectrequests whose messages never say "JSON". strict: Trueis an OpenAI feature: the schema is compiled into a grammar and enforced during decoding, but every field must berequiredandadditionalProperties: False. The Anthropic and Gemini adapters forward onlyschema;strictandnamenever reach those providers.- Soft parsing. On a plain prose response,
response.parse_json(default=None)andresponse.jsonboth returnNone; bareparse_json()raisesValueErrorwith the raw text. - Streaming.
response_formatcomposes withstream()(recipe 05); you receive the JSON document as text deltas and must buffer to the end before parsing.
See also¶
- 06 — Function tools —
tool(),derive_tool(), and dispatch. - 05 — Streaming — buffering deltas before
json.loads. - 02 — Conversations — feeding tool results back.
- ../tools-from-functions.md — schema derivation rules.
- ../mapping-rules.md — full per-provider wire mappings.