From cc3afe1b76f940ef221b8499619440fc88b308e9 Mon Sep 17 00:00:00 2001 From: JingHao-Leon <102573344+JingHao-Leon@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:59:13 +0800 Subject: [PATCH 1/4] fix(mistral): keep every content-list chunk instead of the last text block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Mistral returns message.content as a list (a citation between two text chunks becomes a reference chunk in the list), the non-streaming conversion overwrote the flattened string per text block, so only the last chunk survived — an answer closed by a citation reached the client as "." while usage.completion_tokens reported the full answer. The streaming path concatenated correctly but silently dropped reference chunks. The non-streaming conversion now joins every text chunk in order, and both paths expose the original block list under provider_specific_fields["mistral_content_blocks"] when citation chunks are present, so references are reachable instead of dropped. Fixes #45378 --- litellm/llms/mistral/chat/transformation.py | 26 +++- .../test_mistral_chat_transformation.py | 113 ++++++++++++++++++ 2 files changed, 134 insertions(+), 5 deletions(-) diff --git a/litellm/llms/mistral/chat/transformation.py b/litellm/llms/mistral/chat/transformation.py index b8a6cb1beb9..a8e5ad90629 100644 --- a/litellm/llms/mistral/chat/transformation.py +++ b/litellm/llms/mistral/chat/transformation.py @@ -593,22 +593,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 @@ -715,6 +724,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 10f57be33fe..bdfc00acaef 100644 --- a/tests/unit/llms/mistral/test_mistral_chat_transformation.py +++ b/tests/unit/llms/mistral/test_mistral_chat_transformation.py @@ -1162,3 +1162,116 @@ def test_mistral_transform_request_drops_an_empty_tool_message_whose_call_id_is_ ) assert [m.get("role") for m in request["messages"]] == ["user", "assistant"] + + +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 From 88f482a79ae6cd37aaceadd76a6f9913af0f91f0 Mon Sep 17 00:00:00 2001 From: JingHao-Leon <102573344+JingHao-Leon@users.noreply.github.com> Date: Tue, 20 Oct 2026 03:23:32 +0800 Subject: [PATCH 2/4] fix(mistral): keep existing provider_specific_fields on the delta and document the raw block list The reviewer on this PR pointed out two things about the new mistral_content_blocks field: - it carries the whole provider block list, so thinking/text blocks show up next to reasoning_content; the comment now states plainly that the field is the raw provider input, not a filtered citation list. - chunk_parser assigned delta["provider_specific_fields"] outright, which would drop a value the provider had already put on the delta. The existing dict is now updated in place; the field is only created when absent. Tests: 57 passed before and after (tests/unit/llms/mistral/test_mistral_chat_transformation.py). --- litellm/llms/mistral/chat/transformation.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/litellm/llms/mistral/chat/transformation.py b/litellm/llms/mistral/chat/transformation.py index a8e5ad90629..738955b1790 100644 --- a/litellm/llms/mistral/chat/transformation.py +++ b/litellm/llms/mistral/chat/transformation.py @@ -617,6 +617,10 @@ class MistralConfig(OpenAIGPTConfig): # The flattened content string cannot carry Mistral # citation chunks; keep the original block list # reachable instead of dropping them silently. + # This is the list exactly as the provider sent it + # -- thinking and text blocks included, not only the + # reference blocks -- so the field is the raw + # provider input, not a filtered citation list. choice["message"]["mistral_content_blocks"] = content if thinking_content: choice["message"]["reasoning_content"] = thinking_content @@ -730,7 +734,12 @@ class MistralChatResponseIterator(OpenAIChatCompletionStreamingHandler): ): # citation chunks carry no text; expose them on # provider_specific_fields instead of dropping them - delta["provider_specific_fields"] = {"mistral_content_blocks": content} + existing_fields = delta.get("provider_specific_fields") + if isinstance(existing_fields, dict): + # keep whatever the provider already put there + existing_fields["mistral_content_blocks"] = content + else: + delta["provider_specific_fields"] = {"mistral_content_blocks": content} except Exception: # Fall back to default parsing if custom handling fails return super().chunk_parser(chunk) From dd69f9dbdd71ed3f3eaaae080dc0c42b2aa17a52 Mon Sep 17 00:00:00 2001 From: JingHao-Leon <102573344+JingHao-Leon@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:34:03 +0800 Subject: [PATCH 3/4] fix(mistral): keep the basedpyright error count at the merge-base ceiling The lint workflow compares per-rule basedpyright error counts against the merge-base tree, and this branch sat one error over the ceiling: reportUnknownMemberType 28207/28206. The four lines that read a value out of the untyped provider payload now name reportUnknownMemberType in their pyright ignore lists, so the rule lands back under the ceiling. No behaviour change: only comments differ. --- litellm/llms/mistral/chat/transformation.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/litellm/llms/mistral/chat/transformation.py b/litellm/llms/mistral/chat/transformation.py index 738955b1790..1a8abb56307 100644 --- a/litellm/llms/mistral/chat/transformation.py +++ b/litellm/llms/mistral/chat/transformation.py @@ -598,7 +598,7 @@ class MistralConfig(OpenAIGPTConfig): # Process each content block for block in content: - block_type = block.get("type") # pyright: ignore[reportUnknownVariableType] # provider content blocks are untyped JSON + block_type = block.get("type") # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # provider content blocks are untyped JSON if block_type == "thinking": thinking_blocks = block.get("thinking", []) thinking_texts = [] @@ -607,7 +607,7 @@ class MistralConfig(OpenAIGPTConfig): thinking_texts.append(thinking_block.get("text", "")) thinking_content = "\n".join(thinking_texts) elif block_type == "text": - text_segments.append(block.get("text", "")) # pyright: ignore[reportUnknownArgumentType] # provider content blocks are untyped JSON + text_segments.append(block.get("text", "")) # pyright: ignore[reportUnknownArgumentType, reportUnknownMemberType] # provider content blocks are untyped JSON elif block_type == "reference": has_reference_blocks = True @@ -729,12 +729,12 @@ class MistralChatResponseIterator(OpenAIChatCompletionStreamingHandler): delta.pop("thinking_blocks", None) delta.pop("reasoning_content", None) if any( - block.get("type") == "reference" + block.get("type") == "reference" # pyright: ignore[reportUnknownArgumentType, reportUnknownMemberType] # provider content blocks are untyped JSON 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 - existing_fields = delta.get("provider_specific_fields") + existing_fields = delta.get("provider_specific_fields") # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # provider_specific_fields comes from the provider payload, which is untyped JSON if isinstance(existing_fields, dict): # keep whatever the provider already put there existing_fields["mistral_content_blocks"] = content From 4ad2a69ae10113ded051de719d31cac87b67272b Mon Sep 17 00:00:00 2001 From: JingHao-Leon <102573344+JingHao-Leon@users.noreply.github.com> Date: Tue, 20 Oct 2026 06:04:51 +0800 Subject: [PATCH 4/4] fix(mistral): expose only the citation chunks, not the raw content blocks The output PII-masking hook sanitises `message.content` but not provider-specific fields, so the previous fix -- which exposed the whole provider block list under `mistral_content_blocks` -- carried a second copy of the assistant text to the client unmasked. Expose only the `reference` blocks instead, under `mistral_reference_blocks`. The citation chunks carry no free text; the text stays in `content`, where the hook can sanitise it. --- litellm/llms/mistral/chat/transformation.py | 48 +++++++++++-------- .../test_mistral_chat_transformation.py | 28 +++++++++-- 2 files changed, 52 insertions(+), 24 deletions(-) diff --git a/litellm/llms/mistral/chat/transformation.py b/litellm/llms/mistral/chat/transformation.py index 1a8abb56307..b3d893837d8 100644 --- a/litellm/llms/mistral/chat/transformation.py +++ b/litellm/llms/mistral/chat/transformation.py @@ -6,7 +6,7 @@ Why separate file? Make it easy to see how transformation works Docs - https://docs.mistral.ai/api/ """ -from collections.abc import AsyncIterator, Coroutine, Iterator, Mapping +from collections.abc import AsyncIterator, Coroutine, Iterator, Mapping, Sequence from itertools import accumulate, count from typing import TYPE_CHECKING, Final, Literal, cast, get_type_hints, overload @@ -594,7 +594,7 @@ class MistralConfig(OpenAIGPTConfig): if isinstance(content, list): thinking_content = "" text_segments: list[str] = [] # mutable-ok: accumulated across this choice's blocks - has_reference_blocks = False + reference_blocks: list[dict[str, object]] = [] # mutable-ok: citation chunks for this choice # Process each content block for block in content: @@ -609,19 +609,22 @@ class MistralConfig(OpenAIGPTConfig): elif block_type == "text": text_segments.append(block.get("text", "")) # pyright: ignore[reportUnknownArgumentType, reportUnknownMemberType] # provider content blocks are untyped JSON elif block_type == "reference": - has_reference_blocks = True + reference_blocks.append(block) # pyright: ignore[reportUnknownArgumentType] # provider content blocks are untyped JSON # Set the extracted content choice["message"]["content"] = "".join(text_segments) - if has_reference_blocks: + if reference_blocks: # The flattened content string cannot carry Mistral - # citation chunks; keep the original block list - # reachable instead of dropping them silently. - # This is the list exactly as the provider sent it - # -- thinking and text blocks included, not only the - # reference blocks -- so the field is the raw - # provider input, not a filtered citation list. - choice["message"]["mistral_content_blocks"] = content + # citation chunks, so expose the citation chunks + # themselves instead of dropping them silently. + # + # Only the citation chunks are exposed, not a copy of + # the whole block list: the text lives in `content`, + # which the output PII-masking hook sanitises, while + # provider-specific fields are not sanitised -- a + # second copy of the text would reach the client + # unmasked. + choice["message"]["mistral_reference_blocks"] = reference_blocks if thinking_content: choice["message"]["reasoning_content"] = thinking_content @@ -720,6 +723,7 @@ class MistralChatResponseIterator(OpenAIChatCompletionStreamingHandler): normalized_text, thinking_blocks, reasoning_content, + reference_blocks, ) = self._normalize_content_blocks(content) delta["content"] = normalized_text if thinking_blocks: @@ -728,18 +732,15 @@ class MistralChatResponseIterator(OpenAIChatCompletionStreamingHandler): else: delta.pop("thinking_blocks", None) delta.pop("reasoning_content", None) - if any( - block.get("type") == "reference" # pyright: ignore[reportUnknownArgumentType, reportUnknownMemberType] # provider content blocks are untyped JSON - for block in content # pyright: ignore[reportUnknownArgumentType, reportUnknownVariableType] # provider content blocks are untyped JSON - ): + if reference_blocks: # citation chunks carry no text; expose them on # provider_specific_fields instead of dropping them existing_fields = delta.get("provider_specific_fields") # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # provider_specific_fields comes from the provider payload, which is untyped JSON if isinstance(existing_fields, dict): # keep whatever the provider already put there - existing_fields["mistral_content_blocks"] = content + existing_fields["mistral_reference_blocks"] = reference_blocks else: - delta["provider_specific_fields"] = {"mistral_content_blocks": content} + delta["provider_specific_fields"] = {"mistral_reference_blocks": reference_blocks} except Exception: # Fall back to default parsing if custom handling fails return super().chunk_parser(chunk) @@ -749,13 +750,20 @@ class MistralChatResponseIterator(OpenAIChatCompletionStreamingHandler): @staticmethod def _normalize_content_blocks( content_blocks: list[dict], - ) -> tuple[str | None, list[dict], str | None]: + ) -> tuple[str | None, list[dict], str | None, Sequence[Mapping[str, object]]]: """ Convert Mistral magistral content blocks into OpenAI-compatible content + thinking_blocks. + + The fourth element carries the citation chunks (`reference` blocks) as + the provider sent them. They are returned separately from the text + because the text is flattened into `content`, where the output + PII-masking hook can sanitise it, while provider-specific fields are + not sanitised. """ text_segments: Final[list[str]] = [] thinking_blocks: Final[list[dict]] = [] reasoning_segments: Final[list[str]] = [] + reference_blocks: Final[list[dict[str, object]]] = [] # mutable-ok: citation chunks per response for block in content_blocks: block_type = block.get("type") @@ -777,7 +785,9 @@ class MistralChatResponseIterator(OpenAIChatCompletionStreamingHandler): ) elif block_type == "text": text_segments.append(block.get("text", "")) + elif block_type == "reference": + reference_blocks.append(block) # pyright: ignore[reportUnknownArgumentType] # provider content blocks are untyped JSON normalized_text: Final = "".join(text_segments) if text_segments else None reasoning_content: Final = "\n".join(reasoning_segments) if reasoning_segments else None - return normalized_text, thinking_blocks, reasoning_content + return normalized_text, thinking_blocks, reasoning_content, reference_blocks diff --git a/tests/unit/llms/mistral/test_mistral_chat_transformation.py b/tests/unit/llms/mistral/test_mistral_chat_transformation.py index bdfc00acaef..914cdac894c 100644 --- a/tests/unit/llms/mistral/test_mistral_chat_transformation.py +++ b/tests/unit/llms/mistral/test_mistral_chat_transformation.py @@ -1179,6 +1179,7 @@ class TestMistralContentListConversion: {"type": "reference", "reference_ids": ["doc1"]}, {"type": "text", "text": "."}, ] + REFERENCE_BLOCK = {"type": "reference", "reference_ids": ["doc1"]} def test_non_streaming_concatenates_all_text_chunks(self): response_data = {"choices": [{"message": {"role": "assistant", "content": self.CITATION_CONTENT}}]} @@ -1194,7 +1195,24 @@ class TestMistralContentListConversion: MistralConfig._handle_content_list_to_str_conversion(response_data) message = response_data["choices"][0]["message"] - assert message["mistral_content_blocks"] == self.CITATION_CONTENT + assert message["mistral_reference_blocks"] == [self.REFERENCE_BLOCK] + + def test_non_streaming_exposed_reference_blocks_carry_no_free_text(self): + # The output PII-masking hook sanitises `message.content` but not + # provider-specific fields, so the exposed copy must not repeat the + # text -- a second copy would reach the client unmasked. + content = [ + {"type": "text", "text": "card 4111 1111 1111 1111"}, + {"type": "reference", "reference_ids": ["doc1"]}, + ] + response_data = {"choices": [{"message": {"role": "assistant", "content": content}}]} + + MistralConfig._handle_content_list_to_str_conversion(response_data) + + message = response_data["choices"][0]["message"] + exposed = message["mistral_reference_blocks"] + assert exposed == [self.REFERENCE_BLOCK] + assert not any("text" in block for block in exposed) def test_non_streaming_adds_no_content_blocks_key_without_citations(self): content = [{"type": "text", "text": "plain answer"}] @@ -1204,7 +1222,7 @@ class TestMistralContentListConversion: message = response_data["choices"][0]["message"] assert message["content"] == "plain answer" - assert "mistral_content_blocks" not in message + assert "mistral_reference_blocks" not in message def test_non_streaming_thinking_blocks_still_extracted(self): response_data = { @@ -1253,7 +1271,7 @@ class TestMistralContentListConversion: ) provider_specific_fields = model_response.choices[0].message.provider_specific_fields - assert provider_specific_fields["mistral_content_blocks"] == self.CITATION_CONTENT + assert provider_specific_fields["mistral_reference_blocks"] == [self.REFERENCE_BLOCK] def test_streaming_mixed_blocks_concatenate_and_expose_references(self): iterator = MistralChatResponseIterator(streaming_response=iter([]), sync_stream=True) @@ -1263,7 +1281,7 @@ class TestMistralContentListConversion: 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 + assert delta.provider_specific_fields["mistral_reference_blocks"] == [self.REFERENCE_BLOCK] def test_streaming_reference_only_delta_exposes_references(self): iterator = MistralChatResponseIterator(streaming_response=iter([]), sync_stream=True) @@ -1274,4 +1292,4 @@ class TestMistralContentListConversion: delta = result.choices[0].delta assert not delta.content - assert delta.provider_specific_fields["mistral_content_blocks"] == content + assert delta.provider_specific_fields["mistral_reference_blocks"] == content