From 965cbb3f87ff060a0b7e8af3da8ab678980fe0d2 Mon Sep 17 00:00:00 2001 From: Sangsiva <101570025+Sangsiva@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:27:28 +0800 Subject: [PATCH 01/10] fix(anthropic): strip Claude Code identity from system prompt for non-Claude providers Claude Code prefixes its system prompt with "You are Claude Code, Anthropic's official CLI for Claude.". When routed to a provider whose Anthropic-compatible endpoint serves a different model (DeepSeek, MiniMax, Tencent, OpenAI-like passthrough), that sentence is a false self-description and should be dropped before sending upstream. This adds a should_strip_claude_code_identity() gate alongside the existing should_strip_billing_metadata() gate, plus a strip_claude_code_identity() helper. The two gates stay distinct: the billing-header strip also fires for Bedrock/Vertex/Azure, which still serve real Claude models, whereas the identity strip only fires for non-Claude models. --- litellm/llms/anthropic/chat/transformation.py | 13 ++ litellm/llms/anthropic/common_utils.py | 22 ++++ .../messages/transformation.py | 59 +++++++++ .../llms/deepseek/messages/transformation.py | 3 + .../llms/minimax/messages/transformation.py | 3 + .../openai_like/messages/transformation.py | 3 + .../llms/tencent/messages/transformation.py | 3 + .../test_anthropic_chat_transformation.py | 114 ++++++++++++++++++ .../anthropic/test_anthropic_common_utils.py | 24 ++++ 9 files changed, 244 insertions(+) diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 1f90d375bc2..bbea06e2cfc 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -1685,6 +1685,19 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): """ return False + def should_strip_claude_code_identity(self) -> bool: + """ + Whether to drop Claude Code's self-identification from the system prompt. + + Distinct from ``should_strip_billing_metadata``: that drops the billing header on + every provider whose request shape rejects it, including Bedrock/Vertex/Azure, which + still serve *Claude* models. This only trips on providers whose + Anthropic-compatible endpoint serves a *different* model, where "You are Claude + Code" is a false self-description. The first-party config and the Claude-serving + providers keep it, so this stays False here. + """ + return False + def translate_system_message(self, messages: list[AllMessageValues]) -> list[AnthropicSystemMessageContent]: """ Translate system message to anthropic format. diff --git a/litellm/llms/anthropic/common_utils.py b/litellm/llms/anthropic/common_utils.py index 98e2f6d5bde..8bd9c56d92e 100644 --- a/litellm/llms/anthropic/common_utils.py +++ b/litellm/llms/anthropic/common_utils.py @@ -77,6 +77,9 @@ _CLAUDE_CODE_OBJECT_LIST_ADAPTER: Final = TypeAdapter(list[object]) _CLAUDE_CODE_USER_AGENT_PREFIXES: Final = ("claude-cli/", "claude-code/") +_CLAUDE_CODE_IDENTITY: Final = "You are Claude Code, Anthropic's official CLI for Claude." + + def supports_anthropic_cache_control(model: str, custom_llm_provider: str | None) -> bool: from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider from litellm.utils import supports_prompt_caching @@ -98,6 +101,25 @@ def is_claude_code_user_agent(user_agent: str) -> bool: return user_agent.startswith(_CLAUDE_CODE_USER_AGENT_PREFIXES) +def strip_claude_code_identity(text: str) -> str | None: + """Remove Claude Code's self-identification sentence from a single system text block. + + Claude Code prefixes its system prompt with ``You are Claude Code, Anthropic's + official CLI for Claude.``. When that prompt is forwarded to a non-Claude model + (e.g. a DeepSeek-compatible or OpenAI-like Anthropic-compatible endpoint), the + sentence is a false self-description and should be dropped before sending + upstream. Returns ``None`` when the text was nothing but the identity sentence + (so callers can drop the whole block), otherwise the text with the sentence and + its framing whitespace removed. + """ + stripped: Final = text.strip() + if stripped == _CLAUDE_CODE_IDENTITY: + return None + if stripped.startswith(_CLAUDE_CODE_IDENTITY): + return text.replace(_CLAUDE_CODE_IDENTITY, "", 1).lstrip("\n") + return text + + def _validated_claude_code_mapping(value: object) -> dict[object, object] | None: try: return _CLAUDE_CODE_OBJECT_MAPPING_ADAPTER.validate_python(value) diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py index 5fa686b7560..641f7410126 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py @@ -25,6 +25,7 @@ from ...common_utils import ( AnthropicModelInfo, optionally_handle_anthropic_oauth, strip_advisor_blocks_from_messages, + strip_claude_code_identity, strip_encrypted_reasoning_blocks_from_anthropic_messages, ) from .mid_conversation_system import ( @@ -122,6 +123,56 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): """ return False + def should_strip_claude_code_identity(self) -> bool: + """ + Whether to drop Claude Code's self-identification from the system prompt. + + Distinct from ``should_strip_billing_metadata``: that drops the billing header on + every provider whose request shape rejects it, including Bedrock/Vertex/Azure, which + still serve *Claude* models. This only trips on providers whose + Anthropic-compatible endpoint serves a *different* model (DeepSeek, MiniMax, + Tencent, OpenAI-like passthrough), where "You are Claude Code" is a false + self-description. The first-party config keeps it. + """ + return False + + @staticmethod + def _strip_claude_code_identity_from_system(system_param): + """ + Strip Claude Code's self-identification sentence from system parameter. + + Args: + system_param: Can be a string or a list of system message content blocks + + Returns: + System parameter with the identity sentence removed, or None if all content was removed + """ + if isinstance(system_param, str): + return strip_claude_code_identity(system_param) + elif isinstance(system_param, list): + filtered_list: Final = [] + for content_block in system_param: + if isinstance(content_block, dict): + text = content_block.get("text", "") + content_type = content_block.get("type", "") + if content_type != "text": + filtered_list.append(content_block) + continue + stripped_text: Final[str | None] = strip_claude_code_identity(text) + if stripped_text is None: + continue + # Only copy the block when the text changed. + if stripped_text == text: + filtered_list.append(content_block) + else: + filtered_list.append({**content_block, "text": stripped_text}) + else: + # Keep non-dict items as-is + filtered_list.append(content_block) + return filtered_list if len(filtered_list) > 0 else None + else: + return system_param + @staticmethod def _filter_billing_headers_from_system(system_param): """ @@ -529,6 +580,14 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): else: anthropic_messages_optional_request_params.pop("system", None) + stripped_identity_system: Final = anthropic_messages_optional_request_params.get("system") + if self.should_strip_claude_code_identity() and stripped_identity_system is not None: + identity_filtered: Final = self._strip_claude_code_identity_from_system(stripped_identity_system) + if identity_filtered is not None and len(identity_filtered) > 0: + anthropic_messages_optional_request_params["system"] = identity_filtered + else: + anthropic_messages_optional_request_params.pop("system", None) + # Transform context_management from OpenAI format to Anthropic format if needed context_management_param: Final = anthropic_messages_optional_request_params.get("context_management") if context_management_param is not None: diff --git a/litellm/llms/deepseek/messages/transformation.py b/litellm/llms/deepseek/messages/transformation.py index 8dd720c464a..d7d44d1cba2 100644 --- a/litellm/llms/deepseek/messages/transformation.py +++ b/litellm/llms/deepseek/messages/transformation.py @@ -29,6 +29,9 @@ class DeepSeekAnthropicMessagesConfig(AnthropicMessagesConfig): def should_strip_billing_metadata(self) -> bool: return True + def should_strip_claude_code_identity(self) -> bool: + return True + @staticmethod def get_api_key(api_key: str | None = None) -> str | None: return api_key or get_secret_str("DEEPSEEK_API_KEY") or litellm.api_key diff --git a/litellm/llms/minimax/messages/transformation.py b/litellm/llms/minimax/messages/transformation.py index d4c24c65cfa..aaa5a46d73f 100644 --- a/litellm/llms/minimax/messages/transformation.py +++ b/litellm/llms/minimax/messages/transformation.py @@ -31,6 +31,9 @@ class MinimaxMessagesConfig(AnthropicMessagesConfig): def should_strip_billing_metadata(self) -> bool: return True + def should_strip_claude_code_identity(self) -> bool: + return True + @staticmethod def get_api_key(api_key: str | None = None) -> str | None: """ diff --git a/litellm/llms/openai_like/messages/transformation.py b/litellm/llms/openai_like/messages/transformation.py index bae190c88c0..28cd069e412 100644 --- a/litellm/llms/openai_like/messages/transformation.py +++ b/litellm/llms/openai_like/messages/transformation.py @@ -32,6 +32,9 @@ class OpenAILikeAnthropicMessagesConfig(AnthropicMessagesConfig): super().__init__() self._cache_control_ttl: Final = cache_control_ttl + def should_strip_claude_code_identity(self) -> bool: + return True + def validate_anthropic_messages_environment( self, headers: dict[str, str], diff --git a/litellm/llms/tencent/messages/transformation.py b/litellm/llms/tencent/messages/transformation.py index f1d9ee966ff..1e8d7243083 100644 --- a/litellm/llms/tencent/messages/transformation.py +++ b/litellm/llms/tencent/messages/transformation.py @@ -30,6 +30,9 @@ class TencentAnthropicMessagesConfig(AnthropicMessagesConfig): def should_strip_billing_metadata(self) -> bool: return True + def should_strip_claude_code_identity(self) -> bool: + return True + @staticmethod def get_api_key(api_key: str | None = None) -> str | None: return api_key or get_secret_str("TENCENT_API_KEY") or litellm.api_key diff --git a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py index 633dd1d9460..8806c297db6 100644 --- a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py +++ b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py @@ -5894,6 +5894,120 @@ def test_should_strip_billing_metadata_by_provider( assert config_cls().should_strip_billing_metadata() is expected_strip +@pytest.mark.parametrize( + "module_path, class_name, expected_strip", + [ + # First-party and Claude-serving providers keep the identity sentence: the + # model genuinely *is* Claude, even via Bedrock/Vertex/Azure. + ("litellm.llms.anthropic.chat.transformation", "AnthropicConfig", False), + ( + "litellm.llms.anthropic.experimental_pass_through.messages.transformation", + "AnthropicMessagesConfig", + False, + ), + ( + "litellm.llms.bedrock.claude_platform.transformation", + "BedrockClaudePlatformConfig", + False, + ), + ( + "litellm.llms.vertex_ai.vertex_ai_partner_models.anthropic.transformation", + "VertexAIAnthropicConfig", + False, + ), + ("litellm.llms.azure_ai.anthropic.transformation", "AzureAnthropicConfig", False), + # Non-Claude Anthropic-compatible endpoints strip the false self-description. + ("litellm.llms.minimax.messages.transformation", "MinimaxMessagesConfig", True), + ( + "litellm.llms.deepseek.messages.transformation", + "DeepSeekAnthropicMessagesConfig", + True, + ), + ( + "litellm.llms.tencent.messages.transformation", + "TencentAnthropicMessagesConfig", + True, + ), + ], +) +def test_should_strip_claude_code_identity_by_provider( + module_path, class_name, expected_strip +): + import importlib + + config_cls = getattr(importlib.import_module(module_path), class_name) + assert config_cls().should_strip_claude_code_identity() is expected_strip + + +def _system_with_identity_block(identity_sentence: str) -> list: + return [ + { + "role": "system", + "content": [ + # Claude Code sends the identity sentence as its own text block. + {"type": "text", "text": identity_sentence}, + {"type": "text", "text": "real system prompt"}, + ], + } + ] + + +def test_messages_request_strips_claude_code_identity_for_minimax(): + from litellm.llms.minimax.messages.transformation import MinimaxMessagesConfig + from litellm.types.router import GenericLiteLLMParams + + config = MinimaxMessagesConfig() + assert config.should_strip_claude_code_identity() is True + + optional_params = { + "max_tokens": 16, + "system": [ + {"type": "text", "text": "You are Claude Code, Anthropic's official CLI for Claude."}, + {"type": "text", "text": "real system prompt"}, + ], + } + result = config.transform_anthropic_messages_request( + model="MiniMax-M2", + messages=[{"role": "user", "content": "hi"}], + anthropic_messages_optional_request_params=optional_params, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + texts = [block["text"] for block in result.get("system", [])] + assert "You are Claude Code, Anthropic's official CLI for Claude." not in texts + assert "real system prompt" in texts + + +def test_messages_request_keeps_claude_code_identity_for_first_party(): + from litellm.llms.anthropic.experimental_pass_through.messages.transformation import ( + AnthropicMessagesConfig, + ) + from litellm.types.router import GenericLiteLLMParams + + config = AnthropicMessagesConfig() + assert config.should_strip_claude_code_identity() is False + + optional_params = { + "max_tokens": 16, + "system": [ + {"type": "text", "text": "You are Claude Code, Anthropic's official CLI for Claude."}, + {"type": "text", "text": "real system prompt"}, + ], + } + result = config.transform_anthropic_messages_request( + model="claude-3-5-sonnet-latest", + messages=[{"role": "user", "content": "hi"}], + anthropic_messages_optional_request_params=optional_params, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + texts = [block["text"] for block in result.get("system", [])] + assert "You are Claude Code, Anthropic's official CLI for Claude." in texts + assert "real system prompt" in texts + + def test_namespace_tool_flat_nested_tools_are_extracted(): """Codex sends nested tools in flat format {type, name, description, parameters} with no 'function' wrapper. These must be normalized and mapped without raising KeyError: 'function'.""" diff --git a/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py b/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py index 945033c5cac..62a6753a482 100644 --- a/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py +++ b/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py @@ -53,6 +53,30 @@ def test_is_claude_code_one_shot_subagent_request(messages, system, expected): ) +@pytest.mark.parametrize( + "text,expected", + [ + # Identity sentence alone -> drop the whole block. + ("You are Claude Code, Anthropic's official CLI for Claude.", None), + # Identity sentence followed by the rest of the system prompt -> keep the rest. + ( + "You are Claude Code, Anthropic's official CLI for Claude.\nYou are an interactive agent.", + "You are an interactive agent.", + ), + # Leading/trailing whitespace around the bare sentence is treated as drop-only. + (" You are Claude Code, Anthropic's official CLI for Claude. ", None), + # Non-Claude-Code prompts are untouched. + ("You are a helpful assistant.", "You are a helpful assistant."), + # A sentence that merely mentions Claude Code is untouched. + ("You are Claude Code's helper.", "You are Claude Code's helper."), + ], +) +def test_strip_claude_code_identity(text, expected): + from litellm.llms.anthropic.common_utils import strip_claude_code_identity + + assert strip_claude_code_identity(text) == expected + + class TestOptionallyHandleAnthropicOAuth: """Tests for optionally_handle_anthropic_oauth function.""" From 2eab418cf94739835a058fb70614602d6692788d Mon Sep 17 00:00:00 2001 From: Sangsiva <101570025+Sangsiva@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:42:35 +0800 Subject: [PATCH 02/10] fix(anthropic): annotate return type of identity-strip staticmethod Restores the ANN205 strict-rule budget: the new helper is a @staticmethod and must carry a return annotation like its billing-header sibling gate. --- .../experimental_pass_through/messages/transformation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py index 641f7410126..f754c558c87 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py @@ -137,7 +137,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): return False @staticmethod - def _strip_claude_code_identity_from_system(system_param): + def _strip_claude_code_identity_from_system(system_param) -> str | list | None: """ Strip Claude Code's self-identification sentence from system parameter. From defee9ad13cbb43a53e6977b64673d0dba60e642 Mon Sep 17 00:00:00 2001 From: Sangsiva <101570025+Sangsiva@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:50:44 +0800 Subject: [PATCH 03/10] fix(anthropic): suppress LIT002 for identity-strip payload lists The identity filter builds the filtered system list and its rewrite dict in a loop, same as the sibling billing-header filter; mark both mutable-ok. --- .../experimental_pass_through/messages/transformation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py index f754c558c87..13acc0bd437 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py @@ -150,7 +150,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): if isinstance(system_param, str): return strip_claude_code_identity(system_param) elif isinstance(system_param, list): - filtered_list: Final = [] + filtered_list: Final = [] # mutable-ok: API message payload for content_block in system_param: if isinstance(content_block, dict): text = content_block.get("text", "") @@ -165,7 +165,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): if stripped_text == text: filtered_list.append(content_block) else: - filtered_list.append({**content_block, "text": stripped_text}) + filtered_list.append({**content_block, "text": stripped_text}) # mutable-ok: API message payload else: # Keep non-dict items as-is filtered_list.append(content_block) From d03238136cafd5e928b581a7f168d797693846be Mon Sep 17 00:00:00 2001 From: Sangsiva <101570025+Sangsiva@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:54:26 +0800 Subject: [PATCH 04/10] style(anthropic): keep identity-strip rewrite ruff-format clean --- .../experimental_pass_through/messages/transformation.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py index 13acc0bd437..2a31c6e7e04 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py @@ -163,9 +163,10 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): continue # Only copy the block when the text changed. if stripped_text == text: - filtered_list.append(content_block) + next_block = content_block else: - filtered_list.append({**content_block, "text": stripped_text}) # mutable-ok: API message payload + next_block = {**content_block, "text": stripped_text} # mutable-ok: API message payload + filtered_list.append(next_block) else: # Keep non-dict items as-is filtered_list.append(content_block) From 31569619c9c1cbb54174c9880c030fc6836d67ff Mon Sep 17 00:00:00 2001 From: Sangsiva <101570025+Sangsiva@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:03:50 +0800 Subject: [PATCH 05/10] fix(anthropic): avoid GeneralTypeIssues by single-assigning the rewrite block --- .../experimental_pass_through/messages/transformation.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py index 2a31c6e7e04..c32a5412839 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py @@ -163,10 +163,10 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): continue # Only copy the block when the text changed. if stripped_text == text: - next_block = content_block + filtered_list.append(content_block) else: - next_block = {**content_block, "text": stripped_text} # mutable-ok: API message payload - filtered_list.append(next_block) + rewritten: Final = {**content_block, "text": stripped_text} # mutable-ok: API message payload + filtered_list.append(rewritten) else: # Keep non-dict items as-is filtered_list.append(content_block) From 2fc4f448b32f76beff0b2a59227efe1e78eae57c Mon Sep 17 00:00:00 2001 From: Sangsiva <101570025+Sangsiva@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:17:22 +0800 Subject: [PATCH 06/10] fix(anthropic): drop Final on loop-locals in identity strip Loop-bound Final declarations trip basedpyright's reportGeneralTypeIssues. --- .../experimental_pass_through/messages/transformation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py index c32a5412839..1f8b78fa08e 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py @@ -158,14 +158,14 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): if content_type != "text": filtered_list.append(content_block) continue - stripped_text: Final[str | None] = strip_claude_code_identity(text) + stripped_text = strip_claude_code_identity(text) if stripped_text is None: continue # Only copy the block when the text changed. if stripped_text == text: filtered_list.append(content_block) else: - rewritten: Final = {**content_block, "text": stripped_text} # mutable-ok: API message payload + rewritten = {**content_block, "text": stripped_text} # mutable-ok: API message payload filtered_list.append(rewritten) else: # Keep non-dict items as-is From bbbf53bec19eb0a8034d048147fa3013fb51fddd Mon Sep 17 00:00:00 2001 From: Sangsiva <101570025+Sangsiva@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:04:45 +0800 Subject: [PATCH 07/10] fix(anthropic): strip Claude Code identity on Anthropic->OpenAI translation bridge The identity-strip gate only ran on the Anthropic messages parse path, so providers routed through the chat-completions translation bridge (e.g. hosted_vllm/* with use_chat_completions_api) forwarded the Claude Code system sentence verbatim to non-Claude models. Promote strip_claude_code_identity_from_system to common_utils and call it from translate_anthropic_to_openai so those providers are scrubbed too. --- litellm/llms/anthropic/common_utils.py | 39 ++++++++++++++++++ .../adapters/transformation.py | 14 +++++++ .../messages/transformation.py | 29 +------------ ...al_pass_through_adapters_transformation.py | 38 +++++++++++++++++ .../anthropic/test_anthropic_common_utils.py | 41 +++++++++++++++++++ 5 files changed, 134 insertions(+), 27 deletions(-) diff --git a/litellm/llms/anthropic/common_utils.py b/litellm/llms/anthropic/common_utils.py index 8bd9c56d92e..6d3180f47aa 100644 --- a/litellm/llms/anthropic/common_utils.py +++ b/litellm/llms/anthropic/common_utils.py @@ -120,6 +120,45 @@ def strip_claude_code_identity(text: str) -> str | None: return text +def strip_claude_code_identity_from_system(system_param: str | list | None) -> str | list | None: + """Strip Claude Code's self-identification sentence from a full system parameter. + + Unlike :func:`strip_claude_code_identity`, which operates on a single text + block, this handles the whole ``system`` value of an Anthropic Messages + request -- either a plain string or a list of system content blocks. + Non-text blocks are kept as-is; text blocks have the identity sentence + removed, and are dropped entirely when it was the only content. + + Returns ``None`` when every block was dropped so callers can remove the whole + ``system`` parameter. + """ + if isinstance(system_param, str): + return strip_claude_code_identity(system_param) + if isinstance(system_param, list): + filtered_list: Final = [] # mutable-ok: API message payload + for content_block in system_param: + if isinstance(content_block, dict): + text = content_block.get("text", "") + content_type = content_block.get("type", "") + if content_type != "text": + filtered_list.append(content_block) + continue + stripped_text = strip_claude_code_identity(text) + if stripped_text is None: + continue + # Only copy the block when the text changed. + if stripped_text == text: + filtered_list.append(content_block) + else: + rewritten = {**content_block, "text": stripped_text} # mutable-ok: API message payload + filtered_list.append(rewritten) + else: + # Keep non-dict items as-is. + filtered_list.append(content_block) + return filtered_list if len(filtered_list) > 0 else None + return system_param + + def _validated_claude_code_mapping(value: object) -> dict[object, object] | None: try: return _CLAUDE_CODE_OBJECT_MAPPING_ADAPTER.validate_python(value) diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index 1a85cf80bff..a0b5c0b3045 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -5,6 +5,7 @@ from collections.abc import AsyncIterator, Iterator, Mapping, Sequence from typing import TYPE_CHECKING, Any, Final, Literal, TypeAlias, TypeVar, cast import litellm +from litellm.llms.anthropic.common_utils import strip_claude_code_identity_from_system from litellm.llms.anthropic.experimental_pass_through.utils import ( is_reasoning_auto_summary_enabled, prompt_cache_key_from_user_id, @@ -1212,6 +1213,19 @@ class LiteLLMAnthropicMessagesAdapter: new_messages: list[AllMessageValues] = [] tool_name_mapping: dict[str, str] = {} + # Strip Claude Code's self-identification from the system prompt before + # translating to an OpenAI chat-completions request. This bridge only runs + # for non-Anthropic models (first-party servers take the native + # AnthropicMessagesConfig path), so "You are Claude Code" is always a + # false self-description here and must not reach the target model. + system_param: Final = anthropic_message_request.get("system") + if system_param is not None: + stripped_system = strip_claude_code_identity_from_system(system_param) + if stripped_system is None: + anthropic_message_request.pop("system", None) + elif stripped_system != system_param: + anthropic_message_request["system"] = stripped_system + ## CONVERT ANTHROPIC MESSAGES TO OPENAI messages_list: Final[list[AllAnthropicPassThroughMessageValues]] = cast( list[AllAnthropicPassThroughMessageValues], diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py index 1f8b78fa08e..58c5a185fef 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py @@ -25,7 +25,7 @@ from ...common_utils import ( AnthropicModelInfo, optionally_handle_anthropic_oauth, strip_advisor_blocks_from_messages, - strip_claude_code_identity, + strip_claude_code_identity_from_system, strip_encrypted_reasoning_blocks_from_anthropic_messages, ) from .mid_conversation_system import ( @@ -147,32 +147,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): Returns: System parameter with the identity sentence removed, or None if all content was removed """ - if isinstance(system_param, str): - return strip_claude_code_identity(system_param) - elif isinstance(system_param, list): - filtered_list: Final = [] # mutable-ok: API message payload - for content_block in system_param: - if isinstance(content_block, dict): - text = content_block.get("text", "") - content_type = content_block.get("type", "") - if content_type != "text": - filtered_list.append(content_block) - continue - stripped_text = strip_claude_code_identity(text) - if stripped_text is None: - continue - # Only copy the block when the text changed. - if stripped_text == text: - filtered_list.append(content_block) - else: - rewritten = {**content_block, "text": stripped_text} # mutable-ok: API message payload - filtered_list.append(rewritten) - else: - # Keep non-dict items as-is - filtered_list.append(content_block) - return filtered_list if len(filtered_list) > 0 else None - else: - return system_param + return strip_claude_code_identity_from_system(system_param) @staticmethod def _filter_billing_headers_from_system(system_param): 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 b9a82e3fc68..329849d35c4 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 @@ -2833,6 +2833,44 @@ def test_translate_completion_input_params_keeps_provider_native_tools(): assert translated["tools"] == [{"googleMaps": {}}] +CLAUDE_CODE_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude." + + +@pytest.mark.parametrize( + "system,expected_system_content", + [ + # Identity sentence alone -> the whole system message is dropped. The bridge + # only serves non-Anthropic models, so the first-party case is never here. + (CLAUDE_CODE_IDENTITY, None), + # Identity followed by the real prompt -> the real prompt survives. + (f"{CLAUDE_CODE_IDENTITY}\nYou are an interactive agent.", "You are an interactive agent."), + # Non-identity system prompt is passed through untouched. + ("You are a helpful assistant.", "You are a helpful assistant."), + ], +) +def test_translate_anthropic_to_openai_strips_claude_code_identity(system, expected_system_content): + """The chat-completions bridge must not forward Claude Code's self-identification upstream.""" + from litellm.types.llms.anthropic import AnthropicMessagesRequest + + adapter = LiteLLMAnthropicMessagesAdapter() + openai_request, _ = adapter.translate_anthropic_to_openai( + anthropic_message_request=AnthropicMessagesRequest( + model="hosted_vllm/kimi-k3", + max_tokens=1024, + messages=[{"role": "user", "content": "hi"}], + system=system, + ), + custom_llm_provider="hosted_vllm", + ) + + system_messages = [m for m in openai_request["messages"] if m["role"] == "system"] + if expected_system_content is None: + assert system_messages == [] + else: + assert len(system_messages) == 1 + assert system_messages[0]["content"] == expected_system_content + + def test_translate_openai_content_to_anthropic_reasoning_content_without_thinking_blocks(): """ Test that reasoning_content is converted to thinking block when thinking_blocks is not present. diff --git a/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py b/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py index 62a6753a482..5d1f92773e5 100644 --- a/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py +++ b/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py @@ -77,6 +77,47 @@ def test_strip_claude_code_identity(text, expected): assert strip_claude_code_identity(text) == expected +CLAUDE_CODE_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude." + + +@pytest.mark.parametrize( + "system_param,expected", + [ + # String form: identity sentence alone -> drop the whole system param. + (CLAUDE_CODE_IDENTITY, None), + # String form: identity followed by the real prompt -> keep the real prompt. + (f"{CLAUDE_CODE_IDENTITY}\nYou are an interactive agent.", "You are an interactive agent."), + # String form: non-identity text is untouched. + ("You are a helpful assistant.", "You are a helpful assistant."), + # List form: identity text block is dropped, non-text blocks and the + # surviving text block are preserved. + ( + [ + {"type": "text", "text": CLAUDE_CODE_IDENTITY}, + {"type": "text", "text": "real system prompt"}, + ], + [{"type": "text", "text": "real system prompt"}], + ), + # List form: identity-only text means every block is dropped. + ([{"type": "text", "text": CLAUDE_CODE_IDENTITY}], None), + # List form: non-text blocks survive identity stripping untouched. + ( + [ + {"type": "text", "text": CLAUDE_CODE_IDENTITY}, + {"type": "text", "text": "real system prompt", "cache_control": {"type": "ephemeral"}}, + ], + [{"type": "text", "text": "real system prompt", "cache_control": {"type": "ephemeral"}}], + ), + ], +) +def test_strip_claude_code_identity_from_system(system_param, expected): + from litellm.llms.anthropic.common_utils import ( + strip_claude_code_identity_from_system, + ) + + assert strip_claude_code_identity_from_system(system_param) == expected + + class TestOptionallyHandleAnthropicOAuth: """Tests for optionally_handle_anthropic_oauth function.""" From 5f8c3f470764b2efcfd11fde2a0f46b9d9b01cbf Mon Sep 17 00:00:00 2001 From: Sangsiva <101570025+Sangsiva@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:08:17 +0800 Subject: [PATCH 08/10] test(anthropic): cover identity-strip drop and non-text block branches Close the Codecov gaps on strip_claude_code_identity_from_system: identity+content same-block rewrite, non-text blocks, non-dict list items, non-str/non-list payload, and the identity-only system-pop path. --- .../test_anthropic_chat_transformation.py | 23 +++++++++++++++++++ .../anthropic/test_anthropic_common_utils.py | 14 +++++++++++ 2 files changed, 37 insertions(+) diff --git a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py index 8806c297db6..da7836d80de 100644 --- a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py +++ b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py @@ -6565,3 +6565,26 @@ def test_eager_input_streaming_reaches_anthropic_request_tools(): assert result["tools"][0]["eager_input_streaming"] is True assert result["tools"][0]["name"] == "write_file" + + +def test_messages_request_strips_identity_only_system_for_minimax(): + """Identity-only system drops the whole system param (pop path).""" + from litellm.llms.minimax.messages.transformation import MinimaxMessagesConfig + from litellm.types.router import GenericLiteLLMParams + + config = MinimaxMessagesConfig() + optional_params = { + "max_tokens": 16, + "system": [ + {"type": "text", "text": "You are Claude Code, Anthropic's official CLI for Claude."}, + ], + } + result = config.transform_anthropic_messages_request( + model="MiniMax-M2", + messages=[{"role": "user", "content": "hi"}], + anthropic_messages_optional_request_params=optional_params, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert "system" not in result diff --git a/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py b/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py index 5d1f92773e5..0e30c4c868c 100644 --- a/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py +++ b/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py @@ -108,6 +108,20 @@ CLAUDE_CODE_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude ], [{"type": "text", "text": "real system prompt", "cache_control": {"type": "ephemeral"}}], ), + # List form: identity + real content in the same block -> rewritten block. + ( + [{"type": "text", "text": f"{CLAUDE_CODE_IDENTITY}\nreal system prompt"}], + [{"type": "text", "text": "real system prompt"}], + ), + # List form: a non-text block (type "other") is kept as-is. + ( + [{"type": "text", "text": CLAUDE_CODE_IDENTITY}, {"type": "other", "foo": "bar"}], + [{"type": "other", "foo": "bar"}], + ), + # List form: non-dict items are kept as-is. + ([{"type": "text", "text": CLAUDE_CODE_IDENTITY}, "loose item"], ["loose item"]), + # Non-string, non-list system payload is passed through untouched. + (None, None), ], ) def test_strip_claude_code_identity_from_system(system_param, expected): From b1d6264a033315ae73a1f8b9f9e27d8e2fb433d6 Mon Sep 17 00:00:00 2001 From: Sangsiva <101570025+Sangsiva@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:30:56 +0800 Subject: [PATCH 09/10] fix(anthropic): restore blank lines before requires_native_compaction_beta (ruff format) --- litellm/llms/anthropic/common_utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/litellm/llms/anthropic/common_utils.py b/litellm/llms/anthropic/common_utils.py index 6e3ac69241e..27aed50720a 100644 --- a/litellm/llms/anthropic/common_utils.py +++ b/litellm/llms/anthropic/common_utils.py @@ -80,6 +80,8 @@ _CLAUDE_CODE_USER_AGENT_PREFIXES: Final = ("claude-cli/", "claude-code/") _CLAUDE_CODE_IDENTITY: Final = "You are Claude Code, Anthropic's official CLI for Claude." + + def requires_native_compaction_beta( custom_llm_provider: str, optional_params: Mapping[str, object], From 49de19e6e349048532d13eed5a62306bc376363d Mon Sep 17 00:00:00 2001 From: Sangsiva <101570025+Sangsiva@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:20:56 +0800 Subject: [PATCH 10/10] fix(anthropic): don't mutate read-only requests and tighten system list typing - copy the request before stripping the Claude Code identity so a read-only wire-body mapping (shadow evaluation) no longer raises TypeError on pop/assign - annotate the system-list helper as list[object] instead of bare list - drop a comment that restated the equality check it precedes - add a regression test for the read-only request path --- litellm/llms/anthropic/common_utils.py | 5 ++-- .../adapters/transformation.py | 12 ++++++---- .../messages/transformation.py | 2 +- ...al_pass_through_adapters_transformation.py | 24 +++++++++++++++++++ 4 files changed, 36 insertions(+), 7 deletions(-) diff --git a/litellm/llms/anthropic/common_utils.py b/litellm/llms/anthropic/common_utils.py index 27aed50720a..a7b8ce2a0e1 100644 --- a/litellm/llms/anthropic/common_utils.py +++ b/litellm/llms/anthropic/common_utils.py @@ -143,7 +143,9 @@ def strip_claude_code_identity(text: str) -> str | None: return text -def strip_claude_code_identity_from_system(system_param: str | list | None) -> str | list | None: +def strip_claude_code_identity_from_system( + system_param: str | list[object] | None, +) -> str | list[object] | None: """Strip Claude Code's self-identification sentence from a full system parameter. Unlike :func:`strip_claude_code_identity`, which operates on a single text @@ -169,7 +171,6 @@ def strip_claude_code_identity_from_system(system_param: str | list | None) -> s stripped_text = strip_claude_code_identity(text) if stripped_text is None: continue - # Only copy the block when the text changed. if stripped_text == text: filtered_list.append(content_block) else: diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index a0be08c4bb9..bc3dc685539 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -1239,10 +1239,14 @@ class LiteLLMAnthropicMessagesAdapter: system_param: Final = anthropic_message_request.get("system") if system_param is not None: stripped_system = strip_claude_code_identity_from_system(system_param) - if stripped_system is None: - anthropic_message_request.pop("system", None) - elif stripped_system != system_param: - anthropic_message_request["system"] = stripped_system + if stripped_system is None or stripped_system != system_param: + # This adapter is also invoked with a read-only wire-body mapping + # (shadow evaluation); mutate a copy, never the caller's request. + anthropic_message_request = cast(AnthropicMessagesRequest, dict(anthropic_message_request)) + if stripped_system is None: + anthropic_message_request.pop("system", None) + else: + anthropic_message_request["system"] = stripped_system ## CONVERT ANTHROPIC MESSAGES TO OPENAI messages_list: Final[list[AllAnthropicPassThroughMessageValues]] = cast( diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py index 56bcd351ef0..f692194f0ad 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py @@ -143,7 +143,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): return False @staticmethod - def _strip_claude_code_identity_from_system(system_param) -> str | list | None: + def _strip_claude_code_identity_from_system(system_param: str | list[object] | None) -> str | list[object] | None: """ Strip Claude Code's self-identification sentence from system parameter. 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 e99ae2358d6..b07f65088e0 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 @@ -2900,6 +2900,30 @@ def test_translate_anthropic_to_openai_strips_claude_code_identity(system, expec assert system_messages[0]["content"] == expected_system_content +def test_translate_anthropic_to_openai_strips_claude_code_identity_read_only_request(): + """Identity stripping must not mutate a read-only request (shadow evaluation passes a + MappingProxyType, so the adapter must copy before rewriting ``system``).""" + from types import MappingProxyType + + adapter = LiteLLMAnthropicMessagesAdapter() + request = MappingProxyType( + { + "model": "hosted_vllm/kimi-k3", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "hi"}], + "system": f"{CLAUDE_CODE_IDENTITY}\nYou are an interactive agent.", + } + ) + + openai_request, _ = adapter.translate_anthropic_to_openai( + anthropic_message_request=request, + custom_llm_provider="hosted_vllm", + ) + + system_messages = [m for m in openai_request["messages"] if m["role"] == "system"] + assert [m["content"] for m in system_messages] == ["You are an interactive agent."] + + def test_translate_openai_content_to_anthropic_reasoning_content_without_thinking_blocks(): """ Test that reasoning_content is converted to thinking block when thinking_blocks is not present.