Strip SERVER_ROOT_PATH before lazy-feature prefix match

LazyFeatureMiddleware compared the raw scope path against registered
prefixes (e.g. /policies), so requests under a server root path like
/api/v1/policies/... never matched, the feature never loaded, and the
endpoint returned 404. Strip the configured root path before matching,
normalizing trailing slashes and enforcing a component boundary so
/api does not falsely match /apiv2.
This commit is contained in:
Yuneng Jiang 2026-05-12 20:43:08 -07:00
parent be2552bead
commit 83f26d17c1
No known key found for this signature in database
2 changed files with 92 additions and 0 deletions

View File

@ -273,7 +273,21 @@ class LazyFeatureMiddleware:
if scope["type"] in ("http", "websocket") and len(self._loaded) < len(
self._features
):
# Lazy import to avoid pulling proxy.utils into this module's
# import graph (proxy_server imports both).
from litellm.proxy.utils import get_server_root_path
path = scope.get("path", "")
# Strip SERVER_ROOT_PATH so prefix matching works under a server
# root path. Without this, requests like /api/v1/policies/... never
# match the registered prefixes (/policies/...) and lazy features
# stay unloaded — every endpoint under them returns 404. The
# `+ "/"` boundary prevents false-positive matches (e.g. /apiv2
# against root /api). If the path doesn't start with the prefix
# (e.g. a reverse proxy already stripped it), we leave it alone.
root_path = get_server_root_path().rstrip("/")
if root_path and path.startswith(root_path + "/"):
path = path[len(root_path) :]
for feat in self._features:
if feat.module_path in self._loaded:
continue

View File

@ -6378,6 +6378,84 @@ class TestLazyFeatureMiddleware:
)
assert loads == ["json"]
@pytest.mark.asyncio
@pytest.mark.parametrize(
"server_root_path,request_path,should_load,case",
[
# SERVER_ROOT_PATH set: incoming path includes prefix → strip and match.
("/api/v1", "/api/v1/dummy/x", True, "root_path strip + match"),
# Trailing-slash env var must be normalized.
("/api/v1/", "/api/v1/dummy/x", True, "trailing-slash env normalization"),
# Reverse proxy already stripped the prefix → original path still matches.
("/api/v1", "/dummy/x", True, "pre-stripped path still loads"),
# No SERVER_ROOT_PATH set → unchanged behavior.
("", "/dummy/x", True, "no root path"),
# SERVER_ROOT_PATH=/ must be a no-op (not strip every leading slash).
("/", "/dummy/x", True, "root_path='/' is no-op"),
# Boundary check: /apiv2 must not match root /api.
("/api", "/apiv2/foo", False, "boundary check prevents false match"),
# Genuine non-match under root_path.
("/api/v1", "/api/v1/unrelated", False, "unrelated path under root"),
],
)
async def test_root_path_handling(
self, monkeypatch, server_root_path, request_path, should_load, case
):
"""
The middleware must strip SERVER_ROOT_PATH before prefix-matching so
lazy features load under deployments that set a server root path,
while handling boundary, trailing-slash, and reverse-proxy edge cases
correctly.
"""
from fastapi import FastAPI
from litellm.proxy._lazy_features import (
LazyFeature,
LazyFeatureMiddleware,
)
monkeypatch.setenv("SERVER_ROOT_PATH", server_root_path)
loads = []
def fake_register(app, module):
loads.append(getattr(module, "__name__", "?"))
feat = LazyFeature(
name=f"dummy_{case}",
module_path="json",
path_prefixes=("/dummy",),
register_fn=fake_register,
)
async def downstream(scope, receive, send):
await send({"type": "http.response.start", "status": 200, "headers": []})
await send({"type": "http.response.body", "body": b""})
target_app = FastAPI()
mw = LazyFeatureMiddleware(downstream, fastapi_app=target_app, features=(feat,))
async def receive():
return {"type": "http.request", "body": b"", "more_body": False}
async def send(message):
pass
await mw(
{
"type": "http",
"path": request_path,
"method": "GET",
"headers": [],
},
receive,
send,
)
if should_load:
assert loads == ["json"], f"{case}: expected feature to load"
else:
assert loads == [], f"{case}: feature must not load"
@pytest.mark.asyncio
async def test_concurrent_first_requests_only_register_once(self):
"""