Files
wehub-resource-sync 5296d0e97c
CI / Ban suppressions and legacy annotations (push) Has been cancelled
CI / pytest (push) Has been cancelled
CI / ruff-check (push) Has been cancelled
CI / ruff-format (push) Has been cancelled
CI / ty (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:44 +08:00

765 lines
23 KiB
Python

"""Tests for Mistral La Plateforme provider."""
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import openai
import pytest
from httpx import Request, Response
from free_claude_code.config.provider_catalog import MISTRAL_DEFAULT_BASE
from free_claude_code.core.failures import ExecutionFailure
from free_claude_code.providers.base import ProviderConfig
from free_claude_code.providers.mistral import MistralProvider
from tests.providers.request_factory import make_messages_request
from tests.providers.support import passthrough_rate_limiter
def make_request(**overrides):
return make_messages_request("devstral-small-latest", **overrides)
@pytest.fixture
def mistral_config():
return ProviderConfig(
api_key="test_mistral_key",
base_url=MISTRAL_DEFAULT_BASE,
rate_limit=10,
rate_window=60,
enable_thinking=True,
)
@pytest.fixture
def mistral_provider(mistral_config):
return MistralProvider(mistral_config, rate_limiter=passthrough_rate_limiter())
def test_init(mistral_config):
"""Test provider initialization."""
with patch(
"free_claude_code.providers.openai_chat.provider.AsyncOpenAI"
) as mock_openai:
provider = MistralProvider(
mistral_config, rate_limiter=passthrough_rate_limiter()
)
assert provider._api_key == "test_mistral_key"
assert provider._base_url == MISTRAL_DEFAULT_BASE
mock_openai.assert_called_once()
def test_default_base_url():
assert MISTRAL_DEFAULT_BASE == "https://api.mistral.ai/v1"
def test_build_request_body_basic(mistral_provider):
"""Basic request body conversion works for Mistral."""
req = make_request()
body = mistral_provider._build_request_body(req)
assert body["model"] == "devstral-small-latest"
assert body["messages"][0]["role"] == "system"
assert body["reasoning_effort"] == "high"
def test_build_request_body_replays_prior_thinking_as_mistral_chunks(
mistral_provider,
):
req = make_request(
system=None,
messages=[
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "Need the tool."},
{"type": "text", "text": "Calling the tool."},
{
"type": "tool_use",
"id": "toolu_1",
"name": "echo",
"input": {"value": "x"},
},
],
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_1",
"content": "result",
}
],
},
],
)
body = mistral_provider._build_request_body(req)
assistant = body["messages"][0]
assert "reasoning_content" not in assistant
assert assistant["content"] == [
{
"type": "thinking",
"thinking": [{"type": "text", "text": "Need the tool."}],
},
{"type": "text", "text": "Calling the tool."},
]
assert assistant["tool_calls"][0]["id"] == "toolu_1"
def test_build_request_body_preserves_tools_tool_choice_and_params(mistral_provider):
req = make_request(
tools=[
{
"name": "echo",
"description": "Echo a value",
"input_schema": {
"type": "object",
"properties": {"value": {"type": "string"}},
"required": ["value"],
},
}
],
tool_choice={"type": "tool", "name": "echo"},
stop_sequences=["STOP"],
)
body = mistral_provider._build_request_body(req)
assert body["max_tokens"] == 100
assert body["temperature"] == 0.5
assert body["top_p"] == 0.9
assert body["stop"] == ["STOP"]
assert body["tools"][0]["function"]["name"] == "echo"
assert body["tool_choice"] == {"type": "function", "function": {"name": "echo"}}
def test_build_request_body_global_disable_blocks_reasoning_mapping():
"""Global disable disables reasoning replay in the converter."""
provider = MistralProvider(
ProviderConfig(
api_key="test_mistral_key",
base_url=MISTRAL_DEFAULT_BASE,
rate_limit=10,
rate_window=60,
enable_thinking=False,
),
rate_limiter=passthrough_rate_limiter(),
)
req = make_request()
body = provider._build_request_body(req)
assert "reasoning_effort" not in body
assert all("reasoning_content" not in m for m in body.get("messages", []))
def test_build_request_body_thinking_disabled_strips_prior_mistral_thinking():
provider = MistralProvider(
ProviderConfig(
api_key="test_mistral_key",
base_url=MISTRAL_DEFAULT_BASE,
rate_limit=10,
rate_window=60,
enable_thinking=False,
),
rate_limiter=passthrough_rate_limiter(),
)
req = make_request(
system=None,
messages=[
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "Hidden."},
{"type": "text", "text": "Visible."},
],
},
],
)
body = provider._build_request_body(req)
assert "reasoning_effort" not in body
assert body["messages"][0]["content"] == "Visible."
@pytest.mark.asyncio
async def test_stream_response_text(mistral_provider):
"""Text content deltas are emitted as text blocks."""
req = make_request()
mock_chunk = MagicMock()
mock_chunk.choices = [
MagicMock(
delta=MagicMock(
content="Hello back!",
reasoning_content=None,
tool_calls=None,
),
finish_reason="stop",
)
]
mock_chunk.usage = MagicMock(completion_tokens=5, prompt_tokens=10)
async def mock_stream():
yield mock_chunk
with patch.object(
mistral_provider._client.chat.completions, "create", new_callable=AsyncMock
) as mock_create:
mock_create.return_value = mock_stream()
events = [event async for event in mistral_provider.stream_response(req)]
assert any(
'"text_delta"' in event and "Hello back!" in event for event in events
)
@pytest.mark.asyncio
async def test_stream_response_reasoning_content(mistral_provider):
"""reasoning_content deltas are emitted as thinking blocks."""
req = make_request()
mock_chunk = MagicMock()
mock_chunk.choices = [
MagicMock(
delta=MagicMock(
content=None,
reasoning_content="Thinking...",
tool_calls=None,
),
finish_reason="stop",
)
]
mock_chunk.usage = MagicMock(completion_tokens=2, prompt_tokens=10)
async def mock_stream():
yield mock_chunk
with patch.object(
mistral_provider._client.chat.completions, "create", new_callable=AsyncMock
) as mock_create:
mock_create.return_value = mock_stream()
events = [event async for event in mistral_provider.stream_response(req)]
assert any(
'"thinking_delta"' in event and "Thinking..." in event for event in events
)
@pytest.mark.asyncio
async def test_stream_response_native_mistral_thinking_chunk(mistral_provider):
req = make_request()
mock_chunk = MagicMock()
mock_chunk.choices = [
MagicMock(
delta=MagicMock(
content=[
{
"type": "thinking",
"thinking": [{"type": "text", "text": "Native thought."}],
}
],
reasoning_content=None,
tool_calls=None,
),
finish_reason="stop",
)
]
mock_chunk.usage = MagicMock(completion_tokens=2, prompt_tokens=10)
async def mock_stream():
yield mock_chunk
with patch.object(
mistral_provider._client.chat.completions, "create", new_callable=AsyncMock
) as mock_create:
mock_create.return_value = mock_stream()
events = [event async for event in mistral_provider.stream_response(req)]
assert any(
'"thinking_delta"' in event and "Native thought." in event for event in events
)
@pytest.mark.asyncio
async def test_stream_response_native_mistral_text_chunk(mistral_provider):
req = make_request()
mock_chunk = MagicMock()
mock_chunk.choices = [
MagicMock(
delta=MagicMock(
content=[{"type": "text", "text": "Native text."}],
reasoning_content=None,
tool_calls=None,
),
finish_reason="stop",
)
]
mock_chunk.usage = MagicMock(completion_tokens=2, prompt_tokens=10)
async def mock_stream():
yield mock_chunk
with patch.object(
mistral_provider._client.chat.completions, "create", new_callable=AsyncMock
) as mock_create:
mock_create.return_value = mock_stream()
events = [event async for event in mistral_provider.stream_response(req)]
assert any('"text_delta"' in event and "Native text." in event for event in events)
@pytest.mark.asyncio
async def test_stream_response_preserves_native_thinking_and_string_text(
mistral_provider,
):
req = make_request()
mock_chunk = SimpleNamespace(
choices=[
SimpleNamespace(
delta=SimpleNamespace(
content="Visible token.",
thinking="Native thought.",
reasoning_content=None,
tool_calls=None,
),
finish_reason="stop",
),
],
usage=MagicMock(completion_tokens=2, prompt_tokens=10),
)
async def mock_stream():
yield mock_chunk
with patch.object(
mistral_provider._client.chat.completions, "create", new_callable=AsyncMock
) as mock_create:
mock_create.return_value = mock_stream()
events = [event async for event in mistral_provider.stream_response(req)]
event_text = "\n".join(events)
assert '"thinking_delta"' in event_text
assert "Native thought." in event_text
assert '"text_delta"' in event_text
assert "Visible token." in event_text
@pytest.mark.asyncio
async def test_stream_response_preserves_native_reasoning_and_string_text(
mistral_provider,
):
req = make_request()
mock_chunk = SimpleNamespace(
choices=[
SimpleNamespace(
delta=SimpleNamespace(
content="Visible token.",
reasoning="Native reasoning.",
reasoning_content=None,
tool_calls=None,
),
finish_reason="stop",
)
],
usage=MagicMock(completion_tokens=2, prompt_tokens=10),
)
async def mock_stream():
yield mock_chunk
with patch.object(
mistral_provider._client.chat.completions, "create", new_callable=AsyncMock
) as mock_create:
mock_create.return_value = mock_stream()
events = [event async for event in mistral_provider.stream_response(req)]
event_text = "\n".join(events)
assert "Native reasoning." in event_text
assert "Visible token." in event_text
@pytest.mark.asyncio
async def test_stream_response_preserves_mixed_native_content_array(
mistral_provider,
):
req = make_request()
mock_chunk = MagicMock()
mock_chunk.choices = [
MagicMock(
delta=MagicMock(
content=[
{
"type": "thinking",
"thinking": [{"type": "text", "text": "Native thought."}],
},
{"type": "text", "text": "Native text."},
],
reasoning_content=None,
tool_calls=None,
),
finish_reason="stop",
)
]
mock_chunk.usage = MagicMock(completion_tokens=2, prompt_tokens=10)
async def mock_stream():
yield mock_chunk
with patch.object(
mistral_provider._client.chat.completions, "create", new_callable=AsyncMock
) as mock_create:
mock_create.return_value = mock_stream()
events = [event async for event in mistral_provider.stream_response(req)]
event_text = "\n".join(events)
assert "Native thought." in event_text
assert "Native text." in event_text
@pytest.mark.asyncio
async def test_stream_response_suppresses_native_mistral_thinking_when_disabled(
mistral_provider,
):
req = make_request()
mock_chunk = MagicMock()
mock_chunk.choices = [
MagicMock(
delta=MagicMock(
content=[
{
"type": "thinking",
"thinking": [{"type": "text", "text": "Hidden."}],
},
{"type": "text", "text": "Visible."},
],
reasoning_content=None,
tool_calls=None,
),
finish_reason="stop",
)
]
mock_chunk.usage = MagicMock(completion_tokens=2, prompt_tokens=10)
async def mock_stream():
yield mock_chunk
with patch.object(
mistral_provider._client.chat.completions, "create", new_callable=AsyncMock
) as mock_create:
mock_create.return_value = mock_stream()
events = [
event
async for event in mistral_provider.stream_response(
req, thinking_enabled=False
)
]
event_text = "\n".join(events)
assert "Hidden." not in event_text
assert "Visible." in event_text
@pytest.mark.asyncio
async def test_stream_response_retries_without_mistral_reasoning_on_rejection(
mistral_provider,
):
req = make_request(
system=None,
messages=[
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "Need the tool."},
{
"type": "tool_use",
"id": "toolu_reasoning",
"name": "echo",
"input": {"value": "FCC_TOOL"},
},
],
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_reasoning",
"content": "result",
}
],
},
],
)
mock_chunk = MagicMock()
mock_chunk.choices = [
MagicMock(
delta=MagicMock(
content="Recovered", reasoning_content=None, tool_calls=None
),
finish_reason="stop",
)
]
mock_chunk.usage = MagicMock(completion_tokens=5, prompt_tokens=10)
async def mock_stream():
yield mock_chunk
error = _make_bad_request_error("Unsupported field: reasoning_effort")
with patch.object(
mistral_provider._client.chat.completions, "create", new_callable=AsyncMock
) as mock_create:
mock_create.side_effect = [error, mock_stream()]
events = [e async for e in mistral_provider.stream_response(req)]
assert mock_create.await_count == 2
first_call = mock_create.await_args_list[0].kwargs
second_call = mock_create.await_args_list[1].kwargs
assert first_call["reasoning_effort"] == "high"
assert first_call["messages"][0]["content"][0]["type"] == "thinking"
assert "reasoning_effort" not in second_call
assert second_call["messages"][0]["content"] == ""
assert second_call["messages"][0]["tool_calls"][0]["id"] == "toolu_reasoning"
assert any("Recovered" in event for event in events)
assert any("message_stop" in event for event in events)
@pytest.mark.asyncio
async def test_stream_response_reasoning_retry_preserves_visible_text_and_tools(
mistral_provider,
):
req = make_request(
system=None,
messages=[
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "Need the tool."},
{"type": "text", "text": "Visible history."},
{
"type": "tool_use",
"id": "toolu_reasoning",
"name": "echo",
"input": {"value": "FCC_TOOL"},
},
],
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_reasoning",
"content": "result",
}
],
},
],
)
mock_chunk = MagicMock()
mock_chunk.choices = [
MagicMock(
delta=MagicMock(content="Recovered", reasoning_content=None),
finish_reason="stop",
)
]
mock_chunk.usage = MagicMock(completion_tokens=5, prompt_tokens=10)
async def mock_stream():
yield mock_chunk
error = _make_bad_request_error("Unsupported field: reasoning_effort")
with patch.object(
mistral_provider._client.chat.completions, "create", new_callable=AsyncMock
) as mock_create:
mock_create.side_effect = [error, mock_stream()]
events = [e async for e in mistral_provider.stream_response(req)]
second_call = mock_create.await_args_list[1].kwargs
assert second_call["messages"][0]["content"] == "Visible history."
assert second_call["messages"][0]["tool_calls"][0]["id"] == "toolu_reasoning"
assert any("Recovered" in event for event in events)
@pytest.mark.asyncio
async def test_stream_response_retries_on_mistral_422_reasoning_rejection(
mistral_provider,
):
req = make_request()
mock_chunk = MagicMock()
mock_chunk.choices = [
MagicMock(
delta=MagicMock(content="Recovered", reasoning_content=None),
finish_reason="stop",
)
]
mock_chunk.usage = MagicMock(completion_tokens=5, prompt_tokens=10)
async def mock_stream():
yield mock_chunk
error = _StatusError(
"body.messages.assistant.thinking extra_forbidden",
status_code=422,
body={"detail": [{"loc": ["body", "reasoning_effort"]}]},
)
with patch.object(
mistral_provider._client.chat.completions, "create", new_callable=AsyncMock
) as mock_create:
mock_create.side_effect = [error, mock_stream()]
events = [e async for e in mistral_provider.stream_response(req)]
assert mock_create.await_count == 2
assert "reasoning_effort" not in mock_create.await_args_list[1].kwargs
assert any("Recovered" in event for event in events)
@pytest.mark.asyncio
async def test_stream_response_retries_when_model_disables_reasoning_input(
mistral_provider,
):
req = make_request(
system=None,
messages=[
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "Need context."},
{"type": "text", "text": "Visible history."},
],
}
],
)
mock_chunk = MagicMock()
mock_chunk.choices = [
MagicMock(
delta=MagicMock(content="Recovered", reasoning_content=None),
finish_reason="stop",
)
]
mock_chunk.usage = MagicMock(completion_tokens=5, prompt_tokens=10)
async def mock_stream():
yield mock_chunk
error = _StatusError(
"Reasoning input is not enabled for this model",
status_code=400,
body={
"object": "error",
"message": "Reasoning input is not enabled for this model",
"type": "invalid_request_invalid_args",
"code": "3051",
},
)
with patch.object(
mistral_provider._client.chat.completions, "create", new_callable=AsyncMock
) as mock_create:
mock_create.side_effect = [error, mock_stream()]
events = [e async for e in mistral_provider.stream_response(req)]
assert mock_create.await_count == 2
second_call = mock_create.await_args_list[1].kwargs
assert "reasoning_effort" not in second_call
assert second_call["messages"][0]["content"] == "Visible history."
assert any("Recovered" in event for event in events)
@pytest.mark.asyncio
async def test_stream_response_unrelated_bad_request_does_not_retry(mistral_provider):
req = make_request()
error = _make_bad_request_error("Unsupported field: top_k")
with patch.object(
mistral_provider._client.chat.completions, "create", new_callable=AsyncMock
) as mock_create:
mock_create.side_effect = error
with pytest.raises(ExecutionFailure) as exc_info:
[e async for e in mistral_provider.stream_response(req)]
assert mock_create.await_count == 1
assert "Invalid request sent to provider" in exc_info.value.message
@pytest.mark.asyncio
async def test_stream_response_generic_thinking_error_does_not_retry(
mistral_provider,
):
req = make_request()
error = _make_bad_request_error("The model was thinking, but top_k is unsupported")
with patch.object(
mistral_provider._client.chat.completions, "create", new_callable=AsyncMock
) as mock_create:
mock_create.side_effect = error
with pytest.raises(ExecutionFailure) as exc_info:
[e async for e in mistral_provider.stream_response(req)]
assert mock_create.await_count == 1
assert "Invalid request sent to provider" in exc_info.value.message
def test_retry_body_without_reasoning_returns_none(mistral_provider):
body = {"model": "x", "messages": [{"role": "user", "content": "hi"}]}
assert (
mistral_provider._get_retry_request_body(
_make_bad_request_error("Unsupported field: reasoning_effort"), body
)
is None
)
@pytest.mark.asyncio
async def test_cleanup(mistral_provider):
"""cleanup closes the OpenAI client."""
mistral_provider._client = AsyncMock()
await mistral_provider.cleanup()
mistral_provider._client.close.assert_called_once()
def _make_bad_request_error(message: str) -> openai.BadRequestError:
request = Request("POST", "https://api.mistral.ai/v1/chat/completions")
response = Response(400, request=request)
body = {"error": {"message": message}}
return openai.BadRequestError(message, response=response, body=body)
class _StatusError(Exception):
def __init__(self, message: str, *, status_code: int, body: dict):
super().__init__(message)
self.status_code = status_code
self.body = body