diff --git a/enterprise/litellm_enterprise/proxy/audit_logging_endpoints.py b/enterprise/litellm_enterprise/proxy/audit_logging_endpoints.py index b6f8bf2dc5b..7df14565c3f 100644 --- a/enterprise/litellm_enterprise/proxy/audit_logging_endpoints.py +++ b/enterprise/litellm_enterprise/proxy/audit_logging_endpoints.py @@ -48,6 +48,18 @@ def _build_json_field_or_condition(json_key: str, value: str) -> dict[str, objec } +def _build_search_condition(search: str) -> dict[str, object]: + """Match a row whose id, changed_by, object_id, or changed_by_api_key equals the search value.""" + return { + "OR": ( + {"id": search}, + {"changed_by": search}, + {"object_id": search}, + {"changed_by_api_key": search}, + ) + } + + @router.get( "/audit", tags=["Audit Logging"], @@ -83,6 +95,10 @@ async def get_audit_logs( None, description="Filter by token (key hash) present in before_value or updated_values JSON (PostgreSQL only)", ), + search: str | None = Query( + None, + description="Match a row whose id, object_id, changed_by, or changed_by_api_key equals this value", + ), # Sorting parameters sort_by: str | None = Query( None, @@ -118,6 +134,11 @@ async def get_audit_logs( *([_build_json_field_or_condition("token", object_key_hash)] if object_key_hash else []), ] + and_conditions: Final[tuple[dict[str, object], ...]] = ( + *json_field_conditions, + *((_build_search_condition(search),) if search else ()), + ) + # Build filter conditions where_conditions: Final[dict[str, object]] = { **({"changed_by": changed_by} if changed_by else {}), @@ -126,14 +147,14 @@ async def get_audit_logs( **({"table_name": table_name} if table_name else {}), **({"object_id": object_id} if object_id else {}), **({"updated_at": date_filter} if start_date or end_date else {}), - **({"AND": json_field_conditions} if json_field_conditions else {}), + **({"AND": and_conditions} if and_conditions else {}), } order_by: Final[dict[str, str]] = ( {sort_by: sort_order} if sort_by and isinstance(sort_by, str) else {"updated_at": sort_order} ) - audit_log_table: Final[TableActions["prisma_models.LiteLLM_AuditLog"]] = AuditLogRepository(prisma_client).table + audit_log_table: Final[TableActions[prisma_models.LiteLLM_AuditLog]] = AuditLogRepository(prisma_client).table # Get paginated results audit_logs: Final = await audit_log_table.find_many( @@ -195,7 +216,7 @@ async def get_audit_log_by_id( detail={"message": CommonProxyErrors.db_not_connected_error.value}, ) - audit_log_table: Final[TableActions["prisma_models.LiteLLM_AuditLog"]] = AuditLogRepository(prisma_client).table + audit_log_table: Final[TableActions[prisma_models.LiteLLM_AuditLog]] = AuditLogRepository(prisma_client).table # Get the audit log by ID audit_log: Final = await audit_log_table.find_unique(where={"id": id}) diff --git a/litellm/litellm_core_utils/llm_cost_calc/utils.py b/litellm/litellm_core_utils/llm_cost_calc/utils.py index 21587af73aa..8e24302b440 100644 --- a/litellm/litellm_core_utils/llm_cost_calc/utils.py +++ b/litellm/litellm_core_utils/llm_cost_calc/utils.py @@ -415,40 +415,64 @@ def _is_off_peak(off_peak: Mapping[str, object], current_time: datetime | None = return False -def _coerce_off_peak_rate(value: object, default: float) -> float: +@dataclass(frozen=True, slots=True) +class TokenRates: + input_rate: float + output_rate: float + cache_read_rate: float + cache_creation_rate: float + reasoning_rate: float | None + + @property + def billed_reasoning_rate(self) -> float: + return self.output_rate if self.reasoning_rate is None else self.reasoning_rate + + +def _parse_off_peak_rate(value: object) -> float | None: if isinstance(value, bool): - return default + return None if isinstance(value, (int, float)): return float(value) if isinstance(value, str): try: return float(value) except ValueError: - return default - return default + return None + return None -def apply_off_peak_pricing( - model_info: ModelInfo, - current_time: datetime | None, - prompt_base_cost: float, - completion_base_cost: float, - cache_read_cost: float, -) -> tuple[float, float, float]: +def _off_peak_rate(off_peak: Mapping[str, object], key: str, standard_rate: float) -> float: + parsed: Final = _parse_off_peak_rate(off_peak.get(key)) + return standard_rate if parsed is None else parsed + + +def _open_off_peak_block(model_info: ModelInfo, current_time: datetime | None) -> Mapping[str, object] | None: + off_peak: Final = model_info.get("off_peak_pricing") + if not isinstance(off_peak, Mapping) or not _is_off_peak(off_peak, current_time): + return None + return off_peak + + +def apply_off_peak_pricing(model_info: ModelInfo, current_time: datetime | None, rates: TokenRates) -> TokenRates: """Swap in off-peak per-token rates when the current UTC time is inside one of the model's off_peak_pricing rules, the every-day hours_utc windows or a day-of-week-qualified entry in windows. An off-peak rate replaces the rate that would otherwise apply rather than discounting it, so a model that also has tiered or above-threshold pricing bills the flat off-peak rate for the whole request while the window is open. Any rate left unset in - off_peak_pricing falls back to the standard rate. + off_peak_pricing falls back to the standard rate, so a block without + output_cost_per_reasoning_token keeps the model's own reasoning rate, or its off-peak output + rate when reasoning has no dedicated rate at all. """ - off_peak: Final = model_info.get("off_peak_pricing") - if not isinstance(off_peak, Mapping) or not _is_off_peak(off_peak, current_time): - return prompt_base_cost, completion_base_cost, cache_read_cost - return ( - _coerce_off_peak_rate(off_peak.get("input_cost_per_token"), prompt_base_cost), - _coerce_off_peak_rate(off_peak.get("output_cost_per_token"), completion_base_cost), - _coerce_off_peak_rate(off_peak.get("cache_read_input_token_cost"), cache_read_cost), + off_peak: Final = _open_off_peak_block(model_info, current_time) + if off_peak is None: + return rates + off_peak_reasoning_rate: Final = _parse_off_peak_rate(off_peak.get("output_cost_per_reasoning_token")) + return TokenRates( + input_rate=_off_peak_rate(off_peak, "input_cost_per_token", rates.input_rate), + output_rate=_off_peak_rate(off_peak, "output_cost_per_token", rates.output_rate), + cache_read_rate=_off_peak_rate(off_peak, "cache_read_input_token_cost", rates.cache_read_rate), + cache_creation_rate=_off_peak_rate(off_peak, "cache_creation_input_token_cost", rates.cache_creation_rate), + reasoning_rate=rates.reasoning_rate if off_peak_reasoning_rate is None else off_peak_reasoning_rate, ) @@ -458,14 +482,28 @@ def _apply_off_peak_to_base_costs( base_costs: tuple[float, float, float, float, float], ) -> tuple[float, float, float, float, float]: """Apply off-peak rates to an already-resolved set of base costs, whichever pricing path - produced them. Cache-creation rates are passed through untouched, since off_peak_pricing - has no field for them. + produced them. The one-hour cache-creation rate passes through untouched, since + off_peak_pricing has no field for it, and reasoning is left to _resolve_billed_reasoning_rate. """ prompt, completion, cache_creation, cache_creation_above_1hr, cache_read = base_costs - off_peak_prompt, off_peak_completion, off_peak_cache_read = apply_off_peak_pricing( - model_info, current_time, prompt, completion, cache_read + rates: Final = apply_off_peak_pricing( + model_info, + current_time, + TokenRates( + input_rate=prompt, + output_rate=completion, + cache_read_rate=cache_read, + cache_creation_rate=cache_creation, + reasoning_rate=None, + ), + ) + return ( + rates.input_rate, + rates.output_rate, + rates.cache_creation_rate, + cache_creation_above_1hr, + rates.cache_read_rate, ) - return (off_peak_prompt, off_peak_completion, cache_creation, cache_creation_above_1hr, off_peak_cache_read) def _get_token_base_cost( @@ -1029,6 +1067,29 @@ def _resolve_reasoning_token_cost( return standard_reasoning_cost if standard_reasoning_cost is not None else completion_base_cost +def _resolve_billed_reasoning_rate( + model_info: ModelInfo, + usage: Usage, + service_tier: str | None, + completion_base_cost: float, + current_time: datetime | None, +) -> float: + off_peak: Final = _open_off_peak_block(model_info, current_time) + off_peak_reasoning_rate: Final = ( + None if off_peak is None else _parse_off_peak_rate(off_peak.get("output_cost_per_reasoning_token")) + ) + if off_peak_reasoning_rate is not None: + return off_peak_reasoning_rate + tiered_reasoning_rate: Final = _get_tiered_reasoning_rate(model_info=model_info, usage=usage) + if tiered_reasoning_rate is not None: + return tiered_reasoning_rate + return _resolve_reasoning_token_cost( + model_info=model_info, + service_tier=service_tier, + completion_base_cost=completion_base_cost, + ) + + def generic_cost_per_token( model: str, usage: Usage, @@ -1037,6 +1098,7 @@ def generic_cost_per_token( data_residency: str | None = None, model_info: ModelInfo | None = None, vertex_location: str | None = None, + current_time: datetime | None = None, ) -> tuple[float, float]: """ Calculates the cost per token for a given model, prompt tokens, and completion tokens. @@ -1051,6 +1113,7 @@ def generic_cost_per_token( - vertex_location: optional Vertex AI location the request was served from (e.g. "us-east5", "global"), used to apply the per-model regional-endpoint uplift multiplier when non-global. + - current_time: the moment the request is billed at, for off_peak_pricing; defaults to now, UTC Returns: Tuple[float, float] - prompt_cost_in_usd, completion_cost_in_usd @@ -1117,6 +1180,7 @@ def generic_cost_per_token( usage.prompt_tokens - cache_hit - audio_tokens - cache_creation - image_tokens - video_tokens, 0 ) + billing_time: Final = current_time if current_time is not None else datetime.now(timezone.utc) ( prompt_base_cost, completion_base_cost, @@ -1127,6 +1191,7 @@ def generic_cost_per_token( model_info=model_info, usage=usage, service_tier=service_tier, + current_time=billing_time, threshold_is_inclusive=_uses_inclusive_token_thresholds(custom_llm_provider), ) @@ -1185,17 +1250,13 @@ def generic_cost_per_token( ## REASONING COST if not is_text_tokens_total and reasoning_tokens and reasoning_tokens > 0: - tiered_reasoning_rate: Final = _get_tiered_reasoning_rate(model_info=model_info, usage=usage) - _output_cost_per_reasoning_token = ( - tiered_reasoning_rate - if tiered_reasoning_rate is not None - else _resolve_reasoning_token_cost( - model_info=model_info, - service_tier=service_tier, - completion_base_cost=completion_base_cost, - ) + completion_cost += float(reasoning_tokens) * _resolve_billed_reasoning_rate( + model_info=model_info, + usage=usage, + service_tier=service_tier, + completion_base_cost=completion_base_cost, + current_time=billing_time, ) - completion_cost += float(reasoning_tokens) * _output_cost_per_reasoning_token ## IMAGE COST if not is_text_tokens_total and image_tokens and image_tokens > 0: @@ -1247,6 +1308,7 @@ def get_token_type_cost_breakdown( service_tier: str | None = None, data_residency: str | None = None, vertex_location: str | None = None, + current_time: datetime | None = None, ) -> TokenTypeCostBreakdown: """ Provider-agnostic cost of reasoning and cache tokens, derived from the usage @@ -1265,6 +1327,7 @@ def get_token_type_cost_breakdown( except Exception: return TokenTypeCostBreakdown(0.0, 0.0, 0.0) + billing_time: Final = current_time if current_time is not None else datetime.now(timezone.utc) ( _prompt_base_cost, completion_base_cost, @@ -1275,6 +1338,7 @@ def get_token_type_cost_breakdown( model_info=model_info, usage=usage, service_tier=service_tier, + current_time=billing_time, threshold_is_inclusive=_uses_inclusive_token_thresholds(custom_llm_provider), ) @@ -1284,18 +1348,12 @@ def get_token_type_cost_breakdown( if not reasoning_tokens: reasoning_tokens = _coerce_token_count(getattr(usage, "reasoning_tokens", 0)) - # Reasoning is billed at the selected tier's reasoning rate for tiered models, - # else at the service-tier-aware per-reasoning-token rate - this mirrors how the - # total completion cost is computed, so the breakdown can never diverge from it. - tiered_reasoning_rate: Final = _get_tiered_reasoning_rate(model_info=model_info, usage=usage) - reasoning_rate: Final = ( - tiered_reasoning_rate - if tiered_reasoning_rate is not None - else _resolve_reasoning_token_cost( - model_info=model_info, - service_tier=service_tier, - completion_base_cost=completion_base_cost, - ) + reasoning_rate: Final = _resolve_billed_reasoning_rate( + model_info=model_info, + usage=usage, + service_tier=service_tier, + completion_base_cost=completion_base_cost, + current_time=billing_time, ) reasoning_cost = float(reasoning_tokens) * reasoning_rate diff --git a/litellm/llms/dashscope/cost_calculator.py b/litellm/llms/dashscope/cost_calculator.py index d8eb1f9f8d7..17f70ec5db7 100644 --- a/litellm/llms/dashscope/cost_calculator.py +++ b/litellm/llms/dashscope/cost_calculator.py @@ -7,12 +7,13 @@ cached, cache-creation, output, reasoning) is billed at that one tier's rate. See https://help.aliyun.com/zh/model-studio/billing-for-model-studio """ -from dataclasses import dataclass, replace +from dataclasses import dataclass from datetime import datetime from typing import Final from litellm.litellm_core_utils.llm_cost_calc.tiered_pricing import select_tier_for_input, tier_rate from litellm.litellm_core_utils.llm_cost_calc.utils import ( + TokenRates, apply_off_peak_pricing, parse_completion_tokens_details, parse_prompt_tokens_details, @@ -34,19 +35,6 @@ class TokenBreakdown: return self.text_tokens + self.cached_tokens + self.cache_creation_tokens -@dataclass(frozen=True, slots=True) -class TokenRates: - input_rate: float - cache_read_rate: float - cache_creation_rate: float - output_rate: float - reasoning_rate: float | None - - @property - def billed_reasoning_rate(self) -> float: - return self.output_rate if self.reasoning_rate is None else self.reasoning_rate - - def _extract_token_breakdown(usage: Usage) -> TokenBreakdown: prompt_details: Final = parse_prompt_tokens_details(usage) cached_tokens: Final = prompt_details["cache_hit_tokens"] @@ -105,13 +93,6 @@ def _tier_rates(model_info: ModelInfo, tier: dict) -> TokenRates: ) -def _off_peak_rates(model_info: ModelInfo, current_time: datetime | None, rates: TokenRates) -> TokenRates: - input_rate, output_rate, cache_read_rate = apply_off_peak_pricing( - model_info, current_time, rates.input_rate, rates.output_rate, rates.cache_read_rate - ) - return replace(rates, input_rate=input_rate, output_rate=output_rate, cache_read_rate=cache_read_rate) - - def _bill(breakdown: TokenBreakdown, rates: TokenRates) -> tuple[float, float]: prompt_cost: Final = ( (breakdown.text_tokens * rates.input_rate) @@ -155,6 +136,6 @@ def cost_per_token( else None ) standard_rates: Final = _flat_rates(model_info) if tier is None else _tier_rates(model_info, tier) - rates: Final = _off_peak_rates(model_info, current_time, standard_rates) + rates: Final = apply_off_peak_pricing(model_info, current_time, standard_rates) return _bill(breakdown, rates) diff --git a/litellm/llms/openai/workload_identity.py b/litellm/llms/openai/workload_identity.py index ecec161ed46..283fdfb92c2 100644 --- a/litellm/llms/openai/workload_identity.py +++ b/litellm/llms/openai/workload_identity.py @@ -8,7 +8,7 @@ from urllib.parse import urlparse import litellm from litellm.secret_managers.main import get_secret_str, normalize_nonempty_secret_str -from .common_utils import OpenAIError +from .common_utils import OpenAIError, is_openai_backed_api_base if TYPE_CHECKING: from collections.abc import Callable @@ -16,7 +16,6 @@ if TYPE_CHECKING: from openai.auth import SubjectTokenProvider, WorkloadIdentity, WorkloadIdentityAuth OPENAI_WIF_CLIENT_ID: Final = "litellm" -_OPENAI_API_HOST: Final = "api.openai.com" _SDK_UPGRADE_MESSAGE: Final = ( "OpenAI workload identity federation requires openai>=2.32.0. " "Upgrade the installed openai package to use OPENAI_IDENTITY_PROVIDER_ID / " @@ -75,7 +74,7 @@ def _targets_openai_api(api_base: str | None) -> bool: if api_base is None: return True parsed: Final = urlparse(api_base) - return parsed.scheme == "https" and parsed.hostname == _OPENAI_API_HOST + return parsed.scheme == "https" and is_openai_backed_api_base(api_base) @lru_cache(maxsize=16) diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index d7d20d168b5..324d380b85b 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -147,6 +147,7 @@ from litellm.types.proxy.management_endpoints.key_management_endpoints import ( BulkUpdateKeyResponse, BulkUpdateTeamKeysRequest, FailedKeyUpdate, + KeySearchWhere, SuccessfulKeyUpdate, ) from litellm.types.router import Deployment @@ -5800,6 +5801,10 @@ async def list_keys( None, description="Filter keys by key alias. Exact match by default; set substring_matching=true (admin only) for case-insensitive substring matching.", ), + search: str | None = Query( + None, + description="Combined search: matches keys whose token (key hash) equals the value OR whose key_alias contains it (case-insensitive).", + ), return_full_object: bool = Query(False, description="Return full key object"), include_team_keys: bool = Query(False, description="Include all keys for teams that user is an admin of."), include_created_by_keys: bool = Query(False, description="Include keys created by the user"), @@ -5943,6 +5948,7 @@ async def list_keys( agent_id=agent_id, use_substring_matching=use_substring_matching, expires_filter=expires if isinstance(expires, str) else None, + search=search, ) verbose_proxy_logger.debug("Successfully prepared response") @@ -6162,6 +6168,16 @@ def _build_expires_where_clause(expires_filter: str, now: datetime) -> dict[str, return {"OR": [{"expires": None}, {"expires": {"gte": now}}]} +def _build_key_search_where(search: str) -> KeySearchWhere: + search_where: Final[KeySearchWhere] = { + "OR": ( + {"token": search}, + {"key_alias": {"contains": search, "mode": "insensitive"}}, + ) + } + return search_where + + def _build_key_filter_conditions( user_id: str | None, team_id: str | None, @@ -6177,6 +6193,7 @@ def _build_key_filter_conditions( agent_id: str | None = None, use_substring_matching: bool = False, expires_filter: str | None = None, + search: str | None = None, ) -> Mapping[str, object]: """Build filter conditions for key listing. @@ -6266,7 +6283,7 @@ def _build_key_filter_conditions( # Apply team_id, project_id and access_group_id as global AND filters so they # narrow results across all visibility conditions (own keys, team keys, etc.) - global_filters: Final[tuple[dict[str, object], ...]] = ( + global_filters: Final[tuple[Mapping[str, object], ...]] = ( *( ( {"key_alias": {"contains": key_alias, "mode": "insensitive"}} @@ -6277,6 +6294,7 @@ def _build_key_filter_conditions( else () ), *(({"token": key_hash},) if key_hash and isinstance(key_hash, str) else ()), + *((_build_key_search_where(search),) if isinstance(search, str) and search else ()), *(({"team_id": team_id},) if team_id and isinstance(team_id, str) else ()), *(({"project_id": project_id},) if project_id else ()), *(({"access_group_ids": {"hasSome": [access_group_id]}},) if access_group_id else ()), @@ -6316,6 +6334,7 @@ async def _list_key_helper( agent_id: str | None = None, use_substring_matching: bool = False, expires_filter: str | None = None, + search: str | None = None, ) -> KeyListResponseObject: """ Helper function to list keys @@ -6354,6 +6373,7 @@ async def _list_key_helper( agent_id=agent_id, use_substring_matching=use_substring_matching, expires_filter=expires_filter, + search=search, ) # Calculate skip for pagination diff --git a/litellm/proxy/memory/memory_endpoints.py b/litellm/proxy/memory/memory_endpoints.py index 98c5fdd198c..d8f72d200c7 100644 --- a/litellm/proxy/memory/memory_endpoints.py +++ b/litellm/proxy/memory/memory_endpoints.py @@ -22,6 +22,7 @@ from collections.abc import Mapping from typing import TYPE_CHECKING, Final from fastapi import APIRouter, Depends, HTTPException, Query +from typing_extensions import ReadOnly, TypedDict from litellm._logging import verbose_proxy_logger from litellm.proxy._types import ( @@ -91,6 +92,36 @@ def _visibility_filter(user_api_key_dict: UserAPIKeyAuth) -> Mapping[str, object return {"OR": ors} +class _StartsWith(TypedDict): + startsWith: ReadOnly[str] + + +class _MemoryKeyWhere(TypedDict): + key: ReadOnly[str | _StartsWith] + + +class _MemoryIdWhere(TypedDict): + memory_id: ReadOnly[str] + + +class _MemorySearchWhere(TypedDict): + OR: ReadOnly[tuple[_MemoryKeyWhere, _MemoryIdWhere]] + + +def _key_filter(search: str | None, key_prefix: str | None, key: str | None) -> Mapping[str, object] | None: + """`search` matches a key prefix or an exact memory_id; otherwise `key_prefix` wins over `key`.""" + if search is not None: + search_where: Final[_MemorySearchWhere] = {"OR": ({"key": {"startsWith": search}}, {"memory_id": search})} + return search_where + if key_prefix is not None: + prefix_where: Final[_MemoryKeyWhere] = {"key": {"startsWith": key_prefix}} + return prefix_where + if key is not None: + exact_where: Final[_MemoryKeyWhere] = {"key": key} + return exact_where + return None + + def _row_to_model(row: "prisma_models.LiteLLM_MemoryTable") -> LiteLLM_MemoryRow: return LiteLLM_MemoryRow( memory_id=row.memory_id, @@ -326,6 +357,13 @@ async def list_memory( "Mutually exclusive with `key`; if both are provided, `key_prefix` wins." ), ), + search: str | None = Query( + None, + description=( + "Match entries whose key starts with this value or whose memory_id equals it. " + "Takes precedence over `key_prefix` and `key` when provided." + ), + ), page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=500), user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), @@ -333,22 +371,16 @@ async def list_memory( """List memory entries visible to the caller.""" prisma_client: Final = _require_prisma() - # Build the key filter first (prefix wins if both `key` and `key_prefix` - # are passed). Then AND it with the visibility filter via an explicit - # top-level "AND" — safer than `dict.update` since future visibility - # filters could grow an "OR" key that would clobber this one if merged - # by key. - key_filter: Final[dict[str, object]] = {} - if key_prefix is not None: - key_filter["key"] = {"startsWith": key_prefix} - elif key is not None: - key_filter["key"] = key + # AND the key filter with the visibility filter via an explicit top-level + # "AND": both sides can carry an "OR" key (`search`, non-admin visibility), + # so merging them by key would let one clobber the other and leak rows. + key_filter: Final = _key_filter(search=search, key_prefix=key_prefix, key=key) vis: Final = _visibility_filter(user_api_key_dict) - where: Mapping[str, object] + where: Mapping[str, object] | None if vis is None: where = key_filter - elif not key_filter: + elif key_filter is None: where = vis else: where = {"AND": [key_filter, vis]} diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index 2a50d5170f0..b86a877e8f9 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -2229,6 +2229,31 @@ async def calculate_spend(request: SpendCalculateRequest): ) +class _SpendLogSearchCondition(NamedTuple): + sql: str + params: tuple[object, ...] + + +def _build_spend_log_search_condition( + search: str, + start_date: datetime, + end_date: datetime, + next_param_index: int, +) -> _SpendLogSearchCondition: + """request_id (indexed) matches across all time; the unindexed id columns only inside the window.""" + raw: Final = f"${next_param_index}" + window_start: Final = f"${next_param_index + 1}" + window_end: Final = f"${next_param_index + 2}" + sql: Final = ( + f"(request_id = {raw} OR (" + f"\"startTime\" >= ({window_start}::timestamptz AT TIME ZONE 'UTC') " + f"AND \"startTime\" <= ({window_end}::timestamptz AT TIME ZONE 'UTC') " + f'AND (api_key = {raw} OR team_id = {raw} OR "user" = {raw} OR end_user = {raw} ' + f"OR session_id = {raw} OR model_id = {raw})))" + ) + return _SpendLogSearchCondition(sql=sql, params=(search, start_date, end_date)) + + @router.get( "/spend/logs/v2", tags=["Budget & Spend Tracking"], @@ -2329,6 +2354,14 @@ async def ui_view_spend_logs( "UI route only, honored when sorting by startTime" ), ), + search: str | None = fastapi.Query( + default=None, + description=( + "Match a log whose request_id, api_key (hash), team_id, user, end_user, " + "session_id, or model_id equals this value. request_id matches across all time; the other columns " + "match inside start_date/end_date, which stay required" + ), + ), ): """ View spend logs with pagination support. @@ -2392,8 +2425,10 @@ async def ui_view_spend_logs( try: is_admin_view: Final = _is_admin_view_safe(user_api_key_dict=user_api_key_dict) is_request_id_lookup: Final = request_id is not None and not is_v2 + is_search_lookup: Final = search is not None + search_owns_window: Final = is_search_lookup and not is_v2 - if is_request_id_lookup: + if is_request_id_lookup and not is_search_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 @@ -2576,7 +2611,7 @@ async def ui_view_spend_logs( # 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: + if start_date_obj is not None and end_date_obj is not None and not search_owns_window: sql_conditions.append(f"\"startTime\" >= (${p}::timestamptz AT TIME ZONE 'UTC')") sql_params.append(start_date_obj) p += 1 @@ -2584,6 +2619,17 @@ async def ui_view_spend_logs( sql_params.append(end_date_obj) p += 1 + if search is not None and start_date_obj is not None and end_date_obj is not None: + search_condition: Final = _build_spend_log_search_condition( + search=search, + start_date=start_date_obj, + end_date=end_date_obj, + next_param_index=p, + ) + sql_conditions.append(search_condition.sql) + sql_params.extend(search_condition.params) + p += len(search_condition.params) # rebind-ok: advances the file's shared $N placeholder counter + # Equality filters - read effective values from where_conditions (post-authorization) for sql_col, wc_key in [ ("team_id", "team_id"), @@ -2662,7 +2708,13 @@ async def ui_view_spend_logs( sql_params.append(f"%{error_message}%") p += 1 - if group_by_session is True and not is_v2 and not is_request_id_lookup and sort_by == "startTime": + if ( + group_by_session is True + and not is_v2 + and not is_request_id_lookup + and not is_search_lookup + and sort_by == "startTime" + ): return await _ui_session_grouped_spend_logs( prisma_client=prisma_client, sql_conditions=sql_conditions, @@ -2696,7 +2748,7 @@ async def ui_view_spend_logs( _order_expr = order_column joined_conditions: Final = " AND ".join(sql_conditions) - session_grouping: Final = group_by_session is True + session_grouping: Final = group_by_session is True and not is_search_lookup count_group_clause: Final = f"GROUP BY {_SESSION_GROUP_KEY_SQL}" if session_grouping else "" count_query: Final = f""" SELECT COUNT(*) AS total_count diff --git a/litellm/types/proxy/management_endpoints/key_management_endpoints.py b/litellm/types/proxy/management_endpoints/key_management_endpoints.py index 0f17f2f23ab..9fb5bea81e3 100644 --- a/litellm/types/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/types/proxy/management_endpoints/key_management_endpoints.py @@ -2,6 +2,23 @@ from datetime import datetime from typing import Any, Final, Literal from pydantic import BaseModel, ConfigDict, model_validator +from typing_extensions import ReadOnly, TypedDict + +from litellm.types.proxy.management_endpoints.internal_user_endpoints import InsensitiveContains + + +class KeyTokenWhere(TypedDict): + token: ReadOnly[str] + + +class KeyAliasContainsWhere(TypedDict): + key_alias: ReadOnly[InsensitiveContains] + + +class KeySearchWhere(TypedDict): + """Prisma filter behind `/key/list?search=`: exact token or case-insensitive alias substring.""" + + OR: ReadOnly[tuple[KeyTokenWhere, KeyAliasContainsWhere]] class BulkUpdateKeyRequestItem(BaseModel): diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 34c47d3f201..c1d90694f14 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -222,7 +222,9 @@ class OffPeakPricing(TypedDict, total=False): weekday_timezone: ReadOnly[str] input_cost_per_token: ReadOnly[float] output_cost_per_token: ReadOnly[float] + output_cost_per_reasoning_token: ReadOnly[float] cache_read_input_token_cost: ReadOnly[float] + cache_creation_input_token_cost: ReadOnly[float] class ModelInfoBase(ProviderSpecificModelInfo, total=False): diff --git a/tests/enterprise/litellm_enterprise/proxy/test_audit_logging_endpoints.py b/tests/enterprise/litellm_enterprise/proxy/test_audit_logging_endpoints.py index a0a26c089eb..fd1b05ff060 100644 --- a/tests/enterprise/litellm_enterprise/proxy/test_audit_logging_endpoints.py +++ b/tests/enterprise/litellm_enterprise/proxy/test_audit_logging_endpoints.py @@ -1,5 +1,6 @@ from datetime import datetime, timedelta -from unittest.mock import AsyncMock, patch +from typing import Final +from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi import FastAPI @@ -8,10 +9,12 @@ from litellm_enterprise.proxy.audit_logging_endpoints import router as audit_rou from litellm_enterprise.types.proxy.audit_logging_endpoints import AuditLogResponse from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth # Create an app with just the audit router for testing app = FastAPI() app.include_router(audit_router) +app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(user_role="proxy_admin") client = TestClient(app) # Mock data for testing @@ -130,3 +133,45 @@ async def test_get_audit_log_by_id_not_found(mock_prisma_client): data = response.json() assert "message" in data["detail"] assert "not found" in data["detail"]["message"].lower() + + +def _list_audit_logs_where(mock_prisma_client: MagicMock, query: str) -> dict[str, object]: + mock_prisma_client.db.litellm_auditlog.find_many.return_value = [] + mock_prisma_client.db.litellm_auditlog.count.return_value = 0 + + response: Final = client.get(f"/audit?{query}") + + assert response.status_code == 200, response.text + find_many_where: Final = mock_prisma_client.db.litellm_auditlog.find_many.call_args.kwargs["where"] + assert mock_prisma_client.db.litellm_auditlog.count.call_args.kwargs["where"] == find_many_where + return find_many_where + + +def test_search_matches_any_id_column_alongside_the_other_filters(mock_prisma_client): + where: Final = _list_audit_logs_where(mock_prisma_client, "search=abc-123&action=create&object_team_id=team-1") + + assert where == { + "action": "create", + "AND": ( + { + "OR": [ + {"before_value": {"path": ["team_id"], "string_contains": "team-1"}}, + {"updated_values": {"path": ["team_id"], "string_contains": "team-1"}}, + ] + }, + { + "OR": ( + {"id": "abc-123"}, + {"changed_by": "abc-123"}, + {"object_id": "abc-123"}, + {"changed_by_api_key": "abc-123"}, + ) + }, + ), + } + + +def test_an_empty_search_leaves_the_where_clause_unchanged(mock_prisma_client): + where: Final = _list_audit_logs_where(mock_prisma_client, "action=create&search=") + + assert where == {"action": "create"} diff --git a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py index 0b6832d4bef..311ba7aebc0 100644 --- a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py +++ b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py @@ -29,11 +29,13 @@ from litellm.types.utils import ( from litellm.litellm_core_utils.llm_cost_calc.utils import ( CostCalculatorUtils, PromptTokensDetailsResult, + TokenRates, TokenTypeCostBreakdown, _calculate_input_cost, _get_token_base_cost, _is_off_peak, _is_within_off_peak_window, + apply_off_peak_pricing, calculate_cache_writing_cost, generic_cost_per_token, get_token_type_cost_breakdown, @@ -782,6 +784,258 @@ def test_get_token_base_cost_off_peak_wins_over_tiered_pricing(): assert outside[:2] == (3e-6, 6e-6) +def _register_off_peak_reasoning_model( + model_name: str, off_peak_pricing: dict, reasoning_rate: float | None = 4e-6, **service_tier_rates: float +) -> None: + reasoning_entry = {} if reasoning_rate is None else {"output_cost_per_reasoning_token": reasoning_rate} + litellm.register_model( + { + model_name: { + "litellm_provider": "openai", + "mode": "chat", + "input_cost_per_token": 1e-6, + "output_cost_per_token": 2e-6, + "cache_read_input_token_cost": 1e-7, + "cache_creation_input_token_cost": 1.25e-6, + "off_peak_pricing": off_peak_pricing, + **reasoning_entry, + **service_tier_rates, + } + } + ) + + +def _off_peak_reasoning_usage() -> Usage: + return Usage( + prompt_tokens=100, + completion_tokens=80, + total_tokens=180, + completion_tokens_details=CompletionTokensDetailsWrapper(reasoning_tokens=30, text_tokens=50), + ) + + +def test_generic_cost_per_token_off_peak_reasoning_rate(): + from datetime import datetime, timezone + + model_name = "litellm-test-off-peak-reasoning" + _register_off_peak_reasoning_model( + model_name, + {"hours_utc": "16:30-00:30", "output_cost_per_token": 1e-6, "output_cost_per_reasoning_token": 5e-7}, + ) + + _, inside = generic_cost_per_token( + model=model_name, + usage=_off_peak_reasoning_usage(), + custom_llm_provider="openai", + current_time=datetime(2026, 1, 1, 18, 0, tzinfo=timezone.utc), + ) + assert inside == pytest.approx(50 * 1e-6 + 30 * 5e-7) + + _, outside = generic_cost_per_token( + model=model_name, + usage=_off_peak_reasoning_usage(), + custom_llm_provider="openai", + current_time=datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc), + ) + assert outside == pytest.approx(50 * 2e-6 + 30 * 4e-6) + + +def test_generic_cost_per_token_off_peak_block_without_reasoning_rate(): + from datetime import datetime, timezone + + inside_window = datetime(2026, 1, 1, 18, 0, tzinfo=timezone.utc) + block = {"hours_utc": "16:30-00:30", "output_cost_per_token": 1e-6} + + _register_off_peak_reasoning_model("litellm-test-off-peak-model-reasoning-rate", block) + _, with_model_rate = generic_cost_per_token( + model="litellm-test-off-peak-model-reasoning-rate", + usage=_off_peak_reasoning_usage(), + custom_llm_provider="openai", + current_time=inside_window, + ) + assert with_model_rate == pytest.approx(50 * 1e-6 + 30 * 4e-6) + + _register_off_peak_reasoning_model("litellm-test-off-peak-no-reasoning-rate", block, reasoning_rate=None) + _, without_model_rate = generic_cost_per_token( + model="litellm-test-off-peak-no-reasoning-rate", + usage=_off_peak_reasoning_usage(), + custom_llm_provider="openai", + current_time=inside_window, + ) + assert without_model_rate == pytest.approx(80 * 1e-6) + + +def test_generic_cost_per_token_off_peak_reasoning_rate_wins_over_the_tier(): + from datetime import datetime, timezone + + model_name = "litellm-test-off-peak-tiered-reasoning" + litellm.register_model( + { + model_name: { + "litellm_provider": "openai", + "mode": "chat", + "tiered_pricing": [ + { + "range": [0, 128000], + "input_cost_per_token": 3e-6, + "output_cost_per_token": 6e-6, + "output_cost_per_reasoning_token": 8e-6, + }, + ], + "off_peak_pricing": { + "hours_utc": "16:30-00:30", + "output_cost_per_token": 1e-6, + "output_cost_per_reasoning_token": 5e-7, + }, + } + } + ) + + _, inside = generic_cost_per_token( + model=model_name, + usage=_off_peak_reasoning_usage(), + custom_llm_provider="openai", + current_time=datetime(2026, 1, 1, 18, 0, tzinfo=timezone.utc), + ) + assert inside == pytest.approx(50 * 1e-6 + 30 * 5e-7) + + _, outside = generic_cost_per_token( + model=model_name, + usage=_off_peak_reasoning_usage(), + custom_llm_provider="openai", + current_time=datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc), + ) + assert outside == pytest.approx(50 * 6e-6 + 30 * 8e-6) + + +def test_generic_cost_per_token_off_peak_reasoning_rate_wins_over_the_service_tier(): + from datetime import datetime, timezone + + model_name = "litellm-test-off-peak-reasoning-service-tier" + _register_off_peak_reasoning_model( + model_name, + {"hours_utc": "16:30-00:30", "output_cost_per_token": 1e-6, "output_cost_per_reasoning_token": 5e-7}, + output_cost_per_token_priority=3e-6, + output_cost_per_reasoning_token_priority=6e-6, + ) + + _, inside = generic_cost_per_token( + model=model_name, + usage=_off_peak_reasoning_usage(), + custom_llm_provider="openai", + service_tier="priority", + current_time=datetime(2026, 1, 1, 18, 0, tzinfo=timezone.utc), + ) + assert inside == pytest.approx(50 * 1e-6 + 30 * 5e-7) + + _, outside = generic_cost_per_token( + model=model_name, + usage=_off_peak_reasoning_usage(), + custom_llm_provider="openai", + service_tier="priority", + current_time=datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc), + ) + assert outside == pytest.approx(50 * 3e-6 + 30 * 6e-6) + + +def test_apply_off_peak_pricing_treats_bool_as_unset_and_parses_strings(): + from datetime import datetime, timezone + + model_name = "litellm-test-off-peak-odd-values" + _register_off_peak_reasoning_model( + model_name, + { + "hours_utc": "16:30-00:30", + "cache_creation_input_token_cost": True, + "output_cost_per_reasoning_token": "5e-7", + }, + ) + standard = TokenRates( + input_rate=1e-6, output_rate=2e-6, cache_read_rate=1e-7, cache_creation_rate=1.25e-6, reasoning_rate=4e-6 + ) + + rates = apply_off_peak_pricing( + litellm.get_model_info(model_name, custom_llm_provider="openai"), + datetime(2026, 1, 1, 18, 0, tzinfo=timezone.utc), + standard, + ) + assert rates.cache_creation_rate == 1.25e-6 + assert rates.reasoning_rate == 5e-7 + + +def test_get_token_base_cost_off_peak_cache_creation_rate(): + from datetime import datetime, timezone + from typing import cast + + from litellm.types.utils import ModelInfo + + model_info = cast( + ModelInfo, + { + "input_cost_per_token": 1e-6, + "output_cost_per_token": 2e-6, + "cache_creation_input_token_cost": 1.25e-6, + "cache_creation_input_token_cost_above_1hr": 2e-6, + "off_peak_pricing": {"hours_utc": "16:30-00:30", "cache_creation_input_token_cost": 5e-7}, + }, + ) + usage = Usage(prompt_tokens=100, completion_tokens=50, total_tokens=150) + inside_window = datetime(2026, 1, 1, 18, 0, tzinfo=timezone.utc) + + inside = _get_token_base_cost(model_info, usage, current_time=inside_window) + assert inside[2] == 5e-7 + assert inside[3] == 2e-6 + + outside = _get_token_base_cost(model_info, usage, current_time=datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc)) + assert outside[2] == 1.25e-6 + + without_key = cast( + ModelInfo, + {**model_info, "off_peak_pricing": {"hours_utc": "16:30-00:30", "input_cost_per_token": 5e-7}}, + ) + assert _get_token_base_cost(without_key, usage, current_time=inside_window)[2] == 1.25e-6 + + +def test_get_token_type_cost_breakdown_reflects_off_peak_reasoning_and_cache_creation_rates(): + from datetime import datetime, timezone + + model_name = "litellm-test-off-peak-breakdown" + _register_off_peak_reasoning_model( + model_name, + { + "hours_utc": "16:30-00:30", + "output_cost_per_reasoning_token": 5e-7, + "cache_creation_input_token_cost": 5e-7, + }, + ) + usage = Usage( + prompt_tokens=1000, + completion_tokens=80, + total_tokens=1080, + completion_tokens_details=CompletionTokensDetailsWrapper(reasoning_tokens=30, text_tokens=50), + prompt_tokens_details=PromptTokensDetailsWrapper(cached_tokens=100, cache_creation_tokens=400, text_tokens=500), + ) + + inside = get_token_type_cost_breakdown( + model=model_name, + custom_llm_provider="openai", + usage=usage, + current_time=datetime(2026, 1, 1, 18, 0, tzinfo=timezone.utc), + ) + assert inside.reasoning_cost == pytest.approx(30 * 5e-7) + assert inside.cache_creation_cost == pytest.approx(400 * 5e-7) + assert inside.cache_read_cost == pytest.approx(100 * 1e-7) + + outside = get_token_type_cost_breakdown( + model=model_name, + custom_llm_provider="openai", + usage=usage, + current_time=datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc), + ) + assert outside.reasoning_cost == pytest.approx(30 * 4e-6) + assert outside.cache_creation_cost == pytest.approx(400 * 1.25e-6) + + def test_generic_cost_per_token_gpt54_above_272k_tokens(_local_model_cost_map): """GPT-5.4/5.4-pro: prompts >272K input tokens priced at 2x input, 1.5x output.""" model = "gpt-5.4" diff --git a/tests/test_litellm/llms/dashscope/test_dashscope_cost_calculator.py b/tests/test_litellm/llms/dashscope/test_dashscope_cost_calculator.py index b6281834f24..a30d35d46f2 100644 --- a/tests/test_litellm/llms/dashscope/test_dashscope_cost_calculator.py +++ b/tests/test_litellm/llms/dashscope/test_dashscope_cost_calculator.py @@ -649,6 +649,90 @@ class TestDashscopeCostCalculator: assert math.isclose(completion_cost, 200 * 2.4e-06, rel_tol=1e-10) + def test_dashscope_off_peak_reasoning_rate_replaces_the_dedicated_reasoning_rate(self): + self._register_off_peak_flat_model( + "dashscope/qwen-reasoning-rate-off-peak-test", + { + "hours_utc": self.OFF_PEAK_WINDOW, + "output_cost_per_token": 2.4e-06, + "output_cost_per_reasoning_token": 4.5e-06, + }, + ) + litellm.model_cost["dashscope/qwen-reasoning-rate-off-peak-test"]["output_cost_per_reasoning_token"] = 9e-06 + usage = Usage( + prompt_tokens=100, + completion_tokens=200, + completion_tokens_details=CompletionTokensDetailsWrapper(reasoning_tokens=50), + ) + + _, completion_cost = dashscope_cost_per_token( + model="qwen-reasoning-rate-off-peak-test", usage=usage, current_time=self.INSIDE_WINDOW + ) + assert math.isclose(completion_cost, (150 * 2.4e-06) + (50 * 4.5e-06), rel_tol=1e-10) + + _, peak_completion_cost = dashscope_cost_per_token( + model="qwen-reasoning-rate-off-peak-test", usage=usage, current_time=self.OUTSIDE_WINDOW + ) + assert math.isclose(peak_completion_cost, (150 * 4.8e-06) + (50 * 9e-06), rel_tol=1e-10) + + def test_dashscope_off_peak_cache_creation_rate_replaces_the_standard_rate(self): + self._register_off_peak_flat_model( + "dashscope/qwen-cache-creation-off-peak-test", + {"hours_utc": self.OFF_PEAK_WINDOW, "cache_creation_input_token_cost": 1.5e-06}, + ) + usage = Usage( + prompt_tokens=1000, + completion_tokens=10, + prompt_tokens_details=PromptTokensDetailsWrapper(cached_tokens=300, cache_creation_tokens=100), + ) + + prompt_cost, _ = dashscope_cost_per_token( + model="qwen-cache-creation-off-peak-test", usage=usage, current_time=self.INSIDE_WINDOW + ) + assert math.isclose(prompt_cost, (600 * 2.4e-06) + (300 * 2e-07) + (100 * 1.5e-06), rel_tol=1e-10) + + peak_prompt_cost, _ = dashscope_cost_per_token( + model="qwen-cache-creation-off-peak-test", usage=usage, current_time=self.OUTSIDE_WINDOW + ) + assert math.isclose(peak_prompt_cost, (600 * 2.4e-06) + (300 * 2e-07) + (100 * 3e-06), rel_tol=1e-10) + + def test_dashscope_off_peak_reasoning_and_cache_creation_rates_override_the_selected_tier(self): + self._register_tiered_model( + "dashscope/qwen-tiered-reasoning-off-peak-test", + [ + { + "range": [0, 1000], + "input_cost_per_token": 4e-07, + "cache_creation_input_token_cost": 3e-07, + "output_cost_per_token": 1.6e-06, + "output_cost_per_reasoning_token": 3.2e-06, + }, + ], + ) + litellm.model_cost["dashscope/qwen-tiered-reasoning-off-peak-test"]["off_peak_pricing"] = { + "hours_utc": self.OFF_PEAK_WINDOW, + "cache_creation_input_token_cost": 1e-07, + "output_cost_per_reasoning_token": 8e-07, + } + usage = Usage( + prompt_tokens=500, + completion_tokens=100, + prompt_tokens_details=PromptTokensDetailsWrapper(cache_creation_tokens=200), + completion_tokens_details=CompletionTokensDetailsWrapper(reasoning_tokens=40), + ) + + prompt_cost, completion_cost = dashscope_cost_per_token( + model="qwen-tiered-reasoning-off-peak-test", usage=usage, current_time=self.INSIDE_WINDOW + ) + assert math.isclose(prompt_cost, (300 * 4e-07) + (200 * 1e-07), rel_tol=1e-10) + assert math.isclose(completion_cost, (60 * 1.6e-06) + (40 * 8e-07), rel_tol=1e-10) + + peak_prompt_cost, peak_completion_cost = dashscope_cost_per_token( + model="qwen-tiered-reasoning-off-peak-test", usage=usage, current_time=self.OUTSIDE_WINDOW + ) + assert math.isclose(peak_prompt_cost, (300 * 4e-07) + (200 * 3e-07), rel_tol=1e-10) + assert math.isclose(peak_completion_cost, (60 * 1.6e-06) + (40 * 3.2e-06), rel_tol=1e-10) + def test_dashscope_off_peak_defaults_to_the_current_time(self): """The proxy's cost dispatch passes no clock, so an all-day window has to apply on the default current time.""" diff --git a/tests/test_litellm/llms/openai/test_openai_workload_identity.py b/tests/test_litellm/llms/openai/test_openai_workload_identity.py index d8d9936e9a1..db107e00df0 100644 --- a/tests/test_litellm/llms/openai/test_openai_workload_identity.py +++ b/tests/test_litellm/llms/openai/test_openai_workload_identity.py @@ -85,6 +85,31 @@ class TestResolveConfig: def test_plaintext_http_api_base_disables(self, wif_env: OpenAIWorkloadIdentityConfig) -> None: assert resolve_openai_workload_identity_config(api_key=None, api_base="http://api.openai.com/v1") is None + @pytest.mark.parametrize( + "api_base", + ( + "https://southcentralus.privatelink.api.openai.com/v1", + "https://eu.api.openai.com/v1", + "https://us.api.openai.com/v1", + ), + ) + def test_openai_backed_api_base_allows(self, wif_env: OpenAIWorkloadIdentityConfig, api_base: str) -> None: + assert resolve_openai_workload_identity_config(api_key=None, api_base=api_base) == wif_env + + @pytest.mark.parametrize( + "api_base", + ( + "https://api.openai.com.evil.example/v1", + "https://openai.com/v1", + "https://euapi.openai.com/v1", + "http://southcentralus.privatelink.api.openai.com/v1", + ), + ) + def test_lookalike_or_plaintext_api_base_disables( + self, wif_env: OpenAIWorkloadIdentityConfig, api_base: str + ) -> None: + assert resolve_openai_workload_identity_config(api_key=None, api_base=api_base) is None + def test_foreign_env_base_url_disables( self, wif_env: OpenAIWorkloadIdentityConfig, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -158,6 +183,14 @@ class TestClientConstruction: assert client.api_key == "workload-identity-auth" assert client._workload_identity_auth is not None + def test_privatelink_client_uses_workload_identity(self, wif_env: OpenAIWorkloadIdentityConfig) -> None: + client: Final = OpenAIChatCompletion()._get_openai_client( + is_async=False, api_key=None, api_base="https://southcentralus.privatelink.api.openai.com/v1" + ) + assert isinstance(client, OpenAI) + assert client.api_key == "workload-identity-auth" + assert client._workload_identity_auth is not None + def test_static_key_client_unaffected(self, wif_env: OpenAIWorkloadIdentityConfig) -> None: client: Final = OpenAIChatCompletion()._get_openai_client(is_async=False, api_key="sk-static", api_base=None) assert isinstance(client, OpenAI) @@ -231,6 +264,16 @@ class TestResponsesValidateEnvironment: ) assert headers["Authorization"] == "Bearer None" + @respx.mock + def test_privatelink_api_base_mints_bearer(self, wif_env: OpenAIWorkloadIdentityConfig) -> None: + mock_token_exchange() + headers: Final = OpenAIResponsesAPIConfig().validate_environment( + headers={}, + model="gpt-4o-mini", + litellm_params=GenericLiteLLMParams(api_base="https://southcentralus.privatelink.api.openai.com/v1"), + ) + assert headers["Authorization"] == "Bearer exchanged-bearer-token" + def test_litellm_proxy_subclass_never_mints_wif(self, wif_env: OpenAIWorkloadIdentityConfig) -> None: headers: Final = LiteLLMProxyResponsesAPIConfig().validate_environment( headers={}, model="gpt-4o-mini", litellm_params=GenericLiteLLMParams() diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index 7954a4693cc..0e4af9f75a5 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -6347,6 +6347,93 @@ def test_build_key_filter_conditions_key_hash_narrows_team_admin_visibility(): assert {"token": "hashed-token-123"} in where["AND"], f"key_hash not ANDed: {where}" +def _search_clause(search: str, token: str) -> dict: + return {"OR": [{"token": token}, {"key_alias": {"contains": search, "mode": "insensitive"}}]} + + +def test_build_key_filter_conditions_search_ors_token_and_alias_contains(): + """ + LIT-4741: `search` matches a key by its alias (case-insensitive contains) OR by + its ID (the token column), with the pasted value used verbatim. + """ + from litellm.proxy.management_endpoints.key_management_endpoints import ( + _build_key_filter_conditions, + ) + + hashed_where = json.loads( + json.dumps( + _build_key_filter_conditions( + user_id=None, + team_id=None, + organization_id=None, + key_alias=None, + key_hash=None, + exclude_team_id=None, + admin_team_ids=None, + search="already-hashed-token", + ) + ) + ) + assert _search_clause("already-hashed-token", "already-hashed-token") in hashed_where["AND"], ( + f"hashed search not used verbatim: {hashed_where}" + ) + + +def test_build_key_filter_conditions_search_narrows_team_admin_visibility(): + """ + LIT-4741, same class as LIT-3243: `search` must be a top-level AND so it + narrows a team admin's admin-team branch instead of being bypassed by it. + """ + from litellm.proxy.management_endpoints.key_management_endpoints import ( + _build_key_filter_conditions, + ) + + where = json.loads( + json.dumps( + _build_key_filter_conditions( + user_id="team-admin-user", + team_id=None, + organization_id=None, + key_alias=None, + key_hash=None, + exclude_team_id=None, + admin_team_ids=["team-a"], + member_team_ids=["team-a"], + include_created_by_keys=False, + search="member-key-id", + ) + ) + ) + + assert where.get("AND"), f"expected top-level AND, got: {where}" + assert _search_clause("member-key-id", "member-key-id") in where["AND"], f"search not ANDed: {where}" + assert json.dumps({"team_id": {"in": ["team-a"]}}) in json.dumps(where) + + +@pytest.mark.asyncio +async def test_list_key_helper_applies_search_to_prisma_where(): + """LIT-4741: `search` given to _list_key_helper must reach the Prisma where clause.""" + mock_prisma_client = AsyncMock() + mock_find_many = AsyncMock(return_value=[]) + mock_prisma_client.db.litellm_verificationtoken.find_many = mock_find_many + mock_prisma_client.db.litellm_verificationtoken.count = AsyncMock(return_value=0) + + await _list_key_helper( + prisma_client=mock_prisma_client, + page=1, + size=50, + user_id=None, + team_id=None, + organization_id=None, + key_alias=None, + key_hash=None, + search="key-id-123", + ) + + where = json.loads(json.dumps(mock_find_many.call_args.kwargs["where"])) + assert _search_clause("key-id-123", "key-id-123") in where["AND"], f"search not in Prisma where: {where}" + + @pytest.mark.asyncio async def test_generate_key_negative_max_budget(): """ @@ -14870,6 +14957,16 @@ async def test_list_keys_non_admin_cannot_opt_into_substring(): assert kwargs["user_id"] == "alice" +@pytest.mark.asyncio +async def test_list_keys_search_is_honored_for_non_admin(): + """LIT-4741: unlike substring_matching, `search` is not admin-gated. A non-admin's + search reaches the helper while their own-user scoping stays in place.""" + user = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="alice") + kwargs = await _list_keys_capture_helper_kwargs(user, user_id=None, search="key-id-123") + assert kwargs["search"] == "key-id-123" + assert kwargs["user_id"] == "alice" + + @pytest.mark.asyncio async def test_cli_session_token_delegation_ceiling_blocked_by_team_budget(): team = LiteLLM_TeamTableCachedObj(team_id="team-1", max_budget=50.0) diff --git a/tests/test_litellm/proxy/memory/test_memory_endpoints.py b/tests/test_litellm/proxy/memory/test_memory_endpoints.py index be75d980d9d..dff0e80fa77 100644 --- a/tests/test_litellm/proxy/memory/test_memory_endpoints.py +++ b/tests/test_litellm/proxy/memory/test_memory_endpoints.py @@ -615,6 +615,96 @@ class TestMemoryEndpoints: assert keys == {"user:profile"} assert body["total"] == 1 + def test_list_memory_search_matches_key_prefix_or_memory_id_within_scope(self): + """ + `search` matches a key prefix OR an exact memory_id, and stays ANDed + with the visibility filter so a pasted foreign id cannot leak a row. + """ + table = self.prisma.db.litellm_memorytable + table.rows.extend( + [ + _make_row(memory_id="mem-own", key="user:profile", user_id="user-a", team_id=None), + _make_row(memory_id="mem-target", key="project:context", user_id="user-a", team_id=None), + _make_row(memory_id="mem-foreign", key="user:secret", user_id="user-b", team_id=None), + ] + ) + client = _make_client(_user_auth("user-a", "team-a")) + with _patch_prisma(self.prisma): + by_id = client.get("/v1/memory?search=mem-target") + by_prefix = client.get("/v1/memory?search=user:") + foreign_id = client.get("/v1/memory?search=mem-foreign") + + assert by_id.status_code == 200, by_id.text + assert [m["memory_id"] for m in by_id.json()["memories"]] == ["mem-target"] + assert by_id.json()["total"] == 1 + + assert by_prefix.status_code == 200, by_prefix.text + assert {m["key"] for m in by_prefix.json()["memories"]} == {"user:profile"} + assert by_prefix.json()["total"] == 1 + + assert foreign_id.status_code == 200, foreign_id.text + assert foreign_id.json()["memories"] == [] + assert foreign_id.json()["total"] == 0 + + def test_list_memory_search_by_memory_id_for_admin_sees_any_scope(self): + """Admins have no visibility filter, so an id search returns the row whoever owns it.""" + table = self.prisma.db.litellm_memorytable + table.rows.extend( + [ + _make_row(memory_id="mem-a", key="a", user_id="user-a", team_id=None), + _make_row(memory_id="mem-b", key="b", user_id="user-b", team_id=None), + ] + ) + client = _make_client(_admin_auth()) + with _patch_prisma(self.prisma): + resp = client.get("/v1/memory?search=mem-b") + assert resp.status_code == 200, resp.text + assert [m["memory_id"] for m in resp.json()["memories"]] == ["mem-b"] + assert resp.json()["total"] == 1 + + def test_list_memory_search_wins_over_key_prefix(self): + """When both are sent, `search` decides the match and `key_prefix` is ignored.""" + table = self.prisma.db.litellm_memorytable + table.rows.extend( + [ + _make_row(memory_id="mem-own", key="user:profile", user_id="user-a", team_id=None), + _make_row(memory_id="mem-target", key="project:context", user_id="user-a", team_id=None), + ] + ) + client = _make_client(_user_auth("user-a", "team-a")) + with _patch_prisma(self.prisma): + resp = client.get("/v1/memory?search=mem-target&key_prefix=user:") + assert resp.status_code == 200, resp.text + assert [m["memory_id"] for m in resp.json()["memories"]] == ["mem-target"] + assert resp.json()["total"] == 1 + + def test_list_memory_key_prefix_never_matches_memory_id(self): + """`key_prefix` stays a pure key-prefix match; only `search` consults memory_id.""" + table = self.prisma.db.litellm_memorytable + table.rows.append(_make_row(memory_id="mem-target", key="project:context", user_id="user-a", team_id=None)) + client = _make_client(_user_auth("user-a", "team-a")) + with _patch_prisma(self.prisma): + resp = client.get("/v1/memory?key_prefix=mem-target") + assert resp.status_code == 200, resp.text + assert resp.json()["memories"] == [] + assert resp.json()["total"] == 0 + + def test_list_memory_key_exact_filter(self): + """`key` is an exact match, never a prefix.""" + table = self.prisma.db.litellm_memorytable + table.rows.extend( + [ + _make_row(memory_id="m1", key="user:profile", user_id="user-a", team_id=None), + _make_row(memory_id="m2", key="user:profile:archived", user_id="user-a", team_id=None), + ] + ) + client = _make_client(_user_auth("user-a", "team-a")) + with _patch_prisma(self.prisma): + resp = client.get("/v1/memory?key=user:profile") + assert resp.status_code == 200, resp.text + assert [m["memory_id"] for m in resp.json()["memories"]] == ["m1"] + assert resp.json()["total"] == 1 + def test_list_memory_admin_sees_all(self): table = self.prisma.db.litellm_memorytable table.rows.extend( diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py index f4cd8814bc1..73a29afd9b9 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py +++ b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py @@ -58,6 +58,24 @@ def _filter_logs_by_date_range(logs, where): return filtered +_SEARCH_CLAUSE_RE = re.compile( + r'\(request_id = \$(\d+) OR \("startTime" >= \(\$(\d+)::timestamptz AT TIME ZONE \'UTC\'\) ' + r'AND "startTime" <= \(\$(\d+)::timestamptz AT TIME ZONE \'UTC\'\) ' + r'AND \(api_key = \$\1 OR team_id = \$\1 OR "user" = \$\1 OR end_user = \$\1 ' + r"OR session_id = \$\1 OR model_id = \$\1\)\)\)" +) + + +def _matches_spend_log_search(log, search): + """Mirror the search clause: request_id across all time, the other id columns inside the window.""" + if log.get("request_id") == search["value"]: + return True + if not _filter_logs_by_date_range([log], {"startTime": {"gte": search["gte"], "lte": search["lte"]}}): + return False + columns = ("api_key", "team_id", "user", "end_user", "session_id", "model_id") + return any(log.get(col) == search["value"] for col in columns) + + def _reconstruct_ui_where_from_sql(sql_query, params): """ Rebuild the Prisma-style ``where`` dict the filter_fns below expect from the @@ -77,6 +95,16 @@ def _reconstruct_ui_where_from_sql(sql_query, params): def _iso(value): return value.isoformat() if hasattr(value, "isoformat") else str(value) + search_clause = _SEARCH_CLAUSE_RE.search(clause.group(1)) + if search_clause: + raw_index, start_index, end_index = (int(g) for g in search_clause.groups()) + where["search"] = { + "value": params[raw_index - 1], + "gte": _iso(params[start_index - 1]), + "lte": _iso(params[end_index - 1]), + } + remaining = clause.group(1) if search_clause is None else clause.group(1).replace(search_clause.group(0), "") + eq_cols = { "team_id": "team_id", '"user"': "user", @@ -89,7 +117,7 @@ def _reconstruct_ui_where_from_sql(sql_query, params): } date_bounds: dict = {} metadata_conds: list = [] - for cond in (c.strip() for c in clause.group(1).split(" AND ")): + for cond in (c.strip() for c in remaining.split(" AND ")): gte = re.search(r'"startTime" >= \(\$(\d+)', cond) lte = re.search(r'"startTime" <= \(\$(\d+)', cond) alias = re.search(r"user_api_key_alias' LIKE \$(\d+)", cond) @@ -2352,6 +2380,208 @@ async def test_ui_view_spend_logs_request_id_owner_scoped_by_id_only( app.dependency_overrides.pop(ps.user_api_key_auth, None) +def test_build_spend_log_search_condition_windows_every_branch_except_request_id(): + """LIT-4741: request_id matches across all time; the six other id columns only inside the window, + all comparing the pasted value verbatim.""" + start = datetime.datetime(2026, 8, 1, tzinfo=timezone.utc) + end = datetime.datetime(2026, 8, 2, tzinfo=timezone.utc) + + condition = spend_management_endpoints._build_spend_log_search_condition( + search="key-hash-7", start_date=start, end_date=end, next_param_index=3 + ) + + assert condition.sql == ( + "(request_id = $3 OR (\"startTime\" >= ($4::timestamptz AT TIME ZONE 'UTC') " + "AND \"startTime\" <= ($5::timestamptz AT TIME ZONE 'UTC') " + 'AND (api_key = $3 OR team_id = $3 OR "user" = $3 OR end_user = $3 OR session_id = $3 OR model_id = $3)))' + ) + assert condition.params == ("key-hash-7", start, end) + + +def _search_fixture_logs(today): + recent = (today - datetime.timedelta(days=1)).isoformat() + old = (today - datetime.timedelta(days=90)).isoformat() + base = { + "api_key": "hashed-other", + "user": "user-x", + "team_id": "team-x", + "end_user": "cust-x", + "session_id": "sess-x", + "model_id": "mdl-x", + "spend": 0.01, + "model": "gpt-4", + } + return [ + {**base, "request_id": "req-session", "session_id": "sess-42", "startTime": recent}, + {**base, "request_id": "req-session-old", "session_id": "sess-42", "startTime": old}, + {**base, "request_id": "req-key", "api_key": "hashed-7", "startTime": recent}, + {**base, "request_id": "req-team", "team_id": "team-7", "startTime": recent}, + {**base, "request_id": "req-user", "user": "user-7", "startTime": recent}, + {**base, "request_id": "req-end-user", "end_user": "cust-7", "startTime": recent}, + {**base, "request_id": "req-model", "model_id": "mdl-7", "startTime": recent}, + ] + + +def _search_filter_fn(logs, captured): + def filter_fn(where): + captured["where"] = where + rows = _filter_logs_by_date_range(logs, where) + if "user" in where: + rows = [row for row in rows if row["user"] == where["user"]] + if "search" in where: + rows = [row for row in rows if _matches_spend_log_search(row, where["search"])] + return rows + + return filter_fn + + +def _five_day_window(today): + return { + "start_date": (today - datetime.timedelta(days=5)).strftime("%Y-%m-%d %H:%M:%S"), + "end_date": today.strftime("%Y-%m-%d %H:%M:%S"), + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "search,expected_request_ids", + [ + ("req-session-old", {"req-session-old"}), + ("sess-42", {"req-session"}), + ("hashed-7", {"req-key"}), + ("team-7", {"req-team"}), + ("user-7", {"req-user"}), + ("cust-7", {"req-end-user"}), + ("mdl-7", {"req-model"}), + ("no-such-id", set()), + ], +) +async def test_ui_view_spend_logs_search_matches_any_id(client, monkeypatch, search, expected_request_ids): + """LIT-4741: one box matches any id column. A request_id is found across all time (the 5-day + window excludes the 90-day-old row), every other column only inside the window, and a raw + sk- key is hashed before it is compared with api_key. The window is not applied globally.""" + today = datetime.datetime.now(timezone.utc) + logs = _search_fixture_logs(today) + captured = {} + monkeypatch.setattr( + "litellm.proxy.proxy_server.prisma_client", + make_ui_spend_logs_mock_prisma(logs, _search_filter_fn(logs, captured)), + ) + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN) + try: + response = client.get( + "/spend/logs/ui", + params={"search": search, **_five_day_window(today)}, + headers={"Authorization": "Bearer sk-test"}, + ) + assert response.status_code == 200, response.text + data = response.json() + assert {row["request_id"] for row in data["data"]} == expected_request_ids + assert data["total"] == len(expected_request_ids) + assert "startTime" not in captured["where"] + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + +@pytest.mark.asyncio +async def test_spend_logs_v2_search_keeps_global_window(client, monkeypatch): + """The public route keeps the caller's window on the whole query, so a search only finds rows + inside it even by request_id; the windowless request_id branch is a dashboard-only relaxation.""" + today = datetime.datetime.now(timezone.utc) + logs = _search_fixture_logs(today) + captured = {} + monkeypatch.setattr( + "litellm.proxy.proxy_server.prisma_client", + make_ui_spend_logs_mock_prisma(logs, _search_filter_fn(logs, captured)), + ) + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN) + try: + response = client.get( + "/spend/logs/v2", + params={"search": "req-session-old", **_five_day_window(today)}, + headers={"Authorization": "Bearer sk-test"}, + ) + assert response.status_code == 200, response.text + data = response.json() + assert data["data"] == [] + assert data["total"] == 0 + assert "startTime" in captured["where"] + assert captured["where"]["search"]["value"] == "req-session-old" + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "params", + [ + {"search": "req-old"}, + {"search": "req-old", "request_id": "req-old"}, + ], +) +async def test_ui_view_spend_logs_search_requires_dates(client, monkeypatch, params): + """A search needs the window for its non-request_id branches, so it stays required even + alongside a request_id, which on its own may drop 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/ui", params=params, 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 +@pytest.mark.parametrize( + "search,expected_request_ids", + [("sess-9", {"req-own"}), ("req-foreign", set())], +) +async def test_ui_view_spend_logs_search_keeps_non_admin_scope(client, monkeypatch, search, expected_request_ids): + """A search is scoped like any other listing: an internal user only sees their own rows even + when the id is on someone else's row, and the request_id ownership shortcut is not used.""" + yesterday = (datetime.datetime.now(timezone.utc) - datetime.timedelta(days=1)).isoformat() + base = {"api_key": "hashed-key", "team_id": None, "spend": 0.01, "startTime": yesterday, "model": "gpt-4"} + logs = [ + {**base, "request_id": "req-own", "user": "internal_user_1", "session_id": "sess-9"}, + {**base, "request_id": "req-own-other", "user": "internal_user_1", "session_id": "sess-other"}, + {**base, "request_id": "req-foreign", "user": "internal_user_2", "session_id": "sess-9"}, + ] + captured = {} + monkeypatch.setattr( + "litellm.proxy.proxy_server.prisma_client", + make_ui_spend_logs_mock_prisma(logs, _search_filter_fn(logs, captured)), + ) + monkeypatch.setattr( + "litellm.proxy.spend_tracking.spend_management_endpoints._get_permitted_team_ids_for_spend_logs", + AsyncMock(return_value=[]), + ) + ownership_check = AsyncMock() + monkeypatch.setattr( + "litellm.proxy.spend_tracking.spend_management_endpoints._assert_user_can_view_request_id", + ownership_check, + ) + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, user_id="internal_user_1" + ) + try: + start_date, end_date = _default_date_range() + response = client.get( + "/spend/logs/ui", + params={"search": search, "start_date": start_date, "end_date": end_date}, + headers={"Authorization": "Bearer sk-test"}, + ) + assert response.status_code == 200, response.text + assert {row["request_id"] for row in response.json()["data"]} == expected_request_ids + assert captured["where"]["user"] == "internal_user_1" + ownership_check.assert_not_awaited() + 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 @@ -6351,3 +6581,46 @@ async def test_ui_view_spend_logs_group_by_session_offset_for_non_starttime_sort assert "OFFSET" in emitted_sql[1] finally: app.dependency_overrides.pop(ps.user_api_key_auth, None) + + +@pytest.mark.asyncio +async def test_ui_view_spend_logs_search_returns_flat_rows_when_grouping_by_session(client, monkeypatch): + """The dashboard lists sessions by default; a search for an id lists every matching row instead, + so both calls of a session show up rather than one representative, and no session cursor is returned.""" + rows = [_session_representative_row("req-1", "sess-1"), _session_representative_row("req-2", "sess-1")] + + async def mock_query_raw(sql_query, *params): + if "mcp_tool_call_count" in sql_query: + return [] + grouped = "DISTINCT ON" in sql_query or "GROUP BY" in sql_query + visible = rows[:1] if grouped else rows + if "COUNT(*)" in sql_query: + return [{"total_count": len(visible)}] + return visible + + mock_prisma = MagicMock() + mock_prisma.db = MagicMock() + mock_prisma.db.query_raw = AsyncMock(side_effect=mock_query_raw) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma) + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user" + ) + try: + start_date, end_date = _default_date_range() + response = client.get( + "/spend/logs/ui", + params={ + "search": "sess-1", + "group_by_session": "true", + "start_date": start_date, + "end_date": end_date, + }, + headers={"Authorization": "Bearer sk-test"}, + ) + assert response.status_code == 200, response.text + data = response.json() + assert [row["request_id"] for row in data["data"]] == ["req-1", "req-2"] + assert data["total"] == 2 + assert "next_session_cursor" not in data + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_query_optimization.py b/tests/test_litellm/proxy/spend_tracking/test_spend_query_optimization.py index 9ae932ff01f..a7de3f1d8d6 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_spend_query_optimization.py +++ b/tests/test_litellm/proxy/spend_tracking/test_spend_query_optimization.py @@ -184,6 +184,7 @@ async def test_spend_logs_ui_wraps_params_in_at_time_zone_utc(monkeypatch): api_key=None, user_id=None, request_id=None, + search=None, start_date="2026-02-16 00:00:00", end_date="2026-02-16 23:59:59", page=1, @@ -247,6 +248,7 @@ async def test_spend_logs_ui_uses_bounded_count_not_full_scan(monkeypatch): api_key=None, user_id=None, request_id=None, + search=None, start_date="2026-02-16 00:00:00", end_date="2026-02-16 23:59:59", page=1, @@ -314,6 +316,7 @@ async def test_spend_logs_ui_caps_total_for_large_result_sets(monkeypatch): api_key=None, user_id=None, request_id=None, + search=None, start_date="2026-02-16 00:00:00", end_date="2026-02-16 23:59:59", page=1, @@ -359,6 +362,7 @@ async def test_spend_logs_ui_empty_page_reports_zero_total(monkeypatch): api_key=None, user_id=None, request_id=None, + search=None, start_date="2026-02-16 00:00:00", end_date="2026-02-16 23:59:59", page=1, @@ -406,6 +410,7 @@ async def test_spend_logs_ui_out_of_range_page_keeps_total(monkeypatch): api_key=None, user_id=None, request_id=None, + search=None, start_date="2026-02-16 00:00:00", end_date="2026-02-16 23:59:59", page=99, @@ -552,6 +557,7 @@ async def test_spend_logs_ui_group_by_session_paginates_sessions(monkeypatch): api_key=None, user_id=None, request_id=None, + search=None, start_date="2026-02-16 00:00:00", end_date="2026-02-16 23:59:59", page=1, @@ -616,6 +622,7 @@ async def test_spend_logs_ui_group_by_session_offset_pages_for_other_sorts(monke api_key=None, user_id=None, request_id=None, + search=None, start_date="2026-02-16 00:00:00", end_date="2026-02-16 23:59:59", page=2, @@ -664,6 +671,7 @@ async def test_spend_logs_ui_request_id_lookup_with_grouping_returns_exact_row(m api_key=None, user_id=None, request_id="req-deep-link", + search=None, start_date=None, end_date=None, page=1, diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index e4236afc586..228588d974f 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -7362,14 +7362,14 @@ def test_get_configured_mode_reads_deployment_model_info(): router = litellm.Router( model_list=[ { - "model_name": "chat-model", - "litellm_params": {"model": "openai/some-unmapped-model"}, - "model_info": {"mode": "chat"}, + "model_name": "tts-model", + "litellm_params": {"model": "openai/some-unmapped-tts-model"}, + "model_info": {"mode": "audio_speech"}, } ] ) - assert router.get_configured_mode("chat-model") == "chat" + assert router.get_configured_mode("tts-model") == "audio_speech" def test_get_configured_mode_returns_none_for_unset_or_unknown(): @@ -12701,33 +12701,3 @@ async def test_prompt_management_factory_marks_injection_for_every_deployment(mo bucket = captured.get("litellm_metadata") or captured["metadata"] assert captured["model_info"]["id"] == "provisional-dep" assert bucket["litellm_gateway_injected_cache"] == "" - - -def test_get_configured_mode_reads_deployment_model_info(): - router = Router( - model_list=[ - { - "model_name": "my-tts", - "litellm_params": {"model": "openai/some-unmapped-mode-model"}, - "model_info": {"mode": "audio_speech"}, - } - ] - ) - - assert router.get_configured_mode("my-tts") == "audio_speech" - - -@pytest.mark.parametrize("model_info", [{}, {"mode": ""}, {"mode": " "}, {"mode": 123}]) -def test_get_configured_mode_returns_none_for_unset_blank_or_unknown(model_info): - router = Router( - model_list=[ - { - "model_name": "plain-model", - "litellm_params": {"model": "openai/some-unmapped-mode-model"}, - "model_info": model_info, - } - ] - ) - - assert router.get_configured_mode("plain-model") is None - assert router.get_configured_mode("unknown-model") is None diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsTable.test.tsx index 4d18ec2ef5f..bef938cd31c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsTable.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsTable.test.tsx @@ -90,7 +90,7 @@ describe("AgentsTable", () => { />, ); - const search = screen.getByPlaceholderText("Search agent names or descriptions..."); + const search = screen.getByPlaceholderText("Search agents by name, ID, or description..."); await user.type(search, "billing"); expect(screen.getByText("Billing Router")).toBeInTheDocument(); expect(screen.queryByText("Second Agent")).not.toBeInTheDocument(); @@ -101,11 +101,36 @@ describe("AgentsTable", () => { expect(screen.queryByText("Billing Router")).not.toBeInTheDocument(); }); + it("filters agents by a pasted agent_id so only that agent's row survives", async () => { + const user = userEvent.setup(); + render( + , + ); + + const search = screen.getByPlaceholderText("Search agents by name, ID, or description..."); + await user.click(search); + await user.paste("5f3c2a1b-9d8e-4f7a-b6c5-d4e3f2a1b0c9"); + expect(screen.getByText("Billing Router")).toBeInTheDocument(); + expect(screen.queryByText("Second Agent")).not.toBeInTheDocument(); + + await user.clear(search); + await user.paste("ffffffff-0000-4000-8000-000000000000"); + expect(screen.queryByText("Billing Router")).not.toBeInTheDocument(); + expect(screen.queryByText("Second Agent")).not.toBeInTheDocument(); + expect(screen.getByText("No matching agents")).toBeInTheDocument(); + }); + it("shows the no-match empty state when the search matches nothing", async () => { const user = userEvent.setup(); render(); - await user.type(screen.getByPlaceholderText("Search agent names or descriptions..."), "zzzz"); + await user.type(screen.getByPlaceholderText("Search agents by name, ID, or description..."), "zzzz"); expect(screen.queryByText("Test Agent")).not.toBeInTheDocument(); expect(screen.getByText("No matching agents")).toBeInTheDocument(); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsTable.tsx index 35ed6b66425..aceb07e2e9a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsTable.tsx @@ -55,7 +55,12 @@ const AgentsTable: React.FC = ({ const [sorting, setSorting] = useState(DEFAULT_SORTING); const [searchTerm, setSearchTerm] = useState(""); const filteredAgents = useMemo( - () => filterBySearchTerm(agents, searchTerm, (agent) => [agent.agent_name, agent.agent_card_params?.description]), + () => + filterBySearchTerm(agents, searchTerm, (agent) => [ + agent.agent_name, + agent.agent_id, + agent.agent_card_params?.description, + ]), [agents, searchTerm], ); @@ -83,7 +88,7 @@ const AgentsTable: React.FC = ({ setSearchTerm(e.target.value)} /> diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.test.ts index 8c9b33f2c3e..84be7e2ef49 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.test.ts @@ -518,6 +518,24 @@ describe("useKeys", () => { const callUrl = mockFetch.mock.calls[0][0]; expect(callUrl).not.toContain("agent_id"); }); + + it("sends the combined alias-or-ID search as the search param, separate from key_alias and key_hash", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockKeysResponse, + }); + + const { result } = renderHook(() => useKeys(1, 10, { search: "pasted-key-id" }), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + const callUrl = new URL(mockFetch.mock.calls[0][0], "http://localhost"); + expect(callUrl.searchParams.get("search")).toBe("pasted-key-id"); + expect(callUrl.searchParams.has("key_alias")).toBe(false); + expect(callUrl.searchParams.has("key_hash")).toBe(false); + }); }); describe("useDeletedKeys", () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts index 94ded01679d..7e7089e685f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts @@ -40,6 +40,7 @@ export interface KeyListCallOptions { selectedKeyAlias?: string | null; userID?: string | null; keyHash?: string | null; + search?: string | null; sortBy?: string | null; sortOrder?: string | null; expand?: string | null; @@ -61,6 +62,7 @@ const keyListCall = async (accessToken: string, page: number, pageSize: number, organization_id: options.organizationID, key_alias: options.selectedKeyAlias, key_hash: options.keyHash, + search: options.search, user_id: options.userID, page, size: pageSize, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryTable.test.tsx index 5100b998b80..984b8135466 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryTable.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryTable.test.tsx @@ -95,6 +95,7 @@ describe("MemoryTable", () => { it("shows the filtered-empty copy when a search is active", () => { render(); expect(screen.getByText("No matching memories")).toBeInTheDocument(); + expect(screen.getByText("No memories match your search.")).toBeInTheDocument(); expect(screen.queryByText("No memories stored yet")).not.toBeInTheDocument(); }); @@ -128,6 +129,7 @@ describe("MemoryTable", () => { const onRefresh = vi.fn(); render(); + expect(screen.getByPlaceholderText("Search by key prefix or memory ID…")).toBeInTheDocument(); fireEvent.change(screen.getByTestId("datatable-search"), { target: { value: "u" } }); expect(onSearchChange).toHaveBeenCalledWith("u"); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryTable.tsx index 50dd04ee14c..3e37faafe15 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryTable.tsx @@ -36,7 +36,7 @@ function MemoryEmptyState({ hasActiveSearch }: { hasActiveSearch: boolean }) {
{hasActiveSearch - ? "No memories have keys starting with your search." + ? "No memories match your search." : "Memories your agents store under /v1/memory will appear here."}
@@ -81,7 +81,7 @@ export function MemoryTable({ table={table} searchValue={searchValue} onSearchChange={onSearchChange} - searchPlaceholder='Filter by key prefix, e.g. "user:"' + searchPlaceholder="Search by key prefix or memory ID…" onRefresh={onRefresh} isRefreshing={isRefreshing} showViewOptions={false} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryView.test.tsx index 9ccef5357b9..b703df652c2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryView.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryView.test.tsx @@ -1,8 +1,9 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { act, render, screen } from "@testing-library/react"; +import type { PaginationState } from "@tanstack/react-table"; +import { act, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import React from "react"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { MemoryRow } from "@/components/networking"; @@ -13,10 +14,13 @@ interface CapturedTableProps { rowCount: number; data: MemoryRow[]; hasActiveSearch: boolean; + onSearchChange: (value: string) => void; + onPaginationChange: (state: PaginationState) => void; onViewClick: (row: MemoryRow) => void; } const captured = vi.hoisted(() => ({ current: null as CapturedTableProps | null })); +const fetchMemoryListMock = vi.hoisted(() => vi.fn()); vi.mock("./MemoryTable", () => ({ MemoryTable: function MemoryTableMock(props: CapturedTableProps) { @@ -25,6 +29,15 @@ vi.mock("./MemoryTable", () => ({ }, })); +vi.mock("@/components/networking", async (importOriginal) => ({ + ...(await importOriginal()), + fetchMemoryList: fetchMemoryListMock, +})); + +vi.mock("@tanstack/react-pacer/debouncer", () => ({ + useDebouncedValue: (value: unknown) => [value, { cancel: vi.fn(), flush: vi.fn() }], +})); + const renderView = (accessToken: string | null) => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return render( @@ -35,6 +48,28 @@ const renderView = (accessToken: string | null) => { }; describe("MemoryView", () => { + beforeEach(() => { + fetchMemoryListMock.mockReset(); + fetchMemoryListMock.mockResolvedValue({ memories: [], total: 0 }); + }); + + it("queries the server with the search box value as `search` and resets to page 1", async () => { + renderView("token"); + await waitFor(() => expect(fetchMemoryListMock).toHaveBeenCalled()); + + act(() => captured.current?.onPaginationChange({ pageIndex: 2, pageSize: 50 })); + await waitFor(() => + expect(fetchMemoryListMock).toHaveBeenLastCalledWith("token", expect.objectContaining({ page: 3 })), + ); + + act(() => captured.current?.onSearchChange("mem-abc123")); + + await waitFor(() => + expect(fetchMemoryListMock).toHaveBeenLastCalledWith("token", { search: "mem-abc123", page: 1, pageSize: 50 }), + ); + expect(captured.current?.hasActiveSearch).toBe(true); + }); + it("keeps the table out of the skeleton state when the token is null (disabled query)", () => { renderView(null); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryView.tsx index 1d2e5150a62..58d4e42aa96 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryView.tsx @@ -43,10 +43,8 @@ export const MemoryView: React.FC = ({ accessToken }) => { queryKey: [MEMORY_LIST_KEY, debouncedSearch, pagination.pageIndex, pagination.pageSize], queryFn: () => { if (!accessToken) throw new Error("Access token required"); - // Prefix search matches the Redis-style mental model (namespace scan): - // typing "user:" finds "user:profile", "user:prefs", etc. return fetchMemoryList(accessToken, { - keyPrefix: debouncedSearch || undefined, + search: debouncedSearch || undefined, page: pagination.pageIndex + 1, pageSize: pagination.pageSize, }); diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx index c69662b69dc..881b0b93ff9 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx @@ -542,6 +542,19 @@ describe("server-side filtering – the LIT-4080 regression guard", () => { expect((lastCall[2] ?? {}).userID).toBeUndefined(); }); }); + + it("sends the search box as the combined alias-or-ID search rather than the key-alias filter", async () => { + renderWithProviders(); + + fireEvent.change(screen.getByPlaceholderText(/Search by key alias or ID/), { target: { value: mockKey.token } }); + + await waitFor(() => { + expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ search: mockKey.token })); + }); + const lastOptions = mockUseKeys.mock.calls.at(-1)?.[2]; + expect(lastOptions?.selectedKeyAlias).toBeUndefined(); + expect(lastOptions?.keyHash).toBeUndefined(); + }); }); describe("pagination display – total count comes from useKeys", () => { @@ -663,7 +676,7 @@ describe("table state lives in the URL so it survives leaving and returning to t expect(mockUseKeys).toHaveBeenLastCalledWith( 3, 25, - expect.objectContaining({ selectedKeyAlias: "prod", sortBy: "spend", sortOrder: "asc" }), + expect.objectContaining({ search: "prod", sortBy: "spend", sortOrder: "asc" }), ); }); expect(screen.getByPlaceholderText(/Search by key alias/)).toHaveValue("prod"); @@ -736,7 +749,7 @@ describe("table state lives in the URL so it survives leaving and returning to t fireEvent.change(screen.getByPlaceholderText(/Search by key alias/), { target: { value: "prod" } }); await waitFor(() => { - expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ selectedKeyAlias: "prod" })); + expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ search: "prod" })); }); await waitFor(() => { expect(lastSearchParam(onUrlUpdate, "page")).toBeNull(); diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx index c424966a0a3..ebedf57af45 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx @@ -118,7 +118,7 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) { const keyListOptions = { teamID: appliedFilters.team_id || undefined, organizationID: appliedFilters.org_id || undefined, - selectedKeyAlias: searchQuery.trim() || undefined, + search: searchQuery.trim() || undefined, userID: appliedFilters.user_id || undefined, keyHash: appliedFilters.key_hash || undefined, sortBy, @@ -291,7 +291,7 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) { table={table} searchValue={searchInput} onSearchChange={handleSearchChange} - searchPlaceholder="Search by key alias…" + searchPlaceholder="Search by key alias or ID…" onRefresh={() => refetch?.()} isRefreshing={isFetching} onOpenFilters={() => setFiltersOpen(true)} diff --git a/ui/litellm-dashboard/src/components/networking.test.ts b/ui/litellm-dashboard/src/components/networking.test.ts index 7a220dd4711..9df9e9a9209 100644 --- a/ui/litellm-dashboard/src/components/networking.test.ts +++ b/ui/litellm-dashboard/src/components/networking.test.ts @@ -854,3 +854,48 @@ describe("userListCall search serialization", () => { expect(lastParams(mockFetch).get("user_email")).toBe("ada@example.com"); }); }); + +describe("fetchMemoryList search serialization", () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + }); + + const mockOkFetch = () => { + const emptyPage = { memories: [], total: 0 }; + const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue(emptyPage) } as any); + global.fetch = mockFetch as any; + return mockFetch; + }; + + const lastParams = (mockFetch: ReturnType) => { + const [url] = mockFetch.mock.calls.at(-1) ?? []; + return new URL(url as string, "http://example.com").searchParams; + }; + + it("sends the search box value as search and omits key_prefix and key", async () => { + const mockFetch = mockOkFetch(); + + await Networking.fetchMemoryList("token", { search: "mem-abc123", page: 1, pageSize: 50 }); + + const params = lastParams(mockFetch); + expect(params.get("search")).toBe("mem-abc123"); + expect(params.has("key_prefix")).toBe(false); + expect(params.has("key")).toBe(false); + expect(params.get("page")).toBe("1"); + expect(params.get("page_size")).toBe("50"); + }); + + it("keeps key_prefix and key working when no search is given", async () => { + const mockFetch = mockOkFetch(); + + await Networking.fetchMemoryList("token", { keyPrefix: "user:" }); + expect(lastParams(mockFetch).get("key_prefix")).toBe("user:"); + expect(lastParams(mockFetch).has("search")).toBe(false); + + await Networking.fetchMemoryList("token", { key: "user:profile" }); + expect(lastParams(mockFetch).get("key")).toBe("user:profile"); + expect(lastParams(mockFetch).has("search")).toBe(false); + }); +}); diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index d8762565e08..1384679a88a 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -2056,6 +2056,7 @@ interface UiSpendLogsParams { exclude_internal_health_checks?: boolean; group_by_session?: boolean; session_cursor?: string; + search?: string; } interface UiSpendLogsCallOptions { @@ -6563,6 +6564,7 @@ interface UiAuditLogsParams { changed_by_api_key?: string; object_team_id?: string; object_key_hash?: string; + search?: string | null; sort_by?: string; sort_order?: "asc" | "desc"; } @@ -8061,15 +8063,18 @@ export const fetchMemoryList = async ( options: { key?: string; keyPrefix?: string; + search?: string; page?: number; pageSize?: number; } = {}, ): Promise => { const base = proxyBaseUrl ? `${proxyBaseUrl}/v1/memory` : `/v1/memory`; const params = new URLSearchParams(); - // keyPrefix takes precedence — backend also does, but we omit `key` + // Backend precedence is search > key_prefix > key; only the winner is sent // to keep the URL clean and intent obvious. - if (options.keyPrefix) { + if (options.search) { + params.append("search", options.search); + } else if (options.keyPrefix) { params.append("key_prefix", options.keyPrefix); } else if (options.key) { params.append("key", options.key); diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx index 8bf5d639d6c..0d9d0988aa1 100644 --- a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx @@ -30,6 +30,8 @@ vi.mock("@tanstack/react-pacer/debouncer", () => ({ const mockUseKeys = useKeys as MockedFunction; +const KEY_HASH = "88a145505dd6e87e2ea166fcef1e4b53948dbdb32af6431dfd05ec06b571ee52"; + const createMockKey = (overrides: Partial = {}): KeyResponse => ({ token: "sk-test123", @@ -277,7 +279,7 @@ describe("TeamVirtualKeysTable", () => { ); }); - it("maps the search box to a server-side key-alias query", async () => { + it("maps the Key ID drawer filter to a server-side useKeys query and clears it", async () => { const user = userEvent.setup(); mockUseKeys.mockReturnValue({ data: { keys: [createMockKey()], total_count: 1, current_page: 1, total_pages: 1 }, @@ -288,11 +290,42 @@ describe("TeamVirtualKeysTable", () => { renderWithProviders(); - fireEvent.change(await screen.findByTestId("datatable-search"), { target: { value: "check-002" } }); + await user.click(await screen.findByTestId("datatable-filters-trigger")); + const drawerBody = await screen.findByTestId("filter-drawer-body"); + fireEvent.change(within(drawerBody).getByPlaceholderText("Enter Key ID…"), { target: { value: KEY_HASH } }); + await user.click(screen.getByTestId("filter-drawer-apply")); await waitFor(() => - expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ selectedKeyAlias: "check-002" })), + expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ keyHash: KEY_HASH })), ); + expect(screen.getByTestId("filter-chip-key_hash")).toHaveTextContent("Key ID"); + + await user.click(screen.getByTestId("datatable-clear-filters")); + await waitFor(() => + expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ keyHash: undefined })), + ); + }); + + it("maps the search box to the combined alias-or-ID search rather than the key-alias filter", async () => { + mockUseKeys.mockReturnValue({ + data: { keys: [createMockKey()], total_count: 1, current_page: 1, total_pages: 1 }, + isPending: false, + isFetching: false, + refetch: vi.fn(), + } as unknown as ReturnType); + + renderWithProviders(); + + const searchBox = await screen.findByTestId("datatable-search"); + expect(searchBox).toHaveAttribute("placeholder", "Search by key alias or ID…"); + fireEvent.change(searchBox, { target: { value: KEY_HASH } }); + + await waitFor(() => + expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ search: KEY_HASH })), + ); + const lastOptions = mockUseKeys.mock.calls.at(-1)?.[2]; + expect(lastOptions?.selectedKeyAlias).toBeUndefined(); + expect(lastOptions?.keyHash).toBeUndefined(); }); it("should show Loading keys when isPending", async () => { diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx index bd7faa41ee9..aa9df4a0319 100644 --- a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx @@ -68,19 +68,17 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi const pageIndex = tablePagination.pageIndex; const pageSize = tablePagination.pageSize; - const { - data: keys, - isPending: isLoading, - isFetching, - refetch, - } = useKeys(pageIndex + 1, pageSize, { + const keyListOptions = { teamID: teamId, - selectedKeyAlias: searchQuery.trim() || undefined, + search: searchQuery.trim() || undefined, userID: getFilterValue("user_id"), + keyHash: getFilterValue("key_hash"), sortBy: sortBy || undefined, sortOrder: sortOrder || undefined, expand: "user", - }); + }; + + const { data: keys, isPending: isLoading, isFetching, refetch } = useKeys(pageIndex + 1, pageSize, keyListOptions); const displayKeys = useMemo(() => { const kList = keys?.keys || []; @@ -481,11 +479,11 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi table={table} searchValue={searchInput} onSearchChange={handleSearchChange} - searchPlaceholder="Search by key alias…" + searchPlaceholder="Search by key alias or ID…" onRefresh={() => refetch?.()} isRefreshing={isFetching} onOpenFilters={() => setFiltersOpen(true)} - filterLabels={{ user_id: "User ID" }} + filterLabels={{ user_id: "User ID", key_hash: "Key ID" }} /> {({ get, set }) => ( - - set("user_id", event.target.value)} - placeholder="Filter by user ID…" - /> - + <> + + set("user_id", event.target.value)} + placeholder="Filter by user ID…" + /> + + + set("key_hash", event.target.value)} + placeholder="Enter Key ID…" + /> + + )} diff --git a/ui/litellm-dashboard/src/components/view_logs/AuditLogsPanel.test.tsx b/ui/litellm-dashboard/src/components/view_logs/AuditLogsPanel.test.tsx new file mode 100644 index 00000000000..3b27f663b8e --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/AuditLogsPanel.test.tsx @@ -0,0 +1,145 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { chooseSelectOption } from "../../../tests/test-utils"; +import AuditLogsPanel from "./AuditLogsPanel"; + +vi.mock("../networking", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, uiAuditLogsCall: vi.fn() }; +}); + +// Resolve the debounced search synchronously so typed input reaches the query within the test tick. +vi.mock("@tanstack/react-pacer/debouncer", () => ({ + useDebouncedValue: (value: unknown) => [value, { cancel: vi.fn(), flush: vi.fn() }], +})); + +import { uiAuditLogsCall } from "../networking"; + +type AuditLogsParams = NonNullable[0]["params"]>; + +const PAGE_SIZE = 50; + +const ID_PARAM_KEYS = [ + "search", + "object_id", + "changed_by", + "object_team_id", + "object_key_hash", + "action", + "table_name", +] as const satisfies readonly (keyof AuditLogsParams)[]; + +const respondWith = (total: number) => { + const response = { audit_logs: [], total, page: 1, page_size: PAGE_SIZE, total_pages: Math.ceil(total / PAGE_SIZE) }; + return vi.mocked(uiAuditLogsCall).mockResolvedValue(response); +}; + +const lastCall = () => vi.mocked(uiAuditLogsCall).mock.calls.at(-1)?.[0]; +const sentIdParams = () => ID_PARAM_KEYS.filter((key) => lastCall()?.params?.[key] !== undefined); + +const defaultProps = { + accessToken: "sk-test", + token: "jwt-test", + userRole: "Admin", + userID: "user-1", + isActive: true, + premiumUser: true, +}; + +const renderPanel = () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +}; + +const TEXT_FILTERS: { filterId: string; placeholder: string; paramKey: keyof AuditLogsParams }[] = [ + { filterId: "object_id", placeholder: "Enter object ID…", paramKey: "object_id" }, + { filterId: "changed_by", placeholder: "Enter user ID…", paramKey: "changed_by" }, + { filterId: "team_id", placeholder: "Enter team ID…", paramKey: "object_team_id" }, + { filterId: "key_hash", placeholder: "Enter key hash…", paramKey: "object_key_hash" }, +]; + +const SELECT_FILTERS: { + label: string; + comboboxIndex: number; + option: string; + paramKey: keyof AuditLogsParams; + value: string; +}[] = [ + { label: "Action", comboboxIndex: 0, option: "Created", paramKey: "action", value: "created" }, + { label: "Table", comboboxIndex: 1, option: "Teams", paramKey: "table_name", value: "LiteLLM_TeamTable" }, +]; + +describe("AuditLogsPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + respondWith(0); + }); + + it("sends the typed search as params.search and returns to the first page", async () => { + const user = userEvent.setup(); + respondWith(120); + renderPanel(); + await waitFor(() => expect(uiAuditLogsCall).toHaveBeenCalled()); + expect(lastCall()?.params?.search).toBeUndefined(); + + await user.click(screen.getByTestId("pagination-next")); + await waitFor(() => expect(lastCall()?.page).toBe(2)); + + await user.type(screen.getByTestId("datatable-search"), "team-abc"); + + await waitFor(() => expect(lastCall()?.params?.search).toBe("team-abc")); + expect(lastCall()?.page).toBe(1); + expect(sentIdParams()).toEqual(["search"]); + }); + + it("trims the search and drops params.search once the box is cleared", async () => { + const user = userEvent.setup(); + renderPanel(); + const input = await screen.findByTestId("datatable-search"); + + await user.type(input, " abc"); + await waitFor(() => expect(lastCall()?.params?.search).toBe("abc")); + + await user.clear(input); + + await waitFor(() => expect(lastCall()?.params?.search).toBeUndefined()); + expect(sentIdParams()).toEqual([]); + }); + + it.each(TEXT_FILTERS)("maps the $filterId drawer filter to params.$paramKey", async ({ placeholder, paramKey }) => { + const user = userEvent.setup(); + renderPanel(); + await waitFor(() => expect(uiAuditLogsCall).toHaveBeenCalled()); + + await user.click(screen.getByTestId("datatable-filters-trigger")); + fireEvent.change(await screen.findByPlaceholderText(placeholder), { target: { value: "val-1" } }); + await user.click(screen.getByTestId("filter-drawer-apply")); + + await waitFor(() => expect(lastCall()?.params?.[paramKey]).toBe("val-1")); + expect(sentIdParams()).toEqual([paramKey]); + }); + + it.each(SELECT_FILTERS)( + "maps the $label drawer select to params.$paramKey", + async ({ comboboxIndex, option, paramKey, value }) => { + const user = userEvent.setup(); + renderPanel(); + await waitFor(() => expect(uiAuditLogsCall).toHaveBeenCalled()); + + await user.click(screen.getByTestId("datatable-filters-trigger")); + const triggers = await screen.findAllByRole("combobox"); + await chooseSelectOption(user, triggers[comboboxIndex], option); + await user.click(screen.getByTestId("filter-drawer-apply")); + + await waitFor(() => expect(lastCall()?.params?.[paramKey]).toBe(value)); + expect(sentIdParams()).toEqual([paramKey]); + }, + ); +}); diff --git a/ui/litellm-dashboard/src/components/view_logs/AuditLogsPanel.tsx b/ui/litellm-dashboard/src/components/view_logs/AuditLogsPanel.tsx index 81bd4a19f76..5ce4ca6053f 100644 --- a/ui/litellm-dashboard/src/components/view_logs/AuditLogsPanel.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/AuditLogsPanel.tsx @@ -1,7 +1,9 @@ import { useCallback, useState } from "react"; +import { useDebouncedValue } from "@tanstack/react-pacer/debouncer"; import { useQuery, keepPreviousData } from "@tanstack/react-query"; import { ColumnFiltersState, OnChangeFn, PaginationState } from "@tanstack/react-table"; import { resolveLogoSrc } from "@/lib/assetPaths"; +import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; import { uiAuditLogsCall } from "../networking"; import { AuditLogEntry } from "./AuditLogsTableColumns"; import { AuditLogsTable } from "./AuditLogsTable"; @@ -39,9 +41,13 @@ export default function AuditLogsPanel({ }: AuditLogsProps) { const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: PAGE_SIZE }); const [columnFilters, setColumnFilters] = useState([]); + const [searchInput, setSearchInput] = useState(""); + const [debouncedSearch] = useDebouncedValue(searchInput, { wait: DEBOUNCE_WAIT_MS }); const [selectedLog, setSelectedLog] = useState(null); const [drawerOpen, setDrawerOpen] = useState(false); + const searchTerm = debouncedSearch.trim(); + const getFilterValue = (columnId: string): string | undefined => { const entry = columnFilters.find((filter) => filter.id === columnId); return typeof entry?.value === "string" && entry.value.trim() ? entry.value.trim() : undefined; @@ -50,7 +56,7 @@ export default function AuditLogsPanel({ const canQueryAuditLogs = !!accessToken && !!token && !!userRole && !!userID && isActive && premiumUser; const query = useQuery({ - queryKey: ["audit_logs", pagination.pageIndex, pagination.pageSize, columnFilters], + queryKey: ["audit_logs", pagination.pageIndex, pagination.pageSize, columnFilters, searchTerm], queryFn: async () => { if (!accessToken) { return { audit_logs: [], total: 0, page: 1, page_size: pagination.pageSize, total_pages: 0 }; @@ -60,6 +66,7 @@ export default function AuditLogsPanel({ page: pagination.pageIndex + 1, page_size: pagination.pageSize, params: { + search: searchTerm || undefined, object_id: getFilterValue("object_id"), changed_by: getFilterValue("changed_by"), object_key_hash: getFilterValue("key_hash"), @@ -80,6 +87,11 @@ export default function AuditLogsPanel({ setPagination((prev) => ({ ...prev, pageIndex: 0 })); }, []); + const handleSearchChange = useCallback((value: string) => { + setSearchInput(value); + setPagination((prev) => ({ ...prev, pageIndex: 0 })); + }, []); + const handleViewLog = useCallback((log: AuditLogEntry) => { setSelectedLog(log); setDrawerOpen(true); @@ -128,6 +140,8 @@ export default function AuditLogsPanel({ onPaginationChange={setPagination} columnFilters={columnFilters} onColumnFiltersChange={handleColumnFiltersChange} + searchValue={searchInput} + onSearchChange={handleSearchChange} onRefresh={() => query.refetch()} onViewLog={handleViewLog} /> diff --git a/ui/litellm-dashboard/src/components/view_logs/AuditLogsTable.test.tsx b/ui/litellm-dashboard/src/components/view_logs/AuditLogsTable.test.tsx index 7349c3019ae..d8e549715af 100644 --- a/ui/litellm-dashboard/src/components/view_logs/AuditLogsTable.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/AuditLogsTable.test.tsx @@ -120,6 +120,24 @@ describe("AuditLogsTable", () => { expect(screen.getByText("No matching audit logs")).toBeInTheDocument(); }); + it("renders the toolbar search box from the search props and forwards typed input", () => { + const onSearchChange = vi.fn(); + renderTable({ searchValue: "team-", onSearchChange }); + + const input = screen.getByPlaceholderText("Search audit logs by ID…"); + expect(input).toHaveValue("team-"); + + fireEvent.change(input, { target: { value: "team-7" } }); + expect(onSearchChange).toHaveBeenCalledWith("team-7"); + }); + + it("treats an active search as a filter for the empty state", () => { + const emptySearchResult = { data: [], rowCount: 0, searchValue: "zzz", onSearchChange: vi.fn() }; + renderTable(emptySearchResult); + + expect(screen.getByText("No matching audit logs")).toBeInTheDocument(); + }); + it("renders active filter chips with human-readable labels", () => { const filters: ColumnFiltersState = [{ id: "action", value: "created" }]; renderTable({ columnFilters: filters }); diff --git a/ui/litellm-dashboard/src/components/view_logs/AuditLogsTable.tsx b/ui/litellm-dashboard/src/components/view_logs/AuditLogsTable.tsx index 799505ed07c..cef828838e2 100644 --- a/ui/litellm-dashboard/src/components/view_logs/AuditLogsTable.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/AuditLogsTable.tsx @@ -24,6 +24,8 @@ interface AuditLogsTableProps { onPaginationChange: OnChangeFn; columnFilters: ColumnFiltersState; onColumnFiltersChange: OnChangeFn; + searchValue?: string; + onSearchChange?: (value: string) => void; onRefresh: () => void; onViewLog: (log: AuditLogEntry) => void; } @@ -102,11 +104,14 @@ export function AuditLogsTable({ onPaginationChange, columnFilters, onColumnFiltersChange, + searchValue, + onSearchChange, onRefresh, onViewLog, }: AuditLogsTableProps) { const [filtersOpen, setFiltersOpen] = useState(false); const columns = useMemo(() => getAuditLogsTableColumns({ onViewLog }), [onViewLog]); + const hasActiveSearch = Boolean(searchValue?.trim()); return ( 0} />} + noDataMessage={ 0 || hasActiveSearch} />} size="compact" toolbar={(table) => ( <> setFiltersOpen(true)} diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx index 93066c106ee..be0d0049c13 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx @@ -54,6 +54,14 @@ vi.mock("./LogDetailsDrawer", () => ({ }, })); +const debounce = vi.hoisted(() => ({ settled: null as string | null })); + +vi.mock("@tanstack/react-pacer/debouncer", () => ({ + useDebouncedValue: vi.fn((value: unknown) => [debounce.settled ?? value, { cancel: vi.fn(), flush: vi.fn() }]), +})); + +import { useDebouncedValue } from "@tanstack/react-pacer/debouncer"; +import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; import { uiSpendLogsCall } from "../networking"; const logEntry = (overrides: Partial): LogEntry => ({ @@ -136,6 +144,7 @@ describe("RequestLogsPanel", () => { sessionStorage.clear(); testQueryClient.clear(); respondWith([]); + debounce.settled = null; }); describe("server-grouped session pagination (#38060)", () => { @@ -322,9 +331,8 @@ describe("RequestLogsPanel", () => { }); }); - 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(); + describe("search by any id (LIT-3981, LIT-4741)", () => { + it("sends the typed id to the server as search on the first page instead of filtering the loaded rows", async () => { renderPanel(); await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled()); @@ -334,9 +342,54 @@ describe("RequestLogsPanel", () => { 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.params?.search).toBe("req-on-another-page"); expect(call.page).toBe(1); }); + expect(lastCall()?.params?.request_id).toBeUndefined(); + expect(lastCall()?.params?.session_cursor).toBeUndefined(); + }); + + it("sends the debounced value to the server while the box shows what is being typed", async () => { + debounce.settled = "settled-id"; + renderPanel(); + + await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled()); + + fireEvent.change(screen.getByTestId("datatable-search"), { target: { value: "still-typing" } }); + + expect(screen.getByTestId("datatable-search")).toHaveValue("still-typing"); + await waitFor(() => + expect(useDebouncedValue).toHaveBeenLastCalledWith("still-typing", { wait: DEBOUNCE_WAIT_MS }), + ); + await waitFor(() => expect(lastCall()?.params?.search).toBe("settled-id")); + const sentLiveValue = vi + .mocked(uiSpendLogsCall) + .mock.calls.some(([options]) => options.params?.search === "still-typing"); + expect(sentLiveValue).toBe(false); + }); + + it("shows a Search chip whose remove button clears the box and restores the unsearched listing", async () => { + const user = userEvent.setup(); + vi.mocked(uiSpendLogsCall).mockImplementation(async ({ params }) => { + const data = + params?.search === "sess-42" + ? [logEntry({ request_id: "req-sess", session_id: "sess-42" })] + : [logEntry({ request_id: "req-initial" })]; + return { data, total: data.length, page: 1, page_size: 50, total_pages: 1 }; + }); + renderPanel(); + + await waitFor(() => expect(row("req-initial")).not.toBeNull()); + fireEvent.change(screen.getByTestId("datatable-search"), { target: { value: "sess-42" } }); + await waitFor(() => expect(row("req-sess")).not.toBeNull()); + expect(row("req-initial")).toBeNull(); + expect(screen.getByTestId("filter-chip-search")).toHaveTextContent("Search:sess-42"); + + await user.click(screen.getByRole("button", { name: "Remove Search filter" })); + + expect(screen.getByTestId("datatable-search")).toHaveValue(""); + await waitFor(() => expect(row("req-initial")).not.toBeNull()); + expect(row("req-sess")).toBeNull(); }); }); diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx index 96cf2bd5dde..9b99c6af923 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx @@ -1,11 +1,13 @@ "use client"; +import { useDebouncedValue } from "@tanstack/react-pacer/debouncer"; import { useQuery, type UseQueryOptions } from "@tanstack/react-query"; import type { ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table"; import moment from "moment"; import { useCallback, useEffect, useMemo, useState } from "react"; import { AutoRouterModelGroupsProvider } from "@/components/shared/table_cells"; +import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; import type { KeyResponse } from "../key_team_helpers/key_list"; import { keyInfoV1Call, uiSpendLogsCall } from "../networking"; import KeyInfoView from "../templates/key_info_view"; @@ -75,12 +77,22 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, sessionStorage.setItem("excludeInternalHealthChecks", JSON.stringify(excludeInternalHealthChecks)); }, [excludeInternalHealthChecks]); + const searchTerm = useMemo(() => { + const entry = columnFilters.find((filter) => filter.id === LOG_FILTER_IDS.SEARCH); + return typeof entry?.value === "string" ? entry.value : ""; + }, [columnFilters]); + const [debouncedSearch] = useDebouncedValue(searchTerm, { wait: DEBOUNCE_WAIT_MS }); + const queryColumnFilters = useMemo(() => { + const others = columnFilters.filter((filter) => filter.id !== LOG_FILTER_IDS.SEARCH); + return debouncedSearch === "" ? others : [...others, { id: LOG_FILTER_IDS.SEARCH, value: debouncedSearch }]; + }, [columnFilters, debouncedSearch]); + const { logsQuery, filteredLogs, allTeams, usesSessionCursor } = useLogFilterLogic({ accessToken, token, userRole, userID, - columnFilters, + columnFilters: queryColumnFilters, activeTab: isActive ? "request logs" : "inactive", isLiveTail, excludeInternalHealthChecks, @@ -155,15 +167,10 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, const rows: LogEntry[] = 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 }]; + const others = previous.filter((filter) => filter.id !== LOG_FILTER_IDS.SEARCH); + return value === "" ? others : [...others, { id: LOG_FILTER_IDS.SEARCH, value }]; }); setSessionCursors({}); setPagination((previous) => ({ ...previous, pageIndex: 0 })); diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsTable.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsTable.tsx index 4159b3b699b..17caa4466fa 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsTable.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsTable.tsx @@ -108,7 +108,7 @@ export function RequestLogsTable({ table={table} searchValue={searchValue} onSearchChange={onSearchChange} - searchPlaceholder="Search by Request ID" + searchPlaceholder="Search logs by ID…" onRefresh={onRefresh} isRefreshing={isRefreshing} onOpenFilters={() => setFiltersOpen(true)} diff --git a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx index 1b738db097d..6af791cc50e 100644 --- a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx @@ -91,6 +91,7 @@ describe("useLogFilterLogic", () => { { id: LOG_FILTER_IDS.ERROR_CODE, value: "429", param: "error_code" }, { id: LOG_FILTER_IDS.ERROR_MESSAGE, value: "rate limited", param: "error_message" }, { id: LOG_FILTER_IDS.USER_ID, value: "user-9", param: "user_id" }, + { id: LOG_FILTER_IDS.SEARCH, value: "any-id", param: "search" }, ]; it.each(cases)("sends $id as $param", async ({ id, value, param }) => { diff --git a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx index 90f0f0a60f1..3d368527ad9 100644 --- a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx @@ -33,6 +33,7 @@ export const LOG_FILTER_IDS = { PUBLIC_MODEL_OR_SEARCH_TOOL: "model", REQUEST_ID: "request_id", USER_ID: "user_id", + SEARCH: "search", } as const; export const LOG_FILTER_LABELS: Record = { @@ -48,6 +49,7 @@ export const LOG_FILTER_LABELS: Record = { [LOG_FILTER_IDS.SESSION_ID]: "Session ID", [LOG_FILTER_IDS.MODEL_ID]: "Model", [LOG_FILTER_IDS.PUBLIC_MODEL_OR_SEARCH_TOOL]: "Public model / search tool", + [LOG_FILTER_IDS.SEARCH]: "Search", }; export interface LogsWindow { @@ -175,6 +177,7 @@ export function useLogFilterLogic({ api_key: getFilterValue(columnFilters, LOG_FILTER_IDS.KEY_HASH), team_id: getFilterValue(columnFilters, LOG_FILTER_IDS.TEAM_ID), request_id: getFilterValue(columnFilters, LOG_FILTER_IDS.REQUEST_ID), + search: getFilterValue(columnFilters, LOG_FILTER_IDS.SEARCH), session_id: getFilterValue(columnFilters, LOG_FILTER_IDS.SESSION_ID), user_id: userIdFilter, end_user: getFilterValue(columnFilters, LOG_FILTER_IDS.END_USER), diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 93bc1de8e7e..6d2ff431137 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -40904,6 +40904,8 @@ export interface operations { object_team_id?: string | null; /** @description Filter by token (key hash) present in before_value or updated_values JSON (PostgreSQL only) */ object_key_hash?: string | null; + /** @description Match a row whose id, object_id, changed_by, or changed_by_api_key equals this value */ + search?: string | null; /** @description Column to sort by (e.g. 'updated_at', 'action', 'table_name') */ sort_by?: string | null; /** @description Sort order ('asc' or 'desc') */ @@ -49622,6 +49624,8 @@ export interface operations { key_hash?: string | null; /** @description Filter keys by key alias. Exact match by default; set substring_matching=true (admin only) for case-insensitive substring matching. */ key_alias?: string | null; + /** @description Combined search: matches keys whose token (key hash) equals the value OR whose key_alias contains it (case-insensitive). */ + search?: string | null; /** @description Return full key object */ return_full_object?: boolean; /** @description Include all keys for teams that user is an admin of. */ @@ -56880,6 +56884,8 @@ export interface operations { group_by_session?: boolean; /** @description Keyset cursor '||' from a previous group_by_session page. UI route only, honored when sorting by startTime */ session_cursor?: string | null; + /** @description Match a log whose request_id, api_key (hash), team_id, user, end_user, session_id, or model_id equals this value. request_id matches across all time; the other columns match inside start_date/end_date, which stay required */ + search?: string | null; }; header?: never; path?: never; @@ -56996,6 +57002,8 @@ export interface operations { group_by_session?: boolean; /** @description Keyset cursor '||' from a previous group_by_session page. UI route only, honored when sorting by startTime */ session_cursor?: string | null; + /** @description Match a log whose request_id, api_key (hash), team_id, user, end_user, session_id, or model_id equals this value. request_id matches across all time; the other columns match inside start_date/end_date, which stay required */ + search?: string | null; }; header?: never; path?: never; @@ -63308,6 +63316,8 @@ export interface operations { key?: string | null; /** @description Filter by key prefix (Redis-style namespace scan). Mutually exclusive with `key`; if both are provided, `key_prefix` wins. */ key_prefix?: string | null; + /** @description Match entries whose key starts with this value or whose memory_id equals it. Takes precedence over `key_prefix` and `key` when provided. */ + search?: string | null; page?: number; page_size?: number; };