mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-13 08:52:02 +00:00
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:
commit
831a14c8f4
45 changed files with 4784 additions and 264 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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", [])
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
75
litellm/llms/bedrock/chat/native_output_fallback.py
Normal file
75
litellm/llms/bedrock/chat/native_output_fallback.py
Normal 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
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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={},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
149
litellm/main.py
149
litellm/main.py
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
342
tests/integration/sdk/test_bedrock_native_output_fallback_sdk.py
Normal file
342
tests/integration/sdk/test_bedrock_native_output_fallback_sdk.py
Normal 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"
|
||||
|
|
@ -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),
|
||||
)
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
123
tests/unit/llms/bedrock/chat/test_native_output_fallback.py
Normal file
123
tests/unit/llms/bedrock/chat/test_native_output_fallback.py
Normal 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
2
uv.lock
generated
|
|
@ -4572,7 +4572,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "litellm"
|
||||
version = "1.108.1"
|
||||
version = "1.108.2"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue