Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions litellm/images/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
20 changes: 17 additions & 3 deletions litellm/llms/openrouter/image_edit/transformation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
69 changes: 69 additions & 0 deletions tests/unit/images/test_image_edit_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading