Chapter 4 of 6 · 13 min read
Detecting a model refusal
On this page
A pipeline that splits its failures four ways only gets value from the split if something in the code can tell the four apart on a single response. Three of them announce themselves. Truncation is readable from the field the provider sets to say why generation stopped, a schema failure is whatever the validator rejected with a field path attached, and a provider content block never produces a completion at all — it arrives as an HTTP error with a code, on the same path that handles a rate limit.
The fourth hides. A model refusal is a successful call: status 200, an ordinary stop reason, and a body containing a paragraph explaining why the model declined. Nothing in the envelope distinguishes it from a good extraction, and the extraction code sees exactly what a formatting failure produces — a response with no JSON in it. This chapter is about the discriminators that separate the two, what each of them costs, and why the two directions of misclassification are priced very differently.
Why the envelope cannot tell them apart is one step, and it is worth taking,
because a reader who has not taken it will keep hunting for a field that does
not exist. The fields a provider sets describe how generation ended: whether
the token budget ran out, whether a stop sequence was reached, whether the
request was blocked before anything was produced. A decline is not an abnormal
ending. The model produced the tokens it meant to produce and stopped when it
had finished, which is the same event as a successful extraction stopping when
it has finished. So the stop reason reads stop, the status is 200, and every
remaining field in the envelope is accounting. The only thing that differs
between a decline and a document is the content, and the content is precisely
what the envelope does not describe.
Four responses that look like one counter
Here are four responses from one morning on an invoice-extraction service, trimmed to the fields that matter. All four ended up in the same dead-letter queue — the holding store a pipeline drops work into when it cannot process it, kept so that somebody can inspect or replay it later.
{
"choices": [{
"finish_reason": "length",
"message": {"content": "{\"invoice_number\": \"INV-90412\", \"issue_date\": \"2024-11-03\", \"total\": 1240.00, \"currency\": \"USD\", \"status\": \"partial\", \"line_items\": [{\"description\": \"Rack unit 2U, ser"}
}],
"usage": {"prompt_tokens": 3182, "completion_tokens": 1500}
}{
"choices": [{
"finish_reason": "stop",
"message": {"content": "{\"invoice_number\": \"INV-90418\", \"issue_date\": \"2024-11-03\", \"total\": \"1,240.00\", \"currency\": \"USD\", \"status\": \"overdue\", \"line_items\": []}"}
}],
"usage": {"prompt_tokens": 2904, "completion_tokens": 118}
}{
"error": {
"message": "The response was filtered due to the prompt triggering the content management policy.",
"code": "content_filter",
"status": 400
}
}{
"choices": [{
"finish_reason": "stop",
"message": {"content": "I'm not able to extract billing details from this document. It appears to be a patient discharge summary rather than an invoice, and it contains health information I shouldn't transcribe into a structured record."}
}],
"usage": {"prompt_tokens": 4106, "completion_tokens": 44}
}The first has finish_reason: length, and the truncated string in content is
consistent with that. The second parses and fails validation on two fields, and the
validator names both — total is a string with a comma in it, status is outside
the enum. The third never produced a completion: the provider's content filter, a
safety layer that inspects requests and responses independently of the model,
blocked it before generation began, and it arrived as a 400 with a code.
The fourth is a 200, with finish_reason: stop, carrying a body. A scan for one
complete JSON value runs over that content, finds none, and raises no_json — the
exact same rejection reason it would raise for a response that was pure formatting
damage. Two entirely different situations, one counter, and the difference between
them is what you would do next: one wants a retry with a nudge, and the other wants
no retry at all, because the model already answered. It answered by declining.
Four discriminators, cheapest first
The provider gives you the answer directly in some configurations, and this is worth
checking before building anything. OpenAI's schema-constrained mode returns a
refusal field on the message, populated with the decline text and leaving
content null, precisely because a refusal cannot be expressed inside a schema the
model is being forced to conform to. Anthropic's Messages API surfaces
stop_reason: "refusal". Where such a field exists, checking it is one branch and it
is authoritative.
That field exists because of the mechanism rather than as a courtesy, and seeing
why tells you when to expect one. Under a schema constraint the decoder may only
emit tokens that continue a legal document. A decline is not a legal document,
so there is nowhere inside content for it to go: the provider cannot hand back
a conforming document and an explanation of why it declined to produce one in
the same field. That leaves two options — force out a document the model does
not stand behind, or open a second channel outside the constrained one and put
the decline there. Providers took the second. Which is why the field is present
in exactly the mode that makes prose impossible, and absent from the modes that
leave prose available.
choice = resp.choices[0]
msg = choice.message
if getattr(msg, "refusal", None):
return Outcome.REFUSAL, msg.refusal
if choice.finish_reason == "length":
return Outcome.TRUNCATED, msg.contentThe stop reason is the second discriminator and it covers truncation completely.
There is no ambiguity here and no heuristic involved: length means the generation
hit max_tokens, and whatever is in content is a prefix of a document rather than
a document. Checking it before parsing is worth the two lines, because a truncated
response will usually also fail to parse, and whichever check runs first is the
label the failure gets. On the pipeline in the chapter on locating the object, 71%
of the new parse failures after a prompt change carried finish_reason: length, and
they were being counted as parse failures purely because the parse ran first.
The third discriminator is the shape of the response, and it is where the refusal has to be caught when the provider gives you nothing. The test that matters is whether a complete JSON value was found. If one was, the response is a document and any failure is a schema failure. If none was, the response is prose, and prose is either a refusal or a formatting failure. Prefix matching separates those two reasonably well and imperfectly — a set of leading phrases like I'm not able, I cannot, I can't, I won't, This document appears to be — and it is worth measuring rather than assuming. Across 1,412 failed responses on this service, prefix matching identified 83% of the true refusals, with a 4% false-positive rate concentrated almost entirely on one pattern: responses that began I can see the total is… before going on to produce a perfectly good object further down, which the greedy prefix rule caught and a balanced-value scan did not.
The fourth is a secondary classification, which sounds expensive and is not, because
it runs only on responses that have already failed. A small fast model, given the
raw text and asked to answer refusal or malformed, needs roughly 400 input
tokens and 5 output tokens. At the price of a small model that is a fraction of a
cent per call, and at a 0.4% failure rate over 2M documents a day it is 8,000 calls,
which is a few dollars a day against a primary extraction spend two or three orders
of magnitude larger. The classification is not free of error either, so the useful
pattern is to use the prefix rule for the fast path and the classifier for the
residue, then sample both against human labels monthly.
That arithmetic comes out favourably for a structural reason rather than a lucky one, and the structure is the part worth copying. The classifier runs only on responses that have already failed, so its volume follows the failure rate rather than the traffic rate — four documents in a thousand instead of all of them. It also reads a failed response and emits one word, so its cost per call is a small fraction of an extraction's. A small share multiplied by a small unit cost disappears. The same reasoning is what rules a classifier out in front of the parser, where it would run on every document and cost a share of the primary spend to answer a question the parser answers for nothing.
The two directions of misclassification carry different costs, which is what decides how conservative the prefix rule should be. A refusal labelled as a parse failure enters the retry path and burns a small number of full-price calls before being dropped anyway; at a 0.1% refusal rate over 2M documents a day, with two retries each, that is 4,000 wasted calls and roughly $56 a day at the prices this service pays. A parse failure labelled as a refusal costs something money does not capture, since it skips the retry that would have recovered the document and sends it to a human queue with a three-hour turnaround and a note saying the model declined — which is the note the reviewer will believe, and which stops anyone from noticing that one more sample would have extracted it cleanly. Tuning the prefix list toward under-matching, and letting the classifier adjudicate whatever it misses, keeps the expensive error rare and the cheap one bounded.
The four thousand calls and the $56 above repay reconstruction, because the asymmetry the tuning advice rests on is only visible once both sides are priced. A tenth of a percent of two million documents is two thousand refusals a day; two retries each is four thousand calls; at what this service pays for a full-price extraction that is the $56. Fifty-six dollars against a primary spend measured in thousands is a rounding error, and it buys nothing, which is exactly what makes it the cheap error — bounded, pointless, and visible on a bill. The expensive error has no figure beside it, and that absence is the whole problem. A document sent to a human queue with a note saying the model declined has been handed a label that stops anyone questioning it: the reviewer reads the note, agrees the model declined, and nobody ever learns that one more sample would have extracted the document cleanly. A cost nobody can observe cannot be traded against anything, so the rule is to arrange the system so that the error you make is the one that shows up in a bill.
| Class | What the response looks like | How you know | What to do |
|---|---|---|---|
| Truncated | valid prefix of a document | finish_reason: length | raise max_tokens, or split the input; retry once |
| Schema | complete object, wrong contents | validator error path | retry with the error fed back, or fix the prompt |
| Parse | prose or damaged formatting, no decline | scan returns no_json or ambiguous | retry once; investigate the prompt |
| Refusal | prose that declines the task | refusal field, or classification | route to review; do not retry |
| Provider error | non-2xx | HTTP status and code | backoff and retry, except on content_filter |
Making a decline into a document
The strongest move available is to stop letting a decline be prose at all. If the contract has a branch for it, the model can decline inside the schema, and the whole discrimination problem collapses into a field read:
{
"type": "object",
"additionalProperties": false,
"required": ["outcome"],
"properties": {
"outcome": {"enum": ["extracted", "declined"]},
"invoice": {"type": ["object", "null"], "…": "…"},
"declined_reason": {
"type": ["string", "null"],
"enum": ["not_an_invoice", "illegible", "sensitive_content", null]
}
}
}Under a schema-constrained mode, the decoder cannot emit anything that is not one of
those two branches, so a model that would otherwise have written a paragraph of
apology writes {"outcome": "declined", "declined_reason": "not_an_invoice"}
instead. The decline arrives as structured data, it is countable by reason, and it
never touches the parse path. It also gives you something the prose version never
did, which is a taxonomy: not_an_invoice at 2% of traffic is a routing problem
upstream, and illegible at 2% is a scanning problem, and in prose form both of
them were just refusals.
Content filters, legitimate declines, and the retry rule
Refusal detection is not content moderation. The third response above was blocked by the provider's filter before the model produced anything, and it arrived as an HTTP error with a code, which is a different code path, a different owner, and a different remedy. Conflating the two produces a counter that moves for two unrelated reasons.
A refusal is also not automatically a defect. Some fraction of any real document stream is genuinely not what the pipeline expects, and a model that declines to transcribe health data out of a misfiled discharge summary has behaved correctly. Treating every refusal as something to be prompted away leads to prompts with escalating insistence in them — you must always return JSON, never refuse — which changes the rate at which the model declines without changing whether it should have, and produces confidently extracted nonsense on exactly the inputs where declining was right.
The declared branch does not eliminate the class either. A model can still refuse to
engage with the schema at all, which is what the provider's refusal field exists
to represent, and safety-trained refusals on genuinely sensitive input are exactly
the ones least likely to be expressible as an enum value the model was handed. What
the branch does is move the ordinary cases — wrong document type, unreadable scan,
out-of-scope content — out of the ambiguous bucket, so that the responses left in it
are few enough to look at individually.
The retry rule follows from what a refusal is. Truncation retried with a larger budget can succeed, because the cause was the budget. A parse failure retried can succeed, because sampling is stochastic and the formatting damage may not recur. A schema failure retried with the validator's error appended to the prompt often succeeds, because the model now has the information it lacked. A refusal retried with the same input almost always produces another refusal, since the reason for declining is the input, and the input has not changed. Each of those retries costs full price. At 2M documents a day and a 0.1% refusal rate, retrying refusals three times is 6,000 extra calls a day spent to be declined again, and it also delays by three round trips the moment when a human sees the document.
The discrimination is therefore not a monitoring nicety. It is the branch that
decides whether a failed document goes back to the provider or to a person, and
getting it wrong is expensive in one direction and slow in the other. Telling the
four apart on a single response is one problem. Knowing which of them moved this
week, when all you have is a dashboard, is another, and it is what a single
structured_output_failure counter at 4% makes impossible to answer.
Getting JSON out — read the current version at https://daviddominguez.dev/learn/en/getting-json-out/detecting-a-model-refusal