mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-19 00:01:29 +00:00
merge: main into litellm_lit5285_login_rate_limit_v2
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
commit
bb52c80d75
73 changed files with 2890 additions and 417 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -461,6 +464,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
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ from litellm.llms.vertex_ai.cost_calculator import cost_router as google_cost_ro
|
|||
from litellm.llms.xai.cost_calculator import cost_per_token as xai_cost_per_token
|
||||
from litellm.responses.utils import ResponseAPILoggingUtils
|
||||
from litellm.types.agents import LiteLLMSendMessageResponse
|
||||
from litellm.types.llms.base import CachedTokensDetails
|
||||
from litellm.types.llms.openai import (
|
||||
HttpxBinaryResponseContent,
|
||||
ImageGenerationRequestQuality,
|
||||
|
|
@ -2381,6 +2382,46 @@ def _summable_prompt_token_fields(prompt_tokens_details: BaseModel) -> list[str]
|
|||
return [attr for attr in field_names if attr != "cache_creation_tokens"]
|
||||
|
||||
|
||||
def _combine_cached_tokens_details(
|
||||
current: CachedTokensDetails | None, new: CachedTokensDetails
|
||||
) -> CachedTokensDetails:
|
||||
def _sum_optional(current_value: int | None, new_value: int | None) -> int | None:
|
||||
if current_value is None and new_value is None:
|
||||
return None
|
||||
return (current_value or 0) + (new_value or 0)
|
||||
|
||||
return CachedTokensDetails(
|
||||
text_tokens=_sum_optional(current.text_tokens if current is not None else None, new.text_tokens),
|
||||
audio_tokens=_sum_optional(current.audio_tokens if current is not None else None, new.audio_tokens),
|
||||
image_tokens=_sum_optional(current.image_tokens if current is not None else None, new.image_tokens),
|
||||
)
|
||||
|
||||
|
||||
def _combine_prompt_tokens_details(
|
||||
current: PromptTokensDetailsWrapper | None, new: PromptTokensDetailsWrapper
|
||||
) -> PromptTokensDetailsWrapper:
|
||||
base: Final = current if current is not None else PromptTokensDetailsWrapper()
|
||||
base_values: Final = MappingProxyType(
|
||||
{attr: getattr(base, attr) for attr in type(base).model_fields if hasattr(base, attr)}
|
||||
)
|
||||
summed: Final = MappingProxyType(
|
||||
{
|
||||
attr: (getattr(base, attr, 0) or 0) + (getattr(new, attr) or 0)
|
||||
for attr in _summable_prompt_token_fields(new)
|
||||
if hasattr(new, attr) and isinstance(getattr(new, attr) or 0, (int, float))
|
||||
}
|
||||
)
|
||||
new_cached_tokens_details: Final = getattr(new, "cached_tokens_details", None)
|
||||
cached_tokens_details: Final = (
|
||||
_combine_cached_tokens_details(getattr(base, "cached_tokens_details", None), new_cached_tokens_details)
|
||||
if isinstance(new_cached_tokens_details, CachedTokensDetails)
|
||||
else getattr(base, "cached_tokens_details", None)
|
||||
)
|
||||
return PromptTokensDetailsWrapper(
|
||||
**MappingProxyType({**base_values, **summed, "cached_tokens_details": cached_tokens_details})
|
||||
)
|
||||
|
||||
|
||||
class BaseTokenUsageProcessor:
|
||||
@staticmethod
|
||||
def combine_usage_objects(usage_objects: list[Usage]) -> Usage:
|
||||
|
|
@ -2389,7 +2430,6 @@ class BaseTokenUsageProcessor:
|
|||
"""
|
||||
from litellm.types.utils import (
|
||||
CompletionTokensDetailsWrapper,
|
||||
PromptTokensDetailsWrapper,
|
||||
Usage,
|
||||
)
|
||||
|
||||
|
|
@ -2408,27 +2448,10 @@ class BaseTokenUsageProcessor:
|
|||
and isinstance(current_val, (int, float))
|
||||
):
|
||||
setattr(combined, attr, current_val + new_val)
|
||||
# Handle nested prompt_tokens_details
|
||||
if hasattr(usage, "prompt_tokens_details") and usage.prompt_tokens_details:
|
||||
if not hasattr(combined, "prompt_tokens_details") or not combined.prompt_tokens_details:
|
||||
combined.prompt_tokens_details = PromptTokensDetailsWrapper()
|
||||
|
||||
# Check what keys exist in the model's prompt_tokens_details
|
||||
# Access model_fields on the class, not the instance, to avoid Pydantic 2.11+ deprecation warnings
|
||||
for attr in _summable_prompt_token_fields(usage.prompt_tokens_details):
|
||||
if (
|
||||
hasattr(usage.prompt_tokens_details, attr)
|
||||
and not attr.startswith("_")
|
||||
and not callable(_attribute_value(usage.prompt_tokens_details, attr))
|
||||
):
|
||||
current_val = getattr(combined.prompt_tokens_details, attr, 0) or 0
|
||||
new_val = getattr(usage.prompt_tokens_details, attr, 0) or 0
|
||||
if new_val is not None and isinstance(new_val, (int, float)):
|
||||
setattr(
|
||||
combined.prompt_tokens_details,
|
||||
attr,
|
||||
current_val + new_val,
|
||||
)
|
||||
combined.prompt_tokens_details = _combine_prompt_tokens_details(
|
||||
getattr(combined, "prompt_tokens_details", None), usage.prompt_tokens_details
|
||||
)
|
||||
|
||||
# Handle nested completion_tokens_details
|
||||
if hasattr(usage, "completion_tokens_details") and usage.completion_tokens_details:
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from typing import Final
|
|||
|
||||
from litellm.integrations.otel.mappers.base import AttributeMap, AttrValue, SpanData
|
||||
from litellm.integrations.otel.mappers.utils import (
|
||||
MAX_MESSAGE_ATTRS_PER_SPAN,
|
||||
MAX_TOOL_DEFINITION_ATTRS_PER_SPAN,
|
||||
collect,
|
||||
drop_none,
|
||||
|
|
@ -26,6 +27,8 @@ from litellm.integrations.otel.model.payloads import (
|
|||
ToolDefinition,
|
||||
)
|
||||
|
||||
_MAX_INDEXED_MESSAGES: Final = MAX_MESSAGE_ATTRS_PER_SPAN // 2
|
||||
|
||||
|
||||
class OpenInferenceMapper:
|
||||
"""Emits OpenInference attributes for LLM_CALL spans.
|
||||
|
|
@ -84,27 +87,44 @@ class OpenInferenceMapper:
|
|||
return {}
|
||||
|
||||
def _llm_call(self, data: LLMCallSpanData) -> AttributeMap:
|
||||
outputs: Final = output_messages(data)
|
||||
indexed_in, indexed_out = self._indexed_split(len(data.messages_in), len(outputs))
|
||||
return {
|
||||
**collect(self._LLM_CALL_ATTRS, data),
|
||||
**collect(self._BLOB_ATTRS, data),
|
||||
**self._messages("llm.input_messages", "input.value", data.messages_in),
|
||||
**self._messages("llm.output_messages", "output.value", output_messages(data)),
|
||||
**self._messages(
|
||||
"llm.input_messages",
|
||||
"input.value",
|
||||
data.messages_in,
|
||||
self._prompt_positions(len(data.messages_in), indexed_in),
|
||||
),
|
||||
**self._messages("llm.output_messages", "output.value", outputs, range(indexed_out)),
|
||||
**self._tools(data),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _messages(prefix: str, value_key: str, messages: Sequence[object]) -> AttributeMap:
|
||||
"""Per-message ``{prefix}.{idx}.message.*`` keys + the ``value_key`` blob."""
|
||||
def _indexed_split(inputs: int, outputs: int) -> tuple[int, int]:
|
||||
"""Prompt and response share one allowance; the response is reserved at least half of it."""
|
||||
indexed_out: Final = min(outputs, max(_MAX_INDEXED_MESSAGES // 2, _MAX_INDEXED_MESSAGES - inputs))
|
||||
return _MAX_INDEXED_MESSAGES - indexed_out, indexed_out
|
||||
|
||||
@staticmethod
|
||||
def _prompt_positions(total: int, indexed: int) -> tuple[int, ...]:
|
||||
"""Prompt messages that get per-index attributes: message 0 and the most recent turns."""
|
||||
if total <= indexed:
|
||||
return tuple(range(total))
|
||||
return (0, *range(total - indexed + 1, total))
|
||||
|
||||
@staticmethod
|
||||
def _messages(prefix: str, value_key: str, messages: Sequence[object], positions: Sequence[int]) -> AttributeMap:
|
||||
"""``{prefix}.{idx}.message.*`` keys for the messages at ``positions`` + the ``value_key`` blob of all."""
|
||||
parsed: Final = [(m.get("role") if isinstance(m, dict) else None, message_content(m)) for m in messages]
|
||||
attrs: Final = drop_none(
|
||||
{
|
||||
key: value
|
||||
for idx, (role, content) in enumerate(parsed)
|
||||
for idx, (role, content) in ((idx, parsed[idx]) for idx in positions)
|
||||
for key, value in (
|
||||
(
|
||||
f"{prefix}.{idx}.message.role",
|
||||
role if isinstance(role, str) else None,
|
||||
),
|
||||
(f"{prefix}.{idx}.message.role", role if isinstance(role, str) else None),
|
||||
(f"{prefix}.{idx}.message.content", content),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,14 @@ core telemetry no matter how many vocabularies are configured.
|
|||
"""
|
||||
|
||||
|
||||
MAX_MESSAGE_ATTRS_PER_SPAN: Final = DEFAULT_SPAN_ATTRIBUTE_LIMIT // 8
|
||||
"""Span-wide ceiling on per-index chat message attributes, prompt and response together.
|
||||
|
||||
An eighth is the largest share that still fits beside the tool ceiling and the core
|
||||
of every vocabulary at once. The complete conversation still rides the JSON blobs.
|
||||
"""
|
||||
|
||||
|
||||
def tool_attr_budget(vocabularies: int) -> int:
|
||||
"""Split the span-wide tool-definition ceiling across active vocabularies."""
|
||||
return MAX_TOOL_DEFINITION_ATTRS_PER_SPAN // max(vocabularies, 1)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -556,7 +556,7 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
# ids leaking into a different, later request on the same thread. Sync
|
||||
# support is deferred to a follow-up PR with its own safe-restore
|
||||
# mechanism; async calls (the proxy's only call path) are unaffected.
|
||||
if supports_correlation_logging:
|
||||
if supports_correlation_logging and litellm.request_correlation_in_logs:
|
||||
set_trace_id(self.litellm_trace_id)
|
||||
set_session_id(self.litellm_session_id)
|
||||
# set_trace_id()/set_session_id() sanitize (strip control chars, bound
|
||||
|
|
@ -2442,7 +2442,7 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
call) would leave the outer request's subsequent log lines stamped with
|
||||
the nested call's trace_id/session_id instead of its own.
|
||||
|
||||
Uses a plain set() of the captured pre-call value rather than
|
||||
Uses a plain contextvar set() of the captured pre-call value rather than
|
||||
contextvars.Token-based reset(), since this can end up called from a
|
||||
different asyncio Task/context than __init__ ran in (e.g. the request
|
||||
task's own wrapper() finally block, plus async_success_handler
|
||||
|
|
@ -2453,8 +2453,8 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
that Task's view of the contextvars, so calling it multiple times
|
||||
(once per Task involved in this attempt) is required, not just safe.
|
||||
"""
|
||||
set_trace_id(self._pre_call_trace_id)
|
||||
set_session_id(self._pre_call_session_id)
|
||||
trace_id_var.set(self._pre_call_trace_id)
|
||||
session_id_var.set(self._pre_call_session_id)
|
||||
|
||||
def _restore_correlation_context_if_unclaimed(self) -> None:
|
||||
"""Guarded variant for __del__-triggered cleanup only.
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ from types import MappingProxyType
|
|||
from typing import Any, Final, Literal, TypedDict, cast
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from typing_extensions import ReadOnly
|
||||
|
||||
import litellm
|
||||
from litellm._internal_context import current_billing_time
|
||||
from litellm._logging import verbose_logger
|
||||
|
|
@ -772,6 +774,7 @@ def calculate_cache_writing_cost(
|
|||
|
||||
class PromptTokensDetailsResult(TypedDict):
|
||||
cache_hit_tokens: int
|
||||
cache_hit_audio_tokens: ReadOnly[int]
|
||||
cache_creation_tokens: int
|
||||
cache_creation_token_details: CacheCreationTokenDetails | None
|
||||
text_tokens: int
|
||||
|
|
@ -802,12 +805,34 @@ def parse_prompt_tokens_details(usage: Usage) -> PromptTokensDetailsResult:
|
|||
)
|
||||
or None
|
||||
)
|
||||
text_tokens: Final = (
|
||||
cast(int | None, getattr(usage.prompt_tokens_details, "text_tokens", None))
|
||||
or 0 # default to prompt tokens, if this field is not set
|
||||
cached_tokens_details: Final = getattr(usage.prompt_tokens_details, "cached_tokens_details", None)
|
||||
cached_audio_tokens: Final = min(
|
||||
_get_token_detail_value(cached_tokens_details, "audio_tokens") or 0, cache_hit_tokens
|
||||
)
|
||||
cached_text_tokens: Final = min(
|
||||
_get_token_detail_value(cached_tokens_details, "text_tokens") or 0,
|
||||
cache_hit_tokens - cached_audio_tokens,
|
||||
)
|
||||
cached_image_tokens: Final = min(
|
||||
_get_token_detail_value(cached_tokens_details, "image_tokens") or 0,
|
||||
cache_hit_tokens - cached_audio_tokens - cached_text_tokens,
|
||||
)
|
||||
text_tokens: Final = max(
|
||||
(
|
||||
cast(int | None, getattr(usage.prompt_tokens_details, "text_tokens", None))
|
||||
or 0 # default to prompt tokens, if this field is not set
|
||||
)
|
||||
- cached_text_tokens,
|
||||
0,
|
||||
)
|
||||
audio_tokens: Final = max(
|
||||
(cast(int | None, getattr(usage.prompt_tokens_details, "audio_tokens", 0)) or 0) - cached_audio_tokens,
|
||||
0,
|
||||
)
|
||||
image_tokens: Final = max(
|
||||
(cast(int | None, getattr(usage.prompt_tokens_details, "image_tokens", 0)) or 0) - cached_image_tokens,
|
||||
0,
|
||||
)
|
||||
audio_tokens: Final = cast(int | None, getattr(usage.prompt_tokens_details, "audio_tokens", 0)) or 0
|
||||
image_tokens: Final = cast(int | None, getattr(usage.prompt_tokens_details, "image_tokens", 0)) or 0
|
||||
video_tokens: Final = _coerce_token_count(getattr(usage.prompt_tokens_details, "video_tokens", 0))
|
||||
character_count: Final = (
|
||||
cast(
|
||||
|
|
@ -835,6 +860,7 @@ def parse_prompt_tokens_details(usage: Usage) -> PromptTokensDetailsResult:
|
|||
|
||||
return PromptTokensDetailsResult(
|
||||
cache_hit_tokens=cache_hit_tokens,
|
||||
cache_hit_audio_tokens=cached_audio_tokens,
|
||||
cache_creation_tokens=cache_creation_tokens,
|
||||
cache_creation_token_details=cache_creation_token_details,
|
||||
text_tokens=text_tokens,
|
||||
|
|
@ -918,7 +944,16 @@ def _calculate_input_cost(
|
|||
prompt_cost = float(prompt_tokens_details["text_tokens"]) * prompt_base_cost
|
||||
|
||||
### CACHE READ COST - Now uses tiered pricing
|
||||
prompt_cost += float(prompt_tokens_details["cache_hit_tokens"]) * cache_read_cost
|
||||
cache_hit_audio_tokens: Final = prompt_tokens_details["cache_hit_audio_tokens"]
|
||||
audio_cache_read_rate: Final = _get_cost_per_unit(
|
||||
model_info,
|
||||
_get_service_tier_cost_key("cache_read_input_audio_token_cost", service_tier),
|
||||
None,
|
||||
)
|
||||
prompt_cost += float(prompt_tokens_details["cache_hit_tokens"] - cache_hit_audio_tokens) * cache_read_cost
|
||||
prompt_cost += float(cache_hit_audio_tokens) * (
|
||||
audio_cache_read_rate if audio_cache_read_rate is not None else cache_read_cost
|
||||
)
|
||||
|
||||
### AUDIO COST
|
||||
if prompt_tokens_details["audio_tokens"]:
|
||||
|
|
@ -1149,6 +1184,7 @@ def generic_cost_per_token(
|
|||
### PROCESSING COST
|
||||
prompt_tokens_details = PromptTokensDetailsResult(
|
||||
cache_hit_tokens=0,
|
||||
cache_hit_audio_tokens=0,
|
||||
cache_creation_tokens=0,
|
||||
cache_creation_token_details=None,
|
||||
text_tokens=usage.prompt_tokens,
|
||||
|
|
@ -1319,6 +1355,7 @@ class BilledTokenRates:
|
|||
input_cost_per_token: float
|
||||
output_cost_per_token: float
|
||||
cache_read_input_token_cost: float
|
||||
cache_read_input_audio_token_cost: float
|
||||
cache_creation_input_token_cost: float
|
||||
cache_creation_input_token_cost_above_1hr: float
|
||||
output_cost_per_reasoning_token: float
|
||||
|
|
@ -1330,6 +1367,7 @@ class BilledTokenRates:
|
|||
input_cost_per_token=self.input_cost_per_token * multiplier,
|
||||
output_cost_per_token=self.output_cost_per_token * multiplier,
|
||||
cache_read_input_token_cost=self.cache_read_input_token_cost * multiplier,
|
||||
cache_read_input_audio_token_cost=self.cache_read_input_audio_token_cost * multiplier,
|
||||
cache_creation_input_token_cost=self.cache_creation_input_token_cost * multiplier,
|
||||
cache_creation_input_token_cost_above_1hr=self.cache_creation_input_token_cost_above_1hr * multiplier,
|
||||
output_cost_per_reasoning_token=self.output_cost_per_reasoning_token * multiplier,
|
||||
|
|
@ -1353,15 +1391,16 @@ def _reasoning_token_count(usage: Usage) -> int:
|
|||
return parsed or _coerce_token_count(getattr(usage, "reasoning_tokens", 0))
|
||||
|
||||
|
||||
def _cache_token_counts(usage: Usage) -> tuple[int, int, CacheCreationTokenDetails | None]:
|
||||
"""(cache read tokens, cache creation tokens, cache creation details): read from prompt_tokens_details
|
||||
first, then the private top-level counters the Usage constructor mirrors cache tokens onto for
|
||||
providers/callers that bypass the details."""
|
||||
def _cache_token_counts(usage: Usage) -> tuple[int, int, int, CacheCreationTokenDetails | None]:
|
||||
"""(cache read tokens, cached audio tokens, cache creation tokens, cache creation details): read from
|
||||
prompt_tokens_details first, then the private top-level counters the Usage constructor mirrors cache
|
||||
tokens onto for providers/callers that bypass the details."""
|
||||
parsed: Final = parse_prompt_tokens_details(usage) if usage.prompt_tokens_details is not None else None
|
||||
parsed_read: Final = parsed["cache_hit_tokens"] if parsed is not None else 0
|
||||
parsed_creation: Final = parsed["cache_creation_tokens"] if parsed is not None else 0
|
||||
return (
|
||||
parsed_read or _coerce_token_count(getattr(usage, "_cache_read_input_tokens", 0)),
|
||||
parsed["cache_hit_audio_tokens"] if parsed is not None else 0,
|
||||
parsed_creation or _coerce_token_count(getattr(usage, "_cache_creation_input_tokens", 0)),
|
||||
parsed["cache_creation_token_details"] if parsed is not None else None,
|
||||
)
|
||||
|
|
@ -1372,11 +1411,13 @@ def _custom_pricing_rates(custom_cost_per_token: CostPerToken) -> BilledTokenRat
|
|||
cache rates (else the input rate) and reasoning at the output rate, as _cost_per_token_custom_pricing_helper does."""
|
||||
input_rate: Final = custom_cost_per_token["input_cost_per_token"]
|
||||
output_rate: Final = custom_cost_per_token["output_cost_per_token"]
|
||||
cache_read_rate: Final = custom_cost_per_token.get("cache_read_input_token_cost", input_rate)
|
||||
cache_creation_rate: Final = custom_cost_per_token.get("cache_creation_input_token_cost", input_rate)
|
||||
return BilledTokenRates(
|
||||
input_cost_per_token=input_rate,
|
||||
output_cost_per_token=output_rate,
|
||||
cache_read_input_token_cost=custom_cost_per_token.get("cache_read_input_token_cost", input_rate),
|
||||
cache_read_input_token_cost=cache_read_rate,
|
||||
cache_read_input_audio_token_cost=cache_read_rate,
|
||||
cache_creation_input_token_cost=cache_creation_rate,
|
||||
cache_creation_input_token_cost_above_1hr=cache_creation_rate,
|
||||
output_cost_per_reasoning_token=output_rate,
|
||||
|
|
@ -1413,6 +1454,11 @@ def _cost_map_billed_rates(
|
|||
completion_base_cost=completion_base_cost,
|
||||
current_time=billing_time,
|
||||
)
|
||||
audio_cache_read_rate: Final = _get_cost_per_unit(
|
||||
model_info,
|
||||
_get_service_tier_cost_key("cache_read_input_audio_token_cost", service_tier),
|
||||
None,
|
||||
)
|
||||
multiplier: Final = (
|
||||
_get_regional_uplift_multiplier(model_info, data_residency)
|
||||
* get_vertex_regional_endpoint_uplift(model_info, vertex_location)
|
||||
|
|
@ -1422,6 +1468,9 @@ def _cost_map_billed_rates(
|
|||
input_cost_per_token=prompt_base_cost,
|
||||
output_cost_per_token=completion_base_cost,
|
||||
cache_read_input_token_cost=cache_read_cost_rate,
|
||||
cache_read_input_audio_token_cost=(
|
||||
audio_cache_read_rate if audio_cache_read_rate is not None else cache_read_cost_rate
|
||||
),
|
||||
cache_creation_input_token_cost=cache_creation_cost_rate,
|
||||
cache_creation_input_token_cost_above_1hr=cache_creation_cost_above_1hr_rate,
|
||||
output_cost_per_reasoning_token=reasoning_rate,
|
||||
|
|
@ -1494,7 +1543,9 @@ def get_token_type_cost_breakdown(
|
|||
if rates is None:
|
||||
return TokenTypeCostBreakdown(0.0, 0.0, 0.0)
|
||||
|
||||
cache_read_tokens, cache_creation_tokens, cache_creation_token_details = _cache_token_counts(usage)
|
||||
cache_read_tokens, cached_audio_tokens, cache_creation_tokens, cache_creation_token_details = _cache_token_counts(
|
||||
usage
|
||||
)
|
||||
cache_creation_cost: Final = (
|
||||
float(cache_creation_tokens) * rates.cache_creation_input_token_cost
|
||||
if custom_cost_per_token is not None
|
||||
|
|
@ -1507,7 +1558,10 @@ def get_token_type_cost_breakdown(
|
|||
)
|
||||
return TokenTypeCostBreakdown(
|
||||
reasoning_cost=float(_reasoning_token_count(usage)) * rates.output_cost_per_reasoning_token,
|
||||
cache_read_cost=float(cache_read_tokens) * rates.cache_read_input_token_cost,
|
||||
cache_read_cost=(
|
||||
float(cache_read_tokens - cached_audio_tokens) * rates.cache_read_input_token_cost
|
||||
+ float(cached_audio_tokens) * rates.cache_read_input_audio_token_cost
|
||||
),
|
||||
cache_creation_cost=cache_creation_cost,
|
||||
rates=rates,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5513,7 +5513,8 @@
|
|||
},
|
||||
"azure/gpt-realtime-2025-08-28": {
|
||||
"cache_creation_input_audio_token_cost": 4e-06,
|
||||
"cache_read_input_token_cost": 4e-06,
|
||||
"cache_read_input_audio_token_cost": 4e-07,
|
||||
"cache_read_input_token_cost": 4e-07,
|
||||
"deprecation_date": "2027-03-02",
|
||||
"input_cost_per_audio_token": 3.2e-05,
|
||||
"input_cost_per_image_token": 5e-06,
|
||||
|
|
@ -5546,7 +5547,8 @@
|
|||
},
|
||||
"azure/gpt-realtime-1.5-2026-02-23": {
|
||||
"cache_creation_input_audio_token_cost": 4e-06,
|
||||
"cache_read_input_token_cost": 4e-06,
|
||||
"cache_read_input_audio_token_cost": 4e-07,
|
||||
"cache_read_input_token_cost": 4e-07,
|
||||
"deprecation_date": "2027-08-24",
|
||||
"input_cost_per_audio_token": 3.2e-05,
|
||||
"input_cost_per_image_token": 5e-06,
|
||||
|
|
@ -5683,6 +5685,7 @@
|
|||
},
|
||||
"azure/gpt-realtime-mini": {
|
||||
"cache_creation_input_audio_token_cost": 3e-07,
|
||||
"cache_read_input_audio_token_cost": 3e-07,
|
||||
"cache_read_input_token_cost": 6e-08,
|
||||
"input_cost_per_audio_token": 1e-05,
|
||||
"input_cost_per_image_token": 8e-07,
|
||||
|
|
@ -5715,6 +5718,7 @@
|
|||
},
|
||||
"azure/gpt-realtime-mini-2025-10-06": {
|
||||
"cache_creation_input_audio_token_cost": 3e-07,
|
||||
"cache_read_input_audio_token_cost": 3e-07,
|
||||
"cache_read_input_token_cost": 6e-08,
|
||||
"input_cost_per_audio_token": 1e-05,
|
||||
"input_cost_per_image_token": 8e-07,
|
||||
|
|
@ -7409,6 +7413,80 @@
|
|||
"supports_web_search": true,
|
||||
"supports_xhigh_reasoning_effort": true
|
||||
},
|
||||
"azure/gpt-chat-latest": {
|
||||
"cache_read_input_token_cost": 5e-07,
|
||||
"deprecation_date": "2026-12-02",
|
||||
"input_cost_per_token": 5e-06,
|
||||
"litellm_provider": "azure",
|
||||
"max_input_tokens": 272000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 3e-05,
|
||||
"reasoning_effort_levels": [
|
||||
"medium"
|
||||
],
|
||||
"source": "https://azure.microsoft.com/en-us/pricing/details/cognitive-services/openai-service/",
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions",
|
||||
"/v1/responses"
|
||||
],
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text"
|
||||
],
|
||||
"supports_function_calling": true,
|
||||
"supports_native_streaming": true,
|
||||
"supports_parallel_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_system_messages": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"supports_web_search": true
|
||||
},
|
||||
"azure/chat-latest": {
|
||||
"cache_read_input_token_cost": 5e-07,
|
||||
"deprecation_date": "2026-12-02",
|
||||
"input_cost_per_token": 5e-06,
|
||||
"litellm_provider": "azure",
|
||||
"max_input_tokens": 272000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 3e-05,
|
||||
"reasoning_effort_levels": [
|
||||
"medium"
|
||||
],
|
||||
"source": "https://azure.microsoft.com/en-us/pricing/details/cognitive-services/openai-service/",
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions",
|
||||
"/v1/responses"
|
||||
],
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text"
|
||||
],
|
||||
"supports_function_calling": true,
|
||||
"supports_native_streaming": true,
|
||||
"supports_parallel_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_system_messages": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"supports_web_search": true
|
||||
},
|
||||
"azure/us/gpt-5.6": {
|
||||
"cache_creation_input_token_cost": 6.875e-06,
|
||||
"cache_creation_input_token_cost_above_272k_tokens": 1.375e-05,
|
||||
|
|
@ -7675,6 +7753,43 @@
|
|||
"supports_web_search": true,
|
||||
"supports_xhigh_reasoning_effort": true
|
||||
},
|
||||
"azure/us/gpt-chat-latest": {
|
||||
"cache_read_input_token_cost": 5.5e-07,
|
||||
"deprecation_date": "2026-12-02",
|
||||
"input_cost_per_token": 5.5e-06,
|
||||
"litellm_provider": "azure",
|
||||
"max_input_tokens": 272000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 3.3e-05,
|
||||
"reasoning_effort_levels": [
|
||||
"medium"
|
||||
],
|
||||
"source": "https://azure.microsoft.com/en-us/pricing/details/cognitive-services/openai-service/",
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions",
|
||||
"/v1/responses"
|
||||
],
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text"
|
||||
],
|
||||
"supports_function_calling": true,
|
||||
"supports_native_streaming": true,
|
||||
"supports_parallel_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_system_messages": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"supports_web_search": true
|
||||
},
|
||||
"azure/eu/gpt-5.6": {
|
||||
"cache_creation_input_token_cost": 6.875e-06,
|
||||
"cache_creation_input_token_cost_above_272k_tokens": 1.375e-05,
|
||||
|
|
@ -23053,34 +23168,6 @@
|
|||
"output_cost_per_token": 0.0,
|
||||
"source": "https://fireworks.ai/pricing"
|
||||
},
|
||||
"friendliai/meta-llama-3.1-70b-instruct": {
|
||||
"input_cost_per_token": 6e-07,
|
||||
"litellm_provider": "friendliai",
|
||||
"max_input_tokens": 8192,
|
||||
"max_output_tokens": 8192,
|
||||
"max_tokens": 8192,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 6e-07,
|
||||
"supports_function_calling": true,
|
||||
"supports_parallel_function_calling": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_system_messages": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"friendliai/meta-llama-3.1-8b-instruct": {
|
||||
"input_cost_per_token": 1e-07,
|
||||
"litellm_provider": "friendliai",
|
||||
"max_input_tokens": 8192,
|
||||
"max_output_tokens": 8192,
|
||||
"max_tokens": 8192,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 1e-07,
|
||||
"supports_function_calling": true,
|
||||
"supports_parallel_function_calling": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_system_messages": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"friendliai/zai-org/GLM-5.3-Flash": {
|
||||
"litellm_provider": "friendliai",
|
||||
"max_input_tokens": 1048576,
|
||||
|
|
@ -32678,6 +32765,7 @@
|
|||
},
|
||||
"gpt-realtime": {
|
||||
"cache_creation_input_audio_token_cost": 4e-07,
|
||||
"cache_read_input_audio_token_cost": 4e-07,
|
||||
"cache_read_input_token_cost": 4e-07,
|
||||
"deprecation_date": "2027-01-20",
|
||||
"input_cost_per_audio_token": 3.2e-05,
|
||||
|
|
@ -32711,6 +32799,7 @@
|
|||
},
|
||||
"gpt-realtime-1.5": {
|
||||
"cache_creation_input_audio_token_cost": 4e-07,
|
||||
"cache_read_input_audio_token_cost": 4e-07,
|
||||
"cache_read_input_token_cost": 4e-07,
|
||||
"input_cost_per_audio_token": 3.2e-05,
|
||||
"input_cost_per_image_token": 5e-06,
|
||||
|
|
@ -32847,6 +32936,7 @@
|
|||
"gpt-realtime-mini": {
|
||||
"cache_creation_input_audio_token_cost": 3e-07,
|
||||
"cache_read_input_audio_token_cost": 3e-07,
|
||||
"cache_read_input_token_cost": 6e-08,
|
||||
"deprecation_date": "2027-01-20",
|
||||
"input_cost_per_audio_token": 1e-05,
|
||||
"input_cost_per_token": 6e-07,
|
||||
|
|
@ -32878,6 +32968,7 @@
|
|||
},
|
||||
"gpt-realtime-2025-08-28": {
|
||||
"cache_creation_input_audio_token_cost": 4e-07,
|
||||
"cache_read_input_audio_token_cost": 4e-07,
|
||||
"cache_read_input_token_cost": 4e-07,
|
||||
"deprecation_date": "2027-01-20",
|
||||
"input_cost_per_audio_token": 3.2e-05,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -4852,6 +4856,14 @@ class JWTIssuerConfig(BaseModel):
|
|||
default=None,
|
||||
description="Issuer-specific claim path to normalize into LiteLLM's end-user id.",
|
||||
)
|
||||
virtual_key_claim_field: str | None = Field(
|
||||
default=None,
|
||||
description="Issuer-specific claim path used for the virtual key mapping lookup. Falls back to the global field.",
|
||||
)
|
||||
unregistered_jwt_client_behavior: UnregisteredJWTClientBehavior | None = Field(
|
||||
default=None,
|
||||
description="Issuer-specific policy when the virtual key claim has no mapping. Falls back to the global policy.",
|
||||
)
|
||||
|
||||
model_config = {
|
||||
"extra": "forbid",
|
||||
|
|
@ -5078,6 +5090,28 @@ class LiteLLM_JWTAuth(LiteLLMPydanticObjectBase):
|
|||
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def get_issuer_config(self, issuer: str | None) -> JWTIssuerConfig | None:
|
||||
if issuer is None or self.issuers is None:
|
||||
return None
|
||||
return next((config for config in self.issuers if config.issuer == issuer), None)
|
||||
|
||||
def is_virtual_key_mapping_configured(self) -> bool:
|
||||
if self.virtual_key_claim_field is not None:
|
||||
return True
|
||||
return any(config.virtual_key_claim_field is not None for config in self.issuers or ())
|
||||
|
||||
def get_virtual_key_claim_field(self, issuer: str | None) -> str | None:
|
||||
issuer_config: Final = self.get_issuer_config(issuer)
|
||||
if issuer_config is not None and issuer_config.virtual_key_claim_field is not None:
|
||||
return issuer_config.virtual_key_claim_field
|
||||
return self.virtual_key_claim_field
|
||||
|
||||
def get_unregistered_jwt_client_behavior(self, issuer: str | None) -> UnregisteredJWTClientBehavior:
|
||||
issuer_config: Final = self.get_issuer_config(issuer)
|
||||
if issuer_config is not None and issuer_config.unregistered_jwt_client_behavior is not None:
|
||||
return issuer_config.unregistered_jwt_client_behavior
|
||||
return self.unregistered_jwt_client_behavior
|
||||
|
||||
|
||||
class PrismaCompatibleUpdateDBModel(TypedDict, total=False):
|
||||
model_name: str
|
||||
|
|
|
|||
|
|
@ -996,9 +996,12 @@ async def _resolve_jwt_to_virtual_key(
|
|||
- Raises HTTPException: REJECT policy hit, missing claim under
|
||||
REJECT/AUTO_REGISTER, or other policy violations.
|
||||
"""
|
||||
virtual_key_claim_field: Final = jwt_handler.litellm_jwtauth.virtual_key_claim_field
|
||||
raw_issuer: Final = jwt_claims.get(JWTHandler.LITELLM_JWT_ISSUER_CLAIM)
|
||||
normalized_issuer: Final = raw_issuer if isinstance(raw_issuer, str) else None
|
||||
virtual_key_claim_field: Final = jwt_handler.litellm_jwtauth.get_virtual_key_claim_field(normalized_issuer)
|
||||
if virtual_key_claim_field is None:
|
||||
return None
|
||||
behavior: Final = jwt_handler.litellm_jwtauth.get_unregistered_jwt_client_behavior(normalized_issuer)
|
||||
|
||||
claim_value: Final = get_nested_value(
|
||||
data=jwt_claims,
|
||||
|
|
@ -1015,7 +1018,6 @@ async def _resolve_jwt_to_virtual_key(
|
|||
# simply by presenting a JWT that omits the configured field. For
|
||||
# AUTO_REGISTER there is no stable identity to map without a claim
|
||||
# value, so we deny rather than create a sentinel-keyed record.
|
||||
behavior = jwt_handler.litellm_jwtauth.unregistered_jwt_client_behavior
|
||||
if behavior in (
|
||||
UnregisteredJWTClientBehavior.REJECT,
|
||||
UnregisteredJWTClientBehavior.AUTO_REGISTER,
|
||||
|
|
@ -1030,7 +1032,13 @@ async def _resolve_jwt_to_virtual_key(
|
|||
return None
|
||||
|
||||
cache_key: Final = jwt_key_mapping_cache_key(virtual_key_claim_field, str(claim_value))
|
||||
cached_mapping: Final = await user_api_key_cache.async_get_cache(cache_key)
|
||||
raw_cached_mapping: Final = await user_api_key_cache.async_get_cache(cache_key)
|
||||
sentinel_written_by_this_policy: Final = behavior == UnregisteredJWTClientBehavior.AUTO_REGISTER
|
||||
cached_mapping: Final = (
|
||||
None
|
||||
if raw_cached_mapping == _JWT_PROXY_ADMIN_SENTINEL and not sentinel_written_by_this_policy
|
||||
else raw_cached_mapping
|
||||
)
|
||||
|
||||
if cached_mapping == _JWT_PROXY_ADMIN_SENTINEL:
|
||||
# Previously resolved to a proxy admin via auth_builder; skip the
|
||||
|
|
@ -1039,7 +1047,6 @@ async def _resolve_jwt_to_virtual_key(
|
|||
return None
|
||||
|
||||
if cached_mapping == "__NO_MAPPING__":
|
||||
behavior = jwt_handler.litellm_jwtauth.unregistered_jwt_client_behavior
|
||||
if behavior == UnregisteredJWTClientBehavior.REJECT:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
|
|
@ -1102,8 +1109,6 @@ async def _resolve_jwt_to_virtual_key(
|
|||
)
|
||||
|
||||
# No mapping found (DB miss or no DB) — apply no-match policy.
|
||||
behavior = jwt_handler.litellm_jwtauth.unregistered_jwt_client_behavior
|
||||
|
||||
if behavior == UnregisteredJWTClientBehavior.REJECT:
|
||||
# Cache the miss before raising so repeated rejections are served from
|
||||
# cache and don't re-query the DB on every request.
|
||||
|
|
@ -1483,7 +1488,7 @@ async def _user_api_key_auth_builder(
|
|||
# unnecessary DB queries in auth_builder
|
||||
do_standard_jwt_auth = True
|
||||
pending_auto_register: _PendingAutoRegister | None = None
|
||||
if jwt_handler.litellm_jwtauth.virtual_key_claim_field is not None:
|
||||
if jwt_handler.litellm_jwtauth.is_virtual_key_mapping_configured():
|
||||
# Decode JWT to get claims without running full auth_builder
|
||||
jwt_claims: dict | None
|
||||
if jwt_handler.litellm_jwtauth.oidc_userinfo_enabled and not is_jwt:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
)
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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=(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -366,6 +366,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,
|
||||
|
|
@ -3452,8 +3453,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:
|
||||
|
|
@ -7074,6 +7077,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")
|
||||
|
||||
|
|
@ -15042,6 +15048,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"],
|
||||
|
|
@ -15089,7 +15102,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
|
||||
|
|
@ -15137,7 +15150,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(
|
||||
|
|
@ -15188,7 +15201,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
|
||||
|
|
@ -15256,7 +15269,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(
|
||||
|
|
@ -15823,10 +15836,7 @@ async def fallback_login(request: Request):
|
|||
else:
|
||||
redirect_url += "/sso/callback"
|
||||
|
||||
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,
|
||||
|
|
@ -17059,6 +17069,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",
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging
|
|||
from litellm.responses.litellm_completion_transformation.session_handler import (
|
||||
ResponsesSessionHandler,
|
||||
)
|
||||
from litellm.types.llms.base import CachedTokensDetails
|
||||
from litellm.types.llms.openai import (
|
||||
AllMessageValues,
|
||||
ChatCompletionAssistantMessage,
|
||||
|
|
@ -2816,27 +2817,24 @@ class LiteLLMCompletionResponsesConfig:
|
|||
# Translate prompt_tokens_details to input_tokens_details
|
||||
if hasattr(usage, "prompt_tokens_details") and usage.prompt_tokens_details is not None:
|
||||
prompt_details: Final = usage.prompt_tokens_details
|
||||
input_details_dict: Final[dict[str, int]] = {}
|
||||
|
||||
if hasattr(prompt_details, "cached_tokens") and prompt_details.cached_tokens is not None:
|
||||
input_details_dict["cached_tokens"] = prompt_details.cached_tokens
|
||||
else:
|
||||
input_details_dict["cached_tokens"] = 0
|
||||
|
||||
if hasattr(prompt_details, "text_tokens") and prompt_details.text_tokens is not None:
|
||||
input_details_dict["text_tokens"] = prompt_details.text_tokens
|
||||
|
||||
if hasattr(prompt_details, "audio_tokens") and prompt_details.audio_tokens is not None:
|
||||
input_details_dict["audio_tokens"] = prompt_details.audio_tokens
|
||||
|
||||
cache_write_tokens = getattr(prompt_details, "cache_write_tokens", None) or getattr(
|
||||
cached_tokens_details: Final = getattr(prompt_details, "cached_tokens_details", None)
|
||||
cache_write_tokens: Final = getattr(prompt_details, "cache_write_tokens", None) or getattr(
|
||||
prompt_details, "cache_creation_tokens", None
|
||||
)
|
||||
if cache_write_tokens is not None:
|
||||
input_details_dict["cache_write_tokens"] = cache_write_tokens
|
||||
|
||||
if input_details_dict:
|
||||
response_usage.input_tokens_details = InputTokensDetails(**input_details_dict)
|
||||
cache_write_extra: Final[Mapping[str, int]] = (
|
||||
MappingProxyType({"cache_write_tokens": cache_write_tokens})
|
||||
if cache_write_tokens is not None
|
||||
else MappingProxyType({})
|
||||
)
|
||||
response_usage.input_tokens_details = InputTokensDetails(
|
||||
cached_tokens=prompt_details.cached_tokens if prompt_details.cached_tokens is not None else 0,
|
||||
text_tokens=prompt_details.text_tokens,
|
||||
audio_tokens=prompt_details.audio_tokens,
|
||||
cached_tokens_details=(
|
||||
cached_tokens_details if isinstance(cached_tokens_details, CachedTokensDetails) else None
|
||||
),
|
||||
**cache_write_extra,
|
||||
)
|
||||
|
||||
# Translate completion_tokens_details to output_tokens_details
|
||||
if hasattr(usage, "completion_tokens_details") and usage.completion_tokens_details is not None:
|
||||
|
|
|
|||
|
|
@ -1179,6 +1179,9 @@ class ResponseAPILoggingUtils:
|
|||
audio_tokens=getattr(response_api_usage.input_tokens_details, "audio_tokens", None),
|
||||
text_tokens=getattr(response_api_usage.input_tokens_details, "text_tokens", None),
|
||||
image_tokens=getattr(response_api_usage.input_tokens_details, "image_tokens", None),
|
||||
cached_tokens_details=getattr(
|
||||
response_api_usage.input_tokens_details, "cached_tokens_details", None
|
||||
),
|
||||
cache_write_tokens=getattr(response_api_usage.input_tokens_details, "cache_write_tokens", None),
|
||||
)
|
||||
completion_tokens_details: CompletionTokensDetailsWrapper | None = None
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -75,3 +75,9 @@ class HiddenParams(OpenAIObject):
|
|||
data: Final = super().model_dump(**kwargs)
|
||||
data["_response_ms"] = self._response_ms
|
||||
return data
|
||||
|
||||
|
||||
class CachedTokensDetails(BaseModel):
|
||||
text_tokens: int | None = None
|
||||
audio_tokens: int | None = None
|
||||
image_tokens: int | None = None
|
||||
|
|
|
|||
|
|
@ -91,6 +91,8 @@ from litellm.types.responses.main import (
|
|||
OutputImageGenerationCall,
|
||||
)
|
||||
|
||||
from .base import CachedTokensDetails
|
||||
|
||||
FileContent = IO[bytes] | bytes | PathLike
|
||||
|
||||
FileTypes = (
|
||||
|
|
@ -1288,6 +1290,7 @@ class OutputTokensDetails(BaseLiteLLMOpenAIResponseObject):
|
|||
class InputTokensDetails(BaseLiteLLMOpenAIResponseObject):
|
||||
audio_tokens: int | None = None
|
||||
cached_tokens: int = 0
|
||||
cached_tokens_details: CachedTokensDetails | None = None
|
||||
text_tokens: int | None = None
|
||||
|
||||
model_config = {"extra": "allow"}
|
||||
|
|
@ -2254,10 +2257,17 @@ class OpenAIRealtimeInputAudioTranscriptionCompleted(TypedDict):
|
|||
usage: NotRequired[ReadOnly[Mapping[str, object]]]
|
||||
|
||||
|
||||
class OpenAIRealtimeCachedTokensDetails(TypedDict, total=False):
|
||||
text_tokens: ReadOnly[int]
|
||||
audio_tokens: ReadOnly[int]
|
||||
image_tokens: ReadOnly[int]
|
||||
|
||||
|
||||
class OpenAIRealtimeUsageTokenDetails(TypedDict):
|
||||
audio_tokens: ReadOnly[int]
|
||||
text_tokens: ReadOnly[int]
|
||||
cached_tokens: NotRequired[ReadOnly[int]]
|
||||
cached_tokens_details: NotRequired[ReadOnly[OpenAIRealtimeCachedTokensDetails]]
|
||||
|
||||
|
||||
class OpenAIRealtimeResponseUsage(TypedDict):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ from litellm._logging import verbose_logger
|
|||
from litellm._uuid import uuid
|
||||
from litellm.types.llms.base import (
|
||||
BaseLiteLLMOpenAIResponseObject,
|
||||
CachedTokensDetails,
|
||||
LiteLLMPydanticObjectBase,
|
||||
)
|
||||
from litellm.types.mcp import MCPServerCostInfo
|
||||
|
|
@ -252,6 +253,7 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False):
|
|||
cache_creation_input_token_cost_priority: float | None # OpenAI priority service tier pricing
|
||||
cache_creation_input_token_cost_ultrafast: ReadOnly[float | None] # OpenAI ultrafast service tier pricing
|
||||
cache_read_input_token_cost: float | None
|
||||
cache_read_input_audio_token_cost: ReadOnly[float | None]
|
||||
cache_read_input_token_cost_flex: float | None # OpenAI flex service tier pricing
|
||||
cache_read_input_token_cost_priority: float | None # OpenAI priority service tier pricing
|
||||
cache_read_input_token_cost_ultrafast: ReadOnly[float | None] # OpenAI ultrafast service tier pricing
|
||||
|
|
@ -1710,6 +1712,9 @@ class PromptTokensDetailsWrapper(
|
|||
cache_creation_token_details: CacheCreationTokenDetails | None = None
|
||||
"""Details of cache creation tokens sent to the model. Used for tracking 5m/1h cache creation tokens for Anthropic prompt caching."""
|
||||
|
||||
cached_tokens_details: CachedTokensDetails | None = None
|
||||
"""Details of cached (cache-hit) tokens sent to the model. OpenAI realtime naming; carries the per-modality cache-read split."""
|
||||
|
||||
def __setattr__(self, name: str, value: object) -> None:
|
||||
super().__setattr__(name, value)
|
||||
if name == "cache_write_tokens":
|
||||
|
|
@ -1756,6 +1761,8 @@ class PromptTokensDetailsWrapper(
|
|||
del self.cache_creation_tokens
|
||||
if self.cache_creation_token_details is None:
|
||||
del self.cache_creation_token_details
|
||||
if self.cached_tokens_details is None:
|
||||
del self.cached_tokens_details
|
||||
|
||||
|
||||
class ServerToolUse(BaseModel):
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
@ -5882,6 +5867,7 @@ def _get_model_info_helper(
|
|||
"cache_creation_input_token_cost_ultrafast", None
|
||||
),
|
||||
cache_read_input_token_cost=_model_info.get("cache_read_input_token_cost", None),
|
||||
cache_read_input_audio_token_cost=_model_info.get("cache_read_input_audio_token_cost", None),
|
||||
prompt_cache_min_tokens=_model_info.get("prompt_cache_min_tokens", None),
|
||||
cache_read_input_token_cost_above_200k_tokens=_model_info.get(
|
||||
"cache_read_input_token_cost_above_200k_tokens", None
|
||||
|
|
|
|||
|
|
@ -5513,7 +5513,8 @@
|
|||
},
|
||||
"azure/gpt-realtime-2025-08-28": {
|
||||
"cache_creation_input_audio_token_cost": 4e-06,
|
||||
"cache_read_input_token_cost": 4e-06,
|
||||
"cache_read_input_audio_token_cost": 4e-07,
|
||||
"cache_read_input_token_cost": 4e-07,
|
||||
"deprecation_date": "2027-03-02",
|
||||
"input_cost_per_audio_token": 3.2e-05,
|
||||
"input_cost_per_image_token": 5e-06,
|
||||
|
|
@ -5546,7 +5547,8 @@
|
|||
},
|
||||
"azure/gpt-realtime-1.5-2026-02-23": {
|
||||
"cache_creation_input_audio_token_cost": 4e-06,
|
||||
"cache_read_input_token_cost": 4e-06,
|
||||
"cache_read_input_audio_token_cost": 4e-07,
|
||||
"cache_read_input_token_cost": 4e-07,
|
||||
"deprecation_date": "2027-08-24",
|
||||
"input_cost_per_audio_token": 3.2e-05,
|
||||
"input_cost_per_image_token": 5e-06,
|
||||
|
|
@ -5683,6 +5685,7 @@
|
|||
},
|
||||
"azure/gpt-realtime-mini": {
|
||||
"cache_creation_input_audio_token_cost": 3e-07,
|
||||
"cache_read_input_audio_token_cost": 3e-07,
|
||||
"cache_read_input_token_cost": 6e-08,
|
||||
"input_cost_per_audio_token": 1e-05,
|
||||
"input_cost_per_image_token": 8e-07,
|
||||
|
|
@ -5715,6 +5718,7 @@
|
|||
},
|
||||
"azure/gpt-realtime-mini-2025-10-06": {
|
||||
"cache_creation_input_audio_token_cost": 3e-07,
|
||||
"cache_read_input_audio_token_cost": 3e-07,
|
||||
"cache_read_input_token_cost": 6e-08,
|
||||
"input_cost_per_audio_token": 1e-05,
|
||||
"input_cost_per_image_token": 8e-07,
|
||||
|
|
@ -7409,6 +7413,80 @@
|
|||
"supports_web_search": true,
|
||||
"supports_xhigh_reasoning_effort": true
|
||||
},
|
||||
"azure/gpt-chat-latest": {
|
||||
"cache_read_input_token_cost": 5e-07,
|
||||
"deprecation_date": "2026-12-02",
|
||||
"input_cost_per_token": 5e-06,
|
||||
"litellm_provider": "azure",
|
||||
"max_input_tokens": 272000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 3e-05,
|
||||
"reasoning_effort_levels": [
|
||||
"medium"
|
||||
],
|
||||
"source": "https://azure.microsoft.com/en-us/pricing/details/cognitive-services/openai-service/",
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions",
|
||||
"/v1/responses"
|
||||
],
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text"
|
||||
],
|
||||
"supports_function_calling": true,
|
||||
"supports_native_streaming": true,
|
||||
"supports_parallel_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_system_messages": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"supports_web_search": true
|
||||
},
|
||||
"azure/chat-latest": {
|
||||
"cache_read_input_token_cost": 5e-07,
|
||||
"deprecation_date": "2026-12-02",
|
||||
"input_cost_per_token": 5e-06,
|
||||
"litellm_provider": "azure",
|
||||
"max_input_tokens": 272000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 3e-05,
|
||||
"reasoning_effort_levels": [
|
||||
"medium"
|
||||
],
|
||||
"source": "https://azure.microsoft.com/en-us/pricing/details/cognitive-services/openai-service/",
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions",
|
||||
"/v1/responses"
|
||||
],
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text"
|
||||
],
|
||||
"supports_function_calling": true,
|
||||
"supports_native_streaming": true,
|
||||
"supports_parallel_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_system_messages": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"supports_web_search": true
|
||||
},
|
||||
"azure/us/gpt-5.6": {
|
||||
"cache_creation_input_token_cost": 6.875e-06,
|
||||
"cache_creation_input_token_cost_above_272k_tokens": 1.375e-05,
|
||||
|
|
@ -7675,6 +7753,43 @@
|
|||
"supports_web_search": true,
|
||||
"supports_xhigh_reasoning_effort": true
|
||||
},
|
||||
"azure/us/gpt-chat-latest": {
|
||||
"cache_read_input_token_cost": 5.5e-07,
|
||||
"deprecation_date": "2026-12-02",
|
||||
"input_cost_per_token": 5.5e-06,
|
||||
"litellm_provider": "azure",
|
||||
"max_input_tokens": 272000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 3.3e-05,
|
||||
"reasoning_effort_levels": [
|
||||
"medium"
|
||||
],
|
||||
"source": "https://azure.microsoft.com/en-us/pricing/details/cognitive-services/openai-service/",
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions",
|
||||
"/v1/responses"
|
||||
],
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"text"
|
||||
],
|
||||
"supports_function_calling": true,
|
||||
"supports_native_streaming": true,
|
||||
"supports_parallel_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_system_messages": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"supports_web_search": true
|
||||
},
|
||||
"azure/eu/gpt-5.6": {
|
||||
"cache_creation_input_token_cost": 6.875e-06,
|
||||
"cache_creation_input_token_cost_above_272k_tokens": 1.375e-05,
|
||||
|
|
@ -23053,34 +23168,6 @@
|
|||
"output_cost_per_token": 0.0,
|
||||
"source": "https://fireworks.ai/pricing"
|
||||
},
|
||||
"friendliai/meta-llama-3.1-70b-instruct": {
|
||||
"input_cost_per_token": 6e-07,
|
||||
"litellm_provider": "friendliai",
|
||||
"max_input_tokens": 8192,
|
||||
"max_output_tokens": 8192,
|
||||
"max_tokens": 8192,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 6e-07,
|
||||
"supports_function_calling": true,
|
||||
"supports_parallel_function_calling": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_system_messages": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"friendliai/meta-llama-3.1-8b-instruct": {
|
||||
"input_cost_per_token": 1e-07,
|
||||
"litellm_provider": "friendliai",
|
||||
"max_input_tokens": 8192,
|
||||
"max_output_tokens": 8192,
|
||||
"max_tokens": 8192,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 1e-07,
|
||||
"supports_function_calling": true,
|
||||
"supports_parallel_function_calling": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_system_messages": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"friendliai/zai-org/GLM-5.3-Flash": {
|
||||
"litellm_provider": "friendliai",
|
||||
"max_input_tokens": 1048576,
|
||||
|
|
@ -32678,6 +32765,7 @@
|
|||
},
|
||||
"gpt-realtime": {
|
||||
"cache_creation_input_audio_token_cost": 4e-07,
|
||||
"cache_read_input_audio_token_cost": 4e-07,
|
||||
"cache_read_input_token_cost": 4e-07,
|
||||
"deprecation_date": "2027-01-20",
|
||||
"input_cost_per_audio_token": 3.2e-05,
|
||||
|
|
@ -32711,6 +32799,7 @@
|
|||
},
|
||||
"gpt-realtime-1.5": {
|
||||
"cache_creation_input_audio_token_cost": 4e-07,
|
||||
"cache_read_input_audio_token_cost": 4e-07,
|
||||
"cache_read_input_token_cost": 4e-07,
|
||||
"input_cost_per_audio_token": 3.2e-05,
|
||||
"input_cost_per_image_token": 5e-06,
|
||||
|
|
@ -32847,6 +32936,7 @@
|
|||
"gpt-realtime-mini": {
|
||||
"cache_creation_input_audio_token_cost": 3e-07,
|
||||
"cache_read_input_audio_token_cost": 3e-07,
|
||||
"cache_read_input_token_cost": 6e-08,
|
||||
"deprecation_date": "2027-01-20",
|
||||
"input_cost_per_audio_token": 1e-05,
|
||||
"input_cost_per_token": 6e-07,
|
||||
|
|
@ -32878,6 +32968,7 @@
|
|||
},
|
||||
"gpt-realtime-2025-08-28": {
|
||||
"cache_creation_input_audio_token_cost": 4e-07,
|
||||
"cache_read_input_audio_token_cost": 4e-07,
|
||||
"cache_read_input_token_cost": 4e-07,
|
||||
"deprecation_date": "2027-01-20",
|
||||
"input_cost_per_audio_token": 3.2e-05,
|
||||
|
|
|
|||
|
|
@ -5,5 +5,5 @@ reason = "diskcache has no fixed release published; remove this entry once one e
|
|||
|
||||
[[IgnoredVulns]]
|
||||
id = "GHSA-h7x2-h6g9-p789"
|
||||
ignoreUntil = 2026-09-14
|
||||
reason = "mlflow has no fixed release published; remove this entry once one exists"
|
||||
ignoreUntil = 2026-10-14
|
||||
reason = "mlflow has no fixed release published (3.16.0, 2026-09-04, and master still store gateway secret api_base unvalidated); remove this entry once one exists"
|
||||
|
|
|
|||
|
|
@ -22,6 +22,11 @@ from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
|||
pytestmark = pytest.mark.asyncio(loop_scope="session")
|
||||
|
||||
|
||||
def _frozen_cache() -> UserApiKeyCache:
|
||||
"""The org entries carry a 5s TTL; a frozen clock keeps a slow first call from expiring them mid-test."""
|
||||
return UserApiKeyCache(in_memory_cache=InMemoryCache(clock=lambda: 1_000_000.0), redis_cache=None)
|
||||
|
||||
|
||||
def _dead_db() -> MagicMock:
|
||||
prisma = MagicMock(name="prisma_client")
|
||||
prisma.db.query_first = AsyncMock(return_value=None)
|
||||
|
|
@ -58,7 +63,7 @@ async def test_join_binds_the_membership_to_the_requested_team(prisma):
|
|||
data={"user_id": user_id, "team_id": team_b, "litellm_budget_table": {"connect": {"budget_id": f"b-{run}"}}}
|
||||
)
|
||||
|
||||
cache = UserApiKeyCache(in_memory_cache=InMemoryCache(), redis_cache=None)
|
||||
cache = _frozen_cache()
|
||||
refs = AuthObjectRefs(user_id=user_id, team_id=team_a, membership_user_id=user_id, organization_id=org_id)
|
||||
await prefetch_auth_objects(refs=refs, user_api_key_cache=cache, prisma_client=prisma)
|
||||
|
||||
|
|
@ -100,7 +105,7 @@ async def test_join_reads_team_model_aliases_from_the_mapped_column(prisma):
|
|||
where={"team_id": team_id}, include={"litellm_model_table": True}
|
||||
)
|
||||
|
||||
cache = UserApiKeyCache(in_memory_cache=InMemoryCache(), redis_cache=None)
|
||||
cache = _frozen_cache()
|
||||
refs = AuthObjectRefs(user_id=None, team_id=team_id, membership_user_id=None, organization_id=None)
|
||||
await prefetch_auth_objects(refs=refs, user_api_key_cache=cache, prisma_client=prisma)
|
||||
|
||||
|
|
@ -144,7 +149,7 @@ async def test_join_reads_null_nested_lists_the_way_prisma_does(prisma):
|
|||
where={"user_id_team_id": {"user_id": user_id, "team_id": team_id}}, include={"litellm_budget_table": True}
|
||||
)
|
||||
|
||||
cache = UserApiKeyCache(in_memory_cache=InMemoryCache(), redis_cache=None)
|
||||
cache = _frozen_cache()
|
||||
refs = AuthObjectRefs(user_id=user_id, team_id=team_id, membership_user_id=user_id, organization_id=None)
|
||||
await prefetch_auth_objects(refs=refs, user_api_key_cache=cache, prisma_client=prisma)
|
||||
|
||||
|
|
|
|||
|
|
@ -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=[]),
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
)
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
"""Golden tests for the OTel v2 engine: span shape, kinds, semconv attributes,
|
||||
legacy dual-emit, hierarchy, error status, and idempotency. Needs the OTel SDK."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("opentelemetry")
|
||||
|
|
@ -18,6 +20,7 @@ from litellm.integrations.otel.plumbing import providers # noqa: E402
|
|||
from litellm.integrations.otel.emitter import SpanEmitter # noqa: E402
|
||||
from litellm.integrations.otel.emitter import stamp_error # noqa: E402
|
||||
from litellm.integrations.otel.mappers.utils import ( # noqa: E402
|
||||
MAX_MESSAGE_ATTRS_PER_SPAN,
|
||||
MAX_TOOL_DEFINITION_ATTRS_PER_SPAN,
|
||||
)
|
||||
from litellm.integrations.otel.model.payloads import ( # noqa: E402
|
||||
|
|
@ -440,3 +443,161 @@ def test_vendor_tool_definitions_are_truncated_not_dropped():
|
|||
assert a["llm.tools.0.tool.name"] == "tool_0"
|
||||
assert a["llm.tools.0.tool.json_schema"]
|
||||
assert "llm.tools.126.tool.name" not in a
|
||||
|
||||
|
||||
def _conversation_payload(turns, choices=1, **overrides):
|
||||
"""A ``turns``-message chat with ``choices`` response choices, content-bearing."""
|
||||
return _payload(
|
||||
messages=[{"role": ("user", "assistant")[i % 2], "content": f"turn {i}"} for i in range(turns)],
|
||||
response={
|
||||
"id": "resp_1",
|
||||
"model": "gpt-4o-2024",
|
||||
"choices": [
|
||||
{"finish_reason": "stop", "message": {"role": "assistant", "content": f"reply {i}"}}
|
||||
for i in range(choices)
|
||||
],
|
||||
},
|
||||
**overrides,
|
||||
)
|
||||
|
||||
|
||||
def _conversation_span(mapper_names, payload, legacy_compat=False):
|
||||
"""The exported LLM-call span for ``payload`` with content capture on."""
|
||||
cfg = OpenTelemetryV2Config(
|
||||
exporter="in_memory",
|
||||
legacy_compat=legacy_compat,
|
||||
mapper_names=list(mapper_names),
|
||||
capture_message_content="span_only",
|
||||
)
|
||||
provider, exporter = providers.in_memory_provider(cfg)
|
||||
engine = SpanEmitter(providers.get_tracer(provider, "litellm-test"), cfg)
|
||||
engine.emit(
|
||||
SpanRole.LLM_CALL,
|
||||
LLMCallSpanData.from_standard_logging_payload(payload, capture_content=True),
|
||||
)
|
||||
(span,) = exporter.get_finished_spans()
|
||||
return span
|
||||
|
||||
|
||||
def _indexed_message_count(attributes, prefix):
|
||||
return len({key.split(".")[2] for key in attributes if key.startswith(f"{prefix}.")})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("turns", [60, 200])
|
||||
def test_long_conversation_does_not_evict_core_attributes(turns):
|
||||
"""Per-message OpenInference attributes must never crowd core telemetry off the span."""
|
||||
span = _conversation_span(["genai", "openinference"], _conversation_payload(turns))
|
||||
a = span.attributes
|
||||
|
||||
assert span.dropped_attributes == 0
|
||||
assert a[GenAI.REQUEST_MODEL] == "gpt-4o"
|
||||
assert a[GenAI.PROVIDER_NAME] == "openai"
|
||||
assert a[GenAI.USAGE_INPUT_TOKENS] == 10
|
||||
assert a[GenAI.USAGE_OUTPUT_TOKENS] == 5
|
||||
assert a[GenAI.RESPONSE_FINISH_REASONS] == ("stop",)
|
||||
assert a[f"{LiteLLM.COST_PREFIX}total"] == 0.002
|
||||
|
||||
assert a["llm.input_messages.0.message.content"] == "turn 0"
|
||||
assert a["llm.output_messages.0.message.content"] == "reply 0"
|
||||
assert a[f"llm.input_messages.{turns - 1}.message.content"] == f"turn {turns - 1}"
|
||||
assert f"llm.input_messages.{turns // 2}.message.role" not in a
|
||||
assert len(json.loads(a["input.value"])) == turns
|
||||
assert len(json.loads(a["output.value"])) == 1
|
||||
assert len(json.loads(a[GenAI.INPUT_MESSAGES])) == turns
|
||||
|
||||
|
||||
def test_short_conversation_keeps_every_message_indexed():
|
||||
"""Below the cap nothing is truncated in either direction."""
|
||||
a = _conversation_span(["genai", "openinference"], _conversation_payload(4, choices=2)).attributes
|
||||
for idx in range(4):
|
||||
assert a[f"llm.input_messages.{idx}.message.content"] == f"turn {idx}"
|
||||
for idx in range(2):
|
||||
assert a[f"llm.output_messages.{idx}.message.content"] == f"reply {idx}"
|
||||
|
||||
|
||||
def test_indexed_prompt_keeps_opener_and_latest_turns_under_a_value_length_limit(monkeypatch):
|
||||
"""The system prompt and the live turn keep their own keys once the SDK clips ``input.value``."""
|
||||
monkeypatch.setenv("OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT", "256")
|
||||
chat = _conversation_payload(60)
|
||||
payload = {
|
||||
**chat,
|
||||
"messages": [
|
||||
{"role": "system", "content": "be terse"},
|
||||
*chat["messages"][1:-1],
|
||||
{"role": "user", "content": "LATEST-TURN"},
|
||||
],
|
||||
}
|
||||
a = _conversation_span(["genai", "openinference"], payload).attributes
|
||||
|
||||
assert len(a["input.value"]) == 256
|
||||
assert a["llm.input_messages.0.message.role"] == "system"
|
||||
assert a["llm.input_messages.0.message.content"] == "be terse"
|
||||
assert a["llm.input_messages.59.message.role"] == "user"
|
||||
assert a["llm.input_messages.59.message.content"] == "LATEST-TURN"
|
||||
assert a["llm.output_messages.0.message.content"] == "reply 0"
|
||||
assert [int(key.split(".")[2]) for key in a if key.endswith("message.content") and key.startswith("llm.input_")] == [
|
||||
0,
|
||||
*range(54, 60),
|
||||
]
|
||||
|
||||
|
||||
def test_message_cap_is_shared_across_input_and_output():
|
||||
"""One span-wide allowance covers both directions, and the response always keeps a share."""
|
||||
long_prompt = _conversation_span(["genai", "openinference"], _conversation_payload(60, choices=1)).attributes
|
||||
many_choices = _conversation_span(["genai", "openinference"], _conversation_payload(60, choices=20)).attributes
|
||||
|
||||
single_reply_indexed = _indexed_message_count(long_prompt, "llm.output_messages")
|
||||
assert single_reply_indexed == 1
|
||||
assert _indexed_message_count(long_prompt, "llm.input_messages") + single_reply_indexed == (
|
||||
MAX_MESSAGE_ATTRS_PER_SPAN // 2
|
||||
)
|
||||
|
||||
assert _indexed_message_count(many_choices, "llm.input_messages") > 0
|
||||
assert _indexed_message_count(many_choices, "llm.output_messages") > single_reply_indexed
|
||||
assert _indexed_message_count(many_choices, "llm.input_messages") + _indexed_message_count(
|
||||
many_choices, "llm.output_messages"
|
||||
) == (MAX_MESSAGE_ATTRS_PER_SPAN // 2)
|
||||
|
||||
|
||||
def test_fully_populated_span_with_every_vocabulary_stays_within_the_attribute_limit():
|
||||
"""Every capped family maxed at once still leaves the whole core intact."""
|
||||
payload = _conversation_payload(
|
||||
200,
|
||||
choices=20,
|
||||
stream=True,
|
||||
model_parameters={
|
||||
**_tools_payload(127)["model_parameters"],
|
||||
"top_p": 0.9,
|
||||
"frequency_penalty": 0.1,
|
||||
"presence_penalty": 0.1,
|
||||
"seed": 7,
|
||||
"stop": ["\n"],
|
||||
},
|
||||
cost_breakdown={
|
||||
key: 0.001
|
||||
for key in (
|
||||
"input_cost",
|
||||
"output_cost",
|
||||
"cache_read_cost",
|
||||
"cache_creation_cost",
|
||||
"tool_usage_cost",
|
||||
"original_cost",
|
||||
"discount_amount",
|
||||
"discount_percent",
|
||||
"margin_fixed_amount",
|
||||
"margin_percent",
|
||||
"margin_total_amount",
|
||||
"total_cost",
|
||||
)
|
||||
},
|
||||
)
|
||||
span = _conversation_span(["genai", "openinference", "langfuse", "weave", "langtrace"], payload, legacy_compat=True)
|
||||
a = span.attributes
|
||||
|
||||
assert span.dropped_attributes == 0
|
||||
assert a[GenAI.REQUEST_MODEL] == "gpt-4o"
|
||||
assert a[f"{LiteLLM.COST_PREFIX}total"] == 0.002
|
||||
assert a[LiteLLM.TOOLS_DECLARED] == 127
|
||||
assert a["llm.input_messages.0.message.content"] == "turn 0"
|
||||
assert a["llm.input_messages.199.message.content"] == "turn 199"
|
||||
assert a["llm.output_messages.0.message.content"] == "reply 0"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -2101,6 +2101,37 @@ def test_generic_cost_per_token_azure_gpt_6_astra_foundry_price_sheet(
|
|||
assert completion_cost == pytest.approx(zone_multiplier * output_multiplier * completion_tokens * 5e-5)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model,input_rate,cache_read_rate,output_rate",
|
||||
[
|
||||
("azure/gpt-chat-latest", 5e-6, 5e-7, 3e-5),
|
||||
("azure/chat-latest", 5e-6, 5e-7, 3e-5),
|
||||
("azure/us/gpt-chat-latest", 5.5e-6, 5.5e-7, 3.3e-5),
|
||||
],
|
||||
)
|
||||
def test_generic_cost_per_token_azure_gpt_chat_latest_price_sheet(
|
||||
_local_model_cost_map, model, input_rate, cache_read_rate, output_rate
|
||||
):
|
||||
"""The Azure OpenAI price sheet lists GPT-Chat Latest at $5 input, $0.50 cached input and $30 output per 1M
|
||||
tokens on Global, and $5.50, $0.55 and $33 on Data Zone. Foundry names the product gpt-chat-latest and the
|
||||
OpenAI API names the same model chat-latest, so both spellings bill the Global sheet.
|
||||
"""
|
||||
prompt_tokens = 100000
|
||||
cached_tokens = 40000
|
||||
completion_tokens = 1000
|
||||
usage = Usage(
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
total_tokens=prompt_tokens + completion_tokens,
|
||||
prompt_tokens_details=PromptTokensDetailsWrapper(cached_tokens=cached_tokens),
|
||||
)
|
||||
|
||||
prompt_cost, completion_cost = generic_cost_per_token(model=model, usage=usage, custom_llm_provider="azure")
|
||||
|
||||
assert prompt_cost == pytest.approx((prompt_tokens - cached_tokens) * input_rate + cached_tokens * cache_read_rate)
|
||||
assert completion_cost == pytest.approx(completion_tokens * output_rate)
|
||||
|
||||
|
||||
def test_generic_cost_per_token_azure_ai_gpt_6_astra_flex_bills_the_standard_rate(_local_model_cost_map):
|
||||
usage = Usage(prompt_tokens=1000, completion_tokens=100, total_tokens=1100)
|
||||
|
||||
|
|
@ -2648,6 +2679,7 @@ def test_cache_writing_cost_with_zero_creation_tokens_and_ephemeral_details():
|
|||
|
||||
prompt_tokens_details: PromptTokensDetailsResult = {
|
||||
"cache_hit_tokens": 0,
|
||||
"cache_hit_audio_tokens": 0,
|
||||
"cache_creation_tokens": 0,
|
||||
"cache_creation_token_details": CacheCreationTokenDetails(
|
||||
ephemeral_5m_input_tokens=100,
|
||||
|
|
@ -4005,6 +4037,7 @@ def test_billed_token_rates_follow_the_token_tier_the_breakdown_bills_at(monkeyp
|
|||
input_cost_per_token=6e-6,
|
||||
output_cost_per_token=3e-5,
|
||||
cache_read_input_token_cost=6e-7,
|
||||
cache_read_input_audio_token_cost=6e-7,
|
||||
cache_creation_input_token_cost=7.5e-6,
|
||||
cache_creation_input_token_cost_above_1hr=0.0,
|
||||
output_cost_per_reasoning_token=3e-5,
|
||||
|
|
@ -5147,3 +5180,157 @@ def test_generic_cost_per_token_bills_nested_reasoning_once_beside_audio_output(
|
|||
assert completion_cost == pytest.approx(
|
||||
30 * info["output_cost_per_token"] + 70 * info["output_cost_per_audio_token"]
|
||||
)
|
||||
|
||||
|
||||
def test_cached_realtime_audio_tokens_billed_at_audio_cache_read_rate(
|
||||
_local_model_cost_map: None,
|
||||
) -> None:
|
||||
usage = Usage(
|
||||
prompt_tokens=283,
|
||||
completion_tokens=0,
|
||||
total_tokens=283,
|
||||
prompt_tokens_details=PromptTokensDetailsWrapper(
|
||||
text_tokens=116,
|
||||
audio_tokens=167,
|
||||
cached_tokens=192,
|
||||
cached_tokens_details={"text_tokens": 64, "audio_tokens": 128},
|
||||
),
|
||||
)
|
||||
|
||||
prompt_cost, _ = generic_cost_per_token(
|
||||
model="gpt-realtime-2", usage=usage, custom_llm_provider="openai"
|
||||
)
|
||||
assert prompt_cost == pytest.approx(0.0015328)
|
||||
|
||||
|
||||
def test_prompt_tokens_details_without_cached_tokens_details_unchanged(
|
||||
_local_model_cost_map: None,
|
||||
) -> None:
|
||||
usage = Usage(
|
||||
prompt_tokens=283,
|
||||
completion_tokens=0,
|
||||
total_tokens=283,
|
||||
prompt_tokens_details=PromptTokensDetailsWrapper(
|
||||
text_tokens=116, audio_tokens=167, cached_tokens=192
|
||||
),
|
||||
)
|
||||
|
||||
prompt_cost, _ = generic_cost_per_token(
|
||||
model="gpt-realtime-2", usage=usage, custom_llm_provider="openai"
|
||||
)
|
||||
assert prompt_cost == pytest.approx(0.0029888)
|
||||
|
||||
|
||||
def test_cached_audio_tokens_fall_back_to_cache_read_input_token_cost() -> None:
|
||||
model_info: ModelInfo = {
|
||||
"input_cost_per_token": 4e-6,
|
||||
"input_cost_per_audio_token": 32e-6,
|
||||
"cache_read_input_token_cost": 5e-7,
|
||||
}
|
||||
usage = Usage(
|
||||
prompt_tokens=283,
|
||||
completion_tokens=0,
|
||||
total_tokens=283,
|
||||
prompt_tokens_details=PromptTokensDetailsWrapper(
|
||||
text_tokens=116,
|
||||
audio_tokens=167,
|
||||
cached_tokens=192,
|
||||
cached_tokens_details={"text_tokens": 64, "audio_tokens": 128},
|
||||
),
|
||||
)
|
||||
|
||||
prompt_cost, _ = generic_cost_per_token(
|
||||
model="some-realtime-model",
|
||||
usage=usage,
|
||||
custom_llm_provider="openai",
|
||||
model_info=model_info,
|
||||
)
|
||||
expected = 52 * 4e-6 + 64 * 5e-7 + 39 * 32e-6 + 128 * 5e-7
|
||||
assert prompt_cost == pytest.approx(expected)
|
||||
|
||||
|
||||
def test_cached_audio_tokens_capped_at_cached_tokens(_local_model_cost_map: None) -> None:
|
||||
"""Nested cached_tokens_details exceeding cached_tokens must not over-subtract the audio bucket."""
|
||||
usage = Usage(
|
||||
prompt_tokens=283,
|
||||
completion_tokens=0,
|
||||
total_tokens=283,
|
||||
prompt_tokens_details=PromptTokensDetailsWrapper(
|
||||
text_tokens=116,
|
||||
audio_tokens=167,
|
||||
cached_tokens=100,
|
||||
cached_tokens_details={"audio_tokens": 128},
|
||||
),
|
||||
)
|
||||
|
||||
prompt_cost, _ = generic_cost_per_token(
|
||||
model="gpt-realtime-2", usage=usage, custom_llm_provider="openai"
|
||||
)
|
||||
assert prompt_cost == pytest.approx(116 * 4e-6 + (167 - 100) * 32e-6 + 100 * 4e-7)
|
||||
|
||||
|
||||
def test_cached_audio_tokens_billed_at_audio_cache_rate_through_model_info_lookup(_local_model_cost_map: None) -> None:
|
||||
usage = Usage(
|
||||
prompt_tokens=1000,
|
||||
completion_tokens=0,
|
||||
total_tokens=1000,
|
||||
prompt_tokens_details=PromptTokensDetailsWrapper(
|
||||
text_tokens=400,
|
||||
audio_tokens=600,
|
||||
cached_tokens=500,
|
||||
cached_tokens_details={"text_tokens": 100, "audio_tokens": 400},
|
||||
),
|
||||
)
|
||||
|
||||
prompt_cost, _ = generic_cost_per_token(model="gpt-realtime-2.1-mini", usage=usage, custom_llm_provider="openai")
|
||||
assert prompt_cost == pytest.approx(300 * 6e-7 + 100 * 6e-8 + 200 * 1e-5 + 400 * 3e-7)
|
||||
|
||||
|
||||
def test_cache_read_breakdown_splits_cached_audio_at_the_audio_cache_rate(_local_model_cost_map: None) -> None:
|
||||
usage = Usage(
|
||||
prompt_tokens=4863,
|
||||
completion_tokens=1087,
|
||||
total_tokens=5950,
|
||||
prompt_tokens_details=PromptTokensDetailsWrapper(
|
||||
text_tokens=1693,
|
||||
audio_tokens=3170,
|
||||
cached_tokens=2816,
|
||||
cached_tokens_details={"text_tokens": 896, "audio_tokens": 1920},
|
||||
),
|
||||
)
|
||||
|
||||
breakdown = get_token_type_cost_breakdown(model="gpt-realtime-2.1-mini", custom_llm_provider="openai", usage=usage)
|
||||
prompt_cost, _ = generic_cost_per_token(model="gpt-realtime-2.1-mini", usage=usage, custom_llm_provider="openai")
|
||||
|
||||
assert breakdown.cache_read_cost == pytest.approx(896 * 6e-8 + 1920 * 3e-7)
|
||||
assert breakdown.rates is not None
|
||||
assert breakdown.rates.cache_read_input_audio_token_cost == pytest.approx(3e-7)
|
||||
assert prompt_cost == pytest.approx((1693 - 896) * 6e-7 + (3170 - 1920) * 1e-5 + breakdown.cache_read_cost)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("model", "custom_llm_provider", "expected_prompt_cost"),
|
||||
(
|
||||
pytest.param("azure/gpt-realtime-2025-08-28", "azure", 300 * 4e-6 + 100 * 4e-7 + 200 * 3.2e-5 + 400 * 4e-7, id="azure-gpt-realtime"),
|
||||
pytest.param("azure/gpt-realtime-1.5-2026-02-23", "azure", 300 * 4e-6 + 100 * 4e-7 + 200 * 3.2e-5 + 400 * 4e-7, id="azure-gpt-realtime-1.5"),
|
||||
pytest.param("azure/gpt-realtime-mini", "azure", 300 * 6e-7 + 100 * 6e-8 + 200 * 1e-5 + 400 * 3e-7, id="azure-gpt-realtime-mini"),
|
||||
pytest.param("gpt-realtime-mini", "openai", 300 * 6e-7 + 100 * 6e-8 + 200 * 1e-5 + 400 * 3e-7, id="openai-gpt-realtime-mini"),
|
||||
),
|
||||
)
|
||||
def test_realtime_models_bill_cached_text_and_audio_at_their_cache_read_rates(
|
||||
_local_model_cost_map: None, model: str, custom_llm_provider: str, expected_prompt_cost: float
|
||||
) -> None:
|
||||
usage = Usage(
|
||||
prompt_tokens=1000,
|
||||
completion_tokens=0,
|
||||
total_tokens=1000,
|
||||
prompt_tokens_details=PromptTokensDetailsWrapper(
|
||||
text_tokens=400,
|
||||
audio_tokens=600,
|
||||
cached_tokens=500,
|
||||
cached_tokens_details={"text_tokens": 100, "audio_tokens": 400},
|
||||
),
|
||||
)
|
||||
|
||||
prompt_cost, _ = generic_cost_per_token(model=model, usage=usage, custom_llm_provider=custom_llm_provider)
|
||||
assert prompt_cost == pytest.approx(expected_prompt_cost)
|
||||
|
|
|
|||
|
|
@ -5221,10 +5221,11 @@ def test_handle_anthropic_messages_parsed_response_logging_preserves_fast_mode_s
|
|||
assert getattr(result.usage, "speed", None) == "fast"
|
||||
|
||||
|
||||
def test_logging_init_sets_trace_id():
|
||||
def test_logging_init_sets_trace_id(monkeypatch):
|
||||
"""Logging.__init__() must call set_trace_id with self.litellm_trace_id."""
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
|
||||
monkeypatch.setattr(litellm, "request_correlation_in_logs", True)
|
||||
trace_id_var.set("")
|
||||
|
||||
log_obj = Logging(
|
||||
|
|
@ -5240,7 +5241,7 @@ def test_logging_init_sets_trace_id():
|
|||
assert trace_id_var.get() == log_obj.litellm_trace_id
|
||||
|
||||
|
||||
def test_logging_init_skips_stamping_when_correlation_logging_unsupported():
|
||||
def test_logging_init_skips_stamping_when_correlation_logging_unsupported(monkeypatch):
|
||||
"""supports_correlation_logging=False (what wrapper(), the sync entry
|
||||
point, always passes) must leave trace_id_var/session_id_var completely
|
||||
untouched, even though self.litellm_trace_id/litellm_session_id (the
|
||||
|
|
@ -5248,6 +5249,7 @@ def test_logging_init_skips_stamping_when_correlation_logging_unsupported():
|
|||
usual - only the ambient contextvar stamping is gated."""
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
|
||||
monkeypatch.setattr(litellm, "request_correlation_in_logs", True)
|
||||
trace_id_var.set("")
|
||||
session_id_var.set("")
|
||||
|
||||
|
|
@ -5271,10 +5273,48 @@ def test_logging_init_skips_stamping_when_correlation_logging_unsupported():
|
|||
assert log_obj.litellm_session_id == "should-not-be-stamped"
|
||||
|
||||
|
||||
def test_logging_init_sets_session_id_when_provided():
|
||||
def test_logging_init_skips_stamping_when_request_correlation_in_logs_disabled(monkeypatch):
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
|
||||
monkeypatch.setattr(litellm, "request_correlation_in_logs", False)
|
||||
trace_id_var.set("outer")
|
||||
session_id_var.set("outer-sid")
|
||||
try:
|
||||
with (
|
||||
patch( # test-quality-ok: regression test verifies disabled stamping skips both setters
|
||||
"litellm.litellm_core_utils.litellm_logging.set_trace_id"
|
||||
) as mock_set_trace_id,
|
||||
patch( # test-quality-ok: regression test verifies disabled stamping skips both setters
|
||||
"litellm.litellm_core_utils.litellm_logging.set_session_id"
|
||||
) as mock_set_session_id,
|
||||
):
|
||||
log_obj = Logging(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
stream=False,
|
||||
call_type="completion",
|
||||
start_time=None,
|
||||
litellm_call_id="call-disabled",
|
||||
function_id="fn-disabled",
|
||||
kwargs={"litellm_session_id": "disabled-session"},
|
||||
supports_correlation_logging=True,
|
||||
)
|
||||
|
||||
assert trace_id_var.get() == "outer"
|
||||
assert session_id_var.get() == "outer-sid"
|
||||
assert log_obj._own_trace_id == "outer"
|
||||
mock_set_trace_id.assert_not_called()
|
||||
mock_set_session_id.assert_not_called()
|
||||
finally:
|
||||
trace_id_var.set("")
|
||||
session_id_var.set("")
|
||||
|
||||
|
||||
def test_logging_init_sets_session_id_when_provided(monkeypatch):
|
||||
"""Logging.__init__() must call set_session_id when litellm_session_id is in kwargs."""
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
|
||||
monkeypatch.setattr(litellm, "request_correlation_in_logs", True)
|
||||
session_id_var.set("")
|
||||
|
||||
Logging(
|
||||
|
|
@ -5290,11 +5330,12 @@ def test_logging_init_sets_session_id_when_provided():
|
|||
assert session_id_var.get() == "my-session-99"
|
||||
|
||||
|
||||
def test_logging_init_resets_session_id_to_empty_when_absent():
|
||||
def test_logging_init_resets_session_id_to_empty_when_absent(monkeypatch):
|
||||
"""When no session_id is in kwargs, Logging.__init__() must reset session_id_var to ""
|
||||
so a prior request's session_id does not leak into subsequent log records."""
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
|
||||
monkeypatch.setattr(litellm, "request_correlation_in_logs", True)
|
||||
session_id_var.set("preexisting-sid")
|
||||
|
||||
Logging(
|
||||
|
|
@ -5310,7 +5351,7 @@ def test_logging_init_resets_session_id_to_empty_when_absent():
|
|||
assert session_id_var.get() == ""
|
||||
|
||||
|
||||
def test_restore_correlation_context_resets_to_pre_call_value():
|
||||
def test_restore_correlation_context_resets_to_pre_call_value(monkeypatch):
|
||||
"""_restore_correlation_context() must put trace_id_var/session_id_var back to
|
||||
whatever they were immediately before this Logging instance was constructed.
|
||||
This is the mechanism that prevents a nested call (e.g. a guardrail's own
|
||||
|
|
@ -5318,6 +5359,7 @@ def test_restore_correlation_context_resets_to_pre_call_value():
|
|||
session_id into the outer call's subsequent log lines."""
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
|
||||
monkeypatch.setattr(litellm, "request_correlation_in_logs", True)
|
||||
trace_id_var.set("outer-trace")
|
||||
session_id_var.set("outer-session")
|
||||
try:
|
||||
|
|
@ -5343,7 +5385,7 @@ def test_restore_correlation_context_resets_to_pre_call_value():
|
|||
session_id_var.set("")
|
||||
|
||||
|
||||
def test_restore_correlation_context_safe_to_call_repeatedly():
|
||||
def test_restore_correlation_context_safe_to_call_repeatedly(monkeypatch):
|
||||
"""Calling _restore_correlation_context() more than once must not raise.
|
||||
|
||||
It's deliberately NOT guarded against repeat calls: wrapper()'s finally
|
||||
|
|
@ -5353,6 +5395,7 @@ def test_restore_correlation_context_safe_to_call_repeatedly():
|
|||
the contextvars, so repeat calls are expected, not just tolerated."""
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
|
||||
monkeypatch.setattr(litellm, "request_correlation_in_logs", True)
|
||||
log_obj = Logging(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
|
|
@ -5367,8 +5410,40 @@ def test_restore_correlation_context_safe_to_call_repeatedly():
|
|||
log_obj._restore_correlation_context() # must not raise
|
||||
|
||||
|
||||
def test_restore_correlation_context_does_not_resanitize(monkeypatch):
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
from litellm._logging import _sanitize_correlation_id
|
||||
|
||||
monkeypatch.setattr(litellm, "request_correlation_in_logs", True)
|
||||
trace_id_var.set("outer-trace")
|
||||
session_id_var.set("outer-session")
|
||||
try:
|
||||
log_obj = Logging(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
stream=False,
|
||||
call_type="completion",
|
||||
start_time=None,
|
||||
litellm_call_id="call-no-resanitize",
|
||||
function_id="fn-no-resanitize",
|
||||
kwargs={"litellm_session_id": "inner-session"},
|
||||
)
|
||||
|
||||
with patch( # test-quality-ok: regression test verifies restore avoids sanitization
|
||||
"litellm._logging._sanitize_correlation_id", wraps=_sanitize_correlation_id
|
||||
) as mock_sanitize:
|
||||
log_obj._restore_correlation_context()
|
||||
|
||||
mock_sanitize.assert_not_called()
|
||||
assert trace_id_var.get() == "outer-trace"
|
||||
assert session_id_var.get() == "outer-session"
|
||||
finally:
|
||||
trace_id_var.set("")
|
||||
session_id_var.set("")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restore_correlation_context_works_across_asyncio_task_boundary():
|
||||
async def test_restore_correlation_context_works_across_asyncio_task_boundary(monkeypatch):
|
||||
"""_restore_correlation_context() must succeed even when it's called from a
|
||||
different asyncio Task than the one Logging.__init__() ran in - exactly what
|
||||
happens on litellm's real async success path, where async_success_handler is
|
||||
|
|
@ -5385,6 +5460,7 @@ async def test_restore_correlation_context_works_across_asyncio_task_boundary():
|
|||
"""
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
|
||||
monkeypatch.setattr(litellm, "request_correlation_in_logs", True)
|
||||
trace_id_var.set("outer-trace-cross-task")
|
||||
session_id_var.set("outer-session-cross-task")
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -4100,7 +4100,7 @@ async def test_async_streaming_completion_does_not_reset_context_before_iteratio
|
|||
session_id_var.set("")
|
||||
|
||||
|
||||
def test_stream_wrapper_del_restores_correlation_context():
|
||||
def test_stream_wrapper_del_restores_correlation_context(monkeypatch):
|
||||
"""CustomStreamWrapper.__del__ is the best-effort fallback for an abandoned
|
||||
stream (caller never exhausts it, so the normal terminal-handler restore
|
||||
never fires). Testing this via real garbage collection is unreliable in
|
||||
|
|
@ -4112,6 +4112,7 @@ def test_stream_wrapper_del_restores_correlation_context():
|
|||
doesn't run actual finalization, and this exercises exactly the logic that
|
||||
real garbage collection would eventually trigger.
|
||||
"""
|
||||
monkeypatch.setattr(litellm, "request_correlation_in_logs", True)
|
||||
trace_id_var.set("outer-trace-abandoned")
|
||||
session_id_var.set("outer-session-abandoned")
|
||||
try:
|
||||
|
|
@ -4159,12 +4160,13 @@ def test_stream_wrapper_del_never_raises_with_broken_logging_obj():
|
|||
wrapper.__del__() # must not raise
|
||||
|
||||
|
||||
def test_stream_wrapper_del_does_not_clobber_a_newer_active_call():
|
||||
def test_stream_wrapper_del_does_not_clobber_a_newer_active_call(monkeypatch):
|
||||
"""A delayed finalizer must never stomp a different, still-active call's
|
||||
context. If an abandoned stream's __del__ fires late - after a new call
|
||||
has already started in the same Task/thread and claimed the contextvars -
|
||||
unconditionally restoring the abandoned stream's own pre-call snapshot
|
||||
would corrupt the active call's subsequent log lines with stale ids."""
|
||||
monkeypatch.setattr(litellm, "request_correlation_in_logs", True)
|
||||
trace_id_var.set("outer-trace-before-abandoned-call")
|
||||
session_id_var.set("outer-session-before-abandoned-call")
|
||||
try:
|
||||
|
|
@ -4210,13 +4212,14 @@ def test_stream_wrapper_del_does_not_clobber_a_newer_active_call():
|
|||
session_id_var.set("")
|
||||
|
||||
|
||||
def test_stream_wrapper_del_restores_when_own_session_id_needed_sanitizing():
|
||||
def test_stream_wrapper_del_restores_when_own_session_id_needed_sanitizing(monkeypatch):
|
||||
"""The __del__ guard must compare against the *sanitized* id actually
|
||||
stored in the contextvar, not the raw litellm_session_id/litellm_trace_id
|
||||
- set_session_id()/set_trace_id() strip control characters before
|
||||
storing, so a caller-supplied id containing e.g. a newline would never
|
||||
equal the raw attribute, and the guard would wrongly conclude some other
|
||||
call has claimed the context and skip cleanup forever."""
|
||||
monkeypatch.setattr(litellm, "request_correlation_in_logs", True)
|
||||
trace_id_var.set("outer-trace-needs-sanitizing")
|
||||
session_id_var.set("outer-session-needs-sanitizing")
|
||||
try:
|
||||
|
|
@ -4250,7 +4253,7 @@ def test_stream_wrapper_del_restores_when_own_session_id_needed_sanitizing():
|
|||
session_id_var.set("")
|
||||
|
||||
|
||||
def test_stream_wrapper_next_keeps_context_active_through_synthesized_finish_reason_chunk():
|
||||
def test_stream_wrapper_next_keeps_context_active_through_synthesized_finish_reason_chunk(monkeypatch):
|
||||
"""When the underlying stream ends without ever emitting an explicit
|
||||
finish_reason chunk, __next__ synthesizes one via finish_reason_handler()
|
||||
and returns it. That chunk is still this call's own data - the caller's
|
||||
|
|
@ -4261,6 +4264,7 @@ def test_stream_wrapper_next_keeps_context_active_through_synthesized_finish_rea
|
|||
correct, deterministic restore on the very next __next__() call, since
|
||||
completion_stream is already exhausted and immediately re-raises
|
||||
StopIteration."""
|
||||
monkeypatch.setattr(litellm, "request_correlation_in_logs", True)
|
||||
trace_id_var.set("outer-trace-finish-reason")
|
||||
session_id_var.set("outer-session-finish-reason")
|
||||
try:
|
||||
|
|
@ -4300,12 +4304,13 @@ def test_stream_wrapper_next_keeps_context_active_through_synthesized_finish_rea
|
|||
session_id_var.set("")
|
||||
|
||||
|
||||
def test_stream_wrapper_del_cleans_up_after_synthesized_finish_reason_chunk():
|
||||
def test_stream_wrapper_del_cleans_up_after_synthesized_finish_reason_chunk(monkeypatch):
|
||||
"""A caller that breaks immediately after seeing finish_reason (the
|
||||
early-break pattern) never triggers the next()-driven restore above - it
|
||||
relies on the best-effort __del__ guard instead, same as any other
|
||||
abandoned stream. The guard must still recognize this call's own
|
||||
(unrestored) ids as unclaimed and clean them up."""
|
||||
monkeypatch.setattr(litellm, "request_correlation_in_logs", True)
|
||||
trace_id_var.set("outer-trace-finish-reason-del")
|
||||
session_id_var.set("outer-session-finish-reason-del")
|
||||
try:
|
||||
|
|
@ -4338,10 +4343,11 @@ def test_stream_wrapper_del_cleans_up_after_synthesized_finish_reason_chunk():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_wrapper_anext_keeps_context_active_through_synthesized_finish_reason_chunk():
|
||||
async def test_stream_wrapper_anext_keeps_context_active_through_synthesized_finish_reason_chunk(monkeypatch):
|
||||
"""Async sibling of test_stream_wrapper_next_keeps_context_active_through_synthesized_finish_reason_chunk -
|
||||
_finalize_completed_stream()'s else branch must not restore before
|
||||
returning the synthesized chunk either."""
|
||||
monkeypatch.setattr(litellm, "request_correlation_in_logs", True)
|
||||
trace_id_var.set("outer-trace-anext-finish-reason")
|
||||
session_id_var.set("outer-session-anext-finish-reason")
|
||||
try:
|
||||
|
|
@ -4394,6 +4400,7 @@ async def test_stream_wrapper_anext_max_duration_timeout_restores_consumer_corre
|
|||
path as every other failure so the consumer's outer correlation context gets
|
||||
restored - calling the check before entering __anext__()'s try block would
|
||||
let the Timeout bypass that restoration entirely."""
|
||||
monkeypatch.setattr(litellm, "request_correlation_in_logs", True)
|
||||
monkeypatch.setattr(litellm.constants, "LITELLM_MAX_STREAMING_DURATION_SECONDS", 1)
|
||||
trace_id_var.set("outer-trace-max-duration")
|
||||
session_id_var.set("outer-session-max-duration")
|
||||
|
|
@ -4434,12 +4441,13 @@ async def test_stream_wrapper_anext_max_duration_timeout_restores_consumer_corre
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_wrapper_aclose_restores_consumer_correlation_context():
|
||||
async def test_stream_wrapper_aclose_restores_consumer_correlation_context(monkeypatch):
|
||||
"""Explicit early termination (aclose(), e.g. on client disconnect or a
|
||||
router fallback aborting an in-progress stream) must restore the caller's
|
||||
correlation context too - not just __del__'s best-effort GC-timed fallback,
|
||||
since aclose() is normally called deterministically by the consumer/
|
||||
framework, unlike __del__."""
|
||||
monkeypatch.setattr(litellm, "request_correlation_in_logs", True)
|
||||
trace_id_var.set("outer-trace-aclose")
|
||||
session_id_var.set("outer-session-aclose")
|
||||
try:
|
||||
|
|
@ -4481,6 +4489,7 @@ async def test_stream_wrapper_aclose_keeps_context_active_through_close_failure_
|
|||
branch logs a debug diagnostic. That log line must still carry the
|
||||
closing stream's own trace_id/session_id - the outer context must not be
|
||||
restored until after the close attempt (and its diagnostic) completes."""
|
||||
monkeypatch.setattr(litellm, "request_correlation_in_logs", True)
|
||||
trace_id_var.set("outer-trace-close-fail")
|
||||
session_id_var.set("outer-session-close-fail")
|
||||
try:
|
||||
|
|
@ -4541,6 +4550,7 @@ def test_handle_stream_fallback_error_restores_context_only_after_exception_mapp
|
|||
mapping. The consumer's outer context must not be restored until that
|
||||
mapping call returns, or the diagnostic log line would carry the outer
|
||||
(or empty) trace_id/session_id instead of the failing stream's own."""
|
||||
monkeypatch.setattr(litellm, "request_correlation_in_logs", True)
|
||||
trace_id_var.set("outer-trace-fallback")
|
||||
session_id_var.set("outer-session-fallback")
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ from unittest.mock import ANY, AsyncMock, MagicMock, patch
|
|||
|
||||
|
||||
import pytest
|
||||
from fastapi import status
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
import litellm
|
||||
import litellm.proxy.proxy_server
|
||||
|
|
@ -7628,3 +7628,226 @@ async def test_claude_view_never_reinterprets_explicit_names(monkeypatch, layer)
|
|||
assert data["model"] == ("foo" if layer == "unclaimed" else encoded)
|
||||
await _normalize_claude_model(data, token, request, "/v1/messages")
|
||||
assert data["model"] == ("foo" if layer == "unclaimed" else encoded)
|
||||
|
||||
|
||||
ISSUER_ONE = "https://issuer-one.example.com"
|
||||
ISSUER_TWO = "https://issuer-two.example.com"
|
||||
|
||||
|
||||
def _per_issuer_virtual_key_jwt_handler(
|
||||
global_claim_field: str | None, global_behavior: str = "fallback_team_mapping"
|
||||
) -> MagicMock:
|
||||
jwt_handler = MagicMock()
|
||||
jwt_handler.is_jwt.return_value = True
|
||||
jwt_handler.litellm_jwtauth = LiteLLM_JWTAuth(
|
||||
virtual_key_claim_field=global_claim_field,
|
||||
unregistered_jwt_client_behavior=global_behavior,
|
||||
issuers=[
|
||||
{
|
||||
"issuer": ISSUER_ONE,
|
||||
"jwks_url": f"{ISSUER_ONE}/keys",
|
||||
"audience": "audience-one",
|
||||
"team_id_jwt_field": "sub",
|
||||
},
|
||||
{
|
||||
"issuer": ISSUER_TWO,
|
||||
"jwks_url": f"{ISSUER_TWO}/keys",
|
||||
"audience": "audience-two",
|
||||
"virtual_key_claim_field": "sub",
|
||||
"unregistered_jwt_client_behavior": "reject",
|
||||
},
|
||||
],
|
||||
)
|
||||
return jwt_handler
|
||||
|
||||
|
||||
def _fake_prisma_with_jwt_key_mapping(hashed_token: str | None) -> tuple[SimpleNamespace, AsyncMock]:
|
||||
find_first = AsyncMock(return_value=None if hashed_token is None else SimpleNamespace(token=hashed_token))
|
||||
prisma_client = SimpleNamespace(db=SimpleNamespace(litellm_jwtkeymapping=SimpleNamespace(find_first=find_first)))
|
||||
return prisma_client, find_first
|
||||
|
||||
|
||||
def _mapping_where(claim_name: str, claim_value: str) -> dict[str, str | bool]:
|
||||
return {"jwt_claim_name": claim_name, "jwt_claim_value": claim_value, "is_active": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_issuer_virtual_key_claim_field_selects_the_issuer_mapping_for_the_db_lookup():
|
||||
from litellm.proxy.auth.user_api_key_auth import _resolve_jwt_to_virtual_key
|
||||
|
||||
jwt_handler = _per_issuer_virtual_key_jwt_handler(global_claim_field=None)
|
||||
prisma_client, find_first = _fake_prisma_with_jwt_key_mapping("hashed-mapped-key")
|
||||
user_api_key_cache = DualCache()
|
||||
await user_api_key_cache.async_set_cache(
|
||||
key="hashed-mapped-key",
|
||||
value=UserAPIKeyAuth(token="hashed-mapped-key", api_key="hashed-mapped-key", team_id="svc-team"),
|
||||
)
|
||||
|
||||
resolved = await _resolve_jwt_to_virtual_key(
|
||||
jwt_claims={JWTHandler.LITELLM_JWT_ISSUER_CLAIM: ISSUER_TWO, "sub": "svc-account-7"},
|
||||
jwt_handler=jwt_handler,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
parent_otel_span=None,
|
||||
proxy_logging_obj=MagicMock(),
|
||||
)
|
||||
|
||||
find_first.assert_awaited_once_with(where=_mapping_where("sub", "svc-account-7"))
|
||||
assert isinstance(resolved, UserAPIKeyAuth)
|
||||
assert resolved.token == "hashed-mapped-key"
|
||||
assert resolved.team_id == "svc-team"
|
||||
assert await user_api_key_cache.async_get_cache("jwt_key_mapping:sub:svc-account-7") == "hashed-mapped-key"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_issuer_reject_behavior_does_not_leak_into_the_team_issuer():
|
||||
from litellm.proxy.auth.user_api_key_auth import _resolve_jwt_to_virtual_key
|
||||
|
||||
jwt_handler = _per_issuer_virtual_key_jwt_handler(global_claim_field=None)
|
||||
prisma_client, find_first = _fake_prisma_with_jwt_key_mapping(None)
|
||||
|
||||
team_issuer_result = await _resolve_jwt_to_virtual_key(
|
||||
jwt_claims={JWTHandler.LITELLM_JWT_ISSUER_CLAIM: ISSUER_ONE, "sub": "team-alpha"},
|
||||
jwt_handler=jwt_handler,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=DualCache(),
|
||||
parent_otel_span=None,
|
||||
proxy_logging_obj=MagicMock(),
|
||||
)
|
||||
assert team_issuer_result is None
|
||||
find_first.assert_not_awaited()
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _resolve_jwt_to_virtual_key(
|
||||
jwt_claims={JWTHandler.LITELLM_JWT_ISSUER_CLAIM: ISSUER_TWO, "sub": "unknown-svc"},
|
||||
jwt_handler=jwt_handler,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=DualCache(),
|
||||
parent_otel_span=None,
|
||||
proxy_logging_obj=MagicMock(),
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 403
|
||||
assert "No registered mapping for sub='unknown-svc'" in str(exc.value.detail)
|
||||
find_first.assert_awaited_once_with(where=_mapping_where("sub", "unknown-svc"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxy_admin_sentinel_cached_by_another_issuer_does_not_bypass_reject():
|
||||
from litellm.proxy.auth.user_api_key_auth import _JWT_PROXY_ADMIN_SENTINEL, _resolve_jwt_to_virtual_key
|
||||
|
||||
jwt_handler = _per_issuer_virtual_key_jwt_handler(global_claim_field="sub", global_behavior="auto_register")
|
||||
prisma_client, find_first = _fake_prisma_with_jwt_key_mapping(None)
|
||||
user_api_key_cache = DualCache()
|
||||
await user_api_key_cache.async_set_cache(key="jwt_key_mapping:sub:admin-7", value=_JWT_PROXY_ADMIN_SENTINEL)
|
||||
|
||||
auto_register_issuer_result = await _resolve_jwt_to_virtual_key(
|
||||
jwt_claims={JWTHandler.LITELLM_JWT_ISSUER_CLAIM: ISSUER_ONE, "sub": "admin-7"},
|
||||
jwt_handler=jwt_handler,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
parent_otel_span=None,
|
||||
proxy_logging_obj=MagicMock(),
|
||||
)
|
||||
assert auto_register_issuer_result is None
|
||||
find_first.assert_not_awaited()
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _resolve_jwt_to_virtual_key(
|
||||
jwt_claims={JWTHandler.LITELLM_JWT_ISSUER_CLAIM: ISSUER_TWO, "sub": "admin-7"},
|
||||
jwt_handler=jwt_handler,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
parent_otel_span=None,
|
||||
proxy_logging_obj=MagicMock(),
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 403
|
||||
assert "No registered mapping for sub='admin-7'" in str(exc.value.detail)
|
||||
find_first.assert_awaited_once_with(where=_mapping_where("sub", "admin-7"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_issuer_without_virtual_key_claim_field_falls_back_to_the_global_field():
|
||||
from litellm.proxy.auth.user_api_key_auth import _resolve_jwt_to_virtual_key
|
||||
|
||||
jwt_handler = _per_issuer_virtual_key_jwt_handler(global_claim_field="client_id")
|
||||
prisma_client, find_first = _fake_prisma_with_jwt_key_mapping(None)
|
||||
|
||||
with_claim = await _resolve_jwt_to_virtual_key(
|
||||
jwt_claims={JWTHandler.LITELLM_JWT_ISSUER_CLAIM: ISSUER_ONE, "sub": "team-alpha", "client_id": "app-9"},
|
||||
jwt_handler=jwt_handler,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=DualCache(),
|
||||
parent_otel_span=None,
|
||||
proxy_logging_obj=MagicMock(),
|
||||
)
|
||||
without_claim = await _resolve_jwt_to_virtual_key(
|
||||
jwt_claims={JWTHandler.LITELLM_JWT_ISSUER_CLAIM: ISSUER_ONE, "sub": "team-alpha"},
|
||||
jwt_handler=jwt_handler,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=DualCache(),
|
||||
parent_otel_span=None,
|
||||
proxy_logging_obj=MagicMock(),
|
||||
)
|
||||
|
||||
assert with_claim is None
|
||||
assert without_claim is None
|
||||
find_first.assert_awaited_once_with(where=_mapping_where("client_id", "app-9"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_flow_enters_virtual_key_mapping_when_only_an_issuer_configures_the_claim_field():
|
||||
jwt_token = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJzdmMtYWNjb3VudC03In0.signature"
|
||||
jwt_handler = _per_issuer_virtual_key_jwt_handler(global_claim_field=None)
|
||||
jwt_handler.auth_jwt = AsyncMock(
|
||||
return_value={JWTHandler.LITELLM_JWT_ISSUER_CLAIM: ISSUER_TWO, "sub": "svc-account-7"}
|
||||
)
|
||||
mapped_key = UserAPIKeyAuth(token="hashed-mapped-key", api_key="hashed-mapped-key", team_id="svc-team")
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.url.path = "/v1/chat/completions"
|
||||
mock_request.method = "POST"
|
||||
mock_request.headers = {"authorization": f"Bearer {jwt_token}"}
|
||||
mock_request.query_params = {}
|
||||
mock_request.state = SimpleNamespace()
|
||||
|
||||
with (
|
||||
patch( # test-quality-ok: the builder reads proxy settings from module globals, no injection seam
|
||||
"litellm.proxy.proxy_server.general_settings", {"enable_jwt_auth": True}
|
||||
),
|
||||
patch("litellm.proxy.proxy_server.premium_user", True), # test-quality-ok: module-global proxy state
|
||||
patch("litellm.proxy.proxy_server.master_key", "sk-master"), # test-quality-ok: module-global proxy state
|
||||
patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), # test-quality-ok: module-global proxy state
|
||||
patch( # test-quality-ok: module-global proxy state
|
||||
"litellm.proxy.proxy_server.user_api_key_cache", DualCache()
|
||||
),
|
||||
patch( # test-quality-ok: module-global proxy state
|
||||
"litellm.proxy.proxy_server.proxy_logging_obj", MagicMock()
|
||||
),
|
||||
patch("litellm.proxy.proxy_server.jwt_handler", jwt_handler), # test-quality-ok: module-global proxy state
|
||||
patch( # test-quality-ok: the regression is whether the builder reaches this seam at all
|
||||
"litellm.proxy.auth.user_api_key_auth._resolve_jwt_to_virtual_key",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mapped_key,
|
||||
) as resolve_mock,
|
||||
patch( # test-quality-ok: a mapped key must short-circuit standard JWT auth; reaching it is the failure
|
||||
"litellm.proxy.auth.user_api_key_auth.JWTAuthManager.auth_builder",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=AssertionError("standard JWT auth must not run for a mapped virtual key"),
|
||||
),
|
||||
):
|
||||
result = await _user_api_key_auth_builder(
|
||||
request=mock_request,
|
||||
api_key=jwt_token,
|
||||
azure_api_key_header="",
|
||||
anthropic_api_key_header=None,
|
||||
google_ai_studio_api_key_header=None,
|
||||
azure_apim_header=None,
|
||||
request_data={"model": "gpt-4o-mini"},
|
||||
)
|
||||
|
||||
resolve_mock.assert_awaited_once()
|
||||
assert resolve_mock.await_args.kwargs["jwt_claims"][JWTHandler.LITELLM_JWT_ISSUER_CLAIM] == ISSUER_TWO
|
||||
assert result.api_key == "hashed-mapped-key"
|
||||
assert result.team_id == "svc-team"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -98,6 +98,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
|
||||
|
|
@ -105,6 +106,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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -277,3 +277,61 @@ def test_team_membership_budget_table_present_still_works():
|
|||
}
|
||||
result = LiteLLM_TeamMembership.model_validate(data)
|
||||
assert result.litellm_budget_table is None
|
||||
|
||||
|
||||
def test_a_jwt_issuer_can_override_the_virtual_key_claim_field_while_other_issuers_keep_the_global_one():
|
||||
from litellm.proxy._types import LiteLLM_JWTAuth, UnregisteredJWTClientBehavior
|
||||
|
||||
jwt_auth = LiteLLM_JWTAuth(
|
||||
virtual_key_claim_field="client_id",
|
||||
issuers=[
|
||||
{
|
||||
"issuer": "https://team-idp.example.com",
|
||||
"jwks_url": "https://team-idp.example.com/keys",
|
||||
"audience": "litellm",
|
||||
"team_id_jwt_field": "sub",
|
||||
},
|
||||
{
|
||||
"issuer": "https://service-idp.example.com",
|
||||
"jwks_url": "https://service-idp.example.com/keys",
|
||||
"audience": "litellm",
|
||||
"virtual_key_claim_field": "sub",
|
||||
"unregistered_jwt_client_behavior": "reject",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
assert jwt_auth.get_virtual_key_claim_field("https://service-idp.example.com") == "sub"
|
||||
assert jwt_auth.get_unregistered_jwt_client_behavior("https://service-idp.example.com") is (
|
||||
UnregisteredJWTClientBehavior.REJECT
|
||||
)
|
||||
assert jwt_auth.get_virtual_key_claim_field("https://team-idp.example.com") == "client_id"
|
||||
assert jwt_auth.get_unregistered_jwt_client_behavior("https://team-idp.example.com") is (
|
||||
UnregisteredJWTClientBehavior.FALLBACK_TEAM_MAPPING
|
||||
)
|
||||
assert jwt_auth.get_virtual_key_claim_field(None) == "client_id"
|
||||
assert jwt_auth.get_virtual_key_claim_field("https://unknown-idp.example.com") == "client_id"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("global_field", "issuer_field", "is_configured"),
|
||||
((None, None, False), ("sub", None, True), (None, "sub", True)),
|
||||
)
|
||||
def test_virtual_key_mapping_counts_as_configured_when_any_issuer_sets_the_claim_field(
|
||||
global_field, issuer_field, is_configured
|
||||
):
|
||||
from litellm.proxy._types import LiteLLM_JWTAuth
|
||||
|
||||
jwt_auth = LiteLLM_JWTAuth(
|
||||
virtual_key_claim_field=global_field,
|
||||
issuers=[
|
||||
{
|
||||
"issuer": "https://idp.example.com",
|
||||
"jwks_url": "https://idp.example.com/keys",
|
||||
"audience": "litellm",
|
||||
"virtual_key_claim_field": issuer_field,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert jwt_auth.is_virtual_key_mapping_configured() is is_configured
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
@ -5272,6 +5274,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
|
||||
|
||||
|
|
@ -5294,13 +5298,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
|
||||
|
|
|
|||
|
|
@ -2789,6 +2789,7 @@ class TestUsageTransformation:
|
|||
assert response_usage.input_tokens_details is not None
|
||||
assert response_usage.input_tokens_details.cached_tokens == 5
|
||||
assert response_usage.input_tokens_details.text_tokens == 8
|
||||
assert "cache_write_tokens" not in response_usage.input_tokens_details.model_dump()
|
||||
|
||||
def test_transform_usage_with_cached_tokens_gemini(self):
|
||||
"""Test that cached_tokens from Gemini are properly transformed to input_tokens_details"""
|
||||
|
|
@ -2851,6 +2852,7 @@ class TestUsageTransformation:
|
|||
assert response_usage.input_tokens_details is not None
|
||||
assert response_usage.input_tokens_details.cached_tokens == 100
|
||||
assert getattr(response_usage.input_tokens_details, "cache_write_tokens", None) == 800
|
||||
assert response_usage.input_tokens_details.model_dump()["cache_write_tokens"] == 800
|
||||
|
||||
def test_transform_usage_with_reasoning_tokens_gemini(self):
|
||||
"""Test that reasoning_tokens from Gemini are properly transformed to output_tokens_details"""
|
||||
|
|
|
|||
|
|
@ -577,6 +577,47 @@ class TestResponseAPILoggingUtils:
|
|||
assert result.completion_tokens_details is not None
|
||||
assert result.completion_tokens_details.reasoning_tokens == 4
|
||||
|
||||
def test_transform_realtime_usage_dict_keeps_cached_tokens_details(self):
|
||||
usage = {
|
||||
"input_tokens": 283,
|
||||
"output_tokens": 0,
|
||||
"total_tokens": 283,
|
||||
"input_token_details": {
|
||||
"text_tokens": 116,
|
||||
"audio_tokens": 167,
|
||||
"cached_tokens": 192,
|
||||
"cached_tokens_details": {"text_tokens": 64, "audio_tokens": 128},
|
||||
},
|
||||
}
|
||||
|
||||
result = ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(usage)
|
||||
|
||||
assert result.prompt_tokens_details is not None
|
||||
assert result.prompt_tokens_details.cached_tokens == 192
|
||||
assert result.prompt_tokens_details.cached_tokens_details is not None
|
||||
assert result.prompt_tokens_details.cached_tokens_details.audio_tokens == 128
|
||||
assert result.prompt_tokens_details.cached_tokens_details.text_tokens == 64
|
||||
|
||||
def test_transform_response_api_usage_object_keeps_cached_tokens_details(self):
|
||||
usage = ResponseAPIUsage(
|
||||
input_tokens=283,
|
||||
output_tokens=0,
|
||||
total_tokens=283,
|
||||
input_tokens_details={
|
||||
"text_tokens": 116,
|
||||
"audio_tokens": 167,
|
||||
"cached_tokens": 192,
|
||||
"cached_tokens_details": {"text_tokens": 64, "audio_tokens": 128},
|
||||
},
|
||||
)
|
||||
|
||||
result = ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(usage)
|
||||
|
||||
assert result.prompt_tokens_details is not None
|
||||
assert result.prompt_tokens_details.cached_tokens_details is not None
|
||||
assert result.prompt_tokens_details.cached_tokens_details.audio_tokens == 128
|
||||
assert result.prompt_tokens_details.cached_tokens_details.text_tokens == 64
|
||||
|
||||
|
||||
class TestResponsesAPIProviderSpecificParams:
|
||||
"""
|
||||
|
|
|
|||
30
tests/test_litellm/rust_bridge/test_lifecycle.py
Normal file
30
tests/test_litellm/rust_bridge/test_lifecycle.py
Normal 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)
|
||||
|
|
@ -19,6 +19,7 @@ from litellm.cost_calculator import (
|
|||
)
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
from litellm.llms.base_llm.ocr.transformation import OCRPage, OCRResponse, OCRUsageInfo
|
||||
from litellm.types.llms.base import CachedTokensDetails
|
||||
from litellm.types.llms.openai import OpenAIRealtimeStreamList
|
||||
from litellm.types.rerank import RerankResponse
|
||||
from litellm.types.utils import (
|
||||
|
|
@ -4848,6 +4849,109 @@ def test_collect_and_combine_realtime_usage_stores_partitioned_text_tokens() ->
|
|||
assert combined.completion_tokens_details.audio_tokens == 0
|
||||
|
||||
|
||||
def test_realtime_combine_sums_nested_cached_tokens_details():
|
||||
results: OpenAIRealtimeStreamList = [
|
||||
{
|
||||
"type": "response.done",
|
||||
"response": {
|
||||
"usage": {
|
||||
"input_tokens": 283,
|
||||
"output_tokens": 0,
|
||||
"total_tokens": 283,
|
||||
"input_token_details": {
|
||||
"text_tokens": 116,
|
||||
"audio_tokens": 167,
|
||||
"cached_tokens": 192,
|
||||
"cached_tokens_details": {"text_tokens": 64, "audio_tokens": 128},
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "response.done",
|
||||
"response": {
|
||||
"usage": {
|
||||
"input_tokens": 150,
|
||||
"output_tokens": 0,
|
||||
"total_tokens": 150,
|
||||
"input_token_details": {
|
||||
"text_tokens": 50,
|
||||
"audio_tokens": 100,
|
||||
"cached_tokens": 100,
|
||||
"cached_tokens_details": {"audio_tokens": 100},
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
combined = RealtimeAPITokenUsageProcessor.collect_and_combine_usage_from_realtime_stream_results(
|
||||
results=results,
|
||||
)
|
||||
|
||||
assert combined.prompt_tokens_details is not None
|
||||
assert combined.prompt_tokens_details.cached_tokens == 292
|
||||
assert combined.prompt_tokens_details.cached_tokens_details is not None
|
||||
assert combined.prompt_tokens_details.cached_tokens_details.audio_tokens == 228
|
||||
assert combined.prompt_tokens_details.cached_tokens_details.text_tokens == 64
|
||||
assert combined.prompt_tokens_details.cached_tokens_details.image_tokens is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("details_first", [True, False])
|
||||
def test_realtime_combine_keeps_cached_split_when_only_one_usage_has_details(details_first: bool):
|
||||
with_details: Final = {
|
||||
"type": "response.done",
|
||||
"response": {
|
||||
"usage": {
|
||||
"input_tokens": 283,
|
||||
"output_tokens": 0,
|
||||
"total_tokens": 283,
|
||||
"input_token_details": {
|
||||
"text_tokens": 116,
|
||||
"audio_tokens": 167,
|
||||
"cached_tokens": 192,
|
||||
"cached_tokens_details": {"text_tokens": 64, "audio_tokens": 128},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
without_details: Final = {
|
||||
"type": "response.done",
|
||||
"response": {
|
||||
"usage": {
|
||||
"input_tokens": 150,
|
||||
"output_tokens": 0,
|
||||
"total_tokens": 150,
|
||||
"input_token_details": {"text_tokens": 50, "audio_tokens": 100, "cached_tokens": 100},
|
||||
}
|
||||
},
|
||||
}
|
||||
results: OpenAIRealtimeStreamList = (
|
||||
[with_details, without_details] if details_first else [without_details, with_details]
|
||||
)
|
||||
|
||||
combined = RealtimeAPITokenUsageProcessor.collect_and_combine_usage_from_realtime_stream_results(
|
||||
results=results,
|
||||
)
|
||||
|
||||
assert combined.prompt_tokens_details is not None
|
||||
assert combined.prompt_tokens_details.cached_tokens == 292
|
||||
assert combined.prompt_tokens_details.cached_tokens_details == CachedTokensDetails(text_tokens=64, audio_tokens=128)
|
||||
|
||||
|
||||
def test_usage_without_cached_tokens_details_omits_key():
|
||||
usage = Usage(
|
||||
prompt_tokens=10,
|
||||
completion_tokens=5,
|
||||
total_tokens=15,
|
||||
prompt_tokens_details=PromptTokensDetailsWrapper(text_tokens=10),
|
||||
)
|
||||
|
||||
dumped = usage.prompt_tokens_details.model_dump()
|
||||
assert "cached_tokens_details" not in dumped
|
||||
assert "cached_tokens_details" not in usage.prompt_tokens_details.model_dump_json()
|
||||
|
||||
|
||||
UNMAPPED_OCR_MODEL: Final = "azure_ai/some-unmapped-ocr-model-for-testing"
|
||||
MAPPED_OCR_MODEL: Final = "mistral/mistral-ocr-4-0"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -221,6 +221,14 @@ def test_chat_latest_declares_the_one_effort_openai_accepts(prices: dict):
|
|||
assert resolve_supported_reasoning_efforts(prices["chat-latest"], deployment_is_mapped=True) == ("medium",)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["azure/gpt-chat-latest", "azure/chat-latest", "azure/us/gpt-chat-latest"])
|
||||
def test_azure_gpt_chat_latest_declares_the_one_effort_azure_accepts(prices: dict, key: str):
|
||||
"""Azure answers every reasoning_effort on a gpt-chat-latest deployment except medium with
|
||||
"Unsupported value ... Supported values are: 'medium'", the same fixed level OpenAI's chat-latest
|
||||
carries, so the Foundry product name and the OpenAI API name both declare that one level."""
|
||||
assert resolve_supported_reasoning_efforts(prices[key], deployment_is_mapped=True) == ("medium",)
|
||||
|
||||
|
||||
BEDROCK_OPENAI_GPT_MARKERS: Final = ("openai.gpt-5.4", "openai.gpt-5.5", "openai.gpt-5.6", "openai.gpt-6-astra")
|
||||
BEDROCK_PROVIDERS: Final = frozenset(("bedrock", "bedrock_converse", "bedrock_mantle"))
|
||||
BEDROCK_ROW_PREFIXES: Final = ("bedrock_mantle/", "us.", "global.")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
@ -6455,3 +6548,11 @@ def test_completion_finishes_response_metadata_before_handing_the_response_to_th
|
|||
assert snapshot["litellm_call_id"]
|
||||
assert snapshot["response_cost"] is not None
|
||||
assert snapshot["api_base"]
|
||||
|
||||
|
||||
def test_get_model_info_carries_cache_read_input_audio_token_cost(monkeypatch):
|
||||
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
|
||||
monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url=""))
|
||||
info = litellm.get_model_info("gpt-realtime-2.1-mini", custom_llm_provider="openai")
|
||||
assert info["cache_read_input_audio_token_cost"] == 3e-07
|
||||
assert info["cache_read_input_token_cost"] == 6e-08
|
||||
|
|
|
|||
|
|
@ -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 == []
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
11
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
11
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue