Merge remote-tracking branch 'origin/main' into litellm_realtime_release_max_parallel_slot

This commit is contained in:
yassin 2026-09-14 21:03:28 +00:00
commit 2441e8a2a9
60 changed files with 2424 additions and 517 deletions

View file

@ -538,7 +538,7 @@ context_window_fallbacks: Optional[List] = None
content_policy_fallbacks: Optional[List] = None
allowed_fails: int = 3
allow_dynamic_callback_disabling: bool = True
num_retries_per_request: Optional[int] = None # for the request overall (incl. fallbacks + model retries)
num_retries_per_request: Optional[int] = None # cap on Router retries of one model group; resets per fallback hop
####### SECRET MANAGERS #####################
secret_manager_client: Optional[Any] = (
None # list of instantiated key management clients - e.g. azure kv, infisical, etc.

View file

@ -1,5 +1,6 @@
import ast
import contextvars
import functools
import logging
import os
import sys
@ -225,6 +226,35 @@ class AccessLogRedactionFilter(logging.Filter):
_access_log_filter: Final = AccessLogRedactionFilter()
@functools.lru_cache(maxsize=1)
def _parse_disabled_access_log_paths(raw: str) -> frozenset[str]:
return frozenset(stripped for path in raw.split(",") if (stripped := path.strip()))
def _disabled_access_log_paths() -> frozenset[str]:
"""Read the variable per record so a value loaded later via proxy config
environment_variables or dotenv is honored."""
return _parse_disabled_access_log_paths(os.getenv("LITELLM_DISABLE_ACCESS_LOG_PATHS", ""))
class AccessLogPathFilter(logging.Filter):
"""Drops uvicorn.access records for request paths listed in LITELLM_DISABLE_ACCESS_LOG_PATHS.
uvicorn passes record.args as (client_addr, method, full_path, http_version, status_code).
"""
def filter(self, record: logging.LogRecord) -> bool:
if not isinstance(record.args, tuple) or len(record.args) < 3:
return True
full_path: Final = record.args[2]
if not isinstance(full_path, str):
return True
return full_path.partition("?")[0] not in _disabled_access_log_paths()
_access_log_path_filter: Final = AccessLogPathFilter()
def _get_max_string_length_stdout_log() -> int:
"""Read the limit per record so a value loaded later via proxy config
environment_variables is honored."""
@ -663,6 +693,7 @@ def _redact_third_party_loggers() -> None:
for name in _REDACTED_THIRD_PARTY_LOGGERS:
logging.getLogger(name).addFilter(_secret_filter)
for name in _REDACTED_ACCESS_LOGGERS:
logging.getLogger(name).addFilter(_access_log_path_filter)
logging.getLogger(name).addFilter(_access_log_filter)

View file

@ -679,7 +679,14 @@ class Cache:
cache_key, cached_data, kwargs = self._add_cache_logic(result=result, **kwargs)
self.cache.set_cache(cache_key, cached_data, **kwargs)
except Exception as e:
log_redis_failure(verbose_logger, logging.ERROR, "LiteLLM Cache: exception in add_cache", e)
self._log_add_cache_failure(e)
def _log_add_cache_failure(self, exc: Exception) -> None:
message: Final = "LiteLLM Cache: exception in add_cache"
if isinstance(self.cache, RedisCache):
log_redis_failure(verbose_logger, logging.ERROR, message, exc)
return
verbose_logger.error("%s: %s", message, exc)
async def async_add_cache(self, result, dynamic_cache_object: BaseCache | None = None, **kwargs):
"""
@ -698,7 +705,7 @@ class Cache:
else:
await self.cache.async_set_cache(cache_key, cached_data, **kwargs)
except Exception as e:
log_redis_failure(verbose_logger, logging.ERROR, "LiteLLM Cache: exception in add_cache", e)
self._log_add_cache_failure(e)
def _convert_to_cached_embedding(
self,
@ -877,7 +884,7 @@ class Cache:
else:
await self.cache.async_set_cache_pipeline(cache_list=cache_list, **kwargs)
except Exception as e:
log_redis_failure(verbose_logger, logging.ERROR, "LiteLLM Cache: exception in add_cache", e)
self._log_add_cache_failure(e)
def should_use_cache(self, **kwargs):
"""

View file

@ -15,6 +15,7 @@ import hashlib
import inspect
import json
import logging
import threading
import time
from collections.abc import Awaitable, Callable, Iterator, Sequence
from contextvars import ContextVar
@ -32,6 +33,7 @@ from litellm.constants import (
REDIS_CIRCUIT_BREAKER_FAILURE_THRESHOLD,
REDIS_CIRCUIT_BREAKER_RECOVERY_TIMEOUT,
REDIS_CIRCUIT_BREAKER_TIMEOUT_MIN_DURATION,
REDIS_TIMEOUT_LOG_INTERVAL,
)
from litellm.litellm_core_utils.core_helpers import _get_parent_otel_span_from_kwargs
from litellm.litellm_core_utils.coroutine_checker import coroutine_checker
@ -340,7 +342,7 @@ def _explicit_causes(exc: BaseException) -> Iterator[BaseException]:
current = current.__cause__
def _is_redis_timeout_failure(exc: BaseException) -> bool:
def is_redis_timeout_failure(exc: BaseException) -> bool:
"""True when ``exc`` or any exception it was explicitly raised ``from`` is a timeout.
redis-py's blocking pool reports a pool wait timeout as ``ConnectionError`` chained from
@ -414,7 +416,7 @@ def _record_swallowed_redis_failure(breaker: RedisCircuitBreaker, exc: BaseExcep
"""
if not _is_redis_health_failure(exc):
return
breaker.record_failure(is_timeout=_is_redis_timeout_failure(exc))
breaker.record_failure(is_timeout=is_redis_timeout_failure(exc))
_swallowed_redis_failures.set(_swallowed_redis_failures.get() + 1)
@ -422,13 +424,58 @@ class RedisCircuitBreakerOpenError(Exception):
pass
class _RedisTimeoutLogThrottle:
"""Admits one Redis timeout log line per interval and counts the timeouts it suppressed in between."""
def __init__(self, interval: float, clock: Callable[[], float] = time.monotonic) -> None:
self.interval = interval
self._clock = clock
self._lock = threading.Lock()
self._last_logged_at: float | None = None
self._suppressed = 0
def admit(self) -> int | None:
"""Return the number of timeouts suppressed since the last admitted line, or None to suppress this one."""
with self._lock:
now: Final = self._clock()
if self._last_logged_at is not None and now - self._last_logged_at < self.interval:
self._suppressed += 1
return None
suppressed: Final = self._suppressed
self._suppressed = 0
self._last_logged_at = now
return suppressed
_redis_timeout_log_throttle: Final = _RedisTimeoutLogThrottle(REDIS_TIMEOUT_LOG_INTERVAL)
def log_redis_failure(
logger: logging.Logger, level: int, message: str, exc: BaseException, with_traceback: bool = False
) -> None:
if isinstance(exc, RedisCircuitBreakerOpenError):
logger.debug("%s: %s", message, exc)
logger.debug("%s: %s", message, exc, stacklevel=2)
return
logger.log(level, "%s: %s", message, exc, exc_info=exc if with_traceback else None)
exc_info: Final = exc if with_traceback else None
if not is_redis_timeout_failure(exc):
logger.log(level, "%s: %s", message, exc, exc_info=exc_info, stacklevel=2)
return
suppressed: Final = _redis_timeout_log_throttle.admit()
if suppressed is None:
logger.debug("%s: %s", message, exc, stacklevel=2)
return
if suppressed == 0:
logger.log(level, "%s: %s", message, exc, exc_info=exc_info, stacklevel=2)
return
logger.log(
level,
"%s: %s (%d more Redis timeouts since the previous Redis timeout line were logged at DEBUG)",
message,
exc,
suppressed,
exc_info=exc_info,
stacklevel=2,
)
@dataclass(frozen=True, slots=True)
@ -475,7 +522,7 @@ async def _run_under_circuit_breaker(
result: Final = await call()
except Exception as e:
if _is_redis_health_failure(e):
breaker.record_failure(is_timeout=_is_redis_timeout_failure(e))
breaker.record_failure(is_timeout=is_redis_timeout_failure(e))
raise
_exit_circuit_breaker(breaker, admission)
return result
@ -492,7 +539,7 @@ def _run_under_circuit_breaker_sync(
result: Final = call()
except Exception as e:
if _is_redis_health_failure(e):
breaker.record_failure(is_timeout=_is_redis_timeout_failure(e))
breaker.record_failure(is_timeout=is_redis_timeout_failure(e))
raise
_exit_circuit_breaker(breaker, admission)
return result
@ -801,10 +848,8 @@ class RedisCache(BaseCache):
## LOGGING ##
end_time = time.time()
_duration = end_time - start_time
verbose_logger.error(
"LiteLLM Redis Caching: increment_cache() - Got exception from REDIS %s, Writing value=%s",
str(e),
value,
log_redis_failure(
verbose_logger, logging.ERROR, "LiteLLM Redis Caching: increment_cache() - Got exception from REDIS", e
)
raise e
@ -1010,11 +1055,8 @@ class RedisCache(BaseCache):
call_type=f"async_set_cache <- {_get_call_stack_info()}",
)
)
verbose_logger.error(
"LiteLLM Redis Caching: async set() - Got exception from REDIS %s, key=%r, value=%r",
str(e),
key,
value,
log_redis_failure(
verbose_logger, logging.ERROR, "LiteLLM Redis Caching: async set() - Got exception from REDIS", e
)
raise e
@ -1062,10 +1104,8 @@ class RedisCache(BaseCache):
event_metadata={"key": key},
)
)
verbose_logger.error(
"LiteLLM Redis Caching: async set() - Got exception from REDIS %s, Writing value=%s",
str(e),
value,
log_redis_failure(
verbose_logger, logging.ERROR, "LiteLLM Redis Caching: async set() - Got exception from REDIS", e
)
_record_swallowed_redis_failure(self._circuit_breaker, e)
@ -1112,7 +1152,6 @@ class RedisCache(BaseCache):
start_time: Final = time.time()
print_verbose(f"Set Async Redis Cache: key list: {cache_list}\nttl={ttl}, redis_version={self.redis_version}")
cache_value: Final = None
try:
async with _redis_client.pipeline(transaction=False) as pipe:
results: Final = await self._pipeline_helper(pipe, cache_list, ttl)
@ -1149,10 +1188,11 @@ class RedisCache(BaseCache):
)
)
verbose_logger.error(
"LiteLLM Redis Caching: async set_cache_pipeline() - Got exception from REDIS %s, Writing value=%s",
str(e),
cache_value,
log_redis_failure(
verbose_logger,
logging.ERROR,
"LiteLLM Redis Caching: async set_cache_pipeline() - Got exception from REDIS",
e,
)
_record_swallowed_redis_failure(self._circuit_breaker, e)
@ -1191,8 +1231,11 @@ class RedisCache(BaseCache):
end_time=time.time(),
)
)
verbose_logger.error(
"LiteLLM Redis Caching: async_set_cache_pipeline_with_ttls() - Got exception from REDIS %s", str(e)
log_redis_failure(
verbose_logger,
logging.ERROR,
"LiteLLM Redis Caching: async_set_cache_pipeline_with_ttls() - Got exception from REDIS",
e,
)
_record_swallowed_redis_failure(self._circuit_breaker, e)
@ -1235,10 +1278,8 @@ class RedisCache(BaseCache):
)
)
# NON blocking - notify users Redis is throwing an exception
verbose_logger.error(
"LiteLLM Redis Caching: async set() - Got exception from REDIS %s, Writing value=%s",
str(e),
value,
log_redis_failure(
verbose_logger, logging.ERROR, "LiteLLM Redis Caching: async set() - Got exception from REDIS", e
)
raise e
@ -1274,10 +1315,11 @@ class RedisCache(BaseCache):
)
)
# NON blocking - notify users Redis is throwing an exception
verbose_logger.error(
"LiteLLM Redis Caching: async set_cache_sadd() - Got exception from REDIS %s, Writing value=%s",
str(e),
value,
log_redis_failure(
verbose_logger,
logging.ERROR,
"LiteLLM Redis Caching: async set_cache_sadd() - Got exception from REDIS",
e,
)
_record_swallowed_redis_failure(self._circuit_breaker, e)
@ -1359,10 +1401,11 @@ class RedisCache(BaseCache):
parent_otel_span=parent_otel_span,
)
)
verbose_logger.error(
"LiteLLM Redis Caching: async async_increment() - Got exception from REDIS %s, Writing value=%s",
str(e),
value,
log_redis_failure(
verbose_logger,
logging.ERROR,
"LiteLLM Redis Caching: async async_increment() - Got exception from REDIS",
e,
)
raise e
@ -1448,7 +1491,9 @@ class RedisCache(BaseCache):
print_verbose(f"Got Redis Cache: key: {key}, cached_response {cached_response}")
return self._get_cache_logic(cached_response=cached_response)
except Exception as e:
verbose_logger.error("litellm.caching.caching: get() - Got exception from REDIS: %s", e)
log_redis_failure(
verbose_logger, logging.ERROR, "litellm.caching.caching: get() - Got exception from REDIS", e
)
_record_swallowed_redis_failure(self._circuit_breaker, e)
def _run_redis_mget_operation(self, keys: list[str]) -> Sequence[bytes | str | None]:
@ -1526,7 +1571,7 @@ class RedisCache(BaseCache):
end_time=failed_at,
parent_otel_span=parent_otel_span,
)
verbose_logger.error("Error occurred in batch get cache - %s", e)
log_redis_failure(verbose_logger, logging.ERROR, "Error occurred in batch get cache", e)
_record_swallowed_redis_failure(self._circuit_breaker, e)
return key_value_dict
@ -1645,7 +1690,7 @@ class RedisCache(BaseCache):
parent_otel_span=parent_otel_span,
)
)
verbose_logger.error("Error occurred in async batch get cache - %s", e)
log_redis_failure(verbose_logger, logging.ERROR, "Error occurred in async batch get cache", e)
_record_swallowed_redis_failure(self._circuit_breaker, e)
return key_value_dict
@ -1870,9 +1915,11 @@ class RedisCache(BaseCache):
parent_otel_span=_get_parent_otel_span_from_kwargs(kwargs),
)
)
verbose_logger.error(
"LiteLLM Redis Caching: async increment_pipeline() - Got exception from REDIS %s",
str(e),
log_redis_failure(
verbose_logger,
logging.ERROR,
"LiteLLM Redis Caching: async increment_pipeline() - Got exception from REDIS",
e,
)
raise e
@ -1949,7 +1996,7 @@ class RedisCache(BaseCache):
call_type=f"async_rpush <- {_get_call_stack_info()}",
)
)
verbose_logger.error("LiteLLM Redis Cache RPUSH: - Got exception from REDIS : %s", e)
log_redis_failure(verbose_logger, logging.ERROR, "LiteLLM Redis Cache RPUSH: - Got exception from REDIS", e)
raise e
async def _pipeline_rpush_helper(
@ -2017,9 +2064,11 @@ class RedisCache(BaseCache):
call_type=f"async_rpush_pipeline <- {_get_call_stack_info()}",
)
)
verbose_logger.error(
"LiteLLM Redis Caching: async_rpush_pipeline() - Got exception from REDIS %s",
str(e),
log_redis_failure(
verbose_logger,
logging.ERROR,
"LiteLLM Redis Caching: async_rpush_pipeline() - Got exception from REDIS",
e,
)
raise e
@ -2095,7 +2144,7 @@ class RedisCache(BaseCache):
call_type=f"async_lpop <- {_get_call_stack_info()}",
)
)
verbose_logger.error("LiteLLM Redis Cache LPOP: - Got exception from REDIS : %s", e)
log_redis_failure(verbose_logger, logging.ERROR, "LiteLLM Redis Cache LPOP: - Got exception from REDIS", e)
raise e
async def _pipeline_lpop_helper(
@ -2206,8 +2255,10 @@ class RedisCache(BaseCache):
call_type=f"async_lpop_pipeline <- {_get_call_stack_info()}",
)
)
verbose_logger.error(
"LiteLLM Redis Caching: async_lpop_pipeline() - Got exception from REDIS %s",
str(e),
log_redis_failure(
verbose_logger,
logging.ERROR,
"LiteLLM Redis Caching: async_lpop_pipeline() - Got exception from REDIS",
e,
)
raise e

View file

@ -227,6 +227,9 @@ PRE_CALL_EXECUTED_GUARDRAILS_KEY: Final = "_pre_call_executed_guardrails"
# Attribute stamped on log_guardrail_information wrappers so __init_subclass__ does not wrap them again
LOGS_GUARDRAIL_INFORMATION_MARKER: Final = "_litellm_logs_guardrail_information"
# llm_provider stamped on proxy-side rate limit errors when the model resolves to no deployment
PROXY_LLM_PROVIDER_FALLBACK: Final = "litellm_proxy"
# Generic fallback for unknown models
DEFAULT_REASONING_EFFORT_MINIMAL_THINKING_BUDGET: Final = int(
os.getenv("DEFAULT_REASONING_EFFORT_MINIMAL_THINKING_BUDGET", 128)
@ -311,6 +314,10 @@ REALTIME_CREDENTIAL_RESOLUTION_TIMEOUT_SECONDS: Final = float(
# RFC 6455 caps the close frame payload at 125 bytes, 2 of which carry the status code
WEBSOCKET_CLOSE_REASON_MAX_BYTES: Final = 123
BEDROCK_REALTIME_PENDING_SESSION_UPDATE_SCOPE_KEY: Final = "litellm.bedrock_realtime.pending_session_update"
BEDROCK_REALTIME_SESSION_COMMITTED_SCOPE_KEY: Final = "litellm.bedrock_realtime.session_committed"
BEDROCK_REALTIME_COMMITTED_FAILURE_SCOPE_KEY: Final = "litellm.bedrock_realtime.committed_failure"
# SSL/TLS cipher configuration for faster handshakes
# Strategy: Strongly prefer fast modern ciphers, but allow fallback to commonly supported ones
# This balances performance with broad compatibility
@ -461,6 +468,7 @@ REDIS_CIRCUIT_BREAKER_ENABLED: Final = os.getenv("REDIS_CIRCUIT_BREAKER_ENABLED"
# minimum seconds a timeout-only failure streak must span before it can open the breaker,
# so one event-loop stall timing out many queued calls at once does not trip it
REDIS_CIRCUIT_BREAKER_TIMEOUT_MIN_DURATION: Final = float(os.getenv("REDIS_CIRCUIT_BREAKER_TIMEOUT_MIN_DURATION", 5.0))
REDIS_TIMEOUT_LOG_INTERVAL: Final = float(os.getenv("REDIS_TIMEOUT_LOG_INTERVAL", "5.0"))
# Seconds of idle before a Redis cluster connection is validated with a PING and
# reconnected if dead, so a connection silently dropped by a cluster restart
# (e.g. ElastiCache Serverless maintenance) is not reused while broken

View file

@ -16,6 +16,7 @@ from pydantic import BaseModel
import litellm
from litellm._logging import print_verbose, verbose_logger
from litellm.constants import PROXY_LLM_PROVIDER_FALLBACK
from litellm.exceptions import (
validate_rate_limit_category,
validate_rate_limit_type,
@ -2581,6 +2582,15 @@ class PrometheusLogger(CustomLogger):
)
return None
@staticmethod
def _extract_api_provider_from_exception(exception: Exception) -> str | None:
if not isinstance(exception, litellm.exceptions.RateLimitError):
return None
llm_provider: Final = exception.llm_provider
if not llm_provider or llm_provider == PROXY_LLM_PROVIDER_FALLBACK:
return None
return llm_provider
async def async_post_call_failure_hook(
self,
request_data: dict,
@ -2616,7 +2626,9 @@ class PrometheusLogger(CustomLogger):
_metadata: Final = request_data.get("metadata", {}) or {}
model_id: Final = _metadata.get("model_info", {}).get("id") or request_data.get("model_info", {}).get("id")
rate_limit_category, rate_limit_type = self._extract_rate_limit_labels(original_exception)
api_provider: Final = self._extract_api_provider_from_request_data(request_data)
api_provider: Final = self._extract_api_provider_from_request_data(
request_data
) or self._extract_api_provider_from_exception(original_exception)
enum_values: Final = UserAPIKeyLabelValues(
end_user=user_api_key_dict.end_user_id,
user=user_api_key_dict.user_id,

View file

@ -303,6 +303,16 @@ def get_metadata_variable_name_from_kwargs(
return "litellm_metadata" if "litellm_metadata" in kwargs else "metadata"
def max_retries_per_request_hit(kwargs: Mapping[str, object], num_retries_per_request: int | None) -> bool:
if num_retries_per_request is None:
return False
metadata: Final = kwargs.get(get_metadata_variable_name_from_kwargs(kwargs))
if not isinstance(metadata, Mapping):
return False
attempted_retries: Final = metadata.get("attempted_retries")
return type(attempted_retries) is int and 0 < attempted_retries and num_retries_per_request <= attempted_retries
def get_or_create_metadata_bucket(
request_data: dict,
) -> tuple[Literal["metadata", "litellm_metadata"], dict]:

View file

@ -7,13 +7,20 @@ This uses aws_sdk_bedrock_runtime for bidirectional streaming with Nova Sonic.
import asyncio
import contextlib
import json
from collections.abc import AsyncIterator, Mapping
from typing import Final, Protocol
from collections.abc import AsyncIterator, Mapping, MutableMapping
from dataclasses import dataclass
from types import MappingProxyType
from typing import Final, NoReturn, Protocol
from pydantic import JsonValue, TypeAdapter
import litellm
from litellm._logging import _redact_string, verbose_proxy_logger
from litellm.constants import (
BEDROCK_REALTIME_COMMITTED_FAILURE_SCOPE_KEY,
BEDROCK_REALTIME_PENDING_SESSION_UPDATE_SCOPE_KEY,
BEDROCK_REALTIME_SESSION_COMMITTED_SCOPE_KEY,
)
from litellm.litellm_core_utils.aws_partition import get_aws_dns_suffix
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
@ -28,6 +35,32 @@ from .transformation import BedrockRealtimeConfig
_CLIENT_MODALITIES_ADAPTER: Final[TypeAdapter["list[str] | None"]] = TypeAdapter(list[str] | None)
_CLIENT_MESSAGE_ADAPTER: Final[TypeAdapter[JsonValue]] = TypeAdapter(JsonValue)
_EMPTY_JSON_OBJECT: Final[Mapping[str, JsonValue]] = MappingProxyType({})
_BEDROCK_STREAM_ERROR_STATUS: Final[Mapping[str, int]] = MappingProxyType(
{
"AccessDeniedException": 403,
"ConflictException": 400,
"InternalServerException": 500,
"ModelErrorException": 424,
"ModelNotReadyException": 429,
"ModelStreamErrorException": 424,
"ModelTimeoutException": 408,
"ResourceNotFoundException": 404,
"ServiceQuotaExceededException": 400,
"ServiceUnavailableException": 503,
"ThrottlingException": 429,
"ValidationException": 400,
}
)
def _as_bedrock_error(error: BaseException) -> BaseException:
status_code: Final = _BEDROCK_STREAM_ERROR_STATUS.get(type(error).__name__)
if status_code is None:
return error
return BedrockError(status_code=status_code, message=f"{type(error).__name__}: {error}")
def _json_dict(value: JsonValue) -> dict[str, JsonValue]:
return value if isinstance(value, dict) else {}
@ -51,6 +84,8 @@ def _should_log_event(openai_message: Mapping[str, object]) -> bool:
class RealtimeClientWebSocket(Protocol):
"""The client-facing websocket surface the realtime bridge talks to."""
scope: MutableMapping[str, object] # mutable-ok: the ASGI scope is the per-connection state store
async def receive_text(self) -> str: ...
async def send_text(self, data: str) -> None: ...
@ -85,6 +120,81 @@ class BedrockBidirectionalStream(Protocol):
async def await_output(self) -> tuple[object, BedrockOutputStream]: ...
@dataclass(frozen=True, slots=True)
class _BridgeOutcome:
logged_events: tuple[OpenAIRealtimeEvents, ...]
provider_failure: BaseException | None
client_disconnected: bool
async def _client_messages(client_ws: RealtimeClientWebSocket, initial_message: str | None) -> AsyncIterator[str]:
if initial_message is not None:
yield initial_message
while True:
try:
yield await client_ws.receive_text()
except Exception as e: # noqa: BLE001 # any receive failure means the client is gone
verbose_proxy_logger.debug("Client to Bedrock forwarding ended: %s", e, exc_info=True)
return
def _pending_session_update(scope: Mapping[str, object]) -> str | None:
"""A fallback attempt on the same websocket replays the session.update the failed attempt never acked."""
if scope.get(BEDROCK_REALTIME_SESSION_COMMITTED_SCOPE_KEY) is True:
committed_failure: Final = scope.get(BEDROCK_REALTIME_COMMITTED_FAILURE_SCOPE_KEY)
raise BedrockError(
status_code=400,
message=(
"Bedrock realtime session already committed to a provider stream; it cannot be replayed"
+ (f". The committed stream failed with: {committed_failure}" if committed_failure else "")
),
)
pending: Final = scope.get(BEDROCK_REALTIME_PENDING_SESSION_UPDATE_SCOPE_KEY)
return pending if isinstance(pending, str) else None
def _raise_provider_failure(scope: MutableMapping[str, object], failure: BaseException) -> NoReturn:
error: Final = _as_bedrock_error(failure)
verbose_proxy_logger.error("Bedrock Realtime: provider stream failed: %s", _redact_string(str(error)))
if scope.get(BEDROCK_REALTIME_SESSION_COMMITTED_SCOPE_KEY) is True:
scope[BEDROCK_REALTIME_COMMITTED_FAILURE_SCOPE_KEY] = _redact_string(str(error))
raise error from failure
def _parse_client_message(message: str) -> Mapping[str, JsonValue]:
try:
return _json_dict(_CLIENT_MESSAGE_ADAPTER.validate_json(message))
except ValueError:
return _EMPTY_JSON_OBJECT
async def _ack_session_update(
client_ws: RealtimeClientWebSocket,
bedrock_stream: BedrockBidirectionalStream,
transformation_config: BedrockRealtimeConfig,
model: str,
logging_obj: LiteLLMLogging | None,
parsed_client_message: Mapping[str, JsonValue],
) -> bool:
"""Ack the client's session.update once Bedrock accepted the stream; False means the client is gone."""
await bedrock_stream.await_output()
client_ws.scope.pop(BEDROCK_REALTIME_PENDING_SESSION_UPDATE_SCOPE_KEY, None)
client_ws.scope[BEDROCK_REALTIME_SESSION_COMMITTED_SCOPE_KEY] = True # rebind-ok: scope outlives the attempt
if logging_obj is None:
return True
requested_modalities: Final = _CLIENT_MODALITIES_ADAPTER.validate_python(
_json_dict(parsed_client_message.get("session")).get("modalities")
)
try:
await client_ws.send_text(
json.dumps(transformation_config.session_updated_event(model, logging_obj, requested_modalities))
)
except Exception as e: # noqa: BLE001 # any send failure means the client is gone
verbose_proxy_logger.debug("Client to Bedrock forwarding ended: %s", e, exc_info=True)
return False
return True
class BedrockRealtime(BaseAWSLLM):
"""Handler for Bedrock Nova Sonic realtime speech-to-speech API."""
@ -132,6 +242,8 @@ class BedrockRealtime(BaseAWSLLM):
except ImportError:
raise ImportError("Missing aws_sdk_bedrock_runtime. Install with: pip install aws-sdk-bedrock-runtime")
pending_session_update: Final = _pending_session_update(websocket.scope)
# Get AWS region
if aws_region_name is None:
optional_params: Final = {
@ -190,90 +302,105 @@ class BedrockRealtime(BaseAWSLLM):
transformation_config: Final = BedrockRealtimeConfig()
try:
# Initialize the bidirectional stream
bedrock_stream: Final = await open_bidirectional_stream()
bedrock_stream: Final = await open_bidirectional_stream()
verbose_proxy_logger.debug("Bedrock Realtime: Bidirectional stream established")
verbose_proxy_logger.debug("Bedrock Realtime: Bidirectional stream established")
if pending_session_update is None:
await websocket.send_text(json.dumps(transformation_config.session_created_event(model, logging_obj)))
verbose_proxy_logger.debug("Bedrock Realtime: sent session.created to client on connect")
# Track state for transformation
session_state: Final[RealtimeResponseTransformInput] = {
"current_output_item_id": None,
"current_response_id": None,
"current_conversation_id": None,
"current_delta_chunks": None,
"current_item_chunks": None,
"current_delta_type": None,
"session_configuration_request": None,
}
# Track state for transformation
session_state: Final[RealtimeResponseTransformInput] = {
"current_output_item_id": None,
"current_response_id": None,
"current_conversation_id": None,
"current_delta_chunks": None,
"current_item_chunks": None,
"current_delta_type": None,
"session_configuration_request": None,
}
# Create tasks for bidirectional forwarding
client_to_bedrock_task: Final = asyncio.create_task(
self._forward_client_to_bedrock(
websocket,
bedrock_stream,
transformation_config,
model,
session_state,
logging_obj,
outcome: Final = await self._bridge(
websocket,
bedrock_stream,
transformation_config,
model,
session_state,
logging_obj,
initial_message=pending_session_update,
)
logged_events: Final = (
*outcome.logged_events,
*(
leftover_event
for leftover_event in transformation_config.leftover_usage_done_events()
if _should_log_event(leftover_event)
),
)
if logged_events:
GLOBAL_LOGGING_WORKER.ensure_initialized_and_enqueue(
logging_obj.dispatch_success_handlers(
list(logged_events), # mutable-ok: realtime spend logging requires a list result
prefer_async_handlers=True,
)
)
async def forward_bedrock_and_collect_logged_events() -> tuple[OpenAIRealtimeEvents, ...]:
return tuple(
[
event
async for event in self._forward_bedrock_to_client(
bedrock_stream,
websocket,
transformation_config,
model,
logging_obj,
session_state,
)
]
)
bedrock_to_client_task: Final = asyncio.create_task(forward_bedrock_and_collect_logged_events())
# Wait for both tasks to complete
await asyncio.gather(
client_to_bedrock_task,
bedrock_to_client_task,
return_exceptions=True,
if outcome.provider_failure is None:
return
if outcome.client_disconnected:
verbose_proxy_logger.debug(
"Bedrock Realtime: stream failed after the client disconnected: %s", outcome.provider_failure
)
return
_raise_provider_failure(websocket.scope, outcome.provider_failure)
forwarded_logged_events: Final = (
bedrock_to_client_task.result()
if not bedrock_to_client_task.cancelled() and bedrock_to_client_task.exception() is None
else ()
)
logged_events: Final = (
*forwarded_logged_events,
*(
leftover_event
for leftover_event in transformation_config.leftover_usage_done_events()
if _should_log_event(leftover_event)
),
)
if logged_events:
GLOBAL_LOGGING_WORKER.ensure_initialized_and_enqueue(
logging_obj.dispatch_success_handlers(
list(logged_events), # mutable-ok: realtime spend logging requires a list result
prefer_async_handlers=True,
)
)
async def _bridge(
self,
websocket: RealtimeClientWebSocket,
bedrock_stream: BedrockBidirectionalStream,
transformation_config: BedrockRealtimeConfig,
model: str,
session_state: RealtimeResponseTransformInput,
logging_obj: LiteLLMLogging,
initial_message: str | None,
) -> _BridgeOutcome:
"""Run both forwarding directions until the client leaves or either side fails."""
logged: Final[list[OpenAIRealtimeEvents]] = [] # mutable-ok: events forwarded before a failure are still spend
except Exception as e:
verbose_proxy_logger.exception("Error in BedrockRealtime.async_realtime: %s", e)
try:
await websocket.close(code=1011, reason=_redact_string(f"Internal error: {e}"))
except Exception:
pass
raise
async def collect_logged_events() -> None:
async for event in self._forward_bedrock_to_client(
bedrock_stream, websocket, transformation_config, model, logging_obj, session_state
):
logged.append(event)
client_task: Final = asyncio.create_task(
self._forward_client_to_bedrock(
websocket, bedrock_stream, transformation_config, model, session_state, logging_obj, initial_message
)
)
bedrock_task: Final = asyncio.create_task(collect_logged_events())
await asyncio.wait((client_task, bedrock_task), return_when=asyncio.FIRST_EXCEPTION)
client_disconnected: Final = (
client_task.done() and not client_task.cancelled() and client_task.exception() is None
)
client_task.cancel()
bedrock_task.cancel()
client_outcome, bedrock_outcome = await asyncio.gather(client_task, bedrock_task, return_exceptions=True)
return _BridgeOutcome(
logged_events=tuple(logged),
provider_failure=(
client_outcome
if isinstance(client_outcome, Exception)
else bedrock_outcome
if isinstance(bedrock_outcome, Exception)
else None
),
client_disconnected=client_disconnected,
)
async def _forward_client_to_bedrock(
self,
@ -283,8 +410,12 @@ class BedrockRealtime(BaseAWSLLM):
model: str,
session_state: RealtimeResponseTransformInput,
logging_obj: LiteLLMLogging | None = None,
):
"""Forward messages from client WebSocket to Bedrock stream."""
initial_message: str | None = None,
) -> None:
"""Forward messages from client WebSocket to Bedrock stream.
Returns once the client is gone; provider failures (input stream or readiness) propagate to the caller.
"""
from aws_sdk_bedrock_runtime.models import (
BidirectionalInputPayloadPart,
InvokeModelWithBidirectionalStreamInputChunk,
@ -299,41 +430,28 @@ class BedrockRealtime(BaseAWSLLM):
verbose_proxy_logger.debug("Bedrock Realtime: Sent to Bedrock: %s", bedrock_message[:200])
try:
while True:
# Receive message from client
message = await client_ws.receive_text()
async for message in _client_messages(client_ws, initial_message):
verbose_proxy_logger.debug("Bedrock Realtime: Received from client: %s", message[:200])
parsed_client_message = _parse_client_message(message)
is_session_update = _json_str(parsed_client_message.get("type")) == "session.update"
if is_session_update:
client_ws.scope[BEDROCK_REALTIME_PENDING_SESSION_UPDATE_SCOPE_KEY] = (
message # rebind-ok: scope outlives the attempt
)
# Transform OpenAI format to Bedrock format
transformed_messages = transformation_config.transform_realtime_request(
message=message,
model=model,
session_configuration_request=session_state.get("session_configuration_request"),
)
# Send transformed messages to Bedrock
for bedrock_message in transformed_messages:
await send_to_bedrock(bedrock_message)
if logging_obj is not None:
client_message_type: str | None = None
requested_modalities: list[str] | None = None
with contextlib.suppress(Exception):
parsed_client_message = _json_dict(_CLIENT_MESSAGE_ADAPTER.validate_json(message))
client_message_type = _json_str(parsed_client_message.get("type"))
if client_message_type == "session.update":
requested_modalities = _CLIENT_MODALITIES_ADAPTER.validate_python(
_json_dict(parsed_client_message.get("session")).get("modalities")
)
if client_message_type == "session.update":
await client_ws.send_text(
json.dumps(
transformation_config.session_updated_event(model, logging_obj, requested_modalities)
)
)
except Exception as e:
verbose_proxy_logger.debug("Client to Bedrock forwarding ended: %s", e, exc_info=True)
if is_session_update and not await _ack_session_update(
client_ws, bedrock_stream, transformation_config, model, logging_obj, parsed_client_message
):
break
finally:
for close_message in transformation_config.session_close_messages():
with contextlib.suppress(Exception):
await send_to_bedrock(close_message)
@ -349,68 +467,71 @@ class BedrockRealtime(BaseAWSLLM):
logging_obj: LiteLLMLogging,
session_state: RealtimeResponseTransformInput,
) -> AsyncIterator[OpenAIRealtimeEvents]:
"""Forward messages from Bedrock to the client, yielding the ones to record for spend logging."""
try:
while True:
# Receive from Bedrock
output = await bedrock_stream.await_output()
result = await output[1].receive()
"""Forward messages from Bedrock to the client, yielding the ones to record for spend logging.
if result is None:
verbose_proxy_logger.debug("Bedrock Realtime: Bedrock stream ended")
break
Provider failures propagate to the caller; the client websocket is only closed on a normal stream end.
"""
payload_bytes = result.value.bytes_ if result.value else None
if payload_bytes:
bedrock_response = payload_bytes.decode("utf-8")
verbose_proxy_logger.debug("Bedrock Realtime: Received from Bedrock: %s", bedrock_response[:200])
# Transform Bedrock format to OpenAI format
realtime_response_transform_input: RealtimeResponseTransformInput = {
"current_output_item_id": session_state.get("current_output_item_id"),
"current_response_id": session_state.get("current_response_id"),
"current_conversation_id": session_state.get("current_conversation_id"),
"current_delta_chunks": session_state.get("current_delta_chunks"),
"current_item_chunks": session_state.get("current_item_chunks"),
"current_delta_type": session_state.get("current_delta_type"),
"session_configuration_request": session_state.get("session_configuration_request"),
}
transformed_response = transformation_config.transform_realtime_response(
message=bedrock_response,
model=model,
logging_obj=logging_obj,
realtime_response_transform_input=realtime_response_transform_input,
)
# Update session state
session_state.update(
{
"current_output_item_id": transformed_response.get("current_output_item_id"),
"current_response_id": transformed_response.get("current_response_id"),
"current_conversation_id": transformed_response.get("current_conversation_id"),
"current_delta_chunks": transformed_response.get("current_delta_chunks"),
"current_item_chunks": transformed_response.get("current_item_chunks"),
"current_delta_type": transformed_response.get("current_delta_type"),
"session_configuration_request": transformed_response.get("session_configuration_request"),
}
)
# Send transformed messages to client
response_value = transformed_response["response"]
openai_messages = response_value if isinstance(response_value, list) else (response_value,)
for openai_message in openai_messages:
message_json = json.dumps(openai_message)
await client_ws.send_text(message_json)
verbose_proxy_logger.debug("Bedrock Realtime: Sent to client: %s", message_json[:200])
if _should_log_event(openai_message):
yield openai_message
except Exception as e:
verbose_proxy_logger.debug("Bedrock to client forwarding ended: %s", e, exc_info=True)
finally:
# Close the client WebSocket
async def send_to_client(message_json: str) -> bool:
try:
await client_ws.close()
except Exception:
pass
await client_ws.send_text(message_json)
except Exception as e: # noqa: BLE001 # any send failure means the client is gone
verbose_proxy_logger.debug("Bedrock to client forwarding ended: %s", e, exc_info=True)
return False
verbose_proxy_logger.debug("Bedrock Realtime: Sent to client: %s", message_json[:200])
return True
output: Final = await bedrock_stream.await_output()
while True:
result = await output[1].receive()
if result is None:
verbose_proxy_logger.debug("Bedrock Realtime: Bedrock stream ended")
with contextlib.suppress(Exception):
await client_ws.close()
return
payload_bytes = result.value.bytes_ if result.value else None
if payload_bytes:
bedrock_response = payload_bytes.decode("utf-8")
verbose_proxy_logger.debug("Bedrock Realtime: Received from Bedrock: %s", bedrock_response[:200])
# Transform Bedrock format to OpenAI format
realtime_response_transform_input: RealtimeResponseTransformInput = {
"current_output_item_id": session_state.get("current_output_item_id"),
"current_response_id": session_state.get("current_response_id"),
"current_conversation_id": session_state.get("current_conversation_id"),
"current_delta_chunks": session_state.get("current_delta_chunks"),
"current_item_chunks": session_state.get("current_item_chunks"),
"current_delta_type": session_state.get("current_delta_type"),
"session_configuration_request": session_state.get("session_configuration_request"),
}
transformed_response = transformation_config.transform_realtime_response(
message=bedrock_response,
model=model,
logging_obj=logging_obj,
realtime_response_transform_input=realtime_response_transform_input,
)
# Update session state
session_state.update(
{
"current_output_item_id": transformed_response.get("current_output_item_id"),
"current_response_id": transformed_response.get("current_response_id"),
"current_conversation_id": transformed_response.get("current_conversation_id"),
"current_delta_chunks": transformed_response.get("current_delta_chunks"),
"current_item_chunks": transformed_response.get("current_item_chunks"),
"current_delta_type": transformed_response.get("current_delta_type"),
"session_configuration_request": transformed_response.get("session_configuration_request"),
}
)
# Send transformed messages to client
response_value = transformed_response["response"]
openai_messages = response_value if isinstance(response_value, list) else (response_value,)
for openai_message in openai_messages:
if not await send_to_client(json.dumps(openai_message)):
return
if _should_log_event(openai_message):
yield openai_message

View file

@ -2635,9 +2635,13 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
None,
description="max file size in MB for /v1/files uploads, for any purpose, if a file is larger than this size it will be rejected before being forwarded to the provider",
)
allowed_file_extensions: tuple[str, ...] | None = Field(
None,
description="the only file extensions (e.g. ['.jsonl', '.pdf', '.txt']) accepted on /v1/files uploads, for any purpose, matched case-insensitively against the uploaded filename. Files with any other extension, or none, are rejected. An empty list rejects every upload. Unset means no allowlist is applied",
)
blocked_file_extensions: tuple[str, ...] | None = Field(
None,
description="file extensions (e.g. ['.exe', '.sh']) rejected on /v1/files uploads, for any purpose, matched case-insensitively against the uploaded filename",
description="file extensions (e.g. ['.exe', '.sh']) rejected on /v1/files uploads, for any purpose, matched case-insensitively against the uploaded filename. Deprecated in favour of allowed_file_extensions; still enforced, after the allowlist, when set",
)
max_response_size_mb: int | None = Field(
None,

View file

@ -123,6 +123,7 @@ from litellm.repositories.table_repositories import (
from litellm.repositories.team_repository import TeamRepository
from litellm.repositories.user_repository import UserRepository
from litellm.router import Router
from litellm.types.proxy.auth.auth_checks import UserNotFoundError
from litellm.types.proxy.model_access_group_budget import ModelAccessGroupBudget
from litellm.utils import get_utc_datetime
@ -2375,13 +2376,6 @@ async def _backfill_null_user_email(
return updated_row
class UserNotFoundError(ValueError):
"""The user row is provably absent, as opposed to merely unreadable, so a caller that reads a missing row as no user-level limits can key on it without also swallowing a database that would not answer."""
def __init__(self, user_id: str) -> None:
super().__init__(f"User doesn't exist in db. 'user_id'={user_id}. Create user via `/user/new` call.")
@log_db_metrics
async def get_user_object(
user_id: str | None,

View file

@ -28,11 +28,11 @@ from litellm.proxy._types import (
)
from litellm.proxy.auth.auth_checks import (
TeamNotFoundError,
UserNotFoundError,
get_team_membership,
get_team_object,
get_user_object,
)
from litellm.types.proxy.auth.auth_checks import UserNotFoundError
if TYPE_CHECKING:
from litellm.proxy._types import Span

View file

@ -136,6 +136,9 @@ class RouteChecks:
# For llm_api_routes, also check registered pass-through endpoints
################################################
if allowed_route == "llm_api_routes":
if route == "/auto_router/session" and RouteChecks._get_request_method(request) == "GET":
return True
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
InitPassThroughEndpointHelpers,
)

View file

@ -585,7 +585,9 @@ LiteLLM ████████░░░░░░░░░░░░░░
Claude Opus 5 ████████████████████████ $0.38
```
The routed model comes from Claude Code's own transcript, so it only names the tier model when the auto-router deployment sets `return_raw_model_name: true` (the `lite autoroute` wizard does); otherwise it shows the alias you requested. The cost lines come from `GET /auto_router/session?session_id=...`, which any virtual key may call for its own sessions, and are cached for five seconds under a per-user `$TMPDIR/litellm-statusline-<uid>` directory. The baseline is the priciest model in the router's hardest tier, the same counterfactual the auto-router's savings reports use. `lite unconfigure claude` removes the `statusLine` entry only while it still points at that script.
After the first response, the status line uses the latest routed model recorded by `GET /auto_router/session?session_id=...`, so it can show the tier model even when the transcript contains the router alias. If no session record is available, it falls back to Claude Code's transcript. Session records and costs are cached for five seconds under a per-user `$TMPDIR/litellm-statusline-<uid>` directory. The gateway records turns asynchronously, so the display can briefly lag a completed turn. Any virtual key may read its own sessions. The baseline is the priciest model in the router's hardest tier, the same counterfactual the auto-router's savings reports use. `lite unconfigure claude` removes the `statusLine` entry only while it still points at that script
After upgrading the CLI, rerun your original `lite configure claude` command with the same gateway, key and model choice to refresh `~/.litellm/statusline.py`. Keep any explicit `--model` value: omitting it removes the earlier model pin. Package upgrades alone do not refresh this installed copy
`lite codex` registers the same script as a Codex `Stop` hook for the launch, so after each turn Codex prints the same block as a system message. Codex asks once to trust the hook; the answer is remembered for later launches.

View file

@ -7,19 +7,17 @@ status refresh (about every 300ms while typing), so the proxy is asked at most o
TTL per session and every other refresh is served from a small on-disk cache that holds
only the proxy's answer, never the key.
Claude Code pipes a JSON payload on stdin (session_id, transcript_path, model); the routed
model is the `message.model` of the latest foreground assistant line in the transcript,
which is the proxy's response `model` field. That only names the tier model when the
auto-router deployment sets `return_raw_model_name: true`; otherwise it is the alias the
client requested. Codex pipes its Stop event instead (hook_event_name, session_id) and has
no transcript to read, so the routed model comes from the proxy's session record and the
result is printed as a `systemMessage` for the transcript. The proxy key is read from the
agent's own environment (the static token `lite configure claude` writes); nothing here
spawns a credential helper.
Claude Code pipes a JSON payload on stdin (session_id, transcript_path, model). After the
first foreground assistant response, the routed model comes from the proxy's session
record, falling back to the latest foreground assistant `message.model` in the transcript
when no record is available. Codex pipes its Stop event instead (hook_event_name, session_id)
and prints the session record as a `systemMessage` for the transcript. The proxy key is read
from the agent's own environment (the static token `lite configure claude` writes); nothing
here spawns a credential helper.
Cost figures come from GET /auto_router/session on the proxy, which reads the per-session
rollup written by the spend flush. That flush is asynchronous, so a turn's cost lands a
second or two after the turn; the cache TTL absorbs it.
The routed model and cost figures come from GET /auto_router/session on the proxy, which
reads the per-session rollup written by the asynchronous spend flush. The record and cache
can briefly lag a completed turn.
"""
from __future__ import annotations
@ -348,7 +346,8 @@ def status_line(
if not session_id or not credentials.usable:
return render(label, None, config_dir, color_enabled(env))
session: Final = load_session(credentials, session_id, cache_dir, fetch)
return render(label, session, config_dir, color_enabled(env))
routed_label: Final = model_label(session.last_model, config_dir) if session is not None else label
return render(routed_label, session, config_dir, color_enabled(env))
def codex_stop_message(

View file

@ -3452,15 +3452,13 @@ class ProxyBaseLLMRequestProcessing:
# a failed request reports no timing, matching /v1/chat/completions
read_timing_from_logging_obj=False,
)
# Extract headers from exception - check both e.headers and e.response.headers
headers = getattr(e, "headers", None) or {}
if not headers:
# Try to get headers from e.response.headers (httpx.Response)
_response: Final = attribute_of(e, "response")
if _response is not None:
_response_headers: Final = getattr(_response, "headers", None)
if _response_headers:
headers = get_response_headers(dict(_response_headers))
_response_headers: Final = getattr(_response, "headers", None) if _response is not None else None
_provider_headers: Final = _response_headers or getattr(e, "litellm_response_headers", None)
if _provider_headers:
headers = get_response_headers(dict(_provider_headers))
headers.update(custom_headers)
# Call response headers hook for failure

View file

@ -0,0 +1,10 @@
import os
from collections.abc import Mapping
def should_hide_default_credentials_hint(general_settings: Mapping[str, object]) -> bool:
return (
os.getenv("LITELLM_HIDE_DEFAULT_CREDENTIALS_HINT", "false").lower() == "true"
or general_settings.get("hide_default_credentials_hint", False) is True
or bool(os.getenv("UI_PASSWORD"))
)

View file

@ -4,6 +4,7 @@ from typing import Final
from fastapi import APIRouter
from litellm.proxy.common_utils.html_forms.default_credentials_hint import should_hide_default_credentials_hint
from litellm.types.proxy.discovery_endpoints.ui_discovery_endpoints import (
UiDiscoveryEndpoints,
)
@ -23,10 +24,7 @@ async def get_ui_config():
or general_settings.get("auto_redirect_ui_login_to_sso", False) is True
)
admin_ui_disabled: Final = os.getenv("DISABLE_ADMIN_UI", "false").lower() == "true"
hide_default_credentials_hint: Final = bool(
os.getenv("LITELLM_HIDE_DEFAULT_CREDENTIALS_HINT", "false").lower() == "true"
or general_settings.get("hide_default_credentials_hint", False) is True
)
hide_default_credentials_hint: Final = should_hide_default_credentials_hint(general_settings)
sso_configured: Final = has_user_setup_sso()

View file

@ -6,11 +6,10 @@ from typing import Final
import litellm
from litellm._logging import verbose_proxy_logger
from litellm.constants import PROXY_LLM_PROVIDER_FALLBACK
from litellm.types.router import ModelGroupInfo
from litellm.types.utils import PriorityReservationDict
PROXY_LLM_PROVIDER_FALLBACK: Final = "litellm_proxy"
def resolve_llm_provider_for_rate_limit(
model: str | None,

View file

@ -156,6 +156,7 @@ from litellm.repositories.verification_token_repository import (
VerificationTokenRepository,
)
from litellm.router import Router
from litellm.types.proxy.auth.auth_checks import UserNotFoundError
from litellm.types.proxy.management_endpoints.common_daily_activity import (
SpendAnalyticsPaginatedResponse,
)
@ -179,6 +180,7 @@ from litellm.types.proxy.management_endpoints.team_endpoints import (
if TYPE_CHECKING:
from prisma import Prisma
from prisma import models as prisma_models
from prisma import types as prisma_types
router: Final = APIRouter()
@ -4857,6 +4859,26 @@ async def _get_org_admin_org_ids(
return org_ids if org_ids else None
async def _get_user_team_ids_from_db(
user_id: str,
prisma_client: PrismaClient,
user_api_key_cache: UserApiKeyCache,
proxy_logging_obj: ProxyLogging,
) -> tuple[str, ...]:
try:
user: Final = await get_user_object(
user_id=user_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
user_id_upsert=False,
proxy_logging_obj=proxy_logging_obj,
check_db_only=True,
)
except UserNotFoundError:
return ()
return tuple(user.teams or ()) if user is not None else ()
async def _build_team_list_where_conditions(
prisma_client: PrismaClient,
team_id: str | None,
@ -4867,12 +4889,16 @@ async def _build_team_list_where_conditions(
search: str | None = None,
search_team_id_match: TeamIdSearchMatch = "exact",
org_admin_org_ids: list[str] | None = None,
own_team_ids: tuple[str, ...] = (),
user_api_key_cache: UserApiKeyCache | None = None,
proxy_logging_obj: ProxyLogging | None = None,
) -> dict[str, object] | None:
"""
Build where conditions for team list query.
An org admin listing their own teams sees the union of the teams in the
orgs they administer and `own_team_ids`, the teams they are a member of.
Returns None when the query is guaranteed to yield no results (e.g. user
has no team memberships), allowing the caller to skip the DB round-trip.
"""
@ -4895,6 +4921,11 @@ async def _build_team_list_where_conditions(
if organization_id:
where_conditions["organization_id"] = organization_id
elif org_admin_org_ids is not None and own_team_ids:
org_or_membership_scope: Final[prisma_types.LiteLLM_TeamTableWhereInput] = {
"OR": [{"organization_id": {"in": org_admin_org_ids}}, {"team_id": {"in": list(own_team_ids)}}]
}
where_conditions["AND"] = [org_or_membership_scope]
elif org_admin_org_ids is not None:
# Org admin: always scope to their orgs, even when filtering by user_id.
where_conditions["organization_id"] = {"in": org_admin_org_ids}
@ -5026,66 +5057,72 @@ async def _enforce_list_team_v2_access(
prisma_client: PrismaClient,
user_api_key_cache: UserApiKeyCache,
proxy_logging_obj: ProxyLogging,
) -> tuple[str | None, list[str] | None]:
) -> tuple[str | None, list[str] | None, tuple[str, ...]]:
"""Enforce access control for list_team_v2.
- Proxy admins and admin viewers can query any teams.
- Org admins can query teams within their organizations.
- Org admins can query teams within their organizations, plus the teams
they are a member of when listing their own teams.
- Regular users can only query their own teams.
Returns the (possibly overridden) user_id and org_admin_org_ids.
Returns the (possibly overridden) user_id, org_admin_org_ids and, for an
org admin's own query, the caller's own team ids.
"""
is_proxy_admin: Final = _user_has_admin_view(user_api_key_dict)
org_admin_org_ids: list[str] | None = None
caller_user_id: Final = user_api_key_dict.user_id
if is_proxy_admin:
return user_id, org_admin_org_ids
return user_id, None, ()
# Always check org admin status so that even own-queries see
# the full set of organisation teams, not just direct memberships.
if user_api_key_dict.user_id:
org_admin_org_ids = await _get_org_admin_org_ids(
user_id=user_api_key_dict.user_id,
org_admin_org_ids: Final = (
await _get_org_admin_org_ids(
user_id=caller_user_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
if caller_user_id
else None
)
if org_admin_org_ids is not None:
if caller_user_id and org_admin_org_ids is not None:
# Org admin: validate org_id filter if provided
if organization_id and organization_id not in org_admin_org_ids:
raise HTTPException(
status_code=403,
detail={"error": "You can only view teams within your organizations."},
)
# When the caller is an org admin querying their own teams (or no
# specific user), null out user_id so that
# _build_team_list_where_conditions scopes only by organization_id
# — org admins should see all teams in their orgs, not just teams
# they are a direct member of. Keep user_id when the org admin
# explicitly queries a *different* user's teams.
if user_id is None or user_id == user_api_key_dict.user_id:
user_id = None
is_own_query: Final = user_id is None or user_id == caller_user_id
own_team_ids: Final = (
await _get_user_team_ids_from_db(
user_id=caller_user_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
if is_own_query
else ()
)
verbose_proxy_logger.debug(
"list_team_v2: org admin access for user=%s, org_ids=%s, user_id_filter=%s",
user_api_key_dict.user_id,
_sanitize_for_log(caller_user_id),
org_admin_org_ids,
user_id,
_sanitize_for_log(None if is_own_query else user_id),
)
else:
# Not an org admin — fall back to standard route check
if not allowed_route_check_inside_route(user_api_key_dict=user_api_key_dict, requested_user_id=user_id):
raise HTTPException(
status_code=401,
detail={
"error": f"Only admin users can query all teams/other teams. Your user role={user_api_key_dict.user_role}"
},
)
# Regular user — auto-inject caller's user_id
if user_id is None:
user_id = user_api_key_dict.user_id
return None if is_own_query else user_id, org_admin_org_ids, own_team_ids
return user_id, org_admin_org_ids
# Not an org admin — fall back to standard route check
if not allowed_route_check_inside_route(user_api_key_dict=user_api_key_dict, requested_user_id=user_id):
raise HTTPException(
status_code=401,
detail={
"error": f"Only admin users can query all teams/other teams. Your user role={user_api_key_dict.user_role}"
},
)
# Regular user — auto-inject caller's user_id
return user_id if user_id is not None else caller_user_id, None, ()
@router.get(
@ -5163,7 +5200,7 @@ async def list_team_v2(
)
# --- Access control ---
user_id, org_admin_org_ids = await _enforce_list_team_v2_access(
user_id, org_admin_org_ids, own_team_ids = await _enforce_list_team_v2_access(
user_api_key_dict=user_api_key_dict,
user_id=user_id,
organization_id=organization_id,
@ -5195,6 +5232,7 @@ async def list_team_v2(
search=search,
search_team_id_match=search_team_id_match,
org_admin_org_ids=org_admin_org_ids,
own_team_ids=own_team_ids,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
@ -5291,17 +5329,16 @@ async def _authorize_and_filter_teams(
- Proxy admins: all teams (or filtered by user_id if provided).
- Org admins: teams from their orgs (scoped to user_id if provided).
- Own query (user_id matches caller): teams the user is a member of.
- Own query (user_id matches caller): teams the user is a member of, across all orgs.
- Others: 401.
"""
is_proxy_admin: Final = _user_has_admin_view(user_api_key_dict)
is_own_query: Final = (
user_id is not None and user_api_key_dict.user_id is not None and user_api_key_dict.user_id == user_id
)
allowed_org_ids: list[str] | None = None
if not is_proxy_admin:
is_own_query: Final = (
user_id is not None and user_api_key_dict.user_id is not None and user_api_key_dict.user_id == user_id
)
# Check if user is an org admin (even for own queries, so they see org teams)
if user_api_key_dict.user_id is not None:
caller_user: Final = await get_user_object(
@ -5328,33 +5365,30 @@ async def _authorize_and_filter_teams(
},
)
if allowed_org_ids is not None:
# Org admin: query DB for teams in their orgs
if allowed_org_ids is not None and not is_own_query:
org_teams: Final = await _raw_team_db(TeamRepository(prisma_client)).find_many(
where={"organization_id": {"in": allowed_org_ids}},
include={"litellm_model_table": True},
)
if not user_id:
return list(org_teams)
# Filter org teams to only those where the target user is a member
return [
team
for team in org_teams
if team.members_with_roles and any(m.get("user_id") == user_id for m in team.members_with_roles)
]
elif user_id:
# Regular user: fetch all and filter by membership (Prisma can't filter JSON arrays)
response: Final = await _raw_team_db(TeamRepository(prisma_client)).find_many(
include={"litellm_model_table": True}
)
return [
team
for team in response
if team.members_with_roles and any(m.get("user_id") == user_id for m in team.members_with_roles)
]
else:
response: Final = await _raw_team_db(TeamRepository(prisma_client)).find_many(include={"litellm_model_table": True})
if not user_id:
# Proxy admin: all teams
return list(await _raw_team_db(TeamRepository(prisma_client)).find_many(include={"litellm_model_table": True}))
return list(response)
# Prisma can't filter JSON arrays, so membership is filtered in Python
return [
team
for team in response
if team.members_with_roles and any(m.get("user_id") == user_id for m in team.members_with_roles)
]
@router.get("/team/list", tags=["team management"], dependencies=[Depends(user_api_key_auth)])

View file

@ -101,6 +101,7 @@ from litellm.proxy.common_utils.admin_ui_utils import (
admin_ui_disabled,
show_missing_vars_in_env,
)
from litellm.proxy.common_utils.html_forms.default_credentials_hint import should_hide_default_credentials_hint
from litellm.proxy.common_utils.html_forms.jwt_display_template import (
jwt_display_template,
)
@ -1110,10 +1111,7 @@ async def google_login(
from fastapi.responses import HTMLResponse
hide_default_credentials_hint: Final = (
os.getenv("LITELLM_HIDE_DEFAULT_CREDENTIALS_HINT", "false").lower() == "true"
or general_settings.get("hide_default_credentials_hint", False) is True
)
hide_default_credentials_hint: Final = should_hide_default_credentials_hint(general_settings)
form_response: Final = HTMLResponse(
content=build_ui_login_form(
show_deprecation_banner=True,

View file

@ -78,6 +78,7 @@ from litellm.proxy.openai_files_endpoints.common_utils import (
)
from litellm.proxy.openai_files_endpoints.general_upload_validation import (
MB,
check_allowed_extension,
check_blocked_extension,
check_unsafe_filename,
check_upload_file_size,
@ -473,6 +474,11 @@ async def create_file(
if general_size_failure is not None:
raise_upload_validation_failure(general_size_failure)
allowed_extensions: Final = coerce_optional_str_list_setting(general_settings.get("allowed_file_extensions"))
allowed_extension_failure: Final = check_allowed_extension(file.filename, allowed_extensions)
if allowed_extension_failure is not None:
raise_upload_validation_failure(allowed_extension_failure)
blocked_extensions: Final = coerce_optional_str_list_setting(general_settings.get("blocked_file_extensions"))
blocked_extension_failure: Final = check_blocked_extension(file.filename, blocked_extensions)
if blocked_extension_failure is not None:

View file

@ -2,8 +2,8 @@
Upload validation applied to every purpose at POST /v1/files.
batch_file_validation.py checks the JSONL shape of purpose="batch" uploads; this
module applies the same fast-fail-before-forwarding shape (size cap, blocked
extensions, path-traversal filenames) regardless of purpose.
module applies the same fast-fail-before-forwarding shape (size cap, allowed and
blocked extensions, path-traversal filenames) regardless of purpose.
"""
from dataclasses import dataclass
@ -31,10 +31,9 @@ def coerce_optional_int_setting(raw: object) -> int | None:
raise TypeError(f"expected an integer, got {raw!r}")
def coerce_optional_str_list_setting(raw: object) -> tuple[str, ...]:
"""A general_settings value declared as an optional list of strings, e.g. blocked_file_extensions."""
def coerce_optional_str_list_setting(raw: object) -> tuple[str, ...] | None:
if raw is None:
return ()
return None
if not isinstance(raw, list) or not all(isinstance(item, str) for item in raw):
raise TypeError(f"expected a list of strings, got {raw!r}")
return tuple(raw)
@ -46,6 +45,11 @@ class UploadedFileTooLarge:
limit_mb: int
@dataclass(frozen=True, slots=True)
class UploadedFileExtensionNotAllowed:
extension: str
@dataclass(frozen=True, slots=True)
class UploadedFileBlockedExtension:
extension: str
@ -56,7 +60,9 @@ class UploadedFileUnsafeFilename:
filename: str
UploadValidationFailure = UploadedFileTooLarge | UploadedFileBlockedExtension | UploadedFileUnsafeFilename
UploadValidationFailure = (
UploadedFileTooLarge | UploadedFileExtensionNotAllowed | UploadedFileBlockedExtension | UploadedFileUnsafeFilename
)
def _file_size_bytes(file_source: bytes | BinaryIO) -> int:
@ -81,19 +87,35 @@ def check_upload_file_size(
return None
def _normalized_extension(filename: str | None) -> str:
if not filename:
return ""
try:
return Path(safe_filename(filename)).suffix.lower()
except ValueError:
return ""
def check_allowed_extension(
filename: str | None,
allowed_extensions: tuple[str, ...] | None,
) -> UploadedFileExtensionNotAllowed | None:
if allowed_extensions is None:
return None
extension: Final = _normalized_extension(filename)
normalized_allowed: Final = frozenset(item.lower() for item in allowed_extensions)
if extension and extension in normalized_allowed:
return None
return UploadedFileExtensionNotAllowed(extension=extension)
def check_blocked_extension(
filename: str | None,
blocked_extensions: tuple[str, ...],
blocked_extensions: tuple[str, ...] | None,
) -> UploadedFileBlockedExtension | None:
if not blocked_extensions or not filename:
if not blocked_extensions:
return None
try:
extension: Final = Path(safe_filename(filename)).suffix.lower()
except ValueError:
return None
# The uploaded name's extension is normalized above; blocked_extensions comes
# straight from config.yaml or the DB and is normalized here too, so a
# differently-cased entry (".EXE") still catches a lowercase upload.
extension: Final = _normalized_extension(filename)
normalized_blocked: Final = frozenset(item.lower() for item in blocked_extensions)
if extension and extension in normalized_blocked:
return UploadedFileBlockedExtension(extension=extension)
@ -128,6 +150,17 @@ def raise_upload_validation_failure(failure: UploadValidationFailure) -> NoRetur
param="file",
code=413,
)
case UploadedFileExtensionNotAllowed(extension=extension):
raise ProxyException(
message=(
(f"File extension '{extension}'" if extension else "A file without an extension")
+ " is not in this proxy's allowed_file_extensions setting. "
"The file was not forwarded to the provider."
),
type="invalid_request_error",
param="file",
code=400,
)
case UploadedFileBlockedExtension(extension=extension):
raise ProxyException(
message=(

View file

@ -250,7 +250,7 @@ import litellm._redis
from litellm import Router
from litellm._logging import _redact_string, verbose_proxy_logger, verbose_router_logger
from litellm.caching.caching import DualCache, RedisCache
from litellm.caching.redis_cache import RedisCircuitBreakerOpenError
from litellm.caching.redis_cache import RedisCircuitBreakerOpenError, is_redis_timeout_failure
from litellm.caching.redis_cluster_cache import RedisClusterCache
from litellm.constants import (
_REALTIME_BODY_CACHE_SIZE,
@ -365,6 +365,7 @@ from litellm.proxy.common_utils.healthy_model_filter import (
get_hidden_unhealthy_model_names,
is_healthy_only_listing_default,
)
from litellm.proxy.common_utils.html_forms.default_credentials_hint import should_hide_default_credentials_hint
from litellm.proxy.common_utils.html_forms.ui_login import build_ui_login_form
from litellm.proxy.common_utils.http_parsing_utils import (
_read_request_body,
@ -3450,8 +3451,10 @@ async def _invalidate_spend_counter(counter_key: str):
async def _apply_spend_counter_increments(pending: Sequence[PendingSpendIncrement]) -> None:
try:
await increment_spend_counters_pipeline(pending=pending)
except RedisCircuitBreakerOpenError:
return
except Exception as e:
if isinstance(e, RedisCircuitBreakerOpenError) or is_redis_timeout_failure(e):
return
raise
async def increment_spend_counters_pipeline(pending: Sequence[PendingSpendIncrement]) -> None:
@ -7065,6 +7068,9 @@ class ProxyConfig:
if "max_file_size_mb" not in self._yaml_general_settings_keys:
general_settings["max_file_size_mb"] = _general_settings.get("max_file_size_mb")
if "allowed_file_extensions" not in self._yaml_general_settings_keys:
general_settings["allowed_file_extensions"] = _general_settings.get("allowed_file_extensions")
if "blocked_file_extensions" not in self._yaml_general_settings_keys:
general_settings["blocked_file_extensions"] = _general_settings.get("blocked_file_extensions")
@ -15047,6 +15053,13 @@ def _get_proxy_model_info(model: dict) -> dict:
return _translate_model_name_for_response(model)
def _model_info_json_response(data: Sequence[Mapping[str, object]] | Mapping[str, object]) -> Response:
return Response(
content=orjson.dumps({"data": data}, default=jsonable_encoder, option=orjson.OPT_NON_STR_KEYS),
media_type="application/json",
)
@router.get(
"/model/info",
tags=["model management"],
@ -15094,7 +15107,7 @@ async def model_info_v1(
`model_info.direct_access` when the proxy database is connected.
Returns:
Returns a dictionary containing information about each model.
A JSON response whose `data` list holds one entry per model.
Example Response:
```json
@ -15142,7 +15155,7 @@ async def model_info_v1(
deployment_dict=_deployment_info_dict,
excluded_keys={"litellm_credential_name"},
)
return {"data": _deployment_info_dict}
return _model_info_json_response(_deployment_info_dict)
if llm_model_list is None:
raise HTTPException(
@ -15193,7 +15206,7 @@ async def model_info_v1(
llm_router=llm_router,
user_api_key_dict=user_api_key_dict,
)
return {"data": single_model_list}
return _model_info_json_response(single_model_list)
# Return router deployments (same source as /v2/model/info), not wildcard-
# expanded model names from get_complete_model_list(). Team-scoped rows
@ -15261,7 +15274,7 @@ async def model_info_v1(
visible_models: Final = [model for model in all_models if model.get("model_name") not in hidden_names]
verbose_proxy_logger.debug("all_models: %s", visible_models)
return {"data": visible_models}
return _model_info_json_response(visible_models)
@router.get(
@ -15830,10 +15843,7 @@ async def fallback_login(request: Request):
from fastapi.responses import HTMLResponse
hide_default_credentials_hint: Final = (
os.getenv("LITELLM_HIDE_DEFAULT_CREDENTIALS_HINT", "false").lower() == "true"
or general_settings.get("hide_default_credentials_hint", False) is True
)
hide_default_credentials_hint: Final = should_hide_default_credentials_hint(general_settings)
return HTMLResponse(
content=build_ui_login_form(
show_deprecation_banner=False,
@ -17050,6 +17060,7 @@ _GENERAL_SETTINGS_CONFIG_LIST_FIELD_TYPES: Final[Mapping[str, str]] = MappingPro
"max_request_size_mb": "Integer",
"max_batch_file_size_mb": "Integer",
"max_file_size_mb": "Integer",
"allowed_file_extensions": "List",
"blocked_file_extensions": "List",
"max_response_size_mb": "Integer",
"proxy_config_reload_interval_seconds": "Integer",

View file

@ -96,7 +96,6 @@ from litellm.litellm_core_utils.request_timeout_resolver import (
from litellm.litellm_core_utils.secret_redaction import redact_string
from litellm.litellm_core_utils.sensitive_data_masker import (
SensitiveDataMasker,
mask_credentials_in_payload,
mask_sensitive_structure,
)
from litellm.litellm_core_utils.token_counter import offload_token_count
@ -623,20 +622,6 @@ def _replay_live_router_model_cost() -> None:
set_live_deployment_replay(_replay_live_router_model_cost)
# Kwargs that carry no signal about the failed attempt, so log_retry drops them from a
# breadcrumb entirely: the request payload, the proxy's snapshot of the inbound request (its body
# aliases the live request metadata, earlier breadcrumbs included, so copying it would nest every
# breadcrumb inside the next one), and the router-internal walk state. Credentials are handled
# separately by mask_credentials_in_payload, which scrubs credential-named values from whatever
# kwargs remain rather than trying to enumerate every credential-bearing key here.
RETRY_BREADCRUMB_EXCLUDED_KWARGS: Final = frozenset(
(
"messages",
"original_function",
"attempted_targets",
"proxy_server_request",
)
)
RETRY_BREADCRUMB_LIMIT: Final = 4
@ -8374,31 +8359,30 @@ class Router:
def log_retry(self, kwargs: dict, e: Exception) -> dict:
"""
When a retry or fallback happens, log the details of the just failed model call - similar to Sentry breadcrumbing
When a retry or fallback happens, record which model group, deployment and attempt just failed and why
"""
from litellm.types.router import RetryAttemptRecord
_metadata_var: Final = "litellm_metadata" if "litellm_metadata" in kwargs else "metadata"
request_metadata: Final[Mapping[str, object]] = kwargs[_metadata_var]
attempt_kwargs: Final = MappingProxyType(
{k: v for k, v in kwargs.items() if k != _metadata_var and k not in RETRY_BREADCRUMB_EXCLUDED_KWARGS}
)
attempt_metadata: Final = MappingProxyType(
{k: v for k, v in request_metadata.items() if k != "previous_models"}
)
previous_model: Final = MappingProxyType(
{
"exception_type": type(e).__name__,
"exception_string": str(e),
**attempt_kwargs,
_metadata_var: attempt_metadata,
}
)
model_group: Final = kwargs.get("model")
model_info: Final = request_metadata.get("model_info")
deployment_id: Final = model_info.get("id") if isinstance(model_info, Mapping) else None
attempted_retries: Final = request_metadata.get("attempted_retries")
attempt_record: Final[RetryAttemptRecord] = {
"model_group": model_group if isinstance(model_group, str) else None,
"deployment_id": deployment_id if isinstance(deployment_id, str) else None,
"exception_type": type(e).__name__,
"exception_string": str(e),
"attempted_retries": attempted_retries if type(attempted_retries) is int else None,
}
earlier_breadcrumbs: Final = request_metadata.get("previous_models")
kept_breadcrumbs: Final[tuple[object, ...]] = (
tuple(earlier_breadcrumbs)[-(RETRY_BREADCRUMB_LIMIT - 1) :]
if isinstance(earlier_breadcrumbs, (list, tuple))
else ()
)
breadcrumbs: Final = (*kept_breadcrumbs, mask_credentials_in_payload(previous_model))
breadcrumbs: Final = (*kept_breadcrumbs, attempt_record)
kwargs[_metadata_var]["previous_models"] = breadcrumbs # rebind-ok: the logging object already holds this dict
return kwargs
@ -13878,6 +13862,7 @@ class Router:
cooldown_time=_cooldown_time,
enable_pre_call_checks=self.enable_pre_call_checks,
cooldown_list=_cooldown_list,
model_ids=model_ids,
)
if strategy == "simple-shuffle":
@ -13910,6 +13895,7 @@ class Router:
cooldown_time=_cooldown_time,
enable_pre_call_checks=self.enable_pre_call_checks,
cooldown_list=_cooldown_list,
model_ids=model_ids,
)
self._override_selector_pre_call_check(strategy, strategy_selector, deployment)
verbose_router_logger.info(
@ -13987,6 +13973,11 @@ class Router:
model=model,
llm_provider="",
)
pass_through_model_ids: Final = tuple(
deployment["model_info"]["id"]
for deployment in pass_through_deployments
if "id" in deployment.get("model_info", {})
)
# 4. Apply health-check and cooldown filtering
parent_otel_span: Final[Span | None] = _get_parent_otel_span_from_kwargs(request_kwargs)
@ -14024,6 +14015,7 @@ class Router:
cooldown_time=_cooldown_time,
enable_pre_call_checks=self.enable_pre_call_checks,
cooldown_list=_cooldown_list,
model_ids=pass_through_model_ids,
)
# 6. Apply load balancing strategy
@ -14057,6 +14049,7 @@ class Router:
cooldown_time=_cooldown_time,
enable_pre_call_checks=self.enable_pre_call_checks,
cooldown_list=_cooldown_list,
model_ids=model_ids,
)
self._override_selector_pre_call_check(strategy, strategy_selector, deployment)

View file

@ -93,4 +93,5 @@ async def async_raise_no_deployment_exception(
cooldown_time=_cooldown_time,
enable_pre_call_checks=litellm_router_instance.enable_pre_call_checks,
cooldown_list=cooldown_list_ids,
model_ids=model_ids,
)

View file

@ -99,23 +99,13 @@ def setup(
def check_limits(kwargs: Mapping[str, object]) -> None:
import litellm
from litellm.litellm_core_utils.core_helpers import max_retries_per_request_hit
current_cost: Final = litellm._current_cost # pyright: ignore[reportPrivateUsage] # shared SDK budget counter has no public accessor
if litellm.max_budget and current_cost > litellm.max_budget:
raise litellm.BudgetExceededError(current_cost=current_cost, max_budget=litellm.max_budget)
metadata: Final = kwargs.get("metadata")
if isinstance(metadata, Mapping):
typed_metadata: Final = cast( # cast-ok: runtime Mapping check establishes read-only metadata
Mapping[str, object], metadata
)
previous: Final = typed_metadata.get("previous_models")
if (
isinstance(previous, list)
and litellm.num_retries_per_request is not None
and len(cast(list[object], previous)) # cast-ok: runtime list check establishes the retry history
>= litellm.num_retries_per_request
):
raise RuntimeError("Max retries per request hit!")
if max_retries_per_request_hit(kwargs, litellm.num_retries_per_request):
raise RuntimeError("Max retries per request hit!")
def finalize(

View file

@ -0,0 +1,8 @@
"""Failure values raised by `litellm/proxy/auth/auth_checks.py`. Kept free of `litellm` imports so any proxy module can import them without joining the `litellm.proxy` import cycle."""
class UserNotFoundError(ValueError):
"""The user row is provably absent, as opposed to merely unreadable, so a caller that reads a missing row as no user-level limits can key on it without also swallowing a database that would not answer."""
def __init__(self, user_id: str) -> None:
super().__init__(f"User doesn't exist in db. 'user_id'={user_id}. Create user via `/user/new` call.")

View file

@ -645,6 +645,7 @@ class RouterErrors(enum.Enum):
user_defined_ratelimit_error = "Deployment over user-defined ratelimit."
no_deployments_available = "No deployments available for selected model"
all_deployments_in_cooldown = "All deployments for selected model are in cooldown"
no_deployments_with_tag_routing = "Not allowed to access model due to tags configuration"
no_deployments_with_provider_budget_routing = "No deployments available - crossed budget"
no_healthy_deployments = "There are no healthy deployments for this model"
@ -868,6 +869,11 @@ class RouterRateLimitErrorBasic(ValueError):
super().__init__(_message)
class RouterErrorTypes(str, enum.Enum):
rate_limit_error = "rate_limit_error"
all_deployments_in_cooldown = "all_deployments_in_cooldown"
class RouterRateLimitError(ValueError):
def __init__(
self,
@ -875,12 +881,25 @@ class RouterRateLimitError(ValueError):
cooldown_time: float,
enable_pre_call_checks: bool,
cooldown_list: list,
model_ids: Sequence[str] = (),
) -> None:
self.model = model
self.cooldown_time = cooldown_time
self.enable_pre_call_checks = enable_pre_call_checks
self.cooldown_list = cooldown_list
_message = f"{RouterErrors.no_deployments_available.value}, Try again in {cooldown_time} seconds. Passed model={model}. pre-call-checks={enable_pre_call_checks}, cooldown_list={cooldown_list}"
self.all_deployments_in_cooldown = bool(model_ids) and frozenset(model_ids) <= frozenset(cooldown_list)
self.type = (
RouterErrorTypes.all_deployments_in_cooldown.value
if self.all_deployments_in_cooldown
else RouterErrorTypes.rate_limit_error.value
)
_reason: Final = (
f" {RouterErrors.all_deployments_in_cooldown.value}." if self.all_deployments_in_cooldown else ""
)
_message: Final = (
f"{RouterErrors.no_deployments_available.value}, Try again in {cooldown_time} seconds.{_reason} "
f"Passed model={model}. pre-call-checks={enable_pre_call_checks}, cooldown_list={cooldown_list}"
)
super().__init__(_message)
@ -889,6 +908,14 @@ class RouterModelGroupAliasItem(TypedDict):
hidden: bool # if 'True', don't return on `.get_model_list`
class RetryAttemptRecord(TypedDict):
model_group: ReadOnly[str | None]
deployment_id: ReadOnly[str | None]
exception_type: ReadOnly[str]
exception_string: ReadOnly[str]
attempted_retries: ReadOnly[int | None]
VALID_LITELLM_ENVIRONMENTS = [
"development",
"staging",

View file

@ -1260,15 +1260,6 @@ async def _client_async_logging_helper(
async_coroutine=logging_obj.async_success_handler(result=result, start_time=start_time, end_time=end_time)
)
################################################
# Sync Logging Worker
################################################
logging_obj.handle_sync_success_callbacks_for_async_calls(
result=result,
start_time=start_time,
end_time=end_time,
)
def _get_wrapper_num_retries(kwargs: dict[str, Any], exception: Exception) -> tuple[int | None, dict[str, Any]]:
"""
@ -1500,6 +1491,8 @@ def post_call_processing(
def client(original_function):
from litellm.litellm_core_utils.core_helpers import max_retries_per_request_hit
Rules: Final = litellm_utils.Rules
rules_obj: Final = Rules()
@ -1510,12 +1503,8 @@ def client(original_function):
call_type = original_function.__name__
if _is_async_request(kwargs):
# [OPTIONAL] CHECK MAX RETRIES / REQUEST
if litellm.num_retries_per_request is not None:
# check if previous_models passed in as ['litellm_params']['metadata]['previous_models']
previous_models = (kwargs.get("metadata") or {}).get("previous_models", None)
if previous_models is not None:
if litellm.num_retries_per_request <= len(previous_models):
raise Exception("Max retries per request hit!")
if max_retries_per_request_hit(kwargs, litellm.num_retries_per_request):
raise Exception("Max retries per request hit!")
# MODEL CALL
result = original_function(*args, **kwargs)
@ -1574,12 +1563,8 @@ def client(original_function):
)
# [OPTIONAL] CHECK MAX RETRIES / REQUEST
if litellm.num_retries_per_request is not None:
# check if previous_models passed in as ['litellm_params']['metadata]['previous_models']
previous_models = (kwargs.get("metadata") or {}).get("previous_models", None)
if previous_models is not None:
if litellm.num_retries_per_request <= len(previous_models):
raise Exception("Max retries per request hit!")
if max_retries_per_request_hit(kwargs, litellm.num_retries_per_request):
raise Exception("Max retries per request hit!")
# [OPTIONAL] CHECK CACHE
print_verbose(

View file

@ -2139,7 +2139,7 @@ async def test_model_info_alias_without_prisma(hidden):
user_api_key_dict=UserAPIKeyAuth(models=[]),
)
models = resp["data"]
models = json.loads(resp.body)["data"]
alias_found = any(
m["model_name"] == model_alias
@ -2203,7 +2203,7 @@ async def test_proxy_model_group_alias_checks(prisma_client, hidden): # noqa: F
resp = await model_info_v1(
user_api_key_dict=UserAPIKeyAuth(models=[]),
)
models = resp["data"]
models = json.loads(resp.body)["data"]
is_model_alias_in_list = False
for item in models:
if model_alias == item["model_name"]:
@ -2280,7 +2280,7 @@ async def test_proxy_model_group_info_rerank(prisma_client): # noqa: F811 # py
resp = await model_info_v1(
user_api_key_dict=UserAPIKeyAuth(models=[]),
)
models = resp["data"]
models = json.loads(resp.body)["data"]
assert models[0]["model_info"]["mode"] == "rerank"
resp = await model_group_info(
user_api_key_dict=UserAPIKeyAuth(models=[]),

View file

@ -1,3 +1,4 @@
import json
import os
import traceback
from dotenv import load_dotenv
@ -628,17 +629,29 @@ def test_deployment_callback_respects_cooldown_time(model_list):
assert mock_set.call_args.kwargs["time_to_cooldown"] == 0
def test_log_retry(model_list):
"""Test if the '_log_retry' function is working correctly"""
import time
@pytest.mark.parametrize("metadata_key", ["metadata", "litellm_metadata"])
def test_log_retry(model_list, metadata_key):
"""log_retry appends one flat record per failed attempt and copies neither the request kwargs nor
the request metadata into it"""
router = Router(model_list=model_list)
new_kwargs = router.log_retry(
kwargs={"metadata": {}},
e=Exception(),
kwargs={
"model": "gpt-3.5-turbo",
"api_key": "sk-must-not-be-recorded",
"messages": [{"role": "user", "content": "hi"}],
metadata_key: {"model_info": {"id": "deployment-1"}, "attempted_retries": 2, "user_api_key": "sk-proxy"},
},
e=litellm.RateLimitError(message="slow down", llm_provider="openai", model="gpt-3.5-turbo"),
)
assert "metadata" in new_kwargs
assert "previous_models" in new_kwargs["metadata"]
assert json.loads(json.dumps(new_kwargs[metadata_key]["previous_models"])) == [
{
"model_group": "gpt-3.5-turbo",
"deployment_id": "deployment-1",
"exception_type": "RateLimitError",
"exception_string": "litellm.RateLimitError: slow down",
"attempted_retries": 2,
}
]
def test_update_usage(model_list):

View file

@ -1,9 +1,12 @@
import logging
import re
from unittest.mock import MagicMock
import pytest
import litellm.caching.redis_cache as redis_cache_module
from litellm.caching.caching import Cache
from litellm.caching.redis_cache import RedisCache, _RedisTimeoutLogThrottle
from litellm.types.caching import LiteLLMCacheType, SemanticCacheScope
from litellm.types.utils import Embedding, EmbeddingResponse, Usage
@ -53,6 +56,29 @@ def test_cache_key_debug_log_does_not_include_prompt_material(caplog):
assert any(cache_key in message for message in created_cache_key_logs)
@pytest.mark.parametrize(
("backend", "expected_level"),
[
pytest.param(MagicMock(spec=RedisCache), logging.DEBUG, id="redis_backend_is_throttled"),
pytest.param(MagicMock(), logging.ERROR, id="other_backend_logs_every_timeout"),
],
)
def test_add_cache_timeout_only_joins_redis_throttle_for_redis_backends(backend, expected_level, caplog, monkeypatch):
throttle = _RedisTimeoutLogThrottle(interval=5.0, clock=MagicMock(return_value=1_000.0))
assert throttle.admit() == 0
monkeypatch.setattr(redis_cache_module, "_redis_timeout_log_throttle", throttle)
cache = Cache(type=LiteLLMCacheType.LOCAL)
backend.set_cache.side_effect = TimeoutError("lit7520 backend timed out")
cache.cache = backend
with caplog.at_level(logging.DEBUG, logger="LiteLLM"):
cache.add_cache("result", model="gpt-4.1-mini", messages=[{"role": "user", "content": "hi"}])
records = [r for r in caplog.records if "lit7520 backend timed out" in r.getMessage()]
assert [r.levelno for r in records] == [expected_level]
def _embedding_response(prompt_tokens, num_items):
return EmbeddingResponse(
model="amazon.titan-embed-image-v1",

View file

@ -704,3 +704,58 @@ async def test_open_breaker_keeps_async_batch_read_memory_hits_and_releases_rese
assert list(await cache.async_batch_get_cache(["k1", "k2"])) == ["v1", None]
assert "k2" not in cache.last_redis_batch_access_time
@pytest.mark.asyncio
async def test_redis_timeouts_falling_back_to_memory_log_once_per_interval(caplog, monkeypatch):
"""The first fallback WARNING of a timeout streak logs, the rest stay at DEBUG until the summary."""
from redis.exceptions import TimeoutError as RedisTimeoutError
from litellm.caching import redis_cache as redis_cache_module
from litellm.caching.redis_cache import _RedisTimeoutLogThrottle
clock = MagicMock(return_value=1_000.0)
monkeypatch.setattr(
redis_cache_module, "_redis_timeout_log_throttle", _RedisTimeoutLogThrottle(interval=5.0, clock=clock)
)
class _TimingOutRedis:
async def async_increment_pipeline(self, increment_list, **kwargs):
raise RedisTimeoutError("Timeout reading from 127.0.0.1:6379")
async def async_increment(self, key, value, **kwargs):
raise RedisTimeoutError("Timeout reading from 127.0.0.1:6379")
cache = DualCache(
in_memory_cache=InMemoryCache(),
redis_cache=_TimingOutRedis(), # pyright: ignore[reportArgumentType] # duck-typed Redis double
)
increments = [RedisPipelineIncrementOperation(key="k", increment_value=1.0, ttl=60)]
with caplog.at_level(logging.DEBUG, logger="LiteLLM"):
for _ in range(100):
await cache.async_increment_cache_pipeline(increment_list=increments)
await cache.async_increment_cache("k", 1.0)
visible = [r for r in caplog.records if r.levelno >= logging.WARNING]
assert [(r.levelno, r.getMessage()) for r in visible] == [
(
logging.WARNING,
"Redis async_increment_cache_pipeline failed, falling back to in-memory result:"
" Timeout reading from 127.0.0.1:6379",
)
]
assert visible[0].filename == "dual_cache.py"
assert sum("Timeout reading from" in r.getMessage() for r in caplog.records) == 200
caplog.clear()
clock.return_value += 5.0
with caplog.at_level(logging.DEBUG, logger="LiteLLM"):
await cache.async_increment_cache("k", 1.0)
assert [(r.levelno, r.getMessage()) for r in caplog.records] == [
(
logging.WARNING,
"Redis async_increment_cache failed, falling back to in-memory result: Timeout reading from 127.0.0.1:6379"
" (199 more Redis timeouts since the previous Redis timeout line were logged at DEBUG)",
)
]

View file

@ -978,17 +978,17 @@ async def test_stale_timeout_does_not_let_sub_threshold_hard_failures_open_the_b
from redis.exceptions import ConnectionError as RedisConnectionError
from redis.exceptions import TimeoutError as RedisTimeoutError
from litellm.caching.redis_cache import RedisCircuitBreaker, _is_redis_timeout_failure
from litellm.caching.redis_cache import RedisCircuitBreaker, is_redis_timeout_failure
breaker = RedisCircuitBreaker(failure_threshold=3, recovery_timeout=60, timeout_min_duration=0.05)
breaker.record_failure(is_timeout=_is_redis_timeout_failure(RedisTimeoutError("read timed out")))
breaker.record_failure(is_timeout=is_redis_timeout_failure(RedisTimeoutError("read timed out")))
await asyncio.sleep(0.06)
for _ in range(breaker.failure_threshold - 1):
breaker.record_failure(is_timeout=_is_redis_timeout_failure(RedisConnectionError("refused")))
breaker.record_failure(is_timeout=is_redis_timeout_failure(RedisConnectionError("refused")))
assert breaker.is_open() is False, "2 hard failures and 1 stale timeout are below both thresholds"
breaker.record_failure(is_timeout=_is_redis_timeout_failure(RedisConnectionError("refused")))
breaker.record_failure(is_timeout=is_redis_timeout_failure(RedisConnectionError("refused")))
assert breaker.is_open() is True, "the threshold-th hard failure must still open it"
@ -1000,19 +1000,19 @@ async def test_hard_failure_resets_timeout_streak_so_a_later_burst_must_earn_its
from redis.exceptions import ConnectionError as RedisConnectionError
from redis.exceptions import TimeoutError as RedisTimeoutError
from litellm.caching.redis_cache import RedisCircuitBreaker, _is_redis_timeout_failure
from litellm.caching.redis_cache import RedisCircuitBreaker, is_redis_timeout_failure
breaker = RedisCircuitBreaker(failure_threshold=3, recovery_timeout=60, timeout_min_duration=0.05)
breaker.record_failure(is_timeout=_is_redis_timeout_failure(RedisTimeoutError("read timed out")))
breaker.record_failure(is_timeout=_is_redis_timeout_failure(RedisConnectionError("refused")))
breaker.record_failure(is_timeout=is_redis_timeout_failure(RedisTimeoutError("read timed out")))
breaker.record_failure(is_timeout=is_redis_timeout_failure(RedisConnectionError("refused")))
await asyncio.sleep(0.06)
for _ in range(breaker.failure_threshold):
breaker.record_failure(is_timeout=_is_redis_timeout_failure(RedisTimeoutError("read timed out")))
breaker.record_failure(is_timeout=is_redis_timeout_failure(RedisTimeoutError("read timed out")))
assert breaker.is_open() is False, "the burst is instantaneous, so the duration gate must hold it closed"
await asyncio.sleep(0.06)
breaker.record_failure(is_timeout=_is_redis_timeout_failure(RedisTimeoutError("read timed out")))
breaker.record_failure(is_timeout=is_redis_timeout_failure(RedisTimeoutError("read timed out")))
assert breaker.is_open() is True, "the same run of timeouts persisting past the duration must open it"
@ -1023,7 +1023,7 @@ async def test_breaker_metrics_track_state_and_failure_class():
from redis.exceptions import ConnectionError as RedisConnectionError
from redis.exceptions import TimeoutError as RedisTimeoutError
from litellm.caching.redis_cache import RedisCircuitBreaker, _is_redis_timeout_failure
from litellm.caching.redis_cache import RedisCircuitBreaker, is_redis_timeout_failure
def sample(name, labels=None):
return REGISTRY.get_sample_value(name, labels) or 0.0
@ -1035,9 +1035,9 @@ async def test_breaker_metrics_track_state_and_failure_class():
closed_gauge_before = sample("litellm_redis_circuit_breaker_state", {"state": "closed"})
breaker = RedisCircuitBreaker(failure_threshold=2, recovery_timeout=60, timeout_min_duration=5.0)
breaker.record_failure(is_timeout=_is_redis_timeout_failure(RedisTimeoutError("t")))
breaker.record_failure(is_timeout=_is_redis_timeout_failure(RedisConnectionError("refused")))
breaker.record_failure(is_timeout=_is_redis_timeout_failure(RedisConnectionError("refused")))
breaker.record_failure(is_timeout=is_redis_timeout_failure(RedisTimeoutError("t")))
breaker.record_failure(is_timeout=is_redis_timeout_failure(RedisConnectionError("refused")))
breaker.record_failure(is_timeout=is_redis_timeout_failure(RedisConnectionError("refused")))
assert sample("litellm_redis_circuit_breaker_failures_total", {"failure_class": "timeout"}) == timeout_before + 1
assert sample("litellm_redis_circuit_breaker_failures_total", {"failure_class": "connectivity"}) == hard_before + 2
@ -1205,6 +1205,117 @@ async def test_a_probe_overtaken_by_a_later_outage_leaves_the_breaker_to_the_new
assert breaker._state == breaker.CLOSED
def test_timeouts_during_a_blip_log_once_per_interval_not_once_per_call(sync_batch_redis_cache, caplog, monkeypatch):
"""A timeout streak logs its first failure plus one summary per interval; other failures log per call."""
import logging
from redis.exceptions import TimeoutError as RedisTimeoutError
from litellm.caching import redis_cache as redis_cache_module
from litellm.caching.redis_cache import _RedisTimeoutLogThrottle
clock = MagicMock(return_value=1_000.0)
monkeypatch.setattr(
redis_cache_module, "_redis_timeout_log_throttle", _RedisTimeoutLogThrottle(interval=5.0, clock=clock)
)
sync_batch_redis_cache.redis_client.get.side_effect = RedisTimeoutError("Timeout reading from 127.0.0.1:6379")
sync_batch_redis_cache.redis_client.mget.side_effect = RedisTimeoutError("Timeout reading from 127.0.0.1:6379")
with caplog.at_level(logging.DEBUG, logger="LiteLLM"):
for _ in range(200):
assert sync_batch_redis_cache.get_cache("lit7520") is None
assert sync_batch_redis_cache.batch_get_cache(key_list=["lit7520"]) == {}
timeout_records = [r for r in caplog.records if "Timeout reading from" in r.getMessage()]
assert len(timeout_records) == 201, "every timeout must stay visible at DEBUG"
assert [r.getMessage() for r in timeout_records if r.levelno >= logging.WARNING] == [
"litellm.caching.caching: get() - Got exception from REDIS: Timeout reading from 127.0.0.1:6379"
]
assert timeout_records[0].levelno == logging.ERROR
assert timeout_records[0].filename == "redis_cache.py"
assert timeout_records[0].lineno != timeout_records[-1].lineno, "the record must point at the cache operation"
caplog.clear()
clock.return_value += 5.0
with caplog.at_level(logging.DEBUG, logger="LiteLLM"):
assert sync_batch_redis_cache.batch_get_cache(key_list=["lit7520"]) == {}
assert [(r.levelno, r.getMessage()) for r in caplog.records] == [
(
logging.ERROR,
"Error occurred in batch get cache: Timeout reading from 127.0.0.1:6379"
" (200 more Redis timeouts since the previous Redis timeout line were logged at DEBUG)",
)
]
caplog.clear()
sync_batch_redis_cache.redis_client.get.side_effect = OSError("redis unavailable")
with caplog.at_level(logging.DEBUG, logger="LiteLLM"):
for _ in range(3):
assert sync_batch_redis_cache.get_cache("lit7520") is None
assert [r.levelno for r in caplog.records if "redis unavailable" in r.getMessage()] == [logging.ERROR] * 3
@pytest.mark.asyncio
@pytest.mark.parametrize(
"call_method",
[
pytest.param(lambda c: c.async_set_cache_pipeline([("lit7520", "v")]), id="async_set_cache_pipeline"),
pytest.param(
lambda c: c.async_set_cache_pipeline_with_ttls([("lit7520", "v", 60.0)]),
id="async_set_cache_pipeline_with_ttls",
),
pytest.param(lambda c: c.async_set_cache_sadd("lit7520", ["v"], ttl=None), id="async_set_cache_sadd"),
pytest.param(lambda c: c.async_increment("lit7520", 1.0), id="async_increment"),
pytest.param(
lambda c: c.async_increment_pipeline([{"key": "lit7520", "increment_value": 1.0, "ttl": 60}]),
id="async_increment_pipeline",
),
pytest.param(lambda c: c.async_rpush("lit7520", ["v"]), id="async_rpush"),
pytest.param(
lambda c: c.async_rpush_pipeline([{"key": "lit7520", "values": ["v"]}]), id="async_rpush_pipeline"
),
pytest.param(lambda c: c.async_lpop("lit7520"), id="async_lpop"),
pytest.param(lambda c: c.async_lpop_pipeline([{"key": "lit7520", "count": 1}]), id="async_lpop_pipeline"),
],
)
async def test_write_path_timeouts_inside_the_interval_stay_at_debug(call_method, caplog, monkeypatch, redis_no_ping):
"""A write or list operation timing out mid-streak is counted by the throttle instead of logging its own ERROR."""
import contextlib
import logging
from redis.exceptions import TimeoutError as RedisTimeoutError
from litellm.caching import redis_cache as redis_cache_module
from litellm.caching.redis_cache import _RedisTimeoutLogThrottle
clock = MagicMock(return_value=1_000.0)
throttle = _RedisTimeoutLogThrottle(interval=5.0, clock=clock)
assert throttle.admit() == 0
monkeypatch.setattr(redis_cache_module, "_redis_timeout_log_throttle", throttle)
timeout = RedisTimeoutError("Timeout reading from 127.0.0.1:6379")
client = MagicMock()
client.pipeline.return_value.__aenter__.side_effect = timeout
client.sadd = AsyncMock(side_effect=timeout)
client.incrbyfloat = AsyncMock(side_effect=timeout)
client.rpush = AsyncMock(side_effect=timeout)
client.lpop = AsyncMock(side_effect=timeout)
monkeypatch.setenv("REDIS_HOST", "https://my-test-host")
cache = RedisCache()
with (
patch.object(cache, "init_async_client", return_value=client),
caplog.at_level(logging.DEBUG, logger="LiteLLM"),
):
with contextlib.suppress(RedisTimeoutError):
await call_method(cache)
timeout_records = [r for r in caplog.records if "Timeout reading from" in r.getMessage()]
assert [(r.levelno, r.filename) for r in timeout_records] == [(logging.DEBUG, "redis_cache.py")]
clock.return_value += 5.0
assert throttle.admit() == 1
@pytest.mark.asyncio
async def test_pool_wait_timeout_is_a_timeout_failure_not_hard_connectivity():
"""A saturated blocking pool must not open the breaker before the timeout minimum duration.
@ -1241,7 +1352,7 @@ async def test_pool_wait_timeout_is_a_timeout_failure_not_hard_connectivity():
def test_timeout_classification_follows_the_explicit_cause_chain_only():
from redis.exceptions import ConnectionError as RedisConnectionError
from litellm.caching.redis_cache import _is_redis_timeout_failure
from litellm.caching.redis_cache import is_redis_timeout_failure
def raise_chained_from_timeout() -> None:
try:
@ -1260,9 +1371,9 @@ def test_timeout_classification_follows_the_explicit_cause_chain_only():
with pytest.raises(RedisConnectionError) as contextual:
raise_while_handling_timeout()
assert _is_redis_timeout_failure(chained.value) is True
assert _is_redis_timeout_failure(contextual.value) is False
assert _is_redis_timeout_failure(RedisConnectionError("refused")) is False
assert is_redis_timeout_failure(chained.value) is True
assert is_redis_timeout_failure(contextual.value) is False
assert is_redis_timeout_failure(RedisConnectionError("refused")) is False
class _RoundTripCountingRedis:

View file

@ -716,6 +716,77 @@ async def test_failure_hook_emits_api_provider_value_on_failed_requests_metric()
_clear_prometheus_registry()
async def _failed_requests_api_provider_labels(
request_data: dict[str, object],
original_exception: Exception,
) -> list[str]:
from litellm.integrations.prometheus import PrometheusLogger
from litellm.proxy._types import UserAPIKeyAuth
_clear_prometheus_registry()
try:
await PrometheusLogger().async_post_call_failure_hook(
request_data=request_data,
original_exception=original_exception,
user_api_key_dict=UserAPIKeyAuth(token="tok"),
)
return [
s.labels.get("api_provider")
for s in _collected_samples("litellm_proxy_failed_requests_metric_total")
]
finally:
_clear_prometheus_registry()
@pytest.mark.asyncio
async def test_failure_hook_emits_api_provider_from_pre_call_rate_limit_error_for_router_alias():
"""
Pre-call limiters reject before a deployment lands on request_data and a
router alias cannot be inferred from its name, so the provider the limiter
resolved onto the exception is the only source for the label.
"""
from litellm.exceptions import RateLimitType
from litellm.proxy.common_utils.proxy_rate_limit_error import ProxyRateLimitError
err = ProxyRateLimitError(
detail={"error": "rpm exceeded"},
rate_limit_type=RateLimitType.REQUESTS,
model="openai/gpt-5.4-mini",
llm_provider="openai",
)
assert await _failed_requests_api_provider_labels(
{"model": "team-chat-model", "metadata": {}}, err
) == ["openai"]
@pytest.mark.asyncio
async def test_failure_hook_leaves_api_provider_unset_when_rate_limiter_could_not_resolve_provider():
from litellm.proxy.common_utils.proxy_rate_limit_error import ProxyRateLimitError
err = ProxyRateLimitError(detail={"error": "rpm exceeded"}, model="unknown-alias")
assert await _failed_requests_api_provider_labels(
{"model": "unknown-alias", "metadata": {}}, err
) == ["None"]
@pytest.mark.asyncio
async def test_failure_hook_prefers_request_data_provider_over_exception_provider():
from litellm.exceptions import RateLimitError
err = RateLimitError(message="upstream 429", llm_provider="openai", model="gpt-4o")
assert await _failed_requests_api_provider_labels(
{
"model": "gpt-4o",
"metadata": {},
"litellm_params": {"custom_llm_provider": "azure"},
},
err,
) == ["azure"]
if __name__ == "__main__":
test_user_email_in_required_metrics()
test_user_email_label_exists()

View file

@ -1,3 +1,4 @@
import asyncio
import json
import sys
import types
@ -51,6 +52,27 @@ class FakeBedrockStream:
def __init__(self, input_stream=None):
self.input_stream = input_stream if input_stream is not None else FakeInputStream()
async def await_output(self):
return (None, EndedBedrockReceiver())
class ServiceUnavailableException(Exception):
"""Named like the modeled AWS SDK error so the handler maps it to HTTP 503"""
class ModelStreamErrorException(Exception):
"""Named like the modeled AWS SDK error so the handler maps it to HTTP 424"""
class UnavailableBedrockStream:
"""Lazy duplex stream whose HTTP response only fails once the output is awaited"""
def __init__(self):
self.input_stream = FakeInputStream()
async def await_output(self):
raise ServiceUnavailableException("fault injected: Bedrock realtime unavailable")
class FakeLogging:
def __init__(self, trace_id="trace-nova-sonic"):
@ -61,6 +83,7 @@ class DisconnectingClientWS:
def __init__(self, messages):
self._messages = list(messages)
self.sent_to_client = []
self.scope = {}
async def receive_text(self):
if self._messages:
@ -93,6 +116,7 @@ class RealtimeClientWS:
def __init__(self):
self.closed = False
self.sent_to_client = []
self.scope = {}
async def receive_text(self):
raise RuntimeError("client disconnected")
@ -104,6 +128,25 @@ class RealtimeClientWS:
self.closed = True
class ConnectedClientWS(RealtimeClientWS):
"""Client that sends its scripted messages and then stays connected until the server closes it"""
def __init__(self, messages):
super().__init__()
self._messages = list(messages)
self._closed_event = asyncio.Event()
async def receive_text(self):
if self._messages:
return self._messages.pop(0)
await self._closed_event.wait()
raise RuntimeError("client disconnected")
async def close(self, code=None, reason=None):
self.closed = True
self._closed_event.set()
class ScriptedBedrockReceiver:
def __init__(self, payloads):
self._payloads = list(payloads)
@ -115,10 +158,48 @@ class ScriptedBedrockReceiver:
return SimpleNamespace(value=SimpleNamespace(bytes_=payload.encode("utf-8")))
class ScriptedBedrockStream:
class BreakingBedrockReceiver(ScriptedBedrockReceiver):
"""Delivers its payloads, then the provider stream breaks instead of ending normally"""
async def receive(self):
if not self._payloads:
await asyncio.sleep(0)
raise ModelStreamErrorException("Nova Sonic stream broke")
return await super().receive()
class DrainedThenOpenBedrockReceiver(ScriptedBedrockReceiver):
"""Delivers its payloads, flags `drained`, then stays open like a live Nova Sonic turn"""
def __init__(self, payloads):
super().__init__(payloads)
self.drained = asyncio.Event()
async def receive(self):
if not self._payloads:
self.drained.set()
await asyncio.Event().wait()
return await super().receive()
class ResetOnAudioInputStream(FakeInputStream):
"""Accepts session setup, then the provider resets the input side once the first response was delivered"""
def __init__(self, drained):
super().__init__()
self._drained = drained
async def send(self, event):
if "audioInput" in json.loads(event.value.bytes_.decode("utf-8")).get("event", {}):
await self._drained.wait()
raise RuntimeError("bedrock input stream reset")
self.sent.append(event)
class ScriptedBedrockStream:
def __init__(self, payloads, receiver_type=ScriptedBedrockReceiver):
self.input_stream = FakeInputStream()
self._receiver = ScriptedBedrockReceiver(payloads)
self._receiver = receiver_type(payloads)
async def await_output(self):
return (None, self._receiver)
@ -163,6 +244,11 @@ def stub_aws_sdk_client(monkeypatch):
async def invoke_model_with_bidirectional_stream(self, operation_input):
captured["operation_input"] = operation_input
if captured.get("streams"):
stream = captured["streams"].pop(0)
if isinstance(stream, Exception):
raise stream
return stream
return ScriptedBedrockStream(captured.get("scripted_payloads", []))
package = types.ModuleType("aws_sdk_bedrock_runtime")
@ -263,7 +349,8 @@ class TestBedrockRealtimeHandler:
[json.dumps({"type": "session.update", "session": {"instructions": "You are helpful."}})]
)
await handler._forward_client_to_bedrock(client_ws, stream, config, "amazon.nova-sonic-v1:0", {})
with pytest.raises(RuntimeError, match="bedrock send failed"):
await handler._forward_client_to_bedrock(client_ws, stream, config, "amazon.nova-sonic-v1:0", {})
assert stream.input_stream.closed
@ -464,6 +551,154 @@ class TestBedrockRealtimeSessionLifecycle:
assert client_ws.sent_to_client == []
class TestBedrockRealtimeProviderFailurePropagation:
"""Deferred Nova Sonic failures must escape async_realtime so the router can fall back / cool down (LIT-6484)"""
SESSION_UPDATE = json.dumps({"type": "session.update", "session": {"instructions": "hi", "modalities": ["text"]}})
AWS_PARAMS = {"aws_region_name": "us-east-1", "aws_access_key_id": "k", "aws_secret_access_key": "s"}
@pytest.mark.asyncio
async def test_readiness_failure_escapes_and_fallback_replays_session_update(self, stub_aws_sdk_client):
handler = BedrockRealtime()
websocket = ConnectedClientWS([self.SESSION_UPDATE])
healthy_stream = ScriptedBedrockStream([])
eager_failure = ServiceUnavailableException("fault injected before the stream was returned")
stub_aws_sdk_client["streams"] = [UnavailableBedrockStream(), eager_failure, healthy_stream]
with pytest.raises(BedrockError) as failure:
await handler.async_realtime(
model="amazon.nova-sonic-v1:0", websocket=websocket, logging_obj=FakeLogging(), **self.AWS_PARAMS
)
assert failure.value.status_code == 503
assert [json.loads(m)["type"] for m in websocket.sent_to_client] == ["session.created"]
assert not websocket.closed, "the proxy route owns the client-facing error event and 1011 close"
with pytest.raises(ServiceUnavailableException):
await handler.async_realtime(
model="amazon.nova-sonic-v1:0", websocket=websocket, logging_obj=FakeLogging(), **self.AWS_PARAMS
)
await handler.async_realtime(
model="amazon.nova-sonic-v1:0", websocket=websocket, logging_obj=FakeLogging(), **self.AWS_PARAMS
)
assert [json.loads(m)["type"] for m in websocket.sent_to_client] == ["session.created", "session.updated"]
replayed = [json.loads(chunk.value.bytes_.decode("utf-8")) for chunk in healthy_stream.input_stream.sent]
assert [next(iter(event["event"])) for event in replayed][:2] == ["sessionStart", "promptStart"]
assert websocket.closed
TEXT_TURN = (
json.dumps({"event": {"contentStart": {"role": "ASSISTANT", "type": "TEXT"}}}),
json.dumps({"event": {"textOutput": {"content": "Hi"}}}),
json.dumps({"event": {"contentEnd": {"stopReason": "END_TURN"}}}),
)
@pytest.fixture
def spend_dispatch(self, monkeypatch):
import litellm.llms.bedrock.realtime.handler as handler_module
dispatched = {}
class RecordingLogging(FakeLogging):
async def dispatch_success_handlers(self, result=None, prefer_async_handlers=False, **kwargs):
dispatched["events"] = result
class RecordingLoggingWorker:
def ensure_initialized_and_enqueue(self, coro):
dispatched["coro"] = coro
monkeypatch.setattr(handler_module, "GLOBAL_LOGGING_WORKER", RecordingLoggingWorker())
dispatched["logging_obj"] = RecordingLogging()
return dispatched
@pytest.mark.asyncio
async def test_mid_stream_failure_escapes_keeps_partial_spend_and_blocks_replay(
self, stub_aws_sdk_client, spend_dispatch
):
handler = BedrockRealtime()
websocket = ConnectedClientWS([self.SESSION_UPDATE])
stream = ScriptedBedrockStream(self.TEXT_TURN, receiver_type=BreakingBedrockReceiver)
stub_aws_sdk_client["streams"] = [stream]
with pytest.raises(BedrockError) as failure:
await handler.async_realtime(
model="amazon.nova-sonic-v1:0",
websocket=websocket,
logging_obj=spend_dispatch["logging_obj"],
**self.AWS_PARAMS,
)
assert failure.value.status_code == 424
await spend_dispatch["coro"]
assert [event["type"] for event in spend_dispatch["events"]] == ["response.done"]
assert "response.done" in [json.loads(m)["type"] for m in websocket.sent_to_client]
flushed = [json.loads(chunk.value.bytes_.decode("utf-8")) for chunk in stream.input_stream.sent]
assert [next(iter(event["event"])) for event in flushed][-2:] == ["promptEnd", "sessionEnd"]
assert stream.input_stream.closed
with pytest.raises(BedrockError) as replay:
await handler.async_realtime(
model="amazon.nova-sonic-v1:0",
websocket=websocket,
logging_obj=spend_dispatch["logging_obj"],
**self.AWS_PARAMS,
)
assert replay.value.status_code == 400, "a committed session must not be silently restarted on a fallback"
assert not litellm._should_retry(replay.value.status_code), "the router must not retry the replay refusal"
assert "Nova Sonic stream broke" in replay.value.message, "the router surfaces the last attempt's error"
@pytest.mark.asyncio
async def test_input_side_failure_keeps_spend_for_responses_already_delivered(
self, stub_aws_sdk_client, spend_dispatch
):
receiver = DrainedThenOpenBedrockReceiver(self.TEXT_TURN)
stream = ScriptedBedrockStream(self.TEXT_TURN, receiver_type=lambda _payloads: receiver)
stream.input_stream = ResetOnAudioInputStream(receiver.drained)
stub_aws_sdk_client["streams"] = [stream]
websocket = ConnectedClientWS(
[self.SESSION_UPDATE, json.dumps({"type": "input_audio_buffer.append", "audio": "AAAA"})]
)
with pytest.raises(RuntimeError, match="bedrock input stream reset"):
await BedrockRealtime().async_realtime(
model="amazon.nova-sonic-v1:0",
websocket=websocket,
logging_obj=spend_dispatch["logging_obj"],
**self.AWS_PARAMS,
)
assert "response.done" in [json.loads(m)["type"] for m in websocket.sent_to_client]
await spend_dispatch["coro"]
assert [event["type"] for event in spend_dispatch["events"]] == ["response.done"]
@pytest.mark.asyncio
async def test_stream_failure_after_client_disconnect_is_not_a_provider_failure(self, stub_aws_sdk_client):
stream = ScriptedBedrockStream([], receiver_type=BreakingBedrockReceiver)
stub_aws_sdk_client["streams"] = [stream]
await BedrockRealtime().async_realtime(
model="amazon.nova-sonic-v1:0", websocket=RealtimeClientWS(), logging_obj=FakeLogging(), **self.AWS_PARAMS
)
assert stream.input_stream.closed
@pytest.mark.asyncio
async def test_session_updated_is_not_sent_before_bedrock_is_ready(self, stub_aws_models):
handler = BedrockRealtime()
stream = UnavailableBedrockStream()
client_ws = DisconnectingClientWS([self.SESSION_UPDATE])
with pytest.raises(ServiceUnavailableException):
await handler._forward_client_to_bedrock(
client_ws, stream, BedrockRealtimeConfig(), "amazon.nova-sonic-v1:0", {}, FakeLogging()
)
assert client_ws.sent_to_client == []
assert stream.input_stream.closed
class TestBedrockRealtimeAwsAuth:
"""AWS auth params passed via litellm_params must reach the Smithy client config (LIT-3923 regression)"""

View file

@ -1,5 +1,6 @@
import os
from datetime import datetime
from typing import Final
from unittest.mock import MagicMock, patch
@ -3919,10 +3920,15 @@ def test_claude_code_marketplace_routes_open_to_internal_users(route):
@pytest.mark.parametrize("user_role", [None, LitellmUserRoles.INTERNAL_USER.value, LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value])
def test_auto_router_session_is_reachable_by_any_key_but_benchmarks_stays_admin_only(user_role):
valid_token = UserAPIKeyAuth(api_key="hash-of-caller", user_role=user_role)
request = MagicMock(spec=Request)
request.query_params = {"session_id": "sess-1"}
@pytest.mark.parametrize("allowed_routes", [None, ["llm_api_routes"]])
def test_auto_router_session_is_reachable_by_any_key_but_benchmarks_stays_admin_only(
user_role: str | None, allowed_routes: list[str] | None
) -> None:
valid_token: Final = UserAPIKeyAuth(api_key="hash-of-caller", user_role=user_role, allowed_routes=allowed_routes)
request: Final = Request({"type": "http", "method": "GET", "query_string": b"session_id=sess-1"})
assert RouteChecks.should_call_route("/auto_router/session", valid_token, request) is True
assert RouteChecks.is_llm_api_route("/auto_router/session") is False
RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=None,
@ -3941,3 +3947,36 @@ def test_auto_router_session_is_reachable_by_any_key_but_benchmarks_stays_admin_
valid_token=valid_token,
request_data={},
)
@pytest.mark.parametrize(
"route,method,allowed_routes",
[
("/auto_router/session", method, ["llm_api_routes"])
for method in ("POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", None)
]
+ [
(route, "GET", ["llm_api_routes"])
for route in (
"/auto_router/benchmarks",
"/auto_router/test_routing",
"/auto_router/validate_complexity_router_config",
"/auto_router/session/other",
"/auto_router/sessions",
)
]
+ [
("/auto_router/session", "GET", allowed_routes)
for allowed_routes in (["/v1/messages"], ["info_routes"], ["openai_routes"])
],
)
def test_auto_router_session_read_grant_rejects_other_methods_paths_and_scopes(
route: str, method: str | None, allowed_routes: list[str]
) -> None:
valid_token: Final = UserAPIKeyAuth(api_key="hash-of-caller", allowed_routes=allowed_routes)
request: Final = Request({"type": "http", "method": method}) if method is not None else None
with pytest.raises(HTTPException) as error:
RouteChecks.should_call_route(route, valid_token, request)
assert error.value.status_code == 403

View file

@ -8,6 +8,7 @@ import re
import subprocess
import sys
from pathlib import Path
from typing import Final
import pytest
@ -297,16 +298,31 @@ class TestRender:
class TestClaudeCodeMode:
def test_the_transcript_names_the_routed_model_and_the_proxy_adds_the_savings(self, tmp_path, transcript, config_dir):
seen = []
@pytest.mark.parametrize("transcript_model", ("claude-auto", "anthropic/claude-opus-5"))
def test_the_session_names_the_routed_model_even_when_the_transcript_differs(
self, tmp_path: Path, config_dir: Path, transcript_model: str
) -> None:
transcript: Final = tmp_path / "session.jsonl"
transcript.write_text(_assistant_line(transcript_model) + "\n")
def fetch(credentials, session_id):
seen.append((credentials, session_id))
def fetch(credentials: Credentials, session_id: str) -> Fetched:
assert credentials == Credentials("http://127.0.0.1:4000", "sk-virtual")
assert session_id == SESSION_ID
return Fetched(RECORDED, definitive=True)
text = _run(_payload(transcript), _env(tmp_path, config_dir), fetch)
text: Final = _run(_payload(transcript), _env(tmp_path, config_dir), fetch)
assert text.startswith("claude-auto · Routed to: claude-sonnet-5 -63% vs Claude Opus 5\n")
assert seen == [(Credentials("http://127.0.0.1:4000", "sk-virtual"), SESSION_ID)]
def test_a_discovered_display_name_labels_the_sessions_model(
self, tmp_path: Path, transcript: Path, config_dir: Path
) -> None:
session: Final = RECORDED._replace(last_model="anthropic/claude-opus-5")
def fetch(credentials: Credentials, session_id: str) -> Fetched:
return Fetched(session, definitive=True)
text: Final = _run(_payload(transcript), _env(tmp_path, config_dir), fetch)
assert text.startswith("claude-auto · Routed to: Claude Opus 5 -63% vs Claude Opus 5\n")
def test_an_unrecorded_session_degrades_to_the_routed_line(self, tmp_path, transcript, config_dir):
assert _run(_payload(transcript), _env(tmp_path, config_dir), lambda c, s: Fetched(None, True)) == (

View file

@ -340,6 +340,7 @@ def test_ui_discovery_endpoints_hide_default_credentials_hint_default_false():
patch.dict(os.environ, {"DISABLE_ADMIN_UI": "false"}, clear=False),
):
os.environ.pop("LITELLM_HIDE_DEFAULT_CREDENTIALS_HINT", None)
os.environ.pop("UI_PASSWORD", None)
response = client.get("/.well-known/litellm-ui-config")
@ -348,6 +349,43 @@ def test_ui_discovery_endpoints_hide_default_credentials_hint_default_false():
assert data["hide_default_credentials_hint"] is False
def test_ui_discovery_endpoints_hide_default_credentials_hint_when_ui_password_set():
app = FastAPI()
app.include_router(router)
client = TestClient(app)
with patch.dict(os.environ, {"UI_PASSWORD": "s3cret-pass", "DISABLE_ADMIN_UI": "false"}, clear=False):
os.environ.pop("LITELLM_HIDE_DEFAULT_CREDENTIALS_HINT", None)
response = client.get("/.well-known/litellm-ui-config")
assert response.status_code == 200
assert response.json()["hide_default_credentials_hint"] is True
@pytest.mark.parametrize(
"env_overrides",
[
pytest.param({"UI_USERNAME": "opsadmin"}, id="username_only_keeps_master_key_password"),
pytest.param({"UI_PASSWORD": ""}, id="empty_password_is_not_set"),
],
)
def test_ui_discovery_endpoints_keeps_default_credentials_hint_without_real_ui_password(env_overrides):
app = FastAPI()
app.include_router(router)
client = TestClient(app)
with patch.dict(os.environ, {"DISABLE_ADMIN_UI": "false", **env_overrides}, clear=False):
os.environ.pop("LITELLM_HIDE_DEFAULT_CREDENTIALS_HINT", None)
if "UI_PASSWORD" not in env_overrides:
os.environ.pop("UI_PASSWORD", None)
response = client.get("/.well-known/litellm-ui-config")
assert response.status_code == 200
assert response.json()["hide_default_credentials_hint"] is False
def test_ui_discovery_endpoints_hide_default_credentials_hint_via_env_var():
"""LITELLM_HIDE_DEFAULT_CREDENTIALS_HINT=true hides the login-page credentials card."""
app = FastAPI()

View file

@ -3937,6 +3937,7 @@ async def test_list_team_v2_org_admin_sees_org_teams():
mock_db.litellm_teamtable.find_many = AsyncMock(return_value=[mock_team])
mock_db.litellm_teamtable.count = AsyncMock(return_value=1)
mock_db.litellm_verificationtoken.group_by = AsyncMock(return_value=[])
mock_db.litellm_usertable.find_unique = AsyncMock(return_value=mock_user)
result = await list_team_v2(
http_request=mock_request,
@ -4036,6 +4037,7 @@ async def test_list_team_v2_org_admin_own_user_id_sees_all_org_teams():
)
mock_db.litellm_teamtable.count = AsyncMock(return_value=2)
mock_db.litellm_verificationtoken.group_by = AsyncMock(return_value=[])
mock_db.litellm_usertable.find_unique = AsyncMock(return_value=mock_user)
# UI sends the caller's own user_id for non-Admin roles
result = await list_team_v2(
@ -4055,10 +4057,217 @@ async def test_list_team_v2_org_admin_own_user_id_sees_all_org_teams():
assert result["total"] == 2
assert len(result["teams"]) == 2
# Verify the where clause scopes by org only — no team_id filter
# Verify the where clause scopes by org OR own membership — no
# top-level team_id filter that would hide org teams they aren't in
where = mock_db.litellm_teamtable.find_many.call_args.kwargs["where"]
assert where["organization_id"] == {"in": ["org_A"]}
assert where["AND"] == [
{"OR": [{"organization_id": {"in": ["org_A"]}}, {"team_id": {"in": ["team_1"]}}]}
]
assert "team_id" not in where
assert "organization_id" not in where
def _team_where_matches(team, where) -> bool:
for key, cond in where.items():
if key == "AND":
if not all(_team_where_matches(team, c) for c in cond):
return False
elif key == "OR":
if not any(_team_where_matches(team, c) for c in cond):
return False
else:
value = getattr(team, key)
if not isinstance(cond, dict):
if value != cond:
return False
elif "in" in cond and value not in cond["in"]:
return False
elif "contains" in cond and cond["contains"].lower() not in (value or "").lower():
return False
return True
def _org_membership(user_id: str, organization_id: str, user_role: str) -> LiteLLM_OrganizationMembershipTable:
return LiteLLM_OrganizationMembershipTable(
user_id=user_id,
organization_id=organization_id,
user_role=user_role,
spend=0.0,
created_at=datetime.now(),
updated_at=datetime.now(),
)
@pytest.mark.asyncio
async def test_list_team_v2_org_admin_own_query_keeps_memberships_in_other_orgs(monkeypatch):
"""
/v2/team/list: an org admin of org_A who is a member of a team in org_B
gets that team back on a self query (with and without user_id, with and
without search), alongside every org_A team. The membership half of the
union comes from the DB, so a stale cached user object cannot hide it.
A query for another user stays scoped to org_A.
Regression test for LIT-3723.
"""
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.management_endpoints.team_endpoints import list_team_v2
org_admin = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="org_admin_user")
cache = UserApiKeyCache()
await cache.async_set_cache(
key="org_admin_user",
value=LiteLLM_UserTable(
user_id="org_admin_user",
teams=["team_in_org_A"],
organization_memberships=[
_org_membership("org_admin_user", "org_A", "org_admin"),
_org_membership("org_admin_user", "org_B", "internal_user"),
],
),
model_type=LiteLLM_UserTable,
)
await cache.async_set_cache(
key="other_user",
value=LiteLLM_UserTable(
user_id="other_user",
teams=["other_team_in_org_A", "team_in_org_B", "unrelated_team_in_org_B"],
organization_memberships=[_org_membership("other_user", "org_B", "internal_user")],
),
model_type=LiteLLM_UserTable,
)
def team(team_id, organization_id, *member_ids):
return LiteLLM_TeamTable(
team_id=team_id,
team_alias=team_id,
organization_id=organization_id,
members_with_roles=[Member(user_id=m, role="user") for m in member_ids],
)
all_teams = [
team("team_in_org_A", "org_A", "org_admin_user"),
team("other_team_in_org_A", "org_A", "other_user"),
team("team_in_org_B", "org_B", "org_admin_user", "other_user"),
team("unrelated_team_in_org_B", "org_B", "other_user"),
]
async def find_many(where=None, **kwargs):
return [t for t in all_teams if where is None or _team_where_matches(t, where)]
async def count(where=None, **kwargs):
return len(await find_many(where))
prisma_client = MagicMock()
prisma_client.db.litellm_teamtable.find_many = AsyncMock(side_effect=find_many)
prisma_client.db.litellm_teamtable.count = AsyncMock(side_effect=count)
prisma_client.db.litellm_verificationtoken.group_by = AsyncMock(return_value=[])
prisma_client.db.litellm_usertable.find_unique = AsyncMock(
return_value=LiteLLM_UserTable(
user_id="org_admin_user",
teams=["team_in_org_A", "team_in_org_B"],
organization_memberships=[
_org_membership("org_admin_user", "org_A", "org_admin"),
_org_membership("org_admin_user", "org_B", "internal_user"),
],
)
)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", prisma_client)
monkeypatch.setattr("litellm.proxy.proxy_server.user_api_key_cache", cache)
proxy_logging_obj = MagicMock()
proxy_logging_obj.service_logging_obj.async_service_success_hook = AsyncMock()
monkeypatch.setattr("litellm.proxy.proxy_server.proxy_logging_obj", proxy_logging_obj)
async def list_teams(user_id, search=None):
result = await list_team_v2(
http_request=MagicMock(),
user_id=user_id,
organization_id=None,
team_id=None,
team_alias=None,
search=search,
user_api_key_dict=org_admin,
page=1,
page_size=10,
sort_by=None,
sort_order="asc",
status=None,
)
assert result["total"] == len(result["teams"])
return [t.team_id for t in result["teams"]]
own_view = ["team_in_org_A", "other_team_in_org_A", "team_in_org_B"]
assert await list_teams("org_admin_user") == own_view
assert await list_teams(None) == own_view
assert await list_teams("org_admin_user", search="team_in_org_B") == ["team_in_org_B"]
assert await list_teams("other_user") == ["other_team_in_org_A"]
prisma_client.db.litellm_usertable.find_unique.assert_awaited_with(
where={"user_id": "org_admin_user"}, include={"organization_memberships": True}
)
prisma_client.db.litellm_usertable.find_unique.side_effect = RuntimeError("db down")
with pytest.raises(ValueError, match="db down"):
await list_teams("org_admin_user")
@pytest.mark.asyncio
async def test_list_team_v1_org_admin_own_query_keeps_memberships_in_other_orgs():
"""
/team/list: an org admin of org_A listing their own teams sees every team
they belong to, including the org_B one. The bare admin listing stays the
org_A view and a query for another user stays scoped to org_A.
Regression test for LIT-3723.
"""
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.management_endpoints.team_endpoints import _authorize_and_filter_teams
org_admin = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="org_admin_user")
cache = UserApiKeyCache()
await cache.async_set_cache(
key="org_admin_user",
value=LiteLLM_UserTable(
user_id="org_admin_user",
teams=["team_in_org_A", "team_in_org_B"],
organization_memberships=[_org_membership("org_admin_user", "org_A", "org_admin")],
),
model_type=LiteLLM_UserTable,
)
def team(team_id, organization_id, *member_ids):
return SimpleNamespace(
team_id=team_id,
organization_id=organization_id,
members_with_roles=[{"user_id": m, "role": "user"} for m in member_ids],
)
all_teams = [
team("team_in_org_A", "org_A", "org_admin_user"),
team("other_team_in_org_A", "org_A", "other_user"),
team("team_in_org_B", "org_B", "org_admin_user", "other_user"),
team("unrelated_team_in_org_B", "org_B", "other_user"),
]
async def find_many(where=None, **kwargs):
if where is None:
return all_teams
return [t for t in all_teams if t.organization_id in where["organization_id"]["in"]]
prisma_client = MagicMock()
prisma_client.db.litellm_teamtable.find_many = AsyncMock(side_effect=find_many)
async def list_teams(user_id):
teams = await _authorize_and_filter_teams(
user_api_key_dict=org_admin,
user_id=user_id,
prisma_client=prisma_client,
user_api_key_cache=cache,
proxy_logging_obj=MagicMock(),
)
return [t.team_id for t in teams]
assert await list_teams("org_admin_user") == ["team_in_org_A", "team_in_org_B"]
assert await list_teams(None) == ["team_in_org_A", "other_team_in_org_A"]
assert await list_teams("other_user") == ["other_team_in_org_A"]
@pytest.mark.asyncio

View file

@ -8334,6 +8334,7 @@ async def _render_legacy_login_page(env_overrides, general_settings):
"GOOGLE_CLIENT_ID",
"GENERIC_CLIENT_ID",
"LITELLM_HIDE_DEFAULT_CREDENTIALS_HINT",
"UI_PASSWORD",
):
os.environ.pop(var, None)
os.environ.update(env_overrides)
@ -8386,6 +8387,20 @@ async def test_legacy_login_page_hides_credentials_hint_via_general_settings():
assert "MASTER_KEY" not in body
@pytest.mark.asyncio
async def test_legacy_login_page_hides_credentials_hint_when_ui_password_set():
response = await _render_legacy_login_page(
env_overrides={"UI_PASSWORD": "s3cret-pass"},
general_settings={},
)
body = response.body.decode()
assert response.status_code == 200
assert "Default Credentials" not in body
assert "MASTER_KEY" not in body
assert 'name="username"' in body
@pytest.mark.asyncio
async def test_saml_callback_blocked_when_admin_ui_disabled():
"""An IdP-initiated assertion must not mint a UI session when the admin UI is

View file

@ -4703,6 +4703,108 @@ def test_create_file_blocked_extension_unset_allows_everything(monkeypatch, llm_
assert len(forwarded_calls) == 1
@pytest.mark.parametrize(
"filename",
["payload.exe", "notes.txt", "README"],
ids=["other_extension", "text_extension", "no_extension"],
)
def test_create_file_extension_outside_allowlist_rejected_before_forwarding(
monkeypatch, llm_router: Router, filename: str
):
import litellm.proxy.proxy_server as ps
forwarded_calls = _setup_batch_upload_endpoint(monkeypatch, llm_router)
monkeypatch.setitem(ps.general_settings, "allowed_file_extensions", [".jsonl"])
try:
response = client.post(
"/v1/files",
files={"file": (filename, b"MZ\x90\x00", "application/octet-stream")},
data={"purpose": "user_data"},
headers={"Authorization": "Bearer test-key"},
)
finally:
_teardown_batch_upload_endpoint()
assert response.status_code == 400, response.text
error = response.json()["error"]
assert error["type"] == "invalid_request_error"
assert error["param"] == "file"
assert "allowed_file_extensions" in error["message"]
assert forwarded_calls == []
def test_create_file_allowed_extension_forwards_case_insensitively(monkeypatch, llm_router: Router):
import litellm.proxy.proxy_server as ps
forwarded_calls = _setup_batch_upload_endpoint(monkeypatch, llm_router)
monkeypatch.setitem(ps.general_settings, "allowed_file_extensions", [".JSONL"])
try:
response = client.post(
"/v1/files",
files={"file": ("input.jsonl", b'{"custom_id": "1"}\n', "application/jsonl")},
data={"purpose": "user_data"},
headers={"Authorization": "Bearer test-key"},
)
finally:
_teardown_batch_upload_endpoint()
assert response.status_code == 200, response.text
assert len(forwarded_calls) == 1
def test_create_file_empty_allowlist_rejects_every_upload(monkeypatch, llm_router: Router):
import litellm.proxy.proxy_server as ps
forwarded_calls = _setup_batch_upload_endpoint(monkeypatch, llm_router)
monkeypatch.setitem(ps.general_settings, "allowed_file_extensions", [])
try:
response = client.post(
"/v1/files",
files={"file": ("input.jsonl", b'{"custom_id": "1"}\n', "application/jsonl")},
data={"purpose": "user_data"},
headers={"Authorization": "Bearer test-key"},
)
finally:
_teardown_batch_upload_endpoint()
assert response.status_code == 400, response.text
assert "allowed_file_extensions" in response.json()["error"]["message"]
assert forwarded_calls == []
def test_create_file_allowlist_runs_before_blocklist(monkeypatch, llm_router: Router):
import litellm.proxy.proxy_server as ps
forwarded_calls = _setup_batch_upload_endpoint(monkeypatch, llm_router)
monkeypatch.setitem(ps.general_settings, "allowed_file_extensions", [".jsonl"])
monkeypatch.setitem(ps.general_settings, "blocked_file_extensions", [".exe", ".jsonl"])
try:
denied_by_allowlist = client.post(
"/v1/files",
files={"file": ("payload.exe", b"MZ\x90\x00", "application/octet-stream")},
data={"purpose": "user_data"},
headers={"Authorization": "Bearer test-key"},
)
denied_by_blocklist = client.post(
"/v1/files",
files={"file": ("input.jsonl", b'{"custom_id": "1"}\n', "application/jsonl")},
data={"purpose": "user_data"},
headers={"Authorization": "Bearer test-key"},
)
finally:
_teardown_batch_upload_endpoint()
assert denied_by_allowlist.status_code == 400, denied_by_allowlist.text
assert "allowed_file_extensions" in denied_by_allowlist.json()["error"]["message"]
assert denied_by_blocklist.status_code == 400, denied_by_blocklist.text
assert "blocked_file_extensions" in denied_by_blocklist.json()["error"]["message"]
assert forwarded_calls == []
def test_create_file_path_traversal_filename_rejected_before_forwarding(monkeypatch, llm_router: Router):
"""A filename carrying a directory-traversal component must never reach storage or the provider."""
forwarded_calls = _setup_batch_upload_endpoint(monkeypatch, llm_router)

View file

@ -1,4 +1,5 @@
import io
from pathlib import Path
import pytest
@ -6,11 +7,14 @@ from litellm.proxy._types import ProxyException
from litellm.proxy.openai_files_endpoints.general_upload_validation import (
MB,
UploadedFileBlockedExtension,
UploadedFileExtensionNotAllowed,
UploadedFileTooLarge,
UploadedFileUnsafeFilename,
check_allowed_extension,
check_blocked_extension,
check_unsafe_filename,
check_upload_file_size,
coerce_optional_str_list_setting,
raise_upload_validation_failure,
)
@ -79,6 +83,44 @@ def test_no_filename_skips_extension_check():
assert check_blocked_extension(None, (".exe",)) is None
def test_allowed_extension_passes():
assert check_allowed_extension("batch.jsonl", (".jsonl", ".pdf")) is None
@pytest.mark.parametrize("filename", ["payload.exe", "notes.txt", "archive.tar.gz"])
def test_extension_outside_allowlist_rejected(filename):
assert check_allowed_extension(filename, (".jsonl", ".pdf")) == UploadedFileExtensionNotAllowed(
extension=Path(filename).suffix
)
def test_allowed_extension_match_is_case_insensitive_for_upload():
assert check_allowed_extension("batch.JSONL", (".jsonl",)) is None
def test_allowed_extension_match_is_case_insensitive_for_configured_value():
assert check_allowed_extension("batch.jsonl", (".JSONL",)) is None
@pytest.mark.parametrize("filename", ["README", "", None, "../../"])
def test_no_extension_rejected_when_allowlist_set(filename):
assert check_allowed_extension(filename, (".jsonl",)) == UploadedFileExtensionNotAllowed(extension="")
def test_empty_allowlist_rejects_everything():
assert check_allowed_extension("batch.jsonl", ()) == UploadedFileExtensionNotAllowed(extension=".jsonl")
def test_unset_allowlist_skips_check():
assert check_allowed_extension("payload.exe", None) is None
def test_coerce_str_list_setting_keeps_unset_and_empty_distinct():
assert coerce_optional_str_list_setting(None) is None
assert coerce_optional_str_list_setting([]) == ()
assert coerce_optional_str_list_setting([".jsonl"]) == (".jsonl",)
def test_path_traversal_filename_rejected():
assert check_unsafe_filename("../../etc/passwd") == UploadedFileUnsafeFilename(filename="../../etc/passwd")
@ -112,6 +154,16 @@ def test_ordinary_filenames_allowed(filename):
"413",
("15.0 MB", "max_file_size_mb", "10 MB", "not forwarded"),
),
(
UploadedFileExtensionNotAllowed(extension=".exe"),
"400",
(".exe", "allowed_file_extensions", "not forwarded"),
),
(
UploadedFileExtensionNotAllowed(extension=""),
"400",
("without an extension", "allowed_file_extensions", "not forwarded"),
),
(
UploadedFileBlockedExtension(extension=".exe"),
"400",

View file

@ -3469,6 +3469,30 @@ async def test_ProxyConfig__update_general_settings_cleared_db_max_batch_file_si
assert ps.general_settings.get("max_batch_file_size_mb") is None
@pytest.mark.asyncio
async def test_ProxyConfig__update_general_settings_applies_db_allowed_file_extensions(monkeypatch):
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {})
pc = ProxyConfig()
await pc._update_general_settings({"allowed_file_extensions": [".jsonl"]})
from litellm.proxy import proxy_server as ps
assert ps.general_settings.get("allowed_file_extensions") == [".jsonl"]
@pytest.mark.asyncio
async def test_ProxyConfig__update_general_settings_yaml_allowed_file_extensions_wins_over_db(monkeypatch):
monkeypatch.setattr(
"litellm.proxy.proxy_server.general_settings",
{"allowed_file_extensions": [".pdf"]},
)
pc = ProxyConfig()
pc._yaml_general_settings_keys = {"allowed_file_extensions"}
await pc._update_general_settings({"allowed_file_extensions": [".jsonl"]})
from litellm.proxy import proxy_server as ps
assert ps.general_settings.get("allowed_file_extensions") == [".pdf"]
@pytest.mark.asyncio
async def test_ProxyConfig__update_general_settings_none_input_noop():
pc = ProxyConfig()

View file

@ -97,6 +97,7 @@ def test_fallback_login_returns_html_form_with_ui_username_set(client, monkeypat
def test_fallback_login_shows_credentials_hint_by_default(client, monkeypatch):
"""Control: without the flag, /fallback/login still renders the hint."""
monkeypatch.delenv("UI_USERNAME", raising=False)
monkeypatch.delenv("UI_PASSWORD", raising=False)
monkeypatch.delenv("LITELLM_HIDE_DEFAULT_CREDENTIALS_HINT", raising=False)
response = client.get("/fallback/login")
assert response.status_code == 200
@ -104,6 +105,16 @@ def test_fallback_login_shows_credentials_hint_by_default(client, monkeypatch):
assert "MASTER_KEY" in response.text
def test_fallback_login_hides_credentials_hint_when_ui_password_set(client, monkeypatch):
monkeypatch.setenv("UI_PASSWORD", "s3cret-pass")
monkeypatch.delenv("LITELLM_HIDE_DEFAULT_CREDENTIALS_HINT", raising=False)
response = client.get("/fallback/login")
assert response.status_code == 200
assert "Default Credentials" not in response.text
assert "MASTER_KEY" not in response.text
assert 'name="username"' in response.text
def test_fallback_login_hides_credentials_hint_via_env_flag(client, monkeypatch):
"""Pin: LITELLM_HIDE_DEFAULT_CREDENTIALS_HINT removes the hint on /fallback/login."""
monkeypatch.delenv("UI_USERNAME", raising=False)

View file

@ -1180,6 +1180,24 @@ async def test_apply_spend_counter_increments_open_breaker_invalidates_and_retur
fake_cache.in_memory_cache.set_cache.assert_not_called()
@pytest.mark.asyncio
async def test_apply_spend_counter_increments_redis_timeout_invalidates_and_returns(monkeypatch):
"""A Redis timeout invalidates the counters and returns without reaching the cost callback's error path."""
from redis.exceptions import TimeoutError as RedisTimeoutError
fake_cache = _make_spend_counter_cache()
fake_cache.redis_cache.async_increment_pipeline = AsyncMock(
side_effect=RedisTimeoutError("Timeout reading from 127.0.0.1:6379")
)
monkeypatch.setattr(ps, "spend_counter_cache", fake_cache)
await ps._apply_spend_counter_increments(_two_pending_increments())
deleted_keys = sorted(call.kwargs["key"] for call in fake_cache.in_memory_cache.delete_cache.call_args_list)
assert deleted_keys == ["spend:key:k", "spend:team:t"]
fake_cache.in_memory_cache.set_cache.assert_not_called()
@pytest.mark.asyncio
async def test_apply_spend_counter_increments_other_redis_error_invalidates_and_raises(monkeypatch):
fake_cache = _make_spend_counter_cache()

View file

@ -9,6 +9,7 @@ rows instead of the internal routing key `model_name_{team_id}_{uuid}`.
from __future__ import annotations
import json
from unittest.mock import AsyncMock, MagicMock
import pytest
@ -238,7 +239,7 @@ async def test_model_info_v1_list_path_translates_team_model_name(monkeypatch):
)
resp = await ps.model_info_v1(user_api_key_dict=admin, litellm_model_id=None)
names = [m["model_name"] for m in resp["data"]]
names = [m["model_name"] for m in json.loads(resp.body)["data"]]
assert "team-claude-sonnet" in names
assert "model_name_team-abc-123_4a6b8" not in names
@ -271,7 +272,7 @@ async def test_model_info_v1_unrestricted_key_returns_all_deployments(monkeypatc
)
resp = await ps.model_info_v1(user_api_key_dict=caller, litellm_model_id=None)
assert [m["model_name"] for m in resp["data"]] == ["gpt-4"]
assert [m["model_name"] for m in json.loads(resp.body)["data"]] == ["gpt-4"]
@pytest.mark.asyncio
@ -303,7 +304,7 @@ async def test_model_info_v1_restricted_key_filters_deployments(monkeypatch):
)
resp = await ps.model_info_v1(user_api_key_dict=caller, litellm_model_id=None)
assert [m["model_name"] for m in resp["data"]] == ["gpt-4"]
assert [m["model_name"] for m in json.loads(resp.body)["data"]] == ["gpt-4"]
def _other_team_row() -> dict:
@ -367,10 +368,11 @@ async def test_model_info_v1_unrestricted_key_hides_other_team_byok(monkeypatch)
)
resp = await ps.model_info_v1(user_api_key_dict=caller, litellm_model_id=None)
returned_ids = {m["model_info"]["id"] for m in resp["data"]}
data = json.loads(resp.body)["data"]
returned_ids = {m["model_info"]["id"] for m in data}
assert returned_ids == {"global-id-1", "byok-id-1"}
assert "byok-id-other" not in returned_ids
names = [m["model_name"] for m in resp["data"]]
names = [m["model_name"] for m in data]
assert "team-claude-sonnet" in names
assert "gpt-4" in names
@ -412,7 +414,7 @@ async def test_model_info_v1_service_key_hides_all_team_byok(monkeypatch):
)
resp = await ps.model_info_v1(user_api_key_dict=caller, litellm_model_id=None)
assert [m["model_info"]["id"] for m in resp["data"]] == ["global-id-1"]
assert [m["model_info"]["id"] for m in json.loads(resp.body)["data"]] == ["global-id-1"]
@pytest.mark.asyncio
@ -466,7 +468,7 @@ async def test_model_info_v1_team_key_sees_own_byok_regardless_of_user_lookup(
)
resp = await ps.model_info_v1(user_api_key_dict=caller, litellm_model_id=None)
assert [m["model_info"]["id"] for m in resp["data"]] == ["byok-id-1", "global-id-1"]
assert [m["model_info"]["id"] for m in json.loads(resp.body)["data"]] == ["byok-id-1", "global-id-1"]
@pytest.mark.asyncio
@ -509,7 +511,7 @@ async def test_model_info_v1_user_team_membership_grants_byok(monkeypatch):
)
resp = await ps.model_info_v1(user_api_key_dict=caller, litellm_model_id=None)
assert [m["model_info"]["id"] for m in resp["data"]] == [
assert [m["model_info"]["id"] for m in json.loads(resp.body)["data"]] == [
"byok-id-other",
"global-id-1",
]
@ -557,7 +559,7 @@ async def test_model_info_v1_populates_access_via_team_ids(monkeypatch):
)
resp = await ps.model_info_v1(user_api_key_dict=admin, litellm_model_id=None)
by_id = {m["model_info"]["id"]: m for m in resp["data"]}
by_id = {m["model_info"]["id"]: m for m in json.loads(resp.body)["data"]}
assert by_id["byok-id-1"]["model_info"]["access_via_team_ids"] == [team_id]
assert by_id["byok-id-1"]["model_info"]["direct_access"] is False
assert by_id["global-id-1"]["model_info"]["direct_access"] is True
@ -816,7 +818,7 @@ async def test_model_info_v1_litellm_model_id_include_team_models_filters_inacce
include_team_models=True,
)
assert resp["data"] == []
assert json.loads(resp.body)["data"] == []
@pytest.mark.asyncio
@ -852,7 +854,7 @@ async def test_model_info_v1_litellm_model_id_team_id_applies_team_filter(monkey
teamId="other-team",
)
assert resp["data"] == []
assert json.loads(resp.body)["data"] == []
team_filter.assert_awaited_once()
assert team_filter.await_args.kwargs["team_id"] == "other-team"
assert team_filter.await_args.kwargs["all_models"] == [team_row]

View file

@ -3879,6 +3879,39 @@ class TestHandleLLMApiExceptionRetryAfter:
assert proxy_exc.headers["retry-after"] == "43"
assert proxy_exc.headers["x-custom"] == "1"
async def test_handle_llm_api_exception_names_cooldown_when_every_deployment_is_cooled_down(self):
from litellm.types.router import RouterRateLimitError
exc = RouterRateLimitError(
model="gpt-4",
cooldown_time=120,
enable_pre_call_checks=False,
cooldown_list=["dep-a", "dep-b"],
model_ids=["dep-a", "dep-b"],
)
proxy_exc = await self._invoke(exc)
body = proxy_exc.to_dict()
assert body["type"] == "all_deployments_in_cooldown"
assert body["code"] == "429"
assert "All deployments for selected model are in cooldown" in body["message"]
assert proxy_exc.headers["retry-after"] == "120"
async def test_handle_llm_api_exception_keeps_rate_limit_type_when_cooldown_is_partial(self):
from litellm.types.router import RouterRateLimitError
exc = RouterRateLimitError(
model="gpt-4",
cooldown_time=120,
enable_pre_call_checks=False,
cooldown_list=["dep-a"],
model_ids=["dep-a", "dep-b"],
)
proxy_exc = await self._invoke(exc)
body = proxy_exc.to_dict()
assert body["type"] == "rate_limit_error"
assert body["code"] == "429"
assert "All deployments for selected model are in cooldown" not in body["message"]
class TestHandleLLMApiExceptionFramingHeaders:
"""HTTP-framing headers on the provider exception must be stripped before the
@ -8400,6 +8433,41 @@ async def test_handle_llm_api_exception_forwards_provider_headers_on_http_status
assert exc_info.value.headers["llm_provider-x-amzn-requestid"] == "req-passthrough-500"
@pytest.mark.asyncio
async def test_handle_llm_api_exception_forwards_litellm_response_headers_when_response_is_synthetic():
"""Exception mapping hands the proxy a mapped error whose ``response`` is a synthetic empty
``httpx.Response`` and parks the provider's real headers on ``litellm_response_headers``.
The client must still get the provider request id, as it does on a 200.
"""
import httpx
from litellm.proxy._types import ProxyException, UserAPIKeyAuth
mapped = litellm.BadRequestError(
message="OpenAIException - max_tokens is too large: 999999999.",
model="gpt-4o-mini",
llm_provider="openai",
)
mapped.litellm_response_headers = httpx.Headers({"x-request-id": "req_openai_400"})
assert dict(mapped.response.headers) == {}
processor = ProxyBaseLLMRequestProcessing(data={})
proxy_logging_obj = MagicMock()
proxy_logging_obj.post_call_failure_hook = AsyncMock(return_value=None)
proxy_logging_obj.post_call_response_headers_hook = AsyncMock(return_value={})
with pytest.raises(ProxyException) as exc_info:
await processor._handle_llm_api_exception(
e=mapped,
user_api_key_dict=UserAPIKeyAuth(api_key="sk-test"),
proxy_logging_obj=proxy_logging_obj,
)
assert exc_info.value.code == "400"
assert "max_tokens is too large: 999999999." in exc_info.value.message
assert exc_info.value.headers["llm_provider-x-request-id"] == "req_openai_400"
class TestBackgroundResponseRetrievalGovernance:
"""LIT-7175: retrieving a background Response attaches the model's post_call policy pipelines."""

View file

@ -3,6 +3,7 @@ Tests verifying that default_api_key_tpm_limit and default_api_key_rpm_limit set
litellm_params are returned by the /model/info endpoint.
"""
import json
from typing import Optional
from unittest.mock import MagicMock, patch
@ -128,8 +129,9 @@ class TestModelInfoEndpointWithRouter:
litellm_model_id="some-model-id",
)
assert len(response["data"]) == 1
litellm_params = response["data"][0]["litellm_params"]
data = json.loads(response.body)["data"]
assert len(data) == 1
litellm_params = data[0]["litellm_params"]
assert litellm_params.get("default_api_key_tpm_limit") == 100
assert litellm_params.get("default_api_key_rpm_limit") == 200
@ -171,7 +173,8 @@ class TestModelInfoEndpointWithRouter:
litellm_model_id=None,
)
assert len(response["data"]) >= 1
litellm_params = response["data"][0]["litellm_params"]
data = json.loads(response.body)["data"]
assert len(data) >= 1
litellm_params = data[0]["litellm_params"]
assert litellm_params.get("default_api_key_tpm_limit") == 100
assert litellm_params.get("default_api_key_rpm_limit") == 200

View file

@ -6,6 +6,7 @@ per-request `healthy_only` query parameter and the proxy-wide
(`model_info_v1`).
"""
import json
from unittest.mock import AsyncMock, MagicMock
import pytest
@ -275,7 +276,7 @@ async def test_model_info_v1_healthy_only_hides_unhealthy_deployments(
litellm_model_id=None,
healthy_only=True,
)
assert [m["model_name"] for m in response["data"]] == ["gpt-4"]
assert [m["model_name"] for m in json.loads(response.body)["data"]] == ["gpt-4"]
@pytest.mark.asyncio
@ -286,7 +287,7 @@ async def test_model_info_v1_general_setting_hides_unhealthy_deployments(patched
user_api_key_dict=_admin_key(),
litellm_model_id=None,
)
assert [m["model_name"] for m in response["data"]] == ["gpt-4"]
assert [m["model_name"] for m in json.loads(response.body)["data"]] == ["gpt-4"]
@pytest.mark.asyncio
@ -297,7 +298,7 @@ async def test_model_info_v1_default_keeps_unhealthy_deployments(
user_api_key_dict=_admin_key(),
litellm_model_id=None,
)
assert [m["model_name"] for m in response["data"]] == ["gpt-4", "claude-sonnet"]
assert [m["model_name"] for m in json.loads(response.body)["data"]] == ["gpt-4", "claude-sonnet"]
patched_model_info_v1.async_get_fully_unhealthy_model_names.assert_not_awaited()
@ -318,4 +319,4 @@ async def test_model_info_v1_litellm_model_id_lookup_ignores_health_filter(patch
user_api_key_dict=_admin_key(),
litellm_model_id="unhealthy-id",
)
assert [m["model_name"] for m in response["data"]] == ["claude-sonnet"]
assert [m["model_name"] for m in json.loads(response.body)["data"]] == ["claude-sonnet"]

View file

@ -15,10 +15,12 @@ from unittest import mock
from unittest.mock import AsyncMock, MagicMock, create_autospec, mock_open, patch
import click
import fastapi.routing
import httpx
import pytest
import yaml
from fastapi import FastAPI
from fastapi.encoders import jsonable_encoder
from fastapi.staticfiles import StaticFiles
from fastapi.testclient import TestClient
@ -5248,6 +5250,8 @@ async def test_model_info_v1_oci_secrets_not_leaked():
result = await model_info_v1(user_api_key_dict=mock_user_api_key_dict, litellm_model_id=None)
# Verify the result structure
result_str = result.body.decode()
result = json.loads(result_str)
assert "data" in result
assert len(result["data"]) == 1
@ -5270,13 +5274,96 @@ async def test_model_info_v1_oci_secrets_not_leaked():
assert litellm_params["model"].startswith("oci/"), "model should retain its full value"
# Verify that actual secret values are not present in the response
result_str = str(result)
assert "ocid1.api_key.oc1..aaaaaaaa7kbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbk" not in result_str
assert "aa:bb:cc:dd:ee:ff:11:22:33:44:55:66:77:88:99:00" not in result_str
assert "ocid1.tenancy.oc1..aaaaaaaa7kbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbk" not in result_str
assert "/path/to/oci_api_key.pem" not in result_str
def test_model_info_v1_list_skips_fastapi_jsonable_encoder(monkeypatch):
"""
/model/info serializes its multi-megabyte listing itself with orjson. FastAPI must not
re-walk the payload through `jsonable_encoder`, while values orjson cannot encode natively
still come out as JSON.
"""
created_at = datetime(2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc)
model_data = {
"model_name": "gpt-4o",
"litellm_params": {"model": "openai/gpt-4o", "api_key": "sk-secret-value"},
"model_info": {
"id": "db-row-1",
"db_model": True,
"created_at": created_at,
"supported_regions": frozenset({"eu"}),
},
}
mock_router = MagicMock()
mock_router.model_list = [model_data]
mock_router.get_model_list_from_model_alias.return_value = []
mock_router.get_model_names.return_value = ["gpt-4o"]
mock_router.get_model_access_groups.return_value = {}
mock_router.get_deployment.return_value = None
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", mock_router)
monkeypatch.setattr("litellm.proxy.proxy_server.llm_model_list", [model_data])
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None)
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {"infer_model_from_keys": False})
monkeypatch.setattr("litellm.proxy.proxy_server.user_model", None)
encoder_spy = MagicMock(wraps=jsonable_encoder)
monkeypatch.setattr(fastapi.routing, "jsonable_encoder", encoder_spy)
original_overrides = app.dependency_overrides.copy()
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-1234", models=[], team_models=[]
)
client = TestClient(app)
try:
response = client.get("/model/info")
finally:
app.dependency_overrides = original_overrides
assert response.status_code == 200
assert response.headers["content-type"] == "application/json"
rows = response.json()["data"]
assert [row["model_name"] for row in rows] == ["gpt-4o"]
assert rows[0]["model_info"]["created_at"] == created_at.isoformat()
assert rows[0]["model_info"]["supported_regions"] == ["eu"]
assert "sk-secret-value" not in response.text
assert encoder_spy.call_count == 0
def test_model_info_v1_cli_model_returns_single_deployment_as_json(monkeypatch):
"""
A proxy started with `litellm --model <name>` answers /model/info with one deployment
object under `data`, serialized the same way as the listing.
"""
monkeypatch.setattr("litellm.proxy.proxy_server.user_model", "gpt-4o")
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", None)
monkeypatch.setattr("litellm.proxy.proxy_server.llm_model_list", None)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None)
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {})
encoder_spy = MagicMock(wraps=jsonable_encoder)
monkeypatch.setattr(fastapi.routing, "jsonable_encoder", encoder_spy)
original_overrides = app.dependency_overrides.copy()
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-1234", models=[], team_models=[]
)
client = TestClient(app)
try:
response = client.get("/model/info")
finally:
app.dependency_overrides = original_overrides
assert response.status_code == 200
assert response.headers["content-type"] == "application/json"
deployment = response.json()["data"]
assert deployment["model_name"] == "*"
assert deployment["litellm_params"]["model"] == "gpt-4o"
assert encoder_spy.call_count == 0
def test_add_callback_from_db_to_in_memory_litellm_callbacks():
"""
Test that _add_callback_from_db_to_in_memory_litellm_callbacks correctly adds callbacks

View file

@ -0,0 +1,30 @@
from typing import Final
import pytest
import litellm
from litellm.rust_bridge.lifecycle import check_limits
@pytest.mark.parametrize("metadata_key", ["metadata", "litellm_metadata"])
@pytest.mark.parametrize(
"cap, attempted_retries, refused",
[(5, 5, True), (5, 4, False), (0, 0, False), (0, 1, True)],
ids=[
"cap-above-four-reached",
"cap-above-four-not-reached",
"first-attempt-passes-cap-of-zero",
"cap-of-zero-refuses-first-retry",
],
)
def test_check_limits_reads_attempted_retries(
monkeypatch: pytest.MonkeyPatch, metadata_key: str, cap: int, attempted_retries: int, refused: bool
) -> None:
monkeypatch.setattr(litellm, "num_retries_per_request", cap)
monkeypatch.setattr(litellm, "max_budget", None)
kwargs: Final = {"model": "mistral/mistral-ocr-latest", metadata_key: {"attempted_retries": attempted_retries}}
if refused:
with pytest.raises(RuntimeError, match="Max retries per request hit!"):
check_limits(kwargs)
else:
check_limits(kwargs)

View file

@ -17,6 +17,7 @@ from litellm._logging import (
_MAX_SCRUBBED_ACCESS_ARG,
_PLAIN_LOG_FORMAT,
ALL_LOGGERS,
AccessLogPathFilter,
AccessLogRedactionFilter,
CorrelationContextFilter,
CorrelationPlainFormatter,
@ -1178,3 +1179,72 @@ def test_access_redaction_survives_the_uvicorn_json_log_config():
lg.handlers[:] = handlers
lg.setLevel(level)
lg.propagate = True
_DISABLED_ACCESS_LOG_PATHS_RAW = " /health/liveliness , ,/metrics/"
@pytest.mark.parametrize(
"full_path",
[
"/health/liveliness",
"/health/liveliness?x=1",
"/health/liveliness?probe=" + "x" * _MAX_SCRUBBED_ACCESS_ARG,
"/metrics/",
"/metrics/?format=prometheus&job=a",
],
)
def test_uvicorn_access_logger_drops_a_configured_path(monkeypatch, full_path):
monkeypatch.setenv("LITELLM_DISABLE_ACCESS_LOG_PATHS", _DISABLED_ACCESS_LOG_PATHS_RAW)
assert _emit_access_line(full_path) == ""
@pytest.mark.parametrize(
"full_path",
["/v1/chat/completions", "/health", "/health/liveliness/", "/metrics", "/v1/models?health=/health/liveliness"],
)
def test_uvicorn_access_logger_keeps_an_unconfigured_path(monkeypatch, full_path):
monkeypatch.setenv("LITELLM_DISABLE_ACCESS_LOG_PATHS", _DISABLED_ACCESS_LOG_PATHS_RAW)
assert f'"GET {full_path} HTTP/1.1" 200' in _emit_access_line(full_path)
@pytest.mark.parametrize("raw", [None, "", " , ,"])
def test_uvicorn_access_logger_keeps_every_line_when_no_path_is_configured(monkeypatch, raw):
if raw is None:
monkeypatch.delenv("LITELLM_DISABLE_ACCESS_LOG_PATHS", raising=False)
else:
monkeypatch.setenv("LITELLM_DISABLE_ACCESS_LOG_PATHS", raw)
assert '"GET /health/liveliness HTTP/1.1" 200' in _emit_access_line("/health/liveliness")
def test_access_log_path_filter_survives_the_uvicorn_json_log_config(monkeypatch):
import logging.config
monkeypatch.setenv("LITELLM_DISABLE_ACCESS_LOG_PATHS", _DISABLED_ACCESS_LOG_PATHS_RAW)
names = ("uvicorn", "uvicorn.error", "uvicorn.access")
saved = tuple((logging.getLogger(n), logging.getLogger(n).handlers[:], logging.getLogger(n).level) for n in names)
try:
logging.config.dictConfig(_get_uvicorn_json_log_config())
assert _emit_access_line("/health/liveliness?x=1") == ""
assert '"GET /v1/models HTTP/1.1" 200' in _emit_access_line("/v1/models")
finally:
for lg, handlers, level in saved:
lg.handlers[:] = handlers
lg.setLevel(level)
lg.propagate = True
@pytest.mark.parametrize("args", [None, ("127.0.0.1:1", "GET", 42)])
def test_access_log_path_filter_keeps_a_record_without_a_string_path_arg(monkeypatch, args):
monkeypatch.setenv("LITELLM_DISABLE_ACCESS_LOG_PATHS", "/health/liveliness")
record = logging.LogRecord(
name="uvicorn.access",
level=logging.INFO,
pathname="",
lineno=0,
msg='127.0.0.1:1 - "GET /health/liveliness HTTP/1.1" 200',
args=args,
exc_info=None,
)
assert AccessLogPathFilter().filter(record) is True

View file

@ -7735,6 +7735,52 @@ def test_get_available_deployment_raises_when_addressed_dict_is_blocked():
router.get_available_deployment(model="dep-0", request_kwargs={})
def _cool_down(router: Router, *deployment_ids: str) -> None:
for deployment_id in deployment_ids:
router.cooldown_cache.add_deployment_to_cooldown(
model_id=deployment_id,
original_exception=litellm.RateLimitError(message="upstream 429", llm_provider="openai", model="gpt-4o"),
exception_status=429,
cooldown_time=60,
)
async def _select_deployment(router: Router, use_async: bool) -> None:
if use_async:
await router.async_get_available_deployment(model="gpt-4o", request_kwargs={})
return
router.get_available_deployment(model="gpt-4o", request_kwargs={})
@pytest.mark.parametrize("use_async", [False, True], ids=["sync", "async"])
@pytest.mark.asyncio
async def test_get_available_deployment_names_cooldown_when_every_deployment_is_cooled_down(use_async: bool):
from litellm.types.router import RouterErrors, RouterRateLimitError
router: Final = _router_with_two_deployments([False, False])
_cool_down(router, "dep-0", "dep-1")
with pytest.raises(RouterRateLimitError) as exc_info:
await _select_deployment(router, use_async)
assert exc_info.value.all_deployments_in_cooldown is True
assert exc_info.value.type == "all_deployments_in_cooldown"
assert RouterErrors.all_deployments_in_cooldown.value in str(exc_info.value)
assert str(exc_info.value).startswith("No deployments available for selected model, Try again in ")
@pytest.mark.parametrize("use_async", [False, True], ids=["sync", "async"])
@pytest.mark.asyncio
async def test_get_available_deployment_keeps_generic_error_when_cooldown_is_partial(use_async: bool):
from litellm.types.router import RouterErrors, RouterRateLimitError
router: Final = _router_with_two_deployments([False, True])
_cool_down(router, "dep-0")
with pytest.raises(RouterRateLimitError) as exc_info:
await _select_deployment(router, use_async)
assert exc_info.value.all_deployments_in_cooldown is False
assert exc_info.value.type == "rate_limit_error"
assert RouterErrors.all_deployments_in_cooldown.value not in str(exc_info.value)
def _router_with_two_pass_through_deployments(blocked_flags):
import litellm
@ -7772,6 +7818,24 @@ def test_get_available_deployment_for_pass_through_raises_when_dict_blocked():
)
def test_get_available_deployment_for_pass_through_names_cooldown_despite_healthy_non_pass_through():
from litellm.types.router import RouterRateLimitError
router: Final = _router_with_two_pass_through_deployments([False, False])
router.add_deployment(
Deployment(
model_name="gpt-4o",
litellm_params=LiteLLM_Params(model="openai/gpt-4o-plain", api_key="sk-fake-for-tests"),
model_info=ModelInfo(id="plain-0"),
)
)
_cool_down(router, "pt-0", "pt-1")
with pytest.raises(RouterRateLimitError) as exc_info:
router.get_available_deployment_for_pass_through(model="gpt-4o", request_kwargs={})
assert exc_info.value.all_deployments_in_cooldown is True
assert exc_info.value.type == "all_deployments_in_cooldown"
def test_initialize_deployment_for_pass_through_keeps_bedrock_iam_deployment():
"""
Bedrock deployments using IAM/OIDC auth have no api_key; pass-through
@ -10642,6 +10706,7 @@ def _cyclic_fallback_router(num_retries=0):
"api_key": "sk-fake",
"mock_response": "litellm.InternalServerError",
},
"model_info": {"id": f"{group}-deployment"},
}
for group in groups
],
@ -10691,28 +10756,37 @@ async def test_cyclic_fallback_graph_does_not_amplify_one_request():
assert sum(len(message) for message in capture.messages) < 5_000
_FLAT_ATTEMPT_RECORD_KEYS = frozenset(
{"model_group", "deployment_id", "exception_type", "exception_string", "attempted_retries"}
)
_BREADCRUMB_CREDENTIAL_CANARY = "Bearer sk-ant-oat01-RETRY-BREADCRUMB-CANARY-doNotShip"
@pytest.mark.asyncio
async def test_retry_breadcrumbs_do_not_carry_the_walk_state():
"""log_retry copies every kwarg into previous_models, which reaches spend logs and
logging callbacks. The set of already-attempted groups is router-internal walk state
with no diagnostic value there, and it is the one entry that is not a plain scalar.
A retry has to be configured for the walk state to reach log_retry at all."""
async def test_retry_records_are_flat_and_name_the_failed_group_on_fallback_hops():
"""Each failed attempt leaves a flat record in previous_models, which reaches spend logs and
logging callbacks. Nothing downstream reads the failed attempt's kwargs or metadata, and copying
them is what carried client credentials and multiplied the payload on every retry. A fallback hop
calls log_retry too, so the record has to name the group that failed, not the one taken next."""
router = _cyclic_fallback_router(num_retries=1)
capture = _LogCapture(logging.ERROR)
recorder = _FallbackAttemptRecorder()
await _drive_cyclic_fallback(router, capture, recorder)
breadcrumbs = [breadcrumb for hop in recorder.breadcrumbs_per_target for breadcrumb in hop]
assert breadcrumbs, "no retry breadcrumbs were recorded"
assert any(
"fallback_depth" in breadcrumb for breadcrumb in breadcrumbs
), "no breadcrumb carried router walk state, so this test cannot see the leak"
for breadcrumb in breadcrumbs:
assert "attempted_targets" not in breadcrumb
_BREADCRUMB_CREDENTIAL_CANARY = "Bearer sk-ant-oat01-RETRY-BREADCRUMB-CANARY-doNotShip"
records = [record for hop in recorder.breadcrumbs_per_target for record in hop]
assert records, "no retry records were recorded"
for record in records:
assert set(record) == _FLAT_ATTEMPT_RECORD_KEYS
assert record["exception_type"] == "InternalServerError"
assert record["deployment_id"] == f"{record['model_group']}-deployment"
group_failed_before_hop = {"group-b": "group-a", "group-c": "group-b", "group-d": "group-c"}
for failed_target, hop_records in zip(recorder.failed_targets, recorder.breadcrumbs_per_target):
groups = [record["model_group"] for record in hop_records]
first_own_attempt = groups.index(failed_target)
assert groups[first_own_attempt - 1] == group_failed_before_hop[failed_target]
assert set(groups[first_own_attempt:]) == {failed_target}
assert [record["attempted_retries"] for record in hop_records[first_own_attempt:]][:2] == [0, 1]
@pytest.mark.parametrize(
@ -10738,22 +10812,20 @@ _BREADCRUMB_CREDENTIAL_CANARY = "Bearer sk-ant-oat01-RETRY-BREADCRUMB-CANARY-doN
],
)
@pytest.mark.asyncio
async def test_retry_breadcrumbs_never_carry_a_forwarded_credential(container_key, request_kwargs):
"""log_retry copies kwargs into previous_models, which reaches spend logs and logging callbacks.
Any of these kwargs can carry a client's forwarded Authorization token or a provider key, and a
breadcrumb has no diagnostic use for the raw secret. A denylist of key names is always one new
credential kwarg behind, so log_retry scrubs credential-named values by pattern instead: the
container still reaches the breadcrumb, but the raw secret never does, whatever key holds it."""
async def test_retry_records_never_carry_a_forwarded_credential(container_key, request_kwargs):
"""previous_models reaches spend logs and logging callbacks. Any request kwarg can carry a client's
forwarded Authorization token or a provider key, so the record must not carry request kwargs at
all: neither the credential-bearing container nor the raw secret, whatever key holds it."""
router = _cyclic_fallback_router(num_retries=1)
capture = _LogCapture(logging.ERROR)
metadata = {}
await _drive_cyclic_fallback(router, capture, metadata=metadata, **request_kwargs)
breadcrumbs = metadata["previous_models"]
assert breadcrumbs, "no retry breadcrumbs were recorded"
dumped = json.dumps(breadcrumbs, default=str)
assert container_key in dumped, "the credential-bearing kwarg never reached the breadcrumb, so this test cannot see the leak"
records = metadata["previous_models"]
assert records, "no retry records were recorded"
dumped = json.dumps(records)
assert container_key not in dumped
assert _BREADCRUMB_CREDENTIAL_CANARY not in dumped
@ -10773,12 +10845,12 @@ def _always_failing_router(num_retries):
)
async def _fail_one_proxy_shaped_request(router, request_marker):
async def _fail_one_proxy_shaped_request(router, request_marker, expected_error=litellm.InternalServerError):
"""The proxy hands the router a metadata dict and a proxy_server_request whose body is a
shallow copy of the request, so body["metadata"] is the very same dict the router later
stamps previous_models onto."""
metadata = {"request_marker": request_marker}
with pytest.raises(litellm.InternalServerError):
with pytest.raises(expected_error):
await router.acompletion(
model="broken-group",
messages=[{"role": "user", "content": "hi"}],
@ -10804,34 +10876,49 @@ def _nested_breadcrumb_lists(node):
@pytest.mark.asyncio
async def test_retry_breadcrumbs_stay_per_request_and_flat_across_failing_requests():
"""Every failed attempt appends a breadcrumb to metadata["previous_models"], and the proxy's
async def test_retry_records_stay_per_request_and_flat_across_failing_requests():
"""Every failed attempt appends a record to metadata["previous_models"], and the proxy's
request snapshot aliases that same metadata dict. Kept on the Router and copied wholesale,
each breadcrumb embedded every earlier one from every earlier request, so the breadcrumb
each breadcrumb once embedded every earlier one from every earlier request, so the breadcrumb
tree, and with it the debug repr of the kwargs, roughly doubled on each failed attempt until
a single-worker proxy spent minutes in the redaction regex and stopped answering."""
router = _always_failing_router(num_retries=2)
breadcrumbs_per_request = [
records_per_request = [
await _fail_one_proxy_shaped_request(router, f"request-{request_number}") for request_number in range(1, 7)
]
for request_number, breadcrumbs in enumerate(breadcrumbs_per_request, start=1):
assert len(breadcrumbs) == 3, "one initial attempt plus two retries failed, each leaving one breadcrumb"
assert {breadcrumb["metadata"]["request_marker"] for breadcrumb in breadcrumbs} == {f"request-{request_number}"}
for breadcrumb in breadcrumbs:
assert _nested_breadcrumb_lists(breadcrumb) == []
assert len({len(repr(breadcrumbs)) for breadcrumbs in breadcrumbs_per_request}) == 1
for records in records_per_request:
assert [record["attempted_retries"] for record in records] == [0, 1, 2]
for record in records:
assert set(record) == _FLAT_ATTEMPT_RECORD_KEYS
assert _nested_breadcrumb_lists(record) == []
assert len({len(repr(records)) for records in records_per_request}) == 1
@pytest.mark.asyncio
async def test_retry_breadcrumbs_keep_only_the_last_four_attempts():
async def test_retry_records_keep_only_the_last_four_attempts():
router = _always_failing_router(num_retries=6)
breadcrumbs = await _fail_one_proxy_shaped_request(router, "request-1")
records = await _fail_one_proxy_shaped_request(router, "request-1")
assert len(breadcrumbs) == 4
assert [breadcrumb["metadata"]["attempted_retries"] for breadcrumb in breadcrumbs] == [3, 4, 5, 6]
assert [record["attempted_retries"] for record in records] == [3, 4, 5, 6]
@pytest.mark.asyncio
async def test_num_retries_per_request_stops_retries_at_caps_above_four(monkeypatch):
monkeypatch.setattr(litellm, "num_retries_per_request", 5)
router = _always_failing_router(num_retries=6)
records = await _fail_one_proxy_shaped_request(router, "request-1", expected_error=litellm.APIConnectionError)
assert [record["attempted_retries"] for record in records] == [3, 4, 5, 6]
assert ["Max retries per request hit!" in record["exception_string"] for record in records] == [
False,
False,
True,
True,
]
@pytest.mark.asyncio

View file

@ -7,8 +7,8 @@ import os
import queue
import threading
from datetime import datetime, timedelta, timezone
from collections.abc import Iterator
from concurrent.futures import ThreadPoolExecutor
from collections.abc import Callable, Iterator
from concurrent.futures import Future, ThreadPoolExecutor
from typing import Final
from unittest.mock import AsyncMock, MagicMock, patch
@ -29,6 +29,7 @@ from litellm._logging import (
verbose_logger,
)
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.thread_pool_executor import executor as logging_executor
from litellm.proxy.utils import is_valid_api_key
from litellm.types.utils import (
CallTypes,
@ -4062,6 +4063,51 @@ class TestMetadataNoneHandling:
assert metadata == {}
_RETRY_CAP_CASES: Final = (
pytest.param(5, {"attempted_retries": 5}, True, id="cap-above-four-reached"),
pytest.param(5, {"attempted_retries": 4}, False, id="cap-above-four-not-reached"),
pytest.param(0, {"attempted_retries": 0}, False, id="first-attempt-passes-cap-of-zero"),
pytest.param(0, {"attempted_retries": 1}, True, id="cap-of-zero-refuses-first-retry"),
pytest.param(5, {"previous_models": ("a", "b", "c", "d", "e")}, False, id="breadcrumb-count-is-not-the-cap"),
pytest.param(5, None, False, id="metadata-none"),
)
def _capped_completion_kwargs(metadata_key: str, metadata: object) -> dict[str, object]:
return {
"model": "openai/gpt-4o-mini",
"messages": [{"role": "user", "content": "hi"}],
"api_key": "sk-fake",
"mock_response": "ok",
metadata_key: metadata,
}
@pytest.mark.parametrize("metadata_key", ["metadata", "litellm_metadata"])
@pytest.mark.parametrize("cap, metadata, refused", _RETRY_CAP_CASES)
def test_num_retries_per_request_reads_attempted_retries_sync(monkeypatch, metadata_key, cap, metadata, refused):
monkeypatch.setattr(litellm, "num_retries_per_request", cap)
kwargs: Final = _capped_completion_kwargs(metadata_key, metadata)
if refused:
with pytest.raises(Exception, match="Max retries per request hit!"):
litellm.completion(**kwargs)
else:
assert litellm.completion(**kwargs).choices[0].message.content == "ok"
@pytest.mark.asyncio
@pytest.mark.parametrize("metadata_key", ["metadata", "litellm_metadata"])
@pytest.mark.parametrize("cap, metadata, refused", _RETRY_CAP_CASES)
async def test_num_retries_per_request_reads_attempted_retries_async(monkeypatch, metadata_key, cap, metadata, refused):
monkeypatch.setattr(litellm, "num_retries_per_request", cap)
kwargs: Final = _capped_completion_kwargs(metadata_key, metadata)
if refused:
with pytest.raises(Exception, match="Max retries per request hit!"):
await litellm.acompletion(**kwargs)
else:
assert (await litellm.acompletion(**kwargs)).choices[0].message.content == "ok"
class TestValidateAndFixThinkingParam:
"""Tests for validate_and_fix_thinking_param."""
@ -6444,6 +6490,53 @@ async def test_acompletion_finishes_response_metadata_before_handing_the_respons
assert snapshot["api_base"]
class _GatedSyncLoggingHookRecorder(CustomLogger):
def __init__(self) -> None:
super().__init__()
self.seen: Final = queue.SimpleQueue[str | None]()
self.release: Final = threading.Event()
def logging_hook(
self, kwargs: dict[str, object], result: object, call_type: str
) -> tuple[dict[str, object], object]:
self.seen.put(result.id if isinstance(result, litellm.ModelResponse) else None)
self.release.wait(timeout=5)
return kwargs, result
@pytest.mark.asyncio
async def test_acompletion_runs_a_custom_logger_sync_logging_hook_exactly_once(monkeypatch: pytest.MonkeyPatch) -> None:
def legacy_sync_callback(
kwargs: dict[str, object], response: litellm.ModelResponse, start_time: datetime, end_time: datetime
) -> None:
pass
recorder: Final = _GatedSyncLoggingHookRecorder()
monkeypatch.setattr(litellm, "success_callback", [legacy_sync_callback, recorder])
logging_futures: Final = queue.SimpleQueue[Future[object]]()
real_submit: Final = logging_executor.submit
def submit_and_track(fn: Callable[..., object], *args: object, **kwargs: object) -> Future[object]:
future: Final = real_submit(fn, *args, **kwargs)
logging_futures.put(future)
return future
with patch( # test-quality-ok: wraps the real submit only to collect the futures to join, the pool still runs
"litellm.litellm_core_utils.litellm_logging.executor.submit", side_effect=submit_and_track
):
response: Final = await litellm.acompletion(
model="gpt-5.5",
messages=[{"role": "user", "content": "hi"}],
mock_response="Hello there!",
num_retries=0,
)
await asyncio.sleep(0)
recorder.release.set()
for _ in range(logging_futures.qsize()):
logging_futures.get_nowait().result(timeout=5)
assert [recorder.seen.get_nowait() for _ in range(recorder.seen.qsize())] == [response.id]
def test_completion_finishes_response_metadata_before_handing_the_response_to_the_logging_thread():
with _recording_hidden_params_at_submit("litellm.utils.executor.submit") as seen:
litellm.completion(

View file

@ -806,7 +806,7 @@ async def test_shared_call_limits_still_reject_before_reading_ocr_file(
monkeypatch.setattr(litellm, "_current_cost", 2)
monkeypatch.setattr(litellm, "num_retries_per_request", 1 if limit == "retries" else None)
expected: Final = litellm.BudgetExceededError if limit == "budget" else RuntimeError
arguments: Final = {"document": {"type": "file", "file": File()}, "metadata": {"previous_models": ["earlier"]}}
arguments: Final = {"document": {"type": "file", "file": File()}, "metadata": {"attempted_retries": 1}}
with pytest.raises(expected, match=r"Budget has been exceeded|Max retries per request hit"):
await call_aocr(ocr_server, **arguments) if asynchronous else call_ocr(ocr_server, **arguments)
assert reads == []

View file

@ -271,6 +271,7 @@ const displayCost = (localModelData: any, field: TouchedPricingField): string =>
interface ModelInfoEditFormProps {
localModelData: any;
modelData: { model_info: { team_id?: string | null } & Record<string, unknown> };
teamAlias: string | null;
accessToken: string | null;
isEditing: boolean;
isSaving: boolean;
@ -341,6 +342,7 @@ const ChipList: React.FC<{ values: unknown; emptyLabel: string }> = ({ values, e
const ModelInfoEditForm: React.FC<ModelInfoEditFormProps> = ({
localModelData,
modelData,
teamAlias,
accessToken,
isEditing,
isSaving,
@ -799,8 +801,12 @@ const ModelInfoEditForm: React.FC<ModelInfoEditFormProps> = ({
</div>
<div>
<FieldLabel>Team ID</FieldLabel>
<Display>{modelData.model_info.team_id || "Not Set"}</Display>
<FieldLabel>Team</FieldLabel>
<Display>
{teamAlias
? `${teamAlias} (${modelData.model_info.team_id})`
: modelData.model_info.team_id || "Not Set"}
</Display>
</div>
</div>

View file

@ -42,6 +42,11 @@ vi.mock("@/app/(dashboard)/hooks/models/useModelCostMap", () => ({
useModelCostMap: (...args: any[]) => mockUseModelCostMap(...args),
}));
const mockUseTeams = vi.fn();
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
useTeams: () => mockUseTeams(),
}));
const mockUsePtuCostAttributionEnabled = vi.fn();
vi.mock("@/app/(dashboard)/hooks/uiSettings/usePtuCostAttributionEnabled", () => ({
usePtuCostAttributionEnabled: () => mockUsePtuCostAttributionEnabled(),
@ -102,6 +107,7 @@ describe("ModelInfoView", () => {
});
vi.clearAllMocks();
mockUsePtuCostAttributionEnabled.mockReturnValue(false);
mockUseTeams.mockReturnValue({ data: undefined, isLoading: false, error: null });
mockUseModelsInfo.mockReturnValue({
data: {
@ -1305,6 +1311,78 @@ describe("ModelInfoView", () => {
});
});
describe("team alias", () => {
const teamModel = {
...defaultModelData,
model_info: { ...defaultModelData.model_info, team_id: "team-1" },
};
beforeEach(() => {
mockUseModelsInfo.mockReturnValue({ data: { data: [teamModel] }, isLoading: false, error: null });
mockModelInfoV1Call.mockResolvedValue({ data: [teamModel] });
});
const readRawJson = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(await screen.findByRole("tab", { name: /raw json/i }));
const pre = await screen.findByText(/"model_name": "GPT-4"/, { selector: "pre" });
return JSON.parse(pre.textContent ?? "");
};
it("shows the team alias next to the team id and adds team_alias to the raw JSON", async () => {
mockUseTeams.mockReturnValue({
data: [
{ team_id: "team-0", team_alias: "other" },
{ team_id: "team-1", team_alias: "alpha" },
],
isLoading: false,
error: null,
});
const user = userEvent.setup();
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { wrapper });
expect(await screen.findByText("alpha (team-1)")).toBeInTheDocument();
const raw = await readRawJson(user);
expect(raw.model_info).toMatchObject({ team_id: "team-1", team_alias: "alpha" });
const keys = Object.keys(raw.model_info);
expect(keys.indexOf("team_alias")).toBe(keys.indexOf("team_id") + 1);
});
it("falls back to the bare team id when the team is not in the caller's team list", async () => {
mockUseTeams.mockReturnValue({
data: [{ team_id: "team-0", team_alias: "other" }],
isLoading: false,
error: null,
});
const user = userEvent.setup();
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { wrapper });
expect(await screen.findByText("team-1")).toBeInTheDocument();
const raw = await readRawJson(user);
expect(raw.model_info.team_id).toBe("team-1");
expect(raw.model_info).not.toHaveProperty("team_alias");
});
it("shows Not Set and no team_alias for a model without a team", async () => {
mockUseModelsInfo.mockReturnValue({ data: { data: [defaultModelData] }, isLoading: false, error: null });
mockModelInfoV1Call.mockResolvedValue({ data: [defaultModelData] });
mockUseTeams.mockReturnValue({
data: [{ team_id: "team-1", team_alias: "alpha" }],
isLoading: false,
error: null,
});
const user = userEvent.setup();
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { wrapper });
expect(await screen.findByText("Team")).toBeInTheDocument();
expect(screen.queryByText(/alpha/)).not.toBeInTheDocument();
const raw = await readRawJson(user);
expect(raw.model_info).not.toHaveProperty("team_alias");
});
});
it("renders the provider card logo from the bundled provider map", async () => {
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { wrapper });

View file

@ -169,6 +169,12 @@ export default function ModelInfoView({
// Keep modelData variable name for backwards compatibility
const modelData = transformedModelData;
const teamAlias = teams?.find((team) => team.team_id === modelData?.model_info?.team_id)?.team_alias || null;
const rawModelInfoEntries = Object.entries(modelData?.model_info ?? {}).flatMap((entry) =>
entry[0] === "team_id" && teamAlias ? [entry, ["team_alias", teamAlias]] : [entry],
);
const rawModelData = modelData && { ...modelData, model_info: Object.fromEntries(rawModelInfoEntries) };
const canEditModel = canModifyModel({ userRole, userID, isViewOnly }, teams ?? null, {
teamId: modelData?.model_info?.team_id,
isDbModel: modelData?.model_info?.db_model === true,
@ -765,6 +771,7 @@ export default function ModelInfoView({
<ModelInfoEditForm
localModelData={localModelData}
modelData={modelData}
teamAlias={teamAlias}
accessToken={accessToken}
isEditing={isEditing}
isSaving={isSaving}
@ -788,7 +795,9 @@ export default function ModelInfoView({
<TabsContent value="raw" keepMounted>
<Card className="block p-6">
<pre className="bg-muted p-4 rounded-sm text-xs overflow-auto">{JSON.stringify(modelData, null, 2)}</pre>
<pre className="bg-muted p-4 rounded-sm text-xs overflow-auto">
{JSON.stringify(rawModelData, null, 2)}
</pre>
</Card>
</TabsContent>
</div>

View file

@ -8938,7 +8938,7 @@ export interface paths {
* `model_info.direct_access` when the proxy database is connected.
*
* Returns:
* Returns a dictionary containing information about each model.
* A JSON response whose `data` list holds one entry per model.
*
* Example Response:
* ```json
@ -19084,7 +19084,7 @@ export interface paths {
* `model_info.direct_access` when the proxy database is connected.
*
* Returns:
* Returns a dictionary containing information about each model.
* A JSON response whose `data` list holds one entry per model.
*
* Example Response:
* ```json
@ -25809,6 +25809,11 @@ export interface components {
* @description If True, lets keys address Responses API ids that this proxy did not issue (raw provider ids, or ids issued before response-id encryption was configured). Such an id carries no owner, so no ownership check can run on it; ids this proxy did issue keep full ownership enforcement. Off by default, in which case an unrecognized response id is rejected with 403
*/
allow_unmanaged_response_ids?: boolean | null;
/**
* Allowed File Extensions
* @description the only file extensions (e.g. ['.jsonl', '.pdf', '.txt']) accepted on /v1/files uploads, for any purpose, matched case-insensitively against the uploaded filename. Files with any other extension, or none, are rejected. An empty list rejects every upload. Unset means no allowlist is applied
*/
allowed_file_extensions?: string[] | null;
/**
* Allowed Routes
* @description Proxy API Endpoints you want users to be able to access
@ -25831,7 +25836,7 @@ export interface components {
background_health_checks?: boolean | null;
/**
* Blocked File Extensions
* @description file extensions (e.g. ['.exe', '.sh']) rejected on /v1/files uploads, for any purpose, matched case-insensitively against the uploaded filename
* @description file extensions (e.g. ['.exe', '.sh']) rejected on /v1/files uploads, for any purpose, matched case-insensitively against the uploaded filename. Deprecated in favour of allowed_file_extensions; still enforced, after the allowlist, when set
*/
blocked_file_extensions?: string[] | null;
/**