From b1274ad3784b3f0139b9227864dee234a418c07a Mon Sep 17 00:00:00 2001 From: shivam Date: Wed, 30 Sep 2026 00:47:24 +0000 Subject: [PATCH 01/12] fix(proxy): return 400 instead of 500 for missing required params and invalid pagination Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../common_daily_activity.py | 6 ++++ litellm/proxy/route_llm_request.py | 4 +++ litellm/proxy/search_endpoints/endpoints.py | 4 +++ .../test_common_daily_activity.py | 30 +++++++++++++++++++ .../proxy/search_endpoints/__init__.py | 0 .../proxy/search_endpoints/test_endpoints.py | 29 ++++++++++++++++++ .../proxy/test_route_llm_request.py | 22 +++++++++----- 7 files changed, 88 insertions(+), 7 deletions(-) create mode 100644 tests/test_litellm/proxy/search_endpoints/__init__.py create mode 100644 tests/test_litellm/proxy/search_endpoints/test_endpoints.py diff --git a/litellm/proxy/management_endpoints/common_daily_activity.py b/litellm/proxy/management_endpoints/common_daily_activity.py index c2a0a41c3e2..1f7c54c6cd7 100644 --- a/litellm/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/proxy/management_endpoints/common_daily_activity.py @@ -1220,6 +1220,12 @@ async def get_daily_activity( detail={"error": "Please provide start_date and end_date"}, ) + if page < 1 or page_size < 1: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"page and page_size must be >= 1, got page={page}, page_size={page_size}", + ) + try: where_conditions: Final = _build_where_conditions( entity_id_field=entity_id_field, diff --git a/litellm/proxy/route_llm_request.py b/litellm/proxy/route_llm_request.py index 536c58df65a..810ab7c018c 100644 --- a/litellm/proxy/route_llm_request.py +++ b/litellm/proxy/route_llm_request.py @@ -161,6 +161,10 @@ REQUIRED_BODY_PARAMS_BY_ROUTE: Final[Mapping[str, tuple[str, ...]]] = { "aembedding": ("input",), "aresponses": ("input",), "acreate_batch": ("input_file_id", "endpoint", "completion_window"), + "aspeech": ("input",), + "amoderation": ("input",), + "aimage_generation": ("prompt",), + "asearch": ("query",), } diff --git a/litellm/proxy/search_endpoints/endpoints.py b/litellm/proxy/search_endpoints/endpoints.py index 2676682c59d..9515b00bc97 100644 --- a/litellm/proxy/search_endpoints/endpoints.py +++ b/litellm/proxy/search_endpoints/endpoints.py @@ -10,6 +10,7 @@ from litellm._logging import verbose_proxy_logger from litellm.proxy._types import * from litellm.proxy.auth.user_api_key_auth import UserAPIKeyAuth, user_api_key_auth from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing +from litellm.proxy.route_llm_request import ProxyMissingRequiredParamError router: Final = APIRouter() @@ -134,6 +135,9 @@ async def search( if search_tool_name is not None: data["search_tool_name"] = search_tool_name + if not data.get("search_tool_name") and not data.get("model"): + raise ProxyMissingRequiredParamError(route="/search", param="search_tool_name") + if "search_tool_name" in data and data["search_tool_name"]: data["model"] = data["search_tool_name"] search_tool_name_value: Final = data["search_tool_name"] diff --git a/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py b/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py index 52c374fe5a5..1ab74d7f729 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py +++ b/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py @@ -115,6 +115,36 @@ async def test_get_daily_activity_order_has_id_tiebreaker(): ) +@pytest.mark.asyncio +@pytest.mark.parametrize("page, page_size", [(0, 10), (-1, 10), (1, 0), (1, -5)]) +async def test_get_daily_activity_rejects_non_positive_pagination_with_400(page, page_size): + from fastapi import HTTPException + + mock_prisma = MagicMock() + mock_table = MagicMock() + mock_table.count = AsyncMock(return_value=0) + mock_table.find_many = AsyncMock(return_value=[]) + mock_prisma.db.litellm_dailyteamspend = mock_table + + with pytest.raises(HTTPException) as exc_info: + await get_daily_activity( + prisma_client=mock_prisma, + table_name="litellm_dailyteamspend", + entity_id_field="team_id", + entity_id=None, + entity_metadata_field=None, + start_date="2026-09-18", + end_date="2026-09-25", + model=None, + api_key=None, + page=page, + page_size=page_size, + ) + + assert exc_info.value.status_code == 400, exc_info.value.detail + mock_table.find_many.assert_not_called() + + def test_is_user_agent_tag(): """Test _is_user_agent_tag function.""" # Test None and empty string diff --git a/tests/test_litellm/proxy/search_endpoints/__init__.py b/tests/test_litellm/proxy/search_endpoints/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/test_litellm/proxy/search_endpoints/test_endpoints.py b/tests/test_litellm/proxy/search_endpoints/test_endpoints.py new file mode 100644 index 00000000000..adfacf83683 --- /dev/null +++ b/tests/test_litellm/proxy/search_endpoints/test_endpoints.py @@ -0,0 +1,29 @@ +from unittest.mock import AsyncMock, MagicMock + +import orjson +import pytest + +from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy.route_llm_request import ProxyMissingRequiredParamError +from litellm.proxy.search_endpoints.endpoints import search + + +def _json_request(body: dict[str, object]) -> MagicMock: + request = MagicMock() + request.body = AsyncMock(return_value=orjson.dumps(body)) + return request + + +@pytest.mark.asyncio +@pytest.mark.parametrize("body", [{"query": "litellm"}, {"query": "litellm", "search_tool_name": ""}]) +async def test_search_without_search_tool_name_or_model_is_a_400(body): + with pytest.raises(ProxyMissingRequiredParamError) as exc_info: + await search( + request=_json_request(body), + fastapi_response=MagicMock(), + user_api_key_dict=UserAPIKeyAuth(api_key="sk-test"), + ) + + assert exc_info.value.code == "400" + assert exc_info.value.param == "search_tool_name" + assert exc_info.value.message == "/search: Missing required parameter: 'search_tool_name'." diff --git a/tests/test_litellm/proxy/test_route_llm_request.py b/tests/test_litellm/proxy/test_route_llm_request.py index 0b51062dd66..0a211625b61 100644 --- a/tests/test_litellm/proxy/test_route_llm_request.py +++ b/tests/test_litellm/proxy/test_route_llm_request.py @@ -1,8 +1,6 @@ - import pytest - from typing import Final from unittest.mock import MagicMock @@ -17,10 +15,10 @@ from litellm.proxy.route_llm_request import ProxyModelNotFoundError, route_reque ("atext_completion", {}), ("acompletion", {"messages": [{"role": "user", "content": "Hello"}]}), ("aembedding", {"input": "Hello"}), - ("aimage_generation", {}), - ("aspeech", {}), + ("aimage_generation", {"prompt": "a cat"}), + ("aspeech", {"input": "Hello"}), ("atranscription", {}), - ("amoderation", {}), + ("amoderation", {"input": "Hello"}), ("arerank", {}), ], ) @@ -1045,9 +1043,15 @@ async def test_route_request_override_enable_tag_filtering_beats_body_value(): ("aembedding", "input", "/embeddings"), ("aresponses", "input", "/responses"), ("acreate_batch", "input_file_id", "/batches"), + ("aspeech", "input", "/audio/speech"), + ("amoderation", "input", "/moderations"), + ("aimage_generation", "prompt", "/image/generations"), + ("asearch", "query", "/search"), ], ) -@pytest.mark.parametrize("data_extra", [{}, {"messages": None, "input": None, "input_file_id": None}]) +@pytest.mark.parametrize( + "data_extra", [{}, {"messages": None, "input": None, "input_file_id": None, "prompt": None, "query": None}] +) def test_raise_if_required_body_param_missing_rejects_missing_param(route_type, param, route, data_extra): from litellm.proxy.route_llm_request import ( ProxyMissingRequiredParamError, @@ -1094,7 +1098,10 @@ def test_raise_if_required_body_param_missing_names_first_missing_batch_param(da ("aresponses", {"model": "gpt-4o", "input": "hi"}), ("aresponses", {"model": "gpt-4o", "input": []}), ("arerank", {"model": "rerank-model"}), - ("aimage_generation", {"model": "dall-e-3"}), + ("aimage_generation", {"model": "gpt-image-1", "prompt": "a cat"}), + ("aspeech", {"model": "gpt-4o-mini-tts", "input": "hi", "voice": "alloy"}), + ("amoderation", {"model": "omni-moderation-latest", "input": ""}), + ("asearch", {"model": "perplexity-search", "query": "litellm"}), ( "acreate_batch", {"input_file_id": "file-abc", "endpoint": "/v1/chat/completions", "completion_window": "24h"}, @@ -1257,6 +1264,7 @@ async def test_route_request_read_through_disabled_without_store_model_in_db(mon assert table.find_many_wheres == [] + @pytest.mark.asyncio async def test_route_request_routing_group_name_passes_model_gate(): from unittest.mock import AsyncMock, patch From f586220be2ea2cdaf9da413221fcbef1390def9d Mon Sep 17 00:00:00 2001 From: shivam Date: Wed, 30 Sep 2026 00:49:21 +0000 Subject: [PATCH 02/12] ci: run search_endpoints tests in proxy-endpoints shard Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/test-unit.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml index f55e186e3b2..390e0407d7b 100644 --- a/.github/workflows/test-unit.yml +++ b/.github/workflows/test-unit.yml @@ -146,6 +146,7 @@ jobs: tests/test_litellm/proxy/response_api_endpoints tests/test_litellm/proxy/image_endpoints tests/test_litellm/proxy/ocr_endpoints + tests/test_litellm/proxy/search_endpoints tests/test_litellm/proxy/vector_store_endpoints tests/test_litellm/proxy/agent_endpoints tests/test_litellm/proxy/a2a From f37c5e36083f34350407c3b77de33fb8b3ed0020 Mon Sep 17 00:00:00 2001 From: shivam Date: Wed, 30 Sep 2026 19:43:39 +0000 Subject: [PATCH 03/12] fix(proxy): return 4xx for missing required params across all LLM routes and propagate provider status on lookups Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/llms/custom_httpx/llm_http_handler.py | 65 +++++++++++ litellm/proxy/proxy_server.py | 2 + litellm/proxy/route_llm_request.py | 44 +++++++- .../management_endpoints.py | 5 + litellm/utils.py | 2 +- tests/test_litellm/proxy/test_proxy_server.py | 7 ++ .../proxy/test_route_llm_request.py | 105 ++++++++++++++++-- .../test_vector_store_rbac.py | 14 +++ .../custom_httpx/test_llm_http_handler.py | 40 +++++++ tests/unit/test_utils.py | 5 + 10 files changed, 279 insertions(+), 10 deletions(-) diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index 8d65aa7b0ca..1d880616cdf 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -3910,6 +3910,7 @@ class BaseLLMHTTPHandler: provider_config=provider_config, ) + self._raise_for_provider_error_status(response=batch_response, provider_config=provider_config) return provider_config.transform_retrieve_batch_response( model=model, raw_response=batch_response, @@ -4067,6 +4068,7 @@ class BaseLLMHTTPHandler: provider_config=provider_config, ) + self._raise_for_provider_error_status(response=batch_response, provider_config=provider_config) return provider_config.transform_retrieve_batch_response( model=model, raw_response=batch_response, @@ -4484,6 +4486,7 @@ class BaseLLMHTTPHandler: except Exception as e: raise self._handle_error(e=e, provider_config=provider_config) + self._raise_for_provider_error_status(response=response, provider_config=provider_config) return provider_config.transform_retrieve_file_response( raw_response=response, logging_obj=logging_obj, @@ -4540,6 +4543,7 @@ class BaseLLMHTTPHandler: except Exception as e: raise self._handle_error(e=e, provider_config=provider_config) + self._raise_for_provider_error_status(response=response, provider_config=provider_config) return provider_config.transform_retrieve_file_response( raw_response=response, logging_obj=logging_obj, @@ -4732,6 +4736,7 @@ class BaseLLMHTTPHandler: except Exception as e: raise self._handle_error(e=e, provider_config=provider_config) + self._raise_for_provider_error_status(response=response, provider_config=provider_config) files_per_page: Final = self._files_per_listing_page( response, provider_config, logging_obj, litellm_params, headers, sync_httpx_client, timeout ) @@ -4789,6 +4794,7 @@ class BaseLLMHTTPHandler: except Exception as e: raise self._handle_error(e=e, provider_config=provider_config) + self._raise_for_provider_error_status(response=response, provider_config=provider_config) files_per_page: Final = self._files_per_async_listing_page( response, provider_config, logging_obj, litellm_params, headers, async_httpx_client, timeout ) @@ -5925,6 +5931,38 @@ class BaseLLMHTTPHandler: return None + def _raise_for_provider_error_status( + self, + response: httpx.Response, + provider_config: Union[ + BaseConfig, + BaseRerankConfig, + BaseResponsesAPIConfig, + BaseImageEditConfig, + BaseImageGenerationConfig, + BaseVectorStoreConfig, + BaseVectorStoreFilesConfig, + BaseGoogleGenAIGenerateContentConfig, + BaseAnthropicMessagesConfig, + BaseBatchesConfig, + BaseVideoConfig, + BaseSearchConfig, + BaseTextToSpeechConfig, + BaseSkillsAPIConfig, + "BasePassthroughConfig", + "BaseContainerConfig", + BaseEvalsAPIConfig, + BaseRealtimeHTTPConfig, + ], + ) -> None: + if not httpx.codes.is_error(response.status_code): + return + raise provider_config.get_error_class( + error_message=response.text, + status_code=response.status_code, + headers=response.headers, + ) + def _handle_error( self, e: Exception, @@ -7337,6 +7375,7 @@ class BaseLLMHTTPHandler: ) # Transform the response using the provider config + self._raise_for_provider_error_status(response=response, provider_config=video_content_provider_config) return video_content_provider_config.transform_video_content_response( raw_response=response, logging_obj=logging_obj, @@ -7415,6 +7454,7 @@ class BaseLLMHTTPHandler: ) # Transform the response using the provider config + self._raise_for_provider_error_status(response=response, provider_config=video_content_provider_config) return await video_content_provider_config.async_transform_video_content_response( raw_response=response, logging_obj=logging_obj, @@ -8388,6 +8428,7 @@ class BaseLLMHTTPHandler: params=params, ) + self._raise_for_provider_error_status(response=response, provider_config=video_list_provider_config) return video_list_provider_config.transform_video_list_response( raw_response=response, logging_obj=logging_obj, @@ -8569,6 +8610,7 @@ class BaseLLMHTTPHandler: headers=headers, ) + self._raise_for_provider_error_status(response=response, provider_config=video_status_provider_config) return video_status_provider_config.transform_video_status_retrieve_response( raw_response=response, logging_obj=logging_obj, @@ -8659,6 +8701,7 @@ class BaseLLMHTTPHandler: url=url, headers=headers, ) + self._raise_for_provider_error_status(response=response, provider_config=video_status_provider_config) return await video_status_provider_config.async_transform_video_status_retrieve_response( raw_response=response, logging_obj=logging_obj, @@ -10156,6 +10199,7 @@ class BaseLLMHTTPHandler: except Exception as e: raise self._handle_error(e=e, provider_config=vector_store_provider_config) + self._raise_for_provider_error_status(response=response, provider_config=vector_store_provider_config) return vector_store_provider_config.transform_create_vector_store_response( response=response, ) @@ -10220,6 +10264,7 @@ class BaseLLMHTTPHandler: except Exception as e: raise self._handle_error(e=e, provider_config=vector_store_provider_config) + self._raise_for_provider_error_status(response=response, provider_config=vector_store_provider_config) return vector_store_provider_config.transform_create_vector_store_response( response=response, ) @@ -10286,6 +10331,7 @@ class BaseLLMHTTPHandler: except Exception as e: raise self._handle_error(e=e, provider_config=vector_store_provider_config) + self._raise_for_provider_error_status(response=response, provider_config=vector_store_provider_config) return response.json() def vector_store_list_handler( @@ -10364,6 +10410,7 @@ class BaseLLMHTTPHandler: except Exception as e: raise self._handle_error(e=e, provider_config=vector_store_provider_config) + self._raise_for_provider_error_status(response=response, provider_config=vector_store_provider_config) return response.json() async def async_vector_store_update_handler( @@ -10832,6 +10879,7 @@ class BaseLLMHTTPHandler: except Exception as e: raise self._handle_error(e=e, provider_config=vector_store_files_provider_config) + self._raise_for_provider_error_status(response=response, provider_config=vector_store_files_provider_config) return vector_store_files_provider_config.transform_list_vector_store_files_response(response=response) def vector_store_file_list_handler( @@ -10908,6 +10956,7 @@ class BaseLLMHTTPHandler: except Exception as e: raise self._handle_error(e=e, provider_config=vector_store_files_provider_config) + self._raise_for_provider_error_status(response=response, provider_config=vector_store_files_provider_config) return vector_store_files_provider_config.transform_list_vector_store_files_response(response=response) async def async_vector_store_file_retrieve_handler( @@ -10967,6 +11016,7 @@ class BaseLLMHTTPHandler: except Exception as e: raise self._handle_error(e=e, provider_config=vector_store_files_provider_config) + self._raise_for_provider_error_status(response=response, provider_config=vector_store_files_provider_config) return vector_store_files_provider_config.transform_retrieve_vector_store_file_response(response=response) def vector_store_file_retrieve_handler( @@ -11037,6 +11087,7 @@ class BaseLLMHTTPHandler: except Exception as e: raise self._handle_error(e=e, provider_config=vector_store_files_provider_config) + self._raise_for_provider_error_status(response=response, provider_config=vector_store_files_provider_config) return vector_store_files_provider_config.transform_retrieve_vector_store_file_response(response=response) async def async_vector_store_file_content_handler( @@ -11096,6 +11147,7 @@ class BaseLLMHTTPHandler: except Exception as e: raise self._handle_error(e=e, provider_config=vector_store_files_provider_config) + self._raise_for_provider_error_status(response=response, provider_config=vector_store_files_provider_config) return vector_store_files_provider_config.transform_retrieve_vector_store_file_content_response( response=response ) @@ -11168,6 +11220,7 @@ class BaseLLMHTTPHandler: except Exception as e: raise self._handle_error(e=e, provider_config=vector_store_files_provider_config) + self._raise_for_provider_error_status(response=response, provider_config=vector_store_files_provider_config) return vector_store_files_provider_config.transform_retrieve_vector_store_file_content_response( response=response ) @@ -12128,6 +12181,7 @@ class BaseLLMHTTPHandler: provider_config=skills_api_provider_config, ) + self._raise_for_provider_error_status(response=response, provider_config=skills_api_provider_config) return skills_api_provider_config.transform_list_skills_response( raw_response=response, logging_obj=logging_obj, @@ -12175,6 +12229,7 @@ class BaseLLMHTTPHandler: provider_config=skills_api_provider_config, ) + self._raise_for_provider_error_status(response=response, provider_config=skills_api_provider_config) return skills_api_provider_config.transform_list_skills_response( raw_response=response, logging_obj=logging_obj, @@ -12231,6 +12286,7 @@ class BaseLLMHTTPHandler: provider_config=skills_api_provider_config, ) + self._raise_for_provider_error_status(response=response, provider_config=skills_api_provider_config) return skills_api_provider_config.transform_get_skill_response( raw_response=response, logging_obj=logging_obj, @@ -12276,6 +12332,7 @@ class BaseLLMHTTPHandler: provider_config=skills_api_provider_config, ) + self._raise_for_provider_error_status(response=response, provider_config=skills_api_provider_config) return skills_api_provider_config.transform_get_skill_response( raw_response=response, logging_obj=logging_obj, @@ -12546,6 +12603,7 @@ class BaseLLMHTTPHandler: provider_config=evals_api_provider_config, ) + self._raise_for_provider_error_status(response=response, provider_config=evals_api_provider_config) return evals_api_provider_config.transform_list_evals_response( raw_response=response, logging_obj=logging_obj, @@ -12593,6 +12651,7 @@ class BaseLLMHTTPHandler: provider_config=evals_api_provider_config, ) + self._raise_for_provider_error_status(response=response, provider_config=evals_api_provider_config) return evals_api_provider_config.transform_list_evals_response( raw_response=response, logging_obj=logging_obj, @@ -12649,6 +12708,7 @@ class BaseLLMHTTPHandler: provider_config=evals_api_provider_config, ) + self._raise_for_provider_error_status(response=response, provider_config=evals_api_provider_config) return evals_api_provider_config.transform_get_eval_response( raw_response=response, logging_obj=logging_obj, @@ -12694,6 +12754,7 @@ class BaseLLMHTTPHandler: provider_config=evals_api_provider_config, ) + self._raise_for_provider_error_status(response=response, provider_config=evals_api_provider_config) return evals_api_provider_config.transform_get_eval_response( raw_response=response, logging_obj=logging_obj, @@ -13171,6 +13232,7 @@ class BaseLLMHTTPHandler: provider_config=evals_api_provider_config, ) + self._raise_for_provider_error_status(response=response, provider_config=evals_api_provider_config) return evals_api_provider_config.transform_list_runs_response( raw_response=response, logging_obj=logging_obj, @@ -13218,6 +13280,7 @@ class BaseLLMHTTPHandler: provider_config=evals_api_provider_config, ) + self._raise_for_provider_error_status(response=response, provider_config=evals_api_provider_config) return evals_api_provider_config.transform_list_runs_response( raw_response=response, logging_obj=logging_obj, @@ -13274,6 +13337,7 @@ class BaseLLMHTTPHandler: provider_config=evals_api_provider_config, ) + self._raise_for_provider_error_status(response=response, provider_config=evals_api_provider_config) return evals_api_provider_config.transform_get_run_response( raw_response=response, logging_obj=logging_obj, @@ -13319,6 +13383,7 @@ class BaseLLMHTTPHandler: provider_config=evals_api_provider_config, ) + self._raise_for_provider_error_status(response=response, provider_config=evals_api_provider_config) return evals_api_provider_config.transform_get_run_response( raw_response=response, logging_obj=logging_obj, diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index a994293a4d8..472a2ce3e5f 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -12172,6 +12172,8 @@ async def completion( ) litellm_call_id: Final = request_litellm_call_id(data) log_llm_api_exception(e, litellm_call_id) + if isinstance(e, ProxyException): + raise with_litellm_call_id(e, litellm_call_id) error_msg: Final = f"{e}" raise ProxyException( message=getattr(e, "message", error_msg), diff --git a/litellm/proxy/route_llm_request.py b/litellm/proxy/route_llm_request.py index 810ab7c018c..59deac0462f 100644 --- a/litellm/proxy/route_llm_request.py +++ b/litellm/proxy/route_llm_request.py @@ -1,5 +1,6 @@ import asyncio from collections.abc import Mapping +from types import MappingProxyType from typing import TYPE_CHECKING, Any, Final, Literal import httpx @@ -165,8 +166,35 @@ REQUIRED_BODY_PARAMS_BY_ROUTE: Final[Mapping[str, tuple[str, ...]]] = { "amoderation": ("input",), "aimage_generation": ("prompt",), "asearch": ("query",), + "atext_completion": ("prompt",), + "atranscription": ("file",), + "arerank": ("query", "documents"), + "acompact_responses": ("input",), + "aimage_edit": ("image", "prompt"), + "anthropic_messages": ("messages", "max_tokens"), + "agenerate_content": ("contents",), + "aocr": ("document",), + "acreate_fine_tuning_job": ("training_file",), + "avector_store_search": ("query",), + "avector_store_file_create": ("file_id",), + "avector_store_file_update": ("attributes",), + "avideo_generation": ("prompt",), + "avideo_remix": ("prompt",), + "avideo_edit": ("prompt",), + "avideo_extension": ("prompt", "seconds"), + "avideo_create_character": ("name", "video"), + "acreate_container": ("name",), + "aupload_container_file": ("file",), + "acreate_agent": ("name",), + "acreate_interaction": ("input",), + "acreate_eval": ("data_source_config", "testing_criteria"), + "acreate_run": ("data_source",), } +REQUIRED_ONE_OF_BODY_PARAMS_BY_ROUTE: Final[Mapping[str, tuple[str, str]]] = MappingProxyType( + {"acreate_interaction": ("model", "agent")} +) + class ProxyMissingRequiredParamError(ProxyException): def __init__(self, route: str, param: str): @@ -178,11 +206,18 @@ class ProxyMissingRequiredParamError(ProxyException): ) -def raise_if_required_body_param_missing(route_type: str, data: Mapping[str, object]) -> None: - missing_param: Final = next( +def _find_missing_required_body_param(route_type: str, data: Mapping[str, object]) -> str | None: + one_of_params: Final = REQUIRED_ONE_OF_BODY_PARAMS_BY_ROUTE.get(route_type) + if one_of_params is not None and all(data.get(param) is None for param in one_of_params): + return one_of_params[0] + return next( (param for param in REQUIRED_BODY_PARAMS_BY_ROUTE.get(route_type, ()) if data.get(param) is None), None, ) + + +def raise_if_required_body_param_missing(route_type: str, data: Mapping[str, object]) -> None: + missing_param: Final = _find_missing_required_body_param(route_type, data) if missing_param is None: return raise ProxyMissingRequiredParamError( @@ -634,6 +669,11 @@ async def _route_request_single_attempt( # noqa: ANN202 # returns unawaited pr # These endpoints don't need a model, use custom_llm_provider directly return getattr(litellm, f"{route_type}")(**data) + if "model" not in data: + raise ProxyMissingRequiredParamError( + route=ROUTE_ENDPOINT_MAPPING.get(route_type, route_type), + param="model", + ) team_model_name: Final = llm_router.map_team_model(data["model"], team_id) if team_id is not None else None if team_model_name is not None: data["model"] = team_model_name diff --git a/litellm/proxy/vector_store_endpoints/management_endpoints.py b/litellm/proxy/vector_store_endpoints/management_endpoints.py index cae144bb266..e65b29dadf8 100644 --- a/litellm/proxy/vector_store_endpoints/management_endpoints.py +++ b/litellm/proxy/vector_store_endpoints/management_endpoints.py @@ -369,6 +369,11 @@ async def list_vector_stores( - page: int - Page number for pagination (default: 1) - page_size: int - Number of items per page (default: 100) """ + if page < 1 or page_size < 1: + raise HTTPException( + status_code=400, + detail=f"page and page_size must be >= 1, got page={page}, page_size={page_size}", + ) await check_feature_access_for_user(user_api_key_dict, "vector_stores") from litellm.proxy.proxy_server import prisma_client diff --git a/litellm/utils.py b/litellm/utils.py index eeccd27c1d8..d47bb643a60 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -1188,7 +1188,7 @@ def function_setup( elif call_type == CallTypes.moderation.value or call_type == CallTypes.amoderation.value: messages = args[1] if len(args) > 1 else kwargs["input"] elif call_type == CallTypes.atext_completion.value or call_type == CallTypes.text_completion.value: - messages = args[0] if len(args) > 0 else kwargs["prompt"] + messages = args[0] if len(args) > 0 else kwargs.get("prompt") elif call_type == CallTypes.rerank.value or call_type == CallTypes.arerank.value: messages = kwargs.get("query") elif call_type in (CallTypes.search.value, CallTypes.asearch.value): diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 23d319159ca..924734eb3a4 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -601,6 +601,13 @@ def test_fallback_login_has_no_deprecation_banner(client_no_auth): assert " object: + async_client: Final = AsyncHTTPHandler() + await async_client.close() + async_client.client = httpx.AsyncClient(transport=httpx.MockTransport(lambda request: upstream_response)) + handler: Final = BaseLLMHTTPHandler() + if handler_name == "get_eval": + return await handler.async_get_eval_handler( + url="https://api.example.test/v1/evals/eval_missing", + evals_api_provider_config=OpenAIEvalsConfig(), + custom_llm_provider="openai", + litellm_params=GenericLiteLLMParams(), + logging_obj=Mock(), + client=async_client, + ) + return await handler.async_get_skill_handler( + url="https://api.example.test/v1/skills/skill_missing", + skills_api_provider_config=AnthropicSkillsConfig(), + custom_llm_provider="anthropic", + litellm_params=GenericLiteLLMParams(), + logging_obj=Mock(), + client=async_client, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("handler_name", ("get_eval", "get_skill")) +@pytest.mark.parametrize("status_code", (400, 401, 404, 429, 503)) +async def test_get_by_id_handlers_raise_the_provider_error_status(handler_name: str, status_code: int) -> None: + upstream_response: Final = httpx.Response(status_code, json={"error": {"message": "No such object"}}) + + with pytest.raises(BaseLLMException) as error: + await _get_by_id_with_upstream(handler_name, upstream_response) + + assert error.value.status_code == status_code + assert "No such object" in error.value.message diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index afc449d22c4..a8bb5147a88 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -6437,6 +6437,11 @@ def test_function_setup_logs_the_search_query_edit_prompt_and_ocr_document_summa assert _logged_request_messages(original_function, *args, **kwargs) == [{"role": "user", "content": expected}] +@pytest.mark.parametrize("original_function", ("atext_completion", "text_completion")) +def test_function_setup_without_a_prompt_leaves_the_missing_prompt_to_request_validation(original_function: str) -> None: + assert _logged_request_messages(original_function, model="gpt-4o") is None + + def test_search_with_a_mixed_type_query_list_still_reaches_its_own_validation_error() -> None: mixed_query: Final = cast(list[str], ["Eiffel Tower", 7]) # cast-ok: the invalid list is the point of the test From db8e9e489acd37ba7cc6c26c6add5ffbf5f2456d Mon Sep 17 00:00:00 2001 From: shivam Date: Wed, 30 Sep 2026 20:23:37 +0000 Subject: [PATCH 04/12] fix(llm_http_handler): keep provider error text when re-raising mapped errors Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/llms/custom_httpx/llm_http_handler.py | 2 +- tests/unit/test_model_block_unblock.py | 2 +- tests/unit/test_video_generation.py | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index 1d880616cdf..0c15692d79e 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -6000,7 +6000,7 @@ class BaseLLMHTTPHandler: if error_headers is None and error_response: error_headers = getattr(error_response, "headers", None) if error_response and hasattr(error_response, "text"): - error_text = getattr(error_response, "text", error_text) + error_text = getattr(error_response, "text", None) or error_text if error_headers: error_headers = dict(error_headers) else: diff --git a/tests/unit/test_model_block_unblock.py b/tests/unit/test_model_block_unblock.py index da63ed4a95a..7045cd77439 100644 --- a/tests/unit/test_model_block_unblock.py +++ b/tests/unit/test_model_block_unblock.py @@ -195,7 +195,7 @@ async def test_route_request_returns_403_when_model_is_fully_blocked(monkeypatch with pytest.raises(litellm.PermissionDeniedError) as exc_info: await route_request( - data={"model": "gpt-4o"}, + data={"model": "gpt-4o", "data_source_config": {"type": "custom"}, "testing_criteria": []}, llm_router=router, user_model=None, route_type="acreate_eval", diff --git a/tests/unit/test_video_generation.py b/tests/unit/test_video_generation.py index 5c1d0bfa884..a1e5a335fd5 100644 --- a/tests/unit/test_video_generation.py +++ b/tests/unit/test_video_generation.py @@ -1109,6 +1109,7 @@ def test_video_content_handler_passes_variant_to_url(): mock_client = MagicMock(spec=HTTPHandler) mock_response = MagicMock() mock_response.content = b"thumbnail-bytes" + mock_response.status_code = 200 mock_client.get.return_value = mock_response with patch( @@ -1154,6 +1155,7 @@ def test_video_content_handler_uses_get_for_openai(): mock_client = MagicMock(spec=HTTPHandler) mock_response = MagicMock() mock_response.content = b"mp4-bytes" + mock_response.status_code = 200 mock_client.get.return_value = mock_response # Patch _get_httpx_client to ensure no real HTTP client is created From f415ff45e1813fbac7cb094c7044bc2060c294f3 Mon Sep 17 00:00:00 2001 From: shivam Date: Wed, 30 Sep 2026 20:59:22 +0000 Subject: [PATCH 05/12] fix(proxy): allow promptless image edits and default search models Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/route_llm_request.py | 1 - litellm/proxy/search_endpoints/endpoints.py | 4 +++- .../proxy/search_endpoints/test_endpoints.py | 23 +++++++++++++++++++ .../proxy/test_route_llm_request.py | 4 ++-- 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/route_llm_request.py b/litellm/proxy/route_llm_request.py index b87b15d62ce..a36f1e0947a 100644 --- a/litellm/proxy/route_llm_request.py +++ b/litellm/proxy/route_llm_request.py @@ -171,7 +171,6 @@ REQUIRED_BODY_PARAMS_BY_ROUTE: Final[Mapping[str, tuple[str, ...]]] = { "atranscription": ("file",), "arerank": ("query", "documents"), "acompact_responses": ("input",), - "aimage_edit": ("image", "prompt"), "anthropic_messages": ("messages", "max_tokens"), "agenerate_content": ("contents",), "aocr": ("document",), diff --git a/litellm/proxy/search_endpoints/endpoints.py b/litellm/proxy/search_endpoints/endpoints.py index 9515b00bc97..9cc76024770 100644 --- a/litellm/proxy/search_endpoints/endpoints.py +++ b/litellm/proxy/search_endpoints/endpoints.py @@ -135,7 +135,9 @@ async def search( if search_tool_name is not None: data["search_tool_name"] = search_tool_name - if not data.get("search_tool_name") and not data.get("model"): + if not ( + data.get("search_tool_name") or data.get("model") or general_settings.get("completion_model") or user_model + ): raise ProxyMissingRequiredParamError(route="/search", param="search_tool_name") if "search_tool_name" in data and data["search_tool_name"]: diff --git a/tests/test_litellm/proxy/search_endpoints/test_endpoints.py b/tests/test_litellm/proxy/search_endpoints/test_endpoints.py index adfacf83683..b55d444b828 100644 --- a/tests/test_litellm/proxy/search_endpoints/test_endpoints.py +++ b/tests/test_litellm/proxy/search_endpoints/test_endpoints.py @@ -3,7 +3,9 @@ from unittest.mock import AsyncMock, MagicMock import orjson import pytest +from litellm.proxy import proxy_server from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing from litellm.proxy.route_llm_request import ProxyMissingRequiredParamError from litellm.proxy.search_endpoints.endpoints import search @@ -27,3 +29,24 @@ async def test_search_without_search_tool_name_or_model_is_a_400(body): assert exc_info.value.code == "400" assert exc_info.value.param == "search_tool_name" assert exc_info.value.message == "/search: Missing required parameter: 'search_tool_name'." + + +@pytest.mark.asyncio +@pytest.mark.parametrize("default_source", ["cli_model", "completion_model"]) +async def test_search_with_only_a_query_falls_back_to_the_proxy_default_model(monkeypatch, default_source): + if default_source == "cli_model": + monkeypatch.setattr(proxy_server, "user_model", "perplexity-search") + else: + monkeypatch.setitem(proxy_server.general_settings, "completion_model", "perplexity-search") + search_result = {"object": "search", "results": []} + monkeypatch.setattr( + ProxyBaseLLMRequestProcessing, "base_process_llm_request", AsyncMock(return_value=search_result) + ) + + response = await search( + request=_json_request({"query": "litellm"}), + fastapi_response=MagicMock(), + user_api_key_dict=UserAPIKeyAuth(api_key="sk-test"), + ) + + assert response == search_result, response diff --git a/tests/test_litellm/proxy/test_route_llm_request.py b/tests/test_litellm/proxy/test_route_llm_request.py index 889143b869f..7e2c2ab3aac 100644 --- a/tests/test_litellm/proxy/test_route_llm_request.py +++ b/tests/test_litellm/proxy/test_route_llm_request.py @@ -1052,7 +1052,6 @@ async def test_route_request_override_enable_tag_filtering_beats_body_value(): ("atranscription", "file", "/audio/transcriptions"), ("arerank", "query", "/rerank"), ("acompact_responses", "input", "/responses/compact"), - ("aimage_edit", "image", "/images/edits"), ("anthropic_messages", "messages", "anthropic_messages"), ("agenerate_content", "contents", "agenerate_content"), ("aocr", "document", "/ocr"), @@ -1130,7 +1129,6 @@ def test_raise_if_required_body_param_missing_names_first_missing_batch_param(da "route_type, data, param", [ ("arerank", {"model": "rerank-model", "query": "hi"}, "documents"), - ("aimage_edit", {"model": "gpt-image-1", "image": b"png"}, "prompt"), ("anthropic_messages", {"model": "claude", "messages": []}, "max_tokens"), ("avideo_extension", {"model": "sora-2", "prompt": "longer"}, "seconds"), ("avideo_create_character", {"name": "hero"}, "video"), @@ -1164,6 +1162,8 @@ def test_raise_if_required_body_param_missing_names_each_missing_param(route_typ ("aresponses", {"model": "gpt-4o", "input": []}), ("arerank", {"model": "rerank-model", "query": "hi", "documents": ["hello"]}), ("aimage_edit", {"model": "gpt-image-1", "image": b"png", "prompt": "a hat"}), + ("aimage_edit", {"model": "stability.stable-image-remove-background-v1:0", "image": b"png"}), + ("aimage_edit", {"model": "stability.stable-style-transfer-v1:0", "init_image": b"png"}), ("anthropic_messages", {"model": "claude", "messages": [], "max_tokens": 16}), ("avideo_extension", {"model": "sora-2", "prompt": "longer", "seconds": "4"}), ("acreate_eval", {"data_source_config": {"type": "custom"}, "testing_criteria": []}), From 23b0a3cf3de21d5e15dac6125b4b2b9b9cbc6015 Mon Sep 17 00:00:00 2001 From: shivam Date: Wed, 30 Sep 2026 21:03:15 +0000 Subject: [PATCH 06/12] fix(proxy): default missing image edit image to None Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/image_endpoints/endpoints.py | 4 ++- .../proxy/image_endpoints/test_endpoints.py | 34 +++++++++++++++++-- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/image_endpoints/endpoints.py b/litellm/proxy/image_endpoints/endpoints.py index b9580ba3948..e20efe52bec 100644 --- a/litellm/proxy/image_endpoints/endpoints.py +++ b/litellm/proxy/image_endpoints/endpoints.py @@ -320,9 +320,11 @@ async def image_edit_api( detail=f"'{_field}' must be provided as a multipart file upload, not a string.", ) - # Ensure prompt exists in data (default to None for models that don't require it) + # Ensure prompt and image exist in data (default to None for models that don't require them) if "prompt" not in data: data["prompt"] = None + if "image" not in data: + data["image"] = None data["model"] = ( model diff --git a/tests/test_litellm/proxy/image_endpoints/test_endpoints.py b/tests/test_litellm/proxy/image_endpoints/test_endpoints.py index ad0901e9eee..f4aebecc11e 100644 --- a/tests/test_litellm/proxy/image_endpoints/test_endpoints.py +++ b/tests/test_litellm/proxy/image_endpoints/test_endpoints.py @@ -222,6 +222,28 @@ def test_image_edit_multipart_n_that_is_not_a_number_is_left_alone(monkeypatch): assert captured["n"] == "two" +@pytest.mark.parametrize( + "files, form, missing", + [ + ({}, {"model": "stability.stable-style-transfer-v1:0", "prompt": "oil painting"}, "image"), + ( + {"image": ("tree.png", b"\x89PNG\r\n\x1a\n", "image/png")}, + {"model": "stability.stable-image-remove-background-v1:0"}, + "prompt", + ), + ], +) +def test_image_edit_without_an_optional_field_reaches_the_provider_with_it_set_to_none( + monkeypatch, files, form, missing +): + captured: Dict[str, Any] = {} + + response = _image_edit_client(monkeypatch, captured).post("/v1/images/edits", files=files or None, data=form) + + assert response.status_code == 200, response.text + assert missing in captured and captured[missing] is None, captured + + @pytest.mark.asyncio async def test_a_model_the_router_cannot_serve_answers_an_openai_typed_error(monkeypatch: pytest.MonkeyPatch): """A bare HTTPException carries no type or param, so the tail used to ship the @@ -290,7 +312,9 @@ async def test_failure_log_carries_the_callers_litellm_call_id( async def fake_add_litellm_data_to_request(**kwargs: object) -> object: return kwargs["data"] - async def fake_pre_call_hook(*, user_api_key_dict: UserAPIKeyAuth, data: dict[str, object], call_type: str) -> dict[str, object]: + async def fake_pre_call_hook( + *, user_api_key_dict: UserAPIKeyAuth, data: dict[str, object], call_type: str + ) -> dict[str, object]: return data async def fake_post_call_failure_hook(**_: object) -> None: @@ -327,7 +351,9 @@ async def test_failure_log_carries_the_callers_litellm_call_id( ) with caplog.at_level(logging.ERROR, logger="LiteLLM Proxy"), pytest.raises(ProxyException) as raised: - await endpoints.image_generation(request=request, fastapi_response=Response(), user_api_key_dict=UserAPIKeyAuth()) + await endpoints.image_generation( + request=request, fastapi_response=Response(), user_api_key_dict=UserAPIKeyAuth() + ) assert raised.value.headers["x-litellm-call-id"] == call_id record = next(r for r in caplog.records if "Exception occured" in r.getMessage()) @@ -378,7 +404,9 @@ async def test_failure_before_the_provider_call_bills_the_callers_litellm_call_i ) with pytest.raises(ProxyException) as raised: - await endpoints.image_generation(request=request, fastapi_response=Response(), user_api_key_dict=UserAPIKeyAuth()) + await endpoints.image_generation( + request=request, fastapi_response=Response(), user_api_key_dict=UserAPIKeyAuth() + ) assert raised.value.headers["x-litellm-call-id"] == call_id assert [data["litellm_call_id"] for data in hook_request_data] == [call_id] From e19b44573aa9334cd5837da5426190dc7f3b6e55 Mon Sep 17 00:00:00 2001 From: shivam Date: Wed, 30 Sep 2026 21:19:31 +0000 Subject: [PATCH 07/12] refactor(proxy): build image edit defaults without mutating request data Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/image_endpoints/endpoints.py | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/litellm/proxy/image_endpoints/endpoints.py b/litellm/proxy/image_endpoints/endpoints.py index e20efe52bec..ea736a87b6c 100644 --- a/litellm/proxy/image_endpoints/endpoints.py +++ b/litellm/proxy/image_endpoints/endpoints.py @@ -298,13 +298,14 @@ async def image_edit_api( ######################################################### # Read request body and convert UploadFiles to BytesIO ######################################################### + form_fields: Final = coerce_numeric_form_fields( + parsed_body=await _read_request_body(request=request), + numeric_fields=IMAGE_EDIT_NUMERIC_FORM_FIELDS, + ) data: Final = { - key: value - for key, value in coerce_numeric_form_fields( - parsed_body=await _read_request_body(request=request), - numeric_fields=IMAGE_EDIT_NUMERIC_FORM_FIELDS, - ).items() - if key not in BRACKETED_FILE_FIELDS + "prompt": None, + "image": None, + **{key: value for key, value in form_fields.items() if key not in BRACKETED_FILE_FIELDS}, } image_files: Final = await batch_to_bytesio(image) mask_files: Final = await batch_to_bytesio(mask) @@ -320,12 +321,6 @@ async def image_edit_api( detail=f"'{_field}' must be provided as a multipart file upload, not a string.", ) - # Ensure prompt and image exist in data (default to None for models that don't require them) - if "prompt" not in data: - data["prompt"] = None - if "image" not in data: - data["image"] = None - data["model"] = ( model or general_settings.get("image_generation_model", None) # server default From 57e95cc66ffb744e4508a3be327408147a257021 Mon Sep 17 00:00:00 2001 From: shivam Date: Wed, 30 Sep 2026 21:40:58 +0000 Subject: [PATCH 08/12] fix(proxy): keep image edit defaults within type-discipline budget and give request mocks a scope Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/image_endpoints/endpoints.py | 9 ++++++--- .../proxy/batches_endpoints/test_endpoints.py | 1 + .../google_endpoints/test_google_endpoint_routing.py | 1 + 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/image_endpoints/endpoints.py b/litellm/proxy/image_endpoints/endpoints.py index c6b7e3d5468..4dc147b6687 100644 --- a/litellm/proxy/image_endpoints/endpoints.py +++ b/litellm/proxy/image_endpoints/endpoints.py @@ -1,6 +1,8 @@ import asyncio import io from collections.abc import Sequence +from itertools import chain +from types import MappingProxyType from typing import Final, get_type_hints import orjson @@ -36,6 +38,7 @@ from litellm.types.llms.openai import ChatCompletionUserMessage router: Final = APIRouter() IMAGE_EDIT_NUMERIC_FORM_FIELDS: Final = numeric_form_fields(get_type_hints(ImageEditRequestParams)) +IMAGE_EDIT_OPTIONAL_FIELD_DEFAULTS: Final = MappingProxyType({"prompt": None, "image": None}) IMAGE_ARRAY_FIELD: Final = "image[]" MASK_ARRAY_FIELD: Final = "mask[]" @@ -299,9 +302,9 @@ async def image_edit_api( numeric_fields=IMAGE_EDIT_NUMERIC_FORM_FIELDS, ) data: Final = { - "prompt": None, - "image": None, - **{key: value for key, value in form_fields.items() if key not in BRACKETED_FILE_FIELDS}, + key: value + for key, value in chain(IMAGE_EDIT_OPTIONAL_FIELD_DEFAULTS.items(), form_fields.items()) + if key not in BRACKETED_FILE_FIELDS } image_files: Final = await batch_to_bytesio(image) mask_files: Final = await batch_to_bytesio(mask) diff --git a/tests/test_litellm/proxy/batches_endpoints/test_endpoints.py b/tests/test_litellm/proxy/batches_endpoints/test_endpoints.py index 2d597abf3b8..f2422d4d22e 100644 --- a/tests/test_litellm/proxy/batches_endpoints/test_endpoints.py +++ b/tests/test_litellm/proxy/batches_endpoints/test_endpoints.py @@ -1034,6 +1034,7 @@ def _raw_batches_request(body: Dict[str, Any]) -> MagicMock: request.url.__str__.return_value = "http://localhost/v1/batches" request.url.path = "/v1/batches" request.method = "POST" + request.scope = {"type": "http", "path": "/v1/batches", "method": "POST"} request.query_params = {} request.headers = {"Content-Type": "application/json"} request.client = MagicMock() diff --git a/tests/unit/proxy/google_endpoints/test_google_endpoint_routing.py b/tests/unit/proxy/google_endpoints/test_google_endpoint_routing.py index 3dcfede92ea..b19678ffb59 100644 --- a/tests/unit/proxy/google_endpoints/test_google_endpoint_routing.py +++ b/tests/unit/proxy/google_endpoints/test_google_endpoint_routing.py @@ -39,6 +39,7 @@ def mock_request(request): mock_req.headers = Headers({"content-type": "application/json"}) mock_req.method = "POST" mock_req.url.path = request.param.get("path") + mock_req.scope = {"type": "http", "path": request.param.get("path"), "method": "POST"} async def mock_body(): return json.dumps(request.param.get("payload", {})).encode("utf-8") From c0631238bb580a86487308be170e3b1bf85f0ac1 Mon Sep 17 00:00:00 2001 From: shivam Date: Wed, 30 Sep 2026 21:42:39 +0000 Subject: [PATCH 09/12] test(proxy): inject a fake router for the search default model test Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../proxy/search_endpoints/test_endpoints.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/test_litellm/proxy/search_endpoints/test_endpoints.py b/tests/test_litellm/proxy/search_endpoints/test_endpoints.py index b55d444b828..bd6460e3dfb 100644 --- a/tests/test_litellm/proxy/search_endpoints/test_endpoints.py +++ b/tests/test_litellm/proxy/search_endpoints/test_endpoints.py @@ -5,7 +5,6 @@ import pytest from litellm.proxy import proxy_server from litellm.proxy._types import UserAPIKeyAuth -from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing from litellm.proxy.route_llm_request import ProxyMissingRequiredParamError from litellm.proxy.search_endpoints.endpoints import search @@ -39,9 +38,9 @@ async def test_search_with_only_a_query_falls_back_to_the_proxy_default_model(mo else: monkeypatch.setitem(proxy_server.general_settings, "completion_model", "perplexity-search") search_result = {"object": "search", "results": []} - monkeypatch.setattr( - ProxyBaseLLMRequestProcessing, "base_process_llm_request", AsyncMock(return_value=search_result) - ) + router = MagicMock() + router.asearch = AsyncMock(return_value=search_result) + monkeypatch.setattr(proxy_server, "llm_router", router) response = await search( request=_json_request({"query": "litellm"}), @@ -50,3 +49,6 @@ async def test_search_with_only_a_query_falls_back_to_the_proxy_default_model(mo ) assert response == search_result, response + router.asearch.assert_awaited_once() + assert router.asearch.await_args.kwargs["query"] == "litellm" + assert router.asearch.await_args.kwargs["model"] == "perplexity-search" From d7a7227fad09bb457502cf4128e1e78a8b563f1b Mon Sep 17 00:00:00 2001 From: shivam Date: Wed, 30 Sep 2026 22:00:09 +0000 Subject: [PATCH 10/12] test(llms): cover provider error status on vector store and file lookup handlers Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../custom_httpx/test_llm_http_handler.py | 111 +++++++++++++++++- 1 file changed, 110 insertions(+), 1 deletion(-) diff --git a/tests/unit/llms/custom_httpx/test_llm_http_handler.py b/tests/unit/llms/custom_httpx/test_llm_http_handler.py index f29c0f18b78..951edecadef 100644 --- a/tests/unit/llms/custom_httpx/test_llm_http_handler.py +++ b/tests/unit/llms/custom_httpx/test_llm_http_handler.py @@ -42,6 +42,9 @@ from litellm.llms.bedrock.messages.invoke_transformations.anthropic_claude3_tran ) from litellm.llms.anthropic.skills.transformation import AnthropicSkillsConfig from litellm.llms.openai.evals.transformation import OpenAIEvalsConfig +from litellm.llms.mistral.files.transformation import MistralFilesConfig +from litellm.llms.openai.vector_store_files.transformation import OpenAIVectorStoreFilesConfig +from litellm.llms.openai.vector_stores.transformation import OpenAIVectorStoreConfig from litellm.llms.openai.videos.transformation import OpenAIVideoConfig from litellm.llms.tinyfish.search.transformation import TinyfishSearchConfig from litellm.types.llms.openai import HttpxBinaryResponseContent, ResponsesAPIResponse @@ -4306,7 +4309,6 @@ async def test_async_text_to_speech_handler_records_upstream_response_headers(): _assert_upstream_headers_recorded(response) - async def _get_by_id_with_upstream(handler_name: str, upstream_response: httpx.Response) -> object: async_client: Final = AsyncHTTPHandler() await async_client.close() @@ -4342,3 +4344,110 @@ async def test_get_by_id_handlers_raise_the_provider_error_status(handler_name: assert error.value.status_code == status_code assert "No such object" in error.value.message + + +def _clients_answering_with(upstream_response: httpx.Response) -> tuple[HTTPHandler, AsyncHTTPHandler]: + sync_client: Final = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(lambda _: upstream_response))) + async_client: Final = AsyncHTTPHandler() + async_client.client = httpx.AsyncClient(transport=httpx.MockTransport(lambda _: upstream_response)) + return sync_client, async_client + + +def _call_lookup_handler(name: str, is_async: bool, client: HTTPHandler | AsyncHTTPHandler) -> object: + handler: Final = BaseLLMHTTPHandler() + vector_store_params: Final = GenericLiteLLMParams(api_base="https://api.example.test/v1", api_key="sk-test") + files_params: Final = {"api_base": "https://api.example.test", "api_key": "sk-test"} + match name: + case "vector_store_retrieve": + return handler.vector_store_retrieve_handler( + vector_store_id="vs_missing", + vector_store_provider_config=OpenAIVectorStoreConfig(), + custom_llm_provider="openai", + litellm_params=vector_store_params, + logging_obj=Mock(), + client=client, + _is_async=is_async, + ) + case "vector_store_list": + return handler.vector_store_list_handler( + after=None, + before=None, + limit=None, + order=None, + vector_store_provider_config=OpenAIVectorStoreConfig(), + custom_llm_provider="openai", + litellm_params=vector_store_params, + logging_obj=Mock(), + client=client, + _is_async=is_async, + ) + case "vector_store_file_list": + return handler.vector_store_file_list_handler( + vector_store_id="vs_missing", + query_params={}, + vector_store_files_provider_config=OpenAIVectorStoreFilesConfig(), + custom_llm_provider="openai", + litellm_params=vector_store_params, + logging_obj=Mock(), + client=client, + _is_async=is_async, + ) + case "vector_store_file_retrieve": + return handler.vector_store_file_retrieve_handler( + vector_store_id="vs_missing", + file_id="file_missing", + vector_store_files_provider_config=OpenAIVectorStoreFilesConfig(), + custom_llm_provider="openai", + litellm_params=vector_store_params, + logging_obj=Mock(), + client=client, + _is_async=is_async, + ) + case "file_retrieve": + return handler.retrieve_file( + file_id="file_missing", + provider_config=MistralFilesConfig(), + litellm_params=files_params, + headers={}, + logging_obj=Mock(), + _is_async=is_async, + client=client, + ) + case _: + return handler.list_files( + purpose=None, + provider_config=MistralFilesConfig(), + litellm_params=files_params, + headers={}, + logging_obj=Mock(), + _is_async=is_async, + client=client, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "name", + ( + "vector_store_retrieve", + "vector_store_list", + "vector_store_file_list", + "vector_store_file_retrieve", + "file_retrieve", + "file_list", + ), +) +@pytest.mark.parametrize("is_async", (False, True)) +@pytest.mark.parametrize("status_code", (404, 503)) +async def test_lookup_handlers_raise_the_provider_error_status(name: str, is_async: bool, status_code: int) -> None: + sync_client, async_client = _clients_answering_with( + httpx.Response(status_code, json={"error": {"message": "No such object"}}) + ) + + with pytest.raises(BaseLLMException) as error: + result: Final = _call_lookup_handler(name, is_async, async_client if is_async else sync_client) + if is_async: + await result + + assert error.value.status_code == status_code + assert "No such object" in error.value.message From 0be90428f524c70bca72cc0c0514ba9296c19485 Mon Sep 17 00:00:00 2001 From: shivam Date: Wed, 30 Sep 2026 22:14:08 +0000 Subject: [PATCH 11/12] test(llms): keep the lookup handler raise block to a single statement Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/unit/llms/custom_httpx/test_llm_http_handler.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/unit/llms/custom_httpx/test_llm_http_handler.py b/tests/unit/llms/custom_httpx/test_llm_http_handler.py index 951edecadef..7f1ca2ff6d0 100644 --- a/tests/unit/llms/custom_httpx/test_llm_http_handler.py +++ b/tests/unit/llms/custom_httpx/test_llm_http_handler.py @@ -1,5 +1,6 @@ import asyncio import base64 +import inspect import json import logging import threading @@ -4425,6 +4426,11 @@ def _call_lookup_handler(name: str, is_async: bool, client: HTTPHandler | AsyncH ) +async def _run_lookup_handler(name: str, is_async: bool, client: HTTPHandler | AsyncHTTPHandler) -> object: + result: Final = _call_lookup_handler(name, is_async, client) + return await result if inspect.isawaitable(result) else result + + @pytest.mark.asyncio @pytest.mark.parametrize( "name", @@ -4445,9 +4451,7 @@ async def test_lookup_handlers_raise_the_provider_error_status(name: str, is_asy ) with pytest.raises(BaseLLMException) as error: - result: Final = _call_lookup_handler(name, is_async, async_client if is_async else sync_client) - if is_async: - await result + await _run_lookup_handler(name, is_async, async_client if is_async else sync_client) assert error.value.status_code == status_code assert "No such object" in error.value.message From e0eb130ddc41dad26e295fc0bfa264f5ff4d7c4b Mon Sep 17 00:00:00 2001 From: shivam Date: Wed, 30 Sep 2026 22:28:13 +0000 Subject: [PATCH 12/12] test(llms): cover provider error status on eval, eval run, skill and vector store file content lookups Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../custom_httpx/test_llm_http_handler.py | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/tests/unit/llms/custom_httpx/test_llm_http_handler.py b/tests/unit/llms/custom_httpx/test_llm_http_handler.py index 7f1ca2ff6d0..d283cc6c64c 100644 --- a/tests/unit/llms/custom_httpx/test_llm_http_handler.py +++ b/tests/unit/llms/custom_httpx/test_llm_http_handler.py @@ -4414,6 +4414,80 @@ def _call_lookup_handler(name: str, is_async: bool, client: HTTPHandler | AsyncH _is_async=is_async, client=client, ) + case "vector_store_file_content": + return handler.vector_store_file_content_handler( + vector_store_id="vs_missing", + file_id="file_missing", + vector_store_files_provider_config=OpenAIVectorStoreFilesConfig(), + custom_llm_provider="openai", + litellm_params=vector_store_params, + logging_obj=Mock(), + client=client, + _is_async=is_async, + ) + case "eval_list": + return handler.list_evals_handler( + url="https://api.example.test/v1/evals", + query_params={}, + evals_api_provider_config=OpenAIEvalsConfig(), + custom_llm_provider="openai", + litellm_params=vector_store_params, + logging_obj=Mock(), + client=client, + _is_async=is_async, + ) + case "eval_get": + return handler.get_eval_handler( + url="https://api.example.test/v1/evals/eval_missing", + evals_api_provider_config=OpenAIEvalsConfig(), + custom_llm_provider="openai", + litellm_params=vector_store_params, + logging_obj=Mock(), + client=client, + _is_async=is_async, + ) + case "eval_run_list": + return handler.list_runs_handler( + url="https://api.example.test/v1/evals/eval_missing/runs", + query_params={}, + evals_api_provider_config=OpenAIEvalsConfig(), + custom_llm_provider="openai", + litellm_params=vector_store_params, + logging_obj=Mock(), + client=client, + _is_async=is_async, + ) + case "eval_run_get": + return handler.get_run_handler( + url="https://api.example.test/v1/evals/eval_missing/runs/run_missing", + evals_api_provider_config=OpenAIEvalsConfig(), + custom_llm_provider="openai", + litellm_params=vector_store_params, + logging_obj=Mock(), + client=client, + _is_async=is_async, + ) + case "skill_list": + return handler.list_skills_handler( + url="https://api.example.test/v1/skills", + query_params={}, + skills_api_provider_config=AnthropicSkillsConfig(), + custom_llm_provider="anthropic", + litellm_params=vector_store_params, + logging_obj=Mock(), + client=client, + _is_async=is_async, + ) + case "skill_get": + return handler.get_skill_handler( + url="https://api.example.test/v1/skills/skill_missing", + skills_api_provider_config=AnthropicSkillsConfig(), + custom_llm_provider="anthropic", + litellm_params=vector_store_params, + logging_obj=Mock(), + client=client, + _is_async=is_async, + ) case _: return handler.list_files( purpose=None, @@ -4439,8 +4513,15 @@ async def _run_lookup_handler(name: str, is_async: bool, client: HTTPHandler | A "vector_store_list", "vector_store_file_list", "vector_store_file_retrieve", + "vector_store_file_content", "file_retrieve", "file_list", + "eval_list", + "eval_get", + "eval_run_list", + "eval_run_get", + "skill_list", + "skill_get", ), ) @pytest.mark.parametrize("is_async", (False, True))