mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-11 08:38:24 +00:00
Merge de22512809 into a66dc50efb
This commit is contained in:
commit
25c4d0e08e
11 changed files with 569 additions and 36 deletions
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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]}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue