Skip to content
Atlas

Foundations

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


⚡
For the best experience: Download the native Atlas Desktop app for instant test running, native IDE sync, and 100% offline practice.
Download Desktop App→

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:

  • Completion represents the full response envelope for one API request, containing response metadata and choices.
  • choices is a list of candidate completions. While APIs support generating multiple completions via the parameter n, agent workflows typically request a single choice (choices[0]).
  • Choice pairs a generated Message with a finish_reason string indicating why token generation halted.
  • Message contains the role ("assistant"), the string content (or None), and an optional list of tool_calls (or None). Because the model is stateless, assistant messages returned by prior calls are re-sent as input history in subsequent requests.
  • usage contains token accounting across the call: prompt_tokens for the full input prefix, and completion_tokens for 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:

  1. The request parameter limit (such as max_tokens or max_completion_tokens).
  2. 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:

  1. Completion tokens carry higher per-unit pricing than prompt tokens because generation requires sequential, token-by-token decoding passes.
  2. 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:

  1. finish_reason: extract choices[0].finish_reason.
  2. stopped_early: return True if finish_reason is not "stop".
  3. answer_text: extract choices[0].message.content, raising IncompleteReply(finish_reason) when finish_reason != "stop".
  4. tool_names: extract a list of function names from message.tool_calls in order.
  5. total_tokens: return total token count for a completion.
  6. usage_totals: aggregate prompt, completion, and total tokens across a sequence of completions.
  7. 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.