diff --git a/litellm/images/main.py b/litellm/images/main.py index 7dc68dafecc..88eb954710f 100644 --- a/litellm/images/main.py +++ b/litellm/images/main.py @@ -749,7 +749,11 @@ def image_edit( "n", "quality", "size", - "style", + # "style" is intentionally NOT blocklisted here: image_edit has no named + # `style` argument, so blocklisting it would drop the param for every + # provider. It only reaches handlers whose image-edit config advertises + # "style" in get_supported_openai_params (e.g. bedrock Nova Canvas, + # recraft); every other provider's transform ignores unknown keys. "async_call", ] non_default_params: Final = filter_out_litellm_params(kwargs, excluding=openai_params) @@ -962,9 +966,9 @@ def image_edit( @client async def aimage_edit( - image: FileTypes | list[FileTypes], - model: str, - prompt: str, + image: FileTypes | list[FileTypes] | None = None, + model: str | None = None, + prompt: str | None = None, mask: str | None = None, n: int | None = None, quality: str | ImageGenerationRequestQuality | None = None, @@ -999,10 +1003,15 @@ async def aimage_edit( # get custom llm provider so we can use this for mapping exceptions if custom_llm_provider is None: _, custom_llm_provider, _, _ = litellm.get_llm_provider( - model=model, api_base=local_vars.get("base_url", None) + model=model or DEFAULT_IMAGE_ENDPOINT_MODEL, + api_base=local_vars.get("base_url", None), ) - images: Final = image if isinstance(image, list) else [image] + images: Final = ( + image + if isinstance(image, list) + else ([image] if image is not None else []) # mutable-ok: single-image wrap like sync image_edit + ) func: Final = partial( image_edit, diff --git a/litellm/llms/bedrock/common_utils.py b/litellm/llms/bedrock/common_utils.py index 333bce0b967..3fdcac6e0b5 100644 --- a/litellm/llms/bedrock/common_utils.py +++ b/litellm/llms/bedrock/common_utils.py @@ -132,6 +132,39 @@ def merge_bedrock_aws_request_params( return request_params +# Headers safe to surface in logs/callbacks: non-sensitive request metadata. +# Everything else (Authorization, X-Amz-Security-Token, X-Amz-Date, ...) is +# signature material and must never leave the request path. +_BEDROCK_LOGGING_SAFE_HEADERS: Final[frozenset[str]] = frozenset( + { + "content-type", + "content-length", + "host", + "accept", + "x-amzn-requestid", + "x-amzn-errortype", + } +) +_BEDROCK_REDACTED_HEADER_VALUE: Final = "[REDACTED]" + + +def redact_bedrock_headers_for_logging(headers: Mapping[str, str]) -> dict[str, str]: + """Copy ``headers`` with every non-allowlisted value replaced by ``[REDACTED]``. + + SigV4-signed Bedrock requests carry credentials in headers (Authorization, + X-Amz-Security-Token, X-Amz-Date); ``Logging.pre_call`` forwards + ``additional_args["headers"]`` unmasked to logger_fn and custom + ``log_pre_api_call`` callbacks, so handlers must pass this copy instead of + the prepared request headers. Key names are preserved (case-sensitively) + so log consumers keep seeing the full header shape; the allowlist match is + case-insensitive per HTTP header semantics. The sent request is untouched. + """ + return { # mutable-ok: redacted logging copy handed to the caller + key: (value if key.lower() in _BEDROCK_LOGGING_SAFE_HEADERS else _BEDROCK_REDACTED_HEADER_VALUE) + for key, value in headers.items() + } + + def s3_static_key_pair(params: Mapping[str, object]) -> tuple[str, str] | None: """The s3_access_key_id / s3_secret_access_key pair when both are set, otherwise None.""" s3_access_key_id: Final = params.get("s3_access_key_id") diff --git a/litellm/llms/bedrock/image_edit/amazon_nova_canvas_image_edit_transformation.py b/litellm/llms/bedrock/image_edit/amazon_nova_canvas_image_edit_transformation.py index acb0cc8dcb7..7d6b5470ce6 100644 --- a/litellm/llms/bedrock/image_edit/amazon_nova_canvas_image_edit_transformation.py +++ b/litellm/llms/bedrock/image_edit/amazon_nova_canvas_image_edit_transformation.py @@ -4,6 +4,9 @@ Amazon Nova Canvas image edit on Bedrock (InvokeModel). Maps OpenAI-style image edit (image + prompt, optional mask) to Nova Canvas task types: - With mask: INPAINTING (inPaintingParams per AWS docs) - Without mask: IMAGE_VARIATION (imageVariationParams) +- TEXT_IMAGE: conditioned editing where the input image conditions layout via + textToImageParams.conditionImage + controlMode (CANNY_EDGE | SEGMENTATION) + + controlStrength (issue #39552) Refs: - https://docs.aws.amazon.com/nova/latest/userguide/image-gen-access.html @@ -38,6 +41,44 @@ else: LiteLLMLoggingObj = Any +NOVA_CANVAS_CONTROL_MODES: Final[tuple[str, ...]] = ("CANNY_EDGE", "SEGMENTATION") + + +def _invalid_input_error(message: str) -> BedrockError: + """400-class error for invalid caller input. + + A plain ValueError would surface as APIConnectionError (500-class, which OpenAI + SDKs auto-retry); BedrockError(status_code=400) maps to BadRequestError instead + (same mapping the Nova Reel video config relies on). + """ + return BedrockError(status_code=400, message=message) + + +def _resolve_edit_image_b64( + image: FileTypes | None, + condition_image_b64: str | None, + task_type: str | None, +) -> str: + """Base64 image for the task body: the multipart ``image`` wins; ``conditionImage`` + (already encoded) backs TEXT_IMAGE when no multipart file was sent.""" + if condition_image_b64 is not None and task_type != "TEXT_IMAGE": + # Checked before the multipart-image early return: image + conditionImage + # with a non-TEXT_IMAGE taskType must fail loudly instead of silently + # discarding the caller's conditionImage. + raise _invalid_input_error( + "Amazon Nova Canvas conditionImage is only supported with " + f"taskType=TEXT_IMAGE (conditioned editing); got taskType={task_type!r}." + ) + if image is not None: + return _file_types_to_b64(image) + if task_type != "TEXT_IMAGE" or condition_image_b64 is None: + raise _invalid_input_error( + "Nova Canvas image edit requires an image input. Pass the multipart " + "`image` file, or a `conditionImage` for taskType=TEXT_IMAGE." + ) + return condition_image_b64 + + def _nova_canvas_task_body( *, image_b64: str, @@ -48,6 +89,9 @@ def _nova_canvas_task_body( task_type: str | None, mask_prompt: str | None, out_painting_mode: str | None, + control_mode: str | None = None, + control_strength: float | str | None = None, + style: str | None = None, ) -> dict[str, object]: """Build InvokeModel body task section (without imageGenerationConfig).""" if task_type == "BACKGROUND_REMOVAL": @@ -77,6 +121,50 @@ def _nova_canvas_task_body( "taskType": "OUTPAINTING", "outPaintingParams": out_params, } + if task_type == "TEXT_IMAGE": + # Conditioned editing: the input image guides layout/composition of the + # generated image via textToImageParams.conditionImage. SEGMENTATION + # controlMode derives a segmentation mask from the condition image; + # CANNY_EDGE (the AWS default) follows its prominent contours. + if mask_b64 is not None or mask_prompt is not None: + # AWS TEXT_IMAGE has no mask field; fail fast instead of silently + # dropping the caller's mask or maskPrompt. + raise _invalid_input_error( + "Amazon Nova Canvas TEXT_IMAGE (conditioned editing) does not support a " + "mask. Use INPAINTING or OUTPAINTING for mask-based editing workflows." + ) + if control_mode is not None and control_mode not in NOVA_CANVAS_CONTROL_MODES: + raise _invalid_input_error( + f"Unsupported Amazon Nova Canvas controlMode: {control_mode!r}. Use one of {NOVA_CANVAS_CONTROL_MODES}." + ) + control_strength_value: float | None = None + if control_strength is not None: + # Multipart form data delivers controlStrength as a string; coerce + # before the range check (raw strings would TypeError on <=). + try: + control_strength_value = float(control_strength) + except (TypeError, ValueError): + raise _invalid_input_error("Amazon Nova Canvas controlStrength must be a number in [0.0, 1.0].") + if not 0.0 <= control_strength_value <= 1.0: + raise _invalid_input_error( + f"Amazon Nova Canvas controlStrength must be between 0.0 and 1.0; got {control_strength_value!r}." + ) + t2i_params: Final[dict[str, object]] = { # mutable-ok: optional conditioned-editing keys are set below + "text": text, + "conditionImage": image_b64, + } + if negative_text is not None: + t2i_params["negativeText"] = negative_text + if control_mode is not None: + t2i_params["controlMode"] = control_mode + if control_strength_value is not None: + t2i_params["controlStrength"] = control_strength_value + if style is not None: + t2i_params["style"] = style + return { # mutable-ok: InvokeModel JSON body is a plain dict + "taskType": "TEXT_IMAGE", + "textToImageParams": t2i_params, + } # Honour explicit IMAGE_VARIATION even when a mask is present (mask is ignored # for this task type; callers use INPAINTING when they want mask semantics). if task_type == "IMAGE_VARIATION": @@ -95,9 +183,10 @@ def _nova_canvas_task_body( # Explicit taskType must be INPAINTING or omitted from here on; anything else is invalid. if task_type is not None and str(task_type).strip() != "": if task_type != "INPAINTING": - raise ValueError( + raise _invalid_input_error( f"Unsupported Amazon Nova Canvas taskType: {task_type!r}. " "Use BACKGROUND_REMOVAL, OUTPAINTING, IMAGE_VARIATION, INPAINTING, " + "TEXT_IMAGE (conditioned editing via conditionImage/controlMode), " "or omit taskType for automatic routing (mask → INPAINTING, else IMAGE_VARIATION)." ) if mask_b64 is not None or mask_prompt is not None or task_type == "INPAINTING": @@ -109,7 +198,7 @@ def _nova_canvas_task_body( if negative_text is not None: in_params["negativeText"] = negative_text if "maskPrompt" not in in_params and "maskImage" not in in_params: - raise ValueError( + raise _invalid_input_error( "Amazon Nova Canvas INPAINTING requires either maskPrompt or maskImage " "(use OpenAI mask= for maskImage, or pass maskPrompt in optional params). " "See https://docs.aws.amazon.com/nova/latest/userguide/image-gen-req-resp-structure.html" @@ -248,9 +337,13 @@ class BedrockAmazonNovaCanvasImageEditConfig(BaseImageEditConfig): "cfgScale", "seed", "quality", + "style", "taskType", "maskPrompt", "outPaintingMode", + "controlMode", + "controlStrength", + "conditionImage", "imageGenerationConfig", ] @@ -314,7 +407,15 @@ class BedrockAmazonNovaCanvasImageEditConfig(BaseImageEditConfig): headers: dict, ) -> tuple[dict, Any]: op: Final = dict(image_edit_optional_request_params) - image_b64: Final = _file_types_to_b64(image) + # conditionImage: alternative source for the TEXT_IMAGE condition image + # for callers that cannot send a multipart `image` file (e.g. plain JSON + # bodies). When both are supplied the multipart `image` field wins. + condition_image_raw: Final = op.pop("conditionImage", None) + condition_image_b64: Final[str | None] = ( + _file_types_to_b64(condition_image_raw) if condition_image_raw is not None else None + ) + task_type: Final = op.pop("taskType", None) + image_b64: Final[str] = _resolve_edit_image_b64(image, condition_image_b64, task_type) mask_raw: Final = op.pop("mask", None) mask_b64: str | None = None @@ -353,12 +454,12 @@ class BedrockAmazonNovaCanvasImageEditConfig(BaseImageEditConfig): if seed is not None: image_generation_config["seed"] = seed - task_type: Final = op.pop("taskType", None) if (prompt is None or prompt == "") and task_type in ( "INPAINTING", "OUTPAINTING", + "TEXT_IMAGE", ): - raise ValueError( + raise _invalid_input_error( f"Amazon Nova Canvas {task_type} requires a text prompt. Pass a non-empty `prompt` in your request." ) text: Final = prompt if prompt is not None and prompt != "" else " " @@ -366,6 +467,19 @@ class BedrockAmazonNovaCanvasImageEditConfig(BaseImageEditConfig): similarity_strength: Final = op.pop("similarityStrength", None) mask_prompt: Final = op.pop("maskPrompt", None) out_painting_mode: Final = op.pop("outPaintingMode", None) + control_mode: Final = op.pop("controlMode", None) + control_strength: Final = op.pop("controlStrength", None) + style: Final = op.pop("style", None) + if ( + control_mode is not None or control_strength is not None or style is not None + ) and task_type != "TEXT_IMAGE": + # Conditioning fields only exist on textToImageParams (TEXT_IMAGE); + # any other resolved task type would silently drop them. + raise _invalid_input_error( + "Amazon Nova Canvas controlMode/controlStrength/style are only supported " + f"with taskType=TEXT_IMAGE (conditioned editing); resolved taskType={task_type!r} " + "would silently drop them. Set taskType=TEXT_IMAGE to use them." + ) body: Final = _nova_canvas_task_body( image_b64=image_b64, @@ -376,6 +490,9 @@ class BedrockAmazonNovaCanvasImageEditConfig(BaseImageEditConfig): task_type=task_type, mask_prompt=mask_prompt, out_painting_mode=out_painting_mode, + control_mode=control_mode, + control_strength=control_strength, + style=style, ) # BACKGROUND_REMOVAL InvokeModel body must not include imageGenerationConfig (AWS rejects it). diff --git a/litellm/llms/bedrock/image_edit/handler.py b/litellm/llms/bedrock/image_edit/handler.py index be6489f20ae..f05eebd86dc 100644 --- a/litellm/llms/bedrock/image_edit/handler.py +++ b/litellm/llms/bedrock/image_edit/handler.py @@ -30,7 +30,7 @@ from litellm.llms.custom_httpx.http_handler import ( from litellm.types.utils import ImageResponse from ..base_aws_llm import BaseAWSLLM, bedrock_bearer_token -from ..common_utils import BedrockError +from ..common_utils import BedrockError, redact_bedrock_headers_for_logging if TYPE_CHECKING: from botocore.awsrequest import AWSPreparedRequest @@ -256,7 +256,10 @@ class BedrockImageEdit(BaseAWSLLM): additional_args={ "complete_input_dict": data, "api_base": proxy_endpoint_url, - "headers": prepped.headers, + # Redacted copy: pre_call forwards additional_args unmasked to + # logger_fn / log_pre_api_call callbacks; the signed headers + # must not leave the request path (prepped keeps them). + "headers": redact_bedrock_headers_for_logging(prepped.headers), }, ) return BedrockImageEditPreparedRequest( diff --git a/litellm/llms/bedrock/videos/__init__.py b/litellm/llms/bedrock/videos/__init__.py new file mode 100644 index 00000000000..382fd0a58ee --- /dev/null +++ b/litellm/llms/bedrock/videos/__init__.py @@ -0,0 +1,8 @@ +"""Bedrock video generation (Amazon Nova Reel via StartAsyncInvoke).""" + +from typing import Final + +from litellm.llms.bedrock.videos.handler import BedrockVideoGeneration +from litellm.llms.bedrock.videos.transformation import BedrockNovaReelVideoConfig + +__all__: Final[tuple[str, ...]] = ("BedrockNovaReelVideoConfig", "BedrockVideoGeneration") diff --git a/litellm/llms/bedrock/videos/dispatch.py b/litellm/llms/bedrock/videos/dispatch.py new file mode 100644 index 00000000000..71d8dbda20a --- /dev/null +++ b/litellm/llms/bedrock/videos/dispatch.py @@ -0,0 +1,118 @@ +"""Bedrock dispatch shims for the main-layer video functions. + +The generic video routes in ``litellm.videos.main`` delegate their bedrock +branches here (AWS SigV4 signing and the async-invoke API need the bedrock +handler instead of the shared HTTP handler). Each function lazy-imports +``BedrockVideoGeneration`` so importing this module never pulls the boto3 +signing stack, and forwards its arguments verbatim. +""" + +from __future__ import annotations + +from collections.abc import Coroutine, Mapping +from typing import TYPE_CHECKING, Final + +import httpx + +from litellm.types.router import GenericLiteLLMParams + +if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging + from litellm.types.videos.main import VideoObject + + +def dispatch_bedrock_video_generation( + *, + model: str, + prompt: str, + video_generation_request_params: Mapping[str, object], + litellm_params: GenericLiteLLMParams, + logging_obj: LiteLLMLogging | None, + timeout: float | httpx.Timeout | None, + is_async: bool, + client: object | None = None, + extra_headers: dict[str, object] | None = None, + api_key: str | None = None, +) -> VideoObject | Coroutine[object, object, VideoObject]: + """Create (StartAsyncInvoke) through the bedrock handler. + + Merges the ``aws_*`` auth params riding on litellm_params into the optional + params for the handler (mirrors how images/main.py merges non_default_params + for bedrock) and threads the real litellm_params through so + metadata.request_id reaches the clientRequestToken fallback. + """ + from litellm.llms.bedrock.videos.handler import BedrockVideoGeneration + + bedrock_optional_params: Final[dict[str, object]] = ( + dict( # mutable-ok: aws_* params are merged in before the handler call + video_generation_request_params + ) + ) + bedrock_optional_params.update( + { # mutable-ok: aws_* auth params merged into the bedrock params + k: v for k, v in litellm_params.model_dump(exclude_none=True).items() if k.startswith("aws_") + } + ) + return BedrockVideoGeneration().video_generation( + model=model, + prompt=prompt, + optional_params=bedrock_optional_params, + logging_obj=logging_obj, + timeout=timeout, + avideo_generation=is_async, + client=client, + api_base=litellm_params.get("api_base"), + extra_headers=extra_headers, + api_key=api_key, + # Real litellm_params so metadata.request_id reaches the + # clientRequestToken fallback (idempotent retries); aws_* keys are + # already merged into bedrock_optional_params above and are never + # consumed from this object by the handler. + litellm_params=litellm_params, + ) + + +def dispatch_bedrock_video_status( + *, + video_id: str, + litellm_params: GenericLiteLLMParams, + logging_obj: LiteLLMLogging | None, + api_base: str | None, + api_key: str | None, + astatus: bool, + timeout: float | httpx.Timeout | None, +) -> VideoObject | Coroutine[object, object, VideoObject]: + """Status (GetAsyncInvoke) through the bedrock handler.""" + from litellm.llms.bedrock.videos.handler import BedrockVideoGeneration + + return BedrockVideoGeneration().video_status( + video_id=video_id, + litellm_params=litellm_params, + logging_obj=logging_obj, + api_base=api_base, + api_key=api_key, + astatus=astatus, + timeout=timeout, + ) + + +def dispatch_bedrock_video_content( + *, + video_id: str, + litellm_params: GenericLiteLLMParams, + logging_obj: LiteLLMLogging | None, + api_base: str | None, + api_key: str | None, + timeout: float | httpx.Timeout | None, +) -> bytes: + """Content download (S3 output object) through the bedrock handler.""" + from litellm.llms.bedrock.videos.handler import BedrockVideoGeneration + + return BedrockVideoGeneration().video_content( + video_id=video_id, + litellm_params=litellm_params, + logging_obj=logging_obj, + api_base=api_base, + api_key=api_key, + timeout=timeout, + ) diff --git a/litellm/llms/bedrock/videos/handler.py b/litellm/llms/bedrock/videos/handler.py new file mode 100644 index 00000000000..826f58446d5 --- /dev/null +++ b/litellm/llms/bedrock/videos/handler.py @@ -0,0 +1,773 @@ +""" +Bedrock Nova Reel video handler. + +Implements the LiteLLM video surface for ``amazon.nova-reel-v1:0`` (and regional +inference-profile ids) on top of the Bedrock asynchronous invoke API: + +- create: POST {runtime}/async-invoke (StartAsyncInvoke, SigV4-signed) +- status: GET {runtime}/async-invoke/{arn} (GetAsyncInvoke, SigV4-signed) +- content: download ``output.mp4`` from the S3 output location via boto3 + +Mirrors the URL + signing conventions of +``litellm/llms/bedrock/embed/embedding.py`` (same async-invoke endpoints). +""" + +from __future__ import annotations + +import json +from collections.abc import Coroutine, Mapping, Sequence +from typing import TYPE_CHECKING, Final, TypeAlias +from urllib.parse import quote + +import httpx + +import litellm +from litellm._logging import verbose_logger +from litellm.llms.bedrock.videos.transformation import BedrockNovaReelVideoConfig +from litellm.secret_managers.main import get_secret +from litellm.types.llms.bedrock import ( + BedrockAsyncInvokeOutputDataConfig, + BedrockAsyncInvokeS3OutputDataConfig, + BedrockGetAsyncInvokeResponse, +) +from litellm.types.router import GenericLiteLLMParams +from litellm.types.videos.main import VideoObject +from litellm.types.videos.utils import decode_video_id_with_provider, extract_original_video_id + +from ..base_aws_llm import ( + AWSPreparedRequest, + BaseAWSLLM, + Credentials, + bedrock_bearer_token, + pop_aws_auth_params, +) +from ..common_utils import BedrockError, BedrockModelInfo, redact_bedrock_headers_for_logging + +if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging + +_LitellmParamsDict: TypeAlias = dict[str, object] +_ExtraHeadersDict: TypeAlias = dict[str, object] + +DEFAULT_VIDEO_REGION: Final = "us-west-2" +NOVA_REEL_OUTPUT_FILENAME: Final = "output.mp4" +# S3 download client defaults when no per-call timeout is given (video_content +# threads its call timeout in; these bound the boto3 client otherwise). +DEFAULT_S3_CONNECT_TIMEOUT_S: Final = 5.0 +DEFAULT_S3_READ_TIMEOUT_S: Final = 60.0 + + +def _client_error_code(err: Exception) -> str: + """AWS error Code from a botocore ClientError response, '' when absent.""" + response: Final = getattr(err, "response", None) + if isinstance(response, Mapping): + error: Final = response.get("Error") + if isinstance(error, Mapping): + code: Final = error.get("Code") + if isinstance(code, str): + return code + return "" + + +def _client_error_http_status(err: Exception) -> int | None: + """HTTPStatusCode from a botocore ClientError ResponseMetadata, None when absent.""" + response: Final = getattr(err, "response", None) + if isinstance(response, Mapping): + metadata: Final = response.get("ResponseMetadata") + if isinstance(metadata, Mapping): + status: Final = metadata.get("HTTPStatusCode") + if isinstance(status, int): + return status + return None + + +def _sign_get_request( + credentials: Credentials | None, + url: str, + headers: Mapping[str, str], + aws_region_name: str, + bearer_token: str | None = None, +) -> AWSPreparedRequest: + try: + from botocore.auth import SigV4Auth + from botocore.awsrequest import AWSRequest + from botocore.exceptions import NoCredentialsError + except ImportError: + raise ImportError("Missing boto3 to call bedrock. Run 'pip install boto3'.") + + request: Final = AWSRequest( + method="GET", + url=url, + data=None, + headers=( + { # mutable-ok: merged headers dict carries the bearer token + **headers, + "Authorization": f"Bearer {bearer_token}", + } + if bearer_token is not None + else headers + ), + ) + if credentials is None and bearer_token is None: + # Fail fast the same way the shared POST signer does (base_aws_llm.py): + # an unsigned request would 403 at AWS with a far less actionable error. + raise NoCredentialsError() + if credentials is not None and bearer_token is None: + SigV4Auth(credentials, "bedrock", aws_region_name).add_auth(request) + return request.prepare() + + +def _region_from_invocation_arn(invocation_arn: str) -> str | None: + """arn:aws:bedrock:{region}:{account}:async-invoke/{id} -> region.""" + parts: Final = invocation_arn.split(":") + if len(parts) >= 4 and parts[0] == "arn": + return parts[3] or None + return None + + +def _parse_s3_uri(s3_uri: str) -> tuple[str, str]: + """s3://bucket/optional/prefix/ -> (bucket, 'optional/prefix').""" + if not s3_uri.startswith("s3://"): + raise BedrockError( + status_code=400, + message=f"Invalid S3 output URI (expected s3://bucket/prefix): {s3_uri!r}", + ) + trimmed: Final = s3_uri.rstrip("/") + without_scheme: Final = trimmed[len("s3://") :] + bucket, _, prefix = without_scheme.partition("/") + if not bucket: + raise BedrockError(status_code=400, message=f"Invalid S3 output URI: {s3_uri!r}") + return bucket, prefix + + +def _s3_uri_from_output_config( + output_config: BedrockAsyncInvokeOutputDataConfig | None, +) -> str | None: + """s3Uri from the invocation's outputDataConfig.s3OutputDataConfig, if present.""" + if output_config is None: + return None + s3_config: Final[BedrockAsyncInvokeS3OutputDataConfig | None] = output_config.get("s3OutputDataConfig") + if s3_config is None: + return None + s3_uri: Final[str | None] = s3_config.get("s3Uri") + return s3_uri + + +def _params_to_dict(litellm_params: GenericLiteLLMParams | Mapping[str, object] | None) -> _LitellmParamsDict: + """GenericLiteLLMParams is a pydantic model with dict-like access; copy to a real dict.""" + if litellm_params is None: + return {} # mutable-ok: empty params dict for None input + if isinstance(litellm_params, dict): + return dict(litellm_params) # mutable-ok: aws_* keys are popped in place downstream + if isinstance(litellm_params, GenericLiteLLMParams): + return litellm_params.model_dump(exclude_none=True) + return dict(litellm_params) # mutable-ok: aws_* keys are popped in place downstream + + +def _as_generic_litellm_params( + litellm_params: GenericLiteLLMParams | Mapping[str, object] | None, +) -> GenericLiteLLMParams: + """Normalize handler-level litellm_params to GenericLiteLLMParams for the create transform. + + Only the clientRequestToken fallback inputs are read downstream + (metadata.request_id, then the litellm_call_id extra field); aws_* credential + keys are consumed from optional_params only, so nothing passed here + double-processes credentials. + """ + if isinstance(litellm_params, GenericLiteLLMParams): + return litellm_params + params: Final[GenericLiteLLMParams] = GenericLiteLLMParams() + if litellm_params is not None: + metadata: Final = litellm_params.get("metadata") + if isinstance(metadata, dict): + params.metadata = metadata # pyright: ignore[reportAttributeAccessIssue] # extra-allowed field + call_id: Final = litellm_params.get("litellm_call_id") + if isinstance(call_id, str) and call_id: + params.litellm_call_id = call_id # pyright: ignore[reportAttributeAccessIssue] # @client extra field + return params + + +class BedrockVideoGeneration(BaseAWSLLM): + """ + Bedrock video generation handler for Amazon Nova Reel models. + """ + + def get_config_class(self) -> type[BedrockNovaReelVideoConfig]: + return BedrockNovaReelVideoConfig + + def _load_credentials( + self, + optional_params: dict, # mutable-ok: aws_* keys are popped in place + aws_region_name: str | None = None, + bearer_token: str | None = None, + ) -> tuple[Credentials | None, str]: + """Resolve SigV4 credentials + region the same way BedrockEmbedding does.""" + auth_params: Final = pop_aws_auth_params(optional_params) + if aws_region_name is None: + aws_region_name = optional_params.pop("aws_region_name", None) + if aws_region_name is None: + litellm_aws_region_name: Final = get_secret("AWS_REGION_NAME", None) + if litellm_aws_region_name is not None and isinstance(litellm_aws_region_name, str): + aws_region_name = litellm_aws_region_name + standard_aws_region_name: Final = get_secret("AWS_REGION", None) + if standard_aws_region_name is not None and isinstance(standard_aws_region_name, str): + aws_region_name = standard_aws_region_name + if aws_region_name is None: + aws_region_name = DEFAULT_VIDEO_REGION + + credentials: Final[Credentials | None] = ( + None if bearer_token is not None else self.resolve_credentials(auth_params, aws_region_name) + ) + return credentials, aws_region_name + + def _prepare_async_invoke_request( + self, + model: str, + prompt: str, + optional_params: _LitellmParamsDict, + api_base: str | None, + extra_headers: _ExtraHeadersDict | None, + logging_obj: LiteLLMLogging | None, + api_key: str | None = None, + litellm_params: GenericLiteLLMParams | Mapping[str, object] | None = None, + ) -> tuple[str, AWSPreparedRequest, bytes, _LitellmParamsDict]: + """ + Returns (endpoint_url, prepped_request, body, data) for POST /async-invoke. + + litellm_params feeds only the clientRequestToken fallback + (metadata.request_id); aws_* credentials are consumed from + optional_params, never from litellm_params, so nothing is double-processed. + """ + bearer_token: Final = bedrock_bearer_token(api_key) + boto3_credentials_info: Final = self._get_boto_credentials_from_optional_params( + optional_params, model, bearer_token=bearer_token + ) + bedrock_provider: Final = self.get_bedrock_invoke_provider(model) + model_id: Final = self.get_bedrock_model_id( + model=model, + provider=bedrock_provider, + optional_params=optional_params, + ) + _, proxy_endpoint_url = self.get_runtime_endpoint( + api_base=api_base, + aws_bedrock_runtime_endpoint=boto3_credentials_info.aws_bedrock_runtime_endpoint, + aws_region_name=boto3_credentials_info.aws_region_name, + ) + endpoint_url: Final = f"{proxy_endpoint_url.rstrip('/')}/async-invoke" + + config: Final = BedrockNovaReelVideoConfig() + data, _, _ = config.transform_video_create_request( + model=model_id, + prompt=prompt, + api_base=endpoint_url, + video_create_optional_request_params=optional_params, + litellm_params=_as_generic_litellm_params(litellm_params), + headers={}, # mutable-ok: transform never reads headers for Nova Reel + ) + # The transform returns the model name it was given; make sure the + # envelope carries the resolved Bedrock model id. + data["modelId"] = model_id + + body: Final = json.dumps(data).encode("utf-8") + headers: Final[_ExtraHeadersDict] = { + "Content-Type": "application/json", + **(extra_headers or {}), + } + prepped: Final = self.get_request_headers( + credentials=boto3_credentials_info.credentials, + aws_region_name=boto3_credentials_info.aws_region_name, + extra_headers=extra_headers, + endpoint_url=endpoint_url, + data=body, + headers=headers, + api_key=api_key, + ) + + if logging_obj is not None: + logging_obj.pre_call( + input=prompt, + api_key="", + additional_args={ # mutable-ok: logging payload dict built for this call + "complete_input_dict": data, + "api_base": endpoint_url, + # Redacted copy: pre_call forwards additional_args unmasked to + # logger_fn / log_pre_api_call callbacks; the signed headers + # must not leave the request path (prepped keeps them). + "headers": redact_bedrock_headers_for_logging(prepped.headers), + }, + ) + return endpoint_url, prepped, body, data + + def _transform_create_response( + self, + model: str, + response: httpx.Response, + data: Mapping[str, object], + logging_obj: LiteLLMLogging | None, + ) -> VideoObject: + if logging_obj is not None: + logging_obj.post_call( + input="", + api_key="", + original_response=response.text, + additional_args={"complete_input_dict": data}, # mutable-ok: logging payload dict built for this call + ) + # raise_for_status() already ran on both call paths, so any non-2xx is + # covered; manual status_code checks here would only misclassify + # legitimate 2xx variants (e.g. 202 from proxies) behind raise_for_status. + config: Final = BedrockNovaReelVideoConfig() + return config.transform_video_create_response( + model=model, + raw_response=response, + logging_obj=logging_obj, + request_data=data, + ) + + def video_generation( + self, + model: str, + prompt: str, + optional_params: _LitellmParamsDict, + logging_obj: LiteLLMLogging | None, + timeout: float | httpx.Timeout | None, + avideo_generation: bool = False, + client: httpx.Client | httpx.AsyncClient | None = None, + api_base: str | None = None, + extra_headers: _ExtraHeadersDict | None = None, + api_key: str | None = None, + litellm_params: GenericLiteLLMParams | Mapping[str, object] | None = None, + ) -> VideoObject | Coroutine[object, object, VideoObject]: + """Returns a VideoObject, or a coroutine resolving to one when avideo_generation is set.""" + if avideo_generation: + return self.async_video_generation( + model=model, + prompt=prompt, + optional_params=optional_params, + logging_obj=logging_obj, + timeout=timeout, + client=client, + api_base=api_base, + extra_headers=extra_headers, + api_key=api_key, + litellm_params=litellm_params, + ) + + endpoint_url, prepped, body, data = self._prepare_async_invoke_request( + model=model, + prompt=prompt, + optional_params=optional_params, + api_base=api_base, + extra_headers=extra_headers, + logging_obj=logging_obj, + api_key=api_key, + litellm_params=litellm_params, + ) + from litellm.llms.custom_httpx.http_handler import _get_httpx_client + + sync_client: Final = client if isinstance(client, httpx.Client) else _get_httpx_client() + try: + response: Final = sync_client.post( + url=endpoint_url, + headers=prepped.headers, + content=body, + timeout=timeout, + ) + response.raise_for_status() + except httpx.HTTPStatusError as err: + raise BedrockError( + status_code=err.response.status_code, + message=err.response.text, + headers=err.response.headers, + response=err.response, + ) + except httpx.TimeoutException: + raise BedrockError(status_code=408, message="Timeout error occurred.") + return self._transform_create_response(model, response, data, logging_obj) + + async def async_video_generation( + self, + model: str, + prompt: str, + optional_params: _LitellmParamsDict, + logging_obj: LiteLLMLogging | None, + timeout: float | httpx.Timeout | None, + client: httpx.Client | httpx.AsyncClient | None = None, + api_base: str | None = None, + extra_headers: _ExtraHeadersDict | None = None, + api_key: str | None = None, + litellm_params: GenericLiteLLMParams | Mapping[str, object] | None = None, + ) -> VideoObject: + from litellm.llms.custom_httpx.http_handler import get_async_httpx_client + + endpoint_url, prepped, body, data = self._prepare_async_invoke_request( + model=model, + prompt=prompt, + optional_params=optional_params, + api_base=api_base, + extra_headers=extra_headers, + logging_obj=logging_obj, + api_key=api_key, + litellm_params=litellm_params, + ) + async_client: Final = ( + client + if isinstance(client, httpx.AsyncClient) + else get_async_httpx_client( + llm_provider=litellm.LlmProviders.BEDROCK, + params={"timeout": timeout}, # mutable-ok: per-call timeout kwargs for the shared client factory + ) + ) + try: + response: Final = await async_client.post( + url=endpoint_url, + headers=prepped.headers, + content=body, + timeout=timeout, + ) + response.raise_for_status() + except httpx.HTTPStatusError as err: + raise BedrockError( + status_code=err.response.status_code, + message=err.response.text, + headers=err.response.headers, + response=err.response, + ) + except httpx.TimeoutException: + raise BedrockError(status_code=408, message="Timeout error occurred.") + return self._transform_create_response(model, response, data, logging_obj) + + def _status_request_parts( + self, + invocation_arn: str, + optional_params: dict, # mutable-ok: aws_* keys are popped in place + api_base: str | None, + api_key: str | None = None, + ) -> tuple[str, AWSPreparedRequest, str]: + """Returns (status_url, prepped_get_request, resolved_region) for GET /async-invoke/{arn}. + + The resolved region (explicit aws_region_name > ARN region > env > default) is + returned so callers that follow up with an S3 download reuse the same region + instead of re-resolving to the env/default one. + """ + bearer_token: Final = bedrock_bearer_token(api_key) + aws_region_name: str | None = optional_params.pop("aws_region_name", None) + if aws_region_name is None: + aws_region_name = _region_from_invocation_arn(invocation_arn) + credentials, aws_region_name = self._load_credentials( + optional_params, + aws_region_name=aws_region_name, + bearer_token=bearer_token, + ) + _, proxy_endpoint_url = self.get_runtime_endpoint( + api_base=api_base, + aws_bedrock_runtime_endpoint=optional_params.pop("aws_bedrock_runtime_endpoint", None), + aws_region_name=aws_region_name, + ) + encoded_arn: Final = quote(invocation_arn, safe="") + status_url: Final = f"{proxy_endpoint_url.rstrip('/')}/async-invoke/{encoded_arn}" + prepped: Final = _sign_get_request( + credentials=credentials, + url=status_url, + headers={"Content-Type": "application/json"}, # mutable-ok: SigV4 signs a plain headers dict + aws_region_name=aws_region_name, + bearer_token=bearer_token, + ) + return status_url, prepped, aws_region_name + + def _decode_status_context(self, video_id: str) -> tuple[str, str]: + """Returns (invocation_arn, model) encoded in the video id (single decode).""" + decoded: Final = decode_video_id_with_provider(video_id) + invocation_arn: Final[str] = decoded.get("video_id") or extract_original_video_id(video_id) + if not invocation_arn: + raise BedrockError( + status_code=400, + message=f"Could not extract a Bedrock invocation ARN from video id: {video_id!r}", + ) + model: Final[str] = decoded.get("model_id") or "amazon.nova-reel-v1:0" + return invocation_arn, model + + def _sync_get(self, prepped: AWSPreparedRequest, timeout: float | httpx.Timeout | None = None) -> httpx.Response: + from litellm.llms.custom_httpx.http_handler import _get_httpx_client + + client: Final = _get_httpx_client() + try: + return client.get(url=prepped.url, headers=prepped.headers, timeout=timeout) + except httpx.TimeoutException: + raise BedrockError(status_code=408, message="Timeout error occurred.") + + async def _async_get( + self, + prepped: AWSPreparedRequest, + timeout: float | httpx.Timeout | None = None, + ) -> httpx.Response: + from litellm.llms.custom_httpx.http_handler import get_async_httpx_client + + client: Final = get_async_httpx_client( + llm_provider=litellm.LlmProviders.BEDROCK, + params={"timeout": timeout}, # mutable-ok: per-call timeout kwargs for the shared client factory + ) + try: + return await client.get(url=prepped.url, headers=prepped.headers, timeout=timeout) + except httpx.TimeoutException: + raise BedrockError(status_code=408, message="Timeout error occurred.") + + def _map_status_response( + self, + response: httpx.Response, + model: str, + video_id: str, + logging_obj: LiteLLMLogging | None, + ) -> tuple[VideoObject, BedrockGetAsyncInvokeResponse]: + if response.status_code != 200: + raise BedrockError( + status_code=response.status_code, + message=f"Nova Reel get-async-invoke error: {response.text}", + headers=response.headers, + response=response, + ) + try: + # Guarded parse: the transform's own non-JSON guard never runs because + # raw is needed here first; a non-JSON 200 must map to a 502, not a 500. + raw: BedrockGetAsyncInvokeResponse = response.json() + except ValueError as err: + raise BedrockError( + status_code=502, + message=f"non-JSON response from Bedrock status endpoint (expected GetAsyncInvoke JSON): {err}", + ) from err + config: Final = BedrockNovaReelVideoConfig() + video_obj = config.transform_video_status_retrieve_response( + raw_response=response, + logging_obj=logging_obj, + model=model, + video_id=video_id, + ) + return video_obj, raw + + def video_status( + self, + video_id: str, + litellm_params: GenericLiteLLMParams | Mapping[str, object] | None = None, + logging_obj: LiteLLMLogging | None = None, + api_base: str | None = None, + api_key: str | None = None, + astatus: bool = False, + timeout: float | httpx.Timeout | None = None, + ) -> VideoObject | Coroutine[object, object, VideoObject]: + """Returns a VideoObject, or a coroutine resolving to one when astatus is set.""" + invocation_arn, model = self._decode_status_context(video_id) + optional_params: Final[_LitellmParamsDict] = _params_to_dict(litellm_params) + _, prepped, _ = self._status_request_parts(invocation_arn, optional_params, api_base, api_key=api_key) + if astatus: + return self._async_video_status( + prepped=prepped, model=model, video_id=video_id, logging_obj=logging_obj, timeout=timeout + ) + response: Final = self._sync_get(prepped, timeout=timeout) + video_obj, _ = self._map_status_response(response, model, video_id, logging_obj) + return video_obj + + async def _async_video_status( + self, + prepped: AWSPreparedRequest, + model: str, + video_id: str, + logging_obj: LiteLLMLogging | None = None, + timeout: float | httpx.Timeout | None = None, + ) -> VideoObject: + """Private async arm of video_status; only its internal dispatch calls it.""" + response: Final = await self._async_get(prepped, timeout=timeout) + video_obj, _ = self._map_status_response(response, model, video_id, logging_obj) + return video_obj + + def video_content( + self, + video_id: str, + litellm_params: GenericLiteLLMParams | Mapping[str, object] | None = None, + logging_obj: LiteLLMLogging | None = None, + api_base: str | None = None, + api_key: str | None = None, + timeout: float | httpx.Timeout | None = None, + ) -> bytes: + """Download output.mp4 from the S3 output location once the job completed.""" + invocation_arn, model = self._decode_status_context(video_id) + optional_params: Final[_LitellmParamsDict] = _params_to_dict(litellm_params) + _, prepped, status_region = self._status_request_parts( + invocation_arn, optional_params, api_base, api_key=api_key + ) + response: Final = self._sync_get(prepped, timeout=timeout) + video_obj, raw = self._map_status_response(response, model, video_id, logging_obj) + if video_obj.status == "failed": + failure_message: Final[str] = ( + (video_obj.error.get("message") if video_obj.error else None) or raw.get("failureMessage") or "" + ) + raise BedrockError( + status_code=502, + message=f"Nova Reel invocation failed: {failure_message}", + ) + if video_obj.status != "completed": + # Client error about job state: 400-class, not a 500 that SDKs retry. + raise BedrockError( + status_code=400, + message=( + "Nova Reel video generation is not complete yet " + f"(status={video_obj.status}). Check video_status() before downloading." + ), + ) + + s3_uri: Final[str | None] = _s3_uri_from_output_config(raw.get("outputDataConfig")) + if not s3_uri: + # The raw get-async-invoke payload carries invocationArn (with the + # account id); debug-log it, raise with only the observed key names. + verbose_logger.debug("Nova Reel completed invocation without an S3 output location: %r", raw) + output_config: Final = raw.get("outputDataConfig") + observed_keys: Final = tuple(sorted(output_config.keys())) if isinstance(output_config, Mapping) else () + raise ValueError( + f"No S3 output location on completed invocation (observed outputDataConfig keys: {observed_keys})" + ) + + bucket, prefix = _parse_s3_uri(s3_uri) + # Nova Reel v1:1 writes output.mp4 into a per-invocation folder under the + # configured prefix (AWS docs); only the older v1:0 flows placed it flat + # under the prefix. With a shared prefix the flat key can hold a foreign + # or stale object, so the flat fallback is v1:0-only. The modelArn on the + # get-async-invoke response is authoritative over the id-encoded model + # (inference profiles can encode a base id while the ARN names the + # actually-invoked variant). + model_arn: Final[object] = raw.get("modelArn") + resolved_model: Final[str] = model_arn.rsplit("/", 1)[-1] if isinstance(model_arn, str) and model_arn else model + gate_model: Final[str] = resolved_model if "nova-reel" in resolved_model else model + allow_flat: Final[bool] = BedrockModelInfo.get_base_model(gate_model) == "amazon.nova-reel-v1:0" + key_candidates: Final[list[str]] = [ # mutable-ok: candidate S3 keys are tried in order + f"{prefix}/{invocation_arn.rsplit('/', 1)[-1]}/{NOVA_REEL_OUTPUT_FILENAME}".lstrip("/"), + *( + [f"{prefix}/{NOVA_REEL_OUTPUT_FILENAME}".lstrip("/")] # mutable-ok: v1:0-only flat fallback key + if allow_flat + else [] + ), + ] + return self._download_s3_object( + bucket, + key_candidates, + litellm_params, + raw, + region_default=status_region, + api_key=api_key, + timeout=timeout, + ) + + def _download_s3_object( + self, + bucket: str, + key_candidates: Sequence[str], + litellm_params: GenericLiteLLMParams | Mapping[str, object] | None, + raw: BedrockGetAsyncInvokeResponse, + region_default: str | None = None, + api_key: str | None = None, + timeout: float | httpx.Timeout | None = None, + ) -> bytes: + """Download the output object from S3. + + region_default (the region the status request resolved: explicit + aws_region_name > ARN region > env > default) is used unless the fresh + litellm_params carry an explicit aws_region_name, which still wins. + timeout (the video_content call timeout) bounds the boto3 client's + read timeout; connect timeout is it or DEFAULT_S3_CONNECT_TIMEOUT_S, + whichever is smaller. + """ + try: + import boto3 + from botocore.config import Config as BotocoreConfig + from botocore.exceptions import BotoCoreError, ClientError, NoCredentialsError + except ImportError: + raise ImportError("Missing boto3 to download Nova Reel output. Run 'pip install boto3'.") + + optional_params: Final[_LitellmParamsDict] = _params_to_dict(litellm_params) + explicit_region: Final[str | None] = optional_params.pop("aws_region_name", None) + bearer_token: Final[str | None] = bedrock_bearer_token(api_key) + bearer_s3_guidance: Final = ( + "Nova Reel video content download requires AWS SigV4 credentials with S3 read " + "access (aws_access_key_id/aws_secret_access_key or an ambient credential chain). " + "Bedrock bearer tokens only cover the Bedrock asynchronous invoke API and cannot " + "download objects from S3." + ) + # Always resolve SigV4 credentials for S3: a Bedrock bearer token only + # covers the async-invoke API, never S3 object access. With a bearer token + # in play and no resolvable SigV4 credentials, resolve_credentials raises + # NoCredentialsError before the guidance check below can fire; catch it so + # the bearer guidance 400 (not the generic mapping) reaches the caller. + try: + credentials, region = self._load_credentials( + optional_params, + aws_region_name=(explicit_region if explicit_region is not None else region_default), + ) + except NoCredentialsError as err: + if bearer_token is not None: + raise BedrockError(status_code=400, message=bearer_s3_guidance) from err + raise # no bearer in play: unchanged propagation + if bearer_token is not None and credentials is None: + raise BedrockError( + status_code=400, + message=bearer_s3_guidance, + ) + session_kwargs: Final[dict[str, str]] = {"region_name": region} # mutable-ok: credential keys are added below + if credentials is not None: + session_kwargs["aws_access_key_id"] = credentials.access_key + session_kwargs["aws_secret_access_key"] = credentials.secret_key + if credentials.token: + session_kwargs["aws_session_token"] = credentials.token + read_timeout: Final[float] = float(timeout) if isinstance(timeout, (int, float)) else DEFAULT_S3_READ_TIMEOUT_S + connect_timeout: Final[float] = min(DEFAULT_S3_CONNECT_TIMEOUT_S, read_timeout) + client_config: Final = BotocoreConfig(connect_timeout=connect_timeout, read_timeout=read_timeout) + session: Final = boto3.Session(**session_kwargs) + s3_client: Final = session.client("s3", config=client_config) + + s3_uri: Final[str | None] = _s3_uri_from_output_config(raw.get("outputDataConfig")) + errors: list[str] = [] # mutable-ok: error strings accumulate across candidate keys + try: + for key in key_candidates: + body = None # per-iteration resource; closed in finally + try: + obj = s3_client.get_object(Bucket=bucket, Key=key) + body = obj["Body"] + return body.read() + except ClientError as err: + # Annotated, not Final: basedpyright forbids Final assignment inside loops. + error_code: str = _client_error_code(err) + if error_code == "NoSuchKey" or _client_error_http_status(err) == 404: + # Only a missing key falls through to the next candidate. + errors.append(str(err)) + continue + if error_code == "AccessDenied": + raise BedrockError( + status_code=403, + message=(f"Access denied downloading Nova Reel output from {s3_uri} (key {key!r}): {err}"), + ) from err + raise BedrockError( + status_code=502, + message=( + f"AWS error (code {error_code!r}) downloading Nova Reel output " + f"from {s3_uri} (key {key!r}): {err}" + ), + ) from err + finally: + if body is not None: + body.close() + except BotoCoreError as err: + # ClientError never reaches this handler: it inherits Exception (not + # BotoCoreError) and is handled per-key above. Anything else that is + # a BotoCoreError (NoCredentialsError, EndpointConnectionError, ...) + # aborts the download and maps to a 502. botocore messages carry + # class name + failure reason, never credentials. + raise BedrockError( + status_code=502, + message=f"Failed to download Nova Reel output from S3: {type(err).__name__}: {err}", + ) + finally: + s3_client.close() + raise BedrockError( + status_code=404, + message=( + "Nova Reel output video not found in the S3 output location " + f"{s3_uri}. Tried keys: {tuple(key_candidates)}. Errors: {tuple(errors)}" + ), + headers={}, # mutable-ok: synthesized 404 carries no provider headers + ) diff --git a/litellm/llms/bedrock/videos/transformation.py b/litellm/llms/bedrock/videos/transformation.py new file mode 100644 index 00000000000..d66182bfb63 --- /dev/null +++ b/litellm/llms/bedrock/videos/transformation.py @@ -0,0 +1,765 @@ +""" +Amazon Nova Reel video generation on Bedrock (StartAsyncInvoke / GetAsyncInvoke). + +Nova Reel is invoked through the Bedrock asynchronous invoke API: +- create: POST {runtime}/async-invoke body: {modelId, modelInput, outputDataConfig} +- status: GET {runtime}/async-invoke/{arn} response: {invocationArn, status, failureMessage, ...} +- content: download output.mp4 from the S3 output location when status is Completed + +The real HTTP (AWS SigV4 signing + S3 download) happens in +``litellm.llms.bedrock.videos.handler.BedrockVideoGeneration``; this config +builds/validates the request bodies and maps responses to ``VideoObject``. + +Refs: +- https://docs.aws.amazon.com/nova/latest/userguide/video-req-resp-structure.html +- https://docs.aws.amazon.com/nova/latest/userguide/video-gen-access.html +- bedrock-runtime service model: StartAsyncInvoke POST /async-invoke, + GetAsyncInvoke GET /async-invoke/{invocationArn}, AsyncInvokeStatus enum + InProgress | Completed | Failed. +""" + +from __future__ import annotations + +import base64 +import binascii +import re +from collections.abc import Mapping +from datetime import datetime +from types import MappingProxyType +from typing import TYPE_CHECKING, Final, TypeAlias + +import httpx +from httpx._types import FileContent, RequestFiles + +from litellm._logging import verbose_logger +from litellm.llms.base_llm.videos.transformation import BaseVideoConfig +from litellm.llms.bedrock.common_utils import BedrockError +from litellm.types.llms.bedrock import ( + BedrockGetAsyncInvokeResponse, + BedrockStartAsyncInvokeResponse, +) +from litellm.types.router import GenericLiteLLMParams +from litellm.types.videos.main import VideoCreateOptionalRequestParams +from litellm.types.videos.utils import ( + decode_video_id_with_provider, + encode_video_id_with_provider, + extract_original_video_id, +) + +if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging + from litellm.llms.custom_httpx.http_handler import HTTPHandler + from litellm.types.videos.main import CharacterObject, VideoObject + +_SupportedParams: TypeAlias = list[str] +_VideoParams: TypeAlias = dict[str, object] +_VideoHeaders: TypeAlias = dict[str, str] +_VideoStringParams: TypeAlias = dict[str, str] + +NOVA_REEL_DEFAULT_DURATION_SECONDS: Final = 6 +NOVA_REEL_DEFAULT_FPS: Final = 24 +NOVA_REEL_DEFAULT_DIMENSION: Final = "1280x720" + +# AWS clientRequestToken: alphanumeric and hyphens only, at most 64 chars. +NOVA_REEL_CLIENT_REQUEST_TOKEN_MAX_LEN: Final = 64 +_NOVA_REEL_TOKEN_UNSAFE: Final = re.compile(r"[^0-9A-Za-z-]") + +# AWS async-invoke status enum (bedrock-runtime service model) -> OpenAI-style +# VideoObject.status values used across LiteLLM video providers. +NOVA_REEL_STATUS_MAP: Final[Mapping[str, str]] = MappingProxyType( + { + "InProgress": "processing", + "Completed": "completed", + "Failed": "failed", + } +) + +_UNSUPPORTED_MESSAGE: Final = ( + "video {operation} is not supported for Bedrock Nova Reel; Nova Reel exposes " + "create (video_generation), status (video_status) and content (video_content) only" +) + + +def _unsupported_operation_error(operation: str) -> BedrockError: + """400-class error for unsupported video operations. + + Verified against litellm.exception_type: a plain ValueError/NotImplementedError + both fall through to APIConnectionError (500-class), while BedrockError carries + status_code=400 into BadRequestError through the bedrock mapping. + """ + return BedrockError(status_code=400, message=_UNSUPPORTED_MESSAGE.format(operation=operation)) + + +def _user_input_error(message: str) -> BedrockError: + """400-class error for invalid caller input (same mapping as _unsupported_operation_error). + + A plain ValueError would surface as APIConnectionError (500-class, which OpenAI + SDKs auto-retry); BedrockError(status_code=400) maps to BadRequestError instead. + """ + return BedrockError(status_code=400, message=message) + + +def _data_url_payload(image: str) -> str: + """Validate a ``data:;base64,`` URL and return only its payload.""" + header, sep, encoded = image.partition(",") + if not sep or not header.removeprefix("data:").endswith(";base64"): + raise _user_input_error( + "Nova Reel input_reference data URLs must be base64-encoded " + "(data:image/png;base64,); got an unsupported data URL prefix." + ) + return encoded + + +def _file_content_to_b64_and_format(image: FileContent) -> tuple[str, str]: + """Read an input-reference image and return (base64, "png"|"jpeg").""" + if isinstance(image, bytes): + image_bytes: bytes = image + elif isinstance(image, str): + # Base64-encoded string, optionally wrapped in a data URL; detect the + # format from the decoded header. + payload: Final = _data_url_payload(image) if image.startswith("data:") else image + try: + image_bytes = base64.b64decode(payload, validate=True) + except (binascii.Error, ValueError) as err: + raise _user_input_error(f"Nova Reel input_reference string did not decode as base64: {err}") from err + elif hasattr(image, "read") and callable(getattr(image, "read", None)): + if hasattr(image, "seek"): + image.seek(0) + image_bytes = image.read() + if not isinstance(image_bytes, bytes): + raise _user_input_error( + "Nova Reel input_reference file objects must be opened in binary mode " + f"(read() returned {type(image_bytes).__name__}); open image files with 'rb'." + ) + else: + raise _user_input_error( + f"Nova Reel input_reference must be bytes, a file-like object or a base64 string; got {type(image)!r}" + ) + + if image_bytes.startswith(b"\x89PNG"): + image_format: str = "png" + elif image_bytes.startswith(b"\xff\xd8"): + image_format = "jpeg" + else: + raise _user_input_error( + "Nova Reel input_reference images must be PNG or JPEG encoded; " + f"unrecognized image header bytes {image_bytes[:8]!r}" + ) + return base64.b64encode(image_bytes).decode("utf-8"), image_format + + +def _duration_seconds_from_request(request_data: Mapping[str, object] | None) -> float | None: + """durationSeconds from the StartAsyncInvoke request envelope, for cost calculation. + + TEXT_VIDEO and MULTI_SHOT_AUTOMATED carry a single durationSeconds on + videoGenerationConfig; MULTI_SHOT_MANUAL carries per-shot durations inside + multiShotManualParams.shots[*].durationSeconds, so the billable duration is + the sum of the shot durations. + """ + if request_data is None: + return None + model_input: Final[object | None] = request_data.get("modelInput") + if not isinstance(model_input, Mapping): + return None + manual_params: Final[object | None] = model_input.get("multiShotManualParams") + if isinstance(manual_params, Mapping): + shots: Final[object | None] = manual_params.get("shots") + if isinstance(shots, list): + total = 0.0 + saw_duration = False + for shot in shots: + if not isinstance(shot, Mapping): + continue + # Annotated, not Final: basedpyright forbids Final assignment inside loops. + shot_duration: object | None = shot.get("durationSeconds") + if not isinstance(shot_duration, (int, float, str)): + continue + try: + total += float(shot_duration) + saw_duration = True + except ValueError: + continue + if saw_duration: + return total + generation_config: Final[object | None] = model_input.get("videoGenerationConfig") + if not isinstance(generation_config, Mapping): + return None + duration: Final[object | None] = generation_config.get("durationSeconds") + if not isinstance(duration, (int, float, str)): + return None + try: + return float(duration) + except ValueError: + return None + + +def _sanitize_client_request_token(token: str) -> str: + """AWS clientRequestToken allows alphanumeric and hyphens, max 64 chars.""" + return _NOVA_REEL_TOKEN_UNSAFE.sub("-", token)[:NOVA_REEL_CLIENT_REQUEST_TOKEN_MAX_LEN] + + +def _request_id_from_litellm_params(litellm_params: GenericLiteLLMParams) -> str | None: + """Best-effort litellm request id: metadata.request_id, then the litellm_call_id extra field.""" + metadata: Final = getattr(litellm_params, "metadata", None) + if isinstance(metadata, Mapping): + request_id: Final = metadata.get("request_id") + if isinstance(request_id, str) and request_id: + return request_id + call_id: Final = getattr(litellm_params, "litellm_call_id", None) + if isinstance(call_id, str) and call_id: + return call_id + return None + + +def _generation_config_from_op(op: _VideoParams, task_type: object) -> _VideoParams: + """videoGenerationConfig from request params (pops seconds/size/dimension/fps/seed). + + durationSeconds lives on videoGenerationConfig for TEXT_VIDEO and + MULTI_SHOT_AUTOMATED only; MULTI_SHOT_MANUAL durations live per shot + inside multiShotManualParams.shots, so it is omitted there. + """ + generation_config: Final[_VideoParams] = { + "fps": NOVA_REEL_DEFAULT_FPS, + "dimension": NOVA_REEL_DEFAULT_DIMENSION, + } + single_duration: Final[bool] = task_type != "MULTI_SHOT_MANUAL" + if single_duration: + generation_config["durationSeconds"] = NOVA_REEL_DEFAULT_DURATION_SECONDS + seconds: Final = op.pop("seconds", None) + if isinstance(seconds, (int, float, str)): + try: + parsed_seconds: Final = int(float(seconds)) + except ValueError as err: + raise _user_input_error(f"Nova Reel seconds must be a number; got {seconds!r}") from err + if single_duration: + generation_config["durationSeconds"] = parsed_seconds + size: Final = op.pop("size", None) + if size is not None and isinstance(size, str) and "x" in size: + generation_config["dimension"] = size.replace(" ", "") + dimension: Final = op.pop("dimension", None) + if dimension is not None and isinstance(dimension, str) and dimension.strip(): + generation_config["dimension"] = dimension + fps: Final = op.pop("fps", None) + if fps is not None: + try: + generation_config["fps"] = int(float(fps)) + except ValueError as err: + raise _user_input_error(f"Nova Reel fps must be a number; got {fps!r}") from err + seed: Final = op.pop("seed", None) + if seed is not None: + try: + generation_config["seed"] = int(float(seed)) + except ValueError as err: + raise _user_input_error(f"Nova Reel seed must be a number; got {seed!r}") from err + return generation_config + + +def _task_params_from_op(task_type: object, op: _VideoParams, prompt: str, input_reference: object) -> _VideoParams: + """Per-taskType params section for modelInput (pops multiShot params from op).""" + if task_type == "MULTI_SHOT_AUTOMATED": + # AWS schema: automated multi-shot takes multiShotAutomatedParams + # (never textToVideoParams) and forbids input images. + if input_reference is not None: + raise _user_input_error( + "Nova Reel MULTI_SHOT_AUTOMATED does not accept input images " + "(input_reference/image); automated multi-shot is text-driven only." + ) + automated_params: Final[object | None] = op.pop("multiShotAutomatedParams", None) + automated_section: Final[_VideoParams] = { + "multiShotAutomatedParams": ( + automated_params if isinstance(automated_params, Mapping) else {"text": prompt} + ) + } + return automated_section + if task_type == "MULTI_SHOT_MANUAL": + manual_params: Final[object | None] = op.pop("multiShotManualParams", None) + if not isinstance(manual_params, Mapping): + raise _user_input_error( + "Nova Reel MULTI_SHOT_MANUAL requires multiShotManualParams in the request " + "(shot definitions with per-shot text/images/durationSeconds); " + f"got {manual_params!r}." + ) + manual_section: Final[_VideoParams] = {"multiShotManualParams": manual_params} + return manual_section + # TEXT_VIDEO (default): textToVideoParams with the prompt and optional images. + text_to_video_params: Final[_VideoParams] = {"text": prompt} + if input_reference is not None: + if isinstance(input_reference, dict): + # Pre-built provider shape: {"format": ..., "source": {...}} + text_to_video_params["images"] = [input_reference] # mutable-ok: AWS images param is a list + else: + image_b64, image_format = _file_content_to_b64_and_format( + input_reference # pyright: ignore[reportArgumentType] # untyped user input; helper validates + ) + text_to_video_params["images"] = [ # mutable-ok: AWS images param is a list + {"format": image_format, "source": {"bytes": image_b64}} # mutable-ok: nested AWS image payload + ] + text_section: Final[_VideoParams] = {"textToVideoParams": text_to_video_params} + return text_section + + +class BedrockNovaReelVideoConfig(BaseVideoConfig): + """ + Video config for amazon.nova-reel-v1:0 (and regional variants) on Bedrock. + + Health checks: the proxy's video_generation probe calls avideo_generation() + with only a prompt, which this config rejects with a 400 because Nova Reel + requires a per-request output_s3_uri; the deployment is then reported + unhealthy (the health check marks any errored probe unhealthy, 4xx included). + Every video provider's probe creates a real video, so there is no cheaper + repo-consistent probe. Operators should set + ``model_info.disable_background_health_check: true`` on Nova Reel + deployments (or put ``output_s3_uri`` in the deployment litellm_params and + accept that each health check starts a real, billed generation). + """ + + def get_supported_openai_params(self, model: str) -> _SupportedParams: + return [ # mutable-ok: BaseVideoConfig requires a list + "seconds", + "size", + "seed", + "fps", + "dimension", + "taskType", + "output_s3_uri", + "kmsKeyId", + "bucketOwner", + "input_reference", + "image", + ] + + def map_openai_params( + self, + video_create_optional_params: VideoCreateOptionalRequestParams, + model: str, + drop_params: bool, + ) -> _VideoParams: + # All supported params pass through untouched; Nova Reel-specific keys + # keep their AWS names (durationSeconds etc. are built in + # transform_video_create_request from seconds/size). + return dict(video_create_optional_params) # mutable-ok: BaseVideoConfig requires a mutable param mapping + + def validate_environment( + self, + headers: _VideoHeaders, + model: str, + api_key: str | None = None, + litellm_params: GenericLiteLLMParams | None = None, + ) -> _VideoHeaders: + if headers is None: + headers = {} # mutable-ok: None headers start empty before Content-Type is added + if "Content-Type" not in headers: + headers["Content-Type"] = "application/json" + return headers + + def get_error_class( + self, + error_message: str, + status_code: int, + headers: dict | httpx.Headers, # mutable-ok: BaseVideoConfig passes headers as a dict + ) -> BedrockError: + """BedrockError synthesizes a response that keeps provider headers like x-amzn-RequestId.""" + return BedrockError(status_code=status_code, message=error_message, headers=headers) + + def get_complete_url( + self, + model: str, + api_base: str | None, + litellm_params: _VideoParams, + ) -> str: + # The shared OpenAI-style video handlers resolve the URL before running + # any config transform, so the transform-level unsupported-operation + # guards never run for those routes. Nova Reel create/status/content + # bypass these handlers entirely (litellm.llms.bedrock.videos.dispatch + # builds the signed requests), so get_complete_url is only reachable + # for unsupported operations: raise the same 400-class BedrockError the + # transforms raise instead of a NotImplementedError that would surface + # as a 500-class APIConnectionError. + raise BedrockError( + status_code=400, + message=( + "bedrock video supports create, status and content only; this " + "request reached the shared video handler, which only happens " + "for unsupported operations (edit, characters, remix, extension, " + "list, delete)" + ), + ) + + def transform_video_create_request( + self, + model: str, + prompt: str, + api_base: str, + video_create_optional_request_params: _VideoParams, + litellm_params: GenericLiteLLMParams, + headers: _VideoHeaders, + ) -> tuple[_VideoParams, RequestFiles, str]: + """ + Build the StartAsyncInvoke request body. + + Returns (request_body, files, "POST") where request_body is the wrapped + async-invoke envelope {modelId, modelInput, outputDataConfig}. + """ + op: Final[_VideoParams] = dict( # mutable-ok: request params are popped in place while building modelInput + video_create_optional_request_params + ) + + output_s3_uri: Final = op.pop("output_s3_uri", None) + if not output_s3_uri or not str(output_s3_uri).strip(): + raise _user_input_error( + "Nova Reel video generation requires an S3 output location. Pass " + 'output_s3_uri="s3://my-bucket/optional-prefix/" in the request ' + "(Bedrock writes output.mp4 there)." + ) + if not str(output_s3_uri).startswith("s3://"): + raise _user_input_error( + "Nova Reel output_s3_uri must be an S3 URI starting with s3:// " + f"(got {output_s3_uri!r}); Bedrock writes output.mp4 into that bucket." + ) + # Pop the S3 config keys (and their snake_case aliases) before the + # modelInput passthrough merge so they never leak into modelInput; they + # are forwarded into outputDataConfig.s3OutputDataConfig below. + kms_key_id: Final = op.pop("kmsKeyId", None) or op.pop("output_s3_kms_key_id", None) + bucket_owner: Final = op.pop("bucketOwner", None) or op.pop("output_s3_bucket_owner", None) + + task_type: Final = op.pop("taskType", "TEXT_VIDEO") + + if not str(prompt or "").strip(): + raise _user_input_error("Nova Reel prompt is required and cannot be empty (or whitespace-only).") + + # Pop both reference keys unconditionally so neither leaks into modelInput; + # input_reference wins when a caller passes both. + popped_reference: Final = op.pop("input_reference", None) + popped_image: Final = op.pop("image", None) + input_reference: Final = popped_reference if popped_reference is not None else popped_image + + model_input: Final[_VideoParams] = { + "taskType": task_type, + "videoGenerationConfig": _generation_config_from_op(op, task_type), + } + model_input.update(_task_params_from_op(task_type, op, prompt, input_reference)) + + # Known non-AWS video-client params have no Nova Reel mapping; drop them + # instead of leaking junk keys into modelInput. Everything else keeps the + # verbatim passthrough (provider-specific AWS keys like multiShotManualParams). + for dropped_key in ("parameters", "resolution", "characters", "user", "extra_headers"): + op.pop(dropped_key, None) + # Pop both token keys unconditionally (caller-supplied wins over the + # litellm request id) so neither leaks into modelInput. + caller_token_snake: Final = op.pop("client_request_token", None) + caller_token_camel: Final = op.pop("clientRequestToken", None) + caller_token: Final = caller_token_snake if caller_token_snake is not None else caller_token_camel + model_input.update(op) + + request_id: Final[str | None] = _request_id_from_litellm_params(litellm_params) + client_request_token: Final[str | None] = ( + _sanitize_client_request_token(str(caller_token)) + if caller_token is not None + else (_sanitize_client_request_token(request_id) if request_id is not None else None) + ) + + # Optional S3 config keys are added below before the envelope is returned. + s3_output_config: Final[_VideoParams] = {"s3Uri": output_s3_uri} + if kms_key_id is not None: + s3_output_config["kmsKeyId"] = kms_key_id + if bucket_owner is not None: + s3_output_config["bucketOwner"] = bucket_owner + request_body: Final[_VideoParams] = { + "modelId": model, + "modelInput": model_input, + "outputDataConfig": {"s3OutputDataConfig": s3_output_config}, + } + if client_request_token: + # Envelope key only when populated: caller-supplied token or litellm request id. + request_body["clientRequestToken"] = client_request_token + return request_body, [], "POST" # mutable-ok: HTTP files payload requires a list + + def transform_video_create_response( + self, + model: str, + raw_response: httpx.Response, + logging_obj: LiteLLMLogging | None, + custom_llm_provider: str | None = None, + request_data: Mapping[str, object] | None = None, + ) -> VideoObject: + from litellm.types.videos.main import VideoObject + + try: + response_data: Final[BedrockStartAsyncInvokeResponse] = raw_response.json() + except ValueError as err: + raise BedrockError( + status_code=502, + message=f"Nova Reel async-invoke returned a non-JSON response: {err}", + ) from err + invocation_arn: Final[str | None] = response_data.get("invocationArn") + if not invocation_arn: + raise ValueError(f"Nova Reel async-invoke response missing invocationArn: {response_data}") + video_obj = VideoObject( + id=encode_video_id_with_provider(invocation_arn, "bedrock", model), + object="video", + status="processing", + model=model, + created_at=_epoch_now(), + ) + duration_seconds: Final[float | None] = _duration_seconds_from_request(request_data) + if duration_seconds is not None: + # Mirrors the Vertex video config: lets the video cost calculator + # compute cost from output_cost_per_second * duration_seconds. + video_obj.usage = {"duration_seconds": duration_seconds} # mutable-ok: VideoObject.usage payload dict + return video_obj + + def transform_video_status_retrieve_request( + self, + video_id: str, + api_base: str, + litellm_params: GenericLiteLLMParams, + headers: _VideoHeaders, + ) -> tuple[str, _VideoParams]: + raise NotImplementedError( + "Nova Reel status URLs are built and signed in BedrockVideoGeneration " + "(GET /async-invoke/{arn}). Do not use this transform for this config." + ) + + def transform_video_status_retrieve_response( + self, + raw_response: httpx.Response, + logging_obj: LiteLLMLogging | None, + custom_llm_provider: str | None = None, + client: HTTPHandler | None = None, + model: str | None = None, + video_id: str | None = None, + ) -> VideoObject: + from litellm.types.videos.main import VideoObject + + try: + response_data: Final[BedrockGetAsyncInvokeResponse] = raw_response.json() + except ValueError as err: + raise BedrockError( + status_code=502, + message=f"Nova Reel get-async-invoke returned a non-JSON response: {err}", + ) from err + invocation_arn: Final[str | None] = response_data.get("invocationArn") + if not invocation_arn: + raise ValueError(f"Nova Reel get-async-invoke response missing invocationArn: {response_data}") + status_field: Final[object] = response_data.get("status") + if not isinstance(status_field, str) or not status_field: + raise BedrockError( + status_code=500, + message=( + "Nova Reel get-async-invoke response had an unexpected shape: " + f"missing or empty 'status' (observed keys: {sorted(response_data.keys())})" + ), + ) + raw_status: Final[str] = status_field + if raw_status not in NOVA_REEL_STATUS_MAP: + verbose_logger.warning("Nova Reel unmapped invocationStatus=%r; reporting processing", raw_status) + status: Final[str] = NOVA_REEL_STATUS_MAP.get(raw_status, "processing") + + failure_message: Final[str | None] = response_data.get("failureMessage") + video_obj = VideoObject( + id=encode_video_id_with_provider(invocation_arn, "bedrock", model), + object="video", + status=status, + model=model, + created_at=_to_epoch(response_data.get("submitTime")), + completed_at=(_to_epoch(response_data.get("endTime")) if status == "completed" else None), + error=( + {"message": failure_message} # mutable-ok: VideoObject.error accepts a plain payload dict + if status == "failed" and failure_message + else None + ), + ) + output_config: Final = response_data.get("outputDataConfig") + if output_config is not None: + s3_config: Final = output_config.get("s3OutputDataConfig") + if s3_config is not None: + s3_uri: Final[str | None] = s3_config.get("s3Uri") + if s3_uri: + # Provider detail, not usage: rides on _hidden_params (like the + # vertex video transforms' provider-specific fields) so cost + # calculators reading usage.duration_seconds never trip on it. + video_obj._hidden_params["output_s3_uri"] = s3_uri + return video_obj + + def transform_video_content_request( + self, + video_id: str, + api_base: str, + litellm_params: GenericLiteLLMParams, + headers: _VideoHeaders, + variant: str | None = None, + ) -> tuple[str, _VideoParams]: + raise NotImplementedError( + "Nova Reel video content is downloaded from the S3 output location in " + "BedrockVideoGeneration. Do not use this transform for this config." + ) + + def transform_video_content_response( + self, + raw_response: httpx.Response, + logging_obj: LiteLLMLogging | None, + ) -> bytes: + return raw_response.content + + def transform_video_remix_request( + self, + video_id: str, + prompt: str, + api_base: str, + litellm_params: GenericLiteLLMParams, + headers: _VideoHeaders, + extra_body: Mapping[str, object] | None = None, + ) -> tuple[str, _VideoParams]: + raise _unsupported_operation_error("remix") + + def transform_video_remix_response( + self, + raw_response: httpx.Response, + logging_obj: LiteLLMLogging | None, + custom_llm_provider: str | None = None, + ) -> VideoObject: + raise _unsupported_operation_error("remix") + + def transform_video_list_request( + self, + api_base: str, + litellm_params: GenericLiteLLMParams, + headers: _VideoHeaders, + after: str | None = None, + limit: int | None = None, + order: str | None = None, + extra_query: Mapping[str, object] | None = None, + ) -> tuple[str, _VideoParams]: + raise _unsupported_operation_error("list") + + def transform_video_list_response( + self, + raw_response: httpx.Response, + logging_obj: LiteLLMLogging | None, + custom_llm_provider: str | None = None, + ) -> _VideoStringParams: + raise _unsupported_operation_error("list") + + def transform_video_delete_request( + self, + video_id: str, + api_base: str, + litellm_params: GenericLiteLLMParams, + headers: _VideoHeaders, + ) -> tuple[str, _VideoParams]: + raise _unsupported_operation_error("delete") + + def transform_video_delete_response( + self, + raw_response: httpx.Response, + logging_obj: LiteLLMLogging | None, + ) -> VideoObject: + raise _unsupported_operation_error("delete") + + def transform_video_create_character_request( + self, + name: str, + video: object, # base declares Any; never read, this override always raises + api_base: str, + litellm_params: GenericLiteLLMParams, + headers: _VideoHeaders, + ) -> tuple[str, list]: + raise _unsupported_operation_error("create character") + + def transform_video_create_character_response( + self, + raw_response: httpx.Response, + logging_obj: LiteLLMLogging | None, + ) -> CharacterObject: + raise _unsupported_operation_error("create character") + + def transform_video_get_character_request( + self, + character_id: str, + api_base: str, + litellm_params: GenericLiteLLMParams, + headers: _VideoHeaders, + ) -> tuple[str, _VideoParams]: + raise _unsupported_operation_error("get character") + + def transform_video_get_character_response( + self, + raw_response: httpx.Response, + logging_obj: LiteLLMLogging | None, + ) -> CharacterObject: + raise _unsupported_operation_error("get character") + + def transform_video_edit_request( + self, + prompt: str, + video_id: str, + api_base: str, + litellm_params: GenericLiteLLMParams, + headers: _VideoHeaders, + video_file: FileContent | None = None, + extra_body: Mapping[str, object] | None = None, + prefetched_source_data: dict[str, object] | None = None, + ) -> tuple[str, Mapping[str, object], RequestFiles | None]: + raise _unsupported_operation_error("edit") + + def transform_video_edit_response( + self, + raw_response: httpx.Response, + logging_obj: LiteLLMLogging | None, + custom_llm_provider: str | None = None, + request_data: dict | None = None, + ) -> VideoObject: + raise _unsupported_operation_error("edit") + + def transform_video_extension_request( + self, + prompt: str, + video_id: str, + seconds: str, + api_base: str, + litellm_params: GenericLiteLLMParams, + headers: _VideoHeaders, + extra_body: Mapping[str, object] | None = None, + ) -> tuple[str, _VideoParams]: + raise _unsupported_operation_error("extension") + + def transform_video_extension_response( + self, + raw_response: httpx.Response, + logging_obj: LiteLLMLogging | None, + custom_llm_provider: str | None = None, + ) -> VideoObject: + raise _unsupported_operation_error("extension") + + @staticmethod + def extract_invocation_arn(video_id: str) -> str: + """Return the raw Bedrock invocationArn from a (possibly encoded) video id.""" + decoded: Final = decode_video_id_with_provider(video_id) + arn: Final = decoded.get("video_id") or "" + return arn or extract_original_video_id(video_id) + + +def _epoch_now() -> int: + import time + + return int(time.time()) + + +def _to_epoch(timestamp: str | float | None) -> int | None: + """Bedrock timestamps to unix epoch seconds. + + The bedrock-runtime Smithy model declares timestampFormat: iso8601 for + submitTime/endTime, so real GetAsyncInvoke payloads carry strings like + "2026-01-15T10:30:00Z"; numeric epochs are accepted too. + """ + if timestamp is None: + return None + try: + return int(float(timestamp)) + except (TypeError, ValueError): + pass + try: + return int(datetime.fromisoformat(str(timestamp).replace("Z", "+00:00")).timestamp()) + except ValueError: + verbose_logger.warning("Nova Reel response carried an unparseable timestamp %r; leaving it unset", timestamp) + return None diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 2d0985bcb64..0d343f5b100 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -292,6 +292,42 @@ "output_cost_per_image": 0.06, "supports_nova_canvas_image_edit": true }, + "amazon.nova-reel-v1:0": { + "litellm_provider": "bedrock", + "mode": "video_generation", + "output_cost_per_second": 0.08, + "source": "https://aws.amazon.com/bedrock/pricing/ (AWS Bulk Price List API, AmazonBedrock offer 2026-09-22) (accessed 2026-09-24)", + "supported_endpoints": ["/v1/videos"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["video"] + }, + "amazon.nova-reel-v1:1": { + "litellm_provider": "bedrock", + "mode": "video_generation", + "output_cost_per_second": 0.08, + "source": "https://aws.amazon.com/bedrock/pricing/ (AWS Bulk Price List API, AmazonBedrock offer 2026-09-22) (accessed 2026-09-24)", + "supported_endpoints": ["/v1/videos"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["video"] + }, + "us.amazon.nova-reel-v1:0": { + "litellm_provider": "bedrock", + "mode": "video_generation", + "output_cost_per_second": 0.08, + "source": "https://aws.amazon.com/bedrock/pricing/ (AWS Bulk Price List API, AmazonBedrock offer 2026-09-22) (accessed 2026-09-24)", + "supported_endpoints": ["/v1/videos"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["video"] + }, + "us.amazon.nova-reel-v1:1": { + "litellm_provider": "bedrock", + "mode": "video_generation", + "output_cost_per_second": 0.08, + "source": "https://aws.amazon.com/bedrock/pricing/ (AWS Bulk Price List API, AmazonBedrock offer 2026-09-22) (accessed 2026-09-24)", + "supported_endpoints": ["/v1/videos"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["video"] + }, "us.writer.palmyra-x4-v1:0": { "input_cost_per_token": 2.5e-06, "litellm_provider": "bedrock_converse", diff --git a/litellm/provider_endpoints_support_backup.json b/litellm/provider_endpoints_support_backup.json index c9635587eeb..dce0c449c2f 100644 --- a/litellm/provider_endpoints_support_backup.json +++ b/litellm/provider_endpoints_support_backup.json @@ -250,7 +250,8 @@ "vector_stores_search": true, "count_tokens": true, "rag_ingest": true, - "rag_query": true + "rag_query": true, + "video_generations": true } }, "s3_vectors": { diff --git a/litellm/proxy/video_endpoints/endpoints.py b/litellm/proxy/video_endpoints/endpoints.py index fe966c2e31a..c3b3ed49b15 100644 --- a/litellm/proxy/video_endpoints/endpoints.py +++ b/litellm/proxy/video_endpoints/endpoints.py @@ -1,6 +1,6 @@ #### Video Endpoints ##### -from typing import Final +from typing import TYPE_CHECKING, Final from fastapi import APIRouter, Depends, File, Form, Request, Response, UploadFile from fastapi.responses import ORJSONResponse @@ -27,9 +27,36 @@ from litellm.types.videos.utils import ( decode_video_id_with_provider, ) +if TYPE_CHECKING: + from litellm.router import Router + router: Final = APIRouter() +def _resolve_model_name_from_decoded_model_id( + llm_router: "Router", + model_id_from_decoded: str, + custom_llm_provider: str | None, +) -> str | None: + """Resolve the router model_name from the model id encoded in a video/character id. + + Bedrock cross-region inference-profile ids (e.g. ``us.amazon.nova-reel-v1:1``) + do not match deployments configured with the base model id + (``bedrock/amazon.nova-reel-v1:1``), which would drop the deployment's + litellm_params (aws_* credentials) on status/content calls. Mirrors the Nova + Canvas image-edit transform: strip the region prefix via + ``BedrockModelInfo.get_base_model`` and retry the resolution (bedrock only). + """ + resolved_model = llm_router.resolve_model_name_from_model_id(model_id_from_decoded) + if resolved_model is None and custom_llm_provider == "bedrock": + from litellm.llms.bedrock.common_utils import BedrockModelInfo + + base_model: Final = BedrockModelInfo.get_base_model(model_id_from_decoded) + if base_model != model_id_from_decoded: + resolved_model = llm_router.resolve_model_name_from_model_id(base_model) + return resolved_model + + @router.post( "/v1/videos", dependencies=[Depends(user_api_key_auth)], @@ -269,7 +296,9 @@ async def video_status( # Resolve model_name from model_id if available # This allows the router to automatically inject litellm_params from the model config if model_id_from_decoded and llm_router: - resolved_model: Final = llm_router.resolve_model_name_from_model_id(model_id_from_decoded) + resolved_model: Final = _resolve_model_name_from_decoded_model_id( + llm_router, model_id_from_decoded, custom_llm_provider + ) if resolved_model: data["model"] = resolved_model @@ -369,7 +398,9 @@ async def video_content( # Resolve model_name from model_id if available # This allows the router to automatically inject litellm_params from the model config if model_id_from_decoded and llm_router: - resolved_model: Final = llm_router.resolve_model_name_from_model_id(model_id_from_decoded) + resolved_model: Final = _resolve_model_name_from_decoded_model_id( + llm_router, model_id_from_decoded, custom_llm_provider + ) if resolved_model: data["model"] = resolved_model # Process request using ProxyBaseLLMRequestProcessing @@ -477,7 +508,9 @@ async def video_remix( # Resolve model_name from model_id if available # This allows the router to automatically inject litellm_params from the model config if model_id_from_decoded and llm_router: - resolved_model: Final = llm_router.resolve_model_name_from_model_id(model_id_from_decoded) + resolved_model: Final = _resolve_model_name_from_decoded_model_id( + llm_router, model_id_from_decoded, custom_llm_provider + ) if resolved_model: data["model"] = resolved_model @@ -680,7 +713,9 @@ async def video_get_character( data["custom_llm_provider"] = custom_llm_provider if model_id_from_decoded and llm_router: - resolved_model: Final = llm_router.resolve_model_name_from_model_id(model_id_from_decoded) + resolved_model: Final = _resolve_model_name_from_decoded_model_id( + llm_router, model_id_from_decoded, custom_llm_provider + ) if resolved_model: data["model"] = resolved_model @@ -791,7 +826,9 @@ async def video_edit( data["custom_llm_provider"] = custom_llm_provider if model_id_from_decoded and llm_router: - resolved_model: Final = llm_router.resolve_model_name_from_model_id(model_id_from_decoded) + resolved_model: Final = _resolve_model_name_from_decoded_model_id( + llm_router, model_id_from_decoded, custom_llm_provider + ) if resolved_model: data["model"] = resolved_model @@ -888,7 +925,9 @@ async def video_extension( data["custom_llm_provider"] = custom_llm_provider if model_id_from_decoded and llm_router: - resolved_model: Final = llm_router.resolve_model_name_from_model_id(model_id_from_decoded) + resolved_model: Final = _resolve_model_name_from_decoded_model_id( + llm_router, model_id_from_decoded, custom_llm_provider + ) if resolved_model: data["model"] = resolved_model diff --git a/litellm/types/llms/bedrock.py b/litellm/types/llms/bedrock.py index e4c41c3ee5b..d95c72b1a07 100644 --- a/litellm/types/llms/bedrock.py +++ b/litellm/types/llms/bedrock.py @@ -914,13 +914,29 @@ class AmazonNovaCanvasImageGenerationConfig(TypedDict, total=False): class AmazonNovaCanvasTextToImageParams(TypedDict, total=False): """ Params for Amazon Nova Canvas Text to Image API + + conditionImage + controlMode + controlStrength enable conditioned editing + (SEGMENTATION derives a segmentation mask from the condition image; + CANNY_EDGE follows its prominent contours and is the AWS default). """ text: str negativeText: str controlStrength: float - controlMode: Literal["CANNY_EDIT", "SEGMENTATION"] + controlMode: ReadOnly[Literal["CANNY_EDGE", "SEGMENTATION"]] conditionImage: str + style: ReadOnly[ + Literal[ + "3D_ANIMATED_FAMILY_FILM", + "DESIGN_SKETCH", + "FLAT_VECTOR_ILLUSTRATION", + "GRAPHIC_NOVEL_ILLUSTRATION", + "MAXIMALISM", + "MIDCENTURY_RETRO", + "PHOTOREALISM", + "SOFT_DIGITAL_PAINTING", + ] + ] class AmazonNovaCanvasTextToImageRequest(AmazonNovaCanvasRequestBase, TypedDict, total=False): @@ -1001,6 +1017,175 @@ class AmazonTitanImageGenerationRequestBody(TypedDict, total=False): imageGenerationConfig: AmazonNovaCanvasImageGenerationConfig +################ Amazon Nova Reel Video Types ################ + +NOVA_REEL_TASK_TYPES = Literal["TEXT_VIDEO", "MULTI_SHOT_AUTOMATED", "MULTI_SHOT_MANUAL"] + + +class AmazonNovaReelS3Location(TypedDict, total=False): + """ + S3 location for a Nova Reel input image. + + Ref: https://docs.aws.amazon.com/nova/latest/userguide/video-req-resp-structure.html + """ + + uri: ReadOnly[str] + bucketOwner: ReadOnly[str] + + +class AmazonNovaReelImageSourceLocation(TypedDict, total=False): + """ + Location of a Nova Reel input image: inline base64 bytes or S3. + """ + + bytes: ReadOnly[str] # base64 encoded image + s3Location: ReadOnly[AmazonNovaReelS3Location] + + +class AmazonNovaReelImageSource(TypedDict, total=False): + """ + Image source for Nova Reel textToVideoParams.images entries. + """ + + format: ReadOnly[Literal["png", "jpeg"]] + source: ReadOnly[AmazonNovaReelImageSourceLocation] + + +class AmazonNovaReelTextToVideoParams(TypedDict, total=False): + """ + Params for Amazon Nova Reel text/image-to-video generation. + + Ref: https://docs.aws.amazon.com/nova/latest/userguide/video-req-resp-structure.html + """ + + text: ReadOnly[str] + images: ReadOnly[Sequence[AmazonNovaReelImageSource]] + + +class AmazonNovaReelVideoGenerationConfig(TypedDict, total=False): + """ + Generation config for Amazon Nova Reel. + + durationSeconds: 6 for single-shot (v1:0 supports 6|10; v1:1 single-shot is 6); + multiples of 6 up to 120 for multi-shot. fps: 24 only. dimension: "1280x720" + (v1:0 also supports "720x1280"). seed: 0-2147483646, AWS default 42. + """ + + durationSeconds: ReadOnly[int] + fps: ReadOnly[int] + dimension: ReadOnly[str] + seed: ReadOnly[int] + + +class AmazonNovaReelMultiShotAutomatedParams(TypedDict, total=False): + """ + Params for Nova Reel MULTI_SHOT_AUTOMATED: text-driven shot planning only + (no input images; durationSeconds stays on videoGenerationConfig). + """ + + text: ReadOnly[str] + + +class AmazonNovaReelMultiShotManualShot(TypedDict, total=False): + """ + One shot of MULTI_SHOT_MANUAL: per-shot text, optional input images and + durationSeconds (durations live per shot, not on videoGenerationConfig). + """ + + text: ReadOnly[str] + durationSeconds: ReadOnly[int] + images: ReadOnly[Sequence[AmazonNovaReelImageSource]] + + +class AmazonNovaReelMultiShotManualParams(TypedDict, total=False): + """ + Params for Nova Reel MULTI_SHOT_MANUAL (required for that task type). + """ + + shots: ReadOnly[Sequence[AmazonNovaReelMultiShotManualShot]] + + +class AmazonNovaReelModelInput(TypedDict, total=False): + """ + modelInput body for Nova Reel StartAsyncInvoke. + + TEXT_VIDEO (default) uses textToVideoParams; MULTI_SHOT_AUTOMATED uses + multiShotAutomatedParams (no input images); MULTI_SHOT_MANUAL requires + multiShotManualParams and omits top-level videoGenerationConfig.durationSeconds. + """ + + taskType: ReadOnly[NOVA_REEL_TASK_TYPES] + textToVideoParams: ReadOnly[AmazonNovaReelTextToVideoParams] + multiShotAutomatedParams: ReadOnly[AmazonNovaReelMultiShotAutomatedParams] + multiShotManualParams: ReadOnly[AmazonNovaReelMultiShotManualParams] + videoGenerationConfig: ReadOnly[AmazonNovaReelVideoGenerationConfig] + + +class BedrockAsyncInvokeS3OutputDataConfig(TypedDict, total=False): + """ + S3 output config for Bedrock StartAsyncInvoke (nested outputDataConfig key). + + Ref: bedrock-runtime service model (StartAsyncInvokeRequest.outputDataConfig) + """ + + s3Uri: ReadOnly[str] + kmsKeyId: ReadOnly[str] + bucketOwner: ReadOnly[str] + + +class BedrockAsyncInvokeOutputDataConfig(TypedDict, total=False): + """ + Output data config for Bedrock StartAsyncInvoke. + """ + + s3OutputDataConfig: ReadOnly[BedrockAsyncInvokeS3OutputDataConfig] + + +class BedrockStartAsyncInvokeRequest(TypedDict, total=False): + """ + Request body for POST {runtime}/async-invoke (StartAsyncInvoke). + + Ref: https://docs.aws.amazon.com/nova/latest/userguide/video-gen-access.html + """ + + modelId: ReadOnly[str] + modelInput: ReadOnly[AmazonNovaReelModelInput] + outputDataConfig: ReadOnly[BedrockAsyncInvokeOutputDataConfig] + clientRequestToken: ReadOnly[str] + + +class BedrockStartAsyncInvokeResponse(TypedDict, total=False): + """ + Response body for POST {runtime}/async-invoke. + """ + + invocationArn: ReadOnly[str] + + +BEDROCK_ASYNC_INVOKE_STATUSES = Literal["InProgress", "Completed", "Failed"] + + +class BedrockGetAsyncInvokeResponse(TypedDict, total=False): + """ + Response body for GET {runtime}/async-invoke/{invocationArn} (GetAsyncInvoke). + + status enum verified against the bedrock-runtime service model + (AsyncInvokeStatus): InProgress | Completed | Failed. failureMessage is + present when status is Failed. Timestamps use the model's iso8601 + timestampFormat ("2026-01-15T10:30:00Z"); numeric epochs are tolerated. + """ + + invocationArn: ReadOnly[str] + modelArn: ReadOnly[str] + clientRequestToken: ReadOnly[str] + status: ReadOnly[BEDROCK_ASYNC_INVOKE_STATUSES] + failureMessage: ReadOnly[str] + submitTime: ReadOnly[str | float] + lastModifiedTime: ReadOnly[str | float] + endTime: ReadOnly[str | float] + outputDataConfig: ReadOnly[BedrockAsyncInvokeOutputDataConfig] + + if TYPE_CHECKING: from botocore.awsrequest import AWSPreparedRequest else: diff --git a/litellm/utils.py b/litellm/utils.py index a7d7447d7f3..122ba3f92dd 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -9669,6 +9669,15 @@ class ProviderConfigManager: from litellm.llms.hosted_vllm.videos import get_hosted_vllm_video_config return get_hosted_vllm_video_config(model) + elif LlmProviders.BEDROCK == provider: + from litellm.llms.bedrock.videos.transformation import BedrockNovaReelVideoConfig + + # model is None for status/content routes (the provider config only + # does response mapping there; the real model id is encoded in the + # video id itself). + if model is None or "nova-reel" in model.lower(): + return BedrockNovaReelVideoConfig() + return None elif LlmProviders.EDENAI == provider: return litellm.EdenAIVideoConfig() return None diff --git a/litellm/videos/main.py b/litellm/videos/main.py index 445435a30fa..d3debf7a0ba 100644 --- a/litellm/videos/main.py +++ b/litellm/videos/main.py @@ -13,6 +13,11 @@ from litellm.constants import request_timeout as DEFAULT_REQUEST_TIMEOUT from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.llms.base_llm.videos.transformation import BaseVideoConfig +from litellm.llms.bedrock.videos.dispatch import ( + dispatch_bedrock_video_content, + dispatch_bedrock_video_generation, + dispatch_bedrock_video_status, +) from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler from litellm.main import base_llm_http_handler from litellm.types.router import GenericLiteLLMParams @@ -243,6 +248,21 @@ def video_generation( # Set the correct call type for video generation litellm_logging_obj.call_type = CallTypes.create_video.value + # Route bedrock to its specific handler (AWS SigV4 signing required) + if custom_llm_provider == "bedrock": + return dispatch_bedrock_video_generation( + model=model, + prompt=prompt, + video_generation_request_params=video_generation_request_params, + litellm_params=litellm_params, + logging_obj=litellm_logging_obj, + timeout=timeout or DEFAULT_REQUEST_TIMEOUT, + is_async=_is_async, + client=kwargs.get("client"), + extra_headers=extra_headers, + api_key=kwargs.get("api_key") or litellm_params.get("api_key"), + ) + # Call the handler with _is_async flag instead of directly calling the async handler return base_llm_http_handler.video_generation_handler( model=model, @@ -355,6 +375,15 @@ def video_content( ) # Call the handler with _is_async flag instead of directly calling the async handler + if custom_llm_provider == "bedrock": + return dispatch_bedrock_video_content( + video_id=video_id, + litellm_params=litellm_params, + logging_obj=litellm_logging_obj, + api_base=litellm_params.get("api_base"), + api_key=kwargs.get("api_key") or litellm_params.get("api_key"), + timeout=timeout or DEFAULT_REQUEST_TIMEOUT, + ) return base_llm_http_handler.video_content_handler( video_id=video_id, video_content_provider_config=video_provider_config, @@ -1058,6 +1087,18 @@ def video_status( # Set the correct call type for video status litellm_logging_obj.call_type = CallTypes.video_retrieve.value + # Route bedrock to its specific handler (AWS SigV4 signing required) + if custom_llm_provider == "bedrock": + return dispatch_bedrock_video_status( + video_id=video_id, + litellm_params=litellm_params, + logging_obj=litellm_logging_obj, + api_base=litellm_params.get("api_base"), + api_key=kwargs.get("api_key") or litellm_params.get("api_key"), + astatus=_is_async, + timeout=timeout or DEFAULT_REQUEST_TIMEOUT, + ) + # Call the handler with _is_async flag instead of directly calling the async handler return base_llm_http_handler.video_status_handler( video_id=video_id, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 2d0985bcb64..0d343f5b100 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -292,6 +292,42 @@ "output_cost_per_image": 0.06, "supports_nova_canvas_image_edit": true }, + "amazon.nova-reel-v1:0": { + "litellm_provider": "bedrock", + "mode": "video_generation", + "output_cost_per_second": 0.08, + "source": "https://aws.amazon.com/bedrock/pricing/ (AWS Bulk Price List API, AmazonBedrock offer 2026-09-22) (accessed 2026-09-24)", + "supported_endpoints": ["/v1/videos"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["video"] + }, + "amazon.nova-reel-v1:1": { + "litellm_provider": "bedrock", + "mode": "video_generation", + "output_cost_per_second": 0.08, + "source": "https://aws.amazon.com/bedrock/pricing/ (AWS Bulk Price List API, AmazonBedrock offer 2026-09-22) (accessed 2026-09-24)", + "supported_endpoints": ["/v1/videos"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["video"] + }, + "us.amazon.nova-reel-v1:0": { + "litellm_provider": "bedrock", + "mode": "video_generation", + "output_cost_per_second": 0.08, + "source": "https://aws.amazon.com/bedrock/pricing/ (AWS Bulk Price List API, AmazonBedrock offer 2026-09-22) (accessed 2026-09-24)", + "supported_endpoints": ["/v1/videos"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["video"] + }, + "us.amazon.nova-reel-v1:1": { + "litellm_provider": "bedrock", + "mode": "video_generation", + "output_cost_per_second": 0.08, + "source": "https://aws.amazon.com/bedrock/pricing/ (AWS Bulk Price List API, AmazonBedrock offer 2026-09-22) (accessed 2026-09-24)", + "supported_endpoints": ["/v1/videos"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["video"] + }, "us.writer.palmyra-x4-v1:0": { "input_cost_per_token": 2.5e-06, "litellm_provider": "bedrock_converse", diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index 7ffaacdb3aa..2960a932431 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -268,7 +268,8 @@ "vector_stores_search": true, "count_tokens": true, "rag_ingest": true, - "rag_query": true + "rag_query": true, + "video_generations": true } }, "s3_vectors": { diff --git a/tests/unit/llms/bedrock/image_edit/test_amazon_nova_canvas_conditioned_edit.py b/tests/unit/llms/bedrock/image_edit/test_amazon_nova_canvas_conditioned_edit.py new file mode 100644 index 00000000000..61b4255e981 --- /dev/null +++ b/tests/unit/llms/bedrock/image_edit/test_amazon_nova_canvas_conditioned_edit.py @@ -0,0 +1,795 @@ +"""Tests for the Nova Canvas conditioned-editing additions (issue #39552). + +Self-contained complement to the branch-added tests in +tests/unit/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py: the +conditioning guard, conditionImage acceptance/precedence, controlStrength +coercion/range, mask/maskPrompt rejection, prompt-required, style forwarding, +supported params, and the litellm-level proxy contracts (400-class validation +errors, style passthrough through litellm.aimage_edit). +""" + +import asyncio +import base64 +import io +import json +from datetime import datetime +from typing import Final +from unittest.mock import patch + +import httpx +import pytest + +import litellm +from litellm.llms.bedrock.common_utils import BedrockError +from litellm.llms.bedrock.image_edit.amazon_nova_canvas_image_edit_transformation import ( + BedrockAmazonNovaCanvasImageEditConfig, +) + +TEST_MODEL = "amazon.nova-canvas-v1:0" + + +@pytest.fixture(autouse=True) +def ensure_nova_canvas_image_edit_model_cost_flags(monkeypatch): + """Routing uses ``supports_nova_canvas_image_edit`` on ``litellm.model_cost``. + + Full ``model_prices_and_context_window.json`` includes these flags, but CI or + alternate cost maps may omit them; merge minimal entries so tests match production. + """ + from litellm.utils import _invalidate_model_cost_lowercase_map + + for key in ( + "amazon.nova-canvas-v1:0", + "us.amazon.nova-canvas-v1:0", + ): + entry = litellm.model_cost.get(key) or {} + if entry.get("supports_nova_canvas_image_edit") is True: + continue + monkeypatch.setitem( + litellm.model_cost, + key, + { + **entry, + "litellm_provider": entry.get("litellm_provider", "bedrock"), + "mode": entry.get("mode", "image_generation"), + "supports_nova_canvas_image_edit": True, + }, + ) + _invalidate_model_cost_lowercase_map() + + yield + + _invalidate_model_cost_lowercase_map() + + +################################################# +# conditioning guard: controlMode/controlStrength/style need TEXT_IMAGE +################################################# + + +def test_transform_request_conditioning_fields_without_text_image_task_type_raises(): + """controlMode/controlStrength/style with any non-TEXT_IMAGE resolved taskType must + fail fast instead of being silently dropped by mask/variation routing.""" + config = BedrockAmazonNovaCanvasImageEditConfig() + img = io.BytesIO(b"cond") + with pytest.raises(BedrockError) as excinfo: + config.transform_image_edit_request( + model=TEST_MODEL, + prompt="restyle", + image=img, + image_edit_optional_request_params={ + "controlMode": "SEGMENTATION", + }, + litellm_params={}, + headers={}, + ) + assert excinfo.value.status_code == 400 + assert "taskType=TEXT_IMAGE" in str(excinfo.value.message) + assert "controlMode/controlStrength/style" in str(excinfo.value.message) + + +def test_transform_request_control_strength_without_text_image_task_type_raises(): + config = BedrockAmazonNovaCanvasImageEditConfig() + img = io.BytesIO(b"cond") + with pytest.raises(BedrockError) as excinfo: + config.transform_image_edit_request( + model=TEST_MODEL, + prompt="restyle", + image=img, + image_edit_optional_request_params={ + "controlStrength": 0.4, + }, + litellm_params={}, + headers={}, + ) + assert excinfo.value.status_code == 400 + assert "taskType=TEXT_IMAGE" in str(excinfo.value.message) + + +def test_transform_request_style_without_text_image_task_type_raises(): + config = BedrockAmazonNovaCanvasImageEditConfig() + img = io.BytesIO(b"cond") + with pytest.raises(BedrockError) as excinfo: + config.transform_image_edit_request( + model=TEST_MODEL, + prompt="restyle", + image=img, + image_edit_optional_request_params={ + "style": "DESIGN_SKETCH", + }, + litellm_params={}, + headers={}, + ) + assert excinfo.value.status_code == 400 + assert "taskType=TEXT_IMAGE" in str(excinfo.value.message) + + +def test_transform_request_conditioning_fields_with_explicit_text_image_works(): + """The guard must not fire when taskType is explicitly TEXT_IMAGE.""" + config = BedrockAmazonNovaCanvasImageEditConfig() + img = io.BytesIO(b"cond") + body, _ = config.transform_image_edit_request( + model=TEST_MODEL, + prompt="restyle", + image=img, + image_edit_optional_request_params={ + "taskType": "TEXT_IMAGE", + "controlMode": "CANNY_EDGE", + }, + litellm_params={}, + headers={}, + ) + assert body["taskType"] == "TEXT_IMAGE" + assert body["textToImageParams"]["controlMode"] == "CANNY_EDGE" + + +################################################# +# conditionImage acceptance and precedence +################################################# + + +def test_transform_request_condition_image_without_multipart_image(): + """conditionImage is an alternative TEXT_IMAGE condition source for JSON-only callers.""" + config = BedrockAmazonNovaCanvasImageEditConfig() + condition_b64: Final = base64.b64encode(b"cond-bytes").decode("utf-8") + body, _ = config.transform_image_edit_request( + model=TEST_MODEL, + prompt="same layout", + image=None, + image_edit_optional_request_params={ + "taskType": "TEXT_IMAGE", + "conditionImage": condition_b64, + "controlMode": "SEGMENTATION", + }, + litellm_params={}, + headers={}, + ) + assert body["taskType"] == "TEXT_IMAGE" + t2i = body["textToImageParams"] + assert t2i["conditionImage"] == condition_b64 + assert t2i["controlMode"] == "SEGMENTATION" + + +def test_transform_request_condition_image_bytes_accepted(): + """Raw bytes conditionImage is base64-encoded on the way into the body.""" + config = BedrockAmazonNovaCanvasImageEditConfig() + body, _ = config.transform_image_edit_request( + model=TEST_MODEL, + prompt="same layout", + image=None, + image_edit_optional_request_params={ + "taskType": "TEXT_IMAGE", + "conditionImage": b"raw-cond-bytes", + }, + litellm_params={}, + headers={}, + ) + assert body["textToImageParams"]["conditionImage"] == base64.b64encode(b"raw-cond-bytes").decode("utf-8") + + +def test_transform_request_multipart_image_wins_over_condition_image(): + """Pinned precedence: when both are supplied the multipart `image` field wins.""" + config = BedrockAmazonNovaCanvasImageEditConfig() + condition_b64: Final = base64.b64encode(b"from-condition-param").decode("utf-8") + body, _ = config.transform_image_edit_request( + model=TEST_MODEL, + prompt="same layout", + image=io.BytesIO(b"from-multipart"), + image_edit_optional_request_params={ + "taskType": "TEXT_IMAGE", + "conditionImage": condition_b64, + }, + litellm_params={}, + headers={}, + ) + assert body["textToImageParams"]["conditionImage"] == base64.b64encode(b"from-multipart").decode("utf-8") + + +def test_transform_request_condition_image_with_other_task_type_raises(): + """conditionImage only conditions TEXT_IMAGE; other task types need the image input.""" + config = BedrockAmazonNovaCanvasImageEditConfig() + with pytest.raises(BedrockError) as excinfo: + config.transform_image_edit_request( + model=TEST_MODEL, + prompt="restyle", + image=None, + image_edit_optional_request_params={ + "taskType": "IMAGE_VARIATION", + "conditionImage": base64.b64encode(b"cond").decode("utf-8"), + }, + litellm_params={}, + headers={}, + ) + assert excinfo.value.status_code == 400 + assert "conditionImage is only supported" in str(excinfo.value.message) + + +def test_transform_request_multipart_image_with_condition_image_non_text_image_raises(): + """image + conditionImage + a non-TEXT_IMAGE taskType must raise 400 instead of + silently discarding the conditionImage (the multipart image no longer short-circuits + the conflict check).""" + config = BedrockAmazonNovaCanvasImageEditConfig() + with pytest.raises(BedrockError) as excinfo: + config.transform_image_edit_request( + model=TEST_MODEL, + prompt="restyle", + image=io.BytesIO(b"img-bytes"), + image_edit_optional_request_params={ + "taskType": "IMAGE_VARIATION", + "conditionImage": base64.b64encode(b"cond").decode("utf-8"), + }, + litellm_params={}, + headers={}, + ) + assert excinfo.value.status_code == 400 + assert "conditionImage is only supported" in str(excinfo.value.message) + + +def test_transform_request_inpainting_without_mask_maps_to_400(): + """INPAINTING without maskPrompt or maskImage must surface as a 400-class + BedrockError, not a plain ValueError (500-class through the proxy).""" + config = BedrockAmazonNovaCanvasImageEditConfig() + with pytest.raises(BedrockError) as excinfo: + config.transform_image_edit_request( + model=TEST_MODEL, + prompt="fix it", + image=io.BytesIO(b"img-bytes"), + image_edit_optional_request_params={"taskType": "INPAINTING"}, + litellm_params={}, + headers={}, + ) + assert excinfo.value.status_code == 400 + assert "INPAINTING requires either maskPrompt or maskImage" in str(excinfo.value.message) + + +def test_transform_request_unsupported_task_type_maps_to_400(): + config = BedrockAmazonNovaCanvasImageEditConfig() + with pytest.raises(BedrockError) as excinfo: + config.transform_image_edit_request( + model=TEST_MODEL, + prompt="x", + image=io.BytesIO(b"img-bytes"), + image_edit_optional_request_params={"taskType": "NOT_A_REAL_TASK_TYPE"}, + litellm_params={}, + headers={}, + ) + assert excinfo.value.status_code == 400 + assert "Unsupported Amazon Nova Canvas taskType" in str(excinfo.value.message) + + +def test_transform_request_text_image_without_any_image_raises(): + """TEXT_IMAGE with neither a multipart image nor a conditionImage fails fast.""" + config = BedrockAmazonNovaCanvasImageEditConfig() + with pytest.raises(BedrockError) as excinfo: + config.transform_image_edit_request( + model=TEST_MODEL, + prompt="same layout", + image=None, + image_edit_optional_request_params={"taskType": "TEXT_IMAGE"}, + litellm_params={}, + headers={}, + ) + assert excinfo.value.status_code == 400 + assert "requires an image input" in str(excinfo.value.message) + + +def test_transform_request_text_image_invalid_control_mode_raises(): + """Unknown controlMode fails fast instead of hitting AWS with a bad payload.""" + config = BedrockAmazonNovaCanvasImageEditConfig() + img = io.BytesIO(b"cond") + with pytest.raises(BedrockError) as excinfo: + config.transform_image_edit_request( + model=TEST_MODEL, + prompt="restyle", + image=img, + image_edit_optional_request_params={"taskType": "TEXT_IMAGE", "controlMode": "CANNY_EDIT"}, + litellm_params={}, + headers={}, + ) + assert excinfo.value.status_code == 400 + assert "Unsupported Amazon Nova Canvas controlMode" in str(excinfo.value.message) + + +def test_transform_request_text_image_forwards_negative_text(): + """TEXT_IMAGE forwards negativeText into textToImageParams.""" + config = BedrockAmazonNovaCanvasImageEditConfig() + img = io.BytesIO(b"cond") + body, _ = config.transform_image_edit_request( + model=TEST_MODEL, + prompt="restyle", + image=img, + image_edit_optional_request_params={"taskType": "TEXT_IMAGE", "negativeText": "blurry"}, + litellm_params={}, + headers={}, + ) + assert body["textToImageParams"]["negativeText"] == "blurry" + + +################################################# +# controlStrength coercion and range +################################################# + + +def test_transform_request_text_image_control_strength_string_coerced(): + """controlStrength arriving as a string (multipart form data) is coerced to a float.""" + config = BedrockAmazonNovaCanvasImageEditConfig() + img = io.BytesIO(b"cond") + body, _ = config.transform_image_edit_request( + model=TEST_MODEL, + prompt="restyle", + image=img, + image_edit_optional_request_params={ + "taskType": "TEXT_IMAGE", + "controlStrength": "0.7", + }, + litellm_params={}, + headers={}, + ) + assert body["textToImageParams"]["controlStrength"] == 0.7 + + +@pytest.mark.parametrize("control_strength", [0.0, 1.0]) +def test_transform_request_text_image_control_strength_bounds_pass(control_strength): + """Boundary controlStrength values 0.0 and 1.0 are accepted.""" + config = BedrockAmazonNovaCanvasImageEditConfig() + img = io.BytesIO(b"cond") + body, _ = config.transform_image_edit_request( + model=TEST_MODEL, + prompt="restyle", + image=img, + image_edit_optional_request_params={ + "taskType": "TEXT_IMAGE", + "controlStrength": control_strength, + }, + litellm_params={}, + headers={}, + ) + assert body["textToImageParams"]["controlStrength"] == control_strength + + +def test_transform_request_text_image_control_strength_out_of_range_raises(): + """controlStrength outside 0.0-1.0 fails fast.""" + config = BedrockAmazonNovaCanvasImageEditConfig() + img = io.BytesIO(b"cond") + with pytest.raises(BedrockError) as excinfo: + config.transform_image_edit_request( + model=TEST_MODEL, + prompt="restyle", + image=img, + image_edit_optional_request_params={ + "taskType": "TEXT_IMAGE", + "controlStrength": 1.5, + }, + litellm_params={}, + headers={}, + ) + assert excinfo.value.status_code == 400 + assert "controlStrength must be between 0.0 and 1.0" in str(excinfo.value.message) + + +def test_transform_request_text_image_control_strength_non_numeric_string_raises(): + """A non-numeric controlStrength string must fail fast, not TypeError.""" + config = BedrockAmazonNovaCanvasImageEditConfig() + img = io.BytesIO(b"cond") + with pytest.raises(BedrockError) as excinfo: + config.transform_image_edit_request( + model=TEST_MODEL, + prompt="restyle", + image=img, + image_edit_optional_request_params={ + "taskType": "TEXT_IMAGE", + "controlStrength": "abc", + }, + litellm_params={}, + headers={}, + ) + assert excinfo.value.status_code == 400 + assert "controlStrength must be a number" in str(excinfo.value.message) + + +################################################# +# TEXT_IMAGE rejects mask inputs and requires a prompt +################################################# + + +def test_transform_request_text_image_with_mask_raises(): + """TEXT_IMAGE has no mask field; a provided mask must fail fast.""" + config = BedrockAmazonNovaCanvasImageEditConfig() + img = io.BytesIO(b"cond") + mask = io.BytesIO(b"mask-bytes") + with pytest.raises(BedrockError) as excinfo: + config.transform_image_edit_request( + model=TEST_MODEL, + prompt="restyle", + image=img, + image_edit_optional_request_params={ + "taskType": "TEXT_IMAGE", + "mask": mask, + }, + litellm_params={}, + headers={}, + ) + assert excinfo.value.status_code == 400 + assert "does not support a mask" in str(excinfo.value.message) + assert "INPAINTING or OUTPAINTING" in str(excinfo.value.message) + + +def test_transform_request_text_image_with_mask_prompt_raises(): + """TEXT_IMAGE has no maskPrompt field either; fail fast like the binary mask + instead of silently dropping it.""" + config = BedrockAmazonNovaCanvasImageEditConfig() + img = io.BytesIO(b"cond") + with pytest.raises(BedrockError) as excinfo: + config.transform_image_edit_request( + model=TEST_MODEL, + prompt="restyle", + image=img, + image_edit_optional_request_params={ + "taskType": "TEXT_IMAGE", + "maskPrompt": "the sky region", + }, + litellm_params={}, + headers={}, + ) + assert excinfo.value.status_code == 400 + assert "does not support a mask" in str(excinfo.value.message) + assert "INPAINTING or OUTPAINTING" in str(excinfo.value.message) + + +def test_transform_request_text_image_empty_prompt_raises(): + """An empty TEXT_IMAGE prompt must fail fast, not silently send a blank.""" + config = BedrockAmazonNovaCanvasImageEditConfig() + img = io.BytesIO(b"cond") + with pytest.raises(BedrockError) as excinfo: + config.transform_image_edit_request( + model=TEST_MODEL, + prompt="", + image=img, + image_edit_optional_request_params={"taskType": "TEXT_IMAGE"}, + litellm_params={}, + headers={}, + ) + assert excinfo.value.status_code == 400 + assert "TEXT_IMAGE requires a text prompt" in str(excinfo.value.message) + + +def test_transform_request_text_image_none_prompt_raises(): + config = BedrockAmazonNovaCanvasImageEditConfig() + img = io.BytesIO(b"cond") + with pytest.raises(BedrockError) as excinfo: + config.transform_image_edit_request( + model=TEST_MODEL, + prompt=None, + image=img, + image_edit_optional_request_params={"taskType": "TEXT_IMAGE"}, + litellm_params={}, + headers={}, + ) + assert excinfo.value.status_code == 400 + assert "TEXT_IMAGE requires a text prompt" in str(excinfo.value.message) + + +################################################# +# style forwarding and supported params +################################################# + + +def test_transform_request_text_image_forwards_style(): + """style is forwarded into textToImageParams for conditioned editing.""" + config = BedrockAmazonNovaCanvasImageEditConfig() + img = io.BytesIO(b"cond") + body, _ = config.transform_image_edit_request( + model=TEST_MODEL, + prompt="a city street in the same layout", + image=img, + image_edit_optional_request_params={ + "taskType": "TEXT_IMAGE", + "controlMode": "SEGMENTATION", + "style": "DESIGN_SKETCH", + }, + litellm_params={}, + headers={}, + ) + assert body["taskType"] == "TEXT_IMAGE" + assert body["textToImageParams"]["style"] == "DESIGN_SKETCH" + + +def test_transform_request_text_image_omits_style_when_absent(): + config = BedrockAmazonNovaCanvasImageEditConfig() + img = io.BytesIO(b"cond") + body, _ = config.transform_image_edit_request( + model=TEST_MODEL, + prompt="restyle", + image=img, + image_edit_optional_request_params={"taskType": "TEXT_IMAGE"}, + litellm_params={}, + headers={}, + ) + assert "style" not in body["textToImageParams"] + + +def test_get_supported_openai_params_includes_conditioning_fields(): + """controlMode/controlStrength are advertised for TEXT_IMAGE conditioned editing.""" + config = BedrockAmazonNovaCanvasImageEditConfig() + supported = config.get_supported_openai_params(TEST_MODEL) + assert "controlMode" in supported + assert "controlStrength" in supported + + +def test_get_supported_openai_params_includes_style(): + """style is advertised for TEXT_IMAGE conditioned editing.""" + config = BedrockAmazonNovaCanvasImageEditConfig() + supported = config.get_supported_openai_params(TEST_MODEL) + assert "style" in supported + + +def test_get_supported_openai_params_includes_condition_image(): + config = BedrockAmazonNovaCanvasImageEditConfig() + supported = config.get_supported_openai_params(TEST_MODEL) + assert "conditionImage" in supported + + +################################################# +# litellm-level proxy contracts +################################################# + + +async def test_aimage_edit_mask_with_text_image_maps_to_bad_request(monkeypatch): + """Through the litellm image-edit layer, the TEXT_IMAGE mask guard must surface as + litellm.BadRequestError (400-class), never APIConnectionError/500.""" + monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "env-bearer-token-12345") + with pytest.raises(litellm.BadRequestError) as excinfo: + await litellm.aimage_edit( + model=f"bedrock/{TEST_MODEL}", + prompt="restyle", + image=io.BytesIO(b"img-bytes"), + taskType="TEXT_IMAGE", + mask=io.BytesIO(b"mask-bytes"), + ) + assert excinfo.value.status_code == 400 + assert "does not support a mask" in str(excinfo.value) + + +async def test_aimage_edit_unsupported_task_type_maps_to_bad_request(monkeypatch): + """Through the litellm image-edit layer, an unsupported taskType must surface as + litellm.BadRequestError (400-class), never APIConnectionError/500.""" + monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "env-bearer-token-12345") + with pytest.raises(litellm.BadRequestError) as excinfo: + await litellm.aimage_edit( + model=f"bedrock/{TEST_MODEL}", + prompt="restyle", + image=io.BytesIO(b"img-bytes"), + taskType="NOT_A_REAL_TASK_TYPE", + ) + assert excinfo.value.status_code == 400 + assert "Unsupported Amazon Nova Canvas taskType" in str(excinfo.value) + + +async def test_aimage_edit_forwards_style_to_nova_canvas_transform(monkeypatch): + """style passed to litellm.aimage_edit must survive the images/main.py param + filtering and reach the Nova Canvas transform (regression: style used to be + blocklisted and silently dropped on the bedrock path).""" + monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "env-bearer-token-12345") + posted: dict[str, object] = {} + + class _FakeAsyncClient: + async def post(self, url, headers, data): + posted["url"] = url + posted["body"] = json.loads(data) + return httpx.Response(200, json={"images": ["aGk="]}, request=httpx.Request("POST", url)) + + import litellm.llms.bedrock.image_edit.handler as bedrock_image_edit_handler + + with patch.object( + bedrock_image_edit_handler, + "get_async_httpx_client", + lambda **kwargs: _FakeAsyncClient(), + ): + response = await litellm.aimage_edit( + model=f"bedrock/{TEST_MODEL}", + prompt="same layout", + image=io.BytesIO(b"img-bytes"), + taskType="TEXT_IMAGE", + style="DESIGN_SKETCH", + ) + body = posted["body"] + assert isinstance(body, dict) + assert body["textToImageParams"]["style"] == "DESIGN_SKETCH" + assert response.data[0].b64_json == "aGk=" + + +async def test_aimage_edit_image_less_condition_image_reaches_transform(monkeypatch): + """POST /v1/images/edits with taskType TEXT_IMAGE and conditionImage only (no + multipart image) must reach the conditioned edit path instead of failing before + dispatch (aimage_edit used to require image as a positional argument).""" + monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "env-bearer-token-12345") + posted: dict[str, object] = {} + + class _FakeAsyncClient: + async def post(self, url, headers, data): + posted["url"] = url + posted["body"] = json.loads(data) + return httpx.Response(200, json={"images": ["aGk="]}, request=httpx.Request("POST", url)) + + import litellm.llms.bedrock.image_edit.handler as bedrock_image_edit_handler + + with patch.object( + bedrock_image_edit_handler, + "get_async_httpx_client", + lambda **kwargs: _FakeAsyncClient(), + ): + response = await litellm.aimage_edit( + model=f"bedrock/{TEST_MODEL}", + prompt="same layout", + taskType="TEXT_IMAGE", + conditionImage="aGVsbG8=", + ) + body = posted["body"] + assert isinstance(body, dict) + assert body["taskType"] == "TEXT_IMAGE" + assert body["textToImageParams"]["conditionImage"] == "aGVsbG8=" + assert response.data is not None + assert response.data[0].b64_json == "aGk=" + + +def test_aimage_edit_positional_arguments_still_work(monkeypatch): + """The pre-existing positional call form litellm.aimage_edit(image, model, prompt) + must keep working now that image is optional: the sync image_edit signature keeps + model/prompt defaulted and positional (no keyword-only marker), and aimage_edit + mirrors that compatibility profile while keeping its own parameter order.""" + monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "env-bearer-token-12345") + posted: dict[str, object] = {} + + class _FakeAsyncClient: + async def post(self, url, headers, data): + posted["url"] = url + posted["body"] = json.loads(data) + return httpx.Response(200, json={"images": ["aGk="]}, request=httpx.Request("POST", url)) + + import litellm.llms.bedrock.image_edit.handler as bedrock_image_edit_handler + + with patch.object( + bedrock_image_edit_handler, + "get_async_httpx_client", + lambda **kwargs: _FakeAsyncClient(), + ): + response = asyncio.run( + litellm.aimage_edit( + io.BytesIO(b"img-bytes"), + f"bedrock/{TEST_MODEL}", + "same layout", + taskType="TEXT_IMAGE", + ) + ) + # Positional 2nd/3rd args landed on model/prompt respectively (the + # historical order), not swapped as sync's (image, prompt, model) order. + body = posted["body"] + assert isinstance(body, dict) + assert body["taskType"] == "TEXT_IMAGE" + assert body["textToImageParams"]["text"] == "same layout" + assert body["textToImageParams"]["conditionImage"] == base64.b64encode(b"img-bytes").decode("utf-8") + assert response.data is not None + assert response.data[0].b64_json == "aGk=" + + +def test_aimage_edit_none_image_builds_empty_list(monkeypatch): + """An omitted image must arrive at the handler as [] (empty list), not [None].""" + from unittest.mock import Mock + + import litellm.images.main as images_main + + seen: dict[str, object] = {} + + def fake_image_edit(**kwargs): + seen.update(kwargs) + return Mock() + + monkeypatch.setattr(images_main, "image_edit", fake_image_edit) + asyncio.run( + images_main.aimage_edit( + model=f"bedrock/{TEST_MODEL}", + prompt="same layout", + taskType="TEXT_IMAGE", + conditionImage="aGVsbG8=", + ) + ) + assert seen["image"] == [] + + +################################################# +# logging headers redaction (pre_call additional_args) +################################################# + + +def test_redact_bedrock_headers_for_logging_masks_signed_headers(): + """SigV4 signature material must be replaced with [REDACTED]; safe headers survive.""" + from litellm.llms.bedrock.common_utils import redact_bedrock_headers_for_logging + + signed: Final[dict[str, str]] = { + "Content-Type": "application/json", + "Host": "bedrock-runtime.us-east-1.amazonaws.com", + "Authorization": ( + "AWS4-HMAC-SHA256 Credential=AKIA-test/20260115/us-east-1/bedrock/aws4_request, " + "SignedHeaders=host;x-amz-date, Signature=deadbeefsecret" + ), + "X-Amz-Date": "20260115T103000Z", + "X-Amz-Security-Token": "session-token-secret", + "X-Amzn-RequestId": "request-id-not-secret", + } + redacted = redact_bedrock_headers_for_logging(signed) + assert redacted["Content-Type"] == "application/json" + assert redacted["Host"] == "bedrock-runtime.us-east-1.amazonaws.com" + assert redacted["X-Amzn-RequestId"] == "request-id-not-secret" + assert redacted["Authorization"] == "[REDACTED]" + assert redacted["X-Amz-Date"] == "[REDACTED]" + assert redacted["X-Amz-Security-Token"] == "[REDACTED]" + # Every key stays present (log consumers see the full header shape). + assert set(redacted.keys()) == set(signed.keys()) + # The input mapping is untouched: redaction never mutates the sent headers. + assert signed["Authorization"].startswith("AWS4-HMAC-SHA256") + assert signed["X-Amz-Security-Token"] == "session-token-secret" + + +def test_prepare_request_logging_headers_redacted(monkeypatch): + """pre_call additional_args must carry the redacted copy; the sent request keeps + the real bearer Authorization header.""" + from litellm.litellm_core_utils.litellm_logging import Logging + from litellm.llms.bedrock.image_edit.handler import BedrockImageEdit + + monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "env-bearer-token-12345") + + captured: dict = {} + + def _capture(model_call_details): + captured.update(model_call_details) + + logging_obj = Logging( + model=f"bedrock/{TEST_MODEL}", + messages=[], + stream=False, + call_type="aimage_edit", + start_time=datetime.now(), + litellm_call_id="test-call-id", + function_id="test-function", + kwargs={"logger_fn": _capture}, + ) + logging_obj.update_environment_variables( + litellm_params={"logger_fn": _capture}, + optional_params={}, + ) + + request = BedrockImageEdit()._prepare_request( + model=TEST_MODEL, + image=[io.BytesIO(b"fake-png")], + prompt="make it warmer", + optional_params={"aws_region_name": "us-west-2", "aws_profile_name": "litellm-no-such-aws-profile"}, + api_base=None, + extra_headers=None, + logging_obj=logging_obj, + api_key=None, + ) + logged_headers: Final = captured["additional_args"]["headers"] + assert logged_headers["Authorization"] == "[REDACTED]" + assert logged_headers["Content-Type"] == "application/json" + assert "env-bearer-token-12345" not in str(captured) + # The sent request still carries the real bearer Authorization header. + assert request.prepped.headers["Authorization"] == "Bearer env-bearer-token-12345" diff --git a/tests/unit/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py b/tests/unit/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py index 122dd5b555a..441c5844600 100644 --- a/tests/unit/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py +++ b/tests/unit/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py @@ -2,13 +2,14 @@ import base64 import io -from typing import cast +from typing import Final, cast from unittest.mock import Mock import httpx import pytest import litellm +from litellm.llms.bedrock.common_utils import BedrockError from litellm.llms.bedrock.image_edit.amazon_nova_canvas_image_edit_transformation import ( BedrockAmazonNovaCanvasImageEditConfig, get_bedrock_image_edit_config_for_model, @@ -203,9 +204,7 @@ def test_transform_request_image_pathlike_input(tmp_path): ) assert body["taskType"] == "IMAGE_VARIATION" - assert body["imageVariationParams"]["images"][0] == base64.b64encode( - image_bytes - ).decode("utf-8") + assert body["imageVariationParams"]["images"][0] == base64.b64encode(image_bytes).decode("utf-8") def test_transform_request_inpainting_with_mask(): @@ -363,12 +362,10 @@ def test_transform_request_outpainting_without_mask_raises(): def test_transform_request_inpainting_explicit_task_without_mask_raises(): - """INPAINTING taskType without mask or maskPrompt must fail fast.""" + """INPAINTING taskType without mask or maskPrompt must fail fast with a 400-class error.""" config = BedrockAmazonNovaCanvasImageEditConfig() img = io.BytesIO(b"img") - with pytest.raises( - ValueError, match="INPAINTING requires either maskPrompt or maskImage" - ): + with pytest.raises(BedrockError, match="INPAINTING requires either maskPrompt or maskImage") as excinfo: config.transform_image_edit_request( model="amazon.nova-canvas-v1:0", prompt="fix it", @@ -377,21 +374,23 @@ def test_transform_request_inpainting_explicit_task_without_mask_raises(): litellm_params={}, # type: ignore[arg-type] headers={}, ) + assert excinfo.value.status_code == 400 def test_transform_request_unknown_task_type_raises(): """Unknown taskType must not silently map to IMAGE_VARIATION or INPAINTING.""" config = BedrockAmazonNovaCanvasImageEditConfig() img = io.BytesIO(b"img") - with pytest.raises(ValueError, match="Unsupported Amazon Nova Canvas taskType"): + with pytest.raises(BedrockError, match="Unsupported Amazon Nova Canvas taskType") as excinfo: config.transform_image_edit_request( model="amazon.nova-canvas-v1:0", prompt="x", image=img, - image_edit_optional_request_params={"taskType": "TEXT_IMAGE"}, + image_edit_optional_request_params={"taskType": "NOT_A_REAL_TASK_TYPE"}, litellm_params={}, # type: ignore[arg-type] headers={}, ) + assert excinfo.value.status_code == 400 def test_transform_request_background_removal(): @@ -410,6 +409,340 @@ def test_transform_request_background_removal(): assert "image" in body["backgroundRemovalParams"] +def test_transform_request_text_image_segmentation(): + """taskType TEXT_IMAGE + controlMode SEGMENTATION conditions via conditionImage (issue #39552).""" + config = BedrockAmazonNovaCanvasImageEditConfig() + img = io.BytesIO(b"cond-bytes") + body, _ = config.transform_image_edit_request( + model="amazon.nova-canvas-v1:0", + prompt="a city street in the same layout", + image=img, + image_edit_optional_request_params={ + "taskType": "TEXT_IMAGE", + "controlMode": "SEGMENTATION", + "controlStrength": 0.7, + }, + litellm_params={}, + headers={}, + ) + assert body["taskType"] == "TEXT_IMAGE" + t2i = body["textToImageParams"] + assert t2i["text"] == "a city street in the same layout" + assert t2i["conditionImage"] == base64.b64encode(b"cond-bytes").decode("utf-8") + assert t2i["controlMode"] == "SEGMENTATION" + assert t2i["controlStrength"] == 0.7 + + +def test_transform_request_text_image_canny_edge(): + """taskType TEXT_IMAGE + controlMode CANNY_EDGE keeps canny conditioning.""" + config = BedrockAmazonNovaCanvasImageEditConfig() + img = io.BytesIO(b"cond-bytes") + body, _ = config.transform_image_edit_request( + model="amazon.nova-canvas-v1:0", + prompt="follow the edges", + image=img, + image_edit_optional_request_params={ + "taskType": "TEXT_IMAGE", + "controlMode": "CANNY_EDGE", + }, + litellm_params={}, + headers={}, + ) + assert body["taskType"] == "TEXT_IMAGE" + t2i = body["textToImageParams"] + assert t2i["controlMode"] == "CANNY_EDGE" + assert t2i["conditionImage"] + assert "controlStrength" not in t2i + + +def test_transform_request_text_image_defaults_omit_control_fields(): + """Without controlMode/controlStrength only conditionImage is set (AWS defaults apply).""" + config = BedrockAmazonNovaCanvasImageEditConfig() + img = io.BytesIO(b"cond") + body, _ = config.transform_image_edit_request( + model="amazon.nova-canvas-v1:0", + prompt="same composition", + image=img, + image_edit_optional_request_params={"taskType": "TEXT_IMAGE"}, + litellm_params={}, + headers={}, + ) + assert body["taskType"] == "TEXT_IMAGE" + t2i = body["textToImageParams"] + assert t2i["conditionImage"] + assert "controlMode" not in t2i + assert "controlStrength" not in t2i + assert "negativeText" not in t2i + + +def test_transform_request_text_image_forwards_negative_text_and_igc(): + """TEXT_IMAGE forwards negativeText and allows imageGenerationConfig.""" + config = BedrockAmazonNovaCanvasImageEditConfig() + img = io.BytesIO(b"cond") + body, _ = config.transform_image_edit_request( + model="amazon.nova-canvas-v1:0", + prompt="restyle", + image=img, + image_edit_optional_request_params={ + "taskType": "TEXT_IMAGE", + "controlMode": "SEGMENTATION", + "negativeText": "blurry", + "size": "1024x1024", + "seed": 7, + }, + litellm_params={}, + headers={}, + ) + assert body["textToImageParams"]["negativeText"] == "blurry" + assert body["imageGenerationConfig"]["width"] == 1024 + assert body["imageGenerationConfig"]["height"] == 1024 + assert body["imageGenerationConfig"]["seed"] == 7 + + +def test_transform_request_text_image_invalid_control_mode_raises(): + """Unknown controlMode fails fast instead of hitting AWS with a bad payload.""" + config = BedrockAmazonNovaCanvasImageEditConfig() + img = io.BytesIO(b"cond") + with pytest.raises(BedrockError) as excinfo: + config.transform_image_edit_request( + model="amazon.nova-canvas-v1:0", + prompt="restyle", + image=img, + image_edit_optional_request_params={ + "taskType": "TEXT_IMAGE", + "controlMode": "CANNY_EDIT", + }, + litellm_params={}, + headers={}, + ) + assert excinfo.value.status_code == 400 + assert "Unsupported Amazon Nova Canvas controlMode" in str(excinfo.value.message) + + +def test_transform_request_text_image_with_mask_raises(): + """TEXT_IMAGE has no mask field; a provided mask must fail fast.""" + config = BedrockAmazonNovaCanvasImageEditConfig() + img = io.BytesIO(b"cond") + mask = io.BytesIO(b"mask-bytes") + with pytest.raises(BedrockError) as excinfo: + config.transform_image_edit_request( + model="amazon.nova-canvas-v1:0", + prompt="restyle", + image=img, + image_edit_optional_request_params={ + "taskType": "TEXT_IMAGE", + "mask": mask, + }, + litellm_params={}, + headers={}, + ) + assert excinfo.value.status_code == 400 + assert "does not support a mask" in str(excinfo.value.message) + assert "INPAINTING or OUTPAINTING" in str(excinfo.value.message) + + +def test_transform_request_text_image_with_mask_prompt_raises(): + """TEXT_IMAGE has no maskPrompt field either; fail fast like the binary mask + instead of silently dropping it.""" + config = BedrockAmazonNovaCanvasImageEditConfig() + img = io.BytesIO(b"cond") + with pytest.raises(BedrockError) as excinfo: + config.transform_image_edit_request( + model="amazon.nova-canvas-v1:0", + prompt="restyle", + image=img, + image_edit_optional_request_params={ + "taskType": "TEXT_IMAGE", + "maskPrompt": "the sky region", + }, + litellm_params={}, + headers={}, + ) + assert excinfo.value.status_code == 400 + assert "does not support a mask" in str(excinfo.value.message) + assert "INPAINTING or OUTPAINTING" in str(excinfo.value.message) + + +def test_transform_request_image_variation_with_mask_still_ignores_mask(): + """The documented IMAGE_VARIATION mask-ignoring behavior is untouched by the guard.""" + config = BedrockAmazonNovaCanvasImageEditConfig() + main = io.BytesIO(b"img-bytes") + mask = io.BytesIO(b"mask-bytes") + body, _ = config.transform_image_edit_request( + model="amazon.nova-canvas-v1:0", + prompt="vary style", + image=main, + image_edit_optional_request_params={ + "taskType": "IMAGE_VARIATION", + "mask": mask, + }, + litellm_params={}, + headers={}, + ) + assert body["taskType"] == "IMAGE_VARIATION" + assert "maskImage" not in body["imageVariationParams"] + assert body["imageVariationParams"]["text"] == "vary style" + + +def test_transform_request_text_image_empty_prompt_raises(): + """An empty TEXT_IMAGE prompt must fail fast, not silently send a blank.""" + config = BedrockAmazonNovaCanvasImageEditConfig() + img = io.BytesIO(b"cond") + with pytest.raises(BedrockError) as excinfo: + config.transform_image_edit_request( + model="amazon.nova-canvas-v1:0", + prompt="", + image=img, + image_edit_optional_request_params={"taskType": "TEXT_IMAGE"}, + litellm_params={}, + headers={}, + ) + assert excinfo.value.status_code == 400 + assert "TEXT_IMAGE requires a text prompt" in str(excinfo.value.message) + + +def test_transform_request_text_image_none_prompt_raises(): + config = BedrockAmazonNovaCanvasImageEditConfig() + img = io.BytesIO(b"cond") + with pytest.raises(BedrockError) as excinfo: + config.transform_image_edit_request( + model="amazon.nova-canvas-v1:0", + prompt=None, + image=img, + image_edit_optional_request_params={"taskType": "TEXT_IMAGE"}, + litellm_params={}, + headers={}, + ) + assert excinfo.value.status_code == 400 + assert "TEXT_IMAGE requires a text prompt" in str(excinfo.value.message) + + +def test_transform_request_text_image_control_strength_out_of_range_raises(): + """controlStrength outside 0.0-1.0 fails fast.""" + config = BedrockAmazonNovaCanvasImageEditConfig() + img = io.BytesIO(b"cond") + with pytest.raises(BedrockError) as excinfo: + config.transform_image_edit_request( + model="amazon.nova-canvas-v1:0", + prompt="restyle", + image=img, + image_edit_optional_request_params={ + "taskType": "TEXT_IMAGE", + "controlStrength": 1.5, + }, + litellm_params={}, + headers={}, + ) + assert excinfo.value.status_code == 400 + assert "controlStrength must be between 0.0 and 1.0" in str(excinfo.value.message) + + +@pytest.mark.parametrize("control_strength", [0.0, 1.0]) +def test_transform_request_text_image_control_strength_bounds_pass(control_strength): + """Boundary controlStrength values 0.0 and 1.0 are accepted.""" + config = BedrockAmazonNovaCanvasImageEditConfig() + img = io.BytesIO(b"cond") + body, _ = config.transform_image_edit_request( + model="amazon.nova-canvas-v1:0", + prompt="restyle", + image=img, + image_edit_optional_request_params={ + "taskType": "TEXT_IMAGE", + "controlStrength": control_strength, + }, + litellm_params={}, + headers={}, + ) + assert body["textToImageParams"]["controlStrength"] == control_strength + + +def test_transform_request_text_image_control_strength_string_coerced(): + """controlStrength arriving as a string (multipart form data) is coerced to a float.""" + config = BedrockAmazonNovaCanvasImageEditConfig() + img = io.BytesIO(b"cond") + body, _ = config.transform_image_edit_request( + model="amazon.nova-canvas-v1:0", + prompt="restyle", + image=img, + image_edit_optional_request_params={ + "taskType": "TEXT_IMAGE", + "controlStrength": "0.7", + }, + litellm_params={}, + headers={}, + ) + assert body["textToImageParams"]["controlStrength"] == 0.7 + + +def test_transform_request_text_image_control_strength_non_numeric_string_raises(): + """A non-numeric controlStrength string must fail fast, not TypeError.""" + config = BedrockAmazonNovaCanvasImageEditConfig() + img = io.BytesIO(b"cond") + with pytest.raises(BedrockError) as excinfo: + config.transform_image_edit_request( + model="amazon.nova-canvas-v1:0", + prompt="restyle", + image=img, + image_edit_optional_request_params={ + "taskType": "TEXT_IMAGE", + "controlStrength": "abc", + }, + litellm_params={}, + headers={}, + ) + assert excinfo.value.status_code == 400 + assert "controlStrength must be a number" in str(excinfo.value.message) + + +def test_transform_request_text_image_forwards_style(): + """style is forwarded into textToImageParams for conditioned editing.""" + config = BedrockAmazonNovaCanvasImageEditConfig() + img = io.BytesIO(b"cond") + body, _ = config.transform_image_edit_request( + model="amazon.nova-canvas-v1:0", + prompt="a city street in the same layout", + image=img, + image_edit_optional_request_params={ + "taskType": "TEXT_IMAGE", + "controlMode": "SEGMENTATION", + "style": "DESIGN_SKETCH", + }, + litellm_params={}, + headers={}, + ) + assert body["taskType"] == "TEXT_IMAGE" + assert body["textToImageParams"]["style"] == "DESIGN_SKETCH" + + +def test_transform_request_text_image_omits_style_when_absent(): + config = BedrockAmazonNovaCanvasImageEditConfig() + img = io.BytesIO(b"cond") + body, _ = config.transform_image_edit_request( + model="amazon.nova-canvas-v1:0", + prompt="restyle", + image=img, + image_edit_optional_request_params={"taskType": "TEXT_IMAGE"}, + litellm_params={}, + headers={}, + ) + assert "style" not in body["textToImageParams"] + + +def test_get_supported_openai_params_includes_conditioning_fields(): + """controlMode/controlStrength are advertised for TEXT_IMAGE conditioned editing.""" + config = BedrockAmazonNovaCanvasImageEditConfig() + supported = config.get_supported_openai_params("amazon.nova-canvas-v1:0") + assert "controlMode" in supported + assert "controlStrength" in supported + + +def test_get_supported_openai_params_includes_style(): + """style is advertised for TEXT_IMAGE conditioned editing.""" + config = BedrockAmazonNovaCanvasImageEditConfig() + supported = config.get_supported_openai_params("amazon.nova-canvas-v1:0") + assert "style" in supported + + def test_transform_request_background_removal_omits_image_generation_config(): """AWS Nova Canvas does not allow imageGenerationConfig on BACKGROUND_REMOVAL.""" config = BedrockAmazonNovaCanvasImageEditConfig() @@ -627,3 +960,161 @@ def test_prepare_request_bearer_token_never_runs_the_sigv4_credential_chain(monk ) assert request.prepped.headers["Authorization"] == "Bearer env-bearer-token-12345" + + +def test_transform_request_conditioning_fields_without_text_image_task_type_raises(): + """controlMode/controlStrength/style with any non-TEXT_IMAGE resolved taskType must + fail fast instead of being silently dropped by mask/variation routing.""" + config = BedrockAmazonNovaCanvasImageEditConfig() + img = io.BytesIO(b"cond") + with pytest.raises(BedrockError) as excinfo: + config.transform_image_edit_request( + model="amazon.nova-canvas-v1:0", + prompt="restyle", + image=img, + image_edit_optional_request_params={ + "controlMode": "SEGMENTATION", + }, + litellm_params={}, + headers={}, + ) + assert excinfo.value.status_code == 400 + assert "taskType=TEXT_IMAGE" in str(excinfo.value.message) + assert "controlMode/controlStrength/style" in str(excinfo.value.message) + + +def test_transform_request_control_strength_without_text_image_task_type_raises(): + config = BedrockAmazonNovaCanvasImageEditConfig() + img = io.BytesIO(b"cond") + with pytest.raises(BedrockError) as excinfo: + config.transform_image_edit_request( + model="amazon.nova-canvas-v1:0", + prompt="restyle", + image=img, + image_edit_optional_request_params={ + "controlStrength": 0.4, + }, + litellm_params={}, + headers={}, + ) + assert excinfo.value.status_code == 400 + assert "taskType=TEXT_IMAGE" in str(excinfo.value.message) + + +def test_transform_request_style_without_text_image_task_type_raises(): + config = BedrockAmazonNovaCanvasImageEditConfig() + img = io.BytesIO(b"cond") + with pytest.raises(BedrockError) as excinfo: + config.transform_image_edit_request( + model="amazon.nova-canvas-v1:0", + prompt="restyle", + image=img, + image_edit_optional_request_params={ + "style": "DESIGN_SKETCH", + }, + litellm_params={}, + headers={}, + ) + assert excinfo.value.status_code == 400 + assert "taskType=TEXT_IMAGE" in str(excinfo.value.message) + + +def test_transform_request_conditioning_fields_with_explicit_text_image_works(): + """The guard must not fire when taskType is explicitly TEXT_IMAGE.""" + config = BedrockAmazonNovaCanvasImageEditConfig() + img = io.BytesIO(b"cond") + body, _ = config.transform_image_edit_request( + model="amazon.nova-canvas-v1:0", + prompt="restyle", + image=img, + image_edit_optional_request_params={ + "taskType": "TEXT_IMAGE", + "controlMode": "CANNY_EDGE", + }, + litellm_params={}, + headers={}, + ) + assert body["taskType"] == "TEXT_IMAGE" + assert body["textToImageParams"]["controlMode"] == "CANNY_EDGE" + + +def test_transform_request_condition_image_without_multipart_image(): + """conditionImage is an alternative TEXT_IMAGE condition source for JSON-only callers.""" + config = BedrockAmazonNovaCanvasImageEditConfig() + condition_b64: Final = base64.b64encode(b"cond-bytes").decode("utf-8") + body, _ = config.transform_image_edit_request( + model="amazon.nova-canvas-v1:0", + prompt="same layout", + image=None, + image_edit_optional_request_params={ + "taskType": "TEXT_IMAGE", + "conditionImage": condition_b64, + "controlMode": "SEGMENTATION", + }, + litellm_params={}, + headers={}, + ) + assert body["taskType"] == "TEXT_IMAGE" + t2i = body["textToImageParams"] + assert t2i["conditionImage"] == condition_b64 + assert t2i["controlMode"] == "SEGMENTATION" + + +def test_transform_request_condition_image_bytes_accepted(): + """Raw bytes conditionImage is base64-encoded on the way into the body.""" + config = BedrockAmazonNovaCanvasImageEditConfig() + body, _ = config.transform_image_edit_request( + model="amazon.nova-canvas-v1:0", + prompt="same layout", + image=None, + image_edit_optional_request_params={ + "taskType": "TEXT_IMAGE", + "conditionImage": b"raw-cond-bytes", + }, + litellm_params={}, + headers={}, + ) + assert body["textToImageParams"]["conditionImage"] == base64.b64encode(b"raw-cond-bytes").decode("utf-8") + + +def test_transform_request_multipart_image_wins_over_condition_image(): + """Pinned precedence: when both are supplied the multipart `image` field wins.""" + config = BedrockAmazonNovaCanvasImageEditConfig() + condition_b64: Final = base64.b64encode(b"from-condition-param").decode("utf-8") + body, _ = config.transform_image_edit_request( + model="amazon.nova-canvas-v1:0", + prompt="same layout", + image=io.BytesIO(b"from-multipart"), + image_edit_optional_request_params={ + "taskType": "TEXT_IMAGE", + "conditionImage": condition_b64, + }, + litellm_params={}, + headers={}, + ) + assert body["textToImageParams"]["conditionImage"] == base64.b64encode(b"from-multipart").decode("utf-8") + + +def test_transform_request_condition_image_with_other_task_type_raises(): + """conditionImage only conditions TEXT_IMAGE; other task types need the image input.""" + config = BedrockAmazonNovaCanvasImageEditConfig() + with pytest.raises(BedrockError) as excinfo: + config.transform_image_edit_request( + model="amazon.nova-canvas-v1:0", + prompt="restyle", + image=None, + image_edit_optional_request_params={ + "taskType": "IMAGE_VARIATION", + "conditionImage": base64.b64encode(b"cond").decode("utf-8"), + }, + litellm_params={}, + headers={}, + ) + assert excinfo.value.status_code == 400 + assert "conditionImage is only supported" in str(excinfo.value.message) + + +def test_get_supported_openai_params_includes_condition_image(): + config = BedrockAmazonNovaCanvasImageEditConfig() + supported = config.get_supported_openai_params("amazon.nova-canvas-v1:0") + assert "conditionImage" in supported diff --git a/tests/unit/llms/bedrock/videos/__init__.py b/tests/unit/llms/bedrock/videos/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit/llms/bedrock/videos/test_amazon_nova_reel_video.py b/tests/unit/llms/bedrock/videos/test_amazon_nova_reel_video.py new file mode 100644 index 00000000000..ae1dad2a02d --- /dev/null +++ b/tests/unit/llms/bedrock/videos/test_amazon_nova_reel_video.py @@ -0,0 +1,2694 @@ +"""Unit tests for Bedrock Amazon Nova Reel video generation (issue #39552).""" + +import asyncio +import base64 +import io +import json +from datetime import datetime, timezone +from typing import Final, cast +from unittest.mock import Mock + +import httpx +import pytest + +import litellm +from litellm.llms.bedrock.common_utils import BedrockError +from litellm.llms.bedrock.videos.handler import BedrockVideoGeneration +from litellm.llms.bedrock.videos.transformation import BedrockNovaReelVideoConfig +from litellm.types.router import GenericLiteLLMParams +from litellm.types.videos.main import VideoCreateOptionalRequestParams +from litellm.types.videos.utils import decode_video_id_with_provider + +TEST_MODEL = "amazon.nova-reel-v1:0" +TEST_ARN = "arn:aws:bedrock:us-east-1:123456789012:async-invoke/abc123-def456" +PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"0" * 8 +JPEG_BYTES = b"\xff\xd8\xff\xe0" + b"0" * 8 + + +def _make_config() -> BedrockNovaReelVideoConfig: + return BedrockNovaReelVideoConfig() + + +def _create_request( + optional_params: dict | None = None, + prompt: str = "A drone shot over the ocean", + model: str = TEST_MODEL, + litellm_params: GenericLiteLLMParams | None = None, +) -> dict: + config = _make_config() + params: dict = optional_params if optional_params is not None else {"output_s3_uri": "s3://bucket/out/"} + body, files, method = config.transform_video_create_request( + model=model, + prompt=prompt, + api_base="https://bedrock-runtime.us-east-1.amazonaws.com/async-invoke", + video_create_optional_request_params=cast(VideoCreateOptionalRequestParams, params), + litellm_params=litellm_params or GenericLiteLLMParams(), + headers={}, + ) + assert files == [] + assert method == "POST" + return body + + +################################################# +# Request transform +################################################# + + +def test_transform_create_request_defaults(): + body = _create_request() + assert body["modelId"] == TEST_MODEL + assert body["outputDataConfig"] == {"s3OutputDataConfig": {"s3Uri": "s3://bucket/out/"}} + model_input = body["modelInput"] + assert model_input["taskType"] == "TEXT_VIDEO" + assert model_input["textToVideoParams"]["text"] == "A drone shot over the ocean" + assert "images" not in model_input["textToVideoParams"] + cfg = model_input["videoGenerationConfig"] + assert cfg["durationSeconds"] == 6 + assert cfg["fps"] == 24 + assert cfg["dimension"] == "1280x720" + + +def test_transform_create_request_seconds_and_size_map_to_generation_config(): + body = _create_request({"output_s3_uri": "s3://bucket/out/", "seconds": "10", "size": "720x1280", "seed": 5}) + cfg = body["modelInput"]["videoGenerationConfig"] + assert cfg["durationSeconds"] == 10 + assert cfg["dimension"] == "720x1280" + assert cfg["seed"] == 5 + + +def test_transform_create_request_input_reference_becomes_images(): + body = _create_request({"output_s3_uri": "s3://bucket/out/", "input_reference": io.BytesIO(PNG_BYTES)}) + images = body["modelInput"]["textToVideoParams"]["images"] + assert len(images) == 1 + assert images[0]["format"] == "png" + assert images[0]["source"]["bytes"] == base64.b64encode(PNG_BYTES).decode("utf-8") + + +def test_transform_create_request_jpeg_detected(): + body = _create_request({"output_s3_uri": "s3://bucket/out/", "input_reference": JPEG_BYTES}) + assert body["modelInput"]["textToVideoParams"]["images"][0]["format"] == "jpeg" + + +def test_transform_create_request_both_reference_keys_pop_both(): + """input_reference wins and no stray `image` key leaks into modelInput.""" + body = _create_request( + { + "output_s3_uri": "s3://bucket/out/", + "input_reference": PNG_BYTES, + "image": JPEG_BYTES, + } + ) + model_input = body["modelInput"] + assert "image" not in model_input + assert "input_reference" not in model_input + images = model_input["textToVideoParams"]["images"] + assert images[0]["format"] == "png" + + +def test_transform_create_request_file_like_image_not_leaked_with_input_reference(): + """A file-like `image` must be popped (not serialized) when input_reference is set.""" + body = _create_request( + { + "output_s3_uri": "s3://bucket/out/", + "input_reference": PNG_BYTES, + "image": io.BytesIO(PNG_BYTES), + } + ) + assert "image" not in body["modelInput"] + assert body["modelInput"]["textToVideoParams"]["images"][0]["format"] == "png" + + +def test_input_reference_data_url_round_trips(): + """data:image/png;base64, decodes only the payload and sniffs the format.""" + data_url = "data:image/png;base64," + base64.b64encode(PNG_BYTES).decode("utf-8") + body = _create_request({"output_s3_uri": "s3://bucket/out/", "input_reference": data_url}) + image = body["modelInput"]["textToVideoParams"]["images"][0] + assert image["format"] == "png" + assert image["source"]["bytes"] == base64.b64encode(PNG_BYTES).decode("utf-8") + + +def test_input_reference_undecodable_string_raises(): + with pytest.raises(BedrockError) as excinfo: + _create_request({"output_s3_uri": "s3://bucket/out/", "input_reference": "definitely!!not!!base64"}) + assert excinfo.value.status_code == 400 + assert "base64" in str(excinfo.value.message) + + +def test_input_reference_unrecognized_magic_raises(): + mystery_b64 = base64.b64encode(b"neither a png nor a jpeg header").decode("utf-8") + with pytest.raises(BedrockError) as excinfo: + _create_request({"output_s3_uri": "s3://bucket/out/", "input_reference": mystery_b64}) + assert excinfo.value.status_code == 400 + assert "PNG or JPEG" in str(excinfo.value.message) + + +def test_input_reference_https_url_raises(): + with pytest.raises(BedrockError) as excinfo: + _create_request({"output_s3_uri": "s3://bucket/out/", "input_reference": "https://example.com/img.png"}) + assert excinfo.value.status_code == 400 + assert "base64" in str(excinfo.value.message) + + +def test_transform_create_request_drops_non_aws_client_params(): + """Known non-AWS video-client params are dropped; provider keys still pass through.""" + body = _create_request( + { + "output_s3_uri": "s3://bucket/out/", + "parameters": {"foo": 1}, + "resolution": "1080p", + "characters": [], + "user": "someone", + "multiShotManualParams": {"shot": []}, + } + ) + model_input = body["modelInput"] + for leaked in ("parameters", "resolution", "characters", "user"): + assert leaked not in model_input + assert model_input["multiShotManualParams"] == {"shot": []} + + +def test_client_request_token_passthrough_populates_envelope(): + body = _create_request({"output_s3_uri": "s3://bucket/out/", "client_request_token": "my-token-123"}) + assert body["clientRequestToken"] == "my-token-123" + assert "client_request_token" not in body["modelInput"] + + +def test_client_request_token_absent_omits_envelope_key(): + body = _create_request() + assert "clientRequestToken" not in body + + +def test_client_request_token_falls_back_to_litellm_request_id(): + litellm_params = GenericLiteLLMParams() + litellm_params.metadata = {"request_id": "req/abc_123"} # extra field allowed on GenericLiteLLMParams + body = _create_request(litellm_params=litellm_params) + assert body["clientRequestToken"] == "req-abc-123" + + +def test_client_request_token_sanitized_and_truncated(): + litellm_params = GenericLiteLLMParams() + litellm_params.metadata = {"request_id": "req/abc_123:" + "x" * 100} # extra field allowed on GenericLiteLLMParams + body = _create_request(litellm_params=litellm_params) + token = body["clientRequestToken"] + assert len(token) == 64 + assert "/" not in token and ":" not in token and "_" not in token + + +def test_fps_seed_string_coercion(): + body = _create_request({"output_s3_uri": "s3://bucket/out/", "fps": "24", "seed": "42"}) + cfg = body["modelInput"]["videoGenerationConfig"] + assert cfg["fps"] == 24 + assert cfg["seed"] == 42 + + +def test_seconds_string_coerces(): + body = _create_request({"output_s3_uri": "s3://bucket/out/", "seconds": "8"}) + assert body["modelInput"]["videoGenerationConfig"]["durationSeconds"] == 8 + + +def test_seconds_non_numeric_raises(): + """Non-numeric seconds must raise like fps/seed, not silently keep the default 6.""" + with pytest.raises(BedrockError) as excinfo: + _create_request({"output_s3_uri": "s3://bucket/out/", "seconds": "abc"}) + assert excinfo.value.status_code == 400 + assert "seconds" in str(excinfo.value.message) + + +def test_fps_non_numeric_raises(): + with pytest.raises(BedrockError) as excinfo: + _create_request({"output_s3_uri": "s3://bucket/out/", "fps": "abc"}) + assert excinfo.value.status_code == 400 + assert "fps" in str(excinfo.value.message) + + +def test_seed_non_numeric_raises(): + with pytest.raises(BedrockError) as excinfo: + _create_request({"output_s3_uri": "s3://bucket/out/", "seed": "abc"}) + assert excinfo.value.status_code == 400 + assert "seed" in str(excinfo.value.message) + + +def test_transform_create_request_requires_output_s3_uri(): + with pytest.raises(BedrockError) as excinfo: + _create_request({}) + assert excinfo.value.status_code == 400 + assert "output_s3_uri" in str(excinfo.value.message) + + +def test_transform_create_request_task_type_passthrough(): + body = _create_request({"output_s3_uri": "s3://bucket/out/", "taskType": "MULTI_SHOT_AUTOMATED"}) + assert body["modelInput"]["taskType"] == "MULTI_SHOT_AUTOMATED" + + +################################################# +# Create + status response transforms +################################################# + + +def test_transform_create_response_maps_invocation_arn(): + config = _make_config() + resp = httpx.Response(200, json={"invocationArn": TEST_ARN}) + video = config.transform_video_create_response( + model=TEST_MODEL, + raw_response=resp, + logging_obj=None, + request_data={"modelInput": {"videoGenerationConfig": {"durationSeconds": 6}}}, + ) + assert video.status == "processing" + assert video.model == TEST_MODEL + assert video.usage is not None + assert video.usage["duration_seconds"] == 6.0 + decoded = decode_video_id_with_provider(video.id) + assert decoded["custom_llm_provider"] == "bedrock" + assert decoded["model_id"] == TEST_MODEL + assert decoded["video_id"] == TEST_ARN + + +def test_transform_create_response_missing_arn_raises(): + config = _make_config() + resp = httpx.Response(200, json={}) + with pytest.raises(ValueError, match="invocationArn"): + config.transform_video_create_response(model=TEST_MODEL, raw_response=resp, logging_obj=None) + + +@pytest.mark.parametrize( + "raw_status,expected", + [ + ("InProgress", "processing"), + ("Completed", "completed"), + ("Failed", "failed"), + ], +) +def test_transform_status_response_maps_aws_enum(raw_status, expected): + config = _make_config() + resp = httpx.Response( + 200, + json={ + "invocationArn": TEST_ARN, + "modelArn": "arn:aws:bedrock:us-east-1::foundation-model/amazon.nova-reel-v1:0", + "status": raw_status, + "submitTime": 1758000000.0, + "lastModifiedTime": 1758000060.0, + "endTime": 1758000060.0 if raw_status != "InProgress" else None, + "failureMessage": "blocked by content filters" if raw_status == "Failed" else None, + "outputDataConfig": {"s3OutputDataConfig": {"s3Uri": "s3://bucket/out/"}}, + }, + ) + video = config.transform_video_status_retrieve_response(raw_response=resp, logging_obj=None, model=TEST_MODEL) + assert video.status == expected + if raw_status == "Failed": + assert video.error == {"message": "blocked by content filters"} + if raw_status == "Completed": + assert video.completed_at == 1758000060 + # output_s3_uri rides on _hidden_params (provider detail), not usage. + assert video.usage is None + assert video._hidden_params["output_s3_uri"] == "s3://bucket/out/" + decoded = decode_video_id_with_provider(video.id) + assert decoded["video_id"] == TEST_ARN + + +def test_transform_status_response_missing_status_raises(): + """A get-async-invoke body without a status must fail loudly, not report InProgress.""" + config = _make_config() + resp = httpx.Response(200, json={"invocationArn": TEST_ARN, "submitTime": 1758000000.0}) + with pytest.raises(BedrockError, match="unexpected shape") as excinfo: + config.transform_video_status_retrieve_response(raw_response=resp, logging_obj=None, model=TEST_MODEL) + message = str(excinfo.value.message) + assert "invocationArn" in message # observed keys are named + assert "submitTime" in message + + +def test_transform_status_response_empty_status_raises(): + config = _make_config() + resp = httpx.Response(200, json={"invocationArn": TEST_ARN, "status": ""}) + with pytest.raises(BedrockError, match="unexpected shape"): + config.transform_video_status_retrieve_response(raw_response=resp, logging_obj=None, model=TEST_MODEL) + + +def test_transform_status_response_unknown_status_warns_and_maps_processing(monkeypatch): + """An unmapped AWS invocationStatus must log a warning while still reporting processing.""" + config = _make_config() + logger = Mock() + monkeypatch.setattr("litellm.llms.bedrock.videos.transformation.verbose_logger", logger) + resp = httpx.Response( + 200, + json={ + "invocationArn": TEST_ARN, + "status": "Throttled", + "submitTime": 1758000000.0, + }, + ) + video = config.transform_video_status_retrieve_response(raw_response=resp, logging_obj=None, model=TEST_MODEL) + assert video.status == "processing" + logger.warning.assert_called_once() + assert "Throttled" in str(logger.warning.call_args) + + +################################################# +# Video id encoding round-trip +################################################# + + +def test_video_id_encoding_round_trip(): + from litellm.types.videos.utils import encode_video_id_with_provider + + encoded = encode_video_id_with_provider(TEST_ARN, "bedrock", TEST_MODEL) + decoded = decode_video_id_with_provider(encoded) + assert decoded["custom_llm_provider"] == "bedrock" + assert decoded["model_id"] == TEST_MODEL + assert decoded["video_id"] == TEST_ARN + assert _make_config().extract_invocation_arn(encoded) == TEST_ARN + + +################################################# +# Provider config dispatch +################################################# + + +def test_provider_config_manager_returns_nova_reel_config(): + from litellm.utils import ProviderConfigManager + + cfg = ProviderConfigManager.get_provider_video_config(TEST_MODEL, litellm.LlmProviders.BEDROCK) + assert isinstance(cfg, BedrockNovaReelVideoConfig) + + +def test_provider_config_manager_us_cross_region_variant(): + from litellm.utils import ProviderConfigManager + + cfg = ProviderConfigManager.get_provider_video_config("us.amazon.nova-reel-v1:0", litellm.LlmProviders.BEDROCK) + assert isinstance(cfg, BedrockNovaReelVideoConfig) + + +def test_provider_config_manager_non_reel_bedrock_returns_none(): + from litellm.utils import ProviderConfigManager + + cfg = ProviderConfigManager.get_provider_video_config("amazon.titan-text-express-v1", litellm.LlmProviders.BEDROCK) + assert cfg is None + + +def test_provider_config_manager_model_none_returns_config(): + """Status/content routes pass model=None; the model lives in the video id.""" + from litellm.utils import ProviderConfigManager + + cfg = ProviderConfigManager.get_provider_video_config(None, litellm.LlmProviders.BEDROCK) + assert isinstance(cfg, BedrockNovaReelVideoConfig) + + +################################################# +# Handler request construction (no network) +################################################# + + +class _FakeCredentialsInfo: + def __init__(self): + from botocore.credentials import Credentials + + self.credentials = Credentials("AKIA-test", "secret-test") + self.aws_region_name = "us-east-1" + self.aws_bedrock_runtime_endpoint = None + + +def test_handler_builds_async_invoke_request(monkeypatch): + handler = BedrockVideoGeneration() + monkeypatch.setattr( + BedrockVideoGeneration, + "_get_boto_credentials_from_optional_params", + lambda self, params, model=None, bearer_token=None: _FakeCredentialsInfo(), + ) + endpoint_url, prepped, body, data = handler._prepare_async_invoke_request( + model="bedrock/amazon.nova-reel-v1:0", + prompt="waves at sunset", + optional_params={"output_s3_uri": "s3://bucket/out/"}, + api_base=None, + extra_headers=None, + logging_obj=None, + ) + assert endpoint_url == "https://bedrock-runtime.us-east-1.amazonaws.com/async-invoke" + assert prepped.url == endpoint_url + assert "Authorization" in prepped.headers + parsed = json.loads(body) + assert parsed["modelId"] == "amazon.nova-reel-v1:0" # bedrock/ prefix stripped + assert parsed["outputDataConfig"]["s3OutputDataConfig"]["s3Uri"] == "s3://bucket/out/" + assert parsed["modelInput"]["taskType"] == "TEXT_VIDEO" + + +################################################# +# logging headers redaction (pre_call additional_args) +################################################# + + +def test_redact_bedrock_headers_for_logging_masks_signed_headers(): + """SigV4 signature material must be replaced with [REDACTED]; safe headers survive.""" + from litellm.llms.bedrock.common_utils import redact_bedrock_headers_for_logging + + signed: Final[dict[str, str]] = { + "Content-Type": "application/json", + "Host": "bedrock-runtime.us-east-1.amazonaws.com", + "Authorization": ( + "AWS4-HMAC-SHA256 Credential=AKIA-test/20260115/us-east-1/bedrock/aws4_request, " + "SignedHeaders=host;x-amz-date, Signature=deadbeefsecret" + ), + "X-Amz-Date": "20260115T103000Z", + "X-Amz-Security-Token": "session-token-secret", + "X-Amz-Content-Sha256": "sensitive-payload-hash", + } + redacted = redact_bedrock_headers_for_logging(signed) + assert redacted["Content-Type"] == "application/json" + assert redacted["Host"] == "bedrock-runtime.us-east-1.amazonaws.com" + assert redacted["Authorization"] == "[REDACTED]" + assert redacted["X-Amz-Date"] == "[REDACTED]" + assert redacted["X-Amz-Security-Token"] == "[REDACTED]" + assert redacted["X-Amz-Content-Sha256"] == "[REDACTED]" + # Every key stays present (log consumers see the full header shape). + assert set(redacted.keys()) == set(signed.keys()) + # The input mapping is untouched: redaction never mutates the sent headers. + assert signed["Authorization"].startswith("AWS4-HMAC-SHA256") + assert signed["X-Amz-Security-Token"] == "session-token-secret" + + +def _capturing_logging_obj(captured: dict): + """A real Logging object whose logger_fn captures model_call_details.""" + from litellm.litellm_core_utils.litellm_logging import Logging + + def _capture(model_call_details): + captured.update(model_call_details) + + logging_obj = Logging( + model="bedrock/amazon.nova-reel-v1:0", + messages=[], + stream=False, + call_type="avideo_generation", + start_time=datetime.now(), + litellm_call_id="test-call-id", + function_id="test-function", + kwargs={"logger_fn": _capture}, + ) + logging_obj.update_environment_variables( + litellm_params={"logger_fn": _capture}, + optional_params={}, + ) + return logging_obj + + +def test_prepare_request_logging_headers_redacted(monkeypatch): + """pre_call additional_args must carry the redacted copy; the sent request keeps + the real signed Authorization header.""" + handler = BedrockVideoGeneration() + monkeypatch.setattr( + BedrockVideoGeneration, + "_get_boto_credentials_from_optional_params", + lambda self, params, model=None, bearer_token=None: _FakeCredentialsInfo(), + ) + captured: dict = {} + _, prepped, _, _ = handler._prepare_async_invoke_request( + model="bedrock/amazon.nova-reel-v1:0", + prompt="waves at sunset", + optional_params={"output_s3_uri": "s3://bucket/out/"}, + api_base=None, + extra_headers=None, + logging_obj=_capturing_logging_obj(captured), + ) + logged_headers: Final = captured["additional_args"]["headers"] + assert logged_headers["Authorization"] == "[REDACTED]" + assert logged_headers["Content-Type"] == "application/json" + assert "deadbeefsecret" not in str(captured) + # The sent request still carries the real SigV4 Authorization header. + assert prepped.headers["Authorization"].startswith("AWS4-HMAC-SHA256") + + +def test_handler_builds_status_get_url(monkeypatch): + handler = BedrockVideoGeneration() + monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIA-test") + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "secret-test") + status_url, prepped, region = handler._status_request_parts(TEST_ARN, {"aws_region_name": "us-east-1"}, None) + from urllib.parse import quote + + expected = "https://bedrock-runtime.us-east-1.amazonaws.com/async-invoke/" + quote(TEST_ARN, safe="") + assert status_url == expected + assert prepped.url == expected + assert region == "us-east-1" + + +def test_handler_status_region_defaults_to_arn_region(monkeypatch): + """No explicit aws_region_name: the ARN region must be resolved and returned.""" + handler = BedrockVideoGeneration() + monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIA-test") + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "secret-test") + _, _, region = handler._status_request_parts(TEST_ARN, {}, None) + assert region == "us-east-1" + + +def test_handler_video_status_maps_response(monkeypatch): + handler = BedrockVideoGeneration() + from litellm.types.videos.utils import encode_video_id_with_provider + + video_id = encode_video_id_with_provider(TEST_ARN, "bedrock", TEST_MODEL) + monkeypatch.setattr( + handler, + "_status_request_parts", + lambda arn, params, api_base, api_key=None: ( + "https://example.com/async-invoke/arn", + Mock(url="https://example.com/async-invoke/arn", headers={}), + "us-east-1", + ), + ) + resp = httpx.Response( + 200, + json={ + "invocationArn": TEST_ARN, + "status": "InProgress", + "submitTime": 1758000000.0, + "outputDataConfig": {"s3OutputDataConfig": {"s3Uri": "s3://bucket/out/"}}, + }, + ) + monkeypatch.setattr(handler, "_sync_get", lambda prepped, timeout=None: resp) + video = handler.video_status(video_id=video_id, litellm_params={"aws_region_name": "us-east-1"}) + assert video.status == "processing" + assert decode_video_id_with_provider(video.id)["video_id"] == TEST_ARN + + +def test_handler_video_content_requires_completed(monkeypatch): + handler = BedrockVideoGeneration() + from litellm.types.videos.utils import encode_video_id_with_provider + + video_id = encode_video_id_with_provider(TEST_ARN, "bedrock", TEST_MODEL) + monkeypatch.setattr( + handler, + "_status_request_parts", + lambda arn, params, api_base, api_key=None: ( + "https://example.com/async-invoke/arn", + Mock(url="https://example.com/async-invoke/arn", headers={}), + "us-east-1", + ), + ) + resp = httpx.Response( + 200, + json={ + "invocationArn": TEST_ARN, + "status": "InProgress", + "outputDataConfig": {"s3OutputDataConfig": {"s3Uri": "s3://bucket/out/"}}, + }, + ) + monkeypatch.setattr(handler, "_sync_get", lambda prepped, timeout=None: resp) + with pytest.raises(BedrockError) as excinfo: + handler.video_content(video_id=video_id, litellm_params={}) + assert excinfo.value.status_code == 400 + assert "not complete" in str(excinfo.value.message) + + +def test_handler_video_content_failed_status_raises_with_failure_message(monkeypatch): + """A Failed invocation surfaces failureMessage as a 502, not a not-complete-yet error.""" + handler = BedrockVideoGeneration() + from litellm.types.videos.utils import encode_video_id_with_provider + + video_id = encode_video_id_with_provider(TEST_ARN, "bedrock", TEST_MODEL) + monkeypatch.setattr( + handler, + "_status_request_parts", + lambda arn, params, api_base, api_key=None: ( + "https://example.com/async-invoke/arn", + Mock(url="https://example.com/async-invoke/arn", headers={}), + "us-east-1", + ), + ) + resp = httpx.Response( + 200, + json={ + "invocationArn": TEST_ARN, + "status": "Failed", + "failureMessage": "Content filtration invoked", + "outputDataConfig": {"s3OutputDataConfig": {"s3Uri": "s3://bucket/out/"}}, + }, + ) + monkeypatch.setattr(handler, "_sync_get", lambda prepped, timeout=None: resp) + with pytest.raises(BedrockError, match="Nova Reel invocation failed: Content filtration invoked") as excinfo: + handler.video_content(video_id=video_id, litellm_params={}) + assert excinfo.value.status_code == 502 + + +def test_handler_video_content_downloads_from_s3(monkeypatch): + handler = BedrockVideoGeneration() + from litellm.types.videos.utils import encode_video_id_with_provider + + # Pin a v1:0 model explicitly: the flat fallback key is v1:0-only. + assert TEST_MODEL == "amazon.nova-reel-v1:0" + video_id = encode_video_id_with_provider(TEST_ARN, "bedrock", TEST_MODEL) + monkeypatch.setattr( + handler, + "_status_request_parts", + lambda arn, params, api_base, api_key=None: ( + "https://example.com/async-invoke/arn", + Mock(url="https://example.com/async-invoke/arn", headers={}), + "us-east-1", + ), + ) + resp = httpx.Response( + 200, + json={ + "invocationArn": TEST_ARN, + "status": "Completed", + "outputDataConfig": {"s3OutputDataConfig": {"s3Uri": "s3://bucket/out/"}}, + }, + ) + monkeypatch.setattr(handler, "_sync_get", lambda prepped, timeout=None: resp) + + downloaded_keys: list[str] = [] + + def fake_download(bucket, key_candidates, litellm_params, raw, region_default=None, api_key=None, timeout=None): + downloaded_keys.extend(key_candidates) + return b"mp4-bytes" + + monkeypatch.setattr(handler, "_download_s3_object", fake_download) + content = handler.video_content(video_id=video_id, litellm_params={}) + assert content == b"mp4-bytes" + # v1:1 per-invocation folder first, then the older v1:0 flat layout. + assert downloaded_keys[0] == "out/abc123-def456/output.mp4" + assert downloaded_keys[1] == "out/output.mp4" + + +def _completed_content_setup( + monkeypatch, + handler: BedrockVideoGeneration, + encoded_model: str, + extra_status: dict, +) -> tuple[str, list[str]]: + """Patch status+download for a Completed video_content run; returns (video_id, keys).""" + from litellm.types.videos.utils import encode_video_id_with_provider + + video_id = encode_video_id_with_provider(TEST_ARN, "bedrock", encoded_model) + monkeypatch.setattr( + handler, + "_status_request_parts", + lambda arn, params, api_base, api_key=None: ( + "https://example.com/async-invoke/arn", + Mock(url="https://example.com/async-invoke/arn", headers={}), + "us-east-1", + ), + ) + resp = httpx.Response( + 200, + json={ + "invocationArn": TEST_ARN, + "status": "Completed", + "outputDataConfig": {"s3OutputDataConfig": {"s3Uri": "s3://bucket/out/"}}, + **extra_status, + }, + ) + monkeypatch.setattr(handler, "_sync_get", lambda prepped, timeout=None: resp) + + downloaded_keys: list[str] = [] + + def fake_download(bucket, key_candidates, litellm_params, raw, region_default=None, api_key=None, timeout=None): + downloaded_keys.extend(key_candidates) + return b"mp4-bytes" + + monkeypatch.setattr(handler, "_download_s3_object", fake_download) + return video_id, downloaded_keys + + +def test_video_content_v1_1_does_not_fall_back_to_flat_key(monkeypatch): + """v1:1 writes per-invocation folders only; the shared-prefix flat key must not + be tried (it can hold a foreign or stale object).""" + handler = BedrockVideoGeneration() + video_id, keys = _completed_content_setup( + monkeypatch, + handler, + "amazon.nova-reel-v1:1", + {"modelArn": "arn:aws:bedrock:us-east-1:123456789012:inference-profile/us.amazon.nova-reel-v1:1"}, + ) + assert handler.video_content(video_id=video_id, litellm_params={}) == b"mp4-bytes" + assert keys == ["out/abc123-def456/output.mp4"] + + +def test_video_content_v1_0_keeps_flat_fallback(monkeypatch): + """A v1:0 invocation (cross-region id + foundation-model arn) keeps both candidate + keys, per-invocation folder first.""" + handler = BedrockVideoGeneration() + video_id, keys = _completed_content_setup( + monkeypatch, + handler, + "us.amazon.nova-reel-v1:0", + {"modelArn": "arn:aws:bedrock:us-east-1::foundation-model/amazon.nova-reel-v1:0"}, + ) + assert handler.video_content(video_id=video_id, litellm_params={}) == b"mp4-bytes" + assert keys == ["out/abc123-def456/output.mp4", "out/output.mp4"] + + +def test_video_content_model_arn_overrides_encoded_model(monkeypatch): + """modelArn on the status response is authoritative: a v1:0-encoded id whose + invocation actually ran v1:1 (per the arn) drops the flat fallback.""" + handler = BedrockVideoGeneration() + video_id, keys = _completed_content_setup( + monkeypatch, + handler, + "amazon.nova-reel-v1:0", + {"modelArn": "arn:aws:bedrock:us-east-1:123456789012:inference-profile/us.amazon.nova-reel-v1:1"}, + ) + assert handler.video_content(video_id=video_id, litellm_params={}) == b"mp4-bytes" + assert keys == ["out/abc123-def456/output.mp4"] + + +def test_handler_sync_create_passes_timeout(monkeypatch): + """The sync create path must forward its timeout to the POST.""" + handler = BedrockVideoGeneration() + monkeypatch.setattr( + BedrockVideoGeneration, + "_get_boto_credentials_from_optional_params", + lambda self, params, model=None, bearer_token=None: _FakeCredentialsInfo(), + ) + seen: dict[str, object] = {} + + class _RecordingClient: + def post(self, **kwargs): + seen.update(kwargs) + return httpx.Response( + 200, + json={"invocationArn": TEST_ARN}, + request=httpx.Request("POST", "https://bedrock-runtime.us-east-1.amazonaws.com/async-invoke"), + ) + + monkeypatch.setattr("litellm.llms.custom_httpx.http_handler._get_httpx_client", lambda: _RecordingClient()) + video = handler.video_generation( + model="bedrock/amazon.nova-reel-v1:0", + prompt="waves at sunset", + optional_params={"output_s3_uri": "s3://bucket/out/"}, + logging_obj=None, + timeout=12.5, + avideo_generation=False, + ) + assert seen["timeout"] == 12.5 + assert video.status == "processing" + + +def test_handler_sync_get_passes_timeout(monkeypatch): + """video_status must thread its timeout into the GET request.""" + handler = BedrockVideoGeneration() + from litellm.types.videos.utils import encode_video_id_with_provider + + video_id = encode_video_id_with_provider(TEST_ARN, "bedrock", TEST_MODEL) + monkeypatch.setattr( + handler, + "_status_request_parts", + lambda arn, params, api_base, api_key=None: ( + "https://example.com/async-invoke/arn", + Mock(url="https://example.com/async-invoke/arn", headers={}), + "us-east-1", + ), + ) + seen: dict[str, object] = {} + + class _RecordingClient: + def get(self, **kwargs): + seen.update(kwargs) + return httpx.Response( + 200, + json={"invocationArn": TEST_ARN, "status": "InProgress", "submitTime": 1758000000.0}, + ) + + monkeypatch.setattr("litellm.llms.custom_httpx.http_handler._get_httpx_client", lambda: _RecordingClient()) + video = handler.video_status(video_id=video_id, litellm_params={}, timeout=7.5) + assert seen["timeout"] == 7.5 + assert video.status == "processing" + + +def test_handler_async_get_passes_timeout(monkeypatch): + """_async_get must thread its timeout into the GET request.""" + handler = BedrockVideoGeneration() + seen: dict[str, object] = {} + + class _RecordingAsyncClient: + async def get(self, **kwargs): + seen.update(kwargs) + return httpx.Response(200, json={"invocationArn": TEST_ARN, "status": "InProgress"}) + + monkeypatch.setattr( + "litellm.llms.custom_httpx.http_handler.get_async_httpx_client", + lambda llm_provider=None, params=None: _RecordingAsyncClient(), + ) + prepped = Mock(url="https://example.com/async-invoke/arn", headers={}) + response = asyncio.run(handler._async_get(prepped, timeout=9.0)) + assert seen["timeout"] == 9.0 + assert response.status_code == 200 + + +def test_sign_get_request_without_credentials_or_bearer_raises(monkeypatch): + """No SigV4 credentials and no bearer token must fail fast, like the shared POST signer.""" + from botocore.exceptions import NoCredentialsError + + from litellm.llms.bedrock.videos.handler import _sign_get_request + + with pytest.raises(NoCredentialsError): + _sign_get_request( + credentials=None, + url="https://bedrock-runtime.us-east-1.amazonaws.com/async-invoke/arn", + headers={}, + aws_region_name="us-east-1", + ) + + +def _patch_s3_download( + monkeypatch, handler: BedrockVideoGeneration, get_object_side_effect +) -> tuple[list, list, list[str]]: + """Mock boto3 + credentials for _download_s3_object. + + Returns (session_kwargs, s3_clients, attempted_keys); each fake client records + the botocore Config it was built with and whether close() ran. + """ + sessions: list[dict] = [] + clients: list = [] + attempted: list[str] = [] + + class _FakeS3Client: + def __init__(self): + self.config = None + self.closed = False + + def get_object(self, Bucket, Key): + attempted.append(Key) + return get_object_side_effect(Bucket, Key) + + def close(self): + self.closed = True + + class _FakeSession: + def __init__(self, **kwargs): + sessions.append(kwargs) + + def client(self, service_name, config=None): + client: Final = _FakeS3Client() + client.config = config + clients.append(client) + return client + + monkeypatch.setattr("boto3.Session", _FakeSession) + monkeypatch.setattr( + handler, + "_load_credentials", + lambda optional_params, aws_region_name=None, bearer_token=None: (None, aws_region_name or "us-east-1"), + ) + return sessions, clients, attempted + + +class _TrackingBody(io.BytesIO): + """BytesIO that counts close() calls so the download's cleanup is observable.""" + + def __init__(self, data: bytes): + super().__init__(data) + self.close_calls = 0 + + def close(self): + self.close_calls += 1 + super().close() + + +def test_download_s3_object_uses_status_region_by_default(monkeypatch): + """region_default (ARN-derived) beats env/default; explicit litellm_params region still wins.""" + handler = BedrockVideoGeneration() + raw: dict = {"outputDataConfig": {"s3OutputDataConfig": {"s3Uri": "s3://bucket/out/"}}} + sessions, _, _ = _patch_s3_download( + monkeypatch, + handler, + lambda bucket, key: {"Body": io.BytesIO(b"mp4-bytes")}, + ) + content = handler._download_s3_object("bucket", ["out/output.mp4"], {}, raw, region_default="eu-central-1") + assert content == b"mp4-bytes" + assert sessions[0]["region_name"] == "eu-central-1" + + handler._download_s3_object( + "bucket", + ["out/output.mp4"], + {"aws_region_name": "ap-south-1"}, + raw, + region_default="eu-central-1", + ) + assert sessions[1]["region_name"] == "ap-south-1" + + +def test_download_s3_object_error_message_redacts_invocation(monkeypatch): + """The 404 names the s3Uri and tried keys, never the raw invocation (ARN/account id).""" + from botocore.exceptions import ClientError + + def _raise(bucket, key): + raise ClientError( + {"Error": {"Code": "NoSuchKey", "Message": "The specified key does not exist."}}, + "GetObject", + ) + + handler = BedrockVideoGeneration() + raw: dict = { + "invocationArn": TEST_ARN, + "outputDataConfig": {"s3OutputDataConfig": {"s3Uri": "s3://bucket/out/"}}, + } + _patch_s3_download(monkeypatch, handler, _raise) + with pytest.raises(BedrockError, match="not found in the S3 output location") as excinfo: + handler._download_s3_object("bucket", ["out/output.mp4"], {}, raw, region_default="us-east-1") + message: Final = str(excinfo.value.message) + assert "s3://bucket/out/" in message + assert "out/output.mp4" in message + assert "NoSuchKey" in message + assert "123456789012" not in message + assert "invocationArn" not in message + + +def test_transform_create_response_accepts_202(monkeypatch): + """Any 2xx is a success once raise_for_status ran; 202 must reach the transform.""" + handler = BedrockVideoGeneration() + resp = httpx.Response(202, json={"invocationArn": TEST_ARN}) + video = handler._transform_create_response(TEST_MODEL, resp, {}, None) + assert video.status == "processing" + + +def test_get_supported_openai_params_includes_video_params(): + supported = _make_config().get_supported_openai_params(TEST_MODEL) + assert "seconds" in supported + assert "size" in supported + assert "output_s3_uri" in supported + assert "kmsKeyId" in supported + assert "bucketOwner" in supported + assert "parameters" not in supported + + +################################################# +# litellm_params threading + timeout/error mapping on the HTTP paths +################################################# + + +def test_handler_create_threads_litellm_params_request_id_into_token(monkeypatch): + """video_generation must feed litellm_params into the transform so + metadata.request_id reaches the signed POST body as clientRequestToken.""" + handler = BedrockVideoGeneration() + monkeypatch.setattr( + BedrockVideoGeneration, + "_get_boto_credentials_from_optional_params", + lambda self, params, model=None, bearer_token=None: _FakeCredentialsInfo(), + ) + bodies: list[bytes] = [] + + class _RecordingClient: + def post(self, **kwargs): + bodies.append(kwargs["content"]) + return httpx.Response( + 200, + json={"invocationArn": TEST_ARN}, + request=httpx.Request("POST", "https://bedrock-runtime.us-east-1.amazonaws.com/async-invoke"), + ) + + monkeypatch.setattr("litellm.llms.custom_httpx.http_handler._get_httpx_client", lambda: _RecordingClient()) + litellm_params = GenericLiteLLMParams() + litellm_params.metadata = {"request_id": "req/abc_123"} # extra field allowed on GenericLiteLLMParams + video = handler.video_generation( + model="bedrock/amazon.nova-reel-v1:0", + prompt="waves at sunset", + optional_params={"output_s3_uri": "s3://bucket/out/"}, + logging_obj=None, + timeout=5.0, + avideo_generation=False, + litellm_params=litellm_params, + ) + assert video.status == "processing" + parsed = json.loads(bodies[0]) + assert parsed["clientRequestToken"] == "req-abc-123" + + +def test_handler_async_injected_client_receives_timeout_on_post(monkeypatch): + """An injected async client must receive the timeout kwarg on post (the shared + factory path applied it; the injected path dropped it).""" + handler = BedrockVideoGeneration() + monkeypatch.setattr( + BedrockVideoGeneration, + "_get_boto_credentials_from_optional_params", + lambda self, params, model=None, bearer_token=None: _FakeCredentialsInfo(), + ) + seen: dict[str, object] = {} + + class _RecordingAsyncClient(httpx.AsyncClient): + async def post(self, **kwargs): + seen.update(kwargs) + return httpx.Response( + 200, + json={"invocationArn": TEST_ARN}, + request=httpx.Request("POST", "https://bedrock-runtime.us-east-1.amazonaws.com/async-invoke"), + ) + + video = asyncio.run( + handler.async_video_generation( + model="bedrock/amazon.nova-reel-v1:0", + prompt="waves at sunset", + optional_params={"output_s3_uri": "s3://bucket/out/"}, + logging_obj=None, + timeout=4.5, + client=_RecordingAsyncClient(), + ) + ) + assert seen["timeout"] == 4.5 + assert video.status == "processing" + + +def test_handler_sync_get_timeout_maps_to_bedrock_408(monkeypatch): + """A status GET timeout must surface as BedrockError 408 like the create path.""" + handler = BedrockVideoGeneration() + + class _TimingOutClient: + def get(self, **kwargs): + raise httpx.ReadTimeout("timed out") + + monkeypatch.setattr("litellm.llms.custom_httpx.http_handler._get_httpx_client", lambda: _TimingOutClient()) + with pytest.raises(BedrockError) as excinfo: + handler._sync_get(Mock(url="https://example.com/async-invoke/arn", headers={}), timeout=1.0) + assert excinfo.value.status_code == 408 + + +def test_handler_async_get_timeout_maps_to_bedrock_408(monkeypatch): + handler = BedrockVideoGeneration() + + class _TimingOutAsyncClient: + async def get(self, **kwargs): + raise httpx.ReadTimeout("timed out") + + monkeypatch.setattr( + "litellm.llms.custom_httpx.http_handler.get_async_httpx_client", + lambda llm_provider=None, params=None: _TimingOutAsyncClient(), + ) + with pytest.raises(BedrockError) as excinfo: + asyncio.run(handler._async_get(Mock(url="https://example.com/async-invoke/arn", headers={}), timeout=1.0)) + assert excinfo.value.status_code == 408 + + +def test_handler_video_content_missing_s3_uri_redacts_invocation(monkeypatch): + """The no-S3-output ValueError must not leak invocationArn (account id); the raw + payload goes to debug logs and the raise names only the observed key names.""" + handler = BedrockVideoGeneration() + from litellm.types.videos.utils import encode_video_id_with_provider + + video_id = encode_video_id_with_provider(TEST_ARN, "bedrock", TEST_MODEL) + monkeypatch.setattr( + handler, + "_status_request_parts", + lambda arn, params, api_base, api_key=None: ( + "https://example.com/async-invoke/arn", + Mock(url="https://example.com/async-invoke/arn", headers={}), + "us-east-1", + ), + ) + resp = httpx.Response( + 200, + json={ + "invocationArn": TEST_ARN, + "status": "Completed", + "outputDataConfig": {"s3OutputDataConfig": {}}, + }, + ) + monkeypatch.setattr(handler, "_sync_get", lambda prepped, timeout=None: resp) + with pytest.raises(ValueError, match="No S3 output location") as excinfo: + handler.video_content(video_id=video_id, litellm_params={}) + message = str(excinfo.value) + assert "123456789012" not in message + assert "invocationArn" not in message + assert "s3OutputDataConfig" in message # observed key names are still reported + + +def test_download_s3_object_no_credentials_maps_to_502(monkeypatch): + """NoCredentialsError (a BotoCoreError) must map to BedrockError 502, not escape raw.""" + from botocore.exceptions import NoCredentialsError + + handler = BedrockVideoGeneration() + raw: dict = {"outputDataConfig": {"s3OutputDataConfig": {"s3Uri": "s3://bucket/out/"}}} + + def _raise(bucket, key): + raise NoCredentialsError() + + _patch_s3_download(monkeypatch, handler, _raise) + with pytest.raises(BedrockError) as excinfo: + handler._download_s3_object("bucket", ["out/output.mp4"], {}, raw, region_default="us-east-1") + assert excinfo.value.status_code == 502 + assert "Failed to download Nova Reel output from S3" in str(excinfo.value.message) + assert "NoCredentialsError" in str(excinfo.value.message) + + +def test_download_s3_object_endpoint_connection_error_maps_to_502(monkeypatch): + from botocore.exceptions import EndpointConnectionError + + handler = BedrockVideoGeneration() + raw: dict = {"outputDataConfig": {"s3OutputDataConfig": {"s3Uri": "s3://bucket/out/"}}} + + def _raise(bucket, key): + raise EndpointConnectionError(endpoint_url="https://s3.us-east-1.amazonaws.com/bucket/out/output.mp4") + + _patch_s3_download(monkeypatch, handler, _raise) + with pytest.raises(BedrockError) as excinfo: + handler._download_s3_object("bucket", ["out/output.mp4"], {}, raw, region_default="us-east-1") + assert excinfo.value.status_code == 502 + assert "EndpointConnectionError" in str(excinfo.value.message) + + +def test_download_s3_object_falls_back_to_second_candidate_key(monkeypatch): + """A ClientError on the first candidate key falls through to the second.""" + from botocore.exceptions import ClientError + + handler = BedrockVideoGeneration() + raw: dict = {"outputDataConfig": {"s3OutputDataConfig": {"s3Uri": "s3://bucket/out/"}}} + attempted: list[str] = [] + + def _first_key_missing(bucket, key): + if key == "out/abc123-def456/output.mp4": + raise ClientError( + {"Error": {"Code": "NoSuchKey", "Message": "The specified key does not exist."}}, + "GetObject", + ) + return {"Body": io.BytesIO(b"mp4-from-second-key")} + + _, clients, attempted = _patch_s3_download(monkeypatch, handler, _first_key_missing) + content = handler._download_s3_object( + "bucket", + ["out/abc123-def456/output.mp4", "out/output.mp4"], + {}, + raw, + region_default="us-east-1", + ) + assert content == b"mp4-from-second-key" + assert attempted == ["out/abc123-def456/output.mp4", "out/output.mp4"] + + +def test_main_layer_bedrock_status_and_content_pass_default_timeout(monkeypatch): + """The bedrock branches of litellm.video_status / litellm.video_content must thread + a real timeout into the handler (never None), like the create branch already does.""" + from litellm.llms.bedrock.videos.handler import BedrockVideoGeneration as _Handler + from litellm.types.videos.utils import encode_video_id_with_provider + from litellm.videos import main as videos_main + + status_kwargs: dict = {} + content_kwargs: dict = {} + + def fake_video_status(self, **kwargs): + status_kwargs.update(kwargs) + return Mock() + + def fake_video_content(self, **kwargs): + content_kwargs.update(kwargs) + return b"mp4-bytes" + + monkeypatch.setattr(_Handler, "video_status", fake_video_status) + monkeypatch.setattr(_Handler, "video_content", fake_video_content) + + video_id = encode_video_id_with_provider(TEST_ARN, "bedrock", TEST_MODEL) + status_result = videos_main.video_status(video_id=video_id, custom_llm_provider="bedrock") + content_result = videos_main.video_content(video_id=video_id, custom_llm_provider="bedrock") + + assert status_result is not None + assert content_result == b"mp4-bytes" + # video_status signature default (600s) flows through as-is; video_content's + # None default is replaced by the layer DEFAULT_REQUEST_TIMEOUT. Never None. + assert status_kwargs["timeout"] == 600 + assert content_kwargs["timeout"] == videos_main.DEFAULT_REQUEST_TIMEOUT + assert status_kwargs["timeout"] is not None + assert content_kwargs["timeout"] is not None + + # Explicit timeout threads through both branches unchanged. + videos_main.video_status(video_id=video_id, custom_llm_provider="bedrock", timeout=33.5) + videos_main.video_content(video_id=video_id, custom_llm_provider="bedrock", timeout=44.5) + assert status_kwargs["timeout"] == 33.5 + assert content_kwargs["timeout"] == 44.5 + + +def test_main_layer_bedrock_create_dispatch_threads_params(monkeypatch): + """The create branch must merge the aws_* auth params riding on litellm_params into + the handler optional params and thread timeout/api_key/avideo_generation through.""" + from litellm.llms.bedrock.videos.handler import BedrockVideoGeneration as _Handler + from litellm.videos import main as videos_main + + seen: dict = {} + + def fake_generation(self, **kwargs): + seen.update(kwargs) + return Mock() + + monkeypatch.setattr(_Handler, "video_generation", fake_generation) + result = videos_main.video_generation( + prompt="waves at sunset", + model="bedrock/amazon.nova-reel-v1:0", + output_s3_uri="s3://bucket/out/", + aws_region_name="eu-central-1", + timeout=11.5, + api_key="sigv4-key", + ) + assert result is not None + assert seen["timeout"] == 11.5 + assert seen["api_key"] == "sigv4-key" + assert seen["avideo_generation"] is False + optional_params: Final[dict] = seen["optional_params"] + assert optional_params["output_s3_uri"] == "s3://bucket/out/" + # aws_* auth params ride on litellm_params and are merged in by the dispatch. + assert optional_params["aws_region_name"] == "eu-central-1" + # The real litellm_params object (not a copy) reaches the handler. + assert isinstance(seen["litellm_params"], videos_main.GenericLiteLLMParams) + + +def test_dispatch_functions_forward_kwargs_verbatim(monkeypatch): + """The three dispatch shims forward their arguments verbatim to the handler.""" + from litellm.llms.bedrock.videos.dispatch import ( + dispatch_bedrock_video_content, + dispatch_bedrock_video_generation, + dispatch_bedrock_video_status, + ) + from litellm.llms.bedrock.videos.handler import BedrockVideoGeneration as _Handler + + seen: dict[str, dict] = {} + + def fake_generation(self, **kwargs): + seen["generation"] = kwargs + return Mock() + + def fake_status(self, **kwargs): + seen["status"] = kwargs + return Mock() + + def fake_content(self, **kwargs): + seen["content"] = kwargs + return b"mp4-bytes" + + monkeypatch.setattr(_Handler, "video_generation", fake_generation) + monkeypatch.setattr(_Handler, "video_status", fake_status) + monkeypatch.setattr(_Handler, "video_content", fake_content) + + litellm_params: Final = GenericLiteLLMParams(api_base="https://examplebedrock", api_key="k1") + + dispatch_bedrock_video_generation( + model="amazon.nova-reel-v1:0", + prompt="waves", + video_generation_request_params={"output_s3_uri": "s3://bucket/out/"}, + litellm_params=litellm_params, + logging_obj=None, + timeout=30.0, + is_async=True, + client="fake-client", + extra_headers={"X-Test": "1"}, + api_key="sigv4-key", + ) + generation_kwargs: Final[dict] = seen["generation"] + assert generation_kwargs["model"] == "amazon.nova-reel-v1:0" + assert generation_kwargs["prompt"] == "waves" + assert generation_kwargs["optional_params"] == {"output_s3_uri": "s3://bucket/out/"} + assert generation_kwargs["timeout"] == 30.0 + assert generation_kwargs["avideo_generation"] is True + assert generation_kwargs["client"] == "fake-client" + assert generation_kwargs["extra_headers"] == {"X-Test": "1"} + assert generation_kwargs["api_key"] == "sigv4-key" + assert generation_kwargs["api_base"] == "https://examplebedrock" + assert generation_kwargs["litellm_params"] is litellm_params + + dispatch_bedrock_video_status( + video_id="vid-1", + litellm_params=litellm_params, + logging_obj=None, + api_base="https://examplebedrock", + api_key="sigv4-key", + astatus=False, + timeout=600, + ) + status_kwargs: Final[dict] = seen["status"] + assert status_kwargs == { + "video_id": "vid-1", + "litellm_params": litellm_params, + "logging_obj": None, + "api_base": "https://examplebedrock", + "api_key": "sigv4-key", + "astatus": False, + "timeout": 600, + } + + content_result = dispatch_bedrock_video_content( + video_id="vid-1", + litellm_params=litellm_params, + logging_obj=None, + api_base="https://examplebedrock", + api_key="sigv4-key", + timeout=60.0, + ) + assert content_result == b"mp4-bytes" + assert seen["content"] == { + "video_id": "vid-1", + "litellm_params": litellm_params, + "logging_obj": None, + "api_base": "https://examplebedrock", + "api_key": "sigv4-key", + "timeout": 60.0, + } + + +################################################# +# _to_epoch iso8601 timestamps +################################################# + + +def test_to_epoch_iso8601_with_z_suffix(): + """Real GetAsyncInvoke payloads carry iso8601 submitTime/endTime (Smithy timestampFormat).""" + from litellm.llms.bedrock.videos.transformation import _to_epoch + + expected: Final = int(datetime(2026, 1, 15, 10, 30, 0, tzinfo=timezone.utc).timestamp()) + assert _to_epoch("2026-01-15T10:30:00Z") == expected + + +def test_to_epoch_numeric_epoch_still_works(): + from litellm.llms.bedrock.videos.transformation import _to_epoch + + assert _to_epoch(1758000000.75) == 1758000000 + assert _to_epoch("1758000000.75") == 1758000000 + + +def test_to_epoch_garbage_warns_and_returns_none(monkeypatch): + from litellm.llms.bedrock.videos.transformation import _to_epoch + + logger = Mock() + monkeypatch.setattr("litellm.llms.bedrock.videos.transformation.verbose_logger", logger) + assert _to_epoch("not-a-timestamp") is None + logger.warning.assert_called_once() + + +def test_transform_status_response_iso8601_times_become_epochs(): + config = _make_config() + resp = httpx.Response( + 200, + json={ + "invocationArn": TEST_ARN, + "status": "Completed", + "submitTime": "2026-01-15T10:30:00Z", + "endTime": "2026-01-15T10:31:00Z", + "outputDataConfig": {"s3OutputDataConfig": {"s3Uri": "s3://bucket/out/"}}, + }, + ) + video = config.transform_video_status_retrieve_response(raw_response=resp, logging_obj=None, model=TEST_MODEL) + submit: Final = int(datetime(2026, 1, 15, 10, 30, 0, tzinfo=timezone.utc).timestamp()) + end: Final = int(datetime(2026, 1, 15, 10, 31, 0, tzinfo=timezone.utc).timestamp()) + assert video.created_at == submit + assert video.completed_at == end + + +################################################# +# empty prompt guard +################################################# + + +def test_transform_create_request_blank_prompt_raises(): + with pytest.raises(BedrockError) as excinfo: + _create_request(prompt=" ") + assert excinfo.value.status_code == 400 + assert "prompt is required" in str(excinfo.value.message) + + +################################################# +# s3:// scheme validation +################################################# + + +def test_transform_create_request_non_s3_output_uri_raises(): + with pytest.raises(BedrockError) as excinfo: + _create_request({"output_s3_uri": "https://bucket/out/"}) + assert excinfo.value.status_code == 400 + assert "s3://" in str(excinfo.value.message) + + +def test_parse_s3_uri_rejects_non_s3_scheme(): + from litellm.llms.bedrock.videos.handler import _parse_s3_uri + + with pytest.raises(BedrockError) as excinfo: + _parse_s3_uri("https://example.com/bucket/key") + assert excinfo.value.status_code == 400 + assert "s3://" in str(excinfo.value.message) + + +################################################# +# MULTI_SHOT body construction +################################################# + + +def test_transform_multi_shot_automated_uses_automated_params(): + body = _create_request({"output_s3_uri": "s3://bucket/out/", "taskType": "MULTI_SHOT_AUTOMATED"}) + model_input = body["modelInput"] + assert model_input["taskType"] == "MULTI_SHOT_AUTOMATED" + assert model_input["multiShotAutomatedParams"] == {"text": "A drone shot over the ocean"} + assert "textToVideoParams" not in model_input + # durationSeconds stays on videoGenerationConfig for automated multi-shot. + assert model_input["videoGenerationConfig"]["durationSeconds"] == 6 + + +def test_transform_multi_shot_automated_preserves_explicit_params(): + body = _create_request( + { + "output_s3_uri": "s3://bucket/out/", + "taskType": "MULTI_SHOT_AUTOMATED", + "multiShotAutomatedParams": {"text": "custom shot plan"}, + } + ) + assert body["modelInput"]["multiShotAutomatedParams"] == {"text": "custom shot plan"} + + +def test_transform_multi_shot_automated_with_image_raises(): + with pytest.raises(BedrockError) as excinfo: + _create_request({"output_s3_uri": "s3://bucket/out/", "taskType": "MULTI_SHOT_AUTOMATED", "image": PNG_BYTES}) + assert excinfo.value.status_code == 400 + assert "does not accept input images" in str(excinfo.value.message) + + +def test_transform_multi_shot_manual_without_params_raises(): + with pytest.raises(BedrockError) as excinfo: + _create_request({"output_s3_uri": "s3://bucket/out/", "taskType": "MULTI_SHOT_MANUAL"}) + assert excinfo.value.status_code == 400 + assert "multiShotManualParams" in str(excinfo.value.message) + + +def test_transform_multi_shot_manual_body_omits_duration_seconds(): + body = _create_request( + { + "output_s3_uri": "s3://bucket/out/", + "taskType": "MULTI_SHOT_MANUAL", + "multiShotManualParams": {"shots": [{"text": "shot one", "durationSeconds": 6}]}, + } + ) + model_input = body["modelInput"] + assert model_input["multiShotManualParams"] == {"shots": [{"text": "shot one", "durationSeconds": 6}]} + assert "textToVideoParams" not in model_input + # Durations live per shot for MANUAL; no top-level durationSeconds. + assert "durationSeconds" not in model_input["videoGenerationConfig"] + + +################################################# +# kmsKeyId/bucketOwner plumbing +################################################# + + +def test_kms_and_bucket_owner_forwarded_to_s3_output_config(): + body = _create_request( + { + "output_s3_uri": "s3://bucket/out/", + "kmsKeyId": "arn:aws:kms:us-east-1:111122223333:key/k", + "bucketOwner": "111122223333", + } + ) + s3_config: Final = body["outputDataConfig"]["s3OutputDataConfig"] + assert s3_config["s3Uri"] == "s3://bucket/out/" + assert s3_config["kmsKeyId"] == "arn:aws:kms:us-east-1:111122223333:key/k" + assert s3_config["bucketOwner"] == "111122223333" + assert "kmsKeyId" not in body["modelInput"] + assert "bucketOwner" not in body["modelInput"] + + +def test_kms_and_bucket_owner_snake_case_aliases_accepted(): + body = _create_request( + { + "output_s3_uri": "s3://bucket/out/", + "output_s3_kms_key_id": "key-id-1", + "output_s3_bucket_owner": "222233334444", + } + ) + s3_config: Final = body["outputDataConfig"]["s3OutputDataConfig"] + assert s3_config["kmsKeyId"] == "key-id-1" + assert s3_config["bucketOwner"] == "222233334444" + assert "output_s3_kms_key_id" not in body["modelInput"] + assert "output_s3_bucket_owner" not in body["modelInput"] + + +################################################# +# non-JSON 2xx responses +################################################# + + +def test_transform_create_response_non_json_maps_to_bedrock_502(): + config = _make_config() + resp = httpx.Response(200, content=b"gateway error") + with pytest.raises(BedrockError) as excinfo: + config.transform_video_create_response(model=TEST_MODEL, raw_response=resp, logging_obj=None) + assert excinfo.value.status_code == 502 + assert "non-JSON" in str(excinfo.value.message) + + +def test_transform_status_response_non_json_maps_to_bedrock_502(): + config = _make_config() + resp = httpx.Response(200, content=b"not json at all") + with pytest.raises(BedrockError) as excinfo: + config.transform_video_status_retrieve_response(raw_response=resp, logging_obj=None, model=TEST_MODEL) + assert excinfo.value.status_code == 502 + assert "non-JSON" in str(excinfo.value.message) + + +################################################# +# unsupported operations raise a 400-class error +################################################# + + +def test_unsupported_list_operation_raises_400_class_bedrock_error(): + """litellm.exception_type maps BedrockError(status_code=400) to BadRequestError; + a plain NotImplementedError would surface as APIConnectionError 500.""" + config = _make_config() + with pytest.raises(BedrockError) as excinfo: + config.transform_video_list_request( + api_base="", + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + assert excinfo.value.status_code == 400 + assert "not supported" in str(excinfo.value.message) + + +def test_unsupported_remix_operation_raises_400_class_bedrock_error(): + config = _make_config() + with pytest.raises(BedrockError) as excinfo: + config.transform_video_remix_request( + video_id="vid", + prompt="p", + api_base="", + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + assert excinfo.value.status_code == 400 + + +################################################# +# unsupported character/edit/extension operations raise 400-class errors +################################################# + + +def test_unsupported_create_character_operation_raises_400_class_bedrock_error(): + config = _make_config() + with pytest.raises(BedrockError) as excinfo: + config.transform_video_create_character_request( + name="hero", + video=b"video-bytes", + api_base="", + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + assert excinfo.value.status_code == 400 + assert "create character" in str(excinfo.value.message) + + +def test_unsupported_create_character_response_raises_400_class_bedrock_error(): + config = _make_config() + with pytest.raises(BedrockError) as excinfo: + config.transform_video_create_character_response(raw_response=Mock(), logging_obj=None) + assert excinfo.value.status_code == 400 + + +def test_unsupported_get_character_operation_raises_400_class_bedrock_error(): + config = _make_config() + with pytest.raises(BedrockError) as excinfo: + config.transform_video_get_character_request( + character_id="char-1", + api_base="", + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + assert excinfo.value.status_code == 400 + assert "get character" in str(excinfo.value.message) + + +def test_unsupported_get_character_response_raises_400_class_bedrock_error(): + config = _make_config() + with pytest.raises(BedrockError) as excinfo: + config.transform_video_get_character_response(raw_response=Mock(), logging_obj=None) + assert excinfo.value.status_code == 400 + + +def test_unsupported_edit_operation_raises_400_class_bedrock_error(): + config = _make_config() + with pytest.raises(BedrockError) as excinfo: + config.transform_video_edit_request( + prompt="brighter", + video_id="vid", + api_base="", + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + assert excinfo.value.status_code == 400 + assert "video edit" in str(excinfo.value.message) + + +def test_unsupported_edit_response_raises_400_class_bedrock_error(): + config = _make_config() + with pytest.raises(BedrockError) as excinfo: + config.transform_video_edit_response(raw_response=Mock(), logging_obj=None) + assert excinfo.value.status_code == 400 + + +def test_unsupported_extension_operation_raises_400_class_bedrock_error(): + config = _make_config() + with pytest.raises(BedrockError) as excinfo: + config.transform_video_extension_request( + prompt="longer", + video_id="vid", + seconds="6", + api_base="", + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + assert excinfo.value.status_code == 400 + assert "video extension" in str(excinfo.value.message) + + +def test_unsupported_extension_response_raises_400_class_bedrock_error(): + config = _make_config() + with pytest.raises(BedrockError) as excinfo: + config.transform_video_extension_response(raw_response=Mock(), logging_obj=None) + assert excinfo.value.status_code == 400 + + +def test_unsupported_operations_raise_400_without_stubbing(): + """Through the litellm video layer, unsupported bedrock video operations must + surface BadRequestError (400-class) with the unsupported-operation message, + without stubbing get_complete_url: the shared video handler resolves the URL + before running the config transforms, so the config's get_complete_url raises + the 400 itself. Valid operations (create/status/content) never reach the + shared handler; videos/main.py routes bedrock to + litellm.llms.bedrock.videos.dispatch first.""" + with pytest.raises(litellm.BadRequestError) as edit_exc: + asyncio.run( + litellm.avideo_edit( + video_id="some-video-id", + prompt="brighter", + custom_llm_provider="bedrock", + ) + ) + assert edit_exc.value.status_code == 400 + # The generic edit error wrapper reads .text off the exception (empty for a + # synthesized BedrockError), so the config-level message is asserted in + # test_get_complete_url_raises_unsupported_operation_400; the 400-class + # mapping is the contract here. + with pytest.raises(litellm.BadRequestError) as character_exc: + asyncio.run( + litellm.avideo_create_character( + name="hero", + video=b"video-bytes", + custom_llm_provider="bedrock", + ) + ) + assert character_exc.value.status_code == 400 + assert "bedrock video supports create, status and content only" in str(character_exc.value) + + +################################################# +# text-mode file guard +################################################# + + +def test_input_reference_text_mode_file_raises_value_error(): + with pytest.raises(BedrockError) as excinfo: + _create_request({"output_s3_uri": "s3://bucket/out/", "input_reference": io.StringIO("not binary")}) + assert excinfo.value.status_code == 400 + assert "binary mode" in str(excinfo.value.message) + + +################################################# +# clientRequestToken falls back to litellm_call_id +################################################# + + +def test_client_request_token_falls_back_to_litellm_call_id(): + litellm_params = GenericLiteLLMParams() + litellm_params.litellm_call_id = "call/abc_123" # extra field set by the @client decorator + body = _create_request(litellm_params=litellm_params) + assert body["clientRequestToken"] == "call-abc-123" + + +def test_handler_create_threads_litellm_call_id_from_mapping_into_token(monkeypatch): + """A Mapping litellm_params carrying only litellm_call_id (no metadata) must reach + the signed POST body through _as_generic_litellm_params.""" + handler = BedrockVideoGeneration() + monkeypatch.setattr( + BedrockVideoGeneration, + "_get_boto_credentials_from_optional_params", + lambda self, params, model=None, bearer_token=None: _FakeCredentialsInfo(), + ) + bodies: list[bytes] = [] + + class _RecordingClient: + def post(self, **kwargs): + bodies.append(kwargs["content"]) + return httpx.Response( + 200, + json={"invocationArn": TEST_ARN}, + request=httpx.Request("POST", "https://bedrock-runtime.us-east-1.amazonaws.com/async-invoke"), + ) + + monkeypatch.setattr("litellm.llms.custom_httpx.http_handler._get_httpx_client", lambda: _RecordingClient()) + video = handler.video_generation( + model="bedrock/amazon.nova-reel-v1:0", + prompt="waves at sunset", + optional_params={"output_s3_uri": "s3://bucket/out/"}, + logging_obj=None, + timeout=5.0, + avideo_generation=False, + litellm_params={"litellm_call_id": "call/xyz_789"}, + ) + assert video.status == "processing" + parsed: Final = json.loads(bodies[0]) + assert parsed["clientRequestToken"] == "call-xyz-789" + + +################################################# +# S3 download error mapping per AWS error code +################################################# + + +def test_download_s3_object_access_denied_maps_to_403_without_second_get(monkeypatch): + from botocore.exceptions import ClientError + + def _deny(bucket, key): + raise ClientError( + {"Error": {"Code": "AccessDenied", "Message": "Access Denied"}}, + "GetObject", + ) + + handler = BedrockVideoGeneration() + raw: dict = {"outputDataConfig": {"s3OutputDataConfig": {"s3Uri": "s3://bucket/out/"}}} + _, clients, attempted = _patch_s3_download(monkeypatch, handler, _deny) + with pytest.raises(BedrockError) as excinfo: + handler._download_s3_object( + "bucket", + ["out/abc/output.mp4", "out/output.mp4"], + {}, + raw, + region_default="us-east-1", + ) + assert excinfo.value.status_code == 403 + # Aborts immediately: no second candidate get_object. + assert attempted == ["out/abc/output.mp4"] + assert "s3://bucket/out/" in str(excinfo.value.message) + assert "AccessDenied" in str(excinfo.value.message) + assert clients[0].closed is True + + +def test_download_s3_object_500_class_client_error_maps_to_502(monkeypatch): + from botocore.exceptions import ClientError + + def _internal_error(bucket, key): + raise ClientError( + { + "Error": {"Code": "InternalError", "Message": "We encountered an internal error."}, + "ResponseMetadata": {"HTTPStatusCode": 500}, + }, + "GetObject", + ) + + handler = BedrockVideoGeneration() + raw: dict = {"outputDataConfig": {"s3OutputDataConfig": {"s3Uri": "s3://bucket/out/"}}} + _, _, attempted = _patch_s3_download(monkeypatch, handler, _internal_error) + with pytest.raises(BedrockError) as excinfo: + handler._download_s3_object("bucket", ["out/output.mp4"], {}, raw, region_default="us-east-1") + assert excinfo.value.status_code == 502 + # The AWS error code is preserved in the message. + assert "InternalError" in str(excinfo.value.message) + assert attempted == ["out/output.mp4"] + + +def test_download_s3_object_http_404_client_error_falls_through(monkeypatch): + """A 404 without a NoSuchKey code still falls through to the next candidate.""" + from botocore.exceptions import ClientError + + def _missing_via_http_status(bucket, key): + if key.endswith("abc123-def456/output.mp4"): + raise ClientError( + { + "Error": {"Code": "NoSuchKey", "Message": "The specified key does not exist."}, + "ResponseMetadata": {"HTTPStatusCode": 404}, + }, + "GetObject", + ) + return {"Body": io.BytesIO(b"mp4-after-404")} + + handler = BedrockVideoGeneration() + raw: dict = {"outputDataConfig": {"s3OutputDataConfig": {"s3Uri": "s3://bucket/out/"}}} + _, _, attempted = _patch_s3_download(monkeypatch, handler, _missing_via_http_status) + content = handler._download_s3_object( + "bucket", + ["out/abc123-def456/output.mp4", "out/output.mp4"], + {}, + raw, + region_default="us-east-1", + ) + assert content == b"mp4-after-404" + assert len(attempted) == 2 + + +################################################# +# bearer-only S3 path +################################################# + + +def test_download_s3_object_bearer_without_sigv4_credentials_maps_to_400(monkeypatch): + """A bedrock bearer token covers only the async-invoke API; S3 needs SigV4.""" + handler = BedrockVideoGeneration() + raw: dict = {"outputDataConfig": {"s3OutputDataConfig": {"s3Uri": "s3://bucket/out/"}}} + _, clients, attempted = _patch_s3_download(monkeypatch, handler, lambda bucket, key: {"Body": io.BytesIO(b"x")}) + with pytest.raises(BedrockError) as excinfo: + handler._download_s3_object( + "bucket", + ["out/output.mp4"], + {}, + raw, + region_default="us-east-1", + api_key="some-bearer-token", + ) + assert excinfo.value.status_code == 400 + assert "SigV4" in str(excinfo.value.message) + assert "bearer" in str(excinfo.value.message) + # Failed fast: no S3 client built, no get_object attempted. + assert clients == [] + assert attempted == [] + + +def test_download_s3_object_bearer_with_failing_credential_chain_maps_to_400(monkeypatch): + """A bearer token plus an unresolvable SigV4 chain (NoCredentialsError from + _load_credentials itself) must surface the bearer guidance 400, not a raw + BotoCoreError/500.""" + from botocore.exceptions import NoCredentialsError + + handler = BedrockVideoGeneration() + raw: dict = {"outputDataConfig": {"s3OutputDataConfig": {"s3Uri": "s3://bucket/out/"}}} + + def _raise_credentials(optional_params, aws_region_name=None, bearer_token=None): + raise NoCredentialsError() + + monkeypatch.setattr(handler, "_load_credentials", _raise_credentials) + with pytest.raises(BedrockError) as excinfo: + handler._download_s3_object( + "bucket", + ["out/output.mp4"], + {}, + raw, + region_default="us-east-1", + api_key="some-bearer-token", + ) + assert excinfo.value.status_code == 400 + assert "SigV4" in str(excinfo.value.message) + assert "bearer" in str(excinfo.value.message) + + +def test_download_s3_object_no_bearer_credential_chain_failure_propagates(monkeypatch): + """Without a bearer token the NoCredentialsError propagation is unchanged.""" + from botocore.exceptions import NoCredentialsError + + handler = BedrockVideoGeneration() + raw: dict = {"outputDataConfig": {"s3OutputDataConfig": {"s3Uri": "s3://bucket/out/"}}} + + def _raise_credentials(optional_params, aws_region_name=None, bearer_token=None): + raise NoCredentialsError() + + monkeypatch.setattr(handler, "_load_credentials", _raise_credentials) + with pytest.raises(NoCredentialsError): + handler._download_s3_object( + "bucket", + ["out/output.mp4"], + {}, + raw, + region_default="us-east-1", + ) + + +################################################# +# non-JSON status responses through the handler +################################################# + + +def test_map_status_response_non_json_maps_to_bedrock_502(): + """A 200 with a non-JSON body must surface as BedrockError 502 from the handler's + guarded parse (before the transform's own guard, which never runs).""" + handler = BedrockVideoGeneration() + resp = httpx.Response(200, content=b"gateway error") + with pytest.raises(BedrockError) as excinfo: + handler._map_status_response(resp, TEST_MODEL, "video-id", None) + assert excinfo.value.status_code == 502 + assert "non-JSON response from Bedrock status endpoint" in str(excinfo.value.message) + + +def test_video_status_non_json_body_maps_to_bedrock_502(monkeypatch): + """End-to-end through video_status: non-JSON 200 -> 502, never a raw 500.""" + handler = BedrockVideoGeneration() + from litellm.types.videos.utils import encode_video_id_with_provider + + video_id = encode_video_id_with_provider(TEST_ARN, "bedrock", TEST_MODEL) + monkeypatch.setattr( + handler, + "_status_request_parts", + lambda arn, params, api_base, api_key=None: ( + "https://example.com/async-invoke/arn", + Mock(url="https://example.com/async-invoke/arn", headers={}), + "us-east-1", + ), + ) + resp = httpx.Response(200, content=b"not json at all") + monkeypatch.setattr(handler, "_sync_get", lambda prepped, timeout=None: resp) + with pytest.raises(BedrockError) as excinfo: + handler.video_status(video_id=video_id, litellm_params={}) + assert excinfo.value.status_code == 502 + assert "non-JSON response from Bedrock status endpoint" in str(excinfo.value.message) + + +################################################# +# S3 client timeouts + resource closes +################################################# + + +def test_download_s3_object_builds_client_with_config_timeouts(monkeypatch): + handler = BedrockVideoGeneration() + raw: dict = {"outputDataConfig": {"s3OutputDataConfig": {"s3Uri": "s3://bucket/out/"}}} + _, clients, _ = _patch_s3_download(monkeypatch, handler, lambda bucket, key: {"Body": io.BytesIO(b"x")}) + + # Call timeout threads into read_timeout; connect stays at the 5s default. + handler._download_s3_object("bucket", ["out/output.mp4"], {}, raw, region_default="us-east-1", timeout=12.5) + assert clients[0].config.read_timeout == 12.5 + assert clients[0].config.connect_timeout == 5 + + # No timeout: sane defaults. + handler._download_s3_object("bucket", ["out/output.mp4"], {}, raw, region_default="us-east-1") + assert clients[1].config.read_timeout == 60.0 + assert clients[1].config.connect_timeout == 5 + + # A small call timeout caps the connect timeout too. + handler._download_s3_object("bucket", ["out/output.mp4"], {}, raw, region_default="us-east-1", timeout=2.0) + assert clients[2].config.read_timeout == 2.0 + assert clients[2].config.connect_timeout == 2.0 + + +def test_download_s3_object_closes_body_and_client_on_success(monkeypatch): + handler = BedrockVideoGeneration() + raw: dict = {"outputDataConfig": {"s3OutputDataConfig": {"s3Uri": "s3://bucket/out/"}}} + body = _TrackingBody(b"mp4-bytes") + _, clients, _ = _patch_s3_download(monkeypatch, handler, lambda bucket, key: {"Body": body}) + content = handler._download_s3_object("bucket", ["out/output.mp4"], {}, raw, region_default="us-east-1") + assert content == b"mp4-bytes" + assert body.close_calls == 1 + assert clients[0].closed is True + + +def test_download_s3_object_closes_client_on_404(monkeypatch): + from botocore.exceptions import ClientError + + def _raise(bucket, key): + raise ClientError( + {"Error": {"Code": "NoSuchKey", "Message": "The specified key does not exist."}}, + "GetObject", + ) + + handler = BedrockVideoGeneration() + raw: dict = {"outputDataConfig": {"s3OutputDataConfig": {"s3Uri": "s3://bucket/out/"}}} + _, clients, _ = _patch_s3_download(monkeypatch, handler, _raise) + with pytest.raises(BedrockError): + handler._download_s3_object("bucket", ["out/output.mp4"], {}, raw, region_default="us-east-1") + assert clients[0].closed is True + + +################################################# +# video id decoded once per status call +################################################# + + +def test_video_status_decodes_video_id_once(monkeypatch): + import litellm.llms.bedrock.videos.handler as handler_module + from litellm.types.videos.utils import encode_video_id_with_provider + + real_decode = handler_module.decode_video_id_with_provider + decode_calls: list[str] = [] + + def counting_decode(video_id): + decode_calls.append(video_id) + return real_decode(video_id) + + monkeypatch.setattr(handler_module, "decode_video_id_with_provider", counting_decode) + + handler = BedrockVideoGeneration() + video_id = encode_video_id_with_provider(TEST_ARN, "bedrock", TEST_MODEL) + monkeypatch.setattr( + handler, + "_status_request_parts", + lambda arn, params, api_base, api_key=None: ( + "https://example.com/async-invoke/arn", + Mock(url="https://example.com/async-invoke/arn", headers={}), + "us-east-1", + ), + ) + resp = httpx.Response(200, json={"invocationArn": TEST_ARN, "status": "InProgress"}) + monkeypatch.setattr(handler, "_sync_get", lambda prepped, timeout=None: resp) + handler.video_status(video_id=video_id, litellm_params={}) + assert decode_calls == [video_id] + + +################################################# +# private async status arm +################################################# + + +def test_video_status_async_dispatch_uses_private_async_arm(monkeypatch): + handler = BedrockVideoGeneration() + from litellm.types.videos.utils import encode_video_id_with_provider + + video_id = encode_video_id_with_provider(TEST_ARN, "bedrock", TEST_MODEL) + monkeypatch.setattr( + handler, + "_status_request_parts", + lambda arn, params, api_base, api_key=None: ( + "https://example.com/async-invoke/arn", + Mock(url="https://example.com/async-invoke/arn", headers={}), + "us-east-1", + ), + ) + resp = httpx.Response(200, json={"invocationArn": TEST_ARN, "status": "InProgress", "submitTime": 1758000000.0}) + + async def fake_async_get(prepped, timeout=None): + return resp + + monkeypatch.setattr(handler, "_async_get", fake_async_get) + assert not hasattr(BedrockVideoGeneration, "async_video_status") + video = asyncio.run(handler.video_status(video_id=video_id, litellm_params={}, astatus=True)) + assert video.status == "processing" + + +################################################# +# api_key falls back to litellm_params on the bedrock branches +################################################# + + +def test_bedrock_branches_fall_back_to_litellm_params_api_key(monkeypatch): + """kwargs without api_key but litellm_params.api_key set: the handler must receive it.""" + from litellm.llms.bedrock.videos.handler import BedrockVideoGeneration as _Handler + from litellm.types.videos.utils import encode_video_id_with_provider + from litellm.videos import main as videos_main + + real_params_cls = videos_main.GenericLiteLLMParams + + class _InjectsApiKey(real_params_cls): + def __init__(self, **kw): + kw.setdefault("api_key", "sigv4-key-from-litellm-params") + super().__init__(**kw) + + seen: dict[str, dict] = {} + + def fake_generation(self, **kwargs): + seen["create"] = kwargs + return Mock() + + def fake_status(self, **kwargs): + seen["status"] = kwargs + return Mock() + + def fake_content(self, **kwargs): + seen["content"] = kwargs + return b"mp4-bytes" + + monkeypatch.setattr(videos_main, "GenericLiteLLMParams", _InjectsApiKey) + monkeypatch.setattr(_Handler, "video_generation", fake_generation) + monkeypatch.setattr(_Handler, "video_status", fake_status) + monkeypatch.setattr(_Handler, "video_content", fake_content) + + videos_main.video_generation(prompt="waves", model="bedrock/amazon.nova-reel-v1:0") + video_id = encode_video_id_with_provider(TEST_ARN, "bedrock", TEST_MODEL) + videos_main.video_status(video_id=video_id, custom_llm_provider="bedrock") + videos_main.video_content(video_id=video_id, custom_llm_provider="bedrock") + + assert seen["create"]["api_key"] == "sigv4-key-from-litellm-params" + assert seen["status"]["api_key"] == "sigv4-key-from-litellm-params" + assert seen["content"]["api_key"] == "sigv4-key-from-litellm-params" + + +################################################# +# proxy contract: user-input validation maps to 400-class errors +################################################# + + +def test_avideo_generation_missing_output_s3_uri_maps_to_bad_request(monkeypatch): + """Through the litellm video layer, the missing-output_s3_uri validation error must + surface as litellm.BadRequestError (400-class), never APIConnectionError/500.""" + monkeypatch.setattr( + BedrockVideoGeneration, + "_get_boto_credentials_from_optional_params", + lambda self, params, model=None, bearer_token=None: _FakeCredentialsInfo(), + ) + with pytest.raises(litellm.BadRequestError) as excinfo: + asyncio.run( + litellm.avideo_generation( + prompt="waves at sunset", + model="bedrock/amazon.nova-reel-v1:0", + ) + ) + assert excinfo.value.status_code == 400 + assert "output_s3_uri" in str(excinfo.value) + + +def test_avideo_content_not_complete_yet_maps_to_bad_request(monkeypatch): + """Through the litellm video layer, downloading an in-progress video must surface + as litellm.BadRequestError (400-class), never APIConnectionError/500.""" + from litellm.types.videos.utils import encode_video_id_with_provider + + video_id = encode_video_id_with_provider(TEST_ARN, "bedrock", TEST_MODEL) + monkeypatch.setattr( + BedrockVideoGeneration, + "_status_request_parts", + lambda self, arn, params, api_base, api_key=None: ( + "https://example.com/async-invoke/arn", + Mock(url="https://example.com/async-invoke/arn", headers={}), + "us-east-1", + ), + ) + resp = httpx.Response( + 200, + json={ + "invocationArn": TEST_ARN, + "status": "InProgress", + "outputDataConfig": {"s3OutputDataConfig": {"s3Uri": "s3://bucket/out/"}}, + }, + ) + monkeypatch.setattr(BedrockVideoGeneration, "_sync_get", lambda self, prepped, timeout=None: resp) + with pytest.raises(litellm.BadRequestError) as excinfo: + asyncio.run(litellm.avideo_content(video_id=video_id, custom_llm_provider="bedrock")) + assert excinfo.value.status_code == 400 + assert "not complete" in str(excinfo.value) + + +################################################# +# MULTI_SHOT_MANUAL spend: per-shot durations sum into usage +################################################# + + +def test_transform_multi_shot_manual_usage_sums_shot_durations(): + """3 shots x 6s must record usage.duration_seconds 18 (the billable total), not the + omitted top-level durationSeconds; the video cost calculator then prices 18s.""" + from litellm.llms.openai.cost_calculation import video_generation_cost + + body = _create_request( + { + "output_s3_uri": "s3://bucket/out/", + "taskType": "MULTI_SHOT_MANUAL", + "multiShotManualParams": { + "shots": [ + {"text": "shot one", "durationSeconds": 6}, + {"text": "shot two", "durationSeconds": 6}, + {"text": "shot three", "durationSeconds": 6}, + ] + }, + } + ) + config = _make_config() + resp = httpx.Response(200, json={"invocationArn": TEST_ARN}) + video = config.transform_video_create_response( + model="amazon.nova-reel-v1:1", + raw_response=resp, + logging_obj=None, + request_data=body, + ) + assert video.usage is not None + assert video.usage["duration_seconds"] == 18.0 + cost = video_generation_cost( + model="amazon.nova-reel-v1:1", + duration_seconds=video.usage["duration_seconds"], + custom_llm_provider="bedrock", + ) + assert cost == pytest.approx(18 * 0.08) + + +################################################# +# cross-region video ids resolve to the base-model deployment +################################################# + + +def test_cross_region_video_id_resolves_to_base_model_deployment(): + """An id encoded with us.amazon.nova-reel-v1:1 must resolve to a deployment whose + litellm_params.model is the base bedrock/amazon.nova-reel-v1:1, so status/content + dispatch through the router and pick up the deployment aws_* credentials.""" + from litellm.llms.bedrock.videos.transformation import BedrockNovaReelVideoConfig as _Config + from litellm.proxy.video_endpoints.endpoints import _resolve_model_name_from_decoded_model_id + from litellm.router import Router + from litellm.types.videos.utils import encode_video_id_with_provider + + router = Router( + model_list=[ + { + "model_name": "amazon.nova-reel-v1:1", + "litellm_params": {"model": "bedrock/amazon.nova-reel-v1:1"}, + } + ] + ) + invocation_arn = "arn:aws:bedrock:us-east-1:123456789012:async-invoke/xyz" + video_id = encode_video_id_with_provider(invocation_arn, "bedrock", "us.amazon.nova-reel-v1:1") + decoded = decode_video_id_with_provider(video_id) + assert decoded["model_id"] == "us.amazon.nova-reel-v1:1" + # The pre-fix behavior: a bare resolve on the cross-region id finds nothing. + assert router.resolve_model_name_from_model_id(decoded["model_id"]) is None + resolved = _resolve_model_name_from_decoded_model_id(router, decoded["model_id"], "bedrock") + assert resolved == "amazon.nova-reel-v1:1" + # Scoped to bedrock: the same id under another provider stays unresolved. + assert _resolve_model_name_from_decoded_model_id(router, decoded["model_id"], "vertex_ai") is None + # Exact (non cross-region) ids keep resolving through the unchanged first lookup. + assert _resolve_model_name_from_decoded_model_id(router, "amazon.nova-reel-v1:1", "bedrock") == ( + "amazon.nova-reel-v1:1" + ) + # The status transform still encodes the cross-region id verbatim. + assert _Config.extract_invocation_arn(video_id) == invocation_arn + + +################################################# +# create-path HTTP error mapping (sync + async) +################################################# + + +def test_handler_sync_create_http_status_error_maps_to_bedrock_error(monkeypatch): + """A non-2xx create response must map to BedrockError with the upstream status, + body and headers (raise_for_status path), not escape as httpx.HTTPStatusError.""" + handler = BedrockVideoGeneration() + monkeypatch.setattr( + BedrockVideoGeneration, + "_get_boto_credentials_from_optional_params", + lambda self, params, model=None, bearer_token=None: _FakeCredentialsInfo(), + ) + + class _FailingClient: + def post(self, **kwargs): + return httpx.Response( + 403, + json={"message": "access denied"}, + request=httpx.Request("POST", "https://bedrock-runtime.us-east-1.amazonaws.com/async-invoke"), + ) + + monkeypatch.setattr("litellm.llms.custom_httpx.http_handler._get_httpx_client", lambda: _FailingClient()) + with pytest.raises(BedrockError) as excinfo: + handler.video_generation( + model="bedrock/amazon.nova-reel-v1:0", + prompt="waves at sunset", + optional_params={"output_s3_uri": "s3://bucket/out/"}, + logging_obj=None, + timeout=5.0, + avideo_generation=False, + ) + assert excinfo.value.status_code == 403 + assert "access denied" in str(excinfo.value.message) + assert excinfo.value.headers is not None + + +def test_handler_async_create_http_status_error_maps_to_bedrock_error(monkeypatch): + handler = BedrockVideoGeneration() + monkeypatch.setattr( + BedrockVideoGeneration, + "_get_boto_credentials_from_optional_params", + lambda self, params, model=None, bearer_token=None: _FakeCredentialsInfo(), + ) + + class _FailingAsyncClient(httpx.AsyncClient): + async def post(self, **kwargs): + return httpx.Response( + 429, + json={"message": "too many requests"}, + request=httpx.Request("POST", "https://bedrock-runtime.us-east-1.amazonaws.com/async-invoke"), + ) + + with pytest.raises(BedrockError) as excinfo: + asyncio.run( + handler.async_video_generation( + model="bedrock/amazon.nova-reel-v1:0", + prompt="waves at sunset", + optional_params={"output_s3_uri": "s3://bucket/out/"}, + logging_obj=None, + timeout=5.0, + client=_FailingAsyncClient(), + ) + ) + assert excinfo.value.status_code == 429 + assert "too many requests" in str(excinfo.value.message) + assert excinfo.value.headers is not None + + +################################################# +# status mapping + video id guards +################################################# + + +def test_handler_empty_video_id_raises_400(): + handler = BedrockVideoGeneration() + with pytest.raises(BedrockError) as excinfo: + handler.video_status(video_id="", litellm_params={}) + assert excinfo.value.status_code == 400 + assert "Could not extract a Bedrock invocation ARN" in str(excinfo.value.message) + + +def test_map_status_response_non_200_maps_to_bedrock_error_with_headers(): + handler = BedrockVideoGeneration() + resp = httpx.Response( + 500, + json={"message": "internal"}, + request=httpx.Request("GET", "https://example.com/async-invoke/arn"), + ) + with pytest.raises(BedrockError) as excinfo: + handler._map_status_response(resp, TEST_MODEL, "video-id", None) + assert excinfo.value.status_code == 500 + assert "get-async-invoke error" in str(excinfo.value.message) + assert excinfo.value.headers is not None + + +################################################# +# helper edge branches +################################################# + + +def test_client_error_helpers_edge_branches(): + from botocore.exceptions import ClientError + + from litellm.llms.bedrock.videos.handler import _client_error_code, _client_error_http_status + + err = ClientError({"Error": {"Code": "NoSuchKey", "Message": "missing"}}, "GetObject") + assert _client_error_code(err) == "NoSuchKey" + # Non-mapping response payloads fall back to "" / None. + assert _client_error_code(Exception("nope")) == "" + assert _client_error_http_status(Exception("nope")) is None + without_status = ClientError({"Error": {"Code": "X"}}, "Op") + assert _client_error_http_status(without_status) is None + + +def test_region_from_invocation_arn_invalid_returns_none(): + from litellm.llms.bedrock.videos.handler import _region_from_invocation_arn + + assert _region_from_invocation_arn("not-an-arn") is None + assert _region_from_invocation_arn("arn:aws:bedrock::123:async-invoke/x") is None + + +def test_parse_s3_uri_empty_bucket_raises(): + from litellm.llms.bedrock.videos.handler import _parse_s3_uri + + with pytest.raises(BedrockError) as excinfo: + _parse_s3_uri("s3:///only-prefix") + assert excinfo.value.status_code == 400 + assert "Invalid S3 output URI" in str(excinfo.value.message) + + +def test_s3_uri_from_output_config_none_branches(): + from litellm.llms.bedrock.videos.handler import _s3_uri_from_output_config + + assert _s3_uri_from_output_config(None) is None + assert _s3_uri_from_output_config({"s3OutputDataConfig": None}) is None + assert _s3_uri_from_output_config({"s3OutputDataConfig": {}}) is None + + +def test_params_to_dict_branches(): + from types import MappingProxyType + + from litellm.llms.bedrock.videos.handler import _params_to_dict + + assert _params_to_dict(None) == {} + mapping = {"aws_region_name": "us-east-1", "litellm_call_id": "x"} + result = _params_to_dict(mapping) + assert result == mapping + assert result is not mapping # copied, never aliased + frozen = MappingProxyType({"k": "v"}) + assert _params_to_dict(frozen) == {"k": "v"} + + +def test_as_generic_litellm_params_copies_metadata_from_mapping(): + from litellm.llms.bedrock.videos.handler import _as_generic_litellm_params + + result = _as_generic_litellm_params({"metadata": {"request_id": "r1"}, "litellm_call_id": "c1"}) + assert isinstance(result, GenericLiteLLMParams) + assert result.metadata == {"request_id": "r1"} + assert result.litellm_call_id == "c1" + direct = GenericLiteLLMParams() + assert _as_generic_litellm_params(direct) is direct + + +def test_get_config_class_returns_nova_reel_config(): + assert BedrockVideoGeneration().get_config_class() is BedrockNovaReelVideoConfig + + +def test_load_credentials_region_fallback_chain(monkeypatch): + """Explicit aws_region_name > AWS_REGION_NAME env > AWS_REGION env > default.""" + handler = BedrockVideoGeneration() + monkeypatch.setattr(handler, "resolve_credentials", lambda auth_params, region: None) + + _, region = handler._load_credentials({"aws_region_name": "eu-west-1"}) + assert region == "eu-west-1" + + monkeypatch.setenv("AWS_REGION_NAME", "ap-south-1") + _, region = handler._load_credentials({}) + assert region == "ap-south-1" + + monkeypatch.delenv("AWS_REGION_NAME") + monkeypatch.setenv("AWS_REGION", "ca-central-1") + _, region = handler._load_credentials({}) + assert region == "ca-central-1" + + monkeypatch.delenv("AWS_REGION") + _, region = handler._load_credentials({}) + assert region == "us-west-2" + + +def test_transform_create_response_post_call_logging(): + handler = BedrockVideoGeneration() + logging_obj = Mock() + resp = httpx.Response(200, json={"invocationArn": TEST_ARN}) + handler._transform_create_response(TEST_MODEL, resp, {}, logging_obj) + logging_obj.post_call.assert_called_once() + + +def test_sign_get_request_missing_botocore_module_raises_import_error(monkeypatch): + import sys + + from litellm.llms.bedrock.videos.handler import _sign_get_request + + monkeypatch.setitem(sys.modules, "botocore.auth", None) + with pytest.raises(ImportError, match="pip install boto3"): + _sign_get_request(None, "https://example.com/async-invoke", {}, "us-east-1") + + +def test_download_s3_object_missing_botocore_module_raises_import_error(monkeypatch): + import sys + + handler = BedrockVideoGeneration() + monkeypatch.setitem(sys.modules, "botocore.config", None) + with pytest.raises(ImportError, match="pip install boto3"): + handler._download_s3_object("bucket", ["out/output.mp4"], {}, {}) + + +def test_download_s3_object_threads_credentials_into_session(monkeypatch): + """Resolved SigV4 credentials (with a session token) must reach the boto3 Session.""" + from botocore.credentials import Credentials + + handler = BedrockVideoGeneration() + raw: dict = {"outputDataConfig": {"s3OutputDataConfig": {"s3Uri": "s3://bucket/out/"}}} + sessions: list[dict] = [] + + class _FakeS3Client: + def get_object(self, Bucket, Key): + return {"Body": io.BytesIO(b"mp4")} + + def close(self): + pass + + class _FakeSession: + def __init__(self, **kwargs): + sessions.append(kwargs) + + def client(self, service_name, config=None): + return _FakeS3Client() + + monkeypatch.setattr("boto3.Session", _FakeSession) + monkeypatch.setattr( + handler, + "_load_credentials", + lambda optional_params, aws_region_name=None, bearer_token=None: ( + Credentials("AK", "SK", "tok"), + aws_region_name or "us-east-1", + ), + ) + content = handler._download_s3_object("bucket", ["out/output.mp4"], {}, raw, region_default="us-east-1") + assert content == b"mp4" + assert sessions[0]["aws_access_key_id"] == "AK" + assert sessions[0]["aws_secret_access_key"] == "SK" + assert sessions[0]["aws_session_token"] == "tok" + + +################################################# +# input_reference + duration edge branches +################################################# + + +def test_input_reference_unsupported_type_raises(): + with pytest.raises(BedrockError) as excinfo: + _create_request({"output_s3_uri": "s3://bucket/out/", "input_reference": 12345}) + assert excinfo.value.status_code == 400 + assert "must be bytes, a file-like object or a base64 string" in str(excinfo.value.message) + + +def test_input_reference_data_url_non_base64_prefix_raises(): + with pytest.raises(BedrockError) as excinfo: + _create_request({"output_s3_uri": "s3://bucket/out/", "input_reference": "data:text/plain,hello"}) + assert excinfo.value.status_code == 400 + assert "base64-encoded" in str(excinfo.value.message) + + +def test_duration_seconds_from_request_edge_branches(): + from litellm.llms.bedrock.videos.transformation import _duration_seconds_from_request + + assert _duration_seconds_from_request(None) is None + assert _duration_seconds_from_request({"modelInput": "not-a-mapping"}) is None + # Manual shots: non-mapping shots and missing/unparseable durations are skipped. + manual: Final[dict] = { + "modelInput": { + "multiShotManualParams": { + "shots": [ + "not-a-shot", + {"text": "a", "durationSeconds": "6"}, + {"text": "b", "durationSeconds": None}, + {"text": "c", "durationSeconds": "not-a-number"}, + {"text": "d", "durationSeconds": 4}, + ] + } + } + } + assert _duration_seconds_from_request(manual) == 10.0 + # Manual params without shots fall through to videoGenerationConfig. + assert _duration_seconds_from_request({"modelInput": {"multiShotManualParams": {}}}) is None + assert _duration_seconds_from_request({"modelInput": {}}) is None + assert _duration_seconds_from_request({"modelInput": {"videoGenerationConfig": "nope"}}) is None + assert _duration_seconds_from_request({"modelInput": {"videoGenerationConfig": {"durationSeconds": None}}}) is None + assert _duration_seconds_from_request({"modelInput": {"videoGenerationConfig": {"durationSeconds": "x"}}}) is None + assert _duration_seconds_from_request({"modelInput": {"videoGenerationConfig": {"durationSeconds": "6"}}}) == 6.0 + + +def test_dimension_param_overrides_size(): + body: Final = _create_request({"output_s3_uri": "s3://bucket/out/", "size": "1280x720", "dimension": "1080x1920"}) + assert body["modelInput"]["videoGenerationConfig"]["dimension"] == "1080x1920" + + +def test_prebuilt_dict_input_reference_passthrough(): + """A pre-built provider-shaped input_reference dict is forwarded as the images list.""" + prebuilt: Final[dict] = {"format": "png", "source": {"bytes": "already-encoded"}} + body: Final = _create_request({"output_s3_uri": "s3://bucket/out/", "input_reference": prebuilt}) + assert body["modelInput"]["textToVideoParams"]["images"] == [prebuilt] + + +################################################# +# remaining transform guards and returns +################################################# + + +def test_validate_environment_none_headers_gets_content_type(): + headers = _make_config().validate_environment(None, TEST_MODEL) + assert headers["Content-Type"] == "application/json" + + +def test_get_complete_url_raises_unsupported_operation_400(): + """get_complete_url runs before the transforms in the shared video handler, + so it must carry the 400 itself (a NotImplementedError would surface 500).""" + with pytest.raises(BedrockError) as excinfo: + _make_config().get_complete_url(model=TEST_MODEL, api_base=None, litellm_params={}) + assert excinfo.value.status_code == 400 + assert "bedrock video supports create, status and content only" in str(excinfo.value.message) + + +def test_transform_status_retrieve_request_raises_not_implemented(): + with pytest.raises(NotImplementedError, match="status URLs"): + _make_config().transform_video_status_retrieve_request( + video_id="vid", api_base="", litellm_params=GenericLiteLLMParams(), headers={} + ) + + +def test_transform_content_request_raises_not_implemented(): + with pytest.raises(NotImplementedError, match="S3 output location"): + _make_config().transform_video_content_request( + video_id="vid", api_base="", litellm_params=GenericLiteLLMParams(), headers={} + ) + + +def test_transform_content_response_returns_raw_content(): + resp = httpx.Response(200, content=b"mp4-bytes") + assert _make_config().transform_video_content_response(resp, None) == b"mp4-bytes" + + +def test_transform_status_response_missing_arn_raises_value_error(): + config = _make_config() + resp = httpx.Response(200, json={"status": "InProgress"}) + with pytest.raises(ValueError, match="invocationArn"): + config.transform_video_status_retrieve_response(raw_response=resp, logging_obj=None, model=TEST_MODEL) + + +def test_unsupported_remix_response_raises_400_class_bedrock_error(): + with pytest.raises(BedrockError) as excinfo: + _make_config().transform_video_remix_response(raw_response=Mock(), logging_obj=None) + assert excinfo.value.status_code == 400 + + +def test_unsupported_list_response_raises_400_class_bedrock_error(): + with pytest.raises(BedrockError) as excinfo: + _make_config().transform_video_list_response(raw_response=Mock(), logging_obj=None) + assert excinfo.value.status_code == 400 + + +def test_unsupported_delete_operations_raise_400_class_bedrock_error(): + config = _make_config() + with pytest.raises(BedrockError) as excinfo: + config.transform_video_delete_request( + video_id="vid", api_base="", litellm_params=GenericLiteLLMParams(), headers={} + ) + assert excinfo.value.status_code == 400 + with pytest.raises(BedrockError) as excinfo: + config.transform_video_delete_response(raw_response=Mock(), logging_obj=None) + assert excinfo.value.status_code == 400 + + +def test_provider_config_manager_bedrock_prefixed_non_reel_model_returns_none(): + from litellm.utils import ProviderConfigManager + + cfg = ProviderConfigManager.get_provider_video_config( + "bedrock/anthropic.claude-sonnet-4-5", litellm.LlmProviders.BEDROCK + ) + assert cfg is None + + +################################################# +# create-path timeout mapping (sync + async) +################################################# + + +def test_handler_sync_create_timeout_maps_to_bedrock_408(monkeypatch): + handler = BedrockVideoGeneration() + monkeypatch.setattr( + BedrockVideoGeneration, + "_get_boto_credentials_from_optional_params", + lambda self, params, model=None, bearer_token=None: _FakeCredentialsInfo(), + ) + + class _TimingOutPostClient: + def post(self, **kwargs): + raise httpx.ReadTimeout("timed out") + + monkeypatch.setattr("litellm.llms.custom_httpx.http_handler._get_httpx_client", lambda: _TimingOutPostClient()) + with pytest.raises(BedrockError) as excinfo: + handler.video_generation( + model="bedrock/amazon.nova-reel-v1:0", + prompt="waves at sunset", + optional_params={"output_s3_uri": "s3://bucket/out/"}, + logging_obj=None, + timeout=1.0, + avideo_generation=False, + ) + assert excinfo.value.status_code == 408 + + +def test_handler_async_create_timeout_maps_to_bedrock_408(monkeypatch): + handler = BedrockVideoGeneration() + monkeypatch.setattr( + BedrockVideoGeneration, + "_get_boto_credentials_from_optional_params", + lambda self, params, model=None, bearer_token=None: _FakeCredentialsInfo(), + ) + + class _TimingOutAsyncPostClient(httpx.AsyncClient): + async def post(self, **kwargs): + raise httpx.ReadTimeout("timed out") + + with pytest.raises(BedrockError) as excinfo: + asyncio.run( + handler.async_video_generation( + model="bedrock/amazon.nova-reel-v1:0", + prompt="waves at sunset", + optional_params={"output_s3_uri": "s3://bucket/out/"}, + logging_obj=None, + timeout=1.0, + client=_TimingOutAsyncPostClient(), + ) + ) + assert excinfo.value.status_code == 408