Files
nanobot/tests/providers/test_mistral_provider.py
T
d5f5eb43e5 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>
2026-06-18 00:08:47 +08:00

356 lines
11 KiB
Python

"""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.openai_compat_provider import OpenAICompatProvider
from nanobot.providers.registry import PROVIDERS, find_by_name
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() -> None:
"""Mistral should be registered in the provider registry."""
specs = {s.name: s for s in PROVIDERS}
assert "mistral" in specs
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