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."}