fix(bedrock): answer 400 for service tiers Bedrock cannot serve, forward reserved (#50516)

* fix(bedrock): answer 400 for service tiers Bedrock cannot serve, forward reserved

Converse used to drop any service_tier outside default, flex, and priority
with no error, rewrite auto to default, and drop reserved. Now reserved and
standard_only map to Bedrock tiers, auto sends no tier, and any other value
answers 400 unless drop_params is on. Nova batch rows honor the row's
drop_params.

* fix(bedrock): keep a Responses batch row's drop_params and keep it out of passthrough rows

* test(bedrock): annotate service tier test locals as Final and drop the row helper's kwargs

* fix(bedrock): refuse a service tier on Nova batch rows unless drop_params, and honor the deployment's drop_params

* fix(bedrock): map standard and any-case service tiers, and drop tiers from Claude batch rows under the deployment's drop_params

* fix(bedrock): forward the tiers OpenAI-format batch rows take, accept auto on Claude rows, and label tiers in lowercase

* fix(bedrock): mark the service tier block field read-only

* fix(bedrock): read drop_params given as a string on batch rows, and cover the Nova row mapper's drop_params

* test(prometheus): sort the service tier label test imports

* fix(bedrock): send the Invoke route's service tier as a header and name the accepted tiers in the 400

* fix(bedrock): report and bill the tier Bedrock served on Invoke calls and streams, and send the tier header only to Nova

* fix(bedrock): return copies when stamping the served tier, read its header without re-encoding the others, and pin it per stream chunk

The Nova Invoke mapper and the event-stream decoder now return model_copy results instead of writing service_tier in place. One shared reader finds the X-Amzn-Bedrock-Service-Tier response header case-insensitively, so a non-ASCII request id no longer breaks the stream decoder, and the passthrough uses the same reader. Tests now vary the served tier in both directions, check every chunk, and cover a stream with no tier header or an empty one

* test(bedrock): check the served tier on a stream whose headers come as a plain dict with an upper-case name

The non-ASCII request id stream test now also carries X-AMZN-BEDROCK-SERVICE-TIER in a plain dict and asserts every chunk reports that tier, so a case-sensitive header reader or a lookup by the canonical name fails it

* refactor(bedrock): drop the passthrough's own service tier header reader that main re-added

The passthrough already reads the served tier through the shared case-insensitive reader in common_utils, so the httpx.Headers copy main brought back with the embeddings change has no callers and references a constant this branch removed

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
quietjules 2026-11-13 17:33:57 -08:00 • committed by GitHub
parent 03f14051b9
commit f5ef14308b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 786 additions and 66 deletions

View file

@ -5,13 +5,14 @@ from typing import Final
from litellm.types.utils import ServiceTier, StandardLoggingPayload
# Tiers a caller may name in a request, across the providers that accept the
# parameter: OpenAI ("auto", "default", "flex", "priority", "scale"), Bedrock and
# Groq (subsets of those), Anthropic ("auto", "standard_only") and Vertex, which
# maps "default" to "standard". Bounds the caller-controlled requested tier
# parameter: OpenAI ("auto", "default", "flex", "priority", "scale"), Bedrock
# ("auto", "default", "flex", "priority", "reserved", "standard", "standard_only"),
# Groq (a subset of OpenAI's), Anthropic ("auto", "standard_only") and Vertex,
# which maps "default" to "standard". Bounds the caller-controlled requested tier
# wherever it is recorded. Derived from ``ServiceTier`` so a tier added there for
# cost calculation cannot go missing here.
KNOWN_REQUEST_SERVICE_TIERS: Final = frozenset(
tuple(tier.value for tier in ServiceTier) + ("batch", "default", "scale", "standard", "standard_only")
tuple(tier.value for tier in ServiceTier) + ("batch", "default", "reserved", "scale", "standard", "standard_only")
)
@ -20,8 +21,7 @@ def get_served_service_tier(standard_logging_payload: StandardLoggingPayload) ->
The tier the provider reports it actually served the request on.
Providers report it either at the top level of the response (OpenAI, Bedrock,
Groq) or on the usage object (Anthropic). Streaming responses carry no served
tier.
Groq) or on the usage object (Anthropic).
"""
response: Final = standard_logging_payload.get("response")
usage_object: Final = standard_logging_payload.get("metadata", {}).get("usage_object")
@ -38,13 +38,14 @@ def get_requested_service_tier(standard_logging_payload: StandardLoggingPayload)
The tier the caller asked for, as sent to the provider.
The value is caller-controlled and survives param mapping even where the
provider then ignores it (Bedrock and Groq accept the request and drop an
unrecognized tier), so it is only reported when it names a known tier.
provider then drops it (Groq for an unrecognized tier, Bedrock for one it
cannot serve under ``drop_params``), so it is only reported when it names a
known tier, in lowercase since Bedrock takes any case.
"""
model_parameters: Final = standard_logging_payload.get("model_parameters")
requested_tier: Final = model_parameters.get("service_tier") if isinstance(model_parameters, dict) else None
if isinstance(requested_tier, str) and requested_tier in KNOWN_REQUEST_SERVICE_TIERS:
return requested_tier
if isinstance(requested_tier, str) and requested_tier.lower() in KNOWN_REQUEST_SERVICE_TIERS:
return requested_tier.lower()
return None

View file

