feat(providers): first-class Mistral support

Mistral's API constrains reasoning_effort to "high"/"none", rejects the
kwarg entirely for Magistral (reasoning is implicit), returns assistant
content as a mixed array of {type:"thinking",...}/{type:"text",...}
blocks, and 400s on the reasoning_content key in history.

- Remap user-supplied reasoning_effort (low/medium/minimal) onto Mistral's
  two-tier vocabulary; strip the kwarg for Magistral models
- Lift thinking blocks into reasoning_content for both batch and streaming
  responses; pass only text through on_content_delta callbacks
- Drop reasoning_content from outbound history when the spec asks for it
- Expose per-preset reasoning_effort_values so the UI can render the
  provider-specific option set

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Javad Arjmandi
2026-06-18 00:08:47 +08:00
committed by Xubin Ren
co-authored by Claude Sonnet 4.6
parent 51bd3337ef
commit d5f5eb43e5
5 changed files with 516 additions and 11 deletions
+95 -5
View File
@@ -535,6 +535,13 @@ class OpenAICompatProvider(LLMProvider):
pending_tool_ids: dict[str, deque[str]] = {}
force_string_content = bool(self._spec and self._spec.name == "deepseek")
normalize_tool_ids = self._should_normalize_tool_call_ids()
strip_reasoning = bool(
self._spec
and getattr(self._spec, "strip_history_reasoning_content", False)
)
if strip_reasoning:
for msg in sanitized:
msg.pop("reasoning_content", None)
def map_id(value: Any) -> Any:
if not isinstance(value, str):
@@ -704,7 +711,34 @@ class OpenAICompatProvider(LLMProvider):
# DashScope accepts none/minimum/low/medium/high/xhigh; "minimal" 400s.
wire_effort = "minimum"
if wire_effort and semantic_effort != "none":
# Magistral and other providers where reasoning is implicit reject the
# reasoning_effort kwarg entirely. Strip it before the remap so we don't
# accidentally send "none"/"high" to a model that always reasons.
strip_effort = False
if spec and getattr(spec, "implicit_reasoning_models", ()):
model_lower = model_name.lower()
strip_effort = any(
pat in model_lower for pat in spec.implicit_reasoning_models
)
# Some providers accept a constrained reasoning_effort vocabulary
# (Mistral: only "high"/"none"). Remap from OpenAI vocab to the
# provider's accepted set; an empty mapped value means "omit".
if (
not strip_effort
and spec
and getattr(spec, "reasoning_effort_remap", ())
and isinstance(semantic_effort, str)
):
remap = dict(spec.reasoning_effort_remap)
mapped = remap.get(semantic_effort)
if mapped is not None:
wire_effort = mapped or None
semantic_effort = mapped or "none"
if strip_effort:
wire_effort = None
elif wire_effort and semantic_effort != "none":
kwargs["reasoning_effort"] = wire_effort
# Only send thinking controls when reasoning_effort is explicit so
@@ -931,6 +965,10 @@ class OpenAICompatProvider(LLMProvider):
for item in value:
item_map = cls._maybe_mapping(item)
if item_map:
# Skip Mistral-style {"type":"thinking","thinking":[...]}
# blocks: their text belongs in reasoning_content.
if item_map.get("type") == "thinking":
continue
text = item_map.get("text")
if isinstance(text, str):
parts.append(text)
@@ -944,6 +982,31 @@ class OpenAICompatProvider(LLMProvider):
return "".join(parts) or None
return str(value)
@classmethod
def _extract_thinking_content(cls, value: Any) -> str | None:
"""Extract reasoning text from Mistral-style thinking blocks.
Mistral returns content as a list mixing
``{"type":"thinking","thinking":[{"type":"text","text":...}]}`` and
``{"type":"text","text":...}``. The thinking text belongs in
``reasoning_content`` so the agent can surface it as a reasoning
trace rather than as the assistant's reply.
"""
if not isinstance(value, list):
return None
parts: list[str] = []
for item in value:
item_map = cls._maybe_mapping(item)
if not item_map:
continue
if item_map.get("type") != "thinking":
continue
inner = item_map.get("thinking")
text = cls._extract_text_content(inner)
if text:
parts.append(text)
return "".join(parts) or None
@classmethod
def _extract_usage(cls, response: Any) -> dict[str, int]:
"""Extract token usage from an OpenAI-compatible response.
@@ -1045,6 +1108,12 @@ class OpenAICompatProvider(LLMProvider):
reasoning_content = msg0.get("reasoning_content")
if reasoning_content is None and msg0.get("reasoning"):
reasoning_content = self._extract_text_content(msg0.get("reasoning"))
# Mistral reasoning models return thinking text inside the content
# array; lift it into reasoning_content so the runner records it
# under the reasoning trace.
spec = getattr(self, "_spec", None)
if reasoning_content is None and getattr(spec, "extract_thinking_blocks", False):
reasoning_content = self._extract_thinking_content(msg0.get("content"))
for ch in choices:
ch_map = self._maybe_mapping(ch) or {}
m = self._maybe_mapping(ch_map.get("message")) or {}
@@ -1195,12 +1264,17 @@ class OpenAICompatProvider(LLMProvider):
if choice.get("finish_reason"):
finish_reason = str(choice["finish_reason"])
delta = cls._maybe_mapping(choice.get("delta")) or {}
text = cls._extract_text_content(delta.get("content"))
raw_delta_content = delta.get("content")
text = cls._extract_text_content(raw_delta_content)
if text:
content_parts.append(text)
text = cls._extract_text_content(delta.get("reasoning_content"))
if not text:
text = cls._extract_text_content(delta.get("reasoning"))
if not text:
# Mistral streams thinking inside the content array as
# {"type":"thinking", thinking:[{"type":"text", ...}]}.
text = cls._extract_thinking_content(raw_delta_content)
if text:
reasoning_parts.append(text)
for idx, tc in enumerate(delta.get("tool_calls") or []):
@@ -1217,13 +1291,20 @@ class OpenAICompatProvider(LLMProvider):
finish_reason = choice.finish_reason
delta = choice.delta
if delta and delta.content:
content_parts.append(delta.content)
text = cls._extract_text_content(delta.content)
if text:
content_parts.append(text)
thinking_text = cls._extract_thinking_content(delta.content)
if thinking_text:
reasoning_parts.append(thinking_text)
if delta:
reasoning = getattr(delta, "reasoning_content", None)
if not reasoning:
reasoning = getattr(delta, "reasoning", None)
if reasoning:
reasoning_parts.append(reasoning)
text = cls._extract_text_content(reasoning)
if text:
reasoning_parts.append(text)
for tc in (getattr(delta, "tool_calls", None) or []) if delta else []:
_accum_tc(tc, getattr(tc, "index", 0))
if delta:
@@ -1476,8 +1557,13 @@ class OpenAICompatProvider(LLMProvider):
chunks.append(chunk)
if chunk.choices:
delta_obj = chunk.choices[0].delta
raw_delta_content = getattr(delta_obj, "content", None)
if on_content_delta:
text = getattr(delta_obj, "content", None)
# Mistral streams content as a list of {"type":"thinking",
# ...} + {"type":"text",...} blocks. Extract just the
# text portion before invoking the callback so callers
# never see non-string content.
text = self._extract_text_content(raw_delta_content)
if text:
await on_content_delta(text)
if on_thinking_delta:
@@ -1485,6 +1571,10 @@ class OpenAICompatProvider(LLMProvider):
delta_obj, "reasoning", None,
)
r_text = self._extract_text_content(reasoning)
if not r_text:
# Mistral keeps the thinking trace inside the
# content array rather than a separate field.
r_text = self._extract_thinking_content(raw_delta_content)
if r_text:
await on_thinking_delta(r_text)
if on_tool_call_delta:
+41 -2
View File
@@ -85,6 +85,29 @@ class ProviderSpec:
# whose API returns the actual answer in "reasoning" instead of "content".
reasoning_as_content: bool = False
# Map user-supplied reasoning_effort (OpenAI vocab: minimal/low/medium/high)
# to the value this provider accepts on the wire. Set when the provider's
# accepted set differs from OpenAI's. An empty mapped value omits the kwarg.
# Mistral: only "high"/"none" — low/minimal map to "none", medium maps to "high".
reasoning_effort_remap: tuple[tuple[str, str], ...] = ()
# Models whose API rejects the reasoning_effort kwarg because reasoning is
# implicit (Magistral always reasons; sending the kwarg returns HTTP 400).
# Substring match against the wire model name (lowercased).
implicit_reasoning_models: tuple[str, ...] = ()
# When the model returns content as a list of {"type":"thinking",...} +
# {"type":"text",...} blocks, extract the thinking text into
# reasoning_content. Mistral's Magistral / reasoning-enabled responses use
# this shape.
extract_thinking_blocks: bool = False
# Strip ``reasoning_content`` from assistant history messages before
# sending. Mistral validates its request schema strictly and 400s on
# any extra fields; other providers (DeepSeek) require this key on the
# wire to keep thinking-mode history intact.
strip_history_reasoning_content: bool = False
@property
def label(self) -> str:
return self.display_name or self.name.title()
@@ -387,14 +410,30 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
backend="anthropic",
default_api_base="https://api.minimax.io/anthropic",
),
# Mistral AI: OpenAI-compatible API
# Mistral AI: OpenAI-compatible API.
# Reasoning quirks:
# * mistral-medium-3-5 / mistral-vibe-cli-* accept reasoning_effort but
# only "high" or "none" — low/medium/minimal must be remapped.
# * Magistral-* models reason implicitly and reject the kwarg entirely.
# * Reasoning responses return content as a list of thinking + text
# blocks; thinking text gets extracted into reasoning_content.
ProviderSpec(
name="mistral",
keywords=("mistral",),
keywords=("mistral", "magistral", "ministral", "codestral", "devstral"),
env_key="MISTRAL_API_KEY",
display_name="Mistral",
backend="openai_compat",
default_api_base="https://api.mistral.ai/v1",
reasoning_effort_remap=(
("minimal", "none"),
("low", "none"),
("medium", "high"),
("high", "high"),
("none", "none"),
),
implicit_reasoning_models=("magistral",),
extract_thinking_blocks=True,
strip_history_reasoning_content=True,
),
# Step Fun (阶跃星辰): OpenAI-compatible API
ProviderSpec(
+40
View File
@@ -654,6 +654,40 @@ def _image_generation_provider_rows(config: Any) -> list[dict[str, Any]]:
return rows
_DEFAULT_REASONING_EFFORT_VALUES: tuple[str, ...] = ("", "low", "medium", "high")
def _reasoning_effort_values_for(provider_name: str, model: str) -> list[str]:
"""Return user-facing reasoning_effort options for this provider+model.
Mistral chat models accept only "high"/"none"; Magistral rejects the
kwarg entirely (reasoning is implicit). For everyone else, return the
full OpenAI vocab.
"""
spec = find_by_name(provider_name) if provider_name else None
if spec is None:
return list(_DEFAULT_REASONING_EFFORT_VALUES)
model_lower = (model or "").lower()
implicit = getattr(spec, "implicit_reasoning_models", ())
if implicit and any(pat in model_lower for pat in implicit):
# Reasoning is always on; only "Default" makes sense.
return [""]
remap = getattr(spec, "reasoning_effort_remap", ())
if remap:
# Reverse the remap: surface the distinct wire-vocab outputs as the
# user's options. Mistral collapses to "high"/"none" → UI shows
# "Default" + "High".
wire_values: list[str] = []
for _user_val, wire_val in remap:
if wire_val and wire_val != "none" and wire_val not in wire_values:
wire_values.append(wire_val)
return ["", *wire_values]
return list(_DEFAULT_REASONING_EFFORT_VALUES)
def _transcription_provider_rows(config: Any) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for name in transcription_provider_names():
@@ -741,6 +775,9 @@ def settings_payload(
"context_window_tokens": defaults.context_window_tokens,
"temperature": defaults.temperature,
"reasoning_effort": defaults.reasoning_effort,
"reasoning_effort_values": _reasoning_effort_values_for(
defaults.provider, defaults.model
),
}
]
for name, preset in config.model_presets.items():
@@ -756,6 +793,9 @@ def settings_payload(
"context_window_tokens": preset.context_window_tokens,
"temperature": preset.temperature,
"reasoning_effort": preset.reasoning_effort,
"reasoning_effort_values": _reasoning_effort_values_for(
preset.provider, preset.model
),
}
)
+339 -4
View File
@@ -1,16 +1,32 @@
"""Tests for the Mistral provider registration."""
"""Tests for the Mistral provider registration and reasoning quirks."""
from __future__ import annotations
from unittest.mock import patch
from nanobot.config.schema import ProvidersConfig
from nanobot.providers.registry import PROVIDERS
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
from nanobot.providers.registry import PROVIDERS, find_by_name
def test_mistral_config_field_exists():
def _mistral_provider(default_model: str = "mistral-medium-3-5") -> OpenAICompatProvider:
spec = find_by_name("mistral")
assert spec is not None
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
return OpenAICompatProvider(
api_key="test-key",
default_model=default_model,
spec=spec,
)
def test_mistral_config_field_exists() -> None:
"""ProvidersConfig should have a mistral field."""
config = ProvidersConfig()
assert hasattr(config, "mistral")
def test_mistral_provider_in_registry():
def test_mistral_provider_in_registry() -> None:
"""Mistral should be registered in the provider registry."""
specs = {s.name: s for s in PROVIDERS}
assert "mistral" in specs
@@ -18,3 +34,322 @@ def test_mistral_provider_in_registry():
mistral = specs["mistral"]
assert mistral.env_key == "MISTRAL_API_KEY"
assert mistral.default_api_base == "https://api.mistral.ai/v1"
def test_mistral_keyword_match_covers_model_families() -> None:
"""Codestral, Devstral, Ministral, Magistral models route to the Mistral spec."""
from nanobot.config.schema import Config
for model in (
"mistral-large-latest",
"magistral-medium-latest",
"ministral-8b-latest",
"codestral-latest",
"devstral-medium-latest",
):
config = Config.model_validate({
"providers": {"mistral": {"apiKey": "test-key"}},
"agents": {"defaults": {"model": model}},
})
assert config.get_provider_name(model) == "mistral", model
def test_reasoning_effort_low_remaps_to_none_omitted() -> None:
"""Mistral rejects low/medium efforts: low should map to "none" (omitted)."""
p = _mistral_provider()
kwargs = p._build_kwargs(
messages=[{"role": "user", "content": "hi"}],
tools=None,
model="mistral-medium-3-5",
max_tokens=64,
temperature=0.5,
reasoning_effort="low",
tool_choice=None,
)
assert "reasoning_effort" not in kwargs
def test_reasoning_effort_minimal_remaps_to_none_omitted() -> None:
p = _mistral_provider()
kwargs = p._build_kwargs(
messages=[{"role": "user", "content": "hi"}],
tools=None,
model="mistral-vibe-cli-latest",
max_tokens=64,
temperature=0.5,
reasoning_effort="minimal",
tool_choice=None,
)
assert "reasoning_effort" not in kwargs
def test_reasoning_effort_medium_remaps_to_high() -> None:
"""Mistral has no 'medium' tier: bump up to 'high'."""
p = _mistral_provider()
kwargs = p._build_kwargs(
messages=[{"role": "user", "content": "hi"}],
tools=None,
model="mistral-medium-3-5",
max_tokens=64,
temperature=0.5,
reasoning_effort="medium",
tool_choice=None,
)
assert kwargs["reasoning_effort"] == "high"
def test_reasoning_effort_high_passes_through() -> None:
p = _mistral_provider()
kwargs = p._build_kwargs(
messages=[{"role": "user", "content": "hi"}],
tools=None,
model="mistral-medium-3-5",
max_tokens=64,
temperature=0.5,
reasoning_effort="high",
tool_choice=None,
)
assert kwargs["reasoning_effort"] == "high"
def test_magistral_strips_reasoning_effort() -> None:
"""Magistral reasons implicitly; API rejects reasoning_effort kwarg."""
p = _mistral_provider()
for effort in ("low", "medium", "high", "minimal"):
kwargs = p._build_kwargs(
messages=[{"role": "user", "content": "hi"}],
tools=None,
model="magistral-medium-latest",
max_tokens=64,
temperature=0.5,
reasoning_effort=effort,
tool_choice=None,
)
assert "reasoning_effort" not in kwargs, effort
def test_extract_thinking_content_from_mistral_response() -> None:
"""Thinking blocks should land in reasoning_content, not content."""
p = _mistral_provider()
response = {
"choices": [
{
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": [
{
"type": "thinking",
"thinking": [
{"type": "text", "text": "Let me think..."}
],
"closed": True,
},
{"type": "text", "text": "Final answer is 35."},
],
},
}
],
"usage": {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30},
}
parsed = p._parse(response)
assert parsed.content == "Final answer is 35."
assert parsed.reasoning_content == "Let me think..."
def test_extract_thinking_content_with_tool_calls() -> None:
"""A response with thinking + tool_calls (no text content) should still parse."""
p = _mistral_provider()
response = {
"choices": [
{
"finish_reason": "tool_calls",
"message": {
"role": "assistant",
"tool_calls": [
{
"id": "abc123def",
"type": "function",
"function": {
"name": "get_weather",
"arguments": '{"city": "Paris"}',
},
}
],
"content": [
{
"type": "thinking",
"thinking": [
{"type": "text", "text": "I should call get_weather."}
],
}
],
},
}
],
}
parsed = p._parse(response)
assert parsed.content in (None, "")
assert parsed.reasoning_content == "I should call get_weather."
assert len(parsed.tool_calls) == 1
assert parsed.tool_calls[0].name == "get_weather"
assert parsed.tool_calls[0].arguments == {"city": "Paris"}
def test_thinking_content_only_for_mistral_spec() -> None:
"""Providers without extract_thinking_blocks should not lift thinking text."""
other_spec = find_by_name("openai")
assert other_spec is not None
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
p = OpenAICompatProvider(
api_key="test-key",
default_model="gpt-4o",
spec=other_spec,
)
response = {
"choices": [
{
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": [
{
"type": "thinking",
"thinking": [{"type": "text", "text": "secret"}],
},
{"type": "text", "text": "hi"},
],
},
}
],
}
parsed = p._parse(response)
assert parsed.content == "hi"
assert parsed.reasoning_content is None
def test_streaming_thinking_chunks_become_reasoning() -> None:
"""Streamed thinking deltas (Mistral shape) feed reasoning_content."""
chunks = [
{
"choices": [
{
"delta": {
"content": [
{
"type": "thinking",
"thinking": [{"type": "text", "text": "step 1 "}],
}
]
},
"finish_reason": None,
}
]
},
{
"choices": [
{
"delta": {
"content": [
{
"type": "thinking",
"thinking": [{"type": "text", "text": "step 2"}],
}
]
},
"finish_reason": None,
}
]
},
{
"choices": [
{
"delta": {"content": "final"},
"finish_reason": "stop",
}
]
},
]
parsed = OpenAICompatProvider._parse_chunks(chunks)
assert parsed.content == "final"
assert parsed.reasoning_content == "step 1 step 2"
def test_streaming_thinking_chunks_via_sdk_path() -> None:
"""The SDK-style branch must coerce list-shaped delta.content to text.
Regression: Mistral's vibe-cli/medium-3-5 streamed delta.content as a
list, which was previously appended verbatim to content_parts and blew
up later with "can only concatenate str (not list) to str".
"""
class _Delta:
def __init__(self, content):
self.content = content
self.tool_calls = None
self.function_call = None
class _Choice:
def __init__(self, content, finish=None):
self.delta = _Delta(content)
self.finish_reason = finish
class _Chunk:
def __init__(self, content, finish=None):
self.choices = [_Choice(content, finish)]
chunks = [
_Chunk([{"type": "thinking", "thinking": [{"type": "text", "text": "ponder"}]}]),
_Chunk([{"type": "text", "text": "Hello "}]),
_Chunk("world.", finish="stop"),
]
parsed = OpenAICompatProvider._parse_chunks(chunks)
assert parsed.content == "Hello world."
assert parsed.reasoning_content == "ponder"
def test_mistral_strips_reasoning_content_from_history() -> None:
"""Mistral's request schema 400s on reasoning_content; it must be dropped."""
p = _mistral_provider()
messages = [
{"role": "user", "content": "hi"},
{
"role": "assistant",
"content": "Hello!",
"reasoning_content": "internal thoughts",
},
{"role": "user", "content": "follow up"},
]
sanitized = p._sanitize_messages(messages)
assert all("reasoning_content" not in msg for msg in sanitized)
assert sanitized[1]["content"] == "Hello!"
def test_mistral_tool_call_ids_get_normalized() -> None:
"""Non-9-char tool_call IDs should be hashed to 9-char alphanumeric."""
p = _mistral_provider()
messages = [
{"role": "user", "content": "hi"},
{
"role": "assistant",
"tool_calls": [
{
"id": "call_abc_xyz_too_long_for_mistral",
"type": "function",
"function": {"name": "x", "arguments": "{}"},
}
],
},
{
"role": "tool",
"tool_call_id": "call_abc_xyz_too_long_for_mistral",
"content": "ok",
},
]
sanitized = p._sanitize_messages(messages)
assistant_id = sanitized[1]["tool_calls"][0]["id"]
tool_id = sanitized[2]["tool_call_id"]
assert len(assistant_id) == 9
assert assistant_id.isalnum()
assert assistant_id == tool_id
+1
View File
@@ -372,6 +372,7 @@ export interface SettingsPayload {
context_window_tokens: number;
temperature: number;
reasoning_effort: string | null;
reasoning_effort_values?: string[];
}>;
providers: Array<{
name: string;