mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-09 02:24:40 +00:00
Merge pull request #25647 from BerriAI/litellm_yj_apr_11
[Infra] Merge dev branch with main
This commit is contained in:
@@ -57,12 +57,31 @@ oidc/config_name_here/
|
||||
|
||||
#### Unofficial Providers (not recommended)
|
||||
|
||||
For the unofficial `file` provider, you can use the following format:
|
||||
For the unofficial `file` provider, you can use the following format
|
||||
(note the double slash — the path after `oidc/file/` must be absolute):
|
||||
|
||||
```
|
||||
oidc/file/home/user/dave/this_is_a_file_with_a_token.txt
|
||||
oidc/file//var/run/secrets/my-token
|
||||
```
|
||||
|
||||
For safety, the resolved path must live inside an allowed credential
|
||||
directory. By default the following directories are allowed:
|
||||
|
||||
- `/var/run/secrets`
|
||||
- `/run/secrets`
|
||||
|
||||
If your deployment mounts credentials elsewhere, set the
|
||||
`LITELLM_OIDC_ALLOWED_CREDENTIAL_DIRS` environment variable to a
|
||||
comma-separated list of absolute directories. The value replaces the
|
||||
default list, so include the defaults if you still need them:
|
||||
|
||||
```bash
|
||||
export LITELLM_OIDC_ALLOWED_CREDENTIAL_DIRS="/var/run/secrets,/etc/litellm/creds"
|
||||
```
|
||||
|
||||
Paths that resolve (after following symlinks and `..`) outside the
|
||||
allowlist are rejected.
|
||||
|
||||
For the unofficial `env`, use the following format, where `SECRET_TOKEN` is the name of the environment variable that contains the token:
|
||||
|
||||
```
|
||||
|
||||
@@ -830,6 +830,7 @@ router_settings:
|
||||
| LITELLM_KEY_ROTATION_LOCK_TTL_SECONDS | TTL in seconds for the distributed lock used by the key rotation job. Default is 600 (10 minutes).
|
||||
| LITELLM_LICENSE | License key for LiteLLM usage
|
||||
| LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS | Set to `True` to use the local bundled Anthropic beta headers config only, disabling remote fetching. Default is `False`
|
||||
| LITELLM_OIDC_ALLOWED_CREDENTIAL_DIRS | Comma-separated list of absolute directories from which the `oidc/file/` provider is permitted to read token files. Defaults to `/var/run/secrets,/run/secrets`.
|
||||
| LITELLM_LOCAL_BLOG_POSTS | When set to `True`, uses the local bundled blog posts only, disabling remote fetching from GitHub. Default is `False`
|
||||
| LITELLM_LOCAL_MODEL_COST_MAP | Local configuration for model cost mapping in LiteLLM
|
||||
| LITELLM_LOCAL_POLICY_TEMPLATES | When set to "true", uses local backup policy templates instead of fetching from GitHub. Policy templates are fetched from https://raw.githubusercontent.com/BerriAI/litellm/main/policy_templates.json by default, with automatic fallback to local backup on failure
|
||||
|
||||
@@ -1,7 +1,28 @@
|
||||
from typing import Dict, Optional
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
|
||||
from litellm.types.utils import StandardCallbackDynamicParams
|
||||
|
||||
|
||||
def _is_env_reference(value: object) -> bool:
|
||||
return isinstance(value, str) and "os.environ/" in value
|
||||
|
||||
|
||||
def _raise_env_reference_error(param: str, *, source: str) -> None:
|
||||
raise ValueError(
|
||||
f"Callback param '{param}' (from {source}) contains an 'os.environ/' "
|
||||
"reference. Environment references in request-supplied parameters are "
|
||||
"no longer resolved server-side for security reasons.\n"
|
||||
"To resolve:\n"
|
||||
" 1. Remove the 'os.environ/' reference from your request body / "
|
||||
"metadata.\n"
|
||||
" 2. Either (a) configure this callback value in your proxy "
|
||||
"config.yaml under 'litellm_settings' / 'general_settings', or "
|
||||
"(b) pass the resolved secret value directly in the request.\n"
|
||||
"See https://docs.litellm.ai/docs/proxy/logging for server-side "
|
||||
"callback configuration."
|
||||
)
|
||||
|
||||
|
||||
# Hardcoded list of supported callback params to avoid runtime inspection issues with TypedDict
|
||||
_supported_callback_params = [
|
||||
"langfuse_public_key",
|
||||
@@ -46,12 +67,8 @@ def initialize_standard_callback_dynamic_params(
|
||||
for param in _supported_callback_params:
|
||||
if param in kwargs:
|
||||
_param_value = kwargs.get(param)
|
||||
if (
|
||||
_param_value is not None
|
||||
and isinstance(_param_value, str)
|
||||
and "os.environ/" in _param_value
|
||||
):
|
||||
_param_value = get_secret_str(secret_name=_param_value)
|
||||
if _is_env_reference(_param_value):
|
||||
_raise_env_reference_error(param, source="request body")
|
||||
standard_callback_dynamic_params[param] = _param_value # type: ignore
|
||||
|
||||
# 2. Fallback: check "metadata" or "litellm_params" -> "metadata"
|
||||
@@ -64,12 +81,8 @@ def initialize_standard_callback_dynamic_params(
|
||||
for param in _supported_callback_params:
|
||||
if param not in standard_callback_dynamic_params and param in metadata:
|
||||
_param_value = metadata.get(param)
|
||||
if (
|
||||
_param_value is not None
|
||||
and isinstance(_param_value, str)
|
||||
and "os.environ/" in _param_value
|
||||
):
|
||||
_param_value = get_secret_str(secret_name=_param_value)
|
||||
if _is_env_reference(_param_value):
|
||||
_raise_env_reference_error(param, source="metadata")
|
||||
standard_callback_dynamic_params[param] = _param_value # type: ignore
|
||||
|
||||
return standard_callback_dynamic_params
|
||||
|
||||
@@ -5,7 +5,7 @@ import os
|
||||
import time
|
||||
import traceback
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, Literal, Optional, Union, cast
|
||||
from typing import Any, Dict, Iterable, Literal, Optional, Union, cast
|
||||
|
||||
import fastapi
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
@@ -36,79 +36,43 @@ from litellm.proxy.health_check import (
|
||||
from litellm.proxy.middleware.in_flight_requests_middleware import (
|
||||
get_in_flight_requests,
|
||||
)
|
||||
from litellm.secret_managers.main import get_secret
|
||||
|
||||
#### Health ENDPOINTS ####
|
||||
|
||||
|
||||
def _resolve_os_environ_variables(params: dict) -> dict:
|
||||
def _reject_os_environ_references(params: dict) -> None:
|
||||
"""
|
||||
Resolve ``os.environ/`` environment variables in ``litellm_params``.
|
||||
|
||||
This walks the input dict/list structure iteratively (no Python recursion) to
|
||||
avoid unbounded recursion / stack overflows on deeply nested inputs.
|
||||
Validate that the provided params do not contain any ``os.environ/``
|
||||
references. Values with that prefix are expected to come only from
|
||||
server-side configuration (already resolved before reaching here). If a
|
||||
request-supplied value still carries the prefix, raise ``HTTPException``.
|
||||
"""
|
||||
if not isinstance(params, dict):
|
||||
return params
|
||||
return
|
||||
|
||||
# Use an explicit stack to avoid recursion and handle nested dicts/lists.
|
||||
# We also keep a `seen` set to guard against accidental cycles.
|
||||
resolved_root: dict = {}
|
||||
stack: list[tuple[object, object]] = [(params, resolved_root)]
|
||||
stack: list[object] = [params]
|
||||
seen: set[int] = {id(params)}
|
||||
|
||||
while stack:
|
||||
src, dst = stack.pop()
|
||||
src = stack.pop()
|
||||
if isinstance(src, dict):
|
||||
values: Iterable[object] = src.values()
|
||||
elif isinstance(src, list):
|
||||
values = src
|
||||
else:
|
||||
continue
|
||||
|
||||
if isinstance(src, dict) and isinstance(dst, dict):
|
||||
for key, value in src.items():
|
||||
# Direct string replacement for os.environ/ references
|
||||
if isinstance(value, str) and value.startswith("os.environ/"):
|
||||
dst[key] = get_secret(value)
|
||||
elif isinstance(value, dict):
|
||||
if id(value) in seen:
|
||||
# Cycle detected – keep a shallow copy reference to prevent infinite loops
|
||||
dst[key] = {}
|
||||
continue
|
||||
seen.add(id(value))
|
||||
new_dict: dict = {}
|
||||
dst[key] = new_dict
|
||||
stack.append((value, new_dict))
|
||||
elif isinstance(value, list):
|
||||
if id(value) in seen:
|
||||
dst[key] = []
|
||||
continue
|
||||
seen.add(id(value))
|
||||
new_list: list = []
|
||||
dst[key] = new_list
|
||||
stack.append((value, new_list))
|
||||
else:
|
||||
dst[key] = value
|
||||
|
||||
elif isinstance(src, list) and isinstance(dst, list):
|
||||
for item in src:
|
||||
if isinstance(item, str) and item.startswith("os.environ/"):
|
||||
dst.append(get_secret(item))
|
||||
elif isinstance(item, dict):
|
||||
if id(item) in seen:
|
||||
dst.append({})
|
||||
continue
|
||||
seen.add(id(item))
|
||||
new_dict = {}
|
||||
dst.append(new_dict)
|
||||
stack.append((item, new_dict))
|
||||
elif isinstance(item, list):
|
||||
if id(item) in seen:
|
||||
dst.append([])
|
||||
continue
|
||||
seen.add(id(item))
|
||||
new_list = []
|
||||
dst.append(new_list)
|
||||
stack.append((item, new_list))
|
||||
else:
|
||||
dst.append(item)
|
||||
|
||||
return resolved_root
|
||||
for value in values:
|
||||
if isinstance(value, str) and value.startswith("os.environ/"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": "Environment variable references are not permitted in request parameters."
|
||||
},
|
||||
)
|
||||
if isinstance(value, (dict, list)) and id(value) not in seen:
|
||||
seen.add(id(value))
|
||||
stack.append(value)
|
||||
|
||||
|
||||
def get_callback_identifier(callback):
|
||||
@@ -1510,6 +1474,10 @@ async def test_model_connection(
|
||||
|
||||
# Get model name from litellm_params
|
||||
request_litellm_params = litellm_params or {}
|
||||
# Reject request-supplied os.environ/ references. Config values are
|
||||
# already resolved before reaching this endpoint; any remaining
|
||||
# reference must have come from the request body.
|
||||
_reject_os_environ_references(request_litellm_params)
|
||||
model_name = request_litellm_params.get("model")
|
||||
|
||||
# Look up model configuration from router if model name is provided
|
||||
@@ -1546,11 +1514,7 @@ async def test_model_connection(
|
||||
|
||||
# Merge: config params (from proxy config) as base, request params override
|
||||
# This allows users to override specific params while using config for credentials
|
||||
merged_litellm_params = {**config_litellm_params, **request_litellm_params}
|
||||
|
||||
# Resolve os.environ/ environment variables in any remaining request params
|
||||
# This handles cases where user explicitly passes os.environ/ values to override config
|
||||
litellm_params = _resolve_os_environ_variables(merged_litellm_params)
|
||||
litellm_params = {**config_litellm_params, **request_litellm_params}
|
||||
|
||||
## Auth check
|
||||
await ModelManagementAuthChecks.can_user_make_model_call(
|
||||
|
||||
@@ -1131,6 +1131,16 @@ async def _update_single_user_helper(
|
||||
if prisma_client is None:
|
||||
raise Exception("Not connected to DB!")
|
||||
|
||||
# Only proxy admins can modify user_role
|
||||
if (
|
||||
user_request.user_role is not None
|
||||
and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Only proxy admins can modify user roles.",
|
||||
)
|
||||
|
||||
# Validate user identifier
|
||||
if not user_request.user_id and not user_request.user_email:
|
||||
raise ValueError("Either user_id or user_email must be provided")
|
||||
@@ -1508,12 +1518,35 @@ async def bulk_user_update(
|
||||
detail={"error": "Database not connected"},
|
||||
)
|
||||
|
||||
# Only proxy admins can modify user_role in bulk updates
|
||||
_bulk_role = (
|
||||
getattr(data.user_updates, "user_role", None) if data.user_updates else None
|
||||
)
|
||||
if _bulk_role is None and data.users:
|
||||
_bulk_role = next(
|
||||
(u.user_role for u in data.users if u.user_role is not None), None
|
||||
)
|
||||
if (
|
||||
_bulk_role is not None
|
||||
and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Only proxy admins can modify user roles.",
|
||||
)
|
||||
|
||||
# Determine the list of users to update
|
||||
users_to_update: Union[
|
||||
List[UpdateUserRequest], List[UpdateUserRequestNoUserIDorEmail]
|
||||
] = []
|
||||
|
||||
if data.all_users and data.user_updates:
|
||||
# Only proxy admins can update all users at once
|
||||
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Only proxy admins can update all users at once.",
|
||||
)
|
||||
# Optimized path for updating all users directly in database
|
||||
all_users_in_db = await prisma_client.db.litellm_usertable.find_many(
|
||||
order={"created_at": "desc"}
|
||||
|
||||
@@ -768,9 +768,9 @@ async def _common_key_generation_helper( # noqa: PLR0915
|
||||
request_type="key", **data_json, table_name="key"
|
||||
)
|
||||
|
||||
response["soft_budget"] = (
|
||||
data.soft_budget
|
||||
) # include the user-input soft budget in the response
|
||||
response[
|
||||
"soft_budget"
|
||||
] = data.soft_budget # include the user-input soft budget in the response
|
||||
|
||||
response = GenerateKeyResponse(**response)
|
||||
|
||||
@@ -1961,8 +1961,13 @@ async def _validate_update_key_data(
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
)
|
||||
|
||||
# Admin-only: only proxy admins, team admins, or org admins can modify max_budget
|
||||
if data.max_budget is not None and data.max_budget != existing_key_row.max_budget:
|
||||
# Admin-only: only proxy admins, team admins, or org admins can modify max_budget or spend
|
||||
if (
|
||||
data.max_budget is not None and data.max_budget != existing_key_row.max_budget
|
||||
) or (
|
||||
data.spend is not None
|
||||
and data.spend != getattr(existing_key_row, "spend", None)
|
||||
):
|
||||
if prisma_client is not None:
|
||||
hashed_key = existing_key_row.token
|
||||
await _check_key_admin_access(
|
||||
@@ -1970,7 +1975,7 @@ async def _validate_update_key_data(
|
||||
hashed_token=hashed_key,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
route="/key/update (max_budget)",
|
||||
route="/key/update (max_budget/spend)",
|
||||
)
|
||||
|
||||
# Check team limits if key has a team_id (from request or existing key)
|
||||
@@ -3272,10 +3277,10 @@ async def delete_verification_tokens(
|
||||
try:
|
||||
if prisma_client:
|
||||
tokens = [_hash_token_if_needed(token=key) for key in tokens]
|
||||
_keys_being_deleted: List[LiteLLM_VerificationToken] = (
|
||||
await prisma_client.db.litellm_verificationtoken.find_many(
|
||||
where={"token": {"in": tokens}}
|
||||
)
|
||||
_keys_being_deleted: List[
|
||||
LiteLLM_VerificationToken
|
||||
] = await prisma_client.db.litellm_verificationtoken.find_many(
|
||||
where={"token": {"in": tokens}}
|
||||
)
|
||||
|
||||
if len(_keys_being_deleted) == 0:
|
||||
@@ -3475,9 +3480,9 @@ async def _rotate_master_key( # noqa: PLR0915
|
||||
from litellm.proxy.proxy_server import proxy_config
|
||||
|
||||
try:
|
||||
models: Optional[List] = (
|
||||
await prisma_client.db.litellm_proxymodeltable.find_many()
|
||||
)
|
||||
models: Optional[
|
||||
List
|
||||
] = await prisma_client.db.litellm_proxymodeltable.find_many()
|
||||
except Exception:
|
||||
models = None
|
||||
# 2. process model table
|
||||
@@ -4117,11 +4122,11 @@ async def validate_key_list_check(
|
||||
param="user_id",
|
||||
code=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
complete_user_info_db_obj: Optional[BaseModel] = (
|
||||
await prisma_client.db.litellm_usertable.find_unique(
|
||||
where={"user_id": user_api_key_dict.user_id},
|
||||
include={"organization_memberships": True},
|
||||
)
|
||||
complete_user_info_db_obj: Optional[
|
||||
BaseModel
|
||||
] = await prisma_client.db.litellm_usertable.find_unique(
|
||||
where={"user_id": user_api_key_dict.user_id},
|
||||
include={"organization_memberships": True},
|
||||
)
|
||||
|
||||
if complete_user_info_db_obj is None:
|
||||
@@ -4204,10 +4209,10 @@ async def _fetch_user_team_objects(
|
||||
if complete_user_info is None or not complete_user_info.teams:
|
||||
return []
|
||||
|
||||
teams: Optional[List[BaseModel]] = (
|
||||
await prisma_client.db.litellm_teamtable.find_many(
|
||||
where={"team_id": {"in": complete_user_info.teams}}
|
||||
)
|
||||
teams: Optional[
|
||||
List[BaseModel]
|
||||
] = await prisma_client.db.litellm_teamtable.find_many(
|
||||
where={"team_id": {"in": complete_user_info.teams}}
|
||||
)
|
||||
if teams is None:
|
||||
return []
|
||||
|
||||
@@ -2297,10 +2297,13 @@ async def view_spend_logs( # noqa: PLR0915
|
||||
}
|
||||
|
||||
if api_key is not None and isinstance(api_key, str):
|
||||
filter_query["api_key"] = api_key # type: ignore
|
||||
elif request_id is not None and isinstance(request_id, str):
|
||||
if api_key.startswith("sk-"):
|
||||
filter_query["api_key"] = prisma_client.hash_token(token=api_key) # type: ignore
|
||||
else:
|
||||
filter_query["api_key"] = api_key # type: ignore
|
||||
if request_id is not None and isinstance(request_id, str):
|
||||
filter_query["request_id"] = request_id # type: ignore
|
||||
elif user_id is not None and isinstance(user_id, str):
|
||||
if user_id is not None and isinstance(user_id, str):
|
||||
filter_query["user"] = user_id # type: ignore
|
||||
|
||||
# Check if user wants unsummarized data
|
||||
@@ -2375,49 +2378,30 @@ async def view_spend_logs( # noqa: PLR0915
|
||||
|
||||
return response
|
||||
|
||||
elif api_key is not None and isinstance(api_key, str):
|
||||
if api_key.startswith("sk-"):
|
||||
hashed_token = prisma_client.hash_token(token=api_key)
|
||||
else:
|
||||
hashed_token = api_key
|
||||
spend_log = await prisma_client.get_data(
|
||||
table_name="spend",
|
||||
query_type="find_all",
|
||||
key_val={"key": "api_key", "value": hashed_token},
|
||||
)
|
||||
if spend_log is None:
|
||||
return []
|
||||
if isinstance(spend_log, list):
|
||||
return spend_log
|
||||
else:
|
||||
return [spend_log]
|
||||
elif request_id is not None:
|
||||
spend_log = await prisma_client.get_data(
|
||||
table_name="spend",
|
||||
query_type="find_unique",
|
||||
key_val={"key": "request_id", "value": request_id},
|
||||
)
|
||||
if spend_log is None:
|
||||
return []
|
||||
return [spend_log]
|
||||
elif user_id is not None:
|
||||
spend_log = await prisma_client.get_data(
|
||||
table_name="spend",
|
||||
query_type="find_all",
|
||||
key_val={"key": "user", "value": user_id},
|
||||
)
|
||||
if spend_log is None:
|
||||
return []
|
||||
if isinstance(spend_log, list):
|
||||
return spend_log
|
||||
else:
|
||||
return [spend_log]
|
||||
else:
|
||||
spend_logs = await prisma_client.get_data(
|
||||
table_name="spend", query_type="find_all"
|
||||
)
|
||||
scoped_filter: Dict[str, Any] = {}
|
||||
if api_key is not None and isinstance(api_key, str):
|
||||
if api_key.startswith("sk-"):
|
||||
hashed_token = prisma_client.hash_token(token=api_key)
|
||||
else:
|
||||
hashed_token = api_key
|
||||
scoped_filter["api_key"] = hashed_token
|
||||
if request_id is not None and isinstance(request_id, str):
|
||||
scoped_filter["request_id"] = request_id
|
||||
if user_id is not None and isinstance(user_id, str):
|
||||
scoped_filter["user"] = user_id
|
||||
|
||||
return spend_logs
|
||||
if not scoped_filter:
|
||||
spend_logs = await prisma_client.get_data(
|
||||
table_name="spend", query_type="find_all"
|
||||
)
|
||||
return spend_logs
|
||||
|
||||
data = await prisma_client.db.litellm_spendlogs.find_many(
|
||||
where=scoped_filter, # type: ignore
|
||||
order={"startTime": "desc"},
|
||||
)
|
||||
return data
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -16,6 +16,52 @@ from litellm.secret_managers.secret_manager_handler import get_secret_from_manag
|
||||
|
||||
oidc_cache = DualCache()
|
||||
|
||||
_DEFAULT_OIDC_ALLOWED_CREDENTIAL_DIRS = ("/var/run/secrets", "/run/secrets")
|
||||
|
||||
|
||||
def _get_oidc_allowed_credential_dirs() -> list[str]:
|
||||
"""
|
||||
Return the absolute, normalized list of directories from which
|
||||
``oidc/file/`` is permitted to read token files.
|
||||
|
||||
Defaults to standard container credential mount points. Operators can
|
||||
override via the ``LITELLM_OIDC_ALLOWED_CREDENTIAL_DIRS`` environment
|
||||
variable (comma-separated list of absolute paths).
|
||||
"""
|
||||
override = os.getenv("LITELLM_OIDC_ALLOWED_CREDENTIAL_DIRS")
|
||||
raw_dirs = (
|
||||
[d.strip() for d in override.split(",") if d.strip()]
|
||||
if override
|
||||
else list(_DEFAULT_OIDC_ALLOWED_CREDENTIAL_DIRS)
|
||||
)
|
||||
return [os.path.realpath(d) for d in raw_dirs]
|
||||
|
||||
|
||||
def _resolve_oidc_file_path(requested_path: str) -> str:
|
||||
"""
|
||||
Resolve ``requested_path`` and verify it falls within one of the allowed
|
||||
credential directories. Raises ``ValueError`` otherwise.
|
||||
"""
|
||||
if not os.path.isabs(requested_path):
|
||||
raise ValueError(
|
||||
"oidc/file path must be absolute. Use the format "
|
||||
"'oidc/file//var/run/secrets/<name>' (note the leading slash "
|
||||
"after 'oidc/file/')."
|
||||
)
|
||||
resolved = os.path.realpath(requested_path)
|
||||
for allowed in _get_oidc_allowed_credential_dirs():
|
||||
try:
|
||||
if os.path.commonpath([resolved, allowed]) == allowed:
|
||||
return resolved
|
||||
except ValueError:
|
||||
# commonpath raises when paths are on different drives (Windows);
|
||||
# treat as not-matching and continue.
|
||||
continue
|
||||
raise ValueError(
|
||||
"oidc/file path is outside the allowed credential directories. "
|
||||
"Set LITELLM_OIDC_ALLOWED_CREDENTIAL_DIRS to extend the allowlist."
|
||||
)
|
||||
|
||||
|
||||
def _get_oidc_http_handler(timeout: Optional[httpx.Timeout] = None) -> HTTPHandler:
|
||||
"""
|
||||
@@ -196,8 +242,9 @@ def get_secret( # noqa: PLR0915
|
||||
oidc_token = f.read()
|
||||
return oidc_token
|
||||
elif oidc_provider == "file":
|
||||
# Load token from a file
|
||||
with open(oidc_aud, "r") as f:
|
||||
# Load token from a file within an allowed credential directory.
|
||||
safe_path = _resolve_oidc_file_path(oidc_aud)
|
||||
with open(safe_path, "r") as f:
|
||||
oidc_token = f.read()
|
||||
return oidc_token
|
||||
elif oidc_provider == "env":
|
||||
|
||||
@@ -183,13 +183,14 @@ def test_oidc_env_variable():
|
||||
del os.environ[env_var_name]
|
||||
|
||||
|
||||
def test_oidc_file():
|
||||
# Create a temporary file
|
||||
with tempfile.NamedTemporaryFile(mode="w+") as temp_file:
|
||||
def test_oidc_file(monkeypatch):
|
||||
# Create a temporary file inside a directory added to the allowlist.
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
monkeypatch.setenv("LITELLM_OIDC_ALLOWED_CREDENTIAL_DIRS", temp_dir)
|
||||
temp_file_path = os.path.join(temp_dir, "token.txt")
|
||||
secret_value = "secret-" + uuid4().hex
|
||||
temp_file.write(secret_value)
|
||||
temp_file.flush()
|
||||
temp_file_path = temp_file.name
|
||||
with open(temp_file_path, "w") as temp_file:
|
||||
temp_file.write(secret_value)
|
||||
|
||||
secret_val = get_secret(f"oidc/file/{temp_file_path}")
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.abspath("../../.."))
|
||||
|
||||
from litellm.litellm_core_utils.initialize_dynamic_callback_params import (
|
||||
initialize_standard_callback_dynamic_params,
|
||||
)
|
||||
|
||||
|
||||
def test_resolves_plain_values_at_top_level():
|
||||
kwargs = {
|
||||
"langfuse_public_key": "pk-test",
|
||||
"langfuse_secret_key": "sk-test",
|
||||
}
|
||||
|
||||
params = initialize_standard_callback_dynamic_params(kwargs)
|
||||
|
||||
assert params.get("langfuse_public_key") == "pk-test"
|
||||
assert params.get("langfuse_secret_key") == "sk-test"
|
||||
|
||||
|
||||
def test_resolves_plain_values_from_metadata():
|
||||
kwargs = {
|
||||
"metadata": {
|
||||
"langfuse_public_key": "pk-meta",
|
||||
"langfuse_host": "https://test.langfuse.com",
|
||||
}
|
||||
}
|
||||
|
||||
params = initialize_standard_callback_dynamic_params(kwargs)
|
||||
|
||||
assert params.get("langfuse_public_key") == "pk-meta"
|
||||
assert params.get("langfuse_host") == "https://test.langfuse.com"
|
||||
|
||||
|
||||
def test_env_reference_at_top_level_raises_with_guidance():
|
||||
kwargs = {"langfuse_public_key": "os.environ/LANGFUSE_PUBLIC_KEY"}
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
initialize_standard_callback_dynamic_params(kwargs)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "langfuse_public_key" in message
|
||||
assert "request body" in message
|
||||
assert "os.environ/" in message
|
||||
assert "config.yaml" in message
|
||||
|
||||
|
||||
def test_env_reference_in_metadata_raises_with_guidance():
|
||||
kwargs = {
|
||||
"metadata": {
|
||||
"langsmith_api_key": "os.environ/LANGSMITH_API_KEY",
|
||||
}
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
initialize_standard_callback_dynamic_params(kwargs)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "langsmith_api_key" in message
|
||||
assert "metadata" in message
|
||||
|
||||
|
||||
def test_env_reference_in_litellm_params_metadata_raises():
|
||||
kwargs = {
|
||||
"litellm_params": {
|
||||
"metadata": {
|
||||
"gcs_bucket_name": "os.environ/GCS_BUCKET",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
initialize_standard_callback_dynamic_params(kwargs)
|
||||
|
||||
assert "gcs_bucket_name" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_non_string_values_are_not_flagged():
|
||||
kwargs = {
|
||||
"langsmith_sampling_rate": 0.5,
|
||||
"turn_off_message_logging": True,
|
||||
}
|
||||
|
||||
params = initialize_standard_callback_dynamic_params(kwargs)
|
||||
|
||||
assert params.get("langsmith_sampling_rate") == 0.5
|
||||
assert params.get("turn_off_message_logging") is True
|
||||
|
||||
|
||||
def test_empty_kwargs_returns_empty_params():
|
||||
params = initialize_standard_callback_dynamic_params(None)
|
||||
assert dict(params) == {}
|
||||
|
||||
params = initialize_standard_callback_dynamic_params({})
|
||||
assert dict(params) == {}
|
||||
@@ -450,10 +450,10 @@ async def test_test_model_connection_loads_config_from_router():
|
||||
params["messages"] = [{"role": "user", "content": "test"}]
|
||||
return params
|
||||
|
||||
# Mock _resolve_os_environ_variables
|
||||
def mock_resolve_os_environ(params):
|
||||
return params
|
||||
|
||||
# Mock _reject_os_environ_references
|
||||
def mock_reject_os_environ(params):
|
||||
return None
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.proxy_server.prisma_client",
|
||||
mock_prisma_client,
|
||||
@@ -476,8 +476,8 @@ async def test_test_model_connection_loads_config_from_router():
|
||||
"litellm.proxy.health_endpoints._health_endpoints._update_litellm_params_for_health_check",
|
||||
mock_update_params,
|
||||
), patch(
|
||||
"litellm.proxy.health_endpoints._health_endpoints._resolve_os_environ_variables",
|
||||
mock_resolve_os_environ,
|
||||
"litellm.proxy.health_endpoints._health_endpoints._reject_os_environ_references",
|
||||
mock_reject_os_environ,
|
||||
):
|
||||
# Call the endpoint with only model name (no credentials)
|
||||
result = await health_test_model_connection(
|
||||
|
||||
@@ -2761,3 +2761,176 @@ async def test_ui_view_spend_logs_team_member_no_permission_blocked(
|
||||
assert response.status_code == 403
|
||||
finally:
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
|
||||
class _CaptureFilterDB:
|
||||
"""Mock DB that records the `where` filter passed to find_many."""
|
||||
|
||||
def __init__(self):
|
||||
self.litellm_spendlogs = self
|
||||
self.captured_where = None
|
||||
|
||||
async def find_many(self, *args, **kwargs):
|
||||
self.captured_where = kwargs.get("where")
|
||||
return []
|
||||
|
||||
async def group_by(self, *args, **kwargs):
|
||||
self.captured_where = kwargs.get("where")
|
||||
return []
|
||||
|
||||
|
||||
class _CapturePrismaClient:
|
||||
def __init__(self):
|
||||
self.db = _CaptureFilterDB()
|
||||
|
||||
def hash_token(self, token):
|
||||
return "hashed::" + token
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_view_spend_logs_internal_user_combines_user_with_api_key(
|
||||
client, monkeypatch
|
||||
):
|
||||
"""Internal users must have their user filter applied alongside api_key."""
|
||||
mock_client = _CapturePrismaClient()
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_client)
|
||||
|
||||
start_date = "2024-01-01"
|
||||
end_date = "2024-12-31"
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.INTERNAL_USER,
|
||||
user_id="internal-user-1",
|
||||
)
|
||||
try:
|
||||
response = client.get(
|
||||
"/spend/logs",
|
||||
params={
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"summarize": "false",
|
||||
"api_key": "sk-some-raw-token",
|
||||
},
|
||||
headers={"Authorization": "Bearer sk-test"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
where = mock_client.db.captured_where
|
||||
assert where is not None
|
||||
assert where["user"] == "internal-user-1"
|
||||
assert where["api_key"] == "hashed::sk-some-raw-token"
|
||||
assert "startTime" in where
|
||||
finally:
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_view_spend_logs_internal_user_combines_user_with_request_id(
|
||||
client, monkeypatch
|
||||
):
|
||||
"""Internal users must have their user filter applied alongside request_id."""
|
||||
mock_client = _CapturePrismaClient()
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_client)
|
||||
|
||||
start_date = "2024-01-01"
|
||||
end_date = "2024-12-31"
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.INTERNAL_USER,
|
||||
user_id="internal-user-2",
|
||||
)
|
||||
try:
|
||||
response = client.get(
|
||||
"/spend/logs",
|
||||
params={
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"summarize": "false",
|
||||
"request_id": "req-abc",
|
||||
},
|
||||
headers={"Authorization": "Bearer sk-test"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
where = mock_client.db.captured_where
|
||||
assert where is not None
|
||||
assert where["user"] == "internal-user-2"
|
||||
assert where["request_id"] == "req-abc"
|
||||
finally:
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_view_spend_logs_non_date_range_combines_user_with_request_id(
|
||||
client, monkeypatch
|
||||
):
|
||||
"""Non-date-range path must also combine user + request_id filters."""
|
||||
mock_client = _CapturePrismaClient()
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_client)
|
||||
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.INTERNAL_USER,
|
||||
user_id="internal-user-3",
|
||||
)
|
||||
try:
|
||||
response = client.get(
|
||||
"/spend/logs",
|
||||
params={"request_id": "req-xyz"},
|
||||
headers={"Authorization": "Bearer sk-test"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
where = mock_client.db.captured_where
|
||||
assert where is not None
|
||||
assert where["user"] == "internal-user-3"
|
||||
assert where["request_id"] == "req-xyz"
|
||||
finally:
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_view_spend_logs_non_date_range_hashes_sk_api_key(client, monkeypatch):
|
||||
"""Non-date-range path must hash sk- prefixed api_keys before filtering."""
|
||||
mock_client = _CapturePrismaClient()
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_client)
|
||||
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN,
|
||||
)
|
||||
try:
|
||||
response = client.get(
|
||||
"/spend/logs",
|
||||
params={"api_key": "sk-raw-admin-token"},
|
||||
headers={"Authorization": "Bearer sk-test"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
where = mock_client.db.captured_where
|
||||
assert where is not None
|
||||
assert where["api_key"] == "hashed::sk-raw-admin-token"
|
||||
finally:
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_view_spend_logs_date_range_hashes_sk_api_key(client, monkeypatch):
|
||||
"""Date-range path must hash sk- prefixed api_keys before filtering."""
|
||||
mock_client = _CapturePrismaClient()
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_client)
|
||||
|
||||
start_date = "2024-01-01"
|
||||
end_date = "2024-12-31"
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN,
|
||||
)
|
||||
try:
|
||||
response = client.get(
|
||||
"/spend/logs",
|
||||
params={
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"summarize": "false",
|
||||
"api_key": "sk-raw-admin-token",
|
||||
},
|
||||
headers={"Authorization": "Bearer sk-test"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
where = mock_client.db.captured_where
|
||||
assert where is not None
|
||||
assert where["api_key"] == "hashed::sk-raw-admin-token"
|
||||
finally:
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
@@ -199,9 +199,10 @@ def test_oidc_azure_ad_token_success(mock_get_azure_ad_token_provider, monkeypat
|
||||
mock_token_provider.assert_called_once_with()
|
||||
|
||||
|
||||
def test_oidc_file_success(tmp_path):
|
||||
def test_oidc_file_success(tmp_path, monkeypatch):
|
||||
token_file = tmp_path / "token.txt"
|
||||
token_file.write_text("file_token")
|
||||
monkeypatch.setenv("LITELLM_OIDC_ALLOWED_CREDENTIAL_DIRS", str(tmp_path))
|
||||
|
||||
secret_name = f"oidc/file/{token_file}"
|
||||
result = get_secret(secret_name)
|
||||
@@ -209,6 +210,24 @@ def test_oidc_file_success(tmp_path):
|
||||
assert result == "file_token"
|
||||
|
||||
|
||||
def test_oidc_file_rejects_path_outside_allowlist(tmp_path, monkeypatch):
|
||||
outside_file = tmp_path / "outside.txt"
|
||||
outside_file.write_text("should_not_read")
|
||||
# Allowlist a different directory.
|
||||
allowed_dir = tmp_path / "allowed"
|
||||
allowed_dir.mkdir()
|
||||
monkeypatch.setenv("LITELLM_OIDC_ALLOWED_CREDENTIAL_DIRS", str(allowed_dir))
|
||||
|
||||
with pytest.raises(ValueError, match="outside the allowed credential directories"):
|
||||
get_secret(f"oidc/file/{outside_file}")
|
||||
|
||||
|
||||
def test_oidc_file_rejects_relative_path(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("LITELLM_OIDC_ALLOWED_CREDENTIAL_DIRS", str(tmp_path))
|
||||
with pytest.raises(ValueError, match="must be absolute"):
|
||||
get_secret("oidc/file/relative/path/token")
|
||||
|
||||
|
||||
def test_oidc_env_success(mock_env):
|
||||
mock_env["CUSTOM_TOKEN"] = "env_token"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user