From 8a41e1033257f546a5c1c711c99ff9340ddd1af5 Mon Sep 17 00:00:00 2001 From: shivam Date: Wed, 16 Sep 2026 21:47:46 +0000 Subject: [PATCH] fix(anthropic-bridge): convert mid-conversation system turns to user turns on /v1/messages to chat completions Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../adapters/transformation.py | 16 ++- .../messages/mid_conversation_system.py | 84 ++++++++++++ .../messages/transformation.py | 81 ++--------- ...al_pass_through_adapters_transformation.py | 129 ++++++++++++++++-- .../messages/test_mid_conversation_system.py | 62 +++++++++ .../test_anthropic_claude3_transformation.py | 17 ++- 6 files changed, 297 insertions(+), 92 deletions(-) create mode 100644 litellm/llms/anthropic/experimental_pass_through/messages/mid_conversation_system.py create mode 100644 tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_mid_conversation_system.py diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index 8ff9f2e0679..6b47b010cc6 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -118,6 +118,10 @@ from litellm.llms.anthropic.common_utils import ( from litellm.llms.anthropic.experimental_pass_through.context_management import ( PolyfillResult, ) +from litellm.llms.anthropic.experimental_pass_through.messages.mid_conversation_system import ( + convert_mid_conversation_system_turns, + is_system_role_message, +) from litellm.llms.anthropic.experimental_pass_through.messages.utils import ( openai_chat_refusal_text, refusal_stop_details, @@ -421,7 +425,15 @@ class LiteLLMAnthropicMessagesAdapter: ) -> list: new_messages: Final[list[AllMessageValues]] = [] replayable_messages: Final = strip_encrypted_reasoning_blocks_from_anthropic_messages(messages) - for m in replayable_messages: + leading_count: Final = next( + (i for i, m in enumerate(replayable_messages) if not is_system_role_message(m)), + len(replayable_messages), + ) + ordered_messages: Final = ( + *replayable_messages[:leading_count], + *convert_mid_conversation_system_turns(replayable_messages[leading_count:]), + ) + for m in ordered_messages: user_message: ChatCompletionUserMessage | None = None tool_message_list: list[ChatCompletionToolMessage] = [] new_user_content_list: list[ChatCompletionTextObject | ChatCompletionImageObject] = [] @@ -494,7 +506,7 @@ class LiteLLMAnthropicMessagesAdapter: if isinstance(m.get("content"), str): assistant_message_str = str(m.get("content", "")) elif isinstance(m.get("content"), list): - for content in m.get("content", []): + for content in cast(list, m.get("content", [])): if isinstance(content, str): assistant_message_str = str(content) elif isinstance(content, dict): diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/mid_conversation_system.py b/litellm/llms/anthropic/experimental_pass_through/messages/mid_conversation_system.py new file mode 100644 index 00000000000..c4fd7bcd320 --- /dev/null +++ b/litellm/llms/anthropic/experimental_pass_through/messages/mid_conversation_system.py @@ -0,0 +1,84 @@ +from collections.abc import Mapping, Sequence +from typing import Final + +CONVERTED_SYSTEM_NOTE: Final = ( + "Operator note (not from the user): the following was originally a mid-conversation system-role reminder." +) + + +def as_system_content_blocks(value: object) -> list[object]: + if value is None: + return [] + if isinstance(value, list): + return list(value) + if isinstance(value, str): + return [{"type": "text", "text": value}] + return [value] + + +def is_system_role_message(message: object) -> bool: + return isinstance(message, dict) and message.get("role") == "system" + + +def system_role_message_as_user(message: Mapping[str, object]) -> Mapping[str, object]: + return { + "role": "user", + "content": as_system_content_blocks(CONVERTED_SYSTEM_NOTE) + as_system_content_blocks(message.get("content")), + } + + +def opens_with_tool_results(message: object) -> bool: + if not isinstance(message, dict) or message.get("role") != "user": + return False + content: Final = message.get("content") + return ( + isinstance(content, list) + and len(content) > 0 + and isinstance(content[0], dict) + and content[0].get("type") == "tool_result" + ) + + +def system_run_before(messages: Sequence[Mapping[str, object]], index: int) -> Sequence[Mapping[str, object]]: + start: Final = next( + (j + 1 for j in range(index - 1, -1, -1) if not is_system_role_message(messages[j])), + 0, + ) + return messages[start:index] + + +def system_run_end(messages: Sequence[Mapping[str, object]], index: int) -> int: + return next( + (j for j in range(index, len(messages)) if not is_system_role_message(messages[j])), + len(messages), + ) + + +def reordered_around_tool_results( + messages: Sequence[Mapping[str, object]], index: int +) -> tuple[Mapping[str, object], ...]: + message: Final = messages[index] + if opens_with_tool_results(message): + return (message, *system_run_before(messages, index)) + if not is_system_role_message(message): + return (message,) + run_end: Final = system_run_end(messages, index) + follower: Final = messages[run_end] if run_end < len(messages) else None + return () if opens_with_tool_results(follower) else (message,) + + +def system_turns_after_tool_results( + messages: Sequence[Mapping[str, object]], +) -> tuple[Mapping[str, object], ...]: + return tuple( + message for index in range(len(messages)) for message in reordered_around_tool_results(messages, index) + ) + + +def convert_mid_conversation_system_turns( + messages: Sequence[Mapping[str, object]], +) -> tuple[Mapping[str, object], ...]: + return tuple( + system_role_message_as_user(m) if is_system_role_message(m) else m + for m in system_turns_after_tool_results(messages) + ) diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py index 27cdac34116..5fa686b7560 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py @@ -27,6 +27,11 @@ from ...common_utils import ( strip_advisor_blocks_from_messages, strip_encrypted_reasoning_blocks_from_anthropic_messages, ) +from .mid_conversation_system import ( + as_system_content_blocks, + convert_mid_conversation_system_turns, + is_system_role_message, +) DEFAULT_ANTHROPIC_API_VERSION: Final = "2023-06-01" @@ -151,73 +156,6 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): else: return system_param - @staticmethod - def _as_system_content_blocks(value: object) -> list: - if value is None: - return [] - if isinstance(value, list): - return list(value) - if isinstance(value, str): - return [{"type": "text", "text": value}] - return [value] - - @staticmethod - def _is_system_role_message(message: object) -> bool: - return isinstance(message, dict) and message.get("role") == "system" - - _CONVERTED_SYSTEM_NOTE: Final = ( - "Operator note (not from the user): the following was originally a mid-conversation system-role reminder." - ) - - def _system_role_message_as_user(self, message: Mapping) -> Mapping: - return { - "role": "user", - "content": self._as_system_content_blocks(self._CONVERTED_SYSTEM_NOTE) - + self._as_system_content_blocks(message.get("content")), - } - - @staticmethod - def _opens_with_tool_results(message: object) -> bool: - if not isinstance(message, dict) or message.get("role") != "user": - return False - content: Final = message.get("content") - return ( - isinstance(content, list) - and len(content) > 0 - and isinstance(content[0], dict) - and content[0].get("type") == "tool_result" - ) - - def _system_run_before(self, messages: Sequence, index: int) -> Sequence: - start: Final = next( - (j + 1 for j in range(index - 1, -1, -1) if not self._is_system_role_message(messages[j])), - 0, - ) - return messages[start:index] - - def _system_run_end(self, messages: Sequence, index: int) -> int: - return next( - (j for j in range(index, len(messages)) if not self._is_system_role_message(messages[j])), - len(messages), - ) - - def _reordered_around_tool_results(self, messages: Sequence, index: int) -> tuple: - message: Final = messages[index] - if self._opens_with_tool_results(message): - return (message, *self._system_run_before(messages, index)) - if not self._is_system_role_message(message): - return (message,) - run_end: Final = self._system_run_end(messages, index) - follower: Final = messages[run_end] if run_end < len(messages) else None - return () if self._opens_with_tool_results(follower) else (message,) - - def _system_turns_after_tool_results(self, messages: Sequence) -> tuple: - return tuple( - message - for index in range(len(messages)) - for message in self._reordered_around_tool_results(messages, index) - ) - def _normalize_system_role_messages(self, anthropic_messages_request: dict, model: str) -> None: """Normalize ``role: "system"`` entries in ``messages`` per the Anthropic ``/v1/messages`` contract, which the first-party API, Bedrock Invoke, @@ -254,7 +192,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): if not isinstance(messages, list): return leading_count: Final = next( - (i for i, m in enumerate(messages) if not self._is_system_role_message(m)), + (i for i, m in enumerate(messages) if not is_system_role_message(m)), len(messages), ) hoisted: Final = messages[:leading_count] @@ -265,10 +203,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): custom_llm_provider=self.custom_llm_provider, key="supports_mid_conversation_system", ) - else [ - self._system_role_message_as_user(m) if self._is_system_role_message(m) else m - for m in self._system_turns_after_tool_results(messages[leading_count:]) - ] + else list(convert_mid_conversation_system_turns(messages[leading_count:])) ) if hoisted or remaining != messages: anthropic_messages_request["messages"] = remaining @@ -278,7 +213,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): anthropic_messages_request.get("system"), *(m.get("content") for m in hoisted), ) - for block in self._as_system_content_blocks(source) + for block in as_system_content_blocks(source) ] filtered_system: Final = self._filter_billing_headers_from_system(system_content) if filtered_system: diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py index 03b9840b1c3..ad98a817a1a 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py @@ -23,6 +23,9 @@ from litellm.llms.anthropic.experimental_pass_through.adapters.transformation im create_tool_name_mapping, truncate_tool_name, ) +from litellm.llms.anthropic.experimental_pass_through.messages.mid_conversation_system import ( + CONVERTED_SYSTEM_NOTE, +) from litellm.llms.openai.chat.gpt_transformation import OpenAIGPTConfig from litellm.types.llms.anthropic import ( AnthopicMessagesAssistantMessageParam, @@ -563,10 +566,19 @@ def test_translate_anthropic_messages_to_openai_tool_message_placement(): @pytest.mark.parametrize( ("system_content", "expected_content"), [ - ("Use the corrected result.", "Use the corrected result."), + ( + "Use the corrected result.", + [ + {"type": "text", "text": CONVERTED_SYSTEM_NOTE}, + {"type": "text", "text": "Use the corrected result."}, + ], + ), ( [{"type": "text", "text": "Use the corrected result."}], - [{"type": "text", "text": "Use the corrected result."}], + [ + {"type": "text", "text": CONVERTED_SYSTEM_NOTE}, + {"type": "text", "text": "Use the corrected result."}, + ], ), ( [ @@ -576,7 +588,11 @@ def test_translate_anthropic_messages_to_openai_tool_message_placement(): }, {"type": "text", "text": "Use the corrected result."}, ], - [{"type": "text", "text": "Use the corrected result."}], + [ + {"type": "text", "text": CONVERTED_SYSTEM_NOTE}, + {"type": "image_url", "image_url": {"url": "https://example.com/a.png"}}, + {"type": "text", "text": "Use the corrected result."}, + ], ), ( [ @@ -584,13 +600,14 @@ def test_translate_anthropic_messages_to_openai_tool_message_placement(): {"type": "text", "text": "Second correction."}, ], [ + {"type": "text", "text": CONVERTED_SYSTEM_NOTE}, {"type": "text", "text": "First correction."}, {"type": "text", "text": "Second correction."}, ], ), ], ) -def test_translate_anthropic_messages_to_openai_preserves_midturn_system_correction( +def test_translate_anthropic_messages_to_openai_converts_midturn_system_correction( system_content: object, expected_content: object, ): @@ -646,7 +663,7 @@ def test_translate_anthropic_messages_to_openai_preserves_midturn_system_correct "tool_call_id": "toolu_01234", "content": "Rainy, 55°F", }, - {"role": "system", "content": expected_content}, + {"role": "user", "content": expected_content}, {"role": "user", "content": "Continue."}, ] @@ -752,8 +769,8 @@ def test_translate_anthropic_messages_to_openai_drops_empty_midturn_system( def test_translate_anthropic_to_openai_orders_top_level_and_midturn_system(): """ Request level: the trusted top-level prompt is hoisted to index 0 exactly once and the - in-sequence correction keeps its own position and `role: "system"` -- no duplication of - either, and no reordering of the surrounding turns. + in-sequence correction keeps its own position as a user turn prefixed with the operator + note -- no duplication of either, and no reordering of the surrounding turns. """ openai_request, _ = LiteLLMAnthropicMessagesAdapter().translate_anthropic_to_openai( anthropic_message_request={ @@ -773,11 +790,107 @@ def test_translate_anthropic_to_openai_orders_top_level_and_midturn_system(): {"role": "system", "content": "Trusted top-level prompt."}, {"role": "user", "content": "First question."}, {"role": "assistant", "content": "First answer.", "thinking_blocks": None}, - {"role": "system", "content": "Use the corrected result."}, + { + "role": "user", + "content": [ + {"type": "text", "text": CONVERTED_SYSTEM_NOTE}, + {"type": "text", "text": "Use the corrected result."}, + ], + }, {"role": "user", "content": "Continue."}, ] +def test_translate_anthropic_to_openai_converts_claude_code_midturn_system_turn(): + """ + Claude Code appends a system-role harness reminder after the user turn. On a + chat-completions target the outbound request must have exactly one system message, + at index 0, and the converted turn must carry the operator note first. + """ + openai_request, _ = LiteLLMAnthropicMessagesAdapter().translate_anthropic_to_openai( + anthropic_message_request={ + "model": "qwen3.8-27B", + "max_tokens": 128, + "system": [{"type": "text", "text": "You are Claude Code."}], + "messages": [ + {"role": "user", "content": "say hi"}, + { + "role": "system", + "content": [ + {"type": "text", "text": "Keep answers to one sentence."} + ], + }, + {"role": "assistant", "content": "Hi."}, + {"role": "user", "content": "say bye"}, + ], + } + ) + + roles = [m["role"] for m in openai_request["messages"]] + assert roles == ["system", "user", "user", "assistant", "user"] + converted = openai_request["messages"][2] + assert converted["content"][0]["text"] == CONVERTED_SYSTEM_NOTE + assert converted["content"][1]["text"] == "Keep answers to one sentence." + + +def test_translate_anthropic_to_openai_moves_midturn_system_after_tool_result(): + """ + A system entry wedged between an assistant tool_use turn and its tool_result turn is + emitted after the role: "tool" message, so the tool call stays paired with its result. + """ + result = LiteLLMAnthropicMessagesAdapter().translate_anthropic_messages_to_openai( + messages=[ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_01234", + "name": "get_weather", + "input": {"location": "Boston"}, + } + ], + }, + {"role": "system", "content": "Use the corrected result."}, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_01234", + "content": "Rainy, 55°F", + } + ], + }, + ], + model="claude-3-5-sonnet-20240620", + ) + + assert [m["role"] for m in result] == ["assistant", "tool", "user"] + assert result[2]["content"][0]["text"] == CONVERTED_SYSTEM_NOTE + + +def test_translate_anthropic_messages_to_openai_converts_string_midturn_system(): + result = LiteLLMAnthropicMessagesAdapter().translate_anthropic_messages_to_openai( + messages=[ + {"role": "user", "content": "hi"}, + {"role": "system", "content": "Keep it short."}, + ], + model="claude-3-5-sonnet-20240620", + ) + + assert result == [ + {"role": "user", "content": "hi"}, + { + "role": "user", + "content": [ + {"type": "text", "text": CONVERTED_SYSTEM_NOTE}, + {"type": "text", "text": "Keep it short."}, + ], + }, + ] + + def _claude_code_user_id(session_id: str) -> str: return json.dumps({"device_id": "d" * 64, "account_uuid": "", "session_id": session_id}) diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_mid_conversation_system.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_mid_conversation_system.py new file mode 100644 index 00000000000..776dbd98833 --- /dev/null +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_mid_conversation_system.py @@ -0,0 +1,62 @@ +from litellm.llms.anthropic.experimental_pass_through.messages.mid_conversation_system import ( + CONVERTED_SYSTEM_NOTE, + convert_mid_conversation_system_turns, +) + + +def test_convert_mid_conversation_system_turns_converts_system_to_user_in_place(): + result = convert_mid_conversation_system_turns( + [ + {"role": "user", "content": "hi"}, + {"role": "system", "content": [{"type": "text", "text": "Keep it short."}]}, + {"role": "assistant", "content": "Hi."}, + ] + ) + + assert result == ( + {"role": "user", "content": "hi"}, + { + "role": "user", + "content": [ + {"type": "text", "text": CONVERTED_SYSTEM_NOTE}, + {"type": "text", "text": "Keep it short."}, + ], + }, + {"role": "assistant", "content": "Hi."}, + ) + + +def test_convert_mid_conversation_system_turns_wraps_string_content(): + result = convert_mid_conversation_system_turns( + [ + {"role": "user", "content": "hi"}, + {"role": "system", "content": "Keep it short."}, + ] + ) + + assert result[1] == { + "role": "user", + "content": [ + {"type": "text", "text": CONVERTED_SYSTEM_NOTE}, + {"type": "text", "text": "Keep it short."}, + ], + } + + +def test_convert_mid_conversation_system_turns_moves_system_after_tool_result(): + assistant_tool_use = { + "role": "assistant", + "content": [{"type": "tool_use", "id": "toolu_1", "name": "get_weather", "input": {}}], + } + wedged_system = {"role": "system", "content": "Use the corrected result."} + tool_result = { + "role": "user", + "content": [{"type": "tool_result", "tool_use_id": "toolu_1", "content": "Rainy"}], + } + + result = convert_mid_conversation_system_turns([assistant_tool_use, wedged_system, tool_result]) + + assert result[0] is assistant_tool_use + assert result[1] is tool_result + assert result[2]["role"] == "user" + assert result[2]["content"][0]["text"] == CONVERTED_SYSTEM_NOTE diff --git a/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py b/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py index 09ebc1a3c95..80f917e0578 100644 --- a/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py +++ b/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py @@ -23,6 +23,9 @@ from litellm.constants import ( DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET, DEFAULT_REASONING_EFFORT_XHIGH_THINKING_BUDGET, ) +from litellm.llms.anthropic.experimental_pass_through.messages.mid_conversation_system import ( + as_system_content_blocks, +) from litellm.llms.bedrock.messages.invoke_transformations.anthropic_claude3_transformation import ( AmazonAnthropicClaudeMessagesConfig, AmazonAnthropicClaudeMessagesStreamDecoder, @@ -2533,20 +2536,16 @@ def test_bedrock_claude_4_8_plus_cost_map_entries_carry_mid_conversation_system_ def test_as_system_content_blocks_handles_each_shape(): - """``_as_system_content_blocks`` normalizes every system shape: ``None`` -> empty, + """``as_system_content_blocks`` normalizes every system shape: ``None`` -> empty, a string -> a single text block, a list -> a shallow copy, and any other value (e.g. a bare content-block dict) -> wrapped in a single-element list.""" block = {"type": "text", "text": "x"} - assert AmazonAnthropicClaudeMessagesConfig._as_system_content_blocks(None) == [] - assert AmazonAnthropicClaudeMessagesConfig._as_system_content_blocks("hello") == [ - {"type": "text", "text": "hello"} - ] + assert as_system_content_blocks(None) == [] + assert as_system_content_blocks("hello") == [{"type": "text", "text": "hello"}] blocks = [block] - out = AmazonAnthropicClaudeMessagesConfig._as_system_content_blocks(blocks) + out = as_system_content_blocks(blocks) assert out == blocks and out is not blocks - assert AmazonAnthropicClaudeMessagesConfig._as_system_content_blocks(block) == [ - block - ] + assert as_system_content_blocks(block) == [block] @pytest.mark.parametrize(