fix(anthropic): drop the non-spec custom object from /v1/messages tool definitions (#48893)

* fix(anthropic): drop the non-spec custom object from /v1/messages tool definitions

Anthropic and Vertex AI reject a tool carrying a custom object with tools.N.custom.custom: Extra inputs are not permitted. Drop it on the shared Anthropic request path, keep the hoist of a boolean custom.defer_loading to top-level defer_loading, reuse the helper on Bedrock, and stop the chat adapter from merging custom into the tool's JSON schema

* fix(messages): hoist custom.defer_loading only on Claude routes and strip custom on count_tokens

Non-Claude Messages providers such as DeepSeek now get custom dropped without a defer_loading hoist, since they have no tool search. /v1/messages/count_tokens on Anthropic, Azure AI, and Vertex AI drops custom too, and the local token counter no longer counts it as schema.

* fix(anthropic): drop tool custom object only under drop_params and hoist defer_loading for Claude models

Without drop_params the tool is forwarded as sent, so the provider's 400 stands. A boolean custom.defer_loading still becomes the top-level flag for Claude models and on Bedrock Invoke and Mantle. Bedrock count_tokens now normalizes invokeModel tools the way Bedrock Messages sends them.

* fix(bedrock): drop tool custom object on the Mantle count_tokens fallback

Mantle Messages drops custom unconditionally, so its count_tokens call now counts the tools the same way.

* fix(messages): keep custom-tool Messages calls on Python and match Mantle count_tokens to Mantle Messages

* test(vertex): annotate count_tokens tool test locals with Final

* fix(messages): pass tools without custom through untouched so Bedrock fallbacks keep filled tool names

* fix(messages): keep Bedrock's in-place tool cleanup so fallbacks still work, and stop hoisting defer_loading on the OpenAI-like passthrough

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
zoveki 2026-10-29 03:36:20 -07:00 • committed by GitHub
parent 42d2909210
commit 310c96162b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 546 additions and 49 deletions

View file

@ -611,7 +611,7 @@ T_ParamSpec: Final = ParamSpec("T_ParamSpec")
T_Retval = TypeVar("T_Retval")
_COUNT_OFFLOAD_LIMITER: Final = anyio.lowlevel.RunVar[anyio.CapacityLimiter]("litellm_count_offload_limiter")
_ANTHROPIC_TOOL_KEYS_MAPPED_BY_BRIDGE: Final = frozenset(
("name", "input_schema", "description", "cache_control", "strict", "type", "eager_input_streaming")
("name", "input_schema", "description", "cache_control", "strict", "type", "eager_input_streaming", "custom")
)

View file

@ -121,6 +121,54 @@ _DECODED_JSON: Final = TypeAdapter(object)
_CLAUDE_CODE_USER_AGENT_PREFIXES: Final = ("claude-cli/", "claude-code/")
def is_claude_tool_target(custom_llm_provider: str | None, model: str) -> bool:
if custom_llm_provider == "anthropic":
return True
model_lower: Final = model.lower()
if custom_llm_provider == "bedrock":
return "claude" in model_lower or ("arn:" in model_lower and ":bedrock:" in model_lower)
if custom_llm_provider == "azure_ai" or custom_llm_provider == "vertex_ai":
return "claude" in model_lower
return False
def is_claude_model_target(custom_llm_provider: str | None, model: str) -> bool:
return "claude" in model.lower() and is_claude_tool_target(custom_llm_provider, model)
def _hoistable_defer_loading(tool_dict: Mapping[object, object], custom: object) -> bool | None:
if not isinstance(custom, dict) or "defer_loading" in tool_dict:
return None
defer_loading: Final = OBJECT_MAPPING_ADAPTER.validate_python(custom).get("defer_loading")
return defer_loading if isinstance(defer_loading, bool) else None
def _tool_without_custom_field(tool: object, hoist_defer_loading: bool, drop_custom: bool) -> object:
if not isinstance(tool, dict):
return tool
if "custom" not in tool:
return tool
tool_dict: Final = OBJECT_MAPPING_ADAPTER.validate_python(tool)
custom: Final = tool_dict["custom"]
without_custom: Final = {key: value for key, value in tool_dict.items() if key != "custom"}
defer_loading: Final = _hoistable_defer_loading(tool_dict, custom) if hoist_defer_loading else None
if defer_loading is None:
return without_custom if drop_custom else tool_dict
unhoisted: Final = {
key: value for key, value in OBJECT_MAPPING_ADAPTER.validate_python(custom).items() if key != "defer_loading"
}
kept_custom: Final = {"custom": unhoisted} if unhoisted and not drop_custom else {}
return {**without_custom, "defer_loading": defer_loading, **kept_custom}
def tools_without_custom_field(tools: Sequence[object], hoist_defer_loading: bool, drop_custom: bool) -> list[object]:
return [_tool_without_custom_field(tool, hoist_defer_loading, drop_custom) for tool in tools]
def is_anthropic_messages_url(url: str) -> bool:
"""Check whether a URL addresses Anthropic's Messages API."""
parsed_url: Final = urlparse(url)

View file

@ -39,6 +39,7 @@ class AnthropicCountTokensHandler(AnthropicCountTokensConfig):
tools: list[dict[str, JsonValue]] | None = None,
system: JsonValue = None,
optional_params: Mapping[str, JsonValue] | None = None,
drop_params: bool = False,
) -> dict[str, JsonValue]:
"""
Handle a CountTokens request using httpx.
@ -69,6 +70,7 @@ class AnthropicCountTokensHandler(AnthropicCountTokensConfig):
tools=tools,
system=system,
optional_params=optional_params,
drop_params=drop_params,
)
verbose_logger.debug("Transformed request: %s", request_body)

View file

@ -11,6 +11,7 @@ from pydantic import JsonValue
import litellm
from litellm._logging import verbose_logger
from litellm.exceptions import AuthenticationError
from litellm.litellm_core_utils.core_helpers import drops_unsupported_params
from litellm.llms.anthropic.count_tokens.handler import AnthropicCountTokensHandler
from litellm.llms.anthropic.count_tokens.transformation import (
AnthropicCountFields,
@ -131,6 +132,7 @@ class AnthropicTokenCounter(BaseTokenCounter):
tools=tools,
system=system,
optional_params=count_options,
drop_params=drops_unsupported_params(litellm_params.get("drop_params")),
)
if result is not None:

View file

@ -26,7 +26,11 @@ from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import
split_leading_system_run,
)
from litellm.litellm_core_utils.url_utils import join_api_base
from litellm.llms.anthropic.common_utils import merge_anthropic_beta_headers
from litellm.llms.anthropic.common_utils import (
is_claude_model_target,
merge_anthropic_beta_headers,
tools_without_custom_field,
)
from litellm.llms.anthropic.wif import resolve_anthropic_base
from litellm.types.llms.openai import AllMessageValues, ChatCompletionImageObject, ValidUserMessageContentTypes
from litellm.types.utils import TokenCountMessagesFormat, TokenCountResponse
@ -372,6 +376,7 @@ class AnthropicCountTokensConfig:
tools: list[dict[str, JsonValue]] | None = None,
system: JsonValue = None,
optional_params: Mapping[str, JsonValue] | None = None,
drop_params: bool = False,
) -> dict[str, JsonValue]: # mutable-ok: provider transport requires JSON dictionaries
"""
Transform request to Anthropic CountTokens format.
@ -391,7 +396,19 @@ class AnthropicCountTokensConfig:
**MappingProxyType(
{
key: value
for key, value in (("system", counted_system), ("tools", tools))
for key, value in (
("system", counted_system),
(
"tools",
None
if tools is None
else tools_without_custom_field(
tools,
hoist_defer_loading=is_claude_model_target("anthropic", model),
drop_custom=drop_params,
),
),
)
if value is not None
}
),

View file

@ -817,6 +817,7 @@ class LiteLLMAnthropicMessagesAdapter:
"name",
"input_schema",
"description",
"custom",
"cache_control",
"strict",
"type",

View file

@ -29,13 +29,16 @@ from litellm.types.llms.anthropic_tool_search import get_tool_search_beta_header
from litellm.types.router import GenericLiteLLMParams
from ...common_utils import (
OBJECT_LIST_ADAPTER,
AnthropicError,
AnthropicModelInfo,
is_claude_model_target,
merge_anthropic_beta_headers,
optionally_handle_anthropic_oauth,
requires_native_compaction_beta,
strip_advisor_blocks_from_messages,
strip_encrypted_reasoning_blocks_from_anthropic_messages,
tools_without_custom_field,
)
from .mid_conversation_system import (
as_system_content_blocks,
@ -181,6 +184,16 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
"""
return False
def hoists_wrapped_defer_loading(self, model: str) -> bool:
return is_claude_model_target(self._resolved_provider, model)
def tools_for_upstream(self, tools: Sequence[object], model: str, drop_params: object) -> Sequence[object]:
return tools_without_custom_field(
tools,
hoist_defer_loading=self.hoists_wrapped_defer_loading(model),
drop_custom=drops_unsupported_params(drop_params),
)
@staticmethod
def _filter_billing_headers_from_system(system_param):
"""
@ -700,7 +713,8 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
# Auto-strip advisor blocks from history if advisor tool is absent.
# Prevents Anthropic 400: advisor_tool_result in history requires advisor tool.
_tools: Final = anthropic_messages_optional_request_params.get("tools") or []
tools: Final = anthropic_messages_optional_request_params.get("tools")
_tools: Final = tools or []
_has_advisor: Final = any(isinstance(t, dict) and t.get("type") == ANTHROPIC_ADVISOR_TOOL_TYPE for t in _tools)
if not _has_advisor:
messages = strip_advisor_blocks_from_messages(messages)
@ -713,7 +727,14 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
model=model,
**anthropic_messages_optional_request_params,
)
return dict(anthropic_messages_request)
if not isinstance(tools, list):
return dict(anthropic_messages_request)
return {
**anthropic_messages_request,
"tools": self.tools_for_upstream(
OBJECT_LIST_ADAPTER.validate_python(tools), model, litellm_params.drop_params
),
}
def transform_anthropic_messages_response(
self,

View file

@ -13,6 +13,7 @@ from pydantic import JsonValue
import litellm
from litellm._logging import verbose_logger
from litellm.litellm_core_utils.asyncify import asyncify
from litellm.litellm_core_utils.core_helpers import drops_unsupported_params
from litellm.llms.anthropic.common_utils import AnthropicError
from litellm.llms.azure_ai.anthropic.count_tokens.transformation import (
AzureAIAnthropicCountTokensConfig,
@ -69,6 +70,7 @@ class AzureAIAnthropicCountTokensHandler(AzureAIAnthropicCountTokensConfig):
tools=tools,
system=system,
optional_params=optional_params,
drop_params=drops_unsupported_params((litellm_params or {}).get("drop_params")),
)
verbose_logger.debug("Transformed request: %s", request_body)

View file

@ -15,7 +15,11 @@ from dataclasses import dataclass
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, Literal, TypeAlias
from typing_extensions import ReadOnly, TypedDict
from typing_extensions import (
ReadOnly,
TypedDict,
TypeIs, # noqa: TID251 # narrows the caller's tools without copying them
)
if TYPE_CHECKING:
from botocore.eventstream import EventStreamMessage
@ -573,6 +577,26 @@ def strip_unsupported_bedrock_invoke_output_config_keys(
request_body["output_config"] = {"format": preserved_format} # rebind-ok: out-param
_TOOL_ITEMS_ADAPTER: Final = TypeAdapter(tuple[object, ...])
def _is_str_keyed_dict(
value: object,
) -> TypeIs[dict[str, object]]: # guard-ok: JSON tool objects # mutable-ok: fallbacks reuse tools edited in place
return isinstance(value, dict)
def _normalize_custom_field_on_tool(tool: object) -> None:
if not _is_str_keyed_dict(tool):
return
custom: Final = tool.pop("custom", None)
if not _is_str_keyed_dict(custom) or "defer_loading" in tool:
return
deferred: Final = custom.get("defer_loading")
if isinstance(deferred, bool):
tool["defer_loading"] = deferred # rebind-ok: a fallback reuses the caller's tool, as on main
def normalize_custom_field_on_tools(request_body: dict) -> None:
"""
Drop the ``custom`` field from each tool, first hoisting a boolean
@ -590,15 +614,8 @@ def normalize_custom_field_on_tools(request_body: dict) -> None:
tools: Final = request_body.get("tools")
if not tools or not isinstance(tools, list):
return
for tool in tools:
if not isinstance(tool, dict):
continue
custom: dict[str, object] | None = tool.pop("custom", None)
if not isinstance(custom, dict) or "defer_loading" in tool:
continue
deferred: object = custom.get("defer_loading")
if isinstance(deferred, bool):
tool["defer_loading"] = deferred
for tool in _TOOL_ITEMS_ADAPTER.validate_python(tools):
_normalize_custom_field_on_tool(tool)
_TOOL_DICTS_ADAPTER: Final = TypeAdapter(tuple[Mapping[str, object], ...])

View file

@ -8,6 +8,7 @@ from typing_extensions import NotRequired, ReadOnly, TypedDict
import litellm
from litellm._logging import verbose_logger
from litellm.litellm_core_utils.url_utils import join_api_base
from litellm.llms.anthropic.common_utils import tools_without_custom_field
from litellm.llms.anthropic.count_tokens.transformation import COUNT_TOKEN_OPTION_NAMES, AnthropicCountTokensConfig
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM, run_aws_signing
from litellm.llms.bedrock.common_utils import BedrockError, build_mantle_messages_url
@ -26,6 +27,7 @@ class MantleCountTokensRequest(TypedDict):
_COUNT_REQUEST: Final = TypeAdapter(MantleCountTokensRequest)
_COUNT_RESPONSE: Final = TypeAdapter(dict[str, JsonValue])
_COUNT_OPTIONS: Final[TypeAdapter[Mapping[str, JsonValue]]] = TypeAdapter(Mapping[str, JsonValue])
_TOOLS: Final = TypeAdapter(list[dict[str, JsonValue]])
class BedrockMantleCountTokensHandler(BaseAWSLLM):
@ -59,10 +61,15 @@ class BedrockMantleCountTokensHandler(BaseAWSLLM):
self.get_aws_region_name, optional_params=litellm_params, model=resolved_model, model_id=None
)
endpoint_url: Final = await run_aws_signing(self.get_mantle_count_tokens_endpoint, aws_region_name)
tools: Final = request.get("tools")
body: Final = self._anthropic_config.transform_request_to_count_tokens(
model=resolved_model,
messages=request["messages"],
tools=request.get("tools"),
tools=None
if tools is None
else _TOOLS.validate_python(
tools_without_custom_field(tools, hoist_defer_loading=True, drop_custom=True)
),
system=request.get("system"),
optional_params=_COUNT_OPTIONS.validate_python(
{name: value for name, value in request_data.items() if name in COUNT_TOKEN_OPTION_NAMES}

View file

@ -12,6 +12,7 @@ from typing import Final, Literal
from pydantic import JsonValue
from litellm.litellm_core_utils.url_utils import join_api_base
from litellm.llms.anthropic.common_utils import tools_without_custom_field
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
from litellm.llms.bedrock.common_utils import get_bedrock_base_model
@ -194,7 +195,15 @@ class BedrockCountTokensConfig(BaseAWSLLM):
# For InvokeModel, we need to provide the raw body that would be sent to the model
# Remove the 'model' field from the body as it's not part of the model input
body_data: Final = {k: v for k, v in request_data.items() if k != "model"}
tools: Final = request_data.get("tools")
body_data: Final = {
**{k: v for k, v in request_data.items() if k != "model"},
**(
{"tools": tools_without_custom_field(tools, hoist_defer_loading=True, drop_custom=True)}
if isinstance(tools, list)
else {}
),
}
if "messages" in body_data:
# Bedrock validates the body against the model's InvokeModel schema;

View file

@ -102,6 +102,9 @@ class AmazonAnthropicClaudeMessagesConfig(
def should_strip_billing_metadata(self) -> bool:
return True
def tools_for_upstream(self, tools: Sequence[object], model: str, drop_params: object) -> Sequence[object]:
return tools
def drops_dangerous_tool_use_beta_without_safeguards(self) -> bool:
return True

View file

@ -37,6 +37,9 @@ class OpenAILikeAnthropicMessagesConfig(AnthropicMessagesConfig):
def is_native_messages_route(self) -> bool:
return False
def hoists_wrapped_defer_loading(self, model: str) -> bool:
return False
def validate_anthropic_messages_environment(
self,
headers: dict[str, str],

View file

@ -6,9 +6,12 @@ Unlike Gemini models which use Google's token counting API, partner models use
their respective publisher-specific count-tokens endpoints.
"""
from collections.abc import Mapping
from typing import Any, Final
from litellm.litellm_core_utils.core_helpers import drops_unsupported_params
from litellm.litellm_core_utils.url_utils import join_api_base
from litellm.llms.anthropic.common_utils import OBJECT_LIST_ADAPTER, is_claude_model_target, tools_without_custom_field
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
from litellm.llms.vertex_ai.common_utils import get_vertex_base_url
from litellm.llms.vertex_ai.vertex_llm_base import VertexBase
@ -20,6 +23,22 @@ _COUNT_TOKENS_PUBLISHERS: Final = (
)
def count_tokens_request_body(
request_data: Mapping[str, object], model: str, drop_params: object
) -> Mapping[str, object]:
tools: Final = request_data.get("tools")
if not isinstance(tools, list):
return request_data
return {
**request_data,
"tools": tools_without_custom_field(
OBJECT_LIST_ADAPTER.validate_python(tools),
hoist_defer_loading=is_claude_model_target("vertex_ai", model),
drop_custom=drops_unsupported_params(drop_params),
),
}
def count_tokens_publisher(model: str) -> str | None:
return next((publisher for marker, publisher in _COUNT_TOKENS_PUBLISHERS if marker in model), None)
@ -169,7 +188,7 @@ class VertexAIPartnerModelsTokenCounter(VertexBase):
response: Final = await async_client.post(
endpoint_url,
headers=headers,
json=request_data,
json=dict(count_tokens_request_body(request_data, model, litellm_params.get("drop_params"))),
timeout=30.0,
)

View file

@ -234,6 +234,7 @@ from .litellm_core_utils.streaming_chunk_builder_utils import (
ChunkProcessor as ChunkProcessor, # noqa: PLC0414 # public re-export
)
from .llms.anthropic.chat import AnthropicChatCompletion
from .llms.anthropic.common_utils import is_claude_tool_target
from .llms.azure.audio_transcriptions import AzureAudioTranscription
from .llms.azure.azure import AzureChatCompletion, check_dynamic_azure_params
from .llms.azure.chat.o_series_handler import AzureOpenAIO1ChatCompletion
@ -1135,17 +1136,6 @@ from litellm.responses.bridge_check import ( # noqa: E402 # re-export at the o
_ANTHROPIC_ONLY_TOOL_KEYS: Final = frozenset({"input_examples", "eager_input_streaming"})
def _is_claude_tool_target(custom_llm_provider: str | None, model: str) -> bool:
if custom_llm_provider == "anthropic":
return True
model_lower: Final = model.lower()
if custom_llm_provider == "bedrock":
return "claude" in model_lower or ("arn:" in model_lower and ":bedrock:" in model_lower)
if custom_llm_provider == "azure_ai" or custom_llm_provider == "vertex_ai":
return "claude" in model_lower
return False
def _history_without_unanswered_tool_search_calls(
messages: Sequence[object], custom_llm_provider: str | None, provider_config: BaseConfig
) -> tuple[object, ...] | None:
@ -5752,7 +5742,7 @@ def completion(
}
)
if not _is_claude_tool_target(custom_llm_provider=custom_llm_provider, model=model):
if not is_claude_tool_target(custom_llm_provider=custom_llm_provider, model=model):
tools = _drop_anthropic_only_tool_keys(tools=tools)
if provider_specific_header is not None:

View file

@ -111,6 +111,7 @@ _FEATURES: Final[detectors.Detectors] = MappingProxyType(
Feature.CACHE_CONTROL_INJECTION: detectors.cache_control_injection,
Feature.STREAM_COST_INJECTION: route_detectors.stream_cost_injection,
Feature.THINKING_SIGNATURE_RETRY: route_detectors.thinking_signature_retry,
Feature.TOOL_CUSTOM_FIELD: route_detectors.tool_custom_field,
}
)

View file

@ -96,6 +96,8 @@ class Feature(str, Enum):
"""litellm.include_cost_in_streaming_usage, where Python adds cost to streamed usage."""
THINKING_SIGNATURE_RETRY = "thinking_signature_retry"
"""Replayed thinking blocks, which Python strips and retries once if Anthropic rejects their signature."""
TOOL_CUSTOM_FIELD = "tool_custom_field"
"""Tools carrying a non-spec custom object, which Python drops or hoists for the provider."""
CLIENT_HEADERS = "client_headers"
"""Proxy-forwarded headers that Python merges into extra_headers."""
DEPLOYMENT_HOOK = "deployment_hook"
@ -176,6 +178,7 @@ MESSAGES_PYTHON_FEATURES: Final = COMMON_PYTHON_FEATURES | {
Feature.CACHE_CONTROL_INJECTION,
Feature.STREAM_COST_INJECTION,
Feature.THINKING_SIGNATURE_RETRY,
Feature.TOOL_CUSTOM_FIELD,
}
RESPONSES_PYTHON_FEATURES: Final = COMMON_PYTHON_FEATURES | {

View file

@ -43,6 +43,11 @@ def thinking_signature_retry(request: NativeCall) -> bool:
return any(_replays_thinking(message) for message in optional_sequence(request.resolved.get("messages")) or ())
def tool_custom_field(request: NativeCall) -> bool:
tools: Final = optional_sequence(request.resolved.get("tools")) or ()
return any("custom" in (optional_mapping(tool) or {}) for tool in tools)
def stream_cost_injection(request: NativeCall) -> bool:
"""Python adds response cost to streaming usage when the global setting is truthy."""
return streaming(request) and bool(litellm.include_cost_in_streaming_usage)

View file

@ -473,6 +473,12 @@ ADVISOR_TOOL: Final = {"type": ANTHROPIC_ADVISOR_TOOL_TYPE, "name": "advisor", "
{"messages": [{"role": "assistant", "content": [{"type": "redacted_thinking", "data": "x"}]}]},
{},
),
(
Feature.TOOL_CUSTOM_FIELD,
"anthropic/claude-sonnet-4-5",
{"tools": [{"name": "a", "input_schema": {"type": "object"}, "custom": {"eager_input_streaming": True}}]},
{},
),
(
Feature.STREAM_COST_INJECTION,
"anthropic/claude-sonnet-4-5",

View file

@ -700,6 +700,7 @@ def test_token_counter_counts_anthropic_tool_choices_like_openai_choices(
_anthropic_tool(_WEATHER_FUNCTION),
{**_anthropic_tool(_WEATHER_FUNCTION), "strict": True},
{**_anthropic_tool(_WEATHER_FUNCTION), "defer_loading": True, "input_examples": [{"location": "Paris"}]},
{**_anthropic_tool(_WEATHER_FUNCTION), "custom": {"eager_input_streaming": True}},
_anthropic_tool(_WEB_SEARCH_FUNCTION),
],
)

View file

@ -4238,6 +4238,21 @@ def test_translate_anthropic_tools_to_openai_preserves_parameters_type():
assert new_tools[0]["type"] == "function"
def test_translate_anthropic_custom_field_does_not_change_openai_parameters():
input_schema: Final = {"type": "object", "properties": {"city": {"type": "string"}}}
tools: Final = [
{
"name": "get_weather",
"input_schema": input_schema,
"custom": {"eager_input_streaming": True},
}
]
new_tools, _ = LiteLLMAnthropicMessagesAdapter().translate_anthropic_tools_to_openai(tools=tools)
assert new_tools[0]["function"]["parameters"] == input_schema
def test_translate_anthropic_tools_to_openai_maps_strict_onto_function_not_parameters():
"""A tool-level `strict` lands on the OpenAI function, leaving the caller's `input_schema` untouched."""
adapter = LiteLLMAnthropicMessagesAdapter()

View file

@ -14,9 +14,13 @@ from litellm.llms.anthropic.messages.native.transformation import (
AnthropicMessagesConfig,
)
from litellm.llms.anthropic.wif import get_anthropic_wif_token
from litellm.llms.deepseek.messages.transformation import DeepSeekAnthropicMessagesConfig
from litellm.llms.minimax.messages.transformation import MinimaxMessagesConfig
from litellm.llms.openai_like.messages.transformation import OpenAILikeAnthropicMessagesConfig
from litellm.llms.tencent.messages.transformation import TencentAnthropicMessagesConfig
from litellm.llms.vertex_ai.vertex_ai_partner_models.anthropic.experimental_pass_through.transformation import (
VertexAIPartnerModelsAnthropicMessagesConfig,
)
from litellm.types.llms.anthropic import AnthropicFinishReason
from litellm.types.router import GenericLiteLLMParams
from tests.unit.llms.anthropic.test_anthropic_wif import (
@ -79,6 +83,84 @@ def test_native_response_defaults_stop_sequence_without_changing_other_fields(
}
_EMPTY_SCHEMA: Final[dict[str, object]] = {"type": "object", "properties": {}}
@pytest.mark.parametrize(
"config_class",
[AnthropicMessagesConfig, VertexAIPartnerModelsAnthropicMessagesConfig],
)
@pytest.mark.parametrize(
("drop_params", "expected_eager_tool"),
[
(True, {"name": "A", "input_schema": _EMPTY_SCHEMA}),
(False, {"name": "A", "input_schema": _EMPTY_SCHEMA, "custom": {"eager_input_streaming": True}}),
],
)
def test_messages_request_hoists_wrapped_defer_loading_and_drops_custom_only_under_drop_params(
config_class: type[AnthropicMessagesConfig], drop_params: bool, expected_eager_tool: dict[str, object]
) -> None:
tools: Final[list[dict[str, object]]] = [
{"name": "A", "input_schema": _EMPTY_SCHEMA, "custom": {"eager_input_streaming": True}},
{"name": "B", "input_schema": _EMPTY_SCHEMA, "custom": {"defer_loading": True}},
{"name": "C", "input_schema": _EMPTY_SCHEMA, "custom": {"defer_loading": True, "eager_input_streaming": True}},
{"name": "D", "input_schema": _EMPTY_SCHEMA},
]
optional_params: Final[dict[str, object]] = {"max_tokens": 50, "tools": tools}
request: Final = config_class().transform_anthropic_messages_request(
model="claude-haiku-5-5",
messages=[{"role": "user", "content": "Hi"}],
anthropic_messages_optional_request_params=optional_params,
litellm_params=GenericLiteLLMParams(drop_params=drop_params),
headers={},
)
assert request["tools"] == [
expected_eager_tool,
{"name": "B", "input_schema": _EMPTY_SCHEMA, "defer_loading": True},
{
"name": "C",
"input_schema": _EMPTY_SCHEMA,
"defer_loading": True,
**({} if drop_params else {"custom": {"eager_input_streaming": True}}),
},
{"name": "D", "input_schema": _EMPTY_SCHEMA},
]
assert tools[0]["custom"] == {"eager_input_streaming": True}
assert optional_params["tools"] is tools
@pytest.mark.parametrize(
("config", "model"),
[
(DeepSeekAnthropicMessagesConfig(), "deepseek-chat"),
(MinimaxMessagesConfig(), "MiniMax-M2"),
(AnthropicMessagesConfig(), "deepseek-chat"),
(OpenAILikeAnthropicMessagesConfig(), "anthropic/claude-haiku-4.5"),
],
)
@pytest.mark.parametrize("drop_params", [True, False])
def test_messages_request_never_defers_tools_off_claude(
config: AnthropicMessagesConfig, model: str, drop_params: bool
) -> None:
wrapped_tool: Final[dict[str, object]] = {
"name": "A",
"input_schema": _EMPTY_SCHEMA,
"custom": {"defer_loading": True},
}
request: Final = config.transform_anthropic_messages_request(
model=model,
messages=[{"role": "user", "content": "Hi"}],
anthropic_messages_optional_request_params={"max_tokens": 50, "tools": [wrapped_tool]},
litellm_params=GenericLiteLLMParams(drop_params=drop_params),
headers={},
)
assert request["tools"] == [{"name": "A", "input_schema": _EMPTY_SCHEMA} if drop_params else wrapped_tool]
def test_workload_identity_allowed_for_anthropic() -> None:
assert AnthropicMessagesConfig()._allows_workload_identity is True

View file

@ -25,6 +25,7 @@ import pytest
import respx
import litellm
from litellm.llms.anthropic.common_utils import is_claude_tool_target
from litellm.llms.custom_httpx.http_handler import HTTPHandler
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../..")))
@ -4522,3 +4523,22 @@ def test_shared_legacy_thinking_translation_drops_a_non_numeric_budget_under_dro
)
assert optional_params == {"thinking": {"type": "adaptive"}}
@pytest.mark.parametrize(
("custom_llm_provider", "model", "expected"),
[
("anthropic", "claude-sonnet-5", True),
("bedrock", "us.anthropic.claude-sonnet-5-20260501-v1:0", True),
("bedrock", "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123", True),
("bedrock", "us.amazon.nova-2-lite-v1:0", False),
("vertex_ai", "claude-sonnet-5", True),
("vertex_ai", "gemini-3.8-flash", False),
("azure_ai", "claude-sonnet-4-6", True),
("azure_ai", "gpt-5.6", False),
("openai", "gpt-5.6", False),
("gemini", "gemini-3.8-flash", False),
],
)
def test_is_claude_tool_target(custom_llm_provider: str, model: str, expected: bool):
assert is_claude_tool_target(custom_llm_provider=custom_llm_provider, model=model) is expected

View file

@ -171,6 +171,54 @@ def test_transform_includes_tools():
assert result["tools"] == tools
_PATH_SCHEMA: Final[dict[str, JsonValue]] = {"type": "object", "properties": {"path": {"type": "string"}}}
@pytest.mark.parametrize(
("model", "drop_params", "expected_tools"),
[
(
"claude-haiku-5-5",
True,
[
{"name": "read_file", "input_schema": _PATH_SCHEMA},
{"name": "list_files", "input_schema": _PATH_SCHEMA, "defer_loading": True},
],
),
(
"claude-haiku-5-5",
False,
[
{"name": "read_file", "input_schema": _PATH_SCHEMA, "custom": {"eager_input_streaming": True}},
{"name": "list_files", "input_schema": _PATH_SCHEMA, "defer_loading": True},
],
),
(
"deepseek-chat",
True,
[
{"name": "read_file", "input_schema": _PATH_SCHEMA},
{"name": "list_files", "input_schema": _PATH_SCHEMA},
],
),
],
)
def test_transform_counts_tools_the_way_messages_sends_them(
model: str, drop_params: bool, expected_tools: list[dict[str, JsonValue]]
):
result: Final = AnthropicCountTokensConfig().transform_request_to_count_tokens(
model=model,
messages=[{"role": "user", "content": "Hello"}],
tools=[
{"name": "read_file", "input_schema": _PATH_SCHEMA, "custom": {"eager_input_streaming": True}},
{"name": "list_files", "input_schema": _PATH_SCHEMA, "custom": {"defer_loading": True}},
],
drop_params=drop_params,
)
assert result["tools"] == expected_tools
def test_transform_includes_system_and_tools():
"""Test that both system and tools are included together."""
config = AnthropicCountTokensConfig()

View file

@ -73,6 +73,27 @@ def test_transform_to_invoke_model_format_base64_encodes_body():
assert body["max_tokens"] == DEFAULT_ANTHROPIC_INVOKE_MODEL_MAX_TOKENS
def test_transform_to_invoke_model_format_counts_tools_the_way_bedrock_messages_sends_them():
schema = {"type": "object", "properties": {}}
result = BedrockCountTokensConfig().transform_anthropic_to_bedrock_count_tokens(
{
"model": "anthropic.claude-haiku-5-5",
"messages": [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}],
"tools": [
{"name": "read", "input_schema": schema, "custom": {"defer_loading": True}},
{"name": "write", "input_schema": schema, "custom": {"eager_input_streaming": True}},
],
}
)
body = json.loads(base64.b64decode(result["input"]["invokeModel"]["body"]))
assert body["tools"] == [
{"name": "read", "input_schema": schema, "defer_loading": True},
{"name": "write", "input_schema": schema},
]
def test_transform_to_invoke_model_format_raw_body_unchanged():
"""Non-messages bodies (e.g. Titan inputText) must not get Anthropic fields."""
config = BedrockCountTokensConfig()

View file

@ -74,6 +74,34 @@ async def test_counts_on_mantle_with_the_base_model_and_the_deployment_credentia
}
@pytest.mark.asyncio
@pytest.mark.parametrize(
"resolved_model",
["anthropic.claude-opus-4-8", "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123"],
)
async def test_counts_tools_the_way_mantle_messages_sends_them(resolved_model: str) -> None:
mantle: Final = _MantleEndpoint(200, {"input_tokens": 11})
schema: Final = {"type": "object", "properties": {}}
await BedrockMantleCountTokensHandler().handle_count_tokens_request(
request_data={
**REQUEST,
"tools": [
{"name": "eager", "input_schema": schema, "custom": {"eager_input_streaming": True}},
{"name": "deferred", "input_schema": schema, "custom": {"defer_loading": True}},
],
},
litellm_params=dict(LITELLM_PARAMS),
resolved_model=resolved_model,
client=_client(mantle),
)
assert json.loads(mantle.only_request().content)["tools"] == [
{"name": "eager", "input_schema": schema},
{"name": "deferred", "input_schema": schema, "defer_loading": True},
]
@pytest.mark.asyncio
async def test_bedrock_mantle_api_base_env_names_the_host(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("BEDROCK_MANTLE_API_BASE", "https://vpce-abc.bedrock-mantle.us-east-1.vpce.api.aws")

View file

@ -4198,3 +4198,110 @@ def test_bedrock_messages_without_guardrail_config_sends_no_guardrail_headers()
headers={}, model="anthropic.claude-sonnet-5-v1:0", messages=[], optional_params={}, litellm_params={}
)
assert not any(name.startswith("X-Amzn-Bedrock-Guardrail") for name in headers)
@dataclass(slots=True)
class BedrockDownAnthropicUp:
anthropic_bodies: tuple[dict[str, object], ...] = ()
def __call__(self, request: httpx.Request) -> httpx.Response:
if request.url.host != "api.anthropic.com":
return httpx.Response(500, json={"message": "Bedrock is unavailable"})
self.anthropic_bodies = (*self.anthropic_bodies, REASONING_BODY_ADAPTER.validate_json(request.content))
return httpx.Response(200, json=_ANTHROPIC_MESSAGE)
@pytest.mark.usefixtures("local_model_cost_map")
@pytest.mark.parametrize(
("unnamed_tool_extras", "sent_extras"),
[
({}, {}),
({"custom": {"eager_input_streaming": True}}, {}),
({"custom": {"defer_loading": False}}, {"defer_loading": False}),
],
)
async def test_bedrock_messages_fallback_to_anthropic_sends_the_tool_names_bedrock_filled_in(
respx_mock: respx.Router,
monkeypatch: pytest.MonkeyPatch,
unnamed_tool_extras: dict[str, object],
sent_extras: dict[str, object],
) -> None:
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
upstream: Final = BedrockDownAnthropicUp()
respx_mock.post(url__regex=r"https://.*").mock(side_effect=upstream)
router: Final = litellm.Router(
model_list=[
{
"model_name": "bedrock-claude",
"litellm_params": {
"model": "bedrock/invoke/us.anthropic.claude-opus-4-8",
"aws_access_key_id": "test-access-key",
"aws_secret_access_key": "test-secret-key",
"aws_region_name": "us-east-1",
},
},
{"model_name": "anthropic-claude", "litellm_params": {"model": "anthropic/claude-opus-4-8", "api_key": "test"}},
],
fallbacks=[{"bedrock-claude": ["anthropic-claude"]}],
num_retries=0,
)
schema: Final[Mapping[str, object]] = {"type": "object", "properties": {}}
await router.aanthropic_messages(
model="bedrock-claude",
messages=[{"role": "user", "content": "What is the weather?"}],
max_tokens=100,
tools=[{"input_schema": schema, **unnamed_tool_extras}, {"name": "get_weather", "input_schema": schema}],
)
(body,) = upstream.anthropic_bodies
assert body["tools"] == [
{"name": "litellm_unnamed_tool_0", "input_schema": schema, **sent_extras},
{"name": "get_weather", "input_schema": schema},
]
@pytest.mark.usefixtures("local_model_cost_map")
@pytest.mark.parametrize(
("custom", "sent_extras"),
[({"eager_input_streaming": True}, {}), ({"defer_loading": False}, {"defer_loading": False})],
)
async def test_bedrock_chat_fallback_to_anthropic_sends_the_tool_bedrock_cleaned(
respx_mock: respx.Router,
monkeypatch: pytest.MonkeyPatch,
custom: dict[str, object],
sent_extras: dict[str, object],
) -> None:
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
upstream: Final = BedrockDownAnthropicUp()
respx_mock.post(url__regex=r"https://.*").mock(side_effect=upstream)
router: Final = litellm.Router(
model_list=[
{
"model_name": "bedrock-claude",
"litellm_params": {
"model": "bedrock/invoke/us.anthropic.claude-opus-4-8",
"aws_access_key_id": "test-access-key",
"aws_secret_access_key": "test-secret-key",
"aws_region_name": "us-east-1",
},
},
{
"model_name": "anthropic-claude",
"litellm_params": {"model": "anthropic/claude-opus-4-8", "api_key": "test", "drop_params": True},
},
],
fallbacks=[{"bedrock-claude": ["anthropic-claude"]}],
num_retries=0,
)
schema: Final[Mapping[str, object]] = {"type": "object", "properties": {}}
await router.acompletion(
model="bedrock-claude",
messages=[{"role": "user", "content": "What is the weather?"}],
max_tokens=100,
tools=[{"name": "get_weather", "input_schema": schema, "custom": custom}],
)
(body,) = upstream.anthropic_bodies
assert body["tools"] == [{"name": "get_weather", "input_schema": schema, **sent_extras}]

