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.

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:

  1. Raise the budget. Most "invalid" output is still truncation. Rule it out first.
  2. 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.
  3. Then reach for a tolerant parserjson-repair for 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.