From 0f3c4ccfbba894c0a66d92ed44ca18f312bf75f0 Mon Sep 17 00:00:00 2001 From: jesus Date: Fri, 11 Sep 2026 00:00:17 +0000 Subject: [PATCH 01/73] fix(auth): inherit organization_alias from the org for JWT and team-linked keys Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/auth/user_api_key_auth.py | 42 ++++++- .../proxy/auth/test_user_api_key_auth.py | 104 +++++++++++++++++- 2 files changed, 143 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index 20ab9904f46..110c524ecdf 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -54,6 +54,7 @@ from litellm.proxy.auth.auth_checks import ( get_end_user_object, get_jwt_key_mapping_object, get_object_permission, + get_org_object, get_project_object, get_team_object, get_user_object, @@ -2398,6 +2399,37 @@ def _token_can_vouch_for_team(valid_token: UserAPIKeyAuth, lookup_error: BaseExc return PrismaDBExceptionHandler.should_allow_request_on_db_unavailable() +async def _inherit_org_identity( + user_api_key_auth_obj: UserAPIKeyAuth, + team_object: LiteLLM_TeamTableCachedObj | None, + prisma_client: PrismaClient | None, + user_api_key_cache: UserApiKeyCache, + parent_otel_span: Span | None, + proxy_logging_obj: ProxyLogging | None, +) -> None: + if user_api_key_auth_obj.org_id is None and team_object is not None and team_object.organization_id is not None: + user_api_key_auth_obj.org_id = team_object.organization_id + if ( + user_api_key_auth_obj.org_id is None + or user_api_key_auth_obj.organization_alias is not None + or prisma_client is None + ): + return + try: + org_object: Final = await get_org_object( + org_id=user_api_key_auth_obj.org_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=parent_otel_span, + proxy_logging_obj=proxy_logging_obj, + ) + except Exception: + verbose_proxy_logger.debug("org alias lookup failed for org_id=%s", user_api_key_auth_obj.org_id, exc_info=True) + return + if org_object is not None: + user_api_key_auth_obj.organization_alias = org_object.organization_alias + + @tracer.wrap() async def _run_centralized_common_checks( user_api_key_auth_obj: UserAPIKeyAuth, @@ -2622,8 +2654,14 @@ async def _run_centralized_common_checks( ) global_proxy_spend: float | None = None if isinstance(global_spend_result, BaseException) else global_spend_result - if user_api_key_auth_obj.org_id is None and team_object is not None and team_object.organization_id is not None: - user_api_key_auth_obj.org_id = team_object.organization_id + await _inherit_org_identity( + user_api_key_auth_obj=user_api_key_auth_obj, + team_object=cast(LiteLLM_TeamTableCachedObj | None, team_object), + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=parent_otel_span, + proxy_logging_obj=proxy_logging_obj, + ) # common_checks identifies admin via user_object, not the token # (non_proxy_admin_allowed_routes_check). JWT admin shortcut and diff --git a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py index 6cce6d0316b..03efbfa7185 100644 --- a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py +++ b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py @@ -23,6 +23,7 @@ from litellm.proxy._types import ( LiteLLM_JWTAuth, LiteLLM_BudgetTable, LiteLLM_EndUserTable, + LiteLLM_OrganizationTable, LiteLLM_UserTable, LitellmUserRoles, ProxyErrorTypes, @@ -31,7 +32,7 @@ from litellm.proxy._types import ( JWTRoutingOverride, ) from litellm.proxy.auth.handle_jwt import JWTHandler -from litellm.proxy.auth.auth_checks import get_key_object, _cache_key_object +from litellm.proxy.auth.auth_checks import OrganizationNotFoundError, get_key_object, _cache_key_object from litellm.proxy.auth.route_checks import RouteChecks from litellm.proxy.auth.user_api_key_auth import ( _check_key_model_budget_with_fallback, @@ -5293,6 +5294,107 @@ async def test_centralized_common_checks_backfills_org_id_from_team(key_org_id, setattr(_proxy_server_mod, k, v) +@pytest.mark.asyncio +@pytest.mark.parametrize( + "key_org_id,team_id,team_org_id,existing_alias,lookup_mode,expected_org_id,expected_alias", + [ + (None, "t1", "org-from-team", None, "success", "org-from-team", "acme-org"), + ("org-jwt", None, None, None, "success", "org-jwt", "acme-org"), + ("org-pinned", None, None, "preset", "success", "org-pinned", "preset"), + ("org-missing", None, None, None, "missing", "org-missing", None), + ], +) +async def test_centralized_common_checks_inherits_org_alias( + key_org_id, + team_id, + team_org_id, + existing_alias, + lookup_mode, + expected_org_id, + expected_alias, +): + import litellm.proxy.proxy_server as _proxy_server_mod + from fastapi import Request + from starlette.datastructures import URL + + from litellm.proxy._types import LiteLLM_TeamTableCachedObj + + token = UserAPIKeyAuth( + api_key="sk-test", + user_id="u", + team_id=team_id, + org_id=key_org_id, + organization_alias=existing_alias, + ) + request = Request(scope={"type": "http"}) + request._url = URL(url="/chat/completions") + + fetched_team = ( + LiteLLM_TeamTableCachedObj(team_id="t1", organization_id=team_org_id) if team_id is not None else None + ) + organization = LiteLLM_OrganizationTable( + organization_id=expected_org_id, + organization_alias="acme-org", + budget_id="budget-id", + models=[], + created_by="test", + updated_by="test", + ) + + attrs = _proxy_attrs_for_centralized_checks(user_custom_auth=None) + attrs["prisma_client"] = MagicMock() + originals = {a: getattr(_proxy_server_mod, a, None) for a in attrs} + try: + for k, v in attrs.items(): + setattr(_proxy_server_mod, k, v) + identity_seen_by_common_checks = [] + with ( + patch( + "litellm.proxy.auth.user_api_key_auth.get_team_object", + new_callable=AsyncMock, + return_value=fetched_team, + ) as mock_get_team_object, + patch( + "litellm.proxy.auth.user_api_key_auth.get_org_object", + new_callable=AsyncMock, + return_value=organization, + ) as mock_get_org_object, + patch( + "litellm.proxy.auth.user_api_key_auth.common_checks", + new_callable=AsyncMock, + side_effect=lambda **kw: identity_seen_by_common_checks.append( + (kw["valid_token"].org_id, kw["valid_token"].organization_alias) + ), + ) as mock_checks, + ): + if lookup_mode == "missing": + mock_get_org_object.side_effect = OrganizationNotFoundError("x") + + await _run_centralized_common_checks( + user_api_key_auth_obj=token, + request=request, + request_data={"model": "gpt-4o"}, + route="/chat/completions", + ) + + mock_checks.assert_awaited_once() + assert token.org_id == expected_org_id + assert token.organization_alias == expected_alias + assert identity_seen_by_common_checks == [(expected_org_id, expected_alias)] + if team_id is None: + mock_get_team_object.assert_not_awaited() + else: + mock_get_team_object.assert_awaited_once() + if existing_alias is not None: + mock_get_org_object.assert_not_awaited() + else: + mock_get_org_object.assert_awaited_once() + assert mock_get_org_object.await_args.kwargs["org_id"] == expected_org_id + finally: + for k, v in originals.items(): + setattr(_proxy_server_mod, k, v) + + @pytest.mark.asyncio async def test_cli_session_token_org_backfilled_from_team(monkeypatch): """LIT-4688 root cause: CLI session tokens (from /sso/cli/poll) are minted From e254377049ca6f7087693cff4b99b291c9ba6010 Mon Sep 17 00:00:00 2001 From: David Steele Date: Thu, 17 Sep 2026 07:07:57 +0100 Subject: [PATCH 02/73] fix(azure): drop tool_choice without tools DEVX-829 --- litellm/llms/azure/chat/gpt_transformation.py | 9 +- .../test_azure_chat_gpt_transformation.py | 123 ++++++++++++++++++ ...test_azure_chat_o_series_transformation.py | 3 +- 3 files changed, 133 insertions(+), 2 deletions(-) diff --git a/litellm/llms/azure/chat/gpt_transformation.py b/litellm/llms/azure/chat/gpt_transformation.py index 6d17a1359bc..0debbe4f74d 100644 --- a/litellm/llms/azure/chat/gpt_transformation.py +++ b/litellm/llms/azure/chat/gpt_transformation.py @@ -280,10 +280,17 @@ class AzureOpenAIConfig(BaseConfig): ordered_messages: Final = system_messages_first(messages) if litellm.openai_system_messages_first else messages stripped_messages: Final = drop_tool_reference_parts_from_tool_messages(ordered_messages) azure_messages: Final = convert_to_azure_openai_messages(hoist_images_from_tool_messages(stripped_messages)) + request_params: Final = { + key: value + for key, value in optional_params.items() + if key != "tool_choice" + or optional_params.get("tools") + or optional_params.get("functions") + } return { "model": model, "messages": azure_messages, - **optional_params, + **request_params, **sanitized_tools_update(optional_params), } diff --git a/tests/test_litellm/llms/azure/chat/test_azure_chat_gpt_transformation.py b/tests/test_litellm/llms/azure/chat/test_azure_chat_gpt_transformation.py index e8b98c696e1..92a8124d8a9 100644 --- a/tests/test_litellm/llms/azure/chat/test_azure_chat_gpt_transformation.py +++ b/tests/test_litellm/llms/azure/chat/test_azure_chat_gpt_transformation.py @@ -333,3 +333,126 @@ class TestAzureToolSchemaCombinatorFlattening: ) assert "tools" not in request assert request["temperature"] == 0.2 + + +@pytest.mark.parametrize("tool_choice", ["none", "auto"]) +def test_azure_drops_tool_choice_without_tools_or_functions(tool_choice: str) -> None: + optional_params = {"tool_choice": tool_choice, "temperature": 0.2} + request = AzureOpenAIConfig().transform_request( + model="gpt-4o", + messages=[{"role": "user", "content": "hi"}], + optional_params=optional_params, + litellm_params={"custom_llm_provider": "azure"}, + headers={}, + ) + + assert "tool_choice" not in request + assert request["temperature"] == 0.2 + assert optional_params["tool_choice"] == tool_choice + + +def test_azure_tools_empty_drops_tool_choice() -> None: + request = AzureOpenAIConfig().transform_request( + model="gpt-4o", + messages=[{"role": "user", "content": "hi"}], + optional_params={"tools": [], "tool_choice": "auto"}, + litellm_params={"custom_llm_provider": "azure"}, + headers={}, + ) + + assert request["tools"] == [] + assert "tool_choice" not in request + + +def test_azure_functions_empty_drops_tool_choice() -> None: + request = AzureOpenAIConfig().transform_request( + model="gpt-4o", + messages=[{"role": "user", "content": "hi"}], + optional_params={"functions": [], "tool_choice": "none"}, + litellm_params={"custom_llm_provider": "azure"}, + headers={}, + ) + + assert request["functions"] == [] + assert "tool_choice" not in request + + +def test_azure_preserves_tool_choice_with_tools() -> None: + tools = [{"type": "function", "function": {"name": "get_weather", "parameters": {}}}] + request = AzureOpenAIConfig().transform_request( + model="gpt-4o", + messages=[{"role": "user", "content": "hi"}], + optional_params={"tools": tools, "tool_choice": "auto"}, + litellm_params={"custom_llm_provider": "azure"}, + headers={}, + ) + + assert request["tools"] == tools + assert request["tool_choice"] == "auto" + + +def test_azure_preserves_tool_choice_with_legacy_functions() -> None: + functions = [{"name": "get_weather", "parameters": {}}] + request = AzureOpenAIConfig().transform_request( + model="gpt-4o", + messages=[{"role": "user", "content": "hi"}], + optional_params={"functions": functions, "tool_choice": "auto"}, + litellm_params={"custom_llm_provider": "azure"}, + headers={}, + ) + + assert request["functions"] == functions + assert request["tool_choice"] == "auto" + + +def test_azure_preserves_function_call_without_tools() -> None: + request = AzureOpenAIConfig().transform_request( + model="gpt-4o", + messages=[{"role": "user", "content": "hi"}], + optional_params={"function_call": "none", "tool_choice": "auto"}, + litellm_params={"custom_llm_provider": "azure"}, + headers={}, + ) + + assert request["function_call"] == "none" + assert "tool_choice" not in request + + +def test_azure_gpt5_drops_tool_choice_without_tools() -> None: + request = AzureOpenAIGPT5Config().transform_request( + model="gpt5_series/gpt-5.6-sol", + messages=[{"role": "user", "content": "hi"}], + optional_params={"tool_choice": "none"}, + litellm_params={"custom_llm_provider": "azure"}, + headers={}, + ) + + assert request["model"] == "gpt-5.6-sol" + assert "tool_choice" not in request + + +@pytest.mark.asyncio +async def test_azure_async_transform_drops_tool_choice_without_tools() -> None: + request = await AzureOpenAIConfig().async_transform_request( + model="gpt-4o", + messages=[{"role": "user", "content": "hi"}], + optional_params={"tool_choice": "none"}, + litellm_params={"custom_llm_provider": "azure"}, + headers={}, + ) + + assert "tool_choice" not in request + + +@pytest.mark.asyncio +async def test_azure_gpt5_async_transform_drops_tool_choice_without_tools() -> None: + request = await AzureOpenAIGPT5Config().async_transform_request( + model="gpt5_series/gpt-5.6-sol", + messages=[{"role": "user", "content": "hi"}], + optional_params={"tool_choice": "auto"}, + litellm_params={"custom_llm_provider": "azure"}, + headers={}, + ) + + assert request["model"] == "gpt-5.6-sol" + assert "tool_choice" not in request diff --git a/tests/test_litellm/llms/azure/chat/test_azure_chat_o_series_transformation.py b/tests/test_litellm/llms/azure/chat/test_azure_chat_o_series_transformation.py index 9db9ab971a0..57d60df3a11 100644 --- a/tests/test_litellm/llms/azure/chat/test_azure_chat_o_series_transformation.py +++ b/tests/test_litellm/llms/azure/chat/test_azure_chat_o_series_transformation.py @@ -14,7 +14,7 @@ async def test_azure_chat_o_series_transformation(): provider_config = AzureOpenAIO1Config() model = "o_series/web-interface-o1-mini" messages = [{"role": "user", "content": "Hello, how are you?"}] - optional_params = {} + optional_params = {"tool_choice": "none"} litellm_params = {} headers = {} @@ -23,6 +23,7 @@ async def test_azure_chat_o_series_transformation(): ) print(response) assert response["model"] == "web-interface-o1-mini" + assert "tool_choice" not in response def test_azure_o_series_transform_request_flattens_top_level_anyof(): From 48712f733a641f16b0b4fe60c221fd9f1c7076fa Mon Sep 17 00:00:00 2001 From: David Steele Date: Thu, 17 Sep 2026 07:30:06 +0100 Subject: [PATCH 03/73] style(azure): format request parameter filter DEVX-829 Co-Authored-By: Claude Code --- litellm/llms/azure/chat/gpt_transformation.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/litellm/llms/azure/chat/gpt_transformation.py b/litellm/llms/azure/chat/gpt_transformation.py index 0debbe4f74d..7cb50ee5348 100644 --- a/litellm/llms/azure/chat/gpt_transformation.py +++ b/litellm/llms/azure/chat/gpt_transformation.py @@ -283,9 +283,7 @@ class AzureOpenAIConfig(BaseConfig): request_params: Final = { key: value for key, value in optional_params.items() - if key != "tool_choice" - or optional_params.get("tools") - or optional_params.get("functions") + if key != "tool_choice" or optional_params.get("tools") or optional_params.get("functions") } return { "model": model, From bd222bd8d9f6d083b8058c5fef3e998b4f92b3af Mon Sep 17 00:00:00 2001 From: David Steele Date: Thu, 17 Sep 2026 08:18:36 +0100 Subject: [PATCH 04/73] fix(azure): avoid mutable request mapping DEVX-829 Co-Authored-By: Claude Code --- litellm/llms/azure/chat/gpt_transformation.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/litellm/llms/azure/chat/gpt_transformation.py b/litellm/llms/azure/chat/gpt_transformation.py index 7cb50ee5348..424422612db 100644 --- a/litellm/llms/azure/chat/gpt_transformation.py +++ b/litellm/llms/azure/chat/gpt_transformation.py @@ -280,11 +280,13 @@ class AzureOpenAIConfig(BaseConfig): ordered_messages: Final = system_messages_first(messages) if litellm.openai_system_messages_first else messages stripped_messages: Final = drop_tool_reference_parts_from_tool_messages(ordered_messages) azure_messages: Final = convert_to_azure_openai_messages(hoist_images_from_tool_messages(stripped_messages)) - request_params: Final = { - key: value - for key, value in optional_params.items() - if key != "tool_choice" or optional_params.get("tools") or optional_params.get("functions") - } + request_params: Final = MappingProxyType( + { + key: value + for key, value in optional_params.items() + if key != "tool_choice" or optional_params.get("tools") or optional_params.get("functions") + } + ) return { "model": model, "messages": azure_messages, From cfd8c186161068bef2ed5faae002dbfb3b2ab63e Mon Sep 17 00:00:00 2001 From: jesus Date: Thu, 17 Sep 2026 21:24:36 +0000 Subject: [PATCH 05/73] fix(auth): inherit org budget, tpm and rpm limits for JWT and team-linked keys Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/auth/user_api_key_auth.py | 31 ++++++++++++----- .../proxy/auth/test_user_api_key_auth.py | 34 +++++++++++++++---- 2 files changed, 50 insertions(+), 15 deletions(-) diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index 110c524ecdf..df5257908ce 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -2409,11 +2409,17 @@ async def _inherit_org_identity( ) -> None: if user_api_key_auth_obj.org_id is None and team_object is not None and team_object.organization_id is not None: user_api_key_auth_obj.org_id = team_object.organization_id - if ( - user_api_key_auth_obj.org_id is None - or user_api_key_auth_obj.organization_alias is not None - or prisma_client is None - ): + already_populated: Final = any( + value is not None + for value in ( + user_api_key_auth_obj.organization_alias, + user_api_key_auth_obj.organization_max_budget, + user_api_key_auth_obj.organization_tpm_limit, + user_api_key_auth_obj.organization_rpm_limit, + user_api_key_auth_obj.organization_metadata, + ) + ) + if user_api_key_auth_obj.org_id is None or already_populated or prisma_client is None: return try: org_object: Final = await get_org_object( @@ -2422,12 +2428,21 @@ async def _inherit_org_identity( user_api_key_cache=user_api_key_cache, parent_otel_span=parent_otel_span, proxy_logging_obj=proxy_logging_obj, + include_budget_table=True, ) except Exception: - verbose_proxy_logger.debug("org alias lookup failed for org_id=%s", user_api_key_auth_obj.org_id, exc_info=True) + verbose_proxy_logger.debug("org lookup failed for org_id=%s", user_api_key_auth_obj.org_id, exc_info=True) return - if org_object is not None: - user_api_key_auth_obj.organization_alias = org_object.organization_alias + if org_object is None: + return + user_api_key_auth_obj.organization_alias = org_object.organization_alias + user_api_key_auth_obj.organization_metadata = org_object.metadata + budget: Final = org_object.litellm_budget_table + if budget is None: + return + user_api_key_auth_obj.organization_max_budget = budget.max_budget + user_api_key_auth_obj.organization_tpm_limit = budget.tpm_limit + user_api_key_auth_obj.organization_rpm_limit = budget.rpm_limit @tracer.wrap() diff --git a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py index 03efbfa7185..fd9b6f09678 100644 --- a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py +++ b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py @@ -5296,22 +5296,26 @@ async def test_centralized_common_checks_backfills_org_id_from_team(key_org_id, @pytest.mark.asyncio @pytest.mark.parametrize( - "key_org_id,team_id,team_org_id,existing_alias,lookup_mode,expected_org_id,expected_alias", + "key_org_id,team_id,team_org_id,existing_alias,existing_rpm,lookup_mode,expected_org_id,expected_alias,expected_limits", [ - (None, "t1", "org-from-team", None, "success", "org-from-team", "acme-org"), - ("org-jwt", None, None, None, "success", "org-jwt", "acme-org"), - ("org-pinned", None, None, "preset", "success", "org-pinned", "preset"), - ("org-missing", None, None, None, "missing", "org-missing", None), + (None, "t1", "org-from-team", None, None, "success", "org-from-team", "acme-org", (12.5, 700, 7)), + ("org-jwt", None, None, None, None, "success", "org-jwt", "acme-org", (12.5, 700, 7)), + ("org-pinned", None, None, "preset", None, "success", "org-pinned", "preset", (None, None, None)), + ("org-view", None, None, None, 3, "success", "org-view", None, (None, None, 3)), + ("org-missing", None, None, None, None, "missing", "org-missing", None, (None, None, None)), + ("org-nobudget", None, None, None, None, "no_budget", "org-nobudget", "acme-org", (None, None, None)), ], ) -async def test_centralized_common_checks_inherits_org_alias( +async def test_centralized_common_checks_inherits_org_identity( key_org_id, team_id, team_org_id, existing_alias, + existing_rpm, lookup_mode, expected_org_id, expected_alias, + expected_limits, ): import litellm.proxy.proxy_server as _proxy_server_mod from fastapi import Request @@ -5325,6 +5329,7 @@ async def test_centralized_common_checks_inherits_org_alias( team_id=team_id, org_id=key_org_id, organization_alias=existing_alias, + organization_rpm_limit=existing_rpm, ) request = Request(scope={"type": "http"}) request._url = URL(url="/chat/completions") @@ -5336,9 +5341,15 @@ async def test_centralized_common_checks_inherits_org_alias( organization_id=expected_org_id, organization_alias="acme-org", budget_id="budget-id", + metadata={"model_rpm_limit": {"gpt-4o": 2}}, models=[], created_by="test", updated_by="test", + litellm_budget_table=( + None + if lookup_mode == "no_budget" + else LiteLLM_BudgetTable(budget_id="budget-id", max_budget=12.5, tpm_limit=700, rpm_limit=7) + ), ) attrs = _proxy_attrs_for_centralized_checks(user_custom_auth=None) @@ -5380,16 +5391,25 @@ async def test_centralized_common_checks_inherits_org_alias( mock_checks.assert_awaited_once() assert token.org_id == expected_org_id assert token.organization_alias == expected_alias + assert ( + token.organization_max_budget, + token.organization_tpm_limit, + token.organization_rpm_limit, + ) == expected_limits assert identity_seen_by_common_checks == [(expected_org_id, expected_alias)] if team_id is None: mock_get_team_object.assert_not_awaited() else: mock_get_team_object.assert_awaited_once() - if existing_alias is not None: + if existing_alias is not None or existing_rpm is not None: mock_get_org_object.assert_not_awaited() + assert token.organization_metadata is None else: mock_get_org_object.assert_awaited_once() assert mock_get_org_object.await_args.kwargs["org_id"] == expected_org_id + assert mock_get_org_object.await_args.kwargs["include_budget_table"] is True + if lookup_mode != "missing": + assert token.organization_metadata == {"model_rpm_limit": {"gpt-4o": 2}} finally: for k, v in originals.items(): setattr(_proxy_server_mod, k, v) From cc875a6eb38a2737a172da9a97ecf9f960c0750f Mon Sep 17 00:00:00 2001 From: jesus Date: Thu, 17 Sep 2026 21:51:06 +0000 Subject: [PATCH 06/73] fix(auth): exempt org lookup fallback from strict lint Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/auth/user_api_key_auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index ac1cc3cfb35..17498467485 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -1309,7 +1309,7 @@ def _ensure_litellm_received_at_on_request_state(request: Request) -> datetime: received_at: Final = datetime.now(timezone.utc) try: request.state.litellm_received_at = received_at - except Exception: + except Exception: # noqa: BLE001 # organization lookup must not fail authentication pass return received_at From 87c00bf47b7ef0c0dcc8aba29bb7b4e2c68ad94e Mon Sep 17 00:00:00 2001 From: jesus Date: Thu, 17 Sep 2026 21:51:18 +0000 Subject: [PATCH 07/73] fix(auth): place strict lint exemption on org lookup Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/auth/user_api_key_auth.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index 17498467485..711fa50f93d 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -1309,7 +1309,7 @@ def _ensure_litellm_received_at_on_request_state(request: Request) -> datetime: received_at: Final = datetime.now(timezone.utc) try: request.state.litellm_received_at = received_at - except Exception: # noqa: BLE001 # organization lookup must not fail authentication + except Exception: pass return received_at @@ -2634,7 +2634,7 @@ async def _inherit_org_identity( proxy_logging_obj=proxy_logging_obj, include_budget_table=True, ) - except Exception: + except Exception: # noqa: BLE001 # organization lookup must not fail authentication verbose_proxy_logger.debug("org lookup failed for org_id=%s", user_api_key_auth_obj.org_id, exc_info=True) return if org_object is None: From 4a13ebbc5b3c62cf50f184d2e26f017caa86c35d Mon Sep 17 00:00:00 2001 From: jesus Date: Thu, 17 Sep 2026 22:01:50 +0000 Subject: [PATCH 08/73] fix(auth): fail closed on org lookup errors when DB is required Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/auth/user_api_key_auth.py | 4 +- .../proxy/auth/test_user_api_key_auth.py | 85 ++++++++++++------- 2 files changed, 56 insertions(+), 33 deletions(-) diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index 711fa50f93d..89d543f9ccb 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -2635,7 +2635,9 @@ async def _inherit_org_identity( include_budget_table=True, ) except Exception: # noqa: BLE001 # organization lookup must not fail authentication - verbose_proxy_logger.debug("org lookup failed for org_id=%s", user_api_key_auth_obj.org_id, exc_info=True) + if not PrismaDBExceptionHandler.should_allow_request_on_db_unavailable(): + raise + verbose_proxy_logger.debug("org lookup failed, continuing without org limits", exc_info=True) return if org_object is None: return diff --git a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py index b5200de115e..377e2d9342d 100644 --- a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py +++ b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py @@ -35,7 +35,6 @@ from litellm.proxy._types import ( ) from litellm.proxy.auth.handle_jwt import JWTHandler from litellm.proxy.auth.auth_checks import ( - OrganizationNotFoundError, TeamNotFoundError, UserNotFoundError, get_key_object, @@ -5809,27 +5808,31 @@ async def test_centralized_common_checks_backfills_org_id_from_team(key_org_id, @pytest.mark.asyncio @pytest.mark.parametrize( - "key_org_id,team_id,team_org_id,existing_alias,existing_rpm,lookup_mode,expected_org_id,expected_alias,expected_limits", + "key_org_id,team_id,team_org_id,existing_alias,existing_rpm,lookup_mode,allow_db_unavailable,expect_lookup_error,expected_org_id,expected_alias,expected_limits", [ - (None, "t1", "org-from-team", None, None, "success", "org-from-team", "acme-org", (12.5, 700, 7)), - ("org-jwt", None, None, None, None, "success", "org-jwt", "acme-org", (12.5, 700, 7)), - ("org-pinned", None, None, "preset", None, "success", "org-pinned", "preset", (None, None, None)), - ("org-view", None, None, None, 3, "success", "org-view", None, (None, None, 3)), - ("org-missing", None, None, None, None, "missing", "org-missing", None, (None, None, None)), - ("org-nobudget", None, None, None, None, "no_budget", "org-nobudget", "acme-org", (None, None, None)), + (None, "t1", "org-from-team", None, None, "success", False, False, "org-from-team", "acme-org", (12.5, 700, 7)), + ("org-jwt", None, None, None, None, "success", False, False, "org-jwt", "acme-org", (12.5, 700, 7)), + ("org-pinned", None, None, "preset", None, "success", False, False, "org-pinned", "preset", (None, None, None)), + ("org-view", None, None, None, 3, "success", False, False, "org-view", None, (None, None, 3)), + ("org-missing", None, None, None, None, "missing", True, False, "org-missing", None, (None, None, None)), + ("org-db-failure-allowed", None, None, None, None, "db_failure", True, False, "org-db-failure-allowed", None, (None, None, None)), + ("org-db-failure-denied", None, None, None, None, "db_failure", False, True, "org-db-failure-denied", None, (None, None, None)), + ("org-nobudget", None, None, None, None, "no_budget", False, False, "org-nobudget", "acme-org", (None, None, None)), ], ) async def test_centralized_common_checks_inherits_org_identity( - key_org_id, - team_id, - team_org_id, - existing_alias, - existing_rpm, - lookup_mode, - expected_org_id, - expected_alias, - expected_limits, -): + key_org_id: str | None, + team_id: str | None, + team_org_id: str | None, + existing_alias: str | None, + existing_rpm: int | None, + lookup_mode: str, + allow_db_unavailable: bool, + expect_lookup_error: bool, + expected_org_id: str | None, + expected_alias: str | None, + expected_limits: tuple[float | None, int | None, int | None], +) -> None: import litellm.proxy.proxy_server as _proxy_server_mod from fastapi import Request from starlette.datastructures import URL @@ -5867,11 +5870,11 @@ async def test_centralized_common_checks_inherits_org_identity( attrs = _proxy_attrs_for_centralized_checks(user_custom_auth=None) attrs["prisma_client"] = MagicMock() + attrs["general_settings"] = {"allow_requests_on_db_unavailable": allow_db_unavailable} originals = {a: getattr(_proxy_server_mod, a, None) for a in attrs} try: for k, v in attrs.items(): setattr(_proxy_server_mod, k, v) - identity_seen_by_common_checks = [] with ( patch( "litellm.proxy.auth.user_api_key_auth.get_team_object", @@ -5886,30 +5889,48 @@ async def test_centralized_common_checks_inherits_org_identity( patch( "litellm.proxy.auth.user_api_key_auth.common_checks", new_callable=AsyncMock, - side_effect=lambda **kw: identity_seen_by_common_checks.append( - (kw["valid_token"].org_id, kw["valid_token"].organization_alias) - ), ) as mock_checks, ): if lookup_mode == "missing": - mock_get_org_object.side_effect = OrganizationNotFoundError("x") + mock_get_org_object.return_value = None + elif lookup_mode == "db_failure": + mock_get_org_object.side_effect = RuntimeError("db unavailable") - await _run_centralized_common_checks( - user_api_key_auth_obj=token, - request=request, - request_data={"model": "gpt-4o"}, - route="/chat/completions", - ) + if expect_lookup_error: + with pytest.raises(RuntimeError, match="db unavailable"): + await _run_centralized_common_checks( + user_api_key_auth_obj=token, + request=request, + request_data={"model": "gpt-4o"}, + route="/chat/completions", + ) + else: + await _run_centralized_common_checks( + user_api_key_auth_obj=token, + request=request, + request_data={"model": "gpt-4o"}, + route="/chat/completions", + ) + + assert token.org_id == expected_org_id + if expect_lookup_error: + mock_checks.assert_not_awaited() + assert token.organization_alias is None + assert token.organization_max_budget is None + assert token.organization_tpm_limit is None + assert token.organization_rpm_limit is None + return mock_checks.assert_awaited_once() - assert token.org_id == expected_org_id assert token.organization_alias == expected_alias assert ( token.organization_max_budget, token.organization_tpm_limit, token.organization_rpm_limit, ) == expected_limits - assert identity_seen_by_common_checks == [(expected_org_id, expected_alias)] + checked_token = mock_checks.await_args.kwargs["valid_token"] + assert checked_token.org_id == expected_org_id + assert checked_token.organization_alias == expected_alias if team_id is None: mock_get_team_object.assert_not_awaited() else: @@ -5921,7 +5942,7 @@ async def test_centralized_common_checks_inherits_org_identity( mock_get_org_object.assert_awaited_once() assert mock_get_org_object.await_args.kwargs["org_id"] == expected_org_id assert mock_get_org_object.await_args.kwargs["include_budget_table"] is True - if lookup_mode != "missing": + if lookup_mode not in {"missing", "db_failure"}: assert token.organization_metadata == {"model_rpm_limit": {"gpt-4o": 2}} finally: for k, v in originals.items(): From 79b6cd29172ae2827259051b0b45b8af12c51a60 Mon Sep 17 00:00:00 2001 From: jesus Date: Thu, 17 Sep 2026 22:03:16 +0000 Subject: [PATCH 09/73] fix(auth): treat a missing org row as no org limits Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/auth/user_api_key_auth.py | 7 ++++--- tests/test_litellm/proxy/auth/test_user_api_key_auth.py | 5 +++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index 89d543f9ccb..10924cf62bd 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -41,6 +41,7 @@ from litellm.litellm_core_utils.dot_notation_indexing import get_nested_value from litellm.proxy._types import * from litellm.proxy.auth.auth_checks import ( ExperimentalUIJWTToken, + OrganizationNotFoundError, TeamNotFoundError, _cache_key_object, _can_object_call_model, @@ -2634,13 +2635,13 @@ async def _inherit_org_identity( proxy_logging_obj=proxy_logging_obj, include_budget_table=True, ) - except Exception: # noqa: BLE001 # organization lookup must not fail authentication + except OrganizationNotFoundError: + return + except Exception: # noqa: BLE001 # DB outage handling is decided by allow_requests_on_db_unavailable if not PrismaDBExceptionHandler.should_allow_request_on_db_unavailable(): raise verbose_proxy_logger.debug("org lookup failed, continuing without org limits", exc_info=True) return - if org_object is None: - return user_api_key_auth_obj.organization_alias = org_object.organization_alias user_api_key_auth_obj.organization_metadata = org_object.metadata budget: Final = org_object.litellm_budget_table diff --git a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py index 377e2d9342d..0bf49523869 100644 --- a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py +++ b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py @@ -35,6 +35,7 @@ from litellm.proxy._types import ( ) from litellm.proxy.auth.handle_jwt import JWTHandler from litellm.proxy.auth.auth_checks import ( + OrganizationNotFoundError, TeamNotFoundError, UserNotFoundError, get_key_object, @@ -5814,7 +5815,7 @@ async def test_centralized_common_checks_backfills_org_id_from_team(key_org_id, ("org-jwt", None, None, None, None, "success", False, False, "org-jwt", "acme-org", (12.5, 700, 7)), ("org-pinned", None, None, "preset", None, "success", False, False, "org-pinned", "preset", (None, None, None)), ("org-view", None, None, None, 3, "success", False, False, "org-view", None, (None, None, 3)), - ("org-missing", None, None, None, None, "missing", True, False, "org-missing", None, (None, None, None)), + ("org-missing", None, None, None, None, "missing", False, False, "org-missing", None, (None, None, None)), ("org-db-failure-allowed", None, None, None, None, "db_failure", True, False, "org-db-failure-allowed", None, (None, None, None)), ("org-db-failure-denied", None, None, None, None, "db_failure", False, True, "org-db-failure-denied", None, (None, None, None)), ("org-nobudget", None, None, None, None, "no_budget", False, False, "org-nobudget", "acme-org", (None, None, None)), @@ -5892,7 +5893,7 @@ async def test_centralized_common_checks_inherits_org_identity( ) as mock_checks, ): if lookup_mode == "missing": - mock_get_org_object.return_value = None + mock_get_org_object.side_effect = OrganizationNotFoundError("x") elif lookup_mode == "db_failure": mock_get_org_object.side_effect = RuntimeError("db unavailable") From ee9294af53f2e681fce2f95a80ae266766f19ce8 Mon Sep 17 00:00:00 2001 From: jesus Date: Thu, 17 Sep 2026 22:08:45 +0000 Subject: [PATCH 10/73] test(auth): annotate centralized auth mocks Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/test_litellm/proxy/auth/test_user_api_key_auth.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py index 0bf49523869..bc2370579d4 100644 --- a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py +++ b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py @@ -5877,17 +5877,17 @@ async def test_centralized_common_checks_inherits_org_identity( for k, v in attrs.items(): setattr(_proxy_server_mod, k, v) with ( - patch( + patch( # test-quality-ok: centralized auth calls this module helper directly; no dependency injection seam exists "litellm.proxy.auth.user_api_key_auth.get_team_object", new_callable=AsyncMock, return_value=fetched_team, ) as mock_get_team_object, - patch( + patch( # test-quality-ok: centralized auth calls this module helper directly; no dependency injection seam exists "litellm.proxy.auth.user_api_key_auth.get_org_object", new_callable=AsyncMock, return_value=organization, ) as mock_get_org_object, - patch( + patch( # test-quality-ok: capture downstream token state without invoking unrelated common checks "litellm.proxy.auth.user_api_key_auth.common_checks", new_callable=AsyncMock, ) as mock_checks, From c4ad6194a0aa2009b12d09fb4f5cd8f671c5a423 Mon Sep 17 00:00:00 2001 From: jesus Date: Thu, 17 Sep 2026 22:28:11 +0000 Subject: [PATCH 11/73] fix(auth): only fail closed on DB outages during org lookup Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/auth/user_api_key_auth.py | 9 +++++++-- tests/test_litellm/proxy/auth/test_user_api_key_auth.py | 9 ++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index 10924cf62bd..41888cb9a64 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -2637,11 +2637,16 @@ async def _inherit_org_identity( ) except OrganizationNotFoundError: return - except Exception: # noqa: BLE001 # DB outage handling is decided by allow_requests_on_db_unavailable - if not PrismaDBExceptionHandler.should_allow_request_on_db_unavailable(): + except Exception as e: # noqa: BLE001 # only a DB outage may fail auth here, anything else degrades to no org limits + if ( + PrismaDBExceptionHandler.is_database_service_unavailable_error_in_chain(e) + and not PrismaDBExceptionHandler.should_allow_request_on_db_unavailable() + ): raise verbose_proxy_logger.debug("org lookup failed, continuing without org limits", exc_info=True) return + if org_object is None: + return user_api_key_auth_obj.organization_alias = org_object.organization_alias user_api_key_auth_obj.organization_metadata = org_object.metadata budget: Final = org_object.litellm_budget_table diff --git a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py index bc2370579d4..ce8310c8aa3 100644 --- a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py +++ b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py @@ -5818,6 +5818,7 @@ async def test_centralized_common_checks_backfills_org_id_from_team(key_org_id, ("org-missing", None, None, None, None, "missing", False, False, "org-missing", None, (None, None, None)), ("org-db-failure-allowed", None, None, None, None, "db_failure", True, False, "org-db-failure-allowed", None, (None, None, None)), ("org-db-failure-denied", None, None, None, None, "db_failure", False, True, "org-db-failure-denied", None, (None, None, None)), + ("org-bad-row", None, None, None, None, "bad_row", False, False, "org-bad-row", None, (None, None, None)), ("org-nobudget", None, None, None, None, "no_budget", False, False, "org-nobudget", "acme-org", (None, None, None)), ], ) @@ -5895,10 +5896,12 @@ async def test_centralized_common_checks_inherits_org_identity( if lookup_mode == "missing": mock_get_org_object.side_effect = OrganizationNotFoundError("x") elif lookup_mode == "db_failure": - mock_get_org_object.side_effect = RuntimeError("db unavailable") + mock_get_org_object.side_effect = ConnectionRefusedError("db unavailable") + elif lookup_mode == "bad_row": + mock_get_org_object.side_effect = ValueError("row failed validation") if expect_lookup_error: - with pytest.raises(RuntimeError, match="db unavailable"): + with pytest.raises(ConnectionRefusedError, match="db unavailable"): await _run_centralized_common_checks( user_api_key_auth_obj=token, request=request, @@ -5943,7 +5946,7 @@ async def test_centralized_common_checks_inherits_org_identity( mock_get_org_object.assert_awaited_once() assert mock_get_org_object.await_args.kwargs["org_id"] == expected_org_id assert mock_get_org_object.await_args.kwargs["include_budget_table"] is True - if lookup_mode not in {"missing", "db_failure"}: + if lookup_mode not in {"missing", "db_failure", "bad_row"}: assert token.organization_metadata == {"model_rpm_limit": {"gpt-4o": 2}} finally: for k, v in originals.items(): From 1b69a5b0a45012408794d1b8aa95043c4f2ae945 Mon Sep 17 00:00:00 2001 From: jesus Date: Thu, 17 Sep 2026 22:34:02 +0000 Subject: [PATCH 12/73] test(proxy): model missing organizations in MCP auth fixtures Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../mcp_server/auth/test_user_api_key_auth_mcp.py | 6 ++++++ .../_experimental/mcp_server/test_discoverable_endpoints.py | 6 +++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py index 90ce821d62e..a0fb76349b2 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py @@ -5739,9 +5739,15 @@ class TestMCPDcrBridgeDelegateAdmission: prisma and are swallowed (``_safe_fetch`` / the SCIM gate's fail-open), so their checks skip. Yields the ``get_key_object`` mock so callers can assert the sealed ``key_hash`` was the reload key.""" + from litellm.proxy.auth.auth_checks import OrganizationNotFoundError + get_key_object = AsyncMock(return_value=return_value, side_effect=side_effect) + get_org_object = AsyncMock(side_effect=OrganizationNotFoundError("Organization doesn't exist in db.")) patchers = [ patch("litellm.proxy.auth.auth_checks.get_key_object", get_key_object), + patch( # test-quality-ok: central auth now resolves org limits; this fixture models a missing org row + "litellm.proxy.auth.user_api_key_auth.get_org_object", get_org_object + ), patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()), ] diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py index aa45b2f6793..200df078e00 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py @@ -11870,9 +11870,13 @@ async def test_oauth_credential_write_keeps_virtual_key_permissions( from litellm.proxy._experimental.mcp_server import mcp_server_manager from litellm.proxy._experimental.mcp_server.bridge_token_flow import authorize_oauth_credential_request from litellm.proxy._types import UserAPIKeyAuth, hash_token - from litellm.proxy.auth.auth_checks import jwt_key_mapping_cache_key + from litellm.proxy.auth.auth_checks import OrganizationNotFoundError, jwt_key_mapping_cache_key handler, signing_key = jwt_oauth_identity + monkeypatch.setattr( + "litellm.proxy.auth.user_api_key_auth.get_org_object", + AsyncMock(side_effect=OrganizationNotFoundError("Organization doesn't exist in db.")), + ) key: Final = "sk-oauth-permission-test" hashed: Final = hash_token(key) credential: Final = UserAPIKeyAuth( From 15b45839e13b84f8bf3dc99ea229c1957955449a Mon Sep 17 00:00:00 2001 From: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:37:40 -0700 Subject: [PATCH 13/73] test(mcp): enforce security regression contracts through live gateway --- tests/integration/contracts.json | 9 + tests/integration/mcp/test_mcp_lifecycle.py | 100 +++ .../observability/test_guardrail_effects.py | 70 ++ tests/mcp_tests/test_mcp_guardrails.py | 770 ------------------ tests/mcp_tests/test_mcp_hooks.py | 475 ----------- 5 files changed, 179 insertions(+), 1245 deletions(-) delete mode 100644 tests/mcp_tests/test_mcp_guardrails.py delete mode 100644 tests/mcp_tests/test_mcp_hooks.py diff --git a/tests/integration/contracts.json b/tests/integration/contracts.json index 91b1bd86954..1520a9488a5 100644 --- a/tests/integration/contracts.json +++ b/tests/integration/contracts.json @@ -213,6 +213,15 @@ ], "tests/integration/sdk/test_http2_wire.py::test_sync_handler_negotiates_http2_only_when_enabled": [ "other.sdk_wire.http2.sync_handler_negotiates_h2_only_when_enabled" + ], + "tests/integration/mcp/test_mcp_lifecycle.py::test_health_intersects_route_restricted_key_grants_in_both_management_modes": [ + "other.mcp.health.restricted_keys_intersect_grants_in_both_modes" + ], + "tests/integration/mcp/test_mcp_lifecycle.py::test_warm_credential_removal_rejects_without_upstream_traffic": [ + "other.mcp.credentials.warm_removal_fails_closed_without_upstream_traffic" + ], + "tests/integration/observability/test_guardrail_effects.py::test_request_selected_mcp_guardrail_blocks_direct_and_virtual_calls": [ + "other.mcp.guardrails.request_selection_blocks_resolved_tool_without_execution" ] }, "browser": { diff --git a/tests/integration/mcp/test_mcp_lifecycle.py b/tests/integration/mcp/test_mcp_lifecycle.py index 7ded23794be..946a3eaae75 100644 --- a/tests/integration/mcp/test_mcp_lifecycle.py +++ b/tests/integration/mcp/test_mcp_lifecycle.py @@ -1,14 +1,17 @@ import uuid from contextlib import ExitStack +from pathlib import Path from typing import Final import pytest +import yaml from hypothesis import strategies as st from hypothesis.stateful import RuleBasedStateMachine, invariant, rule, run_state_machine_as_test from integration._support.client import Gateway from integration._support.database import read_rows from integration._support.generation import LIFECYCLE_SETTINGS, bounded_http_requests +from integration._support.process import owned_proxy from integration._support.mcp import call_tool, mcp_peer, register_mcp, tool_names @@ -121,3 +124,100 @@ def test_generated_mcp_edits_preserve_actual_headers_and_tool_results(gateway: G self.resources.close() run_state_machine_as_test(Servers, settings=LIFECYCLE_SETTINGS) + + +@pytest.mark.covers("other.mcp.health.restricted_keys_intersect_grants_in_both_modes") +def test_health_intersects_route_restricted_key_grants_in_both_management_modes( + gateway: Gateway, tmp_path: Path +) -> None: + for mode in ("restricted", "view_all"): + config = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text()) + config["general_settings"]["user_mcp_management_mode"] = mode + path = tmp_path / f"health-{mode}.yaml" + path.write_text(yaml.safe_dump(config)) + with ( + owned_proxy(gateway, tmp_path, {}, config=path) as candidate, + mcp_peer() as peer, + candidate.scenario() as scenario, + ): + first = register_mcp(scenario, peer, "health" + uuid.uuid4().hex) + second = register_mcp(scenario, peer, "health" + uuid.uuid4().hex) + owned = {first, second} + control = scenario.key(object_permission={"mcp_servers": [first]}) + names = tool_names(candidate, control, first) + healthy = call_tool(candidate, control, first, names["add"], {"a": 3, "b": 5}) + assert healthy.status_code == 200 and healthy.json()["content"][0]["text"] == "8", healthy.text + for grants in ([first], [second], []): + key = scenario.key( + allowed_routes=["/v1/mcp/server", "/v1/mcp/server/health"], + object_permission={"mcp_servers": grants}, + ) + listed = candidate.request("GET", "/v1/mcp/server", key=key) + assert listed.status_code == 200, listed.text + assert {row["server_id"] for row in listed.json()}.intersection(owned) == set(grants) + for requested in (None, [second], [first, second]): + response = candidate.client.get( + "/v1/mcp/server/health", + headers={"Authorization": f"Bearer {key}"}, + params=[] if requested is None else [("server_ids", identity) for identity in requested], + ) + assert response.status_code == 200, response.text + expected = set(grants) if requested is None else set(grants).intersection(requested) + assert {row["server_id"] for row in response.json()}.intersection(owned) == expected, response.text + assert all(row["status"] == "healthy" for row in response.json() if row["server_id"] in owned) + + +@pytest.mark.covers("other.mcp.credentials.warm_removal_fails_closed_without_upstream_traffic") +def test_warm_credential_removal_rejects_without_upstream_traffic(gateway: Gateway) -> None: + with mcp_peer() as peer, gateway.scenario() as scenario: + identity = register_mcp( + scenario, + peer, + "credentials" + uuid.uuid4().hex, + auth_type="bearer_token", + static_headers={"Authorization": "Bearer synthetic-upstream-credential"}, + ) + key = scenario.key(object_permission={"mcp_servers": [identity]}) + names = tool_names(gateway, key, identity) + warm = call_tool(gateway, key, identity, names["add"], {"a": 3, "b": 5}) + assert warm.status_code == 200 and warm.json()["content"][0]["text"] == "8", warm.text + calls = tuple(item for item in peer.drain() if item["body"].get("method") == "tools/call") + assert len(calls) == 1 + assert calls[0]["headers"][b"authorization"] == b"Bearer synthetic-upstream-credential" + removed = gateway.request("PUT", "/v1/mcp/server", {"server_id": identity, "static_headers": {}}) + assert removed.status_code == 202, removed.text + stored = gateway.request("GET", f"/v1/mcp/server/{identity}") + assert stored.status_code == 200, stored.text + assert stored.json()["auth_type"] == "bearer_token" + assert not stored.json().get("static_headers"), stored.text + peer.drain() + for operation in ("list", "call"): + rejected = ( + gateway.client.get( + "/mcp-rest/tools/list", params={"server_id": identity}, headers={"x-litellm-api-key": key} + ) + if operation == "list" + else call_tool(gateway, key, identity, names["add"], {"a": 3, "b": 5}) + ) + assert rejected.status_code == 500, rejected.text + assert peer.drain() == (), "missing static credential escaped to upstream" + changed = gateway.request( + "PUT", + "/v1/mcp/server", + { + "server_id": identity, + "auth_type": "oauth2_token_exchange", + "token_exchange_endpoint": peer.url + "/token", + "credentials": {"client_id": "synthetic-client"}, + }, + ) + assert changed.status_code == 202, changed.text + peer.drain() + rejected_subject = call_tool(gateway, key, identity, names["add"], {"a": 3, "b": 5}) + assert rejected_subject.status_code == 401, rejected_subject.text + assert peer.drain() == (), "virtual key cannot supply an OBO subject token" + control_id = register_mcp(scenario, peer, "control" + uuid.uuid4().hex, auth_type="none") + control_key = scenario.key(object_permission={"mcp_servers": [control_id]}) + control_names = tool_names(gateway, control_key, control_id) + control = call_tool(gateway, control_key, control_id, control_names["multiply"], {"a": 3, "b": 5}) + assert control.status_code == 200 and control.json()["content"][0]["text"] == "15", control.text diff --git a/tests/integration/observability/test_guardrail_effects.py b/tests/integration/observability/test_guardrail_effects.py index 645af77526f..cd44c06cd82 100644 --- a/tests/integration/observability/test_guardrail_effects.py +++ b/tests/integration/observability/test_guardrail_effects.py @@ -8,6 +8,7 @@ import yaml from integration._support.client import Gateway, eventually from integration._support.database import read_rows +from integration._support.mcp import mcp_peer, register_mcp, tool_names from integration._support.process import owned_proxy from integration._support.wire import Reply, Request, wire_server @@ -143,3 +144,72 @@ def test_guardrail_denial_prevents_provider_and_preserves_allowed_control(gatewa ) assert len(observed.get("/__observations").json()["requests"]) == 1 assert len(policy.drain()) == 2 + + +@pytest.mark.covers("other.mcp.guardrails.request_selection_blocks_resolved_tool_without_execution") +def test_request_selected_mcp_guardrail_blocks_direct_and_virtual_calls(gateway: Gateway, tmp_path: Path) -> None: + guardrail = "mcp-policy-" + uuid.uuid4().hex + config = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text()) + config["guardrails"] = [ + { + "guardrail_name": guardrail, + "litellm_params": { + "guardrail": "custom_code", + "mode": "pre_mcp_call", + "default_on": False, + "custom_code": ( + "def apply_guardrail(inputs, request_data, input_type):\n" + ' if inputs.get("tools", [{}])[0].get("function", {}).get("name") == "add":\n' + ' return block("integration resolved add denied")\n' + " return allow()\n" + ), + }, + } + ] + path = tmp_path / "mcp-guardrail.yaml" + path.write_text(yaml.safe_dump(config)) + with ( + owned_proxy(gateway, tmp_path, {}, config=path) as candidate, + mcp_peer() as peer, + candidate.scenario() as scenario, + ): + identity = register_mcp(scenario, peer, "guardrail" + uuid.uuid4().hex) + permission = {"mcp_servers": [identity], "mcp_tool_search_enabled": True} + key = scenario.key(object_permission=permission) + key_selected = scenario.key(object_permission=permission, guardrails=[guardrail]) + team = scenario.team(guardrails=[guardrail]) + team_selected = scenario.key(team_id=team, object_permission=permission) + names = tool_names(candidate, key, identity) + assert set(names) == {"add", "multiply", "fail"} + for virtual in (False, True): + for caller, selected, tool, expected in ( + (key, [], "add", 8), + (key, [guardrail], "add", None), + (key_selected, [], "add", None), + (team_selected, [], "add", None), + (key, [guardrail], "multiply", 15), + ): + arguments = {"a": 3, "b": 5} + peer.drain() + response = candidate.client.post( + "/mcp-rest/tools/call", + headers={"x-litellm-api-key": caller}, + json={ + "server_id": identity, + "name": "mcp_tool_call" if virtual else names[tool], + "arguments": {"tool_name": names[tool], "arguments": arguments} if virtual else arguments, + "guardrails": selected, + }, + ) + calls = tuple(item for item in peer.drain() if item["body"].get("method") == "tools/call") + if expected is None: + assert response.status_code == 400, response.text + assert "integration resolved add denied" in response.text, response.text + assert calls == (), "pre-call denial must prevent upstream execution" + else: + assert response.status_code == 200, response.text + assert response.json()["isError"] is False + assert response.json()["content"][0]["text"] == str(expected), response.text + assert len(calls) == 1 + assert calls[0]["body"]["params"]["name"] == tool + assert calls[0]["body"]["params"]["arguments"] == arguments diff --git a/tests/mcp_tests/test_mcp_guardrails.py b/tests/mcp_tests/test_mcp_guardrails.py deleted file mode 100644 index 04401992449..00000000000 --- a/tests/mcp_tests/test_mcp_guardrails.py +++ /dev/null @@ -1,770 +0,0 @@ -""" -Test file for MCP Guardrails Feature - -This file tests the MCP guardrails functionality for both pre and during MCP call hooks, -including various guardrail types and proper exception handling. -""" - -import asyncio -import pytest -from datetime import datetime -from typing import Optional, Dict, Any -from unittest.mock import MagicMock, AsyncMock, patch - -# Add the project root to the path - -import litellm -from litellm.exceptions import BlockedPiiEntityError, GuardrailRaisedException -from litellm.integrations.custom_guardrail import CustomGuardrail -from litellm.integrations.custom_logger import CustomLogger -from litellm.proxy._types import UserAPIKeyAuth -from litellm.caching.caching import DualCache -from litellm.types.mcp import ( - MCPPreCallRequestObject, - MCPPreCallResponseObject, - MCPDuringCallRequestObject, - MCPDuringCallResponseObject, -) -from litellm.types.llms.base import HiddenParams -from litellm.types.guardrails import GuardrailEventHooks -from fastapi import HTTPException - - -class MockPiiGuardrail(CustomGuardrail): - """Mock PII guardrail that raises BlockedPiiEntityError""" - - def __init__(self, should_block: bool = True, entity_type: str = "EMAIL_ADDRESS"): - super().__init__() - self.should_block = should_block - self.entity_type = entity_type - self.guardrail_name = "mock-pii-guardrail" - self.call_count = 0 - - def should_run_guardrail(self, data: dict, event_type: GuardrailEventHooks) -> bool: - """Always run for testing""" - return True - - async def async_pre_call_hook( - self, - user_api_key_dict: UserAPIKeyAuth, - cache: DualCache, - data: dict, - call_type: str, - ): - """Mock pre-call hook that raises BlockedPiiEntityError""" - self.call_count += 1 - - if self.should_block: - raise BlockedPiiEntityError( - entity_type=self.entity_type, - guardrail_name=self.guardrail_name, - ) - return None - - -class MockContentGuardrail(CustomGuardrail): - """Mock content guardrail that raises GuardrailRaisedException""" - - def __init__(self, should_block: bool = True): - super().__init__() - self.should_block = should_block - self.guardrail_name = "mock-content-guardrail" - self.call_count = 0 - - def should_run_guardrail(self, data: dict, event_type: GuardrailEventHooks) -> bool: - """Always run for testing""" - return True - - async def async_pre_call_hook( - self, - user_api_key_dict: UserAPIKeyAuth, - cache: DualCache, - data: dict, - call_type: str, - ): - """Mock pre-call hook that raises GuardrailRaisedException""" - self.call_count += 1 - - if self.should_block: - raise GuardrailRaisedException( - guardrail_name=self.guardrail_name, message="Content violates policy" - ) - return None - - -class MockHttpGuardrail(CustomGuardrail): - """Mock HTTP guardrail that raises HTTPException""" - - def __init__(self, should_block: bool = True): - super().__init__() - self.should_block = should_block - self.guardrail_name = "mock-http-guardrail" - self.call_count = 0 - - def should_run_guardrail(self, data: dict, event_type: GuardrailEventHooks) -> bool: - """Always run for testing""" - return True - - async def async_pre_call_hook( - self, - user_api_key_dict: UserAPIKeyAuth, - cache: DualCache, - data: dict, - call_type: str, - ): - """Mock pre-call hook that raises HTTPException""" - self.call_count += 1 - - if self.should_block: - raise HTTPException( - status_code=400, detail={"error": "Violated guardrail policy"} - ) - return None - - -class MockDuringCallGuardrail(CustomGuardrail): - """Mock guardrail for during-call testing""" - - def __init__(self, should_block: bool = True): - super().__init__() - self.should_block = should_block - self.guardrail_name = "mock-during-guardrail" - self.call_count = 0 - - def should_run_guardrail(self, data: dict, event_type: GuardrailEventHooks) -> bool: - """Always run for testing""" - return True - - async def async_moderation_hook( - self, - data: dict, - user_api_key_dict: UserAPIKeyAuth, - call_type: str, - ): - """Mock during-call hook that raises exceptions""" - self.call_count += 1 - - if self.should_block: - raise BlockedPiiEntityError( - entity_type="PHONE_NUMBER", - guardrail_name=self.guardrail_name, - ) - return None - - -class MockProxyLogging: - """Mock proxy logging object for testing MCP guardrails""" - - def __init__(self, guardrails: Optional[list] = None): - self.guardrails = guardrails if guardrails is not None else [] - self.call_details = {"user_api_key_cache": DualCache()} - self.dynamic_success_callbacks = [] - self.call_count = 0 - - def get_combined_callback_list(self, dynamic_success_callbacks, global_callbacks): - """Return the guardrails for testing""" - return self.guardrails - - def _convert_mcp_to_llm_format(self, request_obj, kwargs: dict) -> dict: - """Convert MCP tool call to LLM message format""" - tool_call_content = ( - f"Tool: {request_obj.tool_name}\nArguments: {request_obj.arguments}" - ) - - return { - "messages": [{"role": "user", "content": tool_call_content}], - "model": kwargs.get("model", "mcp-tool-call"), - "user_api_key_user_id": kwargs.get("user_api_key_user_id"), - "user_api_key_team_id": kwargs.get("user_api_key_team_id"), - } - - def _convert_llm_result_to_mcp_response(self, llm_result, request_obj): - """Convert LLM result back to MCP response format""" - return None # For testing, we don't need to convert back - - def _parse_pre_mcp_call_hook_response(self, response, original_request): - """Parse pre MCP call hook response""" - return response - - async def async_pre_mcp_tool_call_hook( - self, - kwargs: dict, - request_obj: Any, - start_time: datetime, - end_time: datetime, - ) -> Optional[Any]: - """Mock pre MCP tool call hook""" - self.call_count += 1 - - # Simulate the actual hook logic - for guardrail in self.guardrails: - if isinstance(guardrail, CustomGuardrail): - try: - synthetic_data = self._convert_mcp_to_llm_format( - request_obj, kwargs - ) - - # Check if guardrail should run - if not guardrail.should_run_guardrail( - synthetic_data, GuardrailEventHooks.pre_mcp_call - ): - continue - - result = await guardrail.async_pre_call_hook( - user_api_key_dict=kwargs.get("user_api_key_auth"), - cache=self.call_details["user_api_key_cache"], - data=synthetic_data, - call_type="mcp_call", - ) - if result is not None: - return self._parse_pre_mcp_call_hook_response( - result, request_obj - ) - except ( - BlockedPiiEntityError, - GuardrailRaisedException, - HTTPException, - ) as e: - # Re-raise guardrail exceptions - raise e - except Exception as e: - # Log non-guardrail exceptions as non-blocking - print( - f"LiteLLM.LoggingError: [Non-Blocking] Exception occurred while logging {str(e)}" - ) - - return None - - async def async_during_mcp_tool_call_hook( - self, - kwargs: dict, - request_obj: Any, - start_time: datetime, - end_time: datetime, - ) -> Optional[Any]: - """Mock during MCP tool call hook""" - self.call_count += 1 - - # Simulate the actual hook logic - for guardrail in self.guardrails: - if isinstance(guardrail, CustomGuardrail): - try: - synthetic_data = self._convert_mcp_to_llm_format( - request_obj, kwargs - ) - result = await guardrail.async_moderation_hook( - data=synthetic_data, - user_api_key_dict=kwargs.get("user_api_key_auth"), - call_type="mcp_call", - ) - if result is not None: - return result - except ( - BlockedPiiEntityError, - GuardrailRaisedException, - HTTPException, - ) as e: - # Re-raise guardrail exceptions - raise e - except Exception as e: - # Log non-guardrail exceptions as non-blocking - print( - f"LiteLLM.LoggingError: [Non-Blocking] Exception occurred while logging {str(e)}" - ) - - return None - - -@pytest.fixture -def mock_user_api_key(): - """Mock user API key for testing""" - return UserAPIKeyAuth(api_key="test_key", user_id="test_user") - - -@pytest.fixture -def mock_cache(): - """Mock cache for testing""" - return DualCache() - - -@pytest.fixture -def mock_pii_guardrail(): - """Mock PII guardrail that blocks""" - return MockPiiGuardrail(should_block=True) - - -@pytest.fixture -def mock_pii_guardrail_allow(): - """Mock PII guardrail that allows""" - return MockPiiGuardrail(should_block=False) - - -@pytest.fixture -def mock_content_guardrail(): - """Mock content guardrail that blocks""" - return MockContentGuardrail(should_block=True) - - -@pytest.fixture -def mock_http_guardrail(): - """Mock HTTP guardrail that blocks""" - return MockHttpGuardrail(should_block=True) - - -@pytest.fixture -def mock_during_guardrail(): - """Mock during-call guardrail that blocks""" - return MockDuringCallGuardrail(should_block=True) - - -@pytest.fixture -def mock_proxy_logging(): - """Mock proxy logging object""" - return MockProxyLogging() - - -class TestMCPGuardrailsPreCall: - """Test MCP guardrails for pre-call hooks""" - - @pytest.mark.asyncio - async def test_pii_guardrail_blocks_pre_call( - self, mock_pii_guardrail, mock_user_api_key, mock_cache - ): - """Test that PII guardrail properly blocks pre-call""" - proxy_logging = MockProxyLogging([mock_pii_guardrail]) - - # Create MCP request - request_obj = MCPPreCallRequestObject( - tool_name="email_tool", - arguments={"email": "test@example.com"}, - server_name="email_server", - user_api_key_auth=mock_user_api_key.model_dump(), - hidden_params=HiddenParams(), - ) - - kwargs = { - "name": "email_tool", - "arguments": {"email": "test@example.com"}, - "server_name": "email_server", - "user_api_key_auth": mock_user_api_key, - } - - # Test that BlockedPiiEntityError is raised - with pytest.raises(BlockedPiiEntityError) as excinfo: - await proxy_logging.async_pre_mcp_tool_call_hook( - kwargs=kwargs, - request_obj=request_obj, - start_time=datetime.now(), - end_time=datetime.now(), - ) - - # Verify the error details - assert excinfo.value.entity_type == "EMAIL_ADDRESS" - assert excinfo.value.guardrail_name == "mock-pii-guardrail" - assert mock_pii_guardrail.call_count == 1 - - @pytest.mark.asyncio - async def test_pii_guardrail_allows_pre_call( - self, mock_pii_guardrail_allow, mock_user_api_key, mock_cache - ): - """Test that PII guardrail allows pre-call when configured to allow""" - proxy_logging = MockProxyLogging([mock_pii_guardrail_allow]) - - request_obj = MCPPreCallRequestObject( - tool_name="email_tool", - arguments={"email": "test@example.com"}, - server_name="email_server", - user_api_key_auth=mock_user_api_key.model_dump(), - hidden_params=HiddenParams(), - ) - - kwargs = { - "name": "email_tool", - "arguments": {"email": "test@example.com"}, - "server_name": "email_server", - "user_api_key_auth": mock_user_api_key, - } - - # Test that no exception is raised - result = await proxy_logging.async_pre_mcp_tool_call_hook( - kwargs=kwargs, - request_obj=request_obj, - start_time=datetime.now(), - end_time=datetime.now(), - ) - - assert result is None - assert mock_pii_guardrail_allow.call_count == 1 - - @pytest.mark.asyncio - async def test_content_guardrail_blocks_pre_call( - self, mock_content_guardrail, mock_user_api_key, mock_cache - ): - """Test that content guardrail properly blocks pre-call""" - proxy_logging = MockProxyLogging([mock_content_guardrail]) - - request_obj = MCPPreCallRequestObject( - tool_name="content_tool", - arguments={"content": "sensitive content"}, - server_name="content_server", - user_api_key_auth=mock_user_api_key.model_dump(), - hidden_params=HiddenParams(), - ) - - kwargs = { - "name": "content_tool", - "arguments": {"content": "sensitive content"}, - "server_name": "content_server", - "user_api_key_auth": mock_user_api_key, - } - - # Test that GuardrailRaisedException is raised - with pytest.raises(GuardrailRaisedException) as excinfo: - await proxy_logging.async_pre_mcp_tool_call_hook( - kwargs=kwargs, - request_obj=request_obj, - start_time=datetime.now(), - end_time=datetime.now(), - ) - - # Verify the error details - assert "Content violates policy" in str(excinfo.value) - assert excinfo.value.guardrail_name == "mock-content-guardrail" - assert mock_content_guardrail.call_count == 1 - - @pytest.mark.asyncio - async def test_http_guardrail_blocks_pre_call( - self, mock_http_guardrail, mock_user_api_key, mock_cache - ): - """Test that HTTP guardrail properly blocks pre-call""" - proxy_logging = MockProxyLogging([mock_http_guardrail]) - - request_obj = MCPPreCallRequestObject( - tool_name="http_tool", - arguments={"url": "http://example.com"}, - server_name="http_server", - user_api_key_auth=mock_user_api_key.model_dump(), - hidden_params=HiddenParams(), - ) - - kwargs = { - "name": "http_tool", - "arguments": {"url": "http://example.com"}, - "server_name": "http_server", - "user_api_key_auth": mock_user_api_key, - } - - # Test that HTTPException is raised - with pytest.raises(HTTPException) as excinfo: - await proxy_logging.async_pre_mcp_tool_call_hook( - kwargs=kwargs, - request_obj=request_obj, - start_time=datetime.now(), - end_time=datetime.now(), - ) - - # Verify the error details - assert excinfo.value.status_code == 400 - assert "Violated guardrail policy" in str(excinfo.value.detail) - assert mock_http_guardrail.call_count == 1 - - @pytest.mark.asyncio - async def test_multiple_guardrails_pre_call( - self, mock_pii_guardrail, mock_content_guardrail, mock_user_api_key, mock_cache - ): - """Test multiple guardrails - first one should block""" - proxy_logging = MockProxyLogging([mock_pii_guardrail, mock_content_guardrail]) - - request_obj = MCPPreCallRequestObject( - tool_name="test_tool", - arguments={"email": "test@example.com"}, - server_name="test_server", - user_api_key_auth=mock_user_api_key.model_dump(), - hidden_params=HiddenParams(), - ) - - kwargs = { - "name": "test_tool", - "arguments": {"email": "test@example.com"}, - "server_name": "test_server", - "user_api_key_auth": mock_user_api_key, - } - - # Test that first guardrail blocks - with pytest.raises(BlockedPiiEntityError): - await proxy_logging.async_pre_mcp_tool_call_hook( - kwargs=kwargs, - request_obj=request_obj, - start_time=datetime.now(), - end_time=datetime.now(), - ) - - # Verify only first guardrail was called - assert mock_pii_guardrail.call_count == 1 - assert mock_content_guardrail.call_count == 0 - - -class TestMCPGuardrailsDuringCall: - """Test MCP guardrails for during-call hooks""" - - @pytest.mark.asyncio - async def test_during_call_guardrail_blocks( - self, mock_during_guardrail, mock_user_api_key, mock_cache - ): - """Test that during-call guardrail properly blocks execution""" - proxy_logging = MockProxyLogging([mock_during_guardrail]) - - request_obj = MCPDuringCallRequestObject( - tool_name="phone_tool", - arguments={"phone": "555-123-4567"}, - server_name="phone_server", - start_time=datetime.now().timestamp(), - hidden_params=HiddenParams(), - ) - - kwargs = { - "name": "phone_tool", - "arguments": {"phone": "555-123-4567"}, - "server_name": "phone_server", - } - - # Test that BlockedPiiEntityError is raised - with pytest.raises(BlockedPiiEntityError) as excinfo: - await proxy_logging.async_during_mcp_tool_call_hook( - kwargs=kwargs, - request_obj=request_obj, - start_time=datetime.now(), - end_time=datetime.now(), - ) - - # Verify the error details - assert excinfo.value.entity_type == "PHONE_NUMBER" - assert excinfo.value.guardrail_name == "mock-during-guardrail" - assert mock_during_guardrail.call_count == 1 - - -class TestMCPGuardrailsIntegration: - """Test MCP guardrails integration with MCP server manager""" - - @pytest.mark.asyncio - async def test_mcp_server_manager_with_guardrails(self): - """Test MCP server manager with guardrail integration""" - - mock_proxy_logging = MockProxyLogging([MockPiiGuardrail(should_block=True)]) - - # Test that guardrail exception is properly raised in the hook - with pytest.raises(BlockedPiiEntityError): - await mock_proxy_logging.async_pre_mcp_tool_call_hook( - kwargs={ - "name": "email_tool", - "arguments": {"email": "test@example.com"}, - }, - request_obj=MagicMock(), - start_time=datetime.now(), - end_time=datetime.now(), - ) - - @pytest.mark.asyncio - async def test_guardrail_exception_propagation(self): - """Test that guardrail exceptions properly propagate through the system""" - # Test BlockedPiiEntityError - with pytest.raises(BlockedPiiEntityError): - raise BlockedPiiEntityError( - entity_type="EMAIL_ADDRESS", guardrail_name="test-guardrail" - ) - - # Test GuardrailRaisedException - with pytest.raises(GuardrailRaisedException): - raise GuardrailRaisedException( - guardrail_name="test-guardrail", message="Test message" - ) - - # Test HTTPException - with pytest.raises(HTTPException): - raise HTTPException(status_code=400, detail={"error": "Test error"}) - - -class TestMCPGuardrailsErrorHandling: - """Test MCP guardrails error handling scenarios""" - - @pytest.mark.asyncio - async def test_non_guardrail_exception_logging(self, mock_user_api_key, mock_cache): - """Test that non-guardrail exceptions are logged as non-blocking""" - - class MockFailingGuardrail(CustomGuardrail): - def should_run_guardrail( - self, data: dict, event_type: GuardrailEventHooks - ) -> bool: - return True - - async def async_pre_call_hook( - self, - user_api_key_dict: UserAPIKeyAuth, - cache: DualCache, - data: dict, - call_type: str, - ): - raise Exception("Non-guardrail error") - - proxy_logging = MockProxyLogging([MockFailingGuardrail()]) - - request_obj = MCPPreCallRequestObject( - tool_name="test_tool", - arguments={"test": "data"}, - server_name="test_server", - user_api_key_auth=mock_user_api_key.model_dump(), - hidden_params=HiddenParams(), - ) - - kwargs = { - "name": "test_tool", - "arguments": {"test": "data"}, - "server_name": "test_server", - "user_api_key_auth": mock_user_api_key, - } - - # Test that non-guardrail exceptions are handled gracefully - result = await proxy_logging.async_pre_mcp_tool_call_hook( - kwargs=kwargs, - request_obj=request_obj, - start_time=datetime.now(), - end_time=datetime.now(), - ) - - # Should return None (not raise exception) - assert result is None - - @pytest.mark.asyncio - async def test_guardrail_should_not_run(self, mock_user_api_key, mock_cache): - """Test that guardrails don't run when should_run_guardrail returns False""" - - class MockConditionalGuardrail(CustomGuardrail): - def should_run_guardrail( - self, data: dict, event_type: GuardrailEventHooks - ) -> bool: - return False # Don't run - - async def async_pre_call_hook( - self, - user_api_key_dict: UserAPIKeyAuth, - cache: DualCache, - data: dict, - call_type: str, - ): - raise BlockedPiiEntityError("EMAIL_ADDRESS", "test-guardrail") - - proxy_logging = MockProxyLogging([MockConditionalGuardrail()]) - - request_obj = MCPPreCallRequestObject( - tool_name="test_tool", - arguments={"test": "data"}, - server_name="test_server", - user_api_key_auth=mock_user_api_key.model_dump(), - hidden_params=HiddenParams(), - ) - - kwargs = { - "name": "test_tool", - "arguments": {"test": "data"}, - "server_name": "test_server", - "user_api_key_auth": mock_user_api_key, - } - - # Test that guardrail doesn't run and no exception is raised - result = await proxy_logging.async_pre_mcp_tool_call_hook( - kwargs=kwargs, - request_obj=request_obj, - start_time=datetime.now(), - end_time=datetime.now(), - ) - - # Should return None (guardrail didn't run) - assert result is None - - -class TestMCPGuardrailsEdgeCases: - """Test MCP guardrails edge cases and error conditions""" - - @pytest.mark.asyncio - async def test_empty_guardrails_list(self, mock_user_api_key, mock_cache): - """Test behavior with empty guardrails list""" - proxy_logging = MockProxyLogging([]) # No guardrails - - request_obj = MCPPreCallRequestObject( - tool_name="test_tool", - arguments={"test": "data"}, - server_name="test_server", - user_api_key_auth=mock_user_api_key.model_dump(), - hidden_params=HiddenParams(), - ) - - kwargs = { - "name": "test_tool", - "arguments": {"test": "data"}, - "server_name": "test_server", - "user_api_key_auth": mock_user_api_key, - } - - # Should return None without any issues - result = await proxy_logging.async_pre_mcp_tool_call_hook( - kwargs=kwargs, - request_obj=request_obj, - start_time=datetime.now(), - end_time=datetime.now(), - ) - - assert result is None - - @pytest.mark.asyncio - async def test_guardrail_with_invalid_data(self, mock_user_api_key, mock_cache): - """Test guardrail behavior with invalid data""" - - class MockInvalidDataGuardrail(CustomGuardrail): - def should_run_guardrail( - self, data: dict, event_type: GuardrailEventHooks - ) -> bool: - return True - - async def async_pre_call_hook( - self, - user_api_key_dict: UserAPIKeyAuth, - cache: DualCache, - data: dict, - call_type: str, - ): - # Try to access invalid data - invalid_data = data.get("invalid_key", {}) - if invalid_data.get("should_fail"): - raise BlockedPiiEntityError("EMAIL_ADDRESS", "test-guardrail") - return None - - proxy_logging = MockProxyLogging([MockInvalidDataGuardrail()]) - - request_obj = MCPPreCallRequestObject( - tool_name="test_tool", - arguments={"test": "data"}, - server_name="test_server", - user_api_key_auth=mock_user_api_key.model_dump(), - hidden_params=HiddenParams(), - ) - - kwargs = { - "name": "test_tool", - "arguments": {"test": "data"}, - "server_name": "test_server", - "user_api_key_auth": mock_user_api_key, - } - - # Should handle invalid data gracefully - result = await proxy_logging.async_pre_mcp_tool_call_hook( - kwargs=kwargs, - request_obj=request_obj, - start_time=datetime.now(), - end_time=datetime.now(), - ) - - assert result is None - - -if __name__ == "__main__": - pytest.main([__file__]) diff --git a/tests/mcp_tests/test_mcp_hooks.py b/tests/mcp_tests/test_mcp_hooks.py deleted file mode 100644 index 6dac7da6d07..00000000000 --- a/tests/mcp_tests/test_mcp_hooks.py +++ /dev/null @@ -1,475 +0,0 @@ -""" -Test file for MCP Hook Architecture - -This file demonstrates the new MCP hook system with comprehensive examples -and validation tests. -""" - -import asyncio -import pytest -from datetime import datetime -from typing import Optional - -from litellm.integrations.custom_logger import CustomLogger -from litellm.types.mcp import ( - MCPPreCallRequestObject, - MCPPreCallResponseObject, - MCPDuringCallRequestObject, - MCPDuringCallResponseObject, - MCPPostCallResponseObject, -) -from litellm.types.llms.base import HiddenParams - - -class TestMCPAccessControlHook(CustomLogger): - """Test hook for access control functionality""" - - def __init__(self): - self.allowed_tools = {"github/create_issue", "zapier/send_email"} - self.blocked_users = {"user123", "user456"} - self.call_count = 0 - - async def async_pre_mcp_tool_call_hook( - self, kwargs, request_obj: MCPPreCallRequestObject, start_time, end_time - ) -> Optional[MCPPreCallResponseObject]: - """Test access control validation""" - self.call_count += 1 - - tool_name = request_obj.tool_name - user_id = kwargs.get("user_api_key_auth", {}).get("user_id") - - # Check if user is blocked - if user_id in self.blocked_users: - return MCPPreCallResponseObject( - should_proceed=False, - error_message=f"User {user_id} is not authorized to use MCP tools", - ) - - # Check if tool is allowed - if tool_name not in self.allowed_tools: - return MCPPreCallResponseObject( - should_proceed=False, - error_message=f"Tool {tool_name} is not authorized", - ) - - return None # Allow execution to proceed - - -class TestMCPCostTrackingHook(CustomLogger): - """Test hook for cost tracking functionality""" - - def __init__(self): - self.cost_map = { - "github/create_issue": 0.10, - "zapier/send_email": 0.05, - "default": 0.01, - } - self.call_count = 0 - - async def async_post_mcp_tool_call_hook( - self, kwargs, response_obj: MCPPostCallResponseObject, start_time, end_time - ) -> Optional[MCPPostCallResponseObject]: - """Test cost calculation after tool execution""" - self.call_count += 1 - - tool_name = kwargs.get("name", "") - cost = self.cost_map.get(tool_name, self.cost_map["default"]) - - # Set the response cost - response_obj.hidden_params.response_cost = cost - - return response_obj - - -class TestMCPMonitoringHook(CustomLogger): - """Test hook for real-time monitoring functionality""" - - def __init__(self): - self.max_execution_time = 30.0 # seconds - self.call_count = 0 - - async def async_during_mcp_tool_call_hook( - self, kwargs, request_obj: MCPDuringCallRequestObject, start_time, end_time - ) -> Optional[MCPDuringCallResponseObject]: - """Test execution time monitoring""" - self.call_count += 1 - - tool_name = request_obj.tool_name - execution_time = (datetime.now() - start_time).total_seconds() - - # Check if execution is taking too long - if execution_time > self.max_execution_time: - return MCPDuringCallResponseObject( - should_continue=False, - error_message=f"Tool {tool_name} execution timeout after {execution_time}s", - ) - - return None # Allow execution to continue - - -class TestMCPArgumentValidationHook(CustomLogger): - """Test hook for argument validation functionality""" - - def __init__(self): - self.call_count = 0 - - async def async_pre_mcp_tool_call_hook( - self, kwargs, request_obj: MCPPreCallRequestObject, start_time, end_time - ) -> Optional[MCPPreCallResponseObject]: - """Test argument validation and sanitization""" - self.call_count += 1 - - tool_name = request_obj.tool_name - arguments = request_obj.arguments.copy() # Create a copy to modify - - # Example: Validate GitHub issue creation - if tool_name == "github/create_issue": - if not arguments.get("title"): - return MCPPreCallResponseObject( - should_proceed=False, error_message="GitHub issue title is required" - ) - - # Sanitize the title - title = arguments["title"] - if len(title) > 100: - title = title[:97] + "..." - arguments["title"] = title - - # Example: Validate email sending - elif tool_name == "zapier/send_email": - if not arguments.get("to"): - return MCPPreCallResponseObject( - should_proceed=False, error_message="Email recipient is required" - ) - - return MCPPreCallResponseObject( - should_proceed=True, modified_arguments=arguments - ) - - -# Test fixtures -@pytest.fixture -def access_control_hook(): - return TestMCPAccessControlHook() - - -@pytest.fixture -def cost_tracking_hook(): - return TestMCPCostTrackingHook() - - -@pytest.fixture -def monitoring_hook(): - return TestMCPMonitoringHook() - - -@pytest.fixture -def argument_validation_hook(): - return TestMCPArgumentValidationHook() - - -# Test cases -class TestMCPHooks: - """Test cases for MCP hook functionality""" - - @pytest.mark.asyncio - async def test_access_control_hook_allowed_tool(self, access_control_hook): - """Test that allowed tools pass validation""" - kwargs = { - "user_api_key_auth": {"user_id": "user789"}, - "name": "github/create_issue", - } - request_obj = MCPPreCallRequestObject( - tool_name="github/create_issue", - arguments={"title": "Test issue"}, - user_api_key_auth={"user_id": "user789"}, - ) - - result = await access_control_hook.async_pre_mcp_tool_call_hook( - kwargs=kwargs, - request_obj=request_obj, - start_time=datetime.now(), - end_time=datetime.now(), - ) - - assert result is None # Should allow execution - assert access_control_hook.call_count == 1 - - @pytest.mark.asyncio - async def test_access_control_hook_blocked_user(self, access_control_hook): - """Test that blocked users are rejected""" - kwargs = { - "user_api_key_auth": {"user_id": "user123"}, - "name": "github/create_issue", - } - request_obj = MCPPreCallRequestObject( - tool_name="github/create_issue", - arguments={"title": "Test issue"}, - user_api_key_auth={"user_id": "user123"}, - ) - - result = await access_control_hook.async_pre_mcp_tool_call_hook( - kwargs=kwargs, - request_obj=request_obj, - start_time=datetime.now(), - end_time=datetime.now(), - ) - - assert result is not None - assert result.should_proceed is False - assert "not authorized" in result.error_message - - @pytest.mark.asyncio - async def test_access_control_hook_unauthorized_tool(self, access_control_hook): - """Test that unauthorized tools are rejected""" - kwargs = { - "user_api_key_auth": {"user_id": "user789"}, - "name": "unauthorized_tool", - } - request_obj = MCPPreCallRequestObject( - tool_name="unauthorized_tool", - arguments={"param": "value"}, - user_api_key_auth={"user_id": "user789"}, - ) - - result = await access_control_hook.async_pre_mcp_tool_call_hook( - kwargs=kwargs, - request_obj=request_obj, - start_time=datetime.now(), - end_time=datetime.now(), - ) - - assert result is not None - assert result.should_proceed is False - assert "not authorized" in result.error_message - - @pytest.mark.asyncio - async def test_cost_tracking_hook(self, cost_tracking_hook): - """Test cost tracking functionality""" - kwargs = {"name": "github/create_issue"} - response_obj = MCPPostCallResponseObject( - mcp_tool_call_response=[], hidden_params=HiddenParams() - ) - - result = await cost_tracking_hook.async_post_mcp_tool_call_hook( - kwargs=kwargs, - response_obj=response_obj, - start_time=datetime.now(), - end_time=datetime.now(), - ) - - assert result is not None - assert result.hidden_params.response_cost == 0.10 - assert cost_tracking_hook.call_count == 1 - - @pytest.mark.asyncio - async def test_cost_tracking_hook_default_cost(self, cost_tracking_hook): - """Test default cost assignment""" - kwargs = {"name": "unknown_tool"} - response_obj = MCPPostCallResponseObject( - mcp_tool_call_response=[], hidden_params=HiddenParams() - ) - - result = await cost_tracking_hook.async_post_mcp_tool_call_hook( - kwargs=kwargs, - response_obj=response_obj, - start_time=datetime.now(), - end_time=datetime.now(), - ) - - assert result is not None - assert result.hidden_params.response_cost == 0.01 # Default cost - - @pytest.mark.asyncio - async def test_monitoring_hook_normal_execution(self, monitoring_hook): - """Test monitoring hook with normal execution time""" - kwargs = {"name": "test_tool"} - request_obj = MCPDuringCallRequestObject( - tool_name="test_tool", arguments={}, start_time=datetime.now().timestamp() - ) - - result = await monitoring_hook.async_during_mcp_tool_call_hook( - kwargs=kwargs, - request_obj=request_obj, - start_time=datetime.now(), - end_time=datetime.now(), - ) - - assert result is None # Should allow execution to continue - assert monitoring_hook.call_count == 1 - - @pytest.mark.asyncio - async def test_argument_validation_hook_valid_github_issue( - self, argument_validation_hook - ): - """Test argument validation for valid GitHub issue""" - kwargs = {"name": "github/create_issue"} - request_obj = MCPPreCallRequestObject( - tool_name="github/create_issue", arguments={"title": "Valid issue title"} - ) - - result = await argument_validation_hook.async_pre_mcp_tool_call_hook( - kwargs=kwargs, - request_obj=request_obj, - start_time=datetime.now(), - end_time=datetime.now(), - ) - - assert result is not None - assert result.should_proceed is True - assert result.modified_arguments == {"title": "Valid issue title"} - assert argument_validation_hook.call_count == 1 - - @pytest.mark.asyncio - async def test_argument_validation_hook_missing_title( - self, argument_validation_hook - ): - """Test argument validation for missing GitHub issue title""" - kwargs = {"name": "github/create_issue"} - request_obj = MCPPreCallRequestObject( - tool_name="github/create_issue", arguments={} # Missing title - ) - - result = await argument_validation_hook.async_pre_mcp_tool_call_hook( - kwargs=kwargs, - request_obj=request_obj, - start_time=datetime.now(), - end_time=datetime.now(), - ) - - assert result is not None - assert result.should_proceed is False - assert "title is required" in result.error_message - - @pytest.mark.asyncio - async def test_argument_validation_hook_long_title_sanitization( - self, argument_validation_hook - ): - """Test argument validation with title sanitization""" - kwargs = {"name": "github/create_issue"} - long_title = "A" * 150 # Very long title - request_obj = MCPPreCallRequestObject( - tool_name="github/create_issue", arguments={"title": long_title} - ) - - result = await argument_validation_hook.async_pre_mcp_tool_call_hook( - kwargs=kwargs, - request_obj=request_obj, - start_time=datetime.now(), - end_time=datetime.now(), - ) - - assert result is not None - assert result.should_proceed is True - assert len(result.modified_arguments["title"]) == 100 # Truncated - assert result.modified_arguments["title"].endswith("...") - - @pytest.mark.asyncio - async def test_argument_validation_hook_email_validation( - self, argument_validation_hook - ): - """Test argument validation for email sending""" - kwargs = {"name": "zapier/send_email"} - request_obj = MCPPreCallRequestObject( - tool_name="zapier/send_email", - arguments={"to": "test@example.com", "subject": "Test"}, - ) - - result = await argument_validation_hook.async_pre_mcp_tool_call_hook( - kwargs=kwargs, - request_obj=request_obj, - start_time=datetime.now(), - end_time=datetime.now(), - ) - - assert result is not None - assert result.should_proceed is True - assert result.modified_arguments == { - "to": "test@example.com", - "subject": "Test", - } - - @pytest.mark.asyncio - async def test_argument_validation_hook_missing_email_recipient( - self, argument_validation_hook - ): - """Test argument validation for missing email recipient""" - kwargs = {"name": "zapier/send_email"} - request_obj = MCPPreCallRequestObject( - tool_name="zapier/send_email", - arguments={"subject": "Test"}, # Missing 'to' field - ) - - result = await argument_validation_hook.async_pre_mcp_tool_call_hook( - kwargs=kwargs, - request_obj=request_obj, - start_time=datetime.now(), - end_time=datetime.now(), - ) - - assert result is not None - assert result.should_proceed is False - assert "recipient is required" in result.error_message - - -# Integration test -class TestMCPHookIntegration: - """Integration tests for MCP hook system""" - - @pytest.mark.asyncio - async def test_hook_chain_execution(self): - """Test that multiple hooks can work together""" - access_hook = TestMCPAccessControlHook() - cost_hook = TestMCPCostTrackingHook() - validation_hook = TestMCPArgumentValidationHook() - - # Test data - kwargs = { - "user_api_key_auth": {"user_id": "user789"}, - "name": "github/create_issue", - } - request_obj = MCPPreCallRequestObject( - tool_name="github/create_issue", - arguments={"title": "Integration test issue"}, - user_api_key_auth={"user_id": "user789"}, - ) - - # Execute pre-hooks - access_result = await access_hook.async_pre_mcp_tool_call_hook( - kwargs=kwargs, - request_obj=request_obj, - start_time=datetime.now(), - end_time=datetime.now(), - ) - - validation_result = await validation_hook.async_pre_mcp_tool_call_hook( - kwargs=kwargs, - request_obj=request_obj, - start_time=datetime.now(), - end_time=datetime.now(), - ) - - # Both hooks should allow execution - assert access_result is None - assert validation_result is not None - assert validation_result.should_proceed is True - - # Simulate post-hook execution - response_obj = MCPPostCallResponseObject( - mcp_tool_call_response=[], hidden_params=HiddenParams() - ) - - cost_result = await cost_hook.async_post_mcp_tool_call_hook( - kwargs=kwargs, - response_obj=response_obj, - start_time=datetime.now(), - end_time=datetime.now(), - ) - - assert cost_result is not None - assert cost_result.hidden_params.response_cost == 0.10 - - -if __name__ == "__main__": - # Run the tests - pytest.main([__file__, "-v"]) From 640b0e5fa9678fb4f018db0a6850f5ebeac62d55 Mon Sep 17 00:00:00 2001 From: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:41:57 -0700 Subject: [PATCH 14/73] test(mcp): discover concrete tools through a catalog key --- tests/integration/observability/test_guardrail_effects.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/integration/observability/test_guardrail_effects.py b/tests/integration/observability/test_guardrail_effects.py index cd44c06cd82..25ecee96c4e 100644 --- a/tests/integration/observability/test_guardrail_effects.py +++ b/tests/integration/observability/test_guardrail_effects.py @@ -179,7 +179,8 @@ def test_request_selected_mcp_guardrail_blocks_direct_and_virtual_calls(gateway: key_selected = scenario.key(object_permission=permission, guardrails=[guardrail]) team = scenario.team(guardrails=[guardrail]) team_selected = scenario.key(team_id=team, object_permission=permission) - names = tool_names(candidate, key, identity) + catalog_key = scenario.key(object_permission={"mcp_servers": [identity]}) + names = tool_names(candidate, catalog_key, identity) assert set(names) == {"add", "multiply", "fail"} for virtual in (False, True): for caller, selected, tool, expected in ( From daff22a88413918e6023fe40c93a74ee973e337f Mon Sep 17 00:00:00 2001 From: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:50:01 -0700 Subject: [PATCH 15/73] test(mcp): grant the guardrail control team its server --- tests/integration/observability/test_guardrail_effects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/observability/test_guardrail_effects.py b/tests/integration/observability/test_guardrail_effects.py index 25ecee96c4e..5a79b619906 100644 --- a/tests/integration/observability/test_guardrail_effects.py +++ b/tests/integration/observability/test_guardrail_effects.py @@ -177,7 +177,7 @@ def test_request_selected_mcp_guardrail_blocks_direct_and_virtual_calls(gateway: permission = {"mcp_servers": [identity], "mcp_tool_search_enabled": True} key = scenario.key(object_permission=permission) key_selected = scenario.key(object_permission=permission, guardrails=[guardrail]) - team = scenario.team(guardrails=[guardrail]) + team = scenario.team(guardrails=[guardrail], object_permission={"mcp_servers": [identity]}) team_selected = scenario.key(team_id=team, object_permission=permission) catalog_key = scenario.key(object_permission={"mcp_servers": [identity]}) names = tool_names(candidate, catalog_key, identity) From f83992f78607d3e6c5f4ceb3768953e4d5594f54 Mon Sep 17 00:00:00 2001 From: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:44:20 -0700 Subject: [PATCH 16/73] test(mcp): reject every unauthorized server in health results --- tests/integration/mcp/test_mcp_lifecycle.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/integration/mcp/test_mcp_lifecycle.py b/tests/integration/mcp/test_mcp_lifecycle.py index 946a3eaae75..120fc2a5a2f 100644 --- a/tests/integration/mcp/test_mcp_lifecycle.py +++ b/tests/integration/mcp/test_mcp_lifecycle.py @@ -142,7 +142,6 @@ def test_health_intersects_route_restricted_key_grants_in_both_management_modes( ): first = register_mcp(scenario, peer, "health" + uuid.uuid4().hex) second = register_mcp(scenario, peer, "health" + uuid.uuid4().hex) - owned = {first, second} control = scenario.key(object_permission={"mcp_servers": [first]}) names = tool_names(candidate, control, first) healthy = call_tool(candidate, control, first, names["add"], {"a": 3, "b": 5}) @@ -154,7 +153,7 @@ def test_health_intersects_route_restricted_key_grants_in_both_management_modes( ) listed = candidate.request("GET", "/v1/mcp/server", key=key) assert listed.status_code == 200, listed.text - assert {row["server_id"] for row in listed.json()}.intersection(owned) == set(grants) + assert {row["server_id"] for row in listed.json()} == set(grants), listed.text for requested in (None, [second], [first, second]): response = candidate.client.get( "/v1/mcp/server/health", @@ -163,8 +162,8 @@ def test_health_intersects_route_restricted_key_grants_in_both_management_modes( ) assert response.status_code == 200, response.text expected = set(grants) if requested is None else set(grants).intersection(requested) - assert {row["server_id"] for row in response.json()}.intersection(owned) == expected, response.text - assert all(row["status"] == "healthy" for row in response.json() if row["server_id"] in owned) + assert {row["server_id"] for row in response.json()} == expected, response.text + assert all(row["status"] == "healthy" for row in response.json()) @pytest.mark.covers("other.mcp.credentials.warm_removal_fails_closed_without_upstream_traffic") From 054771cac53b550734d5c6c1cd778c7d69c24801 Mon Sep 17 00:00:00 2001 From: David Steele Date: Fri, 18 Sep 2026 08:41:46 +0100 Subject: [PATCH 17/73] test(azure): remove redundant o-series assertion --- .../llms/azure/chat/test_azure_chat_o_series_transformation.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_litellm/llms/azure/chat/test_azure_chat_o_series_transformation.py b/tests/test_litellm/llms/azure/chat/test_azure_chat_o_series_transformation.py index 57d60df3a11..9db9ab971a0 100644 --- a/tests/test_litellm/llms/azure/chat/test_azure_chat_o_series_transformation.py +++ b/tests/test_litellm/llms/azure/chat/test_azure_chat_o_series_transformation.py @@ -14,7 +14,7 @@ async def test_azure_chat_o_series_transformation(): provider_config = AzureOpenAIO1Config() model = "o_series/web-interface-o1-mini" messages = [{"role": "user", "content": "Hello, how are you?"}] - optional_params = {"tool_choice": "none"} + optional_params = {} litellm_params = {} headers = {} @@ -23,7 +23,6 @@ async def test_azure_chat_o_series_transformation(): ) print(response) assert response["model"] == "web-interface-o1-mini" - assert "tool_choice" not in response def test_azure_o_series_transform_request_flattens_top_level_anyof(): From 8983eefea57ea39d143f22103b7fa259d5518269 Mon Sep 17 00:00:00 2001 From: jesus Date: Fri, 18 Sep 2026 21:30:32 +0000 Subject: [PATCH 18/73] fix(auth): drop redundant cast on team_object in centralized checks Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/auth/user_api_key_auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index ced86318d7b..b4d8648c8a9 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -2901,7 +2901,7 @@ async def _run_centralized_common_checks( await _inherit_org_identity( user_api_key_auth_obj=user_api_key_auth_obj, - team_object=cast(LiteLLM_TeamTableCachedObj | None, team_object), + team_object=team_object, prisma_client=prisma_client, user_api_key_cache=user_api_key_cache, parent_otel_span=parent_otel_span, From 82e3f3980d44f3822fa30ae089d0a034335a402c Mon Sep 17 00:00:00 2001 From: jesus Date: Fri, 18 Sep 2026 23:59:33 +0000 Subject: [PATCH 19/73] refactor(auth): resolve org identity through an auth_checks helper Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/auth/auth_checks.py | 28 ++++++++++++++++++ litellm/proxy/auth/user_api_key_auth.py | 29 +++++-------------- .../auth/test_user_api_key_auth_mcp.py | 2 +- .../mcp_server/test_discoverable_endpoints.py | 2 +- .../proxy/auth/test_user_api_key_auth.py | 2 +- 5 files changed, 39 insertions(+), 24 deletions(-) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index cdada970956..ba37eed037f 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -4012,6 +4012,34 @@ async def get_org_object( return _org_obj +async def get_org_object_for_request( + org_id: str, + prisma_client: PrismaClient, + user_api_key_cache: UserApiKeyCache, + parent_otel_span: Span | None, + proxy_logging_obj: ProxyLogging | None, +) -> LiteLLM_OrganizationTable | None: + try: + return await get_org_object( + org_id=org_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=parent_otel_span, + proxy_logging_obj=proxy_logging_obj, + include_budget_table=True, + ) + except OrganizationNotFoundError: + return None + except Exception as e: # noqa: BLE001 # only a DB outage may fail auth here, anything else degrades to no org limits + if ( + PrismaDBExceptionHandler.is_database_service_unavailable_error_in_chain(e) + and not PrismaDBExceptionHandler.should_allow_request_on_db_unavailable() + ): + raise + verbose_proxy_logger.debug("org lookup failed, continuing without org limits", exc_info=True) + return None + + async def _get_resources_from_access_groups( access_group_ids: Sequence[str], resource_field: Literal["access_model_names", "access_mcp_server_ids", "access_agent_ids"], diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index b4d8648c8a9..7ef1c2775ab 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -41,7 +41,6 @@ from litellm.litellm_core_utils.dot_notation_indexing import get_nested_value from litellm.proxy._types import * from litellm.proxy.auth.auth_checks import ( ExperimentalUIJWTToken, - OrganizationNotFoundError, TeamNotFoundError, _cache_key_object, _can_object_call_model, @@ -59,7 +58,7 @@ from litellm.proxy.auth.auth_checks import ( get_jwt_key_mapping_object, get_key_end_user_budget_id, get_object_permission, - get_org_object, + get_org_object_for_request, get_project_object, get_team_membership, get_team_object, @@ -2630,25 +2629,13 @@ async def _inherit_org_identity( ) if user_api_key_auth_obj.org_id is None or already_populated or prisma_client is None: return - try: - org_object: Final = await get_org_object( - org_id=user_api_key_auth_obj.org_id, - prisma_client=prisma_client, - user_api_key_cache=user_api_key_cache, - parent_otel_span=parent_otel_span, - proxy_logging_obj=proxy_logging_obj, - include_budget_table=True, - ) - except OrganizationNotFoundError: - return - except Exception as e: # noqa: BLE001 # only a DB outage may fail auth here, anything else degrades to no org limits - if ( - PrismaDBExceptionHandler.is_database_service_unavailable_error_in_chain(e) - and not PrismaDBExceptionHandler.should_allow_request_on_db_unavailable() - ): - raise - verbose_proxy_logger.debug("org lookup failed, continuing without org limits", exc_info=True) - return + org_object: Final = await get_org_object_for_request( + org_id=user_api_key_auth_obj.org_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=parent_otel_span, + proxy_logging_obj=proxy_logging_obj, + ) if org_object is None: return user_api_key_auth_obj.organization_alias = org_object.organization_alias diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py index a0fb76349b2..4380df194ed 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py @@ -5746,7 +5746,7 @@ class TestMCPDcrBridgeDelegateAdmission: patchers = [ patch("litellm.proxy.auth.auth_checks.get_key_object", get_key_object), patch( # test-quality-ok: central auth now resolves org limits; this fixture models a missing org row - "litellm.proxy.auth.user_api_key_auth.get_org_object", get_org_object + "litellm.proxy.auth.auth_checks.get_org_object", get_org_object ), patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()), diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py index 200df078e00..3556722ff6e 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py @@ -11874,7 +11874,7 @@ async def test_oauth_credential_write_keeps_virtual_key_permissions( handler, signing_key = jwt_oauth_identity monkeypatch.setattr( - "litellm.proxy.auth.user_api_key_auth.get_org_object", + "litellm.proxy.auth.auth_checks.get_org_object", AsyncMock(side_effect=OrganizationNotFoundError("Organization doesn't exist in db.")), ) key: Final = "sk-oauth-permission-test" diff --git a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py index 761bd454eaf..8593be751fa 100644 --- a/tests/test_litellm/proxy/auth/test_user_api_key_auth.py +++ b/tests/test_litellm/proxy/auth/test_user_api_key_auth.py @@ -6065,7 +6065,7 @@ async def test_centralized_common_checks_inherits_org_identity( return_value=fetched_team, ) as mock_get_team_object, patch( # test-quality-ok: centralized auth calls this module helper directly; no dependency injection seam exists - "litellm.proxy.auth.user_api_key_auth.get_org_object", + "litellm.proxy.auth.auth_checks.get_org_object", new_callable=AsyncMock, return_value=organization, ) as mock_get_org_object, From 6c8f1c22e01d32cd28d57af9b4d1a7bee6229e58 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 19 Sep 2026 00:35:56 +0000 Subject: [PATCH 20/73] test(e2e): cover MCP OAuth happy path through gateway Co-Authored-By: bot_apk --- tests/e2e/CLAUDE.md | 8 +- tests/e2e/conftest.py | 7 + tests/e2e/coverage_registry/mcp.yaml | 8 + tests/e2e/e2e_config.py | 1 + tests/e2e/mcp/oauth_chat_client.py | 146 ++++++++++++++++-- .../e2e/mcp/test_mcp_oauth_happy_path_e2e.py | 121 +++++++++++++++ tests/e2e/models.py | 26 +++- tests/e2e/proxy_client.py | 11 ++ tests/e2e/pytest.ini | 1 + 9 files changed, 306 insertions(+), 23 deletions(-) create mode 100644 tests/e2e/mcp/test_mcp_oauth_happy_path_e2e.py diff --git a/tests/e2e/CLAUDE.md b/tests/e2e/CLAUDE.md index 0541ce25d4b..0cdc0fdb124 100644 --- a/tests/e2e/CLAUDE.md +++ b/tests/e2e/CLAUDE.md @@ -14,7 +14,7 @@ Each subdirectory under `tests/e2e/` is one suite, scoped to an endpoint family - `quota_management/` - quota enforcement and accounting, one subfolder per behavior: `ratelimit/` (rpm/tpm blocks, window reset, pacing headers on live traffic), `budgets/` (budget definition, enforcement, and reset windows: key, team, tag, soft, multi-window), and `spend_tracking/` (spend logging and cost attribution on `/spend/*`) - `management/` - key/team/user/organization management routes: create/update/delete persistence via the info routes, team membership, and llm-only-key route denials (API surface; not Playwright) - `a2a/` - the A2A (agent-to-agent) surface: admin registration via `/v1/agents`, proxy-fronted card discovery at `/.well-known/agent-card.json`, and JSON-RPC `message/send` invocation, driving agents backed by the litellm completion bridge (a real provider) and asserting protocol-version normalization (0.3 vs 1.0) -- `mcp/` - the MCP server surface over api_key auth against the real Datadog remote MCP server (see "MCP suite: real Datadog only" below); plus the gateway-managed OAuth (authorization_code) path exercised through `/chat/completions`, the one behavior Datadog's static-header auth cannot reach, seeding the per-user upstream token via the interactive authorize dance driven with the mcp SDK's own OAuth client (headless-browser consent from a saved session) and asserting the completion lists and executes the server's tools with the stored per-user token +- `mcp/` - the MCP server surface over api_key auth against the real Datadog remote MCP server (see "MCP suite: real Datadog only" below); plus the gateway-managed OAuth (authorization_code) path exercised through `/chat/completions` in `test_mcp_chat_completion_oauth_e2e.py` and direct MCP protocol operations in `test_mcp_oauth_happy_path_e2e.py`, the one behavior Datadog's static-header auth cannot reach, seeding the per-user upstream token via the interactive authorize dance driven with the mcp SDK's own OAuth client (headless-browser consent from a saved session) and asserting the completion or protocol call lists and executes the server's tools with the stored per-user token - `logging/` - logging-integration delivery (datadog and friends) - `security/` - secret handling and log-leak protection - `router/` - routing and reliability behavior (fallbacks, cooldowns) plus the memory regression test (`test_reliability_memory_e2e.py`: a few hundred failing requests with retries and fallbacks must not grow proxy RSS past a fixed budget nor store a request snapshot past a fixed size, the release-gate check for the v1.100.0 retry-breadcrumb leak) @@ -26,14 +26,14 @@ Each subdirectory under `tests/e2e/` is one suite, scoped to an endpoint family ## MCP suite: real Datadog only -Every test under `tests/e2e/mcp/` must exercise the proxy against the real Datadog remote MCP server. Do not add a compose service, FastMCP fixture, mock upstream, or any other fake MCP host for this suite +Every test under `tests/e2e/mcp/` must exercise the proxy against the real Datadog remote MCP server, except the two Linear OAuth tests `test_mcp_chat_completion_oauth_e2e.py` and `test_mcp_oauth_happy_path_e2e.py`. Do not add a compose service, FastMCP fixture, mock upstream, or any other fake MCP host for this suite - Register via `register_datadog_mcp` in `tests/e2e/mcp/datadog_mcp.py` (or extend that helper if you need a different `toolsets=` / `allowed_tools` slice of the same Datadog endpoint). That posts `/v1/mcp/server` with `url=datadog_mcp_url(...)` and static headers `DD-API-KEY` / `DD-APPLICATION-KEY` from the process env - Auth is Datadog's documented CI/header path, not a browser OAuth authorize/token dance. Hard-fail when `DD_API_KEY` or `DD_APP_KEY` is missing (`assert_dd_mcp_creds`); never skip for a missing fake upstream - Prefer calling real Datadog tools that prove the product path (e.g. `search_datadog_logs` for list/call and permission denials). Seed a unique marker (`e2e-datadog-mcp-*`) in a chat completion when you need a log the tool can find; dual-read with `dd_logs` from conftest when delivery matters - Delete the MCP server (and any keys) through `resources.defer` the same way every other suite tears down - If a new MCP behavior cannot be covered with Datadog's tool surface, say so in the PR and get agreement before inventing another upstream; the default is always Datadog -- The one standing exception is `test_mcp_chat_completion_oauth_e2e.py`. Datadog authenticates with the static `DD-API-KEY` / `DD-APPLICATION-KEY` headers and exposes no authorize/token dance at all, so it cannot exercise gateway-managed OAuth or per-user token seeding in any form. That test drives a real Linear MCP server instead; it is still a real remote upstream, so the no-mock, no-fixture rule above holds unchanged +- The two standing exceptions are `test_mcp_chat_completion_oauth_e2e.py` and `test_mcp_oauth_happy_path_e2e.py`. Datadog authenticates with the static `DD-API-KEY` / `DD-APPLICATION-KEY` headers and exposes no authorize/token dance at all, so these tests drive a real Linear MCP server instead; they are still real remote upstreams, so the no-mock, no-fixture rule above holds unchanged ## Lay the pattern down in a class @@ -152,7 +152,7 @@ MCPs - endpoint features with the protocol op as the variant mcp... operation : list_tools | call_tool | list_resources | read_resource | list_prompts | get_prompt auth_family : none | api_key | bearer | oauth - assertion : succeeds | denied_without_permission + assertion : succeeds | denied_without_permission | persists_across_processes e.g. mcp.call_tool.oauth.succeeds ``` diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index e83827fac74..d7d173c93d4 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -28,6 +28,7 @@ from e2e_config import ( FIXTURE_DIR, FIXTURE_MODE_RAW, MANAGED_FILES_OPT_IN_ENV, + MCP_OAUTH_LIVE_OPT_IN_ENV, PROMPT_CACHING_OPT_IN_ENV, PROXY_BASE_URL, REDIS_CHAOS_OPT_IN_ENV, @@ -56,6 +57,7 @@ OPT_IN_MARKERS: Final = MappingProxyType( "prompt_caching_stack": PROMPT_CACHING_OPT_IN_ENV, "redis_chaos": REDIS_CHAOS_OPT_IN_ENV, "cli_determinism": CLI_DETERMINISM_OPT_IN_ENV, + "mcp_oauth_live": MCP_OAUTH_LIVE_OPT_IN_ENV, } ) @@ -132,6 +134,11 @@ def pytest_configure(config: pytest.Config) -> None: "redis_chaos: load test that pauses the proxy's Redis outright mid-run; needs a proxy booted from " "gateway/redis_chaos_ci_config.yml on the same host, and is deselected unless E2E_REDIS_CHAOS is set", ) + config.addinivalue_line( + "markers", + "mcp_oauth_live: real Linear OAuth consent via a captured browser session; deselected unless " + "E2E_MCP_OAUTH_LIVE is set", + ) def pytest_sessionstart(session: pytest.Session) -> None: diff --git a/tests/e2e/coverage_registry/mcp.yaml b/tests/e2e/coverage_registry/mcp.yaml index a7d4135d550..05013389d77 100644 --- a/tests/e2e/coverage_registry/mcp.yaml +++ b/tests/e2e/coverage_registry/mcp.yaml @@ -71,6 +71,14 @@ assertions: [succeeds] source: "db.py user_oauth_credential lookup" rationale: OAuth2 token passthrough; per-user credential storage +- id: mcp.call_tool.oauth.persists_across_processes + module: mcp + tier: P1 + operation: call_tool + auth_family: oauth + assertions: [persists_across_processes] + source: "outbound_credentials/per_user_oauth_store.py V2PerUserTokenStore" + rationale: Stored per-user token is resolved by a gateway process that did not run the consent - id: mcp.list_tools.none.succeeds module: mcp tier: P1 diff --git a/tests/e2e/e2e_config.py b/tests/e2e/e2e_config.py index 11c52d1398c..740540b25bc 100644 --- a/tests/e2e/e2e_config.py +++ b/tests/e2e/e2e_config.py @@ -144,6 +144,7 @@ MANAGED_FILES_OPT_IN_ENV = "E2E_MANAGED_FILES_STACK" PROMPT_CACHING_OPT_IN_ENV = "E2E_PROMPT_CACHING_STACK" REDIS_CHAOS_OPT_IN_ENV = "E2E_REDIS_CHAOS" CLI_DETERMINISM_OPT_IN_ENV = "E2E_CLI_DETERMINISM" +MCP_OAUTH_LIVE_OPT_IN_ENV: Final = "E2E_MCP_OAUTH_LIVE" ANOMALY_SESSIONS = int(os.environ.get("E2E_ANOMALY_SESSIONS", "6")) ANOMALY_TURNS_PER_SESSION = int(os.environ.get("E2E_ANOMALY_TURNS_PER_SESSION", "6")) ANOMALY_TURN_ATTEMPTS = int(os.environ.get("E2E_ANOMALY_TURN_ATTEMPTS", "3")) diff --git a/tests/e2e/mcp/oauth_chat_client.py b/tests/e2e/mcp/oauth_chat_client.py index 2eaf512cfa5..1b437fea76a 100644 --- a/tests/e2e/mcp/oauth_chat_client.py +++ b/tests/e2e/mcp/oauth_chat_client.py @@ -18,20 +18,28 @@ import asyncio import re import time from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Final from urllib.parse import parse_qsl import httpx import pytest +from e2e_config import PROXY_BASE_URL, REQUEST_TIMEOUT +from e2e_http import AuthHeaders, NoBody, unwrap from mcp import ClientSession from mcp.client.auth import OAuthClientProvider from mcp.client.streamable_http import streamable_http_client from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken - -from e2e_config import PROXY_BASE_URL, REQUEST_TIMEOUT +from mcp.types import TextContent +from models import ( + ChatBody, + ChatResponse, + McpOauthUserCredentialStatus, + McpServerCreateBody, + McpServerInfo, + McpServerUserCredentialListResponse, + McpServerUserCredentialRow, +) from proxy_client import ProxyClient -from e2e_http import AuthHeaders, NoBody, unwrap -from models import ChatBody, ChatResponse, McpServerCreateBody, McpServerInfo if TYPE_CHECKING: from playwright.async_api import Route @@ -44,8 +52,8 @@ OAUTH_CLIENT_REDIRECT_URI = "http://127.0.0.1:53682/e2e/callback" BROWSER_CONSENT_TIMEOUT = 60.0 -def _mcp_url(alias: str) -> str: - return f"{PROXY_BASE_URL}/{alias}/mcp" +def _mcp_url(alias: str, base_url: str = PROXY_BASE_URL) -> str: + return f"{base_url.rstrip('/')}/{alias}/mcp" class InMemoryTokenStorage: @@ -88,7 +96,7 @@ async def _browser_follow_authorize(start_url: str, storage_state_path: str) -> if url.startswith(OAUTH_CLIENT_REDIRECT_URI) and "url" not in captured: captured["url"] = url - async def _swallow_redirect(route: "Route") -> None: + async def _swallow_redirect(route: Route) -> None: await route.fulfill(status=200, content_type="text/plain", body="ok") async with async_playwright() as playwright: @@ -128,17 +136,23 @@ async def _browser_follow_authorize(start_url: str, storage_state_path: str) -> return params["code"], params.get("state") -def _oauth_provider(url: str, storage: InMemoryTokenStorage, storage_state_path: str) -> OAuthClientProvider: +def _oauth_provider(url: str, storage: InMemoryTokenStorage, storage_state_path: str | None) -> OAuthClientProvider: """The SDK's real OAuth machinery (RFC 9728/8414 discovery, RFC 7591 DCR, PKCE, token exchange) with the browser leg driven by Playwright against the upstream's consent screen.""" code_holder: dict[str, str | None] = {} # mutable-ok: hand-off between the two SDK callbacks - async def redirect_handler(authorize_url: str) -> None: + async def _reject_redirect(_: str) -> None: + raise AssertionError("gateway demanded a fresh upstream consent; stored per-user token was not reused") + + async def _follow_redirect(authorize_url: str) -> None: + assert storage_state_path is not None code, state = await _browser_follow_authorize(authorize_url, storage_state_path) code_holder["code"] = code code_holder["state"] = state + redirect_handler: Final = _reject_redirect if storage_state_path is None else _follow_redirect + async def callback_handler() -> tuple[str, str | None]: code = code_holder.get("code") assert code is not None, "callback_handler ran before the authorize redirect completed" @@ -167,24 +181,38 @@ class _HeaderInjectingTransport(httpx.AsyncBaseTransport): store the upstream token for from the key on the token exchange, exactly like a production MCP host configured with a LiteLLM key header.""" - def __init__(self, inner: httpx.AsyncBaseTransport, headers: dict[str, str]) -> None: + def __init__(self, inner: httpx.AsyncBaseTransport, headers: dict[str, str], gateway_url: str) -> None: self._inner = inner self._headers = headers + self._gateway_url = httpx.URL(gateway_url) + + @staticmethod + def _port(url: httpx.URL) -> int | None: + if url.port is not None: + return url.port + return {"http": 80, "https": 443}.get(url.scheme) async def handle_async_request(self, request: httpx.Request) -> httpx.Response: - for name, value in self._headers.items(): - if name not in request.headers: - request.headers[name] = value + same_origin: Final = ( + request.url.scheme == self._gateway_url.scheme + and request.url.host == self._gateway_url.host + and self._port(request.url) == self._port(self._gateway_url) + ) + if same_origin: + for name, value in self._headers.items(): + if name not in request.headers: + request.headers[name] = value return await self._inner.handle_async_request(request) -def _oauth_http_client(headers: dict[str, str], auth: OAuthClientProvider) -> httpx.AsyncClient: +def _oauth_http_client( + headers: dict[str, str], auth: OAuthClientProvider, gateway_url: str = PROXY_BASE_URL +) -> httpx.AsyncClient: return httpx.AsyncClient( - headers=headers, auth=auth, timeout=httpx.Timeout(REQUEST_TIMEOUT), follow_redirects=True, - transport=_HeaderInjectingTransport(httpx.AsyncHTTPTransport(), headers), + transport=_HeaderInjectingTransport(httpx.AsyncHTTPTransport(), headers, gateway_url), ) @@ -199,6 +227,38 @@ async def _seed_via_dance( return tuple(sorted(tool.name for tool in listed.tools)) +@dataclass(frozen=True, slots=True) +class OauthToolRun: + tools: tuple[str, ...] + is_error: bool + text: str + + +async def _list_and_call( + url: str, + headers: dict[str, str], + storage: InMemoryTokenStorage, + storage_state_path: str | None, + tool: str, + arguments: dict[str, str], + gateway_url: str = PROXY_BASE_URL, +) -> OauthToolRun: + async with _oauth_http_client( + headers, _oauth_provider(url, storage, storage_state_path), gateway_url + ) as http_client: + async with streamable_http_client(url, http_client=http_client) as (read, write, _): + async with ClientSession(read, write) as session: + await session.initialize() + listed: Final = await session.list_tools() + result: Final = await session.call_tool(tool, arguments) + text: Final = "".join(content.text for content in result.content if isinstance(content, TextContent)) + return OauthToolRun( + tools=tuple(sorted(tool_item.name for tool_item in listed.tools)), + is_error=result.isError, + text=text, + ) + + @dataclass(frozen=True, slots=True) class ChatMcpClient: proxy: ProxyClient @@ -252,6 +312,58 @@ class ChatMcpClient: f"last error: {last_error!r}" ) + def list_and_call( + self, + alias: str, + headers: dict[str, str], + storage: InMemoryTokenStorage, + storage_state_path: str | None, + tool: str, + arguments: dict[str, str], + base_url: str = PROXY_BASE_URL, + ) -> OauthToolRun: + deadline: Final = time.monotonic() + self.proxy.poll_timeout + last_error: Exception | None = None + while time.monotonic() < deadline: + try: + return asyncio.run( + _list_and_call( + _mcp_url(alias, base_url), + headers, + storage, + storage_state_path, + tool, + arguments, + base_url, + ) + ) + except Exception as exc: # noqa: BLE001 - retried to the deadline; the last error surfaces below + last_error = exc + time.sleep(self.proxy.poll_interval) + pytest.fail( + f"list and call for {alias!r} never completed within {self.proxy.poll_timeout}s; last error: {last_error!r}" + ) + + def server_user_credentials(self, server_id: str) -> tuple[McpServerUserCredentialRow, ...]: + return unwrap( + self.proxy.transport.get( + f"/v1/mcp/server/{server_id}/user-credentials", + headers=self.proxy.transport.master, + params=NoBody(), + response_type=McpServerUserCredentialListResponse, + ) + ).root + + def revoke_user_token(self, server_id: str, headers: AuthHeaders) -> None: + _ = unwrap( + self.proxy.transport.delete( + f"/v1/mcp/server/{server_id}/oauth-user-credential", + headers=headers, + json=NoBody(), + response_type=McpOauthUserCredentialStatus, + ) + ) + def chat_with_mcp(self, headers: AuthHeaders, body: ChatBody) -> ChatResponse: """POST /chat/completions carrying the LiteLLM key in `headers` (either ingress form) with an MCP server attached in `body.tools`. The gateway diff --git a/tests/e2e/mcp/test_mcp_oauth_happy_path_e2e.py b/tests/e2e/mcp/test_mcp_oauth_happy_path_e2e.py new file mode 100644 index 00000000000..b35cadd7d55 --- /dev/null +++ b/tests/e2e/mcp/test_mcp_oauth_happy_path_e2e.py @@ -0,0 +1,121 @@ +"""Live e2e coverage for the gateway-managed MCP OAuth protocol path. + +The test creates a JWT-authorized user, completes real Linear authorization +consent, lists and calls a tool immediately through the per-server MCP route, +and verifies the canonical per-user credential row. It then uses a fresh SDK +client against one gateway URL or a configured replica URL. With one gateway +URL, that second run proves fresh-client reuse only. With replica URLs, it +proves that a process which did not run consent resolves the stored token. +""" + +from __future__ import annotations + +import os +from typing import Final + +import pytest +from e2e_config import ( + LINEAR_MCP_URL, + LINEAR_STORAGE_STATE, + PROXY_BASE_URL, + PROXY_REPLICA_URLS, + unique_marker, +) +from e2e_http import AuthHeaders +from lifecycle import ResourceManager +from models import McpServerCreateBody, ObjectPermission, TeamUpdateBody +from proxy_client import ProxyClient + +pytest.importorskip("mcp", reason="mcp SDK not installed; run `uv sync --inexact --group e2e-dev`") +pytest.importorskip( + "playwright.async_api", + reason="playwright not installed; run `uv pip install playwright` and `playwright install chromium`", +) + +from idp import Identity, Keycloak # noqa: E402 +from oauth_chat_client import ChatMcpClient, InMemoryTokenStorage, build_chat_client # noqa: E402 +from test_mcp_chat_completion_oauth_e2e import LINEAR_READONLY_TOOL # noqa: E402 + +pytestmark = [pytest.mark.e2e, pytest.mark.mcp_oauth_live] + + +@pytest.fixture(scope="session") +def chat_client(proxy: ProxyClient) -> ChatMcpClient: + return build_chat_client(proxy) + + +class TestMcpOauthHappyPath: + @pytest.mark.covers("mcp.list_tools.oauth.succeeds") + @pytest.mark.covers("mcp.call_tool.oauth.succeeds") + @pytest.mark.covers("mcp.call_tool.oauth.persists_across_processes") + def test_jwt_user_lists_and_calls_then_reconnects_from_another_gateway( + self, + chat_client: ChatMcpClient, + resources: ResourceManager, + jwt_identity: Identity, + idp: Keycloak, + ) -> None: + assert LINEAR_STORAGE_STATE and os.path.exists(LINEAR_STORAGE_STATE), ( + "E2E_MCP_OAUTH_LIVE is set but E2E_LINEAR_STORAGE_STATE does not point at a captured " + "Linear session (run mcp/linear_session_capture.py)" + ) + + alias: Final = f"e2elinear{unique_marker()}" + created: Final = chat_client.create_server( + McpServerCreateBody( + alias=alias, + url=LINEAR_MCP_URL, + allow_all_keys=False, + auth_type="oauth2", + oauth2_flow="authorization_code", + per_server_oauth_discovery=True, + ) + ) + resources.defer(lambda: chat_client.delete_server(created.server_id)) + + chat_client.proxy.update_team( + TeamUpdateBody( + team_id=jwt_identity.group, + object_permission=ObjectPermission(mcp_servers=[created.server_id]), + ) + ) + + token: Final = idp.access_token(jwt_identity) + headers: Final = {"x-litellm-api-key": f"Bearer {token}"} + storage: Final = InMemoryTokenStorage() + first_run: Final = chat_client.list_and_call( + alias, + headers, + storage, + LINEAR_STORAGE_STATE, + LINEAR_READONLY_TOOL, + {}, + ) + assert f"{alias}-{LINEAR_READONLY_TOOL}" in first_run.tools + assert first_run.is_error is False + assert first_run.text.strip() != "" + + credentials: Final = chat_client.server_user_credentials(created.server_id) + assert len(credentials) == 1 + assert credentials[0].user_id == jwt_identity.user_id + assert credentials[0].credential_type == "oauth2" + resources.defer( + lambda: chat_client.revoke_user_token( + created.server_id, + AuthHeaders.model_validate(headers), + ) + ) + + replica: Final = PROXY_REPLICA_URLS[-1] if len(PROXY_REPLICA_URLS) > 1 else PROXY_BASE_URL + second_run: Final = chat_client.list_and_call( + alias, + {"x-litellm-api-key": f"Bearer {idp.access_token(jwt_identity)}"}, + InMemoryTokenStorage(), + None, + LINEAR_READONLY_TOOL, + {}, + base_url=replica, + ) + assert f"{alias}-{LINEAR_READONLY_TOOL}" in second_run.tools + assert second_run.is_error is False + assert second_run.text.strip() != "" diff --git a/tests/e2e/models.py b/tests/e2e/models.py index 9f49c5974d0..4308984c3be 100644 --- a/tests/e2e/models.py +++ b/tests/e2e/models.py @@ -192,7 +192,7 @@ class ImageUrl(BaseModel): class TextContentPart(BaseModel): type: str = "text" text: str - cache_control: "CacheControl | None" = None + cache_control: CacheControl | None = None class ImageContentPart(BaseModel): @@ -584,6 +584,7 @@ class McpServerCreateBody(BaseModel): allow_all_keys: bool = True auth_type: str | None = None oauth2_flow: Literal["client_credentials", "authorization_code"] | None = None + per_server_oauth_discovery: bool | None = None authorization_url: str | None = None token_url: str | None = None server_name: str | None = None @@ -625,6 +626,26 @@ class McpServerListResponse(RootModel[list[McpServerRow]]): """GET /v1/mcp/server answers with a bare array of servers.""" +class McpServerUserCredentialRow(BaseModel): + user_id: str + credential_type: Literal["oauth2", "byok"] + expires_at: str | None = None + connected_at: str | None = None + updated_at: str + + +class McpServerUserCredentialListResponse(RootModel[tuple[McpServerUserCredentialRow, ...]]): + """GET /v1/mcp/server/{server_id}/user-credentials answers with a bare array.""" + + +class McpOauthUserCredentialStatus(BaseModel): + server_id: str + has_credential: bool + expires_at: str | None = None + is_expired: bool = False + connected_at: str | None = None + + class ToolsetTool(BaseModel): server_id: str tool_name: str @@ -1172,8 +1193,9 @@ class TeamNewResponse(BaseModel): class TeamUpdateBody(BaseModel): team_id: str - team_alias: str + team_alias: str | None = None models: list[str] | None = None + object_permission: ObjectPermission | None = None class TeamInfoParams(BaseModel): diff --git a/tests/e2e/proxy_client.py b/tests/e2e/proxy_client.py index 44d9df5e5c5..c6ede240c3b 100644 --- a/tests/e2e/proxy_client.py +++ b/tests/e2e/proxy_client.py @@ -89,6 +89,7 @@ from models import ( TeamDeleteBody, TeamNewBody, TeamNewResponse, + TeamUpdateBody, ToolsetCreateBody, ToolsetRow, ToolsetUpdateBody, @@ -871,6 +872,16 @@ class ProxyClient: ) ).team_id + def update_team(self, body: TeamUpdateBody) -> None: + unwrap( + self.transport.post( + "/team/update", + headers=self.transport.master, + json=body, + response_type=NoBody, + ) + ) + def delete_team(self, team_id: str) -> None: result = self.transport.post( "/team/delete", diff --git a/tests/e2e/pytest.ini b/tests/e2e/pytest.ini index f9e5995079b..97acb9ec52b 100644 --- a/tests/e2e/pytest.ini +++ b/tests/e2e/pytest.ini @@ -12,3 +12,4 @@ markers = prompt_caching_stack: needs a proxy running with router_settings.optional_pre_call_checks including prompt_caching; deselected unless E2E_PROMPT_CACHING_STACK is set cli_determinism: drives the real claude CLI for several seconds; deselected unless E2E_CLI_DETERMINISM is set redis_chaos: load test that pauses the proxy's Redis outright mid-run; needs a proxy booted from gateway/redis_chaos_ci_config.yml on the same host, and is deselected unless E2E_REDIS_CHAOS is set + mcp_oauth_live: real Linear OAuth consent via a captured browser session; deselected unless E2E_MCP_OAUTH_LIVE is set From c1bd5ba91d7099888a31e4b8d900edb3b5209482 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 19 Sep 2026 00:38:28 +0000 Subject: [PATCH 21/73] test(e2e): share the Linear readonly tool constant and fail fast on unexpected consent Co-Authored-By: bot_apk --- tests/e2e/e2e_config.py | 38 +++++++------------ tests/e2e/mcp/oauth_chat_client.py | 2 + .../mcp/test_mcp_chat_completion_oauth_e2e.py | 13 ++++--- .../e2e/mcp/test_mcp_oauth_happy_path_e2e.py | 2 +- 4 files changed, 24 insertions(+), 31 deletions(-) diff --git a/tests/e2e/e2e_config.py b/tests/e2e/e2e_config.py index 740540b25bc..0c7cb39aef1 100644 --- a/tests/e2e/e2e_config.py +++ b/tests/e2e/e2e_config.py @@ -28,9 +28,7 @@ MASTER_KEY = os.environ.get("LITELLM_MASTER_KEY", "sk-1234") # single path-routing host (stage ALB, compose monolith) works for both planes. # Set LITELLM_CONTROL_PLANE_URL only when management is a different base than # the LLM host and you are not going through an ingress that path-routes. -CONTROL_PLANE_BASE_URL = os.environ.get( - "LITELLM_CONTROL_PLANE_URL", PROXY_BASE_URL -).rstrip("/") +CONTROL_PLANE_BASE_URL = os.environ.get("LITELLM_CONTROL_PLANE_URL", PROXY_BASE_URL).rstrip("/") def parse_replica_urls(raw: str, fallback: str) -> tuple[str, ...]: @@ -52,6 +50,7 @@ CHEAP_OPENAI_MODEL = os.environ.get("E2E_CHEAP_OPENAI_MODEL", "gpt-5.5") LINEAR_MCP_URL = os.environ.get("E2E_LINEAR_MCP_URL", "https://mcp.linear.app/mcp") LINEAR_STORAGE_STATE = os.environ.get("E2E_LINEAR_STORAGE_STATE", "") +LINEAR_READONLY_TOOL: Final = "list_teams" # Jaeger query API of the compose stack's OTEL trace destination (the `jaeger` # service in docker-compose.yml maps it to host 16686). Trace-completeness tests @@ -106,18 +105,13 @@ PROPAGATION_TIMEOUT = float(os.environ.get("E2E_PROPAGATION_TIMEOUT", "15")) # for empty values) means the harness behaves exactly as before this knob # existed. FIXTURE_MODE_RAW = os.environ.get("E2E_FIXTURE_MODE", "live") -FIXTURE_DIR = Path( - os.environ.get("E2E_FIXTURE_DIR", "").strip() - or str(Path(__file__).resolve().parent / ".fixtures") -) +FIXTURE_DIR = Path(os.environ.get("E2E_FIXTURE_DIR", "").strip() or str(Path(__file__).resolve().parent / ".fixtures")) # Where the provider-edge server binds, and the host name edge api_base URLs # advertise to the proxy. They differ when the proxy runs in a container and # reaches the pytest host via a gateway name like host.docker.internal. PROVIDER_EDGE_BIND_HOST = os.environ.get("E2E_PROVIDER_EDGE_BIND_HOST", "").strip() or "127.0.0.1" -PROVIDER_EDGE_ADVERTISE_HOST = ( - os.environ.get("E2E_PROVIDER_EDGE_ADVERTISE_HOST", "").strip() or PROVIDER_EDGE_BIND_HOST -) +PROVIDER_EDGE_ADVERTISE_HOST = os.environ.get("E2E_PROVIDER_EDGE_ADVERTISE_HOST", "").strip() or PROVIDER_EDGE_BIND_HOST # Deliberately modest concurrency. The suite shares its proxy with every other # suite in the run, and 750 users at spawn rate 50 saturated the request path hard @@ -149,18 +143,10 @@ ANOMALY_SESSIONS = int(os.environ.get("E2E_ANOMALY_SESSIONS", "6")) ANOMALY_TURNS_PER_SESSION = int(os.environ.get("E2E_ANOMALY_TURNS_PER_SESSION", "6")) ANOMALY_TURN_ATTEMPTS = int(os.environ.get("E2E_ANOMALY_TURN_ATTEMPTS", "3")) ANOMALY_MAX_ERROR_RATIO = float(os.environ.get("E2E_ANOMALY_MAX_ERROR_RATIO", "0.05")) -ANOMALY_MIN_WARM_CACHE_READ_SHARE = float( - os.environ.get("E2E_ANOMALY_MIN_WARM_CACHE_READ_SHARE", "0.65") -) -ANOMALY_MAX_P95_TURN_SECONDS = float( - os.environ.get("E2E_ANOMALY_MAX_P95_TURN_SECONDS", "30") -) -ANOMALY_MAX_KEY_SPEND_USD = float( - os.environ.get("E2E_ANOMALY_MAX_KEY_SPEND_USD", "0.60") -) -ANOMALY_SPEND_SETTLE_SECONDS = float( - os.environ.get("E2E_ANOMALY_SPEND_SETTLE_SECONDS", "75") -) +ANOMALY_MIN_WARM_CACHE_READ_SHARE = float(os.environ.get("E2E_ANOMALY_MIN_WARM_CACHE_READ_SHARE", "0.65")) +ANOMALY_MAX_P95_TURN_SECONDS = float(os.environ.get("E2E_ANOMALY_MAX_P95_TURN_SECONDS", "30")) +ANOMALY_MAX_KEY_SPEND_USD = float(os.environ.get("E2E_ANOMALY_MAX_KEY_SPEND_USD", "0.60")) +ANOMALY_SPEND_SETTLE_SECONDS = float(os.environ.get("E2E_ANOMALY_SPEND_SETTLE_SECONDS", "75")) MEMORY_REQUESTS_PER_PHASE = int(os.environ.get("E2E_MEMORY_REQUESTS_PER_PHASE", "300")) MEMORY_RETRIES_PER_REQUEST = int(os.environ.get("E2E_MEMORY_RETRIES_PER_REQUEST", "2")) MEMORY_TRANSCRIPT_TURNS = int(os.environ.get("E2E_MEMORY_TRANSCRIPT_TURNS", "40")) @@ -188,8 +174,12 @@ def datadog_mcp_url(*, toolsets: str = "core") -> str: belong to a non-US1 org. """ site = ( - os.environ.get("DD_SITE", DD_SITE) or "datadoghq.com" - ).strip().removeprefix("https://").removeprefix("http://").rstrip("/") + (os.environ.get("DD_SITE", DD_SITE) or "datadoghq.com") + .strip() + .removeprefix("https://") + .removeprefix("http://") + .rstrip("/") + ) site = site.removeprefix("app.") host = "mcp.datadoghq.com" if site in ("", "datadoghq.com") else f"mcp.{site}" base = f"https://{host}/v1/mcp" diff --git a/tests/e2e/mcp/oauth_chat_client.py b/tests/e2e/mcp/oauth_chat_client.py index 1b437fea76a..ebae8029a47 100644 --- a/tests/e2e/mcp/oauth_chat_client.py +++ b/tests/e2e/mcp/oauth_chat_client.py @@ -337,6 +337,8 @@ class ChatMcpClient: base_url, ) ) + except AssertionError: + raise except Exception as exc: # noqa: BLE001 - retried to the deadline; the last error surfaces below last_error = exc time.sleep(self.proxy.poll_interval) diff --git a/tests/e2e/mcp/test_mcp_chat_completion_oauth_e2e.py b/tests/e2e/mcp/test_mcp_chat_completion_oauth_e2e.py index 01e94f7b86f..086ec929a17 100644 --- a/tests/e2e/mcp/test_mcp_chat_completion_oauth_e2e.py +++ b/tests/e2e/mcp/test_mcp_chat_completion_oauth_e2e.py @@ -27,8 +27,13 @@ from __future__ import annotations import os import pytest - -from e2e_config import CHEAP_ANTHROPIC_MODEL, LINEAR_MCP_URL, LINEAR_STORAGE_STATE, unique_marker +from e2e_config import ( + CHEAP_ANTHROPIC_MODEL, + LINEAR_MCP_URL, + LINEAR_READONLY_TOOL, + LINEAR_STORAGE_STATE, + unique_marker, +) from e2e_http import AuthHeaders from lifecycle import ResourceManager from models import ChatBody, ChatMessage, KeyGenerateBody, McpChatTool, McpServerCreateBody, ObjectPermission @@ -50,10 +55,6 @@ pytestmark = [ ), ] -# Pinned from a live dance during verification (never guessed); the gateway -# prefixes every upstream tool name with the server alias. list_teams is a -# read-only Linear tool that takes no arguments and returns the caller's teams. -LINEAR_READONLY_TOOL = "list_teams" LINEAR_PROMPT = "Use the list_teams tool to list my Linear teams, then reply with the name of one of them." diff --git a/tests/e2e/mcp/test_mcp_oauth_happy_path_e2e.py b/tests/e2e/mcp/test_mcp_oauth_happy_path_e2e.py index b35cadd7d55..1b2cc0032bb 100644 --- a/tests/e2e/mcp/test_mcp_oauth_happy_path_e2e.py +++ b/tests/e2e/mcp/test_mcp_oauth_happy_path_e2e.py @@ -16,6 +16,7 @@ from typing import Final import pytest from e2e_config import ( LINEAR_MCP_URL, + LINEAR_READONLY_TOOL, LINEAR_STORAGE_STATE, PROXY_BASE_URL, PROXY_REPLICA_URLS, @@ -34,7 +35,6 @@ pytest.importorskip( from idp import Identity, Keycloak # noqa: E402 from oauth_chat_client import ChatMcpClient, InMemoryTokenStorage, build_chat_client # noqa: E402 -from test_mcp_chat_completion_oauth_e2e import LINEAR_READONLY_TOOL # noqa: E402 pytestmark = [pytest.mark.e2e, pytest.mark.mcp_oauth_live] From 890e5feabe81cde4f5f4a70c8ddd74b17f592fb3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 19 Sep 2026 00:38:57 +0000 Subject: [PATCH 22/73] test(e2e): keep e2e_config formatting untouched Co-Authored-By: bot_apk --- tests/e2e/e2e_config.py | 37 ++++++++++++++++++++++++------------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/tests/e2e/e2e_config.py b/tests/e2e/e2e_config.py index 0c7cb39aef1..a4d79b7f139 100644 --- a/tests/e2e/e2e_config.py +++ b/tests/e2e/e2e_config.py @@ -28,7 +28,9 @@ MASTER_KEY = os.environ.get("LITELLM_MASTER_KEY", "sk-1234") # single path-routing host (stage ALB, compose monolith) works for both planes. # Set LITELLM_CONTROL_PLANE_URL only when management is a different base than # the LLM host and you are not going through an ingress that path-routes. -CONTROL_PLANE_BASE_URL = os.environ.get("LITELLM_CONTROL_PLANE_URL", PROXY_BASE_URL).rstrip("/") +CONTROL_PLANE_BASE_URL = os.environ.get( + "LITELLM_CONTROL_PLANE_URL", PROXY_BASE_URL +).rstrip("/") def parse_replica_urls(raw: str, fallback: str) -> tuple[str, ...]: @@ -105,13 +107,18 @@ PROPAGATION_TIMEOUT = float(os.environ.get("E2E_PROPAGATION_TIMEOUT", "15")) # for empty values) means the harness behaves exactly as before this knob # existed. FIXTURE_MODE_RAW = os.environ.get("E2E_FIXTURE_MODE", "live") -FIXTURE_DIR = Path(os.environ.get("E2E_FIXTURE_DIR", "").strip() or str(Path(__file__).resolve().parent / ".fixtures")) +FIXTURE_DIR = Path( + os.environ.get("E2E_FIXTURE_DIR", "").strip() + or str(Path(__file__).resolve().parent / ".fixtures") +) # Where the provider-edge server binds, and the host name edge api_base URLs # advertise to the proxy. They differ when the proxy runs in a container and # reaches the pytest host via a gateway name like host.docker.internal. PROVIDER_EDGE_BIND_HOST = os.environ.get("E2E_PROVIDER_EDGE_BIND_HOST", "").strip() or "127.0.0.1" -PROVIDER_EDGE_ADVERTISE_HOST = os.environ.get("E2E_PROVIDER_EDGE_ADVERTISE_HOST", "").strip() or PROVIDER_EDGE_BIND_HOST +PROVIDER_EDGE_ADVERTISE_HOST = ( + os.environ.get("E2E_PROVIDER_EDGE_ADVERTISE_HOST", "").strip() or PROVIDER_EDGE_BIND_HOST +) # Deliberately modest concurrency. The suite shares its proxy with every other # suite in the run, and 750 users at spawn rate 50 saturated the request path hard @@ -143,10 +150,18 @@ ANOMALY_SESSIONS = int(os.environ.get("E2E_ANOMALY_SESSIONS", "6")) ANOMALY_TURNS_PER_SESSION = int(os.environ.get("E2E_ANOMALY_TURNS_PER_SESSION", "6")) ANOMALY_TURN_ATTEMPTS = int(os.environ.get("E2E_ANOMALY_TURN_ATTEMPTS", "3")) ANOMALY_MAX_ERROR_RATIO = float(os.environ.get("E2E_ANOMALY_MAX_ERROR_RATIO", "0.05")) -ANOMALY_MIN_WARM_CACHE_READ_SHARE = float(os.environ.get("E2E_ANOMALY_MIN_WARM_CACHE_READ_SHARE", "0.65")) -ANOMALY_MAX_P95_TURN_SECONDS = float(os.environ.get("E2E_ANOMALY_MAX_P95_TURN_SECONDS", "30")) -ANOMALY_MAX_KEY_SPEND_USD = float(os.environ.get("E2E_ANOMALY_MAX_KEY_SPEND_USD", "0.60")) -ANOMALY_SPEND_SETTLE_SECONDS = float(os.environ.get("E2E_ANOMALY_SPEND_SETTLE_SECONDS", "75")) +ANOMALY_MIN_WARM_CACHE_READ_SHARE = float( + os.environ.get("E2E_ANOMALY_MIN_WARM_CACHE_READ_SHARE", "0.65") +) +ANOMALY_MAX_P95_TURN_SECONDS = float( + os.environ.get("E2E_ANOMALY_MAX_P95_TURN_SECONDS", "30") +) +ANOMALY_MAX_KEY_SPEND_USD = float( + os.environ.get("E2E_ANOMALY_MAX_KEY_SPEND_USD", "0.60") +) +ANOMALY_SPEND_SETTLE_SECONDS = float( + os.environ.get("E2E_ANOMALY_SPEND_SETTLE_SECONDS", "75") +) MEMORY_REQUESTS_PER_PHASE = int(os.environ.get("E2E_MEMORY_REQUESTS_PER_PHASE", "300")) MEMORY_RETRIES_PER_REQUEST = int(os.environ.get("E2E_MEMORY_RETRIES_PER_REQUEST", "2")) MEMORY_TRANSCRIPT_TURNS = int(os.environ.get("E2E_MEMORY_TRANSCRIPT_TURNS", "40")) @@ -174,12 +189,8 @@ def datadog_mcp_url(*, toolsets: str = "core") -> str: belong to a non-US1 org. """ site = ( - (os.environ.get("DD_SITE", DD_SITE) or "datadoghq.com") - .strip() - .removeprefix("https://") - .removeprefix("http://") - .rstrip("/") - ) + os.environ.get("DD_SITE", DD_SITE) or "datadoghq.com" + ).strip().removeprefix("https://").removeprefix("http://").rstrip("/") site = site.removeprefix("app.") host = "mcp.datadoghq.com" if site in ("", "datadoghq.com") else f"mcp.{site}" base = f"https://{host}/v1/mcp" From e3755a88e72eed377aea78d19eb0d12a75926f80 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 19 Sep 2026 00:50:00 +0000 Subject: [PATCH 23/73] test(mcp): assert the misconfigured credential message on fail-closed rejections Co-Authored-By: bot_apk --- tests/integration/mcp/test_mcp_lifecycle.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration/mcp/test_mcp_lifecycle.py b/tests/integration/mcp/test_mcp_lifecycle.py index 120fc2a5a2f..7ca5f3a69b7 100644 --- a/tests/integration/mcp/test_mcp_lifecycle.py +++ b/tests/integration/mcp/test_mcp_lifecycle.py @@ -7,12 +7,11 @@ import pytest import yaml from hypothesis import strategies as st from hypothesis.stateful import RuleBasedStateMachine, invariant, rule, run_state_machine_as_test - from integration._support.client import Gateway from integration._support.database import read_rows from integration._support.generation import LIFECYCLE_SETTINGS, bounded_http_requests -from integration._support.process import owned_proxy from integration._support.mcp import call_tool, mcp_peer, register_mcp, tool_names +from integration._support.process import owned_proxy @pytest.mark.covers("mcp.call_tool.saved_headers.reach_actual_transport") @@ -199,6 +198,7 @@ def test_warm_credential_removal_rejects_without_upstream_traffic(gateway: Gatew else call_tool(gateway, key, identity, names["add"], {"a": 3, "b": 5}) ) assert rejected.status_code == 500, rejected.text + assert "requires a usable upstream credential" in rejected.text, rejected.text assert peer.drain() == (), "missing static credential escaped to upstream" changed = gateway.request( "PUT", From 6247b75543c2cc59aa4512487f61e7d4416647ca Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 19 Sep 2026 00:51:51 +0000 Subject: [PATCH 24/73] test(mcp): keep the import block as merged on main Co-Authored-By: bot_apk --- tests/integration/mcp/test_mcp_lifecycle.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/integration/mcp/test_mcp_lifecycle.py b/tests/integration/mcp/test_mcp_lifecycle.py index 7ca5f3a69b7..fa0ae0ec643 100644 --- a/tests/integration/mcp/test_mcp_lifecycle.py +++ b/tests/integration/mcp/test_mcp_lifecycle.py @@ -7,11 +7,12 @@ import pytest import yaml from hypothesis import strategies as st from hypothesis.stateful import RuleBasedStateMachine, invariant, rule, run_state_machine_as_test + from integration._support.client import Gateway from integration._support.database import read_rows from integration._support.generation import LIFECYCLE_SETTINGS, bounded_http_requests -from integration._support.mcp import call_tool, mcp_peer, register_mcp, tool_names from integration._support.process import owned_proxy +from integration._support.mcp import call_tool, mcp_peer, register_mcp, tool_names @pytest.mark.covers("mcp.call_tool.saved_headers.reach_actual_transport") From fc4a11ac530a4ab30057017314ecd5aabfe8105e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 19 Sep 2026 02:03:12 +0000 Subject: [PATCH 25/73] test(e2e): call the prefixed tool, require two gateways, cite the Linear tool name Co-Authored-By: bot_apk --- tests/e2e/e2e_config.py | 2 +- .../e2e/mcp/test_mcp_oauth_happy_path_e2e.py | 25 +++++++++++-------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/tests/e2e/e2e_config.py b/tests/e2e/e2e_config.py index a4d79b7f139..617b1c40820 100644 --- a/tests/e2e/e2e_config.py +++ b/tests/e2e/e2e_config.py @@ -52,7 +52,7 @@ CHEAP_OPENAI_MODEL = os.environ.get("E2E_CHEAP_OPENAI_MODEL", "gpt-5.5") LINEAR_MCP_URL = os.environ.get("E2E_LINEAR_MCP_URL", "https://mcp.linear.app/mcp") LINEAR_STORAGE_STATE = os.environ.get("E2E_LINEAR_STORAGE_STATE", "") -LINEAR_READONLY_TOOL: Final = "list_teams" +LINEAR_READONLY_TOOL: Final = "list_teams" # Linear MCP tool name as listed by tools/list on mcp.linear.app when PR #33787 landed # Jaeger query API of the compose stack's OTEL trace destination (the `jaeger` # service in docker-compose.yml maps it to host 16686). Trace-completeness tests diff --git a/tests/e2e/mcp/test_mcp_oauth_happy_path_e2e.py b/tests/e2e/mcp/test_mcp_oauth_happy_path_e2e.py index 1b2cc0032bb..2755629421a 100644 --- a/tests/e2e/mcp/test_mcp_oauth_happy_path_e2e.py +++ b/tests/e2e/mcp/test_mcp_oauth_happy_path_e2e.py @@ -2,10 +2,10 @@ The test creates a JWT-authorized user, completes real Linear authorization consent, lists and calls a tool immediately through the per-server MCP route, -and verifies the canonical per-user credential row. It then uses a fresh SDK -client against one gateway URL or a configured replica URL. With one gateway -URL, that second run proves fresh-client reuse only. With replica URLs, it -proves that a process which did not run consent resolves the stored token. +and verifies the canonical per-user credential row. The first run targets the +first configured gateway replica, and a fresh SDK client then targets a +different replica to prove that a process which did not run consent resolves +the stored token. """ from __future__ import annotations @@ -18,7 +18,6 @@ from e2e_config import ( LINEAR_MCP_URL, LINEAR_READONLY_TOOL, LINEAR_STORAGE_STATE, - PROXY_BASE_URL, PROXY_REPLICA_URLS, unique_marker, ) @@ -61,6 +60,11 @@ class TestMcpOauthHappyPath: ) alias: Final = f"e2elinear{unique_marker()}" + tool: Final = f"{alias}-{LINEAR_READONLY_TOOL}" + assert len(PROXY_REPLICA_URLS) >= 2, ( + "set LITELLM_PROXY_REPLICA_URLS to at least two gateway URLs; the persistence cell needs a process " + "that did not run the consent" + ) created: Final = chat_client.create_server( McpServerCreateBody( alias=alias, @@ -88,10 +92,11 @@ class TestMcpOauthHappyPath: headers, storage, LINEAR_STORAGE_STATE, - LINEAR_READONLY_TOOL, + tool, {}, + base_url=PROXY_REPLICA_URLS[0], ) - assert f"{alias}-{LINEAR_READONLY_TOOL}" in first_run.tools + assert tool in first_run.tools assert first_run.is_error is False assert first_run.text.strip() != "" @@ -106,16 +111,16 @@ class TestMcpOauthHappyPath: ) ) - replica: Final = PROXY_REPLICA_URLS[-1] if len(PROXY_REPLICA_URLS) > 1 else PROXY_BASE_URL + replica: Final = PROXY_REPLICA_URLS[-1] second_run: Final = chat_client.list_and_call( alias, {"x-litellm-api-key": f"Bearer {idp.access_token(jwt_identity)}"}, InMemoryTokenStorage(), None, - LINEAR_READONLY_TOOL, + tool, {}, base_url=replica, ) - assert f"{alias}-{LINEAR_READONLY_TOOL}" in second_run.tools + assert tool in second_run.tools assert second_run.is_error is False assert second_run.text.strip() != "" From ebf3f04717a2d3b12fbb0e22962b4ff71837e59c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 19 Sep 2026 02:03:43 +0000 Subject: [PATCH 26/73] test(e2e): shorten the Linear tool citation Co-Authored-By: bot_apk --- tests/e2e/e2e_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/e2e_config.py b/tests/e2e/e2e_config.py index 617b1c40820..a79c158f9c4 100644 --- a/tests/e2e/e2e_config.py +++ b/tests/e2e/e2e_config.py @@ -52,7 +52,7 @@ CHEAP_OPENAI_MODEL = os.environ.get("E2E_CHEAP_OPENAI_MODEL", "gpt-5.5") LINEAR_MCP_URL = os.environ.get("E2E_LINEAR_MCP_URL", "https://mcp.linear.app/mcp") LINEAR_STORAGE_STATE = os.environ.get("E2E_LINEAR_STORAGE_STATE", "") -LINEAR_READONLY_TOOL: Final = "list_teams" # Linear MCP tool name as listed by tools/list on mcp.linear.app when PR #33787 landed +LINEAR_READONLY_TOOL: Final = "list_teams" # as listed by tools/list on mcp.linear.app when PR #33787 landed # Jaeger query API of the compose stack's OTEL trace destination (the `jaeger` # service in docker-compose.yml maps it to host 16686). Trace-completeness tests From 7f4dd4eabcce3c53a413e4697e96a8ca03834928 Mon Sep 17 00:00:00 2001 From: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:02:09 -0700 Subject: [PATCH 27/73] test(e2e): cover MCP OAuth SSO and cold restart acceptance --- .github/e2e-stack/assert_tests_ran.py | 7 + .github/e2e-stack/select_tests.py | 1 + .github/workflows/test-mcp-oauth-e2e.yml | 168 +++++++++++++ tests/e2e/AGENTS.md | 2 +- tests/e2e/CONTRIBUTING.md | 46 ++++ tests/e2e/conftest.py | 2 + tests/e2e/coverage_registry/mcp.yaml | 2 +- tests/e2e/idp.py | 4 +- tests/e2e/mcp/oauth_chat_client.py | 98 +++++--- tests/e2e/mcp/oauth_gateway.py | 197 +++++++++++++++ .../e2e/mcp/test_mcp_oauth_happy_path_e2e.py | 226 ++++++++++++------ tests/e2e/models.py | 6 + tests/e2e/provider_edge.py | 12 +- 13 files changed, 664 insertions(+), 107 deletions(-) create mode 100644 .github/workflows/test-mcp-oauth-e2e.yml create mode 100644 tests/e2e/mcp/oauth_gateway.py diff --git a/.github/e2e-stack/assert_tests_ran.py b/.github/e2e-stack/assert_tests_ran.py index 2303c42f4fb..bc299b14af8 100644 --- a/.github/e2e-stack/assert_tests_ran.py +++ b/.github/e2e-stack/assert_tests_ran.py @@ -1,3 +1,4 @@ +import os import sys import xml.etree.ElementTree as ET from pathlib import Path @@ -15,6 +16,12 @@ def main() -> int: _ = sys.stdout.write("::error::could not read the test execution report\n") return 1 cases: Final = tuple(report.iter("testcase")) + expected_count: Final = os.environ.get("E2E_REQUIRED_TEST_COUNT") + if expected_count is not None and ( + len(cases) != int(expected_count) or any(case.find("skipped") is not None for case in cases) + ): + _ = sys.stdout.write("::error::required test count was not met or a required case was skipped\n") + return 1 passed: Final = frozenset( case.get("file") for case in cases if all(case.find(tag) is None for tag in ("skipped", "failure", "error")) ) diff --git a/.github/e2e-stack/select_tests.py b/.github/e2e-stack/select_tests.py index 982e93cf642..a9ca1f88660 100644 --- a/.github/e2e-stack/select_tests.py +++ b/.github/e2e-stack/select_tests.py @@ -5,6 +5,7 @@ from typing import Final SELECTABLE: Final = re.compile(r"^tests/e2e/([A-Za-z0-9_.-]+/)*test_[A-Za-z0-9_.-]+\.py$") UNSUPPORTED: Final = re.compile( r"^tests/e2e/(ui|claude_code|load)/" + r"|^tests/e2e/mcp/test_mcp_oauth_happy_path_e2e\.py$" r"|^tests/e2e/llm_translation/realtime/test_realtime_pipecat_audio_e2e\.py$" r"|^tests/e2e/batches/test_managed_files_enforcement_e2e\.py$" r"|^tests/e2e/guardrails/test_presidio_masking_e2e\.py$" diff --git a/.github/workflows/test-mcp-oauth-e2e.yml b/.github/workflows/test-mcp-oauth-e2e.yml new file mode 100644 index 00000000000..5fc9b711fd5 --- /dev/null +++ b/.github/workflows/test-mcp-oauth-e2e.yml @@ -0,0 +1,168 @@ +name: MCP OAuth happy path + +on: + pull_request: + paths: + - tests/e2e/idp.py + - tests/e2e/provider_edge.py + - tests/e2e/models.py + - tests/e2e/conftest.py + - .github/e2e-stack/assert_tests_ran.py + - tests/e2e/mcp/oauth_chat_client.py + - tests/e2e/mcp/oauth_gateway.py + - tests/e2e/mcp/test_mcp_oauth_happy_path_e2e.py + - .github/workflows/test-mcp-oauth-e2e.yml + workflow_dispatch: + +permissions: {} + +concurrency: + group: mcp-oauth-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + oauth: + if: github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + environment: e2e-changed + timeout-minutes: 45 + permissions: + contents: read + id-token: write + services: + postgres: + image: postgres:16.6 + env: + POSTGRES_USER: litellm + POSTGRES_PASSWORD: dbpassword9090 + POSTGRES_DB: litellm + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U litellm" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + DATABASE_HOST: 127.0.0.1 + DATABASE_PORT: '5432' + DATABASE_USER: litellm + DATABASE_PASSWORD: dbpassword9090 + DATABASE_NAME: litellm + DATABASE_URL: postgresql://litellm:dbpassword9090@127.0.0.1:5432/litellm + E2E_KEYCLOAK_URL: http://127.0.0.1:8081 + E2E_KEYCLOAK_ADMIN_USER: admin + E2E_KEYCLOAK_ADMIN_PASSWORD: e2e-ephemeral-idp-not-a-secret + E2E_FIXTURE_MODE: live + E2E_PROVIDER_CACHE: '0' + E2E_MCP_OAUTH_LIVE: '1' + E2E_REQUIRED_TEST_COUNT: '4' + steps: + - name: Checkout the tested source + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + - name: Require and materialize the upstream login + env: + STORAGE_STATE: ${{ secrets.E2E_LINEAR_STORAGE_STATE_B64 }} + run: | + umask 077 + python3 - <<'PY' + import base64 + import json + import os + import secrets + from pathlib import Path + encoded = os.environ.get("STORAGE_STATE", "") + if not encoded: + raise SystemExit("E2E_LINEAR_STORAGE_STATE_B64 is required; capture and provision a test-account login") + state = json.loads(base64.b64decode(encoded, validate=True)) + if not isinstance(state, dict) or not state.get("cookies"): + raise SystemExit("The captured login must contain browser cookies") + directory = Path(os.environ["RUNNER_TEMP"]) / "mcp-oauth-private" + directory.mkdir(mode=0o700) + path = directory / "linear-state.json" + path.write_text(json.dumps(state)) + with open(os.environ["GITHUB_ENV"], "a") as output: + output.write(f"E2E_LINEAR_STORAGE_STATE={path}\n") + for name in ("LITELLM_MASTER_KEY", "LITELLM_SALT_KEY"): + value = "sk-e2e-" + secrets.token_hex(24) + print(f"::add-mask::{value}") + output.write(f"{name}={value}\n") + PY + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.13' + - uses: ./.github/actions/setup-uv-with-retries + with: + version: '0.10.9' + - uses: ./.github/actions/cache-cargo-build + - name: Install the frozen E2E environment + run: | + .github/scripts/uv_sync_with_retries.sh --frozen --extra proxy --extra proxy-runtime --extra extra_proxy --group ci --group proxy-dev --group e2e-dev + uv run --no-sync python scripts/prisma_generate_if_needed.py + uv run --no-sync playwright install --with-deps chromium + + - name: Configure license access + id: aws + uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6.2.0 + with: + role-to-assume: ${{ vars.E2E_AWS_ROLE_TO_ASSUME }} + aws-region: us-east-1 + role-session-name: mcp-oauth-${{ github.run_id }} + role-duration-seconds: 900 + output-env-credentials: false + output-credentials: true + - name: Load the E2E license + env: + AWS_ACCESS_KEY_ID: ${{ steps.aws.outputs.aws-access-key-id }} + AWS_SECRET_ACCESS_KEY: ${{ steps.aws.outputs.aws-secret-access-key }} + AWS_SESSION_TOKEN: ${{ steps.aws.outputs.aws-session-token }} + AWS_DEFAULT_REGION: us-east-1 + run: | + license="$(aws secretsmanager get-secret-value --secret-id litellm-e2e-changed-license --query SecretString --output text)" + test -n "${license}" + echo "::add-mask::${license}" + echo "LITELLM_LICENSE=${license}" >> "${GITHUB_ENV}" + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: ui/litellm-dashboard/.nvmrc + - name: Build the gateway consent UI at the tested commit + run: | + cd ui/litellm-dashboard + ../../scripts/with_dashboard_node.sh npm ci + ../../scripts/with_dashboard_node.sh npm run build + mkdir -p ../../litellm/proxy/_experimental/out + cp -r out/. ../../litellm/proxy/_experimental/out/ + find ../../litellm/proxy/_experimental/out -name '*.html' ! -name index.html | while read -r page; do + mkdir -p "${page%.html}" + mv "${page}" "${page%.html}/index.html" + done + + - name: Prepare the isolated database and IdP + run: | + umask 077 + bash .github/e2e-stack/start-idp.sh + uv run --no-sync python migrations/run.py > "${RUNNER_TEMP}/mcp-oauth-private/migrations.log" 2>&1 + + - name: Run every required OAuth variant without retries + run: | + umask 077 + uv run --no-sync pytest -c tests/e2e/pytest.ini tests/e2e/mcp/test_mcp_oauth_happy_path_e2e.py \ + --rootdir=. --reruns 0 --tb=short -o junit_family=xunit1 \ + --junitxml="${RUNNER_TEMP}/mcp-oauth-private/results.xml" \ + > "${RUNNER_TEMP}/mcp-oauth-private/pytest.log" 2>&1 + - name: Reject skipped or missing cases + if: always() + run: | + uv run --no-sync python .github/e2e-stack/assert_tests_ran.py \ + "${RUNNER_TEMP}/mcp-oauth-private/results.xml" tests/e2e/mcp/test_mcp_oauth_happy_path_e2e.py + - name: Remove private login and logs + if: always() + run: | + docker rm -f e2e-keycloak >/dev/null 2>&1 || true + rm -rf "${RUNNER_TEMP}/mcp-oauth-private" diff --git a/tests/e2e/AGENTS.md b/tests/e2e/AGENTS.md index 967a85f1255..9b662e511b8 100644 --- a/tests/e2e/AGENTS.md +++ b/tests/e2e/AGENTS.md @@ -33,7 +33,7 @@ Every test under `tests/e2e/mcp/` must exercise the proxy against the real Datad - Prefer calling real Datadog tools that prove the product path (e.g. `search_datadog_logs` for list/call and permission denials). Seed a unique marker (`e2e-datadog-mcp-*`) in a chat completion when you need a log the tool can find; dual-read with `dd_logs` from conftest when delivery matters - Delete the MCP server (and any keys) through `resources.defer` the same way every other suite tears down - If a new MCP behavior cannot be covered with Datadog's tool surface, say so in the PR and get agreement before inventing another upstream; the default is always Datadog -- The two standing exceptions are `test_mcp_chat_completion_oauth_e2e.py` and `test_mcp_oauth_happy_path_e2e.py`. Datadog authenticates with the static `DD-API-KEY` / `DD-APPLICATION-KEY` headers and exposes no authorize/token dance at all, so these tests drive a real Linear MCP server instead; they are still real remote upstreams, so the no-mock, no-fixture rule above holds unchanged +- The two standing exceptions are `test_mcp_chat_completion_oauth_e2e.py` and `test_mcp_oauth_happy_path_e2e.py`. Datadog authenticates with the static `DD-API-KEY` / `DD-APPLICATION-KEY` headers and exposes no authorize/token dance at all, so these tests drive a real Linear MCP server instead; they are still real remote upstreams, so the no-mock, no-fixture rule above holds unchanged. The direct OAuth test also uses the existing live provider edge to inspect forwarded headers without replay, and owns a separate source-built gateway for cold restarts ## Lay the pattern down in a class diff --git a/tests/e2e/CONTRIBUTING.md b/tests/e2e/CONTRIBUTING.md index 2afcc563824..2adac08329f 100644 --- a/tests/e2e/CONTRIBUTING.md +++ b/tests/e2e/CONTRIBUTING.md @@ -248,3 +248,49 @@ The semantic header set is `content-type`, `accept`, `anthropic-version`, `anthr Excluded transport and telemetry headers are `host`, `content-length`, `connection`, `accept-encoding`, `user-agent`, `traceparent`, `tracestate`, `x-request-id`, `x-client-request-id` and `x-stainless-*`. Inbound transfer-encoding is unsupported; send JSON with content-length framing. The destination represents host identity and the relay carries original body bytes. Replay does not verify credentials, SDK timeout/retry behavior, transport performance, model availability or stateful remote IDs. Live relay uses original request bytes and header values, never the stored identity Strict replay harness regression tests live in `tests/code_coverage_tests/test_provider_replay_harness.py`. The CircleCI `provider_replay_harness` job runs them alongside the existing legacy harness files with `--noconftest -o pythonpath=tests/e2e`; they need only synthetic HTTP providers and temporary fixture storage + + +## MCP OAuth happy path + +`test_mcp_oauth_happy_path_e2e.py` runs one shared scenario with four variants: +aggregate gateway SSO and explicitly configured per-server JWT, each directly +against Linear and through the live provider edge. The edge forwards to real +Linear without replay and compares the forwarded bearer to the encrypted +canonical user/server credential. This observes the forwarding boundary, not +Linear's internal logs. Direct variants independently exercise discovery + +Use the existing database preparation, Prisma generation and Keycloak setup. +Build and stage the dashboard from the tested checkout as in the UI runner. +Provide `DATABASE_URL`, `LITELLM_MASTER_KEY`, `LITELLM_SALT_KEY`, `LITELLM_LICENSE`, +and the `E2E_KEYCLOAK_*` settings. Capture a test-account Linear login using +`mcp/linear_session_capture.py` and set `E2E_LINEAR_STORAGE_STATE` to that private +file. The test workspace must contain a team. Do not publish browser state or +raw test/proxy output + +```bash +E2E_MCP_OAUTH_LIVE=1 E2E_FIXTURE_MODE=live E2E_PROVIDER_CACHE=0 \ + uv run --no-sync pytest tests/e2e/mcp/test_mcp_oauth_happy_path_e2e.py \ + --rootdir=. --reruns 0 +``` + +The test starts and restarts its own source-built proxy on a free loopback port, +retaining its database and SSO client but no Redis or process-local cache. It +does not restart an existing proxy or clear shared databases. Gateway login, +consent, immediate list/call and post-restart reconnect must all succeed. The +aggregate client never injects a gateway header; the explicitly labeled JWT +variant configures `x-litellm-api-key` for the first consent and reconnects with +only its gateway JWT after restart + +`.github/workflows/test-mcp-oauth-e2e.yml` runs the four cases in the protected +`e2e-changed` environment. Provision `E2E_LINEAR_STORAGE_STATE_B64` as a secret +there and retain the existing E2E license/AWS role configuration. A missing or +expired session fails the job; collection, deselection and skips are not passes. +The generic changed-test job excludes this file because it requires an owned +proxy and consent UI. No LLM call is needed + +Coverage remains limited to authorization-code OAuth over HTTP. M2M, OBO, +PKCE passthrough, static/BYOK, ID-JAG, forwarding, SigV4 and stdio are outside this +scenario; consult the registry and LIT-3559 for their existing coverage and gaps. +LIT-4506 owns broader isolation/failure regressions. LIT-7737 retains ownership +of dependency/Python compatibility and its matrix; this test reuses its delivered +environment and does not change dependency constraints or compatibility gates diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index d7d173c93d4..268d517a7fe 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -220,6 +220,8 @@ def pytest_runtest_setup(item: pytest.Item) -> None: LIVE_PROVIDER_REQUIRED.set(item.get_closest_marker("provider_live") is not None) if item.get_closest_marker("e2e") is None: return + if isinstance(item, pytest.Function) and "oauth_gateway" in item.fixturenames: + return reason = _proxy_fail_reason() if reason is not None: pytest.fail(reason) diff --git a/tests/e2e/coverage_registry/mcp.yaml b/tests/e2e/coverage_registry/mcp.yaml index bf511ad6b06..1cdeac7b77f 100644 --- a/tests/e2e/coverage_registry/mcp.yaml +++ b/tests/e2e/coverage_registry/mcp.yaml @@ -78,7 +78,7 @@ auth_family: oauth assertions: [persists_across_processes] source: "outbound_credentials/per_user_oauth_store.py V2PerUserTokenStore" - rationale: Stored per-user token is resolved by a gateway process that did not run the consent + rationale: Stored per-user token survives a verified restart of an owned gateway with no Redis cache - id: mcp.list_tools.none.succeeds module: mcp tier: P1 diff --git a/tests/e2e/idp.py b/tests/e2e/idp.py index 2dc7c2ad71b..a89a036baeb 100644 --- a/tests/e2e/idp.py +++ b/tests/e2e/idp.py @@ -435,7 +435,7 @@ def _signal_process_group(process_id: int, signum: int) -> bool: return True -def _stop_process_group(child: subprocess.Popen[bytes]) -> None: +def stop_process_group(child: subprocess.Popen[bytes]) -> None: _signal_process_group(child.pid, signal.SIGTERM) deadline: Final = time.monotonic() + 5 while _process_group_exists(child.pid): @@ -476,7 +476,7 @@ def run_oidc_profile(proxy_url: str, command: list[str]) -> int: try: return child.wait() finally: - _stop_process_group(child) + stop_process_group(child) if __name__ == "__main__": diff --git a/tests/e2e/mcp/oauth_chat_client.py b/tests/e2e/mcp/oauth_chat_client.py index ebae8029a47..f6e72fb37dc 100644 --- a/tests/e2e/mcp/oauth_chat_client.py +++ b/tests/e2e/mcp/oauth_chat_client.py @@ -25,6 +25,7 @@ import httpx import pytest from e2e_config import PROXY_BASE_URL, REQUEST_TIMEOUT from e2e_http import AuthHeaders, NoBody, unwrap +from idp import Identity from mcp import ClientSession from mcp.client.auth import OAuthClientProvider from mcp.client.streamable_http import streamable_http_client @@ -77,7 +78,13 @@ class InMemoryTokenStorage: self._client_info = client_info -async def _browser_follow_authorize(start_url: str, storage_state_path: str) -> tuple[str, str | None]: +async def _browser_follow_authorize( + start_url: str, + storage_state_path: str, + identity: Identity | None = None, + server_alias: str | None = None, + allow_upstream_consent: bool = True, +) -> tuple[str, str | None]: """Play the browser's role for a real upstream whose authorize endpoint serves an interactive consent page (Linear). A headless Chromium primed with a human's saved Linear session opens the gateway authorize URL and @@ -115,6 +122,29 @@ async def _browser_follow_authorize(start_url: str, storage_state_path: str) -> pass if "url" in captured: break + if await page.locator("#username").count() and identity is not None: + await page.locator("#username").fill(identity.username) + await page.locator("#password").fill(identity.password) + await page.locator("#kc-login").click() + continue + if httpx.URL(page.url).host.endswith("linear.app") and not allow_upstream_consent: + raise AssertionError("cold reconnect required upstream consent") + if "/ui/connect" in page.url and server_alias is not None: + card = page.locator("div.cursor-pointer").filter(has=page.get_by_text(server_alias, exact=True)) + if await card.count() != 1: + await asyncio.sleep(0.5) + continue + connect = card.get_by_text("Connect", exact=True) + if await connect.count(): + await connect.click() + continue + if not await card.locator("svg.text-success").count(): + await asyncio.sleep(0.5) + continue + finish = page.get_by_role("button", name="Finish connecting", exact=True) + if await finish.count() and await finish.is_enabled(): + await finish.click() + continue control = page.locator( 'button[name="action"][value="approve"], button:has-text("Authorize"), ' 'button:has-text("Allow"), button:has-text("@"), a:has-text("@")' @@ -132,11 +162,18 @@ async def _browser_follow_authorize(start_url: str, storage_state_path: str) -> f"final={final_url.split('?', 1)[0]!r}; trail={trail[-6:]}" ) params = dict(parse_qsl(httpx.URL(landing).query.decode())) - assert "code" in params, f"client redirect_uri carried no code: {landing}" + assert "code" in params, "client redirect_uri carried no authorization code" return params["code"], params.get("state") -def _oauth_provider(url: str, storage: InMemoryTokenStorage, storage_state_path: str | None) -> OAuthClientProvider: +def _oauth_provider( + url: str, + storage: InMemoryTokenStorage, + storage_state_path: str | None, + identity: Identity | None = None, + server_alias: str | None = None, + allow_upstream_consent: bool = True, +) -> OAuthClientProvider: """The SDK's real OAuth machinery (RFC 9728/8414 discovery, RFC 7591 DCR, PKCE, token exchange) with the browser leg driven by Playwright against the upstream's consent screen.""" @@ -147,7 +184,9 @@ def _oauth_provider(url: str, storage: InMemoryTokenStorage, storage_state_path: async def _follow_redirect(authorize_url: str) -> None: assert storage_state_path is not None - code, state = await _browser_follow_authorize(authorize_url, storage_state_path) + code, state = await _browser_follow_authorize( + authorize_url, storage_state_path, identity, server_alias, allow_upstream_consent + ) code_holder["code"] = code code_holder["state"] = state @@ -202,8 +241,15 @@ class _HeaderInjectingTransport(httpx.AsyncBaseTransport): for name, value in self._headers.items(): if name not in request.headers: request.headers[name] = value + else: + for name, value in self._headers.items(): + if request.headers.get(name) == value: + del request.headers[name] return await self._inner.handle_async_request(request) + async def aclose(self) -> None: + await self._inner.aclose() + def _oauth_http_client( headers: dict[str, str], auth: OAuthClientProvider, gateway_url: str = PROXY_BASE_URL @@ -242,9 +288,14 @@ async def _list_and_call( tool: str, arguments: dict[str, str], gateway_url: str = PROXY_BASE_URL, + identity: Identity | None = None, + server_alias: str | None = None, + allow_upstream_consent: bool = True, ) -> OauthToolRun: async with _oauth_http_client( - headers, _oauth_provider(url, storage, storage_state_path), gateway_url + headers, + _oauth_provider(url, storage, storage_state_path, identity, server_alias, allow_upstream_consent), + gateway_url, ) as http_client: async with streamable_http_client(url, http_client=http_client) as (read, write, _): async with ClientSession(read, write) as session: @@ -321,29 +372,22 @@ class ChatMcpClient: tool: str, arguments: dict[str, str], base_url: str = PROXY_BASE_URL, + identity: Identity | None = None, + allow_upstream_consent: bool = True, ) -> OauthToolRun: - deadline: Final = time.monotonic() + self.proxy.poll_timeout - last_error: Exception | None = None - while time.monotonic() < deadline: - try: - return asyncio.run( - _list_and_call( - _mcp_url(alias, base_url), - headers, - storage, - storage_state_path, - tool, - arguments, - base_url, - ) - ) - except AssertionError: - raise - except Exception as exc: # noqa: BLE001 - retried to the deadline; the last error surfaces below - last_error = exc - time.sleep(self.proxy.poll_interval) - pytest.fail( - f"list and call for {alias!r} never completed within {self.proxy.poll_timeout}s; last error: {last_error!r}" + return asyncio.run( + _list_and_call( + f"{base_url.rstrip('/')}/mcp" if identity is not None else _mcp_url(alias, base_url), + headers, + storage, + storage_state_path, + tool, + arguments, + base_url, + identity, + alias, + allow_upstream_consent, + ) ) def server_user_credentials(self, server_id: str) -> tuple[McpServerUserCredentialRow, ...]: diff --git a/tests/e2e/mcp/oauth_gateway.py b/tests/e2e/mcp/oauth_gateway.py new file mode 100644 index 00000000000..cd71502aad5 --- /dev/null +++ b/tests/e2e/mcp/oauth_gateway.py @@ -0,0 +1,197 @@ +"""An owned, source-built OAuth gateway with cold restarts and credential observations. + +Only this child process is restarted. Its database and SSO client survive while +its process-local caches do not; Redis is deliberately absent from its config. +The optional live edge measures headers without recording credentials or bodies. +""" + +from __future__ import annotations + +import os +import socket +import subprocess +import sys +import threading +import time +from collections.abc import Callable, Mapping +from contextlib import ExitStack +from dataclasses import dataclass, field +from pathlib import Path +from typing import Final + +import psycopg +from e2e_http import NoBody +from idp import Keycloak, stop_process_group +from proxy_client import ProxyClient, build_proxy_client +from psycopg.rows import class_row +from pydantic import BaseModel, SecretStr, TypeAdapter, ValidationError + + +class StoredOAuth(BaseModel): + type: str + access_token: SecretStr + + +@dataclass(frozen=True, slots=True) +class CredentialRow: + credential_b64: str = field(repr=False) + + +def stored_oauth(user_id: str, server_id: str) -> StoredOAuth: + from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper + + with psycopg.Connection[CredentialRow].connect( + os.environ["DATABASE_URL"], row_factory=class_row(CredentialRow) + ) as conn: + row: Final = conn.execute( + 'SELECT credential_b64 FROM "LiteLLM_MCPUserCredentials" WHERE user_id = %s AND server_id = %s', + (user_id, server_id), + ).fetchone() + assert row is not None, "canonical user/server has no persisted credential" + plaintext: Final = decrypt_value_helper( + row.credential_b64, "e2e_mcp_oauth", exception_type="debug", return_original_value=False + ) + assert plaintext is not None, "persisted credential must decrypt with the gateway salt" + assert plaintext != row.credential_b64, "persisted credential must be encrypted" + try: + credential: Final = StoredOAuth.model_validate_json(plaintext) + except ValidationError: + raise AssertionError("decrypted credential is not an OAuth payload") from None + assert credential.type == "oauth2" + assert bool(credential.access_token.get_secret_value()), "stored upstream token is empty" + return credential + + +class RpcMethod(BaseModel): + method: str = "" + + +@dataclass(slots=True) +class OAuthObservation: + user_id: str + server_id: str = "" + gateway_token: str = field(default="", repr=False) + _seen: tuple[tuple[str, bool, bool], ...] = field(default=(), init=False, repr=False) + _lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False) + + def observe(self, url: str, headers: Mapping[str, str], body: bytes | None) -> None: + if not self.server_id or body is None or not url.endswith("/mcp"): + return + try: + operation: Final = RpcMethod.model_validate_json(body).method + except ValidationError: + return + if operation not in ("tools/list", "tools/call"): + return + credential: Final = stored_oauth(self.user_id, self.server_id) + received: Final = headers.get("authorization", "") + matches: Final = received == f"Bearer {credential.access_token.get_secret_value()}" + differs: Final = bool(received) and all( + value not in (self.gateway_token, f"Bearer {self.gateway_token}") for value in headers.values() + ) + with self._lock: + self._seen = (*self._seen, (operation, matches, differs)) + + def assert_forwarded(self) -> None: + with self._lock: + snapshot: Final = self._seen + self._seen = () + assert {item[0] for item in snapshot} == {"tools/list", "tools/call"}, "missing upstream observations" + assert all(item[1] and item[2] for item in snapshot), "upstream bearer did not match the user's stored token" + + +def available_port() -> int: + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + return TypeAdapter(tuple[str, int]).validate_python(listener.getsockname())[1] + + +@dataclass(slots=True) +class OAuthGateway: + base_url: str + proxy: ProxyClient + _environment: Mapping[str, str] = field(repr=False) + _command: tuple[str, ...] = field(repr=False) + _log_path: Path + _child: subprocess.Popen[bytes] | None = field(default=None, init=False, repr=False) + + def start(self) -> None: + with self._log_path.open("ab") as log: + self._child = subprocess.Popen( + self._command, + env=self._environment, + stdout=log, + stderr=log, + start_new_session=True, + ) + deadline: Final = time.monotonic() + 120 + while time.monotonic() < deadline: + assert self._child.poll() is None, "owned OAuth gateway exited; inspect its private log" + result = self.proxy.transport.probe("/health/liveliness", params=NoBody()) + if result.status_code == 200: + return + time.sleep(0.5) + raise AssertionError("owned OAuth gateway did not become ready") + + def stop(self) -> None: + if self._child is not None: + stop_process_group(self._child) + assert self._child.poll() is not None, "old gateway process is still alive" + + def restart(self) -> None: + assert self._child is not None + previous: Final = self._child.pid + self.stop() + self.start() + assert self._child.pid != previous, "gateway restart did not create a new process" + + +def owned_gateway(idp: Keycloak, directory: Path, cleanup: ExitStack) -> OAuthGateway: + for name in ("DATABASE_URL", "LITELLM_LICENSE", "LITELLM_SALT_KEY", "LITELLM_MASTER_KEY"): + assert os.environ.get(name), f"{name} is required for the owned OAuth gateway" + port: Final = available_port() + base_url: Final = f"http://127.0.0.1:{port}" + + def defer(callback: Callable[[], object]) -> None: + cleanup.callback(callback) + + browser: Final = idp.browser_client(callback_url=f"{base_url}/sso/callback", defer=defer) + config: Final = directory / "oauth-gateway.yaml" + config.write_text( + "model_list: []\n" + "general_settings:\n" + " master_key: os.environ/LITELLM_MASTER_KEY\n" + " database_url: os.environ/DATABASE_URL\n" + " enable_jwt_auth: true\n" + " litellm_jwtauth:\n" + " user_id_jwt_field: sub\n" + " user_email_jwt_field: email\n" + " team_ids_jwt_field: groups\n" + " user_id_upsert: true\n" + ) + environment: Final = { + **{key: value for key, value in os.environ.items() if not key.startswith("REDIS_")}, + **browser.environment(idp.discovery()), + "PROXY_BASE_URL": base_url, + "JWT_PUBLIC_KEY_URL": idp.jwks_url, + "JWT_ISSUER": idp.issuer, + "JWT_AUDIENCE": "litellm-e2e", + "DISABLE_SCHEMA_UPDATE": "true", + "STORE_MODEL_IN_DB": "True", + "PYTHONPATH": str(Path(__file__).resolve().parents[3]), + } + gateway: Final = OAuthGateway( + base_url=base_url, + proxy=build_proxy_client( + base_url=base_url, + control_plane_base_url=base_url, + replica_urls=(base_url,), + master_key=os.environ["LITELLM_MASTER_KEY"], + ), + _environment=environment, + _command=(sys.executable, "-m", "litellm.proxy.proxy_cli", "--config", str(config), "--port", str(port)), + _log_path=directory / "oauth-gateway.log", + ) + cleanup.callback(gateway.stop) + gateway.start() + return gateway diff --git a/tests/e2e/mcp/test_mcp_oauth_happy_path_e2e.py b/tests/e2e/mcp/test_mcp_oauth_happy_path_e2e.py index 2755629421a..305989850f1 100644 --- a/tests/e2e/mcp/test_mcp_oauth_happy_path_e2e.py +++ b/tests/e2e/mcp/test_mcp_oauth_happy_path_e2e.py @@ -1,45 +1,85 @@ -"""Live e2e coverage for the gateway-managed MCP OAuth protocol path. +"""Real OAuth consent, immediate MCP operations and cold-restart persistence. -The test creates a JWT-authorized user, completes real Linear authorization -consent, lists and calls a tool immediately through the per-server MCP route, -and verifies the canonical per-user credential row. The first run targets the -first configured gateway replica, and a fresh SDK client then targets a -different replica to prove that a process which did not run consent resolves -the stored token. +Aggregate SSO uses the SDK's normal authentication. The per-server variant is +explicitly a configured two-header client, not an Authorization-only OAuth host. +The observed variants forward to the same real Linear upstream and compare its +bearer at the forwarding boundary; direct variants retain unmodified discovery. """ from __future__ import annotations import os -from typing import Final +from collections.abc import Iterator +from contextlib import ExitStack +from pathlib import Path +from types import MappingProxyType +from typing import Final, Literal import pytest -from e2e_config import ( - LINEAR_MCP_URL, - LINEAR_READONLY_TOOL, - LINEAR_STORAGE_STATE, - PROXY_REPLICA_URLS, - unique_marker, -) -from e2e_http import AuthHeaders +from e2e_config import LINEAR_MCP_URL, LINEAR_READONLY_TOOL, LINEAR_STORAGE_STATE, unique_marker +from e2e_http import AuthHeaders, NoBody, get_external, unwrap +from idp import Identity, Keycloak from lifecycle import ResourceManager -from models import McpServerCreateBody, ObjectPermission, TeamUpdateBody -from proxy_client import ProxyClient - -pytest.importorskip("mcp", reason="mcp SDK not installed; run `uv sync --inexact --group e2e-dev`") -pytest.importorskip( - "playwright.async_api", - reason="playwright not installed; run `uv pip install playwright` and `playwright install chromium`", +from models import ( + McpOauthCredentials, + McpServerCreateBody, + ObjectPermission, + TeamMemberAddBody, + TeamMemberEntry, + TeamUpdateBody, ) +from oauth_chat_client import ChatMcpClient, InMemoryTokenStorage, OauthToolRun, build_chat_client +from oauth_gateway import OAuthGateway, OAuthObservation, owned_gateway, stored_oauth +from provider_edge import LiveEdge, start_provider_edge +from proxy_client import ProxyClient +from pydantic import BaseModel, ValidationError -from idp import Identity, Keycloak # noqa: E402 -from oauth_chat_client import ChatMcpClient, InMemoryTokenStorage, build_chat_client # noqa: E402 - -pytestmark = [pytest.mark.e2e, pytest.mark.mcp_oauth_live] +pytestmark = [pytest.mark.e2e, pytest.mark.mcp_oauth_live, pytest.mark.provider_live] -@pytest.fixture(scope="session") -def chat_client(proxy: ProxyClient) -> ChatMcpClient: +class OAuthMetadata(BaseModel): + authorization_endpoint: str + token_endpoint: str + registration_endpoint: str + + +class LinearTeam(BaseModel): + id: str + name: str + + +class LinearTeams(BaseModel): + teams: tuple[LinearTeam, ...] + + +def assert_tool_result(run: OauthToolRun, tool: str) -> None: + assert tool in run.tools + assert run.is_error is False + try: + result: Final = LinearTeams.model_validate_json(run.text) + except ValidationError: + raise AssertionError("list_teams did not return the expected teams payload") from None + assert result.teams, "the test workspace must contain at least one team" + assert all(team.id and team.name for team in result.teams), "team results must contain identifiers and names" + + +@pytest.fixture(scope="module") +def oauth_gateway(idp: Keycloak, tmp_path_factory: pytest.TempPathFactory) -> Iterator[OAuthGateway]: + assert LINEAR_STORAGE_STATE and Path(LINEAR_STORAGE_STATE).is_file(), ( + "E2E_LINEAR_STORAGE_STATE must name a captured Linear login; see mcp/linear_session_capture.py" + ) + assert os.environ.get("E2E_FIXTURE_MODE", "live") == "live", "OAuth acceptance cannot use replay" + with ExitStack() as cleanup: + yield owned_gateway(idp, tmp_path_factory.mktemp("mcp-oauth"), cleanup) + + +@pytest.fixture(scope="module") +def proxy(oauth_gateway: OAuthGateway) -> ProxyClient: + return oauth_gateway.proxy + + +@pytest.fixture(scope="module") +def client(proxy: ProxyClient) -> ChatMcpClient: return build_chat_client(proxy) @@ -47,80 +87,122 @@ class TestMcpOauthHappyPath: @pytest.mark.covers("mcp.list_tools.oauth.succeeds") @pytest.mark.covers("mcp.call_tool.oauth.succeeds") @pytest.mark.covers("mcp.call_tool.oauth.persists_across_processes") - def test_jwt_user_lists_and_calls_then_reconnects_from_another_gateway( + @pytest.mark.parametrize("route", ("aggregate_sso", "explicit_header_jwt")) + @pytest.mark.parametrize("observed", (False, True), ids=("direct", "observed")) + def test_consent_list_call_and_cold_restart( self, - chat_client: ChatMcpClient, + client: ChatMcpClient, resources: ResourceManager, jwt_identity: Identity, idp: Keycloak, + oauth_gateway: OAuthGateway, + route: Literal["aggregate_sso", "explicit_header_jwt"], + observed: bool, ) -> None: - assert LINEAR_STORAGE_STATE and os.path.exists(LINEAR_STORAGE_STATE), ( - "E2E_MCP_OAUTH_LIVE is set but E2E_LINEAR_STORAGE_STATE does not point at a captured " - "Linear session (run mcp/linear_session_capture.py)" - ) - alias: Final = f"e2elinear{unique_marker()}" tool: Final = f"{alias}-{LINEAR_READONLY_TOOL}" - assert len(PROXY_REPLICA_URLS) >= 2, ( - "set LITELLM_PROXY_REPLICA_URLS to at least two gateway URLs; the persistence cell needs a process " - "that did not run the consent" + token: Final = idp.access_token(jwt_identity) + observation: Final = OAuthObservation(user_id=jwt_identity.user_id, gateway_token=token) + edge: Final = ( + start_provider_edge( + LiveEdge(observe_request=observation.observe), + mounts=MappingProxyType( + {"linear": "https://mcp.linear.app", ".well-known": "https://mcp.linear.app/.well-known"} + ), + ) + if observed + else None ) - created: Final = chat_client.create_server( + if edge is not None: + resources.defer(edge.shutdown) + metadata: Final = ( + unwrap( + get_external( + "https://mcp.linear.app/.well-known/oauth-authorization-server", + response_type=OAuthMetadata, + ) + ) + if observed + else None + ) + created: Final = client.create_server( McpServerCreateBody( alias=alias, - url=LINEAR_MCP_URL, + server_name=alias, + url=f"{edge.edge.api_base('linear')}/mcp" if edge is not None else LINEAR_MCP_URL, + transport="http", allow_all_keys=False, auth_type="oauth2", oauth2_flow="authorization_code", - per_server_oauth_discovery=True, + per_server_oauth_discovery=route == "explicit_header_jwt", + authorization_url=metadata.authorization_endpoint if metadata else None, + token_url=metadata.token_endpoint if metadata else None, + registration_url=metadata.registration_endpoint if metadata else None, + credentials=McpOauthCredentials(upstream_resource=LINEAR_MCP_URL) if observed else None, ) ) - resources.defer(lambda: chat_client.delete_server(created.server_id)) - - chat_client.proxy.update_team( + resources.defer(lambda: client.delete_server(created.server_id)) + assert client.server_user_credentials(created.server_id) == (), ( + "scenario must start without upstream credentials" + ) + observation.server_id = created.server_id + client.proxy.update_team( TeamUpdateBody( team_id=jwt_identity.group, object_permission=ObjectPermission(mcp_servers=[created.server_id]), ) ) - - token: Final = idp.access_token(jwt_identity) - headers: Final = {"x-litellm-api-key": f"Bearer {token}"} - storage: Final = InMemoryTokenStorage() - first_run: Final = chat_client.list_and_call( + unwrap( + client.proxy.transport.post( + "/team/member_add", + headers=client.proxy.transport.master, + json=TeamMemberAddBody( + team_id=jwt_identity.group, member=TeamMemberEntry(user_id=jwt_identity.user_id, role="user") + ), + response_type=NoBody, + ) + ) + headers: Final = {"x-litellm-api-key": f"Bearer {token}"} if route == "explicit_header_jwt" else {} + resources.defer( + lambda: client.revoke_user_token( + created.server_id, + AuthHeaders(authorization=f"Bearer {idp.access_token(jwt_identity)}"), + ) + ) + identity: Final = jwt_identity if route == "aggregate_sso" else None + first: Final = client.list_and_call( alias, headers, - storage, + InMemoryTokenStorage(), LINEAR_STORAGE_STATE, tool, {}, - base_url=PROXY_REPLICA_URLS[0], + base_url=oauth_gateway.base_url, + identity=identity, ) - assert tool in first_run.tools - assert first_run.is_error is False - assert first_run.text.strip() != "" - - credentials: Final = chat_client.server_user_credentials(created.server_id) + assert_tool_result(first, tool) + credentials: Final = client.server_user_credentials(created.server_id) assert len(credentials) == 1 assert credentials[0].user_id == jwt_identity.user_id assert credentials[0].credential_type == "oauth2" - resources.defer( - lambda: chat_client.revoke_user_token( - created.server_id, - AuthHeaders.model_validate(headers), - ) - ) - - replica: Final = PROXY_REPLICA_URLS[-1] - second_run: Final = chat_client.list_and_call( + stored_oauth(jwt_identity.user_id, created.server_id) + if observed: + observation.assert_forwarded() + oauth_gateway.restart() + fresh_token: Final = idp.access_token(jwt_identity) + observation.gateway_token = fresh_token + second: Final = client.list_and_call( alias, - {"x-litellm-api-key": f"Bearer {idp.access_token(jwt_identity)}"}, + {"Authorization": f"Bearer {fresh_token}"} if identity is None else {}, InMemoryTokenStorage(), - None, + LINEAR_STORAGE_STATE if identity is not None else None, tool, {}, - base_url=replica, + base_url=oauth_gateway.base_url, + identity=identity, + allow_upstream_consent=False, ) - assert tool in second_run.tools - assert second_run.is_error is False - assert second_run.text.strip() != "" + assert_tool_result(second, tool) + stored_oauth(jwt_identity.user_id, created.server_id) + if observed: + observation.assert_forwarded() diff --git a/tests/e2e/models.py b/tests/e2e/models.py index 4308984c3be..4b202e3c663 100644 --- a/tests/e2e/models.py +++ b/tests/e2e/models.py @@ -572,6 +572,10 @@ class McpInfo(BaseModel): logo_url: str | None = None +class McpOauthCredentials(BaseModel): + upstream_resource: str + + class McpServerCreateBody(BaseModel): """POST /v1/mcp/server. For a gateway-managed OAuth server, `auth_type` is `oauth2` and `oauth2_flow` is `authorization_code`; the upstream endpoints @@ -587,6 +591,8 @@ class McpServerCreateBody(BaseModel): per_server_oauth_discovery: bool | None = None authorization_url: str | None = None token_url: str | None = None + registration_url: str | None = None + credentials: McpOauthCredentials | None = None server_name: str | None = None description: str | None = None mcp_info: McpInfo | None = None diff --git a/tests/e2e/provider_edge.py b/tests/e2e/provider_edge.py index 7bbb1375623..136b00208f7 100644 --- a/tests/e2e/provider_edge.py +++ b/tests/e2e/provider_edge.py @@ -46,7 +46,7 @@ import os import re import threading from collections import deque -from collections.abc import Generator, Mapping, Sequence +from collections.abc import Callable, Generator, Mapping, Sequence from contextlib import closing, contextmanager from dataclasses import dataclass, field, replace from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -538,7 +538,7 @@ class ReplayEdge: @dataclass(frozen=True, slots=True) class LiveEdge: - pass + observe_request: Callable[[str, Mapping[str, str], bytes | None], None] | None = None type EdgeBackend = RecordEdge | ReplayEdge | LiveEdge | CacheEdge @@ -787,10 +787,13 @@ def _handle_record( def _handle_live( method: str, url: str, headers: Mapping[str, str], body: bytes | None, timeout: float, cache: CacheEdge | None = None, mount: str = "", test_key: str | None = None, + observe_request: Callable[[str, Mapping[str, str], bytes | None], None] | None = None, ) -> EdgeOutcome: forwarded: Final = { name: value for name, value in headers.items() if name.lower() not in _REQUEST_DROPPED_HEADERS } + if observe_request is not None: + observe_request(url, forwarded, body) head: Final = ( forward_stream(method, url, headers=forwarded, body=body, timeout=timeout) if cache is None else cache.forward(mount, method, url, forwarded, body, timeout, test_key=test_key) @@ -868,9 +871,10 @@ def handle_edge_request( method, _upstream_url(upstream_base, upstream_path, split.query), headers, body, timeout, backend, mount, test_key, ) - case LiveEdge(): + case LiveEdge(observe_request=observe_request): return _handle_live( - method, _upstream_url(upstream_base, upstream_path, split.query), headers, body, timeout + method, _upstream_url(upstream_base, upstream_path, split.query), headers, body, timeout, + observe_request=observe_request, ) case RecordEdge(): return _handle_record( From fdb0fb648eabbabe8a27900695c9a023ff707895 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 19 Sep 2026 08:12:35 +0000 Subject: [PATCH 28/73] fix(e2e): bind MCP OAuth acceptance to the owned gateway and snapshot the stored token once per phase Co-Authored-By: bot_apk --- .github/workflows/test-mcp-oauth-e2e.yml | 17 ++++--------- tests/e2e/mcp/oauth_gateway.py | 25 ++++++++++--------- .../e2e/mcp/test_mcp_oauth_happy_path_e2e.py | 23 ++++++++--------- 3 files changed, 29 insertions(+), 36 deletions(-) diff --git a/.github/workflows/test-mcp-oauth-e2e.yml b/.github/workflows/test-mcp-oauth-e2e.yml index 5fc9b711fd5..ea9ef93bf14 100644 --- a/.github/workflows/test-mcp-oauth-e2e.yml +++ b/.github/workflows/test-mcp-oauth-e2e.yml @@ -1,23 +1,12 @@ name: MCP OAuth happy path on: - pull_request: - paths: - - tests/e2e/idp.py - - tests/e2e/provider_edge.py - - tests/e2e/models.py - - tests/e2e/conftest.py - - .github/e2e-stack/assert_tests_ran.py - - tests/e2e/mcp/oauth_chat_client.py - - tests/e2e/mcp/oauth_gateway.py - - tests/e2e/mcp/test_mcp_oauth_happy_path_e2e.py - - .github/workflows/test-mcp-oauth-e2e.yml workflow_dispatch: permissions: {} concurrency: - group: mcp-oauth-${{ github.event.pull_request.number || github.ref }} + group: mcp-oauth-${{ github.ref }} cancel-in-progress: true jobs: @@ -161,6 +150,10 @@ jobs: run: | uv run --no-sync python .github/e2e-stack/assert_tests_ran.py \ "${RUNNER_TEMP}/mcp-oauth-private/results.xml" tests/e2e/mcp/test_mcp_oauth_happy_path_e2e.py + - name: Publish sanitized summary + if: always() + run: | + grep -E '^(FAILED|PASSED|ERROR|E AssertionError|=+ .* =+)' "${RUNNER_TEMP}/mcp-oauth-private/pytest.log" || true - name: Remove private login and logs if: always() run: | diff --git a/tests/e2e/mcp/oauth_gateway.py b/tests/e2e/mcp/oauth_gateway.py index cd71502aad5..82bb5f7ba0b 100644 --- a/tests/e2e/mcp/oauth_gateway.py +++ b/tests/e2e/mcp/oauth_gateway.py @@ -26,6 +26,8 @@ from proxy_client import ProxyClient, build_proxy_client from psycopg.rows import class_row from pydantic import BaseModel, SecretStr, TypeAdapter, ValidationError +INHERITED_ENV_PREFIXES: Final = ("REDIS_", "MICROSOFT_", "GOOGLE_", "GENERIC_", "PROXY_") + class StoredOAuth(BaseModel): type: str @@ -38,6 +40,7 @@ class CredentialRow: def stored_oauth(user_id: str, server_id: str) -> StoredOAuth: + """Read the encrypted credential because management APIs omit the plaintext token.""" from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper with psycopg.Connection[CredentialRow].connect( @@ -68,14 +71,12 @@ class RpcMethod(BaseModel): @dataclass(slots=True) class OAuthObservation: - user_id: str - server_id: str = "" gateway_token: str = field(default="", repr=False) - _seen: tuple[tuple[str, bool, bool], ...] = field(default=(), init=False, repr=False) + _seen: tuple[tuple[str, str, bool], ...] = field(default=(), init=False, repr=False) _lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False) def observe(self, url: str, headers: Mapping[str, str], body: bytes | None) -> None: - if not self.server_id or body is None or not url.endswith("/mcp"): + if body is None or not url.endswith("/mcp"): return try: operation: Final = RpcMethod.model_validate_json(body).method @@ -83,21 +84,21 @@ class OAuthObservation: return if operation not in ("tools/list", "tools/call"): return - credential: Final = stored_oauth(self.user_id, self.server_id) received: Final = headers.get("authorization", "") - matches: Final = received == f"Bearer {credential.access_token.get_secret_value()}" - differs: Final = bool(received) and all( - value not in (self.gateway_token, f"Bearer {self.gateway_token}") for value in headers.values() + gateway_leaked: Final = any( + value in (self.gateway_token, f"Bearer {self.gateway_token}") for value in headers.values() ) with self._lock: - self._seen = (*self._seen, (operation, matches, differs)) + self._seen = (*self._seen, (operation, received, gateway_leaked)) - def assert_forwarded(self) -> None: + def assert_forwarded(self, expected: StoredOAuth) -> None: with self._lock: snapshot: Final = self._seen self._seen = () assert {item[0] for item in snapshot} == {"tools/list", "tools/call"}, "missing upstream observations" - assert all(item[1] and item[2] for item in snapshot), "upstream bearer did not match the user's stored token" + expected_header: Final = f"Bearer {expected.access_token.get_secret_value()}" + assert all(item[1] == expected_header for item in snapshot), "upstream bearer did not match the stored token" + assert all(not item[2] for item in snapshot), "gateway bearer leaked to the upstream" def available_port() -> int: @@ -170,7 +171,7 @@ def owned_gateway(idp: Keycloak, directory: Path, cleanup: ExitStack) -> OAuthGa " user_id_upsert: true\n" ) environment: Final = { - **{key: value for key, value in os.environ.items() if not key.startswith("REDIS_")}, + **{key: value for key, value in os.environ.items() if not key.startswith(INHERITED_ENV_PREFIXES)}, **browser.environment(idp.discovery()), "PROXY_BASE_URL": base_url, "JWT_PUBLIC_KEY_URL": idp.jwks_url, diff --git a/tests/e2e/mcp/test_mcp_oauth_happy_path_e2e.py b/tests/e2e/mcp/test_mcp_oauth_happy_path_e2e.py index 305989850f1..c20b73c0d63 100644 --- a/tests/e2e/mcp/test_mcp_oauth_happy_path_e2e.py +++ b/tests/e2e/mcp/test_mcp_oauth_happy_path_e2e.py @@ -102,7 +102,7 @@ class TestMcpOauthHappyPath: alias: Final = f"e2elinear{unique_marker()}" tool: Final = f"{alias}-{LINEAR_READONLY_TOOL}" token: Final = idp.access_token(jwt_identity) - observation: Final = OAuthObservation(user_id=jwt_identity.user_id, gateway_token=token) + observation: Final = OAuthObservation(gateway_token=token) edge: Final = ( start_provider_edge( LiveEdge(observe_request=observation.observe), @@ -145,13 +145,6 @@ class TestMcpOauthHappyPath: assert client.server_user_credentials(created.server_id) == (), ( "scenario must start without upstream credentials" ) - observation.server_id = created.server_id - client.proxy.update_team( - TeamUpdateBody( - team_id=jwt_identity.group, - object_permission=ObjectPermission(mcp_servers=[created.server_id]), - ) - ) unwrap( client.proxy.transport.post( "/team/member_add", @@ -162,6 +155,12 @@ class TestMcpOauthHappyPath: response_type=NoBody, ) ) + client.proxy.update_team( + TeamUpdateBody( + team_id=jwt_identity.group, + object_permission=ObjectPermission(mcp_servers=[created.server_id]), + ) + ) headers: Final = {"x-litellm-api-key": f"Bearer {token}"} if route == "explicit_header_jwt" else {} resources.defer( lambda: client.revoke_user_token( @@ -185,9 +184,9 @@ class TestMcpOauthHappyPath: assert len(credentials) == 1 assert credentials[0].user_id == jwt_identity.user_id assert credentials[0].credential_type == "oauth2" - stored_oauth(jwt_identity.user_id, created.server_id) + first_stored_oauth: Final = stored_oauth(jwt_identity.user_id, created.server_id) if observed: - observation.assert_forwarded() + observation.assert_forwarded(first_stored_oauth) oauth_gateway.restart() fresh_token: Final = idp.access_token(jwt_identity) observation.gateway_token = fresh_token @@ -203,6 +202,6 @@ class TestMcpOauthHappyPath: allow_upstream_consent=False, ) assert_tool_result(second, tool) - stored_oauth(jwt_identity.user_id, created.server_id) + second_stored_oauth: Final = stored_oauth(jwt_identity.user_id, created.server_id) if observed: - observation.assert_forwarded() + observation.assert_forwarded(second_stored_oauth) From fd45412c89df25928ff186f21b602b387df492f2 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 01:47:43 -0700 Subject: [PATCH 29/73] feat(batches): run hosted_vllm batches inside LiteLLM vLLM serves no /v1/files or /v1/batches, so a hosted_vllm deployment can never host a batch. Batch inputs for such a deployment now land in a LiteLLM-owned storage backend, the batch is executed line by line through the deployment's own chat, completion, embedding, or responses route, and the batch plus its output and error files are served back from the database under the creating key --- .../proxy/hooks/managed_files.py | 99 ++- .../migration.sql | 8 + litellm/constants.py | 1 + .../files/litellm_db_storage_backend.py | 65 ++ .../base_llm/files/storage_backend_factory.py | 26 +- litellm/proxy/_lazy_openapi_snapshot.json | 2 +- litellm/proxy/batches_endpoints/endpoints.py | 116 ++- .../litellm_executed_batches.py | 562 ++++++++++++++ .../openai_files_endpoints/common_utils.py | 5 + .../openai_files_endpoints/files_endpoints.py | 67 +- .../storage_backend_service.py | 16 +- litellm/proxy/schema.prisma | 6 + litellm/types/llms/openai.py | 1 + litellm/types/utils.py | 2 + schema.prisma | 6 + tests/e2e/batches/test_batches_e2e.py | 158 +++- .../proxy/test_managed_files_hook.py | 135 +++- .../files/test_litellm_db_storage_backend.py | 92 +++ .../files/test_storage_backend_factory.py | 28 + .../proxy/batches_endpoints/test_endpoints.py | 189 ++++- .../test_litellm_executed_batches.py | 715 ++++++++++++++++++ .../test_files_common_utils.py | 13 + .../test_files_endpoint.py | 115 +++ .../test_storage_backend_service.py | 34 +- 24 files changed, 2339 insertions(+), 122 deletions(-) create mode 100644 litellm-proxy-extras/litellm_proxy_extras/migrations/20260918000000_add_managed_file_content_table/migration.sql create mode 100644 litellm/llms/base_llm/files/litellm_db_storage_backend.py create mode 100644 litellm/proxy/batches_endpoints/litellm_executed_batches.py create mode 100644 tests/test_litellm/llms/base_llm/files/test_litellm_db_storage_backend.py create mode 100644 tests/test_litellm/llms/base_llm/files/test_storage_backend_factory.py create mode 100644 tests/test_litellm/proxy/batches_endpoints/test_litellm_executed_batches.py diff --git a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py index 4899b87da7a..8eef8a5f1ce 100644 --- a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py +++ b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py @@ -34,6 +34,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import ( ) from openai.types.file_deleted import FileDeleted +from litellm.llms.base_llm.files.storage_backend_factory import get_storage_backend from litellm.llms.base_llm.files.transformation import BaseFileEndpoints from litellm.llms.base_llm.managed_resources.isolation import ( build_list_page, @@ -59,6 +60,7 @@ from litellm.proxy.openai_files_endpoints.common_utils import ( get_content_type_from_file_object, get_model_id_from_unified_batch_id, get_original_file_id, + is_litellm_executed_batch, map_raw_file_ids_to_unified, normalize_mime_type_for_provider, resolve_managed_output_file_model_name, @@ -204,6 +206,19 @@ def _managed_object_table(prisma_client: PrismaClient) -> _ManagedObjectTableAct return prisma_client.db.litellm_managedobjecttable +def _storage_metadata_of(file_object: OpenAIFileObject | None) -> Mapping[str, str]: + hidden_params: Final = cast( # cast-ok: _hidden_params is an untyped attribute the upload path sets + "Mapping[str, object]", getattr(file_object, "_hidden_params", None) or {} + ) + return MappingProxyType( + { + key: value + for key in ("storage_backend", "storage_url") + if isinstance(value := hidden_params.get(key), str) + } + ) + + class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): # Class variables or attributes def __init__(self, internal_usage_cache: InternalUsageCache, prisma_client: PrismaClient): @@ -226,6 +241,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): user_api_key_dict: UserAPIKeyAuth, ) -> None: verbose_logger.info(f"Storing LiteLLM Managed File object with id={file_id} in cache") + storage_metadata: Final = _storage_metadata_of(file_object) if file_object is not None: litellm_managed_file_object = LiteLLM_ManagedFileTable( unified_file_id=file_id, @@ -235,6 +251,8 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): created_by=resolve_resource_owner_id(user_api_key_dict), team_id=user_api_key_dict.team_id, updated_by=user_api_key_dict.user_id, + storage_backend=storage_metadata.get("storage_backend"), + storage_url=storage_metadata.get("storage_url"), ) await self.internal_usage_cache.async_set_cache( key=file_id, @@ -262,14 +280,8 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): file_object_json = file_object.model_dump_json() db_data["file_object"] = file_object_json update_data["file_object"] = file_object_json - # Extract storage metadata from hidden params if present - hidden_params = getattr(file_object, "_hidden_params", {}) or {} - if "storage_backend" in hidden_params: - db_data["storage_backend"] = hidden_params["storage_backend"] - update_data["storage_backend"] = hidden_params["storage_backend"] - if "storage_url" in hidden_params: - db_data["storage_url"] = hidden_params["storage_url"] - update_data["storage_url"] = hidden_params["storage_url"] + db_data.update(storage_metadata) + update_data.update(storage_metadata) verbose_logger.debug( f"Storage metadata: storage_backend={db_data.get('storage_backend')}, " @@ -314,6 +326,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): request_tags: Sequence[str] | None = None, persist_attribution: bool = False, create_if_missing: bool = True, + batch_processed: bool = False, ) -> None: """Persist a managed object row, caching it and upserting it in the DB. @@ -328,6 +341,10 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): row absent from the table is left absent rather than created with the observer as its creator, because created_by and team_id are written from whoever calls the create branch. + + batch_processed is set by callers that have already billed the batch + themselves, so CheckBatchCost skips the row instead of billing it twice. + It is written only in the upsert create branch. """ verbose_logger.info(f"Storing LiteLLM Managed {file_purpose} object with id={unified_object_id} in cache") litellm_managed_object = LiteLLM_ManagedObjectTable( @@ -379,6 +396,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): "updated_by": user_api_key_dict.user_id, "status": file_object.status, **attribution_columns, + "batch_processed": batch_processed, }, "update": update_columns, }, @@ -1343,6 +1361,9 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): self, data: Dict, user_api_key_dict: UserAPIKeyAuth, response: LLMResponseTypes ) -> LLMResponseTypes: if isinstance(response, LiteLLMBatch): + decoded_batch_id: Final = _is_base64_encoded_unified_file_id(response.id) + if decoded_batch_id and is_litellm_executed_batch(decoded_batch_id): + return response ## Check if unified_file_id is in the response unified_file_id = response._hidden_params.get("unified_file_id") # managed file id unified_batch_id = response._hidden_params.get("unified_batch_id") # managed batch id @@ -1794,24 +1815,11 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): # Check if file deletion should be blocked due to batch references await self._check_file_deletion_allowed(file_id) - # file_id = convert_b64_uid_to_unified_uid(file_id) - model_file_id_mapping = await self.get_model_file_id_mapping([file_id], litellm_parent_otel_span) - - specific_model_file_id_mapping = model_file_id_mapping.get(file_id) - if specific_model_file_id_mapping: - # Remove conflicting keys from data to avoid duplicate keyword arguments - filtered_data = {k: v for k, v in data.items() if k not in ("model", "file_id")} - for model_id, model_file_id in specific_model_file_id_mapping.items(): - credentials = llm_router.get_deployment_credentials_with_provider(model_id=model_id) - delete_data = { - **{k: v for k, v in filtered_data.items() if k != "_litellm_internal_model_credentials"}, - **( - {"_litellm_internal_model_credentials": MappingProxyType(dict(credentials))} - if credentials is not None - else {} - ), - } - await llm_router.afile_delete(model=model_id, file_id=model_file_id, **delete_data) + managed_file: Final = await self.get_unified_file_id(file_id, litellm_parent_otel_span) + if managed_file is not None and managed_file.storage_backend and managed_file.storage_url: + await self._delete_storage_backend_content(managed_file.storage_backend, managed_file.storage_url) + else: + await self._delete_provider_files(file_id, litellm_parent_otel_span, llm_router, data) await self.delete_unified_file_id(file_id, litellm_parent_otel_span) @@ -1820,6 +1828,39 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): prom_logger.record_managed_file_deleted(result="success") return FileDeleted(id=file_id, object="file", deleted=True) + async def _delete_storage_backend_content(self, storage_backend_name: str, storage_url: str) -> None: + try: + storage_backend: Final = get_storage_backend(storage_backend_name, prisma_client=self.prisma_client) + except ValueError as e: + raise HTTPException(status_code=400, detail=f"Cannot delete the stored file content: {e}") from e + await storage_backend.delete_file(storage_url) + + async def _delete_provider_files( + self, + file_id: str, + litellm_parent_otel_span: Span | None, + llm_router: Router, + data: Mapping[str, object], + ) -> None: + model_file_id_mapping: Final = await self.get_model_file_id_mapping([file_id], litellm_parent_otel_span) + specific_model_file_id_mapping: Final = model_file_id_mapping.get(file_id) + if not specific_model_file_id_mapping: + return + filtered_data: Final = { + k: v for k, v in data.items() if k not in ("model", "file_id", "_litellm_internal_model_credentials") + } + for model_id, model_file_id in specific_model_file_id_mapping.items(): + credentials = llm_router.get_deployment_credentials_with_provider(model_id=model_id) + delete_data = { + **filtered_data, + **( + {"_litellm_internal_model_credentials": MappingProxyType(dict(credentials))} + if credentials is not None + else {} + ), + } + await llm_router.afile_delete(model=model_id, file_id=model_file_id, **delete_data) + async def afile_content( self, file_id: str, @@ -1889,16 +1930,12 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): # File is stored in a storage backend, download and convert to base64 try: - from litellm.llms.base_llm.files.storage_backend_factory import ( - get_storage_backend, - ) - storage_backend_name = db_file.storage_backend storage_url = db_file.storage_url # Get storage backend (uses same env vars as callback) try: - storage_backend = get_storage_backend(storage_backend_name) + storage_backend = get_storage_backend(storage_backend_name, prisma_client=self.prisma_client) except ValueError as e: verbose_logger.warning( f"Storage backend '{storage_backend_name}' error for file {file_id}: {str(e)}" diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260918000000_add_managed_file_content_table/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260918000000_add_managed_file_content_table/migration.sql new file mode 100644 index 00000000000..bb1a3eab6ee --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260918000000_add_managed_file_content_table/migration.sql @@ -0,0 +1,8 @@ +-- CreateTable +CREATE TABLE IF NOT EXISTS "LiteLLM_ManagedFileContentTable" ( + "id" TEXT NOT NULL, + "content" BYTEA NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "LiteLLM_ManagedFileContentTable_pkey" PRIMARY KEY ("id") +); diff --git a/litellm/constants.py b/litellm/constants.py index d62cad74a36..bbeb4846e27 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -1694,6 +1694,7 @@ LOGIN_THROTTLE_NOT_BLOCKED: Final = (0, 0) LITELLM_PROXY_ADMIN_NAME: Final = "default_user_id" LITELLM_PROXY_BUDGET_NAME: Final = "litellm-proxy-budget" GLOBAL_PROXY_SPEND_CACHE_KEY: Final = f"{LITELLM_PROXY_ADMIN_NAME}:spend" +LITELLM_EXECUTED_BATCH_CONCURRENCY: Final = max(1, int(os.getenv("LITELLM_EXECUTED_BATCH_CONCURRENCY", "4"))) ########################### CLI SSO AUTHENTICATION CONSTANTS ########################### LITELLM_CLI_SOURCE_IDENTIFIER: Final = "litellm-cli" diff --git a/litellm/llms/base_llm/files/litellm_db_storage_backend.py b/litellm/llms/base_llm/files/litellm_db_storage_backend.py new file mode 100644 index 00000000000..bca4b8f4c6f --- /dev/null +++ b/litellm/llms/base_llm/files/litellm_db_storage_backend.py @@ -0,0 +1,65 @@ +from collections.abc import Mapping +from typing import TYPE_CHECKING, Final + +from litellm.llms.base_llm.files.storage_backend import BaseFileStorageBackend +from litellm.repositories.prisma_protocols import TableActions +from litellm.repositories.table_repositories import PrismaTableRepository + +if TYPE_CHECKING: + from prisma import models as prisma_models + + from litellm.proxy.utils import PrismaClient + +LITELLM_DB_STORAGE_BACKEND_NAME: Final = "litellm_db" +LITELLM_DB_STORAGE_URL_PREFIX: Final = f"{LITELLM_DB_STORAGE_BACKEND_NAME}://" + + +def storage_url_to_row_id(storage_url: str) -> str: + if not storage_url.startswith(LITELLM_DB_STORAGE_URL_PREFIX): + raise ValueError(f"Not a {LITELLM_DB_STORAGE_BACKEND_NAME} storage url: {storage_url}") + return storage_url.removeprefix(LITELLM_DB_STORAGE_URL_PREFIX) + + +def _where_id(storage_url: str) -> Mapping[str, str]: + return {"id": storage_url_to_row_id(storage_url)} # mutable-ok: Prisma filter + + +class ManagedFileContentRepository(PrismaTableRepository["prisma_models.LiteLLM_ManagedFileContentTable"]): + table_name = "litellm_managedfilecontenttable" + + +class LiteLLMDbStorageBackend(BaseFileStorageBackend): + def __init__(self, prisma_client: "PrismaClient") -> None: + self._prisma_client = prisma_client + + @property + def _table(self) -> "TableActions[prisma_models.LiteLLM_ManagedFileContentTable]": + return ManagedFileContentRepository(self._prisma_client).table + + async def upload_file( + self, + file_content: bytes, + filename: str, + content_type: str, + path_prefix: str | None = None, + file_naming_strategy: str = "uuid", + ) -> str: + from prisma import Base64 + + data: Final = {"content": Base64.encode(file_content)} # mutable-ok: Prisma payload + row: Final = await self._table.create(data=data) + return f"{LITELLM_DB_STORAGE_URL_PREFIX}{row.id}" + + async def download_file(self, storage_url: str) -> bytes: + row: Final = await self._table.find_unique(where=_where_id(storage_url)) + if row is None: + raise ValueError(f"No stored file content for {storage_url}") + return row.content.decode() + + async def delete_file(self, storage_url: str) -> None: + from prisma.errors import RecordNotFoundError + + try: + await self._table.delete(where=_where_id(storage_url)) + except RecordNotFoundError: + return diff --git a/litellm/llms/base_llm/files/storage_backend_factory.py b/litellm/llms/base_llm/files/storage_backend_factory.py index 0cf8164bc4a..e126da44d0a 100644 --- a/litellm/llms/base_llm/files/storage_backend_factory.py +++ b/litellm/llms/base_llm/files/storage_backend_factory.py @@ -6,32 +6,46 @@ based on the backend type. Backends use the same configuration as their correspo callbacks (e.g., azure_storage uses the same env vars as AzureBlobStorageLogger). """ +from typing import TYPE_CHECKING + from litellm._logging import verbose_logger from .azure_blob_storage_backend import AzureBlobStorageBackend +from .litellm_db_storage_backend import LITELLM_DB_STORAGE_BACKEND_NAME, LiteLLMDbStorageBackend from .storage_backend import BaseFileStorageBackend +if TYPE_CHECKING: + from litellm.proxy.utils import PrismaClient -def get_storage_backend(backend_type: str) -> BaseFileStorageBackend: + +def get_storage_backend(backend_type: str, prisma_client: "PrismaClient | None" = None) -> BaseFileStorageBackend: """ Factory function to create a storage backend instance. Backends are configured using the same environment variables as their corresponding callbacks. For example, "azure_storage" uses the same - env vars as AzureBlobStorageLogger. + env vars as AzureBlobStorageLogger. "litellm_db" stores file bytes in the + proxy's own database and needs the connected Prisma client. Args: - backend_type: Backend type identifier (e.g., "azure_storage") + backend_type: Backend type identifier (e.g., "azure_storage", "litellm_db") + prisma_client: The proxy's database client, required by "litellm_db" Returns: BaseFileStorageBackend: Instance of the appropriate storage backend Raises: - ValueError: If backend_type is not supported + ValueError: If backend_type is not supported, or "litellm_db" is asked for without a database """ verbose_logger.debug("Creating storage backend: type=%s", backend_type) if backend_type == "azure_storage": return AzureBlobStorageBackend() - else: - raise ValueError(f"Unsupported storage backend type: {backend_type}. Supported types: azure_storage") + if backend_type == LITELLM_DB_STORAGE_BACKEND_NAME: + if prisma_client is None: + raise ValueError(f"Storage backend {LITELLM_DB_STORAGE_BACKEND_NAME} requires a database-connected proxy") + return LiteLLMDbStorageBackend(prisma_client) + raise ValueError( + f"Unsupported storage backend type: {backend_type}. " + f"Supported types: azure_storage, {LITELLM_DB_STORAGE_BACKEND_NAME}" + ) diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 73ea8cf1991..18849ef5b64 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -19622,7 +19622,7 @@ } } }, - "description": "\n Unified rate-limit error.\n\n Every rate-limit condition surfaced by litellm \u2014 whether it originated from\n an upstream LLM provider, a vendor batch endpoint, or one of litellm's own\n proxy-side limiters (parallel-requests, dynamic-rate, batch-rate, budget,\n max-iterations, etc.) \u2014 is raised as an instance of this class.\n\n The :attr:`category` attribute lets callers distinguish the source. See\n :class:`RateLimitErrorCategory` for the available values.\n " + "description": "\nUnified rate-limit error.\n\nEvery rate-limit condition surfaced by litellm \u2014 whether it originated from\nan upstream LLM provider, a vendor batch endpoint, or one of litellm's own\nproxy-side limiters (parallel-requests, dynamic-rate, batch-rate, budget,\nmax-iterations, etc.) \u2014 is raised as an instance of this class.\n\nThe :attr:`category` attribute lets callers distinguish the source. See\n:class:`RateLimitErrorCategory` for the available values.\n" }, "500": { "content": { diff --git a/litellm/proxy/batches_endpoints/endpoints.py b/litellm/proxy/batches_endpoints/endpoints.py index 5d9ecddd4c2..cc698ad760e 100644 --- a/litellm/proxy/batches_endpoints/endpoints.py +++ b/litellm/proxy/batches_endpoints/endpoints.py @@ -11,6 +11,7 @@ from types import MappingProxyType from typing import Any, Final, cast from fastapi import APIRouter, Depends, HTTPException, Path, Request, Response +from pydantic import TypeAdapter import litellm from litellm._logging import verbose_proxy_logger @@ -18,6 +19,14 @@ from litellm.batches.main import CancelBatchRequest, RetrieveBatchRequest from litellm.proxy._types import * from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.batches_endpoints.common_utils import validate_batch_list_limit +from litellm.proxy.batches_endpoints.litellm_executed_batches import ( + LITELLM_EXECUTED_BATCH_UPLOAD_GUIDANCE, + LiteLLMExecutedBatchRunner, + ManagedBatchStore, + batch_http_error, + litellm_executed_provider_of, + resolve_litellm_executed_provider, +) from litellm.proxy.common_request_processing import ( ProxyBaseLLMRequestProcessing, log_llm_api_exception, @@ -45,16 +54,55 @@ from litellm.proxy.openai_files_endpoints.common_utils import ( get_model_id_from_unified_batch_id, get_models_from_unified_file_id, get_original_file_id, + is_litellm_executed_batch, prepare_data_with_credentials, update_batch_in_database, validate_managed_id_requirement, ) +from litellm.proxy.pass_through_endpoints.llm_provider_handlers.batch_attribution import request_tags_from_metadata from litellm.proxy.route_llm_request import raise_if_required_body_param_missing -from litellm.proxy.utils import handle_exception_on_proxy, is_known_model +from litellm.proxy.utils import ProxyLogging, handle_exception_on_proxy, is_known_model from litellm.repositories.table_repositories import ManagedFileRepository +from litellm.router import Router from litellm.types.llms.openai import LiteLLMBatchCreateRequest +from litellm.types.utils import LiteLLMBatch router: Final = APIRouter() +_METADATA_ADAPTER: Final[TypeAdapter[Mapping[str, object]]] = TypeAdapter(Mapping[str, object]) + + +def _request_tags(data: Mapping[str, object]) -> tuple[str, ...] | None: + metadata: Final = data.get("litellm_metadata") + if metadata is None: + return None + return request_tags_from_metadata(_METADATA_ADAPTER.validate_python(metadata)) + + +def _litellm_executed_batch_runner(llm_router: Router, proxy_logging_obj: ProxyLogging) -> LiteLLMExecutedBatchRunner: + from litellm.proxy.proxy_server import prisma_client + + managed_files: Final = proxy_logging_obj.get_proxy_hook("managed_files") + if prisma_client is None or not isinstance(managed_files, ManagedBatchStore): + raise batch_http_error( + 400, + "LiteLLM-executed batches need a database: set DATABASE_URL so LiteLLM can keep the batch and its files", + ) + return LiteLLMExecutedBatchRunner( + llm_router=llm_router, + prisma_client=prisma_client, + managed_files=managed_files, + proxy_logging_obj=proxy_logging_obj, + ) + + +def _raise_when_input_file_must_be_managed(model: str, credentials: Mapping[str, object]) -> None: + if litellm_executed_provider_of(credentials) is None: + return + raise batch_http_error( + 400, + f"Batches for {model} run inside LiteLLM, so the input file must be a LiteLLM managed file: " + f"{LITELLM_EXECUTED_BATCH_UPLOAD_GUIDANCE}", + ) def _raise_not_found_when_openai_fallback_unservable( @@ -99,6 +147,24 @@ async def _resolve_managed_input_file_storage_url(input_file_id: str) -> "str | return db_file.storage_url or None +async def _create_provider_batch_for_managed_file( + llm_router: Router, + create_batch_data: LiteLLMBatchCreateRequest, + input_file_id: str, + unified_file_id: str, +) -> LiteLLMBatch: + resolved_storage_url: Final = await _resolve_managed_input_file_storage_url(input_file_id) + request: Final[LiteLLMBatchCreateRequest] = { + **create_batch_data, + "input_file_id": resolved_storage_url or input_file_id, + "disable_fallbacks": True, + } + response: Final = await llm_router.acreate_batch(**request) + response.input_file_id = input_file_id + response._hidden_params["unified_file_id"] = unified_file_id + return response + + @router.post( "/{provider}/v1/batches", dependencies=[Depends(user_api_key_auth)], @@ -292,24 +358,33 @@ async def create_batch( model: Final = target_model_names[0] _create_batch_data["model"] = model - resolved_storage_url: Final = await _resolve_managed_input_file_storage_url(input_file_id) - if resolved_storage_url is not None: - _create_batch_data["input_file_id"] = resolved_storage_url - if llm_router is None: raise HTTPException( status_code=500, detail={"error": "LLM Router not initialized. Ensure models added to proxy."}, ) - _create_batch_data.update(disable_fallbacks=True) # pyright: ignore[reportCallIssue] # router flag - response = await llm_router.acreate_batch(**_create_batch_data) - response.input_file_id = input_file_id - response._hidden_params["unified_file_id"] = unified_file_id + executed_provider: Final = resolve_litellm_executed_provider(llm_router, model, user_api_key_dict.team_id) + response = ( + await _litellm_executed_batch_runner(llm_router, proxy_logging_obj).create( + create_request=_create_batch_data, + unified_input_file_id=input_file_id, + model=model, + provider=executed_provider, + user_api_key_dict=user_api_key_dict, + request_tags=_request_tags(_create_batch_data), + ) + if executed_provider is not None + else await _create_provider_batch_for_managed_file( + llm_router, _create_batch_data, input_file_id, unified_file_id + ) + ) else: # Check if model specified via header/query/body param model_param: Final = ( - data.get("model") or request.query_params.get("model") or request.headers.get("x-litellm-model") + _create_batch_data.get("model") + or request.query_params.get("model") + or request.headers.get("x-litellm-model") ) # SCENARIO 2 & 3: Model from header/query OR custom_llm_provider fallback @@ -320,6 +395,7 @@ async def create_batch( model_id=model_param, operation_context="batch creation", ) + _raise_when_input_file_must_be_managed(model_param, credentials) prepare_data_with_credentials( data=_create_batch_data, @@ -478,15 +554,15 @@ async def retrieve_batch( verbose_proxy_logger=verbose_proxy_logger, ) + executed_batch: Final = isinstance(unified_batch_id, str) and is_litellm_executed_batch(unified_batch_id) + if executed_batch and response is None: + raise batch_http_error(404, f"No batch found with id '{batch_id}'.") + # If batch is in a terminal state, return immediately. # Include "complete" (DB-normalized form of "completed"). - if response is not None and response.status in [ - "completed", - "complete", - "failed", - "cancelled", - "expired", - ]: + if response is not None and ( + response.status in ("completed", "complete", "failed", "cancelled", "expired") or executed_batch + ): # Call hooks and return response = await proxy_logging_obj.post_call_success_hook( data=data, user_api_key_dict=user_api_key_dict, response=response @@ -989,6 +1065,12 @@ async def cancel_batch( ) # SCENARIO 2: target_model_names based routing + elif unified_batch_id and is_litellm_executed_batch(unified_batch_id): + if llm_router is None: + raise batch_http_error(500, "LLM Router not initialized. Ensure models added to proxy.") + response = await _litellm_executed_batch_runner( # rebind-ok: each cancel path sets the route's response + llm_router, proxy_logging_obj + ).cancel(batch_id, user_api_key_dict) elif unified_batch_id: if llm_router is None: raise HTTPException( diff --git a/litellm/proxy/batches_endpoints/litellm_executed_batches.py b/litellm/proxy/batches_endpoints/litellm_executed_batches.py new file mode 100644 index 00000000000..f567f0e2263 --- /dev/null +++ b/litellm/proxy/batches_endpoints/litellm_executed_batches.py @@ -0,0 +1,562 @@ +import asyncio +import json +import time +from collections.abc import Awaitable, Callable, Mapping, Sequence +from dataclasses import dataclass +from itertools import pairwise +from types import MappingProxyType +from typing import TYPE_CHECKING, Final, Literal, Protocol, TypeAlias, runtime_checkable + +from fastapi import HTTPException +from openai.types.batch import Errors +from openai.types.batch_error import BatchError +from openai.types.batch_request_counts import BatchRequestCounts +from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError +from typing_extensions import ReadOnly, TypedDict, assert_never + +import litellm +from litellm._logging import verbose_proxy_logger +from litellm._uuid import uuid as uuid_module +from litellm.constants import LITELLM_EXECUTED_BATCH_CONCURRENCY +from litellm.integrations.prometheus import PrometheusLogger +from litellm.llms.base_llm.files.litellm_db_storage_backend import LITELLM_DB_STORAGE_BACKEND_NAME +from litellm.llms.base_llm.files.storage_backend import BaseFileStorageBackend +from litellm.llms.base_llm.files.storage_backend_factory import get_storage_backend +from litellm.models.managed_files import LiteLLM_ManagedFileTable +from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup +from litellm.proxy.openai_files_endpoints.common_utils import ( + LITELLM_EXECUTED_BATCH_ID_PREFIX, + convert_b64_uid_to_unified_uid, + get_batch_id_from_unified_batch_id, +) +from litellm.proxy.openai_files_endpoints.storage_backend_service import StorageBackendFileService +from litellm.proxy.utils import PrismaClient, ProxyLogging +from litellm.repositories.table_repositories import ManagedObjectRepository +from litellm.types.llms.openai import LiteLLMBatchCreateRequest, OpenAIFileObject, OpenAIFilesPurpose +from litellm.types.utils import LITELLM_EXECUTED_BATCH_PROVIDERS, ExtractedFileData, LiteLLMBatch + +if TYPE_CHECKING: + from prisma import models as prisma_models + + from litellm.router import Router + +BatchEndpoint: TypeAlias = Literal["/v1/chat/completions", "/v1/embeddings", "/v1/completions", "/v1/responses"] +BatchStatus: TypeAlias = Literal["in_progress", "finalizing", "completed", "failed", "cancelling", "cancelled"] + +TERMINAL_BATCH_STATUSES: Final[frozenset[str]] = frozenset({"completed", "failed", "cancelled", "expired"}) +_BATCH_ENDPOINT_ADAPTER: Final[TypeAdapter[BatchEndpoint]] = TypeAdapter(BatchEndpoint) +_CANCEL_POLL_SECONDS: Final = 1.0 +_COMPLETION_WINDOW_SECONDS: Final = 24 * 60 * 60 +LITELLM_EXECUTED_BATCH_UPLOAD_GUIDANCE: Final = ( + "upload it through POST /v1/files with purpose=batch and either the x-litellm-model header or the " + "target_model_names form field naming the model, so LiteLLM keeps the file and runs the batch itself" +) +_RUNNING_BATCHES: Final[set[asyncio.Task[None]]] = set() # mutable-ok: strong references keep running batch tasks alive +_NO_FIELDS: Final[Mapping[str, object]] = MappingProxyType({}) +_NO_HEADERS: Final[Mapping[str, str]] = MappingProxyType({}) + + +class _ErrorDetail(TypedDict): + message: ReadOnly[str] + type: ReadOnly[str] + param: ReadOnly[None] + code: ReadOnly[None] + + +class _ErrorBody(TypedDict): + error: ReadOnly[_ErrorDetail] + + +class _ResultResponse(TypedDict): + status_code: ReadOnly[int] + request_id: ReadOnly[str] + body: ReadOnly[Mapping[str, object]] + + +class _ResultLine(TypedDict): + id: ReadOnly[str] + custom_id: ReadOnly[str] + response: ReadOnly[_ResultResponse] + error: ReadOnly[None] + + +class BatchInputLine(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + custom_id: str + method: Literal["POST"] + url: str + body: Mapping[str, object] + + +@dataclass(frozen=True, slots=True) +class InvalidBatchInput: + line_number: int | None + reason: str + + def describe(self) -> str: + return f"line {self.line_number}: {self.reason}" if self.line_number is not None else self.reason + + +@dataclass(frozen=True, slots=True) +class RowOutcome: + custom_id: str + status_code: int + body: Mapping[str, object] + succeeded: bool + + +@dataclass(frozen=True, slots=True) +class _BatchRun: + unified_batch_id: str + llm_batch_id: str + model: str + endpoint: BatchEndpoint + lines: tuple[BatchInputLine, ...] + user_api_key_dict: UserAPIKeyAuth + request_tags: tuple[str, ...] + + +@runtime_checkable +class ManagedBatchStore(Protocol): + def get_unified_batch_id(self, batch_id: str, model_id: str) -> str: ... + + async def get_unified_file_id( + self, file_id: str, litellm_parent_otel_span: object | None = None + ) -> LiteLLM_ManagedFileTable | None: ... + + async def store_unified_object_id( + self, + unified_object_id: str, + file_object: LiteLLMBatch, + litellm_parent_otel_span: object | None, + model_object_id: str, + file_purpose: Literal["batch", "fine-tune", "response"], + user_api_key_dict: UserAPIKeyAuth, + request_tags: Sequence[str] | None = None, + persist_attribution: bool = False, + create_if_missing: bool = True, + batch_processed: bool = False, + ) -> None: ... + + +class _StorageBackendFactory(Protocol): + def __call__(self, backend_type: str, prisma_client: PrismaClient | None = None) -> BaseFileStorageBackend: ... + + +class _ResultFileUploader(Protocol): + def __call__( + self, + file_data: Mapping[str, object], + target_storage: str, + target_model_names: Sequence[str], + purpose: OpenAIFilesPurpose, + proxy_logging_obj: ProxyLogging, + user_api_key_dict: UserAPIKeyAuth, + prisma_client: PrismaClient | None = None, + ) -> Awaitable[OpenAIFileObject]: ... + + +@runtime_checkable +class _RouterCall(Protocol): + def __call__(self, **params: object) -> Awaitable[object]: ... # kwargs-ok: the request body is passed as keywords + + +def _router_method_name(endpoint: BatchEndpoint) -> str: + match endpoint: + case "/v1/chat/completions": + return "acompletion" + case "/v1/completions": + return "atext_completion" + case "/v1/embeddings": + return "aembedding" + case "/v1/responses": + return "aresponses" + case _: + assert_never(endpoint) + + +def litellm_executed_provider_of(credentials: Mapping[str, object]) -> str | None: + explicit_provider: Final = credentials.get("custom_llm_provider") + provider: Final = ( + explicit_provider if isinstance(explicit_provider, str) else _provider_of(credentials.get("model")) + ) + return provider if provider in LITELLM_EXECUTED_BATCH_PROVIDERS else None + + +def resolve_litellm_executed_provider(llm_router: "Router", model: str, team_id: str | None) -> str | None: + credentials: Final = llm_router.get_deployment_credentials_with_provider(model_id=model, team_id=team_id) + return None if credentials is None else litellm_executed_provider_of(credentials) + + +def _provider_of(model: object) -> str | None: + if not isinstance(model, str): + return None + try: + return litellm.get_llm_provider(model=model)[1] + except Exception: # noqa: BLE001 # get_llm_provider raises on an unknown model, which means no provider + return None + + +def _validation_reason(error: ValidationError) -> str: + return "; ".join( + f"{'.'.join(str(part) for part in item['loc'])}: {item['msg']}" if item["loc"] else item["msg"] + for item in error.errors() + ) + + +def _parse_line(line_number: int, raw: bytes, endpoint: BatchEndpoint) -> BatchInputLine | InvalidBatchInput: + try: + line: Final = BatchInputLine.model_validate_json(raw) + except ValidationError as e: + return InvalidBatchInput(line_number, _validation_reason(e)) + if line.url != endpoint: + return InvalidBatchInput(line_number, f"url {line.url!r} does not match the batch endpoint {endpoint!r}") + if line.body.get("stream"): + return InvalidBatchInput(line_number, "streaming requests are not supported in a batch") + return line + + +def parse_batch_input(content: bytes, endpoint: BatchEndpoint) -> tuple[BatchInputLine, ...] | InvalidBatchInput: + raw_lines: Final = tuple((number, raw) for number, raw in enumerate(content.splitlines(), start=1) if raw.strip()) + if not raw_lines: + return InvalidBatchInput(None, "the input file has no requests") + parsed: Final = tuple(_parse_line(number, raw, endpoint) for number, raw in raw_lines) + first_invalid: Final = next((item for item in parsed if isinstance(item, InvalidBatchInput)), None) + if first_invalid is not None: + return first_invalid + lines: Final = tuple(item for item in parsed if isinstance(item, BatchInputLine)) + custom_ids: Final = sorted(line.custom_id for line in lines) + duplicate: Final = next((first for first, second in pairwise(custom_ids) if first == second), None) + if duplicate is not None: + return InvalidBatchInput(None, f"custom_id {duplicate!r} is used more than once") + return lines + + +def batch_http_error(status_code: int, message: str) -> HTTPException: + detail: Final = {"error": message} # mutable-ok: HTTPException detail must be a plain mapping + return HTTPException(status_code=status_code, detail=detail) + + +def _validate_endpoint(endpoint: object) -> BatchEndpoint: + try: + return _BATCH_ENDPOINT_ADAPTER.validate_python(endpoint) + except ValidationError: + raise batch_http_error(400, f"endpoint {endpoint!r} is not supported for a LiteLLM-executed batch") + + +def _status_code_of(error: Exception) -> int: + status_code: Final[object] = getattr(error, "status_code", None) + return status_code if isinstance(status_code, int) else 500 + + +def _batch_of(blob: object) -> LiteLLMBatch: + return LiteLLMBatch.model_validate_json(blob) if isinstance(blob, str) else LiteLLMBatch.model_validate(blob) + + +def _error_body(error: Exception) -> _ErrorBody: + body: Final[_ErrorBody] = { + "error": {"message": str(error), "type": type(error).__name__, "param": None, "code": None} + } + return body + + +def _result_line(outcome: RowOutcome) -> _ResultLine: + line: Final[_ResultLine] = { + "id": f"batch_req_{uuid_module.uuid4().hex[:24]}", + "custom_id": outcome.custom_id, + "response": { + "status_code": outcome.status_code, + "request_id": f"req_{uuid_module.uuid4().hex[:24]}", + "body": outcome.body, + }, + "error": None, + } + return line + + +def _dump(response: object) -> Mapping[str, object]: + if isinstance(response, BaseModel): + return response.model_dump(mode="json") + raise TypeError(f"Batch rows must return a single response object, got {type(response).__name__}") + + +def _resolve_transition(current_status: str, requested: BatchStatus) -> BatchStatus: + if current_status != "cancelling": + return requested + match requested: + case "completed": + return "cancelled" + case "in_progress" | "finalizing": + return "cancelling" + case "failed" | "cancelling" | "cancelled": + return requested + case _: + assert_never(requested) + + +def _llm_batch_id_of(unified_batch_id: str) -> str: + return get_batch_id_from_unified_batch_id(convert_b64_uid_to_unified_uid(unified_batch_id)) + + +class _CancelWatch: + def __init__(self, load_status: Callable[[], Awaitable[str | None]], interval_seconds: float) -> None: + self._load_status = load_status + self._interval_seconds = interval_seconds + self._checked_at = float("-inf") + self._cancelling = False + + async def cancelling(self) -> bool: + if self._cancelling: + return True + now: Final = time.monotonic() + if now - self._checked_at < self._interval_seconds: + return False + self._checked_at = now + self._cancelling = await self._load_status() == "cancelling" + return self._cancelling + + +class LiteLLMExecutedBatchRunner: + def __init__( + self, + llm_router: "Router", + prisma_client: PrismaClient, + managed_files: ManagedBatchStore, + proxy_logging_obj: ProxyLogging, + concurrency: int = LITELLM_EXECUTED_BATCH_CONCURRENCY, + storage_backend_factory: _StorageBackendFactory = get_storage_backend, + upload_result_file: _ResultFileUploader = StorageBackendFileService.upload_file_to_storage_backend, + ) -> None: + self.llm_router = llm_router + self.prisma_client = prisma_client + self.managed_files = managed_files + self.proxy_logging_obj = proxy_logging_obj + self.concurrency = concurrency + self.storage_backend_factory = storage_backend_factory + self.upload_result_file = upload_result_file + + async def create( + self, + create_request: LiteLLMBatchCreateRequest, + unified_input_file_id: str, + model: str, + provider: str, + user_api_key_dict: UserAPIKeyAuth, + request_tags: Sequence[str] | None, + ) -> LiteLLMBatch: + endpoint: Final = _validate_endpoint(create_request.get("endpoint")) + content: Final = await self._download_input(unified_input_file_id, user_api_key_dict) + parsed: Final = parse_batch_input(content, endpoint) + if isinstance(parsed, InvalidBatchInput): + raise batch_http_error(400, f"Invalid batch input file: {parsed.describe()}") + llm_batch_id: Final = f"{LITELLM_EXECUTED_BATCH_ID_PREFIX}{uuid_module.uuid4().hex}" + model_id: Final = next(iter(self.llm_router.get_model_ids(model_name=model)), model) + unified_batch_id: Final = self.managed_files.get_unified_batch_id(batch_id=llm_batch_id, model_id=model_id) + created_at: Final = int(time.time()) + batch: Final = LiteLLMBatch( + id=unified_batch_id, + object="batch", + endpoint=endpoint, + input_file_id=unified_input_file_id, + completion_window="24h", + status="validating", + created_at=created_at, + expires_at=created_at + _COMPLETION_WINDOW_SECONDS, + metadata=create_request.get("metadata"), + model=model, + request_counts=BatchRequestCounts(completed=0, failed=0, total=len(parsed)), + ) + await self.managed_files.store_unified_object_id( + unified_object_id=unified_batch_id, + file_object=batch, + litellm_parent_otel_span=user_api_key_dict.parent_otel_span, + model_object_id=llm_batch_id, + file_purpose="batch", + user_api_key_dict=user_api_key_dict, + request_tags=request_tags, + persist_attribution=True, + batch_processed=True, + ) + _record_batch_created(model, provider, user_api_key_dict) + run: Final = _BatchRun( + unified_batch_id=unified_batch_id, + llm_batch_id=llm_batch_id, + model=model, + endpoint=endpoint, + lines=parsed, + user_api_key_dict=user_api_key_dict, + request_tags=tuple(request_tags or ()), + ) + task: Final = asyncio.create_task(self._run(run)) + _RUNNING_BATCHES.add(task) + task.add_done_callback(_RUNNING_BATCHES.discard) + return batch + + async def cancel(self, unified_batch_id: str, user_api_key_dict: UserAPIKeyAuth) -> LiteLLMBatch: + current: Final = await self._load_batch(unified_batch_id) + if current is None: + raise batch_http_error(404, f"Batch {unified_batch_id} not found") + if current.status in TERMINAL_BATCH_STATUSES: + raise batch_http_error(400, f"Cannot cancel a batch with status '{current.status}'") + if current.status == "cancelling": + return current + cancelling: Final = current.model_copy( + update=MappingProxyType({"status": "cancelling", "cancelling_at": int(time.time())}) + ) + await self._store(cancelling, user_api_key_dict) + return cancelling + + async def _download_input(self, unified_input_file_id: str, user_api_key_dict: UserAPIKeyAuth) -> bytes: + stored: Final = await self.managed_files.get_unified_file_id( + unified_input_file_id, litellm_parent_otel_span=user_api_key_dict.parent_otel_span + ) + if stored is None or not stored.storage_backend or not stored.storage_url: + raise batch_http_error( + 400, + f"LiteLLM does not hold the content of input file {unified_input_file_id}: " + f"{LITELLM_EXECUTED_BATCH_UPLOAD_GUIDANCE}", + ) + try: + backend: Final = self.storage_backend_factory(stored.storage_backend, prisma_client=self.prisma_client) + return await backend.download_file(stored.storage_url) + except ValueError as e: + raise batch_http_error(400, str(e)) + + async def _run(self, run: _BatchRun) -> None: + try: + await self._execute(run) + except Exception as e: # noqa: BLE001 # whatever fails, the batch must end up marked failed + verbose_proxy_logger.exception("LiteLLM-executed batch %s failed: %s", run.unified_batch_id, e) + error: Final = BatchError(message=str(e), code="internal_error") + errors: Final = Errors(data=[error], object="list") # mutable-ok: Errors.data is typed as a list + try: + await self._advance(run, "failed", MappingProxyType({"errors": errors})) + except Exception as advance_error: # noqa: BLE001 # a failed status write is logged, never raised + verbose_proxy_logger.exception( + "LiteLLM-executed batch %s could not be marked failed: %s", run.unified_batch_id, advance_error + ) + + async def _execute(self, run: _BatchRun) -> None: + await self._advance(run, "in_progress") + watch: Final = _CancelWatch(lambda: self._load_status(run.unified_batch_id), _CANCEL_POLL_SECONDS) + semaphore: Final = asyncio.Semaphore(self.concurrency) + results: Final = await asyncio.gather(*(self._run_row(run, line, watch, semaphore) for line in run.lines)) + outcomes: Final = tuple(outcome for outcome in results if outcome is not None) + await self._advance(run, "finalizing") + succeeded: Final = tuple(outcome for outcome in outcomes if outcome.succeeded) + failed: Final = tuple(outcome for outcome in outcomes if not outcome.succeeded) + output_file_id: Final = await self._upload_results(run, "output", succeeded) + error_file_id: Final = await self._upload_results(run, "error", failed) + request_counts: Final = BatchRequestCounts(completed=len(succeeded), failed=len(failed), total=len(run.lines)) + await self._advance( + run, + "completed", + MappingProxyType( + {"output_file_id": output_file_id, "error_file_id": error_file_id, "request_counts": request_counts} + ), + ) + + async def _run_row( + self, run: _BatchRun, line: BatchInputLine, watch: _CancelWatch, semaphore: asyncio.Semaphore + ) -> RowOutcome | None: + async with semaphore: + if await watch.cancelling(): + return None + try: + body: Final = await self._dispatch(run, line) + except Exception as e: # noqa: BLE001 # a provider error becomes the row's error line, never a crashed batch + return RowOutcome( + custom_id=line.custom_id, status_code=_status_code_of(e), body=_error_body(e), succeeded=False + ) + return RowOutcome(custom_id=line.custom_id, status_code=200, body=body, succeeded=True) + + async def _dispatch(self, run: _BatchRun, line: BatchInputLine) -> Mapping[str, object]: + params: Final = MappingProxyType({**line.body, "model": run.model, "metadata": self._row_metadata(run)}) + return _dump(await self._router_call(run.endpoint)(**params)) + + def _router_call(self, endpoint: BatchEndpoint) -> _RouterCall: + method: Final[object] = getattr(self.llm_router, _router_method_name(endpoint), None) + if not isinstance(method, _RouterCall): + raise TypeError(f"the router has no callable for {endpoint}") + return method + + def _row_metadata(self, run: _BatchRun) -> dict[str, object]: # mutable-ok: router updates metadata in place + return { # mutable-ok: the router updates request metadata in place + **LiteLLMProxyRequestSetup.get_sanitized_user_information_from_key(run.user_api_key_dict), + "user_api_key": run.user_api_key_dict.api_key, + "user_api_end_user_max_budget": run.user_api_key_dict.end_user_max_budget, + "tags": list(run.request_tags), # mutable-ok: litellm types request tags as a list + "batch_id": run.unified_batch_id, + } + + async def _upload_results( + self, run: _BatchRun, kind: Literal["output", "error"], outcomes: Sequence[RowOutcome] + ) -> str | None: + if not outcomes: + return None + content: Final = "".join(f"{json.dumps(_result_line(outcome))}\n" for outcome in outcomes).encode() + file_data: Final[ExtractedFileData] = { + "filename": f"{run.llm_batch_id}_{kind}.jsonl", + "content": content, + "content_type": "application/jsonl", + "headers": _NO_HEADERS, + } + file_object: Final = await self.upload_result_file( + file_data=file_data, + target_storage=LITELLM_DB_STORAGE_BACKEND_NAME, + target_model_names=(run.model,), + purpose="batch_output", + proxy_logging_obj=self.proxy_logging_obj, + user_api_key_dict=run.user_api_key_dict, + prisma_client=self.prisma_client, + ) + return file_object.id + + async def _advance(self, run: _BatchRun, requested: BatchStatus, fields: Mapping[str, object] = _NO_FIELDS) -> None: + current: Final = await self._load_batch(run.unified_batch_id) + if current is None: + raise RuntimeError(f"Batch {run.unified_batch_id} is no longer stored") + status: Final = _resolve_transition(current.status, requested) + updated: Final = current.model_copy( + update=MappingProxyType({**fields, "status": status, f"{status}_at": int(time.time())}) + ) + await self._store(updated, run.user_api_key_dict) + + async def _store(self, batch: LiteLLMBatch, user_api_key_dict: UserAPIKeyAuth) -> None: + await self.managed_files.store_unified_object_id( + unified_object_id=batch.id, + file_object=batch, + litellm_parent_otel_span=user_api_key_dict.parent_otel_span, + model_object_id=_llm_batch_id_of(batch.id), + file_purpose="batch", + user_api_key_dict=user_api_key_dict, + create_if_missing=False, + ) + + async def _find_row(self, unified_batch_id: str) -> "prisma_models.LiteLLM_ManagedObjectTable | None": + return await ManagedObjectRepository(self.prisma_client).table.find_first( + where={"unified_object_id": unified_batch_id} # mutable-ok: Prisma filter + ) + + async def _load_batch(self, unified_batch_id: str) -> LiteLLMBatch | None: + row: Final = await self._find_row(unified_batch_id) + return None if row is None or not row.file_object else _batch_of(row.file_object) + + async def _load_status(self, unified_batch_id: str) -> str | None: + row: Final = await self._find_row(unified_batch_id) + return row.status if row is not None else None + + +def _record_batch_created(model: str, provider: str, user_api_key_dict: UserAPIKeyAuth) -> None: + prometheus_logger: Final = PrometheusLogger.get_instance() + if prometheus_logger is None: + return + prometheus_logger.record_managed_batch_created( + model=model, + api_provider=provider, + user=user_api_key_dict.user_id or "", + user_email=user_api_key_dict.user_email or "", + api_key_alias=user_api_key_dict.key_alias or "", + ) diff --git a/litellm/proxy/openai_files_endpoints/common_utils.py b/litellm/proxy/openai_files_endpoints/common_utils.py index 38a907892b4..c45d08c5546 100644 --- a/litellm/proxy/openai_files_endpoints/common_utils.py +++ b/litellm/proxy/openai_files_endpoints/common_utils.py @@ -38,6 +38,7 @@ if TYPE_CHECKING: FILE_LIST_CONTINUATION_CHUNK_SIZE: Final = 500 BATCH_CREATE_HIDDEN_PARAM: Final = "batch_create" +LITELLM_EXECUTED_BATCH_ID_PREFIX: Final = "litellm_batch_" def validate_file_list_limit(limit: int | None) -> None: @@ -179,6 +180,10 @@ def get_batch_id_from_unified_batch_id(file_id: str) -> str: return re.split(r"[;,]", batch_id, maxsplit=1)[0] +def is_litellm_executed_batch(decoded_unified_batch_id: str) -> bool: + return get_batch_id_from_unified_batch_id(decoded_unified_batch_id).startswith(LITELLM_EXECUTED_BATCH_ID_PREFIX) + + def encode_file_id_with_model(file_id: str, model: str, id_type: Literal["file", "batch"] = "file") -> str: """ Encode a file/batch ID with model routing information. diff --git a/litellm/proxy/openai_files_endpoints/files_endpoints.py b/litellm/proxy/openai_files_endpoints/files_endpoints.py index ae6e222a863..ad869100fb9 100644 --- a/litellm/proxy/openai_files_endpoints/files_endpoints.py +++ b/litellm/proxy/openai_files_endpoints/files_endpoints.py @@ -7,7 +7,7 @@ import asyncio import traceback -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from typing import Any, BinaryIO, Final, TypedDict, cast, get_args import httpx @@ -32,10 +32,12 @@ from litellm.litellm_core_utils.cloud_storage_security import ( is_managed_cloud_storage_uri, ) from litellm.litellm_core_utils.core_helpers import get_or_create_metadata_bucket +from litellm.llms.base_llm.files.litellm_db_storage_backend import LITELLM_DB_STORAGE_BACKEND_NAME from litellm.llms.base_llm.files.transformation import BaseFileEndpoints from litellm.llms.base_llm.managed_resources.isolation import build_list_page from litellm.proxy._types import * from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.batches_endpoints.litellm_executed_batches import resolve_litellm_executed_provider from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing from litellm.proxy.common_utils.http_parsing_utils import ( _read_request_body, @@ -86,7 +88,7 @@ from litellm.proxy.openai_files_endpoints.general_upload_validation import ( coerce_optional_str_list_setting, raise_upload_validation_failure, ) -from litellm.proxy.utils import ProxyLogging, is_known_model +from litellm.proxy.utils import PrismaClient, ProxyLogging, is_known_model from litellm.repositories.table_repositories import ManagedFileRepository from litellm.router import Router from litellm.types.llms.openai import ( @@ -99,6 +101,39 @@ from litellm.types.llms.openai import ( router: Final = APIRouter() + +def _litellm_executed_batch_input_model( + llm_router: Router | None, + purpose: OpenAIFilesPurpose, + model: str | None, + target_model_names_list: Sequence[str], + team_id: str | None, +) -> str | None: + if purpose != "batch" or llm_router is None: + return None + candidates: Final = (model,) if model is not None else tuple(target_model_names_list) + executed: Final = tuple( + candidate + for candidate in candidates + if resolve_litellm_executed_provider(llm_router, candidate, team_id) is not None + ) + match executed: + case (): + return None + case (only,) if len(candidates) == 1: + return only + case _: + raise ProxyException( + message=( + f"LiteLLM runs batches for {', '.join(executed)} itself and keeps their input files, so a batch " + f"input file can target only that one model; got target_model_names={', '.join(candidates)}" + ), + type="invalid_request_error", + param="target_model_names", + code=400, + ) + + _MAX_BATCH_FILE_SIZE_MB_ADAPTER: Final = TypeAdapter(int | None) _LISTED_FILES_ADAPTER: Final = TypeAdapter(list[OpenAIFileObject]) @@ -244,30 +279,30 @@ async def route_create_file( 5. Else -> use custom_llm_provider with files_settings """ - # Handle custom storage backend - if target_storage and target_storage != "default": + executed_model: Final = _litellm_executed_batch_input_model( + llm_router, purpose, model, target_model_names_list, user_api_key_dict.team_id + ) + explicit_storage: Final = target_storage if target_storage and target_storage != "default" else None + storage: Final = explicit_storage or (LITELLM_DB_STORAGE_BACKEND_NAME if executed_model is not None else None) + if storage is not None: from litellm.litellm_core_utils.prompt_templates.common_utils import ( extract_file_data, ) from litellm.proxy.openai_files_endpoints.storage_backend_service import ( StorageBackendFileService, ) + from litellm.proxy.proxy_server import prisma_client - # Extract file data - file_data: Final = extract_file_data(cast(Any, _create_file_request.get("file"))) - - # Use storage backend service to handle upload - file_object: Final = await StorageBackendFileService.upload_file_to_storage_backend( - file_data=file_data, - target_storage=target_storage, - target_model_names=target_model_names_list, + return await StorageBackendFileService.upload_file_to_storage_backend( + file_data=extract_file_data(cast(Any, _create_file_request.get("file"))), + target_storage=storage, + target_model_names=(executed_model,) if executed_model is not None else target_model_names_list, purpose=purpose, proxy_logging_obj=proxy_logging_obj, user_api_key_dict=user_api_key_dict, + prisma_client=prisma_client, ) - return file_object - # NEW: Handle model-based routing (no DB required) if model is not None: # Get credentials from model_list via router @@ -847,7 +882,7 @@ async def get_file_content( # Check if file is stored in a storage backend (check DB) if hasattr(managed_files_obj, "prisma_client") and getattr(managed_files_obj, "prisma_client", None): - prisma_client: Final = getattr(managed_files_obj, "prisma_client") + prisma_client: Final[PrismaClient] = getattr(managed_files_obj, "prisma_client") db_file: Final = await ManagedFileRepository(prisma_client).table.find_first( where={"unified_file_id": file_id} ) @@ -862,7 +897,7 @@ async def get_file_content( try: # Get storage backend (uses same env vars as callback) - storage_backend: Final = get_storage_backend(storage_backend_name) + storage_backend: Final = get_storage_backend(storage_backend_name, prisma_client=prisma_client) file_content: Final = await storage_backend.download_file(storage_url) # Return file content diff --git a/litellm/proxy/openai_files_endpoints/storage_backend_service.py b/litellm/proxy/openai_files_endpoints/storage_backend_service.py index e766f335071..b4a36336c22 100644 --- a/litellm/proxy/openai_files_endpoints/storage_backend_service.py +++ b/litellm/proxy/openai_files_endpoints/storage_backend_service.py @@ -7,7 +7,7 @@ storage backends (e.g., Azure Blob Storage) and managing associated metadata. import base64 import time -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from typing import Any, Final, cast from litellm._logging import verbose_proxy_logger @@ -15,7 +15,7 @@ from litellm._uuid import uuid as uuid_module from litellm.llms.base_llm.files.storage_backend_factory import get_storage_backend from litellm.llms.base_llm.files.transformation import BaseFileEndpoints from litellm.proxy._types import ProxyException, UserAPIKeyAuth -from litellm.proxy.utils import ProxyLogging +from litellm.proxy.utils import PrismaClient, ProxyLogging from litellm.types.llms.openai import OpenAIFileObject, OpenAIFilesPurpose from litellm.types.utils import SpecialEnums @@ -35,21 +35,23 @@ class StorageBackendFileService: async def upload_file_to_storage_backend( file_data: Mapping[str, Any], target_storage: str, - target_model_names: list[str], + target_model_names: Sequence[str], purpose: OpenAIFilesPurpose, proxy_logging_obj: ProxyLogging, user_api_key_dict: UserAPIKeyAuth, + prisma_client: PrismaClient | None = None, ) -> OpenAIFileObject: """ Upload a file to a storage backend and create a file object. Args: file_data: File data dictionary from extract_file_data() - target_storage: Storage backend name (e.g., "azure_storage") + target_storage: Storage backend name (e.g., "azure_storage", "litellm_db") target_model_names: List of model names for managed files purpose: File purpose (e.g., "user_data", "batch") proxy_logging_obj: Proxy logging object for accessing hooks user_api_key_dict: User API key authentication data + prisma_client: The proxy's database client, required by the "litellm_db" backend Returns: OpenAIFileObject: Created file object with storage metadata @@ -59,7 +61,7 @@ class StorageBackendFileService: """ # Get storage backend instance try: - storage_backend: Final = get_storage_backend(target_storage) + storage_backend: Final = get_storage_backend(target_storage, prisma_client=prisma_client) except ValueError as e: raise ProxyException( message=str(e), @@ -164,7 +166,7 @@ class StorageBackendFileService: @staticmethod def _create_unified_file_id( file_type: str, - target_model_names: list[str], + target_model_names: Sequence[str], file_id: str, ) -> str: """ @@ -194,7 +196,7 @@ class StorageBackendFileService: async def _store_in_managed_files( file_object: OpenAIFileObject, file_data: Mapping[str, Any], - target_model_names: list[str], + target_model_names: Sequence[str], target_storage: str, storage_url: str, proxy_logging_obj: ProxyLogging, diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index 91b59e56906..c4606796ebf 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -1107,6 +1107,12 @@ model LiteLLM_ManagedObjectTable { // for batches or finetuning jobs which use t @@index([team_id, created_at(sort: Desc)]) } +model LiteLLM_ManagedFileContentTable { + id String @id @default(uuid()) + content Bytes + created_at DateTime @default(now()) +} + model LiteLLM_ManagedVectorStoreTable { id String @id @default(uuid()) unified_resource_id String @unique // The base64 encoded unified vector store ID diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index 632efcc3c4f..d9e8b545765 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -512,6 +512,7 @@ class CreateBatchRequest(TypedDict, total=False): class LiteLLMBatchCreateRequest(CreateBatchRequest, total=False): model: str + disable_fallbacks: ReadOnly[bool] class RetrieveBatchRequest(TypedDict, total=False): diff --git a/litellm/types/utils.py b/litellm/types/utils.py index c63d971b89b..12535493eb5 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -4143,6 +4143,8 @@ FILE_CONTENT_STREAMING_PROVIDERS: Final[frozenset[str]] = frozenset( {*OPENAI_COMPATIBLE_BATCH_AND_FILES_PROVIDERS, LlmProviders.VERTEX_AI.value} ) +LITELLM_EXECUTED_BATCH_PROVIDERS: Final[frozenset[str]] = frozenset({LlmProviders.HOSTED_VLLM.value}) + ListBatchesSupportedProvider = Literal["openai", "azure", "hosted_vllm", "litellm_proxy", "vertex_ai"] LIST_BATCHES_SUPPORTED_PROVIDERS: Final[frozenset[str]] = frozenset(get_args(ListBatchesSupportedProvider)) diff --git a/schema.prisma b/schema.prisma index 91b59e56906..c4606796ebf 100644 --- a/schema.prisma +++ b/schema.prisma @@ -1107,6 +1107,12 @@ model LiteLLM_ManagedObjectTable { // for batches or finetuning jobs which use t @@index([team_id, created_at(sort: Desc)]) } +model LiteLLM_ManagedFileContentTable { + id String @id @default(uuid()) + content Bytes + created_at DateTime @default(now()) +} + model LiteLLM_ManagedVectorStoreTable { id String @id @default(uuid()) unified_resource_id String @unique // The base64 encoded unified vector store ID diff --git a/tests/e2e/batches/test_batches_e2e.py b/tests/e2e/batches/test_batches_e2e.py index eff8f297f25..9b3c06d9a1b 100644 --- a/tests/e2e/batches/test_batches_e2e.py +++ b/tests/e2e/batches/test_batches_e2e.py @@ -1160,62 +1160,151 @@ def _vllm_params(api_base: str, api_key: str | None, model_id: str) -> LiteLLMPa ) -class TestHostedVllmBatch: - """hosted_vllm file upload + batch create (OpenAI-compatible path, LIT-3266). +HOSTED_VLLM_DEFAULT_MODEL = "Qwen/Qwen2.5-0.5B-Instruct" +HOSTED_VLLM_BAD_LINE_CUSTOM_ID = "req-bad" - hosted_vllm is in OPENAI_COMPATIBLE_BATCH_AND_FILES_PROVIDERS, so /v1/files - and /v1/batches route through the OpenAI handler against the deployment's - api_base. Skipped for now: it needs a live vLLM (or OpenAI-compatible) server - exposing the files/batches APIs (HOSTED_VLLM_API_BASE), which the e2e - environment does not currently provision. + +def _hosted_vllm_deployment(client: BatchClient, resources: ResourceManager) -> str: + api_base = os.environ.get("HOSTED_VLLM_API_BASE") + if api_base is None: + pytest.skip("set HOSTED_VLLM_API_BASE (the live vLLM server this deployment targets)") + api_key = (os.environ.get("HOSTED_VLLM_API_KEY") or "").strip() or None + model_id = (os.environ.get("HOSTED_VLLM_MODEL") or HOSTED_VLLM_DEFAULT_MODEL).strip() + proxy_name = batch_model_name("hosted-vllm-batch") + model_row_id = client.create_model(proxy_name, _vllm_params(api_base, api_key, model_id)) + resources.defer(lambda: client.delete_model(model_row_id)) + return proxy_name + + +def _upload_hosted_vllm_input( + client: BatchClient, content: bytes, *, proxy_name: str, key: str, upload_route: str +) -> Result[FileObject]: + if upload_route == "model_query": + return client.upload_file(content=content, form=FileUploadForm(purpose="batch"), model=proxy_name, key=key) + return client.upload_file( + content=content, form=FileUploadForm(purpose="batch", target_model_names=proxy_name), key=key + ) + + +def _jsonl_with_a_failing_line(model: str) -> bytes: + bad_line = { + "custom_id": HOSTED_VLLM_BAD_LINE_CUSTOM_ID, + "method": "POST", + "url": "/v1/chat/completions", + "body": {"model": model, "messages": [{"role": "user", "content": "ping"}], "max_tokens": -1}, + } + return render_jsonl(model) + (json.dumps(bad_line) + "\n").encode() + + +def _download_managed_file(client: BatchClient, file_id: str, *, key: str) -> list[str]: + downloaded = client.proxy.transport.download( + f"/v1/files/{file_id}/content", headers=client.proxy.transport.bearer(key) + ) + assert downloaded.status_code == 200, ( + f"file content must be 200, got {downloaded.status_code}: {downloaded.body[:300]}" + ) + return downloaded.body.strip().splitlines() + + +class TestHostedVllmBatch: + """hosted_vllm file upload + batch execution (LIT-5739). + + vLLM implements neither /v1/files nor /v1/batches, so LiteLLM keeps the batch + input in its own database, runs every line through the deployment's + /v1/chat/completions itself, and serves the batch plus its output and error + files from that database under the creating key. Needs a live vLLM server + (HOSTED_VLLM_API_BASE), which the default e2e stack does not provision, so + the cases skip without it. """ - @pytest.mark.skip( - reason="hosted_vllm batch/files needs a live vLLM server (HOSTED_VLLM_API_BASE) " - "not provisioned in the e2e environment; re-enable when available (LIT-3266)" - ) + @pytest.mark.parametrize("upload_route", ["target_model_names", "model_query"]) @pytest.mark.covers( "llm.batches.hosted_vllm.basic.nonstream.works", "llm.files.hosted_vllm.upload.nonstream.works", exercised_on=["batches", "files"], ) - def test_unified_file_and_batch_create( - self, client: BatchClient, resources: ResourceManager + def test_batch_runs_to_completion_with_a_downloadable_output( + self, client: BatchClient, resources: ResourceManager, upload_route: str ) -> None: - api_base = os.environ["HOSTED_VLLM_API_BASE"] - api_key = (os.environ.get("HOSTED_VLLM_API_KEY") or "").strip() or None - model_id = ( - os.environ.get("HOSTED_VLLM_MODEL") or "meta-llama/Llama-3.2-3B-Instruct" - ).strip() - proxy_name = batch_model_name("hosted-vllm-batch") - - model_row_id = client.create_model( - proxy_name, _vllm_params(api_base, api_key, model_id) - ) - resources.defer(lambda: client.delete_model(model_row_id)) + proxy_name = _hosted_vllm_deployment(client, resources) key = resources.key() file = unwrap( - client.upload_file( - content=render_jsonl(model_id), - form=FileUploadForm(purpose="batch", target_model_names=proxy_name), - key=key, + _upload_hosted_vllm_input( + client, render_jsonl(proxy_name), proxy_name=proxy_name, key=key, upload_route=upload_route ) ) resources.defer(lambda: cleanup_file(client, file.id, key=key)) assert_file_object(file, provider="hosted_vllm") + assert is_managed_id(file.id), f"hosted_vllm batch input must stay in LiteLLM, got file id {file.id!r}" created = client.create_batch(body=BatchCreateBody(input_file_id=file.id), key=key) require_successful_call(created) batch = BatchObject.model_validate_json(created.body) - resources.defer(lambda: cleanup_batch(client, batch.id, key=key)) - - assert batch.id, f"hosted_vllm create returned no batch id: {created.body[:200]}" - assert batch.status in CREATED_BATCH_STATUSES, ( - f"hosted_vllm batch has non-transitional status {batch.status!r}" - ) + resources.defer(lambda: cleanup_batch(client, batch.id, key=key, delete_output_files=True)) + assert is_managed_id(batch.id), f"hosted_vllm batch must be LiteLLM-managed, got {batch.id!r}" + assert batch.status in CREATED_BATCH_STATUSES, f"hosted_vllm batch has non-transitional status {batch.status!r}" assert_batch_object(batch) + finished = _poll_until_terminal(client, batch.id, key) + assert finished.status == "completed", f"hosted_vllm batch ended {finished.status!r}: {finished.errors!r}" + assert finished.output_file_id, "completed hosted_vllm batch has no output_file_id" + assert finished.error_file_id is None, f"all lines succeeded but error_file_id={finished.error_file_id!r}" + + output_lines = _download_managed_file(client, finished.output_file_id, key=key) + assert len(output_lines) == 1, f"one input line must yield one output line, got {output_lines!r}" + first_line = BatchOutputLine.model_validate_json(output_lines[0]) + assert first_line.custom_id == "req-1", f"output line lost its custom_id: {output_lines[0][:300]}" + assert first_line.response.status_code == 200, f"batch output line reports failure: {output_lines[0][:400]}" + assert first_line.response.body is not None and first_line.response.body.choices, ( + "batch output line has no choices" + ) + + rows = client.proxy.poll_logs_for_key( + key, predicate=lambda found: any(row.call_type == "acompletion" for row in found) + ) + line_rows = [row for row in rows if row.call_type == "acompletion"] + assert line_rows, f"the batch line's chat call was not logged under the creating key: {rows!r}" + assert all(row.custom_llm_provider == "hosted_vllm" for row in line_rows), ( + f"batch line rows must be attributed to hosted_vllm: {line_rows!r}" + ) + + @pytest.mark.covers("llm.batches.hosted_vllm.basic.nonstream.works", exercised_on=["batches", "files"]) + def test_failing_line_lands_in_the_error_file_not_the_batch_status( + self, client: BatchClient, resources: ResourceManager + ) -> None: + proxy_name = _hosted_vllm_deployment(client, resources) + key = resources.key() + + file = unwrap( + _upload_hosted_vllm_input( + client, + _jsonl_with_a_failing_line(proxy_name), + proxy_name=proxy_name, + key=key, + upload_route="target_model_names", + ) + ) + resources.defer(lambda: cleanup_file(client, file.id, key=key)) + + created = client.create_batch(body=BatchCreateBody(input_file_id=file.id), key=key) + require_successful_call(created) + batch = BatchObject.model_validate_json(created.body) + resources.defer(lambda: cleanup_batch(client, batch.id, key=key, delete_output_files=True)) + + finished = _poll_until_terminal(client, batch.id, key) + assert finished.status == "completed", f"a failing line must not fail the batch, got {finished.status!r}" + assert finished.output_file_id, "the good line must still produce an output file" + assert finished.error_file_id, "the failing line must produce an error file" + + output_lines = _download_managed_file(client, finished.output_file_id, key=key) + error_lines = _download_managed_file(client, finished.error_file_id, key=key) + assert [BatchOutputLine.model_validate_json(line).custom_id for line in output_lines] == ["req-1"] + assert len(error_lines) == 1, f"one failing line must yield one error line, got {error_lines!r}" + error_line = BatchOutputLine.model_validate_json(error_lines[0]) + assert error_line.custom_id == HOSTED_VLLM_BAD_LINE_CUSTOM_ID + assert error_line.response.status_code == 400, f"error line must carry the provider's 4xx: {error_lines[0][:400]}" + BATCH_TERMINAL_STATUSES = frozenset({"completed", "failed", "expired", "cancelled"}) FAILED_BATCH_POLL_SECONDS = 120.0 @@ -1443,6 +1532,7 @@ class BatchOutputResponse(BaseModel): class BatchOutputLine(BaseModel): + custom_id: str | None = None response: BatchOutputResponse diff --git a/tests/test_litellm/enterprise/proxy/test_managed_files_hook.py b/tests/test_litellm/enterprise/proxy/test_managed_files_hook.py index cb08e00ff65..0ae4e3a5fd8 100644 --- a/tests/test_litellm/enterprise/proxy/test_managed_files_hook.py +++ b/tests/test_litellm/enterprise/proxy/test_managed_files_hook.py @@ -1732,8 +1732,38 @@ async def test_batch_retrieve_hook_does_not_claim_attribution(): assert managed_files.store_unified_object_id.await_args.kwargs["persist_attribution"] is False +def _unified_batch_id(llm_batch_id: str) -> str: + decoded = f"litellm_proxy;model_id:my-vllm;llm_batch_id:{llm_batch_id}" + return base64.urlsafe_b64encode(decoded.encode()).decode().rstrip("=") + + @pytest.mark.asyncio -async def test_afile_delete_passes_trusted_model_credentials_to_router(): +@pytest.mark.parametrize( + "llm_batch_id, stores", + [("litellm_batch_abc", False), ("batch_abc", True)], + ids=["litellm-executed batch is left alone", "provider batch is still stored"], +) +async def test_post_call_hook_leaves_litellm_executed_batches_untouched(llm_batch_id: str, stores: bool): + managed_files = _make_managed_files_instance() + response = _make_batch_response(status="in_progress", output_file_id=None) + response.id = _unified_batch_id(llm_batch_id) + response._hidden_params = { + "unified_batch_id": response.id, + "model_id": "my-vllm", + "model_name": "hosted_vllm/qwen", + } + original_id = response.id + + returned = await managed_files.async_post_call_success_hook( + data={}, + user_api_key_dict=UserAPIKeyAuth(api_key="sk-the-poller", user_id="bob", parent_otel_span=None), + response=response, + ) + + assert returned is response + assert managed_files.store_unified_object_id.await_count == (1 if stores else 0) + if not stores: + assert response.id == original_id """ afile_delete must hand the deployment's credential snapshot to the router call, since Bedrock validates the s3:// file id against the bucket in it. @@ -1743,6 +1773,7 @@ async def test_afile_delete_passes_trusted_model_credentials_to_router(): managed_files = _make_managed_files_instance() unified_file_id = "unified-file-id" s3_uri = "s3://my-bucket/litellm-bedrock-files/job-123/input.jsonl" + managed_files.get_unified_file_id = AsyncMock(return_value=None) managed_files.get_model_file_id_mapping = AsyncMock(return_value={unified_file_id: {"model-123": s3_uri}}) managed_files.delete_unified_file_id = AsyncMock(return_value=_make_file_object(unified_file_id)) @@ -1809,6 +1840,7 @@ async def test_afile_delete_bedrock_unified_id_end_to_end(monkeypatch): managed_files = _make_managed_files_instance() unified_file_id = "unified-file-id" s3_uri = "s3://my-bucket/litellm-bedrock-files/job-123/input.jsonl" + managed_files.get_unified_file_id = AsyncMock(return_value=None) managed_files.get_model_file_id_mapping = AsyncMock(return_value={unified_file_id: {"model-123": s3_uri}}) managed_files.delete_unified_file_id = AsyncMock(return_value=_make_file_object(unified_file_id)) @@ -1827,3 +1859,104 @@ async def test_afile_delete_bedrock_unified_id_end_to_end(monkeypatch): assert response.id == unified_file_id assert response.model_dump() == {"id": unified_file_id, "object": "file", "deleted": True} managed_files.delete_unified_file_id.assert_awaited_once_with(unified_file_id, None) + + +@pytest.mark.asyncio +async def test_afile_delete_storage_backed_row_deletes_stored_content_not_provider_files(): + from litellm_enterprise.proxy.hooks.managed_files import _PROXY_LiteLLMManagedFiles + from openai.types import FileDeleted + + from litellm.caching import DualCache + from litellm.models.managed_files import LiteLLM_ManagedFileTable + + storage_url = "litellm_db://content-row-1" + unified_file_id = _managed_deletion_file_id(storage_url) + row = LiteLLM_ManagedFileTable( + unified_file_id=unified_file_id, + model_mappings={"vllm-batch": storage_url}, + flat_model_file_ids=[storage_url], + file_object=_make_file_object(unified_file_id), + storage_backend="litellm_db", + storage_url=storage_url, + ) + file_table = MagicMock(find_first=AsyncMock(return_value=row), delete=AsyncMock()) + content_table = MagicMock(delete=AsyncMock()) + managed_files = _PROXY_LiteLLMManagedFiles( + internal_usage_cache=DualCache(), + prisma_client=MagicMock( + db=MagicMock(litellm_managedfiletable=file_table, litellm_managedfilecontenttable=content_table) + ), + ) + router = MagicMock( + get_deployment_credentials_with_provider=MagicMock(return_value=None), + afile_delete=AsyncMock(), + ) + + response = await managed_files.afile_delete( + file_id=unified_file_id, + litellm_parent_otel_span=None, + llm_router=router, + ) + + content_table.delete.assert_awaited_once_with(where={"id": "content-row-1"}) + router.afile_delete.assert_not_awaited() + file_table.delete.assert_awaited_once_with(where={"unified_file_id": unified_file_id}) + assert response == FileDeleted(id=unified_file_id, object="file", deleted=True) + + +@pytest.mark.asyncio +async def test_store_unified_object_id_batch_processed_is_written_only_when_asked(): + managed_files, mock_prisma = _make_object_store_instance() + upsert = mock_prisma.db.litellm_managedobjecttable.upsert + creator = UserAPIKeyAuth(api_key="sk-creator", user_id="alice", team_id="team-alpha", parent_otel_span=None) + + await managed_files.store_unified_object_id( + unified_object_id="uoi-processed", + file_object=_make_batch_response(status="completed"), + litellm_parent_otel_span=None, + model_object_id="batch-processed", + file_purpose="batch", + user_api_key_dict=creator, + batch_processed=True, + ) + await managed_files.store_unified_object_id( + unified_object_id="uoi-default", + file_object=_make_batch_response(status="completed"), + litellm_parent_otel_span=None, + model_object_id="batch-default", + file_purpose="batch", + user_api_key_dict=creator, + ) + + processed_create, default_create = (call.kwargs["data"]["create"] for call in upsert.await_args_list) + assert processed_create["batch_processed"] is True + assert default_create["batch_processed"] is False + + +@pytest.mark.asyncio +async def test_store_unified_file_id_caches_the_storage_location_the_db_row_gets(): + from litellm_enterprise.proxy.hooks.managed_files import _PROXY_LiteLLMManagedFiles + + from litellm.caching import DualCache + + file_table = MagicMock(upsert=AsyncMock(), find_first=AsyncMock(side_effect=AssertionError("cache miss"))) + managed_files = _PROXY_LiteLLMManagedFiles( + internal_usage_cache=DualCache(), + prisma_client=MagicMock(db=MagicMock(litellm_managedfiletable=file_table)), + ) + stored = _make_file_object("file-kept").model_copy(update={"purpose": "batch"}) + stored._hidden_params = {"storage_backend": "litellm_db", "storage_url": "litellm_db://content-row-1"} + + await managed_files.store_unified_file_id( + file_id="unified-kept", + file_object=stored, + litellm_parent_otel_span=None, + model_mappings={"vllm-batch": "litellm_db://content-row-1"}, + user_api_key_dict=_make_user_api_key_dict(), + ) + cached = await managed_files.get_unified_file_id("unified-kept") + + assert cached is not None + assert (cached.storage_backend, cached.storage_url) == ("litellm_db", "litellm_db://content-row-1") + create_data = file_table.upsert.await_args.kwargs["data"]["create"] + assert (create_data["storage_backend"], create_data["storage_url"]) == ("litellm_db", "litellm_db://content-row-1") diff --git a/tests/test_litellm/llms/base_llm/files/test_litellm_db_storage_backend.py b/tests/test_litellm/llms/base_llm/files/test_litellm_db_storage_backend.py new file mode 100644 index 00000000000..fabcb340a48 --- /dev/null +++ b/tests/test_litellm/llms/base_llm/files/test_litellm_db_storage_backend.py @@ -0,0 +1,92 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest +from prisma import Base64 +from prisma.errors import RecordNotFoundError + +from litellm.llms.base_llm.files.litellm_db_storage_backend import ( + LITELLM_DB_STORAGE_URL_PREFIX, + LiteLLMDbStorageBackend, + storage_url_to_row_id, +) + + +def _backend_with_table(): + table = MagicMock(create=AsyncMock(), find_unique=AsyncMock(), delete=AsyncMock()) + prisma_client = MagicMock(db=MagicMock(litellm_managedfilecontenttable=table)) + return LiteLLMDbStorageBackend(prisma_client), table + + +@pytest.mark.asyncio +async def test_upload_stores_bytes_and_returns_prefixed_row_id(): + backend, table = _backend_with_table() + table.create.return_value = SimpleNamespace(id="row-1") + content = b"\x00\x01binary jsonl\n" + + storage_url = await backend.upload_file(file_content=content, filename="input.jsonl", content_type="text/plain") + + assert storage_url == f"{LITELLM_DB_STORAGE_URL_PREFIX}row-1" + stored = table.create.await_args.kwargs["data"]["content"] + assert isinstance(stored, Base64) + assert stored.decode() == content + + +@pytest.mark.asyncio +async def test_download_returns_exact_bytes_of_the_row(): + backend, table = _backend_with_table() + content = b'{"custom_id": "1"}\n' + table.find_unique.return_value = SimpleNamespace(id="row-1", content=Base64.encode(content)) + + downloaded = await backend.download_file(f"{LITELLM_DB_STORAGE_URL_PREFIX}row-1") + + assert downloaded == content + table.find_unique.assert_awaited_once_with(where={"id": "row-1"}) + + +@pytest.mark.asyncio +async def test_download_missing_row_raises_value_error_naming_the_url(): + backend, table = _backend_with_table() + table.find_unique.return_value = None + storage_url = f"{LITELLM_DB_STORAGE_URL_PREFIX}missing" + + with pytest.raises(ValueError, match="missing"): + await backend.download_file(storage_url) + + +@pytest.mark.asyncio +async def test_download_rejects_url_without_prefix_before_touching_the_db(): + backend, table = _backend_with_table() + + with pytest.raises(ValueError, match="https://elsewhere/blob"): + await backend.download_file("https://elsewhere/blob") + + table.find_unique.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_delete_removes_the_parsed_row(): + backend, table = _backend_with_table() + + await backend.delete_file(f"{LITELLM_DB_STORAGE_URL_PREFIX}row-1") + + table.delete.assert_awaited_once_with(where={"id": "row-1"}) + + +@pytest.mark.asyncio +async def test_delete_tolerates_a_row_that_is_already_gone(): + backend, table = _backend_with_table() + table.delete.side_effect = RecordNotFoundError({"user_facing_error": {"message": "gone"}}) + + await backend.delete_file(f"{LITELLM_DB_STORAGE_URL_PREFIX}row-1") + + table.delete.assert_awaited_once_with(where={"id": "row-1"}) + + +def test_storage_url_to_row_id_round_trips(): + assert storage_url_to_row_id(f"{LITELLM_DB_STORAGE_URL_PREFIX}abc-123") == "abc-123" + + +def test_storage_url_to_row_id_rejects_foreign_urls(): + with pytest.raises(ValueError, match="s3://bucket/key"): + storage_url_to_row_id("s3://bucket/key") diff --git a/tests/test_litellm/llms/base_llm/files/test_storage_backend_factory.py b/tests/test_litellm/llms/base_llm/files/test_storage_backend_factory.py new file mode 100644 index 00000000000..39b0adb56fc --- /dev/null +++ b/tests/test_litellm/llms/base_llm/files/test_storage_backend_factory.py @@ -0,0 +1,28 @@ +from unittest.mock import MagicMock + +import pytest + +from litellm.llms.base_llm.files.litellm_db_storage_backend import ( + LITELLM_DB_STORAGE_BACKEND_NAME, + LiteLLMDbStorageBackend, +) +from litellm.llms.base_llm.files.storage_backend_factory import get_storage_backend + + +def test_litellm_db_backend_is_built_on_the_given_prisma_client(): + prisma_client = MagicMock() + + backend = get_storage_backend(LITELLM_DB_STORAGE_BACKEND_NAME, prisma_client=prisma_client) + + assert isinstance(backend, LiteLLMDbStorageBackend) + assert backend._table is prisma_client.db.litellm_managedfilecontenttable + + +def test_litellm_db_backend_without_a_database_is_rejected(): + with pytest.raises(ValueError, match="database-connected proxy"): + get_storage_backend(LITELLM_DB_STORAGE_BACKEND_NAME) + + +def test_unknown_backend_is_still_rejected(): + with pytest.raises(ValueError, match="Unsupported storage backend type: nope"): + get_storage_backend("nope", prisma_client=MagicMock()) diff --git a/tests/test_litellm/proxy/batches_endpoints/test_endpoints.py b/tests/test_litellm/proxy/batches_endpoints/test_endpoints.py index d9bfb3fe3da..e655438a672 100644 --- a/tests/test_litellm/proxy/batches_endpoints/test_endpoints.py +++ b/tests/test_litellm/proxy/batches_endpoints/test_endpoints.py @@ -51,7 +51,7 @@ from litellm.proxy.openai_files_endpoints.common_utils import ( from litellm.proxy.utils import ProxyLogging from litellm.router import Router from litellm.types.llms.openai import BatchJobStatus -from litellm.types.utils import CredentialItem, LiteLLMBatch +from litellm.types.utils import CredentialItem, LiteLLMBatch, SpecialEnums from fastapi import Request, Response @@ -73,6 +73,12 @@ CREDS: Dict[str, Dict[str, str]] = { "api_base": "https://vertex.test", "model": "vertex_ai/gemini-2.0", }, + "my-vllm": { + "custom_llm_provider": "hosted_vllm", + "api_key": "sk-vllm", + "api_base": "http://vllm.test/v1", + "model": "hosted_vllm/qwen", + }, } # A real model-encoded file id: decodes to "azure/gpt-4o", strips to "file-original123". @@ -161,9 +167,10 @@ class Harness: return dict(self.router_acreate.call_args.kwargs) -def _creds_lookup(*, model_id: str) -> Dict[str, str]: - # KeyError on an unknown/hardcoded model_id - the bug cannot hide. - return dict(CREDS[model_id]) +def _creds_lookup(*, model_id: str, team_id: str | None = None) -> dict[str, str] | None: + # An unknown/hardcoded model_id resolves to None exactly like the real router, + # which the endpoint turns into a 400 and a missing dispatch - the bug cannot hide. + return dict(CREDS[model_id]) if model_id in CREDS else None @pytest.fixture @@ -250,6 +257,25 @@ async def call_create( ) +@pytest.fixture +def executed_runner(): + runner = MagicMock(spec=endpoints.LiteLLMExecutedBatchRunner) + runner.create = AsyncMock(return_value=make_batch(id="litellm-executed-batch")) + runner.cancel = AsyncMock(return_value=make_batch(id="litellm-executed-batch", status="cancelling")) + factory = MagicMock(return_value=runner) + with patch.object( # test-quality-ok: the route builds its runner from proxy_server globals; the factory is the only seam + endpoints, "_litellm_executed_batch_runner", factory + ): + yield runner, factory + + +def _managed_input_file_id(model: str) -> str: + unified = SpecialEnums.LITELLM_MANAGED_FILE_COMPLETE_STR.value.format( + "application/jsonl", "managed-id", model, "file-id", "file-model-id" + ) + return base64.urlsafe_b64encode(unified.encode()).decode().rstrip("=") + + # =========================================================================== # # SCENARIO 1 - input_file_id encoded with model. The full showcase: every # assertion type from the design lives here. @@ -761,6 +787,98 @@ async def test_create__unified_file_id_legacy_row_without_storage_url_dispatches assert harness.router_kwargs()["input_file_id"] == "litellm_proxy_unified_id" +# --------------------------------------------------------------------------- # +# LiteLLM-executed batches: a unified file targeting a provider whose API has +# no /v1/batches (hosted_vllm) runs inside LiteLLM instead of being forwarded. +# --------------------------------------------------------------------------- # + + +@pytest.mark.asyncio +async def test_create__unified_executed_provider_runs_inside_litellm(harness, executed_runner): + runner, factory = executed_runner + caller = UserAPIKeyAuth(api_key="sk-test", team_id="team-vllm") + input_file_id = _managed_input_file_id("my-vllm") + set_body( + harness, + { + "input_file_id": input_file_id, + "endpoint": "/v1/chat/completions", + "completion_window": "24h", + "litellm_metadata": {"tags": ["batch-tag"]}, + }, + ) + resp = await call_create(harness, user=caller) + + harness.router_acreate.assert_not_called() + harness.litellm_acreate.assert_not_called() + harness.creds_resolver.assert_called_once_with(model_id="my-vllm", team_id="team-vllm") + factory.assert_called_once_with(harness.router, harness.logging) + runner.create.assert_awaited_once() + create_kwargs = runner.create.call_args.kwargs + assert create_kwargs["unified_input_file_id"] == input_file_id + assert create_kwargs["model"] == "my-vllm" + assert create_kwargs["provider"] == "hosted_vllm" + assert create_kwargs["request_tags"] == ("batch-tag",) + assert create_kwargs["user_api_key_dict"] is caller + assert create_kwargs["create_request"]["model"] == "my-vllm" + assert resp.id == "litellm-executed-batch" + + +@pytest.mark.asyncio +async def test_create__unified_executed_provider_without_database_400(harness): + set_body( + harness, + { + "input_file_id": _managed_input_file_id("my-vllm"), + "endpoint": "/v1/chat/completions", + "completion_window": "24h", + }, + ) + with pytest.raises(ProxyException) as exc: + await call_create(harness) + + assert exc.value.code == "400" + assert "need a database" in exc.value.message + harness.router_acreate.assert_not_called() + harness.litellm_acreate.assert_not_called() + + +@pytest.mark.asyncio +async def test_create__unified_provider_model_never_touches_executed_runner(harness, executed_runner): + runner, factory = executed_runner + set_body( + harness, + { + "input_file_id": _managed_input_file_id("azure/gpt-4o"), + "endpoint": "/v1/chat/completions", + "completion_window": "24h", + }, + ) + await call_create(harness) + + factory.assert_not_called() + runner.create.assert_not_called() + harness.creds_resolver.assert_called_once_with(model_id="azure/gpt-4o", team_id=None) + assert harness.router_kwargs()["model"] == "azure/gpt-4o" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("via", ["body", "header"]) +async def test_create__raw_file_with_executed_model_400_with_upload_guidance(harness, via): + body = {"input_file_id": "file-plain", "endpoint": "/v1/chat/completions", "completion_window": "24h"} + set_body(harness, {**body, "model": "my-vllm"} if via == "body" else body) + headers = {"x-litellm-model": "my-vllm"} if via == "header" else None + + with pytest.raises(ProxyException) as exc: + await call_create(harness, headers=headers) + + assert exc.value.code == "400" + assert "POST /v1/files" in exc.value.message + assert "x-litellm-model" in exc.value.message + harness.litellm_acreate.assert_not_called() + harness.router_acreate.assert_not_called() + + @pytest.mark.asyncio async def test_create__model_encoded_beats_unified(harness): """Precedence row: a file id that is BOTH model-encoded and (pretend) unified @@ -1141,6 +1259,11 @@ AZURE_BATCH_ID = encode_file_id_with_model("batch_orig123", "azure/gpt-4o", id_t # returns). model_id / llm_batch_id are parsed out of this by the real helpers. UNIFIED_BATCH_ID = "litellm_proxy;model_id:gpt-4o-mini;llm_batch_id:batch-raw-xyz" +# A decoded unified id of a batch LiteLLM runs itself: the llm_batch_id carries +# the litellm_batch_ prefix, so no provider holds a batch to sync with. +EXECUTED_BATCH_ID = "litellm_proxy;model_id:my-vllm;llm_batch_id:litellm_batch_abc" +EXECUTED_BATCH_B64 = base64.urlsafe_b64encode(EXECUTED_BATCH_ID.encode()).decode().rstrip("=") + @dataclass class RetrieveHarness: @@ -1546,6 +1669,33 @@ async def test_retrieve__db_non_terminal_state_syncs_with_provider(retrieve_harn assert retrieve_harness.update_batch_in_db.call_count == 1 +@pytest.mark.asyncio +@pytest.mark.parametrize("status", ["validating", "in_progress", "finalizing", "cancelling"]) +async def test_retrieve__executed_batch_served_from_db_in_every_status(retrieve_harness, status): + db_response = make_batch(id="litellm-executed-batch", status=status) + db_batch_object = MagicMock() + retrieve_harness.get_batch_from_db.return_value = (db_batch_object, db_response) + + resp = await call_retrieve(retrieve_harness, EXECUTED_BATCH_B64) + + assert resp is db_response + retrieve_harness.litellm_aretrieve.assert_not_called() + retrieve_harness.router_aretrieve.assert_not_called() + retrieve_harness.update_batch_in_db.assert_not_called() + retrieve_harness.ensure_managed_files.assert_called_once() + assert retrieve_harness.ensure_managed_files.call_args.kwargs["unified_batch_id"] == EXECUTED_BATCH_ID + + +@pytest.mark.asyncio +async def test_retrieve__executed_batch_without_db_row_404(retrieve_harness): + with pytest.raises(ProxyException) as exc: + await call_retrieve(retrieve_harness, EXECUTED_BATCH_B64) + + assert exc.value.code == "404" + retrieve_harness.litellm_aretrieve.assert_not_called() + retrieve_harness.router_aretrieve.assert_not_called() + + # --------------------------------------------------------------------------- # # Cross-cutting: enrichment route_type and failure-hook on provider error. # --------------------------------------------------------------------------- # @@ -2257,6 +2407,35 @@ async def test_cancel__unified_no_router_500(cancel_harness): assert exc.value.code == "500" +@pytest.mark.asyncio +async def test_cancel__executed_batch_routes_to_runner(cancel_harness, executed_runner): + runner, factory = executed_runner + caller = UserAPIKeyAuth(api_key="sk-test", user_id="user-cancel-2") + resp = await call_cancel(cancel_harness, EXECUTED_BATCH_B64, user=caller) + + runner.cancel.assert_awaited_once_with(EXECUTED_BATCH_B64, caller) + factory.assert_called_once_with(cancel_harness.router, cancel_harness.logging) + cancel_harness.router_acancel.assert_not_called() + cancel_harness.litellm_acancel.assert_not_called() + cancel_harness.creds_resolver.assert_not_called() + assert resp is runner.cancel.return_value + assert cancel_harness.update_batch_in_db.call_args.kwargs["operation"] == "cancel" + + +@pytest.mark.asyncio +async def test_cancel__executed_batch_no_router_500(cancel_harness, executed_runner): + runner, factory = executed_runner + with patch.object( # test-quality-ok: proxy_server module global is the endpoint's only injection point + proxy_server, "llm_router", None + ): + with pytest.raises(ProxyException) as exc: + await call_cancel(cancel_harness, EXECUTED_BATCH_B64) + + assert exc.value.code == "500" + factory.assert_not_called() + runner.cancel.assert_not_called() + + # --------------------------------------------------------------------------- # # SCENARIO 3 - fallback to custom_llm_provider. Rebuilds a CancelBatchRequest # and forwards only {custom_llm_provider, batch_id}. @@ -2774,8 +2953,6 @@ async def test_cancel__unified_batch_id_allowed_when_managed_files_required(canc assert cancel_harness.router_acancel.call_count == 1 - - @pytest.mark.asyncio async def test_retrieve__managed_batch_defers_cost_to_the_poller_when_it_is_running(retrieve_harness): with patch.object(endpoints, "batch_cost_poller_is_active", MagicMock(return_value=True)): diff --git a/tests/test_litellm/proxy/batches_endpoints/test_litellm_executed_batches.py b/tests/test_litellm/proxy/batches_endpoints/test_litellm_executed_batches.py new file mode 100644 index 00000000000..0806dd3451e --- /dev/null +++ b/tests/test_litellm/proxy/batches_endpoints/test_litellm_executed_batches.py @@ -0,0 +1,715 @@ +import asyncio +import json +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from typing import Final, Literal, cast +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import HTTPException +from litellm_enterprise.proxy.hooks.managed_files import _PROXY_LiteLLMManagedFiles +from openai.types.batch_request_counts import BatchRequestCounts + +from litellm.models.managed_files import LiteLLM_ManagedFileTable +from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy.batches_endpoints import litellm_executed_batches +from litellm.proxy.batches_endpoints.litellm_executed_batches import ( + BatchEndpoint, + BatchInputLine, + BatchStatus, + InvalidBatchInput, + LiteLLMExecutedBatchRunner, + _resolve_transition, + litellm_executed_provider_of, + parse_batch_input, + resolve_litellm_executed_provider, +) +from litellm.proxy.openai_files_endpoints.common_utils import ( + _is_base64_encoded_unified_file_id, + get_batch_id_from_unified_batch_id, + is_litellm_executed_batch, +) +from litellm.proxy.utils import PrismaClient, ProxyLogging +from litellm.router import Router +from litellm.types.llms.openai import LiteLLMBatchCreateRequest, OpenAIFileObject, OpenAIFilesPurpose +from litellm.types.utils import EmbeddingResponse, LiteLLMBatch, ModelResponse, SpecialEnums + +BATCH_MODEL: Final = "batch-model" +DEPLOYMENT_ID: Final = "deployment-id-1" +INPUT_FILE_ID: Final = "unified-input-file" +STORAGE_BACKEND: Final = "s3" +STORAGE_URL: Final = "s3://bucket/input.jsonl" +CHAT_ENDPOINT: Final = "/v1/chat/completions" +ROUTER_METHODS: Final = ("acompletion", "atext_completion", "aembedding", "aresponses") +ALL_STATUSES: Final[tuple[BatchStatus, ...]] = ( + "in_progress", + "finalizing", + "completed", + "failed", + "cancelling", + "cancelled", +) + + +def chat_row(custom_id: str, content: str, **body_extra: object) -> dict[str, object]: + return { + "custom_id": custom_id, + "method": "POST", + "url": CHAT_ENDPOINT, + "body": {"model": "row-model", "messages": [{"role": "user", "content": content}], **body_extra}, + } + + +def jsonl(*rows: Mapping[str, object]) -> bytes: + return "".join(f"{json.dumps(row)}\n" for row in rows).encode() + + +TWO_CHAT_ROWS: Final = jsonl(chat_row("row-1", "hi 1"), chat_row("row-2", "hi 2")) + + +def chat_response(content: str) -> ModelResponse: + return ModelResponse( + id=f"chatcmpl-{content}", + model=BATCH_MODEL, + choices=[{"index": 0, "message": {"role": "assistant", "content": f"echo {content}"}, "finish_reason": "stop"}], + ) + + +def managed_input_file(storage_backend: str | None = STORAGE_BACKEND) -> LiteLLM_ManagedFileTable: + return LiteLLM_ManagedFileTable( + unified_file_id=INPUT_FILE_ID, + model_mappings={}, + flat_model_file_ids=[], + storage_backend=storage_backend, + storage_url=STORAGE_URL, + ) + + +def batch_request(endpoint: str) -> LiteLLMBatchCreateRequest: + return cast( + "LiteLLMBatchCreateRequest", + {"endpoint": endpoint, "input_file_id": INPUT_FILE_ID, "completion_window": "24h"}, + ) + + +class ProviderRateLimited(Exception): + status_code = 429 + + +@dataclass(frozen=True, slots=True) +class StoredObject: + file_object: str + status: str + + +@dataclass(frozen=True, slots=True) +class StoreCall: + unified_object_id: str + model_object_id: str + status: str + request_tags: tuple[str, ...] | None + persist_attribution: bool + create_if_missing: bool + batch_processed: bool + + +class FakeManagedBatchStore: + def __init__(self, files: Mapping[str, LiteLLM_ManagedFileTable]) -> None: + self.files = files + self.objects: dict[str, StoredObject] = {} + self.calls: list[StoreCall] = [] + + def get_unified_batch_id(self, batch_id: str, model_id: str) -> str: + return SpecialEnums.LITELLM_MANAGED_BATCH_COMPLETE_STR.value.format(model_id, batch_id) + + async def get_unified_file_id( + self, file_id: str, litellm_parent_otel_span: object | None = None + ) -> LiteLLM_ManagedFileTable | None: + return self.files.get(file_id) + + async def store_unified_object_id( + self, + unified_object_id: str, + file_object: LiteLLMBatch, + litellm_parent_otel_span: object | None, + model_object_id: str, + file_purpose: Literal["batch", "fine-tune", "response"], + user_api_key_dict: UserAPIKeyAuth, + request_tags: Sequence[str] | None = None, + persist_attribution: bool = False, + create_if_missing: bool = True, + batch_processed: bool = False, + ) -> None: + self.calls.append( + StoreCall( + unified_object_id=unified_object_id, + model_object_id=model_object_id, + status=file_object.status, + request_tags=tuple(request_tags) if request_tags is not None else None, + persist_attribution=persist_attribution, + create_if_missing=create_if_missing, + batch_processed=batch_processed, + ) + ) + if create_if_missing or unified_object_id in self.objects: + self.write(file_object) + + def write(self, batch: LiteLLMBatch) -> None: + self.objects[batch.id] = StoredObject(file_object=batch.model_dump_json(), status=batch.status) + + def batch(self, unified_batch_id: str) -> LiteLLMBatch: + return LiteLLMBatch.model_validate_json(self.objects[unified_batch_id].file_object) + + +REAL_HOOK: Final = _PROXY_LiteLLMManagedFiles(internal_usage_cache=MagicMock(), prisma_client=MagicMock()) + + +class RealIdManagedBatchStore(FakeManagedBatchStore): + def get_unified_batch_id(self, batch_id: str, model_id: str) -> str: + return REAL_HOOK.get_unified_batch_id(batch_id=batch_id, model_id=model_id) + + +class FakeManagedObjectTable: + def __init__(self, objects: Mapping[str, StoredObject]) -> None: + self.objects = objects + + async def find_first(self, where: Mapping[str, str]) -> StoredObject | None: + return self.objects.get(where["unified_object_id"]) + + +class FakeDb: + def __init__(self, objects: Mapping[str, StoredObject]) -> None: + self.litellm_managedobjecttable = FakeManagedObjectTable(objects) + + +class FakePrismaClient: + def __init__(self, objects: Mapping[str, StoredObject]) -> None: + self.db = FakeDb(objects) + + +class FakeRouter: + def __init__(self) -> None: + self.acompletion = AsyncMock(return_value=chat_response("default")) + self.atext_completion = AsyncMock(return_value=chat_response("default")) + self.aembedding = AsyncMock( + return_value=EmbeddingResponse( + model=BATCH_MODEL, data=[{"embedding": [0.1], "index": 0, "object": "embedding"}] + ) + ) + self.aresponses = AsyncMock(return_value=chat_response("default")) + + def get_model_ids(self, model_name: str) -> list[str]: + return [DEPLOYMENT_ID] if model_name == BATCH_MODEL else [] + + +class FakeStorageBackend: + def __init__(self, contents: Mapping[str, bytes]) -> None: + self.contents = contents + self.downloads: list[str] = [] + + async def download_file(self, storage_url: str) -> bytes: + self.downloads.append(storage_url) + return self.contents[storage_url] + + +class FakeStorageBackendFactory: + def __init__(self, backend: FakeStorageBackend, error: ValueError | None) -> None: + self.backend = backend + self.error = error + self.calls: list[tuple[str, object]] = [] + + def __call__(self, backend_type: str, prisma_client: object = None) -> FakeStorageBackend: + self.calls.append((backend_type, prisma_client)) + if self.error is not None: + raise self.error + return self.backend + + +@dataclass(frozen=True, slots=True) +class UploadCall: + content: bytes + filename: str + target_storage: str + target_model_names: tuple[str, ...] + purpose: str + user_api_key_dict: UserAPIKeyAuth + prisma_client: object + + def lines(self) -> dict[str, dict[str, object]]: + parsed = tuple(json.loads(line) for line in self.content.decode().splitlines()) + return {str(line["custom_id"]): line for line in parsed} + + +class FakeResultFileUploader: + def __init__(self, error: Exception | None) -> None: + self.error = error + self.calls: list[UploadCall] = [] + + async def __call__( + self, + file_data: Mapping[str, object], + target_storage: str, + target_model_names: list[str], + purpose: OpenAIFilesPurpose, + proxy_logging_obj: ProxyLogging, + user_api_key_dict: UserAPIKeyAuth, + prisma_client: object = None, + ) -> OpenAIFileObject: + content = file_data["content"] + assert isinstance(content, bytes) + self.calls.append( + UploadCall( + content=content, + filename=str(file_data["filename"]), + target_storage=target_storage, + target_model_names=tuple(target_model_names), + purpose=purpose, + user_api_key_dict=user_api_key_dict, + prisma_client=prisma_client, + ) + ) + if self.error is not None: + raise self.error + return OpenAIFileObject( + id=f"unified-output-{len(self.calls)}", + object="file", + bytes=len(content), + created_at=0, + filename=str(file_data["filename"]), + purpose=purpose, + status="uploaded", + ) + + +@dataclass(frozen=True, slots=True) +class Harness: + runner: LiteLLMExecutedBatchRunner + store: FakeManagedBatchStore + router: FakeRouter + uploads: FakeResultFileUploader + storage: FakeStorageBackend + storage_factory: FakeStorageBackendFactory + prisma: FakePrismaClient + user: UserAPIKeyAuth + + async def create(self, endpoint: str = CHAT_ENDPOINT) -> LiteLLMBatch: + return await self.runner.create( + create_request=batch_request(endpoint), + unified_input_file_id=INPUT_FILE_ID, + model=BATCH_MODEL, + provider="hosted_vllm", + user_api_key_dict=self.user, + request_tags=["tag-a"], + ) + + async def create_and_finish(self, endpoint: str = CHAT_ENDPOINT) -> tuple[LiteLLMBatch, LiteLLMBatch]: + created = await self.create(endpoint) + await asyncio.gather(*list(litellm_executed_batches._RUNNING_BATCHES)) + return created, self.store.batch(created.id) + + +def make_runner( + content: bytes = TWO_CHAT_ROWS, + concurrency: int = 4, + files: Mapping[str, LiteLLM_ManagedFileTable] | None = None, + upload_error: Exception | None = None, + storage_error: ValueError | None = None, + store_factory: Callable[[Mapping[str, LiteLLM_ManagedFileTable]], FakeManagedBatchStore] = FakeManagedBatchStore, +) -> Harness: + store = store_factory({INPUT_FILE_ID: managed_input_file()} if files is None else files) + router = FakeRouter() + uploads = FakeResultFileUploader(upload_error) + storage = FakeStorageBackend({STORAGE_URL: content}) + storage_factory = FakeStorageBackendFactory(storage, storage_error) + prisma = FakePrismaClient(store.objects) + user = UserAPIKeyAuth( + api_key="sk-batch-key", user_id="user-1", team_id="team-1", key_alias="alias-1", user_email="user@example.com" + ) + runner = LiteLLMExecutedBatchRunner( + llm_router=cast("Router", router), + prisma_client=cast("PrismaClient", prisma), + managed_files=store, + proxy_logging_obj=MagicMock(spec=ProxyLogging), + concurrency=concurrency, + storage_backend_factory=storage_factory, + upload_result_file=uploads, + ) + return Harness(runner, store, router, uploads, storage, storage_factory, prisma, user) + + +def seeded_batch(store: FakeManagedBatchStore, status: Literal["in_progress", "completed"]) -> LiteLLMBatch: + batch = LiteLLMBatch( + id=store.get_unified_batch_id(batch_id="litellm_batch_seed", model_id=DEPLOYMENT_ID), + object="batch", + endpoint=CHAT_ENDPOINT, + input_file_id=INPUT_FILE_ID, + completion_window="24h", + status=status, + created_at=1, + model=BATCH_MODEL, + ) + store.write(batch) + return batch + + +@pytest.mark.parametrize( + ("content", "line_number", "reason_fragment"), + [ + (b"", None, "no requests"), + (b"\n \n", None, "no requests"), + (b"{not json", 1, "JSON"), + (jsonl({"custom_id": "a", "method": "POST", "url": CHAT_ENDPOINT}), 1, "body"), + (jsonl({**chat_row("a", "hi"), "extra_field": 1}), 1, "extra_field"), + ( + jsonl(chat_row("a", "hi")) + b"\n" + jsonl({**chat_row("b", "hi"), "url": "/v1/embeddings"}), + 3, + "/v1/embeddings", + ), + (jsonl(chat_row("a", "hi", stream=True)), 1, "streaming"), + (jsonl(chat_row("a", "hi"), chat_row("a", "again")), None, "'a'"), + ], + ids=["empty", "blank lines", "not json", "missing body", "unknown field", "url mismatch", "stream", "duplicate id"], +) +def test_parse_batch_input_rejects(content: bytes, line_number: int | None, reason_fragment: str) -> None: + result = parse_batch_input(content, CHAT_ENDPOINT) + assert isinstance(result, InvalidBatchInput) + assert result.line_number == line_number + assert reason_fragment in result.reason + + +def test_parse_batch_input_keeps_every_request_and_skips_blank_lines() -> None: + content = b"\n" + jsonl(chat_row("a", "hi 1")) + b"\n" + jsonl(chat_row("b", "hi 2")) + b"\n\n" + lines = parse_batch_input(content, CHAT_ENDPOINT) + assert isinstance(lines, tuple) + assert [line.custom_id for line in lines] == ["a", "b"] + assert lines[1] == BatchInputLine( + custom_id="b", + method="POST", + url=CHAT_ENDPOINT, + body={"model": "row-model", "messages": [{"role": "user", "content": "hi 2"}]}, + ) + + +@pytest.mark.parametrize("current", ["validating", "in_progress", "finalizing"]) +@pytest.mark.parametrize("requested", ALL_STATUSES) +def test_resolve_transition_keeps_the_requested_status_unless_cancelling(current: str, requested: BatchStatus) -> None: + assert _resolve_transition(current, requested) == requested + + +@pytest.mark.parametrize( + ("requested", "expected"), + [ + ("completed", "cancelled"), + ("in_progress", "cancelling"), + ("finalizing", "cancelling"), + ("failed", "failed"), + ("cancelling", "cancelling"), + ("cancelled", "cancelled"), + ], +) +def test_resolve_transition_from_cancelling(requested: BatchStatus, expected: BatchStatus) -> None: + assert _resolve_transition("cancelling", requested) == expected + + +@pytest.mark.parametrize( + ("credentials", "expected"), + [ + ({"custom_llm_provider": "hosted_vllm", "model": "openai/gpt-4o"}, "hosted_vllm"), + ({"model": "hosted_vllm/qwen"}, "hosted_vllm"), + ({"custom_llm_provider": "openai", "model": "gpt-4o"}, None), + ({"model": "gpt-4o"}, None), + ], + ids=["explicit hosted_vllm", "model prefix", "explicit openai", "openai model"], +) +def test_litellm_executed_provider_of(credentials: Mapping[str, object], expected: str | None) -> None: + assert litellm_executed_provider_of(credentials) == expected + + +@pytest.mark.parametrize( + ("credentials", "expected"), [(None, None), ({"model": "hosted_vllm/qwen"}, "hosted_vllm")], ids=["unknown", "vllm"] +) +def test_resolve_litellm_executed_provider_asks_the_router_for_the_team_scoped_deployment( + credentials: Mapping[str, object] | None, expected: str | None +) -> None: + router = MagicMock(spec=Router) + router.get_deployment_credentials_with_provider.return_value = credentials + assert resolve_litellm_executed_provider(router, BATCH_MODEL, "team-1") == expected + router.get_deployment_credentials_with_provider.assert_called_once_with(model_id=BATCH_MODEL, team_id="team-1") + + +async def test_create_stores_a_validating_batch_and_completes_it_in_the_background() -> None: + harness = make_runner() + created, finished = await harness.create_and_finish() + + assert created.status == "validating" + assert is_litellm_executed_batch(created.id) + assert created.id.startswith(f"litellm_proxy;model_id:{DEPLOYMENT_ID};llm_batch_id:litellm_batch_") + assert (created.model, created.input_file_id) == (BATCH_MODEL, INPUT_FILE_ID) + assert created.request_counts == BatchRequestCounts(completed=0, failed=0, total=2) + first_write = harness.store.calls[0] + assert (first_write.unified_object_id, first_write.model_object_id) == ( + created.id, + get_batch_id_from_unified_batch_id(created.id), + ) + assert (first_write.persist_attribution, first_write.batch_processed, first_write.request_tags) == ( + True, + True, + ("tag-a",), + ) + assert harness.storage_factory.calls == [(STORAGE_BACKEND, harness.prisma)] + assert harness.storage.downloads == [STORAGE_URL] + + assert finished.status == "completed" + assert finished.request_counts == BatchRequestCounts(completed=2, failed=0, total=2) + assert (finished.output_file_id, finished.error_file_id) == ("unified-output-1", None) + assert finished.in_progress_at is not None + assert finished.completed_at is not None + + +async def test_create_dispatches_each_row_with_the_batch_model_and_the_key_metadata() -> None: + harness = make_runner() + created, _ = await harness.create_and_finish() + + calls = {call.kwargs["messages"][0]["content"]: call.kwargs for call in harness.router.acompletion.await_args_list} + assert set(calls) == {"hi 1", "hi 2"} + for content, kwargs in calls.items(): + assert kwargs["model"] == BATCH_MODEL + assert kwargs["messages"] == [{"role": "user", "content": content}] + metadata = kwargs["metadata"] + assert metadata["user_api_key"] == harness.user.api_key + assert metadata["tags"] == ["tag-a"] + assert metadata["batch_id"] == created.id + assert metadata["user_api_key_user_id"] == "user-1" + assert metadata["user_api_key_team_id"] == "team-1" + assert metadata["user_api_key_alias"] == "alias-1" + assert metadata["user_api_key_user_email"] == "user@example.com" + + +async def test_create_uploads_one_output_line_per_row_with_the_router_response() -> None: + harness = make_runner() + replies = {"hi 1": chat_response("hi 1"), "hi 2": chat_response("hi 2")} + harness.router.acompletion.side_effect = lambda **kwargs: replies[kwargs["messages"][0]["content"]] + created, _ = await harness.create_and_finish() + + assert len(harness.uploads.calls) == 1 + upload = harness.uploads.calls[0] + assert (upload.target_storage, upload.purpose, upload.target_model_names) == ( + "litellm_db", + "batch_output", + (BATCH_MODEL,), + ) + assert upload.filename == f"{get_batch_id_from_unified_batch_id(created.id)}_output.jsonl" + assert upload.user_api_key_dict is harness.user + assert upload.prisma_client is harness.prisma + lines = upload.lines() + assert set(lines) == {"row-1", "row-2"} + for custom_id, content in (("row-1", "hi 1"), ("row-2", "hi 2")): + line = lines[custom_id] + assert str(line["id"]).startswith("batch_req_") + assert line["error"] is None + response = line["response"] + assert isinstance(response, dict) + assert response["status_code"] == 200 + assert response["body"] == replies[content].model_dump(mode="json") + + +async def test_create_splits_failed_rows_into_the_error_file() -> None: + harness = make_runner() + failure = ProviderRateLimited("slow down") + reply = chat_response("hi 1") + + def dispatch(messages: Sequence[Mapping[str, str]], **_: object) -> ModelResponse: + if messages[0]["content"] == "hi 1": + return reply + raise failure + + harness.router.acompletion.side_effect = dispatch + created, finished = await harness.create_and_finish() + + assert finished.status == "completed" + assert finished.request_counts == BatchRequestCounts(completed=1, failed=1, total=2) + assert (finished.output_file_id, finished.error_file_id) == ("unified-output-1", "unified-output-2") + llm_batch_id = get_batch_id_from_unified_batch_id(created.id) + assert [call.filename for call in harness.uploads.calls] == [ + f"{llm_batch_id}_output.jsonl", + f"{llm_batch_id}_error.jsonl", + ] + assert set(harness.uploads.calls[0].lines()) == {"row-1"} + error_lines = harness.uploads.calls[1].lines() + assert set(error_lines) == {"row-2"} + response = error_lines["row-2"]["response"] + assert isinstance(response, dict) + assert response["status_code"] == 429 + assert response["body"] == { + "error": {"message": str(failure), "type": "ProviderRateLimited", "param": None, "code": None} + } + + +async def test_create_rejects_an_unsupported_endpoint() -> None: + harness = make_runner() + with pytest.raises(HTTPException) as raised: + await harness.create(endpoint="/v1/moderations") + assert raised.value.status_code == 400 + assert "/v1/moderations" in raised.value.detail["error"] + assert harness.store.calls == [] + assert harness.storage_factory.calls == [] + + +@pytest.mark.parametrize( + "files", + [{}, {INPUT_FILE_ID: managed_input_file(storage_backend=None)}], + ids=["unknown file", "no stored content"], +) +async def test_create_rejects_an_input_file_litellm_does_not_hold( + files: Mapping[str, LiteLLM_ManagedFileTable], +) -> None: + harness = make_runner(files=files) + with pytest.raises(HTTPException) as raised: + await harness.create() + assert raised.value.status_code == 400 + assert "POST /v1/files" in raised.value.detail["error"] + assert harness.storage_factory.calls == [] + assert harness.store.calls == [] + + +async def test_create_rejects_an_invalid_input_file() -> None: + harness = make_runner(content=jsonl(chat_row("a", "hi"), chat_row("a", "again"))) + with pytest.raises(HTTPException) as raised: + await harness.create() + assert raised.value.status_code == 400 + assert raised.value.detail["error"].startswith("Invalid batch input file:") + assert "'a'" in raised.value.detail["error"] + assert harness.store.calls == [] + + +async def test_create_surfaces_a_storage_backend_error_as_a_400() -> None: + harness = make_runner(storage_error=ValueError("Unknown storage backend 's3'")) + with pytest.raises(HTTPException) as raised: + await harness.create() + assert raised.value.status_code == 400 + assert raised.value.detail["error"] == "Unknown storage backend 's3'" + assert harness.store.calls == [] + + +@pytest.mark.parametrize( + ("endpoint", "body", "method"), + [ + ("/v1/chat/completions", {"messages": [{"role": "user", "content": "hi"}]}, "acompletion"), + ("/v1/completions", {"prompt": "hi"}, "atext_completion"), + ("/v1/embeddings", {"input": "hi"}, "aembedding"), + ("/v1/responses", {"input": "hi"}, "aresponses"), + ], +) +async def test_each_endpoint_awaits_only_its_router_method( + endpoint: BatchEndpoint, body: Mapping[str, object], method: str +) -> None: + row = {"custom_id": "a", "method": "POST", "url": endpoint, "body": {"model": "row-model", **body}} + harness = make_runner(content=jsonl(row)) + _, finished = await harness.create_and_finish(endpoint) + + assert finished.request_counts == BatchRequestCounts(completed=1, failed=0, total=1) + awaited = {name: getattr(harness.router, name).await_count for name in ROUTER_METHODS} + assert awaited == {name: int(name == method) for name in ROUTER_METHODS} + kwargs = getattr(harness.router, method).await_args.kwargs + assert kwargs["model"] == BATCH_MODEL + assert all(kwargs[key] == value for key, value in body.items()) + + +async def test_cancel_unknown_batch_is_404() -> None: + harness = make_runner() + with pytest.raises(HTTPException) as raised: + await harness.runner.cancel("missing-batch", harness.user) + assert raised.value.status_code == 404 + + +async def test_cancel_terminal_batch_is_400() -> None: + harness = make_runner() + batch = seeded_batch(harness.store, "completed") + with pytest.raises(HTTPException) as raised: + await harness.runner.cancel(batch.id, harness.user) + assert raised.value.status_code == 400 + assert "completed" in raised.value.detail["error"] + assert harness.store.calls == [] + + +async def test_cancel_marks_a_running_batch_cancelling_once() -> None: + harness = make_runner() + batch = seeded_batch(harness.store, "in_progress") + + cancelled = await harness.runner.cancel(batch.id, harness.user) + + assert cancelled.status == "cancelling" + assert cancelled.cancelling_at is not None + assert harness.store.batch(batch.id).status == "cancelling" + assert [(call.status, call.create_if_missing) for call in harness.store.calls] == [("cancelling", False)] + + again = await harness.runner.cancel(batch.id, harness.user) + + assert again.model_dump() == cancelled.model_dump() + assert len(harness.store.calls) == 1 + + +async def test_running_batch_skips_the_remaining_rows_after_an_operator_cancel( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(litellm_executed_batches, "_CANCEL_POLL_SECONDS", 0.0) + rows = jsonl(chat_row("row-1", "hi 1"), chat_row("row-2", "hi 2"), chat_row("row-3", "hi 3")) + harness = make_runner(content=rows, concurrency=1) + reply = chat_response("hi 1") + + def dispatch(metadata: Mapping[str, object], **_: object) -> ModelResponse: + running = harness.store.batch(str(metadata["batch_id"])) + harness.store.write(running.model_copy(update={"status": "cancelling"})) + return reply + + harness.router.acompletion.side_effect = dispatch + _, finished = await harness.create_and_finish() + + assert harness.router.acompletion.await_count == 1 + assert finished.status == "cancelled" + assert finished.cancelled_at is not None + assert finished.request_counts == BatchRequestCounts(completed=1, failed=0, total=3) + assert (finished.output_file_id, finished.error_file_id) == ("unified-output-1", None) + + +async def test_upload_failure_marks_the_batch_failed() -> None: + harness = make_runner(upload_error=RuntimeError("storage exploded")) + _, finished = await harness.create_and_finish() + + assert finished.status == "failed" + assert finished.failed_at is not None + assert finished.output_file_id is None + assert finished.errors is not None + assert [(error.message, error.code) for error in finished.errors.data or []] == [ + ("storage exploded", "internal_error") + ] + + +async def test_only_the_create_write_carries_attribution_and_billing_flags() -> None: + harness = make_runner() + await harness.create_and_finish() + + assert [call.status for call in harness.store.calls] == ["validating", "in_progress", "finalizing", "completed"] + flags = [(call.persist_attribution, call.batch_processed, call.create_if_missing) for call in harness.store.calls] + assert flags[0] == (True, True, True) + assert flags[1:] == [(False, False, False)] * 3 + + +async def test_run_completes_under_the_real_hooks_base64_batch_id() -> None: + harness = make_runner(store_factory=RealIdManagedBatchStore) + created, finished = await harness.create_and_finish() + + assert _is_base64_encoded_unified_file_id(created.id) + assert finished.status == "completed" + llm_batch_id = harness.store.calls[0].model_object_id + assert llm_batch_id.startswith("litellm_batch_") + assert [call.model_object_id for call in harness.store.calls] == [llm_batch_id] * 4 + + +async def test_cancel_works_under_the_real_hooks_base64_batch_id() -> None: + harness = make_runner(store_factory=RealIdManagedBatchStore) + batch = seeded_batch(harness.store, "in_progress") + cancelled = await harness.runner.cancel(batch.id, harness.user) + + assert cancelled.status == "cancelling" + assert [call.model_object_id for call in harness.store.calls] == ["litellm_batch_seed"] diff --git a/tests/test_litellm/proxy/openai_files_endpoint/test_files_common_utils.py b/tests/test_litellm/proxy/openai_files_endpoint/test_files_common_utils.py index 87cd2aaff1f..f88d94d2c08 100644 --- a/tests/test_litellm/proxy/openai_files_endpoint/test_files_common_utils.py +++ b/tests/test_litellm/proxy/openai_files_endpoint/test_files_common_utils.py @@ -6,6 +6,7 @@ import pytest from litellm.proxy.openai_files_endpoints.common_utils import ( apply_unified_file_ids, + is_litellm_executed_batch, map_raw_file_ids_to_unified, ) from litellm.types.utils import LiteLLMBatch @@ -478,3 +479,15 @@ class TestCompletedBatchSafeToRetire: def test_no_output_and_unknown_counts_is_not_safe(self): assert _completed_batch_safe_to_retire(_completed_batch_for_retire(None)) is False + + +@pytest.mark.parametrize( + "decoded_unified_batch_id, executed", + [ + ("litellm_proxy;model_id:my-vllm;llm_batch_id:litellm_batch_0123abcd", True), + ("litellm_proxy;model_id:my-vllm;llm_batch_id:batch_0123abcd", False), + ("litellm_proxy;model_id:my-vllm;generic_response_id:resp_0123abcd", False), + ], +) +def test_is_litellm_executed_batch_reads_the_llm_batch_id_prefix(decoded_unified_batch_id: str, executed: bool): + assert is_litellm_executed_batch(decoded_unified_batch_id) is executed diff --git a/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py b/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py index aa505c3019b..08d7bf8e0da 100644 --- a/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py +++ b/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py @@ -609,6 +609,121 @@ def test_target_storage_with_target_models( app.dependency_overrides.pop(ps.user_api_key_auth, None) +BATCH_JSONL_LINE = ( + b'{"custom_id": "req-1", "method": "POST", "url": "/v1/chat/completions", ' + b'"body": {"model": "my-vllm", "messages": [{"role": "user", "content": "hi"}]}}\n' +) + + +def _router_with_executed_batch_model() -> Router: + return Router( + model_list=[ + { + "model_name": "my-vllm", + "litellm_params": { + "model": "hosted_vllm/qwen", + "api_key": "sk-vllm", + "api_base": "http://vllm.test/v1", + }, + "model_info": {"id": "my-vllm-id"}, + }, + { + "model_name": "gemini-2.0-flash", + "litellm_params": {"model": "gemini/gemini-2.0-flash"}, + "model_info": {"id": "gemini-2.0-flash-id"}, + }, + ] + ) + + +@pytest.fixture +def batch_upload_seams(mocker: MockerFixture, monkeypatch): + import litellm.proxy.proxy_server as ps + from litellm.proxy._types import LitellmUserRoles + + llm_router = _router_with_executed_batch_model() + monkeypatch.setattr("litellm.proxy.proxy_server.master_key", None) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", llm_router) + setup_proxy_logging_object(monkeypatch, llm_router) + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, user_id="test-user" + ) + uploaded = OpenAIFileObject( + id="file-kept", + object="file", + purpose="batch", + created_at=0, + bytes=len(BATCH_JSONL_LINE), + filename="batch.jsonl", + status="uploaded", + ) + stored = mocker.patch( # test-quality-ok: the route calls the storage service directly with no injection seam + "litellm.proxy.openai_files_endpoints.storage_backend_service.StorageBackendFileService.upload_file_to_storage_backend", + new=mocker.AsyncMock(return_value=uploaded), + ) + provider_upload = mocker.patch( # test-quality-ok: the route calls litellm.acreate_file directly with no injection seam + "litellm.acreate_file", new=mocker.AsyncMock(return_value=uploaded) + ) + try: + yield stored, provider_upload + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + +def _upload_batch_file(headers: dict[str, str], form: dict[str, str]): + return client.post( + "/v1/files", + files={"file": ("batch.jsonl", BATCH_JSONL_LINE, "application/jsonl")}, + data={"purpose": "batch", **form}, + headers={"Authorization": "Bearer test-key", **headers}, + ) + + +@pytest.mark.parametrize( + "headers, form", + [({"x-litellm-model": "my-vllm"}, {}), ({}, {"target_model_names": "my-vllm"})], + ids=["x-litellm-model header", "target_model_names form field"], +) +def test_batch_upload_for_a_litellm_executed_model_is_kept_by_litellm( + batch_upload_seams, headers: dict[str, str], form: dict[str, str] +): + stored, provider_upload = batch_upload_seams + + response = _upload_batch_file(headers, form) + + assert response.status_code == 200, response.text + provider_upload.assert_not_awaited() + stored.assert_awaited_once() + kwargs = stored.call_args.kwargs + assert kwargs["target_storage"] == "litellm_db" + assert tuple(kwargs["target_model_names"]) == ("my-vllm",) + assert kwargs["purpose"] == "batch" + + +def test_batch_upload_naming_an_executed_and_a_provider_model_is_rejected(batch_upload_seams): + stored, provider_upload = batch_upload_seams + + response = _upload_batch_file({}, {"target_model_names": "my-vllm,gemini-2.0-flash"}) + + assert response.status_code == 400, response.text + assert "my-vllm" in response.text + assert "target_model_names" in response.text + stored.assert_not_awaited() + provider_upload.assert_not_awaited() + + +def test_batch_upload_for_a_provider_model_still_goes_to_the_provider(batch_upload_seams): + stored, provider_upload = batch_upload_seams + + response = _upload_batch_file({"x-litellm-model": "gemini-2.0-flash"}, {}) + + assert response.status_code == 200, response.text + stored.assert_not_awaited() + provider_upload.assert_awaited_once() + assert provider_upload.call_args.kwargs["custom_llm_provider"] == "gemini" + + @pytest.mark.skip(reason="mock respx fails on ci/cd - unclear why") def test_create_file_and_call_chat_completion_e2e( mocker: MockerFixture, monkeypatch, llm_router: Router diff --git a/tests/test_litellm/proxy/openai_files_endpoint/test_storage_backend_service.py b/tests/test_litellm/proxy/openai_files_endpoint/test_storage_backend_service.py index 07a85a70815..067826004e2 100644 --- a/tests/test_litellm/proxy/openai_files_endpoint/test_storage_backend_service.py +++ b/tests/test_litellm/proxy/openai_files_endpoint/test_storage_backend_service.py @@ -1,3 +1,5 @@ +from unittest.mock import MagicMock + import pytest from litellm.llms.base_llm.files.transformation import BaseFileEndpoints @@ -6,6 +8,7 @@ from litellm.proxy.openai_files_endpoints import storage_backend_service from litellm.proxy.openai_files_endpoints.storage_backend_service import ( StorageBackendFileService, ) +from litellm.proxy.utils import PrismaClient class _RecordingStorageBackend: @@ -57,7 +60,7 @@ def _file_data(): @pytest.mark.asyncio async def test_upload_with_target_model_names_but_no_hook_raises_before_uploading(monkeypatch): backend = _RecordingStorageBackend() - monkeypatch.setattr(storage_backend_service, "get_storage_backend", lambda name: backend) + monkeypatch.setattr(storage_backend_service, "get_storage_backend", lambda name, prisma_client=None: backend) with pytest.raises(ProxyException) as exc_info: await StorageBackendFileService.upload_file_to_storage_backend( @@ -80,7 +83,7 @@ async def test_upload_with_target_model_names_but_no_hook_raises_before_uploadin @pytest.mark.asyncio async def test_upload_without_target_model_names_skips_hook_requirement(monkeypatch): backend = _RecordingStorageBackend() - monkeypatch.setattr(storage_backend_service, "get_storage_backend", lambda name: backend) + monkeypatch.setattr(storage_backend_service, "get_storage_backend", lambda name, prisma_client=None: backend) file_object = await StorageBackendFileService.upload_file_to_storage_backend( file_data=_file_data(), @@ -101,7 +104,7 @@ async def test_upload_without_target_model_names_skips_hook_requirement(monkeypa @pytest.mark.asyncio async def test_upload_with_target_model_names_and_hook_stores_unified_id(monkeypatch): backend = _RecordingStorageBackend() - monkeypatch.setattr(storage_backend_service, "get_storage_backend", lambda name: backend) + monkeypatch.setattr(storage_backend_service, "get_storage_backend", lambda name, prisma_client=None: backend) hook = _FakeManagedFilesHook() file_object = await StorageBackendFileService.upload_file_to_storage_backend( @@ -125,3 +128,28 @@ async def test_upload_with_target_model_names_and_hook_stores_unified_id(monkeyp "stored_id_matches_response": True, "model_mappings": {"gpt-x": "https://storage.example/blob-1"}, } + + +@pytest.mark.asyncio +async def test_upload_hands_the_prisma_client_to_the_storage_backend_factory(monkeypatch: pytest.MonkeyPatch): + backend = _RecordingStorageBackend() + factory_calls: list[tuple[str, PrismaClient | None]] = [] + + def _factory(name: str, prisma_client: PrismaClient | None = None) -> _RecordingStorageBackend: + factory_calls.append((name, prisma_client)) + return backend + + monkeypatch.setattr(storage_backend_service, "get_storage_backend", _factory) + prisma_client = MagicMock() + + await StorageBackendFileService.upload_file_to_storage_backend( + file_data=_file_data(), + target_storage="litellm_db", + target_model_names=[], + purpose="batch", + proxy_logging_obj=_FakeProxyLogging(hook=None), + user_api_key_dict=UserAPIKeyAuth(api_key="sk-test"), + prisma_client=prisma_client, + ) + + assert factory_calls == [("litellm_db", prisma_client)] From b4f10e211c7655eeb5e0784c8d698a66f5149da7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 19 Sep 2026 08:49:34 +0000 Subject: [PATCH 30/73] chore: sync schema.prisma copies from root --- litellm-proxy-extras/litellm_proxy_extras/schema.prisma | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index 91b59e56906..c4606796ebf 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -1107,6 +1107,12 @@ model LiteLLM_ManagedObjectTable { // for batches or finetuning jobs which use t @@index([team_id, created_at(sort: Desc)]) } +model LiteLLM_ManagedFileContentTable { + id String @id @default(uuid()) + content Bytes + created_at DateTime @default(now()) +} + model LiteLLM_ManagedVectorStoreTable { id String @id @default(uuid()) unified_resource_id String @unique // The base64 encoded unified vector store ID From 167e3244ab7b2e904e6d7f3f193d3f2bf737a874 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 01:58:53 -0700 Subject: [PATCH 31/73] fix(batches): shape LiteLLM-executed batch errors like OpenAI errors --- litellm/proxy/batches_endpoints/endpoints.py | 10 ++--- .../litellm_executed_batches.py | 21 +++++----- .../test_litellm_executed_batches.py | 40 +++++++++---------- 3 files changed, 35 insertions(+), 36 deletions(-) diff --git a/litellm/proxy/batches_endpoints/endpoints.py b/litellm/proxy/batches_endpoints/endpoints.py index cc698ad760e..7e49d937171 100644 --- a/litellm/proxy/batches_endpoints/endpoints.py +++ b/litellm/proxy/batches_endpoints/endpoints.py @@ -23,7 +23,7 @@ from litellm.proxy.batches_endpoints.litellm_executed_batches import ( LITELLM_EXECUTED_BATCH_UPLOAD_GUIDANCE, LiteLLMExecutedBatchRunner, ManagedBatchStore, - batch_http_error, + batch_error, litellm_executed_provider_of, resolve_litellm_executed_provider, ) @@ -83,7 +83,7 @@ def _litellm_executed_batch_runner(llm_router: Router, proxy_logging_obj: ProxyL managed_files: Final = proxy_logging_obj.get_proxy_hook("managed_files") if prisma_client is None or not isinstance(managed_files, ManagedBatchStore): - raise batch_http_error( + raise batch_error( 400, "LiteLLM-executed batches need a database: set DATABASE_URL so LiteLLM can keep the batch and its files", ) @@ -98,7 +98,7 @@ def _litellm_executed_batch_runner(llm_router: Router, proxy_logging_obj: ProxyL def _raise_when_input_file_must_be_managed(model: str, credentials: Mapping[str, object]) -> None: if litellm_executed_provider_of(credentials) is None: return - raise batch_http_error( + raise batch_error( 400, f"Batches for {model} run inside LiteLLM, so the input file must be a LiteLLM managed file: " f"{LITELLM_EXECUTED_BATCH_UPLOAD_GUIDANCE}", @@ -556,7 +556,7 @@ async def retrieve_batch( executed_batch: Final = isinstance(unified_batch_id, str) and is_litellm_executed_batch(unified_batch_id) if executed_batch and response is None: - raise batch_http_error(404, f"No batch found with id '{batch_id}'.") + raise batch_error(404, f"No batch found with id '{batch_id}'.") # If batch is in a terminal state, return immediately. # Include "complete" (DB-normalized form of "completed"). @@ -1067,7 +1067,7 @@ async def cancel_batch( # SCENARIO 2: target_model_names based routing elif unified_batch_id and is_litellm_executed_batch(unified_batch_id): if llm_router is None: - raise batch_http_error(500, "LLM Router not initialized. Ensure models added to proxy.") + raise batch_error(500, "LLM Router not initialized. Ensure models added to proxy.") response = await _litellm_executed_batch_runner( # rebind-ok: each cancel path sets the route's response llm_router, proxy_logging_obj ).cancel(batch_id, user_api_key_dict) diff --git a/litellm/proxy/batches_endpoints/litellm_executed_batches.py b/litellm/proxy/batches_endpoints/litellm_executed_batches.py index f567f0e2263..77e583781ea 100644 --- a/litellm/proxy/batches_endpoints/litellm_executed_batches.py +++ b/litellm/proxy/batches_endpoints/litellm_executed_batches.py @@ -7,7 +7,6 @@ from itertools import pairwise from types import MappingProxyType from typing import TYPE_CHECKING, Final, Literal, Protocol, TypeAlias, runtime_checkable -from fastapi import HTTPException from openai.types.batch import Errors from openai.types.batch_error import BatchError from openai.types.batch_request_counts import BatchRequestCounts @@ -23,7 +22,7 @@ from litellm.llms.base_llm.files.litellm_db_storage_backend import LITELLM_DB_ST from litellm.llms.base_llm.files.storage_backend import BaseFileStorageBackend from litellm.llms.base_llm.files.storage_backend_factory import get_storage_backend from litellm.models.managed_files import LiteLLM_ManagedFileTable -from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy._types import ProxyErrorTypes, ProxyException, UserAPIKeyAuth from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup from litellm.proxy.openai_files_endpoints.common_utils import ( LITELLM_EXECUTED_BATCH_ID_PREFIX, @@ -234,16 +233,16 @@ def parse_batch_input(content: bytes, endpoint: BatchEndpoint) -> tuple[BatchInp return lines -def batch_http_error(status_code: int, message: str) -> HTTPException: - detail: Final = {"error": message} # mutable-ok: HTTPException detail must be a plain mapping - return HTTPException(status_code=status_code, detail=detail) +def batch_error(status_code: int, message: str) -> ProxyException: + error_type: Final = "invalid_request_error" if status_code < 500 else ProxyErrorTypes.internal_server_error.value + return ProxyException(message=message, type=error_type, param=None, code=status_code) def _validate_endpoint(endpoint: object) -> BatchEndpoint: try: return _BATCH_ENDPOINT_ADAPTER.validate_python(endpoint) except ValidationError: - raise batch_http_error(400, f"endpoint {endpoint!r} is not supported for a LiteLLM-executed batch") + raise batch_error(400, f"endpoint {endpoint!r} is not supported for a LiteLLM-executed batch") def _status_code_of(error: Exception) -> int: @@ -350,7 +349,7 @@ class LiteLLMExecutedBatchRunner: content: Final = await self._download_input(unified_input_file_id, user_api_key_dict) parsed: Final = parse_batch_input(content, endpoint) if isinstance(parsed, InvalidBatchInput): - raise batch_http_error(400, f"Invalid batch input file: {parsed.describe()}") + raise batch_error(400, f"Invalid batch input file: {parsed.describe()}") llm_batch_id: Final = f"{LITELLM_EXECUTED_BATCH_ID_PREFIX}{uuid_module.uuid4().hex}" model_id: Final = next(iter(self.llm_router.get_model_ids(model_name=model)), model) unified_batch_id: Final = self.managed_files.get_unified_batch_id(batch_id=llm_batch_id, model_id=model_id) @@ -397,9 +396,9 @@ class LiteLLMExecutedBatchRunner: async def cancel(self, unified_batch_id: str, user_api_key_dict: UserAPIKeyAuth) -> LiteLLMBatch: current: Final = await self._load_batch(unified_batch_id) if current is None: - raise batch_http_error(404, f"Batch {unified_batch_id} not found") + raise batch_error(404, f"Batch {unified_batch_id} not found") if current.status in TERMINAL_BATCH_STATUSES: - raise batch_http_error(400, f"Cannot cancel a batch with status '{current.status}'") + raise batch_error(400, f"Cannot cancel a batch with status '{current.status}'") if current.status == "cancelling": return current cancelling: Final = current.model_copy( @@ -413,7 +412,7 @@ class LiteLLMExecutedBatchRunner: unified_input_file_id, litellm_parent_otel_span=user_api_key_dict.parent_otel_span ) if stored is None or not stored.storage_backend or not stored.storage_url: - raise batch_http_error( + raise batch_error( 400, f"LiteLLM does not hold the content of input file {unified_input_file_id}: " f"{LITELLM_EXECUTED_BATCH_UPLOAD_GUIDANCE}", @@ -422,7 +421,7 @@ class LiteLLMExecutedBatchRunner: backend: Final = self.storage_backend_factory(stored.storage_backend, prisma_client=self.prisma_client) return await backend.download_file(stored.storage_url) except ValueError as e: - raise batch_http_error(400, str(e)) + raise batch_error(400, str(e)) async def _run(self, run: _BatchRun) -> None: try: diff --git a/tests/test_litellm/proxy/batches_endpoints/test_litellm_executed_batches.py b/tests/test_litellm/proxy/batches_endpoints/test_litellm_executed_batches.py index 0806dd3451e..9c775fd2c97 100644 --- a/tests/test_litellm/proxy/batches_endpoints/test_litellm_executed_batches.py +++ b/tests/test_litellm/proxy/batches_endpoints/test_litellm_executed_batches.py @@ -6,12 +6,11 @@ from typing import Final, Literal, cast from unittest.mock import AsyncMock, MagicMock import pytest -from fastapi import HTTPException from litellm_enterprise.proxy.hooks.managed_files import _PROXY_LiteLLMManagedFiles from openai.types.batch_request_counts import BatchRequestCounts from litellm.models.managed_files import LiteLLM_ManagedFileTable -from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy._types import ProxyException, UserAPIKeyAuth from litellm.proxy.batches_endpoints import litellm_executed_batches from litellm.proxy.batches_endpoints.litellm_executed_batches import ( BatchEndpoint, @@ -547,10 +546,11 @@ async def test_create_splits_failed_rows_into_the_error_file() -> None: async def test_create_rejects_an_unsupported_endpoint() -> None: harness = make_runner() - with pytest.raises(HTTPException) as raised: + with pytest.raises(ProxyException) as raised: await harness.create(endpoint="/v1/moderations") - assert raised.value.status_code == 400 - assert "/v1/moderations" in raised.value.detail["error"] + assert raised.value.code == "400" + assert raised.value.type == "invalid_request_error" + assert "/v1/moderations" in raised.value.message assert harness.store.calls == [] assert harness.storage_factory.calls == [] @@ -564,30 +564,30 @@ async def test_create_rejects_an_input_file_litellm_does_not_hold( files: Mapping[str, LiteLLM_ManagedFileTable], ) -> None: harness = make_runner(files=files) - with pytest.raises(HTTPException) as raised: + with pytest.raises(ProxyException) as raised: await harness.create() - assert raised.value.status_code == 400 - assert "POST /v1/files" in raised.value.detail["error"] + assert raised.value.code == "400" + assert "POST /v1/files" in raised.value.message assert harness.storage_factory.calls == [] assert harness.store.calls == [] async def test_create_rejects_an_invalid_input_file() -> None: harness = make_runner(content=jsonl(chat_row("a", "hi"), chat_row("a", "again"))) - with pytest.raises(HTTPException) as raised: + with pytest.raises(ProxyException) as raised: await harness.create() - assert raised.value.status_code == 400 - assert raised.value.detail["error"].startswith("Invalid batch input file:") - assert "'a'" in raised.value.detail["error"] + assert raised.value.code == "400" + assert raised.value.message.startswith("Invalid batch input file:") + assert "'a'" in raised.value.message assert harness.store.calls == [] async def test_create_surfaces_a_storage_backend_error_as_a_400() -> None: harness = make_runner(storage_error=ValueError("Unknown storage backend 's3'")) - with pytest.raises(HTTPException) as raised: + with pytest.raises(ProxyException) as raised: await harness.create() - assert raised.value.status_code == 400 - assert raised.value.detail["error"] == "Unknown storage backend 's3'" + assert raised.value.code == "400" + assert raised.value.message == "Unknown storage backend 's3'" assert harness.store.calls == [] @@ -617,18 +617,18 @@ async def test_each_endpoint_awaits_only_its_router_method( async def test_cancel_unknown_batch_is_404() -> None: harness = make_runner() - with pytest.raises(HTTPException) as raised: + with pytest.raises(ProxyException) as raised: await harness.runner.cancel("missing-batch", harness.user) - assert raised.value.status_code == 404 + assert raised.value.code == "404" async def test_cancel_terminal_batch_is_400() -> None: harness = make_runner() batch = seeded_batch(harness.store, "completed") - with pytest.raises(HTTPException) as raised: + with pytest.raises(ProxyException) as raised: await harness.runner.cancel(batch.id, harness.user) - assert raised.value.status_code == 400 - assert "completed" in raised.value.detail["error"] + assert raised.value.code == "400" + assert "completed" in raised.value.message assert harness.store.calls == [] From 9e8b686c7accbcf8e2cba87b982970dafbad94f2 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 02:53:46 -0700 Subject: [PATCH 32/73] fix(batches): run hosted_vllm batches in LiteLLM only when the server has no Files API --- litellm/proxy/batches_endpoints/endpoints.py | 12 ++- .../litellm_executed_batches.py | 67 +++++++++++- .../openai_files_endpoints/files_endpoints.py | 23 ++-- .../proxy/batches_endpoints/test_endpoints.py | 50 ++++++++- .../test_litellm_executed_batches.py | 101 +++++++++++++++++- .../test_files_endpoint.py | 52 ++++++++- 6 files changed, 283 insertions(+), 22 deletions(-) diff --git a/litellm/proxy/batches_endpoints/endpoints.py b/litellm/proxy/batches_endpoints/endpoints.py index 7e49d937171..ba5853b7c46 100644 --- a/litellm/proxy/batches_endpoints/endpoints.py +++ b/litellm/proxy/batches_endpoints/endpoints.py @@ -24,7 +24,7 @@ from litellm.proxy.batches_endpoints.litellm_executed_batches import ( LiteLLMExecutedBatchRunner, ManagedBatchStore, batch_error, - litellm_executed_provider_of, + litellm_executed_provider_for, resolve_litellm_executed_provider, ) from litellm.proxy.common_request_processing import ( @@ -95,8 +95,8 @@ def _litellm_executed_batch_runner(llm_router: Router, proxy_logging_obj: ProxyL ) -def _raise_when_input_file_must_be_managed(model: str, credentials: Mapping[str, object]) -> None: - if litellm_executed_provider_of(credentials) is None: +async def _raise_when_input_file_must_be_managed(model: str, credentials: Mapping[str, object]) -> None: + if await litellm_executed_provider_for(credentials) is None: return raise batch_error( 400, @@ -364,7 +364,9 @@ async def create_batch( detail={"error": "LLM Router not initialized. Ensure models added to proxy."}, ) - executed_provider: Final = resolve_litellm_executed_provider(llm_router, model, user_api_key_dict.team_id) + executed_provider: Final = await resolve_litellm_executed_provider( + llm_router, model, user_api_key_dict.team_id + ) response = ( await _litellm_executed_batch_runner(llm_router, proxy_logging_obj).create( create_request=_create_batch_data, @@ -395,7 +397,7 @@ async def create_batch( model_id=model_param, operation_context="batch creation", ) - _raise_when_input_file_must_be_managed(model_param, credentials) + await _raise_when_input_file_must_be_managed(model_param, credentials) prepare_data_with_credentials( data=_create_batch_data, diff --git a/litellm/proxy/batches_endpoints/litellm_executed_batches.py b/litellm/proxy/batches_endpoints/litellm_executed_batches.py index 77e583781ea..642b2ea5f8c 100644 --- a/litellm/proxy/batches_endpoints/litellm_executed_batches.py +++ b/litellm/proxy/batches_endpoints/litellm_executed_batches.py @@ -7,6 +7,7 @@ from itertools import pairwise from types import MappingProxyType from typing import TYPE_CHECKING, Final, Literal, Protocol, TypeAlias, runtime_checkable +import httpx from openai.types.batch import Errors from openai.types.batch_error import BatchError from openai.types.batch_request_counts import BatchRequestCounts @@ -21,6 +22,7 @@ from litellm.integrations.prometheus import PrometheusLogger from litellm.llms.base_llm.files.litellm_db_storage_backend import LITELLM_DB_STORAGE_BACKEND_NAME from litellm.llms.base_llm.files.storage_backend import BaseFileStorageBackend from litellm.llms.base_llm.files.storage_backend_factory import get_storage_backend +from litellm.llms.custom_httpx.http_handler import get_async_httpx_client from litellm.models.managed_files import LiteLLM_ManagedFileTable from litellm.proxy._types import ProxyErrorTypes, ProxyException, UserAPIKeyAuth from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup @@ -33,7 +35,7 @@ from litellm.proxy.openai_files_endpoints.storage_backend_service import Storage from litellm.proxy.utils import PrismaClient, ProxyLogging from litellm.repositories.table_repositories import ManagedObjectRepository from litellm.types.llms.openai import LiteLLMBatchCreateRequest, OpenAIFileObject, OpenAIFilesPurpose -from litellm.types.utils import LITELLM_EXECUTED_BATCH_PROVIDERS, ExtractedFileData, LiteLLMBatch +from litellm.types.utils import LITELLM_EXECUTED_BATCH_PROVIDERS, ExtractedFileData, LiteLLMBatch, LlmProviders if TYPE_CHECKING: from prisma import models as prisma_models @@ -46,6 +48,7 @@ BatchStatus: TypeAlias = Literal["in_progress", "finalizing", "completed", "fail TERMINAL_BATCH_STATUSES: Final[frozenset[str]] = frozenset({"completed", "failed", "cancelled", "expired"}) _BATCH_ENDPOINT_ADAPTER: Final[TypeAdapter[BatchEndpoint]] = TypeAdapter(BatchEndpoint) _CANCEL_POLL_SECONDS: Final = 1.0 +_FILES_API_PROBE_TIMEOUT_SECONDS: Final = 5.0 _COMPLETION_WINDOW_SECONDS: Final = 24 * 60 * 60 LITELLM_EXECUTED_BATCH_UPLOAD_GUIDANCE: Final = ( "upload it through POST /v1/files with purpose=batch and either the x-litellm-model header or the " @@ -184,9 +187,67 @@ def litellm_executed_provider_of(credentials: Mapping[str, object]) -> str | Non return provider if provider in LITELLM_EXECUTED_BATCH_PROVIDERS else None -def resolve_litellm_executed_provider(llm_router: "Router", model: str, team_id: str | None) -> str | None: +class _HttpGetter(Protocol): + async def get( + self, url: str, *, headers: dict[str, str] | None = None, timeout: float | httpx.Timeout | None = None + ) -> httpx.Response: ... + + +class FilesApiProbe(Protocol): + async def __call__(self, api_base: str, api_key: str | None) -> bool: ... + + +async def upstream_lacks_files_api(api_base: str, api_key: str | None, http_client: _HttpGetter | None = None) -> bool: + client: Final = http_client or get_async_httpx_client(llm_provider=LlmProviders.HOSTED_VLLM) + try: + response: Final = await client.get( + f"{api_base.rstrip('/')}/files", + headers={"Authorization": f"Bearer {api_key}"} if api_key else None, + timeout=_FILES_API_PROBE_TIMEOUT_SECONDS, + ) + except httpx.HTTPError: + return False + return response.status_code == httpx.codes.NOT_FOUND + + +def _upstream_of(credentials: Mapping[str, object], provider: str) -> tuple[str, str | None] | None: + model: Final = credentials.get("model") + api_base: Final = credentials.get("api_base") + api_key: Final = credentials.get("api_key") + if not isinstance(model, str): + return None + try: + _, _, resolved_api_key, resolved_api_base = litellm.get_llm_provider( + model=model, + custom_llm_provider=provider, + api_base=api_base if isinstance(api_base, str) else None, + api_key=api_key if isinstance(api_key, str) else None, + ) + except Exception: # noqa: BLE001 # get_llm_provider raises on a model it cannot map, which means nothing to probe + return None + return None if resolved_api_base is None else (resolved_api_base, resolved_api_key) + + +async def litellm_executed_provider_for( + credentials: Mapping[str, object], lacks_files_api: FilesApiProbe = upstream_lacks_files_api +) -> str | None: + provider: Final = litellm_executed_provider_of(credentials) + if provider is None: + return None + upstream: Final = _upstream_of(credentials, provider) + if upstream is None: + return None + return provider if await lacks_files_api(*upstream) else None + + +async def resolve_litellm_executed_provider( + llm_router: "Router", + model: str, + team_id: str | None, + lacks_files_api: FilesApiProbe = upstream_lacks_files_api, +) -> str | None: credentials: Final = llm_router.get_deployment_credentials_with_provider(model_id=model, team_id=team_id) - return None if credentials is None else litellm_executed_provider_of(credentials) + return None if credentials is None else await litellm_executed_provider_for(credentials, lacks_files_api) def _provider_of(model: object) -> str | None: diff --git a/litellm/proxy/openai_files_endpoints/files_endpoints.py b/litellm/proxy/openai_files_endpoints/files_endpoints.py index ad869100fb9..b8dcb89baef 100644 --- a/litellm/proxy/openai_files_endpoints/files_endpoints.py +++ b/litellm/proxy/openai_files_endpoints/files_endpoints.py @@ -102,24 +102,35 @@ from litellm.types.llms.openai import ( router: Final = APIRouter() -def _litellm_executed_batch_input_model( +async def _litellm_executed_batch_input_model( llm_router: Router | None, purpose: OpenAIFilesPurpose, model: str | None, target_model_names_list: Sequence[str], team_id: str | None, ) -> str | None: - if purpose != "batch" or llm_router is None: + if llm_router is None: return None candidates: Final = (model,) if model is not None else tuple(target_model_names_list) + providers: Final = await asyncio.gather( + *(resolve_litellm_executed_provider(llm_router, candidate, team_id) for candidate in candidates) + ) executed: Final = tuple( - candidate - for candidate in candidates - if resolve_litellm_executed_provider(llm_router, candidate, team_id) is not None + candidate for candidate, provider in zip(candidates, providers, strict=True) if provider is not None ) match executed: case (): return None + case _ if purpose != "batch": + raise ProxyException( + message=( + f"The server behind {', '.join(executed)} has no Files API, so LiteLLM keeps only batch input " + f"files for it and runs the batch itself: upload with purpose=batch; got purpose={purpose}" + ), + type="invalid_request_error", + param="purpose", + code=400, + ) case (only,) if len(candidates) == 1: return only case _: @@ -279,7 +290,7 @@ async def route_create_file( 5. Else -> use custom_llm_provider with files_settings """ - executed_model: Final = _litellm_executed_batch_input_model( + executed_model: Final = await _litellm_executed_batch_input_model( llm_router, purpose, model, target_model_names_list, user_api_key_dict.team_id ) explicit_storage: Final = target_storage if target_storage and target_storage != "default" else None diff --git a/tests/test_litellm/proxy/batches_endpoints/test_endpoints.py b/tests/test_litellm/proxy/batches_endpoints/test_endpoints.py index e655438a672..3a2ddf50143 100644 --- a/tests/test_litellm/proxy/batches_endpoints/test_endpoints.py +++ b/tests/test_litellm/proxy/batches_endpoints/test_endpoints.py @@ -37,7 +37,9 @@ from dataclasses import dataclass from typing import Any, Dict, Optional from unittest.mock import AsyncMock, MagicMock, patch +import httpx import pytest +import respx from litellm_enterprise.proxy.hooks.managed_files import _PROXY_LiteLLMManagedFiles import litellm @@ -152,6 +154,7 @@ class Harness: router: MagicMock logging: MagicMock creds_resolver: MagicMock + upstream_files_route: respx.Route @property def router_acreate(self) -> AsyncMock: @@ -174,7 +177,7 @@ def _creds_lookup(*, model_id: str, team_id: str | None = None) -> dict[str, str @pytest.fixture -def harness(): +def harness(monkeypatch: pytest.MonkeyPatch): """Seam harness. Patches only true I/O boundaries; pure encode/decode/merge helpers run for real. Object mocks are spec'd so unknown method calls raise.""" body_holder: Dict[str, Any] = {} @@ -194,6 +197,7 @@ def harness(): provider_from_headers = MagicMock(return_value=None) is_known_model = MagicMock(return_value=False) litellm_acreate = AsyncMock(return_value=make_batch()) + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) with ExitStack() as stack: stack.enter_context(patch.object(endpoints, "_read_request_body", read_body)) @@ -215,6 +219,10 @@ def harness(): stack.enter_context(patch.object(endpoints, "is_known_model", is_known_model)) stack.enter_context(patch.object(litellm, "acreate_batch", litellm_acreate)) stack.enter_context(patch.object(litellm, "enable_loadbalancing_on_batch_endpoints", False)) + upstream = stack.enter_context(respx.mock(assert_all_called=False)) + upstream_files_route = upstream.get(f"{CREDS['my-vllm']['api_base']}/files").mock( + return_value=httpx.Response(404, json={"detail": "Not Found"}) + ) stack.enter_context(patch.object(proxy_server, "llm_router", router)) stack.enter_context(patch.object(proxy_server, "proxy_logging_obj", logging)) stack.enter_context(patch.object(proxy_server, "general_settings", {})) @@ -233,6 +241,7 @@ def harness(): router=router, logging=logging, creds_resolver=router.get_deployment_credentials_with_provider, + upstream_files_route=upstream_files_route, ) yield h @@ -843,6 +852,25 @@ async def test_create__unified_executed_provider_without_database_400(harness): harness.litellm_acreate.assert_not_called() +@pytest.mark.asyncio +async def test_create__unified_executed_provider_with_its_own_files_api_goes_to_the_provider(harness, executed_runner): + runner, factory = executed_runner + harness.upstream_files_route.mock(return_value=httpx.Response(200, json={"object": "list", "data": []})) + set_body( + harness, + { + "input_file_id": _managed_input_file_id("my-vllm"), + "endpoint": "/v1/chat/completions", + "completion_window": "24h", + }, + ) + await call_create(harness) + + factory.assert_not_called() + runner.create.assert_not_called() + assert harness.router_kwargs()["model"] == "my-vllm" + + @pytest.mark.asyncio async def test_create__unified_provider_model_never_touches_executed_runner(harness, executed_runner): runner, factory = executed_runner @@ -879,6 +907,26 @@ async def test_create__raw_file_with_executed_model_400_with_upload_guidance(har harness.router_acreate.assert_not_called() +@pytest.mark.asyncio +@pytest.mark.parametrize( + "upstream_answer", + [httpx.Response(200, json={"object": "list", "data": []}), httpx.Response(405), httpx.ConnectError("refused")], + ids=["lists files", "files route without list", "unreachable"], +) +async def test_create__raw_file_with_executed_model_is_forwarded_unless_the_server_lacks_a_files_api( + harness, upstream_answer +): + harness.upstream_files_route.mock(side_effect=[upstream_answer]) + set_body(harness, {"input_file_id": "file-plain", "endpoint": "/v1/chat/completions", "completion_window": "24h"}) + + await call_create(harness, headers={"x-litellm-model": "my-vllm"}) + + forwarded = harness.acreate_kwargs() + assert forwarded["input_file_id"] == "file-plain" + assert forwarded["custom_llm_provider"] == "hosted_vllm" + assert forwarded["api_base"] == CREDS["my-vllm"]["api_base"] + + @pytest.mark.asyncio async def test_create__model_encoded_beats_unified(harness): """Precedence row: a file id that is BOTH model-encoded and (pretend) unified diff --git a/tests/test_litellm/proxy/batches_endpoints/test_litellm_executed_batches.py b/tests/test_litellm/proxy/batches_endpoints/test_litellm_executed_batches.py index 9c775fd2c97..96e054272a4 100644 --- a/tests/test_litellm/proxy/batches_endpoints/test_litellm_executed_batches.py +++ b/tests/test_litellm/proxy/batches_endpoints/test_litellm_executed_batches.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from typing import Final, Literal, cast from unittest.mock import AsyncMock, MagicMock +import httpx import pytest from litellm_enterprise.proxy.hooks.managed_files import _PROXY_LiteLLMManagedFiles from openai.types.batch_request_counts import BatchRequestCounts @@ -19,9 +20,11 @@ from litellm.proxy.batches_endpoints.litellm_executed_batches import ( InvalidBatchInput, LiteLLMExecutedBatchRunner, _resolve_transition, + litellm_executed_provider_for, litellm_executed_provider_of, parse_batch_input, resolve_litellm_executed_provider, + upstream_lacks_files_api, ) from litellm.proxy.openai_files_endpoints.common_utils import ( _is_base64_encoded_unified_file_id, @@ -424,15 +427,107 @@ def test_litellm_executed_provider_of(credentials: Mapping[str, object], expecte assert litellm_executed_provider_of(credentials) == expected +VLLM_CREDENTIALS: Final[Mapping[str, object]] = { + "model": "hosted_vllm/qwen", + "api_base": "http://vllm.test/v1/", + "api_key": "vllm-key", +} + + +@dataclass(slots=True) +class FakeFilesApiProbe: + lacks_files_api: bool + upstreams: list[tuple[str, str | None]] + + async def __call__(self, api_base: str, api_key: str | None) -> bool: + self.upstreams.append((api_base, api_key)) + return self.lacks_files_api + + +@dataclass(slots=True) +class FakeHttpGetter: + outcome: int | httpx.HTTPError + requests: list[tuple[str, dict[str, str] | None]] + + async def get( + self, url: str, *, headers: dict[str, str] | None = None, timeout: float | httpx.Timeout | None = None + ) -> httpx.Response: + self.requests.append((url, headers)) + if isinstance(self.outcome, httpx.HTTPError): + raise self.outcome + return httpx.Response(self.outcome) + + @pytest.mark.parametrize( - ("credentials", "expected"), [(None, None), ({"model": "hosted_vllm/qwen"}, "hosted_vllm")], ids=["unknown", "vllm"] + ("outcome", "expected"), + [ + (404, True), + (200, False), + (405, False), + (401, False), + (500, False), + (httpx.ConnectError("refused"), False), + (httpx.ReadTimeout("slow"), False), + ], + ids=["no files route", "lists files", "files route without list", "unauthorized", "server error", "down", "slow"], ) -def test_resolve_litellm_executed_provider_asks_the_router_for_the_team_scoped_deployment( +async def test_upstream_lacks_files_api_only_when_the_files_route_is_a_404( + outcome: int | httpx.HTTPError, expected: bool +) -> None: + assert await upstream_lacks_files_api("http://vllm.test/v1", "vllm-key", FakeHttpGetter(outcome, [])) is expected + + +@pytest.mark.parametrize( + ("api_base", "api_key", "expected_headers"), + [ + ("http://vllm.test/v1/", "vllm-key", {"Authorization": "Bearer vllm-key"}), + ("http://vllm.test/v1", None, None), + ], + ids=["trailing slash with key", "keyless"], +) +async def test_upstream_lacks_files_api_asks_the_files_route_under_the_api_base( + api_base: str, api_key: str | None, expected_headers: dict[str, str] | None +) -> None: + http_client = FakeHttpGetter(404, []) + await upstream_lacks_files_api(api_base, api_key, http_client) + assert http_client.requests == [("http://vllm.test/v1/files", expected_headers)] + + +@pytest.mark.parametrize( + ("lacks_files_api", "expected"), [(True, "hosted_vllm"), (False, None)], ids=["bare", "router"] +) +async def test_litellm_executed_provider_for_leaves_a_server_with_its_own_files_api_alone( + lacks_files_api: bool, expected: str | None +) -> None: + probe = FakeFilesApiProbe(lacks_files_api, []) + assert await litellm_executed_provider_for(VLLM_CREDENTIALS, probe) == expected + assert probe.upstreams == [("http://vllm.test/v1/", "vllm-key")] + + +@pytest.mark.parametrize( + "credentials", + [{"custom_llm_provider": "openai", "model": "gpt-4o", "api_base": "http://openai.test/v1"}, {"model": 7}], + ids=["provider runs its own batches", "no model to resolve an api_base from"], +) +async def test_litellm_executed_provider_for_never_probes_what_it_would_not_run( + credentials: Mapping[str, object], +) -> None: + probe = FakeFilesApiProbe(True, []) + assert await litellm_executed_provider_for(credentials, probe) is None + assert probe.upstreams == [] + + +@pytest.mark.parametrize( + ("credentials", "expected"), [(None, None), (VLLM_CREDENTIALS, "hosted_vllm")], ids=["unknown", "vllm"] +) +async def test_resolve_litellm_executed_provider_asks_the_router_for_the_team_scoped_deployment( credentials: Mapping[str, object] | None, expected: str | None ) -> None: router = MagicMock(spec=Router) router.get_deployment_credentials_with_provider.return_value = credentials - assert resolve_litellm_executed_provider(router, BATCH_MODEL, "team-1") == expected + assert ( + await resolve_litellm_executed_provider(router, BATCH_MODEL, "team-1", FakeFilesApiProbe(True, [])) == expected + ) router.get_deployment_credentials_with_provider.assert_called_once_with(model_id=BATCH_MODEL, team_id="team-1") diff --git a/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py b/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py index 08d7bf8e0da..a8e0097b831 100644 --- a/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py +++ b/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py @@ -646,6 +646,7 @@ def batch_upload_seams(mocker: MockerFixture, monkeypatch): monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", llm_router) setup_proxy_logging_object(monkeypatch, llm_router) + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( user_role=LitellmUserRoles.PROXY_ADMIN, user_id="test-user" ) @@ -666,7 +667,11 @@ def batch_upload_seams(mocker: MockerFixture, monkeypatch): "litellm.acreate_file", new=mocker.AsyncMock(return_value=uploaded) ) try: - yield stored, provider_upload + with respx.mock(assert_all_called=False) as upstream: + upstream_files_route = upstream.get("http://vllm.test/v1/files").mock( + return_value=httpx.Response(404, json={"detail": "Not Found"}) + ) + yield stored, provider_upload, upstream_files_route finally: app.dependency_overrides.pop(ps.user_api_key_auth, None) @@ -688,7 +693,7 @@ def _upload_batch_file(headers: dict[str, str], form: dict[str, str]): def test_batch_upload_for_a_litellm_executed_model_is_kept_by_litellm( batch_upload_seams, headers: dict[str, str], form: dict[str, str] ): - stored, provider_upload = batch_upload_seams + stored, provider_upload, _ = batch_upload_seams response = _upload_batch_file(headers, form) @@ -702,7 +707,7 @@ def test_batch_upload_for_a_litellm_executed_model_is_kept_by_litellm( def test_batch_upload_naming_an_executed_and_a_provider_model_is_rejected(batch_upload_seams): - stored, provider_upload = batch_upload_seams + stored, provider_upload, _ = batch_upload_seams response = _upload_batch_file({}, {"target_model_names": "my-vllm,gemini-2.0-flash"}) @@ -713,8 +718,47 @@ def test_batch_upload_naming_an_executed_and_a_provider_model_is_rejected(batch_ provider_upload.assert_not_awaited() +@pytest.mark.parametrize("purpose", ["assistants", "user_data"]) +def test_non_batch_upload_for_a_litellm_executed_model_is_rejected_with_the_purpose_to_use( + batch_upload_seams, purpose: str +): + stored, provider_upload, _ = batch_upload_seams + + response = _upload_batch_file({"x-litellm-model": "my-vllm"}, {"purpose": purpose}) + + assert response.status_code == 400, response.text + error = response.json()["error"] + assert error["type"] == "invalid_request_error" + assert error["param"] == "purpose" + assert "purpose=batch" in error["message"] + assert f"purpose={purpose}" in error["message"] + stored.assert_not_awaited() + provider_upload.assert_not_awaited() + + +@pytest.mark.parametrize("purpose", ["batch", "assistants"]) +@pytest.mark.parametrize( + "upstream_answer", + [httpx.Response(200, json={"object": "list", "data": []}), httpx.Response(405), httpx.ConnectError("refused")], + ids=["lists files", "files route without list", "unreachable"], +) +def test_upload_for_a_litellm_executed_model_goes_to_the_provider_unless_the_server_lacks_a_files_api( + batch_upload_seams, upstream_answer: httpx.Response | httpx.ConnectError, purpose: str +): + stored, provider_upload, upstream_files_route = batch_upload_seams + upstream_files_route.mock(side_effect=[upstream_answer]) + + response = _upload_batch_file({"x-litellm-model": "my-vllm"}, {"purpose": purpose}) + + assert response.status_code == 200, response.text + stored.assert_not_awaited() + provider_upload.assert_awaited_once() + assert provider_upload.call_args.kwargs["custom_llm_provider"] == "hosted_vllm" + assert provider_upload.call_args.kwargs["api_base"] == "http://vllm.test/v1" + + def test_batch_upload_for_a_provider_model_still_goes_to_the_provider(batch_upload_seams): - stored, provider_upload = batch_upload_seams + stored, provider_upload, _ = batch_upload_seams response = _upload_batch_file({"x-litellm-model": "gemini-2.0-flash"}, {}) From a0957edc9cf1794728afdf7094e0554f6ade5b88 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 04:07:34 -0700 Subject: [PATCH 33/73] fix(batches): gate row credentials, heartbeat executed batches, clean orphaned uploads --- litellm/proxy/_lazy_openapi_snapshot.json | 2 +- litellm/proxy/batches_endpoints/endpoints.py | 47 +++++- .../litellm_executed_batches.py | 154 +++++++++++++----- .../openai_files_endpoints/files_endpoints.py | 48 +++--- .../storage_backend_service.py | 24 ++- .../proxy/batches_endpoints/test_endpoints.py | 37 +++++ .../test_litellm_executed_batches.py | 121 ++++++++++++++ .../test_storage_backend_service.py | 36 +++- 8 files changed, 391 insertions(+), 78 deletions(-) diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 18849ef5b64..73ea8cf1991 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -19622,7 +19622,7 @@ } } }, - "description": "\nUnified rate-limit error.\n\nEvery rate-limit condition surfaced by litellm \u2014 whether it originated from\nan upstream LLM provider, a vendor batch endpoint, or one of litellm's own\nproxy-side limiters (parallel-requests, dynamic-rate, batch-rate, budget,\nmax-iterations, etc.) \u2014 is raised as an instance of this class.\n\nThe :attr:`category` attribute lets callers distinguish the source. See\n:class:`RateLimitErrorCategory` for the available values.\n" + "description": "\n Unified rate-limit error.\n\n Every rate-limit condition surfaced by litellm \u2014 whether it originated from\n an upstream LLM provider, a vendor batch endpoint, or one of litellm's own\n proxy-side limiters (parallel-requests, dynamic-rate, batch-rate, budget,\n max-iterations, etc.) \u2014 is raised as an instance of this class.\n\n The :attr:`category` attribute lets callers distinguish the source. See\n :class:`RateLimitErrorCategory` for the available values.\n " }, "500": { "content": { diff --git a/litellm/proxy/batches_endpoints/endpoints.py b/litellm/proxy/batches_endpoints/endpoints.py index ba5853b7c46..19e56e97338 100644 --- a/litellm/proxy/batches_endpoints/endpoints.py +++ b/litellm/proxy/batches_endpoints/endpoints.py @@ -7,8 +7,9 @@ import asyncio import os from collections.abc import Mapping +from datetime import datetime from types import MappingProxyType -from typing import Any, Final, cast +from typing import TYPE_CHECKING, Any, Final, Literal, cast from fastapi import APIRouter, Depends, HTTPException, Path, Request, Response from pydantic import TypeAdapter @@ -24,6 +25,7 @@ from litellm.proxy.batches_endpoints.litellm_executed_batches import ( LiteLLMExecutedBatchRunner, ManagedBatchStore, batch_error, + executed_batch_runner_lost, litellm_executed_provider_for, resolve_litellm_executed_provider, ) @@ -61,12 +63,15 @@ from litellm.proxy.openai_files_endpoints.common_utils import ( ) from litellm.proxy.pass_through_endpoints.llm_provider_handlers.batch_attribution import request_tags_from_metadata from litellm.proxy.route_llm_request import raise_if_required_body_param_missing -from litellm.proxy.utils import ProxyLogging, handle_exception_on_proxy, is_known_model +from litellm.proxy.utils import PrismaClient, ProxyLogging, handle_exception_on_proxy, is_known_model from litellm.repositories.table_repositories import ManagedFileRepository from litellm.router import Router from litellm.types.llms.openai import LiteLLMBatchCreateRequest from litellm.types.utils import LiteLLMBatch +if TYPE_CHECKING: + from prisma.models import LiteLLM_ManagedObjectTable + router: Final = APIRouter() _METADATA_ADAPTER: Final[TypeAdapter[Mapping[str, object]]] = TypeAdapter(Mapping[str, object]) @@ -79,7 +84,7 @@ def _request_tags(data: Mapping[str, object]) -> tuple[str, ...] | None: def _litellm_executed_batch_runner(llm_router: Router, proxy_logging_obj: ProxyLogging) -> LiteLLMExecutedBatchRunner: - from litellm.proxy.proxy_server import prisma_client + from litellm.proxy.proxy_server import general_settings, prisma_client managed_files: Final = proxy_logging_obj.get_proxy_hook("managed_files") if prisma_client is None or not isinstance(managed_files, ManagedBatchStore): @@ -92,9 +97,36 @@ def _litellm_executed_batch_runner(llm_router: Router, proxy_logging_obj: ProxyL prisma_client=prisma_client, managed_files=managed_files, proxy_logging_obj=proxy_logging_obj, + general_settings=general_settings, ) +async def _batch_from_database( + batch_id: str, + unified_batch_id: str | Literal[False], + executed_batch: bool, + managed_files_obj: object, + prisma_client: PrismaClient | None, + llm_router: Router | None, + proxy_logging_obj: ProxyLogging, + user_api_key_dict: UserAPIKeyAuth, +) -> tuple["LiteLLM_ManagedObjectTable | None", LiteLLMBatch | None]: + row, batch = await get_batch_from_database( + batch_id=batch_id, + unified_batch_id=unified_batch_id, + managed_files_obj=managed_files_obj, + prisma_client=prisma_client, + verbose_proxy_logger=verbose_proxy_logger, + ) + updated_at: Final[object] = getattr(row, "updated_at", None) + if not executed_batch or batch is None or llm_router is None or not isinstance(updated_at, datetime): + return row, batch + if not executed_batch_runner_lost(batch.status, updated_at): + return row, batch + runner: Final = _litellm_executed_batch_runner(llm_router, proxy_logging_obj) + return row, await runner.fail_abandoned(batch, user_api_key_dict) + + async def _raise_when_input_file_must_be_managed(model: str, credentials: Mapping[str, object]) -> None: if await litellm_executed_provider_for(credentials) is None: return @@ -548,15 +580,18 @@ async def retrieve_batch( managed_files_obj: Final = proxy_logging_obj.get_proxy_hook("managed_files") from litellm.proxy.proxy_server import prisma_client - db_batch_object, response = await get_batch_from_database( + executed_batch: Final = isinstance(unified_batch_id, str) and is_litellm_executed_batch(unified_batch_id) + db_batch_object, response = await _batch_from_database( batch_id=batch_id, unified_batch_id=unified_batch_id, + executed_batch=executed_batch, managed_files_obj=managed_files_obj, prisma_client=prisma_client, - verbose_proxy_logger=verbose_proxy_logger, + llm_router=llm_router, + proxy_logging_obj=proxy_logging_obj, + user_api_key_dict=user_api_key_dict, ) - executed_batch: Final = isinstance(unified_batch_id, str) and is_litellm_executed_batch(unified_batch_id) if executed_batch and response is None: raise batch_error(404, f"No batch found with id '{batch_id}'.") diff --git a/litellm/proxy/batches_endpoints/litellm_executed_batches.py b/litellm/proxy/batches_endpoints/litellm_executed_batches.py index 642b2ea5f8c..7bd4c678183 100644 --- a/litellm/proxy/batches_endpoints/litellm_executed_batches.py +++ b/litellm/proxy/batches_endpoints/litellm_executed_batches.py @@ -3,6 +3,7 @@ import json import time from collections.abc import Awaitable, Callable, Mapping, Sequence from dataclasses import dataclass +from datetime import datetime, timezone from itertools import pairwise from types import MappingProxyType from typing import TYPE_CHECKING, Final, Literal, Protocol, TypeAlias, runtime_checkable @@ -12,7 +13,7 @@ from openai.types.batch import Errors from openai.types.batch_error import BatchError from openai.types.batch_request_counts import BatchRequestCounts from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError -from typing_extensions import ReadOnly, TypedDict, assert_never +from typing_extensions import ReadOnly, TypedDict import litellm from litellm._logging import verbose_proxy_logger @@ -25,6 +26,7 @@ from litellm.llms.base_llm.files.storage_backend_factory import get_storage_back from litellm.llms.custom_httpx.http_handler import get_async_httpx_client from litellm.models.managed_files import LiteLLM_ManagedFileTable from litellm.proxy._types import ProxyErrorTypes, ProxyException, UserAPIKeyAuth +from litellm.proxy.auth.auth_utils import is_request_body_safe from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup from litellm.proxy.openai_files_endpoints.common_utils import ( LITELLM_EXECUTED_BATCH_ID_PREFIX, @@ -44,12 +46,26 @@ if TYPE_CHECKING: BatchEndpoint: TypeAlias = Literal["/v1/chat/completions", "/v1/embeddings", "/v1/completions", "/v1/responses"] BatchStatus: TypeAlias = Literal["in_progress", "finalizing", "completed", "failed", "cancelling", "cancelled"] - TERMINAL_BATCH_STATUSES: Final[frozenset[str]] = frozenset({"completed", "failed", "cancelled", "expired"}) +_STOP_STATUSES: Final[frozenset[str]] = TERMINAL_BATCH_STATUSES | frozenset({"cancelling"}) _BATCH_ENDPOINT_ADAPTER: Final[TypeAdapter[BatchEndpoint]] = TypeAdapter(BatchEndpoint) _CANCEL_POLL_SECONDS: Final = 1.0 +_HEARTBEAT_SECONDS: Final = 30.0 +_STALE_AFTER_SECONDS: Final = 180.0 _FILES_API_PROBE_TIMEOUT_SECONDS: Final = 5.0 _COMPLETION_WINDOW_SECONDS: Final = 24 * 60 * 60 +_RUNNER_LOST_MESSAGE: Final = "the proxy replica running this batch stopped before it finished; resubmit the batch" +_ROUTER_METHODS: Final[Mapping[BatchEndpoint, str]] = MappingProxyType( + { + "/v1/chat/completions": "acompletion", + "/v1/completions": "atext_completion", + "/v1/embeddings": "aembedding", + "/v1/responses": "aresponses", + } +) +_CANCELLING_TRANSITIONS: Final[Mapping[BatchStatus, BatchStatus]] = MappingProxyType( + {"completed": "cancelled", "in_progress": "cancelling", "finalizing": "cancelling"} +) LITELLM_EXECUTED_BATCH_UPLOAD_GUIDANCE: Final = ( "upload it through POST /v1/files with purpose=batch and either the x-litellm-model header or the " "target_model_names form field naming the model, so LiteLLM keeps the file and runs the batch itself" @@ -165,20 +181,6 @@ class _RouterCall(Protocol): def __call__(self, **params: object) -> Awaitable[object]: ... # kwargs-ok: the request body is passed as keywords -def _router_method_name(endpoint: BatchEndpoint) -> str: - match endpoint: - case "/v1/chat/completions": - return "acompletion" - case "/v1/completions": - return "atext_completion" - case "/v1/embeddings": - return "aembedding" - case "/v1/responses": - return "aresponses" - case _: - assert_never(endpoint) - - def litellm_executed_provider_of(credentials: Mapping[str, object]) -> str | None: explicit_provider: Final = credentials.get("custom_llm_provider") provider: Final = ( @@ -197,12 +199,20 @@ class FilesApiProbe(Protocol): async def __call__(self, api_base: str, api_key: str | None) -> bool: ... +class BodyRejection(Protocol): + def __call__(self, body: Mapping[str, object], /) -> str | None: ... + + async def upstream_lacks_files_api(api_base: str, api_key: str | None, http_client: _HttpGetter | None = None) -> bool: client: Final = http_client or get_async_httpx_client(llm_provider=LlmProviders.HOSTED_VLLM) try: response: Final = await client.get( f"{api_base.rstrip('/')}/files", - headers={"Authorization": f"Bearer {api_key}"} if api_key else None, + headers=( + {"Authorization": f"Bearer {api_key}"} # mutable-ok: AsyncHTTPHandler.get wants a plain dict + if api_key + else None + ), timeout=_FILES_API_PROBE_TIMEOUT_SECONDS, ) except httpx.HTTPError: @@ -266,7 +276,13 @@ def _validation_reason(error: ValidationError) -> str: ) -def _parse_line(line_number: int, raw: bytes, endpoint: BatchEndpoint) -> BatchInputLine | InvalidBatchInput: +def _accept_every_body(_body: Mapping[str, object]) -> str | None: + return None + + +def _parse_line( + line_number: int, raw: bytes, endpoint: BatchEndpoint, reject_body: BodyRejection +) -> BatchInputLine | InvalidBatchInput: try: line: Final = BatchInputLine.model_validate_json(raw) except ValidationError as e: @@ -275,14 +291,19 @@ def _parse_line(line_number: int, raw: bytes, endpoint: BatchEndpoint) -> BatchI return InvalidBatchInput(line_number, f"url {line.url!r} does not match the batch endpoint {endpoint!r}") if line.body.get("stream"): return InvalidBatchInput(line_number, "streaming requests are not supported in a batch") + rejection: Final = reject_body(line.body) + if rejection is not None: + return InvalidBatchInput(line_number, rejection) return line -def parse_batch_input(content: bytes, endpoint: BatchEndpoint) -> tuple[BatchInputLine, ...] | InvalidBatchInput: +def parse_batch_input( + content: bytes, endpoint: BatchEndpoint, reject_body: BodyRejection = _accept_every_body +) -> tuple[BatchInputLine, ...] | InvalidBatchInput: raw_lines: Final = tuple((number, raw) for number, raw in enumerate(content.splitlines(), start=1) if raw.strip()) if not raw_lines: return InvalidBatchInput(None, "the input file has no requests") - parsed: Final = tuple(_parse_line(number, raw, endpoint) for number, raw in raw_lines) + parsed: Final = tuple(_parse_line(number, raw, endpoint, reject_body) for number, raw in raw_lines) first_invalid: Final = next((item for item in parsed if isinstance(item, InvalidBatchInput)), None) if first_invalid is not None: return first_invalid @@ -345,37 +366,35 @@ def _dump(response: object) -> Mapping[str, object]: def _resolve_transition(current_status: str, requested: BatchStatus) -> BatchStatus: if current_status != "cancelling": return requested - match requested: - case "completed": - return "cancelled" - case "in_progress" | "finalizing": - return "cancelling" - case "failed" | "cancelling" | "cancelled": - return requested - case _: - assert_never(requested) + return _CANCELLING_TRANSITIONS.get(requested, requested) + + +def executed_batch_runner_lost(status: str, updated_at: datetime) -> bool: + if status in TERMINAL_BATCH_STATUSES: + return False + return (datetime.now(timezone.utc) - updated_at).total_seconds() > _STALE_AFTER_SECONDS def _llm_batch_id_of(unified_batch_id: str) -> str: return get_batch_id_from_unified_batch_id(convert_b64_uid_to_unified_uid(unified_batch_id)) -class _CancelWatch: +class _StopWatch: def __init__(self, load_status: Callable[[], Awaitable[str | None]], interval_seconds: float) -> None: self._load_status = load_status self._interval_seconds = interval_seconds self._checked_at = float("-inf") - self._cancelling = False + self._stopped = False - async def cancelling(self) -> bool: - if self._cancelling: + async def stopped(self) -> bool: + if self._stopped: return True now: Final = time.monotonic() if now - self._checked_at < self._interval_seconds: return False self._checked_at = now - self._cancelling = await self._load_status() == "cancelling" - return self._cancelling + self._stopped = await self._load_status() in _STOP_STATUSES + return self._stopped class LiteLLMExecutedBatchRunner: @@ -385,7 +404,9 @@ class LiteLLMExecutedBatchRunner: prisma_client: PrismaClient, managed_files: ManagedBatchStore, proxy_logging_obj: ProxyLogging, + general_settings: Mapping[str, object], concurrency: int = LITELLM_EXECUTED_BATCH_CONCURRENCY, + heartbeat_seconds: float = _HEARTBEAT_SECONDS, storage_backend_factory: _StorageBackendFactory = get_storage_backend, upload_result_file: _ResultFileUploader = StorageBackendFileService.upload_file_to_storage_backend, ) -> None: @@ -393,7 +414,9 @@ class LiteLLMExecutedBatchRunner: self.prisma_client = prisma_client self.managed_files = managed_files self.proxy_logging_obj = proxy_logging_obj + self.general_settings = general_settings self.concurrency = concurrency + self.heartbeat_seconds = heartbeat_seconds self.storage_backend_factory = storage_backend_factory self.upload_result_file = upload_result_file @@ -408,7 +431,7 @@ class LiteLLMExecutedBatchRunner: ) -> LiteLLMBatch: endpoint: Final = _validate_endpoint(create_request.get("endpoint")) content: Final = await self._download_input(unified_input_file_id, user_api_key_dict) - parsed: Final = parse_batch_input(content, endpoint) + parsed: Final = parse_batch_input(content, endpoint, self._body_rejection(model)) if isinstance(parsed, InvalidBatchInput): raise batch_error(400, f"Invalid batch input file: {parsed.describe()}") llm_batch_id: Final = f"{LITELLM_EXECUTED_BATCH_ID_PREFIX}{uuid_module.uuid4().hex}" @@ -468,6 +491,30 @@ class LiteLLMExecutedBatchRunner: await self._store(cancelling, user_api_key_dict) return cancelling + async def fail_abandoned(self, batch: LiteLLMBatch, user_api_key_dict: UserAPIKeyAuth) -> LiteLLMBatch: + error: Final = BatchError(message=_RUNNER_LOST_MESSAGE, code="runner_lost") + errors: Final = Errors(data=[error], object="list") # mutable-ok: Errors.data is typed as a list + failed: Final = batch.model_copy( + update=MappingProxyType({"status": "failed", "failed_at": int(time.time()), "errors": errors}) + ) + await self._store(failed, user_api_key_dict) + return failed + + def _body_rejection(self, model: str) -> BodyRejection: + def reject(body: Mapping[str, object]) -> str | None: + try: + is_request_body_safe( + request_body=dict(body), # mutable-ok: is_request_body_safe takes a dict + general_settings=dict(self.general_settings), # mutable-ok: is_request_body_safe takes a dict + llm_router=self.llm_router, + model=model, + ) + except ValueError as e: + return str(e) + return None + + return reject + async def _download_input(self, unified_input_file_id: str, user_api_key_dict: UserAPIKeyAuth) -> bytes: stored: Final = await self.managed_files.get_unified_file_id( unified_input_file_id, litellm_parent_otel_span=user_api_key_dict.parent_otel_span @@ -485,6 +532,7 @@ class LiteLLMExecutedBatchRunner: raise batch_error(400, str(e)) async def _run(self, run: _BatchRun) -> None: + heartbeat: Final = asyncio.create_task(self._heartbeat(run)) try: await self._execute(run) except Exception as e: # noqa: BLE001 # whatever fails, the batch must end up marked failed @@ -497,14 +545,31 @@ class LiteLLMExecutedBatchRunner: verbose_proxy_logger.exception( "LiteLLM-executed batch %s could not be marked failed: %s", run.unified_batch_id, advance_error ) + finally: + heartbeat.cancel() + + async def _heartbeat(self, run: _BatchRun) -> None: + while True: + await asyncio.sleep(self.heartbeat_seconds) + try: + await self._touch(run) + except Exception as e: # noqa: BLE001 # a missed beat is logged and the next one retries + verbose_proxy_logger.warning("LiteLLM-executed batch %s heartbeat failed: %s", run.unified_batch_id, e) + + async def _touch(self, run: _BatchRun) -> None: + await ManagedObjectRepository(self.prisma_client).table.update_many( + where={"unified_object_id": run.unified_batch_id}, # mutable-ok: Prisma filter + data={"updated_by": run.user_api_key_dict.user_id}, # mutable-ok: Prisma payload + ) async def _execute(self, run: _BatchRun) -> None: await self._advance(run, "in_progress") - watch: Final = _CancelWatch(lambda: self._load_status(run.unified_batch_id), _CANCEL_POLL_SECONDS) + watch: Final = _StopWatch(lambda: self._load_status(run.unified_batch_id), _CANCEL_POLL_SECONDS) semaphore: Final = asyncio.Semaphore(self.concurrency) results: Final = await asyncio.gather(*(self._run_row(run, line, watch, semaphore) for line in run.lines)) outcomes: Final = tuple(outcome for outcome in results if outcome is not None) - await self._advance(run, "finalizing") + if await self._advance(run, "finalizing") is None: + return succeeded: Final = tuple(outcome for outcome in outcomes if outcome.succeeded) failed: Final = tuple(outcome for outcome in outcomes if not outcome.succeeded) output_file_id: Final = await self._upload_results(run, "output", succeeded) @@ -519,10 +584,10 @@ class LiteLLMExecutedBatchRunner: ) async def _run_row( - self, run: _BatchRun, line: BatchInputLine, watch: _CancelWatch, semaphore: asyncio.Semaphore + self, run: _BatchRun, line: BatchInputLine, watch: _StopWatch, semaphore: asyncio.Semaphore ) -> RowOutcome | None: async with semaphore: - if await watch.cancelling(): + if await watch.stopped(): return None try: body: Final = await self._dispatch(run, line) @@ -537,7 +602,7 @@ class LiteLLMExecutedBatchRunner: return _dump(await self._router_call(run.endpoint)(**params)) def _router_call(self, endpoint: BatchEndpoint) -> _RouterCall: - method: Final[object] = getattr(self.llm_router, _router_method_name(endpoint), None) + method: Final[object] = getattr(self.llm_router, _ROUTER_METHODS[endpoint], None) if not isinstance(method, _RouterCall): raise TypeError(f"the router has no callable for {endpoint}") return method @@ -574,15 +639,20 @@ class LiteLLMExecutedBatchRunner: ) return file_object.id - async def _advance(self, run: _BatchRun, requested: BatchStatus, fields: Mapping[str, object] = _NO_FIELDS) -> None: + async def _advance( + self, run: _BatchRun, requested: BatchStatus, fields: Mapping[str, object] = _NO_FIELDS + ) -> BatchStatus | None: current: Final = await self._load_batch(run.unified_batch_id) if current is None: raise RuntimeError(f"Batch {run.unified_batch_id} is no longer stored") + if current.status in TERMINAL_BATCH_STATUSES: + return None status: Final = _resolve_transition(current.status, requested) updated: Final = current.model_copy( update=MappingProxyType({**fields, "status": status, f"{status}_at": int(time.time())}) ) await self._store(updated, run.user_api_key_dict) + return status async def _store(self, batch: LiteLLMBatch, user_api_key_dict: UserAPIKeyAuth) -> None: await self.managed_files.store_unified_object_id( diff --git a/litellm/proxy/openai_files_endpoints/files_endpoints.py b/litellm/proxy/openai_files_endpoints/files_endpoints.py index b8dcb89baef..7356d197be9 100644 --- a/litellm/proxy/openai_files_endpoints/files_endpoints.py +++ b/litellm/proxy/openai_files_endpoints/files_endpoints.py @@ -118,31 +118,29 @@ async def _litellm_executed_batch_input_model( executed: Final = tuple( candidate for candidate, provider in zip(candidates, providers, strict=True) if provider is not None ) - match executed: - case (): - return None - case _ if purpose != "batch": - raise ProxyException( - message=( - f"The server behind {', '.join(executed)} has no Files API, so LiteLLM keeps only batch input " - f"files for it and runs the batch itself: upload with purpose=batch; got purpose={purpose}" - ), - type="invalid_request_error", - param="purpose", - code=400, - ) - case (only,) if len(candidates) == 1: - return only - case _: - raise ProxyException( - message=( - f"LiteLLM runs batches for {', '.join(executed)} itself and keeps their input files, so a batch " - f"input file can target only that one model; got target_model_names={', '.join(candidates)}" - ), - type="invalid_request_error", - param="target_model_names", - code=400, - ) + if not executed: + return None + if purpose != "batch": + raise ProxyException( + message=( + f"The server behind {', '.join(executed)} has no Files API, so LiteLLM keeps only batch input " + f"files for it and runs the batch itself: upload with purpose=batch; got purpose={purpose}" + ), + type="invalid_request_error", + param="purpose", + code=400, + ) + if len(candidates) == 1: + return executed[0] + raise ProxyException( + message=( + f"LiteLLM runs batches for {', '.join(executed)} itself and keeps their input files, so a batch " + f"input file can target only that one model; got target_model_names={', '.join(candidates)}" + ), + type="invalid_request_error", + param="target_model_names", + code=400, + ) _MAX_BATCH_FILE_SIZE_MB_ADAPTER: Final = TypeAdapter(int | None) diff --git a/litellm/proxy/openai_files_endpoints/storage_backend_service.py b/litellm/proxy/openai_files_endpoints/storage_backend_service.py index b4a36336c22..66dbcd87c0b 100644 --- a/litellm/proxy/openai_files_endpoints/storage_backend_service.py +++ b/litellm/proxy/openai_files_endpoints/storage_backend_service.py @@ -12,6 +12,7 @@ from typing import Any, Final, cast from litellm._logging import verbose_proxy_logger from litellm._uuid import uuid as uuid_module +from litellm.llms.base_llm.files.storage_backend import BaseFileStorageBackend from litellm.llms.base_llm.files.storage_backend_factory import get_storage_backend from litellm.llms.base_llm.files.transformation import BaseFileEndpoints from litellm.proxy._types import ProxyException, UserAPIKeyAuth @@ -105,8 +106,9 @@ class StorageBackendFileService: storage_url=storage_url, ) - # Store in managed files if target_model_names provided - if target_model_names: + if not target_model_names: + return file_object + try: await StorageBackendFileService._store_in_managed_files( file_object=file_object, file_data=file_data, @@ -116,9 +118,25 @@ class StorageBackendFileService: proxy_logging_obj=proxy_logging_obj, user_api_key_dict=user_api_key_dict, ) - + except Exception: + await StorageBackendFileService._discard_orphaned_content(storage_backend, storage_url, target_storage) + raise return file_object + @staticmethod + async def _discard_orphaned_content( + storage_backend: BaseFileStorageBackend, storage_url: str, target_storage: str + ) -> None: + try: + await storage_backend.delete_file(storage_url) + except Exception as e: # noqa: BLE001 # the metadata failure is what surfaces; a failed cleanup is only logged + verbose_proxy_logger.warning( + "Could not delete orphaned content at %s on %s after its metadata write failed: %s", + storage_url, + target_storage, + e, + ) + @staticmethod def _create_file_object_with_storage_metadata( file_content: bytes, diff --git a/tests/test_litellm/proxy/batches_endpoints/test_endpoints.py b/tests/test_litellm/proxy/batches_endpoints/test_endpoints.py index 3a2ddf50143..59c0e05d97b 100644 --- a/tests/test_litellm/proxy/batches_endpoints/test_endpoints.py +++ b/tests/test_litellm/proxy/batches_endpoints/test_endpoints.py @@ -34,6 +34,7 @@ import json import logging from contextlib import ExitStack from dataclasses import dataclass +from datetime import datetime, timedelta, timezone from typing import Any, Dict, Optional from unittest.mock import AsyncMock, MagicMock, patch @@ -1722,6 +1723,7 @@ async def test_retrieve__db_non_terminal_state_syncs_with_provider(retrieve_harn async def test_retrieve__executed_batch_served_from_db_in_every_status(retrieve_harness, status): db_response = make_batch(id="litellm-executed-batch", status=status) db_batch_object = MagicMock() + db_batch_object.updated_at = datetime.now(timezone.utc) retrieve_harness.get_batch_from_db.return_value = (db_batch_object, db_response) resp = await call_retrieve(retrieve_harness, EXECUTED_BATCH_B64) @@ -1734,6 +1736,41 @@ async def test_retrieve__executed_batch_served_from_db_in_every_status(retrieve_ assert retrieve_harness.ensure_managed_files.call_args.kwargs["unified_batch_id"] == EXECUTED_BATCH_ID +@pytest.mark.asyncio +async def test_retrieve__executed_batch_abandoned_by_its_runner_is_served_failed(retrieve_harness, executed_runner): + runner, _ = executed_runner + failed = make_batch(id="litellm-executed-batch", status="failed") + runner.fail_abandoned = AsyncMock(return_value=failed) + db_response = make_batch(id="litellm-executed-batch", status="in_progress") + db_batch_object = MagicMock() + db_batch_object.updated_at = datetime.now(timezone.utc) - timedelta(minutes=10) + retrieve_harness.get_batch_from_db.return_value = (db_batch_object, db_response) + user = UserAPIKeyAuth(api_key="sk-test", user_id="user-1") + + resp = await call_retrieve(retrieve_harness, EXECUTED_BATCH_B64, user=user) + + assert resp is failed + runner.fail_abandoned.assert_awaited_once_with(db_response, user) + retrieve_harness.litellm_aretrieve.assert_not_called() + retrieve_harness.router_aretrieve.assert_not_called() + retrieve_harness.ensure_managed_files.assert_called_once() + + +@pytest.mark.asyncio +async def test_retrieve__executed_batch_with_a_fresh_heartbeat_is_left_running(retrieve_harness, executed_runner): + runner, _ = executed_runner + runner.fail_abandoned = AsyncMock() + db_response = make_batch(id="litellm-executed-batch", status="in_progress") + db_batch_object = MagicMock() + db_batch_object.updated_at = datetime.now(timezone.utc) - timedelta(seconds=30) + retrieve_harness.get_batch_from_db.return_value = (db_batch_object, db_response) + + resp = await call_retrieve(retrieve_harness, EXECUTED_BATCH_B64) + + assert resp is db_response + runner.fail_abandoned.assert_not_awaited() + + @pytest.mark.asyncio async def test_retrieve__executed_batch_without_db_row_404(retrieve_harness): with pytest.raises(ProxyException) as exc: diff --git a/tests/test_litellm/proxy/batches_endpoints/test_litellm_executed_batches.py b/tests/test_litellm/proxy/batches_endpoints/test_litellm_executed_batches.py index 96e054272a4..e5ed873a29d 100644 --- a/tests/test_litellm/proxy/batches_endpoints/test_litellm_executed_batches.py +++ b/tests/test_litellm/proxy/batches_endpoints/test_litellm_executed_batches.py @@ -2,6 +2,8 @@ import asyncio import json from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from types import MappingProxyType from typing import Final, Literal, cast from unittest.mock import AsyncMock, MagicMock @@ -20,6 +22,7 @@ from litellm.proxy.batches_endpoints.litellm_executed_batches import ( InvalidBatchInput, LiteLLMExecutedBatchRunner, _resolve_transition, + executed_batch_runner_lost, litellm_executed_provider_for, litellm_executed_provider_of, parse_batch_input, @@ -174,10 +177,15 @@ class RealIdManagedBatchStore(FakeManagedBatchStore): class FakeManagedObjectTable: def __init__(self, objects: Mapping[str, StoredObject]) -> None: self.objects = objects + self.touches: list[tuple[str, str | None]] = [] async def find_first(self, where: Mapping[str, str]) -> StoredObject | None: return self.objects.get(where["unified_object_id"]) + async def update_many(self, where: Mapping[str, str], data: Mapping[str, str | None]) -> int: + self.touches.append((where["unified_object_id"], data["updated_by"])) + return 1 + class FakeDb: def __init__(self, objects: Mapping[str, StoredObject]) -> None: @@ -203,6 +211,9 @@ class FakeRouter: def get_model_ids(self, model_name: str) -> list[str]: return [DEPLOYMENT_ID] if model_name == BATCH_MODEL else [] + def get_model_group_info(self, model_group: str) -> None: + return None + class FakeStorageBackend: def __init__(self, contents: Mapping[str, bytes]) -> None: @@ -317,6 +328,8 @@ def make_runner( upload_error: Exception | None = None, storage_error: ValueError | None = None, store_factory: Callable[[Mapping[str, LiteLLM_ManagedFileTable]], FakeManagedBatchStore] = FakeManagedBatchStore, + general_settings: Mapping[str, object] = MappingProxyType({}), + heartbeat_seconds: float = 30.0, ) -> Harness: store = store_factory({INPUT_FILE_ID: managed_input_file()} if files is None else files) router = FakeRouter() @@ -332,7 +345,9 @@ def make_runner( prisma_client=cast("PrismaClient", prisma), managed_files=store, proxy_logging_obj=MagicMock(spec=ProxyLogging), + general_settings=general_settings, concurrency=concurrency, + heartbeat_seconds=heartbeat_seconds, storage_backend_factory=storage_factory, upload_result_file=uploads, ) @@ -413,6 +428,27 @@ def test_resolve_transition_from_cancelling(requested: BatchStatus, expected: Ba assert _resolve_transition("cancelling", requested) == expected +@pytest.mark.parametrize( + ("status", "age_seconds", "lost"), + [ + ("validating", 200, True), + ("in_progress", 200, True), + ("in_progress", 100, False), + ("finalizing", 200, True), + ("cancelling", 200, True), + ("completed", 200, False), + ("failed", 200, False), + ("cancelled", 200, False), + ("expired", 200, False), + ], +) +def test_executed_batch_runner_lost_only_for_a_stale_non_terminal_batch( + status: str, age_seconds: int, lost: bool +) -> None: + updated_at = datetime.now(timezone.utc) - timedelta(seconds=age_seconds) + assert executed_batch_runner_lost(status, updated_at) is lost + + @pytest.mark.parametrize( ("credentials", "expected"), [ @@ -686,6 +722,91 @@ async def test_create_surfaces_a_storage_backend_error_as_a_400() -> None: assert harness.store.calls == [] +CREDENTIAL_ROWS: Final = jsonl(chat_row("row-1", "hi 1"), chat_row("row-2", "hi 2", api_base="https://evil.example")) + + +async def test_create_rejects_a_row_carrying_client_side_credentials() -> None: + harness = make_runner(content=CREDENTIAL_ROWS) + with pytest.raises(ProxyException) as raised: + await harness.create() + assert raised.value.code == "400" + assert raised.value.message.startswith("Invalid batch input file: line 2") + assert "api_base" in raised.value.message + assert "allow_client_side_credentials" in raised.value.message + assert harness.store.calls == [] + assert harness.router.acompletion.await_count == 0 + + +async def test_create_forwards_row_credentials_when_the_admin_opted_in() -> None: + harness = make_runner( + content=CREDENTIAL_ROWS, general_settings=MappingProxyType({"allow_client_side_credentials": True}) + ) + _, finished = await harness.create_and_finish() + + assert finished.status == "completed" + assert finished.request_counts == BatchRequestCounts(completed=2, failed=0, total=2) + by_content = {call.kwargs["messages"][0]["content"]: call.kwargs for call in harness.router.acompletion.await_args_list} + assert by_content["hi 2"]["api_base"] == "https://evil.example" + assert "api_base" not in by_content["hi 1"] + + +async def test_running_batch_touches_its_row_until_it_finishes() -> None: + harness = make_runner(heartbeat_seconds=0.01) + + async def slow_dispatch(**_: object) -> ModelResponse: + await asyncio.sleep(0.05) + return chat_response("slow") + + harness.router.acompletion.side_effect = slow_dispatch + created, finished = await harness.create_and_finish() + + touches = harness.prisma.db.litellm_managedobjecttable.touches + assert finished.status == "completed" + assert touches + assert set(touches) == {(created.id, "user-1")} + assert [call.status for call in harness.store.calls] == ["validating", "in_progress", "finalizing", "completed"] + beats_at_finish = len(touches) + await asyncio.sleep(0.05) + assert len(touches) == beats_at_finish + + +async def test_fail_abandoned_marks_the_batch_failed_with_the_runner_lost_error() -> None: + harness = make_runner() + batch = seeded_batch(harness.store, "in_progress") + + failed = await harness.runner.fail_abandoned(batch, harness.user) + + assert failed.status == "failed" + assert failed.failed_at is not None + assert failed.errors is not None + assert [(error.message, error.code) for error in failed.errors.data or []] == [ + (litellm_executed_batches._RUNNER_LOST_MESSAGE, "runner_lost") + ] + assert harness.store.batch(batch.id).status == "failed" + assert [(call.status, call.create_if_missing) for call in harness.store.calls] == [("failed", False)] + + +async def test_running_batch_stops_and_writes_nothing_once_a_retriever_marked_it_failed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(litellm_executed_batches, "_CANCEL_POLL_SECONDS", 0.0) + rows = jsonl(chat_row("row-1", "hi 1"), chat_row("row-2", "hi 2"), chat_row("row-3", "hi 3")) + harness = make_runner(content=rows, concurrency=1) + + def dispatch(metadata: Mapping[str, object], **_: object) -> ModelResponse: + running = harness.store.batch(str(metadata["batch_id"])) + harness.store.write(running.model_copy(update={"status": "failed"})) + return chat_response("hi 1") + + harness.router.acompletion.side_effect = dispatch + _, finished = await harness.create_and_finish() + + assert harness.router.acompletion.await_count == 1 + assert finished.status == "failed" + assert [call.status for call in harness.store.calls] == ["validating", "in_progress"] + assert harness.uploads.calls == [] + + @pytest.mark.parametrize( ("endpoint", "body", "method"), [ diff --git a/tests/test_litellm/proxy/openai_files_endpoint/test_storage_backend_service.py b/tests/test_litellm/proxy/openai_files_endpoint/test_storage_backend_service.py index 067826004e2..81c5803da33 100644 --- a/tests/test_litellm/proxy/openai_files_endpoint/test_storage_backend_service.py +++ b/tests/test_litellm/proxy/openai_files_endpoint/test_storage_backend_service.py @@ -12,13 +12,20 @@ from litellm.proxy.utils import PrismaClient class _RecordingStorageBackend: - def __init__(self): + def __init__(self, delete_error: Exception | None = None): self.upload_calls = [] + self.delete_calls: list[str] = [] + self.delete_error = delete_error async def upload_file(self, **kwargs): self.upload_calls.append(kwargs) return "https://storage.example/blob-1" + async def delete_file(self, storage_url: str) -> None: + self.delete_calls.append(storage_url) + if self.delete_error is not None: + raise self.delete_error + class _FakeManagedFilesHook(BaseFileEndpoints): def __init__(self): @@ -45,6 +52,11 @@ class _FakeManagedFilesHook(BaseFileEndpoints): self.stored.append(kwargs) +class _FailingManagedFilesHook(_FakeManagedFilesHook): + async def store_unified_file_id(self, **kwargs): + raise RuntimeError("db down") + + class _FakeProxyLogging: def __init__(self, hook): self._hook = hook @@ -153,3 +165,25 @@ async def test_upload_hands_the_prisma_client_to_the_storage_backend_factory(mon ) assert factory_calls == [("litellm_db", prisma_client)] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("delete_error", [None, OSError("blob locked")], ids=["delete succeeds", "delete fails"]) +async def test_upload_deletes_the_uploaded_content_when_the_metadata_write_fails( + monkeypatch: pytest.MonkeyPatch, delete_error: Exception | None +): + backend = _RecordingStorageBackend(delete_error=delete_error) + monkeypatch.setattr(storage_backend_service, "get_storage_backend", lambda name, prisma_client=None: backend) + + with pytest.raises(RuntimeError, match="db down"): + await StorageBackendFileService.upload_file_to_storage_backend( + file_data=_file_data(), + target_storage="azure_storage", + target_model_names=["gpt-x"], + purpose="batch", + proxy_logging_obj=_FakeProxyLogging(hook=_FailingManagedFilesHook()), + user_api_key_dict=UserAPIKeyAuth(api_key="sk-test"), + ) + + assert len(backend.upload_calls) == 1 + assert backend.delete_calls == ["https://storage.example/blob-1"] From 42271b282a7d195b0f8b0ac324b852ab8109b8fa Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 04:44:26 -0700 Subject: [PATCH 34/73] fix(batches): guard batch status writes against stale reads and disable per-line fallbacks --- .../litellm_executed_batches.py | 65 +++--- .../test_litellm_executed_batches.py | 194 +++++++++++++++--- 2 files changed, 198 insertions(+), 61 deletions(-) diff --git a/litellm/proxy/batches_endpoints/litellm_executed_batches.py b/litellm/proxy/batches_endpoints/litellm_executed_batches.py index 7bd4c678183..b121ad1590e 100644 --- a/litellm/proxy/batches_endpoints/litellm_executed_batches.py +++ b/litellm/proxy/batches_endpoints/litellm_executed_batches.py @@ -3,7 +3,7 @@ import json import time from collections.abc import Awaitable, Callable, Mapping, Sequence from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from itertools import pairwise from types import MappingProxyType from typing import TYPE_CHECKING, Final, Literal, Protocol, TypeAlias, runtime_checkable @@ -28,11 +28,7 @@ from litellm.models.managed_files import LiteLLM_ManagedFileTable from litellm.proxy._types import ProxyErrorTypes, ProxyException, UserAPIKeyAuth from litellm.proxy.auth.auth_utils import is_request_body_safe from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup -from litellm.proxy.openai_files_endpoints.common_utils import ( - LITELLM_EXECUTED_BATCH_ID_PREFIX, - convert_b64_uid_to_unified_uid, - get_batch_id_from_unified_batch_id, -) +from litellm.proxy.openai_files_endpoints.common_utils import LITELLM_EXECUTED_BATCH_ID_PREFIX from litellm.proxy.openai_files_endpoints.storage_backend_service import StorageBackendFileService from litellm.proxy.utils import PrismaClient, ProxyLogging from litellm.repositories.table_repositories import ManagedObjectRepository @@ -41,6 +37,7 @@ from litellm.types.utils import LITELLM_EXECUTED_BATCH_PROVIDERS, ExtractedFileD if TYPE_CHECKING: from prisma import models as prisma_models + from prisma import types as prisma_types from litellm.router import Router @@ -154,7 +151,6 @@ class ManagedBatchStore(Protocol): user_api_key_dict: UserAPIKeyAuth, request_tags: Sequence[str] | None = None, persist_attribution: bool = False, - create_if_missing: bool = True, batch_processed: bool = False, ) -> None: ... @@ -375,10 +371,6 @@ def executed_batch_runner_lost(status: str, updated_at: datetime) -> bool: return (datetime.now(timezone.utc) - updated_at).total_seconds() > _STALE_AFTER_SECONDS -def _llm_batch_id_of(unified_batch_id: str) -> str: - return get_batch_id_from_unified_batch_id(convert_b64_uid_to_unified_uid(unified_batch_id)) - - class _StopWatch: def __init__(self, load_status: Callable[[], Awaitable[str | None]], interval_seconds: float) -> None: self._load_status = load_status @@ -488,8 +480,10 @@ class LiteLLMExecutedBatchRunner: cancelling: Final = current.model_copy( update=MappingProxyType({"status": "cancelling", "cancelling_at": int(time.time())}) ) - await self._store(cancelling, user_api_key_dict) - return cancelling + unchanged: Final[prisma_types.LiteLLM_ManagedObjectTableWhereInput] = {"status": current.status} + if await self._store_unless_changed(cancelling, unchanged, user_api_key_dict): + return cancelling + return await self.cancel(unified_batch_id, user_api_key_dict) async def fail_abandoned(self, batch: LiteLLMBatch, user_api_key_dict: UserAPIKeyAuth) -> LiteLLMBatch: error: Final = BatchError(message=_RUNNER_LOST_MESSAGE, code="runner_lost") @@ -497,8 +491,16 @@ class LiteLLMExecutedBatchRunner: failed: Final = batch.model_copy( update=MappingProxyType({"status": "failed", "failed_at": int(time.time()), "errors": errors}) ) - await self._store(failed, user_api_key_dict) - return failed + untouched: Final[prisma_types.DateTimeFilter] = { + "lt": datetime.now(timezone.utc) - timedelta(seconds=_STALE_AFTER_SECONDS) + } + still_abandoned: Final[prisma_types.LiteLLM_ManagedObjectTableWhereInput] = { + "status": batch.status, + "updated_at": untouched, + } + if await self._store_unless_changed(failed, still_abandoned, user_api_key_dict): + return failed + return await self._load_batch(batch.id) or batch def _body_rejection(self, model: str) -> BodyRejection: def reject(body: Mapping[str, object]) -> str | None: @@ -598,7 +600,9 @@ class LiteLLMExecutedBatchRunner: return RowOutcome(custom_id=line.custom_id, status_code=200, body=body, succeeded=True) async def _dispatch(self, run: _BatchRun, line: BatchInputLine) -> Mapping[str, object]: - params: Final = MappingProxyType({**line.body, "model": run.model, "metadata": self._row_metadata(run)}) + params: Final = MappingProxyType( + {**line.body, "model": run.model, "metadata": self._row_metadata(run), "disable_fallbacks": True} + ) return _dump(await self._router_call(run.endpoint)(**params)) def _router_call(self, endpoint: BatchEndpoint) -> _RouterCall: @@ -651,19 +655,26 @@ class LiteLLMExecutedBatchRunner: updated: Final = current.model_copy( update=MappingProxyType({**fields, "status": status, f"{status}_at": int(time.time())}) ) - await self._store(updated, run.user_api_key_dict) - return status + unchanged: Final[prisma_types.LiteLLM_ManagedObjectTableWhereInput] = {"status": current.status} + if await self._store_unless_changed(updated, unchanged, run.user_api_key_dict): + return status + return await self._advance(run, requested, fields) - async def _store(self, batch: LiteLLMBatch, user_api_key_dict: UserAPIKeyAuth) -> None: - await self.managed_files.store_unified_object_id( - unified_object_id=batch.id, - file_object=batch, - litellm_parent_otel_span=user_api_key_dict.parent_otel_span, - model_object_id=_llm_batch_id_of(batch.id), - file_purpose="batch", - user_api_key_dict=user_api_key_dict, - create_if_missing=False, + async def _store_unless_changed( + self, + batch: LiteLLMBatch, + guard: "prisma_types.LiteLLM_ManagedObjectTableWhereInput", + user_api_key_dict: UserAPIKeyAuth, + ) -> bool: + updated_rows: Final = await ManagedObjectRepository(self.prisma_client).table.update_many( + where={"unified_object_id": batch.id, **guard}, # mutable-ok: Prisma filter + data={ # mutable-ok: Prisma payload + "file_object": batch.model_dump_json(), + "status": batch.status, + "updated_by": user_api_key_dict.user_id, + }, ) + return updated_rows > 0 async def _find_row(self, unified_batch_id: str) -> "prisma_models.LiteLLM_ManagedObjectTable | None": return await ManagedObjectRepository(self.prisma_client).table.find_first( diff --git a/tests/test_litellm/proxy/batches_endpoints/test_litellm_executed_batches.py b/tests/test_litellm/proxy/batches_endpoints/test_litellm_executed_batches.py index e5ed873a29d..cb566d4dca7 100644 --- a/tests/test_litellm/proxy/batches_endpoints/test_litellm_executed_batches.py +++ b/tests/test_litellm/proxy/batches_endpoints/test_litellm_executed_batches.py @@ -105,6 +105,10 @@ class ProviderRateLimited(Exception): class StoredObject: file_object: str status: str + updated_at: datetime + + def batch(self) -> LiteLLMBatch: + return LiteLLMBatch.model_validate_json(self.file_object) @dataclass(frozen=True, slots=True) @@ -114,10 +118,20 @@ class StoreCall: status: str request_tags: tuple[str, ...] | None persist_attribution: bool - create_if_missing: bool batch_processed: bool +@dataclass(frozen=True, slots=True) +class StatusWrite: + unified_object_id: str + status: str + columns: frozenset[str] + + +STATUS_WRITE_COLUMNS: Final = frozenset({"file_object", "status", "updated_by"}) +STALE: Final = timedelta(seconds=litellm_executed_batches._STALE_AFTER_SECONDS + 20) + + class FakeManagedBatchStore: def __init__(self, files: Mapping[str, LiteLLM_ManagedFileTable]) -> None: self.files = files @@ -142,7 +156,6 @@ class FakeManagedBatchStore: user_api_key_dict: UserAPIKeyAuth, request_tags: Sequence[str] | None = None, persist_attribution: bool = False, - create_if_missing: bool = True, batch_processed: bool = False, ) -> None: self.calls.append( @@ -152,18 +165,18 @@ class FakeManagedBatchStore: status=file_object.status, request_tags=tuple(request_tags) if request_tags is not None else None, persist_attribution=persist_attribution, - create_if_missing=create_if_missing, batch_processed=batch_processed, ) ) - if create_if_missing or unified_object_id in self.objects: - self.write(file_object) + self.write(file_object) - def write(self, batch: LiteLLMBatch) -> None: - self.objects[batch.id] = StoredObject(file_object=batch.model_dump_json(), status=batch.status) + def write(self, batch: LiteLLMBatch, age: timedelta = timedelta(0)) -> None: + self.objects[batch.id] = StoredObject( + file_object=batch.model_dump_json(), status=batch.status, updated_at=datetime.now(timezone.utc) - age + ) def batch(self, unified_batch_id: str) -> LiteLLMBatch: - return LiteLLMBatch.model_validate_json(self.objects[unified_batch_id].file_object) + return self.objects[unified_batch_id].batch() REAL_HOOK: Final = _PROXY_LiteLLMManagedFiles(internal_usage_cache=MagicMock(), prisma_client=MagicMock()) @@ -174,26 +187,51 @@ class RealIdManagedBatchStore(FakeManagedBatchStore): return REAL_HOOK.get_unified_batch_id(batch_id=batch_id, model_id=model_id) +def row_matches(row: StoredObject, where: Mapping[str, object]) -> bool: + if "status" in where and row.status != where["status"]: + return False + match where.get("updated_at"): + case {"lt": datetime() as before}: + return row.updated_at < before + case _: + return True + + class FakeManagedObjectTable: - def __init__(self, objects: Mapping[str, StoredObject]) -> None: + def __init__(self, objects: dict[str, StoredObject]) -> None: self.objects = objects self.touches: list[tuple[str, str | None]] = [] + self.writes: list[StatusWrite] = [] + self.after_read: Callable[[StoredObject | None], None] | None = None async def find_first(self, where: Mapping[str, str]) -> StoredObject | None: - return self.objects.get(where["unified_object_id"]) + row = self.objects.get(where["unified_object_id"]) + if self.after_read is not None: + self.after_read(row) + return row - async def update_many(self, where: Mapping[str, str], data: Mapping[str, str | None]) -> int: - self.touches.append((where["unified_object_id"], data["updated_by"])) + async def update_many(self, where: Mapping[str, object], data: Mapping[str, str | None]) -> int: + unified_object_id = str(where["unified_object_id"]) + row = self.objects.get(unified_object_id) + if row is None or not row_matches(row, where): + return 0 + now = datetime.now(timezone.utc) + if "status" not in data: + self.touches.append((unified_object_id, data["updated_by"])) + self.objects[unified_object_id] = StoredObject(row.file_object, row.status, now) + return 1 + self.writes.append(StatusWrite(unified_object_id, str(data["status"]), frozenset(data))) + self.objects[unified_object_id] = StoredObject(str(data["file_object"]), str(data["status"]), now) return 1 class FakeDb: - def __init__(self, objects: Mapping[str, StoredObject]) -> None: + def __init__(self, objects: dict[str, StoredObject]) -> None: self.litellm_managedobjecttable = FakeManagedObjectTable(objects) class FakePrismaClient: - def __init__(self, objects: Mapping[str, StoredObject]) -> None: + def __init__(self, objects: dict[str, StoredObject]) -> None: self.db = FakeDb(objects) @@ -320,6 +358,13 @@ class Harness: await asyncio.gather(*list(litellm_executed_batches._RUNNING_BATCHES)) return created, self.store.batch(created.id) + @property + def table(self) -> FakeManagedObjectTable: + return self.prisma.db.litellm_managedobjecttable + + def written_statuses(self) -> list[str]: + return [write.status for write in self.table.writes] + def make_runner( content: bytes = TWO_CHAT_ROWS, @@ -354,7 +399,9 @@ def make_runner( return Harness(runner, store, router, uploads, storage, storage_factory, prisma, user) -def seeded_batch(store: FakeManagedBatchStore, status: Literal["in_progress", "completed"]) -> LiteLLMBatch: +def seeded_batch( + store: FakeManagedBatchStore, status: Literal["in_progress", "completed"], age: timedelta = timedelta(0) +) -> LiteLLMBatch: batch = LiteLLMBatch( id=store.get_unified_batch_id(batch_id="litellm_batch_seed", model_id=DEPLOYMENT_ID), object="batch", @@ -365,7 +412,7 @@ def seeded_batch(store: FakeManagedBatchStore, status: Literal["in_progress", "c created_at=1, model=BATCH_MODEL, ) - store.write(batch) + store.write(batch, age) return batch @@ -745,7 +792,9 @@ async def test_create_forwards_row_credentials_when_the_admin_opted_in() -> None assert finished.status == "completed" assert finished.request_counts == BatchRequestCounts(completed=2, failed=0, total=2) - by_content = {call.kwargs["messages"][0]["content"]: call.kwargs for call in harness.router.acompletion.await_args_list} + by_content = { + call.kwargs["messages"][0]["content"]: call.kwargs for call in harness.router.acompletion.await_args_list + } assert by_content["hi 2"]["api_base"] == "https://evil.example" assert "api_base" not in by_content["hi 1"] @@ -760,19 +809,20 @@ async def test_running_batch_touches_its_row_until_it_finishes() -> None: harness.router.acompletion.side_effect = slow_dispatch created, finished = await harness.create_and_finish() - touches = harness.prisma.db.litellm_managedobjecttable.touches + touches = harness.table.touches assert finished.status == "completed" assert touches assert set(touches) == {(created.id, "user-1")} - assert [call.status for call in harness.store.calls] == ["validating", "in_progress", "finalizing", "completed"] + assert [call.status for call in harness.store.calls] == ["validating"] + assert harness.written_statuses() == ["in_progress", "finalizing", "completed"] beats_at_finish = len(touches) await asyncio.sleep(0.05) assert len(touches) == beats_at_finish -async def test_fail_abandoned_marks_the_batch_failed_with_the_runner_lost_error() -> None: +async def test_fail_abandoned_marks_a_stale_batch_failed_with_the_runner_lost_error() -> None: harness = make_runner() - batch = seeded_batch(harness.store, "in_progress") + batch = seeded_batch(harness.store, "in_progress", age=STALE) failed = await harness.runner.fail_abandoned(batch, harness.user) @@ -783,7 +833,63 @@ async def test_fail_abandoned_marks_the_batch_failed_with_the_runner_lost_error( (litellm_executed_batches._RUNNER_LOST_MESSAGE, "runner_lost") ] assert harness.store.batch(batch.id).status == "failed" - assert [(call.status, call.create_if_missing) for call in harness.store.calls] == [("failed", False)] + assert harness.store.calls == [] + assert harness.table.writes == [StatusWrite(batch.id, "failed", STATUS_WRITE_COLUMNS)] + + +async def test_fail_abandoned_leaves_a_batch_that_finished_after_the_stale_read() -> None: + harness = make_runner() + stale_read = seeded_batch(harness.store, "in_progress", age=STALE) + harness.store.write(stale_read.model_copy(update={"status": "completed", "output_file_id": "out-1"}), age=STALE) + + current = await harness.runner.fail_abandoned(stale_read, harness.user) + + assert (current.status, current.output_file_id) == ("completed", "out-1") + assert harness.store.batch(stale_read.id).status == "completed" + assert harness.table.writes == [] + + +async def test_fail_abandoned_leaves_a_batch_its_runner_touched_since_the_read() -> None: + harness = make_runner() + batch = seeded_batch(harness.store, "in_progress", age=STALE) + harness.store.write(batch) + + current = await harness.runner.fail_abandoned(batch, harness.user) + + assert current.status == "in_progress" + assert harness.store.batch(batch.id).status == "in_progress" + assert harness.table.writes == [] + + +async def test_run_does_not_reverse_a_failure_written_between_its_read_and_its_completed_write() -> None: + harness = make_runner() + + def fail_once_finalizing_is_read(row: StoredObject | None) -> None: + if row is not None and row.status == "finalizing": + harness.store.write(row.batch().model_copy(update={"status": "failed"})) + + harness.table.after_read = fail_once_finalizing_is_read + _, finished = await harness.create_and_finish() + + assert finished.status == "failed" + assert finished.output_file_id is None + assert harness.written_statuses() == ["in_progress", "finalizing"] + + +async def test_run_honours_a_cancel_written_between_its_read_and_its_finalizing_write() -> None: + harness = make_runner(content=jsonl(chat_row("row-1", "hi 1"))) + + def cancel_once_the_row_is_dispatched(row: StoredObject | None) -> None: + if row is not None and row.status == "in_progress" and harness.router.acompletion.await_count == 1: + harness.store.write(row.batch().model_copy(update={"status": "cancelling"})) + + harness.table.after_read = cancel_once_the_row_is_dispatched + _, finished = await harness.create_and_finish() + + assert finished.status == "cancelled" + assert finished.request_counts == BatchRequestCounts(completed=1, failed=0, total=1) + assert finished.output_file_id == "unified-output-1" + assert harness.written_statuses() == ["in_progress", "cancelling", "cancelled"] async def test_running_batch_stops_and_writes_nothing_once_a_retriever_marked_it_failed( @@ -803,7 +909,8 @@ async def test_running_batch_stops_and_writes_nothing_once_a_retriever_marked_it assert harness.router.acompletion.await_count == 1 assert finished.status == "failed" - assert [call.status for call in harness.store.calls] == ["validating", "in_progress"] + assert [call.status for call in harness.store.calls] == ["validating"] + assert harness.written_statuses() == ["in_progress"] assert harness.uploads.calls == [] @@ -828,6 +935,7 @@ async def test_each_endpoint_awaits_only_its_router_method( assert awaited == {name: int(name == method) for name in ROUTER_METHODS} kwargs = getattr(harness.router, method).await_args.kwargs assert kwargs["model"] == BATCH_MODEL + assert kwargs["disable_fallbacks"] is True assert all(kwargs[key] == value for key, value in body.items()) @@ -845,7 +953,7 @@ async def test_cancel_terminal_batch_is_400() -> None: await harness.runner.cancel(batch.id, harness.user) assert raised.value.code == "400" assert "completed" in raised.value.message - assert harness.store.calls == [] + assert harness.table.writes == [] async def test_cancel_marks_a_running_batch_cancelling_once() -> None: @@ -857,12 +965,30 @@ async def test_cancel_marks_a_running_batch_cancelling_once() -> None: assert cancelled.status == "cancelling" assert cancelled.cancelling_at is not None assert harness.store.batch(batch.id).status == "cancelling" - assert [(call.status, call.create_if_missing) for call in harness.store.calls] == [("cancelling", False)] + assert harness.store.calls == [] + assert harness.table.writes == [StatusWrite(batch.id, "cancelling", STATUS_WRITE_COLUMNS)] again = await harness.runner.cancel(batch.id, harness.user) assert again.model_dump() == cancelled.model_dump() - assert len(harness.store.calls) == 1 + assert len(harness.table.writes) == 1 + + +async def test_cancel_racing_a_completion_is_400_and_leaves_the_batch_completed() -> None: + harness = make_runner() + batch = seeded_batch(harness.store, "in_progress") + + def complete_once_read(row: StoredObject | None) -> None: + if row is not None and row.status == "in_progress": + harness.store.write(row.batch().model_copy(update={"status": "completed"})) + + harness.table.after_read = complete_once_read + with pytest.raises(ProxyException) as raised: + await harness.runner.cancel(batch.id, harness.user) + + assert raised.value.code == "400" + assert harness.store.batch(batch.id).status == "completed" + assert harness.table.writes == [] async def test_running_batch_skips_the_remaining_rows_after_an_operator_cancel( @@ -905,10 +1031,10 @@ async def test_only_the_create_write_carries_attribution_and_billing_flags() -> harness = make_runner() await harness.create_and_finish() - assert [call.status for call in harness.store.calls] == ["validating", "in_progress", "finalizing", "completed"] - flags = [(call.persist_attribution, call.batch_processed, call.create_if_missing) for call in harness.store.calls] - assert flags[0] == (True, True, True) - assert flags[1:] == [(False, False, False)] * 3 + assert [(call.status, call.persist_attribution, call.batch_processed) for call in harness.store.calls] == [ + ("validating", True, True) + ] + assert [write.columns for write in harness.table.writes] == [STATUS_WRITE_COLUMNS] * 3 async def test_run_completes_under_the_real_hooks_base64_batch_id() -> None: @@ -917,9 +1043,8 @@ async def test_run_completes_under_the_real_hooks_base64_batch_id() -> None: assert _is_base64_encoded_unified_file_id(created.id) assert finished.status == "completed" - llm_batch_id = harness.store.calls[0].model_object_id - assert llm_batch_id.startswith("litellm_batch_") - assert [call.model_object_id for call in harness.store.calls] == [llm_batch_id] * 4 + assert [call.model_object_id.startswith("litellm_batch_") for call in harness.store.calls] == [True] + assert [write.unified_object_id for write in harness.table.writes] == [created.id] * 3 async def test_cancel_works_under_the_real_hooks_base64_batch_id() -> None: @@ -928,4 +1053,5 @@ async def test_cancel_works_under_the_real_hooks_base64_batch_id() -> None: cancelled = await harness.runner.cancel(batch.id, harness.user) assert cancelled.status == "cancelling" - assert [call.model_object_id for call in harness.store.calls] == ["litellm_batch_seed"] + assert harness.store.batch(batch.id).status == "cancelling" + assert [write.unified_object_id for write in harness.table.writes] == [batch.id] From 2ee8c1bd0e6ac9125befdcb8fb479773d7c5dd07 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 06:25:42 -0700 Subject: [PATCH 35/73] fix(batches): enforce the completion window and guard executed-batch id parsing --- .../litellm_executed_batches.py | 82 ++++++++++++++----- .../openai_files_endpoints/common_utils.py | 3 +- .../test_litellm_executed_batches.py | 44 ++++++++++ .../test_files_common_utils.py | 2 + 4 files changed, 111 insertions(+), 20 deletions(-) diff --git a/litellm/proxy/batches_endpoints/litellm_executed_batches.py b/litellm/proxy/batches_endpoints/litellm_executed_batches.py index b121ad1590e..5a7061d9ab1 100644 --- a/litellm/proxy/batches_endpoints/litellm_executed_batches.py +++ b/litellm/proxy/batches_endpoints/litellm_executed_batches.py @@ -42,7 +42,9 @@ if TYPE_CHECKING: from litellm.router import Router BatchEndpoint: TypeAlias = Literal["/v1/chat/completions", "/v1/embeddings", "/v1/completions", "/v1/responses"] -BatchStatus: TypeAlias = Literal["in_progress", "finalizing", "completed", "failed", "cancelling", "cancelled"] +BatchStatus: TypeAlias = Literal[ + "in_progress", "finalizing", "completed", "failed", "cancelling", "cancelled", "expired" +] TERMINAL_BATCH_STATUSES: Final[frozenset[str]] = frozenset({"completed", "failed", "cancelled", "expired"}) _STOP_STATUSES: Final[frozenset[str]] = TERMINAL_BATCH_STATUSES | frozenset({"cancelling"}) _BATCH_ENDPOINT_ADAPTER: Final[TypeAdapter[BatchEndpoint]] = TypeAdapter(BatchEndpoint) @@ -52,6 +54,7 @@ _STALE_AFTER_SECONDS: Final = 180.0 _FILES_API_PROBE_TIMEOUT_SECONDS: Final = 5.0 _COMPLETION_WINDOW_SECONDS: Final = 24 * 60 * 60 _RUNNER_LOST_MESSAGE: Final = "the proxy replica running this batch stopped before it finished; resubmit the batch" +_EXPIRED_MESSAGE: Final = "This request could not be executed before the completion window expired." _ROUTER_METHODS: Final[Mapping[BatchEndpoint, str]] = MappingProxyType( { "/v1/chat/completions": "acompletion", @@ -61,7 +64,7 @@ _ROUTER_METHODS: Final[Mapping[BatchEndpoint, str]] = MappingProxyType( } ) _CANCELLING_TRANSITIONS: Final[Mapping[BatchStatus, BatchStatus]] = MappingProxyType( - {"completed": "cancelled", "in_progress": "cancelling", "finalizing": "cancelling"} + {"completed": "cancelled", "expired": "cancelled", "in_progress": "cancelling", "finalizing": "cancelling"} ) LITELLM_EXECUTED_BATCH_UPLOAD_GUIDANCE: Final = ( "upload it through POST /v1/files with purpose=batch and either the x-litellm-model header or the " @@ -89,11 +92,16 @@ class _ResultResponse(TypedDict): body: ReadOnly[Mapping[str, object]] +class _LineError(TypedDict): + code: ReadOnly[str] + message: ReadOnly[str] + + class _ResultLine(TypedDict): id: ReadOnly[str] custom_id: ReadOnly[str] - response: ReadOnly[_ResultResponse] - error: ReadOnly[None] + response: ReadOnly[_ResultResponse | None] + error: ReadOnly[_LineError | None] class BatchInputLine(BaseModel): @@ -122,6 +130,11 @@ class RowOutcome: succeeded: bool +@dataclass(frozen=True, slots=True) +class ExpiredRow: + custom_id: str + + @dataclass(frozen=True, slots=True) class _BatchRun: unified_batch_id: str @@ -131,6 +144,7 @@ class _BatchRun: lines: tuple[BatchInputLine, ...] user_api_key_dict: UserAPIKeyAuth request_tags: tuple[str, ...] + deadline: float @runtime_checkable @@ -339,16 +353,30 @@ def _error_body(error: Exception) -> _ErrorBody: return body -def _result_line(outcome: RowOutcome) -> _ResultLine: +def _line_response(outcome: RowOutcome | ExpiredRow) -> _ResultResponse | None: + if isinstance(outcome, ExpiredRow): + return None + response: Final[_ResultResponse] = { + "status_code": outcome.status_code, + "request_id": f"req_{uuid_module.uuid4().hex[:24]}", + "body": outcome.body, + } + return response + + +def _line_error(outcome: RowOutcome | ExpiredRow) -> _LineError | None: + if isinstance(outcome, RowOutcome): + return None + error: Final[_LineError] = {"code": "batch_expired", "message": _EXPIRED_MESSAGE} + return error + + +def _result_line(outcome: RowOutcome | ExpiredRow) -> _ResultLine: line: Final[_ResultLine] = { "id": f"batch_req_{uuid_module.uuid4().hex[:24]}", "custom_id": outcome.custom_id, - "response": { - "status_code": outcome.status_code, - "request_id": f"req_{uuid_module.uuid4().hex[:24]}", - "body": outcome.body, - }, - "error": None, + "response": _line_response(outcome), + "error": _line_error(outcome), } return line @@ -399,6 +427,7 @@ class LiteLLMExecutedBatchRunner: general_settings: Mapping[str, object], concurrency: int = LITELLM_EXECUTED_BATCH_CONCURRENCY, heartbeat_seconds: float = _HEARTBEAT_SECONDS, + completion_window_seconds: float = _COMPLETION_WINDOW_SECONDS, storage_backend_factory: _StorageBackendFactory = get_storage_backend, upload_result_file: _ResultFileUploader = StorageBackendFileService.upload_file_to_storage_backend, ) -> None: @@ -409,6 +438,7 @@ class LiteLLMExecutedBatchRunner: self.general_settings = general_settings self.concurrency = concurrency self.heartbeat_seconds = heartbeat_seconds + self.completion_window_seconds = completion_window_seconds self.storage_backend_factory = storage_backend_factory self.upload_result_file = upload_result_file @@ -429,7 +459,8 @@ class LiteLLMExecutedBatchRunner: llm_batch_id: Final = f"{LITELLM_EXECUTED_BATCH_ID_PREFIX}{uuid_module.uuid4().hex}" model_id: Final = next(iter(self.llm_router.get_model_ids(model_name=model)), model) unified_batch_id: Final = self.managed_files.get_unified_batch_id(batch_id=llm_batch_id, model_id=model_id) - created_at: Final = int(time.time()) + now: Final = time.time() + created_at: Final = int(now) batch: Final = LiteLLMBatch( id=unified_batch_id, object="batch", @@ -438,7 +469,7 @@ class LiteLLMExecutedBatchRunner: completion_window="24h", status="validating", created_at=created_at, - expires_at=created_at + _COMPLETION_WINDOW_SECONDS, + expires_at=created_at + int(self.completion_window_seconds), metadata=create_request.get("metadata"), model=model, request_counts=BatchRequestCounts(completed=0, failed=0, total=len(parsed)), @@ -463,6 +494,7 @@ class LiteLLMExecutedBatchRunner: lines=parsed, user_api_key_dict=user_api_key_dict, request_tags=tuple(request_tags or ()), + deadline=now + self.completion_window_seconds, ) task: Final = asyncio.create_task(self._run(run)) _RUNNING_BATCHES.add(task) @@ -572,14 +604,21 @@ class LiteLLMExecutedBatchRunner: outcomes: Final = tuple(outcome for outcome in results if outcome is not None) if await self._advance(run, "finalizing") is None: return - succeeded: Final = tuple(outcome for outcome in outcomes if outcome.succeeded) - failed: Final = tuple(outcome for outcome in outcomes if not outcome.succeeded) + succeeded: Final = tuple( + outcome for outcome in outcomes if isinstance(outcome, RowOutcome) and outcome.succeeded + ) + failed: Final = tuple( + outcome for outcome in outcomes if isinstance(outcome, ExpiredRow) or not outcome.succeeded + ) output_file_id: Final = await self._upload_results(run, "output", succeeded) error_file_id: Final = await self._upload_results(run, "error", failed) request_counts: Final = BatchRequestCounts(completed=len(succeeded), failed=len(failed), total=len(run.lines)) + final_status: Final[BatchStatus] = ( + "expired" if any(isinstance(outcome, ExpiredRow) for outcome in outcomes) else "completed" + ) await self._advance( run, - "completed", + final_status, MappingProxyType( {"output_file_id": output_file_id, "error_file_id": error_file_id, "request_counts": request_counts} ), @@ -587,12 +626,17 @@ class LiteLLMExecutedBatchRunner: async def _run_row( self, run: _BatchRun, line: BatchInputLine, watch: _StopWatch, semaphore: asyncio.Semaphore - ) -> RowOutcome | None: + ) -> RowOutcome | ExpiredRow | None: async with semaphore: if await watch.stopped(): return None + remaining: Final = run.deadline - time.time() + if remaining <= 0: + return ExpiredRow(custom_id=line.custom_id) try: - body: Final = await self._dispatch(run, line) + body: Final = await asyncio.wait_for(self._dispatch(run, line), timeout=remaining) + except asyncio.TimeoutError: + return ExpiredRow(custom_id=line.custom_id) except Exception as e: # noqa: BLE001 # a provider error becomes the row's error line, never a crashed batch return RowOutcome( custom_id=line.custom_id, status_code=_status_code_of(e), body=_error_body(e), succeeded=False @@ -621,7 +665,7 @@ class LiteLLMExecutedBatchRunner: } async def _upload_results( - self, run: _BatchRun, kind: Literal["output", "error"], outcomes: Sequence[RowOutcome] + self, run: _BatchRun, kind: Literal["output", "error"], outcomes: Sequence[RowOutcome | ExpiredRow] ) -> str | None: if not outcomes: return None diff --git a/litellm/proxy/openai_files_endpoints/common_utils.py b/litellm/proxy/openai_files_endpoints/common_utils.py index 25365d34187..ccc3b5b7f5f 100644 --- a/litellm/proxy/openai_files_endpoints/common_utils.py +++ b/litellm/proxy/openai_files_endpoints/common_utils.py @@ -181,7 +181,8 @@ def get_batch_id_from_unified_batch_id(file_id: str) -> str: def is_litellm_executed_batch(decoded_unified_batch_id: str) -> bool: - return get_batch_id_from_unified_batch_id(decoded_unified_batch_id).startswith(LITELLM_EXECUTED_BATCH_ID_PREFIX) + _, marker, batch_id = decoded_unified_batch_id.partition("llm_batch_id:") + return bool(marker) and batch_id.startswith(LITELLM_EXECUTED_BATCH_ID_PREFIX) def encode_file_id_with_model(file_id: str, model: str, id_type: Literal["file", "batch"] = "file") -> str: diff --git a/tests/test_litellm/proxy/batches_endpoints/test_litellm_executed_batches.py b/tests/test_litellm/proxy/batches_endpoints/test_litellm_executed_batches.py index cb566d4dca7..860827e8fbd 100644 --- a/tests/test_litellm/proxy/batches_endpoints/test_litellm_executed_batches.py +++ b/tests/test_litellm/proxy/batches_endpoints/test_litellm_executed_batches.py @@ -53,6 +53,7 @@ ALL_STATUSES: Final[tuple[BatchStatus, ...]] = ( "failed", "cancelling", "cancelled", + "expired", ) @@ -375,6 +376,7 @@ def make_runner( store_factory: Callable[[Mapping[str, LiteLLM_ManagedFileTable]], FakeManagedBatchStore] = FakeManagedBatchStore, general_settings: Mapping[str, object] = MappingProxyType({}), heartbeat_seconds: float = 30.0, + completion_window_seconds: float = 24 * 60 * 60, ) -> Harness: store = store_factory({INPUT_FILE_ID: managed_input_file()} if files is None else files) router = FakeRouter() @@ -393,6 +395,7 @@ def make_runner( general_settings=general_settings, concurrency=concurrency, heartbeat_seconds=heartbeat_seconds, + completion_window_seconds=completion_window_seconds, storage_backend_factory=storage_factory, upload_result_file=uploads, ) @@ -464,6 +467,7 @@ def test_resolve_transition_keeps_the_requested_status_unless_cancelling(current ("requested", "expected"), [ ("completed", "cancelled"), + ("expired", "cancelled"), ("in_progress", "cancelling"), ("finalizing", "cancelling"), ("failed", "failed"), @@ -1014,6 +1018,46 @@ async def test_running_batch_skips_the_remaining_rows_after_an_operator_cancel( assert (finished.output_file_id, finished.error_file_id) == ("unified-output-1", None) +async def test_batch_expires_at_the_completion_window_and_keeps_what_finished() -> None: + rows = jsonl(chat_row("row-1", "hi 1"), chat_row("row-2", "hi 2"), chat_row("row-3", "hi 3")) + harness = make_runner(content=rows, concurrency=1, completion_window_seconds=0.2) + reply = chat_response("hi 1") + + async def dispatch(messages: Sequence[Mapping[str, str]], **_: object) -> ModelResponse: + if messages[0]["content"] == "hi 1": + return reply + await asyncio.Event().wait() + raise AssertionError("a row still running at the completion window must be cut off") + + harness.router.acompletion.side_effect = dispatch + created, finished = await harness.create_and_finish() + + assert created.expires_at == created.created_at + assert finished.status == "expired" + assert finished.expired_at is not None + assert finished.request_counts == BatchRequestCounts(completed=1, failed=2, total=3) + assert (finished.output_file_id, finished.error_file_id) == ("unified-output-1", "unified-output-2") + assert set(harness.uploads.calls[0].lines()) == {"row-1"} + error_lines = harness.uploads.calls[1].lines() + assert set(error_lines) == {"row-2", "row-3"} + for line in error_lines.values(): + assert line["response"] is None + error = line["error"] + assert isinstance(error, dict) + assert error["code"] == "batch_expired" + + +async def test_batch_created_past_its_window_dispatches_nothing() -> None: + harness = make_runner(completion_window_seconds=0) + _, finished = await harness.create_and_finish() + + assert harness.router.acompletion.await_count == 0 + assert finished.status == "expired" + assert finished.request_counts == BatchRequestCounts(completed=0, failed=2, total=2) + assert (finished.output_file_id, finished.error_file_id) == (None, "unified-output-1") + assert set(harness.uploads.calls[0].lines()) == {"row-1", "row-2"} + + async def test_upload_failure_marks_the_batch_failed() -> None: harness = make_runner(upload_error=RuntimeError("storage exploded")) _, finished = await harness.create_and_finish() diff --git a/tests/test_litellm/proxy/openai_files_endpoint/test_files_common_utils.py b/tests/test_litellm/proxy/openai_files_endpoint/test_files_common_utils.py index f88d94d2c08..b7e3088ff01 100644 --- a/tests/test_litellm/proxy/openai_files_endpoint/test_files_common_utils.py +++ b/tests/test_litellm/proxy/openai_files_endpoint/test_files_common_utils.py @@ -487,6 +487,8 @@ class TestCompletedBatchSafeToRetire: ("litellm_proxy;model_id:my-vllm;llm_batch_id:litellm_batch_0123abcd", True), ("litellm_proxy;model_id:my-vllm;llm_batch_id:batch_0123abcd", False), ("litellm_proxy;model_id:my-vllm;generic_response_id:resp_0123abcd", False), + ("litellm_proxy;model_id:my-vllm;llm_output_file_id:file-0123abcd", False), + ("batch_0123abcd", False), ], ) def test_is_litellm_executed_batch_reads_the_llm_batch_id_prefix(decoded_unified_batch_id: str, executed: bool): From 2ce972b992905b8e3cca0293ac693224310ea38a Mon Sep 17 00:00:00 2001 From: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 07:54:49 -0700 Subject: [PATCH 36/73] test(e2e): report OAuth results without raw assertion logs --- .github/workflows/test-mcp-oauth-e2e.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/test-mcp-oauth-e2e.yml b/.github/workflows/test-mcp-oauth-e2e.yml index ea9ef93bf14..7625fb4d59f 100644 --- a/.github/workflows/test-mcp-oauth-e2e.yml +++ b/.github/workflows/test-mcp-oauth-e2e.yml @@ -145,15 +145,11 @@ jobs: --rootdir=. --reruns 0 --tb=short -o junit_family=xunit1 \ --junitxml="${RUNNER_TEMP}/mcp-oauth-private/results.xml" \ > "${RUNNER_TEMP}/mcp-oauth-private/pytest.log" 2>&1 - - name: Reject skipped or missing cases + - name: Report JUnit results and reject skipped or missing cases if: always() run: | uv run --no-sync python .github/e2e-stack/assert_tests_ran.py \ "${RUNNER_TEMP}/mcp-oauth-private/results.xml" tests/e2e/mcp/test_mcp_oauth_happy_path_e2e.py - - name: Publish sanitized summary - if: always() - run: | - grep -E '^(FAILED|PASSED|ERROR|E AssertionError|=+ .* =+)' "${RUNNER_TEMP}/mcp-oauth-private/pytest.log" || true - name: Remove private login and logs if: always() run: | From bb44fe5292bd8f967bfa9823d593203bb445b35f Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Sat, 19 Sep 2026 10:36:59 -0700 Subject: [PATCH 37/73] wip --- litellm-rust/Cargo.lock | 1 - litellm-rust/Cargo.toml | 2 +- litellm-rust/clippy.toml | 10 ++ .../crates/host-python/src/execution.rs | 102 +++++++++--- .../crates/host-python/src/fork_gate.rs | 121 ++++++++++++++ litellm-rust/crates/host-python/src/lib.rs | 7 +- .../crates/python-bridge/src/diagnostics.rs | 18 ++- litellm-rust/crates/python-bridge/src/lib.rs | 8 +- .../python-bridge/src/routes/responses.rs | 12 +- litellm/proxy/proxy_cli.py | 5 + litellm/rust_bridge/_native.pyi | 8 + litellm/rust_bridge/fork_guard.py | 47 ++++++ tests/test_litellm/proxy/test_proxy_cli.py | 35 ++++ .../rust_bridge/test_fork_guard.py | 36 +++++ tests/test_litellm_rust/test_fork_guard.py | 150 ++++++++++++++++++ 15 files changed, 534 insertions(+), 28 deletions(-) create mode 100644 litellm-rust/clippy.toml create mode 100644 litellm-rust/crates/host-python/src/fork_gate.rs create mode 100644 litellm/rust_bridge/fork_guard.py create mode 100644 tests/test_litellm/rust_bridge/test_fork_guard.py create mode 100644 tests/test_litellm_rust/test_fork_guard.py diff --git a/litellm-rust/Cargo.lock b/litellm-rust/Cargo.lock index 860f01c4ad1..ebab2a118fc 100644 --- a/litellm-rust/Cargo.lock +++ b/litellm-rust/Cargo.lock @@ -3046,7 +3046,6 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", - "futures-channel", "futures-core", "futures-util", "h2 0.4.15", diff --git a/litellm-rust/Cargo.toml b/litellm-rust/Cargo.toml index 8634dce92d0..fa2bdb4224c 100644 --- a/litellm-rust/Cargo.toml +++ b/litellm-rust/Cargo.toml @@ -34,7 +34,7 @@ pyo3 = "0.29.2" pyo3-async-runtimes = { version = "0.29.0", features = ["tokio-runtime"] } pythonize = "0.29.0" rand = "0.8" -reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "multipart", "rustls-tls", "http2", "stream"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls", "http2", "stream"] } rstest = "0.26.1" rstest_reuse = "0.7.0" rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } diff --git a/litellm-rust/clippy.toml b/litellm-rust/clippy.toml new file mode 100644 index 00000000000..f7e3293069b --- /dev/null +++ b/litellm-rust/clippy.toml @@ -0,0 +1,10 @@ +# The Tokio runtime is reached only through `host-python/src/execution.rs`, whose fork gate +# must see every entry. Going around it makes a fork-after-use hang instead of raising. +disallowed-methods = [ + { path = "pyo3_async_runtimes::tokio::get_runtime", reason = "use litellm_host_python::run_sync / run_sync_value" }, + { path = "pyo3_async_runtimes::tokio::future_into_py", reason = "use litellm_host_python::run_async / run_async_value" }, + { path = "pyo3_async_runtimes::tokio::future_into_py_with_locals", reason = "use litellm_host_python::run_async / run_async_value" }, + { path = "pyo3_async_runtimes::tokio::local_future_into_py", reason = "use litellm_host_python::run_async / run_async_value" }, + { path = "pyo3_async_runtimes::tokio::run", reason = "use litellm_host_python::run_sync / run_sync_value" }, + { path = "pyo3_async_runtimes::tokio::run_until_complete", reason = "use litellm_host_python::run_sync / run_sync_value" }, +] diff --git a/litellm-rust/crates/host-python/src/execution.rs b/litellm-rust/crates/host-python/src/execution.rs index 45a1183acf5..083c184e37e 100644 --- a/litellm-rust/crates/host-python/src/execution.rs +++ b/litellm-rust/crates/host-python/src/execution.rs @@ -4,6 +4,7 @@ use std::pin::Pin; use std::task::{Context, Poll, Waker}; use std::time::Duration; +use crate::fork_gate::{ForkGate, Refused, RuntimeAlreadyStarted}; use crate::{Pythonized, panic_to_pyerr, release_gil}; use futures_util::FutureExt; use pyo3::exceptions::PyRuntimeError; @@ -12,6 +13,67 @@ use serde::Serialize; use tokio::runtime::{Handle, Runtime}; use tokio::time::{self, MissedTickBehavior}; +pyo3::create_exception!( + _native, + ForkedAfterNativeRuntimeStarted, + PyRuntimeError, + "This process was forked after the native runtime started. Runtime threads do not survive fork(), so native routes cannot run here." +); + +pyo3::create_exception!( + _native, + ProcessReservedForForking, + PyRuntimeError, + "This process was reserved for forking workers, so native routes cannot run here." +); + +static FORK_GATE: ForkGate = ForkGate::new(); + +/// Whether this process has started the Tokio runtime. +pub fn runtime_started() -> bool { + FORK_GATE.started(std::process::id()) +} + +/// Declares that this process exists to fork workers, so it must never start the runtime. +/// Fails if it already has. Workers are unaffected: the reservation is keyed by pid. +pub fn reserve_process_for_forking() -> Result<(), RuntimeAlreadyStarted> { + FORK_GATE.reserve(std::process::id()) +} + +/// The only door to the Tokio runtime: every route reaches it through this module, which is +/// what lets the gate speak for the whole extension. `clippy.toml` disallows going around it. +fn enter_runtime() -> PyResult<()> { + FORK_GATE + .enter(std::process::id()) + .map_err(|refused| match refused { + Refused::ReservedForForking => ProcessReservedForForking::new_err( + "this process is reserved for forking workers and cannot run native routes; \ + move the call into a worker, after the fork", + ), + Refused::ForkedAfterStart => ForkedAfterNativeRuntimeStarted::new_err( + "this process was forked after the native runtime started, and runtime threads \ + do not survive fork(); start workers with spawn or forkserver, or fork before \ + the first native call", + ), + }) +} + +#[expect(clippy::disallowed_methods, reason = "this is the gated door")] +fn runtime() -> PyResult<&'static Runtime> { + enter_runtime()?; + Ok(pyo3_async_runtimes::tokio::get_runtime()) +} + +#[expect(clippy::disallowed_methods, reason = "this is the gated door")] +fn future_into_py(py: Python<'_>, future: F) -> PyResult> +where + F: Future> + Send + 'static, + T: for<'py> IntoPyObject<'py> + Send + 'static, +{ + enter_runtime()?; + pyo3_async_runtimes::tokio::future_into_py(py, future) +} + pub fn run_sync( py: Python<'_>, future: F, @@ -22,12 +84,7 @@ where E: Send + 'static, F: Future> + Send + 'static, { - run_sync_on( - py, - pyo3_async_runtimes::tokio::get_runtime(), - future, - map_error, - ) + run_sync_on(py, runtime()?, future, map_error) } pub fn run_sync_value(py: Python<'_>, future: F) -> PyResult @@ -35,7 +92,7 @@ where T: Send + 'static, F: Future> + Send + 'static, { - run_sync_value_on(py, pyo3_async_runtimes::tokio::get_runtime(), future) + run_sync_value_on(py, runtime()?, future) } fn run_sync_value_on(py: Python<'_>, runtime: &Runtime, future: F) -> PyResult @@ -83,7 +140,7 @@ where E: Send + 'static, F: Future> + Send + 'static, { - pyo3_async_runtimes::tokio::future_into_py(py, async move { + future_into_py(py, async move { let result = catch_future_panic(future).await?; let result = map_core_result(result, map_error)?; Ok(Pythonized(result)) @@ -95,7 +152,7 @@ where T: for<'py> IntoPyObject<'py> + Send + 'static, F: Future> + Send + 'static, { - pyo3_async_runtimes::tokio::future_into_py(py, async move { catch_future_panic(future).await? }) + future_into_py(py, async move { catch_future_panic(future).await? }) } pub fn poll_async_value(py: Python<'_>, future: Pin<&mut F>) -> PyResult> @@ -103,8 +160,9 @@ where T: Send, F: Future> + Send, { + let runtime = runtime()?; let result = release_gil(py, || { - let _runtime = pyo3_async_runtimes::tokio::get_runtime().enter(); + let _runtime = runtime.enter(); std::panic::catch_unwind(AssertUnwindSafe(|| { future.poll(&mut Context::from_waker(Waker::noop())) })) @@ -286,27 +344,25 @@ mod tests { } #[pyfunction] - fn runtime_worker_count() -> usize { - pyo3_async_runtimes::tokio::get_runtime() - .metrics() - .num_workers() + fn runtime_worker_count() -> PyResult { + Ok(runtime()?.metrics().num_workers()) } #[pyfunction] - fn runtime_is_responsive(_py: Python<'_>, expected_completions: usize) -> bool { + fn runtime_is_responsive(_py: Python<'_>, expected_completions: usize) -> PyResult { let completion_deadline = Instant::now() + Duration::from_secs(2); while ASYNC_PROBE_COMPLETED.load(Ordering::SeqCst) < expected_completions { if Instant::now() >= completion_deadline { - return false; + return Ok(false); } thread::sleep(Duration::from_millis(1)); } let (heartbeat_tx, heartbeat_rx) = mpsc::sync_channel(1); - pyo3_async_runtimes::tokio::get_runtime().spawn(async move { + runtime()?.spawn(async move { let _ = heartbeat_tx.send(()); }); - heartbeat_rx.recv_timeout(Duration::from_secs(2)).is_ok() + Ok(heartbeat_rx.recv_timeout(Duration::from_secs(2)).is_ok()) } fn extract_bool(py: Python<'_>, result: PyResult>) -> bool { @@ -317,6 +373,16 @@ mod tests { .expect("result should convert") } + #[rstest] + fn reaching_the_runtime_marks_the_process_as_started( + #[from(initialized_python)] python: &InitializedPython, + ) { + python.attach(|py| { + run_sync_value(py, async { Ok(()) }).unwrap(); + assert!(runtime_started()); + }); + } + #[rstest] fn inline_poll_releases_gil_and_enters_runtime( #[from(initialized_python)] python: &InitializedPython, diff --git a/litellm-rust/crates/host-python/src/fork_gate.rs b/litellm-rust/crates/host-python/src/fork_gate.rs new file mode 100644 index 00000000000..62284e978ff --- /dev/null +++ b/litellm-rust/crates/host-python/src/fork_gate.rs @@ -0,0 +1,121 @@ +use std::sync::atomic::{AtomicU32, Ordering}; + +const UNSET: u32 = 0; + +/// Decides which process may use the Tokio runtime. Its worker threads do not survive +/// `fork()`: a child forked after they started hangs on its first native call. The gate turns +/// both halves of that hazard into errors, keyed by pid so a fork needs no hook to be seen: +/// a process reserved for forking can never start the runtime, and a child of a process that +/// did start it is refused instead of hanging. +pub(crate) struct ForkGate { + runtime_pid: AtomicU32, + fork_only_pid: AtomicU32, +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum Refused { + ReservedForForking, + ForkedAfterStart, +} + +#[derive(Debug, PartialEq, Eq)] +pub struct RuntimeAlreadyStarted; + +impl ForkGate { + pub(crate) const fn new() -> Self { + Self { + runtime_pid: AtomicU32::new(UNSET), + fork_only_pid: AtomicU32::new(UNSET), + } + } + + /// Claims the runtime for `pid`. Claim first, then look for a reservation: `reserve` does + /// the mirror image, so when the two race at least one of them sees the other. + pub(crate) fn enter(&self, pid: u32) -> Result<(), Refused> { + match self + .runtime_pid + .compare_exchange(UNSET, pid, Ordering::SeqCst, Ordering::SeqCst) + { + Err(owner) if owner != pid => return Err(Refused::ForkedAfterStart), + _ => {} + } + + if self.fork_only_pid.load(Ordering::SeqCst) == pid { + // Nothing was started, so the workers forked from here must still find it unclaimed. + let _ = + self.runtime_pid + .compare_exchange(pid, UNSET, Ordering::SeqCst, Ordering::SeqCst); + return Err(Refused::ReservedForForking); + } + + Ok(()) + } + + pub(crate) fn reserve(&self, pid: u32) -> Result<(), RuntimeAlreadyStarted> { + self.fork_only_pid.store(pid, Ordering::SeqCst); + if self.runtime_pid.load(Ordering::SeqCst) == pid { + return Err(RuntimeAlreadyStarted); + } + Ok(()) + } + + pub(crate) fn started(&self, pid: u32) -> bool { + self.runtime_pid.load(Ordering::SeqCst) == pid + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const MASTER: u32 = 100; + const WORKER: u32 = 101; + + #[test] + fn unreserved_process_starts_the_runtime_and_stays_started() { + let gate = ForkGate::new(); + + assert!(!gate.started(MASTER)); + assert_eq!(gate.enter(MASTER), Ok(())); + assert_eq!(gate.enter(MASTER), Ok(())); + assert!(gate.started(MASTER)); + } + + #[test] + fn reserved_process_can_never_start_the_runtime() { + let gate = ForkGate::new(); + + assert_eq!(gate.reserve(MASTER), Ok(())); + assert_eq!(gate.enter(MASTER), Err(Refused::ReservedForForking)); + assert_eq!(gate.enter(MASTER), Err(Refused::ReservedForForking)); + assert!(!gate.started(MASTER)); + } + + #[test] + fn workers_forked_from_a_reserved_process_start_their_own_runtime() { + let gate = ForkGate::new(); + gate.reserve(MASTER).unwrap(); + gate.enter(MASTER).unwrap_err(); + + assert_eq!(gate.enter(WORKER), Ok(())); + assert!(gate.started(WORKER)); + } + + #[test] + fn reserving_after_the_runtime_started_is_refused() { + let gate = ForkGate::new(); + gate.enter(MASTER).unwrap(); + + assert_eq!(gate.reserve(MASTER), Err(RuntimeAlreadyStarted)); + } + + #[test] + fn child_forked_after_the_runtime_started_is_refused_instead_of_hanging() { + let gate = ForkGate::new(); + gate.enter(MASTER).unwrap(); + + assert_eq!(gate.enter(WORKER), Err(Refused::ForkedAfterStart)); + assert!(!gate.started(WORKER)); + assert_eq!(gate.enter(MASTER), Ok(())); + } +} diff --git a/litellm-rust/crates/host-python/src/lib.rs b/litellm-rust/crates/host-python/src/lib.rs index 583a4eb91b6..4e6337d916d 100644 --- a/litellm-rust/crates/host-python/src/lib.rs +++ b/litellm-rust/crates/host-python/src/lib.rs @@ -8,6 +8,7 @@ mod argument; mod callable; mod driver; mod execution; +mod fork_gate; mod gil; mod handle; mod marshal; @@ -18,7 +19,11 @@ pub use adapter::{ pub use argument::lookup; pub use callable::wrap_failure; pub use driver::run_call; -pub use execution::{poll_async_value, run_async, run_async_value, run_sync, run_sync_value}; +pub use execution::{ + ForkedAfterNativeRuntimeStarted, ProcessReservedForForking, poll_async_value, reserve_process_for_forking, run_async, run_async_value, run_sync, + run_sync_value, runtime_started, +}; +pub use fork_gate::RuntimeAlreadyStarted; pub use gil::{release_count, release_gil}; pub use handle::{Execution, ExecutionBody, ExecutionStep}; pub use marshal::{Pythonized, from_py, from_py_argument, panic_to_pyerr, to_py}; diff --git a/litellm-rust/crates/python-bridge/src/diagnostics.rs b/litellm-rust/crates/python-bridge/src/diagnostics.rs index 39fa8bc3596..687a090e768 100644 --- a/litellm-rust/crates/python-bridge/src/diagnostics.rs +++ b/litellm-rust/crates/python-bridge/src/diagnostics.rs @@ -1,5 +1,5 @@ -use litellm_host_python::release_count; -use pyo3::{prelude::*, types::PyDict}; +use litellm_host_python::{release_count, runtime_started}; +use pyo3::{exceptions::PyRuntimeError, prelude::*, types::PyDict}; #[pyfunction] pub(crate) fn gil_stats(py: Python<'_>) -> PyResult> { @@ -8,6 +8,20 @@ pub(crate) fn gil_stats(py: Python<'_>) -> PyResult> { Ok(stats.into_any().unbind()) } +/// True once this process has started the native runtime, which does not survive `fork()`. +#[pyfunction] +pub(crate) fn process_state_started() -> bool { + runtime_started() +} + +/// Declares that this process only forks workers: from now on every native route raises here, +/// so the runtime can never start. Raises if it already has. Forked workers are unaffected. +#[pyfunction] +pub(crate) fn reserve_process_for_forking() -> PyResult<()> { + litellm_host_python::reserve_process_for_forking() + .map_err(|_| PyRuntimeError::new_err("the native runtime already started in this process")) +} + #[cfg(feature = "panic-test")] #[pyfunction] pub(crate) fn _panic_for_test() { diff --git a/litellm-rust/crates/python-bridge/src/lib.rs b/litellm-rust/crates/python-bridge/src/lib.rs index 7eba0d201be..a41e1500f04 100644 --- a/litellm-rust/crates/python-bridge/src/lib.rs +++ b/litellm-rust/crates/python-bridge/src/lib.rs @@ -13,10 +13,12 @@ mod _native { #[pymodule_export] use crate::diagnostics::_panic_for_test; #[pymodule_export] - use crate::diagnostics::gil_stats; + use crate::diagnostics::{gil_stats, process_state_started, reserve_process_for_forking}; #[pymodule_export] use crate::errors::{RustBridgeDeclined, RustUpstreamError}; #[pymodule_export] + use litellm_host_python::{ForkedAfterNativeRuntimeStarted, ProcessReservedForForking}; + #[pymodule_export] use crate::routes::audio_transcription::{atranscription, transcription}; #[pymodule_export] use crate::routes::chat_completions::{ @@ -50,6 +52,8 @@ mod tests { let mut expected = vec![ "RustBridgeDeclined", "RustUpstreamError", + "ForkedAfterNativeRuntimeStarted", + "ProcessReservedForForking", "ocr", "aocr", "transcription", @@ -62,6 +66,8 @@ mod tests { "ResponsesWebSocketConnection", "TokenCounter", "gil_stats", + "process_state_started", + "reserve_process_for_forking", ]; expected.sort_unstable(); diff --git a/litellm-rust/crates/python-bridge/src/routes/responses.rs b/litellm-rust/crates/python-bridge/src/routes/responses.rs index 9c10d58de4f..2e7e8fcbc21 100644 --- a/litellm-rust/crates/python-bridge/src/routes/responses.rs +++ b/litellm-rust/crates/python-bridge/src/routes/responses.rs @@ -25,7 +25,7 @@ impl ResponsesWebSocketConnection { ) -> PyResult> { let headers = marshal_headers(headers)?; let timeout = optional_timeout(timeout_seconds); - pyo3_async_runtimes::tokio::future_into_py(py, async move { + litellm_host_python::run_async_value(py, async move { let inner = RustResponsesWebSocketConnection::connect_url(&url, &headers, timeout) .await .map_err(responses_error_to_pyerr)?; @@ -35,7 +35,7 @@ impl ResponsesWebSocketConnection { fn send_text<'py>(&self, py: Python<'py>, text: String) -> PyResult> { let inner = self.inner.clone(); - pyo3_async_runtimes::tokio::future_into_py(py, async move { + litellm_host_python::run_async_value(py, async move { inner .send_text(text) .await @@ -45,14 +45,14 @@ impl ResponsesWebSocketConnection { fn recv_text<'py>(&self, py: Python<'py>) -> PyResult> { let inner = self.inner.clone(); - pyo3_async_runtimes::tokio::future_into_py(py, async move { + litellm_host_python::run_async_value(py, async move { inner.recv_text().await.map_err(responses_error_to_pyerr) }) } fn close<'py>(&self, py: Python<'py>) -> PyResult> { let inner = self.inner.clone(); - pyo3_async_runtimes::tokio::future_into_py(py, async move { + litellm_host_python::run_async_value(py, async move { inner.close().await.map_err(responses_error_to_pyerr) }) } @@ -68,6 +68,10 @@ mod tests { use tokio_tungstenite::{accept_async, tungstenite::Message}; #[test] + #[expect( + clippy::disallowed_methods, + reason = "the test server shares the routes' runtime" + )] fn responses_websocket_connection_round_trips_through_python() { Python::initialize(); let runtime = pyo3_async_runtimes::tokio::get_runtime(); diff --git a/litellm/proxy/proxy_cli.py b/litellm/proxy/proxy_cli.py index 9f2e4c9802e..0477b6c62e9 100644 --- a/litellm/proxy/proxy_cli.py +++ b/litellm/proxy/proxy_cli.py @@ -589,6 +589,11 @@ class ProxyInitializationHelpers: gunicorn_options["certfile"] = ssl_certfile_path gunicorn_options["keyfile"] = ssl_keyfile_path + # The master preloads the app and then forks every worker, so native routes are + # forbidden in it: their runtime threads would not survive the fork. + from litellm.rust_bridge.fork_guard import reserve_process_for_forking + + reserve_process_for_forking("the gunicorn master") start_query_engine_reaper() StandaloneApplication(app=app, options=gunicorn_options).run() # Run gunicorn diff --git a/litellm/rust_bridge/_native.pyi b/litellm/rust_bridge/_native.pyi index 9f959c056de..c0a06364261 100644 --- a/litellm/rust_bridge/_native.pyi +++ b/litellm/rust_bridge/_native.pyi @@ -9,6 +9,8 @@ from litellm.types.llms.anthropic_messages.anthropic_response import AnthropicMe class RustBridgeDeclined(Exception): ... class RustUpstreamError(Exception): ... +class ForkedAfterNativeRuntimeStarted(RuntimeError): ... +class ProcessReservedForForking(RuntimeError): ... def ocr( request: LiteLLMOcrRequest, @@ -101,8 +103,12 @@ class TokenCounter: def acount_request(self, body: bytes) -> Future[dict[str, object]]: ... def gil_stats() -> dict[str, int]: ... +def process_state_started() -> bool: ... +def reserve_process_for_forking() -> None: ... __all__ = [ + "ForkedAfterNativeRuntimeStarted", + "ProcessReservedForForking", "ResponsesWebSocketConnection", "RustBridgeDeclined", "RustUpstreamError", @@ -116,5 +122,7 @@ __all__ = [ "gil_stats", "messages", "ocr", + "process_state_started", + "reserve_process_for_forking", "transcription", ] diff --git a/litellm/rust_bridge/fork_guard.py b/litellm/rust_bridge/fork_guard.py new file mode 100644 index 00000000000..c94665fb8db --- /dev/null +++ b/litellm/rust_bridge/fork_guard.py @@ -0,0 +1,47 @@ +"""Fork safety of the Rust extension. + +Its runtime threads do not survive ``fork``, so a child forked after the first native call +cannot run native routes: it raises ``ForkedAfterNativeRuntimeStarted`` instead of hanging. +Fork before the first native call, or start workers with ``spawn`` / ``forkserver``. + +A process whose job is to fork workers (the gunicorn master under ``preload``) reserves itself: +from then on any native route called in it raises ``ProcessReservedForForking`` at the call +site, so the runtime can never start there. Workers forked from it are unaffected. +""" + +from __future__ import annotations + +from typing import Final + +from litellm.rust_bridge.loader import get_native_bridge + + +class NativeStateStartedBeforeFork(RuntimeError): + pass + + +class _NeverRaised(RuntimeError): + """Stands in for a native exception when the extension is unavailable or predates it.""" + + +_native: Final = get_native_bridge() +ForkedAfterNativeRuntimeStarted: Final[type[RuntimeError]] = getattr( + _native, "ForkedAfterNativeRuntimeStarted", _NeverRaised +) +ProcessReservedForForking: Final[type[RuntimeError]] = getattr(_native, "ProcessReservedForForking", _NeverRaised) + + +def reserve_process_for_forking(where: str) -> None: + """Forbid native routes in this process. Raises if one already ran here.""" + native: Final = get_native_bridge() + reserve: Final = getattr(native, "reserve_process_for_forking", None) + if not callable(reserve): + return + try: + reserve() + except RuntimeError as error: + raise NativeStateStartedBeforeFork( + f"The LiteLLM Rust extension already ran a native route in {where}, and its runtime " + "threads do not survive fork(). Move the native call (warm-up, health check, " + "import-time initialization) into the worker, after the fork." + ) from error diff --git a/tests/test_litellm/proxy/test_proxy_cli.py b/tests/test_litellm/proxy/test_proxy_cli.py index 712c526b244..c806725d594 100644 --- a/tests/test_litellm/proxy/test_proxy_cli.py +++ b/tests/test_litellm/proxy/test_proxy_cli.py @@ -21,6 +21,15 @@ from uvicorn.importer import import_from_string from litellm.proxy.proxy_cli import ProxyInitializationHelpers, run_server +@pytest.fixture(autouse=True) +def fork_reservation(): + """Reserving is irreversible: it would forbid native routes in this pytest worker for good""" + with patch( # test-quality-ok: process-global native state, a real reservation would poison every later test in the worker + "litellm.rust_bridge.fork_guard.reserve_process_for_forking" + ) as reserve: + yield reserve + + @pytest.mark.xdist_group("proxy_cli") class TestProxyInitializationHelpers: @patch("importlib.metadata.version") @@ -1574,6 +1583,32 @@ class TestProxyInitializationHelpers: assert captured["options"]["max_requests"] == 1000 assert captured["options"]["max_requests_jitter"] == 50 + @pytest.mark.skipif(os.name == "nt", reason="gunicorn server path skips Windows") + def test_gunicorn_master_is_reserved_for_forking_before_it_runs(self, fork_reservation): + """preload forks workers from the master, so native routes are forbidden there first""" + pytest.importorskip("gunicorn") + reserved_before_run: list = [] + + def capture_run(self): + reserved_before_run.append(fork_reservation.call_args) + + with ( + patch("gunicorn.app.base.BaseApplication.run", capture_run), + patch( # test-quality-ok: option tests must not start a thread or change the pytest worker's child ownership + "litellm.proxy.proxy_cli.start_query_engine_reaper" + ), + ): + ProxyInitializationHelpers._run_gunicorn_server( + host="127.0.0.1", + port=4012, + app=MagicMock(), + num_workers=2, + ssl_certfile_path=None, + ssl_keyfile_path=None, + ) + + assert [call.args for call in reserved_before_run] == [("the gunicorn master",)] + @pytest.mark.skipif(os.name == "nt", reason="gunicorn server path skips Windows") def test_gunicorn_jitter_without_base_warns(self): """gunicorn path warns when jitter is set without --max_requests_before_restart""" diff --git a/tests/test_litellm/rust_bridge/test_fork_guard.py b/tests/test_litellm/rust_bridge/test_fork_guard.py new file mode 100644 index 00000000000..54bfd54c230 --- /dev/null +++ b/tests/test_litellm/rust_bridge/test_fork_guard.py @@ -0,0 +1,36 @@ +from types import SimpleNamespace + +import pytest + +from litellm.rust_bridge import fork_guard + + +def _reserve_with(monkeypatch: pytest.MonkeyPatch, native: object) -> None: + monkeypatch.setattr(fork_guard, "get_native_bridge", lambda: native) + fork_guard.reserve_process_for_forking("the gunicorn master") + + +def test_missing_extension_has_nothing_to_reserve(monkeypatch: pytest.MonkeyPatch) -> None: + _reserve_with(monkeypatch, None) + + +def test_extension_built_before_reservation_existed_passes(monkeypatch: pytest.MonkeyPatch) -> None: + _reserve_with(monkeypatch, SimpleNamespace()) + + +def test_unused_extension_is_reserved(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[None] = [] + + _reserve_with(monkeypatch, SimpleNamespace(reserve_process_for_forking=lambda: calls.append(None))) + + assert calls == [None] + + +def test_used_extension_refuses_and_names_the_place(monkeypatch: pytest.MonkeyPatch) -> None: + def reserve() -> None: + raise RuntimeError("the native runtime already started in this process") + + with pytest.raises(fork_guard.NativeStateStartedBeforeFork, match="the gunicorn master") as raised: + _reserve_with(monkeypatch, SimpleNamespace(reserve_process_for_forking=reserve)) + + assert isinstance(raised.value.__cause__, RuntimeError) diff --git a/tests/test_litellm_rust/test_fork_guard.py b/tests/test_litellm_rust/test_fork_guard.py new file mode 100644 index 00000000000..b92095cbaaa --- /dev/null +++ b/tests/test_litellm_rust/test_fork_guard.py @@ -0,0 +1,150 @@ +import os +import subprocess +import sys +import textwrap + +import pytest + +pytestmark = pytest.mark.requires_rust_extension + +_NATIVE_CONTRACT = textwrap.dedent( + """ + import os + from litellm.rust_bridge import _native + from litellm.rust_bridge.fork_guard import reserve_process_for_forking + + def native_route_error(): + import asyncio + + async def call(): + await _native.ResponsesWebSocketConnection.connect("ws://127.0.0.1:1", {}, 0.2) + + try: + asyncio.run(call()) + except Exception as error: + return f"{type(error).__name__}: {error}" + return "" + + assert _native.process_state_started() is False + reserve_process_for_forking("the test master") + assert native_route_error().startswith("ProcessReservedForForking: ") + assert _native.process_state_started() is False + + pid = os.fork() + if pid == 0: + error = native_route_error() + started = _native.process_state_started() + os._exit(0 if started and "reserved" not in error and "forked" not in error else 1) + assert os.waitpid(pid, 0)[1] == 0 + + pid = os.fork() + if pid == 0: + native_route_error() + grandchild = os.fork() + if grandchild == 0: + os._exit(0 if native_route_error().startswith("ForkedAfterNativeRuntimeStarted: ") else 1) + os._exit(os.waitpid(grandchild, 0)[1]) + assert os.waitpid(pid, 0)[1] == 0 + """ +) + + +@pytest.mark.skipif(not hasattr(os, "fork"), reason="fork only") +def test_compiled_extension_forbids_the_master_and_frees_its_workers() -> None: + env = {**os.environ, "OBJC_DISABLE_INITIALIZE_FORK_SAFETY": "YES"} + + result = subprocess.run( + [sys.executable, "-c", _NATIVE_CONTRACT], capture_output=True, text=True, timeout=60, env=env + ) + + assert result.returncode == 0, result.stderr + + +_SDK_CONTRACT = textwrap.dedent( + """ + import asyncio, json, multiprocessing, os, threading + from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + import litellm + from litellm.rust_bridge.fork_guard import ForkedAfterNativeRuntimeStarted + + class Handler(BaseHTTPRequestHandler): + def do_POST(self): + self.rfile.read(int(self.headers["Content-Length"])) + if self.headers.get("User-Agent", "").startswith("python-httpx"): + self.send_response(418) + self.end_headers() + return + body = json.dumps({ + "pages": [{"index": 0, "markdown": "native", "images": [], "dimensions": None}], + "model": "mistral-ocr-latest", + "usage_info": {"pages_processed": 1, "doc_size_bytes": 3}, + }).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + arguments = { + "model": "mistral/mistral-ocr-latest", + "document": {"type": "document_url", "document_url": "data:application/pdf;base64,YWJj"}, + "api_key": "test-key", + "api_base": f"http://127.0.0.1:{server.server_port}", + "num_retries": 0, + } + litellm.rust(True) + + SERVED, REFUSED, OTHER = 0, 3, 4 + + def outcome(asynchronous): + try: + response = asyncio.run(litellm.aocr(**arguments)) if asynchronous else litellm.ocr(**arguments) + except ForkedAfterNativeRuntimeStarted: + return REFUSED + except Exception: + return OTHER + return SERVED if response.pages[0].markdown == "native" else OTHER + + def forked(asynchronous): + pid = os.fork() + if pid == 0: + os._exit(outcome(asynchronous)) + return os.waitstatus_to_exitcode(os.waitpid(pid, 0)[1]) + + def pooled(asynchronous): + with multiprocessing.get_context("fork").Pool(1) as pool: + return pool.apply(outcome, (asynchronous,)) + + # Forking before the first native call is fine: the child starts its own runtime. + assert [forked(False), forked(True)] == [SERVED, SERVED] + + assert outcome(False) == SERVED + # After it, a forked child is told so instead of hanging on threads that do not exist. + assert [forked(False), forked(True)] == [REFUSED, REFUSED] + assert [pooled(False), pooled(True)] == [REFUSED, REFUSED] + # The parent is not poisoned by any of it. + assert [outcome(False), outcome(True)] == [SERVED, SERVED] + """ +) + + +@pytest.mark.skipif(not hasattr(os, "fork"), reason="fork only") +def test_sdk_call_in_a_child_forked_after_native_use_raises_instead_of_hanging() -> None: + env = { + **os.environ, + "OBJC_DISABLE_INITIALIZE_FORK_SAFETY": "YES", + "LITELLM_RUST": "1", + "LITELLM_LOCAL_MODEL_COST_MAP": "True", + } + + result = subprocess.run( + [sys.executable, "-c", _SDK_CONTRACT], capture_output=True, text=True, timeout=120, env=env + ) + + assert result.returncode == 0, result.stderr From 752647d1467624fd794d653bed9933b6c8c8037a Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Sat, 19 Sep 2026 10:41:41 -0700 Subject: [PATCH 38/73] wip --- litellm-rust/crates/host-python/src/lib.rs | 5 +++-- litellm-rust/crates/python-bridge/src/lib.rs | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/litellm-rust/crates/host-python/src/lib.rs b/litellm-rust/crates/host-python/src/lib.rs index 4e6337d916d..7d164ab7535 100644 --- a/litellm-rust/crates/host-python/src/lib.rs +++ b/litellm-rust/crates/host-python/src/lib.rs @@ -20,8 +20,9 @@ pub use argument::lookup; pub use callable::wrap_failure; pub use driver::run_call; pub use execution::{ - ForkedAfterNativeRuntimeStarted, ProcessReservedForForking, poll_async_value, reserve_process_for_forking, run_async, run_async_value, run_sync, - run_sync_value, runtime_started, + ForkedAfterNativeRuntimeStarted, ProcessReservedForForking, poll_async_value, + reserve_process_for_forking, run_async, run_async_value, run_sync, run_sync_value, + runtime_started, }; pub use fork_gate::RuntimeAlreadyStarted; pub use gil::{release_count, release_gil}; diff --git a/litellm-rust/crates/python-bridge/src/lib.rs b/litellm-rust/crates/python-bridge/src/lib.rs index a41e1500f04..46f98736aa1 100644 --- a/litellm-rust/crates/python-bridge/src/lib.rs +++ b/litellm-rust/crates/python-bridge/src/lib.rs @@ -17,8 +17,6 @@ mod _native { #[pymodule_export] use crate::errors::{RustBridgeDeclined, RustUpstreamError}; #[pymodule_export] - use litellm_host_python::{ForkedAfterNativeRuntimeStarted, ProcessReservedForForking}; - #[pymodule_export] use crate::routes::audio_transcription::{atranscription, transcription}; #[pymodule_export] use crate::routes::chat_completions::{ @@ -32,6 +30,8 @@ mod _native { use crate::routes::responses::ResponsesWebSocketConnection; #[pymodule_export] use crate::token_counter::TokenCounter; + #[pymodule_export] + use litellm_host_python::{ForkedAfterNativeRuntimeStarted, ProcessReservedForForking}; } use pyo3::prelude::*; From 1bcd8d704fe4ee0a791cec1f2e96221495f94f8e Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Sat, 19 Sep 2026 18:08:19 +0000 Subject: [PATCH 39/73] test: run fork-guard contract subprocesses with python -I Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/test_litellm_rust/test_fork_guard.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_litellm_rust/test_fork_guard.py b/tests/test_litellm_rust/test_fork_guard.py index b92095cbaaa..c15555ff535 100644 --- a/tests/test_litellm_rust/test_fork_guard.py +++ b/tests/test_litellm_rust/test_fork_guard.py @@ -54,7 +54,7 @@ def test_compiled_extension_forbids_the_master_and_frees_its_workers() -> None: env = {**os.environ, "OBJC_DISABLE_INITIALIZE_FORK_SAFETY": "YES"} result = subprocess.run( - [sys.executable, "-c", _NATIVE_CONTRACT], capture_output=True, text=True, timeout=60, env=env + [sys.executable, "-I", "-c", _NATIVE_CONTRACT], capture_output=True, text=True, timeout=60, env=env ) assert result.returncode == 0, result.stderr @@ -144,7 +144,7 @@ def test_sdk_call_in_a_child_forked_after_native_use_raises_instead_of_hanging() } result = subprocess.run( - [sys.executable, "-c", _SDK_CONTRACT], capture_output=True, text=True, timeout=120, env=env + [sys.executable, "-I", "-c", _SDK_CONTRACT], capture_output=True, text=True, timeout=120, env=env ) assert result.returncode == 0, result.stderr From 18a1491bd2b3cb2ddc9a493e712c92393f970d4c Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Sat, 19 Sep 2026 18:17:54 +0000 Subject: [PATCH 40/73] test(rust): pin child interpreters to the parent's litellm and lint for it Children spawned as [sys.executable, -c, ...] put the working directory first on sys.path, so under 'make test-rust-extension' a source checkout shadows the installed wheel and the child imports a litellm with no compiled extension. A shared helper spawns them with -I and asserts the child resolved the same litellm.__file__ as the parent, and a new TQ009 rule flags un-isolated sys.executable spawns. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- scripts/check_test_quality.py | 40 +++++++++++++++++++ test-quality-budget.json | 3 ++ .../rust_bridge/test_fork_guard.py | 4 +- tests/test_litellm/test_check_test_quality.py | 25 ++++++++++++ .../support/child_interpreter.py | 36 +++++++++++++++++ tests/test_litellm_rust/test_fork_guard.py | 12 ++---- 6 files changed, 110 insertions(+), 10 deletions(-) create mode 100644 tests/test_litellm_rust/support/child_interpreter.py diff --git a/scripts/check_test_quality.py b/scripts/check_test_quality.py index 41342acd23a..1ef4aed8675 100644 --- a/scripts/check_test_quality.py +++ b/scripts/check_test_quality.py @@ -60,6 +60,13 @@ TQ007 A module global that a conftest saves before every test and restores aft names are read from the keys the conftest assigns directly and from whatever the save loop iterates, including a module-level tuple or dict it names rather than spells out. +TQ009 A child interpreter spawned as `subprocess.run([sys.executable, ...])` without + `-I`/`-P` as its first flag. Without isolation the child's sys.path leads with + the working directory, so a source checkout shadows the installed package and + the child tests a different `litellm` than the parent imported -- TQ003 is the + same working-directory hazard seen from the child's side. Use + tests.test_litellm_rust.support.child_interpreter.run_child_interpreter, which + also asserts the child resolved the same `litellm.__file__` as the parent. Every rule is suppressible with `# test-quality-ok: ` on the reported line, following the repo's `*-ok: ` convention. A suppression without a @@ -140,6 +147,9 @@ SKIP_CALLS: Final = frozenset(("pytest.skip", "skip")) CONFTEST_NAME: Final = "conftest.py" SDK_MODULE: Final = "litellm" +SUBPROCESS_SPAWNS: Final = frozenset(("run", "Popen", "check_output", "check_call", "call")) +INTERPRETER_ISOLATION_FLAGS: Final = frozenset(("-I", "-P")) + CREDENTIAL_NAME_RE: Final = re.compile( r"(?:API_KEY|_KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|DATABASE_URL|ACCESS_KEY_ID)$" ) @@ -709,6 +719,35 @@ def _snapshotted_names(tree: ast.Module) -> Iterator[tuple[str, int]]: yield from _string_members(iterable) +def iter_child_interpreter_violations(path: Path, tree: ast.Module) -> Iterator[Violation]: + for node in ast.walk(tree): + if not (isinstance(node, ast.Call) and node.args): + continue + if _dotted_name(node.func).rsplit(".", 1)[-1] not in SUBPROCESS_SPAWNS: + continue + argv: Final = node.args[0] + if not isinstance(argv, (ast.List, ast.Tuple)) or not argv.elts: + continue + if _dotted_name(argv.elts[0]) != "sys.executable": + continue + isolated: Final = ( + len(argv.elts) > 1 + and isinstance(argv.elts[1], ast.Constant) + and argv.elts[1].value in INTERPRETER_ISOLATION_FLAGS + ) + if isolated: + continue + yield Violation( + path, + node.lineno, + "TQ009", + "child interpreter spawned without -I/-P; the working directory lands on sys.path " + "and a source checkout can shadow the installed package, use " + "tests.test_litellm_rust.support.child_interpreter.run_child_interpreter or pass -I " + f"(suppress: `# {SUPPRESSION_TOKEN}: `)", + ) + + def iter_conftest_inventory_violations(path: Path, tree: ast.Module) -> Iterator[Violation]: if path.name != CONFTEST_NAME: return @@ -746,6 +785,7 @@ def check_file(path: Path) -> tuple[Violation, ...]: *iter_credential_skip_violations(path, tree), *iter_conftest_inventory_violations(path, tree), *iter_internal_patch_violations(path, tree), + *iter_child_interpreter_violations(path, tree), ) if violation.line not in skip ) diff --git a/test-quality-budget.json b/test-quality-budget.json index 3c12371f02f..ae4ea4d31be 100644 --- a/test-quality-budget.json +++ b/test-quality-budget.json @@ -22,5 +22,8 @@ }, "TQ008": { "limit": 10993 + }, + "TQ009": { + "limit": 59 } } diff --git a/tests/test_litellm/rust_bridge/test_fork_guard.py b/tests/test_litellm/rust_bridge/test_fork_guard.py index 54bfd54c230..88ae017ec39 100644 --- a/tests/test_litellm/rust_bridge/test_fork_guard.py +++ b/tests/test_litellm/rust_bridge/test_fork_guard.py @@ -11,11 +11,11 @@ def _reserve_with(monkeypatch: pytest.MonkeyPatch, native: object) -> None: def test_missing_extension_has_nothing_to_reserve(monkeypatch: pytest.MonkeyPatch) -> None: - _reserve_with(monkeypatch, None) + assert _reserve_with(monkeypatch, None) is None def test_extension_built_before_reservation_existed_passes(monkeypatch: pytest.MonkeyPatch) -> None: - _reserve_with(monkeypatch, SimpleNamespace()) + assert _reserve_with(monkeypatch, SimpleNamespace()) is None def test_unused_extension_is_reserved(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/tests/test_litellm/test_check_test_quality.py b/tests/test_litellm/test_check_test_quality.py index a75b1e43fb7..bfe503e74d1 100644 --- a/tests/test_litellm/test_check_test_quality.py +++ b/tests/test_litellm/test_check_test_quality.py @@ -737,3 +737,28 @@ def test_a_fanned_out_run_reports_each_generated_file_exactly_once(tmp_path): assert len(reported) == len(paths) assert len({line.split(":")[0] for line in reported}) == len(paths) assert all(" TQ001 " in line for line in reported) + + +def test_sys_executable_child_without_isolation_flag_is_flagged(tmp_path): + source = 'import subprocess, sys\nsubprocess.run([sys.executable, "-c", "pass"])\n' + assert _codes(tmp_path, source) == ["TQ009"] + + +def test_sys_executable_child_with_dash_i_is_clean(tmp_path): + source = 'import subprocess, sys\nsubprocess.run([sys.executable, "-I", "-c", "pass"])\n' + assert _codes(tmp_path, source) == [] + + +def test_sys_executable_child_with_dash_p_is_clean(tmp_path): + source = 'import subprocess, sys\nsubprocess.run([sys.executable, "-P", "-c", "pass"])\n' + assert _codes(tmp_path, source) == [] + + +def test_non_interpreter_subprocess_call_is_untouched(tmp_path): + source = 'import subprocess\nsubprocess.run(["python", "-c", "pass"])\n' + assert _codes(tmp_path, source) == [] + + +def test_popen_sys_executable_tuple_is_flagged(tmp_path): + source = 'import subprocess, sys\nsubprocess.Popen((sys.executable, "script.py"))\n' + assert _codes(tmp_path, source) == ["TQ009"] diff --git a/tests/test_litellm_rust/support/child_interpreter.py b/tests/test_litellm_rust/support/child_interpreter.py new file mode 100644 index 00000000000..26bbe03a2d8 --- /dev/null +++ b/tests/test_litellm_rust/support/child_interpreter.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import os +import subprocess +import sys +from collections.abc import Mapping +from typing import Final + +import litellm + +PARENT_LITELLM_FILE: Final = "LITELLM_TEST_PARENT_LITELLM_FILE" + +_PROLOGUE: Final = ( + "import os as _os, litellm as _litellm; _parent = _os.environ.pop({key!r}); " + 'assert _litellm.__file__ == _parent, f"child imported litellm from {{_litellm.__file__}}, parent from {{_parent}}"; ' + "del _os, _litellm, _parent\n" +) + + +def run_child_interpreter( + source: str, *, env: Mapping[str, str] | None = None, timeout: float +) -> subprocess.CompletedProcess[str]: + """Run `source` in a fresh interpreter that imports the same `litellm` as this process. + + `-I` keeps the working directory off sys.path so a source checkout cannot shadow an + installed wheel, and the prologue fails fast with both paths if the child still + resolves a different package. + """ + environment: Final = {**(os.environ if env is None else env), PARENT_LITELLM_FILE: litellm.__file__} + return subprocess.run( + [sys.executable, "-I", "-c", _PROLOGUE.format(key=PARENT_LITELLM_FILE) + source], + capture_output=True, + text=True, + timeout=timeout, + env=environment, + ) diff --git a/tests/test_litellm_rust/test_fork_guard.py b/tests/test_litellm_rust/test_fork_guard.py index c15555ff535..086397bab5c 100644 --- a/tests/test_litellm_rust/test_fork_guard.py +++ b/tests/test_litellm_rust/test_fork_guard.py @@ -1,10 +1,10 @@ import os -import subprocess -import sys import textwrap import pytest +from tests.test_litellm_rust.support.child_interpreter import run_child_interpreter + pytestmark = pytest.mark.requires_rust_extension _NATIVE_CONTRACT = textwrap.dedent( @@ -53,9 +53,7 @@ _NATIVE_CONTRACT = textwrap.dedent( def test_compiled_extension_forbids_the_master_and_frees_its_workers() -> None: env = {**os.environ, "OBJC_DISABLE_INITIALIZE_FORK_SAFETY": "YES"} - result = subprocess.run( - [sys.executable, "-I", "-c", _NATIVE_CONTRACT], capture_output=True, text=True, timeout=60, env=env - ) + result = run_child_interpreter(_NATIVE_CONTRACT, env=env, timeout=60) assert result.returncode == 0, result.stderr @@ -143,8 +141,6 @@ def test_sdk_call_in_a_child_forked_after_native_use_raises_instead_of_hanging() "LITELLM_LOCAL_MODEL_COST_MAP": "True", } - result = subprocess.run( - [sys.executable, "-I", "-c", _SDK_CONTRACT], capture_output=True, text=True, timeout=120, env=env - ) + result = run_child_interpreter(_SDK_CONTRACT, env=env, timeout=120) assert result.returncode == 0, result.stderr From 38fa8a7f551dc0a3e37d85930f3084afab97d5a4 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Sat, 19 Sep 2026 18:21:32 +0000 Subject: [PATCH 41/73] fix(rust): leave the fork gate untouched when a late reservation is refused reserve() stored fork_only_pid before noticing the runtime already ran under that pid, so a refused reservation still reserved the process: the next enter() cleared the runtime claim and children forked afterwards inherited a dead runtime and hung. Undo the reservation on the error path so the gate is exactly as it was. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../crates/host-python/src/fork_gate.rs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/litellm-rust/crates/host-python/src/fork_gate.rs b/litellm-rust/crates/host-python/src/fork_gate.rs index 62284e978ff..cdf269deaec 100644 --- a/litellm-rust/crates/host-python/src/fork_gate.rs +++ b/litellm-rust/crates/host-python/src/fork_gate.rs @@ -51,9 +51,18 @@ impl ForkGate { Ok(()) } + /// Reserves `pid` for forking. Reserve first, then look for a started runtime: `enter` does + /// the mirror image, so when the two race at least one of them sees the other. A refused + /// reservation leaves the gate exactly as it was, so a process already running the runtime + /// keeps refusing the children it forks. pub(crate) fn reserve(&self, pid: u32) -> Result<(), RuntimeAlreadyStarted> { self.fork_only_pid.store(pid, Ordering::SeqCst); if self.runtime_pid.load(Ordering::SeqCst) == pid { + // Nothing may change for a process that already runs the runtime: its children + // must still be refused. + let _ = + self.fork_only_pid + .compare_exchange(pid, UNSET, Ordering::SeqCst, Ordering::SeqCst); return Err(RuntimeAlreadyStarted); } Ok(()) @@ -109,6 +118,17 @@ mod tests { assert_eq!(gate.reserve(MASTER), Err(RuntimeAlreadyStarted)); } + #[test] + fn a_refused_reservation_leaves_the_runtime_claimed_and_its_children_refused() { + let gate = ForkGate::new(); + gate.enter(MASTER).unwrap(); + + assert_eq!(gate.reserve(MASTER), Err(RuntimeAlreadyStarted)); + assert_eq!(gate.enter(MASTER), Ok(())); + assert!(gate.started(MASTER)); + assert_eq!(gate.enter(WORKER), Err(Refused::ForkedAfterStart)); + } + #[test] fn child_forked_after_the_runtime_started_is_refused_instead_of_hanging() { let gate = ForkGate::new(); From cc23e5781e4d09de7c744ea45dfd197e46d2fbff Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Sat, 19 Sep 2026 18:22:06 +0000 Subject: [PATCH 42/73] refactor(rust): drop a comment that repeats the reserve doc Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm-rust/crates/host-python/src/fork_gate.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/litellm-rust/crates/host-python/src/fork_gate.rs b/litellm-rust/crates/host-python/src/fork_gate.rs index cdf269deaec..c4842dd9223 100644 --- a/litellm-rust/crates/host-python/src/fork_gate.rs +++ b/litellm-rust/crates/host-python/src/fork_gate.rs @@ -58,8 +58,6 @@ impl ForkGate { pub(crate) fn reserve(&self, pid: u32) -> Result<(), RuntimeAlreadyStarted> { self.fork_only_pid.store(pid, Ordering::SeqCst); if self.runtime_pid.load(Ordering::SeqCst) == pid { - // Nothing may change for a process that already runs the runtime: its children - // must still be refused. let _ = self.fork_only_pid .compare_exchange(pid, UNSET, Ordering::SeqCst, Ordering::SeqCst); From 364d8975456548d7e2753aa13e51ab202e6fc110 Mon Sep 17 00:00:00 2001 From: yucheng Date: Sat, 19 Sep 2026 18:26:59 +0000 Subject: [PATCH 43/73] fix(otel v2): map Responses API output onto the Langfuse generation output Responses API calls build the generation output only from response["choices"], which Responses payloads do not carry, so Langfuse rendered a blank output. Fold output[] into one assistant choice (output_text parts concatenated, function_call and custom_tool_call items as tool_calls) and derive the finish reason from status when choices are absent. Custom tool call input is now redacted alongside function call arguments under turn_off_message_logging. Carries the behavior of #41604 by @moshemorad (issue #41591) onto current main with typed conversion and single-message output. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/integrations/otel/model/payloads.py | 82 +++++++++++- litellm/litellm_core_utils/redact_messages.py | 4 + .../otel/test_otel_v2_sources_of_truth.py | 117 ++++++++++++++++++ .../otel/test_otel_v2_vendor_mappers.py | 30 +++++ .../test_redact_messages.py | 22 ++++ 5 files changed, 253 insertions(+), 2 deletions(-) diff --git a/litellm/integrations/otel/model/payloads.py b/litellm/integrations/otel/model/payloads.py index 467c286db9d..484f4a4c294 100644 --- a/litellm/integrations/otel/model/payloads.py +++ b/litellm/integrations/otel/model/payloads.py @@ -7,9 +7,11 @@ from collections.abc import Mapping, Sequence from dataclasses import dataclass, field from enum import Enum from types import MappingProxyType -from typing import TYPE_CHECKING, ClassVar, Final, cast +from typing import TYPE_CHECKING, ClassVar, Final, Literal, cast from urllib.parse import urlsplit +from typing_extensions import ReadOnly, TypedDict + from litellm.integrations.otel.model.metadata import RequestContext, RequestIdentity from litellm.integrations.otel.model.semconv import ( GenAIOperation, @@ -25,6 +27,7 @@ from litellm.integrations.otel.model.utils import ( as_float, as_int, as_str, + as_str_mapping, as_str_tuple, ) @@ -424,7 +427,7 @@ class LLMCallSpanData: # plain ``.get`` — no repeated ``isinstance`` guards. raw_response: Final = payload.get("response") response: Final = cast(Mapping[str, object], raw_response if isinstance(raw_response, dict) else {}) - choices_out: Final = _dicts(response.get("choices")) + choices_out: Final = _dicts(response.get("choices")) or _responses_choices(response) # ``finish_reasons`` is metadata, not content, so derive it from # ``choices_out`` before gating. The raw message/choice bodies are only # retained when content capture is enabled (see ``capture_span_content``); @@ -703,6 +706,81 @@ def _finish_reasons(choices: tuple[Mapping[str, object], ...]) -> tuple[str, ... return tuple(r for c in choices if (r := as_str(c.get("finish_reason")))) +class _ToolFunction(TypedDict): + name: ReadOnly[str] + arguments: ReadOnly[str] + + +class _ToolCall(TypedDict): + id: ReadOnly[str] + type: ReadOnly[Literal["function"]] + function: ReadOnly[_ToolFunction] + + +class _AssistantMessage(TypedDict): + role: ReadOnly[str] + content: ReadOnly[str | None] + tool_calls: ReadOnly[tuple[_ToolCall, ...] | None] + + +class _Choice(TypedDict): + message: ReadOnly[_AssistantMessage] + finish_reason: ReadOnly[str | None] + + +_RESPONSES_TOOL_CALL_TYPES: Final = frozenset({"function_call", "custom_tool_call"}) + + +def _responses_choices(response: Mapping[str, object]) -> tuple[_Choice, ...]: + """A Responses API ``output`` folded into one chat-shaped assistant choice.""" + items: Final = _dicts(response.get("output")) + messages: Final = tuple(item for item in items if item.get("type") == "message") + content: Final = "".join( + text + for item in messages + for part in _dicts(item.get("content")) + if part.get("type") == "output_text" + if (text := as_str(part.get("text"))) is not None + ) + tool_calls: Final = tuple( + _responses_tool_call(item) for item in items if item.get("type") in _RESPONSES_TOOL_CALL_TYPES + ) + if not messages and not tool_calls: + return () + message: Final[_AssistantMessage] = { + "role": next((role for item in messages if (role := as_str(item.get("role")))), "assistant"), + "content": content if messages else None, + "tool_calls": tool_calls or None, + } + choice: Final[_Choice] = {"message": message, "finish_reason": _responses_finish_reason(response, bool(tool_calls))} + return (choice,) + + +def _responses_tool_call(item: Mapping[str, object]) -> _ToolCall: + custom: Final = item.get("type") == "custom_tool_call" + function: Final[_ToolFunction] = { + "name": as_str(item.get("name")) or "", + "arguments": as_str(item.get("input" if custom else "arguments")) or "", + } + tool_call: Final[_ToolCall] = { + "id": as_str(item.get("call_id")) or as_str(item.get("id")) or "", + "type": "function", + "function": function, + } + return tool_call + + +def _responses_finish_reason(response: Mapping[str, object], has_tool_calls: bool) -> str | None: + status: Final = as_str(response.get("status")) + if status == "completed": + return "tool_calls" if has_tool_calls else "stop" + if status != "incomplete": + return None + details: Final = as_str_mapping(response.get("incomplete_details")) + reason: Final = details.get("reason") if details is not None else None + return "content_filter" if reason == "content_filter" else "length" + + def _parse_error(payload: StandardLoggingPayload) -> SpanError | None: """A ``SpanError`` for a failed request, or ``None`` on success.""" if payload.get("status") != "failure": diff --git a/litellm/litellm_core_utils/redact_messages.py b/litellm/litellm_core_utils/redact_messages.py index 9d22a5ddef5..1f9464a2a26 100644 --- a/litellm/litellm_core_utils/redact_messages.py +++ b/litellm/litellm_core_utils/redact_messages.py @@ -138,6 +138,8 @@ def _redact_responses_api_output(output_items): if hasattr(output_item, "type") and output_item.type == "function_call" and hasattr(output_item, "arguments"): output_item.arguments = REDACTED_BY_LITELLM + if hasattr(output_item, "type") and output_item.type == "custom_tool_call" and hasattr(output_item, "input"): + output_item.input = REDACTED_BY_LITELLM def _redact_responses_api_output_dict(output_items, redacted_str: str): @@ -161,6 +163,8 @@ def _redact_responses_api_output_dict(output_items, redacted_str: str): if output_item.get("type") == "function_call" and "arguments" in output_item: output_item["arguments"] = redacted_str + if output_item.get("type") == "custom_tool_call" and "input" in output_item: + output_item["input"] = redacted_str def redacted_standard_logging_payload(payload: Mapping[str, object]) -> Mapping[str, object]: diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_sources_of_truth.py b/tests/test_litellm/integrations/otel/test_otel_v2_sources_of_truth.py index f4a8691f72f..972c91670f8 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_sources_of_truth.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_sources_of_truth.py @@ -738,6 +738,123 @@ def test_embedding_summary_is_absent_without_vectors_and_for_chat_data_lists(): assert chat.embedding_output is None +def _responses_payload(output: list[object], status: str = "completed", **response_fields: object): + return _sample_payload( + call_type="aresponses", + model="gpt-5.4-nano", + response={"id": "resp_1", "object": "response", "status": status, "output": output, **response_fields}, + ) + + +_RESPONSES_TEXT_ITEM = { + "type": "message", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": "po", "annotations": []}, {"type": "output_text", "text": "ng"}], +} + + +def test_responses_output_text_becomes_one_assistant_choice_with_stop(): + data = LLMCallSpanData.from_standard_logging_payload( + _responses_payload([{"type": "reasoning", "summary": []}, _RESPONSES_TEXT_ITEM]), capture_content=True + ) + + assert json.loads(json.dumps(data.choices_out)) == [ + { + "message": {"role": "assistant", "content": "pong", "tool_calls": None}, + "finish_reason": "stop", + } + ] + assert data.finish_reasons == ("stop",) + assert data.response_id == "resp_1" + + +def test_responses_tool_calls_fold_into_the_assistant_message_with_tool_calls_finish_reason(): + data = LLMCallSpanData.from_standard_logging_payload( + _responses_payload( + [ + _RESPONSES_TEXT_ITEM, + {"type": "function_call", "call_id": "call_1", "name": "get_weather", "arguments": '{"city": "sf"}'}, + {"type": "custom_tool_call", "call_id": "call_2", "name": "grep", "input": "-r TODO"}, + ] + ), + capture_content=True, + ) + + assert len(data.choices_out) == 1 + message = data.choices_out[0]["message"] + assert message["content"] == "pong" + assert json.loads(json.dumps(message["tool_calls"])) == [ + {"id": "call_1", "type": "function", "function": {"name": "get_weather", "arguments": '{"city": "sf"}'}}, + {"id": "call_2", "type": "function", "function": {"name": "grep", "arguments": "-r TODO"}}, + ] + assert data.finish_reasons == ("tool_calls",) + + +def test_responses_tool_call_only_output_has_no_content(): + data = LLMCallSpanData.from_standard_logging_payload( + _responses_payload([{"type": "function_call", "id": "fc_1", "name": "get_weather", "arguments": "{}"}]), + capture_content=True, + ) + + assert data.choices_out[0]["message"]["content"] is None + assert data.choices_out[0]["message"]["tool_calls"][0]["id"] == "fc_1" + + +@pytest.mark.parametrize( + ("status", "response_fields", "expected"), + [ + ("incomplete", {"incomplete_details": {"reason": "max_output_tokens"}}, ("length",)), + ("incomplete", {"incomplete_details": {"reason": "content_filter"}}, ("content_filter",)), + ("incomplete", {}, ("length",)), + ("failed", {}, ()), + ], +) +def test_responses_status_maps_to_finish_reasons(status, response_fields, expected): + data = LLMCallSpanData.from_standard_logging_payload( + _responses_payload([_RESPONSES_TEXT_ITEM], status=status, **response_fields), capture_content=True + ) + + assert data.finish_reasons == expected + assert data.choices_out[0]["message"]["content"] == "pong" + + +def test_responses_output_follows_the_content_capture_gate_but_finish_reasons_do_not(): + data = LLMCallSpanData.from_standard_logging_payload(_responses_payload([_RESPONSES_TEXT_ITEM])) + + assert data.choices_out == () + assert data.finish_reasons == ("stop",) + + +def test_responses_content_only_reads_output_text_parts(): + item = { + "type": "message", + "role": "assistant", + "content": [{"type": "refusal", "refusal": "no", "text": "not output"}, {"type": "output_text", "text": "ok"}], + } + data = LLMCallSpanData.from_standard_logging_payload(_responses_payload([item]), capture_content=True) + + assert data.choices_out[0]["message"]["content"] == "ok" + + +def test_responses_output_without_messages_or_tool_calls_stays_empty(): + data = LLMCallSpanData.from_standard_logging_payload( + _responses_payload([{"type": "reasoning", "summary": []}]), capture_content=True + ) + + assert data.choices_out == () + assert data.finish_reasons == () + + +def test_chat_choices_win_over_a_responses_output_list(): + payload = _sample_payload(response={"choices": [{"finish_reason": "stop", "message": {"content": "chat"}}]}) + payload["response"]["output"] = [_RESPONSES_TEXT_ITEM] + data = LLMCallSpanData.from_standard_logging_payload(payload, capture_content=True) + + assert data.choices_out[0]["message"]["content"] == "chat" + assert data.finish_reasons == ("stop",) + + def test_request_identity_prefers_canonical_team_keys(): from litellm.integrations.otel.model.payloads import RequestIdentity diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_vendor_mappers.py b/tests/test_litellm/integrations/otel/test_otel_v2_vendor_mappers.py index c5ebc4bc53a..5b4d1e7a802 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_vendor_mappers.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_vendor_mappers.py @@ -196,6 +196,36 @@ def test_langfuse_mapper_keeps_chat_output_when_no_embedding_summary(): assert json.loads(attrs["langfuse.observation.output"]) == [{"role": "assistant", "content": "Sunny."}] +def test_langfuse_mapper_renders_a_responses_api_call_from_the_standard_logging_payload(): + payload = { + "call_type": "aresponses", + "custom_llm_provider": "openai", + "model": "gpt-5.4-nano", + "messages": [{"role": "user", "content": "weather in sf?"}], + "response": { + "id": "resp_1", + "status": "completed", + "output": [ + {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "Checking."}]}, + {"type": "function_call", "call_id": "call_1", "name": "get_weather", "arguments": '{"city": "sf"}'}, + ], + }, + } + data = LLMCallSpanData.from_standard_logging_payload(payload, capture_content=True) + attrs = LangfuseMapper().map(data) + + assert json.loads(attrs["langfuse.observation.output"]) == [ + { + "role": "assistant", + "content": "Checking.", + "tool_calls": [ + {"id": "call_1", "type": "function", "function": {"name": "get_weather", "arguments": '{"city": "sf"}'}} + ], + } + ] + assert attrs["langfuse.observation.type"] == "generation" + + # --------------------------------------------------------------------------- # # Weave # --------------------------------------------------------------------------- # diff --git a/tests/test_litellm/litellm_core_utils/test_redact_messages.py b/tests/test_litellm/litellm_core_utils/test_redact_messages.py index 584a3ac471c..c6c9a9dd2b7 100644 --- a/tests/test_litellm/litellm_core_utils/test_redact_messages.py +++ b/tests/test_litellm/litellm_core_utils/test_redact_messages.py @@ -493,6 +493,20 @@ class TestPerformRedaction: assert redacted["output"][0]["arguments"] == "redacted-by-litellm" assert redacted["output"][0]["name"] == "get_weather" + def test_redacts_responses_api_custom_tool_call_input_dict(self): + result = { + "output": [ + {"type": "custom_tool_call", "name": "grep", "input": "-r secret-token", "call_id": "call_1"}, + {"type": "function_call", "name": "get_weather", "input": "not-a-custom-input", "call_id": "call_2"}, + ] + } + + redacted = perform_redaction({}, result) + + assert redacted["output"][0]["input"] == "redacted-by-litellm" + assert redacted["output"][0]["name"] == "grep" + assert redacted["output"][1]["input"] == "not-a-custom-input" + def test_redacts_every_tool_call_in_multi_element_list(self): result = litellm.ModelResponse( id="resp-multi", @@ -563,6 +577,14 @@ class TestPerformRedaction: assert output_item.arguments == "redacted-by-litellm" assert output_item.name == "get_weather" + def test_redacts_responses_api_custom_tool_call_input_object(self): + output_item = SimpleNamespace(type="custom_tool_call", name="grep", input="-r secret-token", call_id="call_1") + + _redact_responses_api_output([output_item]) + + assert output_item.input == "redacted-by-litellm" + assert output_item.name == "grep" + def test_redacts_response_output_objects_with_top_level_text(self): output_items = [ SimpleNamespace(text="top-level output"), From d67d9984f710b90527dea9b7e1ed43b5aace0888 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Sat, 19 Sep 2026 18:38:37 +0000 Subject: [PATCH 44/73] test: expect TQ009 in the shipped quality budget Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/test_litellm/test_test_quality_gate.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_litellm/test_test_quality_gate.py b/tests/test_litellm/test_test_quality_gate.py index 6652211a828..cde33787c6c 100644 --- a/tests/test_litellm/test_test_quality_gate.py +++ b/tests/test_litellm/test_test_quality_gate.py @@ -136,7 +136,9 @@ def test_the_shipped_budget_covers_every_rule_the_checker_can_emit(): import json budget = json.loads((_REPO_ROOT / "test-quality-budget.json").read_text()) - assert set(budget) == {"TQ001", "TQ002", "TQ003", "TQ004", "TQ005", "TQ006", "TQ007", "TQ008"} + assert set(budget) == { + "TQ001", "TQ002", "TQ003", "TQ004", "TQ005", "TQ006", "TQ007", "TQ008", "TQ009" + } assert all(spec["limit"] >= 0 for spec in budget.values()) From 03a63db1fded5690687f2fcc24f199e9f3a11df8 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:54:57 -0700 Subject: [PATCH 45/73] fix(batches): keep provider timeouts as failed rows and move batch rows behind a repository --- litellm/proxy/batches_endpoints/endpoints.py | 2 + .../litellm_executed_batches.py | 73 ++++++------------- .../repositories/managed_batch_repository.py | 48 ++++++++++++ .../test_litellm_executed_batches.py | 29 ++++++++ 4 files changed, 100 insertions(+), 52 deletions(-) create mode 100644 litellm/repositories/managed_batch_repository.py diff --git a/litellm/proxy/batches_endpoints/endpoints.py b/litellm/proxy/batches_endpoints/endpoints.py index 1284690172a..3f6c9f4d6ed 100644 --- a/litellm/proxy/batches_endpoints/endpoints.py +++ b/litellm/proxy/batches_endpoints/endpoints.py @@ -66,6 +66,7 @@ from litellm.proxy.openai_files_endpoints.common_utils import ( from litellm.proxy.pass_through_endpoints.llm_provider_handlers.batch_attribution import request_tags_from_metadata from litellm.proxy.route_llm_request import raise_if_required_body_param_missing from litellm.proxy.utils import PrismaClient, ProxyLogging, handle_exception_on_proxy, is_known_model +from litellm.repositories.managed_batch_repository import ManagedBatchRepository from litellm.repositories.table_repositories import ManagedFileRepository from litellm.router import Router from litellm.types.llms.openai import LiteLLMBatchCreateRequest @@ -98,6 +99,7 @@ def _litellm_executed_batch_runner(llm_router: Router, proxy_logging_obj: ProxyL llm_router=llm_router, prisma_client=prisma_client, managed_files=managed_files, + batches=ManagedBatchRepository(prisma_client), proxy_logging_obj=proxy_logging_obj, general_settings=general_settings, ) diff --git a/litellm/proxy/batches_endpoints/litellm_executed_batches.py b/litellm/proxy/batches_endpoints/litellm_executed_batches.py index 5a7061d9ab1..67201d99422 100644 --- a/litellm/proxy/batches_endpoints/litellm_executed_batches.py +++ b/litellm/proxy/batches_endpoints/litellm_executed_batches.py @@ -31,12 +31,11 @@ from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup from litellm.proxy.openai_files_endpoints.common_utils import LITELLM_EXECUTED_BATCH_ID_PREFIX from litellm.proxy.openai_files_endpoints.storage_backend_service import StorageBackendFileService from litellm.proxy.utils import PrismaClient, ProxyLogging -from litellm.repositories.table_repositories import ManagedObjectRepository +from litellm.repositories.managed_batch_repository import ManagedBatchRepository from litellm.types.llms.openai import LiteLLMBatchCreateRequest, OpenAIFileObject, OpenAIFilesPurpose from litellm.types.utils import LITELLM_EXECUTED_BATCH_PROVIDERS, ExtractedFileData, LiteLLMBatch, LlmProviders if TYPE_CHECKING: - from prisma import models as prisma_models from prisma import types as prisma_types from litellm.router import Router @@ -342,10 +341,6 @@ def _status_code_of(error: Exception) -> int: return status_code if isinstance(status_code, int) else 500 -def _batch_of(blob: object) -> LiteLLMBatch: - return LiteLLMBatch.model_validate_json(blob) if isinstance(blob, str) else LiteLLMBatch.model_validate(blob) - - def _error_body(error: Exception) -> _ErrorBody: body: Final[_ErrorBody] = { "error": {"message": str(error), "type": type(error).__name__, "param": None, "code": None} @@ -423,6 +418,7 @@ class LiteLLMExecutedBatchRunner: llm_router: "Router", prisma_client: PrismaClient, managed_files: ManagedBatchStore, + batches: ManagedBatchRepository, proxy_logging_obj: ProxyLogging, general_settings: Mapping[str, object], concurrency: int = LITELLM_EXECUTED_BATCH_CONCURRENCY, @@ -434,6 +430,7 @@ class LiteLLMExecutedBatchRunner: self.llm_router = llm_router self.prisma_client = prisma_client self.managed_files = managed_files + self.batches = batches self.proxy_logging_obj = proxy_logging_obj self.general_settings = general_settings self.concurrency = concurrency @@ -502,7 +499,7 @@ class LiteLLMExecutedBatchRunner: return batch async def cancel(self, unified_batch_id: str, user_api_key_dict: UserAPIKeyAuth) -> LiteLLMBatch: - current: Final = await self._load_batch(unified_batch_id) + current: Final = await self.batches.load_batch(unified_batch_id) if current is None: raise batch_error(404, f"Batch {unified_batch_id} not found") if current.status in TERMINAL_BATCH_STATUSES: @@ -513,7 +510,7 @@ class LiteLLMExecutedBatchRunner: update=MappingProxyType({"status": "cancelling", "cancelling_at": int(time.time())}) ) unchanged: Final[prisma_types.LiteLLM_ManagedObjectTableWhereInput] = {"status": current.status} - if await self._store_unless_changed(cancelling, unchanged, user_api_key_dict): + if await self.batches.compare_and_set(cancelling, unchanged, user_api_key_dict.user_id): return cancelling return await self.cancel(unified_batch_id, user_api_key_dict) @@ -530,9 +527,9 @@ class LiteLLMExecutedBatchRunner: "status": batch.status, "updated_at": untouched, } - if await self._store_unless_changed(failed, still_abandoned, user_api_key_dict): + if await self.batches.compare_and_set(failed, still_abandoned, user_api_key_dict.user_id): return failed - return await self._load_batch(batch.id) or batch + return await self.batches.load_batch(batch.id) or batch def _body_rejection(self, model: str) -> BodyRejection: def reject(body: Mapping[str, object]) -> str | None: @@ -591,14 +588,11 @@ class LiteLLMExecutedBatchRunner: verbose_proxy_logger.warning("LiteLLM-executed batch %s heartbeat failed: %s", run.unified_batch_id, e) async def _touch(self, run: _BatchRun) -> None: - await ManagedObjectRepository(self.prisma_client).table.update_many( - where={"unified_object_id": run.unified_batch_id}, # mutable-ok: Prisma filter - data={"updated_by": run.user_api_key_dict.user_id}, # mutable-ok: Prisma payload - ) + await self.batches.touch(run.unified_batch_id, run.user_api_key_dict.user_id) async def _execute(self, run: _BatchRun) -> None: await self._advance(run, "in_progress") - watch: Final = _StopWatch(lambda: self._load_status(run.unified_batch_id), _CANCEL_POLL_SECONDS) + watch: Final = _StopWatch(lambda: self.batches.load_status(run.unified_batch_id), _CANCEL_POLL_SECONDS) semaphore: Final = asyncio.Semaphore(self.concurrency) results: Final = await asyncio.gather(*(self._run_row(run, line, watch, semaphore) for line in run.lines)) outcomes: Final = tuple(outcome for outcome in results if outcome is not None) @@ -634,14 +628,18 @@ class LiteLLMExecutedBatchRunner: if remaining <= 0: return ExpiredRow(custom_id=line.custom_id) try: - body: Final = await asyncio.wait_for(self._dispatch(run, line), timeout=remaining) + return await asyncio.wait_for(self._row_outcome(run, line), timeout=remaining) except asyncio.TimeoutError: return ExpiredRow(custom_id=line.custom_id) - except Exception as e: # noqa: BLE001 # a provider error becomes the row's error line, never a crashed batch - return RowOutcome( - custom_id=line.custom_id, status_code=_status_code_of(e), body=_error_body(e), succeeded=False - ) - return RowOutcome(custom_id=line.custom_id, status_code=200, body=body, succeeded=True) + + async def _row_outcome(self, run: _BatchRun, line: BatchInputLine) -> RowOutcome: + try: + body: Final = await self._dispatch(run, line) + except Exception as e: # noqa: BLE001 # a provider error becomes the row's error line, never a crashed batch + return RowOutcome( + custom_id=line.custom_id, status_code=_status_code_of(e), body=_error_body(e), succeeded=False + ) + return RowOutcome(custom_id=line.custom_id, status_code=200, body=body, succeeded=True) async def _dispatch(self, run: _BatchRun, line: BatchInputLine) -> Mapping[str, object]: params: Final = MappingProxyType( @@ -690,7 +688,7 @@ class LiteLLMExecutedBatchRunner: async def _advance( self, run: _BatchRun, requested: BatchStatus, fields: Mapping[str, object] = _NO_FIELDS ) -> BatchStatus | None: - current: Final = await self._load_batch(run.unified_batch_id) + current: Final = await self.batches.load_batch(run.unified_batch_id) if current is None: raise RuntimeError(f"Batch {run.unified_batch_id} is no longer stored") if current.status in TERMINAL_BATCH_STATUSES: @@ -700,39 +698,10 @@ class LiteLLMExecutedBatchRunner: update=MappingProxyType({**fields, "status": status, f"{status}_at": int(time.time())}) ) unchanged: Final[prisma_types.LiteLLM_ManagedObjectTableWhereInput] = {"status": current.status} - if await self._store_unless_changed(updated, unchanged, run.user_api_key_dict): + if await self.batches.compare_and_set(updated, unchanged, run.user_api_key_dict.user_id): return status return await self._advance(run, requested, fields) - async def _store_unless_changed( - self, - batch: LiteLLMBatch, - guard: "prisma_types.LiteLLM_ManagedObjectTableWhereInput", - user_api_key_dict: UserAPIKeyAuth, - ) -> bool: - updated_rows: Final = await ManagedObjectRepository(self.prisma_client).table.update_many( - where={"unified_object_id": batch.id, **guard}, # mutable-ok: Prisma filter - data={ # mutable-ok: Prisma payload - "file_object": batch.model_dump_json(), - "status": batch.status, - "updated_by": user_api_key_dict.user_id, - }, - ) - return updated_rows > 0 - - async def _find_row(self, unified_batch_id: str) -> "prisma_models.LiteLLM_ManagedObjectTable | None": - return await ManagedObjectRepository(self.prisma_client).table.find_first( - where={"unified_object_id": unified_batch_id} # mutable-ok: Prisma filter - ) - - async def _load_batch(self, unified_batch_id: str) -> LiteLLMBatch | None: - row: Final = await self._find_row(unified_batch_id) - return None if row is None or not row.file_object else _batch_of(row.file_object) - - async def _load_status(self, unified_batch_id: str) -> str | None: - row: Final = await self._find_row(unified_batch_id) - return row.status if row is not None else None - def _record_batch_created(model: str, provider: str, user_api_key_dict: UserAPIKeyAuth) -> None: prometheus_logger: Final = PrometheusLogger.get_instance() diff --git a/litellm/repositories/managed_batch_repository.py b/litellm/repositories/managed_batch_repository.py new file mode 100644 index 00000000000..3f85251fdbd --- /dev/null +++ b/litellm/repositories/managed_batch_repository.py @@ -0,0 +1,48 @@ +from collections.abc import Mapping +from typing import TYPE_CHECKING, Final + +from litellm.repositories.table_repositories import PrismaTableRepository +from litellm.types.utils import LiteLLMBatch + +if TYPE_CHECKING: + from prisma import models as prisma_models + + +def _batch_of(blob: object) -> LiteLLMBatch: + return LiteLLMBatch.model_validate_json(blob) if isinstance(blob, str) else LiteLLMBatch.model_validate(blob) + + +class ManagedBatchRepository(PrismaTableRepository["prisma_models.LiteLLM_ManagedObjectTable"]): + table_name = "litellm_managedobjecttable" + + async def load_batch(self, unified_batch_id: str) -> LiteLLMBatch | None: + row: Final = await self._find_row(unified_batch_id) + return None if row is None or not row.file_object else _batch_of(row.file_object) + + async def load_status(self, unified_batch_id: str) -> str | None: + row: Final = await self._find_row(unified_batch_id) + return row.status if row is not None else None + + async def compare_and_set( + self, batch: LiteLLMBatch, unchanged: Mapping[str, object], updated_by: str | None + ) -> bool: + updated_rows: Final = await self.table.update_many( + where={"unified_object_id": batch.id, **unchanged}, # mutable-ok: prisma filters are plain dicts + data={ # mutable-ok: prisma payloads are plain dicts + "file_object": batch.model_dump_json(), + "status": batch.status, + "updated_by": updated_by, + }, + ) + return updated_rows > 0 + + async def touch(self, unified_batch_id: str, updated_by: str | None) -> None: + await self.table.update_many( + where={"unified_object_id": unified_batch_id}, # mutable-ok: prisma filters are plain dicts + data={"updated_by": updated_by}, # mutable-ok: prisma payloads are plain dicts + ) + + async def _find_row(self, unified_batch_id: str) -> "prisma_models.LiteLLM_ManagedObjectTable | None": + return await self.table.find_first( + where={"unified_object_id": unified_batch_id} # mutable-ok: prisma filters are plain dicts + ) diff --git a/tests/test_litellm/proxy/batches_endpoints/test_litellm_executed_batches.py b/tests/test_litellm/proxy/batches_endpoints/test_litellm_executed_batches.py index 860827e8fbd..6f2341a578c 100644 --- a/tests/test_litellm/proxy/batches_endpoints/test_litellm_executed_batches.py +++ b/tests/test_litellm/proxy/batches_endpoints/test_litellm_executed_batches.py @@ -35,6 +35,7 @@ from litellm.proxy.openai_files_endpoints.common_utils import ( is_litellm_executed_batch, ) from litellm.proxy.utils import PrismaClient, ProxyLogging +from litellm.repositories.managed_batch_repository import ManagedBatchRepository from litellm.router import Router from litellm.types.llms.openai import LiteLLMBatchCreateRequest, OpenAIFileObject, OpenAIFilesPurpose from litellm.types.utils import EmbeddingResponse, LiteLLMBatch, ModelResponse, SpecialEnums @@ -391,6 +392,7 @@ def make_runner( llm_router=cast("Router", router), prisma_client=cast("PrismaClient", prisma), managed_files=store, + batches=ManagedBatchRepository(prisma), proxy_logging_obj=MagicMock(spec=ProxyLogging), general_settings=general_settings, concurrency=concurrency, @@ -1047,6 +1049,33 @@ async def test_batch_expires_at_the_completion_window_and_keeps_what_finished() assert error["code"] == "batch_expired" +async def test_a_provider_timeout_fails_its_row_without_expiring_the_batch() -> None: + harness = make_runner() + reply = chat_response("hi 2") + + def dispatch(messages: Sequence[Mapping[str, str]], **_: object) -> ModelResponse: + if messages[0]["content"] == "hi 1": + raise asyncio.TimeoutError("the provider took too long") + return reply + + harness.router.acompletion.side_effect = dispatch + _, finished = await harness.create_and_finish() + + assert finished.status == "completed" + assert finished.expired_at is None + assert finished.request_counts == BatchRequestCounts(completed=1, failed=1, total=2) + assert set(harness.uploads.calls[0].lines()) == {"row-2"} + error_lines = harness.uploads.calls[1].lines() + assert set(error_lines) == {"row-1"} + assert error_lines["row-1"]["error"] is None + response = error_lines["row-1"]["response"] + assert isinstance(response, dict) + assert response["status_code"] == 500 + assert response["body"] == { + "error": {"message": "the provider took too long", "type": "TimeoutError", "param": None, "code": None} + } + + async def test_batch_created_past_its_window_dispatches_nothing() -> None: harness = make_runner(completion_window_seconds=0) _, finished = await harness.create_and_finish() From 47d06d9fdd5973ea2e72daec07b1a38bae24b2bd Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:56:02 -0700 Subject: [PATCH 46/73] test(unified_google_tests): use the Vertex global endpoint and retry 429s with backoff The google_generate_content_endpoint_testing job went red on main when us-central1 ran out of shared gemini-2.5-flash-lite capacity for a few hours. The suite's proxy config now sends the Vertex deployment to the global endpoint and retries rate limit errors 5 times with exponential backoff, and a regression test pins that the config rides out 3 consecutive 429s --- .../google_genai_proxy_test_config.yaml | 5 ++ .../test_google_genai_proxy_test_config.py | 67 +++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 tests/unified_google_tests/test_google_genai_proxy_test_config.py diff --git a/tests/unified_google_tests/google_genai_proxy_test_config.yaml b/tests/unified_google_tests/google_genai_proxy_test_config.yaml index 9913c05d434..64a83ef3d81 100644 --- a/tests/unified_google_tests/google_genai_proxy_test_config.yaml +++ b/tests/unified_google_tests/google_genai_proxy_test_config.yaml @@ -7,6 +7,11 @@ model_list: - model_name: vertex-gemini-2.5-flash-lite litellm_params: model: vertex_ai/gemini-2.5-flash-lite + vertex_location: global + +router_settings: + retry_policy: + RateLimitErrorRetries: 5 general_settings: master_key: sk-1234 diff --git a/tests/unified_google_tests/test_google_genai_proxy_test_config.py b/tests/unified_google_tests/test_google_genai_proxy_test_config.py new file mode 100644 index 00000000000..d84eefb406b --- /dev/null +++ b/tests/unified_google_tests/test_google_genai_proxy_test_config.py @@ -0,0 +1,67 @@ +import time +from pathlib import Path +from typing import Final, ReadOnly, TypedDict + +import httpx +import pytest +import respx +import yaml +from pydantic import TypeAdapter + +import litellm +from litellm import Router + +CONFIG_PATH: Final = Path(__file__).parent / "google_genai_proxy_test_config.yaml" +GEMINI_HOST: Final = "generativelanguage.googleapis.com" +GEMINI_GENERATE_CONTENT_PATH: Final = "/v1beta/models/gemini-2.5-flash-lite:generateContent" +RESOURCE_EXHAUSTED: Final = { + "error": {"code": 429, "message": "Resource exhausted. Please try again later.", "status": "RESOURCE_EXHAUSTED"} +} +PONG: Final = { + "candidates": [{"content": {"role": "model", "parts": [{"text": "pong"}]}, "finishReason": "STOP"}], + "usageMetadata": {"promptTokenCount": 8, "candidatesTokenCount": 1, "totalTokenCount": 9}, +} +CONSECUTIVE_RATE_LIMITS: Final = 3 +MINIMUM_BACKOFF_SECONDS: Final = 0.5 + 1.0 + 2.0 + + +class _Deployment(TypedDict): + model_name: ReadOnly[str] + litellm_params: ReadOnly[dict[str, str]] + + +class _ProxyConfig(TypedDict): + model_list: ReadOnly[list[_Deployment]] + router_settings: ReadOnly[dict[str, dict[str, int]]] + + +def _router_from_ci_proxy_config() -> Router: + config: Final = TypeAdapter(_ProxyConfig).validate_python(yaml.safe_load(CONFIG_PATH.read_text())) + gemini_deployments: Final = [ + {"model_name": deployment["model_name"], "litellm_params": {**deployment["litellm_params"], "api_key": "test"}} + for deployment in config["model_list"] + if deployment["model_name"] == "gemini-2.5-flash-lite" + ] + return Router(model_list=gemini_deployments, retry_policy=config["router_settings"]["retry_policy"]) + + +@pytest.mark.asyncio +async def test_ci_proxy_config_rides_out_consecutive_429s_with_backoff( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + litellm.in_memory_llm_clients_cache.flush_cache() + route: Final = respx_mock.post(host=GEMINI_HOST, path=GEMINI_GENERATE_CONTENT_PATH).mock( + side_effect=[httpx.Response(429, json=RESOURCE_EXHAUSTED)] * CONSECUTIVE_RATE_LIMITS + + [httpx.Response(200, json=PONG)] + ) + started: Final = time.monotonic() + response: Final = await _router_from_ci_proxy_config().agenerate_content( + model="gemini-2.5-flash-lite", + contents=[{"role": "user", "parts": [{"text": "Reply with only the single word: pong"}]}], + ) + elapsed: Final = time.monotonic() - started + + assert response.model_dump()["candidates"][0]["content"]["parts"][0]["text"] == "pong" + assert route.call_count == CONSECUTIVE_RATE_LIMITS + 1 + assert elapsed >= MINIMUM_BACKOFF_SECONDS From 162d6225e065c771d0c30876daf76732df3f4d5f Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Sat, 19 Sep 2026 12:06:35 -0700 Subject: [PATCH 47/73] fix(proxy): block project requests when max_budget is 0 A project max_budget of 0 was treated as unbudgeted by #41354, while key budgets block at 0 and null is the unlimited value. Drop the <= 0 skip so 0 blocks and null stays unlimited --- litellm/proxy/auth/auth_checks.py | 2 +- tests/test_litellm/proxy/auth/test_auth_checks.py | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 61d2fa572a1..fbcb35d66c9 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -5680,7 +5680,7 @@ async def _project_max_budget_check( if project_object.litellm_budget_table is not None: max_budget = project_object.litellm_budget_table.max_budget - if max_budget is None or max_budget <= 0 or not math.isfinite(max_budget): + if max_budget is None or not math.isfinite(max_budget): return from litellm.proxy.proxy_server import get_current_spend diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index 1ae986db23b..0a6f6d8e69b 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -7572,7 +7572,7 @@ async def test_project_allowlist_enforced_when_key_models_empty(): assert exc_info.value.code == "403" -def _project_with_budget(spend: float, max_budget: float): +def _project_with_budget(spend: float, max_budget: float | None): from litellm.proxy._types import LiteLLM_BudgetTable, LiteLLM_ProjectTableCachedObj return LiteLLM_ProjectTableCachedObj( @@ -7592,11 +7592,12 @@ def _project_with_budget(spend: float, max_budget: float): pytest.param(4.99, 0.0, 5.0, False, id="counter-under-budget-admits"), pytest.param(None, 5.0, 5.0, True, id="no-counter-falls-back-to-persisted-spend"), pytest.param(None, 0.0, 5.0, False, id="no-counter-and-no-persisted-spend-admits"), - pytest.param(12.5, 12.5, 0.0, False, id="zero-budget-is-unbudgeted"), - pytest.param(12.5, 12.5, -1.0, False, id="negative-budget-is-unbudgeted"), + pytest.param(None, 0.0, 0.0, True, id="zero-budget-blocks-before-any-spend"), + pytest.param(12.5, 12.5, 0.0, True, id="zero-budget-blocks-with-spend"), + pytest.param(12.5, 12.5, None, False, id="null-budget-is-unlimited"), ], ) -async def test_project_max_budget_check_blocks_only_when_live_spend_reaches_a_positive_budget( +async def test_project_max_budget_check_blocks_when_live_spend_reaches_the_budget( counter_spend, db_spend, max_budget, blocks ): from litellm.caching.dual_cache import DualCache @@ -7631,7 +7632,7 @@ async def test_project_max_budget_check_blocks_only_when_live_spend_reaches_a_po assert exc_info.value.entity_type == Litellm_EntityType.PROJECT.value assert exc_info.value.entity_id == "p-budget" - assert exc_info.value.current_cost == 5.0 + assert exc_info.value.current_cost == (db_spend if counter_spend is None else counter_spend) proxy_logging_obj.budget_alerts.assert_awaited_once() assert proxy_logging_obj.budget_alerts.await_args.kwargs["type"] == "project_budget" From 8f8c2e2fda909b65e41cbcf836f9cca00a306a10 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 19 Sep 2026 19:13:44 +0000 Subject: [PATCH 48/73] ci(e2e): keep the Linear OAuth chat test out of the stage-mirror selector Co-Authored-By: bot_apk --- .github/e2e-stack/select_tests.py | 1 + tests/e2e/CONTRIBUTING.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/e2e-stack/select_tests.py b/.github/e2e-stack/select_tests.py index a9ca1f88660..183a4208286 100644 --- a/.github/e2e-stack/select_tests.py +++ b/.github/e2e-stack/select_tests.py @@ -6,6 +6,7 @@ SELECTABLE: Final = re.compile(r"^tests/e2e/([A-Za-z0-9_.-]+/)*test_[A-Za-z0-9_. UNSUPPORTED: Final = re.compile( r"^tests/e2e/(ui|claude_code|load)/" r"|^tests/e2e/mcp/test_mcp_oauth_happy_path_e2e\.py$" + r"|^tests/e2e/mcp/test_mcp_chat_completion_oauth_e2e\.py$" r"|^tests/e2e/llm_translation/realtime/test_realtime_pipecat_audio_e2e\.py$" r"|^tests/e2e/batches/test_managed_files_enforcement_e2e\.py$" r"|^tests/e2e/guardrails/test_presidio_masking_e2e\.py$" diff --git a/tests/e2e/CONTRIBUTING.md b/tests/e2e/CONTRIBUTING.md index 2adac08329f..99304c50e58 100644 --- a/tests/e2e/CONTRIBUTING.md +++ b/tests/e2e/CONTRIBUTING.md @@ -105,7 +105,7 @@ A couple of logging destinations are configured on the proxy rather than by the ### The pull request check -Every same-repository PR that adds, modifies, or renames a `tests/e2e/**/test_*.py` file runs those changed files three times. A change to the harness itself, meaning a root-level `tests/e2e/*.py` file or `pytest.ini`, `tests/e2e/gateway/`, `.github/e2e-stack/`, or the workflow, also runs the `access_control` suite and both JWT suites as canaries, because those files have no test of their own that exercises the stack. `.github/e2e-stack/select_tests.py` applies both rules. The stack config at `tests/e2e/gateway/stage_mirror_ci_config.yml` must declare every model the selected suites use; a missing one shows up as a failed test id in the public log. The suite's own single rerun for network errors and 5xx responses (see `pytest.ini`) applies on every pass, so a transport blip does not fail the check while a race inside a test still does. The stage-mirror stack has a control-plane backend, two gateways behind nginx, Postgres, Keycloak, Jaeger, and TLS cluster-mode Valkey. Realm-only edits also trigger these canaries. The stack exports every gateway address in `LITELLM_PROXY_REPLICA_URLS`, so model registration waits until each gateway lists the new model rather than whichever one the load balancer answered from. Documentation, deleted-file, and application-only changes do not start the stack or request environment approval. The `ui/`, `claude_code/`, and `load/` directories, `batches/test_managed_files_enforcement_e2e.py`, `llm_translation/realtime/test_realtime_pipecat_audio_e2e.py`, and `guardrails/test_presidio_masking_e2e.py` remain outside this check because they use separate tooling or need a differently configured stack: the pipecat audio suite skips itself at import time unless the NLTK `punkt_tab` data is installed, and the presidio suite fails without the analyzer and anonymizer services this stack does not start. The Redis chaos test under `load/` needs a proxy it can pause the Redis of on the same host (`gateway/redis_chaos_ci_config.yml`), which `.github/workflows/test-e2e-redis-chaos.yml` boots, and which the Buildkite `e2e-redis-chaos` step in project-releaser runs co-located with Postgres and Valkey in one pod; it is deselected unless `E2E_REDIS_CHAOS` is set +Every same-repository PR that adds, modifies, or renames a `tests/e2e/**/test_*.py` file runs those changed files three times. A change to the harness itself, meaning a root-level `tests/e2e/*.py` file or `pytest.ini`, `tests/e2e/gateway/`, `.github/e2e-stack/`, or the workflow, also runs the `access_control` suite and both JWT suites as canaries, because those files have no test of their own that exercises the stack. `.github/e2e-stack/select_tests.py` applies both rules. The stack config at `tests/e2e/gateway/stage_mirror_ci_config.yml` must declare every model the selected suites use; a missing one shows up as a failed test id in the public log. The suite's own single rerun for network errors and 5xx responses (see `pytest.ini`) applies on every pass, so a transport blip does not fail the check while a race inside a test still does. The stage-mirror stack has a control-plane backend, two gateways behind nginx, Postgres, Keycloak, Jaeger, and TLS cluster-mode Valkey. Realm-only edits also trigger these canaries. The stack exports every gateway address in `LITELLM_PROXY_REPLICA_URLS`, so model registration waits until each gateway lists the new model rather than whichever one the load balancer answered from. Documentation, deleted-file, and application-only changes do not start the stack or request environment approval. The `ui/`, `claude_code/`, and `load/` directories, `batches/test_managed_files_enforcement_e2e.py`, `llm_translation/realtime/test_realtime_pipecat_audio_e2e.py`, `guardrails/test_presidio_masking_e2e.py`, `mcp/test_mcp_chat_completion_oauth_e2e.py`, and `mcp/test_mcp_oauth_happy_path_e2e.py` remain outside this check because they use separate tooling or need a differently configured stack: the pipecat audio suite skips itself at import time unless the NLTK `punkt_tab` data is installed, and the presidio suite fails without the analyzer and anonymizer services this stack does not start. The Redis chaos test under `load/` needs a proxy it can pause the Redis of on the same host (`gateway/redis_chaos_ci_config.yml`), which `.github/workflows/test-e2e-redis-chaos.yml` boots, and which the Buildkite `e2e-redis-chaos` step in project-releaser runs co-located with Postgres and Valkey in one pod; it is deselected unless `E2E_REDIS_CHAOS` is set. The two MCP OAuth suites need a saved Linear browser session (`E2E_LINEAR_STORAGE_STATE`) that the stage-mirror stack does not have, and the happy path runs in its own dispatch workflow `.github/workflows/test-mcp-oauth-e2e.yml` Every selected file must execute at least one passing test in each pass, and any test failure, collection error, or entirely skipped or deselected file fails the check. A file whose tests are all marked skip therefore cannot pass this check, so unskip at least one of them, or add the file to `UNSUPPORTED` in `select_tests.py` with the reason, before changing one. A failed pass stops the run. The public log prints pytest's one-line summary for each pass, including the rerun count, and names each failed or errored test as `classname::name`, so a retried network error or a failing test is visible without the raw output. The final `e2e-changed-tests` job succeeds only when no supported test files changed or the approved run completed all three passes. Fork PRs with selected tests fail this gate until a maintainer brings the reviewed change onto a same-repository branch From e0b6bae5167b9fc2b716ece7116257bca8189b3b Mon Sep 17 00:00:00 2001 From: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 12:14:32 -0700 Subject: [PATCH 49/73] test(mcp): cover scoped execution and OAuth credential isolation --- tests/integration/_support/mcp.py | 19 ++-- tests/integration/contracts.json | 9 ++ tests/integration/mcp/README.md | 28 ++++++ tests/integration/mcp/test_mcp_lifecycle.py | 45 ++++++++++ .../mcp/test_oauth_configuration.py | 87 ++++++++++++++++++- tests/mcp_tests/mcp_e2e_upstream_server.py | 18 ++-- 6 files changed, 184 insertions(+), 22 deletions(-) create mode 100644 tests/integration/mcp/README.md diff --git a/tests/integration/_support/mcp.py b/tests/integration/_support/mcp.py index d924ee6dad0..bdf60becbaa 100644 --- a/tests/integration/_support/mcp.py +++ b/tests/integration/_support/mcp.py @@ -9,7 +9,7 @@ import httpx from integration._support.asgi import asgi_server from integration._support.client import Gateway, Scenario from integration._support.database import read_rows -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer from mcp.server.transport_security import TransportSecuritySettings from mcp_tests.mcp_e2e_upstream_server import add, multiply from starlette.requests import Request @@ -27,12 +27,7 @@ class McpPeer: @contextmanager def mcp_peer() -> Iterator[McpPeer]: - service: Final = FastMCP( - "integration-math", - stateless_http=True, - json_response=True, - transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False), - ) + service: Final = MCPServer("integration-math") service.add_tool(add) service.add_tool(multiply) @@ -40,7 +35,11 @@ def mcp_peer() -> Iterator[McpPeer]: def fail() -> str: raise ValueError("synthetic tool failure") - app: Final = service.streamable_http_app() + app: Final = service.streamable_http_app( + stateless_http=True, + json_response=True, + transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False), + ) observed: Final[queue.Queue[dict[str, object]]] = queue.Queue() async def capture(scope: Scope, receive: Receive, send: Send) -> None: @@ -94,9 +93,7 @@ def tool_names(gateway: Gateway, key: str, identity: str) -> dict[str, str]: } -def call_tool( - gateway: Gateway, key: str, identity: str, name: str, arguments: dict[str, object] -) -> httpx.Response: +def call_tool(gateway: Gateway, key: str, identity: str, name: str, arguments: dict[str, object]) -> httpx.Response: return gateway.client.post( "/mcp-rest/tools/call", headers={"x-litellm-api-key": key}, diff --git a/tests/integration/contracts.json b/tests/integration/contracts.json index 7472cde99d3..b370b577c9b 100644 --- a/tests/integration/contracts.json +++ b/tests/integration/contracts.json @@ -1320,6 +1320,15 @@ ], "tests/integration/observability/test_guardrail_effects.py::test_request_selected_mcp_guardrail_blocks_direct_and_virtual_calls": [ "other.mcp.guardrails.request_selection_blocks_resolved_tool_without_execution" + ], + "tests/integration/mcp/test_mcp_lifecycle.py::test_same_url_server_grants_scope_discovery_and_direct_or_virtual_execution": [ + "other.mcp.permissions.same_url_servers_enforce_discovery_and_execution" + ], + "tests/integration/mcp/test_oauth_configuration.py::test_same_url_oauth_credentials_and_revocation_are_isolated_by_user_and_server[revoke]": [ + "other.mcp.oauth.same_url_credentials_are_isolated_by_user_and_server" + ], + "tests/integration/mcp/test_oauth_configuration.py::test_same_url_oauth_credentials_and_revocation_are_isolated_by_user_and_server[expire]": [ + "other.mcp.oauth.same_url_credentials_are_isolated_by_user_and_server" ] }, "browser": { diff --git a/tests/integration/mcp/README.md b/tests/integration/mcp/README.md new file mode 100644 index 00000000000..870176e8196 --- /dev/null +++ b/tests/integration/mcp/README.md @@ -0,0 +1,28 @@ +# MCP security regression coverage + +[LIT-4506](https://linear.app/litellm-ai/issue/LIT-4506) tracks ten gateway guards and the later JWT/OAuth acceptance. This inventory distinguishes executable assertions from unresolved coverage. A listed test counts as verified only when its exact commit has an executed, passing result + +Run the controlled gateway cases through `python tests/integration/run.py extensions`. They use real HTTP, PostgreSQL, scoped non-master keys and an SDK upstream. The existing runner supplies test entitlement; these tests do not validate licenses or external-provider consent. Canonical nodes and contract IDs live in `../contracts.json` + +| Requested guard | Existing or added coverage | Remaining limitation and owner | +| --- | --- | --- | +| 1. Discovery scoped by org/team/user/key | `test_mcp_lifecycle.py` checks the exact key-granted catalog and health visibility in both management modes. [PR #38680](https://github.com/BerriAI/litellm/pull/38680) adds team/org/user toolset E2E assertions | Per-principal native MCP coverage is not established by REST results; reuse #38680 rather than duplicate it | +| 2. Users cannot attach unauthorized servers to their own keys | Existing live probes are recorded on LIT-4506; they are not durable endpoint regression tests | Own-key create/update escalation and its permission-validator boundary remain on existing management security tickets, including [LIT-4502](https://linear.app/litellm-ai/issue/LIT-4502). A generic route denial does not prove that validator ran | +| 3. UI/API permission parity | Existing dashboard tests cover admin operations | The same non-admin actor must be tested through browser and API; admin UI tests do not establish parity. Retained with [LIT-3644](https://linear.app/litellm-ai/issue/LIT-3644) | +| 4. Server ID determines identity | `test_mcp_lifecycle.py` grants one of two servers sharing a URL and denies explicit calls to the other, through direct and virtual REST execution | Duplicate aliases/names and unprefixed protocol routing remain with [LIT-4500](https://linear.app/litellm-ai/issue/LIT-4500) | +| 5. Same-URL servers do not share credentials | `test_oauth_configuration.py` crosses two gateway users with two server IDs and four distinct stored OAuth tokens. It checks actual upstream headers and successful results, then invalidates only one tuple | Controlled stored-token tests do not prove separate external-provider accounts or consent flows | +| 6. OAuth never falls back to anonymous | OAuth isolation variants remove a stored token or expire it without refresh, require separate list/call auth failures and no upstream requests, and preserve all other valid tuples. `test_mcp_lifecycle.py` also covers warm static-header removal and OBO without a caller JWT | External upstream revocation, refresh/reauthorization and aggregate challenges remain with [LIT-4501](https://linear.app/litellm-ai/issue/LIT-4501), [LIT-3433](https://linear.app/litellm-ai/issue/LIT-3433), [LIT-4422](https://linear.app/litellm-ai/issue/LIT-4422) and [LIT-4436](https://linear.app/litellm-ai/issue/LIT-4436) | +| 7. Stateful HTTP/session continuity | Legacy public-client tests exercise initialized sessions | No claim here proves upstream session state continuity; retained under [LIT-3143](https://linear.app/litellm-ai/issue/LIT-3143) | +| 8. Production guardrails/hooks run | `../observability/test_guardrail_effects.py` checks selected pre-call guards on direct and virtual execution, key/team/request selection, allowed results and zero denied executions. The two legacy test-owned dispatcher files are removed | This does not establish every post-call/output-scanning or concurrent hook contract | +| 9. Permissions enforced at discovery and execution | Exact key catalog plus forbidden direct/virtual calls in `test_mcp_lifecycle.py`; existing `../compatibility/test_persisted_toolsets.py` checks tool-level ceiling, denied sibling and allowed control | All principal/transport combinations are not established; link #38680's evidence for its additional principal cases | +| 10. Stateless/stateful matrix | These controlled peers use stateless HTTP upstreams | Stateful combinations depend on LIT-3143 and shared conformance runs. Modern-agent/legacy-upstream interaction remains deferred; legacy passes do not establish modern conformance | + +## Additional JWT/OAuth acceptance + +[LIT-3467 / PR #41909](https://github.com/BerriAI/litellm/pull/41909) owns one shared real login/consent, immediate list/call and cold-restart implementation, with aggregate SSO and explicitly configured per-server JWT variants. Reuse that implementation and its protected login secret; do not create another browser bootstrap here. Credit its exact-commit evidence separately from these controlled credential tests + +The two-user/two-server cases here create non-admin users and scoped API keys through management APIs. They store synthetic upstream OAuth credentials through the real credential endpoint and assert the actual bearer at the owned upstream. This deliberately isolates credential lookup, expiry and revocation from consent. No gateway API key may replace the expected upstream token + +Gateway JWT precedence, invalid/expired gateway JWTs, inactive-user denial, and their MCP-specific interaction with isolated credential lookup remain unverified by these API-key cases. General JWT unit/API tests are useful existing coverage but do not substitute for those MCP outcomes. Real-provider auth failures should extend LIT-3467's settled helpers; its explicit-header case must not be described as an uninterrupted Authorization-only OAuth flow + +[PR #41718 / LIT-7737](https://github.com/BerriAI/litellm/pull/41718) owns dependency and public-client compatibility checks. This suite consumes the merged SDK2 API and keeps the existing dependency constraints. Its result must be reported independently of an installation-matrix pass diff --git a/tests/integration/mcp/test_mcp_lifecycle.py b/tests/integration/mcp/test_mcp_lifecycle.py index fa0ae0ec643..0e29959bac7 100644 --- a/tests/integration/mcp/test_mcp_lifecycle.py +++ b/tests/integration/mcp/test_mcp_lifecycle.py @@ -221,3 +221,48 @@ def test_warm_credential_removal_rejects_without_upstream_traffic(gateway: Gatew control_names = tool_names(gateway, control_key, control_id) control = call_tool(gateway, control_key, control_id, control_names["multiply"], {"a": 3, "b": 5}) assert control.status_code == 200 and control.json()["content"][0]["text"] == "15", control.text + + +@pytest.mark.covers("other.mcp.permissions.same_url_servers_enforce_discovery_and_execution") +def test_same_url_server_grants_scope_discovery_and_direct_or_virtual_execution(gateway: Gateway) -> None: + with mcp_peer() as peer, gateway.scenario() as scenario: + allowed: Final = register_mcp(scenario, peer, "allowed" + uuid.uuid4().hex) + forbidden: Final = register_mcp(scenario, peer, "forbidden" + uuid.uuid4().hex) + caller: Final = scenario.key(object_permission={"mcp_servers": [allowed], "mcp_tool_search_enabled": True}) + control: Final = scenario.key(object_permission={"mcp_servers": [forbidden], "mcp_tool_search_enabled": True}) + allowed_names: Final = tool_names(gateway, caller, allowed) + forbidden_names: Final = tool_names(gateway, control, forbidden) + catalog: Final = gateway.request("GET", "/mcp-rest/tools/list", key=caller) + assert catalog.status_code == 200, catalog.text + assert {tool["mcp_info"]["server_id"] for tool in catalog.json()["tools"]} == {allowed} + assert {tool["name"] for tool in catalog.json()["tools"]} == set(allowed_names.values()) + for virtual in (False, True): + for server_id, names, key, expected in ( + (allowed, allowed_names, caller, 200), + (forbidden, forbidden_names, caller, 403), + (forbidden, forbidden_names, control, 200), + ): + peer.drain() + response: Final = gateway.request( + "POST", + "/mcp-rest/tools/call", + { + "server_id": server_id, + "name": "mcp_tool_call" if virtual else names["add"], + "arguments": ( + {"tool_name": names["add"], "arguments": {"a": 3, "b": 5}} if virtual else {"a": 3, "b": 5} + ), + }, + key=key, + ) + assert response.status_code == expected, response.text + calls: Final = tuple(item for item in peer.drain() if item["body"].get("method") == "tools/call") + if expected == 403: + assert "access" in response.text.lower(), response.text + assert calls == (), "a denied server must not execute through either route" + else: + assert response.json()["isError"] is False, response.text + assert response.json()["content"][0]["text"] == "8", response.text + assert len(calls) == 1 + assert calls[0]["body"]["params"]["name"] == "add" + assert calls[0]["body"]["params"]["arguments"] == {"a": 3, "b": 5} diff --git a/tests/integration/mcp/test_oauth_configuration.py b/tests/integration/mcp/test_oauth_configuration.py index 45d407f2423..fbef9e8fed9 100644 --- a/tests/integration/mcp/test_oauth_configuration.py +++ b/tests/integration/mcp/test_oauth_configuration.py @@ -2,14 +2,14 @@ import json import queue import uuid from urllib.parse import parse_qs, urlsplit -from typing import Final +from typing import Final, Literal from pathlib import Path import pytest from integration._support.client import Gateway, eventually from integration._support.database import read_rows -from integration._support.mcp import McpPeer, register_mcp +from integration._support.mcp import McpPeer, call_tool, mcp_peer, register_mcp, tool_names from integration._support.process import owned_proxy from integration._support.wire import Reply, Request, wire_server @@ -102,3 +102,86 @@ def test_partial_discovery_and_unrelated_edit_keep_actual_authorization_destinat "PUT", "/v1/mcp/server", {"server_id": identity, "server_name": alias + "renamed"} ) assert updated.status_code == 202, updated.text + + +@pytest.mark.covers("other.mcp.oauth.same_url_credentials_are_isolated_by_user_and_server") +@pytest.mark.parametrize("transition", ("revoke", "expire")) +def test_same_url_oauth_credentials_and_revocation_are_isolated_by_user_and_server( + gateway: Gateway, + transition: Literal["revoke", "expire"], +) -> None: + with mcp_peer() as peer, gateway.scenario() as scenario: + servers: Final = tuple( + register_mcp( + scenario, + peer, + "oauth" + uuid.uuid4().hex, + auth_type="oauth2", + oauth2_flow="authorization_code", + authorization_url=peer.url + "/authorize", + token_url=peer.url + "/token", + credentials={"client_id": "synthetic-oauth-client"}, + ) + for _ in range(2) + ) + users: Final = tuple(scenario.user(user_role="internal_user") for _ in range(2)) + keys: Final = tuple( + scenario.key(user_id=user, object_permission={"mcp_servers": list(servers)}) for user in users + ) + for user_index, key in enumerate(keys): + for server_index, server_id in enumerate(servers): + stored: Final = gateway.request( + "POST", + f"/v1/mcp/server/{server_id}/oauth-user-credential", + {"access_token": f"synthetic-user-{user_index}-server-{server_index}", "expires_in": 3600}, + key=key, + ) + assert stored.status_code == 200 and stored.json()["has_credential"] is True, stored.text + scenario.cleanups.callback( + gateway.request, + "DELETE", + f"/v1/mcp/server/{server_id}/oauth-user-credential", + key=key, + ) + names: Final = tuple(tool_names(gateway, keys[0], server) for server in servers) + for generation in range(2): + for user_index, key in enumerate(keys): + for server_index, server_id in enumerate(servers): + peer.drain() + discovery: Final = gateway.request( + "GET", + "/mcp-rest/tools/list", + key=key, + params={"server_id": server_id}, + ) + call: Final = call_tool(gateway, key, server_id, names[server_index]["add"], {"a": 3, "b": 5}) + observed: Final = peer.drain() + if generation == 1 and user_index == 0 and server_index == 0: + for rejected in (discovery, call): + assert rejected.status_code == 401, rejected.text + assert "uthorization required" in rejected.text, rejected.text + assert observed == (), "unusable credentials must not fall back to another user or server" + else: + assert discovery.status_code == 200, discovery.text + assert {tool["name"] for tool in discovery.json()["tools"]} == set(names[server_index].values()) + assert call.status_code == 200 and call.json()["isError"] is False, call.text + assert call.json()["content"][0]["text"] == "8", call.text + calls: Final = tuple(item for item in observed if item["body"].get("method") == "tools/call") + assert len(calls) == 1 + expected: Final = f"Bearer synthetic-user-{user_index}-server-{server_index}".encode() + assert calls[0]["headers"][b"authorization"] == expected + assert all(item["headers"].get(b"authorization") == expected for item in observed) + if generation == 0: + changed: Final = gateway.request( + "DELETE" if transition == "revoke" else "POST", + f"/v1/mcp/server/{servers[0]}/oauth-user-credential", + None + if transition == "revoke" + else { + "access_token": "synthetic-expired-user-0-server-0", + "expires_in": -60, + }, + key=keys[0], + ) + assert changed.status_code == 200, changed.text + assert changed.json()["has_credential"] is (transition == "expire"), changed.text diff --git a/tests/mcp_tests/mcp_e2e_upstream_server.py b/tests/mcp_tests/mcp_e2e_upstream_server.py index 28fb0846481..3361163badf 100644 --- a/tests/mcp_tests/mcp_e2e_upstream_server.py +++ b/tests/mcp_tests/mcp_e2e_upstream_server.py @@ -1,6 +1,6 @@ """Deterministic upstream MCP server for the mcp e2e suite. -A tiny FastMCP server exposing `add` and `multiply` over streamable-http so the +A tiny MCP server exposing `add` and `multiply` over streamable-http so the suite has a self-hosted, offline upstream to register and exercise. DNS-rebinding protection is turned off because the litellm container reaches this over the compose network by service name (`mcp-upstream:8090`), not localhost, and the @@ -9,15 +9,10 @@ stack is an isolated throwaway. Bind host/port come from MCP_HOST/MCP_PORT. import os -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer from mcp.server.transport_security import TransportSecuritySettings -mcp: FastMCP = FastMCP( - "e2e-math", - host=os.getenv("MCP_HOST", "0.0.0.0"), - port=int(os.getenv("MCP_PORT", "8090")), - transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False), -) +mcp: MCPServer = MCPServer("e2e-math") @mcp.tool() @@ -33,7 +28,12 @@ def multiply(a: int, b: int) -> int: def main() -> None: - mcp.run(transport="streamable-http") + mcp.run( + transport="streamable-http", + host=os.getenv("MCP_HOST", "0.0.0.0"), + port=int(os.getenv("MCP_PORT", "8090")), + transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False), + ) if __name__ == "__main__": From a7870a902a281e61a4842dbc4bd079fd621a21cf Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 12:15:34 -0700 Subject: [PATCH 50/73] test(unified_google_tests): import ReadOnly from typing_extensions and cover the Vertex global endpoint The first commit imported ReadOnly from typing, which only exists on Python 3.13 and up. CircleCI runs this suite on 3.12, so the module failed at import and the job stopped at collection before any of its tests ran. ReadOnly and TypedDict now come from typing_extensions, like the rest of the repo A new test resolves the Vertex deployment's location from the suite's config with VERTEXAI_LOCATION set to a region, and fails if the vertex_location line is removed The expected minimum backoff is now derived from litellm's INITIAL_RETRY_DELAY and MAX_RETRY_DELAY, so the test holds when those are overridden through the environment --- .../test_google_genai_proxy_test_config.py | 50 +++++++++++++++---- 1 file changed, 40 insertions(+), 10 deletions(-) diff --git a/tests/unified_google_tests/test_google_genai_proxy_test_config.py b/tests/unified_google_tests/test_google_genai_proxy_test_config.py index d84eefb406b..694ec336bac 100644 --- a/tests/unified_google_tests/test_google_genai_proxy_test_config.py +++ b/tests/unified_google_tests/test_google_genai_proxy_test_config.py @@ -1,19 +1,26 @@ import time from pathlib import Path -from typing import Final, ReadOnly, TypedDict +from typing import Final import httpx import pytest import respx import yaml from pydantic import TypeAdapter +from typing_extensions import ReadOnly, TypedDict import litellm from litellm import Router +from litellm.constants import INITIAL_RETRY_DELAY, MAX_RETRY_DELAY +from litellm.llms.vertex_ai.common_utils import get_vertex_base_url +from litellm.llms.vertex_ai.vertex_llm_base import VertexBase CONFIG_PATH: Final = Path(__file__).parent / "google_genai_proxy_test_config.yaml" +GEMINI_DEPLOYMENT: Final = "gemini-2.5-flash-lite" +VERTEX_DEPLOYMENT: Final = "vertex-gemini-2.5-flash-lite" GEMINI_HOST: Final = "generativelanguage.googleapis.com" GEMINI_GENERATE_CONTENT_PATH: Final = "/v1beta/models/gemini-2.5-flash-lite:generateContent" +VERTEX_GLOBAL_BASE_URL: Final = "https://aiplatform.googleapis.com" RESOURCE_EXHAUSTED: Final = { "error": {"code": 429, "message": "Resource exhausted. Please try again later.", "status": "RESOURCE_EXHAUSTED"} } @@ -22,7 +29,9 @@ PONG: Final = { "usageMetadata": {"promptTokenCount": 8, "candidatesTokenCount": 1, "totalTokenCount": 9}, } CONSECUTIVE_RATE_LIMITS: Final = 3 -MINIMUM_BACKOFF_SECONDS: Final = 0.5 + 1.0 + 2.0 +MINIMUM_BACKOFF_SECONDS: Final = sum( + min(INITIAL_RETRY_DELAY * 2**attempt, MAX_RETRY_DELAY) for attempt in range(CONSECUTIVE_RATE_LIMITS) +) class _Deployment(TypedDict): @@ -35,14 +44,35 @@ class _ProxyConfig(TypedDict): router_settings: ReadOnly[dict[str, dict[str, int]]] +def _ci_proxy_config() -> _ProxyConfig: + return TypeAdapter(_ProxyConfig).validate_python(yaml.safe_load(CONFIG_PATH.read_text())) + + +def _litellm_params(config: _ProxyConfig, model_name: str) -> dict[str, str]: + return next( + deployment["litellm_params"] for deployment in config["model_list"] if deployment["model_name"] == model_name + ) + + def _router_from_ci_proxy_config() -> Router: - config: Final = TypeAdapter(_ProxyConfig).validate_python(yaml.safe_load(CONFIG_PATH.read_text())) - gemini_deployments: Final = [ - {"model_name": deployment["model_name"], "litellm_params": {**deployment["litellm_params"], "api_key": "test"}} - for deployment in config["model_list"] - if deployment["model_name"] == "gemini-2.5-flash-lite" - ] - return Router(model_list=gemini_deployments, retry_policy=config["router_settings"]["retry_policy"]) + config: Final = _ci_proxy_config() + return Router( + model_list=[ + { + "model_name": GEMINI_DEPLOYMENT, + "litellm_params": {**_litellm_params(config, GEMINI_DEPLOYMENT), "api_key": "test"}, + } + ], + retry_policy=config["router_settings"]["retry_policy"], + ) + + +def test_ci_proxy_config_sends_vertex_calls_to_the_global_endpoint(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("VERTEXAI_LOCATION", "us-east5") + location: Final = VertexBase.safe_get_vertex_ai_location(_litellm_params(_ci_proxy_config(), VERTEX_DEPLOYMENT)) + + assert location == "global" + assert get_vertex_base_url(location) == VERTEX_GLOBAL_BASE_URL @pytest.mark.asyncio @@ -57,7 +87,7 @@ async def test_ci_proxy_config_rides_out_consecutive_429s_with_backoff( ) started: Final = time.monotonic() response: Final = await _router_from_ci_proxy_config().agenerate_content( - model="gemini-2.5-flash-lite", + model=GEMINI_DEPLOYMENT, contents=[{"role": "user", "parts": [{"text": "Reply with only the single word: pong"}]}], ) elapsed: Final = time.monotonic() - started From 3dff41f3696e7b62207e5e41cac72df9aef80f89 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 19 Sep 2026 12:17:20 -0700 Subject: [PATCH 51/73] fix(proxy): close the config-ownership gaps QA found in the settings store - apply_db_row only clears runtime values for keys the row actually changed, so an env-resolved DB-owned setting survives a reload - DELETE /config/field/delete refuses a key the config file owns instead of silently rewriting the row - GET /config/field/info reports the declared value of a config-owned key, not the env-resolved secret - SettingsStore gains a short-circuiting __bool__ so truthiness checks stop at the first key - _initialize_jwt_auth resolves os.environ refs into a local mapping instead of mutating the shared general_settings dict - rejected_writes compares against the resolved value, matching what __setitem__ accepts - a stored value identical to the config template is no longer reported as shadowed - the enterprise email-settings and coordination-redis writers go through reject_config_owned_writes --- .../send_emails/endpoints.py | 9 ++ .../proxy/config_resolvers/settings_store.py | 21 +++- .../coordination_redis_endpoints.py | 5 + litellm/proxy/proxy_server.py | 31 ++++-- .../send_emails/test_endpoints.py | 71 ++++++++++++++ .../config_resolvers/test_settings_store.py | 90 +++++++++++++++++ .../test_coordination_redis_endpoints.py | 61 ++++++++++++ tests/test_litellm/proxy/test_proxy_server.py | 96 +++++++++++++++++++ 8 files changed, 373 insertions(+), 11 deletions(-) diff --git a/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/endpoints.py b/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/endpoints.py index 61681c27ee9..1ab173a915a 100644 --- a/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/endpoints.py +++ b/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/endpoints.py @@ -60,6 +60,11 @@ async def _get_email_settings(prisma_client) -> Dict[str, bool]: async def _save_email_settings(prisma_client, settings: Dict[str, bool]): """Helper function to save email settings to general_settings in db""" + from litellm.proxy.proxy_server import proxy_config + + proxy_config.reject_config_owned_writes( + section_name="general_settings", changed_keys={"email_settings": settings} + ) try: verbose_proxy_logger.debug( f"Saving email settings to general_settings: {settings}" @@ -168,6 +173,8 @@ async def update_event_settings( await _save_email_settings(prisma_client, settings_dict) return {"message": "Email event settings updated successfully"} + except HTTPException: + raise except Exception as e: verbose_proxy_logger.exception(f"Error updating email settings: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) @@ -197,6 +204,8 @@ async def reset_event_settings( await _save_email_settings(prisma_client, default_settings) return {"message": "Email event settings reset to defaults"} + except HTTPException: + raise except Exception as e: verbose_proxy_logger.exception(f"Error resetting email settings: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) diff --git a/litellm/proxy/config_resolvers/settings_store.py b/litellm/proxy/config_resolvers/settings_store.py index 90f1da76bf6..291000b3b6a 100644 --- a/litellm/proxy/config_resolvers/settings_store.py +++ b/litellm/proxy/config_resolvers/settings_store.py @@ -60,9 +60,7 @@ class SettingsStore(MutableMapping[str, JsonValue]): def rejected_writes(self, incoming: Mapping[str, JsonValue]) -> tuple[str, ...]: return tuple( - sorted( - key for key, value in incoming.items() if self.owned_by_config(key) and value != self._yaml_values[key] - ) + sorted(key for key, value in incoming.items() if self.owned_by_config(key) and value != self.get(key)) ) def shadowed_db_keys(self) -> tuple[str, ...]: @@ -74,8 +72,13 @@ class SettingsStore(MutableMapping[str, JsonValue]): def apply_db_row(self, row: DbRow, db_row: Mapping[str, JsonValue]) -> None: previous_row: Final = self._database_rows.get(row, _EMPTY_VALUES) + changed: Final = frozenset( + key + for key in (*previous_row, *db_row) + if previous_row.get(key, ABSENT) != db_row.get(key, ABSENT) # pyright: ignore[reportUnknownArgumentType] # JsonValue vs Absent compare + ) self._database_rows = MappingProxyType({**self._database_rows, row: MappingProxyType(dict(db_row))}) - self._clear_runtime_keys(frozenset((*previous_row, *db_row))) + self._clear_runtime_keys(changed) def resolved(self) -> Mapping[str, JsonValue]: return MappingProxyType(dict(self)) @@ -130,6 +133,9 @@ class SettingsStore(MutableMapping[str, JsonValue]): def __len__(self) -> int: return sum(1 for _ in self) + def __bool__(self) -> bool: + return any(True for _ in self) + def _clear_runtime(self) -> None: self._runtime_values = _EMPTY_VALUES self._deleted_runtime_keys = frozenset() @@ -160,7 +166,12 @@ class SettingsStore(MutableMapping[str, JsonValue]): def _db_value_is_shadowed(self, key: str) -> bool: db_value: Final = self._db_value(key) - return not isinstance(db_value, Absent) and db_value is not None and db_value != self.get(key) + return ( + not isinstance(db_value, Absent) + and db_value is not None + and db_value != self.get(key) + and db_value != self.config_value(key) + ) def _resolution_for(self, key: str) -> Resolved: yaml_value: Final[SettingValue] = self._yaml_values.get(key, ABSENT) diff --git a/litellm/proxy/management_endpoints/coordination_redis_endpoints.py b/litellm/proxy/management_endpoints/coordination_redis_endpoints.py index 8e64e1ea651..c59ee92f073 100644 --- a/litellm/proxy/management_endpoints/coordination_redis_endpoints.py +++ b/litellm/proxy/management_endpoints/coordination_redis_endpoints.py @@ -364,6 +364,11 @@ async def update_coordination_redis_settings( settings: Final = _merge_over_saved(request.settings, saved_settings or {}) _validated_params(settings) + from litellm.proxy.proxy_server import proxy_config + + proxy_config.reject_config_owned_writes( + section_name=_GENERAL_SETTINGS_PARAM_NAME, changed_keys={_COORDINATION_REDIS_KEY: settings} + ) general_settings: Final = await _read_general_settings() before_settings: Final = general_settings.get(_COORDINATION_REDIS_KEY) action: Final[AUDIT_ACTIONS] = "updated" if isinstance(before_settings, dict) else "created" diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 3c7d06268ad..37c5ff2907e 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -5117,6 +5117,15 @@ class ProxyConfig: store.apply_db_row(cast(DbRow, section_name), wrote_section) await invalidate_config_param(section_name) + def reject_config_owned_deletes(self, *, section_name: str, keys: tuple[str, ...]) -> None: + """Refuse a delete of a setting the config file owns; unlike a write, the value never makes it allowed.""" + store: Final = self._settings_stores.get(cast(Section, section_name)) + if store is None: + return + owned: Final = tuple(sorted(key for key in keys if store.owned_by_config(key))) + if owned: + self._raise_config_owned(section_name=section_name, rejected=owned, store=store) + def reject_config_owned_writes(self, *, section_name: str, changed_keys: Mapping[str, JsonValue]) -> None: """Refuse a write to a setting the config file owns, rather than storing a value that never applies.""" store: Final = self._settings_stores.get(cast(Section, section_name)) @@ -5125,6 +5134,9 @@ class ProxyConfig: rejected: Final = store.rejected_writes(changed_keys) if not rejected: return + self._raise_config_owned(section_name=section_name, rejected=rejected, store=store) + + def _raise_config_owned(self, *, section_name: str, rejected: tuple[str, ...], store: SettingsStore) -> None: subject: Final = ( f"key '{rejected[0]}' is" if len(rejected) == 1 else f"keys {', '.join(repr(key) for key in rejected)} are" ) @@ -9684,10 +9696,12 @@ class ProxyStartupEvent: user_api_key_cache: UserApiKeyCache, ): """Initialize JWT auth on startup""" - if general_settings.get("litellm_jwtauth", None) is not None: - for k, v in general_settings["litellm_jwtauth"].items(): - if isinstance(v, str) and v.startswith("os.environ/"): - general_settings["litellm_jwtauth"][k] = get_secret(v) + declared_jwtauth: Final = general_settings.get("litellm_jwtauth", None) + if declared_jwtauth is not None: + resolved_jwtauth: Final = { + key: (get_secret(value) if isinstance(value, str) and value.startswith("os.environ/") else value) + for key, value in declared_jwtauth.items() + } # ``user_config_file_path`` is set by ``ProxyConfig._get_config_from_file`` # during startup. Threading it through lets an operator- # configured ``custom_validate: s3://...`` resolve through @@ -9695,7 +9709,7 @@ class ProxyStartupEvent: # file context) hit the gate and refuse remote loads. litellm_jwtauth = LiteLLM_JWTAuth( config_file_path=user_config_file_path, - **general_settings["litellm_jwtauth"], + **resolved_jwtauth, ) else: litellm_jwtauth = LiteLLM_JWTAuth() @@ -17665,9 +17679,12 @@ async def get_config_general_settings( detail={"error": f"Field name={field_name} is not set"}, ) + declared: Final = ( + settings.config_value(field_name) if settings.owned_by_config(field_name) else settings[field_name] + ) field_value = _redact_general_setting_value( field_name, - settings[field_name], + declared, user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN, ) if field_name == "plugins" and isinstance(field_value, list): @@ -18041,6 +18058,8 @@ async def delete_config_general_settings( detail={"error": f"Invalid field={data.field_name} passed in."}, ) + proxy_config.reject_config_owned_deletes(section_name="general_settings", keys=(data.field_name,)) + ## get general settings from db db_general_settings: Final[_ConfigParamRow | None] = await _config_param_table(prisma_client).find_first( where={"param_name": "general_settings"} diff --git a/tests/test_litellm/enterprise/enterprise_callbacks/send_emails/test_endpoints.py b/tests/test_litellm/enterprise/enterprise_callbacks/send_emails/test_endpoints.py index f0e1461c616..1e7492726ed 100644 --- a/tests/test_litellm/enterprise/enterprise_callbacks/send_emails/test_endpoints.py +++ b/tests/test_litellm/enterprise/enterprise_callbacks/send_emails/test_endpoints.py @@ -260,3 +260,74 @@ async def test_endpoint_with_no_prisma_client(mock_user_api_key_auth): with pytest.raises(HTTPException) as exc_info: await reset_event_settings(user_api_key_dict=mock_user_api_key_auth) assert exc_info.value.status_code == 500 + + +def _prisma_recording_upserts(upserts): + client = mock.MagicMock() + + async def find_unique(*args, **kwargs): + return None + + async def upsert(*args, **kwargs): + upserts.append(kwargs) + return None + + client.db.litellm_config.find_unique = find_unique + client.db.litellm_config.upsert = upsert + return client + + +def _proxy_config_owning(general_settings): + from litellm.proxy.proxy_server import ProxyConfig + + proxy_config = ProxyConfig() + proxy_config._load_yaml_settings_stores({"general_settings": general_settings}) + return proxy_config + + +@pytest.mark.asyncio +async def test_save_email_settings_refuses_a_config_owned_email_settings(): + upserts = [] + client = _prisma_recording_upserts(upserts) + proxy_config = _proxy_config_owning({"email_settings": {EmailEvent.new_user_invitation.value: True}}) + + with mock.patch("litellm.proxy.proxy_server.proxy_config", proxy_config): + with pytest.raises(HTTPException) as refused: + await _save_email_settings(client, {EmailEvent.new_user_invitation.value: False}) + + assert refused.value.status_code == 400 + assert refused.value.detail["keys"] == ["email_settings"] + assert upserts == [] + + +@pytest.mark.asyncio +async def test_update_event_settings_surfaces_the_config_owned_refusal(mock_user_api_key_auth): + upserts = [] + client = _prisma_recording_upserts(upserts) + proxy_config = _proxy_config_owning({"email_settings": {EmailEvent.virtual_key_created.value: False}}) + request = EmailEventSettingsUpdateRequest( + settings=[EmailEventSettings(event=EmailEvent.virtual_key_created, enabled=True)] + ) + + with mock.patch("litellm.proxy.proxy_server.prisma_client", client): + with mock.patch("litellm.proxy.proxy_server.proxy_config", proxy_config): + with pytest.raises(HTTPException) as refused: + await update_event_settings(request=request, user_api_key_dict=mock_user_api_key_auth) + + assert refused.value.status_code == 400 + assert refused.value.detail["keys"] == ["email_settings"] + assert upserts == [] + + +@pytest.mark.asyncio +async def test_save_email_settings_still_writes_when_the_config_file_is_silent(): + upserts = [] + client = _prisma_recording_upserts(upserts) + proxy_config = _proxy_config_owning({}) + + with mock.patch("litellm.proxy.proxy_server.proxy_config", proxy_config): + await _save_email_settings(client, {EmailEvent.new_user_invitation.value: False}) + + assert len(upserts) == 1 + written = json.loads(upserts[0]["data"]["create"]["param_value"]) + assert written["email_settings"] == {EmailEvent.new_user_invitation.value: False} diff --git a/tests/test_litellm/proxy/config_resolvers/test_settings_store.py b/tests/test_litellm/proxy/config_resolvers/test_settings_store.py index daf6609325e..c3e30341993 100644 --- a/tests/test_litellm/proxy/config_resolvers/test_settings_store.py +++ b/tests/test_litellm/proxy/config_resolvers/test_settings_store.py @@ -359,3 +359,93 @@ def test_settings_store_refusal_stays_quiet_about_the_database_when_nothing_is_s assert refused.value.shadows_db_value is False assert "stored in the database" not in str(refused.value) assert "config file" in str(refused.value) + + +def test_settings_store_keeps_a_resolved_runtime_value_when_a_db_row_repeats_it() -> None: + store: Final = SettingsStore("general_settings") + store.apply_db_row("general_settings", {"litellm_key_header_name": "os.environ/HDR"}) + store.apply_runtime_values({"litellm_key_header_name": "X-Resolved-Header"}) + + store.apply_db_row("general_settings", {"litellm_key_header_name": "os.environ/HDR"}) + + assert store["litellm_key_header_name"] == "X-Resolved-Header" + + +def test_settings_store_drops_a_resolved_runtime_value_when_a_db_row_changes_it() -> None: + store: Final = SettingsStore("general_settings") + store.apply_db_row("general_settings", {"litellm_key_header_name": "os.environ/HDR"}) + store.apply_runtime_values({"litellm_key_header_name": "X-Resolved-Header"}) + + store.apply_db_row("general_settings", {"litellm_key_header_name": "os.environ/OTHER"}) + + assert store["litellm_key_header_name"] == "os.environ/OTHER" + + +def test_settings_store_accepts_the_writes_it_does_not_report_as_rejected() -> None: + store: Final = SettingsStore("general_settings") + store.load_yaml({"litellm_key_header_name": "os.environ/HDR"}) + store.apply_runtime_values({"litellm_key_header_name": "X-Resolved-Header"}) + incoming: Final[dict[str, JsonValue]] = {"litellm_key_header_name": "X-Resolved-Header"} + + assert store.rejected_writes(incoming) == () + store["litellm_key_header_name"] = "X-Resolved-Header" + assert store["litellm_key_header_name"] == "X-Resolved-Header" + + +def test_settings_store_reports_a_rejected_write_the_store_itself_refuses() -> None: + store: Final = SettingsStore("general_settings") + store.load_yaml({"litellm_key_header_name": "os.environ/HDR"}) + store.apply_runtime_values({"litellm_key_header_name": "X-Resolved-Header"}) + + assert store.rejected_writes({"litellm_key_header_name": "X-Other-Header"}) == ("litellm_key_header_name",) + with pytest.raises(ConfigOwnedKeyError): + store["litellm_key_header_name"] = "X-Other-Header" + + +def test_settings_store_reports_no_shadowing_when_the_database_repeats_the_config_template() -> None: + store: Final = SettingsStore("general_settings") + store.load_yaml({"litellm_key_header_name": "os.environ/HDR"}) + store.apply_db_row("general_settings", {"litellm_key_header_name": "os.environ/HDR"}) + store.apply_runtime_values({"litellm_key_header_name": "X-Resolved-Header"}) + + assert store.shadowed_db_keys() == () + assert store.shadows_db_value("litellm_key_header_name") is False + + +def test_settings_store_still_reports_shadowing_when_the_database_holds_another_template() -> None: + store: Final = SettingsStore("general_settings") + store.load_yaml({"litellm_key_header_name": "os.environ/HDR"}) + store.apply_db_row("general_settings", {"litellm_key_header_name": "os.environ/OTHER"}) + store.apply_runtime_values({"litellm_key_header_name": "X-Resolved-Header"}) + + assert store.shadowed_db_keys() == ("litellm_key_header_name",) + + +def test_settings_store_truthiness_stops_at_the_first_key() -> None: + store: Final = SettingsStore("general_settings") + store.load_yaml({f"key_{index}": index for index in range(25)}) + resolutions: Final[list[str]] = [] + original: Final = SettingsStore._resolution_for + + def counted(self: SettingsStore, key: str): # type: ignore[no-untyped-def] + resolutions.append(key) + return original(self, key) + + with patch.object(SettingsStore, "_resolution_for", counted): + assert bool(store) is True + truthiness_resolutions: Final = len(resolutions) + resolutions.clear() + assert len(store) == 25 + + assert len(resolutions) == 25 + assert truthiness_resolutions <= 1 + + +def test_settings_store_truthiness_matches_emptiness() -> None: + store: Final = SettingsStore("general_settings") + + assert bool(store) is False + store["max_parallel_requests"] = 3 + assert bool(store) is True + del store["max_parallel_requests"] + assert bool(store) is False diff --git a/tests/test_litellm/proxy/management_endpoints/test_coordination_redis_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_coordination_redis_endpoints.py index 4481a87c9e7..dc703640768 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_coordination_redis_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_coordination_redis_endpoints.py @@ -616,3 +616,64 @@ async def test_connection_test_rejects_proxy_admin_viewer(): user_api_key_dict=UserAPIKeyAuth(api_key="hashed", user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY), ) assert exc_info.value.status_code == 403 + + +def _real_proxy_config(file_general_settings: dict) -> "object": + from litellm.proxy.proxy_server import ProxyConfig + + proxy_config = ProxyConfig() + proxy_config._load_yaml_settings_stores({"general_settings": file_general_settings}) + proxy_config.get_config_state = MagicMock( # type: ignore[method-assign] + return_value={"general_settings": file_general_settings} + ) + return proxy_config + + +@pytest.mark.asyncio +async def test_update_refuses_a_config_owned_coordination_redis_block(monkeypatch): + monkeypatch.setattr(litellm, "store_audit_logs", False) + mock_prisma = _prisma_with_general_settings({"master_key": "sk-1234"}) + from_file = {"coordination_redis": {"host": "yaml-redis.example.com", "port": 6379}} + + with ( + patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), + patch("litellm.proxy.proxy_server.proxy_config", _real_proxy_config(from_file)), + patch("litellm.proxy.proxy_server.store_model_in_db", True), + ): + with pytest.raises(HTTPException) as refused: + await update_coordination_redis_settings( + request=CoordinationRedisSettingsRequest(settings={"host": "db-redis.example.com", "port": 6380}), + user_api_key_dict=_admin_auth(), + litellm_changed_by=None, + ) + + assert refused.value.status_code == 400 + assert refused.value.detail["keys"] == ["coordination_redis"] + mock_prisma.db.litellm_config.upsert.assert_not_called() + + +@pytest.mark.asyncio +async def test_update_still_persists_when_the_config_file_declares_no_block(monkeypatch): + monkeypatch.setattr(litellm, "store_audit_logs", False) + mock_prisma = _prisma_with_general_settings({"master_key": "sk-1234"}) + + async def _capture_invalidate(param_name: str) -> None: + return None + + with ( + patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), + patch("litellm.proxy.proxy_server.proxy_config", _real_proxy_config({"master_key": "sk-1234"})), + patch("litellm.proxy.proxy_server.store_model_in_db", True), + patch( + "litellm.proxy.management_endpoints.coordination_redis_endpoints.invalidate_config_param", + new=_capture_invalidate, + ), + ): + await update_coordination_redis_settings( + request=CoordinationRedisSettingsRequest(settings={"host": "db-redis.example.com", "port": 6380}), + user_api_key_dict=_admin_auth(), + litellm_changed_by=None, + ) + + persisted = json.loads(mock_prisma.db.litellm_config.upsert.call_args.kwargs["data"]["update"]["param_value"]) + assert persisted["coordination_redis"] == {"host": "db-redis.example.com", "port": 6380} diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 935cc6ad8b7..08a4621de24 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -14737,3 +14737,99 @@ async def test_auth_cache_invalidation_subscriber_evicts_byok_credentials_cached byok_credential_cache.flush_cache() assert evicted, "the subscriber does not evict the BYOK credential cache on a peer worker's broadcast" + + +@pytest.mark.asyncio +async def test_delete_config_general_settings_refuses_a_key_the_config_file_owns(monkeypatch): + from litellm.proxy._types import ConfigFieldDelete + from litellm.proxy.proxy_server import ProxyConfig, delete_config_general_settings + + pc = ProxyConfig() + pc._load_yaml_settings_stores({"general_settings": {"max_request_size_mb": 42}}) + monkeypatch.setattr(proxy_server_module, "proxy_config", pc) + monkeypatch.setattr(proxy_server_module, "prisma_client", _fake_prisma_with_config({"max_request_size_mb": 99})) + + admin = UserAPIKeyAuth(api_key="hashed-admin", user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN) + with pytest.raises(HTTPException) as refused: + await delete_config_general_settings( + data=ConfigFieldDelete(field_name="max_request_size_mb", config_type="general_settings"), + user_api_key_dict=admin, + ) + + assert refused.value.status_code == 400 + assert refused.value.detail["keys"] == ["max_request_size_mb"] + assert "config file" in refused.value.detail["error"] + assert pc.settings["max_request_size_mb"] == 42 + + +@pytest.mark.asyncio +async def test_delete_config_general_settings_still_removes_a_key_the_database_owns(monkeypatch): + from litellm.proxy._types import ConfigFieldDelete + from litellm.proxy.proxy_server import ProxyConfig, delete_config_general_settings + + pc = ProxyConfig() + pc._load_yaml_settings_stores({"general_settings": {}}) + pc.settings.apply_db_row("general_settings", {"max_request_size_mb": 42}) + monkeypatch.setattr(proxy_server_module, "proxy_config", pc) + monkeypatch.setattr(proxy_server_module, "prisma_client", _fake_prisma_with_config({"max_request_size_mb": 42})) + + admin = UserAPIKeyAuth(api_key="hashed-admin", user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN) + await delete_config_general_settings( + data=ConfigFieldDelete(field_name="max_request_size_mb", config_type="general_settings"), + user_api_key_dict=admin, + ) + + assert "max_request_size_mb" not in pc.settings + + +@pytest.mark.asyncio +async def test_config_field_info_reports_the_declared_value_of_a_config_owned_secret(monkeypatch): + from litellm.proxy.proxy_server import ProxyConfig, get_config_general_settings + + pc = ProxyConfig() + pc._load_yaml_settings_stores({"general_settings": {"master_key": "os.environ/PROXY_MASTER_KEY"}}) + pc.settings.apply_runtime_values({"master_key": "sk-resolved-secret"}) + monkeypatch.setattr(proxy_server_module, "proxy_config", pc) + monkeypatch.setattr(proxy_server_module, "prisma_client", _fake_prisma_with_config({})) + + admin = UserAPIKeyAuth(api_key="hashed-admin", user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN) + info = await get_config_general_settings(field_name="master_key", user_api_key_dict=admin) + + assert info.field_value == "os.environ/PROXY_MASTER_KEY" + assert info.source == "config" + assert info.editable is False + + +@pytest.mark.asyncio +async def test_config_field_info_still_reports_a_database_owned_value(monkeypatch): + from litellm.proxy.proxy_server import ProxyConfig, get_config_general_settings + + pc = ProxyConfig() + pc._load_yaml_settings_stores({"general_settings": {}}) + pc.settings.apply_db_row("general_settings", {"max_request_size_mb": 42}) + monkeypatch.setattr(proxy_server_module, "proxy_config", pc) + monkeypatch.setattr(proxy_server_module, "prisma_client", _fake_prisma_with_config({"max_request_size_mb": 42})) + + admin = UserAPIKeyAuth(api_key="hashed-admin", user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN) + info = await get_config_general_settings(field_name="max_request_size_mb", user_api_key_dict=admin) + + assert info.field_value == 42 + assert info.source == "db" + + +@pytest.mark.asyncio +async def test_initialize_jwt_auth_leaves_the_declared_jwtauth_mapping_unresolved(monkeypatch): + from litellm.proxy.proxy_server import ProxyStartupEvent + + declared = {"public_key_ttl": "600", "team_id_jwt_field": "os.environ/JWT_TEAM_FIELD"} + general_settings = {"litellm_jwtauth": declared} + monkeypatch.setattr(proxy_server_module, "get_secret", lambda value: "resolved-team-field") + + ProxyStartupEvent._initialize_jwt_auth( + general_settings=general_settings, + prisma_client=None, + user_api_key_cache=DualCache(), + ) + + assert declared["team_id_jwt_field"] == "os.environ/JWT_TEAM_FIELD" + assert proxy_server_module.jwt_handler.litellm_jwtauth.team_id_jwt_field == "resolved-team-field" From 4e8a4d4b6184a7338429ce8abfbb30ba737e13e0 Mon Sep 17 00:00:00 2001 From: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 12:17:51 -0700 Subject: [PATCH 52/73] test(e2e): restore existing OAuth chat test to baseline --- .github/e2e-stack/select_tests.py | 1 - tests/e2e/CONTRIBUTING.md | 2 +- tests/e2e/mcp/test_mcp_chat_completion_oauth_e2e.py | 13 ++++++------- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/.github/e2e-stack/select_tests.py b/.github/e2e-stack/select_tests.py index 183a4208286..a9ca1f88660 100644 --- a/.github/e2e-stack/select_tests.py +++ b/.github/e2e-stack/select_tests.py @@ -6,7 +6,6 @@ SELECTABLE: Final = re.compile(r"^tests/e2e/([A-Za-z0-9_.-]+/)*test_[A-Za-z0-9_. UNSUPPORTED: Final = re.compile( r"^tests/e2e/(ui|claude_code|load)/" r"|^tests/e2e/mcp/test_mcp_oauth_happy_path_e2e\.py$" - r"|^tests/e2e/mcp/test_mcp_chat_completion_oauth_e2e\.py$" r"|^tests/e2e/llm_translation/realtime/test_realtime_pipecat_audio_e2e\.py$" r"|^tests/e2e/batches/test_managed_files_enforcement_e2e\.py$" r"|^tests/e2e/guardrails/test_presidio_masking_e2e\.py$" diff --git a/tests/e2e/CONTRIBUTING.md b/tests/e2e/CONTRIBUTING.md index 99304c50e58..2adac08329f 100644 --- a/tests/e2e/CONTRIBUTING.md +++ b/tests/e2e/CONTRIBUTING.md @@ -105,7 +105,7 @@ A couple of logging destinations are configured on the proxy rather than by the ### The pull request check -Every same-repository PR that adds, modifies, or renames a `tests/e2e/**/test_*.py` file runs those changed files three times. A change to the harness itself, meaning a root-level `tests/e2e/*.py` file or `pytest.ini`, `tests/e2e/gateway/`, `.github/e2e-stack/`, or the workflow, also runs the `access_control` suite and both JWT suites as canaries, because those files have no test of their own that exercises the stack. `.github/e2e-stack/select_tests.py` applies both rules. The stack config at `tests/e2e/gateway/stage_mirror_ci_config.yml` must declare every model the selected suites use; a missing one shows up as a failed test id in the public log. The suite's own single rerun for network errors and 5xx responses (see `pytest.ini`) applies on every pass, so a transport blip does not fail the check while a race inside a test still does. The stage-mirror stack has a control-plane backend, two gateways behind nginx, Postgres, Keycloak, Jaeger, and TLS cluster-mode Valkey. Realm-only edits also trigger these canaries. The stack exports every gateway address in `LITELLM_PROXY_REPLICA_URLS`, so model registration waits until each gateway lists the new model rather than whichever one the load balancer answered from. Documentation, deleted-file, and application-only changes do not start the stack or request environment approval. The `ui/`, `claude_code/`, and `load/` directories, `batches/test_managed_files_enforcement_e2e.py`, `llm_translation/realtime/test_realtime_pipecat_audio_e2e.py`, `guardrails/test_presidio_masking_e2e.py`, `mcp/test_mcp_chat_completion_oauth_e2e.py`, and `mcp/test_mcp_oauth_happy_path_e2e.py` remain outside this check because they use separate tooling or need a differently configured stack: the pipecat audio suite skips itself at import time unless the NLTK `punkt_tab` data is installed, and the presidio suite fails without the analyzer and anonymizer services this stack does not start. The Redis chaos test under `load/` needs a proxy it can pause the Redis of on the same host (`gateway/redis_chaos_ci_config.yml`), which `.github/workflows/test-e2e-redis-chaos.yml` boots, and which the Buildkite `e2e-redis-chaos` step in project-releaser runs co-located with Postgres and Valkey in one pod; it is deselected unless `E2E_REDIS_CHAOS` is set. The two MCP OAuth suites need a saved Linear browser session (`E2E_LINEAR_STORAGE_STATE`) that the stage-mirror stack does not have, and the happy path runs in its own dispatch workflow `.github/workflows/test-mcp-oauth-e2e.yml` +Every same-repository PR that adds, modifies, or renames a `tests/e2e/**/test_*.py` file runs those changed files three times. A change to the harness itself, meaning a root-level `tests/e2e/*.py` file or `pytest.ini`, `tests/e2e/gateway/`, `.github/e2e-stack/`, or the workflow, also runs the `access_control` suite and both JWT suites as canaries, because those files have no test of their own that exercises the stack. `.github/e2e-stack/select_tests.py` applies both rules. The stack config at `tests/e2e/gateway/stage_mirror_ci_config.yml` must declare every model the selected suites use; a missing one shows up as a failed test id in the public log. The suite's own single rerun for network errors and 5xx responses (see `pytest.ini`) applies on every pass, so a transport blip does not fail the check while a race inside a test still does. The stage-mirror stack has a control-plane backend, two gateways behind nginx, Postgres, Keycloak, Jaeger, and TLS cluster-mode Valkey. Realm-only edits also trigger these canaries. The stack exports every gateway address in `LITELLM_PROXY_REPLICA_URLS`, so model registration waits until each gateway lists the new model rather than whichever one the load balancer answered from. Documentation, deleted-file, and application-only changes do not start the stack or request environment approval. The `ui/`, `claude_code/`, and `load/` directories, `batches/test_managed_files_enforcement_e2e.py`, `llm_translation/realtime/test_realtime_pipecat_audio_e2e.py`, and `guardrails/test_presidio_masking_e2e.py` remain outside this check because they use separate tooling or need a differently configured stack: the pipecat audio suite skips itself at import time unless the NLTK `punkt_tab` data is installed, and the presidio suite fails without the analyzer and anonymizer services this stack does not start. The Redis chaos test under `load/` needs a proxy it can pause the Redis of on the same host (`gateway/redis_chaos_ci_config.yml`), which `.github/workflows/test-e2e-redis-chaos.yml` boots, and which the Buildkite `e2e-redis-chaos` step in project-releaser runs co-located with Postgres and Valkey in one pod; it is deselected unless `E2E_REDIS_CHAOS` is set Every selected file must execute at least one passing test in each pass, and any test failure, collection error, or entirely skipped or deselected file fails the check. A file whose tests are all marked skip therefore cannot pass this check, so unskip at least one of them, or add the file to `UNSUPPORTED` in `select_tests.py` with the reason, before changing one. A failed pass stops the run. The public log prints pytest's one-line summary for each pass, including the rerun count, and names each failed or errored test as `classname::name`, so a retried network error or a failing test is visible without the raw output. The final `e2e-changed-tests` job succeeds only when no supported test files changed or the approved run completed all three passes. Fork PRs with selected tests fail this gate until a maintainer brings the reviewed change onto a same-repository branch diff --git a/tests/e2e/mcp/test_mcp_chat_completion_oauth_e2e.py b/tests/e2e/mcp/test_mcp_chat_completion_oauth_e2e.py index 086ec929a17..01e94f7b86f 100644 --- a/tests/e2e/mcp/test_mcp_chat_completion_oauth_e2e.py +++ b/tests/e2e/mcp/test_mcp_chat_completion_oauth_e2e.py @@ -27,13 +27,8 @@ from __future__ import annotations import os import pytest -from e2e_config import ( - CHEAP_ANTHROPIC_MODEL, - LINEAR_MCP_URL, - LINEAR_READONLY_TOOL, - LINEAR_STORAGE_STATE, - unique_marker, -) + +from e2e_config import CHEAP_ANTHROPIC_MODEL, LINEAR_MCP_URL, LINEAR_STORAGE_STATE, unique_marker from e2e_http import AuthHeaders from lifecycle import ResourceManager from models import ChatBody, ChatMessage, KeyGenerateBody, McpChatTool, McpServerCreateBody, ObjectPermission @@ -55,6 +50,10 @@ pytestmark = [ ), ] +# Pinned from a live dance during verification (never guessed); the gateway +# prefixes every upstream tool name with the server alias. list_teams is a +# read-only Linear tool that takes no arguments and returns the caller's teams. +LINEAR_READONLY_TOOL = "list_teams" LINEAR_PROMPT = "Use the list_teams tool to list my Linear teams, then reply with the name of one of them." From 9f0eb5082ae4e2360f68b7cba19478023fc1ccbb Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 12:26:11 -0700 Subject: [PATCH 53/73] fix(batches): authorize executed upload targets before the files api probe A batch upload naming a model on a LiteLLM-executed provider now checks that the key may call that model before the upstream server is probed for a Files API, matching the order batch create already uses. Only targets on an executed provider are checked here, so provider-model uploads keep their existing behavior. File content reads and writes move out of the storage backend into ManagedFileContentRepository, so the backend no longer queries Prisma directly. --- .../files/litellm_db_storage_backend.py | 39 ++++--------------- .../openai_files_endpoints/files_endpoints.py | 23 +++++++++-- .../managed_file_content_repository.py | 30 ++++++++++++++ .../files/test_storage_backend_factory.py | 14 +++++-- .../test_files_endpoint.py | 24 ++++++++++++ 5 files changed, 91 insertions(+), 39 deletions(-) create mode 100644 litellm/repositories/managed_file_content_repository.py diff --git a/litellm/llms/base_llm/files/litellm_db_storage_backend.py b/litellm/llms/base_llm/files/litellm_db_storage_backend.py index bca4b8f4c6f..a686062b2f7 100644 --- a/litellm/llms/base_llm/files/litellm_db_storage_backend.py +++ b/litellm/llms/base_llm/files/litellm_db_storage_backend.py @@ -1,13 +1,9 @@ -from collections.abc import Mapping from typing import TYPE_CHECKING, Final from litellm.llms.base_llm.files.storage_backend import BaseFileStorageBackend -from litellm.repositories.prisma_protocols import TableActions -from litellm.repositories.table_repositories import PrismaTableRepository +from litellm.repositories.managed_file_content_repository import ManagedFileContentRepository if TYPE_CHECKING: - from prisma import models as prisma_models - from litellm.proxy.utils import PrismaClient LITELLM_DB_STORAGE_BACKEND_NAME: Final = "litellm_db" @@ -20,21 +16,9 @@ def storage_url_to_row_id(storage_url: str) -> str: return storage_url.removeprefix(LITELLM_DB_STORAGE_URL_PREFIX) -def _where_id(storage_url: str) -> Mapping[str, str]: - return {"id": storage_url_to_row_id(storage_url)} # mutable-ok: Prisma filter - - -class ManagedFileContentRepository(PrismaTableRepository["prisma_models.LiteLLM_ManagedFileContentTable"]): - table_name = "litellm_managedfilecontenttable" - - class LiteLLMDbStorageBackend(BaseFileStorageBackend): def __init__(self, prisma_client: "PrismaClient") -> None: - self._prisma_client = prisma_client - - @property - def _table(self) -> "TableActions[prisma_models.LiteLLM_ManagedFileContentTable]": - return ManagedFileContentRepository(self._prisma_client).table + self._contents = ManagedFileContentRepository(prisma_client) async def upload_file( self, @@ -44,22 +28,13 @@ class LiteLLMDbStorageBackend(BaseFileStorageBackend): path_prefix: str | None = None, file_naming_strategy: str = "uuid", ) -> str: - from prisma import Base64 - - data: Final = {"content": Base64.encode(file_content)} # mutable-ok: Prisma payload - row: Final = await self._table.create(data=data) - return f"{LITELLM_DB_STORAGE_URL_PREFIX}{row.id}" + return f"{LITELLM_DB_STORAGE_URL_PREFIX}{await self._contents.store(file_content)}" async def download_file(self, storage_url: str) -> bytes: - row: Final = await self._table.find_unique(where=_where_id(storage_url)) - if row is None: + content: Final = await self._contents.load(storage_url_to_row_id(storage_url)) + if content is None: raise ValueError(f"No stored file content for {storage_url}") - return row.content.decode() + return content async def delete_file(self, storage_url: str) -> None: - from prisma.errors import RecordNotFoundError - - try: - await self._table.delete(where=_where_id(storage_url)) - except RecordNotFoundError: - return + await self._contents.delete(storage_url_to_row_id(storage_url)) diff --git a/litellm/proxy/openai_files_endpoints/files_endpoints.py b/litellm/proxy/openai_files_endpoints/files_endpoints.py index 6d60bc3fda5..a5921cc6380 100644 --- a/litellm/proxy/openai_files_endpoints/files_endpoints.py +++ b/litellm/proxy/openai_files_endpoints/files_endpoints.py @@ -37,7 +37,10 @@ from litellm.llms.base_llm.files.transformation import BaseFileEndpoints from litellm.llms.base_llm.managed_resources.isolation import build_list_page from litellm.proxy._types import * from litellm.proxy.auth.user_api_key_auth import user_api_key_auth -from litellm.proxy.batches_endpoints.litellm_executed_batches import resolve_litellm_executed_provider +from litellm.proxy.batches_endpoints.litellm_executed_batches import ( + litellm_executed_provider_of, + resolve_litellm_executed_provider, +) from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing from litellm.proxy.common_utils.http_parsing_utils import ( _read_request_body, @@ -69,6 +72,7 @@ from litellm.proxy.openai_files_endpoints.common_utils import ( _is_base64_encoded_unified_file_id, add_internal_model_credentials, apply_team_provider_credentials, + authorize_model_for_key, encode_file_id_with_model, extract_file_creation_params, get_authorized_credentials_for_model, @@ -102,16 +106,29 @@ from litellm.types.llms.openai import ( router: Final = APIRouter() +def _names_a_litellm_executed_provider(llm_router: Router, candidate: str, team_id: str | None) -> bool: + credentials: Final = llm_router.get_deployment_credentials_with_provider(model_id=candidate, team_id=team_id) + return credentials is not None and litellm_executed_provider_of(credentials) is not None + + async def _litellm_executed_batch_input_model( llm_router: Router | None, purpose: OpenAIFilesPurpose, model: str | None, target_model_names_list: Sequence[str], - team_id: str | None, + user_api_key_dict: UserAPIKeyAuth, ) -> str | None: if llm_router is None: return None candidates: Final = (model,) if model is not None else tuple(target_model_names_list) + team_id: Final = user_api_key_dict.team_id + await asyncio.gather( + *( + authorize_model_for_key(model_id=candidate, llm_router=llm_router, user_api_key_dict=user_api_key_dict) + for candidate in candidates + if _names_a_litellm_executed_provider(llm_router, candidate, team_id) + ) + ) providers: Final = await asyncio.gather( *(resolve_litellm_executed_provider(llm_router, candidate, team_id) for candidate in candidates) ) @@ -289,7 +306,7 @@ async def route_create_file( """ executed_model: Final = await _litellm_executed_batch_input_model( - llm_router, purpose, model, target_model_names_list, user_api_key_dict.team_id + llm_router, purpose, model, target_model_names_list, user_api_key_dict ) explicit_storage: Final = target_storage if target_storage and target_storage != "default" else None storage: Final = explicit_storage or (LITELLM_DB_STORAGE_BACKEND_NAME if executed_model is not None else None) diff --git a/litellm/repositories/managed_file_content_repository.py b/litellm/repositories/managed_file_content_repository.py new file mode 100644 index 00000000000..8810269279c --- /dev/null +++ b/litellm/repositories/managed_file_content_repository.py @@ -0,0 +1,30 @@ +from typing import TYPE_CHECKING, Final + +from litellm.repositories.table_repositories import PrismaTableRepository + +if TYPE_CHECKING: + from prisma import models as prisma_models # noqa: F401 # used by the quoted base-class subscript + + +class ManagedFileContentRepository(PrismaTableRepository["prisma_models.LiteLLM_ManagedFileContentTable"]): + table_name = "litellm_managedfilecontenttable" + + async def store(self, content: bytes) -> str: + from prisma import Base64 + + row: Final = await self.table.create( + data={"content": Base64.encode(content)} # mutable-ok: prisma payloads are plain dicts + ) + return row.id + + async def load(self, row_id: str) -> bytes | None: + row: Final = await self.table.find_unique(where={"id": row_id}) # mutable-ok: prisma filters are plain dicts + return None if row is None else row.content.decode() + + async def delete(self, row_id: str) -> None: + from prisma.errors import RecordNotFoundError + + try: + await self.table.delete(where={"id": row_id}) # mutable-ok: prisma filters are plain dicts + except RecordNotFoundError: + return diff --git a/tests/test_litellm/llms/base_llm/files/test_storage_backend_factory.py b/tests/test_litellm/llms/base_llm/files/test_storage_backend_factory.py index 39b0adb56fc..945691c5b98 100644 --- a/tests/test_litellm/llms/base_llm/files/test_storage_backend_factory.py +++ b/tests/test_litellm/llms/base_llm/files/test_storage_backend_factory.py @@ -1,21 +1,27 @@ -from unittest.mock import MagicMock +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock import pytest from litellm.llms.base_llm.files.litellm_db_storage_backend import ( LITELLM_DB_STORAGE_BACKEND_NAME, + LITELLM_DB_STORAGE_URL_PREFIX, LiteLLMDbStorageBackend, ) from litellm.llms.base_llm.files.storage_backend_factory import get_storage_backend -def test_litellm_db_backend_is_built_on_the_given_prisma_client(): - prisma_client = MagicMock() +@pytest.mark.asyncio +async def test_litellm_db_backend_stores_through_the_given_prisma_client(): + table = MagicMock(create=AsyncMock(return_value=SimpleNamespace(id="row-1"))) + prisma_client = MagicMock(db=MagicMock(litellm_managedfilecontenttable=table)) backend = get_storage_backend(LITELLM_DB_STORAGE_BACKEND_NAME, prisma_client=prisma_client) assert isinstance(backend, LiteLLMDbStorageBackend) - assert backend._table is prisma_client.db.litellm_managedfilecontenttable + stored_at = await backend.upload_file(file_content=b"line\n", filename="input.jsonl", content_type="text/plain") + assert stored_at == f"{LITELLM_DB_STORAGE_URL_PREFIX}row-1" + table.create.assert_awaited_once() def test_litellm_db_backend_without_a_database_is_rejected(): diff --git a/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py b/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py index 6c0f4c012ba..6787aaa3525 100644 --- a/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py +++ b/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py @@ -706,6 +706,30 @@ def test_batch_upload_for_a_litellm_executed_model_is_kept_by_litellm( assert kwargs["purpose"] == "batch" +@pytest.mark.parametrize( + "headers, form", + [({"x-litellm-model": "my-vllm"}, {}), ({}, {"target_model_names": "my-vllm"})], + ids=["x-litellm-model header", "target_model_names form field"], +) +def test_batch_upload_for_a_litellm_executed_model_the_key_cannot_call_is_refused_before_the_server_is_probed( + batch_upload_seams, headers: dict[str, str], form: dict[str, str] +): + import litellm.proxy.proxy_server as ps + + stored, provider_upload, upstream_files_route = batch_upload_seams + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_id="restricted-user", models=["gemini-2.0-flash"] + ) + + response = _upload_batch_file(headers, form) + + assert response.status_code == 403, response.text + assert "my-vllm" in response.text + assert upstream_files_route.call_count == 0 + stored.assert_not_awaited() + provider_upload.assert_not_awaited() + + def test_batch_upload_naming_an_executed_and_a_provider_model_is_rejected(batch_upload_seams): stored, provider_upload, _ = batch_upload_seams From 7d93821e415bca477f3041b6f20b878d68de227f Mon Sep 17 00:00:00 2001 From: yucheng Date: Sat, 19 Sep 2026 19:30:57 +0000 Subject: [PATCH 54/73] fix(otel v2): keep Responses refusal text on the folded assistant message Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/integrations/otel/model/payloads.py | 19 +++++++++++-------- .../otel/test_otel_v2_sources_of_truth.py | 19 ++++++++++++++++++- .../otel/test_otel_v2_vendor_mappers.py | 1 + 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/litellm/integrations/otel/model/payloads.py b/litellm/integrations/otel/model/payloads.py index 484f4a4c294..c23b3291365 100644 --- a/litellm/integrations/otel/model/payloads.py +++ b/litellm/integrations/otel/model/payloads.py @@ -720,6 +720,7 @@ class _ToolCall(TypedDict): class _AssistantMessage(TypedDict): role: ReadOnly[str] content: ReadOnly[str | None] + refusal: ReadOnly[str | None] tool_calls: ReadOnly[tuple[_ToolCall, ...] | None] @@ -735,13 +736,7 @@ def _responses_choices(response: Mapping[str, object]) -> tuple[_Choice, ...]: """A Responses API ``output`` folded into one chat-shaped assistant choice.""" items: Final = _dicts(response.get("output")) messages: Final = tuple(item for item in items if item.get("type") == "message") - content: Final = "".join( - text - for item in messages - for part in _dicts(item.get("content")) - if part.get("type") == "output_text" - if (text := as_str(part.get("text"))) is not None - ) + parts: Final = tuple(part for item in messages for part in _dicts(item.get("content"))) tool_calls: Final = tuple( _responses_tool_call(item) for item in items if item.get("type") in _RESPONSES_TOOL_CALL_TYPES ) @@ -749,13 +744,21 @@ def _responses_choices(response: Mapping[str, object]) -> tuple[_Choice, ...]: return () message: Final[_AssistantMessage] = { "role": next((role for item in messages if (role := as_str(item.get("role")))), "assistant"), - "content": content if messages else None, + "content": _responses_parts_text(parts, "output_text", "text"), + "refusal": _responses_parts_text(parts, "refusal", "refusal"), "tool_calls": tool_calls or None, } choice: Final[_Choice] = {"message": message, "finish_reason": _responses_finish_reason(response, bool(tool_calls))} return (choice,) +def _responses_parts_text(parts: tuple[Mapping[str, object], ...], part_type: str, field: str) -> str | None: + texts: Final = tuple( + text for part in parts if part.get("type") == part_type if (text := as_str(part.get(field))) is not None + ) + return "".join(texts) if texts else None + + def _responses_tool_call(item: Mapping[str, object]) -> _ToolCall: custom: Final = item.get("type") == "custom_tool_call" function: Final[_ToolFunction] = { diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_sources_of_truth.py b/tests/test_litellm/integrations/otel/test_otel_v2_sources_of_truth.py index 972c91670f8..17de3cf1e8a 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_sources_of_truth.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_sources_of_truth.py @@ -761,7 +761,7 @@ def test_responses_output_text_becomes_one_assistant_choice_with_stop(): assert json.loads(json.dumps(data.choices_out)) == [ { - "message": {"role": "assistant", "content": "pong", "tool_calls": None}, + "message": {"role": "assistant", "content": "pong", "refusal": None, "tool_calls": None}, "finish_reason": "stop", } ] @@ -835,6 +835,23 @@ def test_responses_content_only_reads_output_text_parts(): data = LLMCallSpanData.from_standard_logging_payload(_responses_payload([item]), capture_content=True) assert data.choices_out[0]["message"]["content"] == "ok" + assert data.choices_out[0]["message"]["refusal"] == "no" + + +def test_responses_refusal_only_output_keeps_the_refusal_text(): + item = { + "type": "message", + "role": "assistant", + "content": [{"type": "refusal", "refusal": "I can't "}, {"type": "refusal", "refusal": "help with that."}], + } + data = LLMCallSpanData.from_standard_logging_payload(_responses_payload([item]), capture_content=True) + + assert json.loads(json.dumps(data.choices_out)) == [ + { + "message": {"role": "assistant", "content": None, "refusal": "I can't help with that.", "tool_calls": None}, + "finish_reason": "stop", + } + ] def test_responses_output_without_messages_or_tool_calls_stays_empty(): diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_vendor_mappers.py b/tests/test_litellm/integrations/otel/test_otel_v2_vendor_mappers.py index 5b4d1e7a802..4e375de0494 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_vendor_mappers.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_vendor_mappers.py @@ -218,6 +218,7 @@ def test_langfuse_mapper_renders_a_responses_api_call_from_the_standard_logging_ { "role": "assistant", "content": "Checking.", + "refusal": None, "tool_calls": [ {"id": "call_1", "type": "function", "function": {"name": "get_weather", "arguments": '{"city": "sf"}'}} ], From e8f2ee82002683b1e7f37c6d24f4281676145e6e Mon Sep 17 00:00:00 2001 From: yucheng Date: Sat, 19 Sep 2026 19:35:08 +0000 Subject: [PATCH 55/73] fix(redaction): redact Responses refusal parts under turn_off_message_logging Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/litellm_core_utils/redact_messages.py | 4 +++ .../test_redact_messages.py | 29 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/litellm/litellm_core_utils/redact_messages.py b/litellm/litellm_core_utils/redact_messages.py index 1f9464a2a26..b409b181a79 100644 --- a/litellm/litellm_core_utils/redact_messages.py +++ b/litellm/litellm_core_utils/redact_messages.py @@ -128,6 +128,8 @@ def _redact_responses_api_output(output_items): for content_part in output_item.content: if getattr(content_part, "text", None) is not None: content_part.text = REDACTED_BY_LITELLM + if getattr(content_part, "refusal", None) is not None: + content_part.refusal = REDACTED_BY_LITELLM # Redact reasoning items in output array if hasattr(output_item, "type") and output_item.type == "reasoning": @@ -155,6 +157,8 @@ def _redact_responses_api_output_dict(output_items, redacted_str: str): for content_item in output_item["content"]: if isinstance(content_item, dict) and content_item.get("text") is not None: content_item["text"] = redacted_str + if isinstance(content_item, dict) and content_item.get("refusal") is not None: + content_item["refusal"] = redacted_str if output_item.get("type") == "reasoning" and isinstance(output_item.get("summary"), list): for summary_item in output_item["summary"]: diff --git a/tests/test_litellm/litellm_core_utils/test_redact_messages.py b/tests/test_litellm/litellm_core_utils/test_redact_messages.py index c6c9a9dd2b7..276a67e0bd4 100644 --- a/tests/test_litellm/litellm_core_utils/test_redact_messages.py +++ b/tests/test_litellm/litellm_core_utils/test_redact_messages.py @@ -507,6 +507,26 @@ class TestPerformRedaction: assert redacted["output"][0]["name"] == "grep" assert redacted["output"][1]["input"] == "not-a-custom-input" + def test_redacts_responses_api_refusal_parts_dict(self): + result = { + "output": [ + { + "type": "message", + "role": "assistant", + "content": [ + {"type": "refusal", "refusal": "I cannot share the secret"}, + {"type": "output_text", "text": "ok"}, + ], + } + ] + } + + redacted = perform_redaction({}, result) + + assert redacted["output"][0]["content"][0]["refusal"] == "redacted-by-litellm" + assert redacted["output"][0]["content"][0]["type"] == "refusal" + assert redacted["output"][0]["content"][1]["text"] == "redacted-by-litellm" + def test_redacts_every_tool_call_in_multi_element_list(self): result = litellm.ModelResponse( id="resp-multi", @@ -585,6 +605,15 @@ class TestPerformRedaction: assert output_item.input == "redacted-by-litellm" assert output_item.name == "grep" + def test_redacts_responses_api_refusal_parts_object(self): + refusal = SimpleNamespace(type="refusal", refusal="I cannot share the secret") + output_item = SimpleNamespace(type="message", role="assistant", content=[refusal]) + + _redact_responses_api_output([output_item]) + + assert refusal.refusal == "redacted-by-litellm" + assert refusal.type == "refusal" + def test_redacts_response_output_objects_with_top_level_text(self): output_items = [ SimpleNamespace(text="top-level output"), From 020cbba4ddc4c336b4fd6a39de146aa1392f1e2d Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 12:39:44 -0700 Subject: [PATCH 56/73] refactor(batches): annotate the stored file row so its model import is a real use --- litellm/repositories/managed_file_content_repository.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/litellm/repositories/managed_file_content_repository.py b/litellm/repositories/managed_file_content_repository.py index 8810269279c..c55d0060080 100644 --- a/litellm/repositories/managed_file_content_repository.py +++ b/litellm/repositories/managed_file_content_repository.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Final from litellm.repositories.table_repositories import PrismaTableRepository if TYPE_CHECKING: - from prisma import models as prisma_models # noqa: F401 # used by the quoted base-class subscript + from prisma import models as prisma_models class ManagedFileContentRepository(PrismaTableRepository["prisma_models.LiteLLM_ManagedFileContentTable"]): @@ -18,7 +18,9 @@ class ManagedFileContentRepository(PrismaTableRepository["prisma_models.LiteLLM_ return row.id async def load(self, row_id: str) -> bytes | None: - row: Final = await self.table.find_unique(where={"id": row_id}) # mutable-ok: prisma filters are plain dicts + row: Final[prisma_models.LiteLLM_ManagedFileContentTable | None] = await self.table.find_unique( + where={"id": row_id} # mutable-ok: prisma filters are plain dicts + ) return None if row is None else row.content.decode() async def delete(self, row_id: str) -> None: From 5de9fc696190604b0a772f1c362d807e1e95a480 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 19 Sep 2026 12:49:17 -0700 Subject: [PATCH 57/73] test: give the new proxy_server-global patches a test-quality reason --- .../send_emails/test_endpoints.py | 8 ++++---- .../proxy/config_resolvers/test_settings_store.py | 2 +- .../test_coordination_redis_endpoints.py | 14 +++++++------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/test_litellm/enterprise/enterprise_callbacks/send_emails/test_endpoints.py b/tests/test_litellm/enterprise/enterprise_callbacks/send_emails/test_endpoints.py index 1e7492726ed..c2ae153556d 100644 --- a/tests/test_litellm/enterprise/enterprise_callbacks/send_emails/test_endpoints.py +++ b/tests/test_litellm/enterprise/enterprise_callbacks/send_emails/test_endpoints.py @@ -291,7 +291,7 @@ async def test_save_email_settings_refuses_a_config_owned_email_settings(): client = _prisma_recording_upserts(upserts) proxy_config = _proxy_config_owning({"email_settings": {EmailEvent.new_user_invitation.value: True}}) - with mock.patch("litellm.proxy.proxy_server.proxy_config", proxy_config): + with mock.patch("litellm.proxy.proxy_server.proxy_config", proxy_config): # test-quality-ok: the endpoint reads these proxy_server module globals at call time; there is no injection seam with pytest.raises(HTTPException) as refused: await _save_email_settings(client, {EmailEvent.new_user_invitation.value: False}) @@ -309,8 +309,8 @@ async def test_update_event_settings_surfaces_the_config_owned_refusal(mock_user settings=[EmailEventSettings(event=EmailEvent.virtual_key_created, enabled=True)] ) - with mock.patch("litellm.proxy.proxy_server.prisma_client", client): - with mock.patch("litellm.proxy.proxy_server.proxy_config", proxy_config): + with mock.patch("litellm.proxy.proxy_server.prisma_client", client): # test-quality-ok: the endpoint reads these proxy_server module globals at call time; there is no injection seam + with mock.patch("litellm.proxy.proxy_server.proxy_config", proxy_config): # test-quality-ok: the endpoint reads these proxy_server module globals at call time; there is no injection seam with pytest.raises(HTTPException) as refused: await update_event_settings(request=request, user_api_key_dict=mock_user_api_key_auth) @@ -325,7 +325,7 @@ async def test_save_email_settings_still_writes_when_the_config_file_is_silent() client = _prisma_recording_upserts(upserts) proxy_config = _proxy_config_owning({}) - with mock.patch("litellm.proxy.proxy_server.proxy_config", proxy_config): + with mock.patch("litellm.proxy.proxy_server.proxy_config", proxy_config): # test-quality-ok: the endpoint reads these proxy_server module globals at call time; there is no injection seam await _save_email_settings(client, {EmailEvent.new_user_invitation.value: False}) assert len(upserts) == 1 diff --git a/tests/test_litellm/proxy/config_resolvers/test_settings_store.py b/tests/test_litellm/proxy/config_resolvers/test_settings_store.py index c3e30341993..ab1bff67c42 100644 --- a/tests/test_litellm/proxy/config_resolvers/test_settings_store.py +++ b/tests/test_litellm/proxy/config_resolvers/test_settings_store.py @@ -431,7 +431,7 @@ def test_settings_store_truthiness_stops_at_the_first_key() -> None: resolutions.append(key) return original(self, key) - with patch.object(SettingsStore, "_resolution_for", counted): + with patch.object(SettingsStore, "_resolution_for", counted): # test-quality-ok: counting resolutions is the only way to observe that truthiness short-circuits assert bool(store) is True truthiness_resolutions: Final = len(resolutions) resolutions.clear() diff --git a/tests/test_litellm/proxy/management_endpoints/test_coordination_redis_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_coordination_redis_endpoints.py index dc703640768..faa8b851db4 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_coordination_redis_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_coordination_redis_endpoints.py @@ -636,9 +636,9 @@ async def test_update_refuses_a_config_owned_coordination_redis_block(monkeypatc from_file = {"coordination_redis": {"host": "yaml-redis.example.com", "port": 6379}} with ( - patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), - patch("litellm.proxy.proxy_server.proxy_config", _real_proxy_config(from_file)), - patch("litellm.proxy.proxy_server.store_model_in_db", True), + patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), # test-quality-ok: the endpoint reads these proxy_server module globals at call time; there is no injection seam + patch("litellm.proxy.proxy_server.proxy_config", _real_proxy_config(from_file)), # test-quality-ok: the endpoint reads these proxy_server module globals at call time; there is no injection seam + patch("litellm.proxy.proxy_server.store_model_in_db", True), # test-quality-ok: the endpoint reads these proxy_server module globals at call time; there is no injection seam ): with pytest.raises(HTTPException) as refused: await update_coordination_redis_settings( @@ -661,10 +661,10 @@ async def test_update_still_persists_when_the_config_file_declares_no_block(monk return None with ( - patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), - patch("litellm.proxy.proxy_server.proxy_config", _real_proxy_config({"master_key": "sk-1234"})), - patch("litellm.proxy.proxy_server.store_model_in_db", True), - patch( + patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), # test-quality-ok: the endpoint reads these proxy_server module globals at call time; there is no injection seam + patch("litellm.proxy.proxy_server.proxy_config", _real_proxy_config({"master_key": "sk-1234"})), # test-quality-ok: the endpoint reads these proxy_server module globals at call time; there is no injection seam + patch("litellm.proxy.proxy_server.store_model_in_db", True), # test-quality-ok: the endpoint reads these proxy_server module globals at call time; there is no injection seam + patch( # test-quality-ok: the endpoint reads these proxy_server module globals at call time; there is no injection seam "litellm.proxy.management_endpoints.coordination_redis_endpoints.invalidate_config_param", new=_capture_invalidate, ), From bf9c717d77105b206edbcc5aa43e5c07adcf81ac Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 19 Sep 2026 12:50:32 -0700 Subject: [PATCH 58/73] test(e2e): stop the config suite locking itself out of the shared proxy Two tests in the config/misc management suite were failing every run against the Buildkite e2e stack, and one of them took the rest of the build with it. test_add_allowed_ip_does_not_store_unrelated_config_value posted 127.0.0.1 to /add/allowed_ip. That route sets the live general_settings["allowed_ips"] that auth_utils._check_valid_ip reads before it persists anything, and the check is exact string membership with no CIDR support, so from the moment the POST returns only 127.0.0.1 can reach the proxy. The runner 403s on its very next call, and the deferred /delete/allowed_ip sits behind the same auth dependency, so the cleanup is locked out too and every later test in the build 403s. Build 254's first attempt lost 459 of its 465 failures to that one cascade. There is no safe way to exercise the route against a shared proxy: nothing reports the caller's address as the proxy sees it, so a test cannot allowlist itself first. Move the claim to the route's own TestClient suite, where the auth dependency is overridden and general_settings is per-test, and record the route in the module docstring beside /cache/settings and the Vault override so it is not re-added. save_config's end of the contract was already covered by test_ProxyConfig_save_config_merges_changed_keys_without_copying_file_settings; the new test covers the route's end, that what it hands save_config differs from the loaded config in allowed_ips and nothing else. The unrelated-key probe also only ever worked on one lane: max_parallel_requests was added to tests/e2e/gateway/stage_mirror_ci_config.yml and never to the Buildkite stack's config, where resolve() reports it as "unset" rather than "config". That key is now unused, so drop it again. test_config_update_persists_router_setting_to_get wrote router_settings. num_retries, which both lanes declare in their config file, so the config- ownership work correctly refuses it with a 400. Switch to retry_after, which is declared by neither lane, is accepted by /config/update, and is reported back by GET /router/settings. Verified against a live proxy: max_fallbacks also takes the write but never reads back, so the read-back poll is what picks the key. --- tests/e2e/coverage_registry/mgmt.yaml | 1 - tests/e2e/gateway/stage_mirror_ci_config.yml | 1 - .../test_config_misc_endpoints_e2e.py | 149 +++++------------- .../test_proxy_setting_endpoints.py | 63 ++++++++ 4 files changed, 102 insertions(+), 112 deletions(-) diff --git a/tests/e2e/coverage_registry/mgmt.yaml b/tests/e2e/coverage_registry/mgmt.yaml index 9890902fa5e..85fbd0acd91 100644 --- a/tests/e2e/coverage_registry/mgmt.yaml +++ b/tests/e2e/coverage_registry/mgmt.yaml @@ -72,7 +72,6 @@ - {id: mgmt.callback.list.happy_path, module: mgmt, tier: P2, surface: api, assertions: [happy_path], source: "callback_management_endpoints.py", rationale: "Callback config (smoke)"} - {id: mgmt.cost_tracking.estimate.happy_path, module: mgmt, tier: P2, surface: api, assertions: [happy_path], source: "cost_tracking_settings.py", rationale: "Cost estimate (smoke)"} - {id: mgmt.router_settings.update.happy_path, module: mgmt, tier: P2, surface: api, assertions: [happy_path], source: "router_settings_endpoints.py", rationale: "Router config (smoke)"} -- {id: mgmt.config.allowed_ip.changed_key_only, module: mgmt, tier: P2, surface: api, assertions: [persists], source: "proxy_setting_endpoints.py:496", rationale: "An allowed-IP change leaves unrelated file settings out of the DB row"} - {id: mgmt.jwt_key_mapping.new.happy_path, module: mgmt, tier: P2, surface: api, assertions: [happy_path], source: "jwt_key_mapping_endpoints.py", rationale: "JWT->key mapping (smoke)"} - {id: mgmt.compliance.gdpr.happy_path, module: mgmt, tier: P2, surface: api, assertions: [happy_path], source: "compliance_endpoints.py", rationale: "GDPR ops (smoke)"} - {id: mgmt.tool_management.list.happy_path, module: mgmt, tier: P2, surface: api, assertions: [happy_path], source: "tool_management_endpoints.py", rationale: "Tool inventory (smoke)"} diff --git a/tests/e2e/gateway/stage_mirror_ci_config.yml b/tests/e2e/gateway/stage_mirror_ci_config.yml index 1b6ae93f461..8c8e64443cb 100644 --- a/tests/e2e/gateway/stage_mirror_ci_config.yml +++ b/tests/e2e/gateway/stage_mirror_ci_config.yml @@ -1,5 +1,4 @@ general_settings: - max_parallel_requests: 100 proxy_batch_write_at: 5 enable_jwt_auth: true litellm_jwtauth: diff --git a/tests/e2e/management/test_config_misc_endpoints_e2e.py b/tests/e2e/management/test_config_misc_endpoints_e2e.py index 20e98e993d4..a3be0a64e7f 100644 --- a/tests/e2e/management/test_config_misc_endpoints_e2e.py +++ b/tests/e2e/management/test_config_misc_endpoints_e2e.py @@ -7,13 +7,18 @@ so a read-back reflects the change. Router settings, which mutate global proxy state, are exercised with a benign, self-restoring change so a shared proxy is left as it was found. -Cache settings and the Vault config override are deliberately not covered here. -Both routes reconfigure the whole proxy: /cache/settings persists what it receives -into a row that outranks the YAML cache_params and is re-applied on a timer, and -/config_overrides/hashicorp_vault swaps the process-wide secret manager. Neither can -be exercised safely against the shared proxy the suites run on, so they need an -isolated proxy before a test lands. Do not add a read-then-write-back test for -either one. +Cache settings, the Vault config override and the allowed-IP routes are deliberately +not covered here. All three reconfigure the whole proxy: /cache/settings persists what +it receives into a row that outranks the YAML cache_params and is re-applied on a timer, +/config_overrides/hashicorp_vault swaps the process-wide secret manager, and +/add/allowed_ip mutates the live general_settings["allowed_ips"] that +auth_utils._check_valid_ip reads, so the first call locks every other client out of the +shared proxy. The allowlist is an exact string match with no CIDR support, and no route +reports the caller's address as the proxy sees it, so a test cannot allowlist itself +first; /delete/allowed_ip sits behind the same auth dependency, so the cleanup is locked +out too and the proxy stays poisoned for the rest of the build. None of the three can be +exercised safely against the shared proxy the suites run on, so they need an isolated +proxy before a test lands. Do not add a read-then-write-back test for any of them. """ from __future__ import annotations @@ -21,10 +26,9 @@ from __future__ import annotations import math import time from collections.abc import Callable -from typing import Final import pytest -from pydantic import BaseModel, JsonValue, RootModel +from pydantic import BaseModel from e2e_config import unique_marker from e2e_http import NoBody, Success, unwrap, unwrap_status @@ -188,7 +192,7 @@ class JwtKeyMappingResponse(BaseModel): class RouterSettingsPatch(BaseModel): - num_retries: int + retry_after: int class ConfigUpdateBody(BaseModel): @@ -199,39 +203,8 @@ class ConfigUpdateResponse(BaseModel): message: str -class AllowedIpBody(BaseModel): - ip: str - - -class ConfigFieldInfoParams(BaseModel): - field_name: str - - -class ConfigFieldInfoResponse(BaseModel): - field_name: str - field_value: JsonValue - source: str - editable: bool - - -class ConfigListParams(BaseModel): - config_type: str - - -class ConfigListEntry(BaseModel): - field_name: str - field_value: JsonValue - stored_in_db: bool | None - source: str - editable: bool - - -class ConfigListResponse(RootModel[list[ConfigListEntry]]): - pass - - class RouterCurrentValues(BaseModel): - num_retries: int | None = None + retry_after: int | None = None class RouterSettingsResponse(BaseModel): @@ -493,17 +466,25 @@ class TestRouterSettings: ) -> None: """/config/update is the only write path for router_settings (there is no dedicated router-settings write route). The change is restored on teardown so - the shared proxy keeps its original retry policy.""" - original = self._read_num_retries(client) - assert original is not None, "GET /router/settings did not report num_retries; cannot prove a change" - resources.defer(lambda: self._write_num_retries(client, original)) + the shared proxy keeps its original retry policy. - target = original + 5 + retry_after is the subject because it satisfies all three constraints at once: + no lane's config file declares it, so the database owns it and the write is not + refused as config-owned; it is in RUNTIME_UPDATABLE_ROUTER_SETTINGS, so + /config/update accepts it; and it is in ROUTER_SETTINGS_FIELDS backed by an + always-set Router attribute, so GET /router/settings reports it for the + read-back. Bumping it by one second is the smallest change that proves the + round-trip without slowing a concurrent test that hits a retry.""" + original = self._read_retry_after(client) + assert original is not None, "GET /router/settings did not report retry_after; cannot prove a change" + resources.defer(lambda: self._write_retry_after(client, original)) + + target = original + 1 response = unwrap( client.proxy.transport.post( "/config/update", headers=client.proxy.transport.master, - json=ConfigUpdateBody(router_settings=RouterSettingsPatch(num_retries=target)), + json=ConfigUpdateBody(router_settings=RouterSettingsPatch(retry_after=target)), response_type=ConfigUpdateResponse, ) ) @@ -513,20 +494,20 @@ class TestRouterSettings: _ = _poll( client, - lambda: True if self._read_num_retries(client) == target else None, - f"GET /router/settings never reported num_retries {target} after /config/update", + lambda: True if self._read_retry_after(client) == target else None, + f"GET /router/settings never reported retry_after {target} after /config/update", ) - self._write_num_retries(client, original) + self._write_retry_after(client, original) restored = _poll( client, - lambda: original if self._read_num_retries(client) == original else None, - f"GET /router/settings never returned to the original num_retries {original} after the restore", + lambda: original if self._read_retry_after(client) == original else None, + f"GET /router/settings never returned to the original retry_after {original} after the restore", ) - assert restored == original, f"router num_retries left at {restored}, expected the original {original}" + assert restored == original, f"router retry_after left at {restored}, expected the original {original}" @staticmethod - def _read_num_retries(client: ManagementClient) -> int | None: + def _read_retry_after(client: ManagementClient) -> int | None: return unwrap( client.proxy.transport.get( "/router/settings", @@ -534,72 +515,20 @@ class TestRouterSettings: params=NoBody(), response_type=RouterSettingsResponse, ) - ).current_values.num_retries + ).current_values.retry_after @staticmethod - def _write_num_retries(client: ManagementClient, value: int) -> None: + def _write_retry_after(client: ManagementClient, value: int) -> None: _ = unwrap( client.proxy.transport.post( "/config/update", headers=client.proxy.transport.master, - json=ConfigUpdateBody(router_settings=RouterSettingsPatch(num_retries=value)), + json=ConfigUpdateBody(router_settings=RouterSettingsPatch(retry_after=value)), response_type=ConfigUpdateResponse, ) ) -class TestConfigPersistence: - @pytest.mark.covers("mgmt.config.allowed_ip.changed_key_only") - def test_add_allowed_ip_does_not_store_unrelated_config_value( - self, client: ManagementClient, resources: ResourceManager - ) -> None: - allowed_ip: Final = "127.0.0.1" - added: Final = unwrap( - client.proxy.transport.post( - "/add/allowed_ip", - headers=client.proxy.transport.master, - json=AllowedIpBody(ip=allowed_ip), - response_type=ConfigUpdateResponse, - ) - ) - resources.defer( - lambda: unwrap( - client.proxy.transport.post( - "/delete/allowed_ip", - headers=client.proxy.transport.master, - json=AllowedIpBody(ip=allowed_ip), - response_type=ConfigUpdateResponse, - ) - ) - ) - assert added.message == f"IP {allowed_ip} address added successfully" - - listed: Final = unwrap( - client.proxy.transport.get( - "/config/list", - headers=client.proxy.transport.master, - params=ConfigListParams(config_type="general_settings"), - response_type=ConfigListResponse, - ) - ) - unrelated: Final = next(entry for entry in listed.root if entry.field_name == "max_parallel_requests") - assert unrelated.stored_in_db is not True - assert unrelated.source == "config" - assert unrelated.editable is False - - field_info: Final = unwrap( - client.proxy.transport.get( - "/config/field/info", - headers=client.proxy.transport.master, - params=ConfigFieldInfoParams(field_name="max_parallel_requests"), - response_type=ConfigFieldInfoResponse, - ) - ) - assert field_info.source == "config" - assert field_info.editable is False - assert field_info.field_value == unrelated.field_value - - class TestMcpServerSubmission: @pytest.mark.covers("mgmt.mcp_server.register.happy_path") def test_register_submits_pending_server(self, client: ManagementClient, resources: ResourceManager) -> None: diff --git a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py index 58201bd14ce..b01544e54e3 100644 --- a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py +++ b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py @@ -2604,6 +2604,69 @@ def test_add_allowed_ip_writes_audit_log(mock_proxy_config, monkeypatch): app.dependency_overrides.pop(user_api_key_auth, None) +def test_add_allowed_ip_hands_save_config_only_the_changed_general_setting(monkeypatch): + """An allowed-IP write must not drag the config file's own general_settings into + the database row. This covers the route end of that contract: what /add/allowed_ip + hands save_config differs from the loaded config in allowed_ips and nothing else. + save_config's end -- that the row it writes holds only those changed keys -- is + covered by test_ProxyConfig_save_config_merges_changed_keys_without_copying_file_settings. + + This lives here rather than in the e2e suite because /add/allowed_ip mutates the + live general_settings["allowed_ips"] that auth_utils._check_valid_ip reads, so on a + shared proxy the first call locks every later request out, cleanup included. + """ + from copy import deepcopy + from unittest.mock import AsyncMock, MagicMock + + import litellm.proxy.proxy_server as proxy_server_module + from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + from litellm.proxy.config_resolvers.changed_section_keys import changed_section_keys + from litellm.proxy.config_resolvers.settings_store import SettingsStore + + file_settings = {"max_parallel_requests": 100, "proxy_config_reload_interval_seconds": 7} + store = SettingsStore("general_settings") + store.load_yaml(file_settings) + saved = [] + + fake_prisma = MagicMock() + fake_prisma.db.litellm_auditlog.create = AsyncMock() + + async def _get_config(): + return {"general_settings": deepcopy(file_settings)} + + async def _save_config(new_config=None): + saved.append(new_config) + return new_config + + monkeypatch.setattr(proxy_server_module, "prisma_client", fake_prisma) + monkeypatch.setattr(proxy_server_module, "store_model_in_db", True) + monkeypatch.setattr(proxy_server_module, "premium_user", True) + monkeypatch.setattr(proxy_server_module, "general_settings", store) + monkeypatch.setattr(proxy_server_module.proxy_config, "get_config", _get_config) + monkeypatch.setattr(proxy_server_module.proxy_config, "save_config", _save_config) + + async def _admin_auth(): + return UserAPIKeyAuth( + user_id="config-admin", + api_key="hashed-admin-key", + user_role=LitellmUserRoles.PROXY_ADMIN, + ) + + app.dependency_overrides[user_api_key_auth] = _admin_auth + try: + resp = client.post("/add/allowed_ip", json={"ip": "203.0.113.77"}) + assert resp.status_code == 200, resp.text + + assert len(saved) == 1, f"expected exactly one save_config call, got {len(saved)}" + changed, removed = changed_section_keys(file_settings, saved[0]["general_settings"]) + assert dict(changed) == {"allowed_ips": ["203.0.113.77"]} + assert removed == frozenset() + assert store["allowed_ips"] == ["203.0.113.77"] + finally: + app.dependency_overrides.pop(user_api_key_auth, None) + + def test_delete_allowed_ip_writes_deleted_audit_log(monkeypatch): """Removing an allowed IP must be audited as a deletion, symmetric with the add path.""" From b7bab56d4d8144cfcd764bd052353094f2410627 Mon Sep 17 00:00:00 2001 From: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 12:52:28 -0700 Subject: [PATCH 59/73] test(e2e): report safe OAuth failure locations --- .github/e2e-stack/assert_tests_ran.py | 8 ++++++ .../test_e2e_changed_gate.py | 28 +++++++++++++++++++ tests/e2e/conftest.py | 8 ++++++ 3 files changed, 44 insertions(+) diff --git a/.github/e2e-stack/assert_tests_ran.py b/.github/e2e-stack/assert_tests_ran.py index bc299b14af8..1b051f860cc 100644 --- a/.github/e2e-stack/assert_tests_ran.py +++ b/.github/e2e-stack/assert_tests_ran.py @@ -1,4 +1,5 @@ import os +import re import sys import xml.etree.ElementTree as ET from pathlib import Path @@ -45,6 +46,13 @@ def main() -> int: if case.get("file") != path or all(case.find(tag) is None for tag in ("failure", "error")): continue _ = sys.stdout.write(f" failed: {case.get('classname', '')}::{case.get('name', '')}\n") + for prop in case.findall("./properties/property"): + name = prop.get("name", "") + value = prop.get("value", "") + if name in ("oauth_failure_phase", "oauth_exception_type", "oauth_frame") and re.fullmatch( + r"[A-Za-z0-9_.:<>-]{1,240}", value + ): + _ = sys.stdout.write(f" {name}: {value}\n") if ( selected and not missing diff --git a/tests/code_coverage_tests/test_e2e_changed_gate.py b/tests/code_coverage_tests/test_e2e_changed_gate.py index 5ae0863baf0..d14f007403b 100644 --- a/tests/code_coverage_tests/test_e2e_changed_gate.py +++ b/tests/code_coverage_tests/test_e2e_changed_gate.py @@ -226,3 +226,31 @@ def test_an_unusable_secret_is_named_without_printing_its_value( assert unprintable not in result.stderr assert result.stdout == "" assert not env_path.exists() + + +@pytest.mark.parametrize("phase", ("setup", "call", "teardown")) +def test_oauth_failure_diagnostics_do_not_publish_private_payloads(tmp_path: Path, phase: str) -> None: + suite: Final = ET.Element("testsuite") + case: Final = ET.SubElement(suite, "testcase", file=SELECTED[0]) + private: Final = "private-token-in-exception-message" + failure: Final = ET.SubElement(case, "failure", message=private) + failure.text = private + properties: Final = ET.SubElement(case, "properties") + for name, value in ( + ("oauth_failure_phase", phase), + ("oauth_exception_type", "AssertionError"), + ("oauth_frame", "oauth_gateway.py:120:start"), + ("oauth_frame", f"injected\\n{private}"), + ("unrelated_property", private), + ): + _ = ET.SubElement(properties, "property", name=name, value=value) + report: Final = tmp_path / "report.xml" + ET.ElementTree(suite).write(report) + result: Final = subprocess.run( + [sys.executable, str(GATE), str(report), SELECTED[0]], capture_output=True, text=True + ) + assert result.returncode == 1 + assert f"oauth_failure_phase: {phase}" in result.stdout + assert "oauth_exception_type: AssertionError" in result.stdout + assert "oauth_frame: oauth_gateway.py:120:start" in result.stdout + assert private not in result.stdout + result.stderr diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 268d517a7fe..b0904e39a1f 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -17,6 +17,7 @@ import functools import os from collections.abc import Generator, Iterator from datetime import datetime, timezone +from pathlib import Path from types import MappingProxyType from typing import Final @@ -245,6 +246,13 @@ def pytest_runtest_makereport( """Stash the call-phase outcome so teardown can tell a passed test from a failed one without re-deriving it.""" report = yield + if item.get_closest_marker("mcp_oauth_live") is not None and call.excinfo is not None: + # Publish code locations only, never exception messages, source text or locals. + item.user_properties.append(("oauth_failure_phase", report.when)) + item.user_properties.append(("oauth_exception_type", call.excinfo.type.__name__)) + for entry in call.excinfo.traceback: + item.user_properties.append(("oauth_frame", f"{Path(entry.path).name}:{entry.lineno + 1}:{entry.name}")) + report.user_properties = list(item.user_properties) if report.when == "call": item.stash[_CALL_PASSED] = report.passed return report From 9075cafb98e3b22c0bedce288217039ce3058698 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 12:55:14 -0700 Subject: [PATCH 60/73] fix(auth): serve the last-known org through a database outage A JWT whose team sits in an org resolves the org on every request, and the org row is cached for only DEFAULT_IN_MEMORY_TTL seconds while the team and user rows ride the 60s management-object TTL. A few seconds into a database outage the org lookup failed closed and that traffic got 503s while the same request through a virtual key kept succeeding on its cached team. get_org_object now also keeps a last-known copy of the org row under the management-object TTL, and get_org_object_for_request serves that copy when the database is unreachable, so JWT traffic degrades the same way the team lookup does. A missing copy keeps the previous behaviour: fail closed unless allow_requests_on_db_unavailable is set. --- litellm/proxy/auth/auth_checks.py | 30 +++++++++--- .../proxy/auth/test_auth_checks.py | 48 +++++++++++++++++++ 2 files changed, 71 insertions(+), 7 deletions(-) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index c92d8a1a543..161a91f648d 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -4008,10 +4008,21 @@ async def get_org_object( model_type=LiteLLM_OrganizationTable, ttl=DEFAULT_IN_MEMORY_TTL, ) + if include_budget_table: + await user_api_key_cache.async_set_cache( + key=_last_known_org_cache_key(org_id), + value=_org_obj, + model_type=LiteLLM_OrganizationTable, + ttl=get_management_object_ttl(user_api_key_cache), + ) return _org_obj +def _last_known_org_cache_key(org_id: str) -> str: + return f"org_id:{org_id}:with_budget:last_known" + + async def get_org_object_for_request( org_id: str, prisma_client: PrismaClient, @@ -4031,13 +4042,18 @@ async def get_org_object_for_request( except OrganizationNotFoundError: return None except Exception as e: # noqa: BLE001 # only a DB outage may fail auth here, anything else degrades to no org limits - if ( - PrismaDBExceptionHandler.is_database_service_unavailable_error_in_chain(e) - and not PrismaDBExceptionHandler.should_allow_request_on_db_unavailable() - ): - raise - verbose_proxy_logger.debug("org lookup failed, continuing without org limits", exc_info=True) - return None + if not PrismaDBExceptionHandler.is_database_service_unavailable_error_in_chain(e): + verbose_proxy_logger.debug("org lookup failed, continuing without org limits", exc_info=True) + return None + last_known_org: Final = await user_api_key_cache.async_get_cache( + key=_last_known_org_cache_key(org_id), + model_type=LiteLLM_OrganizationTable, + ) + if last_known_org is not None: + return last_known_org + if PrismaDBExceptionHandler.should_allow_request_on_db_unavailable(): + return None + raise async def _get_resources_from_access_groups( diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index 1ae986db23b..08764ad5b18 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -6087,6 +6087,54 @@ async def test_organization_budget_check_carries_org_state_on_the_token(): assert token.org_budget_snapshot == OrgBudgetSnapshot(spend=12.5, max_budget=100.0) +@pytest.mark.asyncio +async def test_get_org_object_for_request_serves_last_known_org_through_db_outage(): + """A JWT whose team sits in an org resolves the org on every request, and the org row + is cached for only DEFAULT_IN_MEMORY_TTL seconds while the team and user rows ride the + 60s management-object TTL. Without a last-known copy, a DB outage a few seconds old + turned that traffic into 503s while the same request through a virtual key kept + succeeding on its cached team.""" + from litellm.proxy.auth.auth_checks import get_org_object_for_request + + org_row = MagicMock() + org_row.model_dump = lambda: { + "organization_id": "org-1", + "organization_alias": "platform-org", + "budget_id": "b1", + "created_by": "admin", + "updated_by": "admin", + "litellm_budget_table": {"budget_id": "b1", "max_budget": 50.0, "tpm_limit": 700, "rpm_limit": 7}, + } + prisma_client = MagicMock() + prisma_client.db.litellm_organizationtable.find_unique = AsyncMock( + side_effect=[org_row, ConnectionRefusedError("db unavailable")] + ) + user_api_key_cache = UserApiKeyCache() + + async def _lookup(): + return await get_org_object_for_request( + org_id="org-1", + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=None, + proxy_logging_obj=None, + ) + + with patch("litellm.proxy.proxy_server.general_settings", {}): # test-quality-ok: the outage fallback reads this module global; no dependency injection seam exists + warm = await _lookup() + assert warm is not None and warm.organization_alias == "platform-org" + await user_api_key_cache.async_delete_cache("org_id:org-1:with_budget") + + during_outage = await _lookup() + + assert prisma_client.db.litellm_organizationtable.find_unique.await_count == 2 + assert during_outage is not None + assert during_outage.organization_alias == "platform-org" + assert during_outage.litellm_budget_table is not None + assert during_outage.litellm_budget_table.rpm_limit == 7 + assert during_outage.litellm_budget_table.max_budget == 50.0 + + @pytest.mark.parametrize( "max_budget, spend, expect_blocked", [ From 3c6a2f258a8017425fc0d53587c9b97daf3133c0 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 19 Sep 2026 12:56:51 -0700 Subject: [PATCH 61/73] test(proxy): capture the saved config with an AsyncMock instead of a mutable list Greptile flagged the unannotated list and append against the repository's immutable-state and Final-local rules (LIT001/LIT010). Recording the call on an AsyncMock removes the accumulator entirely and matches how the neighbouring audit-log tests in this file read their captured arguments. --- .../test_proxy_setting_endpoints.py | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py index b01544e54e3..47bb1ad5a81 100644 --- a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py +++ b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py @@ -2615,7 +2615,8 @@ def test_add_allowed_ip_hands_save_config_only_the_changed_general_setting(monke live general_settings["allowed_ips"] that auth_utils._check_valid_ip reads, so on a shared proxy the first call locks every later request out, cleanup included. """ - from copy import deepcopy + from types import MappingProxyType + from typing import Final from unittest.mock import AsyncMock, MagicMock import litellm.proxy.proxy_server as proxy_server_module @@ -2624,27 +2625,23 @@ def test_add_allowed_ip_hands_save_config_only_the_changed_general_setting(monke from litellm.proxy.config_resolvers.changed_section_keys import changed_section_keys from litellm.proxy.config_resolvers.settings_store import SettingsStore - file_settings = {"max_parallel_requests": 100, "proxy_config_reload_interval_seconds": 7} - store = SettingsStore("general_settings") + file_settings: Final = MappingProxyType({"max_parallel_requests": 100, "proxy_config_reload_interval_seconds": 7}) + store: Final = SettingsStore("general_settings") store.load_yaml(file_settings) - saved = [] - fake_prisma = MagicMock() + fake_prisma: Final = MagicMock() fake_prisma.db.litellm_auditlog.create = AsyncMock() + save_config: Final = AsyncMock(side_effect=lambda new_config: new_config) async def _get_config(): - return {"general_settings": deepcopy(file_settings)} - - async def _save_config(new_config=None): - saved.append(new_config) - return new_config + return {"general_settings": dict(file_settings)} monkeypatch.setattr(proxy_server_module, "prisma_client", fake_prisma) monkeypatch.setattr(proxy_server_module, "store_model_in_db", True) monkeypatch.setattr(proxy_server_module, "premium_user", True) monkeypatch.setattr(proxy_server_module, "general_settings", store) monkeypatch.setattr(proxy_server_module.proxy_config, "get_config", _get_config) - monkeypatch.setattr(proxy_server_module.proxy_config, "save_config", _save_config) + monkeypatch.setattr(proxy_server_module.proxy_config, "save_config", save_config) async def _admin_auth(): return UserAPIKeyAuth( @@ -2655,11 +2652,12 @@ def test_add_allowed_ip_hands_save_config_only_the_changed_general_setting(monke app.dependency_overrides[user_api_key_auth] = _admin_auth try: - resp = client.post("/add/allowed_ip", json={"ip": "203.0.113.77"}) + resp: Final = client.post("/add/allowed_ip", json={"ip": "203.0.113.77"}) assert resp.status_code == 200, resp.text - assert len(saved) == 1, f"expected exactly one save_config call, got {len(saved)}" - changed, removed = changed_section_keys(file_settings, saved[0]["general_settings"]) + save_config.assert_awaited_once() + persisted: Final = save_config.await_args.kwargs["new_config"]["general_settings"] + changed, removed = changed_section_keys(file_settings, persisted) assert dict(changed) == {"allowed_ips": ["203.0.113.77"]} assert removed == frozenset() assert store["allowed_ips"] == ["203.0.113.77"] From a41b60cf776a40d844d8cf7a356e8c3046c45b49 Mon Sep 17 00:00:00 2001 From: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 13:03:00 -0700 Subject: [PATCH 62/73] test(mcp): align live regressions with discovery and error contracts --- tests/integration/contracts.json | 9 +- tests/integration/mcp/README.md | 4 +- tests/integration/mcp/test_mcp_lifecycle.py | 104 ++++++++++++------ .../mcp/test_oauth_configuration.py | 3 +- 4 files changed, 81 insertions(+), 39 deletions(-) diff --git a/tests/integration/contracts.json b/tests/integration/contracts.json index b370b577c9b..3f1ecab3489 100644 --- a/tests/integration/contracts.json +++ b/tests/integration/contracts.json @@ -1321,14 +1321,17 @@ "tests/integration/observability/test_guardrail_effects.py::test_request_selected_mcp_guardrail_blocks_direct_and_virtual_calls": [ "other.mcp.guardrails.request_selection_blocks_resolved_tool_without_execution" ], - "tests/integration/mcp/test_mcp_lifecycle.py::test_same_url_server_grants_scope_discovery_and_direct_or_virtual_execution": [ - "other.mcp.permissions.same_url_servers_enforce_discovery_and_execution" - ], "tests/integration/mcp/test_oauth_configuration.py::test_same_url_oauth_credentials_and_revocation_are_isolated_by_user_and_server[revoke]": [ "other.mcp.oauth.same_url_credentials_are_isolated_by_user_and_server" ], "tests/integration/mcp/test_oauth_configuration.py::test_same_url_oauth_credentials_and_revocation_are_isolated_by_user_and_server[expire]": [ "other.mcp.oauth.same_url_credentials_are_isolated_by_user_and_server" + ], + "tests/integration/mcp/test_mcp_lifecycle.py::test_same_url_server_grants_scope_discovery_and_direct_or_virtual_execution[anonymous]": [ + "other.mcp.permissions.same_url_servers_enforce_discovery_and_execution" + ], + "tests/integration/mcp/test_mcp_lifecycle.py::test_same_url_server_grants_scope_discovery_and_direct_or_virtual_execution[bearer]": [ + "other.mcp.permissions.same_url_servers_enforce_discovery_and_execution" ] }, "browser": { diff --git a/tests/integration/mcp/README.md b/tests/integration/mcp/README.md index 870176e8196..6260d128a2c 100644 --- a/tests/integration/mcp/README.md +++ b/tests/integration/mcp/README.md @@ -9,12 +9,12 @@ Run the controlled gateway cases through `python tests/integration/run.py extens | 1. Discovery scoped by org/team/user/key | `test_mcp_lifecycle.py` checks the exact key-granted catalog and health visibility in both management modes. [PR #38680](https://github.com/BerriAI/litellm/pull/38680) adds team/org/user toolset E2E assertions | Per-principal native MCP coverage is not established by REST results; reuse #38680 rather than duplicate it | | 2. Users cannot attach unauthorized servers to their own keys | Existing live probes are recorded on LIT-4506; they are not durable endpoint regression tests | Own-key create/update escalation and its permission-validator boundary remain on existing management security tickets, including [LIT-4502](https://linear.app/litellm-ai/issue/LIT-4502). A generic route denial does not prove that validator ran | | 3. UI/API permission parity | Existing dashboard tests cover admin operations | The same non-admin actor must be tested through browser and API; admin UI tests do not establish parity. Retained with [LIT-3644](https://linear.app/litellm-ai/issue/LIT-3644) | -| 4. Server ID determines identity | `test_mcp_lifecycle.py` grants one of two servers sharing a URL and denies explicit calls to the other, through direct and virtual REST execution | Duplicate aliases/names and unprefixed protocol routing remain with [LIT-4500](https://linear.app/litellm-ai/issue/LIT-4500) | +| 4. Server ID determines identity | `test_mcp_lifecycle.py` grants one of two servers sharing a URL and denies calls to the other, using explicit server IDs for direct REST calls and server-qualified search results for virtual calls, with and without bearer credentials | Virtual calls identify the target by the searched tool name, not the REST `server_id` field. Bare names such as `add` are ambiguous across servers; duplicate aliases/names and unprefixed protocol routing remain with [LIT-4500](https://linear.app/litellm-ai/issue/LIT-4500) | | 5. Same-URL servers do not share credentials | `test_oauth_configuration.py` crosses two gateway users with two server IDs and four distinct stored OAuth tokens. It checks actual upstream headers and successful results, then invalidates only one tuple | Controlled stored-token tests do not prove separate external-provider accounts or consent flows | | 6. OAuth never falls back to anonymous | OAuth isolation variants remove a stored token or expire it without refresh, require separate list/call auth failures and no upstream requests, and preserve all other valid tuples. `test_mcp_lifecycle.py` also covers warm static-header removal and OBO without a caller JWT | External upstream revocation, refresh/reauthorization and aggregate challenges remain with [LIT-4501](https://linear.app/litellm-ai/issue/LIT-4501), [LIT-3433](https://linear.app/litellm-ai/issue/LIT-3433), [LIT-4422](https://linear.app/litellm-ai/issue/LIT-4422) and [LIT-4436](https://linear.app/litellm-ai/issue/LIT-4436) | | 7. Stateful HTTP/session continuity | Legacy public-client tests exercise initialized sessions | No claim here proves upstream session state continuity; retained under [LIT-3143](https://linear.app/litellm-ai/issue/LIT-3143) | | 8. Production guardrails/hooks run | `../observability/test_guardrail_effects.py` checks selected pre-call guards on direct and virtual execution, key/team/request selection, allowed results and zero denied executions. The two legacy test-owned dispatcher files are removed | This does not establish every post-call/output-scanning or concurrent hook contract | -| 9. Permissions enforced at discovery and execution | Exact key catalog plus forbidden direct/virtual calls in `test_mcp_lifecycle.py`; existing `../compatibility/test_persisted_toolsets.py` checks tool-level ceiling, denied sibling and allowed control | All principal/transport combinations are not established; link #38680's evidence for its additional principal cases | +| 9. Permissions enforced at discovery and execution | Exact key catalog and virtual search results plus forbidden direct/virtual calls in `test_mcp_lifecycle.py`; existing `../compatibility/test_persisted_toolsets.py` checks tool-level ceiling, denied sibling and allowed control | All principal/transport combinations are not established; link #38680's evidence for its additional principal cases | | 10. Stateless/stateful matrix | These controlled peers use stateless HTTP upstreams | Stateful combinations depend on LIT-3143 and shared conformance runs. Modern-agent/legacy-upstream interaction remains deferred; legacy passes do not establish modern conformance | ## Additional JWT/OAuth acceptance diff --git a/tests/integration/mcp/test_mcp_lifecycle.py b/tests/integration/mcp/test_mcp_lifecycle.py index 0e29959bac7..b32cf97605f 100644 --- a/tests/integration/mcp/test_mcp_lifecycle.py +++ b/tests/integration/mcp/test_mcp_lifecycle.py @@ -1,3 +1,4 @@ +import json import uuid from contextlib import ExitStack from pathlib import Path @@ -56,7 +57,7 @@ def test_tool_error_remains_error_and_healthy_sibling_returns_value(gateway: Gat failure: Final = call_tool(gateway, key, identity, names["fail"], {}) assert failure.status_code == 200, failure.text assert failure.json()["isError"] is True - assert "synthetic tool failure" in failure.json()["content"][0]["text"] + assert failure.json()["content"][0]["text"] == "Error executing tool fail" healthy: Final = call_tool(gateway, key, identity, names["multiply"], {"a": 3, "b": 5}) assert healthy.status_code == 200, healthy.text assert healthy.json()["isError"] is False @@ -199,7 +200,11 @@ def test_warm_credential_removal_rejects_without_upstream_traffic(gateway: Gatew else call_tool(gateway, key, identity, names["add"], {"a": 3, "b": 5}) ) assert rejected.status_code == 500, rejected.text - assert "requires a usable upstream credential" in rejected.text, rejected.text + if operation == "list": + assert rejected.json()["detail"]["error"] == "internal", rejected.text + assert "Failed to list tools from server" in rejected.json()["detail"]["message"], rejected.text + else: + assert "requires a usable upstream credential" in rejected.text, rejected.text assert peer.drain() == (), "missing static credential escaped to upstream" changed = gateway.request( "PUT", @@ -223,46 +228,79 @@ def test_warm_credential_removal_rejects_without_upstream_traffic(gateway: Gatew assert control.status_code == 200 and control.json()["content"][0]["text"] == "15", control.text +@pytest.mark.parametrize("authenticated", (False, True), ids=("anonymous", "bearer")) @pytest.mark.covers("other.mcp.permissions.same_url_servers_enforce_discovery_and_execution") -def test_same_url_server_grants_scope_discovery_and_direct_or_virtual_execution(gateway: Gateway) -> None: +def test_same_url_server_grants_scope_discovery_and_direct_or_virtual_execution( + gateway: Gateway, authenticated: bool +) -> None: with mcp_peer() as peer, gateway.scenario() as scenario: - allowed: Final = register_mcp(scenario, peer, "allowed" + uuid.uuid4().hex) - forbidden: Final = register_mcp(scenario, peer, "forbidden" + uuid.uuid4().hex) - caller: Final = scenario.key(object_permission={"mcp_servers": [allowed], "mcp_tool_search_enabled": True}) - control: Final = scenario.key(object_permission={"mcp_servers": [forbidden], "mcp_tool_search_enabled": True}) - allowed_names: Final = tool_names(gateway, caller, allowed) - forbidden_names: Final = tool_names(gateway, control, forbidden) - catalog: Final = gateway.request("GET", "/mcp-rest/tools/list", key=caller) - assert catalog.status_code == 200, catalog.text - assert {tool["mcp_info"]["server_id"] for tool in catalog.json()["tools"]} == {allowed} - assert {tool["name"] for tool in catalog.json()["tools"]} == set(allowed_names.values()) + aliases: Final = tuple("scope" + uuid.uuid4().hex for _ in range(2)) + servers: Final = tuple( + register_mcp( + scenario, + peer, + alias, + auth_type="bearer_token" if authenticated else "none", + static_headers={ + "X-Integration-Server": alias, + **({"Authorization": f"Bearer synthetic-{alias}"} if authenticated else {}), + }, + ) + for alias in aliases + ) for virtual in (False, True): - for server_id, names, key, expected in ( - (allowed, allowed_names, caller, 200), - (forbidden, forbidden_names, caller, 403), - (forbidden, forbidden_names, control, 200), - ): + keys: Final = tuple( + scenario.key(object_permission={"mcp_servers": [server], "mcp_tool_search_enabled": virtual}) + for server in servers + ) + for server, alias, key in zip(servers, aliases, keys): + catalog: Final = gateway.request("GET", "/mcp-rest/tools/list", key=key) + assert catalog.status_code == 200, catalog.text + if virtual: + assert {tool["name"] for tool in catalog.json()["tools"]} == { + "mcp_tool_search", + "mcp_tool_call", + "agent_search", + "skill_search", + }, catalog.text + search: Final = gateway.request( + "POST", + "/mcp-rest/tools/call", + {"name": "mcp_tool_search", "arguments": {"query": "add", "top_k": 10}}, + key=key, + ) + assert search.status_code == 200 and search.json()["isError"] is False, search.text + assert [tool["name"] for tool in json.loads(search.json()["content"][0]["text"])] == [ + f"{alias}-add" + ], search.text + else: + assert {tool["mcp_info"]["server_id"] for tool in catalog.json()["tools"]} == {server} + assert {tool["name"] for tool in catalog.json()["tools"]} == {"add", "multiply", "fail"} + for server_index, caller_index in ((0, 0), (1, 0), (1, 1)): peer.drain() response: Final = gateway.request( "POST", "/mcp-rest/tools/call", { - "server_id": server_id, - "name": "mcp_tool_call" if virtual else names["add"], + "name": "mcp_tool_call" if virtual else "add", + **({} if virtual else {"server_id": servers[server_index]}), "arguments": ( - {"tool_name": names["add"], "arguments": {"a": 3, "b": 5}} if virtual else {"a": 3, "b": 5} + {"tool_name": f"{aliases[server_index]}-add", "arguments": {"a": 3, "b": 5}} + if virtual + else {"a": 3, "b": 5} ), }, - key=key, + key=keys[caller_index], ) - assert response.status_code == expected, response.text - calls: Final = tuple(item for item in peer.drain() if item["body"].get("method") == "tools/call") - if expected == 403: - assert "access" in response.text.lower(), response.text - assert calls == (), "a denied server must not execute through either route" - else: - assert response.json()["isError"] is False, response.text - assert response.json()["content"][0]["text"] == "8", response.text - assert len(calls) == 1 - assert calls[0]["body"]["params"]["name"] == "add" - assert calls[0]["body"]["params"]["arguments"] == {"a": 3, "b": 5} + observed: Final = peer.drain() + if server_index != caller_index: + assert response.status_code == 403 and "not allowed" in response.text, response.text + assert observed == (), "forbidden server reached the upstream" + continue + assert response.status_code == 200 and response.json()["isError"] is False, response.text + assert response.json()["content"][0]["text"] == "8", response.text + calls: Final = tuple(item for item in observed if item["body"].get("method") == "tools/call") + assert len(calls) == 1 + assert calls[0]["headers"][b"x-integration-server"] == aliases[server_index].encode() + expected_auth: Final = f"Bearer synthetic-{aliases[server_index]}".encode() if authenticated else None + assert all(item["headers"].get(b"authorization") == expected_auth for item in observed) diff --git a/tests/integration/mcp/test_oauth_configuration.py b/tests/integration/mcp/test_oauth_configuration.py index fbef9e8fed9..4c46c706054 100644 --- a/tests/integration/mcp/test_oauth_configuration.py +++ b/tests/integration/mcp/test_oauth_configuration.py @@ -159,7 +159,8 @@ def test_same_url_oauth_credentials_and_revocation_are_isolated_by_user_and_serv if generation == 1 and user_index == 0 and server_index == 0: for rejected in (discovery, call): assert rejected.status_code == 401, rejected.text - assert "uthorization required" in rejected.text, rejected.text + assert rejected.json() == {"detail": "Unauthorized"}, rejected.text + assert "resource_metadata=" in rejected.headers["www-authenticate"] assert observed == (), "unusable credentials must not fall back to another user or server" else: assert discovery.status_code == 200, discovery.text From 549548de62454448b1b89ea3b362f9d0e980ee3d Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 13:20:02 -0700 Subject: [PATCH 63/73] fix(files): keep an explicit target_storage on its old path and refuse litellm_db as a caller choice An explicit target_storage=litellm_db upload was accepted for any model, so an OpenAI model's litellm_db:// id was sent to OpenAI as input_file_id and a model-less upload left a content row nothing can read; it now answers 400 on target_storage. An explicit target_storage skips the files api probe and the purpose and single-target gates, which only decide whether LiteLLM keeps the file itself, so an azure_storage user_data upload for a vLLM model reaches the storage path again as it did before this branch. cancel_batch authorizes the model of every LiteLLM-managed batch id before it branches, the way retrieve_batch already does, so the LiteLLM-executed branch gets the check its provider sibling had. Restores the test_afile_delete_passes_trusted_model_credentials_to_router definition line an earlier commit dropped --- litellm/proxy/batches_endpoints/endpoints.py | 16 ++++-- .../openai_files_endpoints/files_endpoints.py | 20 ++++++- .../proxy/test_managed_files_hook.py | 4 ++ .../proxy/batches_endpoints/test_endpoints.py | 13 +++++ .../test_files_endpoint.py | 57 +++++++++++++++++++ 5 files changed, 102 insertions(+), 8 deletions(-) diff --git a/litellm/proxy/batches_endpoints/endpoints.py b/litellm/proxy/batches_endpoints/endpoints.py index 3f6c9f4d6ed..6d5f7a65855 100644 --- a/litellm/proxy/batches_endpoints/endpoints.py +++ b/litellm/proxy/batches_endpoints/endpoints.py @@ -1093,6 +1093,17 @@ async def cancel_batch( proxy_config=proxy_config, ) + unified_model_id: Final = get_model_id_from_unified_batch_id(unified_batch_id) if unified_batch_id else None + if unified_model_id is not None: + resolved_unified_model: Final = ( + llm_router.resolve_model_name_from_model_id(unified_model_id) if llm_router is not None else None + ) + await authorize_model_for_key( + model_id=resolved_unified_model or unified_model_id, + llm_router=llm_router, + user_api_key_dict=user_api_key_dict, + ) + # SCENARIO 1: Batch ID is encoded with model info if model_from_id is not None: credentials: Final = await get_authorized_credentials_for_model( @@ -1143,11 +1154,6 @@ async def cancel_batch( status_code=400, detail={"error": "Invalid LiteLLM managed batch ID. Missing model_id."}, ) - await authorize_model_for_key( - model_id=llm_router.resolve_model_name_from_model_id(model_id_from_batch) or model_id_from_batch, - llm_router=llm_router, - user_api_key_dict=user_api_key_dict, - ) data["model"] = model_id_from_batch data["batch_id"] = get_batch_id_from_unified_batch_id(unified_batch_id) response = await llm_router.acancel_batch(**data) diff --git a/litellm/proxy/openai_files_endpoints/files_endpoints.py b/litellm/proxy/openai_files_endpoints/files_endpoints.py index a5921cc6380..9f12b6faa61 100644 --- a/litellm/proxy/openai_files_endpoints/files_endpoints.py +++ b/litellm/proxy/openai_files_endpoints/files_endpoints.py @@ -117,6 +117,7 @@ async def _litellm_executed_batch_input_model( model: str | None, target_model_names_list: Sequence[str], user_api_key_dict: UserAPIKeyAuth, + explicit_storage: str | None, ) -> str | None: if llm_router is None: return None @@ -129,6 +130,8 @@ async def _litellm_executed_batch_input_model( if _names_a_litellm_executed_provider(llm_router, candidate, team_id) ) ) + if explicit_storage is not None: + return None providers: Final = await asyncio.gather( *(resolve_litellm_executed_provider(llm_router, candidate, team_id) for candidate in candidates) ) @@ -305,10 +308,21 @@ async def route_create_file( 5. Else -> use custom_llm_provider with files_settings """ - executed_model: Final = await _litellm_executed_batch_input_model( - llm_router, purpose, model, target_model_names_list, user_api_key_dict - ) explicit_storage: Final = target_storage if target_storage and target_storage != "default" else None + if explicit_storage == LITELLM_DB_STORAGE_BACKEND_NAME: + raise ProxyException( + message=( + f"target_storage={LITELLM_DB_STORAGE_BACKEND_NAME} is not a storage a caller can pick: LiteLLM " + "chooses it on its own for the batch input files of a model whose batches it runs itself, so " + "upload with purpose=batch and name that model instead of target_storage" + ), + type="invalid_request_error", + param="target_storage", + code=400, + ) + executed_model: Final = await _litellm_executed_batch_input_model( + llm_router, purpose, model, target_model_names_list, user_api_key_dict, explicit_storage + ) storage: Final = explicit_storage or (LITELLM_DB_STORAGE_BACKEND_NAME if executed_model is not None else None) if storage is not None: from litellm.litellm_core_utils.prompt_templates.common_utils import ( diff --git a/tests/test_litellm/enterprise/proxy/test_managed_files_hook.py b/tests/test_litellm/enterprise/proxy/test_managed_files_hook.py index 0ae4e3a5fd8..419a460d098 100644 --- a/tests/test_litellm/enterprise/proxy/test_managed_files_hook.py +++ b/tests/test_litellm/enterprise/proxy/test_managed_files_hook.py @@ -1764,6 +1764,10 @@ async def test_post_call_hook_leaves_litellm_executed_batches_untouched(llm_batc assert managed_files.store_unified_object_id.await_count == (1 if stores else 0) if not stores: assert response.id == original_id + + +@pytest.mark.asyncio +async def test_afile_delete_passes_trusted_model_credentials_to_router(): """ afile_delete must hand the deployment's credential snapshot to the router call, since Bedrock validates the s3:// file id against the bucket in it. diff --git a/tests/test_litellm/proxy/batches_endpoints/test_endpoints.py b/tests/test_litellm/proxy/batches_endpoints/test_endpoints.py index c2101abd350..8571ff20e57 100644 --- a/tests/test_litellm/proxy/batches_endpoints/test_endpoints.py +++ b/tests/test_litellm/proxy/batches_endpoints/test_endpoints.py @@ -3220,3 +3220,16 @@ async def test_cancel__unified_batch_id_rejects_key_without_model_grant(cancel_h assert exc_info.value.code == "403" cancel_harness.router_acancel.assert_not_called() + + +@pytest.mark.asyncio +async def test_cancel__executed_batch_rejects_key_without_model_grant(cancel_harness, executed_runner): + runner, factory = executed_runner + + with pytest.raises(ProxyException) as exc_info: + await call_cancel(cancel_harness, EXECUTED_BATCH_B64, user=_key_restricted_to("vertex-model")) + + assert exc_info.value.code == "403" + factory.assert_not_called() + runner.cancel.assert_not_called() + cancel_harness.router_acancel.assert_not_called() diff --git a/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py b/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py index 6787aaa3525..48699b47e7f 100644 --- a/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py +++ b/tests/test_litellm/proxy/openai_files_endpoint/test_files_endpoint.py @@ -781,6 +781,63 @@ def test_upload_for_a_litellm_executed_model_goes_to_the_provider_unless_the_ser assert provider_upload.call_args.kwargs["api_base"] == "http://vllm.test/v1" +@pytest.mark.parametrize( + "form", + [{}, {"target_model_names": "my-vllm"}, {"target_model_names": "gemini-2.0-flash"}], + ids=["no model", "litellm-executed model", "provider model"], +) +def test_upload_naming_litellm_db_as_target_storage_is_rejected(batch_upload_seams, form: dict[str, str]): + stored, provider_upload, upstream_files_route = batch_upload_seams + + response = _upload_batch_file({}, {**form, "target_storage": "litellm_db"}) + + assert response.status_code == 400, response.text + error = response.json()["error"] + assert error["type"] == "invalid_request_error" + assert error["param"] == "target_storage" + assert "litellm_db" in error["message"] + assert upstream_files_route.call_count == 0 + stored.assert_not_awaited() + provider_upload.assert_not_awaited() + + +@pytest.mark.parametrize("purpose", ["user_data", "batch"]) +def test_upload_with_an_explicit_target_storage_goes_where_the_caller_said_without_probing_the_server( + batch_upload_seams, purpose: str +): + stored, provider_upload, upstream_files_route = batch_upload_seams + + response = _upload_batch_file( + {}, {"purpose": purpose, "target_model_names": "my-vllm", "target_storage": "azure_storage"} + ) + + assert response.status_code == 200, response.text + assert upstream_files_route.call_count == 0 + provider_upload.assert_not_awaited() + stored.assert_awaited_once() + kwargs = stored.call_args.kwargs + assert kwargs["target_storage"] == "azure_storage" + assert tuple(kwargs["target_model_names"]) == ("my-vllm",) + assert kwargs["purpose"] == purpose + + +def test_upload_with_an_explicit_target_storage_still_refuses_a_key_without_the_executed_model(batch_upload_seams): + import litellm.proxy.proxy_server as ps + + stored, provider_upload, upstream_files_route = batch_upload_seams + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_id="restricted-user", models=["gemini-2.0-flash"] + ) + + response = _upload_batch_file({}, {"target_model_names": "my-vllm", "target_storage": "azure_storage"}) + + assert response.status_code == 403, response.text + assert "my-vllm" in response.text + assert upstream_files_route.call_count == 0 + stored.assert_not_awaited() + provider_upload.assert_not_awaited() + + def test_batch_upload_for_a_provider_model_still_goes_to_the_provider(batch_upload_seams): stored, provider_upload, _ = batch_upload_seams From 742a3ad93df7bb43b1fa0b1e8eb3adba915bcaf3 Mon Sep 17 00:00:00 2001 From: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 13:30:36 -0700 Subject: [PATCH 64/73] ci(e2e): trigger OAuth acceptance on relevant pull requests --- .github/workflows/test-mcp-oauth-e2e.yml | 20 ++++++++++++++++++++ tests/e2e/CONTRIBUTING.md | 13 ++++++++++--- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test-mcp-oauth-e2e.yml b/.github/workflows/test-mcp-oauth-e2e.yml index 7625fb4d59f..034b9fe49ec 100644 --- a/.github/workflows/test-mcp-oauth-e2e.yml +++ b/.github/workflows/test-mcp-oauth-e2e.yml @@ -1,6 +1,26 @@ name: MCP OAuth happy path on: + pull_request: + paths: + - '.github/workflows/test-mcp-oauth-e2e.yml' + - '.github/e2e-stack/**' + - 'tests/e2e/*.py' + - 'tests/e2e/pytest.ini' + - 'tests/e2e/idp_realm.json' + - 'tests/e2e/mcp/**' + - 'litellm/experimental_mcp_client/**' + - 'litellm/proxy/_experimental/mcp_server/**' + - 'litellm/proxy/auth/**' + - 'litellm/proxy/management_endpoints/*sso*.py' + - 'litellm/proxy/management_endpoints/sso/**' + - 'litellm/proxy/common_utils/encrypt_decrypt_utils.py' + - 'litellm/proxy/proxy_server.py' + - 'litellm/proxy/schema.prisma' + - 'ui/litellm-dashboard/src/app/connect/**' + - 'ui/litellm-dashboard/src/app/mcp/oauth/**' + - 'pyproject.toml' + - 'uv.lock' workflow_dispatch: permissions: {} diff --git a/tests/e2e/CONTRIBUTING.md b/tests/e2e/CONTRIBUTING.md index 2adac08329f..6c3dc4d0bd1 100644 --- a/tests/e2e/CONTRIBUTING.md +++ b/tests/e2e/CONTRIBUTING.md @@ -281,9 +281,16 @@ aggregate client never injects a gateway header; the explicitly labeled JWT variant configures `x-litellm-api-key` for the first consent and reconnects with only its gateway JWT after restart -`.github/workflows/test-mcp-oauth-e2e.yml` runs the four cases in the protected -`e2e-changed` environment. Provision `E2E_LINEAR_STORAGE_STATE_B64` as a secret -there and retain the existing E2E license/AWS role configuration. A missing or +`.github/workflows/test-mcp-oauth-e2e.yml` automatically requests a run for +same-repository pull requests changing MCP, gateway authentication/SSO, consent +UI, dependencies or the relevant E2E harness/workflow paths. It retains manual +`workflow_dispatch` for targeted verification. The four cases run in the +protected `e2e-changed` environment after its normal deployment approval; +reviewers should approve and inspect this separate OAuth check when it appears. +Fork pull requests do not run this credentialed job; use a reviewed +same-repository branch for their verification. The workflow's path-filtered +check is not configured here as a globally required branch-protection check. +Provision `E2E_LINEAR_STORAGE_STATE_B64` as a secret there and retain the existing E2E license/AWS role configuration. A missing or expired session fails the job; collection, deselection and skips are not passes. The generic changed-test job excludes this file because it requires an owned proxy and consent UI. No LLM call is needed From cae6634192dbad73ef089dbf8a1f28a3df7a56bd Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 13:36:37 -0700 Subject: [PATCH 65/73] fix(auth): keep the last-known org copy when the auth prefetch warmed the org row The last-known org copy was written only on get_org_object's DB-read path. The virtual-key auth prefetch fills the same 5s org entry directly, so with keys and JWTs of one org on the same worker the JWT lookup always hit the cache, never wrote the copy, and a DB outage turned that JWT traffic into 503s again. get_org_object_for_request now writes the copy itself whenever this worker holds none, under the management-object TTL, and get_org_object is back to its shape on main. --- litellm/proxy/auth/auth_checks.py | 30 ++++++--- .../proxy/auth/test_auth_checks.py | 65 +++++++++++++++++-- 2 files changed, 81 insertions(+), 14 deletions(-) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 161a91f648d..65795e09976 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -4008,13 +4008,6 @@ async def get_org_object( model_type=LiteLLM_OrganizationTable, ttl=DEFAULT_IN_MEMORY_TTL, ) - if include_budget_table: - await user_api_key_cache.async_set_cache( - key=_last_known_org_cache_key(org_id), - value=_org_obj, - model_type=LiteLLM_OrganizationTable, - ttl=get_management_object_ttl(user_api_key_cache), - ) return _org_obj @@ -4023,6 +4016,23 @@ def _last_known_org_cache_key(org_id: str) -> str: return f"org_id:{org_id}:with_budget:last_known" +async def _keep_last_known_org( + org: LiteLLM_OrganizationTable, org_id: str, user_api_key_cache: UserApiKeyCache +) -> None: + cache_key: Final = _last_known_org_cache_key(org_id) + held_locally: Final = await user_api_key_cache.async_get_cache( + key=cache_key, local_only=True, model_type=LiteLLM_OrganizationTable + ) + if held_locally is not None: + return + await user_api_key_cache.async_set_cache( + key=cache_key, + value=org, + model_type=LiteLLM_OrganizationTable, + ttl=get_management_object_ttl(user_api_key_cache), + ) + + async def get_org_object_for_request( org_id: str, prisma_client: PrismaClient, @@ -4031,7 +4041,7 @@ async def get_org_object_for_request( proxy_logging_obj: ProxyLogging | None, ) -> LiteLLM_OrganizationTable | None: try: - return await get_org_object( + org: Final = await get_org_object( org_id=org_id, prisma_client=prisma_client, user_api_key_cache=user_api_key_cache, @@ -4054,6 +4064,10 @@ async def get_org_object_for_request( if PrismaDBExceptionHandler.should_allow_request_on_db_unavailable(): return None raise + if org is None: + return None + await _keep_last_known_org(org, org_id, user_api_key_cache) + return org async def _get_resources_from_access_groups( diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index 08764ad5b18..b64e4d6ae6c 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -6087,17 +6087,19 @@ async def test_organization_budget_check_carries_org_state_on_the_token(): assert token.org_budget_snapshot == OrgBudgetSnapshot(spend=12.5, max_budget=100.0) +@pytest.mark.parametrize("warmed_by_auth_prefetch", [False, True]) @pytest.mark.asyncio -async def test_get_org_object_for_request_serves_last_known_org_through_db_outage(): +async def test_get_org_object_for_request_serves_last_known_org_through_db_outage(warmed_by_auth_prefetch): """A JWT whose team sits in an org resolves the org on every request, and the org row is cached for only DEFAULT_IN_MEMORY_TTL seconds while the team and user rows ride the 60s management-object TTL. Without a last-known copy, a DB outage a few seconds old turned that traffic into 503s while the same request through a virtual key kept - succeeding on its cached team.""" + succeeding on its cached team. The copy must exist whoever filled the short-lived entry: + this lookup's own DB read, or the virtual-key auth prefetch warming it for the same org.""" + from litellm.proxy._types import LiteLLM_OrganizationTable from litellm.proxy.auth.auth_checks import get_org_object_for_request - org_row = MagicMock() - org_row.model_dump = lambda: { + org_columns = { "organization_id": "org-1", "organization_alias": "platform-org", "budget_id": "b1", @@ -6105,11 +6107,20 @@ async def test_get_org_object_for_request_serves_last_known_org_through_db_outag "updated_by": "admin", "litellm_budget_table": {"budget_id": "b1", "max_budget": 50.0, "tpm_limit": 700, "rpm_limit": 7}, } + org_row = MagicMock() + org_row.model_dump = lambda: org_columns + db_outage = ConnectionRefusedError("db unavailable") prisma_client = MagicMock() prisma_client.db.litellm_organizationtable.find_unique = AsyncMock( - side_effect=[org_row, ConnectionRefusedError("db unavailable")] + side_effect=[db_outage] if warmed_by_auth_prefetch else [org_row, db_outage] ) user_api_key_cache = UserApiKeyCache() + if warmed_by_auth_prefetch: + await user_api_key_cache.async_set_cache( + key="org_id:org-1:with_budget", + value=LiteLLM_OrganizationTable.model_validate(org_columns), + model_type=LiteLLM_OrganizationTable, + ) async def _lookup(): return await get_org_object_for_request( @@ -6127,7 +6138,7 @@ async def test_get_org_object_for_request_serves_last_known_org_through_db_outag during_outage = await _lookup() - assert prisma_client.db.litellm_organizationtable.find_unique.await_count == 2 + assert prisma_client.db.litellm_organizationtable.find_unique.await_count == (1 if warmed_by_auth_prefetch else 2) assert during_outage is not None assert during_outage.organization_alias == "platform-org" assert during_outage.litellm_budget_table is not None @@ -6135,6 +6146,48 @@ async def test_get_org_object_for_request_serves_last_known_org_through_db_outag assert during_outage.litellm_budget_table.max_budget == 50.0 +@pytest.mark.asyncio +async def test_get_org_object_for_request_writes_the_last_known_org_only_when_absent(): + """The last-known copy is written when this worker holds none, never per request: + with Redis attached, a write on every cached org hit would cost one SET per JWT request.""" + from litellm.proxy._types import LiteLLM_OrganizationTable + from litellm.proxy.auth.auth_checks import get_org_object_for_request + + class _WriteRecordingCache(UserApiKeyCache): + def __init__(self): + super().__init__() + self.written_keys = [] + + async def async_set_cache(self, key, value, local_only=False, **kwargs): + self.written_keys.append(key) + return await super().async_set_cache(key=key, value=value, local_only=local_only, **kwargs) + + user_api_key_cache = _WriteRecordingCache() + await user_api_key_cache.async_set_cache( + key="org_id:org-1:with_budget", + value=LiteLLM_OrganizationTable( + organization_id="org-1", + organization_alias="platform-org", + budget_id="b1", + created_by="admin", + updated_by="admin", + ), + model_type=LiteLLM_OrganizationTable, + ) + + for _ in range(3): + org = await get_org_object_for_request( + org_id="org-1", + prisma_client=MagicMock(), + user_api_key_cache=user_api_key_cache, + parent_otel_span=None, + proxy_logging_obj=None, + ) + assert org is not None and org.organization_alias == "platform-org" + + assert user_api_key_cache.written_keys.count("org_id:org-1:with_budget:last_known") == 1 + + @pytest.mark.parametrize( "max_budget, spend, expect_blocked", [ From c02399b29dbc6b3a243679c888302caa47245d73 Mon Sep 17 00:00:00 2001 From: Shivam Rawat Date: Sat, 19 Sep 2026 12:23:59 -0700 Subject: [PATCH 66/73] fix(terraform): unlink the registry docs entries that 404 on click The resource and data source links on the provider's registry docs overview page 404 when clicked. They are written as relative paths like ./resources/team, and the registry serves the overview at .../latest/docs with no trailing slash and passes hrefs through unrewritten, so the browser resolves them to .../latest/resources/team. Drops the link markup and keeps both lists and their descriptions. No relative form works in both places: only a docs/-prefixed target resolves correctly on the registry, and that same path is wrong when reading the file on GitHub. The registry sidebar already links every resource and data source for the version being read. Co-Authored-By: Claude Opus 5 --- terraform/provider/docs/index.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/terraform/provider/docs/index.md b/terraform/provider/docs/index.md index e6641782a4d..c446567549d 100644 --- a/terraform/provider/docs/index.md +++ b/terraform/provider/docs/index.md @@ -43,22 +43,22 @@ resource "litellm_team" "dev_team" { The LiteLLM provider supports the following resources: -* [`litellm_model`](./resources/model) - Manage LiteLLM model configurations -* [`litellm_team`](./resources/team) - Manage teams and their permissions -* [`litellm_team_member`](./resources/team_member) - Manage team member configurations -* [`litellm_team_member_add`](./resources/team_member_add) - Add members to teams -* [`litellm_key`](./resources/key) - Manage API keys -* [`litellm_mcp_server`](./resources/mcp_server) - Manage MCP (Model Context Protocol) servers -* [`litellm_credential`](./resources/credential) - Manage credentials for various providers -* [`litellm_vector_store`](./resources/vector_store) - Manage vector stores -* [`litellm_jwt_key_mapping`](./resources/jwt_key_mapping) - Map JWT claim values to virtual keys +* `litellm_model` - Manage LiteLLM model configurations +* `litellm_team` - Manage teams and their permissions +* `litellm_team_member` - Manage team member configurations +* `litellm_team_member_add` - Add members to teams +* `litellm_key` - Manage API keys +* `litellm_mcp_server` - Manage MCP (Model Context Protocol) servers +* `litellm_credential` - Manage credentials for various providers +* `litellm_vector_store` - Manage vector stores +* `litellm_jwt_key_mapping` - Map JWT claim values to virtual keys ## Available Data Sources The LiteLLM provider supports the following data sources: -* [`litellm_credential`](./data-sources/credential) - Retrieve credential information -* [`litellm_vector_store`](./data-sources/vector_store) - Retrieve vector store information +* `litellm_credential` - Retrieve credential information +* `litellm_vector_store` - Retrieve vector store information ## Authentication From a61bceb0cf00dd05be46387e8d56a3dfb2daf013 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:00:51 -0700 Subject: [PATCH 67/73] fix(files): read storage-backed managed files from their storage backend The managed files hook's content read looped the file's model mappings and asked each deployment for the file. A file LiteLLM stored itself maps every model to its storage url, so the read sent that internal id to the upstream server, failed, and the batch rate limiter failed open: a key's TPM limit did not apply to a LiteLLM-executed batch. The hook now returns the stored bytes from the file's storage backend before it consults any deployment --- .../proxy/hooks/managed_files.py | 17 +++++-- .../proxy/test_managed_files_hook.py | 46 +++++++++++++++++++ 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py index 8eef8a5f1ce..09cd0ed192f 100644 --- a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py +++ b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py @@ -20,6 +20,7 @@ from typing import ( ) from uuid import NAMESPACE_URL, uuid5 +import httpx from fastapi import HTTPException from pydantic import ValidationError @@ -77,6 +78,7 @@ from litellm.types.llms.openai import ( # pyright: ignore[reportAttributeAccess CreateFileRequest, FileListPage, FileObject, + HttpxBinaryResponseContent, OpenAIFileObject, ResponsesAPIResponse, ) @@ -88,10 +90,6 @@ from litellm.types.utils import ( SpecialEnums, ) -if TYPE_CHECKING: - from litellm.types.llms.openai import HttpxBinaryResponseContent - - if TYPE_CHECKING: from opentelemetry.trace import Span as _Span from prisma.models import ( @@ -1867,10 +1865,14 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): litellm_parent_otel_span: Optional[Span], llm_router: Router, **data: Dict, - ) -> "HttpxBinaryResponseContent": + ) -> HttpxBinaryResponseContent: """ Get the content of a file from first model that has it """ + managed_file: Final = await self.get_unified_file_id(file_id, litellm_parent_otel_span) + if managed_file is not None and managed_file.storage_backend and managed_file.storage_url: + return await self._storage_backend_content(managed_file.storage_backend, managed_file.storage_url) + model_file_id_mapping = data.pop("model_file_id_mapping", None) model_file_id_mapping = model_file_id_mapping or await self.get_model_file_id_mapping( [file_id], litellm_parent_otel_span @@ -1900,6 +1902,11 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): else: raise Exception(f"LiteLLM Managed File object with id={file_id} not found") + async def _storage_backend_content(self, storage_backend_name: str, storage_url: str) -> HttpxBinaryResponseContent: + storage_backend: Final = get_storage_backend(storage_backend_name, prisma_client=self.prisma_client) + content: Final = await storage_backend.download_file(storage_url) + return HttpxBinaryResponseContent(response=httpx.Response(status_code=httpx.codes.OK, content=content)) + async def _convert_storage_files_to_base64( self, messages: List[AllMessageValues], diff --git a/tests/test_litellm/enterprise/proxy/test_managed_files_hook.py b/tests/test_litellm/enterprise/proxy/test_managed_files_hook.py index 419a460d098..74bd67efaf2 100644 --- a/tests/test_litellm/enterprise/proxy/test_managed_files_hook.py +++ b/tests/test_litellm/enterprise/proxy/test_managed_files_hook.py @@ -1067,6 +1067,7 @@ async def test_afile_content_passes_trusted_model_credentials_to_router(): managed_files = _make_managed_files_instance() unified_file_id = "unified-file-id" s3_uri = "s3://my-bucket/litellm-batch-outputs/job-123/input.jsonl.out" + managed_files.get_unified_file_id = AsyncMock(return_value=None) managed_files.get_model_file_id_mapping = AsyncMock( return_value={unified_file_id: {"model-123": s3_uri}} ) @@ -1238,6 +1239,7 @@ async def test_afile_content_bedrock_unified_id_end_to_end(monkeypatch): managed_files = _make_managed_files_instance() unified_file_id = "unified-file-id" s3_uri = "s3://my-bucket/litellm-batch-outputs/job-123/input.jsonl.out" + managed_files.get_unified_file_id = AsyncMock(return_value=None) managed_files.get_model_file_id_mapping = AsyncMock( return_value={unified_file_id: {"model-123": s3_uri}} ) @@ -1268,6 +1270,7 @@ async def test_afile_content_error_reports_unified_id_not_provider_uri(): managed_files = _make_managed_files_instance() unified_file_id = "litellm_proxy_unified_id_abc" s3_uri = "s3://my-bucket/litellm-batch-outputs/job-123/input.jsonl.out" + managed_files.get_unified_file_id = AsyncMock(return_value=None) managed_files.get_model_file_id_mapping = AsyncMock( return_value={unified_file_id: {"model-123": s3_uri}} ) @@ -1908,6 +1911,49 @@ async def test_afile_delete_storage_backed_row_deletes_stored_content_not_provid assert response == FileDeleted(id=unified_file_id, object="file", deleted=True) +@pytest.mark.asyncio +async def test_afile_content_storage_backed_row_returns_stored_bytes_not_provider_content(): + from litellm_enterprise.proxy.hooks.managed_files import _PROXY_LiteLLMManagedFiles + from prisma import Base64 + + from litellm.caching import DualCache + from litellm.models.managed_files import LiteLLM_ManagedFileTable + + storage_url = "litellm_db://content-row-1" + unified_file_id = _managed_deletion_file_id(storage_url) + stored_bytes = b'{"custom_id": "line-1", "method": "POST", "url": "/v1/chat/completions", "body": {}}\n' + row = LiteLLM_ManagedFileTable( + unified_file_id=unified_file_id, + model_mappings={"vllm-batch": storage_url}, + flat_model_file_ids=[storage_url], + file_object=_make_file_object(unified_file_id), + storage_backend="litellm_db", + storage_url=storage_url, + ) + file_table = MagicMock(find_first=AsyncMock(return_value=row)) + content_table = MagicMock(find_unique=AsyncMock(return_value=MagicMock(content=Base64.encode(stored_bytes)))) + managed_files = _PROXY_LiteLLMManagedFiles( + internal_usage_cache=DualCache(), + prisma_client=MagicMock( + db=MagicMock(litellm_managedfiletable=file_table, litellm_managedfilecontenttable=content_table) + ), + ) + router = MagicMock( + get_deployment_credentials_with_provider=MagicMock(return_value=None), + afile_content=AsyncMock(), + ) + + response = await managed_files.afile_content( + file_id=unified_file_id, + litellm_parent_otel_span=None, + llm_router=router, + ) + + assert response.content == stored_bytes + content_table.find_unique.assert_awaited_once_with(where={"id": "content-row-1"}) + router.afile_content.assert_not_awaited() + + @pytest.mark.asyncio async def test_store_unified_object_id_batch_processed_is_written_only_when_asked(): managed_files, mock_prisma = _make_object_store_instance() From e5398e7e3077ced21269871e41de777a56a02de0 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 19 Sep 2026 14:22:19 -0700 Subject: [PATCH 68/73] test: drop two inert type: ignore comments pyrightconfig.json sets enableTypeIgnoreComments to false and does not include tests/, so neither comment suppressed anything. --- .../test_litellm/proxy/config_resolvers/test_settings_store.py | 2 +- .../management_endpoints/test_coordination_redis_endpoints.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_litellm/proxy/config_resolvers/test_settings_store.py b/tests/test_litellm/proxy/config_resolvers/test_settings_store.py index ab1bff67c42..806b2d5e5aa 100644 --- a/tests/test_litellm/proxy/config_resolvers/test_settings_store.py +++ b/tests/test_litellm/proxy/config_resolvers/test_settings_store.py @@ -427,7 +427,7 @@ def test_settings_store_truthiness_stops_at_the_first_key() -> None: resolutions: Final[list[str]] = [] original: Final = SettingsStore._resolution_for - def counted(self: SettingsStore, key: str): # type: ignore[no-untyped-def] + def counted(self: SettingsStore, key: str): resolutions.append(key) return original(self, key) diff --git a/tests/test_litellm/proxy/management_endpoints/test_coordination_redis_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_coordination_redis_endpoints.py index faa8b851db4..7c6e8154107 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_coordination_redis_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_coordination_redis_endpoints.py @@ -623,7 +623,7 @@ def _real_proxy_config(file_general_settings: dict) -> "object": proxy_config = ProxyConfig() proxy_config._load_yaml_settings_stores({"general_settings": file_general_settings}) - proxy_config.get_config_state = MagicMock( # type: ignore[method-assign] + proxy_config.get_config_state = MagicMock( return_value={"general_settings": file_general_settings} ) return proxy_config From 9c3a7133f11929c5f398d16f1b386504843141b4 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 19 Sep 2026 14:25:52 -0700 Subject: [PATCH 69/73] test: cover the config-owned refusal on the email reset route --- .../send_emails/test_endpoints.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_litellm/enterprise/enterprise_callbacks/send_emails/test_endpoints.py b/tests/test_litellm/enterprise/enterprise_callbacks/send_emails/test_endpoints.py index c2ae153556d..7b32d9e8c44 100644 --- a/tests/test_litellm/enterprise/enterprise_callbacks/send_emails/test_endpoints.py +++ b/tests/test_litellm/enterprise/enterprise_callbacks/send_emails/test_endpoints.py @@ -331,3 +331,19 @@ async def test_save_email_settings_still_writes_when_the_config_file_is_silent() assert len(upserts) == 1 written = json.loads(upserts[0]["data"]["create"]["param_value"]) assert written["email_settings"] == {EmailEvent.new_user_invitation.value: False} + + +@pytest.mark.asyncio +async def test_reset_event_settings_surfaces_the_config_owned_refusal(mock_user_api_key_auth): + upserts = [] + client = _prisma_recording_upserts(upserts) + proxy_config = _proxy_config_owning({"email_settings": {EmailEvent.new_user_invitation.value: True}}) + + with mock.patch("litellm.proxy.proxy_server.prisma_client", client): # test-quality-ok: the endpoint reads these proxy_server module globals at call time; there is no injection seam + with mock.patch("litellm.proxy.proxy_server.proxy_config", proxy_config): # test-quality-ok: the endpoint reads these proxy_server module globals at call time; there is no injection seam + with pytest.raises(HTTPException) as refused: + await reset_event_settings(user_api_key_dict=mock_user_api_key_auth) + + assert refused.value.status_code == 400 + assert refused.value.detail["keys"] == ["email_settings"] + assert upserts == [] From 8767f1279489ddbae97108b4d00d318efb57f3f0 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 19 Sep 2026 14:27:21 -0700 Subject: [PATCH 70/73] bump: litellm-enterprise 0.1.68 -> 0.1.69, litellm-proxy-extras 0.4.99 -> 0.4.100 --- enterprise/pyproject.toml | 4 ++-- litellm-proxy-extras/pyproject.toml | 4 ++-- pyproject.toml | 4 ++-- uv.lock | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/enterprise/pyproject.toml b/enterprise/pyproject.toml index 06b1da7ea76..729f3264706 100644 --- a/enterprise/pyproject.toml +++ b/enterprise/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "litellm-enterprise" -version = "0.1.68" +version = "0.1.69" description = "Package for LiteLLM Enterprise features" readme = "README.md" requires-python = ">=3.9" @@ -26,7 +26,7 @@ required-version = ">=0.10.9" module-root = "" [tool.commitizen] -version = "0.1.68" +version = "0.1.69" version_files = [ "pyproject.toml:^version", "../pyproject.toml:litellm-enterprise==", diff --git a/litellm-proxy-extras/pyproject.toml b/litellm-proxy-extras/pyproject.toml index 604ffc3abd4..fb9022f89a5 100644 --- a/litellm-proxy-extras/pyproject.toml +++ b/litellm-proxy-extras/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "litellm-proxy-extras" -version = "0.4.99" +version = "0.4.100" description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package." readme = "README.md" requires-python = ">=3.9" @@ -26,7 +26,7 @@ required-version = ">=0.10.9" module-root = "" [tool.commitizen] -version = "0.4.99" +version = "0.4.100" version_files = [ "pyproject.toml:^version", "../pyproject.toml:litellm-proxy-extras==", diff --git a/pyproject.toml b/pyproject.toml index f2ee1d92d7f..1295feabb43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,8 +71,8 @@ proxy = [ "mcp>=2.2.0,<3", "httpx2>=2.5.0,<3", "pydantic>=2.12.0,<3", - "litellm-proxy-extras==0.4.99", - "litellm-enterprise==0.1.68", + "litellm-proxy-extras==0.4.100", + "litellm-enterprise==0.1.69", "RestrictedPython>=8.5,<9.0", "rich>=13.9.4,<14.0", "InquirerPy>=0.3.4,<1.0", diff --git a/uv.lock b/uv.lock index db2fb11c6e3..f1a58500a61 100644 --- a/uv.lock +++ b/uv.lock @@ -4942,12 +4942,12 @@ proxy-dev = [ [[package]] name = "litellm-enterprise" -version = "0.1.68" +version = "0.1.69" source = { editable = "enterprise" } [[package]] name = "litellm-proxy-extras" -version = "0.4.99" +version = "0.4.100" source = { editable = "litellm-proxy-extras" } [[package]] From 540375cfeb6402fdbb92db829678be5391651e52 Mon Sep 17 00:00:00 2001 From: yucheng Date: Sat, 19 Sep 2026 21:36:01 +0000 Subject: [PATCH 71/73] fix(proxy): forward stream attributes and merge logged guardrails Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/litellm_core_utils/litellm_logging.py | 4 + litellm/proxy/utils.py | 6 +- .../test_litellm_logging.py | 18 ++++ .../proxy_logging/test_streaming_hooks.py | 87 ++++++++++++++++++- 4 files changed, 113 insertions(+), 2 deletions(-) diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index f7679b31f69..009089e0a8e 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -5528,6 +5528,10 @@ class StandardLoggingPayloadSetup: for key in metadata.keys() & _STANDARD_LOGGING_METADATA_KEYS: clean_metadata[key] = metadata[key] + recorded_guardrails: Final = metadata.get("applied_guardrails") + if applied_guardrails and isinstance(recorded_guardrails, list): + clean_metadata["applied_guardrails"] = list(dict.fromkeys([*applied_guardrails, *recorded_guardrails])) + user_api_key: Final = metadata.get("user_api_key") if user_api_key and isinstance(user_api_key, str) and is_valid_sha256_hash(user_api_key): clean_metadata["user_api_key_hash"] = user_api_key diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index b078a65759e..62710d570db 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -470,12 +470,16 @@ def _record_raising_guardrail(request_data: Mapping[str, object], callback: obje class _UpstreamStreamBoundary(Generic[_T]): - __slots__ = ("_upstream", "failure") + __slots__ = ("_source", "_upstream", "failure") def __init__(self, upstream: AsyncIterable[_T]) -> None: + self._source: Final = upstream self._upstream: Final = upstream.__aiter__() self.failure: BaseException | None = None + def __getattr__(self, name: str) -> object: + return getattr(self._source, name) + def __aiter__(self) -> "_UpstreamStreamBoundary[_T]": return self diff --git a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py index 999adbdd935..626a13c8061 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -3532,6 +3532,24 @@ def test_function_setup_litellm_metadata_guardrail_writes_visible_after_setup(): assert merged.get("applied_guardrails") == ["pam-ethical-request"] +def test_get_standard_logging_metadata_merges_recorded_applied_guardrails(): + from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup + + result = StandardLoggingPayloadSetup.get_standard_logging_metadata( + metadata={"applied_guardrails": ["blocker"]}, + litellm_params={}, + applied_guardrails=["guard-a", "blocker", "guard-b"], + ) + assert result["applied_guardrails"] == ["guard-a", "blocker", "guard-b"] + + result = StandardLoggingPayloadSetup.get_standard_logging_metadata( + metadata={"applied_guardrails": ["blocker"]}, + litellm_params={}, + applied_guardrails=["guard-a"], + ) + assert result["applied_guardrails"] == ["guard-a", "blocker"] + + def test_function_setup_metadata_takes_precedence_over_litellm_metadata(): """ Test that when BOTH metadata and litellm_metadata are present (e.g., user sets diff --git a/tests/test_litellm/proxy/utils/proxy_logging/test_streaming_hooks.py b/tests/test_litellm/proxy/utils/proxy_logging/test_streaming_hooks.py index 6fb000b4fa7..5132aeb02e8 100644 --- a/tests/test_litellm/proxy/utils/proxy_logging/test_streaming_hooks.py +++ b/tests/test_litellm/proxy/utils/proxy_logging/test_streaming_hooks.py @@ -12,7 +12,7 @@ from __future__ import annotations import asyncio from collections.abc import AsyncGenerator, AsyncIterator from datetime import datetime -from typing import Any, Dict, List +from typing import Any, Dict, Final, List from unittest.mock import AsyncMock, MagicMock import pytest @@ -179,6 +179,29 @@ async def _one_chunk() -> AsyncGenerator[object, None]: yield "chunk" +class _AttributeStream: + _hidden_params = {"model_id": "m-1"} + model = "gpt-x" + + def __init__(self) -> None: + self._chunks = ("chunk-1", "chunk-2") + self._index = 0 + self.closed = False + + def __aiter__(self) -> "_AttributeStream": + return self + + async def __anext__(self) -> str: + if self._index >= len(self._chunks): + raise StopAsyncIteration + chunk = self._chunks[self._index] + self._index += 1 + return chunk + + async def aclose(self) -> None: + self.closed = True + + @pytest.mark.asyncio async def test_wrap_streaming_iterator_with_enrichment_passes_through_chunks(proxy_logging): async def gen(): @@ -247,6 +270,68 @@ async def test_wrap_streaming_iterator_leaves_upstream_http_exception_unattribut assert request_data == {} +@pytest.mark.asyncio +async def test_wrap_streaming_iterator_forwards_response_attributes_to_hook(proxy_logging): + async def prefix_hook(*, response: AsyncIterator[object]) -> AsyncGenerator[object, None]: + async for chunk in response: + yield f"{response._hidden_params['model_id']}:{response.model}:{chunk}" + + source = _AttributeStream() + wrapped = proxy_logging._wrap_streaming_iterator_with_enrichment( + callback=MagicMock(guardrail_name="g", event_hook="post_call"), + response=source, + hook=prefix_hook, + request_data={}, + ) + + assert [chunk async for chunk in wrapped] == [ + "m-1:gpt-x:chunk-1", + "m-1:gpt-x:chunk-2", + ] + + +@pytest.mark.asyncio +async def test_wrap_streaming_iterator_forwards_aclose_to_upstream(proxy_logging): + async def close_hook(*, response: AsyncIterator[object]) -> AsyncGenerator[object, None]: + first: Final = await response.__anext__() + yield first + await response.aclose() + + source = _AttributeStream() + request_data: dict[str, object] = {} + wrapped = proxy_logging._wrap_streaming_iterator_with_enrichment( + callback=MagicMock(guardrail_name="g", event_hook="post_call"), + response=source, + hook=close_hook, + request_data=request_data, + ) + + assert [chunk async for chunk in wrapped] == ["chunk-1"] + assert source.closed is True + assert request_data == {} + + +@pytest.mark.asyncio +async def test_wrap_streaming_iterator_missing_attribute_still_raises(proxy_logging): + async def missing_attribute_hook(*, response: AsyncIterator[object]) -> AsyncGenerator[object, None]: + _missing: Final = response.not_there + if False: + yield + + request_data: dict[str, object] = {} + wrapped = proxy_logging._wrap_streaming_iterator_with_enrichment( + callback=MagicMock(guardrail_name="hook-bug", event_hook="post_call"), + response=_one_chunk(), + hook=missing_attribute_hook, + request_data=request_data, + ) + + with pytest.raises(AttributeError): + async for _ in wrapped: + pass + assert request_data["metadata"]["applied_guardrails"] == ["hook-bug"] + + # --------------------------------------------------------------------------- # async_post_call_streaming_hook # --------------------------------------------------------------------------- From 90687ae597cc9e97aa24501a88b820da64edf912 Mon Sep 17 00:00:00 2001 From: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:39:03 -0700 Subject: [PATCH 72/73] test(e2e): detect fast upstream reauthorization on reconnect --- tests/e2e/mcp/oauth_chat_client.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/e2e/mcp/oauth_chat_client.py b/tests/e2e/mcp/oauth_chat_client.py index d2fca790132..0c5c6106259 100644 --- a/tests/e2e/mcp/oauth_chat_client.py +++ b/tests/e2e/mcp/oauth_chat_client.py @@ -101,6 +101,9 @@ async def _browser_follow_authorize( def _note_request(request: object) -> None: url = getattr(request, "url", "") + host = httpx.URL(url).host + if not allow_upstream_consent and (host == "linear.app" or host.endswith(".linear.app")): + captured["upstream_consent"] = "seen" if url.startswith(OAUTH_CLIENT_REDIRECT_URI) and "url" not in captured: captured["url"] = url @@ -112,7 +115,7 @@ async def _browser_follow_authorize( context = await browser.new_context(storage_state=storage_state_path) await context.route(re.compile(re.escape(OAUTH_CLIENT_REDIRECT_URI) + r".*"), _swallow_redirect) page = await context.new_page() - page.on("request", _note_request) + context.on("request", _note_request) page.on("framenavigated", lambda frame: trail.append(frame.url.split("?", 1)[0])) await page.goto(start_url, wait_until="domcontentloaded") deadline = time.monotonic() + BROWSER_CONSENT_TIMEOUT @@ -121,15 +124,13 @@ async def _browser_follow_authorize( await page.wait_for_load_state("networkidle", timeout=8000) except Exception: # noqa: BLE001 - a busy consent page never idles; fall through and try to advance it pass - if "url" in captured: + if "upstream_consent" in captured or "url" in captured: break if await page.locator("#username").count() and identity is not None: await page.locator("#username").fill(identity.username) await page.locator("#password").fill(identity.password) await page.locator("#kc-login").click() continue - if httpx.URL(page.url).host.endswith("linear.app") and not allow_upstream_consent: - raise AssertionError("cold reconnect required upstream consent") if "/ui/connect" in page.url and server_alias is not None: card = page.locator("div.cursor-pointer").filter(has=page.get_by_text(server_alias, exact=True)) if await card.count() != 1: @@ -157,6 +158,8 @@ async def _browser_follow_authorize( final_url = page.url await browser.close() + # A redirect chain can finish inside goto/networkidle before the loop checks the page. + assert "upstream_consent" not in captured, "cold reconnect required upstream consent" landing = captured.get("url") assert landing is not None, ( f"consent flow never reached {OAUTH_CLIENT_REDIRECT_URI}; " From d5ac850feb7e69883cec795fbca8ebf98890ed9a Mon Sep 17 00:00:00 2001 From: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 15:13:27 -0700 Subject: [PATCH 73/73] test(e2e): isolate diagnostic reporter subprocess --- tests/code_coverage_tests/test_e2e_changed_gate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/code_coverage_tests/test_e2e_changed_gate.py b/tests/code_coverage_tests/test_e2e_changed_gate.py index d14f007403b..707566c0333 100644 --- a/tests/code_coverage_tests/test_e2e_changed_gate.py +++ b/tests/code_coverage_tests/test_e2e_changed_gate.py @@ -247,7 +247,7 @@ def test_oauth_failure_diagnostics_do_not_publish_private_payloads(tmp_path: Pat report: Final = tmp_path / "report.xml" ET.ElementTree(suite).write(report) result: Final = subprocess.run( - [sys.executable, str(GATE), str(report), SELECTED[0]], capture_output=True, text=True + [sys.executable, "-I", str(GATE), str(report), SELECTED[0]], capture_output=True, text=True ) assert result.returncode == 1 assert f"oauth_failure_phase: {phase}" in result.stdout