diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 3bffee48d6a..bb8c3b07194 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -100,9 +100,11 @@ from ..common_utils import ( AnthropicError, AnthropicModelInfo, eager_input_streaming_flag, + has_context_1m_suffix, process_anthropic_headers, requires_native_compaction_beta, strip_advisor_blocks_from_messages, + strip_context_1m_suffix, ) if TYPE_CHECKING: @@ -1904,6 +1906,15 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): """ Translate messages to anthropic format. """ + if has_context_1m_suffix(model): + return AnthropicConfig.transform_request( + self, + model=strip_context_1m_suffix(model), + messages=messages, + optional_params=optional_params, + litellm_params=litellm_params, + headers=headers, + ) ## VALIDATE REQUEST """Anthropic requires ``tools`` when messages include tool blocks; LiteLLM injects a dummy tool if omitted (no ``modify_params`` needed).""" from litellm.litellm_core_utils.prompt_templates.factory import ( diff --git a/litellm/llms/anthropic/common_utils.py b/litellm/llms/anthropic/common_utils.py index bf2d588dd3a..445b166e444 100644 --- a/litellm/llms/anthropic/common_utils.py +++ b/litellm/llms/anthropic/common_utils.py @@ -30,6 +30,7 @@ from litellm.litellm_core_utils.prompt_templates.factory import ( from litellm.llms.base_llm.base_utils import BaseLLMModelInfo, BaseTokenCounter from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.types.llms.anthropic import ( + ANTHROPIC_BETA_HEADER_VALUES, ANTHROPIC_HOSTED_TOOLS, ANTHROPIC_OAUTH_BETA_HEADER, ANTHROPIC_OAUTH_TOKEN_PREFIX, @@ -71,6 +72,17 @@ _BEDROCK_VERSION_SUFFIX_RE: Final = re.compile(r"-v\d+(?::\d+)?$") _INFERENCE_PROFILE_MINOR_RE: Final = re.compile(r":\d+$") _DATED_RELEASE_SUFFIX_RE: Final = re.compile(r"-\d{8}$") _DOTTED_VERSION_RE: Final = re.compile(r"(\d)\.(\d)") +_CONTEXT_1M_SUFFIX: Final = re.compile(r"\[1m\]$", flags=re.IGNORECASE) + + +def has_context_1m_suffix(model: str) -> bool: + return _CONTEXT_1M_SUFFIX.search(model) is not None + + +def strip_context_1m_suffix(model: str) -> str: + return _CONTEXT_1M_SUFFIX.sub("", model) + + _CLAUDE_CODE_BILLING_HEADER_PREFIX: Final = "x-anthropic-billing-header:" _CLAUDE_CODE_OBJECT_MAPPING_ADAPTER: Final = TypeAdapter(dict[object, object]) _CLAUDE_CODE_OBJECT_LIST_ADAPTER: Final = TypeAdapter(list[object]) @@ -915,8 +927,11 @@ class AnthropicModelInfo(BaseLLMModelInfo): container_with_skills_used: bool = False, api_base: str | None = None, use_bearer_for_custom_base: bool = False, + context_1m_requested: bool = False, ) -> dict: - betas: Final = set() + betas: Final[set[str]] = set() + if context_1m_requested: + betas.add(ANTHROPIC_BETA_HEADER_VALUES.CONTEXT_1M_2025_08_07.value) # Anthropic no longer requires the prompt-caching beta header # Prompt caching now works automatically when cache_control is used in messages # Reference: https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching @@ -972,8 +987,6 @@ class AnthropicModelInfo(BaseLLMModelInfo): if is_vertex_request is True: # Vertex AI requires web search beta header for web search to work if web_search_tool_used: - from litellm.types.llms.anthropic import ANTHROPIC_BETA_HEADER_VALUES - headers["anthropic-beta"] = ANTHROPIC_BETA_HEADER_VALUES.WEB_SEARCH_2025_03_05.value elif len(betas) > 0: headers["anthropic-beta"] = ",".join(betas) @@ -1027,6 +1040,7 @@ class AnthropicModelInfo(BaseLLMModelInfo): ) anthropic_headers: Final = self.get_anthropic_headers( computer_tool_used=computer_tool_used, + context_1m_requested=has_context_1m_suffix(model), prompt_caching_set=prompt_caching_set, pdf_used=pdf_used, api_key=api_key, diff --git a/litellm/llms/anthropic/pass_through/messages/transformation.py b/litellm/llms/anthropic/pass_through/messages/transformation.py index 1f604cfb8d7..88aa98c0963 100644 --- a/litellm/llms/anthropic/pass_through/messages/transformation.py +++ b/litellm/llms/anthropic/pass_through/messages/transformation.py @@ -23,9 +23,11 @@ from litellm.types.router import GenericLiteLLMParams from ...common_utils import ( AnthropicError, AnthropicModelInfo, + has_context_1m_suffix, optionally_handle_anthropic_oauth, requires_native_compaction_beta, strip_advisor_blocks_from_messages, + strip_context_1m_suffix, strip_encrypted_reasoning_blocks_from_anthropic_messages, ) from .mid_conversation_system import ( @@ -277,6 +279,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): headers=headers, optional_params=optional_params, messages=messages, + model=model, ) return headers, api_base @@ -562,7 +565,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): anthropic_messages_request: Final[AnthropicMessagesRequest] = AnthropicMessagesRequest( messages=strip_encrypted_reasoning_blocks_from_anthropic_messages(messages), max_tokens=max_tokens, - model=model, + model=strip_context_1m_suffix(model), **anthropic_messages_optional_request_params, ) return dict(anthropic_messages_request) @@ -611,6 +614,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): optional_params: dict, custom_llm_provider: str = "anthropic", messages: Sequence[object] = (), + model: str = "", ) -> dict: """ Auto-inject anthropic-beta headers based on features used. @@ -621,14 +625,16 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): - output_format: adds 'structured-outputs-2025-11-13' - speed: adds 'fast-mode-2026-02-01' - a message carrying output_config: adds 'per-turn-control-2026-07-01' + - [1m] model suffix: adds 'context-1m-2025-08-07' Args: headers: Request headers dict optional_params: Optional parameters including tools, context_management, output_format, speed custom_llm_provider: Provider name for looking up correct tool search header messages: Request messages, scanned for per-message output_config + model: Requested model name, checked for the [1m] suffix """ - beta_values: Final[set] = set() + beta_values: Final[set[str]] = set() existing_beta: Final = tuple( piece.strip() @@ -642,6 +648,9 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): if requires_native_compaction_beta(custom_llm_provider, optional_params, messages): beta_values.add(ANTHROPIC_BETA_HEADER_VALUES.COMPACT_2026_09_04.value) + if has_context_1m_suffix(model): + beta_values.add(ANTHROPIC_BETA_HEADER_VALUES.CONTEXT_1M_2025_08_07.value) + # Check for context management context_management_param: Final = optional_params.get("context_management") if context_management_param is not None: diff --git a/litellm/types/llms/anthropic.py b/litellm/types/llms/anthropic.py index a818daf554d..9fd3a19b1f8 100644 --- a/litellm/types/llms/anthropic.py +++ b/litellm/types/llms/anthropic.py @@ -754,6 +754,7 @@ class ANTHROPIC_BETA_HEADER_VALUES(str, Enum): WEB_FETCH_2025_09_10 = "web-fetch-2025-09-10" WEB_SEARCH_2025_03_05 = "web-search-2025-03-05" CONTEXT_MANAGEMENT_2025_06_27 = "context-management-2025-06-27" + CONTEXT_1M_2025_08_07 = "context-1m-2025-08-07" COMPACT_2026_01_12 = "compact-2026-01-12" COMPACT_2026_09_04 = "compact-2026-09-04" STRUCTURED_OUTPUT_2025_09_25 = "structured-outputs-2025-11-13" diff --git a/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py b/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py index 332153b4c7d..5171012186f 100644 --- a/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py +++ b/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py @@ -6798,3 +6798,58 @@ def test_chat_dummy_tool_result_for_an_orphaned_tool_call_replays_a_byte_identic _assert_prefix_stable(requests) assert [m["role"] for m in requests[0]["messages"]] == ["user", "assistant", "user"] assert requests[0]["messages"][2]["content"][0]["type"] == "tool_result" + + +@pytest.mark.parametrize("optional_params", [{}, {"thinking": {"type": "enabled", "budget_tokens": 2048}}]) +def test_transform_request_treats_context_1m_model_as_its_base_model(optional_params: dict[str, object]) -> None: + def transform(model: str) -> dict[str, object]: + return AnthropicConfig().transform_request( + model=model, + messages=[{"role": "user", "content": "hi"}], + optional_params=copy.deepcopy(optional_params), + litellm_params={}, + headers={}, + ) + + assert transform("claude-sonnet-4-6[1m]") == transform("claude-sonnet-4-6") + + +@pytest.mark.parametrize( + "model,expected_betas", + [ + ("anthropic/claude-sonnet-4-6[1m]", {"context-1m-2025-08-07", "interleaved-thinking-2025-05-14"}), + ("anthropic/claude-sonnet-4-6", {"interleaved-thinking-2025-05-14"}), + ], +) +async def test_acompletion_context_1m_suffix_sends_base_model_with_beta( + model: str, + expected_betas: set[str], + monkeypatch: pytest.MonkeyPatch, + respx_mock: respx.MockRouter, +) -> None: + monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True") + monkeypatch.setenv("LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS", "True") + monkeypatch.setattr(litellm.anthropic_beta_headers_manager, "_BETA_HEADERS_CONFIG", None) + route: Final = respx_mock.post("https://api.anthropic.com/v1/messages").respond( + 200, + json={ + "id": "msg_context_1m", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-6", + "content": [{"type": "text", "text": "hi"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + ) + + await litellm.acompletion( + model=model, + messages=[{"role": "user", "content": "hi"}], + api_key="test", + extra_headers={"anthropic-beta": "interleaved-thinking-2025-05-14"}, + ) + + request: Final = route.calls.last.request + assert json.loads(request.content)["model"] == "claude-sonnet-4-6" + assert {beta.strip() for beta in request.headers["anthropic-beta"].split(",")} == expected_betas diff --git a/tests/unit/llms/anthropic/pass_through/messages/test_anthropic_messages_context_1m.py b/tests/unit/llms/anthropic/pass_through/messages/test_anthropic_messages_context_1m.py new file mode 100644 index 00000000000..4525769a7cf --- /dev/null +++ b/tests/unit/llms/anthropic/pass_through/messages/test_anthropic_messages_context_1m.py @@ -0,0 +1,57 @@ +import json +from typing import Final + +import pytest +import respx + +import litellm +from litellm import Router + + +@pytest.mark.parametrize( + "deployment_model,expected_betas", + [ + ("anthropic/claude-sonnet-4-6[1m]", {"context-1m-2025-08-07", "interleaved-thinking-2025-05-14"}), + ("anthropic/claude-sonnet-4-6", {"interleaved-thinking-2025-05-14"}), + ], +) +async def test_router_messages_context_1m_deployment_sends_base_model_with_beta( + deployment_model: str, + expected_betas: set[str], + monkeypatch: pytest.MonkeyPatch, + respx_mock: respx.MockRouter, +) -> None: + monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True") + monkeypatch.setenv("LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS", "True") + monkeypatch.setattr(litellm.anthropic_beta_headers_manager, "_BETA_HEADERS_CONFIG", None) + route: Final = respx_mock.post("https://api.anthropic.com/v1/messages").respond( + 200, + json={ + "id": "msg_context_1m", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-6", + "content": [{"type": "text", "text": "hi"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + ) + router: Final = Router( + model_list=[ + { + "model_name": "claude-sonnet-4-6[1m]", + "litellm_params": {"model": deployment_model, "api_key": "test"}, + } + ] + ) + + await router.aanthropic_messages( + model="claude-sonnet-4-6[1m]", + messages=[{"role": "user", "content": "hi"}], + max_tokens=16, + extra_headers={"anthropic-beta": "interleaved-thinking-2025-05-14"}, + ) + + request: Final = route.calls.last.request + assert json.loads(request.content)["model"] == "claude-sonnet-4-6" + assert {beta.strip() for beta in request.headers["anthropic-beta"].split(",")} == expected_betas diff --git a/tests/unit/llms/anthropic/test_anthropic_common_utils.py b/tests/unit/llms/anthropic/test_anthropic_common_utils.py index 52b53769457..02358442817 100644 --- a/tests/unit/llms/anthropic/test_anthropic_common_utils.py +++ b/tests/unit/llms/anthropic/test_anthropic_common_utils.py @@ -2345,3 +2345,41 @@ class TestMalformedContentListItems: api_key=FAKE_REGULAR_KEY, max_tokens=5, ) + + +@pytest.mark.parametrize( + "model,base_model", + [ + ("claude-sonnet-4-6[1m]", "claude-sonnet-4-6"), + ("claude-sonnet-4-6[1M]", "claude-sonnet-4-6"), + ("claude-sonnet-4-6", "claude-sonnet-4-6"), + ("claude-[1m]-sonnet-4-6", "claude-[1m]-sonnet-4-6"), + ("claude-sonnet-4-6[1m][1m]", "claude-sonnet-4-6[1m]"), + ("claude-sonnet-4-6[200k]", "claude-sonnet-4-6[200k]"), + ], +) +def test_context_1m_suffix_is_only_a_trailing_marker(model: str, base_model: str) -> None: + from litellm.llms.anthropic.common_utils import has_context_1m_suffix, strip_context_1m_suffix + + assert strip_context_1m_suffix(model) == base_model + assert has_context_1m_suffix(model) is (model != base_model) + + +@pytest.mark.parametrize("model,expects_context_1m", [("claude-sonnet-4-6[1m]", True), ("claude-sonnet-4-6", False)]) +def test_validate_environment_adds_context_1m_beta_only_for_suffixed_model( + model: str, expects_context_1m: bool +) -> None: + from litellm.llms.anthropic.common_utils import AnthropicModelInfo + + headers: Final = AnthropicModelInfo().validate_environment( + headers={"anthropic-beta": "interleaved-thinking-2025-05-14"}, + model=model, + messages=[{"role": "user", "content": "hi"}], + optional_params={}, + litellm_params={}, + api_key=FAKE_REGULAR_KEY, + ) + + betas: Final = {beta.strip() for beta in headers["anthropic-beta"].split(",")} + assert ("context-1m-2025-08-07" in betas) is expects_context_1m + assert "interleaved-thinking-2025-05-14" in betas