This commit is contained in:
Ewertonslv 2026-10-22 02:15:35 +00:00 • committed by GitHub
commit 59790c94c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 135 additions and 8 deletions

View file

@ -859,13 +859,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
@ -877,11 +876,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: Final = 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,
@ -957,6 +959,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,

View file

@ -75,6 +75,13 @@ 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: Final = frozenset({"models", "route", "provider", "transforms"})
@with_config(ConfigDict(extra="allow", strict=True, hide_input_in_errors=True))
class _ImageUrl(TypedDict):
url: ReadOnly[NotRequired[str | None]]
@ -233,10 +240,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

View file

@ -297,6 +297,75 @@ 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 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.

View file

@ -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)