mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-21 00:21:49 +00:00
Merge remote-tracking branch 'origin/main' into litellm_mcp_admin_terminate_sessions_revoke_credentials
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> # Conflicts: # tests/test_litellm/proxy/config_resolvers/test_settings_store.py
This commit is contained in:
commit
737c6e1b2d
31 changed files with 2988 additions and 460 deletions
|
|
@ -1660,6 +1660,11 @@ LITELLM_EXPIRED_UI_SESSION_KEY_CLEANUP_INTERVAL_SECONDS: Final = int(
|
|||
LITELLM_EXPIRED_UI_SESSION_KEY_CLEANUP_BATCH_SIZE: Final = int(
|
||||
os.getenv("LITELLM_EXPIRED_UI_SESSION_KEY_CLEANUP_BATCH_SIZE", 1000)
|
||||
)
|
||||
LOGIN_THROTTLE_CACHE_KEY_PREFIX: Final = "login_fail"
|
||||
LOGIN_THROTTLE_UNKNOWN_SOURCE: Final = "unknown"
|
||||
LOGIN_THROTTLE_MAX_TRACKED_COUNTERS: Final = 20_000
|
||||
LOGIN_THROTTLE_MAX_TRACKED_BLOCKS: Final = 10_000
|
||||
LOGIN_THROTTLE_NOT_BLOCKED: Final = (0, 0)
|
||||
LITELLM_PROXY_ADMIN_NAME: Final = "default_user_id"
|
||||
LITELLM_PROXY_BUDGET_NAME: Final = "litellm-proxy-budget"
|
||||
GLOBAL_PROXY_SPEND_CACHE_KEY: Final = f"{LITELLM_PROXY_ADMIN_NAME}:spend"
|
||||
|
|
@ -2052,6 +2057,7 @@ MCP_SPEND_LOG_MODEL_PREFIX: Final[str] = "MCP: "
|
|||
PTU_SENTINEL_API_KEY: Final[str] = "__ptu_flat_cost__"
|
||||
PTU_ROLLUP_JOB_ID: Final[str] = "ptu_flat_cost_rollup_job"
|
||||
PTU_ROLLUP_LOCK_TTL_SECONDS: Final[int] = 900
|
||||
USAGE_TOP_API_KEYS_LIMIT: Final[int] = int(os.getenv("USAGE_TOP_API_KEYS_LIMIT", "100"))
|
||||
# Furthest back the catch-up pass looks for unpriced PTU days when a deployment
|
||||
# declares no ptu_effective_from, bounding the scan for an open-ended window.
|
||||
PTU_ROLLUP_MAX_BACKFILL_DAYS: Final[int] = 90
|
||||
|
|
|
|||
|
|
@ -3050,6 +3050,18 @@
|
|||
},
|
||||
"DailySpendMetadata": {
|
||||
"properties": {
|
||||
"api_key_limit": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "When set, api_keys and every api_key_breakdown list at most this many keys, ranked by spend. Totals and the model, provider, mcp and endpoint rollups still cover every key.",
|
||||
"title": "Api Key Limit"
|
||||
},
|
||||
"has_more": {
|
||||
"default": false,
|
||||
"title": "Has More",
|
||||
|
|
@ -3060,6 +3072,18 @@
|
|||
"title": "Page",
|
||||
"type": "integer"
|
||||
},
|
||||
"total_api_keys": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Distinct API keys matching the filters. When this exceeds api_key_limit, the per-key lists are truncated to the highest-spend keys.",
|
||||
"title": "Total Api Keys"
|
||||
},
|
||||
"total_api_requests": {
|
||||
"default": 0,
|
||||
"title": "Total Api Requests",
|
||||
|
|
|
|||
|
|
@ -2778,6 +2778,25 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
|
|||
description="sends alerts if requests hang for 5min+",
|
||||
)
|
||||
ui_access_mode: Literal["admin_only", "all"] | None = Field("all", description="Control access to the Proxy UI")
|
||||
max_failed_login_attempts_per_source: int | None = Field(
|
||||
None,
|
||||
ge=1,
|
||||
description="Failed Admin UI sign-in attempts allowed from one source address, across every username, within `failed_login_window_seconds`. One more blocks that address for `failed_login_block_seconds`. Half this value, rounded down but at least 1, is the allowance for one username from that address; one more blocks that address for that username only, and its further failures stop counting toward the address limit, so a script stuck on one account does not block everyone behind a shared address. The per-address limit is only enforced when `trusted_proxy_ranges` is set: to the proxies in front of LiteLLM, or to an empty list when clients connect directly. Left unset, the peer address may be a shared ingress and only the per-username half runs. IPv6 addresses are grouped by /64. Set under `general_settings` in config.yaml. Defaults to 10",
|
||||
)
|
||||
max_failed_login_attempts_per_source_overrides: dict[str, int] | None = Field(
|
||||
None,
|
||||
description="Per-address overrides of `max_failed_login_attempts_per_source`, keyed by IP address or CIDR range, e.g. {'1.2.3.4': 200, '5.6.0.0/24': 500}. The most specific matching range wins (between equivalent keys such as '1.2.3.4' and '1.2.3.4/32', an exemption wins, then the higher limit), and the per-username allowance for that address follows as half the override. A value of 0 exempts the address from both limits. Set under `general_settings` in config.yaml",
|
||||
)
|
||||
failed_login_window_seconds: int | None = Field(
|
||||
None,
|
||||
ge=1,
|
||||
description="Fixed window in seconds over which failed Admin UI sign-in attempts are counted. The window starts at the first failure and is not extended by later ones. Set under `general_settings` in config.yaml. Defaults to 60",
|
||||
)
|
||||
failed_login_block_seconds: int | None = Field(
|
||||
None,
|
||||
ge=1,
|
||||
description="How long a blocked source address, or source address and username, stays blocked. Every attempt from a blocked key, right or wrong, is refused with 429 before the password is checked; the block is not extended by refused attempts. Set under `general_settings` in config.yaml. Defaults to 300",
|
||||
)
|
||||
allowed_routes: list | None = Field(None, description="Proxy API Endpoints you want users to be able to access")
|
||||
reject_clientside_metadata_tags: bool | None = Field(
|
||||
None,
|
||||
|
|
@ -2891,7 +2910,7 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
|
|||
)
|
||||
trusted_proxy_ranges: list[str] | None = Field(
|
||||
None,
|
||||
description="CIDR ranges of trusted reverse proxies allowed to provide identity headers for header-based auth paths such as enable_oauth2_proxy_auth and custom_ui_sso_sign_in_handler.",
|
||||
description="CIDR ranges of trusted reverse proxies allowed to provide identity headers for header-based auth paths such as enable_oauth2_proxy_auth and custom_ui_sso_sign_in_handler, and whose X-Forwarded-For is used to attribute Admin UI sign-in attempts to a source address. Set it to an empty list when clients connect directly, so the peer address is the source. Left unset, or containing an entry that is not an address or CIDR range, the per-source sign-in limit is off.",
|
||||
)
|
||||
store_model_in_db: bool | None = Field(
|
||||
None,
|
||||
|
|
|
|||
445
litellm/proxy/auth/login_throttle.py
Normal file
445
litellm/proxy/auth/login_throttle.py
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
"""Failed-login accounting for the Admin UI sign-in path.
|
||||
|
||||
Wrong passwords are counted over a short window per source address and per source-and-username
|
||||
pair; too many in one window blocks that key for a fixed time. While a key is blocked every attempt
|
||||
from it, right or wrong, is refused with 429 before the password is checked. A blocked pair stops
|
||||
counting against its source, so one script stuck on one account does not block the whole office.
|
||||
Recovery is the master key over the API, which never passes through here, or waiting out the block.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import math
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from functools import cache
|
||||
from typing import Final, Literal, NamedTuple, Protocol, TypeAlias
|
||||
|
||||
from fastapi import Request, status
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.caching.in_memory_cache import InMemoryCache
|
||||
from litellm.caching.redis_cache import RedisCache, RedisCircuitBreakerOpenError
|
||||
from litellm.constants import (
|
||||
EMPTY_MAPPING,
|
||||
LOGIN_THROTTLE_CACHE_KEY_PREFIX,
|
||||
LOGIN_THROTTLE_MAX_TRACKED_BLOCKS,
|
||||
LOGIN_THROTTLE_MAX_TRACKED_COUNTERS,
|
||||
LOGIN_THROTTLE_NOT_BLOCKED,
|
||||
LOGIN_THROTTLE_UNKNOWN_SOURCE,
|
||||
)
|
||||
from litellm.proxy._types import ProxyErrorTypes, ProxyException
|
||||
from litellm.proxy.auth.network import TrustedProxyConfig, resolve_client_ip
|
||||
from litellm.secret_managers.main import get_secret_bool
|
||||
|
||||
DEFAULT_MAX_FAILED_LOGIN_ATTEMPTS_PER_SOURCE: Final = 10
|
||||
DEFAULT_FAILED_LOGIN_WINDOW_SECONDS: Final = 60
|
||||
DEFAULT_FAILED_LOGIN_BLOCK_SECONDS: Final = 300
|
||||
|
||||
IPV6_SOURCE_PREFIX_LENGTH: Final = 64
|
||||
EXEMPT: Final = 0
|
||||
|
||||
SOURCE_LIMIT_KEY: Final = "max_failed_login_attempts_per_source"
|
||||
SOURCE_LIMIT_OVERRIDES_KEY: Final = "max_failed_login_attempts_per_source_overrides"
|
||||
WINDOW_KEY: Final = "failed_login_window_seconds"
|
||||
BLOCK_KEY: Final = "failed_login_block_seconds"
|
||||
TRUSTED_PROXY_RANGES_KEY: Final = "trusted_proxy_ranges"
|
||||
|
||||
_REDIS_FAILURES: Final = (RedisError, RedisCircuitBreakerOpenError, OSError, asyncio.TimeoutError)
|
||||
_LOCAL_BLOCK_EXPIRY: Final = TypeAdapter[float | None](float | None)
|
||||
_SOURCE_LIMIT_OVERRIDES: Final = TypeAdapter[Mapping[str, object]](Mapping[str, object])
|
||||
_RANGE_ENTRIES: Final = TypeAdapter[tuple[object, ...]](tuple[object, ...])
|
||||
|
||||
Scope: TypeAlias = Literal["user", "source"]
|
||||
|
||||
_BlockTtls: TypeAlias = tuple[int, int]
|
||||
_LUA_BLOCK_TTLS: Final = TypeAdapter[_BlockTtls](_BlockTtls)
|
||||
_Network: TypeAlias = ipaddress.IPv4Network | ipaddress.IPv6Network
|
||||
|
||||
|
||||
class LocalStore(Protocol):
|
||||
"""The per-worker store behind the counters and blocks; ``InMemoryCache`` satisfies it."""
|
||||
|
||||
def get_cache(self, key: str) -> object: ...
|
||||
|
||||
def set_cache(self, key: str, value: float, *, ttl: int) -> None: ...
|
||||
|
||||
def increment_cache(self, key: str, value: float, *, ttl: int) -> float: ...
|
||||
|
||||
def delete_cache(self, key: str) -> None: ...
|
||||
|
||||
|
||||
# KEYS: pair counter, pair block, source counter, source block (one cluster slot via the source hash tag)
|
||||
# ARGV: pair limit, source limit (0 = source scope off), window seconds, block seconds
|
||||
# Both scripts return {pair block TTL, source block TTL}; 0 or below means not blocked
|
||||
_BLOCK_TTLS_LUA: Final = "return {redis.call('TTL', KEYS[2]), redis.call('TTL', KEYS[4])}"
|
||||
_RECORD_FAILURE_LUA: Final = (
|
||||
"local function bump(count_key, block_key, limit) "
|
||||
"local blocked = redis.call('TTL', block_key) "
|
||||
"if blocked > 0 then return blocked end "
|
||||
"local count = redis.call('INCR', count_key) "
|
||||
"if redis.call('TTL', count_key) < 0 then redis.call('EXPIRE', count_key, ARGV[3]) end "
|
||||
"if count > limit then redis.call('SET', block_key, '1', 'EX', ARGV[4]) return tonumber(ARGV[4]) end "
|
||||
"return 0 end "
|
||||
"local user_block = bump(KEYS[1], KEYS[2], tonumber(ARGV[1])) "
|
||||
"local source_block = 0 "
|
||||
"if tonumber(ARGV[2]) > 0 and user_block == 0 then "
|
||||
"source_block = bump(KEYS[3], KEYS[4], tonumber(ARGV[2])) end "
|
||||
"return {user_block, source_block}"
|
||||
)
|
||||
|
||||
_COUNTERS: Final = InMemoryCache(
|
||||
max_size_in_memory=LOGIN_THROTTLE_MAX_TRACKED_COUNTERS, default_ttl=DEFAULT_FAILED_LOGIN_WINDOW_SECONDS
|
||||
)
|
||||
_BLOCKS: Final = InMemoryCache(
|
||||
max_size_in_memory=LOGIN_THROTTLE_MAX_TRACKED_BLOCKS, default_ttl=DEFAULT_FAILED_LOGIN_BLOCK_SECONDS
|
||||
)
|
||||
|
||||
|
||||
@cache
|
||||
def _rate_limit_disabled() -> bool:
|
||||
return get_secret_bool("LITELLM_DISABLE_LOGIN_RATE_LIMIT", default_value=False) is True
|
||||
|
||||
|
||||
@cache
|
||||
def warn_login_counters_are_per_worker(num_workers: str) -> None:
|
||||
verbose_proxy_logger.warning(
|
||||
"Running %s workers but Redis is not configured. Failed Admin UI sign-in attempts are counted "
|
||||
"per worker, so the effective limits are %s times the configured values. Configure Redis "
|
||||
"to share one count across workers.",
|
||||
num_workers,
|
||||
num_workers,
|
||||
)
|
||||
|
||||
|
||||
@cache
|
||||
def warn_source_login_limit_is_off() -> None:
|
||||
verbose_proxy_logger.warning(
|
||||
"%s is not set or not a valid list of ranges, so failed Admin UI sign-in attempts are limited per "
|
||||
"source address and username only. Set it to the address ranges of the proxies in front of LiteLLM, "
|
||||
"or to an empty list when clients connect directly, to also limit each source address across usernames.",
|
||||
TRUSTED_PROXY_RANGES_KEY,
|
||||
)
|
||||
|
||||
|
||||
def declared_proxy_ranges(settings: Mapping[str, object]) -> tuple[str, ...] | None:
|
||||
"""What the operator says fronts LiteLLM: the proxy ranges, an empty tuple for none, None when unsaid.
|
||||
|
||||
Only a declared topology makes the source address trustworthy enough to limit across usernames.
|
||||
An unset key, a value that is not a list of ranges, or a list with an entry that is not an address
|
||||
or range leaves it unknown and the source scope off.
|
||||
"""
|
||||
entries: Final = _configured_range_entries(settings.get(TRUSTED_PROXY_RANGES_KEY))
|
||||
if entries is None or any(_parse_network(entry, TRUSTED_PROXY_RANGES_KEY) is None for entry in entries):
|
||||
return None
|
||||
return entries
|
||||
|
||||
|
||||
def _configured_range_entries(raw_ranges: object) -> tuple[str, ...] | None:
|
||||
"""Every configured entry, blanks included, so a stray empty string fails validation like any other typo."""
|
||||
if raw_ranges is None:
|
||||
return None
|
||||
if isinstance(raw_ranges, str):
|
||||
return tuple(part.strip() for part in raw_ranges.split(","))
|
||||
try:
|
||||
return tuple(str(entry).strip() for entry in _RANGE_ENTRIES.validate_python(raw_ranges))
|
||||
except ValidationError:
|
||||
verbose_proxy_logger.warning(
|
||||
"Invalid %s value: expected a list of address ranges, got %s",
|
||||
TRUSTED_PROXY_RANGES_KEY,
|
||||
type(raw_ranges).__name__,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _positive_int(raw: object, key: str, default: int) -> int:
|
||||
if raw is None:
|
||||
return default
|
||||
try:
|
||||
value: Final = int(str(raw))
|
||||
except (TypeError, ValueError):
|
||||
verbose_proxy_logger.warning("Invalid %s value %r; using %s", key, raw, default)
|
||||
return default
|
||||
if value < 1:
|
||||
verbose_proxy_logger.warning("Invalid %s value %s (must be >= 1); using %s", key, value, default)
|
||||
return default
|
||||
return value
|
||||
|
||||
|
||||
def _int_setting(settings: Mapping[str, object], key: str, default: int) -> int:
|
||||
return _positive_int(settings.get(key), key, default)
|
||||
|
||||
|
||||
def _override_limit(raw: object, default: int) -> int:
|
||||
"""A per-address override: a limit of 1 or more, or ``EXEMPT`` (0) to leave that address unlimited."""
|
||||
if str(raw).strip() == str(EXEMPT):
|
||||
return EXEMPT
|
||||
return _positive_int(raw, SOURCE_LIMIT_OVERRIDES_KEY, default)
|
||||
|
||||
|
||||
def _parse_address(client_ip: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
|
||||
"""The address as it is limited and counted: an IPv4-mapped IPv6 address is its IPv4 address."""
|
||||
try:
|
||||
address: Final = ipaddress.ip_address(client_ip)
|
||||
except ValueError:
|
||||
return None
|
||||
if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped is not None:
|
||||
return address.ipv4_mapped
|
||||
return address
|
||||
|
||||
|
||||
def _parse_network(raw_range: str, setting_name: str = SOURCE_LIMIT_OVERRIDES_KEY) -> _Network | None:
|
||||
try:
|
||||
return ipaddress.ip_network(raw_range.strip(), strict=False)
|
||||
except ValueError:
|
||||
verbose_proxy_logger.warning("Invalid address or range %r in %s; skipping", raw_range, setting_name)
|
||||
return None
|
||||
|
||||
|
||||
def _precedence(network: _Network, limit: int) -> tuple[int, bool, int]:
|
||||
"""Sort key for competing overrides: the longest prefix wins, then an exemption, then the higher limit."""
|
||||
return (network.prefixlen, limit == EXEMPT, limit)
|
||||
|
||||
|
||||
def _source_limit(settings: Mapping[str, object], client_ip: str) -> int:
|
||||
"""Failure allowance for this address: the most specific configured range containing it, else the default.
|
||||
|
||||
``EXEMPT`` (0) means the operator opted this address out of both limits. Between equivalent keys such as
|
||||
``1.2.3.4`` and ``1.2.3.4/32`` an exemption wins, then the higher limit.
|
||||
"""
|
||||
default: Final = _int_setting(settings, SOURCE_LIMIT_KEY, DEFAULT_MAX_FAILED_LOGIN_ATTEMPTS_PER_SOURCE)
|
||||
raw_overrides: Final = settings.get(SOURCE_LIMIT_OVERRIDES_KEY)
|
||||
if raw_overrides is None:
|
||||
return default
|
||||
try:
|
||||
overrides: Final = _SOURCE_LIMIT_OVERRIDES.validate_python(raw_overrides)
|
||||
except ValidationError:
|
||||
verbose_proxy_logger.warning(
|
||||
"Invalid %s value; expected a mapping of address or range to limit", SOURCE_LIMIT_OVERRIDES_KEY
|
||||
)
|
||||
return default
|
||||
address: Final = _parse_address(client_ip)
|
||||
if address is None:
|
||||
return default
|
||||
matches: Final = sorted(
|
||||
_precedence(network, _override_limit(raw_limit, default))
|
||||
for raw_range, raw_limit in overrides.items()
|
||||
if (network := _parse_network(raw_range)) is not None and address in network
|
||||
)
|
||||
return matches[-1][-1] if matches else default
|
||||
|
||||
|
||||
def user_limit_for(source_limit: int) -> int:
|
||||
"""Failures allowed for one username from one address: half the address allowance, rounded down, at least 1."""
|
||||
return max(source_limit // 2, 1)
|
||||
|
||||
|
||||
def source_group(client_ip: str) -> str:
|
||||
"""The bucket an address is counted in: IPv4 as is, IPv6 by its /64, so one prefix holder cannot rotate."""
|
||||
address: Final = _parse_address(client_ip)
|
||||
if address is None:
|
||||
return client_ip
|
||||
if isinstance(address, ipaddress.IPv6Address):
|
||||
return str(ipaddress.ip_network((address, IPV6_SOURCE_PREFIX_LENGTH), strict=False))
|
||||
return str(address)
|
||||
|
||||
|
||||
class _Keys(NamedTuple):
|
||||
pair_counter: str
|
||||
pair_block: str
|
||||
source_counter: str
|
||||
source_block: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Block:
|
||||
scope: Scope
|
||||
retry_after: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LoginThrottle:
|
||||
"""Failed-login limits for one request's source address.
|
||||
|
||||
``source_limit`` is None when the source scope is off: ``trusted_proxy_ranges`` is unset, so the peer
|
||||
address may be a shared ingress. An empty list means clients connect directly and the peer is the source.
|
||||
``user_limit`` is derived from the address allowance either way, see ``user_limit_for``. An address whose
|
||||
override is ``EXEMPT`` gets a disabled throttle: nothing is counted or blocked for it.
|
||||
"""
|
||||
|
||||
client_ip: str
|
||||
source_limit: int | None
|
||||
user_limit: int
|
||||
window_seconds: int
|
||||
block_seconds: int
|
||||
counters: LocalStore
|
||||
blocks: LocalStore
|
||||
redis_cache: RedisCache | None = None
|
||||
enabled: bool = True
|
||||
|
||||
@classmethod
|
||||
def from_request(
|
||||
cls,
|
||||
request: Request,
|
||||
general_settings: Mapping[str, object] | None,
|
||||
redis_cache: RedisCache | None,
|
||||
) -> LoginThrottle:
|
||||
settings: Final[Mapping[str, object]] = general_settings if general_settings is not None else EMPTY_MAPPING
|
||||
proxies: Final = declared_proxy_ranges(settings)
|
||||
resolved, _ = resolve_client_ip(
|
||||
request, TrustedProxyConfig(use_forwarded_for=bool(proxies), trusted_proxy_cidrs=proxies or ())
|
||||
)
|
||||
source_limit: Final = _source_limit(settings, resolved or LOGIN_THROTTLE_UNKNOWN_SOURCE)
|
||||
exempt: Final = source_limit == EXEMPT
|
||||
return cls(
|
||||
client_ip=resolved or LOGIN_THROTTLE_UNKNOWN_SOURCE,
|
||||
source_limit=source_limit if proxies is not None and resolved is not None and not exempt else None,
|
||||
user_limit=user_limit_for(source_limit),
|
||||
window_seconds=_int_setting(settings, WINDOW_KEY, DEFAULT_FAILED_LOGIN_WINDOW_SECONDS),
|
||||
block_seconds=_int_setting(settings, BLOCK_KEY, DEFAULT_FAILED_LOGIN_BLOCK_SECONDS),
|
||||
counters=_COUNTERS,
|
||||
blocks=_BLOCKS,
|
||||
redis_cache=redis_cache,
|
||||
enabled=not exempt and not _rate_limit_disabled(),
|
||||
)
|
||||
|
||||
def _keys(self, username: str) -> _Keys:
|
||||
group: Final = source_group(self.client_ip)
|
||||
user: Final = hashlib.sha256(username.casefold().encode("utf-8")).hexdigest()
|
||||
return _Keys(
|
||||
pair_counter=f"{LOGIN_THROTTLE_CACHE_KEY_PREFIX}:{{{group}}}:user:{user}",
|
||||
pair_block=f"{LOGIN_THROTTLE_CACHE_KEY_PREFIX}:{{{group}}}:block:user:{user}",
|
||||
source_counter=f"{LOGIN_THROTTLE_CACHE_KEY_PREFIX}:{{{group}}}:source",
|
||||
source_block=f"{LOGIN_THROTTLE_CACHE_KEY_PREFIX}:{{{group}}}:block:source",
|
||||
)
|
||||
|
||||
async def attempt(self, username: str) -> LoginAttempt:
|
||||
"""Refuses a blocked key before any credential is looked at; otherwise hands back the attempt to settle."""
|
||||
if not self.enabled:
|
||||
return LoginAttempt(throttle=self, username=username)
|
||||
block: Final = await self._active_block(self._keys(username))
|
||||
if block is None:
|
||||
return LoginAttempt(throttle=self, username=username)
|
||||
verbose_proxy_logger.warning(
|
||||
"Admin UI sign-in refused: the %s is blocked for %s more seconds; username=%r source=%s",
|
||||
block.scope,
|
||||
block.retry_after,
|
||||
username,
|
||||
self.client_ip,
|
||||
)
|
||||
raise self.refused(block.retry_after)
|
||||
|
||||
async def _active_block(self, keys: _Keys) -> Block | None:
|
||||
local: Final = self._local_block_ttls(keys)
|
||||
shared: Final = await self._shared_block_ttls(keys)
|
||||
user_ttl: Final = max(local[0], shared[0])
|
||||
source_ttl: Final = max(local[1], shared[1])
|
||||
if self.source_limit is not None and source_ttl > 0:
|
||||
return Block(scope="source", retry_after=source_ttl)
|
||||
if user_ttl > 0:
|
||||
return Block(scope="user", retry_after=user_ttl)
|
||||
return None
|
||||
|
||||
async def _shared_block_ttls(self, keys: _Keys) -> _BlockTtls:
|
||||
if self.redis_cache is None:
|
||||
return LOGIN_THROTTLE_NOT_BLOCKED
|
||||
try:
|
||||
return _LUA_BLOCK_TTLS.validate_python(
|
||||
await self.redis_cache.async_register_script(_BLOCK_TTLS_LUA)(keys, ())
|
||||
)
|
||||
except _REDIS_FAILURES as err:
|
||||
self._warn_redis(err)
|
||||
return LOGIN_THROTTLE_NOT_BLOCKED
|
||||
|
||||
def _local_block_ttls(self, keys: _Keys) -> _BlockTtls:
|
||||
return self._local_block_ttl(keys.pair_block), self._local_block_ttl(keys.source_block)
|
||||
|
||||
def _local_block_ttl(self, block_key: str) -> int:
|
||||
expires_at: Final = _LOCAL_BLOCK_EXPIRY.validate_python(self.blocks.get_cache(block_key))
|
||||
if expires_at is None:
|
||||
return 0
|
||||
return max(math.ceil(expires_at - time.time()), 0)
|
||||
|
||||
async def record_failure(self, username: str) -> _BlockTtls:
|
||||
keys: Final = self._keys(username)
|
||||
source_limit: Final = self.source_limit or 0
|
||||
if self.redis_cache is not None:
|
||||
try:
|
||||
return _LUA_BLOCK_TTLS.validate_python(
|
||||
await self.redis_cache.async_register_script(_RECORD_FAILURE_LUA)(
|
||||
keys, (self.user_limit, source_limit, self.window_seconds, self.block_seconds)
|
||||
)
|
||||
)
|
||||
except _REDIS_FAILURES as err:
|
||||
self._warn_redis(err)
|
||||
user_block: Final = self._local_bump(keys.pair_counter, keys.pair_block, self.user_limit)
|
||||
if source_limit == 0 or user_block > 0:
|
||||
return user_block, 0
|
||||
return user_block, self._local_bump(keys.source_counter, keys.source_block, source_limit)
|
||||
|
||||
def _local_bump(self, count_key: str, block_key: str, limit: int) -> int:
|
||||
blocked: Final = self._local_block_ttl(block_key)
|
||||
if blocked > 0:
|
||||
return blocked
|
||||
count: Final = int(self.counters.increment_cache(count_key, 1, ttl=self.window_seconds))
|
||||
if count <= limit:
|
||||
return 0
|
||||
self.blocks.set_cache(block_key, time.time() + self.block_seconds, ttl=self.block_seconds)
|
||||
return self.block_seconds
|
||||
|
||||
async def clear_pair(self, username: str) -> None:
|
||||
pair_counter: Final = self._keys(username).pair_counter
|
||||
if self.redis_cache is not None:
|
||||
try:
|
||||
await self.redis_cache.async_delete_cache(pair_counter)
|
||||
except _REDIS_FAILURES as err:
|
||||
self._warn_redis(err)
|
||||
self.counters.delete_cache(pair_counter)
|
||||
|
||||
def _warn_redis(self, err: Exception) -> None:
|
||||
verbose_proxy_logger.warning(
|
||||
"Redis failed while counting Admin UI sign-in attempts; using this worker's own counters "
|
||||
"until it recovers: %s",
|
||||
err,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def refused(retry_after: int) -> ProxyException:
|
||||
return ProxyException(
|
||||
message="Too many failed sign-in attempts. Try again later.",
|
||||
type=ProxyErrorTypes.auth_error,
|
||||
param="username",
|
||||
code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
headers={"Retry-After": str(retry_after)}, # mutable-ok: ProxyException writes into its headers dict
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LoginAttempt:
|
||||
throttle: LoginThrottle
|
||||
username: str
|
||||
|
||||
async def succeeded(self) -> None:
|
||||
if not self.throttle.enabled:
|
||||
return
|
||||
await self.throttle.clear_pair(self.username)
|
||||
|
||||
async def failed(self) -> None:
|
||||
if not self.throttle.enabled:
|
||||
return
|
||||
user_block, source_block = await self.throttle.record_failure(self.username)
|
||||
if user_block == 0 and source_block == 0:
|
||||
return
|
||||
verbose_proxy_logger.warning(
|
||||
"Admin UI sign-in blocked for %s seconds after too many failures; scope=%s username=%r source=%s",
|
||||
user_block or source_block,
|
||||
"user" if user_block else "source",
|
||||
self.username,
|
||||
self.throttle.client_ip,
|
||||
)
|
||||
|
|
@ -27,6 +27,7 @@ from litellm.proxy._types import (
|
|||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.auth.auth_utils import is_sso_provider_fully_configured
|
||||
from litellm.proxy.auth.login_throttle import LoginAttempt, LoginThrottle
|
||||
from litellm.proxy.management_endpoints.internal_user_endpoints import user_update
|
||||
from litellm.proxy.management_endpoints.key_management_endpoints import (
|
||||
generate_key_helper_fn,
|
||||
|
|
@ -44,6 +45,11 @@ from litellm.repositories.user_repository import UserRepository
|
|||
from litellm.secret_managers.main import get_secret_bool
|
||||
from litellm.types.proxy.ui_sso import ReturnedUITokenObject
|
||||
|
||||
INVALID_UI_CREDENTIALS_MESSAGE: Final = (
|
||||
"Invalid credentials used to access UI. Check 'UI_USERNAME' and 'UI_PASSWORD', or the password set for your user"
|
||||
)
|
||||
INVALID_USER_PASSWORD_MESSAGE: Final = "Invalid credentials used to access UI. Check the password set for your user"
|
||||
|
||||
|
||||
async def _rehash_password_if_needed(user_id: str, password: str, stored: str) -> None:
|
||||
"""Rehash legacy password (SHA256) to scrypt on successful login."""
|
||||
|
|
@ -92,6 +98,21 @@ def _matches_env_credentials(username: str, password: str, master_key: str | Non
|
|||
)
|
||||
|
||||
|
||||
def _admin_credentials_match(
|
||||
username: str, password: str, master_key: str, general_settings: Mapping[str, object]
|
||||
) -> bool:
|
||||
return general_settings.get("disable_env_credential_login") is not True and _matches_env_credentials(
|
||||
username, password, master_key
|
||||
)
|
||||
|
||||
|
||||
def _invalid_credentials_message(general_settings: Mapping[str, object]) -> str:
|
||||
"""One rejection message for unknown usernames and wrong passwords alike, so neither can be enumerated."""
|
||||
if is_env_credential_login_enabled(general_settings):
|
||||
return INVALID_UI_CREDENTIALS_MESSAGE
|
||||
return INVALID_USER_PASSWORD_MESSAGE
|
||||
|
||||
|
||||
def is_env_credential_login_enabled(general_settings: Mapping[str, object]) -> bool:
|
||||
"""Whether a login with UI_USERNAME/UI_PASSWORD (or the master-key fallback) can succeed.
|
||||
|
||||
|
|
@ -137,6 +158,7 @@ async def authenticate_user(
|
|||
password: str,
|
||||
master_key: str | None,
|
||||
prisma_client: PrismaClient | None,
|
||||
throttle: LoginThrottle,
|
||||
general_settings: Mapping[str, object] = MappingProxyType({}),
|
||||
) -> LoginResult:
|
||||
"""
|
||||
|
|
@ -151,6 +173,7 @@ async def authenticate_user(
|
|||
password: Password from the login form
|
||||
master_key: Master key for the proxy (required)
|
||||
prisma_client: Prisma database client (optional)
|
||||
throttle: Failed sign-in accounting for this request's source address
|
||||
general_settings: Proxy general_settings, checked for
|
||||
`disable_password_login_when_sso_enabled` and
|
||||
`disable_env_credential_login`
|
||||
|
|
@ -163,9 +186,11 @@ async def authenticate_user(
|
|||
or if username/password login is disabled while SSO is configured
|
||||
|
||||
Recovery: an admin locked out of the UI by
|
||||
`disable_password_login_when_sso_enabled` can still administer the proxy over
|
||||
the API with the master key (Authorization: Bearer <master_key>), which never
|
||||
goes through this function. To restore UI username/password login, unset the
|
||||
`disable_password_login_when_sso_enabled`, or by the failed sign-in block in
|
||||
`throttle`, can still administer the proxy over the API with the master key
|
||||
(Authorization: Bearer <master_key>), which never goes through this function.
|
||||
No credential, the env admin credentials and the master key included, is
|
||||
exempt from the block. To restore UI username/password login, unset the
|
||||
setting in config.yaml (or the DB-persisted general_settings) and restart the
|
||||
proxy; this is a deliberate, auditable config change rather than a hidden
|
||||
bypass.
|
||||
|
|
@ -194,6 +219,19 @@ async def authenticate_user(
|
|||
code=500,
|
||||
)
|
||||
|
||||
attempt: Final = await throttle.attempt(username)
|
||||
return await _sign_in(username, password, master_key, prisma_client, attempt, general_settings)
|
||||
|
||||
|
||||
async def _sign_in(
|
||||
username: str,
|
||||
password: str,
|
||||
master_key: str,
|
||||
prisma_client: PrismaClient | None,
|
||||
attempt: LoginAttempt,
|
||||
general_settings: Mapping[str, object],
|
||||
) -> LoginResult:
|
||||
admin_credentials_match: Final = _admin_credentials_match(username, password, master_key, general_settings)
|
||||
# Check if we can find the `username` in the db. On the UI, users can enter username=their email
|
||||
_user_row: LiteLLM_UserTable | None = None
|
||||
user_role: (
|
||||
|
|
@ -219,20 +257,13 @@ async def authenticate_user(
|
|||
- Login with UI_USERNAME and UI_PASSWORD
|
||||
- Login with Invite Link `user_email` and `password` combination
|
||||
"""
|
||||
if general_settings.get("disable_env_credential_login") is not True and _matches_env_credentials(
|
||||
username, password, master_key
|
||||
):
|
||||
if admin_credentials_match:
|
||||
# Non SSO -> If user is using UI_USERNAME and UI_PASSWORD they are Proxy admin
|
||||
user_role = LitellmUserRoles.PROXY_ADMIN
|
||||
user_id = LITELLM_PROXY_ADMIN_NAME
|
||||
|
||||
# we want the key created to have PROXY_ADMIN_PERMISSIONS
|
||||
key_user_id = LITELLM_PROXY_ADMIN_NAME
|
||||
if (
|
||||
os.getenv("PROXY_ADMIN_ID", None) is not None and os.environ["PROXY_ADMIN_ID"] == user_id
|
||||
) or user_id == LITELLM_PROXY_ADMIN_NAME:
|
||||
# checks if user is admin
|
||||
key_user_id = os.getenv("PROXY_ADMIN_ID", LITELLM_PROXY_ADMIN_NAME)
|
||||
key_user_id: Final = os.getenv("PROXY_ADMIN_ID", LITELLM_PROXY_ADMIN_NAME)
|
||||
|
||||
# Admin is Authe'd in - generate key for the UI to access Proxy
|
||||
|
||||
|
|
@ -294,6 +325,8 @@ async def authenticate_user(
|
|||
|
||||
key = ExperimentalUIJWTToken.get_experimental_ui_login_jwt_auth_token(user_info)
|
||||
|
||||
await attempt.succeeded()
|
||||
|
||||
return LoginResult(
|
||||
user_id=user_id,
|
||||
key=key,
|
||||
|
|
@ -349,6 +382,8 @@ async def authenticate_user(
|
|||
|
||||
key = response["token"]
|
||||
|
||||
await attempt.succeeded()
|
||||
|
||||
return LoginResult(
|
||||
user_id=user_id,
|
||||
key=key,
|
||||
|
|
@ -357,20 +392,17 @@ async def authenticate_user(
|
|||
login_method="username_password",
|
||||
)
|
||||
else:
|
||||
await attempt.failed()
|
||||
raise ProxyException(
|
||||
message=f"Invalid credentials used to access UI.\nNot valid credentials for {username}",
|
||||
message=_invalid_credentials_message(general_settings),
|
||||
type=ProxyErrorTypes.auth_error,
|
||||
param="invalid_credentials",
|
||||
code=401,
|
||||
)
|
||||
else:
|
||||
env_credentials_hint: Final = (
|
||||
"\nCheck 'UI_USERNAME', 'UI_PASSWORD' in .env file"
|
||||
if is_env_credential_login_enabled(general_settings)
|
||||
else ""
|
||||
)
|
||||
await attempt.failed()
|
||||
raise ProxyException(
|
||||
message=f"Invalid credentials used to access UI.{env_credentials_hint}",
|
||||
message=_invalid_credentials_message(general_settings),
|
||||
type=ProxyErrorTypes.auth_error,
|
||||
param="invalid_credentials",
|
||||
code=401,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
from collections.abc import Sequence
|
||||
from typing import Any, Final
|
||||
|
||||
from fastapi import Request
|
||||
|
|
@ -19,7 +20,7 @@ class NetworkContext(BaseModel):
|
|||
|
||||
class TrustedProxyConfig(BaseModel):
|
||||
use_forwarded_for: bool = False
|
||||
trusted_proxy_cidrs: list[str] = Field(default_factory=list)
|
||||
trusted_proxy_cidrs: Sequence[str] = Field(default_factory=tuple)
|
||||
|
||||
|
||||
def normalize_cidr_ranges(configured_ranges: Any, *, setting_name: str = "trusted_proxy_cidrs") -> list[str]:
|
||||
|
|
@ -49,6 +50,12 @@ def parse_trusted_proxy_ranges(
|
|||
return networks
|
||||
|
||||
|
||||
def _unmapped(addr: ipaddress.IPv4Address | ipaddress.IPv6Address) -> ipaddress.IPv4Address | ipaddress.IPv6Address:
|
||||
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
|
||||
return addr.ipv4_mapped
|
||||
return addr
|
||||
|
||||
|
||||
def ip_in_networks(client_ip: str | None, networks: list[TrustedProxyNetwork]) -> bool:
|
||||
if not client_ip or not networks:
|
||||
return False
|
||||
|
|
@ -56,7 +63,8 @@ def ip_in_networks(client_ip: str | None, networks: list[TrustedProxyNetwork]) -
|
|||
addr: Final = ipaddress.ip_address(client_ip.strip())
|
||||
except ValueError:
|
||||
return False
|
||||
return any(addr in network for network in networks)
|
||||
candidates: Final = (addr, _unmapped(addr))
|
||||
return any(candidate in network for candidate in candidates for network in networks)
|
||||
|
||||
|
||||
def _is_valid_ip(value: str) -> bool:
|
||||
|
|
|
|||
|
|
@ -87,6 +87,12 @@ class SettingsStore(MutableMapping[str, JsonValue]):
|
|||
)
|
||||
self._deleted_runtime_keys = self._deleted_runtime_keys | frozenset((key,))
|
||||
|
||||
def clear(self) -> None:
|
||||
self._deleted_runtime_keys = frozenset(key for key in self._keys() if not self.owned_by_config(key))
|
||||
self._runtime_values = MappingProxyType(
|
||||
{key: value for key, value in self._runtime_values.items() if self.owned_by_config(key)}
|
||||
)
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
return iter(
|
||||
key
|
||||
|
|
@ -98,10 +104,6 @@ class SettingsStore(MutableMapping[str, JsonValue]):
|
|||
def __len__(self) -> int:
|
||||
return sum(1 for _ in self)
|
||||
|
||||
def clear(self) -> None:
|
||||
self._runtime_values = _EMPTY_VALUES
|
||||
self._deleted_runtime_keys = frozenset(key for key in self._keys() if not self.owned_by_config(key))
|
||||
|
||||
def _clear_runtime(self) -> None:
|
||||
self._runtime_values = _EMPTY_VALUES
|
||||
self._deleted_runtime_keys = frozenset()
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from fastapi import HTTPException, status
|
|||
from typing_extensions import ReadOnly, TypedDict
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.constants import PTU_SENTINEL_API_KEY
|
||||
from litellm.constants import PTU_SENTINEL_API_KEY, USAGE_TOP_API_KEYS_LIMIT
|
||||
from litellm.proxy._types import CommonProxyErrors
|
||||
from litellm.proxy.spend_tracking.key_metadata_recovery import (
|
||||
attach_user_emails,
|
||||
|
|
@ -146,15 +146,9 @@ class _AggregatedSpendData(TypedDict):
|
|||
totals: SpendMetrics
|
||||
|
||||
|
||||
class _GroupingSetsRow(SimpleNamespace):
|
||||
class _RollupMetricsRow(SimpleNamespace):
|
||||
date: str
|
||||
api_key: str | None
|
||||
model: str | None
|
||||
model_group: str | None
|
||||
custom_llm_provider: str | None
|
||||
mcp_namespaced_tool_name: str | None
|
||||
endpoint: str | None
|
||||
group_level: int
|
||||
spend: float | None
|
||||
prompt_tokens: int | None
|
||||
completion_tokens: int | None
|
||||
|
|
@ -172,12 +166,46 @@ class _GroupingSetsRow(SimpleNamespace):
|
|||
timed_requests: int | None
|
||||
|
||||
|
||||
class _EntityRollupRow(_GroupingSetsRow):
|
||||
class _GroupingSetsRow(_RollupMetricsRow):
|
||||
model: str | None
|
||||
model_group: str | None
|
||||
custom_llm_provider: str | None
|
||||
mcp_namespaced_tool_name: str | None
|
||||
endpoint: str | None
|
||||
group_level: int
|
||||
distinct_api_keys: int | None
|
||||
|
||||
|
||||
class _EntityRollupRow(_RollupMetricsRow):
|
||||
entity_id: str | None
|
||||
api_key_rolled: int
|
||||
|
||||
|
||||
def _reported_flat_cost(record: DailySpendRecord | _GroupingSetsRow) -> float:
|
||||
class _AggregatedQueryKwargs(TypedDict):
|
||||
table_name: ReadOnly[str]
|
||||
entity_id_field: ReadOnly[str]
|
||||
entity_id: ReadOnly[str | list[str] | None]
|
||||
start_date: ReadOnly[str]
|
||||
end_date: ReadOnly[str]
|
||||
model: ReadOnly[str | None]
|
||||
api_key: ReadOnly[str | list[str] | None]
|
||||
exclude_entity_ids: ReadOnly[list[str] | None]
|
||||
timezone_offset_minutes: ReadOnly[int | None]
|
||||
include_current_utc_day: ReadOnly[bool]
|
||||
|
||||
|
||||
_SqlQuery = tuple[str, list[str]]
|
||||
|
||||
|
||||
async def _query_raw_optional(
|
||||
prisma_client: PrismaClient, query: _SqlQuery | None
|
||||
) -> list[dict[str, object]] | None: # mutable-ok: prisma query_raw return shape
|
||||
if query is None:
|
||||
return None
|
||||
return await prisma_client.db.query_raw(query[0], *query[1])
|
||||
|
||||
|
||||
def _reported_flat_cost(record: DailySpendRecord | _RollupMetricsRow) -> float:
|
||||
"""Flat cost a daily row reports, which is zero unless PTU cost attribution is enabled.
|
||||
|
||||
Both read paths funnel through here: the paginated path reads the ``ptu_flat_cost``
|
||||
|
|
@ -699,71 +727,8 @@ def _ptu_flat_cost_select(table_name: str) -> str:
|
|||
return "0::float AS ptu_flat_cost"
|
||||
|
||||
|
||||
def _build_aggregated_sql_query(
|
||||
*,
|
||||
table_name: str,
|
||||
entity_id_field: str,
|
||||
entity_id: str | list[str] | None, # mutable-ok: filter union shared with the paginated path
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
model: str | None,
|
||||
api_key: str | list[str] | None, # mutable-ok: filter union shared with the paginated path
|
||||
exclude_entity_ids: list[str] | None = None, # mutable-ok: filter union shared with the paginated path
|
||||
timezone_offset_minutes: int | None = None,
|
||||
include_current_utc_day: bool = False,
|
||||
) -> tuple[str, list[str]]: # mutable-ok: SQL text plus its ordered $N params
|
||||
"""Build a parameterized SQL GROUP BY query for aggregated daily activity.
|
||||
|
||||
Groups by (date, api_key, model, model_group, custom_llm_provider,
|
||||
mcp_namespaced_tool_name, endpoint) with SUMs on all metric columns.
|
||||
The entity_id column is intentionally omitted from GROUP BY to collapse
|
||||
rows across entities — this is where the biggest row reduction comes from.
|
||||
|
||||
Returns:
|
||||
Tuple of (sql_query, params_list) ready for prisma_client.db.query_raw().
|
||||
"""
|
||||
pg_table: Final = _PRISMA_TO_PG_TABLE.get(table_name)
|
||||
if pg_table is None:
|
||||
raise ValueError(f"Unknown table name: {table_name}")
|
||||
|
||||
adjusted_start, adjusted_end = _adjust_dates_for_timezone(
|
||||
start_date, end_date, timezone_offset_minutes, include_current_utc_day
|
||||
)
|
||||
|
||||
where_clause, sql_params = _build_aggregated_where_clause(
|
||||
entity_id_field=entity_id_field,
|
||||
entity_id=entity_id,
|
||||
adjusted_start=adjusted_start,
|
||||
adjusted_end=adjusted_end,
|
||||
model=model,
|
||||
api_key=api_key,
|
||||
exclude_entity_ids=exclude_entity_ids,
|
||||
)
|
||||
|
||||
# Postgres computes every rollup level the response needs — per-date
|
||||
# totals, per-(date, model), per-(date, model, api_key), per-provider,
|
||||
# etc. — in a single pass via GROUPING SETS. The GROUPING() bitmask
|
||||
# encodes which level a row belongs to so Python can dispatch rows
|
||||
# straight into their buckets without re-summing. The leaf grouping
|
||||
# is omitted on purpose: nothing in the response shape needs it once
|
||||
# all the rollups are present.
|
||||
#
|
||||
# TODO: drop the successful_requests/failed_requests aggregates (and the
|
||||
# total_successful_requests metadata they feed) once the admin UI reads SGR
|
||||
# only from LiteLLM_DailyGatewayRequests. The remaining spend, token and
|
||||
# api_requests rollups are still served from here.
|
||||
sql_query: Final = f"""
|
||||
SELECT
|
||||
date,
|
||||
api_key,
|
||||
model,
|
||||
COALESCE(NULLIF(model_group, ''), model) AS model_group,
|
||||
custom_llm_provider,
|
||||
mcp_namespaced_tool_name,
|
||||
endpoint,
|
||||
GROUPING(date, api_key, model, COALESCE(NULLIF(model_group, ''), model),
|
||||
custom_llm_provider, mcp_namespaced_tool_name,
|
||||
endpoint) AS group_level,
|
||||
def _rollup_metric_select(table_name: str) -> str:
|
||||
return f"""
|
||||
SUM(spend)::float AS spend,
|
||||
{_ptu_flat_cost_select(table_name)},
|
||||
SUM(prompt_tokens)::bigint AS prompt_tokens,
|
||||
|
|
@ -779,27 +744,113 @@ def _build_aggregated_sql_query(
|
|||
SUM(successful_requests)::bigint AS successful_requests,
|
||||
SUM(failed_requests)::bigint AS failed_requests,
|
||||
SUM(total_response_time_ms)::bigint AS total_response_time_ms,
|
||||
SUM(timed_requests)::bigint AS timed_requests
|
||||
SUM(timed_requests)::bigint AS timed_requests"""
|
||||
|
||||
|
||||
_MODEL_GROUP_EXPR: Final = "COALESCE(NULLIF(model_group, ''), model)"
|
||||
|
||||
|
||||
def _build_aggregated_sql_query(
|
||||
*,
|
||||
table_name: str,
|
||||
entity_id_field: str,
|
||||
entity_id: str | list[str] | None, # mutable-ok: filter union shared with the paginated path
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
model: str | None,
|
||||
api_key: str | list[str] | None, # mutable-ok: filter union shared with the paginated path
|
||||
exclude_entity_ids: list[str] | None = None, # mutable-ok: filter union shared with the paginated path
|
||||
timezone_offset_minutes: int | None = None,
|
||||
include_current_utc_day: bool = False,
|
||||
) -> tuple[str, list[str]]: # mutable-ok: SQL text plus its ordered $N params
|
||||
"""Build the GROUPING SETS query for aggregated daily activity.
|
||||
|
||||
Returns:
|
||||
Tuple of (sql_query, params_list) ready for prisma_client.db.query_raw().
|
||||
"""
|
||||
pg_table: Final = _PRISMA_TO_PG_TABLE.get(table_name)
|
||||
if pg_table is None:
|
||||
raise ValueError(f"Unknown table name: {table_name}")
|
||||
|
||||
adjusted_start, adjusted_end = _adjust_dates_for_timezone(
|
||||
start_date, end_date, timezone_offset_minutes, include_current_utc_day
|
||||
)
|
||||
|
||||
where_clause, where_params = _build_aggregated_where_clause(
|
||||
entity_id_field=entity_id_field,
|
||||
entity_id=entity_id,
|
||||
adjusted_start=adjusted_start,
|
||||
adjusted_end=adjusted_end,
|
||||
model=model,
|
||||
api_key=api_key,
|
||||
exclude_entity_ids=exclude_entity_ids,
|
||||
)
|
||||
sentinel_param: Final = f"${len(where_params) + 1}"
|
||||
metric_select: Final = _rollup_metric_select(table_name)
|
||||
|
||||
# TODO: drop the successful_requests/failed_requests aggregates (and the
|
||||
# total_successful_requests metadata they feed) once the admin UI reads SGR
|
||||
# only from LiteLLM_DailyGatewayRequests. The remaining spend, token and
|
||||
# api_requests rollups are still served from here.
|
||||
sql_query: Final = f"""
|
||||
(SELECT
|
||||
date,
|
||||
NULL::text AS api_key,
|
||||
model,
|
||||
{_MODEL_GROUP_EXPR} AS model_group,
|
||||
custom_llm_provider,
|
||||
mcp_namespaced_tool_name,
|
||||
endpoint,
|
||||
(GROUPING(date) << 6) | {_API_KEY_ROLLED_UP_BIT}
|
||||
| GROUPING(model, {_MODEL_GROUP_EXPR},
|
||||
custom_llm_provider, mcp_namespaced_tool_name,
|
||||
endpoint) AS group_level,
|
||||
NULL::bigint AS distinct_api_keys,{metric_select}
|
||||
FROM "{pg_table}"
|
||||
WHERE {where_clause}
|
||||
GROUP BY GROUPING SETS (
|
||||
(date),
|
||||
(date, api_key),
|
||||
(date, model),
|
||||
(date, model, api_key),
|
||||
(date, COALESCE(NULLIF(model_group, ''), model)),
|
||||
(date, COALESCE(NULLIF(model_group, ''), model), api_key),
|
||||
(date, {_MODEL_GROUP_EXPR}),
|
||||
(date, custom_llm_provider),
|
||||
(date, custom_llm_provider, api_key),
|
||||
(date, mcp_namespaced_tool_name),
|
||||
(date, mcp_namespaced_tool_name, api_key),
|
||||
(date, endpoint),
|
||||
(date, endpoint, api_key),
|
||||
()
|
||||
))
|
||||
UNION ALL
|
||||
(WITH top_api_keys AS (
|
||||
SELECT api_key, COUNT(*) OVER () AS distinct_api_keys
|
||||
FROM "{pg_table}"
|
||||
WHERE {where_clause} AND api_key <> {sentinel_param}
|
||||
GROUP BY api_key
|
||||
ORDER BY SUM(spend) DESC, api_key
|
||||
LIMIT {USAGE_TOP_API_KEYS_LIMIT}
|
||||
)
|
||||
SELECT
|
||||
date,
|
||||
api_key,
|
||||
model,
|
||||
{_MODEL_GROUP_EXPR} AS model_group,
|
||||
custom_llm_provider,
|
||||
mcp_namespaced_tool_name,
|
||||
endpoint,
|
||||
GROUPING(date, api_key, model, {_MODEL_GROUP_EXPR},
|
||||
custom_llm_provider, mcp_namespaced_tool_name,
|
||||
endpoint) AS group_level,
|
||||
MAX(top_api_keys.distinct_api_keys) AS distinct_api_keys,{metric_select}
|
||||
FROM "{pg_table}" JOIN top_api_keys USING (api_key)
|
||||
WHERE {where_clause}
|
||||
GROUP BY GROUPING SETS (
|
||||
(date, api_key),
|
||||
(date, model, api_key),
|
||||
(date, {_MODEL_GROUP_EXPR}, api_key),
|
||||
(date, custom_llm_provider, api_key),
|
||||
(date, mcp_namespaced_tool_name, api_key),
|
||||
(date, endpoint, api_key)
|
||||
))
|
||||
"""
|
||||
|
||||
return sql_query, sql_params
|
||||
return sql_query, [*where_params, PTU_SENTINEL_API_KEY]
|
||||
|
||||
|
||||
def _build_entity_rollup_sql_query(
|
||||
|
|
@ -844,23 +895,7 @@ def _build_entity_rollup_sql_query(
|
|||
"{entity_id_field}" AS entity_id,
|
||||
date,
|
||||
api_key,
|
||||
GROUPING(api_key) AS api_key_rolled,
|
||||
SUM(spend)::float AS spend,
|
||||
{_ptu_flat_cost_select(table_name)},
|
||||
SUM(prompt_tokens)::bigint AS prompt_tokens,
|
||||
SUM(completion_tokens)::bigint AS completion_tokens,
|
||||
SUM(cache_read_input_tokens)::bigint AS cache_read_input_tokens,
|
||||
SUM(cache_creation_input_tokens)::bigint AS cache_creation_input_tokens,
|
||||
SUM(compression_saved_tokens)::bigint AS compression_saved_tokens,
|
||||
SUM(compression_savings_spend)::float AS compression_savings_spend,
|
||||
SUM(prompt_caching_savings_spend)::float AS prompt_caching_savings_spend,
|
||||
SUM(gateway_injected_caching_savings_spend)::float AS gateway_injected_caching_savings_spend,
|
||||
SUM(autorouter_savings_spend)::float AS autorouter_savings_spend,
|
||||
SUM(api_requests)::bigint AS api_requests,
|
||||
SUM(successful_requests)::bigint AS successful_requests,
|
||||
SUM(failed_requests)::bigint AS failed_requests,
|
||||
SUM(total_response_time_ms)::bigint AS total_response_time_ms,
|
||||
SUM(timed_requests)::bigint AS timed_requests
|
||||
GROUPING(api_key) AS api_key_rolled,{_rollup_metric_select(table_name)}
|
||||
FROM "{pg_table}"
|
||||
WHERE {where_clause}
|
||||
GROUP BY GROUPING SETS (
|
||||
|
|
@ -962,6 +997,7 @@ async def _aggregate_spend_records(
|
|||
# current grouping set's key), 0 when the column is part of the key.
|
||||
_GROUP_GRAND_TOTAL: Final = 127 # 0b1111111 — all rolled up
|
||||
_GROUP_DATE: Final = 63 # 0b0111111 — only date kept
|
||||
_API_KEY_ROLLED_UP_BIT: Final = 32 # 0b0100000
|
||||
_GROUP_DATE_API_KEY: Final = 31 # 0b0011111
|
||||
_GROUP_DATE_MODEL: Final = 47 # 0b0101111
|
||||
_GROUP_DATE_MODEL_API_KEY: Final = 15 # 0b0001111
|
||||
|
|
@ -975,7 +1011,7 @@ _GROUP_DATE_ENDPOINT: Final = 62 # 0b0111110
|
|||
_GROUP_DATE_ENDPOINT_API_KEY: Final = 30 # 0b0011110
|
||||
|
||||
|
||||
def _record_to_spend_metrics(record: _GroupingSetsRow) -> SpendMetrics:
|
||||
def _record_to_spend_metrics(record: _RollupMetricsRow) -> SpendMetrics:
|
||||
"""Build a SpendMetrics directly from one already-aggregated rollup row.
|
||||
|
||||
SUM() over zero rows is SQL NULL, so rollup rows (notably the grand-total
|
||||
|
|
@ -1329,10 +1365,6 @@ async def get_daily_activity_aggregated(
|
|||
) -> SpendAnalyticsPaginatedResponse:
|
||||
"""Aggregated variant that returns the full result set (no pagination).
|
||||
|
||||
Uses SQL GROUP BY to aggregate rows in the database rather than fetching
|
||||
all individual rows into Python. This collapses rows across entities
|
||||
(users/teams/orgs), reducing ~150k rows to ~2-3k grouped rows.
|
||||
|
||||
include_entity_breakdown runs a small companion rollup query and folds
|
||||
`breakdown.entities` onto the response, as entity-scoped views like Team Usage need.
|
||||
|
||||
|
|
@ -1351,7 +1383,7 @@ async def get_daily_activity_aggregated(
|
|||
)
|
||||
|
||||
try:
|
||||
sql_query, sql_params = _build_aggregated_sql_query(
|
||||
query_kwargs: Final = _AggregatedQueryKwargs(
|
||||
table_name=table_name,
|
||||
entity_id_field=entity_id_field,
|
||||
entity_id=entity_id,
|
||||
|
|
@ -1363,36 +1395,16 @@ async def get_daily_activity_aggregated(
|
|||
timezone_offset_minutes=timezone_offset_minutes,
|
||||
include_current_utc_day=include_current_utc_day,
|
||||
)
|
||||
sql_query, sql_params = _build_aggregated_sql_query(**query_kwargs)
|
||||
entity_query: Final = _build_entity_rollup_sql_query(**query_kwargs) if include_entity_breakdown else None
|
||||
|
||||
entity_query: Final = (
|
||||
_build_entity_rollup_sql_query(
|
||||
table_name=table_name,
|
||||
entity_id_field=entity_id_field,
|
||||
entity_id=entity_id,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
model=model,
|
||||
api_key=api_key,
|
||||
exclude_entity_ids=exclude_entity_ids,
|
||||
timezone_offset_minutes=timezone_offset_minutes,
|
||||
include_current_utc_day=include_current_utc_day,
|
||||
)
|
||||
if include_entity_breakdown
|
||||
else None
|
||||
raw_rows, raw_entity_rows = await asyncio.gather(
|
||||
prisma_client.db.query_raw(sql_query, *sql_params),
|
||||
_query_raw_optional(prisma_client, entity_query),
|
||||
)
|
||||
|
||||
# Execute the GROUPING SETS query (one row per rollup level), alongside
|
||||
# the per-entity companion rollup when the caller wants entities.
|
||||
raw_rows, raw_entity_rows = (
|
||||
await asyncio.gather(
|
||||
prisma_client.db.query_raw(sql_query, *sql_params),
|
||||
prisma_client.db.query_raw(entity_query[0], *entity_query[1]),
|
||||
)
|
||||
if entity_query is not None
|
||||
else (await prisma_client.db.query_raw(sql_query, *sql_params), None)
|
||||
)
|
||||
|
||||
records: Final = [_GroupingSetsRow(**row) for row in (raw_rows or [])]
|
||||
records: Final = [_GroupingSetsRow(**row) for row in (raw_rows or ())]
|
||||
total_api_keys: Final = next((r.distinct_api_keys for r in records if r.distinct_api_keys is not None), 0)
|
||||
|
||||
# The grouping-sets dispatcher places each row directly in its bucket
|
||||
# using the row's GROUPING() bitmask. No Python-side summing needed.
|
||||
|
|
@ -1446,6 +1458,8 @@ async def get_daily_activity_aggregated(
|
|||
page=1,
|
||||
total_pages=1,
|
||||
has_more=False,
|
||||
api_key_limit=USAGE_TOP_API_KEYS_LIMIT,
|
||||
total_api_keys=total_api_keys,
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1411,6 +1411,8 @@ def run_server(
|
|||
# DO NOT DELETE - enables global variables to work across files
|
||||
from litellm.proxy.proxy_server import app
|
||||
|
||||
os.environ["NUM_WORKERS"] = str(num_workers)
|
||||
|
||||
# Auto-create PROMETHEUS_MULTIPROC_DIR for multi-worker setups
|
||||
prometheus_multiproc_dir: Final = ProxyInitializationHelpers._maybe_setup_prometheus_multiproc_dir(
|
||||
num_workers=num_workers,
|
||||
|
|
|
|||
|
|
@ -331,6 +331,12 @@ from litellm.proxy.auth.fallback_budget import router_fallback_budget_check
|
|||
from litellm.proxy.auth.fallback_model_access import router_fallback_access_check
|
||||
from litellm.proxy.auth.handle_jwt import JWTHandler
|
||||
from litellm.proxy.auth.litellm_license import AUTO_ROUTER_LICENSE_REMEDY, LicenseCheck
|
||||
from litellm.proxy.auth.login_throttle import (
|
||||
LoginThrottle,
|
||||
declared_proxy_ranges,
|
||||
warn_login_counters_are_per_worker,
|
||||
warn_source_login_limit_is_off,
|
||||
)
|
||||
from litellm.proxy.auth.model_checks import (
|
||||
expand_wildcard_deployments_for_model_info,
|
||||
get_all_fallbacks,
|
||||
|
|
@ -828,6 +834,7 @@ from fastapi.openapi.docs import get_swagger_ui_html
|
|||
from fastapi.openapi.utils import get_openapi
|
||||
from fastapi.responses import (
|
||||
FileResponse,
|
||||
HTMLResponse,
|
||||
JSONResponse,
|
||||
ORJSONResponse,
|
||||
RedirectResponse,
|
||||
|
|
@ -6051,6 +6058,12 @@ class ProxyConfig:
|
|||
general_settings = config.get("general_settings", {})
|
||||
if general_settings is None:
|
||||
general_settings = {}
|
||||
|
||||
if os.getenv("NUM_WORKERS", "1") != "1" and redis_usage_cache is None:
|
||||
warn_login_counters_are_per_worker(os.getenv("NUM_WORKERS", "1"))
|
||||
if declared_proxy_ranges(general_settings) is None:
|
||||
warn_source_login_limit_is_off()
|
||||
|
||||
_bg_hc_model_groups: Final = parse_background_health_check_model_groups(general_settings)
|
||||
_enable_hc_routing = False
|
||||
_hc_staleness = None
|
||||
|
|
@ -15902,8 +15915,6 @@ async def fallback_login(request: Request):
|
|||
else:
|
||||
redirect_url += "/sso/callback"
|
||||
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
hide_default_credentials_hint: Final = should_hide_default_credentials_hint(general_settings)
|
||||
return HTMLResponse(
|
||||
content=build_ui_login_form(
|
||||
|
|
@ -15925,13 +15936,27 @@ async def login(request: Request):
|
|||
password: Final = str(form.get("password"))
|
||||
|
||||
# Authenticate user and get login result
|
||||
login_result: Final = await authenticate_user(
|
||||
username=username,
|
||||
password=password,
|
||||
master_key=master_key,
|
||||
prisma_client=prisma_client,
|
||||
general_settings=general_settings,
|
||||
)
|
||||
try:
|
||||
login_result: Final = await authenticate_user(
|
||||
username=username,
|
||||
password=password,
|
||||
master_key=master_key,
|
||||
prisma_client=prisma_client,
|
||||
throttle=LoginThrottle.from_request(request, general_settings, redis_usage_cache),
|
||||
general_settings=general_settings,
|
||||
)
|
||||
except ProxyException as exc:
|
||||
if int(exc.code) != status.HTTP_429_TOO_MANY_REQUESTS:
|
||||
raise
|
||||
retry_after: Final = exc.headers.get("Retry-After", "30")
|
||||
return HTMLResponse(
|
||||
content=(
|
||||
"<html><body><h1>Too many sign-in attempts</h1>"
|
||||
f"<p>Try again in about {retry_after} seconds</p></body></html>"
|
||||
),
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
headers=exc.headers,
|
||||
)
|
||||
|
||||
# Create UI token object
|
||||
returned_ui_token_object: Final = create_ui_token_object(
|
||||
|
|
@ -16010,6 +16035,7 @@ async def login_v2(request: Request):
|
|||
password=password,
|
||||
master_key=master_key,
|
||||
prisma_client=prisma_client,
|
||||
throttle=LoginThrottle.from_request(request, general_settings, redis_usage_cache),
|
||||
general_settings=general_settings,
|
||||
)
|
||||
|
||||
|
|
@ -16081,6 +16107,7 @@ async def login_v3(request: Request):
|
|||
password=password,
|
||||
master_key=master_key,
|
||||
prisma_client=prisma_client,
|
||||
throttle=LoginThrottle.from_request(request, general_settings, redis_usage_cache),
|
||||
general_settings=general_settings,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -100,6 +100,16 @@ class DailySpendMetadata(BaseModel):
|
|||
page: int = Field(default=1)
|
||||
total_pages: int = Field(default=1)
|
||||
has_more: bool = Field(default=False)
|
||||
api_key_limit: int | None = Field(
|
||||
default=None,
|
||||
description="When set, api_keys and every api_key_breakdown list at most this many keys, "
|
||||
"ranked by spend. Totals and the model, provider, mcp and endpoint rollups still cover every key.",
|
||||
)
|
||||
total_api_keys: int | None = Field(
|
||||
default=None,
|
||||
description="Distinct API keys matching the filters. When this exceeds api_key_limit, the per-key "
|
||||
"lists are truncated to the highest-spend keys.",
|
||||
)
|
||||
|
||||
|
||||
class SpendAnalyticsPaginatedResponse(BaseModel):
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -57,6 +57,21 @@ def test_xff_honored_from_trusted_peer():
|
|||
assert via_proxy is True
|
||||
|
||||
|
||||
def test_ipv4_mapped_peer_and_hop_match_ipv4_trusted_ranges():
|
||||
request = make_request(headers={"x-forwarded-for": "203.0.113.9, ::ffff:10.0.0.5"}, client=("::ffff:10.0.0.1", 1))
|
||||
ip, via_proxy = resolve_client_ip(request, TRUSTED)
|
||||
assert ip == "203.0.113.9"
|
||||
assert via_proxy is True
|
||||
|
||||
|
||||
def test_ipv4_mapped_peer_still_matches_mapped_notation_trusted_range():
|
||||
config = TrustedProxyConfig(use_forwarded_for=True, trusted_proxy_cidrs=["::ffff:10.0.0.0/104"])
|
||||
request = make_request(headers={"x-forwarded-for": "203.0.113.9"}, client=("::ffff:10.0.0.1", 1))
|
||||
ip, via_proxy = resolve_client_ip(request, config)
|
||||
assert ip == "203.0.113.9"
|
||||
assert via_proxy is True
|
||||
|
||||
|
||||
def test_spoofed_xff_from_untrusted_peer_is_ignored():
|
||||
request = make_request(
|
||||
headers={"x-forwarded-for": "203.0.113.9"}, client=("8.8.8.8", 1)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -169,18 +170,50 @@ def test_settings_store_refuses_a_runtime_write_to_a_config_owned_key() -> None:
|
|||
|
||||
|
||||
@pytest.mark.timeout(10)
|
||||
def test_settings_store_clear_terminates_and_keeps_config_owned_keys() -> None:
|
||||
def test_settings_store_clear_removes_every_key_the_config_file_does_not_own() -> None:
|
||||
store: Final = SettingsStore("general_settings")
|
||||
store.load_yaml({"max_parallel_requests": 3})
|
||||
store.apply_db_row("general_settings", {"max_file_size_mb": 9})
|
||||
store["ui_access_mode"] = "admin_only"
|
||||
before: Final = dict(store)
|
||||
store.load_yaml({"master_key": "os.environ/MASTER_KEY"})
|
||||
store.apply_db_row("general_settings", {"max_parallel_requests": 3, "alerting": ["slack"]})
|
||||
store.apply_runtime_values({"master_key": "sk-resolved", "alerting": ["slack"]})
|
||||
store["allow_requests_on_db_unavailable"] = True
|
||||
del store["alerting"]
|
||||
|
||||
store.clear()
|
||||
cleared: Final = dict(store)
|
||||
store.update(before)
|
||||
|
||||
assert cleared == {"max_parallel_requests": 3}
|
||||
assert dict(store) == {"master_key": "sk-resolved"}
|
||||
assert "alerting" not in store
|
||||
with pytest.raises(KeyError):
|
||||
store["max_parallel_requests"]
|
||||
|
||||
|
||||
@pytest.mark.timeout(10)
|
||||
def test_settings_store_clear_then_refill_matches_a_plain_dict() -> None:
|
||||
refilled: Final[dict[str, JsonValue]] = {"alerting": ["email"], "max_parallel_requests": 11}
|
||||
store: Final = SettingsStore("general_settings")
|
||||
store.update({"max_parallel_requests": 3, "alerting": ["slack"]})
|
||||
|
||||
store.clear()
|
||||
store.update(refilled)
|
||||
|
||||
assert dict(store) == refilled
|
||||
assert tuple(store) == tuple(refilled)
|
||||
assert len(store) == len(refilled)
|
||||
|
||||
|
||||
@pytest.mark.timeout(10)
|
||||
@pytest.mark.parametrize("clear", (False, True))
|
||||
def test_settings_store_survives_a_patch_dict_round_trip_when_the_config_file_owns_a_key(clear: bool) -> None:
|
||||
store: Final = SettingsStore("general_settings")
|
||||
store.load_yaml({"master_key": "os.environ/MASTER_KEY"})
|
||||
store.apply_db_row("general_settings", {"max_parallel_requests": 3})
|
||||
store.apply_runtime_values({"master_key": "sk-resolved", "max_parallel_requests": 3})
|
||||
before: Final = dict(store)
|
||||
|
||||
with patch.dict(store, {"allow_requests_on_db_unavailable": True}, clear=clear):
|
||||
assert store["allow_requests_on_db_unavailable"] is True
|
||||
assert store["master_key"] == "sk-resolved"
|
||||
assert ("max_parallel_requests" in store) is not clear
|
||||
|
||||
assert dict(store) == before
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,19 @@
|
|||
import re
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from typing import Final
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import psycopg
|
||||
import pytest
|
||||
from psycopg.rows import dict_row
|
||||
from pytest_postgresql import factories
|
||||
|
||||
from litellm.proxy.spend_tracking.ptu_feature_flag import PTU_COST_ATTRIBUTION_ENV_VAR
|
||||
|
||||
|
||||
from litellm.constants import PTU_SENTINEL_API_KEY, USAGE_TOP_API_KEYS_LIMIT
|
||||
from litellm.proxy.management_endpoints.common_daily_activity import (
|
||||
_adjust_dates_for_timezone,
|
||||
_build_aggregated_sql_query,
|
||||
|
|
@ -169,6 +175,7 @@ async def test_get_daily_activity_aggregated_with_endpoint_breakdown():
|
|||
"endpoint": "/v1/chat/completions",
|
||||
"api_key": None,
|
||||
"group_level": 62,
|
||||
"distinct_api_keys": None,
|
||||
"spend": 15.0,
|
||||
"prompt_tokens": 150,
|
||||
"completion_tokens": 75,
|
||||
|
|
@ -181,31 +188,7 @@ async def test_get_daily_activity_aggregated_with_endpoint_breakdown():
|
|||
"endpoint": "/v1/embeddings",
|
||||
"api_key": None,
|
||||
"group_level": 62,
|
||||
"spend": 3.0,
|
||||
"prompt_tokens": 30,
|
||||
"completion_tokens": 0,
|
||||
"api_requests": 1,
|
||||
"successful_requests": 1,
|
||||
},
|
||||
# (date, endpoint, api_key) — populates the per-key sub-bucket
|
||||
{
|
||||
**base,
|
||||
"date": "2024-01-01",
|
||||
"endpoint": "/v1/chat/completions",
|
||||
"api_key": "key-1",
|
||||
"group_level": 30,
|
||||
"spend": 15.0,
|
||||
"prompt_tokens": 150,
|
||||
"completion_tokens": 75,
|
||||
"api_requests": 2,
|
||||
"successful_requests": 2,
|
||||
},
|
||||
{
|
||||
**base,
|
||||
"date": "2024-01-01",
|
||||
"endpoint": "/v1/embeddings",
|
||||
"api_key": "key-2",
|
||||
"group_level": 30,
|
||||
"distinct_api_keys": None,
|
||||
"spend": 3.0,
|
||||
"prompt_tokens": 30,
|
||||
"completion_tokens": 0,
|
||||
|
|
@ -219,6 +202,7 @@ async def test_get_daily_activity_aggregated_with_endpoint_breakdown():
|
|||
"endpoint": None,
|
||||
"api_key": None,
|
||||
"group_level": 63,
|
||||
"distinct_api_keys": None,
|
||||
"spend": 18.0,
|
||||
"prompt_tokens": 180,
|
||||
"completion_tokens": 75,
|
||||
|
|
@ -232,12 +216,40 @@ async def test_get_daily_activity_aggregated_with_endpoint_breakdown():
|
|||
"endpoint": None,
|
||||
"api_key": None,
|
||||
"group_level": 127,
|
||||
"distinct_api_keys": None,
|
||||
"spend": 18.0,
|
||||
"prompt_tokens": 180,
|
||||
"completion_tokens": 75,
|
||||
"api_requests": 3,
|
||||
"successful_requests": 3,
|
||||
},
|
||||
# (date, endpoint, api_key) — populates the per-key sub-bucket
|
||||
{
|
||||
**base,
|
||||
"date": "2024-01-01",
|
||||
"endpoint": "/v1/chat/completions",
|
||||
"api_key": "key-1",
|
||||
"group_level": 30,
|
||||
"distinct_api_keys": 2,
|
||||
"spend": 15.0,
|
||||
"prompt_tokens": 150,
|
||||
"completion_tokens": 75,
|
||||
"api_requests": 2,
|
||||
"successful_requests": 2,
|
||||
},
|
||||
{
|
||||
**base,
|
||||
"date": "2024-01-01",
|
||||
"endpoint": "/v1/embeddings",
|
||||
"api_key": "key-2",
|
||||
"group_level": 30,
|
||||
"distinct_api_keys": 2,
|
||||
"spend": 3.0,
|
||||
"prompt_tokens": 30,
|
||||
"completion_tokens": 0,
|
||||
"api_requests": 1,
|
||||
"successful_requests": 1,
|
||||
},
|
||||
]
|
||||
|
||||
mock_prisma.db.query_raw = AsyncMock(return_value=mock_rows)
|
||||
|
|
@ -474,9 +486,7 @@ async def test_get_api_key_metadata_recovers_double_hashed_key_via_reverse_hash(
|
|||
return_value=[SimpleNamespace(user_id="alice", user_email="alice@example.com")]
|
||||
)
|
||||
mock_prisma.db.query_raw = AsyncMock(
|
||||
return_value=[
|
||||
{"digest": double_hashed, "key_alias": "batch-worker", "team_id": "team-1", "user_id": "alice"}
|
||||
]
|
||||
return_value=[{"digest": double_hashed, "key_alias": "batch-worker", "team_id": "team-1", "user_id": "alice"}]
|
||||
)
|
||||
|
||||
result = await get_api_key_metadata(
|
||||
|
|
@ -835,6 +845,7 @@ async def test_aggregated_activity_preserves_metadata_for_deleted_keys():
|
|||
"endpoint": "/v1/chat/completions",
|
||||
"api_key": None,
|
||||
"group_level": 62,
|
||||
"distinct_api_keys": None,
|
||||
"spend": 10.0,
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 50,
|
||||
|
|
@ -847,6 +858,7 @@ async def test_aggregated_activity_preserves_metadata_for_deleted_keys():
|
|||
"endpoint": "/v1/chat/completions",
|
||||
"api_key": "deleted-key-hash",
|
||||
"group_level": 30,
|
||||
"distinct_api_keys": 1,
|
||||
"spend": 10.0,
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 50,
|
||||
|
|
@ -1230,42 +1242,11 @@ class TestBuildAggregatedSqlQuery:
|
|||
"user-1",
|
||||
"bedrock/global.anthropic.claude-opus-4-8",
|
||||
"sk-test",
|
||||
PTU_SENTINEL_API_KEY,
|
||||
]
|
||||
assert "model = $4" in sql
|
||||
assert "api_key = $5" in sql
|
||||
|
||||
def test_model_group_rollups_fall_back_to_model_name(self):
|
||||
"""Aggregated model_groups rollups must fall back to model for group-less rows.
|
||||
|
||||
The (date, model_group) grouping level cannot recover the model column
|
||||
after the fact (it is rolled up), so the fallback has to happen in SQL;
|
||||
without it, group-less rows silently vanish from the model_groups
|
||||
breakdown that the usage UI now renders by default. Group-less rows are
|
||||
stored as empty strings, not NULL (spend_tracking_utils defaults
|
||||
model_group to ""), so a plain COALESCE is not enough: the fallback must
|
||||
be NULLIF-wrapped to catch both
|
||||
"""
|
||||
sql, _ = _build_aggregated_sql_query(
|
||||
table_name="litellm_dailyuserspend",
|
||||
entity_id_field="user_id",
|
||||
entity_id=None,
|
||||
start_date="2026-07-01",
|
||||
end_date="2026-07-01",
|
||||
model=None,
|
||||
api_key=None,
|
||||
)
|
||||
|
||||
normalized = " ".join(sql.split())
|
||||
fallback = "COALESCE(NULLIF(model_group, ''), model)"
|
||||
assert f"{fallback} AS model_group" in normalized
|
||||
assert (
|
||||
f"GROUPING(date, api_key, model, {fallback}, "
|
||||
"custom_llm_provider, mcp_namespaced_tool_name, endpoint) AS group_level" in normalized
|
||||
)
|
||||
assert f"(date, {fallback}), (date, {fallback}, api_key)," in normalized
|
||||
assert "(date, model_group)" not in normalized
|
||||
assert "COALESCE(model_group, model)" not in normalized
|
||||
|
||||
|
||||
class TestAggregatedEmptyEntityFilter:
|
||||
_BUILDERS: Final = (_build_aggregated_sql_query, _build_entity_rollup_sql_query)
|
||||
|
|
@ -1285,7 +1266,8 @@ class TestAggregatedEmptyEntityFilter:
|
|||
normalized = " ".join(sql.split())
|
||||
assert "IN ()" not in normalized
|
||||
assert '"team_id" IN' not in normalized
|
||||
assert params == ["2026-08-01", "2026-08-19"]
|
||||
sentinel_params = [PTU_SENTINEL_API_KEY] if build is _build_aggregated_sql_query else []
|
||||
assert params == ["2026-08-01", "2026-08-19", *sentinel_params]
|
||||
|
||||
@pytest.mark.parametrize("build", _BUILDERS)
|
||||
def test_empty_entity_list_matches_nothing_rather_than_everything(self, build):
|
||||
|
|
@ -1316,7 +1298,8 @@ class TestAggregatedEmptyEntityFilter:
|
|||
normalized = " ".join(sql.split())
|
||||
assert '"team_id" IN ($3, $4)' in normalized
|
||||
assert "FALSE" not in normalized
|
||||
assert params == ["2026-08-01", "2026-08-19", "team-alpha", "team-beta"]
|
||||
sentinel_params = [PTU_SENTINEL_API_KEY] if build is _build_aggregated_sql_query else []
|
||||
assert params == ["2026-08-01", "2026-08-19", "team-alpha", "team-beta", *sentinel_params]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -1341,6 +1324,7 @@ async def test_get_daily_activity_aggregated_empty_result_set():
|
|||
"mcp_namespaced_tool_name": None,
|
||||
"endpoint": None,
|
||||
"group_level": 127,
|
||||
"distinct_api_keys": None,
|
||||
"spend": None,
|
||||
"prompt_tokens": None,
|
||||
"completion_tokens": None,
|
||||
|
|
@ -1385,6 +1369,305 @@ async def test_get_daily_activity_aggregated_empty_result_set():
|
|||
assert result.metadata.total_compression_saved_tokens == 0
|
||||
|
||||
|
||||
_aggregated_postgresql_proc: Final = factories.postgresql_proc()
|
||||
_aggregated_postgresql: Final = factories.postgresql("_aggregated_postgresql_proc")
|
||||
|
||||
_DAILY_USER_SPEND_DDL: Final = """
|
||||
CREATE TABLE "LiteLLM_DailyUserSpend" (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT,
|
||||
date TEXT NOT NULL,
|
||||
api_key TEXT NOT NULL,
|
||||
model TEXT,
|
||||
model_group TEXT,
|
||||
custom_llm_provider TEXT,
|
||||
mcp_namespaced_tool_name TEXT,
|
||||
endpoint TEXT,
|
||||
prompt_tokens BIGINT DEFAULT 0,
|
||||
completion_tokens BIGINT DEFAULT 0,
|
||||
cache_read_input_tokens BIGINT DEFAULT 0,
|
||||
cache_creation_input_tokens BIGINT DEFAULT 0,
|
||||
compression_saved_tokens BIGINT DEFAULT 0,
|
||||
compression_savings_spend DOUBLE PRECISION DEFAULT 0,
|
||||
prompt_caching_savings_spend DOUBLE PRECISION DEFAULT 0,
|
||||
gateway_injected_caching_savings_spend DOUBLE PRECISION DEFAULT 0,
|
||||
autorouter_savings_spend DOUBLE PRECISION DEFAULT 0,
|
||||
spend DOUBLE PRECISION DEFAULT 0,
|
||||
api_requests BIGINT DEFAULT 0,
|
||||
successful_requests BIGINT DEFAULT 0,
|
||||
failed_requests BIGINT DEFAULT 0,
|
||||
total_response_time_ms BIGINT DEFAULT 0,
|
||||
timed_requests BIGINT DEFAULT 0
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def _seed_daily_user_spend(conn: psycopg.Connection, rows: Sequence[tuple[object, ...]]) -> None:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(_DAILY_USER_SPEND_DDL)
|
||||
cur.executemany(
|
||||
"""
|
||||
INSERT INTO "LiteLLM_DailyUserSpend"
|
||||
(id, user_id, date, api_key, model, model_group, custom_llm_provider,
|
||||
endpoint, prompt_tokens, spend, api_requests, successful_requests)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
rows,
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _psycopg_query_raw(conn: psycopg.Connection, row_counts: list[int]):
|
||||
"""Run the proxy's $N-parameterized SQL through psycopg, recording each result size."""
|
||||
|
||||
async def query_raw(sql: str, *params: str) -> list[dict[str, object]]:
|
||||
converted: Final = re.sub(r"\$(\d+)", r"%(p\1)s", sql)
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
converted, # pyright: ignore[reportArgumentType] # psycopg stubs want a literal-typed query
|
||||
{f"p{i}": v for i, v in enumerate(params, start=1)},
|
||||
)
|
||||
rows: Final = cur.fetchall()
|
||||
row_counts.append(len(rows))
|
||||
return rows
|
||||
|
||||
return query_raw
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_daily_activity_aggregated_bounds_api_key_rollups(
|
||||
_aggregated_postgresql: psycopg.Connection,
|
||||
):
|
||||
"""Run the GROUPING SETS statement against real Postgres with more keys than the cap.
|
||||
|
||||
key-004 and key-005 tie on spend exactly at the USAGE_TOP_API_KEYS_LIMIT
|
||||
cutoff; the api_key tiebreaker must keep key-004 and drop key-005. The PTU
|
||||
sentinel outspends every key but must not take a slot. Excluded keys and the
|
||||
sentinel still count toward the totals and the model rollup, which come from
|
||||
the key-free arm.
|
||||
"""
|
||||
n_keys: Final = USAGE_TOP_API_KEYS_LIMIT + 5
|
||||
key_rows: Final = [
|
||||
(
|
||||
f"row-{i:03d}",
|
||||
f"user-{i:03d}",
|
||||
"2026-06-01",
|
||||
f"key-{i:03d}",
|
||||
"gpt-5",
|
||||
"",
|
||||
"openai",
|
||||
"/v1/chat/completions",
|
||||
10,
|
||||
6.0 if i == 4 else float(i + 1),
|
||||
1,
|
||||
1,
|
||||
)
|
||||
for i in range(n_keys)
|
||||
]
|
||||
sentinel_row: Final = (
|
||||
"row-ptu",
|
||||
None,
|
||||
"2026-06-01",
|
||||
PTU_SENTINEL_API_KEY,
|
||||
"gpt-5",
|
||||
"",
|
||||
"azure",
|
||||
None,
|
||||
0,
|
||||
1000.0,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
_seed_daily_user_spend(_aggregated_postgresql, [*key_rows, sentinel_row])
|
||||
key_spend: Final = sum(6.0 if i == 4 else float(i + 1) for i in range(n_keys))
|
||||
|
||||
row_counts: Final[list[int]] = [] # mutable-ok: out-param for the query_raw shim
|
||||
mock_prisma = MagicMock()
|
||||
mock_prisma.db = MagicMock()
|
||||
mock_prisma.db.query_raw = _psycopg_query_raw(_aggregated_postgresql, row_counts)
|
||||
mock_prisma.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[])
|
||||
mock_prisma.db.litellm_deletedverificationtoken.find_many = AsyncMock(return_value=[])
|
||||
|
||||
result = await get_daily_activity_aggregated(
|
||||
prisma_client=mock_prisma,
|
||||
table_name="litellm_dailyuserspend",
|
||||
entity_id_field="user_id",
|
||||
entity_id=None,
|
||||
entity_metadata_field=None,
|
||||
start_date="2026-06-01",
|
||||
end_date="2026-06-01",
|
||||
model=None,
|
||||
api_key=None,
|
||||
)
|
||||
|
||||
# Key-free arm: (), (date), (date, model), (date, model_group), two providers,
|
||||
# one mcp NULL bucket, endpoint plus its NULL bucket = 9 rows regardless of key count.
|
||||
# Per-key arm: six per-key grouping sets, each capped at the limit.
|
||||
assert row_counts == [9 + 6 * USAGE_TOP_API_KEYS_LIMIT]
|
||||
|
||||
assert result.metadata.total_spend == pytest.approx(key_spend + 1000.0)
|
||||
assert result.metadata.total_api_requests == n_keys
|
||||
assert result.metadata.api_key_limit == USAGE_TOP_API_KEYS_LIMIT
|
||||
assert result.metadata.total_api_keys == n_keys
|
||||
|
||||
expected_top: Final = {f"key-{i:03d}" for i in range(6, n_keys)} | {"key-004"}
|
||||
day: Final = result.results[0]
|
||||
assert day.metrics.spend == pytest.approx(key_spend + 1000.0)
|
||||
assert set(day.breakdown.api_keys) == expected_top
|
||||
assert day.breakdown.api_keys["key-004"].metrics.spend == 6.0
|
||||
assert "key-005" not in day.breakdown.api_keys
|
||||
assert PTU_SENTINEL_API_KEY not in day.breakdown.api_keys
|
||||
|
||||
assert day.breakdown.models["gpt-5"].metrics.spend == pytest.approx(key_spend + 1000.0)
|
||||
assert set(day.breakdown.models["gpt-5"].api_key_breakdown) == expected_top
|
||||
assert day.breakdown.providers["openai"].metrics.spend == pytest.approx(key_spend)
|
||||
assert set(day.breakdown.providers["openai"].api_key_breakdown) == expected_top
|
||||
assert day.breakdown.endpoints["/v1/chat/completions"].metrics.api_requests == n_keys
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_daily_activity_aggregated_explicit_api_key_filter_scopes_both_arms(
|
||||
_aggregated_postgresql: psycopg.Connection,
|
||||
):
|
||||
"""An explicit api_key filter must scope the key-free totals and the per-key
|
||||
rollups to that key alone, so the two arms never disagree."""
|
||||
rows: Final = [
|
||||
(
|
||||
f"row-{i}",
|
||||
f"user-{i}",
|
||||
"2026-06-01",
|
||||
f"key-{i}",
|
||||
"gpt-5",
|
||||
"",
|
||||
"openai",
|
||||
"/v1/chat/completions",
|
||||
10,
|
||||
float(i + 1),
|
||||
1,
|
||||
1,
|
||||
)
|
||||
for i in range(3)
|
||||
]
|
||||
_seed_daily_user_spend(_aggregated_postgresql, rows)
|
||||
|
||||
row_counts: Final[list[int]] = [] # mutable-ok: out-param for the query_raw shim
|
||||
mock_prisma = MagicMock()
|
||||
mock_prisma.db = MagicMock()
|
||||
mock_prisma.db.query_raw = _psycopg_query_raw(_aggregated_postgresql, row_counts)
|
||||
mock_prisma.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[])
|
||||
mock_prisma.db.litellm_deletedverificationtoken.find_many = AsyncMock(return_value=[])
|
||||
|
||||
result = await get_daily_activity_aggregated(
|
||||
prisma_client=mock_prisma,
|
||||
table_name="litellm_dailyuserspend",
|
||||
entity_id_field="user_id",
|
||||
entity_id=None,
|
||||
entity_metadata_field=None,
|
||||
start_date="2026-06-01",
|
||||
end_date="2026-06-01",
|
||||
model=None,
|
||||
api_key="key-1",
|
||||
)
|
||||
|
||||
assert result.metadata.total_spend == 2.0
|
||||
assert result.metadata.total_api_keys == 1
|
||||
day: Final = result.results[0]
|
||||
assert set(day.breakdown.api_keys) == {"key-1"}
|
||||
assert day.breakdown.api_keys["key-1"].metrics.spend == 2.0
|
||||
assert day.breakdown.models["gpt-5"].metrics.spend == 2.0
|
||||
assert set(day.breakdown.models["gpt-5"].api_key_breakdown) == {"key-1"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_daily_activity_aggregated_reports_exact_limit_key_count_as_complete(
|
||||
_aggregated_postgresql: psycopg.Connection,
|
||||
):
|
||||
"""With exactly USAGE_TOP_API_KEYS_LIMIT keys nothing is dropped, and the
|
||||
response must say so: total_api_keys equals the limit rather than exceeding it."""
|
||||
rows: Final = [
|
||||
(
|
||||
f"row-{i:03d}",
|
||||
f"user-{i:03d}",
|
||||
"2026-06-01",
|
||||
f"key-{i:03d}",
|
||||
"gpt-5",
|
||||
"",
|
||||
"openai",
|
||||
"/v1/chat/completions",
|
||||
10,
|
||||
float(i + 1),
|
||||
1,
|
||||
1,
|
||||
)
|
||||
for i in range(USAGE_TOP_API_KEYS_LIMIT)
|
||||
]
|
||||
_seed_daily_user_spend(_aggregated_postgresql, rows)
|
||||
|
||||
row_counts: Final[list[int]] = [] # mutable-ok: out-param for the query_raw shim
|
||||
mock_prisma = MagicMock()
|
||||
mock_prisma.db = MagicMock()
|
||||
mock_prisma.db.query_raw = _psycopg_query_raw(_aggregated_postgresql, row_counts)
|
||||
mock_prisma.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[])
|
||||
mock_prisma.db.litellm_deletedverificationtoken.find_many = AsyncMock(return_value=[])
|
||||
|
||||
result = await get_daily_activity_aggregated(
|
||||
prisma_client=mock_prisma,
|
||||
table_name="litellm_dailyuserspend",
|
||||
entity_id_field="user_id",
|
||||
entity_id=None,
|
||||
entity_metadata_field=None,
|
||||
start_date="2026-06-01",
|
||||
end_date="2026-06-01",
|
||||
model=None,
|
||||
api_key=None,
|
||||
)
|
||||
|
||||
assert result.metadata.total_api_keys == USAGE_TOP_API_KEYS_LIMIT
|
||||
assert result.metadata.api_key_limit == USAGE_TOP_API_KEYS_LIMIT
|
||||
assert len(result.results[0].breakdown.api_keys) == USAGE_TOP_API_KEYS_LIMIT
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_daily_activity_aggregated_model_group_rollups_fall_back_to_model_name(
|
||||
_aggregated_postgresql: psycopg.Connection,
|
||||
):
|
||||
"""Rows stored with an empty or NULL model_group must land in the model_groups
|
||||
breakdown under their model name instead of vanishing from the usage UI."""
|
||||
rows: Final = [
|
||||
("row-0", "user-0", "2026-06-01", "key-0", "gpt-5", "gpt-5-eu", "openai", "/v1/chat/completions", 10, 7.0, 1, 1),
|
||||
("row-1", "user-1", "2026-06-01", "key-1", "gpt-5", "", "openai", "/v1/chat/completions", 10, 3.0, 1, 1),
|
||||
("row-2", "user-2", "2026-06-01", "key-2", "claude-x", None, "anthropic", "/v1/messages", 10, 2.0, 1, 1),
|
||||
]
|
||||
_seed_daily_user_spend(_aggregated_postgresql, rows)
|
||||
|
||||
mock_prisma = MagicMock()
|
||||
mock_prisma.db = MagicMock()
|
||||
mock_prisma.db.query_raw = _psycopg_query_raw(_aggregated_postgresql, [])
|
||||
mock_prisma.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[])
|
||||
mock_prisma.db.litellm_deletedverificationtoken.find_many = AsyncMock(return_value=[])
|
||||
|
||||
result = await get_daily_activity_aggregated(
|
||||
prisma_client=mock_prisma,
|
||||
table_name="litellm_dailyuserspend",
|
||||
entity_id_field="user_id",
|
||||
entity_id=None,
|
||||
entity_metadata_field=None,
|
||||
start_date="2026-06-01",
|
||||
end_date="2026-06-01",
|
||||
model=None,
|
||||
api_key=None,
|
||||
)
|
||||
|
||||
breakdown: Final = result.results[0].breakdown
|
||||
assert set(breakdown.model_groups) == {"gpt-5-eu", "gpt-5", "claude-x"}
|
||||
assert breakdown.model_groups["gpt-5-eu"].metrics.spend == 7.0
|
||||
assert breakdown.model_groups["gpt-5"].metrics.spend == 3.0
|
||||
assert breakdown.model_groups["claude-x"].metrics.spend == 2.0
|
||||
assert set(breakdown.model_groups["gpt-5"].api_key_breakdown) == {"key-1"}
|
||||
assert set(breakdown.models) == {"gpt-5", "claude-x"}
|
||||
assert breakdown.models["gpt-5"].metrics.spend == 10.0
|
||||
|
||||
|
||||
def _no_spend_record():
|
||||
"""A rollup row for a key with no spend, where SUM() returns NULL (None)."""
|
||||
return SimpleNamespace(
|
||||
|
|
@ -2170,7 +2453,7 @@ def test_entity_rollup_sql_query_and_api_key_list_filter():
|
|||
api_key=[],
|
||||
)
|
||||
assert "FALSE" in empty_sql
|
||||
assert empty_params == ["2024-01-01", "2024-01-31"]
|
||||
assert empty_params == ["2024-01-01", "2024-01-31", PTU_SENTINEL_API_KEY]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -2204,10 +2487,10 @@ async def test_get_daily_activity_aggregated_with_entity_breakdown():
|
|||
"successful_requests": 0,
|
||||
}
|
||||
main_rows = [
|
||||
{**base, "date": None, "group_level": 127, "spend": 18.0},
|
||||
{**base, "date": "2024-01-01", "group_level": 63, "spend": 18.0},
|
||||
{**base, "date": "2024-01-01", "model": "gpt-4o", "group_level": 47, "spend": 18.0},
|
||||
{**base, "date": "2024-01-01", "api_key": "key-1", "group_level": 31, "spend": 12.0},
|
||||
{**base, "date": None, "group_level": 127, "distinct_api_keys": None, "spend": 18.0},
|
||||
{**base, "date": "2024-01-01", "group_level": 63, "distinct_api_keys": None, "spend": 18.0},
|
||||
{**base, "date": "2024-01-01", "model": "gpt-4o", "group_level": 47, "distinct_api_keys": None, "spend": 18.0},
|
||||
{**base, "date": "2024-01-01", "api_key": "key-1", "group_level": 31, "distinct_api_keys": 1, "spend": 12.0},
|
||||
]
|
||||
entity_base = {
|
||||
key: value
|
||||
|
|
|
|||
|
|
@ -511,3 +511,27 @@ def make_key(
|
|||
max_budget=max_budget,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_login_throttle(monkeypatch):
|
||||
"""Clear the Admin UI failed-login counters between tests.
|
||||
|
||||
`client` is session scoped and the counters live in shared module stores with a 300s block
|
||||
window, so without this a failed sign-in test could block unrelated tests later.
|
||||
Only the throttle's own keys are removed, so other cache entries remain untouched.
|
||||
"""
|
||||
from litellm.constants import LOGIN_THROTTLE_CACHE_KEY_PREFIX
|
||||
from litellm.proxy import proxy_server as ps
|
||||
from litellm.proxy.auth.login_throttle import _BLOCKS, _COUNTERS
|
||||
|
||||
def _drop_throttle_keys() -> None:
|
||||
for store in (_COUNTERS, _BLOCKS):
|
||||
for key in tuple(store.cache_dict) + tuple(store.ttl_dict):
|
||||
if key.startswith(LOGIN_THROTTLE_CACHE_KEY_PREFIX):
|
||||
store.delete_cache(key)
|
||||
|
||||
monkeypatch.setattr(ps, "redis_usage_cache", None)
|
||||
_drop_throttle_keys()
|
||||
yield _drop_throttle_keys
|
||||
_drop_throttle_keys()
|
||||
|
|
|
|||
|
|
@ -12,8 +12,6 @@ from __future__ import annotations
|
|||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from .conftest import normalize
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -29,7 +27,7 @@ def _install_login_mocks(monkeypatch, raise_on_auth: bool = False) -> None:
|
|||
"""
|
||||
from litellm.proxy import proxy_server as ps
|
||||
|
||||
async def _fake_auth(username, password, master_key, prisma_client, general_settings=None):
|
||||
async def _fake_auth(username, password, master_key, prisma_client, throttle=None, general_settings=None):
|
||||
if raise_on_auth:
|
||||
raise Exception("boom-auth-failure")
|
||||
fake = MagicMock()
|
||||
|
|
@ -471,3 +469,222 @@ def test_login_form_ignores_open_redirect_return_to(client, monkeypatch):
|
|||
location = response.headers.get("location", "")
|
||||
assert "evil.example.com" not in location
|
||||
assert "/ui" in location # dashboard fallback
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Failed-login accounting across the login routes (LIT-5285)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _install_real_auth(monkeypatch, **settings):
|
||||
"""Run the real authenticate_user so the throttle inside it is exercised.
|
||||
|
||||
prisma_client stays None, so every guess falls through to the credential rejection.
|
||||
"""
|
||||
from litellm.proxy import proxy_server as ps
|
||||
|
||||
monkeypatch.setenv("UI_USERNAME", "admin")
|
||||
monkeypatch.setenv("UI_PASSWORD", "right-password")
|
||||
monkeypatch.setattr(ps, "master_key", "sk-test-master")
|
||||
monkeypatch.setattr(ps, "prisma_client", None)
|
||||
monkeypatch.setattr(ps, "premium_user", False)
|
||||
monkeypatch.setattr(ps, "general_settings", dict(settings))
|
||||
|
||||
|
||||
def _form_login(client, username="admin", password="wrong"):
|
||||
return client.post("/login", data={"username": username, "password": password}, follow_redirects=False).status_code
|
||||
|
||||
|
||||
def _json_login(client, path, username="admin", password="wrong"):
|
||||
return client.post(path, json={"username": username, "password": password}).status_code
|
||||
|
||||
|
||||
def _db_user(monkeypatch, email: str):
|
||||
"""A database user with a stored hash, faked so the route reaches the known-user branch without Postgres."""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from litellm.proxy import proxy_server as ps
|
||||
|
||||
user = MagicMock()
|
||||
user.user_id = "u-1"
|
||||
user.user_email = email
|
||||
user.user_role = "internal_user"
|
||||
user.password = "scrypt:stored"
|
||||
repo = MagicMock()
|
||||
repo.return_value.table.find_first = AsyncMock(return_value=user)
|
||||
monkeypatch.setattr(ps, "prisma_client", MagicMock())
|
||||
monkeypatch.setattr("litellm.proxy.auth.login_utils.UserRepository", repo)
|
||||
monkeypatch.setattr("litellm.proxy.auth.login_utils._rehash_password_if_needed", AsyncMock())
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.auth.login_utils.verify_password", lambda given, stored: given == "right-db-password"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.auth.login_utils.generate_key_helper_fn", AsyncMock(return_value={"token": "sk-ui"})
|
||||
)
|
||||
monkeypatch.setenv("DATABASE_URL", "postgresql://stub")
|
||||
|
||||
|
||||
def test_budget_is_shared_across_every_login_endpoint(client, monkeypatch, reset_login_throttle):
|
||||
"""The endpoint is not part of the key, so spending the budget on one route blocks the rest.
|
||||
|
||||
Partitioning the counter per endpoint would silently triple the real allowance.
|
||||
"""
|
||||
_install_real_auth(
|
||||
monkeypatch,
|
||||
max_failed_login_attempts_per_source=20,
|
||||
control_plane_url="https://cp.example.com",
|
||||
)
|
||||
|
||||
assert [_form_login(client) for _ in range(5)] == [401] * 5
|
||||
assert [_json_login(client, "/v2/login") for _ in range(5)] == [401] * 5
|
||||
|
||||
assert _json_login(client, "/v3/login") == 401, "the eleventh failure crosses the limit and installs the block"
|
||||
assert _json_login(client, "/v3/login") == 429, "the twelfth attempt must be refused on a third route"
|
||||
|
||||
|
||||
def test_budget_is_shared_across_username_casing(client, monkeypatch, reset_login_throttle):
|
||||
"""The database lookup is case-insensitive, so casing must not partition the counter."""
|
||||
_install_real_auth(monkeypatch, max_failed_login_attempts_per_source=6)
|
||||
|
||||
assert [_json_login(client, "/v2/login", username="admin@corp.com") for _ in range(2)] == [401] * 2
|
||||
assert [_json_login(client, "/v2/login", username="ADMIN@corp.com") for _ in range(2)] == [401] * 2
|
||||
|
||||
assert _json_login(client, "/v2/login", username="Admin@corp.com") == 429
|
||||
|
||||
|
||||
def test_a_refused_attempt_carries_retry_after(client, monkeypatch, reset_login_throttle):
|
||||
"""The 429 tells the caller how long the block has left."""
|
||||
_install_real_auth(monkeypatch, max_failed_login_attempts_per_source=2, failed_login_block_seconds=77)
|
||||
|
||||
assert [_json_login(client, "/v2/login") for _ in range(2)] == [401, 401]
|
||||
|
||||
refused = client.post("/v2/login", json={"username": "admin", "password": "wrong"})
|
||||
assert refused.status_code == 429
|
||||
assert refused.headers.get("retry-after") == "77"
|
||||
|
||||
|
||||
def test_the_form_returns_a_human_readable_lockout_page(client, monkeypatch, reset_login_throttle):
|
||||
"""The no-JavaScript form must render a wait page when its POST is throttled."""
|
||||
_install_real_auth(monkeypatch, max_failed_login_attempts_per_source=2, failed_login_block_seconds=77)
|
||||
|
||||
assert [_form_login(client) for _ in range(2)] == [401, 401]
|
||||
|
||||
refused = client.post("/login", data={"username": "admin", "password": "wrong"})
|
||||
assert refused.status_code == 429
|
||||
assert refused.headers.get("content-type", "").startswith("text/html")
|
||||
assert "Try again in about 77 seconds" in refused.text
|
||||
assert refused.headers.get("retry-after") == "77"
|
||||
|
||||
|
||||
def test_a_second_username_from_the_same_source_still_gets_through(client, monkeypatch, reset_login_throttle):
|
||||
"""The pair block is per username, so one account's block cannot take the office down with it."""
|
||||
_install_real_auth(monkeypatch, max_failed_login_attempts_per_source=2)
|
||||
|
||||
assert [_json_login(client, "/v2/login", username="admin") for _ in range(3)] == [401, 401, 429]
|
||||
|
||||
assert _json_login(client, "/v2/login", username="someone-else@example.com") == 401
|
||||
|
||||
|
||||
def test_a_spray_across_usernames_is_blocked_on_the_source_when_the_source_is_attributable(
|
||||
client, monkeypatch, reset_login_throttle
|
||||
):
|
||||
"""A fresh username per guess keeps every pair at one, so the address is what stops it."""
|
||||
_install_real_auth(monkeypatch, trusted_proxy_ranges=["10.0.0.0/8"], max_failed_login_attempts_per_source=4)
|
||||
|
||||
sprayed = [_json_login(client, "/v2/login", username=f"sprayed-{i}@corp.com") for i in range(5)]
|
||||
assert sprayed == [401] * 5
|
||||
|
||||
assert _json_login(client, "/v2/login", username="sprayed-6@corp.com") == 429
|
||||
|
||||
|
||||
def test_a_spray_across_usernames_is_not_blocked_without_trusted_proxy_ranges(
|
||||
client, monkeypatch, reset_login_throttle
|
||||
):
|
||||
"""Without a configured proxy range the peer address is whoever fronts the proxy, shared by every
|
||||
client, so a source-wide block would block them all and the source scope stays off."""
|
||||
_install_real_auth(monkeypatch, max_failed_login_attempts_per_source=4)
|
||||
|
||||
sprayed = [_json_login(client, "/v2/login", username=f"sprayed-{i}@corp.com") for i in range(8)]
|
||||
assert sprayed == [401] * 8
|
||||
|
||||
|
||||
def test_a_spray_across_usernames_is_blocked_on_the_source_with_an_empty_trusted_proxy_ranges(
|
||||
client, monkeypatch, reset_login_throttle
|
||||
):
|
||||
"""An explicit empty list says nothing fronts the proxy, so the peer address is the client and the
|
||||
source scope is on. A forwarded header from an untrusted peer is ignored rather than trusted."""
|
||||
_install_real_auth(monkeypatch, trusted_proxy_ranges=[], max_failed_login_attempts_per_source=4)
|
||||
|
||||
sprayed = [
|
||||
client.post(
|
||||
"/v2/login",
|
||||
json={"username": f"sprayed-{i}@corp.com", "password": "wrong"},
|
||||
headers={"x-forwarded-for": f"203.0.113.{i}"},
|
||||
).status_code
|
||||
for i in range(5)
|
||||
]
|
||||
assert sprayed == [401] * 5
|
||||
|
||||
assert _json_login(client, "/v2/login", username="sprayed-6@corp.com") == 429
|
||||
|
||||
|
||||
def test_the_configured_admin_password_is_refused_while_blocked(client, monkeypatch, reset_login_throttle):
|
||||
"""The env credentials get no bypass: a bypass would make them the one password worth guessing without
|
||||
limit. An operator who is blocked administers the proxy with the master key over the API meanwhile."""
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
_install_real_auth(monkeypatch, max_failed_login_attempts_per_source=2)
|
||||
monkeypatch.setenv("DATABASE_URL", "postgresql://stub")
|
||||
|
||||
assert [_json_login(client, "/v2/login") for _ in range(3)] == [401, 401, 429]
|
||||
|
||||
with (
|
||||
patch( # test-quality-ok: the admin sign-in upserts the admin row; faked so no DB is needed
|
||||
"litellm.proxy.auth.login_utils.user_update", new=AsyncMock()
|
||||
),
|
||||
patch( # test-quality-ok: success mints a UI key and persists the user; faked so no DB is needed
|
||||
"litellm.proxy.auth.login_utils.generate_key_helper_fn", new=AsyncMock(return_value={"token": "sk-ui"})
|
||||
),
|
||||
):
|
||||
assert _json_login(client, "/v2/login", password="right-password") == 429
|
||||
reset_login_throttle()
|
||||
assert _json_login(client, "/v2/login", password="right-password") == 200
|
||||
|
||||
|
||||
def test_the_master_key_as_a_bearer_token_still_works_while_the_ui_password_is_blocked(
|
||||
client, monkeypatch, reset_login_throttle
|
||||
):
|
||||
"""Lockout recovery: the API path with the master key never enters the sign-in throttle."""
|
||||
_install_real_auth(monkeypatch, max_failed_login_attempts_per_source=2)
|
||||
|
||||
assert [_json_login(client, "/v2/login") for _ in range(3)] == [401, 401, 429]
|
||||
|
||||
assert client.get("/models", headers={"Authorization": "Bearer sk-not-the-master"}).status_code >= 400
|
||||
assert client.get("/models", headers={"Authorization": "Bearer sk-test-master"}).status_code == 200
|
||||
assert _json_login(client, "/v2/login", password="right-password") == 429, "the UI block is unaffected"
|
||||
|
||||
|
||||
def test_a_database_users_correct_password_is_refused_while_blocked(client, monkeypatch, reset_login_throttle):
|
||||
"""The block is hard: while it lasts, nothing from that source signs in as that user, right password or not,
|
||||
and the block is not extended by the refused attempts."""
|
||||
_install_real_auth(monkeypatch, max_failed_login_attempts_per_source=2, failed_login_block_seconds=64)
|
||||
_db_user(monkeypatch, "user@corp.com")
|
||||
|
||||
assert [_json_login(client, "/v2/login", username="user@corp.com") for _ in range(3)] == [401, 401, 429]
|
||||
|
||||
refused = client.post("/v2/login", json={"username": "user@corp.com", "password": "right-db-password"})
|
||||
assert refused.status_code == 429
|
||||
assert refused.headers.get("retry-after") == "64"
|
||||
|
||||
reset_login_throttle()
|
||||
assert _json_login(client, "/v2/login", username="user@corp.com", password="right-db-password") == 200
|
||||
|
||||
|
||||
def test_sign_in_succeeds_again_once_the_block_is_cleared(client, monkeypatch, reset_login_throttle):
|
||||
"""A cleared store lets the same username straight back to a plain credential check."""
|
||||
_install_real_auth(monkeypatch, max_failed_login_attempts_per_source=2)
|
||||
|
||||
assert [_json_login(client, "/v2/login") for _ in range(3)] == [401, 401, 429]
|
||||
|
||||
reset_login_throttle()
|
||||
assert _json_login(client, "/v2/login") == 401
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ from fastapi.encoders import jsonable_encoder
|
|||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
import litellm
|
||||
import litellm.proxy.proxy_server as proxy_server_module
|
||||
from litellm.caching.caching import RedisCache
|
||||
|
|
@ -41,6 +40,7 @@ from litellm.proxy._types import (
|
|||
TokenCountRequest,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.auth.login_throttle import LoginThrottle
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.hooks.parallel_request_limiter_v3 import RequestRateLimiterStash
|
||||
from litellm.proxy.proxy_server import app, initialize, openai_exception_handler
|
||||
|
|
@ -139,13 +139,14 @@ def test_login_v2_returns_redirect_url_and_sets_cookie(monkeypatch):
|
|||
}
|
||||
assert response.cookies.get("token") == "signed-token"
|
||||
|
||||
mock_authenticate_user.assert_awaited_once_with(
|
||||
username="alice",
|
||||
password="secret",
|
||||
master_key="test-master-key",
|
||||
prisma_client=mock_prisma_client,
|
||||
general_settings={},
|
||||
)
|
||||
mock_authenticate_user.assert_awaited_once()
|
||||
auth_kwargs = mock_authenticate_user.call_args.kwargs
|
||||
assert auth_kwargs["username"] == "alice"
|
||||
assert auth_kwargs["password"] == "secret"
|
||||
assert auth_kwargs["master_key"] == "test-master-key"
|
||||
assert auth_kwargs["prisma_client"] is mock_prisma_client
|
||||
assert auth_kwargs["general_settings"] == {}
|
||||
assert isinstance(auth_kwargs["throttle"], LoginThrottle), "the endpoint must thread a throttle through"
|
||||
mock_create_ui_token_object.assert_called_once_with(
|
||||
login_result=mock_login_result,
|
||||
general_settings={},
|
||||
|
|
@ -3410,6 +3411,60 @@ async def test_load_config_user_url_validation_handles_null_and_string_false(tmp
|
|||
assert litellm.user_url_validation is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_config_warns_per_worker_login_counters_without_general_settings(tmp_path, monkeypatch, caplog):
|
||||
"""Regression: the failed-login throttle is on by default, so a multi-worker proxy with no
|
||||
Redis must hear that its counters are per worker even when the config has no general_settings."""
|
||||
import logging
|
||||
|
||||
import litellm.proxy.proxy_server as proxy_server
|
||||
from litellm.proxy.auth.login_throttle import warn_login_counters_are_per_worker
|
||||
from litellm.proxy.proxy_server import ProxyConfig
|
||||
|
||||
for redis_var in ("REDIS_HOST", "REDIS_URL", "REDIS_CLUSTER_NODES", "REDIS_SENTINEL_NODES"):
|
||||
monkeypatch.delenv(redis_var, raising=False)
|
||||
monkeypatch.setenv("NUM_WORKERS", "4")
|
||||
monkeypatch.setattr(proxy_server, "redis_usage_cache", None)
|
||||
warn_login_counters_are_per_worker.cache_clear()
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text("model_list: []\n")
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="LiteLLM Proxy"):
|
||||
await ProxyConfig().load_config(router=MagicMock(), config_file_path=str(config_file))
|
||||
|
||||
assert "Running 4 workers but Redis is not configured" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_config_warns_that_the_source_login_limit_is_off_without_trusted_proxy_ranges(
|
||||
tmp_path, monkeypatch, caplog
|
||||
):
|
||||
"""The per-source failed-login limit is skipped when the source cannot be attributed, and the
|
||||
operator must be told so at startup. Both a configured range and an explicit empty list (no
|
||||
proxies, the peer is the source) silence it, since both keep the limit on."""
|
||||
import logging
|
||||
|
||||
from litellm.proxy.auth.login_throttle import warn_source_login_limit_is_off
|
||||
from litellm.proxy.proxy_server import ProxyConfig
|
||||
|
||||
monkeypatch.setenv("NUM_WORKERS", "1")
|
||||
warn_source_login_limit_is_off.cache_clear()
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text("model_list: []\n")
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="LiteLLM Proxy"):
|
||||
await ProxyConfig().load_config(router=MagicMock(), config_file_path=str(config_file))
|
||||
assert "trusted_proxy_ranges is not set" in caplog.text
|
||||
|
||||
for configured in ("['10.0.0.0/8']", "[]"):
|
||||
caplog.clear()
|
||||
warn_source_login_limit_is_off.cache_clear()
|
||||
config_file.write_text(f"model_list: []\ngeneral_settings:\n trusted_proxy_ranges: {configured}\n")
|
||||
with caplog.at_level(logging.WARNING, logger="LiteLLM Proxy"):
|
||||
await ProxyConfig().load_config(router=MagicMock(), config_file_path=str(config_file))
|
||||
assert "trusted_proxy_ranges is not set" not in caplog.text, configured
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_environment_variables_direct_and_os_environ():
|
||||
"""
|
||||
|
|
@ -13952,6 +14007,35 @@ async def test_authoritative_floor_spend_keeps_a_reset_marker_written_during_the
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_throttle_settings_are_not_hot_applied_from_the_database():
|
||||
"""LIT-5285: a stored sign-in limit does not take effect on a live worker.
|
||||
|
||||
_update_general_settings copies an allowlist of keys out of the DB row on every config
|
||||
poll. Adding these to it would let a stored value outrank config.yaml without a restart,
|
||||
so an operator locked out by a bad value could not fix it by editing YAML and restarting.
|
||||
"""
|
||||
import litellm.proxy.proxy_server as ps
|
||||
from litellm.proxy.proxy_server import ProxyConfig
|
||||
|
||||
original = dict(ps.general_settings)
|
||||
try:
|
||||
ps.general_settings.clear()
|
||||
await ProxyConfig()._update_general_settings(
|
||||
db_general_settings={
|
||||
"max_failed_login_attempts_per_source": 999,
|
||||
"failed_login_window_seconds": 1,
|
||||
"failed_login_block_seconds": 1,
|
||||
}
|
||||
)
|
||||
assert "max_failed_login_attempts_per_source" not in ps.general_settings
|
||||
assert "failed_login_window_seconds" not in ps.general_settings
|
||||
assert "failed_login_block_seconds" not in ps.general_settings
|
||||
finally:
|
||||
ps.general_settings.clear()
|
||||
ps.general_settings.update(original)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_config_router_authorizes_fallback_targets_against_the_calling_key(tmp_path):
|
||||
from litellm.proxy.auth.fallback_model_access import router_fallback_access_check
|
||||
|
|
|
|||
|
|
@ -178,4 +178,28 @@ describe("CacheLeakageCard", () => {
|
|||
screen.queryByText("Data is still loading; rows and totals will update as the rest of the range arrives."),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says which keys are missing from the key ranking when the proxy capped the per-key lists", () => {
|
||||
const day = dayWithKeys("2026-07-12", {
|
||||
"hash-leaky": key("leaky-key", { prompt_tokens: 10000, cache_read_input_tokens: 0 }),
|
||||
});
|
||||
renderWith([day], { apiKeyTruncation: { limit: 100, total: 3000 } });
|
||||
|
||||
expect(screen.getByRole("note")).toHaveTextContent(
|
||||
"Only the 100 highest-spend keys of 3,000 are loaded, so a lower-spend key that leaks more is not listed here.",
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "By model" }));
|
||||
|
||||
expect(screen.queryByRole("note")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the key ranking note off when every key was loaded", () => {
|
||||
const day = dayWithKeys("2026-07-12", {
|
||||
"hash-leaky": key("leaky-key", { prompt_tokens: 10000, cache_read_input_tokens: 0 }),
|
||||
});
|
||||
renderWith([day]);
|
||||
|
||||
expect(screen.queryByRole("note")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ const SortableHead = ({
|
|||
};
|
||||
|
||||
const CacheLeakageCard: React.FC<CacheLeakageCardProps> = ({ activity }) => {
|
||||
const { dateValue, onDateChange, results, loading, isFetchingMore } = activity;
|
||||
const { dateValue, onDateChange, results, loading, isFetchingMore, apiKeyTruncation } = activity;
|
||||
const [dimension, setDimension] = useState<CacheLeakageDimension>("key");
|
||||
const [sort, setSort] = useState<SortState>({ column: "potentialSavings", dir: "desc" });
|
||||
const leakage = useMemo(() => computeCacheLeakage(results, dimension), [results, dimension]);
|
||||
|
|
@ -123,6 +123,13 @@ const CacheLeakageCard: React.FC<CacheLeakageCardProps> = ({ activity }) => {
|
|||
</Tabs>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{dimension === "key" && apiKeyTruncation !== undefined && (
|
||||
<p className="mb-2 text-sm text-muted-foreground" role="note">
|
||||
Only the {apiKeyTruncation.limit.toLocaleString()} highest-spend keys of{" "}
|
||||
{apiKeyTruncation.total.toLocaleString()} are loaded, so a lower-spend key that leaks more is not listed
|
||||
here. Raise USAGE_TOP_API_KEYS_LIMIT on the proxy to load more keys.
|
||||
</p>
|
||||
)}
|
||||
{rows.length > 0 && isFetchingMore && (
|
||||
<p className="mb-2 text-sm text-muted-foreground">
|
||||
Data is still loading; rows and totals will update as the rest of the range arrives.
|
||||
|
|
|
|||
|
|
@ -4,12 +4,13 @@ import { describe, expect, it, vi } from "vitest";
|
|||
const mockUsePaginatedDailyActivity = vi.fn();
|
||||
|
||||
const mockCancel = vi.fn();
|
||||
let mockMetadata: Record<string, number> = {};
|
||||
|
||||
vi.mock("@/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity", () => ({
|
||||
usePaginatedDailyActivity: (args: unknown) => {
|
||||
mockUsePaginatedDailyActivity(args);
|
||||
return {
|
||||
data: { results: [] },
|
||||
data: { results: [], metadata: mockMetadata },
|
||||
loading: false,
|
||||
isFetchingMore: false,
|
||||
progress: { currentPage: 4, totalPages: 9 },
|
||||
|
|
@ -80,4 +81,18 @@ describe("useDailyActivityRange", () => {
|
|||
|
||||
expect(mockUsePaginatedDailyActivity).toHaveBeenLastCalledWith(expect.objectContaining({ enabled: false }));
|
||||
});
|
||||
|
||||
it("reports how many keys the proxy left out of the per-key lists", () => {
|
||||
mockMetadata = { api_key_limit: 100, total_api_keys: 3000 };
|
||||
const { result } = renderHook(() => useDailyActivityRange("test-token", "u1", "proxy_admin"));
|
||||
|
||||
expect(result.current.apiKeyTruncation).toEqual({ limit: 100, total: 3000 });
|
||||
});
|
||||
|
||||
it("reports no key truncation when every key fit under the proxy limit", () => {
|
||||
mockMetadata = { api_key_limit: 100, total_api_keys: 100 };
|
||||
const { result } = renderHook(() => useDailyActivityRange("test-token", "u1", "proxy_admin"));
|
||||
|
||||
expect(result.current.apiKeyTruncation).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useMemo, useState } from "react";
|
||||
|
||||
import { userDailyActivityAggregatedCall, userDailyActivityCall } from "@/components/networking";
|
||||
import { ApiKeyTruncation, getApiKeyTruncation } from "@/components/EntityUsageExport/exportBlockedReason";
|
||||
import { DailyData } from "@/components/UsagePage/types";
|
||||
import { spendScopeUserId } from "@/utils/roles";
|
||||
import { usePaginatedDailyActivity } from "@/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity";
|
||||
|
|
@ -22,6 +23,7 @@ export interface DailyActivityRange {
|
|||
cancelled: boolean;
|
||||
failed: boolean;
|
||||
cancel: () => void;
|
||||
apiKeyTruncation?: ApiKeyTruncation;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -78,6 +80,7 @@ export const useScopedDailyActivityRange = (
|
|||
cancelled,
|
||||
failed,
|
||||
cancel,
|
||||
apiKeyTruncation: getApiKeyTruncation(data.metadata?.api_key_limit, data.metadata?.total_api_keys),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -569,6 +569,23 @@ describe("EntityUsage", () => {
|
|||
expect(screen.getAllByText("Activity Metrics")[1]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("tells the team view how many keys the proxy left out of the per-key lists", async () => {
|
||||
mockTeamDailyActivityAggregatedCall.mockResolvedValue({
|
||||
...mockSpendData,
|
||||
metadata: { ...mockSpendData.metadata, api_key_limit: 100, total_api_keys: 3000 },
|
||||
});
|
||||
render(<EntityUsage {...defaultProps} entityType="team" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
});
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByText("Key Activity"));
|
||||
});
|
||||
|
||||
expect(await screen.findByRole("note")).toHaveTextContent("Only the 100 highest-spend keys of 3,000 are loaded");
|
||||
});
|
||||
|
||||
// An inactive tab panel is marked aria-selected="false" by one tab library and hidden by the
|
||||
// other, so treat either as "not on screen" and the assertion holds whichever one is rendering.
|
||||
const isShowing = (element: HTMLElement): boolean => {
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import TeamMultiSelect from "@/components/common_components/team_multi_select";
|
|||
import UserDropdown from "@/components/common_components/UserDropdown";
|
||||
import { ActivityMetrics, processActivityData } from "@/components/activity_metrics";
|
||||
import { UsageExportHeader } from "@/components/EntityUsageExport";
|
||||
import { getExportBlockedReason } from "@/components/EntityUsageExport/exportBlockedReason";
|
||||
import { getApiKeyTruncation, getExportBlockedReason } from "@/components/EntityUsageExport/exportBlockedReason";
|
||||
import type { EntityType } from "@/components/EntityUsageExport/types";
|
||||
import {
|
||||
agentDailyActivityCall,
|
||||
|
|
@ -71,6 +71,8 @@ interface EntitySpendData {
|
|||
total_successful_requests: number;
|
||||
total_failed_requests: number;
|
||||
total_tokens: number;
|
||||
api_key_limit?: number | null;
|
||||
total_api_keys?: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -160,6 +162,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
});
|
||||
|
||||
const spendData = spendDataRaw as unknown as EntitySpendData;
|
||||
const apiKeyTruncation = getApiKeyTruncation(spendData.metadata?.api_key_limit, spendData.metadata?.total_api_keys);
|
||||
|
||||
const {
|
||||
data: agentSpendDataRaw,
|
||||
|
|
@ -659,12 +662,18 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
{
|
||||
key: "keys",
|
||||
label: "Key Activity",
|
||||
content: <KeyActivityPanel keyMetrics={keyMetrics} hidePromptCachingMetrics={entityType === "agent"} />,
|
||||
content: (
|
||||
<KeyActivityPanel
|
||||
keyMetrics={keyMetrics}
|
||||
hidePromptCachingMetrics={entityType === "agent"}
|
||||
apiKeyTruncation={apiKeyTruncation}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ key: "endpoints", label: "Endpoint Activity", content: <EndpointUsage userSpendData={spendData} /> },
|
||||
];
|
||||
|
||||
const spendFetchState = { coversRange, cancelled, failed };
|
||||
const spendFetchState = { coversRange, cancelled, failed, apiKeyTruncation };
|
||||
|
||||
return (
|
||||
<div style={{ width: "100%" }} className="relative">
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ import { ActivityMetrics, processActivityData } from "@/components/activity_metr
|
|||
import CloudZeroExportModal from "@/components/cloudzero_export_modal";
|
||||
import UserDropdown from "@/components/common_components/UserDropdown";
|
||||
import EntityUsageExportModal from "@/components/EntityUsageExport";
|
||||
import { getExportBlockedReason } from "@/components/EntityUsageExport/exportBlockedReason";
|
||||
import { getApiKeyTruncation, getExportBlockedReason } from "@/components/EntityUsageExport/exportBlockedReason";
|
||||
import KeyActivityPanel from "@/components/UsagePage/components/KeyActivityPanel";
|
||||
import { Team } from "@/components/key_team_helpers/key_list";
|
||||
import {
|
||||
|
|
@ -256,6 +256,10 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
coversRange: activeAggregated !== null || paginatedResult.coversRange,
|
||||
cancelled: paginatedResult.cancelled,
|
||||
failed: paginatedResult.failed,
|
||||
apiKeyTruncation: getApiKeyTruncation(
|
||||
userSpendData.metadata?.api_key_limit,
|
||||
userSpendData.metadata?.total_api_keys,
|
||||
),
|
||||
};
|
||||
const exportBlockedReason = getExportBlockedReason(spendFetchState);
|
||||
|
||||
|
|
@ -904,7 +908,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
<ActivityMetrics modelMetrics={modelMetrics} />
|
||||
</TabsContent>
|
||||
<TabsContent value="keys" keepMounted>
|
||||
<KeyActivityPanel keyMetrics={keyMetrics} />
|
||||
<KeyActivityPanel keyMetrics={keyMetrics} apiKeyTruncation={spendFetchState.apiKeyTruncation} />
|
||||
</TabsContent>
|
||||
<TabsContent value="mcp" keepMounted>
|
||||
<ActivityMetrics modelMetrics={mcpServerMetrics} />
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getExportBlockedReason, type UsageFetchState } from "./exportBlockedReason";
|
||||
import { getApiKeyTruncation, getExportBlockedReason, type UsageFetchState } from "./exportBlockedReason";
|
||||
|
||||
const state = (overrides: Partial<UsageFetchState> = {}): UsageFetchState => ({
|
||||
coversRange: true,
|
||||
cancelled: false,
|
||||
failed: false,
|
||||
apiKeyTruncation: undefined,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
|
|
@ -31,4 +32,27 @@ describe("getExportBlockedReason", () => {
|
|||
expect(reason).toMatch(/failed to load/i);
|
||||
expect(reason).not.toMatch(/stopped/i);
|
||||
});
|
||||
|
||||
it("blocks when the aggregated endpoint dropped keys, since a per-team CSV would miss them", () => {
|
||||
const reason = getExportBlockedReason(state({ apiKeyTruncation: { limit: 100, total: 3000 } }));
|
||||
|
||||
expect(reason).toMatch(/100 highest-spend keys of 3000/);
|
||||
expect(reason).toMatch(/USAGE_TOP_API_KEYS_LIMIT/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getApiKeyTruncation", () => {
|
||||
it("reports truncation once the proxy saw more keys than it returned", () => {
|
||||
expect(getApiKeyTruncation(100, 101)).toEqual({ limit: 100, total: 101 });
|
||||
});
|
||||
|
||||
it("stays quiet when exactly the cap exists, since every key is on screen", () => {
|
||||
expect(getApiKeyTruncation(100, 100)).toBeUndefined();
|
||||
expect(getApiKeyTruncation(100, 7)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stays quiet when the response carries no cap, as the paginated fallback does", () => {
|
||||
expect(getApiKeyTruncation(undefined, undefined)).toBeUndefined();
|
||||
expect(getApiKeyTruncation(100, null)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,13 +1,31 @@
|
|||
export interface ApiKeyTruncation {
|
||||
limit: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface UsageFetchState {
|
||||
coversRange: boolean;
|
||||
cancelled: boolean;
|
||||
failed: boolean;
|
||||
apiKeyTruncation: ApiKeyTruncation | undefined;
|
||||
}
|
||||
|
||||
export const getExportBlockedReason = ({ coversRange, cancelled, failed }: UsageFetchState): string | undefined => {
|
||||
export const getApiKeyTruncation = (apiKeyLimit: unknown, totalApiKeys: unknown): ApiKeyTruncation | undefined => {
|
||||
if (typeof apiKeyLimit !== "number" || typeof totalApiKeys !== "number") return undefined;
|
||||
return totalApiKeys > apiKeyLimit ? { limit: apiKeyLimit, total: totalApiKeys } : undefined;
|
||||
};
|
||||
|
||||
export const getExportBlockedReason = ({
|
||||
coversRange,
|
||||
cancelled,
|
||||
failed,
|
||||
apiKeyTruncation,
|
||||
}: UsageFetchState): string | undefined => {
|
||||
if (failed) return "Some spend data failed to load, so an export would under-report. Reload the page to try again.";
|
||||
if (cancelled)
|
||||
return "Loading was stopped before the whole range arrived, so an export would under-report. Reload the page to load it all.";
|
||||
if (!coversRange) return "Spend data is still loading, so an export would under-report. Wait for it to finish.";
|
||||
if (apiKeyTruncation !== undefined)
|
||||
return `Only the ${apiKeyTruncation.limit} highest-spend keys of ${apiKeyTruncation.total} were loaded, so a per-team export would under-report. Raise USAGE_TOP_API_KEYS_LIMIT on the proxy to load more keys.`;
|
||||
return undefined;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -68,4 +68,14 @@ describe("KeyActivityPanel", () => {
|
|||
expect(screen.getByLabelText("Search keys")).toHaveValue("");
|
||||
expect(screen.getByTestId("rendered-keys")).toHaveTextContent("hash-alicehash-bob");
|
||||
});
|
||||
|
||||
it("says how many keys the proxy left out when only the top spenders were loaded", () => {
|
||||
render(<KeyActivityPanel keyMetrics={keyMetrics} apiKeyTruncation={{ limit: 2, total: 3000 }} />);
|
||||
expect(screen.getByRole("note")).toHaveTextContent("Only the 2 highest-spend keys of 3,000 are loaded");
|
||||
});
|
||||
|
||||
it("shows no truncation note when every key is loaded", () => {
|
||||
render(<KeyActivityPanel keyMetrics={keyMetrics} />);
|
||||
expect(screen.queryByRole("note")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Search, X } from "lucide-react";
|
|||
import React, { useMemo, useState } from "react";
|
||||
|
||||
import { ActivityMetrics } from "@/components/activity_metrics";
|
||||
import type { ApiKeyTruncation } from "@/components/EntityUsageExport/exportBlockedReason";
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
|
||||
|
||||
import { filterKeyActivity } from "../keyActivityFilter";
|
||||
|
|
@ -10,9 +11,14 @@ import type { ModelActivityData } from "../types";
|
|||
interface KeyActivityPanelProps {
|
||||
keyMetrics: Record<string, ModelActivityData>;
|
||||
hidePromptCachingMetrics?: boolean;
|
||||
apiKeyTruncation?: ApiKeyTruncation;
|
||||
}
|
||||
|
||||
const KeyActivityPanel: React.FC<KeyActivityPanelProps> = ({ keyMetrics, hidePromptCachingMetrics = false }) => {
|
||||
const KeyActivityPanel: React.FC<KeyActivityPanelProps> = ({
|
||||
keyMetrics,
|
||||
hidePromptCachingMetrics = false,
|
||||
apiKeyTruncation,
|
||||
}) => {
|
||||
const [query, setQuery] = useState("");
|
||||
const filtered = useMemo(() => filterKeyActivity(keyMetrics, query), [keyMetrics, query]);
|
||||
const totalKeys = Object.keys(keyMetrics).length;
|
||||
|
|
@ -43,6 +49,12 @@ const KeyActivityPanel: React.FC<KeyActivityPanelProps> = ({ keyMetrics, hidePro
|
|||
<span className="text-sm text-muted-foreground">
|
||||
Showing {shownKeys.toLocaleString()} of {totalKeys.toLocaleString()} keys
|
||||
</span>
|
||||
{apiKeyTruncation !== undefined && (
|
||||
<span className="text-sm text-muted-foreground" role="note">
|
||||
Only the {apiKeyTruncation.limit.toLocaleString()} highest-spend keys of{" "}
|
||||
{apiKeyTruncation.total.toLocaleString()} are loaded
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isFiltering && totalKeys > 0 && shownKeys === 0 ? (
|
||||
<p className="rounded-lg border p-6 text-center text-sm text-muted-foreground">
|
||||
|
|
|
|||
34
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
34
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -26716,6 +26716,16 @@ export interface components {
|
|||
* @description If True, router fallbacks configured in router_settings are only attempted when the calling key (and its team and project) is allowed to call the fallback model; unauthorized fallback targets are skipped and the primary model's error is returned. Default is False.
|
||||
*/
|
||||
enforce_fallback_model_access?: boolean | null;
|
||||
/**
|
||||
* Failed Login Block Seconds
|
||||
* @description How long a blocked source address, or source address and username, stays blocked. Every attempt from a blocked key, right or wrong, is refused with 429 before the password is checked; the block is not extended by refused attempts. Set under `general_settings` in config.yaml. Defaults to 300
|
||||
*/
|
||||
failed_login_block_seconds?: number | null;
|
||||
/**
|
||||
* Failed Login Window Seconds
|
||||
* @description Fixed window in seconds over which failed Admin UI sign-in attempts are counted. The window starts at the first failure and is not extended by later ones. Set under `general_settings` in config.yaml. Defaults to 60
|
||||
*/
|
||||
failed_login_window_seconds?: number | null;
|
||||
/**
|
||||
* Forward Client Headers To Llm Api
|
||||
* @description If True, forwards client headers (e.g. Authorization) to the LLM API. Required for Claude Code with Max subscription.
|
||||
|
|
@ -26760,6 +26770,18 @@ export interface components {
|
|||
* @description max batch input file size in MB for /v1/files uploads with purpose=batch, if a file is larger than this size it will be rejected before being forwarded to the provider
|
||||
*/
|
||||
max_batch_file_size_mb?: number | null;
|
||||
/**
|
||||
* Max Failed Login Attempts Per Source
|
||||
* @description Failed Admin UI sign-in attempts allowed from one source address, across every username, within `failed_login_window_seconds`. One more blocks that address for `failed_login_block_seconds`. Half this value, rounded down but at least 1, is the allowance for one username from that address; one more blocks that address for that username only, and its further failures stop counting toward the address limit, so a script stuck on one account does not block everyone behind a shared address. The per-address limit is only enforced when `trusted_proxy_ranges` is set: to the proxies in front of LiteLLM, or to an empty list when clients connect directly. Left unset, the peer address may be a shared ingress and only the per-username half runs. IPv6 addresses are grouped by /64. Set under `general_settings` in config.yaml. Defaults to 10
|
||||
*/
|
||||
max_failed_login_attempts_per_source?: number | null;
|
||||
/**
|
||||
* Max Failed Login Attempts Per Source Overrides
|
||||
* @description Per-address overrides of `max_failed_login_attempts_per_source`, keyed by IP address or CIDR range, e.g. {'1.2.3.4': 200, '5.6.0.0/24': 500}. The most specific matching range wins (between equivalent keys such as '1.2.3.4' and '1.2.3.4/32', an exemption wins, then the higher limit), and the per-username allowance for that address follows as half the override. A value of 0 exempts the address from both limits. Set under `general_settings` in config.yaml
|
||||
*/
|
||||
max_failed_login_attempts_per_source_overrides?: {
|
||||
[key: string]: number;
|
||||
} | null;
|
||||
/**
|
||||
* Max File Size Mb
|
||||
* @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
|
||||
|
|
@ -26940,7 +26962,7 @@ export interface components {
|
|||
transcribe_media_buckets?: string[] | null;
|
||||
/**
|
||||
* Trusted Proxy Ranges
|
||||
* @description CIDR ranges of trusted reverse proxies allowed to provide identity headers for header-based auth paths such as enable_oauth2_proxy_auth and custom_ui_sso_sign_in_handler.
|
||||
* @description CIDR ranges of trusted reverse proxies allowed to provide identity headers for header-based auth paths such as enable_oauth2_proxy_auth and custom_ui_sso_sign_in_handler, and whose X-Forwarded-For is used to attribute Admin UI sign-in attempts to a source address. Set it to an empty list when clients connect directly, so the peer address is the source. Left unset, or containing an entry that is not an address or CIDR range, the per-source sign-in limit is off.
|
||||
*/
|
||||
trusted_proxy_ranges?: string[] | null;
|
||||
/**
|
||||
|
|
@ -27717,6 +27739,11 @@ export interface components {
|
|||
};
|
||||
/** DailySpendMetadata */
|
||||
DailySpendMetadata: {
|
||||
/**
|
||||
* Api Key Limit
|
||||
* @description When set, api_keys and every api_key_breakdown list at most this many keys, ranked by spend. Totals and the model, provider, mcp and endpoint rollups still cover every key.
|
||||
*/
|
||||
api_key_limit?: number | null;
|
||||
/**
|
||||
* Has More
|
||||
* @default false
|
||||
|
|
@ -27727,6 +27754,11 @@ export interface components {
|
|||
* @default 1
|
||||
*/
|
||||
page: number;
|
||||
/**
|
||||
* Total Api Keys
|
||||
* @description Distinct API keys matching the filters. When this exceeds api_key_limit, the per-key lists are truncated to the highest-spend keys.
|
||||
*/
|
||||
total_api_keys?: number | null;
|
||||
/**
|
||||
* Total Api Requests
|
||||
* @default 0
|
||||
|
|
|
|||
388
uv.lock
generated
388
uv.lock
generated
|
|
@ -10,7 +10,7 @@ resolution-markers = [
|
|||
]
|
||||
|
||||
[options]
|
||||
exclude-newer = "2026-09-14T23:55:55.024292355Z"
|
||||
exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values.
|
||||
exclude-newer-span = "P3D"
|
||||
|
||||
[manifest]
|
||||
|
|
@ -225,9 +225,9 @@ name = "aiologic"
|
|||
version = "0.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "sniffio", marker = "python_full_version < '3.13'" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
{ name = "wrapt", marker = "python_full_version < '3.13'" },
|
||||
{ name = "sniffio" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "wrapt" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/a7/809482759f40079f4c4328c7318bf569ae25d457f5017aad30a1b9aafedc/aiologic-0.17.0.tar.gz", hash = "sha256:65aa058e858c94cd208badb188e7f00b54dcabb3ba85b34f794db98074d108b9", size = 251625, upload-time = "2026-06-14T12:24:35.367Z" }
|
||||
wheels = [
|
||||
|
|
@ -315,16 +315,16 @@ vertex = [
|
|||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.13.0"
|
||||
version = "4.14.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
||||
{ name = "idna" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -519,14 +519,14 @@ name = "aurelio-sdk"
|
|||
version = "0.0.19"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiofiles", marker = "python_full_version < '3.14'" },
|
||||
{ name = "aiohttp", marker = "python_full_version < '3.14'" },
|
||||
{ name = "colorlog", marker = "python_full_version < '3.14'" },
|
||||
{ name = "pydantic", marker = "python_full_version < '3.14'" },
|
||||
{ name = "python-dotenv", marker = "python_full_version < '3.14'" },
|
||||
{ name = "requests", marker = "python_full_version < '3.14'" },
|
||||
{ name = "requests-toolbelt", marker = "python_full_version < '3.14'" },
|
||||
{ name = "tornado", marker = "python_full_version < '3.14'" },
|
||||
{ name = "aiofiles" },
|
||||
{ name = "aiohttp" },
|
||||
{ name = "colorlog" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "requests" },
|
||||
{ name = "requests-toolbelt" },
|
||||
{ name = "tornado" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/27/0e/c2e369ad173fb3d76448e46d10beb3dcc53388318933ddf8169a3f21a810/aurelio_sdk-0.0.19.tar.gz", hash = "sha256:14107e7440ff2efd0b4a08c52fb595e7680bd4bc973a0ddfb3b64157c6666b91", size = 15258, upload-time = "2025-03-24T14:37:32.203Z" }
|
||||
wheels = [
|
||||
|
|
@ -538,9 +538,9 @@ name = "aws-sdk-bedrock-runtime"
|
|||
version = "0.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "smithy-aws-core", extra = ["eventstream", "json"], marker = "python_full_version >= '3.12'" },
|
||||
{ name = "smithy-core", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "smithy-http", extra = ["aiohttp"], marker = "python_full_version >= '3.12'" },
|
||||
{ name = "smithy-aws-core", extra = ["eventstream", "json"] },
|
||||
{ name = "smithy-core" },
|
||||
{ name = "smithy-http", extra = ["aiohttp"] },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/b3/9c225cbfe9f17ea2e3d75a0fdd0b325ef79839b9c09a376bda63a7bf3bb3/aws_sdk_bedrock_runtime-0.11.0.tar.gz", hash = "sha256:f2c45d34625bf6a7b56375e29a53a16b376880bda771e4bbf7d84491622eb193", size = 173854, upload-time = "2026-08-24T21:17:16.304Z" }
|
||||
wheels = [
|
||||
|
|
@ -549,7 +549,7 @@ wheels = [
|
|||
|
||||
[package.optional-dependencies]
|
||||
awscrt = [
|
||||
{ name = "smithy-http", extra = ["awscrt"], marker = "python_full_version >= '3.12'" },
|
||||
{ name = "smithy-http", extra = ["awscrt"] },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1207,7 +1207,7 @@ name = "colorlog"
|
|||
version = "6.10.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "python_full_version < '3.14' and sys_platform == 'win32'" },
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/61/f083b5ac52e505dfc1c624eafbf8c7589a0d7f32daa398d2e7590efa5fda/colorlog-6.10.1.tar.gz", hash = "sha256:eb4ae5cb65fe7fec7773c2306061a8e63e02efc2c72eba9d27b0fa23c94f1321", size = 17162, upload-time = "2025-10-16T16:14:11.978Z" }
|
||||
wheels = [
|
||||
|
|
@ -1231,7 +1231,7 @@ resolution-markers = [
|
|||
"python_full_version < '3.11'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" }
|
||||
wheels = [
|
||||
|
|
@ -1304,7 +1304,7 @@ resolution-markers = [
|
|||
"python_full_version == '3.11.*'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
|
||||
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
|
||||
{ name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" }
|
||||
|
|
@ -1574,8 +1574,8 @@ name = "culsans"
|
|||
version = "0.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiologic", marker = "python_full_version < '3.13'" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
{ name = "aiologic" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" }
|
||||
wheels = [
|
||||
|
|
@ -1829,7 +1829,7 @@ name = "exceptiongroup"
|
|||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
||||
wheels = [
|
||||
|
|
@ -2412,11 +2412,11 @@ resolution-markers = [
|
|||
"python_full_version >= '3.14'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "google-auth", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "googleapis-common-protos", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "proto-plus", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "protobuf", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "requests", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "google-auth" },
|
||||
{ name = "googleapis-common-protos" },
|
||||
{ name = "proto-plus" },
|
||||
{ name = "protobuf" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/09/cd/63f1557235c2440fe0577acdbc32577c5c002684c58c7f4d770a92366a24/google_api_core-2.25.2.tar.gz", hash = "sha256:1c63aa6af0d0d5e37966f157a77f9396d820fba59f9e43e9415bc3dc5baff300", size = 166266, upload-time = "2025-10-03T00:07:34.778Z" }
|
||||
wheels = [
|
||||
|
|
@ -2425,8 +2425,8 @@ wheels = [
|
|||
|
||||
[package.optional-dependencies]
|
||||
grpc = [
|
||||
{ name = "grpcio", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "grpcio-status", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "grpcio" },
|
||||
{ name = "grpcio-status" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2440,11 +2440,11 @@ resolution-markers = [
|
|||
"python_full_version < '3.11'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "google-auth", marker = "python_full_version < '3.14'" },
|
||||
{ name = "googleapis-common-protos", marker = "python_full_version < '3.14'" },
|
||||
{ name = "proto-plus", marker = "python_full_version < '3.14'" },
|
||||
{ name = "protobuf", marker = "python_full_version < '3.14'" },
|
||||
{ name = "requests", marker = "python_full_version < '3.14'" },
|
||||
{ name = "google-auth" },
|
||||
{ name = "googleapis-common-protos" },
|
||||
{ name = "proto-plus" },
|
||||
{ name = "protobuf" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/16/ce/502a57fb0ec752026d24df1280b162294b22a0afb98a326084f9a979138b/google_api_core-2.30.3.tar.gz", hash = "sha256:e601a37f148585319b26db36e219df68c5d07b6382cff2d580e83404e44d641b", size = 177001, upload-time = "2026-04-10T00:41:28.035Z" }
|
||||
wheels = [
|
||||
|
|
@ -2453,8 +2453,8 @@ wheels = [
|
|||
|
||||
[package.optional-dependencies]
|
||||
grpc = [
|
||||
{ name = "grpcio", marker = "python_full_version < '3.14'" },
|
||||
{ name = "grpcio-status", marker = "python_full_version < '3.14'" },
|
||||
{ name = "grpcio" },
|
||||
{ name = "grpcio-status" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2623,12 +2623,12 @@ resolution-markers = [
|
|||
"python_full_version >= '3.14'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "google-api-core", version = "2.25.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" },
|
||||
{ name = "google-auth", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "google-cloud-core", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "google-crc32c", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "google-resumable-media", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "requests", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "google-api-core", version = "2.25.2", source = { registry = "https://pypi.org/simple" } },
|
||||
{ name = "google-auth" },
|
||||
{ name = "google-cloud-core" },
|
||||
{ name = "google-crc32c" },
|
||||
{ name = "google-resumable-media" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bd/ef/7cefdca67a6c8b3af0ec38612f9e78e5a9f6179dd91352772ae1a9849246/google_cloud_storage-3.4.1.tar.gz", hash = "sha256:6f041a297e23a4b485fad8c305a7a6e6831855c208bcbe74d00332a909f82268", size = 17238203, upload-time = "2025-10-08T18:43:39.665Z" }
|
||||
wheels = [
|
||||
|
|
@ -2646,12 +2646,12 @@ resolution-markers = [
|
|||
"python_full_version < '3.11'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "google-api-core", version = "2.30.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" },
|
||||
{ name = "google-auth", marker = "python_full_version < '3.14'" },
|
||||
{ name = "google-cloud-core", marker = "python_full_version < '3.14'" },
|
||||
{ name = "google-crc32c", marker = "python_full_version < '3.14'" },
|
||||
{ name = "google-resumable-media", marker = "python_full_version < '3.14'" },
|
||||
{ name = "requests", marker = "python_full_version < '3.14'" },
|
||||
{ name = "google-api-core", version = "2.30.3", source = { registry = "https://pypi.org/simple" } },
|
||||
{ name = "google-auth" },
|
||||
{ name = "google-cloud-core" },
|
||||
{ name = "google-crc32c" },
|
||||
{ name = "google-resumable-media" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4c/47/205eb8e9a1739b5345843e5a425775cbdc472cc38e7eda082ba5b8d02450/google_cloud_storage-3.10.1.tar.gz", hash = "sha256:97db9aa4460727982040edd2bd13ff3d5e2260b5331ad22895802da1fc2a5286", size = 17309950, upload-time = "2026-03-23T09:35:23.409Z" }
|
||||
wheels = [
|
||||
|
|
@ -4081,13 +4081,13 @@ name = "langchain-classic"
|
|||
version = "1.0.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "langchain-text-splitters", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "langsmith", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "pydantic", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "pyyaml", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "requests", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "sqlalchemy", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "langchain-core" },
|
||||
{ name = "langchain-text-splitters" },
|
||||
{ name = "langsmith" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "requests" },
|
||||
{ name = "sqlalchemy" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9b/78/84b5065816f348c39fefa4316f209f0135e8410216340a953bec17d9e4e4/langchain_classic-1.0.7.tar.gz", hash = "sha256:debbec8065e69b95108d2652e8d5c44f4516e19aa8d716c02ed2211c3aee099d", size = 10554118, upload-time = "2026-05-07T15:46:56.8Z" }
|
||||
wheels = [
|
||||
|
|
@ -4102,18 +4102,18 @@ resolution-markers = [
|
|||
"python_full_version < '3.11'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "aiohttp", marker = "python_full_version < '3.11'" },
|
||||
{ name = "dataclasses-json", marker = "python_full_version < '3.11'" },
|
||||
{ name = "httpx-sse", marker = "python_full_version < '3.11'" },
|
||||
{ name = "langchain", marker = "python_full_version < '3.11'" },
|
||||
{ name = "langchain-core", marker = "python_full_version < '3.11'" },
|
||||
{ name = "langsmith", marker = "python_full_version < '3.11'" },
|
||||
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "pydantic-settings", marker = "python_full_version < '3.11'" },
|
||||
{ name = "pyyaml", marker = "python_full_version < '3.11'" },
|
||||
{ name = "requests", marker = "python_full_version < '3.11'" },
|
||||
{ name = "sqlalchemy", marker = "python_full_version < '3.11'" },
|
||||
{ name = "tenacity", marker = "python_full_version < '3.11'" },
|
||||
{ name = "aiohttp" },
|
||||
{ name = "dataclasses-json" },
|
||||
{ name = "httpx-sse" },
|
||||
{ name = "langchain" },
|
||||
{ name = "langchain-core" },
|
||||
{ name = "langsmith" },
|
||||
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "requests" },
|
||||
{ name = "sqlalchemy" },
|
||||
{ name = "tenacity" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/83/49/2ff5354273809e9811392bc24bcffda545a196070666aef27bc6aacf1c21/langchain_community-0.3.31.tar.gz", hash = "sha256:250e4c1041539130f6d6ac6f9386cb018354eafccd917b01a4cff1950b80fd81", size = 33241237, upload-time = "2025-10-07T20:17:57.857Z" }
|
||||
wheels = [
|
||||
|
|
@ -4131,19 +4131,19 @@ resolution-markers = [
|
|||
"python_full_version == '3.11.*'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "aiohttp", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "dataclasses-json", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "httpx-sse", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "langchain-classic", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "langchain-core", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "langsmith", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
|
||||
{ name = "aiohttp" },
|
||||
{ name = "dataclasses-json" },
|
||||
{ name = "httpx-sse" },
|
||||
{ name = "langchain-classic" },
|
||||
{ name = "langchain-core" },
|
||||
{ name = "langsmith" },
|
||||
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
|
||||
{ name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
|
||||
{ name = "pydantic-settings", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "pyyaml", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "requests", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "sqlalchemy", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "tenacity", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "requests" },
|
||||
{ name = "sqlalchemy" },
|
||||
{ name = "tenacity" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/97/a03585d42b9bdb6fbd935282d6e3348b10322a24e6ce12d0c99eb461d9af/langchain_community-0.4.1.tar.gz", hash = "sha256:f3b211832728ee89f169ddce8579b80a085222ddb4f4ed445a46e977d17b1e85", size = 33241144, upload-time = "2025-10-27T15:20:32.504Z" }
|
||||
wheels = [
|
||||
|
|
@ -4215,7 +4215,7 @@ name = "langchain-text-splitters"
|
|||
version = "1.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "langchain-core" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/26/9f/6c545900fefb7b00ddfa3f16b80d61338a0ec68c31c5451eeeab99082760/langchain_text_splitters-1.1.2.tar.gz", hash = "sha256:782a723db0a4746ac91e251c7c1d57fd23636e4f38ed733074e28d7a86f41627", size = 293580, upload-time = "2026-04-16T14:20:39.162Z" }
|
||||
wheels = [
|
||||
|
|
@ -4959,16 +4959,16 @@ resolution-markers = [
|
|||
"python_full_version < '3.11'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "aiohttp", marker = "python_full_version < '3.11'" },
|
||||
{ name = "chevron", marker = "python_full_version < '3.11'" },
|
||||
{ name = "jsonpickle", marker = "python_full_version < '3.11'" },
|
||||
{ name = "langchain-community", version = "0.3.31", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "packaging", marker = "python_full_version < '3.11'" },
|
||||
{ name = "pydantic", marker = "python_full_version < '3.11'" },
|
||||
{ name = "pyhumps", marker = "python_full_version < '3.11'" },
|
||||
{ name = "requests", marker = "python_full_version < '3.11'" },
|
||||
{ name = "setuptools", marker = "python_full_version < '3.11'" },
|
||||
{ name = "tenacity", marker = "python_full_version < '3.11'" },
|
||||
{ name = "aiohttp" },
|
||||
{ name = "chevron" },
|
||||
{ name = "jsonpickle" },
|
||||
{ name = "langchain-community", version = "0.3.31", source = { registry = "https://pypi.org/simple" } },
|
||||
{ name = "packaging" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pyhumps" },
|
||||
{ name = "requests" },
|
||||
{ name = "setuptools" },
|
||||
{ name = "tenacity" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4a/6f/9ca1acf766848aaf5f0ac4140c34c91ad0dbfad2654359699644be3352c9/lunary-1.4.36.tar.gz", hash = "sha256:53f002f385c83d9c0e6368e7999923acffbde987f53c5205c2c249c38ee2d75c", size = 20253, upload-time = "2026-02-09T20:49:30.56Z" }
|
||||
wheels = [
|
||||
|
|
@ -4986,16 +4986,16 @@ resolution-markers = [
|
|||
"python_full_version == '3.11.*'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "aiohttp", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "chevron", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "jsonpickle", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "langchain-community", version = "0.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "packaging", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "pydantic", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "pyhumps", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "requests", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "setuptools", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "tenacity", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "aiohttp" },
|
||||
{ name = "chevron" },
|
||||
{ name = "jsonpickle" },
|
||||
{ name = "langchain-community", version = "0.4.1", source = { registry = "https://pypi.org/simple" } },
|
||||
{ name = "packaging" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pyhumps" },
|
||||
{ name = "requests" },
|
||||
{ name = "setuptools" },
|
||||
{ name = "tenacity" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/37/ef/1acbc6957585cc0110e648d787663871717ced3df27fcd3cb5e18fa418f3/lunary-1.4.37.tar.gz", hash = "sha256:1781091e9dceffcc28ebc4be7e085c9fec4102d98d7ca945ed0021e9ce03c36f", size = 20248, upload-time = "2026-02-12T08:15:02.091Z" }
|
||||
wheels = [
|
||||
|
|
@ -8787,10 +8787,10 @@ resolution-markers = [
|
|||
"python_full_version < '3.11'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "joblib", marker = "python_full_version < '3.11'" },
|
||||
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "threadpoolctl", marker = "python_full_version < '3.11'" },
|
||||
{ name = "joblib" },
|
||||
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } },
|
||||
{ name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" } },
|
||||
{ name = "threadpoolctl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/98/c2/a7855e41c9d285dfe86dc50b250978105dce513d6e459ea66a6aeb0e1e0c/scikit_learn-1.7.2.tar.gz", hash = "sha256:20e9e49ecd130598f1ca38a1d85090e1a600147b9c02fa6f15d69cb53d968fda", size = 7193136, upload-time = "2025-09-09T08:21:29.075Z" }
|
||||
wheels = [
|
||||
|
|
@ -8837,11 +8837,11 @@ resolution-markers = [
|
|||
"python_full_version == '3.11.*'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "joblib", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
|
||||
{ name = "joblib" },
|
||||
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
|
||||
{ name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
|
||||
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "threadpoolctl", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" } },
|
||||
{ name = "threadpoolctl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" }
|
||||
wheels = [
|
||||
|
|
@ -8891,7 +8891,7 @@ resolution-markers = [
|
|||
"python_full_version < '3.11'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" } },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" }
|
||||
wheels = [
|
||||
|
|
@ -8953,7 +8953,7 @@ resolution-markers = [
|
|||
"python_full_version == '3.11.*'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
|
||||
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
|
||||
{ name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" }
|
||||
|
|
@ -9038,20 +9038,20 @@ name = "semantic-router"
|
|||
version = "0.1.15"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp", marker = "python_full_version < '3.14'" },
|
||||
{ name = "aurelio-sdk", marker = "python_full_version < '3.14'" },
|
||||
{ name = "colorama", marker = "python_full_version < '3.14'" },
|
||||
{ name = "colorlog", marker = "python_full_version < '3.14'" },
|
||||
{ name = "litellm", marker = "python_full_version < '3.14'" },
|
||||
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
|
||||
{ name = "aiohttp" },
|
||||
{ name = "aurelio-sdk" },
|
||||
{ name = "colorama" },
|
||||
{ name = "colorlog" },
|
||||
{ name = "litellm" },
|
||||
{ name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' or python_full_version >= '3.14'" },
|
||||
{ name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and python_full_version < '3.14'" },
|
||||
{ name = "openai", marker = "python_full_version < '3.14'" },
|
||||
{ name = "pydantic", marker = "python_full_version < '3.14'" },
|
||||
{ name = "pyyaml", marker = "python_full_version < '3.14'" },
|
||||
{ name = "regex", marker = "python_full_version < '3.14'" },
|
||||
{ name = "tiktoken", marker = "python_full_version < '3.14'" },
|
||||
{ name = "tornado", marker = "python_full_version < '3.14'" },
|
||||
{ name = "urllib3", marker = "python_full_version < '3.14'" },
|
||||
{ name = "openai" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "regex" },
|
||||
{ name = "tiktoken" },
|
||||
{ name = "tornado" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/dc/a9/1a689e916e8b280f1fd8fb335cc059be626a22fe4533baa045d32fcd6de5/semantic_router-0.1.15.tar.gz", hash = "sha256:328256ddc3c2b713101ec69561d6585aecbf1198ea3461e1486289d8c3a35288", size = 95605, upload-time = "2026-05-23T12:58:15.444Z" }
|
||||
wheels = [
|
||||
|
|
@ -9134,9 +9134,9 @@ name = "smithy-aws-core"
|
|||
version = "0.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aws-sdk-signers", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "smithy-core", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "smithy-http", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "aws-sdk-signers" },
|
||||
{ name = "smithy-core" },
|
||||
{ name = "smithy-http" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/d3/501c0023548173416109ac42298ca33b708469dc922005770811a597949f/smithy_aws_core-0.11.0.tar.gz", hash = "sha256:29ee89976a520a87e3db557e03e115fdc21a0a60b81161e95174395a1b064da1", size = 38791, upload-time = "2026-08-24T21:16:59.631Z" }
|
||||
wheels = [
|
||||
|
|
@ -9145,10 +9145,10 @@ wheels = [
|
|||
|
||||
[package.optional-dependencies]
|
||||
eventstream = [
|
||||
{ name = "smithy-aws-event-stream", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "smithy-aws-event-stream" },
|
||||
]
|
||||
json = [
|
||||
{ name = "smithy-json", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "smithy-json" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -9156,7 +9156,7 @@ name = "smithy-aws-event-stream"
|
|||
version = "0.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "smithy-core", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "smithy-core" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/38/0e/6efb3a4ed92c0f1ada6de060ac92e7115a1e34d0ab1fb99a6056734a88ea/smithy_aws_event_stream-0.3.0.tar.gz", hash = "sha256:a0e227367a973144e205a075d0a424f95c92f26656a1018d08900da2ae547c49", size = 12818, upload-time = "2026-05-05T18:04:14.317Z" }
|
||||
wheels = [
|
||||
|
|
@ -9177,7 +9177,7 @@ name = "smithy-http"
|
|||
version = "0.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "smithy-core", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "smithy-core" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/98/78/b5f3113d6c8f0bc1f9777a7f5ca84b892d29efac05850e14f7d4f7e645b5/smithy_http-0.5.0.tar.gz", hash = "sha256:bb4a19672f7c7eeb872a308f777eb505281a5bafb1ee3d1ea9c760c06c352510", size = 31122, upload-time = "2026-08-24T21:16:56.488Z" }
|
||||
wheels = [
|
||||
|
|
@ -9186,11 +9186,11 @@ wheels = [
|
|||
|
||||
[package.optional-dependencies]
|
||||
aiohttp = [
|
||||
{ name = "aiohttp", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "yarl", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "aiohttp" },
|
||||
{ name = "yarl" },
|
||||
]
|
||||
awscrt = [
|
||||
{ name = "awscrt", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "awscrt" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -9198,8 +9198,8 @@ name = "smithy-json"
|
|||
version = "0.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "ijson", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "smithy-core", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "ijson" },
|
||||
{ name = "smithy-core" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/ac/04164eefb3da7479f52f6535b4b39cc8384c292cb2bb74279f2acc4f4b4d/smithy_json-0.3.0.tar.gz", hash = "sha256:c81c7034587e01bc64767cbbecb05a7d65ca9070612fd94e8a03e80540290a22", size = 7956, upload-time = "2026-08-20T17:55:32.177Z" }
|
||||
wheels = [
|
||||
|
|
@ -9277,23 +9277,23 @@ resolution-markers = [
|
|||
"python_full_version < '3.11'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "alabaster", marker = "python_full_version < '3.11'" },
|
||||
{ name = "babel", marker = "python_full_version < '3.11'" },
|
||||
{ name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" },
|
||||
{ name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "imagesize", marker = "python_full_version < '3.11'" },
|
||||
{ name = "jinja2", marker = "python_full_version < '3.11'" },
|
||||
{ name = "packaging", marker = "python_full_version < '3.11'" },
|
||||
{ name = "pygments", marker = "python_full_version < '3.11'" },
|
||||
{ name = "requests", marker = "python_full_version < '3.11'" },
|
||||
{ name = "snowballstemmer", marker = "python_full_version < '3.11'" },
|
||||
{ name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.11'" },
|
||||
{ name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.11'" },
|
||||
{ name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.11'" },
|
||||
{ name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.11'" },
|
||||
{ name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.11'" },
|
||||
{ name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.11'" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
{ name = "alabaster" },
|
||||
{ name = "babel" },
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" } },
|
||||
{ name = "imagesize" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pygments" },
|
||||
{ name = "requests" },
|
||||
{ name = "snowballstemmer" },
|
||||
{ name = "sphinxcontrib-applehelp" },
|
||||
{ name = "sphinxcontrib-devhelp" },
|
||||
{ name = "sphinxcontrib-htmlhelp" },
|
||||
{ name = "sphinxcontrib-jsmath" },
|
||||
{ name = "sphinxcontrib-qthelp" },
|
||||
{ name = "sphinxcontrib-serializinghtml" },
|
||||
{ name = "tomli" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" }
|
||||
wheels = [
|
||||
|
|
@ -9308,23 +9308,23 @@ resolution-markers = [
|
|||
"python_full_version == '3.11.*'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "alabaster", marker = "python_full_version == '3.11.*'" },
|
||||
{ name = "babel", marker = "python_full_version == '3.11.*'" },
|
||||
{ name = "colorama", marker = "python_full_version == '3.11.*' and sys_platform == 'win32'" },
|
||||
{ name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
|
||||
{ name = "imagesize", marker = "python_full_version == '3.11.*'" },
|
||||
{ name = "jinja2", marker = "python_full_version == '3.11.*'" },
|
||||
{ name = "packaging", marker = "python_full_version == '3.11.*'" },
|
||||
{ name = "pygments", marker = "python_full_version == '3.11.*'" },
|
||||
{ name = "requests", marker = "python_full_version == '3.11.*'" },
|
||||
{ name = "roman-numerals", marker = "python_full_version == '3.11.*'" },
|
||||
{ name = "snowballstemmer", marker = "python_full_version == '3.11.*'" },
|
||||
{ name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.11.*'" },
|
||||
{ name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.11.*'" },
|
||||
{ name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.11.*'" },
|
||||
{ name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.11.*'" },
|
||||
{ name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.11.*'" },
|
||||
{ name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.11.*'" },
|
||||
{ name = "alabaster" },
|
||||
{ name = "babel" },
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" } },
|
||||
{ name = "imagesize" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pygments" },
|
||||
{ name = "requests" },
|
||||
{ name = "roman-numerals" },
|
||||
{ name = "snowballstemmer" },
|
||||
{ name = "sphinxcontrib-applehelp" },
|
||||
{ name = "sphinxcontrib-devhelp" },
|
||||
{ name = "sphinxcontrib-htmlhelp" },
|
||||
{ name = "sphinxcontrib-jsmath" },
|
||||
{ name = "sphinxcontrib-qthelp" },
|
||||
{ name = "sphinxcontrib-serializinghtml" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" }
|
||||
wheels = [
|
||||
|
|
@ -9341,23 +9341,23 @@ resolution-markers = [
|
|||
"python_full_version == '3.12.*'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "alabaster", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "babel", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" },
|
||||
{ name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
|
||||
{ name = "imagesize", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "jinja2", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "packaging", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "pygments", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "requests", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "roman-numerals", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "snowballstemmer", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "alabaster" },
|
||||
{ name = "babel" },
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" } },
|
||||
{ name = "imagesize" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pygments" },
|
||||
{ name = "requests" },
|
||||
{ name = "roman-numerals" },
|
||||
{ name = "snowballstemmer" },
|
||||
{ name = "sphinxcontrib-applehelp" },
|
||||
{ name = "sphinxcontrib-devhelp" },
|
||||
{ name = "sphinxcontrib-htmlhelp" },
|
||||
{ name = "sphinxcontrib-jsmath" },
|
||||
{ name = "sphinxcontrib-qthelp" },
|
||||
{ name = "sphinxcontrib-serializinghtml" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" }
|
||||
wheels = [
|
||||
|
|
@ -9505,8 +9505,8 @@ name = "standard-aifc"
|
|||
version = "3.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "audioop-lts", marker = "python_full_version >= '3.13'" },
|
||||
{ name = "standard-chunk", marker = "python_full_version >= '3.13'" },
|
||||
{ name = "audioop-lts" },
|
||||
{ name = "standard-chunk" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c4/53/6050dc3dde1671eb3db592c13b55a8005e5040131f7509cef0215212cb84/standard_aifc-3.13.0.tar.gz", hash = "sha256:64e249c7cb4b3daf2fdba4e95721f811bde8bdfc43ad9f936589b7bb2fae2e43", size = 15240, upload-time = "2024-10-30T16:01:31.772Z" }
|
||||
wheels = [
|
||||
|
|
@ -9527,7 +9527,7 @@ name = "standard-sunau"
|
|||
version = "3.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "audioop-lts", marker = "python_full_version >= '3.13'" },
|
||||
{ name = "audioop-lts" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/66/e3/ce8d38cb2d70e05ffeddc28bb09bad77cfef979eb0a299c9117f7ed4e6a9/standard_sunau-3.13.0.tar.gz", hash = "sha256:b319a1ac95a09a2378a8442f403c66f4fd4b36616d6df6ae82b8e536ee790908", size = 9368, upload-time = "2024-10-30T16:01:41.626Z" }
|
||||
wheels = [
|
||||
|
|
@ -9561,8 +9561,8 @@ name = "taskgroup"
|
|||
version = "0.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||
{ name = "exceptiongroup" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f0/8d/e218e0160cc1b692e6e0e5ba34e8865dbb171efeb5fc9a704544b3020605/taskgroup-0.2.2.tar.gz", hash = "sha256:078483ac3e78f2e3f973e2edbf6941374fbea81b9c5d0a96f51d297717f4752d", size = 11504, upload-time = "2025-01-03T09:24:13.761Z" }
|
||||
wheels = [
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue