Claude, OpenAI, and Gemini each flag a truncated response with a different field and a different value: Claude’s Messages API returns stop_reason: "max_tokens", OpenAI’s Chat Completions API returns finish_reason: "length", and Gemini’s API returns finishReason: "MAX_TOKENS". All three mean the same thing — the model hit your output-token limit mid-generation — but only Gemini offers a built-in way to resume the exact same response rather than starting a fresh continuation prompt.
Image: “Macro circuit board” by Ingo Dierking, licensed CC BY-SA 4.0, via Wikimedia Commons.
The three fields, side by side
Each API names its field differently, which is a common source of bugs when a codebase calls more than one provider through a shared wrapper and checks for the wrong key.
| Provider | Field name | Value for a token-limit cutoff |
|---|---|---|
| Anthropic (Claude) | stop_reason | max_tokens |
| OpenAI | finish_reason | length |
| Google (Gemini) | finishReason | MAX_TOKENS |
A client that only checks OpenAI’s casing convention (finish_reason, lowercase snake values) will silently miss Gemini’s camelCase field name and upper-case value if it’s routing requests through an abstraction layer without normalizing the response shape first.
Every other value each provider defines
Truncation is one reason a response ends; each provider documents several others, and the lists don’t map one-to-one.
Claude’s stop_reason (per Anthropic’s documentation): end_turn (finished naturally), max_tokens (hit the limit), stop_sequence (emitted a configured stop string), tool_use (calling a tool), pause_turn (a server-tool loop hit its iteration cap), refusal (Claude declined, returned as an HTTP 200), and model_context_window_exceeded (the response filled the entire context window, not just the output-token budget).
OpenAI’s finish_reason (per OpenAI’s API reference): stop (natural end or stop sequence), length (hit the token limit), tool_calls (calling one or more tools), content_filter (content omitted by OpenAI’s filters), and the deprecated function_call.
Gemini’s finishReason is the longest list of the three, covering cases the other two fold into broader categories: STOP, MAX_TOKENS, SAFETY, RECITATION (potential recitation of training data), LANGUAGE (unsupported language used), MALFORMED_FUNCTION_CALL, SPII (potential sensitive personal information), BLOCKLIST (forbidden terms), PROHIBITED_CONTENT, TOO_MANY_TOOL_CALLS, OTHER, and — the one with no equivalent on the other two platforms — CONTINUATION. Gemini’s image-generation models add a further set (IMAGE_SAFETY, IMAGE_PROHIBITED_CONTENT, IMAGE_RECITATION, NO_IMAGE), which only applies when the request asked for image output and has no counterpart in a pure text comparison.
The one real asymmetry: resuming a cut-off response
Claude and OpenAI both expect you to handle a token-limit cutoff the same manual way: take the partial output, append it to the conversation as the assistant’s turn, and send a follow-up user message asking the model to continue from where it left off. Anthropic’s own documentation shows exactly this pattern — appending the partial response and sending “Please continue from where you left off” as the next user turn — and notes that if the cutoff happened mid-tool_use block, you may instead need to simply raise max_tokens and resend the same request, since a half-formed tool call can’t be continued piecemeal.
Gemini’s CONTINUATION finish reason exists for a narrower but related case: the response hit the per-request token limit but the model flags that generation “is not yet complete,” and the API can hand back a continuation token that a later request uses to pick up the same generation rather than re-prompting from scratch with reconstructed context. It’s a different mechanism from Claude and OpenAI’s “resend the conversation with the partial text appended” pattern — closer to resuming a paused stream than starting a new turn.
A normalization layer, worked out
Here’s the minimum mapping needed before any retry logic can treat the three providers the same way. It’s written to show the shape of the problem, not as a drop-in library — your own code needs to handle streaming deltas and partial-JSON parsing on top of this.
function normalizeStop(provider, response) { if (provider === "anthropic") return { kind: response.stop_reason === "max_tokens" ? "truncated" : response.stop_reason === "refusal" ? "refused" : response.stop_reason === "tool_use" ? "tool_call" : "done" }; if (provider === "openai") { const r = response.choices[0].finish_reason; return { kind: r === "length" ? "truncated" : r === "content_filter" ? "refused" : r === "tool_calls" ? "tool_call" : "done" }; } if (provider === "gemini") { const r = response.candidates[0].finishReason; return { kind: r === "MAX_TOKENS" ? "truncated" : r === "CONTINUATION" ? "truncated_resumable" : ["SAFETY","BLOCKLIST","PROHIBITED_CONTENT","SPII"].includes(r) ? "refused" : r === "MALFORMED_FUNCTION_CALL" ? "tool_call_error" : "done" }; } }
Two things in that mapping are easy to get wrong. First, Gemini’s refusal-adjacent reasons split across four distinct enum values (SAFETY, BLOCKLIST, PROHIBITED_CONTENT, SPII) where Claude and OpenAI each use one (refusal, content_filter) — collapsing all four into a single internal “refused” bucket loses information your logging might want to keep separately. Second, truncated_resumable is deliberately not the same internal state as plain truncated: only Gemini’s CONTINUATION case carries a token your retry code can use instead of re-sending the whole conversation, so merging it with Claude’s and OpenAI’s “append and reprompt” path throws away the one piece of information that would have made the retry cheaper.
Streaming changes where the field shows up, not what it means
Claude’s streaming API documents exactly where its stop information lands: a separate message_delta event carries delta.stop_reason, arriving after the last content_block_delta — code that stops listening the moment text stops arriving, rather than waiting for that explicit event, will never see the stop reason at all. OpenAI’s API reference marks finish_reason on each streamed chunk as nullable, which is consistent with it staying empty until generation actually ends and only resolving to a real value on the terminal chunk, though OpenAI’s reference doesn’t spell out that sequencing as explicitly as Anthropic’s does. Either way, the practical rule holds for both: don’t key your “did the model finish normally” check off the arrival of text deltas, because the field that actually answers that question is reported separately, and for Claude specifically, only on a named event your stream handler has to watch for.
What this means for a multi-provider codebase
Three practical consequences follow from the differences above, and none of them are optional if you support more than one provider behind the same retry logic.
- Normalize the field name and casing before you branch on it. A switch statement checking
response.finish_reason === "length"will never match a Gemini response, whose field isfinishReasonand whose value is"MAX_TOKENS". Map every provider’s cutoff value to one internal constant first. - Don’t reuse the same “continue” handler across providers unchanged. The append-and-reprompt pattern that works for Claude and OpenAI wastes Gemini’s continuation token if one was returned, and sending Gemini’s continuation token format to a provider that doesn’t recognize it will just fail silently or get ignored as inert text.
- Treat a cutoff during a tool call as its own case, not a generic truncation. On Claude, a
max_tokensstop reason with the last content block being an in-progresstool_useneeds a higher token limit on retry, not a “please continue” follow-up — the partial tool call isn’t valid JSON to resume from. Gemini’s parallel case isMALFORMED_FUNCTION_CALL, a distinct finish reason rather than a flavor ofMAX_TOKENS, so code that only checks for truncation will miss it entirely. - Log the provider-specific value, not just your normalized bucket. Collapsing everything into
truncatedorrefusedfor retry logic is correct, but if that’s also all you store for monitoring, you lose the ability to later ask “how often is Gemini stopping for SPII versus BLOCKLIST” or “did OpenAI’s content_filter rate change after a prompt edit.” Keep the raw value alongside the normalized one.
None of these three APIs will warn you that your cross-provider abstraction is silently mishandling truncation — a mismatched field name just returns undefined and the code falls through to whatever the default branch does, which is often “treat it as a normal completion” and ship a truncated answer to a user without anyone noticing in testing.
That failure mode is worth testing for directly rather than waiting to notice it in production: set max_tokens deliberately low on a prompt you know needs a long answer, run it against each provider you support, and confirm your code actually branches into the truncated-response path instead of quietly returning a half sentence as if it were complete. It’s a five-minute test that catches a bug most teams only find later, after a user reports a response that trails off mid-word with no warning anywhere in the interface.
For the mechanics of what triggers a tool-related stop reason in the first place, see how each provider handles calling several tools in one turn and how each one defines function calling. If your pipeline needs the response in a specific shape rather than free text, choosing between structured output modes covers a related failure mode: a schema violation instead of a token cutoff. And if token limits are a recurring cost concern rather than just a truncation risk, setting up prompt caching breakpoints addresses the input side of that budget.



