1 of 4
Branch a completion on finish_reason rather than on what the text looks like, and count what a conversation cost.
A reply was cut off at the token limit and this function handed the fragment back as a finished answer. Click the line that let it through.
python
Foundations· 3 of 3
Reading a completion
The object every model call hands back, and why finish_reason rather than the text is the thing your code branches on. Truncation, tool call turns, and what token usage counts.
25 minute read
A model call returns a structured response object that downstream agent components inspect. Understanding the schema of this object ensures reliable control flow throughout subsequent modules.
The response schema from outside in
A chat completion API returns a nested response structure. In mockllm, matching the standard OpenAI chat completions API, a text reply has the following structure:
Completion(
choices=[
Choice(
message=Message(role="assistant", content="Six on weekdays.", tool_calls=None),
finish_reason="stop",
index=0,
)
],
usage=Usage(prompt_tokens=7, completion_tokens=3),
model="mock",
)
Each layer serves a distinct architectural purpose:
Completionrepresents the full response envelope for one API request, containing response metadata and choices.choicesis a list of candidate completions. While APIs support generating multiple completions via the parametern, agent workflows typically request a single choice (choices[0]).Choicepairs a generatedMessagewith afinish_reasonstring indicating why token generation halted.Messagecontains therole("assistant"), the stringcontent(orNone), and an optional list oftool_calls(orNone). Because the model is stateless, assistant messages returned by prior calls are re-sent as input history in subsequent requests.usagecontains token accounting across the call:prompt_tokensfor the full input prefix, andcompletion_tokensfor all generated output.
Branching on finish_reason
Application logic must branch on finish_reason rather than parsing string contents:
| finish_reason | Event description | Application action |
|---|---|---|
stop |
Model completed its turn normally | Read and process message.content |
length |
Generation reached maximum token limit | Reject output as incomplete; do not consume as valid text |
tool_calls |
Model requested tool execution | Execute requested tools, append outputs, and submit next request |
A common implementation error checks for tool calls and falls back to content:
def reply_for(completion):
message = completion.choices[0].message
if message.tool_calls:
return run_tools(message.tool_calls)
return message.content
This function produces silent bugs. When finish_reason is length, the model stopped mid-generation. Because message.tool_calls is None, the function falls through and returns partial text as a complete answer.
An explicit dispatch branches on known finish_reason values:
def reply_for(completion):
choice = completion.choices[0]
if choice.finish_reason == "tool_calls":
return run_tools(choice.message.tool_calls)
if choice.finish_reason == "stop":
return choice.message.content or ""
raise IncompleteReply(choice.finish_reason)
This pattern handles unhandled conditions, such as length or content_filter, by raising an exception rather than returning corrupted state.
run_tools executes requested functions and appends outputs to the dialogue history for the next iteration. Orchestrating this cycle in a loop forms the ReAct loop.
Branching on finish_reason provides structural reliability. The inference runtime sets finish_reason when decoding halts. The model cannot alter this metadata, bypass it via prompt injection, or omit it in text output.
Truncation and the length reason
A finish_reason of length indicates token generation halted due to hitting a token ceiling. Two distinct limits trigger truncation:
- The request parameter limit (such as
max_tokensormax_completion_tokens). - The model context window limit, which is shared between prompt tokens and generated output tokens.
When an ongoing conversation expands, available output headroom decreases. A prompt that succeeds during early turns can truncate in later turns when conversation history consumes the shared window.
Truncated text does not trigger an API error. The response delivers standard text that terminates abruptly:
response = client.chat.completions.create(model="mock", messages=messages)
answer = response.choices[0].message.content # appears valid
store(answer) # writes incomplete state to storage
If your application expects JSON, truncated outputs fail parsing. Inspecting finish_reason before JSON deserialization allows the caller to handle the failure explicitly: expanding token budgets, trimming context history, or dividing the workload into smaller tasks. See structured output for retry patterns.
Regardless of whether output was truncated, providers bill for all generated completion tokens.
Tool call payload structure
When the model requests tool execution, content is None and tool_calls contains a list of tool call objects:
Message(
role="assistant",
content=None,
tool_calls=[
ToolCall(id="call_1_0", function=Function(name="get_hours", arguments='{"day": "monday"}')),
],
)
On standard text turns, tool_calls is None rather than an empty list. Code that iterates over message.tool_calls without checking for None will raise a TypeError.
The arguments property is a serialized JSON string. Because arguments are generated tokens, malformed JSON can occur. Defensive systems parse arguments within a try/except block or use structured decoding modes where supported.
When building conversation history, append the exact assistant message received from the API before appending tool results. Provider APIs validate that every tool result message includes a tool_call_id matching an earlier assistant tool_calls entry in the request array.
Token usage and dialogue expansion
prompt_tokens measures the size of the entire input array for that request. completion_tokens measures only the tokens generated during that specific call.
In multi-turn chat applications, token consumption grows quadratically across turns:
turn 1 prompt 7 completion 3 paid 10
turn 2 prompt 21 completion 4 paid 25
turn 3 prompt 38 completion 9 paid 47
--------
paid 82
Because the model retains no internal state between calls, each turn resends the entire preceding dialogue. In this three-turn exchange, the final conversation length is 47 tokens, but the application paid for 82 total tokens.
Two cost dynamics apply:
- Completion tokens carry higher per-unit pricing than prompt tokens because generation requires sequential, token-by-token decoding passes.
- Prompt caching provides discounts for repeated prompt prefixes without reducing context window consumption. For details on context mechanics and budgeting arithmetic, see tokens and context.
In mockllm, usage counts are computed via standard multipliers to simulate context growth.
Exercise specification
Implement seven pure helper functions for processing Completion objects:
finish_reason: extractchoices[0].finish_reason.stopped_early: returnTrueiffinish_reasonis not"stop".answer_text: extractchoices[0].message.content, raisingIncompleteReply(finish_reason)whenfinish_reason != "stop".tool_names: extract a list of function names frommessage.tool_callsin order.total_tokens: return total token count for a completion.usage_totals: aggregate prompt, completion, and total tokens across a sequence of completions.describe: produce formatted summary strings across four finish conditions:"stop","tool_calls","length", and unknown reasons.
Ensure your implementation safely handles None values in content and tool_calls.
Reading a completion
The object every model call hands back, and why finish_reason rather than the text is the thing your code branches on. Truncation, tool call turns, and what token usage counts.
25 minute read
Implement your solution in the editor on the right or work in your local IDE. All unit tests run against your code in real-time.
Exercise
reading-a-completion
22 tests, graded on your machine in about a second. Stuck for 20 minutes? The desktop app will offer you the reference solution.