mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_/migrate-page-memory-9b2c09
This commit is contained in:
commit
caac7d8aa8
17 changed files with 1018 additions and 107 deletions
|
|
@ -211,6 +211,9 @@ filter_invalid_headers: Optional[bool] = False
|
|||
add_user_information_to_llm_headers: Optional[bool] = (
|
||||
None # adds user_id, team_id, token hash (params from StandardLoggingMetadata) to request headers
|
||||
)
|
||||
overwrite_user_with_key_hash: bool = (
|
||||
False # force the outgoing `user` param to the hashed api key, so providers see a stable, tamper-proof id
|
||||
)
|
||||
store_audit_logs = False # Enterprise feature, allow users to see audit logs
|
||||
skip_system_message_in_guardrail: bool = False
|
||||
skip_tool_message_in_guardrail: bool = False
|
||||
|
|
|
|||
|
|
@ -2616,6 +2616,17 @@ class UserAPIKeyAuth(LiteLLM_VerificationTokenView): # the expected response ob
|
|||
# key off. Server-only and stripped from validated input for the same reason as the marker
|
||||
# above: a forged entry would let a caller pick which team's rpm bucket it is charged against.
|
||||
mcp_source_team_rpm_limits: dict[str, dict[str, int]] | None = Field(default=None, exclude=True)
|
||||
via_virtual_key: bool = Field(
|
||||
default=False,
|
||||
exclude=True,
|
||||
description=(
|
||||
"Server-only marker set exclusively by the DB virtual-key and master-key auth paths via "
|
||||
"post-construction assignment. Stripped from validated input so custom auth handlers, JWT "
|
||||
"claims, or key metadata cannot forge it. Gates overwrite_user_with_key_hash stamping: only "
|
||||
"a credential the proxy itself validated as a key may be forwarded as the provider-facing "
|
||||
"user id."
|
||||
),
|
||||
)
|
||||
budget_reservation: Optional[Dict[str, Any]] = Field(default=None, exclude=True)
|
||||
budget_throttle_pct: Optional[float] = Field(default=None, exclude=True)
|
||||
user: Optional[Any] = None # Expanded user object when expand=user is used
|
||||
|
|
@ -2641,6 +2652,7 @@ class UserAPIKeyAuth(LiteLLM_VerificationTokenView): # the expected response ob
|
|||
# kwargs, model_validate, a JWT/key claim splat) so it can never be forged from caller data.
|
||||
values.pop("mcp_admitted_user_subject", None)
|
||||
values.pop("mcp_source_team_rpm_limits", None)
|
||||
values.pop("via_virtual_key", None)
|
||||
if values.get("api_key") is not None:
|
||||
values.update({"token": cls._safe_hash_litellm_api_key(values.get("api_key"))})
|
||||
if isinstance(values.get("api_key"), str):
|
||||
|
|
|
|||
|
|
@ -1497,6 +1497,13 @@ async def _user_api_key_auth_builder(
|
|||
check_cache_only=True,
|
||||
).resolve(hashed_token=hash_token(api_key))
|
||||
)
|
||||
# Key-cache entries are written only after the proxy validated a
|
||||
# virtual key or the master key, but via_virtual_key is exclude=True
|
||||
# so serialization drops it; restore it at this trusted boundary.
|
||||
# The UI-login JWT fallback below constructs its token from a
|
||||
# decrypted blob, not this cache, and stays unmarked.
|
||||
if isinstance(valid_token, UserAPIKeyAuth):
|
||||
valid_token.via_virtual_key = True
|
||||
except Exception:
|
||||
verbose_logger.debug("api key not found in cache.")
|
||||
valid_token = None
|
||||
|
|
@ -1614,6 +1621,7 @@ async def _user_api_key_auth_builder(
|
|||
_user_api_key_obj = update_valid_token_with_end_user_params(
|
||||
valid_token=_user_api_key_obj, end_user_params=end_user_params
|
||||
)
|
||||
_user_api_key_obj.via_virtual_key = True
|
||||
|
||||
return _user_api_key_obj
|
||||
|
||||
|
|
@ -2021,7 +2029,7 @@ async def _user_api_key_auth_builder(
|
|||
# No token was found when looking up in the DB
|
||||
raise Exception("Invalid proxy server token passed")
|
||||
if valid_token_dict is not None:
|
||||
return await _return_user_api_key_auth_obj(
|
||||
virtual_key_auth_obj = await _return_user_api_key_auth_obj(
|
||||
user_obj=user_obj,
|
||||
api_key=api_key,
|
||||
parent_otel_span=parent_otel_span,
|
||||
|
|
@ -2029,6 +2037,8 @@ async def _user_api_key_auth_builder(
|
|||
route=route,
|
||||
start_time=start_time,
|
||||
)
|
||||
virtual_key_auth_obj.via_virtual_key = True
|
||||
return virtual_key_auth_obj
|
||||
except Exception as e:
|
||||
return await UserAPIKeyAuthExceptionHandler._handle_authentication_error(
|
||||
e=e,
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ from starlette.datastructures import Headers
|
|||
import litellm
|
||||
from litellm._logging import verbose_logger, verbose_proxy_logger
|
||||
from litellm._service_logger import ServiceLogging
|
||||
from litellm.constants import PRE_CALL_EXECUTED_GUARDRAILS_KEY
|
||||
from litellm.constants import LITELLM_PROXY_MASTER_KEY_ALIAS, PRE_CALL_EXECUTED_GUARDRAILS_KEY
|
||||
from litellm.litellm_core_utils.credential_accessor import CredentialAccessor
|
||||
from litellm.litellm_core_utils.initialize_dynamic_callback_params import (
|
||||
iter_client_callback_metadata_dicts,
|
||||
|
|
@ -48,6 +48,24 @@ _EXPLICIT_SESSION_HEADERS = frozenset({"x-litellm-trace-id", "x-litellm-session-
|
|||
# Session-id values must be non-empty strings of alphanumerics, hyphens, or underscores
|
||||
# (covers UUIDs and most common session-id formats).
|
||||
_SESSION_ID_VALUE_RE = re.compile(r"^[a-zA-Z0-9_\-]{8,}$")
|
||||
|
||||
_SHA256_HEX_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
def _stampable_key_hash(user_api_key_dict: UserAPIKeyAuth) -> str | None:
|
||||
"""Only proxy-validated keys are stamped, proven by the unforgeable
|
||||
via_virtual_key marker AND a known non-secret shape: the sha256 hex digest
|
||||
UserAPIKeyAuth stores virtual keys in, or the master key's stable alias.
|
||||
Custom-auth credentials arrive raw (never forward auth material) and hashed
|
||||
JWTs rotate on re-issue (useless as a stable ban id), so both are skipped."""
|
||||
api_key = user_api_key_dict.api_key
|
||||
if not user_api_key_dict.via_virtual_key or api_key is None:
|
||||
return None
|
||||
if api_key == LITELLM_PROXY_MASTER_KEY_ALIAS or _SHA256_HEX_RE.fullmatch(api_key):
|
||||
return api_key
|
||||
return None
|
||||
|
||||
|
||||
_ANTHROPIC_SESSION_ID_VALUE_RE = re.compile(r"^[a-zA-Z0-9_\-]+$")
|
||||
|
||||
|
||||
|
|
@ -1447,6 +1465,11 @@ async def add_litellm_data_to_request(
|
|||
if "user" not in data:
|
||||
data["user"] = user
|
||||
|
||||
if litellm.overwrite_user_with_key_hash is True:
|
||||
stampable_hash = _stampable_key_hash(user_api_key_dict)
|
||||
if stampable_hash is not None:
|
||||
data["user"] = stampable_hash
|
||||
|
||||
data["secret_fields"] = SecretFields(raw_headers=_raw_headers)
|
||||
|
||||
## Dynamic api version (Azure OpenAI endpoints) ##
|
||||
|
|
|
|||
|
|
@ -1695,13 +1695,10 @@ async def ui_view_spend_logs(
|
|||
code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
if start_date is None or end_date is None:
|
||||
raise ProxyException(
|
||||
message="Start date and end date are required",
|
||||
type="bad_request",
|
||||
param="None",
|
||||
code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
# Inline import — auth_utils participates in a proxy import cycle.
|
||||
from litellm.proxy.auth.auth_utils import get_request_route # noqa: PLC0415
|
||||
|
||||
is_v2 = "/spend/logs/v2" in get_request_route(request)
|
||||
|
||||
# Validate sort_by and sort_order
|
||||
valid_sort_fields = {
|
||||
|
|
@ -1729,36 +1726,50 @@ async def ui_view_spend_logs(
|
|||
)
|
||||
|
||||
try:
|
||||
# Inline import — auth_utils participates in a proxy import cycle.
|
||||
from litellm.proxy.auth.auth_utils import get_request_route # noqa: PLC0415
|
||||
is_admin_view = _is_admin_view_safe(user_api_key_dict=user_api_key_dict)
|
||||
is_request_id_lookup = request_id is not None and not is_v2
|
||||
|
||||
is_v2 = "/spend/logs/v2" in get_request_route(request)
|
||||
formats = ["%Y-%m-%d %H:%M:%S", "%Y-%m-%d"] if is_v2 else ["%Y-%m-%d %H:%M:%S"]
|
||||
if is_request_id_lookup:
|
||||
# request_id is the @id primary key: it identifies a single row, so a
|
||||
# time window is meaningless. The dashboard always sends a default 24h
|
||||
# window, which hid ids copied from an older page (LIT-3981). Drop the
|
||||
# window for the id lookup so it resolves across all time; every other
|
||||
# query, including the public v2 route, still requires one (below).
|
||||
start_date_obj: datetime | None = None
|
||||
end_date_obj: datetime | None = None
|
||||
else:
|
||||
if start_date is None or end_date is None:
|
||||
raise ProxyException(
|
||||
message="Start date and end date are required",
|
||||
type="bad_request",
|
||||
param="None",
|
||||
code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
formats = ["%Y-%m-%d %H:%M:%S", "%Y-%m-%d"] if is_v2 else ["%Y-%m-%d %H:%M:%S"]
|
||||
|
||||
def parse_date(date_str: str) -> datetime:
|
||||
date_str = date_str.strip()
|
||||
for fmt in formats:
|
||||
try:
|
||||
return datetime.strptime(date_str, fmt).replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
continue
|
||||
expected = "'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS'" if is_v2 else "'YYYY-MM-DD HH:MM:SS'"
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid date format: {date_str}. Expected: {expected}",
|
||||
)
|
||||
def parse_date(date_str: str) -> datetime:
|
||||
date_str = date_str.strip()
|
||||
for fmt in formats:
|
||||
try:
|
||||
return datetime.strptime(date_str, fmt).replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
continue
|
||||
expected = "'YYYY-MM-DD' or 'YYYY-MM-DD HH:MM:SS'" if is_v2 else "'YYYY-MM-DD HH:MM:SS'"
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid date format: {date_str}. Expected: {expected}",
|
||||
)
|
||||
|
||||
start_date_obj = parse_date(start_date)
|
||||
end_date_obj = parse_date(end_date)
|
||||
|
||||
# Convert to ISO format strings for Prisma
|
||||
start_date_iso = start_date_obj.isoformat() # Already in UTC, no need to add Z
|
||||
end_date_iso = end_date_obj.isoformat() # Already in UTC, no need to add Z
|
||||
start_date_obj = parse_date(start_date)
|
||||
end_date_obj = parse_date(end_date)
|
||||
|
||||
# Build where conditions
|
||||
where_conditions: dict[str, Any] = {
|
||||
"startTime": {"gte": start_date_iso, "lte": end_date_iso},
|
||||
}
|
||||
where_conditions: dict[str, Any] = {}
|
||||
if start_date_obj is not None and end_date_obj is not None:
|
||||
where_conditions["startTime"] = {
|
||||
"gte": start_date_obj.isoformat(), # Already in UTC, no need to add Z
|
||||
"lte": end_date_obj.isoformat(),
|
||||
}
|
||||
|
||||
if team_id is not None:
|
||||
where_conditions["team_id"] = team_id
|
||||
|
|
@ -1827,9 +1838,19 @@ async def ui_view_spend_logs(
|
|||
where_conditions["spend"]["gte"] = min_spend
|
||||
if max_spend is not None:
|
||||
where_conditions["spend"]["lte"] = max_spend
|
||||
is_admin_view = _is_admin_view_safe(user_api_key_dict=user_api_key_dict)
|
||||
# A request_id lookup drops the date window, so a non-admin could otherwise
|
||||
# reach any single row by id; require they own it, mirroring the detail
|
||||
# endpoint. That ownership check fully authorizes the one row, so the
|
||||
# general scoping below is skipped for id lookups. Scoped to the UI route
|
||||
# so the public v2 contract is unchanged.
|
||||
if request_id is not None and not is_v2 and not is_admin_view:
|
||||
await _assert_user_can_view_request_id(
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
request_id=request_id,
|
||||
)
|
||||
permitted_team_ids: List[str] | None = None
|
||||
if not is_admin_view:
|
||||
if not is_request_id_lookup and not is_admin_view:
|
||||
if team_id is not None:
|
||||
can_view_team = await _can_team_member_view_log(
|
||||
prisma_client=prisma_client,
|
||||
|
|
@ -1875,15 +1896,16 @@ async def ui_view_spend_logs(
|
|||
sql_params: List[Any] = []
|
||||
p = 1 # parameter index counter
|
||||
|
||||
# Date range (always present). Wrap the param side with
|
||||
# `AT TIME ZONE 'UTC'` so comparison against the plain `timestamp`
|
||||
# column does not depend on the DB session timezone (see #22529).
|
||||
sql_conditions.append(f"\"startTime\" >= (${p}::timestamptz AT TIME ZONE 'UTC')")
|
||||
sql_params.append(start_date_obj)
|
||||
p += 1
|
||||
sql_conditions.append(f"\"startTime\" <= (${p}::timestamptz AT TIME ZONE 'UTC')")
|
||||
sql_params.append(end_date_obj)
|
||||
p += 1
|
||||
# Date range. Wrap the param side with `AT TIME ZONE 'UTC'` so comparison
|
||||
# against the plain `timestamp` column does not depend on the DB session
|
||||
# timezone (see #22529). Absent for a request_id-only lookup (see above).
|
||||
if start_date_obj is not None and end_date_obj is not None:
|
||||
sql_conditions.append(f"\"startTime\" >= (${p}::timestamptz AT TIME ZONE 'UTC')")
|
||||
sql_params.append(start_date_obj)
|
||||
p += 1
|
||||
sql_conditions.append(f"\"startTime\" <= (${p}::timestamptz AT TIME ZONE 'UTC')")
|
||||
sql_params.append(end_date_obj)
|
||||
p += 1
|
||||
|
||||
# Equality filters - read effective values from where_conditions (post-authorization)
|
||||
for sql_col, wc_key in [
|
||||
|
|
|
|||
|
|
@ -1290,6 +1290,250 @@ async def test_scim_deactivated_user_key_is_rejected():
|
|||
setattr(_proxy_server_mod, attr, val)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cached_proxy_admin_key_sets_via_virtual_key_marker():
|
||||
"""Cached PROXY_ADMIN auth objects early-return before the marked DB and
|
||||
master-key returns, and cache serialization drops the exclude=True marker;
|
||||
the cache-hit boundary must restore it or cached admin traffic silently
|
||||
bypasses overwrite_user_with_key_hash stamping."""
|
||||
from fastapi import Request
|
||||
from starlette.datastructures import URL
|
||||
|
||||
from litellm.proxy.auth.user_api_key_auth import _user_api_key_auth_builder
|
||||
from litellm.proxy.proxy_server import hash_token
|
||||
|
||||
api_key = "sk-cached-admin-marker-test"
|
||||
hashed_key = hash_token(api_key)
|
||||
|
||||
cached_token = UserAPIKeyAuth(
|
||||
api_key=api_key,
|
||||
token=hashed_key,
|
||||
user_id="cached-admin-user",
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN,
|
||||
)
|
||||
assert cached_token.via_virtual_key is False
|
||||
|
||||
mock_cache = AsyncMock()
|
||||
mock_cache.async_get_cache = AsyncMock(return_value=None)
|
||||
mock_cache.delete_cache = MagicMock()
|
||||
|
||||
mock_proxy_logging_obj = MagicMock()
|
||||
mock_proxy_logging_obj.internal_usage_cache = MagicMock()
|
||||
mock_proxy_logging_obj.internal_usage_cache.dual_cache = AsyncMock()
|
||||
mock_proxy_logging_obj.internal_usage_cache.dual_cache.async_delete_cache = (
|
||||
AsyncMock()
|
||||
)
|
||||
mock_proxy_logging_obj.post_call_failure_hook = AsyncMock(return_value=None)
|
||||
|
||||
import litellm.proxy.proxy_server as _proxy_server_mod
|
||||
|
||||
_attrs_to_set = {
|
||||
"prisma_client": MagicMock(),
|
||||
"user_api_key_cache": mock_cache,
|
||||
"proxy_logging_obj": mock_proxy_logging_obj,
|
||||
"master_key": "sk-master-key",
|
||||
"general_settings": {},
|
||||
"llm_model_list": [],
|
||||
"llm_router": None,
|
||||
"open_telemetry_logger": None,
|
||||
"model_max_budget_limiter": MagicMock(),
|
||||
"user_custom_auth": None,
|
||||
"jwt_handler": None,
|
||||
"litellm_proxy_admin_name": "admin",
|
||||
}
|
||||
_original_values = {
|
||||
attr: getattr(_proxy_server_mod, attr, None) for attr in _attrs_to_set
|
||||
}
|
||||
try:
|
||||
for attr, val in _attrs_to_set.items():
|
||||
setattr(_proxy_server_mod, attr, val)
|
||||
|
||||
request = Request(scope={"type": "http"})
|
||||
request._url = URL(url="/chat/completions")
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.auth.resolvers.store.IdentityStore._resolve_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=cached_token,
|
||||
):
|
||||
result = await _user_api_key_auth_builder(
|
||||
request=request,
|
||||
api_key=f"Bearer {api_key}",
|
||||
azure_api_key_header="",
|
||||
anthropic_api_key_header=None,
|
||||
google_ai_studio_api_key_header=None,
|
||||
azure_apim_header=None,
|
||||
request_data={},
|
||||
)
|
||||
|
||||
assert isinstance(result, UserAPIKeyAuth)
|
||||
assert result.user_role == LitellmUserRoles.PROXY_ADMIN
|
||||
assert result.via_virtual_key is True
|
||||
assert result.api_key == hashed_key
|
||||
finally:
|
||||
for attr, val in _original_values.items():
|
||||
setattr(_proxy_server_mod, attr, val)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_master_key_auth_sets_via_virtual_key_marker():
|
||||
"""Master-key requests must also be stamped by overwrite_user_with_key_hash;
|
||||
the auth path substitutes the stable alias for api_key and must mark the
|
||||
result as proxy-validated."""
|
||||
from fastapi import Request
|
||||
from starlette.datastructures import URL
|
||||
|
||||
from litellm.constants import LITELLM_PROXY_MASTER_KEY_ALIAS
|
||||
from litellm.proxy.auth.user_api_key_auth import _user_api_key_auth_builder
|
||||
|
||||
master_key = "sk-master-key"
|
||||
|
||||
mock_cache = AsyncMock()
|
||||
mock_cache.async_get_cache = AsyncMock(return_value=None)
|
||||
mock_cache.delete_cache = MagicMock()
|
||||
|
||||
mock_proxy_logging_obj = MagicMock()
|
||||
mock_proxy_logging_obj.internal_usage_cache = MagicMock()
|
||||
mock_proxy_logging_obj.internal_usage_cache.dual_cache = AsyncMock()
|
||||
mock_proxy_logging_obj.internal_usage_cache.dual_cache.async_delete_cache = (
|
||||
AsyncMock()
|
||||
)
|
||||
mock_proxy_logging_obj.post_call_failure_hook = AsyncMock(return_value=None)
|
||||
|
||||
import litellm.proxy.proxy_server as _proxy_server_mod
|
||||
|
||||
_attrs_to_set = {
|
||||
"prisma_client": MagicMock(),
|
||||
"user_api_key_cache": mock_cache,
|
||||
"proxy_logging_obj": mock_proxy_logging_obj,
|
||||
"master_key": master_key,
|
||||
"general_settings": {},
|
||||
"llm_model_list": [],
|
||||
"llm_router": None,
|
||||
"open_telemetry_logger": None,
|
||||
"model_max_budget_limiter": MagicMock(),
|
||||
"user_custom_auth": None,
|
||||
"jwt_handler": None,
|
||||
"litellm_proxy_admin_name": "admin",
|
||||
}
|
||||
_original_values = {
|
||||
attr: getattr(_proxy_server_mod, attr, None) for attr in _attrs_to_set
|
||||
}
|
||||
try:
|
||||
for attr, val in _attrs_to_set.items():
|
||||
setattr(_proxy_server_mod, attr, val)
|
||||
|
||||
request = Request(scope={"type": "http"})
|
||||
request._url = URL(url="/chat/completions")
|
||||
|
||||
result = await _user_api_key_auth_builder(
|
||||
request=request,
|
||||
api_key=f"Bearer {master_key}",
|
||||
azure_api_key_header="",
|
||||
anthropic_api_key_header=None,
|
||||
google_ai_studio_api_key_header=None,
|
||||
azure_apim_header=None,
|
||||
request_data={},
|
||||
)
|
||||
|
||||
assert isinstance(result, UserAPIKeyAuth)
|
||||
assert result.via_virtual_key is True
|
||||
assert result.api_key == LITELLM_PROXY_MASTER_KEY_ALIAS
|
||||
finally:
|
||||
for attr, val in _original_values.items():
|
||||
setattr(_proxy_server_mod, attr, val)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_db_virtual_key_auth_sets_via_virtual_key_marker():
|
||||
"""via_virtual_key gates overwrite_user_with_key_hash stamping and is
|
||||
forge-stripped from validated input, so the DB auth path setting it by
|
||||
post-construction assignment is the only thing that turns stamping on."""
|
||||
from fastapi import Request
|
||||
from starlette.datastructures import URL
|
||||
|
||||
from litellm.proxy.auth.user_api_key_auth import _user_api_key_auth_builder
|
||||
from litellm.proxy.proxy_server import hash_token
|
||||
|
||||
api_key = "sk-via-virtual-key-marker-test"
|
||||
hashed_key = hash_token(api_key)
|
||||
|
||||
valid_token = UserAPIKeyAuth(
|
||||
api_key=api_key,
|
||||
token=hashed_key,
|
||||
user_id="marker-test-user",
|
||||
)
|
||||
|
||||
mock_cache = AsyncMock()
|
||||
mock_cache.async_get_cache = AsyncMock(return_value=None)
|
||||
mock_cache.delete_cache = MagicMock()
|
||||
|
||||
mock_proxy_logging_obj = MagicMock()
|
||||
mock_proxy_logging_obj.internal_usage_cache = MagicMock()
|
||||
mock_proxy_logging_obj.internal_usage_cache.dual_cache = AsyncMock()
|
||||
mock_proxy_logging_obj.internal_usage_cache.dual_cache.async_delete_cache = (
|
||||
AsyncMock()
|
||||
)
|
||||
mock_proxy_logging_obj.post_call_failure_hook = AsyncMock(return_value=None)
|
||||
|
||||
mock_prisma_client = MagicMock()
|
||||
|
||||
import litellm.proxy.proxy_server as _proxy_server_mod
|
||||
|
||||
_attrs_to_set = {
|
||||
"prisma_client": mock_prisma_client,
|
||||
"user_api_key_cache": mock_cache,
|
||||
"proxy_logging_obj": mock_proxy_logging_obj,
|
||||
"master_key": "sk-master-key",
|
||||
"general_settings": {},
|
||||
"llm_model_list": [],
|
||||
"llm_router": None,
|
||||
"open_telemetry_logger": None,
|
||||
"model_max_budget_limiter": MagicMock(),
|
||||
"user_custom_auth": None,
|
||||
"jwt_handler": None,
|
||||
"litellm_proxy_admin_name": "admin",
|
||||
}
|
||||
_original_values = {
|
||||
attr: getattr(_proxy_server_mod, attr, None) for attr in _attrs_to_set
|
||||
}
|
||||
try:
|
||||
for attr, val in _attrs_to_set.items():
|
||||
setattr(_proxy_server_mod, attr, val)
|
||||
|
||||
request = Request(scope={"type": "http"})
|
||||
request._url = URL(url="/chat/completions")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy.auth.resolvers.store.IdentityStore._resolve_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=valid_token,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.auth.user_api_key_auth.get_user_object",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
result = await _user_api_key_auth_builder(
|
||||
request=request,
|
||||
api_key=f"Bearer {api_key}",
|
||||
azure_api_key_header="",
|
||||
anthropic_api_key_header=None,
|
||||
google_ai_studio_api_key_header=None,
|
||||
azure_apim_header=None,
|
||||
request_data={},
|
||||
)
|
||||
|
||||
assert isinstance(result, UserAPIKeyAuth)
|
||||
assert result.via_virtual_key is True
|
||||
assert result.api_key == hashed_key
|
||||
finally:
|
||||
for attr, val in _original_values.items():
|
||||
setattr(_proxy_server_mod, attr, val)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_return_user_api_key_auth_obj_user_spend_and_budget():
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1628,6 +1628,226 @@ async def test_ui_view_spend_logs_date_range_filter(client, monkeypatch):
|
|||
assert data["data"][0]["id"] == "log2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_view_spend_logs_request_id_lookup_ignores_date_window(
|
||||
client, monkeypatch
|
||||
):
|
||||
"""
|
||||
LIT-3981: a request_id lookup on the UI route resolves across all time even
|
||||
when the caller sends a date window that excludes the log (the dashboard
|
||||
always sends a window). The window is dropped and request_id alone scopes
|
||||
the query. Pre-fix the window was always applied, so an id from an older
|
||||
page returned nothing.
|
||||
"""
|
||||
today = datetime.datetime.now(timezone.utc)
|
||||
mock_spend_logs = [
|
||||
{
|
||||
"id": "log_old",
|
||||
"request_id": "req-old",
|
||||
"api_key": "sk-test-key",
|
||||
"user": "test_user_1",
|
||||
"team_id": "team1",
|
||||
"spend": 0.05,
|
||||
"startTime": (today - datetime.timedelta(days=90)).isoformat(),
|
||||
"model": "gpt-4",
|
||||
},
|
||||
]
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
def filter_fn(where):
|
||||
captured["where"] = where
|
||||
rows = _filter_logs_by_date_range(mock_spend_logs, where)
|
||||
if where.get("request_id"):
|
||||
rows = [r for r in rows if r["request_id"] == where["request_id"]]
|
||||
return rows
|
||||
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.proxy_server.prisma_client",
|
||||
make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_fn),
|
||||
)
|
||||
|
||||
# A 5-day window that EXCLUDES the 90-day-old log, as the dashboard sends.
|
||||
start_date = (today - datetime.timedelta(days=5)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
end_date = today.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN
|
||||
)
|
||||
try:
|
||||
response = client.get(
|
||||
"/spend/logs/ui",
|
||||
params={
|
||||
"request_id": "req-old",
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
},
|
||||
headers={"Authorization": "Bearer sk-test"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == 1
|
||||
assert data["data"][0]["request_id"] == "req-old"
|
||||
# Query dropped the time window and scoped solely by the primary key.
|
||||
assert "startTime" not in captured["where"]
|
||||
assert captured["where"]["request_id"] == "req-old"
|
||||
finally:
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_view_spend_logs_requires_dates_without_request_id(
|
||||
client, monkeypatch
|
||||
):
|
||||
"""The date window stays mandatory on the UI route when no request_id is set."""
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.proxy_server.prisma_client",
|
||||
make_ui_spend_logs_mock_prisma([], lambda where: []),
|
||||
)
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN
|
||||
)
|
||||
try:
|
||||
response = client.get(
|
||||
"/spend/logs/ui", headers={"Authorization": "Bearer sk-test"}
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "date" in response.text.lower()
|
||||
finally:
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spend_logs_v2_still_requires_dates_with_request_id(client, monkeypatch):
|
||||
"""The public /spend/logs/v2 contract is unchanged: dates remain required even
|
||||
when request_id is supplied. Only the internal UI route relaxes the window."""
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.proxy_server.prisma_client",
|
||||
make_ui_spend_logs_mock_prisma([], lambda where: []),
|
||||
)
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN
|
||||
)
|
||||
try:
|
||||
response = client.get(
|
||||
"/spend/logs/v2",
|
||||
params={"request_id": "req-old"},
|
||||
headers={"Authorization": "Bearer sk-test"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "date" in response.text.lower()
|
||||
finally:
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_view_spend_logs_request_id_blocks_non_owner(client, monkeypatch):
|
||||
"""A non-admin looking up a request_id they do not own is rejected (403), so
|
||||
the relaxed date window cannot read another tenant's log by id."""
|
||||
|
||||
class _ForeignRow:
|
||||
user = "other_user"
|
||||
team_id = None
|
||||
|
||||
class _SpendLogs:
|
||||
async def find_unique(self, where, include=None):
|
||||
return _ForeignRow()
|
||||
|
||||
class _DB:
|
||||
def __init__(self):
|
||||
self.litellm_spendlogs = _SpendLogs()
|
||||
|
||||
class _Prisma:
|
||||
def __init__(self):
|
||||
self.db = _DB()
|
||||
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", _Prisma())
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.INTERNAL_USER, user_id="user_1"
|
||||
)
|
||||
try:
|
||||
response = client.get(
|
||||
"/spend/logs/ui",
|
||||
params={"request_id": "foreign-req"},
|
||||
headers={"Authorization": "Bearer sk-test"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
finally:
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_view_spend_logs_request_id_owner_scoped_by_id_only(
|
||||
client, monkeypatch
|
||||
):
|
||||
"""A non-admin owner looking up their own request_id resolves across all time.
|
||||
The ownership check authorizes the single row, so the query drops both the date
|
||||
window and the general user/team scoping and filters by the primary key alone;
|
||||
without that skip an internal user would have a `user`/`OR` clause added."""
|
||||
today = datetime.datetime.now(timezone.utc)
|
||||
mock_spend_logs = [
|
||||
{
|
||||
"id": "log_old",
|
||||
"request_id": "req-old",
|
||||
"api_key": "sk-test-key",
|
||||
"user": "user_1",
|
||||
"team_id": "team1",
|
||||
"spend": 0.05,
|
||||
"startTime": (today - datetime.timedelta(days=90)).isoformat(),
|
||||
"model": "gpt-4",
|
||||
},
|
||||
]
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
def filter_fn(where):
|
||||
captured["where"] = where
|
||||
rows = _filter_logs_by_date_range(mock_spend_logs, where)
|
||||
if where.get("request_id"):
|
||||
rows = [r for r in rows if r["request_id"] == where["request_id"]]
|
||||
return rows
|
||||
|
||||
mock_prisma = make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_fn)
|
||||
|
||||
class _OwnedRow:
|
||||
user = "user_1"
|
||||
team_id = "team1"
|
||||
|
||||
async def _find_unique(where, include=None):
|
||||
return _OwnedRow()
|
||||
|
||||
mock_prisma.db.find_unique = _find_unique
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
|
||||
|
||||
# A 5-day window that EXCLUDES the 90-day-old log, as the dashboard sends.
|
||||
start_date = (today - datetime.timedelta(days=5)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
end_date = today.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.INTERNAL_USER, user_id="user_1"
|
||||
)
|
||||
try:
|
||||
response = client.get(
|
||||
"/spend/logs/ui",
|
||||
params={
|
||||
"request_id": "req-old",
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
},
|
||||
headers={"Authorization": "Bearer sk-test"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == 1
|
||||
assert data["data"][0]["request_id"] == "req-old"
|
||||
assert "startTime" not in captured["where"]
|
||||
assert captured["where"]["request_id"] == "req-old"
|
||||
assert "user" not in captured["where"]
|
||||
assert "OR" not in captured["where"]
|
||||
finally:
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_view_spend_logs_unauthorized(client):
|
||||
# Test without authorization header
|
||||
|
|
|
|||
|
|
@ -5226,3 +5226,198 @@ async def test_add_litellm_data_to_request_unions_metadata_tags_with_header_tags
|
|||
tags = updated["litellm_metadata"]["tags"]
|
||||
assert "header-tag" in tags
|
||||
assert "body-tag" in tags
|
||||
|
||||
|
||||
def _make_chat_request_mock() -> MagicMock:
|
||||
return _make_request_mock("/v1/chat/completions", {"Content-Type": "application/json"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_overwrite_user_with_key_hash_clobbers_caller_supplied_user(monkeypatch):
|
||||
"""The flag exists so providers can ban by a tamper-proof id; a caller-chosen
|
||||
`user` must never survive, and the raw sk- key must never be forwarded."""
|
||||
from litellm.proxy._types import hash_token
|
||||
|
||||
monkeypatch.setattr(litellm, "overwrite_user_with_key_hash", True)
|
||||
|
||||
raw_key = "sk-overwrite-user-test-1234"
|
||||
user_api_key_dict = UserAPIKeyAuth(api_key=raw_key)
|
||||
user_api_key_dict.via_virtual_key = True
|
||||
data = {"model": "gpt-4o", "user": "attacker-chosen-id"}
|
||||
|
||||
updated_data = await add_litellm_data_to_request(
|
||||
data=data,
|
||||
request=_make_chat_request_mock(),
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
proxy_config=MagicMock(),
|
||||
general_settings={},
|
||||
version="test-version",
|
||||
)
|
||||
|
||||
assert updated_data["user"] == hash_token(raw_key)
|
||||
assert updated_data["user"] != "attacker-chosen-id"
|
||||
assert raw_key not in updated_data["user"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_overwrite_user_with_key_hash_sets_user_when_absent(monkeypatch):
|
||||
from litellm.proxy._types import hash_token
|
||||
|
||||
monkeypatch.setattr(litellm, "overwrite_user_with_key_hash", True)
|
||||
|
||||
raw_key = "sk-overwrite-user-test-5678"
|
||||
user_api_key_dict = UserAPIKeyAuth(api_key=raw_key)
|
||||
user_api_key_dict.via_virtual_key = True
|
||||
data = {"model": "gpt-4o"}
|
||||
|
||||
updated_data = await add_litellm_data_to_request(
|
||||
data=data,
|
||||
request=_make_chat_request_mock(),
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
proxy_config=MagicMock(),
|
||||
general_settings={},
|
||||
version="test-version",
|
||||
)
|
||||
|
||||
assert updated_data["user"] == hash_token(raw_key)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_overwrite_user_with_key_hash_disabled_preserves_caller_user():
|
||||
assert litellm.overwrite_user_with_key_hash is False
|
||||
|
||||
user_api_key_dict = UserAPIKeyAuth(api_key="sk-overwrite-user-test-9999")
|
||||
user_api_key_dict.via_virtual_key = True
|
||||
data = {"model": "gpt-4o", "user": "caller-chosen-id"}
|
||||
|
||||
updated_data = await add_litellm_data_to_request(
|
||||
data=data,
|
||||
request=_make_chat_request_mock(),
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
proxy_config=MagicMock(),
|
||||
general_settings={},
|
||||
version="test-version",
|
||||
)
|
||||
|
||||
assert updated_data["user"] == "caller-chosen-id"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_overwrite_user_with_key_hash_skips_custom_auth_credential(monkeypatch):
|
||||
"""Custom-auth credentials are not sk-prefixed or JWTs, so UserAPIKeyAuth stores
|
||||
them raw; the stamp must skip them entirely so auth material never leaks."""
|
||||
monkeypatch.setattr(litellm, "overwrite_user_with_key_hash", True)
|
||||
|
||||
raw_credential = "my-custom-auth-credential-abc123"
|
||||
user_api_key_dict = UserAPIKeyAuth(api_key=raw_credential)
|
||||
assert user_api_key_dict.api_key == raw_credential
|
||||
|
||||
updated_data = await add_litellm_data_to_request(
|
||||
data={"model": "gpt-4o", "user": "caller-chosen-id"},
|
||||
request=_make_chat_request_mock(),
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
proxy_config=MagicMock(),
|
||||
general_settings={},
|
||||
version="test-version",
|
||||
)
|
||||
|
||||
assert updated_data["user"] == "caller-chosen-id"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_overwrite_user_with_key_hash_skips_jwt_auth(monkeypatch):
|
||||
"""A hashed JWT rotates on every token re-issue, so it is useless as a stable
|
||||
ban id; JWT-authenticated requests are not stamped."""
|
||||
from litellm.proxy._types import hash_token
|
||||
|
||||
monkeypatch.setattr(litellm, "overwrite_user_with_key_hash", True)
|
||||
|
||||
hashed_jwt = f"hashed-jwt-{hash_token('some-jwt-token')}"
|
||||
user_api_key_dict = UserAPIKeyAuth(api_key=hashed_jwt)
|
||||
|
||||
updated_data = await add_litellm_data_to_request(
|
||||
data={"model": "gpt-4o", "user": "caller-chosen-id"},
|
||||
request=_make_chat_request_mock(),
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
proxy_config=MagicMock(),
|
||||
general_settings={},
|
||||
version="test-version",
|
||||
)
|
||||
|
||||
assert updated_data["user"] == "caller-chosen-id"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_overwrite_user_with_key_hash_skips_hex_shaped_custom_credential(monkeypatch):
|
||||
"""A custom-auth credential that happens to be 64 hex chars is indistinguishable
|
||||
from a key hash by shape alone; only the server-set via_virtual_key marker may
|
||||
authorize stamping, so this raw credential must never be forwarded."""
|
||||
monkeypatch.setattr(litellm, "overwrite_user_with_key_hash", True)
|
||||
|
||||
hex_shaped_credential = "a" * 64
|
||||
user_api_key_dict = UserAPIKeyAuth(api_key=hex_shaped_credential)
|
||||
assert user_api_key_dict.api_key == hex_shaped_credential
|
||||
assert user_api_key_dict.via_virtual_key is False
|
||||
|
||||
updated_data = await add_litellm_data_to_request(
|
||||
data={"model": "gpt-4o", "user": "caller-chosen-id"},
|
||||
request=_make_chat_request_mock(),
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
proxy_config=MagicMock(),
|
||||
general_settings={},
|
||||
version="test-version",
|
||||
)
|
||||
|
||||
assert updated_data["user"] == "caller-chosen-id"
|
||||
|
||||
|
||||
def test_via_virtual_key_cannot_be_forged_from_validated_input():
|
||||
from_kwargs = UserAPIKeyAuth(api_key="b" * 64, via_virtual_key=True)
|
||||
assert from_kwargs.via_virtual_key is False
|
||||
|
||||
from_dict = UserAPIKeyAuth.model_validate({"api_key": "b" * 64, "via_virtual_key": True})
|
||||
assert from_dict.via_virtual_key is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_overwrite_user_with_key_hash_stamps_master_key_alias(monkeypatch):
|
||||
"""Master-key requests carry the stable alias instead of a hash (so the master
|
||||
key never propagates anywhere); the alias is the stampable id for them."""
|
||||
from litellm.constants import LITELLM_PROXY_MASTER_KEY_ALIAS
|
||||
|
||||
monkeypatch.setattr(litellm, "overwrite_user_with_key_hash", True)
|
||||
|
||||
user_api_key_dict = UserAPIKeyAuth(api_key=LITELLM_PROXY_MASTER_KEY_ALIAS)
|
||||
user_api_key_dict.via_virtual_key = True
|
||||
|
||||
updated_data = await add_litellm_data_to_request(
|
||||
data={"model": "gpt-4o", "user": "attacker-chosen-id"},
|
||||
request=_make_chat_request_mock(),
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
proxy_config=MagicMock(),
|
||||
general_settings={},
|
||||
version="test-version",
|
||||
)
|
||||
|
||||
assert updated_data["user"] == LITELLM_PROXY_MASTER_KEY_ALIAS
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_overwrite_user_with_key_hash_rejects_alias_without_marker(monkeypatch):
|
||||
from litellm.constants import LITELLM_PROXY_MASTER_KEY_ALIAS
|
||||
|
||||
monkeypatch.setattr(litellm, "overwrite_user_with_key_hash", True)
|
||||
|
||||
user_api_key_dict = UserAPIKeyAuth(api_key=LITELLM_PROXY_MASTER_KEY_ALIAS)
|
||||
assert user_api_key_dict.via_virtual_key is False
|
||||
|
||||
updated_data = await add_litellm_data_to_request(
|
||||
data={"model": "gpt-4o", "user": "caller-chosen-id"},
|
||||
request=_make_chat_request_mock(),
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
proxy_config=MagicMock(),
|
||||
general_settings={},
|
||||
version="test-version",
|
||||
)
|
||||
|
||||
assert updated_data["user"] == "caller-chosen-id"
|
||||
|
|
|
|||
|
|
@ -1132,6 +1132,11 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/models-and-endpoints/layout.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer.ts": {
|
||||
"prefer-const": {
|
||||
"count": 6
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
/* @vitest-environment jsdom */
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockPush, navState } = vi.hoisted(() => ({
|
||||
mockPush: vi.fn(),
|
||||
navState: { pathname: "/logs" },
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => navState.pathname,
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/networking", () => ({ serverRootPath: "" }));
|
||||
|
||||
import { createTabRoutes } from "@/utils/tabRoutes";
|
||||
import { useTabRouting } from "./useTabRouting";
|
||||
|
||||
const routes = createTabRoutes("logs", ["audit", "deleted-keys", "deleted-teams"] as const);
|
||||
|
||||
const render = (ready = true) => {
|
||||
const config = {
|
||||
routes,
|
||||
baseTabKey: "request-logs",
|
||||
visibleKeys: ["audit", "deleted-keys", "deleted-teams"],
|
||||
ready,
|
||||
};
|
||||
return renderHook(() => useTabRouting(config));
|
||||
};
|
||||
|
||||
describe("useTabRouting", () => {
|
||||
beforeEach(() => {
|
||||
navState.pathname = "/logs";
|
||||
mockPush.mockClear();
|
||||
});
|
||||
|
||||
it("maps the base path to the base tab key", () => {
|
||||
const { result } = render();
|
||||
expect(result.current.activeSlug).toBe("");
|
||||
expect(result.current.activeKey).toBe("request-logs");
|
||||
});
|
||||
|
||||
it("uses the slug itself as the active key for a known nested tab", () => {
|
||||
navState.pathname = "/ui/logs/audit";
|
||||
const { result } = render();
|
||||
expect(result.current.activeKey).toBe("audit");
|
||||
});
|
||||
|
||||
it("falls back to the base tab key for an unknown slug", () => {
|
||||
navState.pathname = "/ui/logs/bogus";
|
||||
const { result } = render();
|
||||
expect(result.current.activeKey).toBe("request-logs");
|
||||
});
|
||||
|
||||
it("redirects an unknown slug to the base href once ready", () => {
|
||||
const replaceMock = vi.fn();
|
||||
const originalLocation = window.location;
|
||||
Object.defineProperty(window, "location", { configurable: true, value: { replace: replaceMock } });
|
||||
navState.pathname = "/ui/logs/bogus";
|
||||
render(true);
|
||||
expect(replaceMock).toHaveBeenCalledWith("/ui/logs/");
|
||||
Object.defineProperty(window, "location", { configurable: true, value: originalLocation });
|
||||
});
|
||||
|
||||
it("does not redirect while not ready (role/creds still loading)", () => {
|
||||
const replaceMock = vi.fn();
|
||||
const originalLocation = window.location;
|
||||
Object.defineProperty(window, "location", { configurable: true, value: { replace: replaceMock } });
|
||||
navState.pathname = "/ui/logs/bogus";
|
||||
render(false);
|
||||
expect(replaceMock).not.toHaveBeenCalled();
|
||||
Object.defineProperty(window, "location", { configurable: true, value: originalLocation });
|
||||
});
|
||||
|
||||
it("pushes the tab href on change, mapping the base key back to the empty slug", () => {
|
||||
const { result } = render();
|
||||
result.current.onTabChange("audit");
|
||||
expect(mockPush).toHaveBeenCalledWith("/ui/logs/audit/");
|
||||
result.current.onTabChange("request-logs");
|
||||
expect(mockPush).toHaveBeenCalledWith("/ui/logs/");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import { useEffect } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import type { TabRoutes } from "@/utils/tabRoutes";
|
||||
|
||||
interface UseTabRoutingArgs {
|
||||
routes: Pick<TabRoutes<string>, "tabHref" | "slugFromPathname">;
|
||||
baseTabKey: string;
|
||||
visibleKeys: readonly string[];
|
||||
ready?: boolean;
|
||||
}
|
||||
|
||||
interface TabRoutingState {
|
||||
activeSlug: string;
|
||||
activeKey: string;
|
||||
onTabChange: (key: string) => void;
|
||||
}
|
||||
|
||||
export function useTabRouting({ routes, baseTabKey, visibleKeys, ready = true }: UseTabRoutingArgs): TabRoutingState {
|
||||
const { tabHref, slugFromPathname } = routes;
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
const activeSlug = slugFromPathname(pathname);
|
||||
const isKnownSlug = activeSlug === "" || visibleKeys.includes(activeSlug);
|
||||
const activeKey = isKnownSlug ? activeSlug || baseTabKey : baseTabKey;
|
||||
|
||||
useEffect(() => {
|
||||
if (ready && activeSlug !== "" && !isKnownSlug) {
|
||||
window.location.replace(tabHref(""));
|
||||
}
|
||||
}, [ready, activeSlug, isKnownSlug, tabHref]);
|
||||
|
||||
const onTabChange = (key: string) => {
|
||||
router.push(tabHref(key === baseTabKey ? "" : key));
|
||||
};
|
||||
|
||||
return { activeSlug, activeKey, onTabChange };
|
||||
}
|
||||
|
|
@ -1,19 +1,19 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Tabs } from "antd";
|
||||
import { RefreshIcon } from "@heroicons/react/outline";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
|
||||
import { useTabRouting } from "@/app/(dashboard)/hooks/useTabRouting";
|
||||
import { all_admin_roles, internalUserRoles, isProxyAdminRole, isUserTeamAdminForAnyTeam } from "@/utils/roles";
|
||||
import CostOptimizationFeedbackBanner from "@/components/molecules/cost_optimization_feedback_banner";
|
||||
import ModelInfoView from "@/components/model_info_view";
|
||||
import TeamInfoView from "@/components/team/TeamInfo";
|
||||
import { modelTabHref, slugFromPathname, type ModelTabSlug } from "@/app/(dashboard)/models-and-endpoints/tabRoutes";
|
||||
import { modelsRoutes, type ModelTabSlug } from "@/app/(dashboard)/models-and-endpoints/tabRoutes";
|
||||
import { useModelDetailRouting } from "@/app/(dashboard)/models-and-endpoints/detailNavigation";
|
||||
import { useModelDashboardData } from "@/app/(dashboard)/models-and-endpoints/useModelDashboardData";
|
||||
|
||||
|
|
@ -33,8 +33,6 @@ export default function ModelsAndEndpointsLayout({ children }: { children: React
|
|||
const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized();
|
||||
const { data: teams, isLoading: teamsLoading } = useTeams();
|
||||
const { data: uiSettings, isLoading: uiSettingsLoading } = useUISettings();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { modelId, teamId, close } = useModelDetailRouting();
|
||||
const { availableModelAccessGroups, allModelsOnProxy } = useModelDashboardData();
|
||||
|
|
@ -60,18 +58,13 @@ export default function ModelsAndEndpointsLayout({ children }: { children: React
|
|||
[shouldHideAddModelTab, isAdmin],
|
||||
);
|
||||
|
||||
const activeSlug = slugFromPathname(pathname);
|
||||
const isKnownSlug = visibleSlugs.some((slug) => slug === activeSlug);
|
||||
const activeKey = isKnownSlug ? activeSlug || BASE_TAB_KEY : BASE_TAB_KEY;
|
||||
|
||||
useEffect(() => {
|
||||
if (teamsLoading || uiSettingsLoading) {
|
||||
return;
|
||||
}
|
||||
if (activeSlug !== "" && !isKnownSlug) {
|
||||
window.location.replace(modelTabHref(""));
|
||||
}
|
||||
}, [activeSlug, isKnownSlug, teamsLoading, uiSettingsLoading]);
|
||||
const tabRoutingConfig = {
|
||||
routes: modelsRoutes,
|
||||
baseTabKey: BASE_TAB_KEY,
|
||||
visibleKeys: visibleSlugs.filter(Boolean),
|
||||
ready: !teamsLoading && !uiSettingsLoading,
|
||||
};
|
||||
const { activeKey, onTabChange } = useTabRouting(tabRoutingConfig);
|
||||
|
||||
const allModelsLabel = isAdmin ? "All Models" : "Your Models";
|
||||
const tabItems = visibleSlugs.map((slug) => {
|
||||
|
|
@ -137,7 +130,7 @@ export default function ModelsAndEndpointsLayout({ children }: { children: React
|
|||
) : (
|
||||
<Tabs
|
||||
activeKey={activeKey}
|
||||
onChange={(key) => router.push(modelTabHref(key === BASE_TAB_KEY ? "" : key))}
|
||||
onChange={onTabChange}
|
||||
items={tabItems}
|
||||
tabBarExtraContent={{
|
||||
right: (
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import { migratedHref } from "@/utils/migratedPages";
|
||||
import { createTabRoutes } from "@/utils/tabRoutes";
|
||||
|
||||
export const MODELS_BASE_SEGMENT = "models-and-endpoints";
|
||||
|
||||
export const MODEL_TAB_SLUGS = [
|
||||
export const modelsRoutes = createTabRoutes("models-and-endpoints", [
|
||||
"add",
|
||||
"llm-credentials",
|
||||
"pass-through",
|
||||
|
|
@ -10,20 +8,11 @@ export const MODEL_TAB_SLUGS = [
|
|||
"retry-settings",
|
||||
"model-group-alias",
|
||||
"price-data",
|
||||
] as const;
|
||||
] as const);
|
||||
|
||||
export type ModelTabSlug = (typeof MODEL_TAB_SLUGS)[number];
|
||||
export type ModelTabSlug = (typeof modelsRoutes.slugs)[number];
|
||||
|
||||
export function modelTabHref(slug: string): string {
|
||||
const base = migratedHref(MODELS_BASE_SEGMENT);
|
||||
return slug ? `${base}/${slug}/` : `${base}/`;
|
||||
}
|
||||
|
||||
export function slugFromPathname(pathname: string): string {
|
||||
const parts = pathname.split("/").filter(Boolean);
|
||||
const idx = parts.indexOf(MODELS_BASE_SEGMENT);
|
||||
if (idx === -1) {
|
||||
return "";
|
||||
}
|
||||
return parts[idx + 1] ?? "";
|
||||
}
|
||||
export const MODELS_BASE_SEGMENT = modelsRoutes.baseSegment;
|
||||
export const MODEL_TAB_SLUGS = modelsRoutes.slugs;
|
||||
export const modelTabHref = modelsRoutes.tabHref;
|
||||
export const slugFromPathname = modelsRoutes.slugFromPathname;
|
||||
|
|
|
|||
|
|
@ -121,23 +121,21 @@ describe("RequestLogsPanel", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("client-side search", () => {
|
||||
it("narrows the visible rows without refetching", async () => {
|
||||
describe("search by request id (LIT-3981)", () => {
|
||||
it("sends the typed request id to the server on the first page instead of filtering the loaded rows", async () => {
|
||||
const user = userEvent.setup();
|
||||
respondWith([
|
||||
logEntry({ request_id: "req-alpha", model: "gpt-4o" }),
|
||||
logEntry({ request_id: "req-beta", model: "claude-opus" }),
|
||||
]);
|
||||
renderWithProviders(<RequestLogsPanel {...defaultProps} />);
|
||||
|
||||
await waitFor(() => expect(row("req-alpha")).not.toBeNull());
|
||||
const callsBefore = vi.mocked(uiSpendLogsCall).mock.calls.length;
|
||||
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled());
|
||||
|
||||
await user.type(screen.getByTestId("datatable-search"), "alpha");
|
||||
await user.type(screen.getByTestId("datatable-search"), "req-on-another-page");
|
||||
|
||||
await waitFor(() => expect(row("req-beta")).toBeNull());
|
||||
expect(row("req-alpha")).not.toBeNull();
|
||||
expect(vi.mocked(uiSpendLogsCall).mock.calls.length).toBe(callsBefore);
|
||||
await waitFor(() => {
|
||||
const call = lastCall();
|
||||
if (!call) throw new Error("uiSpendLogsCall was not called");
|
||||
expect(call.params?.request_id).toBe("req-on-another-page");
|
||||
expect(call.page).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { keyInfoV1Call } from "../networking";
|
|||
import KeyInfoView from "../templates/key_info_view";
|
||||
import type { LogEntry } from "./columns";
|
||||
import { AGENT_CALL_TYPES, MCP_CALL_TYPES } from "./constants";
|
||||
import { DEFAULT_LOGS_SORTING, useLogFilterLogic } from "./log_filter_logic";
|
||||
import { DEFAULT_LOGS_SORTING, LOG_FILTER_IDS, useLogFilterLogic } from "./log_filter_logic";
|
||||
import { LogDetailsDrawer } from "./LogDetailsDrawer";
|
||||
import { LiveTailBanner, LogsTableToolbar } from "./LogsTableToolbar";
|
||||
import { RequestLogsTable } from "./RequestLogsTable";
|
||||
|
|
@ -34,7 +34,6 @@ interface SessionComposition {
|
|||
}
|
||||
|
||||
export default function RequestLogsPanel({ accessToken, token, userRole, userID, isActive }: RequestLogsPanelProps) {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: PAGE_SIZE });
|
||||
const [sorting, setSorting] = useState<SortingState>(DEFAULT_LOGS_SORTING);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
|
|
@ -93,14 +92,7 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
|
|||
const { data: selectedKeyInfo } = useQuery(keyInfoQueryOptions);
|
||||
|
||||
const rows = useMemo<LogEntry[]>(() => {
|
||||
const searchedLogs = filteredLogs.data.filter((log) => {
|
||||
if (!searchTerm) return true;
|
||||
return (
|
||||
log.request_id.includes(searchTerm) ||
|
||||
log.model.includes(searchTerm) ||
|
||||
(log.user !== undefined && log.user.includes(searchTerm))
|
||||
);
|
||||
});
|
||||
const searchedLogs = filteredLogs.data;
|
||||
|
||||
const sessionCompositionById = searchedLogs.reduce<Record<string, SessionComposition>>((acc, log) => {
|
||||
if (!log.session_id) return acc;
|
||||
|
|
@ -141,7 +133,20 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
|
|||
if (!log.session_id || (log.session_total_count || 1) <= 1) return true;
|
||||
return sessionRepresentativeMap.get(log.session_id)?.requestId === log.request_id;
|
||||
});
|
||||
}, [filteredLogs.data, searchTerm]);
|
||||
}, [filteredLogs.data]);
|
||||
|
||||
const searchTerm = useMemo(() => {
|
||||
const entry = columnFilters.find((filter) => filter.id === LOG_FILTER_IDS.REQUEST_ID);
|
||||
return typeof entry?.value === "string" ? entry.value : "";
|
||||
}, [columnFilters]);
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setColumnFilters((previous) => {
|
||||
const others = previous.filter((filter) => filter.id !== LOG_FILTER_IDS.REQUEST_ID);
|
||||
return value === "" ? others : [...others, { id: LOG_FILTER_IDS.REQUEST_ID, value }];
|
||||
});
|
||||
setPagination((previous) => ({ ...previous, pageIndex: 0 }));
|
||||
}, []);
|
||||
|
||||
const handleSortingChange = useCallback<OnChangeFn<SortingState>>((updaterOrValue) => {
|
||||
setSorting(updaterOrValue);
|
||||
|
|
@ -159,7 +164,6 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
|
|||
|
||||
const handleResetFilters = useCallback(() => {
|
||||
setColumnFilters([]);
|
||||
setSearchTerm("");
|
||||
setStartTime(moment().subtract(24, "hours").format("YYYY-MM-DDTHH:mm"));
|
||||
setEndTime(moment().format("YYYY-MM-DDTHH:mm"));
|
||||
setIsCustomDate(false);
|
||||
|
|
@ -221,7 +225,7 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
|
|||
columnFilters={columnFilters}
|
||||
onColumnFiltersChange={handleColumnFiltersChange}
|
||||
searchValue={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
onSearchChange={handleSearchChange}
|
||||
onRefresh={() => void logsQuery.refetch()}
|
||||
onRowClick={handleRowClick}
|
||||
onKeyHashClick={handleKeyHashClick}
|
||||
|
|
|
|||
47
ui/litellm-dashboard/src/utils/tabRoutes.test.ts
Normal file
47
ui/litellm-dashboard/src/utils/tabRoutes.test.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/* @vitest-environment jsdom */
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/components/networking", () => ({ serverRootPath: "" }));
|
||||
|
||||
import { createTabRoutes } from "./tabRoutes";
|
||||
|
||||
const routes = createTabRoutes("logs", ["audit", "deleted-keys", "deleted-teams"] as const);
|
||||
|
||||
describe("createTabRoutes.slugFromPathname", () => {
|
||||
it("returns empty string for the base path with or without a trailing slash", () => {
|
||||
expect(routes.slugFromPathname("/logs")).toBe("");
|
||||
expect(routes.slugFromPathname("/logs/")).toBe("");
|
||||
});
|
||||
|
||||
it("extracts the tab slug from dev and proxy-mounted (/ui) paths", () => {
|
||||
expect(routes.slugFromPathname("/logs/audit")).toBe("audit");
|
||||
expect(routes.slugFromPathname("/ui/logs/deleted-teams/")).toBe("deleted-teams");
|
||||
});
|
||||
|
||||
it("returns the raw segment for an unknown tab so the caller can redirect to base", () => {
|
||||
expect(routes.slugFromPathname("/ui/logs/bogus")).toBe("bogus");
|
||||
});
|
||||
|
||||
it("returns empty string when the base segment is not in the path", () => {
|
||||
expect(routes.slugFromPathname("/teams")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTabRoutes.tabHref", () => {
|
||||
it("builds the trailing-slash base href for the empty slug", () => {
|
||||
expect(routes.tabHref("")).toBe("/ui/logs/");
|
||||
});
|
||||
|
||||
it("builds a trailing-slash href for every tab slug (required by static export)", () => {
|
||||
for (const slug of routes.slugs) {
|
||||
expect(routes.tabHref(slug)).toBe(`/ui/logs/${slug}/`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTabRoutes metadata", () => {
|
||||
it("preserves the base segment and slug tuple", () => {
|
||||
expect(routes.baseSegment).toBe("logs");
|
||||
expect(routes.slugs).toEqual(["audit", "deleted-keys", "deleted-teams"]);
|
||||
});
|
||||
});
|
||||
26
ui/litellm-dashboard/src/utils/tabRoutes.ts
Normal file
26
ui/litellm-dashboard/src/utils/tabRoutes.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { migratedHref } from "@/utils/migratedPages";
|
||||
|
||||
export interface TabRoutes<Slug extends string> {
|
||||
baseSegment: string;
|
||||
slugs: readonly Slug[];
|
||||
tabHref: (slug: string) => string;
|
||||
slugFromPathname: (pathname: string) => string;
|
||||
}
|
||||
|
||||
export function createTabRoutes<Slug extends string>(baseSegment: string, slugs: readonly Slug[]): TabRoutes<Slug> {
|
||||
const tabHref = (slug: string): string => {
|
||||
const base = migratedHref(baseSegment);
|
||||
return slug ? `${base}/${slug}/` : `${base}/`;
|
||||
};
|
||||
|
||||
const slugFromPathname = (pathname: string): string => {
|
||||
const parts = pathname.split("/").filter(Boolean);
|
||||
const idx = parts.indexOf(baseSegment);
|
||||
if (idx === -1) {
|
||||
return "";
|
||||
}
|
||||
return parts[idx + 1] ?? "";
|
||||
};
|
||||
|
||||
return { baseSegment, slugs, tabHref, slugFromPathname };
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue