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."""