This commit is contained in:
Sami Jawhar 2026-10-29 05:28:05 +00:00 • committed by GitHub
commit 25c4d0e08e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 569 additions and 36 deletions

View file

@ -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<ContextEdit>) -> 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<Recognized<ContextManagement>>,
) -> Option<Recognized<ContextManagement>> {
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<Value>,
) {
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)]

View file

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

View file

@ -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()

View file

@ -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,
)

View file

@ -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

View file

@ -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

View file

@ -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)}

View file

@ -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]}

View file

@ -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

View file

@ -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

View file

@ -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):