In Mistral's chat completions response, which fields carry a tool call?
answer
- look at choices[0], not the text
- one flag plus one list
- the payload is a string, not an object
- id, function.name, function.arguments
- finish_reason tool_calls
basics
~10 sMistral sets choices[0].finish_reason to "tool_calls" and fills choices[0].message.tool_calls. Each entry has an id, a function.name, and function.arguments — a JSON-encoded string you must parse before running your code.
solid answer
~40 sMistral's chat completions response is OpenAI-shaped, so the tool call lives in `choices[0]`. Check `finish_reason`: `"stop"` means a normal text answer, `"tool_calls"` means the model wants your code run. The assistant message then carries `message.tool_calls`, a list where each element has `id` (server-generated), `type: "function"`, and a `function` object with `name` and `arguments`. On the wire `arguments` is a **JSON string**, not an object — `json.loads` it, inside a try/except, because a model can emit malformed JSON. To continue, append the assistant message unchanged, then one message per call with `role: "tool"`, the string `content`, and `tool_call_id` set to the `id` you were given. Never invent that id yourself.
code
python · 26 linesimport json, os
from mistralai import Mistral
client = Mistral(api_key=os.environ["MISTRAL_API_KEY"])
tools = [{
"type": "function",
"function": {
"name": "get_stock",
"description": "Get stock on hand for a SKU",
"parameters": {
"type": "object",
"properties": {"sku": {"type": "string"}},
"required": ["sku"],
},
},
}]
messages = [{"role": "user", "content": "How many of SKU A-17 are left?"}]
resp = client.chat.complete(model="mistral-large-latest", messages=messages, tools=tools)
choice = resp.choices[0]
if choice.finish_reason == "tool_calls":
for call in choice.message.tool_calls:
args = json.loads(call.function.arguments)
print(call.id, call.function.name, args)go deeper
Be able to name the three fields — id, function.name, function.arguments — and say out loud that arguments is a JSON string that needs parsing. Mention that finish_reason is "tool_calls" on that turn.
Explain the round trip: branch on the flag, parse arguments defensively, re-send the assistant message unchanged, then one role "tool" message per call with the echoed tool_call_id and a string content.
Show the defensive posture a production handler needs: guarded JSON parsing, registry-based dispatch, malformed calls fed back as tool errors rather than exceptions, and a bound on how many correction rounds you allow.
Frame the field shape as a portability question — Mistral matches the OpenAI wire layout, so one adapter can serve both, and the interesting work is deciding where that adapter normalizes ids, argument parsing and result serialization across vendors whose shapes do not match.
## What changes when you attach tools When you send a `tools` array on `POST /v1/chat/completions` to Mistral's la Plateforme, you tell the model it may answer not with prose but with a *request to run your code*. The endpoint, the SDK method (`client.chat.complete(...)` in the `mistralai` Python SDK) and the request envelope are unchanged. What changes is the shape of the reply, and reading that reply correctly is the whole skill here. ## The response envelope Mistral returns an OpenAI-shaped object: top-level `id`, `model`, `usage`, and a `choices` array. Everything you need sits in `choices[0]`: - **`choices[0].finish_reason`** — `"stop"` for a normal completed text answer, `"tool_calls"` when the model wants one or more tools executed, `"length"` when generation hit the token cap. - **`choices[0].message`** — the assistant message. On a tool turn its `content` is typically empty or a short preamble; the payload lives in `message.tool_calls`. Branching on `finish_reason` (or, equivalently, on whether `message.tool_calls` is non-empty) is the fork in your loop: text answer means you are done with this turn; tool call means you execute and come back around. ## The three fields on a tool call `message.tool_calls` is a **list** — plan for more than one entry even if your first test only ever produces one. Each element has: - **`id`** — a server-generated handle for this specific invocation. It is how Mistral pairs your eventual result with the call. Echo it back byte-for-byte; do not generate your own, and do not reuse one across turns. - **`function.name`** — the name of one of the tools you declared. The model can only choose from names in your `tools` array, but dispatch through a lookup table with a graceful miss rather than an unguarded `dict[...]`, so an unexpected name becomes an error message to the model instead of a crash in your process. - **`function.arguments`** — the arguments the model chose, **serialized as a JSON string**. This is the single most common junior mistake: treating it as a ready-made dictionary. Parse it (`json.loads`) and wrap the parse in error handling — nothing guarantees a syntactically valid object, especially with a small model or a deeply nested schema. ## Sending the result back The continuation request re-sends the whole conversation with two additions: 1. The **assistant message exactly as returned**, `tool_calls` included. This is not optional bookkeeping — it is the anchor the ids refer to. 2. One message per call with `role: "tool"`, `tool_call_id` set to the id from the response, `name` set to the function name, and `content` as a **string**. If your function returns a dict or a dataclass, serialize it (`json.dumps`) before assigning it to `content`. The model then reads the tool output as context and either answers in prose (`finish_reason: "stop"`) or asks for another tool. ## finish_reason versus truthiness Both `finish_reason == "tool_calls"` and `if message.tool_calls:` work as the branch condition. The truthiness check is slightly more robust because it does not depend on the exact reason string, and it naturally handles a message that carries both a short text preamble and calls. Whichever you choose, do not parse `message.content` for a function name — the structured field is the contract, prose is not. ## Streaming Under a streamed request the same fields arrive as incremental deltas, with `arguments` accumulating fragment by fragment across chunks; you concatenate the fragments and only then parse. The accumulation rules are a streaming concern rather than a field-shape one, but the end state is identical to the non-streamed response described above. ## Mistakes that cost interview points - Assuming `arguments` is already a Python dict, then crashing on `args["sku"]` when it is a string. - Synthesizing a `tool_call_id` instead of echoing the one you were given — the request then fails validation. - Returning the result as a `user` or `assistant` message rather than `role: "tool"`. - Passing a dict as `content` instead of a serialized string. - Handling only `tool_calls[0]` and silently dropping any additional calls in the list. - Ignoring `finish_reason` and rendering the empty `content` to the user as "the model said nothing".
- What do you do when json.loads on function.arguments raises?Do not crash the process and do not silently drop the call. Catch the error and reply with a `role: "tool"` message whose `tool_call_id` matches the call and whose `content` says the arguments were not valid JSON, naming the expected shape. The model then usually re-emits a corrected call. Bound this with a retry counter so a persistently malformed call terminates instead of looping.
- The assistant message has both non-empty content and tool_calls. What do you do with the content?Keep it. It is a legitimate preamble the model wrote alongside the call, and you must re-send that assistant message verbatim — content and `tool_calls` together — as the anchor for your tool results. Whether you surface the preamble to the user is a product decision; stripping it from the history you send back is not, because the history must match what the model produced.
- Why should you validate function.name against your own registry when the model can only pick declared names?Because the guarantee is about training and prompt construction, not about a hard API-side constraint, and because names drift as you edit the `tools` array. A registry lookup with an explicit miss path turns a surprising name into a tool-result error the model can recover from, rather than a KeyError that takes down the request handler.
saying these in an interview costs you the question
- Says function.arguments arrives as a ready-to-use dictionary
- Generates a fresh tool_call_id instead of echoing the response's id
- Returns tool output as a user or assistant message
- Assumes tool_calls always contains exactly one entry
- Passes a dict as the tool message content without serializing