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:
committed by
Xubin Ren
co-authored by
Claude Sonnet 4.6
parent
51bd3337ef
commit
d5f5eb43e5
@@ -535,6 +535,13 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
pending_tool_ids: dict[str, deque[str]] = {}
|
pending_tool_ids: dict[str, deque[str]] = {}
|
||||||
force_string_content = bool(self._spec and self._spec.name == "deepseek")
|
force_string_content = bool(self._spec and self._spec.name == "deepseek")
|
||||||
normalize_tool_ids = self._should_normalize_tool_call_ids()
|
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:
|
def map_id(value: Any) -> Any:
|
||||||
if not isinstance(value, str):
|
if not isinstance(value, str):
|
||||||
@@ -704,7 +711,34 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
# DashScope accepts none/minimum/low/medium/high/xhigh; "minimal" 400s.
|
# DashScope accepts none/minimum/low/medium/high/xhigh; "minimal" 400s.
|
||||||
wire_effort = "minimum"
|
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
|
kwargs["reasoning_effort"] = wire_effort
|
||||||
|
|
||||||
# Only send thinking controls when reasoning_effort is explicit so
|
# Only send thinking controls when reasoning_effort is explicit so
|
||||||
@@ -931,6 +965,10 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
for item in value:
|
for item in value:
|
||||||
item_map = cls._maybe_mapping(item)
|
item_map = cls._maybe_mapping(item)
|
||||||
if item_map:
|
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")
|
text = item_map.get("text")
|
||||||
if isinstance(text, str):
|
if isinstance(text, str):
|
||||||
parts.append(text)
|
parts.append(text)
|
||||||
@@ -944,6 +982,31 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
return "".join(parts) or None
|
return "".join(parts) or None
|
||||||
return str(value)
|
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
|
@classmethod
|
||||||
def _extract_usage(cls, response: Any) -> dict[str, int]:
|
def _extract_usage(cls, response: Any) -> dict[str, int]:
|
||||||
"""Extract token usage from an OpenAI-compatible response.
|
"""Extract token usage from an OpenAI-compatible response.
|
||||||
@@ -1045,6 +1108,12 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
reasoning_content = msg0.get("reasoning_content")
|
reasoning_content = msg0.get("reasoning_content")
|
||||||
if reasoning_content is None and msg0.get("reasoning"):
|
if reasoning_content is None and msg0.get("reasoning"):
|
||||||
reasoning_content = self._extract_text_content(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:
|
for ch in choices:
|
||||||
ch_map = self._maybe_mapping(ch) or {}
|
ch_map = self._maybe_mapping(ch) or {}
|
||||||
m = self._maybe_mapping(ch_map.get("message")) or {}
|
m = self._maybe_mapping(ch_map.get("message")) or {}
|
||||||
@@ -1195,12 +1264,17 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
if choice.get("finish_reason"):
|
if choice.get("finish_reason"):
|
||||||
finish_reason = str(choice["finish_reason"])
|
finish_reason = str(choice["finish_reason"])
|
||||||
delta = cls._maybe_mapping(choice.get("delta")) or {}
|
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:
|
if text:
|
||||||
content_parts.append(text)
|
content_parts.append(text)
|
||||||
text = cls._extract_text_content(delta.get("reasoning_content"))
|
text = cls._extract_text_content(delta.get("reasoning_content"))
|
||||||
if not text:
|
if not text:
|
||||||
text = cls._extract_text_content(delta.get("reasoning"))
|
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:
|
if text:
|
||||||
reasoning_parts.append(text)
|
reasoning_parts.append(text)
|
||||||
for idx, tc in enumerate(delta.get("tool_calls") or []):
|
for idx, tc in enumerate(delta.get("tool_calls") or []):
|
||||||
@@ -1217,13 +1291,20 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
finish_reason = choice.finish_reason
|
finish_reason = choice.finish_reason
|
||||||
delta = choice.delta
|
delta = choice.delta
|
||||||
if delta and delta.content:
|
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:
|
if delta:
|
||||||
reasoning = getattr(delta, "reasoning_content", None)
|
reasoning = getattr(delta, "reasoning_content", None)
|
||||||
if not reasoning:
|
if not reasoning:
|
||||||
reasoning = getattr(delta, "reasoning", None)
|
reasoning = getattr(delta, "reasoning", None)
|
||||||
if reasoning:
|
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 []:
|
for tc in (getattr(delta, "tool_calls", None) or []) if delta else []:
|
||||||
_accum_tc(tc, getattr(tc, "index", 0))
|
_accum_tc(tc, getattr(tc, "index", 0))
|
||||||
if delta:
|
if delta:
|
||||||
@@ -1476,8 +1557,13 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
chunks.append(chunk)
|
chunks.append(chunk)
|
||||||
if chunk.choices:
|
if chunk.choices:
|
||||||
delta_obj = chunk.choices[0].delta
|
delta_obj = chunk.choices[0].delta
|
||||||
|
raw_delta_content = getattr(delta_obj, "content", None)
|
||||||
if on_content_delta:
|
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:
|
if text:
|
||||||
await on_content_delta(text)
|
await on_content_delta(text)
|
||||||
if on_thinking_delta:
|
if on_thinking_delta:
|
||||||
@@ -1485,6 +1571,10 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
delta_obj, "reasoning", None,
|
delta_obj, "reasoning", None,
|
||||||
)
|
)
|
||||||
r_text = self._extract_text_content(reasoning)
|
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:
|
if r_text:
|
||||||
await on_thinking_delta(r_text)
|
await on_thinking_delta(r_text)
|
||||||
if on_tool_call_delta:
|
if on_tool_call_delta:
|
||||||
|
|||||||
@@ -85,6 +85,29 @@ class ProviderSpec:
|
|||||||
# whose API returns the actual answer in "reasoning" instead of "content".
|
# whose API returns the actual answer in "reasoning" instead of "content".
|
||||||
reasoning_as_content: bool = False
|
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
|
@property
|
||||||
def label(self) -> str:
|
def label(self) -> str:
|
||||||
return self.display_name or self.name.title()
|
return self.display_name or self.name.title()
|
||||||
@@ -387,14 +410,30 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
|||||||
backend="anthropic",
|
backend="anthropic",
|
||||||
default_api_base="https://api.minimax.io/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(
|
ProviderSpec(
|
||||||
name="mistral",
|
name="mistral",
|
||||||
keywords=("mistral",),
|
keywords=("mistral", "magistral", "ministral", "codestral", "devstral"),
|
||||||
env_key="MISTRAL_API_KEY",
|
env_key="MISTRAL_API_KEY",
|
||||||
display_name="Mistral",
|
display_name="Mistral",
|
||||||
backend="openai_compat",
|
backend="openai_compat",
|
||||||
default_api_base="https://api.mistral.ai/v1",
|
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
|
# Step Fun (阶跃星辰): OpenAI-compatible API
|
||||||
ProviderSpec(
|
ProviderSpec(
|
||||||
|
|||||||
@@ -654,6 +654,40 @@ def _image_generation_provider_rows(config: Any) -> list[dict[str, Any]]:
|
|||||||
return rows
|
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]]:
|
def _transcription_provider_rows(config: Any) -> list[dict[str, Any]]:
|
||||||
rows: list[dict[str, Any]] = []
|
rows: list[dict[str, Any]] = []
|
||||||
for name in transcription_provider_names():
|
for name in transcription_provider_names():
|
||||||
@@ -741,6 +775,9 @@ def settings_payload(
|
|||||||
"context_window_tokens": defaults.context_window_tokens,
|
"context_window_tokens": defaults.context_window_tokens,
|
||||||
"temperature": defaults.temperature,
|
"temperature": defaults.temperature,
|
||||||
"reasoning_effort": defaults.reasoning_effort,
|
"reasoning_effort": defaults.reasoning_effort,
|
||||||
|
"reasoning_effort_values": _reasoning_effort_values_for(
|
||||||
|
defaults.provider, defaults.model
|
||||||
|
),
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
for name, preset in config.model_presets.items():
|
for name, preset in config.model_presets.items():
|
||||||
@@ -756,6 +793,9 @@ def settings_payload(
|
|||||||
"context_window_tokens": preset.context_window_tokens,
|
"context_window_tokens": preset.context_window_tokens,
|
||||||
"temperature": preset.temperature,
|
"temperature": preset.temperature,
|
||||||
"reasoning_effort": preset.reasoning_effort,
|
"reasoning_effort": preset.reasoning_effort,
|
||||||
|
"reasoning_effort_values": _reasoning_effort_values_for(
|
||||||
|
preset.provider, preset.model
|
||||||
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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.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."""
|
"""ProvidersConfig should have a mistral field."""
|
||||||
config = ProvidersConfig()
|
config = ProvidersConfig()
|
||||||
assert hasattr(config, "mistral")
|
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."""
|
"""Mistral should be registered in the provider registry."""
|
||||||
specs = {s.name: s for s in PROVIDERS}
|
specs = {s.name: s for s in PROVIDERS}
|
||||||
assert "mistral" in specs
|
assert "mistral" in specs
|
||||||
@@ -18,3 +34,322 @@ def test_mistral_provider_in_registry():
|
|||||||
mistral = specs["mistral"]
|
mistral = specs["mistral"]
|
||||||
assert mistral.env_key == "MISTRAL_API_KEY"
|
assert mistral.env_key == "MISTRAL_API_KEY"
|
||||||
assert mistral.default_api_base == "https://api.mistral.ai/v1"
|
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
|
||||||
|
|||||||
@@ -372,6 +372,7 @@ export interface SettingsPayload {
|
|||||||
context_window_tokens: number;
|
context_window_tokens: number;
|
||||||
temperature: number;
|
temperature: number;
|
||||||
reasoning_effort: string | null;
|
reasoning_effort: string | null;
|
||||||
|
reasoning_effort_values?: string[];
|
||||||
}>;
|
}>;
|
||||||
providers: Array<{
|
providers: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user