diff --git a/litellm/images/main.py b/litellm/images/main.py index af01e0512110..a06fb643f733 100644 --- a/litellm/images/main.py +++ b/litellm/images/main.py @@ -836,13 +836,12 @@ def image_edit( raise ValueError(f"image edit is not supported for {custom_llm_provider}") local_vars.update(kwargs) + provider_supported_params: Final = frozenset(image_edit_provider_config.get_supported_openai_params(model)) # Get ImageEditOptionalRequestParams with only valid parameters image_edit_optional_params: Final[ImageEditOptionalRequestParams] = ( _get_ImageEditRequestUtils().get_requested_image_edit_optional_param( local_vars, - provider_supported_params=frozenset( - image_edit_provider_config.get_supported_openai_params(model) - ).intersection(non_default_params), + provider_supported_params=provider_supported_params.intersection(non_default_params), ) ) # Get optional parameters for the responses API @@ -854,11 +853,14 @@ def image_edit( additional_drop_params=kwargs.get("additional_drop_params"), ) - if image_edit_provider_config.use_multipart_form_data() and ( + # Multipart OpenAI-compatible routes merge the caller's params here, flattened + # and with extra_body taking precedence; the default path below must not redo it. + merged_provider_params = image_edit_provider_config.use_multipart_form_data() and ( custom_llm_provider == "openai" or custom_llm_provider == "azure" or custom_llm_provider in litellm.openai_compatible_providers - ): + ) + if merged_provider_params: image_edit_request_params.update( flatten_form_field_values( non_default_params, @@ -934,6 +936,18 @@ def image_edit( client=kwargs.get("client"), aimage_edit=_is_async, ) + if not merged_provider_params: + # Forward caller-supplied params the provider config does not map itself + # (e.g. OpenRouter's image_config) on the default handler path; without this + # they are silently dropped before the request. Params the config supports were + # already mapped above (e.g. size -> width/height), so never re-add or override them. + image_edit_request_params.update( + { + key: value + for key, value in non_default_params.items() + if key not in provider_supported_params and key not in image_edit_request_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 96b16e3e5104..613f217ba0d6 100644 --- a/litellm/llms/openrouter/image_edit/transformation.py +++ b/litellm/llms/openrouter/image_edit/transformation.py @@ -71,6 +71,13 @@ 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. @@ -186,10 +193,17 @@ def transform_image_edit_request( "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/unit/images/test_image_edit_utils.py b/tests/unit/images/test_image_edit_utils.py index 2146c1fab018..77048c907a85 100644 --- a/tests/unit/images/test_image_edit_utils.py +++ b/tests/unit/images/test_image_edit_utils.py @@ -269,6 +269,75 @@ def test_custom_pricing_not_detected_without_model_info(self): 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 default handler path now merges them for every provider that reaches it + (openrouter, vertex_ai, ...) unless the multipart OpenAI-compatible branch already merged + them (flattened, with extra_body taking precedence), so they are forwarded instead of + dropped. 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 non-multipart fallthrough provider, not just + # openrouter. (Multipart OpenAI-compatible providers merge them in their own branch.) + image_config = {"aspect_ratio": "1:1", "image_size": "1K"} + forwarded = self._run_image_edit_and_capture( + provider="vertex_ai", + model="imagen-3.0-capability-001", + 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/unit/llms/openrouter/image_edit/test_openrouter_image_edit_transformation.py b/tests/unit/llms/openrouter/image_edit/test_openrouter_image_edit_transformation.py index 5a78560f61b5..7bb9356a5eb7 100644 --- a/tests/unit/llms/openrouter/image_edit/test_openrouter_image_edit_transformation.py +++ b/tests/unit/llms/openrouter/image_edit/test_openrouter_image_edit_transformation.py @@ -237,6 +237,36 @@ def test_transform_image_edit_request_basic(self): # 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)