fix(auth): enforce model access on Google and image URL routes (#51226)

* fix(auth): authorize URL-selected Google and image models

* fix(auth): preserve server model selection and budget fallbacks

* test(auth): isolate URL model authorization settings

* fix(auth): type handler model selection boundaries

* fix(auth): validate request model selection inputs

* fix(auth): tolerate absent request path metadata

* fix(auth): support requests without path metadata

---------

Co-authored-by: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com>
This commit is contained in:
joshua-berri 2026-11-11 12:22:12 -08:00 • committed by GitHub
parent 8f42db985d
commit deb7697a8e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 320 additions and 6 deletions

View file

@ -37,7 +37,11 @@ from litellm.llms.azure.passthrough.transformation import azure_router_model_in_
from litellm.llms.nvidia_nim.passthrough.transformation import nvidia_nim_model_group_in_path
from litellm.proxy._types import *
from litellm.proxy.auth.route_checks import RouteChecks
from litellm.proxy.common_utils.http_parsing_utils import extract_nested_form_metadata
from litellm.proxy.common_utils.http_parsing_utils import (
extract_nested_form_metadata,
read_request_path_params,
resolve_inference_model,
)
from litellm.types.passthrough_endpoints.pass_through_endpoints import (
LITELLM_PASS_THROUGH_ENDPOINT_MARKER,
)
@ -2672,6 +2676,7 @@ def get_model_from_request(
llm_router: Router | None = None,
request: Request | None = None,
team_id: str | None = None,
path_params: Mapping[str, object] | None = None,
) -> str | list[str] | None:
"""Resolve the model(s) a request targets, for model-access and budget checks.
@ -2690,6 +2695,12 @@ def get_model_from_request(
if request_dispatched_to_pass_through_endpoint(request):
return None
handler_request_data: Final = _MODEL_REQUEST_DATA.validate_python(request_data)
handler_path_params: Final = read_request_path_params(request) if request is not None else path_params
handler_model: Final = _handler_selected_model(route, handler_request_data, handler_path_params)
if handler_model is not None:
return handler_model
candidates: Final = _extract_model_candidates_from_request(
request_data=request_data,
route=route,
@ -2719,6 +2730,39 @@ def get_model_from_request(
return model
_HandlerModelKind: TypeAlias = Literal["completion", "image_generation", "image_edit"]
_MODEL_REQUEST_DATA: Final = TypeAdapter(Mapping[str, object])
_HANDLER_MODEL_ROUTES: Final[tuple[tuple[re.Pattern[str], str, _HandlerModelKind], ...]] = (
(re.compile(r"^(?:/v1beta)?/models/(.+):(?:generateContent|streamGenerateContent)$"), "model_name", "completion"),
(re.compile(r"^/openai/deployments/(.+)/images/generations$"), "model", "image_generation"),
(re.compile(r"^/openai/deployments/(.+)/images/edits$"), "model", "image_edit"),
)
def _handler_selected_model(
route: str, request_data: Mapping[str, object], path_params: Mapping[str, object] | None
) -> str | None:
selection: Final[tuple[re.Match[str], str, _HandlerModelKind] | None] = next(
((match, name, kind) for pattern, name, kind in _HANDLER_MODEL_ROUTES if (match := pattern.match(route))),
None,
)
if selection is None:
return None
from litellm.proxy.proxy_server import general_settings_view, user_model_view
route_match, path_param_name, kind = selection
route_model: Final = route_match.group(1)
path_model: Final = path_params.get(path_param_name) if path_params is not None else None
model: Final = resolve_inference_model(
request_data.get("model"),
general_settings_view(),
user_model_view(),
path_model if isinstance(path_model, str) and path_model else route_model,
kind=kind,
)
return model if isinstance(model, str) and model else route_model
def _model_from_route_path(route: str) -> str | None:
"""
Google generateContent-style routes allow "/" inside the model id, e.g.

View file

@ -153,6 +153,7 @@ from litellm.proxy.common_utils.http_parsing_utils import ( # noqa: F401 # leg
populate_request_with_path_params,
read_raw_json_body,
read_request_body,
read_request_path_params,
resolve_realtime_route_model,
rewrite_request_model,
safe_get_request_headers,
@ -4252,6 +4253,7 @@ async def _reserve_budget_after_common_checks(
reserve_budget_for_request,
)
reservation_path_params: Final = read_request_path_params(request)
user_api_key_auth_obj.budget_reservation = await reserve_budget_for_request(
request_body=request_data,
route=route,
@ -4270,6 +4272,7 @@ async def _reserve_budget_after_common_checks(
or user_api_key_auth_obj.billing_agent_policy is not None
),
raw_body=await read_raw_json_body(request=request),
path_params=reservation_path_params,
)
if request is not None:
reservation: Final = user_api_key_auth_obj.budget_reservation

View file

@ -37,6 +37,12 @@ _BINARY_CONTENT_TYPES: Final[frozenset[str]] = frozenset({"application/x-protobu
_ANNOTATION_QUALIFIERS: Final[frozenset[object]] = frozenset({Annotated, NotRequired, ReadOnly, Required})
_DECODED_JSON: Final = TypeAdapter(object)
_JSON_REQUEST_BODY: Final = TypeAdapter(Mapping[str, object])
_REQUEST_PATH_PARAMS: Final = TypeAdapter(Mapping[str, object])
def read_request_path_params(request: Request | None) -> Mapping[str, object] | None:
path_params: Final = _DECODED_JSON.validate_python(getattr(request, "path_params", None))
return _REQUEST_PATH_PARAMS.validate_python(path_params) if isinstance(path_params, Mapping) else None
class _ReadableUpload(Protocol):

View file

@ -3183,6 +3183,10 @@ def general_settings_view() -> Mapping[str, object]:
return _GENERAL_SETTINGS_VIEW.validate_python(general_settings)
def user_model_view() -> str | None:
return user_model if isinstance(user_model, str) else None
config_passthrough_endpoints: list[dict[str, object]] | None = None
log_file: Final = "api_log.json"
worker_config: Final = None

View file

@ -262,12 +262,13 @@ async def reserve_budget_for_request(
apply_user_budget_to_team_keys: bool = False,
fail_closed_budget_enforcement: bool = False,
raw_body: bytes | None = None,
path_params: Mapping[str, object] | None = None,
) -> dict | None:
if valid_token is None or not RouteChecks.is_llm_api_route(route=route):
return None
if RouteChecks.is_unbilled_route(route):
return None
if get_model_from_request(request_body, route, llm_router=llm_router) is None:
if get_model_from_request(request_body, route, llm_router=llm_router, path_params=path_params) is None:
return None
counters: Final = await _get_budget_counters(
@ -291,6 +292,7 @@ async def reserve_budget_for_request(
llm_router=llm_router,
team_id=valid_token.team_id,
raw_body=raw_body,
path_params=path_params,
)
reservation_cost = estimate_request_max_cost(
@ -298,6 +300,7 @@ async def reserve_budget_for_request(
route=route,
llm_router=llm_router,
input_token_counts=input_token_counts,
path_params=path_params,
)
# estimate_request_max_cost still returns None when the model is unknown
# to the cost map (no token-priced cost fields, e.g. image/audio routes).
@ -334,6 +337,7 @@ async def reserve_budget_for_request(
route=route,
llm_router=llm_router,
input_token_counts=input_token_counts,
path_params=path_params,
)
budget_reservation: Final = {
"reserved_cost": reservation_cost,
@ -1319,6 +1323,7 @@ def estimate_request_max_cost(
route: str,
llm_router: Router | None,
input_token_counts: Mapping[str, int] | None = None,
path_params: Mapping[str, object] | None = None,
) -> float | None:
estimates = [
_estimate_request_max_cost_for_model(
@ -1328,7 +1333,9 @@ def estimate_request_max_cost(
llm_router=llm_router,
input_tokens=(input_token_counts or {}).get(model_name),
)
for model_name in _get_request_models(request_body=request_body, route=route, llm_router=llm_router)
for model_name in _get_request_models(
request_body=request_body, route=route, llm_router=llm_router, path_params=path_params
)
]
estimates = [estimate for estimate in estimates if estimate is not None]
if not estimates:
@ -1341,6 +1348,7 @@ def estimate_request_input_cost(
route: str,
llm_router: Router | None,
input_token_counts: Mapping[str, int] | None = None,
path_params: Mapping[str, object] | None = None,
) -> float | None:
"""Cost of the request's input tokens alone.
@ -1357,7 +1365,9 @@ def estimate_request_input_cost(
llm_router=llm_router,
input_tokens=(input_token_counts or {}).get(model_name),
)
for model_name in _get_request_models(request_body=request_body, route=route, llm_router=llm_router)
for model_name in _get_request_models(
request_body=request_body, route=route, llm_router=llm_router, path_params=path_params
)
]
estimates = [estimate for estimate in estimates if estimate is not None]
if not estimates:
@ -1609,8 +1619,9 @@ def _get_request_models(
request_body: dict,
route: str,
llm_router: Router | None,
path_params: Mapping[str, object] | None = None,
) -> Sequence[str]:
model: Final = get_model_from_request(request_body, route, llm_router=llm_router)
model: Final = get_model_from_request(request_body, route, llm_router=llm_router, path_params=path_params)
if model is None:
return ()
return (model,) if isinstance(model, str) else tuple(model)
@ -1622,11 +1633,14 @@ async def count_request_input_tokens(
llm_router: Router | None,
team_id: str | None,
raw_body: bytes | None = None,
path_params: Mapping[str, object] | None = None,
) -> Mapping[str, int]:
"""Input-token count per candidate model, counted once per request.
The counts are reused by both the max-cost and the input-cost estimate."""
models: Final = _get_request_models(request_body=request_body, route=route, llm_router=llm_router)
models: Final = _get_request_models(
request_body=request_body, route=route, llm_router=llm_router, path_params=path_params
)
if not models:
return MappingProxyType({})
counted_models: Final = MappingProxyType(

View file

@ -1379,6 +1379,107 @@ def test_get_model_from_request_supports_google_model_names_with_slashes():
)
_URL_SELECTED_MODEL_ROUTES: Final = (
("/v1beta/models/premium-model:generateContent", "premium-model"),
("/models/premium-model:generateContent", "premium-model"),
("/v1beta/models/premium-model:streamGenerateContent", "premium-model"),
("/models/provider/premium-model:7b:streamGenerateContent", "provider/premium-model:7b"),
("/openai/deployments/premium-model/images/generations", "premium-model"),
("/openai/deployments/provider/premium-model/images/edits", "provider/premium-model"),
)
@pytest.fixture
def no_inference_model_override(monkeypatch: pytest.MonkeyPatch) -> None:
from litellm.proxy import proxy_server
monkeypatch.setattr(proxy_server, "general_settings", {})
monkeypatch.setattr(proxy_server, "user_model", None)
@pytest.mark.usefixtures("no_inference_model_override")
@pytest.mark.parametrize("route, url_model", _URL_SELECTED_MODEL_ROUTES)
@pytest.mark.parametrize(
"request_data",
[
{"model": "cheap-model"},
{"target_model_names": ["cheap-model"]},
{"model": "cheap-model", "target_model_names": "cheap-model"},
],
)
def test_get_model_from_request_checks_the_url_model_on_routes_served_from_the_url(
route: str, url_model: str, request_data: dict[str, object]
) -> None:
assert get_model_from_request(request_data=request_data, route=route) == url_model
@pytest.mark.usefixtures("no_inference_model_override")
@pytest.mark.parametrize(
"route, path_params",
[
("/v1beta/models/premium-model:generateContent", {"model_name": "cheap-model"}),
("/models/premium-model:streamGenerateContent", {"model_name": "cheap-model"}),
("/openai/deployments/premium-model/images/generations", {"model": "cheap-model"}),
("/openai/deployments/premium-model/images/edits", {"model": "cheap-model"}),
],
)
def test_get_model_from_request_follows_the_handlers_path_param_over_route_and_body(
route: str, path_params: dict[str, str]
) -> None:
request: Final = Request(scope={"type": "http", "path": route, "path_params": path_params, "headers": []})
body: Final = {"model": "body-model"}
assert get_model_from_request(request_data=body, route=route, request=request) == "cheap-model"
assert get_model_from_request(request_data=body, route=route, path_params=path_params) == "cheap-model"
assert get_model_from_request(request_data=body, route=route, path_params={"other": "x"}) == "premium-model"
@pytest.mark.parametrize(
"route, settings, cli_model, expected",
[
("/v1beta/models/url-model:generateContent", {"completion_model": "configured"}, None, "configured"),
("/models/url-model:streamGenerateContent", {}, "cli", "cli"),
("/v1beta/models/url-model:generateContent", {"image_generation_model": "configured"}, None, "url-model"),
("/openai/deployments/url-model/images/generations", {}, "cli", "cli"),
("/openai/deployments/url-model/images/generations", {"completion_model": "configured"}, None, "url-model"),
(
"/openai/deployments/url-model/images/generations",
{"image_generation_model": "configured"},
None,
"url-model",
),
("/openai/deployments/url-model/images/edits", {"completion_model": "configured"}, "cli", "configured"),
("/openai/deployments/url-model/images/edits", {}, "cli", "cli"),
("/openai/deployments/url-model/images/edits", {"image_generation_model": "configured"}, None, "url-model"),
],
)
def test_get_model_from_request_applies_the_handlers_server_model_precedence(
monkeypatch: pytest.MonkeyPatch,
route: str,
settings: dict[str, str],
cli_model: str | None,
expected: str,
) -> None:
from litellm.proxy import proxy_server
monkeypatch.setattr(proxy_server, "general_settings", settings)
monkeypatch.setattr(proxy_server, "user_model", cli_model)
assert get_model_from_request(request_data={"model": "body-model"}, route=route) == expected
@pytest.mark.usefixtures("no_inference_model_override")
@pytest.mark.parametrize("route", ["/v1/images/generations", "/images/edits", "/v1/chat/completions"])
def test_get_model_from_request_keeps_the_body_model_where_the_url_names_none(route: str) -> None:
assert get_model_from_request(request_data={"model": "cheap-model"}, route=route) == "cheap-model"
@pytest.mark.parametrize("route, url_model", _URL_SELECTED_MODEL_ROUTES)
def test_get_model_from_request_skips_url_model_on_configured_pass_through(route: str, url_model: str) -> None:
request: Final = _request_dispatched_to(_pass_through_endpoint())
assert get_model_from_request(request_data={"model": url_model}, route=route, request=request) is None
def test_get_model_from_request_vertex_passthrough_still_works():
route = "/vertex_ai/v1/projects/p/locations/l/publishers/google/models/gemini-1.5-pro:generateContent"
assert get_model_from_request(request_data={}, route=route) == "gemini-1.5-pro"

View file

@ -1293,6 +1293,54 @@ def test_get_model_from_request(route, request_data, expected_model):
assert get_model_from_request(request_data, route) == expected_model
@pytest.mark.asyncio
@pytest.mark.parametrize(
"route_template",
[
"/v1beta/models/{model}:generateContent",
"/models/{model}:streamGenerateContent",
"/openai/deployments/{model}/images/generations",
"/openai/deployments/{model}/images/edits",
],
)
@pytest.mark.parametrize("body", [{"model": "cheap-model"}, {"target_model_names": ["cheap-model"]}])
async def test_key_model_allowlist_applies_to_the_model_selected_by_the_url(
monkeypatch: pytest.MonkeyPatch, route_template: str, body: dict[str, object]
) -> None:
from litellm.proxy._types import ProxyErrorTypes
from litellm.proxy.auth.user_api_key_auth import enforce_key_and_fallback_model_access
monkeypatch.setattr(litellm.proxy.proxy_server, "llm_router", None)
monkeypatch.setattr(litellm.proxy.proxy_server, "general_settings", {})
monkeypatch.setattr(litellm.proxy.proxy_server, "user_model", None)
router: Final = litellm.Router(
model_list=[
{"model_name": name, "litellm_params": {"model": f"openai/{name}", "api_key": "sk-fake"}}
for name in ("cheap-model", "premium-model")
]
)
token: Final = UserAPIKeyAuth(models=["cheap-model"])
async def authorize(url_model: str) -> None:
route: Final = route_template.format(model=url_model)
request: Final = StarletteRequest(
{"type": "http", "method": "POST", "path": route, "headers": [], "query_string": b""}
)
await enforce_key_and_fallback_model_access(
valid_token=token,
request_data=dict(body),
route=route,
request=request,
llm_model_list=list(router.model_list),
llm_router=router,
)
await authorize("cheap-model")
with pytest.raises(ProxyException) as exc_info:
await authorize("premium-model")
assert exc_info.value.type == ProxyErrorTypes.key_model_access_denied
@pytest.mark.asyncio
async def test_jwt_non_admin_team_route_access(monkeypatch):
"""

View file

@ -10952,6 +10952,42 @@ class TestCheckTeamMemberModelBudgetWithFallback:
assert request_data["model"] == "gpt-4o-mini"
@pytest.mark.asyncio
@pytest.mark.parametrize("operation", ["generations", "edits"])
async def test_image_deployment_key_fallback_is_the_model_later_checks_and_reservation_see(
self, monkeypatch: pytest.MonkeyPatch, operation: str
) -> None:
from litellm.proxy import proxy_server
from litellm.proxy.auth.auth_utils import get_model_from_request
monkeypatch.setattr(proxy_server, "general_settings", {})
monkeypatch.setattr(proxy_server, "user_model", None)
route: Final = f"/openai/deployments/gpt-4o/images/{operation}"
valid_token: Final = UserAPIKeyAuth(
token="test-key",
models=["gpt-4o", "gpt-4o-mini"],
budget_fallbacks={"gpt-4o": ["gpt-4o-mini"]},
model_max_budget={
"gpt-4o": {"max_budget": 5.0, "budget_duration": "1d"},
"gpt-4o-mini": {"max_budget": 100.0, "budget_duration": "1d"},
},
)
limiter, _ = await _real_model_budget_limiter(key_spend={"gpt-4o": 10.0})
request_data: Final[dict[str, object]] = {"model": "gpt-4o"}
request: Final = self._request("gpt-4o")
assert get_model_from_request(request_data, route, request=request) == "gpt-4o"
await _check_key_model_budget_with_fallback(
valid_token=valid_token,
model_max_budget_limiter=limiter,
model_name="gpt-4o",
request_data=request_data,
request=request,
)
assert get_model_from_request(request_data, route, request=request) == "gpt-4o-mini"
assert get_model_from_request(request_data, route, path_params=request.path_params) == "gpt-4o-mini"
@pytest.mark.asyncio
async def test_fallback_outside_member_allowed_models_is_rejected(self):
valid_token: Final = UserAPIKeyAuth(
@ -13440,6 +13476,10 @@ async def test_managed_actor_cannot_access_provider_resource_routes(monkeypatch,
("/v1/chat/completions", {}, "forbidden-model"),
("/v1/images/generations", {"image_generation_model": "forbidden-model"}, None),
("/v1/images/edits", {"image_generation_model": "forbidden-model"}, None),
("/v1beta/models/test-model:generateContent", {"completion_model": "forbidden-model"}, None),
("/models/test-model:streamGenerateContent", {}, "forbidden-model"),
("/openai/deployments/test-model/images/generations", {}, "forbidden-model"),
("/openai/deployments/test-model/images/edits", {"completion_model": "forbidden-model"}, None),
],
)
async def test_managed_agent_cannot_bypass_grants_with_server_default(

View file

@ -31,6 +31,7 @@ from litellm.proxy.common_utils.http_parsing_utils import (
numeric_form_fields,
populate_request_with_path_params,
read_raw_json_body,
read_request_path_params,
)
from fastapi import Request as Request_http_parsing
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
@ -1786,3 +1787,22 @@ async def test_convert_upload_files_to_file_data_reads_every_upload_into_file_tu
"tags": ["red", "blue"],
"display_title": "My skill",
}
@pytest.mark.parametrize(
"scope_path_params, expected",
[
({"model": "deployment/name"}, {"model": "deployment/name"}),
({}, {}),
(None, None),
],
)
def test_read_request_path_params_returns_mappings_and_drops_non_mapping_metadata(
scope_path_params: Mapping[str, object] | None, expected: Mapping[str, object] | None
) -> None:
request: Final = Request({"type": "http", "path_params": scope_path_params})
assert read_request_path_params(request) == expected
def test_read_request_path_params_without_request_is_none() -> None:
assert read_request_path_params(None) is None

View file

@ -223,6 +223,40 @@ def test_deployment_pricing_update_invalidates_cached_estimate() -> None:
assert math.isclose(after, before * 1000)
def _priced_group(model_name: str, cost_per_token: float) -> dict[str, object]:
return Deployment(
model_name=model_name,
litellm_params=LiteLLM_Params(model="dashscope/qwen3-max", api_key="sk-fake"),
model_info=ModelInfo(
id=model_name,
max_output_tokens=1000,
tiered_pricing=[
{"input_cost_per_token": cost_per_token, "output_cost_per_token": cost_per_token, "range": [0, 128000]}
],
),
).model_dump()
def test_google_route_reserves_on_the_model_its_path_param_selects(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(proxy_server, "general_settings", {})
monkeypatch.setattr(proxy_server, "user_model", None)
router: Final = Router(model_list=[_priced_group("cheap-group", 1e-06), _priced_group("premium-group", 1e-03)])
route: Final = "/v1beta/models/premium-group:generateContent"
body: Final = {"model": "cheap-group", "messages": [{"role": "user", "content": "hello"}], "max_tokens": 10}
url_model_cost: Final = estimate_request_max_cost(request_body=body, route=route, llm_router=router)
path_param_cost: Final = estimate_request_max_cost(
request_body=body, route=route, llm_router=router, path_params={"model_name": "cheap-group"}
)
cheap_cost: Final = estimate_request_max_cost(
request_body=body, route="/v1beta/models/cheap-group:generateContent", llm_router=router
)
assert url_model_cost is not None and cheap_cost is not None
assert path_param_cost == cheap_cost
assert math.isclose(url_model_cost, cheap_cost * 1000)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"expiry_offset, expected_max_budget",