Structured outputs
When you need a machine-readable answer rather than prose — extracting fields, classifying, building an API response — structured outputs make the model return valid JSON, so there's no fragile string-parsing on your end. Pendra enforces this while the reply is being generated rather than by asking the model nicely in the prompt, so the shape you ask for is the shape you get.
The one thing that can still leave you with JSON that doesn't parse is
running out of room: if the reply hits max_tokens (or the end
of the model's context window) before the closing brace, you get the
fragment that was produced so far. Pendra tells you when that happens —
see Truncated replies below — so you never have
to infer it from a parse error.
JSON mode
Set response_format to
{ "type": "json_object" } and the reply is syntactically
valid JSON. Use this when you want JSON but don't need a specific shape —
describe the fields you want in your prompt.
JSON Schema
For a guaranteed shape, pass
{ "type": "json_schema", "json_schema": { ... } }. The
model is constrained to produce output matching your schema — the right
keys, the right types — so you can deserialise straight into your own
types.
Exact conformance with strict
Add "strict": true next to your schema (as in the example
below) and the reply matches it exactly: every field you list in
required is present, each key appears once, and nothing
outside the schema can appear. Nested objects, arrays and
enum values are held to the same standard, so the reply
deserialises into a typed struct or model without a validation step of
your own.
Without strict, the shape is best-effort: keys and value
types come from your schema, but an optional field the model skips — or a
required one it forgets — won't be corrected for you. Reach for
strict whenever you're parsing the result into a fixed type;
leave it off when you'd rather the model answer with whichever fields it
actually knows.
Enforced exactly: the object's shape and key set, which fields are
required, value types, enum and const values,
array item types and lengths (minItems /
maxItems), and schemas that reference their own definitions —
so a schema generated from a Pydantic model or a Zod object works as you'd
expect.
Some things can't be enforced: a union (anyOf,
oneOf, allOf — including an optional or nullable
field), a $ref to another document, and value constraints
like pattern, format, minLength or
minimum. Setting additionalProperties to a
schema (rather than false) is also not honoured — the reply
is held to exactly the keys you listed, so it still matches your schema,
but the extra keys you allowed won't appear. In particular, nothing in the
schema bounds how long a value can run — a number with no ceiling
is the usual way a reply ends up truncated, so give it a sensible
max_tokens and check for the notice below. The rest of the
schema is enforced in every case, and because you asked for exact
conformance and didn't get all of it, the reply tells you so:
{
"choices": [ ... ],
"pendra": {
"notice": {
"code": "strict_schema_not_enforced",
"message": "Part of this schema could not be enforced, so the reply may not match it exactly ..."
}
}
}
The pendra field is an addition of ours, so OpenAI-compatible
clients ignore it — check for it when you want to know whether the
guarantee held. When streaming, it arrives as its own event near the end
of the stream.
If response_format is malformed — a type other
than text, json_object, or
json_schema, or { "type": "json_schema" }
without its json_schema object — the request is rejected with
a 422 before it runs, so a bad constraint fails fast instead
of quietly returning unconstrained text.
Truncated replies
A schema constrains the shape of the reply, not how long the model takes to
fill it in. If generation stops because it reached max_tokens
— or ran to the end of the context window — before the JSON was finished,
you get finish_reason: "length" and a fragment that won't
parse. Rather than leave a parse error as your only clue, the reply says so
directly:
{
"choices": [{ "index": 0, "message": { ... }, "finish_reason": "length" }],
"pendra": {
"notice": {
"code": "truncated_during_structured_output",
"message": "Response truncated before the JSON was complete, so it will not parse ..."
}
}
}
Check for it and retry with a larger max_tokens, or a shorter
prompt if the context window is what ran out. A schema whose fields have no
natural end — an integer with no ceiling, a free-text field you asked the
model to be thorough in — is the common cause, so it's worth budgeting
generously for those.
Reasoning models
A reasoning model thinks before it answers, and its thinking isn't JSON. So
on those models the constraint takes effect at the start of the answer
rather than at the first token — the thinking is left alone, and everything
you actually receive as content is held to your schema, from
its first character to its last. You don't need to do anything differently:
your prompt doesn't have to ask for JSON, so an SDK helper that only passes
a schema — such as the OpenAI SDK's chat.completions.parse —
gets JSON back with thinking on, just as it does with thinking off.
The one case worth handling: if the model spends the whole reply thinking and never reaches its answer, there was nothing to constrain and what comes back isn't JSON. That reply tells you so:
{
"choices": [{ "index": 0, "message": { ... }, "finish_reason": "stop" }],
"pendra": {
"notice": {
"code": "structured_output_not_json",
"message": "The reply is not valid JSON even though response_format was set ..."
}
}
}
Retry, or send "reasoning_effort": "low" so the model
spends less of the reply thinking. A
thinking budget
avoids it altogether on most models: when the budget is used up the
thinking ends, and the JSON starts from there. See
Thinking
for the full set of reasoning controls.
On a few models, the same notice can instead say the model
finished thinking but its answer wasn't held to the format. Sending
the request again gives the same reply, so ask for the JSON in your prompt
as well, or send "reasoning_effort": "none" to turn
thinking off for that request.
from pendra import Pendra
client = Pendra()
response = client.chat.completions.create(
model="qwen3.6:27b",
messages=[{"role": "user", "content": "Extract the name and age from: Sara is 34."}],
response_format={
"type": "json_schema",
"json_schema": {
"name": "person",
"strict": True,
"schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
},
"required": ["name", "age"],
"additionalProperties": False,
},
},
},
)
print(response.choices[0].message.content) # {"name": "Sara", "age": 34}
{ "name": "Sara", "age": 34 }
Requests with images
response_format is not applied to a request that includes an
image, so the reply is not held to your schema. Ask for the JSON in the
prompt as well — most vision models follow it — and validate what comes
back, or run the image and the structured step as two calls: describe the
image first, then send that description as a text-only request with your
schema.
A reply from an image request that isn't JSON carries the same
structured_output_not_json notice, and its message names this
cause.
Workers with web tools on
On a worker with Web tools switched on, the model can search and fetch pages first, and your schema still applies to the final reply. When streaming, you see any search and page-fetch steps as they happen, but the reply itself only starts once the model has finished using tools, so the first text can take a little longer to arrive. The reply is written as a separate step after the model has finished with its tools, so these requests use more tokens than the answer alone. Requests with images are the exception, as above.
When the schema wasn't enforced
Occasionally the same notice comes back on an ordinary text request to a
model that doesn't think first — no image, no reasoning. That means the
schema couldn't be applied to the reply at all, so nothing held the model to
it. Retrying, or changing reasoning_effort, won't help here:
{
"pendra": {
"notice": {
"code": "structured_output_not_json",
"message": "... the schema was not enforced on this generation, so nothing held the model to it. Ask for the JSON in the prompt as well, or simplify the schema."
}
}
}
Ask for the JSON in the prompt as well as in response_format,
and validate what comes back. A very large or deeply nested schema is the
usual trigger, so simplifying it — fewer fields, shallower objects, one
schema per call — is what tends to fix it.