mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-30 06:43:27 +00:00
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:
parent
42d2909210
commit
310c96162b
31 changed files with 546 additions and 49 deletions
|
|
@ -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")
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
),
|
||||
|
|
|
|||
|
|
@ -817,6 +817,7 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
"name",
|
||||
"input_schema",
|
||||
"description",
|
||||
"custom",
|
||||
"cache_control",
|
||||
"strict",
|
||||
"type",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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], ...])
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 | {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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}]
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue