skip to content

Function calling

Letting a Mistral model call your code. You declare tools as JSON Schema, the model replies with tool_calls, and you feed results back — the same contract as OpenAI's, with Mistral's own tool_choice modes.

on this pageshow

questions

4

In Mistral's chat completions response, which fields carry a tool call?

level: juniorimportance: must knowfreq 70%

answer

  1. look at choices[0], not the text
  2. one flag plus one list
  3. the payload is a string, not an object
  4. id, function.name, function.arguments
  5. finish_reason tool_calls

basics

~10 s

Mistral 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 s

Mistral'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 lines
python
import 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

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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

context

open as a page

Porting tool_choice "required" from OpenAI to Mistral — which value do you use?

level: middleimportance: must knowfreq 58%

basics

~20 s

Use "any". Mistral's documented tool_choice values are auto (the default when tools are present), any (the model must call a tool), and none (tools stay declared but unused); "required" is OpenAI's spelling for the any mode.

open as a page

With Mistral tool_choice "any" on every turn, why does your agent never finish?

level: seniorimportance: should knowfreq 32%

basics

~20 s

Because "any" forces a tool call on every request. The model can never return a plain text answer, so each round trip yields another call and the loop has no natural exit. Flip back to "auto" after the forced turn.

open as a page

Your Mistral tool loop 400s after tool_calls — what is wrong with the history?

level: seniorimportance: should knowfreq 44%

basics

~20 s

Almost always a broken pairing in the messages array: the assistant message carrying tool_calls was not re-sent verbatim, a tool_call_id does not match one the server issued, or some of the returned calls got no role "tool" reply at all.

open as a page