If you moved your extraction and tool-argument paths onto a provider's strict structured-output mode — Anthropic's strict tools or output_config.format, OpenAI's strict: true, Grok's tool-calls — you already deleted the failure you used to fight most: syntactically invalid JSON. Constrained decoding forces the model to emit tokens that satisfy your schema, so the malformed-brace, trailing-comma, markdown-fenced mess is gone, and the json-repair loop you wrote last year is now dead code on that path.
But three failures walk straight through strict mode, and here's the thing that trips people: none of them is fixed by parsing harder. Truncation, refusal, and a safety stop all leave you without a valid object, and each wants a different response. The one habit that fixes all three is to check why the response ended before you try to parse it.
Read the terminating signal first#
Every response carries a field that tells you how generation stopped. Read it before json.loads, not after a parse throws.
- Truncation →
stop_reason: "max_tokens"(Anthropic) orfinish_reason: "length"(OpenAI, Grok). - Refusal →
stop_reason: "refusal"(Anthropic) or a populatedmessage.refusal(OpenAI); Grok surfaces an OpenAI-compatible refusal field. - Clean result →
stop_reason: "tool_use"/"end_turn", orfinish_reason: "stop".
resp = client.messages.parse(model="claude-opus-4-8", max_tokens=1024, ...)
if resp.stop_reason == "max_tokens":
raise Truncated() # a budget problem — re-run bigger, don't repair
if resp.stop_reason == "refusal":
return handle_refusal(resp) # a policy problem — never blind-retry
obj = resp.parsed_output # only now is parsing meaningful
Skip this and a truncated response looks exactly like "the model returned bad JSON," so you burn a retry on the wrong fix. The signal is free; use it.
Truncation is a budget bug, not a corruption bug#
By far the most common "broken structured output" is simply the model running out of output tokens mid-object. The JSON is correct up to the cutoff — which is exactly why "repairing" it is dangerous: closing the open braces fabricates values the model never generated, and now you've laundered a truncation into confident, wrong data.
The fix is upstream: raise max_tokens, or emit less — fewer fields, shorter strings, or chunk a large extraction into several calls. Remember that reasoning-heavy models spend output budget on thinking before the JSON, so a max_tokens that felt generous can still truncate. Detect length/max_tokens, bump the ceiling, re-run.
Truncated JSON isn't malformed — it's incomplete. The braces you'd add to make it parse are the exact bytes the model was never given room to say.
A refusal is a decision, not a glitch#
When the model declines — stop_reason: "refusal" or a set message.refusal — you get HTTP 200, billed tokens, and no object from .parse(). The reflex to wrap the call in a retry loop is the wrong instinct: an identical request refuses identically. You'll spend money spinning.
Branch instead. Surface the refusal to the caller, log it for review, or fall back — a reworded prompt, a safe default, a human step. A safety or content stop behaves the same way and takes the same handling; treat it as a refusal whose cause is the input. If refusals are frequent on a legitimate workload, that's a prompt or routing problem to fix at the source, not something to paper over downstream.
The real repair case: the non-strict path#
Genuine JSON repair still earns its keep — just not where most people put it. It belongs on the non-strict paths: json_object mode (valid JSON, but unconstrained shape), older models without strict support, or a provider that doesn't offer it. There, walk a recovery ladder, cheapest rung first:
- Raise the budget. Most "invalid" output is still truncation. Rule it out first.
- Re-ask with the error attached. Feed the parse error plus the offending output back and ask for a corrected object. Cheap, model-agnostic, and it fixes the long tail of genuine malformations.
- Then reach for a tolerant parser —
json-repairfor post-hoc fixups. Keep this last; a repair that silently succeeds on a truncation is the same data-invention trap as before.
The single best move, though, is to leave this path: if the provider offers strict/structured output, adopt it and the ladder mostly disappears. (The mechanics of doing that across providers — and the schema traps that break a naive port — are in the companion piece, one schema, three API shapes.)
Streaming is a partial-JSON problem by definition#
If you stream structured output to a UI, the JSON is incomplete on every frame but the last, so a strict json.loads fails until the end — which defeats the point of streaming. Here a partial parser isn't a repair tool, it's the mechanism: jiter and partial-json-parser parse the prefix into the best-known-so-far object each frame. OpenAI's SDK does this natively, emitting incremental parsed snapshots as the object fills in, so on that provider you often need no third-party parser at all. Either way, render from the partial object and let it converge — don't wait for a valid document that only exists at the final token. (For the streaming UX itself — rendering fields as they arrive — see streaming structured output from an LLM.)
The playbook, in one pass#
Read the terminating signal first. On length/max_tokens, raise the budget and re-run — never repair. On a refusal or safety stop, branch to surface/log/fallback — never blind-retry. Reserve tolerant parsers for the non-strict path and for streaming. And once the object parses, remember the last gap strict mode never closed: it guarantees the shape, not your rules, so validate dates, ranges, and patterns in your own layer. Do that and "structured output broke" stops being a mystery and becomes a three-way switch you already know the answer to.



