Skip to content

feat: Support reasoning_effort for Mistral thinking models beyond magistral* #5285

Description

@YHallouard

Description

Problem

With the releases of Mistral Medium 3.5 and Mistral Small 4, Mistral now has non-magistral models that support thinking via the reasoning_effort API parameter. The current pydantic-ai Mistral integration has two gaps:

1. reasoning_effort is never forwarded to the Mistral API

When model_request_parameters.thinking is set, the Mistral completion methods (_completions_create / _stream_completions_create) don't pass the corresponding reasoning_effort parameter to the Mistral client. The Mistral API supports reasoning_effort (values: "high" or "none") to control chain-of-thought behavior, but currently there's no mapping from pydantic-ai's unified thinking setting.

2. Model profile only recognizes magistral* as thinking-capable

In pydantic_ai_slim/pydantic_ai/profiles/mistral.py:

def mistral_model_profile(model_name: str) -> ModelProfile | None:
    is_magistral = model_name.startswith('magistral')
    if is_magistral:
        return ModelProfile(supports_thinking=True, thinking_always_enabled=True)
    return None

This misses two models that support thinking via reasoning_effort (opt-in, not always-on like magistral):

  • mistral-medium-3-5 — Mistral Medium 3.5
  • mistral-small-latest — Mistral Small 4 (v26.03), which also exposes configurable reasoning_effort

Unlike magistral models, thinking is not always enabled for these models — it's controlled explicitly by the reasoning_effort parameter.

Additionally, the alias mistral-medium-latest creates ambiguity: it currently resolves to mistral-medium-3-5 on the public API, but on private/self-hosted Mistral deployments it may point to an older version (e.g. mistral-medium-2505) that does not support thinking. The same concern applies to mistral-small-latest on private servers. The profile logic cannot assume these -latest aliases always support thinking.

Proposed Solution

In pydantic_ai_slim/pydantic_ai/profiles/mistral.py:

Add mistral-medium-3-5 and mistral-small-2603 (the versioned name behind mistral-small-latest) as thinking-capable models, with thinking_always_enabled=False since reasoning is opt-in via reasoning_effort:

_ADJUSTABLE_REASONING_MODELS = {'mistral-medium-3-5', 'mistral-small-2603'}

def mistral_model_profile(model_name: str) -> ModelProfile | None:
    is_magistral = model_name.startswith('magistral')
    if is_magistral:
        return ModelProfile(supports_thinking=True, thinking_always_enabled=True)
    if model_name in _ADJUSTABLE_REASONING_MODELS:
        return ModelProfile(supports_thinking=True, thinking_always_enabled=False)
    return None

In pydantic_ai_slim/pydantic_ai/models/mistral.py:

Map the unified thinking parameter to Mistral's reasoning_effort in both _completions_create and _stream_completions_create. When model_request_parameters.thinking is truthy, pass reasoning_effort="high" to self.client.chat.complete_async() / self.client.chat.stream_async().

For example in _completions_create:

async def _completions_create(
    self,
    messages: list[ModelMessage],
    model_settings: MistralModelSettings,
    model_request_parameters: ModelRequestParameters,
) -> MistralChatCompletionResponse:
    reasoning_effort = self._resolve_reasoning_effort(model_request_parameters)

    with _map_api_errors(self.model_name):
        response = await self.client.chat.complete_async(
            model=str(self._model_name),
            messages=await self._map_messages(messages, model_request_parameters),
            # ... existing params ...
            reasoning_effort=reasoning_effort if reasoning_effort else UNSET,
            http_headers={'User-Agent': get_user_agent()},
        )
    assert response, 'An unexpected empty response from Mistral.'
    return response

With a helper to map pydantic-ai's thinking levels to Mistral's binary reasoning_effort:

_THINKING_TO_REASONING_EFFORT: dict[str, str] = {
    'high': 'high',
    'xhigh': 'high',
    'medium': 'high',
    'low': 'none',
    'minimal': 'none',
}

def _resolve_reasoning_effort(self, model_request_parameters: ModelRequestParameters) -> str | None:
    thinking = model_request_parameters.thinking
    if thinking is None or thinking is False:
        return None
    if thinking is True:
        return 'high'
    return self._THINKING_TO_REASONING_EFFORT.get(thinking, 'high')

Key Considerations

  • magistral* models: thinking_always_enabled=True — no change needed, they always reason.
  • mistral-medium-3-5 and mistral-small-2603: thinking_always_enabled=False — thinking is opt-in via reasoning_effort.
  • mistral-medium-latest and mistral-small-latest: Should we add them to _ADJUSTABLE_REASONING_MODELS, ? On private/self-hosted Mistral servers, these aliases may resolve to older non-thinking versions.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    featureNew feature request, or PR implementing a feature (enhancement)mistral

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions