From 22717d0f9ad534856ddbd9bafc31cfa6ea59fbee Mon Sep 17 00:00:00 2001 From: Alex Castillo Date: Fri, 30 Oct 2026 23:42:11 -0400 Subject: [PATCH] fix(mistral): map unknown finish reasons instead of raising Mistral returns finish_reason values such as model_length that FinishReason does not define, so FinishReason(choice.finish_reason) raised ValueError for valid responses in both the non-streaming and streaming paths. model_length now maps to LENGTH and any other unknown value to None. --- .../services/mistral_ai_chat_completion.py | 21 ++++- .../test_mistralai_chat_completion.py | 88 +++++++++++++++++++ 2 files changed, 107 insertions(+), 2 deletions(-) diff --git a/python/semantic_kernel/connectors/ai/mistral_ai/services/mistral_ai_chat_completion.py b/python/semantic_kernel/connectors/ai/mistral_ai/services/mistral_ai_chat_completion.py index 26e474d12e37..5e6544c13632 100644 --- a/python/semantic_kernel/connectors/ai/mistral_ai/services/mistral_ai_chat_completion.py +++ b/python/semantic_kernel/connectors/ai/mistral_ai/services/mistral_ai_chat_completion.py @@ -195,6 +195,23 @@ async def _inner_get_streaming_chat_message_contents( # region content conversion to SK + @staticmethod + def _get_finish_reason(finish_reason: str | None) -> FinishReason | None: + """Map a Mistral finish reason to a FinishReason. + + Mistral can return values that FinishReason does not define (e.g. `model_length`, which + means the model's context window was reached), so those are mapped or ignored instead of raising. + """ + if not finish_reason: + return None + if finish_reason == "model_length": + return FinishReason.LENGTH + try: + return FinishReason(finish_reason) + except ValueError: + logger.debug(f"Unknown finish reason from Mistral AI: {finish_reason}") + return None + def _create_chat_message_content( self, response: ChatCompletionResponse, choice: ChatCompletionChoice, response_metadata: dict[str, Any] ) -> "ChatMessageContent": @@ -213,7 +230,7 @@ def _create_chat_message_content( metadata=metadata, role=AuthorRole(choice.message.role), items=items, - finish_reason=FinishReason(choice.finish_reason) if choice.finish_reason else None, + finish_reason=self._get_finish_reason(choice.finish_reason), ) def _create_streaming_chat_message_content( @@ -238,7 +255,7 @@ def _create_streaming_chat_message_content( ai_model_id=self.ai_model_id, metadata=metadata, role=AuthorRole(choice.delta.role) if choice.delta.role else AuthorRole.ASSISTANT, - finish_reason=FinishReason(choice.finish_reason) if choice.finish_reason else None, + finish_reason=self._get_finish_reason(choice.finish_reason), items=items, function_invoke_attempt=function_invoke_attempt, ) diff --git a/python/tests/unit/connectors/ai/mistral_ai/services/test_mistralai_chat_completion.py b/python/tests/unit/connectors/ai/mistral_ai/services/test_mistralai_chat_completion.py index 35df9d37e990..ed26d7e0bbe8 100644 --- a/python/tests/unit/connectors/ai/mistral_ai/services/test_mistralai_chat_completion.py +++ b/python/tests/unit/connectors/ai/mistral_ai/services/test_mistralai_chat_completion.py @@ -30,6 +30,7 @@ from semantic_kernel.contents.chat_message_content import ChatMessageContent from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent from semantic_kernel.contents.utils.author_role import AuthorRole +from semantic_kernel.contents.utils.finish_reason import FinishReason from semantic_kernel.exceptions import ( ServiceInitializationError, ServiceInvalidExecutionSettingsError, @@ -453,6 +454,93 @@ async def test_mistral_ai_chat_completion_get_chat_message_contents_success(): async_mock_client.chat.complete_async.assert_awaited_once() +@pytest.mark.parametrize( + ("mistral_finish_reason", "expected"), + [ + ("model_length", FinishReason.LENGTH), + ("length", FinishReason.LENGTH), + ("something_new", None), + ], +) +async def test_mistral_ai_chat_completion_finish_reason_mapping(mistral_finish_reason, expected): + """Finish reasons FinishReason does not define must not raise.""" + mock_response = ChatCompletionResponse( + id="some_id", + object="object", + created=12345, + usage=UsageInfo(prompt_tokens=10, completion_tokens=20, total_tokens=30), + model="test-model", + choices=[ + ChatCompletionChoice( + index=0, + message=AssistantMessage(role="assistant", content="Hello!"), + finish_reason=mistral_finish_reason, + ) + ], + ) + + async_mock_client = MagicMock(spec=Mistral) + async_mock_client.chat = MagicMock() + async_mock_client.chat.complete_async = AsyncMock(return_value=mock_response) + + chat_completion = MistralAIChatCompletion( + ai_model_id="test-model", + api_key="test_key", + async_client=async_mock_client, + ) + + results = await chat_completion.get_chat_message_contents(ChatHistory(), MistralAIChatPromptExecutionSettings()) + + assert results[0].finish_reason == expected + + +@pytest.mark.parametrize( + ("mistral_finish_reason", "expected"), + [ + ("model_length", FinishReason.LENGTH), + ("something_new", None), + ], +) +async def test_mistral_ai_chat_completion_streaming_finish_reason_mapping(mistral_finish_reason, expected): + """Streaming finish reasons FinishReason does not define must not raise.""" + mock_chunk = CompletionEvent( + data=CompletionChunk( + id="chunk1", + created=1, + model="test-model", + choices=[ + CompletionResponseStreamChoice( + index=0, + delta=DeltaMessage(role="assistant", content="Hello"), + finish_reason=mistral_finish_reason, + ) + ], + ) + ) + + async def mock_stream_async(**kwargs): + yield mock_chunk + + async_mock_client = MagicMock(spec=Mistral) + async_mock_client.chat = MagicMock() + async_mock_client.chat.stream_async = AsyncMock(return_value=mock_stream_async()) + + chat_completion = MistralAIChatCompletion( + ai_model_id="test-model", + api_key="test_key", + async_client=async_mock_client, + ) + + chunks = [ + chunk_list + async for chunk_list in chat_completion.get_streaming_chat_message_contents( + ChatHistory(), MistralAIChatPromptExecutionSettings() + ) + ] + + assert chunks[0][0].finish_reason == expected + + async def test_mistral_ai_chat_completion_get_chat_message_contents_failure(): """Test get_chat_message_contents should raise ServiceResponseException if Mistral call fails.""" async_mock_client = MagicMock(spec=Mistral)