Skip to content

12 min read

The completion check at the response boundary

On this page

An extraction service that writes 0.4% of its rows with a field the model never finished producing has no broken component. The HTTP client, the SDK, the JSON parser, the schema validator, the business rule and the database column each behave exactly as specified, and a review that goes looking for the faulty one will not find it.

What that service is missing is a component rather than a fix: a single place where somebody asks whether generation completed. This chapter is about where that place is, why it can only be there, how to test it, and what belongs beside it so that the condition is visible even when the check is doing its job.

Every layer answered its own question correctly

Walk the path a truncated response takes through a typical service and the absence becomes structural rather than accidental.

The HTTP client received a 200 and returned it. The SDK deserialised the body into a message object and did not raise, because a stop reason is data, not an error — raising on max_tokens would break every caller who deliberately caps output for a preview. The JSON parser was handed valid JSON and produced a dictionary. The schema validator was handed an object with all its required fields and produced a domain object. The business rule that follows was asked whether an empty notes is acceptable and said yes, correctly, because 8% of invoices have no notes. The database column was nullable, correctly, for the same reason. Monitoring tracked error rate, p95 latency and rows written per hour, all of which were normal, because 28 truncated responses per day on 7,000 requests is 0.4% and every one of them produced a row.

Flowchart: HTTP 200 → SDK message object stop_reason = max_tokens; SDK message object stop_reason = max_tokens → json.loads — valid; json.loads — valid → Schema — required fields present; Schema — required fields present → Extraction domain object stop_reason no longer exists; Extraction domain object stop_reason no longer exists → Business rule: empty notes is legal; Business rule: empty notes is legal → INSERT — column is nullable; SDK message object stop_reason = max_tokens → Schema — required fields present (the check belongs here).

Reviews go badly when this is not stated plainly. Every layer in that chain answers a question correctly, and none of the questions is "did generation complete?" — so the system never asks it, and a system that never asks a question cannot be said to have got the answer wrong. Reviews that go looking for the broken layer either find nothing, which produces a shrug and no action, or settle on the most proximate layer and add a rule there, which produces a field-level validator and its slow decay into carve-outs.

The belief that stalls those reviews is that a bad outcome implies a broken component. It is a good belief most of the time, which is what makes it hard to put down: the large majority of production faults really are one component doing something other than what it claims, and the technique that follows from the belief — narrow the blast radius, find the layer, read its code — genuinely works. It does not work here, and the reason is that correctness of a part is defined against the question that part was given. A layer answering its own question correctly is correct, and remains correct however bad the outcome is. A chain of such layers is complete only up to the set of questions somebody thought to ask, and no member of the chain is answerable for a question that was never written down. What is absent is not behaviour inside a layer. It is a layer, which is why the fix reads as an addition, and why hunting for the culprit ends in a shrug.

The dotted edge marks the only span in that diagram where the check can be written, and the reason is the label on node E. The finish reason lives on the provider's response object. The moment that response becomes an Extraction, the field is gone — not hidden, not inconvenient to reach, gone, because your domain type does not have a slot for it and should not. Every layer downstream of that conversion is working with an object that no longer carries any evidence about how the generation ended, which is why none of them could have caught this no matter how carefully they were written.

Stated as a rule, that gives a placement argument you can apply to checks with nothing to do with truncation. A check needs two things in scope at the same time: the evidence it examines, and the knowledge of what the right answer is here. The finish reason is in scope from the moment the SDK parses the response until the moment the domain object is built, and nowhere afterwards. What counts as an acceptable ending is in scope only where the purpose of the call is known — an agent loop that expects tool_use, a preview that means its cap. Above the call site the first is available and the second is not. Below the conversion the second is available and the first is gone. Those two intervals overlap in exactly one region, and the conversion function sits at the end of it. The boundary is therefore not a matter of taste about layering. It is the last point at which both of the facts this check requires are in hand at once, and a check placed anywhere else has to reconstruct one of them.

Put the check where the information still exists

The conversion from provider response to domain object is where a codebase stops thinking in terms of the API and starts thinking in terms of the business. That is the last place the finish reason exists and the first place anyone would think to look for it, which makes it the right place for one reason: it is the point of no return for the information.

python
class IncompleteGeneration(Exception):
    def __init__(self, stop_reason: str | None):
        super().__init__(f"generation did not complete: {stop_reason}")
        self.stop_reason = stop_reason


def to_extraction(response) -> Extraction:
    """The single boundary between the provider's response and our domain."""
    stop_metric.labels(reason=response.stop_reason or "none").inc()

    if response.stop_reason not in ACCEPTABLE_ENDINGS:
        raise IncompleteGeneration(response.stop_reason)

    return Extraction.model_validate_json(response.content[0].text)

Three properties make this hold up where a field rule does not. It is applied to the finish reason rather than to any field, so it catches truncation in notes, in line_items, in vendor_address, and in the field somebody adds next quarter, without being edited. It has no opinion about what an empty string means, so legitimately empty notes flow through untouched and the false-failure problem never arises. And it is one function, so there is one place to read to know what this service considers a complete response, rather than a set of field validators that collectively imply an answer.

The check runs before model_validate_json, and the ordering is the same rule that governs repair passes: the cheap, definitive check about whether the response is worth interpreting comes before any interpretation. Reversed, the validator would sometimes raise its own error first — a type error on a half-emitted number, say — and the diagnostic you get would be about a schema mismatch rather than about a truncation, which sends whoever is on call to the wrong file.

