DEV Community

jidonglab
jidonglab

Posted on

Claude stop_reason max_tokens: json_repair Hid 198 Truncated Replies

My ticket-extraction pipeline had a 99.6% success rate. Only 14 errors across 3,418 Claude calls in two weeks. I was proud of that number for about a month.

Then a teammate asked why our longest, angriest support tickets had fewer action items than the short polite ones. The answer was stop_reason: "max_tokens". Claude had been cut off mid-JSON 212 times, and a "helpful" repair library had quietly stitched 198 of those broken replies into perfectly valid, perfectly incomplete records.

This is the autopsy.

TL;DR

  • When Claude hits your max_tokens limit, the response still comes back as HTTP 200 with stop_reason: "max_tokens". No exception is raised. Your JSON just stops.
  • 212 of my 3,418 calls (6.2%) were truncated. Every one had usage.output_tokens exactly equal to 1024, my max_tokens value.
  • json_repair closed the open brackets on 198 of them, producing valid JSON with the last fields missing. Only 14 failed loudly.
  • Fix: check stop_reason before you parse anything. On max_tokens, retry with a bigger budget or fail hard. Never repair.
  • Raising max_tokens is cheap because you're billed for tokens generated, not tokens allowed.

What does stop_reason max_tokens actually mean?

stop_reason: "max_tokens" means the model was still generating when it hit the output ceiling you set, so the API stopped it. The request succeeded, the response is well-formed, and the content is truncated at whatever token happened to be number 1024.

Nothing in the SDK treats this as an error. It's a normal completion with a different label. If your code reads resp.content[0].text and moves on, you'll never know.

Here's what I had, more or less:

import json
from json_repair import repair_json

resp = client.messages.create(
    model=MODEL,
    max_tokens=1024,
    system=EXTRACTION_PROMPT,
    messages=[{"role": "user", "content": ticket_text}],
)

text = resp.content[0].text
try:
    data = json.loads(text)
except json.JSONDecodeError:
    data = json.loads(repair_json(text))  # "just in case"
Enter fullscreen mode Exit fullscreen mode

That except branch was added on day one because Claude occasionally wrapped output in a code fence. It was meant for cosmetic noise. It ended up laundering real data loss.

How did json_repair hide truncated Claude output?

json_repair is good at its job, which is the problem. Give it a string that ends mid-array and it closes the array, closes the object, and hands back valid JSON. It has no idea that the missing part mattered.

A truncated reply looked like this:

