From 02f219c16331c0ada04b6eb4b29cdc0ff5870fbd Mon Sep 17 00:00:00 2001 From: Neel-K26 Date: Mon, 24 Aug 2026 17:20:56 +0530 Subject: [PATCH 1/2] refactor(proxy): stop reading from the private fastapi.dependencies.utils module Issue #36922 (fastapi 0.141 removed get_flat_dependant) was already fixed by f9b86b253a, which switched to get_flat_params(). That helper lives in the same private fastapi.dependencies.utils module fastapi silently dropped get_flat_dependant from, so it carries the same removal risk. Read Dependant.query_params/.dependencies directly instead: the public dataclass fields fastapi's own routing engine depends on, and the same fields get_flat_params itself reads under the hood. Also tightens the FastAPI upper bound to <0.142 (tested on 0.136.3 and 0.141.1), and generalizes the management_v1 regression suite's private-import guard from a specific removed-names list to any import from fastapi.dependencies.utils, so a future name removed from that module fails the guard on sight. Related to #36922 --- .../management_v1/common.py | 21 +++--- pyproject.toml | 2 +- .../management_v1/test_common.py | 67 +++++++++---------- uv.lock | 2 +- 4 files changed, 45 insertions(+), 47 deletions(-) diff --git a/litellm/proxy/management_endpoints/management_v1/common.py b/litellm/proxy/management_endpoints/management_v1/common.py index ec79820465a..da9758db186 100644 --- a/litellm/proxy/management_endpoints/management_v1/common.py +++ b/litellm/proxy/management_endpoints/management_v1/common.py @@ -1,11 +1,11 @@ """Contract machinery shared by every `/management/v1` route.""" +from collections.abc import Iterator from typing import Final from urllib.parse import urlencode from fastapi import Request -from fastapi.dependencies.utils import get_flat_params -from fastapi.params import ParamTypes +from fastapi.dependencies.models import Dependant from fastapi.responses import JSONResponse from litellm.types.proxy.management_endpoints.management_v1 import ( @@ -38,18 +38,19 @@ def problem_response(problem: ProblemDetail) -> JSONResponse: ) +def _query_param_aliases(dependant: Dependant) -> Iterator[str]: + for field in dependant.query_params: + yield field.alias + for sub_dependant in dependant.dependencies: + yield from _query_param_aliases(sub_dependant) + + def _declared_query_params(request: Request) -> frozenset[str]: route: Final = request.scope.get("route") dependant: Final = getattr(route, "dependant", None) - if dependant is None: + if not isinstance(dependant, Dependant): return frozenset() - # fastapi>=0.140.7 removed get_flat_dependant(); get_flat_params() returns the - # flattened (deduped) param list. Filter to query params to match the old behavior. - return frozenset( - field.alias - for field in get_flat_params(dependant) - if getattr(field.field_info, "in_", None) == ParamTypes.query - ) + return frozenset(_query_param_aliases(dependant)) def escape_like(value: str) -> str: diff --git a/pyproject.toml b/pyproject.toml index fca5c7da1e2..ca93da4fd90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ proxy = [ "uvicorn>=0.33.0,<1.0", "granian>=2.7.4,<3.0", "uvloop>=0.21.0,<1.0; sys_platform != 'win32'", - "fastapi>=0.136.3,<1.0", + "fastapi>=0.136.3,<0.142", "starlette>=1.0.1,<2.0", "backoff>=2.2.1,<3.0", "pyyaml>=6.0.3,<7.0", diff --git a/tests/test_litellm/proxy/management_endpoints/management_v1/test_common.py b/tests/test_litellm/proxy/management_endpoints/management_v1/test_common.py index f3515e84d0d..57093c0ae31 100644 --- a/tests/test_litellm/proxy/management_endpoints/management_v1/test_common.py +++ b/tests/test_litellm/proxy/management_endpoints/management_v1/test_common.py @@ -52,11 +52,8 @@ def test_an_unknown_query_param_is_rejected_as_a_problem(): def test_a_path_param_name_is_not_a_declared_query_param(): - """The flatten step returns path+query+header together; only query names count as declared. - - If the ParamTypes.query filter were dropped, `thing_id` (a path param) would leak - into the declared set and this request would be wrongly accepted. - """ + """`_query_param_aliases` walks `Dependant.query_params`; if it were ever widened + to also read `.path_params`, `thing_id` would leak into the declared set.""" response = _client().get("/things/abc", params={"thing_id": "x"}) assert response.status_code == 400 assert "thing_id" in response.json()["detail"] @@ -102,9 +99,25 @@ def test_declared_query_params_is_empty_when_the_route_has_no_dependant(): assert _declared_query_params(request) == frozenset() -# fastapi removed these in 0.140.7, which `pyproject.toml` still allows via -# `fastapi>=0.136.3,<1.0`. Add a name here whenever a supported release drops one. -FASTAPI_NAMES_REMOVED_IN_0_140_7 = frozenset({"get_flat_dependant"}) +def _shared_pagination(page: Annotated[int, Query()] = 1) -> int: + return page + + +def test_declared_query_params_includes_a_param_declared_only_on_a_shared_sub_dependency(): + """`page` sits on `_shared_pagination`'s own Dependant, nested under the route's + `dependencies`, not on the route function's own `query_params` — the case a + non-recursive walk over just the top-level Dependant would miss.""" + captured: dict[str, frozenset[str]] = {} + app = FastAPI() + + @app.get("/probe") + def _handler(request: Request, page: Annotated[int, Depends(_shared_pagination)]) -> dict[str, bool]: + captured["declared"] = _declared_query_params(request) + return {"ok": True} + + TestClient(app).get("/probe") + assert captured["declared"] == frozenset({"page"}) + MANAGEMENT_V1_PACKAGE = Path(str(common_module.__file__)).parent @@ -113,39 +126,23 @@ def _public_names(module: ModuleType) -> frozenset[str]: return frozenset(name for name in vars(module) if not name.startswith("_")) -def _fastapi_names_imported_by(source_file: Path) -> frozenset[str]: +def _modules_imported_from_by(source_file: Path) -> frozenset[str]: tree = ast.parse(source_file.read_text()) - return frozenset( - alias.name - for node in ast.walk(tree) - if isinstance(node, ast.ImportFrom) and (node.module or "").startswith("fastapi") - for alias in node.names - ) + return frozenset(node.module for node in ast.walk(tree) if isinstance(node, ast.ImportFrom) and node.module) -@pytest.mark.parametrize( - "source_file", sorted(MANAGEMENT_V1_PACKAGE.glob("*.py")), ids=lambda path: path.name -) -def test_no_module_imports_a_fastapi_name_removed_in_a_supported_release(source_file: Path): - """`pyproject.toml` allows fastapi up to <1.0, but CI only ever resolves 0.136.3. - - Every other test here passes just as well against a module importing a name - fastapi has since deleted, because the pinned fastapi still has it. On a user's - fastapi>=0.140.7 that import is an ImportError, and `proxy_server` imports this - package unguarded at module level, so it takes the whole proxy down rather than - just these routes. Globbing the package means a new module is covered on sight. +@pytest.mark.parametrize("source_file", sorted(MANAGEMENT_V1_PACKAGE.glob("*.py")), ids=lambda path: path.name) +def test_no_module_imports_from_the_private_fastapi_dependencies_utils_module(source_file: Path): + """fastapi.dependencies.utils is a private module fastapi has already removed a name + from once (get_flat_dependant, in 0.140.7) without notice; the names it still exposes + (e.g. get_flat_params) carry the same risk. Nothing here should import from it at all, + only from the stable public Dependant dataclass in fastapi.dependencies.models. """ - assert not _fastapi_names_imported_by(source_file) & FASTAPI_NAMES_REMOVED_IN_0_140_7 + assert "fastapi.dependencies.utils" not in _modules_imported_from_by(source_file) -def test_common_still_imports_when_fastapi_has_dropped_those_names(monkeypatch: pytest.MonkeyPatch): - """The static check above cannot prove the module actually loads; this does. - - Behaviour cannot be asserted under the same simulation: on 0.136.3 - `get_flat_params` calls `get_flat_dependant` internally, so it raises NameError - once the name is gone. Loading is the part this pins. - """ - for name in FASTAPI_NAMES_REMOVED_IN_0_140_7: +def test_common_still_imports_with_fastapi_dependencies_utils_emptied_out(monkeypatch: pytest.MonkeyPatch): + for name in ("get_flat_dependant", "get_flat_params"): monkeypatch.delattr(fastapi_dependency_utils, name, raising=False) spec = importlib.util.spec_from_file_location( "management_v1_common__simulated_fastapi", Path(str(common_module.__file__)) diff --git a/uv.lock b/uv.lock index 628483c0117..de774e4caa8 100644 --- a/uv.lock +++ b/uv.lock @@ -4501,7 +4501,7 @@ requires-dist = [ { name = "detect-secrets", marker = "extra == 'proxy-runtime'", specifier = ">=1.5.0,<2.0" }, { name = "diskcache", marker = "extra == 'caching'", specifier = ">=5.6.3,<6.0" }, { name = "expression", marker = "extra == 'proxy'", specifier = ">=5.6.0,<6.0" }, - { name = "fastapi", marker = "extra == 'proxy'", specifier = ">=0.136.3,<1.0" }, + { name = "fastapi", marker = "extra == 'proxy'", specifier = ">=0.136.3,<0.142" }, { name = "fastapi-sso", marker = "extra == 'proxy'", specifier = ">=0.19.0,<1.0" }, { name = "fastuuid", specifier = ">=0.14.0,<1.0" }, { name = "google-cloud-aiplatform", marker = "extra == 'google'", specifier = ">=1.133.0,<2.0" }, From 6a7388cea2e14ac374dda55df89a4a7ea6136d2f Mon Sep 17 00:00:00 2001 From: Neel-K26 Date: Mon, 24 Aug 2026 20:36:53 +0530 Subject: [PATCH 2/2] fix(deps): revert lockfile and fastapi upper bound for the fork guard guard-fork-dependencies.yml rejects any uv.lock diff on a fork-origin PR, and this branch pushes from a fork. Revert uv.lock and the fastapi upper bound in pyproject.toml to the base branch's exact state; the Dependant field-walk fix itself works unchanged under the original >=0.136.3,<1.0 bound (already verified against both 0.136.3 and 0.141.1). --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ca93da4fd90..fca5c7da1e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ proxy = [ "uvicorn>=0.33.0,<1.0", "granian>=2.7.4,<3.0", "uvloop>=0.21.0,<1.0; sys_platform != 'win32'", - "fastapi>=0.136.3,<0.142", + "fastapi>=0.136.3,<1.0", "starlette>=1.0.1,<2.0", "backoff>=2.2.1,<3.0", "pyyaml>=6.0.3,<7.0", diff --git a/uv.lock b/uv.lock index de774e4caa8..628483c0117 100644 --- a/uv.lock +++ b/uv.lock @@ -4501,7 +4501,7 @@ requires-dist = [ { name = "detect-secrets", marker = "extra == 'proxy-runtime'", specifier = ">=1.5.0,<2.0" }, { name = "diskcache", marker = "extra == 'caching'", specifier = ">=5.6.3,<6.0" }, { name = "expression", marker = "extra == 'proxy'", specifier = ">=5.6.0,<6.0" }, - { name = "fastapi", marker = "extra == 'proxy'", specifier = ">=0.136.3,<0.142" }, + { name = "fastapi", marker = "extra == 'proxy'", specifier = ">=0.136.3,<1.0" }, { name = "fastapi-sso", marker = "extra == 'proxy'", specifier = ">=0.19.0,<1.0" }, { name = "fastuuid", specifier = ">=0.14.0,<1.0" }, { name = "google-cloud-aiplatform", marker = "extra == 'google'", specifier = ">=1.133.0,<2.0" },