Merge pull request #50280 from BerriAI/litellm_backport_translation_p0_lts_1_108_x

fix: backport #46870, #46984 and #47100 to lts/1.108.x
This commit is contained in:
yuneng-jiang 2026-11-06 11:36:57 -08:00 • committed by GitHub
commit 831a14c8f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 4784 additions and 264 deletions

View file

@ -9,7 +9,8 @@ import json
import mimetypes
import re
from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
from itertools import groupby, islice
from graphlib import CycleError, TopologicalSorter
from itertools import chain, groupby, islice
from os import PathLike
from pathlib import Path
from types import MappingProxyType
@ -1426,6 +1427,123 @@ def _schema_levels(schema: Mapping[str, object]) -> Iterator[tuple[Mapping[str,
frontier = tuple(child for node in frontier for child in _subschemas(node))
def schema_fragment_ref(ref: str) -> str:
return f"#{ref}" if ref.startswith(("/$defs/", "/definitions/")) else ref
def schema_has_unresolvable_ref(schema: Mapping[str, object]) -> bool:
definitions: Final = MappingProxyType(dict(_root_definitions(schema)))
if not _refs_in(schema) <= definitions.keys():
return True
try:
tuple(TopologicalSorter({pointer: _refs_in(body) for pointer, body in definitions.items()}).static_order())
except CycleError:
return True
return False
def schema_union_property_count(schema: Mapping[str, object]) -> int:
return sum(1 for _, node in _property_schemas(schema) if _is_union(node))
def schema_optional_property_count(schema: Mapping[str, object]) -> int:
return sum(len(_optional_property_names(node)) for node in _schema_nodes(schema))
def schema_property_count(schema: Mapping[str, object]) -> int:
return sum(len(properties) for node in _schema_nodes(schema) if (properties := _schema_at(node, "properties")))
def schema_grammar_branch_count(schema: Mapping[str, object]) -> int:
return sum(_grammar_branches(node) for node in _schema_nodes(schema))
def schema_has_open_object(schema: Mapping[str, object]) -> bool:
return any(_is_open_object(node) for node in _schema_nodes(schema))
def schema_has_lookaround_pattern(schema: Mapping[str, object]) -> bool:
return any(
isinstance(pattern := node.get("pattern"), str) and _uses_regex_lookaround(pattern)
for node in _schema_nodes(schema)
)
def schema_string_formats(schema: Mapping[str, object]) -> frozenset[str]:
return frozenset(
value for node in _schema_nodes(schema) if _may_be_string(node) and isinstance(value := node.get("format"), str)
)
def _may_be_string(node: Mapping[str, object]) -> bool:
node_type: Final = node.get("type")
return node_type is None or node_type == "string" or "string" in _list_at(node, "type")
def is_object_schema(node: Mapping[str, object]) -> bool:
node_type: Final = node.get("type")
return node_type == "object" or (isinstance(node_type, list) and "object" in node_type)
def _schema_nodes(schema: Mapping[str, object]) -> Iterator[Mapping[str, object]]:
return chain.from_iterable(islice(_schema_levels(schema), _MAX_SCHEMA_NESTING))
_SCHEMA_LIST: Final = TypeAdapter(tuple[object, ...])
def _list_at(container: Mapping[str, object], key: str) -> tuple[object, ...]:
value: Final = container.get(key)
return _SCHEMA_LIST.validate_python(value) if isinstance(value, list) else ()
def _named_schemas(container: Mapping[str, object] | None) -> Iterator[tuple[str, Mapping[str, object]]]:
if container is None:
return iter(())
return ((key, value) for key in container if (value := _schema_at(container, key)) is not None)
def _root_definitions(schema: Mapping[str, object]) -> Iterator[tuple[str, Mapping[str, object]]]:
for defs_key in ("$defs", "definitions"):
yield from ((f"#/{defs_key}/{name}", body) for name, body in _named_schemas(_schema_at(schema, defs_key)))
def _refs_in(schema: Mapping[str, object]) -> frozenset[str]:
return frozenset(
schema_fragment_ref(ref) for node in _schema_nodes(schema) if isinstance(ref := node.get("$ref"), str)
)
def _property_schemas(schema: Mapping[str, object]) -> Iterator[tuple[str, Mapping[str, object]]]:
return chain.from_iterable(_named_schemas(_schema_at(node, "properties")) for node in _schema_nodes(schema))
def _is_union(node: Mapping[str, object]) -> bool:
return isinstance(node.get("anyOf"), list) or isinstance(node.get("oneOf"), list) or len(_list_at(node, "type")) > 1
def _grammar_branches(node: Mapping[str, object]) -> int:
properties: Final = _schema_at(node, "properties")
if properties is None:
return 0
optional: Final = len(_optional_property_names(node))
return 2**optional * (len(properties) - optional + 1)
def _optional_property_names(node: Mapping[str, object]) -> frozenset[str]:
properties: Final = _schema_at(node, "properties")
if properties is None:
return frozenset()
return frozenset(properties) - frozenset(name for name in _list_at(node, "required") if isinstance(name, str))
def _is_open_object(node: Mapping[str, object]) -> bool:
additional_properties: Final = node.get("additionalProperties")
if additional_properties is True or isinstance(additional_properties, dict) or "patternProperties" in node:
return True
return is_object_schema(node) and "properties" not in node and additional_properties is not False
def _subschemas(node: Mapping[str, object]) -> Iterator[Mapping[str, object]]:
for key, value in node.items():
if key in _SUBSCHEMA_MAP_KEYWORDS and isinstance(value, dict):
@ -1646,12 +1764,14 @@ def infer_content_type_from_url_and_content(
"""
from litellm.litellm_core_utils.token_counter import get_image_type
media_type: Final = current_content_type.split(";", 1)[0].strip().lower() if current_content_type else None
# If we have a valid content type that's not generic, use it
if current_content_type and current_content_type not in [
if media_type and media_type not in [
"binary/octet-stream",
"application/octet-stream",
]:
return current_content_type
return media_type
# Extension to MIME type mapping
# Supports images, documents, and other common file types

View file

@ -907,9 +907,9 @@ def create_anthropic_image_param(
image_url_input: str | dict,
format: str | None = None,
is_bedrock_invoke: bool = False,
) -> AnthropicMessagesImageParam:
) -> AnthropicMessagesImageParam | AnthropicMessagesDocumentParam:
"""
Create an AnthropicMessagesImageParam from an image URL input.
Create an Anthropic content block from an image URL input.
Supports both URL references (for HTTP/HTTPS URLs) and base64 encoding.
"""
@ -928,14 +928,7 @@ def create_anthropic_image_param(
if is_bedrock_invoke or image_url.startswith("http://"):
base64_url: Final = convert_url_to_base64(url=image_url)
image_chunk = convert_to_anthropic_image_obj(openai_image_url=base64_url, format=format)
return AnthropicMessagesImageParam(
type="image",
source=AnthropicContentParamSource(
type="base64",
media_type=image_chunk["media_type"],
data=image_chunk["data"],
),
)
return _anthropic_content_element_factory(image_chunk)
else:
# HTTPS URL - pass directly for regular Anthropic
return AnthropicMessagesImageParam(
@ -948,14 +941,7 @@ def create_anthropic_image_param(
else:
# Convert to base64 for data URIs or other formats
image_chunk = convert_to_anthropic_image_obj(openai_image_url=image_url, format=format)
return AnthropicMessagesImageParam(
type="image",
source=AnthropicContentParamSource(
type="base64",
media_type=image_chunk["media_type"],
data=image_chunk["data"],
),
)
return _anthropic_content_element_factory(image_chunk)
# The following XML functions will be deprecated once JSON schema support is available on Bedrock and Vertex
@ -1795,7 +1781,12 @@ def convert_to_anthropic_tool_result(
anthropic_content_element=_anthropic_image_param,
original_content_element=content,
)
anthropic_content_list.append(cast(AnthropicMessagesImageParam, _anthropic_image_param))
anthropic_content_list.append(
cast(
AnthropicMessagesImageParam | AnthropicMessagesDocumentParam,
_anthropic_image_param,
)
)
elif content["type"] == "tool_reference":
anthropic_content_list.append(ToolReference(type="tool_reference", tool_name=content["tool_name"]))
elif content["type"] == "file":
@ -5478,14 +5469,14 @@ def _bedrock_tools_pt(
description = name
defs = parameters.pop("$defs", {})
defs_copy = copy.deepcopy(defs)
defs_copy = copy.deepcopy(defs) if isinstance(defs, dict) else {}
# Expand $ref references in parameters using the definitions
# Note: We don't pre-flatten defs as that causes exponential memory growth
# with circular references (see issue #19098). unpack_defs handles nested
# refs recursively and correctly detects/skips circular references.
unpack_defs(parameters, defs_copy)
normalize_json_schema_custom_types_to_object(parameters)
if parameters.get("type") not in _valid_json_schema_root_types:
if not (isinstance(root_type := parameters.get("type"), str) and root_type in _valid_json_schema_root_types):
parameters["type"] = "object"
tool_block = cast(
BedrockToolBlock,

View file

@ -1,3 +1,4 @@
import copy
import json
import re
import time
@ -25,7 +26,18 @@ from litellm.constants import (
)
from litellm.litellm_core_utils.core_helpers import map_finish_reason
from litellm.litellm_core_utils.prompt_templates.common_utils import (
DEFS_MAX_INLINED_BYTES,
is_object_schema,
sanitize_input_schema_for_anthropic,
schema_fragment_ref,
schema_grammar_branch_count,
schema_has_lookaround_pattern,
schema_has_open_object,
schema_has_unresolvable_ref,
schema_optional_property_count,
schema_string_formats,
schema_union_property_count,
unpack_defs,
)
from litellm.litellm_core_utils.prompt_templates.image_handling import (
RemoteMedia,
@ -45,7 +57,7 @@ from litellm.llms.base_llm.base_utils import (
type_to_response_format_param,
unwrap_response_format_envelope,
)
from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException
from litellm.llms.base_llm.chat.transformation import REQUEST_PARAMS, BaseConfig, BaseLLMException
from litellm.types.llms.anthropic import (
ANTHROPIC_ADVISOR_TOOL_TYPE,
ANTHROPIC_BETA_HEADER_VALUES,
@ -153,6 +165,12 @@ _ANTHROPIC_TOOL_NAME_MAX_LEN: Final = 128
_DEFER_LOADING_ADAPTER: Final = TypeAdapter(bool)
_TOOL_FIELDS_ADAPTER: Final = TypeAdapter(Mapping[str, object])
_TOOLS_ADAPTER: Final = TypeAdapter(list[object])
_NATIVE_OUTPUT_STRING_FORMATS: Final = frozenset(
("date-time", "time", "date", "duration", "email", "hostname", "uri", "ipv4", "ipv6", "uuid")
)
NATIVE_OUTPUT_MAX_UNION_PARAMETERS: Final = 16
NATIVE_OUTPUT_MAX_OPTIONAL_PARAMETERS: Final = 24
NATIVE_OUTPUT_MAX_GRAMMAR_BRANCHES: Final = 2048
def _tool_type(tool: object) -> str | None:
@ -756,7 +774,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
grammar Anthropic compiles the schema into, so the API rejects them with a
400 ``invalid_request_error`` (e.g. "output_format.schema: For 'array' type,
property 'uniqueItems' is not supported"):
- maxItems/minItems/uniqueItems/contains/minContains/maxContains/prefixItems: array constraints
- maxItems/minItems above 1/uniqueItems/contains/minContains/maxContains/prefixItems: array constraints
- minimum/maximum/exclusiveMinimum/exclusiveMaximum/multipleOf: numeric constraints
- minLength/maxLength: string constraints
- minProperties/maxProperties/patternProperties/propertyNames: object constraints
@ -813,7 +831,11 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
"else": "conditional (else): {}",
"not": "must not match: {}",
}
unsupported_fields: Final = set(constraint_labels)
min_items: Final = schema.get("minItems")
enforced_min_items: Final = (
isinstance(min_items, int) and not isinstance(min_items, bool) and min_items in (0, 1)
)
unsupported_fields: Final = set(constraint_labels) - ({"minItems"} if enforced_min_items else set())
# Build description additions from removed constraints. Iterating
# constraint_labels (not the set) keeps the note order deterministic across
@ -821,7 +843,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
# PYTHONHASHSEED and stay cache-friendly.
constraint_descriptions: Final[list] = []
for field, label in constraint_labels.items():
if field not in schema:
if field not in schema or field not in unsupported_fields:
continue
value = schema[field]
# A falsy boolean constraint (e.g. ``uniqueItems: false``) imposes no
@ -856,7 +878,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
# Already handled above
continue
if key in ("properties", "$defs") and isinstance(value, dict):
if key in ("properties", "$defs", "definitions") and isinstance(value, dict):
nested_schemas: dict[str, Any] = value # mutable-ok: filter_anthropic_output_schema takes a dict
result[key] = {k: AnthropicConfig.filter_anthropic_output_schema(v) for k, v in nested_schemas.items()}
elif key == "items" and isinstance(value, dict):
@ -868,6 +890,8 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
]
elif key == "allOf" and isinstance(value, list):
result[key] = [AnthropicConfig.filter_anthropic_output_schema(item) for item in value]
elif key == "$ref" and isinstance(value, str):
result[key] = schema_fragment_ref(value)
elif key == "oneOf" and isinstance(value, list):
# Anthropic rejects oneOf ("Schema type 'oneOf' is not supported");
# the Anthropic SDK rewrites it to anyOf, so do the same.
@ -879,7 +903,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
# Anthropic requires additionalProperties=false for object schemas
# See: https://docs.anthropic.com/en/docs/build-with-claude/structured-outputs
if result.get("type") == "object":
if is_object_schema(result):
result["additionalProperties"] = False
return result
@ -1636,32 +1660,51 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
return json_schema
def map_response_format_to_anthropic_output_format(self, value: dict | None) -> AnthropicOutputSchema | None:
json_schema: dict | None = self._extract_json_schema_from_response_format(value)
@staticmethod
def _defs_unpacked(json_schema: Mapping[str, object]) -> Mapping[str, object]:
unpacked: Final = copy.deepcopy(dict(json_schema))
defs: Final = unpacked.pop("$defs", unpacked.pop("definitions", None))
if isinstance(defs, dict) and defs:
unpack_defs(unpacked, defs, max_inlined_bytes=DEFS_MAX_INLINED_BYTES)
return unpacked
@staticmethod
def native_output_schema(json_schema: Mapping[str, object]) -> Mapping[str, object] | None:
if (
schema_has_unresolvable_ref(json_schema)
or schema_has_open_object(json_schema)
or schema_has_lookaround_pattern(json_schema)
or not schema_string_formats(json_schema) <= _NATIVE_OUTPUT_STRING_FORMATS
or schema_union_property_count(json_schema) > NATIVE_OUTPUT_MAX_UNION_PARAMETERS
or schema_optional_property_count(json_schema) > NATIVE_OUTPUT_MAX_OPTIONAL_PARAMETERS
or schema_grammar_branch_count(json_schema) > NATIVE_OUTPUT_MAX_GRAMMAR_BRANCHES
):
return None
return AnthropicConfig.filter_anthropic_output_schema(dict(json_schema))
def uses_native_structured_output_for_schema(
self,
model: str,
json_schema: Mapping[str, object] | None,
request_params: Mapping[str, object],
) -> bool:
return self.uses_native_structured_output(model)
def native_output_format_schema(
self, json_schema: Mapping[str, object], model: str | None = None
) -> Mapping[str, object] | None:
return self.filter_anthropic_output_schema(dict(self._defs_unpacked(json_schema)))
def map_response_format_to_anthropic_output_format(
self, value: dict | None, model: str | None = None
) -> AnthropicOutputSchema | None:
json_schema: Final = self._extract_json_schema_from_response_format(value)
if json_schema is None:
return None
# Resolve $ref/$defs before filtering — Anthropic doesn't support
# external schema references (e.g., /$defs/CalendarEvent).
import copy
from litellm.litellm_core_utils.prompt_templates.common_utils import (
DEFS_MAX_INLINED_BYTES,
unpack_defs,
)
json_schema = copy.deepcopy(json_schema)
defs: Final = json_schema.pop("$defs", json_schema.pop("definitions", {}))
if defs:
unpack_defs(json_schema, defs, max_inlined_bytes=DEFS_MAX_INLINED_BYTES)
# Filter out unsupported fields for Anthropic's output_format API
filtered_schema: Final = self.filter_anthropic_output_schema(json_schema)
return AnthropicOutputSchema(
type="json_schema",
schema=filtered_schema,
)
native_schema: Final = self.native_output_format_schema(json_schema, model)
if native_schema is None:
return None
return AnthropicOutputSchema(type="json_schema", schema=dict(native_schema))
def map_response_format_to_anthropic_tool(
self, value: dict | None, optional_params: dict, is_thinking_enabled: bool
@ -1835,8 +1878,12 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
output_key=param,
)
elif param == "response_format" and isinstance(value, dict):
if self.uses_native_structured_output(model):
_output_format = self.map_response_format_to_anthropic_output_format(value)
if self.uses_native_structured_output_for_schema(
model,
self._extract_json_schema_from_response_format(value),
REQUEST_PARAMS.validate_python(non_default_params),
):
_output_format = self.map_response_format_to_anthropic_output_format(value, model)
if _output_format is not None:
optional_params["output_format"] = _output_format
else:
@ -3008,6 +3055,9 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
completion_response["stop_reason"] = "stop"
if refusal_fields:
json_mode_message.provider_specific_fields = dict(refusal_fields)
if thinking_blocks:
json_mode_message.thinking_blocks = thinking_blocks
json_mode_message.reasoning_content = reasoning_content
_message = json_mode_message
model_response.choices[0].message = _message

View file

@ -1,5 +1,5 @@
from abc import ABC, abstractmethod
from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Mapping
from typing import TYPE_CHECKING, Any, Final
import httpx
@ -183,7 +183,7 @@ class BaseAnthropicMessagesConfig(ABC):
return 2
def should_retry_anthropic_messages_on_http_error(
self, e: httpx.HTTPStatusError, litellm_params: dict, request_body: object = None
self, e: httpx.HTTPStatusError, litellm_params: Mapping[str, object], request_body: object = None
) -> bool:
"""
When True, async_anthropic_messages_handler will transform the request body
@ -205,7 +205,9 @@ class BaseAnthropicMessagesConfig(ABC):
) and is_anthropic_thinking_binding_mismatch_error(error_text)
return not caller_asked_for_this_error and is_anthropic_invalid_thinking_block_error(error_text)
def transform_anthropic_messages_request_on_http_error(self, e: httpx.HTTPStatusError, request_data: dict) -> dict:
def transform_anthropic_messages_request_on_http_error(
self, e: httpx.HTTPStatusError, request_data: dict[str, object]
) -> dict[str, object]:
"""
Mutates request_data in place when retrying after a recoverable HTTP error.
"""
@ -217,3 +219,16 @@ class BaseAnthropicMessagesConfig(ABC):
if e.response.status_code == 400 and is_anthropic_invalid_thinking_block_error(e.response.text):
strip_thinking_blocks_from_anthropic_messages_request_dict(request_data)
return request_data
def native_output_fallback_litellm_params(
self,
model: str,
anthropic_messages_optional_request_params: Mapping[str, object],
litellm_params: GenericLiteLLMParams,
) -> GenericLiteLLMParams | None:
return None
def should_retry_anthropic_messages_without_native_output(
self, e: httpx.HTTPStatusError, request_body: Mapping[str, object], model: str
) -> bool:
return False

View file

@ -8,7 +8,7 @@ from collections.abc import AsyncIterator, Iterator, Mapping
from typing import TYPE_CHECKING, Any, Final, Union
import httpx
from pydantic import BaseModel
from pydantic import BaseModel, TypeAdapter
from litellm.constants import DEFAULT_MAX_TOKENS, RESPONSE_FORMAT_TOOL_NAME
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
@ -38,6 +38,9 @@ else:
LiteLLMLoggingObj = Any
REQUEST_PARAMS: Final = TypeAdapter(Mapping[str, object])
class BaseLLMException(Exception):
def __init__(
self,
@ -126,6 +129,17 @@ class BaseConfig(ABC):
thinking_type: Final = thinking.get("type") if isinstance(thinking, dict) else None
return thinking is True or thinking_type == "enabled" or non_default_params.get("reasoning_effort") is not None
def is_thinking_requested(self, non_default_params: Mapping[str, object]) -> bool:
match non_default_params:
case {"thinking": True | {"type": "enabled" | "adaptive"}}:
return True
case {"reasoning_effort": None | "none"}:
return False
case {"reasoning_effort": object()}:
return True
case _:
return False
def is_max_tokens_in_request(self, non_default_params: dict) -> bool:
"""
OpenAI spec allows max_tokens or max_completion_tokens to be specified.

View file

@ -12,7 +12,7 @@ from itertools import chain
from typing import TYPE_CHECKING, Final, Literal, cast, overload
import httpx
from pydantic import TypeAdapter
from pydantic import TypeAdapter, ValidationError
import litellm
from litellm._logging import verbose_logger
@ -29,6 +29,7 @@ from litellm.litellm_core_utils.core_helpers import (
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.litellm_core_utils.prompt_templates.common_utils import (
drop_lookaround_regex_patterns,
is_object_schema,
native_redacted_thinking_data,
parse_content_for_reasoning,
tool_with_sanitized_parameters,
@ -54,9 +55,18 @@ from litellm.llms.anthropic.chat.transformation import (
tools_without_unsupported_tool_search,
)
from litellm.llms.anthropic.common_utils import AnthropicModelInfo
from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException
from litellm.llms.base_llm.chat.transformation import REQUEST_PARAMS, BaseConfig, BaseLLMException
from litellm.llms.bedrock.chat.tool_result_images import place_tool_result_images
from litellm.llms.bedrock.common_utils import bedrock_model_supports_regex_lookaround, bedrock_supports_tool_search
from litellm.llms.bedrock.common_utils import (
bedrock_chat_uses_native_structured_output,
bedrock_cleaned_output_schema,
bedrock_model_supports_regex_lookaround,
bedrock_native_output_format,
bedrock_native_output_is_opt_in,
bedrock_supports_tool_search,
deployment_model_info,
requests_output_config_format,
)
from litellm.llms.bedrock.request_metadata import (
bedrock_request_metadata_headers,
bedrock_request_metadata_is_owned,
@ -165,6 +175,18 @@ BEDROCK_SERVICE_TIERS: Final = ("default", "flex", "priority", "reserved")
_BEDROCK_SERVICE_TIER_ALIASES: Final = types.MappingProxyType({"standard_only": "default"})
_OUTPUT_CONFIG_BLOCK: Final = TypeAdapter(OutputConfigBlock)
def _has_native_text_format(output_config: object) -> bool:
if not isinstance(output_config, Mapping):
return False
try:
return _OUTPUT_CONFIG_BLOCK.validate_python(output_config).get("textFormat") is not None
except ValidationError:
return False
class AmazonConverseConfig(BaseConfig):
"""
Reference - https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_Converse.html
@ -944,17 +966,22 @@ class AmazonConverseConfig(BaseConfig):
return _tool
@staticmethod
def _supports_native_structured_outputs(model: str, custom_llm_provider: str | None = None) -> bool:
"""Check if the Bedrock model supports native structured outputs (outputConfig.textFormat).
def _supports_native_structured_outputs(
model: str,
custom_llm_provider: str | None,
thinking_requested: bool,
model_info: Mapping[str, object] | None,
) -> bool:
"""Check if the Bedrock model takes native structured outputs (outputConfig.textFormat) for this request.
Delegates to the standard ``supports_native_structured_output`` utility
which looks up the flag in ``litellm.model_cost`` via
``_get_model_info_helper``.
``supports_native_structured_output`` in ``litellm.model_cost`` turns it on for every request, and
``supports_bedrock_native_structured_output`` (the deployment's ``model_info`` first) only when the
request asks for thinking, which the json tool path would drop.
Ref: https://docs.aws.amazon.com/bedrock/latest/userguide/structured-output.html
"""
from litellm.utils import supports_native_structured_output
return supports_native_structured_output(model=model, custom_llm_provider=custom_llm_provider)
return bedrock_chat_uses_native_structured_output(
model, thinking_requested, custom_llm_provider or "bedrock_converse", model_info
)
@staticmethod
def _add_additional_properties_to_schema(schema: dict) -> dict:
@ -969,9 +996,9 @@ class AmazonConverseConfig(BaseConfig):
if not isinstance(schema, dict):
return schema
result: Final = dict(schema)
result: Final[dict[str, object]] = dict(schema)
if result.get("type") == "object" and "additionalProperties" not in result:
if is_object_schema(result) and "additionalProperties" not in result:
result["additionalProperties"] = False
# Recurse into nested schemas
@ -982,16 +1009,48 @@ class AmazonConverseConfig(BaseConfig):
if "items" in result and isinstance(result["items"], dict):
result["items"] = AmazonConverseConfig._add_additional_properties_to_schema(result["items"])
for defs_key in ("$defs", "definitions"):
if defs_key in result and isinstance(result[defs_key], dict):
if isinstance(definitions := result.get(defs_key), dict):
result[defs_key] = {
k: AmazonConverseConfig._add_additional_properties_to_schema(v) for k, v in result[defs_key].items()
k: AmazonConverseConfig._add_additional_properties_to_schema(v) for k, v in definitions.items()
}
for key in ("anyOf", "allOf", "oneOf"):
if key in result and isinstance(result[key], list):
result[key] = [AmazonConverseConfig._add_additional_properties_to_schema(item) for item in result[key]]
if isinstance(variants := result.get(key), list):
result[key] = [AmazonConverseConfig._add_additional_properties_to_schema(item) for item in variants]
return result
def _native_output_schema(
self,
model: str,
json_schema: Mapping[str, object],
thinking_requested: bool,
model_info: Mapping[str, object] | None,
) -> Mapping[str, object] | None:
if not self._supports_native_structured_outputs(
model, self.custom_llm_provider, thinking_requested, model_info
):
return None
if bedrock_native_output_is_opt_in(
model, self.custom_llm_provider or "bedrock_converse", model_info
) and BedrockModelInfo.get_base_model(model).startswith("anthropic"):
return bedrock_cleaned_output_schema(json_schema)
return json_schema
def _output_config_for_anthropic_format(
self, model: str, output_config_format: Mapping[str, object], model_info: Mapping[str, object] | None
) -> OutputConfigBlock | None:
native_format: Final = bedrock_native_output_format(
model, output_config_format, self.custom_llm_provider or "bedrock_converse", model_info
)
if native_format is None:
return None
schema, name, description = (native_format.get(key) for key in ("schema", "name", "description"))
return AmazonConverseConfig._create_output_config_for_response_format(
json_schema={**schema} if isinstance(schema, Mapping) else None,
name=name if isinstance(name, str) else None,
description=description if isinstance(description, str) else None,
)
@staticmethod
def _create_output_config_for_response_format(
json_schema: dict | None = None,
@ -1051,6 +1110,7 @@ class AmazonConverseConfig(BaseConfig):
optional_params: dict,
model: str,
drop_params: bool,
model_info: Mapping[str, object] | None = None,
) -> dict:
request_params: Final = tool_params_without_hosted_mcp(
non_default_params, model=model, route="bedrock", drop_params=drop_params
@ -1070,6 +1130,7 @@ class AmazonConverseConfig(BaseConfig):
optional_params=optional_params,
non_default_params=request_params,
is_thinking_enabled=is_thinking_enabled,
model_info=model_info,
)
if param == "max_tokens" or param == "max_completion_tokens":
optional_params["maxTokens"] = (
@ -1286,6 +1347,7 @@ class AmazonConverseConfig(BaseConfig):
optional_params: dict,
non_default_params: dict,
is_thinking_enabled: bool,
model_info: Mapping[str, object] | None = None,
) -> dict:
"""
Handles translation of response_format parameter to Bedrock format.
@ -1309,13 +1371,23 @@ class AmazonConverseConfig(BaseConfig):
if "type" in value and value["type"] == "text":
return optional_params
if self._supports_native_structured_outputs(model, self.custom_llm_provider) and json_schema is not None:
native_schema: Final = (
self._native_output_schema(
model,
json_schema,
self.is_thinking_requested(REQUEST_PARAMS.validate_python(non_default_params)),
model_info,
)
if json_schema is not None
else None
)
if native_schema is not None:
# Use Bedrock's native structured outputs API (outputConfig.textFormat)
# No synthetic tool injection, no fake_stream needed.
# Requires an explicit schema — json_object with no schema falls through
# to the tool-call path below.
output_config: Final = self._create_output_config_for_response_format(
json_schema=json_schema,
json_schema=dict(native_schema),
name=name,
description=description,
)
@ -1349,6 +1421,22 @@ class AmazonConverseConfig(BaseConfig):
optional_params["json_mode"] = True
return optional_params
@staticmethod
def sends_native_text_format(optional_params: Mapping[str, object]) -> bool:
return _has_native_text_format(optional_params.get("outputConfig"))
def uses_opt_in_native_output(
self, model: str, optional_params: Mapping[str, object], litellm_params: Mapping[str, object]
) -> bool:
native_output_requested: Final = (
optional_params.get("json_mode") is True and self.sends_native_text_format(optional_params)
) or requests_output_config_format(optional_params)
return native_output_requested and bedrock_native_output_is_opt_in(
bedrock_capability_model(model, litellm_params.get("base_model")),
self.custom_llm_provider or "bedrock_converse",
deployment_model_info(litellm_params),
)
def update_optional_params_with_thinking_tokens(self, non_default_params: dict, optional_params: dict):
"""
Handles scenario where max tokens is not specified. For anthropic models (anthropic api/bedrock/vertex ai), this requires having the max tokens being set and being greater than the thinking token budget.
@ -1657,7 +1745,11 @@ class AmazonConverseConfig(BaseConfig):
tool_choice.pop("type", None)
def _prepare_request_params(
self, optional_params: dict, model: str, drop_params: bool = False
self,
optional_params: dict,
model: str,
drop_params: bool = False,
model_info: Mapping[str, object] | None = None,
) -> tuple[dict, dict, dict, OutputConfigBlock | None]:
"""Prepare and separate request parameters."""
# Consume the internal ``_output_config_normalized`` marker set by
@ -1700,17 +1792,12 @@ class AmazonConverseConfig(BaseConfig):
and output_config_format is not None
and output_config_format.get("type") == "json_schema"
and base_model.startswith("anthropic")
and self._supports_native_structured_outputs(model, self.custom_llm_provider)
):
output_config = self._create_output_config_for_response_format(
json_schema=output_config_format.get("schema"),
name=output_config_format.get("name"),
description=output_config_format.get("description"),
)
elif output_config is None and output_config_format is not None:
output_config = self._output_config_for_anthropic_format(model, output_config_format, model_info)
if output_config is None and output_config_format is not None:
litellm.verbose_logger.warning(
"Bedrock Converse: dropping `output_config.format` for model=%s — "
"model does not advertise `supports_native_structured_output` in "
"model does not take native structured output for this schema per "
"model_prices_and_context_window.json. The schema will not be "
"enforced; pass `response_format` to use the synthetic tool-call "
"fallback.",
@ -2000,7 +2087,7 @@ class AmazonConverseConfig(BaseConfig):
additional_request_params,
request_metadata,
output_config,
) = self._prepare_request_params(optional_params, model, drop_params)
) = self._prepare_request_params(optional_params, model, drop_params, deployment_model_info(litellm_params))
original_tools: Final = inference_params.pop("tools", [])

View file

@ -92,8 +92,11 @@ class AmazonInvokeNovaConfig(AmazonInvokeConfig, AmazonConverseConfig):
optional_params: dict,
model: str,
drop_params: bool,
model_info: Mapping[str, object] | None = None,
) -> dict:
return AmazonConverseConfig.map_openai_params(self, non_default_params, optional_params, model, drop_params)
return AmazonConverseConfig.map_openai_params(
self, non_default_params, optional_params, model, drop_params, model_info
)
def transform_request(
self,

View file

@ -25,11 +25,16 @@ from litellm.llms.bedrock.chat.invoke_transformations.base_invoke_transformation
from litellm.llms.bedrock.common_utils import (
BEDROCK_INVOKE_TRACE_FIELD,
apply_bedrock_invoke_structured_output,
bedrock_chat_uses_native_structured_output,
bedrock_cleaned_output_schema,
bedrock_native_output_is_opt_in,
bedrock_supports_tool_search,
deployment_model_info,
get_anthropic_beta_from_headers,
normalize_bedrock_opus_output_config_effort,
normalize_custom_field_on_tools,
normalize_tool_input_schema_types_for_bedrock_invoke,
requests_opt_in_native_output,
strip_unsupported_bedrock_invoke_output_config_keys,
tools_without_eager_input_streaming,
)
@ -50,6 +55,7 @@ else:
LiteLLMLoggingObj = Any
_NON_DEFAULT_PARAMS: Final = TypeAdapter(Mapping[str, object])
_LITELLM_PARAMS: Final = TypeAdapter(Mapping[str, object])
class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig):
@ -72,6 +78,10 @@ class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig):
anthropic_version: str = "bedrock-2023-05-31"
def __init__(self, model_info: Mapping[str, object] | None = None) -> None:
super().__init__()
self._deployment_model_info: Final = model_info
@property
def custom_llm_provider(self) -> str | None:
return "bedrock"
@ -82,10 +92,31 @@ class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig):
def get_supported_openai_params(self, model: str) -> list[str]:
return AnthropicConfig.get_supported_openai_params(self, model)
def uses_native_structured_output(self, model: str) -> bool:
from litellm.utils import supports_native_structured_output
def native_output_format_schema(
self, json_schema: Mapping[str, object], model: str | None = None
) -> Mapping[str, object] | None:
if model is not None and bedrock_native_output_is_opt_in(model, model_info=self._deployment_model_info):
return bedrock_cleaned_output_schema(json_schema)
return AnthropicConfig.native_output_format_schema(self, json_schema, model)
return supports_native_structured_output(model=model, custom_llm_provider="bedrock")
def uses_native_structured_output_for_schema(
self,
model: str,
json_schema: Mapping[str, object] | None,
request_params: Mapping[str, object],
) -> bool:
return bedrock_chat_uses_native_structured_output(
model, self.is_thinking_requested(request_params), model_info=self._deployment_model_info
) and (
json_schema is None
or not bedrock_native_output_is_opt_in(model, model_info=self._deployment_model_info)
or bedrock_cleaned_output_schema(json_schema) is not None
)
def uses_opt_in_native_output(
self, model: str, optional_params: Mapping[str, object], litellm_params: Mapping[str, object]
) -> bool:
return requests_opt_in_native_output(model, optional_params, deployment_model_info(litellm_params))
def map_openai_params(
self,
@ -241,6 +272,7 @@ class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig):
apply_bedrock_invoke_structured_output(
model=model,
request_body=anthropic_request,
model_info=deployment_model_info(_LITELLM_PARAMS.validate_python(litellm_params)),
)
strip_unsupported_bedrock_invoke_output_config_keys(
model=model,

View file

@ -37,6 +37,14 @@ class AmazonMantleConfig(AmazonAnthropicClaudeConfig):
Usage: model="bedrock/mantle/anthropic.claude-mythos-preview"
"""
def uses_native_structured_output_for_schema(
self,
model: str,
json_schema: Mapping[str, object] | None,
request_params: Mapping[str, object],
) -> bool:
return super().uses_native_structured_output_for_schema(model, json_schema, {})
def get_complete_url(
self,
api_base: str | None,

View file

@ -0,0 +1,75 @@
from collections.abc import Callable, Coroutine
from typing import Final, TypeAlias
import httpx
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.bedrock.common_utils import (
NativeOutputFallback,
error_response_text,
is_bedrock_native_output_rejection,
warn_bedrock_native_output_rejected,
)
from litellm.types.utils import ModelResponse
from litellm.utils import CustomStreamWrapper
NativeOutputResponse: TypeAlias = ModelResponse | CustomStreamWrapper
NativeOutputAttemptResult: TypeAlias = NativeOutputResponse | Coroutine[object, object, NativeOutputResponse]
NativeOutputAttempt: TypeAlias = Callable[[NativeOutputFallback | None], NativeOutputAttemptResult]
NativeOutputFallbackFactory: TypeAlias = Callable[[], NativeOutputFallback | None]
UpstreamError: TypeAlias = BaseLLMException | httpx.HTTPStatusError
def _status_and_text(error: UpstreamError) -> tuple[int, str]:
match error:
case BaseLLMException():
return error.status_code, error.message
case httpx.HTTPStatusError():
return error.response.status_code, error_response_text(error.response)
def _fallback_for(error: UpstreamError, fallback: NativeOutputFallbackFactory, model: str) -> NativeOutputFallback | None:
status_code, error_text = _status_and_text(error)
if not is_bedrock_native_output_rejection(status_code, error_text):
return None
retry: Final = fallback()
if retry is not None:
warn_bedrock_native_output_rejected(model, error_text)
return retry
async def _await_with_native_output_fallback(
first_attempt: Coroutine[object, object, NativeOutputResponse],
attempt: NativeOutputAttempt,
fallback: NativeOutputFallbackFactory,
model: str,
) -> NativeOutputResponse:
try:
return await first_attempt
except (BaseLLMException, httpx.HTTPStatusError) as error:
retry: Final = _fallback_for(error, fallback, model)
if retry is None:
raise
second_attempt: Final = attempt(retry)
return await second_attempt if isinstance(second_attempt, Coroutine) else second_attempt
def call_with_native_output_fallback(
attempt: NativeOutputAttempt,
fallback: NativeOutputFallbackFactory | None,
model: str,
) -> NativeOutputAttemptResult:
if fallback is None:
return attempt(None)
try:
first_attempt: Final = attempt(None)
except (BaseLLMException, httpx.HTTPStatusError) as error:
retry: Final = _fallback_for(error, fallback, model)
if retry is None:
raise
return attempt(retry)
if isinstance(first_attempt, Coroutine):
return _await_with_native_output_fallback(first_attempt, attempt, fallback, model)
return first_attempt

View file

@ -11,6 +11,7 @@ import os
import re
import warnings
from collections.abc import Iterator, Mapping, Sequence
from dataclasses import dataclass
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, Literal, TypeAlias
@ -29,6 +30,7 @@ import litellm
from litellm import verbose_logger
from litellm.litellm_core_utils.aws_partition import get_aws_dns_suffix
from litellm.litellm_core_utils.optional_imports import ensure_optional_import
from litellm.litellm_core_utils.prompt_templates.common_utils import schema_property_count
from litellm.llms.base_llm.anthropic_messages.transformation import (
BaseAnthropicMessagesConfig,
)
@ -185,7 +187,7 @@ def _get_local_model_cost_map() -> dict:
return GetModelCostMap.load_local_model_cost_map()
def pop_bedrock_invoke_output_config_format(request_body: dict) -> dict | None:
def pop_bedrock_invoke_output_config_format(request_body: dict[str, object]) -> dict[str, object] | None:
"""
Remove and return Anthropic's nested ``output_config.format`` field.
@ -206,8 +208,8 @@ def pop_bedrock_invoke_output_config_format(request_body: dict) -> dict | None:
def convert_bedrock_invoke_output_format_to_inline_schema(
output_format: dict,
request_body: dict,
output_format: Mapping[str, object],
request_body: dict[str, object],
) -> None:
"""
Embed an Anthropic structured-output schema into the last user message.
@ -251,15 +253,164 @@ def convert_bedrock_invoke_output_format_to_inline_schema(
request_body["messages"] = new_messages
def _bedrock_model_supports(model: str, key: str) -> bool:
def _bedrock_model_supports(model: str, key: str, custom_llm_provider: str = "bedrock") -> bool:
from litellm.utils import supports_factory
return supports_factory(model=model, custom_llm_provider="bedrock", key=key)
return supports_factory(model=model, custom_llm_provider=custom_llm_provider, key=key)
_DEPLOYMENT_MODEL_INFO: Final = TypeAdapter(dict[str, object])
def deployment_model_info(litellm_params: Mapping[str, object] | None) -> Mapping[str, object]:
model_info: Final = (litellm_params or {}).get("model_info")
return _DEPLOYMENT_MODEL_INFO.validate_python(model_info) if isinstance(model_info, Mapping) else {}
def _bedrock_native_output_opt_in(
model: str, custom_llm_provider: str, model_info: Mapping[str, object] | None
) -> bool:
deployment_flag: Final = (model_info or {}).get("supports_bedrock_native_structured_output")
if isinstance(deployment_flag, bool):
return deployment_flag
return _bedrock_model_supports(model, "supports_bedrock_native_structured_output", custom_llm_provider)
def bedrock_supports_native_structured_output(
model: str, custom_llm_provider: str = "bedrock", model_info: Mapping[str, object] | None = None
) -> bool:
return _bedrock_model_supports(
model, "supports_native_structured_output", custom_llm_provider
) or _bedrock_native_output_opt_in(model, custom_llm_provider, model_info)
def bedrock_chat_uses_native_structured_output(
model: str,
thinking_requested: bool,
custom_llm_provider: str = "bedrock",
model_info: Mapping[str, object] | None = None,
) -> bool:
return _bedrock_model_supports(model, "supports_native_structured_output", custom_llm_provider) or (
thinking_requested and _bedrock_native_output_opt_in(model, custom_llm_provider, model_info)
)
def bedrock_native_output_is_opt_in(
model: str, custom_llm_provider: str = "bedrock", model_info: Mapping[str, object] | None = None
) -> bool:
return not _bedrock_model_supports(
model, "supports_native_structured_output", custom_llm_provider
) and _bedrock_native_output_opt_in(model, custom_llm_provider, model_info)
def without_bedrock_native_output_opt_in(litellm_params: Mapping[str, object]) -> Mapping[str, object]:
return {
**litellm_params,
"model_info": {**deployment_model_info(litellm_params), "supports_bedrock_native_structured_output": False},
}
_BEDROCK_NATIVE_OUTPUT_UNSUPPORTED: Final = "output_config.format: Extra inputs are not permitted"
_BEDROCK_NATIVE_OUTPUT_REJECTIONS: Final = (
_BEDROCK_NATIVE_OUTPUT_UNSUPPORTED,
"compilation exceeded limits: Schema is too complex",
"output_config.format.schema:",
)
def is_bedrock_native_output_rejection(status_code: int, error_text: str) -> bool:
return status_code == 400 and any(rejection in error_text for rejection in _BEDROCK_NATIVE_OUTPUT_REJECTIONS)
def warn_bedrock_native_output_rejected(model: str, error_text: str) -> None:
opt_out_advice: Final = (
" Set `supports_bedrock_native_structured_output: false` in this deployment's model_info to skip the "
"rejected call."
if _BEDROCK_NATIVE_OUTPUT_UNSUPPORTED in error_text
else ""
)
verbose_logger.warning(
"Bedrock rejected native structured output for model=%s, so litellm sent the request again without it.%s",
model,
opt_out_advice,
)
@dataclass(frozen=True, slots=True)
class NativeOutputFallback:
optional_params: Mapping[str, object]
litellm_params: Mapping[str, object]
def requests_output_config_format(optional_params: Mapping[str, object]) -> bool:
match optional_params:
case {"output_config": {"format": object()}}:
return True
case _:
return False
def requests_opt_in_native_output(
model: str, anthropic_optional_params: Mapping[str, object], model_info: Mapping[str, object] | None
) -> bool:
native_output_requested: Final = isinstance(
anthropic_optional_params.get("output_format"), Mapping
) or requests_output_config_format(anthropic_optional_params)
return native_output_requested and bedrock_native_output_is_opt_in(model, model_info=model_info)
_JSON_OBJECT: Final = TypeAdapter(dict[str, object])
def json_object_or_none(value: object) -> Mapping[str, object] | None:
return _JSON_OBJECT.validate_python(value) if isinstance(value, Mapping) else None
BEDROCK_NATIVE_OUTPUT_MAX_PROPERTIES: Final = 64
def bedrock_cleaned_output_schema(schema: Mapping[str, object]) -> Mapping[str, object] | None:
from litellm.llms.anthropic.chat.transformation import AnthropicConfig
if schema_property_count(schema) > BEDROCK_NATIVE_OUTPUT_MAX_PROPERTIES:
return None
return AnthropicConfig.native_output_schema(schema)
def bedrock_native_output_schema(
model: str,
schema: Mapping[str, object],
custom_llm_provider: str = "bedrock",
model_info: Mapping[str, object] | None = None,
) -> Mapping[str, object] | None:
if _bedrock_model_supports(model, "supports_native_structured_output", custom_llm_provider):
return schema
if _bedrock_native_output_opt_in(model, custom_llm_provider, model_info):
return bedrock_cleaned_output_schema(schema)
return None
def bedrock_native_output_format(
model: str,
output_format: Mapping[str, object],
custom_llm_provider: str = "bedrock",
model_info: Mapping[str, object] | None = None,
) -> Mapping[str, object] | None:
schema: Final = output_format.get("schema")
if not isinstance(schema, Mapping):
return (
output_format if bedrock_supports_native_structured_output(model, custom_llm_provider, model_info) else None
)
native_schema: Final = bedrock_native_output_schema(model, schema, custom_llm_provider, model_info)
if native_schema is None:
return None
return {**output_format, "schema": native_schema}
def apply_bedrock_invoke_structured_output(
model: str,
request_body: dict[str, object], # mutable-ok: edited in place like siblings
model_info: Mapping[str, object] | None = None,
) -> None:
"""
Route Anthropic structured-output params to what the Bedrock model supports.
@ -275,21 +426,23 @@ def apply_bedrock_invoke_structured_output(
"""
legacy_output_format: Final = request_body.pop("output_format", None)
output_config_format: Final = pop_bedrock_invoke_output_config_format(request_body)
schema_format: Final = legacy_output_format if isinstance(legacy_output_format, dict) else output_config_format
legacy_format: Final = json_object_or_none(legacy_output_format)
schema_format: Final = legacy_format if legacy_format is not None else output_config_format
if schema_format is None:
return
if _bedrock_model_supports(model, "supports_native_structured_output"):
native_format: Final = bedrock_native_output_format(model, schema_format, model_info=model_info)
if native_format is not None:
existing_output_config: Final = request_body.get("output_config")
if isinstance(existing_output_config, dict):
existing_output_config["format"] = schema_format
existing_output_config["format"] = native_format
else:
request_body["output_config"] = {"format": schema_format} # rebind-ok: out-param
request_body["output_config"] = {"format": native_format} # rebind-ok: out-param
return
verbose_logger.warning(
"Bedrock Invoke: model=%s does not advertise `supports_native_structured_output` "
"in model_prices_and_context_window.json, so the JSON schema was inlined into "
"Bedrock Invoke: model=%s does not take native structured output for this schema "
"per model_prices_and_context_window.json, so the JSON schema was inlined into "
"the last user message and is NOT enforced by the model.",
model,
)
@ -1307,7 +1460,6 @@ def is_claude_4_5_on_bedrock(model: str) -> bool:
_BEDROCK_MODEL_VERSION_SUFFIX_RE: Final = re.compile(r"-v\d+(?::\d+)?$")
_DEPLOYMENT_MODEL_INFO: Final = TypeAdapter(dict[str, object])
def bedrock_converse_supports_strict_tools(model: str) -> bool:
@ -1338,8 +1490,7 @@ def bedrock_model_supports_regex_lookaround(model: str, litellm_params: Mapping[
entry of ``model`` itself. A model nobody flagged keeps its schema as sent.
"""
params: Final = litellm_params or {}
model_info: Final = _DEPLOYMENT_MODEL_INFO.validate_python(params.get("model_info") or {})
deployment_flag: Final = model_info.get("supports_regex_lookaround")
deployment_flag: Final = deployment_model_info(params).get("supports_regex_lookaround")
if isinstance(deployment_flag, bool):
return deployment_flag
base_model: Final = params.get("base_model")

View file

@ -66,8 +66,10 @@ from litellm.utils import get_llm_provider, get_optional_params
from ..base_aws_llm import BaseAWSLLM
from ..common_utils import (
BedrockError,
deployment_model_info,
merge_bedrock_aws_request_params,
resolve_s3_encryption_key_id,
without_bedrock_native_output_opt_in,
)
S3_SIGNED_REQUEST_HEADERS_PARAM: Final = "_s3_signed_request_headers"
@ -993,10 +995,12 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig):
)
config: Final = AmazonAnthropicClaudeConfig()
litellm_params: Final = without_bedrock_native_output_opt_in({})
mapped_params = get_optional_params(
model=_invoke_route_model(model),
custom_llm_provider="bedrock",
messages=messages,
model_info=deployment_model_info(litellm_params),
**MappingProxyType(
{
k: v
@ -1011,7 +1015,7 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig):
optional_params={
k: v for k, v in mapped_params.items() if k not in _MAPPED_PARAMS_THE_REQUEST_HANDLER_STRIPS
},
litellm_params={},
litellm_params=dict(litellm_params),
headers={},
)

View file

@ -42,12 +42,16 @@ from litellm.llms.bedrock.common_utils import (
bedrock_supports_tool_search,
ensure_bedrock_anthropic_messages_tool_names,
get_anthropic_beta_from_headers,
is_bedrock_native_output_rejection,
normalize_bedrock_opus_output_config_effort,
normalize_custom_field_on_tools,
normalize_tool_input_schema_types_for_bedrock_invoke,
requests_opt_in_native_output,
requests_output_config_format,
strip_unsupported_bedrock_invoke_output_config_keys,
supports_bedrock_prompt_cache_ttl,
tools_without_eager_input_streaming,
warn_bedrock_native_output_rejected,
)
from litellm.llms.bedrock.request_metadata import (
bedrock_request_metadata_headers,
@ -117,6 +121,29 @@ class AmazonAnthropicClaudeMessagesConfig(
BaseAnthropicMessagesConfig.__init__(self, **kwargs)
AmazonInvokeConfig.__init__(self, **kwargs)
def native_output_fallback_litellm_params(
self,
model: str,
anthropic_messages_optional_request_params: Mapping[str, object],
litellm_params: GenericLiteLLMParams,
) -> GenericLiteLLMParams | None:
model_info: Final = _MODEL_INFO.validate_python(litellm_params.model_info)
if not requests_opt_in_native_output(model, anthropic_messages_optional_request_params, model_info):
return None
return litellm_params.model_copy(
update={"model_info": {**(model_info or {}), "supports_bedrock_native_structured_output": False}}
)
def should_retry_anthropic_messages_without_native_output(
self, e: httpx.HTTPStatusError, request_body: Mapping[str, object], model: str
) -> bool:
if not requests_output_config_format(request_body) or not is_bedrock_native_output_rejection(
e.response.status_code, e.response.text
):
return False
warn_bedrock_native_output_rejected(model, e.response.text)
return True
def validate_anthropic_messages_environment(
self,
headers: dict,
@ -765,6 +792,7 @@ class AmazonAnthropicClaudeMessagesConfig(
apply_bedrock_invoke_structured_output(
model=model,
request_body=anthropic_messages_request,
model_info=_MODEL_INFO.validate_python(getattr(litellm_params, "model_info", None)),
)
normalize_bedrock_opus_output_config_effort(
model=model,

View file

@ -1,8 +1,9 @@
import asyncio
import copy
import inspect
import json
import ssl
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Coroutine, Iterator, Mapping, Sequence
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Coroutine, Iterator, Mapping, Sequence
from contextlib import asynccontextmanager
from functools import lru_cache, partial, reduce
from types import MappingProxyType
@ -468,6 +469,8 @@ async def _aiter_bytes_then_close(response: httpx.Response, *, chunk_size: int)
_DECODED_BODY_STALE_HEADERS: Final[frozenset[str]] = frozenset({"content-encoding", "content-length"})
_OPTIONAL_PARAMS: Final = TypeAdapter(Mapping[str, object])
_JSON_OBJECT: Final = TypeAdapter(dict[str, object])
_JSON_OBJECTS: Final = TypeAdapter(list[dict[str, object]])
def _decoded_body_headers(response: httpx.Response) -> httpx.Headers:
@ -2122,11 +2125,13 @@ class BaseLLMHTTPHandler:
api_key: str | None,
model: str,
timeout: float | httpx.Timeout | None = None,
rebuild_without_native_output: Callable[[], Mapping[str, object]] | None = None,
) -> httpx.Response:
max_attempts: Final = max(provider_config.max_retry_on_anthropic_messages_http_error, 1)
litellm_params_dict: Final = dict(litellm_params)
optional_params_dict: Final = dict(litellm_params)
for attempt_idx in range(max_attempts):
recovered_errors: tuple[httpx.HTTPStatusError, ...] = () # rebind-ok: a rebuilt body replays every recovery
while True:
try:
response = await async_httpx_client.post(
url=request_url,
@ -2139,40 +2144,54 @@ class BaseLLMHTTPHandler:
response.raise_for_status()
return response
except httpx.HTTPStatusError as e:
hit_max_attempt = attempt_idx + 1 == max_attempts
rebuild_body = (
rebuild_without_native_output
if rebuild_without_native_output is not None
and provider_config.should_retry_anthropic_messages_without_native_output(
e=e, request_body=request_body, model=model
)
else None
)
hit_max_attempt = len(recovered_errors) + 1 == max_attempts
should_retry = provider_config.should_retry_anthropic_messages_on_http_error(
e=e, litellm_params=litellm_params_dict, request_body=request_body
)
if should_retry and not hit_max_attempt:
if logging_obj.baseline_cache_context is not None:
await logging_obj.invalidate_baseline_cache_estimate("retried_request")
if rebuild_body is None and (hit_max_attempt or not should_retry):
raise self._handle_error(e=e, provider_config=provider_config)
if logging_obj.baseline_cache_context is not None:
await logging_obj.invalidate_baseline_cache_estimate("retried_request")
if rebuild_body is not None:
request_body.clear()
request_body.update(rebuild_body())
for recovered_error in recovered_errors:
provider_config.transform_anthropic_messages_request_on_http_error(
e=recovered_error, request_data=request_body
)
else:
verbose_logger.debug(
"Anthropic /v1/messages: invalid thinking signature; "
"stripping thinking blocks and retrying (attempt %s/%s).",
attempt_idx + 2,
len(recovered_errors) + 2,
max_attempts,
)
provider_config.transform_anthropic_messages_request_on_http_error(e=e, request_data=request_body)
headers, signed_json_body = await sign_request_off_loop_if_aws(
provider_config,
provider_config.sign_request,
headers=headers,
optional_params=optional_params_dict,
request_data=request_body,
api_base=request_url,
api_key=api_key,
stream=stream,
fake_stream=False,
model=model,
)
logging_obj.model_call_details.update(request_body)
continue
raise self._handle_error(e=e, provider_config=provider_config)
recovered_errors = (*recovered_errors, e)
headers, signed_json_body = await sign_request_off_loop_if_aws(
provider_config,
provider_config.sign_request,
headers=headers,
optional_params=optional_params_dict,
request_data=request_body,
api_base=request_url,
api_key=api_key,
stream=stream,
fake_stream=False,
model=model,
)
logging_obj.model_call_details.update(request_body)
except Exception as e:
raise self._handle_error(e=e, provider_config=provider_config)
raise RuntimeError("unreachable: anthropic messages HTTP retry loop exited without return")
@staticmethod
def _resolve_anthropic_messages_timeout(
litellm_params: GenericLiteLLMParams,
@ -2298,6 +2317,24 @@ class BaseLLMHTTPHandler:
custom_llm_provider=custom_llm_provider,
)
native_output_fallback_params: Final = anthropic_messages_provider_config.native_output_fallback_litellm_params(
model=model,
anthropic_messages_optional_request_params=_OPTIONAL_PARAMS.validate_python(request_params),
litellm_params=litellm_params,
)
rebuild_without_native_output: Final = (
None
if native_output_fallback_params is None
else partial[Mapping[str, object]](
anthropic_messages_provider_config.transform_anthropic_messages_request,
model=model,
messages=copy.deepcopy(_JSON_OBJECTS.validate_python(messages)),
anthropic_messages_optional_request_params=copy.deepcopy(_JSON_OBJECT.validate_python(request_params)),
litellm_params=native_output_fallback_params,
headers=_JSON_OBJECT.validate_python(headers),
)
)
# Prepare request body
request_body: Final = anthropic_messages_provider_config.transform_anthropic_messages_request(
model=model,
@ -2371,6 +2408,7 @@ class BaseLLMHTTPHandler:
stream=stream or False,
custom_llm_provider=custom_llm_provider,
),
rebuild_without_native_output=rebuild_without_native_output,
)
# used for logging + cost tracking

View file

@ -119,7 +119,10 @@ from litellm.llms.base_llm.base_model_iterator import (
from litellm.llms.base_llm.chat.transformation import with_attribution_headers
from litellm.llms.bedrock.common_utils import (
BedrockModelInfo,
NativeOutputFallback,
bedrock_route_for_request,
deployment_model_info,
without_bedrock_native_output_opt_in,
without_bedrock_route_prefix,
)
from litellm.llms.bedrock_mantle.chat.claude_transformation import bedrock_mantle_chat_config
@ -223,6 +226,9 @@ from .llms.azure.completion.handler import AzureTextCompletion
from .llms.azure_ai.anthropic.handler import AzureAnthropicChatCompletion
from .llms.azure_ai.embed import AzureAIEmbedding
from .llms.bedrock.chat import BedrockConverseLLM
from .llms.bedrock.chat.converse_transformation import AmazonConverseConfig
from .llms.bedrock.chat.invoke_transformations.anthropic_claude3_transformation import AmazonAnthropicClaudeConfig
from .llms.bedrock.chat.native_output_fallback import NativeOutputAttemptResult, call_with_native_output_fallback
from .llms.bedrock.embed.embedding import BedrockEmbedding
from .llms.bedrock.image_edit.handler import BedrockImageEdit
from .llms.bedrock.image_generation.image_handler import BedrockImageGeneration
@ -4126,10 +4132,48 @@ def _complete_sagemaker(ctx: CompletionDispatchContext) -> _CompletionDispatchRe
_ADDITIONAL_DROP_PARAMS_ADAPTER: Final = TypeAdapter(list[str])
_OPTIONAL_PARAMS_ADAPTER: Final = TypeAdapter(dict[str, object])
_REQUEST_KWARGS_ADAPTER: Final = TypeAdapter(Mapping[str, object])
_MODEL_INFO_ADAPTER: Final = TypeAdapter(Mapping[str, object])
_PROVIDER_ROUTING_HINTS: Final = TypeAdapter(ProviderRoutingHints)
_BASE_URL_HINT: Final = TypeAdapter(BaseUrlRoutingHint)
_AWS_BEDROCK_CLIENT_CREDENTIAL_KEYS: Final = (
"aws_access_key_id",
"aws_secret_access_key",
"aws_session_token",
"aws_region_name",
)
def _native_output_fallback(
ctx: CompletionDispatchContext,
first_attempt_params: Mapping[str, object],
first_attempt_litellm_params: Mapping[str, object],
) -> NativeOutputFallback:
litellm_params: Final = without_bedrock_native_output_opt_in(first_attempt_litellm_params)
remapped_params: Final = ctx.optional_params_for_model_info(deployment_model_info(litellm_params))
return NativeOutputFallback(
optional_params={
**{key: value for key, value in remapped_params.items() if key != "aws_bedrock_client"},
**{
key: first_attempt_params[key]
for key in _AWS_BEDROCK_CLIENT_CREDENTIAL_KEYS
if key in first_attempt_params
},
},
litellm_params=litellm_params,
)
def _converse_native_output_fallback(
ctx: CompletionDispatchContext,
first_attempt_params: Mapping[str, object],
first_attempt_litellm_params: Mapping[str, object],
) -> NativeOutputFallback | None:
fallback: Final = _native_output_fallback(ctx, first_attempt_params, first_attempt_litellm_params)
return None if AmazonConverseConfig.sends_native_text_format(fallback.optional_params) else fallback
def _complete_bedrock(ctx: CompletionDispatchContext) -> _CompletionDispatchResult:
acompletion: Final = ctx.acompletion
api_base: Final = ctx.api_base
@ -4201,22 +4245,36 @@ def _complete_bedrock(ctx: CompletionDispatchContext) -> _CompletionDispatchResu
)
elif bedrock_route == "converse":
model = without_bedrock_route_prefix(model)
response = bedrock_converse_chat_completion.completion(
model=model,
messages=messages,
custom_prompt_dict=custom_prompt_dict,
model_response=model_response,
optional_params=optional_params,
litellm_params=litellm_params,
logger_fn=logger_fn,
encoding=_get_encoding(),
logging_obj=logging,
extra_headers=headers, # Use merged headers instead of original extra_headers
timeout=timeout,
acompletion=acompletion,
client=client,
api_base=api_base,
api_key=api_key,
converse_model: Final = model
def converse_attempt(fallback: NativeOutputFallback | None) -> NativeOutputAttemptResult:
return bedrock_converse_chat_completion.completion(
model=converse_model,
messages=messages,
custom_prompt_dict=custom_prompt_dict,
model_response=model_response,
optional_params=optional_params if fallback is None else dict(fallback.optional_params),
litellm_params=litellm_params if fallback is None else dict(fallback.litellm_params),
logger_fn=logger_fn,
encoding=_get_encoding(),
logging_obj=logging,
extra_headers=headers, # Use merged headers instead of original extra_headers
timeout=timeout,
acompletion=acompletion,
client=client,
api_base=api_base,
api_key=api_key,
)
converse_params: Final = _OPTIONAL_PARAMS_ADAPTER.validate_python(optional_params)
response = call_with_native_output_fallback(
converse_attempt,
(
partial(_converse_native_output_fallback, ctx, converse_params, dict(litellm_params))
if AmazonConverseConfig().uses_opt_in_native_output(converse_model, converse_params, litellm_params)
else None
),
converse_model,
)
elif bedrock_route == "converse_like":
model = model.replace("converse_like/", "")
@ -4238,22 +4296,37 @@ def _complete_bedrock(ctx: CompletionDispatchContext) -> _CompletionDispatchResu
client=client,
)
else:
response = base_llm_http_handler.completion(
model=model,
stream=stream,
messages=messages,
acompletion=acompletion,
api_base=api_base,
model_response=model_response,
optional_params=optional_params,
litellm_params=litellm_params,
custom_llm_provider="bedrock",
timeout=timeout,
headers=headers,
encoding=_get_encoding(),
api_key=api_key,
logging_obj=logging,
client=client,
headers_before_first_attempt: Final = dict(headers)
def invoke_attempt(fallback: NativeOutputFallback | None) -> NativeOutputAttemptResult:
return base_llm_http_handler.completion(
model=model,
stream=stream,
messages=messages,
acompletion=acompletion,
api_base=api_base,
model_response=model_response,
optional_params=optional_params if fallback is None else dict(fallback.optional_params),
litellm_params=litellm_params if fallback is None else dict(fallback.litellm_params),
custom_llm_provider="bedrock",
timeout=timeout,
headers=headers if fallback is None else dict(headers_before_first_attempt),
encoding=_get_encoding(),
api_key=api_key,
logging_obj=logging,
client=client,
)
invoke_params: Final = _OPTIONAL_PARAMS_ADAPTER.validate_python(optional_params)
response = call_with_native_output_fallback(
invoke_attempt,
(
partial(_native_output_fallback, ctx, invoke_params, dict(litellm_params))
if isinstance(provider_config, AmazonAnthropicClaudeConfig)
and provider_config.uses_opt_in_native_output(model, invoke_params, litellm_params)
else None
),
model,
)
return response
@ -5685,8 +5758,15 @@ def completion(
accepts_summary=provider_config is not None
and provider_config.accepts_reasoning_summary_in_reasoning_effort,
)
optional_params = get_optional_params(
**{**optional_param_args, "reasoning_effort": provider_reasoning_effort}, **non_default_params
mapping_args: Final = MappingProxyType({**optional_param_args, "reasoning_effort": provider_reasoning_effort})
mapping_special_params: Final = MappingProxyType(dict(non_default_params))
def optional_params_for_model_info(deployment_info: Mapping[str, object] | None) -> dict[str, object]:
return get_optional_params(**mapping_args, model_info=deployment_info, **mapping_special_params)
optional_params = optional_params_for_model_info(
_MODEL_INFO_ADAPTER.validate_python(model_info) if isinstance(model_info, dict) else None
)
processed_non_default_params: Final = pre_process_non_default_params(
model=model,
@ -5921,6 +6001,7 @@ def completion(
model=model,
model_response=model_response,
optional_params=optional_params,
optional_params_for_model_info=optional_params_for_model_info,
organization=organization,
provider_config=provider_config,
request_params=MappingProxyType(

View file

@ -229,6 +229,7 @@ class CompletionDispatchContext:
model: str
model_response: ModelResponse
optional_params: dict
optional_params_for_model_info: Callable[[Mapping[str, object] | None], dict[str, object]]
organization: str | None
provider_config: BaseConfig | None
request_params: Mapping[str, object]

View file

@ -205,6 +205,7 @@ class ProviderSpecificModelInfo(TypedDict, total=False):
supports_pdf_input: bool | None
supports_native_streaming: bool | None
supports_native_structured_output: bool | None
supports_bedrock_native_structured_output: ReadOnly[bool | None]
supports_parallel_function_calling: bool | None
supports_web_search: bool | None
supports_reasoning: bool | None
@ -4095,7 +4096,11 @@ class CustomPricingLiteLLMParams(MirroredPricingParams):
DEPLOYMENT_SCOPED_PRICING_FIELDS: Final[frozenset[str]] = frozenset({"off_peak_pricing"})
DEPLOYMENT_SCOPED_CAPABILITY_FIELDS: Final[frozenset[str]] = frozenset(
{"supports_regex_lookaround", "supports_per_turn_control"}
{
"supports_regex_lookaround",
"supports_bedrock_native_structured_output",
"supports_per_turn_control",
}
)
DEPLOYMENT_SCOPED_TOKEN_COUNT_FIELDS: Final[frozenset[str]] = frozenset({"token_count_framing", "token_count_model"})

View file

@ -4827,6 +4827,7 @@ def get_optional_params(
store: bool | None = None,
prompt_cache_key: str | None = None,
base_model: str | None = None,
model_info: Mapping[str, object] | None = None,
**kwargs: object,
):
drop_params = normalize_drop_params(drop_params) # rebind-ok: config and DB deployments pass "true" as a string
@ -4837,6 +4838,7 @@ def get_optional_params(
# non_default_params / _check_valid_arg — it's a routing hint, not an
# OpenAI param.
passed_params.pop("base_model", None)
passed_params.pop("model_info", None)
provider_config: BaseConfig | None = None
if custom_llm_provider is not None and custom_llm_provider in [provider.value for provider in LlmProviders]:
provider_config = ProviderConfigManager.get_provider_chat_config(
@ -5078,6 +5080,7 @@ def get_optional_params(
non_default_params=non_default_params,
optional_params=optional_params,
drop_params=bool(drop_params),
model_info=model_info,
)
elif bedrock_route == "openai":
optional_params = litellm.AmazonBedrockOpenAIConfig().map_openai_params(
@ -5095,7 +5098,7 @@ def get_optional_params(
drop_params=bool(drop_params),
)
else:
optional_params = litellm.AmazonAnthropicClaudeConfig().map_openai_params(
optional_params = litellm.AmazonAnthropicClaudeConfig(model_info=model_info).map_openai_params(
non_default_params=non_default_params,
optional_params=optional_params,
model=model,
@ -6759,6 +6762,9 @@ def get_model_info_helper(
supports_embedding_image_input=_model_info.get("supports_embedding_image_input", None),
supports_native_streaming=_model_info.get("supports_native_streaming", None),
supports_native_structured_output=_model_info.get("supports_native_structured_output", None),
supports_bedrock_native_structured_output=_model_info.get(
"supports_bedrock_native_structured_output", None
),
supports_web_search=_model_info.get("supports_web_search", None),
supports_url_context=_model_info.get("supports_url_context", None),
supports_reasoning=_model_info.get("supports_reasoning", None),

View file

@ -1054,6 +1054,9 @@
"supports_bedrock_converse_tool_result_images": {
"type": "boolean"
},
"supports_bedrock_native_structured_output": {
"type": "boolean"
},
"supports_bedrock_runtime_chat_completions_inline_reasoning": {
"type": "boolean"
},

View file

@ -1,6 +1,6 @@
[project]
name = "litellm"
version = "1.108.1"
version = "1.108.2"
description = "Library to easily interface with LLM API providers"
readme = "README.md"
requires-python = ">=3.10, <3.15"
@ -393,7 +393,7 @@ litellm-enterprise = { workspace = true }
members = ["enterprise", "litellm-proxy-extras"]
[tool.commitizen]
version = "1.108.1"
version = "1.108.2"
version_files = [
"pyproject.toml:^version",
]

View file

@ -2,7 +2,7 @@ import json
from typing import Final
import pytest
from integration._support.client import Gateway
from integration._support.client import JSON_OBJECT, Gateway, list_value, object_value
from integration._support.wire import Reply, Request, wire_server
MODEL: Final = "bedrock/anthropic.claude-3-haiku-20240307-v1:0"
@ -76,3 +76,56 @@ def test_completions_and_responses_batch_records_upload_as_anthropic_user_messag
assert len(uploads) == 1, f"Expected exactly one S3 PUT, saw {[upload.target for upload in uploads]}"
stored: Final = tuple(json.loads(line) for line in uploads[0].body.decode().splitlines() if line.strip())
assert stored == EXPECTED_S3_OBJECT
_JSON_TOOL: Final = "json_tool_call"
_SCHEMA: Final = {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
"additionalProperties": False,
}
@pytest.mark.parametrize(
"backend", ["us.anthropic.claude-haiku-5-5", "us.anthropic.claude-opus-4-8"], ids=["haiku_5_5", "opus_4_8"]
)
def test_json_schema_batch_record_with_thinking_uploads_the_json_tool_and_no_native_output(
gateway: Gateway, backend: str
) -> None:
record: Final = {
"custom_id": "schema-record",
"method": "POST",
"url": "/v1/chat/completions",
"body": {
"model": f"bedrock/{backend}",
"messages": [{"role": "user", "content": "Name a city."}],
"max_tokens": 2048,
"thinking": {"type": "adaptive"},
"response_format": {"type": "json_schema", "json_schema": {"name": "city", "schema": _SCHEMA}},
},
}
with wire_server(s3_peer) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(
model=f"bedrock/{backend}",
api_key=None,
api_base=None,
aws_access_key_id="AKIAIOSFODNN7EXAMPLE",
aws_secret_access_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
aws_region_name="us-east-1",
s3_bucket_name=BUCKET,
s3_endpoint_url=wire.url,
)
response: Final = gateway.request_multipart(
"/v1/files",
{"purpose": "batch", "model": model},
{"file": ("in.jsonl", (json.dumps(record) + "\n").encode(), "application/jsonl")},
)
assert response.status_code == 200, response.text
(upload,) = wire.drain()
(stored,) = tuple(JSON_OBJECT.validate_json(line) for line in upload.body.decode().splitlines() if line.strip())
model_input: Final = object_value(stored["modelInput"])
assert "format" not in object_value(model_input.get("output_config", {})), model_input
(tool,) = (object_value(entry) for entry in list_value(model_input["tools"]))
assert tool["name"] == _JSON_TOOL, model_input
assert object_value(tool["input_schema"])["properties"] == _SCHEMA["properties"], model_input

View file

@ -17,6 +17,7 @@ from litellm.constants import DEFAULT_ANTHROPIC_CHAT_MAX_TOKENS
_HAIKU: Final = "anthropic.claude-haiku-4-5"
_OPUS: Final = "anthropic.claude-opus-5-5"
_OPT_IN_NATIVE_OUTPUT: Final = "anthropic.claude-opus-4-8"
_SAFEGUARD: Final = "openai.gpt-oss-safeguard-120b"
_API_KEY: Final = "synthetic-mantle-bearer"
_ACCESS_KEY: Final = "AKIAINTEGRATION000009"
@ -504,6 +505,37 @@ def test_chat_json_schema_response_format_is_sent_as_a_forced_tool_and_returned_
assert [(request.method, request.target) for request in wire.drain()] == [("POST", _MESSAGES_PATH)]
def test_chat_json_schema_with_thinking_on_an_opt_in_model_stays_on_the_json_tool(gateway: Gateway) -> None:
marker: Final = uuid4().hex
thinking: Final[dict[str, JsonValue]] = {"type": "adaptive"}
expected: Final = _outbound(
_OPT_IN_NATIVE_OUTPUT,
[_user_turn(marker)],
tools=[{"name": "json_tool_call", "input_schema": _CITY_SCHEMA}],
tool_choice={"name": "json_tool_call", "type": "tool"},
thinking=thinking,
)
reply: Final = _tool_reply(marker, _OPT_IN_NATIVE_OUTPUT, "json_tool_call")
with wire_server(_bearer_peer(expected, reply)) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(
model=f"bedrock_mantle/{_OPT_IN_NATIVE_OUTPUT}", api_base=wire.url, api_key=_API_KEY
)
response: Final = gateway.request(
"POST",
"/v1/chat/completions",
{
"model": model,
"messages": _chat_messages(marker),
"thinking": thinking,
"response_format": {"type": "json_schema", "json_schema": {"name": "weather", "schema": _CITY_SCHEMA}},
},
)
assert response.status_code == 200, response.text
message: Final = object_value(_only_choice(_JSON_OBJECT.validate_json(response.content))["message"])
assert json.loads(string_value(message["content"])) == {"city": "Paris"}, response.text
assert [(request.method, request.target) for request in wire.drain()] == [("POST", _MESSAGES_PATH)]
def _authorization_field(part: str) -> tuple[str, str]:
name, _, value = part.partition("=")
return name, value

File diff suppressed because it is too large Load diff

View file

@ -37,8 +37,11 @@ _STRUCTURED_OUTPUTS_BETA: Final = "structured-outputs-2025-11-13"
_TOOL_MODEL: Final = "claude-sonnet-5"
_EFFORT_GATED_MODEL: Final = "claude-haiku-4-5"
_NATIVE_MODEL: Final = "claude-opus-5-5"
_FORCED_TOOL_NATIVE_MODEL: Final = "claude-haiku-5-5"
_UNMAPPED_MODEL: Final = "claude-scripted-9"
_BEDROCK_INVOKE_MODEL: Final = "anthropic.claude-sonnet-5"
_BEDROCK_JSON_TOOL_MODEL: Final = "us.anthropic.claude-opus-4-8"
_THINKING_SIGNATURE: Final = "c2NyaXB0ZWQtc2lnbmF0dXJl"
_PROJECT: Final = "scripted-project"
_LOCATION: Final = "us-east5"
_VERTEX_MODELS: Final = f"/v1/projects/{_PROJECT}/locations/{_LOCATION}/publishers/anthropic/models/"
@ -85,6 +88,10 @@ _VALUES_SCHEMA: Final[dict[str, JsonValue]] = {
"properties": {"values": {"type": "object"}},
"required": ["values"],
}
_VALUES_RESPONSE_FORMAT: Final[dict[str, JsonValue]] = {
"type": "json_schema",
"json_schema": {"name": "valued", "schema": _VALUES_SCHEMA},
}
_ADDITIONAL_OBJECT_SCHEMA: Final[dict[str, JsonValue]] = {
**_OPTIONAL_SCHEMA,
"additionalProperties": {"type": "object"},
@ -750,12 +757,15 @@ def _vertex_deployment(gateway: Gateway, scenario: Scenario, api_base: str, back
)
def _bedrock_invoke_deployment(scenario: Scenario, api_base: str, backend: str) -> str:
def _bedrock_invoke_deployment(
scenario: Scenario, api_base: str, backend: str, model_info: Mapping[str, JsonValue] | None = None
) -> str:
return scenario.model(
model=f"bedrock/invoke/{backend}",
api_base=api_base,
api_key=_BEDROCK_TOKEN,
aws_region_name="us-east-1",
model_info=model_info,
)
@ -1429,6 +1439,144 @@ def test_vertex_responses_native_folds_output_format(gateway: Gateway) -> None:
assert _folded_format(_one_delivery(wire, marker_hex)) == _OUTPUT_FORMAT
def _native_deployment(gateway: Gateway, scenario: Scenario, api_base: str, provider: str, backend: str) -> str:
match provider:
case "vertex_ai":
return _vertex_deployment(gateway, scenario, api_base, backend)
case "anthropic":
return scenario.model(model=f"anthropic/{backend}", api_base=api_base, api_key=_ANTHROPIC_KEY)
case "azure_ai":
return scenario.model(model=f"azure_ai/{backend}", api_base=api_base, api_key=_AZURE_KEY)
case _:
raise AssertionError(provider)
@pytest.mark.parametrize(
("provider", "backend", "extra"),
[
("anthropic", _NATIVE_MODEL, {}),
("vertex_ai", _NATIVE_MODEL, {}),
("anthropic", _FORCED_TOOL_NATIVE_MODEL, {}),
("azure_ai", _FORCED_TOOL_NATIVE_MODEL, {}),
("anthropic", _FORCED_TOOL_NATIVE_MODEL, {"thinking": {"type": "adaptive"}}),
("anthropic", _FORCED_TOOL_NATIVE_MODEL, {"tools": [_USER_TOOL_SPEC]}),
("anthropic", _FORCED_TOOL_NATIVE_MODEL, {"reasoning_effort": "none"}),
("azure_ai", _FORCED_TOOL_NATIVE_MODEL, {"reasoning_effort": "none"}),
],
ids=[
"forced_tool_refused-anthropic",
"forced_tool_refused-vertex_ai",
"plain-anthropic",
"plain-azure_ai",
"thinking",
"caller_tools",
"effort_none-anthropic",
"effort_none-azure_ai",
],
)
def test_open_object_schema_keeps_native_output_on_a_native_model(
gateway: Gateway, provider: str, backend: str, extra: dict[str, JsonValue]
) -> None:
marker, marker_hex = _marker("values")
expected_tools: Final[frozenset[str]] = frozenset({_USER_TOOL}) if "tools" in extra else frozenset()
with wire_server(_peer) as wire, gateway.scenario() as scenario:
model: Final = _native_deployment(gateway, scenario, wire.url, provider, backend)
response: Final = gateway.request(
"POST",
"/v1/chat/completions",
{**_chat_body(model, marker, response_format=_VALUES_RESPONSE_FORMAT), **extra},
)
assert _chat_content(response) == _payload(marker_hex), response.text
sent: Final = _one_delivery(wire, marker_hex)
assert _structured(sent.body), sent.body
assert _tool_names(sent.body) == expected_tools, sent.body
assert "tool_choice" not in sent.body, sent.body
assert ("thinking" in sent.body) is ("thinking" in extra), sent.body
def _thinking_then_json_tool(request: Request) -> Reply:
body: Final = _JSON_OBJECT.validate_json(request.body)
_, marker_hex = _marker_of(body)
assert _JSON_TOOL in _tool_names(body), body
thinking: Final[dict[str, JsonValue]] = {
"type": "thinking",
"thinking": f"thinking {marker_hex}",
"signature": _THINKING_SIGNATURE,
}
answer: Final = _tool_use(f"toolu_{marker_hex}", _JSON_TOOL, _payload(marker_hex))
return Reply(body=json.dumps(_message(f"msg_{marker_hex}", [thinking, answer], "tool_use")).encode())
def _json_tool_deployment(gateway: Gateway, scenario: Scenario, api_base: str, provider: str) -> str:
match provider:
case "vertex_ai":
return _vertex_deployment(gateway, scenario, api_base, _TOOL_MODEL)
case "bedrock_invoke":
return _bedrock_invoke_deployment(
scenario, api_base, _BEDROCK_JSON_TOOL_MODEL, {"supports_bedrock_native_structured_output": False}
)
case _:
raise AssertionError(provider)
@pytest.mark.parametrize("provider", ["vertex_ai", "bedrock_invoke"])
def test_json_mode_answer_keeps_the_thinking_that_preceded_the_json_tool_call(gateway: Gateway, provider: str) -> None:
marker, marker_hex = _marker("plain")
with wire_server(_thinking_then_json_tool) as wire, gateway.scenario() as scenario:
model: Final = _json_tool_deployment(gateway, scenario, wire.url, provider)
response: Final = gateway.request(
"POST", "/v1/chat/completions", {**_chat_body(model, marker), "reasoning_effort": "high"}
)
message: Final = _chat_message(response)
assert json.loads(string_value(message["content"])) == _payload(marker_hex), response.text
assert message.get("reasoning_content") == f"thinking {marker_hex}", response.text
(block,) = tuple(object_value(item) for item in list_value(message.get("thinking_blocks") or []))
assert (block["thinking"], block["signature"]) == (f"thinking {marker_hex}", _THINKING_SIGNATURE), block
sent: Final = _one_delivery(wire, marker_hex)
assert "thinking" in sent.body, sent.body
assert sent.body.get("tool_choice") != _FORCED_JSON_TOOL, sent.body
def test_vertex_chat_native_schema_keeps_min_items_one_and_closes_a_nullable_object(gateway: Gateway) -> None:
marker, marker_hex = _marker("native")
schema: Final[dict[str, JsonValue]] = {
"type": "object",
"properties": {
"summary": {"type": "string"},
"tags": {"type": "array", "items": {"type": "string"}, "minItems": 1},
"backup": {"type": ["object", "null"], "properties": {"name": {"type": "string"}}, "required": ["name"]},
},
"required": ["summary", "tags", "backup"],
"additionalProperties": False,
}
expected: Final[dict[str, JsonValue]] = {
**schema,
"properties": {
"summary": {"type": "string"},
"tags": {"type": "array", "items": {"type": "string"}, "minItems": 1},
"backup": {
"type": ["object", "null"],
"properties": {"name": {"type": "string"}},
"required": ["name"],
"additionalProperties": False,
},
},
}
response_format: Final[dict[str, JsonValue]] = {
"type": "json_schema",
"json_schema": {"name": "tagged", "schema": schema},
}
with wire_server(_peer) as wire, gateway.scenario() as scenario:
model: Final = _vertex_deployment(gateway, scenario, wire.url, _NATIVE_MODEL)
response: Final = gateway.request(
"POST", "/v1/chat/completions", _chat_body(model, marker, response_format=response_format)
)
assert _chat_content(response) == _payload(marker_hex), response.text
sent: Final = _one_delivery(wire, marker_hex)
assert _folded_format(sent) == {"type": "json_schema", "schema": expected}, sent.body
assert "tools" not in sent.body, sent.body
@pytest.mark.parametrize("stream", [False, True], ids=["non_stream", "stream"])
def test_vertex_messages_folds_output_format_into_output_config(gateway: Gateway, stream: bool) -> None:
marker, marker_hex = _marker("native")

View file

@ -98,3 +98,4 @@ def test_k03_sync_stream_with_normal_frames_delivers_the_text_and_a_stop() -> No
assert consumed.text == _ANSWER, consumed
assert consumed.finish_reasons[-1] == "stop", consumed
assert len(wire.drain()) == 1

View file

@ -0,0 +1,342 @@
import json
import re
import uuid
from collections.abc import Mapping
from typing import Final
import boto3
import pytest
from integration._support.client import list_value, object_value, string_value
from integration._support.wire import Reply, Request, Wire, wire_server
from pydantic import JsonValue, TypeAdapter
import litellm
from litellm import CustomStreamWrapper, ModelResponse
from litellm.types.utils import Choices, StreamingChoices
_OPUS_4_8: Final = "us.anthropic.claude-opus-4-8"
_JSON_TOOL: Final = "json_tool_call"
_MARKER: Final = re.compile(r"probe ([0-9a-f]{32})")
_REJECTION: Final = "The model returned the following errors: output_config.format: Extra inputs are not permitted"
_SCHEMA: Final = {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
"additionalProperties": False,
}
_JSON: Final = TypeAdapter(dict[str, JsonValue])
_ROUTES: Final = pytest.mark.parametrize(
("route", "stream"),
[("converse", False), ("converse", True), ("invoke", False)],
ids=["converse", "converse_stream", "invoke"],
)
@pytest.fixture
def marker_hex() -> str:
return uuid.uuid4().hex
def _is_invoke(request: Request) -> bool:
return "/invoke" in request.target
def _native(body: Mapping[str, JsonValue], invoke: bool) -> bool:
output_config: Final = body.get("output_config" if invoke else "outputConfig")
return isinstance(output_config, dict) and ("format" if invoke else "textFormat") in output_config
def _tool_names(body: Mapping[str, JsonValue], invoke: bool) -> frozenset[str]:
if invoke:
return frozenset(string_value(object_value(tool)["name"]) for tool in list_value(body.get("tools", [])))
tools: Final = list_value(object_value(body.get("toolConfig") or {"tools": []})["tools"])
return frozenset(string_value(object_value(object_value(tool)["toolSpec"])["name"]) for tool in tools)
def _tool_reply(invoke: bool, answer: Mapping[str, JsonValue]) -> Reply:
if invoke:
message: Final = {
"id": "msg_scripted",
"type": "message",
"role": "assistant",
"model": "claude-scripted",
"content": [{"type": "tool_use", "id": "toolu_scripted", "name": _JSON_TOOL, "input": answer}],
"stop_reason": "tool_use",
"stop_sequence": None,
"usage": {"input_tokens": 11, "output_tokens": 7},
}
return Reply(body=json.dumps(message).encode())
converse: Final = {
"output": {
"message": {
"role": "assistant",
"content": [{"toolUse": {"toolUseId": "tooluse_scripted", "name": _JSON_TOOL, "input": answer}}],
}
},
"stopReason": "tool_use",
"usage": {"inputTokens": 11, "outputTokens": 7, "totalTokens": 18},
"metrics": {"latencyMs": 1},
}
return Reply(body=json.dumps(converse).encode())
def _bedrock_without_native_output(request: Request) -> Reply:
body: Final = _JSON.validate_json(request.body)
invoke: Final = _is_invoke(request)
if _native(body, invoke):
return Reply(status=400, body=json.dumps({"message": _REJECTION}).encode())
found: Final = _MARKER.search(json.dumps(body))
assert found is not None and _JSON_TOOL in _tool_names(body, invoke), body
return _tool_reply(invoke, {"city": found.group(1)})
def _request(route: str, stream: bool, wire: Wire, marker_hex: str) -> Mapping[str, object]:
return {
"model": f"bedrock/{route}/{_OPUS_4_8}",
"messages": [{"role": "user", "content": f"Name a city. probe {marker_hex}"}],
"api_base": wire.url,
"api_key": "synthetic-bedrock-bearer",
"aws_region_name": "us-east-1",
"max_tokens": 2048,
"thinking": {"type": "adaptive"},
"response_format": {"type": "json_schema", "json_schema": {"name": "city", "schema": _SCHEMA}},
"num_retries": 0,
"stream": stream,
}
def _message_content(response: ModelResponse) -> str:
(choice,) = response.choices
assert isinstance(choice, Choices), response
return string_value(choice.message.content)
def _delta_content(chunk: ModelResponse) -> str:
(choice,) = chunk.choices
assert isinstance(choice, StreamingChoices), chunk
return choice.delta.content or ""
def _assert_one_retry_without_native_output(wire: Wire, route: str) -> None:
rejected, retried = (_JSON.validate_json(request.body) for request in wire.drain())
invoke: Final = route == "invoke"
assert _native(rejected, invoke), rejected
assert not _native(retried, invoke), retried
assert _JSON_TOOL in _tool_names(retried, invoke), retried
@_ROUTES
def test_sync_call_retries_a_native_output_rejection_once_with_the_json_tool(
route: str, stream: bool, marker_hex: str
) -> None:
with wire_server(_bedrock_without_native_output) as wire:
response: Final = litellm.completion(**_request(route, stream, wire, marker_hex))
content: Final = (
"".join(_delta_content(chunk) for chunk in response)
if isinstance(response, CustomStreamWrapper)
else _message_content(response)
)
assert json.loads(content) == {"city": marker_hex}
_assert_one_retry_without_native_output(wire, route)
@_ROUTES
async def test_async_call_retries_a_native_output_rejection_once_with_the_json_tool(
route: str, stream: bool, marker_hex: str
) -> None:
with wire_server(_bedrock_without_native_output) as wire:
response: Final = await litellm.acompletion(**_request(route, stream, wire, marker_hex))
content: Final = (
"".join([_delta_content(chunk) async for chunk in response])
if isinstance(response, CustomStreamWrapper)
else _message_content(response)
)
assert json.loads(content) == {"city": marker_hex}
_assert_one_retry_without_native_output(wire, route)
_CONSTRAINED_SCHEMA: Final = {
"type": "object",
"properties": {
"city": {"type": "string", "maxLength": 20},
"population": {"type": "integer", "minimum": 0},
"districts": {"type": "array", "items": {"type": "string"}, "minItems": 2, "uniqueItems": True},
"code": {"oneOf": [{"type": "string"}, {"type": "integer"}]},
},
"required": ["city", "population", "districts", "code"],
"additionalProperties": False,
}
def _response_format(schema: Mapping[str, object]) -> Mapping[str, object]:
return {"type": "json_schema", "json_schema": {"name": "city", "schema": schema}}
def _consume(response: ModelResponse | CustomStreamWrapper) -> None:
if isinstance(response, CustomStreamWrapper):
tuple(response)
def _sent_bodies(
route: str,
stream: bool,
marker_hex: str,
request_params: Mapping[str, object],
model_info: Mapping[str, object] | None,
) -> tuple[Mapping[str, JsonValue], ...]:
with wire_server(_bedrock_without_native_output) as wire:
request: Final = {
**_request(route, stream, wire, marker_hex),
**{"thinking": None, "response_format": None},
**request_params,
**({} if model_info is None else {"model_info": model_info}),
}
_consume(litellm.completion(**{key: value for key, value in request.items() if value is not None}))
return tuple(_JSON.validate_json(sent.body) for sent in wire.drain())
@_ROUTES
@pytest.mark.parametrize(
"request_params",
[
{"reasoning_effort": "low", "response_format": _response_format(_SCHEMA)},
{"thinking": {"type": "adaptive"}, "response_format": _response_format(_CONSTRAINED_SCHEMA)},
{
"thinking": {"type": "adaptive"},
"response_format": _response_format(_SCHEMA),
"output_config": {"format": {"type": "json_schema", "schema": _SCHEMA}},
},
{
"thinking": {"type": "adaptive"},
"output_config": {"effort": "low"},
"response_format": _response_format(_SCHEMA),
},
{
"thinking": {"type": "adaptive"},
"response_format": _response_format(_SCHEMA),
"extra_headers": {"anthropic-beta": "context-1m-2025-08-07"},
},
],
ids=[
"reasoning_effort",
"constrained_schema",
"response_format_and_output_config_format",
"adaptive_thinking_with_effort",
"caller_beta_header",
],
)
def test_the_retry_sends_what_a_deployment_without_native_output_sends(
route: str, stream: bool, marker_hex: str, request_params: Mapping[str, object]
) -> None:
rejected, retried = _sent_bodies(route, stream, marker_hex, request_params, None)
(opted_out,) = _sent_bodies(
route, stream, marker_hex, request_params, {"supports_bedrock_native_structured_output": False}
)
assert _native(rejected, route == "invoke"), rejected
assert retried == opted_out
@_ROUTES
def test_the_retry_keeps_the_callers_continuation_message(route: str, stream: bool, marker_hex: str) -> None:
request_params: Final = {
"thinking": {"type": "adaptive"},
"response_format": _response_format(_SCHEMA),
"messages": [
{"role": "user", "content": f"Name a city. probe {marker_hex}"},
{"role": "assistant", "content": "Which country should it be in?"},
],
"user_continue_message": {"role": "user", "content": "Any country in South America"},
}
rejected, retried = _sent_bodies(route, stream, marker_hex, request_params, None)
(opted_out,) = _sent_bodies(
route, stream, marker_hex, request_params, {"supports_bedrock_native_structured_output": False}
)
assert _native(rejected, route == "invoke"), rejected
assert retried == opted_out
_MAX_LENGTH_ONE_OF_SCHEMA: Final = {
"type": "object",
"properties": {
"city": {"type": "string", "maxLength": 20},
"code": {"oneOf": [{"type": "string"}, {"type": "integer"}]},
},
"required": ["city", "code"],
"additionalProperties": False,
}
def _messages_bedrock_without_native_output(request: Request) -> Reply:
if not _is_invoke(request) or _native(_JSON.validate_json(request.body), invoke=True):
return _bedrock_without_native_output(request)
message: Final = {
"id": "msg_scripted",
"type": "message",
"role": "assistant",
"model": "claude-scripted",
"content": [{"type": "text", "text": '{"city": "Paris"}'}],
"stop_reason": "end_turn",
"stop_sequence": None,
"usage": {"input_tokens": 11, "output_tokens": 7},
}
return Reply(body=json.dumps(message).encode())
async def _messages_bodies(
route: str, marker_hex: str, model_info: Mapping[str, object] | None
) -> tuple[Mapping[str, JsonValue], ...]:
with wire_server(_messages_bedrock_without_native_output) as wire:
await litellm.anthropic_messages(
model=f"bedrock/{route}/{_OPUS_4_8}",
messages=[{"role": "user", "content": f"Name a city. probe {marker_hex}"}],
api_base=wire.url,
api_key="synthetic-bedrock-bearer",
aws_region_name="us-east-1",
max_tokens=2048,
thinking={"type": "adaptive"},
output_config={"effort": "low", "format": {"type": "json_schema", "schema": _MAX_LENGTH_ONE_OF_SCHEMA}},
**({} if model_info is None else {"model_info": model_info}),
)
return tuple(_JSON.validate_json(sent.body) for sent in wire.drain())
@pytest.mark.parametrize("route", ["converse", "invoke"])
async def test_a_messages_retry_sends_what_a_deployment_without_native_output_sends(
route: str, marker_hex: str
) -> None:
rejected, retried = await _messages_bodies(route, marker_hex, None)
(opted_out,) = await _messages_bodies(route, marker_hex, {"supports_bedrock_native_structured_output": False})
assert _native(rejected, invoke=route == "invoke"), rejected
assert retried == opted_out
@_ROUTES
def test_the_retry_signs_with_the_credentials_of_a_deprecated_aws_bedrock_client(
route: str, stream: bool, marker_hex: str
) -> None:
aws_bedrock_client: Final = boto3.client(
"bedrock-runtime",
region_name="us-west-2",
aws_access_key_id="AKIASYNTHETICCLIENT",
aws_secret_access_key="synthetic-client-secret",
aws_session_token="synthetic-client-session",
)
with wire_server(_bedrock_without_native_output) as wire:
request: Final = {
key: value
for key, value in _request(route, stream, wire, marker_hex).items()
if key not in {"api_key", "aws_region_name"}
}
_consume(litellm.completion(**request, aws_bedrock_client=aws_bedrock_client))
sent: Final = wire.drain()
assert len(sent) == 2
for attempt in sent:
headers = {name.lower(): value for name, value in attempt.headers.items()}
assert "Credential=AKIASYNTHETICCLIENT/" in headers["authorization"]
assert "/us-west-2/bedrock/aws4_request" in headers["authorization"]
assert headers["x-amz-security-token"] == "synthetic-client-session"

View file

@ -0,0 +1,139 @@
import json
import re
import uuid
from collections.abc import Callable, Iterator, Mapping
from typing import Final
import pytest
from integration._support.wire import Reply, Request, Wire, wire_server
from pydantic import JsonValue, TypeAdapter
import litellm
from litellm import ModelResponse, Router
from litellm.utils import _invalidate_model_cost_lowercase_map # pyright: ignore[reportPrivateUsage] # required cache invalidation
_OPUS_4_8: Final = "us.anthropic.claude-opus-4-8"
_JSON_TOOL: Final = "json_tool_call"
_MARKER: Final = re.compile(r"probe ([0-9a-f]{32})")
_SCHEMA: Final[dict[str, JsonValue]] = {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
"additionalProperties": False,
}
_JSON: Final = TypeAdapter(dict[str, JsonValue])
_COST_MAP: Final = TypeAdapter(dict[str, dict[str, object]])
_OPTIONS: Final[dict[str, JsonValue]] = {
"max_tokens": 2048,
"thinking": {"type": "adaptive"},
"response_format": {"type": "json_schema", "json_schema": {"name": "city", "schema": _SCHEMA}},
"num_retries": 0,
}
def _native(body: Mapping[str, JsonValue], invoke: bool) -> bool:
output_config: Final = body.get("output_config" if invoke else "outputConfig")
return isinstance(output_config, dict) and ("format" if invoke else "textFormat") in output_config
def _invoke_reply(answer: dict[str, JsonValue], native: bool) -> Reply:
block: Final[dict[str, JsonValue]] = (
{"type": "text", "text": json.dumps(answer)}
if native
else {"type": "tool_use", "id": "toolu_scripted", "name": _JSON_TOOL, "input": answer}
)
message: Final[dict[str, JsonValue]] = {
"id": f"msg_{uuid.uuid4().hex}",
"type": "message",
"role": "assistant",
"model": "claude-scripted",
"content": [block],
"stop_reason": "end_turn" if native else "tool_use",
"stop_sequence": None,
"usage": {"input_tokens": 11, "output_tokens": 7},
}
return Reply(body=json.dumps(message).encode())
def _converse_reply(answer: dict[str, JsonValue], native: bool) -> Reply:
block: Final[dict[str, JsonValue]] = (
{"text": json.dumps(answer)}
if native
else {"toolUse": {"toolUseId": "tooluse_scripted", "name": _JSON_TOOL, "input": answer}}
)
converse: Final[dict[str, JsonValue]] = {
"output": {"message": {"role": "assistant", "content": [block]}},
"stopReason": "end_turn" if native else "tool_use",
"usage": {"inputTokens": 11, "outputTokens": 7, "totalTokens": 18},
"metrics": {"latencyMs": 1},
}
return Reply(body=json.dumps(converse).encode())
def _bedrock(request: Request) -> Reply:
body: Final = _JSON.validate_json(request.body)
found: Final = _MARKER.search(json.dumps(body))
assert found is not None, body
invoke: Final = "/invoke" in request.target
reply: Final[Callable[[dict[str, JsonValue], bool], Reply]] = _invoke_reply if invoke else _converse_reply
return reply({"city": found.group(1)}, _native(body, invoke))
@pytest.fixture
def opus_4_8_opted_in_for_the_process(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
monkeypatch.setattr(litellm, "model_cost", _COST_MAP.validate_python(litellm.model_cost)) # pyright: ignore[reportUnknownMemberType] # the cost map is untyped
litellm.register_model(
{_OPUS_4_8: {"supports_bedrock_native_structured_output": True}}, persist_across_reloads=False
)
yield
monkeypatch.undo()
_invalidate_model_cost_lowercase_map()
def _model(route: str) -> str:
return f"bedrock/{route}/{_OPUS_4_8}"
def _connection(wire: Wire) -> dict[str, JsonValue]:
return {"api_base": wire.url, "api_key": "synthetic-bedrock-bearer", "aws_region_name": "us-east-1"}
def _sent_native(wire: Wire, route: str, call: Callable[[list[dict[str, str]]], object]) -> bool:
marker_hex: Final = uuid.uuid4().hex
response: Final = call([{"role": "user", "content": f"Name a city. probe {marker_hex}"}])
assert isinstance(response, ModelResponse), response
assert json.loads(str(response.choices[0].message.content)) == {"city": marker_hex}, response # pyright: ignore[reportAttributeAccessIssue] # choices also types streaming chunks
(request,) = wire.drain()
return _native(_JSON.validate_json(request.body), route == "invoke")
@pytest.mark.parametrize("route", ["converse", "invoke"])
@pytest.mark.usefixtures("opus_4_8_opted_in_for_the_process")
def test_a_register_model_native_output_opt_in_reaches_every_call_but_a_deployment_that_opts_out(route: str) -> None:
with wire_server(_bedrock) as wire:
router: Final = Router(
model_list=[
{
"model_name": "opted-out",
"litellm_params": {"model": _model(route), **_connection(wire)},
"model_info": {"supports_bedrock_native_structured_output": False},
},
{"model_name": "inherits", "litellm_params": {"model": _model(route), **_connection(wire)}},
],
num_retries=0,
)
assert _sent_native(
wire,
route,
lambda messages: router.completion(model="inherits", messages=messages, **_OPTIONS), # pyright: ignore[reportUnknownMemberType] # Router.completion leaves **kwargs untyped
)
assert not _sent_native(
wire,
route,
lambda messages: router.completion(model="opted-out", messages=messages, **_OPTIONS), # pyright: ignore[reportUnknownMemberType] # Router.completion leaves **kwargs untyped
)
assert _sent_native(
wire,
route,
lambda messages: litellm.completion(_model(route), messages, **_connection(wire), **_OPTIONS),
)

View file

@ -2,11 +2,11 @@ import asyncio
import base64
import importlib
import os
from collections.abc import Coroutine, Iterator
from collections.abc import Callable, Coroutine, Iterator
from dataclasses import dataclass, field
from pathlib import Path
from types import MappingProxyType
from typing import Final
from typing import Final, Literal, TypeAlias
import boto3
import httpx
@ -308,6 +308,22 @@ def local_model_cost_map(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
litellm.get_model_info.cache_clear()
NativeStructuredOutputKey: TypeAlias = Literal["legacy", "bedrock"] | None
@pytest.fixture
def set_native_structured_output(
local_model_cost_map: None, monkeypatch: pytest.MonkeyPatch
) -> Callable[[str, NativeStructuredOutputKey], None]:
def set_flags(model_cost_key: str, native_key: NativeStructuredOutputKey) -> None:
entry: Final = litellm.model_cost[model_cost_key]
monkeypatch.setitem(entry, "supports_native_structured_output", native_key == "legacy")
monkeypatch.setitem(entry, "supports_bedrock_native_structured_output", native_key == "bedrock")
litellm.get_model_info.cache_clear()
return set_flags
@pytest.fixture
def local_beta_headers_config(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
monkeypatch.setenv("LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS", "True")

View file

@ -4,6 +4,7 @@ import functools
import json
import os
import sys
from collections.abc import Mapping
from typing import Final, cast
import pytest
@ -29,6 +30,10 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
parse_tool_call_arguments,
responses_reasoning_items_from_thinking_blocks,
salvage_concatenated_tool_arguments,
schema_grammar_branch_count,
schema_has_open_object,
schema_has_unresolvable_ref,
schema_property_count,
split_concatenated_json_objects,
strip_encrypted_reasoning_from_messages,
strip_litellm_response_only_fields,
@ -2362,3 +2367,75 @@ def test_strip_litellm_response_only_fields_drops_nested_tool_call_provider_fiel
assert "provider_specific_fields" not in function
assert function["name"] == "get_weather"
assert function["arguments"] == '{"city": "Paris"}'
@pytest.mark.parametrize(
("schema", "is_open"),
[
({"type": "object", "properties": {"a": {"type": "string"}}}, False),
({"type": "object", "properties": {}}, False),
({"type": "object", "additionalProperties": False}, False),
({"type": "string"}, False),
({"type": "object"}, True),
({"type": ["object", "null"]}, True),
({"type": "object", "properties": {"a": {"type": "string"}}, "additionalProperties": True}, True),
({"type": "object", "additionalProperties": {"type": "integer"}}, True),
({"type": "object", "properties": {}, "patternProperties": {"^x": {"type": "string"}}}, True),
({"type": "object", "properties": {"inner": {"anyOf": [{"type": "object"}, {"type": "null"}]}}}, True),
({"type": "array", "items": {"type": "object", "additionalProperties": {"type": "string"}}}, True),
],
)
def test_schema_has_open_object_flags_any_node_that_accepts_unlisted_keys(schema, is_open):
assert schema_has_open_object(schema) is is_open
def _object_with(properties: Mapping[str, object], **defs: Mapping[str, object]) -> Mapping[str, object]:
return {"type": "object", "properties": properties, **defs}
@pytest.mark.parametrize(
("schema", "unresolvable"),
[
(_object_with({"a": {"type": "string"}}), False),
(_object_with({"a": {"$ref": "#/$defs/A"}, "b": {"$ref": "#/$defs/A"}}, **{"$defs": {"A": {"type": "string"}}}), False),
(_object_with({"a": {"$ref": "#/definitions/A"}}, definitions={"A": {"$ref": "#/definitions/B"}, "B": {"type": "integer"}}), False),
(_object_with({"a": {"$ref": "/$defs/A"}}, **{"$defs": {"A": {"items": {"$ref": "/$defs/B"}}, "B": {"type": "string"}}}), False),
(_object_with({"a": {"$ref": "#/$defs/Missing"}}, **{"$defs": {"A": {"type": "string"}}}), True),
(_object_with({"a": {"$ref": "other.json#/A"}}), True),
(_object_with({"parent": {"$ref": "#"}}), True),
(_object_with({"a": {"$ref": "#/$defs/A"}}, **{"$defs": {"A": {"type": "array", "items": {"$ref": "#/$defs/A"}}}}), True),
(_object_with({"a": {"$ref": "/$defs/A"}}, **{"$defs": {"A": {"type": "array", "items": {"$ref": "/$defs/A"}}}}), True),
(
_object_with(
{"a": {"$ref": "#/$defs/A"}},
**{"$defs": {"A": {"properties": {"b": {"$ref": "#/$defs/B"}}}, "B": {"properties": {"a": {"$ref": "#/$defs/A"}}}}},
),
True,
),
],
ids=["no-refs", "shared-def", "chained-definitions", "root-path-defs", "missing-def", "external", "root", "self-cycle", "root-path-self-cycle", "mutual-cycle"],
)
def test_schema_has_unresolvable_ref_flags_refs_that_cannot_be_inlined(schema, unresolvable):
assert schema_has_unresolvable_ref(schema) is unresolvable
@pytest.mark.parametrize(
("schema", "properties", "branches"),
[
(_object_with({"a": {"type": "string"}, "b": {"type": "string"}, "c": {"type": "string"}}, required=["a"]), 3, 8),
(
_object_with(
{"x": {"$ref": "#/$defs/Pair"}, "y": {"$ref": "#/$defs/Pair"}},
required=["x", "y"],
**{"$defs": {"Pair": _object_with({"l": {"type": "string"}, "r": {"type": "string"}}, required=["l"])}},
),
4,
7,
),
(_object_with({"inner": _object_with({"z": {"type": "string"}})}, required=["inner"]), 2, 4),
],
ids=["flat", "shared-def-counted-once", "nested"],
)
def test_schema_size_counts_every_written_object_once(schema, properties, branches):
assert schema_property_count(schema) == properties
assert schema_grammar_branch_count(schema) == branches

View file

@ -796,6 +796,80 @@ def test_anthropic_messages_pt_keeps_signed_thinking_block():
assert thinking_blocks[0]["thinking"] == "genuine anthropic reasoning"
@pytest.mark.parametrize("llm_provider", ("anthropic", "vertex_ai"))
def test_anthropic_messages_pt_converts_pdf_image_url_data_uri_to_document(llm_provider: str) -> None:
pdf_data = "JVBERi0xLjQK"
result: Final = anthropic_messages_pt(
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": "summarize"},
{"type": "image_url", "image_url": {"url": f"data:application/pdf;base64,{pdf_data}"}},
],
}
],
model="claude-opus-5-5",
llm_provider=llm_provider,
)
assert result[0]["content"][1] == {
"type": "document",
"source": {"type": "base64", "media_type": "application/pdf", "data": pdf_data},
}
@pytest.mark.parametrize("llm_provider", ("anthropic", "vertex_ai"))
def test_anthropic_messages_pt_uses_pdf_format_for_generic_mime_data_uri(llm_provider: str) -> None:
pdf_data = "JVBERi0xLjQK"
result: Final = anthropic_messages_pt(
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": "summarize"},
{
"type": "image_url",
"image_url": {
"url": f"data:application/octet-stream;base64,{pdf_data}",
"format": "application/pdf",
},
},
],
}
],
model="claude-opus-5-5",
llm_provider=llm_provider,
)
assert result[0]["content"][1] == {
"type": "document",
"source": {"type": "base64", "media_type": "application/pdf", "data": pdf_data},
}
def test_anthropic_messages_pt_keeps_png_image_url_as_image() -> None:
image_data = "iVBORw0KGgo="
result: Final = anthropic_messages_pt(
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": "describe"},
{"type": "image_url", "image_url": f"data:image/png;base64,{image_data}"},
],
}
],
model="claude-opus-5-5",
llm_provider="anthropic",
)
assert result[0]["content"][1] == {
"type": "image",
"source": {"type": "base64", "media_type": "image/png", "data": image_data},
}
EMPTY_USER_MESSAGES: Final = pytest.mark.parametrize(
"empty_user_message",
[{"role": "user", "content": None}, {"role": "user", "content": []}, {"role": "user"}],
@ -1821,6 +1895,25 @@ def test_bedrock_tools_unpack_defs():
_bedrock_tools_pt(tools=tools)
@pytest.mark.parametrize(
"parameters",
[
{"type": ["object", "null"]},
{"type": "object", "properties": {"tier": {"type": "integer"}}, "$defs": [{"type": "string"}]},
],
ids=["root_type_list", "defs_list"],
)
def test_bedrock_tools_pt_sends_malformed_parameters_as_an_object_schema(parameters: dict[str, object]) -> None:
from litellm.litellm_core_utils.prompt_templates.factory import _bedrock_tools_pt
tools: Final = [{"type": "function", "function": {"name": "json_tool_call", "parameters": parameters}}]
input_schema: Final = _bedrock_tools_pt(tools=tools)[0]["toolSpec"]["inputSchema"]["json"]
assert input_schema["type"] == "object"
assert "$defs" not in input_schema
def test_bedrock_tools_pt_strict_parameter():
"""Regression for strict tools on the Bedrock Converse path.

View file

@ -288,6 +288,37 @@ def test_convert_url_to_base64_keeps_a_real_content_type(monkeypatch):
assert result.startswith("data:image/jpeg;base64,")
@pytest.mark.parametrize(
("url", "content_type", "expected"),
[
(
"https://docs.example/document.pdf",
"application/pdf; qs=0.001",
"data:application/pdf;base64,cGRm",
),
(
"https://img.example/image.png",
"image/png; charset=binary",
"data:image/png;base64,cGRm",
),
(
"https://docs.example/document.pdf",
"application/octet-stream; charset=binary",
"data:application/pdf;base64,cGRm",
),
],
)
def test_convert_url_to_base64_uses_bare_content_type(
monkeypatch: pytest.MonkeyPatch,
url: str,
content_type: str,
expected: str,
) -> None:
monkeypatch.setattr(litellm, "module_level_client", _stub_image_client(b"pdf", content_type))
assert convert_url_to_base64(url) == expected
def test_convert_url_to_base64_raises_when_no_content_type_is_determinable(monkeypatch):
monkeypatch.setattr(
litellm,

View file

@ -1345,6 +1345,73 @@ def test_opus_uses_native_structured_output(model_name):
assert optional_params.get("json_mode") is True
@pytest.mark.parametrize(
"field_schema",
[
{"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
{"type": "object", "additionalProperties": {"type": "integer"}},
{"anyOf": [{"type": "object"}, {"type": "null"}]},
],
ids=["closed-object", "dict-field", "nullable-dict-field"],
)
def test_every_field_shape_keeps_native_output_on_a_native_structured_output_model(field_schema):
optional_params: Final = AnthropicConfig().map_openai_params(
non_default_params={
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "out",
"schema": {"type": "object", "properties": {"field": field_schema}, "required": ["field"]},
},
}
},
optional_params={},
model="claude-opus-4-8",
drop_params=False,
)
assert "output_format" in optional_params
assert "tools" not in optional_params
@pytest.mark.parametrize(
("model", "thinking_params"),
[
("claude-opus-4-8", {"thinking": {"type": "adaptive"}}),
("claude-opus-4-8", {"reasoning_effort": "low"}),
("claude-opus-4-8", {"reasoning_effort": "none"}),
("claude-sonnet-5-5", {}),
(
"claude-opus-4-8",
{"tools": [{"type": "function", "function": {"name": "lookup", "parameters": {"type": "object"}}}]},
),
],
ids=["adaptive-thinking", "reasoning-effort", "reasoning-effort-none", "model-without-forced-tools", "caller-tools"],
)
def test_dict_fields_keep_native_output_when_the_json_tool_would_not_be_forced(model, thinking_params):
optional_params: Final = AnthropicConfig().map_openai_params(
non_default_params={
**thinking_params,
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "out",
"schema": {
"type": "object",
"properties": {"field": {"type": "object", "additionalProperties": {"type": "integer"}}},
"required": ["field"],
},
},
},
},
optional_params={},
model=model,
drop_params=False,
)
assert "output_format" in optional_params
assert "json_tool_call" not in [tool["name"] for tool in optional_params.get("tools", [])]
def test_native_structured_output_uses_bundled_capability_when_remote_map_lags(
monkeypatch: pytest.MonkeyPatch,
@ -9379,17 +9446,26 @@ def test_map_openai_params_lets_a_native_anthropic_search_tool_replace_the_opena
@pytest.mark.parametrize(
("config", "model", "provider"),
("config", "model", "provider", "model_cost_key"),
[
pytest.param(AmazonAnthropicClaudeConfig(), "us.anthropic.claude-sonnet-5-5", "bedrock", id="bedrock-invoke"),
pytest.param(VertexAIAnthropicConfig(), "claude-sonnet-5-5", "vertex_ai", id="vertex_ai"),
pytest.param(
AmazonAnthropicClaudeConfig(),
"us.anthropic.claude-sonnet-5-5",
"bedrock",
"us.anthropic.claude-sonnet-5-5",
id="bedrock-invoke",
),
pytest.param(
VertexAIAnthropicConfig(), "claude-sonnet-5-5", "vertex_ai", "vertex_ai/claude-sonnet-5-5", id="vertex_ai"
),
],
)
def test_map_openai_params_judges_tool_search_on_the_real_model_when_tool_based_response_format_swaps_it(
config, model, provider
set_native_structured_output, config, model, provider, model_cost_key
):
from litellm.responses.litellm_completion_transformation.tool_search import anthropic_tool_search_chat_tool
set_native_structured_output(model_cost_key, None)
assert litellm.supports_tool_search(model, provider)
mapped: Final = config.map_openai_params(
non_default_params={
@ -9553,6 +9629,17 @@ def test_json_mode_tool_use_turn_adds_no_stop_fields_to_the_message():
assert not message.provider_specific_fields
def test_json_mode_keeps_the_thinking_that_preceded_the_json_tool_call():
thinking_block: Final = {"type": "thinking", "thinking": "The user wants a greeting.", "signature": "sig-1"}
body: Final = _json_mode_message_body("tool_use", None)
result: Final = _transform_json_mode_body({**body, "content": [thinking_block, *body["content"]]})
message: Final = result.choices[0].message
assert json.loads(message.content) == {"answer": "Hello"}
assert message.reasoning_content == thinking_block["thinking"]
assert [block["signature"] for block in message.thinking_blocks] == [thinking_block["signature"]]
def test_non_dict_upstream_body_still_transforms_without_refusal_fields() -> None:
result = AnthropicConfig().transform_parsed_response(
completion_response=MagicMock(),

View file

@ -12,6 +12,18 @@ from litellm.llms.anthropic.chat.transformation import AnthropicConfig
class TestOutputFormatArrayObjectConstraints:
def test_closes_an_object_whose_type_is_a_union_with_null(self):
schema = {
"type": "object",
"properties": {
"address": {"type": ["object", "null"], "properties": {"city": {"type": "string"}}},
},
}
result = AnthropicConfig.filter_anthropic_output_schema(schema)
assert result["properties"]["address"]["additionalProperties"] is False
def test_removes_uniqueitems(self):
schema = {
"type": "object",
@ -129,7 +141,7 @@ class TestOutputFormatArrayObjectConstraints:
schema = {
"type": "array",
"items": {"type": "string"},
"minItems": 1,
"minItems": 2,
"maxItems": 10,
"uniqueItems": True,
"minContains": 2,
@ -139,7 +151,7 @@ class TestOutputFormatArrayObjectConstraints:
result = AnthropicConfig.filter_anthropic_output_schema(schema)
assert result["description"] == (
"Note: minimum number of items: 1, maximum number of items: 10, "
"Note: minimum number of items: 2, maximum number of items: 10, "
"all array items must be unique, minimum number of matching items: 2, "
"maximum number of matching items: 3."
)

View file

@ -69,7 +69,7 @@ class TestFilterAnthropicOutputSchema:
schema = {
"type": "array",
"items": {"type": "string"},
"minItems": 1,
"minItems": 2,
"maxItems": 10,
}
@ -80,9 +80,27 @@ class TestFilterAnthropicOutputSchema:
assert result["type"] == "array"
assert result["items"] == {"type": "string"}
# Description should contain constraint info
assert "minimum number of items: 1" in result["description"]
assert "minimum number of items: 2" in result["description"]
assert "maximum number of items: 10" in result["description"]
def test_keeps_min_items_of_zero_or_one(self):
for min_items in (0, 1):
schema = {"type": "array", "items": {"type": "string"}, "minItems": min_items}
result = AnthropicConfig.filter_anthropic_output_schema(schema)
assert result["minItems"] == min_items
assert "description" not in result
def test_moves_a_min_items_that_is_not_an_integer_into_the_description(self):
for min_items in (True, 1.0):
schema = {"type": "array", "items": {"type": "string"}, "minItems": min_items}
result = AnthropicConfig.filter_anthropic_output_schema(schema)
assert "minItems" not in result
assert "minimum number of items" in result["description"]
def test_handles_nested_schemas(self):
"""Test that nested schemas are also filtered."""
schema = {
@ -100,7 +118,7 @@ class TestFilterAnthropicOutputSchema:
}
},
},
"minItems": 1,
"minItems": 2,
}
},
}
@ -336,7 +354,7 @@ class TestFilterAnthropicOutputSchema:
schema = {
"type": "array",
"items": {"type": "string"},
"minItems": 1,
"minItems": 2,
"maxItems": 10,
"uniqueItems": True,
"minContains": 2,
@ -346,7 +364,7 @@ class TestFilterAnthropicOutputSchema:
result = AnthropicConfig.filter_anthropic_output_schema(schema)
assert result["description"] == (
"Note: minimum number of items: 1, maximum number of items: 10, "
"Note: minimum number of items: 2, maximum number of items: 10, "
"all array items must be unique, minimum number of matching items: 2, "
"maximum number of matching items: 3."
)

View file

@ -3,11 +3,13 @@ import copy
import json
import uuid
from types import SimpleNamespace
from collections.abc import Mapping
from typing import Final
from unittest.mock import MagicMock, patch
import httpx
import pytest
from pydantic import BaseModel, ConfigDict
# Ensure the project root is on the import path so `litellm` can be imported when
# tests are executed from any working directory.
@ -482,7 +484,7 @@ def test_output_config_format_converted_for_bedrock_chat_invoke_request():
with patch( # test-quality-ok: pin non-native path
"litellm.llms.bedrock.common_utils._bedrock_model_supports",
side_effect=lambda _model, key: key == "supports_output_config",
side_effect=lambda _model, key, *_: key == "supports_output_config",
):
result = config.transform_request(
model="anthropic.claude-opus-4-7",
@ -504,10 +506,12 @@ def test_output_config_format_converted_for_bedrock_chat_invoke_request():
@pytest.mark.parametrize("model", ["anthropic.claude-opus-4-7", "us.anthropic.claude-opus-4-8"])
def test_output_config_format_inlined_for_bedrock_chat_invoke_opus_4_7_and_4_8(local_model_cost_map, model):
"""Bedrock rejects ``output_config.format`` on Claude Opus 4.7 and 4.8, so the
Invoke chat path inlines the schema into the last user message and keeps effort,
driven by the cost map alone (no capability stub)."""
def test_output_config_format_inlined_for_bedrock_chat_invoke_profile_without_native_output(
set_native_structured_output, model
):
"""On a profile without native structured output the Invoke chat path inlines the
schema into the last user message and keeps effort."""
set_native_structured_output(model, None)
schema = {"type": "object", "properties": {"answer": {"type": "string"}}}
result = AmazonAnthropicClaudeConfig().transform_request(
@ -525,12 +529,240 @@ def test_output_config_format_inlined_for_bedrock_chat_invoke_opus_4_7_and_4_8(l
assert json.loads(result["messages"][-1]["content"][-1]["text"]) == schema
TREE_SCHEMA: Final = {
"type": "object",
"properties": {"root": {"$ref": "#/$defs/Node"}},
"$defs": {
"Node": {
"type": "object",
"properties": {"children": {"type": "array", "items": {"$ref": "#/$defs/Node"}}},
}
},
}
OPEN_OBJECT_SCHEMA: Final = {
"type": "object",
"properties": {"counts": {"type": "object", "additionalProperties": {"type": "integer"}}},
}
def _invoke_params_for_schema(
model: str, schema: Mapping[str, object], thinking_params: Mapping[str, object] | None = None
) -> Mapping[str, object]:
return AmazonAnthropicClaudeConfig().map_openai_params(
non_default_params={
"response_format": {"type": "json_schema", "json_schema": {"name": "out", "schema": schema}},
**({"thinking": {"type": "adaptive"}} if thinking_params is None else thinking_params),
},
optional_params={},
model=model,
drop_params=False,
)
@pytest.mark.parametrize(
("native_key", "thinking_params", "native"),
[
("legacy", {}, True),
("bedrock", {}, False),
("bedrock", {"thinking": {"type": "adaptive"}}, True),
("bedrock", {"reasoning_effort": "low"}, True),
],
ids=["legacy-key", "bedrock-key-no-thinking", "bedrock-key-adaptive", "bedrock-key-effort"],
)
def test_bedrock_native_output_key_applies_only_when_thinking_is_requested(
set_native_structured_output, native_key, thinking_params, native
):
model = "us.anthropic.claude-haiku-5-5"
set_native_structured_output(model, native_key)
schema = {"type": "object", "properties": {"answer": {"type": "string"}}, "required": ["answer"]}
result = _invoke_params_for_schema(model, schema, thinking_params)
assert ("output_format" in result) is native
assert ("tools" in result) is not native
@pytest.mark.parametrize("schema", [TREE_SCHEMA, OPEN_OBJECT_SCHEMA], ids=["recursive", "open-object"])
def test_schema_native_output_cannot_hold_keeps_the_tool_path_on_an_opt_in_model(set_native_structured_output, schema):
model = "us.anthropic.claude-opus-4-8"
set_native_structured_output(model, "bedrock")
result = _invoke_params_for_schema(model, schema)
assert "output_format" not in result
assert [tool["name"] for tool in result["tools"]] == ["json_tool_call"]
LOOKUP_TOOL: Final = {
"type": "function",
"function": {"name": "lookup", "parameters": {"type": "object", "properties": {"id": {"type": "string"}}}},
}
ARRAY_ROOT_SCHEMA: Final = {"type": "array", "items": {"type": "object", "additionalProperties": {"type": "string"}}}
TYPED_ALL_OF_ROOT_SCHEMA: Final = {
"type": "object",
"allOf": [
{"type": "object", "properties": {"a": {"type": "string"}}, "required": ["a"]},
{"type": "object", "properties": {"b": {"type": "string"}}, "required": ["b"]},
],
}
DEFINITIONS_REF_SCHEMA: Final = {
"type": "object",
"properties": {"home": {"$ref": "#/definitions/Address"}},
"required": ["home"],
"definitions": {"Address": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}},
}
@pytest.mark.parametrize(
"schema",
[ARRAY_ROOT_SCHEMA, TYPED_ALL_OF_ROOT_SCHEMA, OPEN_OBJECT_SCHEMA, TREE_SCHEMA, DEFINITIONS_REF_SCHEMA],
ids=["array-root", "typed-all-of-root", "open-object", "recursive", "definitions-ref"],
)
@pytest.mark.parametrize(
"request_params",
[
{},
{"thinking": {"type": "adaptive"}},
{"reasoning_effort": "low"},
{"tool_choice": "none"},
{"tool_choice": {"type": "function", "function": {"name": "lookup"}}},
],
ids=["plain", "thinking", "reasoning-effort", "tool-choice-none", "named-tool-choice-without-tools"],
)
def test_model_native_on_every_request_sends_every_schema_natively_without_the_json_tool(
set_native_structured_output, schema, request_params
):
model: Final = "us.anthropic.claude-opus-4-8"
set_native_structured_output(model, "legacy")
result: Final = _invoke_params_for_schema(model, schema, request_params)
assert result["output_format"]["type"] == "json_schema"
assert "json_tool_call" not in [tool["name"] for tool in result.get("tools", [])]
def test_definitions_ref_reaches_a_model_native_on_every_request_inlined(set_native_structured_output):
model: Final = "us.anthropic.claude-opus-4-8"
set_native_structured_output(model, "legacy")
result: Final = _invoke_params_for_schema(model, DEFINITIONS_REF_SCHEMA, {})
sent: Final = result["output_format"]["schema"]
assert sent["properties"]["home"]["properties"]["city"] == {"type": "string"}
assert "$ref" not in json.dumps(sent)
@pytest.mark.parametrize("thinking_params", [{}, {"thinking": {"type": "adaptive"}}], ids=["no-thinking", "thinking"])
def test_open_object_schema_stays_native_beside_the_callers_tools_on_a_model_native_on_every_request(
set_native_structured_output, thinking_params
):
model: Final = "us.anthropic.claude-opus-4-8"
set_native_structured_output(model, "legacy")
result: Final = _invoke_params_for_schema(model, OPEN_OBJECT_SCHEMA, {**thinking_params, "tools": [LOOKUP_TOOL]})
assert "tool_choice" not in result
assert [tool["name"] for tool in result["tools"]] == ["lookup"]
assert result["output_format"]["schema"]["properties"]["counts"]["additionalProperties"] is False
def test_open_object_schema_keeps_the_json_tool_beside_the_callers_tools_on_an_opt_in_model(
set_native_structured_output,
):
model: Final = "us.anthropic.claude-opus-4-8"
set_native_structured_output(model, "bedrock")
result: Final = _invoke_params_for_schema(
model, OPEN_OBJECT_SCHEMA, {"thinking": {"type": "adaptive"}, "tools": [LOOKUP_TOOL]}
)
assert "output_format" not in result
assert sorted(tool["name"] for tool in result["tools"]) == ["json_tool_call", "lookup"]
@pytest.mark.parametrize("native_key", ["legacy", "bedrock"])
def test_closed_schema_goes_native_on_either_native_key(set_native_structured_output, native_key):
model = "us.anthropic.claude-opus-4-8"
set_native_structured_output(model, native_key)
schema = {"type": "object", "properties": {"age": {"type": "integer", "minimum": 0}}, "required": ["age"]}
result = _invoke_params_for_schema(model, schema)
assert result["output_format"]["schema"]["properties"]["age"]["type"] == "integer"
assert "minimum" not in result["output_format"]["schema"]["properties"]["age"]
assert "tools" not in result
def test_shared_defs_reach_native_output_once_instead_of_inlined_per_use_on_an_opt_in_model(
set_native_structured_output,
):
model = "us.anthropic.claude-opus-4-8"
set_native_structured_output(model, "bedrock")
party_ref = {"$ref": "#/$defs/Party"}
schema = {
"type": "object",
"properties": {"buyer": party_ref, "seller": party_ref},
"required": ["buyer", "seller"],
"$defs": {
"Party": {
"type": "object",
"properties": {"email": {"anyOf": [{"type": "string"}, {"type": "null"}]}},
"required": ["email"],
}
},
}
result = _invoke_params_for_schema(model, schema)
sent = result["output_format"]["schema"]
assert sent["properties"]["buyer"] == party_ref
assert json.dumps(sent).count('"anyOf"') == 1
assert "tools" not in result
class Relabel(BaseModel):
model_config = ConfigDict(frozen=True)
box: str
new_label: str
class PuzzleAnswer(BaseModel):
model_config = ConfigDict(frozen=True)
relabels: list[Relabel]
def test_nested_pydantic_response_format_reaches_native_output_with_refs_that_resolve_on_an_opt_in_model(
set_native_structured_output,
):
model = "us.anthropic.claude-haiku-5-5"
set_native_structured_output(model, "bedrock")
config: Final = AmazonAnthropicClaudeConfig()
result: Final = config.map_openai_params(
non_default_params={
"response_format": config.get_json_schema_from_pydantic_object(PuzzleAnswer),
"thinking": {"type": "adaptive"},
},
optional_params={},
model=model,
drop_params=False,
)
sent: Final = result["output_format"]["schema"]
item_ref: Final = sent["properties"]["relabels"]["items"]["$ref"]
assert item_ref.removeprefix("#/$defs/") in sent["$defs"]
assert "tools" not in result
def test_output_config_format_forwarded_for_bedrock_chat_invoke_request(local_model_cost_map):
"""Bedrock Invoke chat path forwards ``output_config.format`` alongside effort
for models with native structured-output support (Claude Sonnet 4.6)."""
schema_format = {
"type": "json_schema",
"schema": {"type": "object", "properties": {"answer": {"type": "string"}}},
"schema": {"type": "object", "properties": {"answer": {"type": "string"}}, "additionalProperties": False},
}
result = AmazonAnthropicClaudeConfig().transform_request(
@ -671,7 +903,7 @@ def test_bedrock_chat_invoke_drop_params_keeps_native_output_config_format(local
monkeypatch.setattr(litellm, "drop_params", True)
schema_format = {
"type": "json_schema",
"schema": {"type": "object", "properties": {"zebra_count": {"type": "integer"}}},
"schema": {"type": "object", "properties": {"zebra_count": {"type": "integer"}}, "additionalProperties": False},
}
result = AmazonAnthropicClaudeConfig().transform_request(
@ -713,10 +945,11 @@ def test_bedrock_chat_invoke_drop_params_still_inlines_for_non_native(local_mode
"model",
["us.anthropic.claude-fable-5-1", "anthropic.claude-fable-5-1"],
)
def test_bedrock_chat_invoke_fable_5_1_response_format_avoids_forced_tool_choice(local_model_cost_map, model):
"""Regression: Bedrock rejects both native ``output_config.format`` and forced
tool_choice for Fable 5.1, so invoke must use the tool-based path without a
def test_bedrock_chat_invoke_fable_5_1_response_format_avoids_forced_tool_choice(set_native_structured_output, model):
"""Regression: Bedrock rejects forced tool_choice for Fable 5.1, so on a profile
without native structured output invoke must use the tool-based path without a
forced ``tool_choice``."""
set_native_structured_output(model, None)
result = AmazonAnthropicClaudeConfig().map_openai_params(
non_default_params={
"response_format": {
@ -738,7 +971,10 @@ def test_bedrock_chat_invoke_fable_5_1_response_format_avoids_forced_tool_choice
@pytest.mark.parametrize("model", ["us.anthropic.claude-sonnet-5", "us.anthropic.claude-fable-5-1"])
def test_bedrock_chat_invoke_tool_based_response_format_still_upgrades_legacy_thinking(local_model_cost_map, model):
def test_bedrock_chat_invoke_tool_based_response_format_still_upgrades_legacy_thinking(
set_native_structured_output, model
):
set_native_structured_output(model, None)
result = AmazonAnthropicClaudeConfig().map_openai_params(
non_default_params={
"response_format": {
@ -761,9 +997,10 @@ def test_bedrock_chat_invoke_tool_based_response_format_still_upgrades_legacy_th
assert result["output_config"] == {"effort": "high"}
def test_bedrock_chat_invoke_adaptive_thinking_keeps_the_forced_response_format_tool(local_model_cost_map):
def test_bedrock_chat_invoke_adaptive_thinking_keeps_the_forced_response_format_tool(set_native_structured_output):
"""Anthropic accepts a forced tool_choice next to adaptive thinking, and dropping
it lets the model answer in prose instead of the requested JSON."""
set_native_structured_output("us.anthropic.claude-opus-5", None)
result = AmazonAnthropicClaudeConfig().map_openai_params(
non_default_params={
"response_format": {
@ -1767,3 +2004,63 @@ def test_refusal_stop_details_kept_through_bedrock_invoke_transform():
provider_fields: Final = result.choices[0].message.provider_specific_fields
assert provider_fields["stop_reason"] == "refusal"
assert provider_fields["stop_details"] == ANTHROPIC_DOCS_REFUSAL_MESSAGE["stop_details"]
_CITY_SCHEMA: Final = {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
"additionalProperties": False,
}
_CITY_RESPONSE_FORMAT: Final = {"type": "json_schema", "json_schema": {"name": "city", "schema": _CITY_SCHEMA}}
def _invoke_chat_params(
model: str, model_info: Mapping[str, object] | None, non_default_params: Mapping[str, object]
) -> Mapping[str, object]:
return AmazonAnthropicClaudeConfig(model_info=model_info).map_openai_params(
non_default_params=dict(non_default_params), optional_params={}, model=model, drop_params=False
)
@pytest.mark.parametrize(
"thinking_params",
[{"thinking": {"type": "adaptive"}}, {"reasoning_effort": "low"}],
ids=["adaptive", "reasoning_effort"],
)
@pytest.mark.parametrize("model", ["us.anthropic.claude-opus-4-8", "global.anthropic.claude-sonnet-5"])
def test_invoke_uses_opt_in_native_output_when_the_opt_in_drove_native_output(
local_model_cost_map, model, thinking_params
):
optional_params: Final = _invoke_chat_params(
model, None, {"response_format": _CITY_RESPONSE_FORMAT, **thinking_params}
)
assert AmazonAnthropicClaudeConfig().uses_opt_in_native_output(model, optional_params, {})
@pytest.mark.parametrize(
("model", "model_info", "request_params"),
[
("us.anthropic.claude-opus-4-8", None, {"response_format": _CITY_RESPONSE_FORMAT}),
(
"us.anthropic.claude-opus-4-8",
{"supports_bedrock_native_structured_output": False},
{"response_format": _CITY_RESPONSE_FORMAT, "thinking": {"type": "adaptive"}},
),
(
"us.anthropic.claude-sonnet-4-6",
None,
{"response_format": _CITY_RESPONSE_FORMAT, "thinking": {"type": "adaptive"}},
),
],
ids=["no_thinking", "deployment_opt_out", "native_model"],
)
def test_invoke_uses_opt_in_native_output_is_false_when_native_output_did_not_come_from_the_opt_in(
local_model_cost_map, model, model_info, request_params
):
optional_params: Final = _invoke_chat_params(model, model_info, request_params)
assert not AmazonAnthropicClaudeConfig().uses_opt_in_native_output(
model, optional_params, {"model_info": model_info}
)

View file

@ -345,3 +345,33 @@ def test_converse_profile_override_records_cost_without_changing_response_model(
CONVERSE_RESPONSE["usage"]["inputTokens"] * prices["input_cost_per_token"]
+ CONVERSE_RESPONSE["usage"]["outputTokens"] * prices["output_cost_per_token"]
)
_INVALID_REQUEST_BODY: Final = b'{"message":"The request is invalid."}'
@pytest.mark.parametrize(
("status_code", "error_class"),
[(400, litellm.BadRequestError), (429, litellm.RateLimitError), (503, litellm.ServiceUnavailableError)],
)
def test_sync_converse_stream_error_keeps_the_status_class_and_raw_body(
status_code: int, error_class: type[Exception]
) -> None:
def respond(request: httpx.Request) -> httpx.Response:
return httpx.Response(status_code, content=_INVALID_REQUEST_BODY)
with httpx.Client(transport=httpx.MockTransport(respond)) as transport:
with pytest.raises(error_class) as raised:
litellm.completion(
model="bedrock/converse/us.anthropic.claude-sonnet-4-5-20250929-v1:0",
messages=[{"role": "user", "content": "hi"}],
stream=True,
num_retries=0,
aws_access_key_id="test-key",
aws_secret_access_key="test-secret",
aws_region_name="us-east-1",
client=HTTPHandler(client=transport),
)
assert raised.value.status_code == status_code
assert repr(_INVALID_REQUEST_BODY) in raised.value.message

View file

@ -12,12 +12,18 @@ import pytest
import respx
import litellm
from litellm import ModelResponse, completion
from litellm import ModelResponse, Router, completion
from litellm.litellm_core_utils.prompt_templates.factory import (
_bedrock_converse_messages_pt,
_bedrock_tools_pt,
)
from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import CONVERTED_SYSTEM_NOTE
from litellm.llms.anthropic.chat.transformation import (
NATIVE_OUTPUT_MAX_GRAMMAR_BRANCHES,
NATIVE_OUTPUT_MAX_OPTIONAL_PARAMETERS,
NATIVE_OUTPUT_MAX_UNION_PARAMETERS,
)
from litellm.llms.bedrock.common_utils import BEDROCK_NATIVE_OUTPUT_MAX_PROPERTIES
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
from litellm.llms.bedrock.chat.converse_transformation import AmazonConverseConfig
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
@ -1246,7 +1252,9 @@ async def test_transform_request_replays_redacted_thinking_only_to_claude(
"""A model id that names its model decides on its own. An application inference profile ARN hides its model,
so the deployment's ``base_model`` is what marks it as Claude, and without one the redacted block is dropped
the way it is for every non-Claude target."""
redacted_data: Final = base64.b64encode(b"EmwKAhgBEgy3va3pzix/LafPsn4aDFIT2Xlxh0L5L8rLVyIwxtE3rAFBa8cr3qpP").decode()
redacted_data: Final = base64.b64encode(
b"EmwKAhgBEgy3va3pzix/LafPsn4aDFIT2Xlxh0L5L8rLVyIwxtE3rAFBa8cr3qpP"
).decode()
messages: Final = [
{"role": "user", "content": "hi"},
{
@ -1475,32 +1483,25 @@ def test_output_config_format_dropped_on_unsupported_converse_model_warns(caplog
"""When Converse model lacks native structured-output support, the silently
dropped ``output_config.format`` must surface as a warning so callers can
diagnose plain-text responses."""
from unittest.mock import patch
config = AmazonConverseConfig()
schema = {
"type": "object",
"properties": {"answer": {"type": "string"}},
}
with patch.object(
AmazonConverseConfig,
"_supports_native_structured_outputs",
return_value=False,
):
with caplog.at_level("WARNING"):
result = config._transform_request(
model="bedrock/converse/us.anthropic.claude-3-haiku-20240307-v1:0",
messages=[{"role": "user", "content": "hi"}],
optional_params={
"maxTokens": 256,
"output_config": {
"format": {"type": "json_schema", "schema": schema},
},
with caplog.at_level("WARNING"):
result = config._transform_request(
model="bedrock/converse/us.anthropic.claude-3-haiku-20240307-v1:0",
messages=[{"role": "user", "content": "hi"}],
optional_params={
"maxTokens": 256,
"output_config": {
"format": {"type": "json_schema", "schema": schema},
},
litellm_params={},
headers={},
)
},
litellm_params={},
headers={},
)
assert "outputConfig" not in result
assert any("dropping `output_config.format`" in record.getMessage() for record in caplog.records)
@ -4841,24 +4842,6 @@ def test_translate_response_format_native_output_config(monkeypatch):
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", old_env)
BEDROCK_OPUS_4_7_AND_4_8_MODELS: Final = (
"anthropic.claude-opus-4-7",
"global.anthropic.claude-opus-4-7",
"us.anthropic.claude-opus-4-7",
"eu.anthropic.claude-opus-4-7",
"au.anthropic.claude-opus-4-7",
"jp.anthropic.claude-opus-4-7",
"anthropic.claude-opus-4-8",
"global.anthropic.claude-opus-4-8",
"us.anthropic.claude-opus-4-8",
"eu.anthropic.claude-opus-4-8",
"au.anthropic.claude-opus-4-8",
"jp.anthropic.claude-opus-4-8",
"us-gov.anthropic.claude-opus-4-8",
"us-gov-west-1/anthropic.claude-opus-4-8",
"us-gov-east-1/anthropic.claude-opus-4-8",
)
CAPITAL_RESPONSE_FORMAT: Final = {
"type": "json_schema",
"json_schema": {
@ -4873,10 +4856,15 @@ CAPITAL_RESPONSE_FORMAT: Final = {
}
def _converse_request_for_json_schema(model: str, stream: bool) -> tuple[dict, dict]:
ADAPTIVE_THINKING: Final = {"type": "adaptive"}
def _converse_request_for_json_schema(
model: str, stream: bool, thinking_params: Mapping[str, object] | None = None
) -> tuple[Mapping[str, object], RequestObject]:
config = AmazonConverseConfig()
optional_params = config.map_openai_params(
non_default_params={"response_format": CAPITAL_RESPONSE_FORMAT, "stream": stream},
non_default_params={"response_format": CAPITAL_RESPONSE_FORMAT, "stream": stream, **(thinking_params or {})},
optional_params={},
model=model,
drop_params=False,
@ -4891,21 +4879,548 @@ def _converse_request_for_json_schema(model: str, stream: bool) -> tuple[dict, d
return optional_params, request
@pytest.mark.parametrize("model", BEDROCK_OPUS_4_7_AND_4_8_MODELS)
@pytest.mark.parametrize(
("native_profile", "tool_profile"),
[
("us.anthropic.claude-opus-4-8", "anthropic.claude-opus-4-8"),
("global.anthropic.claude-opus-4-7", "us.anthropic.claude-opus-4-7"),
("anthropic.claude-opus-4-8", "us-gov.anthropic.claude-opus-4-8"),
],
)
@pytest.mark.parametrize("stream", [False, True])
def test_opus_4_7_and_4_8_json_schema_sent_as_forced_tool_not_output_config(monkeypatch, model, stream):
"""Regression for issue #27846: Bedrock rejects outputConfig on Opus 4.7 and 4.8
(``output_config.format: Extra inputs are not permitted``), so json_schema has to
go out as the forced json_tool_call tool, streamed through fake_stream."""
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url=""))
def test_json_schema_shape_follows_each_inference_profile_own_native_output_flag(
set_native_structured_output, native_profile, tool_profile, stream
):
set_native_structured_output(native_profile, "bedrock")
set_native_structured_output(tool_profile, None)
optional_params, request = _converse_request_for_json_schema(model=model, stream=stream)
thinking_params: Final = {"thinking": ADAPTIVE_THINKING}
native_params, native_request = _converse_request_for_json_schema(native_profile, stream, thinking_params)
tool_params, tool_request = _converse_request_for_json_schema(tool_profile, stream, thinking_params)
assert native_request["outputConfig"]["textFormat"]["structure"]["jsonSchema"]["name"] == "capital"
assert "toolConfig" not in native_request
assert "fake_stream" not in native_params
assert "outputConfig" not in tool_request
assert [tool["toolSpec"]["name"] for tool in tool_request["toolConfig"]["tools"]] == ["json_tool_call"]
assert tool_request["toolConfig"]["toolChoice"] == {"tool": {"name": "json_tool_call"}}
assert tool_params.get("fake_stream", False) is stream
@pytest.mark.parametrize(
("native_key", "thinking_params", "native"),
[
("legacy", {}, True),
("bedrock", {}, False),
("bedrock", {"thinking": ADAPTIVE_THINKING}, True),
("bedrock", {"thinking": {"type": "enabled", "budget_tokens": 1024}}, True),
("bedrock", {"reasoning_effort": "low"}, True),
("bedrock", {"reasoning_effort": "none"}, False),
("bedrock", {"thinking": {"type": "disabled"}}, False),
],
ids=[
"legacy-key",
"bedrock-key-no-thinking",
"bedrock-key-adaptive",
"bedrock-key-enabled",
"bedrock-key-effort",
"bedrock-key-effort-none",
"bedrock-key-thinking-disabled",
],
)
def test_bedrock_native_output_key_applies_only_when_thinking_is_requested(
set_native_structured_output, native_key, thinking_params, native
):
model: Final = "us.anthropic.claude-haiku-5-5"
set_native_structured_output(model, native_key)
_, request = _converse_request_for_json_schema(model, False, thinking_params)
assert ("outputConfig" in request) is native
assert ("toolConfig" in request) is not native
def test_adaptive_thinking_is_kept_next_to_json_schema_on_a_native_output_profile(set_native_structured_output):
model: Final = "us.anthropic.claude-opus-5"
set_native_structured_output(model, "bedrock")
config: Final = AmazonConverseConfig()
thinking: Final = {"type": "adaptive", "display": "summarized"}
optional_params: Final = config.map_openai_params(
non_default_params={"response_format": CAPITAL_RESPONSE_FORMAT, "thinking": thinking},
optional_params={},
model=model,
drop_params=False,
)
request: Final = config._transform_request(
model=model,
messages=[{"role": "user", "content": "Name the capital of France."}],
optional_params=optional_params,
litellm_params={},
headers={},
)
assert request["additionalModelRequestFields"]["thinking"] == thinking
assert request["outputConfig"]["textFormat"]["structure"]["jsonSchema"]["name"] == "capital"
assert "toolConfig" not in request
_OPUS_4_8_INVOKE_RESPONSE: Final = {
"id": "msg_1",
"type": "message",
"role": "assistant",
"model": "claude-opus-4-8",
"content": [{"type": "text", "text": '{"city": "Paris", "country": "France"}'}],
"stop_reason": "end_turn",
"usage": {"input_tokens": 10, "output_tokens": 5},
}
def _routed_request_body(router: Router, model_name: str, route: str) -> Mapping[str, object]:
response: Final = _mock_converse_response()
if route == "invoke":
response.json.return_value = _OPUS_4_8_INVOKE_RESPONSE
response.text = json.dumps(_OPUS_4_8_INVOKE_RESPONSE)
client: Final = HTTPHandler()
with patch.object(client, "post", return_value=response) as mock_post:
router.completion(
model=model_name,
messages=[{"role": "user", "content": "Name the capital of France."}],
response_format=CAPITAL_RESPONSE_FORMAT,
thinking=ADAPTIVE_THINKING,
client=client,
)
return json.loads(mock_post.call_args.kwargs["data"])
def _sends_native_output(body: Mapping[str, object], route: str) -> bool:
if route == "converse":
return "outputConfig" in body and "toolConfig" not in body
output_config: Final = body.get("output_config")
return isinstance(output_config, dict) and "format" in output_config and "tools" not in body
@pytest.mark.parametrize("route", ["converse", "invoke"])
def test_a_deployment_native_output_opt_in_stays_on_that_deployment(set_native_structured_output, route):
set_native_structured_output("us.anthropic.claude-opus-4-8", None)
litellm_params: Final = {
"model": f"bedrock/{route}/us.anthropic.claude-opus-4-8",
"aws_access_key_id": "fake-access-key",
"aws_secret_access_key": "fake-secret-key",
"aws_region_name": "us-east-1",
}
router: Final = Router(
model_list=[
{
"model_name": "opted-in",
"litellm_params": litellm_params,
"model_info": {"supports_bedrock_native_structured_output": True},
},
{"model_name": "sibling", "litellm_params": litellm_params},
]
)
assert _sends_native_output(_routed_request_body(router, "opted-in", route), route)
assert not _sends_native_output(_routed_request_body(router, "sibling", route), route)
NATIVE_OUTPUT_REJECTED_KEYWORDS: Final = frozenset({"minimum", "maximum", "uniqueItems", "oneOf"})
CONSTRAINED_PERSON_SCHEMA: Final = {
"type": "object",
"properties": {
"age": {"type": "integer", "minimum": 0, "maximum": 150},
"tags": {"type": "array", "items": {"type": "string"}, "minItems": 2, "uniqueItems": True},
"aliases": {"type": "array", "items": {"type": "string"}, "minItems": 1},
"contact": {"oneOf": [{"type": "string"}, {"type": "integer"}]},
"address": {"type": ["object", "null"], "properties": {"city": {"type": "string"}}, "required": ["city"]},
},
"required": ["age", "tags", "aliases", "contact", "address"],
}
RECURSIVE_TREE_SCHEMA: Final = {
"type": "object",
"properties": {"root": {"$ref": "#/$defs/Node"}},
"required": ["root"],
"$defs": {
"Node": {
"type": "object",
"properties": {
"name": {"type": "string"},
"children": {"type": "array", "items": {"$ref": "#/$defs/Node"}},
},
"required": ["name", "children"],
}
},
}
OPEN_OBJECT_SCHEMA: Final = {
"type": "object",
"properties": {"counts": {"type": "object", "additionalProperties": {"type": "integer"}}},
"required": ["counts"],
}
LOOKAROUND_PATTERN_SCHEMA: Final = {
"type": "object",
"properties": {"amount": {"type": "string", "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$"}},
"required": ["amount"],
}
UNKNOWN_FORMAT_SCHEMA: Final = {
"type": "object",
"properties": {"blob": {"type": "string", "format": "x-litellm-unlisted-format"}},
"required": ["blob"],
}
ROOT_REF_SCHEMA: Final = {
"type": "object",
"properties": {"name": {"type": "string"}, "parent": {"anyOf": [{"$ref": "#"}, {"type": "null"}]}},
"required": ["name", "parent"],
}
def _schema_keys(node: object) -> frozenset[str]:
if isinstance(node, dict):
return frozenset(node).union(*(_schema_keys(value) for value in node.values()))
if isinstance(node, list):
return frozenset().union(*(_schema_keys(item) for item in node))
return frozenset()
def _converse_request_for_schema(
model: str, schema: Mapping[str, object], tools: tuple[Mapping[str, object], ...] = ()
) -> RequestObject:
config: Final = AmazonConverseConfig()
optional_params: Final = config.map_openai_params(
non_default_params={
"response_format": {"type": "json_schema", "json_schema": {"name": "out", "schema": schema}},
"thinking": ADAPTIVE_THINKING,
**({"tools": list(tools)} if tools else {}),
},
optional_params={},
model=model,
drop_params=False,
)
return config._transform_request(
model=model,
messages=[{"role": "user", "content": "Fill in a plausible example."}],
optional_params=optional_params,
litellm_params={},
headers={},
)
def test_native_output_schema_drops_keywords_bedrock_rejects_and_closes_objects_on_an_opt_in_model(
set_native_structured_output,
):
model: Final = "us.anthropic.claude-opus-4-8"
set_native_structured_output(model, "bedrock")
request: Final = _converse_request_for_schema(model, CONSTRAINED_PERSON_SCHEMA)
sent: Final = json.loads(request["outputConfig"]["textFormat"]["structure"]["jsonSchema"]["schema"])
assert not _schema_keys(sent) & NATIVE_OUTPUT_REJECTED_KEYWORDS
assert sent["additionalProperties"] is False
assert sent["properties"]["contact"]["anyOf"] == [{"type": "string"}, {"type": "integer"}]
assert "minimum value: 0" in sent["properties"]["age"]["description"]
assert "minItems" not in sent["properties"]["tags"]
assert sent["properties"]["aliases"]["minItems"] == 1
assert sent["properties"]["address"]["additionalProperties"] is False
@pytest.mark.parametrize(
("schema", "kept_keyword"),
[
(RECURSIVE_TREE_SCHEMA, "$ref"),
(ROOT_REF_SCHEMA, "$ref"),
(OPEN_OBJECT_SCHEMA, "additionalProperties"),
(LOOKAROUND_PATTERN_SCHEMA, "pattern"),
(UNKNOWN_FORMAT_SCHEMA, "format"),
],
ids=["recursive", "root-ref", "open-object", "lookaround-pattern", "unlisted-format"],
)
def test_schema_native_output_cannot_hold_keeps_the_tool_path_on_an_opt_in_model(
set_native_structured_output, schema, kept_keyword
):
model: Final = "us.anthropic.claude-opus-4-8"
set_native_structured_output(model, "bedrock")
request: Final = _converse_request_for_schema(model, schema)
assert "outputConfig" not in request
assert [tool["toolSpec"]["name"] for tool in request["toolConfig"]["tools"]] == ["json_tool_call"]
assert request["toolConfig"]["toolChoice"] == {"tool": {"name": "json_tool_call"}}
assert optional_params.get("fake_stream", False) is stream
tool_spec: Final = request["toolConfig"]["tools"][0]["toolSpec"]
assert tool_spec["name"] == "json_tool_call"
assert kept_keyword in _schema_keys(tool_spec["inputSchema"]["json"])
LOOKUP_TOOL: Final = {
"type": "function",
"function": {"name": "lookup", "parameters": {"type": "object", "properties": {"id": {"type": "string"}}}},
}
ARRAY_ROOT_SCHEMA: Final = {"type": "array", "items": {"type": "object", "additionalProperties": {"type": "string"}}}
TYPED_ALL_OF_ROOT_SCHEMA: Final = {
"type": "object",
"allOf": [
{"type": "object", "properties": {"a": {"type": "string"}}, "required": ["a"]},
{"type": "object", "properties": {"b": {"type": "string"}}, "required": ["b"]},
],
}
@pytest.mark.parametrize(
"schema",
[
ARRAY_ROOT_SCHEMA,
TYPED_ALL_OF_ROOT_SCHEMA,
OPEN_OBJECT_SCHEMA,
CONSTRAINED_PERSON_SCHEMA,
LOOKAROUND_PATTERN_SCHEMA,
RECURSIVE_TREE_SCHEMA,
],
ids=["array-root", "typed-all-of-root", "open-object", "constrained", "lookaround-pattern", "recursive"],
)
@pytest.mark.parametrize(
"request_params",
[
{},
{"thinking": ADAPTIVE_THINKING},
{"reasoning_effort": "low"},
{"tool_choice": "auto"},
{"tool_choice": {"type": "function", "function": {"name": "lookup"}}},
],
ids=["plain", "thinking", "reasoning-effort", "tool-choice-auto", "named-tool-choice-without-tools"],
)
def test_model_native_on_every_request_sends_the_callers_schema_natively_with_every_keyword(
set_native_structured_output, schema, request_params
):
model: Final = "us.anthropic.claude-opus-4-8"
set_native_structured_output(model, "legacy")
config: Final = AmazonConverseConfig()
optional_params: Final = config.map_openai_params(
non_default_params={
"response_format": {"type": "json_schema", "json_schema": {"name": "out", "schema": schema}},
**request_params,
},
optional_params={},
model=model,
drop_params=False,
)
request: Final = config._transform_request(
model=model,
messages=[{"role": "user", "content": "Fill in a plausible example."}],
optional_params=optional_params,
litellm_params={},
headers={},
)
sent: Final = json.loads(request["outputConfig"]["textFormat"]["structure"]["jsonSchema"]["schema"])
assert _schema_keys(schema) <= _schema_keys(sent)
assert "json_tool_call" not in [tool["toolSpec"]["name"] for tool in request.get("toolConfig", {}).get("tools", [])]
def _tool_names(request: RequestObject) -> list[str]:
return sorted(tool["toolSpec"]["name"] for tool in request["toolConfig"]["tools"])
def test_open_object_schema_stays_native_beside_the_callers_tools_on_a_model_native_on_every_request(
set_native_structured_output,
):
model: Final = "us.anthropic.claude-opus-4-8"
set_native_structured_output(model, "legacy")
request: Final = _converse_request_for_schema(model, OPEN_OBJECT_SCHEMA, (LOOKUP_TOOL,))
assert "outputConfig" in request
assert _tool_names(request) == ["lookup"]
assert "toolChoice" not in request["toolConfig"]
def test_open_object_schema_keeps_the_json_tool_beside_the_callers_tools_on_an_opt_in_model(
set_native_structured_output,
):
model: Final = "us.anthropic.claude-opus-4-8"
set_native_structured_output(model, "bedrock")
request: Final = _converse_request_for_schema(model, OPEN_OBJECT_SCHEMA, (LOOKUP_TOOL,))
assert "outputConfig" not in request
assert _tool_names(request) == ["json_tool_call", "lookup"]
def _shared_defs_schema(defs_key: str) -> Mapping[str, object]:
party_ref: Final = {"$ref": f"#/{defs_key}/Party"}
return {
"type": "object",
"properties": {"buyer": party_ref, "seller": party_ref, "broker": {"anyOf": [party_ref, {"type": "null"}]}},
"required": ["buyer", "seller", "broker"],
defs_key: {
"Party": {
"type": ["object", "null"],
"properties": {"name": {"type": "string"}, "email": {"anyOf": [{"type": "string"}, {"type": "null"}]}},
"required": ["name", "email"],
}
},
}
@pytest.mark.parametrize("defs_key", ["$defs", "definitions"])
@pytest.mark.parametrize("native_key", ["legacy", "bedrock"])
def test_shared_defs_reach_native_output_once_instead_of_inlined_per_use(
set_native_structured_output, native_key, defs_key
):
model: Final = "us.anthropic.claude-opus-4-8"
set_native_structured_output(model, native_key)
request: Final = _converse_request_for_schema(model, _shared_defs_schema(defs_key))
sent: Final = json.loads(request["outputConfig"]["textFormat"]["structure"]["jsonSchema"]["schema"])
assert sent["properties"]["buyer"] == {"$ref": f"#/{defs_key}/Party"}
assert json.dumps(sent).count('"anyOf"') == 2
assert sent[defs_key]["Party"]["additionalProperties"] is False
def _flat_schema(property_schema: Mapping[str, object], count: int, required: bool) -> Mapping[str, object]:
names: Final = tuple(f"field_{index}" for index in range(count))
return {
"type": "object",
"properties": dict.fromkeys(names, property_schema),
"required": list(names) if required else [],
}
def _optional_groups_schema(count: int, group_size: int = 6) -> Mapping[str, object]:
groups: Final = {
f"group_{start}": _flat_schema({"type": "string"}, min(group_size, count - start), required=False)
for start in range(0, count, group_size)
}
return {"type": "object", "properties": groups, "required": list(groups)}
@pytest.mark.parametrize(
("schema_for", "limit"),
[
(lambda count: _flat_schema({"type": ["string", "null"]}, count, True), NATIVE_OUTPUT_MAX_UNION_PARAMETERS),
(
lambda count: _flat_schema({"anyOf": [{"type": "string"}, {"type": "integer"}]}, count, True),
NATIVE_OUTPUT_MAX_UNION_PARAMETERS,
),
(_optional_groups_schema, NATIVE_OUTPUT_MAX_OPTIONAL_PARAMETERS),
(
lambda count: _flat_schema({"type": "string"}, count, False),
NATIVE_OUTPUT_MAX_GRAMMAR_BRANCHES.bit_length() - 1,
),
],
ids=["type-list-unions", "any-of-unions", "optional", "optional-grammar-branches"],
)
def test_schema_past_a_native_output_size_limit_keeps_the_tool_path_on_an_opt_in_model(
set_native_structured_output, schema_for, limit
):
model: Final = "us.anthropic.claude-opus-4-8"
set_native_structured_output(model, "bedrock")
at_limit: Final = _converse_request_for_schema(model, schema_for(limit))
past_limit: Final = _converse_request_for_schema(model, schema_for(limit + 1))
sent: Final = json.loads(at_limit["outputConfig"]["textFormat"]["structure"]["jsonSchema"]["schema"])
assert sent["properties"].keys() == schema_for(limit)["properties"].keys()
assert "outputConfig" not in past_limit
assert past_limit["toolConfig"]["tools"][0]["toolSpec"]["name"] == "json_tool_call"
@pytest.mark.parametrize(
("native_key", "native_past_limit"), [("legacy", True), ("bedrock", False)], ids=["legacy-key", "bedrock-key"]
)
def test_field_count_limit_sends_the_json_tool_only_on_the_bedrock_native_key(
set_native_structured_output, native_key, native_past_limit
):
model: Final = "us.anthropic.claude-opus-4-8"
set_native_structured_output(model, native_key)
at_limit: Final = _converse_request_for_schema(
model, _flat_schema({"type": "string"}, BEDROCK_NATIVE_OUTPUT_MAX_PROPERTIES, True)
)
past_limit: Final = _converse_request_for_schema(
model, _flat_schema({"type": "string"}, BEDROCK_NATIVE_OUTPUT_MAX_PROPERTIES + 1, True)
)
assert "outputConfig" in at_limit
assert ("outputConfig" in past_limit) is native_past_limit
NUMERIC_FORMAT_SCHEMA: Final = {
"type": "object",
"properties": {
"count": {"type": "integer", "format": "int32"},
"total": {"type": "integer", "format": "int64"},
"ratio": {"type": "number", "format": "double"},
},
"required": ["count", "total", "ratio"],
}
@pytest.mark.parametrize("native_key", ["legacy", "bedrock"])
def test_numeric_formats_keep_native_output(set_native_structured_output, native_key):
model: Final = "us.anthropic.claude-opus-4-8"
set_native_structured_output(model, native_key)
request: Final = _converse_request_for_schema(model, NUMERIC_FORMAT_SCHEMA)
sent: Final = json.loads(request["outputConfig"]["textFormat"]["structure"]["jsonSchema"]["schema"])
assert {name: field["format"] for name, field in sent["properties"].items()} == {
"count": "int32",
"total": "int64",
"ratio": "double",
}
def _doubling_defs_schema(depth: int) -> Mapping[str, object]:
levels: Final = {
f"Level{index}": {
"type": "object",
"properties": {
"left": {"$ref": f"#/$defs/Level{index + 1}"},
"right": {"$ref": f"#/$defs/Level{index + 1}"},
},
"required": ["left", "right"],
}
for index in range(depth)
}
return {
"type": "object",
"properties": {"root": {"$ref": "#/$defs/Level0"}},
"required": ["root"],
"$defs": {**levels, f"Level{depth}": {"type": "string"}},
}
@pytest.mark.timeout(30)
def test_defs_that_double_at_every_level_reach_native_output_without_being_expanded(set_native_structured_output):
model: Final = "us.anthropic.claude-opus-4-8"
set_native_structured_output(model, "legacy")
schema: Final = _doubling_defs_schema(30)
request: Final = _converse_request_for_schema(model, schema)
sent: Final = json.loads(request["outputConfig"]["textFormat"]["structure"]["jsonSchema"]["schema"])
assert sent["$defs"].keys() == schema["$defs"].keys()
assert sent["properties"]["root"] == {"$ref": "#/$defs/Level0"}
@pytest.mark.parametrize(
("native_key", "forwarded"), [("legacy", True), ("bedrock", False)], ids=["legacy-key", "bedrock-key"]
)
def test_recursive_output_config_format_follows_the_profile_native_key(
set_native_structured_output, native_key, forwarded
):
model: Final = "us.anthropic.claude-opus-4-8"
set_native_structured_output(model, native_key)
request: Final = AmazonConverseConfig()._transform_request(
model=model,
messages=[{"role": "user", "content": "Fill in a plausible example."}],
optional_params={"output_config": {"format": {"type": "json_schema", "schema": RECURSIVE_TREE_SCHEMA}}},
litellm_params={},
headers={},
)
assert ("outputConfig" in request) is forwarded
def test_sonnet_4_6_json_schema_still_uses_native_output_config(monkeypatch):
@ -8008,10 +8523,13 @@ def test_unforced_tool_choice_unaffected_on_fable_5_1_converse(local_model_cost_
"model",
["anthropic.claude-fable-5-1", "us.anthropic.claude-fable-5-1"],
)
def test_response_format_avoids_native_and_forced_tool_choice_on_fable_5_1_converse(local_model_cost_map, model):
"""Regression: Bedrock rejects both ``outputConfig`` structured output and forced
tool_choice for Fable 5.1, so response_format must map to a tool without a forced
tool_choice."""
def test_response_format_avoids_native_and_forced_tool_choice_on_fable_5_1_converse(
set_native_structured_output, model
):
"""Regression: Bedrock rejects forced tool_choice for Fable 5.1, so on a profile
without native structured output response_format must map to a tool without a
forced tool_choice."""
set_native_structured_output(model, None)
config = AmazonConverseConfig()
result = config.map_openai_params(
@ -10149,6 +10667,154 @@ def test_map_openai_params_drops_the_openai_tool_search_tool_and_every_deferral_
assert all("defer_loading" not in tool and "defer_loading" not in tool["function"] for tool in mapped["tools"])
def _converse_params(
model: str, model_info: Mapping[str, object] | None, non_default_params: Mapping[str, object]
) -> Mapping[str, object]:
return AmazonConverseConfig().map_openai_params(
non_default_params=dict(non_default_params),
optional_params={},
model=model,
drop_params=False,
model_info=model_info,
)
_OUTPUT_CONFIG_FORMAT: Final = {
"output_config": {"format": {"type": "json_schema", "schema": CAPITAL_RESPONSE_FORMAT["json_schema"]["schema"]}}
}
@pytest.mark.parametrize(
"request_params",
[
{"response_format": CAPITAL_RESPONSE_FORMAT, "thinking": ADAPTIVE_THINKING},
{"response_format": CAPITAL_RESPONSE_FORMAT, "reasoning_effort": "low"},
{"response_format": CAPITAL_RESPONSE_FORMAT, "thinking": {"type": "enabled", "budget_tokens": 2048}},
_OUTPUT_CONFIG_FORMAT,
],
ids=["adaptive", "reasoning_effort", "budget", "output_config_format"],
)
@pytest.mark.parametrize("model", ["us.anthropic.claude-opus-4-8", "global.anthropic.claude-sonnet-5"])
def test_uses_opt_in_native_output_when_the_opt_in_drove_native_output(local_model_cost_map, model, request_params):
optional_params: Final = _converse_params(model, None, request_params)
assert AmazonConverseConfig().uses_opt_in_native_output(model, optional_params, {"model_info": {"id": "d1"}})
@pytest.mark.parametrize(
("model", "model_info", "request_params"),
[
("us.anthropic.claude-opus-4-8", None, {"response_format": CAPITAL_RESPONSE_FORMAT}),
(
"us.anthropic.claude-opus-4-8",
{"supports_bedrock_native_structured_output": False},
{"response_format": CAPITAL_RESPONSE_FORMAT, "thinking": ADAPTIVE_THINKING},
),
(
"us.anthropic.claude-sonnet-4-6",
{"supports_bedrock_native_structured_output": True},
{"response_format": CAPITAL_RESPONSE_FORMAT, "thinking": ADAPTIVE_THINKING},
),
],
ids=["no_thinking", "deployment_opt_out", "native_model"],
)
def test_uses_opt_in_native_output_is_false_when_native_output_did_not_come_from_the_opt_in(
local_model_cost_map, model, model_info, request_params
):
optional_params: Final = _converse_params(model, model_info, request_params)
assert not AmazonConverseConfig().uses_opt_in_native_output(model, optional_params, {"model_info": model_info})
def test_uses_opt_in_native_output_reads_the_base_model_of_an_application_inference_profile(local_model_cost_map):
profile: Final = "arn:aws:bedrock:us-east-1:000000000000:application-inference-profile/profile-id"
litellm_params: Final = {"base_model": "bedrock/us.anthropic.claude-opus-4-8"}
optional_params: Final = _converse_params(
"us.anthropic.claude-opus-4-8",
None,
{"response_format": CAPITAL_RESPONSE_FORMAT, "thinking": ADAPTIVE_THINKING},
)
assert AmazonConverseConfig().uses_opt_in_native_output(profile, optional_params, litellm_params)
_CALLER_OUTPUT_CONFIG_WITH_A_SCHEMA_OBJECT: Final = {
"textFormat": {"type": "json_schema", "structure": {"jsonSchema": {"name": "capital", "schema": {"type": "object"}}}}
}
_BEDROCK_SCHEMA_TYPE_ERROR: Final = "outputConfig.textFormat.structure.jsonSchema.schema must be a string"
@pytest.mark.parametrize("model", [_OPUS, _LLAMA], ids=["opt_in", "not_opt_in"])
@pytest.mark.respx(assert_all_called=True)
def test_a_malformed_caller_output_config_gets_bedrocks_own_400(
model: str, respx_mock: respx.Router, local_model_cost_map: None
) -> None:
route: Final = respx_mock.post(url__regex=r"/converse$").mock(
return_value=httpx.Response(400, json={"message": _BEDROCK_SCHEMA_TYPE_ERROR})
)
with pytest.raises(litellm.BadRequestError, match=re.escape(_BEDROCK_SCHEMA_TYPE_ERROR)):
completion(
model=f"bedrock/converse/{model}",
messages=[{"role": "user", "content": "What is the capital of France?"}],
response_format=CAPITAL_RESPONSE_FORMAT,
outputConfig=_CALLER_OUTPUT_CONFIG_WITH_A_SCHEMA_OBJECT,
num_retries=0,
aws_access_key_id="fake",
aws_secret_access_key="fake",
aws_region_name="us-east-1",
client=HTTPHandler(),
)
assert route.call_count == 1
_CALLER_OUTPUT_CONFIG: Final = {
"textFormat": {
"type": "json_schema",
"structure": {
"jsonSchema": {"name": "capital", "schema": json.dumps(CAPITAL_RESPONSE_FORMAT["json_schema"]["schema"])}
},
}
}
_BEDROCK_SCHEMA_REFUSAL: Final = "output_config.format.schema: pattern is too complex for structured output"
@pytest.mark.parametrize(
("caller_params", "expected_calls"),
[
({"thinking": ADAPTIVE_THINKING}, 2),
({"outputConfig": _CALLER_OUTPUT_CONFIG}, 1),
({"outputConfig": _CALLER_OUTPUT_CONFIG, "thinking": ADAPTIVE_THINKING}, 1),
],
ids=["litellm_native_output", "caller_output_config", "caller_output_config_thinking"],
)
@pytest.mark.respx(assert_all_called=True)
def test_a_refused_native_schema_is_sent_again_only_when_the_retry_drops_native_output(
caller_params: dict, expected_calls: int, respx_mock: respx.Router, local_model_cost_map: None
) -> None:
route: Final = respx_mock.post(url__regex=r"/converse$").mock(
return_value=httpx.Response(400, json={"message": _BEDROCK_SCHEMA_REFUSAL})
)
with pytest.raises(litellm.BadRequestError, match=re.escape(_BEDROCK_SCHEMA_REFUSAL)):
completion(
model=f"bedrock/converse/{_OPUS}",
messages=[{"role": "user", "content": "What is the capital of France?"}],
response_format=CAPITAL_RESPONSE_FORMAT,
num_retries=0,
aws_access_key_id="fake",
aws_secret_access_key="fake",
aws_region_name="us-east-1",
client=HTTPHandler(),
**caller_params,
)
assert route.call_count == expected_calls
assert all("outputConfig" in json.loads(call.request.content) for call in route.calls[:1])
assert all("outputConfig" not in json.loads(call.request.content) for call in route.calls[1:])
_HOSTED_MCP_TOOL: Final = {
"type": "mcp",
"server_label": "deepwiki",

View file

@ -0,0 +1,123 @@
import asyncio
from collections.abc import Coroutine
from dataclasses import dataclass
from typing import Final
import pytest
from litellm.llms.bedrock.chat.native_output_fallback import NativeOutputAttemptResult, call_with_native_output_fallback
from litellm.llms.bedrock.common_utils import BedrockError, NativeOutputFallback
from litellm.types.utils import ModelResponse
_MODEL: Final = "us.anthropic.claude-opus-4-8"
_FALLBACK: Final = NativeOutputFallback(
optional_params={"tools": "json_tool_call"}, litellm_params={"model_info": {"opted_out": True}}
)
_REJECTION: Final = BedrockError(
status_code=400,
message='{"message":"The model returned the following errors: output_config.format: Extra inputs are not permitted"}',
)
_SCHEMA_REFUSAL: Final = BedrockError(
status_code=400, message='{"message":"output_config.format.schema: pattern is too complex for structured output"}'
)
_OTHER_400: Final = BedrockError(status_code=400, message='{"message":"messages.0.content: Field required"}')
@dataclass(slots=True)
class ScriptedBedrock:
errors: tuple[BedrockError | None, ...]
asynchronous: bool
sent: tuple[NativeOutputFallback | None, ...] = ()
def _answer(self, error: BedrockError | None) -> ModelResponse:
if error is not None:
raise error
return ModelResponse(id=f"answer-{len(self.sent)}")
async def _answer_later(self, error: BedrockError | None) -> ModelResponse:
await asyncio.sleep(0)
return self._answer(error)
def __call__(self, fallback: NativeOutputFallback | None) -> NativeOutputAttemptResult:
self.sent = (*self.sent, fallback)
error: Final = self.errors[len(self.sent) - 1]
return self._answer_later(error) if self.asynchronous else self._answer(error)
def _resolve(result: NativeOutputAttemptResult) -> object:
if isinstance(result, Coroutine):
return asyncio.run(result)
return result
def _call(bedrock: ScriptedBedrock, fallback: NativeOutputFallback | None) -> object:
return _resolve(call_with_native_output_fallback(bedrock, None if fallback is None else lambda: fallback, _MODEL))
_MODES: Final = pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"])
@_MODES
def test_a_native_output_rejection_is_sent_again_once_with_the_fallback_params(asynchronous: bool) -> None:
bedrock: Final = ScriptedBedrock(errors=(_REJECTION, None), asynchronous=asynchronous)
response: Final = _call(bedrock, _FALLBACK)
assert isinstance(response, ModelResponse) and response.id == "answer-2"
assert bedrock.sent == (None, _FALLBACK)
@_MODES
def test_a_second_native_output_rejection_reaches_the_caller(asynchronous: bool) -> None:
bedrock: Final = ScriptedBedrock(errors=(_REJECTION, _REJECTION), asynchronous=asynchronous)
with pytest.raises(BedrockError):
_call(bedrock, _FALLBACK)
assert len(bedrock.sent) == 2
@_MODES
@pytest.mark.parametrize(
("error", "fallback"),
[(_OTHER_400, _FALLBACK), (_REJECTION, None)],
ids=["other_400", "no_fallback"],
)
def test_no_second_request_without_both_a_rejection_and_a_fallback(
asynchronous: bool, error: BedrockError, fallback: NativeOutputFallback | None
) -> None:
bedrock: Final = ScriptedBedrock(errors=(error,), asynchronous=asynchronous)
with pytest.raises(BedrockError) as raised:
_call(bedrock, fallback)
assert raised.value is error
assert bedrock.sent == (None,)
@_MODES
def test_a_rejection_reaches_the_caller_when_the_retry_would_send_native_output_again(asynchronous: bool) -> None:
bedrock: Final = ScriptedBedrock(errors=(_REJECTION,), asynchronous=asynchronous)
with pytest.raises(BedrockError) as raised:
_resolve(call_with_native_output_fallback(bedrock, lambda: None, _MODEL))
assert raised.value is _REJECTION
assert bedrock.sent == (None,)
@pytest.mark.parametrize(
("error", "advises_opting_out"),
[(_REJECTION, True), (_SCHEMA_REFUSAL, False)],
ids=["native_output_unsupported", "schema_refused"],
)
def test_a_rejection_logs_the_retry_and_advises_opting_out_only_when_native_output_is_unsupported(
error: BedrockError, advises_opting_out: bool, caplog: pytest.LogCaptureFixture
) -> None:
bedrock: Final = ScriptedBedrock(errors=(error, None), asynchronous=False)
with caplog.at_level("WARNING", logger="LiteLLM"):
_call(bedrock, _FALLBACK)
assert _MODEL in caplog.text
assert ("supports_bedrock_native_structured_output: false" in caplog.text) is advises_opting_out

View file

@ -2073,6 +2073,31 @@ class TestBedrockBatchAnthropicRowParams:
assert "response_format" not in model_input
assert ("output_config" in model_input) == (response_format["type"] == "json_schema")
@pytest.mark.parametrize(
("model", "native_output"),
[("bedrock/us.anthropic.claude-opus-4-8", False), (MODEL, True)],
ids=["opt_in_native_output_model", "native_output_model"],
)
def test_thinking_json_schema_rows_use_native_output_only_on_models_that_always_take_it(
self, local_model_cost_map, model, native_output
):
model_input: Final = self._transform(
"/v1/chat/completions",
{
"messages": [{"role": "user", "content": "Weather in Paris?"}],
"thinking": {"type": "adaptive"},
"response_format": {
"type": "json_schema",
"json_schema": {"name": "weather", "schema": self.PARAMETERS},
},
},
model=model,
)
tool_names: Final = frozenset(tool["name"] for tool in model_input.get("tools", []))
assert ("format" in model_input.get("output_config", {})) is native_output
assert (tool_names == {"json_tool_call"}) is not native_output
def test_provider_native_params_still_pass_through(self):
model_input = self._transform(
"/v1/chat/completions",

View file

@ -1051,6 +1051,7 @@ def test_bedrock_messages_forwards_output_config_with_output_format():
"schema": {
"type": "object",
"properties": {"answer": {"type": "string"}},
"additionalProperties": False,
},
}
optional_params = {
@ -1099,7 +1100,7 @@ def test_bedrock_messages_converts_output_config_format_to_inline_schema():
with patch(
"litellm.llms.bedrock.common_utils._bedrock_model_supports",
side_effect=lambda _model, key: key == "supports_output_config",
side_effect=lambda _model, key, *_: key == "supports_output_config",
):
result = cfg.transform_anthropic_messages_request(
model="anthropic.claude-opus-4-7",
@ -1171,7 +1172,7 @@ def test_bedrock_messages_does_not_mutate_callers_messages_when_embedding_schema
with patch(
"litellm.llms.bedrock.common_utils._bedrock_model_supports",
side_effect=lambda _model, key: key == "supports_output_config",
side_effect=lambda _model, key, *_: key == "supports_output_config",
):
result = cfg.transform_anthropic_messages_request(
model="anthropic.claude-opus-4-7",
@ -3189,11 +3190,12 @@ def test_bedrock_messages_forwards_output_config_format_natively(local_model_cos
assert "zebra_count" not in json.dumps(result["messages"])
def test_bedrock_messages_inlines_schema_for_claude_5(local_model_cost_map):
"""Bedrock rejects ``output_config.format`` for the Claude 5 family, so the
schema falls back to the inline-text path instead of a deterministic 400."""
def test_bedrock_messages_inlines_schema_for_a_profile_without_native_output(set_native_structured_output):
"""On a profile without native structured output the schema falls back to the
inline-text path instead of a deterministic 400."""
from litellm.types.router import GenericLiteLLMParams
set_native_structured_output("us.anthropic.claude-sonnet-5", None)
cfg = AmazonAnthropicClaudeMessagesConfig()
schema = {
"type": "object",
@ -3216,6 +3218,126 @@ def test_bedrock_messages_inlines_schema_for_claude_5(local_model_cost_map):
assert json.loads(last_content[-1]["text"]) == schema
def _bedrock_messages_request_with_schema(model: str, schema: Mapping[str, object]) -> Mapping[str, object]:
from litellm.types.router import GenericLiteLLMParams
return AmazonAnthropicClaudeMessagesConfig().transform_anthropic_messages_request(
model=model,
messages=[{"role": "user", "content": [{"type": "text", "text": "say hello"}]}],
anthropic_messages_optional_request_params={
"max_tokens": 100,
"output_config": {"format": {"type": "json_schema", "schema": schema}},
},
litellm_params=GenericLiteLLMParams(),
headers={},
)
def test_bedrock_messages_native_output_drops_schema_keywords_bedrock_rejects(set_native_structured_output):
model: Final = "us.anthropic.claude-sonnet-5"
set_native_structured_output(model, "bedrock")
schema: Final = {
"type": "object",
"properties": {
"count": {"type": "integer", "minimum": 1},
"ids": {"type": "array", "items": {"type": "string"}, "uniqueItems": True},
},
"required": ["count", "ids"],
}
result: Final = _bedrock_messages_request_with_schema(model, schema)
sent: Final = result["output_config"]["format"]["schema"]
assert "minimum" not in sent["properties"]["count"]
assert "uniqueItems" not in sent["properties"]["ids"]
assert sent["additionalProperties"] is False
@pytest.mark.parametrize("native_key", ["legacy", "bedrock"])
def test_bedrock_messages_shared_defs_reach_native_output_once_instead_of_inlined_per_use(
set_native_structured_output, native_key
):
model: Final = "us.anthropic.claude-sonnet-5"
set_native_structured_output(model, native_key)
party_ref: Final = {"$ref": "#/$defs/Party"}
schema: Final = {
"type": "object",
"properties": {"buyer": party_ref, "seller": party_ref},
"required": ["buyer", "seller"],
"additionalProperties": False,
"$defs": {
"Party": {
"type": "object",
"properties": {"email": {"anyOf": [{"type": "string"}, {"type": "null"}]}},
"required": ["email"],
"additionalProperties": False,
}
},
}
result: Final = _bedrock_messages_request_with_schema(model, schema)
sent: Final = result["output_config"]["format"]["schema"]
assert sent["properties"]["buyer"] == party_ref
assert json.dumps(sent).count('"anyOf"') == 1
MESSAGES_TREE_SCHEMA: Final = {
"type": "object",
"properties": {"root": {"$ref": "#/$defs/Node"}},
"$defs": {
"Node": {
"type": "object",
"properties": {"children": {"type": "array", "items": {"$ref": "#/$defs/Node"}}},
}
},
}
MESSAGES_OPEN_OBJECT_SCHEMA: Final = {
"type": "object",
"properties": {"counts": {"type": "object", "additionalProperties": {"type": "integer"}}},
}
MESSAGES_KEYWORDS_SCHEMA: Final = {
"type": "object",
"properties": {
"count": {"type": "integer", "minimum": 1},
"ids": {"type": "array", "items": {"type": "string"}, "uniqueItems": True},
},
"required": ["count", "ids"],
}
@pytest.mark.parametrize(
"schema", [MESSAGES_TREE_SCHEMA, MESSAGES_OPEN_OBJECT_SCHEMA], ids=["recursive", "open-object"]
)
def test_bedrock_messages_schema_native_output_cannot_hold_is_inlined_on_a_bedrock_native_profile(
set_native_structured_output, schema
):
model: Final = "us.anthropic.claude-sonnet-5"
set_native_structured_output(model, "bedrock")
result: Final = _bedrock_messages_request_with_schema(model, schema)
assert "output_config" not in result
assert json.loads(result["messages"][-1]["content"][-1]["text"]) == schema
@pytest.mark.parametrize(
"schema",
[MESSAGES_TREE_SCHEMA, MESSAGES_OPEN_OBJECT_SCHEMA, MESSAGES_KEYWORDS_SCHEMA],
ids=["recursive", "open-object", "keywords"],
)
def test_bedrock_messages_forwards_the_callers_schema_unchanged_on_a_legacy_native_profile(
set_native_structured_output, schema
):
model: Final = "us.anthropic.claude-sonnet-4-6"
set_native_structured_output(model, "legacy")
sent_by_caller: Final = copy.deepcopy(schema)
result: Final = _bedrock_messages_request_with_schema(model, schema)
assert result["output_config"]["format"]["schema"] == sent_by_caller
def test_bedrock_messages_legacy_output_format_wins_over_output_config_format(local_model_cost_map):
"""When a request carries both schema forms, the legacy top-level
``output_format`` keeps winning, matching the pre-existing precedence."""
@ -3224,7 +3346,11 @@ def test_bedrock_messages_legacy_output_format_wins_over_output_config_format(lo
cfg = AmazonAnthropicClaudeMessagesConfig()
legacy_format = {
"type": "json_schema",
"schema": {"type": "object", "properties": {"legacy_field": {"type": "string"}}},
"schema": {
"type": "object",
"properties": {"legacy_field": {"type": "string"}},
"additionalProperties": False,
},
}
newer_format = {
"type": "json_schema",
@ -3258,7 +3384,7 @@ def test_bedrock_messages_drop_params_keeps_native_output_config_format(local_mo
cfg = AmazonAnthropicClaudeMessagesConfig()
schema_format = {
"type": "json_schema",
"schema": {"type": "object", "properties": {"zebra_count": {"type": "integer"}}},
"schema": {"type": "object", "properties": {"zebra_count": {"type": "integer"}}, "additionalProperties": False},
}
result = cfg.transform_anthropic_messages_request(
@ -3283,7 +3409,7 @@ def test_bedrock_messages_strips_effort_but_keeps_format_for_sonnet_4_5(local_mo
cfg = AmazonAnthropicClaudeMessagesConfig()
schema_format = {
"type": "json_schema",
"schema": {"type": "object", "properties": {"zebra_count": {"type": "integer"}}},
"schema": {"type": "object", "properties": {"zebra_count": {"type": "integer"}}, "additionalProperties": False},
}
result = cfg.transform_anthropic_messages_request(
@ -4006,3 +4132,162 @@ 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)
_NATIVE_OUTPUT_REJECTION: Final = {
"message": "The model returned the following errors: compilation exceeded limits: Schema is too complex."
}
_STALE_SIGNATURE_ERROR: Final = {"message": "messages.1.content.0: Invalid `signature` in `thinking` block"}
_OPTED_OUT: Final = {"supports_bedrock_native_structured_output": False}
_CONSTRAINED_SCHEMA: Final = {
"type": "object",
"properties": {
"summary": {"type": "string", "maxLength": 50},
"kind": {"oneOf": [{"type": "string"}, {"type": "integer"}]},
},
"required": ["summary", "kind"],
"additionalProperties": False,
}
_ANTHROPIC_MESSAGE: Final = {
"id": "msg_scripted",
"type": "message",
"role": "assistant",
"model": "claude-scripted",
"content": [{"type": "text", "text": '{"summary": "sunny", "kind": 1}'}],
"stop_reason": "end_turn",
"stop_sequence": None,
"usage": {"input_tokens": 1, "output_tokens": 1},
}
_THINKING_HISTORY: Final = (
{"role": "user", "content": "Describe the weather."},
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "checking", "signature": "stale"},
{"type": "text", "text": "Sunny."},
],
},
{"role": "user", "content": "Summarise it as JSON."},
)
@dataclass(slots=True)
class ScriptedBedrockInvoke:
replies: tuple[tuple[int, Mapping[str, object]], ...]
bodies: tuple[dict[str, object], ...] = ()
def __call__(self, request: httpx.Request) -> httpx.Response:
self.bodies = (*self.bodies, REASONING_BODY_ADAPTER.validate_json(request.content))
status_code, body = self.replies[len(self.bodies) - 1]
return httpx.Response(status_code, json=body)
async def _send_bedrock_messages(
model: str,
model_info: Mapping[str, object] | None,
replies: Sequence[tuple[int, Mapping[str, object]]],
messages: Sequence[Mapping[str, object]] = ({"role": "user", "content": "Summarise the weather as JSON."},),
) -> tuple[ScriptedBedrockInvoke, Exception | None]:
bedrock: Final = ScriptedBedrockInvoke(replies=tuple(replies))
try:
await litellm.anthropic_messages(
model=f"bedrock/invoke/{model}",
messages=[dict(message) for message in messages],
max_tokens=4000,
system="Answer in French.",
thinking={"type": "adaptive"},
output_config={"format": {"type": "json_schema", "schema": _CONSTRAINED_SCHEMA}},
aws_access_key_id="test-access-key",
aws_secret_access_key="test-secret-key",
aws_region_name="us-east-1",
client=AsyncHTTPHandler(transport=httpx.MockTransport(bedrock)),
**({} if model_info is None else {"model_info": model_info}),
)
except Exception as error:
return bedrock, error
return bedrock, None
_OPT_IN_DEPLOYMENTS: Final = pytest.mark.parametrize(
("model", "model_info"),
[
("us.anthropic.claude-opus-4-8", None),
("us.anthropic.claude-opus-5-5", {"supports_bedrock_native_structured_output": True}),
],
ids=["mapped_opt_in", "deployment_opt_in"],
)
@pytest.mark.usefixtures("local_model_cost_map")
@_OPT_IN_DEPLOYMENTS
async def test_bedrock_messages_native_output_rejection_resends_the_body_an_opted_out_deployment_sends(
model: str, model_info: Mapping[str, object] | None
) -> None:
opt_in, opt_in_error = await _send_bedrock_messages(
model, model_info, [(400, _NATIVE_OUTPUT_REJECTION), (200, _ANTHROPIC_MESSAGE)]
)
opted_out, opted_out_error = await _send_bedrock_messages(model, _OPTED_OUT, [(200, _ANTHROPIC_MESSAGE)])
assert opt_in_error is None and opted_out_error is None
native, retried = opt_in.bodies
assert "format" in REASONING_BODY_ADAPTER.validate_python(native["output_config"])
assert opted_out.bodies == (retried,)
@pytest.mark.usefixtures("local_model_cost_map")
@pytest.mark.parametrize("model_info", [None, _OPTED_OUT], ids=["native_output", "opted_out"])
async def test_bedrock_messages_thinking_signature_400_that_persists_reaches_bedrock_twice_as_before(
model_info: Mapping[str, object] | None,
) -> None:
bedrock, error = await _send_bedrock_messages(
"us.anthropic.claude-opus-4-8", model_info, [(400, _STALE_SIGNATURE_ERROR)] * 3, _THINKING_HISTORY
)
assert isinstance(error, litellm.BadRequestError)
assert len(bedrock.bodies) == 2
@pytest.mark.usefixtures("local_model_cost_map")
@pytest.mark.parametrize(
"first_error, second_error",
[(_NATIVE_OUTPUT_REJECTION, _STALE_SIGNATURE_ERROR), (_STALE_SIGNATURE_ERROR, _NATIVE_OUTPUT_REJECTION)],
ids=["rejection_first", "signature_first"],
)
async def test_bedrock_messages_native_output_rejection_and_signature_400_each_get_one_retry_like_an_opted_out_deployment(
first_error: Mapping[str, object], second_error: Mapping[str, object]
) -> None:
opt_in, opt_in_error = await _send_bedrock_messages(
"us.anthropic.claude-opus-4-8",
None,
[(400, first_error), (400, second_error), (200, _ANTHROPIC_MESSAGE)],
_THINKING_HISTORY,
)
opted_out, opted_out_error = await _send_bedrock_messages(
"us.anthropic.claude-opus-4-8",
_OPTED_OUT,
[(400, _STALE_SIGNATURE_ERROR), (200, _ANTHROPIC_MESSAGE)],
_THINKING_HISTORY,
)
assert opt_in_error is None and opted_out_error is None
assert len(opt_in.bodies) == 3
assert opt_in.bodies[-1] == opted_out.bodies[-1]
@pytest.mark.usefixtures("local_model_cost_map")
@pytest.mark.parametrize(
("model", "model_info", "error"),
[
("us.anthropic.claude-opus-4-8", None, {"message": "messages.0.content: Field required"}),
("us.anthropic.claude-sonnet-4-6", None, _NATIVE_OUTPUT_REJECTION),
("us.anthropic.claude-opus-4-8", _OPTED_OUT, _NATIVE_OUTPUT_REJECTION),
],
ids=["other_400", "native_model", "deployment_opt_out"],
)
async def test_bedrock_messages_400_is_not_retried_unless_it_rejects_opt_in_native_output(
model: str, model_info: Mapping[str, object] | None, error: Mapping[str, object]
) -> None:
bedrock, raised = await _send_bedrock_messages(model, model_info, [(400, error), (200, _ANTHROPIC_MESSAGE)])
assert isinstance(raised, litellm.BadRequestError)
assert len(bedrock.bodies) == 1

View file

@ -1,15 +1,20 @@
import asyncio, importlib, litellm, litellm.litellm_core_utils.get_model_cost_map as bedrock_govcloud_model_cost_map, pytest
from typing import Final
from litellm.llms.bedrock.common_utils import(
AmazonBedrockGlobalConfig,
BedrockModelInfo,
bedrock_native_output_is_opt_in,
deployment_model_info,
extract_model_name_from_bedrock_arn,
get_bedrock_base_model,
get_bedrock_cross_region_inference_regions,
is_bedrock_native_output_rejection,
strip_bedrock_routing_prefix,
strip_bedrock_throughput_suffix,
without_bedrock_native_output_opt_in,
)
from collections.abc import Iterator
from litellm import completion
@ -659,8 +664,14 @@ def test_apply_structured_output_prefers_legacy_output_format(local_model_cost_m
apply_bedrock_invoke_structured_output,
)
legacy = {"type": "json_schema", "schema": {"type": "object", "properties": {"a": {"type": "string"}}}}
newer = {"type": "json_schema", "schema": {"type": "object", "properties": {"b": {"type": "string"}}}}
legacy = {
"type": "json_schema",
"schema": {"type": "object", "properties": {"a": {"type": "string"}}, "additionalProperties": False},
}
newer = {
"type": "json_schema",
"schema": {"type": "object", "properties": {"b": {"type": "string"}}, "additionalProperties": False},
}
body = {
"messages": [{"role": "user", "content": "hi"}],
"output_format": legacy,
@ -2048,3 +2059,70 @@ def test_bedrock_capability_model_reads_base_model_only_for_application_profile_
from litellm.llms.bedrock.common_utils import bedrock_capability_model
assert bedrock_capability_model(model, base_model) == expected
_FORMAT_REJECTION: Final = "The model returned the following errors: output_config.format: Extra inputs are not permitted"
_COMPILE_REJECTION: Final = "The model returned the following errors: compilation exceeded limits: Schema is too complex."
_SCHEMA_PATTERN_REJECTION: Final = (
"The model returned the following errors: output_config.format.schema: Unsupported regex feature in pattern "
"field: pattern is too complex for structured output: reduce the {n,m} upper bound, narrow the character class "
"range, or avoid nesting quantified groups"
)
@pytest.mark.parametrize(
("status_code", "error_text", "expected"),
[
(400, f'{{"message":"{_FORMAT_REJECTION}"}}', True),
(400, f'{{"message":"{_COMPILE_REJECTION}"}}', True),
(400, f'{{"message":"{_SCHEMA_PATTERN_REJECTION}"}}', True),
(400, "Malformed input request: #: extraneous key [output_config] is not permitted", False),
(400, "The model returned the following errors: messages.0.content: Field required", False),
(500, _FORMAT_REJECTION, False),
(429, _COMPILE_REJECTION, False),
],
ids=[
"format_not_permitted",
"schema_too_complex",
"schema_pattern_too_complex",
"other_key_not_permitted",
"other_400",
"500",
"429",
],
)
def test_is_bedrock_native_output_rejection_matches_only_the_400s_that_reject_native_output(
status_code, error_text, expected
):
assert is_bedrock_native_output_rejection(status_code, error_text) is expected
@pytest.mark.parametrize(
("model", "model_info", "expected"),
[
("us.anthropic.claude-opus-4-8", None, True),
("global.anthropic.claude-sonnet-5", None, True),
("us-gov.anthropic.claude-opus-4-8", None, False),
("us.anthropic.claude-opus-4-8", {"supports_bedrock_native_structured_output": False}, False),
("us.anthropic.claude-opus-5-5", {"supports_bedrock_native_structured_output": True}, True),
("us.anthropic.claude-sonnet-4-6", {"supports_bedrock_native_structured_output": True}, False),
],
ids=["opus_4_8", "sonnet_5", "govcloud_opus_4_8", "deployment_opt_out", "deployment_opt_in", "native_model"],
)
def test_bedrock_native_output_is_opt_in_only_where_the_opt_in_key_turns_native_output_on(
local_model_cost_map, model, model_info, expected
):
assert bedrock_native_output_is_opt_in(model, model_info=model_info) is expected
def test_without_bedrock_native_output_opt_in_turns_the_opt_in_off_and_keeps_the_deployment(local_model_cost_map):
litellm_params: Final = {"api_base": "https://bedrock.example", "model_info": {"id": "deployment-1"}}
turned_off: Final = without_bedrock_native_output_opt_in(litellm_params)
assert not bedrock_native_output_is_opt_in(
"us.anthropic.claude-opus-4-8", model_info=deployment_model_info(turned_off)
)
assert turned_off["api_base"] == "https://bedrock.example"
assert deployment_model_info(turned_off)["id"] == "deployment-1"
assert litellm_params["model_info"] == {"id": "deployment-1"}

View file

@ -7,6 +7,7 @@ association via `aws_bedrock_project_id`.
"""
import json
from typing import Final
from unittest.mock import patch
import httpx
@ -17,6 +18,7 @@ from litellm.llms.bedrock.chat.mantle.transformation import AmazonMantleConfig
from litellm.llms.bedrock.messages.mantle_transformation import (
AmazonMantleMessagesConfig,
)
from litellm.llms.bedrock_mantle.chat.claude_transformation import BedrockMantleClaudeChatConfig
# AWS names this header for Mantle workspaces on the Anthropic Messages API, checked 2026-10-02:
# https://docs.aws.amazon.com/bedrock/latest/userguide/workspaces.html
@ -390,6 +392,62 @@ def test_mantle_messages_validate_environment_without_project_id():
assert _MANTLE_WORKSPACE_HEADER not in headers
_MANTLE_CONFIGS: Final = pytest.mark.parametrize(
"config", [AmazonMantleConfig(), BedrockMantleClaudeChatConfig()], ids=["bedrock_mantle_route", "mantle_provider"]
)
_MANTLE_MODEL: Final = "anthropic.claude-opus-4-8"
_CLOSED_RESPONSE_FORMAT: Final = {
"type": "json_schema",
"json_schema": {
"name": "out",
"schema": {"type": "object", "properties": {"a": {"type": "string"}}, "required": ["a"]},
},
}
_OPEN_RESPONSE_FORMAT: Final = {
"type": "json_schema",
"json_schema": {
"name": "out",
"schema": {"type": "object", "properties": {"counts": {"type": "object", "additionalProperties": True}}},
},
}
_LOOKUP_TOOL: Final = {
"type": "function",
"function": {"name": "lookup", "parameters": {"type": "object", "properties": {"id": {"type": "string"}}}},
}
@_MANTLE_CONFIGS
def test_mantle_opt_in_model_keeps_the_json_tool_when_thinking_is_requested(set_native_structured_output, config):
set_native_structured_output(_MANTLE_MODEL, "bedrock")
result: Final = config.map_openai_params(
non_default_params={"response_format": _CLOSED_RESPONSE_FORMAT, "thinking": {"type": "adaptive"}},
optional_params={},
model=_MANTLE_MODEL,
drop_params=False,
)
assert "output_format" not in result
assert [tool["name"] for tool in result["tools"]] == ["json_tool_call"]
@_MANTLE_CONFIGS
def test_mantle_model_native_on_every_request_stays_native_beside_the_callers_tools(
set_native_structured_output, config
):
set_native_structured_output(_MANTLE_MODEL, "legacy")
result: Final = config.map_openai_params(
non_default_params={"response_format": _OPEN_RESPONSE_FORMAT, "tools": [_LOOKUP_TOOL]},
optional_params={},
model=_MANTLE_MODEL,
drop_params=False,
)
assert "output_format" in result
assert [tool["name"] for tool in result["tools"]] == ["lookup"]
def test_mantle_completion_sends_workspace_header_and_clean_body():
import litellm

View file

@ -1288,6 +1288,7 @@ def test_aaamodel_prices_and_context_window_json_is_valid():
"supports_native_streaming": {"type": "boolean"},
"supports_image_size": {"type": "boolean"},
"supports_native_structured_output": {"type": "boolean"},
"supports_bedrock_native_structured_output": {"type": "boolean"},
"use_openai_responses_path": {"type": "boolean"},
"off_peak_pricing": {
"type": "object",

View file

@ -179,6 +179,7 @@ def _build_dispatch_context() -> CompletionDispatchContext:
model="gpt-4o",
model_response=None, # type: ignore[arg-type]
optional_params={},
optional_params_for_model_info=lambda model_info: {},
organization=None,
provider_config=None,
request_params={},

2
uv.lock generated
View file

@ -4572,7 +4572,7 @@ wheels = [
[[package]]
name = "litellm"
version = "1.108.1"
version = "1.108.2"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },