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.
This commit is contained in:
Sangsiva 2026-09-21 17:04:45 +08:00
parent 2fc4f448b3
commit bbbf53bec1
5 changed files with 134 additions and 27 deletions

View file

@ -120,6 +120,45 @@ def strip_claude_code_identity(text: str) -> str | None:
return text 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: def _validated_claude_code_mapping(value: object) -> dict[object, object] | None:
try: try:
return _CLAUDE_CODE_OBJECT_MAPPING_ADAPTER.validate_python(value) return _CLAUDE_CODE_OBJECT_MAPPING_ADAPTER.validate_python(value)

View file

@ -5,6 +5,7 @@ from collections.abc import AsyncIterator, Iterator, Mapping, Sequence
from typing import TYPE_CHECKING, Any, Final, Literal, TypeAlias, TypeVar, cast from typing import TYPE_CHECKING, Any, Final, Literal, TypeAlias, TypeVar, cast
import litellm import litellm
from litellm.llms.anthropic.common_utils import strip_claude_code_identity_from_system
from litellm.llms.anthropic.experimental_pass_through.utils import ( from litellm.llms.anthropic.experimental_pass_through.utils import (
is_reasoning_auto_summary_enabled, is_reasoning_auto_summary_enabled,
prompt_cache_key_from_user_id, prompt_cache_key_from_user_id,
@ -1212,6 +1213,19 @@ class LiteLLMAnthropicMessagesAdapter:
new_messages: list[AllMessageValues] = [] new_messages: list[AllMessageValues] = []
tool_name_mapping: dict[str, str] = {} 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 ## CONVERT ANTHROPIC MESSAGES TO OPENAI
messages_list: Final[list[AllAnthropicPassThroughMessageValues]] = cast( messages_list: Final[list[AllAnthropicPassThroughMessageValues]] = cast(
list[AllAnthropicPassThroughMessageValues], list[AllAnthropicPassThroughMessageValues],

View file

@ -25,7 +25,7 @@ from ...common_utils import (
AnthropicModelInfo, AnthropicModelInfo,
optionally_handle_anthropic_oauth, optionally_handle_anthropic_oauth,
strip_advisor_blocks_from_messages, strip_advisor_blocks_from_messages,
strip_claude_code_identity, strip_claude_code_identity_from_system,
strip_encrypted_reasoning_blocks_from_anthropic_messages, strip_encrypted_reasoning_blocks_from_anthropic_messages,
) )
from .mid_conversation_system import ( from .mid_conversation_system import (
@ -147,32 +147,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
Returns: Returns:
System parameter with the identity sentence removed, or None if all content was removed System parameter with the identity sentence removed, or None if all content was removed
""" """
if isinstance(system_param, str): return strip_claude_code_identity_from_system(system_param)
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
@staticmethod @staticmethod
def _filter_billing_headers_from_system(system_param): def _filter_billing_headers_from_system(system_param):

View file

@ -2833,6 +2833,44 @@ def test_translate_completion_input_params_keeps_provider_native_tools():
assert translated["tools"] == [{"googleMaps": {}}] 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(): 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. Test that reasoning_content is converted to thinking block when thinking_blocks is not present.

View file

@ -77,6 +77,47 @@ def test_strip_claude_code_identity(text, expected):
assert 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: class TestOptionallyHandleAnthropicOAuth:
"""Tests for optionally_handle_anthropic_oauth function.""" """Tests for optionally_handle_anthropic_oauth function."""