A check like this is cheap to test, which matters because a check nobody tests is a check that quietly stops working the day someone refactors the boundary. You do not need the network for it — fabricate the response shape and assert the behaviour:

python
def test_truncated_response_is_rejected():
    response = FakeResponse(
        stop_reason="max_tokens",
        text='{"id": "A-1", "total": 4200, "notes": ""}',   # valid JSON
    )
    with pytest.raises(IncompleteGeneration):
        to_extraction(response)


def test_legitimately_empty_notes_is_accepted():
    response = FakeResponse(
        stop_reason="end_turn",
        text='{"id": "A-2", "total": 900, "notes": ""}',
    )
    assert to_extraction(response).notes == ""

The pair matters more than either test alone. The first pins the behaviour a truncation bug is about; the second pins the behaviour a field validator gets wrong, and it is the one that stops a future contributor from "improving" the check into a non-empty-notes rule.

The metric that belongs beside the exception

Alerting on the rate of non-natural finish reasons is not a substitute for the check, but it is a necessary complement, and it is the second half of what a service in this state is missing. The counter in to_extraction costs one line and turns an invisible 0.4% into a labelled series. At 7,000 requests a day, 28 truncations do not move an error-rate panel that renders in whole percentages, but they are unmissable on a graph of stop reasons by count, and a threshold on that graph fires on the day a model migration or a longer document class pushes the rate from 0.4% to 4%. The exception stops the bad row from being written; the metric tells you it is happening at all. Neither one does the other's job.

The invisibility is worth doing as arithmetic, because the arithmetic is what shows that nobody involved was careless. Twenty-eight responses out of seven thousand is four in every thousand, which is the 0.4% above. A dashboard that renders an error rate to the nearest whole percentage point displays four in a thousand as zero. It also displays it as zero on the day the rate doubles, and on the day it doubles again. Nothing in that panel is broken; a percentage rounded to a unit cannot represent a quantity living two orders of magnitude below the unit, and no amount of attention paid to the panel changes what it is able to show. Counting stop reasons separately escapes the problem because a count of twenty-eight is a count of twenty-eight regardless of what the denominator is doing, and because a step change in a small count is plainly visible on a graph of that count while being invisible in a ratio that was rounding to zero on either side of the step.

The metric's design is unfussy and worth getting right on the first pass. Label by stop reason and by call site, and by nothing else — the reason field has a handful of values and your call sites are countable, so cardinality (the number of distinct label combinations, and therefore of separate time series, that the metric creates) stays trivial, whereas a label carrying the model string or the document type will grow without bound and make the series expensive. Count every response, including the successful ones, because a rate needs a denominator and reconstructing one from a separate request counter is the kind of thing that goes subtly wrong during a deploy. Alert on the ratio rather than the raw count so that the threshold survives traffic growth.

Choose the threshold from the measured baseline rather than from zero. A service that legitimately truncates 0.3% of the time because a small share of its inputs are genuinely enormous will page somebody every night under a zero-tolerance alert, and an alert that pages every night is an alert that gets muted. Setting it at three to five times the observed baseline catches the step changes — a migration, a new document source, a prompt edit that made answers longer — which is what you actually want to know about.

Why the layers above and below cannot host it

Pushing the check up, into an SDK interceptor or an HTTP middleware that raises on any non-end_turn response, is appealing because it would cover every call site at once. It fails on the same point that makes an allowlist a per-call-site constant: which endings are acceptable is a property of the call site, not of the transport. An agent loop must accept tool_use; a call using server-side tools must accept pause_turn and resume; a deliberate preview request may accept max_tokens and mean it. A middleware that raises on all of them breaks working code, and one that accepts all of them checks nothing. Hoisting the check above the point where those distinctions exist requires throwing away the distinctions.

Pushing it down, into schema validation, is closed off for a different reason. Schemas describe documents, and interruption is not a property of a document.

An exception is also a decision to stop rather than a recovery, and conflating the two produces the retry loop that re-runs the identical request against the identical cap and truncates three times before giving up. A truncation is deterministic given the same inputs, so a useful retry has to change something. Re-issuing with a substantially larger max_tokens works when the cap was simply undersized. Splitting the document and extracting in two passes works when the input is the outlier. Routing to a dead-letter queue for manual handling works when neither is safe to do automatically. All three are choices made by the caller with knowledge of the workload, which is precisely why the boundary function should surface the condition and stop rather than guess.

Nor is the check a correctness gate. A response with stop_reason: "end_turn" and a hallucinated total passes it, as it should. There is even a case it cannot see: a model that decides it is finished while having answered half the question emits a perfectly natural ending on a genuinely incomplete answer, and no finish-reason check will ever catch that, because from the API's point of view nothing went wrong. That case belongs to prompting and evaluation, and it is worth naming precisely so that the completion check does not get blamed for missing something it never claimed.

What the boundary buys is narrow and durable. One function, one comparison, one counter, placed at the last point where the provider's account of the generation still exists — and after that point, every downstream layer is free to reason about documents without ever needing to know that a model produced them. Systems that lack it are not systems whose components are wrong; they are systems in which the question was never asked, which is why the fix is an addition rather than a repair.

That failure came from a bound set too low. The remaining case is stranger, and more common in user-facing products: a bound set exactly where you intended, doing exactly what you asked, and still cutting the answer in half.

Stop sequences, max tokens, truncation, silent output cutoff — read the current version at https://daviddominguez.dev/learn/en/stop-sequences-max-tokens-truncation-silent-output-cutoff/the-completion-check-at-the-response-boundary

Back to top