"""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