From 3f5491448e77306a45f602a26ebdfd6fc51d41b1 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:21:42 +0000 Subject: [PATCH] fix(translation): remove the _flush_user out-param mutation (critic-openai M2) The accumulator escaped into a callee that appended to it through an out-parameter, waved through with the annotation meant for the opposite case. _user_message is now pure (returns zero-or-one user message; call sites concatenate), and the remaining genuinely-local accumulators carry the one-line justification the convention requires. --- .../providers/openai_compat/messages.py | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/litellm/translation/providers/openai_compat/messages.py b/litellm/translation/providers/openai_compat/messages.py index 730fd27b0e..622f8373dd 100644 --- a/litellm/translation/providers/openai_compat/messages.py +++ b/litellm/translation/providers/openai_compat/messages.py @@ -31,6 +31,7 @@ def serialize_messages(request: ChatRequest) -> list[PlainJson] | TranslationErr ) if isinstance(emitted, TranslationError): return emitted + # local build-then-freeze accumulator; never escapes this function out.extend(emitted) # nosemgrep: translation-no-mutation return out @@ -41,7 +42,9 @@ def _user_messages(message: Message) -> list[PlainJson] | TranslationError: image_count = 0 for block in message.content: if block.tag == "tool_result": - _flush_user(out, parts, image_count) + # local build-then-freeze accumulators; never escape this function + flushed = _user_message(parts, image_count) + out.extend(flushed) # nosemgrep: translation-no-mutation parts = [] image_count = 0 out.append( # nosemgrep: translation-no-mutation @@ -58,21 +61,18 @@ def _user_messages(message: Message) -> list[PlainJson] | TranslationError: return TranslationError.of_unsupported( f"{block.tag} block in a user turn has no OpenAI wire form; v1 handles it" ) - _flush_user(out, parts, image_count) - return out + return [*out, *_user_message(parts, image_count)] -def _flush_user(out: list[PlainJson], parts: list[PlainJson], image_count: int) -> None: +def _user_message(parts: list[PlainJson], image_count: int) -> list[PlainJson]: + """Pure flush: the pending content parts as zero or one user message.""" if not parts: - return + return [] if len(parts) == 1 and image_count == 0: only = parts[0] text = only.get("text") if isinstance(only, dict) else None - out.append( # nosemgrep: translation-no-mutation - {"role": "user", "content": text} - ) - return - out.append({"role": "user", "content": parts}) # nosemgrep: translation-no-mutation + return [{"role": "user", "content": text}] + return [{"role": "user", "content": parts}] def _image_part(image: Image) -> PlainJson: @@ -105,6 +105,8 @@ def _assistant_message(message: Message) -> list[PlainJson] | TranslationError: texts: list[str] = [] tool_calls: list[PlainJson] = [] for block in message.content: + # texts/tool_calls are local build-then-freeze accumulators; they + # never escape this function if block.tag == "text": texts.append(block.text.text) # nosemgrep: translation-no-mutation elif block.tag == "tool_use":