From d5f5eb43e5835f7a06efad0572b54a831ea11bd8 Mon Sep 17 00:00:00 2001 From: Javad Arjmandi Date: Mon, 15 Jun 2026 12:24:35 +0000 Subject: [PATCH] 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 --- nanobot/providers/openai_compat_provider.py | 100 +++++- nanobot/providers/registry.py | 43 ++- nanobot/webui/settings_api.py | 40 +++ tests/providers/test_mistral_provider.py | 343 +++++++++++++++++++- webui/src/lib/types.ts | 1 + 5 files changed, 516 insertions(+), 11 deletions(-) diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index 5e2d7115..cccb9391 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -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: diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index cf64cb6b..273ea4cc 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -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( diff --git a/nanobot/webui/settings_api.py b/nanobot/webui/settings_api.py index cf3f1bab..6cc795c1 100644 --- a/nanobot/webui/settings_api.py +++ b/nanobot/webui/settings_api.py @@ -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 + ), } ) diff --git a/tests/providers/test_mistral_provider.py b/tests/providers/test_mistral_provider.py index 30023afe..4f429add 100644 --- a/tests/providers/test_mistral_provider.py +++ b/tests/providers/test_mistral_provider.py @@ -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 diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index 88de7e38..a5ed2ceb 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -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;