feat(prompt_templates): opt-in LITELLM_DROP_MIDTURN_SYSTEM removes system turns after the leading run

Strict chat templates (Qwen3 on vLLM) reject a system row that is not first.
The Anthropic bridge now converts such turns to user turns with an operator
note; operators who would rather not deliver the reminder at all can set
LITELLM_DROP_MIDTURN_SYSTEM=true to remove them before placement, on the
/v1/messages bridge and on the hosted_vllm chat completions path. Callers
that pass preserve_midturn_system=True (guardrails, compaction) still see
the turns. Off by default.
This commit is contained in:
dev 2026-09-26 03:36:41 -04:00
parent cda19d6a29
commit 7a23771a24
6 changed files with 142 additions and 7 deletions

View file

@ -29,6 +29,7 @@ prompt cache readable across turns. Messages are handled in OpenAI format; the
Anthropic wire shape is built later by ``anthropic_messages_pt``.
"""
import os
from collections.abc import Iterable, Iterator, Mapping, Sequence
from itertools import accumulate, chain, dropwhile, groupby, takewhile
from operator import itemgetter
@ -55,6 +56,8 @@ CONVERTED_SYSTEM_NOTE: Final = (
"Operator note (not from the user): the following was originally a mid-conversation system-role reminder."
)
DROP_MID_CONVERSATION_SYSTEM_ENV: Final = "LITELLM_DROP_MIDTURN_SYSTEM"
_USER_TYPE_ROLES: Final = frozenset({"user", "tool", "function"})
_TOOL_ROLES: Final = frozenset({"tool", "function"})
_NO_IDS: Final[frozenset[str]] = frozenset()
@ -104,6 +107,22 @@ def _kind(message: object) -> _MessageKind:
return "other"
def drops_mid_conversation_system() -> bool:
"""``LITELLM_DROP_MIDTURN_SYSTEM=true`` removes every system message after the leading run
before any placement runs, for operators whose backend must never see one (a strict chat
template) and who would rather lose the reminder than deliver it as a user turn."""
return os.getenv(DROP_MID_CONVERSATION_SYSTEM_ENV, "").strip().lower() == "true"
def drop_mid_conversation_system(messages: Sequence[_Message]) -> tuple[_Message, ...]:
"""``messages`` without the system messages that follow the leading run."""
leading_count: Final = next(
(index for index, message in enumerate(messages) if not is_system_message(message)),
len(messages),
)
return (*messages[:leading_count], *(m for m in messages[leading_count:] if not is_system_message(m)))
def split_leading_system_run(
messages: Sequence[_Message],
) -> tuple[tuple[_Message, ...], tuple[_Message, ...]]:

View file

@ -127,6 +127,10 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
from litellm.litellm_core_utils.prompt_templates.factory import (
THOUGHT_SIGNATURE_SEPARATOR,
)
from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import (
drop_mid_conversation_system,
drops_mid_conversation_system,
)
from litellm.litellm_core_utils.reasoning_effort_utils import (
reasoning_effort_from_thinking_budget,
)
@ -467,7 +471,12 @@ class LiteLLMAnthropicMessagesAdapter:
preserve_midturn_system: bool = False,
) -> list[AllMessageValues]:
new_messages: Final[list[AllMessageValues]] = []
replayable_messages: Final = strip_encrypted_reasoning_blocks_from_anthropic_messages(messages)
kept_messages: Final = (
messages
if preserve_midturn_system or not drops_mid_conversation_system()
else drop_mid_conversation_system(messages)
)
replayable_messages: Final = strip_encrypted_reasoning_blocks_from_anthropic_messages(kept_messages)
leading_count: Final = next(
(i for i, m in enumerate(replayable_messages) if not is_system_role_message(m)),
len(replayable_messages),

View file

@ -10,6 +10,10 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
get_image_mime_type_from_url,
)
from litellm.litellm_core_utils.prompt_templates.factory import parse_mime_type
from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import (
drop_mid_conversation_system,
drops_mid_conversation_system,
)
from litellm.litellm_core_utils.reasoning_effort_utils import (
reasoning_effort_from_thinking_budget,
)
@ -170,8 +174,13 @@ class HostedVLLMChatConfig(OpenAIGPTConfig):
- video files from file_id or file_data to video_url
- thinking_blocks and non-string reasoning_content on assistant messages
are removed, and content lists are converted to strings for vLLM compatibility
- system messages after the leading run are removed when
LITELLM_DROP_MIDTURN_SYSTEM=true, for chat templates that reject them
"""
for message in messages:
kept_messages: Final = (
list(drop_mid_conversation_system(messages)) if drops_mid_conversation_system() else messages
)
for message in kept_messages:
if message["role"] == "assistant":
message.pop("thinking_blocks", None)
if not isinstance(message.get("reasoning_content"), str):
@ -241,6 +250,6 @@ class HostedVLLMChatConfig(OpenAIGPTConfig):
message_content[idx] = self._convert_file_to_video_url(content_item)
if is_async:
return super()._transform_messages(messages, model, is_async=cast(Literal[True], True))
return super()._transform_messages(kept_messages, model, is_async=cast(Literal[True], True))
else:
return super()._transform_messages(messages, model, is_async=cast(Literal[False], False))
return super()._transform_messages(kept_messages, model, is_async=cast(Literal[False], False))

