From f9fa3772150c5e4a24d4b0451a0a59b694e9ee07 Mon Sep 17 00:00:00 2001 From: Ewerton Silva Date: Thu, 18 Jun 2026 21:15:37 -0300 Subject: [PATCH 1/3] fix(images): forward image_config on OpenRouter image edits image_config was honored on OpenRouter image generation but silently dropped on image edits. The default edit branch in image_edit() never merged non_default_params (which still carries image_config after the optional-param whitelist filters it out) before calling the handler, unlike the bedrock, stability, and black_forest_labs branches. Merge non_default_params for the openrouter path so image_config survives; OpenRouter's transform already forwards extra top-level params into the chat-completions body, so it then reaches the provider Fixes #30753 --- litellm/images/main.py | 2 + .../images/test_image_edit_utils.py | 54 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/litellm/images/main.py b/litellm/images/main.py index 8b108ded4c9..be28ea4e845 100644 --- a/litellm/images/main.py +++ b/litellm/images/main.py @@ -963,6 +963,8 @@ def image_edit( client=kwargs.get("client"), aimage_edit=_is_async, ) + if custom_llm_provider == "openrouter": + 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/tests/test_litellm/images/test_image_edit_utils.py b/tests/test_litellm/images/test_image_edit_utils.py index 2146c1fab01..3311306a2bb 100644 --- a/tests/test_litellm/images/test_image_edit_utils.py +++ b/tests/test_litellm/images/test_image_edit_utils.py @@ -269,6 +269,60 @@ class TestImageEditCustomPricing: assert use_custom_pricing_for_model(litellm_params) is False +class TestImageEditOpenRouterImageConfig: + """ + 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. OpenRouter's transform forwards extra top-level params, so once image_config + survives the merge it reaches the provider. + """ + + def test_openrouter_image_edit_forwards_image_config(self): + from litellm.images.main import image_edit + + image_config = {"aspect_ratio": "16:9", "image_size": "2K"} + + with ( + patch( + "litellm.images.main.get_llm_provider", + return_value=( + "google/gemini-3-pro-image-preview", + "openrouter", + 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="openrouter/google/gemini-3-pro-image-preview", + image_config=image_config, + ) + + forwarded = mock_handler.image_edit_handler.call_args.kwargs[ + "image_edit_optional_request_params" + ] + assert forwarded.get("image_config") == image_config + + class TestImageEditHandlerCredentialsForwarding: """ Regression tests for Vertex AI image_edit credentials bug. From b9dab763a758cec77ba1a5c56a5703ba5de1d0c3 Mon Sep 17 00:00:00 2001 From: Ewerton Silva Date: Wed, 24 Jun 2026 08:25:03 -0300 Subject: [PATCH 2/3] fix(images): forward non_default_params on the default edit path for all providers Greptile review thread: the merge was gated on custom_llm_provider == openrouter, but every fallthrough provider (openai, azure, vertex_ai, ...) hits the same base_llm_http_handler.image_edit_handler call and silently dropped extra params like image_config. Make the merge unconditional before the default handler call, mirroring the bedrock/stability/black_forest_labs branches. Generalize the regression test to also cover a non-openrouter provider. --- litellm/images/main.py | 7 +++- .../images/test_image_edit_utils.py | 41 ++++++++++++------- 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/litellm/images/main.py b/litellm/images/main.py index be28ea4e845..4d50d2ab867 100644 --- a/litellm/images/main.py +++ b/litellm/images/main.py @@ -963,8 +963,11 @@ def image_edit( client=kwargs.get("client"), aimage_edit=_is_async, ) - if custom_llm_provider == "openrouter": - image_edit_request_params.update(non_default_params) + # 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/tests/test_litellm/images/test_image_edit_utils.py b/tests/test_litellm/images/test_image_edit_utils.py index 3311306a2bb..21319e9127a 100644 --- a/tests/test_litellm/images/test_image_edit_utils.py +++ b/tests/test_litellm/images/test_image_edit_utils.py @@ -269,31 +269,26 @@ class TestImageEditCustomPricing: assert use_custom_pricing_for_model(litellm_params) is False -class TestImageEditOpenRouterImageConfig: +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. OpenRouter's transform forwards extra top-level params, so once image_config - survives the merge it reaches the provider. + 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 test_openrouter_image_edit_forwards_image_config(self): + def _run_image_edit_and_capture(self, provider: str, model: str, image_config: dict): from litellm.images.main import image_edit - image_config = {"aspect_ratio": "16:9", "image_size": "2K"} - with ( patch( "litellm.images.main.get_llm_provider", - return_value=( - "google/gemini-3-pro-image-preview", - "openrouter", - None, - None, - ), + return_value=(model, provider, None, None), ), patch( "litellm.images.main.ProviderConfigManager.get_provider_image_edit_config", @@ -313,13 +308,31 @@ class TestImageEditOpenRouterImageConfig: image_edit( image=b"fake-image-data", prompt="add a red border", - model="openrouter/google/gemini-3-pro-image-preview", + model=f"{provider}/{model}", image_config=image_config, ) - forwarded = mock_handler.image_edit_handler.call_args.kwargs[ + 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 From d95a258904a94c9a6b1863e5a83dc604160ba867 Mon Sep 17 00:00:00 2001 From: Ewerton Silva Date: Thu, 25 Jun 2026 08:34:50 -0300 Subject: [PATCH 3/3] fix(openrouter): drop routing-control fields from image-edit transform The image-edit transform copied every forwarded optional param into the upstream /chat/completions body. Since the default edit path now forwards non-default params (so image_config survives), a caller could smuggle OpenRouter routing controls (models/route/provider/transforms) into an allowed image-edit request and redirect it to other models/providers, bypassing LiteLLM's model authorization and budget checks. Skip those routing-control keys when building the request body; image_config and other intended params still pass through. Addresses the routing-control-bypass review finding on #30881. --- .../openrouter/image_edit/transformation.py | 22 ++++++++++++-- ...st_openrouter_image_edit_transformation.py | 30 +++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/litellm/llms/openrouter/image_edit/transformation.py b/litellm/llms/openrouter/image_edit/transformation.py index 0d96b62425f..0deb4246a06 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. @@ -191,10 +200,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 = cast(RequestFiles, []) return request_body, empty_files 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 f352c077fc4..0efd16a07db 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 @@ -242,6 +242,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)