Skip to content

[BUG MODEL] mistral-large-4 chat completions loses tool-call blocks server-side (finish_reason=tool_calls with none present); reasoning responses use a choices[].messages[] shape the SDK parses as message=None #652

Open

Description

@lu-zero

Upstream issue report (draft for mistralai/client-python)

Title: [BUG MODEL] mistral-large-4 chat completions loses tool-call blocks server-side (finish_reason=tool_calls with none present); reasoning responses use a choices[].messages[] shape the SDK parses as message=None


Environment

  • mistralai SDK 3.5.0 (Python 3.12), client.chat.complete_async
  • Model: mistral-large-4, chat completions endpoint, 2026-11-01
  • Request: system + user turn, 31 function tools (a real coding agent's toolset), parallel_tool_calls=True, prompt_cache_key set
  • Also reproduced through two independent agent harnesses (Mistral Vibe and a Rust client), so it is not client-specific

Bug 1 — tool calls are lost between generation and serialization

With a large tool set and thinking enabled, most turns answer with only a thinking block and a text block. Two flavours:

  1. finish_reason: "stop" right after a plan, while the thinking text shows the model intended to call tools next.
  2. Worse: finish_reason: "tool_calls" and no tool-call block anywhere in the response — the raw choice contains exactly one message with [thinking, text] content blocks and nothing else:
{
  "index": 0,
  "finish_reason": "tool_calls",
  "messages": [{
    "role": "assistant",
    "content": [
      {"thinking": [{"text": "The user wants me to demonstrate tool usage: 3 background tasks each with 3 ..."}], "type": "...", "closed": ...},
      {"text": "Here's what I'd use, and why:\n\n1. **`todo_write`** — track ...", "type": "..."}
    ]
  }]
}

The tool calls appear to be dropped between generation and serialization. Nothing client-side can recover them. Across ~20 replayed attempts the failure rate was ~60–100% for this prompt shape; it varies over the day (possibly backend routing — x-affinity/prompt_cache_key pin a session to a backend).

The same model + prompt over the Responses API (public preview) serializes function_call output items reliably (4/4 in our clean runs), which strongly suggests the loss is in the chat-completions serialization path for reasoning models.

This also matches the defensive machinery Vibe itself carries (adapters/_tool_call_finish.py: responses "can finish with tool calls without sending any", retried as UnusableToolCalls).

Bug 2 — the reasoning response shape parses as message=None

For a thinking model, the choice carries messages (plural) — a list of assistant messages with thinking/text content blocks — instead of the documented message. SDK 3.5.0 deserializes the choice to message=None, so even a successful text answer reaches SDK users as an empty completion:

answer.choices[0].message        # -> None
answer.choices[0].finish_reason  # -> "stop"

Either the SDK needs to parse the new shape, or the API should keep emitting message alongside messages.

Reproducer

# pip install mistralai==3.5.0 ; set MISTRAL_API_KEY
import asyncio, uuid
from mistralai.client import Mistral

client = Mistral(api_key=__import__("os").environ["MISTRAL_API_KEY"])
TOOLS = [...]  # a large toolset matters; we used 31 real agent tools
PROMPT = """I want you to run 3 background tasks each with 3 background monitors,
each of them counting from 1 to N with N 4, 10, 30, taking 1 second per increment."""

async def main():
    for i in range(5):
        a = await client.chat.complete_async(
            model="mistral-large-4",
            messages=[{"role": "user", "content": PROMPT}],
            tools=TOOLS, parallel_tool_calls=True,
            prompt_cache_key=str(uuid.uuid4()),
        )
        c = a.choices[0]
        calls = c.message.tool_calls if c.message else None
        print(i, c.finish_reason, "tool_calls:", len(calls) if calls else 0)

asyncio.run(main())

(Scripts used for the full evidence: system-prompt + 31-tool replay, and a raw block dumper that prints every block of every message in choices[0].messages.)

Expected

  1. finish_reason: "tool_calls" implies at least one tool-call block in the response.
  2. The SDK parses the API's own response (message present, thinking content either in a typed block or surfaced under content).

Side note

The Responses API endpoint returned 500 {"message": "Service unavailable.", "code": "3000"} intermittently during the same session — expected for a preview, just noting it.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions