From 710fdc341cb7e5ee41098f62cdee7258f12fdf48 Mon Sep 17 00:00:00 2001 From: lokm01 <2735155+lokm01@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:19:38 +0200 Subject: [PATCH 01/30] feat(bedrock): add Nova Canvas segmentation + conditioned editing task types Support taskType=TEXT_IMAGE conditioned editing on the /v1/images/edits Bedrock path: the request image is passed as textToImageParams.conditionImage with optional controlMode (CANNY_EDGE | SEGMENTATION) and controlStrength (0.0-1.0), enabling segmentation-mask-guided edits and canny-edge conditioned generation (issue #39552). Verified against the official Nova userguide (PDF) request structure: - https://docs.aws.amazon.com/nova/latest/userguide/image-req-resp-structure.html (Text-to-image request with image conditioning: controlMode enum is CANNY_EDGE | SEGMENTATION, default controlStrength 0.7) - Fixes AmazonNovaCanvasTextToImageParams.controlMode literal in litellm/types/llms/bedrock.py from the incorrect CANNY_EDIT to CANNY_EDGE and adds the style field. New optional params controlMode/controlStrength are advertised via get_supported_openai_params; invalid controlMode values fail fast. imageGenerationConfig still applies for TEXT_IMAGE (only BACKGROUND_REMOVAL suppresses it). --- ...n_nova_canvas_image_edit_transformation.py | 38 ++++++ litellm/types/llms/bedrock.py | 16 ++- .../test_amazon_nova_canvas_image_edit.py | 118 +++++++++++++++++- 3 files changed, 170 insertions(+), 2 deletions(-) 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..3d555a555e2 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 — 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,9 @@ else: LiteLLMLoggingObj = Any +NOVA_CANVAS_CONTROL_MODES: Final[tuple[str, ...]] = ("CANNY_EDGE", "SEGMENTATION") + + def _nova_canvas_task_body( *, image_b64: str, @@ -48,6 +54,8 @@ 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 | None = None, ) -> dict[str, object]: """Build InvokeModel body task section (without imageGenerationConfig).""" if task_type == "BACKGROUND_REMOVAL": @@ -77,6 +85,29 @@ 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 control_mode is not None and control_mode not in NOVA_CANVAS_CONTROL_MODES: + raise ValueError( + f"Unsupported Amazon Nova Canvas controlMode: {control_mode!r}. Use one of {NOVA_CANVAS_CONTROL_MODES}." + ) + t2i_params: Final[dict[str, object]] = { + "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 is not None: + t2i_params["controlStrength"] = control_strength + return { + "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": @@ -98,6 +129,7 @@ def _nova_canvas_task_body( raise ValueError( 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": @@ -251,6 +283,8 @@ class BedrockAmazonNovaCanvasImageEditConfig(BaseImageEditConfig): "taskType", "maskPrompt", "outPaintingMode", + "controlMode", + "controlStrength", "imageGenerationConfig", ] @@ -366,6 +400,8 @@ 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) body: Final = _nova_canvas_task_body( image_b64=image_b64, @@ -376,6 +412,8 @@ 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, ) # BACKGROUND_REMOVAL InvokeModel body must not include imageGenerationConfig (AWS rejects it). diff --git a/litellm/types/llms/bedrock.py b/litellm/types/llms/bedrock.py index e4c41c3ee5b..e7c2b3b9659 100644 --- a/litellm/types/llms/bedrock.py +++ b/litellm/types/llms/bedrock.py @@ -914,13 +914,27 @@ 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 — the AWS default). """ text: str negativeText: str controlStrength: float - controlMode: Literal["CANNY_EDIT", "SEGMENTATION"] + controlMode: Literal["CANNY_EDGE", "SEGMENTATION"] conditionImage: str + style: 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): 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..283465d0025 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 @@ -388,7 +388,7 @@ def test_transform_request_unknown_task_type_raises(): 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={}, ) @@ -410,6 +410,122 @@ 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={}, # type: ignore[arg-type] + 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={}, # type: ignore[arg-type] + 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={}, # type: ignore[arg-type] + 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={}, # type: ignore[arg-type] + 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(ValueError, match="Unsupported Amazon Nova Canvas controlMode"): + 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={}, # type: ignore[arg-type] + headers={}, + ) + + +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_transform_request_background_removal_omits_image_generation_config(): """AWS Nova Canvas does not allow imageGenerationConfig on BACKGROUND_REMOVAL.""" config = BedrockAmazonNovaCanvasImageEditConfig() From 5e9b71de742da7c0a4a032ee0145f5f43c459838 Mon Sep 17 00:00:00 2001 From: lokm01 <2735155+lokm01@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:33:06 +0200 Subject: [PATCH 02/30] feat(bedrock): add Nova Reel video generation via /v1/videos Support amazon.nova-reel-v1:0 (and us.* cross-region inference ids) on the OpenAI-style /v1/videos API (create/status/content) through the Bedrock asynchronous invoke API (issue #39552): - create: POST {runtime}/async-invoke (StartAsyncInvoke) with body {modelId, modelInput, outputDataConfig:{s3OutputDataConfig:{s3Uri}}}; modelInput taskType TEXT_VIDEO with textToVideoParams {text, images:[{format, source:{bytes}}]} and videoGenerationConfig {durationSeconds (OpenAI seconds, default 6), fps (24), dimension (OpenAI size, default 1280x720), seed}. output_s3_uri is required and forwarded. Response invocationArn is provider-encoded into the VideoObject id via encode_video_id_with_provider so status/content round-trip; usage.duration_seconds stamped for the video cost calculator (same pattern as the Vertex Veo config). - status: GET {runtime}/async-invoke/{urlencoded arn} (GetAsyncInvoke, SigV4-signed; region derived from the invocation ARN). Status enum InProgress|Completed|Failed maps to processing|completed|failed with failureMessage -> error.message and endTime -> completed_at. - content: once Completed, downloads output.mp4 from the S3 output location via a boto3 s3 client built from the same AWS credential resolution helpers as the rest of the bedrock integration (tries the per-invocation folder layout then the flat prefix layout). Wiring: bedrock branch in ProviderConfigManager.get_provider_video_config (gated on nova-reel model ids, model=None for status/content routes) and bedrock routing in litellm/videos/main.py for create/status/content (mirrors how images/main.py routes bedrock for AWS SigV4 signing). Cost map: amazon.nova-reel-v1:0 + us.amazon.nova-reel-v1:0 with mode video_generation and output_cost_per_second 0.012 (AWS publishes Nova Reel as priced per second of generated video at 720p/24fps; the per-second value matches AWS's published rate but the JS-rendered pricing page could not be scraped to re-confirm the exact figure). Verified against the official AWS docs and the bedrock-runtime Smithy service model (botocore): - https://docs.aws.amazon.com/nova/latest/userguide/video-gen-access.html (start_async_invoke/get_async_invoke usage, TEXT_VIDEO modelInput, status enum, output.mp4 in S3) - https://docs.aws.amazon.com/nova/latest/userguide/video-req-resp-structure.html (textToVideoParams/videoGenerationConfig field names; imageSource schema {format, source:{bytes}}; MULTI_SHOT_* task types) - bedrock-runtime service-2.json: StartAsyncInvoke POST /async-invoke, GetAsyncInvoke GET /async-invoke/{invocationArn}, AsyncInvokeStatus enum [InProgress, Completed, Failed], failureMessage field. --- litellm/llms/bedrock/videos/__init__.py | 8 + litellm/llms/bedrock/videos/handler.py | 520 ++++++++++++++++++ litellm/llms/bedrock/videos/transformation.py | 399 ++++++++++++++ litellm/types/llms/bedrock.py | 134 +++++ litellm/utils.py | 9 + litellm/videos/main.py | 46 ++ model_prices_and_context_window.json | 18 + .../videos/test_amazon_nova_reel_video.py | 390 +++++++++++++ 8 files changed, 1524 insertions(+) create mode 100644 litellm/llms/bedrock/videos/__init__.py create mode 100644 litellm/llms/bedrock/videos/handler.py create mode 100644 litellm/llms/bedrock/videos/transformation.py create mode 100644 tests/unit/llms/bedrock/videos/test_amazon_nova_reel_video.py diff --git a/litellm/llms/bedrock/videos/__init__.py b/litellm/llms/bedrock/videos/__init__.py new file mode 100644 index 00000000000..ee89c374d64 --- /dev/null +++ b/litellm/llms/bedrock/videos/__init__.py @@ -0,0 +1,8 @@ +""" +Bedrock video generation (Amazon Nova Reel via StartAsyncInvoke). +""" + +from litellm.llms.bedrock.videos.handler import BedrockVideoGeneration +from litellm.llms.bedrock.videos.transformation import BedrockNovaReelVideoConfig + +__all__ = ["BedrockNovaReelVideoConfig", "BedrockVideoGeneration"] diff --git a/litellm/llms/bedrock/videos/handler.py b/litellm/llms/bedrock/videos/handler.py new file mode 100644 index 00000000000..32dbe1d8d11 --- /dev/null +++ b/litellm/llms/bedrock/videos/handler.py @@ -0,0 +1,520 @@ +""" +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 typing import TYPE_CHECKING, Any, Final +from urllib.parse import quote + +import httpx + +import litellm +from litellm.llms.bedrock.videos.transformation import BedrockNovaReelVideoConfig +from litellm.secret_managers.main import get_secret +from litellm.types.router import GenericLiteLLMParams +from litellm.types.videos.main import VideoObject +from litellm.types.videos.utils import decode_video_id_with_provider + +from ..base_aws_llm import ( + AWSPreparedRequest, + BaseAWSLLM, + Credentials, + bedrock_bearer_token, + pop_aws_auth_params, +) +from ..common_utils import BedrockError + +if TYPE_CHECKING: + from botocore.awsrequest import AWSPreparedRequest as _AWSPreparedRequest + + from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging +else: + _AWSPreparedRequest = Any + +DEFAULT_VIDEO_REGION: Final = "us-west-2" +NOVA_REEL_OUTPUT_FILENAME: Final = "output.mp4" + + +def _sign_get_request( + credentials: Credentials | None, + url: str, + headers: dict[str, str], + aws_region_name: str, + bearer_token: str | None = None, +) -> AWSPreparedRequest: + try: + from botocore.auth import SigV4Auth + from botocore.awsrequest import AWSRequest + except ImportError: + raise ImportError("Missing boto3 to call bedrock. Run 'pip install boto3'.") + + if bearer_token is not None: + headers = {**headers, "Authorization": f"Bearer {bearer_token}"} + request: Final = AWSRequest(method="GET", url=url, data=None, headers=headers) + return request.prepare() + + request = AWSRequest(method="GET", url=url, data=None, headers=headers) + if credentials is not 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').""" + trimmed: Final = s3_uri.rstrip("/") + without_scheme: Final = trimmed.removeprefix("s3://") + bucket, _, prefix = without_scheme.partition("/") + if not bucket: + raise ValueError(f"Invalid S3 output URI: {s3_uri!r}") + return bucket, prefix + + +def _params_to_dict(litellm_params: GenericLiteLLMParams | dict | None) -> dict: + """GenericLiteLLMParams is a pydantic model with dict-like access; copy to a real dict.""" + if litellm_params is None: + return {} + if isinstance(litellm_params, dict): + return dict(litellm_params) + if hasattr(litellm_params, "model_dump"): + return litellm_params.model_dump(exclude_none=True) + return dict(litellm_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: dict, + api_base: str | None, + extra_headers: dict | None, + logging_obj: LiteLLMLogging | None, + api_key: str | None = None, + ) -> tuple[str, _AWSPreparedRequest, bytes, dict]: + """ + Returns (endpoint_url, prepped_request, body, data) for POST /async-invoke. + """ + 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=GenericLiteLLMParams(), + headers={}, + ) + # 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 = {"Content-Type": "application/json"} + if extra_headers is not None: + headers = {"Content-Type": "application/json", **extra_headers} + + 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={ + "complete_input_dict": data, + "api_base": endpoint_url, + "headers": prepped.headers, + }, + ) + return endpoint_url, prepped, body, data + + def _transform_create_response( + self, + model: str, + response: httpx.Response, + data: dict, + 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}, + ) + if response.status_code not in (200, 201): + raise BedrockError( + status_code=response.status_code, + message=f"Nova Reel video generation error: {response.text}", + headers=response.headers, + response=response, + ) + 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: dict, + logging_obj: LiteLLMLogging | None, + timeout: float | httpx.Timeout | None, + avideo_generation: bool = False, + client: Any = None, + api_base: str | None = None, + extra_headers: dict | None = None, + api_key: str | None = None, + ) -> VideoObject: + if avideo_generation: + return self.async_video_generation( # type: ignore[no-any-return] + 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, + ) + + 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, + ) + from litellm.llms.custom_httpx.http_handler import _get_httpx_client + + sync_client = client if isinstance(client, httpx.Client) else _get_httpx_client() + try: + response: Final = sync_client.post( + url=endpoint_url, + headers=prepped.headers, + content=body, + ) + 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: dict, + logging_obj: LiteLLMLogging | None, + timeout: float | httpx.Timeout | None, + client: Any = None, + api_base: str | None = None, + extra_headers: dict | None = None, + api_key: str | 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, + ) + async_client = ( + client + if isinstance(client, httpx.AsyncClient) + else get_async_httpx_client( + llm_provider=litellm.LlmProviders.BEDROCK, + params={"timeout": timeout}, + ) + ) + try: + response: Final = await async_client.post( + url=endpoint_url, + headers=prepped.headers, + content=body, + ) + 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]: + """Returns (status_url, prepped_get_request) for GET /async-invoke/{arn}.""" + 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"}, + aws_region_name=aws_region_name, + bearer_token=bearer_token, + ) + return status_url, prepped + + def _decode_status_context(self, video_id: str) -> tuple[str, str]: + """Returns (invocation_arn, model) encoded in the video id.""" + config: Final = BedrockNovaReelVideoConfig() + invocation_arn: Final = config.extract_invocation_arn(video_id) + if not invocation_arn: + raise ValueError(f"Could not extract a Bedrock invocation ARN from video id: {video_id!r}") + decoded: Final = decode_video_id_with_provider(video_id) + model: Final = decoded.get("model_id") or "amazon.nova-reel-v1:0" + return invocation_arn, model + + def _sync_get(self, prepped: AWSPreparedRequest) -> httpx.Response: + from litellm.llms.custom_httpx.http_handler import _get_httpx_client + + client: Final = _get_httpx_client() + return client.get(url=prepped.url, headers=prepped.headers) + + async def _async_get(self, prepped: AWSPreparedRequest) -> 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) + return await client.get(url=prepped.url, headers=prepped.headers) + + def _map_status_response( + self, + response: httpx.Response, + model: str, + video_id: str, + logging_obj: LiteLLMLogging | None, + ) -> tuple[VideoObject, dict]: + 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, + ) + raw: Final[dict] = response.json() + 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 | dict | None = None, + logging_obj: LiteLLMLogging | None = None, + api_base: str | None = None, + api_key: str | None = None, + astatus: bool = False, + ) -> VideoObject: + invocation_arn, model = self._decode_status_context(video_id) + optional_params: Final[dict] = _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( # type: ignore[no-any-return] + prepped=prepped, model=model, video_id=video_id, logging_obj=logging_obj + ) + response: Final = self._sync_get(prepped) + 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, + ) -> VideoObject: + response: Final = await self._async_get(prepped) + 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 | dict | None = None, + logging_obj: LiteLLMLogging | None = None, + api_base: str | None = None, + api_key: str | 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[dict] = _params_to_dict(litellm_params) + _, prepped = self._status_request_parts(invocation_arn, optional_params, api_base, api_key=api_key) + response: Final = self._sync_get(prepped) + video_obj, raw = self._map_status_response(response, model, video_id, logging_obj) + if video_obj.status != "completed": + raise ValueError( + "Nova Reel video generation is not complete yet " + f"(status={video_obj.status}). Check video_status() before downloading." + ) + + output_config: Final = (raw.get("outputDataConfig") or {}).get("s3OutputDataConfig") or {} + s3_uri: Final = output_config.get("s3Uri") + if not s3_uri: + raise ValueError(f"No S3 output location on completed invocation: {raw}") + + bucket, prefix = _parse_s3_uri(s3_uri) + # Nova writes output.mp4 into a per-invocation folder (v1:1 docs); older + # v1:0 flows placed it directly under the configured prefix. + key_candidates: Final = [ + f"{prefix}/{invocation_arn.rsplit('/', 1)[-1]}/{NOVA_REEL_OUTPUT_FILENAME}".lstrip("/"), + f"{prefix}/{NOVA_REEL_OUTPUT_FILENAME}".lstrip("/"), + ] + return self._download_s3_object(bucket, key_candidates, _params_to_dict(litellm_params), raw) + + def _download_s3_object(self, bucket: str, key_candidates: list[str], litellm_params: dict, raw: dict) -> bytes: + try: + import boto3 + from botocore.exceptions import ClientError + except ImportError: + raise ImportError("Missing boto3 to download Nova Reel output. Run 'pip install boto3'.") + + optional_params: Final[dict] = _params_to_dict(litellm_params) + credentials, region = self._load_credentials(optional_params) + session_kwargs: Final[dict[str, Any]] = {"region_name": region} + if credentials is not None: + session_kwargs.update( + { + "aws_access_key_id": credentials.access_key, + "aws_secret_access_key": credentials.secret_key, + **({"aws_session_token": credentials.token} if credentials.token else {}), + } + ) + session: Final = boto3.Session(**session_kwargs) + s3_client: Final = session.client("s3") + + errors: list[str] = [] + for key in key_candidates: + try: + obj: Final = s3_client.get_object(Bucket=bucket, Key=key) + content: Final = obj["Body"].read() + return bytes(content) + except ClientError as err: + errors.append(f"s3://{bucket}/{key}: {err}") + raise BedrockError( + status_code=404, + message=( + "Nova Reel output video not found in the S3 output location. " + f"Tried: {key_candidates}. Errors: {errors}. Raw invocation: {raw}" + ), + headers={}, + ) diff --git a/litellm/llms/bedrock/videos/transformation.py b/litellm/llms/bedrock/videos/transformation.py new file mode 100644 index 00000000000..54f8bd24a8b --- /dev/null +++ b/litellm/llms/bedrock/videos/transformation.py @@ -0,0 +1,399 @@ +""" +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 +from typing import TYPE_CHECKING, Any, Final + +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.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.types.videos.main import VideoObject + +NOVA_REEL_DEFAULT_DURATION_SECONDS: Final = 6 +NOVA_REEL_DEFAULT_FPS: Final = 24 +NOVA_REEL_DEFAULT_DIMENSION: Final = "1280x720" + +# AWS async-invoke status enum (bedrock-runtime service model) -> OpenAI-style +# VideoObject.status values used across LiteLLM video providers. +NOVA_REEL_STATUS_MAP: Final[dict[str, str]] = { + "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 _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): + # Already base64-encoded string - detect format from the decoded header. + try: + image_bytes = base64.b64decode(image, validate=False) + except (binascii.Error, ValueError): + return image, "png" + elif hasattr(image, "read") and callable(getattr(image, "read", None)): + if hasattr(image, "seek"): + image.seek(0) + image_bytes = image.read() + else: + raise ValueError( + 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: + image_format = "png" + return base64.b64encode(image_bytes).decode("utf-8"), image_format + + +class BedrockNovaReelVideoConfig(BaseVideoConfig): + """ + Video config for amazon.nova-reel-v1:0 (and regional variants) on Bedrock. + """ + + def get_supported_openai_params(self, model: str) -> list: + return [ + "seconds", + "size", + "seed", + "fps", + "dimension", + "taskType", + "output_s3_uri", + "parameters", + "input_reference", + "image", + ] + + def map_openai_params( + self, + video_create_optional_params: VideoCreateOptionalRequestParams, + model: str, + drop_params: bool, + ) -> dict: + # 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) + + def validate_environment( + self, + headers: dict, + model: str, + api_key: str | None = None, + litellm_params: GenericLiteLLMParams | None = None, + ) -> dict: + if headers is None: + headers = {} + if "Content-Type" not in headers: + headers["Content-Type"] = "application/json" + return headers + + def get_complete_url( + self, + model: str, + api_base: str | None, + litellm_params: dict, + ) -> str: + raise NotImplementedError( + "Nova Reel video URLs are built in BedrockVideoGeneration " + "(AWS runtime endpoint + /async-invoke paths). Do not use get_complete_url " + "for this config." + ) + + def transform_video_create_request( + self, + model: str, + prompt: str, + api_base: str, + video_create_optional_request_params: dict, + litellm_params: GenericLiteLLMParams, + headers: dict, + ) -> tuple[dict, RequestFiles, str]: + """ + Build the StartAsyncInvoke request body. + + Returns (request_body, None, "POST") where request_body is the wrapped + async-invoke envelope {modelId, modelInput, outputDataConfig}. + """ + op: Final = dict(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 ValueError( + "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)." + ) + + task_type: Final = op.pop("taskType", "TEXT_VIDEO") + + text_to_video_params: Final[dict[str, object]] = {"text": prompt} + input_reference: Final = op.pop("input_reference", None) or op.pop("image", None) + if input_reference is not None: + if isinstance(input_reference, dict): + # Pre-built provider shape: {"format": ..., "source": {...}} + text_to_video_params["images"] = [input_reference] + else: + image_b64, image_format = _file_content_to_b64_and_format(input_reference) + text_to_video_params["images"] = [{"format": image_format, "source": {"bytes": image_b64}}] + + generation_config: Final[dict[str, object]] = { + "durationSeconds": NOVA_REEL_DEFAULT_DURATION_SECONDS, + "fps": NOVA_REEL_DEFAULT_FPS, + "dimension": NOVA_REEL_DEFAULT_DIMENSION, + } + seconds: Final = op.pop("seconds", None) + if seconds is not None: + try: + generation_config["durationSeconds"] = int(float(seconds)) + except (TypeError, ValueError): + verbose_logger.debug("Nova Reel ignoring non-numeric seconds=%r", 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: + generation_config["fps"] = fps + seed: Final = op.pop("seed", None) + if seed is not None: + generation_config["seed"] = seed + + model_input: Final[dict[str, object]] = { + "taskType": task_type, + "textToVideoParams": text_to_video_params, + "videoGenerationConfig": generation_config, + } + + # Remaining provider-specific keys (e.g. multiShotManualParams) are passed + # through verbatim on the modelInput body. + model_input.update(op) + + request_body: Final[dict[str, object]] = { + "modelId": model, + "modelInput": model_input, + "outputDataConfig": {"s3OutputDataConfig": {"s3Uri": output_s3_uri}}, + } + return request_body, None, "POST" + + def transform_video_create_response( + self, + model: str, + raw_response: httpx.Response, + logging_obj: Any, + custom_llm_provider: str | None = None, + request_data: dict | None = None, + ) -> VideoObject: + from litellm.types.videos.main import VideoObject + + response_data: Final = raw_response.json() + invocation_arn: Final = 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(), + ) + usage: Final[dict[str, object]] = {} + duration: Final = ( + ((request_data or {}).get("modelInput") or {}).get("videoGenerationConfig", {}).get("durationSeconds") + ) + if duration is not None: + # Mirrors the Vertex video config: lets the video cost calculator + # compute cost from output_cost_per_second * duration_seconds. + usage["duration_seconds"] = float(duration) + if usage: + video_obj.usage = usage + return video_obj + + def transform_video_status_retrieve_request( + self, + video_id: str, + api_base: str, + litellm_params: GenericLiteLLMParams, + headers: dict, + ) -> tuple[str, dict]: + 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: Any, + custom_llm_provider: str | None = None, + client: Any = None, + model: str | None = None, + video_id: str | None = None, + ) -> VideoObject: + from litellm.types.videos.main import VideoObject + + response_data: Final[dict] = raw_response.json() + invocation_arn: Final = response_data.get("invocationArn") + if not invocation_arn: + raise ValueError(f"Nova Reel get-async-invoke response missing invocationArn: {response_data}") + raw_status: Final = str(response_data.get("status") or response_data.get("invocationStatus") or "InProgress") + status: Final = NOVA_REEL_STATUS_MAP.get(raw_status, "processing") + + 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": response_data["failureMessage"]} + if status == "failed" and response_data.get("failureMessage") + else None + ), + ) + output_config: Final = (response_data.get("outputDataConfig") or {}).get("s3OutputDataConfig") or {} + s3_uri: Final = output_config.get("s3Uri") + if s3_uri: + video_obj.usage = {"output_s3_uri": s3_uri} + return video_obj + + def transform_video_content_request( + self, + video_id: str, + api_base: str, + litellm_params: GenericLiteLLMParams, + headers: dict, + variant: str | None = None, + ) -> tuple[str, dict]: + 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: Any, + ) -> bytes: + return raw_response.content + + def transform_video_remix_request( + self, + video_id: str, + prompt: str, + api_base: str, + litellm_params: GenericLiteLLMParams, + headers: dict, + extra_body: dict[str, object] | None = None, + ) -> tuple[str, dict]: + raise NotImplementedError(_UNSUPPORTED_MESSAGE.format(operation="remix")) + + def transform_video_remix_response( + self, + raw_response: httpx.Response, + logging_obj: Any, + custom_llm_provider: str | None = None, + ) -> VideoObject: + raise NotImplementedError(_UNSUPPORTED_MESSAGE.format(operation="remix")) + + def transform_video_list_request( + self, + api_base: str, + litellm_params: GenericLiteLLMParams, + headers: dict, + after: str | None = None, + limit: int | None = None, + order: str | None = None, + extra_query: dict[str, object] | None = None, + ) -> tuple[str, dict]: + raise NotImplementedError(_UNSUPPORTED_MESSAGE.format(operation="list")) + + def transform_video_list_response( + self, + raw_response: httpx.Response, + logging_obj: Any, + custom_llm_provider: str | None = None, + ) -> dict[str, str]: + raise NotImplementedError(_UNSUPPORTED_MESSAGE.format(operation="list")) + + def transform_video_delete_request( + self, + video_id: str, + api_base: str, + litellm_params: GenericLiteLLMParams, + headers: dict, + ) -> tuple[str, dict]: + raise NotImplementedError(_UNSUPPORTED_MESSAGE.format(operation="delete")) + + def transform_video_delete_response( + self, + raw_response: httpx.Response, + logging_obj: Any, + ) -> VideoObject: + raise NotImplementedError(_UNSUPPORTED_MESSAGE.format(operation="delete")) + + @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: Any) -> int | None: + if timestamp is None: + return None + try: + return int(float(timestamp)) + except (TypeError, ValueError): + return None diff --git a/litellm/types/llms/bedrock.py b/litellm/types/llms/bedrock.py index e7c2b3b9659..a4e45f3e101 100644 --- a/litellm/types/llms/bedrock.py +++ b/litellm/types/llms/bedrock.py @@ -1015,6 +1015,140 @@ 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: str + bucketOwner: str + + +class AmazonNovaReelImageSourceLocation(TypedDict, total=False): + """ + Location of a Nova Reel input image: inline base64 bytes or S3. + """ + + bytes: str # base64 encoded image + s3Location: AmazonNovaReelS3Location + + +class AmazonNovaReelImageSource(TypedDict, total=False): + """ + Image source for Nova Reel textToVideoParams.images entries. + """ + + format: Literal["png", "jpeg"] + source: 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: str + images: list[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: int + fps: int + dimension: str + seed: int + + +class AmazonNovaReelModelInput(TypedDict, total=False): + """ + modelInput body for Nova Reel StartAsyncInvoke. + """ + + taskType: NOVA_REEL_TASK_TYPES + textToVideoParams: AmazonNovaReelTextToVideoParams + videoGenerationConfig: AmazonNovaReelVideoGenerationConfig + + +class BedrockAsyncInvokeS3OutputDataConfig(TypedDict, total=False): + """ + S3 output config for Bedrock StartAsyncInvoke (nested outputDataConfig key). + + Ref: bedrock-runtime service model (StartAsyncInvokeRequest.outputDataConfig) + """ + + s3Uri: str + kmsKeyId: str + bucketOwner: str + + +class BedrockAsyncInvokeOutputDataConfig(TypedDict, total=False): + """ + Output data config for Bedrock StartAsyncInvoke. + """ + + s3OutputDataConfig: 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: str + modelInput: AmazonNovaReelModelInput + outputDataConfig: BedrockAsyncInvokeOutputDataConfig + clientRequestToken: str + + +class BedrockStartAsyncInvokeResponse(TypedDict, total=False): + """ + Response body for POST {runtime}/async-invoke. + """ + + invocationArn: 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. + """ + + invocationArn: str + modelArn: str + clientRequestToken: str + status: BEDROCK_ASYNC_INVOKE_STATUSES + failureMessage: str + submitTime: float + lastModifiedTime: float + endTime: float + outputDataConfig: BedrockAsyncInvokeOutputDataConfig + + if TYPE_CHECKING: from botocore.awsrequest import AWSPreparedRequest else: diff --git a/litellm/utils.py b/litellm/utils.py index 64097021dff..cff7561ce10 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -9569,6 +9569,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..212d6051f7a 100644 --- a/litellm/videos/main.py +++ b/litellm/videos/main.py @@ -243,6 +243,29 @@ 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": + from litellm.llms.bedrock.videos.handler import BedrockVideoGeneration + + bedrock_optional_params: Final[dict] = dict(video_generation_request_params) + # aws_* auth params ride on litellm_params; surface them for the handler + # (mirrors how images/main.py merges non_default_params for bedrock). + bedrock_optional_params.update( + {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=litellm_logging_obj, + timeout=timeout or DEFAULT_REQUEST_TIMEOUT, + avideo_generation=_is_async, + client=kwargs.get("client"), + api_base=litellm_params.get("api_base"), + extra_headers=extra_headers, + api_key=kwargs.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 +378,16 @@ def video_content( ) # Call the handler with _is_async flag instead of directly calling the async handler + if custom_llm_provider == "bedrock": + from litellm.llms.bedrock.videos.handler import BedrockVideoGeneration + + return BedrockVideoGeneration().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"), + ) return base_llm_http_handler.video_content_handler( video_id=video_id, video_content_provider_config=video_provider_config, @@ -1058,6 +1091,19 @@ 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": + from litellm.llms.bedrock.videos.handler import BedrockVideoGeneration + + return BedrockVideoGeneration().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"), + astatus=_is_async, + ) + # 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 0f2782efffa..8300f8da121 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -292,6 +292,24 @@ "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.012, + "source": "https://aws.amazon.com/bedrock/pricing/", + "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.012, + "source": "https://aws.amazon.com/bedrock/pricing/", + "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/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..9b371fb2307 --- /dev/null +++ b/tests/unit/llms/bedrock/videos/test_amazon_nova_reel_video.py @@ -0,0 +1,390 @@ +"""Unit tests for Bedrock Amazon Nova Reel video generation (issue #39552).""" + +import base64 +import io +import json +from typing import cast +from unittest.mock import Mock, patch + +import httpx +import pytest + +import litellm +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" + + +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, +) -> 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=GenericLiteLLMParams(), + headers={}, + ) + assert files is None + 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(): + png_bytes = b"\x89PNG\r\n\x1a\n" + b"0" * 8 + 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(): + jpeg_bytes = b"\xff\xd8\xff\xe0" + b"0" * 8 + 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_requires_output_s3_uri(): + with pytest.raises(ValueError, match="output_s3_uri"): + _create_request({}) + + +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 + assert video.usage is not None + assert video.usage["output_s3_uri"] == "s3://bucket/out/" + decoded = decode_video_id_with_provider(video.id) + assert decoded["video_id"] == TEST_ARN + + +################################################# +# 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" + + +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") + from litellm.types.videos.utils import encode_video_id_with_provider + + video_id = encode_video_id_with_provider(TEST_ARN, "bedrock", TEST_MODEL) + status_url, prepped = 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 + + +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={}), + ), + ) + 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: 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={}), + ), + ) + resp = httpx.Response( + 200, + json={ + "invocationArn": TEST_ARN, + "status": "InProgress", + "outputDataConfig": {"s3OutputDataConfig": {"s3Uri": "s3://bucket/out/"}}, + }, + ) + monkeypatch.setattr(handler, "_sync_get", lambda prepped: resp) + with pytest.raises(ValueError, match="not complete"): + handler.video_content(video_id=video_id, litellm_params={}) + + +def test_handler_video_content_downloads_from_s3(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={}), + ), + ) + resp = httpx.Response( + 200, + json={ + "invocationArn": TEST_ARN, + "status": "Completed", + "outputDataConfig": {"s3OutputDataConfig": {"s3Uri": "s3://bucket/out/"}}, + }, + ) + monkeypatch.setattr(handler, "_sync_get", lambda prepped: resp) + + downloaded_keys: list[str] = [] + + def fake_download(bucket, key_candidates, litellm_params, raw): + 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 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 From 570399abb2c8b335c7a7b27844dc656152b996d2 Mon Sep 17 00:00:00 2001 From: lokm01 <2735155+lokm01@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:54:36 +0200 Subject: [PATCH 03/30] chore(bedrock): add nova-reel-v1:1 cost map entries v1:0 is legacy-denied on many accounts while v1:1 is the active model. Live-validated on v1:1 via /v1/videos (create/status/content all worked). Price uses output_cost_per_second 0.012, consistent with the v1:0 entries. Adds amazon.nova-reel-v1:1 and us.amazon.nova-reel-v1:1 mirroring v1:0. --- ...odel_prices_and_context_window_backup.json | 36 +++++++++++++++++++ model_prices_and_context_window.json | 18 ++++++++++ 2 files changed, 54 insertions(+) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 0f2782efffa..a3f1f9037ae 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.012, + "source": "https://aws.amazon.com/bedrock/pricing/", + "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.012, + "source": "https://aws.amazon.com/bedrock/pricing/", + "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.012, + "source": "https://aws.amazon.com/bedrock/pricing/", + "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.012, + "source": "https://aws.amazon.com/bedrock/pricing/", + "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/model_prices_and_context_window.json b/model_prices_and_context_window.json index 8300f8da121..a3f1f9037ae 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -301,6 +301,15 @@ "supported_modalities": ["text", "image"], "supported_output_modalities": ["video"] }, + "amazon.nova-reel-v1:1": { + "litellm_provider": "bedrock", + "mode": "video_generation", + "output_cost_per_second": 0.012, + "source": "https://aws.amazon.com/bedrock/pricing/", + "supported_endpoints": ["/v1/videos"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["video"] + }, "us.amazon.nova-reel-v1:0": { "litellm_provider": "bedrock", "mode": "video_generation", @@ -310,6 +319,15 @@ "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.012, + "source": "https://aws.amazon.com/bedrock/pricing/", + "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", From 17d6b33bd4e8b72a3d38296eb7d6fe68ce048dc6 Mon Sep 17 00:00:00 2001 From: lokm01 <2735155+lokm01@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:02:45 +0200 Subject: [PATCH 04/30] fix(bedrock): preserve provider headers in nova reel video error class BedrockNovaReelVideoConfig inherited get_error_class from the generic BaseVideoConfig, whose plain BaseLLMException path drops the synthesized response that carries provider headers. The bedrock audit test test_every_bedrock_config_get_error_class_keeps_provider_headers catches exactly this: x-amzn-RequestId must survive error classification. Override get_error_class to build BedrockError, which synthesizes an httpx response from the headers it is given, matching the pattern every other bedrock surface (chat converse, agentcore, invoke agent, claude platform) already uses. --- litellm/llms/bedrock/videos/transformation.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/litellm/llms/bedrock/videos/transformation.py b/litellm/llms/bedrock/videos/transformation.py index 54f8bd24a8b..24379b9e62d 100644 --- a/litellm/llms/bedrock/videos/transformation.py +++ b/litellm/llms/bedrock/videos/transformation.py @@ -29,6 +29,7 @@ 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.router import GenericLiteLLMParams from litellm.types.videos.main import VideoCreateOptionalRequestParams from litellm.types.videos.utils import ( @@ -129,6 +130,15 @@ class BedrockNovaReelVideoConfig(BaseVideoConfig): 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, From 967610e323cafaf4096f30298357208083f82913 Mon Sep 17 00:00:00 2001 From: lokm01 <2735155+lokm01@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:03:07 +0200 Subject: [PATCH 05/30] chore(bedrock): satisfy strict typing and type discipline gates Clear every gate finding the contribution audit filed against the nova reel and nova canvas additions without changing request behavior: - ANN401: client params typed httpx.Client | httpx.AsyncClient | None, logging_obj params typed LiteLLMLogging | None (Logging import under TYPE_CHECKING, matching vertex/runwayml video configs), _to_epoch takes str | float | None - LIT001/LIT002: dict/list annotations and constructions replaced with Mapping, MappingProxyType-frozen status maps, TypeAlias aliases for the shapes BaseVideoConfig's contract requires (same pattern as fal_ai videos), reasoned mutable-ok markers where a mutable dict is genuinely built (AWS JSON envelopes, headers dicts, boto3 session) - LIT012: every TypedDict field this branch added in litellm/types/llms/bedrock.py is now ReadOnly[...] qualified, with Sequence for the images array - basedpyright reportGeneralTypeIssues: request in _sign_get_request binds once under Final instead of being rebound, obj/content in the S3 download loop drop their illegal Final, the two coroutine returns declare VideoObject | Coroutine[object, object, VideoObject] instead of carrying inert type: ignore comments, and transform_video_create_ request returns [] for files so the RequestFiles contract holds - responses parse into the BedrockStartAsyncInvokeResponse and BedrockGetAsyncInvokeResponse TypedDicts with isinstance narrowing instead of Any-chained lookups Also folds in the audit's metadata findings: - provider_endpoints_support.json: bedrock now declares video_generations: true (runwayml-style entry) - pricing: nova-reel-v1:0/v1:1 (direct and us. prefix, root and backup maps) corrected from 0.012 to 0.08 usd per second, verified against the AWS bulk price list API (AmazonBedrock offer 20260922212139, published 2026-09-22) and the price list query API, both showing 0.08 usd per second for USE1-NovaReel-T2V/I2V-Medfps-HDRes effective 2026-09-01; source fields now carry the access date - em-dashes in two added comments replaced with plain punctuation --- ...n_nova_canvas_image_edit_transformation.py | 6 +- litellm/llms/bedrock/videos/__init__.py | 8 +- litellm/llms/bedrock/videos/handler.py | 165 +++++++++------- litellm/llms/bedrock/videos/transformation.py | 181 +++++++++++------- ...odel_prices_and_context_window_backup.json | 16 +- litellm/types/llms/bedrock.py | 90 ++++----- litellm/videos/main.py | 10 +- model_prices_and_context_window.json | 16 +- provider_endpoints_support.json | 3 +- 9 files changed, 285 insertions(+), 210 deletions(-) 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 3d555a555e2..b26da1d588d 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,7 +4,7 @@ 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 — the input image conditions layout via +- TEXT_IMAGE: conditioned editing where the input image conditions layout via textToImageParams.conditionImage + controlMode (CANNY_EDGE | SEGMENTATION) + controlStrength (issue #39552) @@ -94,7 +94,7 @@ def _nova_canvas_task_body( raise ValueError( f"Unsupported Amazon Nova Canvas controlMode: {control_mode!r}. Use one of {NOVA_CANVAS_CONTROL_MODES}." ) - t2i_params: Final[dict[str, object]] = { + t2i_params: Final[dict[str, object]] = { # mutable-ok: optional conditioned-editing keys are set below "text": text, "conditionImage": image_b64, } @@ -104,7 +104,7 @@ def _nova_canvas_task_body( t2i_params["controlMode"] = control_mode if control_strength is not None: t2i_params["controlStrength"] = control_strength - return { + return { # mutable-ok: InvokeModel JSON body is a plain dict "taskType": "TEXT_IMAGE", "textToImageParams": t2i_params, } diff --git a/litellm/llms/bedrock/videos/__init__.py b/litellm/llms/bedrock/videos/__init__.py index ee89c374d64..382fd0a58ee 100644 --- a/litellm/llms/bedrock/videos/__init__.py +++ b/litellm/llms/bedrock/videos/__init__.py @@ -1,8 +1,8 @@ -""" -Bedrock video generation (Amazon Nova Reel via StartAsyncInvoke). -""" +"""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__ = ["BedrockNovaReelVideoConfig", "BedrockVideoGeneration"] +__all__: Final[tuple[str, ...]] = ("BedrockNovaReelVideoConfig", "BedrockVideoGeneration") diff --git a/litellm/llms/bedrock/videos/handler.py b/litellm/llms/bedrock/videos/handler.py index 32dbe1d8d11..3b216acc9ca 100644 --- a/litellm/llms/bedrock/videos/handler.py +++ b/litellm/llms/bedrock/videos/handler.py @@ -15,7 +15,8 @@ Mirrors the URL + signing conventions of from __future__ import annotations import json -from typing import TYPE_CHECKING, Any, Final +from collections.abc import Coroutine, Mapping, Sequence +from typing import TYPE_CHECKING, Final, TypeAlias from urllib.parse import quote import httpx @@ -23,6 +24,11 @@ import httpx import litellm 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 @@ -37,11 +43,10 @@ from ..base_aws_llm import ( from ..common_utils import BedrockError if TYPE_CHECKING: - from botocore.awsrequest import AWSPreparedRequest as _AWSPreparedRequest - from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging -else: - _AWSPreparedRequest = Any + +_LitellmParamsDict: TypeAlias = dict[str, object] +_ExtraHeadersDict: TypeAlias = dict[str, object] DEFAULT_VIDEO_REGION: Final = "us-west-2" NOVA_REEL_OUTPUT_FILENAME: Final = "output.mp4" @@ -50,7 +55,7 @@ NOVA_REEL_OUTPUT_FILENAME: Final = "output.mp4" def _sign_get_request( credentials: Credentials | None, url: str, - headers: dict[str, str], + headers: Mapping[str, str], aws_region_name: str, bearer_token: str | None = None, ) -> AWSPreparedRequest: @@ -60,13 +65,20 @@ def _sign_get_request( except ImportError: raise ImportError("Missing boto3 to call bedrock. Run 'pip install boto3'.") - if bearer_token is not None: - headers = {**headers, "Authorization": f"Bearer {bearer_token}"} - request: Final = AWSRequest(method="GET", url=url, data=None, headers=headers) - return request.prepare() - - request = AWSRequest(method="GET", url=url, data=None, headers=headers) - if credentials is not None: + 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 not None and bearer_token is None: SigV4Auth(credentials, "bedrock", aws_region_name).add_auth(request) return request.prepare() @@ -89,15 +101,28 @@ def _parse_s3_uri(s3_uri: str) -> tuple[str, str]: return bucket, prefix -def _params_to_dict(litellm_params: GenericLiteLLMParams | dict | None) -> dict: +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 {} + return {} # mutable-ok: empty params dict for None input if isinstance(litellm_params, dict): - return dict(litellm_params) - if hasattr(litellm_params, "model_dump"): + 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) + return dict(litellm_params) # mutable-ok: aws_* keys are popped in place downstream class BedrockVideoGeneration(BaseAWSLLM): @@ -137,12 +162,12 @@ class BedrockVideoGeneration(BaseAWSLLM): self, model: str, prompt: str, - optional_params: dict, + optional_params: _LitellmParamsDict, api_base: str | None, - extra_headers: dict | None, + extra_headers: _ExtraHeadersDict | None, logging_obj: LiteLLMLogging | None, api_key: str | None = None, - ) -> tuple[str, _AWSPreparedRequest, bytes, dict]: + ) -> tuple[str, AWSPreparedRequest, bytes, _LitellmParamsDict]: """ Returns (endpoint_url, prepped_request, body, data) for POST /async-invoke. """ @@ -170,17 +195,17 @@ class BedrockVideoGeneration(BaseAWSLLM): api_base=endpoint_url, video_create_optional_request_params=optional_params, litellm_params=GenericLiteLLMParams(), - headers={}, + 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 = {"Content-Type": "application/json"} - if extra_headers is not None: - headers = {"Content-Type": "application/json", **extra_headers} - + 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, @@ -195,7 +220,7 @@ class BedrockVideoGeneration(BaseAWSLLM): logging_obj.pre_call( input=prompt, api_key="", - additional_args={ + additional_args={ # mutable-ok: logging payload dict built for this call "complete_input_dict": data, "api_base": endpoint_url, "headers": prepped.headers, @@ -207,7 +232,7 @@ class BedrockVideoGeneration(BaseAWSLLM): self, model: str, response: httpx.Response, - data: dict, + data: Mapping[str, object], logging_obj: LiteLLMLogging | None, ) -> VideoObject: if logging_obj is not None: @@ -215,7 +240,7 @@ class BedrockVideoGeneration(BaseAWSLLM): input="", api_key="", original_response=response.text, - additional_args={"complete_input_dict": data}, + additional_args={"complete_input_dict": data}, # mutable-ok: logging payload dict built for this call ) if response.status_code not in (200, 201): raise BedrockError( @@ -236,17 +261,18 @@ class BedrockVideoGeneration(BaseAWSLLM): self, model: str, prompt: str, - optional_params: dict, + optional_params: _LitellmParamsDict, logging_obj: LiteLLMLogging | None, timeout: float | httpx.Timeout | None, avideo_generation: bool = False, - client: Any = None, + client: httpx.Client | httpx.AsyncClient | None = None, api_base: str | None = None, - extra_headers: dict | None = None, + extra_headers: _ExtraHeadersDict | None = None, api_key: str | None = None, - ) -> VideoObject: + ) -> 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( # type: ignore[no-any-return] + return self.async_video_generation( model=model, prompt=prompt, optional_params=optional_params, @@ -269,7 +295,7 @@ class BedrockVideoGeneration(BaseAWSLLM): ) from litellm.llms.custom_httpx.http_handler import _get_httpx_client - sync_client = client if isinstance(client, httpx.Client) else _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, @@ -292,12 +318,12 @@ class BedrockVideoGeneration(BaseAWSLLM): self, model: str, prompt: str, - optional_params: dict, + optional_params: _LitellmParamsDict, logging_obj: LiteLLMLogging | None, timeout: float | httpx.Timeout | None, - client: Any = None, + client: httpx.Client | httpx.AsyncClient | None = None, api_base: str | None = None, - extra_headers: dict | None = None, + extra_headers: _ExtraHeadersDict | None = None, api_key: str | None = None, ) -> VideoObject: from litellm.llms.custom_httpx.http_handler import get_async_httpx_client @@ -311,12 +337,12 @@ class BedrockVideoGeneration(BaseAWSLLM): logging_obj=logging_obj, api_key=api_key, ) - async_client = ( + async_client: Final = ( client if isinstance(client, httpx.AsyncClient) else get_async_httpx_client( llm_provider=litellm.LlmProviders.BEDROCK, - params={"timeout": timeout}, + params={"timeout": timeout}, # mutable-ok: per-call timeout kwargs for the shared client factory ) ) try: @@ -364,7 +390,7 @@ class BedrockVideoGeneration(BaseAWSLLM): prepped: Final = _sign_get_request( credentials=credentials, url=status_url, - headers={"Content-Type": "application/json"}, + headers={"Content-Type": "application/json"}, # mutable-ok: SigV4 signs a plain headers dict aws_region_name=aws_region_name, bearer_token=bearer_token, ) @@ -398,7 +424,7 @@ class BedrockVideoGeneration(BaseAWSLLM): model: str, video_id: str, logging_obj: LiteLLMLogging | None, - ) -> tuple[VideoObject, dict]: + ) -> tuple[VideoObject, BedrockGetAsyncInvokeResponse]: if response.status_code != 200: raise BedrockError( status_code=response.status_code, @@ -406,7 +432,7 @@ class BedrockVideoGeneration(BaseAWSLLM): headers=response.headers, response=response, ) - raw: Final[dict] = response.json() + raw: Final[BedrockGetAsyncInvokeResponse] = response.json() config: Final = BedrockNovaReelVideoConfig() video_obj = config.transform_video_status_retrieve_response( raw_response=response, @@ -419,19 +445,18 @@ class BedrockVideoGeneration(BaseAWSLLM): def video_status( self, video_id: str, - litellm_params: GenericLiteLLMParams | dict | None = None, + 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, - ) -> VideoObject: + ) -> 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[dict] = _params_to_dict(litellm_params) + 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( # type: ignore[no-any-return] - prepped=prepped, model=model, video_id=video_id, logging_obj=logging_obj - ) + return self.async_video_status(prepped=prepped, model=model, video_id=video_id, logging_obj=logging_obj) response: Final = self._sync_get(prepped) video_obj, _ = self._map_status_response(response, model, video_id, logging_obj) return video_obj @@ -450,14 +475,14 @@ class BedrockVideoGeneration(BaseAWSLLM): def video_content( self, video_id: str, - litellm_params: GenericLiteLLMParams | dict | None = None, + litellm_params: GenericLiteLLMParams | Mapping[str, object] | None = None, logging_obj: LiteLLMLogging | None = None, api_base: str | None = None, api_key: str | 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[dict] = _params_to_dict(litellm_params) + optional_params: Final[_LitellmParamsDict] = _params_to_dict(litellm_params) _, prepped = self._status_request_parts(invocation_arn, optional_params, api_base, api_key=api_key) response: Final = self._sync_get(prepped) video_obj, raw = self._map_status_response(response, model, video_id, logging_obj) @@ -467,46 +492,48 @@ class BedrockVideoGeneration(BaseAWSLLM): f"(status={video_obj.status}). Check video_status() before downloading." ) - output_config: Final = (raw.get("outputDataConfig") or {}).get("s3OutputDataConfig") or {} - s3_uri: Final = output_config.get("s3Uri") + s3_uri: Final[str | None] = _s3_uri_from_output_config(raw.get("outputDataConfig")) if not s3_uri: raise ValueError(f"No S3 output location on completed invocation: {raw}") bucket, prefix = _parse_s3_uri(s3_uri) # Nova writes output.mp4 into a per-invocation folder (v1:1 docs); older # v1:0 flows placed it directly under the configured prefix. - key_candidates: Final = [ + key_candidates: Final = [ # 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("/"), ] - return self._download_s3_object(bucket, key_candidates, _params_to_dict(litellm_params), raw) + return self._download_s3_object(bucket, key_candidates, litellm_params, raw) - def _download_s3_object(self, bucket: str, key_candidates: list[str], litellm_params: dict, raw: dict) -> bytes: + def _download_s3_object( + self, + bucket: str, + key_candidates: Sequence[str], + litellm_params: GenericLiteLLMParams | Mapping[str, object] | None, + raw: BedrockGetAsyncInvokeResponse, + ) -> bytes: try: import boto3 from botocore.exceptions import ClientError except ImportError: raise ImportError("Missing boto3 to download Nova Reel output. Run 'pip install boto3'.") - optional_params: Final[dict] = _params_to_dict(litellm_params) + optional_params: Final[_LitellmParamsDict] = _params_to_dict(litellm_params) credentials, region = self._load_credentials(optional_params) - session_kwargs: Final[dict[str, Any]] = {"region_name": region} + session_kwargs: Final[dict[str, str]] = {"region_name": region} # mutable-ok: credential keys are added below if credentials is not None: - session_kwargs.update( - { - "aws_access_key_id": credentials.access_key, - "aws_secret_access_key": credentials.secret_key, - **({"aws_session_token": credentials.token} if credentials.token else {}), - } - ) + 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 session: Final = boto3.Session(**session_kwargs) s3_client: Final = session.client("s3") - errors: list[str] = [] + errors: list[str] = [] # mutable-ok: error strings accumulate across candidate keys for key in key_candidates: try: - obj: Final = s3_client.get_object(Bucket=bucket, Key=key) - content: Final = obj["Body"].read() + obj = s3_client.get_object(Bucket=bucket, Key=key) + content = obj["Body"].read() return bytes(content) except ClientError as err: errors.append(f"s3://{bucket}/{key}: {err}") @@ -516,5 +543,5 @@ class BedrockVideoGeneration(BaseAWSLLM): "Nova Reel output video not found in the S3 output location. " f"Tried: {key_candidates}. Errors: {errors}. Raw invocation: {raw}" ), - headers={}, + 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 index 24379b9e62d..de586ce0b4c 100644 --- a/litellm/llms/bedrock/videos/transformation.py +++ b/litellm/llms/bedrock/videos/transformation.py @@ -22,7 +22,9 @@ from __future__ import annotations import base64 import binascii -from typing import TYPE_CHECKING, Any, Final +from collections.abc import Mapping +from types import MappingProxyType +from typing import TYPE_CHECKING, Final, TypeAlias import httpx from httpx._types import FileContent, RequestFiles @@ -30,6 +32,10 @@ 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 ( @@ -39,19 +45,28 @@ from litellm.types.videos.utils import ( ) 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 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 async-invoke status enum (bedrock-runtime service model) -> OpenAI-style # VideoObject.status values used across LiteLLM video providers. -NOVA_REEL_STATUS_MAP: Final[dict[str, str]] = { - "InProgress": "processing", - "Completed": "completed", - "Failed": "failed", -} +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 " @@ -87,13 +102,32 @@ def _file_content_to_b64_and_format(image: FileContent) -> tuple[str, str]: 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.""" + if request_data is None: + return None + model_input: Final[object | None] = request_data.get("modelInput") + if not isinstance(model_input, Mapping): + return None + 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 + + class BedrockNovaReelVideoConfig(BaseVideoConfig): """ Video config for amazon.nova-reel-v1:0 (and regional variants) on Bedrock. """ - def get_supported_openai_params(self, model: str) -> list: - return [ + def get_supported_openai_params(self, model: str) -> _SupportedParams: + return [ # mutable-ok: BaseVideoConfig requires a list "seconds", "size", "seed", @@ -111,21 +145,21 @@ class BedrockNovaReelVideoConfig(BaseVideoConfig): video_create_optional_params: VideoCreateOptionalRequestParams, model: str, drop_params: bool, - ) -> dict: + ) -> _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) + return dict(video_create_optional_params) # mutable-ok: BaseVideoConfig requires a mutable param mapping def validate_environment( self, - headers: dict, + headers: _VideoHeaders, model: str, api_key: str | None = None, litellm_params: GenericLiteLLMParams | None = None, - ) -> dict: + ) -> _VideoHeaders: if headers is None: - headers = {} + 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 @@ -143,7 +177,7 @@ class BedrockNovaReelVideoConfig(BaseVideoConfig): self, model: str, api_base: str | None, - litellm_params: dict, + litellm_params: _VideoParams, ) -> str: raise NotImplementedError( "Nova Reel video URLs are built in BedrockVideoGeneration " @@ -156,17 +190,19 @@ class BedrockNovaReelVideoConfig(BaseVideoConfig): model: str, prompt: str, api_base: str, - video_create_optional_request_params: dict, + video_create_optional_request_params: _VideoParams, litellm_params: GenericLiteLLMParams, - headers: dict, - ) -> tuple[dict, RequestFiles, str]: + headers: _VideoHeaders, + ) -> tuple[_VideoParams, RequestFiles, str]: """ Build the StartAsyncInvoke request body. - Returns (request_body, None, "POST") where request_body is the wrapped + Returns (request_body, files, "POST") where request_body is the wrapped async-invoke envelope {modelId, modelInput, outputDataConfig}. """ - op: Final = dict(video_create_optional_request_params) + 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(): @@ -178,26 +214,30 @@ class BedrockNovaReelVideoConfig(BaseVideoConfig): task_type: Final = op.pop("taskType", "TEXT_VIDEO") - text_to_video_params: Final[dict[str, object]] = {"text": prompt} + text_to_video_params: Final[_VideoParams] = {"text": prompt} input_reference: Final = op.pop("input_reference", None) or op.pop("image", None) if input_reference is not None: if isinstance(input_reference, dict): # Pre-built provider shape: {"format": ..., "source": {...}} - text_to_video_params["images"] = [input_reference] + 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) - text_to_video_params["images"] = [{"format": image_format, "source": {"bytes": image_b64}}] + image_b64, image_format = _file_content_to_b64_and_format( + input_reference # pyright: ignore[reportArgumentType] # request params are untyped user input; the helper validates and raises for unsupported shapes + ) + 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 + ] - generation_config: Final[dict[str, object]] = { + generation_config: Final[_VideoParams] = { "durationSeconds": NOVA_REEL_DEFAULT_DURATION_SECONDS, "fps": NOVA_REEL_DEFAULT_FPS, "dimension": NOVA_REEL_DEFAULT_DIMENSION, } seconds: Final = op.pop("seconds", None) - if seconds is not None: + if isinstance(seconds, (int, float, str)): try: generation_config["durationSeconds"] = int(float(seconds)) - except (TypeError, ValueError): + except ValueError: verbose_logger.debug("Nova Reel ignoring non-numeric seconds=%r", seconds) size: Final = op.pop("size", None) if size is not None and isinstance(size, str) and "x" in size: @@ -212,7 +252,7 @@ class BedrockNovaReelVideoConfig(BaseVideoConfig): if seed is not None: generation_config["seed"] = seed - model_input: Final[dict[str, object]] = { + model_input: Final[_VideoParams] = { "taskType": task_type, "textToVideoParams": text_to_video_params, "videoGenerationConfig": generation_config, @@ -222,25 +262,25 @@ class BedrockNovaReelVideoConfig(BaseVideoConfig): # through verbatim on the modelInput body. model_input.update(op) - request_body: Final[dict[str, object]] = { + request_body: Final[_VideoParams] = { "modelId": model, "modelInput": model_input, "outputDataConfig": {"s3OutputDataConfig": {"s3Uri": output_s3_uri}}, } - return request_body, None, "POST" + 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: Any, + logging_obj: LiteLLMLogging | None, custom_llm_provider: str | None = None, - request_data: dict | None = None, + request_data: Mapping[str, object] | None = None, ) -> VideoObject: from litellm.types.videos.main import VideoObject - response_data: Final = raw_response.json() - invocation_arn: Final = response_data.get("invocationArn") + response_data: Final[BedrockStartAsyncInvokeResponse] = raw_response.json() + 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( @@ -250,16 +290,11 @@ class BedrockNovaReelVideoConfig(BaseVideoConfig): model=model, created_at=_epoch_now(), ) - usage: Final[dict[str, object]] = {} - duration: Final = ( - ((request_data or {}).get("modelInput") or {}).get("videoGenerationConfig", {}).get("durationSeconds") - ) - if duration is not None: + 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. - usage["duration_seconds"] = float(duration) - if usage: - video_obj.usage = usage + video_obj.usage = {"duration_seconds": duration_seconds} # mutable-ok: VideoObject.usage payload dict return video_obj def transform_video_status_retrieve_request( @@ -267,8 +302,8 @@ class BedrockNovaReelVideoConfig(BaseVideoConfig): video_id: str, api_base: str, litellm_params: GenericLiteLLMParams, - headers: dict, - ) -> tuple[str, dict]: + 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." @@ -277,21 +312,22 @@ class BedrockNovaReelVideoConfig(BaseVideoConfig): def transform_video_status_retrieve_response( self, raw_response: httpx.Response, - logging_obj: Any, + logging_obj: LiteLLMLogging | None, custom_llm_provider: str | None = None, - client: Any = None, + client: HTTPHandler | None = None, model: str | None = None, video_id: str | None = None, ) -> VideoObject: from litellm.types.videos.main import VideoObject - response_data: Final[dict] = raw_response.json() - invocation_arn: Final = response_data.get("invocationArn") + response_data: Final[BedrockGetAsyncInvokeResponse] = raw_response.json() + 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}") - raw_status: Final = str(response_data.get("status") or response_data.get("invocationStatus") or "InProgress") - status: Final = NOVA_REEL_STATUS_MAP.get(raw_status, "processing") + raw_status: Final[str] = str(response_data.get("status") or "InProgress") + 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", @@ -300,15 +336,18 @@ class BedrockNovaReelVideoConfig(BaseVideoConfig): created_at=_to_epoch(response_data.get("submitTime")), completed_at=(_to_epoch(response_data.get("endTime")) if status == "completed" else None), error=( - {"message": response_data["failureMessage"]} - if status == "failed" and response_data.get("failureMessage") + {"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") or {}).get("s3OutputDataConfig") or {} - s3_uri: Final = output_config.get("s3Uri") - if s3_uri: - video_obj.usage = {"output_s3_uri": s3_uri} + 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: + video_obj.usage = {"output_s3_uri": s3_uri} # mutable-ok: VideoObject.usage payload dict return video_obj def transform_video_content_request( @@ -316,9 +355,9 @@ class BedrockNovaReelVideoConfig(BaseVideoConfig): video_id: str, api_base: str, litellm_params: GenericLiteLLMParams, - headers: dict, + headers: _VideoHeaders, variant: str | None = None, - ) -> tuple[str, dict]: + ) -> 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." @@ -327,7 +366,7 @@ class BedrockNovaReelVideoConfig(BaseVideoConfig): def transform_video_content_response( self, raw_response: httpx.Response, - logging_obj: Any, + logging_obj: LiteLLMLogging | None, ) -> bytes: return raw_response.content @@ -337,15 +376,15 @@ class BedrockNovaReelVideoConfig(BaseVideoConfig): prompt: str, api_base: str, litellm_params: GenericLiteLLMParams, - headers: dict, - extra_body: dict[str, object] | None = None, - ) -> tuple[str, dict]: + headers: _VideoHeaders, + extra_body: Mapping[str, object] | None = None, + ) -> tuple[str, _VideoParams]: raise NotImplementedError(_UNSUPPORTED_MESSAGE.format(operation="remix")) def transform_video_remix_response( self, raw_response: httpx.Response, - logging_obj: Any, + logging_obj: LiteLLMLogging | None, custom_llm_provider: str | None = None, ) -> VideoObject: raise NotImplementedError(_UNSUPPORTED_MESSAGE.format(operation="remix")) @@ -354,20 +393,20 @@ class BedrockNovaReelVideoConfig(BaseVideoConfig): self, api_base: str, litellm_params: GenericLiteLLMParams, - headers: dict, + headers: _VideoHeaders, after: str | None = None, limit: int | None = None, order: str | None = None, - extra_query: dict[str, object] | None = None, - ) -> tuple[str, dict]: + extra_query: Mapping[str, object] | None = None, + ) -> tuple[str, _VideoParams]: raise NotImplementedError(_UNSUPPORTED_MESSAGE.format(operation="list")) def transform_video_list_response( self, raw_response: httpx.Response, - logging_obj: Any, + logging_obj: LiteLLMLogging | None, custom_llm_provider: str | None = None, - ) -> dict[str, str]: + ) -> _VideoStringParams: raise NotImplementedError(_UNSUPPORTED_MESSAGE.format(operation="list")) def transform_video_delete_request( @@ -375,14 +414,14 @@ class BedrockNovaReelVideoConfig(BaseVideoConfig): video_id: str, api_base: str, litellm_params: GenericLiteLLMParams, - headers: dict, - ) -> tuple[str, dict]: + headers: _VideoHeaders, + ) -> tuple[str, _VideoParams]: raise NotImplementedError(_UNSUPPORTED_MESSAGE.format(operation="delete")) def transform_video_delete_response( self, raw_response: httpx.Response, - logging_obj: Any, + logging_obj: LiteLLMLogging | None, ) -> VideoObject: raise NotImplementedError(_UNSUPPORTED_MESSAGE.format(operation="delete")) @@ -400,7 +439,7 @@ def _epoch_now() -> int: return int(time.time()) -def _to_epoch(timestamp: Any) -> int | None: +def _to_epoch(timestamp: str | float | None) -> int | None: if timestamp is None: return None try: diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index a3f1f9037ae..0d5f48758d4 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -295,8 +295,8 @@ "amazon.nova-reel-v1:0": { "litellm_provider": "bedrock", "mode": "video_generation", - "output_cost_per_second": 0.012, - "source": "https://aws.amazon.com/bedrock/pricing/", + "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"] @@ -304,8 +304,8 @@ "amazon.nova-reel-v1:1": { "litellm_provider": "bedrock", "mode": "video_generation", - "output_cost_per_second": 0.012, - "source": "https://aws.amazon.com/bedrock/pricing/", + "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"] @@ -313,8 +313,8 @@ "us.amazon.nova-reel-v1:0": { "litellm_provider": "bedrock", "mode": "video_generation", - "output_cost_per_second": 0.012, - "source": "https://aws.amazon.com/bedrock/pricing/", + "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"] @@ -322,8 +322,8 @@ "us.amazon.nova-reel-v1:1": { "litellm_provider": "bedrock", "mode": "video_generation", - "output_cost_per_second": 0.012, - "source": "https://aws.amazon.com/bedrock/pricing/", + "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"] diff --git a/litellm/types/llms/bedrock.py b/litellm/types/llms/bedrock.py index a4e45f3e101..0ebb2ab8bca 100644 --- a/litellm/types/llms/bedrock.py +++ b/litellm/types/llms/bedrock.py @@ -917,23 +917,25 @@ class AmazonNovaCanvasTextToImageParams(TypedDict, total=False): conditionImage + controlMode + controlStrength enable conditioned editing (SEGMENTATION derives a segmentation mask from the condition image; - CANNY_EDGE follows its prominent contours — the AWS default). + CANNY_EDGE follows its prominent contours and is the AWS default). """ text: str negativeText: str controlStrength: float - controlMode: Literal["CANNY_EDGE", "SEGMENTATION"] + controlMode: ReadOnly[Literal["CANNY_EDGE", "SEGMENTATION"]] conditionImage: str - style: Literal[ - "3D_ANIMATED_FAMILY_FILM", - "DESIGN_SKETCH", - "FLAT_VECTOR_ILLUSTRATION", - "GRAPHIC_NOVEL_ILLUSTRATION", - "MAXIMALISM", - "MIDCENTURY_RETRO", - "PHOTOREALISM", - "SOFT_DIGITAL_PAINTING", + style: ReadOnly[ + Literal[ + "3D_ANIMATED_FAMILY_FILM", + "DESIGN_SKETCH", + "FLAT_VECTOR_ILLUSTRATION", + "GRAPHIC_NOVEL_ILLUSTRATION", + "MAXIMALISM", + "MIDCENTURY_RETRO", + "PHOTOREALISM", + "SOFT_DIGITAL_PAINTING", + ] ] @@ -1027,8 +1029,8 @@ class AmazonNovaReelS3Location(TypedDict, total=False): Ref: https://docs.aws.amazon.com/nova/latest/userguide/video-req-resp-structure.html """ - uri: str - bucketOwner: str + uri: ReadOnly[str] + bucketOwner: ReadOnly[str] class AmazonNovaReelImageSourceLocation(TypedDict, total=False): @@ -1036,8 +1038,8 @@ class AmazonNovaReelImageSourceLocation(TypedDict, total=False): Location of a Nova Reel input image: inline base64 bytes or S3. """ - bytes: str # base64 encoded image - s3Location: AmazonNovaReelS3Location + bytes: ReadOnly[str] # base64 encoded image + s3Location: ReadOnly[AmazonNovaReelS3Location] class AmazonNovaReelImageSource(TypedDict, total=False): @@ -1045,8 +1047,8 @@ class AmazonNovaReelImageSource(TypedDict, total=False): Image source for Nova Reel textToVideoParams.images entries. """ - format: Literal["png", "jpeg"] - source: AmazonNovaReelImageSourceLocation + format: ReadOnly[Literal["png", "jpeg"]] + source: ReadOnly[AmazonNovaReelImageSourceLocation] class AmazonNovaReelTextToVideoParams(TypedDict, total=False): @@ -1056,8 +1058,8 @@ class AmazonNovaReelTextToVideoParams(TypedDict, total=False): Ref: https://docs.aws.amazon.com/nova/latest/userguide/video-req-resp-structure.html """ - text: str - images: list[AmazonNovaReelImageSource] + text: ReadOnly[str] + images: ReadOnly[Sequence[AmazonNovaReelImageSource]] class AmazonNovaReelVideoGenerationConfig(TypedDict, total=False): @@ -1069,10 +1071,10 @@ class AmazonNovaReelVideoGenerationConfig(TypedDict, total=False): (v1:0 also supports "720x1280"). seed: 0-2147483646, AWS default 42. """ - durationSeconds: int - fps: int - dimension: str - seed: int + durationSeconds: ReadOnly[int] + fps: ReadOnly[int] + dimension: ReadOnly[str] + seed: ReadOnly[int] class AmazonNovaReelModelInput(TypedDict, total=False): @@ -1080,9 +1082,9 @@ class AmazonNovaReelModelInput(TypedDict, total=False): modelInput body for Nova Reel StartAsyncInvoke. """ - taskType: NOVA_REEL_TASK_TYPES - textToVideoParams: AmazonNovaReelTextToVideoParams - videoGenerationConfig: AmazonNovaReelVideoGenerationConfig + taskType: ReadOnly[NOVA_REEL_TASK_TYPES] + textToVideoParams: ReadOnly[AmazonNovaReelTextToVideoParams] + videoGenerationConfig: ReadOnly[AmazonNovaReelVideoGenerationConfig] class BedrockAsyncInvokeS3OutputDataConfig(TypedDict, total=False): @@ -1092,9 +1094,9 @@ class BedrockAsyncInvokeS3OutputDataConfig(TypedDict, total=False): Ref: bedrock-runtime service model (StartAsyncInvokeRequest.outputDataConfig) """ - s3Uri: str - kmsKeyId: str - bucketOwner: str + s3Uri: ReadOnly[str] + kmsKeyId: ReadOnly[str] + bucketOwner: ReadOnly[str] class BedrockAsyncInvokeOutputDataConfig(TypedDict, total=False): @@ -1102,7 +1104,7 @@ class BedrockAsyncInvokeOutputDataConfig(TypedDict, total=False): Output data config for Bedrock StartAsyncInvoke. """ - s3OutputDataConfig: BedrockAsyncInvokeS3OutputDataConfig + s3OutputDataConfig: ReadOnly[BedrockAsyncInvokeS3OutputDataConfig] class BedrockStartAsyncInvokeRequest(TypedDict, total=False): @@ -1112,10 +1114,10 @@ class BedrockStartAsyncInvokeRequest(TypedDict, total=False): Ref: https://docs.aws.amazon.com/nova/latest/userguide/video-gen-access.html """ - modelId: str - modelInput: AmazonNovaReelModelInput - outputDataConfig: BedrockAsyncInvokeOutputDataConfig - clientRequestToken: str + modelId: ReadOnly[str] + modelInput: ReadOnly[AmazonNovaReelModelInput] + outputDataConfig: ReadOnly[BedrockAsyncInvokeOutputDataConfig] + clientRequestToken: ReadOnly[str] class BedrockStartAsyncInvokeResponse(TypedDict, total=False): @@ -1123,7 +1125,7 @@ class BedrockStartAsyncInvokeResponse(TypedDict, total=False): Response body for POST {runtime}/async-invoke. """ - invocationArn: str + invocationArn: ReadOnly[str] BEDROCK_ASYNC_INVOKE_STATUSES = Literal["InProgress", "Completed", "Failed"] @@ -1138,15 +1140,15 @@ class BedrockGetAsyncInvokeResponse(TypedDict, total=False): present when status is Failed. """ - invocationArn: str - modelArn: str - clientRequestToken: str - status: BEDROCK_ASYNC_INVOKE_STATUSES - failureMessage: str - submitTime: float - lastModifiedTime: float - endTime: float - outputDataConfig: BedrockAsyncInvokeOutputDataConfig + invocationArn: ReadOnly[str] + modelArn: ReadOnly[str] + clientRequestToken: ReadOnly[str] + status: ReadOnly[BEDROCK_ASYNC_INVOKE_STATUSES] + failureMessage: ReadOnly[str] + submitTime: ReadOnly[float] + lastModifiedTime: ReadOnly[float] + endTime: ReadOnly[float] + outputDataConfig: ReadOnly[BedrockAsyncInvokeOutputDataConfig] if TYPE_CHECKING: diff --git a/litellm/videos/main.py b/litellm/videos/main.py index 212d6051f7a..137f16a2e62 100644 --- a/litellm/videos/main.py +++ b/litellm/videos/main.py @@ -247,11 +247,17 @@ def video_generation( if custom_llm_provider == "bedrock": from litellm.llms.bedrock.videos.handler import BedrockVideoGeneration - bedrock_optional_params: Final[dict] = dict(video_generation_request_params) + bedrock_optional_params: Final[dict] = ( + dict( # mutable-ok: aws_* params are merged in before the handler call + video_generation_request_params + ) + ) # aws_* auth params ride on litellm_params; surface them for the handler # (mirrors how images/main.py merges non_default_params for bedrock). bedrock_optional_params.update( - {k: v for k, v in litellm_params.model_dump(exclude_none=True).items() if k.startswith("aws_")} + { # 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, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index a3f1f9037ae..0d5f48758d4 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -295,8 +295,8 @@ "amazon.nova-reel-v1:0": { "litellm_provider": "bedrock", "mode": "video_generation", - "output_cost_per_second": 0.012, - "source": "https://aws.amazon.com/bedrock/pricing/", + "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"] @@ -304,8 +304,8 @@ "amazon.nova-reel-v1:1": { "litellm_provider": "bedrock", "mode": "video_generation", - "output_cost_per_second": 0.012, - "source": "https://aws.amazon.com/bedrock/pricing/", + "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"] @@ -313,8 +313,8 @@ "us.amazon.nova-reel-v1:0": { "litellm_provider": "bedrock", "mode": "video_generation", - "output_cost_per_second": 0.012, - "source": "https://aws.amazon.com/bedrock/pricing/", + "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"] @@ -322,8 +322,8 @@ "us.amazon.nova-reel-v1:1": { "litellm_provider": "bedrock", "mode": "video_generation", - "output_cost_per_second": 0.012, - "source": "https://aws.amazon.com/bedrock/pricing/", + "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"] diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index b8d1621cde3..5fc09a61f72 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -267,7 +267,8 @@ "vector_stores_search": true, "count_tokens": true, "rag_ingest": true, - "rag_query": true + "rag_query": true, + "video_generations": true } }, "s3_vectors": { From e78b14d351288635fc9c30e8a63cc53010a52c15 Mon Sep 17 00:00:00 2001 From: lokm01 <2735155+lokm01@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:03:16 +0200 Subject: [PATCH 06/30] test(bedrock): mirror nova reel video tests into CI-covered test_litellm tree The nova reel video tests only lived under tests/unit, which the GH Actions unit workflow never collects: the All Other Providers shard runs tests/test_litellm/llms and the misc shard runs tests/test_litellm/videos, so regressions in this config would not be caught on any PR. Copy the suite verbatim to tests/test_litellm/llms/bedrock/videos/ (own imports, empty __init__.py matching the sibling batches/chat dirs) and keep the tests/unit copy as-is. Also updates the shared files assertion to files == [] now that transform_video_create_request returns an empty list per the RequestFiles contract instead of None. --- .../llms/bedrock/videos/__init__.py | 0 .../videos/test_amazon_nova_reel_video.py | 363 ++++++++++++++++++ .../videos/test_amazon_nova_reel_video.py | 55 +-- 3 files changed, 377 insertions(+), 41 deletions(-) create mode 100644 tests/test_litellm/llms/bedrock/videos/__init__.py create mode 100644 tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py diff --git a/tests/test_litellm/llms/bedrock/videos/__init__.py b/tests/test_litellm/llms/bedrock/videos/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py b/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py new file mode 100644 index 00000000000..177cd33fc1f --- /dev/null +++ b/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py @@ -0,0 +1,363 @@ +"""Unit tests for Bedrock Amazon Nova Reel video generation (issue #39552).""" + +import base64 +import io +import json +from typing import cast +from unittest.mock import Mock, patch + +import httpx +import pytest + +import litellm +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" + + +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, +) -> 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=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(): + png_bytes = b"\x89PNG\r\n\x1a\n" + b"0" * 8 + 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(): + jpeg_bytes = b"\xff\xd8\xff\xe0" + b"0" * 8 + 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_requires_output_s3_uri(): + with pytest.raises(ValueError, match="output_s3_uri"): + _create_request({}) + + +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 + assert video.usage is not None + assert video.usage["output_s3_uri"] == "s3://bucket/out/" + decoded = decode_video_id_with_provider(video.id) + assert decoded["video_id"] == TEST_ARN + + +################################################# +# 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" + + +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") + from litellm.types.videos.utils import encode_video_id_with_provider + + video_id = encode_video_id_with_provider(TEST_ARN, "bedrock", TEST_MODEL) + status_url, prepped = 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 + + +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={}), + ), + ) + 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: 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={}), + ), + ) + resp = httpx.Response( + 200, + json={ + "invocationArn": TEST_ARN, + "status": "InProgress", + "outputDataConfig": {"s3OutputDataConfig": {"s3Uri": "s3://bucket/out/"}}, + }, + ) + monkeypatch.setattr(handler, "_sync_get", lambda prepped: resp) + with pytest.raises(ValueError, match="not complete"): + handler.video_content(video_id=video_id, litellm_params={}) + + +def test_handler_video_content_downloads_from_s3(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={}), + ), + ) + resp = httpx.Response( + 200, + json={ + "invocationArn": TEST_ARN, + "status": "Completed", + "outputDataConfig": {"s3OutputDataConfig": {"s3Uri": "s3://bucket/out/"}}, + }, + ) + monkeypatch.setattr(handler, "_sync_get", lambda prepped: resp) + + downloaded_keys: list[str] = [] + + def fake_download(bucket, key_candidates, litellm_params, raw): + 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 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 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 index 9b371fb2307..177cd33fc1f 100644 --- a/tests/unit/llms/bedrock/videos/test_amazon_nova_reel_video.py +++ b/tests/unit/llms/bedrock/videos/test_amazon_nova_reel_video.py @@ -30,9 +30,7 @@ def _create_request( model: str = TEST_MODEL, ) -> dict: config = _make_config() - params: dict = ( - optional_params if optional_params is not None else {"output_s3_uri": "s3://bucket/out/"} - ) + 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, @@ -41,7 +39,7 @@ def _create_request( litellm_params=GenericLiteLLMParams(), headers={}, ) - assert files is None + assert files == [] assert method == "POST" return body @@ -66,9 +64,7 @@ def test_transform_create_request_defaults(): 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} - ) + 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" @@ -77,9 +73,7 @@ def test_transform_create_request_seconds_and_size_map_to_generation_config(): def test_transform_create_request_input_reference_becomes_images(): png_bytes = b"\x89PNG\r\n\x1a\n" + b"0" * 8 - body = _create_request( - {"output_s3_uri": "s3://bucket/out/", "input_reference": io.BytesIO(png_bytes)} - ) + 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" @@ -88,9 +82,7 @@ def test_transform_create_request_input_reference_becomes_images(): def test_transform_create_request_jpeg_detected(): jpeg_bytes = b"\xff\xd8\xff\xe0" + b"0" * 8 - body = _create_request( - {"output_s3_uri": "s3://bucket/out/", "input_reference": jpeg_bytes} - ) + body = _create_request({"output_s3_uri": "s3://bucket/out/", "input_reference": jpeg_bytes}) assert body["modelInput"]["textToVideoParams"]["images"][0]["format"] == "jpeg" @@ -100,9 +92,7 @@ def test_transform_create_request_requires_output_s3_uri(): def test_transform_create_request_task_type_passthrough(): - body = _create_request( - {"output_s3_uri": "s3://bucket/out/", "taskType": "MULTI_SHOT_AUTOMATED"} - ) + body = _create_request({"output_s3_uri": "s3://bucket/out/", "taskType": "MULTI_SHOT_AUTOMATED"}) assert body["modelInput"]["taskType"] == "MULTI_SHOT_AUTOMATED" @@ -118,9 +108,7 @@ def test_transform_create_response_maps_invocation_arn(): model=TEST_MODEL, raw_response=resp, logging_obj=None, - request_data={ - "modelInput": {"videoGenerationConfig": {"durationSeconds": 6}} - }, + request_data={"modelInput": {"videoGenerationConfig": {"durationSeconds": 6}}}, ) assert video.status == "processing" assert video.model == TEST_MODEL @@ -136,9 +124,7 @@ 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 - ) + config.transform_video_create_response(model=TEST_MODEL, raw_response=resp, logging_obj=None) @pytest.mark.parametrize( @@ -164,9 +150,7 @@ def test_transform_status_response_maps_aws_enum(raw_status, expected): "outputDataConfig": {"s3OutputDataConfig": {"s3Uri": "s3://bucket/out/"}}, }, ) - video = config.transform_video_status_retrieve_response( - raw_response=resp, logging_obj=None, model=TEST_MODEL - ) + 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"} @@ -202,27 +186,21 @@ def test_video_id_encoding_round_trip(): 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 - ) + 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 - ) + 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 - ) + cfg = ProviderConfigManager.get_provider_video_config("amazon.titan-text-express-v1", litellm.LlmProviders.BEDROCK) assert cfg is None @@ -279,15 +257,10 @@ def test_handler_builds_status_get_url(monkeypatch): from litellm.types.videos.utils import encode_video_id_with_provider video_id = encode_video_id_with_provider(TEST_ARN, "bedrock", TEST_MODEL) - status_url, prepped = handler._status_request_parts( - TEST_ARN, {"aws_region_name": "us-east-1"}, None - ) + status_url, prepped = 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="") - ) + expected = "https://bedrock-runtime.us-east-1.amazonaws.com/async-invoke/" + quote(TEST_ARN, safe="") assert status_url == expected assert prepped.url == expected From e052f5a2c026e9876b811c25005fe37bd9e88a8e Mon Sep 17 00:00:00 2001 From: lokm01 <2735155+lokm01@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:13:11 +0200 Subject: [PATCH 07/30] fix(bedrock): harden nova reel request construction and error paths Verification pass on the nova reel review round confirmed twelve findings; this fixes F1-F12 plus the test hygiene items: - request transform: input_reference and image are now both popped unconditionally (input_reference wins), so a file-like image never leaks into modelInput and json.dumps cannot TypeError (F1) - input_reference strings: data:image/...;base64, URLs are unwrapped and only the payload decoded, undecodable strings raise instead of being returned raw, and magic sniffing accepts only png/jpeg headers instead of defaulting unknown bytes to png (F2) - known non-AWS video-client params (parameters, resolution, characters, user, extra_headers) are dropped before the modelInput passthrough, and parameters is no longer advertised as a supported openai param (F3) - clientRequestToken: caller-supplied token (snake or camel key) is sanitized to AWS charset and 64 chars, else the litellm request_id from litellm_params.metadata, else the envelope key is omitted entirely (F4) - fps and seed coerce through int(float(...)) and raise naming the param on non-numeric input instead of forwarding junk values (F5) - a get-async-invoke body without a usable status raises a 500 BedrockError naming the observed keys instead of silently reporting InProgress (F6) - video_content on a Failed invocation raises a 502 BedrockError carrying failureMessage; only still-processing statuses keep the not-complete-yet ValueError (F7) - the ARN-derived region resolved for the status GET is threaded into the S3 download as the region default, with an explicit aws_region_name in litellm_params still winning over it (F8) - sync create and both status GETs forward their timeout, the async GET via the shared-client params like the create path (F9) - _transform_create_response drops the manual (200, 201) allowlist so legitimate 2xx variants behind proxies reach the transform; raise_for_status already guards non-2xx on both call paths (F10) - _sign_get_request raises NoCredentialsError when neither SigV4 credentials nor a bearer token resolve, matching the shared POST signer instead of silently sending an unsigned request (F11) - the S3 download 404 message names the s3Uri, the tried keys and the ClientError details only, no raw invocation dict with the ARN and account id (F12) Tests: both copies of the video suite gain coverage for each behavior (byte-identical md5), drop the unused patch import and unused video_id binding, update _status_request_parts mocks for the new region return and _download_s3_object for region_default, and add the missing tests/unit/llms/bedrock/videos/__init__.py per sibling convention. --- litellm/llms/bedrock/videos/handler.py | 93 +++-- litellm/llms/bedrock/videos/transformation.py | 96 ++++- .../videos/test_amazon_nova_reel_video.py | 391 +++++++++++++++++- tests/unit/llms/bedrock/videos/__init__.py | 0 .../videos/test_amazon_nova_reel_video.py | 391 +++++++++++++++++- 5 files changed, 903 insertions(+), 68 deletions(-) create mode 100644 tests/unit/llms/bedrock/videos/__init__.py diff --git a/litellm/llms/bedrock/videos/handler.py b/litellm/llms/bedrock/videos/handler.py index 3b216acc9ca..ce8b75fdf94 100644 --- a/litellm/llms/bedrock/videos/handler.py +++ b/litellm/llms/bedrock/videos/handler.py @@ -62,6 +62,7 @@ def _sign_get_request( 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'.") @@ -78,6 +79,10 @@ def _sign_get_request( 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() @@ -242,13 +247,9 @@ class BedrockVideoGeneration(BaseAWSLLM): original_response=response.text, additional_args={"complete_input_dict": data}, # mutable-ok: logging payload dict built for this call ) - if response.status_code not in (200, 201): - raise BedrockError( - status_code=response.status_code, - message=f"Nova Reel video generation error: {response.text}", - headers=response.headers, - response=response, - ) + # 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, @@ -301,6 +302,7 @@ class BedrockVideoGeneration(BaseAWSLLM): url=endpoint_url, headers=prepped.headers, content=body, + timeout=timeout, ) response.raise_for_status() except httpx.HTTPStatusError as err: @@ -369,8 +371,13 @@ class BedrockVideoGeneration(BaseAWSLLM): optional_params: dict, # mutable-ok: aws_* keys are popped in place api_base: str | None, api_key: str | None = None, - ) -> tuple[str, AWSPreparedRequest]: - """Returns (status_url, prepped_get_request) for GET /async-invoke/{arn}.""" + ) -> 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: @@ -394,7 +401,7 @@ class BedrockVideoGeneration(BaseAWSLLM): aws_region_name=aws_region_name, bearer_token=bearer_token, ) - return status_url, prepped + 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.""" @@ -406,17 +413,24 @@ class BedrockVideoGeneration(BaseAWSLLM): model: Final = decoded.get("model_id") or "amazon.nova-reel-v1:0" return invocation_arn, model - def _sync_get(self, prepped: AWSPreparedRequest) -> httpx.Response: + 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() - return client.get(url=prepped.url, headers=prepped.headers) + return client.get(url=prepped.url, headers=prepped.headers, timeout=timeout) - async def _async_get(self, prepped: AWSPreparedRequest) -> httpx.Response: + 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) - return await client.get(url=prepped.url, headers=prepped.headers) + 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 + ) + return await client.get(url=prepped.url, headers=prepped.headers, timeout=timeout) def _map_status_response( self, @@ -450,14 +464,17 @@ class BedrockVideoGeneration(BaseAWSLLM): 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) + _, 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) - response: Final = self._sync_get(prepped) + 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 @@ -467,8 +484,9 @@ class BedrockVideoGeneration(BaseAWSLLM): model: str, video_id: str, logging_obj: LiteLLMLogging | None = None, + timeout: float | httpx.Timeout | None = None, ) -> VideoObject: - response: Final = await self._async_get(prepped) + response: Final = await self._async_get(prepped, timeout=timeout) video_obj, _ = self._map_status_response(response, model, video_id, logging_obj) return video_obj @@ -479,13 +497,24 @@ class BedrockVideoGeneration(BaseAWSLLM): 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 = self._status_request_parts(invocation_arn, optional_params, api_base, api_key=api_key) - response: Final = self._sync_get(prepped) + _, 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": raise ValueError( "Nova Reel video generation is not complete yet " @@ -503,7 +532,7 @@ class BedrockVideoGeneration(BaseAWSLLM): f"{prefix}/{invocation_arn.rsplit('/', 1)[-1]}/{NOVA_REEL_OUTPUT_FILENAME}".lstrip("/"), f"{prefix}/{NOVA_REEL_OUTPUT_FILENAME}".lstrip("/"), ] - return self._download_s3_object(bucket, key_candidates, litellm_params, raw) + return self._download_s3_object(bucket, key_candidates, litellm_params, raw, region_default=status_region) def _download_s3_object( self, @@ -511,7 +540,14 @@ class BedrockVideoGeneration(BaseAWSLLM): key_candidates: Sequence[str], litellm_params: GenericLiteLLMParams | Mapping[str, object] | None, raw: BedrockGetAsyncInvokeResponse, + region_default: str | 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. + """ try: import boto3 from botocore.exceptions import ClientError @@ -519,7 +555,11 @@ class BedrockVideoGeneration(BaseAWSLLM): raise ImportError("Missing boto3 to download Nova Reel output. Run 'pip install boto3'.") optional_params: Final[_LitellmParamsDict] = _params_to_dict(litellm_params) - credentials, region = self._load_credentials(optional_params) + explicit_region: Final[str | None] = optional_params.pop("aws_region_name", None) + credentials, region = self._load_credentials( + optional_params, + aws_region_name=(explicit_region if explicit_region is not None else region_default), + ) 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 @@ -529,6 +569,7 @@ class BedrockVideoGeneration(BaseAWSLLM): session: Final = boto3.Session(**session_kwargs) s3_client: Final = session.client("s3") + s3_uri: Final[str | None] = _s3_uri_from_output_config(raw.get("outputDataConfig")) errors: list[str] = [] # mutable-ok: error strings accumulate across candidate keys for key in key_candidates: try: @@ -536,12 +577,12 @@ class BedrockVideoGeneration(BaseAWSLLM): content = obj["Body"].read() return bytes(content) except ClientError as err: - errors.append(f"s3://{bucket}/{key}: {err}") + errors.append(str(err)) raise BedrockError( status_code=404, message=( - "Nova Reel output video not found in the S3 output location. " - f"Tried: {key_candidates}. Errors: {errors}. Raw invocation: {raw}" + "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 index de586ce0b4c..3919b9bb1eb 100644 --- a/litellm/llms/bedrock/videos/transformation.py +++ b/litellm/llms/bedrock/videos/transformation.py @@ -22,6 +22,7 @@ from __future__ import annotations import base64 import binascii +import re from collections.abc import Mapping from types import MappingProxyType from typing import TYPE_CHECKING, Final, TypeAlias @@ -58,6 +59,10 @@ 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( @@ -74,16 +79,29 @@ _UNSUPPORTED_MESSAGE: Final = ( ) +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 ValueError( + "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): - # Already base64-encoded string - detect format from the decoded header. + # 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(image, validate=False) - except (binascii.Error, ValueError): - return image, "png" + image_bytes = base64.b64decode(payload, validate=True) + except (binascii.Error, ValueError) as err: + raise ValueError(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) @@ -98,7 +116,10 @@ def _file_content_to_b64_and_format(image: FileContent) -> tuple[str, str]: elif image_bytes.startswith(b"\xff\xd8"): image_format = "jpeg" else: - image_format = "png" + raise ValueError( + "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 @@ -121,6 +142,21 @@ def _duration_seconds_from_request(request_data: Mapping[str, object] | None) -> 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 from litellm_params.metadata (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 + return None + + class BedrockNovaReelVideoConfig(BaseVideoConfig): """ Video config for amazon.nova-reel-v1:0 (and regional variants) on Bedrock. @@ -135,7 +171,6 @@ class BedrockNovaReelVideoConfig(BaseVideoConfig): "dimension", "taskType", "output_s3_uri", - "parameters", "input_reference", "image", ] @@ -215,7 +250,11 @@ class BedrockNovaReelVideoConfig(BaseVideoConfig): task_type: Final = op.pop("taskType", "TEXT_VIDEO") text_to_video_params: Final[_VideoParams] = {"text": prompt} - input_reference: Final = op.pop("input_reference", None) or op.pop("image", None) + # 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 if input_reference is not None: if isinstance(input_reference, dict): # Pre-built provider shape: {"format": ..., "source": {...}} @@ -247,10 +286,16 @@ class BedrockNovaReelVideoConfig(BaseVideoConfig): generation_config["dimension"] = dimension fps: Final = op.pop("fps", None) if fps is not None: - generation_config["fps"] = fps + try: + generation_config["fps"] = int(float(fps)) + except ValueError as err: + raise ValueError(f"Nova Reel fps must be a number; got {fps!r}") from err seed: Final = op.pop("seed", None) if seed is not None: - generation_config["seed"] = seed + try: + generation_config["seed"] = int(float(seed)) + except ValueError as err: + raise ValueError(f"Nova Reel seed must be a number; got {seed!r}") from err model_input: Final[_VideoParams] = { "taskType": task_type, @@ -258,15 +303,33 @@ class BedrockNovaReelVideoConfig(BaseVideoConfig): "videoGenerationConfig": generation_config, } - # Remaining provider-specific keys (e.g. multiShotManualParams) are passed - # through verbatim on the modelInput body. + # 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) + ) + request_body: Final[_VideoParams] = { "modelId": model, "modelInput": model_input, "outputDataConfig": {"s3OutputDataConfig": {"s3Uri": output_s3_uri}}, } + 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( @@ -324,7 +387,16 @@ class BedrockNovaReelVideoConfig(BaseVideoConfig): 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}") - raw_status: Final[str] = str(response_data.get("status") or "InProgress") + 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 status: Final[str] = NOVA_REEL_STATUS_MAP.get(raw_status, "processing") failure_message: Final[str | None] = response_data.get("failureMessage") diff --git a/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py b/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py index 177cd33fc1f..4387f189eab 100644 --- a/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py +++ b/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py @@ -1,15 +1,17 @@ """Unit tests for Bedrock Amazon Nova Reel video generation (issue #39552).""" +import asyncio import base64 import io import json from typing import cast -from unittest.mock import Mock, patch +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 @@ -18,6 +20,8 @@ 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: @@ -28,6 +32,7 @@ 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/"} @@ -36,7 +41,7 @@ def _create_request( prompt=prompt, api_base="https://bedrock-runtime.us-east-1.amazonaws.com/async-invoke", video_create_optional_request_params=cast(VideoCreateOptionalRequestParams, params), - litellm_params=GenericLiteLLMParams(), + litellm_params=litellm_params or GenericLiteLLMParams(), headers={}, ) assert files == [] @@ -72,20 +77,134 @@ def test_transform_create_request_seconds_and_size_map_to_generation_config(): def test_transform_create_request_input_reference_becomes_images(): - png_bytes = b"\x89PNG\r\n\x1a\n" + b"0" * 8 - body = _create_request({"output_s3_uri": "s3://bucket/out/", "input_reference": io.BytesIO(png_bytes)}) + 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") + assert images[0]["source"]["bytes"] == base64.b64encode(PNG_BYTES).decode("utf-8") def test_transform_create_request_jpeg_detected(): - jpeg_bytes = b"\xff\xd8\xff\xe0" + b"0" * 8 - body = _create_request({"output_s3_uri": "s3://bucket/out/", "input_reference": jpeg_bytes}) + 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 (F1).""" + 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 (F1).""" + 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 (F2).""" + 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(ValueError, match="base64"): + _create_request({"output_s3_uri": "s3://bucket/out/", "input_reference": "definitely!!not!!base64"}) + + +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(ValueError, match="PNG or JPEG"): + _create_request({"output_s3_uri": "s3://bucket/out/", "input_reference": mystery_b64}) + + +def test_input_reference_https_url_raises(): + with pytest.raises(ValueError, match="base64"): + _create_request({"output_s3_uri": "s3://bucket/out/", "input_reference": "https://example.com/img.png"}) + + +def test_transform_create_request_drops_non_aws_client_params(): + """Known non-AWS video-client params are dropped; provider keys still pass through (F3).""" + 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_fps_non_numeric_raises(): + with pytest.raises(ValueError, match="fps"): + _create_request({"output_s3_uri": "s3://bucket/out/", "fps": "abc"}) + + +def test_seed_non_numeric_raises(): + with pytest.raises(ValueError, match="seed"): + _create_request({"output_s3_uri": "s3://bucket/out/", "seed": "abc"}) + + def test_transform_create_request_requires_output_s3_uri(): with pytest.raises(ValueError, match="output_s3_uri"): _create_request({}) @@ -162,6 +281,24 @@ def test_transform_status_response_maps_aws_enum(raw_status, expected): 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 (F6).""" + 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) + + ################################################# # Video id encoding round-trip ################################################# @@ -254,15 +391,22 @@ 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") - from litellm.types.videos.utils import encode_video_id_with_provider - - video_id = encode_video_id_with_provider(TEST_ARN, "bedrock", TEST_MODEL) - status_url, prepped = handler._status_request_parts(TEST_ARN, {"aws_region_name": "us-east-1"}, None) + 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 (F8).""" + 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): @@ -276,6 +420,7 @@ def test_handler_video_status_maps_response(monkeypatch): 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( @@ -287,7 +432,7 @@ def test_handler_video_status_maps_response(monkeypatch): "outputDataConfig": {"s3OutputDataConfig": {"s3Uri": "s3://bucket/out/"}}, }, ) - monkeypatch.setattr(handler, "_sync_get", lambda prepped: resp) + 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 @@ -304,6 +449,7 @@ def test_handler_video_content_requires_completed(monkeypatch): 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( @@ -314,11 +460,41 @@ def test_handler_video_content_requires_completed(monkeypatch): "outputDataConfig": {"s3OutputDataConfig": {"s3Uri": "s3://bucket/out/"}}, }, ) - monkeypatch.setattr(handler, "_sync_get", lambda prepped: resp) + monkeypatch.setattr(handler, "_sync_get", lambda prepped, timeout=None: resp) with pytest.raises(ValueError, match="not complete"): handler.video_content(video_id=video_id, litellm_params={}) +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 (F7).""" + 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 @@ -330,6 +506,7 @@ def test_handler_video_content_downloads_from_s3(monkeypatch): 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( @@ -340,11 +517,11 @@ def test_handler_video_content_downloads_from_s3(monkeypatch): "outputDataConfig": {"s3OutputDataConfig": {"s3Uri": "s3://bucket/out/"}}, }, ) - monkeypatch.setattr(handler, "_sync_get", lambda prepped: resp) + monkeypatch.setattr(handler, "_sync_get", lambda prepped, timeout=None: resp) downloaded_keys: list[str] = [] - def fake_download(bucket, key_candidates, litellm_params, raw): + def fake_download(bucket, key_candidates, litellm_params, raw, region_default=None): downloaded_keys.extend(key_candidates) return b"mp4-bytes" @@ -356,8 +533,192 @@ def test_handler_video_content_downloads_from_s3(monkeypatch): assert downloaded_keys[1] == "out/output.mp4" +def test_handler_sync_create_passes_timeout(monkeypatch): + """The sync create path must forward its timeout to the POST (F9).""" + 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 (F9).""" + 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 (F9).""" + 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 (F11), 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) -> list[dict]: + """Mock boto3 + credentials for _download_s3_object; returns the Session kwargs it saw.""" + sessions: list[dict] = [] + + class _FakeS3Client: + def get_object(self, Bucket, Key): + return get_object_side_effect(Bucket, Key) + + class _FakeSession: + def __init__(self, **kwargs): + sessions.append(kwargs) + + def client(self, service_name): + return _FakeS3Client() + + 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 + + +def test_download_s3_object_uses_status_region_by_default(monkeypatch): + """region_default (ARN-derived) beats env/default; explicit litellm_params region still wins (F8).""" + 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) (F12).""" + 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 = 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 (F10).""" + 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 "parameters" not 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 index 177cd33fc1f..4387f189eab 100644 --- a/tests/unit/llms/bedrock/videos/test_amazon_nova_reel_video.py +++ b/tests/unit/llms/bedrock/videos/test_amazon_nova_reel_video.py @@ -1,15 +1,17 @@ """Unit tests for Bedrock Amazon Nova Reel video generation (issue #39552).""" +import asyncio import base64 import io import json from typing import cast -from unittest.mock import Mock, patch +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 @@ -18,6 +20,8 @@ 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: @@ -28,6 +32,7 @@ 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/"} @@ -36,7 +41,7 @@ def _create_request( prompt=prompt, api_base="https://bedrock-runtime.us-east-1.amazonaws.com/async-invoke", video_create_optional_request_params=cast(VideoCreateOptionalRequestParams, params), - litellm_params=GenericLiteLLMParams(), + litellm_params=litellm_params or GenericLiteLLMParams(), headers={}, ) assert files == [] @@ -72,20 +77,134 @@ def test_transform_create_request_seconds_and_size_map_to_generation_config(): def test_transform_create_request_input_reference_becomes_images(): - png_bytes = b"\x89PNG\r\n\x1a\n" + b"0" * 8 - body = _create_request({"output_s3_uri": "s3://bucket/out/", "input_reference": io.BytesIO(png_bytes)}) + 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") + assert images[0]["source"]["bytes"] == base64.b64encode(PNG_BYTES).decode("utf-8") def test_transform_create_request_jpeg_detected(): - jpeg_bytes = b"\xff\xd8\xff\xe0" + b"0" * 8 - body = _create_request({"output_s3_uri": "s3://bucket/out/", "input_reference": jpeg_bytes}) + 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 (F1).""" + 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 (F1).""" + 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 (F2).""" + 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(ValueError, match="base64"): + _create_request({"output_s3_uri": "s3://bucket/out/", "input_reference": "definitely!!not!!base64"}) + + +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(ValueError, match="PNG or JPEG"): + _create_request({"output_s3_uri": "s3://bucket/out/", "input_reference": mystery_b64}) + + +def test_input_reference_https_url_raises(): + with pytest.raises(ValueError, match="base64"): + _create_request({"output_s3_uri": "s3://bucket/out/", "input_reference": "https://example.com/img.png"}) + + +def test_transform_create_request_drops_non_aws_client_params(): + """Known non-AWS video-client params are dropped; provider keys still pass through (F3).""" + 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_fps_non_numeric_raises(): + with pytest.raises(ValueError, match="fps"): + _create_request({"output_s3_uri": "s3://bucket/out/", "fps": "abc"}) + + +def test_seed_non_numeric_raises(): + with pytest.raises(ValueError, match="seed"): + _create_request({"output_s3_uri": "s3://bucket/out/", "seed": "abc"}) + + def test_transform_create_request_requires_output_s3_uri(): with pytest.raises(ValueError, match="output_s3_uri"): _create_request({}) @@ -162,6 +281,24 @@ def test_transform_status_response_maps_aws_enum(raw_status, expected): 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 (F6).""" + 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) + + ################################################# # Video id encoding round-trip ################################################# @@ -254,15 +391,22 @@ 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") - from litellm.types.videos.utils import encode_video_id_with_provider - - video_id = encode_video_id_with_provider(TEST_ARN, "bedrock", TEST_MODEL) - status_url, prepped = handler._status_request_parts(TEST_ARN, {"aws_region_name": "us-east-1"}, None) + 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 (F8).""" + 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): @@ -276,6 +420,7 @@ def test_handler_video_status_maps_response(monkeypatch): 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( @@ -287,7 +432,7 @@ def test_handler_video_status_maps_response(monkeypatch): "outputDataConfig": {"s3OutputDataConfig": {"s3Uri": "s3://bucket/out/"}}, }, ) - monkeypatch.setattr(handler, "_sync_get", lambda prepped: resp) + 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 @@ -304,6 +449,7 @@ def test_handler_video_content_requires_completed(monkeypatch): 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( @@ -314,11 +460,41 @@ def test_handler_video_content_requires_completed(monkeypatch): "outputDataConfig": {"s3OutputDataConfig": {"s3Uri": "s3://bucket/out/"}}, }, ) - monkeypatch.setattr(handler, "_sync_get", lambda prepped: resp) + monkeypatch.setattr(handler, "_sync_get", lambda prepped, timeout=None: resp) with pytest.raises(ValueError, match="not complete"): handler.video_content(video_id=video_id, litellm_params={}) +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 (F7).""" + 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 @@ -330,6 +506,7 @@ def test_handler_video_content_downloads_from_s3(monkeypatch): 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( @@ -340,11 +517,11 @@ def test_handler_video_content_downloads_from_s3(monkeypatch): "outputDataConfig": {"s3OutputDataConfig": {"s3Uri": "s3://bucket/out/"}}, }, ) - monkeypatch.setattr(handler, "_sync_get", lambda prepped: resp) + monkeypatch.setattr(handler, "_sync_get", lambda prepped, timeout=None: resp) downloaded_keys: list[str] = [] - def fake_download(bucket, key_candidates, litellm_params, raw): + def fake_download(bucket, key_candidates, litellm_params, raw, region_default=None): downloaded_keys.extend(key_candidates) return b"mp4-bytes" @@ -356,8 +533,192 @@ def test_handler_video_content_downloads_from_s3(monkeypatch): assert downloaded_keys[1] == "out/output.mp4" +def test_handler_sync_create_passes_timeout(monkeypatch): + """The sync create path must forward its timeout to the POST (F9).""" + 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 (F9).""" + 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 (F9).""" + 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 (F11), 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) -> list[dict]: + """Mock boto3 + credentials for _download_s3_object; returns the Session kwargs it saw.""" + sessions: list[dict] = [] + + class _FakeS3Client: + def get_object(self, Bucket, Key): + return get_object_side_effect(Bucket, Key) + + class _FakeSession: + def __init__(self, **kwargs): + sessions.append(kwargs) + + def client(self, service_name): + return _FakeS3Client() + + 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 + + +def test_download_s3_object_uses_status_region_by_default(monkeypatch): + """region_default (ARN-derived) beats env/default; explicit litellm_params region still wins (F8).""" + 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) (F12).""" + 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 = 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 (F10).""" + 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 "parameters" not in supported From 220f1792debaa9611406840ab0b047bb20eb30d9 Mon Sep 17 00:00:00 2001 From: lokm01 <2735155+lokm01@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:13:17 +0200 Subject: [PATCH 08/30] fix(bedrock): fail fast on invalid canvas conditioned edit inputs Verification pass on the nova canvas review round confirmed four findings in the conditioned-editing path; this fixes F13-F16: - a mask provided with taskType TEXT_IMAGE raises instead of being silently discarded, pointing at INPAINTING/OUTPAINTING for mask-based workflows; the documented IMAGE_VARIATION mask-ignoring behavior is untouched (F13) - an empty or missing prompt for TEXT_IMAGE raises the same required-prompt error class as INPAINTING/OUTPAINTING instead of sending a single blank (F14) - controlStrength outside 0.0-1.0 raises, mirroring the controlMode fail-fast style; the boundary values pass through (F15) - style is advertised in get_supported_openai_params and forwarded into textToImageParams when provided, matching the sibling generation transform's field name (F16) Tests cover each rejection, the IMAGE_VARIATION mask pass-through, boundary controlStrength values, and the style advertisement. --- ...n_nova_canvas_image_edit_transformation.py | 18 +++ .../test_amazon_nova_canvas_image_edit.py | 150 ++++++++++++++++++ 2 files changed, 168 insertions(+) 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 b26da1d588d..b9ccacac5e0 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 @@ -56,6 +56,7 @@ def _nova_canvas_task_body( out_painting_mode: str | None, control_mode: str | None = None, control_strength: float | None = None, + style: str | None = None, ) -> dict[str, object]: """Build InvokeModel body task section (without imageGenerationConfig).""" if task_type == "BACKGROUND_REMOVAL": @@ -90,10 +91,21 @@ def _nova_canvas_task_body( # 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: + # AWS TEXT_IMAGE has no mask field; fail fast instead of silently + # dropping the caller's mask. + raise ValueError( + "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 ValueError( f"Unsupported Amazon Nova Canvas controlMode: {control_mode!r}. Use one of {NOVA_CANVAS_CONTROL_MODES}." ) + if control_strength is not None and not 0.0 <= control_strength <= 1.0: + raise ValueError( + f"Amazon Nova Canvas controlStrength must be between 0.0 and 1.0; got {control_strength!r}." + ) t2i_params: Final[dict[str, object]] = { # mutable-ok: optional conditioned-editing keys are set below "text": text, "conditionImage": image_b64, @@ -104,6 +116,8 @@ def _nova_canvas_task_body( t2i_params["controlMode"] = control_mode if control_strength is not None: t2i_params["controlStrength"] = control_strength + 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, @@ -280,6 +294,7 @@ class BedrockAmazonNovaCanvasImageEditConfig(BaseImageEditConfig): "cfgScale", "seed", "quality", + "style", "taskType", "maskPrompt", "outPaintingMode", @@ -391,6 +406,7 @@ class BedrockAmazonNovaCanvasImageEditConfig(BaseImageEditConfig): if (prompt is None or prompt == "") and task_type in ( "INPAINTING", "OUTPAINTING", + "TEXT_IMAGE", ): raise ValueError( f"Amazon Nova Canvas {task_type} requires a text prompt. Pass a non-empty `prompt` in your request." @@ -402,6 +418,7 @@ class BedrockAmazonNovaCanvasImageEditConfig(BaseImageEditConfig): 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) body: Final = _nova_canvas_task_body( image_b64=image_b64, @@ -414,6 +431,7 @@ class BedrockAmazonNovaCanvasImageEditConfig(BaseImageEditConfig): 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/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 283465d0025..4b0a8186f23 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 @@ -518,6 +518,149 @@ def test_transform_request_text_image_invalid_control_mode_raises(): ) +def test_transform_request_text_image_with_mask_raises(): + """TEXT_IMAGE has no mask field; a provided mask must fail fast (F13).""" + config = BedrockAmazonNovaCanvasImageEditConfig() + img = io.BytesIO(b"cond") + mask = io.BytesIO(b"mask-bytes") + with pytest.raises(ValueError, match="does not support a mask") 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={}, # type: ignore[arg-type] + headers={}, + ) + assert "INPAINTING or OUTPAINTING" in str(excinfo.value) + + +def test_transform_request_image_variation_with_mask_still_ignores_mask(): + """The documented IMAGE_VARIATION mask-ignoring behavior is untouched by F13.""" + 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={}, # type: ignore[arg-type] + 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 (F14).""" + config = BedrockAmazonNovaCanvasImageEditConfig() + img = io.BytesIO(b"cond") + with pytest.raises(ValueError, match="TEXT_IMAGE requires a text prompt"): + config.transform_image_edit_request( + model="amazon.nova-canvas-v1:0", + prompt="", + image=img, + image_edit_optional_request_params={"taskType": "TEXT_IMAGE"}, + litellm_params={}, # type: ignore[arg-type] + headers={}, + ) + + +def test_transform_request_text_image_none_prompt_raises(): + config = BedrockAmazonNovaCanvasImageEditConfig() + img = io.BytesIO(b"cond") + with pytest.raises(ValueError, match="TEXT_IMAGE requires a text prompt"): + 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={}, # type: ignore[arg-type] + headers={}, + ) + + +def test_transform_request_text_image_control_strength_out_of_range_raises(): + """controlStrength outside 0.0-1.0 fails fast (F15).""" + import re + + config = BedrockAmazonNovaCanvasImageEditConfig() + img = io.BytesIO(b"cond") + with pytest.raises(ValueError, match=re.escape("controlStrength must be between 0.0 and 1.0")): + 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={}, # type: ignore[arg-type] + headers={}, + ) + + +@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 (F15).""" + 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={}, # type: ignore[arg-type] + headers={}, + ) + assert body["textToImageParams"]["controlStrength"] == control_strength + + +def test_transform_request_text_image_forwards_style(): + """style is forwarded into textToImageParams for conditioned editing (F16).""" + 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={}, # type: ignore[arg-type] + 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={}, # type: ignore[arg-type] + 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() @@ -526,6 +669,13 @@ def test_get_supported_openai_params_includes_conditioning_fields(): assert "controlStrength" in supported +def test_get_supported_openai_params_includes_style(): + """style is advertised for TEXT_IMAGE conditioned editing (F16).""" + 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() From 64148cb2b6efdb32aa3ffa5e807125792a2581a0 Mon Sep 17 00:00:00 2001 From: lokm01 <2735155+lokm01@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:52:01 +0200 Subject: [PATCH 09/30] fix(bedrock): thread litellm params into nova reel create for idempotent retries The handler passed a fresh GenericLiteLLMParams() to the create transform, so the metadata.request_id -> clientRequestToken fallback never fired in the real path and client retries could duplicate invocations. Accept the real litellm_params in _prepare_async_invoke_request / video_generation / async_video_generation and pass it from the litellm.videos bedrock create branch. aws_* credential keys are still consumed only from optional_params, so nothing is double-processed. --- litellm/llms/bedrock/videos/handler.py | 31 +++++++++++- litellm/videos/main.py | 5 ++ .../videos/test_amazon_nova_reel_video.py | 50 ++++++++++++++++--- .../videos/test_amazon_nova_reel_video.py | 50 ++++++++++++++++--- 4 files changed, 123 insertions(+), 13 deletions(-) diff --git a/litellm/llms/bedrock/videos/handler.py b/litellm/llms/bedrock/videos/handler.py index ce8b75fdf94..592acadbbf2 100644 --- a/litellm/llms/bedrock/videos/handler.py +++ b/litellm/llms/bedrock/videos/handler.py @@ -130,6 +130,25 @@ def _params_to_dict(litellm_params: GenericLiteLLMParams | Mapping[str, object] 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 ``metadata.request_id`` is read downstream (the clientRequestToken + fallback); 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] # metadata is an extra-allowed field on GenericLiteLLMParams + return params + + class BedrockVideoGeneration(BaseAWSLLM): """ Bedrock video generation handler for Amazon Nova Reel models. @@ -172,9 +191,14 @@ class BedrockVideoGeneration(BaseAWSLLM): 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( @@ -199,7 +223,7 @@ class BedrockVideoGeneration(BaseAWSLLM): prompt=prompt, api_base=endpoint_url, video_create_optional_request_params=optional_params, - litellm_params=GenericLiteLLMParams(), + 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 @@ -270,6 +294,7 @@ class BedrockVideoGeneration(BaseAWSLLM): 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: @@ -283,6 +308,7 @@ class BedrockVideoGeneration(BaseAWSLLM): 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( @@ -293,6 +319,7 @@ class BedrockVideoGeneration(BaseAWSLLM): 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 @@ -327,6 +354,7 @@ class BedrockVideoGeneration(BaseAWSLLM): 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 @@ -338,6 +366,7 @@ class BedrockVideoGeneration(BaseAWSLLM): extra_headers=extra_headers, logging_obj=logging_obj, api_key=api_key, + litellm_params=litellm_params, ) async_client: Final = ( client diff --git a/litellm/videos/main.py b/litellm/videos/main.py index 137f16a2e62..d4747d25d7d 100644 --- a/litellm/videos/main.py +++ b/litellm/videos/main.py @@ -270,6 +270,11 @@ def video_generation( api_base=litellm_params.get("api_base"), extra_headers=extra_headers, api_key=kwargs.get("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, ) # Call the handler with _is_async flag instead of directly calling the async handler diff --git a/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py b/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py index 4387f189eab..9441f29a93c 100644 --- a/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py +++ b/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py @@ -552,9 +552,7 @@ def test_handler_sync_create_passes_timeout(monkeypatch): 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() - ) + 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", @@ -592,9 +590,7 @@ def test_handler_sync_get_passes_timeout(monkeypatch): json={"invocationArn": TEST_ARN, "status": "InProgress", "submitTime": 1758000000.0}, ) - monkeypatch.setattr( - "litellm.llms.custom_httpx.http_handler._get_httpx_client", lambda: _RecordingClient() - ) + 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" @@ -722,3 +718,45 @@ def test_get_supported_openai_params_includes_video_params(): assert "size" in supported assert "output_s3_uri" in supported assert "parameters" not in supported + + +################################################# +# litellm_params threading into the create request +################################################# + + +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" 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 index 4387f189eab..9441f29a93c 100644 --- a/tests/unit/llms/bedrock/videos/test_amazon_nova_reel_video.py +++ b/tests/unit/llms/bedrock/videos/test_amazon_nova_reel_video.py @@ -552,9 +552,7 @@ def test_handler_sync_create_passes_timeout(monkeypatch): 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() - ) + 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", @@ -592,9 +590,7 @@ def test_handler_sync_get_passes_timeout(monkeypatch): json={"invocationArn": TEST_ARN, "status": "InProgress", "submitTime": 1758000000.0}, ) - monkeypatch.setattr( - "litellm.llms.custom_httpx.http_handler._get_httpx_client", lambda: _RecordingClient() - ) + 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" @@ -722,3 +718,45 @@ def test_get_supported_openai_params_includes_video_params(): assert "size" in supported assert "output_s3_uri" in supported assert "parameters" not in supported + + +################################################# +# litellm_params threading into the create request +################################################# + + +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" From 6e87dd3c3da9b43a0d58525168da3e408b6ba469 Mon Sep 17 00:00:00 2001 From: lokm01 <2735155+lokm01@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:52:46 +0200 Subject: [PATCH 10/30] fix(bedrock): harden nova reel status, s3, and canvas numeric input paths - seconds: non-numeric values now raise like fps/seed instead of silently keeping the 6s default - unmapped AWS invocationStatus logs a warning before reporting processing - status/content GET timeouts map to BedrockError 408 like the create path - injected async clients receive the timeout kwarg on create POST - no-S3-output ValueError names only observed outputDataConfig keys; the raw payload (invocationArn + account id) goes to debug logs - BotoCoreError during the S3 download maps to BedrockError 502 (per-key ClientError fallback unchanged) - litellm.video_status / litellm.video_content bedrock branches thread the layer default timeout instead of None - canvas controlStrength coerces string form input to float before the range check, and TEXT_IMAGE rejects maskPrompt like it rejects a binary mask --- ...n_nova_canvas_image_edit_transformation.py | 26 +- litellm/llms/bedrock/videos/handler.py | 47 +++- litellm/llms/bedrock/videos/transformation.py | 6 +- litellm/videos/main.py | 2 + .../videos/test_amazon_nova_reel_video.py | 233 +++++++++++++++++- .../test_amazon_nova_canvas_image_edit.py | 64 ++++- .../videos/test_amazon_nova_reel_video.py | 233 +++++++++++++++++- 7 files changed, 581 insertions(+), 30 deletions(-) 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 b9ccacac5e0..fbdd4d29ec6 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 @@ -55,7 +55,7 @@ def _nova_canvas_task_body( mask_prompt: str | None, out_painting_mode: str | None, control_mode: str | None = None, - control_strength: float | None = None, + control_strength: float | str | None = None, style: str | None = None, ) -> dict[str, object]: """Build InvokeModel body task section (without imageGenerationConfig).""" @@ -91,9 +91,9 @@ def _nova_canvas_task_body( # 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: + 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. + # dropping the caller's mask or maskPrompt. raise ValueError( "Amazon Nova Canvas TEXT_IMAGE (conditioned editing) does not support a " "mask. Use INPAINTING or OUTPAINTING for mask-based editing workflows." @@ -102,10 +102,18 @@ def _nova_canvas_task_body( raise ValueError( f"Unsupported Amazon Nova Canvas controlMode: {control_mode!r}. Use one of {NOVA_CANVAS_CONTROL_MODES}." ) - if control_strength is not None and not 0.0 <= control_strength <= 1.0: - raise ValueError( - f"Amazon Nova Canvas controlStrength must be between 0.0 and 1.0; got {control_strength!r}." - ) + 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 ValueError("Amazon Nova Canvas controlStrength must be a number in [0.0, 1.0].") + if not 0.0 <= control_strength_value <= 1.0: + raise ValueError( + 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, @@ -114,8 +122,8 @@ def _nova_canvas_task_body( t2i_params["negativeText"] = negative_text if control_mode is not None: t2i_params["controlMode"] = control_mode - if control_strength is not None: - t2i_params["controlStrength"] = control_strength + 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 diff --git a/litellm/llms/bedrock/videos/handler.py b/litellm/llms/bedrock/videos/handler.py index 592acadbbf2..b6ae9b38221 100644 --- a/litellm/llms/bedrock/videos/handler.py +++ b/litellm/llms/bedrock/videos/handler.py @@ -22,6 +22,7 @@ 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 ( @@ -381,6 +382,7 @@ class BedrockVideoGeneration(BaseAWSLLM): url=endpoint_url, headers=prepped.headers, content=body, + timeout=timeout, ) response.raise_for_status() except httpx.HTTPStatusError as err: @@ -446,7 +448,10 @@ class BedrockVideoGeneration(BaseAWSLLM): from litellm.llms.custom_httpx.http_handler import _get_httpx_client client: Final = _get_httpx_client() - return client.get(url=prepped.url, headers=prepped.headers, timeout=timeout) + 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, @@ -459,7 +464,10 @@ class BedrockVideoGeneration(BaseAWSLLM): llm_provider=litellm.LlmProviders.BEDROCK, params={"timeout": timeout}, # mutable-ok: per-call timeout kwargs for the shared client factory ) - return await client.get(url=prepped.url, headers=prepped.headers, timeout=timeout) + 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, @@ -552,7 +560,14 @@ class BedrockVideoGeneration(BaseAWSLLM): s3_uri: Final[str | None] = _s3_uri_from_output_config(raw.get("outputDataConfig")) if not s3_uri: - raise ValueError(f"No S3 output location on completed invocation: {raw}") + # 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 writes output.mp4 into a per-invocation folder (v1:1 docs); older @@ -579,7 +594,7 @@ class BedrockVideoGeneration(BaseAWSLLM): """ try: import boto3 - from botocore.exceptions import ClientError + from botocore.exceptions import BotoCoreError, ClientError except ImportError: raise ImportError("Missing boto3 to download Nova Reel output. Run 'pip install boto3'.") @@ -600,13 +615,23 @@ class BedrockVideoGeneration(BaseAWSLLM): s3_uri: Final[str | None] = _s3_uri_from_output_config(raw.get("outputDataConfig")) errors: list[str] = [] # mutable-ok: error strings accumulate across candidate keys - for key in key_candidates: - try: - obj = s3_client.get_object(Bucket=bucket, Key=key) - content = obj["Body"].read() - return bytes(content) - except ClientError as err: - errors.append(str(err)) + try: + for key in key_candidates: + try: + obj = s3_client.get_object(Bucket=bucket, Key=key) + content = obj["Body"].read() + return bytes(content) + except ClientError as err: + errors.append(str(err)) + except BotoCoreError as err: + # ClientError (caught per-key above) is a BotoCoreError subclass; + # anything else (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}", + ) raise BedrockError( status_code=404, message=( diff --git a/litellm/llms/bedrock/videos/transformation.py b/litellm/llms/bedrock/videos/transformation.py index 3919b9bb1eb..4b6b75c33a2 100644 --- a/litellm/llms/bedrock/videos/transformation.py +++ b/litellm/llms/bedrock/videos/transformation.py @@ -276,8 +276,8 @@ class BedrockNovaReelVideoConfig(BaseVideoConfig): if isinstance(seconds, (int, float, str)): try: generation_config["durationSeconds"] = int(float(seconds)) - except ValueError: - verbose_logger.debug("Nova Reel ignoring non-numeric seconds=%r", seconds) + except ValueError as err: + raise ValueError(f"Nova Reel seconds must be a number; got {seconds!r}") from err size: Final = op.pop("size", None) if size is not None and isinstance(size, str) and "x" in size: generation_config["dimension"] = size.replace(" ", "") @@ -397,6 +397,8 @@ class BedrockNovaReelVideoConfig(BaseVideoConfig): ), ) 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") diff --git a/litellm/videos/main.py b/litellm/videos/main.py index d4747d25d7d..cbd89ddb8be 100644 --- a/litellm/videos/main.py +++ b/litellm/videos/main.py @@ -398,6 +398,7 @@ def video_content( logging_obj=litellm_logging_obj, api_base=litellm_params.get("api_base"), api_key=kwargs.get("api_key"), + timeout=timeout or DEFAULT_REQUEST_TIMEOUT, ) return base_llm_http_handler.video_content_handler( video_id=video_id, @@ -1113,6 +1114,7 @@ def video_status( api_base=litellm_params.get("api_base"), api_key=kwargs.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 diff --git a/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py b/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py index 9441f29a93c..7346502ae99 100644 --- a/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py +++ b/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py @@ -195,6 +195,17 @@ def test_fps_seed_string_coercion(): 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(ValueError, match="seconds"): + _create_request({"output_s3_uri": "s3://bucket/out/", "seconds": "abc"}) + + def test_fps_non_numeric_raises(): with pytest.raises(ValueError, match="fps"): _create_request({"output_s3_uri": "s3://bucket/out/", "fps": "abc"}) @@ -299,6 +310,25 @@ def test_transform_status_response_empty_status_raises(): 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 ################################################# @@ -721,7 +751,7 @@ def test_get_supported_openai_params_includes_video_params(): ################################################# -# litellm_params threading into the create request +# litellm_params threading + timeout/error mapping on the HTTP paths ################################################# @@ -760,3 +790,204 @@ def test_handler_create_threads_litellm_params_request_id_into_token(monkeypatch 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): + attempted.append(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")} + + _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 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 4b0a8186f23..9c9818849fd 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 @@ -203,9 +203,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(): @@ -366,9 +364,7 @@ def test_transform_request_inpainting_explicit_task_without_mask_raises(): """INPAINTING taskType without mask or maskPrompt must fail fast.""" config = BedrockAmazonNovaCanvasImageEditConfig() img = io.BytesIO(b"img") - with pytest.raises( - ValueError, match="INPAINTING requires either maskPrompt or maskImage" - ): + with pytest.raises(ValueError, match="INPAINTING requires either maskPrompt or maskImage"): config.transform_image_edit_request( model="amazon.nova-canvas-v1:0", prompt="fix it", @@ -538,6 +534,26 @@ def test_transform_request_text_image_with_mask_raises(): assert "INPAINTING or OUTPAINTING" in str(excinfo.value) +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(ValueError, match="does not support a mask") 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={}, # type: ignore[arg-type] + headers={}, + ) + assert "INPAINTING or OUTPAINTING" in str(excinfo.value) + + def test_transform_request_image_variation_with_mask_still_ignores_mask(): """The documented IMAGE_VARIATION mask-ignoring behavior is untouched by F13.""" config = BedrockAmazonNovaCanvasImageEditConfig() @@ -627,6 +643,42 @@ def test_transform_request_text_image_control_strength_bounds_pass(control_stren 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={}, # type: ignore[arg-type] + 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 raise ValueError, not TypeError.""" + config = BedrockAmazonNovaCanvasImageEditConfig() + img = io.BytesIO(b"cond") + with pytest.raises(ValueError, match="controlStrength must be a number"): + 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={}, # type: ignore[arg-type] + headers={}, + ) + + def test_transform_request_text_image_forwards_style(): """style is forwarded into textToImageParams for conditioned editing (F16).""" config = BedrockAmazonNovaCanvasImageEditConfig() 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 index 9441f29a93c..7346502ae99 100644 --- a/tests/unit/llms/bedrock/videos/test_amazon_nova_reel_video.py +++ b/tests/unit/llms/bedrock/videos/test_amazon_nova_reel_video.py @@ -195,6 +195,17 @@ def test_fps_seed_string_coercion(): 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(ValueError, match="seconds"): + _create_request({"output_s3_uri": "s3://bucket/out/", "seconds": "abc"}) + + def test_fps_non_numeric_raises(): with pytest.raises(ValueError, match="fps"): _create_request({"output_s3_uri": "s3://bucket/out/", "fps": "abc"}) @@ -299,6 +310,25 @@ def test_transform_status_response_empty_status_raises(): 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 ################################################# @@ -721,7 +751,7 @@ def test_get_supported_openai_params_includes_video_params(): ################################################# -# litellm_params threading into the create request +# litellm_params threading + timeout/error mapping on the HTTP paths ################################################# @@ -760,3 +790,204 @@ def test_handler_create_threads_litellm_params_request_id_into_token(monkeypatch 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): + attempted.append(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")} + + _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 From f77c515fa1b2248481442e74df8324168caa05da Mon Sep 17 00:00:00 2001 From: lokm01 <2735155+lokm01@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:25:42 +0200 Subject: [PATCH 11/30] fix(bedrock): harden nova reel timestamps, multi-shot bodies and s3 paths Verified review fixes for the Nova Reel video surface: - parse iso8601 submitTime/endTime (the bedrock-runtime smithy timestampformat) in addition to numeric epochs; warn and leave unset on unparseable values - branch modelInput on taskType: TEXT_VIDEO keeps textToVideoParams, MULTI_SHOT_AUTOMATED emits multiShotAutomatedParams and rejects input images, MULTI_SHOT_MANUAL requires multiShotManualParams and omits top-level durationSeconds (durations live per shot) - map S3 download errors by AWS error code: only NoSuchKey or HTTP 404 falls through to the next candidate key, AccessDenied raises 403, any other ClientError aborts with 502 preserving the code - reject bearer-only credentials for the S3 download with a 400 explaining bearer tokens only cover the async-invoke API - build the S3 client with connect/read timeouts derived from the call timeout and close the object body and client on every path - validate the s3:// scheme at both the transform and _parse_s3_uri - guard response.json() on both response transforms with a 502 on non-JSON 2xx bodies - require binary-mode input_reference files and a non-empty prompt - raise BedrockError 400 for unsupported operations (list/remix/delete); verified litellm.exception_type maps it to BadRequestError while a plain NotImplementedError surfaces as APIConnectionError 500 - move output_s3_uri from usage to _hidden_params on status responses - thread kmsKeyId/bucketOwner (and snake_case aliases) into s3OutputDataConfig instead of leaking them into modelInput - decode the video id once per status call, rename the async arm to _async_video_status (private, internal dispatch only), extend the clientRequestToken fallback chain to litellm_call_id, and fall back to litellm_params.api_key on the three bedrock branches in videos/main --- litellm/llms/bedrock/videos/handler.py | 126 ++++++++-- litellm/llms/bedrock/videos/transformation.py | 221 +++++++++++++----- litellm/types/llms/bedrock.py | 43 +++- litellm/videos/main.py | 6 +- 4 files changed, 315 insertions(+), 81 deletions(-) diff --git a/litellm/llms/bedrock/videos/handler.py b/litellm/llms/bedrock/videos/handler.py index b6ae9b38221..8e4ace0c771 100644 --- a/litellm/llms/bedrock/videos/handler.py +++ b/litellm/llms/bedrock/videos/handler.py @@ -32,7 +32,7 @@ from litellm.types.llms.bedrock import ( ) from litellm.types.router import GenericLiteLLMParams from litellm.types.videos.main import VideoObject -from litellm.types.videos.utils import decode_video_id_with_provider +from litellm.types.videos.utils import decode_video_id_with_provider, extract_original_video_id from ..base_aws_llm import ( AWSPreparedRequest, @@ -51,6 +51,34 @@ _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( @@ -99,8 +127,10 @@ def _region_from_invocation_arn(invocation_arn: str) -> str | 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 ValueError(f"Invalid S3 output URI (expected s3://bucket/prefix): {s3_uri!r}") trimmed: Final = s3_uri.rstrip("/") - without_scheme: Final = trimmed.removeprefix("s3://") + without_scheme: Final = trimmed[len("s3://") :] bucket, _, prefix = without_scheme.partition("/") if not bucket: raise ValueError(f"Invalid S3 output URI: {s3_uri!r}") @@ -136,9 +166,10 @@ def _as_generic_litellm_params( ) -> GenericLiteLLMParams: """Normalize handler-level litellm_params to GenericLiteLLMParams for the create transform. - Only ``metadata.request_id`` is read downstream (the clientRequestToken - fallback); aws_* credential keys are consumed from optional_params only, so - nothing passed here double-processes credentials. + 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 @@ -147,6 +178,9 @@ def _as_generic_litellm_params( metadata: Final = litellm_params.get("metadata") if isinstance(metadata, dict): params.metadata = metadata # pyright: ignore[reportAttributeAccessIssue] # metadata is an extra-allowed field on GenericLiteLLMParams + 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] # litellm_call_id is an extra-allowed field on GenericLiteLLMParams (set by the @client decorator) return params @@ -435,13 +469,12 @@ class BedrockVideoGeneration(BaseAWSLLM): 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.""" - config: Final = BedrockNovaReelVideoConfig() - invocation_arn: Final = config.extract_invocation_arn(video_id) + """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 ValueError(f"Could not extract a Bedrock invocation ARN from video id: {video_id!r}") - decoded: Final = decode_video_id_with_provider(video_id) - model: Final = decoded.get("model_id") or "amazon.nova-reel-v1:0" + 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: @@ -508,14 +541,14 @@ class BedrockVideoGeneration(BaseAWSLLM): 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( + 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( + async def _async_video_status( self, prepped: AWSPreparedRequest, model: str, @@ -523,6 +556,7 @@ class BedrockVideoGeneration(BaseAWSLLM): 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 @@ -576,7 +610,15 @@ class BedrockVideoGeneration(BaseAWSLLM): f"{prefix}/{invocation_arn.rsplit('/', 1)[-1]}/{NOVA_REEL_OUTPUT_FILENAME}".lstrip("/"), f"{prefix}/{NOVA_REEL_OUTPUT_FILENAME}".lstrip("/"), ] - return self._download_s3_object(bucket, key_candidates, litellm_params, raw, region_default=status_region) + 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, @@ -585,53 +627,99 @@ class BedrockVideoGeneration(BaseAWSLLM): 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 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) + # Always resolve SigV4 credentials for S3: a Bedrock bearer token only + # covers the async-invoke API, never S3 object access. credentials, region = self._load_credentials( optional_params, aws_region_name=(explicit_region if explicit_region is not None else region_default), ) + if bearer_token is not None and credentials is None: + raise BedrockError( + status_code=400, + message=( + "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." + ), + ) 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") + 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) - content = obj["Body"].read() - return bytes(content) + body = obj["Body"] + return body.read() except ClientError as err: - errors.append(str(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 (caught per-key above) is a BotoCoreError subclass; - # anything else (NoCredentialsError, EndpointConnectionError, ...) + # 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=( diff --git a/litellm/llms/bedrock/videos/transformation.py b/litellm/llms/bedrock/videos/transformation.py index 4b6b75c33a2..bc550de7ccd 100644 --- a/litellm/llms/bedrock/videos/transformation.py +++ b/litellm/llms/bedrock/videos/transformation.py @@ -24,6 +24,7 @@ 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 @@ -79,6 +80,16 @@ _UNSUPPORTED_MESSAGE: Final = ( ) +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 _data_url_payload(image: str) -> str: """Validate a ``data:;base64,`` URL and return only its payload.""" header, sep, encoded = image.partition(",") @@ -106,6 +117,11 @@ def _file_content_to_b64_and_format(image: FileContent) -> tuple[str, str]: if hasattr(image, "seek"): image.seek(0) image_bytes = image.read() + if not isinstance(image_bytes, bytes): + raise ValueError( # noqa: TRY004 # ValueError maps to a 400-class through the video surface; TypeError would 500 + "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 ValueError( f"Nova Reel input_reference must be bytes, a file-like object or a base64 string; got {type(image)!r}" @@ -148,15 +164,105 @@ def _sanitize_client_request_token(token: str) -> str: def _request_id_from_litellm_params(litellm_params: GenericLiteLLMParams) -> str | None: - """Best-effort litellm request id from litellm_params.metadata (extra field).""" + """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 ValueError(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 ValueError(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 ValueError(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 ValueError( + "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 ValueError( + "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] # request params are untyped user input; the helper validates and raises for unsupported shapes + ) + 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. @@ -171,6 +277,8 @@ class BedrockNovaReelVideoConfig(BaseVideoConfig): "dimension", "taskType", "output_s3_uri", + "kmsKeyId", + "bucketOwner", "input_reference", "image", ] @@ -246,62 +354,33 @@ class BedrockNovaReelVideoConfig(BaseVideoConfig): '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 ValueError( + "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") - text_to_video_params: Final[_VideoParams] = {"text": prompt} + if not str(prompt or "").strip(): + raise ValueError("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 - 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] # request params are untyped user input; the helper validates and raises for unsupported shapes - ) - 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 - ] - - generation_config: Final[_VideoParams] = { - "durationSeconds": NOVA_REEL_DEFAULT_DURATION_SECONDS, - "fps": NOVA_REEL_DEFAULT_FPS, - "dimension": NOVA_REEL_DEFAULT_DIMENSION, - } - seconds: Final = op.pop("seconds", None) - if isinstance(seconds, (int, float, str)): - try: - generation_config["durationSeconds"] = int(float(seconds)) - except ValueError as err: - raise ValueError(f"Nova Reel seconds must be a number; got {seconds!r}") from err - 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 ValueError(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 ValueError(f"Nova Reel seed must be a number; got {seed!r}") from err model_input: Final[_VideoParams] = { "taskType": task_type, - "textToVideoParams": text_to_video_params, - "videoGenerationConfig": generation_config, + "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 @@ -322,10 +401,16 @@ class BedrockNovaReelVideoConfig(BaseVideoConfig): 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": {"s3Uri": output_s3_uri}}, + "outputDataConfig": {"s3OutputDataConfig": s3_output_config}, } if client_request_token: # Envelope key only when populated: caller-supplied token or litellm request id. @@ -342,7 +427,13 @@ class BedrockNovaReelVideoConfig(BaseVideoConfig): ) -> VideoObject: from litellm.types.videos.main import VideoObject - response_data: Final[BedrockStartAsyncInvokeResponse] = raw_response.json() + 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}") @@ -383,7 +474,13 @@ class BedrockNovaReelVideoConfig(BaseVideoConfig): ) -> VideoObject: from litellm.types.videos.main import VideoObject - response_data: Final[BedrockGetAsyncInvokeResponse] = raw_response.json() + 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}") @@ -421,7 +518,10 @@ class BedrockNovaReelVideoConfig(BaseVideoConfig): if s3_config is not None: s3_uri: Final[str | None] = s3_config.get("s3Uri") if s3_uri: - video_obj.usage = {"output_s3_uri": s3_uri} # mutable-ok: VideoObject.usage payload dict + # 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( @@ -453,7 +553,7 @@ class BedrockNovaReelVideoConfig(BaseVideoConfig): headers: _VideoHeaders, extra_body: Mapping[str, object] | None = None, ) -> tuple[str, _VideoParams]: - raise NotImplementedError(_UNSUPPORTED_MESSAGE.format(operation="remix")) + raise _unsupported_operation_error("remix") def transform_video_remix_response( self, @@ -461,7 +561,7 @@ class BedrockNovaReelVideoConfig(BaseVideoConfig): logging_obj: LiteLLMLogging | None, custom_llm_provider: str | None = None, ) -> VideoObject: - raise NotImplementedError(_UNSUPPORTED_MESSAGE.format(operation="remix")) + raise _unsupported_operation_error("remix") def transform_video_list_request( self, @@ -473,7 +573,7 @@ class BedrockNovaReelVideoConfig(BaseVideoConfig): order: str | None = None, extra_query: Mapping[str, object] | None = None, ) -> tuple[str, _VideoParams]: - raise NotImplementedError(_UNSUPPORTED_MESSAGE.format(operation="list")) + raise _unsupported_operation_error("list") def transform_video_list_response( self, @@ -481,7 +581,7 @@ class BedrockNovaReelVideoConfig(BaseVideoConfig): logging_obj: LiteLLMLogging | None, custom_llm_provider: str | None = None, ) -> _VideoStringParams: - raise NotImplementedError(_UNSUPPORTED_MESSAGE.format(operation="list")) + raise _unsupported_operation_error("list") def transform_video_delete_request( self, @@ -490,14 +590,14 @@ class BedrockNovaReelVideoConfig(BaseVideoConfig): litellm_params: GenericLiteLLMParams, headers: _VideoHeaders, ) -> tuple[str, _VideoParams]: - raise NotImplementedError(_UNSUPPORTED_MESSAGE.format(operation="delete")) + raise _unsupported_operation_error("delete") def transform_video_delete_response( self, raw_response: httpx.Response, logging_obj: LiteLLMLogging | None, ) -> VideoObject: - raise NotImplementedError(_UNSUPPORTED_MESSAGE.format(operation="delete")) + raise _unsupported_operation_error("delete") @staticmethod def extract_invocation_arn(video_id: str) -> str: @@ -514,9 +614,20 @@ def _epoch_now() -> int: 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/types/llms/bedrock.py b/litellm/types/llms/bedrock.py index 0ebb2ab8bca..d95c72b1a07 100644 --- a/litellm/types/llms/bedrock.py +++ b/litellm/types/llms/bedrock.py @@ -1077,13 +1077,47 @@ class AmazonNovaReelVideoGenerationConfig(TypedDict, total=False): 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] @@ -1137,7 +1171,8 @@ class BedrockGetAsyncInvokeResponse(TypedDict, total=False): status enum verified against the bedrock-runtime service model (AsyncInvokeStatus): InProgress | Completed | Failed. failureMessage is - present when status is Failed. + present when status is Failed. Timestamps use the model's iso8601 + timestampFormat ("2026-01-15T10:30:00Z"); numeric epochs are tolerated. """ invocationArn: ReadOnly[str] @@ -1145,9 +1180,9 @@ class BedrockGetAsyncInvokeResponse(TypedDict, total=False): clientRequestToken: ReadOnly[str] status: ReadOnly[BEDROCK_ASYNC_INVOKE_STATUSES] failureMessage: ReadOnly[str] - submitTime: ReadOnly[float] - lastModifiedTime: ReadOnly[float] - endTime: ReadOnly[float] + submitTime: ReadOnly[str | float] + lastModifiedTime: ReadOnly[str | float] + endTime: ReadOnly[str | float] outputDataConfig: ReadOnly[BedrockAsyncInvokeOutputDataConfig] diff --git a/litellm/videos/main.py b/litellm/videos/main.py index cbd89ddb8be..5bc91e14211 100644 --- a/litellm/videos/main.py +++ b/litellm/videos/main.py @@ -269,7 +269,7 @@ def video_generation( client=kwargs.get("client"), api_base=litellm_params.get("api_base"), extra_headers=extra_headers, - api_key=kwargs.get("api_key"), + api_key=kwargs.get("api_key") or litellm_params.get("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 @@ -397,7 +397,7 @@ def video_content( litellm_params=litellm_params, logging_obj=litellm_logging_obj, api_base=litellm_params.get("api_base"), - api_key=kwargs.get("api_key"), + 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( @@ -1112,7 +1112,7 @@ def video_status( litellm_params=litellm_params, logging_obj=litellm_logging_obj, api_base=litellm_params.get("api_base"), - api_key=kwargs.get("api_key"), + api_key=kwargs.get("api_key") or litellm_params.get("api_key"), astatus=_is_async, timeout=timeout or DEFAULT_REQUEST_TIMEOUT, ) From 2f94287ea7b868795a0c9476fd78f22394a1dc64 Mon Sep 17 00:00:00 2001 From: lokm01 <2735155+lokm01@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:25:42 +0200 Subject: [PATCH 12/30] test(bedrock): consolidate nova reel video tests into the ci-covered tree Delete the md5-duplicate copy under tests/unit/llms/bedrock/videos; the single home stays tests/test_litellm/llms/bedrock/videos/ which github actions runs on every pr per contributing. Extend the canonical file with coverage for the hardened paths: iso8601 timestamps, multi-shot bodies, s3 error-code mapping (403/504/fallthrough on 404), bearer-only 400, client timeouts and resource closes, non-JSON 502s, unsupported-op 400s, binary-mode and prompt guards, litellm_call_id fallback, single video id decode, the private async status arm, api_key fallback through litellm_params, and kms/bucketOwner plumbing --- .../videos/test_amazon_nova_reel_video.py | 612 ++++++++++- tests/unit/llms/bedrock/videos/__init__.py | 0 .../videos/test_amazon_nova_reel_video.py | 993 ------------------ 3 files changed, 599 insertions(+), 1006 deletions(-) delete mode 100644 tests/unit/llms/bedrock/videos/__init__.py delete mode 100644 tests/unit/llms/bedrock/videos/test_amazon_nova_reel_video.py diff --git a/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py b/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py index 7346502ae99..f2970616b90 100644 --- a/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py +++ b/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py @@ -4,7 +4,8 @@ import asyncio import base64 import io import json -from typing import cast +from datetime import datetime, timezone +from typing import Final, cast from unittest.mock import Mock import httpx @@ -286,8 +287,9 @@ def test_transform_status_response_maps_aws_enum(raw_status, expected): assert video.error == {"message": "blocked by content filters"} if raw_status == "Completed": assert video.completed_at == 1758000060 - assert video.usage is not None - assert video.usage["output_s3_uri"] == "s3://bucket/out/" + # output_s3_uri rides on _hidden_params (provider detail), not usage (F3/C3). + 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 @@ -551,7 +553,7 @@ def test_handler_video_content_downloads_from_s3(monkeypatch): downloaded_keys: list[str] = [] - def fake_download(bucket, key_candidates, litellm_params, raw, region_default=None): + 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" @@ -661,20 +663,37 @@ def test_sign_get_request_without_credentials_or_bearer_raises(monkeypatch): ) -def _patch_s3_download(monkeypatch, handler: BedrockVideoGeneration, get_object_side_effect) -> list[dict]: - """Mock boto3 + credentials for _download_s3_object; returns the Session kwargs it saw.""" +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): - return _FakeS3Client() + 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( @@ -682,14 +701,26 @@ def _patch_s3_download(monkeypatch, handler: BedrockVideoGeneration, get_object_ "_load_credentials", lambda optional_params, aws_region_name=None, bearer_token=None: (None, aws_region_name or "us-east-1"), ) - return sessions + 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 (F8).""" handler = BedrockVideoGeneration() raw: dict = {"outputDataConfig": {"s3OutputDataConfig": {"s3Uri": "s3://bucket/out/"}}} - sessions = _patch_s3_download( + sessions, _, _ = _patch_s3_download( monkeypatch, handler, lambda bucket, key: {"Body": io.BytesIO(b"mp4-bytes")}, @@ -726,7 +757,7 @@ def test_download_s3_object_error_message_redacts_invocation(monkeypatch): _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 = str(excinfo.value.message) + message: Final = str(excinfo.value.message) assert "s3://bucket/out/" in message assert "out/output.mp4" in message assert "NoSuchKey" in message @@ -747,6 +778,8 @@ def test_get_supported_openai_params_includes_video_params(): 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 @@ -932,7 +965,6 @@ def test_download_s3_object_falls_back_to_second_candidate_key(monkeypatch): attempted: list[str] = [] def _first_key_missing(bucket, key): - attempted.append(key) if key == "out/abc123-def456/output.mp4": raise ClientError( {"Error": {"Code": "NoSuchKey", "Message": "The specified key does not exist."}}, @@ -940,7 +972,7 @@ def test_download_s3_object_falls_back_to_second_candidate_key(monkeypatch): ) return {"Body": io.BytesIO(b"mp4-from-second-key")} - _patch_s3_download(monkeypatch, handler, _first_key_missing) + _, clients, attempted = _patch_s3_download(monkeypatch, handler, _first_key_missing) content = handler._download_s3_object( "bucket", ["out/abc123-def456/output.mp4", "out/output.mp4"], @@ -991,3 +1023,557 @@ def test_main_layer_bedrock_status_and_content_pass_default_timeout(monkeypatch) 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 + + +################################################# +# C5: _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 + + +################################################# +# C18: empty prompt guard +################################################# + + +def test_transform_create_request_blank_prompt_raises(): + with pytest.raises(ValueError, match="prompt is required"): + _create_request(prompt=" ") + + +################################################# +# C7: s3:// scheme validation +################################################# + + +def test_transform_create_request_non_s3_output_uri_raises(): + with pytest.raises(ValueError, match="s3://"): + _create_request({"output_s3_uri": "https://bucket/out/"}) + + +def test_parse_s3_uri_rejects_non_s3_scheme(): + from litellm.llms.bedrock.videos.handler import _parse_s3_uri + + with pytest.raises(ValueError, match="s3://"): + _parse_s3_uri("https://example.com/bucket/key") + + +################################################# +# C2: 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(ValueError, match="does not accept input images"): + _create_request( + {"output_s3_uri": "s3://bucket/out/", "taskType": "MULTI_SHOT_AUTOMATED", "image": PNG_BYTES} + ) + + +def test_transform_multi_shot_manual_without_params_raises(): + with pytest.raises(ValueError, match="multiShotManualParams"): + _create_request({"output_s3_uri": "s3://bucket/out/", "taskType": "MULTI_SHOT_MANUAL"}) + + +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"] + + +################################################# +# C23: 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"] + + +################################################# +# C16: 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) + + +################################################# +# C22: 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 + + +################################################# +# C19: text-mode file guard +################################################# + + +def test_input_reference_text_mode_file_raises_value_error(): + with pytest.raises(ValueError, match="binary mode"): + _create_request({"output_s3_uri": "s3://bucket/out/", "input_reference": io.StringIO("not binary")}) + + +################################################# +# C26: 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" + + +################################################# +# C1/C28: 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 + + +################################################# +# C14: 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 == [] + + +################################################# +# C4: 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 + + +################################################# +# C20: 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] + + +################################################# +# C24: 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" + + +################################################# +# C8: 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" diff --git a/tests/unit/llms/bedrock/videos/__init__.py b/tests/unit/llms/bedrock/videos/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 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 deleted file mode 100644 index 7346502ae99..00000000000 --- a/tests/unit/llms/bedrock/videos/test_amazon_nova_reel_video.py +++ /dev/null @@ -1,993 +0,0 @@ -"""Unit tests for Bedrock Amazon Nova Reel video generation (issue #39552).""" - -import asyncio -import base64 -import io -import json -from typing import 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 (F1).""" - 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 (F1).""" - 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 (F2).""" - 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(ValueError, match="base64"): - _create_request({"output_s3_uri": "s3://bucket/out/", "input_reference": "definitely!!not!!base64"}) - - -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(ValueError, match="PNG or JPEG"): - _create_request({"output_s3_uri": "s3://bucket/out/", "input_reference": mystery_b64}) - - -def test_input_reference_https_url_raises(): - with pytest.raises(ValueError, match="base64"): - _create_request({"output_s3_uri": "s3://bucket/out/", "input_reference": "https://example.com/img.png"}) - - -def test_transform_create_request_drops_non_aws_client_params(): - """Known non-AWS video-client params are dropped; provider keys still pass through (F3).""" - 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(ValueError, match="seconds"): - _create_request({"output_s3_uri": "s3://bucket/out/", "seconds": "abc"}) - - -def test_fps_non_numeric_raises(): - with pytest.raises(ValueError, match="fps"): - _create_request({"output_s3_uri": "s3://bucket/out/", "fps": "abc"}) - - -def test_seed_non_numeric_raises(): - with pytest.raises(ValueError, match="seed"): - _create_request({"output_s3_uri": "s3://bucket/out/", "seed": "abc"}) - - -def test_transform_create_request_requires_output_s3_uri(): - with pytest.raises(ValueError, match="output_s3_uri"): - _create_request({}) - - -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 - assert video.usage is not None - assert video.usage["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 (F6).""" - 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" - - -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 (F8).""" - 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(ValueError, match="not complete"): - handler.video_content(video_id=video_id, litellm_params={}) - - -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 (F7).""" - 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 - - 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): - 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 test_handler_sync_create_passes_timeout(monkeypatch): - """The sync create path must forward its timeout to the POST (F9).""" - 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 (F9).""" - 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 (F9).""" - 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 (F11), 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) -> list[dict]: - """Mock boto3 + credentials for _download_s3_object; returns the Session kwargs it saw.""" - sessions: list[dict] = [] - - class _FakeS3Client: - def get_object(self, Bucket, Key): - return get_object_side_effect(Bucket, Key) - - class _FakeSession: - def __init__(self, **kwargs): - sessions.append(kwargs) - - def client(self, service_name): - return _FakeS3Client() - - 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 - - -def test_download_s3_object_uses_status_region_by_default(monkeypatch): - """region_default (ARN-derived) beats env/default; explicit litellm_params region still wins (F8).""" - 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) (F12).""" - 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 = 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 (F10).""" - 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 "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): - attempted.append(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")} - - _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 From b9b04384c071fa69b61f8d9e93eb6dc1c91850e3 Mon Sep 17 00:00:00 2001 From: lokm01 <2735155+lokm01@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:25:42 +0200 Subject: [PATCH 13/30] fix(bedrock): guard canvas conditioning params and accept conditionImage - raise when controlMode, controlStrength or style is supplied with any resolved taskType other than TEXT_IMAGE instead of silently dropping them - advertise conditionImage and accept it as the TEXT_IMAGE condition source when no multipart image file was sent; the multipart image field wins when both are supplied, and conditionImage with another taskType raises --- ...n_nova_canvas_image_edit_transformation.py | 44 ++++++++++++++++++- 1 file changed, 42 insertions(+), 2 deletions(-) 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 fbdd4d29ec6..21dde830964 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 @@ -44,6 +44,28 @@ else: NOVA_CANVAS_CONTROL_MODES: Final[tuple[str, ...]] = ("CANNY_EDGE", "SEGMENTATION") +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 image is not None: + return _file_types_to_b64(image) + if condition_image_b64 is not None and task_type != "TEXT_IMAGE": + raise ValueError( + "Amazon Nova Canvas conditionImage is only supported with " + f"taskType=TEXT_IMAGE (conditioned editing); got taskType={task_type!r}." + ) + if task_type != "TEXT_IMAGE" or condition_image_b64 is None: + raise ValueError( + "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, @@ -308,6 +330,7 @@ class BedrockAmazonNovaCanvasImageEditConfig(BaseImageEditConfig): "outPaintingMode", "controlMode", "controlStrength", + "conditionImage", "imageGenerationConfig", ] @@ -371,7 +394,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 @@ -410,7 +441,6 @@ 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", @@ -427,6 +457,16 @@ class BedrockAmazonNovaCanvasImageEditConfig(BaseImageEditConfig): 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 ValueError( + "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, From 7595b64a8cbd3a8a659e1086d960e75ec97ad636 Mon Sep 17 00:00:00 2001 From: lokm01 <2735155+lokm01@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:25:42 +0200 Subject: [PATCH 14/30] test(bedrock): cover canvas conditioning guard and conditionImage Pin the behavior that controlMode, controlStrength or style without taskType=TEXT_IMAGE raises, that conditionImage backs TEXT_IMAGE when no multipart image is sent, and that the multipart image field wins when both are supplied --- .../test_amazon_nova_canvas_image_edit.py | 152 +++++++++++++++++- 1 file changed, 151 insertions(+), 1 deletion(-) 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 9c9818849fd..ab493357901 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,7 +2,7 @@ import base64 import io -from typing import cast +from typing import Final, cast from unittest.mock import Mock import httpx @@ -945,3 +945,153 @@ 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(ValueError, match="taskType=TEXT_IMAGE") 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={}, # type: ignore[arg-type] + headers={}, + ) + assert "controlMode/controlStrength/style" in str(excinfo.value) + + +def test_transform_request_control_strength_without_text_image_task_type_raises(): + config = BedrockAmazonNovaCanvasImageEditConfig() + img = io.BytesIO(b"cond") + with pytest.raises(ValueError, match="taskType=TEXT_IMAGE"): + 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={}, # type: ignore[arg-type] + headers={}, + ) + + +def test_transform_request_style_without_text_image_task_type_raises(): + config = BedrockAmazonNovaCanvasImageEditConfig() + img = io.BytesIO(b"cond") + with pytest.raises(ValueError, match="taskType=TEXT_IMAGE"): + 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={}, # type: ignore[arg-type] + headers={}, + ) + + +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={}, # type: ignore[arg-type] + 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={}, # type: ignore[arg-type] + 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={}, # type: ignore[arg-type] + 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={}, # type: ignore[arg-type] + 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(ValueError, match="conditionImage is only supported"): + 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={}, # type: ignore[arg-type] + headers={}, + ) + + +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 From 2fdbc008feba1a3555e994d161b1d08a34b0cbf2 Mon Sep 17 00:00:00 2001 From: lokm01 <2735155+lokm01@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:20:32 +0200 Subject: [PATCH 15/30] fix(bedrock): map user input validation errors to http 400 Convert the user-input validation raises in the Nova Reel video transform/handler and the Nova Canvas conditioned-editing guards from plain ValueError to BedrockError(status_code=400). Through litellm.exception_type a ValueError surfaces as APIConnectionError (500-class, auto-retried by OpenAI SDKs) while BedrockError(400) maps to BadRequestError, matching the existing unsupported-operation error pattern in the video config. Response-path errors (missing invocationArn in provider payloads, the no-S3-output-location shape error) keep their previous types; the multi-shot duration accounting and cross-region id resolution land in follow-up commits. --- ...n_nova_canvas_image_edit_transformation.py | 26 ++- litellm/llms/bedrock/videos/handler.py | 26 ++- litellm/llms/bedrock/videos/transformation.py | 47 +++-- .../videos/test_amazon_nova_reel_video.py | 178 +++++++++++++----- .../test_amazon_nova_canvas_image_edit.py | 117 +++++++----- 5 files changed, 268 insertions(+), 126 deletions(-) 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 21dde830964..2f4c4017410 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 @@ -44,6 +44,16 @@ else: 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, @@ -54,12 +64,12 @@ def _resolve_edit_image_b64( if image is not None: return _file_types_to_b64(image) if condition_image_b64 is not None and task_type != "TEXT_IMAGE": - raise ValueError( + raise _invalid_input_error( "Amazon Nova Canvas conditionImage is only supported with " f"taskType=TEXT_IMAGE (conditioned editing); got taskType={task_type!r}." ) if task_type != "TEXT_IMAGE" or condition_image_b64 is None: - raise ValueError( + raise _invalid_input_error( "Nova Canvas image edit requires an image input. Pass the multipart " "`image` file, or a `conditionImage` for taskType=TEXT_IMAGE." ) @@ -116,12 +126,12 @@ def _nova_canvas_task_body( 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 ValueError( + 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 ValueError( + 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 @@ -131,9 +141,9 @@ def _nova_canvas_task_body( try: control_strength_value = float(control_strength) except (TypeError, ValueError): - raise ValueError("Amazon Nova Canvas controlStrength must be a number in [0.0, 1.0].") + 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 ValueError( + 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 @@ -446,7 +456,7 @@ class BedrockAmazonNovaCanvasImageEditConfig(BaseImageEditConfig): "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 " " @@ -462,7 +472,7 @@ class BedrockAmazonNovaCanvasImageEditConfig(BaseImageEditConfig): ) and task_type != "TEXT_IMAGE": # Conditioning fields only exist on textToImageParams (TEXT_IMAGE); # any other resolved task type would silently drop them. - raise ValueError( + 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." diff --git a/litellm/llms/bedrock/videos/handler.py b/litellm/llms/bedrock/videos/handler.py index 8e4ace0c771..4b53aa41d10 100644 --- a/litellm/llms/bedrock/videos/handler.py +++ b/litellm/llms/bedrock/videos/handler.py @@ -128,12 +128,15 @@ def _region_from_invocation_arn(invocation_arn: str) -> str | 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 ValueError(f"Invalid S3 output URI (expected s3://bucket/prefix): {s3_uri!r}") + 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 ValueError(f"Invalid S3 output URI: {s3_uri!r}") + raise BedrockError(status_code=400, message=f"Invalid S3 output URI: {s3_uri!r}") return bucket, prefix @@ -177,10 +180,10 @@ def _as_generic_litellm_params( if litellm_params is not None: metadata: Final = litellm_params.get("metadata") if isinstance(metadata, dict): - params.metadata = metadata # pyright: ignore[reportAttributeAccessIssue] # metadata is an extra-allowed field on GenericLiteLLMParams + 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] # litellm_call_id is an extra-allowed field on GenericLiteLLMParams (set by the @client decorator) + params.litellm_call_id = call_id # pyright: ignore[reportAttributeAccessIssue] # @client extra field return params @@ -473,7 +476,10 @@ class BedrockVideoGeneration(BaseAWSLLM): 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 ValueError(f"Could not extract a Bedrock invocation ARN from video id: {video_id!r}") + 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 @@ -587,9 +593,13 @@ class BedrockVideoGeneration(BaseAWSLLM): message=f"Nova Reel invocation failed: {failure_message}", ) if video_obj.status != "completed": - raise ValueError( - "Nova Reel video generation is not complete yet " - f"(status={video_obj.status}). Check video_status() before downloading." + # 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")) diff --git a/litellm/llms/bedrock/videos/transformation.py b/litellm/llms/bedrock/videos/transformation.py index bc550de7ccd..5d967ecd8e6 100644 --- a/litellm/llms/bedrock/videos/transformation.py +++ b/litellm/llms/bedrock/videos/transformation.py @@ -90,11 +90,20 @@ def _unsupported_operation_error(operation: str) -> BedrockError: 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 ValueError( + raise _user_input_error( "Nova Reel input_reference data URLs must be base64-encoded " "(data:image/png;base64,); got an unsupported data URL prefix." ) @@ -112,18 +121,18 @@ def _file_content_to_b64_and_format(image: FileContent) -> tuple[str, str]: try: image_bytes = base64.b64decode(payload, validate=True) except (binascii.Error, ValueError) as err: - raise ValueError(f"Nova Reel input_reference string did not decode as base64: {err}") from 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 ValueError( # noqa: TRY004 # ValueError maps to a 400-class through the video surface; TypeError would 500 + 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 ValueError( + raise _user_input_error( f"Nova Reel input_reference must be bytes, a file-like object or a base64 string; got {type(image)!r}" ) @@ -132,7 +141,7 @@ def _file_content_to_b64_and_format(image: FileContent) -> tuple[str, str]: elif image_bytes.startswith(b"\xff\xd8"): image_format = "jpeg" else: - raise ValueError( + raise _user_input_error( "Nova Reel input_reference images must be PNG or JPEG encoded; " f"unrecognized image header bytes {image_bytes[:8]!r}" ) @@ -195,7 +204,7 @@ def _generation_config_from_op(op: _VideoParams, task_type: object) -> _VideoPar try: parsed_seconds: Final = int(float(seconds)) except ValueError as err: - raise ValueError(f"Nova Reel seconds must be a number; got {seconds!r}") from 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) @@ -209,13 +218,13 @@ def _generation_config_from_op(op: _VideoParams, task_type: object) -> _VideoPar try: generation_config["fps"] = int(float(fps)) except ValueError as err: - raise ValueError(f"Nova Reel fps must be a number; got {fps!r}") from 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 ValueError(f"Nova Reel seed must be a number; got {seed!r}") from err + raise _user_input_error(f"Nova Reel seed must be a number; got {seed!r}") from err return generation_config @@ -225,7 +234,7 @@ def _task_params_from_op(task_type: object, op: _VideoParams, prompt: str, input # AWS schema: automated multi-shot takes multiShotAutomatedParams # (never textToVideoParams) and forbids input images. if input_reference is not None: - raise ValueError( + raise _user_input_error( "Nova Reel MULTI_SHOT_AUTOMATED does not accept input images " "(input_reference/image); automated multi-shot is text-driven only." ) @@ -239,7 +248,7 @@ def _task_params_from_op(task_type: object, op: _VideoParams, prompt: str, input if task_type == "MULTI_SHOT_MANUAL": manual_params: Final[object | None] = op.pop("multiShotManualParams", None) if not isinstance(manual_params, Mapping): - raise ValueError( + 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}." @@ -254,7 +263,7 @@ def _task_params_from_op(task_type: object, op: _VideoParams, prompt: str, input 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] # request params are untyped user input; the helper validates and raises for unsupported shapes + 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 @@ -266,6 +275,16 @@ def _task_params_from_op(task_type: object, op: _VideoParams, prompt: str, input 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: @@ -349,13 +368,13 @@ class BedrockNovaReelVideoConfig(BaseVideoConfig): output_s3_uri: Final = op.pop("output_s3_uri", None) if not output_s3_uri or not str(output_s3_uri).strip(): - raise ValueError( + 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 ValueError( + 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." ) @@ -368,7 +387,7 @@ class BedrockNovaReelVideoConfig(BaseVideoConfig): task_type: Final = op.pop("taskType", "TEXT_VIDEO") if not str(prompt or "").strip(): - raise ValueError("Nova Reel prompt is required and cannot be empty (or whitespace-only).") + 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. diff --git a/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py b/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py index f2970616b90..6e64c8abaa3 100644 --- a/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py +++ b/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py @@ -91,7 +91,7 @@ def test_transform_create_request_jpeg_detected(): def test_transform_create_request_both_reference_keys_pop_both(): - """input_reference wins and no stray `image` key leaks into modelInput (F1).""" + """input_reference wins and no stray `image` key leaks into modelInput.""" body = _create_request( { "output_s3_uri": "s3://bucket/out/", @@ -107,7 +107,7 @@ def test_transform_create_request_both_reference_keys_pop_both(): 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 (F1).""" + """A file-like `image` must be popped (not serialized) when input_reference is set.""" body = _create_request( { "output_s3_uri": "s3://bucket/out/", @@ -120,7 +120,7 @@ def test_transform_create_request_file_like_image_not_leaked_with_input_referenc def test_input_reference_data_url_round_trips(): - """data:image/png;base64, decodes only the payload and sniffs the format (F2).""" + """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] @@ -129,23 +129,29 @@ def test_input_reference_data_url_round_trips(): def test_input_reference_undecodable_string_raises(): - with pytest.raises(ValueError, match="base64"): + 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(ValueError, match="PNG or JPEG"): + 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(ValueError, match="base64"): + 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 (F3).""" + """Known non-AWS video-client params are dropped; provider keys still pass through.""" body = _create_request( { "output_s3_uri": "s3://bucket/out/", @@ -203,23 +209,31 @@ def test_seconds_string_coerces(): def test_seconds_non_numeric_raises(): """Non-numeric seconds must raise like fps/seed, not silently keep the default 6.""" - with pytest.raises(ValueError, match="seconds"): + 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(ValueError, match="fps"): + 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(ValueError, match="seed"): + 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(ValueError, match="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(): @@ -287,7 +301,7 @@ def test_transform_status_response_maps_aws_enum(raw_status, expected): 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 (F3/C3). + # 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) @@ -295,7 +309,7 @@ def test_transform_status_response_maps_aws_enum(raw_status, expected): def test_transform_status_response_missing_status_raises(): - """A get-async-invoke body without a status must fail loudly, not report InProgress (F6).""" + """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: @@ -433,7 +447,7 @@ def test_handler_builds_status_get_url(monkeypatch): def test_handler_status_region_defaults_to_arn_region(monkeypatch): - """No explicit aws_region_name: the ARN region must be resolved and returned (F8).""" + """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") @@ -493,12 +507,14 @@ def test_handler_video_content_requires_completed(monkeypatch): }, ) monkeypatch.setattr(handler, "_sync_get", lambda prepped, timeout=None: resp) - with pytest.raises(ValueError, match="not complete"): + 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 (F7).""" + """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 @@ -566,7 +582,7 @@ def test_handler_video_content_downloads_from_s3(monkeypatch): def test_handler_sync_create_passes_timeout(monkeypatch): - """The sync create path must forward its timeout to the POST (F9).""" + """The sync create path must forward its timeout to the POST.""" handler = BedrockVideoGeneration() monkeypatch.setattr( BedrockVideoGeneration, @@ -598,7 +614,7 @@ def test_handler_sync_create_passes_timeout(monkeypatch): def test_handler_sync_get_passes_timeout(monkeypatch): - """video_status must thread its timeout into the GET request (F9).""" + """video_status must thread its timeout into the GET request.""" handler = BedrockVideoGeneration() from litellm.types.videos.utils import encode_video_id_with_provider @@ -629,7 +645,7 @@ def test_handler_sync_get_passes_timeout(monkeypatch): def test_handler_async_get_passes_timeout(monkeypatch): - """_async_get must thread its timeout into the GET request (F9).""" + """_async_get must thread its timeout into the GET request.""" handler = BedrockVideoGeneration() seen: dict[str, object] = {} @@ -649,7 +665,7 @@ def test_handler_async_get_passes_timeout(monkeypatch): def test_sign_get_request_without_credentials_or_bearer_raises(monkeypatch): - """No SigV4 credentials and no bearer token must fail fast (F11), like the shared POST signer.""" + """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 @@ -663,7 +679,9 @@ def test_sign_get_request_without_credentials_or_bearer_raises(monkeypatch): ) -def _patch_s3_download(monkeypatch, handler: BedrockVideoGeneration, get_object_side_effect) -> tuple[list, list, list[str]]: +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 @@ -717,7 +735,7 @@ class _TrackingBody(io.BytesIO): def test_download_s3_object_uses_status_region_by_default(monkeypatch): - """region_default (ARN-derived) beats env/default; explicit litellm_params region still wins (F8).""" + """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( @@ -740,7 +758,7 @@ def test_download_s3_object_uses_status_region_by_default(monkeypatch): 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) (F12).""" + """The 404 names the s3Uri and tried keys, never the raw invocation (ARN/account id).""" from botocore.exceptions import ClientError def _raise(bucket, key): @@ -766,7 +784,7 @@ def test_download_s3_object_error_message_redacts_invocation(monkeypatch): def test_transform_create_response_accepts_202(monkeypatch): - """Any 2xx is a success once raise_for_status ran; 202 must reach the transform (F10).""" + """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) @@ -1026,7 +1044,7 @@ def test_main_layer_bedrock_status_and_content_pass_default_timeout(monkeypatch) ################################################# -# C5: _to_epoch iso8601 timestamps +# _to_epoch iso8601 timestamps ################################################# @@ -1074,34 +1092,40 @@ def test_transform_status_response_iso8601_times_become_epochs(): ################################################# -# C18: empty prompt guard +# empty prompt guard ################################################# def test_transform_create_request_blank_prompt_raises(): - with pytest.raises(ValueError, match="prompt is required"): + with pytest.raises(BedrockError) as excinfo: _create_request(prompt=" ") + assert excinfo.value.status_code == 400 + assert "prompt is required" in str(excinfo.value.message) ################################################# -# C7: s3:// scheme validation +# s3:// scheme validation ################################################# def test_transform_create_request_non_s3_output_uri_raises(): - with pytest.raises(ValueError, match="s3://"): + 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(ValueError, match="s3://"): + 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) ################################################# -# C2: MULTI_SHOT body construction +# MULTI_SHOT body construction ################################################# @@ -1127,15 +1151,17 @@ def test_transform_multi_shot_automated_preserves_explicit_params(): def test_transform_multi_shot_automated_with_image_raises(): - with pytest.raises(ValueError, match="does not accept input images"): - _create_request( - {"output_s3_uri": "s3://bucket/out/", "taskType": "MULTI_SHOT_AUTOMATED", "image": PNG_BYTES} - ) + 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(ValueError, match="multiShotManualParams"): + 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(): @@ -1154,7 +1180,7 @@ def test_transform_multi_shot_manual_body_omits_duration_seconds(): ################################################# -# C23: kmsKeyId/bucketOwner plumbing +# kmsKeyId/bucketOwner plumbing ################################################# @@ -1190,7 +1216,7 @@ def test_kms_and_bucket_owner_snake_case_aliases_accepted(): ################################################# -# C16: non-JSON 2xx responses +# non-JSON 2xx responses ################################################# @@ -1213,7 +1239,7 @@ def test_transform_status_response_non_json_maps_to_bedrock_502(): ################################################# -# C22: unsupported operations raise a 400-class error +# unsupported operations raise a 400-class error ################################################# @@ -1245,17 +1271,19 @@ def test_unsupported_remix_operation_raises_400_class_bedrock_error(): ################################################# -# C19: text-mode file guard +# text-mode file guard ################################################# def test_input_reference_text_mode_file_raises_value_error(): - with pytest.raises(ValueError, match="binary mode"): + 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) ################################################# -# C26: clientRequestToken falls back to litellm_call_id +# clientRequestToken falls back to litellm_call_id ################################################# @@ -1302,7 +1330,7 @@ def test_handler_create_threads_litellm_call_id_from_mapping_into_token(monkeypa ################################################# -# C1/C28: S3 download error mapping per AWS error code +# S3 download error mapping per AWS error code ################################################# @@ -1387,7 +1415,7 @@ def test_download_s3_object_http_404_client_error_falls_through(monkeypatch): ################################################# -# C14: bearer-only S3 path +# bearer-only S3 path ################################################# @@ -1414,7 +1442,7 @@ def test_download_s3_object_bearer_without_sigv4_credentials_maps_to_400(monkeyp ################################################# -# C4: S3 client timeouts + resource closes +# S3 client timeouts + resource closes ################################################# @@ -1468,7 +1496,7 @@ def test_download_s3_object_closes_client_on_404(monkeypatch): ################################################# -# C20: video id decoded once per status call +# video id decoded once per status call ################################################# @@ -1503,7 +1531,7 @@ def test_video_status_decodes_video_id_once(monkeypatch): ################################################# -# C24: private async status arm +# private async status arm ################################################# @@ -1533,7 +1561,7 @@ def test_video_status_async_dispatch_uses_private_async_arm(monkeypatch): ################################################# -# C8: api_key falls back to litellm_params on the bedrock branches +# api_key falls back to litellm_params on the bedrock branches ################################################# @@ -1577,3 +1605,57 @@ def test_bedrock_branches_fall_back_to_litellm_params_api_key(monkeypatch): 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) 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 ab493357901..a4e682309bd 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 @@ -9,6 +9,7 @@ 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, @@ -419,7 +420,7 @@ def test_transform_request_text_image_segmentation(): "controlMode": "SEGMENTATION", "controlStrength": 0.7, }, - litellm_params={}, # type: ignore[arg-type] + litellm_params={}, headers={}, ) assert body["taskType"] == "TEXT_IMAGE" @@ -442,7 +443,7 @@ def test_transform_request_text_image_canny_edge(): "taskType": "TEXT_IMAGE", "controlMode": "CANNY_EDGE", }, - litellm_params={}, # type: ignore[arg-type] + litellm_params={}, headers={}, ) assert body["taskType"] == "TEXT_IMAGE" @@ -461,7 +462,7 @@ def test_transform_request_text_image_defaults_omit_control_fields(): prompt="same composition", image=img, image_edit_optional_request_params={"taskType": "TEXT_IMAGE"}, - litellm_params={}, # type: ignore[arg-type] + litellm_params={}, headers={}, ) assert body["taskType"] == "TEXT_IMAGE" @@ -487,7 +488,7 @@ def test_transform_request_text_image_forwards_negative_text_and_igc(): "size": "1024x1024", "seed": 7, }, - litellm_params={}, # type: ignore[arg-type] + litellm_params={}, headers={}, ) assert body["textToImageParams"]["negativeText"] == "blurry" @@ -500,7 +501,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(ValueError, match="Unsupported Amazon Nova Canvas controlMode"): + with pytest.raises(BedrockError) as excinfo: config.transform_image_edit_request( model="amazon.nova-canvas-v1:0", prompt="restyle", @@ -509,17 +510,19 @@ def test_transform_request_text_image_invalid_control_mode_raises(): "taskType": "TEXT_IMAGE", "controlMode": "CANNY_EDIT", }, - litellm_params={}, # type: ignore[arg-type] + 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 (F13).""" + """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(ValueError, match="does not support a mask") as excinfo: + with pytest.raises(BedrockError) as excinfo: config.transform_image_edit_request( model="amazon.nova-canvas-v1:0", prompt="restyle", @@ -528,10 +531,12 @@ def test_transform_request_text_image_with_mask_raises(): "taskType": "TEXT_IMAGE", "mask": mask, }, - litellm_params={}, # type: ignore[arg-type] + litellm_params={}, headers={}, ) - assert "INPAINTING or OUTPAINTING" in str(excinfo.value) + 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(): @@ -539,7 +544,7 @@ def test_transform_request_text_image_with_mask_prompt_raises(): instead of silently dropping it.""" config = BedrockAmazonNovaCanvasImageEditConfig() img = io.BytesIO(b"cond") - with pytest.raises(ValueError, match="does not support a mask") as excinfo: + with pytest.raises(BedrockError) as excinfo: config.transform_image_edit_request( model="amazon.nova-canvas-v1:0", prompt="restyle", @@ -548,14 +553,16 @@ def test_transform_request_text_image_with_mask_prompt_raises(): "taskType": "TEXT_IMAGE", "maskPrompt": "the sky region", }, - litellm_params={}, # type: ignore[arg-type] + litellm_params={}, headers={}, ) - assert "INPAINTING or OUTPAINTING" in str(excinfo.value) + 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 F13.""" + """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") @@ -567,7 +574,7 @@ def test_transform_request_image_variation_with_mask_still_ignores_mask(): "taskType": "IMAGE_VARIATION", "mask": mask, }, - litellm_params={}, # type: ignore[arg-type] + litellm_params={}, headers={}, ) assert body["taskType"] == "IMAGE_VARIATION" @@ -576,41 +583,43 @@ def test_transform_request_image_variation_with_mask_still_ignores_mask(): def test_transform_request_text_image_empty_prompt_raises(): - """An empty TEXT_IMAGE prompt must fail fast, not silently send a blank (F14).""" + """An empty TEXT_IMAGE prompt must fail fast, not silently send a blank.""" config = BedrockAmazonNovaCanvasImageEditConfig() img = io.BytesIO(b"cond") - with pytest.raises(ValueError, match="TEXT_IMAGE requires a text prompt"): + 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={}, # type: ignore[arg-type] + 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(ValueError, match="TEXT_IMAGE requires a text prompt"): + 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={}, # type: ignore[arg-type] + 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 (F15).""" - import re - + """controlStrength outside 0.0-1.0 fails fast.""" config = BedrockAmazonNovaCanvasImageEditConfig() img = io.BytesIO(b"cond") - with pytest.raises(ValueError, match=re.escape("controlStrength must be between 0.0 and 1.0")): + with pytest.raises(BedrockError) as excinfo: config.transform_image_edit_request( model="amazon.nova-canvas-v1:0", prompt="restyle", @@ -619,14 +628,16 @@ def test_transform_request_text_image_control_strength_out_of_range_raises(): "taskType": "TEXT_IMAGE", "controlStrength": 1.5, }, - litellm_params={}, # type: ignore[arg-type] + 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 (F15).""" + """Boundary controlStrength values 0.0 and 1.0 are accepted.""" config = BedrockAmazonNovaCanvasImageEditConfig() img = io.BytesIO(b"cond") body, _ = config.transform_image_edit_request( @@ -637,7 +648,7 @@ def test_transform_request_text_image_control_strength_bounds_pass(control_stren "taskType": "TEXT_IMAGE", "controlStrength": control_strength, }, - litellm_params={}, # type: ignore[arg-type] + litellm_params={}, headers={}, ) assert body["textToImageParams"]["controlStrength"] == control_strength @@ -655,17 +666,17 @@ def test_transform_request_text_image_control_strength_string_coerced(): "taskType": "TEXT_IMAGE", "controlStrength": "0.7", }, - litellm_params={}, # type: ignore[arg-type] + 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 raise ValueError, not TypeError.""" + """A non-numeric controlStrength string must fail fast, not TypeError.""" config = BedrockAmazonNovaCanvasImageEditConfig() img = io.BytesIO(b"cond") - with pytest.raises(ValueError, match="controlStrength must be a number"): + with pytest.raises(BedrockError) as excinfo: config.transform_image_edit_request( model="amazon.nova-canvas-v1:0", prompt="restyle", @@ -674,13 +685,15 @@ def test_transform_request_text_image_control_strength_non_numeric_string_raises "taskType": "TEXT_IMAGE", "controlStrength": "abc", }, - litellm_params={}, # type: ignore[arg-type] + 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 (F16).""" + """style is forwarded into textToImageParams for conditioned editing.""" config = BedrockAmazonNovaCanvasImageEditConfig() img = io.BytesIO(b"cond") body, _ = config.transform_image_edit_request( @@ -692,7 +705,7 @@ def test_transform_request_text_image_forwards_style(): "controlMode": "SEGMENTATION", "style": "DESIGN_SKETCH", }, - litellm_params={}, # type: ignore[arg-type] + litellm_params={}, headers={}, ) assert body["taskType"] == "TEXT_IMAGE" @@ -707,7 +720,7 @@ def test_transform_request_text_image_omits_style_when_absent(): prompt="restyle", image=img, image_edit_optional_request_params={"taskType": "TEXT_IMAGE"}, - litellm_params={}, # type: ignore[arg-type] + litellm_params={}, headers={}, ) assert "style" not in body["textToImageParams"] @@ -722,7 +735,7 @@ def test_get_supported_openai_params_includes_conditioning_fields(): def test_get_supported_openai_params_includes_style(): - """style is advertised for TEXT_IMAGE conditioned editing (F16).""" + """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 @@ -952,7 +965,7 @@ def test_transform_request_conditioning_fields_without_text_image_task_type_rais fail fast instead of being silently dropped by mask/variation routing.""" config = BedrockAmazonNovaCanvasImageEditConfig() img = io.BytesIO(b"cond") - with pytest.raises(ValueError, match="taskType=TEXT_IMAGE") as excinfo: + with pytest.raises(BedrockError) as excinfo: config.transform_image_edit_request( model="amazon.nova-canvas-v1:0", prompt="restyle", @@ -960,16 +973,18 @@ def test_transform_request_conditioning_fields_without_text_image_task_type_rais image_edit_optional_request_params={ "controlMode": "SEGMENTATION", }, - litellm_params={}, # type: ignore[arg-type] + litellm_params={}, headers={}, ) - assert "controlMode/controlStrength/style" in str(excinfo.value) + 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(ValueError, match="taskType=TEXT_IMAGE"): + with pytest.raises(BedrockError) as excinfo: config.transform_image_edit_request( model="amazon.nova-canvas-v1:0", prompt="restyle", @@ -977,15 +992,17 @@ def test_transform_request_control_strength_without_text_image_task_type_raises( image_edit_optional_request_params={ "controlStrength": 0.4, }, - litellm_params={}, # type: ignore[arg-type] + 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(ValueError, match="taskType=TEXT_IMAGE"): + with pytest.raises(BedrockError) as excinfo: config.transform_image_edit_request( model="amazon.nova-canvas-v1:0", prompt="restyle", @@ -993,9 +1010,11 @@ def test_transform_request_style_without_text_image_task_type_raises(): image_edit_optional_request_params={ "style": "DESIGN_SKETCH", }, - litellm_params={}, # type: ignore[arg-type] + 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(): @@ -1010,7 +1029,7 @@ def test_transform_request_conditioning_fields_with_explicit_text_image_works(): "taskType": "TEXT_IMAGE", "controlMode": "CANNY_EDGE", }, - litellm_params={}, # type: ignore[arg-type] + litellm_params={}, headers={}, ) assert body["taskType"] == "TEXT_IMAGE" @@ -1030,7 +1049,7 @@ def test_transform_request_condition_image_without_multipart_image(): "conditionImage": condition_b64, "controlMode": "SEGMENTATION", }, - litellm_params={}, # type: ignore[arg-type] + litellm_params={}, headers={}, ) assert body["taskType"] == "TEXT_IMAGE" @@ -1050,7 +1069,7 @@ def test_transform_request_condition_image_bytes_accepted(): "taskType": "TEXT_IMAGE", "conditionImage": b"raw-cond-bytes", }, - litellm_params={}, # type: ignore[arg-type] + litellm_params={}, headers={}, ) assert body["textToImageParams"]["conditionImage"] == base64.b64encode(b"raw-cond-bytes").decode("utf-8") @@ -1068,7 +1087,7 @@ def test_transform_request_multipart_image_wins_over_condition_image(): "taskType": "TEXT_IMAGE", "conditionImage": condition_b64, }, - litellm_params={}, # type: ignore[arg-type] + litellm_params={}, headers={}, ) assert body["textToImageParams"]["conditionImage"] == base64.b64encode(b"from-multipart").decode("utf-8") @@ -1077,7 +1096,7 @@ def test_transform_request_multipart_image_wins_over_condition_image(): 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(ValueError, match="conditionImage is only supported"): + with pytest.raises(BedrockError) as excinfo: config.transform_image_edit_request( model="amazon.nova-canvas-v1:0", prompt="restyle", @@ -1086,9 +1105,11 @@ def test_transform_request_condition_image_with_other_task_type_raises(): "taskType": "IMAGE_VARIATION", "conditionImage": base64.b64encode(b"cond").decode("utf-8"), }, - litellm_params={}, # type: ignore[arg-type] + 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(): From e59a9cb688fb542e9c5e2fae557727940d8b3ead Mon Sep 17 00:00:00 2001 From: lokm01 <2735155+lokm01@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:20:42 +0200 Subject: [PATCH 16/30] fix(bedrock): keep style passthrough and multi shot spend for canvas and reel style: image_edit blocklisted "style" in its openai_params filter, so the param never reached non_default_params and was silently dropped for every provider that advertises it (bedrock Nova Canvas; recraft has the same latent bug). image_edit has no named style argument, so the blocklist entry only dropped the kwarg; remove it. Providers that do not advertise style in get_supported_openai_params keep dropping it exactly as before, and the bedrock stability and BFL transforms filter unknown keys, so the non_default_params merges are unaffected. MULTI_SHOT_MANUAL: _duration_seconds_from_request only read videoGenerationConfig.durationSeconds, so manual multi-shot requests recorded zero spend. Sum multiShotManualParams.shots[*].durationSeconds into usage.duration_seconds (3 shots x 6s now prices 18 x $0.08). --- litellm/images/main.py | 6 ++- litellm/llms/bedrock/videos/transformation.py | 28 ++++++++++++- .../videos/test_amazon_nova_reel_video.py | 41 +++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/litellm/images/main.py b/litellm/images/main.py index 1f722eb752a..042db3b4aaf 100644 --- a/litellm/images/main.py +++ b/litellm/images/main.py @@ -754,7 +754,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", ] litellm_params_list: Final = all_litellm_params diff --git a/litellm/llms/bedrock/videos/transformation.py b/litellm/llms/bedrock/videos/transformation.py index 5d967ecd8e6..a2c5628f532 100644 --- a/litellm/llms/bedrock/videos/transformation.py +++ b/litellm/llms/bedrock/videos/transformation.py @@ -149,12 +149,38 @@ def _file_content_to_b64_and_format(image: FileContent) -> tuple[str, str]: def _duration_seconds_from_request(request_data: Mapping[str, object] | None) -> float | None: - """durationSeconds from the StartAsyncInvoke request envelope, for cost calculation.""" + """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 diff --git a/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py b/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py index 6e64c8abaa3..e3b902e216d 100644 --- a/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py +++ b/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py @@ -1659,3 +1659,44 @@ def test_avideo_content_not_complete_yet_maps_to_bad_request(monkeypatch): 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) From 2b4278cb981e1fd3d0ad44ad3ed7f80371f76757 Mon Sep 17 00:00:00 2001 From: lokm01 <2735155+lokm01@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:20:50 +0200 Subject: [PATCH 17/30] fix(bedrock): resolve cross region model ids for video status and content Video and character ids created through a cross-region inference profile encode model ids like us.amazon.nova-reel-v1:1, which router.resolve_model_name_from_model_id cannot match against a deployment configured with the base bedrock/amazon.nova-reel-v1:1 model, so status/content dispatched without the deployment aws_* credentials. Strip the region prefix with BedrockModelInfo.get_base_model (the same normalization the Nova Canvas transform uses) and retry the resolution, scoped to the bedrock provider path; the router method itself is unchanged. --- litellm/proxy/video_endpoints/endpoints.py | 53 ++++++++++++++++--- .../videos/test_amazon_nova_reel_video.py | 40 ++++++++++++++ 2 files changed, 86 insertions(+), 7 deletions(-) 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/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py b/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py index e3b902e216d..71c2145a212 100644 --- a/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py +++ b/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py @@ -1700,3 +1700,43 @@ def test_transform_multi_shot_manual_usage_sums_shot_durations(): 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 From 04f191ebf287af8ef7f8d087062b402255843d3a Mon Sep 17 00:00:00 2001 From: lokm01 <2735155+lokm01@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:20:55 +0200 Subject: [PATCH 18/30] test(bedrock): mirror canvas coverage into the ci covered tree Self-contained mirror of the branch-added Nova Canvas tests under tests/test_litellm (the tree GH Actions covers), with the updated BedrockError(400) expectations, plus two litellm-level proxy-contract tests: the TEXT_IMAGE mask guard surfaces as BadRequestError 400 through aimage_edit, and style passed to aimage_edit reaches the Nova Canvas transform (regression for the blocklist drop). --- .../llms/bedrock/image_edit/__init__.py | 0 .../test_amazon_nova_canvas_image_edit.py | 500 ++++++++++++++++++ 2 files changed, 500 insertions(+) create mode 100644 tests/test_litellm/llms/bedrock/image_edit/__init__.py create mode 100644 tests/test_litellm/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py diff --git a/tests/test_litellm/llms/bedrock/image_edit/__init__.py b/tests/test_litellm/llms/bedrock/image_edit/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/test_litellm/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py b/tests/test_litellm/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py new file mode 100644 index 00000000000..3d1154f5c95 --- /dev/null +++ b/tests/test_litellm/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py @@ -0,0 +1,500 @@ +"""CI-covered tests for the Nova Canvas conditioned-editing additions (issue #39552). + +Self-contained mirror of 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 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) + + +################################################# +# 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_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=" From 09b1f7b70ba907fdc16295a69395f1e70afbb2a9 Mon Sep 17 00:00:00 2001 From: lokm01 <2735155+lokm01@users.noreply.github.com> Date: Fri, 25 Sep 2026 08:07:37 +0200 Subject: [PATCH 19/30] fix(bedrock): redact signed headers before logging callbacks the bedrock video and image edit handlers passed the full signed request headers (authorization + x-amz-security-token + x-amz-date) into logging pre_call additional_args, which litellm_logging forwards unmasked to logger_fn and custom log_pre_api_call callbacks. add a shared redact_bedrock_headers_for_logging helper in bedrock common_utils with a case-insensitive allowlist (content-type, content-length, host, accept, x-amzn-requestid, x-amzn-errortype); every other header keeps its name with the value replaced by [redacted]. both handlers now log the redacted copy while the sent request keeps the real headers. --- litellm/llms/bedrock/common_utils.py | 33 ++++++++ litellm/llms/bedrock/image_edit/handler.py | 7 +- litellm/llms/bedrock/videos/handler.py | 7 +- .../test_amazon_nova_canvas_image_edit.py | 81 ++++++++++++++++++ .../videos/test_amazon_nova_reel_video.py | 84 +++++++++++++++++++ 5 files changed, 208 insertions(+), 4 deletions(-) diff --git a/litellm/llms/bedrock/common_utils.py b/litellm/llms/bedrock/common_utils.py index 9b52f531cbb..1fe1fcb6ce7 100644 --- a/litellm/llms/bedrock/common_utils.py +++ b/litellm/llms/bedrock/common_utils.py @@ -121,6 +121,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/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/handler.py b/litellm/llms/bedrock/videos/handler.py index 4b53aa41d10..ec129a9bbe6 100644 --- a/litellm/llms/bedrock/videos/handler.py +++ b/litellm/llms/bedrock/videos/handler.py @@ -41,7 +41,7 @@ from ..base_aws_llm import ( bedrock_bearer_token, pop_aws_auth_params, ) -from ..common_utils import BedrockError +from ..common_utils import BedrockError, redact_bedrock_headers_for_logging if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging @@ -290,7 +290,10 @@ class BedrockVideoGeneration(BaseAWSLLM): additional_args={ # mutable-ok: logging payload dict built for this call "complete_input_dict": data, "api_base": 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 endpoint_url, prepped, body, data diff --git a/tests/test_litellm/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py b/tests/test_litellm/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py index 3d1154f5c95..786072cf4c1 100644 --- a/tests/test_litellm/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py +++ b/tests/test_litellm/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py @@ -12,6 +12,7 @@ import asyncio import base64 import io import json +from datetime import datetime from typing import Final from unittest.mock import patch @@ -498,3 +499,83 @@ async def test_aimage_edit_forwards_style_to_nova_canvas_transform(monkeypatch): assert isinstance(body, dict) assert body["textToImageParams"]["style"] == "DESIGN_SKETCH" assert response.data[0].b64_json == "aGk=" + + +################################################# +# 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.llms.bedrock.image_edit.handler import BedrockImageEdit + from litellm.litellm_core_utils.litellm_logging import Logging + + 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/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py b/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py index 71c2145a212..343bc54622c 100644 --- a/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py +++ b/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py @@ -433,6 +433,90 @@ def test_handler_builds_async_invoke_request(monkeypatch): 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") From f3bbec234bbcfc4ddca1d31eac60f21061efd797 Mon Sep 17 00:00:00 2001 From: lokm01 <2735155+lokm01@users.noreply.github.com> Date: Fri, 25 Sep 2026 08:08:42 +0200 Subject: [PATCH 20/30] fix(bedrock): gate nova reel flat s3 fallback to v1:0 invocations video_content always added the flat {prefix}/output.mp4 key as a second candidate. with a shared s3 prefix a missing invocation key can resolve to a foreign or stale object from another invocation. resolve the gate model from the modelArn on the raw get-async-invoke response (authoritative over the id-encoded model, falling back to the decoded id when it carries no nova-reel model) and keep the flat candidate only for amazon.nova-reel-v1:0, whose flows placed output.mp4 directly under the prefix. v1:1 writes per-invocation folders (aws docs), so it gets the invocation key alone and a missing key surfaces the existing not-found error. --- litellm/llms/bedrock/videos/handler.py | 23 +++-- .../videos/test_amazon_nova_reel_video.py | 84 +++++++++++++++++++ 2 files changed, 102 insertions(+), 5 deletions(-) diff --git a/litellm/llms/bedrock/videos/handler.py b/litellm/llms/bedrock/videos/handler.py index ec129a9bbe6..1ac6c8cbc03 100644 --- a/litellm/llms/bedrock/videos/handler.py +++ b/litellm/llms/bedrock/videos/handler.py @@ -41,7 +41,7 @@ from ..base_aws_llm import ( bedrock_bearer_token, pop_aws_auth_params, ) -from ..common_utils import BedrockError, redact_bedrock_headers_for_logging +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 @@ -617,11 +617,24 @@ class BedrockVideoGeneration(BaseAWSLLM): ) bucket, prefix = _parse_s3_uri(s3_uri) - # Nova writes output.mp4 into a per-invocation folder (v1:1 docs); older - # v1:0 flows placed it directly under the configured prefix. - key_candidates: Final = [ # mutable-ok: candidate S3 keys are tried in order + # 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("/"), + *( + [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, diff --git a/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py b/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py index 343bc54622c..09c9da314c2 100644 --- a/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py +++ b/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py @@ -631,6 +631,8 @@ 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, @@ -665,6 +667,88 @@ def test_handler_video_content_downloads_from_s3(monkeypatch): 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() From 842e3325a1898279508ec18216c497b74a599d8c Mon Sep 17 00:00:00 2001 From: lokm01 <2735155+lokm01@users.noreply.github.com> Date: Fri, 25 Sep 2026 08:11:18 +0200 Subject: [PATCH 21/30] fix(images): allow image-less edits through aimage_edit aimage_edit required image as a mandatory positional argument while the sync image_edit already accepted none; the proxy omits the key for image-less requests, so those failed with a typeerror before any provider code ran. none also used to be wrapped into [none] on the way to the handler. make image optional (default none, model and prompt become keyword-only to keep them required) and build the images list the same way the sync path does: [] when no image is supplied instead of [none]. behavior when an image is provided is unchanged. --- litellm/images/main.py | 9 ++- .../test_amazon_nova_canvas_image_edit.py | 57 +++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/litellm/images/main.py b/litellm/images/main.py index 042db3b4aaf..b091bf27c8a 100644 --- a/litellm/images/main.py +++ b/litellm/images/main.py @@ -975,7 +975,8 @@ def image_edit( @client async def aimage_edit( - image: FileTypes | list[FileTypes], + image: FileTypes | list[FileTypes] | None = None, + *, model: str, prompt: str, mask: str | None = None, @@ -1015,7 +1016,11 @@ async def aimage_edit( model=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/tests/test_litellm/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py b/tests/test_litellm/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py index 786072cf4c1..2c52e936d35 100644 --- a/tests/test_litellm/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py +++ b/tests/test_litellm/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py @@ -501,6 +501,63 @@ async def test_aimage_edit_forwards_style_to_nova_canvas_transform(monkeypatch): 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_none_image_builds_empty_list(monkeypatch): + """An omitted image must arrive at the handler as [] (empty list), not [None].""" + import litellm.images.main as images_main + from unittest.mock import Mock + + 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) ################################################# From b536dd3741189271771bd63a690dc9574b360c83 Mon Sep 17 00:00:00 2001 From: lokm01 <2735155+lokm01@users.noreply.github.com> Date: Fri, 25 Sep 2026 08:12:22 +0200 Subject: [PATCH 22/30] fix(bedrock): map invalid canvas inputs to 400 and reject conflicting conditionImage the conditionImage-with-non-text_image rejection sat after the multipart-image early return, so image + conditionImage with a non-text_image tasktype silently discarded the conditionImage instead of failing; it now runs first. the unsupported-tasktype and inpainting-without-mask guards raised plain valueerror, which the exception mapper surfaces as a 500-class error that openai sdks retry; both now raise the bedrock 400 used by the other input guards. --- ...n_nova_canvas_image_edit_transformation.py | 11 +-- .../test_amazon_nova_canvas_image_edit.py | 68 +++++++++++++++++++ .../test_amazon_nova_canvas_image_edit.py | 8 ++- 3 files changed, 80 insertions(+), 7 deletions(-) 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 2f4c4017410..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 @@ -61,13 +61,16 @@ def _resolve_edit_image_b64( ) -> str: """Base64 image for the task body: the multipart ``image`` wins; ``conditionImage`` (already encoded) backs TEXT_IMAGE when no multipart file was sent.""" - if image is not None: - return _file_types_to_b64(image) 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 " @@ -180,7 +183,7 @@ 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), " @@ -195,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" diff --git a/tests/test_litellm/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py b/tests/test_litellm/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py index 2c52e936d35..835bbc897c0 100644 --- a/tests/test_litellm/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py +++ b/tests/test_litellm/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py @@ -223,6 +223,59 @@ def test_transform_request_condition_image_with_other_task_type_raises(): 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) + + ################################################# # controlStrength coercion and range ################################################# @@ -468,6 +521,21 @@ async def test_aimage_edit_mask_with_text_image_maps_to_bad_request(monkeypatch) 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 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 a4e682309bd..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 @@ -362,10 +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", @@ -374,13 +374,14 @@ 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", @@ -389,6 +390,7 @@ def test_transform_request_unknown_task_type_raises(): litellm_params={}, # type: ignore[arg-type] headers={}, ) + assert excinfo.value.status_code == 400 def test_transform_request_background_removal(): From 1e7072295a0d712b1d24cbe73786d349ec1975be Mon Sep 17 00:00:00 2001 From: lokm01 <2735155+lokm01@users.noreply.github.com> Date: Fri, 25 Sep 2026 08:13:41 +0200 Subject: [PATCH 23/30] fix(bedrock): harden status parse and s3 credential error paths _map_status_response called response.json() before the transform's guarded parse, so a non-json 200 from the status endpoint raised jsondecodeerror and surfaced as a 500; it now maps to a bedrock 502 with a clear message. _download_s3_object resolved the sigv4 credential chain before the bearer-token guidance check, so a bearer-only caller without ambient aws credentials hit nocredentialserror (500-class) instead of the actionable 400 explaining that bearer tokens cannot read s3. the nocredentialserror is now caught around the credential resolution: bearer in play raises the guidance 400, no bearer keeps propagation unchanged. --- litellm/llms/bedrock/videos/handler.py | 43 ++++++--- .../videos/test_amazon_nova_reel_video.py | 87 +++++++++++++++++++ 2 files changed, 117 insertions(+), 13 deletions(-) diff --git a/litellm/llms/bedrock/videos/handler.py b/litellm/llms/bedrock/videos/handler.py index 1ac6c8cbc03..826f58446d5 100644 --- a/litellm/llms/bedrock/videos/handler.py +++ b/litellm/llms/bedrock/videos/handler.py @@ -525,7 +525,15 @@ class BedrockVideoGeneration(BaseAWSLLM): headers=response.headers, response=response, ) - raw: Final[BedrockGetAsyncInvokeResponse] = response.json() + 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, @@ -668,28 +676,37 @@ class BedrockVideoGeneration(BaseAWSLLM): try: import boto3 from botocore.config import Config as BotocoreConfig - from botocore.exceptions import BotoCoreError, ClientError + 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) - # Always resolve SigV4 credentials for S3: a Bedrock bearer token only - # covers the async-invoke API, never S3 object access. - credentials, region = self._load_credentials( - optional_params, - aws_region_name=(explicit_region if explicit_region is not None else region_default), + 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=( - "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." - ), + 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: diff --git a/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py b/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py index 09c9da314c2..ce8e1486ffe 100644 --- a/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py +++ b/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py @@ -1609,6 +1609,93 @@ def test_download_s3_object_bearer_without_sigv4_credentials_maps_to_400(monkeyp 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 ################################################# From b24d11fc89157315e2ff61de3e25a8897672ab82 Mon Sep 17 00:00:00 2001 From: lokm01 <2735155+lokm01@users.noreply.github.com> Date: Fri, 25 Sep 2026 08:17:07 +0200 Subject: [PATCH 24/30] fix(bedrock): return 400 for unsupported bedrock video operations utils.get_provider_video_config returns the nova reel config for model none, which also serves the character, edit and extension routes; their base video config defaults raised notimplementederror, surfacing as 500-class errors through the exception mapper. override the create/get character, edit and extension request and response transforms on bedrocknovareelvideoconfig to raise the same bedrock 400 the remix, list and delete overrides already use, so unsupported routes map to badrequesterror instead. --- litellm/llms/bedrock/videos/transformation.py | 77 +++++++++- .../videos/test_amazon_nova_reel_video.py | 133 ++++++++++++++++++ 2 files changed, 209 insertions(+), 1 deletion(-) diff --git a/litellm/llms/bedrock/videos/transformation.py b/litellm/llms/bedrock/videos/transformation.py index a2c5628f532..0c0520bbd32 100644 --- a/litellm/llms/bedrock/videos/transformation.py +++ b/litellm/llms/bedrock/videos/transformation.py @@ -49,7 +49,7 @@ from litellm.types.videos.utils import ( 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 VideoObject + from litellm.types.videos.main import CharacterObject, VideoObject _SupportedParams: TypeAlias = list[str] _VideoParams: TypeAlias = dict[str, object] @@ -644,6 +644,81 @@ class BedrockNovaReelVideoConfig(BaseVideoConfig): ) -> 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.""" diff --git a/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py b/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py index ce8e1486ffe..fda2a68a905 100644 --- a/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py +++ b/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py @@ -1438,6 +1438,139 @@ def test_unsupported_remix_operation_raises_400_class_bedrock_error(): 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_avideo_edit_bedrock_maps_unsupported_operation_to_bad_request(monkeypatch): + """Through the litellm video layer, litellm.avideo_edit on bedrock must surface + BadRequestError (400-class), never a NotImplementedError-driven 500. get_complete_url + is stubbed because it is documented as unused for this config (URLs are built in + the handler); the unsupported-operation error comes from the transform.""" + monkeypatch.setattr( + BedrockNovaReelVideoConfig, + "get_complete_url", + lambda self, model, api_base, litellm_params: "https://example.com/videos/edits", + ) + with pytest.raises(litellm.BadRequestError) as excinfo: + asyncio.run( + litellm.avideo_edit( + video_id="some-video-id", + prompt="brighter", + custom_llm_provider="bedrock", + ) + ) + # The 400-class mapping is the contract; the generic edit error wrapper reads + # .text off the exception (empty for a synthesized BedrockError), so only the + # status survives here. The message is asserted in the config-level tests. + assert excinfo.value.status_code == 400 + + +def test_avideo_create_character_bedrock_maps_unsupported_operation_to_bad_request(monkeypatch): + """Through the litellm video layer, litellm.avideo_create_character on bedrock must + surface BadRequestError (400-class). get_complete_url is stubbed as above.""" + monkeypatch.setattr( + BedrockNovaReelVideoConfig, + "get_complete_url", + lambda self, model, api_base, litellm_params: "https://example.com/characters", + ) + with pytest.raises(litellm.BadRequestError) as excinfo: + asyncio.run( + litellm.avideo_create_character( + name="hero", + video=b"video-bytes", + custom_llm_provider="bedrock", + ) + ) + assert excinfo.value.status_code == 400 + assert "video create character is not supported" in str(excinfo.value) + + ################################################# # text-mode file guard ################################################# From 7061902ef4f5d1ec8f5142f3c7900a83364e2e72 Mon Sep 17 00:00:00 2001 From: lokm01 <2735155+lokm01@users.noreply.github.com> Date: Fri, 25 Sep 2026 08:17:30 +0200 Subject: [PATCH 25/30] chore(bedrock): sync packaged provider endpoint matrix the packaged provider_endpoints_support_backup.json (the copy the proxy serves) lacked the bedrock video_generations flag that the root provider_endpoints_support.json already carries; sync the one line so both matrices report the video generations endpoint. --- litellm/provider_endpoints_support_backup.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/litellm/provider_endpoints_support_backup.json b/litellm/provider_endpoints_support_backup.json index 1fcb7600a5e..2e998ee6205 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": { From 8d0921148aee71c8d7d35764a9fbc89b34e2c25a Mon Sep 17 00:00:00 2001 From: lokm01 <2735155+lokm01@users.noreply.github.com> Date: Fri, 25 Sep 2026 08:20:03 +0200 Subject: [PATCH 26/30] refactor(bedrock): move bedrock video dispatch into llms the create, status and content bedrock branches in videos/main.py each inlined the handler import, the aws_* param merge and the handler call. extract them into litellm/llms/bedrock/videos/dispatch.py with three module functions that lazy-import the handler (no import cost or circular import risk) and forward their arguments verbatim; the aws_* merge moves unchanged into the create dispatch. the main-layer sites become thin guard clauses. --- litellm/llms/bedrock/videos/dispatch.py | 118 +++++++++++++++++ litellm/videos/main.py | 40 ++---- .../videos/test_amazon_nova_reel_video.py | 125 ++++++++++++++++++ 3 files changed, 254 insertions(+), 29 deletions(-) create mode 100644 litellm/llms/bedrock/videos/dispatch.py 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/videos/main.py b/litellm/videos/main.py index 5bc91e14211..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 @@ -245,36 +250,17 @@ def video_generation( # Route bedrock to its specific handler (AWS SigV4 signing required) if custom_llm_provider == "bedrock": - from litellm.llms.bedrock.videos.handler import BedrockVideoGeneration - - bedrock_optional_params: Final[dict] = ( - dict( # mutable-ok: aws_* params are merged in before the handler call - video_generation_request_params - ) - ) - # aws_* auth params ride on litellm_params; surface them for the handler - # (mirrors how images/main.py merges non_default_params for bedrock). - 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( + return dispatch_bedrock_video_generation( model=model, prompt=prompt, - optional_params=bedrock_optional_params, + video_generation_request_params=video_generation_request_params, + litellm_params=litellm_params, logging_obj=litellm_logging_obj, timeout=timeout or DEFAULT_REQUEST_TIMEOUT, - avideo_generation=_is_async, + is_async=_is_async, client=kwargs.get("client"), - api_base=litellm_params.get("api_base"), extra_headers=extra_headers, api_key=kwargs.get("api_key") or litellm_params.get("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, ) # Call the handler with _is_async flag instead of directly calling the async handler @@ -390,9 +376,7 @@ def video_content( # Call the handler with _is_async flag instead of directly calling the async handler if custom_llm_provider == "bedrock": - from litellm.llms.bedrock.videos.handler import BedrockVideoGeneration - - return BedrockVideoGeneration().video_content( + return dispatch_bedrock_video_content( video_id=video_id, litellm_params=litellm_params, logging_obj=litellm_logging_obj, @@ -1105,9 +1089,7 @@ def video_status( # Route bedrock to its specific handler (AWS SigV4 signing required) if custom_llm_provider == "bedrock": - from litellm.llms.bedrock.videos.handler import BedrockVideoGeneration - - return BedrockVideoGeneration().video_status( + return dispatch_bedrock_video_status( video_id=video_id, litellm_params=litellm_params, logging_obj=litellm_logging_obj, diff --git a/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py b/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py index fda2a68a905..121cebea096 100644 --- a/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py +++ b/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py @@ -1211,6 +1211,131 @@ def test_main_layer_bedrock_status_and_content_pass_default_timeout(monkeypatch) 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 ################################################# From 354dbc93ec4a5d24e7d64c3cc36011a29e9a7261 Mon Sep 17 00:00:00 2001 From: lokm01 <2735155+lokm01@users.noreply.github.com> Date: Fri, 25 Sep 2026 08:25:38 +0200 Subject: [PATCH 27/30] test(bedrock): close remaining patch coverage gaps port the last three conditioned-edit canvas cases into the ci covered tree (missing image input error, invalid controlmode, negativetext forwarding) and add handler and transform coverage: create-path httpstatuserror and timeout mapping on both arms, empty video id and non-200 status guards, credential region fallback chain, sigv4 credentials threaded into the boto3 session, boto3 import guards, input_reference and duration parsing edge branches, the remaining notimplemented guards and unsupported-operation response overrides, and the get_provider_video_config none arm for a bedrock prefixed non-reel model. videos handler and transformation now sit at full line coverage from the ci covered tree. --- .../test_amazon_nova_canvas_image_edit.py | 48 ++ .../videos/test_amazon_nova_reel_video.py | 446 ++++++++++++++++++ 2 files changed, 494 insertions(+) diff --git a/tests/test_litellm/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py b/tests/test_litellm/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py index 835bbc897c0..547d3b2d7b2 100644 --- a/tests/test_litellm/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py +++ b/tests/test_litellm/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py @@ -276,6 +276,54 @@ def test_transform_request_unsupported_task_type_maps_to_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 ################################################# diff --git a/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py b/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py index 121cebea096..664e50d2792 100644 --- a/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py +++ b/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py @@ -2253,3 +2253,449 @@ def test_cross_region_video_id_resolves_to_base_model_deployment(): ) # 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_not_implemented(): + with pytest.raises(NotImplementedError, match="get_complete_url"): + _make_config().get_complete_url(model=TEST_MODEL, api_base=None, litellm_params={}) + + +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 From d3c3da720eef0e259aff05378d65dbfcf4f72d3f Mon Sep 17 00:00:00 2001 From: lokm01 <2735155+lokm01@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:20:20 +0200 Subject: [PATCH 28/30] fix(bedrock): restore positional arguments in aimage_edit making image optional via a keyword-only marker (842e3325) broke the historical positional call form aimage_edit(image, model, prompt) with a typeerror. the sync image_edit keeps image/prompt/model defaulted and positional, so mirror that compatibility profile: drop the keyword-only marker, default model and prompt to none, and keep the async parameter order (image, model, prompt) so existing positional callers keep their meaning instead of hitting a silent model/prompt swap against the sync order. model none now falls back to default_image_endpoint_model for provider resolution exactly like the sync path. add a regression test for the positional form through mocked transport. --- litellm/images/main.py | 8 ++-- .../test_amazon_nova_canvas_image_edit.py | 40 +++++++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/litellm/images/main.py b/litellm/images/main.py index b091bf27c8a..ac5a6235495 100644 --- a/litellm/images/main.py +++ b/litellm/images/main.py @@ -976,9 +976,8 @@ def image_edit( @client async def aimage_edit( image: FileTypes | list[FileTypes] | None = None, - *, - model: str, - prompt: str, + model: str | None = None, + prompt: str | None = None, mask: str | None = None, n: int | None = None, quality: str | ImageGenerationRequestQuality | None = None, @@ -1013,7 +1012,8 @@ 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 = ( diff --git a/tests/test_litellm/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py b/tests/test_litellm/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py index 547d3b2d7b2..1ef5d874f9d 100644 --- a/tests/test_litellm/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py +++ b/tests/test_litellm/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py @@ -651,6 +651,46 @@ async def test_aimage_edit_image_less_condition_image_reaches_transform(monkeypa 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].""" import litellm.images.main as images_main From 475c689a550c50c81d06fd7dfef2df6b08093968 Mon Sep 17 00:00:00 2001 From: lokm01 <2735155+lokm01@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:20:25 +0200 Subject: [PATCH 29/30] fix(bedrock): raise 400 for unsupported video operations before url resolution the shared video handlers resolve the url via get_complete_url before running any config transform, so the transform-level unsupported- operation guards (b24d11fc) never ran for edit, characters and extension: the base notimplementederror surfaced as a 500-class error first. override get_complete_url on the nova reel config to raise the same bedrock 400 the transforms raise, naming the supported operations. safety: bedrock create, status and content never reach the shared handlers; videos/main.py routes them to litellm.llms.bedrock.videos.dispatch (which builds the signed requests in bedrockvideogeneration), so this override only fires for unsupported operations. keep the transform overrides as defense in depth. the end-to-end tests now pass without stubbing get_complete_url. --- litellm/llms/bedrock/videos/transformation.py | 20 +++++-- .../videos/test_amazon_nova_reel_video.py | 53 ++++++++----------- 2 files changed, 39 insertions(+), 34 deletions(-) diff --git a/litellm/llms/bedrock/videos/transformation.py b/litellm/llms/bedrock/videos/transformation.py index 0c0520bbd32..d66182bfb63 100644 --- a/litellm/llms/bedrock/videos/transformation.py +++ b/litellm/llms/bedrock/videos/transformation.py @@ -367,10 +367,22 @@ class BedrockNovaReelVideoConfig(BaseVideoConfig): api_base: str | None, litellm_params: _VideoParams, ) -> str: - raise NotImplementedError( - "Nova Reel video URLs are built in BedrockVideoGeneration " - "(AWS runtime endpoint + /async-invoke paths). Do not use get_complete_url " - "for this config." + # 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( diff --git a/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py b/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py index 664e50d2792..ae1dad2a02d 100644 --- a/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py +++ b/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py @@ -1652,17 +1652,15 @@ def test_unsupported_extension_response_raises_400_class_bedrock_error(): assert excinfo.value.status_code == 400 -def test_avideo_edit_bedrock_maps_unsupported_operation_to_bad_request(monkeypatch): - """Through the litellm video layer, litellm.avideo_edit on bedrock must surface - BadRequestError (400-class), never a NotImplementedError-driven 500. get_complete_url - is stubbed because it is documented as unused for this config (URLs are built in - the handler); the unsupported-operation error comes from the transform.""" - monkeypatch.setattr( - BedrockNovaReelVideoConfig, - "get_complete_url", - lambda self, model, api_base, litellm_params: "https://example.com/videos/edits", - ) - with pytest.raises(litellm.BadRequestError) as excinfo: +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", @@ -1670,21 +1668,12 @@ def test_avideo_edit_bedrock_maps_unsupported_operation_to_bad_request(monkeypat custom_llm_provider="bedrock", ) ) - # The 400-class mapping is the contract; the generic edit error wrapper reads - # .text off the exception (empty for a synthesized BedrockError), so only the - # status survives here. The message is asserted in the config-level tests. - assert excinfo.value.status_code == 400 - - -def test_avideo_create_character_bedrock_maps_unsupported_operation_to_bad_request(monkeypatch): - """Through the litellm video layer, litellm.avideo_create_character on bedrock must - surface BadRequestError (400-class). get_complete_url is stubbed as above.""" - monkeypatch.setattr( - BedrockNovaReelVideoConfig, - "get_complete_url", - lambda self, model, api_base, litellm_params: "https://example.com/characters", - ) - with pytest.raises(litellm.BadRequestError) as excinfo: + 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", @@ -1692,8 +1681,8 @@ def test_avideo_create_character_bedrock_maps_unsupported_operation_to_bad_reque custom_llm_provider="bedrock", ) ) - assert excinfo.value.status_code == 400 - assert "video create character is not supported" in str(excinfo.value) + assert character_exc.value.status_code == 400 + assert "bedrock video supports create, status and content only" in str(character_exc.value) ################################################# @@ -2581,9 +2570,13 @@ def test_validate_environment_none_headers_gets_content_type(): assert headers["Content-Type"] == "application/json" -def test_get_complete_url_raises_not_implemented(): - with pytest.raises(NotImplementedError, match="get_complete_url"): +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(): From a55da63024f1251ebc5c8bce6cb9c2d2b2e2c622 Mon Sep 17 00:00:00 2001 From: lokm01 <2735155+lokm01@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:01:01 +0200 Subject: [PATCH 30/30] test: move bedrock nova reel/canvas tests into tests/unit/llms Upstream moved tests/test_litellm/llms to tests/unit/llms (#43191) and assert-ci-coverage now fails for test files left under the old tree. Relocate the branch test files to the canonical location (pure moves; the conditioned-edit mirror gets its own file name beside the existing unit file) so every file stays invoked by a CI job. --- tests/test_litellm/llms/bedrock/videos/__init__.py | 0 .../test_amazon_nova_canvas_conditioned_edit.py} | 9 +++++---- .../image_edit => unit/llms/bedrock/videos}/__init__.py | 0 .../llms/bedrock/videos/test_amazon_nova_reel_video.py | 0 4 files changed, 5 insertions(+), 4 deletions(-) delete mode 100644 tests/test_litellm/llms/bedrock/videos/__init__.py rename tests/{test_litellm/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py => unit/llms/bedrock/image_edit/test_amazon_nova_canvas_conditioned_edit.py} (99%) rename tests/{test_litellm/llms/bedrock/image_edit => unit/llms/bedrock/videos}/__init__.py (100%) rename tests/{test_litellm => unit}/llms/bedrock/videos/test_amazon_nova_reel_video.py (100%) diff --git a/tests/test_litellm/llms/bedrock/videos/__init__.py b/tests/test_litellm/llms/bedrock/videos/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/tests/test_litellm/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py b/tests/unit/llms/bedrock/image_edit/test_amazon_nova_canvas_conditioned_edit.py similarity index 99% rename from tests/test_litellm/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py rename to tests/unit/llms/bedrock/image_edit/test_amazon_nova_canvas_conditioned_edit.py index 1ef5d874f9d..61b4255e981 100644 --- a/tests/test_litellm/llms/bedrock/image_edit/test_amazon_nova_canvas_image_edit.py +++ b/tests/unit/llms/bedrock/image_edit/test_amazon_nova_canvas_conditioned_edit.py @@ -1,6 +1,6 @@ -"""CI-covered tests for the Nova Canvas conditioned-editing additions (issue #39552). +"""Tests for the Nova Canvas conditioned-editing additions (issue #39552). -Self-contained mirror of the branch-added tests in +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, @@ -693,9 +693,10 @@ def test_aimage_edit_positional_arguments_still_work(monkeypatch): def test_aimage_edit_none_image_builds_empty_list(monkeypatch): """An omitted image must arrive at the handler as [] (empty list), not [None].""" - import litellm.images.main as images_main from unittest.mock import Mock + import litellm.images.main as images_main + seen: dict[str, object] = {} def fake_image_edit(**kwargs): @@ -751,8 +752,8 @@ def test_redact_bedrock_headers_for_logging_masks_signed_headers(): 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.llms.bedrock.image_edit.handler import BedrockImageEdit 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") diff --git a/tests/test_litellm/llms/bedrock/image_edit/__init__.py b/tests/unit/llms/bedrock/videos/__init__.py similarity index 100% rename from tests/test_litellm/llms/bedrock/image_edit/__init__.py rename to tests/unit/llms/bedrock/videos/__init__.py diff --git a/tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py b/tests/unit/llms/bedrock/videos/test_amazon_nova_reel_video.py similarity index 100% rename from tests/test_litellm/llms/bedrock/videos/test_amazon_nova_reel_video.py rename to tests/unit/llms/bedrock/videos/test_amazon_nova_reel_video.py