@ -202,9 +202,16 @@ def _tools_the_model_accepts(
_OUTPUT_CONFIG_BLOCK: Final = TypeAdapter(OutputConfigBlock)
_DEEPSEEK_THINKING_REASON: Final = "DeepSeek on Bedrock reasons natively and takes no thinking field."
BEDROCK_SERVICE_TIERS: Final = ("default", "flex", "priority", "reserved")
_BEDROCK_SERVICE_TIER_ALIASES: Final = types.MappingProxyType({"standard": "default", "standard_only": "default"})
_DEEPSEEK_R1_EFFORT_REASON: Final = "DeepSeek R1 on Bedrock always reasons and rejects reasoning_effort."
def bedrock_service_tier_name(requested_tier: str) -> str:
tier: Final = requested_tier.lower()
return _BEDROCK_SERVICE_TIER_ALIASES.get(tier, tier)
def _has_native_text_format(output_config: object) -> bool:
if not isinstance(output_config, Mapping):
return False
@ -1253,8 +1260,13 @@ class AmazonConverseConfig(BaseConfig):
self._map_context_management_param(value, optional_params)
if param == "requestMetadata":
self._map_request_metadata_param(value, optional_params, drop_params)
if param == "service_tier" and isinstance(value, str):
self._map_service_tier_param(value, optional_params)
if param == "service_tier":
self._map_service_tier_param(
REQUEST_PARAMS.validate_python(request_params)["service_tier"],
optional_params,
model,
drop_params,
)
if param == "web_search_options" and isinstance(value, dict):
# Note: we use `isinstance(value, dict)` instead of `value and isinstance(value, dict)`
@ -1308,17 +1320,25 @@ class AmazonConverseConfig(BaseConfig):
if mapped is not None:
optional_params["context_management"] = mapped
def _map_service_tier_param(self, value: str, optional_params: dict) -> None:
"""Map OpenAI service_tier (string) to Bedrock serviceTier (object).
OpenAI values: "auto", "default", "flex", "priority"
Bedrock values: "default", "flex", "priority" (no "auto")
"""
bedrock_tier = value
if value == "auto":
bedrock_tier = "default" # Bedrock doesn't support "auto"
if bedrock_tier in ("default", "flex", "priority"):
optional_params["serviceTier"] = {"type": bedrock_tier}
def _map_service_tier_param(self, value: object, optional_params: dict, model: str, drop_params: bool) -> None:
tier: Final = bedrock_service_tier_name(value) if isinstance(value, str) else None
if value is None or tier == "auto":
return
if tier in BEDROCK_SERVICE_TIERS:
optional_params["serviceTier"] = {"type": tier}
return
if not (litellm.drop_params or drop_params):
raise litellm.utils.UnsupportedParamsError(
message=(
f"{model} does not support service_tier={value!r} on this Bedrock route, which takes "
f"auto, standard, or one of {', '.join(BEDROCK_SERVICE_TIERS)}. "
"To drop unsupported params, set `litellm.drop_params = True`."
),
status_code=400,
)
verbose_logger.warning(
"Dropping service_tier=%r for %s, since this Bedrock route does not serve it.", value, model
)
def _translate_response_format_param(
self,

View file

@ -1,7 +1,7 @@
import json
import types
from collections.abc import AsyncIterator, Iterator, Mapping
from typing import TYPE_CHECKING, Final
from typing import TYPE_CHECKING, Final, TypeVar
import httpx
from pydantic import TypeAdapter, ValidationError
@ -61,6 +61,7 @@ from ..common_utils import (
get_bedrock_stream_event_statuses,
get_bedrock_tool_name,
parse_bedrock_event_stream,
served_service_tier,
)
bedrock_tool_name_mappings: Final[InMemoryCache] = InMemoryCache(max_size_in_memory=50, default_ttl=600)
@ -218,6 +219,15 @@ def _stream_decoder(
return AWSEventStreamDecoder(model=model, json_mode=json_mode)
_StreamChunk: Final = TypeVar("_StreamChunk")
def _with_served_service_tier(chunk: _StreamChunk, served_tier: str | None) -> _StreamChunk:
if served_tier is None or not isinstance(chunk, ModelResponseStream):
return chunk
return chunk.model_copy(update={"service_tier": served_tier})
async def make_call(
client: AsyncHTTPHandler | None,
api_base: str,
@ -851,6 +861,7 @@ class AWSEventStreamDecoder:
event_stream_buffer: Final = EventStreamBuffer()
tally: Final = _EventStreamTally(get_bedrock_stream_event_statuses())
request_id_headers: Final = provider_request_id_headers(response_headers)
served_tier: Final = served_service_tier(response_headers)
for chunk in iterator:
event_stream_buffer.add_data(chunk)
tally.add_chunk(chunk)
@ -859,7 +870,7 @@ class AWSEventStreamDecoder:
if message:
# sse_event = ServerSentEvent(data=message, event="completion")
_data = json.loads(message)
yield self.chunk_parser(chunk_data=_data)
yield _with_served_service_tier(self.chunk_parser(chunk_data=_data), served_tier)
undecoded_stream_error: Final = tally.undecoded_stream_error(response_headers)
if undecoded_stream_error is not None:
raise undecoded_stream_error
@ -876,6 +887,7 @@ class AWSEventStreamDecoder:
event_stream_buffer: Final = EventStreamBuffer()
tally: Final = _EventStreamTally(get_bedrock_stream_event_statuses())
request_id_headers: Final = provider_request_id_headers(response_headers)
served_tier: Final = served_service_tier(response_headers)
async for chunk in iterator:
event_stream_buffer.add_data(chunk)
tally.add_chunk(chunk)
@ -883,7 +895,7 @@ class AWSEventStreamDecoder:
message = self._decode_event(event, tally, request_id_headers)
if message:
_data = json.loads(message)
yield self.chunk_parser(chunk_data=_data)
yield _with_served_service_tier(self.chunk_parser(chunk_data=_data), served_tier)
undecoded_stream_error: Final = tally.undecoded_stream_error(response_headers)
if undecoded_stream_error is not None:
raise undecoded_stream_error

View file

@ -24,6 +24,7 @@ from litellm.types.llms.bedrock import (
from litellm.types.llms.openai import AllMessageValues
from litellm.types.utils import ModelResponse
from ...common_utils import served_service_tier
from ..converse_transformation import AmazonConverseConfig
from .base_invoke_transformation import AmazonInvokeConfig
@ -142,7 +143,7 @@ class AmazonInvokeNovaConfig(AmazonInvokeConfig, AmazonConverseConfig):
api_key: str | None = None,
json_mode: bool | None = None,
) -> ModelResponse:
return AmazonConverseConfig.transform_response(
response: Final = AmazonConverseConfig.transform_response(
self,
model,
raw_response,
@ -156,6 +157,10 @@ class AmazonInvokeNovaConfig(AmazonInvokeConfig, AmazonConverseConfig):
api_key,
json_mode,
)
served_tier: Final = served_service_tier(raw_response.headers)
if served_tier is None:
return response
return response.model_copy(update={"service_tier": served_tier})
@staticmethod
def _inline_cache_points(request: BedrockInvokeNovaRequest) -> BedrockInvokeNovaRequest:

View file

@ -22,7 +22,7 @@ from litellm.litellm_core_utils.prompt_templates.factory import (
from litellm.litellm_core_utils.url_utils import join_api_base
from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException
from litellm.llms.bedrock.chat.invoke_handler import make_call, make_sync_call
from litellm.llms.bedrock.common_utils import BedrockError
from litellm.llms.bedrock.common_utils import BEDROCK_SERVICE_TIER_HEADER, BedrockError
from litellm.llms.bedrock.request_metadata import (
bedrock_request_metadata_headers,
merge_bedrock_invoke_headers,
@ -67,8 +67,13 @@ class _LlamaInvokeResponse(TypedDict):
generation: ReadOnly[str | None]
class _InvokeServiceTierParams(TypedDict):
serviceTier: NotRequired[ReadOnly[object]]
_COHERE_INVOKE_RESPONSE: Final = TypeAdapter(_CohereInvokeResponse)
_LLAMA_INVOKE_RESPONSE: Final = TypeAdapter(_LlamaInvokeResponse)
_INVOKE_SERVICE_TIER_PARAMS: Final = TypeAdapter(_InvokeServiceTierParams)
_GUARDRAIL_CONFIG_EXPECTED_FORMAT: Final = (
"{'guardrailIdentifier': str, 'guardrailVersion': str, 'trace': 'enabled'|'disabled'|'enabled_full'}"
@ -79,6 +84,14 @@ def _response_header(headers: Mapping[str, str], name: str) -> str | None:
return headers.get(name, None)
def _bedrock_invoke_service_tier_headers(optional_params: _InvokeServiceTierParams) -> tuple[tuple[str, str], ...]:
match optional_params.get("serviceTier"):
case {"type": str(tier)}:
return ((BEDROCK_SERVICE_TIER_HEADER, tier),)
case _:
return ()
def bedrock_invoke_guardrail_headers(raw_guardrail_config: object) -> "dict[str, str]":
try:
guardrail_config: Final = _GUARDRAIL_CONFIG_VALIDATOR.validate_python(raw_guardrail_config)
@ -455,8 +468,15 @@ class AmazonInvokeConfig(BaseConfig, BaseAWSLLM):
if raw_guardrail_config is None
else tuple(bedrock_invoke_guardrail_headers(raw_guardrail_config).items())
)
service_tier_headers: Final = (
_bedrock_invoke_service_tier_headers(_INVOKE_SERVICE_TIER_PARAMS.validate_python(optional_params))
if self.get_bedrock_invoke_provider(model) == "nova"
else ()
)
owned_names, metadata_headers = bedrock_request_metadata_headers(litellm_params)
return merge_bedrock_invoke_headers(headers, guardrail_headers, metadata_headers, owned_names)
return merge_bedrock_invoke_headers(
headers, (*guardrail_headers, *service_tier_headers), metadata_headers, owned_names
)
def get_error_class(self, error_message: str, status_code: int, headers: dict | httpx.Headers) -> BaseLLMException:
return BedrockError(status_code=status_code, message=error_message, headers=headers)

View file

@ -58,6 +58,7 @@ if TYPE_CHECKING:
from litellm.types.llms.openai import AllMessageValues
BEDROCK_INVOKE_TRACE_FIELD: Final = "amazon-bedrock-trace"
BEDROCK_SERVICE_TIER_HEADER: Final = "X-Amzn-Bedrock-Service-Tier"
BEDROCK_INVOKE_CONTEXT_MANAGEMENT_EDIT_BETAS: Final[Mapping[str, str]] = MappingProxyType(
{
"compact_20260112": ANTHROPIC_BETA_HEADER_VALUES.COMPACT_2026_01_12.value,
@ -86,6 +87,13 @@ BedrockRoute = Literal[
]
def served_service_tier(response_headers: Mapping[str, str] | None) -> str | None:
tier_header: Final = BEDROCK_SERVICE_TIER_HEADER.lower()
return next(
(value for name, value in (response_headers or {}).items() if name.lower() == tier_header and value), None
)
def error_response_text(response: httpx.Response) -> str:
try:
return response.text

View file

@ -24,7 +24,7 @@ from typing_extensions import TypedDict as ExtraItemsTypedDict
from litellm._logging import verbose_logger
from litellm._uuid import uuid
from litellm.constants import BEDROCK_INVOKE_PROVIDERS_LITERAL
from litellm.exceptions import BadRequestError
from litellm.exceptions import BadRequestError, UnsupportedParamsError
from litellm.files.utils import FilesAPIUtils
from litellm.litellm_core_utils.aws_partition import get_aws_dns_suffix
from litellm.litellm_core_utils.cloud_storage_security import (
@ -40,7 +40,7 @@ from litellm.litellm_core_utils.cloud_storage_security import (
split_configured_cloud_bucket_name,
validate_managed_cloud_file_id,
)
from litellm.litellm_core_utils.core_helpers import require_attributes
from litellm.litellm_core_utils.core_helpers import normalize_drop_params, require_attributes
from litellm.litellm_core_utils.prompt_templates.common_utils import (
text_completion_prompt_to_messages,
)
@ -49,7 +49,7 @@ from litellm.litellm_core_utils.prompt_templates.file_id_utils import (
)
from litellm.llms.anthropic.common_utils import AnthropicError
from litellm.llms.base_llm.base_utils import map_developer_role_to_system_role
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.base_llm.chat.transformation import REQUEST_PARAMS, BaseLLMException
from litellm.llms.base_llm.files.batch_records import responses_batch_body_to_chat_body
from litellm.llms.base_llm.files.transformation import (
BaseFilesConfig,
@ -391,6 +391,42 @@ def _batch_line_error(line_number: int, problem: str) -> BedrockError:
return _client_error(f"Batch input file line {line_number} {problem}")
_OPENAI_FORMAT_INVOKE_SERVICE_TIERS: Final = frozenset({"default", "flex", "priority"})
def _check_batch_record_service_tier(
value: object, model: str, drop_params: bool, accepted_tiers: frozenset[str] = frozenset()
) -> None:
import litellm
if value is None or (isinstance(value, str) and value.lower() == "auto"):
return
if not (litellm.drop_params or drop_params):
raise UnsupportedParamsError(
message=(
f"{model} batch records do not support service_tier={value!r}. "
+ (
f"Their InvokeModel body takes only {', '.join(sorted(accepted_tiers))}. "
if accepted_tiers
else "A Bedrock batch record for this model is an InvokeModel body, which takes no service tier. "
)
+ "To drop unsupported params, set `litellm.drop_params = True`."
),
status_code=400,
)
verbose_logger.warning("Dropping service_tier=%r from a %s batch record, since Bedrock rejects it.", value, model)
def _openai_format_batch_record_service_tier(value: object, model: str, drop_params: bool) -> Mapping[str, str]:
from litellm.llms.bedrock.chat.converse_transformation import bedrock_service_tier_name
tier: Final = bedrock_service_tier_name(value) if isinstance(value, str) else None
if tier in _OPENAI_FORMAT_INVOKE_SERVICE_TIERS:
return MappingProxyType({"service_tier": tier})
_check_batch_record_service_tier(value, model, drop_params, _OPENAI_FORMAT_INVOKE_SERVICE_TIERS)
return MappingProxyType({})
def _decode_batch_input(content: str | bytes) -> str:
if isinstance(content, str):
return content.removeprefix("\ufeff")
@ -1047,7 +1083,11 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig):
model), so `input`, `instructions`, `max_output_tokens` and the tool
params translate identically in batch and real time.
"""
return responses_batch_body_to_chat_body(openai_request_body)
chat_body: Final = responses_batch_body_to_chat_body(openai_request_body)
drop_params: Final = openai_request_body.get("drop_params")
if drop_params is None:
return chat_body
return _frozen_mapping(chain(chat_body.items(), (("drop_params", drop_params),)))
@staticmethod
def _transform_batch_body_to_chat_body(
@ -1073,6 +1113,7 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig):
openai_request_body: Mapping[str, Any],
model: str,
provider: str | None = None,
drop_params: bool = False,
model_info: Mapping[str, object] | None = None,
) -> dict[str, object]:
"""
@ -1088,6 +1129,10 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig):
messages: Final = map_developer_role_to_system_role(openai_request_body.get("messages", []))
request_params: Final[Mapping[str, object]] = openai_request_body
optional_params: Final = {k: v for k, v in request_params.items() if k not in ["model", "messages"]}
drop_unsupported_params: Final = (
drop_params or normalize_drop_params(optional_params.get("drop_params")) is True
)
requested_service_tier: Final = REQUEST_PARAMS.validate_python(optional_params).get("service_tier")
# --- Anthropic: use existing AmazonAnthropicClaudeConfig ---
if provider == LlmProviders.ANTHROPIC:
@ -1095,6 +1140,7 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig):
AmazonAnthropicClaudeConfig,
)
_check_batch_record_service_tier(requested_service_tier, model, drop_unsupported_params)
config: Final = AmazonAnthropicClaudeConfig()
litellm_params: Final = without_bedrock_native_output_opt_in(
{
@ -1114,8 +1160,10 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig):
**{
k: v
for k, v in optional_params.items()
if not is_litellm_owned_kwarg(k) or k in _LITELLM_PARAMS_THE_MAPPER_TAKES
if k != "service_tier"
and (not is_litellm_owned_kwarg(k) or k in _LITELLM_PARAMS_THE_MAPPER_TAKES)
}
| ({"drop_params": True} if drop_params else {})
),
)
return config.transform_request(
@ -1136,12 +1184,15 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig):
AmazonConverseConfig,
)
_check_batch_record_service_tier(requested_service_tier, model, drop_unsupported_params)
converse_config: Final = AmazonConverseConfig()
mapped_params = converse_config.map_openai_params(
non_default_params={k: v for k, v in optional_params.items() if k != "requestMetadata"},
non_default_params={
k: v for k, v in optional_params.items() if k not in ("requestMetadata", "service_tier")
},
optional_params={},
model=model,
drop_params=False,
drop_params=drop_unsupported_params,
)
return converse_config.transform_request(
model=model,
@ -1155,7 +1206,8 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig):
# like openai.gpt-oss-*, qwen, deepseek, etc.) ---
return {
"messages": messages,
**optional_params,
**{k: v for k, v in optional_params.items() if k not in ("drop_params", "service_tier")},
**_openai_format_batch_record_service_tier(requested_service_tier, model, drop_unsupported_params),
}
def _resolve_batch_record_model_and_provider(
@ -1203,17 +1255,25 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig):
batch_input_lines: Sequence[_BatchInputLine],
target_model: str = "",
model_info: Mapping[str, object] | None = None,
drop_params: bool = False,
) -> tuple[_BedrockBatchRecord, ...]:
return tuple(
self._transform_batch_input_line(idx, batch_input_line, target_model, model_info)
self._transform_batch_input_line(idx, batch_input_line, target_model, model_info, drop_params)
for idx, batch_input_line in enumerate(batch_input_lines)
)
def _transform_batch_input_line(
self, idx: int, batch_input_line: _BatchInputLine, target_model: str, model_info: Mapping[str, object] | None
self,
idx: int,
batch_input_line: _BatchInputLine,
target_model: str,
model_info: Mapping[str, object] | None,
drop_params: bool,
) -> _BedrockBatchRecord:
try:
return self._transform_openai_batch_record(idx, batch_input_line.record, target_model, model_info)
return self._transform_openai_batch_record(
idx, batch_input_line.record, target_model, model_info, drop_params
)
except _BATCH_RECORD_CONVERSION_ERRORS as e:
raise _batch_line_error(
batch_input_line.line_number, f"could not be converted to a Bedrock batch record: {e}"
@ -1225,6 +1285,7 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig):
openai_record: _OpenAIBatchRecord,
target_model: str,
model_info: Mapping[str, object] | None = None,
drop_params: bool = False,
) -> _BedrockBatchRecord:
import litellm
@ -1249,6 +1310,7 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig):
model=model_for_transform,
provider=provider,
model_info=model_info,
drop_params=drop_params,
)
)
return _BedrockBatchRecord(
@ -1293,8 +1355,12 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig):
batch_input_lines: Final = _parse_batch_jsonl(original_file_content)
litellm_params_model: Final = litellm_params.get("model")
target_model: Final = model or (litellm_params_model if isinstance(litellm_params_model, str) else "")
deployment_drop_params: Final = REQUEST_PARAMS.validate_python(litellm_params).get("drop_params")
bedrock_jsonl_content: Final = self.transform_batch_input_lines(
batch_input_lines, target_model=target_model, model_info=_upload_deployment_model_info(litellm_params)
batch_input_lines,
target_model=target_model,
model_info=_upload_deployment_model_info(litellm_params),
drop_params=normalize_drop_params(deployment_drop_params) is True,
)
file_content = "\n".join(json.dumps(item) for item in bedrock_jsonl_content)
elif isinstance(extracted_file_data_content, bytes):

