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.
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, FunctionTool, LMRouter, Message, Request, ToolChoice
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. Describe the target shape as a
tool's input schema, force a call with ToolChoice(mode="required"),
and read tool_calls[0].input — already a dict, no string parsing.
No function exists behind the tool; nothing executes.
extract = FunctionTool(
name="record_invoice",
description="Record one extracted invoice.",
parameters={
"type": "object",
"properties": {
"vendor": {"type": "string", "description": "Company that issued the invoice."},
"total": {"type": "number", "description": "Grand total, numeric."},
"currency": {"type": "string", "description": "ISO 4217 code."},
"line_items": {"type": "array", "items": {"type": "string"}, "description": "One string per line item."},
},
"required": ["vendor", "total", "currency", "line_items"],
},
)
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)",
…
]
}
Config.response_format takes exactly two shapes, checked when the
Config is built: {"type": "json_object"} or {"type": "json_schema", "schema": {...}, "name"?: ..., "strict"?: ...}. The schema itself is
yours and is sent verbatim. 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, its own OpenAPI-style subset, andresponseJsonSchema, full JSON Schema. lm15 usesresponseJsonSchemawhen your schema uses something only JSON Schema can say (additionalProperties,const,$defs/$ref, a listtypesuch as["string", "null"], a non-stringenum), andresponseSchemaotherwise, so schemas written in Gemini's own style keep working. The same rule picks betweenparametersandparametersJsonSchemafor function tools (contract rule MAP-16).
A provider-native shape ({"format": ...}, {"generationConfig": ...}, a bare schema) is refused in response_format with a message
naming both canonical shapes. If you need a dialect feature the
canonical shape can't express, put it in Config.extensions, which
reaches that provider as-is — and gives up portability.
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).
-
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. OpenAI and Gemini honor it; Anthropic has no any-JSON mode and raisesUnsupportedFeatureErrorbefore sending, so give it a schema. 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.
- 06 — Function tools —
FunctionTooland dispatch. - 05 — Streaming — buffering deltas before
json.loads. - 02 — Conversations — feeding tool results back.
- ../mapping-rules.md — full per-provider wire mappings.