Skip to content
Chapter 4 of 6 · Getting JSON outDetecting a model refusal

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.

json
{
  "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}
}
json
{
  "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}
}
json
{
  "error": {
    "message": "The response was filtered due to the prompt triggering the content management policy.",
    "code": "content_filter",
    "status": 400
  }
}
json
{
  "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.

python
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.content

The 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.

Flowchart: Response received → HTTP error?; HTTP error? → yes; yes → Provider error: filter, rate limit, 5xx; HTTP error? → no; no → refusal field set?; refusal field set? → yes; yes → REFUSAL; refusal field set? → no; no → finish_reason == length?; finish_reason == length? → yes; yes → TRUNCATED; finish_reason == length? → no; no → one complete JSON value?; one complete JSON value? → yes; yes → validates?; validates? → yes; yes → OK; validates? → no; no → SCHEMA; one complete JSON value? → no; no → prose reads as a decline?; prose reads as a decline? → yes; yes → REFUSAL; prose reads as a decline? → no; no → PARSE.
ClassWhat the response looks likeHow you knowWhat to do
Truncatedvalid prefix of a documentfinish_reason: lengthraise max_tokens, or split the input; retry once
Schemacomplete object, wrong contentsvalidator error pathretry with the error fed back, or fix the prompt
Parseprose or damaged formatting, no declinescan returns no_json or ambiguousretry once; investigate the prompt
Refusalprose that declines the taskrefusal field, or classificationroute to review; do not retry
Provider errornon-2xxHTTP status and codebackoff 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:

json
{
  "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

Back to top