View file

@ -31,6 +31,7 @@ from ..common_utils import (
BedrockError,
BedrockEventStreamDecoderBase,
BedrockModelInfo,
served_service_tier,
)
from ..request_metadata import (
BEDROCK_REQUEST_METADATA_HEADER,
@ -59,7 +60,6 @@ _BEDROCK_INVOKE_ACTIONS: Final = frozenset({"invoke", "invoke-with-response-stre
_BEDROCK_INVOKE_GUARDRAIL_HEADERS: Final = frozenset(
{"x-amzn-bedrock-guardrailidentifier", "x-amzn-bedrock-guardrailversion", "x-amzn-bedrock-trace"}
)
_BEDROCK_SERVICE_TIER_HEADER: Final = "x-amzn-bedrock-service-tier"
_NO_RESPONSE_HEADERS: Final[Mapping[str, str]] = MappingProxyType({})
_TieredResponse: Final = TypeVar("_TieredResponse", bound=BaseModel)
@ -423,13 +423,6 @@ def _invoke_embedding_response(model: str, httpx_response: Response, has_input_i
)
def _served_service_tier(response_headers: Mapping[str, str]) -> str | None:
headers: Final = httpx.Headers(response_headers)
if _BEDROCK_SERVICE_TIER_HEADER not in headers:
return None
return headers[_BEDROCK_SERVICE_TIER_HEADER]
def _with_served_service_tier(response: _TieredResponse, service_tier: str | None) -> _TieredResponse:
if service_tier is None:
return response
@ -659,7 +652,7 @@ class BedrockPassthroughConfig(BaseAWSLLM, BedrockModelInfo, BedrockEventStreamD
if "invoke" in endpoint:
logging_obj.record_native_bedrock_guardrail(_invoke_guardrail_trace(httpx_response))
return _with_served_service_tier(litellm_model_response, _served_service_tier(httpx_response.headers))
return _with_served_service_tier(litellm_model_response, served_service_tier(httpx_response.headers))
def create_stream_collector(
self,
@ -672,7 +665,7 @@ class BedrockPassthroughConfig(BaseAWSLLM, BedrockModelInfo, BedrockEventStreamD
return BedrockEventStreamCollector(
parse_event=self._parse_message_from_event,
decoder=self._get_event_stream_decoder(model=model, endpoint=endpoint),
service_tier=_served_service_tier(response_headers),
service_tier=served_service_tier(response_headers),
)
def _get_event_stream_decoder(self, model: str, endpoint: str) -> Optional["AWSEventStreamDecoder"]:

View file

@ -244,7 +244,7 @@ class ConverseTokenUsageBlock(TypedDict, total=False):
class ServiceTierBlock(TypedDict):
type: Literal["priority", "default", "flex"]
type: ReadOnly[Literal["priority", "default", "flex", "reserved"]]
class ConverseResponseBlock(TypedDict, total=False):

View file

@ -10,6 +10,7 @@ Run with:
"""
import datetime
from typing import Final
import pytest
@ -18,6 +19,7 @@ from litellm.litellm_core_utils.service_tier_utils import (
KNOWN_REQUEST_SERVICE_TIERS,
get_service_tier_from_standard_logging_payload,
)
from litellm.llms.bedrock.chat.converse_transformation import BEDROCK_SERVICE_TIERS
from litellm.types.integrations.prometheus import (
PrometheusMetricLabels,
UserAPIKeyLabelNames,
@ -138,7 +140,7 @@ def test_requested_tier_used_when_no_served_tier():
def test_unrecognized_requested_tier_is_not_labelled():
"""
A caller-supplied tier survives param mapping even where the provider then
ignores it (Bedrock and Groq drop an unrecognized tier and still answer), so
ignores it (Groq drops an unrecognized tier and still answers), so
labelling it verbatim would let one caller mint a series per string.
"""
payload = _standard_logging_payload(model_parameters={"service_tier": "attacker-controlled-a1b2c3"})
@ -166,6 +168,20 @@ def test_every_known_requested_tier_is_labelled(tier):
assert get_service_tier_from_standard_logging_payload(payload) == tier
@pytest.mark.parametrize("tier", BEDROCK_SERVICE_TIERS)
def test_every_tier_bedrock_forwards_is_labelled(tier: str):
payload: Final = _standard_logging_payload(model_parameters={"service_tier": tier})
assert get_service_tier_from_standard_logging_payload(payload) == tier
@pytest.mark.parametrize("tier", ["Priority", "FLEX", "Reserved"])
def test_a_requested_tier_in_any_case_is_labelled_in_lowercase(tier: str):
payload: Final = _standard_logging_payload(model_parameters={"service_tier": tier})
assert get_service_tier_from_standard_logging_payload(payload) == tier.lower()
@pytest.mark.parametrize(
"response, usage_object, model_parameters",
[

View file

@ -1,11 +1,16 @@
import json
from collections.abc import Mapping
from typing import Final
from unittest.mock import MagicMock
import httpx
import pytest
import litellm
from litellm.llms.bedrock.chat.invoke_transformations.amazon_nova_transformation import (
AmazonInvokeNovaConfig,
)
from litellm.llms.custom_httpx.http_handler import HTTPHandler
from litellm.types.integrations.anthropic_cache_control_hook import GATEWAY_INJECTED_CACHE_METADATA_KEY
MODEL = "us.amazon.nova-pro-v1:0"
@ -134,3 +139,63 @@ def test_caller_request_metadata_is_ignored_because_invoke_model_has_no_field_fo
assert "requestMetadata" not in request
assert request["inferenceConfig"] == {"maxTokens": 8}
@pytest.mark.parametrize(
("service_tier", "sent_tier"),
[("priority", "priority"), ("flex", "flex"), ("reserved", "reserved"), ("Standard", "default"), ("auto", None)],
)
def test_invoke_sends_the_service_tier_in_the_header_invoke_model_takes(service_tier: str, sent_tier: str | None):
send: Final = MagicMock(return_value=httpx.Response(200))
client: Final = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(send)))
litellm.completion(
model=f"bedrock/invoke/{MODEL}",
messages=[{"role": "user", "content": "hi"}],
stream=True,
service_tier=service_tier,
client=client,
aws_access_key_id="fake",
aws_secret_access_key="fake",
aws_region_name="us-east-1",
)
request: Final = send.call_args.args[0]
assert request.headers.get("X-Amzn-Bedrock-Service-Tier") == sent_tier
assert "serviceTier" not in json.loads(request.content)
@pytest.mark.parametrize(("requested_tier", "served_tier"), [("priority", "flex"), ("flex", "priority")])
def test_invoke_reports_and_bills_the_tier_bedrock_served_over_the_tier_requested(
requested_tier: str, served_tier: str
) -> None:
reply: Final = {
"output": {"message": {"role": "assistant", "content": [{"text": "hi"}]}},
"stopReason": "end_turn",
"usage": {"inputTokens": 1000, "outputTokens": 1000, "totalTokens": 2000},
}
def invoke(tier: Mapping[str, object]) -> litellm.ModelResponse:
send: Final = MagicMock(
return_value=httpx.Response(200, json=reply, headers={"X-Amzn-Bedrock-Service-Tier": served_tier})
)
return litellm.completion(
model=f"bedrock/invoke/{MODEL}",
messages=[{"role": "user", "content": "hi"}],
client=HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(send))),
aws_access_key_id="fake",
aws_secret_access_key="fake",
aws_region_name="us-east-1",
**tier,
)
native_requested: Final = invoke({"serviceTier": {"type": requested_tier}})
openai_requested: Final = invoke({"service_tier": requested_tier})
openai_served: Final = invoke({"service_tier": served_tier})
assert native_requested.service_tier == openai_requested.service_tier == served_tier
assert (
native_requested._hidden_params["response_cost"]
== openai_requested._hidden_params["response_cost"]
== openai_served._hidden_params["response_cost"]
)

View file

@ -654,3 +654,30 @@ def test_guarded_non_claude_invoke_stream_yields_only_the_text_and_stop_chunks(
([(choice.delta.content, choice.finish_reason) for choice in chunk.choices], chunk.provider_specific_fields or {})
for chunk in stream
] == [([("hi", None)], {}), ([(None, "stop")], {})]
@pytest.mark.parametrize(
"model",
[
"anthropic.claude-sonnet-4-6",
"amazon.titan-text-express-v1",
"mistral.mistral-7b-instruct-v0:2",
],
)
def test_a_native_service_tier_sends_no_tier_header_to_invoke_models_whose_replies_do_not_report_it(model: str) -> None:
send: Final = MagicMock(return_value=httpx.Response(200))
client: Final = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(send)))
litellm.completion(
model=f"bedrock/invoke/{model}",
messages=[{"role": "user", "content": "hi"}],
stream=True,
max_tokens=10,
client=client,
aws_access_key_id="fake",
aws_secret_access_key="fake",
aws_region_name="us-east-1",
serviceTier={"type": "priority"},
)
assert "X-Amzn-Bedrock-Service-Tier" not in send.call_args.args[0].headers

View file

@ -1539,16 +1539,23 @@ async def test_aiter_bytes_stream_error_carries_only_the_provider_request_id_hea
@pytest.mark.parametrize("request_id", ("req-\u00fc-1", "req-\udcfc-1"), ids=("latin-1", "surrogate-escaped"))
def test_iter_bytes_streams_and_fails_normally_with_a_non_ascii_provider_request_id(request_id: str) -> None:
def test_iter_bytes_streams_the_served_tier_and_fails_normally_with_a_non_ascii_provider_request_id(
request_id: str,
) -> None:
decoder: Final = AWSEventStreamDecoder(model="us.moonshotai.kimi-k3")
headers: Final = {"content-type": "application/vnd.amazon.eventstream", "x-amzn-RequestId": request_id}
headers: Final = {
"content-type": "application/vnd.amazon.eventstream",
"x-amzn-RequestId": request_id,
"X-AMZN-BEDROCK-SERVICE-TIER": "flex",
}
chunks: Final = list(decoder.iter_bytes(iter(_CONVERSE_CONTENT_FRAMES), response_headers=headers))
with pytest.raises(BedrockError) as exc_info:
list(decoder.iter_bytes(iter([_unknown_event_frame()]), response_headers=headers))
texts: Final = [chunk.choices[0].delta.content for chunk in chunks if isinstance(chunk, ModelResponseStream)]
assert "".join(text or "" for text in texts) == "hi"
stream_chunks: Final = [chunk for chunk in chunks if isinstance(chunk, ModelResponseStream)]
assert "".join(chunk.choices[0].delta.content or "" for chunk in stream_chunks) == "hi"
assert {chunk.service_tier for chunk in stream_chunks} == {"flex"}
assert exc_info.value.status_code == 502
assert exc_info.value.headers is None
@ -2084,3 +2091,110 @@ def test_invoke_stream_decoder_carries_the_guardrail_trace_of_the_message_stop_c
assert (plain.provider_specific_fields or {}).get("amazon-bedrock-trace") is None
assert traced.provider_specific_fields is not None
assert traced.provider_specific_fields["amazon-bedrock-trace"] == trace
NOVA_INVOKE_STREAM_EVENTS: Final = (
{"messageStart": {"role": "assistant"}},
{"contentBlockDelta": {"contentBlockIndex": 0, "delta": {"text": "hi"}}},
{"contentBlockStop": {"contentBlockIndex": 0}},
{"messageStop": {"stopReason": "end_turn"}},
{"metadata": {"usage": {"inputTokens": 1000, "outputTokens": 1000, "totalTokens": 2000}, "metrics": {}}},
)
def _nova_invoke(served_tier: str | None) -> httpx.MockTransport:
tier_header: Final = {} if served_tier is None else {"X-Amzn-Bedrock-Service-Tier": served_tier}
def reply(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/invoke-with-response-stream"):
return httpx.Response(
200,
headers={"content-type": "application/vnd.amazon.eventstream", **tier_header},
content=b"".join(_bedrock_event_stream_frame(event) for event in NOVA_INVOKE_STREAM_EVENTS),
)
return httpx.Response(
200,
headers=tier_header,
json={
"output": {"message": {"role": "assistant", "content": [{"text": "hi"}]}},
"stopReason": "end_turn",
"usage": {"inputTokens": 1000, "outputTokens": 1000, "totalTokens": 2000},
},
)
return httpx.MockTransport(reply)
async def _nova_invoke_calls(
requested_tier: Mapping[str, object], served_tier: str | None
) -> tuple[litellm.ModelResponse, Sequence[ModelResponseStream], Sequence[ModelResponseStream]]:
def completion(stream: bool) -> object:
return litellm.completion(
model="bedrock/invoke/us.amazon.nova-pro-v1:0",
messages=[{"role": "user", "content": "hi"}],
stream=stream,
client=HTTPHandler(client=httpx.Client(transport=_nova_invoke(served_tier))),
aws_access_key_id="fake",
aws_secret_access_key="fake",
aws_region_name="us-east-1",
**requested_tier,
)
async_stream: Final = await litellm.acompletion(
model="bedrock/invoke/us.amazon.nova-pro-v1:0",
messages=[{"role": "user", "content": "hi"}],
stream=True,
client=AsyncHTTPHandler(transport=_nova_invoke(served_tier)),
aws_access_key_id="fake",
aws_secret_access_key="fake",
aws_region_name="us-east-1",
**requested_tier,
)
non_streaming: Final = completion(stream=False)
assert isinstance(non_streaming, litellm.ModelResponse)
return non_streaming, tuple(completion(stream=True)), tuple([chunk async for chunk in async_stream])
@pytest.mark.parametrize(
("requested_tier", "served_tier"),
[
pytest.param({"serviceTier": {"type": "priority"}}, "flex", id="native"),
pytest.param({"service_tier": "priority"}, "flex", id="openai"),
pytest.param({"service_tier": "flex"}, "priority", id="openai-served-priority"),
],
)
@pytest.mark.asyncio
async def test_a_nova_invoke_stream_reports_and_bills_the_tier_bedrock_served_like_a_non_streaming_call(
requested_tier: Mapping[str, object], served_tier: str
) -> None:
non_streaming, sync_chunks, async_chunks = await _nova_invoke_calls(requested_tier, served_tier)
sync_streamed: Final = litellm.stream_chunk_builder(list(sync_chunks))
async_streamed: Final = litellm.stream_chunk_builder(list(async_chunks))
assert {getattr(chunk, "service_tier", None) for chunk in (*sync_chunks, *async_chunks)} == {served_tier}
assert sync_streamed.service_tier == async_streamed.service_tier == non_streaming.service_tier == served_tier
assert (
litellm.completion_cost(sync_streamed)
== litellm.completion_cost(async_streamed)
== non_streaming._hidden_params["response_cost"]
)
@pytest.mark.parametrize(
"requested_tier",
[
pytest.param({"serviceTier": {"type": "priority"}}, id="native"),
pytest.param({"service_tier": "priority"}, id="openai"),
],
)
@pytest.mark.parametrize("served_tier", [pytest.param(None, id="no-header"), pytest.param("", id="empty-header")])
@pytest.mark.asyncio
async def test_a_nova_invoke_stream_without_a_served_tier_reports_no_tier(
requested_tier: Mapping[str, object], served_tier: str | None
) -> None:
non_streaming, sync_chunks, async_chunks = await _nova_invoke_calls(requested_tier, served_tier)
assert {getattr(chunk, "service_tier", None) for chunk in (*sync_chunks, *async_chunks)} == {None}
assert getattr(litellm.stream_chunk_builder(list(sync_chunks)), "service_tier", None) is None
assert getattr(litellm.stream_chunk_builder(list(async_chunks)), "service_tier", None) is None
assert getattr(non_streaming, "service_tier", None) is None

View file

@ -3,11 +3,14 @@ Tests for Bedrock Converse API serviceTier support.
"""
import json
from typing import Final
from unittest.mock import MagicMock
import pytest
import litellm
from litellm.llms.bedrock.chat.converse_transformation import AmazonConverseConfig
from litellm.llms.custom_httpx.http_handler import HTTPHandler
from litellm.types.llms.bedrock import ServiceTierBlock
@ -201,24 +204,165 @@ def test_map_openai_service_tier_flex():
assert result["serviceTier"] == {"type": "flex"}
def test_map_openai_service_tier_auto_maps_to_default():
"""Test that OpenAI service_tier='auto' maps to Bedrock serviceTier='default'.
def test_map_openai_service_tier_auto_sends_no_tier():
"""`auto` picks no tier, so Bedrock gets no serviceTier and serves its own default."""
config: Final = AmazonConverseConfig()
Bedrock doesn't support 'auto', so we map it to 'default'.
"""
config = AmazonConverseConfig()
result = config.map_openai_params(
result: Final = config.map_openai_params(
non_default_params={"service_tier": "auto"},
optional_params={},
model="anthropic.claude-3-sonnet-20240229-v1:0",
drop_params=False,
)
assert "serviceTier" in result
assert "serviceTier" not in result
def test_map_openai_service_tier_none_sends_no_tier():
config: Final = AmazonConverseConfig()
result: Final = config.map_openai_params(
non_default_params={"service_tier": None},
optional_params={},
model="anthropic.claude-3-sonnet-20240229-v1:0",
drop_params=False,
)
assert "serviceTier" not in result
def test_map_openai_service_tier_reserved_reaches_the_request():
config: Final = AmazonConverseConfig()
model: Final = "anthropic.claude-3-sonnet-20240229-v1:0"
optional_params: Final = config.map_openai_params(
non_default_params={"service_tier": "reserved"},
optional_params={},
model=model,
drop_params=False,
)
request: Final = config.transform_request(
model=model,
messages=[{"role": "user", "content": "Hello!"}],
optional_params=optional_params,
litellm_params={},
headers={},
)
assert request["serviceTier"] == {"type": "reserved"}
def test_map_openai_service_tier_standard_only_maps_to_default():
"""`standard_only` is the Anthropic Messages value that means Bedrock's Standard tier."""
config: Final = AmazonConverseConfig()
result: Final = config.map_openai_params(
non_default_params={"service_tier": "standard_only"},
optional_params={},
model="anthropic.claude-3-sonnet-20240229-v1:0",
drop_params=False,
)
assert result["serviceTier"] == {"type": "default"}
@pytest.mark.parametrize(
("value", "bedrock_tier"),
[("standard", "default"), ("Standard_Only", "default"), ("Priority", "priority"), ("FLEX", "flex")],
)
def test_map_openai_service_tier_maps_standard_and_any_case_to_the_bedrock_tier(value: str, bedrock_tier: str):
config: Final = AmazonConverseConfig()
result: Final = config.map_openai_params(
non_default_params={"service_tier": value},
optional_params={},
model="anthropic.claude-3-sonnet-20240229-v1:0",
drop_params=False,
)
assert result["serviceTier"] == {"type": bedrock_tier}
def test_map_openai_service_tier_auto_in_any_case_sends_no_tier():
config: Final = AmazonConverseConfig()
result: Final = config.map_openai_params(
non_default_params={"service_tier": "AUTO"},
optional_params={},
model="anthropic.claude-3-sonnet-20240229-v1:0",
drop_params=False,
)
assert "serviceTier" not in result
@pytest.mark.parametrize("value", ["ultrafast", "fast", "scale", "", {"type": "priority"}, ["fast"], 1])
def test_map_openai_service_tier_unmapped_value_answers_400(value: object):
config: Final = AmazonConverseConfig()
optional_params: Final = dict[str, object]()
with pytest.raises(litellm.utils.UnsupportedParamsError) as error:
config.map_openai_params(
non_default_params={"service_tier": value},
optional_params=optional_params,
model="anthropic.claude-3-sonnet-20240229-v1:0",
drop_params=False,
)
assert error.value.status_code == 400
assert f"service_tier={value!r}" in error.value.message
assert "default, flex, priority, reserved" in error.value.message
assert "drop_params" in error.value.message
assert "serviceTier" not in optional_params
@pytest.mark.parametrize("value", ["ultrafast", {"type": "priority"}])
def test_map_openai_service_tier_unmapped_value_drops_under_request_drop_params(value: object):
config: Final = AmazonConverseConfig()
result: Final = config.map_openai_params(
non_default_params={"service_tier": value},
optional_params={},
model="anthropic.claude-3-sonnet-20240229-v1:0",
drop_params=True,
)
assert "serviceTier" not in result
def test_map_openai_service_tier_unmapped_value_drops_under_global_drop_params(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(litellm, "drop_params", True)
config: Final = AmazonConverseConfig()
result: Final = config.map_openai_params(
non_default_params={"service_tier": "ultrafast"},
optional_params={},
model="anthropic.claude-3-sonnet-20240229-v1:0",
drop_params=False,
)
assert "serviceTier" not in result
@pytest.mark.parametrize("model", ["bedrock/converse/us.openai.gpt-6-luna", "bedrock/converse/us.amazon.nova-2-lite-v1:0"])
def test_completion_refuses_unmapped_service_tier_before_calling_bedrock(model: str, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(litellm, "drop_params", False)
client: Final = MagicMock(spec=HTTPHandler)
with pytest.raises(litellm.BadRequestError) as error:
litellm.completion(
model=model,
messages=[{"role": "user", "content": "Hello!"}],
service_tier="ultrafast",
aws_region_name="us-east-1",
client=client,
)
assert error.value.status_code == 400
assert "service_tier='ultrafast'" in error.value.message
client.post.assert_not_called()
# Tests for service_tier in response

View file

@ -9,6 +9,7 @@ from contextlib import AsyncExitStack, closing
from datetime import datetime, timezone
from io import BytesIO
from pathlib import Path
from types import MappingProxyType
from typing import Final
from unittest.mock import MagicMock
from urllib.parse import unquote, urlparse
@ -98,6 +99,234 @@ class TestBedrockFilesTransformation:
assert "_litellm_undeclared_sentinel" not in model_input, model_input
assert result[0]["modelInput"]["max_tokens"] == 10
@staticmethod
def _nova_row(
url: str = "/v1/chat/completions",
model: str = "us.amazon.nova-pro-v1:0",
body_params: Mapping[str, object] = MappingProxyType({}),
) -> Mapping[str, object]:
prompt: Final = {"input": "hi"} if url == "/v1/responses" else {"messages": [{"role": "user", "content": "hi"}]}
return {
"custom_id": "nova-tier-1",
"method": "POST",
"url": url,
"body": {"model": model, **prompt, **body_params},
}
@pytest.mark.parametrize("tier", [None, "auto", "AUTO"])
def test_nova_row_with_null_or_auto_service_tier_sends_no_tier(self, tier: str | None):
result: Final = BedrockFilesConfig().transform_openai_jsonl_content_to_bedrock_jsonl_content(
[self._nova_row(body_params={"service_tier": tier})]
)
assert "serviceTier" not in result[0]["modelInput"]
@pytest.mark.parametrize("tier", ["ultrafast", "reserved", "priority", "standard_only"])
@pytest.mark.parametrize("url", ["/v1/chat/completions", "/v1/responses"])
def test_nova_row_with_a_service_tier_fails_the_row_without_drop_params(self, url: str, tier: str):
with pytest.raises(litellm.UnsupportedParamsError, match=f"service_tier='{tier}'"):
BedrockFilesConfig().transform_openai_jsonl_content_to_bedrock_jsonl_content(
[self._nova_row(url, body_params={"service_tier": tier})]
)
@pytest.mark.parametrize("tier", ["ultrafast", "reserved"])
@pytest.mark.parametrize("url", ["/v1/chat/completions", "/v1/responses"])
def test_nova_row_level_drop_params_drops_the_service_tier(self, url: str, tier: str):
result: Final = BedrockFilesConfig().transform_openai_jsonl_content_to_bedrock_jsonl_content(
[self._nova_row(url, body_params={"service_tier": tier, "drop_params": True})]
)
assert "serviceTier" not in result[0]["modelInput"]
assert "drop_params" not in json.dumps(result[0]["modelInput"])
def test_nova_row_service_tier_drops_under_global_drop_params(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(litellm, "drop_params", True)
result: Final = BedrockFilesConfig().transform_openai_jsonl_content_to_bedrock_jsonl_content(
[self._nova_row(body_params={"service_tier": "reserved"})]
)
assert "serviceTier" not in result[0]["modelInput"]
@pytest.mark.parametrize("tier", ["ultrafast", "reserved"])
def test_deployment_drop_params_drops_the_service_tier_from_the_uploaded_nova_rows(self, tier: str):
request: Final = BedrockFilesConfig().transform_create_file_request(
model="us.amazon.nova-pro-v1:0",
create_file_data={
"file": (
"batch.jsonl",
json.dumps(self._nova_row(body_params={"service_tier": tier})).encode(),
"application/jsonl",
),
"purpose": "batch",
},
optional_params={
"aws_access_key_id": "test-key-id",
"aws_secret_access_key": "test-secret",
"aws_region_name": "us-west-2",
},
litellm_params={"s3_bucket_name": "litellm-batch-bucket", "drop_params": True},
)
assert isinstance(request, dict)
uploaded_row: Final = json.loads(request["data"])
assert uploaded_row["modelInput"]["messages"]
assert "serviceTier" not in uploaded_row["modelInput"]
@pytest.mark.parametrize("drop_params, drops", [(False, False), ("false", False), (True, True), ("true", True)])
def test_deployment_drop_params_decides_whether_a_claude_row_tier_fails_or_drops(
self, drop_params: bool | str, drops: bool
):
claude_model: Final = "us.anthropic.claude-haiku-4-5-20251001-v1:0"
row: Final = {
"custom_id": "claude-tier-1",
"method": "POST",
"url": "/v1/chat/completions",
"body": {
"model": claude_model,
"messages": [{"role": "user", "content": "hi"}],
"max_tokens": 10,
"service_tier": "ultrafast",
},
}
def upload() -> Mapping[str, object]:
request: Final = BedrockFilesConfig().transform_create_file_request(
model=claude_model,
create_file_data={
"file": ("batch.jsonl", json.dumps(row).encode(), "application/jsonl"),
"purpose": "batch",
},
optional_params={
"aws_access_key_id": "test-key-id",
"aws_secret_access_key": "test-secret",
"aws_region_name": "us-west-2",
},
litellm_params={"s3_bucket_name": "litellm-batch-bucket", "drop_params": drop_params},
)
assert isinstance(request, dict)
return json.loads(request["data"])
if not drops:
with pytest.raises(BedrockError, match="service_tier"):
upload()
return
model_input: Final = upload()["modelInput"]
assert model_input["messages"]
assert "service_tier" not in json.dumps(model_input)
assert "serviceTier" not in model_input
@pytest.mark.parametrize("tier", [None, "auto", "AUTO"])
def test_claude_row_with_null_or_auto_service_tier_sends_no_tier(self, tier: str | None):
result: Final = BedrockFilesConfig().transform_openai_jsonl_content_to_bedrock_jsonl_content(
[
self._nova_row(
model="us.anthropic.claude-haiku-4-5-20251001-v1:0",
body_params={"service_tier": tier, "max_tokens": 10},
)
]
)
assert result[0]["modelInput"]["messages"]
assert "service_tier" not in json.dumps(result[0]["modelInput"])
@pytest.mark.parametrize("drop_params, drops", [(True, True), ("true", True), (False, False), ("false", False)])
@pytest.mark.parametrize(
"model", ["us.anthropic.claude-haiku-4-5-20251001-v1:0", "openai.gpt-oss-120b-1:0", "us.amazon.nova-pro-v1:0"]
)
def test_row_level_drop_params_decides_whether_a_rejected_tier_fails_or_drops_for_every_row_format(
self, model: str, drop_params: bool | str, drops: bool
):
rows: Final = [
self._nova_row(
model=model, body_params={"service_tier": "ultrafast", "drop_params": drop_params, "max_tokens": 10}
)
]
if not drops:
with pytest.raises(litellm.UnsupportedParamsError, match="service_tier='ultrafast'"):
BedrockFilesConfig().transform_openai_jsonl_content_to_bedrock_jsonl_content(rows)
return
result: Final = BedrockFilesConfig().transform_openai_jsonl_content_to_bedrock_jsonl_content(rows)
assert result[0]["modelInput"]["messages"]
assert "ultrafast" not in json.dumps(result[0]["modelInput"])
@pytest.mark.parametrize(
"tier, bedrock_tier",
[("priority", "priority"), ("Flex", "flex"), ("standard", "default"), ("default", "default")],
)
@pytest.mark.parametrize("url", ["/v1/chat/completions", "/v1/responses"])
def test_openai_format_row_forwards_a_tier_its_invoke_body_takes(self, url: str, tier: str, bedrock_tier: str):
result: Final = BedrockFilesConfig().transform_openai_jsonl_content_to_bedrock_jsonl_content(
[self._nova_row(url, model="openai.gpt-oss-120b-1:0", body_params={"service_tier": tier})]
)
assert result[0]["modelInput"]["service_tier"] == bedrock_tier
@pytest.mark.parametrize("tier", [None, "auto", "AUTO"])
def test_openai_format_row_with_null_or_auto_service_tier_sends_no_tier(self, tier: str | None):
result: Final = BedrockFilesConfig().transform_openai_jsonl_content_to_bedrock_jsonl_content(
[self._nova_row(model="openai.gpt-oss-120b-1:0", body_params={"service_tier": tier})]
)
assert "service_tier" not in result[0]["modelInput"]
@pytest.mark.parametrize("tier", ["ultrafast", "reserved", "scale"])
@pytest.mark.parametrize("url", ["/v1/chat/completions", "/v1/responses"])
def test_openai_format_row_with_a_tier_bedrock_rejects_fails_without_drop_params(self, url: str, tier: str):
with pytest.raises(litellm.UnsupportedParamsError, match=f"service_tier='{tier}'"):
BedrockFilesConfig().transform_openai_jsonl_content_to_bedrock_jsonl_content(
[self._nova_row(url, model="openai.gpt-oss-120b-1:0", body_params={"service_tier": tier})]
)
@pytest.mark.parametrize("url", ["/v1/chat/completions", "/v1/responses"])
def test_passthrough_row_keeps_row_level_drop_params_out_of_the_model_input(self, url: str):
result: Final = BedrockFilesConfig().transform_openai_jsonl_content_to_bedrock_jsonl_content(
[self._nova_row(url, "openai.gpt-oss-120b-1:0", {"drop_params": True})]
)
assert "drop_params" not in result[0]["modelInput"]
@pytest.mark.parametrize(
"row_drop_params, deployment_drop_params, drops",
[(True, False, True), ("true", False, True), (None, True, True), (None, False, False), ("false", False, False)],
)
def test_drop_params_decides_whether_a_param_nova_rejects_fails_or_drops_from_an_uploaded_nova_row(
self, row_drop_params: bool | str | None, deployment_drop_params: bool, drops: bool
):
tool: Final = {"type": "function", "function": {"name": "get_weather", "parameters": {"type": "object"}}}
row_params: Final = {
"tools": [tool],
"tool_choice": "none",
**({} if row_drop_params is None else {"drop_params": row_drop_params}),
}
row_bytes: Final = json.dumps(self._nova_row(body_params=row_params)).encode()
def upload() -> Mapping[str, object]:
request: Final = BedrockFilesConfig().transform_create_file_request(
model="us.amazon.nova-pro-v1:0",
create_file_data={"file": ("batch.jsonl", row_bytes, "application/jsonl"), "purpose": "batch"},
optional_params={
"aws_access_key_id": "test-key-id",
"aws_secret_access_key": "test-secret",
"aws_region_name": "us-west-2",
},
litellm_params={"s3_bucket_name": "litellm-batch-bucket", "drop_params": deployment_drop_params},
)
assert isinstance(request, dict)
return json.loads(request["data"])
if not drops:
with pytest.raises(BedrockError, match="tool_choice"):
upload()
return
tool_config: Final = upload()["modelInput"]["toolConfig"]
assert tool_config["tools"]
assert "toolChoice" not in tool_config
def test_nova_text_only_uses_converse_format(self):
"""
Test that Nova models produce Converse API format in batch modelInput.