diff --git a/litellm/images/main.py b/litellm/images/main.py index 6a94e7c8df2..b16a754016c 100644 --- a/litellm/images/main.py +++ b/litellm/images/main.py @@ -936,6 +936,11 @@ def image_edit( client=kwargs.get("client"), aimage_edit=_is_async, ) + # Forward any caller-supplied params not covered by the optional-param + # whitelist (e.g. image_config) for every provider that reaches the default + # handler path, mirroring the bedrock/stability/black_forest_labs branches + # above. Without this merge they are silently dropped before the request. + image_edit_request_params.update(non_default_params) # Call the handler with _is_async flag instead of directly calling the async handler return base_llm_http_handler.image_edit_handler( model=model, diff --git a/litellm/llms/openrouter/image_edit/transformation.py b/litellm/llms/openrouter/image_edit/transformation.py index b01c25aad0c..86d5cda92af 100644 --- a/litellm/llms/openrouter/image_edit/transformation.py +++ b/litellm/llms/openrouter/image_edit/transformation.py @@ -71,6 +71,15 @@ else: LiteLLMLoggingObj = Any +# OpenRouter routing/control fields that must not be forwarded from an image-edit +# request body into the upstream /chat/completions call. Forwarding them would let +# a caller of an allowed image-edit model redirect the request to other models or +# providers, escaping LiteLLM's model-authorization and budget enforcement. +OPENROUTER_ROUTING_CONTROL_PARAMS = frozenset( + {"models", "route", "provider", "transforms"} +) + + class OpenRouterImageEditConfig(BaseImageEditConfig): """ Configuration for OpenRouter image editing via chat completions. @@ -185,10 +194,17 @@ class OpenRouterImageEditConfig(BaseImageEditConfig): "modalities": ["image", "text"], } - # Add mapped optional params (image_config, n, etc.) + # Add mapped optional params (image_config, n, etc.). Skip OpenRouter + # routing/control fields: these can arrive via forwarded non-default + # params, and copying them into the chat body would let a caller route + # an allowed image-edit request to other models/providers, bypassing + # LiteLLM's model-authorization and budget checks. for key, value in image_edit_optional_request_params.items(): - if key not in ("model", "messages", "modalities"): - request_body[key] = value + if key in ("model", "messages", "modalities"): + continue + if key in OPENROUTER_ROUTING_CONTROL_PARAMS: + continue + request_body[key] = value empty_files: Final = cast(RequestFiles, []) return request_body, empty_files diff --git a/tests/test_litellm/images/test_image_edit_utils.py b/tests/test_litellm/images/test_image_edit_utils.py index 2146c1fab01..21319e9127a 100644 --- a/tests/test_litellm/images/test_image_edit_utils.py +++ b/tests/test_litellm/images/test_image_edit_utils.py @@ -269,6 +269,73 @@ class TestImageEditCustomPricing: assert use_custom_pricing_for_model(litellm_params) is False +class TestImageEditDefaultPathForwardsNonDefaultParams: + """ + Regression test for https://github.com/BerriAI/litellm/issues/30753 + + image_config is honored on OpenRouter image generation but was silently dropped on + image edits: the default edit path never merged non_default_params (which still carries + image_config) before calling the handler, unlike the bedrock/stability/black_forest_labs + branches. The merge is now unconditional on the default handler path, so every provider + that reaches it (openrouter, openai, azure, vertex_ai, ...) forwards those params instead + of dropping them. OpenRouter's transform forwards extra top-level params, so once + image_config survives the merge it reaches the provider. + """ + + def _run_image_edit_and_capture(self, provider: str, model: str, image_config: dict): + from litellm.images.main import image_edit + + with ( + patch( + "litellm.images.main.get_llm_provider", + return_value=(model, provider, None, None), + ), + patch( + "litellm.images.main.ProviderConfigManager.get_provider_image_edit_config", + return_value=MagicMock(), + ), + patch( + "litellm.images.main._get_ImageEditRequestUtils", + return_value=MagicMock( + get_requested_image_edit_optional_param=MagicMock(return_value={}), + get_optional_params_image_edit=MagicMock(return_value={}), + ), + ), + patch("litellm.images.main.base_llm_http_handler") as mock_handler, + ): + mock_handler.image_edit_handler.return_value = MagicMock() + + image_edit( + image=b"fake-image-data", + prompt="add a red border", + model=f"{provider}/{model}", + image_config=image_config, + ) + + return mock_handler.image_edit_handler.call_args.kwargs[ + "image_edit_optional_request_params" + ] + + def test_openrouter_image_edit_forwards_image_config(self): + image_config = {"aspect_ratio": "16:9", "image_size": "2K"} + forwarded = self._run_image_edit_and_capture( + provider="openrouter", + model="google/gemini-3-pro-image-preview", + image_config=image_config, + ) + assert forwarded.get("image_config") == image_config + + def test_default_path_forwards_image_config_for_non_openrouter_provider(self): + # The same silent-drop affected every fallthrough provider, not just openrouter. + image_config = {"aspect_ratio": "1:1", "image_size": "1K"} + forwarded = self._run_image_edit_and_capture( + provider="openai", + model="gpt-image-1", + image_config=image_config, + ) + assert forwarded.get("image_config") == image_config + + class TestImageEditHandlerCredentialsForwarding: """ Regression tests for Vertex AI image_edit credentials bug. diff --git a/tests/test_litellm/llms/openrouter/image_edit/test_openrouter_image_edit_transformation.py b/tests/test_litellm/llms/openrouter/image_edit/test_openrouter_image_edit_transformation.py index 5a78560f61b..7bb9356a5eb 100644 --- a/tests/test_litellm/llms/openrouter/image_edit/test_openrouter_image_edit_transformation.py +++ b/tests/test_litellm/llms/openrouter/image_edit/test_openrouter_image_edit_transformation.py @@ -237,6 +237,36 @@ class TestOpenRouterImageEditTransformation: # Files should be empty (JSON mode) assert list(files) == [] + def test_transform_image_edit_request_drops_openrouter_routing_controls(self): + """Routing/control fields must not be forwarded into the chat body. + + They can arrive via forwarded non-default params; copying OpenRouter + routing controls (models/route/provider/transforms) into the upstream + request would let a caller of an allowed image-edit model redirect the + call to other models/providers, bypassing LiteLLM's model-authorization + and budget checks. The intended image param (image_config) must still + pass through. + """ + data, _ = self.config.transform_image_edit_request( + model=self.model, + prompt="Edit this", + image=self.sample_image_bytes, + image_edit_optional_request_params={ + "image_config": {"aspect_ratio": "16:9"}, + "models": ["openai/gpt-5", "anthropic/claude"], + "route": "fallback", + "provider": {"order": ["openai"]}, + "transforms": ["middle-out"], + }, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + for routing_key in ("models", "route", "provider", "transforms"): + assert routing_key not in data + + assert data["image_config"] == {"aspect_ratio": "16:9"} + def test_transform_image_edit_request_with_bytesio(self): """Test request transformation with BytesIO image input.""" image = BytesIO(self.sample_image_bytes)