diff --git a/litellm/llms/mistral/chat/transformation.py b/litellm/llms/mistral/chat/transformation.py index a40970ca429..957a9bd2c80 100644 --- a/litellm/llms/mistral/chat/transformation.py +++ b/litellm/llms/mistral/chat/transformation.py @@ -535,22 +535,31 @@ class MistralConfig(OpenAIGPTConfig): # Only process if content is a list if isinstance(content, list): thinking_content = "" - text_content = "" + text_segments: list[str] = [] # mutable-ok: accumulated across this choice's blocks + has_reference_blocks = False # Process each content block for block in content: - if block.get("type") == "thinking": + block_type = block.get("type") # pyright: ignore[reportUnknownVariableType] # provider content blocks are untyped JSON + if block_type == "thinking": thinking_blocks = block.get("thinking", []) thinking_texts = [] for thinking_block in thinking_blocks: if thinking_block.get("type") == "text": thinking_texts.append(thinking_block.get("text", "")) thinking_content = "\n".join(thinking_texts) - elif block.get("type") == "text": - text_content = block.get("text", "") + elif block_type == "text": + text_segments.append(block.get("text", "")) # pyright: ignore[reportUnknownArgumentType] # provider content blocks are untyped JSON + elif block_type == "reference": + has_reference_blocks = True # Set the extracted content - choice["message"]["content"] = text_content + choice["message"]["content"] = "".join(text_segments) + if has_reference_blocks: + # The flattened content string cannot carry Mistral + # citation chunks; keep the original block list + # reachable instead of dropping them silently. + choice["message"]["mistral_content_blocks"] = content if thinking_content: choice["message"]["reasoning_content"] = thinking_content @@ -657,6 +666,13 @@ class MistralChatResponseIterator(OpenAIChatCompletionStreamingHandler): else: delta.pop("thinking_blocks", None) delta.pop("reasoning_content", None) + if any( + block.get("type") == "reference" + for block in content # pyright: ignore[reportUnknownArgumentType, reportUnknownVariableType] # provider content blocks are untyped JSON + ): + # citation chunks carry no text; expose them on + # provider_specific_fields instead of dropping them + delta["provider_specific_fields"] = {"mistral_content_blocks": content} except Exception: # Fall back to default parsing if custom handling fails return super().chunk_parser(chunk) diff --git a/tests/unit/llms/mistral/test_mistral_chat_transformation.py b/tests/unit/llms/mistral/test_mistral_chat_transformation.py index 2e88bccd49b..bbbbea49270 100644 --- a/tests/unit/llms/mistral/test_mistral_chat_transformation.py +++ b/tests/unit/llms/mistral/test_mistral_chat_transformation.py @@ -957,3 +957,116 @@ def test_mistral_transform_request_hoists_tool_message_image(): {"type": "text", "text": TOOL_RESULT_IMAGE_BOUNDARY}, {"type": "image_url", "image_url": {"url": data_uri}}, ] + + +class TestMistralContentListConversion: + """ + Mistral content-as-list must keep every text chunk and expose citation chunks. + + Issue #45378: the non-streaming conversion overwrote the content per text + block (only the last chunk survived — an answer closed by a citation was + flattened to "."), and reference chunks were dropped on both the + non-streaming and streaming paths. + """ + + CITATION_CONTENT = [ + {"type": "text", "text": "The sky is blue during the day"}, + {"type": "reference", "reference_ids": ["doc1"]}, + {"type": "text", "text": "."}, + ] + + def test_non_streaming_concatenates_all_text_chunks(self): + response_data = {"choices": [{"message": {"role": "assistant", "content": self.CITATION_CONTENT}}]} + + MistralConfig._handle_content_list_to_str_conversion(response_data) + + message = response_data["choices"][0]["message"] + assert message["content"] == "The sky is blue during the day." + + def test_non_streaming_exposes_reference_chunks_in_provider_specific_fields(self): + response_data = {"choices": [{"message": {"role": "assistant", "content": self.CITATION_CONTENT}}]} + + MistralConfig._handle_content_list_to_str_conversion(response_data) + + message = response_data["choices"][0]["message"] + assert message["mistral_content_blocks"] == self.CITATION_CONTENT + + def test_non_streaming_adds_no_content_blocks_key_without_citations(self): + content = [{"type": "text", "text": "plain answer"}] + response_data = {"choices": [{"message": {"role": "assistant", "content": content}}]} + + MistralConfig._handle_content_list_to_str_conversion(response_data) + + message = response_data["choices"][0]["message"] + assert message["content"] == "plain answer" + assert "mistral_content_blocks" not in message + + def test_non_streaming_thinking_blocks_still_extracted(self): + response_data = { + "choices": [ + { + "message": { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": [{"type": "text", "text": "reasoning"}]}, + {"type": "text", "text": "answer part one"}, + {"type": "text", "text": " and two"}, + ], + } + } + ] + } + + MistralConfig._handle_content_list_to_str_conversion(response_data) + + message = response_data["choices"][0]["message"] + assert message["content"] == "answer part one and two" + assert message["reasoning_content"] == "reasoning" + + def test_non_streaming_reference_blocks_reach_model_response_provider_specific_fields(self): + # the exposed key must survive convert_to_model_response_object so + # callers actually receive the citation chunks + from litellm.utils import convert_to_model_response_object + + response_data = { + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 1, + "model": "mistral-medium-2604", + "choices": [ + { + "index": 0, + "finish_reason": "stop", + "message": {"role": "assistant", "content": self.CITATION_CONTENT}, + } + ], + } + + response_data = MistralConfig._handle_content_list_to_str_conversion(response_data) + model_response = convert_to_model_response_object( + response_object=response_data, model_response_object=ModelResponse() + ) + + provider_specific_fields = model_response.choices[0].message.provider_specific_fields + assert provider_specific_fields["mistral_content_blocks"] == self.CITATION_CONTENT + + def test_streaming_mixed_blocks_concatenate_and_expose_references(self): + iterator = MistralChatResponseIterator(streaming_response=iter([]), sync_stream=True) + chunk = {"id": "x", "choices": [{"delta": {"content": self.CITATION_CONTENT}}]} + + result = iterator.chunk_parser(chunk) + + delta = result.choices[0].delta + assert delta.content == "The sky is blue during the day." + assert delta.provider_specific_fields["mistral_content_blocks"] == self.CITATION_CONTENT + + def test_streaming_reference_only_delta_exposes_references(self): + iterator = MistralChatResponseIterator(streaming_response=iter([]), sync_stream=True) + content = [{"type": "reference", "reference_ids": ["doc1"]}] + chunk = {"id": "x", "choices": [{"delta": {"content": content}}]} + + result = iterator.chunk_parser(chunk) + + delta = result.choices[0].delta + assert not delta.content + assert delta.provider_specific_fields["mistral_content_blocks"] == content