From b4f9e14a44789b408c3ceab56d01671f33a7b3d0 Mon Sep 17 00:00:00 2001 From: milan Date: Tue, 28 Jul 2026 14:14:53 +0000 Subject: [PATCH 1/7] feat(dd_span_tagger): emit litellm_user_email span tag for JWT-authenticated requests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/dd_span_tagger.py | 8 +++++- .../proxy/test_common_request_processing.py | 27 ++++++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/dd_span_tagger.py b/litellm/proxy/dd_span_tagger.py index 08b7d928d0e..1fda0c638d4 100644 --- a/litellm/proxy/dd_span_tagger.py +++ b/litellm/proxy/dd_span_tagger.py @@ -38,11 +38,15 @@ class DDSpanTagger: - ``litellm.key_alias`` — human-readable alias for the API key - ``litellm.key_hash`` — hashed API key (safe to log; never the raw secret) - ``litellm.requested_model``— model name as sent by the client + - ``litellm_user_email`` — email of the authenticated user Use cases: - Trace all requests from a specific user/key: filter by ``litellm.key_alias`` or ``litellm.key_hash``. - Trace all requests for a specific model: filter by ``litellm.requested_model``. + - Trace all requests from a specific person under JWT auth, where there is no key_alias: + filter by ``@litellm_user_email:"user@example.com"``. The value comes from the JWT claim + mapped by ``litellm_jwtauth.user_email_jwt_field``, or from the user row for virtual keys. Note: key_alias / key_hash are not available for unauthenticated (e.g. 401) requests. """ @@ -53,8 +57,10 @@ class DDSpanTagger: set_active_span_tag("litellm.key_hash", str(user_api_key_dict.token)) if requested_model: set_active_span_tag("litellm.requested_model", str(requested_model)) + if user_api_key_dict.user_email: + set_active_span_tag("litellm_user_email", str(user_api_key_dict.user_email)) except Exception: verbose_proxy_logger.debug( - "Failed to tag active ddtrace span with key/model tags", + "Failed to tag active ddtrace span with key/model/user tags", exc_info=True, ) diff --git a/tests/test_litellm/proxy/test_common_request_processing.py b/tests/test_litellm/proxy/test_common_request_processing.py index 58f81cdad35..287e6f2f80c 100644 --- a/tests/test_litellm/proxy/test_common_request_processing.py +++ b/tests/test_litellm/proxy/test_common_request_processing.py @@ -2318,12 +2318,13 @@ class TestStreamingOverheadHeader: class TestDDSpanTaggerTagRequest: """Tests for DDSpanTagger.tag_request - key/model DD span tagging.""" - def _make_user_api_key_dict(self, key_alias=None, token=None): + def _make_user_api_key_dict(self, key_alias=None, token=None, user_email=None): from litellm.proxy._types import UserAPIKeyAuth d = UserAPIKeyAuth() d.key_alias = key_alias d.token = token + d.user_email = user_email return d def test_tags_key_alias_and_model(self): @@ -2364,6 +2365,30 @@ class TestDDSpanTaggerTagRequest: mock_set_tag.assert_called_once_with("litellm.requested_model", "claude-3-5-sonnet") + def test_tags_user_email(self): + """user_email is tagged so JWT-authenticated requests are traceable per person.""" + user_key = self._make_user_api_key_dict(user_email="user@example.com") + + with patch("litellm.proxy.dd_span_tagger.set_active_span_tag") as mock_set_tag: + DDSpanTagger.tag_request( + user_api_key_dict=user_key, + requested_model=None, + ) + + mock_set_tag.assert_called_once_with("litellm_user_email", "user@example.com") + + def test_no_user_email_tag_when_absent(self): + """No user email tag when the authenticated identity has no email.""" + user_key = self._make_user_api_key_dict(key_alias="my-prod-key", user_email=None) + + with patch("litellm.proxy.dd_span_tagger.set_active_span_tag") as mock_set_tag: + DDSpanTagger.tag_request( + user_api_key_dict=user_key, + requested_model="gpt-4o", + ) + + assert all(call.args[0] != "litellm_user_email" for call in mock_set_tag.call_args_list) + class TestHasAttributeErrorInChain: """Tests for _has_attribute_error_in_chain helper.""" From 819faa01c336d825619b3592ed1c1d763a4156d4 Mon Sep 17 00:00:00 2001 From: milan Date: Tue, 28 Jul 2026 14:40:04 +0000 Subject: [PATCH 2/7] refactor(dd_span_tagger): use dotted litellm.user_email tag for consistency Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/dd_span_tagger.py | 6 +++--- tests/test_litellm/proxy/test_common_request_processing.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/litellm/proxy/dd_span_tagger.py b/litellm/proxy/dd_span_tagger.py index 1fda0c638d4..cafae9bb2a3 100644 --- a/litellm/proxy/dd_span_tagger.py +++ b/litellm/proxy/dd_span_tagger.py @@ -38,14 +38,14 @@ class DDSpanTagger: - ``litellm.key_alias`` — human-readable alias for the API key - ``litellm.key_hash`` — hashed API key (safe to log; never the raw secret) - ``litellm.requested_model``— model name as sent by the client - - ``litellm_user_email`` — email of the authenticated user + - ``litellm.user_email`` — email of the authenticated user Use cases: - Trace all requests from a specific user/key: filter by ``litellm.key_alias`` or ``litellm.key_hash``. - Trace all requests for a specific model: filter by ``litellm.requested_model``. - Trace all requests from a specific person under JWT auth, where there is no key_alias: - filter by ``@litellm_user_email:"user@example.com"``. The value comes from the JWT claim + filter by ``@litellm.user_email:"user@example.com"``. The value comes from the JWT claim mapped by ``litellm_jwtauth.user_email_jwt_field``, or from the user row for virtual keys. Note: key_alias / key_hash are not available for unauthenticated (e.g. 401) requests. @@ -58,7 +58,7 @@ class DDSpanTagger: if requested_model: set_active_span_tag("litellm.requested_model", str(requested_model)) if user_api_key_dict.user_email: - set_active_span_tag("litellm_user_email", str(user_api_key_dict.user_email)) + set_active_span_tag("litellm.user_email", str(user_api_key_dict.user_email)) except Exception: verbose_proxy_logger.debug( "Failed to tag active ddtrace span with key/model/user tags", diff --git a/tests/test_litellm/proxy/test_common_request_processing.py b/tests/test_litellm/proxy/test_common_request_processing.py index 287e6f2f80c..312c3d2a884 100644 --- a/tests/test_litellm/proxy/test_common_request_processing.py +++ b/tests/test_litellm/proxy/test_common_request_processing.py @@ -2375,7 +2375,7 @@ class TestDDSpanTaggerTagRequest: requested_model=None, ) - mock_set_tag.assert_called_once_with("litellm_user_email", "user@example.com") + mock_set_tag.assert_called_once_with("litellm.user_email", "user@example.com") def test_no_user_email_tag_when_absent(self): """No user email tag when the authenticated identity has no email.""" @@ -2387,7 +2387,7 @@ class TestDDSpanTaggerTagRequest: requested_model="gpt-4o", ) - assert all(call.args[0] != "litellm_user_email" for call in mock_set_tag.call_args_list) + assert all(call.args[0] != "litellm.user_email" for call in mock_set_tag.call_args_list) class TestHasAttributeErrorInChain: From b224b15b9d24c15590a81c3c4bb5399d40b7d9e0 Mon Sep 17 00:00:00 2001 From: milan Date: Fri, 21 Aug 2026 02:41:55 +0000 Subject: [PATCH 3/7] feat(model_hub): surface model_info.description in model group info and Model Hub UI Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/router.py | 6 +++++ litellm/types/router.py | 1 + tests/test_litellm/test_router.py | 25 +++++++++++++++++++ .../src/components/AIHub/ModelHubTable.tsx | 6 +++++ .../components/AIHub/ModelHubTableColumns.tsx | 1 + .../components/PublicModelHubTableColumns.tsx | 1 + .../src/components/public_model_hub.tsx | 6 +++++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 2 ++ 8 files changed, 48 insertions(+) diff --git a/litellm/router.py b/litellm/router.py index e9eeab53934..90968800460 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -9334,6 +9334,9 @@ class Router: model_litellm_params = model.get("litellm_params", {}) model_info_dict = model.get("model_info", {}) + _raw_description = model_info_dict.get("description") + _deployment_description: str | None = _raw_description if isinstance(_raw_description, str) else None + # get model tpm _deployment_tpm: int | None = None if _deployment_tpm is None: @@ -9415,6 +9418,7 @@ class Router: **{ "model_group": user_facing_model_group_name, "providers": [llm_provider], + "description": _deployment_description, **model_info, } ) @@ -9428,6 +9432,8 @@ class Router: # supports_function_calling == True if llm_provider not in model_group_info.providers: model_group_info.providers.append(llm_provider) + if model_group_info.description is None and _deployment_description is not None: + model_group_info.description = _deployment_description if ( model_info.get("max_input_tokens", None) is not None and model_info["max_input_tokens"] is not None diff --git a/litellm/types/router.py b/litellm/types/router.py index 99a4603ae49..b33e387c508 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -637,6 +637,7 @@ class ModelGroupInfo(BaseModel): supports_function_calling: bool = Field(default=False) supported_openai_params: list[str] | None = Field(default=[]) configurable_clientside_auth_params: CONFIGURABLE_CLIENTSIDE_AUTH_PARAMS = None + description: str | None = None def __init__(self, **data) -> None: for field_name, field_type in get_type_hints(self.__class__).items(): diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index 9894fcef163..765e479e378 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -1626,6 +1626,31 @@ def test_model_group_info_cost_none_when_db_model_info_has_no_cost(): assert result.output_cost_per_token is None +def test_model_group_info_description_from_model_info(): + """ + model_info.description set in the config should surface on ModelGroupInfo, + including when only a later deployment in the group carries it. + """ + router = litellm.Router( + model_list=[ + { + "model_name": "gpt-4", + "litellm_params": {"model": "gpt-4", "api_key": "fake"}, + "model_info": {}, + }, + { + "model_name": "gpt-4", + "litellm_params": {"model": "gpt-4", "api_key": "fake2"}, + "model_info": {"description": "State-of-the-art language model."}, + }, + ] + ) + + result = router.get_model_group_info("gpt-4") + assert result is not None + assert result.description == "State-of-the-art language model." + + @pytest.mark.parametrize( "value,expected", [ diff --git a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx index 299104b271b..5fadbe3300d 100644 --- a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx @@ -647,6 +647,12 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, ))} + {selectedModel.description && ( +
+

Description:

+

{selectedModel.description}

+
+ )} diff --git a/ui/litellm-dashboard/src/components/AIHub/ModelHubTableColumns.tsx b/ui/litellm-dashboard/src/components/AIHub/ModelHubTableColumns.tsx index 9f74771f3b1..b025bcf62aa 100644 --- a/ui/litellm-dashboard/src/components/AIHub/ModelHubTableColumns.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/ModelHubTableColumns.tsx @@ -31,6 +31,7 @@ export interface ModelHubData { supports_function_calling: boolean; supported_openai_params?: string[]; is_public_model_group: boolean; + description?: string; [key: string]: any; } diff --git a/ui/litellm-dashboard/src/components/PublicModelHubTableColumns.tsx b/ui/litellm-dashboard/src/components/PublicModelHubTableColumns.tsx index ab0ed976149..f3673df8025 100644 --- a/ui/litellm-dashboard/src/components/PublicModelHubTableColumns.tsx +++ b/ui/litellm-dashboard/src/components/PublicModelHubTableColumns.tsx @@ -24,6 +24,7 @@ export interface ModelGroupInfo { health_status?: string; health_response_time?: number; health_checked_at?: string; + description?: string; [key: string]: any; } diff --git a/ui/litellm-dashboard/src/components/public_model_hub.tsx b/ui/litellm-dashboard/src/components/public_model_hub.tsx index 171b7992325..cd10068b9e6 100644 --- a/ui/litellm-dashboard/src/components/public_model_hub.tsx +++ b/ui/litellm-dashboard/src/components/public_model_hub.tsx @@ -918,6 +918,12 @@ const PublicModelHub: React.FC = ({ accessToken, isEmbedded })} + {selectedModel.description && ( +
+

Description:

+

{selectedModel.description}

+
+ )} {/* Wildcard Routing Note */} diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index fff48e14ecf..39b3255efb3 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -29481,6 +29481,8 @@ export interface components { ModelGroupInfoProxy: { /** Configurable Clientside Auth Params */ configurable_clientside_auth_params?: (string | components["schemas"]["ConfigurableClientsideParamsCustomAuth-Output"])[] | null; + /** Description */ + description?: string | null; /** Health Checked At */ health_checked_at?: string | null; /** Health Response Time */ From 342b93fe0a92f32b8c2fb4ccf61441cb0a2cf3b1 Mon Sep 17 00:00:00 2001 From: milan Date: Fri, 21 Aug 2026 03:27:35 +0000 Subject: [PATCH 4/7] refactor(router): aggregate model group description without in-place mutation Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/router.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/litellm/router.py b/litellm/router.py index 90968800460..d65c13d57b2 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -9313,6 +9313,10 @@ class Router: model_list: Final = self.get_model_list(model_name=model_group) if model_list is None: return None + model_group_description: Final[str | None] = next( + (d for d in (m.get("model_info", {}).get("description") for m in model_list) if isinstance(d, str)), + None, + ) for model in model_list: is_match = False if ( @@ -9334,9 +9338,6 @@ class Router: model_litellm_params = model.get("litellm_params", {}) model_info_dict = model.get("model_info", {}) - _raw_description = model_info_dict.get("description") - _deployment_description: str | None = _raw_description if isinstance(_raw_description, str) else None - # get model tpm _deployment_tpm: int | None = None if _deployment_tpm is None: @@ -9418,7 +9419,7 @@ class Router: **{ "model_group": user_facing_model_group_name, "providers": [llm_provider], - "description": _deployment_description, + "description": model_group_description, **model_info, } ) @@ -9432,8 +9433,6 @@ class Router: # supports_function_calling == True if llm_provider not in model_group_info.providers: model_group_info.providers.append(llm_provider) - if model_group_info.description is None and _deployment_description is not None: - model_group_info.description = _deployment_description if ( model_info.get("max_input_tokens", None) is not None and model_info["max_input_tokens"] is not None From 6d2c4899b0f9fa36c59c793657234f75b6a83704 Mon Sep 17 00:00:00 2001 From: yassin Date: Sun, 13 Sep 2026 09:44:22 +0000 Subject: [PATCH 5/7] fix(health): skip background health check DB writes when the latest-row read fails Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/db/health_check_latest.py | 8 ++++-- .../health_endpoints/_health_endpoints.py | 7 +++-- .../proxy/db/test_health_check_latest.py | 10 +++++++ .../proxy/test_health_check_functions.py | 27 +++++++++++++++---- 4 files changed, 43 insertions(+), 9 deletions(-) diff --git a/litellm/proxy/db/health_check_latest.py b/litellm/proxy/db/health_check_latest.py index 35bc838379c..21438f095bb 100644 --- a/litellm/proxy/db/health_check_latest.py +++ b/litellm/proxy/db/health_check_latest.py @@ -74,10 +74,14 @@ class LatestHealthCheckRow(BaseModel): _ROWS_ADAPTER: Final = TypeAdapter(tuple[LatestHealthCheckRow, ...]) +async def query_latest_health_checks(prisma_client: PrismaClient) -> tuple[LatestHealthCheckRow, ...]: + rows: Final = await prisma_client.db.query_raw(LATEST_HEALTH_CHECKS_SQL) + return _ROWS_ADAPTER.validate_python(rows) + + async def fetch_latest_health_checks(prisma_client: PrismaClient) -> tuple[LatestHealthCheckRow, ...]: try: - rows: Final = await prisma_client.db.query_raw(LATEST_HEALTH_CHECKS_SQL) - return _ROWS_ADAPTER.validate_python(rows) + return await query_latest_health_checks(prisma_client) except Exception as query_err: # noqa: BLE001 # health decorates other reads; a driver error must not fail them verbose_proxy_logger.error("Error getting all latest health checks: %s", query_err) return () diff --git a/litellm/proxy/health_endpoints/_health_endpoints.py b/litellm/proxy/health_endpoints/_health_endpoints.py index db6ec754c6e..64fd59bbe44 100644 --- a/litellm/proxy/health_endpoints/_health_endpoints.py +++ b/litellm/proxy/health_endpoints/_health_endpoints.py @@ -45,7 +45,10 @@ from litellm.proxy.auth.auth_utils import ( from litellm.proxy.auth.model_checks import get_key_models from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler -from litellm.proxy.db.health_check_latest import LatestHealthCheckRow +from litellm.proxy.db.health_check_latest import ( + LatestHealthCheckRow, + query_latest_health_checks, +) from litellm.proxy.db.proxy_worker_heartbeat import count_live_proxy_workers from litellm.proxy.health_check import ( ADMIN_ONLY_HEALTH_DISPLAY_PARAMS, @@ -876,7 +879,7 @@ async def _save_background_health_checks_to_db( ) # Step 3: Get latest health checks for all models in one query to compare status - latest_checks: Final = await prisma_client.get_all_latest_health_checks() + latest_checks: Final = await query_latest_health_checks(prisma_client) latest_checks_map: Final = {} for check in latest_checks: # Use model_id as primary key, fallback to model_name diff --git a/tests/test_litellm/proxy/db/test_health_check_latest.py b/tests/test_litellm/proxy/db/test_health_check_latest.py index 529e4d8f2e9..6322891ae9e 100644 --- a/tests/test_litellm/proxy/db/test_health_check_latest.py +++ b/tests/test_litellm/proxy/db/test_health_check_latest.py @@ -8,6 +8,7 @@ from litellm.proxy.db.health_check_latest import ( LATEST_HEALTH_CHECKS_SQL, fetch_latest_health_checks, fetch_latest_health_checks_for_models, + query_latest_health_checks, ) @@ -83,6 +84,15 @@ async def test_fetch_all_degrades_to_no_rows_when_the_query_fails(): assert await fetch_latest_health_checks(prisma) == () +@pytest.mark.asyncio +async def test_query_all_raises_when_the_query_fails_instead_of_reading_as_an_empty_table(): + """The background save decides what to write from this read; a failure has to be told apart from no rows.""" + prisma = _prisma([]) + prisma.db.query_raw.side_effect = RuntimeError("db down") + with pytest.raises(RuntimeError, match="db down"): + await query_latest_health_checks(prisma) + + @pytest.mark.asyncio async def test_fetch_all_degrades_to_no_rows_for_a_malformed_row(): assert await fetch_latest_health_checks(_prisma([{"unexpected": "shape"}])) == () diff --git a/tests/test_litellm/proxy/test_health_check_functions.py b/tests/test_litellm/proxy/test_health_check_functions.py index c0c853ae2c5..7d6c5d3cebe 100644 --- a/tests/test_litellm/proxy/test_health_check_functions.py +++ b/tests/test_litellm/proxy/test_health_check_functions.py @@ -374,7 +374,7 @@ async def test_save_background_health_checks_to_db(): """Test the main background health check save function""" mock_prisma = MagicMock() mock_prisma.save_health_check_result = AsyncMock() - mock_prisma.get_all_latest_health_checks = AsyncMock(return_value=[]) + mock_prisma.db.query_raw = AsyncMock(return_value=[]) model_list = [ { @@ -398,9 +398,9 @@ async def test_save_background_health_checks_to_db(): "background_health_check", ) - # Should call get_all_latest_health_checks and save_health_check_result, and report completion + # Should read the latest rows and save_health_check_result, and report completion assert persisted is True - mock_prisma.get_all_latest_health_checks.assert_called_once() + mock_prisma.db.query_raw.assert_awaited_once() mock_prisma.save_health_check_result.assert_called_once() call_kwargs = mock_prisma.save_health_check_result.call_args[1] @@ -493,7 +493,7 @@ def _one_model_setup(): @pytest.mark.asyncio async def test_save_background_health_checks_to_db_returns_false_when_a_write_fails(): mock_prisma = MagicMock() - mock_prisma.get_all_latest_health_checks = AsyncMock(return_value=[]) + mock_prisma.db.query_raw = AsyncMock(return_value=[]) mock_prisma.save_health_check_result = AsyncMock(return_value=None) model_list, healthy_endpoints, unhealthy_endpoints = _one_model_setup() @@ -504,6 +504,23 @@ async def test_save_background_health_checks_to_db_returns_false_when_a_write_fa assert (persisted, mock_prisma.save_health_check_result.await_count) == (False, 1) +@pytest.mark.asyncio +async def test_save_background_health_checks_to_db_writes_nothing_when_the_latest_row_read_fails(mock_prisma): + """ + A failed dedup read must not read as an empty table. Treated that way, every model was written on every + cycle by every pod while the read kept failing, which is what filled the table in production. + """ + mock_prisma.db.query_raw = AsyncMock(side_effect=RuntimeError("db down")) + mock_prisma.save_health_check_result = AsyncMock(return_value={"id": "row"}) + model_list, healthy_endpoints, unhealthy_endpoints = _one_model_setup() + + persisted = await _save_background_health_checks_to_db( + mock_prisma, model_list, healthy_endpoints, unhealthy_endpoints, 1234567890.0, "background_health_check" + ) + + assert (persisted, mock_prisma.save_health_check_result.await_count) == (False, 0) + + @pytest.mark.asyncio async def test_save_background_health_checks_to_db_no_prisma(): """Test graceful handling when no prisma client""" @@ -515,7 +532,7 @@ async def test_save_background_health_checks_to_db_no_prisma(): async def test_save_background_health_checks_to_db_exception_handling(): """Test exception handling in background health check save""" mock_prisma = MagicMock() - mock_prisma.get_all_latest_health_checks = AsyncMock(side_effect=Exception("DB Error")) + mock_prisma.db.query_raw = AsyncMock(side_effect=Exception("DB Error")) model_list = [ { From 834290290144b121d027401e3d27e3299f93a2ba Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 02:03:48 +0000 Subject: [PATCH 6/7] fix(images): keep drop_params out of images/edits multipart Per-model drop_params and additional_drop_params were flattened into the OpenAI images/edits body. Strip them only when building image-edit non_default_params. Do not add them to all_litellm_params: that list is popped before completion and embedding apply the drop_params flag Fixes #40153 Co-authored-by: Zsanz3 --- litellm/images/main.py | 7 +++-- .../images/test_image_edit_extra_params.py | 29 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/litellm/images/main.py b/litellm/images/main.py index 6a94e7c8df2..18255e88c24 100644 --- a/litellm/images/main.py +++ b/litellm/images/main.py @@ -64,6 +64,9 @@ from litellm.utils import ( # Cache for ImageEditRequestUtils to avoid repeated __getattr__ calls _ImageEditRequestUtils_cache: Optional["ImageEditRequestUtils"] = None +# Kept out of all_litellm_params because that list is stripped before drop_params is applied. +_IMAGE_EDIT_CONTROL_KWARGS: Final = frozenset(("drop_params", "additional_drop_params")) + def _get_ImageEditRequestUtils() -> "ImageEditRequestUtils": """Get ImageEditRequestUtils, loading it lazily if needed.""" @@ -759,8 +762,8 @@ def image_edit( litellm_params_list: Final = all_litellm_params default_params: Final = openai_params + litellm_params_list non_default_params: Final = { - k: v for k, v in kwargs.items() if k not in default_params - } # model-specific params - pass them straight to the model/provider + k: v for k, v in kwargs.items() if k not in default_params and k not in _IMAGE_EDIT_CONTROL_KWARGS + } litellm_logging_obj: Final[LiteLLMLoggingObj] = kwargs.get("litellm_logging_obj") litellm_call_id: Final[str | None] = kwargs.get("litellm_call_id", None) model_info: Final = kwargs.get("model_info", None) diff --git a/tests/test_litellm/images/test_image_edit_extra_params.py b/tests/test_litellm/images/test_image_edit_extra_params.py index 088faafa9f3..1d1fce2bf63 100644 --- a/tests/test_litellm/images/test_image_edit_extra_params.py +++ b/tests/test_litellm/images/test_image_edit_extra_params.py @@ -121,6 +121,35 @@ def test_image_edit_forwards_scalar_array_as_repeated_fields(): assert b"style_a" in body and b"style_b" in body and b"style_c" in body +@pytest.mark.parametrize("drop_params", [True, False]) +def test_image_edit_strips_per_model_drop_params_from_multipart(drop_params): + """Per-model drop_params / additional_drop_params are LiteLLM control + flags. If they survive into non_default_params they are flattened onto the + OpenAI images/edits multipart body and the provider rejects the call with + Unknown parameter: 'drop_params'. Issue #40153.""" + captured = {} + client = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(_capture_image_edit_request(captured)))) + + litellm.image_edit( + model="openai/gpt-image-1", + image=PNG_BYTES, + prompt="Make the background blue", + api_key="sk-test", + api_base="https://edit.example/v1", + client=client, + seed=42, + drop_params=drop_params, + additional_drop_params=["quality_level"], + ) + + fields = _multipart_text_fields(captured["content_type"], captured["body"]) + assert "drop_params" not in fields + assert "additional_drop_params" not in fields + assert fields["seed"] == "42" + assert fields["prompt"] == "Make the background blue" + assert fields["model"] == "gpt-image-1" + + @pytest.mark.asyncio async def test_aimage_edit_forwards_extra_body(): """aimage_edit used to drop extra_headers/extra_query/extra_body when From 13638fb3b9c6deeee8e3402af2aa289e41e68a87 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 16 Sep 2026 07:34:28 +0000 Subject: [PATCH 7/7] test(images): annotate drop_params in image-edit multipart regression Greptile flagged the parametrized test argument as untyped. Co-authored-by: Zsanz3 --- tests/test_litellm/images/test_image_edit_extra_params.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_litellm/images/test_image_edit_extra_params.py b/tests/test_litellm/images/test_image_edit_extra_params.py index 1d1fce2bf63..5b61b474c71 100644 --- a/tests/test_litellm/images/test_image_edit_extra_params.py +++ b/tests/test_litellm/images/test_image_edit_extra_params.py @@ -122,7 +122,7 @@ def test_image_edit_forwards_scalar_array_as_repeated_fields(): @pytest.mark.parametrize("drop_params", [True, False]) -def test_image_edit_strips_per_model_drop_params_from_multipart(drop_params): +def test_image_edit_strips_per_model_drop_params_from_multipart(drop_params: bool): """Per-model drop_params / additional_drop_params are LiteLLM control flags. If they survive into non_default_params they are flattened onto the OpenAI images/edits multipart body and the provider rejects the call with