View file

@ -15,7 +15,10 @@ import litellm
from litellm.litellm_core_utils.prompt_templates.common_utils import encrypted_reasoning_signature
from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import (
CONVERTED_SYSTEM_NOTE,
DROP_MID_CONVERSATION_SYSTEM_ENV,
anthropic_system_blocks,
drop_mid_conversation_system,
drops_mid_conversation_system,
place_mid_conversation_system,
split_leading_system_run,
)
@ -523,3 +526,32 @@ def test_anthropic_system_blocks_keeps_text_parts_with_their_cache_control_and_d
{"type": "text", "text": "one", "cache_control": {"type": "ephemeral", "ttl": "1h"}},
{"type": "text", "text": "two", "cache_control": {"type": "ephemeral"}},
)
def test_drop_mid_conversation_system_keeps_the_leading_run_and_removes_every_later_system_message():
messages = [
{"role": "system", "content": "one"},
{"role": "system", "content": "two"},
{"role": "user", "content": "q1"},
{"role": "system", "content": "reminder"},
{"role": "assistant", "content": "a1"},
{"role": "system", "content": "trailing"},
]
kept = drop_mid_conversation_system(messages)
assert [m["content"] for m in kept] == ["one", "two", "q1", "a1"]
assert drop_mid_conversation_system(kept) == kept
@pytest.mark.parametrize(
("value", "expected"),
[("true", True), ("TRUE", True), (" true ", True), ("false", False), ("drop", False), ("1", False), (None, False)],
)
def test_drops_mid_conversation_system_only_on_a_literal_true(monkeypatch, value: str | None, expected: bool):
if value is None:
monkeypatch.delenv(DROP_MID_CONVERSATION_SYSTEM_ENV, raising=False)
else:
monkeypatch.setenv(DROP_MID_CONVERSATION_SYSTEM_ENV, value)
assert drops_mid_conversation_system() is expected

View file

