From f9fa3772150c5e4a24d4b0451a0a59b694e9ee07 Mon Sep 17 00:00:00 2001 From: Ewerton Silva Date: Thu, 18 Jun 2026 21:15:37 -0300 Subject: [PATCH 1/5] 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 8b108ded4c96..be28ea4e845b 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 2146c1fab018..3311306a2bba 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 @@ def test_custom_pricing_not_detected_without_model_info(self): 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/5] 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 be28ea4e845b..4d50d2ab867e 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 3311306a2bba..21319e9127a9 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 @@ def test_custom_pricing_not_detected_without_model_info(self): 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 @@ def test_openrouter_image_edit_forwards_image_config(self): 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/5] 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 0d96b62425f7..0deb4246a064 100644 --- a/litellm/llms/openrouter/image_edit/transformation.py +++ b/litellm/llms/openrouter/image_edit/transformation.py @@ -71,6 +71,15 @@ 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 @@ 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 = 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 f352c077fc4f..0efd16a07db7 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 @@ 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) From 9fba1df02cf300eb4a44ed6064aa74d12c06b4c0 Mon Sep 17 00:00:00 2001 From: Ewerton Silva Date: Fri, 25 Sep 2026 09:10:05 -0300 Subject: [PATCH 4/5] style(openrouter): apply ruff format after rebasing onto main --- litellm/llms/openrouter/image_edit/transformation.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/litellm/llms/openrouter/image_edit/transformation.py b/litellm/llms/openrouter/image_edit/transformation.py index 0b61cc1eb6d3..5060282bb7eb 100644 --- a/litellm/llms/openrouter/image_edit/transformation.py +++ b/litellm/llms/openrouter/image_edit/transformation.py @@ -75,9 +75,7 @@ # 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"} -) +OPENROUTER_ROUTING_CONTROL_PARAMS = frozenset({"models", "route", "provider", "transforms"}) class OpenRouterImageEditConfig(BaseImageEditConfig): From 9a20dca6a41f2cb3d325649adeebf06b9add3023 Mon Sep 17 00:00:00 2001 From: Ewerton Silva Date: Tue, 29 Sep 2026 09:07:21 -0300 Subject: [PATCH 5/5] fix(images): forward only unmapped params on the default edit path After merging main, the unconditional non_default_params merge on the default image_edit path collided with two upstream changes: - multipart OpenAI-compatible routes now merge caller params themselves (flattened, extra_body taking precedence); re-merging the raw params let seed=42 override extra_body={"seed": 7} and undid the flattening. - provider configs such as Azure AI FLUX.2 map supported params (size -> width/height, guidance "4.5" -> 4.5); the raw merge re-added size and overwrote the coerced values. Skip the fallback merge when the multipart branch already merged, and otherwise forward only params the provider config does not map and that are not already in the request (e.g. OpenRouter's image_config). --- litellm/images/main.py | 29 ++++++++++++++-------- tests/unit/images/test_image_edit_utils.py | 14 ++++++----- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/litellm/images/main.py b/litellm/images/main.py index 73d23a1d19d0..219abd57856d 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, @@ -933,11 +935,18 @@ 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) + 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/tests/unit/images/test_image_edit_utils.py b/tests/unit/images/test_image_edit_utils.py index 21319e9127a9..77048c907a85 100644 --- a/tests/unit/images/test_image_edit_utils.py +++ b/tests/unit/images/test_image_edit_utils.py @@ -276,9 +276,10 @@ class TestImageEditDefaultPathForwardsNonDefaultParams: 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 + 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. """ @@ -326,11 +327,12 @@ def test_openrouter_image_edit_forwards_image_config(self): 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. + # 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="openai", - model="gpt-image-1", + provider="vertex_ai", + model="imagen-3.0-capability-001", image_config=image_config, ) assert forwarded.get("image_config") == image_config