diff --git a/litellm-rust/crates/llms/src/anthropic/messages/thinking.rs b/litellm-rust/crates/llms/src/anthropic/messages/thinking.rs index df1ae13fd28..f964c797815 100644 --- a/litellm-rust/crates/llms/src/anthropic/messages/thinking.rs +++ b/litellm-rust/crates/llms/src/anthropic/messages/thinking.rs @@ -2,8 +2,8 @@ use litellm_llms_types::{ formats::{ chat_completions::ReasoningEffort, messages::{ - EffortLevel, MessagesOptionalParams, MessagesRequest, OutputConfig, ThinkingConfig, - ThinkingDisplay, + ContextEdit, ContextManagement, EffortLevel, MessagesOptionalParams, MessagesRequest, + OutputConfig, ThinkingConfig, ThinkingDisplay, }, }, recognized::Recognized, @@ -144,6 +144,58 @@ fn legacy_reasoning_effort( } } +fn is_clear_thinking_edit(edit: &Recognized) -> bool { + match edit { + Recognized::Known(ContextEdit::ClearThinking { .. }) => true, + Recognized::Known(_) => false, + Recognized::Unrecognized(value) => value + .get("type") + .and_then(Value::as_str) + .is_some_and(|edit_type| edit_type.starts_with("clear_thinking_")), + } +} + +/// Anthropic rejects `clear_thinking_*` edits without thinking. `context_management` goes +/// only when such an edit was removed and nothing else is left in it. +fn without_clear_thinking_edits( + context_management: Option>, +) -> Option> { + match context_management { + Some(Recognized::Known(ContextManagement { + edits: Some(edits), + extra, + })) if edits.iter().any(is_clear_thinking_edit) => { + let kept: Vec<_> = edits + .into_iter() + .filter(|edit| !is_clear_thinking_edit(edit)) + .collect(); + (!kept.is_empty() || !extra.is_empty()).then(|| { + Recognized::Known(ContextManagement { + edits: Some(kept), + extra, + }) + }) + } + other => other, + } +} + +fn without_thinking( + params: MessagesOptionalParams, + context: &ThinkingContext, +) -> MessagesOptionalParams { + let context_management = if context.capabilities.thinking_always_on { + params.context_management + } else { + without_clear_thinking_edits(params.context_management) + }; + MessagesOptionalParams { + thinking: None, + context_management, + ..params + } +} + fn translate_reasoning_effort( request: MessagesRequest, context: &ThinkingContext, @@ -171,9 +223,8 @@ fn translate_reasoning_effort( ) else { return Ok(MessagesRequest { params: MessagesOptionalParams { - thinking: None, output_config: None, - ..request.params + ..without_thinking(request.params, context) }, ..request }); @@ -277,15 +328,11 @@ fn translate_adaptive_effort_for_non_adaptive_model( _ => true, }; if supports_effort_param(capabilities) && (!adaptive_thinking || level_accepted) { + if !adaptive_thinking { + return Ok(request); + } return Ok(MessagesRequest { - params: MessagesOptionalParams { - thinking: if adaptive_thinking { - None - } else { - request.params.thinking - }, - ..request.params - }, + params: without_thinking(request.params, context), ..request }); } @@ -294,13 +341,17 @@ fn translate_adaptive_effort_for_non_adaptive_model( } else { None }; + let thinking = budget + .and_then(|budget| fit_budget_to_max_tokens(budget, request.params.max_tokens)) + .map(|budget| Recognized::Known(ThinkingConfig::enabled(budget))); + let params = MessagesOptionalParams { + output_config: without_effort(request.params.output_config), + ..request.params + }; Ok(MessagesRequest { - params: MessagesOptionalParams { - thinking: budget - .and_then(|budget| fit_budget_to_max_tokens(budget, request.params.max_tokens)) - .map(|budget| Recognized::Known(ThinkingConfig::enabled(budget))), - output_config: without_effort(request.params.output_config), - ..request.params + params: match thinking { + Some(_) => MessagesOptionalParams { thinking, ..params }, + None => without_thinking(params, context), }, ..request }) @@ -625,6 +676,95 @@ mod tests { assert_eq!(translate(capabilities, input), Ok(request(expected))); } + #[rstest] + #[case::reasoning_effort_none( + haiku_4_5(), + serde_json::json!({"max_tokens": 8192, "reasoning_effort": "none"}), + false + )] + #[case::reasoning_effort_high( + haiku_4_5(), + serde_json::json!({"max_tokens": 8192, "reasoning_effort": "high"}), + true + )] + #[case::effort_model_drops_adaptive(opus_4_5(), claude_code_payload("high", 8192), false)] + #[case::adaptive_model_keeps_adaptive(sonnet_4_6(), claude_code_payload("high", 8192), true)] + #[case::non_reasoning_model(haiku_3_5(), claude_code_payload("high", 8192), false)] + #[case::legacy_thinking_model(haiku_4_5(), claude_code_payload("high", 8192), true)] + #[case::always_on_model_keeps_edit( + fable_5_1(), + serde_json::json!({"max_tokens": 8192, "reasoning_effort": "none"}), + true + )] + fn clear_thinking_edit_is_dropped_with_thinking_except_on_always_on_models( + #[case] capabilities: MessagesModelCapabilities, + #[case] fields: Value, + #[case] keeps_edit: bool, + ) { + let mut fields = fields; + fields.as_object_mut().unwrap().insert( + "context_management".into(), + serde_json::json!({"edits": [{"type": "clear_thinking_20251015", "keep": "all"}, {"type": "clear_tool_uses_20250919"}]}), + ); + let always_on = capabilities.thinking_always_on; + let translated = serde_json::to_value(translate(capabilities, fields).unwrap()).unwrap(); + let edit_types: Vec<&str> = translated["context_management"]["edits"] + .as_array() + .unwrap() + .iter() + .filter_map(|edit| edit["type"].as_str()) + .collect(); + let expected: &[&str] = if keeps_edit { + &["clear_thinking_20251015", "clear_tool_uses_20250919"] + } else { + &["clear_tool_uses_20250919"] + }; + assert_eq!(edit_types, expected); + assert_eq!( + translated.get("thinking").is_some(), + keeps_edit && !always_on + ); + } + + #[rstest] + #[case::only_clear_thinking_is_removed_whole( + serde_json::json!({"edits": [{"type": "clear_thinking_20251015"}]}), + None + )] + #[case::any_clear_thinking_version( + serde_json::json!({"edits": [{"type": "clear_thinking_20990101"}, {"type": "compact_20260112"}]}), + Some(serde_json::json!({"edits": [{"type": "compact_20260112"}]})) + )] + #[case::other_keys_kept( + serde_json::json!({"edits": [{"type": "clear_thinking_20251015"}], "extra": 1}), + Some(serde_json::json!({"edits": [], "extra": 1})) + )] + #[case::no_clear_thinking_untouched( + serde_json::json!({"edits": []}), + Some(serde_json::json!({"edits": []})) + )] + #[case::openai_list_untouched( + serde_json::json!([{"type": "compaction"}]), + Some(serde_json::json!([{"type": "compaction"}])) + )] + fn clear_thinking_edits_are_removed( + #[case] context_management: Value, + #[case] expected: Option, + ) { + let translated = translate( + haiku_4_5(), + serde_json::json!({"max_tokens": 8192, "reasoning_effort": "none", "context_management": context_management}), + ) + .unwrap(); + assert_eq!( + serde_json::to_value(translated) + .unwrap() + .get("context_management") + .cloned(), + expected + ); + } + #[rstest] #[case::minimal_floors_at_min_budget("minimal", 1024)] #[case::low("low", 1024)] diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 92e7c84031c..03d8b728ce0 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -2,7 +2,7 @@ import copy import json import re import time -from collections.abc import Callable, Mapping, Sequence +from collections.abc import Callable, Mapping, MutableMapping, Sequence from types import MappingProxyType from typing import TYPE_CHECKING, Any, ClassVar, Final, NoReturn, cast @@ -121,6 +121,7 @@ from ..common_utils import ( REFUSAL_STOP_FIELDS, AnthropicError, AnthropicModelInfo, + drop_clear_thinking_edits_for_model, eager_input_streaming_flag, process_anthropic_headers, refusal_provider_specific_fields, @@ -181,6 +182,16 @@ def _tool_type(tool: object) -> str | None: return None +def _requested_effort(reasoning_effort: object) -> str | None: + match reasoning_effort: + case str(): + return reasoning_effort + case {"effort": str(effort)}: + return effort + case _: + return None + + def _is_openai_tool_search_tool(tool: object) -> bool: return _tool_type(tool) == "tool_search" @@ -1833,6 +1844,10 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): # via ``data = {**optional_params}`` and would 400 with # ``Extra inputs are not permitted``). + requested_params: Final = cast( # cast-ok: non_default_params is the untyped dict of the shared signature + Mapping[str, object], non_default_params + ) + for param, value in non_default_params.items(): if param == "max_tokens" or param == "max_completion_tokens": optional_params["max_tokens"] = value if isinstance(value, int) else max(1, int(round(value))) @@ -1946,10 +1961,8 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): # send. Coerce to the effort string before mapping — same # shape-tolerance the GPT-5 path already implements in # `_normalize_reasoning_effort_for_chat_completion`. - effort_value = value - if isinstance(effort_value, dict): - effort_value = effort_value.get("effort") - if not isinstance(effort_value, str): + effort_value = _requested_effort(requested_params[param]) + if effort_value is None: continue mapped_thinking = AnthropicConfig._map_reasoning_effort( reasoning_effort=effort_value, @@ -2001,6 +2014,14 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): ): optional_params["service_tier"] = anthropic_tier # rebind-ok: in-place fill is this method's contract + mapped_params: Final = cast( # cast-ok: optional_params is the untyped dict of the shared signature + MutableMapping[str, object], optional_params + ) + if "thinking" not in mapped_params and ( + "thinking" in requested_params or _requested_effort(requested_params.get("reasoning_effort")) is not None + ): + drop_clear_thinking_edits_for_model(mapped_params, model, self._resolved_provider) + ## handle thinking tokens self.update_optional_params_with_thinking_tokens( non_default_params=non_default_params, optional_params=optional_params @@ -2287,7 +2308,11 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): and not any_assistant_message_has_thinking_blocks(messages) ): if litellm.modify_params: - optional_params.pop("thinking", None) + mapped_params: Final = cast( # cast-ok: optional_params is the untyped dict of the shared signature + MutableMapping[str, object], optional_params + ) + mapped_params.pop("thinking", None) + drop_clear_thinking_edits_for_model(mapped_params, model, self._resolved_provider) litellm.verbose_logger.warning( "Dropping 'thinking' param because the last assistant message with tool_calls " "has no thinking_blocks. The model won't use extended thinking for this turn." diff --git a/litellm/llms/anthropic/common_utils.py b/litellm/llms/anthropic/common_utils.py index 1f5e4e7de39..4f609c7759a 100644 --- a/litellm/llms/anthropic/common_utils.py +++ b/litellm/llms/anthropic/common_utils.py @@ -1780,17 +1780,63 @@ def strip_encrypted_reasoning_blocks_from_anthropic_messages( return [m for m in stripped if m is not None] +def _is_clear_thinking_edit(edit: object) -> bool: + match edit: + case {"type": str(edit_type)}: + return edit_type.startswith("clear_thinking_") + case _: + return False + + +def drop_clear_thinking_edits(params: MutableMapping[str, object]) -> None: # mutable-ok: edits in place + """ + Remove the ``clear_thinking_*`` context-management edits, which Anthropic + rejects without ``thinking``. ``context_management`` itself goes only when + such an edit was removed and nothing else is left in it. + """ + raw_context_management: Final = params.get("context_management") + if not isinstance(raw_context_management, dict): + return + context_management: Final = OBJECT_MAPPING_ADAPTER.validate_python(raw_context_management) + raw_edits: Final = context_management.get("edits") + if not isinstance(raw_edits, list): + return + edits: Final = OBJECT_LIST_ADAPTER.validate_python(raw_edits) + kept_edits: Final = [edit for edit in edits if not _is_clear_thinking_edit(edit)] + if len(kept_edits) == len(edits): + return + if not kept_edits and len(context_management) == 1: + params.pop("context_management") + return + params["context_management"] = { # rebind-ok: callers mutate their request params in place + **context_management, + "edits": kept_edits, + } + + +def drop_clear_thinking_edits_for_model( + params: MutableMapping[str, object], # mutable-ok: edits in place + model: str, + custom_llm_provider: str, +) -> None: + """Remove the clear_thinking edits unless the model always thinks anyway.""" + if not AnthropicModelInfo.supports_model_capability(model, "thinking_always_on", custom_llm_provider): + drop_clear_thinking_edits(params) + + def strip_thinking_blocks_from_anthropic_messages_request_dict( data: dict[str, object], ) -> None: """ Mutate an Anthropic Messages-style request dict: strip thinking blocks from - ``messages`` and remove the top-level ``thinking`` extended-thinking param. + ``messages`` and remove ``thinking`` with its dependent context-management + edits (see :func:`drop_clear_thinking_edits`). """ msgs: Final = data.get("messages") if isinstance(msgs, list): data["messages"] = strip_thinking_blocks_from_anthropic_messages(msgs) data.pop("thinking", None) + drop_clear_thinking_edits(data) _DROP_ANTHROPIC_MESSAGE: Final = object() diff --git a/litellm/llms/anthropic/messages/native/transformation.py b/litellm/llms/anthropic/messages/native/transformation.py index cc53858f842..6869c3d4095 100644 --- a/litellm/llms/anthropic/messages/native/transformation.py +++ b/litellm/llms/anthropic/messages/native/transformation.py @@ -1,4 +1,4 @@ -from collections.abc import AsyncIterator, Mapping, Sequence +from collections.abc import AsyncIterator, Mapping, MutableMapping, Sequence from typing import ClassVar, Final import httpx @@ -31,6 +31,7 @@ from litellm.types.router import GenericLiteLLMParams from ...common_utils import ( AnthropicError, AnthropicModelInfo, + drop_clear_thinking_edits_for_model, merge_anthropic_beta_headers, optionally_handle_anthropic_oauth, requires_native_compaction_beta, @@ -92,6 +93,15 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): def _resolved_provider(self) -> str: return self.custom_llm_provider or "anthropic" + def _drop_thinking( + self, + optional_params: MutableMapping[str, object], # mutable-ok: edits optional params in place + model: str, + custom_llm_provider: str, + ) -> None: + optional_params.pop("thinking", None) + drop_clear_thinking_edits_for_model(optional_params, model, custom_llm_provider) + def get_supported_anthropic_messages_params(self, model: str) -> list: return [ "messages", @@ -412,9 +422,8 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): messages=messages, ) - @staticmethod def _translate_reasoning_effort_to_anthropic( - model: str, optional_params: dict, max_tokens: int | None, custom_llm_provider: str + self, model: str, optional_params: dict[str, object], max_tokens: int | None, custom_llm_provider: str ) -> None: """Map OpenAI-style ``reasoning_effort`` to native Anthropic params. @@ -443,7 +452,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): raise AnthropicError(message=str(e.message), status_code=400) if mapped_thinking is None: - optional_params.pop("thinking", None) + self._drop_thinking(optional_params, model, custom_llm_provider) optional_params.pop("output_config", None) return @@ -473,9 +482,8 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): existing_output_config.setdefault("effort", mapped_effort) optional_params["output_config"] = existing_output_config - @staticmethod def _translate_adaptive_effort_for_non_adaptive_model( - model: str, optional_params: dict, max_tokens: int | None, custom_llm_provider: str + self, model: str, optional_params: dict[str, object], max_tokens: int | None, custom_llm_provider: str ) -> None: """Translate the 4.6+ adaptive-thinking interface (``thinking.type=adaptive`` and/or ``output_config.effort``) down to what an older Anthropic model @@ -538,7 +546,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): or AnthropicConfig.validate_effort_for_model(model, effort, custom_llm_provider) is None ): if adaptive_thinking: - optional_params.pop("thinking", None) + self._drop_thinking(optional_params, model, custom_llm_provider) return supports_thinking: Final = AnthropicModelInfo.supports_model_capability( @@ -566,7 +574,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): optional_params["thinking"] = capped_thinking else: verbose_logger.warning(DROP_UNSUPPORTED_ADAPTIVE_EFFORT_WARNING, model) - optional_params.pop("thinking", None) + self._drop_thinking(optional_params, model, custom_llm_provider) if isinstance(output_config, dict) and "effort" in output_config: residual: Final = {k: v for k, v in output_config.items() if k != "effort"} @@ -642,7 +650,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): self._translate_reasoning_effort_to_anthropic( model=model, - optional_params=anthropic_messages_optional_request_params, + optional_params=anthropic_messages_optional_request_params, # pyright: ignore[reportUnknownArgumentType] # the request params arrive as a bare dict max_tokens=max_tokens, custom_llm_provider=self._resolved_provider, ) @@ -662,7 +670,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): self._translate_adaptive_effort_for_non_adaptive_model( model=model, - optional_params=anthropic_messages_optional_request_params, + optional_params=anthropic_messages_optional_request_params, # pyright: ignore[reportUnknownArgumentType] # the request params arrive as a bare dict max_tokens=max_tokens, custom_llm_provider=self._resolved_provider, ) diff --git a/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude_transformation.py b/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude_transformation.py index e1e15861a90..55d55d413e9 100644 --- a/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude_transformation.py +++ b/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude_transformation.py @@ -1,4 +1,4 @@ -from collections.abc import AsyncIterator, Mapping, Sequence +from collections.abc import AsyncIterator, Mapping, MutableMapping, Sequence from types import MappingProxyType from typing import TYPE_CHECKING, Any, Final, cast @@ -99,6 +99,17 @@ class AmazonAnthropicClaudeMessagesConfig( def beta_headers_provider(self) -> str: return self.custom_llm_provider or "bedrock" + def _drop_thinking( + self, + optional_params: MutableMapping[str, object], # mutable-ok: edits optional params in place + model: str, + custom_llm_provider: str, + ) -> None: + """Keep the clear_thinking edit: it makes + ``_ensure_thinking_for_clear_thinking_context_management`` add thinking back, + and ``_filter_context_management_for_bedrock_invoke`` strips it before sending.""" + optional_params.pop("thinking", None) + def should_strip_billing_metadata(self) -> bool: return True diff --git a/litellm/llms/bedrock_mantle/messages/transformation.py b/litellm/llms/bedrock_mantle/messages/transformation.py index aa6510f0491..2ce1a7a4e81 100644 --- a/litellm/llms/bedrock_mantle/messages/transformation.py +++ b/litellm/llms/bedrock_mantle/messages/transformation.py @@ -3,6 +3,7 @@ from types import MappingProxyType from typing import Final from litellm.litellm_core_utils.url_utils import join_api_base +from litellm.llms.anthropic.common_utils import drop_clear_thinking_edits_for_model from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM from litellm.llms.bedrock.common_utils import MANTLE_MESSAGES_PATH from litellm.llms.bedrock.messages.mantle_transformation import AmazonMantleMessagesConfig @@ -45,6 +46,22 @@ class BedrockMantleAnthropicMessagesConfig(BedrockMantleAuthMixin, AmazonMantleM } ) + def _ensure_thinking_for_clear_thinking_context_management( + self, + anthropic_messages_request: dict[str, object], # mutable-ok: edits the request in place like the base + model: str, + drop_params: bool, + ) -> bool: + injected_thinking: Final = super()._ensure_thinking_for_clear_thinking_context_management( + anthropic_messages_request, model, drop_params + ) + match anthropic_messages_request.get("thinking"): + case {"type": "enabled" | "adaptive"}: + pass + case _: + drop_clear_thinking_edits_for_model(anthropic_messages_request, model, self._resolved_provider) + return injected_thinking + def __init__(self, aws_signer: BaseAWSLLM | None = None) -> None: AmazonMantleMessagesConfig.__init__(self) self._aws_signer = aws_signer or self 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 4d5d6cc17a9..d90337709a0 100644 --- a/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py +++ b/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py @@ -9807,3 +9807,106 @@ def test_transform_request_tool_config_point_without_tools_drops_the_key(): assert "cache_control_injection_points" not in data assert "tools" not in data assert litellm_params["metadata"] == {} + + +CLEAR_THINKING_EDIT: Final = {"type": "clear_thinking_20251015", "keep": "all"} +CLEAR_TOOL_USES_EDIT: Final = {"type": "clear_tool_uses_20250919"} + + +def _edits_for(keeps_thinking: bool) -> list[dict[str, str]]: + return [CLEAR_THINKING_EDIT, CLEAR_TOOL_USES_EDIT] if keeps_thinking else [CLEAR_TOOL_USES_EDIT] + + +@pytest.mark.parametrize( + ("model", "params", "keeps_thinking"), + [ + pytest.param( + "claude-haiku-4-5", + {"thinking": {"type": "adaptive"}, "max_tokens": 1000}, + False, + id="adaptive-unfit-dropped", + ), + pytest.param( + "claude-haiku-4-5", {"thinking": {"type": "adaptive"}, "max_tokens": 8192}, True, id="adaptive-translated" + ), + pytest.param("claude-sonnet-4-5", {"reasoning_effort": "none"}, False, id="reasoning-effort-none"), + pytest.param( + "claude-sonnet-4-5", {"reasoning_effort": {"effort": "none"}}, False, id="reasoning-effort-dict-none" + ), + pytest.param("claude-sonnet-4-5", {"reasoning_effort": "high"}, True, id="reasoning-effort-high"), + ], +) +@pytest.mark.parametrize("context_management_first", [True, False]) +def test_map_openai_params_drops_clear_thinking_edit_exactly_when_thinking_is( + model, params, keeps_thinking, context_management_first +): + context_management: Final = {"context_management": {"edits": [CLEAR_THINKING_EDIT, CLEAR_TOOL_USES_EDIT]}} + rest: Final = {"max_tokens": 8192, **params} + result = AnthropicConfig().map_openai_params( + non_default_params={**context_management, **rest} + if context_management_first + else {**rest, **context_management}, + optional_params={}, + model=model, + drop_params=False, + ) + + assert ("thinking" in result) is keeps_thinking + assert result["context_management"] == {"edits": _edits_for(keeps_thinking)} + + +@pytest.mark.parametrize( + ("model", "reasoning_effort"), + [ + pytest.param("claude-fable-5", "none", id="always-on-model"), + pytest.param("claude-sonnet-4-5", {"summary": "concise"}, id="effort-less-dict"), + ], +) +def test_map_openai_params_keeps_clear_thinking_edit_when_thinking_was_not_dropped(model, reasoning_effort): + result = AnthropicConfig().map_openai_params( + non_default_params={ + "context_management": {"edits": [CLEAR_THINKING_EDIT]}, + "max_tokens": 8192, + "reasoning_effort": reasoning_effort, + }, + optional_params={}, + model=model, + drop_params=False, + ) + + assert result["context_management"] == {"edits": [CLEAR_THINKING_EDIT]} + + +@pytest.mark.parametrize( + ("model", "modify_params", "keeps_thinking", "keeps_edit"), + [ + pytest.param("claude-sonnet-4-5", True, False, False, id="dropped"), + pytest.param("claude-sonnet-4-5", False, True, True, id="modify-params-off"), + pytest.param("claude-fable-5", True, False, True, id="always-on-keeps-edit"), + ], +) +def test_transform_request_drops_clear_thinking_edit_with_thinking_for_tool_turn_without_thinking_blocks( + monkeypatch, model, modify_params, keeps_thinking, keeps_edit +): + monkeypatch.setattr(litellm, "modify_params", modify_params) + result = AnthropicConfig().transform_request( + model=model, + messages=[ + {"role": "user", "content": "Look it up"}, + { + "role": "assistant", + "content": None, + "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "lookup", "arguments": "{}"}}], + }, + {"role": "tool", "tool_call_id": "call_1", "content": "found"}, + ], + optional_params={ + "thinking": {"type": "enabled", "budget_tokens": 1024}, + "context_management": {"edits": [CLEAR_THINKING_EDIT, CLEAR_TOOL_USES_EDIT]}, + }, + litellm_params={}, + headers={}, + ) + + assert ("thinking" in result) is keeps_thinking + assert result["context_management"] == {"edits": _edits_for(keeps_edit)} diff --git a/tests/unit/llms/anthropic/messages/native/test_anthropic_messages_effort.py b/tests/unit/llms/anthropic/messages/native/test_anthropic_messages_effort.py index 94476139313..37592a66c9f 100644 --- a/tests/unit/llms/anthropic/messages/native/test_anthropic_messages_effort.py +++ b/tests/unit/llms/anthropic/messages/native/test_anthropic_messages_effort.py @@ -1,3 +1,5 @@ +from typing import Final + import pytest from litellm.constants import ( @@ -334,3 +336,45 @@ def test_reasoning_effort_budget_capped_for_openai_like_messages_upstream(): assert result["thinking"] == {"type": "enabled", "budget_tokens": 3999} assert result["max_tokens"] == 4000 + + +CLEAR_THINKING_EDIT: Final = {"type": "clear_thinking_20251015", "keep": "all"} +CLEAR_TOOL_USES_EDIT: Final = {"type": "clear_tool_uses_20250919"} + + +@pytest.mark.parametrize( + ("model", "params", "keeps_thinking"), + [ + pytest.param("claude-sonnet-4-5", {"reasoning_effort": "none"}, False, id="reasoning-effort-none"), + pytest.param("claude-haiku-4-5", {"reasoning_effort": "high"}, True, id="reasoning-effort-high"), + pytest.param("claude-opus-4-5", _claude_code_payload(effort="high"), False, id="effort-model-drops-adaptive"), + pytest.param("claude-sonnet-4-6", _claude_code_payload(effort="high"), True, id="adaptive-model"), + pytest.param("claude-3-5-haiku-latest", _claude_code_payload(), False, id="non-reasoning-model"), + pytest.param("claude-haiku-4-5", _claude_code_payload(), True, id="legacy-thinking-model"), + ], +) +def test_clear_thinking_edit_dropped_exactly_when_thinking_is(model, params, keeps_thinking): + """Anthropic rejects a clear_thinking edit without thinking, so every path that drops + thinking must drop the edit too, and leave other edits alone""" + result = _transform( + model, + { + "max_tokens": 8192, + **params, + "context_management": {"edits": [CLEAR_THINKING_EDIT, CLEAR_TOOL_USES_EDIT]}, + }, + ) + + assert ("thinking" in result) is keeps_thinking + expected_edits = [CLEAR_THINKING_EDIT, CLEAR_TOOL_USES_EDIT] if keeps_thinking else [CLEAR_TOOL_USES_EDIT] + assert result["context_management"] == {"edits": expected_edits} + + +def test_clear_thinking_edit_kept_for_always_on_thinking_model_without_thinking(): + """An always-on model keeps thinking adaptively after reasoning_effort='none' drops the param""" + result = _transform( + "claude-fable-5", + {"max_tokens": 8192, "reasoning_effort": "none", "context_management": {"edits": [CLEAR_THINKING_EDIT]}}, + ) + + assert result["context_management"] == {"edits": [CLEAR_THINKING_EDIT]} diff --git a/tests/unit/llms/anthropic/test_anthropic_common_utils.py b/tests/unit/llms/anthropic/test_anthropic_common_utils.py index eb5c1598c17..bd25138a8cc 100644 --- a/tests/unit/llms/anthropic/test_anthropic_common_utils.py +++ b/tests/unit/llms/anthropic/test_anthropic_common_utils.py @@ -1610,6 +1610,52 @@ class TestAnthropicThinkingSignatureSelfHeal: assert "thinking" not in data assert data["messages"] == [] + def test_strip_thinking_request_dict_drops_clear_thinking_edits(self): + from litellm.llms.anthropic.common_utils import ( + strip_thinking_blocks_from_anthropic_messages_request_dict, + ) + + data = { + "messages": [{"role": "assistant", "content": [{"type": "thinking", "thinking": "unsigned"}]}], + "thinking": {"type": "adaptive"}, + "context_management": {"edits": [{"type": "clear_thinking_20251015", "keep": "all"}]}, + } + strip_thinking_blocks_from_anthropic_messages_request_dict(data) + assert "context_management" not in data + + @pytest.mark.parametrize( + ("context_management", "expected"), + [ + pytest.param( + {"edits": [{"type": "clear_thinking_20990101"}, {"type": "clear_tool_uses_20250919"}]}, + {"edits": [{"type": "clear_tool_uses_20250919"}]}, + id="any-clear-thinking-version-dropped-others-kept", + ), + pytest.param({"edits": [{"type": "clear_thinking_20251015"}]}, None, id="emptied-removed"), + pytest.param( + {"edits": [{"type": "clear_thinking_20251015"}], "extra": 1}, + {"edits": [], "extra": 1}, + id="other-keys-kept", + ), + pytest.param({"edits": []}, {"edits": []}, id="empty-edits-untouched"), + pytest.param("clear_thinking_20251015", "clear_thinking_20251015", id="non-dict-untouched"), + pytest.param({"edits": "clear_thinking"}, {"edits": "clear_thinking"}, id="non-list-edits-untouched"), + pytest.param( + {"edits": ["clear_thinking_20251015", {"type": "clear_thinking_20251015"}]}, + {"edits": ["clear_thinking_20251015"]}, + id="non-dict-edit-kept", + ), + ], + ) + def test_drop_clear_thinking_edits(self, context_management, expected): + from litellm.llms.anthropic.common_utils import ( + drop_clear_thinking_edits, + ) + + params = {"context_management": context_management} + drop_clear_thinking_edits(params) + assert params == ({} if expected is None else {"context_management": expected}) + def test_strip_empty_content_blocks_from_anthropic_messages(self): """Covers #22930. The core regression scenario: an assistant message with an empty text block alongside ``tool_use`` loses the empty block diff --git a/tests/unit/llms/bedrock/messages/invoke_transformations/test_anthropic_claude_transformation.py b/tests/unit/llms/bedrock/messages/invoke_transformations/test_anthropic_claude_transformation.py index 3c0bbadc133..cf31bdec96c 100644 --- a/tests/unit/llms/bedrock/messages/invoke_transformations/test_anthropic_claude_transformation.py +++ b/tests/unit/llms/bedrock/messages/invoke_transformations/test_anthropic_claude_transformation.py @@ -2195,6 +2195,36 @@ def test_bedrock_invoke_transform_emits_adaptive_thinking_for_opus_4_8(): assert result["output_config"]["effort"] == "low" +@pytest.mark.parametrize( + "thinking_params", + [ + pytest.param({"thinking": {"type": "adaptive"}, "output_config": {"effort": "high"}}, id="adaptive-effort"), + pytest.param({"reasoning_effort": "none"}, id="reasoning-effort-none"), + ], +) +def test_bedrock_invoke_keeps_thinking_for_clear_thinking_edit_when_base_drops_thinking(thinking_params): + """The base messages transform drops thinking for Opus 4.5 here. Bedrock keeps the + clear_thinking edit long enough to add the minimum enabled thinking back, then strips it""" + from litellm.types.router import GenericLiteLLMParams + + result = AmazonAnthropicClaudeMessagesConfig().transform_anthropic_messages_request( + model="anthropic.claude-opus-4-5-20251101-v1:0", + messages=[{"role": "user", "content": "hi"}], + anthropic_messages_optional_request_params={ + "max_tokens": 32000, + **thinking_params, + "context_management": { + "edits": [{"type": "clear_thinking_20251015", "keep": "all"}, {"type": "clear_tool_uses_20250919"}] + }, + }, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert result["thinking"] == {"type": "enabled", "budget_tokens": BEDROCK_MIN_THINKING_BUDGET_TOKENS} + assert result["context_management"] == {"edits": [{"type": "clear_tool_uses_20250919"}]} + + def test_bedrock_invoke_transform_normalizes_system_role_message_into_system(): """Bedrock Invoke rejects ``role: "system"`` entries in ``messages`` on some Claude aliases; they must be moved into the top-level ``system`` field diff --git a/tests/unit/llms/bedrock_mantle/test_bedrock_mantle_messages_transformation.py b/tests/unit/llms/bedrock_mantle/test_bedrock_mantle_messages_transformation.py index 3236efbbfba..5cca57744b0 100644 --- a/tests/unit/llms/bedrock_mantle/test_bedrock_mantle_messages_transformation.py +++ b/tests/unit/llms/bedrock_mantle/test_bedrock_mantle_messages_transformation.py @@ -19,6 +19,7 @@ import respx import litellm from litellm.caching.llm_caching_handler import LLMClientCache +from litellm.constants import BEDROCK_MIN_THINKING_BUDGET_TOKENS from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM from litellm.llms.bedrock_mantle.messages.transformation import ( BedrockMantleAnthropicMessagesConfig, @@ -224,6 +225,68 @@ class TestRequestBody: ) assert "stream" not in body + @pytest.mark.parametrize( + ("model", "thinking_params", "expected_thinking", "expected_edit_types"), + [ + pytest.param( + "anthropic.claude-3-5-haiku-20241022-v1:0", + {"thinking": {"type": "adaptive"}}, + None, + ["clear_tool_uses_20250919"], + id="no-thinking-support-drops-edit", + ), + pytest.param( + "anthropic.claude-opus-4-5-20251101-v1:0", + {"thinking": {"type": "adaptive"}, "output_config": {"effort": "high"}}, + {"type": "enabled", "budget_tokens": BEDROCK_MIN_THINKING_BUDGET_TOKENS}, + ["clear_thinking_20251015", "clear_tool_uses_20250919"], + id="thinking-added-back-keeps-edit", + ), + pytest.param( + "anthropic.claude-mythos-preview", + {}, + None, + ["clear_thinking_20251015", "clear_tool_uses_20250919"], + id="always-on-model-keeps-edit", + ), + pytest.param( + "anthropic.claude-sonnet-4-6", + {"thinking": {"type": "adaptive"}}, + {"type": "adaptive"}, + ["clear_thinking_20251015", "clear_tool_uses_20250919"], + id="adaptive-thinking-keeps-edit", + ), + ], + ) + def test_clear_thinking_edit_forwarded_only_with_thinking( + self, model, thinking_params, expected_thinking, expected_edit_types + ): + body = BedrockMantleAnthropicMessagesConfig().transform_anthropic_messages_request( + model=model, + messages=[{"role": "user", "content": "ping"}], + anthropic_messages_optional_request_params={ + "max_tokens": 32000, + **thinking_params, + "context_management": { + "edits": [{"type": "clear_thinking_20251015", "keep": "all"}, {"type": "clear_tool_uses_20250919"}] + }, + }, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + assert body.get("thinking") == expected_thinking + assert [edit["type"] for edit in body["context_management"]["edits"]] == expected_edit_types + + def test_disabled_thinking_without_edits_is_untouched(self): + body = BedrockMantleAnthropicMessagesConfig().transform_anthropic_messages_request( + model="anthropic.claude-3-5-haiku-20241022-v1:0", + messages=[{"role": "user", "content": "ping"}], + anthropic_messages_optional_request_params={"max_tokens": 8, "thinking": {"type": "disabled"}}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + assert body["thinking"] == {"type": "disabled"} + class TestAuth: def test_bearer_from_api_key_skips_aws_credentials(self):