View file

@ -224,6 +224,20 @@ class TestRequestBody:
)
assert "stream" not in body
def test_body_hoists_wrapped_defer_loading_and_drops_custom(self):
schema: Final = {"type": "object", "properties": {}}
body = BedrockMantleAnthropicMessagesConfig().transform_anthropic_messages_request(
model="anthropic.claude-sonnet-5",
messages=[{"role": "user", "content": "ping"}],
anthropic_messages_optional_request_params={
"max_tokens": 8,
"tools": [{"name": "Read", "input_schema": schema, "custom": {"defer_loading": True}}],
},
litellm_params=GenericLiteLLMParams(),
headers={},
)
assert body["tools"] == [{"name": "Read", "input_schema": schema, "defer_loading": True}]
class TestAuth:
def test_bearer_from_api_key_skips_aws_credentials(self):

View file

@ -4,10 +4,13 @@ Tests for Vertex AI partner models count_tokens location resolution.
Ref: https://github.com/BerriAI/litellm/issues/23872
"""
from typing import Final
import pytest
from litellm.llms.vertex_ai.vertex_ai_partner_models.count_tokens.handler import (
VertexAIPartnerModelsTokenCounter,
count_tokens_request_body,
)
@ -294,3 +297,21 @@ class TestCountTokensVersionSuffixStripping:
# The model name sent to the API must NOT have the @default suffix
assert captured_json["model"] == "claude-sonnet-4-6"
@pytest.mark.parametrize("drop_params", [True, False])
def test_count_tokens_counts_tools_the_way_vertex_messages_sends_them(drop_params):
schema: Final = {"type": "object", "properties": {}}
eager_tool: Final = {"name": "write", "input_schema": schema, "custom": {"eager_input_streaming": True}}
request_data: Final = {
"messages": [{"role": "user", "content": "hi"}],
"tools": [{"name": "read", "input_schema": schema, "custom": {"defer_loading": True}}, eager_tool],
}
body: Final = count_tokens_request_body(request_data, "claude-sonnet-4-6", drop_params)
assert body["messages"] == request_data["messages"]
assert body["tools"] == [
{"name": "read", "input_schema": schema, "defer_loading": True},
{"name": "write", "input_schema": schema} if drop_params else eager_tool,
]

View file

@ -189,6 +189,9 @@ def _wrap_nested_level(value: object, depth: int) -> object:
{"messages": [{"role": "user", "content": [{"type": "text"}]}]},
False,
),
(messages_detectors.tool_custom_field, {"tools": [{"name": "a"}, {"name": "b", "custom": {}}]}, True),
(messages_detectors.tool_custom_field, {"tools": [{"name": "a", "defer_loading": True}]}, False),
(messages_detectors.tool_custom_field, {}, False),
(detectors.drop_params, {"drop_params": True}, True),
(detectors.drop_params, {"drop_params": False}, False),
(detectors.drop_params, {"drop_params": " True "}, True),

View file

@ -503,25 +503,6 @@ def test_bedrock_latency_optimized_inference():
assert json_data["performanceConfig"]["latency"] == "optimized"
@pytest.mark.parametrize(
("custom_llm_provider", "model", "expected"),
[
("anthropic", "claude-sonnet-5", True),
("bedrock", "us.anthropic.claude-sonnet-5-20260501-v1:0", True),
("bedrock", "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123", True),
("bedrock", "us.amazon.nova-2-lite-v1:0", False),
("vertex_ai", "claude-sonnet-5", True),
("vertex_ai", "gemini-3.8-flash", False),
("azure_ai", "claude-sonnet-4-6", True),
("azure_ai", "gpt-5.6", False),
("openai", "gpt-5.6", False),
("gemini", "gemini-3.8-flash", False),
],
)
def test_is_claude_tool_target(custom_llm_provider: str, model: str, expected: bool):
assert litellm_main._is_claude_tool_target(custom_llm_provider=custom_llm_provider, model=model) is expected
@pytest.mark.parametrize("key", ["input_examples", "eager_input_streaming"])
def test_drop_anthropic_only_tool_keys_strips_tool_and_function_levels(key: str):
tools = [