Mapping Finish Reason Values Between APIs
9 min read · updated August 11, 2026
The field that tells you whether a response is complete or truncated has a different name and a different enum on every API. Getting it wrong does not raise; it means your code treats a cut-off answer as a finished one, which is the failure mode users report as “it stops mid-sentence sometimes”.
Four fields, four names
Start with where to look, because even the location differs.
- OpenAI Chat Completions:
choices[0].finish_reason, a string on each choice. - OpenAI Responses: there is no finish reason. The response has a
status, and when that status isincompletethe reason is inincomplete_details.reason. This is a structural change, not a rename: the “why it stopped” information is split across two fields and one of them is usually absent. - Anthropic Messages:
stop_reasonat the top level of the message, with a companionstop_sequencefield naming which of your sequences matched. - Google Gemini:
candidates[0].finishReason, camel case, with an enum in SCREAMING_SNAKE_CASE rather than lowercase.
So a mapper cannot key off a single path, and it cannot compare strings case-insensitively either — stop and STOP happen to mean the same thing, but tool_use and tool_calls do not differ by case, and Gemini has no tool value at all.
The values that do map
Three concepts are genuinely common to all of these APIs: the model finished, the model hit a length limit, and the model wants to call a tool.
concept OpenAI Chat Anthropic Gemini ------------------ ------------------ ------------------ -------------------- finished naturally stop end_turn STOP hit the cap length max_tokens MAX_TOKENS wants a tool tool_calls tool_use (folded into STOP) filtered content_filter (HTTP error) SAFETY OpenAI Responses expresses the first two as status = "completed" status = "incomplete", incomplete_details.reason = "max_output_tokens"
Two rows in that table are approximations and should be treated as such. Gemini signals a tool call by the presence of a functionCall part in the candidate’s content, with the finish reason still STOP — so a mapper that only reads the enum will classify every tool call as a completed text answer. You have to inspect the parts. And OpenAI’s content_filter is a finish reason, where an Anthropic request blocked before generation surfaces as an error response rather than a stop reason, which means it arrives on a completely different code path.
The general treatment of one side of this is in the page on OpenAI’s finish_reason values; what follows is the part that only shows up when you cross between APIs.
The values with no counterpart
This is the lossy part, and it is the reason a mapping cannot be a lookup table with a default of “stop”.
Anthropic’s documented stop reasons include stop_sequence (one of your own sequences matched, and stop_sequence tells you which), pause_turn (a server-tool loop hit its iteration limit and the turn can be continued), refusal (the model declined), and model_context_window_exceeded (the response filled the context window rather than your max_tokens). Of those, only stop_sequence has an OpenAI analogue and even that one is collapsed: OpenAI reports a stop-sequence match as plain stop, so the information that a sequence matched, and which, does not exist on that side.
pause_turn is the one that will actively break a naive mapper. Mapped to stop, it looks like a finished answer; it is in fact an instruction to send the message back and let the model continue. There is no OpenAI value that means this.
Gemini adds its own orphans. Its enum includes FINISH_REASON_UNSPECIFIED, RECITATION (output flagged for unattributed quotation) and MALFORMED_FUNCTION_CALL (the model emitted a function call that did not parse). None of those exists elsewhere, and MALFORMED_FUNCTION_CALL in particular is a failure that has to be retried rather than surfaced as an answer.
When the value arrives
On a streamed response the value is not in the last thing you receive; it is in a specific frame before it, and the frame differs.
In OpenAI’s Chat Completions stream, every chunk carries choices[0].finish_reason and it is null until the final content-bearing chunk, where it is set. After that comes the literal line data: [DONE], which is not JSON and will throw if you hand it to a parser. If you enabled stream_options.include_usage, a usage-only chunk with an empty choices array sits between the two, so “the last chunk with a choice” and “the last chunk” are no longer the same object.
In Anthropic’s stream, stop_reason arrives on the message_delta event, inside its delta object, alongside stop_sequence. The terminal frame is the message_stop event, which carries nothing but its own type. A reader that waits for the final event and then looks for a stop reason finds none.
In the Responses API the completion signal is the response.completed event, which carries the whole response object — including its status — rather than a delta. Gemini’s streaming endpoint puts finishReason on the candidate in whichever chunk terminates it, usually the last.
There is a consequence of this that catches people building proxies. If you are translating one stream shape into another, the stop reason is not available at the moment you would like to emit it. An OpenAI-shaped consumer expects finish_reason on the chunk that carries the last piece of content; an Anthropic source does not tell you the reason until the following event. So a faithful translation has to hold the last content frame back by one event, or emit an extra content-free chunk at the end carrying only the finish reason. The second is the better choice: it keeps first-token latency intact and it is a shape OpenAI clients already handle, since a chunk with an empty delta and a set finish_reason is exactly what the real API sends.
Writing the switch statement
Normalise to a small enum of your own, and make the unknown case loud. The correct default is not “treat as complete”.
type Stop = | "complete" // safe to show the user | "truncated" // hit a cap; the answer is incomplete | "tool_call" // do not show; run the tool and continue | "continue" // provider asked to be re-invoked (Anthropic pause_turn) | "blocked" // filtered or refused; show a handled message | "unknown"; // log it, alert on it, treat as truncated // Unknown is deliberately grouped with truncated at every call site. // A new enum member is far more likely to mean "something went wrong" // than "everything was fine", and treating it as complete means shipping // a half-finished answer to a user with no trace of why.
Two rules make this hold up. First, assert on the raw value before mapping: log the provider string verbatim next to your normalised one, so when a vendor adds a member you find out from your own logs rather than from a support ticket. Second, treat truncated as a real outcome with its own handling — a retry with a larger cap, a continuation, or a visible message — rather than as a log line. The whole reason to normalise this field is to have somewhere to put that decision, and a mapping that ends in a silent default has not bought you anything.
Both Anthropic lists used above are from its published stop reason reference, which is the document to re-read when a value you have never seen appears in a log.