{"customer_tier": "enterprise", "sentiment": "angry",
 "reasoning": "The customer describes three separate outages...",
 "action_items": [
   {"owner": "billing", "task": "Refund March invoice"},
   {"owner": "infra", "task": "Post incident report for the
Enter fullscreen mode Exit fullscreen mode

After repair:

{"customer_tier": "enterprise", "sentiment": "angry",
 "reasoning": "The customer describes three separate outages...",
 "action_items": [
   {"owner": "billing", "task": "Refund March invoice"},
   {"owner": "infra", "task": "Post incident report for the"}
 ]}
Enter fullscreen mode Exit fullscreen mode

Valid. Schema-compliant. Missing the third, fourth and fifth action items, plus the priority and escalate fields that came after the array. My Pydantic model had defaults for those, so escalate silently became False on enterprise customers who were threatening to churn.

The 14 errors I did see were cases where the cut landed somewhere repair couldn't guess, like the middle of a key name. Those 14 were the only visible symptom of a 212-row problem.

How do you find truncated responses after the fact?

Look for rows where output_tokens equals your max_tokens. I wasn't logging stop_reason, but I was logging usage, and that was enough:

SELECT output_tokens, count(*)
FROM llm_calls
WHERE job = 'ticket_extract'
GROUP BY output_tokens
ORDER BY output_tokens DESC
LIMIT 5;
Enter fullscreen mode Exit fullscreen mode

The top row was 1024 with a count of 212. The next row was 1019 with a count of 1. A real distribution doesn't do that. When I plotted all 3,418 calls, it looked like a normal hill with a wall bolted onto the right edge.

Then I bucketed truncation rate by input length:

Ticket input tokens Calls Truncated Rate
under 2k 2,391 9 0.4%
2k to 6k 873 140 16.0%
over 6k 154 63 40.9%

The bug wasn't random. It targeted exactly the tickets that mattered most. Long tickets are long because something went badly wrong, and those are the ones where a missing escalate: true costs real money.

Why was 1024 tokens not enough?

My output schema had a reasoning field, and I'd put it first so the model would "think before extracting." On long tickets it wrote essays. Across the 212 truncated replies, reasoning averaged about 380 tokens before the first action item even started.

So the budget went to the part of the output I never stored. The part I did store got whatever was left.

I kept the reasoning field but capped it in the prompt ("two sentences max") and moved escalate and priority above action_items, so the most important booleans land early. Median output dropped from 610 tokens to 290. That alone would have prevented most of the truncations, but it's a mitigation, not a fix. A ticket with 30 action items will still blow any budget you pick.

How do you handle stop_reason max_tokens correctly?

Check stop_reason before parsing, and treat anything other than a clean finish as a failure you handle on purpose. Here's the version that runs now:

class Truncated(Exception):
    pass

def extract(ticket_text: str, budget: int = 1024) -> Ticket:
    resp = client.messages.create(
        model=MODEL,
        max_tokens=budget,
        system=EXTRACTION_PROMPT,
        messages=[{"role": "user", "content": ticket_text}],
    )
    log_call(resp, budget)  # now stores stop_reason too

    if resp.stop_reason == "max_tokens":
        if budget >= 8192:
            raise Truncated(f"still truncated at {budget}")
        return extract(ticket_text, budget * 4)

    if resp.stop_reason != "end_turn":
        raise RuntimeError(f"unexpected stop_reason: {resp.stop_reason}")

    return Ticket.model_validate_json(strip_fences(resp.content[0].text))
Enter fullscreen mode Exit fullscreen mode

Three rules I follow now:

  1. stop_reason is checked first. Parsing a response you haven't classified is guessing.
  2. No JSON repair on model output. I kept a narrow strip_fences helper for code fences, and that's all. If the JSON is broken, I want the exception.
  3. Streaming gets the same check. If you stream, stop_reason arrives in the message_delta event near the end. Don't commit the accumulated text until you've seen it.

Using tool use or structured outputs instead of raw JSON text helps with formatting, but it doesn't save you here. A tool call cut off by max_tokens is just as incomplete. The check still has to happen.

Does raising max_tokens cost more?

Not by itself. You pay for output tokens the model actually generates, not the ceiling you set. A reply that finishes in 290 tokens costs the same whether max_tokens is 1024 or 8192.

What costs money is the retry, because the second call re-sends the whole input. I reprocessed the 212 bad rows and the retries added about 7% to that job's token count for the two-week window. After the schema change, retries dropped to 11 calls a week. Honestly, I could've just started at 4096 and skipped the retry path for most of them. I kept the retry because I want a hard ceiling that pages me, not a silent one.

The real bill was the cleanup: 198 records to reprocess and 23 enterprise tickets that should have been escalated and weren't. Two of those had already gone to a renewal call where nobody knew about the outage.

What should you check in your own pipeline today?

Run the output_tokens = max_tokens query against your logs. If you don't log usage, start today, because it's the only way to audit this after the fact. Then grep your codebase for any JSON repair, lenient parsing, or except: return {} near an LLM call. Every one of those is a place where truncation turns into a quiet wrong answer instead of a loud error.

A low error rate is not proof of health. Mine was low because I'd built a machine that converted errors into data.

So why did json_repair hide 198 truncated Claude replies?

Because Claude reports stop_reason: "max_tokens" as a successful response, not an error, and my code never checked it. The replies were cut off at exactly 1024 output tokens, and json_repair closed the dangling brackets so the broken JSON parsed cleanly, with the trailing fields silently dropped or defaulted. The fix is to read stop_reason before parsing, retry with a larger max_tokens (which costs nothing unless the tokens are used), put critical fields early in the schema, and never repair model output you haven't classified. Audit your history by counting rows where output_tokens equals your limit.


Written by the developer behind Preterview, an interview prep platform.

Top comments (0)