@ -7,9 +7,6 @@ from typing import Any, Final, cast
import pytest
import litellm
from litellm.litellm_core_utils.prompt_templates.common_utils import (
TOOL_RESULT_IMAGE_PLACEHOLDER,
encrypted_reasoning_signature,
@ -18,6 +15,7 @@ from litellm.litellm_core_utils.prompt_templates.factory import (
THOUGHT_SIGNATURE_SEPARATOR,
_bedrock_converse_messages_pt,
)
from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import DROP_MID_CONVERSATION_SYSTEM_ENV
from litellm.llms.anthropic.pass_through.adapters.transformation import (
OPENAI_MAX_TOOL_NAME_LENGTH,
AnthropicAdapter,
@ -881,6 +879,43 @@ def test_translate_anthropic_to_openai_converts_claude_code_midturn_system_turn(
assert converted["content"][1]["text"] == "<system-reminder>Keep answers to one sentence.</system-reminder>"
@pytest.mark.parametrize("custom_llm_provider", [None, "hosted_vllm"])
def test_translate_anthropic_to_openai_drops_midturn_system_when_flag_set(monkeypatch, custom_llm_provider: str | None):
"""
With LITELLM_DROP_MIDTURN_SYSTEM=true the harness reminder is removed before placement, so the
outbound request carries neither a system row after index 0 nor the operator-note user turn.
"""
monkeypatch.setenv(DROP_MID_CONVERSATION_SYSTEM_ENV, "true")
openai_request, _ = LiteLLMAnthropicMessagesAdapter().translate_anthropic_to_openai(
anthropic_message_request={"model": "qwen3.8-27B", **_CLAUDE_CODE_MIDTURN_SYSTEM_REQUEST},
custom_llm_provider=custom_llm_provider,
)
assert openai_request["messages"] == [
{"role": "system", "content": [{"type": "text", "text": "You are Claude Code."}]},
{"role": "user", "content": "say hi"},
{"role": "assistant", "content": "Hi.", "thinking_blocks": None},
{"role": "user", "content": "say bye"},
]
def test_translate_anthropic_to_openai_drop_flag_yields_to_preserve_midturn_system(monkeypatch):
"""
Guardrail and compaction callers pass preserve_midturn_system=True to scan the conversation as
sent; the drop flag must not hide the reminder from them.
"""
monkeypatch.setenv(DROP_MID_CONVERSATION_SYSTEM_ENV, "true")
openai_request, _ = LiteLLMAnthropicMessagesAdapter().translate_anthropic_to_openai(
anthropic_message_request={"model": "qwen3.8-27B", **_CLAUDE_CODE_MIDTURN_SYSTEM_REQUEST},
custom_llm_provider="hosted_vllm",
preserve_midturn_system=True,
)
assert [m["role"] for m in openai_request["messages"]] == ["system", "user", "system", "assistant", "user"]
def test_translate_anthropic_to_openai_keeps_midturn_system_when_target_declares_support(monkeypatch):
"""
A chat-completions target flagged ``supports_mid_conversation_system`` in the cost map accepts

View file

@ -8,6 +8,7 @@ from litellm.constants import (
DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET,
DEFAULT_REASONING_EFFORT_LOW_THINKING_BUDGET,
)
from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import DROP_MID_CONVERSATION_SYSTEM_ENV
from litellm.llms.hosted_vllm.chat.transformation import HostedVLLMChatConfig
@ -397,3 +398,33 @@ def test_hosted_vllm_custom_tools_use_top_level_input_schema():
assert tools[0]["function"]["name"] == "search"
assert tools[0]["function"]["description"] == "Search docs"
assert tools[0]["function"]["parameters"] == input_schema
@pytest.mark.parametrize(
("flag", "expected_roles"),
[("true", ["system", "user", "assistant", "user"]), (None, ["system", "user", "system", "assistant", "user"])],
ids=["drop", "default"],
)
@pytest.mark.parametrize("is_async", [False, True], ids=["sync", "async"])
@pytest.mark.asyncio
async def test_transform_messages_drops_midturn_system_only_when_flag_set(monkeypatch, flag, expected_roles, is_async):
if flag is None:
monkeypatch.delenv(DROP_MID_CONVERSATION_SYSTEM_ENV, raising=False)
else:
monkeypatch.setenv(DROP_MID_CONVERSATION_SYSTEM_ENV, flag)
messages = [
{"role": "system", "content": "You are Claude Code."},
{"role": "user", "content": "say hi"},
{"role": "system", "content": "<system-reminder>Keep answers to one sentence.</system-reminder>"},
{"role": "assistant", "content": "Hi."},
{"role": "user", "content": "say bye"},
]
config = HostedVLLMChatConfig()
transformed = (
await config._transform_messages(messages, model="qwen3.8-27B", is_async=True)
if is_async
else config._transform_messages(messages, model="qwen3.8-27B")
)
assert [m["role"] for m in transformed] == expected_roles