litellm/litellm/proxy/auth/auth_checks.py
Yassin Kortam 1d695a714b
fix(proxy): reset a stuck team member's budget (#37971)
* fix(proxy): reset a stuck team member's budget

A per-team-member budget check reads a cross-pod spend counter that
nothing ever invalidates. Once a member exceeds their per-member
budget, resetting the key's spend, raising the user's or the team's
own budget, or issuing a new key all leave the member stuck, because
none of them touch this counter or its cached membership object.

Add POST /team/{team_id}/member/{user_id}/reset_spend to reset a
member's tracked spend, and invalidate the same cached state from
/team/member_update when it raises a member's own budget, so that
path also takes effect immediately instead of waiting on the
membership cache's TTL. Name the entity in the check's error message
so a stuck member is diagnosable from the 429 body alone.

* fix(proxy): close reset-vs-floor-read race and surface double Redis write failure on member spend reset

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(proxy): broadcast spend reset as a SET so the handler's self-delivered message cannot erase the reset guard

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(proxy): omit null fields from the invalidation message so plain evictions keep the old wire format

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-25 09:50:09 -07:00

5416 lines
198 KiB
Python

# What is this?
## Common auth checks between jwt + key based auth
"""
Got Valid Token from Cache, DB
Run checks for:
1. If user can call model
2. If user is in budget
3. If end_user ('user' passed to /chat/completions, /embeddings endpoint) is in budget
"""
import asyncio
import math
import re
import time
from collections.abc import Awaitable, Callable, Iterator, Mapping, Sequence
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, Literal, Optional, Protocol, cast
from fastapi import HTTPException, Request, status
from pydantic import BaseModel
from typing_extensions import ReadOnly, TypedDict
import litellm
from litellm._logging import verbose_proxy_logger
from litellm.caching.dual_cache import LimitedSizeOrderedDict
from litellm.constants import (
CLI_JWT_EXPIRATION_HOURS,
CLI_SESSION_KEY_PREFIX,
DEFAULT_ACCESS_GROUP_CACHE_TTL,
DEFAULT_IN_MEMORY_TTL,
DEFAULT_MAX_RECURSE_DEPTH,
EMAIL_BUDGET_ALERT_MAX_SPEND_ALERT_PERCENTAGE,
END_USER_RESTRICTED_REGISTRY_MAX_SIZE,
REGISTRY_ERROR_NEGATIVE_CACHE_TTL,
TAG_REGISTRY_MAX_SIZE,
)
from litellm.litellm_core_utils.dd_tracing import tracer
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
from litellm.litellm_core_utils.safe_json_loads import safe_json_loads
from litellm.proxy._types import (
RBAC_ROLES,
CallInfo,
LiteLLM_AccessGroupTable,
LiteLLM_BudgetTable,
LiteLLM_EndUserTable,
Litellm_EntityType,
LiteLLM_JWTAuth,
LiteLLM_ManagedVectorStoresTable,
LiteLLM_ObjectPermissionTable,
LiteLLM_OrganizationMembershipTable,
LiteLLM_OrganizationTable,
LiteLLM_ProjectTableCachedObj,
LiteLLM_TagTable,
LiteLLM_TeamMembership,
LiteLLM_TeamTable,
LiteLLM_TeamTableCachedObj,
LiteLLM_UserTable,
LiteLLMRoutes,
LitellmUserRoles,
NewTeamRequest,
ProxyErrorTypes,
ProxyException,
RoleBasedPermissions,
SpecialModelNames,
UserAPIKeyAuth,
)
from litellm.proxy.auth.budget_throttle import (
budget_throttle_percentage,
should_throttle_budget_exceeded,
)
from litellm.proxy.auth.route_checks import RouteChecks
from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import publish_auth_cache_invalidation
from litellm.proxy.common_utils.cache_pydantic_utils import CacheCodec
from litellm.proxy.common_utils.http_parsing_utils import (
_safe_get_request_headers,
_safe_get_request_query_params,
)
from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time
from litellm.proxy.common_utils.user_api_key_cache import (
END_USER_RESTRICTED_REGISTRY_OVERFLOW_SENTINEL,
TAG_REGISTRY_OVERFLOW_SENTINEL,
UserApiKeyCache,
end_user_cache_key,
end_user_restricted_registry_cache_key,
get_management_object_ttl,
object_permission_cache_key,
tag_cache_key,
tag_registry_cache_key,
team_membership_auth_cache_key,
team_membership_reservation_cache_key,
)
from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler
from litellm.proxy.guardrails.tool_name_extraction import (
TOOL_CAPABLE_CALL_TYPES,
extract_request_tool_names,
)
from litellm.proxy.route_llm_request import route_request
from litellm.proxy.spend_tracking.budget_reservation import get_budget_window_start
from litellm.proxy.utils import PrismaClient, ProxyLogging, log_db_metrics
from litellm.repositories.budget_repository import BudgetRepository
from litellm.repositories.object_permission_repository import ObjectPermissionRepository
from litellm.repositories.organization_repository import OrganizationRepository
from litellm.repositories.prisma_protocols import RowT_co
from litellm.repositories.project_repository import ProjectRepository
from litellm.repositories.table_repositories import (
AccessGroupRepository,
EndUserRepository,
JWTKeyMappingRepository,
ManagedVectorStoresRepository,
TagRepository,
TeamMembershipRepository,
)
from litellm.repositories.team_repository import TeamRepository
from litellm.repositories.user_repository import UserRepository
from litellm.router import Router
from litellm.utils import get_utc_datetime
from .auth_checks_organization import (
add_team_org_context_to_request_body,
organization_role_based_access_check,
)
from .auth_utils import get_model_from_request, get_request_route_template
if TYPE_CHECKING:
from opentelemetry.trace import Span as _Span
Span = _Span
else:
Span = Any
class _PrismaDictableRow(Protocol):
def dict(self) -> Mapping[str, object]: ...
class _PrismaJWTKeyMappingRow(Protocol):
token: str
class _PrismaModelDumpRow(Protocol):
def model_dump(self) -> Mapping[str, object]: ...
class _PrismaTeamRow(Protocol):
def dict(self) -> Mapping[str, object]: ...
def model_dump(self) -> Mapping[str, object]: ...
class _PrismaVectorStoreRow(Protocol):
def dict(self) -> Mapping[str, object]: ...
def model_dump(self) -> Mapping[str, object]: ...
def __iter__(self) -> Iterator[tuple[str, object]]: ...
class _PrismaUserRow(Protocol):
user_id: str
organization_memberships: Sequence[LiteLLM_OrganizationMembershipTable | None] | None
def __iter__(self) -> Iterator[tuple[str, object]]: ...
class _PrismaAuthTable(Protocol[RowT_co]):
async def find_unique(
self, *, where: Mapping[str, object], include: Mapping[str, object] | None = None
) -> RowT_co | None: ...
async def find_first(
self, *, where: Mapping[str, object], include: Mapping[str, object] | None = None
) -> RowT_co | None: ...
async def find_many(
self,
*,
where: Mapping[str, object] | None = None,
include: Mapping[str, object] | None = None,
take: int | None = None,
) -> Sequence[RowT_co]: ...
async def update(self, *, where: Mapping[str, object], data: Mapping[str, object]) -> RowT_co | None: ...
async def create(self, *, data: Mapping[str, object], include: Mapping[str, object] | None = None) -> RowT_co: ...
class _PrismaTableHolder(Protocol[RowT_co]):
@property
def table(self) -> _PrismaAuthTable[RowT_co]: ...
def _dictable_table(repo: _PrismaTableHolder[_PrismaDictableRow]) -> _PrismaAuthTable[_PrismaDictableRow]:
return repo.table
def _jwt_key_mapping_table(
repo: _PrismaTableHolder[_PrismaJWTKeyMappingRow],
) -> _PrismaAuthTable[_PrismaJWTKeyMappingRow]:
return repo.table
def _model_dump_table(repo: _PrismaTableHolder[_PrismaModelDumpRow]) -> _PrismaAuthTable[_PrismaModelDumpRow]:
return repo.table
def _team_table(repo: _PrismaTableHolder[_PrismaTeamRow]) -> _PrismaAuthTable[_PrismaTeamRow]:
return repo.table
def _vector_store_table(repo: _PrismaTableHolder[_PrismaVectorStoreRow]) -> _PrismaAuthTable[_PrismaVectorStoreRow]:
return repo.table
def _user_table(repo: _PrismaTableHolder[_PrismaUserRow]) -> _PrismaAuthTable[_PrismaUserRow]:
return repo.table
def _object_permission_table(
repo: _PrismaTableHolder[LiteLLM_ObjectPermissionTable],
) -> _PrismaAuthTable[LiteLLM_ObjectPermissionTable]:
return repo.table
class _PrismaTagRow(Protocol):
tag_name: str
def dict(self) -> Mapping[str, object]: ...
def _tag_table(repo: _PrismaTableHolder[_PrismaTagRow]) -> _PrismaAuthTable[_PrismaTagRow]:
return repo.table
class _PrismaEndUserRow(Protocol):
user_id: str
def dict(self) -> Mapping[str, object]: ...
def _end_user_table(repo: _PrismaTableHolder[_PrismaEndUserRow]) -> _PrismaAuthTable[_PrismaEndUserRow]:
return repo.table
class _RawCacheRead(Protocol):
async def async_get_cache(self, *, key: str) -> object: ...
def _raw_cache(cache: _RawCacheRead) -> _RawCacheRead:
return cache
def _typed_request_body(request_body: dict) -> Mapping[str, object]:
return request_body
class _JsonLoadsObj(Protocol):
def __call__(self, data: str) -> object: ...
def _typed_json_loads(fn: _JsonLoadsObj) -> _JsonLoadsObj:
return fn
_safe_json_loads_obj: Final = _typed_json_loads(safe_json_loads)
last_db_access_time: Final = LimitedSizeOrderedDict(max_size=100)
db_cache_expiry: Final = DEFAULT_IN_MEMORY_TTL # refresh every 5s
all_routes: Final = LiteLLMRoutes.openai_routes.value + LiteLLMRoutes.management_routes.value
def _log_budget_lookup_failure(entity: str, error: Exception) -> None:
"""
Log a warning when budget lookup fails; cache will not be populated.
Skips logging for expected "user not found" cases (bare Exception from
get_user_object when user_id_upsert=False). Adds a schema migration hint
when the error appears schema-related.
"""
# Skip logging for expected "user not found" - not caching is correct
if str(error) == "" and type(error).__name__ == "Exception":
return
err_str: Final = str(error).lower()
hint = ""
if any(x in err_str for x in ("column", "schema", "does not exist", "prisma", "migrate")):
hint = " Run `prisma db push` or `prisma migrate deploy` to fix schema mismatches."
verbose_proxy_logger.error(
"Budget lookup failed for %s; cache will not be populated. Each request will hit the database. Error: %s.%s",
entity,
error,
hint,
)
def _get_router_zero_cost_cache(llm_router: Router) -> dict[str, bool] | None:
"""
Return the router's per-instance zero-cost cache, or ``None`` for objects
that don't expose one (e.g. ``MagicMock`` stand-ins in unit tests).
The cache lives on the ``Router`` instance so it:
* is invalidated by ``Router._invalidate_model_group_info_cache`` on
any model add/remove/upsert (including in-place pricing changes via
``/model/update``, which go through ``upsert_deployment``);
* dies with the router itself — no risk of CPython reusing the
previous router's ``id()`` and serving its cached entries.
"""
cache: Final = getattr(llm_router, "_zero_cost_cache", None)
return cache if isinstance(cache, dict) else None
def _is_model_cost_zero(model: str | list[str] | None, llm_router: Router | None) -> bool:
"""
Check if a model has zero cost (no configured pricing).
Uses the router's get_model_group_info method to get pricing information.
Args:
model: The model name or list of model names
llm_router: The LiteLLM router instance
Returns:
bool: True if all costs for the model are zero, False otherwise
"""
if model is None or llm_router is None:
return False
# Handle list of models
model_list: Final = [model] if isinstance(model, str) else model
zero_cost_cache: Final = _get_router_zero_cost_cache(llm_router)
for model_name in model_list:
if zero_cost_cache is not None:
cached = zero_cost_cache.get(model_name)
if cached is not None:
if cached is False:
return False
continue
try:
# Use router's get_model_group_info method directly for better reliability
model_group_info = llm_router.get_model_group_info(model_group=model_name)
if model_group_info is None:
# Model not found or no pricing info available
# Conservative approach: assume it has cost
verbose_proxy_logger.debug("No model group info found for %s, assuming it has cost", model_name)
if zero_cost_cache is not None:
zero_cost_cache[model_name] = False
return False
# Check costs for this model
# Only allow bypass if BOTH costs are explicitly set to 0 (not None)
input_cost = model_group_info.input_cost_per_token
output_cost = model_group_info.output_cost_per_token
# If costs are not explicitly configured (None), assume it has cost
if input_cost is None or output_cost is None:
verbose_proxy_logger.debug(
"Model %s has undefined cost (input: %s, output: %s), assuming it has cost",
model_name,
input_cost,
output_cost,
)
if zero_cost_cache is not None:
zero_cost_cache[model_name] = False
return False
# If either cost is non-zero, return False
if input_cost > 0 or output_cost > 0:
verbose_proxy_logger.debug(
"Model %s has non-zero cost (input: %s, output: %s)", model_name, input_cost, output_cost
)
if zero_cost_cache is not None:
zero_cost_cache[model_name] = False
return False
# Costs are 0 — verify this is from explicit configuration,
# not from defaulted sparse auto-registration entries.
# See: https://github.com/BerriAI/litellm/issues/24770
safe_name = str(model_name).replace("\n", "").replace("\r", "")
if not _is_cost_explicitly_configured(model_name, llm_router):
verbose_proxy_logger.debug(
"Model %s has zero cost but no explicit cost "
"configuration in model_cost entry — treating as unknown "
"cost (enforce budget)",
safe_name,
)
if zero_cost_cache is not None:
zero_cost_cache[model_name] = False
return False
if _has_ptu_flat_cost(model_name, llm_router):
verbose_proxy_logger.debug(
"Model %s prices reserved PTU capacity as a flat cost, so its zero per-token "
"rate is not a free model (enforce budget)",
safe_name,
)
if zero_cost_cache is not None:
zero_cost_cache[model_name] = False
return False
verbose_proxy_logger.debug(
"Model %s has zero cost explicitly configured (input: %s, output: %s)",
safe_name,
input_cost,
output_cost,
)
if zero_cost_cache is not None:
zero_cost_cache[model_name] = True
except Exception as e:
# If we can't determine the cost, assume it has cost (conservative approach)
verbose_proxy_logger.debug("Error checking cost for model %s: %s, assuming it has cost", model_name, e)
return False
# All models checked have zero cost
return True
_NO_MODEL_INFO: Final[Mapping[str, object]] = MappingProxyType({})
def _has_ptu_flat_cost(model: str, llm_router: "Router") -> bool:
"""Whether any deployment in the model group bills reserved PTU capacity as a flat cost.
Such a deployment carries an explicit zero per-token price so the flat cost is not charged
twice, which otherwise reads here as a free model and waives every budget check for it.
"""
for deployment in llm_router.model_list:
if deployment.get("model_name") != model:
continue
model_info = deployment.get("model_info") or _NO_MODEL_INFO
if model_info.get("ptu_count") is not None and model_info.get("cost_per_ptu_per_hour") is not None:
return True
return False
def _is_cost_explicitly_configured(model: str, llm_router: "Router") -> bool:
"""
Check if any deployment in the model group has cost fields explicitly
set in its litellm.model_cost entry.
When Router._create_deployment() registers a model not in the global
cost map, it creates a sparse entry like {"id": "<hash>"} with no cost
fields. _get_model_info_helper() then defaults missing costs to 0.
This function detects that scenario by checking the raw model_cost entry.
"""
for deployment in llm_router.model_list:
if deployment.get("model_name") != model:
continue
model_id = deployment.get("model_info", {}).get("id")
if model_id is None:
continue
raw_entry = litellm.model_cost.get(model_id, {})
if "input_cost_per_token" in raw_entry or "output_cost_per_token" in raw_entry:
return True
return False
_EMPTY_COST_ENTRY: Final[Mapping[str, object]] = MappingProxyType({})
def _is_positive_cost(value: object) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0
def _entry_has_priced_metric(entry: Mapping[str, object]) -> bool:
if entry.get("tiered_pricing") is not None:
return True
for key, value in entry.items():
if "cost_per" not in key:
continue
if _is_positive_cost(value):
return True
if isinstance(value, dict) and any(_is_positive_cost(nested) for nested in value.values()):
return True
return False
def _entry_declares_price(entry: Mapping[str, object]) -> bool:
return any("cost_per" in key or key == "tiered_pricing" for key in entry)
def _model_group_has_pricing(model: str, llm_router: "Router") -> bool:
"""
A model group counts as priced when a deployment overrides any *cost_per* field or
tiered_pricing in its litellm_params, even at zero, or when its resolved model info carries
tiered_pricing or a positive price on any billed metric (tokens, characters, seconds, pages,
images, queries, ...), so models billed by a non-token metric are not treated as unpriced.
"""
for deployment in llm_router.get_model_list(model_name=model) or ():
litellm_params = deployment.get("litellm_params") or _EMPTY_COST_ENTRY
if _entry_declares_price(litellm_params):
return True
model_id = (deployment.get("model_info") or _EMPTY_COST_ENTRY).get("id")
if model_id is None:
continue
model_info = llm_router.get_deployment_model_info(
model_id=model_id, model_name=litellm_params.get("model") or ""
)
if model_info is not None and _entry_has_priced_metric(model_info):
return True
return False
def _group_declares_explicit_cost(model: str, llm_router: "Router") -> bool:
"""
Alias-aware counterpart to ``_is_cost_explicitly_configured``, which resolves the model group
the same way ``_model_group_has_pricing`` does. A deployment that prices itself through its
``model_info`` block lands in the cost map under its deployment id rather than in its
litellm_params, and reaching that entry through the router's own resolution keeps an alias
pointing at such a group from being read as unpriced.
"""
for deployment in llm_router.get_model_list(model_name=model) or ():
model_id = (deployment.get("model_info") or _EMPTY_COST_ENTRY).get("id")
if model_id is None:
continue
raw_entry = litellm.model_cost.get(model_id, _EMPTY_COST_ENTRY)
if "input_cost_per_token" in raw_entry or "output_cost_per_token" in raw_entry:
return True
return False
def model_has_no_cost_mapping(model: str | None, llm_router: Router | None) -> bool:
if not model or llm_router is None:
return False
if llm_router.get_model_group_info(model_group=model) is None:
return False
if _model_group_has_pricing(model=model, llm_router=llm_router):
return False
return not _group_declares_explicit_cost(model=model, llm_router=llm_router)
def _unpriced_models_in_request(model: str | list[str] | None, llm_router: Router | None) -> tuple[str, ...]:
candidates: Final = (model,) if isinstance(model, str) else tuple(model or ())
return tuple(
candidate for candidate in candidates if model_has_no_cost_mapping(model=candidate, llm_router=llm_router)
)
def _unpriced_models_block_message(models: tuple[str, ...]) -> str:
names: Final = ", ".join(f"'{model}'" for model in models)
subject: Final = f"Model {names} has" if len(models) == 1 else f"Models {names} have"
return (
f"{subject} no pricing in the cost map, so litellm cannot price the request. "
"Requests for unpriced models are blocked because 'block_requests_for_models_without_pricing' "
"is enabled. Add pricing (input_cost_per_token/output_cost_per_token) to allow the request."
)
async def _run_project_checks(
project_object: LiteLLM_ProjectTableCachedObj | None,
_model: str | list[str] | None,
llm_router: Router | None,
skip_budget_checks: bool,
valid_token: UserAPIKeyAuth | None,
proxy_logging_obj: ProxyLogging,
) -> None:
"""
Run all project-level checks: blocked, model access, budget, soft budget.
Extracted from common_checks() to keep statement count manageable.
"""
if project_object is None:
return
# 1.1. If project is blocked
if project_object.blocked is True:
raise Exception(
f"Project={project_object.project_id} is blocked. Update via `/project/update` if you're an admin."
)
# 2.2 If project can call model
if _model and len(project_object.models) > 0:
can_project_access_model(
model=_model,
project_object=project_object,
llm_router=llm_router,
)
if not skip_budget_checks:
# 3.0.2. If project is in budget
await _project_max_budget_check(
project_object=project_object,
valid_token=valid_token,
proxy_logging_obj=proxy_logging_obj,
)
# 3.0.3. If project is over soft budget (alert only, doesn't block)
await _project_soft_budget_check(
project_object=project_object,
valid_token=valid_token,
proxy_logging_obj=proxy_logging_obj,
)
def _enforce_user_param_check(general_settings: dict, request: Request, request_body: dict, route: str) -> None:
if not general_settings.get("enforce_user_param", False):
return
http_method: Final = request.method if hasattr(request, "method") else None
is_post_method: Final = http_method and http_method.upper() == "POST"
is_openai_route: Final = RouteChecks.is_llm_api_route(route=route)
is_mcp_route: Final = route in LiteLLMRoutes.mcp_routes.value or RouteChecks.check_route_access(
route=route, allowed_routes=LiteLLMRoutes.mcp_routes.value
)
if is_post_method and is_openai_route and not is_mcp_route and "user" not in request_body:
raise Exception(f"'user' param not passed in. 'enforce_user_param'={general_settings['enforce_user_param']}")
def _reject_clientside_metadata_tags_check(general_settings: dict, request_body: dict, route: str) -> None:
if not general_settings.get("reject_clientside_metadata_tags", False):
return
if (
RouteChecks.is_llm_api_route(route=route)
and "metadata" in request_body
and isinstance(request_body["metadata"], dict)
and "tags" in request_body["metadata"]
):
raise ProxyException(
message=f"Client-side 'metadata.tags' not allowed in request. 'reject_clientside_metadata_tags'={general_settings['reject_clientside_metadata_tags']}. Tags can only be set via API key metadata.",
type=ProxyErrorTypes.bad_request_error,
param="metadata.tags",
code=status.HTTP_400_BAD_REQUEST,
)
def _global_proxy_budget_check(global_proxy_spend: float | None, skip_budget_checks: bool, route: str) -> None:
if (
litellm.max_budget > 0
and not skip_budget_checks
and global_proxy_spend is not None
and RouteChecks.is_llm_api_route(route=route)
and route != "/v1/models"
and route != "/models"
):
if math.isfinite(litellm.max_budget) and global_proxy_spend > litellm.max_budget:
raise litellm.BudgetExceededError(
current_cost=global_proxy_spend,
max_budget=litellm.max_budget,
entity_type=Litellm_EntityType.PROXY.value,
)
_GUARDRAIL_MODIFICATION_KEYS: Final[tuple] = (
"guardrails",
"disable_global_guardrails",
"disable_global_guardrail",
"opted_out_global_guardrails",
)
def _guardrail_modification_check(request_body: Mapping[str, object], team_object: LiteLLM_TeamTable | None) -> None:
"""
Reject user-supplied metadata flags that would modify guardrail behavior
unless the team has explicit permission. Checked keys include the plural
``guardrails`` list plus the per-request toggles that influence whether
default-on guardrails run (``disable_global_guardrails``,
``disable_global_guardrail`` singular, and ``opted_out_global_guardrails``).
User-supplied values for the bypass toggles are also silently ignored by
``_get_admin_metadata`` at read time; this check adds defense in depth by
failing loudly at the auth layer so operators see an explicit 403 instead
of a confusing silent-ignore.
"""
from litellm.proxy.guardrails.guardrail_helpers import can_modify_guardrails
def _coerce_to_dict(container: object) -> dict | None:
"""Accept dict or JSON-string (from multipart/form-data or extra_body).
Without this, an attacker can smuggle guardrail keys past the check by
sending ``{"metadata": "{\\"disable_global_guardrails\\": true}"}`` —
``isinstance(dict)`` on the string returns False, the check returns
no-modification, and ``add_litellm_data_to_request`` parses the string
to a dict downstream.
"""
if isinstance(container, dict):
return container
if isinstance(container, str):
parsed: Final = _safe_json_loads_obj(container)
return parsed if isinstance(parsed, dict) else None
return None
def _user_requested_modification(container: object) -> bool:
coerced: Final = _coerce_to_dict(container)
if coerced is None:
return False
return any(key in coerced for key in _GUARDRAIL_MODIFICATION_KEYS)
# Check both metadata keys — callers can populate either depending on the
# endpoint. Cover the top-level too so root-level injection is rejected.
modifies: Final = (
_user_requested_modification(request_body.get("metadata"))
or _user_requested_modification(request_body.get("litellm_metadata"))
or _user_requested_modification(request_body)
)
if not modifies:
return
if not can_modify_guardrails(team_object):
raise HTTPException(
status_code=403,
detail={"error": "Your team does not have permission to modify guardrails."},
)
async def check_tools_allowlist(
request_body: dict,
valid_token: UserAPIKeyAuth | None,
team_object: LiteLLM_TeamTable | None,
route: str,
) -> None:
"""
Enforce key/team tool allowlist (metadata.allowed_tools). No DB in hot path —
effective allowlist is read from valid_token.metadata and valid_token.team_metadata.
Raises ProxyException with tool_access_denied if a tool is not allowed.
"""
from litellm.litellm_core_utils.api_route_to_call_types import (
get_call_types_for_route,
)
if valid_token is None:
return
call_types: Final = get_call_types_for_route(route)
if not call_types or not any(ct.value in TOOL_CAPABLE_CALL_TYPES for ct in call_types):
return
tool_names: Final = extract_request_tool_names(route, request_body)
if not tool_names:
return
key_meta: Final = (valid_token.metadata or {}) if isinstance(valid_token.metadata, dict) else {}
team_meta: Final = (valid_token.team_metadata or {}) if isinstance(valid_token.team_metadata, dict) else {}
key_allowed: Final = key_meta.get("allowed_tools")
team_allowed: Final = team_meta.get("allowed_tools")
effective: Final = key_allowed if (isinstance(key_allowed, list) and len(key_allowed) > 0) else team_allowed
if not isinstance(effective, list) or len(effective) == 0:
return
allowed_set: Final = {str(t) for t in effective}
disallowed: Final = [n for n in tool_names if n not in allowed_set]
if disallowed:
raise ProxyException(
message=f"Tool(s) {disallowed} are not in the allowed tools list for this key/team.",
type=ProxyErrorTypes.tool_access_denied,
param="tools",
code=status.HTTP_403_FORBIDDEN,
)
# Read-only discovery routes that incur no spend. Kept narrower than info_routes so an exhausted
# budget cannot reach side-effectful routes like /health/services (Slack/email/webhook). See #27923.
MODEL_DISCOVERY_ROUTES: Final = frozenset(
{
"/v1/models",
"/models",
"/model/info",
"/v1/model/info",
"/v2/model/info",
"/model_group/info",
}
)
BUDGET_ENFORCED_SIDE_EFFECT_ROUTES: Final = frozenset(
{
"/health",
"/health/services",
"/health/test_connection",
}
)
async def common_checks(
request_body: dict,
team_object: LiteLLM_TeamTable | None,
user_object: LiteLLM_UserTable | None,
end_user_object: LiteLLM_EndUserTable | None,
global_proxy_spend: float | None,
general_settings: dict,
route: str,
llm_router: Router | None,
proxy_logging_obj: ProxyLogging,
valid_token: UserAPIKeyAuth | None,
request: Request,
skip_budget_checks: bool = False,
project_object: LiteLLM_ProjectTableCachedObj | None = None,
) -> bool:
"""
Common checks across jwt + key-based auth.
1. If team is blocked
1.1. If project is blocked
2. If team can call model
2.2 If project can call model
3. If team is in budget
3.0.2. If project is in budget
3.0.3. If project is over soft budget (alert only)
4. If user passed in (JWT or key.user_id) - is in budget
5. If end_user (either via JWT or 'user' passed to /chat/completions, /embeddings endpoint) is in budget
6. [OPTIONAL] If 'enforce_end_user' enabled - did developer pass in 'user' param for openai endpoints
7. [OPTIONAL] If 'litellm.max_budget' is set (>0), is proxy under budget
8. [OPTIONAL] If guardrails modified - is request allowed to change this
9. Check if request body is safe
10. [OPTIONAL] Organization checks - is user_object.organization_id is set, run these checks
11. [OPTIONAL] Vector store checks - is the object allowed to access the vector store
"""
from litellm.proxy.proxy_server import prisma_client, user_api_key_cache
_model: Final[str | list[str] | None] = get_model_from_request(
request_data=request_body,
route=route,
request_headers=_safe_get_request_headers(request=request),
request_query_params=_safe_get_request_query_params(request=request),
llm_router=llm_router,
request=request,
)
skip_all_budget_checks: Final = skip_budget_checks or (
route not in BUDGET_ENFORCED_SIDE_EFFECT_ROUTES
and (route in MODEL_DISCOVERY_ROUTES or not RouteChecks.is_llm_api_route(route=route))
)
unpriced_models: Final = (
_unpriced_models_in_request(model=_model, llm_router=llm_router)
if litellm.block_requests_for_models_without_pricing and RouteChecks.is_llm_api_route(route=route)
else ()
)
if unpriced_models:
raise ProxyException(
message=_unpriced_models_block_message(unpriced_models),
type=ProxyErrorTypes.model_cost_map_missing,
param="model",
code=status.HTTP_403_FORBIDDEN,
)
# 1. If team is blocked
if team_object is not None and team_object.blocked is True:
raise Exception(f"Team={team_object.team_id} is blocked. Update via `/team/unblock` if you're an admin.")
# 2. If team can call model (or key's access_group_ids grant it)
if _model and team_object:
with tracer.trace("litellm.proxy.auth.common_checks.can_team_access_model"):
try:
await can_team_access_model(
model=_model,
team_object=team_object,
llm_router=llm_router,
team_model_aliases=(valid_token.team_model_aliases if valid_token else None),
)
except ProxyException as team_denial:
if team_denial.type != ProxyErrorTypes.team_model_access_denied:
raise
if not await _key_access_group_grants_model(
model=_model,
valid_token=valid_token,
team_object=team_object,
llm_router=llm_router,
):
raise
# 2.2. If team member has per-member model scope, enforce it
if _model and team_object and valid_token and valid_token.user_id:
with tracer.trace("litellm.proxy.auth.common_checks.check_team_member_model_access"):
await _check_team_member_model_access(
model=_model,
team_object=team_object,
valid_token=valid_token,
llm_router=llm_router,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
# Require trace id for agent keys when agent has require_trace_id_on_calls_by_agent
if valid_token is not None and valid_token.agent_id:
from litellm.proxy.agent_endpoints.agent_registry import global_agent_registry
from litellm.proxy.litellm_pre_call_utils import get_chain_id_from_headers
agent: Final = global_agent_registry.get_agent_by_id(agent_id=valid_token.agent_id)
if agent is not None:
require_trace_id: Final = (agent.litellm_params or {}).get("require_trace_id_on_calls_by_agent")
if require_trace_id:
headers_dict: Final = dict(request.headers)
trace_id: Final = get_chain_id_from_headers(headers_dict)
if not trace_id:
raise ProxyException(
message="Requests made with this agent's key must include the x-litellm-trace-id header.",
type=ProxyErrorTypes.bad_request_error,
param=None,
code=status.HTTP_400_BAD_REQUEST,
)
## 2.1 If user can call model (if personal key)
if _model and team_object is None and user_object is not None:
with tracer.trace("litellm.proxy.auth.common_checks.can_user_call_model"):
await can_user_call_model(
model=_model,
llm_router=llm_router,
user_object=user_object,
)
# 1.1 - 2.2 - 3.0.2 - 3.0.3: Project checks (blocked, model access, budget)
with tracer.trace("litellm.proxy.auth.common_checks.run_project_checks"):
await _run_project_checks(
project_object=project_object,
_model=_model,
llm_router=llm_router,
skip_budget_checks=skip_all_budget_checks,
valid_token=valid_token,
proxy_logging_obj=proxy_logging_obj,
)
# Run before apply_key_tags_pre_auth injects key metadata.tags into request_body.
_reject_clientside_metadata_tags_check(general_settings, request_body, route)
# If this is a free model, skip all budget checks
if not skip_all_budget_checks:
# Key metadata.tags are injected into request_body here so the tag budget
# check can read them; this mutation must run before the gathered checks.
if valid_token is not None:
from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup
LiteLLMProxyRequestSetup.pre_seed_litellm_metadata_for_route(
request_data=request_body,
route=route,
)
LiteLLMProxyRequestSetup.apply_key_tags_pre_auth(
request_data=request_body,
user_api_key_dict=valid_token,
)
async def _user_max_budget_check() -> None:
# 4.1 personal budget
if user_object is None or user_object.max_budget is None:
return
is_team_key: Final = team_object is not None and team_object.team_id is not None
if is_team_key and general_settings.get("apply_user_budget_to_team_keys") is not True:
return
from litellm.proxy.proxy_server import get_current_spend
user_budget: Final = user_object.max_budget
user_spend: Final = await get_current_spend(
counter_key=f"spend:user:{user_object.user_id}",
fallback_spend=user_object.spend or 0.0,
max_budget=user_budget,
)
if math.isfinite(user_budget) and user_spend >= user_budget:
raise litellm.BudgetExceededError(
current_cost=user_spend,
max_budget=user_budget,
message=f"ExceededBudget: User={user_object.user_id} over budget. Spend={user_spend}, Budget={user_budget}",
entity_type=Litellm_EntityType.USER.value,
entity_id=user_object.user_id,
)
# Each scope reads a distinct counter key with no cross-scope ordering
# dependency, so the per-scope Redis-first reads run concurrently instead
# of one sequential await per scope. return_exceptions lets every scope
# settle, then the first error in scope-priority order propagates exactly
# as the sequential path raised.
budget_check_coros: Final = tuple(
coro
for coro in (
_team_max_budget_check(
team_object=team_object,
proxy_logging_obj=proxy_logging_obj,
valid_token=valid_token,
),
_team_multi_budget_check(team_object=team_object),
_virtual_key_multi_budget_check(valid_token=valid_token) if valid_token is not None else None,
_team_soft_budget_check(
team_object=team_object,
proxy_logging_obj=proxy_logging_obj,
valid_token=valid_token,
),
_organization_max_budget_check(
valid_token=valid_token,
team_object=team_object,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
),
_tag_max_budget_check(
request_body=request_body,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
valid_token=valid_token,
),
_user_max_budget_check(),
_check_team_member_budget(
team_object=team_object,
user_object=user_object,
valid_token=valid_token,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
),
_check_end_user_budget(end_user_obj=end_user_object, route=route)
if end_user_object is not None and end_user_object.litellm_budget_table is not None
else None,
)
if coro is not None
)
with tracer.trace("litellm.proxy.auth.common_checks.budget_checks"):
budget_results: Final = await asyncio.gather(*budget_check_coros, return_exceptions=True)
budget_error: Final = next((r for r in budget_results if isinstance(r, BaseException)), None)
if budget_error is not None:
raise budget_error
_enforce_user_param_check(general_settings, request, request_body, route)
_global_proxy_budget_check(global_proxy_spend, skip_all_budget_checks, route)
_guardrail_modification_check(_typed_request_body(request_body), team_object)
# 10 [OPTIONAL] Organization RBAC checks
organization_role_based_access_check(user_object=user_object, route=route, request_body=request_body)
async def _fetch_team_org_id(team_id: str) -> str | None:
try:
team: Final = await get_team_object(
team_id=team_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
except HTTPException:
return None
return team.organization_id
request_body_for_route_check: Final = await add_team_org_context_to_request_body(
route=route,
request_body=request_body,
fetch_team_org_id=_fetch_team_org_id,
route_template=get_request_route_template(request),
)
_is_route_allowed: Final = _is_api_route_allowed(
route=route,
request=request,
request_data=request_body_for_route_check,
valid_token=valid_token,
user_obj=user_object,
)
# 11. [OPTIONAL] Vector store checks - is the object allowed to access the vector store
with tracer.trace("litellm.proxy.auth.common_checks.vector_store_access_check"):
await vector_store_access_check(
request_body=request_body,
team_object=team_object,
valid_token=valid_token,
)
# 12. [OPTIONAL] Tool allowlist - key/team allowed_tools (no DB in hot path)
with tracer.trace("litellm.proxy.auth.common_checks.check_tools_allowlist"):
await check_tools_allowlist(
request_body=request_body,
valid_token=valid_token,
team_object=team_object,
route=route,
)
return True
def _get_user_role(
user_obj: LiteLLM_UserTable | None,
) -> LitellmUserRoles | None:
if user_obj is None:
return None
_user: Final = user_obj
_user_role: Final = _user.user_role
try:
role: Final = LitellmUserRoles(_user_role)
except ValueError:
return LitellmUserRoles.INTERNAL_USER
return role
def _is_api_route_allowed(
route: str,
request: Request,
request_data: dict,
valid_token: UserAPIKeyAuth | None,
user_obj: LiteLLM_UserTable | None = None,
) -> bool:
"""
- Route b/w api token check and normal token check
"""
_user_role: Final = _get_user_role(user_obj=user_obj)
if valid_token is None:
raise Exception("Invalid proxy server token passed. valid_token=None.")
if not _is_user_proxy_admin(user_obj=user_obj): # if non-admin
RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=user_obj,
_user_role=_user_role,
route=route,
request=request,
request_data=request_data,
valid_token=valid_token,
)
return True
def _is_user_proxy_admin(user_obj: LiteLLM_UserTable | None):
if user_obj is None:
return False
if user_obj.user_role is not None and user_obj.user_role == LitellmUserRoles.PROXY_ADMIN.value:
return True
return False
def _allowed_routes_check(user_route: str, allowed_routes: list) -> bool:
"""
Return if a user is allowed to access route. Helper function for `allowed_routes_check`.
Parameters:
- user_route: str - the route the user is trying to call
- allowed_routes: List[str|LiteLLMRoutes] - the list of allowed routes for the user.
"""
from starlette.routing import compile_path
for allowed_route in allowed_routes:
if allowed_route in LiteLLMRoutes.__members__:
for template in LiteLLMRoutes[allowed_route].value:
regex, _, _ = compile_path(template)
if regex.match(user_route):
return True
elif allowed_route == user_route:
return True
return False
def allowed_routes_check(
user_role: LitellmUserRoles,
user_route: str,
litellm_proxy_roles: LiteLLM_JWTAuth,
) -> bool:
"""
Check if user -> not admin - allowed to access these routes
"""
if user_role == LitellmUserRoles.PROXY_ADMIN:
is_allowed = _allowed_routes_check(
user_route=user_route,
allowed_routes=litellm_proxy_roles.admin_allowed_routes,
)
return is_allowed
elif user_role == LitellmUserRoles.TEAM:
if litellm_proxy_roles.team_allowed_routes is None:
"""
By default allow a team to call openai + info routes
"""
is_allowed = _allowed_routes_check(user_route=user_route, allowed_routes=["openai_routes", "info_routes"])
return is_allowed
elif litellm_proxy_roles.team_allowed_routes is not None:
is_allowed = _allowed_routes_check(
user_route=user_route,
allowed_routes=litellm_proxy_roles.team_allowed_routes,
)
return is_allowed
return False
def allowed_route_check_inside_route(
user_api_key_dict: UserAPIKeyAuth,
requested_user_id: str | None,
) -> bool:
ret_val = True
if (
user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN
and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY
):
ret_val = False
if requested_user_id is not None and user_api_key_dict.user_id is not None:
if user_api_key_dict.user_id == requested_user_id:
ret_val = True
return ret_val
def get_actual_routes(allowed_routes: list) -> list:
actual_routes: Final[list] = []
for route_name in allowed_routes:
try:
route_value = LiteLLMRoutes[route_name].value
if isinstance(route_value, set):
actual_routes.extend(list(route_value))
else:
actual_routes.extend(route_value)
except KeyError:
actual_routes.append(route_name)
return actual_routes
async def get_default_end_user_budget(
prisma_client: PrismaClient | None,
user_api_key_cache: UserApiKeyCache,
parent_otel_span: Span | None = None,
) -> LiteLLM_BudgetTable | None:
"""
Fetches the default end user budget from the database if litellm.max_end_user_budget_id is configured.
This budget is applied to end users who don't have an explicit budget_id set.
Results are cached for performance.
Args:
prisma_client: Database client instance
user_api_key_cache: Cache for storing/retrieving budget data
parent_otel_span: Optional OpenTelemetry span for tracing
Returns:
LiteLLM_BudgetTable if configured and found, None otherwise
"""
if prisma_client is None or litellm.max_end_user_budget_id is None:
return None
cache_key: Final = f"default_end_user_budget:{litellm.max_end_user_budget_id}"
# Check cache first
cached_budget: Final = await user_api_key_cache.async_get_cache(
key=cache_key,
model_type=LiteLLM_BudgetTable,
)
if cached_budget is not None:
return cached_budget
# Fetch from database
try:
budget_record: Final = await _dictable_table(BudgetRepository(prisma_client)).find_unique(
where={"budget_id": litellm.max_end_user_budget_id}
)
if budget_record is None:
verbose_proxy_logger.warning(
"Default end user budget not found in database: %s", litellm.max_end_user_budget_id
)
return None
_budget_obj: Final = LiteLLM_BudgetTable.model_validate(budget_record.dict())
# Cache the budget for 60 seconds
await user_api_key_cache.async_set_cache(
key=cache_key,
value=_budget_obj,
model_type=LiteLLM_BudgetTable,
ttl=get_management_object_ttl(user_api_key_cache),
)
return _budget_obj
except Exception as e:
verbose_proxy_logger.error("Error fetching default end user budget: %s", e)
return None
@log_db_metrics
async def get_team_member_default_budget(
budget_id: str,
prisma_client: PrismaClient | None,
user_api_key_cache: UserApiKeyCache,
) -> LiteLLM_BudgetTable | None:
"""
Fetches the team-level default per-member budget referenced by team.metadata["team_member_budget_id"].
This budget is applied to team members whose TeamMembership row has no
linked budget, or whose linked budget has max_budget=NULL. Results are
cached for performance.
Args:
budget_id: The budget_id pulled from team.metadata["team_member_budget_id"]
prisma_client: Database client instance
user_api_key_cache: Cache for storing/retrieving budget data
Returns:
LiteLLM_BudgetTable if found, None otherwise
"""
if prisma_client is None:
return None
cache_key: Final = f"team_member_default_budget:{budget_id}"
cached_budget: Final = await user_api_key_cache.async_get_cache(
key=cache_key,
model_type=LiteLLM_BudgetTable,
)
if cached_budget is not None:
return cached_budget
try:
budget_record: Final = await _dictable_table(BudgetRepository(prisma_client)).find_unique(
where={"budget_id": budget_id}
)
except Exception:
verbose_proxy_logger.exception("Error fetching team-default member budget %s", budget_id)
return None
if budget_record is None:
verbose_proxy_logger.warning("Team-default member budget not found in database: %s", budget_id)
return None
budget: Final = LiteLLM_BudgetTable.model_validate(budget_record.dict())
await user_api_key_cache.async_set_cache(
key=cache_key,
value=budget,
model_type=LiteLLM_BudgetTable,
ttl=get_management_object_ttl(user_api_key_cache),
)
return budget
async def _apply_default_budget_to_end_user(
end_user_obj: LiteLLM_EndUserTable,
prisma_client: PrismaClient,
user_api_key_cache: UserApiKeyCache,
parent_otel_span: Span | None = None,
) -> LiteLLM_EndUserTable:
"""
Helper function to apply default budget to end user if they don't have a budget assigned.
Args:
end_user_obj: The end user object to potentially apply default budget to
prisma_client: Database client instance
user_api_key_cache: Cache for storing/retrieving data
parent_otel_span: Optional OpenTelemetry span for tracing
Returns:
Updated end user object with default budget applied if applicable
"""
# If end user already has a budget assigned, no need to apply default
if end_user_obj.litellm_budget_table is not None:
return end_user_obj
# If no default budget configured, return as-is
if litellm.max_end_user_budget_id is None:
return end_user_obj
# Fetch and apply default budget
default_budget: Final = await get_default_end_user_budget(
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
parent_otel_span=parent_otel_span,
)
if default_budget is not None:
# Apply default budget to end user object
end_user_obj.litellm_budget_table = default_budget
verbose_proxy_logger.debug(
"Applied default budget %s to end user %s", litellm.max_end_user_budget_id, end_user_obj.user_id
)
return end_user_obj
async def _check_end_user_budget(
end_user_obj: LiteLLM_EndUserTable,
route: str,
) -> None:
"""
Check if end user is within their budget limit.
Args:
end_user_obj: The end user object to check
route: The request route
Raises:
litellm.BudgetExceededError: If end user has exceeded their budget
"""
if RouteChecks.is_info_route(route):
return
if end_user_obj.litellm_budget_table is None:
return
end_user_budget: Final = end_user_obj.litellm_budget_table.max_budget
if end_user_budget is None:
return
from litellm.proxy.proxy_server import get_current_spend
end_user_spend: Final = await get_current_spend(
counter_key=f"spend:end_user:{end_user_obj.user_id}",
fallback_spend=end_user_obj.spend or 0.0,
max_budget=end_user_budget,
fallback_authoritative=True,
)
if end_user_spend > end_user_budget:
raise litellm.BudgetExceededError(
current_cost=end_user_spend,
max_budget=end_user_budget,
message=f"ExceededBudget: End User={end_user_obj.user_id} over budget. Spend={end_user_spend}, Budget={end_user_budget}",
entity_type=Litellm_EntityType.END_USER.value,
entity_id=end_user_obj.user_id,
)
#: Columns whose non-null value makes an end-user row restrict something auth enforces. ``blocked``
#: is separate: it restricts when true rather than when merely set.
_RESTRICTED_COLUMNS: Final = ("budget_id", "allowed_model_region", "default_model", "object_permission_id")
def _column_is_set(column: str) -> Mapping[str, object]:
"""``column IS NOT NULL`` as a plain dict, which is the only shape prisma's builder accepts."""
return {column: {"not": None}} # mutable-ok: prisma's query builder isinstance-checks for dict
def _restricted_end_user_where() -> Mapping[str, object]:
"""Prisma filter selecting every end-user row that carries a restriction auth enforces."""
return {"OR": [{"blocked": True}, *map(_column_is_set, _RESTRICTED_COLUMNS)]} # mutable-ok: prisma needs dict/list
class _RegistryNotCached:
"""No cached registry answer, as distinct from the cached answer ``None`` (registry unusable)."""
_REGISTRY_NOT_CACHED: Final = _RegistryNotCached()
#: One lock per registry; module-level because the stampede to collapse is worker-wide.
_TAG_REGISTRY_LOAD_LOCK: Final = asyncio.Lock()
_END_USER_REGISTRY_LOAD_LOCK: Final = asyncio.Lock()
async def _cached_registry(
cache_key: str,
overflow_sentinel: str,
user_api_key_cache: UserApiKeyCache,
) -> frozenset[str] | None | _RegistryNotCached:
"""The cached registry answer, or ``_REGISTRY_NOT_CACHED`` when the caller has to query."""
cached: Final = await _raw_cache(user_api_key_cache).async_get_cache(key=cache_key)
if cached == overflow_sentinel:
return None
# Memory hands back the tuple that was written; Redis round-trips it through JSON as a list.
if isinstance(cached, (list, tuple)):
return frozenset(entry for entry in cached if isinstance(entry, str))
return _REGISTRY_NOT_CACHED
async def _cache_registry_answer(
cache_key: str,
value: tuple[str, ...] | str,
ttl: float,
user_api_key_cache: UserApiKeyCache,
) -> None:
"""Best-effort: a cache backend failure must not turn a registry load into a failed request."""
try:
await user_api_key_cache.async_set_cache(key=cache_key, value=value, ttl=ttl)
except Exception as e: # noqa: BLE001 # best-effort cache write: auth must survive a cache backend error
verbose_proxy_logger.warning("Failed to cache registry %s: %s", cache_key, e)
async def _fetch_and_cache_registry(
cache_key: str,
overflow_sentinel: str,
max_size: int,
fetch_ids: Callable[[], Awaitable[tuple[str, ...]]],
user_api_key_cache: UserApiKeyCache,
) -> frozenset[str] | None:
"""The registry as the database has it, cached whole, or ``None`` when it is unusable."""
try:
registry_ids: Final = await fetch_ids()
except Exception as e: # noqa: BLE001 # fail-safe: any registry load error must degrade to per-id lookups, never break auth
verbose_proxy_logger.warning(
"Registry %s could not be loaded from the database, so per-id lookups will run and the "
"registry query is suppressed for %ss: %s",
cache_key,
REGISTRY_ERROR_NEGATIVE_CACHE_TTL,
e,
)
await _cache_registry_answer(
cache_key=cache_key,
value=overflow_sentinel,
ttl=REGISTRY_ERROR_NEGATIVE_CACHE_TTL,
user_api_key_cache=user_api_key_cache,
)
return None
if len(registry_ids) > max_size:
await _cache_registry_answer(
cache_key=cache_key,
value=overflow_sentinel,
ttl=get_management_object_ttl(user_api_key_cache),
user_api_key_cache=user_api_key_cache,
)
return None
await _cache_registry_answer(
cache_key=cache_key,
value=registry_ids,
ttl=get_management_object_ttl(user_api_key_cache),
user_api_key_cache=user_api_key_cache,
)
return frozenset(registry_ids)
async def _load_bounded_registry(
cache_key: str,
overflow_sentinel: str,
max_size: int,
load_lock: asyncio.Lock,
fetch_ids: Callable[[], Awaitable[tuple[str, ...]]],
user_api_key_cache: UserApiKeyCache,
) -> frozenset[str] | None:
"""
A bounded id set under one cache key, so an id outside it costs no DB read.
``None`` = unusable (overflow or recent DB error): fall back to per-id lookups. An empty
frozenset is a real, cacheable answer. Loads are single-flighted to stop TTL-expiry stampedes.
"""
cached: Final = await _cached_registry(cache_key, overflow_sentinel, user_api_key_cache)
if not isinstance(cached, _RegistryNotCached):
return cached
async with load_lock:
# The request that held the lock has since cached an answer for everyone waiting on it.
cached_after_wait: Final = await _cached_registry(cache_key, overflow_sentinel, user_api_key_cache)
if not isinstance(cached_after_wait, _RegistryNotCached):
return cached_after_wait
return await _fetch_and_cache_registry(
cache_key=cache_key,
overflow_sentinel=overflow_sentinel,
max_size=max_size,
fetch_ids=fetch_ids,
user_api_key_cache=user_api_key_cache,
)
async def _load_end_user_restricted_registry(
prisma_client: PrismaClient,
user_api_key_cache: UserApiKeyCache,
) -> frozenset[str] | None:
"""The set of end-user ids whose ``LiteLLM_EndUserTable`` row carries a restriction."""
async def fetch_ids() -> tuple[str, ...]:
restricted_rows: Final = await _end_user_table(EndUserRepository(prisma_client)).find_many(
where=_restricted_end_user_where(),
take=END_USER_RESTRICTED_REGISTRY_MAX_SIZE + 1,
)
return tuple(row.user_id for row in restricted_rows)
return await _load_bounded_registry(
cache_key=end_user_restricted_registry_cache_key(),
overflow_sentinel=END_USER_RESTRICTED_REGISTRY_OVERFLOW_SENTINEL,
max_size=END_USER_RESTRICTED_REGISTRY_MAX_SIZE,
load_lock=_END_USER_REGISTRY_LOAD_LOCK,
fetch_ids=fetch_ids,
user_api_key_cache=user_api_key_cache,
)
async def _end_user_is_known_unrestricted(
end_user_id: str,
prisma_client: PrismaClient,
user_api_key_cache: UserApiKeyCache,
token_end_user_max_budget: float | None,
) -> bool:
"""
True when the cached registry proves the id restricts nothing, so its row need not be read.
Every field ``get_end_user_object`` callers consume (budget, spend under that budget, region,
default model, object permission, blocked) is part of the registry predicate, so an id outside
it is indistinguishable from one with no row at all. The skip is off whenever mere existence of
the row is meaningful: ``max_end_user_budget_id`` grafts a default budget onto any row that
exists, ``validate_end_user_id_in_db`` rejects ids that resolve to no row, and a token-supplied
``end_user_max_budget`` (a ``user_custom_auth`` callable can set one against an otherwise
unrestricted row) is enforced against the row's recorded spend.
"""
if (
litellm.max_end_user_budget_id is not None
or litellm.validate_end_user_id_in_db
or token_end_user_max_budget is not None
):
return False
registry: Final = await _load_end_user_restricted_registry(
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
)
return registry is not None and end_user_id not in registry
@log_db_metrics
async def get_end_user_object(
end_user_id: str | None,
prisma_client: PrismaClient | None,
user_api_key_cache: UserApiKeyCache,
route: str | None = "",
parent_otel_span: Span | None = None,
proxy_logging_obj: ProxyLogging | None = None,
token_end_user_max_budget: float | None = None,
) -> LiteLLM_EndUserTable | None:
"""
Returns end user object from database or cache.
If end user exists but has no budget_id, applies the default budget
(if configured via litellm.max_end_user_budget_id).
Args:
end_user_id: The ID of the end user
prisma_client: Database client instance
user_api_key_cache: Cache for storing/retrieving data
route: The request route
parent_otel_span: Optional OpenTelemetry span for tracing
proxy_logging_obj: Optional proxy logging object
token_end_user_max_budget: ``valid_token.end_user_max_budget``, when the caller holds a
token. Budget enforcement reads the row's spend, so a row that restricts nothing on
its own must still be loaded when the token carries a budget for it.
Returns:
LiteLLM_EndUserTable if found, None otherwise
"""
if prisma_client is None:
raise Exception("No db connected")
if end_user_id is None:
return None
_key: Final = end_user_cache_key(end_user_id)
# Check cache first
cached_user_obj: Final = await user_api_key_cache.async_get_cache(
key=_key,
model_type=LiteLLM_EndUserTable,
)
if cached_user_obj is not None:
return_obj = cached_user_obj
# Apply default budget if needed
return_obj = await _apply_default_budget_to_end_user(
end_user_obj=return_obj,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
parent_otel_span=parent_otel_span,
)
return return_obj
if await _end_user_is_known_unrestricted(
end_user_id=end_user_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
token_end_user_max_budget=token_end_user_max_budget,
):
return None
# Fetch from database
try:
response: Final = await _dictable_table(EndUserRepository(prisma_client)).find_unique(
where={"user_id": end_user_id},
include={"litellm_budget_table": True, "object_permission": True},
)
if response is None:
raise Exception
# Convert to LiteLLM_EndUserTable object
_response = LiteLLM_EndUserTable.model_validate(response.dict())
# Apply default budget if needed
_response = await _apply_default_budget_to_end_user(
end_user_obj=_response,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
parent_otel_span=parent_otel_span,
)
# Save to cache
await user_api_key_cache.async_set_cache(
key=_key,
value=_response,
model_type=LiteLLM_EndUserTable,
ttl=get_management_object_ttl(user_api_key_cache),
)
return _response
except Exception:
return None
_END_USER_VALIDATION_NEGATIVE_TTL: Final = 60
_END_USER_VALIDATION_POSITIVE_TTL: Final = 300
async def resolve_and_validate_end_user_id(
raw_end_user_id: str | None,
prisma_client: PrismaClient | None,
user_api_key_cache: UserApiKeyCache,
parent_otel_span: Span | None = None,
proxy_logging_obj: ProxyLogging | None = None,
route: str = "",
) -> str | None:
"""Optionally drop end-user ids that don't resolve to a known DB row.
Default: pass-through. LiteLLM's documented pattern is that the `user`
field is an arbitrary caller-supplied identifier, so validation is
opt-in behind ``litellm.validate_end_user_id_in_db`` to preserve
backwards compatibility.
When the flag is set: accept the id when it matches any of
- LiteLLM_EndUserTable.user_id
- LiteLLM_UserTable.user_id
- LiteLLM_UserTable.user_email (case-insensitive)
If the id doesn't match but ``litellm.max_end_user_budget_id`` is set,
we still preserve the id so the default end-user budget is applied
downstream; otherwise we return None.
DB lookups reuse ``get_end_user_object`` / ``get_user_object`` so they
share the same cache as the rest of the auth path instead of adding new
raw Prisma queries.
"""
if raw_end_user_id is None:
return None
if not litellm.validate_end_user_id_in_db:
return raw_end_user_id
if prisma_client is None:
return raw_end_user_id
cache_key: Final = f"end_user_validation:{raw_end_user_id}"
cached: Final = await _raw_cache(user_api_key_cache).async_get_cache(key=cache_key)
if cached == "valid":
return raw_end_user_id
if cached == "invalid":
return raw_end_user_id if litellm.max_end_user_budget_id else None
is_valid: Final = await _end_user_id_exists_in_db(
end_user_id=raw_end_user_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,
route=route,
)
await user_api_key_cache.async_set_cache(
key=cache_key,
value="valid" if is_valid else "invalid",
ttl=(_END_USER_VALIDATION_POSITIVE_TTL if is_valid else _END_USER_VALIDATION_NEGATIVE_TTL),
)
if is_valid:
return raw_end_user_id
# Preserve id so the caller can still apply litellm.max_end_user_budget_id.
if litellm.max_end_user_budget_id:
return raw_end_user_id
return None
async def _end_user_id_exists_in_db(
end_user_id: str,
prisma_client: PrismaClient,
user_api_key_cache: UserApiKeyCache,
parent_otel_span: Span | None = None,
proxy_logging_obj: ProxyLogging | None = None,
route: str = "",
) -> bool:
"""True when the id matches an EndUser, User, or user_email row."""
try:
end_user_obj: Final = await get_end_user_object(
end_user_id=end_user_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,
route=route,
)
if end_user_obj is not None:
return True
except Exception as e:
verbose_proxy_logger.debug("end_user validation: get_end_user_object lookup failed: %s", e)
try:
user_obj: Final = await get_user_object(
user_id=end_user_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
user_id_upsert=False,
parent_otel_span=parent_otel_span,
proxy_logging_obj=proxy_logging_obj,
check_db_only=False,
user_email=end_user_id if "@" in end_user_id else None,
)
if user_obj is not None:
return True
except Exception as e:
verbose_proxy_logger.debug("end_user validation: get_user_object lookup failed: %s", e)
return False
async def _load_tag_registry(
prisma_client: PrismaClient,
user_api_key_cache: UserApiKeyCache,
) -> frozenset[str] | None:
"""The set of tag names that have a row in ``LiteLLM_TagTable``."""
async def fetch_ids() -> tuple[str, ...]:
registry_rows: Final = await _tag_table(TagRepository(prisma_client)).find_many(
take=TAG_REGISTRY_MAX_SIZE + 1,
)
return tuple(row.tag_name for row in registry_rows)
return await _load_bounded_registry(
cache_key=tag_registry_cache_key(),
overflow_sentinel=TAG_REGISTRY_OVERFLOW_SENTINEL,
max_size=TAG_REGISTRY_MAX_SIZE,
load_lock=_TAG_REGISTRY_LOAD_LOCK,
fetch_ids=fetch_ids,
user_api_key_cache=user_api_key_cache,
)
async def _fetch_uncached_tags(
uncached_tags: Sequence[str],
prisma_client: PrismaClient,
user_api_key_cache: UserApiKeyCache,
) -> tuple[tuple[str, LiteLLM_TagTable], ...]:
"""Rows for the tags a cache probe missed; names absent from the registry never reach the DB."""
if not uncached_tags:
return ()
registry: Final = await _load_tag_registry(
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
)
tags_to_fetch: Final = (
tuple(uncached_tags) if registry is None else tuple(tag for tag in uncached_tags if tag in registry)
)
if not tags_to_fetch:
return ()
try:
db_tags: Final = await _tag_table(TagRepository(prisma_client)).find_many(
where={"tag_name": {"in": list(tags_to_fetch)}},
include={"litellm_budget_table": True},
)
fetched: Final = tuple((db_tag.tag_name, LiteLLM_TagTable.model_validate(db_tag.dict())) for db_tag in db_tags)
for fetched_name, fetched_obj in fetched:
await user_api_key_cache.async_set_cache(
key=tag_cache_key(fetched_name),
value=fetched_obj,
model_type=LiteLLM_TagTable,
ttl=get_management_object_ttl(user_api_key_cache),
)
except Exception as e: # noqa: BLE001 # fail-safe: a tag fetch error must yield "no budget objects", never break auth
verbose_proxy_logger.debug("Error batch fetching tags from database: %s", e)
return ()
else:
return fetched
@log_db_metrics
async def get_tag_objects_batch(
tag_names: list[str],
prisma_client: PrismaClient | None,
user_api_key_cache: UserApiKeyCache,
parent_otel_span: Span | None = None,
proxy_logging_obj: ProxyLogging | None = None,
) -> dict[str, LiteLLM_TagTable]:
"""
Batch fetch multiple tag objects from cache and db.
Optimizes for latency by:
1. Serving already-cached tags without touching the DB
2. Skipping tags that no ``LiteLLM_TagTable`` row exists for, via the cached name registry
3. Batch fetching the remaining uncached tags in one DB query
Args:
tag_names: List of tag names to fetch
prisma_client: Prisma database client
user_api_key_cache: Cache for storing tag objects
parent_otel_span: Optional OpenTelemetry span for tracing
proxy_logging_obj: Optional proxy logging object
Returns:
Dictionary mapping tag_name to LiteLLM_TagTable object
"""
if prisma_client is None or not tag_names:
return {}
probed: Final = [
(
tag_name,
await user_api_key_cache.async_get_cache(key=tag_cache_key(tag_name), model_type=LiteLLM_TagTable),
)
for tag_name in tag_names
]
fetched: Final = await _fetch_uncached_tags(
uncached_tags=tuple(tag_name for tag_name, tag_obj in probed if tag_obj is None),
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
)
return {tag_name: tag_obj for tag_name, tag_obj in (*probed, *fetched) if tag_obj is not None}
@log_db_metrics
async def get_tag_object(
tag_name: str | None,
prisma_client: PrismaClient | None,
user_api_key_cache: UserApiKeyCache,
parent_otel_span: Span | None = None,
proxy_logging_obj: ProxyLogging | None = None,
) -> LiteLLM_TagTable | None:
"""
Returns tag object from cache or db.
Uses default cache TTL (same as end_user objects) to avoid drift.
Args:
tag_name: Name of the tag to fetch
prisma_client: Prisma database client
user_api_key_cache: Cache for storing tag objects
parent_otel_span: Optional OpenTelemetry span for tracing
proxy_logging_obj: Optional proxy logging object
Returns:
LiteLLM_TagTable object if found, None otherwise
"""
if prisma_client is None or tag_name is None:
return None
# Use batch helper for consistency
tag_objects: Final = await get_tag_objects_batch(
tag_names=[tag_name],
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
parent_otel_span=parent_otel_span,
proxy_logging_obj=proxy_logging_obj,
)
return tag_objects.get(tag_name)
@log_db_metrics
async def get_team_membership(
user_id: str,
team_id: str,
prisma_client: PrismaClient | None,
user_api_key_cache: UserApiKeyCache,
parent_otel_span: Span | None = None,
proxy_logging_obj: ProxyLogging | None = None,
) -> Optional["LiteLLM_TeamMembership"]:
"""
Returns team membership object if user is member of team.
Do a isolated check for team membership vs. doing a combined key + team + user + team-membership check, as key might come in frequently for different users/teams. Larger call will slowdown query time. This way we get to cache the constant (key/team/user info) and only update based on the changing value (team membership).
"""
from litellm.proxy._types import LiteLLM_TeamMembership
if prisma_client is None:
raise Exception("No db connected")
if user_id is None or team_id is None:
return None
_key: Final = team_membership_reservation_cache_key(user_id=user_id, team_id=team_id)
# check if in cache
cached_membership_obj: Final = await user_api_key_cache.async_get_cache(
key=_key,
model_type=LiteLLM_TeamMembership,
)
if cached_membership_obj is not None:
return cached_membership_obj
# else, check db
try:
response: Final = await _dictable_table(TeamMembershipRepository(prisma_client)).find_unique(
where={"user_id_team_id": {"user_id": user_id, "team_id": team_id}},
include={"litellm_budget_table": True},
)
if response is None:
return None
_response: Final = LiteLLM_TeamMembership.model_validate(response.dict())
await user_api_key_cache.async_set_cache(
key=_key,
value=_response,
model_type=LiteLLM_TeamMembership,
)
return _response
except Exception:
verbose_proxy_logger.exception(
"Error getting team membership for user_id: %s, team_id: %s",
user_id,
team_id,
)
return None
def model_in_access_group(model: str, team_models: list[str] | None, llm_router: Router | None) -> bool:
from collections import defaultdict
if team_models is None:
return True
if model in team_models:
return True
access_groups: dict[str, list[str]] = defaultdict(list)
if llm_router:
access_groups = llm_router.get_model_access_groups(model_name=model)
if len(access_groups) > 0: # check if token contains any model access groups
for idx, m in enumerate(
team_models
): # loop token models, if any of them are an access group add the access group
if m in access_groups:
return True
# Filter out models that are access_groups
filtered_models: Final = [m for m in team_models if m not in access_groups]
if model in filtered_models:
return True
return False
def _should_check_db(key: str, last_db_access_time: LimitedSizeOrderedDict, db_cache_expiry: int) -> bool:
"""
Prevent calling db repeatedly for items that don't exist in the db.
"""
current_time: Final = time.time()
# if key doesn't exist in last_db_access_time -> check db
if key not in last_db_access_time or last_db_access_time[key][0] is not None:
return True
elif last_db_access_time[key][0] is None:
if current_time - last_db_access_time[key][1] >= db_cache_expiry:
return True
return False
def _update_last_db_access_time(key: str, value: object | None, last_db_access_time: LimitedSizeOrderedDict):
last_db_access_time[key] = (value, time.time())
def _get_role_based_permissions(
rbac_role: RBAC_ROLES,
general_settings: dict,
key: Literal["models", "routes"],
) -> list[str] | None:
"""
Get the role based permissions from the general settings.
"""
role_based_permissions: Final = cast(
list[RoleBasedPermissions] | None,
general_settings.get("role_permissions", []),
)
if role_based_permissions is None:
return None
for role_based_permission in role_based_permissions:
if role_based_permission.role == rbac_role:
return role_based_permission.models if key == "models" else role_based_permission.routes
return None
def get_role_based_models(
rbac_role: RBAC_ROLES,
general_settings: dict,
) -> list[str] | None:
"""
Get the models allowed for a user role.
Used by JWT Auth.
"""
return _get_role_based_permissions(
rbac_role=rbac_role,
general_settings=general_settings,
key="models",
)
def get_role_based_routes(
rbac_role: RBAC_ROLES,
general_settings: dict,
) -> list[str] | None:
"""
Get the routes allowed for a user role.
"""
return _get_role_based_permissions(
rbac_role=rbac_role,
general_settings=general_settings,
key="routes",
)
async def _get_fuzzy_user_object(
prisma_client: PrismaClient,
sso_user_id: str | None = None,
user_email: str | None = None,
) -> "_PrismaUserRow | None":
"""
Checks if sso user is in db.
Called when user id match is not found in db.
- Check if sso_user_id is user_id in db
- Check if sso_user_id is sso_user_id in db
- Check if user_email is user_email in db
- If not, create new user with user_email and sso_user_id and user_id = sso_user_id
"""
response = None
if sso_user_id is not None:
response = await _user_table(UserRepository(prisma_client)).find_unique(
where={"sso_user_id": sso_user_id},
include={"organization_memberships": True},
)
if response is None and user_email is not None:
# Use case-insensitive query to handle emails with different casing
# This matches the pattern used in _check_duplicate_user_email
response = await _user_table(UserRepository(prisma_client)).find_first(
where={"user_email": {"equals": user_email, "mode": "insensitive"}},
include={"organization_memberships": True},
)
if response is not None and sso_user_id is not None: # update sso_user_id
asyncio.create_task( # background task to update user with sso id
_user_table(UserRepository(prisma_client)).update(
where={"user_id": response.user_id},
data={"sso_user_id": sso_user_id},
)
)
return response
async def _backfill_null_user_email(
prisma_client: PrismaClient | None,
user_api_key_cache: UserApiKeyCache,
user_row: LiteLLM_UserTable,
user_email: str | None,
) -> LiteLLM_UserTable:
if user_email is None or user_row.user_email is not None or prisma_client is None:
return user_row
user_repo: Final = UserRepository(prisma_client)
await user_repo.backfill_null_user_email(
user_id=user_row.user_id,
user_email=user_email,
)
db_row: Final = await user_repo.find_by_id(user_row.user_id)
if db_row is None:
return user_row
email_update: Final = {"user_email": db_row.user_email} # mutable-ok: model_copy update payload is dict-shaped
updated_row: Final = user_row.model_copy(update=email_update)
await user_api_key_cache.async_set_cache(
key=user_row.user_id,
value=updated_row,
model_type=LiteLLM_UserTable,
ttl=get_management_object_ttl(user_api_key_cache),
)
return updated_row
@log_db_metrics
async def get_user_object(
user_id: str | None,
prisma_client: PrismaClient | None,
user_api_key_cache: UserApiKeyCache,
user_id_upsert: bool,
parent_otel_span: Span | None = None,
proxy_logging_obj: ProxyLogging | None = None,
sso_user_id: str | None = None,
user_email: str | None = None,
check_db_only: bool | None = None,
) -> LiteLLM_UserTable | None:
"""
- Check if user id in proxy User Table
- if valid, return LiteLLM_UserTable object with defined limits
- if not, then raise an error
"""
if user_id is None:
return None
# check if in cache
if not check_db_only:
cached_user_obj: Final = await user_api_key_cache.async_get_cache(
key=user_id,
model_type=LiteLLM_UserTable,
)
if cached_user_obj is not None:
return await _backfill_null_user_email(
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
user_row=cached_user_obj,
user_email=user_email,
)
# else, check db
if prisma_client is None:
raise Exception("No db connected")
try:
db_access_time_key: Final = f"user_id:{user_id}"
should_check_db: Final = _should_check_db(
key=db_access_time_key,
last_db_access_time=last_db_access_time,
db_cache_expiry=db_cache_expiry,
)
if should_check_db:
response = await _user_table(UserRepository(prisma_client)).find_unique(
where={"user_id": user_id}, include={"organization_memberships": True}
)
if response is None:
response = await _get_fuzzy_user_object(
prisma_client=prisma_client,
sso_user_id=sso_user_id,
user_email=user_email,
)
else:
response = None
if response is None:
if user_id_upsert:
from litellm.proxy.management_endpoints.internal_user_endpoints import (
add_new_user_to_default_team,
check_if_default_team_set,
)
default_params: Final = litellm.default_internal_user_params or {}
scalar_default_params: Final = {
key: value for key, value in default_params.items() if key not in ("teams", "available_teams")
}
new_user_params: Final[dict[str, Any]] = {
"user_id": user_id,
**({"user_email": user_email} if user_email is not None else {}),
**scalar_default_params,
}
if (
new_user_params.get("budget_duration") is not None
and new_user_params.get("budget_reset_at") is None
):
new_user_params["budget_reset_at"] = get_budget_reset_time(
budget_duration=new_user_params["budget_duration"]
)
response = await _user_table(UserRepository(prisma_client)).create(
data=new_user_params,
include={"organization_memberships": True},
)
default_teams: Final = check_if_default_team_set()
if default_teams:
await add_new_user_to_default_team(
user_id=user_id,
user_email=user_email,
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
teams=default_teams,
prisma_client=prisma_client,
)
else:
if should_check_db:
_update_last_db_access_time(
key=db_access_time_key,
value=None,
last_db_access_time=last_db_access_time,
)
raise Exception
if response.organization_memberships is not None and len(response.organization_memberships) > 0:
# dump each organization membership to type LiteLLM_OrganizationMembershipTable
_dumped_memberships: Final = [
LiteLLM_OrganizationMembershipTable.model_validate(membership.model_dump())
for membership in response.organization_memberships
if membership is not None
]
response.organization_memberships = _dumped_memberships
_response = LiteLLM_UserTable.model_validate(dict(response))
_response = await _backfill_null_user_email(
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
user_row=_response,
user_email=user_email,
)
response_dict: Final = _response.model_dump()
# save the user object to cache
await user_api_key_cache.async_set_cache(
key=user_id,
value=_response,
model_type=LiteLLM_UserTable,
ttl=get_management_object_ttl(user_api_key_cache),
)
# save to db access time
_update_last_db_access_time(
key=db_access_time_key,
value=response_dict,
last_db_access_time=last_db_access_time,
)
return _response
except Exception as e: # if user not in db
_log_budget_lookup_failure("user", e)
raise ValueError(
f"User doesn't exist in db. 'user_id'={user_id}. Create user via `/user/new` call. Got error - {e}"
)
async def _cache_management_object(
key: str,
value: BaseModel | Mapping[str, object],
user_api_key_cache: UserApiKeyCache,
proxy_logging_obj: ProxyLogging | None,
*,
model_type: type[BaseModel],
):
"""
Persist management objects via ``UserApiKeyCache`` (in-memory + optional Redis).
``UserApiKeyCache`` serializes with ``model_type`` so Redis and in-memory stay aligned.
"""
await user_api_key_cache.async_set_cache(
key=key,
value=value,
model_type=model_type,
ttl=get_management_object_ttl(user_api_key_cache),
)
async def _cache_team_object(
team_id: str,
team_table: LiteLLM_TeamTableCachedObj,
user_api_key_cache: UserApiKeyCache,
proxy_logging_obj: ProxyLogging | None,
):
## CACHE REFRESH TIME!
team_table.last_refreshed_at = time.time()
key: Final = f"team_id:{team_id}"
if proxy_logging_obj is not None:
try:
await proxy_logging_obj.internal_usage_cache.dual_cache.async_delete_cache(key=key)
except Exception as e: # noqa: BLE001 # best-effort invalidation: any cache backend error must not fail the write
verbose_proxy_logger.warning(
"Failed to invalidate internal usage cache entry %s; "
"a stale team object may be served until its TTL expires: %s",
key,
e,
)
# team_id is the table primary key — guaranteed unique, safe to write.
await _cache_management_object(
key=key,
value=team_table,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
model_type=LiteLLM_TeamTableCachedObj,
)
# Invalidate the alias-keyed cache so the JWT auth path with
# `team_alias_jwt_field` (which reads via `get_team_object_by_alias`)
# doesn't keep serving the pre-mutation team after every team-write
# endpoint (team_model_add, team_model_delete, update_team, etc.).
#
# Why DELETE and not WRITE: `team_alias` has no UNIQUE constraint in
# schema.prisma. Writing this cache from the generic refresh path
# would let a team admin who renamed their team to collide with
# another team's alias silently overwrite the cached team for
# JWT-by-alias auth (veria-ai review on #28739). Deleting forces the
# next reader through `get_team_object_by_alias`, which DOES enforce
# uniqueness (len(teams) > 1 raises HTTPException) before populating
# the cache from a verified single row.
if team_table.team_alias:
alias_key: Final = f"team_alias:{team_table.team_alias}"
try:
user_api_key_cache.delete_cache(key=alias_key)
if proxy_logging_obj is not None:
await proxy_logging_obj.internal_usage_cache.dual_cache.async_delete_cache(key=alias_key)
except Exception as e: # noqa: BLE001 # best-effort invalidation: any cache backend error must not fail the mutation
verbose_proxy_logger.warning(
"Failed to invalidate cached team alias entry %s; "
"a stale team object may be served until its TTL expires: %s",
alias_key,
e,
)
async def invalidate_team_member_spend_state(
user_id: str,
team_id: str,
user_api_key_cache: UserApiKeyCache,
new_spend: float | None = None,
) -> None:
"""
Clear every cached read path for one team member's budget so a spend
reset or a raised cap takes effect on the next request instead of
waiting on the membership cache's TTL.
Two independently-keyed cache entries hold the same LiteLLM_TeamMembership
row: user_api_key_auth.py's admission check writes ``{team_id}_{user_id}``,
while budget_reservation.py's pre-call reservation and auth_checks.py's own
get_team_membership() (used by _check_team_member_budget) both write
``team_membership:{user_id}:{team_id}``. Both formats must be invalidated
explicitly; writing one does not refresh the other. All keys are also
broadcast (LIT-3803): each worker's own in-memory copy (membership object,
spend counter, or the counter's own short-TTL DB-floor marker) survives
eviction elsewhere until its TTL, so the handling worker alone clearing its
copy leaves every other worker still enforcing the pre-reset budget.
``new_spend`` is only passed by reset_team_member_spend_fn, which knows the
exact post-reset value: it is SET everywhere (matching /key/{key}/reset_spend's
own precedent) rather than deleted, so a worker's next read reflects it
directly instead of re-deriving it through a DB reseed. team_member_update
only changes the budget cap, not the tracked spend, so it passes no
new_spend; the live spend counter is untouched in that case (deleting it
would force a reseed from the DB's own spend column, which lags the live
counter via periodic batch writes, briefly under-enforcing the raised cap
against a spend value lower than what was actually tracked) and only the
membership caches carrying the new cap are invalidated.
The floor marker (``spend_db_floor:``, proxy_server.py's
_authoritative_floor_spend) caches the pre-reset DB spend for
SPEND_DB_FLOOR_CACHE_TTL_SECONDS; left stale after a real reset, a request
landing on the pod that cached it can read that higher floor and raise the
counter right back above the just-reset spend. It is overwritten here with
the post-reset floor (not merely deleted) and _authoritative_floor_spend
re-checks the marker after its DB read, so a floor read already in flight
on this pod when the reset commits cannot clobber it with the pre-reset
value. Both keys are broadcast as SETs carrying new_spend, not deletes:
every subscriber (remote pods AND this pod's own, which receives its own
message) writes the post-reset value, so the self-delivered message cannot
erase the guard just written here.
Raises HTTPException(503) if Redis still holds the stale pre-reset counter
after both the SET and the fallback DELETE fail: budget checks read Redis
first, so returning success would leave the old value authoritative for
every worker despite the DB write having committed.
"""
from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import (
evict_and_broadcast,
publish_auth_cache_invalidation,
)
if new_spend is not None:
from litellm.proxy.proxy_server import SPEND_DB_FLOOR_CACHE_TTL_SECONDS, spend_counter_cache
spend_counter_key: Final = f"spend:team_member:{user_id}:{team_id}"
spend_db_floor_key: Final = f"spend_db_floor:{spend_counter_key}"
spend_counter_cache.in_memory_cache.set_cache(key=spend_counter_key, value=new_spend, ttl=60)
if spend_counter_cache.redis_cache is not None:
try:
await spend_counter_cache.redis_cache.async_set_cache(key=spend_counter_key, value=new_spend, ttl=60)
except Exception as e: # noqa: BLE001 # fall back to deleting the stale entry before giving up
verbose_proxy_logger.warning(
"Failed to set spend counter %s in Redis after reset: %s; deleting it instead so the next "
"read reseeds from the DB rather than keeping the stale pre-reset value authoritative",
spend_counter_key,
e,
)
try:
await spend_counter_cache.redis_cache.async_delete_cache(key=spend_counter_key)
except Exception: # noqa: BLE001 # stale value now authoritative in Redis; surface instead of reporting success
verbose_proxy_logger.warning(
"Failed to delete stale spend counter %s in Redis after a failed reset write",
spend_counter_key,
exc_info=True,
)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={ # mutable-ok: HTTPException.detail takes a dict
"error": "Spend was reset in the database, but Redis is unreachable and still "
"holds the pre-reset counter. Retry once Redis is reachable."
},
) from e
spend_counter_cache.in_memory_cache.set_cache(
key=spend_db_floor_key,
value=new_spend,
ttl=SPEND_DB_FLOOR_CACHE_TTL_SECONDS,
)
await publish_auth_cache_invalidation(cache_key=spend_counter_key, new_value=new_spend, ttl=60)
await publish_auth_cache_invalidation(
cache_key=spend_db_floor_key,
new_value=new_spend,
ttl=SPEND_DB_FLOOR_CACHE_TTL_SECONDS,
)
await evict_and_broadcast(
cache_keys=(
team_membership_auth_cache_key(team_id=team_id, user_id=user_id),
team_membership_reservation_cache_key(user_id=user_id, team_id=team_id),
),
user_api_key_cache=user_api_key_cache,
)
async def delete_cache_team_object(
team_id: str,
team_alias: str | None,
user_api_key_cache: UserApiKeyCache,
proxy_logging_obj: ProxyLogging | None,
) -> None:
"""
Evict both keys `_cache_team_object` writes.
`get_team_object` reads the id key and the JWT `team_alias_jwt_field` path reads the alias key,
so leaving either behind keeps a deleted team resolvable for auth until its TTL expires.
Mirrors `delete_cached_project_object`: evicting locally only reaches the worker handling the
delete, so every key is also broadcast to drop the other workers' in-memory copies.
Eviction is best-effort, matching `_cache_team_object`. `delete_team` calls this after the team
rows are already gone, so letting an unreachable cache backend raise here would fail a request
whose delete has committed.
"""
keys: Final = (f"team_id:{team_id}", *((f"team_alias:{team_alias}",) if team_alias else ()))
for key in keys:
try:
user_api_key_cache.delete_cache(key=key)
## UPDATE REDIS CACHE ##
if proxy_logging_obj is not None:
await proxy_logging_obj.internal_usage_cache.dual_cache.async_delete_cache(key=key)
except Exception as e: # noqa: BLE001 # best-effort invalidation: any cache backend error must not abort the delete
verbose_proxy_logger.warning(
"Failed to invalidate cached team entry %s on delete; "
"a deleted team may be served until its TTL expires: %s",
key,
e,
)
await publish_auth_cache_invalidation(cache_key=key)
async def _cache_key_object(
hashed_token: str,
user_api_key_obj: UserAPIKeyAuth,
user_api_key_cache: UserApiKeyCache,
proxy_logging_obj: ProxyLogging | None,
):
key: Final = hashed_token
## CACHE REFRESH TIME
user_api_key_obj.last_refreshed_at = time.time()
cached_key_obj: Final = _copy_user_api_key_auth_for_cache(user_api_key_obj=user_api_key_obj)
await _cache_management_object(
key=key,
value=cached_key_obj,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
model_type=UserAPIKeyAuth,
)
async def _delete_cache_key_object(
hashed_token: str,
user_api_key_cache: UserApiKeyCache,
proxy_logging_obj: ProxyLogging | None,
):
key: Final = hashed_token
user_api_key_cache.delete_cache(key=key)
## UPDATE REDIS CACHE ##
if proxy_logging_obj is not None:
await proxy_logging_obj.internal_usage_cache.dual_cache.async_delete_cache(key=key)
async def delete_cache_key_objects(
hashed_tokens: Sequence[str],
user_api_key_cache: UserApiKeyCache,
proxy_logging_obj: ProxyLogging | None,
) -> None:
"""
Evict a batch of key objects, for callers that delete keys in bulk rather than through
`/key/delete`. Auth resolves a cached key object without re-reading its team, so a key left
cached after its row is gone keeps buying access until its TTL expires.
Evicting locally only reaches this worker, so each token is also broadcast: a deleted key left
in a peer worker's in-memory cache still authenticates there until its TTL expires.
Best-effort per key: the rows are already deleted by the time this runs, so an unreachable
cache backend must not abort the caller partway through its own cascade.
"""
results: Final = await asyncio.gather(
*(
_delete_cache_key_object(
hashed_token=hashed_token,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
for hashed_token in hashed_tokens
),
return_exceptions=True,
)
for hashed_token, result in zip(hashed_tokens, results):
if isinstance(result, BaseException):
verbose_proxy_logger.warning(
"Failed to evict cached key entry for %s; a deleted key may authenticate until its TTL expires: %s",
hashed_token,
result,
)
await publish_auth_cache_invalidation(cache_key=hashed_token)
class _TeamNotFoundDetail(TypedDict):
error: ReadOnly[str]
class TeamNotFoundError(HTTPException):
"""The team row is provably absent, as opposed to merely unreadable.
``get_team_object`` reports every failure as a 404, so a deleted team and a
database that would not answer are indistinguishable to its callers. Callers
that must not treat a degraded read as a definitive answer, such as the
authorization fallback in ``user_api_key_auth``, key on this subclass. It
stays a 404 carrying the same detail, so every other caller is unaffected.
"""
def __init__(self, team_id: str) -> None:
detail: Final[_TeamNotFoundDetail] = {
"error": f"Team doesn't exist in db. Team={team_id}. Create team via `/team/new` call."
}
super().__init__(status_code=404, detail=detail)
@log_db_metrics
async def _get_team_db_check(
team_id: str, prisma_client: PrismaClient, team_id_upsert: bool | None = None
) -> "_PrismaTeamRow | None":
response = await _team_table(TeamRepository(prisma_client)).find_unique(where={"team_id": team_id})
if response is None and team_id_upsert:
from litellm.proxy.management_endpoints.team_endpoints import new_team
new_team_data: Final = NewTeamRequest(team_id=team_id)
mock_request: Final = Request(scope={"type": "http"})
system_admin_user: Final = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)
created_team_dict: Final = await new_team(
data=new_team_data,
http_request=mock_request,
user_api_key_dict=system_admin_user,
)
response = LiteLLM_TeamTable.model_validate(created_team_dict)
return response
async def _get_team_object_from_db(team_id: str, prisma_client: PrismaClient) -> "_PrismaTeamRow | None":
return await _team_table(TeamRepository(prisma_client)).find_unique(where={"team_id": team_id})
async def _get_team_object_from_user_api_key_cache(
team_id: str,
prisma_client: PrismaClient,
user_api_key_cache: UserApiKeyCache,
last_db_access_time: LimitedSizeOrderedDict,
db_cache_expiry: int,
proxy_logging_obj: ProxyLogging | None,
key: str,
team_id_upsert: bool | None = None,
) -> LiteLLM_TeamTableCachedObj:
db_access_time_key: Final = key
should_check_db: Final = _should_check_db(
key=db_access_time_key,
last_db_access_time=last_db_access_time,
db_cache_expiry=db_cache_expiry,
)
if should_check_db:
response = await _get_team_db_check(team_id=team_id, prisma_client=prisma_client, team_id_upsert=team_id_upsert)
# The database answered and the row is not there. Distinct from every
# other failure here, which leaves the team's grant unknown.
if response is None:
raise TeamNotFoundError(team_id=team_id)
else:
response = None
if response is None:
raise Exception
_response: Final = LiteLLM_TeamTableCachedObj.model_validate(response.dict())
# Load object_permission if object_permission_id exists but object_permission is not loaded
if _response.object_permission_id and not _response.object_permission:
try:
_response.object_permission = await get_object_permission(
object_permission_id=_response.object_permission_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
parent_otel_span=None,
proxy_logging_obj=proxy_logging_obj,
)
except Exception as e:
verbose_proxy_logger.debug(
"Failed to load object_permission for team %s with object_permission_id=%s: %s",
team_id,
_response.object_permission_id,
e,
)
# save the team object to cache
await _cache_team_object(
team_id=team_id,
team_table=_response,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
# save to db access time
_update_last_db_access_time(
key=db_access_time_key,
value=_response,
last_db_access_time=last_db_access_time,
)
return _response
async def _get_team_object_from_cache(
key: str,
proxy_logging_obj: ProxyLogging | None,
user_api_key_cache: UserApiKeyCache,
parent_otel_span: Span | None,
) -> LiteLLM_TeamTableCachedObj | None:
## INTERNAL USAGE CACHE (plain DualCache) — checked before UserApiKeyCache stores ##
if proxy_logging_obj is not None and proxy_logging_obj.internal_usage_cache.dual_cache:
cached_raw: Final = await proxy_logging_obj.internal_usage_cache.dual_cache.async_get_cache(
key=key, parent_otel_span=parent_otel_span
)
if cached_raw is not None:
from_internal: Final = CacheCodec.deserialize(cached_raw, LiteLLM_TeamTableCachedObj)
if from_internal is not None:
return from_internal
decoded: Final = await user_api_key_cache.async_get_cache(
key=key,
parent_otel_span=parent_otel_span,
model_type=LiteLLM_TeamTableCachedObj,
)
return decoded
async def get_team_object(
team_id: str,
prisma_client: PrismaClient | None,
user_api_key_cache: UserApiKeyCache,
parent_otel_span: Span | None = None,
proxy_logging_obj: ProxyLogging | None = None,
check_cache_only: bool | None = None,
check_db_only: bool | None = None,
team_id_upsert: bool | None = None,
) -> LiteLLM_TeamTableCachedObj:
"""
- Check if team id in proxy Team Table
- if valid, return LiteLLM_TeamTable object with defined limits
- if not, then raise an error
Raises:
- HTTPException: If team doesn't exist in db or cache (status_code=404)
"""
if prisma_client is None:
raise Exception("No DB Connected. See - https://docs.litellm.ai/docs/proxy/virtual_keys")
# check if in cache
key: Final = f"team_id:{team_id}"
if not check_db_only:
cached_team_obj: Final = await _get_team_object_from_cache(
key=key,
proxy_logging_obj=proxy_logging_obj,
user_api_key_cache=user_api_key_cache,
parent_otel_span=parent_otel_span,
)
if cached_team_obj is not None:
return cached_team_obj
if check_cache_only:
raise HTTPException(
status_code=404,
detail={"error": f"Team doesn't exist in cache + check_cache_only=True. Team={team_id}."},
)
# else, check db
try:
return await _get_team_object_from_user_api_key_cache(
team_id=team_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
last_db_access_time=last_db_access_time,
db_cache_expiry=db_cache_expiry,
key=key,
team_id_upsert=team_id_upsert,
)
except TeamNotFoundError:
raise
except Exception:
raise HTTPException(
status_code=404,
detail={"error": f"Team doesn't exist in db. Team={team_id}. Create team via `/team/new` call."},
)
async def _cache_access_object(
access_group_id: str,
access_group_table: LiteLLM_AccessGroupTable,
user_api_key_cache: UserApiKeyCache,
proxy_logging_obj: ProxyLogging | None = None,
):
key: Final = f"access_group_id:{access_group_id}"
await user_api_key_cache.async_set_cache(
key=key,
value=access_group_table,
model_type=LiteLLM_AccessGroupTable,
ttl=DEFAULT_ACCESS_GROUP_CACHE_TTL,
)
async def _delete_cache_access_object(
access_group_id: str,
user_api_key_cache: UserApiKeyCache,
proxy_logging_obj: ProxyLogging | None = None,
):
key: Final = f"access_group_id:{access_group_id}"
user_api_key_cache.delete_cache(key=key)
## UPDATE REDIS CACHE ##
if proxy_logging_obj is not None:
await proxy_logging_obj.internal_usage_cache.dual_cache.async_delete_cache(key=key)
@log_db_metrics
async def get_access_object(
access_group_id: str,
prisma_client: PrismaClient | None,
user_api_key_cache: UserApiKeyCache,
proxy_logging_obj: ProxyLogging | None = None,
) -> LiteLLM_AccessGroupTable:
"""
- Check if access_group_id in proxy AccessGroupTable
- Always checks cache first, then DB only when not found in cache
- if valid, return LiteLLM_AccessGroupTable object
- if not, then raise an error
Unlike get_team_object, this has no check_cache_only or check_db_only flags;
it always follows cache-first-then-db semantics.
Raises:
- HTTPException: If access group doesn't exist in db or cache (status_code=404)
"""
if prisma_client is None:
raise Exception("No DB Connected. See - https://docs.litellm.ai/docs/proxy/virtual_keys")
key: Final = f"access_group_id:{access_group_id}"
cached_access_obj: Final = await user_api_key_cache.async_get_cache(
key=key,
model_type=LiteLLM_AccessGroupTable,
)
if cached_access_obj is not None:
return cached_access_obj
# Not in cache - fetch from DB
try:
response: Final = await _dictable_table(AccessGroupRepository(prisma_client)).find_unique(
where={"access_group_id": access_group_id}
)
if response is None:
raise HTTPException(
status_code=404,
detail={"error": f"Access group doesn't exist in db. Access group={access_group_id}."},
)
_response: Final = LiteLLM_AccessGroupTable.model_validate(response.dict())
# Save to cache
await _cache_access_object(
access_group_id=access_group_id,
access_group_table=_response,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
return _response
except HTTPException:
raise
except Exception as e:
verbose_proxy_logger.exception(
"Error getting access group for access_group_id: %s",
access_group_id,
)
raise HTTPException(
status_code=404,
detail={"error": f"Access group doesn't exist in db. Access group={access_group_id}. Error: {e}"},
)
@log_db_metrics
async def get_team_object_by_alias(
team_alias: str,
prisma_client: PrismaClient | None,
user_api_key_cache: UserApiKeyCache,
parent_otel_span: Optional["Span"] = None,
proxy_logging_obj: ProxyLogging | None = None,
) -> LiteLLM_TeamTableCachedObj:
"""
Look up a team by its team_alias (name) in the database.
Args:
team_alias: The team name/alias to look up
prisma_client: Database client
user_api_key_cache: Cache for storing results
parent_otel_span: Optional OpenTelemetry span
proxy_logging_obj: Optional proxy logging object
Returns:
LiteLLM_TeamTableCachedObj: The team object if found
Raises:
HTTPException: If team doesn't exist or multiple teams have the same alias
"""
if prisma_client is None:
raise Exception("No DB Connected. See - https://docs.litellm.ai/docs/proxy/virtual_keys")
# Check cache first (keyed by alias)
cache_key: Final = f"team_alias:{team_alias}"
cached_team_obj: Final = await _get_team_object_from_cache(
key=cache_key,
proxy_logging_obj=proxy_logging_obj,
user_api_key_cache=user_api_key_cache,
parent_otel_span=parent_otel_span,
)
if cached_team_obj is not None:
return cached_team_obj
# Query database by team_alias
try:
teams: Final = await _team_table(TeamRepository(prisma_client)).find_many(where={"team_alias": team_alias})
if not teams:
raise HTTPException(
status_code=404,
detail={
"error": f"Team with alias '{team_alias}' doesn't exist in db. Create team via `/team/new` call."
},
)
if len(teams) > 1:
raise HTTPException(
status_code=400,
detail={
"error": f"Multiple teams found with alias '{team_alias}'. Please use team_id_jwt_field instead or ensure team aliases are unique."
},
)
team: Final = teams[0]
team_obj: Final = LiteLLM_TeamTableCachedObj.model_validate(team.model_dump())
# Load object_permission if object_permission_id exists but object_permission is not loaded
if team_obj.object_permission_id and not team_obj.object_permission:
try:
team_obj.object_permission = await get_object_permission(
object_permission_id=team_obj.object_permission_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 as e:
verbose_proxy_logger.debug(
"Failed to load object_permission for team %s with object_permission_id=%s: %s",
team_obj.team_id,
team_obj.object_permission_id,
e,
)
# Cache the result by both alias and team_id
await user_api_key_cache.async_set_cache(
key=cache_key,
value=team_obj,
model_type=LiteLLM_TeamTableCachedObj,
ttl=DEFAULT_IN_MEMORY_TTL,
)
# Also cache by team_id for consistency
team_id_cache_key: Final = f"team_id:{team_obj.team_id}"
await user_api_key_cache.async_set_cache(
key=team_id_cache_key,
value=team_obj,
model_type=LiteLLM_TeamTableCachedObj,
ttl=DEFAULT_IN_MEMORY_TTL,
)
return team_obj
except HTTPException:
raise
except Exception as e:
verbose_proxy_logger.exception("Error looking up team by alias: %s", team_alias)
raise HTTPException(
status_code=500,
detail={"error": f"Error looking up team by alias '{team_alias}': {e}"},
)
@log_db_metrics
async def get_org_object_by_alias(
org_alias: str,
prisma_client: PrismaClient | None,
user_api_key_cache: UserApiKeyCache,
parent_otel_span: Optional["Span"] = None,
proxy_logging_obj: ProxyLogging | None = None,
) -> LiteLLM_OrganizationTable | None:
"""
Look up an organization by its organization_alias in the database.
Args:
org_alias: The organization name/alias to look up
prisma_client: Database client
user_api_key_cache: Cache for storing results
parent_otel_span: Optional OpenTelemetry span
proxy_logging_obj: Optional proxy logging object
Returns:
LiteLLM_OrganizationTable if found, None otherwise
Raises:
HTTPException: If organization not found or multiple orgs have the same alias
"""
if prisma_client is None:
raise Exception("No DB Connected. See - https://docs.litellm.ai/docs/proxy/virtual_keys")
# Check cache first (keyed by alias)
cache_key: Final = f"org_alias:{org_alias}"
cached_org_obj: Final = await user_api_key_cache.async_get_cache(
key=cache_key,
model_type=LiteLLM_OrganizationTable,
)
if cached_org_obj is not None:
return cached_org_obj
# Query database by organization_alias
try:
orgs = await _model_dump_table(OrganizationRepository(prisma_client)).find_many(
where={"organization_alias": org_alias}
)
if not orgs:
raise HTTPException(
status_code=404,
detail={
"error": f"Organization with alias '{org_alias}' doesn't exist in db. Create organization via `/organization/new` call."
},
)
if len(orgs) > 1:
raise HTTPException(
status_code=400,
detail={
"error": f"Multiple organizations found with alias '{org_alias}'. Please use org_id_jwt_field instead or ensure organization aliases are unique."
},
)
org: Final = orgs[0]
org_obj: Final = LiteLLM_OrganizationTable.model_validate(org.model_dump())
# Cache the result
await user_api_key_cache.async_set_cache(
key=cache_key,
value=org_obj,
model_type=LiteLLM_OrganizationTable,
ttl=DEFAULT_IN_MEMORY_TTL,
)
# Also cache by org_id for consistency
await user_api_key_cache.async_set_cache(
key=f"org_id:{org_obj.organization_id}",
value=org_obj,
model_type=LiteLLM_OrganizationTable,
ttl=DEFAULT_IN_MEMORY_TTL,
)
return org_obj
except HTTPException:
raise
except Exception as e:
verbose_proxy_logger.exception("Error looking up organization by alias: %s", org_alias)
raise HTTPException(
status_code=500,
detail={"error": f"Error looking up organization by alias '{org_alias}': {e}"},
)
class ExperimentalUIJWTToken:
@staticmethod
def get_experimental_ui_login_jwt_auth_token(user_info: LiteLLM_UserTable) -> str:
from datetime import timedelta
from litellm.proxy.common_utils.encrypt_decrypt_utils import (
encrypt_value_helper,
)
if user_info.user_role is None:
raise Exception("User role is required for experimental UI login")
# Experimental UI flow uses fixed 10-min expiry for security (does not use LITELLM_UI_SESSION_DURATION)
expiration_time: Final = get_utc_datetime() + timedelta(minutes=10)
# Format the expiration time as ISO 8601 string
expires: Final = expiration_time.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "+00:00"
valid_token: Final = UserAPIKeyAuth(
token="ui-token",
key_name="ui-token",
key_alias="ui-token",
max_budget=litellm.max_ui_session_budget,
rpm_limit=100, # allow user to have a conversation on test key pane of UI
expires=expires,
user_id=user_info.user_id,
team_id="litellm-dashboard",
models=user_info.models,
max_parallel_requests=None,
user_role=LitellmUserRoles(user_info.user_role),
)
return encrypt_value_helper(valid_token.model_dump_json(exclude_none=True))
@staticmethod
def get_cli_jwt_auth_token(
user_info: LiteLLM_UserTable,
team_id: str | None = None,
team_alias: str | None = None,
team_models: Sequence[str] | None = None,
team_model_aliases: Mapping[str, str] | None = None,
max_budget: float | None = None,
) -> str:
"""
Generate a JWT token for CLI authentication with configurable expiration.
The expiration time can be controlled via the LITELLM_CLI_JWT_EXPIRATION_HOURS
environment variable (defaults to 24 hours).
Args:
user_info: User information from the database
team_id: Team ID for the user (optional, uses user's team if available)
team_alias: Team alias for the selected team, if available
team_models: Model allowlist granted by the selected team
team_model_aliases: Team model aliases for the selected team
Returns:
Encrypted JWT token string
"""
import secrets
from datetime import timedelta
from litellm.proxy.common_utils.encrypt_decrypt_utils import (
encrypt_value_helper,
)
if user_info.user_role is None:
raise Exception("User role is required for CLI JWT login")
# Calculate expiration time (configurable via LITELLM_CLI_JWT_EXPIRATION_HOURS env var)
expiration_time: Final = get_utc_datetime() + timedelta(hours=CLI_JWT_EXPIRATION_HOURS)
# Format the expiration time as ISO 8601 string
expires: Final = expiration_time.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "+00:00"
# Use provided team_id, or fall back to user's teams if available
_team_id = team_id
if _team_id is None and hasattr(user_info, "teams") and user_info.teams:
# Use first team if user has teams
_team_id = user_info.teams[0] if len(user_info.teams) > 0 else None
session_token: Final = f"{CLI_SESSION_KEY_PREFIX}-{secrets.token_urlsafe(16)}"
session_alias: Final = f"{CLI_SESSION_KEY_PREFIX}-{user_info.user_id}"
valid_token: Final = UserAPIKeyAuth(
token=session_token,
key_name=session_alias,
key_alias=session_alias,
expires=expires,
max_budget=max_budget,
user_id=user_info.user_id,
team_id=_team_id,
team_alias=team_alias,
team_models=list(team_models) if team_models is not None else [],
team_model_aliases=dict(team_model_aliases) if team_model_aliases is not None else None,
models=[] if _team_id is not None else user_info.models,
max_parallel_requests=None,
user_role=LitellmUserRoles(user_info.user_role),
is_session_token=True,
)
return encrypt_value_helper(valid_token.model_dump_json(exclude_none=True))
@staticmethod
def get_key_object_from_ui_hash_key(
hashed_token: str,
) -> UserAPIKeyAuth | None:
import json
from litellm.proxy.auth.user_api_key_auth import UserAPIKeyAuth
from litellm.proxy.common_utils.encrypt_decrypt_utils import (
decrypt_value_helper,
)
decrypted_token: Final = decrypt_value_helper(hashed_token, key="ui_hash_key", exception_type="debug")
if decrypted_token is None:
return None
try:
return UserAPIKeyAuth.model_validate(json.loads(decrypted_token))
except Exception as e:
raise Exception(f"Invalid hash key. Hash key={hashed_token}. Decrypted token={decrypted_token}. Error: {e}")
async def _fetch_key_object_from_db_with_reconnect(
hashed_token: str,
prisma_client: PrismaClient,
parent_otel_span: Span | None,
proxy_logging_obj: ProxyLogging | None,
) -> BaseModel | None:
"""
Fetch key object from DB and retry once if a DB connection error can be healed.
"""
try:
return await prisma_client.get_data(
token=hashed_token,
table_name="combined_view",
parent_otel_span=parent_otel_span,
proxy_logging_obj=proxy_logging_obj,
)
except Exception as e:
if PrismaDBExceptionHandler.is_database_transport_error(e):
did_reconnect = False
if hasattr(prisma_client, "attempt_db_reconnect"):
auth_reconnect_timeout = getattr(prisma_client, "_db_auth_reconnect_timeout_seconds", 2.0)
if not isinstance(auth_reconnect_timeout, (int, float)):
auth_reconnect_timeout = 2.0
auth_reconnect_lock_timeout = getattr(prisma_client, "_db_auth_reconnect_lock_timeout_seconds", 0.1)
if not isinstance(auth_reconnect_lock_timeout, (int, float)):
auth_reconnect_lock_timeout = 0.1
did_reconnect = await prisma_client.attempt_db_reconnect(
reason="auth_get_key_object_lookup_failure",
timeout_seconds=auth_reconnect_timeout,
lock_timeout_seconds=auth_reconnect_lock_timeout,
)
if did_reconnect:
return await prisma_client.get_data(
token=hashed_token,
table_name="combined_view",
parent_otel_span=parent_otel_span,
proxy_logging_obj=proxy_logging_obj,
)
raise
@log_db_metrics
async def get_jwt_key_mapping_object(
jwt_claim_name: str,
jwt_claim_value: str,
prisma_client: PrismaClient,
) -> str | None:
"""
Lookup a JWT-to-virtual-key mapping from the database.
Returns the hashed token (str) if a matching active mapping is found, else None.
"""
mapping: Final = await _jwt_key_mapping_table(JWTKeyMappingRepository(prisma_client)).find_first(
where={
"jwt_claim_name": jwt_claim_name,
"jwt_claim_value": jwt_claim_value,
"is_active": True,
}
)
if mapping is not None:
return mapping.token
return None
@log_db_metrics
async def get_key_object(
hashed_token: str,
prisma_client: PrismaClient | None,
user_api_key_cache: UserApiKeyCache,
parent_otel_span: Span | None = None,
proxy_logging_obj: ProxyLogging | None = None,
check_cache_only: bool | None = None,
) -> UserAPIKeyAuth:
"""
- Check if team id in proxy Team Table
- if valid, return LiteLLM_TeamTable object with defined limits
- if not, then raise an error
"""
if prisma_client is None:
raise Exception("No DB Connected. See - https://docs.litellm.ai/docs/proxy/virtual_keys")
# check if in cache
key: Final = hashed_token
# Same flow as before: use cache only when we have a hit we can turn into UserAPIKeyAuth
# (dict from Redis / model_dump, or UserAPIKeyAuth from in-memory). Otherwise fall through to DB.
user_api_key_auth: Final = await user_api_key_cache.async_get_cache(
key=key,
model_type=UserAPIKeyAuth,
)
if user_api_key_auth is not None:
return _copy_user_api_key_auth_for_cache(user_api_key_obj=user_api_key_auth)
if check_cache_only:
raise Exception(f"Key doesn't exist in cache + check_cache_only=True. key={key}.")
# else, check db
_valid_token: Final[BaseModel | None] = await _fetch_key_object_from_db_with_reconnect(
hashed_token=hashed_token,
prisma_client=prisma_client,
parent_otel_span=parent_otel_span,
proxy_logging_obj=proxy_logging_obj,
)
if _valid_token is None:
raise ProxyException(
message=f"Authentication Error, Invalid proxy server token passed. key={hashed_token}, not found in db. Create key via `/key/generate` call.",
type=ProxyErrorTypes.token_not_found_in_db,
param="key",
code=status.HTTP_401_UNAUTHORIZED,
)
_response: Final = UserAPIKeyAuth.model_validate(_valid_token.model_dump(exclude_none=True))
# Load object_permission if object_permission_id exists but object_permission is not loaded
if _response.object_permission_id and not _response.object_permission:
try:
_response.object_permission = await get_object_permission(
object_permission_id=_response.object_permission_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 as e:
verbose_proxy_logger.debug(
"Failed to load object_permission for key with object_permission_id=%s: %s",
_response.object_permission_id,
e,
)
# save the key object to cache
await _cache_key_object(
hashed_token=hashed_token,
user_api_key_obj=_response,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
return _response
def _copy_user_api_key_auth_for_cache(
user_api_key_obj: UserAPIKeyAuth,
) -> UserAPIKeyAuth:
copied_key_obj: Final = user_api_key_obj.model_copy()
copied_key_obj.budget_reservation = None
copied_key_obj.budget_throttle_pct = None
copied_key_obj.parent_otel_span = None
copied_key_obj.request_route = None
return copied_key_obj
@log_db_metrics
async def get_object_permission(
object_permission_id: str,
prisma_client: PrismaClient | None,
user_api_key_cache: UserApiKeyCache,
parent_otel_span: Span | None = None,
proxy_logging_obj: ProxyLogging | None = None,
) -> LiteLLM_ObjectPermissionTable | None:
"""
- Check if object permission id in proxy ObjectPermissionTable
- if valid, return LiteLLM_ObjectPermissionTable object
- if not, then raise an error
"""
if prisma_client is None:
raise Exception("No DB Connected. See - https://docs.litellm.ai/docs/proxy/virtual_keys")
# check if in cache
key: Final = object_permission_cache_key(object_permission_id)
deserialized_perm: Final = await user_api_key_cache.async_get_cache(
key=key,
model_type=LiteLLM_ObjectPermissionTable,
)
if deserialized_perm is not None:
return deserialized_perm
# else, check db
try:
response: Final = await _dictable_table(ObjectPermissionRepository(prisma_client)).find_unique(
where={"object_permission_id": object_permission_id}
)
if response is None:
return None
_perm_obj: Final = LiteLLM_ObjectPermissionTable.model_validate(response.dict())
await user_api_key_cache.async_set_cache(
key=key,
value=_perm_obj,
model_type=LiteLLM_ObjectPermissionTable,
ttl=get_management_object_ttl(user_api_key_cache),
)
return _perm_obj
except Exception:
return None
@log_db_metrics
async def get_managed_vector_store_rows_by_uuids(
uuids: list[str],
prisma_client: PrismaClient | None,
user_api_key_cache: UserApiKeyCache,
parent_otel_span: Span | None = None,
proxy_logging_obj: ProxyLogging | None = None,
) -> list[LiteLLM_ManagedVectorStoresTable]:
"""
Fetch managed vector store rows by their internal UUIDs.
Follows the get_team_object / get_key_object / get_object_permission pattern:
cache-first lookup (in-memory / Redis), DB fallback only on cache miss.
Critical-path DB access must go through this helper to avoid raw Prisma
calls on the hot request path.
"""
if not uuids or prisma_client is None:
return []
result: Final[list[LiteLLM_ManagedVectorStoresTable]] = []
cache_misses: Final[list[str]] = []
for uuid in uuids:
key = f"managed_vector_store_id:{uuid}"
deserialized_vs = await user_api_key_cache.async_get_cache(
key=key,
model_type=LiteLLM_ManagedVectorStoresTable,
)
if deserialized_vs is not None:
result.append(deserialized_vs)
else:
cache_misses.append(uuid)
if not cache_misses:
return result
rows: Final = await _vector_store_table(ManagedVectorStoresRepository(prisma_client)).find_many(
where={"vector_store_id": {"in": cache_misses}},
take=len(cache_misses),
)
for row in rows:
row_dict = row.model_dump() if hasattr(row, "model_dump") else (row.dict() if hasattr(row, "dict") else None)
if not isinstance(row_dict, dict) or not row_dict:
row_dict = dict(row) if hasattr(row, "__dict__") else {}
if not row_dict:
continue
cached_obj = LiteLLM_ManagedVectorStoresTable.model_validate(row_dict)
key = f"managed_vector_store_id:{cached_obj.vector_store_id}"
await user_api_key_cache.async_set_cache(
key=key,
value=cached_obj,
model_type=LiteLLM_ManagedVectorStoresTable,
ttl=get_management_object_ttl(user_api_key_cache),
)
result.append(cached_obj)
return result
class OrganizationNotFoundError(Exception):
"""The organization row is CONFIRMED absent, as opposed to a lookup that failed.
Subclasses Exception so every existing except Exception caller keeps its current
behavior; it exists so a caller that wants to treat "no such org" as "no restriction" can do
that WITHOUT also swallowing an outage and silently dropping a real org ceiling.
"""
@log_db_metrics
async def get_org_object(
org_id: str,
prisma_client: PrismaClient | None,
user_api_key_cache: UserApiKeyCache,
parent_otel_span: Span | None = None,
proxy_logging_obj: ProxyLogging | None = None,
include_budget_table: bool = False,
) -> LiteLLM_OrganizationTable | None:
"""
- Check if org id in proxy Org Table
- if valid, return LiteLLM_OrganizationTable object
- if not, then raise an error
Args:
org_id: Organization ID to look up
prisma_client: Database client
user_api_key_cache: Cache for storing results
parent_otel_span: Optional OpenTelemetry span
proxy_logging_obj: Optional proxy logging object
include_budget_table: If True, includes litellm_budget_table in the query
"""
if prisma_client is None:
raise Exception("No DB Connected. See - https://docs.litellm.ai/docs/proxy/virtual_keys")
if not isinstance(org_id, str):
return None
# Use different cache key if budget table is included
cache_key = f"org_id:{org_id}"
if include_budget_table:
cache_key = f"org_id:{org_id}:with_budget"
# check if in cache
deserialized_org: Final = await user_api_key_cache.async_get_cache(
key=cache_key,
model_type=LiteLLM_OrganizationTable,
)
if deserialized_org is not None:
return deserialized_org
# else, check db
try:
query_kwargs: Final[dict[str, Mapping[str, object]]] = {"where": {"organization_id": org_id}}
if include_budget_table:
query_kwargs["include"] = {"litellm_budget_table": True}
response: Final = await _model_dump_table(OrganizationRepository(prisma_client)).find_unique(**query_kwargs)
except Exception:
# An operational failure (DB down, timeout, cache fault) is NOT the same fact as a confirmed
# missing row, and relabelling it as "doesn't exist" made every caller unable to tell them
# apart — a caller that treats absence as "this org places no restriction" then drops a real
# org ceiling during an outage. Propagate the real error; callers that already catch
# Exception are unaffected.
raise
if response is None:
raise OrganizationNotFoundError(
f"Organization doesn't exist in db. Organization={org_id}. Create organization via `/organization/new` call."
)
_org_obj: Final = LiteLLM_OrganizationTable.model_validate(response.model_dump())
# Cache the result
await user_api_key_cache.async_set_cache(
key=cache_key,
value=_org_obj,
model_type=LiteLLM_OrganizationTable,
ttl=DEFAULT_IN_MEMORY_TTL,
)
return _org_obj
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"],
prisma_client: PrismaClient | None = None,
user_api_key_cache: UserApiKeyCache | None = None,
proxy_logging_obj: ProxyLogging | None = None,
) -> list[str]:
"""
Fetch access groups by their IDs (from cache or DB) and collect
the specified resource field across all of them.
Args:
access_group_ids: List of access group IDs to fetch
resource_field: Which resource list to extract from each access group
- "access_model_names": model names (for model access checks)
- "access_mcp_server_ids": MCP server IDs (for MCP access checks)
- "access_agent_ids": agent IDs (for agent access checks)
prisma_client: Optional PrismaClient (lazy-imported from proxy_server if None)
user_api_key_cache: Optional DualCache (lazy-imported from proxy_server if None)
proxy_logging_obj: Optional ProxyLogging (lazy-imported from proxy_server if None)
Returns:
Deduplicated list of resource identifiers from all resolved access groups.
"""
if not access_group_ids:
return []
# Lazy import to avoid circular imports
if prisma_client is None or user_api_key_cache is None:
from litellm.proxy.proxy_server import prisma_client as _prisma_client
from litellm.proxy.proxy_server import proxy_logging_obj as _proxy_logging_obj
from litellm.proxy.proxy_server import user_api_key_cache as _user_api_key_cache
prisma_client = prisma_client or _prisma_client
user_api_key_cache = user_api_key_cache or _user_api_key_cache
proxy_logging_obj = proxy_logging_obj or _proxy_logging_obj
if user_api_key_cache is None:
return []
resources: Final[list[str]] = []
for ag_id in access_group_ids:
try:
ag = await get_access_object(
access_group_id=ag_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
resources.extend(getattr(ag, resource_field, []))
except Exception:
verbose_proxy_logger.debug(
"Could not fetch access group %s for resource field %s",
ag_id,
resource_field,
)
return list(set(resources))
async def _get_models_from_access_groups(
access_group_ids: Sequence[str],
prisma_client: PrismaClient | None = None,
user_api_key_cache: UserApiKeyCache | None = None,
proxy_logging_obj: ProxyLogging | None = None,
) -> list[str]:
"""
Collect model names from unified access groups.
Models are matched by model name for backwards compatibility.
"""
return await _get_resources_from_access_groups(
access_group_ids=access_group_ids,
resource_field="access_model_names",
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
async def _get_mcp_server_ids_from_access_groups(
access_group_ids: list[str],
prisma_client: PrismaClient | None = None,
user_api_key_cache: UserApiKeyCache | None = None,
proxy_logging_obj: ProxyLogging | None = None,
) -> list[str]:
"""
Collect MCP server IDs from unified access groups.
MCPs are matched by server ID.
"""
return await _get_resources_from_access_groups(
access_group_ids=access_group_ids,
resource_field="access_mcp_server_ids",
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
async def _get_agent_ids_from_access_groups(
access_group_ids: list[str],
prisma_client: PrismaClient | None = None,
user_api_key_cache: UserApiKeyCache | None = None,
proxy_logging_obj: ProxyLogging | None = None,
) -> list[str]:
"""
Collect agent IDs from unified access groups.
Agents are matched by agent ID.
"""
return await _get_resources_from_access_groups(
access_group_ids=access_group_ids,
resource_field="access_agent_ids",
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
def _resolve_all_team_model_sentinel_for_auth_check(
models: list[str],
llm_router: Router | None,
team_id: str | None,
) -> list[str]:
if SpecialModelNames.all_team_models.value not in models or team_id is None or llm_router is None:
return models
proxy_models: Final = llm_router.get_model_names()
non_sentinel_models: Final = [model for model in models if model != SpecialModelNames.all_team_models.value]
if not proxy_models:
return non_sentinel_models or models
return list(dict.fromkeys(non_sentinel_models + proxy_models))
def _check_model_access_helper(
model: str,
llm_router: Router | None,
models: list[str],
team_model_aliases: dict[str, str] | None = None,
team_id: str | None = None,
) -> bool:
## check if model in allowed model names
from collections import defaultdict
access_groups: dict[str, list[str]] = defaultdict(list)
if llm_router:
access_groups = llm_router.get_model_access_groups(model_name=model, team_id=team_id)
models = _resolve_all_team_model_sentinel_for_auth_check(
models=models,
llm_router=llm_router,
team_id=team_id,
)
if len(access_groups) > 0 and llm_router is not None: # check if token contains any model access groups
for idx, m in enumerate(models): # loop token models, if any of them are an access group add the access group
if m in access_groups:
return True
# Filter out models that are access_groups
filtered_models: Final = [m for m in models if m not in access_groups]
if _model_in_team_aliases(model=model, team_model_aliases=team_model_aliases):
return True
if _model_matches_any_wildcard_pattern_in_list(model=model, allowed_model_list=filtered_models):
return True
all_model_access: bool = False
if (len(filtered_models) == 0 and len(models) == 0) or "*" in filtered_models:
all_model_access = True
if SpecialModelNames.all_proxy_models.value in filtered_models:
all_model_access = True
if model is not None and model not in filtered_models and all_model_access is False:
return False
return True
def _can_object_call_model(
model: str | list[str],
llm_router: Router | None,
models: list[str],
team_model_aliases: dict[str, str] | None = None,
team_id: str | None = None,
object_type: Literal["user", "team", "key", "org", "project"] = "user",
fallback_depth: int = 0,
) -> Literal[True]:
"""
Checks if token can call a given model
Args:
- model: str
- llm_router: Optional[Router]
- models: List[str]
- team_model_aliases: Optional[Dict[str, str]]
- object_type: Literal["user", "team", "key", "org"]. We use the object type to raise the correct exception type
Returns:
- True: if token allowed to call model
Raises:
- Exception: If token not allowed to call model
"""
if fallback_depth >= DEFAULT_MAX_RECURSE_DEPTH:
raise Exception(f"Unable to parse model, max fallback depth exceeded - received model: {model}")
if isinstance(model, list):
for m in model:
_can_object_call_model(
model=m,
llm_router=llm_router,
models=models,
team_model_aliases=team_model_aliases,
team_id=team_id,
object_type=object_type,
fallback_depth=fallback_depth + 1,
)
return True
potential_models: Final = [model]
if model in litellm.model_alias_map:
potential_models.append(litellm.model_alias_map[model])
elif llm_router and model in llm_router.model_group_alias:
_model: Final = llm_router._get_model_from_alias(model)
if _model:
potential_models.append(_model)
## check model access for alias + underlying model - allow if either is in allowed models
for m in potential_models:
if _check_model_access_helper(
model=m,
llm_router=llm_router,
models=models,
team_model_aliases=team_model_aliases,
team_id=team_id,
):
return True
raise ProxyException(
message=f"{object_type} not allowed to access model. This {object_type} can only access models={models}. Tried to access {model}",
type=ProxyErrorTypes.get_model_access_error_type_for_object(object_type=object_type),
param="model",
code=status.HTTP_403_FORBIDDEN,
)
def _model_in_team_aliases(model: str, team_model_aliases: dict[str, str] | None = None) -> bool:
"""
Returns True if `model` being accessed is an alias of a team model
- `model=gpt-4o`
- `team_model_aliases={"gpt-4o": "gpt-4o-team-1"}`
- returns True
- `model=gp-4o`
- `team_model_aliases={"o-3": "o3-preview"}`
- returns False
"""
if team_model_aliases:
if model in team_model_aliases:
return True
return False
def _resolve_key_models_for_auth_check(valid_token: UserAPIKeyAuth) -> list[str]:
"""
Expand key model sentinels before auth checks.
``all-team-models`` means inherit the parent team's allowlist -- same
semantics as ``get_key_models`` in ``model_checks.py``.
If the key has no team_id, it inherits the full proxy model list
(equivalent to an empty models field, i.e. unrestricted access).
"""
models: Final = list(valid_token.models or [])
if SpecialModelNames.all_team_models.value in models:
if valid_token.team_id is None:
return []
return list(valid_token.team_models or [])
return models
async def can_key_call_model(
model: str | list[str],
llm_model_list: list | None,
valid_token: UserAPIKeyAuth,
llm_router: litellm.Router | None,
) -> Literal[True]:
"""
Checks if token can call a given model
1. First checks native key-level model permissions (current implementation)
2. If not allowed natively, falls back to access_group_ids on the key
Returns:
- True: if token allowed to call model
Raises:
- Exception: If token not allowed to call model
"""
key_models: Final = _resolve_key_models_for_auth_check(valid_token=valid_token)
try:
return _can_object_call_model(
model=model,
llm_router=llm_router,
models=key_models,
team_model_aliases=valid_token.team_model_aliases,
team_id=valid_token.team_id,
object_type="key",
)
except ProxyException:
# Fallback: check key's access_group_ids
key_access_group_ids: Final = valid_token.access_group_ids or []
if key_access_group_ids:
models_from_groups: Final = await _get_models_from_access_groups(
access_group_ids=key_access_group_ids,
)
if models_from_groups:
return _can_object_call_model(
model=model,
llm_router=llm_router,
models=models_from_groups,
team_model_aliases=valid_token.team_model_aliases,
team_id=valid_token.team_id,
object_type="key",
)
raise
async def can_key_call_resolved_model(
model: str,
llm_model_list: list | None,
valid_token: UserAPIKeyAuth,
llm_router: litellm.Router | None,
) -> None:
from litellm.proxy.proxy_server import (
prisma_client,
proxy_logging_obj,
user_api_key_cache,
)
skip_key_model_check: Final = valid_token.config or (
isinstance(valid_token.models, list) and SpecialModelNames.all_team_models.value in valid_token.models
)
if not skip_key_model_check:
await can_key_call_model(
model=model,
llm_model_list=llm_model_list,
valid_token=valid_token,
llm_router=llm_router,
)
team_object: LiteLLM_TeamTableCachedObj | None = None
team_object_from_lookup = False
if valid_token.team_id is not None:
try:
team_object = await get_team_object(
team_id=valid_token.team_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
parent_otel_span=valid_token.parent_otel_span,
proxy_logging_obj=proxy_logging_obj,
)
team_object_from_lookup = True
except Exception:
team_object = LiteLLM_TeamTableCachedObj(
team_id=valid_token.team_id,
models=valid_token.team_models,
blocked=valid_token.team_blocked,
team_alias=valid_token.team_alias,
metadata=valid_token.team_metadata,
object_permission_id=valid_token.team_object_permission_id,
object_permission=valid_token.team_object_permission,
)
if team_object is not None:
try:
await can_team_access_model(
model=model,
team_object=team_object,
llm_router=llm_router,
team_model_aliases=valid_token.team_model_aliases,
)
except ProxyException as team_denial:
if team_denial.type != ProxyErrorTypes.team_model_access_denied:
raise
if not await _key_access_group_grants_model(
model=model,
valid_token=valid_token,
team_object=team_object,
llm_router=llm_router,
):
raise
if valid_token.user_id is not None and team_object_from_lookup:
await _check_team_member_model_access(
model=model,
team_object=team_object,
valid_token=valid_token,
llm_router=llm_router,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
if valid_token.project_id is not None:
project_object: Final = await get_project_object(
project_id=valid_token.project_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
if project_object is not None and len(project_object.models) > 0:
can_project_access_model(
model=model,
project_object=project_object,
llm_router=llm_router,
)
def can_org_access_model(
model: str,
org_object: LiteLLM_OrganizationTable | None,
llm_router: Router | None,
team_model_aliases: dict[str, str] | None = None,
) -> Literal[True]:
"""
Returns True if the team can access a specific model.
"""
return _can_object_call_model(
model=model,
llm_router=llm_router,
models=org_object.models if org_object else [],
team_model_aliases=team_model_aliases,
object_type="org",
)
async def can_team_access_model(
model: str | list[str],
team_object: LiteLLM_TeamTable | None,
llm_router: Router | None,
team_model_aliases: dict[str, str] | None = None,
) -> Literal[True]:
"""
Returns True if the team can access a specific model.
1. First checks native team-level model permissions (current implementation)
2. If not allowed natively, falls back to access_group_ids on the team
"""
try:
return _can_object_call_model(
model=model,
llm_router=llm_router,
models=team_object.models if team_object else [],
team_model_aliases=team_model_aliases,
team_id=team_object.team_id if team_object else None,
object_type="team",
)
except ProxyException:
# Fallback: check team's access_group_ids
team_access_group_ids: Final = (team_object.access_group_ids or []) if team_object else []
if team_access_group_ids:
models_from_groups: Final = await _get_models_from_access_groups(
access_group_ids=team_access_group_ids,
)
if models_from_groups:
return _can_object_call_model(
model=model,
llm_router=llm_router,
models=models_from_groups,
team_model_aliases=team_model_aliases,
team_id=team_object.team_id if team_object else None,
object_type="team",
)
raise
async def get_authorized_resources_from_key_access_groups(
valid_token: UserAPIKeyAuth | None,
team_object: LiteLLM_TeamTable | None,
resource_field: Literal["access_model_names", "access_mcp_server_ids", "access_agent_ids"],
) -> list[str]:
"""
For each access_group_id on the key, fetch the LiteLLM_AccessGroupTable row
and contribute its `resource_field` only if the group authorizes the caller
as an owner — that is, the group's `assigned_team_ids` includes the key's
`team_id`, or the group's `assigned_key_ids` includes the key's token. This
preserves the team-as-owner boundary while still letting a group reach the
key without first being added to the team's `access_group_ids` list.
"""
if valid_token is None:
return []
key_access_group_ids: Final = list(valid_token.access_group_ids or [])
if not key_access_group_ids:
return []
from litellm.proxy.proxy_server import prisma_client as _prisma_client
from litellm.proxy.proxy_server import proxy_logging_obj as _proxy_logging_obj
from litellm.proxy.proxy_server import user_api_key_cache as _user_api_key_cache
if _prisma_client is None or _user_api_key_cache is None:
return []
key_team_id: Final = valid_token.team_id or (team_object.team_id if team_object is not None else None)
key_token: Final = valid_token.token
authorized_resources: Final[list[str]] = []
for ag_id in key_access_group_ids:
try:
ag = await get_access_object(
access_group_id=ag_id,
prisma_client=_prisma_client,
user_api_key_cache=_user_api_key_cache,
proxy_logging_obj=_proxy_logging_obj,
)
except Exception:
continue
team_authorized = bool(key_team_id and key_team_id in (ag.assigned_team_ids or []))
key_authorized = bool(key_token and key_token in (ag.assigned_key_ids or []))
if team_authorized or key_authorized:
authorized_resources.extend(getattr(ag, resource_field, []) or [])
return list(set(authorized_resources))
async def _key_access_group_grants_model(
model: str | list[str],
valid_token: UserAPIKeyAuth | None,
team_object: LiteLLM_TeamTable | None,
llm_router: Router | None,
) -> bool:
"""
Returns True if the key's `access_group_ids` expand to models that grant
access to `model`. Used to let a key's access group override a team's
model restriction in `common_checks`.
"""
authorized_models: Final = await get_authorized_resources_from_key_access_groups(
valid_token=valid_token,
team_object=team_object,
resource_field="access_model_names",
)
if not authorized_models:
return False
try:
_can_object_call_model(
model=model,
llm_router=llm_router,
models=authorized_models,
team_model_aliases=valid_token.team_model_aliases if valid_token else None,
team_id=valid_token.team_id if valid_token else None,
object_type="key",
)
return True
except ProxyException:
return False
def can_project_access_model(
model: str | list[str],
project_object: LiteLLM_ProjectTableCachedObj,
llm_router: Router | None,
) -> Literal[True]:
"""
Returns True if the project can access a specific model.
Raises ProxyException if access is denied.
"""
return _can_object_call_model(
model=model,
llm_router=llm_router,
models=project_object.models if project_object else [],
object_type="project",
)
async def can_user_call_model(
model: str | list[str],
llm_router: Router | None,
user_object: LiteLLM_UserTable | None,
) -> Literal[True]:
if user_object is None:
return True
if SpecialModelNames.no_default_models.value in user_object.models:
raise ProxyException(
message=f"User not allowed to access model. No default model access, only team models allowed. Tried to access {model}",
type=ProxyErrorTypes.key_model_access_denied,
param="model",
code=status.HTTP_403_FORBIDDEN,
)
return _can_object_call_model(
model=model,
llm_router=llm_router,
models=user_object.models,
object_type="user",
)
def _search_tool_names_from_object_permission(
object_permission: LiteLLM_ObjectPermissionTable | None,
) -> list[str]:
"""Return allowlisted search tool names from object_permission (empty = unrestricted)."""
if object_permission is None:
return []
raw: Final = object_permission.search_tools
if not raw:
return []
return list(raw)
def _can_object_call_search_tool(
search_tool_name: str,
allowed_search_tools: list[str],
object_type: Literal["key", "team", "project"],
) -> Literal[True]:
"""
Check if an object (key/team/project) can access a specific search tool.
Similar to _can_object_call_model but for search tools.
Args:
search_tool_name: The search tool being requested
allowed_search_tools: List of allowed search tool names for this object
object_type: Type of object for error messaging
Returns:
True if access is allowed
Raises:
ProxyException if access is denied
"""
# Empty list means all search tools are allowed
if not allowed_search_tools:
return True
# Check if the search tool is in the allowlist
if search_tool_name in allowed_search_tools:
return True
# Access denied
raise ProxyException(
message=f"{object_type.capitalize()} not allowed to access search tool: {search_tool_name}. "
f"Allowed search tools: {allowed_search_tools}",
type=ProxyErrorTypes.key_model_access_denied,
param="search_tool_name",
code=status.HTTP_403_FORBIDDEN,
)
async def can_key_call_search_tool(
search_tool_name: str,
valid_token: UserAPIKeyAuth,
) -> Literal[True]:
"""
Check if a key can access a specific search tool.
Similar to can_key_call_model but for search tools.
Args:
search_tool_name: The search tool being requested
valid_token: The authenticated key
Returns:
True if access is allowed
Raises:
ProxyException if access is denied
"""
return _can_object_call_search_tool(
search_tool_name=search_tool_name,
allowed_search_tools=_search_tool_names_from_object_permission(valid_token.object_permission),
object_type="key",
)
async def can_team_call_search_tool(
search_tool_name: str,
team_object: LiteLLM_TeamTable | None,
) -> Literal[True]:
"""
Check if a team can access a specific search tool.
Similar to can_team_access_model but for search tools.
Args:
search_tool_name: The search tool being requested
team_object: The team object
Returns:
True if access is allowed
Raises:
ProxyException if access is denied
"""
if team_object is None:
return True
return _can_object_call_search_tool(
search_tool_name=search_tool_name,
allowed_search_tools=_search_tool_names_from_object_permission(team_object.object_permission),
object_type="team",
)
async def can_user_view_search_tool(
search_tool_name: str,
valid_token: UserAPIKeyAuth,
team_object: LiteLLM_TeamTable | None,
) -> bool:
"""
Boolean variant of the key + team authorization enforced on /search, used to
scope /search_tools/list so a non-admin caller only sees tools it may invoke.
"""
try:
await can_key_call_search_tool(
search_tool_name=search_tool_name,
valid_token=valid_token,
)
await can_team_call_search_tool(
search_tool_name=search_tool_name,
team_object=team_object,
)
except ProxyException:
return False
return True
async def is_valid_fallback_model(
model: str,
llm_router: Router | None,
user_model: str | None,
) -> Literal[True]:
"""
Try to route the fallback model request.
Validate if it can't be routed.
Help catch invalid fallback models.
"""
await route_request(
data={
"model": model,
"messages": [{"role": "user", "content": "Who was Alexander?"}],
},
llm_router=llm_router,
user_model=user_model,
route_type="acompletion", # route type shouldn't affect the fallback model check
)
return True
def _apply_budget_exceeded_throttle(valid_token: UserAPIKeyAuth) -> bool:
"""
Throttle an over-budget key instead of blocking it, when the key opted in
via `throttle_on_budget_exceeded` and a global percentage is configured.
Records the percentage on the request-scoped `budget_throttle_pct` so the
rate limiter scales the key's TPM/RPM down to it; the persistent limits are
left untouched so the throttle never compounds across requests. Returns True
when the key was throttled (caller skips raising), False when it should still
be hard-blocked.
"""
pct: Final = budget_throttle_percentage()
if pct is None or not should_throttle_budget_exceeded(valid_token):
return False
valid_token.budget_throttle_pct = pct
return True
async def _virtual_key_max_budget_check(
valid_token: UserAPIKeyAuth,
proxy_logging_obj: ProxyLogging,
user_obj: LiteLLM_UserTable | None = None,
):
"""
Raises:
BudgetExceededError if the token is over it's max budget.
Triggers a budget alert if the token is over it's max budget.
"""
if valid_token.max_budget is not None:
from litellm.proxy.proxy_server import get_current_spend
fallback_spend: Final = valid_token.spend or 0.0
counter_key: Final = f"spend:key:{valid_token.token}"
# Read spend from cross-pod counter (Redis-first) or cached object (fallback)
spend: Final = await get_current_spend(
counter_key=counter_key,
fallback_spend=fallback_spend,
max_budget=valid_token.max_budget,
)
####################################
# collect information for alerting #
####################################
user_email = None
# Check if the token has any user id information
if user_obj is not None:
user_email = user_obj.user_email
call_info: Final = CallInfo(
token=valid_token.token,
spend=spend,
max_budget=valid_token.max_budget,
soft_budget=valid_token.soft_budget,
user_id=valid_token.user_id,
team_id=valid_token.team_id,
organization_id=valid_token.org_id,
user_email=user_email,
key_alias=valid_token.key_alias,
event_group=Litellm_EntityType.KEY,
)
asyncio.create_task(
proxy_logging_obj.budget_alerts(
type="token_budget",
user_info=call_info,
)
)
####################################
# collect information for alerting #
####################################
# Defense-in-depth (GHSA-2rv4-xv66-fpjg): spend >= NaN is always False,
# so a NaN max_budget would silently disable enforcement. Treat a
# non-finite max_budget as "no configured limit" rather than as a bypass.
if math.isfinite(valid_token.max_budget) and spend >= valid_token.max_budget:
if _apply_budget_exceeded_throttle(valid_token):
return
# name the key in the error so operators don't have to reverse-map
# spend back to a key; key_name is the masked form (last 4 chars)
key_label: Final = valid_token.key_alias or "key"
key_descriptor: Final = f"{key_label} ({valid_token.key_name})" if valid_token.key_name else key_label
raise litellm.BudgetExceededError(
current_cost=spend,
max_budget=valid_token.max_budget,
message=f"Budget has been exceeded! Key={key_descriptor} Current cost: {spend}, Max budget: {valid_token.max_budget}",
entity_type=Litellm_EntityType.KEY.value,
entity_id=valid_token.token,
)
async def _virtual_key_multi_budget_check(
valid_token: UserAPIKeyAuth,
):
"""
Raises BudgetExceededError if any budget window in valid_token.budget_limits is exceeded.
Each window has its own Redis counter keyed by spend:key:{token}:window:{budget_duration}.
Using budget_duration (not list index) keeps counters stable when windows are reordered
or removed during a key update.
Note: counters are not seeded from DB on Redis cold-start. After a Redis flush,
per-window spend resets to zero within the current window period. This is an acceptable
trade-off: the DB stores reset_at timestamps but not per-window accumulated spend.
"""
if not valid_token.budget_limits:
return
from litellm.proxy.proxy_server import get_current_spend
for window in valid_token.budget_limits:
w: dict = window if isinstance(window, dict) else window.model_dump()
counter_key = f"spend:key:{valid_token.token}:window:{w['budget_duration']}"
window_spend = await get_current_spend(
counter_key=counter_key,
fallback_spend=0.0,
max_budget=w["max_budget"],
window_entity_type="Key",
window_entity_id=valid_token.token,
window_start=get_budget_window_start(w),
)
if math.isfinite(w["max_budget"]) and window_spend >= w["max_budget"]:
raise litellm.BudgetExceededError(
current_cost=window_spend,
max_budget=w["max_budget"],
message=(
f"ExceededBudget: Key over {w['budget_duration']} budget. "
f"Spend=${window_spend:.4f}, Limit=${w['max_budget']:.2f}"
),
entity_type=Litellm_EntityType.KEY.value,
entity_id=valid_token.token,
)
async def _virtual_key_soft_budget_check(
valid_token: UserAPIKeyAuth,
proxy_logging_obj: ProxyLogging,
user_obj: LiteLLM_UserTable | None = None,
):
"""
Triggers a budget alert if the token is over it's soft budget.
"""
if valid_token.soft_budget and valid_token.spend >= valid_token.soft_budget:
verbose_proxy_logger.debug(
"Crossed Soft Budget for token %s, spend %s, soft_budget %s",
valid_token.token,
valid_token.spend,
valid_token.soft_budget,
)
call_info: Final = CallInfo(
token=valid_token.token,
spend=valid_token.spend,
max_budget=valid_token.max_budget,
soft_budget=valid_token.soft_budget,
user_id=valid_token.user_id,
team_id=valid_token.team_id,
team_alias=valid_token.team_alias,
organization_id=valid_token.org_id,
user_email=user_obj.user_email if user_obj else None,
key_alias=valid_token.key_alias,
event_group=Litellm_EntityType.KEY,
)
asyncio.create_task(
proxy_logging_obj.budget_alerts(
type="soft_budget",
user_info=call_info,
)
)
def _parse_email_list(raw: str | Sequence[object] | None) -> list[str]:
"""Parse emails from a list or comma-separated string."""
if isinstance(raw, list):
return [e.strip() for e in raw if isinstance(e, str) and e.strip()]
elif isinstance(raw, str):
return [e.strip() for e in raw.split(",") if e.strip()]
return []
def _normalize_alert_emails(
cfg: Mapping[str, str | Sequence[object] | None] | None,
) -> dict[str, list[str]]:
"""Coerce user-supplied threshold→recipients mapping to Dict[str, List[str]].
Values may legitimately arrive as list, comma-separated string, or None
from YAML/metadata; _parse_email_list tolerates all three.
"""
if not cfg:
return {}
return {k: _parse_email_list(v) for k, v in cfg.items()}
def _merge_budget_alert_email_configs(
global_cfg: Mapping[str, str | Sequence[object] | None] | None,
per_key_cfg: Mapping[str, str | Sequence[object] | None] | None,
) -> dict[str, list[str]] | None:
"""
Per-threshold additive merge: each threshold's recipient list is the union
of global + per-key entries (deduped, global-first ordering). Missing
thresholds on one side are inherited from the other.
"""
global_cfg_normalized: Final = _normalize_alert_emails(global_cfg)
per_key_cfg_normalized: Final = _normalize_alert_emails(per_key_cfg)
if not global_cfg_normalized and not per_key_cfg_normalized:
return None
thresholds: Final = set(global_cfg_normalized) | set(per_key_cfg_normalized)
return {
t: list(dict.fromkeys(global_cfg_normalized.get(t, []) + per_key_cfg_normalized.get(t, []))) for t in thresholds
}
async def _virtual_key_max_budget_alert_check(
valid_token: UserAPIKeyAuth,
proxy_logging_obj: ProxyLogging,
user_obj: LiteLLM_UserTable | None = None,
):
"""
Triggers a budget alert if the token has reached EMAIL_BUDGET_ALERT_MAX_SPEND_ALERT_PERCENTAGE
(default 80%) of its max budget.
This is a warning alert before the token actually exceeds the max budget.
"""
if valid_token.max_budget is not None and valid_token.spend is not None and valid_token.spend > 0:
owner_email: Final = user_obj.user_email if user_obj else None
alert_email_config: Final[dict[str, list[str]] | None] = _merge_budget_alert_email_configs(
global_cfg=litellm.default_key_max_budget_alert_emails,
per_key_cfg=(valid_token.metadata or {}).get("max_budget_alert_emails"),
)
if isinstance(alert_email_config, dict) and alert_email_config:
# New path: only create task if spend has crossed the lowest threshold
min_pct: Final = min(
(int(k) for k in alert_email_config if k.isdigit()),
default=None,
)
if min_pct is None or valid_token.spend < valid_token.max_budget * (min_pct / 100.0):
return
call_info = CallInfo(
token=valid_token.token,
spend=valid_token.spend,
max_budget=valid_token.max_budget,
soft_budget=valid_token.soft_budget,
user_id=valid_token.user_id,
team_id=valid_token.team_id,
team_alias=valid_token.team_alias,
organization_id=valid_token.org_id,
user_email=owner_email,
key_alias=valid_token.key_alias,
event_group=Litellm_EntityType.KEY,
max_budget_alert_emails=alert_email_config,
)
asyncio.create_task(
proxy_logging_obj.budget_alerts(
type="max_budget_alert",
user_info=call_info,
)
)
else:
# Old path: existing single 80% threshold — completely unchanged
alert_threshold: Final = valid_token.max_budget * EMAIL_BUDGET_ALERT_MAX_SPEND_ALERT_PERCENTAGE
if valid_token.spend >= alert_threshold and valid_token.spend < valid_token.max_budget:
verbose_proxy_logger.debug(
"Reached Max Budget Alert Threshold for token %s, spend %s, max_budget %s, alert_threshold %s",
valid_token.token,
valid_token.spend,
valid_token.max_budget,
alert_threshold,
)
call_info = CallInfo(
token=valid_token.token,
spend=valid_token.spend,
max_budget=valid_token.max_budget,
soft_budget=valid_token.soft_budget,
user_id=valid_token.user_id,
team_id=valid_token.team_id,
team_alias=valid_token.team_alias,
organization_id=valid_token.org_id,
user_email=owner_email,
key_alias=valid_token.key_alias,
event_group=Litellm_EntityType.KEY,
)
asyncio.create_task(
proxy_logging_obj.budget_alerts(
type="max_budget_alert",
user_info=call_info,
)
)
async def _check_team_member_budget(
team_object: LiteLLM_TeamTable | None,
user_object: LiteLLM_UserTable | None,
valid_token: UserAPIKeyAuth | None,
prisma_client: PrismaClient | None,
user_api_key_cache: UserApiKeyCache,
proxy_logging_obj: ProxyLogging,
):
"""Check if team member is over their max budget within the team."""
if (
team_object is not None
and team_object.team_id is not None
and valid_token is not None
and valid_token.user_id is not None
):
team_membership: Final = await get_team_membership(
user_id=valid_token.user_id,
team_id=team_object.team_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
# Per-member override wins; otherwise fall back to the team-level
# default configured via team.metadata["team_member_budget_id"].
team_member_budget: float | None = None
if (
team_membership is not None
and team_membership.litellm_budget_table is not None
and team_membership.litellm_budget_table.max_budget is not None
):
team_member_budget = team_membership.litellm_budget_table.max_budget
else:
default_budget_id: Final = (team_object.metadata or {}).get("team_member_budget_id")
if isinstance(default_budget_id, str):
default_budget: Final = await get_team_member_default_budget(
budget_id=default_budget_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
)
# Treat 0 on the team default as "no cap".
# Per-member rows still respect 0 as an explicit admin disable.
if (
default_budget is not None
and default_budget.max_budget is not None
and default_budget.max_budget > 0
):
team_member_budget = default_budget.max_budget
if team_member_budget is not None:
team_member_spend = (team_membership.spend if team_membership is not None else 0.0) or 0.0
# Read from cross-pod counter (Redis-first) if available
from litellm.proxy.proxy_server import get_current_spend
team_member_spend = await get_current_spend(
counter_key=f"spend:team_member:{valid_token.user_id}:{team_object.team_id}",
fallback_spend=team_member_spend,
max_budget=team_member_budget,
)
if math.isfinite(team_member_budget) and team_member_spend >= team_member_budget:
raise litellm.BudgetExceededError(
current_cost=team_member_spend,
max_budget=team_member_budget,
message=f"Budget has been exceeded! User={valid_token.user_id} in Team={team_object.team_id} Current cost: {team_member_spend}, Max budget: {team_member_budget}",
entity_type=Litellm_EntityType.TEAM_MEMBER.value,
entity_id=f"{valid_token.user_id}:{team_object.team_id}",
)
async def _check_team_member_model_access(
model: str | list[str],
team_object: LiteLLM_TeamTable,
valid_token: UserAPIKeyAuth,
llm_router: Router | None,
prisma_client: Optional["PrismaClient"],
user_api_key_cache: UserApiKeyCache,
proxy_logging_obj: ProxyLogging,
) -> None:
"""
Check if a team member's per-member model scope allows access to the requested model.
Only enforced when the member's budget table has a non-empty allowed_models list.
If allowed_models is empty or absent, the team-level models list applies (no extra restriction).
"""
if valid_token.user_id is None or team_object.team_id is None:
return
team_membership: Final = await get_team_membership(
user_id=valid_token.user_id,
team_id=team_object.team_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
if (
team_membership is None
or team_membership.litellm_budget_table is None
or not team_membership.litellm_budget_table.allowed_models
):
return # no per-member restriction — inherit team-level check
member_allowed_models: Final[list[str]] = team_membership.litellm_budget_table.allowed_models
try:
_can_object_call_model(
model=model,
llm_router=llm_router,
models=member_allowed_models,
object_type="team",
team_id=team_object.team_id,
)
except ProxyException:
raise ProxyException(
message=f"Team member not allowed to access model. User={valid_token.user_id}, Team={team_object.team_id}, Model={model}. Allowed member models = {member_allowed_models}",
type=ProxyErrorTypes.team_model_access_denied,
param="model",
code=status.HTTP_403_FORBIDDEN,
)
async def _team_max_budget_check(
team_object: LiteLLM_TeamTable | None,
valid_token: UserAPIKeyAuth | None,
proxy_logging_obj: ProxyLogging,
):
"""
Check if the team is over it's max budget.
Raises:
BudgetExceededError if the team is over it's max budget.
Triggers a budget alert if the team is over it's max budget.
"""
if team_object is not None and team_object.max_budget is not None:
from litellm.proxy.proxy_server import get_current_spend
# Read spend from cross-pod counter (Redis-first) or cached object (fallback)
spend: Final = await get_current_spend(
counter_key=f"spend:team:{team_object.team_id}",
fallback_spend=team_object.spend or 0.0,
max_budget=team_object.max_budget,
)
if math.isfinite(team_object.max_budget) and spend > team_object.max_budget:
if valid_token:
call_info: Final = CallInfo(
token=valid_token.token,
spend=spend,
max_budget=team_object.max_budget,
user_id=valid_token.user_id,
team_id=valid_token.team_id,
team_alias=valid_token.team_alias,
organization_id=valid_token.org_id,
event_group=Litellm_EntityType.TEAM,
)
asyncio.create_task(
proxy_logging_obj.budget_alerts(
type="team_budget",
user_info=call_info,
)
)
raise litellm.BudgetExceededError(
current_cost=spend,
max_budget=team_object.max_budget,
message=f"Budget has been exceeded! Team={team_object.team_id} Current cost: {spend}, Max budget: {team_object.max_budget}",
entity_type=Litellm_EntityType.TEAM.value,
entity_id=team_object.team_id,
)
async def _team_multi_budget_check(
team_object: LiteLLM_TeamTable | None,
):
"""
Raises BudgetExceededError if any budget window in team_object.budget_limits is exceeded.
Each window has its own Redis counter keyed by spend:team:{team_id}:window:{budget_duration}.
Using budget_duration (not list index) keeps counters stable when windows are reordered
or removed during a team update.
"""
if team_object is None or not team_object.budget_limits:
return
from litellm.proxy.proxy_server import get_current_spend
for window in team_object.budget_limits:
w: dict = window if isinstance(window, dict) else window.model_dump()
counter_key = f"spend:team:{team_object.team_id}:window:{w['budget_duration']}"
window_spend = await get_current_spend(
counter_key=counter_key,
fallback_spend=0.0,
max_budget=w["max_budget"],
window_entity_type="Team",
window_entity_id=team_object.team_id,
window_start=get_budget_window_start(w),
)
if math.isfinite(w["max_budget"]) and window_spend >= w["max_budget"]:
raise litellm.BudgetExceededError(
current_cost=window_spend,
max_budget=w["max_budget"],
message=(
f"ExceededBudget: Team={team_object.team_id} over {w['budget_duration']} budget. "
f"Spend=${window_spend:.4f}, Limit=${w['max_budget']:.2f}"
),
entity_type=Litellm_EntityType.TEAM.value,
entity_id=team_object.team_id,
)
async def _team_soft_budget_check(
team_object: LiteLLM_TeamTable | None,
valid_token: UserAPIKeyAuth | None,
proxy_logging_obj: ProxyLogging,
):
"""
Triggers a budget alert if the team is over it's soft budget.
"""
if (
team_object is not None
and team_object.soft_budget is not None
and team_object.spend is not None
and team_object.spend >= team_object.soft_budget
):
verbose_proxy_logger.debug(
"Crossed Soft Budget for team %s, spend %s, soft_budget %s",
team_object.team_id,
team_object.spend,
team_object.soft_budget,
)
if valid_token:
# Extract alert emails from team metadata
alert_emails: list[str] | None = None
if team_object.metadata is not None and isinstance(team_object.metadata, dict):
soft_budget_alert_emails: Final = team_object.metadata.get("soft_budget_alerting_emails")
if soft_budget_alert_emails is not None:
if isinstance(soft_budget_alert_emails, list):
alert_emails = [
email for email in soft_budget_alert_emails if isinstance(email, str) and email.strip()
]
elif isinstance(soft_budget_alert_emails, str):
# Handle comma-separated string
alert_emails = [email.strip() for email in soft_budget_alert_emails.split(",") if email.strip()]
# Filter out empty strings
if alert_emails:
alert_emails = [email for email in alert_emails if email]
else:
alert_emails = None
# Only send team soft budget alerts if alert_emails are configured
# Team soft budget alerts are sent via metadata.soft_budget_alerting_emails, not global alerting
if alert_emails is None or len(alert_emails) == 0:
verbose_proxy_logger.debug(
"Skipping team soft budget alert for team %s: no alert_emails configured in metadata.soft_budget_alerting_emails",
team_object.team_id,
)
return
call_info: Final = CallInfo(
token=valid_token.token,
spend=team_object.spend,
max_budget=team_object.max_budget,
soft_budget=team_object.soft_budget,
user_id=valid_token.user_id,
team_id=valid_token.team_id,
team_alias=valid_token.team_alias,
organization_id=valid_token.org_id,
user_email=None, # Team-level alert, no specific user email
key_alias=valid_token.key_alias,
event_group=Litellm_EntityType.TEAM,
alert_emails=alert_emails,
)
asyncio.create_task(
proxy_logging_obj.budget_alerts(
type="soft_budget",
user_info=call_info,
)
)
async def _project_max_budget_check(
project_object: LiteLLM_ProjectTableCachedObj | None,
valid_token: UserAPIKeyAuth | None,
proxy_logging_obj: ProxyLogging,
):
"""
Check if the project is over its max budget.
Raises:
BudgetExceededError if the project is over its max budget.
Triggers a budget alert if the project is over its max budget.
"""
if project_object is None:
return
max_budget = None
if project_object.litellm_budget_table is not None:
max_budget = project_object.litellm_budget_table.max_budget
if (
max_budget is not None
and project_object.spend is not None
and math.isfinite(max_budget)
and project_object.spend > max_budget
):
if valid_token:
call_info: Final = CallInfo(
token=valid_token.token,
spend=project_object.spend,
max_budget=max_budget,
user_id=valid_token.user_id,
team_id=valid_token.team_id,
team_alias=valid_token.team_alias,
organization_id=valid_token.org_id,
event_group=Litellm_EntityType.PROJECT,
)
asyncio.create_task(
proxy_logging_obj.budget_alerts(
type="project_budget",
user_info=call_info,
)
)
raise litellm.BudgetExceededError(
current_cost=project_object.spend,
max_budget=max_budget,
message=f"Budget has been exceeded! Project={project_object.project_id} Current cost: {project_object.spend}, Max budget: {max_budget}",
entity_type=Litellm_EntityType.PROJECT.value,
entity_id=project_object.project_id,
)
async def _project_soft_budget_check(
project_object: LiteLLM_ProjectTableCachedObj | None,
valid_token: UserAPIKeyAuth | None,
proxy_logging_obj: ProxyLogging,
):
"""
Triggers a budget alert if the project is over its soft budget.
Mirrors _team_soft_budget_check() pattern.
"""
if project_object is None:
return
soft_budget = None
if project_object.litellm_budget_table is not None:
soft_budget = project_object.litellm_budget_table.soft_budget
if soft_budget is not None and project_object.spend is not None and project_object.spend >= soft_budget:
verbose_proxy_logger.debug(
"Crossed Soft Budget for project %s, spend %s, soft_budget %s",
project_object.project_id,
project_object.spend,
soft_budget,
)
if valid_token:
call_info: Final = CallInfo(
token=valid_token.token,
spend=project_object.spend,
max_budget=None,
soft_budget=soft_budget,
user_id=valid_token.user_id,
team_id=valid_token.team_id,
team_alias=valid_token.team_alias,
organization_id=valid_token.org_id,
event_group=Litellm_EntityType.PROJECT,
)
asyncio.create_task(
proxy_logging_obj.budget_alerts(
type="soft_budget",
user_info=call_info,
)
)
def _project_cache_key(project_id: str) -> str:
return f"project_id:{project_id}"
async def get_project_object(
project_id: str,
prisma_client: PrismaClient | None,
user_api_key_cache: UserApiKeyCache,
proxy_logging_obj: ProxyLogging | None = None,
) -> LiteLLM_ProjectTableCachedObj | None:
"""
Fetch project object from cache or DB.
Follows get_team_object() caching pattern with TTL and last_refreshed_at.
Returns LiteLLM_ProjectTableCachedObj or None if not found.
"""
if prisma_client is None:
return None
# Check cache first
cache_key: Final = _project_cache_key(project_id)
deserialized_project: Final = await user_api_key_cache.async_get_cache(
key=cache_key,
model_type=LiteLLM_ProjectTableCachedObj,
)
if deserialized_project is not None:
return deserialized_project
# Fetch from DB
project_row: Final = await _model_dump_table(ProjectRepository(prisma_client)).find_unique(
where={"project_id": project_id},
include={"litellm_budget_table": True},
)
if project_row is None:
return None
project_obj: Final = LiteLLM_ProjectTableCachedObj.model_validate(project_row.model_dump())
# Cache with TTL following _cache_management_object pattern
project_obj.last_refreshed_at = time.time()
await _cache_management_object(
key=cache_key,
value=project_obj,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
model_type=LiteLLM_ProjectTableCachedObj,
)
return project_obj
async def delete_cached_project_object(
project_id: str,
user_api_key_cache: UserApiKeyCache,
) -> None:
"""
Every endpoint that mutates litellm_projecttable must call this, or a stale project (e.g. a
pre-update empty model allowlist) keeps being enforced until the TTL expires (LIT-3803).
"""
from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import evict_and_broadcast
await evict_and_broadcast(
cache_keys=(_project_cache_key(project_id),),
user_api_key_cache=user_api_key_cache,
)
async def _organization_max_budget_check(
valid_token: UserAPIKeyAuth | None,
team_object: LiteLLM_TeamTable | None,
prisma_client: PrismaClient | None,
user_api_key_cache: UserApiKeyCache,
proxy_logging_obj: ProxyLogging,
):
"""
Check if the organization is over its max budget.
This function checks the organization budget using:
1. First, tries to use valid_token.org_id (if key has organization_id set)
2. Falls back to team_object.organization_id (if key doesn't have org_id but team does)
This ensures organization budget checks work even when keys don't have organization_id
set directly, as long as their team belongs to an organization.
Raises:
BudgetExceededError if the organization is over its max budget.
Triggers a budget alert if the organization is over its max budget.
"""
if valid_token is None or prisma_client is None:
return
# Determine organization_id: first try from token, then fallback to team
org_id: str | None = None
if valid_token.org_id is not None:
org_id = valid_token.org_id
elif team_object is not None and team_object.organization_id is not None:
org_id = team_object.organization_id
# If no organization_id found, skip the check
if org_id is None:
return
# Get organization object with budget table - use get_org_object so it can be mocked in tests
try:
org_table: Final = await get_org_object(
org_id=org_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
include_budget_table=True,
)
except Exception:
# If organization lookup fails, skip the check
return
if org_table is None:
return
# Get max_budget from organization's budget table
org_max_budget: float | None = None
if org_table.litellm_budget_table is not None:
org_max_budget = org_table.litellm_budget_table.max_budget
# Only check if organization has a valid max_budget set
if org_max_budget is None or org_max_budget <= 0:
return
# Read spend from cross-pod counter (Redis-first) or cached object (fallback)
from litellm.proxy.proxy_server import get_current_spend
org_spend: Final = await get_current_spend(
counter_key=f"spend:org:{org_id}",
fallback_spend=org_table.spend or 0.0,
max_budget=org_max_budget,
)
# Check if organization spend exceeds max budget
if math.isfinite(org_max_budget) and org_spend >= org_max_budget:
# Trigger budget alert
call_info: Final = CallInfo(
token=valid_token.token,
spend=org_spend,
max_budget=org_max_budget,
user_id=valid_token.user_id,
team_id=valid_token.team_id,
team_alias=valid_token.team_alias,
organization_id=org_id,
event_group=Litellm_EntityType.ORGANIZATION,
)
asyncio.create_task(
proxy_logging_obj.budget_alerts(
type="organization_budget",
user_info=call_info,
)
)
raise litellm.BudgetExceededError(
current_cost=org_spend,
max_budget=org_max_budget,
message=f"Budget has been exceeded! Organization={org_id} Current cost: {org_spend}, Max budget: {org_max_budget}",
entity_type=Litellm_EntityType.ORGANIZATION.value,
entity_id=org_id,
)
async def _tag_max_budget_check(
request_body: dict,
prisma_client: PrismaClient | None,
user_api_key_cache: UserApiKeyCache,
proxy_logging_obj: ProxyLogging,
valid_token: UserAPIKeyAuth | None,
):
"""
Check if any tags in the request are over their max budget.
Raises:
BudgetExceededError if any tag is over its max budget.
Triggers a budget alert if any tag is over its max budget.
"""
from litellm.proxy.common_utils.http_parsing_utils import get_tags_from_request_body
if prisma_client is None:
return
# Get tags from request metadata
tags: Final = get_tags_from_request_body(request_body=request_body)
if not tags:
return
# Batch fetch all tags in one go
tag_objects: Final = await get_tag_objects_batch(
tag_names=tags,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
# Check budget for each tag
for tag_name in tags:
tag_object = tag_objects.get(tag_name)
if tag_object is None:
continue
# Check if tag has budget limits
if tag_object.litellm_budget_table is not None and tag_object.litellm_budget_table.max_budget is not None:
from litellm.proxy.proxy_server import get_current_spend
tag_spend = await get_current_spend(
counter_key=f"spend:tag:{tag_name}",
fallback_spend=tag_object.spend or 0.0,
max_budget=tag_object.litellm_budget_table.max_budget,
fallback_authoritative=True,
)
if tag_spend <= tag_object.litellm_budget_table.max_budget:
continue
raise litellm.BudgetExceededError(
current_cost=tag_spend,
max_budget=tag_object.litellm_budget_table.max_budget,
message=f"Budget has been exceeded! Tag={tag_name} Current cost: {tag_spend}, Max budget: {tag_object.litellm_budget_table.max_budget}",
entity_type=Litellm_EntityType.TAG.value,
entity_id=tag_name,
)
def is_model_allowed_by_pattern(model: str, allowed_model_pattern: str) -> bool:
"""
Check if a model matches an allowed pattern.
Handles exact matches and wildcard patterns.
Args:
model (str): The model to check (e.g., "bedrock/anthropic.claude-3-5-sonnet-20240620")
allowed_model_pattern (str): The allowed pattern (e.g., "bedrock/*", "*", "openai/*")
Returns:
bool: True if model matches the pattern, False otherwise
"""
if "*" in allowed_model_pattern:
pattern: Final = f"^{allowed_model_pattern.replace('*', '.*')}$"
return bool(re.match(pattern, model))
return False
def _model_matches_any_wildcard_pattern_in_list(model: str, allowed_model_list: list) -> bool:
"""
Returns True if a model matches any wildcard pattern in a list.
eg.
- model=`bedrock/us.amazon.nova-micro-v1:0`, allowed_models=`bedrock/*` returns True
- model=`bedrock/us.amazon.nova-micro-v1:0`, allowed_models=`bedrock/us.*` returns True
- model=`bedrockzzzz/us.amazon.nova-micro-v1:0`, allowed_models=`bedrock/*` returns False
"""
if any(
_is_wildcard_pattern(allowed_model_pattern)
and is_model_allowed_by_pattern(model=model, allowed_model_pattern=allowed_model_pattern)
for allowed_model_pattern in allowed_model_list
):
return True
if any(
_is_wildcard_pattern(allowed_model_pattern)
and _model_custom_llm_provider_matches_wildcard_pattern(
model=model, allowed_model_pattern=allowed_model_pattern
)
for allowed_model_pattern in allowed_model_list
):
return True
return False
def _model_custom_llm_provider_matches_wildcard_pattern(model: str, allowed_model_pattern: str) -> bool:
"""
Returns True for this scenario:
- `model=gpt-4o`
- `allowed_model_pattern=openai/*`
or
- `model=claude-3-5-sonnet-20240620`
- `allowed_model_pattern=anthropic/*`
A model that already carries a namespace get_llm_provider did not consume
(e.g. `bedrockz/anthropic.claude-...`) is never granted here: its provider was
inferred from a fragment of the full string, so rebuilding
`{provider}/{model}` would produce `bedrock/bedrockz/...` and slip an
unrecognized namespace through a `bedrock/*` key.
"""
try:
stripped_model, custom_llm_provider, _, _ = get_llm_provider(model=model)
except Exception:
return False
if stripped_model == model and "/" in model:
return False
return is_model_allowed_by_pattern(
model=f"{custom_llm_provider}/{stripped_model}",
allowed_model_pattern=allowed_model_pattern,
)
def _is_wildcard_pattern(allowed_model_pattern: str) -> bool:
"""
Returns True if the pattern is a wildcard pattern.
Checks if `*` is in the pattern.
"""
return "*" in allowed_model_pattern
async def vector_store_access_check(
request_body: dict,
team_object: LiteLLM_TeamTable | None,
valid_token: UserAPIKeyAuth | None,
):
"""
Checks if the object (key, team, org) has access to the vector store.
Raises ProxyException if the object (key, team, org) cannot access the specific vector store.
"""
from litellm.proxy.proxy_server import prisma_client
#########################################################
# Get the vector store the user is trying to access
#########################################################
if prisma_client is None:
verbose_proxy_logger.debug("Prisma client not found, skipping vector store access check")
return True
if litellm.vector_store_registry is None:
verbose_proxy_logger.debug("Vector store registry not found, skipping vector store access check")
return True
vector_store_ids_to_run: Final = litellm.vector_store_registry.get_vector_store_ids_to_run(
non_default_params=request_body, tools=request_body.get("tools", None)
)
if vector_store_ids_to_run is None:
verbose_proxy_logger.debug("Vector store to run not found, skipping vector store access check")
return True
#########################################################
# Check if the object (key, team, org) has access to the vector store
#########################################################
# Check if the key can access the vector store
if valid_token is not None and valid_token.object_permission_id is not None:
key_object_permission: Final = await _object_permission_table(
ObjectPermissionRepository(prisma_client)
).find_unique(
where={"object_permission_id": valid_token.object_permission_id},
)
if key_object_permission is not None:
_can_object_call_vector_stores(
object_type="key",
vector_store_ids_to_run=vector_store_ids_to_run,
object_permissions=key_object_permission,
)
# Check if the team can access the vector store
if team_object is not None and team_object.object_permission_id is not None:
team_object_permission: Final = await _object_permission_table(
ObjectPermissionRepository(prisma_client)
).find_unique(
where={"object_permission_id": team_object.object_permission_id},
)
if team_object_permission is not None:
_can_object_call_vector_stores(
object_type="team",
vector_store_ids_to_run=vector_store_ids_to_run,
object_permissions=team_object_permission,
)
return True
def _can_object_call_vector_stores(
object_type: Literal["key", "team", "org"],
vector_store_ids_to_run: list[str],
object_permissions: LiteLLM_ObjectPermissionTable | None,
):
"""
Raises ProxyException if the object (key, team, org) cannot access the specific vector store.
"""
if object_permissions is None:
return True
if object_permissions.vector_stores is None:
return True
# If length is 0, then the object has access to all vector stores.
if len(object_permissions.vector_stores) == 0:
return True
for vector_store_id in vector_store_ids_to_run:
if vector_store_id not in object_permissions.vector_stores:
raise ProxyException(
message=f"User not allowed to access vector store. Tried to access {vector_store_id}. Only allowed to access {object_permissions.vector_stores}",
type=ProxyErrorTypes.get_vector_store_access_error_type_for_object(object_type),
param="vector_store",
code=status.HTTP_401_UNAUTHORIZED,
)
return True