mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-30 06:43:27 +00:00
Merge cf6b1929b7 into f2118fc767
This commit is contained in:
commit
59790c94c2
4 changed files with 135 additions and 8 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue