mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
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:
parent
2fc4f448b3
commit
bbbf53bec1
5 changed files with 134 additions and 27 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue