From 7ae157198bad05bb4df2a5573517cd6e75a120c9 Mon Sep 17 00:00:00 2001 From: Henrique Cavarsan Date: Mon, 23 Feb 2026 13:57:01 -0300 Subject: [PATCH 01/17] fix(proxy): recover from prisma-query-engine zombie process (#21899) * fix(proxy): recover from prisma-query-engine zombie process * fix(proxy): remove unused imports and extract helper to fix PLR0915 in utils.py --- litellm/proxy/utils.py | 445 ++++++++++++++--- .../proxy/test_prisma_engine_watchdog.py | 446 ++++++++++++++++++ 2 files changed, 814 insertions(+), 77 deletions(-) create mode 100644 tests/litellm/proxy/test_prisma_engine_watchdog.py diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index bbc549782d..f21fb0f9e3 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -13,8 +13,6 @@ from email.mime.text import MIMEText from typing import ( TYPE_CHECKING, Any, - Callable, - Coroutine, Dict, List, Literal, @@ -2295,6 +2293,11 @@ class PrismaClient: 0.0, float(os.getenv("PRISMA_AUTH_RECONNECT_LOCK_TIMEOUT_SECONDS", "0.1")), ) + self._engine_pidfd: int = -1 + self._engine_pid: int = 0 + self._watching_engine: bool = False + self._engine_confirmed_dead: bool = False + self._engine_wait_thread: Optional[threading.Thread] = None verbose_proxy_logger.debug("Success - Created Prisma Client") def get_request_status( @@ -3562,31 +3565,368 @@ class PrismaClient: ) raise e + def _get_engine_pid(self) -> int: + try: + engine = self.db._original_prisma._engine # type: ignore[attr-defined] + if engine is not None and engine.process is not None: + return engine.process.pid + except (AttributeError, TypeError): + pass + return 0 + + def _is_engine_alive(self) -> bool: + if self._engine_pid <= 0: + return True + try: + os.kill(self._engine_pid, 0) + return True + except ProcessLookupError: + return False + except (PermissionError, OSError): + return True + + @staticmethod + def _reap_all_zombies() -> set: + """Reap ALL zombie child processes via waitpid(-1, WNOHANG). + + Returns a set of reaped PIDs. As PID 1 in Docker (or any + process that spawns children), we must reap ALL terminated + children to prevent zombie accumulation. + """ + reaped: set = set() + while True: + try: + pid, _ = os.waitpid(-1, os.WNOHANG) + if pid == 0: + break + reaped.add(pid) + except ChildProcessError: + break + return reaped + + def _try_waitpid_watch(self, pid: int) -> bool: + """Watch engine PID via os.waitpid() in a dedicated thread. + + The thread blocks on os.waitpid(pid, 0) which is a kernel-level + wait and with zero CPU overhead, instant detection when the process exits. + When the process dies, the thread notifies the asyncio event loop + via call_soon_threadsafe. + + Returns True if the thread was started, False on failure. + """ + try: + probe_pid, _ = os.waitpid(pid, os.WNOHANG) + except ChildProcessError: + verbose_proxy_logger.debug( + "PID %s is not a child process; skipping waitpid watch.", pid, + ) + return False + + if probe_pid == pid: + verbose_proxy_logger.warning( + "prisma-query-engine PID %s already dead at watch start.", pid, + ) + self._engine_confirmed_dead = True + self._reap_all_zombies() + self._cleanup_engine_watcher() + asyncio.create_task( + self.attempt_db_reconnect( + reason="engine_process_death", + force=True, + ) + ) + return True + + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return False + + thread = threading.Thread( + target=self._waitpid_thread_func, + args=(pid, loop), + daemon=True, + name=f"prisma-engine-waitpid-{pid}", + ) + thread.start() + self._engine_wait_thread = thread + return True + + def _waitpid_thread_func(self, pid: int, loop: asyncio.AbstractEventLoop) -> None: + """Thread function: block until engine PID exits, then notify event loop. + + Note: uvloop/libuv may reap the child first via waitpid(-1, WNOHANG) + in its SIGCHLD handler. In that case our waitpid raises ChildProcessError. + we still notify the event loop because the engine is dead either way. + """ + try: + os.waitpid(pid, 0) + except ChildProcessError: + pass + except OSError: + pass + try: + loop.call_soon_threadsafe(self._on_engine_death_from_thread, pid) + except RuntimeError: + pass + + def _on_engine_death_from_thread(self, dead_pid: int) -> None: + """Called on the event loop thread when the waitpid thread detects engine death.""" + if self._engine_confirmed_dead: + return + if dead_pid != self._engine_pid: + return + verbose_proxy_logger.error( + "prisma-query-engine PID %s exited (waitpid thread); triggering reconnect.", + dead_pid, + ) + self._engine_confirmed_dead = True + self._reap_all_zombies() + self._cleanup_engine_watcher() + asyncio.create_task( + self.attempt_db_reconnect( + reason="engine_process_death", + force=True, + ) + ) + + def _try_pidfd_watch(self, pid: int) -> bool: + """ + Watch engine PID via pidfd_open + asyncio event loop reader. + + Returns True if pidfd watch was set up, False if unavailable or failed. + Broad OSError catch handles both ENOSYS and SECCOMP-blocked syscalls. + """ + if not hasattr(os, "pidfd_open"): + return False + fd = -1 + try: + fd = os.pidfd_open(pid, 0) # type: ignore[attr-defined] + asyncio.get_running_loop().add_reader(fd, self._on_pidfd_readable) + self._engine_pidfd = fd + return True + except OSError: + if fd >= 0: + os.close(fd) + return False + + def _on_pidfd_readable(self) -> None: + """pidfd became readable: engine process exited or became zombie. + + Sets _engine_confirmed_dead BEFORE cleanup so _run_reconnect_cycle + takes the heavy path (recreate Prisma client + re-arm watcher). + """ + if self._engine_confirmed_dead: + # Already handled -- just clean up pidfd resources. + if self._engine_pidfd >= 0: + try: + asyncio.get_running_loop().remove_reader(self._engine_pidfd) + except Exception: + pass + try: + os.close(self._engine_pidfd) + except OSError: + pass + self._engine_pidfd = -1 + return + dead_pid = self._engine_pid + verbose_proxy_logger.error( + "prisma-query-engine PID %s exited (pidfd event); triggering reconnect.", + dead_pid, + ) + self._engine_confirmed_dead = True + self._reap_all_zombies() + self._cleanup_engine_watcher() + asyncio.create_task( + self.attempt_db_reconnect( + reason="engine_process_death", + force=True, + ) + ) + + async def _poll_engine_proc(self) -> None: + """poll via os.kill(pid, 0) every 1s. + Only used when BOTH waitpid thread and pidfd are unavailable + (e.g., PID is not our child process and pidfd_open fails) + """ + while self._watching_engine and self._engine_pid > 0: + try: + os.kill(self._engine_pid, 0) + except ProcessLookupError: + verbose_proxy_logger.error( + "prisma-query-engine PID %s gone; triggering reconnect.", + self._engine_pid, + ) + self._engine_confirmed_dead = True + self._reap_all_zombies() + self._cleanup_engine_watcher() + await self.attempt_db_reconnect( + reason="engine_process_death", + force=True, + ) + return + except (PermissionError, OSError): + verbose_proxy_logger.debug( + "Cannot signal PID %s; stopping engine poll.", + self._engine_pid, + ) + self._cleanup_engine_watcher() + return + await asyncio.sleep(1) + + def _cleanup_engine_watcher(self) -> None: + """Clean up pidfd reader, waitpid thread ref, or stop polling and reset state.""" + self._watching_engine = False + if self._engine_pidfd >= 0: + try: + asyncio.get_running_loop().remove_reader(self._engine_pidfd) + except Exception: + pass + try: + os.close(self._engine_pidfd) + except OSError: + pass + self._engine_pidfd = -1 + self._engine_wait_thread = None + self._engine_pid = 0 + + async def _start_engine_watcher(self) -> None: + """ + Start watching the Prisma query engine process for death. + + Detection priority: + 1. os.waitpid() in a dedicated thread, works with all event loops. + 2. pidfd_open kernel fd registered with asyncio. + 3. os.kill(pid, 0) polling (1s), last-resort fallback when neither + waitpid thread nor pidfd are available. + + """ + if self._watching_engine or self._engine_pidfd >= 0 or self._engine_wait_thread is not None: + return + pid = self._get_engine_pid() + if pid == 0: + verbose_proxy_logger.debug("Could not find prisma-query-engine PID; engine death detection unavailable.") + return + self._engine_pid = pid + self._engine_confirmed_dead = False + verbose_proxy_logger.info("Found prisma-query-engine at PID %s.", pid) + waitpid_ok = self._try_waitpid_watch(pid) + pidfd_ok = False if waitpid_ok else self._try_pidfd_watch(pid) + if waitpid_ok: + verbose_proxy_logger.info( + "Watching engine PID %s via waitpid thread.", pid, + ) + elif pidfd_ok: + verbose_proxy_logger.info( + "Watching engine PID %s via pidfd.", pid, + ) + else: + verbose_proxy_logger.info( + "Watching engine PID %s via os.kill polling.", pid, + ) + self._watching_engine = True + asyncio.create_task(self._poll_engine_proc()) + + def _stop_engine_watcher(self) -> None: + """Stop watching the engine process and clean up all resources.""" + self._cleanup_engine_watcher() + self._engine_confirmed_dead = False + verbose_proxy_logger.debug("Stopped engine process watcher.") + async def _run_reconnect_cycle( self, timeout_seconds: Optional[float] = None ) -> None: """ - Run a reconnect cycle with direct db operations and a single overall timeout - budget to avoid long retries on hot paths (e.g. auth). + Run a reconnect cycle with a single overall timeout budget. + + Uses the _engine_confirmed_dead flag (set by waitpid thread / pidfd / poll + handlers) to choose between heavy reconnect (engine dead -- recreate + Prisma client, re-arm watcher) and lightweight reconnect (network + blip -- disconnect, connect, SELECT 1). """ - async def _do_direct_reconnect() -> None: - try: - await self.db.disconnect() - except Exception as disconnect_err: - verbose_proxy_logger.debug( - "Prisma DB disconnect before reconnect failed (ignored): %s", - disconnect_err, - ) - - await self.db.connect() - await self.db.query_raw("SELECT 1") - effective_timeout = ( - timeout_seconds - if timeout_seconds is not None - else self._db_watchdog_reconnect_timeout_seconds + timeout_seconds if timeout_seconds is not None else self._db_watchdog_reconnect_timeout_seconds ) - await asyncio.wait_for(_do_direct_reconnect(), timeout=effective_timeout) + + engine_is_dead = self._engine_confirmed_dead or ( + self._engine_pid > 0 and not self._is_engine_alive() + ) + + if engine_is_dead: + dead_pid = self._engine_pid + verbose_proxy_logger.warning( + "prisma-query-engine PID %s is dead; reconnecting.", + dead_pid, + ) + self._reap_all_zombies() + self._cleanup_engine_watcher() + self._engine_confirmed_dead = False + + async def _do_heavy_reconnect() -> None: + db_url = os.getenv("DATABASE_URL", "") + if not db_url: + verbose_proxy_logger.error("DATABASE_URL not set; cannot recreate Prisma client.") + raise RuntimeError("DATABASE_URL not set") + await self.db.recreate_prisma_client(db_url) + await self._start_engine_watcher() + + await asyncio.wait_for(_do_heavy_reconnect(), timeout=effective_timeout) + else: + verbose_proxy_logger.debug("Performing Prisma DB reconnect (engine alive or unknown).") + + async def _do_direct_reconnect() -> None: + try: + await self.db.disconnect() + except Exception as disconnect_err: + verbose_proxy_logger.debug( + "Prisma DB disconnect before reconnect failed (ignored): %s", + disconnect_err, + ) + + await self.db.connect() + await self.db.query_raw("SELECT 1") + + await asyncio.wait_for(_do_direct_reconnect(), timeout=effective_timeout) + + async def _attempt_reconnect_inside_lock( + self, + force: bool, + reason: str, + timeout_seconds: Optional[float], + ) -> bool: + now = time.time() + if ( + force is False + and now - self._db_last_reconnect_attempt_ts + < self._db_reconnect_cooldown_seconds + ): + verbose_proxy_logger.debug( + "Skipping DB reconnect attempt inside lock due to cooldown. reason=%s", + reason, + ) + return False + + verbose_proxy_logger.warning( + "Attempting Prisma DB reconnect. reason=%s", reason + ) + + reconnect_succeeded = False + try: + await self._run_reconnect_cycle(timeout_seconds=timeout_seconds) + reconnect_succeeded = True + verbose_proxy_logger.info( + "Prisma DB reconnect succeeded. reason=%s", reason + ) + except Exception as reconnect_err: + verbose_proxy_logger.error( + "Prisma DB reconnect failed. reason=%s error=%s", + reason, + reconnect_err, + ) + finally: + self._db_last_reconnect_attempt_ts = time.time() + + return reconnect_succeeded async def attempt_db_reconnect( self, @@ -3613,59 +3953,10 @@ class PrismaClient: ) return False - async def _attempt_reconnect_inside_lock() -> bool: - now = time.time() - if ( - force is False - and now - self._db_last_reconnect_attempt_ts - < self._db_reconnect_cooldown_seconds - ): - verbose_proxy_logger.debug( - "Skipping DB reconnect attempt inside lock due to cooldown. reason=%s", - reason, - ) - return False - - verbose_proxy_logger.warning( - "Attempting Prisma DB reconnect. reason=%s", reason - ) - - reconnect_succeeded = False - try: - await self._run_reconnect_cycle(timeout_seconds=timeout_seconds) - reconnect_succeeded = True - verbose_proxy_logger.info( - "Prisma DB reconnect succeeded. reason=%s", reason - ) - except Exception as reconnect_err: - verbose_proxy_logger.error( - "Prisma DB reconnect failed. reason=%s error=%s", - reason, - reconnect_err, - ) - finally: - # Start cooldown after reconnect attempt has completed. - self._db_last_reconnect_attempt_ts = time.time() - - return reconnect_succeeded - if lock_timeout_seconds is None: async with self._db_reconnect_lock: - return await _attempt_reconnect_inside_lock() + return await self._attempt_reconnect_inside_lock(force, reason, timeout_seconds) - return await self._attempt_reconnect_with_lock_timeout( - _attempt_reconnect_inside_lock, - reason=reason, - lock_timeout_seconds=lock_timeout_seconds, - ) - - async def _attempt_reconnect_with_lock_timeout( - self, - reconnect_fn: Callable[[], Coroutine[Any, Any, bool]], - reason: str, - lock_timeout_seconds: float, - ) -> bool: - """Acquire the reconnect lock with a timeout, then run reconnect_fn.""" lock_acquired_by_timeout_task = False async def _acquire_reconnect_lock() -> bool: @@ -3713,14 +4004,14 @@ class PrismaClient: return False try: - return await reconnect_fn() + return await self._attempt_reconnect_inside_lock(force, reason, timeout_seconds) finally: self._db_reconnect_lock.release() async def start_db_health_watchdog_task(self) -> None: - """ - Start a background task that probes DB health and attempts reconnect on failure. - """ + """Start background tasks that monitor DB health: + - A periodic SELECT 1 probe that triggers reconnect on network/connection failure. + - A process-level watcher that detects engine death via waitpid thread, pidfd, or os.kill polling.""" if self._db_health_watchdog_enabled is not True: verbose_proxy_logger.debug( "Prisma DB health watchdog disabled via PRISMA_HEALTH_WATCHDOG_ENABLED" @@ -3738,11 +4029,11 @@ class PrismaClient: self._db_health_watchdog_probe_timeout_seconds, self._db_watchdog_reconnect_timeout_seconds, ) + await self._start_engine_watcher() async def stop_db_health_watchdog_task(self) -> None: - """ - Stop DB health watchdog task gracefully. - """ + """Stop DB health watchdog task and engine watcher gracefully.""" + self._stop_engine_watcher() if self._db_health_watchdog_task is None: return self._db_health_watchdog_task.cancel() diff --git a/tests/litellm/proxy/test_prisma_engine_watchdog.py b/tests/litellm/proxy/test_prisma_engine_watchdog.py new file mode 100644 index 0000000000..011b8002db --- /dev/null +++ b/tests/litellm/proxy/test_prisma_engine_watchdog.py @@ -0,0 +1,446 @@ +""" +Tests for PrismaClient engine watchdog: death detection and automatic reconnect. + +Covers: +- Engine PID discovery and liveness check +- Engine process gone (os.kill raises ProcessLookupError) → reconnect triggered +- PermissionError from os.kill → treated as alive (process exists but not ours) +- pidfd handler → schedules attempt_db_reconnect even when lock is held +- waitpid thread → instant cross-platform detection, triggers reconnect +- _run_reconnect_cycle branches: heavy path (engine dead) vs lightweight path (engine alive) +- _engine_confirmed_dead flag ensures heavy reconnect even after _engine_pid reset +- Successful heavy reconnect → watcher re-armed for new process +- Missing DATABASE_URL → graceful RuntimeError in reconnect cycle +- Shutdown → polling loop exits cleanly +""" + +import asyncio +import os +import threading +import time +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from litellm.proxy.utils import PrismaClient, ProxyLogging + + +@pytest.fixture(autouse=True) +def mock_prisma_binary(): + """Mock prisma.Prisma to avoid requiring generated Prisma binaries for unit tests.""" + import sys + + mock_module = MagicMock() + with patch.dict(sys.modules, {"prisma": mock_module}): + yield + + +@pytest.fixture +def mock_proxy_logging(): + proxy_logging = AsyncMock(spec=ProxyLogging) + proxy_logging.failure_handler = AsyncMock() + return proxy_logging + + +@pytest.fixture +def engine_client(mock_proxy_logging) -> PrismaClient: + """ + Minimal PrismaClient fixture for engine watchdog tests. + Uses the real constructor pattern from PR #21706 (database_url). + """ + client = PrismaClient(database_url="mock://test", proxy_logging_obj=mock_proxy_logging) + client.db = MagicMock() + client.db.recreate_prisma_client = AsyncMock() + client.db.disconnect = AsyncMock(return_value=None) + client.db.connect = AsyncMock(return_value=None) + client.db.query_raw = AsyncMock(return_value=[{"result": 1}]) + return client + + +# --------------------------------------------------------------------------- +# _is_engine_alive +# --------------------------------------------------------------------------- + + +def test_is_engine_alive_returns_true_when_pid_unknown(engine_client): + """_is_engine_alive returns True when no engine PID is tracked.""" + engine_client._engine_pid = 0 + assert engine_client._is_engine_alive() is True + + +def test_is_engine_alive_returns_false_when_process_gone(engine_client): + """_is_engine_alive returns False when os.kill raises ProcessLookupError.""" + engine_client._engine_pid = 9999 + with patch("os.kill", side_effect=ProcessLookupError): + assert engine_client._is_engine_alive() is False + + +def test_is_engine_alive_returns_true_on_permission_error(engine_client): + """_is_engine_alive returns True when os.kill raises PermissionError (process exists but not ours).""" + engine_client._engine_pid = 1234 + with patch("os.kill", side_effect=PermissionError): + assert engine_client._is_engine_alive() is True + + +def test_is_engine_alive_returns_true_for_running_process(engine_client): + """_is_engine_alive returns True when os.kill succeeds (process running).""" + engine_client._engine_pid = 1234 + with patch("os.kill"): + assert engine_client._is_engine_alive() is True + + +# --------------------------------------------------------------------------- +# _poll_engine_proc — calls attempt_db_reconnect on death +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_poll_missing_process_triggers_reconnect(engine_client) -> None: + """Polling loop triggers attempt_db_reconnect when os.kill raises ProcessLookupError.""" + engine_client._engine_pid = 1234 + engine_client._watching_engine = True + engine_client.attempt_db_reconnect = AsyncMock(return_value=True) + + with patch("os.kill", side_effect=ProcessLookupError): + await engine_client._poll_engine_proc() + + engine_client.attempt_db_reconnect.assert_awaited_once_with( + reason="engine_process_death", + force=True, + ) + + +@pytest.mark.asyncio +async def test_poll_permission_error_stops_polling(engine_client) -> None: + """Polling loop stops cleanly when os.kill raises PermissionError (process not ours).""" + engine_client._engine_pid = 1234 + engine_client._watching_engine = True + engine_client.attempt_db_reconnect = AsyncMock(return_value=True) + + with patch("os.kill", side_effect=PermissionError): + await engine_client._poll_engine_proc() + + # PermissionError means process exists but isn't ours — no reconnect, just stop polling + engine_client.attempt_db_reconnect.assert_not_awaited() + assert engine_client._watching_engine is False + assert engine_client._engine_pid == 0 + + +@pytest.mark.asyncio +async def test_stop_loop_halts_polling(engine_client) -> None: + """Polling loop exits cleanly when _stop_engine_watcher is called.""" + engine_client._engine_pid = 1234 + engine_client._watching_engine = True + + async def stop_during_sleep(_duration: float) -> None: + engine_client._stop_engine_watcher() + + with ( + patch("os.kill"), + patch("asyncio.sleep", side_effect=stop_during_sleep), + ): + await engine_client._poll_engine_proc() + + assert engine_client._watching_engine is False + assert engine_client._engine_pid == 0 + + +# --------------------------------------------------------------------------- +# _on_pidfd_readable — calls attempt_db_reconnect +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_pidfd_readable_schedules_reconnect(engine_client) -> None: + """pidfd handler schedules attempt_db_reconnect via asyncio.create_task.""" + engine_client._engine_pid = 1234 + engine_client.attempt_db_reconnect = AsyncMock(return_value=True) + + created_coros = [] + + def capture_task(coro): + created_coros.append(coro) + return MagicMock() + + with patch("asyncio.create_task", side_effect=capture_task): + engine_client._on_pidfd_readable() + + # Run the captured coroutine to completion + assert len(created_coros) == 1 + await created_coros[0] + + engine_client.attempt_db_reconnect.assert_awaited_once_with( + reason="engine_process_death", + force=True, + ) + + +@pytest.mark.asyncio +async def test_pidfd_schedules_reconnect_task_when_lock_held(engine_client) -> None: + """pidfd handler schedules reconnect task even when _db_reconnect_lock is held.""" + engine_client._engine_pid = 1234 + + created_coros = [] + + def capture_task(coro): + created_coros.append(coro) + return MagicMock() + + async with engine_client._db_reconnect_lock: + with patch("asyncio.create_task", side_effect=capture_task): + engine_client._on_pidfd_readable() + + for coro in created_coros: + coro.close() + + assert len(created_coros) == 1 + + +# --------------------------------------------------------------------------- +# _run_reconnect_cycle — engine liveness branching +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_run_reconnect_cycle_uses_heavy_path_when_engine_dead( + engine_client, +) -> None: + """_run_reconnect_cycle calls recreate_prisma_client when engine is dead.""" + engine_client._engine_pid = 1234 + engine_client._start_engine_watcher = AsyncMock() + + with ( + patch.object(engine_client, "_is_engine_alive", return_value=False), + patch.dict(os.environ, {"DATABASE_URL": "postgresql://test"}), + patch("os.waitpid", side_effect=ChildProcessError), + ): + await engine_client._run_reconnect_cycle(timeout_seconds=5.0) + + engine_client.db.recreate_prisma_client.assert_awaited_once_with("postgresql://test") + engine_client._start_engine_watcher.assert_awaited_once() + engine_client.db.connect.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_run_reconnect_cycle_uses_heavy_path_when_confirmed_dead( + engine_client, +) -> None: + """_run_reconnect_cycle takes heavy path when _engine_confirmed_dead is set. + + This is the critical race-condition fix: SIGCHLD/pidfd handlers set + _engine_confirmed_dead BEFORE _cleanup_engine_watcher resets _engine_pid + to 0, so the heavy path executes even after cleanup. + """ + engine_client._engine_pid = 0 # Already reset by cleanup! + engine_client._engine_confirmed_dead = True # But flag survives cleanup + engine_client._start_engine_watcher = AsyncMock() + + with ( + patch.dict(os.environ, {"DATABASE_URL": "postgresql://test"}), + patch("os.waitpid", side_effect=ChildProcessError), + ): + await engine_client._run_reconnect_cycle(timeout_seconds=5.0) + + engine_client.db.recreate_prisma_client.assert_awaited_once_with("postgresql://test") + engine_client._start_engine_watcher.assert_awaited_once() + engine_client.db.connect.assert_not_awaited() + assert engine_client._engine_confirmed_dead is False # Reset after use + + +@pytest.mark.asyncio +async def test_run_reconnect_cycle_uses_lightweight_path_when_engine_alive( + engine_client, +) -> None: + """_run_reconnect_cycle uses disconnect/connect when engine is alive.""" + engine_client._engine_pid = 1234 + + with patch.object(engine_client, "_is_engine_alive", return_value=True): + await engine_client._run_reconnect_cycle(timeout_seconds=5.0) + + engine_client.db.connect.assert_awaited_once() + engine_client.db.query_raw.assert_awaited_once_with("SELECT 1") + engine_client.db.recreate_prisma_client.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_run_reconnect_cycle_uses_lightweight_path_when_pid_unknown( + engine_client, +) -> None: + """_run_reconnect_cycle uses lightweight path when engine PID is not tracked.""" + engine_client._engine_pid = 0 + + await engine_client._run_reconnect_cycle(timeout_seconds=5.0) + + engine_client.db.connect.assert_awaited_once() + engine_client.db.query_raw.assert_awaited_once_with("SELECT 1") + engine_client.db.recreate_prisma_client.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_run_reconnect_cycle_heavy_path_raises_without_database_url( + engine_client, +) -> None: + """Heavy reconnect raises RuntimeError when DATABASE_URL is not set.""" + engine_client._engine_pid = 1234 + + with ( + patch.object(engine_client, "_is_engine_alive", return_value=False), + patch.dict(os.environ, {}, clear=True), + patch("os.waitpid", side_effect=ChildProcessError), + ): + with pytest.raises(RuntimeError, match="DATABASE_URL not set"): + await engine_client._run_reconnect_cycle(timeout_seconds=5.0) + + engine_client.db.recreate_prisma_client.assert_not_awaited() + + +# --------------------------------------------------------------------------- +# start/stop lifecycle integration +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_start_watchdog_task_also_starts_engine_watcher( + engine_client, +) -> None: + """start_db_health_watchdog_task() also starts engine watcher.""" + engine_client._start_engine_watcher = AsyncMock() + + loop = asyncio.get_running_loop() + dummy_task = loop.create_task(asyncio.sleep(3600)) + + def fake_create_task(coro): + coro.close() + return dummy_task + + with patch("asyncio.create_task", side_effect=fake_create_task): + await engine_client.start_db_health_watchdog_task() + + engine_client._start_engine_watcher.assert_awaited_once() + dummy_task.cancel() + try: + await dummy_task + except asyncio.CancelledError: + pass + + +@pytest.mark.asyncio +async def test_stop_watchdog_task_also_stops_engine_watcher( + engine_client, +) -> None: + """stop_db_health_watchdog_task() also stops engine watcher.""" + engine_client._stop_engine_watcher = MagicMock() + + loop = asyncio.get_running_loop() + dummy_task = loop.create_task(asyncio.sleep(3600)) + engine_client._db_health_watchdog_task = dummy_task + + await engine_client.stop_db_health_watchdog_task() + + engine_client._stop_engine_watcher.assert_called_once() + assert engine_client._db_health_watchdog_task is None + + +# --------------------------------------------------------------------------- +# waitpid thread (cross-platform) +# --------------------------------------------------------------------------- + + +def test_try_waitpid_watch_returns_false_when_not_child(engine_client): + """_try_waitpid_watch returns False when PID is not our child process.""" + engine_client._engine_pid = 9999 + with patch("os.waitpid", side_effect=ChildProcessError): + assert engine_client._try_waitpid_watch(9999) is False + assert engine_client._engine_wait_thread is None + + +def test_try_waitpid_watch_starts_thread_for_child(engine_client): + """_try_waitpid_watch starts a daemon thread when PID is our child.""" + engine_client._engine_pid = 1234 + mock_thread = MagicMock() + mock_loop = MagicMock() + with ( + patch("os.waitpid", return_value=(0, 0)), + patch("asyncio.get_running_loop", return_value=mock_loop), + patch("threading.Thread", return_value=mock_thread) as mock_thread_cls, + ): + result = engine_client._try_waitpid_watch(1234) + assert result is True + mock_thread.start.assert_called_once() + assert engine_client._engine_wait_thread is mock_thread + + +@pytest.mark.asyncio +async def test_try_waitpid_watch_handles_already_dead_engine(engine_client) -> None: + """_try_waitpid_watch detects engine already dead at watch start.""" + engine_client._engine_pid = 1234 + engine_client.attempt_db_reconnect = AsyncMock(return_value=True) + + created_coros = [] + + def capture_task(coro): + created_coros.append(coro) + return MagicMock() + + waitpid_calls = iter([(1234, 0)]) + + def mock_waitpid(pid, flags): + if pid == -1: + raise ChildProcessError + return next(waitpid_calls) + + with ( + patch("os.waitpid", side_effect=mock_waitpid), + patch("asyncio.create_task", side_effect=capture_task), + ): + result = engine_client._try_waitpid_watch(1234) + + assert result is True + assert engine_client._engine_confirmed_dead is True + assert len(created_coros) == 1 + created_coros[0].close() + + +@pytest.mark.asyncio +async def test_on_engine_death_from_thread_triggers_reconnect(engine_client) -> None: + """waitpid thread callback schedules attempt_db_reconnect.""" + engine_client._engine_pid = 1234 + engine_client.attempt_db_reconnect = AsyncMock(return_value=True) + + created_coros = [] + + def capture_task(coro): + created_coros.append(coro) + return MagicMock() + + with patch("asyncio.create_task", side_effect=capture_task): + engine_client._on_engine_death_from_thread(1234) + + assert len(created_coros) == 1 + await created_coros[0] + + engine_client.attempt_db_reconnect.assert_awaited_once_with( + reason="engine_process_death", + force=True, + ) + + +def test_on_engine_death_from_thread_no_double_trigger(engine_client): + """waitpid thread callback does not trigger reconnect if already confirmed dead.""" + engine_client._engine_pid = 1234 + engine_client._engine_confirmed_dead = True + + with patch("asyncio.create_task") as mock_create_task: + engine_client._on_engine_death_from_thread(1234) + + mock_create_task.assert_not_called() + + +def test_on_engine_death_from_thread_ignores_stale_pid(engine_client): + """waitpid thread callback ignores death notification for a stale PID.""" + engine_client._engine_pid = 5678 + + with patch("asyncio.create_task") as mock_create_task: + engine_client._on_engine_death_from_thread(1234) + + mock_create_task.assert_not_called() From 5899e909fd161ff2959de883c657231d896dbe57 Mon Sep 17 00:00:00 2001 From: Kesku Date: Thu, 19 Feb 2026 03:14:03 +0000 Subject: [PATCH 02/17] feat(perplexity): update Responses API integration to match Agent API - Rename "Agentic Research API" to "Agent API" Expand - supported Responses API parameters - Fix function tool handling to pass custom function tools through unchanged instead of heuristically mapping them. -Update model registry with current Perplexity models and presets - Add Function Calling and Structured Outputs documentation sections. - Unit tests for transformation logic. --- docs/my-website/docs/providers/perplexity.md | 121 ++++-- litellm/llms/perplexity/responses/__init__.py | 2 +- .../perplexity/responses/transformation.py | 88 ++++- model_prices_and_context_window.json | 95 ++++- ...est_perplexity_responses_transformation.py | 370 ++++++++++++++++++ 5 files changed, 620 insertions(+), 56 deletions(-) create mode 100644 tests/test_litellm/llms/perplexity/responses/test_perplexity_responses_transformation.py diff --git a/docs/my-website/docs/providers/perplexity.md b/docs/my-website/docs/providers/perplexity.md index 68adf9939c..e3991c63bf 100644 --- a/docs/my-website/docs/providers/perplexity.md +++ b/docs/my-website/docs/providers/perplexity.md @@ -120,7 +120,7 @@ All models listed here https://docs.perplexity.ai/docs/model-cards are supported -## Agentic Research API (Responses API) +## Agent API (Responses API) Requires v1.72.6+ @@ -196,7 +196,7 @@ import os os.environ['PERPLEXITY_API_KEY'] = "" response = responses( - model="perplexity/openai/gpt-4o", + model="perplexity/openai/gpt-5.2", input="Explain quantum computing in simple terms", custom_llm_provider="perplexity", max_output_tokens=500, @@ -215,7 +215,7 @@ import os os.environ['PERPLEXITY_API_KEY'] = "" response = responses( - model="perplexity/anthropic/claude-3-5-sonnet-20241022", + model="perplexity/anthropic/claude-sonnet-4-5", input="Write a short story about a robot learning to paint", custom_llm_provider="perplexity", max_output_tokens=500, @@ -234,7 +234,7 @@ import os os.environ['PERPLEXITY_API_KEY'] = "" response = responses( - model="perplexity/google/gemini-2.0-flash-exp", + model="perplexity/google/gemini-2.5-flash", input="Explain the concept of neural networks", custom_llm_provider="perplexity", max_output_tokens=500, @@ -253,7 +253,7 @@ import os os.environ['PERPLEXITY_API_KEY'] = "" response = responses( - model="perplexity/xai/grok-2-1212", + model="perplexity/xai/grok-4-1-fast-non-reasoning", input="What makes a good AI assistant?", custom_llm_provider="perplexity", max_output_tokens=500, @@ -276,7 +276,7 @@ import os os.environ['PERPLEXITY_API_KEY'] = "" response = responses( - model="perplexity/openai/gpt-4o", + model="perplexity/openai/gpt-5.2", input="What's the weather in San Francisco today?", custom_llm_provider="perplexity", tools=[{"type": "web_search"}], @@ -286,6 +286,78 @@ response = responses( print(response.output) ``` +### Function Calling + +The Agent API supports custom function tools. Pass function tools through unchanged: + +```python +from litellm import responses +import os + +os.environ['PERPLEXITY_API_KEY'] = "" + +response = responses( + model="perplexity/openai/gpt-5.2", + input="What's the weather in San Francisco?", + custom_llm_provider="perplexity", + tools=[ + {"type": "web_search"}, + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get the current weather for a location", + "parameters": { + "type": "object", + "properties": { + "location": {"type": "string"}, + "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}, + }, + }, + }, + }, + ], + instructions="Use tools when appropriate.", +) + +print(response.output) +``` + +### Structured Outputs + +Request JSON schema structured outputs via the `text` parameter: + +```python +from litellm import responses +import os + +os.environ['PERPLEXITY_API_KEY'] = "" + +response = responses( + model="perplexity/preset/pro-search", + input="Extract key facts about the Eiffel Tower", + custom_llm_provider="perplexity", + text={ + "format": { + "type": "json_schema", + "name": "facts", + "schema": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "height_meters": {"type": "number"}, + "year_built": {"type": "integer"}, + }, + "required": ["name", "height_meters", "year_built"], + }, + "strict": True, + } + }, +) + +print(response.output) +``` + ### Reasoning Effort (Responses API) @@ -319,7 +391,7 @@ import os os.environ['PERPLEXITY_API_KEY'] = "" response = responses( - model="perplexity/anthropic/claude-3-5-sonnet-20241022", + model="perplexity/anthropic/claude-sonnet-4-5", input=[ {"type": "message", "role": "system", "content": "You are a helpful assistant."}, {"type": "message", "role": "user", "content": "What are the latest AI developments?"}, @@ -343,7 +415,7 @@ import os os.environ['PERPLEXITY_API_KEY'] = "" response = responses( - model="perplexity/openai/gpt-4o", + model="perplexity/openai/gpt-5.2", input="Tell me a story about space exploration", custom_llm_provider="perplexity", stream=True, @@ -360,23 +432,28 @@ for chunk in response: | Provider | Model Name | Function Call | |----------|------------|---------------| -| OpenAI | gpt-4o | `responses(model="perplexity/openai/gpt-4o", ...)` | -| OpenAI | gpt-4o-mini | `responses(model="perplexity/openai/gpt-4o-mini", ...)` | | OpenAI | gpt-5.2 | `responses(model="perplexity/openai/gpt-5.2", ...)` | -| Anthropic | claude-3-5-sonnet-20241022 | `responses(model="perplexity/anthropic/claude-3-5-sonnet-20241022", ...)` | -| Anthropic | claude-3-5-haiku-20241022 | `responses(model="perplexity/anthropic/claude-3-5-haiku-20241022", ...)` | -| Google | gemini-2.0-flash-exp | `responses(model="perplexity/google/gemini-2.0-flash-exp", ...)` | -| Google | gemini-2.0-flash-thinking-exp | `responses(model="perplexity/google/gemini-2.0-flash-thinking-exp", ...)` | -| xAI | grok-2-1212 | `responses(model="perplexity/xai/grok-2-1212", ...)` | -| xAI | grok-2-vision-1212 | `responses(model="perplexity/xai/grok-2-vision-1212", ...)` | +| OpenAI | gpt-5.1 | `responses(model="perplexity/openai/gpt-5.1", ...)` | +| OpenAI | gpt-5-mini | `responses(model="perplexity/openai/gpt-5-mini", ...)` | +| Anthropic | claude-opus-4-6 | `responses(model="perplexity/anthropic/claude-opus-4-6", ...)` | +| Anthropic | claude-opus-4-5 | `responses(model="perplexity/anthropic/claude-opus-4-5", ...)` | +| Anthropic | claude-sonnet-4-5 | `responses(model="perplexity/anthropic/claude-sonnet-4-5", ...)` | +| Anthropic | claude-haiku-4-5 | `responses(model="perplexity/anthropic/claude-haiku-4-5", ...)` | +| Google | gemini-3-pro-preview | `responses(model="perplexity/google/gemini-3-pro-preview", ...)` | +| Google | gemini-3-flash-preview | `responses(model="perplexity/google/gemini-3-flash-preview", ...)` | +| Google | gemini-2.5-pro | `responses(model="perplexity/google/gemini-2.5-pro", ...)` | +| Google | gemini-2.5-flash | `responses(model="perplexity/google/gemini-2.5-flash", ...)` | +| xAI | grok-4-1-fast-non-reasoning | `responses(model="perplexity/xai/grok-4-1-fast-non-reasoning", ...)` | +| Perplexity | sonar | `responses(model="perplexity/perplexity/sonar", ...)` | ### Available Presets -| Preset Name | Function Call | -|----------------|--------------------------------------------------------| -| fast-search | `responses(model="perplexity/preset/fast-search", ...)`| -| pro-search | `responses(model="perplexity/preset/pro-search", ...)` | -| deep-research | `responses(model="perplexity/preset/deep-research", ...)`| +| Preset Name | Function Call | +|-------------|---------------| +| fast-search | `responses(model="perplexity/preset/fast-search", ...)` | +| pro-search | `responses(model="perplexity/preset/pro-search", ...)` | +| deep-research | `responses(model="perplexity/preset/deep-research", ...)` | +| advanced-deep-research | `responses(model="perplexity/preset/advanced-deep-research", ...)` | ### Complete Example @@ -388,7 +465,7 @@ os.environ['PERPLEXITY_API_KEY'] = "" # Comprehensive example with multiple features response = responses( - model="perplexity/openai/gpt-4o", + model="perplexity/openai/gpt-5.2", input="Research the latest developments in quantum computing and provide sources", custom_llm_provider="perplexity", tools=[ diff --git a/litellm/llms/perplexity/responses/__init__.py b/litellm/llms/perplexity/responses/__init__.py index 9bdf810e83..3285a47211 100644 --- a/litellm/llms/perplexity/responses/__init__.py +++ b/litellm/llms/perplexity/responses/__init__.py @@ -1,5 +1,5 @@ """ -Perplexity Agentic Research API (Responses API) module +Perplexity Agent API (Responses API) module """ from .transformation import PerplexityResponsesConfig diff --git a/litellm/llms/perplexity/responses/transformation.py b/litellm/llms/perplexity/responses/transformation.py index 178e76ea97..9e801c92f3 100644 --- a/litellm/llms/perplexity/responses/transformation.py +++ b/litellm/llms/perplexity/responses/transformation.py @@ -1,5 +1,5 @@ """ -Transformation logic for Perplexity Agentic Research API (Responses API) +Transformation logic for Perplexity Agent API (Responses API) This module handles the translation between OpenAI's Responses API format and Perplexity's Responses API format, which supports: @@ -32,10 +32,10 @@ from litellm.types.utils import LlmProviders class PerplexityResponsesConfig(OpenAIResponsesAPIConfig): """ - Configuration for Perplexity Agentic Research API (Responses API) + Configuration for Perplexity Agent API (Responses API) - Reference: https://docs.perplexity.ai/agentic-research/quickstart + Reference: https://docs.perplexity.ai/docs/agent-api/overview """ @property @@ -47,6 +47,7 @@ class PerplexityResponsesConfig(OpenAIResponsesAPIConfig): Perplexity Responses API supports a different set of parameters Ref: https://docs.perplexity.ai/api-reference/responses-post + Params aligned with response-echo fields and Open Responses spec. """ return [ "max_output_tokens", @@ -58,6 +59,23 @@ class PerplexityResponsesConfig(OpenAIResponsesAPIConfig): "preset", "instructions", "models", # Model fallback support + "tool_choice", + "parallel_tool_calls", + "max_tool_calls", + "text", + "previous_response_id", + "store", + "background", + "truncation", + "metadata", + "safety_identifier", + "user", + "stream_options", + "top_logprobs", + "prompt_cache_key", + "frequency_penalty", + "presence_penalty", + "service_tier", ] def validate_environment( @@ -142,35 +160,75 @@ class PerplexityResponsesConfig(OpenAIResponsesAPIConfig): tools_list = [dict(tool) if hasattr(tool, '__dict__') else tool for tool in tools] # type: ignore mapped_params["tools"] = self._transform_tools(tools_list) # type: ignore + # Tool control + if response_api_optional_params.get("tool_choice"): + mapped_params["tool_choice"] = response_api_optional_params["tool_choice"] + if response_api_optional_params.get("parallel_tool_calls") is not None: + mapped_params["parallel_tool_calls"] = response_api_optional_params["parallel_tool_calls"] + if response_api_optional_params.get("max_tool_calls"): + mapped_params["max_tool_calls"] = response_api_optional_params["max_tool_calls"] + + # Structured outputs + text_param = response_api_optional_params.get("text") + if text_param: + mapped_params["text"] = text_param + + # Conversation continuity + if response_api_optional_params.get("previous_response_id"): + mapped_params["previous_response_id"] = response_api_optional_params["previous_response_id"] + + # Storage and lifecycle + if response_api_optional_params.get("store") is not None: + mapped_params["store"] = response_api_optional_params["store"] + if response_api_optional_params.get("background") is not None: + mapped_params["background"] = response_api_optional_params["background"] + if response_api_optional_params.get("truncation"): + mapped_params["truncation"] = response_api_optional_params["truncation"] + + # Metadata + if response_api_optional_params.get("metadata"): + mapped_params["metadata"] = response_api_optional_params["metadata"] + if response_api_optional_params.get("safety_identifier"): + mapped_params["safety_identifier"] = response_api_optional_params["safety_identifier"] + if response_api_optional_params.get("user"): + mapped_params["user"] = response_api_optional_params["user"] + + # Additional + if response_api_optional_params.get("top_logprobs") is not None: + mapped_params["top_logprobs"] = response_api_optional_params["top_logprobs"] + if response_api_optional_params.get("prompt_cache_key"): + mapped_params["prompt_cache_key"] = response_api_optional_params["prompt_cache_key"] + if response_api_optional_params.get("frequency_penalty") is not None: + mapped_params["frequency_penalty"] = response_api_optional_params["frequency_penalty"] + if response_api_optional_params.get("presence_penalty") is not None: + mapped_params["presence_penalty"] = response_api_optional_params["presence_penalty"] + if response_api_optional_params.get("service_tier"): + mapped_params["service_tier"] = response_api_optional_params["service_tier"] + return mapped_params - + def _transform_tools(self, tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """ - Transform tools to Perplexity format + Transform tools to Perplexity format. - Perplexity supports: + Perplexity supports (per public OpenAPI spec): - web_search: Performs web searches - fetch_url: Fetches content from URLs + - function: Function Calling """ perplexity_tools = [] for tool in tools: if isinstance(tool, dict): - tool_type = tool.get("type") + tool_type = tool.get("type", "") # Direct Perplexity tool format if tool_type in ["web_search", "fetch_url"]: perplexity_tools.append(tool) - # OpenAI function format - try to map to Perplexity tools + # Function tools: Perplexity supports them natively elif tool_type == "function": - function = tool.get("function", {}) - function_name = function.get("name", "") - - if function_name == "web_search" or "search" in function_name.lower(): - perplexity_tools.append({"type": "web_search"}) - elif function_name == "fetch_url" or "fetch" in function_name.lower(): - perplexity_tools.append({"type": "fetch_url"}) + perplexity_tools.append(tool) return perplexity_tools diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index e54eaf89d7..a8de7f2056 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -26642,65 +26642,124 @@ "supports_function_calling": true, "supports_tool_choice": true }, + "perplexity/preset/fast-search": { + "litellm_provider": "perplexity", + "mode": "responses", + "supports_web_search": true, + "supports_preset": true, + "supports_function_calling": true + }, "perplexity/preset/pro-search": { "litellm_provider": "perplexity", "mode": "responses", "supports_web_search": true, - "supports_preset": true + "supports_preset": true, + "supports_function_calling": true }, - "perplexity/openai/gpt-4o": { + "perplexity/preset/deep-research": { "litellm_provider": "perplexity", "mode": "responses", "supports_web_search": true, - "supports_reasoning": false + "supports_preset": true, + "supports_function_calling": true }, - "perplexity/openai/gpt-4o-mini": { + "perplexity/preset/advanced-deep-research": { "litellm_provider": "perplexity", "mode": "responses", "supports_web_search": true, - "supports_reasoning": false + "supports_preset": true, + "supports_function_calling": true }, "perplexity/openai/gpt-5.2": { "litellm_provider": "perplexity", "mode": "responses", "supports_web_search": true, - "supports_reasoning": true + "supports_reasoning": true, + "supports_function_calling": true }, - "perplexity/anthropic/claude-3-5-sonnet-20241022": { + "perplexity/openai/gpt-5.1": { "litellm_provider": "perplexity", "mode": "responses", "supports_web_search": true, - "supports_reasoning": false + "supports_reasoning": false, + "supports_function_calling": true }, - "perplexity/anthropic/claude-3-5-haiku-20241022": { + "perplexity/openai/gpt-5-mini": { "litellm_provider": "perplexity", "mode": "responses", "supports_web_search": true, - "supports_reasoning": false + "supports_reasoning": false, + "supports_function_calling": true }, - "perplexity/google/gemini-2.0-flash-exp": { + "perplexity/anthropic/claude-opus-4-6": { "litellm_provider": "perplexity", "mode": "responses", "supports_web_search": true, - "supports_reasoning": false + "supports_reasoning": false, + "supports_function_calling": true }, - "perplexity/google/gemini-2.0-flash-thinking-exp": { + "perplexity/anthropic/claude-opus-4-5": { "litellm_provider": "perplexity", "mode": "responses", "supports_web_search": true, - "supports_reasoning": true + "supports_reasoning": false, + "supports_function_calling": true }, - "perplexity/xai/grok-2-1212": { + "perplexity/anthropic/claude-sonnet-4-5": { "litellm_provider": "perplexity", "mode": "responses", "supports_web_search": true, - "supports_reasoning": false + "supports_reasoning": false, + "supports_function_calling": true }, - "perplexity/xai/grok-2-vision-1212": { + "perplexity/anthropic/claude-haiku-4-5": { "litellm_provider": "perplexity", "mode": "responses", "supports_web_search": true, - "supports_reasoning": false + "supports_reasoning": false, + "supports_function_calling": true + }, + "perplexity/google/gemini-3-pro-preview": { + "litellm_provider": "perplexity", + "mode": "responses", + "supports_web_search": true, + "supports_reasoning": false, + "supports_function_calling": true + }, + "perplexity/google/gemini-3-flash-preview": { + "litellm_provider": "perplexity", + "mode": "responses", + "supports_web_search": true, + "supports_reasoning": false, + "supports_function_calling": true + }, + "perplexity/google/gemini-2.5-pro": { + "litellm_provider": "perplexity", + "mode": "responses", + "supports_web_search": true, + "supports_reasoning": false, + "supports_function_calling": true + }, + "perplexity/google/gemini-2.5-flash": { + "litellm_provider": "perplexity", + "mode": "responses", + "supports_web_search": true, + "supports_reasoning": false, + "supports_function_calling": true + }, + "perplexity/xai/grok-4-1-fast-non-reasoning": { + "litellm_provider": "perplexity", + "mode": "responses", + "supports_web_search": true, + "supports_reasoning": false, + "supports_function_calling": true + }, + "perplexity/perplexity/sonar": { + "litellm_provider": "perplexity", + "mode": "responses", + "supports_web_search": true, + "supports_reasoning": false, + "supports_function_calling": true }, "publicai/aisingapore/Qwen-SEA-LION-v4-32B-IT": { "input_cost_per_token": 0.0, diff --git a/tests/test_litellm/llms/perplexity/responses/test_perplexity_responses_transformation.py b/tests/test_litellm/llms/perplexity/responses/test_perplexity_responses_transformation.py new file mode 100644 index 0000000000..d2526cb7c6 --- /dev/null +++ b/tests/test_litellm/llms/perplexity/responses/test_perplexity_responses_transformation.py @@ -0,0 +1,370 @@ +""" +Tests for Perplexity Responses API transformation + +Tests the PerplexityResponsesConfig class that handles Perplexity-specific +transformations for the Agent API (Responses API). + +Source: litellm/llms/perplexity/responses/transformation.py +""" +import os +import sys + +sys.path.insert(0, os.path.abspath("../../../../..")) + +import pytest + +from litellm.llms.perplexity.responses.transformation import PerplexityResponsesConfig +from litellm.types.llms.openai import ResponsesAPIOptionalRequestParams +from litellm.types.utils import LlmProviders +from litellm.utils import ProviderConfigManager + + +class TestPerplexityResponsesTransformation: + """Test Perplexity Responses API configuration and transformations""" + + def test_function_tool_passthrough(self): + """Function tools with name/description/parameters are preserved""" + config = PerplexityResponsesConfig() + + params = ResponsesAPIOptionalRequestParams( + tools=[ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get the current weather", + "parameters": { + "type": "object", + "properties": { + "location": {"type": "string"}, + "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}, + }, + }, + }, + } + ] + ) + + result = config.map_openai_params( + response_api_optional_params=params, + model="perplexity/openai/gpt-5.2", + drop_params=False, + ) + + assert "tools" in result + assert len(result["tools"]) == 1 + assert result["tools"][0]["type"] == "function" + assert result["tools"][0]["function"]["name"] == "get_weather" + assert result["tools"][0]["function"]["description"] == "Get the current weather" + assert "parameters" in result["tools"][0]["function"] + + def test_web_search_tool_passthrough(self): + """web_search tools are passed through unchanged""" + config = PerplexityResponsesConfig() + + params = ResponsesAPIOptionalRequestParams(tools=[{"type": "web_search"}]) + + result = config.map_openai_params( + response_api_optional_params=params, + model="perplexity/openai/gpt-5.2", + drop_params=False, + ) + + assert "tools" in result + assert len(result["tools"]) == 1 + assert result["tools"][0]["type"] == "web_search" + + def test_fetch_url_tool_passthrough(self): + """fetch_url tools are passed through""" + config = PerplexityResponsesConfig() + + params = ResponsesAPIOptionalRequestParams(tools=[{"type": "fetch_url"}]) + + result = config.map_openai_params( + response_api_optional_params=params, + model="perplexity/openai/gpt-5.2", + drop_params=False, + ) + + assert "tools" in result + assert len(result["tools"]) == 1 + assert result["tools"][0]["type"] == "fetch_url" + + def test_mixed_tools_function_and_web_search(self): + """Mixed function and web_search tools are transformed correctly""" + config = PerplexityResponsesConfig() + + params = ResponsesAPIOptionalRequestParams( + tools=[ + {"type": "web_search"}, + { + "type": "function", + "function": { + "name": "custom_tool", + "description": "A custom tool", + "parameters": {"type": "object"}, + }, + }, + ] + ) + + result = config.map_openai_params( + response_api_optional_params=params, + model="perplexity/openai/gpt-5.2", + drop_params=False, + ) + + assert len(result["tools"]) == 2 + assert result["tools"][0]["type"] == "web_search" + assert result["tools"][1]["type"] == "function" + assert result["tools"][1]["function"]["name"] == "custom_tool" + + def test_tool_choice_mapping(self): + """tool_choice passes through""" + config = PerplexityResponsesConfig() + + params = ResponsesAPIOptionalRequestParams(tool_choice="required", temperature=0.7) + + result = config.map_openai_params( + response_api_optional_params=params, + model="perplexity/openai/gpt-5.2", + drop_params=False, + ) + + assert result.get("tool_choice") == "required" + + def test_parallel_tool_calls(self): + """parallel_tool_calls passes through""" + config = PerplexityResponsesConfig() + + params = ResponsesAPIOptionalRequestParams(parallel_tool_calls=True, temperature=0.7) + + result = config.map_openai_params( + response_api_optional_params=params, + model="perplexity/openai/gpt-5.2", + drop_params=False, + ) + + assert result.get("parallel_tool_calls") is True + + def test_max_tool_calls_mapping(self): + """max_tool_calls passes through""" + config = PerplexityResponsesConfig() + + params = ResponsesAPIOptionalRequestParams(max_tool_calls=5, temperature=0.7) + + result = config.map_openai_params( + response_api_optional_params=params, + model="perplexity/openai/gpt-5.2", + drop_params=False, + ) + + assert result.get("max_tool_calls") == 5 + + def test_text_passthrough(self): + """text param passes through as-is (Perplexity accepts Open Responses format directly)""" + config = PerplexityResponsesConfig() + + text_value = { + "format": { + "type": "json_schema", + "name": "weather_response", + "schema": {"type": "object", "properties": {"temp": {"type": "number"}}}, + "strict": True, + } + } + + params = ResponsesAPIOptionalRequestParams( + text=text_value, + temperature=0.7, + ) + + result = config.map_openai_params( + response_api_optional_params=params, + model="perplexity/openai/gpt-5.2", + drop_params=False, + ) + + assert "text" in result + assert result["text"] == text_value + assert "response_format" not in result + + def test_previous_response_id(self): + """previous_response_id passes through""" + config = PerplexityResponsesConfig() + + params = ResponsesAPIOptionalRequestParams( + previous_response_id="resp_abc123", + temperature=0.7, + ) + + result = config.map_openai_params( + response_api_optional_params=params, + model="perplexity/openai/gpt-5.2", + drop_params=False, + ) + + assert result.get("previous_response_id") == "resp_abc123" + + def test_store_background_truncation(self): + """Lifecycle params pass through""" + config = PerplexityResponsesConfig() + + params = ResponsesAPIOptionalRequestParams( + store=True, + background=False, + truncation="auto", + temperature=0.7, + ) + + result = config.map_openai_params( + response_api_optional_params=params, + model="perplexity/openai/gpt-5.2", + drop_params=False, + ) + + assert result.get("store") is True + assert result.get("background") is False + assert result.get("truncation") == "auto" + + def test_metadata_safety_identifier_user(self): + """Metadata params pass through""" + config = PerplexityResponsesConfig() + + params = ResponsesAPIOptionalRequestParams( + metadata={"request_id": "req_123"}, + safety_identifier="safety_123", + user="user_456", + temperature=0.7, + ) + + result = config.map_openai_params( + response_api_optional_params=params, + model="perplexity/openai/gpt-5.2", + drop_params=False, + ) + + assert result.get("metadata") == {"request_id": "req_123"} + assert result.get("safety_identifier") == "safety_123" + assert result.get("user") == "user_456" + + def test_all_supported_params_declared(self): + """get_supported_openai_params returns complete list""" + config = PerplexityResponsesConfig() + supported = config.get_supported_openai_params("perplexity/openai/gpt-5.2") + + expected = [ + "max_output_tokens", + "stream", + "temperature", + "top_p", + "tools", + "reasoning", + "preset", + "instructions", + "models", + "tool_choice", + "parallel_tool_calls", + "max_tool_calls", + "text", + "previous_response_id", + "store", + "background", + "truncation", + "metadata", + "safety_identifier", + "user", + "stream_options", + "top_logprobs", + "prompt_cache_key", + "frequency_penalty", + "presence_penalty", + "service_tier", + ] + + for param in expected: + assert param in supported, f"Missing supported param: {param}" + + def test_cost_transformation(self): + """Perplexity cost dict to OpenAI float""" + config = PerplexityResponsesConfig() + + usage_data = { + "input_tokens": 100, + "output_tokens": 200, + "total_tokens": 300, + "cost": { + "currency": "USD", + "input_cost": 0.0001, + "output_cost": 0.0002, + "total_cost": 0.0003, + }, + } + + result = config._transform_usage(usage_data) + + assert result["input_tokens"] == 100 + assert result["output_tokens"] == 200 + assert result["total_tokens"] == 300 + assert result["cost"] == 0.0003 + + def test_cost_transformation_float_passthrough(self): + """Cost already float passes through""" + config = PerplexityResponsesConfig() + + usage_data = { + "input_tokens": 100, + "output_tokens": 200, + "total_tokens": 300, + "cost": 0.0005, + } + + result = config._transform_usage(usage_data) + + assert result["cost"] == 0.0005 + + def test_preset_handling(self): + """Preset model names work""" + config = PerplexityResponsesConfig() + + data = config.transform_responses_api_request( + model="preset/pro-search", + input="What is AI?", + response_api_optional_request_params={"temperature": 0.7}, + litellm_params={}, + headers={}, + ) + + assert data["preset"] == "pro-search" + assert data["input"] == "What is AI?" + assert "temperature" in data + + def test_get_complete_url(self): + """Correct endpoint URL""" + config = PerplexityResponsesConfig() + + url = config.get_complete_url(api_base=None, litellm_params={}) + assert url == "https://api.perplexity.ai/v1/responses" + + custom_url = config.get_complete_url( + api_base="https://custom.perplexity.ai", + litellm_params={}, + ) + assert custom_url == "https://custom.perplexity.ai/v1/responses" + + url_with_slash = config.get_complete_url( + api_base="https://api.perplexity.ai/", + litellm_params={}, + ) + assert url_with_slash == "https://api.perplexity.ai/v1/responses" + + def test_perplexity_provider_config_registration(self): + """Test that Perplexity provider returns PerplexityResponsesConfig""" + config = ProviderConfigManager.get_provider_responses_api_config( + model="perplexity/openai/gpt-5.2", + provider=LlmProviders.PERPLEXITY, + ) + + assert config is not None + assert isinstance(config, PerplexityResponsesConfig) + assert config.custom_llm_provider == LlmProviders.PERPLEXITY From e468d108a60fa4c7c8e7f08186c60f4f6d1d00f4 Mon Sep 17 00:00:00 2001 From: Kesku Date: Thu, 19 Feb 2026 03:29:03 +0000 Subject: [PATCH 03/17] format --- .../perplexity/responses/transformation.py | 161 ++++++++++-------- .../expected_bedrock_batch_completions.jsonl | 4 +- ...est_perplexity_responses_transformation.py | 25 ++- 3 files changed, 113 insertions(+), 77 deletions(-) diff --git a/litellm/llms/perplexity/responses/transformation.py b/litellm/llms/perplexity/responses/transformation.py index 9e801c92f3..4a6fb4d69e 100644 --- a/litellm/llms/perplexity/responses/transformation.py +++ b/litellm/llms/perplexity/responses/transformation.py @@ -34,7 +34,7 @@ class PerplexityResponsesConfig(OpenAIResponsesAPIConfig): """ Configuration for Perplexity Agent API (Responses API) - + Reference: https://docs.perplexity.ai/docs/agent-api/overview """ @@ -45,7 +45,7 @@ class PerplexityResponsesConfig(OpenAIResponsesAPIConfig): def get_supported_openai_params(self, model: str) -> list: """ Perplexity Responses API supports a different set of parameters - + Ref: https://docs.perplexity.ai/api-reference/responses-post Params aligned with response-echo fields and Open Responses spec. """ @@ -83,16 +83,15 @@ class PerplexityResponsesConfig(OpenAIResponsesAPIConfig): ) -> dict: """Validate environment and set up headers""" # Get API key from environment - api_key = ( - get_secret_str("PERPLEXITYAI_API_KEY") - or get_secret_str("PERPLEXITY_API_KEY") + api_key = get_secret_str("PERPLEXITYAI_API_KEY") or get_secret_str( + "PERPLEXITY_API_KEY" ) - + if api_key: headers["Authorization"] = f"Bearer {api_key}" - + headers["Content-Type"] = "application/json" - + return headers def get_complete_url( @@ -102,15 +101,17 @@ class PerplexityResponsesConfig(OpenAIResponsesAPIConfig): ) -> str: """Get the complete URL for the Perplexity Responses API""" if api_base is None: - api_base = get_secret_str("PERPLEXITY_API_BASE") or "https://api.perplexity.ai" - + api_base = ( + get_secret_str("PERPLEXITY_API_BASE") or "https://api.perplexity.ai" + ) + # Ensure api_base doesn't end with a slash api_base = api_base.rstrip("/") - + # Add the responses endpoint return f"{api_base}/v1/responses" - def map_openai_params( + def map_openai_params( # noqa: PLR0915 self, response_api_optional_params: ResponsesAPIOptionalRequestParams, model: str, @@ -118,65 +119,75 @@ class PerplexityResponsesConfig(OpenAIResponsesAPIConfig): ) -> Dict: """ Map OpenAI Responses API parameters to Perplexity format - + Key differences: - Supports 'preset' parameter for predefined configurations - Supports 'instructions' parameter for system-level guidance - Tools are specified differently (web_search, fetch_url) """ mapped_params: Dict[str, Any] = {} - + # Map standard parameters if response_api_optional_params.get("max_output_tokens"): - mapped_params["max_output_tokens"] = response_api_optional_params["max_output_tokens"] - + mapped_params["max_output_tokens"] = response_api_optional_params[ + "max_output_tokens" + ] + if response_api_optional_params.get("temperature"): mapped_params["temperature"] = response_api_optional_params["temperature"] - + if response_api_optional_params.get("top_p"): mapped_params["top_p"] = response_api_optional_params["top_p"] - + if response_api_optional_params.get("stream"): mapped_params["stream"] = response_api_optional_params["stream"] - + if response_api_optional_params.get("stream_options"): - mapped_params["stream_options"] = response_api_optional_params["stream_options"] - + mapped_params["stream_options"] = response_api_optional_params[ + "stream_options" + ] + # Map Perplexity-specific parameters (using .get() with Any dict access) preset = response_api_optional_params.get("preset") # type: ignore if preset: mapped_params["preset"] = preset - + instructions = response_api_optional_params.get("instructions") # type: ignore if instructions: mapped_params["instructions"] = instructions - + if response_api_optional_params.get("reasoning"): mapped_params["reasoning"] = response_api_optional_params["reasoning"] - + tools = response_api_optional_params.get("tools") if tools: # Convert tools to list of dicts for transformation - tools_list = [dict(tool) if hasattr(tool, '__dict__') else tool for tool in tools] # type: ignore + tools_list = [dict(tool) if hasattr(tool, "__dict__") else tool for tool in tools] # type: ignore mapped_params["tools"] = self._transform_tools(tools_list) # type: ignore - + # Tool control if response_api_optional_params.get("tool_choice"): mapped_params["tool_choice"] = response_api_optional_params["tool_choice"] if response_api_optional_params.get("parallel_tool_calls") is not None: - mapped_params["parallel_tool_calls"] = response_api_optional_params["parallel_tool_calls"] + mapped_params["parallel_tool_calls"] = response_api_optional_params[ + "parallel_tool_calls" + ] if response_api_optional_params.get("max_tool_calls"): - mapped_params["max_tool_calls"] = response_api_optional_params["max_tool_calls"] - + mapped_params["max_tool_calls"] = response_api_optional_params[ + "max_tool_calls" + ] + # Structured outputs text_param = response_api_optional_params.get("text") if text_param: mapped_params["text"] = text_param - + # Conversation continuity if response_api_optional_params.get("previous_response_id"): - mapped_params["previous_response_id"] = response_api_optional_params["previous_response_id"] - + mapped_params["previous_response_id"] = response_api_optional_params[ + "previous_response_id" + ] + # Storage and lifecycle if response_api_optional_params.get("store") is not None: mapped_params["store"] = response_api_optional_params["store"] @@ -184,52 +195,60 @@ class PerplexityResponsesConfig(OpenAIResponsesAPIConfig): mapped_params["background"] = response_api_optional_params["background"] if response_api_optional_params.get("truncation"): mapped_params["truncation"] = response_api_optional_params["truncation"] - + # Metadata if response_api_optional_params.get("metadata"): mapped_params["metadata"] = response_api_optional_params["metadata"] if response_api_optional_params.get("safety_identifier"): - mapped_params["safety_identifier"] = response_api_optional_params["safety_identifier"] + mapped_params["safety_identifier"] = response_api_optional_params[ + "safety_identifier" + ] if response_api_optional_params.get("user"): mapped_params["user"] = response_api_optional_params["user"] - + # Additional if response_api_optional_params.get("top_logprobs") is not None: mapped_params["top_logprobs"] = response_api_optional_params["top_logprobs"] if response_api_optional_params.get("prompt_cache_key"): - mapped_params["prompt_cache_key"] = response_api_optional_params["prompt_cache_key"] + mapped_params["prompt_cache_key"] = response_api_optional_params[ + "prompt_cache_key" + ] if response_api_optional_params.get("frequency_penalty") is not None: - mapped_params["frequency_penalty"] = response_api_optional_params["frequency_penalty"] + mapped_params["frequency_penalty"] = response_api_optional_params[ + "frequency_penalty" + ] if response_api_optional_params.get("presence_penalty") is not None: - mapped_params["presence_penalty"] = response_api_optional_params["presence_penalty"] + mapped_params["presence_penalty"] = response_api_optional_params[ + "presence_penalty" + ] if response_api_optional_params.get("service_tier"): mapped_params["service_tier"] = response_api_optional_params["service_tier"] - + return mapped_params - + def _transform_tools(self, tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """ Transform tools to Perplexity format. - + Perplexity supports (per public OpenAPI spec): - web_search: Performs web searches - fetch_url: Fetches content from URLs - function: Function Calling """ perplexity_tools = [] - + for tool in tools: if isinstance(tool, dict): tool_type = tool.get("type", "") - + # Direct Perplexity tool format if tool_type in ["web_search", "fetch_url"]: perplexity_tools.append(tool) - + # Function tools: Perplexity supports them natively elif tool_type == "function": perplexity_tools.append(tool) - + return perplexity_tools def transform_responses_api_request( @@ -262,24 +281,26 @@ class PerplexityResponsesConfig(OpenAIResponsesAPIConfig): "model": model, "input": self._format_input(input), } - + # Add all optional parameters for key, value in response_api_optional_request_params.items(): data[key] = value - + return data - def _format_input(self, input: Union[str, ResponseInputParam]) -> Union[str, List[Dict[str, Any]]]: + def _format_input( + self, input: Union[str, ResponseInputParam] + ) -> Union[str, List[Dict[str, Any]]]: """ Format input for Perplexity Responses API - + The API accepts either: - A simple string for single-turn queries - An array of message objects for multi-turn conversations """ if isinstance(input, str): return input - + # Handle ResponseInputParam format if isinstance(input, list): formatted_messages = [] @@ -292,7 +313,7 @@ class PerplexityResponsesConfig(OpenAIResponsesAPIConfig): } formatted_messages.append(formatted_message) return formatted_messages - + return str(input) def transform_response_api_response( @@ -325,10 +346,14 @@ class PerplexityResponsesConfig(OpenAIResponsesAPIConfig): # Transform usage to handle Perplexity's cost structure usage_data = raw_response_json.get("usage", {}) transformed_usage_dict = self._transform_usage(usage_data) - + # Convert usage dict to ResponseAPIUsage object - usage_obj = ResponseAPIUsage(**transformed_usage_dict) if transformed_usage_dict else None - + usage_obj = ( + ResponseAPIUsage(**transformed_usage_dict) + if transformed_usage_dict + else None + ) + # Map Perplexity response to OpenAI Responses API format response = ResponsesAPIResponse( id=raw_response_json.get("id", ""), @@ -341,11 +366,11 @@ class PerplexityResponsesConfig(OpenAIResponsesAPIConfig): ) return response - + def _transform_usage(self, usage_data: Dict[str, Any]) -> Dict[str, Any]: """ Transform Perplexity usage data to OpenAI format - + Perplexity returns: { "input_tokens": 100, @@ -358,7 +383,7 @@ class PerplexityResponsesConfig(OpenAIResponsesAPIConfig): "total_cost": 0.0003 } } - + OpenAI expects: { "input_tokens": 100, @@ -372,7 +397,7 @@ class PerplexityResponsesConfig(OpenAIResponsesAPIConfig): "output_tokens": usage_data.get("output_tokens", 0), "total_tokens": usage_data.get("total_tokens", 0), } - + # Transform cost from Perplexity format (dict) to OpenAI format (float) cost_obj = usage_data.get("cost") if isinstance(cost_obj, dict) and "total_cost" in cost_obj: @@ -380,20 +405,20 @@ class PerplexityResponsesConfig(OpenAIResponsesAPIConfig): verbose_logger.debug( "Transformed Perplexity cost object to float: %s -> %s", cost_obj, - cost_obj["total_cost"] + cost_obj["total_cost"], ) elif cost_obj is not None: # If cost is already a float/number, use it as-is transformed["cost"] = cost_obj - + # Add input_tokens_details if present if "input_tokens_details" in usage_data: transformed["input_tokens_details"] = usage_data["input_tokens_details"] - + # Add output_tokens_details if present if "output_tokens_details" in usage_data: transformed["output_tokens_details"] = usage_data["output_tokens_details"] - + return transformed def transform_streaming_response( @@ -411,10 +436,10 @@ class PerplexityResponsesConfig(OpenAIResponsesAPIConfig): event_pydantic_model = PerplexityResponsesConfig.get_event_model_class( event_type=event_type ) - + # Transform Perplexity-specific fields to OpenAI format parsed_chunk = self._transform_perplexity_chunk(parsed_chunk) - + # Defensive: Handle error.code being null (similar to OpenAI implementation) try: error_obj = parsed_chunk.get("error") @@ -433,13 +458,13 @@ class PerplexityResponsesConfig(OpenAIResponsesAPIConfig): def _transform_perplexity_chunk(self, chunk: dict) -> dict: """ Transform Perplexity-specific fields in a streaming chunk to OpenAI format. - + This handles: - Converting Perplexity's cost object to a simple float """ # Make a copy to avoid modifying the original chunk = dict(chunk) - + # Transform usage.cost from Perplexity format to OpenAI format # Perplexity: {"currency": "USD", "input_cost": 0.0001, "output_cost": 0.0002, "total_cost": 0.0003} # OpenAI: 0.0003 (just the total_cost as a float) @@ -458,10 +483,10 @@ class PerplexityResponsesConfig(OpenAIResponsesAPIConfig): verbose_logger.debug( "Transformed Perplexity cost object to float: %s -> %s", cost_obj, - cost_obj["total_cost"] + cost_obj["total_cost"], ) except Exception as e: # If transformation fails, log and continue with original chunk verbose_logger.debug("Failed to transform Perplexity cost object: %s", e) - + return chunk diff --git a/tests/test_litellm/llms/bedrock/files/expected_bedrock_batch_completions.jsonl b/tests/test_litellm/llms/bedrock/files/expected_bedrock_batch_completions.jsonl index c58963bb1d..8bb35ba95d 100644 --- a/tests/test_litellm/llms/bedrock/files/expected_bedrock_batch_completions.jsonl +++ b/tests/test_litellm/llms/bedrock/files/expected_bedrock_batch_completions.jsonl @@ -1,2 +1,2 @@ -{"recordId": "request-1", "modelInput": {"messages": [{"role": "user", "content": [{"type": "text", "text": "Hello world!"}]}], "max_tokens": 10, "system": [{"type": "text", "text": "You are a helpful assistant."}], "anthropic_version": "bedrock-2023-05-31"}} -{"recordId": "request-2", "modelInput": {"messages": [{"role": "user", "content": [{"type": "text", "text": "Hello world!"}]}], "max_tokens": 10, "system": [{"type": "text", "text": "You are an unhelpful assistant."}], "anthropic_version": "bedrock-2023-05-31"}} +{"recordId": "request-1", "modelInput": {"messages": [{"role": "user", "content": [{"type": "text", "text": "Hello world!"}]}], "max_tokens": 10, "system": [{"type": "text", "text": "You are a helpful assistant."}], "anthropic_version": "bedrock-2023-05-31", "anthropic_beta": []}} +{"recordId": "request-2", "modelInput": {"messages": [{"role": "user", "content": [{"type": "text", "text": "Hello world!"}]}], "max_tokens": 10, "system": [{"type": "text", "text": "You are an unhelpful assistant."}], "anthropic_version": "bedrock-2023-05-31", "anthropic_beta": []}} diff --git a/tests/test_litellm/llms/perplexity/responses/test_perplexity_responses_transformation.py b/tests/test_litellm/llms/perplexity/responses/test_perplexity_responses_transformation.py index d2526cb7c6..cdd4ef913f 100644 --- a/tests/test_litellm/llms/perplexity/responses/test_perplexity_responses_transformation.py +++ b/tests/test_litellm/llms/perplexity/responses/test_perplexity_responses_transformation.py @@ -6,13 +6,12 @@ transformations for the Agent API (Responses API). Source: litellm/llms/perplexity/responses/transformation.py """ + import os import sys sys.path.insert(0, os.path.abspath("../../../../..")) -import pytest - from litellm.llms.perplexity.responses.transformation import PerplexityResponsesConfig from litellm.types.llms.openai import ResponsesAPIOptionalRequestParams from litellm.types.utils import LlmProviders @@ -37,7 +36,10 @@ class TestPerplexityResponsesTransformation: "type": "object", "properties": { "location": {"type": "string"}, - "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}, + "unit": { + "type": "string", + "enum": ["celsius", "fahrenheit"], + }, }, }, }, @@ -55,7 +57,9 @@ class TestPerplexityResponsesTransformation: assert len(result["tools"]) == 1 assert result["tools"][0]["type"] == "function" assert result["tools"][0]["function"]["name"] == "get_weather" - assert result["tools"][0]["function"]["description"] == "Get the current weather" + assert ( + result["tools"][0]["function"]["description"] == "Get the current weather" + ) assert "parameters" in result["tools"][0]["function"] def test_web_search_tool_passthrough(self): @@ -123,7 +127,9 @@ class TestPerplexityResponsesTransformation: """tool_choice passes through""" config = PerplexityResponsesConfig() - params = ResponsesAPIOptionalRequestParams(tool_choice="required", temperature=0.7) + params = ResponsesAPIOptionalRequestParams( + tool_choice="required", temperature=0.7 + ) result = config.map_openai_params( response_api_optional_params=params, @@ -137,7 +143,9 @@ class TestPerplexityResponsesTransformation: """parallel_tool_calls passes through""" config = PerplexityResponsesConfig() - params = ResponsesAPIOptionalRequestParams(parallel_tool_calls=True, temperature=0.7) + params = ResponsesAPIOptionalRequestParams( + parallel_tool_calls=True, temperature=0.7 + ) result = config.map_openai_params( response_api_optional_params=params, @@ -169,7 +177,10 @@ class TestPerplexityResponsesTransformation: "format": { "type": "json_schema", "name": "weather_response", - "schema": {"type": "object", "properties": {"temp": {"type": "number"}}}, + "schema": { + "type": "object", + "properties": {"temp": {"type": "number"}}, + }, "strict": True, } } From 058852ac5b213f1e4e22f0bcf602c2dca15782e4 Mon Sep 17 00:00:00 2001 From: Kesku Date: Thu, 19 Feb 2026 03:41:06 +0000 Subject: [PATCH 04/17] lint --- litellm/llms/perplexity/responses/transformation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/litellm/llms/perplexity/responses/transformation.py b/litellm/llms/perplexity/responses/transformation.py index 4a6fb4d69e..6d2ed51600 100644 --- a/litellm/llms/perplexity/responses/transformation.py +++ b/litellm/llms/perplexity/responses/transformation.py @@ -215,11 +215,11 @@ class PerplexityResponsesConfig(OpenAIResponsesAPIConfig): ] if response_api_optional_params.get("frequency_penalty") is not None: mapped_params["frequency_penalty"] = response_api_optional_params[ - "frequency_penalty" + "frequency_penalty" # type: ignore[typeddict-item] ] if response_api_optional_params.get("presence_penalty") is not None: mapped_params["presence_penalty"] = response_api_optional_params[ - "presence_penalty" + "presence_penalty" # type: ignore[typeddict-item] ] if response_api_optional_params.get("service_tier"): mapped_params["service_tier"] = response_api_optional_params["service_tier"] From 75d9f4d84f2abf32b58838243f4383d5b01b2323 Mon Sep 17 00:00:00 2001 From: Julio Quinteros Pro Date: Mon, 23 Feb 2026 19:10:54 -0300 Subject: [PATCH 05/17] fix: use atomic increment-first pattern for model RPM rate limiting Replace racy check-then-increment RPM logic with atomic increment-first pattern to prevent concurrent requests from bypassing the rate limit. Co-Authored-By: Claude Opus 4.6 --- .../pre_call_checks/model_rate_limit_check.py | 45 +---------------- .../test_enforce_model_rate_limits.py | 50 +++++++++++++++++-- 2 files changed, 47 insertions(+), 48 deletions(-) diff --git a/litellm/router_utils/pre_call_checks/model_rate_limit_check.py b/litellm/router_utils/pre_call_checks/model_rate_limit_check.py index e5be61690b..836f985874 100644 --- a/litellm/router_utils/pre_call_checks/model_rate_limit_check.py +++ b/litellm/router_utils/pre_call_checks/model_rate_limit_check.py @@ -129,27 +129,8 @@ class ModelRateLimitingCheck(CustomLogger): ), ) - # Check RPM limit + # Check RPM limit (atomic increment-first to avoid race conditions) if rpm_limit is not None: - # First check local cache - current_rpm = self.dual_cache.get_cache(key=rpm_key, local_only=True) - if current_rpm >= rpm_limit: - raise litellm.RateLimitError( - message=f"Model rate limit exceeded. RPM limit={rpm_limit}, current usage={current_rpm}", - llm_provider="", - model=model_name, - response=httpx.Response( - status_code=429, - content=f"{RouterErrors.user_defined_ratelimit_error.value} rpm limit={rpm_limit}. current usage={current_rpm}. id={model_id}, model_group={model_group}", - headers={"retry-after": str(60)}, - request=httpx.Request( - method="model_rate_limit_check", - url="https://github.com/BerriAI/litellm", - ), - ), - ) - - # Check redis cache and increment current_rpm = self.dual_cache.increment_cache( key=rpm_key, value=1, ttl=RoutingArgs.ttl ) @@ -226,30 +207,8 @@ class ModelRateLimitingCheck(CustomLogger): num_retries=0, # Don't retry - return 429 immediately ) - # Check RPM limit + # Check RPM limit (atomic increment-first to avoid race conditions) if rpm_limit is not None: - # First check local cache - current_rpm = await self.dual_cache.async_get_cache( - key=rpm_key, local_only=True - ) - if current_rpm is not None and current_rpm >= rpm_limit: - raise litellm.RateLimitError( - message=f"Model rate limit exceeded. RPM limit={rpm_limit}, current usage={current_rpm}", - llm_provider="", - model=model_name, - response=httpx.Response( - status_code=429, - content=f"{RouterErrors.user_defined_ratelimit_error.value} rpm limit={rpm_limit}. current usage={current_rpm}. id={model_id}, model_group={model_group}", - headers={"retry-after": str(60)}, - request=httpx.Request( - method="model_rate_limit_check", - url="https://github.com/BerriAI/litellm", - ), - ), - num_retries=0, # Don't retry - return 429 immediately - ) - - # Check redis cache and increment current_rpm = await self.dual_cache.async_increment_cache( key=rpm_key, value=1, diff --git a/tests/test_litellm/test_router/test_enforce_model_rate_limits.py b/tests/test_litellm/test_router/test_enforce_model_rate_limits.py index 3bca3df4e1..1def253ac9 100644 --- a/tests/test_litellm/test_router/test_enforce_model_rate_limits.py +++ b/tests/test_litellm/test_router/test_enforce_model_rate_limits.py @@ -5,12 +5,14 @@ This feature allows users to enforce TPM/RPM limits set on model deployments regardless of the routing strategy being used. """ +import asyncio from unittest.mock import AsyncMock, MagicMock import pytest import litellm from litellm import Router +from litellm.caching.dual_cache import DualCache from litellm.router_utils.pre_call_checks.model_rate_limit_check import ( ModelRateLimitingCheck, ) @@ -88,7 +90,7 @@ class TestModelRateLimitingCheck: def test_pre_call_check_raises_rate_limit_error_when_over_rpm(self): """Test that RateLimitError is raised when RPM limit is exceeded.""" mock_cache = MagicMock() - mock_cache.get_cache.return_value = 10 # Already at limit + mock_cache.increment_cache.return_value = 11 # Over limit after increment check = ModelRateLimitingCheck(dual_cache=mock_cache) @@ -103,12 +105,11 @@ class TestModelRateLimitingCheck: check.pre_call_check(deployment) assert "RPM limit=10" in str(exc_info.value) - assert "current usage=10" in str(exc_info.value) + assert "current usage=11" in str(exc_info.value) def test_pre_call_check_allows_request_under_limit(self): """Test that requests are allowed when under the limit.""" mock_cache = MagicMock() - mock_cache.get_cache.return_value = 5 mock_cache.increment_cache.return_value = 6 check = ModelRateLimitingCheck(dual_cache=mock_cache) @@ -188,7 +189,8 @@ class TestModelRateLimitingCheckAsync: async def test_async_pre_call_check_raises_rate_limit_error_when_over_rpm(self): """Test that RateLimitError is raised when RPM limit is exceeded (async).""" mock_cache = MagicMock() - mock_cache.async_get_cache = AsyncMock(return_value=10) # Already at limit + mock_cache.async_get_cache = AsyncMock(return_value=None) + mock_cache.async_increment_cache = AsyncMock(return_value=11) # Over limit check = ModelRateLimitingCheck(dual_cache=mock_cache) @@ -208,7 +210,7 @@ class TestModelRateLimitingCheckAsync: async def test_async_pre_call_check_allows_request_under_limit(self): """Test that requests are allowed when under the limit (async).""" mock_cache = MagicMock() - mock_cache.async_get_cache = AsyncMock(return_value=5) + mock_cache.async_get_cache = AsyncMock(return_value=None) mock_cache.async_increment_cache = AsyncMock(return_value=6) check = ModelRateLimitingCheck(dual_cache=mock_cache) @@ -313,3 +315,41 @@ class TestRouterWithEnforceModelRateLimits: break assert found, "ModelRateLimitingCheck should be in litellm.callbacks" + + +class TestModelRateLimitConcurrency: + """Test that RPM rate limiting is atomic under concurrent requests.""" + + @pytest.mark.asyncio + async def test_concurrent_requests_respect_rpm_limit(self): + """ + Fire 4 concurrent async requests with RPM limit of 2. + Exactly 2 should succeed and 2 should raise RateLimitError. + + This test validates the atomic increment-first pattern: + the old check-then-increment pattern would let 3+ through + due to a race condition on the local cache read. + """ + dual_cache = DualCache() + check = ModelRateLimitingCheck(dual_cache=dual_cache) + + deployment = { + "rpm": 2, + "litellm_params": {"model": "gpt-4"}, + "model_info": {"id": "concurrent-test-id"}, + "model_name": "test-model", + } + + async def attempt_request(): + return await check.async_pre_call_check(deployment) + + results = await asyncio.gather( + *[attempt_request() for _ in range(4)], + return_exceptions=True, + ) + + successes = [r for r in results if not isinstance(r, Exception)] + failures = [r for r in results if isinstance(r, litellm.RateLimitError)] + + assert len(successes) == 2, f"Expected 2 successes, got {len(successes)}" + assert len(failures) == 2, f"Expected 2 rate limit errors, got {len(failures)}" From 289e6031bce7219975c83cdc984169fa9f846a27 Mon Sep 17 00:00:00 2001 From: Atharva Jaiswal <92455570+AtharvaJaiswal005@users.noreply.github.com> Date: Tue, 24 Feb 2026 10:14:02 +0530 Subject: [PATCH 06/17] fix: apply custom video pricing from deployment model_info (#21923) * auth_with_role_name add region_name arg for cross-account sts * update tests to include case with aws_region_name for _auth_with_aws_role * Only pass region_name to STS client when aws_region_name is set * Add optional aws_sts_endpoint to _auth_with_aws_role * Parametrize ambient-credentials test for no opts, region_name, and aws_sts_endpoint * consistently passing region and endpoint args into explicit credentials irsa * fix env var leakage * fix: bedrock openai-compatible imported-model should also have model arn encoded * fix: custom pricing not applied for /v1/videos endpoint (#21907) * fix: resolve mypy type errors for video pricing model_info parameter Use Optional[ModelInfo] instead of Optional[dict] and restructure cost_info narrowing so mypy can properly track non-None state. --------- Co-authored-by: An Tang Co-authored-by: Sameer Kankute --- litellm/cost_calculator.py | 85 +++++--- litellm/llms/bedrock/base_aws_llm.py | 48 +++-- .../amazon_openai_transformation.py | 4 + litellm/llms/openai/cost_calculation.py | 17 +- .../test_bedrock_completion.py | 2 +- .../llms/bedrock/test_base_aws_llm.py | 182 ++++++++++-------- tests/test_litellm/test_video_generation.py | 71 +++++++ 7 files changed, 276 insertions(+), 133 deletions(-) diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index 74c1afb0cc..05c3da224e 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -1195,6 +1195,16 @@ def completion_cost( # noqa: PLR0915 ) elif call_type in _VIDEO_CALL_TYPES: ### VIDEO GENERATION COST CALCULATION ### + # Extract custom model_info for deployment-specific pricing + _video_model_info: Optional[ModelInfo] = None + if custom_pricing and litellm_logging_obj is not None: + _litellm_params = getattr( + litellm_logging_obj, "litellm_params", None + ) + if _litellm_params is not None: + _metadata = _litellm_params.get("metadata", {}) or {} + _video_model_info = _metadata.get("model_info", None) + usage_obj = getattr(completion_response, "usage", None) if completion_response is not None and usage_obj: # Handle both dict and Pydantic Usage object @@ -1215,12 +1225,14 @@ def completion_cost( # noqa: PLR0915 model=model, duration_seconds=duration_seconds, custom_llm_provider=custom_llm_provider, + model_info=_video_model_info, ) # Fallback to default video cost calculation if no duration available return default_video_cost_calculator( model=model, duration_seconds=0.0, # Default to 0 if no duration available custom_llm_provider=custom_llm_provider, + model_info=_video_model_info, ) elif call_type in _SPEECH_CALL_TYPES: prompt_characters = litellm.utils._count_characters(text=prompt) @@ -1845,6 +1857,7 @@ def default_video_cost_calculator( model: str, duration_seconds: float, custom_llm_provider: Optional[str] = None, + model_info: Optional[ModelInfo] = None, ) -> float: """ Default video cost calculator for video generation @@ -1853,6 +1866,9 @@ def default_video_cost_calculator( model (str): Model name duration_seconds (float): Duration of the generated video in seconds custom_llm_provider (Optional[str]): Custom LLM provider + model_info (Optional[ModelInfo]): Deployment-level model info containing + custom video pricing. When provided, used before falling back to + the global litellm.model_cost lookup. Returns: float: Cost in USD for the video generation @@ -1860,42 +1876,47 @@ def default_video_cost_calculator( Raises: Exception: If model pricing not found in cost map """ - # Build model names for cost lookup - base_model_name = model - model_name_without_custom_llm_provider: Optional[str] = None - if custom_llm_provider and model.startswith(f"{custom_llm_provider}/"): - model_name_without_custom_llm_provider = model.replace( - f"{custom_llm_provider}/", "" - ) - base_model_name = ( - f"{custom_llm_provider}/{model_name_without_custom_llm_provider}" - ) - - verbose_logger.debug(f"Looking up cost for video model: {base_model_name}") - - model_without_provider = model.split("/")[-1] - - # Try model with provider first, fall back to base model name + # Use custom model_info pricing if provided (deployment-specific pricing) cost_info: Optional[dict] = None - models_to_check: List[Optional[str]] = [ - base_model_name, - model, - model_without_provider, - model_name_without_custom_llm_provider, - ] - for _model in models_to_check: - if _model is not None and _model in litellm.model_cost: - cost_info = litellm.model_cost[_model] - break + if model_info is not None: + cost_info = dict(model_info) + else: + # Build model names for cost lookup + base_model_name = model + model_name_without_custom_llm_provider: Optional[str] = None + if custom_llm_provider and model.startswith(f"{custom_llm_provider}/"): + model_name_without_custom_llm_provider = model.replace( + f"{custom_llm_provider}/", "" + ) + base_model_name = ( + f"{custom_llm_provider}/{model_name_without_custom_llm_provider}" + ) + + verbose_logger.debug(f"Looking up cost for video model: {base_model_name}") + + model_without_provider = model.split("/")[-1] + + # Try model with provider first, fall back to base model name + models_to_check: List[Optional[str]] = [ + base_model_name, + model, + model_without_provider, + model_name_without_custom_llm_provider, + ] + for _model in models_to_check: + if _model is not None and _model in litellm.model_cost: + cost_info = litellm.model_cost[_model] + break + + # If still not found, try with custom_llm_provider prefix + if cost_info is None and custom_llm_provider: + prefixed_model = f"{custom_llm_provider}/{model}" + if prefixed_model in litellm.model_cost: + cost_info = litellm.model_cost[prefixed_model] - # If still not found, try with custom_llm_provider prefix - if cost_info is None and custom_llm_provider: - prefixed_model = f"{custom_llm_provider}/{model}" - if prefixed_model in litellm.model_cost: - cost_info = litellm.model_cost[prefixed_model] if cost_info is None: raise Exception( - f"Model not found in cost map. Tried checking {models_to_check}" + f"Model not found in cost map for model={model}" ) # Check for video-specific cost per second first diff --git a/litellm/llms/bedrock/base_aws_llm.py b/litellm/llms/bedrock/base_aws_llm.py index dfaddb3c2b..5da118a8f5 100644 --- a/litellm/llms/bedrock/base_aws_llm.py +++ b/litellm/llms/bedrock/base_aws_llm.py @@ -234,6 +234,8 @@ class BaseAWSLLM: aws_session_token=aws_session_token, aws_role_name=aws_role_name, aws_session_name=aws_session_name, + aws_region_name=aws_region_name, + aws_sts_endpoint=aws_sts_endpoint, aws_external_id=aws_external_id, ssl_verify=ssl_verify, ) @@ -733,6 +735,7 @@ class BaseAWSLLM: region: str, web_identity_token_file: str, aws_external_id: Optional[str] = None, + aws_sts_endpoint: Optional[str] = None, ssl_verify: Optional[Union[bool, str]] = None, ) -> dict: """Handle cross-account role assumption for IRSA.""" @@ -744,11 +747,13 @@ class BaseAWSLLM: with open(web_identity_token_file, "r") as f: web_identity_token = f.read().strip() + irsa_sts_kwargs: dict = {"region_name": region, "verify": self._get_ssl_verify(ssl_verify)} + if aws_sts_endpoint is not None: + irsa_sts_kwargs["endpoint_url"] = aws_sts_endpoint + # Create an STS client without credentials with tracer.trace("boto3.client(sts) for manual IRSA"): - sts_client = boto3.client( - "sts", region_name=region, verify=self._get_ssl_verify(ssl_verify) - ) + sts_client = boto3.client("sts", **irsa_sts_kwargs) # Manually assume the IRSA role with the session name verbose_logger.debug( @@ -767,11 +772,10 @@ class BaseAWSLLM: with tracer.trace("boto3.client(sts) with manual IRSA credentials"): sts_client_with_creds = boto3.client( "sts", - region_name=region, aws_access_key_id=irsa_creds["AccessKeyId"], aws_secret_access_key=irsa_creds["SecretAccessKey"], aws_session_token=irsa_creds["SessionToken"], - verify=self._get_ssl_verify(ssl_verify), + **irsa_sts_kwargs, ) # Get current caller identity for debugging @@ -804,16 +808,19 @@ class BaseAWSLLM: aws_session_name: str, region: str, aws_external_id: Optional[str] = None, + aws_sts_endpoint: Optional[str] = None, ssl_verify: Optional[Union[bool, str]] = None, ) -> dict: """Handle same-account role assumption for IRSA.""" import boto3 + irsa_sts_kwargs: dict = {"region_name": region, "verify": self._get_ssl_verify(ssl_verify)} + if aws_sts_endpoint is not None: + irsa_sts_kwargs["endpoint_url"] = aws_sts_endpoint + verbose_logger.debug("Same account role assumption, using automatic IRSA") with tracer.trace("boto3.client(sts) with automatic IRSA"): - sts_client = boto3.client( - "sts", region_name=region, verify=self._get_ssl_verify(ssl_verify) - ) + sts_client = boto3.client("sts", **irsa_sts_kwargs) # Get current caller identity for debugging try: @@ -867,6 +874,8 @@ class BaseAWSLLM: aws_session_token: Optional[str], aws_role_name: str, aws_session_name: str, + aws_region_name: Optional[str] = None, + aws_sts_endpoint: Optional[str] = None, aws_external_id: Optional[str] = None, ssl_verify: Optional[Union[bool, str]] = None, ) -> Tuple[Credentials, Optional[int]]: @@ -880,6 +889,8 @@ class BaseAWSLLM: web_identity_token_file = os.getenv("AWS_WEB_IDENTITY_TOKEN_FILE") irsa_role_arn = os.getenv("AWS_ROLE_ARN") + region = aws_region_name or os.getenv("AWS_REGION") or os.getenv("AWS_DEFAULT_REGION") + # If we have IRSA environment variables and no explicit credentials, # we need to use the web identity token flow if ( @@ -895,12 +906,8 @@ class BaseAWSLLM: ) try: - # Get region from environment - region = ( - os.getenv("AWS_REGION") - or os.getenv("AWS_DEFAULT_REGION") - or "us-east-1" - ) + # Use passed-in region when set, else env, else default (align with AssumeRole path) + region = region or "us-east-1" # Check if we need to do cross-account role assumption if aws_role_name != irsa_role_arn: @@ -911,6 +918,7 @@ class BaseAWSLLM: region, web_identity_token_file, aws_external_id, + aws_sts_endpoint=aws_sts_endpoint, ssl_verify=ssl_verify, ) else: @@ -919,6 +927,7 @@ class BaseAWSLLM: aws_session_name, region, aws_external_id, + aws_sts_endpoint=aws_sts_endpoint, ssl_verify=ssl_verify, ) @@ -940,11 +949,14 @@ class BaseAWSLLM: # In EKS/IRSA environments, use ambient credentials (no explicit keys needed) # This allows the web identity token to work automatically + sts_client_kwargs: dict = {"verify": self._get_ssl_verify(ssl_verify)} + if region is not None: + sts_client_kwargs["region_name"] = region + if aws_sts_endpoint is not None: + sts_client_kwargs["endpoint_url"] = aws_sts_endpoint if aws_access_key_id is None and aws_secret_access_key is None: with tracer.trace("boto3.client(sts)"): - sts_client = boto3.client( - "sts", verify=self._get_ssl_verify(ssl_verify) - ) + sts_client = boto3.client("sts", **sts_client_kwargs) else: with tracer.trace("boto3.client(sts)"): sts_client = boto3.client( @@ -952,7 +964,7 @@ class BaseAWSLLM: aws_access_key_id=aws_access_key_id, aws_secret_access_key=aws_secret_access_key, aws_session_token=aws_session_token, - verify=self._get_ssl_verify(ssl_verify), + **sts_client_kwargs, ) assume_role_params = { diff --git a/litellm/llms/bedrock/chat/invoke_transformations/amazon_openai_transformation.py b/litellm/llms/bedrock/chat/invoke_transformations/amazon_openai_transformation.py index ee07b71ef1..a438be1745 100644 --- a/litellm/llms/bedrock/chat/invoke_transformations/amazon_openai_transformation.py +++ b/litellm/llms/bedrock/chat/invoke_transformations/amazon_openai_transformation.py @@ -14,6 +14,7 @@ import httpx from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM from litellm.llms.bedrock.common_utils import BedrockError from litellm.llms.openai.chat.gpt_transformation import OpenAIGPTConfig +from litellm.passthrough.utils import CommonUtils from litellm.types.llms.openai import AllMessageValues if TYPE_CHECKING: @@ -94,6 +95,9 @@ class AmazonBedrockOpenAIConfig(OpenAIGPTConfig, BaseAWSLLM): aws_bedrock_runtime_endpoint=aws_bedrock_runtime_endpoint, aws_region_name=aws_region_name, ) + + # Encode model ID for ARNs (e.g., :imported-model/ -> :imported-model%2F) + model_id = CommonUtils.encode_bedrock_runtime_modelid_arn(model_id) # Build the invoke URL if stream: diff --git a/litellm/llms/openai/cost_calculation.py b/litellm/llms/openai/cost_calculation.py index e5349db3af..ac1e4a6b08 100644 --- a/litellm/llms/openai/cost_calculation.py +++ b/litellm/llms/openai/cost_calculation.py @@ -7,7 +7,7 @@ from typing import Literal, Optional, Tuple from litellm._logging import verbose_logger from litellm.litellm_core_utils.llm_cost_calc.utils import generic_cost_per_token -from litellm.types.utils import CallTypes, Usage +from litellm.types.utils import CallTypes, ModelInfo, Usage from litellm.utils import get_model_info @@ -129,7 +129,10 @@ def cost_per_second( def video_generation_cost( - model: str, duration_seconds: float, custom_llm_provider: Optional[str] = None + model: str, + duration_seconds: float, + custom_llm_provider: Optional[str] = None, + model_info: Optional[ModelInfo] = None, ) -> float: """ Calculates the cost for video generation based on duration in seconds. @@ -138,14 +141,18 @@ def video_generation_cost( - model: str, the model name without provider prefix - duration_seconds: float, the duration of the generated video in seconds - custom_llm_provider: str, the custom llm provider + - model_info: Optional[dict], deployment-level model info containing + custom video pricing. When provided, skips the global + get_model_info() lookup so that deployment-specific pricing is used. Returns: float - total_cost_in_usd """ ## GET MODEL INFO - model_info = get_model_info( - model=model, custom_llm_provider=custom_llm_provider or "openai" - ) + if model_info is None: + model_info = get_model_info( + model=model, custom_llm_provider=custom_llm_provider or "openai" + ) # Check for video-specific cost per second video_cost_per_second = model_info.get("output_cost_per_video_per_second") diff --git a/tests/llm_translation/test_bedrock_completion.py b/tests/llm_translation/test_bedrock_completion.py index d23033c1e4..40ef2c3283 100644 --- a/tests/llm_translation/test_bedrock_completion.py +++ b/tests/llm_translation/test_bedrock_completion.py @@ -3517,7 +3517,7 @@ def test_bedrock_openai_imported_model(): print(f"URL: {url}") assert "bedrock-runtime.us-east-1.amazonaws.com" in url assert ( - "arn:aws:bedrock:us-east-1:117159858402:imported-model/m4gc1mrfuddy" in url + "arn:aws:bedrock:us-east-1:117159858402:imported-model%2Fm4gc1mrfuddy" in url ) assert "/invoke" in url diff --git a/tests/test_litellm/llms/bedrock/test_base_aws_llm.py b/tests/test_litellm/llms/bedrock/test_base_aws_llm.py index cf9fee6bac..18fc7c6173 100644 --- a/tests/test_litellm/llms/bedrock/test_base_aws_llm.py +++ b/tests/test_litellm/llms/bedrock/test_base_aws_llm.py @@ -541,25 +541,35 @@ def test_different_roles_without_session_names_should_not_share_cache(): assert cache_key1 != cache_key2 -def test_eks_irsa_ambient_credentials_used(): +@pytest.mark.parametrize( + "role_kwargs,expected_client_kwargs", + [ + ({}, {"verify": True}), + ({"aws_region_name": "us-east-1"}, {"region_name": "us-east-1", "verify": True}), + ( + {"aws_sts_endpoint": "https://sts.eu-west-1.amazonaws.com"}, + {"endpoint_url": "https://sts.eu-west-1.amazonaws.com", "verify": True}, + ), + ], + ids=["no_region_or_endpoint", "regional_sts", "explicit_sts_endpoint"], +) +def test_eks_irsa_ambient_credentials_used(role_kwargs, expected_client_kwargs): """ Test that in EKS/IRSA environments, ambient credentials are used when no explicit keys provided. This allows web identity tokens to work automatically. """ + # Isolate from ambient AWS_REGION/AWS_DEFAULT_REGION so no_region_or_endpoint is deterministic + env_without_aws_region = { + k: v + for k, v in os.environ.items() + if k not in ("AWS_REGION", "AWS_DEFAULT_REGION") + } base_aws_llm = BaseAWSLLM() - - # Mock the boto3 STS client - mock_sts_client = MagicMock() - - # Mock the STS response with proper expiration handling mock_expiry = MagicMock() mock_expiry.tzinfo = timezone.utc - current_time = datetime.now(timezone.utc) - # Create a timedelta object that returns 3600 when total_seconds() is called time_diff = MagicMock() time_diff.total_seconds.return_value = 3600 mock_expiry.__sub__ = MagicMock(return_value=time_diff) - mock_sts_response = { "Credentials": { "AccessKeyId": "assumed-access-key", @@ -568,54 +578,82 @@ def test_eks_irsa_ambient_credentials_used(): "Expiration": mock_expiry, } } + mock_sts_client = MagicMock() mock_sts_client.assume_role.return_value = mock_sts_response - - with patch("boto3.client", return_value=mock_sts_client) as mock_boto3_client: - - # Call with no explicit credentials (EKS/IRSA scenario) - credentials, ttl = base_aws_llm._auth_with_aws_role( - aws_access_key_id=None, - aws_secret_access_key=None, - aws_session_token=None, - aws_role_name="arn:aws:iam::2222222222222:role/LitellmEvalBedrockRole", - aws_session_name="test-session" - ) - - # Should create STS client without explicit credentials (using ambient credentials) - # Note: verify parameter is passed for SSL verification - mock_boto3_client.assert_called_once_with("sts", verify=True) - - # Should call assume_role - mock_sts_client.assume_role.assert_called_once_with( - RoleArn="arn:aws:iam::2222222222222:role/LitellmEvalBedrockRole", - RoleSessionName="test-session" - ) - - # Verify credentials are returned correctly - assert credentials.access_key == "assumed-access-key" - assert credentials.secret_key == "assumed-secret-key" - assert credentials.token == "assumed-session-token" - assert ttl is not None + + with patch.dict(os.environ, env_without_aws_region, clear=True): + with patch("boto3.client", return_value=mock_sts_client) as mock_boto3_client: + credentials, ttl = base_aws_llm._auth_with_aws_role( + aws_access_key_id=None, + aws_secret_access_key=None, + aws_session_token=None, + aws_role_name="arn:aws:iam::2222222222222:role/LitellmEvalBedrockRole", + aws_session_name="test-session", + **role_kwargs, + ) + mock_boto3_client.assert_called_once_with( + "sts", **expected_client_kwargs + ) + mock_sts_client.assume_role.assert_called_once_with( + RoleArn="arn:aws:iam::2222222222222:role/LitellmEvalBedrockRole", + RoleSessionName="test-session", + ) + assert credentials.access_key == "assumed-access-key" + assert ttl is not None -def test_explicit_credentials_used_when_provided(): +@pytest.mark.parametrize( + "role_kwargs,expected_client_kwargs", + [ + ( + {}, + { + "aws_access_key_id": "explicit-access-key", + "aws_secret_access_key": "explicit-secret-key", + "aws_session_token": "assumed-session-token", + "verify": True, + }, + ), + ( + {"aws_region_name": "us-east-1"}, + { + "region_name": "us-east-1", + "aws_access_key_id": "explicit-access-key", + "aws_secret_access_key": "explicit-secret-key", + "aws_session_token": "assumed-session-token", + "verify": True, + }, + ), + ( + {"aws_sts_endpoint": "https://sts.eu-west-1.amazonaws.com"}, + { + "endpoint_url": "https://sts.eu-west-1.amazonaws.com", + "aws_access_key_id": "explicit-access-key", + "aws_secret_access_key": "explicit-secret-key", + "aws_session_token": "assumed-session-token", + "verify": True, + }, + ), + ], + ids=["no_region_or_endpoint", "regional_sts", "explicit_sts_endpoint"], +) +def test_explicit_credentials_used_when_provided(role_kwargs, expected_client_kwargs): """ Test that explicit credentials are used when provided (non-EKS/IRSA scenario). """ + # Isolate from ambient AWS_REGION/AWS_DEFAULT_REGION so no_region_or_endpoint is deterministic + env_without_aws_region = { + k: v + for k, v in os.environ.items() + if k not in ("AWS_REGION", "AWS_DEFAULT_REGION") + } base_aws_llm = BaseAWSLLM() - - # Mock the boto3 STS client - mock_sts_client = MagicMock() - - # Mock the STS response with proper expiration handling mock_expiry = MagicMock() mock_expiry.tzinfo = timezone.utc - current_time = datetime.now(timezone.utc) # Create a timedelta object that returns 3600 when total_seconds() is called time_diff = MagicMock() time_diff.total_seconds.return_value = 3600 mock_expiry.__sub__ = MagicMock(return_value=time_diff) - mock_sts_response = { "Credentials": { "AccessKeyId": "assumed-access-key", @@ -624,40 +662,30 @@ def test_explicit_credentials_used_when_provided(): "Expiration": mock_expiry, } } + mock_sts_client = MagicMock() mock_sts_client.assume_role.return_value = mock_sts_response - - with patch("boto3.client", return_value=mock_sts_client) as mock_boto3_client: - - # Call with explicit credentials - credentials, ttl = base_aws_llm._auth_with_aws_role( - aws_access_key_id="explicit-access-key", - aws_secret_access_key="explicit-secret-key", - aws_session_token="assumed-session-token", - aws_role_name="arn:aws:iam::2222222222222:role/LitellmEvalBedrockRole", - aws_session_name="test-session" - ) - - # Should create STS client with explicit credentials - # Note: verify parameter is passed for SSL verification - mock_boto3_client.assert_called_once_with( - "sts", - aws_access_key_id="explicit-access-key", - aws_secret_access_key="explicit-secret-key", - aws_session_token="assumed-session-token", - verify=True, - ) - - # Should call assume_role - mock_sts_client.assume_role.assert_called_once_with( - RoleArn="arn:aws:iam::2222222222222:role/LitellmEvalBedrockRole", - RoleSessionName="test-session" - ) - - # Verify credentials are returned correctly - assert credentials.access_key == "assumed-access-key" - assert credentials.secret_key == "assumed-secret-key" - assert credentials.token == "assumed-session-token" - assert ttl is not None + + with patch.dict(os.environ, env_without_aws_region, clear=True): + with patch("boto3.client", return_value=mock_sts_client) as mock_boto3_client: + credentials, ttl = base_aws_llm._auth_with_aws_role( + aws_access_key_id="explicit-access-key", + aws_secret_access_key="explicit-secret-key", + aws_session_token="assumed-session-token", + aws_role_name="arn:aws:iam::2222222222222:role/LitellmEvalBedrockRole", + aws_session_name="test-session", + **role_kwargs, + ) + mock_boto3_client.assert_called_once_with( + "sts", **expected_client_kwargs + ) + mock_sts_client.assume_role.assert_called_once_with( + RoleArn="arn:aws:iam::2222222222222:role/LitellmEvalBedrockRole", + RoleSessionName="test-session", + ) + assert credentials.access_key == "assumed-access-key" + assert credentials.secret_key == "assumed-secret-key" + assert credentials.token == "assumed-session-token" + assert ttl is not None def test_partial_credentials_still_use_ambient(): diff --git a/tests/test_litellm/test_video_generation.py b/tests/test_litellm/test_video_generation.py index 75552d3d10..5545a138dd 100644 --- a/tests/test_litellm/test_video_generation.py +++ b/tests/test_litellm/test_video_generation.py @@ -242,6 +242,77 @@ class TestVideoGeneration: custom_llm_provider="openai" ) + def test_video_generation_cost_with_custom_model_info(self): + """Test that custom model_info pricing is applied for video generation. + + When a deployment has custom pricing via model_info, it should be used + instead of looking up the global litellm.model_cost map. + + Related: https://github.com/BerriAI/litellm/issues/21907 + """ + model_info = { + "output_cost_per_video_per_second": 0.05, + } + cost = default_video_cost_calculator( + model="my-custom-video-model", + duration_seconds=10.0, + model_info=model_info, + ) + assert cost == 0.5 + + def test_video_generation_cost_custom_model_info_fallback_to_per_second(self): + """Test that output_cost_per_second is used as fallback when + output_cost_per_video_per_second is not set in custom model_info. + + Related: https://github.com/BerriAI/litellm/issues/21907 + """ + model_info = { + "output_cost_per_second": 0.10, + } + cost = default_video_cost_calculator( + model="my-custom-video-model", + duration_seconds=5.0, + model_info=model_info, + ) + assert cost == 0.5 + + def test_video_generation_cost_custom_pricing_through_completion_cost(self): + """Test that custom video pricing flows through completion_cost via litellm_logging_obj. + + This tests the full cost calculation path: completion_cost extracts model_info + from litellm_logging_obj.litellm_params.metadata.model_info and passes it to + the video cost calculator. + + Related: https://github.com/BerriAI/litellm/issues/21907 + """ + from litellm.cost_calculator import completion_cost + + # Create mock response with usage containing duration_seconds + mock_response = MagicMock() + mock_response.usage = MagicMock() + mock_response.usage.duration_seconds = 10.0 + type(mock_response)._hidden_params = {} + + # Create mock litellm_logging_obj with custom pricing + mock_logging_obj = MagicMock() + mock_logging_obj.litellm_params = { + "metadata": { + "model_info": { + "output_cost_per_video_per_second": 0.05, + } + } + } + + cost = completion_cost( + completion_response=mock_response, + model="openai/hunyuanvideo", + call_type="create_video", + custom_llm_provider="openai", + custom_pricing=True, + litellm_logging_obj=mock_logging_obj, + ) + assert cost == 0.5 + def test_video_generation_with_files(self): """Test video generation with file uploads.""" config = OpenAIVideoConfig() From c5819b5ede0d90abbfc3b7e444a378703c9abce9 Mon Sep 17 00:00:00 2001 From: Atharva Jaiswal <92455570+AtharvaJaiswal005@users.noreply.github.com> Date: Tue, 24 Feb 2026 10:15:53 +0530 Subject: [PATCH 07/17] fix: team assignment fails for keys with special model names (#21919) * auth_with_role_name add region_name arg for cross-account sts * update tests to include case with aws_region_name for _auth_with_aws_role * Only pass region_name to STS client when aws_region_name is set * Add optional aws_sts_endpoint to _auth_with_aws_role * Parametrize ambient-credentials test for no opts, region_name, and aws_sts_endpoint * consistently passing region and endpoint args into explicit credentials irsa * fix env var leakage * fix: bedrock openai-compatible imported-model should also have model arn encoded * fix: team assignment fails for keys with special model names (#21880) --------- Co-authored-by: An Tang Co-authored-by: Sameer Kankute --- .../key_management_endpoints.py | 3 + .../test_key_management_endpoints.py | 100 ++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index a230c2e933..6ddedfcda3 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -2165,8 +2165,11 @@ async def validate_key_team_change( - The person initiating the change must be either Proxy Admin or Team Admin """ # Check if the team has access to the key's models + _special_model_names = {e.value for e in SpecialModelNames} if len(key.models) > 0: for model in key.models: + if model in _special_model_names: + continue await can_team_access_model( model=model, team_object=team, diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index ffb4e95542..44d379a388 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -1468,6 +1468,106 @@ async def test_validate_key_team_change_with_member_permissions(): ) +@pytest.mark.asyncio +async def test_validate_key_team_change_with_special_model_names(): + """ + Test that validate_key_team_change skips validation for special model names + like 'all-team-models', 'all-proxy-models', and 'no-default-models'. + + These are placeholder values, not real model names, so they should not be + checked against the team's actual model list. + + Related: https://github.com/BerriAI/litellm/issues/21880 + """ + mock_key = MagicMock() + mock_key.user_id = "test-user-123" + mock_key.models = ["all-team-models"] + mock_key.tpm_limit = None + mock_key.rpm_limit = None + + mock_team = MagicMock() + mock_team.team_id = "test-team-456" + mock_team.members_with_roles = [] + mock_team.tpm_limit = None + mock_team.rpm_limit = None + + mock_change_initiator = MagicMock() + mock_change_initiator.user_id = "test-user-123" + mock_change_initiator.user_role = LitellmUserRoles.PROXY_ADMIN.value + + mock_router = MagicMock() + + with patch( + "litellm.proxy.management_endpoints.key_management_endpoints.can_team_access_model", + new_callable=AsyncMock, + ) as mock_can_access: + with patch( + "litellm.proxy.management_endpoints.key_management_endpoints._get_user_in_team" + ) as mock_get_user: + mock_get_user.return_value = MagicMock() + + # Should NOT raise - "all-team-models" should be skipped + await validate_key_team_change( + key=mock_key, + team=mock_team, + change_initiated_by=mock_change_initiator, + llm_router=mock_router, + ) + + # can_team_access_model should NOT have been called + mock_can_access.assert_not_called() + + +@pytest.mark.asyncio +async def test_validate_key_team_change_special_models_mixed_with_real(): + """ + Test that when a key has both special and real model names, + only real model names are validated against the team. + + Related: https://github.com/BerriAI/litellm/issues/21880 + """ + mock_key = MagicMock() + mock_key.user_id = "test-user-123" + mock_key.models = ["all-team-models", "gpt-4"] + mock_key.tpm_limit = None + mock_key.rpm_limit = None + + mock_team = MagicMock() + mock_team.team_id = "test-team-456" + mock_team.members_with_roles = [] + mock_team.tpm_limit = None + mock_team.rpm_limit = None + + mock_change_initiator = MagicMock() + mock_change_initiator.user_id = "test-user-123" + mock_change_initiator.user_role = LitellmUserRoles.PROXY_ADMIN.value + + mock_router = MagicMock() + + with patch( + "litellm.proxy.management_endpoints.key_management_endpoints.can_team_access_model", + new_callable=AsyncMock, + ) as mock_can_access: + with patch( + "litellm.proxy.management_endpoints.key_management_endpoints._get_user_in_team" + ) as mock_get_user: + mock_get_user.return_value = MagicMock() + + await validate_key_team_change( + key=mock_key, + team=mock_team, + change_initiated_by=mock_change_initiator, + llm_router=mock_router, + ) + + # Should only be called for "gpt-4", not "all-team-models" + mock_can_access.assert_called_once_with( + model="gpt-4", + team_object=mock_team, + llm_router=mock_router, + ) + + def test_key_rotation_fields_helper(): """ Test the key data update logic for rotation fields. From 470444febc8ba4b2bec7a49d08c374d3a9e81f58 Mon Sep 17 00:00:00 2001 From: Krish Dholakia Date: Mon, 23 Feb 2026 20:49:09 -0800 Subject: [PATCH 08/17] Revert "fix: team assignment fails for keys with special model names (#21919)" (#21977) This reverts commit c5819b5ede0d90abbfc3b7e444a378703c9abce9. --- .../key_management_endpoints.py | 3 - .../test_key_management_endpoints.py | 100 ------------------ 2 files changed, 103 deletions(-) diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 6ddedfcda3..a230c2e933 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -2165,11 +2165,8 @@ async def validate_key_team_change( - The person initiating the change must be either Proxy Admin or Team Admin """ # Check if the team has access to the key's models - _special_model_names = {e.value for e in SpecialModelNames} if len(key.models) > 0: for model in key.models: - if model in _special_model_names: - continue await can_team_access_model( model=model, team_object=team, diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index 44d379a388..ffb4e95542 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -1468,106 +1468,6 @@ async def test_validate_key_team_change_with_member_permissions(): ) -@pytest.mark.asyncio -async def test_validate_key_team_change_with_special_model_names(): - """ - Test that validate_key_team_change skips validation for special model names - like 'all-team-models', 'all-proxy-models', and 'no-default-models'. - - These are placeholder values, not real model names, so they should not be - checked against the team's actual model list. - - Related: https://github.com/BerriAI/litellm/issues/21880 - """ - mock_key = MagicMock() - mock_key.user_id = "test-user-123" - mock_key.models = ["all-team-models"] - mock_key.tpm_limit = None - mock_key.rpm_limit = None - - mock_team = MagicMock() - mock_team.team_id = "test-team-456" - mock_team.members_with_roles = [] - mock_team.tpm_limit = None - mock_team.rpm_limit = None - - mock_change_initiator = MagicMock() - mock_change_initiator.user_id = "test-user-123" - mock_change_initiator.user_role = LitellmUserRoles.PROXY_ADMIN.value - - mock_router = MagicMock() - - with patch( - "litellm.proxy.management_endpoints.key_management_endpoints.can_team_access_model", - new_callable=AsyncMock, - ) as mock_can_access: - with patch( - "litellm.proxy.management_endpoints.key_management_endpoints._get_user_in_team" - ) as mock_get_user: - mock_get_user.return_value = MagicMock() - - # Should NOT raise - "all-team-models" should be skipped - await validate_key_team_change( - key=mock_key, - team=mock_team, - change_initiated_by=mock_change_initiator, - llm_router=mock_router, - ) - - # can_team_access_model should NOT have been called - mock_can_access.assert_not_called() - - -@pytest.mark.asyncio -async def test_validate_key_team_change_special_models_mixed_with_real(): - """ - Test that when a key has both special and real model names, - only real model names are validated against the team. - - Related: https://github.com/BerriAI/litellm/issues/21880 - """ - mock_key = MagicMock() - mock_key.user_id = "test-user-123" - mock_key.models = ["all-team-models", "gpt-4"] - mock_key.tpm_limit = None - mock_key.rpm_limit = None - - mock_team = MagicMock() - mock_team.team_id = "test-team-456" - mock_team.members_with_roles = [] - mock_team.tpm_limit = None - mock_team.rpm_limit = None - - mock_change_initiator = MagicMock() - mock_change_initiator.user_id = "test-user-123" - mock_change_initiator.user_role = LitellmUserRoles.PROXY_ADMIN.value - - mock_router = MagicMock() - - with patch( - "litellm.proxy.management_endpoints.key_management_endpoints.can_team_access_model", - new_callable=AsyncMock, - ) as mock_can_access: - with patch( - "litellm.proxy.management_endpoints.key_management_endpoints._get_user_in_team" - ) as mock_get_user: - mock_get_user.return_value = MagicMock() - - await validate_key_team_change( - key=mock_key, - team=mock_team, - change_initiated_by=mock_change_initiator, - llm_router=mock_router, - ) - - # Should only be called for "gpt-4", not "all-team-models" - mock_can_access.assert_called_once_with( - model="gpt-4", - team_object=mock_team, - llm_router=mock_router, - ) - - def test_key_rotation_fields_helper(): """ Test the key data update logic for rotation fields. From 9495f4e941bbe8e686b92e2622d657c878e1f916 Mon Sep 17 00:00:00 2001 From: Cesar Garcia <128240629+Chesars@users.noreply.github.com> Date: Tue, 24 Feb 2026 02:00:37 -0300 Subject: [PATCH 09/17] fix(ollama): thread api_base to get_model_info + graceful fallback (#21970) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * auth_with_role_name add region_name arg for cross-account sts * update tests to include case with aws_region_name for _auth_with_aws_role * Only pass region_name to STS client when aws_region_name is set * Add optional aws_sts_endpoint to _auth_with_aws_role * Parametrize ambient-credentials test for no opts, region_name, and aws_sts_endpoint * consistently passing region and endpoint args into explicit credentials irsa * fix env var leakage * fix: bedrock openai-compatible imported-model should also have model arn encoded * feat: show proxy url in ModelHub (#21660) * fix(bedrock): correct modelInput format for Converse API batch models (#21656) * fix(proxy): add model_ids param to access group endpoints for precise deployment tagging (#21655) POST /access_group/new and PUT /access_group/{name}/update now accept an optional model_ids list that targets specific deployments by their unique model_id, instead of tagging every deployment that shares a model_name. When model_ids is provided it takes priority over model_names, giving API callers the same single-deployment precision that the UI already has via PATCH /model/{model_id}/update. Backward compatible: model_names continues to work as before. Closes #21544 * feat(proxy): add custom favicon support\n\nAdd ability to configure a custom favicon for the litellm proxy UI.\n\n- Add favicon_url field to UIThemeConfig model\n- Add LITELLM_FAVICON_URL env var support\n- Add /get_favicon endpoint to serve custom favicons\n- Update ThemeContext to dynamically set favicon\n- Add favicon URL input to UI theme settings page\n- Add comprehensive tests\n\nCloses #8323 (#21653) * fix(bedrock): prevent double UUID in create_file S3 key (#21650) In create_file for Bedrock, get_complete_file_url is called twice: once in the sync handler (generating UUID-1 for api_base) and once inside transform_create_file_request (generating UUID-2 for the actual S3 upload). The Bedrock provider correctly writes UUID-2 into litellm_params["upload_url"], but the sync handler unconditionally overwrites it with api_base (UUID-1). This causes the returned file_id to point to a non-existent S3 key. Fix: only set upload_url to api_base when transform_create_file_request has not already set it, preserving the Bedrock provider's value. Closes #21546 * feat(semantic-cache): support configurable vector dimensions for Qdrant (#21649) Add vector_size parameter to QdrantSemanticCache and expose it through the Cache facade as qdrant_semantic_cache_vector_size. This allows users to use embedding models with dimensions other than the default 1536, enabling cheaper/stronger models like Stella (1024d), bge-en-icl (4096d), voyage, cohere, etc. The parameter defaults to QDRANT_VECTOR_SIZE (env var or 1536) for backward compatibility. When creating new collections, the configured vector_size is used instead of the hardcoded constant. Closes #9377 * fix(utils): normalize camelCase thinking param keys to snake_case (#21762) Clients like OpenCode's @ai-sdk/openai-compatible send budgetTokens (camelCase) instead of budget_tokens in the thinking parameter, causing validation errors. Add early normalization in completion(). * feat: add optional digest mode for Slack alert types (#21683) Adds per-alert-type digest mode that aggregates duplicate alerts within a configurable time window and emits a single summary message with count, start/end timestamps. Configuration via general_settings.alert_type_config: alert_type_config: llm_requests_hanging: digest: true digest_interval: 86400 Digest key: (alert_type, request_model, api_base) Default interval: 24 hours Window type: fixed interval Co-authored-by: Claude Opus 4.6 (1M context) * feat: add blog_posts.json and local backup * feat: add GetBlogPosts utility with GitHub fetch and local fallback Adds GetBlogPosts class that fetches blog posts from GitHub with a 1-hour in-process TTL cache, validates the response, and falls back to the bundled blog_posts_backup.json on any network or validation failure. * test: add cache reset fixture and LITELLM_LOCAL_BLOG_POSTS test Co-Authored-By: Claude Sonnet 4.6 * feat: add GET /public/litellm_blog_posts endpoint Co-Authored-By: Claude Sonnet 4.6 * fix: log fallback warning in blog posts endpoint and tighten test * feat: add disable_show_blog to UISettings Co-Authored-By: Claude Sonnet 4.6 * feat: add useUISettings and useDisableShowBlog hooks * fix: rename useUISettings to useUISettingsFlags to avoid naming collision * fix: use existing useUISettings hook in useDisableShowBlog to avoid cache duplication Co-Authored-By: Claude Sonnet 4.6 * feat: add BlogDropdown component with react-query and error/retry state Co-Authored-By: Claude Sonnet 4.6 * fix: enforce 5-post limit in BlogDropdown and add cap test * fix: add retry, stable post key, enabled guard in BlogDropdown Co-Authored-By: Claude Sonnet 4.6 * feat: add BlogDropdown to navbar after Docs link * feat: add network_mock transport for benchmarking proxy overhead without real API calls Intercepts at httpx transport layer so the full proxy path (auth, routing, OpenAI SDK, response transformation) is exercised with zero-latency responses. Activated via `litellm_settings: { network_mock: true }` in proxy config. * Litellm dev 02 19 2026 p2 (#21871) * feat(ui/): new guardrails monitor 'demo mock representation of what guardrails monitor looks like * fix: ui updates * style(ui/): fix styling * feat: enable running ai monitor on individual guardrails * feat: add backend logic for guardrail monitoring * fix(guardrails/usage_endpoints.py): fix usage dashboard * fix(budget): fix timezone config lookup and replace hardcoded timezone map with ZoneInfo (#21754) * fix(budget): fix timezone config lookup and replace hardcoded timezone map with ZoneInfo * fix(budget): update stale docstring on get_budget_reset_time * fix: add missing return type annotations to iterator protocol methods in streaming_handler (#21750) * fix: add return type annotations to iterator protocol methods in streaming_handler Add missing return type annotations to __iter__, __aiter__, __next__, and __anext__ methods in CustomStreamWrapper and related classes. - __iter__(self) -> Iterator["ModelResponseStream"] - __aiter__(self) -> AsyncIterator["ModelResponseStream"] - __next__(self) -> "ModelResponseStream" - __anext__(self) -> "ModelResponseStream" Also adds AsyncIterator and Iterator to typing imports. Fixes issue with PLR0915 noqa comments and ensures proper type checking support. Related to: BerriAI/litellm#8304 * fix: add ruff PLR0915 noqa for files with too many statements * Add gollem Go agent framework cookbook example (#21747) Show how to use gollem, a production Go agent framework, with LiteLLM proxy for multi-provider LLM access including tool use and streaming. * fix: avoid mutating caller-owned dicts in SpendUpdateQueue aggregation (#21742) * fix(vertex_ai): enable context-1m-2025-08-07 beta header (#21870) * server root path regression doc * fixing syntax * fix: replace Zapier webhook with Google Form for survey submission (#21621) * Replace Zapier webhook with Google Form for survey submission * Add back error logging for survey submission debugging --------- Co-authored-by: Ishaan Jaff * Revert "Merge pull request #21140 from BerriAI/litellm_perf_user_api_key_auth" This reverts commit 0e1db3f7e4dbfd622825cc2e936f29091232496d, reversing changes made to 7e2d6f2355d64885b590628ab998e868d3a84643. * test_vertex_ai_gemini_2_5_pro_streaming * UI new build * fix rendering * ui new build * docs fix * docs fix * docs fix * docs fix * docs fix * docs fix * docs fix * docs fix * release note docs * docs * adding image * fix(vertex_ai): enable context-1m-2025-08-07 beta header The `context-1m-2025-08-07` Anthropic beta header was set to `null` for vertex_ai, causing it to be filtered out when users set `extra_headers: {anthropic-beta: context-1m-2025-08-07}`. This prevented using Claude's 1M context window feature via Vertex AI, resulting in `prompt is too long: 460500 tokens > 200000 maximum` errors. Fixes #21861 --------- Co-authored-by: yuneng-jiang Co-authored-by: milan-berri Co-authored-by: Ishaan Jaff * Revert "fix(vertex_ai): enable context-1m-2025-08-07 beta header (#21870)" (#21876) This reverts commit bce078a796174420951a3d973ea17e91b81afbbb. * docs(ui): add pre-PR checklist to UI contributing guide Add testing and build verification steps per maintainer feedback from @yjiang-litellm. Contributors should run their related tests per-file and ensure npm run build passes before opening PRs. * Fix entries with fast and us/ * Add tests for fast and us * Add support for Priority PayGo for vertex ai and gemini * Add model pricing * fix: ensure arrival_time is set before calculating queue time * Fix: Anthropic model wildcard access issue * Add incident report * Add ability to see which model cost map is getting used * Fix name of title * Readd tpm limit * State management fixes for CheckBatchCost * Fix PR review comments * State management fixes for CheckBatchCost - Address greptile comments * fix mypy issues: * Add Noma guardrails v2 based on custom guardrails (#21400) * Fix code qa issues * Fix mypy issues * Fix mypy issues * Fix test_aaamodel_prices_and_context_window_json_is_valid * fix: update calendly on repo * fix(tests): use counter-based mock for time.time in prisma self-heal test The test used a fixed side_effect list for time.time(), but the number of calls varies by Python version, causing StopIteration on 3.12 and AssertionError on 3.14. Replace with an infinite counter-based callable and assert the timestamp was updated rather than checking for an exact value. Co-Authored-By: Claude Opus 4.6 * fix(tests): use absolute path for model_prices JSON in validation test The test used a relative path 'litellm/model_prices_and_context_window.json' which only works when pytest runs from a specific working directory. Use os.path based on __file__ to resolve the path reliably. Co-Authored-By: Claude Opus 4.6 * Update tests/test_litellm/test_utils.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * fix(tests): use os.path instead of Path to avoid NameError Path is not imported at module level. Use os.path.join which is already available. Co-Authored-By: Claude Opus 4.6 * clean up mock transport: remove streaming, add defensive parsing * docs: add Google GenAI SDK tutorial (JS & Python) (#21885) * docs: add Google GenAI SDK tutorial for JS and Python Add tutorial for using Google's official GenAI SDK (@google/genai for JS, google-genai for Python) with LiteLLM proxy. Covers pass-through and native router endpoints, streaming, multi-turn chat, and multi-provider routing via model_group_alias. Also updates pass-through docs to use the new SDK replacing the deprecated @google/generative-ai. * fix(docs): correct Python SDK env var name in GenAI tutorial GOOGLE_GENAI_API_KEY does not exist in the google-genai SDK. The correct env var is GEMINI_API_KEY (or GOOGLE_API_KEY). Also note that the Python SDK has no base URL env var. * fix(docs): replace non-existent GOOGLE_GENAI_BASE_URL env var in interactions.md The Python google-genai SDK does not read GOOGLE_GENAI_BASE_URL. Use http_options={"base_url": "..."} in code instead. * docs: add network mock benchmarking section * docs: tweak benchmarks wording * fix: add auth headers and empty latencies guard to benchmark script * refactor: use method-level import for MockOpenAITransport * fix: guard print_aggregate against empty latencies * fix: add INCOMPLETE status to Interactions API enum and test Google added INCOMPLETE to the Interactions API OpenAPI spec status enum. Update both the Status3 enum in the SDK types and the test's expected values to match. Co-Authored-By: Claude Opus 4.6 * Guardrail Monitor - measure guardrail reliability in prod (#21944) * fix: fix log viewer for guardrail monitoring * feat(ui/): fix rendering logs per guardrail * fix: fix viewing logs on overview tab of guardrail * fix: log viewer * fix: fix naming to align with metric * docs: add performance & reliability section to v1.81.14 release notes * fix(tests): make RPM limit test sequential to avoid race condition Concurrent requests via run_in_executor + asyncio.gather caused a race condition where more requests slipped through the rate limiter than expected, leading to flaky test failures (e.g. 3 successes instead of 2 with rpm_limit=2). Co-Authored-By: Claude Opus 4.6 * feat: Singapore guardrail policies (PDPA + MAS AI Risk Management) (#21948) * feat: Singapore PDPA PII protection guardrail policy template Add Singapore Personal Data Protection Act (PDPA) guardrail support: Regex patterns (patterns.json): - sg_nric: NRIC/FIN detection ([STFGM] + 7 digits + checksum letter) - sg_phone: Singapore phone numbers (+65/0065/65 prefix) - sg_postal_code: 6-digit postal codes (contextual) - passport_singapore: Passport numbers (E/K + 7 digits, contextual) - sg_uen: Unique Entity Numbers (3 formats) - sg_bank_account: Bank account numbers (dash format, contextual) YAML policy templates (5 sub-guardrails): - sg_pdpa_personal_identifiers: s.13 Consent - sg_pdpa_sensitive_data: Advisory Guidelines - sg_pdpa_do_not_call: Part IX DNC Registry - sg_pdpa_data_transfer: s.26 overseas transfers - sg_pdpa_profiling_automated_decisions: Model AI Governance Framework Policy template entry in policy_templates.json with 9 guardrail definitions (4 regex-based + 5 YAML conditional keyword matching). Tests: - test_sg_patterns.py: regex pattern unit tests - test_sg_pdpa_guardrails.py: conditional keyword matching tests (100+ cases) * feat: MAS AI Risk Management Guidelines guardrail policy template Add Monetary Authority of Singapore (MAS) AI Risk Management Guidelines guardrail support for financial institutions: YAML policy templates (5 sub-guardrails): - sg_mas_fairness_bias: Blocks discriminatory financial AI (credit/loans/insurance by protected attributes) - sg_mas_transparency_explainability: Blocks opaque/unexplainable AI for consequential financial decisions - sg_mas_human_oversight: Blocks fully automated financial decisions without human-in-the-loop - sg_mas_data_governance: Blocks unauthorized sharing/mishandling of financial customer data - sg_mas_model_security: Blocks adversarial attacks, model poisoning, inversion on financial AI Policy template entry in policy_templates.json with 5 guardrail definitions. Aligned with MAS FEAT Principles, Project MindForge, and NIST AI RMF. Tests: - test_sg_mas_ai_guardrails.py: conditional keyword matching tests (100+ cases) * fix: address SG pattern review feedback - Update NRIC lowercase test for IGNORECASE runtime behavior - Add keyword context guard to sg_uen pattern to reduce false positives * docs: clarify MAS AIRM timeline references - Explicitly mark MAS AIRM as Nov 2025 consultation draft - Add 2018 qualifier for FEAT principles in MAS policy descriptions - Update MAS guardrail wording to avoid release-year ambiguity * chore: commit resolved MAS policy conflicts * test: * chore: * Add OpenAI Agents SDK tutorial with LiteLLM Proxy to docs (#21221) * Add OpenAI Agents SDK tutorial to docs * Update OpenAI Agents SDK tutorial to use LiteLLM environment variables * Enhance OpenAI Agents SDK tutorial with built-in LiteLLM extension details and updated configuration steps. Adjust section headings for clarity and improve the flow of information regarding model setup and usage. * adjust blog posts to fetch from github first * feat(videos): add variant parameter to video content download (#21955) openai videos models support the features to download variants. See more details here: https://developers.openai.com/api/docs/guides/video-generation#use-image-references. Plumb variant (e.g. "thumbnail", "spritesheet") through the full video content download chain: avideo_content → video_content → video_content_handler → transform_video_content_request. OpenAI appends ?variant= to the GET URL; other providers accept the parameter in their signature but ignore it. * fixing path * adjust blog post path * Revert duplicate issue checker to text-based matching, remove duplicate PR workflow Remove the Claude Code-powered duplicate PR detection workflow and revert the duplicate issue checker back to wow-actions/potential-duplicates with text similarity matching. * ui changes * adding tests * adjust default aggregation threshold * fix(videos): pass api_key from litellm_params to video remix handlers (#21965) video_remix_handler and async_video_remix_handler were not falling back to litellm_params.api_key when the api_key parameter was None, causing Authorization: Bearer None to be sent to the provider. This matches the pattern already used by async_video_generation_handler. * adding testing coverage + fixing flaky tests * fix(ollama): thread api_base through get_model_info and add graceful fallback When users pass api_base to litellm.completion() for Ollama, the model info fetch (context window, function_calling support) was ignoring the user's api_base and only reading OLLAMA_API_BASE env var or defaulting to localhost:11434. This caused confusing errors in logs when Ollama runs on a remote server. Thread api_base from litellm_params through the get_model_info call chain so OllamaConfig.get_model_info() uses the correct server. Also return safe defaults instead of raising when the server is unreachable. Fixes #21967 --------- Co-authored-by: An Tang Co-authored-by: janfrederickk <75388864+janfrederickk@users.noreply.github.com> Co-authored-by: Zhenting Huang <3061613175@qq.com> Co-authored-by: Darien Kindlund Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: yuneng-jiang Co-authored-by: Ryan Crabbe Co-authored-by: Krish Dholakia Co-authored-by: LeeJuOh <56071126+LeeJuOh@users.noreply.github.com> Co-authored-by: Monesh Ram <31161039+WhoisMonesh@users.noreply.github.com> Co-authored-by: Trevor Prater Co-authored-by: The Mavik <179817126+themavik@users.noreply.github.com> Co-authored-by: Edwin Isac <33712823+edwiniac@users.noreply.github.com> Co-authored-by: milan-berri Co-authored-by: Ishaan Jaff Co-authored-by: Sameer Kankute Co-authored-by: Harshit Jain Co-authored-by: Harshit Jain <48647625+Harshit28j@users.noreply.github.com> Co-authored-by: Ephrim Stanley Co-authored-by: TomAlon Co-authored-by: Julio Quinteros Pro Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: ryan-crabbe <128659760+ryan-crabbe@users.noreply.github.com> Co-authored-by: Ron Zhong Co-authored-by: Arindam Majumder <109217591+Arindam200@users.noreply.github.com> Co-authored-by: Lei Nie --- .github/ISSUE_TEMPLATE/config.yml | 2 +- .github/workflows/check_duplicate_issues.yml | 51 +- .github/workflows/check_duplicate_prs.yml | 52 -- .github/workflows/interpret_load_test.py | 2 +- README.md | 2 +- cookbook/benchmark/readme.md | 2 +- cookbook/gollem_go_agent_framework/README.md | 119 +++ .../gollem_go_agent_framework/basic/main.go | 41 ++ cookbook/gollem_go_agent_framework/go.mod | 5 + cookbook/gollem_go_agent_framework/go.sum | 2 + .../proxy_config.yaml | 16 + .../streaming/main.go | 56 ++ .../gollem_go_agent_framework/tools/main.go | 64 ++ .../index.md | 147 ++++ docs/my-website/docs/benchmarks.md | 38 + docs/my-website/docs/caching/all_caches.md | 2 + docs/my-website/docs/contributing.md | 22 +- docs/my-website/docs/enterprise.md | 6 +- docs/my-website/docs/fine_tuning.md | 2 +- docs/my-website/docs/interactions.md | 18 +- .../observability/gcs_bucket_integration.md | 2 +- .../docs/pass_through/google_ai_studio.md | 71 +- docs/my-website/docs/proxy/alerting.md | 53 ++ .../docs/proxy/budget_reset_and_tz.md | 2 + docs/my-website/docs/proxy/caching.md | 1 + docs/my-website/docs/proxy/config_settings.md | 1 + docs/my-website/docs/proxy/cost_tracking.md | 2 +- docs/my-website/docs/proxy/email.md | 2 +- docs/my-website/docs/proxy/enterprise.md | 2 +- .../docs/proxy/guardrails/aporia_api.md | 2 +- .../docs/proxy/guardrails/custom_guardrail.md | 2 +- .../docs/proxy/guardrails/guardrails_ai.md | 2 +- .../docs/proxy/guardrails/noma_security.md | 102 +++ docs/my-website/docs/proxy/ip_address.md | 2 +- docs/my-website/docs/proxy/logging.md | 6 +- docs/my-website/docs/proxy/multiple_admins.md | 2 +- docs/my-website/docs/proxy/oauth2.md | 2 +- docs/my-website/docs/proxy/prod.md | 2 +- docs/my-website/docs/proxy/public_routes.md | 2 +- docs/my-website/docs/proxy/tag_routing.md | 2 +- docs/my-website/docs/proxy/team_logging.md | 4 +- docs/my-website/docs/proxy/team_model_add.md | 2 +- docs/my-website/docs/proxy/token_auth.md | 2 +- docs/my-website/docs/secret.md | 2 +- .../docs/secret_managers/aws_kms.md | 2 +- .../secret_managers/aws_secret_manager.md | 2 +- .../docs/secret_managers/azure_key_vault.md | 2 +- .../docs/secret_managers/cyberark.md | 2 +- .../docs/secret_managers/google_kms.md | 2 +- .../secret_managers/google_secret_manager.md | 2 +- .../docs/secret_managers/hashicorp_vault.md | 2 +- .../docs/secret_managers/overview.md | 2 +- .../my-website/docs/tutorials/compare_llms.md | 2 +- .../docs/tutorials/google_genai_sdk.md | 406 ++++++++++ .../docs/tutorials/openai_agents_sdk.md | 373 ++++++++++ docs/my-website/img/litellm_proxy_setup.png | Bin 0 -> 550520 bytes .../img/release_notes/v1_81_14_perf.png | Bin 0 -> 639632 bytes docs/my-website/release_notes/v1.81.14.md | 34 + docs/my-website/sidebars.js | 3 + enterprise/LICENSE.md | 2 +- enterprise/README.md | 2 +- .../send_emails/base_email.py | 196 +++-- .../proxy/common_utils/check_batch_cost.py | 58 +- .../proxy/hooks/managed_files.py | 12 +- .../migration.sql | 2 - .../migration.sql | 60 ++ .../migration.sql | 3 + .../litellm_proxy_extras/schema.prisma | 49 ++ litellm/__init__.py | 10 +- litellm/blog_posts.json | 10 + litellm/caching/caching.py | 2 + litellm/caching/qdrant_semantic_cache.py | 4 +- litellm/constants.py | 6 +- litellm/cost_calculator.py | 49 +- .../SlackAlerting/hanging_request_check.py | 2 + .../SlackAlerting/slack_alerting.py | 123 +++- litellm/integrations/custom_guardrail.py | 7 +- litellm/litellm_core_utils/duration_parser.py | 28 +- litellm/litellm_core_utils/get_blog_posts.py | 128 ++++ .../litellm_core_utils/get_model_cost_map.py | 45 ++ litellm/litellm_core_utils/litellm_logging.py | 6 +- .../litellm_core_utils/llm_cost_calc/utils.py | 39 +- .../litellm_core_utils/streaming_handler.py | 26 +- litellm/llms/anthropic/cost_calculation.py | 78 +- .../llms/base_llm/videos/transformation.py | 3 +- litellm/llms/bedrock/files/transformation.py | 76 +- litellm/llms/custom_httpx/llm_http_handler.py | 14 +- litellm/llms/custom_httpx/mock_transport.py | 92 +++ litellm/llms/gemini/cost_calculator.py | 8 +- litellm/llms/gemini/videos/transformation.py | 3 +- .../llms/ollama/completion/transformation.py | 29 +- litellm/llms/openai/common_utils.py | 10 + litellm/llms/openai/videos/transformation.py | 10 +- .../llms/runwayml/videos/transformation.py | 3 +- litellm/llms/vertex_ai/cost_calculator.py | 4 + .../llms/vertex_ai/videos/transformation.py | 1 + litellm/main.py | 3 + ...odel_prices_and_context_window_backup.json | 298 +++----- litellm/policy_templates_backup.json | 362 +++++++++ litellm/proxy/_new_secret_config.yaml | 5 - litellm/proxy/common_request_processing.py | 56 +- litellm/proxy/common_utils/timezone_utils.py | 18 +- .../db_transaction_queue/base_update_queue.py | 9 + .../proxy/guardrails/guardrail_endpoints.py | 72 +- .../litellm_content_filter/patterns.json | 50 ++ .../sg_mas_data_governance.yaml | 96 +++ .../sg_mas_fairness_bias.yaml | 101 +++ .../sg_mas_human_oversight.yaml | 89 +++ .../sg_mas_model_security.yaml | 97 +++ .../sg_mas_transparency_explainability.yaml | 85 +++ .../sg_pdpa_data_transfer.yaml | 72 ++ .../policy_templates/sg_pdpa_do_not_call.yaml | 85 +++ .../sg_pdpa_personal_identifiers.yaml | 94 +++ ...sg_pdpa_profiling_automated_decisions.yaml | 82 +++ .../sg_pdpa_sensitive_data.yaml | 100 +++ .../guardrail_hooks/noma/__init__.py | 29 + .../guardrails/guardrail_hooks/noma/noma.py | 13 + .../guardrail_hooks/noma/noma_v2.py | 309 ++++++++ litellm/proxy/guardrails/usage_endpoints.py | 691 ++++++++++++++++++ litellm/proxy/guardrails/usage_tracking.py | 170 +++++ ...model_access_group_management_endpoints.py | 157 +++- litellm/proxy/management_endpoints/ui_sso.py | 72 +- litellm/proxy/proxy_server.py | 136 +++- .../public_endpoints/public_endpoints.py | 32 + litellm/proxy/schema.prisma | 50 +- .../proxy_setting_endpoints.py | 29 +- litellm/proxy/utils.py | 187 ++--- litellm/types/guardrails.py | 5 + litellm/types/integrations/slack_alerting.py | 31 +- litellm/types/interactions/generated.py | 1 + .../proxy/guardrails/guardrail_hooks/noma.py | 34 +- .../model_management_endpoints.py | 9 +- litellm/types/utils.py | 65 +- litellm/utils.py | 43 +- litellm/videos/main.py | 4 + model_prices_and_context_window.json | 298 +++----- policy_templates.json | 362 +++++++++ ruff.toml | 5 +- schema.prisma | 49 ++ scripts/benchmark_mock.py | 160 ++++ .../test_sg_mas_ai_guardrails.py | 416 +++++++++++ .../test_sg_pdpa_guardrails.py | 476 ++++++++++++ .../test_pass_through_endpoints.py | 21 +- tests/proxy_unit_tests/test_auth_checks.py | 126 ++++ .../test_blog_posts_endpoint.py | 80 ++ tests/proxy_unit_tests/test_get_favicon.py | 75 ++ .../caching/test_qdrant_semantic_cache.py | 129 +++- .../test_slack_alerting_digest.py | 235 ++++++ .../interactions/test_openapi_compliance.py | 3 +- .../test_duration_parser.py | 60 +- .../test_anthropic_chat_transformation.py | 65 +- .../test_bedrock_files_transformation.py | 220 ++++++ .../llms/custom_httpx/test_mock_transport.py | 116 +++ .../llms/ollama/test_ollama_model_info.py | 82 +++ .../proxy/common_utils/test_timezone_utils.py | 83 ++- .../test_base_update_queue.py | 18 + .../proxy/db/test_prisma_self_heal.py | 9 +- .../content_filter/test_sg_patterns.py | 156 ++++ .../guardrails/guardrail_hooks/test_noma.py | 37 + .../guardrail_hooks/test_noma_v2.py | 531 ++++++++++++++ .../guardrails/test_guardrail_registry.py | 16 +- .../test_access_group_management.py | 210 ++++++ .../proxy/test_common_request_processing.py | 340 +++++---- .../test_proxy_setting_endpoints.py | 113 +++ tests/test_litellm/test_get_blog_posts.py | 165 +++++ tests/test_litellm/test_utils.py | 59 +- tests/test_litellm/test_video_generation.py | 190 +++++ ui/litellm-dashboard/package.json | 1 + .../hooks/blogPosts/useBlogPosts.ts | 32 + .../(dashboard)/hooks/useDisableBlogPosts.ts | 33 + ui/litellm-dashboard/src/app/page.tsx | 3 + .../src/components/AIHub/ModelHubTable.tsx | 6 +- .../EvaluationSettingsModal.tsx | 157 ++++ .../GuardrailsMonitor/GuardrailConfig.tsx | 217 ++++++ .../GuardrailsMonitor/GuardrailDetail.tsx | 245 +++++++ .../GuardrailsMonitorView.test.tsx | 52 ++ .../GuardrailsMonitorView.tsx | 74 ++ .../GuardrailsMonitor/GuardrailsOverview.tsx | 313 ++++++++ .../GuardrailsMonitor/LogViewer.tsx | 227 ++++++ .../GuardrailsMonitor/MetricCard.tsx | 30 + .../GuardrailsMonitor/ScoreChart.tsx | 39 + .../components/GuardrailsMonitor/mockData.ts | 50 ++ .../Navbar/BlogDropdown/BlogDropdown.test.tsx | 230 ++++++ .../Navbar/BlogDropdown/BlogDropdown.tsx | 84 +++ .../Navbar/UserDropdown/UserDropdown.tsx | 19 + .../MCPSemanticFilterSettings.test.tsx | 165 +++++ .../MCPSemanticFilterTestPanel.test.tsx | 141 ++++ .../semanticFilterTestUtils.test.ts | 117 +++ .../src/components/TeamSSOSettings.test.tsx | 30 - .../src/components/leftnav.tsx | 7 + .../src/components/navbar.tsx | 46 +- .../src/components/networking.tsx | 149 ++++ .../src/components/page_metadata.ts | 1 + .../src/components/price_data_reload.tsx | 137 +++- .../src/components/ui_theme_settings.tsx | 122 +--- .../GuardrailViewer/GuardrailViewer.tsx | 6 +- .../src/contexts/ThemeContext.tsx | 41 +- 197 files changed, 13104 insertions(+), 1371 deletions(-) delete mode 100644 .github/workflows/check_duplicate_prs.yml create mode 100644 cookbook/gollem_go_agent_framework/README.md create mode 100644 cookbook/gollem_go_agent_framework/basic/main.go create mode 100644 cookbook/gollem_go_agent_framework/go.mod create mode 100644 cookbook/gollem_go_agent_framework/go.sum create mode 100644 cookbook/gollem_go_agent_framework/proxy_config.yaml create mode 100644 cookbook/gollem_go_agent_framework/streaming/main.go create mode 100644 cookbook/gollem_go_agent_framework/tools/main.go create mode 100644 docs/my-website/blog/anthropic_wildcard_model_access_incident/index.md create mode 100644 docs/my-website/docs/tutorials/google_genai_sdk.md create mode 100644 docs/my-website/docs/tutorials/openai_agents_sdk.md create mode 100644 docs/my-website/img/litellm_proxy_setup.png create mode 100644 docs/my-website/img/release_notes/v1_81_14_perf.png delete mode 100644 litellm-proxy-extras/litellm_proxy_extras/migrations/20260214124140_baseline_diff/migration.sql create mode 100644 litellm-proxy-extras/litellm_proxy_extras/migrations/20260219181415_baseline_diff/migration.sql create mode 100644 litellm-proxy-extras/litellm_proxy_extras/migrations/20260222000000_add_batch_processed_to_managed_object_table/migration.sql create mode 100644 litellm/blog_posts.json create mode 100644 litellm/litellm_core_utils/get_blog_posts.py create mode 100644 litellm/llms/custom_httpx/mock_transport.py create mode 100644 litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_data_governance.yaml create mode 100644 litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_fairness_bias.yaml create mode 100644 litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_human_oversight.yaml create mode 100644 litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_model_security.yaml create mode 100644 litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_transparency_explainability.yaml create mode 100644 litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_data_transfer.yaml create mode 100644 litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_do_not_call.yaml create mode 100644 litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_personal_identifiers.yaml create mode 100644 litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_profiling_automated_decisions.yaml create mode 100644 litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_sensitive_data.yaml create mode 100644 litellm/proxy/guardrails/guardrail_hooks/noma/noma_v2.py create mode 100644 litellm/proxy/guardrails/usage_endpoints.py create mode 100644 litellm/proxy/guardrails/usage_tracking.py create mode 100644 scripts/benchmark_mock.py create mode 100644 tests/guardrails_tests/test_sg_mas_ai_guardrails.py create mode 100644 tests/guardrails_tests/test_sg_pdpa_guardrails.py create mode 100644 tests/proxy_unit_tests/test_blog_posts_endpoint.py create mode 100644 tests/proxy_unit_tests/test_get_favicon.py create mode 100644 tests/test_litellm/integrations/SlackAlerting/test_slack_alerting_digest.py create mode 100644 tests/test_litellm/llms/custom_httpx/test_mock_transport.py create mode 100644 tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_sg_patterns.py create mode 100644 tests/test_litellm/proxy/guardrails/guardrail_hooks/test_noma_v2.py create mode 100644 tests/test_litellm/test_get_blog_posts.py create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/blogPosts/useBlogPosts.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableBlogPosts.ts create mode 100644 ui/litellm-dashboard/src/components/GuardrailsMonitor/EvaluationSettingsModal.tsx create mode 100644 ui/litellm-dashboard/src/components/GuardrailsMonitor/GuardrailConfig.tsx create mode 100644 ui/litellm-dashboard/src/components/GuardrailsMonitor/GuardrailDetail.tsx create mode 100644 ui/litellm-dashboard/src/components/GuardrailsMonitor/GuardrailsMonitorView.test.tsx create mode 100644 ui/litellm-dashboard/src/components/GuardrailsMonitor/GuardrailsMonitorView.tsx create mode 100644 ui/litellm-dashboard/src/components/GuardrailsMonitor/GuardrailsOverview.tsx create mode 100644 ui/litellm-dashboard/src/components/GuardrailsMonitor/LogViewer.tsx create mode 100644 ui/litellm-dashboard/src/components/GuardrailsMonitor/MetricCard.tsx create mode 100644 ui/litellm-dashboard/src/components/GuardrailsMonitor/ScoreChart.tsx create mode 100644 ui/litellm-dashboard/src/components/GuardrailsMonitor/mockData.ts create mode 100644 ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx create mode 100644 ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx create mode 100644 ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterSettings.test.tsx create mode 100644 ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.test.tsx create mode 100644 ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/semanticFilterTestUtils.test.ts diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index b067941123..4744ab048c 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,7 +1,7 @@ blank_issues_enabled: true contact_links: - name: Schedule Demo - url: https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat + url: https://calendly.com/d/cx9p-5yf-2nm/litellm-introductions about: Speak directly with Krrish and Ishaan, the founders, to discuss issues, share feedback, or explore improvements for LiteLLM - name: Discord url: https://discord.com/invite/wuPM9dRgDw diff --git a/.github/workflows/check_duplicate_issues.yml b/.github/workflows/check_duplicate_issues.yml index b2a298bfcd..9477dd2f8e 100644 --- a/.github/workflows/check_duplicate_issues.yml +++ b/.github/workflows/check_duplicate_issues.yml @@ -2,47 +2,28 @@ name: Check Duplicate Issues on: issues: - types: [opened] + types: [opened, edited] jobs: - check-duplicates: - if: github.event.action == 'opened' + check-duplicate: runs-on: ubuntu-latest permissions: - contents: read issues: write + contents: read steps: - - name: Install Claude Code - run: npm install -g @anthropic-ai/claude-code - - - name: Check duplicates - env: - ANTHROPIC_API_KEY: ${{ secrets.LITELLM_VIRTUAL_KEY }} - ANTHROPIC_BASE_URL: ${{ secrets.LITELLM_BASE_URL }} + - name: Check for potential duplicates + uses: wow-actions/potential-duplicates@v1 + with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PROMPT: | - A new issue has been created in the ${{ github.repository }} repository. + label: potential-duplicate + threshold: 0.6 + reaction: eyes + comment: | + **⚠️ Potential duplicate detected** - Issue number: ${{ github.event.issue.number }} + This issue appears similar to existing issue(s): + {{#issues}} + - [#{{number}}]({{html_url}}) - {{title}} ({{accuracy}}% similar) + {{/issues}} - Lookup this issue with gh issue view ${{ github.event.issue.number }} --repo ${{ github.repository }}. - - Search through existing issues (excluding #${{ github.event.issue.number }}) to find potential duplicates. - - Use gh issue list --repo ${{ github.repository }} with relevant search terms from the new issue's title and description. Try multiple keyword combinations to search broadly. Check both open and recently closed issues. - - Consider: - 1. Similar titles or descriptions - 2. Same error messages or symptoms - 3. Related functionality or components - 4. Similar feature requests - - If you find potential duplicates, post a SINGLE comment on issue #${{ github.event.issue.number }} using gh issue comment ${{ github.event.issue.number }} --repo ${{ github.repository }} with this format: - - _This comment was generated by an LLM and may be inaccurate._ - - This issue might be a duplicate of existing issues. Please check: - - #[issue_number]: [brief description of similarity] - - If you find NO duplicates, do NOT post any comment. Stay silent. - run: claude -p "$PROMPT" --model sonnet --max-turns 10 --allowedTools "Bash(gh issue *)" + Please review the linked issue(s) to see if they address your concern. If this is not a duplicate, please provide additional context to help us understand the difference. diff --git a/.github/workflows/check_duplicate_prs.yml b/.github/workflows/check_duplicate_prs.yml deleted file mode 100644 index 5a5f1a89e6..0000000000 --- a/.github/workflows/check_duplicate_prs.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: Check Duplicate PRs - -on: - pull_request_target: - types: [opened] - -jobs: - check-duplicates: - if: | - github.event.pull_request.user.login != 'ishaan-jaff' && - github.event.pull_request.user.login != 'krrishdholakia' && - github.event.pull_request.user.login != 'actions-user' && - !endsWith(github.event.pull_request.user.login, '[bot]') - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - steps: - - name: Install Claude Code - run: npm install -g @anthropic-ai/claude-code - - - name: Check duplicates - env: - ANTHROPIC_API_KEY: ${{ secrets.LITELLM_VIRTUAL_KEY }} - ANTHROPIC_BASE_URL: ${{ secrets.LITELLM_BASE_URL }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PROMPT: | - A new PR has been opened in the ${{ github.repository }} repository. - - PR number: ${{ github.event.pull_request.number }} - - Lookup this PR with gh pr view ${{ github.event.pull_request.number }} --repo ${{ github.repository }}. - - Search through existing open PRs (excluding #${{ github.event.pull_request.number }}) to find potential duplicates. - - Use gh pr list --repo ${{ github.repository }} with relevant search terms from the new PR's title and description. Try multiple keyword combinations to search broadly. Check both open and recently closed PRs. - - Consider: - 1. Similar titles or descriptions - 2. Same bug fix or feature being implemented - 3. Related functionality or components - 4. Overlapping code changes (same files or areas) - - If you find potential duplicates, post a SINGLE comment on PR #${{ github.event.pull_request.number }} using gh pr comment ${{ github.event.pull_request.number }} --repo ${{ github.repository }} with this format: - - _This comment was generated by an LLM and may be inaccurate._ - - This PR might be a duplicate of existing PRs. Please check: - - #[pr_number]: [brief description of similarity] - - If you find NO duplicates, do NOT post any comment. Stay silent. - run: claude -p "$PROMPT" --model sonnet --max-turns 10 --allowedTools "Bash(gh pr *)" diff --git a/.github/workflows/interpret_load_test.py b/.github/workflows/interpret_load_test.py index 0b5df73862..348ff300ff 100644 --- a/.github/workflows/interpret_load_test.py +++ b/.github/workflows/interpret_load_test.py @@ -123,7 +123,7 @@ if __name__ == "__main__": + docker_run_command + "\n\n" + "### Don't want to maintain your internal proxy? get in touch 🎉" - + "\nHosted Proxy Alpha: https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat" + + "\nHosted Proxy Alpha: https://calendly.com/d/cx9p-5yf-2nm/litellm-introductions" + "\n\n" + "## Load Test LiteLLM Proxy Results" + "\n\n" diff --git a/README.md b/README.md index 7790c67afd..3ebaefb10c 100644 --- a/README.md +++ b/README.md @@ -399,7 +399,7 @@ Support for more providers. Missing a provider or LLM Platform, raise a [feature # Enterprise For companies that need better security, user management and professional support -[Talk to founders](https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat) +[Talk to founders](https://calendly.com/d/cx9p-5yf-2nm/litellm-introductions) This covers: - ✅ **Features under the [LiteLLM Commercial License](https://docs.litellm.ai/docs/proxy/enterprise):** diff --git a/cookbook/benchmark/readme.md b/cookbook/benchmark/readme.md index a543d91011..57115eb96a 100644 --- a/cookbook/benchmark/readme.md +++ b/cookbook/benchmark/readme.md @@ -178,4 +178,4 @@ Benchmark Results for 'When will BerriAI IPO?': ``` ## Support -**🤝 Schedule a 1-on-1 Session:** Book a [1-on-1 session](https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat) with Krrish and Ishaan, the founders, to discuss any issues, provide feedback, or explore how we can improve LiteLLM for you. +**🤝 Schedule a 1-on-1 Session:** Book a [1-on-1 session](https://calendly.com/d/cx9p-5yf-2nm/litellm-introductions) with Krrish and Ishaan, the founders, to discuss any issues, provide feedback, or explore how we can improve LiteLLM for you. diff --git a/cookbook/gollem_go_agent_framework/README.md b/cookbook/gollem_go_agent_framework/README.md new file mode 100644 index 0000000000..729f985d08 --- /dev/null +++ b/cookbook/gollem_go_agent_framework/README.md @@ -0,0 +1,119 @@ +# Gollem Go Agent Framework with LiteLLM + +A working example showing how to use [gollem](https://github.com/fugue-labs/gollem), a production-grade Go agent framework, with LiteLLM as a proxy gateway. This lets Go developers access 100+ LLM providers through a single proxy while keeping compile-time type safety for tools and structured output. + +## Quick Start + +### 1. Start LiteLLM Proxy + +```bash +# Simple start with a single model +litellm --model gpt-4o + +# Or with the example config for multi-provider access +litellm --config proxy_config.yaml +``` + +### 2. Run the examples + +```bash +# Install Go dependencies +go mod tidy + +# Basic agent +go run ./basic + +# Agent with type-safe tools +go run ./tools + +# Streaming responses +go run ./streaming +``` + +## Configuration + +The included `proxy_config.yaml` sets up three providers through LiteLLM: + +```yaml +model_list: + - model_name: gpt-4o # OpenAI + - model_name: claude-sonnet # Anthropic + - model_name: gemini-pro # Google Vertex AI +``` + +Switch providers in Go by changing a single string — no code changes needed: + +```go +model := openai.NewLiteLLM("http://localhost:4000", + openai.WithModel("gpt-4o"), // OpenAI + // openai.WithModel("claude-sonnet"), // Anthropic + // openai.WithModel("gemini-pro"), // Google +) +``` + +## Examples + +### `basic/` — Basic Agent + +Connects gollem to LiteLLM and runs a simple prompt. Demonstrates the `NewLiteLLM` constructor and basic agent creation. + +### `tools/` — Type-Safe Tools + +Shows gollem's compile-time type-safe tool framework working through LiteLLM's tool-use passthrough. The tool parameters are Go structs with JSON tags — the schema is generated automatically at compile time. + +### `streaming/` — Streaming Responses + +Real-time token streaming using Go 1.23+ range-over-function iterators, proxied through LiteLLM's SSE passthrough. + +## How It Works + +Gollem's `openai.NewLiteLLM()` constructor creates an OpenAI-compatible provider pointed at your LiteLLM proxy. Since LiteLLM speaks the OpenAI API protocol, everything works out of the box: + +- **Chat completions** — standard request/response +- **Tool use** — LiteLLM passes tool definitions and calls through transparently +- **Streaming** — Server-Sent Events proxied through LiteLLM +- **Structured output** — JSON schema response format works with supporting models + +``` +Go App (gollem) → LiteLLM Proxy → OpenAI / Anthropic / Google / ... +``` + +## Why Use This? + +- **Type-safe Go**: Compile-time type checking for tools, structured output, and agent configuration — no runtime surprises +- **Single proxy, many models**: Switch between OpenAI, Anthropic, Google, and 100+ other providers by changing a model name string +- **Zero-dependency core**: gollem's core has no external dependencies — just stdlib +- **Single binary deployment**: `go build` produces one binary, no pip/venv/Docker needed +- **Cost tracking & rate limiting**: LiteLLM handles cost tracking, rate limits, and fallbacks at the proxy layer + +## Environment Variables + +```bash +# Required for providers you want to use (set in LiteLLM config or env) +export OPENAI_API_KEY="sk-..." +export ANTHROPIC_API_KEY="sk-ant-..." + +# Optional: point to a non-default LiteLLM proxy +export LITELLM_PROXY_URL="http://localhost:4000" +``` + +## Troubleshooting + +**Connection errors?** +- Make sure LiteLLM is running: `litellm --model gpt-4o` +- Check the URL is correct (default: `http://localhost:4000`) + +**Model not found?** +- Verify the model name matches what's configured in LiteLLM +- Run `curl http://localhost:4000/models` to see available models + +**Tool calls not working?** +- Ensure the underlying model supports tool use (GPT-4o, Claude, Gemini) +- Check LiteLLM logs for any provider-specific errors + +## Learn More + +- [gollem GitHub](https://github.com/fugue-labs/gollem) +- [gollem API Reference](https://pkg.go.dev/github.com/fugue-labs/gollem/core) +- [LiteLLM Proxy Docs](https://docs.litellm.ai/docs/simple_proxy) +- [LiteLLM Supported Models](https://docs.litellm.ai/docs/providers) diff --git a/cookbook/gollem_go_agent_framework/basic/main.go b/cookbook/gollem_go_agent_framework/basic/main.go new file mode 100644 index 0000000000..838149a8ff --- /dev/null +++ b/cookbook/gollem_go_agent_framework/basic/main.go @@ -0,0 +1,41 @@ +// Basic gollem agent connected to a LiteLLM proxy. +// +// Usage: +// +// litellm --model gpt-4o # start proxy in another terminal +// go run ./basic +package main + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/fugue-labs/gollem/core" + "github.com/fugue-labs/gollem/provider/openai" +) + +func main() { + proxyURL := "http://localhost:4000" + if u := os.Getenv("LITELLM_PROXY_URL"); u != "" { + proxyURL = u + } + + // Connect to LiteLLM proxy. NewLiteLLM creates an OpenAI-compatible + // provider pointed at the given URL. + model := openai.NewLiteLLM(proxyURL, + openai.WithModel("gpt-4o"), // any model name configured in LiteLLM + ) + + // Create and run a simple agent. + agent := core.NewAgent[string](model, + core.WithSystemPrompt[string]("You are a helpful assistant. Be concise."), + ) + + result, err := agent.Run(context.Background(), "Explain quantum computing in two sentences.") + if err != nil { + log.Fatal(err) + } + fmt.Println(result.Output) +} diff --git a/cookbook/gollem_go_agent_framework/go.mod b/cookbook/gollem_go_agent_framework/go.mod new file mode 100644 index 0000000000..89d9033aa2 --- /dev/null +++ b/cookbook/gollem_go_agent_framework/go.mod @@ -0,0 +1,5 @@ +module github.com/BerriAI/litellm/cookbook/gollem_go_agent_framework + +go 1.25.1 + +require github.com/fugue-labs/gollem v0.1.0 diff --git a/cookbook/gollem_go_agent_framework/go.sum b/cookbook/gollem_go_agent_framework/go.sum new file mode 100644 index 0000000000..1eb6c5ac9f --- /dev/null +++ b/cookbook/gollem_go_agent_framework/go.sum @@ -0,0 +1,2 @@ +github.com/fugue-labs/gollem v0.1.0 h1:QexYnvkb44QZFEljgAePqMIGZjgsbk0Y5GJ2jYYgfa8= +github.com/fugue-labs/gollem v0.1.0/go.mod h1:htW1YO81uysSKVOkYJtxhGCFrzm+36HBFxEWuECoHKQ= diff --git a/cookbook/gollem_go_agent_framework/proxy_config.yaml b/cookbook/gollem_go_agent_framework/proxy_config.yaml new file mode 100644 index 0000000000..18265a002b --- /dev/null +++ b/cookbook/gollem_go_agent_framework/proxy_config.yaml @@ -0,0 +1,16 @@ +model_list: + - model_name: gpt-4o + litellm_params: + model: openai/gpt-4o + api_key: os.environ/OPENAI_API_KEY + + - model_name: claude-sonnet + litellm_params: + model: anthropic/claude-sonnet-4-20250514 + api_key: os.environ/ANTHROPIC_API_KEY + + - model_name: gemini-pro + litellm_params: + model: vertex_ai/gemini-2.0-flash + vertex_project: my-project + vertex_location: us-central1 diff --git a/cookbook/gollem_go_agent_framework/streaming/main.go b/cookbook/gollem_go_agent_framework/streaming/main.go new file mode 100644 index 0000000000..42bc9bbe34 --- /dev/null +++ b/cookbook/gollem_go_agent_framework/streaming/main.go @@ -0,0 +1,56 @@ +// Streaming responses from gollem through LiteLLM. +// +// Uses Go 1.23+ range-over-function iterators for real-time token +// streaming via LiteLLM's SSE passthrough. +// +// Usage: +// +// litellm --model gpt-4o +// go run ./streaming +package main + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/fugue-labs/gollem/core" + "github.com/fugue-labs/gollem/provider/openai" +) + +func main() { + proxyURL := "http://localhost:4000" + if u := os.Getenv("LITELLM_PROXY_URL"); u != "" { + proxyURL = u + } + + model := openai.NewLiteLLM(proxyURL, + openai.WithModel("gpt-4o"), + ) + + agent := core.NewAgent[string](model) + + // RunStream returns a streaming result that yields tokens as they arrive. + stream, err := agent.RunStream(context.Background(), "Write a haiku about distributed systems") + if err != nil { + log.Fatal(err) + } + + // StreamText yields text chunks in real-time. + // The boolean argument controls whether deltas (true) or accumulated + // text (false) is returned. + fmt.Print("Response: ") + for text, err := range stream.StreamText(true) { + if err != nil { + log.Fatal(err) + } + fmt.Print(text) + } + fmt.Println() + + // After streaming completes, the final response is available. + resp := stream.Response() + fmt.Printf("\nTokens used: input=%d, output=%d\n", + resp.Usage.InputTokens, resp.Usage.OutputTokens) +} diff --git a/cookbook/gollem_go_agent_framework/tools/main.go b/cookbook/gollem_go_agent_framework/tools/main.go new file mode 100644 index 0000000000..ed41a95ffe --- /dev/null +++ b/cookbook/gollem_go_agent_framework/tools/main.go @@ -0,0 +1,64 @@ +// Gollem agent with type-safe tools through LiteLLM. +// +// The tool parameters are Go structs — gollem generates the JSON schema +// automatically at compile time. LiteLLM passes tool definitions through +// transparently to the underlying provider. +// +// Usage: +// +// litellm --model gpt-4o +// go run ./tools +package main + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/fugue-labs/gollem/core" + "github.com/fugue-labs/gollem/provider/openai" +) + +// WeatherParams defines the tool's input schema via struct tags. +// The JSON schema is generated at compile time — no runtime reflection needed. +type WeatherParams struct { + City string `json:"city" description:"City name to get weather for"` + Unit string `json:"unit,omitempty" description:"Temperature unit: celsius or fahrenheit"` +} + +func main() { + proxyURL := "http://localhost:4000" + if u := os.Getenv("LITELLM_PROXY_URL"); u != "" { + proxyURL = u + } + + model := openai.NewLiteLLM(proxyURL, + openai.WithModel("gpt-4o"), + ) + + // Define a type-safe tool. The function signature enforces correct types. + weatherTool := core.FuncTool[WeatherParams]( + "get_weather", + "Get current weather for a city", + func(ctx context.Context, p WeatherParams) (string, error) { + unit := p.Unit + if unit == "" { + unit = "fahrenheit" + } + // In production, call a real weather API here. + return fmt.Sprintf("Weather in %s: 72°F (22°C), sunny", p.City), nil + }, + ) + + agent := core.NewAgent[string](model, + core.WithTools[string](weatherTool), + core.WithSystemPrompt[string]("You are a helpful weather assistant. Use the get_weather tool to answer weather questions."), + ) + + result, err := agent.Run(context.Background(), "What's the weather like in San Francisco and Tokyo?") + if err != nil { + log.Fatal(err) + } + fmt.Println(result.Output) +} diff --git a/docs/my-website/blog/anthropic_wildcard_model_access_incident/index.md b/docs/my-website/blog/anthropic_wildcard_model_access_incident/index.md new file mode 100644 index 0000000000..f6172cd674 --- /dev/null +++ b/docs/my-website/blog/anthropic_wildcard_model_access_incident/index.md @@ -0,0 +1,147 @@ +--- +slug: anthropic-wildcard-model-access-incident +title: "Incident Report: Wildcard Blocking New Models After Cost Map Reload" +date: 2026-02-23T10:00:00 +authors: + - name: Sameer Kankute + title: SWE @ LiteLLM (LLM Translation) + url: https://www.linkedin.com/in/sameer-kankute/ + image_url: https://pbs.twimg.com/profile_images/2001352686994907136/ONgNuSk5_400x400.jpg + - name: Krrish Dholakia + title: "CEO, LiteLLM" + url: https://www.linkedin.com/in/krish-d/ + image_url: https://pbs.twimg.com/profile_images/1298587542745358340/DZv3Oj-h_400x400.jpg + - name: Ishaan Jaff + title: "CTO, LiteLLM" + url: https://www.linkedin.com/in/reffajnaahsi/ + image_url: https://pbs.twimg.com/profile_images/1613813310264340481/lz54oEiB_400x400.jpg +tags: [incident-report, proxy, auth, model-access] +hide_table_of_contents: false +--- + +**Date:** Feb 23, 2026 +**Duration:** ~3 hours +**Severity:** High (for users with provider wildcard access rules) +**Status:** Resolved + +## Summary + +When a new Anthropic model (e.g. `claude-sonnet-4-6`) was added to the LiteLLM model cost map and a cost map reload was triggered, requests to the new model were rejected with: + +``` +key not allowed to access model. This key can only access models=['anthropic/*']. Tried to access claude-sonnet-4-6. +``` + +The reload updated `litellm.model_cost` correctly but never re-ran `add_known_models()`, so `litellm.anthropic_models` (the in-memory set used by the wildcard resolver) remained stale. The new model was invisible to the `anthropic/*` wildcard even though the cost map knew about it. + +- **LLM calls:** All requests to newly-added Anthropic models were blocked with a 401. +- **Existing models:** Unaffected — only models missing from the stale provider set were impacted. +- **Other providers:** Same bug class existed for any provider wildcard (e.g. `openai/*`, `gemini/*`). + +{/* truncate */} + +--- + +## Background + +LiteLLM supports provider-level wildcard access rules. When an admin configures a key or team with `models=['anthropic/*']`, any model whose provider resolves to `anthropic` should be allowed. The resolution happens in `_model_custom_llm_provider_matches_wildcard_pattern`: + +```mermaid +flowchart TD + A["1. Request arrives for claude-sonnet-4-6"] --> B["2. Auth check: can this key call this model? + proxy/auth/auth_checks.py"] + B --> C["3. Key has models=['anthropic/*'] + → wildcard match attempted"] + C --> D["4. get_llm_provider('claude-sonnet-4-6') + checks litellm.anthropic_models set"] + D -->|"model IN set"| E["5a. ✅ Provider = 'anthropic' + → 'anthropic/claude-sonnet-4-6' matches 'anthropic/*'"] + D -->|"model NOT IN set"| F["5b. ❌ Provider unknown + → exception raised → wildcard returns False"] + E --> G["6. Request allowed"] + F --> H["6. 401: key not allowed to access model"] + + style E fill:#d4edda,stroke:#28a745 + style F fill:#f8d7da,stroke:#dc3545 + style H fill:#f8d7da,stroke:#dc3545 + style D fill:#fff3cd,stroke:#ffc107 +``` + +`litellm.anthropic_models` is a Python `set` populated at import time by `add_known_models()`. It is the source `get_llm_provider()` consults to map a bare model name like `claude-sonnet-4-6` to the provider string `"anthropic"`. + +--- + +## Root Cause + +`add_known_models()` is called **once** at module import time. Both reload paths in `proxy_server.py` updated `litellm.model_cost` with the fresh map but never called `add_known_models()` again: + +```python +# Before the fix — both reload paths looked like this: +new_model_cost_map = get_model_cost_map(url=model_cost_map_url) +litellm.model_cost = new_model_cost_map # ✅ cost map updated +_invalidate_model_cost_lowercase_map() # ✅ cache cleared +# ❌ add_known_models() never called +# → litellm.anthropic_models still has the old set +# → new model not in the set +# → get_llm_provider() raises for the new model +# → wildcard match returns False +# → 401 for every request to the new model +``` + +The gap existed in two places: +1. `_check_and_reload_model_cost_map` — the periodic automatic reload (every 10 s) +2. The `/reload/model_cost_map` admin endpoint — the manual reload + +**Timeline:** + +1. New model (`claude-sonnet-4-6`) added to `model_prices_and_context_window.json` +2. Admin triggers cost map reload via UI → `litellm.model_cost` updated +3. Users with `anthropic/*` wildcard keys attempt requests to `claude-sonnet-4-6` +4. `get_llm_provider('claude-sonnet-4-6')` raises → wildcard returns False → 401 +5. Admin reloads cost map again — same result (root cause not addressed) +6. ~3 hours of investigation → root cause identified → fix deployed + +--- + +## The Fix + +After each reload, `add_known_models()` is called with the freshly fetched map passed explicitly. Passing the map directly (rather than relying on the module-level reference) removes any ambiguity about which dict is iterated: + +```python +# After the fix — both reload paths now do: +new_model_cost_map = get_model_cost_map(url=model_cost_map_url) +litellm.model_cost = new_model_cost_map +_invalidate_model_cost_lowercase_map() +litellm.add_known_models(model_cost_map=new_model_cost_map) # ✅ sets repopulated +``` + +`add_known_models()` was also updated to accept an optional explicit map so callers cannot accidentally iterate a stale module-level reference: + +```python +# Before +def add_known_models(): + for key, value in model_cost.items(): # reads module global — ambiguous after reload + ... + +# After +def add_known_models(model_cost_map: Optional[Dict] = None): + _map = model_cost_map if model_cost_map is not None else model_cost + for key, value in _map.items(): # always iterates the map you just fetched + ... +``` + +After the fix, the provider sets (`anthropic_models`, `open_ai_chat_completion_models`, etc.) are always consistent with `litellm.model_cost` immediately after every reload. New models become accessible via wildcard rules without any proxy restart. + +--- + +## Remediation + +| # | Action | Status | Code | +|---|---|---|---| +| 1 | Call `add_known_models(model_cost_map=...)` in the periodic reload path | ✅ Done | [`proxy_server.py#L4393`](https://github.com/BerriAI/litellm/blob/main/litellm/proxy/proxy_server.py#L4393) | +| 2 | Call `add_known_models(model_cost_map=...)` in the `/reload/model_cost_map` endpoint | ✅ Done | [`proxy_server.py#L11904`](https://github.com/BerriAI/litellm/blob/main/litellm/proxy/proxy_server.py#L11904) | +| 3 | Update `add_known_models()` to accept an explicit map parameter | ✅ Done | [`__init__.py#L617`](https://github.com/BerriAI/litellm/blob/main/litellm/__init__.py#L617) | +| 4 | Regression test: `add_known_models(model_cost_map=...)` populates provider sets | ✅ Done | [`test_auth_checks.py`](https://github.com/BerriAI/litellm/blob/main/tests/proxy_unit_tests/test_auth_checks.py) | +| 5 | Regression test: `anthropic/*` wildcard grants/denies access correctly after reload | ✅ Done | [`test_auth_checks.py`](https://github.com/BerriAI/litellm/blob/main/tests/proxy_unit_tests/test_auth_checks.py) | + +--- diff --git a/docs/my-website/docs/benchmarks.md b/docs/my-website/docs/benchmarks.md index 1f818cef49..5ed2263d05 100644 --- a/docs/my-website/docs/benchmarks.md +++ b/docs/my-website/docs/benchmarks.md @@ -5,6 +5,44 @@ import Image from '@theme/IdealImage'; Benchmarks for LiteLLM Gateway (Proxy Server) tested against a fake OpenAI endpoint. +## Setting Up Benchmarking with Network Mock + +The fastest way to benchmark proxy overhead is using `network_mock` mode. This intercepts outbound requests at the httpx transport layer and returns canned responses, no need for setting up a mock provider. + +**1. Create a proxy config:** + +```yaml +model_list: + - model_name: db-openai-endpoint + litellm_params: + model: openai/gpt-4o + api_key: "sk-fake-key" + api_base: "https://api.openai.com" + +litellm_settings: + network_mock: true + callbacks: [] + num_retries: 0 + request_timeout: 30 + +general_settings: + master_key: "sk-1234" +``` + +**2. Start the proxy:** + +```bash +litellm --config benchmark_config.yaml --port 4000 --num_workers 8 +``` + +**3. Run the benchmark script:** + +```bash +python scripts/benchmark_mock.py --requests 2000 --max-concurrent 200 --runs 3 +``` + +This measures pure proxy overhead on the hot path without any network latency to a real or fake provider. + ## Setting Up a Fake OpenAI Endpoint For load testing and benchmarking, you can use a fake OpenAI proxy server. LiteLLM provides: diff --git a/docs/my-website/docs/caching/all_caches.md b/docs/my-website/docs/caching/all_caches.md index 37fb8bc360..6f81da9105 100644 --- a/docs/my-website/docs/caching/all_caches.md +++ b/docs/my-website/docs/caching/all_caches.md @@ -297,6 +297,7 @@ litellm.cache = Cache( similarity_threshold=0.7, # similarity threshold for cache hits, 0 == no similarity, 1 = exact matches, 0.5 == 50% similarity qdrant_quantization_config ="binary", # can be one of 'binary', 'product' or 'scalar' quantizations that is supported by qdrant qdrant_semantic_cache_embedding_model="text-embedding-ada-002", # this model is passed to litellm.embedding(), any litellm.embedding() model is supported here + qdrant_semantic_cache_vector_size=1536, # vector size for the embedding model, must match the dimensionality of the embedding model used ) response1 = completion( @@ -635,6 +636,7 @@ def __init__( qdrant_quantization_config: Optional[str] = None, qdrant_semantic_cache_embedding_model="text-embedding-ada-002", + qdrant_semantic_cache_vector_size: Optional[int] = None, **kwargs ): ``` diff --git a/docs/my-website/docs/contributing.md b/docs/my-website/docs/contributing.md index be7222f6cb..168d092ddc 100644 --- a/docs/my-website/docs/contributing.md +++ b/docs/my-website/docs/contributing.md @@ -79,7 +79,27 @@ cp -r out/* ../../litellm/proxy/_experimental/out/ Then restart the proxy and access the UI at `http://localhost:4000/ui` -## 4. Submitting a PR +## 4. Pre-PR Checklist + +Before submitting your pull request, make sure the following pass locally from `ui/litellm-dashboard/`: + +**Run tests related to your changes:** + +```bash +npx vitest run src/components/path/to/YourComponent.test.tsx +``` + +Tests are co-located with components (e.g., `TeamInfo.tsx` → `TeamInfo.test.tsx`). If you add a new component, add a corresponding `.test.tsx` file next to it. + +**Run the build:** + +```bash +npm run build +``` + +These map to the `ui_tests` and `ui_build` CI checks. + +## 5. Submitting a PR 1. Create a new branch for your changes: ```bash diff --git a/docs/my-website/docs/enterprise.md b/docs/my-website/docs/enterprise.md index 0a1b47f062..6dccf7ff4e 100644 --- a/docs/my-website/docs/enterprise.md +++ b/docs/my-website/docs/enterprise.md @@ -4,7 +4,7 @@ import Image from '@theme/IdealImage'; :::info - ✨ SSO is free for up to 5 users. After that, an enterprise license is required. [Get Started with Enterprise here](https://www.litellm.ai/enterprise) -- Who is Enterprise for? Companies giving access to 100+ users **OR** 10+ AI use-cases. If you're not sure, [get in touch with us](https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat) to discuss your needs. +- Who is Enterprise for? Companies giving access to 100+ users **OR** 10+ AI use-cases. If you're not sure, [get in touch with us](https://calendly.com/d/cx9p-5yf-2nm/litellm-introductions) to discuss your needs. ::: For companies that need SSO, user management and professional support for LiteLLM Proxy @@ -36,7 +36,7 @@ Manage Yourself - you can deploy our Docker Image or build a custom image from o ### What’s the cost of the Self-Managed Enterprise edition? -Self-Managed Enterprise deployments require our team to understand your exact needs. [Get in touch with us to learn more](https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat) +Self-Managed Enterprise deployments require our team to understand your exact needs. [Get in touch with us to learn more](https://calendly.com/d/cx9p-5yf-2nm/litellm-introductions) ### How does deployment with Enterprise License work? @@ -106,7 +106,7 @@ Professional Support can assist with LLM/Provider integrations, deployment, upgr Pricing is based on usage. We can figure out a price that works for your team, on the call. -[**Contact Us to learn more**](https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat) +[**Contact Us to learn more**](https://calendly.com/d/cx9p-5yf-2nm/litellm-introductions) diff --git a/docs/my-website/docs/fine_tuning.md b/docs/my-website/docs/fine_tuning.md index 2779a478f8..d0bd98a76f 100644 --- a/docs/my-website/docs/fine_tuning.md +++ b/docs/my-website/docs/fine_tuning.md @@ -6,7 +6,7 @@ import TabItem from '@theme/TabItem'; :::info -This is an Enterprise only endpoint [Get Started with Enterprise here](https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat) +This is an Enterprise only endpoint [Get Started with Enterprise here](https://calendly.com/d/cx9p-5yf-2nm/litellm-introductions) ::: diff --git a/docs/my-website/docs/interactions.md b/docs/my-website/docs/interactions.md index 32c82a1589..8014bf0536 100644 --- a/docs/my-website/docs/interactions.md +++ b/docs/my-website/docs/interactions.md @@ -130,13 +130,12 @@ Point the Google GenAI SDK to LiteLLM Proxy: ```python showLineNumbers title="Google GenAI SDK with LiteLLM Proxy" from google import genai -import os # Point SDK to LiteLLM Proxy -os.environ["GOOGLE_GENAI_BASE_URL"] = "http://localhost:4000" -os.environ["GEMINI_API_KEY"] = "sk-1234" # Your LiteLLM API key - -client = genai.Client() +client = genai.Client( + api_key="sk-1234", # Your LiteLLM API key + http_options={"base_url": "http://localhost:4000"}, +) # Create an interaction interaction = client.interactions.create( @@ -151,12 +150,11 @@ print(interaction.outputs[-1].text) ```python showLineNumbers title="Google GenAI SDK Streaming" from google import genai -import os -os.environ["GOOGLE_GENAI_BASE_URL"] = "http://localhost:4000" -os.environ["GEMINI_API_KEY"] = "sk-1234" - -client = genai.Client() +client = genai.Client( + api_key="sk-1234", # Your LiteLLM API key + http_options={"base_url": "http://localhost:4000"}, +) for chunk in client.interactions.create_stream( model="gemini/gemini-2.5-flash", diff --git a/docs/my-website/docs/observability/gcs_bucket_integration.md b/docs/my-website/docs/observability/gcs_bucket_integration.md index 4050970808..69b956950e 100644 --- a/docs/my-website/docs/observability/gcs_bucket_integration.md +++ b/docs/my-website/docs/observability/gcs_bucket_integration.md @@ -6,7 +6,7 @@ Log LLM Logs to [Google Cloud Storage Buckets](https://cloud.google.com/storage? :::info -✨ This is an Enterprise only feature [Get Started with Enterprise here](https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat) +✨ This is an Enterprise only feature [Get Started with Enterprise here](https://calendly.com/d/cx9p-5yf-2nm/litellm-introductions) ::: diff --git a/docs/my-website/docs/pass_through/google_ai_studio.md b/docs/my-website/docs/pass_through/google_ai_studio.md index 3de7c54aa7..d87c17fa7e 100644 --- a/docs/my-website/docs/pass_through/google_ai_studio.md +++ b/docs/my-website/docs/pass_through/google_ai_studio.md @@ -35,26 +35,25 @@ curl 'http://0.0.0.0:4000/gemini/v1beta/models/gemini-1.5-flash:countTokens?key= ``` - + ```javascript -const { GoogleGenerativeAI } = require("@google/generative-ai"); +const { GoogleGenAI } = require("@google/genai"); -const modelParams = { - model: 'gemini-pro', -}; - -const requestOptions = { - baseUrl: 'http://localhost:4000/gemini', // http:///gemini -}; - -const genAI = new GoogleGenerativeAI("sk-1234"); // litellm proxy API key -const model = genAI.getGenerativeModel(modelParams, requestOptions); +const ai = new GoogleGenAI({ + apiKey: "sk-1234", // litellm proxy API key + httpOptions: { + baseUrl: "http://localhost:4000/gemini", // http:///gemini + }, +}); async function main() { try { - const result = await model.generateContent("Explain how AI works"); - console.log(result.response.text()); + const response = await ai.models.generateContent({ + model: "gemini-2.5-flash", + contents: "Explain how AI works", + }); + console.log(response.text); } catch (error) { console.error('Error:', error); } @@ -63,12 +62,13 @@ async function main() { // For streaming responses async function main_streaming() { try { - const streamingResult = await model.generateContentStream("Explain how AI works"); - for await (const chunk of streamingResult.stream) { - console.log('Stream chunk:', JSON.stringify(chunk)); + const response = await ai.models.generateContentStream({ + model: "gemini-2.5-flash", + contents: "Explain how AI works", + }); + for await (const chunk of response) { + process.stdout.write(chunk.text); } - const aggregatedResponse = await streamingResult.response; - console.log('Aggregated response:', JSON.stringify(aggregatedResponse)); } catch (error) { console.error('Error:', error); } @@ -321,29 +321,28 @@ curl 'http://0.0.0.0:4000/gemini/v1beta/models/gemini-1.5-flash:generateContent? ``` - + ```javascript -const { GoogleGenerativeAI } = require("@google/generative-ai"); +const { GoogleGenAI } = require("@google/genai"); -const modelParams = { - model: 'gemini-pro', -}; - -const requestOptions = { - baseUrl: 'http://localhost:4000/gemini', // http:///gemini - customHeaders: { - "tags": "gemini-js-sdk,pass-through-endpoint" - } -}; - -const genAI = new GoogleGenerativeAI("sk-1234"); -const model = genAI.getGenerativeModel(modelParams, requestOptions); +const ai = new GoogleGenAI({ + apiKey: "sk-1234", + httpOptions: { + baseUrl: "http://localhost:4000/gemini", // http:///gemini + headers: { + "tags": "gemini-js-sdk,pass-through-endpoint", + }, + }, +}); async function main() { try { - const result = await model.generateContent("Explain how AI works"); - console.log(result.response.text()); + const response = await ai.models.generateContent({ + model: "gemini-2.5-flash", + contents: "Explain how AI works", + }); + console.log(response.text); } catch (error) { console.error('Error:', error); } diff --git a/docs/my-website/docs/proxy/alerting.md b/docs/my-website/docs/proxy/alerting.md index 38d6d47be4..e9afe2d993 100644 --- a/docs/my-website/docs/proxy/alerting.md +++ b/docs/my-website/docs/proxy/alerting.md @@ -438,6 +438,59 @@ curl -X GET --location 'http://0.0.0.0:4000/health/services?service=webhook' \ - `event_message` *str*: A human-readable description of the event. +### Digest Mode (Reducing Alert Noise) + +By default, LiteLLM sends a separate Slack message for **every** alert event. For high-frequency alert types like `llm_requests_hanging` or `llm_too_slow`, this can produce hundreds of duplicate messages per day. + +**Digest mode** aggregates duplicate alerts within a configurable time window and emits a single summary message with the total count and time range. + +#### Configuration + +Use `alert_type_config` in `general_settings` to enable digest mode per alert type: + +```yaml +general_settings: + alerting: ["slack"] + alert_type_config: + llm_requests_hanging: + digest: true + digest_interval: 86400 # 24 hours (default) + llm_too_slow: + digest: true + digest_interval: 3600 # 1 hour + llm_exceptions: + digest: true + # uses default interval (86400 seconds / 24 hours) +``` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `digest` | bool | `false` | Enable digest mode for this alert type | +| `digest_interval` | int | `86400` (24h) | Time window in seconds. Alerts are aggregated within this interval. | + +#### How It Works + +1. When an alert fires for a digest-enabled type, it is **grouped** by `(alert_type, request_model, api_base)` instead of being sent immediately +2. A counter tracks how many times the alert fires within the interval +3. When the interval expires, a **single summary message** is sent: + +``` +Alert type: `llm_requests_hanging` (Digest) +Level: `Medium` +Start: `2026-02-19 03:27:39` +End: `2026-02-20 03:27:39` +Count: `847` + +Message: `Requests are hanging - 600s+ request time` +Request Model: `gemini-2.5-flash` +API Base: `None` +``` + +#### Limitations + +- **Per-instance**: Digest state is held in memory per proxy instance. If you run multiple instances (e.g., Cloud Run with autoscaling), each instance maintains its own digest and emits its own summary. +- **Not durable**: If an instance is terminated before the digest interval expires, the aggregated alerts for that instance are lost. + ## Region-outage alerting (✨ Enterprise feature) :::info diff --git a/docs/my-website/docs/proxy/budget_reset_and_tz.md b/docs/my-website/docs/proxy/budget_reset_and_tz.md index 340e33afe1..0fedff8be1 100644 --- a/docs/my-website/docs/proxy/budget_reset_and_tz.md +++ b/docs/my-website/docs/proxy/budget_reset_and_tz.md @@ -22,6 +22,8 @@ litellm_settings: This ensures that all budget resets happen at midnight in your specified timezone rather than in UTC. If no timezone is specified, UTC will be used by default. +Any valid [IANA timezone string](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) is supported (powered by Python's `zoneinfo` module). DST transitions are handled automatically. + Common timezone values: - `UTC` - Coordinated Universal Time diff --git a/docs/my-website/docs/proxy/caching.md b/docs/my-website/docs/proxy/caching.md index 3cb9e9f3fe..3357dcb28b 100644 --- a/docs/my-website/docs/proxy/caching.md +++ b/docs/my-website/docs/proxy/caching.md @@ -340,6 +340,7 @@ litellm_settings: qdrant_semantic_cache_embedding_model: openai-embedding # the model should be defined on the model_list qdrant_collection_name: test_collection qdrant_quantization_config: binary + qdrant_semantic_cache_vector_size: 1536 # vector size must match embedding model dimensionality similarity_threshold: 0.8 # similarity threshold for semantic cache ``` diff --git a/docs/my-website/docs/proxy/config_settings.md b/docs/my-website/docs/proxy/config_settings.md index 809478bb6d..2a85fc71b8 100644 --- a/docs/my-website/docs/proxy/config_settings.md +++ b/docs/my-website/docs/proxy/config_settings.md @@ -73,6 +73,7 @@ litellm_settings: qdrant_semantic_cache_embedding_model: openai-embedding # the model should be defined on the model_list qdrant_collection_name: test_collection qdrant_quantization_config: binary + qdrant_semantic_cache_vector_size: 1536 # vector size must match embedding model dimensionality similarity_threshold: 0.8 # similarity threshold for semantic cache # Optional - S3 Cache Settings diff --git a/docs/my-website/docs/proxy/cost_tracking.md b/docs/my-website/docs/proxy/cost_tracking.md index 26a4920c09..baebdf0f31 100644 --- a/docs/my-website/docs/proxy/cost_tracking.md +++ b/docs/my-website/docs/proxy/cost_tracking.md @@ -161,7 +161,7 @@ Use this when you want non-proxy admins to access `/spend` endpoints :::info -Schedule a [meeting with us to get your Enterprise License](https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat) +Schedule a [meeting with us to get your Enterprise License](https://calendly.com/d/cx9p-5yf-2nm/litellm-introductions) ::: diff --git a/docs/my-website/docs/proxy/email.md b/docs/my-website/docs/proxy/email.md index ad158cb342..86a79cbcfc 100644 --- a/docs/my-website/docs/proxy/email.md +++ b/docs/my-website/docs/proxy/email.md @@ -203,7 +203,7 @@ After regenerating the key, the user will receive an email notification with: :::info -Customizing Email Branding is an Enterprise Feature [Get in touch with us for a Free Trial](https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat) +Customizing Email Branding is an Enterprise Feature [Get in touch with us for a Free Trial](https://calendly.com/d/cx9p-5yf-2nm/litellm-introductions) ::: diff --git a/docs/my-website/docs/proxy/enterprise.md b/docs/my-website/docs/proxy/enterprise.md index 26d2587320..4b525837a2 100644 --- a/docs/my-website/docs/proxy/enterprise.md +++ b/docs/my-website/docs/proxy/enterprise.md @@ -5,7 +5,7 @@ import TabItem from '@theme/TabItem'; # ✨ Enterprise Features :::tip -To get a license, get in touch with us [here](https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat) +To get a license, get in touch with us [here](https://calendly.com/d/cx9p-5yf-2nm/litellm-introductions) ::: diff --git a/docs/my-website/docs/proxy/guardrails/aporia_api.md b/docs/my-website/docs/proxy/guardrails/aporia_api.md index 8c5c1ec194..ceafc19a1c 100644 --- a/docs/my-website/docs/proxy/guardrails/aporia_api.md +++ b/docs/my-website/docs/proxy/guardrails/aporia_api.md @@ -139,7 +139,7 @@ curl -i http://localhost:4000/v1/chat/completions \ :::info -✨ This is an Enterprise only feature [Contact us to get a free trial](https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat) +✨ This is an Enterprise only feature [Contact us to get a free trial](https://calendly.com/d/cx9p-5yf-2nm/litellm-introductions) ::: diff --git a/docs/my-website/docs/proxy/guardrails/custom_guardrail.md b/docs/my-website/docs/proxy/guardrails/custom_guardrail.md index 365fdf81aa..c9115cf826 100644 --- a/docs/my-website/docs/proxy/guardrails/custom_guardrail.md +++ b/docs/my-website/docs/proxy/guardrails/custom_guardrail.md @@ -409,7 +409,7 @@ curl -i -X POST http://localhost:4000/v1/chat/completions \ :::info -✨ This is an Enterprise only feature [Contact us to get a free trial](https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat) +✨ This is an Enterprise only feature [Contact us to get a free trial](https://calendly.com/d/cx9p-5yf-2nm/litellm-introductions) ::: diff --git a/docs/my-website/docs/proxy/guardrails/guardrails_ai.md b/docs/my-website/docs/proxy/guardrails/guardrails_ai.md index ddeccaf16d..55d586aee7 100644 --- a/docs/my-website/docs/proxy/guardrails/guardrails_ai.md +++ b/docs/my-website/docs/proxy/guardrails/guardrails_ai.md @@ -59,7 +59,7 @@ curl -i http://localhost:4000/v1/chat/completions \ :::info -✨ This is an Enterprise only feature [Contact us to get a free trial](https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat) +✨ This is an Enterprise only feature [Contact us to get a free trial](https://calendly.com/d/cx9p-5yf-2nm/litellm-introductions) ::: diff --git a/docs/my-website/docs/proxy/guardrails/noma_security.md b/docs/my-website/docs/proxy/guardrails/noma_security.md index a66788cbb5..a397efeb14 100644 --- a/docs/my-website/docs/proxy/guardrails/noma_security.md +++ b/docs/my-website/docs/proxy/guardrails/noma_security.md @@ -6,6 +6,108 @@ import TabItem from '@theme/TabItem'; Use [Noma Security](https://noma.security/) to protect your LLM applications with comprehensive AI content moderation and safety guardrails. +:::warning Deprecated: `guardrail: noma` (Legacy) +`guardrail: noma` is deprecated and users should migrate to `guardrail: noma_v2`. +The legacy `guardrail: noma` API will no longer be supported after March 31, 2026. + +For easier migration of existing integrations, keep `guardrail: noma` and set `use_v2: true`. +With `use_v2: true`, requests route to `noma_v2`; `monitor_mode` and `block_failures` still apply, while `anonymize_input` is ignored. +::: + +## Noma v2 guardrails (Recommended) + +### Quick Start + +```yaml showLineNumbers title="litellm config.yaml" +guardrails: + - guardrail_name: "noma-v2-guard" + litellm_params: + guardrail: noma_v2 + mode: "pre_call" + api_key: os.environ/NOMA_API_KEY + api_base: os.environ/NOMA_API_BASE +``` + +If you want to migrate gradually without changing guardrail names yet: + +```yaml showLineNumbers title="litellm config.yaml" +guardrails: + - guardrail_name: "noma-guard" + litellm_params: + guardrail: noma + use_v2: true + mode: "pre_call" + api_key: os.environ/NOMA_API_KEY + api_base: os.environ/NOMA_API_BASE +``` + +### Supported Params + +- **`guardrail`**: Use `noma_v2` (recommended), or `noma` with `use_v2: true` for migration +- **`mode`**: `pre_call`, `post_call`, `during_call`, `pre_mcp_call`, `during_mcp_call` +- **`api_key`**: Noma API key (required for Noma SaaS, optional for self-managed deployments) +- **`api_base`**: Noma API base URL (defaults to `https://api.noma.security/`) +- **`application_id`**: Application identifier. If omitted, v2 checks dynamic `extra_body.application_id`, then configured/env `application_id`; otherwise it is omitted. +- **`monitor_mode`**: If `true`, runs in monitor-only mode without blocking (defaults to `false`) +- **`block_failures`**: If `true`, fail-closed on guardrail technical failures (defaults to `true`) +- **`use_v2`**: Migration toggle when `guardrail: noma` is used + +### Environment Variables + +```shell +export NOMA_API_KEY="your-api-key-here" +export NOMA_API_BASE="https://api.noma.security/" # Optional +export NOMA_APPLICATION_ID="my-app" # Optional +export NOMA_MONITOR_MODE="false" # Optional +export NOMA_BLOCK_FAILURES="true" # Optional +``` + +### Multiple Guardrails + +Apply different v2 configurations for input and output: + +```yaml showLineNumbers title="litellm config.yaml" +guardrails: + - guardrail_name: "noma-v2-input" + litellm_params: + guardrail: noma_v2 + mode: "pre_call" + api_key: os.environ/NOMA_API_KEY + + - guardrail_name: "noma-v2-output" + litellm_params: + guardrail: noma_v2 + mode: "post_call" + api_key: os.environ/NOMA_API_KEY +``` + +### Pass Additional Parameters + +This is supported in v2 via `extra_body`. +Currently, `noma_v2` consumes dynamic `application_id`. + +```shell showLineNumbers title="Curl Request" +curl 'http://0.0.0.0:4000/v1/chat/completions' \ + -H 'Content-Type: application/json' \ + -d '{ + "model": "gpt-4o-mini", + "messages": [ + { + "role": "user", + "content": "Hello, how are you?" + } + ], + "guardrails": { + "noma-v2-guard": { + "extra_body": { + "application_id": "my-specific-app-id" + } + } + } + }' +``` +## Noma guardrails (Legacy) + ## Quick Start ### 1. Define Guardrails on your LiteLLM config.yaml diff --git a/docs/my-website/docs/proxy/ip_address.md b/docs/my-website/docs/proxy/ip_address.md index 80d5561da4..8f042d9f18 100644 --- a/docs/my-website/docs/proxy/ip_address.md +++ b/docs/my-website/docs/proxy/ip_address.md @@ -3,7 +3,7 @@ :::info -You need a LiteLLM License to unlock this feature. [Grab time](https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat), to get one today! +You need a LiteLLM License to unlock this feature. [Grab time](https://calendly.com/d/cx9p-5yf-2nm/litellm-introductions), to get one today! ::: diff --git a/docs/my-website/docs/proxy/logging.md b/docs/my-website/docs/proxy/logging.md index 1abb127dfd..74a79776fb 100644 --- a/docs/my-website/docs/proxy/logging.md +++ b/docs/my-website/docs/proxy/logging.md @@ -1109,7 +1109,7 @@ Log LLM Logs to [Google Cloud Storage Buckets](https://cloud.google.com/storage? :::info -✨ This is an Enterprise only feature [Get Started with Enterprise here](https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat) +✨ This is an Enterprise only feature [Get Started with Enterprise here](https://calendly.com/d/cx9p-5yf-2nm/litellm-introductions) ::: @@ -1194,7 +1194,7 @@ Log LLM Logs/SpendLogs to [Google Cloud Storage PubSub Topic](https://cloud.goog :::info -✨ This is an Enterprise only feature [Get Started with Enterprise here](https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat) +✨ This is an Enterprise only feature [Get Started with Enterprise here](https://calendly.com/d/cx9p-5yf-2nm/litellm-introductions) ::: @@ -1497,7 +1497,7 @@ Log LLM Logs to [Azure Data Lake Storage](https://learn.microsoft.com/en-us/azur :::info -✨ This is an Enterprise only feature [Get Started with Enterprise here](https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat) +✨ This is an Enterprise only feature [Get Started with Enterprise here](https://calendly.com/d/cx9p-5yf-2nm/litellm-introductions) ::: diff --git a/docs/my-website/docs/proxy/multiple_admins.md b/docs/my-website/docs/proxy/multiple_admins.md index cf122f85b9..8d39674df1 100644 --- a/docs/my-website/docs/proxy/multiple_admins.md +++ b/docs/my-website/docs/proxy/multiple_admins.md @@ -20,7 +20,7 @@ LiteLLM tracks changes to the following entities and actions: :::tip -Requires Enterprise License, Get in touch with us [here](https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat) +Requires Enterprise License, Get in touch with us [here](https://calendly.com/d/cx9p-5yf-2nm/litellm-introductions) ::: diff --git a/docs/my-website/docs/proxy/oauth2.md b/docs/my-website/docs/proxy/oauth2.md index ec076d8fae..41c4110e44 100644 --- a/docs/my-website/docs/proxy/oauth2.md +++ b/docs/my-website/docs/proxy/oauth2.md @@ -4,7 +4,7 @@ Use this if you want to use an Oauth2.0 token to make `/chat`, `/embeddings` req :::info -This is an Enterprise Feature - [get in touch with us if you want a free trial to test if this feature meets your needs]((https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat)) +This is an Enterprise Feature - [get in touch with us if you want a free trial to test if this feature meets your needs]((https://calendly.com/d/cx9p-5yf-2nm/litellm-introductions)) ::: diff --git a/docs/my-website/docs/proxy/prod.md b/docs/my-website/docs/proxy/prod.md index 994788a3ad..26cb484cbe 100644 --- a/docs/my-website/docs/proxy/prod.md +++ b/docs/my-website/docs/proxy/prod.md @@ -47,7 +47,7 @@ export LITELLM_LOG="ERROR" :::info -Need Help or want dedicated support ? Talk to a founder [here]: (https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat) +Need Help or want dedicated support ? Talk to a founder [here]: (https://calendly.com/d/cx9p-5yf-2nm/litellm-introductions) ::: diff --git a/docs/my-website/docs/proxy/public_routes.md b/docs/my-website/docs/proxy/public_routes.md index 21a92a00be..d5f3941751 100644 --- a/docs/my-website/docs/proxy/public_routes.md +++ b/docs/my-website/docs/proxy/public_routes.md @@ -5,7 +5,7 @@ import TabItem from '@theme/TabItem'; :::info -Requires a LiteLLM Enterprise License. [Get a free trial](https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat). +Requires a LiteLLM Enterprise License. [Get a free trial](https://calendly.com/d/cx9p-5yf-2nm/litellm-introductions). ::: diff --git a/docs/my-website/docs/proxy/tag_routing.md b/docs/my-website/docs/proxy/tag_routing.md index 838b2a09d7..399c43d2c0 100644 --- a/docs/my-website/docs/proxy/tag_routing.md +++ b/docs/my-website/docs/proxy/tag_routing.md @@ -215,7 +215,7 @@ LiteLLM Proxy supports team-based tag routing, allowing you to associate specifi :::info -This is an enterprise feature, [Contact us here to get a free trial](https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat) +This is an enterprise feature, [Contact us here to get a free trial](https://calendly.com/d/cx9p-5yf-2nm/litellm-introductions) ::: diff --git a/docs/my-website/docs/proxy/team_logging.md b/docs/my-website/docs/proxy/team_logging.md index bb35839bb2..2ad7e2a4a8 100644 --- a/docs/my-website/docs/proxy/team_logging.md +++ b/docs/my-website/docs/proxy/team_logging.md @@ -26,7 +26,7 @@ Team 3 -> Disabled Logging (for GDPR compliance) :::info -✨ This is an Enterprise only feature [Get Started with Enterprise here](https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat) +✨ This is an Enterprise only feature [Get Started with Enterprise here](https://calendly.com/d/cx9p-5yf-2nm/litellm-introductions) ::: @@ -248,7 +248,7 @@ Use the `/key/generate` or `/key/update` endpoints to add logging callbacks to a :::info -✨ This is an Enterprise only feature [Get Started with Enterprise here](https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat) +✨ This is an Enterprise only feature [Get Started with Enterprise here](https://calendly.com/d/cx9p-5yf-2nm/litellm-introductions) ::: diff --git a/docs/my-website/docs/proxy/team_model_add.md b/docs/my-website/docs/proxy/team_model_add.md index a8a6878fd5..7db59a3300 100644 --- a/docs/my-website/docs/proxy/team_model_add.md +++ b/docs/my-website/docs/proxy/team_model_add.md @@ -5,7 +5,7 @@ This is an Enterprise feature. [Enterprise Pricing](https://www.litellm.ai/#pricing) -[Contact us here to get a free trial](https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat) +[Contact us here to get a free trial](https://calendly.com/d/cx9p-5yf-2nm/litellm-introductions) ::: diff --git a/docs/my-website/docs/proxy/token_auth.md b/docs/my-website/docs/proxy/token_auth.md index 78cd144d56..e8634f0faf 100644 --- a/docs/my-website/docs/proxy/token_auth.md +++ b/docs/my-website/docs/proxy/token_auth.md @@ -11,7 +11,7 @@ Use JWT's to auth admins / users / projects into the proxy. [Enterprise Pricing](https://www.litellm.ai/#pricing) -[Contact us here to get a free trial](https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat) +[Contact us here to get a free trial](https://calendly.com/d/cx9p-5yf-2nm/litellm-introductions) ::: diff --git a/docs/my-website/docs/secret.md b/docs/my-website/docs/secret.md index 21eb639581..c5c8031147 100644 --- a/docs/my-website/docs/secret.md +++ b/docs/my-website/docs/secret.md @@ -6,7 +6,7 @@ [Enterprise Pricing](https://www.litellm.ai/#pricing) -[Contact us here to get a free trial](https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat) +[Contact us here to get a free trial](https://calendly.com/d/cx9p-5yf-2nm/litellm-introductions) ::: diff --git a/docs/my-website/docs/secret_managers/aws_kms.md b/docs/my-website/docs/secret_managers/aws_kms.md index 79dc80897f..7f69d91fe8 100644 --- a/docs/my-website/docs/secret_managers/aws_kms.md +++ b/docs/my-website/docs/secret_managers/aws_kms.md @@ -6,7 +6,7 @@ [Enterprise Pricing](https://www.litellm.ai/#pricing) -[Contact us here to get a free trial](https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat) +[Contact us here to get a free trial](https://calendly.com/d/cx9p-5yf-2nm/litellm-introductions) ::: diff --git a/docs/my-website/docs/secret_managers/aws_secret_manager.md b/docs/my-website/docs/secret_managers/aws_secret_manager.md index 5b7ab1e3e7..c49797a15d 100644 --- a/docs/my-website/docs/secret_managers/aws_secret_manager.md +++ b/docs/my-website/docs/secret_managers/aws_secret_manager.md @@ -9,7 +9,7 @@ import TabItem from '@theme/TabItem'; [Enterprise Pricing](https://www.litellm.ai/#pricing) -[Contact us here to get a free trial](https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat) +[Contact us here to get a free trial](https://calendly.com/d/cx9p-5yf-2nm/litellm-introductions) ::: diff --git a/docs/my-website/docs/secret_managers/azure_key_vault.md b/docs/my-website/docs/secret_managers/azure_key_vault.md index 6ec95b378b..81aeaa3215 100644 --- a/docs/my-website/docs/secret_managers/azure_key_vault.md +++ b/docs/my-website/docs/secret_managers/azure_key_vault.md @@ -6,7 +6,7 @@ [Enterprise Pricing](https://www.litellm.ai/#pricing) -[Contact us here to get a free trial](https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat) +[Contact us here to get a free trial](https://calendly.com/d/cx9p-5yf-2nm/litellm-introductions) ::: diff --git a/docs/my-website/docs/secret_managers/cyberark.md b/docs/my-website/docs/secret_managers/cyberark.md index c33aa28670..0a17c0afc3 100644 --- a/docs/my-website/docs/secret_managers/cyberark.md +++ b/docs/my-website/docs/secret_managers/cyberark.md @@ -8,7 +8,7 @@ import Image from '@theme/IdealImage'; [Enterprise Pricing](https://www.litellm.ai/#pricing) -[Contact us here to get a free trial](https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat) +[Contact us here to get a free trial](https://calendly.com/d/cx9p-5yf-2nm/litellm-introductions) ::: diff --git a/docs/my-website/docs/secret_managers/google_kms.md b/docs/my-website/docs/secret_managers/google_kms.md index 0c6f66846f..31fd6195bd 100644 --- a/docs/my-website/docs/secret_managers/google_kms.md +++ b/docs/my-website/docs/secret_managers/google_kms.md @@ -6,7 +6,7 @@ [Enterprise Pricing](https://www.litellm.ai/#pricing) -[Contact us here to get a free trial](https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat) +[Contact us here to get a free trial](https://calendly.com/d/cx9p-5yf-2nm/litellm-introductions) ::: diff --git a/docs/my-website/docs/secret_managers/google_secret_manager.md b/docs/my-website/docs/secret_managers/google_secret_manager.md index a545e7a85b..81878b7e39 100644 --- a/docs/my-website/docs/secret_managers/google_secret_manager.md +++ b/docs/my-website/docs/secret_managers/google_secret_manager.md @@ -6,7 +6,7 @@ [Enterprise Pricing](https://www.litellm.ai/#pricing) -[Contact us here to get a free trial](https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat) +[Contact us here to get a free trial](https://calendly.com/d/cx9p-5yf-2nm/litellm-introductions) ::: diff --git a/docs/my-website/docs/secret_managers/hashicorp_vault.md b/docs/my-website/docs/secret_managers/hashicorp_vault.md index e9e0116f4f..52d9b55620 100644 --- a/docs/my-website/docs/secret_managers/hashicorp_vault.md +++ b/docs/my-website/docs/secret_managers/hashicorp_vault.md @@ -8,7 +8,7 @@ import Image from '@theme/IdealImage'; [Enterprise Pricing](https://www.litellm.ai/#pricing) -[Contact us here to get a free trial](https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat) +[Contact us here to get a free trial](https://calendly.com/d/cx9p-5yf-2nm/litellm-introductions) ::: diff --git a/docs/my-website/docs/secret_managers/overview.md b/docs/my-website/docs/secret_managers/overview.md index a987c72d76..bf7386ab89 100644 --- a/docs/my-website/docs/secret_managers/overview.md +++ b/docs/my-website/docs/secret_managers/overview.md @@ -8,7 +8,7 @@ import Image from '@theme/IdealImage'; [Enterprise Pricing](https://www.litellm.ai/#pricing) -[Contact us here to get a free trial](https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat) +[Contact us here to get a free trial](https://calendly.com/d/cx9p-5yf-2nm/litellm-introductions) ::: diff --git a/docs/my-website/docs/tutorials/compare_llms.md b/docs/my-website/docs/tutorials/compare_llms.md index d7fdf8d7d9..02877b4660 100644 --- a/docs/my-website/docs/tutorials/compare_llms.md +++ b/docs/my-website/docs/tutorials/compare_llms.md @@ -82,7 +82,7 @@ Benchmark Results for 'When will BerriAI IPO?': +-----------------+----------------------------------------------------------------------------------+---------------------------+------------+ ``` ## Support -**🤝 Schedule a 1-on-1 Session:** Book a [1-on-1 session](https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat) with Krrish and Ishaan, the founders, to discuss any issues, provide feedback, or explore how we can improve LiteLLM for you. +**🤝 Schedule a 1-on-1 Session:** Book a [1-on-1 session](https://calendly.com/d/cx9p-5yf-2nm/litellm-introductions) with Krrish and Ishaan, the founders, to discuss any issues, provide feedback, or explore how we can improve LiteLLM for you. $h1v0X?cPd;KA}d4RQj3!7^? zdL8y#UHDuk*F1D{F~WjWvyLP#T8oH$F{#~ld%xiaT%2mBs0%=Lfg#DMA&8H;a&fbbhmj6(z~^@zOs_pB_w$ufzBA6p<)+xsQ|tcCQf%U|@5`{T>Eu=4z$BAK_o5!f8k^=lqJtEEo~ z;3m%4cWuRTVQb_s9xiQye$6l1s`wg1$aC~NJ=0Y4zk1dyC_H8+dvXP7U+4jW`f zrHvPn=c=qHAXA66<538yF$&>~B>#qq2W$AV-sKfGA7N@a@p|EO%CQ8)w_G#i(;W`s zn#mG}WsaB|&_yFZ`BhIGsY&LBOOMLOeQT*TdTq)P4W>6(r()oQ$CpMdR?`0{ds_BZ z{sKKoH=!wL5?rtFhrjzM$!uCQ?pnL8OWRM4>mZ0@u7ZPST-3UA_bcM;m_v?F9g6|oiE@RTrAP~bki z`O9+YS?jS#u1{5P3$-wPD1Z&(a)ExdK1o@em+vR&yWb8@$(rR@?U4RL*B+BeLmm+dJ`XM`K3`0 zCr1gKY)-tMc&k0V<9Q77wmYC=sq8zd921Ln(rm?A9)B#fL1Slb@|DI;JN-Gk>#IzM z8L9JUD^<$RaP&Fkz2mBp{fM!#RDkaU`^UdK^E>r3mYnN@YF&=x{=0H?Pet{bWrF zB8IMy+FO5ganU06NmY1Z?X-zv%o8t@^tGs#f)W0qNrG0|*TJD5DD|&O__wMOqn-0< zDzj(PeA^cJ)ZpJcVq(h~zVvglzw$Ba*)?R5km9^gR7mV$nH5tt7S<+RH<}8!7XxcE z_FAQLzWB`tXR4;;h6cYQXMd+Edn6i)w{0iMTRG^?$d~pm+SsIhTkLy}jFoH-KBzqR z*$&XMQ%@FNKlsv_RH7=sU0b`g)K3?AO{$R$l*n;(Agz=PFE;mx*U^^-MU zpCP_C0Tn%IwSwDSSznf`v;0j~H!l zyQRgWa|xT*YQMNy^sNh@jmP$5F{m;-Fe+M>JomLLXJWzY_55=op5WgXErfmPW_Uej z(cjD^ll2v&ZMVghYniF{#kbFof5^)sT~jgOE3yED$*;x}o!<{q0x4Uno z=tPkqSOiJq4D`IWmlhWr7*>mVqos zNesMf4=5*B2INc$2lq3JQNRB!F32Y4EHu$&YdtEx@>Y0CL95qS^@Ok`|Z)4_%_Gr%d z=|E)hdu`kz5f@Og!%k;e{dKTPefvSm;mFMtC3c3IB!jTKX>&ur$OxRvQd)~fNs?Go zPx@@{Y~(`Nc5^v9!NsQwj9gyc-9?uNZw+}z2831AqMjt4^Ixo0B3JcY&*---E3B<3 zCO>B`wewq=T*-ZEUS#dkhgp@eQF9LLkO_B^CH*Y*V&d~&f7JWdgf1<7$$MwBd0RoV zl@Enn@9*KUZ26l`$Y5hsUtxm$dd2PGneQj(I8=`&eH^a7ODentX1H_5X{+bfOK$80b$gbc&2jeI z9}gzZP!Gj^g&YwhGf91tB(<>o<$3dS{O0jFD20InGbA9HUq?|}_G7GwQ9jrb=TnRg z$oYgk)MWn^f)-UQXoh+2tD#c$;+`=<=w=*~*u`eWDK3$kUGFbZO%L{0gXJ`f!&O_! z6)c-ptX5n63e8lo0+BCi;*K4y#1y1)QSNn&Wf-&Tme5=jsR%52cX6Z*(O`KfK6drm zKR@=@Mj`YlGOu!IoMCqIRD;eK>liBzY0GeolUKzg4eXx*1G-R@FBB73^vFohsm?O$P`YMEl*R=7=f0-Q+AGLS4 zgXGnevV*RGAbks}S(dh0{yV%f=N?M-DG|Y++duRDe!QRa;KNQgwUIj(1$Q_Rp|x^( z8$^CF?#re0b5P5~#%BIshI`bR#pjq;@f+tJy=Fc$CVbWOK=qU=w%khoYbV}8vmB*% zrJKdnI2siSD%~Vs=Nf`S9-NBgFW zI`4RjGShxL*@(d&mik@)Bz~Q0w#K9M)ak66P{o@lE76Z{Y{E}j>2TI%fli-)@;G_5 zpxsO^beTsl@PDOphv==pG9YT4r%PFDPa_6phy{0 zKKzc@y2owUYjv@D$Pc|Wvood#vWAWmnoObc_W->D3Ukw*TmJEF&d;F{lV%}M->dn&E^_v>Y z^X@`^lTv1|%-CtF`R@V@L$f<5LTKnh8{TkbgQP&fKB41UTwXBBmLILsi*xBGVD0C^5bB^`4>|0KK^Y!ad!? z;O$6al3IZzYl(2Sei}4a!pGs;Rd?p*2D}v=+F@0|iTbSd;5u^46br63C|T#c{sfB{ z0Lf0j{Tw`PSE_uM^)5O-D&I1&Lq6(s4K$tP7(bxwurrip#-tk1dA8Rn?e1+p8w=;I z5jYHZaQw=RSM49+1vkp})}P*)6uAO`>ME!rVfirhKVleqTIF36CPC zX<1_1YsuTp_)kY~?UTz#%EF*fd#^yR;ZmlNlV6YtW%cWZze#lx4fwCtN|hige3TpV zF6$K-v@GNP>OR#yRpC##pQijI8nG!mRK{DqHsLqfKm&?@8%~Kt;k2WBwCDVMfKo81_<=wQyD{Hw2)_sY-$jVUDq?& zovZ%ik6%FEb0sOB=JQIngEoAvK}$_m80XqkKNICGxjoD``ri^^*`bP3_l>$2gEkLO ziYJ#9$!%Ttlw(pVF4-_K&^JP18gmM));G+M<^-sumB!A0C^@hX^yK3-14Qk|y+|{+ zzPlvr(3yNXwrE$K+bOj}LbhKi!D{GE1gUmL~Bp zO21Z)3=bjgnmyi2@^{yJpY#*Ez6EW5)WkmG6m0tQbz#s1qU4bgbgMMn)AkRWj@L@q z!}?<7N@a7^m|{BU!KU@P#|xrIT{zu9fCO`s4Q}WDLz$maU+y+8VXya6RsYrNlzt_D z+${D8rn~G@5Lf5|xaj_8pY_Mvf%B3k(y=tQ%QU^SJw*e*!n+3f(6z+yLF@#NPZsT>6?f*)?GQg*x)@JoBQ6h=X zf|PZw1uG`bA0qc8nPfyC33c|bztQi531%sEx;qmLHKui`sYhP5Ky4kU+fM`vY7G@% zl>LbBf2`1Etx-Z0w%^hj3D!*Fw>W4~;%jBM+TdAOucET=z=yr?Bh(oJfCPCJs@iB0 zYvwDG{Cb_p^IgQ&dDMM_eu=XIFG5EP7;{cY3SK*5L-29@fJ2>rZM&jLkY7G5`2Rrd z9!HSxU_L;LP&AIAUTRnmNAV`mOT+DOTh~<)xbv*GT0x0f{NkX*zY1 z43>|k-68e4;?ozxen@0m#WTjLSQJ&Hb?rnwS>AB-aQv9SEJr|E?EEX%|CD*Pu7y-C zc#F9u%103})CT;2bP{;s+Lb>-!hxp`1$SVPm?c3E`+kVWro=78)~7e8!hh_L+UEfB zOU17lKZ)IzWFZTf+MwY@y|43}(eb=5w0KP0C}&fH)hg&|YK~MYz`7@Sd;i(evMD;} zbFppNuMqN0L`A=kpIp*#GV?vY?z@q%u$X~CesDWp9`rpU=iBQ$vFK6#h)$LC>_&kz zG9nr6k!tZ&YuvWa4B_kg;H@sKrv!cYeJfqPl$=5WUae8w7IseC87yFRN7>)6V<37b6VAFK z9Z*q;ALJeBIXxl&WQk_z?$VbZNXH~3v(TVg%=$yp&GO`>1^21<*@%!h#JToMkAVMG z-i$>gO0ay^Fz)xx)~4LoIvW#xTW^7Tvdf5T@}rIg4K)sdfpyVEKb8ZLHzR!_*n;pW zGNyw%kIl|s;=EFOAe5rP@m3r8&I^cp+P&C0c5Vt1z4->Bx1xv6A8N2S4YT}ibn37i ziQ%_)aT*zSGGV#0#ca>`OiAfg%=id^YEdcc7*b&0AR^DfePK|bEMW3}fZ#}ARi8}R zrio?zBacI|ooJ$Z6b5}K3WWyZQ1wc|6X)^TsuV7W-TV$o=$r;gcUtL*426Us+&zFKh6=hwQ24EAeOmMc53^fl72 z)GLK7b=vh-?lqWs{#O~Fn2|G++Q9nwyPq8`mHnk2hN^#)oX2dq7E1WU2YxD}{!F2m zNXh_AJ4wAHLb{*qa(KHjea%)ucv`12Kz8u7`myE5amG7CUQ0K%kek6?4ZY3*&6-F5 zP9>(Yvu->%V-zay7F!LbI7X3+kP9 zJ2^`jx7y9wpr0xHSJr0~mZjlu>Tf!VN?;6=x4_CK-SX;rIFsj0Kh2{3pZ)lXZ`)iu zs>m~%5fOM$vB%6@w}CslG03oD|0S8^^~ER$rn1Z6hC806n<5KWamL@2Be#s_{J#&8 z?4W}lD?4gAzs)!UDFYrkfS)X8IiXsLiV4~SPv49jy7?NE>ufLuLo+9hdBy{N0TY2u z_XbAg_>}+DiU|mxxfd%hC_lq+Al&;>yM=)#G$Q2_JqXC2zjNM&Ov|$fbWy0=6rv`B zJu&3P5s=EoHRDtLNsDPdaBCGk;=sQ>xR$EC(1qv2URWs_^qF_dwe{OS$VQ^m$;^w3 zsf!G5)aOE2RiJl^Dz{l%MVld>&h5GYWL>7fyXQ&`{CNuzVCf%t?!QT+^dlw1N?wHU zVC{@e?R}?xWI$il&-k}R*bnq|8FP2xw~~PhKUT$)TPMH>ISHg$Dh8{PWHT!33Q;?! zY|0(Et~is9RPv#r#m$^2Zj~ZCzGOG2qz~0rtK3}bxsuLZwrWtgY$K}Wp=9~(zelTA zL;F7Tz(w$8Tg5r&k&xonh!S#neqaBPS<%3YJdXk6;mTW6cU%W5%DqP3N4o#^pqu}4 z105qP6ul!kvuXFJ1z!7rCo1?3A4VQeA_vRdBUMMGr+C+8Qx))b(9QR?s2dd@tSj2B zEAE5`FK+bSNOE^igH&`+Z-4ol z8S#~K{cpDvMp`<*x{hBmS{D=h&{f}A&%9Ms3oi=qB$?1otvi*bsXnQwg(o{IM$~Wo2D2yzutjzF}T{uuk_*`V0!dTJ8K(}?U z#$S14t3AlXZKQf>f+X^T$HcE}djPh1*^kH_B0UOcmGgkqFp6$!WE1i+^3dTG`HiKe zZPUZ2vSZ7d#swLS8)LyYUq>TIn}u|m2P>Posr-1=4$uP zVux>X^SM;uHhP*IE?v3CYmQE;R+$9_lk(&@k8eJS{2afo_$O=|^lk5G>Zb9rzYn8W zbF}~kYOn}CRLllztDmk+c2Yfdaw}uGXc?vdZkgw^=CtmqfEut$RT2 zm$G1)O0u2*udF}EE6tp2apamBXH9aGcRA!n_VQ@ z5YrwDlMe8WJ(CQS2F#IWFe(mroa3U7zCRc*-(5Q3R!F@Yb#RuqTQ2)Rzy7~1J7#XDcInP* zD7|uUSQ`7Jz-!1;1`=Pj-!g_s-@yHkA9lF_b|Z zOa7F$9IQz&<;yR8zkrLM6d3)weLriwY1!`zGw_U}8`8OE8NI8P1Fp5J zT-z8nmX(ZCarM>*uxpB(HbS9GuCwl|xCzZFHk@kxhg08!LR-I|qL#zikMT+|9cPOg z@2ZQAzocVQD#>@sn8aKkU!X0{i#LX1{A5W2Dqw?2C< zmLDStA-UPlYB&CvR?k4lQy*LG(h@@oO64n~Z0d#5Pc0lQ^KQ7<1nr1xH$M!PmsZ#g z_Qj#*A5VS`-&ptn6`@fw(lKl#<{|%~zZ?5+-Kce#gb)Uq)hmO)tBzVo#rC9wjHLqN|XrkKDc(shZWTO zeFao)-M{jvo~3Iw8-8?#*EW3xIp3?nylWqBxXozB1pf(-(&oJUY;H-*E}CB}po^qH z40r@v$aIqY=9a!y_6iJb+Z0bS#c6m<$PHf;2g0T2ki+pBkym;0`7p)_ZBos((u5WkYqsu z8VTBqJjR7^*yj&ApX3lh9Z!Nlfm-nKHjg6~v^yfo5m@@w4q9vjS{zCOo=9}n?u`UXeu}!`y zm5Sx4LwZ+MF!#F@ypbuNf@6p+?Na#W*>*4ZFH+k!;=hoiZ-9h4cuyIC^i0PWjVC@m z=k=3(rkRRYDG~B%Vl^nY^ZT0&A*on3cUIE&xAMUyv|AG*w7FX57Ww7eYKNK6xxmSv z*o)~?uD_r;#Tly^StaymVdOnl&4ty$+Xlj;TM@o{hdAu$o_Bi6J8KC-(}#amdn5Cz z_k2`Iq|?;$b569ApA?$=8#ZS?#38?@me08XKw}C@2|xek^e*0cYss*zyB8@BgzaR*;z$pJ4H5G30cXi5VGgV&fZB9 zMI@V0*0E)T>=DPvp2s*2=bY#HKZpAKzW>X0eXf&>K2F}x`@Zkjx-Uq-Hxwb9yTkbV z90hlow`tZvPjk2WBJ+laC_d{i7|NQ2xsu0a2x!dny!N%NFxQ7-kM!yJ^Yp+`v&H2h zmkH))&Q;r2sQp_8%_Vu_v;0oK=N9|Y)JJ2VKpAa zEGlmy-NYP6qLy>a*(rh)=nhVC?1wc!vEkfpRJ9DXg}^aQsPs>|c9wcB6O%{97Xc~S zl+8|LLIu!C>pOS2l;DZGyl_{^R4)7I9QSst_9G%zk!5N5;)91B8bKx(3|NA9`Fhi7kT%cj*Lqv$um`fOx&~)V zlIp%PH4fws%Kc$O)A}=%?$Ly8RSo@?G_qp35HdTF| zBhU0Yp=U4wa4UTXTe=3Ad_Daw)MuJu#0GlR$^u`KgnD#qg4L?pZ^`p|(sMUl9dP=M zzb7Jz^rPuAhlTriGmi%}#&viY-@jPt%Q})5P?EH@A{KYly`b%V<135>R25SzH%|o| zDR#CjglYgtVunc^*8igLYoNQf*9MCj0|d$V6*;jayl68uJ?7^l@&?j$zNQ_-ILU&L zw;{}yU74w7bFTPNTK;*8yd%YaPMt_Ti@#r`0nlf4bLoryqYO6gJQN#jRD^CR_$2pqP$P+Lc zl23;JnBgRPR@32UjJ?02x$aVQBA?r$Vg7L+ck+j=K1qPxy@O_+_3~P5iCFfLd-^q> z-=q=nv_{{FVPueC2+v)c=(03tDRS~uQ?bEiY^EhJx~anUi0r+TmASMtNW<@#;2}ouqvDJP% z?bwaZboN$M4GLN1yYWPL@~c9vA~+p@wlVo`tyGVOUj%@z1MTvSgK+`>ROxi-G;wL< z=8ixrY!Z&v0{b3T+6SAN29_5YRb3)4K$P>iGI<~74j?i}88-TNmQ+9m-Y6~cRfj2=O%?qoG zGW(jAV6nbaE>_Rrmp%hpad1JjXbs>^B)adV?(Po!8e6Ei_pSlTRvs;@IJ{Y$gjOt| z?7uoaOL@;1zD?^Qvc<B**wSbk2b6~0J)9T-6CRIFE$k} z{e86_eq5^-jkSreYhjb zw#0H(iNA_TTTs5KVp_kwI+I#hNbtfz89=eV!ww1QHK2z433u}dA?iVa>0gN(QHa~4 zYt9UOpX6M6BIb_bwnisCB~ss`5Myd64G)>pfrNr4I7JNAiE6iBBE)m%Axa`Ai$HnR zv`s8WED<1Oiu9E*Ize`UV`-=c%5L)}J)EFiOEl${%fawnA%Pk!NCBJ{Q#XI5K#@1C zR&6gTZP*DCA)#19;LH8z#J9~`&P*p$MAeD%Zr`hOXy#3V#Aldb2QZ0jVWXs|sDf1z z3k^5y7S_5;?U69h=I*Br??0!jZ1-7eEr%GY9<^gvcNpz0O zs4AOFB{PCx6mQ?<(XmY?K7;XA4JWODSk@czhoD*fjO>%4{wFZeI}ef}J6^`y>iBi( z;au46m!IL$uyf!DllV4!7_%4Z$wAd`=8llxHSD6xV8O* zB8Bt;aa>VF?RQ~+xam&lJ{t}q#!(truRH90{=0IuPlr-;FT=B?=c($j8M4=$z%M-A z8`T{k_cYU*_FzDpMP?tSz@lvVNEbpU286u~+!^Qvr4u}FZ0d_9eVkPhd2iY+@W#y2 zIQd~jXqz9h+chO*k*S=>rO`xol38Y*!|iwBmG2jjhWKRKGm7J?fC%LZ$Bn>y_XuRx zIAk!MP&wrA{LpC=a^nx&6-?`FYx2sXnx(GIQsiZd0d+JkX$b()KzV*!W;R4; z7p4u8h(CMNC^OP`^CsXpT2ztYu|e`)va?Pd;8-2xZI8}RR{#u@q82^czd5lc-Suyh zX*k>y^jK0FZJBoR1Cgh9r1!gAi;SaeAGr1nqGwV(i`&3Wm*0UNGuthwnj6xl+wX7m z2uP*8hyhKAQ=N|vYuPKpOO6V*B;0WlxK$@(m7d1*VGwgJp|M*6D~U&FEqRPMh8H=A zb1!*}eH4E`-YM;lq?+s0FT&2mP(%Yxd(LcnNG86Rms14hRZJbCY+aP6 zDi2e+M4M$?Mt*+|5~bG|uRWA$?+$o^tv4M%un=?lU?z@ZvhRd9l9Nr!zX_DE2b-lh z#xNIBxh=~de>Ivf!SD{9ZG#VikHN=o8(m$T{qfQT#l;>;)P>v9qr>+)Pynvxi?Wvxa_&U9izzW-{+iAG=c2-%w;M<%uoic z3N)OhzDO5^C5&pX0%vc))3_lqMFAv+Em`Y? z#nmK{*WF-$C03dSap;qBfups$Ns9=#n{Dx~fP41&q7`Ee@8Wln-biy_ zw=Y^P>kYehJt(Y^wHd}WPZ2x$VTq%K&&Rm9{Ys!`=Q_!XreFB_94hsl)s*L$PsTp> zz_<^+i?!Q1mhP4!Ep4azm)2t_Zb7BW0cxXj4L>U;?hy6R`jsKm>zO8@ND^F<{W-S} z76{-uoFXOV4YV}PZsupWRKEME%y%O!{^CdUlqtgN&DWK@#ykB|BTWKnS5hBIazw_d zT*t#;KfmB9x^~`=9mM>__6elY=7$$cb229mq(-}#y>l#7$KCOkZ2zktME*(;Bm@Ec z#$!-NOCXR)=^;u85do(M)lDt~%wp&NPX;}QWSzbEpEP=&hW+{tqo+8fd2J3j?&(kl zOktVaj$CULjY2~-xVLF{{o7GU7lh=b&-xe? z7+TgIl;#TQVayn}qW0TcIn1iJEcv3P$ke`&`0gx{ZA7s0jus%4)?VMjt?phoajwO9 z#4oL<+D){~Z&rGmdvzl?lqM<PI`f1Qb#(PsmYnQ`-$;hI; z>lU2D06r3+J@o0_3RQ(xK#v}0!A-%@F@_HFuMdEE`FDW_e}Z*;?Z1XrERAEt+h4)6 z-iKl?ryq5nH!@BLvAdNk&4`NxChkzx|agWm+PYAT?g5bsh z32BEc_@8^yP?!5%((6BF2o1Td@1|_tY_{YFqE%%g?xH;RF8JsWrukMxqh=&Dpz{KT zI8PSU3(yO#ztUX}IR`>b8$bNCOhXXb*Gl)_Ip&C1;25KM8m=u2rzF#MTu+4p$FXk( zJypia4LBg>XuS?$WlH6`J&h(#?e|TOgm&U;4pyFafIF8sh8Hj*gDjvt-~=Iragz3N zqZ6O!5sIMQH^&Mrli$eHS|77jL26N z=YENX$8DV0GlNuzF!+)c`WmgSahR|@1>lX@5-GiB;t|NaC{Br%w4L!k%EkI~2qJy7 zT$p(%sy3!&CQ?;0A2IxVt1>N$BpKbk`(VA~VM88X2n3qnf0)OwaL6{)8bWdpIQr>19LW;oQ!VaHy}?0;Ppep_S! zm-iJ;ewp-J)Z%N;Chk`Zx_79WCsepnyd_jU+?I}ka2^ZbR3__>ghqxwddk9SK+&vA%iMIYRe?3!Sj9GLoX}z9$ zgExGT_?|TM7WY4@J_z?Kk#UWNVJep4S|8v$AjXfiAEMtF%|HspYs8}axiz`cUoS*5 zP;^xk>lUjP=dZBz?W%jE)&5?JuCK%-2})`!?ACLd^A-<%R0iHQZAF-je&KqigBQ72 zO~py69v?KHP~!E2gpd0_?O)RcVZITlL?|xYEC$k7S;;5nd&IerPN%c)c|H?@2>GIR?_Ueys4tHkTeIjjeVz;OXj7{ik!Q=Yib&7^lHx7@gOlPiPk>b<}Sk~ag zDgnbD1(zC|>sq?&QA2_rNKwFA@U=(_R9I|%<5+vzpBDj%j*jr(Gauvf z-D^FnHsoa{M1s+!j**jC${j5(;n8(KFu@cL1va7MJ*ot56_gi0p|;-b5Wf! zy8&$g^il|heiVf3*M0nzL@qotiAgZC7u^3h&9u)9x2957R z*A88cL;z@i6`#%0X4hDA0Y8e-Pt+jBn^+t&VHu`m8LtY4UtaxOu_|Cte2rcxiPwLY zX(mWG@1dO+ZTN4+pU=s4*!ir!Y+{}O)~`uXsoKBZvv*>xQnhEqE5c?9M?XM$?>RyP zFb}4E7!-R6F&{od*3>6PmJk@RIOT~8;ZXUT>LU{6G=knR3jS#O&s0+xm*Fpcf+d$w z`!2LMk%bxoUaZw>K~U8m$*zrM zM=`vii_RH_2)1ZuFX+%HMB3nKD&KpzAWYJ!H(c@Q;3k>J;CI8ZRR$=&xR3^PY5s&P z-K?)ZmC^d{_VS>0Popn6tylUtaLaFu-DCOl2QS0a*D#K$kEu+ z2b2C2?K#IbuZ$aDw0u1ohL1a7h?9*CNxb_n3!ssj<@y_isDpO4Q7aE5$sqEe_Ao1V z1OefO*4PZcSM$o$X2O1PB{b)Ha^(MnIW;@B%|C=Q|VBo2XtfE@Az&i6lI zy4gbiVb@|4_i;ZjvQFQ@5&yXz0 zLaW`vLdcwzwIy-ZC^$|1qt6?%=R2R;bblKhjL!Fqi3x!u>=G6F0peZKmW-2-$)MCk zr0!~Kzbc-q7_6#<5SRGLuUO8O>(%;?IiA6#h_?dXc;8F{OX7fpBI7n|V~-JSQE(Ph z#a6X^2~Fd)JEoPio|euYUGf&E#0%czC!hj65re63sG zo13#(E9E6M&6)ZsgZ$sX9@`KSGHP6HfPEpD;Lt}_gF4huQ3FK5(atLTwEr)5?&}|G zq)HHW5}Cd>58OrIs{@uxoaC&1`>J#X(J~O9O&IDC2w{$(qgwwD;TbQDQ@c~i;%}R~ zjwS)?aoKPc{GQv|Sf%SUAm9nJ>6=EYeob%+KDr2Qxz06JA=QZWQ{1bZM~*>_H`b^=wCI5EQH#Auj2^jRPWj+8%=kyl+3q&2hDBHB2&`ZH|BQS) zu-QusaJEay56)bqKPPd9`nQ0I*D_2&b14MIFQTf15qx@=Kc*yK<@~vWeAU0Q7J}^s z`k1{G3ZrxQaqG;R|GtwSXG|NlO$czHF(PTR=u{8;wRdxa|89%_Ln_Exd)#X29ivQ! zl2CRL`7LW!QltOA8%np~W<2?_rmGG?sL}75Ge0+&Y-f~+wl+X1YLB87JRB=5J+U%s zdUkMSVy0@hO|5S-N5cv1nJb|<1Gk&r<`}6_WHoX;ON7P+r@5{lSb)RIeXuq79{}BU zL6M4Kk^`J)N#Acy_u_es5?g9Et6S6sUEeyItZyKn9wyqTHq^B~5Ha2)Ih0~O;;?$L zU4nR;QN6mlW6{WL*jLkPP@X?$!Mp9TcV?Q1R%&fMf_3YF*^9LToWi2N!m)?rAAHon zO<+YB-OeRi=m{W4YWK^t;V*oaOR$#Q;(2GL!!}?~{umZnESQVPt|HU55e}Zfc39jn z@K<**iaF)g{=0f{ECyL8yXx85@hp!(PA}2?tYw&lZ@ju*<<*P3-(~Uo3*)|o;8U1qL$P_t-Q zr&*}pVIC$&6y-@J|A`vxR3}mW?j~Q{F1^|q;Bz;hdYGd;0rv^Mt_X#7xL?^4YOnx} z@H7Kz9#EVb2>}Hs%!sxgoD%8JIUcb$>)a?U>+GTcZ$t58C!r^nbV)Ws;@NjUovsOi zak9;7kDi-#Lerl}2m~Mleac~**v##<&rq0w9kqbQMVSmpy>f>i5}p)cYOPt-QMQ=G zfwDR6HP1VA%-|FFFvv<#Q@KFlzqwSlRm2eaM68K$@@~zAGzK} z?9#9KTdIHo_IU;rBi>ZaM=^)h&WMKWSL`OuMy%C%xr1d#6*ITDhO1j=_TTNK=B)=> zAR}DA&MqL^E2}hPG&(XF!Ab|@L>PWk)_$t2sQ&PehR3Lqgx?dq5;vB;)!Jgql*AX` z$+RH~`#!!p(zrBpj`au`+<;;bd_cV|m<;ZE%07dGQ*uIMSura^eEo=7O$8+)Q6}C5 z`G9$D-yIs7#c%zhIR@2i1BDgFL06~e8={;lQ#VT@4IjoG#bKKAN9;>PORI(!r85IP z=XmIxNoj<35x)3I0^vvMZEXIq%qz{zf3>gBWB={`bFfjD;6B)zb(}wV58&+4OIvXV zj45zq8 zW8`-2j$KY3#zk=XXGChi)P(28az~--=AN-f%MU@86qwzN4Uwq(yN~9QtmIR6u@Syy z4*;H!@(WnRTAT)KFh;8ccw7d%*+LO4nAT`gy1M$sluYr%;ebPEiC>I(!?vdP1ApnC zOACFkT=~E~SKlHD{Qn`Y5PEU29F{YrV;?} zEnRTl$$x+lt|AC>fr2>B$$ zV(50xej?B@`Y6xxq<^y^L3wbjnKpJy3(`csf~%h9{iF*k)H_zTXsogew=&dR>+I9lXh=Z8^F@QhS2i2etzjXD#PtF!OIOfzC?KL zi2rJn$iXAw7|W`I>tqjwX`P*9x75=Q(Cais2&MGbZz}iw>?h02d=x7n)6dhmXl#1D z%)XXi*AsBD-goSD3)#4b@8-pY{#FA)Lo8K`JeR}KkA}lHkfd1I&{HXb*{k*s9*kh+ zn@p0BJHLNxCKF-|?d$*mIsdkV5FwIu=3<+oL(czSE`wEKyGWVPB;bV2~8Z!G`x+4Luyfbr-+nztW3_M2ToXV>+ z{f9FvMez3FIa6E>@I?2udWOw~4V3Pie*A=$zQ$4)CeuH@t`@0A)^W3t2}1W!%QEJ= zg?{xShf5O51|ltz9iDMX3p)p{Hv7A4$!9o*ZOhd~6H>N2%J*s}EJ`lZ+=t~DSbz|< zQEN$R9uTgu5Z^TPm;e--``HH=ns2Lf;vT-6clmsp58e*9O=M1$T=~>(Nb*}HyRBQ* zM-)5u|7YU#KY^%1MZUz5M}l}lLQ(?0$d10*Gr8y__5jkv@IZHAuwH3Od@`Y~o+MOo zebdY#Mp;cj`Q(X@$CT0Z=pGSw^TpAyM>afrGdr; z$P@3rq-1mP2gehuKFlZKw2DXd9hqT3-_t60aAVN*{L>kwVmY>oPjk$u)?9nAW~Xn$ zW8+aCuqbOE$ld!C5=GL9&Qp~$51Dx4WdM6v0q$Uapf<0X1kv!K^M#2RysUDwf8-56 zrS+LE=7tM23$9?mhQ-F=Bu5n1*~H_$Aw=?dtr{T9W2a;k!`|FsbeDH&!J~(M(kN@N zFoQBEL*&=uN%y{e8oI@adKjY3+#3_>UWH(zHKjRRyikRIDeom>k|g{KczVFaiJO}o zIRZizLNk1Zy+@u>a$nr-p4)mmTq(b{)lH+Vrv0oAUo=r&*@ToDsj{KH@h3{JD%T@s zYCEReu}Q`iPtt|*EUEYS$`#-oIofpBy$f6*duH0VCON+)jRA!{H*7j^Uf&@5Z;;ij zM6ize-9&7%Z)TOTXdl0X1Hmg;HP3_{XSbN@gEXh1tVRvJ3*Z1UZ>%0x+tzm&bt4{p zNe6Lj{gW*kP`kR>qaiX0FG47P!PrHNI~GY?Ki0K>^Q(m1^(<0tvM71pNQME>7*>jqq=1KEoTQqcIszKu{0ZqFNf0RnlYjJj?gX9UP^~6}03a18ahRm2! z6o|(_X3ZHWf2cHTWfIBWnV~sz4j1z*!2vS|x#i|A_PpsD+ps#ybU;6nkE6UkWz=(} z#tR+f^#(R1IxN$Rg7v=N4c{{EzE!k)#MEa7bu>{Ds@QDWs# zAvV@zk{Y{dEyMn;;HJ}b^D|VDcXy$o9Yi(x_^>&ELB_l5BcKGMT2FVO(&R{$+h%he z04WR|gOvj+-vig1JbZ@j(R29WH{UUiCRLBhicA~29?nmTOuAQ?tI|*JZ`a3DP4Uuw z;8Qfj@00s(chwG7=+j{oj3%Cs{8EmtcNRV@F{){PJ>!pAOp5<1zox|f=T;-K*8i!K z%(A_Tk$4@`@Y_rglM?fWf!D&O&)f^v3f=Q=jeKF-du`agm)QX!&}3?u(TQhYv%e-M zzg#$ITCat&hOX^hzG^J#%WgZvHh+~16a|rQ@LLw^OVVba3k&zAm&oh-uhH)QVpBBp zZclj~xl(Inkab2xrLbvNGAp60>!>QHc)?ew&Emne7UY(dF;1(VxE4B)qWZ5irE=i; znYVBK%fWN|@HOtFEu3a0Ql*96Xdomm5keh4Vd&5a-jFx|%}g?nRPcx4UP{YX=1%#D+H$-gHSc|I(ojeVdXDGTX1qB3;j;nX z|HZUk0(Pfqq&MJWX15?OZ*`I=w#K-^7QM&8QQlEf8+Y!|Xn)IjM#N)V%A%|)W3pC= zpDaw7{u~ppddb78W_D(YU%8i2Lz$UhX7(|(Hhl87coD(h;<(l1#`IPATyCf8jQ`6- zha14-o#iqZY_e*-0&49V$Wk?#@ckB)1tF7W`X5p&&o8!&;uy$lcQ>G*sT~P4X2XZUtU){9UkTUmNY+GSxlIAe~Vu9%}*v=e>5+@9)aQaf;%8O5he*W^7 zC{#^d%SK?|$dKE#b8jkcp8xKf)X)q3rB;t-xmLHqzF*f$^@ombn$MEB>9JR^yuO2X zch`hNYhJ87VQ!ouf&QaZupMHH?>mp;CUY{1tp;F`DtH>PctX0RRy>r*=8nAG&HuMW zJZ8#&2SO;yF=@e%tY!?rX$bd_Q0;?j3PB61mf~2fOf3%$^YT0!_t77%+o+s+(HwfN-X~ zaQ^7uMKn(FXo+071pq{h_+~a#xiyK^ZmwUidM1#hpf7&`&5s%=e|}i~WU%Xqy()aa zJ-RI2sXDas>1g|FQH$r4w!M`S?*kYjYPH9Al)7&{0Vea`1y-9Z{Qw&!0`?ZM8!N0F zjcdDB63^k#RRj4cI#))#*Pb`cV@_Q#iQbv^DHNb)I%DWwWj+0PG_AV7c(KT2;uQ*s zHcrOFc@&I0z_rf^gW3lV4!1gtOXLvoFxWFnJk9C0wD#4^>kC@i@n*N+x^+X+nCeZ7 zWlKfyfXY7NV7`0az3$vJ)urFJjvl5P@7}t%6Z$=(>J#zWkd>><%wCHF6ntS`fH%)w z4;vr+%nAeg^Gn~-m!786< zKN}|&eVgh|+DztlYLo&-d?i?e7-|{`CoOfZWSiEe;p6Y8-0627Ye9ehTX0VjkW7wc z^V&_q&cZ;xkn(x(Poyrpddd|oQ(CB|l6P?epN0f?8WOj>mt)JY z2OHy6kB%c-LKk_TfG`RmrPm7;cHX6pW8GVy3J}v8Lh=Zdl zMfF}U203?ucs;8Ev0fg*eH-I){|FP4w6T)TZd4nfsbV7g7&bXyfc0*^o_TL-f9QVr zDf>~x6SSZ2W^6~%VsA&`$WOe=?v`2AnyoL}u4vWp^GV}L5jIqVdzD}3i*)RWP%V^H zHQwwAdllw?7T653US~riJF6zG$n24o6_sm4y-9(F*QBsh-nW(@P^8Gtq6|v6BXL~p z?kJZ(N|mRCrEZIcaN^dwwkYmSIXiZF_COJ*2iLV{Z1SP)%^#PXpFQ<3()_xyM&y%A zx4W#zP^Zu)RBDCg8cfo?L}QnC)>YS>v%73ja*2&i(4)Kj0JZkFtLppmSE4LXDFbNn0jW6_@QjR?@)}vPfV)<=vwf^eg^tP%c)8Deoon45okn@;spKyET z#PE}%>cw?h^Sr*QH2vwS>i6E`BZa2JPXHtu>P{e=Q(ooP$gc+_rdAx6UA`@D@;Ral zwlJkiRyh095p{AN8#X5Bji*ySPL@S7Un^canq-?-Fhm)Sl`CmDY_e9YXXHE0BHf0% z=`DoH5u9X)neeNi{yMS`;ah*koYwQwshzfQ!MQ{?5w^S-y9jY?#`uo>lh#)AKl^i8 zP0dT*^8Q3py3@X58n09O*1CH21q=|~qYHn^J+7+*@uA204$yvYfJ}|@q!rlT=C-uX z;+fS>B}U!YuCl^~ohj5%$5^0fl0zux#a1ajOnFx%9SQii4vq&<#L=w3JoYgmUQ!W0 zdHHr>^?B{95R|mg6f&p1N7IHJ3bp3%T32GX9|+j4D~KlC^g1CqG*7vJ(^r0B_%uTb z!U%suDHQ=$@4qu-^5`-YNlO|}3@XSsXR4haN)q$G01RO=4`MV$>$(a5QV>ghYg@>y*)!c3f{o6^{ymo-%iF^>S6K(LrhCx$AXYf)%G=h*#}DE zD{<8n>zKH7eSdR4v;q7qe`=A&rKM)9Iw0~s{+BtaBB4zAmc7QkP|Tj zEiV5N*na&|B&8CDcusYP|2ysbtar|&@c9>}Kf7mga89p&kkHdomRSme8uf(qtrYOn z&p%K=(~rPjZo;<#OwhOe@V8&o`2#moQIO@#FjbZmfQl$5z%{P#P#%g`f}R9IH0iOg ztW^^lSBYO_xl-L;QP4dO z?R9J{HfxKdT~)U3&Op}T5jB2k@yAN&!y)WVEqcMjvGWgU&E)u6>;}HJCt=cq zuYI+2z)+0qZN6RpY^1PHw|x8yLs*-MaS>N=Vv{WW#w~oJHE*u^(*E<7zI^y)uil#tcJ(FTizyG6fCa3I9CaQW?7HnrB-e&GZv{kRDkP>>sl zAZ6sTF+R!f?5+DRdLvOSD_|&x;6U>m!%)(BrGbm&b?<)G<>r#cGJrf3;APs9g3wb^ z!~R5021jPropM{<&`nH$|1q{?>;khm!~P+rc@0 zy7*qRqT{F5aONH%E%pTH=9OtY1;sBz%$(@Gn-OrL2VrvO!I87O7xP-7H^TsG7F(SB zuye@MOs$BM{Lv|%#`{DuFy~9k^P;a9{$^K^Al0L?POSBu`@}Dvu|Sy)!R;LH%%*S8 z(?D4i-f%%e76luEAN^1bGfzy9sg)w~^yoabz5dpPI_rHylu|iK2lKgQ&-R#aZ$0!7 zBA4CYJo+{rq?oBYs6BW4__^AW`Q-}<-Nl=qg$SJ_Id;{3CSq{W%@kMA|AQvR1P~ksLFNcKhGiqp(lVbPh>_k&` zJB$D#5HPNDi#oV;ee%tVKdK2T3aHN}#CV~$HmSNElM#{xPMKe^evt*ec1= zdv^=Z7!+c!Sp?S5aPrO#;K+#NV$>u_VK40(R^v_K6Q=B*-Bca7YB?P~0hzUJ3 zDj5Gej-AzFWp@a_VZz9uG!KDwzo0}}i#ZkO_cwqUY-fXPD_dJeU$0rK@_^Ns3i691 zrY~m8$e&t-ec*O(QxI!8*kOa2J>;AhvAv*8@_9T!G_LF#S4ktMvOJGqFjuKkVJ5}- z{W9l=J9d9OlQ*nG%gYIOijQ@T(x)!BbJADv!LrbA^yv#O1%`xUI}0JD`laz+tPatG zW(#t~KkV5KdEC56Iw-Nx?kpEBVl%$&1@lO=z^v0NCCsEd*A|x~Ex>sE(d)0w46Tn0 z3Cy7p4$fE>q#u3=dFZ$KEDbkJp56~dd|b+^hC;tNLSQXkyj)!(S0H(^n%sHdWsKXM zvZM@mN^N`GC=2}RLC8U70F@k&{IUG3T3=MxF8Gl;EkNZ6-B4g~yz z(kQ8dFsZlCpCr8hhpy?}$M-eD7^3j!1!uM26CQIvB1Qg0@>AVX#KDSvo4L{FRlSoJ z1eFcE&X%W(bLpkeq*Tx@i0#uoRXYD55d89`xOuC`}Sn(OL4gRqC3j!(CRiXwCDy zaEI|>H7esl)EUNqj%FhM=4VIb9lviR%-#rruCr^i2GE!WHI3_}He^Kp2hx-!%(Pt)#7La~yQ{sQL+48bM6ocakhk6Pr?( zcybZg;5#gin4)3@tw_y{5+S|PzSruzbS~oj;H^HZRRS z(_zf>)f%G|#K)FnXOrqhSdY45!QBom%(>j}_3iWrcYa~}%&Qp@a>X`qe%VwNTWg!Y zS<6po1`^Xq*(o6Sm+&Dqn}gBDB+Xnu<-WsM_SfSm427f@VOp??q~!zwMSt|A`S-p53&%cIDK_EBBl%4s<|HRUNhjnUlX%j-y|0dO6?$F`} zu0sAi1e7w&vL-M*{2s=7n}d`R-i2d^Un5d9ry#~exzaFi8&E!B*xGLEK20;H=^>KR zv-#>Vf3Mw(S>E&|#eIGDa- ztfu-RCmS%vwRwMW(!;Mce)MjneVt@-!Yrx8omi`h{S_JQBjCt_xL5DxMgO(5ok~w#n`5Vq_xY~Qm+xQT< zEK1m@(wynWj!UNsbsA{c_B=}_uY@9VyNe>*yK{oFgp5bREU8QR0ri?0uRuWp&yhto zeZ8ETmhA59NLV)Fq&=Y{J*|8PWf%F~KMDO*Oy9H^Lzk$7Bljne#NwqXbD|TFt|hiG zshi-O(!(*_x{|$r6IiNmZG$$~7*U`VPRNo|=hrFIKDfc&z7x<=mM+0Wp@WEYab*In z9BqJ7^(=%{y((~WcfVOVEM-`1|KVFPB7o6O=L=)~6lg^}eR{30(D~R4wB#2-_wBTd zY+P+uW}7QXa!jo^58BHqSsz-Gw^vfvS6Vd8oZO*_je2+aLKu{p50<<9Ja)U;TQ!!(0Tj# zxFNm){OytCB5R=}HyaD8*s$Uem*|5iH%{vi3X>#wYJuW6!cRo(*7BE&a z*0<|KnG&4P7inz5M(% zUvty0$h0BLwa1*W)O&47-a5*kb`PQUOZ)EI|KN^rb6WQMP=sZ0C+ZNB6? z$D=dkzuDwCv3XtrI7Xd>bMr(c-km|@c^WH4{RD+S!cOuAGNfR5<8R}jD<0C2ZIz{N~w z;UpFi0-zR7kr?;{nH~>ep_~wq@w}oYmu2KizqQmG_2nS(Rsh5OE4c8;_^{; zF$h8Mvd*oaq|reqtymJ`e>N#a!Y-&>w^g^i-*VAjKMt1GxVB{>b%wm|$4;hR3zUU> z32=LTfs^%Ycz9rU4F%Did*rdgB{>h~%MG@De!p~|&>po4uA~XjdpNdaA>W%B#}*W| zZb%AT8s2*BdbIEmBaN z_n;R+BO#k#!KWbW+zf{J6X$%;yl5;;3Sg}D`w{DmyWS%(7d#&q3zG(xG1<@ygfy^@ zshIDJMW|d1ou%){WP=|Z#a(h;ta~@#cfl}dP1Wy}eyn)@DR1C{%o|J72^3#P<|X(G z9J#A_^O+JD)eOD7G_Z&VX6}$y zGxt1UJd3g&%KI3}QTK>h#?G#=XQWV{c8jwwsY3$a#1|^l1E{mo=1~ohEhwV{P_7ANlm98{U2+k}V4Qsbp<2b9xZ%sHyRfx~54)6(}3VPe!Kpx)d z3vma>CAbUjsdofIi$(g1ANwnJ4}Z58IJvBQv*kA2fRFngyXQe7niS}8c#YF^gd?d#J_ zxz93j<*IE;Vz*^7?5bB`jGH%#rqI?-ZuMFW#a{lO?dcw#z4X;=>}5qqFEpCqTcjF zvp=bt6xKR&((ej#PJ#!ce+$;INm?4d2$zayW)|K6Ld}@DdnwCFi2e z^lm2S=zEZS_I&&Ov2e}Ccc^exS{=Q-*yYE#n*d`{(^1FKD4Hy%32it+_u+;c6@y^Z z(7#9F^#icrQ`t)ikC_aqilP^!-%izwd0AhSh%a)9yLr}^=Vq}1A95~2CtiA+OaJGO z;q*Vb&zKp8ZLFek3G{C)IWR2|`@0#x6_@PeXjZMI$&A%x29Y?sL%|oi#MHBD3pmf_ zi}N&Pw}|Fj<9Xr`9rsv;KX!XHkKxktJ?h@^DtSmvMNJ;*d=5;_&wSljIc!Tb?ltyc7Qe2pYTW&e^jn~*s+lO}eZ&nb)sC{t=Qq7u-#<;(U7^G#$L-k3%}5mBIrvV;O12>0xS|lS4>Xj2*@bFI0~V zcjMh)I`Y$m1hb8BNkyd?WF4Rk`Gzarfe?)7rOjgkE>_M)ZZlnn;_M~|TXo@nSn$Iz zjnD`01`whKLCJjA6&kLc5qmG2keF7y3JVaxTHRi$;tJs^e@A1zF0syWfGWvT<ZuA8cq{x`Aj{2yWd`@_5shogC(OBMvu(HCLYHejYji*wy0t_m7VX0 z-ZvhPEqfgo6aR`_<~t-JY7KD@SIY_J!S-nJzh#6gV%$`2!jPkM&){964S>a+QK?h} z6zz4n6c3fft{|t5m|-z2J`7xuYben%z!&=-eET8bhg~=Z;(DLfCpq6(-aREhf;4oL z>$NychVuSCg;)bCr%-EvDv9RDt$MyztR2c4$agkvHtd6~!V$0l{C~~Vq7$4p(uJnMuyr8gCt;Ajp{|CGk`PLGg~j<8wjqX>+CV7KBJ%okhn>5Kq6E_#TrNZr*^kki$t7&sow0g()W zK!V0fO4mK1N#PVJ0dpWxr6A(8YIey7LwOiPfxFINhcURBNQ}c1xF+hYD=O~vz}2;) z0Akr8SjQ9mXZ~n7oD7fkD!Dsr4yECG=0f7D)Z@EY&9Jw9Wm!FzKj*7d<6bhIGNqt| zW3KfdvOZ|v0;{41pX?8G7_!`nRMiSp5Yj}-yIt`8hMr!bdKGkJVG_z3eZ!e#i|FC8 zsHsXKN8a^#-`@re0g;&B!C&za%rqtp-^!+fQ@^KwIsm^+oX$6zlv1H9MsBP$8l^9x zDPe(CB3eyAb|9HnlxnK6U^)cs^UJ}tK^Aca1_?;S_HD%H4Y(5b7!Y(% zqKdi^&tt8W<3($Kvd7SVC-+zmaBE8eP~E=*71BJ0{()7&^2S`zlGq2A7JvnC@0DooRs!7G%l_7Z$QcGy2WkXF+p52|jV#0|KFfVP1 zd8|!U$gS10K(-4EKT>x}ZrWizIJ_EbY+Z3F?udc_FxKQ3w;k7OF zVGnbm;24trVMLyS<(B`#ugF70!@b_#PyT~QT9W-95S>2)ONFi`AZ9O!Tkv2r@J6VU z?mU(xCaOQPnUJ9d<9xY*1=l@yVMJh^CL@$yE7bxvYuyOqYJwGM&*x}muTlo;cxWpE z5fP<&|3<(_jmC?T)q}jC)q~-O=tpxpe~OP;>FhVQb?D@(<2PVTQuvBOKn{>5avN&} zo}dLF*%*NotuL8{ij&>%#`C*HzjbPp7oo>@2m;R_?pCb6>22SxGnps~^Zb{qBw=^7 zeG=(9S6cDsmxP7e5F>Mtbos+~#&th_JXh-?3JPPNV!I!rMpJ2AliY9LHlv$_$XuTO zE%aRA^!c~z?%&zll1Sz=JYuQozzHG)NEa2nOA$~l#ZR`J1=n6l)x| zdN5C|KdrJj?AZrdtvXFOt=c2k3EC4~$gQUX-!}l>eaiNb^iHhV8H+$?HE=y~neJEm zE=(S~jEo^vK2ov-q4sW1F3)3P>kP%7ItK-;z{1T0WxY{TX0McbpNF-wC46I8$WYiu zKmY~)B1P1L%~01dm`Z<=ro zOm+v6=77U@|2nsK;FDj;&&+>(iHLFN1g#Y3B%9Aft#6BNi!Ar@Zgf#I8%{FE(-hss zuiWE{xB zX*2dkQDfd&9;cC%n^Wc|S!2rcFTuekFZ^>>N`BP0a(PjeQ#*z)#rN<~e)lPE@267^ zQ|aNDf+9!JbY6)cQkzw7@x9KkHcZETYZosL%$M8kEEQ-90%orx{`J(DHup>U6Nh}w znl5)(gre=`*EtjQtbvQGQ=#z<7C_D1uw(eT%^5y^pEXQfcW`H{6-$<> z{Bpz^wt1SSrdjm1V5)%wwItm3W(y%Nyu3$En|hWPJdqcdH%Xch9mGi`HAOE5zjq@I@fqF zSk#6FiEO&H8hmmCt_T|4zojS|8obTLbNC7N30AD^4pZ_>yv41p#U^OX!M7^BU{esW zH};t5AI;Q$bA=8^HfsUyolY!o+n(5mU6`meBI3HLJ~^;-Z1dqBhQg9LzuRf|nA|1q zZmmSCzI57yc#&QcLs%!mko?<=npGe#Y{RBgVF>pl87)S=TPI}l4 zP6MS+FfAhP9UF^TWzq!*gM{Qg3B{wqGdi!RJ<;lGn|XIctAOJR%?0qy4^agsX`z^3 zU%hpg^}$(%-fGO43!>Zn)GT$a_()dVDt`XL#3Cmr1yd+TZC_s!l+9G;;LIY@s`kj; zyz)*$n*0eegOyNy%7#k}(2iCi5r$DG2GXsPU&GsqEUX+7Ab`Ux)2^%^R!e{Hm0=42~V?|LL;%8UNLUg*p@0b8>Z@5~I|7areCv{8LLi5-8*?)?@JGD44tR zC=A_Tfy^1KDeftiVhrbeu?wC}?OqpGF>IU!b(+`ki=`ry?v4_$)v zF*9Ah-&#<$N^(StkZ+D^)*!Wbb)gLU6}@oh*sue47Y4j>?5Pft2 zdr53v33qpA;UcfAyeWTJpv9)lD5k?hEHx7OqUgrZg5Ie@6~}mHl*L#6<7|K11{2MX z?jZvW(ctf%C6rAc2Fa-;_L}2Pgou-cE#fE`++_PenP9bMGxSkG`W5M^{${#gPMp`x zoQZwr7QUeT7gV!+W@(>TGJYm1veD!mBb9GVvt{0szPg^rVx+aMm)7V%jp(;4f^z}gM`qZ~{Bwhdv z8Itm68svrTiB5E4Jr0BSJV=PJV>>U^oRi->dbtC$*aMP3zD(Nx78I7VRp8pARC)tb;n8r%*xX1!8jd49VuZUq0_aH=JiFDRl z8AenFv-W#k542l?T4Yt#*OPZ17_KvY(rBBC*V~=bz1L@uAQ$=Cgx8Pj*7sDS|pu8_x_Ii8=NA8HN%_bh<&^I=B>I_=|#B0!fd{45IjG7|Ruejh#} zX#-FIHc(I;rJ6d13yz{aRGBRQA}SoJ!hK#@!h$I%=?-6DDrpZTUQ4;o2+5}GT~k?x zY~+Y96y*91k~!M_7H0IceIUvBTc0@Rd!%D3BCtwMtV~4Sc%5(OMeH2SFN^=qDe%VX z7)cbo-;>zdUoupH8z1$1h@F(|(Yx8{l@X)uj873oPw?ec+U)%cuL?!^NrmfJ?=>qm zm}<6sTa8fzsaC>!{ZweU(LYIUXUQ$+9|)?W0(D{}WP=0^6F%zG7_SJ;&OSR)ZUzjyHux{q^falN2 zH&gv)1jf$u7pu#7CI3T~3pUeHdW}%+>kCWb*U4WS@CgQQ{7)&q=k^>OG;Ur@!fS|6 zbgNO?$sSlhMBLh$%Gj+r>4oq(6I&mOAkz^XI7&HooJ$cAyhBYoRGCeXtpMDJPNZ*8 zI||e*O1`#^@<{AFURsMx%wc{>15;bv5mjQT)zW55#(`Uq)PfSrJXqn^d-)_Kg|00u z<@LX)#nzkYdQ`%D@fSONzOp!*E{sgy4`FyHde|Ma;p)*BTtaq->O}0Y}Ud#uvk_mcJ!=VczE@hSBGF@#P8q#rbj~L6?3+FdYGvYn&tN13GzwgBFs}w zWhoLez+^1v@FLF5jt#tYwTuAqM4IkGSP>YYK-uktomv|d6A8K z7dE|jZ(*qszJpdu7`d25K>Fc}4=-P?j8Em2DFEvzC}X4TM&JB#ZnEBw%N&5nW_+Uz zNnmVC?%V|v0zNd zv+H6OliZyvY<-gsCf~}1tCF0Oq@+K3g7Rdw#hv!Y8tw&t^}pkgO3h|uc^?}rlm63! z&8~DI-`BhkjH-_X6*-2&)#D>YlAS0ZyL4sMpd*Pqg^h@(*qEleT;TTTZk)p>tr#d{ zPsVz5Mre|iIk8OAbN%m%cdt|0OJlylQsE@e1Z4RG)#j9J`^uhtWrRuO$HE^cj`PY{ zfy(0#>=lI{*mqa?FGBy6;GB(>h;h0`xU*>xEnDcGD4yE44>W%EQ(YjtKS-pygjk4N z%ZUd9341#R94{eNF@SiI{NZGH@tpd&fuF!yLcK=8`cmDEly%bHhP_9f+sWC2Tr7I&j(fZ>thY=Vn|uztQFb^t`4oZ{T(;pu(<*b8z!2I zU`8SxRcPV0Vk#Sz*xJ`bJou0@RkmFXI7BF|#fZBsd$3SZOEtO`SiCXJ^toTOPt6FTLp08xM zew0O-VrV&b5Gu+ISBzfI#SgU>_K-SHOXq(gfK$!=?1W^D(3H0fB&M^%mE{w=Pl)df za6WE+T>aJ-8)9gO64b~>IL`Nh{9SYQ5GqLY+h#`Cm`-3+A@{g`w-}ZuZ%!TYZZOWP zE9mImPQ=QylR?^>-~?AD#dsph?FHW(N%bghbK}*4xSoLMhcL+CpOh+nv6pb#R8zp| zouTcG*f0eH(`eS>0b^_xNhM611E(-MdFVtKXt)8x8^T{~FPSJ@{wks{mEhg$Io;Kf zb=6d$;KmlTr@vCX=fRk!uE%vpnd%!9k$F`6`10js;*=pbauBf*3(tE7PU(pi-F+x z67q!L(#BR<#0qP6kV-obuOSdsYTIki$Ui|!X{ET2D1<&*da-gtbm@wkb&C0&65kj` z?xa<5x$##Fp*afelm-V?KcCG0WjeiGdc%+_LCfq{*yW@qTh-%0H^K?cj!;X$uhpf* zl4ac{frx%I8CoNO=Bz?3u|6Z6@`%fxI7MvjOx>0tvb7lwvUW8ur!wrwa>PXb58 zTrm$qsCQsB;q;-c$~EHHJ}w%u&}>6tbmASzM9`WTRwbZEq!GX|PIuT9 z(oo_?xE4si;sGf=frX{ua4{~M@7Nq!d%~OdGm6}1wThgQ)#5e#bG7*=I3x8A5jZ*! zQ>yir#PzW27(RaQ1YqaEK{^TrLbkPdjm)M}!&`S)^->>i{p3U>(#FBexY&d$&x^hkuT>l8BUX4j90Db~Unn1^pYzq!6dSj7BF^cwS1~oY`#+*UY^=77p8$GQ z>e_5%kJ~EsB~0- zAi+sQY^e!bIThvf1f$!|KM4M>BEWuPqo%!(TLc!kI>!8cn6beq;dZ|X8*74`so82vJX%cW%!vCvgWJRpnZ?)EE7fa)>INh|;- z1RRu8ia@WrQq5{{DaYd&Z_D#ESzr?d&x^z{*DaKOfuni9g6T1oFNaRiyl>K}FdPLM zkLU)_ov1YEJ|lko!kVz*pE>Md?4eK#lH-T;&@4Yn3m*p_8^pMZbZVBz^6n{x?<(rTj#p9>3c)|-O z^vD3;8;%C+^ll$Sr}wWa5cg+FOXje-T#cUe9+l%3pS`4UE#9eiz2SPPX4l%F;dTo0 znRES=zUngT*GX^WcfILPr(rF64R08GS7kOi-eq7xBPSP8g0E|@bn|cT^=N#6Q?K5g z3j|bSu0N~(pP=gZM;h|=w{BRLGmzyKITtz_4<)oqbYzAFD*i_55=atqb<%qT0)s@Y z!@6h_SwXiT1H;b+Y`)ki4vkK?8ejc_(HP)Yf}piij3QtlwOX_Y)4&No!_g(j>Z zhZ)9UAViL6x~{6>hZVfUTX|k9XZFz-tAA!dQz5caeq151P*ET?+A;6rH2 z(^dP^8@MeD|MQ5)kaa4*t7s2bK6oo>Zt^Ck_k(TSn9Z2M%Pu-3L4j_&fRtg2>_hp^ zV>Z_LMwa!-%VM^)mooJVPfl=z?>WmJ+zd`ai2V@wz&TGiducI(RrrwziOg zkKnEOUop-n&LhA9^d%H`VzO9jsNX1mz5_p2#&h6Wfw}O6%pp2$11OKWU+cBpio3y9 zHR=x6zCRgRrjm%+fUiGzgHc|-8vo(es)seKbM66e?b?lhfgv}TN%YJaT@t1F)~R=% zYAmdkkq7MDWsCc=s_Yd7_-n(yxnvcm&25O{)Ilu{PfD;`u4 z@zYekSi!|l*5W*aiU6@+;&){Vgx{Yxsm2ZrtWJtGWc=py?CN!rrYxxjow3#o~U7%ombJ(25+uZEe5ry=tLB@v}{c8yItDptd ziRQ(tLtj_sW9%V`ibN1|JVA3anFCr`mcY{rjIzgX}E(eA{$86Xr07hQP ziJ31Ka{w}vbrft))EZbmg;X21V!7RT)UQ0x%TnburEM>;N5#it@1M^ZeDpBZY%6sE z??W1HNH@YcG#+14kvzH60B;UV)2S|dB?jK&6oh}>7Xr}+K6s0ITqFU6Ri~jaBXl|S zWIFvz_oZ^y^vLDJmHzCp^VOb!IWk~A5$0BbP0D}y!1PW9BtcXn^6q~T3HHaI>2a?9 z);Nup5$iO|$T2Gak&0(1R+;G6WQ|LQUP?4O-32e*17da6?4;k^`plwNdAcyqRFwGem`@48-)e=&#ZaNa-Zz zznB@*kh~T|9Yo@@c&ED41@XY*mi%c*2rahu!xet-!Ly`Z{lZdS5&@lht#rjVZ>OM= zF{p$U4pa@AXq)g(pl~c083}C|rUFeUf3F#+g@-|c79lb@=mz(d(d&<#ZlDD(B7Dp1 z-txqBKn5yaezy*Kc@_c^%o1DH66qFhzk#zmIAh-cykS)lu_&S zBW=wH;O1hx;l%*|WN{ZpO^gvb*#C&*rVDeuzFCetf;6{F-9_2>@o%MLO73pRZ&!Dj*I9Gb zt}3hX8{LSiALIpbEAbuvpX^CBpre^2B;J?nK!LLjdS_?v1kDq{f;-Kbsqrav+D1Zd z$ixWc5$i;W1m!x^ID3UIj%!^In=crdl#si(6NruG))qIm7<63`L)rJU*MRtg%TL{*rxNq>BTm1 z33EP}F@0S|t+?sH57`kpL`t{#u3-|armeY80yvxfRBYr-ZF{X3$iG913xtn!LCU^<4Np56Iy zX(yo-JG3*_Wpp$DU;1F9)&&MmfF!(779V2eHtFIFclUQY*WMkm-qfYn;oA&M>AAVP zwKn+}%&oqURr&Q^=4WZAuDiFDIZPLpPKiAv~QVUo5Rz% z`lbbc$%x}8!*s1zFPi^GZIgBl9^=s34E%5p%<$dJGhy;eVXVU^dHNr_09}KoL?hNP zOhRTnA4xa(%9h?UXb5OsgpkF952r<5?Xlq@V_>gKS(&Z2iMr>xg{n$EV^olR;MM4=!aqrzd_?!Im09AiyNdan#6JRpfM z%L14s?44ScgKxZ8kZn7e)ZJPFF3em!4z!p&YCT<^g->KY(0uboE&zGxbvj)p@SFa~ zXAP*);uJkWuGIYW_M(5}<-wE=XwePZ9O9vR|AN~qG_mkd0(x!_;Yau$?$^BH!yY%>$7*JscVPl0cM(CKD9E)Abbiig!{yY@ejOe zDUokZwZ7@PZMnzE6rI%eYFRTplk(}vdPDe!PWTi(ij$I|?y39R+X*(CEcI^Vppy4B z9Z;e5w12(?UIGi+!}B@kLZXYB`j8#E>ML}}B`$P=$D)KTckv^P*XWT*{@5tJl)j<# z1%r$==V!|!Pb!uz@^fvzZNrUR=LmdY2)Ql!&vS^4 zv4B76Cn=OQ`Fs zPs{HJ>>#ff+|x7@Nvul|GV?y~PhxBq(-&(;o2w|jyX#la{_qwYF~o;QoO8He~QaHlPr=qJjOLDU4BV@Hi;nM>^*+} zTJSc2LSe9JVJJ}&H`TryxAVkSZC0&S7gZ;jW>aB_!$?PT6SfXZOzwk;*#lpJ*a)QZ zURD(sH`QBB<(Y~syX&~SOV%}{>&J3U@Wh71&+{0yx0h!(B%9~kYWAsH8=K^gC%#e_ zd{4C^rq^9Lx|)K^@qdF^{hDUuYutuonnkl35%nfHriNESU;E1$GJ`%(9Zi%122LSh zGR6^&$Ha!AIf?>uu1i^Ykejvp-6f>&%W-_x`6Td17L;6lAdvno;a4FxddKzV;HjT?5y z)iF2auJ4?!9~zI~vV<5b!6fBl_XoJ$*R|jZli+m!GNG|mOlPA<%<|Tt1#>1(>x)h* z$<2wsd*P(mtqNQk{CHE<)v8stO-teg*@Q4>Y zysD^=9pMe2;R%Ka*p7c`uAzvIWKNmS({Y;(y6c+-<(V>f8yNDXSTs=Lwpp$Hd4%K& z|0_BP-$T}o{%|e+fCXP5=u%NR*|Jlal>@_~PUh?qiskpea4I5~L#Qo_X>J0I^G~y+ z?D|OeWW{`s*1T4tc!h3bA@X*{`A@pS_kQ@B5gGf83;s+Bz2S-KCEq3w`{PXU%1F)g zo8Pz6{y}T3XTHdkitHB9RIH~Y|^DP2Xb$DYJ z7EESR3)~BMj_yRz_2;GnNUPlrs~~WbP66mOceTSFlnYkr5nLtJs%kyb`SBvAa#HtY zjh(QaYwRzH#^K~T&(n($SY`Wm`PMcB!`BA8+>`Dpv$-&8K~D~^h6t}9y?=VX*{;zh z_aFVuOQ!!GCe~c{WA+*!NRrJS`l>U^KF-?|v%5GsnI8-Ij7Y4SLT|i2SP=1FCV*&p z`0rRXq$59n-Pbu^f=! zOV(^)bH2{e(*K+EBvk@5i8TNTn{sdZm1^x5B~PI#FlGY-)<2kvqF(u7@7ux>L`l-I z*06CCEErL`X~Ztbuu86lXDMWh(Uo|E(HE!sm(|?$U#$Z3nvON69=Zp#%FehLv^d>t z(vPQ4@H`$Z7&WOb)xTt?ie-Ih8s*y@Q93>y&m0$MIf<#Jm)2s|!4uRVAL^V~Lkb)r zZ@Ln5iT^e2!n$))OfC7pwRo8N?N}{ljxtE{#4*X-rie0l(4D1WyG+UQ%d9*1yV)ad ze?-yy$=?x>oEhskdq1rRy5}}3uZ8=s3)*PIklm1#fS|8o^gvy%;?<>bh_XT474`)Y zWfZV&Hl~$(F`eW*njJB9U;gyX_YLrA6jkj*+gm?&s_t2Nv^8iA6SeY^LPU0rI#m>4 zAF$n@8HD8sDI0#?mM$dk^Z9Oam-gs|_%rzv&E`Ls_C~o$s@-~ejSjrU6151duLgwh z=L;Lh1pT2*?Bkdb#!j}Iv=V=_D}74^MxS88w`Sl{Z0V)4i6!o~pxNh0NozzHZSoXH zpg`Az>Btr!NO%6b+M<8C6sVqQC{=P)lTL94T3GOT?0IN@?LJ)I7=4xtO*`8bZ;x&F8+Q$i}zbg z5UI<@EiEqQ1iaXLwYB!XJw3yJD-%T<1ukXe`y zM)#4N#19%zNi3eUR;UWas{RMoz9lmoSZ&@Z9f+F1k6;dV>(`{9=-QysBwE z!fyojWp1TDN^BbA$-Q`#>YtnAJZ7r1&$ZP|1{||nv_JKJxG6s^+r?UX#DgWe2kQtOiDJ$9 zhy59b%b$?dr!m8xqwyYgK!1bzm~r+2sbSRTEVx`!winuL8vPttoPHYz{l=(ZD<4-F2QVsmOsd?yVT4Qz%oAES8$en zP1!C~8S=lL?OJo2jJ)gABi z8Xr-$FD)@wblzKY={)~h$fDFQakB9FjZ@cM!-$y5!w3-r;#F@isnsrU(Ag76T(xM+ zW-{X&(H;qTTNYC16T4S%<9I2W9VS>gogIKKbe$xV@8g)OnG5nJeZB0jxCqF=QE>u? zX9;jX@q_=_{FWK~!o-gm3TfDH@t7Dh`tV|CaO=^5UlVd43ixV3vre1f&_;vRgXaE- z$#W5u$d7jkAVSc`s+q-=a1&QS0Eq9J;a2QKp>z*t!ebdiG}p|^%EA6ua=o-ieOH{5 zz9)ZtOuYCT{112{;FcY_iX22i`x8)Ei;b0+uisrhQJ}y#rwiG!(%$Cqx^+8xjJmH4 z?K#gWDyz3;qh89d_y?g)#x(^$j6jj^vUUDuzhQJQ?{Cr=7kU@1o;V|R6I`&V=XM8I z2vg_P$a}F7>B=Y7;OZb%tO2^>@HQ?5$F>@^8@Ps4O(O4ybdVV_E7+@BJ}C9jK-;b) zPOYdqSkckNumIC_!kx&fNMgUXq+^dP_{+Kz*IlJynb-WZIas);zjIUqd`8Z-gbC(p zw>?3C@H!kUiI)?=>|W$@=sVPHj=`}Di{W3eSL*en{V!dJttSr<{lLvP`FARt7X8eV(J<$sDb56i=x_3iNn{F}c_;hc?xR*nEtaie8!XA| z2~5u2oOC)FQ%mRdljGc_#Kq&B^U@zL=N_GJSCo8&ViEdt&gaebzr&e|uo&VZP-_YM zunaT8aFRj0(Bv;#S&#J*!AGvQ!iIxRti=G|w@&w-NPL;yA$NN4-g_WDCLR_X)#WFL z%QW`oeiBpt><0K^(%w=keL98jL8@T&n1;~ZF)$P`eh1mb?Y&&=dAp+W-ctre>DZ_T z-S&lLeS#>bA+_&(34<<-5!qk*J#?jj&% zY0^J3v47w+T3AyH-}Mt4`BUwFa_HT?pG~hEg)%CFcK60&Et+T^ygM05|++b_OLGM=_{!s95_77jQe z+PpbFPb}@eNq?tfIvMb9T|5Ftz1t-H(aRxIj!^L2BsTk`9LZ=8&$b?@GE$3hVcT!w zePeLn080q+qaaK|x>XH;_=vKh0_Cc%_>&FOJfu8j^E+ru^RX`$?<<*>SpcBZ^_o%9 zFF7DTzGm}-_dK_I?I4fy_wS~<3BcvjoSM_qm4aE<&nQp7VhqhR*+_!t5qBTIv+`=L z|Cz-pU`B^l>Gt-|oj9aV1Ly;YU+ zTp5U<)#u+!qdWY=DEA14Io64@`r9MJyfzeGQP%wv0PD64H9Ud{TJ>Ahx^h`g3Nc(|P8TLT>Djm|MU`1pjKfwvQc}ohX=D+D#lA%ooWVLRQbNQ;RJ_XnoXul#CPK1Uzf7k9bZ*;q*h^A3=M& z1zd(9+7=g!=G4i2<)&iK!IQaQcc@$<}~A3qNX?3?PH99D2=UIBrS)d~?v|e9(5DM!xg=GK+SAyFyplkhVj$1)2vcG#p=jAzTd>tjVaAK z;`xM54T@6Orll=p@^w%W*Wn-HM72#;YlJ~4Ln$?oxpj3Y9c&2{w})-GX~5W>$Ccv$ zORHW3Hy}l6-(-5S;C4+mYfKPO)FWIn|2BFrm<|WDANW!Ws1@}ii2LEuk4!#Y_g#{Z zcCu@=!1jZxz5|$ms<=eK^#hKoPa3M!nKAqF2Rdy_aDPjNtS4S+e8+VVr0xZJ1D?2l ziNQqoifp6ps?OU)F>60u8xVaj#}u=!lKm7`yx1%Z;-$}f`}_9G2dj2spBw0p^(#KN zxcK?*4?AOC&EB(uduMrpkozM6nJnA+LQ?zjhl7tGYjLmXKbR`9Y4O{(KcQ2=f{R3n z;D)Y_Xgl)B;a$xx;iIJ}8wKsG;I+;vuzzQKp-3C!R;W}2BF^SGAHCQrF29)66)hHS z{3+3PG$hF2n>9s0lF$5hJjg}Hfn?=^m+f;E{blPhe5rkN!MUpQO7)wr@7WUU)z>&;fgY_$LNc}MJCGg7JpVhg=qOx-_X$CB*ABE;G}Q) zDbKh5Iu|F#{>?%*8}C=R_bhkSC_*Lj@+qv|sb6jnVC$oG{ESIL?Zmp1^+!zYQeK!I zBMYiF3H{QsPdJYuKDHL`;4jBMB;Gx%NieLP5|#nvSl$jO55?qj&L+NkSk<)ex0Une zK)d2otwc^8=iX0`jg!OdhzAKdic@<{zdq7NbJJw-1qaEST+wbYuoXva#PIr4c2@s( zT1p;;9w-Pii*$F)Y^ecwU-vUVUtmoblc0~j!(p)X5Gw+pFAkDMrm)=caPe0e0TlIJ z^4VID^$qO33pZ*G+AH)LDNjT zIkpkLohRf+_=}T{7?g6~b2goivF`<6qU}l&OgrLz8@?@-KA>StM{0gI6mEC8K2!|r zx$!&wu)B=7op1e!joC_OPP@I>Lqr2UBJp?=x1DvZwIZw{FJ(*rnIvH7)p5h}tx~i( zxLje>7izjH`aK$VQKU-u65Q+!`O@8BcwEX?1q`z@nlSu+e7EhvRZMV&;4&BQT?Y{^ zz3E^WLdOhvqHVWg;Cl3aenSPRtmaMEB zm2+-8olz|#wfp7IDnA^sJ)mD~v-CnzCuVHCRJq9|;1~i$|0V}3eXl5h=boG>2u3po z3h@B3&?}+cBmsKSIcy7)sJ=)HbMc4VP z(TL0CS)!8rAId-9!KPJP^wF3-T--Y*XOWP4^t_Ykd%x`2ob8q-w54SM(KPoY#_RZ= zjM*z_^(xtxM*>Fiv-miTnRQU_7{T-z@e+VXOPp)omVbm+c%Sp;iTGhZIp)je>*H@S zCAuQbT<5w4J?IFnzyKTli!_8HK-$F!Q+X!2(L4B8aB8K+)RkT|y+>;EXJS7>pQpoS zfJPVzCk0;zIk!pU?HlGIsY(4-k2Y3@u#Fha$+c6_MnDen;PCsAGjO{9>lW4qTs%8@ zxuCb(q5Hge{4gVfoS{6P!Jpsk3!Qg`9N#o^fJvavotl8s=QWv@wU&}(mQ2b0t2h4| z`)tg~MA>|d>tBS1mp&6*EvlfPSgC789}Slkdmo&Pyx0F`hJ^+k_D{(msEprY7oL4p z+=ByHryu!YZH4zCVXqlTS#t4yx4Buk=1YayW;nO zsnxTT&+z@0`U}R25(t3IJOL*TJ4+}FabFx$32{0)WYGBwtz!T2p?Oa3YFcs4I_m2R zP$Q{P(N3%MySMh)g6lSWaQ|{x)8^YWdNox_W8MkV}wjK)=?iru(a^-bk0> zH(>aN&dh;q&^MbAX$NlD1Sy6J%9^By)CzcjMww0-j?n$U4m`$%Lcp}Rn`OA)z+GZ@ z;(xndKY)5*3z?wcCP0KH*PKES_dVJ)gQNahWqq!lI}^JU6l)-h%?(t}+lw+O9|KoEdQX>P zb4>nab^Np$bBal(~k5{h6ODHB~;(7%gVyHyC2e0?h?gsAmbaY=4~oaf?W zp#aLmC1kPOU0|gZXt(T;FJ>&TS!y?zLc_m^P@9;J!TX`>c@Il3wxQ2uv z=0z&(U7HeuCvBN+n0RKC{$@Zh(BoqhC_Gc(sQ9_4YJm8`V{RAOxh@bb7$Z zr!nvE-=&FxQ6^hm!NzuzRTFa{`=y?a+;JA7OLlopFUuJ&@UYtzxaIbshw_DRcx&LPO z|55el@ld_-`#2u5WlOR|OuHgv%WgtiY!i}7j3o(0l6^UrQ1+!FVv?O~*$NrEkXSu{^?wTy>dU6&F8Us76K| z;V-zt|0;%4`afgte*V)1BJC)Ery8jGtRKwHDtTmpE#-g2?;^NS$I>nr7*gmM=k@T@|uj$^w z3kMqBT>F&O@z0j|QFt5W<)pMsd)-++T6lU)fa`SDodgZveHm-bgDzaY{I$HSSarSX zot?V`-{HKtkorDJXtfw?Rl7J@HzXlhnz}z`schj^dz|^${AWSU9OEDpxB87g3M@Ep zgc=Knp8T!v0yLXT#CRQU-btbe9R`1&Ui8EP(QT#fgTl@w?c_mbS zhF1Do=h_bnfo2Y0AEUOnns%pYfyaFeeY0kq=G+DeDnLr$IPuTX8N1uo`;wBo@ndQl z$lcL6d}?(kI6*J9!@b}R~}=i zw)QntlLI+6;Wd#IzNYq~d;v+d?U*Pq%_}SgXj_m(lGGBDz|&$I#LJJ+wGYBO?3(PoaSY-v)JT$70<9sfC)vZAh8 z-k@gXgmHM=kIv;3oQt9va4w!`v^TsDI3usd z=2ihQ!EArP_G0rwUUpuYq*VE(!H^F8--8kA#%WIr`SOy$pN%bD70?Bpsk`c##H?o!xWBx>pQ z=YDd3kVxQPzSXL#r8R^-9KL<&zu6HiW?V(DFpVK9`bN@ ztOIPYFYqR8s!FVW6)M!SpDNb@9ET*n652a3u@p>a;%*firx|x#-DP&Vjiz&@+YH-Up@#ED9G0TcQOBy-m^$ z0iKa50V9ixLQE{&2DBaRh4;SOORqEP0MKx&6w>YCzNo&yU0>mGuO032>+KML@w~6P z#ut%e22c75-`TC7MiLsCmN&bl^^<2H4w;6#G0>v{EWBF*ceuTj(YB@Z@c=j8Un>k| zKg0og@6UuT#)69A(M~zoVNhEG?Q3V4&kD=|LcnyD;^S(IlNUG zU~lpPW!Rg{IWAq3*z}S6^X2~CskayS&%M<=j>tWxVnz6wWdkTf7^4wOFUyQN*B=nvb7Zh^|1rG#yp=a1 z4>Lh4ZVELS9YGP`$pzC=*EDD31R`cxLDs)Q`KkYvEWk>_6YW=Z{t-# zc)E^pY3=*?bUQX?2m@_i5spshw(?%~(NN;aR$E4?8f3_+a3KO`w2|2JuRJrIM~B?T zgUwUR-|(A@In?Vn^xpcw%lPZ#l8Mopa|k zVeALnnEuxraeD146)pGP=FS+5FD3{%u!7&*5%vnRM!p;r5rpSReQc`Ehn%l|4n^2b z&tPf!8IVMS3p|dO0Rj(TpVfp<&au%p$GoH|mmky!)A5dYmqk%H4Zf3g?-G1w1Wuk{Fti7w zE*IavDM(I&Y7WpOHxVS(_Z1-fW-9a(a7_dSnfCu!z$&$Y)wE#7cFg<{;C?ke%!?zg zY&6*Gj>w3~55XkC@!$hj`LH!z>KsoNviKv|6EebM5fXQh1TQLhHnbPEUJ2 z!KW{C?%j8~5r=qlMAiU^Z-SjL0?*BIpL}}q_YIaWR*j)Zd2%rVD`@$`SXlk5 z;&NN zS(J46(9-6y+kBZELQ?kW*0=4i0e+{6~Tw2y{M_ZrW*Jo%7M zn131OVVf6(l1@n%B@(cDwD-C=QoGV%X2kKx2iI%2O6rCJUzy4n&@3m22U_d{iLRga z!d-DEiYqrI4v8blb9@x$CJJeVmA|e5aCuz5)k{}slyRYjd491RCiiIuP;FT!k5nv{ zF07xq|3V3LVPehhF8OMLNuuPjdj2gwnh-7Tf2|FhW-{_Ef`JZ8j4GLAB&4lDo8qe9 z25L5w){WA2xxsJ)Qax^~uMv1$bx?6AH&15DoDb$UlxE6~+fVM5me*whx@?bX)dBxW z|GB4?9f-jY3=ZxwqJbCUfaN?|UA$yp`fxz%!JQM3@jEZ5 zMX*c%F9vpz-*vLv_vrUBClP8ZNs&T~mmJ$|^SM>rZGys+Le(COH^2M*l2d$l%PY3! zk-e<%E44BUCzeBBYDGi|kyTS9c#6Fbhr4lVN;q};t`e4)QJp-TRN&RU3&XTzNivf3 zF?8^ctlbOf6aK*mBjnRL?diU+ZnHxScW-nlc>Yv@XGG)9?U>(9p+jxBhVwy(FL?~K z3FeiaOd-)Y{sdHq13G)6i4Ei!*W4;Xj9)Is3J8G36EoI_#{1 z-}_r4_N7f8ftMf#_J89~*QMk8c;OFMrA)JDIh_~1u>CoJ($Y%f&QG*Wj95Y6f|HMxoP zSpHnmYlO$yz;|Eo%_p0dT78GzhCae5-Oi;z|2`=3J(^DU14Vx@`iP%yvS&njyn|Gs zB!-*Dxg(~a#(tpEeuCnEV%x0>Y|BPG*@mj~E_EFk&6*&iOWPpFhzV|G)Xd+7TG-nDee? zjy_^Yj+dw|m6Ispo^6jsJ%D#C3rA3gt)P|c%ln_=gA;XIF(QGx6_x9rV+AOd{#fM3 z*HKnuH;+GddptS%GIiOtlUq7|GmF_^kaM7QMlAvu>haCRoqQEuS1622S!6Z|%N)=z z;Cq{;=keeM89!EoPQBmI16>M?Eka|+Gx$2(PRX`uzqR4PYxr7`I^nfCS0^B>>5bEE z`$AD~W6fi*(+vT(jXIzSTV8FDjr_I6-T@dLFG@h|lBUj^CXi~Ey*Zk2Kq^U4wvroZC>EEU080V%uKJ>xQ~Oq8#RMo)aKNY%+0HC_j_PfWAh+*G9X%QL4f-G04Yev^bMF-6n8_CT5M z+liGOVt)A(Zwiuz*u|f@M=>LTUv7b516-53h6{W_l~;fFmdEl6P3$+Rh#*n#QkcI` zze@Xri1@Ha_Oo@vKdr&tDa-}yc2O0G55>|rVysto+y)l=m^7D!%IzLl3?bp4uDZr= z)UNhcb`=)o2dsYlt+3iaRW2*36T#Hf`auIhhWE6x?qJFj3Y^U7Ue^JbJC4$-od*6H z;bwP~tbl{ZKYAuN6fc|oXH9GNZ*f{P?$qnGd3J2}&TGsY z*+Nu{)4h9d{DhO+NHR@RwXz1DKK_Ge@*nQ^g}P+wFz@y3(hQ&6Z0eV8X%w0=n0y9W zEvd0e^?_%T^JaH$?pq7W;=QJ{yCLtj1AbG%u$L{HxgLIRiu+&9e90$Ow7s*)O>?ci z(0;wN?$p&1>)DUAk{%?{RVa9@R4Dy8?iMEr8 z%~(x3$^C&3>!b?L?BZA0+r--X>cFTfn4$Unp(-WC?)GkybE%>GEvy=CVnqXg({HUo zP3>y<^}qyDTUaj4d}M`KEoHJ}3PLgLq3!1g#=niif{u0w<@vk3 z?M#W>Jn_(9c*8SCnHyh!y@}(xNx*3iSIL;@R3NQUiymfy-2K~kX*<>7j3#=TOH!(g|oU1 zQsOl!191|ceLJ4kVqjCMjoTW#_=hXnxp^2Lmbz~ZGT&)~DoRJ2Fcufd5wB-;s(5i=k!GY%h!uPdd{EIaTKDfBlN{-te8VN(TB z_YlB|1%r~WG`#>LvC1GXO_;kA_nc}|GiD5?@Ce0=ebBJLyshJcHrqws0>n1K!8;ZW ztReyRgHLJLXkc%`D<|f%Z$#}F2i8itqF9QO(MRHv$hyBy_X-FMr^2%vB7wHYNR}bJ zzUV*;_)dhA@!@h5#QO0={`#zPDR`E$@1AW(-w#vy3;OkdaK!D7t0h!jxt-Ye7ltw% zz3x+B{(_=K27LW8Tdh$u~Z0@~vwx9aNM2DB!-*3Lq>9fC3lAeE%s21EK+LNE& zR$sVEJi88i-DILzbRYU8!o0Eta>4^|>G;o}j-eCD`yDbAgSUahFJUE$tJH@0$?9l+OXu40#ZzDna`<#iQ|4L`xAgul` zx=Rfq;ENpm6-C|V@)Xw2CO=4QpyALiglF{A!|jW)b@v<7@OzClGs)SstudM@FXvoe zuZHU407*qU)^eaQ7z>Yt(lzPJSu@AHfsB`|#x3nJC1=M2rkC?DF1M{qc4?MG;Lr9z z)nXsw(9Sn%{0seKK~ZkRxaYh{Cx+GXL$7?)blUN8M_e?s;Et{K4rI3ESH6lUd?!+F zKuQ~%8HDl70`f=n!}AY&H(7m;j{oqs-5<9^Jv2+?*FP`R$<6)0{bT3395+W;WAF9n zwz4)dNPG>rftvY<{C}bi>G1(ZRGfYUq~Z8unK!C6ibylxg&aGAP&}kR43037QExJe zsH>d}M}J5|(*lwVeIHLRjjnz!rU=5iEMYfcfg4#ef#fV_T6)w&hn^2__2}?o=g2O8 z;0wKJbZTt*Mv^V>B7(a8_ktz81#xHMxaXhgWkKv$hQ3$lpTE}i@`1!LAz?o{I<)7$ zmHsrWTdU`|+>~AJuwdxkRVq9Z*iVMIG`xzMH-{tG1!VWLT!wGzRg7+g;#ic}u1T!l&i%t+ zEYEKIQTuqmb_1_P;d3j4uwBl|r~&V|ooARW-uvWCe70Fhwc@bE*-5s4h3a^EgX!#F=}d)O2v3*HMx7o?jrlIq$ZIDW>&j8|*I z-suwN7`lxt#}=*jnI)Kw9x6J;OS^!r8nc@0Ams_pE(Te_6}HQLiysjz%#Tr@2o zcb-7Xj)0+H7M-=}WB%}5%jWfv8&~&9ZKt??AoK1s{+}n)&wR6C7RdVW+K1gtTH0Rj z(yewU*yRimLWzRSA;?vPwRSt}Dq8O4i9z_GC65kK1UCpWM=a-puiX{lFdK^&kUl zDc8;4^~8~R8}KRI1n6Pu>-fR3%XiUzqj6&I2DAsnt=wm2M8zjQOY&UDI>VZ1Y~QK=veTme@W5FL+o0=l8>O(=^H>2% zi9>j~?CZ|I7#>y|@yvmCM$mB+L4M12>O`19A+*HZ@Uj{CEA)*BetWg^$1VbXw3=Zx z+xO0)$XddIGlVu@D1f?q)3gZPlNO5ZS>>B(*YOet;3uY^oXlMRwa?P?X5K~ywj&po zc-%FxGy!N`Q%pQF<}B__Ugh4Dmg3@C$`}rO$E~s5znzDK3i#;v$i=RfN|q#~AdIdy zLTU|{&6RL(xap*m#;zxqvGto!q%;&5_^Qc&&Q-S&z`{RU$X}9s4|L!q1m>?foCMe&u2p)mV#C5 z_h~}ojLS&wI#A5JU(3n;_dSQ4t=<;KDNd&?5v+K~QBh+G#LCe1bAs*A~*%K ztIUtlN_l~*B3JH9Mj7fbm8kiE-k~EUOy4(LJ(4XJy50aB<%I~SB=L7Le0Foq z{;%bHd7Rz?!&k4G$vlqbu*~AFSo%7U(;?`1^~7NN|C1T8VkvPjLr1?=B{|u4vI69f zxp9`BpYp?=8v5?ty3D0e5<^Jq=k-3}>_4D@^DC_%BLAgMRnwpDXDC)zJO#R|xv#$* zO}u%Py>5kT8woSv#xYi!uMchee0qtwcq*Dh5a6aGzF#z;r77A)Kj!PW)|sXP2EN+C zc!}B4Rrcjk%L{2PdNt#ltJ|djBw1XsYca@;@CHVm3096Viob73X+i~g9}-I%ZHJjP zAYumsW2B{VZi80aQ$3dJfC;!W;t^Rtx)rA;SWacKg>ULqYHAmL(sH}q^>2e$Dmd%7 z4_NPf%a~nAQ$!O>0lH-Im(4E6?&4~_P*xtJw5QeYR|td=EU3u!*8#JAFtF3r4IUG1 zuZ9{b)wZsQlvWEz=)$!6(H=dU_o$@}GT9m4?Xlk?em;T65>FKE2X?>vLp8G z7UgbljwcWGl|c7uXM5EKXHn`IzuTH8o@(Jw>V#)k{l2fiZsoh2)7pc2)RUTX`JRxx z>!#^vZyKuk#dYZJpO7>Kf(B%HH*8Mg;xQje`v-{O{kvo`im1cj^DmzJFWE!uM80~?l?$RV3a-XkXP(2cduR3&q~zdy6cu<*6D!+j`(MAxh8kUm z+VOwxw(0Et6{+JK@6OG6?gIxK#=D*md=^T2N{jAw|zb*H{Xw#a2F z?QKDO`b3pHTcQ5qRM5(yLj@e~+z-%nHMrV)ebf>o)iqyhNgRaab?E!yu^~Jcfuo0K zdDfvB36Xy-^46!Te|h)0buGhqHigW5Iqz1?I?CkK`6F+5U&|#t0g{BPv7Ov=yl4JX zsi=YpZjGS$r2#N+CeHN@q~f^b*niC??UvNQ=PjC`;*0*5sT4vBw|-un@Y_2rETRT& z%|p7$I(}5i<)89>da$3x#v{UQY-0gv*qhKF%s@;s?W7LU_i&h9T7O2AE<~*2xR_A=c3|l5RJVcNcg=V_BCj)>2HNG z##Ng3%Pv;sT+DG5In9UlMweaBcnE7?VY$9yc3qK)44>fqO7|u%zKKfS@>!(k$`K z(lvc~d$VacngIQiI`q}et_;3@2s_VRZ&xNn`bAj0v*urS!LxCmG+Q>6{B(un-b7x^ z%cO2dKJaD4DI;LJwl5nl*TLzd`4nzxAZyT=ZCDm&@xMEke%}lHMf{tHbn{p(u906T zsbm+}ZV-mt^QOz$l)tbeSwmwIOyRo|>gyKqW2|Z%UKEv*^le%xGeF(3$CO*R{SEdC zoRzG-9nkE}I0$!GYtaMK7iT+DjeBn|Bj$o*y>Bm_TDkB*U%cS{qEfT2T~B^XMQXm3 z$R)mjC6+^~3ldpdoA+B>$~4IV4iZwj9XiW12;aDN-bmc;Nafwg86YV@c4^6jIi|r08g-=QIQmf@*nScvijUgks zj~eEEhO(f&$CO@O+)Z5FT-|b*pjGstC>7|NDSEAOtS49GxRR!8XO6-%>k(0JC9Pr_ zdtEdvLCL+_=;y2p!`pzVCf3FG0I5G>@1P~hXzWSF)8z|s7VW5%7LuhZb*(f9wtkxS z)#h)@5TW_^P)nKQxQVh93D<|HpWEZP`0&=Z#L;c^)adl{_qZ>&`SR=bBVub zf`Yvd&#zNPd>`FwffA-)GM#k$gljXzvkN1#&e?EBP$&q?9yX8@Q^vpdLBikzz2WM__vI+}v zpHTByHNWB1 z+BA$W;N^8V^w0W6+#V%?pADK5@SQ|eI^jw;JfBykzYF?Sh;npu+%?6n{9L8p(S5La zp1FD@t^4j*(tD3I`*n#KCID1gISec0dpJaG)2|vr!6_0s(vLcz?gW>)?;Lbr*%d zB=f*|b5A6+g~U*4ARYqVzoB_Oy!cz@S{s?!{w?NF6oAZf@IFN(sQ@eDfZh&P4WuWY zlkj82-`)H@*^n*v?#paU>JQ{A<)IL%qpqN3@3V;&of{N^JOp~@R2Ar z#-YJXQ|DbbWv_MXH8%CNxU_DVd8oCI^5XUC6oZ}N}@ z!RZ$akO|uqhTyu*O3@!bK)>*)Y~ZeBvgeonFDimoufhAXA>eblHpp0wt){T|({ASJ z?d0YuW#!|T7;y98WZn{1#126%J#FNu=4ik^&hvt{z6H#n)&h4OpsWd0Clxe@mat!_ z;+wyq=^7~WToQ<{11JzUM9a|ZUUa4JA%FHkd`M!)jyvshg9je9uDSOOfulzOWEoFr z2|-8J%@UzM^a(5#Sh$|=tqQ25xBy~zOI*lVkrXr?8GIIJ%=Lr z@VI;`IcXOSZYFyn=)2eFqXQXfBA5^a1L5H~J`BK;IDDB(&z+B3gtAM>*`&16*{-qY zduo8_NFRM1^K0wSYdYXf+p7E7?d1$8UijlWHG$t`nZ%OjQM9cQzL7;Ni9{cFXjM8R zOV)<;c|57etY#J78;gliC0B)wzMP{YdStF*d4IXnYh41uU1c*Yd_LrxOTJE;R;BJcxC;2{ap_&}-SSAdV$e#{a+GmVI-mgWHUeXluo zur2e7(!^`Hs4lxom=I_;#%q$=3OWaN6NlD>{0cndCp4cQn?<};b9iv{^WtT&36H~C zjsooMPRnl(EkRQVl6gQTm6?PB?ZAZhZiCEQr71u=tz1=~=u2+*$QW zrOZ+&*T7fGGt(cH2u?&1Z$G_jjuM8NTVw3<;x&PTmGtt>qlJ{napNx&ss2_4*ZNw2 z7sSg>bf6Pz1gP!0LzhjzcsJFYNR_0-1`R5-RmGdK6hZb zkFF9h2)Y+M4>p$|r{l=!6Rxy&*EKOQu@~E&#L{yFu$V?`M*|)svx}>9Z8MlHXY}5~ z+2-B{?#8n`fnXne8&gVieB< zEo}DpLlRAK4EPR0V#76vYCO{yNB@JEptC}tpcgcW z0f*}P=%hG&AY71g8njo`C@ZDdD(bFR!|CqoXS_5jNW}4ZkJQW@;&^*x;VPWao0+8= zN)5%r#cIhL^>at#3zzeW_?4{SIOyvAqY z6`n>AWJuyP5qdDF4Sd3`_Y{jUf=nO>I5akm4It(0*^16U@4|#nJcQ30R6B4C+D(Z( zaMDgnv2lh`%JO%M{3BFJk}T_bT&8e4_C3%hU0E6jIPV!h z#{j(qnvv5OF1EfHz)D7cL4F;-&0+;bcSP7Z=!kc|HDKjRlC>=ARWUpxZT9eo!2-eJ zFp6S2BB&deupIkF)ng%Bd_$gMemBiME<^T*dY`$qSX(%02wVcfRF*}i`YM`mf(Uvo zkk(vZ`D~sm6vN|j{QL`-%}*Zd+4V-|lU-@ERJ~;L!h%&39s9A)dUt~C-seRk?T|EF zX}{GBK%Bzx7w3F<0GN=k%(sjOuHkOA-+C_9^uX;Tlv>z`j20ot>~Q8K_8%%O%$w^E zT6s^jn!>U((JDrnXx-`Uz%;tucs3k*Wc*+Xk@fU{_O6#~1R@+-WUp_xZN~~hcC;iS zuF1INHvJTDJ2t9NRbbSPhW_@N%=1=Wzw_$YuP3K#5@uY_K08Ixo0^N>Df?@PUeb}7 zLz7sy;!=P%T17ibr|=3d$N+@^m(kvuv>`t479Vb})W)J3p@v~|Z1VhcoOBAW<9x3S z>zu#+34-K+PMQ;;pv=Z=e#=Aaz-ek*xk;*B-T8*!S$E((n(usGx@p9Be;+h$@rAcW z{9DnfIEJnDMtD7eU!CGUr;?6+P))pa^}3|nZID-U;r8z>M*Pp5!7A<~p!SbSzVzo! zBb_mJ{(^$%8A#jt-RtoFc41wK-4NME5*(^SoOm3MPp&i5ijV7pZq1@GwsS{C)nus8 zLN8Qs9kYAmUYJzCNWOmZ+3`m7W*qeh&D=6G>lX2R86i+=4)%eLv5}RGmyBeeJ9UK7 zIG{hzB41|`WWsE>`I*^3c8JDA+Ka%~3qJ9vwx{U_ockktc|)fc$t9<`zM=3E!@qB!C!zPhvb~97-Til%$ z_u90qwL<~=xN^?`RtquJ6u+<>G~2}V&MWkZ zgD!_AT*g0)-+p00&4PlFQ>6*PH2E(FVbHd}Gn%mjh?%Y>8RnDPCKnmtn&~2Xq4p)z zPd_z0@JSeAhB`q#$Jco?*Z@;LZ+&PPa?u&UUbWMhMMpz9I(aHz?H{_WdQz=vLlQRw z!iea0>@f&6`6?>|PSbnrJ0~@ZbGse#p=wXP14*WLt7X&k(uW{PPWaqJfv2Ir8avt` z5?_)mBYf18I@WU1v&GnTDki>b?Ty?CVNJd-t-bg4AHC@DEve|O@af=r} z*PFjAU$TA1yr5ZccqdF!dTU3udrR~o^O=O_m6ghuzwZ~YoumQPB_KR@K&3G5MH~Z= zaRO{0kpdu!v>@GgON5*A_gwtSfwx^F!~r6cbJ5wBoaOU{d6#D-SFcG7>k34Q+pIr9 z2AEwQ$|my0I`aN~mruyz3%}3?r$oQ36+8^*rpJI*)IpwZfV7a@kXU_pG?!A6c4iiR z)??7}*nh9SkwuvC0J75|oaZFVERIJ>6 zd&|G$6%>5J=FAL4zves5Y8`?#mOTCX%(Xepoc*Q@jR{md$@lNmpSSpg)z0q6NS0m$ zT)$O)3WkECCVkN>P4v{XOs6l5?#{1qh zX34F}`}rlK`+Z^M@1IsHw*{R1nnzaj!%Gf!SvGtt!(=8l%u_fsV5K#nc(CT=C~j1# zw&G`OrPvTO{GbujJkwZ6?Hy{wc_Aq$J|Z>?p*rlNwKKHt5ksd{bWvvG>+`O^da3~& zIftr;`?TU@{xp+vtzocw&$N?W95AbKeK5A;n}LGMAzTQrUtx!V>jzYlQ6~&u=b1_7 zKBuPRLVZeTykNoIM(i)nY*~e4&aI5}gpBD}llxrsoGa(^1YX`YIC=`+bbRnjd=+T; zM%ij1vP|l&NFoe=dj)PmbVpJt$ZIgNtrS%wb|reLPB~fLvO8@@c|Ce zP1q?kpCA+W_rl{MAHCn${lL0PKzHAX(3|I?CdAZ(y|8Lxcxp`*J%_BchOo&A3#F4JBsVQZ{|zCsTKU>FA^D5_?>(#8c}kH zYeF!pRBiQ|W^r=tlSE5gClB%U`1LCPwHz;)YOng&MtYPQSWFa&wKNil}IY*V|G5vx?0)Z!4^b6E;`cSnW+g7PbF^hd|r)^cr6#d}LrWAPaVo^1gfS z1IR}-Abm}|c+RSc#<~Dpf^_Lu0-E?}?H`@-waMPJw`Al`03IFtX@3b9<)`~iZXxA* zLO~ud7{Vz+QcC()YydyMyz(Dg8{Vbt^%hmr;}Ubq{12~2=b)c9w({?aH$^599uX5t zdlF4AEHk?Pw&SowB6ZaPGqpqPFwz@y3o?i3YAN@ZW zF2)wloJO^4TmXK)n2nUisB{WmPN8r&bmo7a=t54eh_Q^jX<1CV~DUP!qLq4n@Y@uv@iku*E{qVhhwlwFTyQ|XOB%WDh>K8r(M{h^AB9_hZc3@$w;W2dc?>0a|(h)Si^zyBn~A&KY(U$67U5{#*Q_g2HoAi57SCp)~D-}Pt;?9dokyB zt}_XbIT>l*7eu^$nd+b@>x3V0k_w!>hez&NgmHG|eb_pNIedexTyslQ1M=VPi1R4! zhD9laKPA^y2lS#bcvlu3#O0C#(ETQ^?wD*OvRHr{7-T8A&4hIr^Ai{RvtQ^=w`Q$ z9{IPd|3jrDZ=R&dKM)2dDhD{(=?T^^uVulciW8d-`o4=Ff-V)`2t0G|4}s+1n{=X5 zb&f=syM{xX(`wi|P{D$)sx_@GrI(~n1gcD7WIZ$?KYr=u;)di#v&-pdNDk~FVJoi9 zUtU1ozL>bgBz$_P$p0b(kEr*UdJxT`wv$)(XClyv-Qa^c9rLn*KaSQxKofk|HtHU5 zu{(zHr2*6AI;BYWQ5Ku1YYC%;{*{w_UTKou`PRhk&4{(fg8qBOHN^t+v4al7L*=Y5 zOs|{Odz%h_uD1NZl!3o=7;-wBR{iCvLIu~;QGtJNE1Pr~m z$^oxJR}2FuDWBb=YJv@lx0R}rUizz-q>DeW*eM#nt5!&voLnSFhBnVzABHLqE5&@< ze&BXH>D zaEje8j(?v&2s!2YQ8xdp;aTV0feWJX!0~WJ3Hdwjc}Mg`UCQ`DR%pN0?9;a55muTU z^zw`PFKo^cC@R-cQ)Uahtw)Q^o||1T_?&l6HT|~9;!eWNUml|HUDp$|-v`keTMs;c zUro^{ZVy`gZ0H3r8F%UBpPvi^%q()v=MLd~Q&a-gQZ=(xF^k;~FpJ_JV=J}gP8q_0C<|R&90KOC zWoHSgbDoR+@yc}iHPPuRC(YR4=)n$#f9%8rkHnDh<`H+&_s_>_O}85x6Bxe$zrSciVY%Aw68jNc;Ae zJpNhW(Kcv0EY(ERTTdXGceG1|5i#3>42rY(jSD}K)}Ze6=`BG7ZEBy%6H9$&fWJhvK#QQtk-nY#4yx-WX_#DT2+`E42 zO{WnH$7^FAv0=&1=j2+6O-t^GB!geTS%%dR@{<9jS5D6k_3soov&fCQV=V*74&UB!^L*`jbjLBqd7L743CKm>aru%Sj8! zH~)wv0OS1qXxB)P)}GvOpTYxuBR$FVD2(XTE4<6MIfkygd%>wv#T-F( zYHNs*&ZeI|^!g->Oc{U}j_>in!S{2xIX)7qh8gabPHSkN`1|yvGCLHhy5GJVB8GJb z)p8e}d1(Qy^0%M$qg~D{p|-v5Yc(>`>7fHS9Bt4q49jTEV|-itIZ)KL#tco^IQHgx zD;q>Z7r0%HgNs2+XcJFFsbMGceIZCMT0)&a{n%-K)s7)(+-UIrLRZ98Tni%@&8xU4 zLZ~4K(9GAcV(+NoRicX zW<}oR+#Ie;X%4LjYX#}lZ1SEjOn?7MgPYU+&U7;3n|^Usn(n!XT-D{RNe?fe3!k0J z^5iW!X=;Zb0V2E|zv_iGm|p%~TC|d?VDoG5cTDY%s<`WhxSkGm%s(PZONp>}Z@(kJ z3Lo(|0iTJTr|mUVq=B{dCgGd0a-kAu?XGHktvzIt*%c)n6W}VD=AHInKIYx=`QX|# z?tkdltnd7zT%&0$+=7W)7gYZ@JqXcd)k<$>XZqLJrz&To8?osZuV1X!1?GyZ`B}Hk z6CG-JbX&+R->+qA0iTdV$3uij2ojQ!8PWx?ZWO8r-e2+S(`{+q6Qz^ZE>ctI`EY7;T>zsx>ty1t!mR@Q*~N{Po<)uld<<17ZR)nD&pkk)m%rG}M=4#gkaO zMNF6MSOEHsoz1n$-n7t%87rq}?~Zk$-ScEVcl3Vf*ph?}LjzyF7|MU5e?P*pm(d!& zkLc96Fk-ErKdTEz#EP%=iV~E{pYsgLDa()}XtUK1_DY&yYJKruP*w- zMAJS;T7k@OCAk`IB1SJynUd_-j)5qx9}kd)qLl?n|99ffynGsT1Z0NWwJc;mT3dU& zqoNe$>x`knX=K-=MEc=j>cY_ z2WO55>sDL3UraVPlghxq?)`V#Ilnk|;`E;b@PXx=a&} zS!@^IN#Qp2DVXT@iqSvE04XGg04-QWFMj$!7b9Jkc3qxbCOMy+yDEd4QiM7WTKPHf z@bFN0jS}BcPM`b#*m@IisNV2>Jo`@Bg;e$}*|TISA=&qxO7<*SqR`l7Pm=7U5<-%Y zW$dz(5Q(vigtCk==KS9?^!a|j|Lb=>*Ok#_mNVx)&wD@jeLv6f8go9}E|;%xj;1XSJQm*C zjZMZdB&yw|#7V*m`j!DELKkc)xvO}_h zv`^3Dw^uN6x0oJPy}pgVto3X5I^dlpaH@MFUup2K*qcz3jA*<5)DCs9H@3Q&g1T+j zXqcymt-KW)mETa|iuQah@SDDz^xf_f>z(|Xmjdtk5IYYqd?<-mV~M8iN%hO`-$|6% zalhyyqIu8yO=*9Xrx}H^-Ju=>B+Y;zrHx&ihERDU`?lggU8{RONe|I{Un=xlFNji& z6#8UqiIimB-iW_D`!fBpnc85Ahj-cU1=08M$|F`e${lG|3tI~ zti3@V02Z)k0H&Xt5vlBbwV9WjP%PAoJTFi=q4;`Rk3Sr8I_QSDJ3$ww)^%C6uQc!> z{DNqrMd>=Sc?C+(Z_8KpJ$08#;5*Ud9<*3+UUe)(dyHc{^S--BN~Ir3_BHVndPig$ zZ`chY-2HvPU252sGMFvxA|jj@GCxpM(@Fo8tR39bj1jVEWBHI0T>7`F&!_I$em&&$!VVvu-*censA4E z{!)J)9T0w#L-!IppLxP7?)y#%{s}4`&~YyiT0Z#*Bp-IaRpKCH7UhJ~((aG5h-d47 z*dduh|G*nKvxCc+$>Z5m&6sKu!;DVzW(?}v#kuKtBn|hZcsxrl>V(vJ=vtR5T?;Zr z^}{W^Nqb8+AC>|CW8Ip5+R*qzBtW1u(~i#vbdX~5^3AA&A3vI>O;LdtR3eo7r#wlQ zV+_YfxHV~_TUKf^1#RHZU{OZ~lk8|GX=Vq%-c0ZyjUdak>nH?UV!PO0F_ z8z7FNGCQ5EJd6uUixLL$%naDvWXG^anUt8n>VOsT3_gFX0jaaSUu>KgyjCL`Sn<5b zZb@ix^D~G2YV4GM`*xV(z5B{3XB9{kgEUX&^aOccG}?dIrvm2yl<#95{}&Md+0fWy zYBzN;(KEc4i18sPSK3s)_lK%umzH~bZp22z;^D>sLI%POf?_b*@4-odTPNhgJwvNfSb^Q4KR)@&i=9pk zXg#`{`Hf`cR*RoHy(ek;;@6)KjeDMf3WfyPG#yiVB_1;{pBtJqZ`T23yErlWv4w(d zYLXsp*C#Lk*4y>@xV@ZG=7TB1*Pe@z!PkmuDdtTz1yZ&uO-)B{By26VRPfkesh#fX zKZOo3ioHA#6$z0Zt14Y69@xr9Rw&BiTJKKN$T+`aUjnI&$31kXFy{(*_bFmr@aE7y zQR8jqg@{T;WTSWA+VsAN636imUnj<$!c$dFNLeiSy+_2o_C6?`{Ob29kLhW7Y33#_ zR+hk9MezT(^}(umBU8u2{M0_ErA2x+p%kT9=%UH-{K{)DrM%<6>*v4COxd~E zIQIiDzvI3ehN5Nww9Olz5L6^Bq8nE!Hr66?kj@mqlNf1DvNmE7etRN-^Y~xL>Z+7b znu(+AQ^_>D5<7cal&#$}5%t#2IL^>U!Htz)5(?|oB;xlQ!wQ}sM@~#HD@}ZXzH&9? zIwhebRPZ;#k=e{&@JiJ+dM|6*GWXRIs8jQlsFZAsODcxh912XxpA|U2k|okTJ&qMaNJ` z=0m!en_BkhW;agBKR)O@`+*6b0xNxy_rASrY=43MU)rtR_miZwvZ1?jGBYz6!+Hz3 zh=D|0K6Wi0wbH-r=4!seb-pGf~c z{W&RnlF&)b3%1DBkRIcaI|2btVH@&)j zB8Oi)1bMH;_Y!gP?7~1C@*$=_5bH;?FNkL|xyHGidw-jU1L_9fIqDDe)v^9O(3F5Q zQi>YpecKw;i}Bf7s-?a@JD*swKZ4r#t7)$oX!? z%E2v6t2pfHz$9Pmd;$A-`x$7B`DdP>yXG}rDQI1&Vqu>nWb7Q~CO4&<7fi9n>5 z^<(KO1d|4}Qqr#(^4=40$B*ih1YM2{lDXP~N>v-=>8IArT`rGkIkk$WU!Hb7XX_rgSZxnG0GD-;lQcXR0409?$4l2PoJ;TS4UMdD@N(njcmv2L5$M4DXaLVF~ zk3lvkylr94OZ47icCNoySS; z(S?y0RWEny1%M^xGwLS;Z`Vn4U`}n-|nt z9Ut*0jEiz=EXiGsrS)ZE!>qLzcW3FhfBhTc{-b^DUSF+Fx`&~oaW+qGCFkz%GxTPC zEwE@={-AT2*FX}KgG(GU>*Wt|m!&i&?n$W&hVY%J#@+28v;2Y|s5A0(dvbNk>HMo= z#CVQ8NznuEMAPC^MnctIwYjfQCK3#P`+`3`W8Ms+uy5z#K8~#703d%r*}Uep5{wczRiYr&wBeP55W~}%gDHuRuGE!9$5Yu3S$Y>Ch8=` zLuTxLrX5x`-(W$Y^@$7JoSph|tfpc0pV4oM(F*D-A1&%{;ye@d)XKKJ)!y7{$E1BX zsw(bE9$x1_o(iVJ3Ev??Hct-;s!wrP*^7Ykj0O_AcjyS1ndU{jK=g7=-8t}Uu%@Zs zMk#Z7$f9nb0{8Tp0>l{)L>@};qjqIaQKkAJLKk}I|un{QF+wu*jmQnfo%@OOCM*3b4iO&lWIjF$r_h zVtVwRzt~I}m17@Yh#~_PUwhov3nu#r!N)6NM)#6#%GxF5Wi47QNaJe7?QS2ej@v*3Dhxg!b*gV3-aK)if6-&>V@Evf)jkTIkFShr;#%y&`^ZbUZB3RofRI+ z)lDpU=KKEZ`xqTxGHhUiRt7D;b;9wBIWx}|8&=DGMtamPr>O^H=2yL?;PAJ_*?p1) zm_}C>Z{zfm^{wO0;*qAD71B?lp6IOC{qR+o8}N{zr-aMXV(w*$x!jIuT?sl4zpHpn zE?PF;bGmYh8)x>avGj!cK_or|DXZ@|ydR~FDZF*Mqa8OOAB&Wb*RoKGZq2q_z3;Dl z>W5`PRl`(}ux4t>sHXGMfbT;2``qbRbo;6(gKjimFR@LY^f`YLul9;Jy>_|gW(>~^ z1WQhV?ao7Xb@sJ7!85Z$iQm`W+->h%FBx8~ew!=VSog92%x`GV_wkv_f=;?aF>SNg zY3KM{GGZG$c7K>DHQyAE_d@Q@F^kaeQqKMR-0BN8o7aLhRVu4Z%bb1 z<)Q~>T~<_Rb@cpoBh8@wHKAEetKfxV3+2e-#OjTt4oyR6^E@WGP! zc1-T2$r~!8u66N95q4>@pS>{yv3I$}IuIPthk?m=x$5sNY8%c&O;h^FhmhX(4{8(0y$YB_eZ>lVPw8T^il{}4Xe(cIry+M5He*fP&{ zlXLplpEKQ05q+xhH2Tgj3_nA^UcY?*xrqRcPD=%|qNd#Wa_Zt_YFiVF1s?9gAm;sa zztYvsAW(>w;91WVHmbpu3Y>AwXkp(GBN7-VQM|kWb4Y9z9y@W60KOt1uHz1>8Bklc zTxuPt-tZm|2b`YFqs;-#66>SQiDAX4@hJy722+NzIaZmfr+z^Ah=(NcX`AOd5H$Sp zr$g!HR2g--Q;?$c90RxUbcD-}QUy+Jjw4+erp}h=X%{B$C*ZCjWoi6>(|7#AvO-49 zwN{KjMm{ys-eX%sMMn7b%bcls=gu&(YxPDKST)Ukn z@qzlqe4dSEF7REAiCro3>) z%&-|?d39{kq#I2G1X2Al>CU!Va^1|!;)fFvP z;v&(=q;E~2w7<#mn-#%~|4CK96WqU0`&w_`n+=#hc|cy(n2AnS%{xX|?n-v10|E579;Sel zzYMTlQ%|8WXoE9AhaNY?ElpfHxu?d}Z$?gxdNLi~eD!ikOVp3lfiW`6-G{m53F~vK zTLWy^^76ies<{qX6^Hcw4P3+-An5|>2rx)Zq+p|TrLg>rQWLxiBpr}}oKC2h&P)%n zv_yVRxZ@jwUk))3$_JwE?^8b{2B$F$7>Vs?CG!Tj!iY1N5Gdlmh=Lx05IU0piTrd% zl0hs``X;nvoj`*rUl5g5`T9CofQ0-nV)EK^Xm;`h^0E>p=`gzKnn@RV1T=29OrnNPO2aOO3e}7~CsKoYW3wbI%GTgqdbxmVvSW(8sWRcsBrLYBC{Kg~D z^j`4{#s2EE;0F37!@5RQe@ljUO9+cjWmNb=&HcNSEE*aO^hbsZN`|a?GHUdClR8;A zZtnE|u`;`AIvg0+0U#icZ&iG3Xh{rFKr~Qk3K>3gm>0^nJ!K&3VQno;k+fzKt_b4&BsPL`Xt$d!BN7#1upHwk(EH_!bP!oW%4xrf2() znjZC!HBW}B^gIznmQED;_JN_%wFEApOsFQJvykZFQhsN=nrx$L@HFsluya zT}Ly?=Aid0fq8kh0l%fDC*^${?A8?RHx|1Vo3Dutlv>w`Sn}`xq*j434gm+Iq^O)A~q<9tZMqWndAqIIW{qs-}^vmTm z@psy;B1PO^x(NB_Aponax8-Eck5$?uhKLB!?8*tXp9+6Zf{&X;vnFf*e3su{@skc% zplR?uzqm0Hy4)mO)VwDpH+V=hF$}JBuBR{(hjB9%Bcb^eBcV5jthHqw&dLV#NQqp- z^bejS&7E`&t3u=Am?t6^;`p&NPsQHruuD7}wH(J}`OC#IY`==Ayny|x@$+h#Ih#W% z%Ru>~3%3ZA`%%)X{!D~x&2p)X2w*{7zk{~RL>pV9b6F@T$nowEVRhHy?t_>=Z)L%T zA@2O6vgMAe!SF zx$&_W&uq@;AeBZz&dzPf_M-^ShCaPz3RtD9-`&-)cYI%nIHo{W828Ikw$gmS&%xgP z+4coL5gOTxBej){w`Z>BO)kudc>28R*=Jpq?oa?*;E$*+Gfw%Gar5`c``Y_PzMpbU zs=a>^tO82d_K1W~z7c~{H);1e&9(4mb0LuO3a1N4rpJdG($K_s6ZbRXs_ta?cG!!h zZGCNN+2n4#?^6v0R^yYXNp+X*54P@tPetArwH_x%UhVcCbWIX10P=`sEp#+W4LD#Z*Mjs zUBZc=u3{oc*oK$5yP^rX`aAj27B#d6USSN4D!Pk?<`AU~an^Nu^{S z>0~6d7XKqQK0(mkCiIDTvjreD#2>!KYB43dmS=;KkhFT zfVuj+z*1%BOPwF+wL3J3$+(802%-WB5PK#C+GqPSW&Z<+hX#?2>DY5RUm`&AjfLW& zeoXl3H^l|FVC+8;&y{=7#UtRZH0ikkBUveYH%4@>JRSu@8ya(I7t0=E;_~DyYU8bG2ly%PF~-W zziU)qBmLSf9WL%LtSl9t8nDyIWM(9A;|shb=mF}S7&462F--y^(pxy%uCYjO;Vr^% zgYJ7k3##)L)CkWcKTL~Ae%Dze0%z?CG9R^-y!|rza$f93mP>GIT4vaN2s+8KNi&CRdB1%LhcjN3D)5Kn;Oi>a~XbrpnxO>gmng4G2*M30m>%OPsiHCOA|cw6e!bbfsK2&w41r!d+7}_{Hiv+ZB`;W zqHo}$leUke?EY5P=TNs)Y4&M8E3VGtw^dcv^ZR&#(=5J(Lw#lCOa^%B>)i7^s$p0X z0w`h(<*M)cJ|+QV%){l#tdb!gfpDI)5eGsY1um_bw^-w)4;mrUV0dS6df5|fI;xnk zjbnr)d!`}(kqkaOeR!3XkbT(lN#~~9IX8GL%~&jFQJ^FM!JH%`;48L~tH>NnfoCFw z(V#)V1}j!ieoX5C&rlux*ES>o2^r4lmtSjKeku7xBfBg4Uf|vAj4QG_G{LvMd3ZJW z6@y22C4Skw-p}#wPs2uB%SiH}85#OJ;Dp3IUo!j5&2GhQUw5kLT^cEnJQ6>~RFoS9 zb068j56f&HCMR`4b%O9g1+j9O7l5;7x2#vv@hpW?hl4vq+90Dpuc`+0FB~+uhyny* zhe3vF;N)={IJ5s>I2qaF{5yEqkvDOXU=iEhIU@}=Po}@nbLcZ}Ku;R~QB0nEz-Fpx z^}aYmXThkYa+FhIcBm3V|65gKpEBp`8nwTugi|s|OR<4;D_sq`-Y2(xl~=!tOL#0S z`AZUTw;wrnI+nVFoW~}lLwWK>oJJpEr&PsfM_hRfV=lVHiM3mcPy59yYnxDLJ6Bqe zR>$;TF^MiFb}i=>Yz!2wff|GXR39{8#b!jD1piKYz4TzS5~-5XNfv=C-2{?@s-4(z zvqjx`jqfv=jbu<1pa5rJimcj4O=4p}C-GO!9iyKaE-dGPSYebvg0DUNdm`Y)!mY~6T7du#i4tb0S&NGyRmd=Rxydm!-{HgeDN%n%*PZX%*BC~>*# zvRfUDBu6b>n>-WGnvn*lUz|-5eP3uIkTr);aM0a9N7RCBJql*7S(Jd?ED8>4h7G$- zLZq?~e&*V9Xp;~@*_&X4C{jYB^z2SR93X7&f8oxrzc(Dk(}rpF_Q?*k)kbf(T_(|g zRwMt=H*Nc_r&KHEW!f56r;bP*dVv-H>=$0KbAT<8tIFhQ8VJx1F?LhwN&6E-aXW!( zQ%~6$Nkwv?7fgaPu+j&8K*^2-EzzL93+Hl$nsd#2iZ98I3e!kj(!@298e^ zQnYh+$(P-~%Ck#;kbgcOPBL(V1}7+zPCJHxB$9dBRt$!^@z4xn2Gt(MtTKzL759Dd zZ9eX&>;+_vvQE?7T?p?XNX(!x)8y0Y8|*Ts)&%^ zMl~h(+w+$`IU$~#t@;{tT^6!gRxqAfD0^oSWX`CfpD7yrBh_@z%k#&(oTA{Ia`Xkc z31cBQ9h|7ph_#5Kw7SSkg1$|4a-yjHfD9-5>TOcy)9}Z zOH8@GRjoTVZlMu5KTT~wY^9y{d+4wsY3;u#+em0C~)&vU?v1T;3Nk(`{$9%4DHQb6_gHPuoRMqqCWll;}xXH zY0JUs=Fee0uH2OgZ#LY&OpnD)H;KxnQq@n?BBu8BBL&i9Qbui+O@L4TEY;$#S&J#b z5#J``k7NA`7N7ICW=l-uS+Kg}TaD|vZq`U`4*4Qs@721*j7HAGIvchDKS4g_fcqyv z^RJQqF@o(EZvHK`M+bv!%qIBRgAKgE0Y6Ug{ymH*C``&7hj!j>=59`NKySdrkXIUZ z|0SiP2M+2T|NieX(VRp)>ii+jx9V&H;&#yJoWw&C&p!h*UZNB}pydIqfz0$h%18I4 zzkf%?_q_VmFY^6&+K@fXTV#4>N`;2>o>|~Qoei=pGnQatn&kcoylyv}`Ee)D3@cmL z|DZs29_QK<6t^F~@EPUGxxtX01QSMoG@K8PTX5!uAkF%LeG`1+BW&ngwi5vn&r;&x zlp{+<4;KCL>vuwk2^%am+YsQ>&f~-J9mvP8n~+79f72PXfJ!ETP|Fzy&$I;BJ~pw6 zd1VPM0ft`0Ej$a}O5altd>qD+KxK% zCh*(gDlXI29ub#kNzK4G@tiig$vk8YC;dCNz;P`p~qAa?xM#6z4tS-cY@F+)WOO0rMD`jY=)nFQb~jTD{DsE zVgyQi3(-t=xXKJEQbF72mxxRj27F}_8FHH z?WhviPfljoeA)drC+Z5GMXm*rb)l*W#YowCh=B7@mYUi-e0M;P`)f+m7bq6;t!k^Q z6%;rg_%g#4eqs7egYmqgQTV6=WKNSqdUFOv#ko0s_SJP0iyI;aXavZ)grFO>wt`{8 zCYcdAPQ#C!02Z786X(+;dv{iSYP^Xm-qqBq#B{lZz_ zuPNYYj?J8~{3**l?$5u58U6Cw!nm~Btw5>I&4)9ahpcRE)d5G~&gfSQXs1OUuke{{#~p(4Jb~FLLo*PhkE;eyc!N%$lTvu3nVbEW6^_+(NPL zc#(@kid*p8_QY>f%mcfee!efK({p*A56G5H_SARQ->Q@rN%#a3OWpnWj|=MH?zj__ zYkHe`5QmGvqnely1RD0_xmkxGnU(2X$sj}JED6VsZ@=PCZ?gicp?pLWV&n|@vrp^K zlL8BZF@?mEw`SRY!hN(bND*`-7k49r7%!ca5RJP%@bhJ?p^6zuPG*L#@{h;;gWi(i z^B;yoW@yQYM~R?DlaQt8$feDpx;o=>@b2q3 zm-#LA{S67STWxKUUrrD@!q4w(KhP?D9rb-MQS++Yc0CQiYovysLVG{X&AIZRC9t`G zyv-hOyT26kC&|Hr*bQ_NouTxvM~&&F$eipRKX2|k+4QDyo#&&9nn6NS)x{fYh}G1_ zb(7pKY2V~B|99Be1yp^PK3S6dzR>xXNlwkwsxt`#cZ&nXwb|Lqjo>T;>@W(Q8?KP4 z-HH;GR%Es8%}tdb;$exadPST1%6rQ!V)RkpBkhkK2!JbKn8<(W5|ZWmER+w56Bz3Q zsB!UWI2jB%+>L*=V+|-U46)I-Dt4Z}S*2zK!~@A+2FGdki@a8I|FC_^F(Rmlo%9Il z4}V`qi1$?0yukNT)iY=~MH!m28l9yv3SBv$axEeA?30`67U?D{0@$q{%GsjfI9HQ~ zNokGM>kdnz!6xH-B4Z0}#(s_^U_q3a#D6RhU(=h(gWG{6_Bt>S0K z%*zAs{riqzIT00g{c^c+)KLodp86aHjXq=o)L03Y6fcioMe9z?BIXcts5oIX{1Z{a zdw~)&@W12A6^E<1r9s|I*C~#ZUpnm#IPgk@*xy}6N%SNP&d*cTSBE~2p3~a<_$m96 z)3HI7PAfs9F&QC;IZ>lps`!=l1>g4N4TXca&C~}f`pLQ(8+!#ZRhyc*UnJljAo+w3 zl=3I4L8l-Yjh9!ks6$pH-fa2iY2QF1By3{8NxEmIa5X^K+)GOH7VP3d=L%Q=loFju zge_s`0}RoR=gZ{zWQ2(k@Kz(7sGs-7Pg58t7|rj<4~~=b9~lTfc;H<=oc|CnT(z{M zXvrL502mCkVK@OXh?2xRd*9~V98N$?iD?BmG=!R6{E7L4Zz;@~dRg^|eE+Yo=u-A3 zl{IJD_8kP`NX@sA|GXLSDDeH%=emaJ3k#f}6I^d!)cz4(@Y4AJ(o6C5tAAXFEDZx~ z!x8khvyzuskH#`|gxiwKV2%T>0GR1ew`%eraBxX#Z0+;B6cG9`D z&b~y13JCs>Hm>R82UU||U1RA@Ztou6S9ZkTIT1|s41ecOI^z79({>Iah`T&pdDAPl zh}81WPae#Mi*PvD_*ff^82{Man(Yf1pcwF1euMyxDq|bR2qnRXMB66yUktm%jg_Cpx2Pm9L_4^g>akN@XGKil>!qzylY| zvioJ%?4qgxyP}<1#Ae!;?iL&OhVWeEE329aLJ=0&vg{U!&9#TCuoc$%+)E^T)teRO zq_~1_Mpbm>+-!siH5XN+is(b}@0z5PorUMOSP5vsEa{N7^zvZ0q=C-R!mRA&AG0Pq z^HN(*4g*cnY_@l!qh&j?KprCwLff%eSZj&h>Pqfl&r0wE4gy zjA6g7PF3LVG|xJXRD3q8r3yD`QLv6TeeH&sSKGU<3VO@iv##TM!R3}87TezFQ#A7B z%6(0&ijOJjd@`oTT+`_GGh$%-lJtOoJ=bwqmukEWNvnh))X6rgIj=k+-*Z|V(0!%( zG25XFHDX7|Foh}6kevSQ;r)$u>O^m9tg1WdK|Zia!b?iKaws>9M0~m2gJy=t7G86% z6hGMu!oP;5^J6QqG~isv;F(WTG7p08y~>}#s*bDPTZLys@y3DhT8^{IN5v;Cm!DtN z+i+jVN7%0HdPI2 zJv+r#X8YP}dlSW-FpKB;-Dz@!z~Yu9qEUd$E5B?IH%6#LDmhgBD3ACdk1? zw9$YRZ&gIWtGZ^P#jJ}4wB#(p1FXkG0YVyISrY~kvMeG~3YcTa;}PfaO&C>C@*bA~ z+BRe-A)Ije!D^NK;wwyW@?B6=avHVGh%$2(pYB06G&aWIzmVF3R0Jw5rRcpNJ^twi zM{6hD-w%*xeX}mLW-3sI51!$tmgI-udSO3fU)~$JxIZUR`HgKUeg&H`F%ShWWMEu* zym2y~kX0D~m2f5~$RYGdpcVdb-2zsPU{^X74dDOvFrKdm;JONYp7rD}^dvw?A$)N7 z0mzn5Kh)Sr;yeE3%#UjA6$Af%kxRJOF-@BgkH9x|@PKJLy{I2N9t9 zx=r^i)J-2{>>d;ujhxId#dl;ixh15S4ezrpP`lOzvKWW$nlyTFpZ9+C)X(6@I0dKZ zkKLucHdKNPI0(40rVG*kq&p9GjBtjrdZO)EC@_U5NseHj0ODUS-iwpkY*lE~41${J zlO`HvihLJ#-f+TxgBma!xtk2z2?08o0d*>S)f>{1lKNjI@mlR?0Aun`Y0$stTxw#$ zyLanZGpe-`I8YcwC8R0A(j4aEJSd%N!pSe9Bq2IzCK>W>5KD(DQNx@8*>Xt?8u@A! zUE=Ak_x$dbJ-OrUuFL6`9eB_&m4g4et@7%M9=7VSR<@$g7oVMn?TzI{T7jKa)=vD= z3dz@373|D+wfv@Qf437%O#4ZJi4Uj<$E;-c7#UA>6>Uig zD6HG=oF0-Y-Q5+NvyBl1x^*b`RlVCG$*N@cf%4qX@>A z6aM6WwMiW=>koJix&ZUM-ir`==Kk%t5J6QM=ubOJ*YI?L>lzl)Rq+qrR!sRG010&u z;`uETYF3G}Q^C;H!O?1*5FM5#WLTRB3%4x|Uz0gi_YdkwVK)(fumeu7 z&|?^W(*fC%ux7WPC2dgRI$s!dPyo8}U%=HE9J}ug?i2!~gOY{{E;=Rk%plUr`F=VF zz0uj%79MPbPAb*g+nt1{2%%{&4GW#DUgSKR(;L&a>nQOtEzIu!sGRYW*`F`>yx>y$ z7+A3_e510kH&gp9w%FG&Jt`%}2H6jUrcsXWq;EiJ!nHX+3# zw5NPq&PP6yzbNxMTL^3!X)DtAI5+Inr_VQgUp?fgvERF-z4A=NBbfpJ3DY`fye=UF z1Oft>2otu>MTOu!T5eNELO0aXL(T&;@lW^N>Hjs)r3cJ&p-W!Ttd+Yl+Rmq?G3ER^ zD0ro~KO=B0SVwoX!^zmspL)YtqtM;ank)iHo=OHIY_>JM^1FT3<7RdJ=0UpqQ{b4( z?@3uwtPR*W`6jOL{+9iH8T2qPZD`cMqcT3DQsvP+UB?LkwoWDF5&m5bW71Wm#cgVy z=$66X>fga>hA=$K>!ILOU(vut$8imDkU(lKGCHc+WMD_-gr-%KqNvN=UJ!%Ba;Q4A z*+s^|bO_i{DQ{Bu(^|$SG~5I*o##1pJ`glF7tI}uZh53m3(8zj==#$gv;uqoxVWp z?A6dzgtgAK{pF{-!DRoN$r4d`f>fm+O{I!bolA%bhxO4J9yfEeoyR#>mW_em{39;! z?;#AG>(^OH{)cv!$JIUI#nrjAetUjHNoMN#g#f2YfD{{|$bep`j-&()nh8(HGAuYX zSWd2UN*-ZDnBl&HH1WQQlyzt4q!Ekm;AEm$zrzZeGe+Nm0eS#I<`ZK}4BlUocAI;1 zWiFVP{0I{^#9-Yl1UE)G1J>7*+Px!e@yD`aa@jl&NN8_c4J>G{e09C3n|-A@BtPHF ze>6?o#-+SRo^40k_)E2UE|@6=%SdjH6*~!4s3C|eRQ41)=>-w~wbcdMd7f>$!$C23 zCDDT=v*tw9+e`;0p*{6q6x;x$xnFksQ;wzHOT%g5vuoJB zmQ5DE9cwS(f=xbeG|_|&E9u<{e0E6h)%P7R0e%T-(%A&T*2+}Qqjg^wJg&(Zy`~@Y z=R_w=KC+Rp--!)zF5FcU=?c~t-)JirPPZ)HaSW4MZI95mQh07OcBtAlDYQeU|k z5GruQlfJlir0ByiS>dZ%T>PEET=DaRxsG(pdP9+yb3dpd{dbq2-!?LIqhltZBh#@P z_!F?Q|2cPa9`GC>a^6B4u*R8j16fnIEoP~M9^nl5ZpQV*rF*%zAy44eojAz3(L1W`M(DXhDYnos~qTa{|Sn`lz;bWJv^Wh9N6$1;j72MCJfSZ zCz&vf)wi<$leFdlulF{mTPFQ5XN^d1*x7%wyyPdeVaH7i6JtlQ`%)d&jtn?&;6lND z)p@?2AQ#FIvlIJ|n*HxVZab#OyD|d#%hPjxi*fRBD9HCw$3%fA-Xh%|o<8TM7?k&i zTLss~NvFgV?9MqSXIWpQwyya7_+p>=_Ftp6zhk3iVEzxl4-(IaFn`(YxKI2?4ahBj z`{g~TW7J1C{WkX8TWUE=bZ%_4Dlxbi8LEl^yantx&>T*_*Lm<}QsKP|5h)l{!iLZ7 z8vca&Tq~`m`|2f4y+sm%JCDYO*O7um8;Tcly86Ac}@N-qUe$a1O)mLU} zR-FIhkEQkRzloPbwoEg}#LE0$xh`2xj>Y%*_P0G<@+6kJbscoyyRcfqKG4g87`JQs z$V1oEO+8o2hLJ^C8oJK!t<24m`d}4{Ecxd%T=iDXVhb`2&#u%+-sZe3)x9))K6&6 zcHHUUx=nD}P0Q{VBVZ%!M$&DHLVW_DIGkrQG`%B`buyd%P@U&Xc) zW-fQc?Dg?74Uu2`U8?(3=I$^Y*Z_l;Hl`yu;JFuTS$SNC<>>6!(~S{O8;)o0mXHj% z4i7`iI_eu0rydcMzja$O`d46;n7rlcz6Fll)=CzrqCKKfi|kZcB(;_@!zG$Oh1f2# zNlPpIU9H*9UYz(jFSGJ9V6@1}cGA^ct*DGdP%a0h-hSrqklNtljKIf#rkZI+jSg+` zK-j_T#f;I|rYda}e(?RK<{4bi%@8EZ%;aFd=GEbdjKmg1;m6Q_+NL}%kWmBx1Gm;> zSh!caq4mn_mBB4q!m=%ExV4j78;TEBmC3}=#eh8u%z96spJiV=sjWPhs@5}WcluE<`GK>#>W8Mp z_iZ_^`Kv!XL(4ehtJcTtw##f*@yc@*M0u8fEgC=Y$n;=qo7zf*+;tLmYi_@DBipM=p>Aqlm&V6!2oeiM$15_FYNZeZ*7-p<$ z`iX0Td z+1Y-^%L-RH*!&~=W?M6^oOsid+W7l=N&RHL$8(B5LGAD6B<(XFiQd@$E{LZlTPwHh zyu<4%O|Tft>aI*XZl2leymgHOQ4os@1xk=KO!qI)z4p#|~v zp5vvjg%9r5gMLpQ0uUjBykj>In8I1~ym`b~@I1KplI*x>sxQS*7@ks{+1l9GG2ZwQ z{*8dtysKr#;zE1Woe7#^9gO}MHb9S`mA0P+DQ74Os%Kp)Nw6x zgP|&$<8iY}abPooi-ORxbn_8-x9C~)?Js+y;N674r<6^tX}aqjqF%G|>urGukZy^}4yVZgGUE*Ik%fSo8r(ks zwnQ);G?>Is!56{ZuwVC*b=JQ-wIH83MMJPZ)Rom{R0X&wkqeKKg@38QOtCcJE{?c> zyx!0M9-bCD+QQj^Tz7MoJs=m}&$CX~*#NExk+(?!#;NV?dex+c*8+?MOvZiMh_3_P zcl_6_&pG&;j^CRedqy?^4fS{M73lAJ>heGFoiJG8LCrkr|NT-wd|9*q2WBNuwYb#4<(bP#+ZRKr=Jfsz3C36LM= z)f~EXUbG=$g9P)rSroX?L8PDU(@e~~8UvZ;z#R(7zzpIipQ|vWAcQ0dD)Z#ahm)#dmGwbi>EQv-|*LKJn&3<(8pi7j}#^*BZ zYwKf|l&-W*$K?ELLnLr8jbA@d;a};qv(_0jAxd1VrCg2(rg(=~z}xGSW-%#`yok9f z_Ppn9DBv~2fYumAI61MvKL&?LqO!I?ovvaVC}21fZpkEQ@kbK=1pc$vwRHqp@`%WZ zN8rwM*eQpOVWpV@4%{Dr5dfdEZK>7~hG&;j$j{?U!uRY&QBl5Ub^pkTuI)9IFk{bh zTeja`@Ga%{3qF@x@@r_KaK+*%S%uLYwnV%#o#F>)#C0~Ih7qTw`}mr@kb`H6jGEX? zE;lL0|28?6*R$~wl1%KB@PHx=(>Hm3ZU3`hfS-sNNE+XaVE|vYeehEn=mazr0K4PF zQeX=T@TBCQnUhFmR_^TTYR-9Y7LU5X*b8}=d3~oe=i^H<`;V8(b;LRB&HDZ6!;()$ z+XugpS-#a|Ks>SUW5imftI2;OJEK@T4GCR?#4r-BEbt}+6KpN& zxaw;~h9<1fuQP++k0M-lj1`bd20_hFi6Db)BH+wsfh^2JAZsaVh#Lyto-R+z#FS?= z1*U!2^QxcDre!z$A<2`a`Zc(0i@e0=Ness`{OgaV4|LRaY&4Y`t3J4}nyju(EbIQA zm{ecV2zA?kz=1qEh3cfu!~`K621Ye_Q3ok0xI4g=T=p9Ik?SJ)hv}%r+oI^7p?K47 z8Cwx47M@ZgG%rCP76fvy9?lNui9<>f4-xy2{o||m-F-fP`5XdPpyNf3%P~jmk@Y(r zRN9hv0g$99Md@W9t=D?}X%oAf*Ix%k9IfV92&;K8wy-D=@Cj9?g!$fb4>WBU@rPd|O zfgXCj;2fyMJx&$4*BT^zd@Xln-$;2Gsh&Y~*5mAve0-7(%|n(HZrO}tW>|_#my}cD zz19#3wLo-dHuU@+g}(Z&v^Y2A%!4nw6#x8!Iy|=r0XM0~q+jjPQMw;gJR7`f=hWbd6+Xcz)RnnKwnfk&4V3PaX%_o}2!8I}~y$bGP9OxC%$4zeFl z=u?l=m1`Jqe_%`;UJy8hoaF9K-G70zw|Lf+0XMR@aIdd#5A!8NdCSqVa?77DUf+){ z)iW^W^>Sx0-yeAJYCUko=?|^V%T?~TjX#~QNIxzc4N>af#6{>NkBn~e9+rB%m(EX& zo;;GP07bKOqhDZnW4${*F_QJK(}K>HM*A&yVO57Bt^<~Q*;~?QQ%b)x98n5X#|P~q zpJ0$j3Mx!QGA!I^jsZV8Spq8_ekH<(&*q!Iy~w}vDJ%kLIZa^bo4oqK=TQu7seps; zQWH{y{NHa|a#+0{Z}GA>2p{70)}i!#=4b6sKerg?DfaRi&b@wg+uYIAG4)f7%7Ii^ zz{=JOOB0FR-l0#1<9pZbOzt&y`zc78WRHu01>wq4u;#3_Z*<{kcafxjjAS7>@fLMKm{8~#UNeiR?w>)2IH400zr5o9rvIwgrgLbC1~ZZzkr-TDi{Qln=2yV5j_HOK?jXv0uiU(4VlSX3f%mVt%OQXtDyroc)bW z{J&WMGr&tHLJl*$@&cbOJA;8phsSIe%gZd}YGX(Zb={1yhylk8g9F&T3?GCo7Zqf) zMG5kB^u|0R-G4QEJmQ}|fXLt)SI7CzCCT?N+FE#`P4pBS>rop;h4L=qFPiZ|-QD+{ zf{4dD94>wz7&sD2$LPG3PtE4MmH*?hF0Z@SLC9C#>@OgyN_H)Ys=5^VhSm|f2x?kliUUab>Hg#@kfWBvoBe5TN zLW6EyDxiVuRe~fbya0O&t(2eU@C=Q7nY zmGcl`Po?Q&V)9517OolZUqX89QxPbjt4LOr2MKt&5p|@3RvwBg(lM2AoFxYNpJlLd zhhqxFUcVbjT~K|lcanF5hbtzQ0pI#D`;d1+BbdSSA#_#@gcb{h&_ZZLgbwUsu8+j` zYX~Y_1MzMb?vWNaqw?F z+^Y}oz09oE(MxteSI}fHGkALDiC<3hE$SJUv2WCxyJj#m|?fqn=t_C{+MB>>$ zs>~sO@>5Y@jjXQ+5O{Kg+UZF)a(#iMds4EueHE}h0W%?ah$+&8ib=6%uZGawbA7u3 z%Acw_e73PKqcV3g4O8EYYBpT7yMq>uz~`|12CcYjbz)j9%fxFn)@JVO3-23NMn_bP z0vkWB5Q_HiI~@M91Uj^s7elZu>3^M-1%n*OpgSG<+{A=wj`@gGo}p{ z&7buHM;RlT4)N=EupSR{PCO)5hDIG`+Sk39^;n`qX>u^O*?U5oWOMp3IFQbELw7Xe zfYU`9?^D~~pI(*rdM4GfXZMd?dAa8c6p*aIjZS9eH~Md?OdECimvfAzr_@bNn^Xo^ z{!Sf0EoMQgSVP*<{gfM@&KBAD4d5|7Vms{$1)myU?^F^NvIZp0S5Iz#bg4!nDS7NH zj+jt;>QY1S{lhM0=w=HsV1k(NnLnkERN=svA6oybqu>V;@d$o#4*+fdXsca&CTP3$IoiHu2k*EEOG`T5d%VZ!PR#W9+}v+;M&P;J(VHCvU>PY9y)hC1Fz8 zOjKSwC&sLu2w&9k&v@I#Kxl#he!w^6!)oHN;kG5hgAr3c!t?fY{L>xw`W9pduxJwrtXT5S#)XZzWnl_pKbKO@P~g{AYn1C;^%S- zY~evYP1F5s^E~#%M-}l&=9%eadhSOi_O#L`4)R&U@P@n=-=nPJliqvZsjra$$%oKg zC%kS6bE3=ZKoW(!Kwtv^4L&X+Mb=2O&`d@E`~Cl8>pkG9e&7G`?6PI=5Ry%{95O2- z5m~8_ow5rZBKwe$6XT7TG(y>^MRl$8pa8ejR#$KflN0|F|FTqR#Q|b?$Lp z*K=Gqw}P$kA!t|V_0fx_(HUZSSPVvly0p2YS8N-)@d?}TFX&!dd zSm;a-9Nb+rby&ClGlu-xH15xX$Z5}EPJhkUkFUR_N%N8J8ca~g5V6f}D0#nh>)1nj zV>*jib#lP@CA&EhUv?SB&~)z^s|096B5VF<ZP(Tit z>V5|NJio4YJAgqs42G^bHp3xU^lyKD?w9q9A}GPJa3>H^9`pQQwF4vR=}T!(WkXr) z>^#yy%1*Ha>zN6`-(6E|%9YAvAOR&W5x&hv6AvkBUnSwaKZ%9!6TG8K1n=nT#*ue4 zgo8Qrh2SBXvOGIRS8giogyX_;n-%^9$;7CaQj&6wWxPq@HL2T9=e}p{37VKsuK(_Rc1ZnVSA5hF z*uJ3*BEzCA$nKZNE=>0}f&!iI6pUPDp(WYZ4GIMvUxMKV*3^o-C5mRjmkzbyH|As{ zeGGvOM7Q#?{dqmt*HyZ>8(w^PGpqg#Z0YAqI6+_u5tbn5dVE7kUVZyq2c+e9IgPf3 znjH(cg~5f-$R?C+6UZ)GK-xhEp2tt}<8^=E)_1V|j*4ksU~47BU=5!=I@WpaESA(! zT^+L{yzX#f4Kj|B>>4Leg$koK{W=zd-oFrc-iCkS_99#z;Zx9GN^`12#^&Yh>+GBf zY<}L??_XcAU2M5XJmknpQgwN7mrO0Aa7nF?H{r6Li#Ar0GkS{lm*l4i(4aIJiFom! zz4E9bDC?K^f~1)DWZ>W}lYZ5j6AvV<-aCL54aGm0xQ60|Y@Y-n0G&X{xzooPK2+n- z`V8<3aPB%%QSTO7kYG%_@AM1156n(qR2G^@9=0Tb)c$$M5xF#Y7|2Luemklv5zxa3 z;dNLbxVNE3X#D@~ZJ7DP9AQ5xx}R+--x`|K^rTR7hi$==zFjT5IQ=ue5kQw zX>VDsaZ_T=DM-LE%zZpA7AFAU)pnR1!eIfY>Jvs3E%cQYxY_@&ECb@LFX*q1(zvSc zV)MKQQ&TFRNdGpf%(xP7)z+lvN4?IBu9}+4jQLB_hucM64v+^oVSG!avOFtn9#3pu zrebd-Yj%ib+Q!z@h@K_TPcaTBaH{R*x}^VLf(jaC`Hx)QL#AFW3Z;2erB4wo0BoOs z4sAwF_mYc8HqqUPaJM9$^L}w|mM22+q4;Nb$KTZwYMz@t|E_cZ1QO?AE^bZ&;`no5 z-5J;5|J7eWg|+J>HsgrX7SubM7jfn4k3bLlAibaIE-khdZn1cT8@vWTU^@(3`0}SR zc{A>TBDy9{E~GB%4&~Bwglq_0-Ti&($P1UEqDGd~gGPbpln^@_<7!hpsl@tMZc!#n zR{H1u%i!2FV4}3mcxDDz2;KfuIF8f9yo3gly5mV4mpp17^%4WDhJXf2hS;g0Z{u=; zNb*^Z62Kuoo#iIDQ|7h*@s93?a*$wT2h`yidZ0_-lWhyafm09{jPs4(2pVkSWvM(LGMSB)xSO#~^(Fj6=hi$g+3TR-W?XUml+d zzWD57cP56iL7wO*n{!woX=?YNF#VG>g$1FxkYDfTH9Ep(6qslhVIm|HP={V9h4^%; z5Lir)Ai^%e%T7?BC3s@~)|a(wVI3)Q>#x_YCr}1l{@wqC*bH)wfP`R|4y$m7d=Uv6 zhY6pHAe5s>MY%}HdC;Jtqn?ctLBPKu4E%H|pYNA;lP37Nixxe62e-t(B1SP!Ktr4{ zv)-2NmEY01N;<^L>Tg+kcU`V4cWd{x_2_@YD&G$({NSssgm|p{%4aQwwT{EgxMaO4 z3b9;a{IE07o1ywqeUvz%2_s19>sk|}O}@UuYT~%L$Z*C%m)0Ld&-mWFeiHA=HfO7? zs8H>7df-Ct8^~ble$-42-10$#e-%av*#F4~XF+^@ejYnW&v|qKt zeK;UgLH>8Pag;Ec5qk zXK?gz18Qg^>FS^&9-Kx~VJ$-Zrkz2g1FhjI_$tES(cTo%wBg)x?suE_`wbtOaT21r zdvbB}34EMC5gwArGt>10+0g)Rzil$}fUTJPS=SBj@HM)AJ|uzm|*V1S7u~4XQe4ebaFvg?IWXhdSwx+zm*8LrVumfhNKJ zWSR#C%!%eMX(DYlGQyH|3|`57O%X|n6}YPv9#d`TC?NL*HmJ>EV^G)yM8NO3!BrPV zs6t@K4S_jO2q)>EyTgPk>@of|@N+cfS`+WQfb%QDyPm>+sAOqyU^?f?aNu3MU}_>X z!@#@j!Mid%+?h}j(7OsxD~ub;Uz0U|^$Sx(wXa|>JZH%v{!1HI!|j`iRZ;oF>HosO zj;P{%Op-Ql$pEiR`{l#Kfa2G9{}k({fwt0()R7?)9e05nX8e?o&V4|;>>+6P3@b~0 zW?^q1;%n_^-xw_Z4&0bkZJV(NSvujb^rVF z+JArAgl)Md{UB?fVhOOirM9haN9zW#p|ee#`+iTJgfjIQr|W}CJtqB0Q8u`g+$LQ@ z#P8nJyG>UUV$kAi&Rz_#x{ju$pQly!a|HT2aA@Aec(f)+7wjd{43XcSC%l?gElYwo zgY99Y5egHKp?*q&wyONU3;8ZtX~LcP*UuQ|!339~?ws?pN-T4;CM-9Z#Z|6>9-+{*mg## zjpwhM1LpiI52`zG;m6?WR9!3|LC`u!H(x;cvdLEFS<77?N|a+m`-MbkUqU*S=lW1H zf~sJzr>T~vXBbZRc#o>XHNdz8*91cgu6;b1b?);g-4N!^gXJRS3Z33c_r3;?bUdnk zSq~WqgZYsCFl#@;)(+Q3<=+bfs|%koGdP%dztH^40ZcmKg@eIuv=L1^Jmt6772(dy zjlEaoc>Ng>pYFpK)E#+vosePp!ox5e?f^^EH18#IBI|02Z-NEU?>UID+!si23?83@;o9$RwyEyW6ZB;7MneHI`EP{b`mhu`uoaJ$ z`28CaySlr&9h$r5yN*#kD}5`F#+*Y2pLn_1H8Yc|OeCNM8zbI~kzA%c&>@YH>RO{Y zT9RP#jrJg8=`s<4L&y+6=fv4_h?Rxle9{ZQ|Gv?;&Pq&dQkHXg$+D;H_wg#|QjQF- zmysNvu-tC@T%JPvlG>1&yIsLD-xwHVK?1nyx@!I>eK%v5moQ`OTE+`t52cTM1$RwF zBjy0L%ZxH-`%-%X{*(}cy3Z=_z8*h;@6l(dJdG2VZ9@KHh~nvo_k&>5pPgvrFP4K8 zQC8Njev0F-PzMPrG?gZ9T5ziKFUq<}O`?dwnmiiCxl7;kK?tIhWRuB(xk%+8^N@Kk zQI;H-Fyazi7rwRkcVgRmrNXl5BfYbibJfihZmHG=P2i9nK&20GmYMOVv)x(6uaqNh znK1bHU2m&nQ9$I~_nB3h_VjZH$%_hwEo%<#5;E%JF$gRz_WbTW- zZVQ&Yr8gNk*%$Vol*h;Is_g6t^mNE=T3_{x-;CBM@No%xr1qyS;KFQ`iJWax{2aWB zVzDpb4HdsH!|}8GSf7B~`@8j1Vg&;fZN1s;t~u+J1OP5`mX8!$Of+z}xFc@6-mfGS z<`8+0aY|1Y!~`hmnP}^YO!+_r7g1Nfb&n+~W9YKHc0e5wJOs&KU`Dap9v6S3iI+oR z14Fmto*0z#W2Yvi%c!zo*ry%b;VP{9Qf?f9R99Phm~G4A)CSy+1E5?$RwtVj-jK zud1k>ZwMxMY{%kB$SE7BRo}Icr)KbaAUIQwT8PW<_VD=Mh+Yrl*NpXb!exiH@PaKB zlfG8SDqB~%X7G~AC)~S^4#MZTLl`|f#3ED zX$UTiFsfzOj25@c-sR!eSx^!Nne4zFtA9$&o7yy@GIIE#iMq_Sdv3AkeHeod3fLch zc*DBa{p4Yv;+lesRr)~od8_oO^#!etwOBK-GE&Vi%ntD2la!8wWyix{tSDzN^pHj< z1wcZ)h&eZxEwaVIN2=w-b)I8Q#~sjvAamRmUPhc-@wnk}37p9zY3e`?;XZyjVAl~0 zKR!UYK!b*qlL(x@|2HfW0MmP@G?$<)XU-9^`%u(H6YOhfAU|Uc{8bB`sz99KBn<<7NzGuK(Bygdv*TOb-nn>~c2|D2aiH~cz@JP>!@VDp&k z9iM&6z;lPS5-fF-b4~4CxlB7iF&)c88}^5lu;P=XpJGe7^8Ab6&Do!h0?l=C#BdJ# z5OA3JlAz7`^*~kqv0Y0gjFQlgKNy_ViEMnKd9{}d`m^0q=tTzryxq?`U^|dKFbfSv zfm|dNPcBFj=oOVNlk7o)?-RR{h$ z+g+^48AX4s6A!&tz8<~sG~BK6oi0^XD`YTb#m@U*yGli)Mhdnen8ONs>p771g?5k* z%US)n?`|@iDCf~U3r;uP*a-3}5O5^{kV1tUnU7-Om_Cy50qus7GzBOkrhf39@=2An zv6vdJv+uP+*NHR#aXJI>Qji;W&elfFId_2O_$L6nyytgA^qBvBu6<<|l=4k*+4S8* zH{u{Nu|tJUN8Fb%;zvd#Sj0gNHKsgnw>VHcc|};YC}q?vpjNc%Wy8TMiJ6+efvCVK z5~wyna`5o$(8D8D=LDn$axAMDSCdJ1N3=g-GTSOg|4&x?jGig z73KzQMctyR=`bz*Mj&hvhK$JriH+Cb1^_C#R}aw8iZ>ujPxlKSo`1IjCu8)9N)9Sen&w5s8tsTR3sS9 zRJj4$WT7#BV9q-lcK3FLk4Dmp79~hOJbsgb?4u(Dr>MfC8^|Uu*3)qp*Vc76oVvr1 zQ9L{DWTKC_C+&lISTn=Zf^UyBDq=orU&I8MTz1WL;{M%~+FD0*LpoLZ!Jf4z9zp!q zPcjSjL3yBO$6b<>6wIfRJR3ZYu9a7pY{A1POpoJ7wgi0Cl%uQm9-w;TSV4h8g#}bE z-Zcj#Gy`RkULq zD?v(K9GsJL0$-M?;#;%%+~ScE)+8ku-pvbEL|>~JO0omowsB@XLuQZzELrpRNTGFE zJZt{h9wIzb%JoL*io5x2VtO7b@oPFfy zyr>~>AThd~6jU}|C4la=pc+Hz%y_zE);Bh+QIg`2@8t%TeCB>e-(S<;+|F%2WnI6v zw%!^yiutuKk0~wl>cN>br!WRmHVnl!LFU-K)VC9?9qfC1au)I2M@35?-<>Qr&Hu9e zwhlQm!k%q`Dd)ZDp*+UGyzdYlLWM{#iXEgvP=bE3sJi!mYANUpg=t%I-A{wt;Sl=@ zOS5TkPMtP0$A90fhxsnea&e@?zXQwB`^AZyaFvYN9I{6K;;Tso^p%CL-D)cIIw^CO z8jwCEw-7^DW>;8H8H=r@tA{b36#JWcH0coGyDl92U1ZY5WM+bbLJ2mHQJridX zHl)>}5$=l%m85t`^F5#4)#ybhY`pk@X>&wmD!V?s9ZjuhVqcr$0t4eqGthY zv>)Rqoy~0`;4=6gtjapwX$KXzbLR-HwfZt7q2NQ<%%|{7@iLAj7hL2zCTI2wSyAnM zFeI2_%b!SxE|0mrj%{MLU;Wq zla?h1@L7q?is)wTgA|SL5z7(mU(^7?W(OXZODYNkn0>(dViTIWQPhORDtvpC1;@}> z(jM{MnF@b>)N~U(BvKCWn5rBu)<^Zr*SbK`ICZ$b;!kuifDHbPXk08|ypoCj$plt_ z-zeO)cVfqLrLR~tN?BR$$J;gift&`s-^PQsh7147g{6D^Lho==B#y1Ry9&E?dwX@; z8;YpEPEVd_Tr}&p|9ba~Cmlbhc(kY6RD9T|3Nfdg!%S!ET7}_P7q6(Ru^ZF6RPZ31 z3!_#IMrM13AH4I7ez+ijm3G56OE6O;i<0oDp4P90>47ILU6vbIY2Dq}ZvRh`2g!;2 z@q9d(-6YWGU^ftgI&^gHAII62pSSG zD^dXJLKtI~ssNW$lAr(?$Qq$*@6IVU!5UAiK41j-#VNdD*a`gG;4XT~!{%5S>Mt19 z-NGyz_)Biym7HrjoJ-4^_tRHs8i`)7}dN-tsI@XHnSbLAqjH^eCwl?>gEKy*8FyX z$nDhmFm!(XWh`{-lB`ECj9)Vt381YlEHOW}<;NAp(>RC3V)Qs$cnQgUDts6|A7Fu2 z-sF!5O;o7u8QVh~!!7z2; zrad2r@ae!%jKbdGaydb#|L{0*Mf=zy1`@))Z`0JogU>kI_w1pwuc~jFB#0~IAO04J z845{{+i|eGW;%2Y!#Mcm(YC5tK?=-q>9tBml_Wf?^^tzPj7QyAk*az?w!1G z3}Am7D&`=_7+TOEU&_`8?C%MR!}b#`Z??ZRHNVi<=0oQzP@%tW@>iUIj~XY23f1!H z${^PNu%Q&toLCwhIXn~zsu`~4S!Vg_ETUS2#jKD}(a;4=l%EF+^cq|)c-RtSnb3P~ z+`0}NW8BjJWsXo*<=`0xzcJqLD|AyH;YUvUmO7el-u9CP;Wepw#Mn}uF6xX*AFdb9 z)_$$QAb5knsrZ!uM;wqf0z|2;?Q$NMwj`YBTT@?K06dsw0^{{i2=n6&EKZG`On^<9 zDTVnkp_qNdE3ffq2A|MroW|$P7;_uSF=Dv*pXZGk7Pr z=MY*}At9mjpy*}mO@?z940n+n-{5InC1hVMCFA2ezhVo(ErSsM@%hd^5R4U4tdySPT>2F3XDXD` z7HtMpMX)f6dwwOQbjT5A#0<6iwc6E5v;pRnW$&zVLvOxy)PU&s(AyX{1W{$o>s6}2 z;jFbaRiuTGej?`v?AN(J+*nB+Is$B1UYQ6ICo(u;kwZ9!YqY-^B@w=ilo97X%$A`Q z8b6s2GyPj6AH|Jnsm{-Vb@HqN2Rt;yLpo3YcLImO<$aAW>uk(rzqs85LvYMxKLw?Q zGadeIOaA0p-^(?RHJicdkMf|Q8RgAI<{+!F>=|4~4~;N`E2@Jl%7oc^CEZZxEeZKW z>D%#hLwa36F@aE3JQW*_OVbrdrIDi5c&E zZh{^laf0gb;iBS5b;34o<~7L?>;LkA1Qc%s916N4jT3CIf-zI&1PD%|IWz_{B&TK0 zSmZ(pT2YgLJcIjqT)Zv-pfhAAMH>7zE@YpACX9uNCZ&+_|7y46BJL}VG+yQvC(sAE zkH5=FoqL*Yt~wCr7x=*#d7lE!rI=`?P!>R^}y-Ahui zDet?PfaEtA-UOTD&8R}`KL7CEVThW31-4=f?wc_R+D`CtOjcwTuW9~>V*_x0INV9q zUk`dUDn4200?+^K&S4cm`)*xVltEcJ^OaZZ9Gavhaa^g9$}OG>HrE4flJ)ng+!+_0!AlAuHGZPL`W| zch#9TTUdB(@KJx6TK^HQ=}hk%a2!$9fh!YrvBoQBamr_%R3UHaT)euGXIR77{Vd}D zrj4uvy%4$`x<(s&o=~y0+*Yq{4#8bp40Ahj`w#FEOR~W_+2^wF(4Y%LUhgaeckMh> zxTcbW`wetDAIvw=>q6h|=z{h$=99!Yi^Z@&60kK*{cq#OG{5Ydv!X^Coa-UTXe_uRx*K>g59$@4L|CXuwa~oPMSQ@;wOX27Z7S zBhn0q6FPcjOb0kUM2*((YZs|g0zjGo5(PHi6_0549=r^m;IPF7g8>}2jbXS}3~xtU zaQ}+rzmY2wmMPWG)X$q}B;*;{ps=`|S&KOk1M0bgjDoJ#U(T9#u~L~d6IU-}nK71^ z+n}sFd**s77Ds0;9I!OkNai;U!g$hjyY@Vx7tCvo(K#GqUkMZHy_p&=k=az9?6j1o502b$mIzcCO~ ze2&x%>LT2yE|d$s@R`UCoJ{Ths3YorA88 znZ>WiLDR zhE=-LfGP#|V_5SC5(8B2y1hC+y<6B&fZ@Uba*+00p(ki*A{RE@a;k;}MS<~(r8&?f zv&=FYUTh$N(5mbe0xmTtf{u_8z}it736%|>2!VH!w|_q-?QBRwNM4`1A0mOxJ z-SmotRRhbHs@fwFJtNRKGCbdv;l9k`ebFq6T^OnxjKRzyWnNhs%JelWCT zx2WkaChwjw4uNaMc0NcLU3Gh{gVt@(%P(EMzO|JI#3jKUJHS~{-sB3gBwsyGGL3yGnv$d>)`vi z24tR%5Wr>#?I1+)CEjHA^nYJ6QU3R*X>hjlJUeMA`LFYq4|M=$xV*L6OGINR%9>L4^|!oOBZ z6{s0Bukp2-Rb*X-v&Vl5Hg7pQ+hkQ8>^BgR^+ z9fId}rptEjt4iVc;;V9f+qNBdzKt!dc)T<&5_XIr^EH~@VMJej^WbKgArVf0EKO(d z9~+h^0&*-3mIsEY>oJsPFFj5F#zBZMMi7Fy0=m=v8(%sN?5oyZYXXe1Zi%T^?oB$= z@xyRpUXE@8@Cq780AK;w9Oc@B^e3v=cNCl3vmLSm{hNO6Uh&5Z3sqH>oubAUH>3Nm zKQUY-Rf?*!C|)A*$Qxi??H3c0Hyk@ZFt(iMRa-uZF;$B>I=2cRXb|wH@AG4G1n}hmfzO|SbN2xi^a!lUbNY{#Z#l$(6oi4&}_66xcj;_1NKR^PQHi*A8 z`F$#$Y*#@WbT2mRxBbBk@_)${FqP4W6%UPq#2e|Ldoi>okA~cECX{#R&XZ}9@9NT! z1DYBT)osMP%dd@BwpO-2;nY-KQ61i(@@M6DmoQ-YT166m(=U?l8y|<7?BmdpD`y!6 z#145s)eN|v&)YOCL60uHF&-}83@EhPe2bV`33PqnVJkTwe>9EDkvZ?w{{e~d*A4?Z ze}EWE4DY7#Or$?5NN8>iN)t1Xd+17$$edQfzNrn&+>f52}!Q z!0j&Q>PdpI=PB%C8vB5Z!ki?TUdPuSeq%4?t~X9w^5C2`e?7c*W_oFk^=WIib?L=(xF=4{bD#kO?(xnjn}|mS9Gf!t{h4jQWkY+n?)>fyZ1{{7D51>*2zv3mnhihc|~U(#CQe;NII?+v5I`NBLub`j z%x1&zY2ulJC|OMzu_Td@XK|pDF-wB-eM&v@?pTCz!TmfBBk+dcp07N@E4j)lFYz}%U^i}QHF|ve7fcS3H%jOs6Y2u znp4Cz(39i{fww&^V1I2`zL*J<)O-4_AjqUS0YM@PSPm610`8vO^=lT`^;_a478Z8C zjeEAwx@4OMzk`c9q803L19P1+3!}ZTZ-|Z%(H)vbj>}2nF(%4Bg$5ZhPF1ka4nG@* zK8Lu@%Vhsr);*#^ry4c5>LUElt|J;-4r)w3OGE63Ev05GjEs~b{^XfB+Mr|3bRizW z`o~G*pLBqW&c!*mIRb1$RzP?$Kcxa$pED{gxZz&R_j30&tEwe&2#~`^Zuf&8$Vd8t zW~8#}z@>a}h#%uJB16U&K7QfIy zM5uHjyzNrl)G0hiA40&kgZLK3x_Hv%vTTE#ir@k~`F@XW+O;!V&cVosyvZ8Kkp#6D z6_KY8!ze}uCtdn{wS7(eW{287{&_|B$|K-5R2!JLXttjGrylWjV>iser-pJ2^CNHqXB2boFYI=fX{5#^%&sIlm-8R+m_tO zb|ci2pgcq+7bm0g%EwF$2!u4?N?xY5Vn2y*2}j{-+7py_R06tlaIU4auO#FGhl9e0 zRgBUNP&Bdw!@LM{lY+0G!9;ADykPl)sCeV1a79aNxnc1iBixcW5!p z#cLU@a?VUjfJqcae++WCPC|^k^YkYwCoy3tfZ$5V5;zjvHGjx`C=UK@^boMd!QwiQ zGzB8@MFF=~a)J~L!eZ3nD^)@mV8XQo@{S`_*xjDkW1)xr=`1}+MP%?9A{i9>c^x|z zBkA&=J=?$8gH*KWc|JexocD8xL8I!e_BSF4tgzxG(AQXU#phvNkuKchyfcEaT$2Z# zh_1~&za&Hc_SMx*r12bXvw%^}TsY&==U)z?U_K=~$QS#zL(q#w>e%u)`pco%p`U#j zR|FAO$#6G<_)t$3 zAS4ERhFpj9%%j`(r|>=7M@2g`Jrfha5xqLzj&jS0N=CrD0nWHj0#-!&I3?~U`Xrvx z-o`}75|S5mvklk>E#dDbhbhfvf4LsbN?7?OC%)L_l680H%cU#Z5Bs817oX7( z9YAv+4Q}tSIr%qBU+B+hzx5}Zl*MqoPO0Uvt401r+`Ev9YrB6-9a#Ho<2Eb8fos8_slf{m`qvA9 z_9UUkC&QaQ34_ypU?L<`j2Npb3f=Is^sDxn>Hxvff^#kD16m#ANzk}M2CmJ72qL%6 zSeyhWxSthLlD?2mGYoJzLpZL$k+nm2!~BBf^?@{X0RI?{Oeo2^&U4IkTD>)ZXJutk z;R?taL4Tm=*`_Ijo&8r=J`0rB$H{#-Je-z`PW|}}ja6R8lB~wxb+SU9dLznTQ@Rz6 z#0|@)D+l>pK`-J^i}0t_t4$p ztPVCm>*HL-#Q!Hbx%?0FDGQ=u>%Vn|Bc%^5S2?GNZ+T_<%YA|yZ=RXuF6LuwtoZG7 z7?z--ZROsra&Y@h!2u4$5pzD8e}o6G<7J0yp#Ex!%UR$?i~c%+OO`!m(H(yP9m`R! z`;NRtn;FVq+rrjPuT&`U^qk=mDVO#S1@qB|jcC_#G5`wq({VHm@-0HNOpju5iM}UE za*s+g@as7h)3+}y1b%Cek>hAHAsRUC5xWM|cA$M6<$)j{MOH#o?FwD#E)a+)j^blk zfa15Xw`)*qfPdfzz5WevIUk86w;z*=zizI7Usw1ev5stsD5}TdSu0Q^lnF-6yEf{CZQNLX_~)P+?*slax%Z z7s_z~ejt1mUUiL4Wc^#t{lCU4ze?6oMoZc0aK}T9JhuoMGBN@=Yq2AEe3V1%?}G7? z{tKP*rJGYK_+8=|^!_p{nBr1wo!CC;cdUq8D`N+rr@No2fbk4c;aA;CKC2 z(ox=fbFt`1pcJqDjFW%GO@ZU-?UO9(xRUN>L9Ml^VV|#U9Or(J(EnKXcK(|l&h(py zBhD7?5)Y#iJ{>sCS5(BbKfe06s|0r;)o?e;x_p}&fFh;u!(4 z*y~Np+5I}euu}g|!UyJY%h3B8*&$!T>0(!DZyz{^zL33iBq>}6oq>yjM9NZhOJUmtM`C^|9PgCT!+Y{>yFMvjb`Q?bbe}@<`_jvUdz%}j(}m30Cete5secOh zs-R$WS@MdWvHrvUq|!KsJx_E@4p>Dr$Ji4r3=%T_%Mo@RYY_X|=Vv{&M6 zx#rX44tOW{E*;u0hIWN6I&Ou|L@DncFr^j@JfNXr0qBv(F5Kv%vZ?<|DrP85y{93p z-17QJkRS)+^1|r#n$tDfDl0c{;+r6<);pi%u&uw6EFzXc?k)k6LHawb@B(6FDDE|^ zH?D(rM;qJ_b>=V6i?*(_%Ytd`1siPlGK%e<($izWxG9oVTN6?Ugn${DSY=H5q!et z2lHH?@)ghi*3m`1HxH+INRBC50%ER`B!K?-CA?O)sn)qs_Q3;(Yk?Y zNi`$BROP{B))9iUC2^5#+1tfdnwTz`{tXMa0a&hDcI7=Bd6}i zdbZ}=`!XFmIX7R}L3$NHw?d2M(|P%)$d)rU{Hwnp6#^)dZs$NEmDz3@=*R;ctc}kE zt-SmMALGySu~4~m1_*bcag&)d;B!`GH4IWS%+D%_;d$bhcTWT{JG3;iqT;&kt8NZ- z(^sP-P)!?#K6~?ZKvDIQ=w!FN4JYz4Orvz)O1JJ&vZ(aqX94v3+2zt_d=oANg~T_Q z*kmm$d=0zo8G2aA-S#;Y-?nnBwIQ@WCMazf37CcfL*ErXh;99XL~v*(IiZXLb|cn! zMuaLg;c321yA%@wSx32Lt{}YybVb#X{sz_8E^)pqk{kQ}G&Q==m;`I`Ah%ig*Zi!H zzv;_XiDEO4b4RN`w;3Y<6o9EIFwG=Q!b8^&tkF>b<;4f_=)Bo?EoR?ZMDBuV97u`JC@VpRoYc{Z3La&`luRfyNNJ=9~hn zX;PcOBjZ==mO(^(NO4GY*f&=!2^_nzrwj86UMpxi$id8|u_M8L?^NPx6qve~w_RL4VZqdWKaauf+-alR7&) zJ5S2(B<9MSq&ONxI30=M>c8?!dSf3KiPW&v9W;v5bFba{=o1~#TQqgW+PcCybu}|Y zq>q>F^#l`$uk$REQULv>%xblbAVpAstd|wbuyQ$$K<@rGZ3S~|wt-Slu1$v+lM$*H zHtI-uP?ET0K0kJMVl?N-7AcfSu(D-D_zTayan?w*7~$at)j^qel3jv}ZQ&$1X*_X7 zv_$wTR!uP1%;z4Eeuk2+2me@45#G&)s!*pxNiG{K_wB{>5z0bI8d7vB;`#iS5Wcwk zTv*rpzM1>*CoR}+z2FBAAI3Zh45I8%3;lVYPw$D0M`24|{OL>HL5Dh>daobFFdrV~ zCQNx({8TKw8pygg;0oUnXWq~JS7p$ zp79_}2*ItaUk#bTv)h23-t_&2^!khLPO%K(@A(3v-VC|q)znbEqDp7a-1qs8dAU?F z{9_oiTECuDm)US5DLU^XZRWWD6A$K>J-Q}3>$DWLZU1~_2A)6Cie>l#8O^Cf1pkrN zFE6D%ZT?OF3R1V&l%dS<=uzPNGoNq$<7;n!aGMrpz!ia?Cxd8zhc6|y$6#K}_JbiQZ zJp0Yx52R{AHh1JHc@zptQGYHh3@FICj2eT@I~}yYKB~F#RbS#x|CiL8X{95dWz`mc zg0hF@Z?sKn!w#Vx@^ssNQGKZBgd7Xzv2^b{+su)02u3y%Xt4|5W=#kMUOG8xhfvZhL##nGCsGk!x(f>l`{BPe&w zRDsI@uM3V%V>2yy<@ocm+)YTJn}&D5z1)Y7!ooI|Hi8LLev^WDJ^PZ;Ao*cBkZw??LXcoUZ$|kOD(}FH)Ju1wfN__ATrnO(7}ZHtGW@lBVQ~HVuCNyz zXHaw#O=;T}Vk{DzWeI)4F`&e4zuibSt+xC)nk88#>Fbe0? z&f>?#e%QjddZmZ(O}tg16cP9v`QYCW=MpbbR~rn-6WHaQ*FMUe#SB=!*;VR1=^HV? zp9|{LRD2`@yTqV!7Gn4$UXRt;TP{ne zMHJiO3ygAr&?PX#$=sRxGPz;KP1!QKHM*%*R+ot2VezjY9lZb=&FYS;56e#~Al4s~^|t_Gcvea9dE-r5)k z4Fu&8WQ`?ahAcrvI`B?5;l00G1&O#t3r`|l)`}`yGb1{-8ic7u)VUwIKNdh(Z4SN z7^2zM-QTCVE~V=rGr;LDeh-uT#H`HM&!OS<_jLD` zh6;mI)wrC{q)OGXzgfoR7~icQp>h9DqW$5v@}aV(&ST%5cZLLAoGKX&F1AzZB<-9> z8##!?N3H%(y3-sMM3q4<*yKzSf~pnt;DZ}k+`du0QGF7U^brQ|KJ9|u$QpS^qhIcX1#*BzGWv_A!>2VOU$Yz+P%rlT*K3euM(Og<6E`NQshGBVRCGEi|#&=tTV#f@}eVwa|RbccjSz=*7j9PK@7&) z`&;b|tL$#a<8zBif`C88Uw2uu0ZU7Ob>^gK2=X=QsSD(<%MSE1h*ReJTu4!V{nTjP z#(iQObMsy1ogPh)$8sJvTY?^{{|!bAY=^f852^5%%s)V7J~tg?&k~dYtOnqhbDTP{ zSB1zb9(sDdd$U2aBUwwjj?5I@;A9Z{a<)|AXdQyZXC;f5QF|c&+W=2KQ*il7{)N04 zlDO0TowF}x9{OClLcBhl>~1-{{{5Fk`AYpE$3l1GHAmMr)`MD0Wj7hc8{TZQP6!=t zR3JO)L19IK?S$i?>mVEj&6p#-HdNF)m`S#r!Grn)+%kKk$@b34DdOYg;(XFdf6`r$ zI>~CcUP0IOoWR%Wo_(y2*8ssr1AawqzA_m1W@UwGfD#ka2vq=(f5M=W;ZVHDwCX&g zz98k1n+N<_7@pw;bcEpI;{rIwTKIUuK@Q{C*+2VI*kF`|EVgL(VJ@ zn$2J9g5aK6LipigU}E_XNMtC1)1`Bs1M{z&;zA zl~%74Ssqds-!xI^Uf}^e(sOYZVm)~H5>^vPbc?ethQbp}zd%zp zGF)`BCHHGi6*V&=xYPF>yaUQfEf3;buWmMBlR_QY_!k{o^?4nu$_4H{k36_ z9Fm7pMu?{<`zm`rE>uQe4R?~SM0k?|>U@H(L7FjNP=Pywvnh@?k{a`Y5U+O#KefzDWO#rmDBzk&^m2_13unMxW z%$C0q1QyQz#n=Q=40hCC*M#K)Ze?A`x;!z7{TGBt@cjp^JMaqZ7Fe3i!6ff$zHxsH z_-AhXefbfB#!2X{)`LKwFDnYJ?h&B2-;qy}l6DU&DAPjVRS`TFmo4KT6kYMJ>!P(O z3HzoxtL*hchDk}maR8At34T+c{CXu+Q|sZis2};HaT9U z)?gAf$EqIe|q2+=@cbna58Xa&0ad5u-?esB?&&JdKoL@(LamCiD2I27%Ig1 zLSGAQpKsdq*&)d~NiwH&iM<4I$#I`ZH;dMQJNc&ha=s)#=br535;pk)Y=RX0oARbd zE0zd^|NcA&md?(O`s#?a-?5QusZLAU@UO0!zc9`K($2WrAL2~=fK6G)yLR{8$~E|H zv&?#H^_hJJoo%1CYT_h5%K7S}A@9>S2?CQ(b6)#sUkkmugUL-fo;S0!cGes!IPdn{ zv;rLUE1&<7l=Z*fkaYw(=ApK!8@G7k^{Q60G8_X263U*1Ufc`xd|*zpdAkzy(|14hn=}DdHh=Bdjy%#W@=-b~fx~!u4v*OYZ>cW}R zWis+L|H=sb2T#e%gT=GpH3Xtg(&@)fLD#oIX3MqCM0iuInH@C( zf8yc{WauwzZ0#(dw3cw0N-2T9Gt_@?n`yDL#gg~-FZJh*Kk-l3FG6bq?}Q8Y%Zu}> z|Kn|SYaBHH;s)~^hUm9T=y!`Oj=h@8S~I)t`z&=Qk-Kio+955VE}ilNLCG2|iL7n< zaYW`PV`G!auJjuF@hQ)Iw@yJ&vYp7{<^0esVEd9Bs)p1QykU)cew6s>~m!V~U*<+IJC4z=r6K2~}XZIE4iX=}nuLO%Iz zl$9fWMrpk0I%!%wE%JqJF0#=F39|ny1|Z~VEaACI$N3;^&oeONwSV76b(wm*ch^6ex;n#Rc7m@WSAP zo7D8Z?C(b~fn0IfK#5~gnpd*DGR9;wR6nLi z2;C0!2QTRx3fKKD4y~=A?1)Oqt#|BYQmtAP+olF4gU0Ox-)+c0 zxh-NZ5BpAgi#+ixFsz~7B{Rw^}u|laUj`kDdb1)e$BF_xxSxR zM_@8`Mf@iS)1B|Hd8_R7DrgIk#ekmCA%8Zxl-fi1L_@^{K5-RpB`8Ch9Qr6fRmf$3{n(yhQ| zF*Etgw$9A`xg=7-`RO0M5=7i+478K4zuu0nTIzi6%ab|N=}ug}B~Hq!t+Vri){}pS z7XRli_t*cg%ioIW+`9PAZa~KZZXNqC`0)3Om4Xn4S=Dl@e;ai6rxq2^#s06I72Kg) zZ5)2x`_DCNj@WJAX7cer;Y-?B{ zUc7skoNp8z>GBJ+BiPkAs+^sO{zc^Eg zqvq(LFN;)`Al+;5@VE7sz|8eW^hTi&lptL~bX@f>a~=c>@{@UCQ2`@qP5^hbn@QsC>rZLtFSN>X`y8`WH`Z#D$KssTkq|0 zWQ%N^0n{Wu!jCIp#IOehWhG^>dXG;0^F{SQhu0~@;j)0O6yT@A=a{b7{2vCynjb$Z zitG}33e{fppnk9B98mfBCa`+*zXfCeOq2W~&XmsG42I98kLDC4>Tt~J(s=eu^D)-!X@-%Cz3_hW`{z$f`3(XOu7H+Z5>1?_i}}`))GlS~ zIZ8Auk;J0#Gco4FFf@E0NCU)F<31|ynH9cK)><)K7xeTKZcTa-xlJ#v)uL*2X1Seh zQ7rl6&!b!$+iz2-cfl{xp;h9OCaWAUARlElZ&3@R13Q%L1*uL$!T>1OSuR(uv_#qY z8kul(n8`aZj*0THu8~2WpelAeJ_#|clN^@j7kMg6RBbu`=->B)LW{O`fwg@}yQKv5 zTb^|X!VSO)pcc=lsSIYG(O1ddq*~LR9pt4tjSQ zf=sK9PHI)wUJzG*p8uBTP`xK7; zaK*^Ob1u!d@ic9tcH&6OEA5aJ<=9dHgt&x1{_1>a(GpSX5E3i!pFL=D{;(JQ30T!- z|8J8g4!*~}R~wT*VfS7acw_~G^bMb&S8YS@ehM#`pRcKjzPkP8rq5SYgKhS$1!Ztw zbx`}N*$5St_1_IOq56Zm6EV%+2VOdyHbU9qGeE*pP zv%*SsM^@8uz(o3lZ}-IOliz6xyAMQ>Jc~G0=Bl^JfsXPevz0r+Mp~N$FCY3L@(#~8 zURPooK9ZAcje<+!Utcw8`0#4yP-qwR)giS1MUR&gwc(?84ljGaDcKVQ6_DUB?!w6E z=hjs^!J>p6gq>Bmms!%BeKRPi&7%B!hjiM4o+5F_ccgzVeFhMtHwfdKj{jP`)92*?e6jes43v#B z5{M%cWN+Z!Z(4p(O({uDr9+yI5zw>#_RgP3+ckdnzdafUl7|H_jJU`K{u%HYcZbX- zc1{1eR}45T$stPo54FgOy95`oAucT{Nx%H{k1wci@VQm84d3q1?V9@Zk<4|w=9PrK z)Tu0tozdSb?b;A{pAMGN(u<${U*pR$u#{FvYF1KceRP$kkgy?ehV&CChx-)^!2Asx z>0I0x^~Xc1_IP@a`TsF8foaBZ1!M@Uu&e(3&k9>gEiw}P49EZf#|pc|vxe1*A~)}y z>507C^&cJl9GbISQmVdQ3*>S8l&q^d9Bu$YSoFUTmY+I^@{~ApMAA)MT=tdY)*^VN znh;i;!Cz+lfKINA91D#`)D|9W4Z1sa?e?u%ov)TRQYQV@(Usb+{vy-5+5Ft6-)hb1!^hFOu?Bm%u3>g% z$85e(u7k<5&a{R^Ti5*#QogmA<%Aa(-=o%cW#rH5ttpP{0iU!h;v_0KEQXcmnXcQv z=mFP1af& zqK~N%9QU2icr5aamZiL^LgC$4MW#M5+|Iz3kw-4te@C<9|F|+q+qD1Q9%xiOcUx2NelSltkGE0CbdDWVJwMl%FAx9<;fK)vljU^@{-N8|k$q)hp zrP0sLaM>zyoXxTfN{QUM=uYnQ+&x;>Mt0gw_r@Z~bb|Zw$DwbUJ%3HR$mNIVk;ibv ziSdu{CP4cpMG#CMDiaokb09rR!1;Q*rgIA1XR3ZUw2stZO`kFK>ox6&vcf$lny=y< zhQo&OdRjS!Bg(SxaR{rfLDeJ#deUbR;Yjc4{dyQTm%_>-nk9R}j$`lrMlD@zS=0;I z>NKC34vXBrCvsSRtzNx_;TTwU=sV(bY~0Z{<95yExQ2ZDH886{CQX& zkh06r?O%5+Li>VP*Jq@aAw#RS9hNxaVqrUi{fh~6{^RlQ<-9FqD=2Ek9*9<~q)zdY zMYtq&(IV`jf6>IwHt6f!AE|%$*2v^(z=vjnjI+wEjg&JE02@EI`+&nCwdSc3fplE0v^2j{a zFap}BHT#>z^w&!5Povlph}uN72x~$1aD8 zW>u0OepV~g;ERF}>*asLvN&uT@O|?Y>y#CXDh=$*co3F2Ef6gKSb5+2xy~1@`#qR) zEIx*=H(HeyiH?qr@^k9$l%(L&Uvyd5P`$FBi)S0oxMSmGSWoZJKMmgY?TLw-6h@9E zEu?myPOGTQ$q4QlTs)ntgv$I{{rsj!``Z#Oem?Uv|{d# z8M2J$gxP%W%*NpoA(6J-UFLN@yvam5&v8LhPm%07<&>dzJ^!)??UlbMTa zAJl8Kzn*tJ(49J4r5Z-^XiTZUNm>Xqen{=uc#jPh)jP;Anz0Uj?>lf)-2DcMPDv!$je&SF)c87>A(sPhS9K0#sS^~9i z2TyVT;p_X#Ag%Wj{;7Kvdkg;;V&XVh7{f1b`!TY9mauh|pItn91XbC- z>0o{>Hb`k0tY}z#Ur2{s%1F|irNu4QbF4o3uh@KbEB)WlCH*p33Dod!dz;;1fE}hi7l1=X6ngTB6TQw2i1mY4$;LiD ztv+3`C1Zv|wd?Gwt2dkf!TM9JeZF~bl4ag_^6)?|Jr-@xNUz1Z7SDXFD-;fij^SCmvqI6-J2|P0dfXd6-XqZDy=(E{tRvZzqNHH!ahGzRU%*=< zZsvAR9cGr-yOP*N0Wa&A1cqhRscl8ejWzR~v=e&36<3x{4|1$wqwOv28K+XWW? zB~-j$PxQvW^HOrR<1szXsPT&vC0X#^<(5;;P&1TaiL3;up(G!7?;TfUpTQzs11Osp zAV#zUM_JuT46b&1LOI5gbh{E9zJH6|&D345wj8Xal?){%UXV6?-O-UgoOk;CDjgr` zED9~-(uUwPVsL@Bd;K?)IlY#Z|M6&gZwlp`I?f2!8%td(I+7EZrg#$v-V3OK!>#8<)h!sUzNMRmP?f#-#K)X_}1CBh) zlgBrs+)kq+^R9K0R8AG^RaVm>aavkV!PHONF19OM-e3C3ZT0y-=JoQ~2Xal(mn3d9 zt0kfsemFz5)pNH~9|+hJM^cTzJu%`ICxEdoTx6X}#TjaU;KN5qL_=I1Ew*vBSa^8Aa@Lz3YQdVjxw!4#~*j zEoo9NLm|VD)OFTq){Z%$D03L(*w38g-yA( z-((q9j#4vcFeJ#+O>(sS(s3ok;-$0%3hqjhjz`3N{@OuOZhglfigai1;bffK(Io^2 zB1vj~NT1FNg^4F&L6Ytl;8Os5@aHO4Hb`HF?2|sN^E7?Iv0AqY6@XFyN{s|{YK>?n zaz*Ir9F(c1VN(`0C$9CRX=71KoVnl>ya=&v#Z(VEFf(qK*s4YWgJq%pzgmh%I&}@A zA9A`~tE>upUE2`YRU;;Y4U`n8a33oadYn(CJ;#rn#`U!p<@*#<7Q^cF2A?fXw)Sn3 zhJ@C*#)h48nQrVAMK4@!XUIg9Yfu-6g9i!;_VeG!UdboMKj>T;CW3uT#U}WuboA0VpB$qo1W zp-C}53$(r&5}VHmtJWUL9G1f&j`M+OuD>mYMk*ju5&BjJdZ4Nvk)m6&=D=Ul3s4 zd=b9WTiGDuou5v~D=^gnCHZEx8SI(|Qd#TA@AQNF6?_%dpVMC>$(-$}s;3{cWj{qr zpnfcqcvUuV?C$z7_!(DL=h6sqH|_{nj2=qbUbAqf1Gm;Bo^;^QB}jtYc)laXxP)F| zV=b{SF45&xH?Zg{bbx4wm(|++Nl!iaa(CVRem0hlYF47-Vr1s!U|nF#*GkE$-gg2! zVWC+oSurLFNKqK z_|BU~O`W@Y{H0j^C$60=8kWZ!X|walHVoDA;5reAQ_j*ccINRhUL^8GdIfcwQ^E=% zc&VBHXoC9BU=HYc*BQ_ay6tZue=!t;DQ^ZZdzHM5PqvsSbd2ClHGauujHnj4bK4TJ zH$kDb%|0U)jt z!=$H08mTtCyuLoceXn_X&OcuWs@)5IeiX&g86zWAG-y%lvR|NeId26v=&4(^DZO;< zS-mhGn&@;OcbT+#?`Zxi(_4W&tF;@FMKeqKKqf)u+?@O3oh-3c0up_;yW>HHe#zs< z&1eNCx>U4{nQyFfetO50^g#s7gsSu{meb2ATjhz`$?6-9h% z&g8>0Oq~;jg@5ssO(L0EdfMDC;Hvhxg6|oBLFZB*4S@-s@L-*6(?UhYBnkj0AA^rp3~w-L zbk5;Ts;Q<=x)P)w=L*%C&Xa8OChhD^pY*uova)m)F)BTE&E7M`BtjROo^dSgR6Kw> zPLbZ%`#5x|aAD8MB184DPkwcic!>9do?h@C{6 z1V)b1)##iw#37cyO-)0mv5P>F!`_{UXb}Otm>mFYVFR0J5Kt$?KD}W`gQ^WU ziSj^VB=`IvqC1CnWl~F0ti{&3-870hyl~`>Qf~73%#;O!|G|}RE(DSVDK$1ZeKD6I zwQNquVY+=>QhNO3eM5Twp$rFfKFnM5w8GS!sfiv{-hZtCT*nORg*@eCwSuLo(JD5j zBVVH=Zq=>HP|u8UW4$urbBUZUvf(7JTAZaubDuVsOgv2RX^3};;f^LKdKxk@ z@bSil3$uw@@lrk;gR2L{efae0fD>6_=p&DxFiTBwuh~H?pQcxj@Tl_RBF%A+d1c`D{}jwW__h9= zxakeExn#p|b`it7lg4JGh-l5e1>>=Ug}s+=@VHh1q3C-0uYt8iD;MHDH0Nwz+o{8_ zq8jpIn5G9s;kOKDw_cU2#-K3$ImzB9(4?Yl2Fw(uM8_|cjo8kC*#g_rnFP{L^eGw? z$=7Y*3{ty(%0T`2H(^z}yFXQ&SBX#+wptW5XAGMUkbhQUD{z2{^FD0Apmycy)s>m>f+uWd}+~cO{ci!{_*GjAkA;eMxra>&c)TR-Iw-^&BY(?_F}t3=}o+W9fgEf>Anr|dXfvNlg?xF$Y0+|J7h+BiAxWCq8P4(NHb5W`Go zReDoAeF`SS4B;Pz^DeRxp|}()vqlEwboek;iol`i`CPZOvZ}AoKEiV#prP|M-$qBt z@tE@{j|=fTAyc#I*AWT3dViY^6E%604y}FlbTiLO>J#6~y9qxnv4t#Fy`o%n&IcGm zNT>ndi+Fv?v9sWPR+C1G451D}`gxhFT`PA^7{d(kD;~(u;=1%HQF#=0xRn;&=uFnt zc792UBve5KsXIj)Xh!*KD(SpV)=GrTkwr?;&T`BUmPLIN0)?Qw35qXslamO22x4Km zL|6}TZMX~s+kmkIn!I?2Qdo9|)BqXs(e6=jKQakb1>HV1c`scSS?GULsDaMl@6c>; z5;myXtR#gy>LrA4UQRiOQHl8ydez_OO!JS&*=6?+TQ=`g*8XKqsyYUOP;BmQ8%c3x zX%bmYk&pTo<#mD>Q~`+fQ5CW8h}KcXQ?^Ie3)GVx`oa($K?G=zk)-I6pZquL6GtQ!BbMrfk(oe9M<(j;n z9pKA{!WHGO&Q?)%>nG>F-c>9SG>xANR07|#f?KKjvFzT_6uU`IPj)l@Wrw8O+$W;x zLKchc5kGSV)2*1zuj7tJWZ*z>OYwY{6_F+EO7iS-5to+PDyWx zPH@3-@ACFycw^Odec3zvs(s4h3F^k9*!cW!=_bZBA|pJPI6v=Lb7rHye8`{pVE6Z4 zJOi6JkFL2nESA?DD}rRbNY_GAWgw*eGP=Jr7+RE9q^lz!aueLB;Qxh_QM#W=_h+wW5dn}o|_(#6eW zc^KE;kss4tk_E8qU+7NK$}*APrb?*RR3+p-sZpXl>L;@)~`|fQ`BAfk4Gr4+MRn#0Kf${If@Nsz=yFn_6Y(gFR`4kJ!51`XuWLG_& zX3PQ{;30QFoYcRrew%SGOf>!wO|nJHBI;X-$SmvGAnp$z5%eA5;zeWUhRZSYl5crG zcvGJ5b+pFyhhIR`&m3o>`%dVM#YU^xz2}pEPbN;2ElCm!zpl)!8sENA;}DJmi`NCt z8Ifl9K@!Q;SY98ET}LgNOS8br+uK*-Z~4r|W2F64Ak-(Cu?3m^Gc}6*e8essl`=$V zsd_{4$tZQCBln=9Hw`tAnP%!F;kSSHntYZK<|l&e;kMm$U;rerc6gtUkh46)&0lOk zmf=2mWIlxmrI_$70Wyd?q}s*b?|=;GDjZ}Z<(^_0k_3XshC#eFTA1gfP~^S>wyICV z-RdF2imtm*Tq3+>&yOmiRLuO*H@T z23++b=8cod`56mBFb2T20yozz*n;w(9=Co$>@HH?>I(jAK_t}T)0vJ97Ks7sYDkFD zvX`19sovphvf{xI-q*O~*gm~o^=-#{vXd&i-xOrW4Tis5Qr0H0xcrNlgg9%c$us<6Qq=LWVXwIOpxoWh3MYM84;~)Q2x(JhTi@!3jp2GiNm2J&bz>T zVl_Efm6<*Rr!@lfE8m1`SY*St%>8BI)qPI>f!1aGS5pTC*T5@xZy&8eUGrN%))`rS zylH8<`9dMXn;}Pbt5WK0L?wZUvYOkNyD%HPQb*e1f%<5#lk=Uxi9Tkv>73XwVSF+x zZ?#Lp9mm?-`aK9obogDMLZpXJK^wLQBjZfIeZ)I7+WK$uluL-1hUd{->dq4tShGKL z>YM4;8BW~9EaV&9C4f5}xE7h~A1EVv(Ov_C7}Tw|Q{2plyUhaSvkAGgRU013^pV$P zgV&Ya>5~96{ju*iV2q2oZ_og841qpKweE~$eHQUfYM|K_8r`ZB)seZ`q%|7zdp+K> zpeJ0>>+cL8ZXAzJi4>!Lj_d-=Z6BAL+dj~5B2qrZ*p{OnJyWkddR%yq5@Q@MxdUh? zb84m(YV_@{cWXp%p-A}$WH^n5PK`h1$AiRv%hhr(^)^-$H%Nz$&v&kuv^fth;aa7` zen*kxDpC!5?zOF56wTxr!<|6$WcYN6vN-s3hpoo$y(2-+#`MkzNQ8@D-r3MvfidZM zA+QBqdx|c`CS!mGT?noev>Z&48NZ;FOhuz-ToKK)V$1e{{CQLW$Yn@-Bluf%tr!&K zteq+d`*GapaJ|^&<8*Nh5Hpnx1W|s9t$SQ}xEs~M;|?Af&e!~Q-?}~g`QDnxY#C=o z*UtRD1Z7T5ryO%JW8^3z5Kl`P2Vgbva9`4koNAk9OSRMcH@H97-TU#BGNH(YRxr=k zh6$L1WP}uZ(+3H}rPgBEzpDG{g|$DO3k`X3$p{{BAO1{m`#@1kzT>um%Q(5OS;U8V z#==q@+bnSz|Ey@>^$%(>FF9Pk;NjmP5<(Am%!;P&j-`)M+1l)b)Xvj&mp>Ihu|o=I zjrd#Ib2PmO&0Zh!Hv%ufA6w{Uw(c185wy3-s!We%jGr|m&&e!3b#|}Qpwq$(-fKRq zE|TGnm%<#g z+s2+HJ90z;NRH)oJsqu?T6XvABw8k3_~``SMsH?cGuuYb{oz8@Kj@?0U;6ldEro?& ztpIrc$m+*>Jmxm*`E0f;jt{i!KC22L1fL;HeJVP8Dpo$gz-N*pggc!7$3!+RLG zkKOY>C&Fu;CVTpDc_gg754HG+&jYNpFKJ``s8;qETgd(mD>pIL*o^fq!kNsLe1n1C zo*BrSn%Q2&WrL3YMo}abg>Y9nGc@?KtWSO-!94DS(A?LdbwK?z^Dvea%jf&0A}}|r z+HPRNV_q1rhJP_TLU!27q;$TA?w`mDJ+w62o0pdFavj2)d%FwSiCSL1f?D6L(rM-R z15L#)bV)75%)Ra96(7WF0(dcGnY=gFa;w6aK-QD1vgg|e^YU(MA2q)HfsLzg0ijeEVxRup zdNTe%WYr6e$48Xg#^toPZ#{PhTdqoo?aR@=<#1Lea)7G<)=9kwQd(bkhc}v?Eq(C? z_pjM}G{ynd<)lnw?!=A8&Y?XV;zTLO=?m>yfa!BiR$mqMir0sAF^BDZS(FhL{IszY z%#lp`#%~SZkS{BwdNT?}xCA%O9>-;P?HsE8Ii!d)0D9R4qxsh>3m1%_M!0}Y@eH%V zdxiV#r>49Qy{c`5FXp3vG`IXypFDntVdAW$*4p0iEa|}O4!h&WRws$9aaIoa^gC&N zoc^gTai1miaRb|x`^}lJ_LN#5-?8Goe*E^8amSNZKXHk7zglr*t=5t4mdO+p&eMZELo+W*z4b+|@eYZq zgyTI*`wBkw+;4YGTI-h4ayctAyBIYfE@V6R<#c|1qe3~LVCvMUO#5Vrfs^>T-0Id` zZ;um7mMdw}Dr4b5+8xP7i!oqvq<}ZIhT_pr>y44IbTsj(%~j5Y-v~dYIEG z1xL+h+4^+vXkUsDNkK43-absfe1@e;sFr$U&$Vjw9d?b%uh;WrVzsl!c&&&-6 zv&b=Nm6;>nxOETrO1t$xrOgm|g~3iF9JqRwu;!Zup~$U?N`Fz)3wyWJ(rQ9ra4 z{d4n=!fuM)?rz6Np1Tlvvz&ba;W4LA2BD>-R2gOlGxN$+1MBx`WE*H;-tQ{GO3FT@ z2sB8>BK^c;ft$|$s=uwQot^oi<7$SHU?l)#tksbF@UWrxo2#Xyy<%0=)2ch9o0}aM zsOkHk&BRH#38y*OSWn%NUI})O1@0J0PbIu7(yY(=^`O$(RxTB#_t6sP!sIBO*XQz1 zWG8myi02%~ZgUNkIwdTI*y~=Pxy+yuoXGWBXRWBN_C^yH(V4I2w zDyC=_^Mv9)3O;j@Zy4P(x?Du(TM6fjUQ7(xhEGE0@vT^NCPK#>VyfD1g2mB-6P)NA z*`TW_&GUvCHy^xCySf>@aGeQpNb8rQq54Z_^TC?8P-;VI;eD86ZZwE-+Q`wdv}7vQ zE(7uivrEV_cqNq8S%UUMe=+LEGUcr^(XAoAkcAPz4S!tzh##hU|1^8GBvjdnUPL0` z45{T@>cpv9^c}hn)jT>w>YMT{TgwNs;U>wt)V%YOj^U+oInyfd+^RN+9#pNB%Q~cW zwtZME9-}5@g6n+eu%D6Jx#}1a?FqEp*6UNo7OG(^CiTBMEl9lOQ}ZH z5znp+`cjJ49o1{SocP!9evTubtgj;zrj1NP7rdW5>Bz}{kI?VoHQyEUKQg{Z6SK^_ z|Eg)7_t&ovyV#%YI>D=Vh-jg1P=yV508x0C zxyVCpvYF>pm^~*$(m9%8TFiiEuBZ~mv1{F;$;lsY4ne9nLb7-s3o3egJ@zC!55$Q` zG;`@sX&H&eQv&Wa#iX=cZcNpTMcECxM}_b<-eaHrzoeuWd#pjGNmO>RbFV|SG8z75 zdA%6G#LiSv=YAB$@r6WAyp5e}uzFKQHOe9w?wVecAB$_#=1qhmGgwrK zGHO0eG>Sd-=LKn)k8!Ecr~Rl`tS9Q;$LePd6w^(kO4YC`^JGm%p_-XK;=-zxP25A6 zogf*{!h0xgXY&XA4Xryr+8kt{l2kR>$(+$x2e{u)Bte|SIcGB+R~cUn-7=wm_gWZL zSdwT=lW)Uo`i;&ZvX7hOn8L94dX$vR5m_+Y>&w3f{lRecSR~yF9Fur60xCfVpfk(= zRVWFxHEI$=2}2}7Vm=G|W6d~zx1Q9WoersE9Fr4?>Ho*5f9ERFH94+$D+M{dvar>! zv`I2bkrMa!#W=+drwDV8&>l;-plH^!pcI8=8(j|)0#LZO;hqD>&l+|v1hn6 zN1Q`5q51C5c^WC8EpCJRPW0N|+CAad3J=Gp8fph3d7H`>p7Ebz>FbatZG>~2zC!%c z2;gvUl<+QYV_F*ixCE1nTiLn_{SyoZIk-_zK60jGJIbDrj{OMuNyRVXC!oZCj+i!0WohGNlhJ~ye0s}Kytl0ez|dReK#$SA zn=8SlxIRxc931Gn2G%^ zukMm`=my#t(aM#}hzi{pt*&(S%^alv+xywtyY-G^yZn=V*@vXxRH1sY)8CxOY(;3^ zrDJ3{;xRP9B#MC%XH}j@9GaGQ@Td;e(gM68vG=m0q{;2#Y8lV3=t4OpzYVhb_K$l< z>Xs-(1bh8lc7&-IIS1Cm@@JisEHUXjT$w4jk3L@`oo2*q`>M}Dhl%o3^9eSFjJS5q z?V~+gr(j)-sy1=YJD$q6n5g8NjArD`kJ_-lTy&UfVfd&Papm#tKK5H=;|Hs$ke5y0 zI#4CcP>a)vJc4spZ$}=pm-V|Sda~U_2x>orm-lx84X2uqGymmxYKP~ zaoXxhR0UP=h`NbFFSiF^omONz&QL>+DGyz$QrBCDXxu6XR~Hk@!LX?NI=gm2dr)0) zN8vBhG*aYxwoemHAHZE(l@%SOhe%n$56tW`FBigSw!P}W>k~p5*UYVvUi~Kn-<$FW z_F1mG5&e(&m3%Sr*SpX8{V-mwCh7xnW*m#fcKE1i7A+3xEgsa8q93vAtgs41t!f6lc5U&QVBpgh+*({!a1o`}lk&-Ww>AXx5taZ_h~k7`Yd zesIJDDZVUCXrtRr4bCUOmZ<{LaXSr*)(LjoY?CG<$ja2Qb{{P+P_cs>;@qg*`6caV z_pt?%cd0b(T;E5-+bF)68xC_{hxS?06ElvZ^q}FR)l#9zLI$c~m;bG}$6S6KRVDGS zbUOy7wS*E`U#!)wnqrO{tSlVlLgF#8j|k`7v3v*c_ND=cF#`E@~C z!jtQ{X_DQEPkg4EXu()YGZP~|P<1&!fp1?h8E&6X`|5D{hff?FLLe%eup1wGs1Gpr zv6WYsjG2ahQL0em^JYvE2L~%6 z;%FfH0;<{7eRS2+@2(T_y0POQYdy^rwa`J?TMsw$>biVM{feA*$TQOEv*p$z#v4L1 z54$J3?q|4fK#bjX5yl?`K!)8MzjNItzex=blJo9E{UYG!2OuViwVp{B*q*SV4Rb4b z)9kn8mm=Gt+dLMw&!*1}r(Z&kP=>6NE*e)p+Q0T__k%xK9dDA%gP}C3_KVSzk$C8=(Tu4(Eg2x40S8BZe@)` zj7PT(?fACoTNqKw17!|T7R#-ry^21uOa6BD0WFw$CVl~$dn%kj_C}Nze32UmKYFoh zX{gV*LHz`(LU9|MeV?eU+~?hCN<(<{4f&vWeuKu_j=QG&+#WnfMRl-1MW*>j{JTZalox|3;iF|ZVn!3}1Vz8dh%N~L5A3e*%Ls zZ_wLBy#wq#|2>3j%NWBE)dfdT?g9xU2jsneLQm`g3|I>WVG2vrovNK?a%*g{r#uOR ze8MC+QN>3AMU1sxz_JTxT66*buH|~Ra>zA7fX>oNg)z=-xdfE;YS2Ag6jnUjTtli8 zM(i-TU-;$dtp+5;$B6zN3+m)bNN8R5ZuGiQL@oydE5hYopB3cxAi(liA-4O)q7+ra zG-w?tmO67Fb_kqVflDQILepss&9XL2Nt8I3)lpc#ai!gW!Sy>>=&V z&?YE#a=QAY>&c`9J#{rTQ>9$j7T7({w{veM+bV3s<0$vvwx5gS=C8;U&c4@@F=(nt zxNULGdDA;+8Z0yu`*nO=tCY{Cm1>_sYcTw*eK$6#?)rYc8CHUm+QkSi~rvn>ZwfLdL z%ZV@%-Qt~A!**SU$4gu^v0X(8`wLNl;wlm7=twp)-l#V_5#n|mwgQq`>P0>q`MK;ozIs$>RuST+gjJD#dRF~B^tL#khMw3q0oo}>5Rwb7z}Z$J z|L~mM_fxY#&gr9E!~kSPPb~Zwx+&W!1J~ZVp`7WSBexQQZ&0ucBIvtqn?Uhe_eg2~ zGqj6wLmh0s-peH2Ts}BW4XY&e2`@xs9;jc&D@Rh-6s_c$XmK$UWVIEDFScJ##5+qR zH5gU`)21&892^nAkGF>U<}5=C#;XCc^KWyN>A|f&nJZ}&(G(=%r=X_iWAT1K%7`+6Q>u_j@02X{yQ-)!TA=H;X@Pw!c$DXndj6d=ts>>n;eddU0lIH?H~A ziNq5PonzP)0dT^aqe90)+r4J{`fV>?eGmSDar=9d+wAMQ?!7P8XuFsW+`r^7=M@+Gp~aisC$2<82J&$a z)EDZ*&8o(!%GiSac6sgL`IL)J=`o>}G9$~)>UE>X)S4 zTF}n(mh~PxgSm|N?CEjdtys;UbD+U^(@%b3 z-(mLqEc<5<@L}K^^T04+Qx?Ah}w?CJ&Z_D*l&u!YZf#Yg@?!ft*Z@un@ zJ%ubhf%OObm-<8oAw5q2^WOW+Dp>c}Z4X2QmArpQ4pdLW{$*$q^P4=J;vvCF+8+crEDpUzGbom)0{GFR`M6Tk6HMp{uc~*DuTdkM|5?U%q@f8+UAU&g1szW$yU> zXqoU_0(RoB<`7>?E<`+om(6g>9NLn9N>dxZY)H;M%}F|^(Uo_JGE>g)#@je|T;b{Y>=|9;GWnL{^&huGz*N|HNX1TD|58tqZ>~Z;9b&uD>}iEF#bKCu zGJ7tI6id7iZERc%d3J)D5!#K-jEcYJR&=LjDJf^Dw&=Qn}UF{KOzCr;^8Dw*A8*=C7|UK4u1$K z0G8BNmm5M%@H0sJiC6Q?LbaZC@&nzqAHIME3pBb08!De9vkJ41BURlnQK%N(6JX5FZ;snQL;*iH*Hcn6lig zaib}>#U8v-U!(7AHI1O~GRSN;p$xZe7rnlUwkKHagejov971_i`<}^`c0=4p%+&8Cg^#y40PKeQ2oRaTv~dOG!?O9>y{JUUY9OM)*&U3 z>~VlLokm{4(O#dqtPcr|Q1zXbU0~Fc|wA} z{_Q{L+fC;cnTl1Ue-W!4;xnStvWt(^7HgHkj0)=Mn)8P%s5#mtUQw_RH=Wn08*}`n`^1P{~bI5bMz4~%_BxDXR(~jC@4&S1AQu+4XkFpwP`R7~8 zMYP_}|FI=I&ot(~?7Y*wc&GKZZ%#K%v5PH!6C2IXBr6cmNNu$b$i)}GT+{~*toDjO z(+)==FcQ93$nP)99-MiSn)_`hyAGfQ{clvK*ga*WLG=G&>`%aA$=;Aq zNp>pBRElIvDqBq1C8_M8C%dxmAx0^bt?Y@0vWBb?l6}iM))~h0{O)J;e!k!T|2Tff z@jIU5eVcl{8gt+Gb)DCFp4WA&%Pyr!<=D^icsS4$5VW?o@vf7`_VuuAaw+8c`{9h= zii+9;JxgktykdD%yZ##QtAh6sToQLoe!;Tx+!${KLiF50D z!43+)#ZP@!R=pOF06DR@tJxfWo{6AQdnP7S68wfBv2VNKhJ;hs<@#r%uqUkBOt$EpD`nH=7?va)5Us|`ipcH)I@krXlV5OYY{ z1j(5f}{_!xDa%_5P`%&9E7ngpeh(vxzqf?(%;JeyCJkW#S+ZVs1Ul=^7E4?>jrwhInH* zRi2zuxf3fk?oioaGGDNB5A{iprvSxTdD=j*`G+F?UoH+YN51Q4$j${c*$F6uW|PBBLzo^$$t_@x%V=#f`RY@+E^nduAcz;6hxJlWPZ4a?X z-v7hCs64G0s3)m;%(O>!{kL<03s4>77Ei|>>ysQ(B4z0A>bLePGOi3Vs(aoNVqMsG?c4(13BCZ_j8BBcTnmf6!2$Xu`LBMhhEU|a`{}JNpc^- zUD&q_jn#vBI`_ahqzneK`zUKYjvHc+Jn*dX-UW(~+hcxz(m-M3GPgJ%ek1;I-{(KU zP!uAJG>{-0lbvLA&S=q_!38VG6;P?d%e^p{S9lEXSV!a!F=KGm(?KBpE@ zwhb2ObSJpm_glq=2h^CKwf|x>u{|Z}bDYHt8+W)Id9|8rixn4m)F?Fb6xd1*Et}t! zkAWv}Uk=H>cnm2cik_WsR)M~?UHr{Cjbp=etpWQd?F;2Cbj8qIUQEKg__SluT95tE zc4pQ$R;WtVKlJ##%8jUZZ4ZWP7&5mOyY5wQwl#x{o#OOecB%eb@$yN6DAu#dryI^_ zK7vRaj&`O$cbUQ=&E;#anItbm>oI2Eov&5Q_n3Cg;3!$YRVfe)6hehBJ?Ru7Gte{G|T!Hv9bQ*ry zjCUaha=jg*th>j|_}CXoC}@3ND0DbG>FA`@<@V>yQ1%*0~5UcxgmY2NN zLKZgh6ud5y97vuluA%p_&zf3{i+IwN(&>P6{?NbnXHe+GlP{ZMh7ImNEZQG4Bgx%m z=PM?sdhZ5Jz3b2btv+6J(lhL99Bp*a2w`$#3FqWE!o${h(wi)V2`aXfy&YIZ7eFtzK;a;B zElZB$8VP7gbOE-X|4wjasBmk0Cwuy|+FtpXpvksy8d-*Q`gx=7^!rM82amQ}=tYC; zjJwUlgUsw-i`*y14yw~4UnF%CG%9rd2dD<%wqEZ@DUtPNxW_Xa8Kjl- zoUb20Gp#2=O3GfrE;W0&-smU}7Ly1)V|hLBsS(!Z{D>z2k$26SZ&bK6=amIu%#7Ee z*yrb}2@@N2oNX&Y(WigK7s8o@u5<;+}v-XQXC5$4lAnr(JOK6$gdhvjcZ@_alfW0GWT zCr_C!Bfo~AlU2Z1e!t%{YkkPOGSfg<>srT>&%?IJCzK3tJa6gt;=R^tC5LrJa&ph- zoTnQErwZwav7|hBfMT2Qyd$rz1j)Ew?UiKXc(@r-=(EcJa&iO#^e#JX8udru$)LyU zXev2Sa@rF<7}-itrHrsvJr`QyZvq|pbdeIK^XEuKkcs5lM`VJK$uq_GWpL=+Z; zkMCuKUJrC#&CK0tc@(fu@Cfk4SaX(!xK8gFTA%S92Sh6TU!ZCgrleek{k7q07zBGBRA zirr9Z$EN7YN8A%GPlZ*W~zJI zO%v{hW!Bp+uTSSG@3HqE}%m;RvpWp+2%7NUIUJhHY;|{uZ_ZE z=CWz*qrr7TG4xAkh}8QH9gWVmf+~enkFSg~hR9;?Owxc;!G)gYuAo@4*#yeA&*H1# z!!b-Arb7sG6QzW~tEaDWyhqp`6-QgtZ0j4AIDL}ok%+U)*B{DNLQt z--IJiD{o)jQ+Z!00r7CN;2J;tgf#L_Pj<4$wya{w?BS+vs|f|(fCN*Kyp@J+xCBu^ z9*;0FbZ&gvGBf*I#YrfO^r2)t&c;T2y>A9rY$N{A^?5Lq&L#9lX_&L}meT;9#npOP za#;hO#;kKiU?_~zFG`*gRA8mUNKwENb{x}0NC0N4q6?~L4lFO$5^52P1EL15LI~UF z2kx5}U}-6OmNK`S0>&uEZ>3vvMATgDdz2(T=z6eatG_N&vbqe@hAE z9_cV#sB-6hp&+3HNUF3CM0Ndhl$0Tg!gd2h(KtKx(z#Qj?03v|>UM&P@#&8b%v#`@5~gq{NyC(qN+p{ zu;y#~Crf)|aaBs`{CRNAUOwi2+;3>P3!fP(S$S+5cuFsSo4AzELix725u{itG`}^q zh3aG#WSfmacid*3WWLtW=he>D@FGI=!l$hT>mDrEd@{6sZfMO@X(8LztyB2hx0wmA zs*~FztjHc@-|=tQUi$4|fWttrA^b&}uyO|l7wL#C%C^R2+ckttlT=*auY9v@ZLa?8! zcK=bPUHtt?ZO@)HKLK3Xu4=l%h`H7LPnY;~yc#+(Z!L>Hu{`}6TbO5&B6?r4Z9{aS zg=H+dO7*Y2*-2roDu*g~IpcA|s^mi<y(0fLUG-dCi>d_-eEjElG4L6b;CN3ojjJrxrX&*dzyV#O>lyH)d zF_5?+UC|L*W@2*ib1kkG$7#DG$^4&xpO7@_AY}}!;fObnFRz~GT5^*c8~V+j7pHSD zubhwL=j64bq5Qv*vF0Uy>E5~<^Q9}iL4oX+SXAj3mma?J1z z{*#pEo3zNjuD!cHo*c&4DPK5Zy$9iUJhr6QBrLv%sxX}d-yu#mo=XJLfMY2IMi=h% z&a)5=g*DElL+$!=@p=k7By~6f!-&^D{oj3xb4-7Nn(IPeks_iJaZ+@+bEW7_#kH=-@I2&!~$Q3zxO9S5X?*0JW2MkQL@WtM(ik}Y;$)afLLlTb>*w@#&~5O>M)(^(;N4j*Osvwz zva_7vhS9j|2eBgukdjvkq(l;)!a&CBpC=z%nDDnA!9{ZmBO-UAklLDZ8ukWlIh%!vb5fyar)ffBmMf%_=8hCMfVj$H~bH8 z_40k|m)%>OP;P$j$k2<$Y55rerS=C%M+EG3&)~>;YQ{Nkx|GF`Cd9O)NP84WLQIGh z{;!0pjC+X4sk)>`N8Vz$iny{uPgRtEqI}u-qKFpTfe4C%^K0igXi%B={a920gIBbDHJuO zuOXzb0h;^$c34?Q0g(z(*zK_pldxaTiKxdi7?7`!bMSf6oxMWHhacmZOjiaOgomGh zwXMP#%z1jnU^9BLQr|z-_lKm>z^|Y0^(0(QO!0dE>CL0*NWWLYWBV{iRi5uzO0&wE zf>{PM9toE)zwSQ>DE|NxyFEZ$A3sYZ1kr)f6=Txm-Y;7VDThwodV#v-(mszx%y5XM z#%1abo*Z|Z%`1DWvi@5b)lzDDz_cn9^JzW3_PXo{6IfQ+>*vM)t!{*O>e4QF`vv9m z>CRuja$=C9z&0{4e8)lRiJif;=ngs9EcpK60f_Tu)V=?Xyr0So>}6m+XuNeIByPKb z6l#Bn$N-*TW?#~`3&$!?jO`(EfhTd>R`c=!V4^z!9eNpeKX(sts@V7bB;w!2pmBEd zob%L=Hpvq%WhWjC+PJRkI}!C~r@oHty{^k~(PZAW4~qFThSO}~4ys~FS=!_KCUT7J zZU(HOehbc{SDi}XJCe-O!YiL(TxcEFd7FP6Up4=%xF@8T$>D z^*N%L{+sU&wgOo3ROf|0@PTBN7atU!b_q3LU|sz9kmMf!)cPU?%(5-y9Mv0zmN7I@ z<4_|u8WKj-;cyBcS#cTMhtNpS>89|J7+_y$7(<%se!6Vk4P8YpY_qQENe5im=2~;= zM)Z-(+f!pa(4t#b9m%;tcBx|A^-p_L-;F0hXKost8{m8Gc-Pvw`BC=L^%ihq=3quRNyo9ur8k{|x|F3NEb1 znyMByzMa3~m3DKtKVbLX@-MZe-H^OJ)_D9Knqf=6G7UnviI)>r*l^c~u#YG(oWl zm{>-{bFa&st=0W7UH$x5*K=aQJ(2L2GBH=olWKbRT1b{OI#wRGk0~g;?y*O)7y*f#e*w8Nkxn{A@-HSXiTrz6nWBF{lsXObXc!RZN zuHLugd%Xkcw7PSN#!-C1QJ>o@DDR9*SawgS){8#<`F^H(ql2)gGnR3g)iWIuqMy?% zrGYPV9c5TDtl_3cLvD`fl*^2Q!#LyplLki))I1!)-;B`T+zqNgAz0S@&}S+0y%HBh zYjGo2LI{N#BFK5lCCYKr1L|m$V~3h$*`bK%VlvS(_#+4=x5hh$6iZT~cWZf55*O~C zVw90422VHDKH8Vh##fI$%0$X?5pvlL??KqlRTI?79G|1Z5|#?0{I}nA2T&lkfSr~Z4+<-+X5?f#H7QOvqd#QjN*g;C(?cw>wZKD1DF7GMfG$y1QX;bu$8)KD z%TDYX(Hh_KPP}>w1w~Y2C4zDV!Xv5yG5Cgg9C?oI$UIfgG%5)mJ)q!$$5|B2tx4dY z@<;tC&6V$ zC|>D6LwKtLB>L1(YR*2~k@?;?86GOX#+SOj+%5BYt>Aai&(5bnCY~S166Yhw>)dl3 z<&Nn+*Xb)`YfvF_cK4VPy6^o~&6jXJA6n3krK&GNsFxoNj()rUUBDs=+i7V35d+|+ z*#G2Xwv@s}QZmW(4AU~}nHNW>N)sPhW4U0QAghtBE!4RybywbtKv` z)-bFTEErS!-^*%}nv%t;D*ETY&L64@(do0L_!7D5@i#pvO|nb$2tWVJe72Xmyu6*v zm1jBCZB0XQDIT_~F~MHTpVq%DwjLk0$j%FuRz6Ti=okO>hn9R(ZBqm8;h=^;nL}Yg zIAN<7!FpvIHE1a8Rr|0Mc`oSC>1sj%%aYzthZ?XdwHW4TotV5m2WxRj&Zrg-=4+Zt zT`x|Mw(F=pQj7Wb;80#{IYvT5lcGb>fzOv;hJA^8^-vSw#I9X53;k_!(U)TS<4hDa z4M+@J5Z{;_l(st$=S8#rFC&CeOv`3>n|A~#(>h(FS-gdb@cl;m^zQECw}&#`&o~#Q zMYdiLf6X^xMbTDscoH+^-E=OcrXKU^lw+@z#>Amjr`Oa@go1RfJv=q^B!e@mB!@`- z8y6_Z3DM9|pe#pIEFX*~ zTL~jqVB-->W~PJ|hZ`)*1@qC@m)pkiP1smS6v>UTUIuH}M9`Q_r6_?#B1pm4M5NB% zwE%0H*uV=tq$!vk0`VHsdGx2!^HJ_*>|R(en$-A#Zu(sW)5tXEpNa>%ESoN|u0O<` z#zdWbeu#I;OIT0J>i8x^HW+VRTL|4LvKbYq=aA!%qCt}O%-5fz)SsnA31kMRQC4tN z+Wctue|Sb{!bU;Qt^)}9(0YW1T=q~Vl6;W5wisk7)#u`#)+2MUxmSb!71z@5&Y*#a2w0XVQ%Q@Xp9jNVVt{h~J;W9BJahEqk|K zQ-FGQzPc5z-G=JJ!g!4jQ56S!PkrERQ)~JD?ZcAf9brmWfb!{!a~?y*hhZIx{)_8l zDsT8|ac8cvmGdDlpW*w6IEwIJn*oy93iuvy(R!0*2 zJMYuK^LQHRO<>3FG{v-q2pa8zs6c}YW-}&)%nsw$nLs{Q1Q^dSK1hW<_vFHuqm1=MXMhB0iPL=4z^+Hev@mTbG+_M@!g zgD<+O=sMj2`;H6MWtyT>iay#4lu=7aCOF1Vy^U`|d3J%)aM2;&Q zh7!6qwdO?}Szh8deoq+JYSB8l{hqay$ZwqW2w`W9TmyC~W0yWf{g5*9XYf`xnKK`_r)v-gj@A;(?koW?i zWH`V-E;_aprB3=y1nWtmGuAZ0UG3eOGf~>GhQXb z`c)mp_(|9A!7UZdc#iuZt>1op^STI!X>%kH8_-7$SX!c0_L4Ewt&VQ@w!_YDQYh-M zzJ4rpOibVabmef&B^$j2or{Uamr+~$IEHs1JAut~jNv~~V()=Qf))LFIFQ2h-ycOm zGnamH2qE3``;Xo1gmh>YD__0Td$4!H%CGTJtZ&C1aLc^fAu`*wE-KTUjhY`Xtsa23qH<+rgZ?4v1n)- zKMMNYlq4bd5BPHj#3`(Vz7*qC!H&&bn3DTFMR^PZgZX6>M zFN#={gjh+SYs0%G1jZ6Qqp|>c<3lYhEwPQ*LuoHG(b!cmjzbd<8pu_qj8YAcAU^<5 zkZe|kW%34C%#&<~Ib)bx@k%H32FE8`zR8m?Mf`n>J-Fc>!PXwbgnvB)E3~8KZWc$o zB-vk2rz_`}FAHr8XNTGa|2GQ&T9xFgw}f`n`D^S7>3CK4KexIOaI5bt9G2YS4Gzb> zz2MI}vQrR?v+LGEOM0;l+S#FHjVb^BCX_1}jrFtU+6R!dk>{?bdB= z$v@qms?$Tu=U_m@8x9LefmVAe8RXUQooyHsLkEN%M&wj>l>f8F2ChSn%jYO;m-`xp ztd2|z!i7f*pB#Wn=K}z8Kp)w$jNaOz%>G*3I(scH;liDGl-#Su?ctP35``@&q2SUx ztgL}uL;M-mJF50!g2-)6D{>TMH_J!{x7Chg8nIU1tH~69A5k)c7bs5>c9Vl|$D)Fw zb~UB>oO`vo^t-3-6*i|9K#$)enu64ei)~keW?Kk-u?%at6giK_$Cva=d9JO*SFW$A zmK;7#AcYq7F(ukmiIpR&Dqn4PYQBKG4gzy+g=juX+5?NF(;%ix(nWhg_nw}yVrA(q zbQhD{x0~KD`;orUB#fNbQrC3?RW3NbMVHW|dJeAn@~Yls9OHK${jFe;QH% z*5Yb%Lz^2rQP%T2w|>`V7!nvCa5@BA_cnkFh0V42BL_+Y_r;B9gv!cW#jVuh>Y11V zNS2NvSg^%?N0NQr)w37d+oMY4ALW~~YCTkg#&UmT|NM}v0)OybJh1us#P85vpZ<+c zM$3$Y=>qs(vw2(cEe*$&!7niz-cI*5&`jho5T?Tdx;?6KJbp+6z{4!hO)7&RP}1WA zrLFv*f^&#gSKqPhP?_a%uq|;YY17={A1$s!q$ql7#C_awgfghv$VEoRj z%$D&X5!xNJ`066_{wy<|%R6{;c`*zdSNXax=1?CU3R%v~-oqztT#&DfDLpBHoRyN2 z7?tE)0_3hlnr|7;hF*m?8l3jm?^mHjf8hU-auV@@cc-=khv(e}UJ9PQ_UWaVVw z^3Hk;Lj|oqG{EyRf4BLbcPl}Ym)O0opD>-$h-=*UdeR5^dp+;&KTjXGSZ3x%=p)*h z`x;_}{u^dr3KVaTt3S+t`CV`Qym;D!h2oi)v!C4EW7+hq@3^hT^mV=_e^@SwQ*RTadcb=gyXvCrfj9Mw%C(-3Lbw*3I zYMaK|bJ3Aa1gkRvPthcb9%x^Rf2G>9lEU7-P{&Juh@lM?ydR8P)1x56K0=2g;gAl8 zZz}#BYQ%;$5tv;*>myxBuXsG>6@n*O7$syol5af$Ui&6_Vz%HI=g9Toxv0ap`&u+Rn%4)xtm>Bmai?4?pQ}?})Xkm&jEBdrV zyPI=>P0RstK|4jL?+)PE_mW?BqTT)(cPwduEfdkaAoQlviS_S_>ifj1S)Wn8dOQ!5 z1q&A4`xXRFG8)i}NLo8*d*|PE`VmQ2xaAe<;Nn>3W}+9%n&qCU$_DK0KvM7GO4zPM?h`dh#}dS(@4 zv6l=Q|IijJ<`*EbYCL`ln#rsztRK?@so^U@_D)YSNqF?VAFF4EJTb8{(;M}embHL2 z%X>BaZyPV4#MB5kVr|Y^dkazvS79`$?mz{!P*BOzf2k`3UZieD#yGE+^OL7u8eW^W z^KMLuv3<_bzk$^6Upy_T^B?NqO<~kdhoxw3hlSC)d~9l{f_89tD7B`B=dLvBWMNUi zFI?De5gnN~5b^6VM|FLgvXh5JiDAZ_)D`x!7k!_@_d!0mmnS1#QYv$>t#6rBH1dl& z7`BCx*C$_T8^3GE0_w5JEn1!ioK!2k{fk=U=73bqQ)(poSI_E=E{e{i3xL}0cr-{L z7eV!wL9t|hGilVhEQq{dlc8h^1RcLPp6L)aqkRxXPJK2n9R-nVj^F5-*@dXmk|D?I{FHx4#yXPsjS_BG}uSlvJMGM0X+W%&X%Ve*K_x+2Ajd>sWE39ZY4} z_fFOaIL#!~l6@6Jt{?yIx;wM%h1EKx&x&Kl?KL(B>Scb-ty@r2~LT2lwM3yP`?oYT`YP5)=KkM=ZmoiU*!M z02n8y_A_Rt_ z*dhvwR@<9-yDh!$mytp@_9}92Tg_5n9H%WHe%dPRSjc&5%H~P|VH7%o6su0)=_|~> zc>_wR>+PRZId{8bVJd&^fI;BrIdFG;qt+(t^y#MC!DsG1To+194=*5>myoVPartKi zh)I!OXUgwn0^CIG= zI}$(3Df-iKH`YJW9JKUi2AM$!#CvpnS!9hm$$jseheuUBQF;!jMBtKMO|ZiyhR&gE z9C*x@3t1dKfKCA$6sg$bQ8`MihtgCU6qG`_+0wvjJjdD#?zUcJ+u}a zx?PH->`&cuT~NGs$kBo*#O~6^g>>kS%1&_4MSF>;?kO>D3N3URdKJhC5VswdtUudwL62EgV_A>VRM)-8nm)}{CN}*Yr)zVU+ zhwjYv;8jVs%W?@Zz0_L28t<2UAz%#yWogedAk`3Xa$|r!0L`iURHM{d*YH3;{=Zw& zF=XsAG>(W~gIR&+I}ph?hd)24dEIzg7XD5Wr}ho!a6uW<-^>!OagRq*nvf_&2sEz;NM&I{(f!z_x5`}m!KOrGq(n` zZnd2}dbTO&@mGlz4huat|E)-a)u9}>gv(JoRvr#UXiIUv?Xc;On#01ggh78Fb@RfF z#$A2Uh#k&3wA_49=Xb*5v9jJn52rE7VY84+JPN&RfvLTpJGSGZ!wL?Zbw{$OQhYlr zCGt{qV6;d~WJ;{@IiRvLP_#nswA)Q#UG{=K3uxFK@{(1lv67BZ`t2wcpCyQ_-urfl zef>P8k2dsVv^W23nBX4~c<#-m&_gGAAS(}RN+Qvz9%&G~ryhAv5RGQ%w?idfmj(Fb z9zz-@fm#iA*Alj7So|y97ZUSYa5ql3eY4Ac&^H(H_xDfhImWg0{lcR&aa9GawOW0K zua7R?9mq0uIn$OqyK;8vUPX!Hy|GD=oA-ZanW~RYK8lQRa9>??(bf`lMRl`RhfIJ= zIx^kar*`Pwwm~)FIeA1SRB{(m11;f{*^ppF46L;Ykj1DiOVCK=5>9g$jA=SQiuw0o zfh&N}1QleF7vs8>!3BR zG}Nq=WZ8dCadv2UwI(`Zm*>K7a#HqnG3(CGn0qRPQ7fZCp8`(Cst7bljtr6 z1=7njdH!DSOY_&~a_phOTaj1!*03@$Q%Q|xn-}IkDoYN%b2S^a5h(6g$E-RrCOIA1 zfh9d6lzIGn9uF4tVW_!m?aiHgZA@UN0w)Ag@_`-Bsoh5zf@xq$9#rV(<3Ram#vwZ^ z7Yo!AtGFB~!w6Uld=o(yt|l>!=|%8roqZc`5TbnV*0X2@+3d=ImG=8N669c48RUZ< zarYc%D^bD{;rU_m0$lF-TPf%6yVXNWc$AqQ#!BP{AbGKr$W5ictnUc7%Kqwo9<+aL zvAN})=G!}d4|EGm+DtBewR%?^A#0s;V6gV@C+joaOD{70zsD}Ux7N@#%V>z^&!2bs zUECDOzm{#TSK$js6I|%FXN~u)bA4zZ`_5^&`;^JA(f6E7wyE_)@bPURO*6pX2muT0 z-V(o@`0u<70!@h)e^l;=Bj1p3P>(%q`AZB|$Y(=_ktJjaL&B+fr3jFm+D;7lMBmsQZfct>M&!rC#E2lUJfi z3Bv|_wj^beo!j{%9kYe zUI6n_%rK5f7=fD|GYG5M{Pexdlb{0Mrlb&|znFx2LJfwB9F%#HTI9;^rO2!P4TcEn z!pR$;ifgK)4j1l@_qSbclICFJW+IDilMs%ZsgkCoRn-nZY97sE@_+&bq2OG`mzCYNG=0O=`HlW1G^RYku)Uc zB&gCmBkvFGQ2D4`sFx1B6*Rt3Jz)$;(dG&;5dE72QHP*Dyc{ozGbb_9qBK_b|gVKJ8E?{Ww9C!V1ijNn82H;ZXP$1V|_bxEc~YjU*}LrHn1s z)YN2$fI&h$9-IQTCrRt=eOT2mTa~Lxw8HUZ{8`GO6{4p3&3!+=>ksn)(34z;2n>y{S-o%Cxv=qY6 zU3kblPlMztI($dQpbjjx4(e$kHGFRv^3As${#o{HxA}aFK=Q0w>Z;Zc;w=xHK&0JW zv#j(Z0~5xFgMW4gT$(!D-(>J|z}bs9c$bgmd#r`Q`ZlG!VSY9)S|Km@7Ff8POrw-s zR_f=g%}kkJ#QPzP-Aqfg2%4O!?7(@-LyfuDypo^35J^2 zX3jM`$iNmYRzO%$tjDyz<~nujjr>?a7-WmlSGjc9_ckS-Xp7d8Y20IN>%qhLw^zBA zFcbzfH9(HNzSuh&ZR0D-Y?*{?w`L6pdZg&k89!r1dEt>ZkN>QsGg-y6J zlojc5C5DeetBzXc0Dcy5vV7d@iH>*VDBMET_#?5UCwsU+WR`Iyui>!FGu`Jy6CT`5 zL0+LwC&pAO?_apF>}pIMeU3lkzcYUX6{^uG5t`S(63}Jj@ki*k-nS5TEM4f-=G4)l zQ^T#HD{jjilR9BoYvY4W-8$mAc3G!glhBlFw)_6rM*;h>!C$$eus{_cD?kq-Vb@`B zM&k{uk=z8JAS)hyikPjzWdp7>1;=dCd=&w0d7qV7lK3t9#lmH2vmB}SOKS4=bsl7^ zs_xNzqBd3>+e9F@>iw`9l9-j*JhfRC5Jh0B0^{Tgj<@6r3jdm(cu|cJcH?ZdJIbjoB1ah}gaZIUI#|qmh+?azmAY z(nQYxnT+g)YH&+jU>pb$%PaRxD84ZK%}4s1%BzLn>v7a*)V4VZ9Xe?QjHh9)vTzIF zq}#%<=!?%Dx}<>H-g(a$>hgPkkwRxb0y4PZ9SMCq)%X$`s6vVLBAlg23nZXh`g4>> zj&sDaQVNrtk`%rv;^eZLHMai2?>DFJPP^^*AEm0g0z>@JS8;64pF^f*p*-@lG}~Gz z-->>}XZ{2O3;I7dMxcPVZc+{{$iEV>ChEcEnOFL&y}>yNh#wZHzF;y1_Me!*eW@DV zk{!a^1(&{m=v-<0pC>s8DlnrcftJDvN`pXSN&ZKQ5_%d~w@eLSE)b8YpOUdGILUjU zUJONGS~qm)LkCPTCn+q6Yiw$aICQ3Z5m3F2WbFny>BxTwghe# zk|h5Q6IYjwr~S?^!^?wmRZk>32004_tX=r*JZ4W z1$Sf{)Tyt?)_?_J8<-_eEB|>}Z}yxJh%DOQ*KayD@s3$K6-RDs9=IWz5FG(F#y)5( zYdyH4ejg>)&je@s0oS6WTWUBAGG7!u%uuiR%NJpKQH^LxgkZla+&w^p_<}&!djjSg z(VTD%XZ1(qi_7}nT{vM1jvW^)-e%7Hre;6J0elrQOpu|QGI$?8y%*F*(PyP#t0^A8 z4`q#7V`{vi_VeB9B@$!M>|fH90Mnt@$U4(BR5BGU&;gMTrWhj3DHNn_?!WewK?72)Gv9AS{_=Rwja zpwy+f>)U^Sx^wbp)uz>8WOa9}U{l{XkH-q9n2NR$1$XpZajO>Y)3yEL3h}``Q#Oyr zgeCUm{*?#*nHyZ~*dzvYe#9sFL8{m(SZA|3>sKH7!tu}z-Z*?2k zdYZnULzP(6ZW8Uj(5Nzw-{2OaJ(9y3&S8BC*cJX6WDY(Nz8Xy|V|Ti8fa4^>zNfMh zVcY`WP;(bJ0>U?6c#tBu!4bo|?AmGzlByv|0sx-pK?b70Nqgh;ing=@PIm_NN z8&c|S^=JV#SGY;_9aJH#@I{t4akqKNqTM+W`hM@$d6z+t16`QfC#Ohj4_7(4wz)Z1 z)S-#!hoJ-Q5bs zV-*>El0Ki=$WA{cDSVu z4_2lxOTkjIj`9pc%NN7%phJa@`>(w^c|+d&GNpt95I?x@q@SILA>#HJgV(gM$RoC( zUC6Ciit~uyrJR$9%)Z4@YgxEZA(C)#b0{nclYC!RQ-P<8SVVxWP>2lt zbVgY^YH}`u#}==1F6Cz<(x1N5W+n9g-Jtq+2=Wc!v!E`}U2T)G9~CURwp(?3J``d(W2Q-Z$Z7{**GH z98HAkk?`TFKqBO9)6jJ$=eEnoM8)V#IA}8?*`d(nA3bJR7chGKeZ$_YiW4i6!)@;y z@V*P@ND$|eZZGb~^o|m&>4&)Zadqt}$gsJ^{5@;sOjk%HzAVY;;!5E%8C8Ngryiay z`fxMU8xJ~-^B1WIv5)R*JKX?nf>>gu1OpPb8qwiLG=O^>eJrQjJ{=mx z0usyVbuEDeQLr!ueEPviHB!h~jog$#Ow)Nhcpz&^64CuH>wlK^|9-u?mfUAY;w1)Kp>A*M$`3%j4ur0-GRFsLO7!d_xwBooObb_?A}$6p9U4WL+I*2; z5hGCe_E(7Z*9ck_N>cEoU{WOYac`|hz@$FqR|~_#%pcbPJ12MabwVtgd70?jwyB?Y zjr#tOzR`bC>vL**K2kTUI=^-6Bst)Idqmr8{v#1w@280V$vyisNj)` zA>--58%60jCiqiwJ!tw7|4KkX$o+wwmfMG&;8Y^6p1_0PoQbUK!2pi_8cP0-1z2qd zvawyx3!)5xAvxKEdFK89RNLc4YhG8EUuEE4YUo@q4vFl^y#+?N%#Pi1rZ*5O>6u6@ zuMjuS>yPl*jGQcX`XM>kmVsF8`Ue>qLkQcah6Mtlv)m0$xvBsVH$s{*{&3zYnEKb_ zI6UG7z=1PXAu3V{(cwiqMk-Fi|BOcJsYQ22RiwrXY8Vy}lRstP={;7!#5IZT&G>?~ zQE-7s7(u6+sJM#R+F@n?Yc9z0u8EmR{P_anCLrK&bq~4GXXQLKq-m~v`o8Y9pBaE{)w-wQ|Dux^ThQhg{+U7 z?Ix&7uJ1wWYSHxaX!fe1l_z`JZc3tfEvQ>Yu%K?yqeLA5UK)QSZ-^2}!c&6f`2p?| zgN3LgkwLn&sEVFNYn2gXGD!L!x56v`M=d1F1g$7rotWZW^e2>HuAguG&m|K}GQDbt zd<8t4WEkN9MAwTls$d9+q9Dw!vR6Aw>bdewTgsXCd37X}2b7a{(671m z;f04~mBX1vaeRMo<#eI=dhi-9=*-Z&*i)OXLQcg^hCx5|8X$}B8{?+U5vHHQ7gwLC z{s@2E>-|Rqo!Ict=_d1pTa#|zaNF5H!uGa74Mf48>^=MksXgF#blhj4v~4Tfd_8}I z2!ISVl3-fID)&+}`p;2}Db}UTI|X>Ha4Qc}IDfPB2;SxwEXEiZwX8}}atM2?UpF?` zjt(`1njofAzy~N%B8uz{vYy`ql!y{!<0h@9L$*7N1SaaBv4ddm#>!b#7rKmIWkE=8VPpsw8u@h@$ zOL~d|(RS8T85RT4s!Fd_%_0llpA%s?`Twi#9%JsQ`qF`Hp_3=LfB!9b`Twu(a?nCs zTZ$|gww#G2?3cDDP5CFBip|5p#pC~V(MrM%_qhRyc7TWJT-+%V)YbQ%OZ*HR4Ar3Z zI@)%`1pizvBH?)GKz2oCpRxzhBw>ki%kyd09mCZmCsAECJc=~L0VzSyeMvN2!f$4z z(~z*y7ym_W+!h6LPv>*hMb+HSb`X-F`@0etdhQi}Je{^OTA5g_&C>j8;~-gG^xV9w zTluF zg^^_7sP@$;nCSl8cG%}2V%aw0G_xV_#PS{7P~P`DJ+;zGQ{@SI$O&1xBXQq)4huj) z5!QPVOOx|e?~e3v+t|bo?{xEY4RTTi-jTkRSZB`d-}LimLyVcxZkVy=|CkGRU0X08 zPLkO0bq^CaNJYjAsUR6)vJsDBVnYi&hg4~7*K}@U*09~!B(eOL6sGk=SwpJrIO!Mc zp8naH`Mzr=>wfi5y-{HH(1reM2Pdq|oY;0JwzP&ksY|mRY&A4<`1vm5;@j{7?xNuE zLDeki)Z~2Vt{N_9HIBCw1}^U(KGVwETKf~ytgmx5t8e{5r}?^FE1}(zb-6y)s9=%v zagQ&SN4FPc)6ZJJ9l-_a=QJ$wE@4+Ch2!R^J0R}~m`U}oA`V4RD$&p#W&un)(8aOC zn12HsM>?4#=r4v}&HYPkv!1|(9y-$XuPz;b&f2dvoF;O8~ppxo%Nm6xw>=Eq=inC0BWcU5QdP4eQ{`Tbe?H&jow6yh=D)-jdHF6;6mHxOfg64BkSlMN zM&2V#M-(iAPYVNUA?C9rsMeIb=0dyGJbmre8!^@;jlb6(^rW}~bu26UsbN8nFu+wdV>!}~FM{Jw! zR`Gla#n6+uY-r#CnXuGuD8FU1_r81bs+Pfs?tk*GqtvnVV=vwIWKLc?_O^g^7GIW; zkehdYb=k9Iua+BYvQCM@`v1e$mxe?6{%?Q3ZIU9XRAMTX%GzSdU@Ao=RFYCm5>r_w zOLp$0C~HzA%T$y~b_Q9-F2s~2%wX*Mm<6+#nfrb&{eJ)Fc%J8Yj_XA)I(pGu*L_`| z&v~Ao^E{rGyu@6e#(EssCG|~iNqt4zWwj6D;(p7N*!81dFF8=ZXuTqTUCmLt^awMv z4*vc~JJ%J=mX$9yb|SN(S6>5Y@}4{!S}#0_?CHreQ(lh|EG8c-l5MfgzR7;BQ=lRg z16@!R%GkzEe54KwKF_$KG+7^9z-q{3&kCf2cT@4Xtl+i~A8)ueJ^M&!=t2Hwa1Abh z9-H?*f-IAr*$B87a70NSZYb%YAzZLgg^fux(JFNRIL|l(&b9-EmcC*CM_?X`miPQs zeJ>*=H34`7L@d-P0e9od`8LlEQ|*t(t!xt8_zk5~T#%)9BnmN0)jKZl2dsa{+({5l2QBu%4~AHJqOl%E5W|1jy8NsZX5qn~?H1A3($OQM1a#oRis?ryJ0h#M-=jo$ znOO)jKAnrx`ZaMQIq0ECy#s_Bg?NITzzhMZ1Z#dhNEG3JL&OEPuzqw9cEOSBQ?FpI zUU?4;yVq;*Sw%Vw#6*9+vkG z=aKfrynY4^nsd$%#VwaZNWldm`KrZ6-Ss~NEia)^kmaBXi-SJnPU5!_p5Rn#l@oe$ zecKz?t;>deRhinej4=sY?*(>P1d7|@EqzUC@ijJm>a@XBL)uoF_6IS3{_T(PdHg2b z88B4+a`V`=u*FTH8{7U{%0)JAzuyfBQ;ulLG9N<#E0Tgk5E%*NIfgB000$rBNGx{~ zId@urkPe<)p0aT|T9O*K$1FbD8pze4ztc^%D@V*DNsDU)+ac#Q*KaJ>IVF#*Oty#H z#Im|4fKmQb0YT*H2hU>%8=vRY+uP5m-TwVFOu<(tkoB#g-5A5+rY2_|$7mt*HNmdB z^FNPvcu`$5wZ@*0=CNB%o=`W2%%KXg06;R=s~3!C^@IMPulJSl80As|~a- z7%&BuG}?}&>1bICtUI11Z$q1h<9 zldE)hX4!KpTYii~*&~@UDc6lm9uk3E6_WTb#BMrVjD%;fS(0c%`N9>VM zG}(*NRw{V(A6@^(+IPR(pvjG(!}`nO)0PP!uNS`JK`vqE{_3h!O(uIoRrk(@@CwAX zUf;vl`Lq3Gxrwm%T*Ow@Ye(Q8%%zeB2u#;D0`s(lbkk#0S4&Kb5Add%nk!?ROTt(B z0xK?4m4~7h)k=7=preeY@81WDt+9Pm`4C<`C2r2t_bmmUXIF0e`%`>?#Z~+bS4;Co zPAaB9PSTgY`(DsyR=mmF*M;K6KQ}TM0Rr^Bg4T@bn{x;(rf{JcVG&2KhZOhRk0?Be z3-LR5bjjdVwCdG7$%Wn;}RnHKNM@HI8@|K zsAf>*SbS2(hNbf)zgbgl+AB%F#gkJpOE)7Cfa%6IAnD9rIN$d@Qgd(sxf^PKHUqxf zD>7e%x}c&M)NAAx)QNq*ry2vsg2h(px!k4waC&h#wqCo=aJ|Z`dgbyMOaxSkb zLGa|q2|b*c{-?N5%XdqOe_i8yA({a!bI6^)A3|P>i zWR6x1q&s2FBNz)m8&eiKGxRkphJeeTQu2ex>yovjJ*;f`Bc45WVf8@)Izonf6XwZ) zb@kuNZudGN*5{N^=G~4U)sQ=jq#=*tE2Yl>Qmi6|+$$aE`43^NJO^b=smDE$KqVXK zv|l>q2=p9BG!m^eFEb`W8khlJig^dZnA>L`?j$|q2I{8ON{d9n(d4P?wQj9e0VmI$ zLNzLO8i`^eM}7BClGaM`aW>+|kL_?=eaPfM45vc%`wrbu`1*++!kyfgtcfObzsr<| z-8PH37pY09oI=wM5to+aTCqr60N3g<5zN)TuubUyJHj>{6I&D)bP1zr`u5oIAUoi? zFTySH$vLJ{y4#5>mbx*(M9JDW)6^cho@#yLP{nFt2#|0xk!7960VTkV$2>x^0^l%& zo);5Y_Lrv1cy76zo4_;H5eN8#y~6|zv^F`ZtG`tp;!|i>LtK%>*C$kD*{#w zRDPID5@wPv$lm4ju^K@6b>SQhE{05~27yVuNr=ag18$}jrwV=>T<$DQ8}3S+TIev3 znb7BfoTvs^kbwxha^6l9kC1`4XU_t#ZhBYh)rS>X>LN7~Mp4Ktu~A_v2@JSX51jF= zyJY<31n>-3D06A-r`(QpXBLy(?XqsZjduB=AHFTj zz$V^#{1CZI{W^Q^0o@MiFIPE#$e&Gwi9?i6$z<;M){0cm3&;;_b1=O4?L*#cM4l&~0Y>|;z(!V_jEfTy8WW*>|Jc5a`v0fRf$ zAcwzD#;Ej{m}*2|%d5+_Zz&prRU*G2DyJJihb`x2FYwg^v;xOF2$rXQHN7s^Q{Z#W zr?6u*8sTe@5<{IX(soIbr&$tyx2wij41Os;bB0GzSwgD9 z3|>SZAAh3*42ZfvuEZ{W{UH!MK~3T6FE3$`$MYR(WwSdnvfC$guMqH^?}D>CVN?XN zoEveLOlA50<~d}xo~~U+&%fcO78gj_Hs@}z{@46mQPBrjb>Y;_mwzvQ1nqLxk%>G!a`Kn%sE-l zM?%nAbr*y;Tz#9Ov74zr_~EH)B(hfT3nm?Q7v*5+US+}1r79<#onXG{P3^O0-=aT> zl^5(@B}Uf0`X5b`$hvyFiMqo`hA&4M7+W~cmIQmdcbxV7|7n`yr6Eqx*uWI_)h?Dh zu;BjGv49JQG5**UZ;UOA0$&nmoh(*SYGO8@{9W#Fu<*=Q>D(`~`R7?O;kzO}sDy|A zh|Vu`z^+<#{k7@pEz+W%ExdouSzo;V`tsI$>keJ~GYX}8%%yn0-0#w^buIx%`RRw7 zL*v?f+dSGTJe2-%9AM&rM||N-4wC2+UYsU+91R8IY_H$rs2Ri*B0QECL!Y}!#Y-A- zHGF&@7?QD-Hz+)oEZ64=dw=t*!%&6QnNmdA-@)E{Hyx`#CMVPvEubg~9MPr|JK1-h z98a3!diMqI?X+ULqnoa9M==}m6B7dNA;)>41S@}+*7rH6+~>0~-++MxPBi_m?D)2;BwuR*Ev-$wtcz)50;EA(cp$ z@ljpoQfD;cHy)$~)5s^^!y*B-gREy0xzk@`&E8UaH_2=jHRol-OEVuF@neED1%``B z*O7l$3VRZ9srPUM53_0vLs*M)5t#?n#H2hJe$E*|VUJKb6A@~aKfEYzO6m~sSEH7= zjZiwF`Yl#HUIamMH`Hsc#{JjAu@ND8&vNOxhEj_&`t!3ML{PAiiEuPqDl@CndU{*S z+n>hLu72f*b?1kkL{Fm*-+m%mmlDPY*35&3)LXSfTJ{ZcJ*iy?^X>0nPxh%@Z6NG6 z45NVT+WZ1NRZcoQ@S=4(! zGpH_ety$#xsu$3uEiE@QbGlQiq0g|HYMT zqhei1Fh_asN-c_xZuM4qpXZ9l-uhM%j*@|O!%4;MPo9fMbOiAX%f!S#bYls2%HMJ~ z8Hg6{3H}tvwJyi6%)Yj=WqFKWK|qg&v$FMdI7FSs#|n&&EUb{3h!df5 z5g#;2@~8;^qJDeTKx~&}`57BM>#xvG-MQ4DrKx)I_p$LL`q#{$@H=FSvqviMGl4=G zufc!Cda|)ST`dp1{v_gh(+l|G-{zX2TmA1!jK07tkozo&_Y>uN7Aw2y*hLed^ynop zjd{w5B0{CIasY+%e=lRkIHsNi+!(>ydKi994m2#q_Gk~_}{T0l62V`k7DOLbs@YSqjAHFPvz5ULgL@0=2o6=I*|18d>(d3_nuY+^pUyLk&mx!1EFwrfEo;kR!2(oI?!(aQ61rnc`d;mDg>aqn z4s^$d7v06Q1f;Pi@i^Yw3!%@TtUU)c{U=fqi69q~0@|57nK?wi@6_wEIieX$>Kghb ztfBEhh-BT}`pq`kI$Ow#BqkZ6;M`ghkJ31TgWcG$*g$;g*w!0jirj`*kIF7 zICtB4YpS)d$z59o?fclTm(oRI?2DrJTVFz`Ld<#O4pe1UlRs7Egxo?vQFL*x~~ zq}nhr);uNeWB0~1YFn`EY^U+ir?5dkziZE%?4{8o4|b|H|0>BFaub($wcpi9);3fv zebkL^{f*Zmayv=;vQ&HA1b(mh{`|T3>5$qhjfC9tH?{9_J=_XNut=ZoKi;LJQuos7B8qxqs0wu$q>$~n&7LReDnh|e=5 zG(x?y$IL9{PH6}^Qy*rzO!j;Ac17zbIl$x=VSlR%Jioy#UihulKR59D{`0Nc^NtyOBm-X3zTx_G ztfCJjXoX&Ki&3W#a+annCwJ{otOpll%Ch0K_ge(Jm97+{xAZLQSzAu>e$%&ia48St zb3BaPQXj|K_HK{AVQ#=rcPZ=lk|$!AuoLS;Lj||b31#E&f&sY^u0(vmh+N7a@=U=k z0Vl!6tG8aQWN?K-(fIbG-!3BuHz(=CT^t6^0_#&v9#T9Wp(sNLjK+Ish%)+uxhp*t zQg!KP1e4YX83r;M`h<4hE%Og?%;p!}vR<^-=^6WD>Q~vb9^_t)KJAPI+n`?8%BlSz z6lEeS+w~p2yru0b^5rJ6OYQqw`pt~w;UnO)Tf~$pC~n11b^9@qdocaPA~Kr&y@(dp@%p8Veh_ngg8Nh$rSic8835*S`3|sE2fZis2y|4 z1zV48$4vd_8c&o#7>AIooEKpu_YbeYM`>zly33#sjy{`64z@4r!YHg*%br~0f8*k& zr`Eq!zphbcJ-4`g#7s}lq2k2%m+I}hIgAONr-s+nzED^XN>faR|JmQA&YvH#0k<1A zD(l#Tedxte3zw{-KQ%vcX0{S0#J@e`KM}|Ep5E(r)3z$+pI4EGY#Irey(ho_j~Bpl z9Fig#{vkbO=J~51!j#9_MCj^Pk<=BBd+vC7I#~e{suM+$=%1GnTYvgJ^lWZLR`Hyl z)|~@r0b9F%^MSkIoc{=@B^gQ`|4Hl_?f^F#s@T#hO1uAg#<^Wu52ufL=F3k}Q-&Bl zc^kZ@91=#fi~RQBTC#|Rf27=23l}&bh7%4@r$H!w?tp8dZqgg01X@}BJU%Cj?fj)s#q7Q7Vu{pPXM!v@Ib=6|IP zhQgHwr-f;?K)f9h^h(Azl$XI%S~ww;A^ubLV48lEwiR(qiscm#OVwkm<(7z|f>Pg5 z<5CYQTqa~IQ<&%XJ&?TR4NPb^EjmUZwP8}lG~sx$%2SSgc zF_qtX)EW`mXL=E4wMbX}W7J(`|*Co>7{!@5NBf39K>27`@vgjST$JUx_*+c*t`b3{r(1 zYaS<3kL)Rv*2O1K5fCx@q~C8w-7Rs4 zKREp^WPNTf;y;RfB|j$1@-w2e2ey$%))dlI$1D|mex3BG>!GyfGJ8{r$Qs`n!%r`rLCd@o&u2uHu(o zigF^OzeB#WZgLv8{?SOQ*Agsb&{vly{=DRd3OcY!MxI7WjHyD~-F|<-BLV{w6$<4O zjb2r3#9tg&| z8t?MX`Dz$nKGksS$l>X;?jK7I0+JxZOcgu`srO57A|PZHCx!~hMU7KOU!9z~w`Jx! z(xxL?PxYp`^%Y`;Y+3mDr@0>_*THwCB&hG&21bWy?bfLs+g(yJ$;bbgF@#6eetUw9U*iBqyr{!09XpVZb6(Mp}~?O=Ocb4alDdbP(|FJB!P zeU?QwNYd>eR$4&)l1d{D+*UM-WD@?chr+xTHM@H>yKSAwdZNO`Mypkrr?K`@=!J_^ zFRF`3cXr8lXtnI^U9TmJWN>ByM4dNGK^rSL?J zRE0^;x9Sg+jbijiUlZ-VF+j zU*h(8sH2K{a>64jH5OM>mkJ1Z-j0=WTV9W&LL%oXXR>d0YW3fd@dv$LuKXg(w=ULe z;kPkggz&2|Q~+m3(>mRRZbEUTo|9q<&pe!FL*;^8jd!}OYMb#D%^XH1+H(vb=cUeG zksxF;UF4B1%$fEUFJZrqG-NY+;B-jiVlQ9$-=(6?mO7*H7u^Q-ytxrcTYOmRL zB))or&=4&jk?)4=3{L0}R*`C@Fnu77N1k-VO?KoX^=sz(d{ts!Gq++=-pANCuoN!5 z)Cqwd=()DP;=v>FcsbYd-Ql;(0+7C@BaHzQXiiZM)ZYyl1K0BHq#SH!CJ$?6HG#1U z>LX#-lJ>TsOt*r#z@}y-9C`-r+hzVtW>DetZsG1z&!7lM8NMj|$aAubA$@Nq)e2e( zJjLvNa2C&y51Iz4-gmqkEbYA~2+MyKgTS$3-4@az_=>Q%gN5_(INGT}LFF58uE3cX zT}G-OMaqSy9w@G`3t%hd9ULm7rRRtL9{5^N=@yM`Es4ChL(v}<`LegKFEydwnNIb2 zwB?D*D8+jVG>;;4Vb!Pnx~;JCAfCyY^BO;dAirf2UBI zcdiP&YxiHdJ^fjf5JuVag^fuWBJiNgd%HgotrZ1vB5~*mDI7vaL%qQXaB---4}Vkh zXj9yfPj9^hQdN25#y`8R?S8T5Rb9JXe2TAAUc-jA044L9{fTAd9-DP>31f%VcM2%2 zz6Yi+i(Zlz@Rtfn)_t96F$VWh{2#`BOQc}(X29y0Ru069upWy=oWv~5NMj8Ar+5~} zd$rN+$L>!ng^7TqB`UpK{X}8^iv4DKE#wqg;dMx~NN>Xkt7ihd$Em~-Gc;Ak++cgt zt4(m%hHnMvAjJPhU{O~Ej1ms|fKq|y*`7R&3P0Tv!t0F1cy!ijXZW-EF$((MU-(B> zp_|{A9tF@&WCv?MfB)Js>lcr72j-x`;Olc3h4X`m?*svd<<-dE0i{qI$e*~Hk0yurbmfqqBZC!B9g z^>VtaL&T`ZC-0Pj-f6XVRdPyLW3;H;G)XI~c1c?yFVD25Tdo_PL=30;;E~L!9q+}= zG4qju^K!JpMmdzZ7&;p12K@utsb&@RZtaE+{zjBqEEU@WH&4Azz!KAa>c@=yFt_ojslhqwK# z;0`*y7I>Dy?+wL_#capdu^>>I6pH=w>iAvQri+@7u4z-f_(pLfG8I%fUt(4_z!s4f zhL4f<=M-@V{Cfkr0|~3|($E}#FTmsYYNCR8!SWigveyI(*?4O1v-@ldm)5b=eQxn9 zZILuV$FdpMRD__6okzFbXH*HgEK1-Yvm z7__&yr5pFO3)W_nYn1?yJ$?O0+2d56hAsQTA(?SWpTSy`&E5#|4LN&RpY=nwD7 z4R07i;0apv_TU}6BaXlq-X1%s%ly!JeX`15i&mE^#q$_X#Pt{mWtpvj?;`zAl6~uG!9K1K+_924%T{K(do?#@^qi5!J}Jr` zK~A88i%>TR2 z&qmZWP5rC4cfa#bE~mSVpAb_;4O!&QSUCw8&?9+KR+=;4At6StUcZvnFvGzh^A4z< zk7)wTIGR?J6G+#HjE|tDQZ^2NsAoYp$+n0RByuUTG`KBVv*7*Q?N59AZ5h^L{c{|l z!%i@taJZQvj?t-(zvlkW`7qdR)Yj|s*nz8=w;v?_Yoa3EJrZ&vHZ!MkN6O`xqOG1r zA6px)7Z5T0bMj1@-OTy|^c*imHHx^$3=uYB=zxC7#|T*vUB6J;&D!C$rN#GOKZ#iLvq})ZGX^%Oc#(+%gO16yX*vwxpuX`F7XCF%Yv<1 ziqxN9uqZ)Z_zPd%7j=B9*p^=v?1tTe6SjXa?-_7jI8Zk9DaoVg&kB9G6(8iHO%qD2 zhusOS0ZxvnG0g7c>vG5x=*n`57$^ES6d*LlJjMU+MZ^mDk5#AuT|QZb-db4JwO?D) zT0$JXVtWR?%6}8ry1cr3y84dJqsh^yiaz!Y-PhMDuXm}63OvB!j9`~A^kcj&B?t9m+kx?2 zO{k|7*9y9pfKyD0YHL7P?qlgDxJ4=X{585vgJ*2FsA|K|g~@r;Q)wT(nIg(O(Cur~ zsjvZlFv&zMFWrXKL7)I70Z{r>aA^ggX6gGh?Ba6%Yk`0(9d5$IYCpIYGXR}Uj8RgS zfl|OZUB(F$csV@MFsdH9wd&pK(RJa_7DO2t%1X6tzR+?JHU=?xC(HL{3JGjNGja%N zEe|hyUU&tDZ0BP@vHf?AQqMH|8O$@r$EQj>$)gzKsDbtJSIvow2Cz6@><6Fw=~I&z zb2s)(*dN(JWye+$DzfccV-O+;lEiaSh%hXLQ`<2c;|*7X;9vO|O#BVU>xP0N`S}z) zb+Fp-+RqF|S}h%*tW(@qEN_9lccF<|WlxL72>DVN#7pY{7zX5muUPz)AfMZLnYf4} z5*hC{;A|p0lc1S>vNq02Lkc{5T_@Pl*qRHeDloSK*RZn*yZ=7Zz6`XUd%$@g$4 z|1|LCX~|y8u?s)Sn6H6fqGoXayj!x__~O#rb=yNXA1qZFxS4+KJV21OzpSRhbM+Hm zZ#3T3Lt~F}dKhyh^7tuGKm!c7VHv}13=wf?w7K&6wy%^7s`7$C)`hn|M0#nrLZ#-v zgtKp-J!ulw5Tq52dSc8l{RS*njQ4$rLE;A60G)(aTj|8MxTC=ih1cL)YPkdd!dZFa z7X|YnQpXSFPri6>_@SI&Sk2t=@jki~vLz|=&^yG7l3dR#GAlK@c)g!EZ0@>>_Utb5 zo!_e`uy+-;dP{T`%RR^*iLFyGzs;x={(kadz2?^=$q?7vR5`nW>SP7!JzJ1@LYlWh z_AbMU>cJ&{QSy!k@GD=Pis6}e^D<1nMF_3_i>lPRoe-M*T@gAbhNt)ntejITnxT`2 zeWq)0>ImaSsz!FMysQ07P)viciyG|bHS#gk3vWz%E^zGj^_Y%Z9@>&wgB@brenNVE zzJ5_bk*QS@cJIEkXa0)qIx%FgVUu;C>eah?x8rvi9o!+0p1n#d;F=7rE1EjnbJZ?s z>O*Pt%j>tdh@I4#&%4qIN%(%AP7r>CBuJvbk!N^HF=TBlAZvpP%w^-6KWZ)Vd7PTNvKz95L zd5(;8%U4>~BU_LsxCi8ENpDwv!qIhXF#vFpUPBk*)sIf#Wjt8s|6Sz!!6nd#bXA~ zY4E%)p)qy~bQ{B~c2f&G-jAF{oXM$@yJe2AP}gOSI}yWBTc_8-k@XXa0L;zzk$m*A zMUC_`$Y)w=M8<5Nkm}o*LXSLZRy-Du9hf1Fc#{fNO6t5bS@fv0VN@jrjlS6JWAR;# zg+DfMh&0uZbQA9QWL6NHW@OabUq@@zrq!6~POSoMSLFHR{gs!XIsSy*!ie~!rz!2x z%IK-m)7EW9@WY28ehaWj_UES{Wb>$g(sO6hLBjvG8er&a;{GtQ^YsNwv}GdTcmpa)03Px{?l+I5wsk z`7FW*-$NE8%b#Z5c2F2b(numz!MJw&JC!CTA%K03@j_6wQmQWXcn|Va_U0{gT8COj z0ye1E$XV}$39E4g;zw+#nXD*(4YG-Tm>pl-+(-c8zcP-d=j7M|EXJTVc8f|{1MuTJ z#~^A}aj(&~6QSEUAbE}=smsgMZ$qr={?W47Z%(8Ht*>e&QeDyh?B_~lo`?g|lCy{C zVs!}Wl`g)Lk6}~+#JS^`yG3M`V3YBnDLPkWf{>}mBvMKOxkVrpMeGcY4C$<6@834- zIIpTzoK;Mr7+gor=_wg}d7}XMUgC%(`nLVRf;o5-(|#Swz9VnuH!d-&JVRnAH=jqw zS0fHkK44^4B(1P$9t?T7EVQU2jY7MEYp#w$Wy6s2>!FR-5${(rwq@wVC4Cyy`}N>Q zO#bMV5$agH-dr83jk%h*{Y4^djLT&NfHg zrrq3bdl%#J6TJZU>afUD*?r1<|9`}T&mvm`1OiPNMLGOvUY7y3k2-gTy^D%;b=!{M z4_IVkXuobn#bw-z`sd{*vju{@4;R!BshR-TGdm$R;9Ez)fDA;=bMv5f`@;bh=uR` z+&b&%Iq0xYy^kgGg8)%QmzgeWFBMP`!+Gf_>XY;i?`S?zQ&X!|2A0}+ zMVrf&`uE7eP8@9fAL1T$IQ9)D2F{>@;miN4jm+Tz+8*^kwEcg`dqCfBg6k#AZCa7E zTY6S%pGW#Wk!vy0el2bJzJ9oIwAc7`9OWaXax->BP}M@*bDS(&>DUbm3TASOL3Cle zvd`%6Mvd?pGycm;%r3<2 zu@Jq;8Q(|w&^MuUv^&yNv^{gpMHJVLvE^Gj2PvN{L)RmAZ-{6)UFB0 z$|oC*zFvG;{VBwz1_egv2{oY?17`OZ)fzIkKx zVI|{=*Ptnt&LZh9KaSD%VgqFrqxmf=_5wPIlmqbqhs3H+xRs*a%bG`N+!FWaAqqsx zHCe-}CH0sm1gex@Iu5^i-8L9N6Ia(&?One63%eRD}&lpre8zoVEW zAsk7VxtBf=oY0-v{v=i=Z@r;n*RARIy1|CEFESmkJsJC4z9#GYNwbRsExQ%!&JS2s zxZWgKJv{Q}(BK|!!Bx@b_W))M*ihYXXa2sc^9PVkojJ7;qkZPD>zT9XpN zc_g-Q&okS_4rO{74gCia&N6dGPMeT>5yv#+w3Q6g7g4uMzni z1b#qmZw>~-12Pki62R(PHy|6~pZ@=an1R{^`#**6KDOC)NmrRiYsQZi1pYjTXk`d- z4V2{Bm&j|RRX27~P5mPjn-(ux4PR(nTRtC-v9gI$R|?`3%+12{G=yS?cEtl2**O?5 zZ6zkzb`FUKA--s4)#QT~uh0$JeZL0Yu$G*u*flD7>H2^+C;kK)x_vZZ!;qK#j?Z_5 zKm^tVlKkHPCf0UK6L>Rm9(6PU%BO0Xt^y%2WSx#SgNdEr!Yrk2%0C=0gpkD?sA$aU zTJY|?KzZzg^3_=P(auW(Zn$#R9WdhcLba?f|G*`CKok_C=Spf@yu}b-x+ZI1qgY3R zJl`a3CBBgjZe5KsDQxx6jvui}JB)kBe0>jfV_dAk^(@v%FUaZlAS2(e!~<|6gW#uY z*?wQq$oaN9N$4JbC2v%7UPQCrp_!QS4H6{L-tBa_AaymRc`;1wp($)D>*!|I$R#Vc zX2Q5{V=?0uUl2nYAN%(}$B7&+n5EW;#6|yP%t8V3M~5(XRvf=WI|Rz&lYu|tc(R8T zzbR6m-H>>cuD(S2W5xThXWI4dwvJ{yQO1R6$#P-r&hJSUk8DgA5*uYk=X$KPyh!WM zC!oBYyFPe2O}n9FPjBCoVXi_r9UNG6ZA<|uOH7cO_?pXGO$BA^QtXR+LkZ-2MJZmC zdIkRl>9XnROc$rU!PYmdBk})|gzYBL(r{bbPA0BBU3{{tOtu+pxXdqUA6q64bU67D zYQt;4JF1Vku%#8XQWar&kDrIVbY31y1}f)u=s7`St27dNM^5;5}(2q}{f*RDlEN3Xoo;fA)czbBVyYO_%~ zcQ3{akc8%!3y1Hu=a6B(cRf7bJ)eLqFYJN{8a%_>0Ny<-78WGC6aFrg{pVZL5spH2 z#9Uq$d9SeC^er!G>3V2+`6nP|%P06TDKqQ)TM^3WGCjeFra%2TZFY$ua?WNWg-8~$ zrzV>O`(3_8P^%nm^aAWy8&Tu{p8Em|v*YzGB*9#n*6ldc*~Rhgw`5g`9LnT7KU$#9 z*5rtk(^7hqC!D?8NIgwlaHlWKu)ZC(H^5nZ(u>vct-Ng8@UZ;BTh>k@)c0V7>{8Hb zK=+3PRBW0(+sb4g;~#kU0;N{*a1#s^3fM(t_6wY|$Qu^FlmYO% zHI-lOqTnwwv+j4!GwU{qf8|YG(e;)Lra!9xpc4ArKd`^Wcsp%I%sseg@E}ZFdG*KG zcAflnh;;cTi5HU|DUDZ1EvMLF`_CfpBqBmZpvhJ9f}hHkJjc4T>X!kwo{<=B$|l~o zww2I#jP8h6Ev#gg{6so{yZ2TPkV@I@rJV~#iJ#x&w(&s67NK4H7?^sf>$0YcqzrP+ zg8~BmoyIRXd#fSOW4?&0-on#OHv@pv{Io+EmG69vtG5ucb@?Sq3GT$cl=!}rC`B1JU zOKJ2G5>B#Hc=LJ7FzVb!wGAw(?sZm?-*)AlBwvmO>ejn@LP9_`5R;vW=d>UvHnB0k z!rJChlKM5MgjlB7;BH|n^BnVm%D((&mmtU%ifE| zZ}zQSgZAZbp?Y>m53oF4StKHMgHfueB8vnssU>fHR4?8rk590__$r#$m|Zq|Tjtk5 z!RSj9rjk!1)jp=aTgn8U$gr|?0H?>)vDB-YFWVE-rXwFxA27MiW}K9SXWc7Xl@e-I z^Q=UV*NBL)0(K>GR02T{KU0iKc|BhTdZ4-=MPounNnk_o_HDww=Em}Yg`%A)aV@-V z5*8A)d6Vpc%H|oel?b8Sf4ujaaVZ`y931W+qZx)}+e!I~4Se?f@#1|@RX`pH$OLpOSS8iHv_cfH1MnOTf zWa_Oo?;X8(9&NocE;0H5$i$iEKF5JlLvN7W-l7(AsYyxkU|~A)36Bz~mW|`Oy~80B z)#FtPK7(Z9(sU+z*IaH#G$+9rm81xt`LJ3%`WVZ?_;ht@JMtUwl^v$DZ*ImJ4a!F? zp12J^;qW}_q>X!ipViI#QGoai>|pL+qT(z1?Rl8blJY)ffymD4HJfJsH?OFkceCc8 zVu3Fc(_ahy?O~Q_trxYrDO=!XIa4 zt)SKoqcb20-;ca20P5GFmgnl;0{(o&)154>Zfwk&X*qmJb^Hy_-KT#_nWP_VRoU0~hwt!6G5NHFi;7^hZFo89CD(V7OBbnLX z@84YSaJ-ogphw9GJ^P(S360ByLFAjV>)+q@E=+Z}%=YAg9Mvo$9@3rutXRGh-`N+Q zV-(Gfb!y>YZas%NXip=U=QqPr3mE=K6DpsN(HdFo4lJbDc=vBfTa}X{Ma{tjTF8@W z6{m8Wn@k}AgjUK%M&1XB^!iwbq}d#_3=SOQaP4aU5P`a{_%pP5E+nLEFMdwYvS@Co zY1Pk{%oWoRnERz|ZVELL@nJ=~jhKBiSS~x~E0%4A zBveK~ca|{{z&QB&@}U(Uc;-1{h(8VK?3Uuf#gkPL(msdXRtPqrNl{MnIoOQ*?H!r=yg=}cadQrPdJdyW_X!$cO7JPuFKxt7uA zUanV(PsaM%-=qd_Theuu0r1%?)9i6h@pf>Hyv>D6XEPS z2hM>FvZx^}gHa$$4oB0pZ&*(Sjsmkl_|K(tc*{eBz5j~UB`vqJvQ#^P{KPek+Y!>i z6qdTd!zYIv(~xrWXigG~LS|uK<5ET#pUuIyvq77$ZzeNuR)g(q-V0KsW5?re7^mMFY=}>m*>#cmx`hpRd~5H zZ-Uo#$6o~xDIs+bn{7Lc-3P0J8HeT7adpM=oCup#5ew>`*I>EK1n$8uQ}LcJc7%QP zR7d*zb}BNxULlP00!9oZw5Nie-Q>`De}|2U2~!sbezvr;qA6Id-a9g7%;5-U=5&Zj zceEutXPEmb&0K%+)$#x^QP!5H6x~Vz=`4=wqNXw8oGvPYVD%M>pE;SEQ_c>jYX?C| zeaA{`mtqX8g&5{RD4J#yol-47>m)NLacxstH2 z!?*m_^kro|7SF@Jk+=KMzxVHtdk2~iK{i<*A-KJf2EP8*ReW6F#{oXxw04{4;M1BT zXWwrszT_~AuaDBX=q~?z=dRj&L<>A^;t&J3^lP%OF377rt2$akBej11ac<*wFqE^! z>Z<4^B!2!Q6j3O0Sxn5D{qB_QA~QmBZ)pW8Hq_k4n(J09+#X%#+-ty;)PZ~VeaOsC z<^&lO)%y)J>-_ecy?I2|a9cI_72aTD{~5p{R2R^2N?t$eSZ*ha4R|kNNkN5Hwt@AS z-YZ|$7q67k`?aie6=~0#&H=&AhBOB35WQINNt1amEes4^?~lJ_}Wq4Dr*R0H+Tw+uz0&Hbs5vtLLU2hq}DY5QeOH#H1Z z&frevwocS;xgg&~J60-EsJbIES;p9o;T?CHG2EsjNJ z4|QJ>NT`?09%-T2h3&jAm_@g($;v-Z)orh*`HXB3h#N60R;|n~e=hyM*!l{nDAzVx zC8Q)Jq#Wr66_u1B1r!A&1!V{kMN&~h<)gbnQE5p9q(KxJK#-D>y|6pghqtE!s4VoWpGn=#x8~GJu z-J}{` zYF+>POZQ-@CIK`blMpzVv-`JXNQ#o7>RCoE$9i5*Gu)*&~fH$}g0(z;Gow0(ou zJ(d?8B@pxN8I0~XsO2_b<$CCCVXNwwPa0`Lg|kC$ovwADt?pB)PS6Kst3^ z-gb3CK+6Z&)p0elQGIM=P3Sft9RY7L2=wetb(dcOjcNEstIkjBFoC+fuPcp9pT4f# ztmt34Us>UM*Ij`2x7+uQ}1at z(3;_VQgp+jV%za|Wd5V?zv!wjRPR;PGY~I@_hTw-QeL)$ME(5pzu#QH=Lo&o{eu{q zIFq{A{;u!CL63$Ww~E_1e%s9=ByMS(OZ3laeX*jpu#6l8g4bB zD9ND-R8X4{g9+~yN9Rpwsb=x-$T`qTxc*}-t{F&wHENB>gy??`>@sV9>odurs{R)J z^F+i2k{=zpJsWO`UhXE^jaJ^30wwo+)r#Sg13x1+%^2~0=9dHn?+*iAkPQmiHIrOgSKz>cOrXc!w zl9yZ^cE-r$45w~n@wc7PRv-l+#fnoBeK$7!x%zcgL2kK6Z;iX25t$#klD!uks?x7Y zX6C|sf2}r{XYDHKzzPZUS6SWqnQ=RCFp&T_t}3zvmgl+~-pjb60djINcl-17~@P68P5pNx`Zzb&vRqmP4j)k=MFv!kfb& z&Ob<-MlGzEXv?j9fnEL3zb6hGiIyZP*#}WopN?c22Cxt!=eWzVPJ1+H{~o5>Xo5y5 znuEylJWR$ZF7Z=>d`q6q&$)2qtC>pUehZQd*PL!1ODhj45syl4 z#DHF<4vV<9q?dGu{B#$3ofh;87vQyp+Rf^57*gWv7us_lJ~$nE#wWk;VkW(7=s3%t zj+?Oxy2R2&si@Ec(cYG1?d0Hb_ICuQ`S
cbGK7aq91e_eI#qQIvQ z2T(hnst@$%Zc;;;koU-c_PEW>bu6J!y6H#$qVA~&31jYWN60h^r~cBKc!Xu^xt5Br ztJEVv1dK*szSL4)X?4xZwT(0Y9j5Eb!*&mo`!11B!`jX7!lT-sv=(T&C%y0anS+z) z1ohVqVsMwSM1s}oy(zbucx8b8z z!USJRuY4P>(eyR@o{}L)kOY4j_Ev-~biX@n2M;U?0-_&LVv`brG7by@IlS!Xv2Ao&W72{*kOXvk!}SJ*(CQCt z7g|GcM;$YFD+(a)8qFN4Z*@@B8IZnc134uVatiUMaruSlf8=ztA{qVhgMTvTxkR;B zt-;OeP|X#WnS~;K>5!S2>KW@gDt?ck;(T0_u9*67v73U1?;{rYK0XDg9`%t2Xf!FH zA&brT!Puw7zr0y0etezcJ#2piyBh87r{gPpS@yyS4a4ZJ{(9_$6eHv(ZoV=mtqa;0 z^wwRysT?>SGIMlOXL9}sfkHrM!@uLG!$DHo!ZLu5?RI!06Qm*=g1svx7(jXjo+YoE zzrx>ub~85!yKeYtN1a?5%w4t}y8*;Eir5J`3+ zwV*#vTh7j@*)Qs2%*Lf%B>6A$s>P{^O=TkDp*h(o+ap_BtsxUb6NN~fnT@GQ4|n~$ zuQxtSlUR~n`yyaN-P<9KQ&6xbxyoVDLwu)rN5MP}7uy*;wZ7TC)^X&3bmGw}-C2$q2CI)dXTGI7Eyd<*ZZ+(hw=%1g zO{|3u8G6E}l6^cPGn5#n;b7!+`_EMvShor6WTe~Z2K3({^e79Tuo2mU6-M&#&pj)Z zmuY2;*O+7F_4Y%DQhMO5oyJe)*vr~BWM*2nkiou`4|N%~`*a+13)b8fm`NeLX#Nnr z;n;!|LbC9~MA1<0$pUG?-Y`~4W{HVX6FnZ;wb##wx!+9X{1Qtm9`z0@eXTd$_FnM@ z6_GX6DiXoe$U_-aHy`$l??r6ziM~}eeWuKwcz%dk8MdKeKNxZfxqzEI9r&53oImSYTGQpSAQeX7(qsNE{Lk#(WP# zQ_vHLJbdtAyzmPUKLwCkZE`xeTvWQuQ}b@4)A&x`dy;3Q{f*_WHBU>zi#=&NlAc
36VuhJ`8K;g1BGY^Te_~7O0yYk82D{*CR(Ghc^44<1`=+54_^Gv>|=Y1#Q z;`Wkn3u~dJ@`(CaQIRvH19ipR1wksG3J#Z#`Np3OJnnWd1@mxcZAg!Vtp9z4drkRi zcrx8!{hGYjS^r^Aia4c%{QUFnJ`34}LZ@5KOXQjkyya&()Axi`^zM%sG^r{*!8o8U z#h(gvsG?(uI2#})NC2T)cdaR zaGD8$gZSJX{91*BdD5XX)+t!*HLkHyoX~f+ZgfeQmuk{5A+=~gwBfauybxHm`GlvW zm!-c%*o67-(zQHvag2Y|d%NocfJQwYXVfc9X)0tG^zm&(F!bR-g&>Od>CnNK>HJ9P zPDs$ZPOQjTC;{^vj#DQkHmiKrqW5+gU3C7lX0-7vXOxyD&XH_G`bfvm7;}azQBZ-t z@R<^KGBd;8dYY;z50NjMmJwIVq6Y$O9-AvWj+vaXa2;c-%2$bO5E65{6j9w;5-Oik zP~u$JZ<#8n~>W1v5Ukho<_0v3&mA_)4-=2CzVdI0+C+OWdk7w@hP14UD# z(#LY`_GHDg8`Y&r%s;n{sZI>fbhUYlX+C6xKPx70dd%x}7^}%MK6UcfGjS@*5m~iy z??r9APrTDt9fjwjnn$_RJ|!~e1&=36gp(_Dp!uTe*&nryL4x4Mpaa*^=IVx>Oba`7LUA!ac_m zIvE3Da44YhjvT2dz!hw1gFdp@g9K`x!hgL0D4=?X`3Wrf!i{foH6Y-`Dt&KH5WO2c zC(g*}Pg-Si&4oLcr7UeYBGT1ZWsU6Iuy>dRZ;XVASB>`e7qo8Lp0|1#p)nF&J}9KS z{gwRAMc_vyIG-Y-_J)i)VE$DG=YY_@uPCLr;<<p?7uD-FX;iZ|G#6)6V`=|z^IT!hUGy-=1m)_zZ?YCd zNxQDL8L14+rhP&iN&2E14_#ncPf_g2;GBdevGoDxsC=%T-zEVD4o)Oyg_^51p|5^q zU3IV&Aimr9VT)K=*imf9o9Ne9=eS1ZxO!WWonCXk0O>i2x%v{_KB^3nUGDtNJx`j} zIWC@g6Q0TsdwfWqP!IotFGz$0@VDjHDu5>iJ~BPoHw7FC62K^4={zc=hMwK&pPgk*wl>&z z9MC9_YpTgT&|NMd#Pr30uW-6>;o= zzgVe1iW`Kl<_ytLH9}58^X~aUV<^`pnTK~7ZWipK4q=Pe&2tKan28)ebP%%$d;&n(mZLPDK2@5_+FYT$Z>t3TnPwOT zXmSrsmysnR?Wr_Qi&_KB76Y;~bodX8J&;)=H1DlZZlJ%#fMER;bXf0HA^G|P-Ykrg zSH~~8jN9jNEA(-5tgrkCn!m=G&VW{pz7@s-TB{jcw;yeoIP*O-j}Rdnv}u+vyK;S7 zPdNrH=W!i`Dz(O+Cogz~+eT>ORO;K`KMc*dImXO}&(F<$5_lGTyaW+>>pwE`V1(~k zyk;_px#*zkzww$s(YZhBej>SpXV)?R?{GZb#;c9T;30)|WOnxc1HIuKC7@(L5&XCQ z#iJl^C48eMnh8<^c9MOmfcH;w(#=mgtqA zR=K)jeV=RJu1;&d_&CzYYPLN$ZSDS(s*R}9zf&fd zdE_0a0a8pIw$a^0cV|oG9`p`>>4o!lY|7BN2e+3yY{BjH&MD(z1sB)O4m?dM!xo}n zgOhMy7>?UTOY5xx1ILteJTM{OL+T!>RE z<)5{A5Axy~r|VL&|05yw{PlmG6prkpMBrZmUWB#jb7lg~FZlgM2WWQr?~pLY537$a zJHy{8$4&M{kxv9v2R^6*d;y?rj(YF*0uC2^D9up=-V$iQ>+60Uo4}Uu&w2Gchxn|5 zlX&dgGPmK3-q^Pm$9^>*Q(t=)Qcyx?h?_NId(yv6^1LWak;!Iq2$(p`Y^OUPKQpuY z2UyP>{;9~IW5#r_s3*=;$|-dVvnR1nfHB_0N6ruKxhUcma@OFJ1ZxfzYW#PF7Ie+U zI7k7=(l#*;(u2=mC?VtsiIsXk0=2S&m00y?ut^Kjd0eEmk{&(ej@YY!A z1uVD;C514Kgfx4|2ZZM|eV6hAR5o6YH3EHDJJ@$cR_xLy)i)g8g$eu!(Cs*A{8<4C z^H~Kz*rfJE1*Jw0OzRvU%V5glQpjU&jeSpl^0^-pz}$(1?y{ zoy=#x_6T5!jvFC6ukC}`zT1lzitqcLE!Gm`u>-7tAU>a6e{NwxastH|w`byWaBsA_ zME(>^g>ZKHArb4O&zcY!ttLc8j1l^5KYf;v>1yL$y(h6}lJA)r;m)mNeRh~dZfiUg zfPSABQA6Wk#Q<`AkmYloONp^3F(n{y9kMd?FZVBJ|A<)%ztFHapZV&JV7$Tp zSltl3JG6Cw9(~{g?pn3FY(I#eR;t$V`KHQQ92AmCa7FQp@-gj-zvY^9?vD9gJiAZR z1!s$+(#Aw-!p^t|4U0l7DAzF|*fkE5UHTdd`Yq4)a6z#T?o2jDtWl0}_dlCG5(`*x$lucJ?<{i(KGb!*cN1fm zf?Yx}M8VgQD~87SHOvlS$0?EBLoCQcSpo4zfiPdj=jsPcrIi%|&3|Bi^9?<^iudf| zII2p~Y_>7+3f?CIuv379TNcYrPB-1^6V$?^oj(Yw|9WB+T+^TaU83TfIJ&7dRkENz zUVJ{gFl<3PnUo<-XHC8G@xv-xHCn4bGvx9aUe<70D zvGtD)9%k~vx0Qd*VZf2i^~7-w<#)sj5}=l~gCU&lx~Y9sJ0^Vny@6e-$=xv`9MX3W zP*vI|zA~CGJljv$19xx$?r)0|i2<}2DClFWL%=(K3qXGtVOm94dDeW&F5P$-mMZa? z{YfPGf~BpRjVa2SMb37Eyy~{*EmraS|05$;?hBHd{MP02?L|-mPzl7tYK^LcsAJya z6UUTYa&DHad|U~>)b5XFOm5}zT_WN9`mogO zp@Xj(5h1wsEFqoXf=M4w5bQ6(Te|OsooU6E*2+Nxw$SaUVGP58#E{nPD`at3U5Dvs z;FEdm7F25wy*daadHj;)JZ9XsS;w7exBq1YIb74kujnio-1M1LH(Y!Aw3^|)M6US$8q$8gxC8Coo z%=Q%Sg!FGc{kb9iRo53h?ckPfQU;JyOmw`D#QJu6yP6%)HXB?_yk+)n^@tf?l>0|5 za1WByBWoe%f4} z2UcI6L#U#=%hEBenbAl&$Qcl?Kfp09{H+30!Pi#4@pz5g z018h6L@pT`>qQg^Re}G7TfT;=xLlj5g35Ws1oOddU#AT?n!y*et@LvXL1H9#T`K1t*)1EM+iu3(`O7n~wxOVR z5O*UcbedKZC>CiCr!W+dzl&b`b&#uP9Ym;u|2am70mRl3w-f*1&(_&*b5rycla8*Z z9%kWHYEh?N8N&YeOm+o6`ffOOl8`I!s{c`bqgH&8q<_E+Z zDV@a1d=?C+%9QbP6m^+g@V9g8tF!ZM8%Xe65YidZfKcu;Ay3U&4r3k@VuVk}X|JbD zq8qR;*%6}vBTL2K0Rctki}`&$JtXg$o5|a+`*t;Y#@Q;B_oMAA~O7pvdQFe=GeE@x!gV*Z9G*gm<^I2`D1Qr7Y>0~pfT8&f&go(keSKPtW2QP zof=tR&i-4~*=9N{UmVq{)kC!e3T;dZX8!lk@+TqC`VDB%1B8D5;XoVz+zjXXWYfiU zIFZzM-T?+n))q0`hsGMvheZM4Noll8_78HUhuzNhQte{*_9jM=@~pJuJZqMdt8*XHcZ)#OF!reVivDoaUs|^~`Ai7Jl&Fh`+S9()U zDuRQHupmRZ;JzYaM|S-8t7JE`{sM|R9&f`Z(}f+K1Ur=goyn`ZLI5P}mRqm*C&w*+ z<#m|nYdcP3kMjFr4^;WNLp(i3b8G6{tCceBWvRne>R&KZ?>{uw1DBsJ$7+HuH(-H{ z#i#6HKOiZ4=#PlNi`u;Tc(b2qmpc%Utn82VT?|tqGb0g=P2k^;4C&FJoOvUsdUh(9 z=+l!Pby}LKx?`e+JVaW_4YtOV0=scLnRlJ79aJ?X8WwXso6)4V z4kxW~LN>~UzJ3fSJXVDK5GQ{E8+pV{!rWf86|2KgoUt<@Yx$!0+>9W z*Z<_@BtjU|0i$+*E_;WU=I=B{g~r;PjOpnG$t!DH`)5|*WqAwKHm22}ec0{wRjujY`7quQ!Df4oj(<4+r|1n3v`9{sQ+F;fW3YQEtn3>y!TJZZPY z2OC7HMa6qwigd2C*couF#d^sk2PZ9UwMJB#@kf-VCbM~3hRoG}c<7&L42Z2O<>@-x zr~f_?LCp&H|C2$IdeUhgqXfhxaxh6+CoJ{`Fe5WdQ7 zI!kcF#P&M)w|HygfJw(0R2+0XlGjG-V)2zmMuhkKEQ)tpIppd3YUk-|JfVTPpRR-U zN+cIAHLi@8>|}T{gKEGsL7#J7Ol#gvMv{uaZzAa3hM&lUJ1F;{A@r7;}quBK5?|}dWR1aA|3A-KV{YlG@j$zZ_+J9W!EOf(uHC{ zlj^EZfx`vv+OT%ABzx0G^Fg-GBaTeo*GNRA?aJiO2mnve?sa4H6;5AgaqVfyti2$T z$;Z^QwYeaD@9s%))M0DFDVXjY3oXcaO$T54b$NP}g7{%ccxWkp2932=CtB#hms#^xeqDxl*t&K?7U;S&f3BBxGgC3Fi%* z{t=ClJA`3L*)f{H?xAF;qYz@vxI=kA5csd*q0H*ZgG5_4wjatWruh%Ar4uu=_dO_8 zIG;RKU&T^$lC+z@pKjNK|JY1XMW%E>c#lsVS;uCT;ul^Cy4)(Fg5asV4mpa(@&)a> z_vSLXelchlzG;Scf`BhmLYcE%JT;+RPEBsb^<&JN!@J@XqS8_Ob$as|_PjcxG9r4j z=9(7Qhn0N-(P7lvoK!q)GKVA7_KrUxD!hQ_W#+-pivwzz;M>Z2a~hiE0Gw9KA)Tp6 z#{K6BO%^WFk`C;fB{TRgU@!?ou8TDU&HM_N?!M^ap-U1Mj1KK~IwqH)MGGE&!dN^sw#ciQm1et>$=pjf@b%6F99W}k;wjjwK$s*u9xnwNRR`v&M~ z#d+PU?(gji+xSlp3fDl>M2| zewnE(Wd(4|$@I&Q>}($wZcB^PSV+iHTB?gwT||$!pN*vX+NP>KV^rhQ`1$U;k!TCK z)huPEbODb_>E^jNA3RyIuWS8##Gis`*!0z1yX0_l)w#3>kqZ(UDk(Uq8MC3CVX#NG zLd~@$jJ!UC#_dlJqgMeq-$5A~M5&`um3edzE4YbueG1jqCMiERy13JE5$eg?oA)Pt zGP?vITPlATszi|PufUoLKyt!(Qi~P_X4$|<*2?OXdsw61i{r7miQJ8M)pq_{Zyw+d z4yHnyUWzC@#Qo}=oxeg=H#l5<;@AfA!pHbN1IoGT%`|6n;h#MC}neu%WE=X0vuM8vvM+tL`dqz|pOAES;l+rSALtBUXsBWsc zn>{W1e^pXJN!6y2P_>%=kR^IzX6%-a_h5OsMcGk#_4Q94fBkA5IA2Uhv~IUT8`Q!k z&2M}kIKQM8N>c??nUpo3g;nlT6D_}9(&11wbeY__X;41P(OKYQ@=heFGyX@pn)cSD18>a@sAn=Lofp%u#7@* z-j6M{sC=C1F4+vm{B#T2y(@BEkHLS#kul&XSI);{Hws$NUmxiMh(+6Zd}izwJ(b{i;cw$O~j~o9AFua7cLpaUKGXAYYjO}aOea7i_!5YYT0jTS77M42AQDmq;`D3q&9=* zUPpw$8G}Ro#d1<#)&btJr`A$;Fz-kX1pLx) zt%KmB*}L)o|NVS_9+=GxLxGH)%StOd;a3QX$es!-J_G8-cU(w<<)*5{L?4R?s_MSx zQU1ace!X;Z-}KT*;G;6!`NX>1)RO@R;kv@cC$=lfi}y2`G?*`X|e_AiY~+JAj-35C?Q5t6$+oar01Em%Rs&xwMo^k^-A#FuQWCR_9I z3nN-wBt{$7PpRa(r`w4a#S4vvf!ZNq!_fiNf;}$T1Ly8I?vdc=j%M2b)A1KzD{muQ z;N!W-X$Cx#zRHgb&FOpsS;^=diTPQp=KG67T( zK6zOWLhgjGw(To*)c(|Hm*$YFei-p;I`OfL9gFp3jJ^0PJvB29b{D&kSYf7+FjIR! zL%Y}aL=GbZi13ai>h)S$mk2ZeHa!UPyJ5bP$SoFY{3092(r^}L@+(o5qvBo=j{B2? zv!Nd*EdGB7i{CY{1qv9{ZvlA=F@vDqyln=3?VH1Ojm|aH9k^EX9v90%?i}|b^j|w? zf`USGE((a{_)u@BZ)GtUWF7amP&tGtUu~KE^BF~six%B37W=Oq&Y8Is;6F44t=3$Z zcCaKfI~Q;0GS|TI+%}zxShm`&@y^yx{tW%Izxhgz&N~A$am-b0>SHs5r0x5ZR8D*$ z9_pB9Swiuf=^*@q6;IZh+SUYf$bf)~IdOvU*s538Y1epxG@wJZs+%Z_>!^k<^GP2c zoH~5FRDGa2;H|5)L~C=TW!9mIt7Ea&Y2Qi5Mk<1SoCzYPjYAA!4B?a;XLi5^s`NROL$y6G%NP&Emv{Soxi5CwF8+ETkLx$DGjYtdNkG59P%EDZ(7-8P^iEl3Nh|QAJos>&V6VUO$XF#|7WGeJ-XZ zj`@z5Wq8$?S{ANx+yAkTe3$Y%%e^!l3%JPRkiojA180Y$)7YIm^d~2&rHNPBwW*+9P~kT5znQUIN}y^O5PcY6&h@}ep$61iSO|J(tvU78IXb{19G5w? zOD7y40%y-EUP!&BNB_uKj?|=qPzVpfo>iW~PH3Mc8v^afqAK`YYH;zFqEbEfEV1P$ zTt9vi11^6cg4o&EMpC3Mfh=txHPYv1(v(Ngw>Q~Kaw)_S4Zq%>d{#^wT@hNM_;B*R zOPW*8rlh->*nNf1Dt9_X^beveV$``zce-;8Mx>9=AWOb>! zH-j)w2=oLr^Gj__8-MC?>XVbo6Uy`eh%+KIUOBfk*~s}DP!aUUS-6svuMT{`@#ymg z=B8adUJ!y7Bs*H|mi$OPnu|;})=5yDaa}uzr#XEse=rRQ2_pR5lx6+H3ia^BU-Za% zOCrxntkbqA9onk-($xZ$ZIgF9@2)AO8lD*ZQ**vBtfVFiriy-&Hi=WB-yNgV&3eRD z8WoS1m?`{&8{4B)ANQBqrl~T1XlFak|7?T*_syDfu8vz@ zh<#NozPjB0s$4Ha&I$tzL$6X^Jm~CFT1{?U?6VY`e`e5p{iyu>?=wtL4Sm6QXt;Ka z+%=0o1Ok?)N^#jdDij>`Sa0ehXmcp2nd^&#nl<}k()D2>OPZs@AW%#YcHr@T_Tct3 zHzANKfnxzgbX|f1&aIj7qc;f8?Hzigfdf$?LqkoVk?=pn9@89s({w;NVM&kOTTr+bPKx z3_u_!26ojopO}L++$gj1`*jXuQVQYhy0NQ1gLwrRzy`ZUGnm@wCo#dd1?+?YE-^I> z$z!wU5jr~K2#+|{>1qUi9L%sj_;H(=v0nHb?XtvDO)KE6);1n_#0qHtm^sHQk8m+O zv{xcNfxh$ydD9Zf&q;gp#YK@kXJ5&&=Gek!!5=m*{coy7Bi$zk`W@O;bmKwPCIGe? z|3eWwaXgzR&~V^+WJkS3f+4O`Bwc^vE{Oep=wloL@pA=1&)=lszN=U3;AhN#iEo76 zI5M9w|D^tJq$PyhlsLdd2M$4pC$!njEm(I~eKL&zPI;OEHL?M+#A9q95BL&^_N9Nx zoXh_Cb_gR#v5mYhNOh1l{;V<7@gJ<3Cbul{ihAMqn7%rp4&@jp`-RSF+{+d>%1CVV zC4=02{+%UCcDu{9L?gK{3FZ55nYl{z3ytJyJ4uV~u?O2p13CwELEwG@W)HO_ihPQL z=tEkNkCD)yT2oTY9)Oo9)W!QooB|+?%BPr+@Ed|CETYCh`kd&pAbe7L!uSJ@-BJgC zBbZCSUmd+Ud+! zq8X)(;Aj%?3xev-UU=R0tf{fG<_k`MB-&b;)qp!S>=R!C?LQM)2&_amS0zS>En_nf5kLa(Mi z656L#uHA}KcKyt`S0p{TN|~E)m%>p}M130)Gj-l(vJaklT?9HT2hnF<4mrrdY=`d7 z9m;?uOvLjT;x%1oR*O3K`o8Za#GqsP>7#D? z0^d=Y$HepRoi9hun0yX-+N-Twsb72#;A+hysRo&sVrh8gVNVsoiu5{OQd0f%2|~fz zoFa_#g(cpL`iDaXjlm5~Ib)5(yGO?S?eDd&@hz(Z)BeLg!Q*_k@Q#=HDklE8xKs& z9o#$4Yd!L4caYg>`SV7OcDM1JXIc4dz~X?qdfublJjnsWJNA6+g zv=U%^>&}9t#9+x^w1wkkq%oxyv00TpmiO_~vZo44mdH|x{u0pzT6Xi`Yu8VMW^lzN zet^aCybDscLsB<%^zyu}T+MvIpjK$5W_^N0)Qj>mV`je)l>f5*+iKgni<~`gp0YZ~ z{2kHT`Tg|w6`Sgat&THASkA{Q77oTT)wg5K%GA{rj#X-2Z8gQ@BfmbZD(w88Xq2W# zfFtBmes(s*H-`B_E#FBa|1G8WgAt3BQkV~~D{_S)u|p!sW=fos)Fj$rVCz7AM}0@C zmpj;IK|QFce|cZ~vgx#}M0}0hH^%e~-Y2EfwT&T8oDmMHLW}2km1d_%F|tg=ux_v+ zj{fQAqL7Mtqe{*eVXC!k@C7e}jqYm*-eWmvfMf=}t2AK6h@8h6P|kUt^BIsebPxI+ z83i=V*I^qCi4O~U`vwjI$UmVj1nZCHJr=$ef7+1>Vt`MOY2sqn1PZ+!>&E=hU81T@ zH*h?e+zcwX^fmQ~$U2nL@ri;uj6aqV%Cs40*JeP;GVdO@Zb5Y5&<*J0EV^veT8{nV z#y%YlIr&;3DWBu8G5_t!jm=reWdvLDa{ni+F=Wql@0XGJJ#L%A{!x~h9&4tZ+^V|K zCZ>P~T2{w4RU{v`#ehuzL3r20YL~MEPP^UtdhpF{G zoa0^FX>|FS72obPqD*bsl_*sahMD6T6s?CSiD+MRi8PX!G2SzOI1nI0)%V>!hvm%2 z`WyWCThV^R>=u&e?vG;ZVyr#$VdC>iTHey$aqrQ$)$wzxPGna%1P*u(MD}_1MG)We zXip!!#BR!pKs4*0P+&HUKYo>P4M+E&SBv*YB~TM1n?OfsR;uc##hj^2@k5hxvNBn( z^?2Uf0pJzF||AyFCqW$VRT|?V4?K?_i z7WN5d2M^W|Fc~(HchR+@ZjWVG_?{|XLOwO!Vt#UVz}_D3t63osEAh|^5G#dm9rd6TnRDQRD>~N6hP`cGG-_UE z&K1B$x}N^yS1TKg-A%YLDjhGe3|ipoU#a3QUy)XirsQ5=CgZE@H$ z*<0nwz8=||b1ntwNlGkD)y>HlPv#16lE3u45vP%M`8VU5O&p1m>$8Xp zDTyk$4_iqSu}p3sl!#XwO&EO^)GA|~e!P58Q?Of=VjjqpwlRF&p~xArr{9*?^SyCl z5_?usTq-VNq})^Y6K|rk#4gYAq%*-WX_m2YG|Y#t7qg%?Yk`}9&M_LudyL?Cy!L5T zS?v|YU$V31+o6y7Cc>WYrfi^Rps(`0H?s(JXhKRM3~MC?t6nNr{P%bUoD?9TsC}Ep zxk!30M$Z59_X>^@_V~d1W&qibu3#xR2Rn>>2~OIJPwPw7C4M8oUvEh|+EsaZrR!IU z2)yim4S1{Nu$Wlbiq*5zsKjWP=$bvujn6N=)M|L!_rAYjkioU@AysBqma)ZEBk;jj zXdrRUY`)jx+Z?hOfmd@w!LTNG70W#0XP^`% z$mV%y zw|tgXP_yNhOs%~_Yg_H<(Qz4AU@?)`UY%+2Z>1jc9*fgH+>8kW!`W7a% zJ+6^Q8c?TIH+EBMS8x7NgiK)9AfZI&0Up`}6N1+Ce_%jZ5Bhl4G9oR~dSy`U?LabcUmeo-p7qRR579h-<|Up!#^SP%e^Yav z?7?+>N_o<=(o+Qrt$8%v%)icDs2W(QiZ`p6M?9Nnv9gUV(TNH5m>a`orQ$FDmVb*> zs9E6gmYz?kD`f~ysYsFP>J!6kzS>3ItuCq9=JYrjL^nB~cp#NSTc5%_k21vHj8HNdI5jm%u=G{tYQz8<2)iIM$(pWho#gSQu}B6A^pAW;4-&h~b{3 zGvBc9Czj)x-&A~5^!xIsW5UtR^C=H4SjI$>0-4+z?VRD>nz6)(axTAFWem97<5L@^ z7X=1>2I~o#vu~jj${$&)A3QEBtB=;KA}v}eaaFjlxB7BYq!w#Z?)XRmzb?8NoH7ybc-loc++Nj>ZAZoZeC14)AVt%w82{I%+NvY$-2+scr_?sO ze7P!?Gl+MDQ+7G79U^6T)0ik(*V1>^n>GBC_P29c{Pyn#=OOm{PZ#@Z-((GDL|b*k z-PzUktE((ppNm`ep_Pa=8mS#vv&8Wo{Cjm;#U9anu0A?J()LMifn}n8EDNu_JBGN! zuDX{OM-o`irM~$xbnV*pnjZZKd7St{l2Zg`~of5 z9!s4VHNe>u$CU&{$SwNG*~Kl_2f9gIPKA{y5NbLw$z-*uIXYT}x>~$YG+h_^tlw6_ zb$_a>+-E0zxTmno0jCC0gMeG0BX3dODyj3nMdY5=RR4Gv)5F3W1cEe1>GCwBi*7

U#V> zU?Mvv=JMwC@1<96S{(Td{Z)oRoB7hK!P7rWjzOM|~+oj6A~er!QhVc{JtJq3$A79yx`+sOx3 zxNwXI-XPoJuG~89oXKKR#dg0AMMYLs-bu~k?Q@p|m<`7pT8cHsf1id&m){UCMM;w3 zB=3BP7g!)XaP*xE)wHTV1TzeRPEi`u}BQz%P zanHvZyIr|$n0RIGqv*(Ii5eNn%9m86ZpQ>f8}-(2evZ5#jyaP&o8O(I4*o+^GUN zmu2(DgPoJ_T0iR=`m}G2UDpcv{jRPx;N-VJlF_2%nnkV=^Vh?(3OO{~I`I;b@utes81OGDG_p4v=aV4*R>+UkkL8 zKlSyHWeu#5tv+lUE_Her<0FZB-oPJzxZ!$uFL!X*4GAehq=y3IivOCE zlh8P(G2TE~HSn3paqn&9E!sH|6&@V}tQx&jH*uyWPc-vPlFly{FU55!CFj7lD9bzC z3PwKz55UzsZ=F`i08R@9w|8`*cVWJ=9LJvcE-WFU2dhq_W}qe{1`z@olR5)N2=;kN zsMML_eAnW8M9HCROTLGbh4Du}XS{KN9AbgR@po}w*IXx7U1D?X_dq>gVh_$()!u{k zZ=B!DtVYl8>-DZ`C)eyJNahhKlp&+qL`|`npzF`M45wyDSDedkYma}k+hX2Dso!<~ zvRwMt;mp@_GK=|hHiKB`iFQ+-B|5T?_=QkTkMynX$|(8{z4H|lP-&et1IOm ztg*i814A>povOWmQ(P)!w*D5qti?V5 zz0>fo$8+UUvSZ=;r>>^Xv6FV#{L_!aKK_6DvyZhji4y1##iPuyOV;OUx+#d zG=5?Tspou}yjc-(ApXeWff@6=*-Q4m-LbSP$&!NPG-pyQb76hAngSi>Q03Mt9RhBQ zc4CI~zxKZFFm6KbvryDuXI2Hfaiwm)a_ zOg~fcWHXQ;qPB~6KwT)m+n==ThVhP%eq~C#^cL}ZL|YzX8Q0aJJm@q3WZ63(d5C6_zB~?$OX;l9Dy6F5|WNCxO zt!cgalTO8#mN4DVh6hrk$0|?Q34$`?vp|qTPW-`d$rVBCwQm$N5@P zkR7$J14URkS?~X__1^JR{{R1YDOsV6tdNLGrDczkBqSt-%xp5to>xecO;Q<0sZ{nn zWE>-VmhIr!n`57SUBBzp>;3uu^ZO$=rE$B}v&Xovhh0a0{79}%&I>&8%qmoOS8k(P z){>9ZdZ`X55L-Fa7(GL2nmX?qvW%(w>8XQ5?l=s_G5Azm!j883Ogj7KAkofBQ$Q^n zw(ZxJ|mCXjud)MR=Y3Jahgs=}$fRsNJyzR@SsH`@AP7IBw>n&#Et|9FzWJn{p)3npK*to&8Dbqb6idJ1WQ4}+bhA;sV<#wL(5~$-X(YcKt#@lP7AfI+U(kVxxc5Ri}bmG0K2|;nUl~-YbK};dR5< zpKMER^xi937y1CMmYSK_X6YVeZ##70dtJjm>TH@f*n6~|xrS@&&+-?2{K;dX}=RITnjEYYhgOEO2u>MNx(8B*Qd&gUMs5zn@1gNiqHXg8IL1*N8lM?bYR* z-ZAO~0p+DBb)y{CPJfB2ZrZOu4sLrlTjuG8O1qda8h(Y;EY9?c8(ZJt?rphKoXNL@ zdS@%&+hVSuV8~KZbdIt$j)qR=hRNKLDk^qdEJh$~uO%Au&Drfxa6%IJip-q-nX}fC z_Ocn{wmkR&m8T%4d_9&AbgGDd#0TwU>GWFR-Jo?eFKhk7NV<&be>%Zd}P6Jq-RX zQ6`bvnTC9L>TvfEWl^3|(~QG7VBxdT+SaOY+3W41(^A4ZlTd_lu%E$BZz@{f!Q7@Z z@n~B7){eh0yZ!IIS3*AZ--Gi+3=dBZP&i5GofT7Fte@%r?)liwa@uAdEvpn5g18B#kSGkkMWtD1wAZY?Tef&GNN!F6=#(I92J%88lz|r7LGzW zFZ_mS8c`q`Btg+82yf|3BQL*e6n+7h+fBJ}`}>6GO^MCWO`d5f_%PJ5(kQbc8Y6N*WSIcxo%pqbWI?*g%P!(QkFG>dJO@a@>JKtY_?Z2Vm771QmT#X z!Mqyv!Y{{zjvL;vqjT3xN>e6sr(L%`8Ck9h*-W|DIVk$9Pk9?kS=ym86dQ*wScc17 zsZt^_APO2+MD>#;sWL8ROFNdAZ7wC5x8})06Koet4kA=0F&VEu56TJw+}=0RHIW|m zpmq_ES&wm-64ox!<~XMY6~B>T?6|)Ri6Fb|X)fqedSs379`X0l}A)TK3l>2a{`|J87>EPo{$43s%bD`=Jd} z#TJGie~?E3;Do+MiX}&hN>R}r$VwfBOwcl-I8D4$ra;6%Ab!WT}YJUH=3fBY)1M7`V?`7 z1ir-MLIi5clOipOa@+~B$^k#!&n`S#pA{Ai@G23#q$oY(&S$zkeY!eu#L{(QrTiRt zl{c#V;#kEkh?jo5*Iws?eSGXm)Ne{;?GYhI2IQldM9*=)w4Lz_frJ%wTFSL2`A>j6 zUHBB5gTz>YvZCiFu*(3YWc_6}b0_D~jD>c_PoEW5wfyFa{I%pN;W6_jXeQ3p?*&;( z7F-T7abJCP#nZ+1(~#iU)y2`PZ5noeR!a2Vp8DvO5-+4kUh1abCzQ|Tb|DPzwd(S3 z1v{5|V(v-x025$~Cm zI3%8vg6b{GcJ0NjalVXv=FUluaIbZDNAG^UNF*5K{-Uebsc^E#_>BE_(nkq#bYB5- z4v{QT*7+0oQd5`THSDvzNrun1^srW~#ysMdv+|UYPROr=AN9mOgM3va&88)Nha~Y` zDHNaF3?9`Gr$jtBol%td>N7@G4!qP6CR@+=9&Bz_nYf=xEPYBDrnEgS%0r!`$x!!Ct;0Xti8dW3)K~3cxuI~}CWl;CS z)BTINCRrmc|9&LvRMY@efc+Ml5z>EIZ{et%?w49bV4ZScXOrBe&7YqUi$LEaZ27uk z#4Y`b(kJcA0-pPfrl=>bDMTK@DzsogkCkJ=pBBrfY2ARGE-$BI%UdjhOevuvKN@$x zxAA-!{`+pten_CreWu8;hNtK{aFmJ(G}(gE>-m@W!8~8r8QezGlS1tuU096j#TKAi zXk?UR%{}@uc5A~LlcE_{Hy?u-`O*7U(fmg*deurfua~G{OkXr478114(=M|&DbX0s zA{sl(h(mX8U`)4qb#MOhioW68^|2}SPe#w%ByA1=O0Lcx znOHz)2S41fB?&zX-}QYPN`WMTp>rMf8Hge}cc5l6CgsiBpKC;=#*1&l==JY0U7Ds_ zc<{{j-FX2A+{G6}3A#4})2`5&=f*c)embk(%^|d(c{7pZ`z-8|m&%!F}*CuWwsdh1OH(X%W)0!)3!n0{$bOT~O`q}A={O&(1cANY3s8KFPo z?vxG}z3z!qp}uQ&+0N3?4>rShUsMV%NVTqfWzEx%ZW;PI>r+8iHFq7RDn3#*zaX$; z+HxM=bwovlie!-=>K^W(uhP@YY@!^-*fM!~*x~a)vt*0zv)iLorP0Pt)Z6 zKj;Wu@k!oyh*yiwgW}_TD|SBnFQQ}eEh^}=eoAJri5!%6;*+8)T$n~oI=Cx6GO+{q z_y0p#bKSAFfEa557UIS*y{EJqC{O9^sKB1^qmgg|QT{*b}7CuLBpdGHa# zf%WGa<^p4i{R<<9|6EBBj2((W$T$yo=u&s+4Rb6q&x2_nB>M)#|1XuP352E4M5wyuh1B z@4>KZe7)eP8J#=K41NC0bu=vPG5@6-Nt=m0iBI%Pp5d+M`-JwZoD0?LTUrjO|2|*b z=6SHkx(XXp-4|)}0Ls#fYf1d^mk~13k)7MsxhGoXye#}GdbhANVl95HkOJ@Z|2C(4 zloW#cm0ftV*Q*?A(EqVuBMBa@IXDA*Hd+L#{Is`t&Lw0=&gk7!+scWKn9;j)#8!<^ zxamF(eYZF&b8Rg=KJl7JBNQ%u#+%M{4rqryvo^4^%|*5Gg`Du|ITvysVi|zxC%=O* z^!P?_8fb)GKxYE3jKmcAb{?lIeQBt5XSeOl;Y+fkFuXIQ!rcSCzY;%IM$UZX4(1`@yL|UrdU!pHrJV-5$n!gT zSkkLnh5gYzs?_z(aIzFi!F%;QXwmxlm|Je?tY`CB|T_m-Q&s-^GTyj9!N-ntR>ZRWr*;lOmiB9!6t#u9J4eSEZg znYt%pJdcpA8~)R$!y8eyfGOV=8d+@JnM74bLix8z_*v$fVEj!e%j6`(vMjlMS!P)e zFFGo84*v%|=+}fb2?|%5D^T5d(-WG0mhh5@9(B2U3}@MO7%qZu-+sRCabIk#pDB5g z`Dm1~@%hQEPeq-aVfPa5B`IGXR&m??q-nR?z%4}R#p;5pR1Lc1v!4Ll*f%F;5bc%; zM;)SylELpk&VDmewoU<4{;l-hU;Zshv~qa=KUkAT?)D~|XxkLSwBGT@jQT!ev5E;w zJRnCqr}-0jFVeI-n?Rp_nsV^-G4S-?T9@j0;RTuz-i-cz_wTaw;{f}W7aZU*#_6)C z>ssRyC(%)arap$kuI^(v0NkZ@?cWxLri4yZfL&sJpxF`wTb0_0yKG%{4pz;B<@+PZ zR*Y$H*W*dk{C#15o7N>x&+(_YiM1t327wdPd#-~?+&=gSpd9W=Lj*8#9R53c>jzoQ z4?S|ClM=$W|H?+aRQWN@o(_pDa+zk^$JU1E>@2>n(VN(nvhh-yT(fks2hz{`+{NuDI|Rx5QoHzNTz^GUCdNIl zBqd{m5K3XnIjynd_me4tP<5$GwXUMS;84K9+?hJR-dXTg2nGAi|6@jQt6pv&6cLzN z#Dfdy>KqBtfdWS^>Y1D^EK<4(+^Av5 zWprsubZTTt4M=7cb~JxipXSC#b^%*`@I`22dBcWF|NEG`fz;x=*c2Oyn(6gl&Sv7M0YsF@f0(*z^O1*=|7lhZjh*^=e;d6d z`EX(mX1r-?&1jY6f7>|6yW0e9=BqS8ia6!?YgIyS_~cR87w`g+%nN&l|8gJc?9y;V z&Hqh?xCYB1JOioYp=>bFhmY*tp<10a(qV0o;5~1H(?L43n_X4V8w`31IpZzV4Jp~P zJKHt9yCsTVN0r3TP&124h1PH^irt}3;KyUjG*B=5MC|$-=U!Cff=)juL0QoaTx^yN z0;QWR)!gbvK9sx0J$<>=bMyX+t!1dJM0i>fIv|#REc4KQc^rA&3g**FjzmOM*f*ai z?@q+KZVbq}Ec{A!6-;{Pyn^m!kFQWB+S_~av?ReeDsU?(jh%% zV9`c~JF04M%l8jd6DYOi)8yCnHZ0E9eAB_Sn%~P;VTcXvhypnv;xcBiXTY$#Pbfm^DAv>U>D~(#5 z*KdD9oQ|N}2mjhD64+nAqPcCD#u!8H{ik{Pt6*Ff5)Pmb(J(vpBi-qRigEqnN$u8wC*Z?K zYMmX!(dEd#^7<9&AI_}&vMtb&%(eEztpnGqqYBDOyt(T-*3um;wbxNt(x)vck^<>R z!%!W@Ev|Gs)%p%Z3{&1i0j|4L_B#Jc=_clys6c7w`%yal#2X4SnFK--bB(4%v!|QY zk&p^{qQk{8RIxz;Ih1?gT#?f(ved!Nzp{mbI-(b8h#(M(-L1;FChQzCsYMFQS-=Ak$%eHvp|3UFVyu)OA~c}W2RJ3#_`gpB+s=R zn=cC|)+-tgecUKx>K*HsRZg#%a-F8)1wvxHVO}_1z|Eka)hT?MI$gB{NyKfk!MG1({ZQKgj;0 zZfP~@AoUv|`qrj!GqS+~&UAvGQyM6$tnRjDw&g2E4@bb5(gax3>;;0$@Un0Wyczg@ zL`TSZ(pdYSFzeXiW~|svT~|U1%@r0Ucu-ADMt#djjNUQ(FJE8iZbp&zqT}$J=DY`S zMw;JK=xwo3;8#f4dT1@-YzYd8CO(TS5Oj%GD;=^zPxOP|gXQ0DOFOS+PRousu&6N2 zexs+B_HIRvgocw#SX}tXev=IFAyv1sj*_dm{>76xqJKJ+kanrWe%*v{?ejn$tboFg z6@M$)n~iU}BxqZ`fgLqktzCzMC!dVo0St&+?!S*KuiX#}9n$zy5YO~J{R+_U&~$6? zVRR~H76@jP9Pion&r0J%S3**<{b@h)ujFt`Ir>2=~YLqZ^l4MaQ4<1@_sig$4MJpqA)=AYtNvBbNvi zU3X4dlo0*DZD3yH^S|y#o1tipPoLm?SWnPbx5zH_=ydN<#`zT?Gh8CN znUynHs1L_#Qn6)`1(l-1wXMNODaaYqv%X%5&2;iEO1g+{PJtzxc(b7F!g~UZ?KSU8 zOu*p;m|o}b?YVV-W$w6HsPAeE^5hN^S`mX5(XDb#s(psNwp6-82@Hl9JB5Y>4Z>O$ zJH^wvY^*6C1Cadqqlxb{73{i1W!6Cl)EyARUPb3_0mYj|AGXLdT+6Q|DR8{smI6rQ_4Ym3D*XUA(_w3d!XJXH&a*NLyhPUx^y=u7N%a$2{~4jwtp zTw|NI{N+pN6%WokXSg(#j)H^?6?zflj)AF=C+1|7KuSYjl^?PK*NCkJ*Sw`R|%`XOx*LXV@;dOFdKEX zf>5Mqp6)>omn*zoeH~Y_Fueky-1-QX3>)E>rFJ_GGYd|D;FInI-W4LVqrrGkKUO2$)uOI8?RA^rSja(i$A$7P6j@b6d(Bniz)b5 zJdu6K`8a52iM1HrH@V}Bu#%df5{~RZ{MVgb4u$x9Dg@Jyrnv5;!#7pMDXF{A);RlG zzJtS6=$yjWC>IHRT=g)&Lt9I~S__Wv2&v>Sl8cM`}F6sjW}a zq@f_vPL(Sp7)Crz}UwdmJ0HMn~GX zgD%)9RJ36Er~A(#iA<|oVh_BjF9n^y>`^ZHtXRTBnd&8)WabarP2LuE87Qy(a~Vib zJ^yJ`e=F9XU_UBO)1{sO6C!gNfzXi6v!@?&HzIFvOuNUaFaurh^>~i`&r-k*9`!y; z8B~UwJk4Wp6Q)J}P5RgJK5bhvRs^)2rAQ}yl-XST$Zj5iHevgt;Cf94)?>_G64*;2 z2xF@Vewb_{+4zW8-#N~`3tIO}lir$_`UQ25l@*DgEaujl236nxA}v!QLSHSlAlwtb zd-VE8mw7;ZYjU|UuO3ZqS}9yq@(ym^29ej#!S9&-YEVSPg6RkpOCj%3QhP16tz z?$^YT&3HCtMa=Qk%$O?<2^W-x>_v{tAfc+$>&rlO-^xHfXLZMjq7>Syck;@pivMcx z^|;KdX_)&~0DC>A0Qp7?%K9Y1gaPDI!??kkeaVY_o3Gu}#-A^9FQO+StuIe%wcS@OYXeToQjf^UGEQGPI%%WZzp2#P9s z?IrU5#;HHU%Wo84fmsm*cFMGp2+X2XSkngI704%urbM2F<&#+gT!1PfjI9xtPoD>0*mHqAYcB!~+S!hjY+ch=-9R*dCR|=eWr9!d@yq z!`k4s&fM_xUO_-woeqb01CR69M?Y43V^k6E49TR)0)+{~2*o{@vnsE5nBD2l$oZ06 z9eyu`-50+Rce`nPqKF5mrFWI?VSsSZq*cZ6v~sQF zK7wi~HI@tnw*?kX0;(Ab#@_q3t0Oz6(mFTwmL(VH^~*SoYJ7KU@1_5+dxYq|Im4PmE#P6>0azWV!uRSLU9lo%_ZD&N zzbkt+3p$2N$@s*mmC(62 zLOa^n3K-b4#uK8H8dfUfB6aE@Gn}8}wPoafbEJ)i(|ZzR@*8c6a_K{4)<^rAnS^os zZ6%qdZfESAWXKc`bWq@WiJ5jYVTkCO_O3h1PE2m^SAAhV=vN(7a6l2o`mv!;q9?mY zA38TLs%qY-4%J@@-Dy-gcIPiotd(*^xAC@rBKT$U4VCGmRna=lGB5|I${&fm2|Dby z&I92Rn$d0ip{jnzndgBdnNWpuu{XGg*id!rzN0Uh(*9F5sMB=g;yi~jKM04svYs-s zMR2MUB-@TJCz4J?sir{lN*rx)#E|iuQ!2K{iO4KrhI0E!@#(aP;$4#< zHicZYwA=3s`QWmm@aRAwayth#0J--+RaA8Ph2L_~Wwuw^PErn`hEGl|HbYe=>j|cp zd{kzzl^$dP`$fCVm`>*|MEW8L&%7q6-fo%53|z@9i}L+_%ILCS;Id=IjKFu)mmR#y zrr9t^euQL++x8JR^Ny_D`6IB}4F$?t+ICmCPQNjUd1#0uP45}%_Xsr^)7r~(I{T)dfS1qcEo6iaHAcaU&t7}sr`iux$ki9y5LZod zk*q)8IO*^idad_%WKH6ZD_B-lv9!+aGMJPSg8qs}5WYs|!Gg77z0yTo5FJxZd?5AW z7ohews|)!P+fXJ{z5XO$vFwoGY4snqklsSDBLMxz-te62l>a0@|C4;m%|y zkIpy&qM-%6a)nPmAzs!@Qsn7|u*Ohv!w6y}l5GI^Y@T&W>c&(d6E(WTER^TZNyDjb zV=X5pDgCzi3o5#6Rf!KP{iKYaGl!z*4MRW3<=^51I8WPV*x1)?ISuSF7sLJ|&k9^` zQOLHW=Y#WWvIAOAUhe;&1WXfZyxIm^lRBUTi2mUz_`&=aU$G-h%9 zW;CtLw0r%9f7ET)+1J8wyzDADybgU>_}tc;U1}6pA0CG3`v31kxZS|N&r$i?)b&P& z!e>CVzcqKg<0R}H8 zYlKd7WpRkg_V&#ks#j7D#h3gJ>8VCKfNP)J!bv`)Shf78xeNP3hH66O9sSj1v;ynO zDEmgW(mK>!jV$BY_z zlL_jumZdtOJ`g`E-2=cNLQ6hB=901kCbOA5Rw0PO%Z%RM23sxtt7{eabYhSLox#wO z%F;d9bQo90I6&FaM~|EWeJQw}l6A#$JhAjkGY+suW%y3mcMA^|KU~LJ7NPH9r$N&8 zusg5cN8Du3)f#mDe8_cyrhVch^p)VQy^NN}A!2+x7xg|oeOulRVRwn1^cKCq*-IPj zPrHqs&KXXrCrS#|9qN@?&>CcwPFH{Q0TAMiEj)Gg$)paToJol;J!>j2(wX}WR0p=B z{+gvYy;L9WnW`@?IO{HBf6*sUMWp7gyzZXqUFIbnjHJ@I~?oF;`yHMu21 z5h1YBqG=3H{BRxoeCFvD{l9q3yd@l2q@YG<0#1Vo1t0EYu#?eQyRKrJaWfBpy55C^ zx1#%9QiJjJ5+@yRKDc0N)=LFd*~b*sl9sQhDLr$kDKMv^uu-*Z&-+OM7`#PLcJOXXAkk%hG1gSN zytIYF>ut8OR!sBXXjriEB;J#rMv#$1tv``ouMkER6Xr56VjfZ78Q2huDF?*8peN6d67DJLMp=-s!tkqmPDns-(8T+%iq#?CQv2YUv~K%}*xA zp~DIFrj4DOZ;RO1me6kWnPJ97Rw^9qdGA(5xx`W1mRE%zn|?e4(y2MhUsiM$Hyz9w zK%5gJ8T@|zSGNC8uD^|#l2S)$fjpY3<-G6Vp87cUM`qbyD;?~+P_Ts$h!PH+y^(g~ z+cgCawuT4g{51UcAMBz7Tbzylw~0i=r$4`YE5qX)MT=nC$i?LRAolY=+MlKa5D2l; zAk5z0Z2idIBBracaFY%vS5RO5o@k`3x9+N}yD|Sw=(n=H;-V7+M=8~y%PnjZ8| zETzAu8;7|jr!ZSs**zJ&chUFI#0o$U_SR#AdVMa8MNE+8?5!V;wu`nB)m_K1N4lAd z>9*8|vYrM9+H^SYw?R}=~_o5H&tF9o|G6)1NN5=fK{y z=mp@bR1_@;?j#3y3GK|BiATe13;s;OM)VQ5jj|G!{iQ=EL*wb@(f%M0r{8x#DKwSt zaxlp&;kmZmtngWVCO7<-3Vr?uT5<|c+sf(XX6bz`+9J~pX5$w4(}w4j3r}|ZSP#$q zDEFP_?2{md|4e|ljfhiy@Lk%zlw|4|;mT-{e+IrOcZ1VrXJO}-!sW^X7msNZz7j;U z;6;NDF#xMgwbnHM8nFLz4FBj+Edsg@V1&gZXJI81P1uB^*+bY#;3u43J@X#bH8%*T zo@$V!bEV3Lcb&ML7|&Eg$SjuMcOVy3k32;yi=G~zd$7apnG+MjbuhRxPB!=Ck6El) zly_e_0(NyhiLMKulrpVtapU(YPf1A${(^(vSt7006FpXPYr-yFlc=>$)_6>B!QguN zVhHJb1P*~!34FdDJaEvsxw6X*n%=9RgsX!>Pm&-JSvHhCrkpP7S_Ofsx7H&Jj-K!A zq>FVXfDh1GY68#tJ)B1>kEy9Q+P5p^RVo}=9~J@?xN7DE$gRDW=_a;uNsXhH$nEuE@C|vT-d}48LRFqHpT4>A0vIcMJHuARl6FsSBDVX>ujhn zK$%~;5Eq-fSm&r`wKsn8J>&~5%3)@YiQkxwopP8QkX`#Ii91@Tf}`T)s!)4M<$5OZ z?VH2G{_+K$rZ9a48^}Ee;uHK#5#8wb;fH4dXyp=|8?axvYDIp8!wH8L(~~?CP)Og4 z1RMQ=S-&CQksGVy?(SyvB&l~-hFid(2V>AeAG(5QN#Oamf&EpNx#jLtczTgM&Ro;4 zeNOugy~|?-$h4{Jz~W(J4)HL>;-j}C_8y%X_2>?Gpa$Cd9rK-K5^nL`JF0Nnz;M*# zJ9y}eruuJW%xO?mY1Y-QQ@}8|LH`m1m=KhNgd%1y(J~u znHpFv%5hw2dBDH%%M32-oS6|LbbyZwNQQ6m8Jwnxm1$Iblo0GhTA`bcpE^7p|D8og zLlqV*{q>H)J~A0b!0T)%n1o~mxM17ZBf`!R7)6;a^pc*IJM>1vXy&Bv^0#lwGKT=w z#=m29e?|Tc8}@#e?jM6by+!D;KkNvWpN`iEg^SD7yjCWL$P~aPPj0yu)*enp5+J+MsX0PyX62!tycw-m1xRcrPYd7VM;wSf>#T zNY8ic-gBVJfu!0wq|~SZ5cusj+34QA$B|Tu80L$EMBE4}$1Pywj!;m6;TFD#PU?fN z%C|BN)$GxDkx*h#YS=a;rtsho1q>Tme9R@*)F{xdFHeWvIpYT>K%6fO^GLRb5xZI9tW(; zmfTx@rLJ$Swvgp^sJM-s-4bdsb7Aj+qfI}Dh4;|!SQ(O0(^rY$)6FVBq{ePuBQy*7 zmene{x&=L%rC`0Phh!Xo`5U73)sQlL(-}sSB}_(>7NXSm^%#1lzQjJ&4Io4^lN+p) zr=-?3nhJpJy4a@5bCoB0;A?;uMbKAJVL#7bO{KA0ThDOuoZrtu`C6ETzm~Wxw+S!M z9liZk86b<13Hj{(M0c#6gW^&QSTP}_7~s+U@P$!G1)XGRTv5qG=O@z8I$cft9Ce4l z=(tPzoWzd>FcT;Zx{azzp2?F$+!r#k!ofYQY?*>k+R2EHFW0RtS*?Y5a35TxCaJax z&<({>>sD(^e%k1ifKdVPdy7Y>S@ zP0hLp&~F(#k<%sLNfdT9U~WW%w1s{NQ~6AbKSv^P6ZqU5Uv!Z@)5A}ucA^VWXB}jw?}P*mWG*8sN3_ z%Pvspl^I*>Qa1b$)&rG(C0xVgrgu##5Ht@N7=EMh=w>?*g6XuiMT! ztu;Ek&@8nb(92V8kZwLxcPz!7h@CqGgMZj(&U`-KS#{2c=O!cG}a@a4`MZyDQ6>Z#67fSFB%H!ksaTyySl%d&yLBpL9$M`WkDY~3z%o`%v?ID zkrB+A^pdA_;YO>;CV~Op8U^8!C%b^?`SqiO*0(23vJlGyFWd6~B_6N#5l`Q{72g4% zgM23^Bh&BcPYih^d>!Ku#4vkq9s#WyMZ1zsTRfxv%V?MlAF(@inYmgJl~e z#qX{Z_oWw-jdF4~W0rp(pG}w|F<*K{_2i91*wide60gDCUB5EcWOXR7d`r`qHFr~7 z)ZVn1>7>cp$Qa4oFUED)Ny5x(=WA2V=pqYV#ZD^hW!-!;a!)|E2sc53GZ((8gs8n~ z>Kqy4&Rzd#iM7Mo3@&?VJ4S_l#=v}VX_J(It*b9)5|%`pm0HKfs;u&xM#}tHM0hK^ zAmE7e=aRr1XO!;OW_UyLlPvM;@0U&NCH` zm+a1>$~P(n4S!Eyj7}7oqJ<>5k2Ae$4H$)f#CX@UBv(C|!$sFLDTWr6~XrF#Ej1icXFgpt#QN9tK{@Kq< z@YQF6z>9XL-!HC)h(Sx_zZwOsBgJY-&FHw92fbGOwzAl^^7C7D7n|Ymoq--*3iXj8 z`WRETerRQUoY*yrv3$}sqtnG@LFVtJUG6)=?w`0L%)yI$$WYx&!L~n?iKz^--+@bW z%r#a^j)ww{wzv&E0=D5`oK5FW@G1O$O9UtE`{KGj?EX-OO(?A4i}}4c0Cwmc!xWop zn1ah!ErWe0K6t0;Kx$~9(vE#Ku?M;PEehO?J>Sv&HOBwfSCK1YD3c0+N5wC~H)9v^ z^cNL|#a^!A4yutxLt2GTwssyeyl!D?-wzUNYC*s6!TTGn1wdMbo15#tqkFaRK(m4m zw&|+3oPbzEfh0;Udl1Y3r+t$wu zEJv+n7DpfLFTCN~pBBK4KG}5STf!DS5_K;3RK%>;UmB{yTNtKBp+^Y38ljG|Fb}Vi1_mQE7RQ0r@NAn}^pe(NzpM0cWdLW)*YbD%Y+c&5%7Rd6!Y0 zrD(gNQ+dUYRp}I&eY*{mXV;f09X-9aC8aZ!VZmQXA93mKskff&e5_`3w_l_)Xe2#s zjI%iN{=?}8`>GiE_@}c>@y{{>IAcZ)WNXEFk(&-`$sS?Punpt;{Aq!y8Z%TSi&=G5)YRGWl)Os3@K zZKN}tO%Z%Fw{xfk-N+8tMCU4%#QX?0!`&g8#c$G4+L!Uf!7S3K7K-ONtLM5S~^;eM#KwC z!>uRdR@zv=@9kV?mMH$>k3DF=(|rU1_{(ybC7y}~R*AxnZf)IvZQ8TWHx3OFfPvAe z{`iI*m$XA#WLZgtPdS3m42uW9o948D@<2svSH}tkL5{kg7PKy>=Vsrz+#)i!1z@c- zr>JZkexqzMmj2|D-R2WaXJY6%pdLp)f;b_NM4HEB&!l1VFwy39f*FuB{O zDfeJ64-X!1q*Uv5WKv+{dCii0tWx`GkPgRWAq$r-F;i{+-HmWM<_|j}xM?)nudtOx z<&X?s*V=ND(S6$HHJ&HUwjWM9%SgzIbPtk;qt4Jf+}}@wp7fXgpJ34&`V3$MYeQxOOdH5(6S&YZUXbI{26o&t=4 z*m$UBiMahn?Kg>UMmE0H$!pD;9QuNrG|UMRY(0PMliJ?4Q`zf7*BlyOvW#EQI6YUh z=z!6Njanya_dxquB+q55>#@*p@sQg{qJ(MaFU@_eV7910lr*&n6otrf5oXxN|sBZ6Q6P0{`z*GB;a#tu7?KhK7F z>^=0dBB1$>1SCN^}GIL}ru+|c?kmIASen=Om6AABVe$|JcMW+;#iMDM$< zy|ZL57QdLV8|yoNUF={L@pAF3;;%2MGqzg{$T3l%2CEWa6DsYBX{__@SGRvGZ8#$d zGY=7P^A`Qryj99x;dxwcuATB1N|oM&P8hBR?|vZXyTBoe?!3U*_f{d=b|%V{dVQg? zF`WYxUXNPPek!%!3l^Or0BI9rqp02`T#!*P*iNZwZv&8Zc%~$*#OV0BYqFByDx6-@ zL)~vUsRrSy#^ZI#@yYSwjiH`X4a-f|^JrH%>P3P6lWPnly9=V@c5!dJk66I2qlVxw z*Y5m?xi-&wm02wDF77DO_LL$+qW?1t>Hl)tl66NeU;PuF@Nz%NwB|l7lYy4yb;#f% zRiGyI@x8F(SLFX<*G7do`j!)UAJMXXXa(@T@UM&dtTk_F>KYJ58$D@mqWV*R{MioD zcia5Tw|pbXFQt~_*bqZb+kNgE#Z|q%&$xGx3b5XMt?`W^g+=S5FnX)* z;_Ug-v_2pr|APtkqsC|td40O~B~i_$p2(<`bfJCZb#v$1;8yRW_7{tPxb54e7%~R2 z`zoYU=^|lUbCxQ0GEOixtjs6>a*$NmUsSN}xhC>f*%~3~GBhGQ>nQ?GXHtyHAvoK^ zS}H!c#U4TWfz|U_Lnsx8wEJX?VJuot+O8|CKaTBVH(E2V^*TcUMbik83@+;(4<8D# zQ6qggK=CyS)b5D6%1^-v(LcgqO`|FF=f60r<#)hMo_=|JE zVX3&fWO$qQ75DP^Q2&bL{^~ZV4eDCtBtowdbgyncn*RD%-3d!R%Q$EeZA&_5|B6Qy zpU}&}D+o8xc!Tx>89|(PW;W*9{KBwnFsQ>(a8*QV`W?ZL_A~BVBU^QaD$($ZWpZjA!~~cj0{-C` zxio9(apRps-$nix+4Ft)8x4mhCMH7pCa}8dE0OrfF?BG5U*M{Wj>( z$Fq&5i3GlG&FSq{QIGXH8QWA*-0$54WeKWq)xu4gwn1M*9>67vr9$LGS3S&=(N3^1 z*Wdkg%3Qlr_B-L)63f5k_$H#>=R6q0_Ck0@{^8ddciB^o!u3q>Hx`=j{4mypaA+M4 z`G~*p2@aRkdEydG5rO~by<;$(5{lKuF4aX?H>?nBl!@c0rM^0>C z-1_Q|xmz}ix!G-ke?I@`1>o3ms<40#-O&?oO351t?lQUZ>)P&D_!qaUn-9%%Mk52@ zXHJ5m$K{4Kh<<&@MGWr;Is}G3yY7!CKk)6&6!|f#G`xiT%n_)<2Qml1x7-7v(U|jT z>2F%z>4-(xKJI_N=`0p6Nd4H}$C)fH{`>8sv!uxS2~f4|fA&p$?B%wVt>BlsY9oaq%{Bsw3FBRm{ zN);ZqZuLa^Z)a>)yA)egc1Rj|_3FG0t$9we2$DDufSt?9-&=W&dt|>kHp{T?1=0=ul?y{)5e}yKktAtaWa}8 zW_>KVBcnFzDCRLCL#%SNSdGqWzYJRaQTP@fTS6B}(i^bH+Rs{U#uTg&k*Z|EGC zu4)*bOC-l{3;^b$=Q0XmFvHLSco##L#?ZzgNXX2J4Ao*mh8f&%MOSWeWdC zOwpu0oH?%*uFM(X3j@3r6n@l>G>qB3ib1u3-CY8>894}+q%w;aehF_bv>m)tw0j*> z{Sp5A^usQFc#g?i!pah;`hXlky=4Bk7^z+{>#<18bK|g4?>l+lUkpjSFlWvz&DdMU z>s0N9bjYA=Mt)I>h#2~uEYd)dHX-HYcII12^Vd2~mkBq~A?+}_nbUqVOX_v3Yryxb zra!L>p*)wiYLKE4U-bT+*{QiyiS$HvkmIFJ;qE$rjq%>L$BUh=eK#;M{WccxNaDkI zl#7Dv75tS&D^nm#VNt_b8nL_y8zFCgcr)?72;KyF}(tlSV&k@MaU0~{FEMS4}QIq8H z&q0YTjaJpJBKF*hofY{d_`Pa}v@gO6-y_1CN0MhotkB;K|FBqrN9ZftOZ;L$G99y% z*fM>h*mJ%i=>OVZOh&PK{w=d?GzM4PVjK;dh+bW>yeytWVC+|(5hcl+Dh_-0TVkPd zJ8N0)nH+a8xlKbCE4}I60vFvdN&fFN2i`SZuyz+tu3PT)pQ`OeXM^e2)u7k~dy;-P z<|w5s1G0r+G^7>Q-({bS+o#{JI%_}pH*)R57c|eE^sZou%2w#fYrC75r#ArkAmXPt( z9X#-Z&UbiZab|=fbBW@Q_?WCqxnCmr8e9xS9MjhZQXorz=ZeLY2$uDo8&Xm05QBK~ z;Y4KFOlMYVsvS~10502SJ_C)9dGEAFSw5*jAcX6MAl3~*cHxr**EqR9A^CT7(Dyzg zti}5`GibRRZR4)JJE_~VkbhvI*}tLtT$W1NXL0!TdBj389hpHlF%LQ3VIUJs6NJvw zX{K(4LECIy(Y29}T)tT7Q=noSO;esCc zwSj+KLA{_=voIp7+<5%UA~kw_lHx9Ni9n-Z<>^_<0Gb6Q?DsgD@~)4Nbp?e2-|N&+HMo{}g)S!CKzrvidCsEq{ z%w4%z=f;Fh|0U8*Pb9x&GYbFp@89&jFRQufj-UDq6^?{>{Y=owL}>_o5MB{2qz46f zyd0OblL5fPEG1v)Tq=C5WJT&-R}A7}p5f;h^10fAcOrA4#1jOOaO*EgZNXQ5PAt4V zf9X3v?DNkLw8)QaGJ#%5rflc!ZLoiB_cW&Y}aQLEhBQ z#nv92mE6&3oPT&-cKs#^pWQ7VBoEGlUOR=<LQTK~%b?l7U3&g{+HrFr4ew$V zhYK_|J%2Z_=|~Gd<(tcArB(ue?FET+S%U>%=0+NGa)4Bs!kxrP8htzcYUxX|_c->R z>np}XW4`Taz+2o5BAv@9Un6LC!o{&>&T(FFL{O_1koCEU(#J`;!J=W?^|3qP* z|G!7fbGWP0!;Q~dijgi9$e^(-LTjO@9b4$2f!BEF%&x^K6gBeUm$PD=hc59ickdb6$9{WrK5s`s56D6(ZaM+RasBcEfSb{2kG7A2>Ntwd_^rZM*{z!IDB>XeF#O1TvHVSK>;!X z!q}7%D?c>roofGQ!$xj|^li8C_GGL`4QRd;zYoL!iZPN^#Q9Tv{!~^x&V+1I0i8>! z`0Yx89QaDd=)?dpOjc%yczx1FQXvP&wazK6RPy*9h=85E=(tLNsA_G^9ae>;IzKrc zAx;LPvqPDC#?6`}HE3B#>!a4%&8UK-f_qA%lipI9H%!2iM}<s~{d^ zbe8A}RBzK;SMST%5UnE4ol_p0xkTds%H%wKp0pITqlv4yZgeNk!K$Uvkd9VNDk@DievMXUq8YNZI!vInm_B&$+B2TZ2j{v;Hm0|Fz<0E-3M? zaAkqer_K>`mvH3Rxh)*t!|0pEwO`uHqe7ZHS=ihUnyw2A!AlF2Y{} zZPW>1Qb52ZaAuRpo=>b} zD9Ep#?AW<;`BT6ZE4@j3vG?V>@GmpE|50Ih)M?5i0R?2#?h_$=T-FGCVynvT9qD3x zcHjRU6SKe?P?6kUon`y4(8Wi!j6QzI7k}5+qr&K;WZIs*#-;1$3G&}@G(=P6u-fdo z`txig&G8%f>E>%*@G+6h)ID+L;N>>R{EGc?CQBp>nb5~zH|gf5)WI@5nHpVt$%uoZ&pW?N$xwATcnZ2wB17fY1EU0UN^|8qmhi$+rN= zu|jxVYB`#Ro`LopudMZF#lsl`Kd^5xwiLUrQ?4He?tXym{m%x2Z-_)f#%{m=Jrz+4 zhOb)i-j~$%WjpNQwERA?P-O3dqL=L=>Hz0_Oi%Y=Totbg6Tx1l3g0c}HtT|r&h8>~ z6!*Glq(0T5>=JcXajkeH>chtc0-$K?+Uj*H;jo-Ut*Mm)xCu-OKcd3-%AS;MerYM2awA{RLfToTBOs+x2y{|H%O(O6B@FkTFFQv4fpEOqwXBK)iubw1+A zjPO(i5^y{5fz=MZ%eZkBvb&-!qRmPb5MVEpAUk&a1C-Qa!{`fx(XEmb<6pDF_vTJ z5z#O;{N#qY7)acn!9*g(o)IepGDDLaH2BGlv*F4`1Y-5N0TqQ} zFYKjY_;1Mf)9URan;`dx)fCg#xTi24v<#?K5}-r7ZB&Bns|gg974Xp&B4-7ARs%;6!6ye6#|@C-NE_|RpI|*y`r`Q2{z788s1~JoXVinheGyi{*!p-|8>sLH<38=!dBROBnJLxQ5&#Y$uwY z7rAppT+(s=!`W}mQ#dYd<6mKpxcPBRdVPKTh}d>13)uSlN=6{pr1mYVx z(0=f0i+x6l!)>RM zo-Xo;jZt2FVHb&}PlhW4OGgoCnPj*MKyuL@dMQc;_;Dn7cO0YCzsQXYz$S@UDhxJN ziSZy4awE|G_}{ZNkMW;OcGkZvn5lVtx7XRhK{<@wjY)Dx#CYj>Pj~!v1RZXHF5uB} zb^t+P&MJ?jVz3m|aILD)9#ISHMGX0q@%to??Os~){qXy!9J<4B zRp8grYxFiRGB*LkZn{(n)>5`(^S`eSW{h)ZN3O$z1-9;RbSzbq9i)_EIVREq%BJd5 z(S%A8t2)XR?B%pm3hBRg3nldIt1p`AI77P*|9J@5`--EOkSW|qrc6Q>%}VAWYog6k zqZ(6zyBDk&tkddrBt#koC#k8^0>fl2vbwT@*qXAFHolHa>9&zcETHt|p94y`&q#jR zl8804qqL@;BjN#qs}wgVD=QKncyM@H_Y4xGx!${!fq91z{9U|6*_?7qEGs;xB%%9P$fUozCSDrj?{;bBIC}tdK~& z7rLBg2pEhE$GB#tn>Tf&zD%UPr+BN``Ad(1)$&HBWJu{Ld7i#FV>u69$p*bcE~xoP z3h6myw?p#GS|j8#KympEq56-9Sar89v5jbv;2?)%on5onFP8|i^s`B!QAE;+{X7E8 z%bo)E6m)fZx*2pq(sM)XO)3b#*@V#r)CRqg?9t23L6INu;bM&FC8k0GVPEW9pa~Er z6=UUi{~E{0d@T84=rBhr!1VoF9Od^$oJm}brLd?NVQ8cuX_7B8oO^b9;`{k$Jw$CH zqmmmz1vxNaQ#!Xp@~f1j9p}2(?vd4@wFI?xAd$WKh_+x4+Kd;u2bqF97-~0ZiNn(V z9x;n8W0YjIg+fJ-Bfm7NfrRa8Q_^d~EHn?SC8tp(u{0T@uXg;N{WU-OidFPktMsZn!CTgIgAyXymQ8~9s zLfcT}4W3mXH^+4osLcAkuL$knpl7L1Zyn149LSB&)`Yj$EV^E0N6LAhZJqJ~(!UHz z9VQn})H;$Cx!$#)^b1P#oEGPRW}MAB`Mfj7KoidU^vhxOmYD;cb?R7wtIiB?V+NN( zP5#7IQTVtMZI((sx4xk-au_i)AUJH3;mRgg#>*`v%cj)6xQn4r2mlM|1=!W- zPhsD7Q4Ws|Gl4t57Ev%1TL!eWba$-BV)(STw7aA1v9>RVYFe8Re4#d;3yn8}F3M=Q zm6X(8PJA&vqxPNi^y?7Qdy%?+1+~%}-{2GihN*>!^b4h_3ee&#Q4c$_F`&)*OeX|) zT%UC;BAE61aZH#43CagcKC8!hv^QA-bj6>0ySmzR)YZ+*l;1)(W{HwHZP)29y$@dlpCg-bgk+SD4Ij@tx4`cp ziFvn>46K_-&tRd8`ZTq)r^P~%IJ47YeBNN9ka#FqFqK5qB|`NYy!>Ekpu@t~rXOv_ zr~M7+OY2`$1N77Qo%J7xNvW{fceygY)vdCY2pT6U5M!;o9U{yWHN8u&X$MI72`oZa z13%BZE*9Z@LA558T9Q5Wo)@OdUg9%XGx=77+c2vmu}%vLAC%~3Ydk!Fw{S-X2a;3B za}g$d^{r`sWOZB^37ZE;tU87uz~9?9fTp=VvwMz&2Dp8Fa5{gF^G6z0(W)+2qva#S z^ZNRsw)?t||4W#~q`XM-BpB;S_ytl`L~&ZwL>!Dz&z|ezfw`nL_ffS`oqE#LFS&p0 z!}Rq^uRoA_H5hasF%9<_!C5Wl181=z=4u$`>q*R^QV3zL3l!{#vzfj<5;|W;D>f?F z4A=;If^z$s6goE&`XG%6J%QmpD5LJafFi%v6EV!YJ@UlVC_^oqZX*5yZlqn*KiHpkjBDtcWzXLt_` zmtB4~2Y&h!q$rV)#FH(OlDb-=)H-mu{%LD8JDWGXt&imUU;|>b>yG)g)M5RZfDsl^ zxXq%#Ep=Wmb0u;wi)j(pk*`gL-h%AjG0hL2h`w%n4htD#t$RPP0$UxB z5}lR<5t;bHNlcwCaTzwXh)qUCh$OyTMqZ=pLScQa*(F9s3-|V=_OW9;Qt>8(s|jzq zqxXjny6<|u8I-(m(vOSaI6<#o#9R}p^wKYq!}Xe-Eh#yND->!=>9Uxl^Xlc_lwWNE z-r^Wfx(?Y_!*#L8=T1pB7=hT z!IDxQ#TGasoOBVmaOG~KujqY5j5gcOQ3igOQY6He#AZ6tdP;zTrcLqvuu4TWAc?;} zI{e_MydUNo#O_R!co~%fzW|m{B!WfA)ni-Q#m>W&EqIr}9glf<3phY78x95?`WqRU zT^1DCIm{wgY73&;S6on8DEyQ1t_H1|zJ4lF!f&Jw%L=%}L(1*NSiZ;oyVPwmA>qw# zsp{a<4WWWgj~lDskRsv+l8oj z|DiRXu~dfUU~i={6`7|!KPgx)JC!t7N~Gz@d+Eilms}vNnQX5?R_)dJH!1IjUW{M= zWHc1A`3=5du@ggb86Q#Nq>8iQRFT>w6_)^ts|Yd&zqD}WzQyo@5-J0C9Q!7};-IW{ zbK{#a^2bmL>4pko7!F3I-O0EjK^jf!#)z-N%BnAYImzkj^f-+;={~gWG--^yYn83L zN?O;JD`hp}?tdDw--vTPMKAUPtu8r?yvQL%=WZZ@(pHVGo*uW3QA&Y7!cL(_yGZ5~ z^?8vZ@L;Su&9@~=((fg*UaZ{<2{g$X;+TZ~Vs9{28ROgyE3;zwyyxtT^uqQ-rG(iM zNsv^=mjC=>3+fK?;aA|#?1aFnBy?buO@oz4P~#Vm015|l8qacPd&av%H2ZE9tEz;W zJoyY#S?M(+cjQaZ6O`K*P_fzL8S|3W!9M{LrB?&Qgnr~SI=jBL?pR0UaDS$?PvaW~qZmgDpFjPXfy3NLtYq=ciVi0p^GmSqeT za5nnFu3#w8-*AuueZ7KcM48w~JXpZT|LjX-{Dfc}qj3?~-&&1FL$s$KDsLr3YBL9m zOIbNMN|x5rUN#JEbzN#zCnxFV_$)wH2YKU+cFXu!pKu6Q)>F98zE=N-hV4=cxc1Pm za!akJdT*f>UpBB$Sa@hljO^aR+K2A^9iS|)ROy+Yps^>_4@un{&MSl3LUWjn!x=qu z2h^<>n!;GmjAxvmZcot#GUu0B^i&A=Vb~AKghpx%U#bW1=t2eb{5fQ_C4D4$qSTe`V!F8VcJx9!iC+K!lO}i zv1ffwm-c)%;4fbOg!Ho%vVz!Z22@FTjv<#P=iD21}vgBc$;q?fAPPT1aI;<3H&L}A$tFZ zToZa68;jC2=5FXv?M8ln)00~FH${z@ODJ-mBw5AP@Y(aC;)CPWyTH9NPSMt{!V(v& z%W0o$Jay{SP4(6jGV!4z|5Lm#4=r;l<%&#j=Pvtfs^W}0c6*OYto%|TjIge;75O4IgQX4oYGT% z^dEg#L@lBqHJur^96-vN2riMHI8P&|K^96O0gt9fz95TXto&T+)fVtbkf!t&gKpSA zV-i7h%}`4I0{q=PUQ~B*h(wv-PXD^stC{#bC*hB{OPMF9W;87@YIbX^4IRi!sQ6-+ zg~=$(7FSbl`CN>3B{b*f*XV(}&)A%~PbW=Uk>i?SA`{_n;@Hc~a&iHo*yGV%_GVWs z|5YiwqbpumFEz$Dh%mT%Y5Y(|Ur`?)^TSBe>tn4V#Ti!-MURXQYCOBXEj3;J+!1zZ z^?*xvIP7_V-1Jj@dWQ24T3VJ)+ZkjOC)ri)Jc8ur_bFMc&JHBrODu+VlXMjc= zDXZ%#bF=yDCa#tzxp^%p19Hx}rgtD?BBH1XM0mQLGC$#)7w8b+=srgy)RhpbTZ+Xl zgc4m~=^(A6PJxFp@sSv;V3eA4e$X&zA4V$L=V^aO7q7AprQ&lX*;VKmL;2JJ)*-p_ zay4%~QNt+ZCE>osk{qb^=|5o)B`~KLrs3cZdXaSSYDeD7^^~|C`q$-lI(*0;8fI?b zr6FKE0}Msl>2Ho^7hQa`4wY~4nG*H4EiLKn`$r4YY@R9iCw(H`cB&klL>vq{uU)z~ zpQv_gLQ;oNS|fU`RiZd(pmHKd!KNERjr;DVpK9I^ZmhCgeg}40j74hw4h2f9tVPBg{A5G*L_djY=)owdg z!inEnqTMTAqkrOpy$m>j1oREaJIIdabg3DDifaO2Wt_tB7_RGT$*2c7k0I}O=0-|9 zlS7miUBw>F&p1UJ!EBnkCjMDb;YG$PF>RL$B+25ToTJObUUnC@6jTWIGuoel1h`m& z_K)A-Yj(#%8&)QgtiS9<+X zPOp#DgX^?1e>v_zF&p+t`?Mc|dvX9tGL>u+ij%oUs~%YotC$73;T0x76qY}7sJX7bqzD*@=|f~Hrdk>mX6US9AoHipWp0V&Sj0{2DELiL?} zXBj?h9>O&hvQ@~G1q?QkpC`dz;}cqdo)zk2ZU=R^iQjXY)9_Hs08fkWbecmJu0<%- z?vM%Fv2t1NRrg;a+5OZVbfm6JdlEOk5=dRMw3m+04p{;uui+snluXR%gf7w?4O6yb ze)CE&{UoZ1^bvR!NIJ&&C^k&t*|X@AxRa``PuF8um7__MrUBEw-SxkTYO2=nWPyZtBu>p6{oVuYHTsGeNEW#T)>M ztoXFOTd9tkdPn1_U@Iv)o6|K6W_r8qM z=YZ!YVnf$ORi6id2%azSy`*63Lbo%F`QtX0M;;I|l#8^#pw;(;d&RC!Y{A7lv!?96 zL{r(jSYlVQ61E|&sh%L!?)Q`8rGb+%bd)uPc@qoC2sTv6Xpc=`?^+wX1qA+k`0Ve`*x^lm@{j77Q@S))UsKrRhg_BJP+SjDCpEm%5<$$$HwwQoOf$T(km+pylj^UG zZ0yyu#BdAC?;_0&WtlGhpHxCMn+9o@Dnmx;uF`!o=^vkJ zTQ3d0zTkH6kGurL;1rW3pT$l_HVz~q*^%UXfk}#Y#+DpupB;Y}kBv{GqVxttbgKEx zK19YWq&@wmCiFle$b#_wz^{s*CYe3ewPKAh`tD8HdETIR=x5 zL|8FjfLrFBz~91Cs4h}0v&harIsDzvpyvafk$QiXmB+&%2|W;|wn~TiYZsIw!|&X6 z+b76wgl(>IT)a>uS7DfPX|TpjPyA|Ym#kv){l8`c5quRE1{Jis3waO*8ZcO1Bmn;A=f2YVkZsAmv=QBwzd1CNRmsP&LYuLqg0eCi@3D2}fX6*Pqnp zh8IpIVL7UntIA7728WkZ_L=ON*u4{yPU+)Q-Tr;f?QqmW_qGW!Ie`(f5Y~IZr^_pl)MpIDBV)i znn04ED8nYGjC`Ix81}R(;k;4AL|sa%4SkfciNkk+F(Pe(wk5CKSZ>zb#+UV(4sUV7 z3E{Gx##znJ!!+k%ZkZjl^Ug~Q{YbVSM_*?Mp{y%Crbi%qV{DTleF$)IC5UfffnD!V zL+FkpSfq?Gw(#>}-5;ub9KjGEY#lZOyvF@L_Adh2M@oMb55PYaP{0RA3!nKJM%Ha2 z$#^_$A`%Z#o$>*kNH1^Wr6ks1Q4=3piHr_k)JX9KCk+G05jHGbLLoWm>D68yljX_e z6dEN#Ta=LV3(0b~>Z`jK+mH<5@;$Hz6}DBzRWAMRUtp7iPuP5Q=JN0zvtj{ug(Zd} z>png5#6ht=Bm|NYVGX9NY8@u`^Kgp$i$YlRT_say0>b+aHnyiy+mDoQ42qOVH|O^l z?M#X$Q6|@S8a}y?TfXksI#zmp8-RF(VknLfmyjsm%+>x5Yk_AOL4N-;ni|&Y!8#%or)vqmrZvZhEZ#ayZ*1ntd1Vi0o&msSMC2n?Ng83-C)obxtVaI%? zgQKYDo#ziN=X&@gs(x8#y&9FU9ARtI|P2-Gn{M51VohG8bjU^6Z&jlM?-r3ig7%O%?+(MG z9CJA=U$$dpUh7rGJ?KLvCzf^OVT!ezpsoJ(QbKA3Oba%;_ezY*{=?+M7IYQE_(tw1=um+3AnBY$S3dg(S-EKa&-q6l2R`J#1Oo>oZ z@e1vIL*2?6{Tb`{p#3G!on3i0o;u3MesMz)qLXw!m|XE&Xo|*P_@&eSaRJJ5P+dD3 zo`@#`Sl@@{lZB5?#bnk9me98fuof35lOMz+`e%|Gt{Nq@bRFNEM7zL2W%C%xahgM4 z=?l{;NrpAwCZhkRuI;G!|E_80F)=&ThVsH?qKqk}MS)Q7wHj%I-NF)fs{ccXKm`CPB#xw;y#$c^gdQ1P`4hc3uZxyVDrDIK05BSPD6 z9n(W4Lbi20jH;HM=fo~Soa#Bu%x#uB7_)dxq9Nsl_MdvCo`~#2a@h32(Z!h2BeTCX zLO+h(d5J0xir3+ffLl(g_XA6*^v!)V-Quy6DeLmU*y~EY0cX@R91Cuo-g|EbOmbt?^abGDcy?mh6>|vX+ zgPQiCBczN-auW_=Y)%-(Qw^t*CzUrkzT66MK}GRH~j#K&&YajS)?OBh=V>Frve#b!TmoFGv2kTR8vI{=;Hl z*LhS6QkZIH1^{#BLG{OPB5~hd`J)a++EWcOsx%xsy~Jf|882t@R;zSdCH0hFTJE0h zy&NuL!rh=**8a{N7xJ=Bv%J-0Jvds_MQGQ~q=fFo-+z6y7^+5#^ZK#u%>t_WvQo$r zeL+gKy8};kMO_myG4lD&n0NUdj>QL|e;o^dF$HYxe6I}XAU`RW4(oH(smu7pY$tYg zn0|!y0b^-Rxg9IljgmXl?v~~jmLngKV-Xc~KXq0VDC!7u|F2HqG-@{vpNGh^@@J$dN7N=O$5vK}{rln)xgytq`{pM1?GaO)@q?VL(Pum({d zF(4^!86!H!GFi#^Y7bwJdfvF7IE{X_GpGhgfm(icnBJkQYRtR}Oet!>qARyh|9k!N z;~?zCg`M5+!{4{zj&rn!?4_Te5>-HO#t~2ljXt(w7EkEamX1>nQMJrhBagJ89?R^a z351&Z23M!2FPYxe=uhU~GasR4<>_;>Mk`v|k{HQ5M_A62l#@Mfv0#D+^o7R%THS3` z^pStm)dWSYPVOJ5Xlv_cOHWO|l|~Uqd@y_hwXFJ5n1DfrY>G&tM8Z#H7NPWG4vhDQ zv1SUjCo@Ya&5VYz{i>RXpzyaA@S!+`30vjQJ?*E^`IhtyT5qW0J zqCkclE)j6Fze{y2gisH3dpTCw@NjU2`kY$-QbU*f21@RDmc;CIu|umL)^&|8MS_LI zCJOERIuT6bQP$jtbOFg09UsMBisANr{k&jlV`3FJM1k64%!=9+G%P%N4MX4Zxo>^% zNDekXy8YK5Qc`k(nv&Ly&YlE7A6UNWQ=$&76FwYB0W!#FM9jH2XsK7cC0|*WgFebvghNEbZDMzznHT>!!KViPL zRyAH;6h_Moq5-wmdJI(6sjgXNVW}x*y$T|w%^SP!OfN?gU=-PGZ`g?4#FCn5kW6$!yyMqH@-+ zFT|yTW7RG21SgAnN_(i2LBdj!v?a5VIy2VMjN3RO|HgO>Ir5_5XEKL#S!kqkU(yU9b7d(Hya zY-U-9^b@Qr>Nd^rLC9xCkZig2!p_toyJz2Fb!s<0o_{`V!a2P|IX*h17=XM^KHVdd z36mo;GEY75v&l}`OTOGRSv6={AMZjyJ8ze6LaU@Wx#D1^d5=Z>Zk_ba+}-5ax>uGX zJ~btehj`YX|C@D}Lov5HI~N5-60AA6zL9C2nj#lC7F?}NX8{IvUu2lV056_gA=lTChBgmSRaP?62;M%O2V#sx z2g09WJ@Dl!4R#coh?}A0F?26LUi#o&E~#Z)d9lb!_p5Sx(uh%{vITw4nww@*KTPA= z1mbE+Kd1(5U>50unKXz9F9V@uCB$GNs>|qiaeeS!a7nwAp5VMY9gPXt|M2)_w2Pb14>4p0NBj}Cn)ajz)b&BGfq8i{0T%A2&gB~y4_ zdloeKXRR$8TuV^3u23!sE0S4>GV~SH6tjH$K6)y@$rahw`IEWddgza{&_IO{BDL#u zaFt5ac2$@43tZm=pnf3kct2$=<&ryA+{y}c!u*YmE7y9WLP!=Lx}E4RZ8_p@du=?{ zc~gh=c@~j7UF}dgE9)|jS#*{+`It*-qqwG_`De!0e`PbwbsmueA)ASC`y3+Cs1d^2 zOBQKq%q|d?G23JxVuN-| zF*#4%A)-54M{fb5uAaK%h;Q;~Euju%?oJ%)?PyLvcfGe0O2ecVoBY$)x4djCh0j9K z0gk$3 zpDg=Qo2}T6Kl2jCXVNBWA^m~M=wT4;@0?=a^ta({Gd{J0(-4(^E=^&hgJ?t7ip@#I zpA^5sI*>m~Wsdsq*U=TOu711u2hC@lqQ-YE_P&$tnmwCW*Hihfe5w94Rqfl#cR>gx z%LoZd$x)D==^(>|gFDYQHMN}(BOi-3M++pnB_h!$44(n&zJ$v+;cCqgVRS7h(jxn7 zc)T@KLpk0EdV?81w7nAR^*Y%h+907!75}SLsx>lWL|Es@A z*jJlIlNUvPsDl?dgudu}&=fpZWRBj!hDq-D0C!N&p0XZ$-GN9Kv3LTC=EU^N924SE zAGo3p)LzlDD^vSyRV=u-M9p5?;+((0XZr%*y(V4ia6S`HR5PD2c?z0-WQD^}2?BilqWYbOg$i~Ai1$-PP0eLkunEjlEmm8N7XuZg8ko3VUcaW;E0zjf z4?bYwzXVa;C!!)D4<>4+QU5guzQyX;e2*!+wySa`JoSZyr&W`XrvtG{*@ITWlQzaD z7g~i$uH8s~SU**Ak0skGNdE_^ucX6@e^L2bu5Vwya8UaYE<>{lPE32Yq_>vwvMXH# zf8>Z*v3t(Vn%~8A62w6k{MY3MNFHov`Ya*Y$x7!3od za(TytSUvtyFZp!sUZ_)rZ!fXfL5u)(*P&iht^U6q_jw5Mv+`EqOM+y0WTnt>)BUot zPk<6b7lvFpJAfC-dBsK9PSVYO&j;&0-pgh(@Bi-UT+mmiFVVmKQ?K1vLwj#j!qdM$ z*-P5*&v7=Tzq)V!-gTjMziS&=%u~ks7k;nn=*?#H@Q+6d_G9Yk-`P%yAoru0(v6Mk zqG<)W3&`KEZHnKFem|}FtT->#&^|Hbec{^Y5ZsjQmsh-xrIJ1X{Tpd|NSg%Yk3YYg z%vlV8_dlI6u%hD3J}>sO{`qTK;MY=nvL>PR>YtMfC%CsKPdRXpurgxvV<2Lf z>7gHYQ#3x}uVc(oz(?4xXcTvF*vjWCzv4zt-WAq4GVRR)_dv`AC_zE3xLED6!4elN zrR4V`E^fp~BaqhwnQWs5@A`vQ>tZ zYwV@QKVd(a>M|}B3X({y3F5R^nP(V(3IFDD>R{sDOXv2%5a_)c4vF14zX@l=U zzUasHmy$pl(ephCMehfhTQajL@+=rVtFz8w8+)K|56i-M|3Lq_o}6a31bGK^?8`*c z(+3_YkpQFDqWRr+@nm**b z#E1KVSqxMGT2`5isscu@DA8&>*KdC(;Mhj-!O^cxn3hvk2#1D&=B^Kmiyu~hU@+MW zC>J_Wm!X{@#g%=~{WSwN{Cs$~VbRjx?=+=Av;VzH%TU42QTfj+^v6iu?Zti3VLYeC z`RH8nPz#%d?F|6Uzy>7N1l~dtE>|e6eeH5bzV)5d0{q-bAsL#mAB%Ozs|QxhNW!5; zBBzx{!Th`V-NO>Xr2@aR7U{lR&be6NDE8nEv#3dL-2KX5YppidR{;d`S;+}CBwsSg zapu$HbyTP_R6Ql^UKDI3dR4?cq=jnlvrv1OMr_HKvvGVc8(}Cst^Jd&-?Pw~)Sl6M zB5p*-@mN5S{gK4>H~7z65M1Tcru_i*?RU-_z>&!nI9DhYL(-<1bbQ@|e?IY&0VR1& zR@>M_G`*{&E*2I4aq=h2z1+r&uI}?7G$!EobWYCr>r?RYrgXR9OIs3)KFatXE?95e z!L$jp==boc2RYd;0))hj#q(q$(~+kuas3!=9yBUJs#*X2pSj-?(~ka?0h{qDII?SG&;CAt zIMHuwub_~#wvEB2s2>K;xu>X4oNl9F>Pz7&oXFcZXmB@Tp1qq>UGzTc^Zy5<`)K|7 z!}0C@a_4{EW%8aLxO2|l(~I3rS5gLK zV$pC&FpI*_4~-r9W8VS991f5o(MoUzNE`J&m2ub91C=s#o-|+j1m7B;u-ZdGgbaMA zR2^!`X3o`Ix^z#^kNFOTc_k-v(VVC8Sa)Bsk9+shg}wSn8e+*~rC0S;*6i>9S8*Is zWcNQBD)LsUV4*kCzIb89KyGgR`ub4udZeY0_$QfHQ>D$}r=@KemwRO8S(iU=R{NZtzMKJ_F{rfkDfm+f-Vy zjRA7Nn;xcw+s#{Zzw0BiE%Oz(A_B*I*{daA9a%g&nED#}ZOa?e%Vsve-KcfpvYYnF zq$s@hk#?gNy}FbM2mIN4;9;lF+ngJZ;(k3{zp`imf4>qFpa3WY?=Tp%UDe&fgdjt{ z%6u^>*R5Ps|J&}n=b7Uu&2!S=UnxjNJ8@a^#tF{o%TJ4=mAo4HTWEhh9tCBd04Y&% zy(d3&P}-<+z3TO%$ELzCN57DEL(TCn~=e(J4ae8|zR5 z4j#ciK-ZLEOnU1R4-kTd74|w;U{an@10i@c6w6*SmWX1Yh17t>=4#XMD7qwr>Hsv- z4s-zLpmv|lJ2(V=GB3_eqLA=s4ZO_3qTRBUuK5kfKf67wo=xN8oKjL7nGE+TlO)mE z3NLPl5Hblin^@oT6;|r{!;t0%$`dT0C=fn6bzXKB_1WTChEC8^uXiZctaC%6nT)py z8^#+D10Q~}U+$go`JMi67lr6GMuw+KOh?m66kra9PpJjPF zu&X4M?6Z%+8%erA?%;p5UIXEHEeX-^dle_D=7#4u`T4~>=|gzepO!PmpB*84=zil3;zk+Ste_f2DNt$N56CBvog{)8Y`{!W#P2aEE z1HE833`lL6y;(4+NGt#906F6tQ|(`TbZ2VzAqqF4{%}!nUPN_pD|-A@1h%iv*7mnb zL+eWX#&Qg=fe|uNlE8iUkIc5=DY|Gr^0b{v91a&f;VribE10mZ51+sF;`>I*tM+)m zdwaiRAgy})`WU|xl&)aUl&=ZvMt%76Uh?TvKfk_*cp65eXrlTBq!XW2nLdRd z{P`yO&(JeZc?5tf^ zDOab0xHmis`3yOOIC+fAhur@^OuczDRFD5Z{t6|PR4PgKB72g3nTi(KDp^uYNRnl; zWp|@RvLy%U|`JIZO;{K7bz)|<@ z2BELLl3cHC%I`ma7=k6~r{0Ohga`FHAExv=9`x6UJnh7kj?v!Pwdhr)ht-uy3}C2(EXUyk&gWh#9#Vgj@Aq5-6iCf{N6g4 zLLFHudbY}!V`UeT9T@r~0!vkvuP^RYyMr09yd%sXJK8IX4AmU>gV;n-0IE*E;xq3) z1w}KE>*lUGCTim+3rg0meGzIqs#sf2`Sgm-7KH38_SKCr|{adYVn|L3XPF7lb z2@6<})XQyqemDbVCDf-IVZZ>FopI>ipKTAHwN)MAxHS4&bt-ixWp3H}hmC{7Vc8!I zYAc?vBP}l8^tE=sJ{U{-san2Hn5(>KF~nYS`T+7yC_|SH$V7ddxhB+({%9J#$Gx2W zg!sX=@Zg8&2CfNZ_-m*{c5XYBWIP1X86{gsQ?1AYtp}>NIBV zTh!Wd8IneOUDLV;ael>B@8I{3*e}@orE}5ezLqX_k2kdKA=p6gy@Ae)Gb|SqC8^%! z+GXW<;N+6%n0D0hqEqD!9JcLUo(i9b`>irGgxuG9Th`}Y|5v{|&TH(Jaua_^wmuMM z(!yUD*!1~NqH*0-ZMT0@>8sr*UzF|I`4SQrxCZA-T}&jDP+(Cu48Fwg=gt52cS@C8 zG8sjGm)-_R-yBIXX>+r_c=fv2cvuX(=tUz0I|&SGyfu%M^4|WGN-&F3ta`{8M*&F{ z*LU=(^KCu&;NNaA(|XSqhfliLK_7NTn?xy;9aA#IGbd=hmGkL&HEIhG!*dAZUN*5S zugBB`z188|TTK zwp%&p_pM!dRjnPJ>^T!4B)8Kyu&&$ZrWZvw>Eo3@0w*_c|7n{amM3uzX20e5k%0S%w{>-mYZ3X6& z>P;Va^u?2sD@JUzlXMG$E~Lnm65f6ND6R5IkK1PU;o1qXopMW8W(yMfIslK+fq6dimI&+hKG$&6XdfQhq)`Kmr9xK*hN=djF74Ii zP->>!GlC~Jub^l5`Osr5pneDbs&t&5;l<7AcBSlzJFpW+vjM+S4smegS}jdPP6vaY z1P^2Vmku0Cd*_~lFi(4F;1knaXxsE*{w&_&%c6Kh$im0Zfb~EiGQi78h-dszZm z5(OQ=r!b1rPko2CPOt9U%d|%$K0l)*v9>*%O>|CJwR@upR&^gTkl?tvTNjpD7&yF6 zC`0VQKRx*ZxGEAa(|0C!KqJn9pyYBN27HbK1;FlCJFk4P+MO(az+k)Wgp1ejos8g9 z0X-WOCFcTJ#6I+G?QsAKsX}^`wR|m5Vu3rqe|OOQwIK6}Da%gkCBz}(EpKlTrnmA6 zYh=?NPg7DLN7hpO6;V^y;AwuF06*8CZ{|;euUC7&<`sMp30f{bpzId0Pw+#_xQ62W zScxR3LUF*vWgfnSji_3C9c*$<2vu`~v5p1>x)X3SYkghvV9m(ayc{INP}b$o<7@fM zt3L6VlOYKvXi%^daG8)23oE9tjqf3r9h}|(T4XDYf2_0gDbYW1kcI|*f^C8dx^rtjDh>p6*U*d<>oh#Sds=lv&9-&PipPR3 z*qo4KS#lY0_E_SM;>Mpg%OBV~AM~{kPe6;ErH0KQe@-ot>7?aW^q(pR_0JEluny`^ z2itZ^eE9FcJ$$ST=Ne5Cis12>SY1k=Wo!zom(te$<@s0Z?zegvz=y zF#$0E+RnSPWXNjMG(vPCVmaFc zy6ABBYQQHIvf$qd^Yf{%>ZMd-KzLx&#l4n7dVya0lq$zBXmd*j-ij+5<($ZcIX zvPeD@(*Im=*Zv!3?n}pfza_;sxL7=aF6?qQ6nYzTSOr$<9zJx|;FSFBhh70PQY358 zK&UaA!c7;i*@iN=C#X+7JMU%1-aGobG~=d0+P9uppKhtcsInAH>tV$Xlwl3R_lX(o zKD5(t_glUj519Pt#2;-l&h9Wp!Y%GKMcL-=Wmrv@KZwhBtm1$#CUmC~9{!g3qc?3( z;~Twrh(hZXOD#OKBw#|t!}lgLtykU=zqczINnVMr*BpG<^s6QcgGWHYk38&7j{DMV ziFwQr_cB7!R@hB^T}U4vK-b@B>YPVFThuY{?pZ>M@FhhC5+0nC8|ajZ+lN=GyF-cT z%vFyuox(ZqZ))aN1eEOtQ-`6@EoybSs6&P3xe;ZE)r5bpj+h=qvgiJ>!HQ0CxHnF2Rcf$`>=vSO4%0jCH}UTM-A9J+ zurMA%oJ&QSLhA@9VPs^a_d`Wo9h;H8F?h#pzEV&((5H&pYqY*3apXZ9^ye}W8G8M$ zo)Jj!-MB`P`b#8a-n}Zar?pcGT$nzPd*DY+T3zmqLz*5Y+bpGYz&DoD5#C4sApgF! zT_2AQ-iurjZ3&seJ~PpQxc6NkA9(sk`?w3^^JT-Cic;_t#UM`Nei<;Q&2lFWOVspp z+124>3JTu!+3ls=>TL*1*gSE7&%PBUs)XgqJ=DN+%S!h*XUfg@u4{?MB4YMLTi5G= zNxo`EqxXx0ZG8X!_x2J`pOtX2X`Mk;&g$iuIfbm5qm7q}uor9|wPl}wdHlOr`w{Kl$|gs~iJZSz=<$&2%m9ZKfKUu~2^4#l`aK%58!c6dB~W|I_}Up z>aeyVT`WtwX{7XS!k^GdrS_R&v{x(il?Q0}5I)+8xA-?uj!%F5vx7mpHX)wV@W33Z zW#XEu)=pm{r2~ozoqsfYd8E^m3oq-G|BmQZ%cGP_#%bL|rV}o?yNhP2B5ExiARQ@p z=W`&=u+B)KG?BqjW7M9dX-hJeb`{%?mFYldekMi0H)o~BeYB^k&N=Jd2&_JYUg~)( zW%@DsXhZms3o2uBtMLf{ZrsQ5XSg^2-RlIn;wz74-IElsdu*?@QPS7HQ+*kts&pGO zSIUci*(lpFvXw$)YMEU@zlVkK&}ril-377OBj>WQ(?%hwZdEQH7$DO#uj)hhA@{X6 zac8ukX-7B8r^6pd3ViP0iDoEv1=nt%Lbw)B`$dD^(d30={>I9e9s5-U~M- z0Ua@OQ|9eabPL{CVH79>w6{dd5J!WVnOBVX#7^b+hq4(C!IeB%yBMAtpSMEouQT_{ zx`-Vg+{HaF$7$@mXW`{=`^c$-b|s4!L|QC06^?o(-P|Qsug07oLRoKX|JFrMWwefj zivuuZX*Uu=Q(F46dWF0uha8qe*bl9BTezcHov7Z=Q3@}g-VYiHT+*XwGovE&ru7&L znW~OOzatMv{e&w^MCCytW&R--LqqT#itsr~(HQg5YjGb1HR@h&hyW>ZzMS2TzAxO% z#-CJy-I{*JBi?*^e!9);a%xf20~*CvKR*5P?%ZNxw^~wflJu>CL1Zu2go3jW zK*Ix&&zcM-cK{_EcueYsQc9PmbM$cqIr7^F-r*2D&ne)J-fDnzQ9$L|K`6}pJ;q0j?BzQFO(B^a5;2o_cl5bC`zTl*p1e|FSbYu!GbG-ai>JI52>U)K_7`zN{ zJ=s>5ULbyhb{HQpK1Y?g(FnqhyuI+PqAkHwCPmltGtBS1wMsn8NsAzuMN6LDU&DLU z^i_X)+4$yqr1DfR^kmdMsjzm_CN2GWv&f*WUx@Le7z^9I0`pO&LGS_gzTX-8y&<&b zEacJmff&cI$RdX|rM|q}au}GB>0Vcf!s?OhIpJ9lJol*7ae8Sdu|vhwDs@~KkYj&G z57~wAvOT_rwm-PcMm&a|RpJv{Vue#p5$uxT9O$=Bm=!%@KW_YtDOD!K8_B>@ay-BI zk@U6>OU>E&-;gi4apM~W#In;!S@09ct(b1$7Bvo9sgy2sh11&t7pACR%tCLv^R`&c zdHfuj`(rj}P#Kqa6bT~%znl27EsCelknYpQVfeza8NUW*I^1nrUw;ezGD)0=uygS9 z^{-Y*z)t?2yoUwipde8Hs)65z8K*7=hrITQf|_IC>3x%8grdJYbK5=2vrM98fo9J;?9kagh zGQN{^dms^Z9-7;tQyZHD-{wpw*9tdv^B_-qb4XDd5)@vvIw=edCWTXW(rT$X0{^wse`-Y{g7`G)iXzUfV$ zJm)oY^`jXPWRjY=W@|A^06v6fc*m_WoBZ~<7#Kp86g6a%hYKX;^NPx}3RBhTIEzDr zV8@83sQbdcmBI>mtXogM+W{hjx3c>&>QeMIy9(kiu<%Ehb8k0_si`Mfc8ejSXZ3_W z6(RRqbMC}%p>6!O?~QmCKeI_uebko0cRbFoy}B)lAa-cD}!aer~_k2EcH^5JH0ANhhi`bIlJa_oO`7K}3oIe^A(>92HZX0PWtL zI&!0E@8GCgjO(-=oz;ycU$k+EY`#AD{21+CbJg(aYNwzLkPpGyonKQOsR%VVA+E;h zuui*vDdmfT7THS=d>m(Y5uTP4K|+XcDD*jH!Z9@EkU-tp>v0Bce2!>UwDbmP9g;-Y9?3IQGY+^cyRxu55YuT9AC;phODT%Fjva>IZuRi zC=(g3MX5w4f-i4$HQuURmzn>gFWFl;!$*MS=H7#-@3)eaShX@Wa0eEGK?7g|obU%c*p_xM@= zs|J_npMo&p)>~_+8Kx?6#vmk2M_PMIey?2GMfOG8{(XBfyoJTG-+2}U`uY8cRV^yt zBOCoPaGy_><>Oojo^QJK{hzml{BYY07n>+=4P89p27mP19voO8##Yk$>Lb1ajQn%o z98woQ-Jfupzbtbf(aUr)qk><|AP9$^lLytFBu&*%+2ntFV@;_E&<0^vBM)Y(brBoH zX9ISGBRNkdX%G)sSL?fE5Fm>celW-GJ^)qMnELThnV z7ZmjLKjg|-2)l* z&c2NAUk`O0(dg8`e+tojq05=n`RDSPaA~T;^(6FFx5Mkx{!VV)Y;FaEL-2N0{^J7=(y2Oh zE3GX-8WYCJo%MwT{4M|;IY^ngLiBPiY4V|6dMCF?rAMU5u}wc+sFXwL8CFWBle2Ou zs$lFfWeH)gR`D;q7v^ed!`iy9y1yNSmcqZ5{OBNl&4=O!1e#_Zuz zH>$iYNcyO?n-+U^+<4dmclxxir@PQEd&I7ofn-zt?&33gftN=9g0 z(i0G4%E>CLj{VI+VNv;g@Zq{;wId1M)yJWc&)+j#unf&;wL+43Q*XOhpdB4zCW-UxR z_ria7p4#zL82$hF*g}M72=AC?KP6(AH%3k>8BV>7GZV?yLzkn@)Hx-pdY#t3E$w;D+C_}pjgf#mA z%QaCqd_2?%gzfy%D7uz1+r5s^fnF4ob}g!TwIB(kjukW5;wsUsm!$`9q2Q61Z}+KC zfN<}8K;Qv;G&4J54zISc6vb0>FS)HC!71^j%ZCiE*_C@I555lYPuO=R9=`LKXDp9# zY`L7(eS3$g7yq7%4ckWY$H#Wxm2JP&bC&PX^_ESTl89@#zUyoUuwKq`f9{0cA}Z#< z?|b!-O>eXb$=>Yzmv+bwIjkMi@S4k*fQvJW5Dr|B$3w@AC&%-QCxxHE3(Ij8GjasY zSKjhJJ$h$Q?HdK2vvD5RV5(TmE1Z2%u z_3N<{wM_VMcVY0yGd%Es71VCm*MrT2^;!>7eO7!Crcw8tcJ4~VY6ok~_w2!Ih9yy@ z-CM?=9`TQW2~`h0K(IrBgy#1r3>DPqFK`?v`*2mpHmsWS*a5zZR?ZDh?ZQ>fA^0an zfwn_Ou5;pk&42%mIDMTl;W zV&F{1m2le#xzN`C&<-J|j$YMMfX(k$SDl2LS#Ojw8dGB6KG3HfBNB3R-)e%f`MB2XLm^tBL8M zb1uy)@N4#K38&Q7cL*mMB)?L%R2W=xy9J4=UYU=4_B0v`{ug!gv1g3n27Z8!e`j?G z8$5~oO*&5`D&Ox6Ftyys>;Ieo=Ou@Ew3CIP@o; z8XbtN&*v~OW<8UcN5kUMgC24yErIZO2cy#8JtHW%cUE?%#-A)H(0eCj7pv>G@L1L< z-Pb(*w6P#m)zWHpuPgmx8V(_Da#HHpTGJcsS+HK@0Tuio)g=flWE?ove$RO>^6oip zMv}t9+4~&G7&za}b`&{?{eQpKEuqb)d?pa)3&L_Td(Hm0c!{z_z?r+2eHrT(a$UtA zhkU+qU$3|DA0aCh@)cW%#qkGNAi7F23Z!B}XLABfEqAzH67A7H;ioqbnn0774)qz) z!^!a8GD`xPt0t}mrALvLTD#G^nVO-sg?_gaAlITwwQW){@C%a!oUbBuX_G@a_rBV1 z+XiWFvnzJ{x=LaBmojk~CL7z|YYKY9!;nhw+ER3%nHda*?HDP_t~hnXI895{Hn^vm zIJVwxLaJq^r+?9A0}e5&y^?Y7d1_W%e2o`)6Ymo$pB;C9KxO5R2!o`)YO5+ri_j$~ zNh7S>N@=54&~b_0ek7h$L)g!yXl2sPUajmeLLv*I?|Kx$M?rZB9#{8$Ye{p|PNj;% zT3NAg4>sR&1pALE;t}632)+O2z8ZgB1oI<^NxT0qCex0 z?Rju+6?`U=gGgzCIrqIMl)(dXoU+?bVGw&sz24y)`+^)Cf-)KJWzS6b+PJAcko_-$ zRh-*Pu9C;(pq-TauU3}_DP1kaY_s3HvbKe0QGw6Bnh>YRFIGqrLcC07_>QI*(%wQ) z_E=>7+qxVW4wuV`hebfP+;eT;w^VDQnooBL1=&@R!}lu7_#J<=E_}gZ{w&x*cU{Ud zjLc)7jSn0z;}9X4I$`T0dLT z7|FLG7&SC(dTGz0q}VTjf7}slMIrIW;`Y=$gN5P|B7qt!mAKVp4PFtV1Ix2pS0jve zw`a!eV|_K{a;sl3IACe35JC!ab_jN0zSgPwG%{J8X;Fk!PY6te9> zo+G^Uas1G*-n|#}hO$rxQW;4<)!5JGVMdEHVYbK!rlUG)L3QwET{QOA=09=QHp;j! z)z!Wfp&4eS=8OfC>!U38VJ7=X$}=t5{4VhhO_Q95or6^Vz-e)94*K{z0xE+e9qTEt zkAJ+UhmI32yIJ@Bv2U#R4-BPE|q<3_M9EaFLs zK4x;3&g04YbGH$j2XuHKTCSe%VC>Vyn8+QL{{@$)V^u)D4`*&BMn^@4X(Tp2eZtdx zkimV#=h_{+Lnv*pO_hdnCM8yHs_!%W^ESwg;nHPC+^P4fJ;MnQW;q6&Ab zND#8w=$EUXWG{S&Mdc0-F@3)RqR% zJf#~L1f=O+(<7x44Dzb~IkU2fL|)T&kRSyPG>Tj*&gF3j@etEc%j>t;J$an!g@a&* zO>>e6^l^ozlrP15!6**loX%bD+YB8+hY>n}zwv0>Sxx1=d$)!1{-AB}Z2GU_RFJPz zW)xk9g)G1;aHs|aX$~7dr*@QJ24xa{#&bO>cygYW!0Irl78tu`CB8p}bFKZ*z2utU zuR6$!T@T!iS+l-h4oaP8aSlpo!%kL;W~ z3NRpN2yX33M7xU06I)Ko zA|W#e_hvZ32Fv}O+b7VNT<%AjAYL~9_#jXa#sn4zf^z8a-pJfm z7nyHhX|<$mcT`* zjQ6woN4vXq771IW^Kuu*&c$`jlv&^5{bVh?aGfgM^7-^a_r9-Hz)d)`TG<%zGT?c- z9XX!3_u5Me=F{{4*{Hc!5DV8vkz(Qd42R8L`~3kEW#9~p^%u9K)wYG>VW*aXmkKgX zBXCSNiZSM}Yi;N3v&Cn{$G3A?otFVLcIh#B&yFL;{P~h#4zoCyGvS;T^Q1>-6Qu~vo^g&uhC5cxE#~!lTHk&j{`j~0e$&j$G5v%P zas#)^xbL7L2^-l41>pN?4;11-!L!il!JkHkBX5CqnAJ<2+MyFq#l#Fa zF1(5xvp*yIKo}?3rRqAZswJqMF3vLwN1v!g-n;yRgG)znL-6!NeUrffv83aUPVc{& z!lhvJm-T!0@h~x2bQab4oZ^yTqW0o(Ii_g2)~pJuW6Ew}=SK1laxufXumBYLrL^?O z*6nr#BBXR)B%?p~gbn|iIU(tv5^82Hzh?jNy+LdaP%j&u8#?ML%`ZVH8 z2W{lWLzydoy(8)VtjLljkLnzk+&)1FCKQ3dea4!rmyIVcoqy-v#lwbO+?US!7(cWk zci%B+-zWCvyk_Dz6{+5W1qqSwYD)KY?`OI6A5g2f%HSTl#fY-Vw4eQIvTAzrrd(w` zn>)q?x_9JXHpcDU=g<6Q)o-e92Lm~6Cty%Nv*W%UT!D@52J#u5+cCMS=r%ZT|CMPQ z1#><1Cb~qTN&wIX#H7jL@ALJN2xiY>kHNw9Q}hbHb6cy`Bq_y*WHt#BD=nSc>q!k5 zwyLNb=`>`St9NL;J>F#cMP@tzm*(<}SN%G1=R6v1iXiMu8{S+;n<45}ZDul|H0J8Z zbQn%e*@T9>J(5EyQQD79f*AK~?l9yX#sCP1c>Ic6tf6WFt8+&Xa>NeV`q?Uam}!&7 z)OA&7zoh6v^J=UU4E75~+ThDj^*>^@Y#PATcKGg!9dt#qPRHlZ8C{51J-TU6xGjvPZmQHM*Mn%7vt z1I7}!8DPSPVe@bi4yy~|>(Rpvc)=+% zXI82eqeum;1}-pt=qLuaZLT>S_~8h0UZ}PQlA#VD>nR*r;>hYO6p4OUxdB1rRI|AE zM|TLrHu)%=>$QY4H)31tjBoDQ{i*s=L}^+=8J+9bQdP^#s~JZdQxx`O-8%4Q@@bIN zWvhlLfta$(m z(sYxR7kYAjta0`I9aQt{MqS^zL~~O^0b8H=U!}Wm7vcq>L2Jhi>Ov;=D>)7)Dg|yd zJhv^Vj}i(slp}jYYA+oJuEZWo->)}wV7$AV;-=)#sKojH6tSoHwqKfWpFrJ#ghF)4 z=CDt(4OPUne!ecd0N>LMfZ-t%Cl#Dr^WB{Qh;X3)30v2abngpV9Mc& zhcX<0zP%!6AI(zO#!f$%-ea1VC4|mgFp&{5x^$TTKnycS2x@y1NjBNmF7on&g} z`1x@qre}fHYt*Bpou3^DaSQt#u#^+Y{$ZZ}Av}~RfpBH`tpW?bGhD3G>$PK%!R>o5 z8+QvO*9SH75YND;#+d#K84qNF+684Ct zwYV$xG9m~aZL4-G1WzI zqR5o%VEg+ZsX_6u4a515R{B|m;A2w(C+>p8DcQfD!gi+k)6d&UAB@BK=Or6ppFez( zEU+cVJ(`w_^^qcxn366Xw)_y(ILVZ&t7D}AT*|DrU&bMq8T zG3Vvy*vsNe4_NU6t?8Nm5?~Opp{h!qCO)e`QSIh6XBEX*tv>RJR8*PZ^c>K*vj<-x zgfhOfL2Q!h>q}m!h5?TkGH+e`nV874_KjS@J5c-s^Vf<6qJ>ilXwM7tpk`QJ9tA5z zDJ|8rz;@r89Tpy#i%thv3jBRJLh20RsL&MZpWsA69bg`e7?38z+q1ZS#5u*wg%c13 zmZt9J_czSr77exL_xJoQ{tl##!7aY{z;2jSr%D&p3(b%8fyv)Oe!+s`74B6mB4k!B zDAV#cQXnzaSw?YNrkKSfJ6#y0&m+LdONxV=+sTGz-C*D^o@L!u%l9xh`g9Y|j_Vy~ANv<;b!qS9{gca7y49L|@^v!!Lgi}O#}44#zMYrA zkXh1Kxgt~TV0-SJ)lWu!%c*}Hv8Gl?Z&k~2HiwV%N7 zBvsCIveRW_z>4n}>kYZc4`n)8d!u`!H%ci-!Ba<)C9L`ZUgi|b2knst1^V`yr=K?} zBbb|NQsl0Mzx{%e`S{3$5E>atI#Y38f4bjNqs8amQAy7{m3KWk)4^pI0JTUp!spy@ z{msv90RyA6GPk^-3zEGOVE8ZC-Ok6l2Rmb)@^Z+)7|{5-Uu<^-LM$_CNPWl#R;auWaP-!LeI1xsFVu#9}rN z+;e&DS{=ld8!{>!ca&l^!^*{>u=qd^#{KaV0E3RRGTb&F^+^P5*cP$^n>L30tSt({ zPaI5$w!HSR;<7!Q9k@6kHy*4aaJ^phqm~!>2CUxmh>fCqT+gwMwSWcC?OEG|TmkI; zctjPzR&YicEaNjEn6=4?QhJcDbw~gXyWJf1JQD(GlS<61rmt8njP=tnITLp_42$a4 zWMhqR!pykLyGJ{hKn<|O)rt*rus?sf8Uy;Z%uL9IQYDX=u_xL67@8&V9Rl=>ZGkkx z{B}=p5^q^fS~M+N{p^e155MNVqG zCO!xxjr(0FFX&x5Cb0145!;HyMNyQ$)GzqAXzUa` z+wbMf=7$r(+Kjhdrjq9yHgVxjK*)YMa_{~}i$hqY^k}%AIg}<;+1Ju1-=hQm27d7# zJBM>%G*?Dl94JKj+iD-5c9E~T?!D<{wEX=4^1fQP`B>~u3-AfEXuS7@wSCe>0G8rz zYsnD0TdC50(?;^?BL3utlA-jV&ZA(zG&xq#+fs|U>^-*!2&4_mjz2%L>y^EzaoX$1 zT%jC4a4RllTGWbf?h#LLj=5R$o<;#X4S3HW@aJK!h;AJz>~V4_RfXZFh!NNN2_c>;tgoKEOF$X#iM0q3ye_QeLq0)(;4=n06|TCBNm?N+-a1TU+&)=dZ;8bA zi|&g#EUAEeC0rC4*qMuK@PL!Z6$kuFAKg9oG4D#yr6ZKU|Fwo4{9!78Z-&6B_5Uxg z{+~Nnh2=in){H5G3N&88r>n3UAhjqGn})bYmnRf8t{$Qi+;67wG&!({M$pFWxueA{ zQi`X0T%+t&1T~sl> z$J$`}rty=Nf-2GAGe;60_!*h$ZQYLmH7n_b)vMQBi-g_lHU@=UU=fkAnZ4=+gFXsV zbs25d?SPx6EdUdjS%g@Gn#c#;{&e7k!%w(tbbfxt?>McTiP@lFFMa4CjmL!Ie4fy4doa}sbEQiFL#1wC#SK-k^3QY> z2FYIRfvrq(d3d<3oEB!qJnOxK!dj4QwqV(Sv~7&@T?M5UPaC`0ia?j(2}MRxAKG`@ z&xb9f;|ZjnyswV$kgBhGwr_A28w(6v`U^J+tf@FNhTz3wJM`e63vHl*+TQ@ZfYSzl zT-rpLPz-}Q<(j~W(3Sg5&@7L;R6QDW0dEAIyce{^F8V3 z2AAO*@EoMu&gd93`wWE~&7bJF$+{fl^k|-Dqudn7SWPKgzj0p(UF#QDEU#U}>{3WM zbR_u!*Pn~6f;->t)OgierCP!nuh0F4*I@NgluBa8{gWerY$Dfvo1E$4M)$RO=UWzf_35f9LIltG#JMR}Qo6ax84h~rfnUTO3H;pwdZo7uy_2$PG zMY>Wyu7YVq&A5G2-;<+B5N3e;m0_iL%%a(D`8 zT?z~Xny~0d09gx7?N7RKtKGS(V(t?e(^cJW(t@5?|J~Hb2Tt;3kp!^xW}+3aVO4Fg z#Id8iW}leCVz2M0s_PtLadV0fdPkfOUB$NR)&#DnVnY}K#L=R_fjov?AC)SNP6xYi zP#3il$sVI_KqEtDD0-Q?@!%WeXo6kH_&}Z&2D+ATft_jBk1h|C?B+MqQw~|lt5cf? zNVK{vb#|zm)>d``@jObR0{(POaX4H+4ls<|dItqDOlxCk>Y;@4Mdw;F69LBKFy)IY zHFkQL0=hM~`TcRyV2Grz<|afG(wOkmfg zXf_G{&R`#LkT^$*@byFaXsDj%!ck<$n!>_jVcHfkZjrZUX&$?tM*1icFcUuvVhefN zf%Osm$3lp()JI}Y2RB6te1wnXOLzo#9=@DGVeJ`YV-cH4e$iHUeqHJ?q9EoFXP{?WN`=Q9zbxM1 z31>91r00NwOitE2)^EBHiH6p{tVitFI;&@)(+!WrmV z3OZ;1*ppxM^b|HyTI7XX(#15YCtR{guL~}Hwe{5y7+78O!qJ@!| zFj2jjSEm(Z>PIZ7?N){AE}^_z@87XywWGJ3`|h0lwx6FFVGV&B`lU*tf>5;;zdzzb zk-+GJTxX~q%HUf?;QH(|2^~>1#{N}$Wu3{KCr{sf!=|*)&F4P5|I0H!XSTq<>f3`$ z_qDsx6t>S~`E&c<=CXkDr zgA4N$ba;MW{(0y_A^c?%5Qykm7X%o?Xm)$A7vzIWQxx&6z*cM7_+HOT%1{UbbRe^k z`Rq0ZjRdiaJl7uy4h1bW1D)%{B38L*I$Zcci*Q;ZrJMw=A~Kz8QGpwISUsx=x7q4Y z5Z-I+NX!Xkb^^e_5?i+nQbX@v8K727xG@LI135^SQIqdXS#ufudFwCc0UA1hNd;*! z^RZ}TlAX2Q(tI63xDNu&pth*vP!MgqI1r4?t$5KPg9SgLhD|#wiZ1YV7DwGR!M(A| zPARmZ1zV)fg@7G@Vch4!)Rn+L#;`YOK1aQlXj1hxCbOox=LN9k45bp+(0X0$O`y;7 zTv;FW1(MlFjYTkHiK0s;t&Wso=fFDVK4v%YW?j{^aUt2VpqO`pUBz~GVYMvWYPWD)Pj2SBrKhRuO(fH z82tbQ1rzDPk{3h0ylg2s?e+8ZsY0{PfdYk1!f0Rcs-)&=kJ|}6!ZCZs89P0`_!l2skgl@Dk zln1e)PcZ&^$E*(ti9!sY*#^-~=^uLdEs{sAC(S&R6BTMz z8>XJ%i6iNjF|hzo4EVL^dynl0YU&dD8N@W%m+rQ7fZ5?3s)^*GIjqA1Y51La1@RuX zV#W1aeW?A-LYPhZ3LA!z-34yeR{Z)FQ}#^fp*qKNk#sYdm9j{nex{dw>ne5K+OJ1; zEa~#@&0_Z}r=z?dHh@hCGNMwS!}({_GiRRD)7{qZ;l^lTd>wcV$sn!+4WcqavLbn2 z@gqAxudl~8N;VtNhUpj3Wv$=%=zCc^K_Bbr;2ofyz+pN!ixb=lor7u^Wf8A|-MTfu zDH!bL5V%Mc##4l=n(9eiwT^-Xj@l!xOMG7fSWBEAZZYflLciVjn0GG?Vl8>N)I!6+ z8rqaL4%zm;Mb-kvv0djvH;sDZ@fg@^Pa}vHeOIS>fml!0ibzQ5oEb|1p*)_~R!-RB zM6mPl%v$0|DJBOUyj)E5#%{Xy=t{@+jNfOur_|>O=u(CJ**~3zfjVC@njT4e*jpsU z*gTsK6;ahDrPHg_YH7u^M-IzHomgOIz}deJn9FWaxKg)K4c8L<@h)AV=1lt4wxcEo zx-*6BP5>;5C`73V8=_MZKGdhGlgrpjdT^mp9rm%)YJ$Oa^bpqs;;SxnqJ5oBV`Ydk zGKk!vliV9EHwz%PPa3xE?!)cy3Gik3{X+QaCTKu6D-tWxsexRY*wQ ztYOu8ZXNss>-@8$k0xZA2E{kats|iEK#Ct(6{*5*RAMz!xuM^p*EGLdL6r#Rd`=v| zBI%(ZNTxP^aQdS+4mOU1;K>l2p;>NKGN89b?SrQP^;5k%M0M0k?Hgb}A0GzIBu8KX zf^Sh?>iB(Ki48x1hBPGw($MJi;OPX)oHy^^G;dUwD(QshC^qND?? zyKG3|kTrtQ6E}Bp?+;XuRb~tt5hD-^ly|x~PZ)N4;Eho|LjH(N`W2Pf&3N3#t_jfq znm5P}!ECG->1;hLwAB-{<@(LB99XPzS9EKEg;|%muWZ2X*w9~CEi}5#Y7fAWhF7{u zzgU46f-OV*>z3aj_H8CP59|NZ42*#e0yCoX9&0EvX5~%rmv^8=3S|T*>DM7W28Rv= zUWZ?Hv$Bdxa0?Qk^Rsxs!uz|ym+;)U>*cwN2eJ&H^H8y9J=UPe?S;_R&;V1^G{2@7 zhqhbi2J5MFC}@)iPf)3tqM=c5fLRW>GCofH$K(SW5FhKvFO(M4_eROzl-Z;C{?QO= z1&=@1r}G1cuiLl-k*O^(ogBzsLKZFYt+~6J0n-V60$?+udv;ydcjgX5BWiyK;;!fA zr7^#WFNMAp5DUR7!0Xgfcq0;!vQuqFgUM*{XJ24pC?6DEceR~i*`DAteMt{sfuL~9 zniq?Wz3eCe51*)1a0M0A^#3vT<?;7i0wM>7I zj8%hqiJe+{8XDx*Z!T^5~p( zu&s*lBOaPU$$ZlaukeVCIa>MX=mIJnTJ`O@3Ci&;IBKG>Qk;p}9!AKiaFBWWd z>HIhu16a@c)8U#tA^C7L!kZoOY~^s0F=nat#r*u)iZBVq_fH1R(mc`@ej2}9TZ$i< zNm^RJAE?_ePVH5QsQS?&`u+`4`fwY|s>58t8=keR-vF1~;e1^2vaaA{7?}AzS~>EA zoT*T(8qV#S2BgA80bSw;C;W#{VH(XUl+Fup*bKcgk108UVT84PPrzcXv!D1{-Wi_0 zBuj-CUZ90Va=xg|#GjiiVi21YJ^0QQj1mtTmMOmDmvxtGZeQQXM%$*>@Ux&ee6Hr# zV~}GqI0HwnwPX0FQHk8F_{UfT>GS;O@u4hd;z80&YMOqDeplaFVk{@%IFIM_#u7luf{Q5 zix**WPk0TSYd?2j>b2oqqjd9&%URMBX-LTt59*$nI>($}vK%X-Mw8H17>0avKd}vA zw!c)AvgS9Ck_|1#U}G1}pjC$)^uah@->iIBrtaqcx;k=L-#Qkbkg0btW>;IHs~lUx zL1M{7iZtEzfuW5NOA?`pBAO+}x;1+nb;;gbzpZei!E_Y<1;xJZ^$sr1bH6<@lRf)e zr28`D4Bij9J(WWK6zhrEwPIP}#=lsegua{2Qg5lN*Yo@XtK0Y+f|mT20vr0Lm@os~ zk75>hADphGg_G_&vV)dN15@MmL|g@KKsTV9(1|BcG!M1wf`)B(s+MEe9K^KPVx&>R zk47<494%&6dM#!%G(n{W2npE0Z5s2Ig~)R64{2PoifrHgE@%2=di<=(hRAOC;iw21 zd<>$n`pzvY)PNMH#%PN$3?GA@^3LHu+n(E0!3OGX_w@Buq$8`H9fCJ#Myq)rax=iE{6PcYwjGxrEH*D++W6rp4!w;SA-W+mpN78 zELf-eBTT9D457uJ_wX4CV9oXCNr`7Cx&Iiq&~1of__5r$Oc|g4i&+2TCJXdEg$U!i z^*_E*y?oQ^B|#qTZJ(kqwxCr%ym)3EvVgzC?9IB~`q(hBNw@kr`?6YS%-GoE zvV9}|1)>GT1>&V!F6Rw%?k=ehaV*88HqvF_b5R}flQCNqRiXFAev;^K#frypJ#LO3 zIu6I(?hq;-dHCBDe4Cljx6e=EU97QgUw1itXMccJ^~1A7Zqxi}xuJvft5DDTUj0WE zdDVy7*)~gyp}A71h|i{*j`6;Wp0RX+IgU@v^oJzHG7kM6as*u0r~3wcw#9(Pf#Kd{ z@l=Pa3bxl19b(8s({rDGrPG1;>z6|DRduS*m!btiWV0;D>uPG)`!hyp3L^tu^56NU z7|uo|an1%+r_d20y@2?Mx0f$rV=z4Uiv}GmZ9w;2D(e3b1=NtnV-R6#Dd{3;HY#7u zehI7GhzU6JX5|=Ob<+|5TGAN7DJf2iWrW#$?zG$hEl*Qx1A*E1m_zHY`(HfpY?;lK z=_o46sLDq)ac3%<2qp3yR2r4Hp9On35n}LnjP}>-)9RJG7{&vHy`BK?Zp`7j?=gb> zf|%sqh+$&e;Kr<-2y0Vf7V(l`#jd76kEY^Rv z9r+n`&<0(m&3qGSSGg-)_!IVTB{QzjvGgnu^~u;g(<$uvd5?3{-+n$=+l!~C2wTHi zWZCuDV;V*)(pFpojOYbo3pBdJOp6;h3NTyD$0=ZPh<=11BE6Ngqt^R0Zr~9HH6g;8 z73A6!=0z$mizw7EfH|YapTyTb!GF7(XMR4Kw*|=xSz=>1hfj&OAg7NvF0gef>kj7Vfurv6hy{ZD+Y-U7jHt%mBsWC*ltsU2+J;ime-eLR$3N zp)SY=w5j-%hQ52!EZJv-#Exz9*7e=I6?fHV`dT?@FApz|3 zwK~o!s_CLgkU8(d$W5>NAEM5`OtOylv70V_Ug;_jzv|q!$V_P{n0QlJ89Uh3tg}_F`Nm_}Yb5m%u z@{jWg_C}{J-OgE#M$9ga8K@1X7*8^D9hu1v)_W8n6V+SiWXC<89s|#B%f)<5yi3;)dF7QG&X-kAAQo}1*L>Js$2&P39b56Ps*;8riYQDW5}pM%>FK6-GVhRdXv)2};2ZT!fZpE@|V$F2;Eh99lm z3NPp&qrE0R<+*2*x}5&_?PJ_&bx*1@tosFr({NoE&hzs!v2KA?7E|gpKjPSqs(2xI zYv}l@Ccxy(OeN0{j?^2;V%aCfraMMR0N(h+9ZfsjGbG*`F~TIhA^Q6`OoP{FAc4Li z;^-642;OJ@@`;lgk8#H?FO8lRz!eFzEX|nt`Z(>H4HmkaSrWeaE+<1Af3sdQni0w+%`{mO#Q+h}xcVa9>gD2Kl(9kW zLvm6m2-RQ~{X!s9iOMZtPywTs^i^qd{?Fbej1i-+XeXjOXJEYDbB;Ph(S({~UZbib zh?2i2{+@}sa%>;lt5@~?X=RqVnwO{$!U%nF`MMCVXc>%bX<_ydzX=iV;=p^( zEuj;fpNi{_W7$R%uK)C<0VK%yC`3}}-@-jUN9Z0i$2Tbo@Kb^7)Y)5Np!L3T)1B^h zXOceYAO^Q!GJ6y67+*;mYpQPo$HSkQMys;8AIGcF^}XDL<`BPFQZK&2XY@XTX|^FI z8oKZi+fHnb*aRJXw%h~LdMSI4*@zlT3sZtSna+V9ISb&fvxu3(-e`wp(0*C(uTKgh ziq|#I*BH*~97{105jss8xoJ|;?@|A=)li7ZG0FV%Qf7I;&mQSxdGosa)h+y6gKqkW zbIFBIhD820Sn1OMPkYQ4Btk{v3Ef!H_I$fC`g(SG%`LAY6V4AS^!{p9Q3lF$KL4W3OK6@!&WO27GL zkmcVJt~;`96^(@)#-HprpL(s?J7Z=MaX4UCzIEcDQa<#MNA8;I^;|3`#D5#TL1O)IB z@ZfEo>b$Fm8{q29RjV>2%T)IXzA|C4fs3}uXd9Snr%>F z@^3!A*}`W+!Cme>%ef&QwR{G&8(REVOU$r$&HbHa+S*q0H#PB$)-Txac!%JT85LYg zW1UuL%VX2`#nzmfmhee}f#?v0=|JIn)8VMi5I6Mf<)wj8pVa9Cu@s?v;lDk!hwC+% z;se_u8hxgr(157A6Donn>qoj%Po=SxdhZ&{s#a2T=^fp9udp>dyks_%y*5B=0tGmY z3!RAq2-NZd05Bf7a?wltuYqPFA~DEq6m&=faIXx@NyITo>T%xAWYb$A5+X-W71WjZ zgZ!hrv3~sY0(~Do`NB(B%a{?j9JVaVUA=D3^ZMDqSADXwpdq~%1=H$1T0TA;4@Hb5 z#bI8F36etz2*M&U{P02QlQ2S$_H%IoZ-Cf%(qwSo(%;vtt z0LFpDoof%nPJe$Lv^iq}MX5D*T8+r$%-I^$1rt$X!4`xkZHI6*cBmy)^%Hkk3PR~< zp>7?yF`fM40T%1b#M5^JSF@i`UK8HmIirSa|Kas{w7t972`#YyrC{K;;NMb1aMZkL z-Avm0CBM@SA_wmhE1s_J8LXXa!L7Gf7ZZ~V@OODY^Ccm+ssicvh_3PrWI376?@^yZ zKR^Y$gbk9$sreesC3+_bP>^FfhS1j?Z)9ZU>Ulqgb#v^oN-_h1tdOu#^P@<_g!6O^ z1{Iu&v4I)3igQd>m`%W8GUi(=EMR;ticPn+SictD)wknJ7#H}HM#JM56R|Kg^2vL=ZMxL zPd-0$EN(~%zoZ~?qk52P;=ccQseP3CliYZdckb8F=$wIqbxP zhGwaAPRyU&P~q?DPAjIt@XRVb{^G7d16mFVpx4pW8K18#sl?qbYCu{inKN$qU}q9v z04K-^oO!2XT^B5SBspp8`mm~S3A_;~5+M$ci2WHW4BONn4cVM0Rh(t~iEb*WqgHU2 zn=aZ)_)arjRJn(7xDsSyNOKT_Re^J0P5g7^rH`NRUTlY!j&g5UKVDDwA}+@} zq;gwyl1$icMfQWoS>twHTo&_Udd;UCI`_{C6JAlmWcuV5#aFZ^AM z8$wsEz+W3|N3$)JQ~I;JP}Y1c$dM~<=ZtI&oE;4f?Ry-ih@9)Ja@^m7J7_i^VybMv zaef>1`6z@yXm;!kc+xD+=yDj8 ze`oNzkDzBAGzoCqPpz~YxNm=@usfzlCepw1`%Iz1U!o_g9C|xznXbq?E^8*GhY6(A%E6Kg-4|2a2LW+;mVFvfz(RT2MT91NWrpM$T!3aP%ibUvh82}NG#%8|JFQ_q`$iObU`hTi@FW?psaCe(VYG8NZU4sMv zX8v{^6YuZATjbWKr1eMH9cQJM>K;gYB~+U3Tzy`harjWCeG10qXp9DVj{{`_0qYm* z#42K~FEAakeD{#2XuW9OB+*s?+U4=1j~U_=5v zEn6^`@;jlEv5c~0usiK#o*;xZjZA1ADVFOI3GBUg1+Pw*J4WD~*JLLF+GYEYY3cX} zP-wX{2zwil!GOr03|b8z?3Z3Jx?G2wG1>;9o6;?4R!E{>r+Q&m9)3T^Q5rrvW;i?( zr(l5{jsLPFNolS6`^6^So8d_9fEMUTgl>9`2M7XSUYoQgMU^P#0H&R#V3|X4Ial6{ zN$E_E$&5sapLC74X@20b zy>ybas~hrsk3PP>ZT^r_9=%s`^9uszNAv-$E9iyl08p16lnU=JO0oU=p@?CH|BLxXCS&L=1KCHe2F zf00c_eq-_5?9lpES<#>S>q6 zwKnKLZf@P6;v7C6p9|3)UqbAfJ$(-iLc{(`s8Ui)4%Z*SP<(~T=r3cJ$vUA>cZF4N zxg(3S>$^*8=6NP{-QV|cZ>zD4NBf^2iJEdh-a$pi{#SJyo}HU^9?`4oU0-Q8eLyWMLx0O_{5R*F zt1>BO$dp$&X7>G}<$%oHI0>g#FaNU(DjNZB+q@g#7Qt^ZR`dXaX+ORzasjVH_gD7M zO*~)GBtYck9+P}z?YpFKl69Up<(-qGj_yAGb*Su z=OWXq;TaIMAGA_y%ken?nwjfRZHSt9F?b zc&_A7%#AzaoStMvBtK7*%8f0t&9I;8mrZ-m!Jf@2*Uvf7b7H^;7+P#fGprEYTovWn{*Q;=PpprDyB;0poFB#x6UJ>-g+73M2sc9*_*wgP9p5 zcwPDvQkTH$_OM&!H+ap{+focOWIxExG_4gu9)a$4ZeD#YaPqhxqJdN_jM9#GiH8|nu~t+ai^#8-w>Y=gJy!aXrv7lxD*fbb72cNhph}$I#6CJ~iN1E_bppT3N5W@rSTX_}}g)b%6?qZSS!0 z7EBj1FS9TOPQHCP?oiGGyio|-_bCrcik;OUfn-g{-*Tvu>-kZ@6)sF2o(~GHV^q$t zU{nssu;1=l4nEAJYkgU1I7kPxR*(ROuVt;R&B@{;03R+MomIUKo>lFv)~ST6Y>;-e z6;|wlxo52SJFfNLDgw~pTPBlLk@>N7hrISP*G$-mil=^ynILp6{?bii7^@4v_=KA1 z5`p$bk4e7Wc_&$yBb^_=MLuB+1%R}gWaq(QF1lg429xRW*y`!C6VWe54}yZ;piX~_ zyl=DhB{iKcpkAc6djBZEDS+E-9;tT(&yUW+BW`ZorJp^x)43(SD-Z?YHpGKBKFV;N z`zk~7o56^akC*j}f-{!1!Yt2XdoB+r3h6o>zBjsXzRf!`=AB8%Qj`6DM)WY_iKNk} z`la`VZQD-a&3YrciCZHydogYutWFGA(TST_Vbhy%?v=aMuHtYuT%H$N;}*J zxJU!fNYA5pntc%6U2vDR&MZajH91_Gv!jSxe0RbB18Rj2KUBdct%j;8d%i1mh+_-HHvXU7R=Kp?%ps1vtCz#k)B=E(dZ+0=zdkfbxg9?7Q31xN`_s7*g=G9hN zm1lrsgyqO0ZSDCj&DOF0a}#W%$6LZDYk&Z z5Odg5iNeh5tEq;hTU1&9?Oa6l43;YRt!-JvABtIRA<2x_KfX>~Tm81k1{<*{Jv-v7-};Zcr)tJdYPO&gxl%bkTk2*ns9trgQ5VAp z9dJjYa-wBbImO9P)y3tmF04?ICn=nOd7W6ZR3Gw1a!uCLEO!oD`9{f56wWPf^zT&!1qzj?3xvGG(1 zW1q+_Kbv>G%VA`+^DKdo0#2jWj_^qDqC2P*o(%qTsC6wX@+04p`tL9z4*qeOkokBH zeKKr9oje`N$!to4kGxy9MT$>yXh8izZ>$3&t!N^Ly7ojy0ClMLOXlP>tFb}u%4=<@ z;me%U>edXS7l-arR>kkwz%Uk2O%pfpMt*S|nNYE9}=7qmZPrLU)#dPrtVCodY&b1$-gVxlTF_kmi^%Iu-V zP2m`fVPN}4(4Cu=MIvMLaGlLYz>4T_BcACv=qEO5DK0%g&xnuVT~v|RjNKR>OS=q9 zZ$k7wdY8W5nJuXPohQs+vh+?n(8Oko9V(v_tTRcSx+7C6pYI}n<5G|4kvT+k4qro2 zC_;DK2+v?8c1329XHbIaaXkA)CfKgqsov>j0R?RGj_mN;6wNGWi5lFl-)m+?bNl|v zqtf@NUreIPQ1uh^H#Z6O+m?iNMR+Q$u`;h6UCwy+d$m#zC|z{k)|ww|!(^T;t++3w zt!yZiSof=EZ*YosSEa%NrrI`pgV&xOY(LqV=k9pAIgFFQMB8}poybM8ScDvMgml?i z6;ol9{tmZ`}{PihQ^42Y0g*bakm2gdaL&mNgv*w9V_`#e0M}rVZ5&w}H8d zJk!NW68r}FL>8QZ?}p4{B5D63TCGRytZx?8#VKGu{^A=n?=_!GK?))`J)R#qr*X)u)W zN4L{&MvSS{1*~NGEVnV169o)ednSJ{E%STb1j21QM5@m&Q;&w<@`kmW&1PKENQp>Z zR%xiDpk5VL(pgKR^cfgI!GtEIOj!qYB&{@OL+ z3!~Y&sx25E&}u;x!?>-2Ax$ML*n!k1-z_Mxt`$o)2VKE8TNZTT&0$6$99`FBVH$rn+;+BltE7^P^T)WbWHg7-&aQX$g-Ra!J_;3^IIa{XHf< zoZXz};ng>_r0dhh7HfmRKesoloIm)5PN2r;H?&uO$RQ2I@ZiTD9PD>6#uPBc->Xcc zGd`=Mu(EG?JuiZP8UzF)Eg5Zy8z8tA4czhdv5D|mVJ!Cr@)qM0eUGUiru--KBKl?OwF1Qzw@#P3uC}TZi zIGYe}@U2*t=5Qr)7?*mm-?w*#JkTQ`AhmD)-ee-J$s31jvTyl?iwn;%Ih>tP)!a%Q zJ+ulM#ZkH9Jr@e3qz%S8U8LlDngf4)y*E!>UwR~B7B9gJeitsQjv~tC+3;67UpJ99 z*w}REsAeKkHTG+7F@ETd?*+akHFeIn^D&sGvKvM>YYU>EzC=#G)7wu6;dR;8?aun* z;V=6XDa7)q4QrV88D7rXtK-Ed$T%`@iT+blf)M!{*$)W@C8+E<&ny;^f5uu~sh-|& z!Bp;s4btp{e}55G{PVLTH)dY}U!NzvVQRdh9EoVxXN6LxKW(Y{sj4K>tz0(!Ja_Ty z6aCi8Vz?VbNgXb~!9Y!a9ST~JTquYXw3HZuj~zVQ=+x7xERiu$ET_-a6)gF=t(tC# zzNLY6pEBZOwSS3~H4u!nYqDc{uuQ#YKt03x>oQd$+wCti?=*klIoDaQV93m^dm^RB zzNCpS=rePg!tlNerrP7@9>o8KODA@=={0lb*VbBb$6_9ktmRMkiye8(kmYM^8MRZ;V3iE@L)Lw4X&U6RTg zIgWoR@%`+EI;N7-e8ZuWc0vU@?pq>L_8xT-e*=n@yXf|0D9lP%`-NapvY^hh-)#TbSwFdtBI-uS9MEU*w1lTvxd_33Z+JZ1SS*|bXu$+XLiSt~iX zsVQR%U7rH(0{;GsTT8JKPXPGc88FbMt<=!~1m_?B3pWvRH;fEMpu zjYL(B^59l?ao3)b72#)cYD^BE(-}V$dYkAWBqDyKy1Vj?{d2{&$sqqaZ8JHORAp4U zMxw%=wD1Im+BAuOh@_|TK68Fo#XBDhsTFRYM!dX1Fh;uzSQQ6^go}zclth0i@t77F zYvO)wcM9k5W|8AV*?Jnyp=66Y3Tq!qQ(f1$lE;*M%x3l0w*!7(;XQuTs;}j=>Q_Dq zJ&FHx@6XqJxbxtCwX<|r&Y*QtHFdx_Yz}m-?};cpjiqZ>CC~N!Gejc+euLT;cOENv z=7X4y(s`9ZO=m%_w{NDa(%Lb{>dX<&T> zf;AVRG2bf+6?UyUGyeUgQ9XnIboZ za-%+Ge(Kz%+TX2DL9(O-$xRGD+=F^?8rphAtV=i=kcShr(y(=MTj92~}F* z>gQ*8-7yBpnrfbXOZ|3OsT0M^_?-A$j^}9t$Pv?T7BgnQZz+G_`irBc-wPBBP+}Ya zWcik8rDF(!RSuIcn*}4K8n~YuKS-Y}y3zBwgLwnL6$P@IE*tx4O$cIEGfpv)noAtE zT3f^CdD?5g@}m9SSw)IV+YG-IeS&ft70uLwV#60<+y3>Yp_^(hC;0$&G8~G`FNqhB zO|hRd1IqopK)}>FfX)fQ!~;&u{@%hDt>=JuUss>xx?tArIsSI^&^Li+x!LCJ$_%@R zScBr-z>D(9qJsLxwdxmTRZqOpuJozhX{p&RRj#SY4OvG%?c{qR*^?Y-!MGAbh*u+V z0ZUhxPCgNwC9p;2#$G=J!XNrDh>D0e$qRb$BOW>SVedP>Eo?3SNrK$9n~!^WfHwWe=}i=Vkdb%6E(=;>m=8LoRQyE zx8t3*qvRk2D)&Uz_54L5n&+pxRlRSw;AiwILRQ-`@$w;?=sMrL8*w48A*+y!@$8oE z3J3h~O}OAnM7U>xn;OfzD!yGe7vi0z`Y+a4y9RrCmK7X=Esv_CHH>vZ`U{sVFUxj6 zy^+|Lz)|ZKF1z&SF85CZ{?o`54)@!7_0<;>G{%jmov941oiz}mJ%cdowvqEJogqO> z)J~{L@aatUmLwkf}U3`t&FLdd_q}&AQ|qkwH93}T6H}dM1Lzl zH{{zJx=fDW{fj`{B0kGbPrC6hn*ya4M&@#NT9j=ht^aZ zK5;g!C%`tHAp`9U5_~Ac{y?XBK~@0u3~Fc2^aS!K_vH-J_RE}C1hIj;^(v|%b%1DDxzPRWkXqkuyK>i?@DZYx+a>j0IK8vOQVxx2qWPo3G$)FOb~|Sm7pl025;EGf0}>!v+9N_ z$Wt3ZjM+3k9J=C3dh}RSPV}O%+-Z--X0Y{oj+bT+qaX$D6N4gs5V^I@2f&lg-pP zW2?@S`BXAJ>p|!2$A25Q7Ft?(dX};+(JP~SFIx2HdU@ZVdB~r-+u?qhR_tC4+t#Q4 z^{I~h?zf4Of>0ck2)eTxTNW&!KyP=Bly4~m<0;kmo%3-k-3e}`)Lf@o0x<&$$9#J^Kt z_%{TnKAP-E@2D(OZ=R4L12JP#T;EyEtE4!m>v&*gC-^LY{eiQgKNi=lWJ|xj@M?DA zR63DO7C15|-#BwJA6hI(G}V3`=+hsaleC*^>C;)le7$GR!GpsrVR=c;H=r(*`){O2 z{dXhx77zY4luM9tRCG`j;xZD!s0O5a2>V4sR+8X%HrBLyGhClU3ri<#X)GUS)f4O` zEJVUGM7Y0iru)xX0DVv&st*sI7qAZd(El^8Ph3&|{RkVh$wH8;EeJwSJoy|x_o_I! zmnjyFe>6Cp%<4BQSNk-VdgiufEg_>p&uY1X-^ZpG;q&rx4GlkP4iWy_58fpwGI9HG zZD%Dr1O_n+Dstc>0+Z__q|!^5=jDzqvHPrr&39g@7fP5df^4}*wxK^=pwnsiL((()%}{q9(g(nO(G8YE^f_CPwPO&!b`z+v8}wnM zSW8MxqA*R!e1}4rh)+U%r%01&B#wUhnisywC;T!Hg8nyVZ&5Y1rHwqf3HjdL4PW)< zCd@Lux0?f!QP(>JNqS$80|XT23kAqoTG)9b&bx%S8N zpW~>w37nR@{u%zV)_)@EO$+u$4*yH*pfr@44~BHDL!lN@_o!Y6jL)6j)0dU3Unll* zTcz;6qU*GBzy}c`8lZ0Sl4ERiPwf!m+J8qlg-nWDBhWg#Pt-Mlf;4k7UG7gtiek)O zlyh65oREQ4YWq)^4F4RIt3PPf;~VQ8?*Xv|8UY;0zqP))r*TaS4~mYuPZ&R~==C?e zU#>`;;(B~KYdiay)hSy&<;3cU$NKa<>0W+feD_&+dSAVI)f-fww}L%4JnK1YwF(*p z0Y<7)@c7uO!Lc$#CCB9t$f};cNYo$++wmrmk}v4+B7Xj6+HHL$io~roijWd?3*x%K zc<8b0w6Okn1a~g1H?tASOS5$>Sf6IBkU6&5@7X5*Z}8uLmMifKEk2#w*p5hqQ>98S ziV!xtiUVP@>claP$%~lpr+8bJ-_}MM-LoxU_NyPNB6~>7@uQ5UZ&PMpyLCyQC2jd5 zV>&`ro5N?v>FQhQo@JwI;Z}LX^TVaW_)Al4e`7v$FPT`S8Ddi(BuM5O%hL6ziXW-p)I;3gpCq^kmTs!41SlPNOBs zQXzABRk592?)4&~O`tMm{!9q?J2b@n`ww4>kIK@(p&*bjFD)8+V#s#EW*%GM-+VGPfJJjM496G(CYg zJfa5Qpazea{?i?HaQ({ArP++1fX2lC?J7R0u>05RR|Vc&4cd>JFs^@Fi2Mn5*-=Ah z^lm5>Ni04*-t^=4-C--9O3fb0vAoQXlcZ6XJD0RBUGyJ zx~e<;JN)N)34dmVarBjro=}ng(mm%r%uQE0iv3AQAmKFsEL!AQY^Bs;tvFd%%ixUL(ox4u{K^6QM|ZiPvCN+;{rD)qF!n}_?i6^5lJ4@z!L z918|vEQ;q!&E_AP%@wib=PhWrdQwqxiE$7kZ!X_K8EQolNbbQ1V|i>5<87(B_1BBu z8_C;Yw1igCPQOEsoy8u5J@t-3l^`-@eH_r}O#AQ8bT^8u+TW1Dx*!QdXa?-^2i(}w zJ7I22SpOFKRh9+%mkc}Mj~sTwo01mj-Km5()|=wR$gxIeN$}~*HcHfBse8P4#Q4mr z5i4ndJ2klJH=xUW47EEQ!)72I6e$B5Mlxf4&G-YuR-qplGGjU}d4mBVCp)YzZSVr{UG@ns=VKm`{-7Gr^^% zGL`$%5*s0hn9hyA0gHd}3hmbxMF0a3`+YoJFZ$I@edk+HB^RNb25nNca0~GB@!ZiS zKu%CunU2G)1PmI9`gkK@to!empGkxNQ)U;f*&tbY`4ezIxhWvUuh0#0>ZM+1^){S# zBFz2_medx)SGf;a>pBnx$$pP0^N9Vb$0dO2$5+BC`W%& zbQ9#3|F%m>V=Y|^cbP#=AoSucMViA`13ZU}0CR#@UZye=01F%Rso%C+qoR!x$MwHP zpm3ozl5jPDFDslwE0g`dSNJ$1YLhCbJ=qltY~VKsl_}F|?-~T-ysPDga8ZN$+IB-v zMb7izXg-1m-*(eK;C~9vaoA1tlSDxpKD}t?%;y5G^&P`i$?8uQE1dJ>wgU`=l3pe{ znt>iy{#yB8$YuC>!u?^Y^&PR@$`w-hwGv@>9*}5MmdJ>8Kp8r2f70|8*gdNHz%ls- zBnVvzam~I3X}`Mx8s2S48I0~*4|aTZd@{**^mpbP9`E?HaDOP$1@3nfMs2K-BKSY8 zFN=}%FK0M4*o9FN+L&qq&zMnQ!^6|7glNm-@wBzj`i=$%F@%6K>#}?Il^-ew2dONQ zn5a6N!=l3dMEjkOW-KK;xe_){y60}N9KNeA^BL<5TuRQ*ALL0(@^CIX%Bop6-Ul1G zx$(;ww_lf3i9n@tnPRt9=5KgE2$9Crh;9}aagVfMZI%&92XQXY(wnOoIS3oahED@` z28^ulmqg2z!DSN$wXz~a7=#}GpFz0v=p=TPOLb`n2<7&Q+rN3(m+hUI30HXW@yPXi z!rE#8tAeq995=-1%edW|EE%MaEI~b8lHRC(d6=oxecaF>K=02rghuwYeZk^*De>*$ z8rkbym;jUaUh79s(u$(ir<+qNFP#4b6SRp;#2ZZ+b|$+2P@gciJ_4oS zR&mk^x&@Un078rW&j?>|KKvzg`rl(|(%!cnxE0s7MYS_Ob$X%aGRwj`f#|xL6CT5u zDaE1b{!O=;=V=oSDYr)A-Ko0rPPxYorAxoNZHBDQm*}qJFpG~b-(>Sj>z?u*>9bSM zmTjZ|6F-EGIJ|d#T)K4IL&C@V)luZ_RxfG-paF4=TOgx5C>9s8bvy2^QyZcrsMdISlmhp6G`~HK%Uz=Q$AeRfFC4}+omM&%B9@*?IUg%2Yk_I$-us>5WtjxHZ6^* zdC7yRG5_|9_0j0n3D#@AHszR8*t$1Bm;oV*OM(q3Sv9@>t`R?-?9F-!%6||KWI@d~ ziUguJik{P2S_WJH_cvmOEtrWAF6;9#{F0EtT?vPI&9^N_-K_th-LC44+5hAtE~)(P zZa9D4O1i{b1a6KVs5 z=zfyOvdm23y79A%B!`zevMkksfLSwsH#1Svc9Z^3L#q0Zqh)oktiJp+$Md92Qcxd} z?|)-6Ge1A?kDK#H<^pn`T@6TdJJ|N2c%kG!lgns!j?NI&BOeF=Mmjq|dbjsi_;vurh zo<@;(R#s$qNAUPxwSNYk5IKqYD2%Q7mgy9CU$dZDgNW&kG8(ZwpuQ(-A~M@Eh*8(E zlEZHt;E*1V;2)c_YU#K02}xd;<(DDiPCD40L8-QoR}GK*ll@M*%N(0#zjc^hy=J_> zvq*+RJzK4hj}KR$QNU?%Z3Vo&kzJfv+d_zzslbb$b0cpM;&~u+=l@>F*1V3et&V7C zu@L2{XK;zFKq(N|LIEEA)Nlie0;zc&FbpGH$m$}`i zhC7-!UsM&LkH1wkth_e7PwzCA?VmQADza%Zs8WP0sMPrylMX#yTkIhmhDW=B`mQJw z67o2@h12S9=2>l5SDtVT(PWGcRk8;07$SnqO`ugj`Hb03ScxJr@>cS#jJ|)^ybqmO zBFp~)F?u{K5uBkm_)G0&_@3pLkq^X1!mH4WPLN>5TVqEhZ%UFAAKd1wo4!B@KR|T9 z3hMn!DhYpTvR&Z+e3fD+d@4SSvt)h9+y7&EJ+j$lvRucbdbFy}(KeFd+wa$}Zb>*u z+HtK0&nrHrw0X)^SwCP*u&@zaJ%LL}{*~-nn$^nMOW@E zmspR#&Z+bx1J+vN8R~><;iLJJx26?oRT_~Jl_dYuYy2s7 zfZR5Gf(M*2z+KY7t}1#jvyGM5Q;dlYG8Jq`lOUs4PC=})f8{NfaMMY%eD>eo21*eW zc5RPjj}d>Mnod`-__~h8|Do$GprY!&@L{Dpq(s!ALr_FIhwc)T5M(F;MM6-(z)MR? zgMiW@BA}q40uCWcC}mJ8Eg-0X!~ipMzjJ5s{r~>!`@VJ7yBy}7xM$8jdq4Ym_Sv_x zG5cx0x19D0L&`&H_)lLN?NK+nb1jGgfpZ8#Sv z^1-mdYeWgNFsu)wEdBA8)!*;s^>3Qez;W0S^Ev~+NlYCz-u4800dj^sgLeS5^p5K_ zOcUL)9c|k<<4ZyG(iu^Aw7j_=DnyGZEVD8{^C(!D0hMS-4eL5U9C#tn-l(XD4j$=9 zrz4z}psw%sZR<7m7slekX#lc*p)exwHVIw$f=mz<#g=0B+Cu>`7SzXj) zp&+I)-w$lVNOKWYRK{$G>KYp6>Jtw@7pM5PX6kPm^Mq5o&AuDoAle62^?@k)8!V^a zc7Ahtj(bvWDg1fP7!zE0*6FvU;(N;LuP={m-t*y0=yl#0en&h-O|<%5lb z)0O$0@JkO^3=+6N)l0TX6if^Cu$;I{E$+$*43z_ap58S)jfD*q=FP?$^IUi^s(&d8 z%1B^(bgS(%qy{(>TmACC(dEnI&n`FuKI%@zNZv9lDRv`*bOvuF(h3yxtf<^aQu+`V z=>2vf6fo61JYAa!mv2m{aO|YZsgeP7$wJyDNGISb{X}Dz4PF8-D316qtEWAy|1ZI( zEu;AF;ak*~`0yW4+H<6HY)NG~R-p3tyKn)#$6vP&^h^?R0eO zXp<^BPjCCKbBjF&lO6Byo z-*j@Laq&TZ{?DKrBF!)dhcf!(z8LJKldyeHt-ynn^yUx7H#Js{CA~Jh;~``J($U)p zQ(!WGsp-jPAh!=AUe>2qXVoc!XE3TU-!X} zjZ+=Lp)=meiOpDTR)7{kNLjGx6g$kg;Ma5a1iPCSlxho(qLJ27La`VdZOm15s_jgX zCLm(Stl`5NeM8C6D>GgA>c)9<)%uHLsBO-kQJZ~6F^{4Hr~32li&@3GaS zRwzgTIN};l6L`92-_rr=vWNLn3e%n?>NI_LTz}7+b&!%+owj&OqBkS-nR#UL{}D9F zB4#ul6=Nb78Ojb6JZ}7;lhwzBSmKX#0rD;bk5j)5O!P3-A^c30WTZx;J8t$yWhhG8 zxyN9k_TT6~3w=9!M5KV!qNFj}tfJ|ot5D|obk`1ZQ^#~y)zuTcFLn~2b4mwa>o&KL z&2wCGJ8ttvAtvPY#6pP>;R7CDy6rgA*YbYrUaj4)MDr49V6Z{SQw~9oro7kK@fMr> z#QPV22)pu-Xb@;-7FQZz#Wq(%q<_WRS8JplZ7zI>T+7S8v>5SX)N!Cv0 z9gIlwln{7RIChpL5v_dvEnB8^x@@RpXM>Q(+1PB~(GpVHizFAucZ9N4f>i=pP_gUlM8W;LeeF|m4On|7OrXaV9|-{&{i z=D9mj0CZh6^P5yRj~Rb(f1%Ncmw9$s&d&d9-07!(Fbx`dr_Cq7AAPb3^=7{QqgBu? zdo{JD@##)^N3@5|D+jT!SMJWgyIl9Up#g;u+@O8XfrxsaY7%urE&xB=HR^l_e?D`t zVZast_!T`Y?jV(a$}Vec3L|i?L!sl;=om!OWq9x=^!tft?GF%G0q7ztoGX9Y4x1{0 zqaS{rolfsj*TRVWH@mn^N~6wary>%_J?P}Sx3P(rAE5skQwG~W0fQFs6QuDCbbSfB zDgO8&{DZ!^Le)0L)lSr!f>D<8vx{h|iXVd^%>|DOXDag+K1z4QdY*UsRJAjguYAtt zk}7(@HuC{$_SOA+_74V+0i#%ppHdy0hECD$RmI+?eTc2w|6kqNBt|}tf@r^1!>-NW zK52PzA#)6=eM7c%4WIqM#5q50z?_~nErd$2EX$?GNlar?3I*xkdWtrEO%jugu*t}< z0dlA=#G+RB@W+ETflxK>=SA2O(!g30LYvgB6qNr`eK#?Zsc2dn^0}re@*p)#LD@+% z=T&*SmBQ;&5BORi!urOxQ7i=?VfNQU{j`^cj3_mrjB*8yMe7|mp!50-#~O_2Eeqi$ zjxs#CaxCLipEmyNroZ4bEUAFMpojev<}=%zNf|XyIwv=YfV-GNO^ks?m?tsO{6Hi8 z(-#*+cYLovgK@{iCCWJd91l=R>+A(>iorxlu?J1QZXQ4(^QdV%*GU2}Y<$Gjkc0eWZ4^n-W%CarQYWH~ zB6SCxfe30aI~s2Z?hmA@sBrxF^LcsvcvieRO&5sE2-7Bw8+NqNzjPJR63BvS)M1PO z-W%{aEqzo@VoOU+=YRBb57@R3K~*KL@gK4)I_bU-G_!1&X`igKgzuUiq_ia}Au8gs1=4=F`g*Em%f76#pO zR#x|_e7|@cOoJY-iA9=zYC`@#2OSv+0(1yzrsZ-)=tck^zW$NtkiN~H_ku$C-cvKM|4w0`AAS^8PTShEVJ>j!b`fTW`82F zy-JTHR#>;`@H#{?eZ*8hRbWheZW8m0v5nCQF}<}&s<(70DktlrAe&~gq%T4xLXGnb z{0*SHME!MTymeL>)Pn*AeEsb-;29GCqar<58f)&t$fufkZm%4-U=+d<||KA9W~sc_UQxT@qY=_V~|h z5SHpw8)>M7j3K-W9UAk0Isg7|VkTTmZGBcJ)ZRae8 zIs!&|&zXZR12iCCZ7oPTc#MDVG^+ImJF(@571_Nm_;nt=&wHugv3QM&q~y%J@+d|? zFS-N_MoAos>|+IFcbYEm(TH+xCm(FS*F;1+WV}V4vz0qHRD2I{!Y4kD%GYmBA9Ct; z$Tx%3fo2N-AI+5Y?{@yN*L)vd^xgkDNBo`J`7wa?7EyE_Pq}C!^X#`p#A19#(1!Zf zJIoIc%zfFPOWv2ze0%swVoFpw2CNtvK=mXmN`cxYG2momII3O20sybVM{qIUQ>bG4 zfwk3wa*{tqLkfGPhUBXUelw%)1;DnXQa!|bHuS~26_9(6xZ!=c$9SQ@OYZnp5k5%m z>roHluOGNH(Uthp{NLt6u78Y1FQKzjxC(!SLO;*6&{Sl;mI3`6-*s0l67B@hZvWP2 zK06tjS?fTP_^-`?vGEoGBv^g|%rJRvekNCy9uo&;CN2g8)6BT4<$c5qKp&Bg+;{@r z6yejU2@;Rn{7*9iq+SxB-9)wH-5JRnC0iy>Qw#WAm>0IZtm~}}1!ejB8jdBJ-539P zkWjYFMoH$RkroUORpf>rJxaYH(-@A8tVfQn%SsE;0gdA0K|W%&mot&?@5DVghUl*a zDS!2qg*^j7(6~1G^}+9q;|yjsZD&fVS_03yTYCqNz5Mcg*l$_=BSh*Bkbc{2cBrE7 z{-QD;)^%d|Q(4Q5)9?DQoco3qbN8hm|KAeo?EkNXTEyZL8|}sx z!--3dzfEwX-($fNt`8$6Br#BMOB4tddljY_-TUuz;#I!2%1ljo+#_PR<5Mq9aDDov zaj2qYDHJq*?oY(Z!iAO%A7j!eNE>hY22$8SX4P;NAsNcnA0H+hUdrr4tA{J1;nfRf zlb@yFr3yd=Ku4gfUlIqfg$(&LC;C~Ls0haXKB481_k;gA!~d*rvZA^S$f~~rpkD_a z6D@dWHPp}^v7!IEaGXRC-1dI3#HU+(R+vaMFpu+>Lqv9`&)Xzb^^E*kRe%BfqA7PG zON#zdfQAqpI}R;#LP`{br;{0f|KGZ*?9R<@@hoeE-OqssbL-#D4HTHz9=LY(_CvEJ z(~ASIVddbEcdz8Y=O82ftw$QiJ;*Lj7Ss(h6Mp^J8Q=8h!?!4FyncLi{^Rc*Bes{3 zUHZMnhg2K*iZ*guJuEU6GwleU7fpSf0^(9z_C(f+q0mdVm=9$fCIYtJ`9OGsR88(z z-e(}$G8fMrJxJFqKAVCr#6V-E{MWW420}#fl|S}Z2UvD=KzR)4r;0EgpWiW+a%VqN zA3#a!l>5GNKB%pmcl7W_V@@!Clg`A?H0#;yd^C0qbR_C925JfsJ>qrNP{C;bk^sMJPA`17R`qzSQ@vKBuw*6-n~n82m(?}*cpqS z=lklVXzW$i^kwIktZlD-8S$R+4yaSmD5?2qe;_&N>X_?Wt+W&i8;Ix8PCH}c_K(*& zs=JrAcoX_BP9P3wPOL+91%0QF?ruE0L6T4`uIj2b@$`3LK=nMto>go`RkD%1QIkd( z9encTfxA&qj{7k*OH9!>u13&n7al}UP6(+MqQzB<(aWD>YYr|*A1k@SOw9j8;g>SP4UM<8PRJl8Asaz~e zL#k6s(9wZQ0A&AWzVmS^Xh{wYPWorhe$QKUZ!~%0dDQ2DRTa&pPyRK>7NrNCc1=dS zY_tpQI`V8)8CA=T`f0NGOvA%R)R&W7foLXsj4 z%Xjx0IJDQk$gS z(ipCT3rN{u<0at#Nj|UfglflI0o5}_&0$Z0=R?hczL3b^37Bbbl*~Tyj#Rn2J2y8m z{wwupVRG?*C&%T^*++82Y%K4$KQ&{CXKW9(oC`0!^zr$&$qs$)2eito52tI|7kGaQ z+|w9W=Fj6a!%UWdm-hd?bHO$3oC_Rk@bnM3g~3r$_upTRMx?4TgvACNHYM@7fm)t+ zD>~1)9h-Eh#E1#^@c52Slfk$!-};?U11%^;y+?wKaP{}6iXi73_VE7U z*vMhb>sk9}x!|@qD-AVD;ve-pVN;(-wHI`kMMHDm@n=(>tkEjt})fYlX;55u}GA0?n?{}o}Wm!$%t7Bfpp?$r}X;UcUTIoI-ki>rvrIyZL^ z8a?V0iB`xyasG>me?R$fb<2l?X8d!H`J0}9xW0OOs2;6;t6V*ywMt~(g!*_&dX+(> z>X##R6}(lqZ|8;(r+=NICso23(##elY=7n^A20<>^YAfMg;$6DKUxMxD^`{8RYgW~ zuT(6%q$7plkv;&Xg7^=7sE$s91nXH<1NmyIaAG7en&`RmOiCl**ZVZQl@TxCJlY!E%?Qbyq}Iq#ybOJy)@0mCyV#9aPXrJyg^< z_SOALE#)=Uy~EE;rq_u*{2k>0+9x=8A>v|&N;lS=KAx6TpUb{3Y(P3N{hw8q&E8|x zIEeO|h&#;)+-DN;3Am@(;RV(QV(xr<)h%qz&Uk*$FzD@}6$?tE;r={n4Mvi9%t^N* z%~v!O&7pG_@j=7ML3B9VQ3~Sdb?w(kEzmLblf198eKKwJOrXXJK%PC3F^nH0tI*KlPv8d#^(%^T1rE_&@@%YNu@jv z)06}=<;YdcTkLs`GdiS=i*Y;@l|4{!51wg0NS6c%eDXMuDG<^gy3 z^K(nP_IMg66M1mApYh(9w+ChJGru&HtG7x2GI%27gu93TkKrpzsvn+z?7X=CarJA3 z@5|sS0j{zp7RQkgqAOS4dxM+$d91b#o#e6y0hn7kpzl*1q94)f?}yHqdL1zx99J+o z)P_-@#8vH^X9()klsoaspA)Yf$oD_7##0ly#ai{r1HeNGFg`9?Z}eYc7(d;@HUzTmn6j z3XqVPApFa3z8x^QL(F2xJbknFiTT{Vs^{leE-^%kzIJ!5@z_0zORU)Sm2>zi8`QG? zHu&#&aOlSU`T7}fkC>XAg{5w}rQ6C?T%?jM!44T6prRppxhM@%rzI^C>FaIO?;16`~g?{Ys*M{?j7P8 zD0m<-Fp(}@pao4FJ@VFjQ*>qQ%KdY7vzOeS_%6+S9~mx}ZMi%=UMj@%^ETtvZ_bZH zvBXEgzjC+cd3mV!ZR8frne1u>I-s^=E?M|R;2XR2tdoxbsH#0a`K~2V%=?C{zZj_Q}y$u}wCH2Oj8SqpdCcN{pA)cwS-(2%CJ4a;FI7~xPM6HH zU2yz*1b`cF20g3uHS5=C&&|!TTL?%DC_~O1n3fS=O!>gyi21rmyd`mYm3^{s{7k-2Xke_2Oyw@nNR4 zKsFIN9fgy!4+{m49Wv#k6ms0Ki2ei z528lIp9gPa0s;=UCiqUvuBcG0{0LMO%@(_3)~I7D{XT#t>~Gq_9cpW}AFMIfMyc-^ zL!==w^U1%#n|(*Km`u8>&Y&fPcdQ2!;7_nHBn*{7rBF0FEL(6y7*B0wDWz{JfM2vP z5l-U7QtifvEKLh0~nqU|6?(*ao-bsqHknkOjIPl&Wxz|=64S)4wvAQ*RVaX6x{R{b-NSCNEvbw zI=4?6TW)J-7dtK>q6Rz1aq_tAi!3w-6h((Umle85cHLZO~G!i8k>)d@= z?FH=YQP>afQwT0O((X{s6T^BnLd~+p=)@X&U|0xF04sy-f(5|?i#6CAuQP*2R%M|g zpc`Dd&|DF|b`9OpEd|%({~UtCCuc9WhNxmSx?!zp?unR#r&y1fOSNP4>4vm%);NdFJIl3QX|aPG?vcwDwGyT_JwzH_cPL#* z?M4c0K&0Kq%tC9MRJ##)zm&UY*j-X#YDa;821+w34(H`qeScIOPOm2br-R=FEUYkW zt@(jy3)U4C&O^sL3+Z~MAdA0UAiy{aZF*$j|Eysv@P9^?U;cpf_W1={=S5R**Thm# z+B7Po9_kE)o=RU>(ad-3&-$_9 zkFUHvh{0D74dV7a<=++pGZi98;$56zPWNJWPT~O~6(3IP)Rf;Sst)T8<&K*5j&YBD zYFiZ8CUBZYAU5-{^~T_B^^hSg_0ae!mK7pq*VU|sx(~% zw7FBk+5AjmDzVIwjkq!`qbWHhJrKDg_~l?+l4j|GEzTBKAgGJuI3n0(wRvRv3enjC z#}P#*Cy?|+3@JDMPP!|Coov5EdD0ptTeAm4ogQ7sN!w zpeM)i)Y??JDf%NEV37n-Z$jVRNWgR|I8p??(G)&?H?JMljuN#Jq=~zJXEz@$3|)Xt zU_wpqZbkQThHEpdZ^WB$aF)=ye;;1|JxmV&U|{iT*@za zlk?rX9{Qe-SsbI5j2Nf<07(w zYhvokaXyC|_tqbxU|f}5FJET0NZ_oXl4N{$0m>=j^5PJ8!_J~cF^FAt6C*TJ2yZuS+xv>9S>5X{%;e12H)feI-U-(7kyI?a= zsiYuhIjF@tCe-rrcHh3Z)}bXKKjW+XyXbGUXdhHf@7s* z?=DVT3TaVJu}(=(S&%o$Ew<=oi$C8*v`@yeT^3hmtM)fVVi)4|bX!~!@P4USBcj1J zW>9PvY!i8BF+Ad`fxFXWSVQ-Mhq5XZas1$ke)6BB2Ra9|g?ix2E3xMCzEpf^yDn`q z+Y@t8HhiAAh4~5fcXvn2Nx1Iwv*nP9rmmlA?EJ-lHDX`S6GEvN$FQz;_wA4}uDkn~ zdOTY{UbxGW-vI^QvRD2+$!30F<;t-nc*-chz4r{E7;WF76d|R3lpW60+%J6hkFi2u zF>$_(mRew2Z_QxMcIRC$-6Cdzt;-~q`WT0`6tFQ&djXYw`$QeOi>3~%7Lh@a*i}u7 zbElK4LnCpGAMyA{g+hvApDUKlLW1`1?Iqwwu=UZ%t_HJS!}h|zWzz6}!VK21-uO2E zI>keB#Nt(+B&-)+Z^q&f^bwc$c0!J5=&!3d-OmTT|IjVtxG1|)EXMV|PkO52ZfN>5 zy^|w;^^YG0w=+4aoCy8)cp~cvYuR1g@frg2W%QZ${KUlO?%inX=BHo@JE89~r9|3r z%_$d(s3C#p%OT>|-uPWPzO6IOs~(Y(xo5$JYOz`Thh9j6eTj?G-OM|g? z_}`oG$MMH^J9c#T*^{j}4L}viw;Wn>YhfVf6(GMs4>4+vf7fACF^SgC^4(4vpXyQL zBdZRMy+qNn#H0J&yNJk}Og6KZYwZfC2D>Q1C(Ta4Bma5;(G{AT{c-Pg z!#*d5rBP~XZuC;P1{F8)z)m8ED1Vo=$3D-mdEH@+x#2de3=WSEukhcEay#AaA1xg- z-7LmKj8a2ADp+R&zrar@HRL3(z@ED|`E~GQ#0ssr0GOu){)iE^pXoichV~#8YaQCu z>h0B&xtdqIh=L+&P#4j3ljGmC*4I+KM<=4j>aY(*+@Vwk_D30{iP#Ersol#Wl&wl9 z!58r#g`!K(FA~_^q-U_Np;HdzYpkJ#w{=0hK+Qraa9giI%^XzNmjQ>L(l;+WI$4%xSSGLVd2xPOD{{@diZkC)aFv~KBlTxL zPQKr>`05q8nf|*sjvwYS`4+z6InAwByrT!LqHbz0c=P^*N~33!XQf|7uP4XREKV3| z+~5?zPYtHeGuIgLkT&q2lNSyg(cYwGmWrp9atc_p1*-y}%WDHNsAT=b-1a36Zet?> z%|eIe*!&RF&5GfCQH9HU5S5HSbso#lLE*Vy*|o?}M|us*Cff@FJ1N2s#%>yt0%0p7 zvJLL7pgrbi@7?NJ_}-F7{db;c_GYTqsF6-Wg;@m7hf106#iW?hdzZLCriHn7*9M=t zm+^@wk*17_xz=KEE5YgPMz+Wi5-C z^?O5CE>{Y<5_l`EtQ%6ZGKm`mJ)92C1ee|u2@7hupa&aP8pT+y#xJw3#Df1Km!%Qk zOjmya(mw2*O5j?Jn4+#hTr~yC?j7BcH-Fgddx+}W>ed6DD zoyaE88+Q?@&|Sm~!s64+W$7M+QB2HZq0FgD1omcUv$s{(uA-Mj^jSs=TCGxe$a{q< z%o#%$F|_VgYASK6c%}|3E^Wv*io#Ow3W+Jcb5}aG5~R3!TwI*LF}FW+N$_ONYVDCu z_KaiNevPbrD-4E^estiJ3dgvbM*=#G@k!gvi(}WNkM?TXL-PH&<4#MxB)F5kEW3OO z!FFlb1v+zRbEaRK1l3d7%`5R7`K%e7GXLzm`1mmQph*1fG)EF!f4KBX64 zZt44!bGz3rQqsQ{{TJ)uIiE|2-sj{#+1bw2ym}J#hkh=vEdf-zb=a6s>dJ2XK+(8IVvm@W$ob=>Uaa z#GB3dA&%L>>R61=e}5+&`E>{?@%n_D@da+Wue$*^p6Gj?y_Heu`IEwd`y(Qbqe@Op z23h;%eFMKp7*pqQ6O)v}vK8)@mCq-*?qBhxVF{t`uL?PTsWmow+3srBM2;%Gt;(O; z9X+@!RTQ+{PAB2AqO2&IOd=PNU&whXc_q6daz*T(772+=m zkM+t%Ztmm9`*${Vsz^#|4?`Mh6o1WkVDW*;?ETSs*4%Cgk z5~7s;?4jkdzckt)-RLA&QPqL@<}~^9-W3wwl{a&qHqt@F8~N>8G=!m)`cp(x_MhF( z@u5yL(n$(-@fWO|-_~Y4m-4l}jEWMKe7ZW@3Bb|x?o)+5XTVz~B^-wKh z$hd<+Z^}>hh@f`q<-|$T48nevR06fA(JGB{%*_TtpBGQ0yTKlm04a1uV>990X5+Ho z=tS!443IoKknI}eBrzO5qJApBoZWhe<1AWon1u0-R;r?;0j#VA95Z8u#r*e6|#_vAT6DV^6* zS^tb~R8w4y$&uUh0Np7OrQ6p;{3RGZKWbSK)S zD4UXhDxNwRs*6~2!7M9v`E2bvD^3#_a|fklyDS-XvEF-{MQ{l^D~!UkeyPJ2$``33 zVOKvm1rBc!NMl68i7=ekzviN(O*CMw5C@Sb1DLr1l5%8#%*t~c*n1ZIt?BfFc`9sA z4DN(h126!9N(Rb_mW852CLB%bZ%zk?Pf;`7UOHsc>T??D=sor;l&MleP7f-gcf4sPp8d zBCe^Z9{iSe0WYfYA(}%%?{10B9e`7WJY+o9Ohrgmze;Eii~v(l9?s!RAHo*-u{qNL zo`fD^08p7sj|4AYZH>XR9+l_@$#pscu(Z)*nvWVqj99l(Em3kUM$uYyF0U7z-G(?s z5K#8Ie_)&+Hd)}KvMY^6OacMk*4J~DT6?6OtPs{k0JuQfsK{kj{Rlo^1cLw)#(=I4 zE7Yn>CjwI*Hx`PW#>_%v;{?A^Wgl@)NBv>nZ|}cZg)ueLlol5{syJIQYH@GkA`P#j zmwF0BHhU)JgNEQ0rubKa9BL!27gc0WnbED5N?Yj7p+nl-nw9tP8md8Hg4Y0QVHpCr!Vs*t@j|SjTNg|rf9Y{}pCH)g6KinbLTYh@H|ilj z2unQG%w(I^Ine{KE8vkV>0&q^@;jm1e{_ka!=$hZu* zLWuv|u~k!9K6IYTa>61@64>i)DsttFuti?bkDkb$?P!6Jr|wvuZ2kE!G2|nadj|!LjD*BENV*HT#13H?3n`#Q4%up4^7=!_*gFn8lHTJMsZoyhIsTxZ>hS+8BWvtld7W2U4lnf%n$vanzh#jWvuENpYCZWh1yOGVe@&OWD)f+sl-e$lIDu(?|98!drTiMcLd=(_GhKQ zNMKYoQ(%Djv|RPlih-j?IFDQJQefaYm;DxK$XleBco$zMbD7DPwpRS($p$1)gn{6a z0&5ZP$c^5sYiyA+`$bM*cQM}3{OoKog{j`+SknGx-kL&ww6>(tK>Ho%cZn9A zgHo!f=+@r(SGO2_3G*Uaro0>JEjq|M|Y3;%>sLl7m>` zl9b>#(3hL!6jaDwg2dR6!8?LlZ0S2>PYJj~`ZVRse#QEKji_+3m~E8cW~`ShdJUc7 z&bf`!WbNnNgQ}MNg;D<}R{?JQaY9$u6VzxyYPi5|`a`Wvu}SSP3tT4Q$hG8H(ajmN zYR)2Z07$AS!U-Y#7X8l0TLI*c>o~duc_lG5KcheO>e`4 zUFw-|WB!gQx1((@n8IY}1+QB>+9uvyyDCboXbrk5#=x5YWLG!S)pjG2il4di&GQ9m zk^_t@6%{5qK(OWp^p+l80xu5$ih=VViMs5Eq!UKlXI#Y)hz%IU*t-I7mGb$EntG)v zaORs7#I65_zpRrEK?69(5X~7@9MHd{PZuLw6e=afDK4dN2sVvsa68|uWFnET$H zN=~df-y}ru`&6>aV4q*wc-4U=!yBeKK=Wh@u$|^^G8^o z6k;QN1Y9#W#sxkXOb4atuJgc^a89r0n%Mc*X@Y6kA@mRyNucQ$4;J2&Y2>Hk} ztAz=>%ROZ=5Gl+~{y~`1E?EVx@7Jb)7bd9(jX16f#V;vj^nK+u10DliOF4*e+>E&c6gCjtKsVz_U6z z9=h24hH1^3n9Zq<5OzjHG4L^Q>RoU~Ub+uUM56DvS2EQltdmJ$MMq-qTz*JDqKO|9 zwaF0=*ZDr&KBH#AU>Cl?gD#Vj%8YaE47zgF>;=<2o#|O9fRg@QaRxa}?=a2$6u$2=0)U(%2i?Y1+%yCtXtku5cjU(Y-_WK!88C zxROm?Mc`loeeh9H>$72}IGHhSbBv%r4w%#AJ%0bXy#&V`B+9O4Q#bMHD2slA1nXtK zHhe7tM!I1Sf(}u!izx}dgwIpiud0=$U&}t=H%vFc~K!C6T^Nt%Jij|D<%)4dGcKo~9fDOs~jfjTC8#A$|I80(d z1ovwdZ%4hhaQ3+;xRcc7N*j$eq?wXtRpNLeOH=$-oBc2sG0}%_%J0AN=b&Xb04ABO zhKQkrQ5cCosFQ1!5hbl#|C!p@UE^lpv^ZC04Hvlo7o4W|2%K|=M6SB)W(#K+3NW)V zskTobqtc0eD>I~nksF)0-~cReZ@>Zgob+_JO)T|_)nT5KVLV-B2dA3tW(><&T{I^X zsDB8KDW-cK{GAu>bwz(UAntaEqQ}JC=co4@nYkxpq%VcPW-2$zs;{>rrJHy1+cZju zo@qKDlxN3;gPBW(5?VF0T;Rtp?2sXxN#dztf3L#YjJEm?+IK7 zuTgcPbI|mX+XY;3ISxx@zLLecUbZFO1)CZPz)jt~4{#HK0rg4V##Ae5SH#aiX`Cd3d#+M5nY^+Ak}CjgZ#ND^eoiyTI+Ufn9X|* zaFIeJk`6G7i2LiXu0t2{UVt)M(~sH|M|KhSF~`2V0AOGma3`tJq<_|sURUnezxNH zNeG0G@SZ_R2BCQ1NkBy4VUrN=W{J?-ywgo5eM0S;>!GQ_W!8SdHpdOqna-+Z)*&r` zeXMcmr_$kQv=;3#j#|q^@%DnfuZvox($n z!tpD#UF?HGWyT?4R|Ufhr;N5YVN|TE*YWh#&sQ&b~Fm{W(-dyuS|Uw_ogmZ9kqD&VRu|! z3Z=bibk4#N7#yv+fMP0&EY%@FH6@W8R@)2MsL25ZNq-+aJNWWA;zZA+uwzCno|&Nm zPFNX5q~Q{Lt?uCW=hA?UhMf$k%(VU>lsn*}waDlIP#}f8^8cj9{l$Brfy9iYtRM7u zKwefDk%()*UHt2t9I=(Dxg2V`w#2~d5hE$CN#mp=N0Um;n>{t5oO~0_uL6;+3~613hi5am74u7%^P zLofj#JL<4=(8-BhymVW4E)cZQ*YkA9>HTZzF5(HTaJh@TyMknFO5$5nk+AO79aWqG zSP>vE3h>MUff;!?L6k5yi9dW(kk`=h`ezq=J>fW>rbn?D&yt`BD&27~y6b0D&;LRuQcE(O+ zJ4OP71@TTmXGHi5vc|Z-nX^rFN;)ONRmPQJ%A)^}Na{0W`yz4g!-ZoAIbj*55?;j3 zo^q1M)Tmm3Bpw5ORPOGA%!$8M?(NqPz| zIYVAT?hw@zPGVv#KiQprbB+3XM^tpGaA+A> z5(mbM_QZ=+m^CcsouT>OPJu4@RLV%r!f8n}Z#L*(X1qotbv7LxQ|R>5mz$n(v%~v| zBq3Uf&u7K;J9kls9F2+8$aCK6AU4%UC@OY>dhPYsg8d;it9T(2AU85RjZ^}7*~GXQ z!rCBdvb^HTPCTWZ!u=1%>1F9A=JWHpLpRJ^>Z~>uyZCqW4o?k6;z16Au;S^BVk11W zP~_Fpcsf8wOfn#V6fsc6F8%F^_Ze$8T+F883}pEjnC*4}#idOwvaWQ^nqB;`kV+(3 z9A>fyYMElmEKtML=b-++Zh2S|Xa;#>Qc_*PdYlKJy!x=Mc_iX@dK{BYXSr$Yla+pB zw`e)8h7Z~!lhrPHV-c={e({U()84-nlu#^BE^2c#@jnf#{yj_Wt zo$CJ;gKT4hKf}4{dERSsB{#Qx@#pSIN{O$3iOsGNb^V}HG<8W(;Xtzl{c>-Vyqc4g zai)*R#nF-HU*Bmb+tCOd{lnAa%=#yBBkx|Lx1G|t>6>qCg%z}mEN#Hl-IH4#+#j`i zPqMF~7K(@lMjVXnb+JG^L3kJGxrmiJJjwgd(L>=!Kk+<$Yj~MTP!HRVZHGAp+6xxg zw6f~{uz}mKkAn!JTS$K~N*!pU$mqB8$bGgB#B@5t9+O(V%LzZwMdA^9r3>ggW5&q@P5-Ef7~rVsmoJeLwr!w&8TLk@)l(nejAICZCBU zLa`nECK9R=%G;4XQ_^gL15tAA`CyTFw)ua{esRDbYnVFlM2JQ(F5oLCh*l?456Bq4 zSN*z$7uc5E#Q@-~_(k-S>P>NCS9iC}(-@}aZ>H^Bv-2hL<@(G4`mO^VR~YoyLlwUC z7q6JM4jq2-1M}1PyP~X$+qFqSM+;XyMP?~Zj^J+!OIZ#lN} zoJ>~%vI4+8%|5D+?GsM2Y20?P1x1P%ppbK=N+NdlN+))EnW{DOGl@89AG{b&X%r^IsJI zXrr6?g5Cp5CCIJ!le+Ada#O{xG8-kE`*9YTT%Kdsf&?3W#@d|4F0j_nHAiArQC&rQ z-d#tTpTKzNe;LZTNO9l@wob4|Xhk{ejD~wfyCJbF1*vlS$?Px-CE(2vK_y;80Bn=m z{i;*8WLB2e3d!67K|9E9NJXY;Nq-`IEpHmzq0>aqNQ0}7t_Y2!d}U?4+YO(9n-EbM zwt{b(o1Z8(Vy6sg%8iwfy2p$??n_(5L1h{9OH4iIh3Em}%?`#lHbprzHt>AIy6 z1!)NFqz`=H9Ze@~Da)LDzzrhR55ips06C$h0IC|;O98(!k!mWe17tKLp(PastsZP+ z)O*|_Ap&!ByY?AxgP((3>jj)VqU#4Ir_MsOYH9HK8P=Hemchb*k~p9zhRBk5`Km#L z&?|Q6JI>A5{O*)-K{$_krE}12di1sBywc+lZMX9-=(TWoOb%UHT=F0^?5Wnj{G4}- z4s5!+&zgOgzCWD=mQb4b_pZPOnvRNrm4iQnrSif%ol2qG3EthP1N{r^QIGd5n08L< z8P#Cxd1PNs)xv7{Yah)N`R?A>$Q(R@_0O7}kOnt0Vx>u~Y+vN^%wsq6LO*Mz~Ev%Oh0`boJssS7a< zO&$;Ou>SeGAw%zcJL9=*V88RQZLQNc=EF(@`5POh!NU!k6t@P7les0qGfRPo`v3$9O~1ju*3S%`N-Ji!&Zuzk*G^ zc^W>mY^^OT^F4Ae^p4J4PiF93Z|1e7FSWEj$KM>d5c~m7P0d+6X8UMX!SDNW!{za{ zdIFY9(nfbhzkK}3{%dZl$-NsjpRtNqXvo{o7YYh$P%XXY-3Z^_Ro;xQp2VJ&v~8-L z@4Hf9PJB}>W|wxIFU2VWSp@Ar!p#UglHT z#*V`0uuhc2zq=Z0u9Up3G0r?z^YCHU@;yC9o>!ID6|XMzW)m)NTkvKrm!`oPi$f6h z?Dp^~ZpN|V?t`z7&Q^U*5zyJZMCi@u(@ppB#$(o4RpKuh&-ACB`^%8@O~@%oEkk9x z@dY<=yLt-cb~y>UQG~iuKZ`Y}6@K@79s3A2AsP{Z)l8_LCeDMxgXVvQ2Qpq;HBS2) z4DbEDL%$ra{D8%PpmJbU^BS+_HR8huo9nHq{!+){2&Io2Op*CoaGai7Z+K-pc_GE-YZ%A`X|HRzaPG@1!(_)Y`3vjN~DId5GEbF zB^VaG@#u4t9`VaVr;hXKbe8vJd{llfO_+xm+*E;o#jQ17nbEI7&+fa>VQry4_oFYT zaeT$r+_z>aX`j;#bD|oq-{#IW%f~@?;F1#*#eL{MYPQcWot+ufguZCyk40!!45m;Q z5N2eT;f6T~+>np0RkdsO51(J+m5=59&a$R)^zbq-u9k-~Wc=BczdTa@LI#N^Z?o3@ zYV3oSOJIiW@n=RH!8^PaO~*2;mY>1W_s|nbH#|5gI2wP2p$!`wevQ2nTEX_czE*8= z*+BD4uu1l-%8%tQ@%Hx~Mn;DUuR{&OlM^%>OHFqlsJtpS%YAifciHX2aLxH84axhz zM9p*E_rIJvQr3vQwY~QHg`$rkSz^X-DWYq26ZoPMc7N91_a)~?Xzb$la%~l73;H!( z?swMRa38NlYm)kn$!p>@T=aFeffhj5QVU9Ds_ZGx+MRde;9#!Wgz-smrAPik6(ae2~-@r7^!@p?V zF1>!z*0u<0l!GwL4{>i($w^%CY}2isxo^}rX z{e1eNo^s!xf1VJK7a z$RPIU&_GMjjm`@_-!uR!+#GCa2;D;ZuJqRRmDl!qNN5dHD0IBD8~$wQqw>9j+_p;9WwcfP@v8G;$d*5-b`Ja_1OkU;>xi$dxU2YfbL2CMH+HZ4lpJ^pFNEmWB*)y+gFyH9W>)S`z2btNo8v4Ef!oOyT5U(& zmnqm=9Cc?1v?)drf4ns{^8Sih=R>hvaH7QSOs+;RLD}0A3vP^kuW!a;tOGXQsZ#IT zHqJGXc0wVyksf$n@YHrGwWg>Sb__O$lOmCdbGqwGv=9=~i=I0Z$pe^~XT2`K` z_1pKzrkW=&Y$_Zcedd1Kq>lqk!@+zO7K&+BbxArvb!Fm6_f#WQ!B_gC_px4b+Gn$o3^BRx3;6LDkijflvm9C_|&^C~HUSXFN1l9I{rA`)XI9~afU*)Vo zuymyI@5QhV6a%pGx7w$PbF`bP%r6kUZ2`Ujb&_1!$hRr_u(8&F9pATe+wqUuq=J>N zfSWs(6L!8mUs74Jb`E5z3m*aM%Q?@KnE+TCqSsl_#u28#uTwqJ0Y zdUsu5lqrAI`Dc&2vw;U*<~`>AU(gm9TcgoW#C(qDo+IIad%rCS9uabXbs^)w)FbN);AsRuG$tD~^cL;0G3nenp($#MXg_8GUZIK_B!}uwIg|+L`f+ zbctn8x~RAVdTjutZ4|QMaJa98x}I5byFaZAoE8eq-2AijpU|MUnP%4Fd_<_!+CswE zx2p1n_rfjY=vsuk}y$A76sbM-aA(q)5g!NSkc?_&|ym9bzWH3W<4 zEPEWv#URjwQd9?ZWCM3*J6s;UWox=>s&Vu=8}8L?eih7q15bxj!$U-FF!+oR;!zQ? z4K&((y_^c;>ZLJgZ>26^QI6zNj%Y%**u9{!-uFBCu`u6kWv}?}2S+%YAVkn!2VY&K zOJAKV930jl&H4p@oD-+@E+10O_VvlIy++FI8PJhxx{vV@PVviC6;5fc=si=HLf`n6 z3|2%mfdG!gN6`g1HYSfi+)4_;lq^Y&`F%s_9;j%~NhM@V!wtxiC)Z9IBzrJIVJnGB z*J<=xm+6mdmoIC%{g9ZfhjbZ5Ox_pL|8^m`A=9jt8b!+b6U8=>HTl;_)rL%O8+k^g z%e*Ia?;&$9u{fmDYtI^yYz6xdcp`&UO4MVTSbAil1|T_#Y^eGJWOi<&-xSB$AeJ(Msay1C=MV`<%Hn0JI zX$CKD9)f-O%%>2}*!$qHXw#RXG3Wiou>!`#{yZ2Y$&%vpCFGF`uR_|^D zN$ii;WY&PF9;87Ue;lcK3N`lKB9M_8dYf)ZbEjAq<%?FwZ?O|s6BP%wBu2(PL;ol# zGu1zpR`3Z;LTn9cl05omY1%9=ekg{cfv4Ca{VPhh>hDett-k%?k5IQQwH+tt;WLDy zu_%^$zd_BukLzo&&yr@7sQtyFF^&oYFVk zU`X9DK3Y8*@Jq_)NcUElUb;2qZ$q`ClpAKql!cRyvH2H*Rxvxio64S-TLEp*E`02L zSzzh3=fH=67Mq|c#RCd5bP#9Pm(F7ScOzCLth>~jQ7F9vw7%}OGpL4BzXlFAIL)2g zl`R+lm#;v$mi^VC=92f8&n|InnOWNTd?~glF0?tO1nn%uiYVE0(4OvT;4{+$xJ9Y$ z*qQoc>+W46<-n~O&)#!L0gWaQ89`i6&MH+dvBB-sl4R1rt(*UZ?*q62HDGI!i@q^e z?!@VRgI9O>rDgb$&Jg@O;IwK9X3&NylIdIEosaGC5ek0wM3UC?u%V0v{;?JRY>4TZ zJVhZ{YJZ7^|ois7*|JO7lw8& zM&@@KIT6CJza1h9?;r=}l>Z<9qED*65EqE~-{uP_Kk`bD^?XRdQJxALf zck*J`E3v8D1Tx9jdGdp1_=juVvSb5Nj3U3EZ{-}7G~q{XB!BqjKHC^_F!%GD*<2+o z{e26Dl3H3~V8OeSVI56A=|gx1D9?Jw%*9yY2q7E(q}5JAFMpmrtF7&s>V_F=V8cv( zhECx#?f-b74#x z;lv(ENL*`Gq4bzE;lSD*u6RP(ZR6N^v6K=d{h`L&<;}~j3N{M0kUObU_MzH>wP0a=I)q|bQa#XTA>o!<-j7XV>O!umcN`reW_!dTd1M`{l z^-jCr#+lCu*7|<+qpi)J=jUOJ7Egd31nQ&OdE8C5!*aUwWgVeJ+V6jk1r~v=UQ}HA zGM3VpI<@B=1kr4;Z&%NboB|Kq?Z~<+o4tWZ9012 z&q?e~;k*624a&rZRDL3@W*AK86HL8mF|!@sEyOt5G{Lg$zn;?0)x6jxv!BHlCDTtR zW3)37eT{-M@W;^e@xQWF+|va$p8sr$p`UZ2RlOjoE7H8P3LE%#8=R*(!d#gb4fi|; z7sU?M%6+X#JmEuvpTULHTa|X6p4G*c<;}%$rrut^4-|JJ^r%MoF+RlmyP$iSz}nMj z&85z$!X?RvTKBWt*iYrU*9FaobhTPKqC3rl!AWn}Co*AgX~|Dra2|M!{B@S*5rBVQ z2?PtYB)IGj)NEOha3AOyZH*giqS35#eX`BAPe~1;BiKK}$$Gp47w~+}bo1t*DL?tI zALjXHsi#`7coW6y-5fNmQ?!My^RIYZf|vHo$^s`(XvdBB=Cv=V#c zmc~29Y-#bopHn33I#SIW?t&vGY6HTrw%&z#)nFpjq9>Iy@&No!|JL&tMPu1{Je46N z*O=f*hsfzO%JbOX3qxfL_$l{7Cxu# z&UQ#ReJ%QZmWT*cItpe#Bv1=uz6q`lWU~p#&q>=H3%@4e-QBO(SQ~dw(8p-aI4YCl z7lxAh=Jtk?25hd=G$(Ck9dP>6S0sIrzVMs~lH2R=Qym%F;d^k`Y!PNk z<&T`eM^RN&gg_K+!VPK~qx{YGs{e)zv)&-BHV|-5}Lzt`R#AHzF)b%~;R9CHS;MA8+ zbUQbPIzdYcK&z|@F_~8TZ%&j41!2OK3Em|3Z7&YW=r2RdO)y1c|Lb0g{AUe;nmdYM z*_PyYI7|3{(ZVkj3J9p)_VD4c)FFCSwd4)L9+kAhhN@f#Ra%e zXdBF_^1&SWk!Nm8L5o0{eQEuk?E&vv3ePp0(7&>_YZ4}z4m{9E=*oUOHz9}o`_wq~ zX{{@^h1M%aOzz1S#(*vEWj;4j%>h?>f5Nq)g($I4N>!9n>uLqz`8+Xyi<8$DAj8i$ z&%O5?U7bv%qrfY`@mvrh6ntlCE>?aSuq=0lXT}7nPwhH>E%O45mLHF{6Oi~gk@hvv ztvk%-UMA!Cv;0sF?Ol7jS(Lgnj+tpp#*UbK)G>t|NX7sg-+qrLZ*v^s5<#>E&bF-x zBPzaokNI52Z=f+0qO)3sIjKR_oe@;hkudk2unQ8d)OE8Ky})?Za1+Q7YxA0WPHKQf zX2HVNv}XrukmQ+9E^I@gF%7Zg1kuGqVtjdh;P%rqVOu}CW|c7!2UXJuX0)CVaL2^m zvS_@y`>A!ONAk|AO4`Ncw`{hVdKS$WqxxOb6`^x7=pO|r#)F_m} zeDzSMhP{T)8a7*1wvi;MH-GrKxx=5efG3-XrNaFX+7ozK0)KC!%95i2 zbm-ouT&YQko$U;@zX?Pc-xEXrU0KL=5;z&XqwKY#K4anq4k*ug#J;_meRR+#X_Zvv z*bujm>lRPBp7Y>`&ZQbpiuEM_0W`bZE=INhYxnYa=8R9?qc%qJjUh?%Vl9Md75T37 zMe6x9he=sw&-vw!7eHk&1wD;udTu;hliLJ-s2J(K?7kb2zH$xlKT$k5176%)jhD<_ zOQz>t_R~1ISQ)ldXV?a~!gMed)E*d+c$x**fo@Mid;;U_{AQNxU$)%Hxf3Aj(a2s= z7yoUZL|cq|-kC0{UUpBROTTFU$v&_DA!EPp2OeJD^Io2eXAIL&pMe62Bs-G$OatS2 zVLE%67|q)0vXkWz)7hx*Lv(XjM9*oxFTaph)NdKV;pT_Ny&fCT#eEgOzqip=_0)W_ zTFKmkC+*$QCV7t?asJ`Uo(ITV`Vm{nOs+SZxhqr{g5w89Rsf^O+E>WGVD2w?E*MQ- zA{k|%zWjo#cfpt7W90JUFyy?+#Z+1X<;ytTwP525|58KU zoO?05u{gfp2nUFR+k)wa(Qz`w9k)PexL+Uzojy`L{#%q8D!TTb{+#V(zcYxu?(;Sj zjKK#!241rANYB_hLCINPTG{gTqbaSTUsEKEePI#1QNl%(wy58o#`l`fkyg`fnAKo( zL&K$QTD*)8x9G*GPIRj-8Fo9HI_Bc~j(KP4)1=OA;KHAKWn`zhYuAZ$l6BAM3tKjr zJagt%;}ht5`u;UAx#6?aIYZ}CNg+EQYdJ58gDD~!ox-s#@*+zb0*Sp*p5p*FL$Jy{ z>c*NS*RHJtx-B$aSJVu7fUeQ5EG3NY8ba<;_czP$J_Gn$Ovb%8Poos)g|M<1f_aK}V95@7|kE?Pq2xb7+YU)EenqRaI@ zg8?ov3Fib+L(1kOj_$657NgM={)@kaaUZr<4_*-){MWaVe`7B_=`gRSsBvf)p*0Vn z3=g?Jzc>(!%&4j3h+-+ujLP6Epg={Ji@sm8EOU%`X}UO9)-I_^dHWocf^E3%9^GK% za_H1~cxe+EABB+*wp3T9Yds59%DuTKZb_5+CO$tI$pm2$%a8DsVO%L?ds;ZECCzj4 zSbb&lSeb*2Kgube^MkyY_M}gmr&dSyQ9~@iuSLkfq6>E_&NHMEMoTc=ovUq~7Y{Rn zFrl(#$WxgM#Rz)@Q|^TJ`q$ELz1x$^SJLLx$WW!;*keVl7`!X!KHiw+{z7YUn9MOE z??57Mmg`bx;2aoG)1|MzDgsD%pZ|Gcf&uv;o|3RQYA7BZut#XYrW^;PXtNf_-$Vb6uUk7E$tU53&mc7eK`VPd1c-^*#EUin9=RKSf1~Y zD3d}Y7Mhd&s-^BCB}H}d^28T+x5N7)b8o-FQn#Q&{YgYjE618ONz#w@Gq%| zm==M)MmxbG$hY>%*rU&2!jv<|t%`*(nhH<=NPWTTn*Ip>Rm zye!$Zb$f_Y9=(^w_jp`(WC-w^bxZvi%HT9wX&{?}@-`0+H%6O|rWNMA(3($q!a#h3 zCLOQ4zeDkHJ6VVgy>PjrXrji`+6rkl&Oj~uRRc6Fd`CrnQ3fMzD?vTB;W}iKYazj~M!{eQWTS-Zl&$N-q2Ml>A#+1izXudle)ht%@eID# z+qkl_1n&L7;K_dMKDd&BsH96B-Y^}8Tfu86XEoBehV#9~{k95hKejn%$!dbLuM1Hi zD@6Td1O`6drkp3sKOI#J98g*PxM}_Canxxecs&W$h-xR9jswEcFN#HXU3O;cARnIh zE@N6_g|-HX!bCpmt23dq($@VBGE%gk3Vg495TdYOzbAvup*f+}VkY;lS~jaQIJ?zO z*oO`%%eMmRT2gINjkcPR9!2waGvKovIQS?shHiW0k7+khpr?_rsy?zr|Bk%hC%Z^z zZ$HHxHCvR!+IWvW{Ay{$e0DhgC7=Dz)5h|~7l_Ktu=`Y>xA*)nd%|NT=7TqlnR4)Y zhOg)179iLTCZ>G=f?c`crRUonmtg@9Ff?9Ex7xdyGfFBVt1TTW&T4l@3|?xA*vBMV zDb8iemmj>G+%AP~z?W0UgUddq=9AuG!J!Bf)?niL{otZZ&PX-Fb2Gah+WE;-4E)1E56`|=8ux}iR zk|fO35F?A&lHz8=!63?t&hi26wv9(-4`z4+mnf=Yl8N=j&*2}^NxxZ50vi9AUq-Y_ z$TS{L^Ur3FwnUph2A&zI*Dk&;_J%~Ir(WTPe|V@v1^*)zTa^Cg&+8ZM9DVx~&iG2@ zQ9Sp=ot}H3YzN4j6A$hCFI-$-bHzlhG}buyhf)Uzb`vBN80<9RF;%>hB-kDXho(0D zxnuy?caS_?eogYExCH2xKM-x$sRR7gDe{hn^3DIHsTF?N zw2~Z#vG4!+g%dyGHb$rT+;k{X>wME^aE+wo?Bn(SuA!9&-Ig^%j~(Gbo|C(ecD69c zXKA>*BY>B-L|E_UkBb;ktdzq?!*ug>*2D2#gXa_vHO5Ppiy7Oi%kfXKTqdsJCWbejoxd@>I8wymbP}nr9UZi!oi%xO3u0%_Rs?d#8`eW_7Qk~X( zFVtvoJ#5k7mEUjDs(`y-#Doq?gWk%EJ0Nkq`M^q*Syb3xaUb7-FPWlkD`rOb!q|?lq{QN31l-zj4rHX`XLA3y3qZ_B z=VA30OT()%@&gKOA~g-V-29@&pfw?1GMQe&QLyfl?FZs|kskQ!j9@Fwr@HvX={I`f zn#f=1k;Hwl3uaj>iF`g>YG3y}mI~XI^%}?)G~Uu1e5}rfpkH*pdYnKK6&#+yl+N^I zGK~*@Ed5DT$&XJXk(nObCbO^}!jFEM%`IfUNVk4)1Xhq)*z7%L%1TdhU}z#=K4txI z)m+`unu5{;zYx4=Se7yMdqgAV0U^cz2j9CjP#yd2>3<0iMUSXf_J{aGQ8&pI6aleE zV`$a{CENpzX}Ez_`(0{!3v3uZdUY5jk`tWjsLu7~HVuA_Ay*U=t_e1qEMgLOx?B4C zSXti~v{iTCz2bPjE#F~3r@VdR?w+{kZ-E$H!okGBId+|cEU}l1)k#h{Jp--uAFFK+ zY^{a!He}v7&gLL!UGJ?j_S-$lfdI?{Dw7>|eC0*=g?P_j{+o_PQR=soqn~L`uk-FVjvJ zaTSlGF(m51FZhXKfNqaN*2?<7t7x52&c=kuFPf|PA6PqA-%ydM zvjTUj8@5pPkZH&C;?i1WMRyrpy|F!RiH8+3ibQ{0M5U92Iy3#gU+>J&{OMqhgVljs zuYM`)d=*dmEoo;XsDX*teAd;kQ5}ixHwjqi;B%Dw`OzgVH(Dwl5k|eVR?{vjNe)WPaK#vx1YYTFp_6D_ zDj(6vBIFoyD|OrBl0w2BW$e41VB<78_|24OthhVouJimQQ}R2qONHPJf`{zgdWzzM zf%YlcMz#(}Lkj1AatY*nbFeAtrc3TKD?-sZDkCf6GbrK@6{z~^&NcY)ZBB3j?FEu|QKrUu>xyMWkEt|UH zkyeb!(`0_zV1*IYfYpNZeIJFkK)=1Z4PNOF0$`FLC>DLl8;!5Nkp1jT(Bwia$>Guv zF6(|l3!;0=L-y0;Tz`KR!6|ozGp+;bqsZ0xGh$BF`FzP{avRoH!}*UB$QpHh@@J4X z6~3zOJmNk-Oa!4!?dN#=^(uOv4^^F4I1zcRb>a~UmK+E$InoBns!@f8-Shz)&anIJ z*K;2U+*V0NL+zpMt|0uNU$KNr^IONFIRws`o^TUkNF*BXM}gIO_a0Va-~1={o=f7> znVOTX&VB0rOS7+MU~5QwfX^A_tTgR-Sp29h)~54@J!~3XQpIrykE0_M#=WN0Y<}s$F&os*ttG6_yyQBl!g%PJ zM3+vRKg4e>YDn<$x2SOnq}?BNOVX8C(*j%-cH*@j_oBQqq&i+C#`8u#;L+9y1K+{p zdVdD&#Q&TcBBG3Gd&S%s&-pBpa)KkI=YOtdP!s#WcYc+85#QrQE&b&azWesi7$}bDeLE;60 z4?tLoQ7lb0gfa+a@#Jc&exw$CeEwQZC@aqDQq{HzQnRg6%%aS;M}DnpdtuvS|5QwV ztcGU40yhhFGofV3IQ3Z)u}G(5A2NM193EX#S^UQCnKV&BaqehK`-9k;z&0$Z=S+Um zc59Jcec+EkUf#iKkAx1$Q}%6r<)9F(`EiGFs&0;!-0G$ZHvd_fMXvQwoxbShx~wLU zWB!)KtwzAK9O|sy!F1Xv0!wm3ezDDt+eGPTouggKjy|QhXRm>MdWgTv%c;h$eXGC4 zWU60F(Dj>ZplWYCi5h`)x$AN&3RI^Me%`E2IOdc5A!n#oJ{=b+?s{`I^sgp(YTEmg zsh9J&rK~HdUFp(%Gr;Y8vN4=)Vr_)t^5o>Q*&oyct~-;^pO0FO?1JW8YIXwmds^hS zhrDVUP}C!!fZBKd?l{`v4~Cv`?`90J%8+u8!!~TM15w}mUMKr>yk_r+> zfC6yKDT`?T{FGIT&-91R1ny^0au-9L*fkXbptv>=q~tjp3mLX`oOw53YptVfmM(W8 z$%?N?E2Ff9DUWq6Vc>&uhuWsY%x0?gNo%Y~rNj0;ctG2VrET)TinOS|x`eKLX1o>A zy4u49mrd69Xm<-`$jUB7FP=QBJXgV+p==Md8Ru$_^?t4aTIgIQOHT`HaFfYTr>GQonFBu&$C3}IjIgbqXLc|%&c-1 zZ*e!TfO~hwslIC&F>-a^E-dd6&ur$)?p$wH5g^a7BlsOEdP;$&U;k0sh#V@JWVX38 z)|xNlMVAqa$OM^3*f4ns{iI`V_Ir<>9Dx(S8zAdc^H5WqmVEWp$20I*nNkpERw%`A zAQKh-qx=t`Ah!Z{C?C>%G(+^o+{=`EM_>s@ihcPW!SUl6=6=UitGCJP#7UyR^B5L9B8g$&W?(T^){n4O)wS{AA zOLh$%tWtq_L!m%s53V@;z*RvmFmx|44U-`%+8DU~!E<{fKmwdXX#3W{JS1JsJR=i} z!(2DOFr%k?9E&T)UJi22z@lv`%LL$@_Uds$1w~SfGY7$lUG8kyY3_5Q=&Ce>dR9qt z0)@yU-21$Bf)979UC5PrH(h6b4ajjHmxbq_w;i50n{T%q2kE)49z*(_UHUWojY`+a zL4O6@9#&}X!5Jr1dScOD$}JoZ z*ySv|!k%^TEQEW`B}xrtdsMC~KgR2=O$BUDsF-|MOX>OX6aG<$dA`o?FShU-@z$wh zbM~QEY37pHKK}fO2m#4rgY-X0rJ)*#1akKe6P_{JTBWr}qw>3JO{bP*o+-T~ebXW9 z&d#>1t9R3GG+XHnM83UalX+&Xo`@T$!mMsWLDL2FK}_`GK1z=wtVw+r9)}LKxZejt z+N_s2{mBD0h6G;+(r7i^sD$wcerhIp&lxYn@FyuQvcl5g9NyuA_t%y? zJDaGr-w9G}yUG>W|3DYbx6-uX^N7yEGX~n&4qu59<8^w#D|wj=q4ePO>~7D0W?!1P zqjZ5=tx8N*3-ejX$-*H*_v?*a`YKQPV(FOOGzxf+3H>qfDTi7$XZV}_HGQLKCpo#Q zEtp9-BiVvklRo0xU;Dnn?U{b1z|IW*WX<}R9)f^k;S9-nNM(C@93r>McOec~72PD8 zW&XsLx1T3$J`OuwpcwtJjFG~_^8dp>!zP|a6&AFa9cSBz8k2_O{A zS=l3I$j4s)qx*x(DeYUWR0mF$D&n)wywQ71Lh=q*=BPJ?tzrlvXPM8=CZKP6-p(^@ zfrXQf0<5g8_(yFPMPS663w2ZQ8}4I)mIw{Hil**pH&>oz8_HH;|A%(y+kOknQ%Gg| z3eG8JBtO>-{1?7-yR32XZWSQ%oIlHK?~2_bD8f7Y0{zM9p8I6nqa8t}PnT9;!d9!I z{6&|deX65)g`jTUtWO?kU*b1%qs-Qb8ZccJSn!scL@0}A^RPAT?_Y!XOKIh~sRZrR zxy|IQ1hi&z;fDo5lB`^j+-1KL*cN-e)wZ{=#=S%-Jfl3P|3f2&bs=3WS+dtUBsh zML13jUfMEs4OfS1%d2a?L+aQQlctaXoZg`-^S81T1b8cc_rDU_HJyg7;#FWsGq2b5 z{YQkb1H0gvjv>&;K-fFRH}bn4 zOt3fUUIpdhucZoR|K@G{iYaq3hcK=9Ff%Vj-zK4PBYZzS7Mu0JaM~j4L<0Y2gGdc= zOQg}sXj1rQ=~=QRFFI`DB}Mv9C|fjLtSI*~^P!szbZ~2q1 zs_-Zm3!e{l!@*-)pOUxadZ8j@H`^N07I+ns>UfV0GoZxu(MV^PPSN6-p7$xnD<-!^ z?|0mNFIF^Os}Bo3hqOb~^c}%s^UPob`(>&xO`?G|hRx*}C#)f>D4F_Wya%C**WAI9moMIBzP4 zt1_}AaCtmvJ=5;Ptf;WI@59vNpldQYqyX7c(8R7FXE@)C)3%c2<}3Ouc9Uh+ds z@ArGWKW0zWsq8?RfQkr{3n%9f@Yi^grN|nDaImPqA@AI{$voM?wJ-vcUGnPb%6CwfmdRZtS~xu#>X?y z(k2RwqbItxhMzz_KM5bw?xuyRUhbPF^C!1Vyv$!{ycK^;Tg=Rr?r&sw7(#(lRKtB> z2%aC|%Ci-s%Fwz?6}WRKIv?vo)g>{4+%?da<%I94J|mpYRH7in*SLTdNEr*@v-f}F zvYN{;u205|rx= zq&yN$>fCfHp?+PsG!2|l*d70B#Z5n6HOtRd(b@488MW*7F{H4E)fFuvQOQ>KdLBHT zYud6Ly(IN;xFj+fEx|z7So3fgYaDb@TIOz`{xd~r%zT$*u80#NU0VhhQZ0VcgvtR4yM9<*_2VmEKU z2cJ?`Gp<(tDe3`6~qJ#vf6!CXaCYFMk=0CeRv$iG>&bTYfjDJlCqA&VrcDC zFJcU3>&|Hxtw^nrX~0S)5jaD|w^oFVQ@8nT#Hh$yTYA6uVAEcKXQONDU~W@Cuf3L2 zVL%AcQy<7Iyf=>lW$)YB4i zVih$K{06+jCh%<0)>q@o%%~c8nHGdM67PiV!4Y^)+h#ArBl4pLu%vMApC6b#vP{4< zQ*EuFnLyq{L$wry-Ve4<#~IqU?OjPJK2*b-#>f94hu{$%3H}fPj zYddA#TdMi<{i#y>n)A;Do@&>4xaGlTP_!xFlfjEK{A5scnMradFJ2zx_FJ|~b;BVM zCz&zveCzv299Y<@EI@&`wDgIRLg3(L;ox=0LSL8Mh85FgLacXmrES$oq6@c0-u*I7y(bUt;7#Da2 z4*O^;D$lg_;ufx7h$1Y(0B0x>K19SiCI67s+HH;fjoKY}>t(nL+%VES|CrlPc$Ys+ z5MnP$0ZOTxKIc6gnXqq@4b!E5JC7`ARz0V+mH|hbH~jhR!nmdvQcuHhtlWje7%*hj zasT4-t{zh}CSqp$k9#*=pzLN@Q()>Tb3e!a5lF=sTZQ`D6!2lSaAxx1>hdqKcT4vv zM;u>M9Ndl0GT3E4n(%mWow|CvYQ#iCV8SgEv3Bjypc!Q4xu#;$e-VMsPO0D9>Ihc$ zaQ}*Y__kUhr8JsdfWcXk7x-s~*E zOuj@EblM&Qsbd7g!y+Mlv@>Xw1`iZsDdqUr&v%kJu>hA{hT9iYaqDM$z|R!vlhrzd zeOl?+994dBH(GZe=0BN5d9*nGS%%es7K$gwt|KC{5|1tQYCPwnRA1Y*da$c+6;ZZOF1ywZ=RWxe6sz z!5D%C-NYjKrZ6|!vaN@96Cj07qS?}4 z^L`T3wlU_EHlz1--L-8i25gxR<5;4%^blWW=lKNU%CsadoTXF&{efZZN>{B=E(K3> zlcZF;`9t7}dedL>rhY*dTUT3G`>PU7zDyzeNB!rHCTzy*!<^?eH2vr9z%XY%CJh}kL!Mk13G^S)V@T$wK#~RkFia%9(yC;rN}UV`;j;- z<(?w6j&f2Hjzxd}YppaSU(mJtQ@V#p1FFI%gtV~-e1mPI-PWO>DE_Y>Cg3!6FRwuU z(#GnDVwEE4JZaXM38xU3air`Ksg=bhD@S$8oA76PuA3qv>T994moQdzF7sc$OKd5ZVNFY!?nE3zs4 z?BT=s{)g2Sk8F1Zr*T1s-@W2#|M7f&_fOiveg^ta*n&vUF$Ki_x(0W zz@y8f1qyAVuy^}7|61NQy>-td{yyXb3yYIF;nu8F9U;M1a4qgC@s}7CB7SDy;C?Q| zHiO>oijgrzCFR;GbqNcr)yJ!M! zQHTPH&lL|v48tiDKP0X9Aue8;T>W_bx6i%-&ziD`jF#(S1|sCNa$k#c`t%N~v*XHd zQ!i7>yYT!&+Lv=WYimeTCP*wVp5B$u-aE>D@+*Dih_vo5b?CvS8)fDx#A$%0=ObB*@=#C zrgL8K1ev8cqd72_(v^L3VFI`F%(clbzt#!#2bPjYMM;owB=)h8GuBd~M!b4N3 zUzAmPK^kGOMX5c2^tCihZb*)wXNp)P0m8m<^>Mh1O%W!w-=ZR|7ke!AQ>qV2KnBK! zRI7s0Kva*xWsp)tD^K|w9u_zj#CbEZ`r;n`a|*mNUckd)pK>65MkqM9*rMQSWh4+C zZ{m~$W=-X2FO4qAHXS;C+n2?H=H2bopg#Zj>|66UJfj^_o-x=lYyp7t7X7ovI3_Z$5lXi@LfxYy(kaU8e)t zcn4~Z{mzMo{b-fBdWo{pvF`t<^`wL}WY>}KMb#ExTpHC(; zrSu)y>Bmq+!;64V?dUN$S%;2sPy<{Nc}(-`z6kFvLuy=kBx8wspZjP~1$v-k;-U$z zqbssM+E({?wt~=bPR+G`z-dTn8XUcx^2Kly>~AqtE$jm$LR$EfvL}yjrJhEjd)*9< z!6mf|ksl||y`PUDZ5N6^1VeJjuX~JX2qfMv==~6w5BgQgVz{@FxaN-A-|Zg586OD{ z`cG$@Y8;HRp$WgaHhLk z-9VQp1pk@a^d|`_5@y&B(2{;an#pF{A5%oXhF_6WXI)^opJyQl9GOES%C{|+G<=iS3f69Qnue4<%mH~W8 zd{V_x?t(3BN2~`2^*`;@aVeDf#Ons4j=wdQilBh35 zJ9Tl597g$HZIews_%FkG_rQYA3_$@_T;SD&+xd3KS@<+Km3tOw(f$n!@Ny*6E% z-Kr7c*r|G!y;A(8r>jP?MyhZs+&ho-H3)ds>>BaC2YCorZ&N$>$fYRQkl!q;R@RUh zL)BIxw^k~t^>}vvR(HGP457;Gg49}@MEaxLJvhtTr()tu{!B0BFSiMbK%Tec1usb$OKr{#$?>Ozas#>1 z^xvx?op#A+6U|7|;kSi1+$Vf3#Tn1T-$&q68|ILngo5zrfrQ;hgcoGTViaD$ZBY?f zDt{*~(wWJEt3oJ@vyvcMb%h5n2mQNmwcTp?11QrPpA6^f?nCPxjJzqy)V>S7nlEs) zzyh0g(>ELeYi9k^jsWHs;TPC4SL1AJikJA>2{)5-A+JSZ(}NA!Bx)nOu3-mKL+^NN(3=X7U}**Sxqyi| zJ&<@LSeqcbZ}r@s*=sjJL9Gg|59vj1vuaNdG z3@TN^@6-r+-p!EI4Pa#Kp0++q|Mm@lUsGg=K|+--P^Jo5xV&q()Lc0_QOGBtMM|ag zkK>7~N9P1qbx2kyG_OsYeDTm7Xhp2s&)1ZBlNp@0yyl2)nUKPNcF8RV=Z_7%nH45g zZ=kK5ikvSf=NbOHD#p3VF#)#rhDFj*Qg*XO zX=Jq-=hhf6a(4bzWD$6Rneq0T6$& zl4-1b5=d@4QD|gMsUIOSQbtxdsM6wEd7Zg!p zzYmE9v9c&4gYV-+7pa*sqMF#f9D!hicQzmkeIrEfju#-#v}Wr-uL|+jrJMb&o3Ka} zXlbYRn-ZwogKCR(4&wl`rtsYPR$^bX{t%3jM6sPlNP~3u$gw!TeZRl^zV7?l|GTc8o%j2^&+8cvqG;M; za=V+H!b>dR&$N`Mtu&6;aZ#rA5`=<#!?UOe8;PsK zg!n^OUb|+LOR)9(H^ciD9!^e;8$I8wz6Q}jO6ln6#_x?#h5xhqwN!yXMw5p_4&rbw9k96d^i4q6$ulT*OPvn_tDelE&59?0an&{ z_M*4ced5L}vVSRlpN$r~-?%6y)1ipq4%!0qGP;;!5uA8o!KU**A=j72PEr<&s$oO3 z`PEZ+Prt##6Ra#gzOjWe__Kab`Na0;D?L?vC;zw&*U??q{2+EX)A);cB%mY@@A8i~ z>S$D@uMAi*rAj-m@1U0ZcXa3eynrg$#^TZ^=YpHx%jmPp-^bp3`*=%CkQ4D%+kmhr zqiga?_=ecC)z92pA*92@d(f4a_AGy<1}U?Q+_(9s5B;ITDblDvQ(ln9jK8=VMdi6$ zbH%R+o#ph^?h`%zwDcDUf4g!=^M`MARt8tGFT6X#aWH%`%h4;1I01pYsN}Gu!`keR zDt+p`zzaN>-1a`Zd8z(I&K}SHLoF-1&fzH-LQFwQ`hDLWpxi|3c2rMSKwz%3>ASdg z$txjr7n`V@WvV^srAf9*y9L9F9$eUa2dwfJMnC4U3gUY6en?O3<7FDi9o_p-x$Enn z911T#`RM_^PG2aqc4xdYo_3|(l=GbVo2uW@_Y80CHr0}5{Z|Hp)zswAIs4oC4_=br zo$+%Spe`?$9ktnW3a^!chg*-;JnnHlSz=j-Jwpj=`YAD$ME3)G?2dAq6DapI{)4_@ zccKOY{-ysB&}vb3M=nA=HiEepMExMcX}yp8)n*-hmiwge@%AtKPZb?;LX9SmZWCr2 zVD`*>#pGgKh(XsrhO!PWV%EZ6a=+GMdAUy`7w|2}@g~cS+XM#M>wW-h%O;GA0kUsbRNOgh~ zafwUw59K1QBFp^>XX8`6LQCC|S$o8ALu*IVPJi z$))t#9_`ytNKJ<%fD+@|ojN-y# ze@IOIEKc3Da??2%Wf@Z0UyBL6(|VsPFhZ{U41dIS}b!`xh<| z5lf5o;KTsMc1P(7Hd(X`)fj zZsYI;=E`n3$^~vpI7!YGR@_Mww(iIHSGXl5OiYCH-P(L12X_A%@i)sVwEOn)%%8Ze%R+SKTny?LTiD(PBn2b0 zevQhQVaPBBjh0{e8D|F^Mo1i->(2f??IHH9$IT^{*Kg&PP(~=#6)a31U&A$RqX)s zaQ=N)kO5kH0TR+;G)s4|G^uTtq@tQXf4uKzQJ%QZ2VWWe=Fe^SIhPi^~y92mtdQ!(On?HiX(22^aYE3wdf z5C)@o&Lpumsz46KPkH+cKJLSHG1gY^?kKsLcBsxE^7KT)mt~^)UiWUB!RJ|YA6?w6 z)!0anGEQ)sw0Eet0$cEE9)1n=yw*SRHG(=w%MpCW?(Vz?#MM0I>8I&9h|(i`Twe;T z&UXJL+Q>&Y{#t*oQB9-9`jT&I#U<-&F`g(o=7fpzFELmdTAPX(*22*JT1QQ5dcTsW z@uckK1LKLPbZi+3Dw9rg`u$vvf7m2*9?_Ft_EM07xdjp?*1`FBZuc+>a=$_|iJMV+`L(!D{`{d-S^=vvE zeZ6SNph2qqYq>v79Gu9P1f)g?^<-;ENPeiw(jYY9o=I>(G)c?K2y|P5Q#hwn4uzOu z(8+~l1s0zbUqpxYo#cb=kj87#eIlDM5rpDK?3qljMbC=UZqlQNBJ$(Aw2>*R9kGIL zAy2U)t3XP3R=XBm%};kkRPNC-Yn3-AV17RAyv=8wIiXDXz_`*8t@I=sg|SG^2lDgB zqNA`W7(~Nq=_v*%O$PiPgjAo&Tj7<4f4Et6hhwkaT>3M}{T8_=RtpMCt`g>Z2C+_a z9+vrUJ}9Ej4h|6A&I6WW_gZDN7Z#W^cYvgW@jIBvtDTKDUZu>TrpL@ICcBNabt5>Arvgb&DT{yWx(hNhgr$3cSI6ZJU=1Y^0 z*M&d&V(I}wu689pJVdc?zucJ)+>1SEm%hUaO-X8(<^5}HE=c{~B9>?AUFLrRGnFLy z=F?BWQGP%xO~87#0S)l{DB~X`x`1Oo>|!xBf}YVH&DTbK?xIhgTviR0XVYjML-8T)?DgZH>0~TtQGZIeJrImq$m`Y1hdA#($LA zKt5wM;Hu_6j+=&puX&@{?M{dDhr9ic`8YZT25f&u#(#u=MA=_K2NdyHa({KDnThbQ z8yM#haV4M7c&D&7%Hp6mA;Bz1W;)ke4ygBcr#OW{wfoP!y(UBw#bRBhOf4hiZeFvW zAcXo@hMc{ku}XfJRD`|BMo)ZpEVGr$6zL2xKM#)&no#`;-dK~lpys*I>x!(?2uzmy zId`jK24Q}jEMrMgpe1qLB?*``GbV3tnc&yES5JE7<`$Np)pf6`x7%eb;sZ7trR*MZ

9@gNbz zc-hYdbmBRLd-UvlGB0UAC3GJf`x&Lp?x-fpwfj-bcKDt@Cl3~NfR#aE(f1Wh#n+}D zvuuuc*H#DujfgNA+3SOflsR#Z4bmX$mS}{rOAWXldKx8$q6w%Ux%se;VoO5pC3IG< zr{?1*1N>9JDf$7LzHg5Plc|L&$*W2=d5aZ`curGLZML=I;mvyKEE0k+V={|8_?~&g zBP#`~&jy%5Ru;Kufp44IXt}m!nG%dVFYLjJJNjPtwccH!Y#%LeMzlrcsPcY*%T_+% zIVs%>vpSnDs^e3MXkP1<$)_2IeX(kEs>sw1ey24C7NB(qdIcz@1N=VYWt1H6%yP?u z1_JH?rN&eJYMl_wt^y;ofdGq@i}RU{ofZB zlC;%|21PJ7kZu*VL}TqkDE0O5*e5VZzLUmi@I)7|WP4gdx6fc-9fJzBG@p_>ZLKNq zY~wQWeu+nDb*0Na&~ZOs4I}cqT~|XW_I+iUm=0%Gc>UxL2BA4USOKL%8WV zJxCw--*?yHQ`qc z{-#(ga;)-^J#Uj}v);cx|500iQGWfg3MJy=(E9}x>7%JuZ-UqH?dwu-@bNxFt^F<| zuiU*0y65>S*?MT~EUQI5DmZ|pA8 zslyQ!BwilQaT1K5JE4`2xl*T7ER>p>&|~CBASS>oHYRe8H|#|^e0k#$6#P%H`~zF7OzrJ4`C=F$bIn3>EPWLCj?mCZHh6h=NdQ~|LwTK zI9_%7=~xc^>^7ukf+OvF0a9Bnqm)%BQNHdxisg-%yNEdYBnZe1Kbqw}GQuctM!sI2 zjq%bYTYP=mKJDiwfxd7sA23PTRd9b-+UoR8S|QeY`G$b@9M_;Adxx%4@lW5ryL|v2F#q_P_&sG&_w(8g_ zXA?_eyeKfGJa$| zA9e}*jFV$TfxR=oDv-7;TM(~yTi@r$vttM6rSUZs{53!jb$qnV6(=bVCX@l+#Lt^=-}heK8>Jq_g$fo0AU^v#$SUFk+^a-`Ew?IadyWa$cj_KMp`-**AEI|JiE#E#S`KnjugQ zv5ajF(tAWa*-6^mGKi@!w6(@`KA}S#F&xaFa$v@xWGVO5t%~Vmbr`@`1{dSLAkatm zh$*4F`uX?(F}u7SQ70U1fxec}5x%}{03d7$xzv^{pJm|I`8lmniUi%~7PlK8%VXl> zYguT4$;cIO`WCANWvRcrm~}kN(f&(!Wu%kS4f0q7tey>hUk0vpSu}t-)l+tw(*BZS zJn3ZBqd~cd9=qyk=(bpAt`ruurxsM>P6Ks>`=4g{!UTWq+GG7j(k2<99z#irx$pIG^qbeyt=;$QNCPJ(D;enlI5cVcJHe|x0im} z-RhTNo$!EpIs#DvScewxj@P#}9vo|i2p$v#P|BU6l8QGa5a-*Ol+@*|YyR4>NFTWL zXBmdPV;2^m>xk(AyB6aOv2_B$OvLl)?gGo45A1!g{tq&QAaP$PaKY0ojet}0pLp2& zb~US(&zbkhy(EJY@FQ|%@p#Ikv|Ud~KC~j6o;zjt5MCPa@?g9$zPmn=W=;zx-0!FsHab6XbnWfTmX^9XyN#eI0 zPM67dYHHU53#!H&xPkv-?F9B&;t?dI?84y<%zYXu!Xp|#*APOHb5c7* z=_cR0U6C=;med$AM{pSbj{MG5eY+u}S#27oR+zYrIQ6eVw5W z6Aer%xY|D&P3|&kwR5*JOg`vRv!MJ$G+>WZV>I?=6VzrE8Y;wEei+pc5fOo5XV|}| zaEJ3~!_2OyO&ZS8DX=Y+%@(f>^7Zab%X5}rKgpTT-MW*lmZCfdwx03n)>X>5=OCI# z+<-aqI88wK*%y*3l!Zo2C=PVCf0SCgN#c=k;+(lP`tx3^HC0r(8i1*j%1-FrSb zW<#y7Q)wod6XT~{q0oDog^5% z?>9c*u-hmrjwFQczt3Sr-W_;3FA=ZpaIc`rL&sQye(`oO^Kx%Cai_uTaM0DWttzm| z=~(=@^IJ$9G7w{-FH5_4Wm17?of1foYl~Kh>yJTR(|2PNyS~ivhFXvee7wC23Jv89 z3dYVjPCL|j6D2IO@T+}!wA2JaGbv73Gvo9$K>mqN%=~)W513HGzQ1gYh6Y6}Ap@{W zuN&HQiJ|-Uo_K8C4Aq4S58M;RJ2>rxBpN5d4g@(t-xVuDWRIqj&loT zT9AJcmojJ9@xdlV+<%fK;`#B{FZoIB7M?Pkle4eK;9eINGi4ME_Ii5ddjn>=u_=`J z`t)|!H5jz)5*IJ$G_S*{m=fX0BsOTJI7 z5iFTMs5-ay+1%}*+jGOI;D#GUoki{^6^+D}8-;^+lw|J1%0O6r&GDC2Q1Zmr-c$7# zeKhYi1?A>5rQ2Xoze=QImYOodNzi`7bZ!kP^E+5TCLHBQ9ec4|eCBv%c9OpORCzLg zLv-WQ{Kc>23bvLH)#Rd0)b|K=(Qx>m@xP$cUn(t*noM?;8;H!=A6lJDITQWHe}vsY z{h%H(PV$9Ok`72VGdG6|YE&Fcho{vN!HFOdu6pAl1yA$?gU&scV<6Z!rII(x&YPJs zA$)!K@cd0$PF+SfS4oeX*G!MhhS9N+>)r3G&bdC$ha_pq5M&O^ulB+-}1wm z=n-}j3}}8pgdg(kQGt2SND)!?0tm$bM9Dnm8EKx*64cn|v&y>BotFL8#d1!ne079$ zYG!B5B6}&LNSgD}Gf3mH^Nn%LKT+r7ZK;w!v9KJtu{Ai&o{M9be(^CJ!z_)6-o3hF zixw$$S*k{!n-k1G=fx}kRF3tvlj2hJw_4!C1f93>bIjEfA=MSp`Uq zb?r-H0T)qk7t8{!F6pSbCo}1P-aCyC+>LEirhv!~v@1oya{0P0&5j1~^gv@8Q-T#? z3Jyxk8!ne6AoIL_f2yy)@Ds+CAm7sLq!Yqr+|{?!O2^V~0Yp3bk>>4g9n?dA#a+0` zJXgN8auJwg)Hl3m_yHquAYN>Ih;{n$L&pUOrw$n2-4CiQsnf)9L5X@mqq@K!@G9c` zfBwU<>c;fn9cfs{f4K2kvYxcr9W%Q5G5{p51@1jAA;KZhSb9rI?)Anr)2sv3+fXN> zN;J>^c$GguN|YOrTru>&h$<+R0!aUvH|MzL+wl(e=P7HJwP@JXG9x%V59DCpCD=|( z$M*)hX;}_qqUDExa|O8%fVzDtVgSbNNsG}bwOV;TM$wBcxEGV-W07)JZoy$4e_JgD z6xXqq`PK1|4rlbvAuVD~TQ=*DINoX&x>repTHT@k2%OIW-kcDq@RS&RDjM|gH3rK$ z*ZLQW0!#3LaTc27gW7zhgu(BlUhPjdwMnImelkPbaGsKmnds+G>8RsC(!B25g#wU+HE6 zz~l#A@_-Y0WwTt_9W1Jd*bhf6e5SzY&%N26{E;NfpwDCejzK$tfqrTB4!s?E;lWFH z9dLCx1+Em|iZMBX(#rS3Am{)qOAaPESN*ee*11A@9e$@DH%%49aTZQ5vCC=iT$p;4 z$B9r=$pK}=+Pk7i?fgL?M989A)N(lz+SK|egx z2REoW)pwme=hq$bDQr5z=NznHvkKF>X!)pR7`lD9!8WJ1g=%%>#(5hj{Pcgg$FX#v zFp)X2(!JMD$!5*5s`_=d?bVZVd8J_&7dMa+iqyWOyq%9PQ4Slea(y5F%S&m1nr^k- z_z>^fY7f^&Q71ZWq&VZ=yDXUYLkoC_)a-H(n!HbG*jZ2=v!F4Lpo7se++tH)@f7)H zBT#y}W(E(@%M<+$ezW|VM4e^9(ww1I6D=kFbm+Y8rd)=99B7O?eZnxB^rfY^zg?-< zq7@tRu9@Zr`w95>V(;n_cg8(2{&==KjnsGjpHwPuH3#nKEPpin^20CVZxUEPr|uqh zvcx%j?AJh>Wvh6_wLcikq7o)DvEsapy|wj3Eo#(s6%*ct7za=+;uaE9kUPYW^`y zp+uQ7z;=9h6t>LWb%W+*-Cv#%BYInXf}jQcChQY0GWrj=)RozGPN#=IbWn2faG~9( zPYb;^Kx4kT^Cc2NXfJ+_H9%m?n;i}~^|ZH7{srxXu02v%h@;1#lE1#y6F;fOGFW`c zI_WHc$*^2P1umy6&8fxz4)I;?iPhuj?f(+V5g=#cK)>t~bl4%d2_Xe2=TT>-LAc_} zO!&go*_oiP`&hXvw_|QKT+se={Np!?gJ^bKC_Crqyw8IV5v|QJ2o}|IC*x)1gXr!( zl3jVo8uZH=`UI(Un(%Ju&hknuwzdj8LrOQ>5y zB30guLasbl-N->a5BWke<#PNLq}dTu&GRdnQ@40!Ldst*n}7UkJVDK*mCjjcAJf|vCd=Bmw)vI!THhQE+sgCNtqIDf*60b*MX2^%{VHY9DS}Pj`m8Av z+GL2|8vKFk!BXpJ=Te+NNfo)NP@K;OK??`=k}hIIA5f)U!K53W-x7U2~s zm-fGOpzIn~DQ`POTU?4Lof-cj=88ldvaWmdSv?B0Z{xhO$`cTluc&ankKFA=YJr>T z*(-B@u?aFSV-MMDexw!o#c^MLVT<4-RCF#cVKo4|Kb-r3@aWwJeA{d)|9?(QcI6+* z|36{t8OemVb0GKUU9#^t8&4kA_X*dI*2DRR3CtPJz=BDh`uz@3709WikvN0EZoEi1 zpSd9F__UClAH}uMn|XEGFJK(z)W}5@i>I#wJsz{oJk~8J#F-Z1vqmufGhuTUyq3Pd zJhmHhBFArEnSJuE$+fKWN&Dmf?C4xBRvPwG+w#@a+%HYt@Lf9OL*FNaO|bv?6Oa9Q zvKHqtw%BI|BSSM6@9@#FtS|^Z@v3*_LeuwZNt?iu_B!C?w-c9m=E?+Ix|5{D!7BFqi&91_ttP-Nq+f+defL52riF?O2Tdy<@E~ z8L@2bvUs6Y%uw1;y0Qtc&Rx6u?}uCblE&oeK`2o(!-ofW(_z&4u};;<{Ym3m5WA20 z%E-4q*8s!Z->2X1+GDPKBnxZ%SlSKw^iG=sMhZ>jE8mAKTd^rH`#+NQ z1m;cf)*@Uf=9qpcW`FlQ5P)q{J#N|e=fK1>kDo_yb*{-|;uw}!yOTN4f9<3N?HbD& z3i)aiWD@$-l)OkYUzIJ3dIRL1Ab$8Qf|bsTEk0BCm*OQ0ZUIK?+vRHGWlXB=Vn`D2!fyr}F*l2g4k;d))%-5w zc+Jz4BfGzhce6NFo6?@P8|Za$erc5f9reJk3N4REVubu)5#32_qhWt%eR=zze0t} z7;u)zf%BN=5ElJ~n5D6XuX&j9``Wq&)N7wkyzy$zZ(-j>8Ex5S5O+uw72e)Lpr-Ab z>3My(rXlYTr>4x^?C0Rrtcuh9_9*VoK57<|2~B7#e|?Pa`hf?~`^A?#&sfbLXE#UMG~97A547?^CV< zmj421d*Tk$`6x8FSpl=6=e;N?;FJICYng%eH!+8o@t5{*bCusAnOla);eHH;3TV+& zoHfqAMR7|zUQ|Ee{)%yNDH`BX{O@^Q0mG^(5A9VEQj zTEPf&a`zYiwff`Et&!F`jK%_M+PVr5#R0i1cgs>y;1Cvrs>fA@1Rt{OO~8PFGYI;N z06|nHon2cZ5 zp6NgtUO%1q^03!=grzcw0*j!gqa5#FteZdfGw2A;{wD=#M9jTzFt{5keq=Ut^K9I) zIH2OwINjnUUO});=msm=y@Vpz)7Wm3v`eup=oo%2k~N3J=nncz%Uy^^qG|Gc^HleF(@U=qOqka+rX@x|nb-BE)cYkh zj}N&_W#2v6aoTc3uSxvWCp&dYoOHfJ>k&$kSlFCPZlD+}Ka}_nM5Tf0?=nNdhs5FI zFBAhZjCg+=HO^&IQb;{@z`&gS^kc01qsSNYj*=oS>6^hLr{O(y-e9uj&HINu!*wqo z%C#E*m@1_=5d3@fdY}kLSoee4kOhDG;YP6)t>y|Y8pDC9r{^vnw+YWh95dnBB%bu7 z-@XDYi_-3%F zr&ATVN+(XbMi5%d>)@D?j*Uo(H;tXKqxbz%{u-SQqf5nBRn1BVtMUoE^LSUfj4H=3 zajgq=Q2F)ra+~FN)O1aowbPWn!s6QwnA6Rmr9^|i3Ex#ZN#(vEg{NvP!%klm(0&4%6LBcqFW?;JB)D;KG;cJm{Z2X#tcz@Nd<-MYNw zFJ`WBQP)Sy;qSBLq-w(*%!&Te``-K&B>nav%}iayHU7G{>(bxJJiy+h(%{p%joMlH zqodutS*|zVqxiNBTDV9XM&&)HRZiVWEB5ZY{V}Gw^g{wQoauL*1DBW8Jl~tv*m~AC zrIczV7e|tPgSH%`K4g*+E+J$#_xrKL*2Qw_JQd=FPuy)Jr%f;c-pJ=di$>2RJ?o8D zMDqqyWxoK~KD0}(g~ZC$TIlt?vrQUXuUv9R?RexXa2B##!FX}RT>tW`Y_&_*7z<{Y z(ehn?1&onrsR%6|mpdeWZDfW!L8M+<9BWaoO%q^j+G@1-TY|hVV>Nxle@7BOG0Hgi zy8Br%|vFl677Ghtb1Io!~JP|&AwR{IA=s2 z4ahVdp5v|ly<~7NoOLnm86{qCr73AIyg(`}J;N?=nFlbAo6Bwzb4LwolS{jqrflhI zRA?p&*Wr0)5rO#4QCgx3@3p-|C#-hV)v$2gLj z?CB@Nn=HH0XM9+CRV#=tt~{dGZ21m)05M0WlYN=$44J(}2juBZxny06mVZ)wfU+ku zZ14UysOS&c=W^NjJLtUWRvC)DgISJB#`nEIT=xtz5VW8B2bi}VVWM}jL|$$BzM@cq zTIJ4iSh-Er4#UCzdr?dc`FzUkk*+%&zM=Gd(+f~&|8x2kh z6g*gHkH+vjr$*Mqe1*P6hw`1-HVfhQhDeUd#U^v2c&4?$_WwriAHit@-TSp#DRyDO zNfrO3E(re>%9+1%e}zCUs(UrdW@kbJlue`J>SR4E|;Mj$g` zo_EUidmS1X2EB@#0^UC3+cpoW_t`IQqZPgb=FzaN$ zRKJnCH&@y&m)ok9;M*-2=w2}^9x9;ZYPn)HD+D{H#(Bb{@b%^& z_D74I#^o~t2|_N3gWmY{gWz{4In}wi3#K7MV2|>COOTZG_J@q~9N9MoV=53_HJ3f8 z%*;?xsCn?}PVUO~M=!V!X^=mng1SJ*1DQP@Gq+0^KWYA@8|nXqu|#N5@-KMwx{Ocy zo}CH}%kv~E{c8iXArbl)y!xuwhXd;68ZWV$uicR2C7cU>C;0)tlqmId+@{LKkPl>W z^G-K?07?wOycjXmEQO1z)txdE}?~x560AOvLCs??Y!e?Rkqz2m;jKTskp-{_)8nj4Vq=BUl z?eeP11W=5+pyRDHWtvZ+J1h2*EdM0FhNW`uoH6D{42KPeps;hNfP^Dgr>d7xxY?3~ zO-N6|LA8Bx0@8nvLKHnWI*~jcvDA(QO>m2)AO=*iq?XxW8eH{IKixBJ<d& z)kXxm*ArMyQsIPGyLU%8Du#fG+|h5JRzJIJmC2&B=UJ_}qHIGM{qBLR^x73U=*ae( zEL|M#=Gepr>@U3A=j*)AEsf)_|D9>GH8U@QtFfYA{1(o@A!b(o-wY_Qij3*6snJh|%Z;skLiXKmme!;;0=|9QYGV*c{gx4j09;NDV z?YV!SUKfSGN{WjV&!7b&QPws#(hY8Z>rFF?>gUoH?PZ}3V)-7aFJJ7+1ZC$~iY+Tc zBt#FfgP)qbA$0zl7l$-$LRL}r=ydIL$Hz5SiyMHqWi|wAF3)!|j=@RDJ|A~rN-2y- z*mEy{;g)@>&lFxEqGj-H+b{_NQY1O&WXmihK%>T$EW7k2Sw8*Fb5t2PuWb8(P%ZN8 zq7&-*Vc}Wr#<{i%-o?X3s}j;9Vim<;O0-XxZ1(fifLy*HUk$n*3LDm_wR}0oVgpD# zasR9Etx{7%BZ>wx+vQZ7t&o=$ApfchXUTqQl9s4&L6-h$qba3hfmx{1_q*1>!KthK z9%aES<<~a_coY*Yaz41xAkR(y3E@A~53EkOckJz{{8gC(!6VoP>i6qsV4HLcg}Y~n zGSzH~W8#*j~yM-kOJvb50GqEcf{ruO|MSqq?L>aRT6hG5 zzNZdH%xY5Qm<#4vF&as!14V+kKS5L9Ycqm@4@uCTh*vOfH!*1a$n z3jC!43loLY%}7TJ`PP`!Q#JrmA>T`x#@zATBSBK z(flq5T{*h*k08`rTK}k-p?CH_6QhUR@wFlKN4ib#hr=Lxr~@h4?Ipf|MU=Lps4_T{8hR%x12^O zfIZj~IS~-A_W_WIpEo=ovHhq8H~NVVE+Y7qc2v(COxiUk%$!rqx1orc8I7oGW)MT&L_4@-b+ z$uF@bG@_xW4ICR+VEp;%i_4T>Q3&2&WTy)r)c#_3NFQI#mlm<@q~v@}3z37r!)84P zoiW0Ws-?4H>4ywe0-Y#m0h{oRM;i}(gfU>+ck7IA`wkSj8g5kZ-XXBK9!mU9I_`+>nH_sC{I+d4A<^{AJl_4;`0Q1D;cA0OY(l)zlttf3|Scu2CE zWm^uJz>&o8PHJkljP!d2wpG*i{WFP{{_sR3;R!`P6BRXnD~5poCg1-l0=Ri^(b;nk zX?6#xZ6io+R;fC zq4fRWR1(XYxMeO*-er63^^14gb^ihQ*mq%xsbVbK^Cy#k5lsQZI|StBzP6dhCDe{q zeMFOrbzCVhAg`7m`Xj-|lHEQz!h|bD@!8N^Bo3#RRvr>guLSBzw|El*pWA#4E7qJ; z_4s$@IWE)uzSnF-?*Lm{>8>`tb^>dt@KBygGz!H1As#+7yGP!IVK}z7Bl=G{fV`Gl z+|pwdx<(q@k%pYUfYOpl6>1h@#&fBBj1mnP6HguEjWkxyjVt<#SK(uH^HU4F6N?1P`g*F=GsxgB&h~e{@^#yiM6X+l1V4`^$JD zSS~s%J6m5@!~u}1L2u$Jog7FDSW8O+$QD*N`3oLu%*ziM$_!$2m*a+_x!IJ z2tVp`9_)RHZ^5BspRb(Lu@jXW3#)1WM-ONMxpGyKvj1am`y#40m4Lehd%}XXR}>|D*?%F2tU(z0Jy=VR`wf&_ zww@H#+M3F*0$)TuHknfO5Q{dhfgUKk+|90Z9rVl#SYS%#aJFf1`9V09SmTS0thm6( z>KxVYFj{6Cy(lU!n>Vn`37vFNyZQ4Z<=BiI&mTcK58Uoq4Mv?xOb}xUA?r%+UfN8v zr?N@^({bg?W62Ykqqx3g$W6w>fVt}v?q;-z>)vxZ0LJWdvKjEl*8kttc;N$zrF(C) z-J>x!ZIv=}A>N#Xk+HC_@BhT{UUiUYSzK$-`3f$+>*~bCYfvVj*>kF9rtyS>0~amC zmGO@``s%6BHH?p<^AL!G_U!^%18EZPGjBLw0e;Oc_-Ie8UlkW+7?zZQO@o#NkDY<{ z@hqD)bb(2SUlIvqcBt#ji5|jQ@a84!1x0e5-;H5_Jg^yF6DO-Nohn zT@AEZC5i6#Cd-tsk2Yo76XBHsOdTw68y^z&O+d@<%6beJdRqi>kTSF&D}z<@yYg{h zI!r1pjciuCbMw^mF7H0_?DCo2hIj#p=jqL4m~*oX+1c8Y;N`ERX!`HaARL}YO-wtm zYY!K>aR#Y*j2icd#(`30w;H>T^Y4Sb|E-PHVynUWs?`kY*kIutd!@)tvhO^7C(1_M zKumq&R(RBp9=zIf){g!gV_C|s17XXDk0GT8KMTlHbhLYFAMwK!BDtbG1+^ojusDz{ zfnQv#*SyVOBPtPnxxPdxr>^e1bM(p|%50eMcG%4d#`7(&wpD%@S50M0z`xLy!nuJb z73=;(`pY+v(1l%Z!ghFo{ozv2;OYCVMdaG}OUHhy1l`MK4;m4!THCYIf<&gX&m-SP zo$hHnT0!;}zIx8n(Ww*M!H5NB=3!WGdqjv0`a&8wblcYCYp*;v0eWvRH=Mss7VGS1 zqC>*b=}7Fox}R{uhe`<3tWpqV;8e!MlV_?x26G=?WrGc`vU0G1G*#i-t}0R7U45b*Q{+!l2912gkN54EswI2I$5-vsMN{j5Aom*-blea5ftGwzzxzDHD*%zdA zmMPaIpE;xyb^lbT}4zJDR~ZGF;?Wli5u|bxAsY^{P(8ZM{me zCQ%k0auM?AR635Q30H8b;>Tda6Z5gp~SK?Wmz<^#%Ix4+J)D2zq`kKn_4u8zY zY{!8D-ht0q7roe3f=+e~Wk4msUlhupquFR|pD(!xas2ObTiA~pk7cWe#l|gh+OtJe z2^vD2WdNSJe)zR(7yc_yF%Av(mQeI`y9&v@Uy96hiRriY2ziWl!qu>#+8zsX+l#-H zM!&G*gitW{x@1k}n&Fh_UxuT%K)C^ELB z?T*<|uLt*kCm&~ETIx;YGg^iw$)2_o zblIIwyWn}u|EDlcg3rL-@wc`zn)y$sI2erH?-`-i%fbQPww{)Huo~CJ2mi_(^ z0H`g!PWxxWWbcS0FzPWuX_fssXw<@gI&KHb`k}iU+!T#R+Xp7 z^dih4my4Lufl~Z>xugPca~3Q5&b!X#k1OJxbp%73?uQ$WG^M#;TZQy%Zs&cFq?{2J zVPTsu=9bIQ<@6j}1XD0Y-ui)Wv@a4ADovPOj1c9vtxfi_Lb|=N9-+Z)=6tnp;_1)AA8al_pC5&^;0DJ3%_U3084fDwprW)o|)MEQHWY zh-oU5IYJq+AlVn0G=Z@X&!qRmCXdIBRGcR1R*QvJX^)?$X`-4w#m9G2N?T*7%wvQi z^~Itu(EWWM(j_WZMFqI#`FBk<`KFudWpJh6X@Pb;^q&68RdlT~ijnbcslFDEA?dXp zj*C!{Te%eO!4@oQmGCo+Epu7MQB2q5izP?t9W5ya+@tBy8Y=dFMH1Meg$L1z!B?)1 z92T0qSp;(tZ-g}cO5J~NZD6mV9cm$U1FNMrOy{KdivvYb>E-4Sd57KP{OqLUJ_FGZ zsKPE&PQ?+t@tLBpe9ihw@5)TjNm#`0)!$K7RPtB%3s!o&i3eGOJkE3F$q3d^op>1g zkoW+VciE&6m=CP2NoL50pZOnwz8&xpNFLX*+O!rz2bE|qWw5kZkX}lW2>i9HLuOcZ zr7av=1eg+SPvzXvb79m{4ZIJQoO(GK7#$w{Ltz)yW=0%})vGGwqkoS=y96AQa7ikj z)9SwTfcq#c)42W!dD%UEYhzZ4_%eDc5;*{#58H_d=FNn0B-qR0UKajl@7`M9m`(ks zq<(@M1fFql^f6CKo0lVOF8Xe*DcWy|TYox0EvW6^%AcAX?nIsK%dAg=A8#AWauslNdajIVTPJv;(jl_pXalFf4-j| ztaa9fwa&TcoPBoOv-f^&>Q}P1?47@Mh`Lm^tuE1Q9}2J?vcbZ6Ef* z7H^!{OrKH^&Q@|EI)dMaV(H4jG_B=xrk#!4fIo7gI;qrz%{#jtJnj}N4yo}Ur~-wq zn^PyUmhq)-69ulfTpcnkbTJ4^vGj9Q*hn zjK<@1XGc6AOHTONAro4%&cISSF)5MLHnX~~rm|#-U&->*W?-i?-qw%?x#q$ z&9b1T9p{P`3&WPy6(&6T@##afRRhlXig(8oM`IDTkejpmtqb64F!Nb<`3Sb;iU& zlZjy|mAOJrDIPgWajDD`gF1){*M2$!E}&>roBoeUA|CsCn|0gZ6^)!isPoj%r|BPz zmM^(rf=|Q9E%lKGTo;O5A3#=gm<)lIks!S`q|`K(0jAesNuJ98R6C7w@(E?24(@Z*ov8=nbC zcU7kKR*u^db{%0em}``_MO5zs_^*Xx8FX9|HI+4)IMOoQ=-F3t990&+pab!{O=3Qs zf0z~f_{&@y5zm$?$*<(UUNFy9-)KHHsJeT zr1cJs+Yr@q-kP~i&rk?h+?zt2z2ABx;)LUCU-aWiZu1sRbt}z=a&aY z5Ytxbai|IK^cf}J0hiX?R{Y$?=S87@Eu$Kym43Pu=dHjT;Rj?x7a@68^LY z?_g+lV^C(|uJyH*oePE;boEbYCn~Di5R3C0ob?Kz059M&`}ORyhNTvNIEkfG!0^Cd z=jgtigNjkcxAc(7EYWxovJzacA_lsRnfz|%SL@*Y!m8y!AOT^8{{UL~V{JIX_|Ma2 z7$)*7GuM-wHYdvOzxb7HdM_@WK}6Jf$9k_>eMI6#74{pC#2x#XyWCtF{}C$RJ0e|USF#OZF& zCh!SJVKA_r^Hfc}5psToDce1RnyDqXX(7yo&`kuaE0S2>+}L+tXAQiD^WY8SBPpI8 zK>XOQNE8#i+M?tvs*E26%7}`v7GQ;Il<#Q5bx4#$)Dt7LfjH}Vk%J`rXn1sqB1@td z*juyRUGFU|`~Zj{&NWw5UpZNL(!5&U8sL*&4HmbStir;-fX?wW;JfaqcI4iEYvhT}jLi!JmiQUh)|B%nxB3xq*|)GmSyY zKC;{>t>Bo`?fQiJZE2eMRhAnFtA>pZfj9(zXUkiKw)!Gzt;Gg?Ms~+Kat*bQ<5w^= zpgc(ku~hJE6CJ)B@XoDWMq^Rom+1NhR|u>DVM=x)r;l;FfkMwabVa8Ga6EWX#FF}Q zF$AR`3FBt{9Z85uqCyilGMl5z;E^gIxnJA)*^| zBZthVEVrKGwvdZ_s{;OohvR5ox_ga~QJPrNTFr}2wad4zX5JcH*z)f_Evm-~yQ^?vGB$m!b;oPWRrycLXJ=<5la$VtmxC<)+leJZ zQ1-;qU0v!c0@fjmKSf~rMg4OiS)Ob4tFvF&bk}K(9{NOy*j`Ln_F(c-Jka%`(>1;K z6fTlYyD3jg>w`CJ9wVv*VsHjh3MT{!iF*~Iecdxt=@>N44kfjqS3|5vInB-wnA-Mxe=g?N1~n1@lsEPIlIgv(Bvcbq z{h1JsfovsvD9kF;$OiG|Q>$O53a=Vxg|xIHK26+IZVM(O=(N>0d4+QLT&y#LOT0x! zb!g~sw~)!Mbq8c0w~H^6PyPU(15x?ZbRxeJ^X*%CL`uh?vf>yD-I?GrMJTpnk$1Bn zZmibW8^;4O67GI5B7eDFSjBInCn6Q$QqI=Y#N$f**{E9m0KLFi4ytQ6J^t#?WVTz; z`{PR;MfDU-*;uvfG_~JNkD0GeH>me=Q>$)OV?^IxW}7TY{{Dj)*L8rXEqRi-%5ZJyN)lG>+|3R|B0 z_E5g5qMcfzj;kV(D%$dm$dr@qDAnoAa_zi$ekz!P8CY}mD~-gI{7n+7gXzH!uzOJ~ zLbP^oIepQ=|C7{i0{_(>m$`EF+~swYL@EuFo$jNM^W8eGx||o}uxEMLoS%i_)0*zC zWF8=R)RO(ZSFEwbsIpftRoPkoY*YD-^l%j1`ur2KM}|8A4XNbPyT6TK@^gtCONvW> zsQkY7V|Qsd%)1{g>OC}o%Jp)TU~slN z*>#DA&>u;|3c=XQ?{|@7EVY|*&5DQvOM8RcEIt$?w!eElCC+Z`MVA>_s4l((+c|BG zG*xedGtYA8FRX%HCO>|K63W{HPEv5V-kP*a;8muCnXl+Ri`8kvRp#p_D;-kA)Mtka9`(#|+oY|JH8%w~AzExq&b2FCHj%6_e~fvu#}FPh^`+d2V*YBby+ECRucP;w zrV}!k*hF5PosX`Bb;lEIvM1?jU5kJzfo3xdhTZDQt+wp`5Kcq-~KpHBXbnE&`UA^6)zSITu6@rHo{4Dwylxa*56Q?0@}o&w!UdS0q5A# zcB|2i>Fg!upde0a&8nx>f zfdq;bNKeqI#VhLH2vjk9R+(q0!sLN53&~0L`0_34sK_niWv#1nNCoqe))wEDqMhaoA?tN&DNna5{9O!! zT6vk)>{6p%L-%V{aY=)##zp#9=7I?&zNI%)eSYreM-g!lNK}v`of`On`5~FBcuNC~ z}!dl*qGtytekz-sWELG;ah#lU)?TggTMNQ1jy2^Q)`~Y88uZEyZWFJ~!s2 z77fFg7!2O&OZ97KWz(F$J6^p8DhsnOFwq?3$9>C`givZV|AcG$CVRM-k(Yx5=gWL= zG-A@rugNZeQ#EJ~jDhA$!#u8LYdf?9TbYfpiAydMs}uT%&yqBD^W#MM{K=38);gV^ zqM~9ke6gfArA4C!y|2PlC#T@s%u@pU0(%e|t?mQE&w*oUUaAx?9=tEozfX%uW(s0L zsg8%TVZslsb)bt)y@ua6U~mzy!{_}Is?B3Bivh<{jwc3$clrj{^%t4v79Ce5bArIm zjPyoX0rtmfUq7XW)N-{BbGXc;E+Oirh=1NDUW>yi7UR%-unIO00Xp>EGqHRIyvlAr z$YlekMxk_3QhR0+;xyB>2lXL#4khj^s@5TLpGU=xqX*Lk^Qn2Wvw)esS%QAXtqw zR3+4ZvRoDxYenzz$u$fm0DS?2}>5cnwMdoH04R9XWTpNGL2=(HpKqt+C7ys zi3FHjDHNc|TaZg<#}RAanK&KmSDv1c^D3I7c>I$jmLYJ(j#JKSDoK~?3t9tbl2EW@ zCp*Qr#&ckQc$)HtuC>W&rjH9ttX2vN%t4Vf>GeflH#Me^LuBVu^gq9zkfcqK@6e=U z5vWqaVwXP{9W^{#BjBP;y~mtgOZPclh7tGLyVn5xu0JE-n1hi8#LiP z-994s+i{d2U3@4e-TABw&w@A+{=;O`a0?8Y>uD}nfZIr9@&GaV{?(e6$?u>rMwYl= zc4RSW={rX*6g$HnCZDN8IPM@X(O$u|y)@fsk=jWMl^$v=R|((sV~PZF@%{i#_H*#U zPgyNR2_!BaxhXw+{=_vuNZ3X}SGvWde221rCj#4GJ~TF*N_`8c0t+6F;;1~6JCNK! zg|7ydEY(JKPeilPziQJ}o&xDZOz>V35PD5hFUJV`4kyLJEtSy3sqrzn6Fjne$Tc&~ zu=rE(cM2zu?56HEyi*cSJTKTE4^;eWm#rCVIK5dj38O!ED%H|Y7rTdIg7cv|OeP>t z@?06ei-*0(Ym(^$;t1IqCXVsS)J6s`pPigFlcb0GFD%rvwzZ1hgV&Onsy?DuXojQt$ui9N!~}09~A0kQOezty5ZlsB!Un`+qJ(a z#BcS(PD!kZ>#YkMS=adRskNhcD8q9=PfHMM8(v0p&^+(0srK>FY(qo{dqxU=!ZG+q zwt20Jlz|Ty{ckjNhKprYEuYQSY1nf1RU{nG-G@fTxOLsV1hba9$#|Q#!irSywJaDQ zsCE%@Bb{M*!w{I1Ba~$sS4phc7l(YR=Czfn#vv5%fg;cb9H}>b zSE5fTI1mpq;2tw-X;)lk8){x!x{|G&kY0)$$i6uLRI!_-tEMUXZo?;3lyic8z=q{c zvMOEGQ)}4{G76q3X7KvL)}VZz8orcjt@N6U;6q7R{EdCXQ@g9ii1d=OoZM_;Zke(S z1gR{y$|hz|jXzNZn3AzW4~3SG&)ZxrzWqUn$e?S#(Uyi1)SNmrkSQniVZ@qMeIaq+D`ZL)GfFh#`EpONYuOM1^2&$d%&FO`$qo!R_%2OPyYHji%S9#{|Da7De7~zRK zPQe7eOC%Y=NSM}1f!*tJPb(r9Y=xR9=Q`fSFIdtGEl4A-2SdPKRbUHjaFVf%3;}te zFfh)iGP4gN7MYgV z)MhKSeJ_I4WxA!X-l`2Cn{@vZd7I``rCouJ(PldG83S`23>2)xntnFoa%u66F>xFq z4og^7-5xLw_9K^Ce7R)P;3guG+f0r+TgL-?_rbVuDws2ZVI56%^c(lwudM0WrXL0V zGx+F)Wl_HvRg-RES6LusVZARv8B!5wrr|Yp42ePc-g8;3wuWNwV#tk`?!8^@^y1&; z4G4Cx6;G6VVK1|PK)C!EL%aCV( z-c3+9H_h8_;0xjl)$z*E(aKVZh|rtSv^iPR(8&Ny4%=pO#>DLQA&~tCs0c4^Dt{H2Iv%CaW}PA%#*d^ zvzmgP8RlmglZ;~LGU9^6)WEpS(&4kFHvK!bE z2{AP4IY0fojnTTwHD3|6&WmPGgs-lSp|e2yz^<>E;hM(YzYJTj_rbvm*@n7&TZxPl z5|w%bWWyZA%OR>AXx?2s!{JUlr|+HoxKLQ2S9+QU$P<3FIlY}|)G!Gv)4i7GYzoFH z(!XfPwrRgjtfPtzJ8zK7c@?T$S9((Gr<{$WW8^rg>_^~Evv!x=-l6-0x@93wM@Nx$ zrA(Mv>8FF(RErmDJpxt0pPOpv*87&(fvZ5*JVBT^3(+-VOQ{k=x6H=t;+ql;%YWNl zgmyWJ(8zN&BTrp*E>~N=vT28vTBvQIoOQ7DJccbR6Px@;HfmUr1OhZZmV;t~+7maUBr*t@V;v<6vJ09E2(CQs~MlpxZKklCKJ<7g~f3GPDFIt4Sj><(QV9qL7z4Y9N{L3_}VfY z&8eG>-gxCH`Ob;1FyQE7FPRAuze>9d@)B0RHHIzewt>{sD63BA%&-FvP4LoOHOn|z z^PxfL`zNcw+c89G+EH87`rJWsW3I+a+-(9I`1&OXWpHe5-yoG9p}L4!wGN(A3s2iy z--8YU1;-hrQy}dsV1ie)z8X1Vo(ijj>g)RcptOKmBxqx7>s-Pmd5qF zRJn8eiT>zsh|#MYHa$pbZG*cu)~0_JsYn{|fm^4E%V4eEgB_-n!zg5s`JdXEh2@y> zUp0r?%jz5-fsE%9)H9(|sA|MM;t}!U&-V)Y`)CuEO~av3Cvfp%*h)SL{zdTCB1=3d zOO9~2!BV=S_5Gliq4Bt^CTh1RemYtXgC17SmM!h>H9X3MJmZfI`&Po+^vb4HbI9=E zrW;QWM=UpVL#e_GrjSzhoz343sGD}~cqwzixmb`@U}Af0$8#KYS>)NY605rV7ixsX z0;b%*lXXK$AD;Y{X<6^!nBb9fkr6Z0-puYBISZ4tvqZ+HuRIWaO7m^>07X}AS=hJS z^6b@BOB372Q{MvMoc_koW6YK&*u}7fgAF)(Zfsd{2Nn3Tbsqt)s^qB>{T+MhT-12W&!qb{$|NX0Uf_C7q2 z9&jN&>_06Ym)wWg@A9)3Ka%J{n~~C~r`>Pu?bdlOvsWS_X=D~g?)YY=ZBKKfLSC#; zX2>|`WGU?BPr}x&otR&=mf|>lMlbT02mU7!!9mTsrpm_jB=`gUQf-y)J}V}BhhgF0T(WKYV4wmry;Goj12Mu*=`Qm80g+HoiI)TYo)QVu~!%{4$ascZJ(}rzgXo) z+*2&m9?Be_ZePwZIqcm!M^VWP1aNv&Sh@f)!(cLYBnl!Ry!F|d6K_3#zPCLmn> zu;fzZmf_#uL^Ag+5->_OB_+PsB*9*;p3iZ3Dm2ZDUSvUkHFV#**m>baC&}64oOCL0-ZOcO)~&$Ek{gk92mIEN(y3i3 z8~#m%{_b=51jHo)ot>(@qoPnV*Y~9IW+y_WMVY<^iAAEtl%5o+{|_Y7_%q$(c$_z} zONe+xKAq1=>xL3c%~(_d!i6g4`Dvdsg^Mgs)Zp}r-Iv7;db+FRhCN)RI~XjZr~}*1 zuJiu(vUEzo;@!Q^$CB7{a2;5S0oh3ILg!7pN5Rt*i3|g84~H@7B_(s~IPr7mW}mE0 zT)&aWDy-FImj~V@>?-M-<>-S3P>|qRLsillGvy-NRk~g`(spX;F7mxM-0JA+I&vTQ zedIn-7Wx4&4uT1@WQ^0XB&a0y#GeMoV9p+uD>Ydh$iVJwWh$xKALuJV_+s+vcs$~T z%^f|ikV>wS66-JAOcVyXGwiv*#({=BTNDiy6b2x7rEoC1&1WF*N*N&S8@Pt!(Vt_9 z7S>nW0>{4qW9W5cC*V`HXUPXnstG+K7LZt~{`!Y_vz&Y4(QiI~P+ycbKUukLyCFV$XL-7vb@UUyQQk zADB+GH*xHnC5y4t=Xp35LiQ+?O*!vb@6HGQ#-)4He(wZHNBe19&nMk1&5!?Qx{7*2 z-jbIA&E9C-@*cmx)r8G(Id6Dmb)@X+RAKbunjrx_X9t=}=70Jb)GJ&{w+C1JO{jC8979uoyb2EX@Voj$W8>a zeegG&?tbZ!DS@#47T&~~StAn?=2n%Aa?({YRniiDah`1V@(+9$+AexiTN-WPSln(A zlLNZ>Z>QoeXD{Kw@3E?U`CEITLk8$vxrkPU9$goV_EkBV{m85(W4 zX&yP^g9JW=8w4z6|MBjJ$B*wDGnGc!_p@vSO9=$xLgOeduL;KEi!h4K)gBkO;KMI4 z8Ux6(J78i0`U}wl^0H2m$3Xv}>r;QU2?BB_48^8Etta`IPPhQ5QS}~^etg;GaGtEw z_E~uW$dY~3f_4<;^bMk3 zc%$3<8-`C-f1~S;c(h$%`XP`q=O2s}mFu!U=)kmryj*thU)nebt z+63f3L5aqT?v0znm?-9NOTw;qsgeF}62OEHNZ0O*fFEx;#I#K{^MF`ozCAGYTh=9h`~%oGhX{a#QlXigbhT?um3 zO~HK?jd`Z_$^2BdxGc`A9Si55=>+eT&a|ObI#^zdCtLN>^|T+Dt)&!wl7Ol*m_ljY zbiT*8mZb-XHH%|u6>eO|4SYW-GCGQb_LL8p@Z_+3F3XruE>#9;gCc4^pa;U@kt6?^ zD@E&x_T^sf?8}npI9~!y6TuBS5sQwQ0&Pu1UOUGRn>COrR!xS@Uhd`wq-P%teZYtz zZJdtT5GhOUfdaMtiI`6P1K!ELU+Pi)70bPSVg9>M$)>9SSlofA^IDeq;yf;wcIlo> z1QUmxdD+D8iE?|#!#}Ey`|D$l%a@XFHb<=y;P0=`0UtnP@DRBSa$eTfeF5YTu1*+W z5HG+x#Q)4IYSJJckck6MJKwFe7e$5oj_LF|U1X&dd%Ht<#`5*fC1tq1*w8YmjJH+y zZ1&A%QAy982=C3vWrG0WfoDj+D*{YwQetmMM2Kg!s1&0GE+O|z52AkLZ4EwS*+=YU z{&2Rqe|!n8O_)g#|AT?O1zaU3nJ3=t(il7z1}G^i zK*Aib*$5&yh6}V$k$hqe^>j2Y`h9QC0nk7 ztgM0(HlOm2SNra`X~WBbX$1r5>l{a9(>dU6Vk*U#0mM4-?cYXE8G5=$wU>tzo4x|8 z`BP|_&ofY~6?Tz4e7qrlcV!Ge-Zy|gZ&E5*T59)N&hkYw{$4uE%(Q^hLg|~q#dp+58-qd4P$BwYRaGxmAAG- z$RqwMJMAC4z)_s#qI*30D)|SFNAl%~>zxAC+e4W2y_ZU4jmjZR5sr3Fdm%jm@s#*s z(U1=yNQWcdFt(ivwdJTcRu*BhiY`?mLtL4FenMRCWLF{&Vd#_@`G8y69~R+!z;7Iz zelwn$f&B^89b)AToMq>n68T_-N5rB+fUFz`HsGV$+BCX_IsLM3KCky`XWVn8Tue$z zrz3q%2;y7GRh)cm?y|{ct>5JKASZ@HB3ykm7n9md_$|^9^>vkYbm4v;HplH4sbPSf znzQu3cF&Ns$G0~m$W%U7!LsBh8`)e}WgZ=~LNy~izaCoQ@si>XLaOIG?#1PA!ikvk z&NGU>TI34S8cw$pdAuqP-mnDpr}R-g^8aT31LM2Y&)1o)1m^9sD1lAoZ$%OWFzTIi zL$pt9-)cYB(i(pw#0QM130aRm(jpHBA0IA(zs>xqC*!Tx73?eh1{S<5JF|A8>1s2#Q1d|}?b^!6UkJKaA8!Lp(+d8W#g$}I?AO6{a#EgKy z-1_4XaYT*{dNY8CJ^6&3<2}Iq(q-uV?w}I*>HpjKB=ipmt`RE-5F5u$fbomdGRIG7 zZ2-KjDt)Kp-m6fK636BBvyWDOlj-Yd)x(?wZq(H15D9h;bw00xf-EBt;FIfxOehpD*5(OG^xXIA8|K#tyR(d7_HU` zEXL50LesOI=M9ff`Y^=k@Jy@k7*q#}ncr1yYoGnV@JRio@Bt6vn8__W1%~;pk_!Ie z&c6p8yU4K0?XT8}I@SzD$9om?GCTqOl-S=K&Uc$Y5=Fm5oKdtxV~jiJMpM^XX}JE6 z<)LBF2+z+i>5k>=Uw(&1jOGQ^JCHGBYz1g`7(8cxJx!bj;Y<~zAVK6<8+<_Fa49`y z;eEYUB=h?*&eR_Mv=I+nG@WM~pLb3uA+~slTe2s08|R^5f9UepK&adbN4ba}8`#I% zt#kk76nKN}pY+`#0q4cojyVsezqC$3a9xzLOTF0)ig$YGK$CqwmNIAe|M5hw?t)N}sr(k;@%Ox!k$X92oXr z#gndw4d~OyU21Z zb|F&j(f?OSy_$bR3rR4dn9E;9N8@%bXs^%y?`pQLyjyId1AY_3|5^kC2>iFmMsOK6 z;{9?j2~vBxH&*8$CBHi7()U*nLXD4vZGcV=FpL+|(D|z!J(zyKeaK(wS;b-2ivkbu zQIBI{jMg&>^|L%al$n=%MeyA&TjF)XR5|qq({))|yLmF5yu&%*uYj!6YwX=8?$DMH z*HwE?T4|&@Ot*U`IB?ctxC3YrOV75?=kD_7Ve=VmVRw#w+_q&O$}$DZGo1*#XUZT_C z{5FvWs@6L9fnD?D?elnmz#{nv@dv%0_+!ZQpyYG~A zz@Se89Su7~TLo56YEhBIXAHE7NNa@-i?gYqQhl`S6)?yLL5IsCsjVB$5xe`was)fKPD5>fH z-Gc{B==NOWitp8?02(XzTmMT}wfVbEuvyNM`{ZA8BIIE!C zM8J6fU#maj@XJ?u;ZV#g+l}CcaVKtml{ac}OP$r)L}S)}xALp>E+==$TLf-q_D)>E zh6&wi|GvAA@-pcXMdkxDcSE$R<~=hzz0y?w-8RmA4WXD#Gn`Pf)A-da%-D7_cqf`t zI$40g?->#OuXZPtn_7;}k3ha1-n_tB|M@*JM@NYpNal% z5&XZ-tMr5!m)X@arGgj443VB!|17e6o7FV>-<|yT*KHn#93oH9#@cUA4L)oPP49eu zkM_0yjuZE4Zz|3IpLtF2&a3jS^A5QTGVfqB3}a-Qpv9f`XzcTE61Lc>?X$1z;s1Ag g?{?pYd!EF?^m|+7vR4cN2L0U9G|>2b!zS$i0h{K4n*aa+ literal 0 HcmV?d00001 diff --git a/docs/my-website/release_notes/v1.81.14.md b/docs/my-website/release_notes/v1.81.14.md index 7b9e64b608..b3a0018b16 100644 --- a/docs/my-website/release_notes/v1.81.14.md +++ b/docs/my-website/release_notes/v1.81.14.md @@ -96,6 +96,39 @@ The Compliance Playground lets you test any guardrail against our pre-built eval --- +## Performance & Reliability — Up to 13% Lower Latency + + + +This release cuts latency across all percentiles through 20+ micro-optimizations across logging, cost calculation, routing, and connection management. See [benchmarking](../../docs/benchmarks) for more info about how to benchmark yourself. + +- **Mean latency:** 78.4 ms → **70.3 ms** (−10.3%) +- **p50 latency:** 64.8 ms → **57.3 ms** (−11.7%) +- **p99 latency:** 288.9 ms → **250.0 ms** (−13.4%) + +**Streaming Connection Pool Fix** + +Fixed a 3-fold connection leak that caused TCP connection starvation under streaming workloads: the aiohttp transport wasn't closing connections, no `finally` blocks were calling close on disconnect, and a Uvicorn bug prevented disconnect signaling. [PR #21213](https://github.com/BerriAI/litellm/pull/21213) + +```mermaid +graph LR + A[Client Disconnects] --> B[Stream Abandoned] + B --> C{Connection cleaned up?} + C -->|Before| D["❌ No — connection leaked"] + C -->|After| E["✅ Yes — connection returned to pool"] +``` + +**Redis Connection Pool Reliability** + +Fixed 4 separate connection pool bugs to make how we use Redis more reliable. The most important change was on pools being leaked on cache expiry and the other fixes are detailed here in [PR #21717](https://github.com/BerriAI/litellm/pull/21717). + +```mermaid +graph LR + A[Cache Entry Expires] --> B{Pool cleanup?} + B -->|Before| C["❌ New untracked pool created — leaked"] + B -->|After| D["✅ Pool closed on eviction"] +``` + --- ## New Providers and Endpoints @@ -438,6 +471,7 @@ The Compliance Playground lets you test any guardrail against our pre-built eval - Fix Redis connection pool reliability — prevent connection exhaustion under load - [PR #21717](https://github.com/BerriAI/litellm/pull/21717) - Fix Prisma connection self-heal for auth and runtime reconnection (reverted, will be re-introduced with fixes) - [PR #21706](https://github.com/BerriAI/litellm/pull/21706) +- Close streaming connections to prevent connection pool exhaustion - [PR #21213](https://github.com/BerriAI/litellm/pull/21213) - Make `PodLockManager.release_lock` atomic compare-and-delete - [PR #21226](https://github.com/BerriAI/litellm/pull/21226) --- diff --git a/docs/my-website/sidebars.js b/docs/my-website/sidebars.js index fdccc2174c..fa090b6ecf 100644 --- a/docs/my-website/sidebars.js +++ b/docs/my-website/sidebars.js @@ -166,6 +166,7 @@ const sidebars = { "tutorials/cursor_integration", "tutorials/github_copilot_integration", "tutorials/litellm_gemini_cli", + "tutorials/google_genai_sdk", "tutorials/litellm_qwen_code_cli", "tutorials/openai_codex" ] @@ -180,6 +181,7 @@ const sidebars = { slug: "/agent_sdks" }, items: [ + "tutorials/openai_agents_sdk", "tutorials/claude_agent_sdk", "tutorials/copilotkit_sdk", "tutorials/google_adk", @@ -419,6 +421,7 @@ const sidebars = { "proxy/dynamic_rate_limit", "proxy/rate_limit_tiers", "proxy/temporary_budget_increase", + "proxy/budget_reset_and_tz", ], }, "proxy/caching", diff --git a/enterprise/LICENSE.md b/enterprise/LICENSE.md index 5cd298ce65..c14a2a0c48 100644 --- a/enterprise/LICENSE.md +++ b/enterprise/LICENSE.md @@ -7,7 +7,7 @@ With regard to the BerriAI Software: This software and associated documentation files (the "Software") may only be used in production, if you (and any entity that you represent) have agreed to, and are in compliance with, the BerriAI Subscription Terms of Service, available -via [call](https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat) or email (info@berri.ai) (the "Enterprise Terms"), or other +via [call](https://calendly.com/d/cx9p-5yf-2nm/litellm-introductions) or email (info@berri.ai) (the "Enterprise Terms"), or other agreement governing the use of the Software, as agreed by you and BerriAI, and otherwise have a valid BerriAI Enterprise license for the correct number of user seats. Subject to the foregoing sentence, you are free to diff --git a/enterprise/README.md b/enterprise/README.md index d5c27bab67..3b2ada6dd8 100644 --- a/enterprise/README.md +++ b/enterprise/README.md @@ -4,6 +4,6 @@ Code in this folder is licensed under a commercial license. Please review the [L **These features are covered under the LiteLLM Enterprise contract** -👉 **Using in an Enterprise / Need specific features ?** Meet with us [here](https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat?month=2024-02) +👉 **Using in an Enterprise / Need specific features ?** Meet with us [here](https://calendly.com/d/cx9p-5yf-2nm/litellm-introductions?month=2024-02) See all Enterprise Features here 👉 [Docs](https://docs.litellm.ai/docs/proxy/enterprise) diff --git a/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/base_email.py b/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/base_email.py index d3e0476930..2f2e444850 100644 --- a/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/base_email.py +++ b/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/base_email.py @@ -16,6 +16,10 @@ from litellm_enterprise.types.enterprise_callbacks.send_emails import ( from litellm._logging import verbose_proxy_logger from litellm.caching.caching import DualCache +from litellm.constants import ( + EMAIL_BUDGET_ALERT_MAX_SPEND_ALERT_PERCENTAGE, + EMAIL_BUDGET_ALERT_TTL, +) from litellm.integrations.custom_logger import CustomLogger from litellm.integrations.email_templates.email_footer import EMAIL_FOOTER from litellm.integrations.email_templates.key_created_email import ( @@ -24,14 +28,14 @@ from litellm.integrations.email_templates.key_created_email import ( from litellm.integrations.email_templates.key_rotated_email import ( KEY_ROTATED_EMAIL_TEMPLATE, ) -from litellm.integrations.email_templates.user_invitation_email import ( - USER_INVITATION_EMAIL_TEMPLATE, -) from litellm.integrations.email_templates.templates import ( MAX_BUDGET_ALERT_EMAIL_TEMPLATE, SOFT_BUDGET_ALERT_EMAIL_TEMPLATE, TEAM_SOFT_BUDGET_ALERT_EMAIL_TEMPLATE, ) +from litellm.integrations.email_templates.user_invitation_email import ( + USER_INVITATION_EMAIL_TEMPLATE, +) from litellm.proxy._types import ( CallInfo, InvitationNew, @@ -41,10 +45,6 @@ from litellm.proxy._types import ( ) from litellm.secret_managers.main import get_secret_bool from litellm.types.integrations.slack_alerting import LITELLM_LOGO_URL -from litellm.constants import ( - EMAIL_BUDGET_ALERT_MAX_SPEND_ALERT_PERCENTAGE, - EMAIL_BUDGET_ALERT_TTL, -) class BaseEmailLogger(CustomLogger): @@ -121,10 +121,16 @@ class BaseEmailLogger(CustomLogger): ) # Check if API key should be included in email - include_api_key = get_secret_bool(secret_name="EMAIL_INCLUDE_API_KEY", default_value=True) + include_api_key = get_secret_bool( + secret_name="EMAIL_INCLUDE_API_KEY", default_value=True + ) if include_api_key is None: include_api_key = True # Default to True if not set - key_token_display = send_key_created_email_event.virtual_key if include_api_key else "[Key hidden for security - retrieve from dashboard]" + key_token_display = ( + send_key_created_email_event.virtual_key + if include_api_key + else "[Key hidden for security - retrieve from dashboard]" + ) email_html_content = KEY_CREATED_EMAIL_TEMPLATE.format( email_logo_url=email_params.logo_url, @@ -162,10 +168,16 @@ class BaseEmailLogger(CustomLogger): ) # Check if API key should be included in email - include_api_key = get_secret_bool(secret_name="EMAIL_INCLUDE_API_KEY", default_value=True) + include_api_key = get_secret_bool( + secret_name="EMAIL_INCLUDE_API_KEY", default_value=True + ) if include_api_key is None: include_api_key = True # Default to True if not set - key_token_display = send_key_rotated_email_event.virtual_key if include_api_key else "[Key hidden for security - retrieve from dashboard]" + key_token_display = ( + send_key_rotated_email_event.virtual_key + if include_api_key + else "[Key hidden for security - retrieve from dashboard]" + ) email_html_content = KEY_ROTATED_EMAIL_TEMPLATE.format( email_logo_url=email_params.logo_url, @@ -201,7 +213,9 @@ class BaseEmailLogger(CustomLogger): ) # Format budget values - soft_budget_str = f"${event.soft_budget}" if event.soft_budget is not None else "N/A" + soft_budget_str = ( + f"${event.soft_budget}" if event.soft_budget is not None else "N/A" + ) spend_str = f"${event.spend}" if event.spend is not None else "$0.00" max_budget_info = "" if event.max_budget is not None: @@ -231,13 +245,13 @@ class BaseEmailLogger(CustomLogger): """ # Collect all recipient emails recipient_emails: List[str] = [] - + # Add additional alert emails from team metadata.soft_budget_alert_emails if hasattr(event, "alert_emails") and event.alert_emails: for email in event.alert_emails: if email and email not in recipient_emails: # Avoid duplicates recipient_emails.append(email) - + # If no recipients found, skip sending if not recipient_emails: verbose_proxy_logger.warning( @@ -268,7 +282,9 @@ class BaseEmailLogger(CustomLogger): ) # Format budget values - soft_budget_str = f"${event.soft_budget}" if event.soft_budget is not None else "N/A" + soft_budget_str = ( + f"${event.soft_budget}" if event.soft_budget is not None else "N/A" + ) spend_str = f"${event.spend}" if event.spend is not None else "$0.00" max_budget_info = "" if event.max_budget is not None: @@ -286,7 +302,7 @@ class BaseEmailLogger(CustomLogger): base_url=email_params.base_url, email_support_contact=email_params.support_contact, ) - + # Send email to all recipients await self.send_email( from_email=self.DEFAULT_LITELLM_EMAIL, @@ -313,11 +329,17 @@ class BaseEmailLogger(CustomLogger): # Format budget values spend_str = f"${event.spend}" if event.spend is not None else "$0.00" - max_budget_str = f"${event.max_budget}" if event.max_budget is not None else "N/A" - + max_budget_str = ( + f"${event.max_budget}" if event.max_budget is not None else "N/A" + ) + # Calculate percentage and alert threshold percentage = int(EMAIL_BUDGET_ALERT_MAX_SPEND_ALERT_PERCENTAGE * 100) - alert_threshold_str = f"${event.max_budget * EMAIL_BUDGET_ALERT_MAX_SPEND_ALERT_PERCENTAGE:.2f}" if event.max_budget is not None else "N/A" + alert_threshold_str = ( + f"${event.max_budget * EMAIL_BUDGET_ALERT_MAX_SPEND_ALERT_PERCENTAGE:.2f}" + if event.max_budget is not None + else "N/A" + ) email_html_content = MAX_BUDGET_ALERT_EMAIL_TEMPLATE.format( email_logo_url=email_params.logo_url, @@ -382,7 +404,10 @@ class BaseEmailLogger(CustomLogger): # For non-team alerts, require either max_budget or soft_budget if user_info.max_budget is None and user_info.soft_budget is None: return - if user_info.soft_budget is not None and user_info.spend >= user_info.soft_budget: + if ( + user_info.soft_budget is not None + and user_info.spend >= user_info.soft_budget + ): # Generate cache key based on event type and identifier # Use appropriate ID based on event_group to ensure unique cache keys per entity type if user_info.event_group == Litellm_EntityType.TEAM: @@ -395,7 +420,7 @@ class BaseEmailLogger(CustomLogger): # For KEY and other types, use token or user_id _id = user_info.token or user_info.user_id or "default_id" _cache_key = f"email_budget_alerts:soft_budget_crossed:{_id}" - + # Check if we've already sent this alert result = await _cache.async_get_cache(key=_cache_key) if result is None: @@ -420,14 +445,14 @@ class BaseEmailLogger(CustomLogger): event_group=user_info.event_group, alert_emails=user_info.alert_emails, ) - + try: # Use team-specific function for team alerts, otherwise use standard function if user_info.event_group == Litellm_EntityType.TEAM: await self.send_team_soft_budget_alert_email(webhook_event) else: await self.send_soft_budget_alert_email(webhook_event) - + # Cache the alert to prevent duplicate sends await _cache.async_set_cache( key=_cache_key, @@ -444,20 +469,27 @@ class BaseEmailLogger(CustomLogger): # For max_budget_alert, check if we've already sent an alert if type == "max_budget_alert": if user_info.max_budget is not None and user_info.spend is not None: - alert_threshold = user_info.max_budget * EMAIL_BUDGET_ALERT_MAX_SPEND_ALERT_PERCENTAGE - + alert_threshold = ( + user_info.max_budget * EMAIL_BUDGET_ALERT_MAX_SPEND_ALERT_PERCENTAGE + ) + # Only alert if we've crossed the threshold but haven't exceeded max_budget yet - if user_info.spend >= alert_threshold and user_info.spend < user_info.max_budget: + if ( + user_info.spend >= alert_threshold + and user_info.spend < user_info.max_budget + ): # Generate cache key based on event type and identifier _id = user_info.token or user_info.user_id or "default_id" _cache_key = f"email_budget_alerts:max_budget_alert:{_id}" - + # Check if we've already sent this alert result = await _cache.async_get_cache(key=_cache_key) if result is None: # Calculate percentage - percentage = int(EMAIL_BUDGET_ALERT_MAX_SPEND_ALERT_PERCENTAGE * 100) - + percentage = int( + EMAIL_BUDGET_ALERT_MAX_SPEND_ALERT_PERCENTAGE * 100 + ) + # Create WebhookEvent for max budget alert event_message = f"Max Budget Alert - {percentage}% of Maximum Budget Reached" webhook_event = WebhookEvent( @@ -478,10 +510,10 @@ class BaseEmailLogger(CustomLogger): projected_spend=user_info.projected_spend, event_group=user_info.event_group, ) - + try: await self.send_max_budget_alert_email(webhook_event) - + # Cache the alert to prevent duplicate sends await _cache.async_set_cache( key=_cache_key, @@ -525,9 +557,14 @@ class BaseEmailLogger(CustomLogger): unused_custom_fields = [] # Function to safely get custom value or default - def get_custom_or_default(custom_value: Optional[str], default_value: str, field_name: str) -> str: - if custom_value is not None: # Only check premium if trying to use custom value + def get_custom_or_default( + custom_value: Optional[str], default_value: str, field_name: str + ) -> str: + if ( + custom_value is not None + ): # Only check premium if trying to use custom value from litellm.proxy.proxy_server import premium_user + if premium_user is not True: unused_custom_fields.append(field_name) return default_value @@ -536,38 +573,48 @@ class BaseEmailLogger(CustomLogger): # Get parameters, falling back to defaults if custom values aren't allowed logo_url = get_custom_or_default(custom_logo, LITELLM_LOGO_URL, "logo URL") - support_contact = get_custom_or_default(custom_support, self.DEFAULT_SUPPORT_EMAIL, "support contact") - base_url = os.getenv("PROXY_BASE_URL", "http://0.0.0.0:4000") # Not a premium feature - signature = get_custom_or_default(custom_signature, EMAIL_FOOTER, "email signature") + support_contact = get_custom_or_default( + custom_support, self.DEFAULT_SUPPORT_EMAIL, "support contact" + ) + base_url = os.getenv( + "PROXY_BASE_URL", "http://0.0.0.0:4000" + ) # Not a premium feature + signature = get_custom_or_default( + custom_signature, EMAIL_FOOTER, "email signature" + ) # Get custom subject template based on email event type if email_event == EmailEvent.new_user_invitation: subject_template = get_custom_or_default( custom_subject_invitation, self.DEFAULT_SUBJECT_TEMPLATES[EmailEvent.new_user_invitation], - "invitation subject template" + "invitation subject template", ) elif email_event == EmailEvent.virtual_key_created: subject_template = get_custom_or_default( custom_subject_key_created, self.DEFAULT_SUBJECT_TEMPLATES[EmailEvent.virtual_key_created], - "key created subject template" + "key created subject template", ) elif email_event == EmailEvent.virtual_key_rotated: custom_subject_key_rotated = os.getenv("EMAIL_SUBJECT_KEY_ROTATED", None) subject_template = get_custom_or_default( custom_subject_key_rotated, self.DEFAULT_SUBJECT_TEMPLATES[EmailEvent.virtual_key_rotated], - "key rotated subject template" + "key rotated subject template", ) else: subject_template = "LiteLLM: {event_message}" - subject = subject_template.format(event_message=event_message) if event_message else "LiteLLM Notification" + subject = ( + subject_template.format(event_message=event_message) + if event_message + else "LiteLLM Notification" + ) - recipient_email: Optional[ - str - ] = user_email or await self._lookup_user_email_from_db(user_id=user_id) + recipient_email: Optional[str] = ( + user_email or await self._lookup_user_email_from_db(user_id=user_id) + ) if recipient_email is None: raise ValueError( f"User email not found for user_id: {user_id}. User email is required to send email." @@ -585,11 +632,9 @@ class BaseEmailLogger(CustomLogger): warning_msg = ( f"Email sent with default values instead of custom values for: {fields_str}. " "This is an Enterprise feature. To use custom email fields, please upgrade to LiteLLM Enterprise. " - "Schedule a meeting here: https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat" - ) - verbose_proxy_logger.warning( - f"{warning_msg}" + "Schedule a meeting here: https://calendly.com/d/cx9p-5yf-2nm/litellm-introductions" ) + verbose_proxy_logger.warning(f"{warning_msg}") return EmailParams( logo_url=logo_url, @@ -636,44 +681,49 @@ class BaseEmailLogger(CustomLogger): if not user_id: verbose_proxy_logger.debug("No user_id provided for invitation link") return base_url - + if not await self._is_prisma_client_available(): return base_url - + # Wait for any concurrent invitation creation to complete await self._wait_for_invitation_creation() - + # Get or create invitation invitation = await self._get_or_create_invitation(user_id) if not invitation: - verbose_proxy_logger.warning(f"Failed to get/create invitation for user_id: {user_id}") + verbose_proxy_logger.warning( + f"Failed to get/create invitation for user_id: {user_id}" + ) return base_url - + return self._construct_invitation_link(invitation.id, base_url) async def _is_prisma_client_available(self) -> bool: """Check if Prisma client is available""" from litellm.proxy.proxy_server import prisma_client - + if prisma_client is None: - verbose_proxy_logger.debug("Prisma client not found. Unable to lookup invitation") + verbose_proxy_logger.debug( + "Prisma client not found. Unable to lookup invitation" + ) return False return True async def _wait_for_invitation_creation(self) -> None: """ Wait for any concurrent invitation creation to complete. - + The UI calls /invitation/new to generate the invitation link. We wait to ensure any pending invitation creation is completed. """ import asyncio + await asyncio.sleep(10) async def _get_or_create_invitation(self, user_id: str): """ Get existing invitation or create a new one for the user - + Returns: Invitation object with id attribute, or None if failed """ @@ -681,31 +731,41 @@ class BaseEmailLogger(CustomLogger): create_invitation_for_user, ) from litellm.proxy.proxy_server import prisma_client - + if prisma_client is None: - verbose_proxy_logger.error("Prisma client is None in _get_or_create_invitation") + verbose_proxy_logger.error( + "Prisma client is None in _get_or_create_invitation" + ) return None - + try: # Try to get existing invitation - existing_invitations = await prisma_client.db.litellm_invitationlink.find_many( - where={"user_id": user_id}, - order={"created_at": "desc"}, + existing_invitations = ( + await prisma_client.db.litellm_invitationlink.find_many( + where={"user_id": user_id}, + order={"created_at": "desc"}, + ) ) - + if existing_invitations and len(existing_invitations) > 0: - verbose_proxy_logger.debug(f"Found existing invitation for user_id: {user_id}") + verbose_proxy_logger.debug( + f"Found existing invitation for user_id: {user_id}" + ) return existing_invitations[0] - + # Create new invitation if none exists - verbose_proxy_logger.debug(f"Creating new invitation for user_id: {user_id}") + verbose_proxy_logger.debug( + f"Creating new invitation for user_id: {user_id}" + ) return await create_invitation_for_user( data=InvitationNew(user_id=user_id), user_api_key_dict=UserAPIKeyAuth(user_id=user_id), ) - + except Exception as e: - verbose_proxy_logger.error(f"Error getting/creating invitation for user_id {user_id}: {e}") + verbose_proxy_logger.error( + f"Error getting/creating invitation for user_id {user_id}: {e}" + ) return None def _construct_invitation_link(self, invitation_id: str, base_url: str) -> str: diff --git a/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py b/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py index bf8bc46f72..4dcabb9c58 100644 --- a/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py +++ b/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py @@ -13,6 +13,9 @@ if TYPE_CHECKING: from litellm.router import Router +CHECK_BATCH_COST_USER_AGENT = "LiteLLM Proxy/CheckBatchCost" + + class CheckBatchCost: def __init__( self, @@ -27,6 +30,25 @@ class CheckBatchCost: self.prisma_client: PrismaClient = prisma_client self.llm_router: Router = llm_router + async def _get_user_info(self, batch_id, user_id) -> dict: + """ + Look up user email and key alias by user_id for enriching the S3 callback metadata. + Returns a dict with user_api_key_user_email and user_api_key_alias (both may be None). + """ + try: + user_row = await self.prisma_client.db.litellm_usertable.find_unique( + where={"user_id": user_id} + ) + if user_row is None: + return {} + return { + "user_api_key_user_email": getattr(user_row, "user_email", None), + "user_api_key_alias": getattr(user_row, "user_alias", None), + } + except Exception as e: + verbose_proxy_logger.error(f"CheckBatchCost: could not look up user {user_id} for batch {batch_id}: {e}") + return {} + async def check_batch_cost(self): """ Check if the batch JOB has been tracked. @@ -48,10 +70,12 @@ class CheckBatchCost: get_model_id_from_unified_batch_id, ) + # Look for all batches that have not yet been processed by CheckBatchCost jobs = await self.prisma_client.db.litellm_managedobjecttable.find_many( where={ - "status": {"in": ["validating", "in_progress", "finalizing"]}, "file_purpose": "batch", + "batch_processed" : False, + "status": {"not_in": ["failed", "expired", "cancelled"]} } ) completed_jobs = [] @@ -107,6 +131,21 @@ class CheckBatchCost: f"Batch ID: {batch_id} is complete, tracking cost and usage" ) + # aretrieve_batch is called with the raw provider batch ID, so response.id + # is the raw provider value (e.g. "batch_20260223-0518.234"). We need the + # unified base64 ID in the S3 log so downstream consumers can correlate it + # back to the batch they submitted via the proxy. + # + # CheckBatchCost builds its own LiteLLMLogging object (logging_obj below) and + # calls async_success_handler(result=response) directly. That handler calls + # _build_standard_logging_payload(response, ...) which reads response.id at + # that point — so setting response.id here is sufficient. + # + # The HTTP endpoint does this substitution via the managed files hook + # (async_post_call_success_hook). CheckBatchCost bypasses that hook entirely, + # so we do it explicitly here. + response.id = job.unified_object_id + # This background job runs as default_user_id, so going through the HTTP endpoint # would trigger check_managed_file_id_access and get 403. Instead, extract the raw # provider file ID and call afile_content directly with deployment credentials. @@ -171,11 +210,21 @@ class CheckBatchCost: function_id=str(uuid.uuid4()), ) + creator_user_id = job.created_by + user_info = await self._get_user_info(batch_id, job.created_by) + logging_obj.update_environment_variables( litellm_params={ + # set the user-agent header so that S3 callback consumers can easily identify CheckBatchCost callbacks + "proxy_server_request": { + "headers": { + "user-agent": CHECK_BATCH_COST_USER_AGENT, + } + }, "metadata": { - "user_api_key_user_id": job.created_by or "default-user-id", - } + "user_api_key_user_id": creator_user_id, + **user_info, + }, }, optional_params={}, ) @@ -191,8 +240,7 @@ class CheckBatchCost: completed_jobs.append(job) if len(completed_jobs) > 0: - # mark the jobs as complete await self.prisma_client.db.litellm_managedobjecttable.update_many( where={"id": {"in": [job.id for job in completed_jobs]}}, - data={"status": "complete"}, + data={"batch_processed": True, "status": "complete"}, ) diff --git a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py index bda20e2f74..4fa050a84a 100644 --- a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py +++ b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py @@ -1086,11 +1086,8 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): self, file_id: str ) -> List[Dict[str, Any]]: """ - Find batches in non-terminal states that reference this file. - - Non-terminal states: validating, in_progress, finalizing - Terminal states: completed, complete, failed, expired, cancelled - + Find batches that reference this file and still need cost tracking. + Find batches that are in non-terminal state and have not yet been processed by CheckBatchCost. Args: file_id: The unified file ID to check @@ -1121,7 +1118,8 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): batches = await self.prisma_client.db.litellm_managedobjecttable.find_many( where={ "file_purpose": "batch", - "status": {"in": ["validating", "in_progress", "finalizing"]}, + "batch_processed": False, + "status": {"not_in": ["failed", "expired", "cancelled"]} }, take=MAX_MATCHES_TO_RETURN, order={"created_at": "desc"}, @@ -1205,7 +1203,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): error_message += ( f"To delete this file before complete cost tracking, please delete or cancel the referencing batch(es) first. " - f"Alternatively, wait for all batches to complete processing." + f"Alternatively, wait for all batches to complete and for cost to be computed (batch_processed=true)." ) raise HTTPException( diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260214124140_baseline_diff/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260214124140_baseline_diff/migration.sql deleted file mode 100644 index 2f725d8380..0000000000 --- a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260214124140_baseline_diff/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- This is an empty migration. - diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260219181415_baseline_diff/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260219181415_baseline_diff/migration.sql new file mode 100644 index 0000000000..dd95d9d84a --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260219181415_baseline_diff/migration.sql @@ -0,0 +1,60 @@ +-- CreateTable +CREATE TABLE "LiteLLM_DailyGuardrailMetrics" ( + "guardrail_id" TEXT NOT NULL, + "date" TEXT NOT NULL, + "requests_evaluated" BIGINT NOT NULL DEFAULT 0, + "passed_count" BIGINT NOT NULL DEFAULT 0, + "blocked_count" BIGINT NOT NULL DEFAULT 0, + "flagged_count" BIGINT NOT NULL DEFAULT 0, + "avg_score" DOUBLE PRECISION, + "avg_latency_ms" DOUBLE PRECISION, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "LiteLLM_DailyGuardrailMetrics_pkey" PRIMARY KEY ("guardrail_id","date") +); + +-- CreateTable +CREATE TABLE "LiteLLM_DailyPolicyMetrics" ( + "policy_id" TEXT NOT NULL, + "date" TEXT NOT NULL, + "requests_evaluated" BIGINT NOT NULL DEFAULT 0, + "passed_count" BIGINT NOT NULL DEFAULT 0, + "blocked_count" BIGINT NOT NULL DEFAULT 0, + "flagged_count" BIGINT NOT NULL DEFAULT 0, + "avg_score" DOUBLE PRECISION, + "avg_latency_ms" DOUBLE PRECISION, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "LiteLLM_DailyPolicyMetrics_pkey" PRIMARY KEY ("policy_id","date") +); + +-- CreateTable +CREATE TABLE "LiteLLM_SpendLogGuardrailIndex" ( + "request_id" TEXT NOT NULL, + "guardrail_id" TEXT NOT NULL, + "policy_id" TEXT, + "start_time" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "LiteLLM_SpendLogGuardrailIndex_pkey" PRIMARY KEY ("request_id","guardrail_id") +); + +-- CreateIndex +CREATE INDEX "LiteLLM_DailyGuardrailMetrics_date_idx" ON "LiteLLM_DailyGuardrailMetrics"("date"); + +-- CreateIndex +CREATE INDEX "LiteLLM_DailyGuardrailMetrics_guardrail_id_idx" ON "LiteLLM_DailyGuardrailMetrics"("guardrail_id"); + +-- CreateIndex +CREATE INDEX "LiteLLM_DailyPolicyMetrics_date_idx" ON "LiteLLM_DailyPolicyMetrics"("date"); + +-- CreateIndex +CREATE INDEX "LiteLLM_DailyPolicyMetrics_policy_id_idx" ON "LiteLLM_DailyPolicyMetrics"("policy_id"); + +-- CreateIndex +CREATE INDEX "LiteLLM_SpendLogGuardrailIndex_guardrail_id_start_time_idx" ON "LiteLLM_SpendLogGuardrailIndex"("guardrail_id", "start_time"); + +-- CreateIndex +CREATE INDEX "LiteLLM_SpendLogGuardrailIndex_policy_id_start_time_idx" ON "LiteLLM_SpendLogGuardrailIndex"("policy_id", "start_time"); + diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260222000000_add_batch_processed_to_managed_object_table/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260222000000_add_batch_processed_to_managed_object_table/migration.sql new file mode 100644 index 0000000000..ac390d164d --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260222000000_add_batch_processed_to_managed_object_table/migration.sql @@ -0,0 +1,3 @@ +-- Add batch_processed column to LiteLLM_ManagedObjectTable +-- Set to true by CheckBatchCost after cost has been computed for a completed batch +ALTER TABLE "LiteLLM_ManagedObjectTable" ADD COLUMN "batch_processed" BOOLEAN NOT NULL DEFAULT false; diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index 5d2cad6da5..4af7484148 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -813,6 +813,7 @@ model LiteLLM_ManagedObjectTable { // for batches or finetuning jobs which use t file_object Json // Stores the OpenAIFileObject file_purpose String // either 'batch' or 'fine-tune' status String? // check if batch cost has been tracked + batch_processed Boolean @default(false) // set to true by CheckBatchCost after cost is computed created_at DateTime @default(now()) created_by String? updated_at DateTime @updatedAt @@ -866,6 +867,54 @@ model LiteLLM_GuardrailsTable { updated_at DateTime @updatedAt } +// Daily guardrail metrics for usage dashboard (one row per guardrail per day) +model LiteLLM_DailyGuardrailMetrics { + guardrail_id String // logical id; may not FK if guardrail from config + date String // YYYY-MM-DD + requests_evaluated BigInt @default(0) + passed_count BigInt @default(0) + blocked_count BigInt @default(0) + flagged_count BigInt @default(0) + avg_score Float? + avg_latency_ms Float? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@id([guardrail_id, date]) + @@index([date]) + @@index([guardrail_id]) +} + +// Daily policy metrics for usage dashboard (one row per policy per day) +model LiteLLM_DailyPolicyMetrics { + policy_id String + date String // YYYY-MM-DD + requests_evaluated BigInt @default(0) + passed_count BigInt @default(0) + blocked_count BigInt @default(0) + flagged_count BigInt @default(0) + avg_score Float? + avg_latency_ms Float? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@id([policy_id, date]) + @@index([date]) + @@index([policy_id]) +} + +// Index for fast "last N logs for guardrail/policy" from SpendLogs +model LiteLLM_SpendLogGuardrailIndex { + request_id String + guardrail_id String + policy_id String? // set when run as part of a policy pipeline + start_time DateTime + + @@id([request_id, guardrail_id]) + @@index([guardrail_id, start_time]) + @@index([policy_id, start_time]) +} + // Prompt table for storing prompt configurations model LiteLLM_PromptTable { id String @id @default(uuid()) diff --git a/litellm/__init__.py b/litellm/__init__.py index 97f36a9b00..1e74b5692e 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -339,6 +339,10 @@ model_cost_map_url: str = os.getenv( "LITELLM_MODEL_COST_MAP_URL", "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json", ) +blog_posts_url: str = os.getenv( + "LITELLM_BLOG_POSTS_URL", + "https://raw.githubusercontent.com/BerriAI/litellm/main/litellm/blog_posts.json", +) anthropic_beta_headers_url: str = os.getenv( "LITELLM_ANTHROPIC_BETA_HEADERS_URL", "https://raw.githubusercontent.com/BerriAI/litellm/main/litellm/anthropic_beta_headers_config.json", @@ -405,6 +409,7 @@ disable_aiohttp_trust_env: bool = ( force_ipv4: bool = ( False # when True, litellm will force ipv4 for all LLM requests. Some users have seen httpx ConnectionError when using ipv6. ) +network_mock: bool = False # When True, use mock transport — no real network calls ####### STOP SEQUENCE LIMIT ####### disable_stop_sequence_limit: bool = False # when True, stop sequence limit is disabled @@ -614,8 +619,9 @@ def is_openai_finetune_model(key: str) -> bool: return key.startswith("ft:") and not key.count(":") > 1 -def add_known_models(): - for key, value in model_cost.items(): +def add_known_models(model_cost_map: Optional[Dict] = None): + _map = model_cost_map if model_cost_map is not None else model_cost + for key, value in _map.items(): if value.get("litellm_provider") == "openai" and not is_openai_finetune_model( key ): diff --git a/litellm/blog_posts.json b/litellm/blog_posts.json new file mode 100644 index 0000000000..15340514bc --- /dev/null +++ b/litellm/blog_posts.json @@ -0,0 +1,10 @@ +{ + "posts": [ + { + "title": "Incident Report: SERVER_ROOT_PATH regression broke UI routing", + "description": "How a single line removal caused UI 404s for all deployments using SERVER_ROOT_PATH, and the tests we added to prevent it from happening again.", + "date": "2026-02-21", + "url": "https://docs.litellm.ai/blog/server-root-path-incident" + } + ] +} diff --git a/litellm/caching/caching.py b/litellm/caching/caching.py index a03bff6068..ad02d2ea89 100644 --- a/litellm/caching/caching.py +++ b/litellm/caching/caching.py @@ -108,6 +108,7 @@ class Cache: qdrant_collection_name: Optional[str] = None, qdrant_quantization_config: Optional[str] = None, qdrant_semantic_cache_embedding_model: str = "text-embedding-ada-002", + qdrant_semantic_cache_vector_size: Optional[int] = None, # GCP IAM authentication parameters gcp_service_account: Optional[str] = None, gcp_ssl_ca_certs: Optional[str] = None, @@ -207,6 +208,7 @@ class Cache: similarity_threshold=similarity_threshold, quantization_config=qdrant_quantization_config, embedding_model=qdrant_semantic_cache_embedding_model, + vector_size=qdrant_semantic_cache_vector_size, ) elif type == LiteLLMCacheType.LOCAL: self.cache = InMemoryCache() diff --git a/litellm/caching/qdrant_semantic_cache.py b/litellm/caching/qdrant_semantic_cache.py index 0e77b5a6c2..181effa01d 100644 --- a/litellm/caching/qdrant_semantic_cache.py +++ b/litellm/caching/qdrant_semantic_cache.py @@ -31,6 +31,7 @@ class QdrantSemanticCache(BaseCache): quantization_config=None, embedding_model="text-embedding-ada-002", host_type=None, + vector_size=None, ): import os @@ -53,6 +54,7 @@ class QdrantSemanticCache(BaseCache): raise Exception("similarity_threshold must be provided, passed None") self.similarity_threshold = similarity_threshold self.embedding_model = embedding_model + self.vector_size = vector_size if vector_size is not None else QDRANT_VECTOR_SIZE headers = {} # check if defined as os.environ/ variable @@ -138,7 +140,7 @@ class QdrantSemanticCache(BaseCache): new_collection_status = self.sync_client.put( url=f"{self.qdrant_api_base}/collections/{self.collection_name}", json={ - "vectors": {"size": QDRANT_VECTOR_SIZE, "distance": "Cosine"}, + "vectors": {"size": self.vector_size, "distance": "Cosine"}, "quantization_config": quantization_params, }, headers=self.headers, diff --git a/litellm/constants.py b/litellm/constants.py index 89992b459c..ee79f2fa56 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -242,9 +242,13 @@ REDIS_DAILY_END_USER_SPEND_UPDATE_BUFFER_KEY = ( REDIS_DAILY_AGENT_SPEND_UPDATE_BUFFER_KEY = "litellm_daily_agent_spend_update_buffer" REDIS_DAILY_TAG_SPEND_UPDATE_BUFFER_KEY = "litellm_daily_tag_spend_update_buffer" MAX_REDIS_BUFFER_DEQUEUE_COUNT = int(os.getenv("MAX_REDIS_BUFFER_DEQUEUE_COUNT", 100)) -MAX_SIZE_IN_MEMORY_QUEUE = int(os.getenv("MAX_SIZE_IN_MEMORY_QUEUE", 2000)) # Bounds asyncio.Queue() instances (log queues, spend update queues, etc.) to prevent unbounded memory growth LITELLM_ASYNCIO_QUEUE_MAXSIZE = int(os.getenv("LITELLM_ASYNCIO_QUEUE_MAXSIZE", 1000)) +# Aggregation threshold: default to 80% of the asyncio queue maxsize so the check can always trigger. +# Must be < LITELLM_ASYNCIO_QUEUE_MAXSIZE; if set higher the aggregation logic will never fire. +MAX_SIZE_IN_MEMORY_QUEUE = int( + os.getenv("MAX_SIZE_IN_MEMORY_QUEUE", int(LITELLM_ASYNCIO_QUEUE_MAXSIZE * 0.8)) +) MAX_IN_MEMORY_QUEUE_FLUSH_COUNT = int( os.getenv("MAX_IN_MEMORY_QUEUE_FLUSH_COUNT", 1000) ) diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index 05c3da224e..cc0f818b0a 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -480,6 +480,7 @@ def cost_per_token( # noqa: PLR0915 model=model_without_prefix, custom_llm_provider=custom_llm_provider, usage=usage_block, + service_tier=service_tier, ) elif custom_llm_provider == "anthropic": return anthropic_cost_per_token(model=model, usage=usage_block) @@ -500,7 +501,9 @@ def cost_per_token( # noqa: PLR0915 model=model, usage=usage_block, response_time_ms=response_time_ms ) elif custom_llm_provider == "gemini": - return gemini_cost_per_token(model=model, usage=usage_block) + return gemini_cost_per_token( + model=model, usage=usage_block, service_tier=service_tier + ) elif custom_llm_provider == "deepseek": return deepseek_cost_per_token(model=model, usage=usage_block) elif custom_llm_provider == "perplexity": @@ -704,6 +707,36 @@ def _get_response_model(completion_response: Any) -> Optional[str]: return None +_GEMINI_TRAFFIC_TYPE_TO_SERVICE_TIER: dict = { + # ON_DEMAND_PRIORITY maps to "priority" — selects input_cost_per_token_priority, etc. + "ON_DEMAND_PRIORITY": "priority", + # FLEX / BATCH maps to "flex" — selects input_cost_per_token_flex, etc. + "FLEX": "flex", + "BATCH": "flex", + # ON_DEMAND is standard pricing — no service_tier suffix applied + "ON_DEMAND": None, +} + + +def _map_traffic_type_to_service_tier(traffic_type: Optional[str]) -> Optional[str]: + """ + Map a Gemini usageMetadata.trafficType value to a LiteLLM service_tier string. + + This allows the same `_priority` / `_flex` cost-key suffix logic used for + OpenAI/Azure to work for Gemini and Vertex AI models. + + trafficType values seen in practice + ------------------------------------ + ON_DEMAND -> standard pricing (service_tier = None) + ON_DEMAND_PRIORITY -> priority pricing (service_tier = "priority") + FLEX / BATCH -> batch/flex pricing (service_tier = "flex") + """ + if traffic_type is None: + return None + service_tier = _GEMINI_TRAFFIC_TYPE_TO_SERVICE_TIER.get(traffic_type.upper()) + return service_tier + + def _get_usage_object( completion_response: Any, ) -> Optional[Usage]: @@ -1145,6 +1178,20 @@ def completion_cost( # noqa: PLR0915 "custom_llm_provider", custom_llm_provider or None ) region_name = hidden_params.get("region_name", region_name) + + # For Gemini/Vertex AI responses, trafficType is stored in + # provider_specific_fields. Map it to the service_tier used + # by the cost key lookup (_priority / _flex suffixes) so that + # ON_DEMAND_PRIORITY requests are billed at priority prices. + if service_tier is None: + provider_specific = ( + hidden_params.get("provider_specific_fields") or {} + ) + raw_traffic_type = provider_specific.get("traffic_type") + if raw_traffic_type: + service_tier = _map_traffic_type_to_service_tier( + raw_traffic_type + ) else: if model is None: raise ValueError( diff --git a/litellm/integrations/SlackAlerting/hanging_request_check.py b/litellm/integrations/SlackAlerting/hanging_request_check.py index 713e790ba9..d2f70c9caf 100644 --- a/litellm/integrations/SlackAlerting/hanging_request_check.py +++ b/litellm/integrations/SlackAlerting/hanging_request_check.py @@ -172,4 +172,6 @@ Team Alias: `{hanging_request_data.team_alias}`""" level="Medium", alert_type=AlertType.llm_requests_hanging, alerting_metadata=hanging_request_data.alerting_metadata or {}, + request_model=hanging_request_data.model, + api_base=hanging_request_data.api_base, ) diff --git a/litellm/integrations/SlackAlerting/slack_alerting.py b/litellm/integrations/SlackAlerting/slack_alerting.py index a525856db8..35634d5067 100644 --- a/litellm/integrations/SlackAlerting/slack_alerting.py +++ b/litellm/integrations/SlackAlerting/slack_alerting.py @@ -70,6 +70,7 @@ class SlackAlerting(CustomBatchLogger): ] = None, # if user wants to separate alerts to diff channels alerting_args={}, default_webhook_url: Optional[str] = None, + alert_type_config: Optional[Dict[str, dict]] = None, **kwargs, ): if alerting_threshold is None: @@ -92,6 +93,12 @@ class SlackAlerting(CustomBatchLogger): self.hanging_request_check = AlertingHangingRequestCheck( slack_alerting_object=self, ) + self.alert_type_config: Dict[str, AlertTypeConfig] = {} + if alert_type_config: + for key, val in alert_type_config.items(): + self.alert_type_config[key] = AlertTypeConfig(**val) if isinstance(val, dict) else val + self.digest_buckets: Dict[str, DigestEntry] = {} + self.digest_lock = asyncio.Lock() super().__init__(**kwargs, flush_lock=self.flush_lock) def update_values( @@ -102,6 +109,7 @@ class SlackAlerting(CustomBatchLogger): alert_to_webhook_url: Optional[Dict[AlertType, Union[List[str], str]]] = None, alerting_args: Optional[Dict] = None, llm_router: Optional[Router] = None, + alert_type_config: Optional[Dict[str, dict]] = None, ): if alerting is not None: self.alerting = alerting @@ -116,6 +124,9 @@ class SlackAlerting(CustomBatchLogger): if not self.periodic_started: asyncio.create_task(self.periodic_flush()) self.periodic_started = True + if alert_type_config is not None: + for key, val in alert_type_config.items(): + self.alert_type_config[key] = AlertTypeConfig(**val) if isinstance(val, dict) else val if alert_to_webhook_url is not None: # update the dict @@ -284,6 +295,8 @@ class SlackAlerting(CustomBatchLogger): level="Low", alert_type=AlertType.llm_too_slow, alerting_metadata=alerting_metadata, + request_model=model, + api_base=api_base, ) async def async_update_daily_reports( @@ -1354,13 +1367,15 @@ Model Info: return False - async def send_alert( + async def send_alert( # noqa: PLR0915 self, message: str, level: Literal["Low", "Medium", "High"], alert_type: AlertType, alerting_metadata: dict, user_info: Optional[WebhookEvent] = None, + request_model: Optional[str] = None, + api_base: Optional[str] = None, **kwargs, ): """ @@ -1376,6 +1391,8 @@ Model Info: Parameters: level: str - Low|Medium|High - if calls might fail (Medium) or are failing (High); Currently, no alerts would be 'Low'. message: str - what is the alert about + request_model: Optional[str] - model name for digest grouping + api_base: Optional[str] - api base for digest grouping """ if self.alerting is None: return @@ -1413,6 +1430,44 @@ Model Info: from datetime import datetime + # Check if digest mode is enabled for this alert type + alert_type_name_str = getattr(alert_type, "value", str(alert_type)) + _atc = self.alert_type_config.get(alert_type_name_str) + if _atc is not None and _atc.digest: + # Resolve webhook URL for this alert type (needed for digest entry) + if ( + self.alert_to_webhook_url is not None + and alert_type in self.alert_to_webhook_url + ): + _digest_webhook: Optional[Union[str, List[str]]] = self.alert_to_webhook_url[alert_type] + elif self.default_webhook_url is not None: + _digest_webhook = self.default_webhook_url + else: + _digest_webhook = os.getenv("SLACK_WEBHOOK_URL", None) + if _digest_webhook is None: + raise ValueError("Missing SLACK_WEBHOOK_URL from environment") + + digest_key = f"{alert_type_name_str}:{request_model or ''}:{api_base or ''}" + + async with self.digest_lock: + now = datetime.now() + if digest_key in self.digest_buckets: + self.digest_buckets[digest_key]["count"] += 1 + self.digest_buckets[digest_key]["last_time"] = now + else: + self.digest_buckets[digest_key] = DigestEntry( + alert_type=alert_type_name_str, + request_model=request_model or "", + api_base=api_base or "", + first_message=message, + level=level, + count=1, + start_time=now, + last_time=now, + webhook_url=_digest_webhook, + ) + return # Suppress immediate alert; will be emitted by _flush_digest_buckets + # Get the current timestamp current_time = datetime.now().strftime("%H:%M:%S") _proxy_base_url = os.getenv("PROXY_BASE_URL", None) @@ -1488,6 +1543,72 @@ Model Info: await asyncio.gather(*tasks) self.log_queue.clear() + async def _flush_digest_buckets(self): + """Flush any digest buckets whose interval has expired. + + For each expired bucket, formats a digest summary message and + appends it to the log_queue for delivery via the normal batching path. + """ + from datetime import datetime + + now = datetime.now() + flushed_keys: List[str] = [] + + async with self.digest_lock: + for key, entry in self.digest_buckets.items(): + alert_type_name = entry["alert_type"] + _atc = self.alert_type_config.get(alert_type_name) + if _atc is None: + continue + elapsed = (now - entry["start_time"]).total_seconds() + if elapsed < _atc.digest_interval: + continue + + # Build digest summary message + start_ts = entry["start_time"].strftime("%H:%M:%S") + end_ts = entry["last_time"].strftime("%H:%M:%S") + start_date = entry["start_time"].strftime("%Y-%m-%d") + end_date = entry["last_time"].strftime("%Y-%m-%d") + formatted_message = ( + f"Alert type: `{alert_type_name}` (Digest)\n" + f"Level: `{entry['level']}`\n" + f"Start: `{start_date} {start_ts}`\n" + f"End: `{end_date} {end_ts}`\n" + f"Count: `{entry['count']}`\n\n" + f"Message: {entry['first_message']}" + ) + _proxy_base_url = os.getenv("PROXY_BASE_URL", None) + if _proxy_base_url is not None: + formatted_message += f"\n\nProxy URL: `{_proxy_base_url}`" + + payload = {"text": formatted_message} + headers = {"Content-type": "application/json"} + webhook_url = entry["webhook_url"] + + if isinstance(webhook_url, list): + for url in webhook_url: + self.log_queue.append( + {"url": url, "headers": headers, "payload": payload, "alert_type": alert_type_name} + ) + else: + self.log_queue.append( + {"url": webhook_url, "headers": headers, "payload": payload, "alert_type": alert_type_name} + ) + flushed_keys.append(key) + + for key in flushed_keys: + del self.digest_buckets[key] + + async def periodic_flush(self): + """Override base periodic_flush to also flush digest buckets.""" + while True: + await asyncio.sleep(self.flush_interval) + try: + await self._flush_digest_buckets() + except Exception as e: + verbose_proxy_logger.debug(f"Error flushing digest buckets: {str(e)}") + await self.flush_queue() + async def async_log_success_event(self, kwargs, response_obj, start_time, end_time): """Log deployment latency""" try: diff --git a/litellm/integrations/custom_guardrail.py b/litellm/integrations/custom_guardrail.py index 4a1e3e41e9..bf330944ef 100644 --- a/litellm/integrations/custom_guardrail.py +++ b/litellm/integrations/custom_guardrail.py @@ -587,9 +587,10 @@ class CustomGuardrail(CustomLogger): elif "litellm_metadata" in request_data: _append_guardrail_info(request_data["litellm_metadata"]) else: - verbose_logger.warning( - "unable to log guardrail information. No metadata found in request_data" - ) + # Ensure guardrail info is always logged (e.g. proxy may not have set + # metadata yet). Attach to "metadata" so spend log / standard logging see it. + request_data["metadata"] = {} + _append_guardrail_info(request_data["metadata"]) async def apply_guardrail( self, diff --git a/litellm/litellm_core_utils/duration_parser.py b/litellm/litellm_core_utils/duration_parser.py index 9a317cfcf0..70c28c4e06 100644 --- a/litellm/litellm_core_utils/duration_parser.py +++ b/litellm/litellm_core_utils/duration_parser.py @@ -8,8 +8,9 @@ duration_in_seconds is used in diff parts of the code base, example import re import time -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta, timezone, tzinfo from typing import Optional, Tuple +from zoneinfo import ZoneInfo def _extract_from_regex(duration: str) -> Tuple[int, str]: @@ -116,7 +117,7 @@ def get_next_standardized_reset_time( - Next reset time at a standardized interval in the specified timezone """ # Set up timezone and normalize current time - current_time, timezone = _setup_timezone(current_time, timezone_str) + current_time, tz = _setup_timezone(current_time, timezone_str) # Parse duration value, unit = _parse_duration(duration) @@ -131,7 +132,7 @@ def get_next_standardized_reset_time( # Handle different time units if unit == "d": - return _handle_day_reset(current_time, base_midnight, value, timezone) + return _handle_day_reset(current_time, base_midnight, value, tz) elif unit == "h": return _handle_hour_reset(current_time, base_midnight, value) elif unit == "m": @@ -147,22 +148,13 @@ def get_next_standardized_reset_time( def _setup_timezone( current_time: datetime, timezone_str: str = "UTC" -) -> Tuple[datetime, timezone]: +) -> Tuple[datetime, tzinfo]: """Set up timezone and normalize current time to that timezone.""" try: if timezone_str is None: - tz = timezone.utc + tz: tzinfo = timezone.utc else: - # Map common timezone strings to their UTC offsets - timezone_map = { - "US/Eastern": timezone(timedelta(hours=-4)), # EDT - "US/Pacific": timezone(timedelta(hours=-7)), # PDT - "Asia/Kolkata": timezone(timedelta(hours=5, minutes=30)), # IST - "Asia/Bangkok": timezone(timedelta(hours=7)), # ICT (Indochina Time) - "Europe/London": timezone(timedelta(hours=1)), # BST - "UTC": timezone.utc, - } - tz = timezone_map.get(timezone_str, timezone.utc) + tz = ZoneInfo(timezone_str) except Exception: # If timezone is invalid, fall back to UTC tz = timezone.utc @@ -190,7 +182,7 @@ def _parse_duration(duration: str) -> Tuple[Optional[int], Optional[str]]: def _handle_day_reset( - current_time: datetime, base_midnight: datetime, value: int, timezone: timezone + current_time: datetime, base_midnight: datetime, value: int, tz: tzinfo ) -> datetime: """Handle day-based reset times.""" # Handle zero value - immediate expiration @@ -215,7 +207,7 @@ def _handle_day_reset( minute=0, second=0, microsecond=0, - tzinfo=timezone, + tzinfo=tz, ) else: next_reset = datetime( @@ -226,7 +218,7 @@ def _handle_day_reset( minute=0, second=0, microsecond=0, - tzinfo=timezone, + tzinfo=tz, ) return next_reset else: # Custom day value - next interval is value days from current diff --git a/litellm/litellm_core_utils/get_blog_posts.py b/litellm/litellm_core_utils/get_blog_posts.py new file mode 100644 index 0000000000..4f054c78ff --- /dev/null +++ b/litellm/litellm_core_utils/get_blog_posts.py @@ -0,0 +1,128 @@ +""" +Pulls the latest LiteLLM blog posts from GitHub. + +Falls back to the bundled local backup on any failure. +GitHub JSON URL is configured via litellm.blog_posts_url (or LITELLM_BLOG_POSTS_URL env var). + +Disable remote fetching entirely: + export LITELLM_LOCAL_BLOG_POSTS=True +""" + +import json +import os +import time +from importlib.resources import files +from typing import Any, Dict, List, Optional + +import httpx +from pydantic import BaseModel + +from litellm import verbose_logger + +BLOG_POSTS_TTL_SECONDS: int = 3600 # 1 hour + + +class BlogPost(BaseModel): + title: str + description: str + date: str + url: str + + +class BlogPostsResponse(BaseModel): + posts: List[BlogPost] + + +class GetBlogPosts: + """ + Fetches, validates, and caches LiteLLM blog posts. + + Mirrors the structure of GetModelCostMap: + - Fetches from GitHub with a 5-second timeout + - Validates the response has a non-empty ``posts`` list + - Caches the result in-process for BLOG_POSTS_TTL_SECONDS (1 hour) + - Falls back to the bundled local backup on any failure + """ + + _cached_posts: Optional[List[Dict[str, str]]] = None + _last_fetch_time: float = 0.0 + + @staticmethod + def load_local_blog_posts() -> List[Dict[str, str]]: + """Load the bundled local backup blog posts.""" + content = json.loads( + files("litellm") + .joinpath("blog_posts.json") + .read_text(encoding="utf-8") + ) + return content.get("posts", []) + + @staticmethod + def fetch_remote_blog_posts(url: str, timeout: int = 5) -> dict: + """ + Fetch blog posts JSON from a remote URL. + + Returns the parsed response. Raises on network/parse errors. + """ + response = httpx.get(url, timeout=timeout) + response.raise_for_status() + return response.json() + + @staticmethod + def validate_blog_posts(data: Any) -> bool: + """Return True if data is a dict with a non-empty ``posts`` list.""" + if not isinstance(data, dict): + verbose_logger.warning( + "LiteLLM: Blog posts response is not a dict (type=%s). " + "Falling back to local backup.", + type(data).__name__, + ) + return False + posts = data.get("posts") + if not isinstance(posts, list) or len(posts) == 0: + verbose_logger.warning( + "LiteLLM: Blog posts response has no valid 'posts' list. " + "Falling back to local backup.", + ) + return False + return True + + @classmethod + def get_blog_posts(cls, url: str) -> List[Dict[str, str]]: + """ + Return the blog posts list. + + Uses the in-process cache if within BLOG_POSTS_TTL_SECONDS. + Fetches from ``url`` otherwise, falling back to local backup on failure. + """ + if os.getenv("LITELLM_LOCAL_BLOG_POSTS", "").lower() == "true": + return cls.load_local_blog_posts() + + now = time.time() + cached = cls._cached_posts + if cached is not None and (now - cls._last_fetch_time) < BLOG_POSTS_TTL_SECONDS: + return cached + + try: + data = cls.fetch_remote_blog_posts(url) + except Exception as e: + verbose_logger.warning( + "LiteLLM: Failed to fetch blog posts from %s: %s. " + "Falling back to local backup.", + url, + str(e), + ) + return cls.load_local_blog_posts() + + if not cls.validate_blog_posts(data): + return cls.load_local_blog_posts() + + posts = data["posts"] + cls._cached_posts = posts + cls._last_fetch_time = now + return posts + + +def get_blog_posts(url: str) -> List[Dict[str, str]]: + """Public entry point — returns the blog posts list.""" + return GetBlogPosts.get_blog_posts(url=url) diff --git a/litellm/litellm_core_utils/get_model_cost_map.py b/litellm/litellm_core_utils/get_model_cost_map.py index e622a31745..f9398979f9 100644 --- a/litellm/litellm_core_utils/get_model_cost_map.py +++ b/litellm/litellm_core_utils/get_model_cost_map.py @@ -11,6 +11,7 @@ export LITELLM_LOCAL_MODEL_COST_MAP=True import json import os from importlib.resources import files +from typing import Optional import httpx @@ -151,6 +152,37 @@ class GetModelCostMap: return response.json() +class ModelCostMapSourceInfo: + """Tracks the source of the currently loaded model cost map.""" + + source: str = "local" # "local" or "remote" + url: Optional[str] = None + is_env_forced: bool = False + fallback_reason: Optional[str] = None + + +# Module-level singleton tracking the source of the current cost map +_cost_map_source_info = ModelCostMapSourceInfo() + + +def get_model_cost_map_source_info() -> dict: + """ + Return metadata about where the current model cost map was loaded from. + + Returns a dict with: + - source: "local" or "remote" + - url: the remote URL attempted (or None for local-only) + - is_env_forced: True if LITELLM_LOCAL_MODEL_COST_MAP=True forced local usage + - fallback_reason: human-readable reason if remote failed and local was used + """ + return { + "source": _cost_map_source_info.source, + "url": _cost_map_source_info.url, + "is_env_forced": _cost_map_source_info.is_env_forced, + "fallback_reason": _cost_map_source_info.fallback_reason, + } + + def get_model_cost_map(url: str) -> dict: """ Public entry point — returns the model cost map dict. @@ -166,8 +198,15 @@ def get_model_cost_map(url: str) -> dict: # Note: can't use get_secret_bool here — this runs during litellm.__init__ # before litellm._key_management_settings is set. if os.getenv("LITELLM_LOCAL_MODEL_COST_MAP", "").lower() == "true": + _cost_map_source_info.source = "local" + _cost_map_source_info.url = None + _cost_map_source_info.is_env_forced = True + _cost_map_source_info.fallback_reason = None return GetModelCostMap.load_local_model_cost_map() + _cost_map_source_info.url = url + _cost_map_source_info.is_env_forced = False + try: content = GetModelCostMap.fetch_remote_model_cost_map(url) except Exception as e: @@ -177,6 +216,8 @@ def get_model_cost_map(url: str) -> dict: url, str(e), ) + _cost_map_source_info.source = "local" + _cost_map_source_info.fallback_reason = f"Remote fetch failed: {str(e)}" return GetModelCostMap.load_local_model_cost_map() # Validate using cached count (cheap int comparison, no file I/O) @@ -189,6 +230,10 @@ def get_model_cost_map(url: str) -> dict: "Using local backup instead. url=%s", url, ) + _cost_map_source_info.source = "local" + _cost_map_source_info.fallback_reason = "Remote data failed integrity validation" return GetModelCostMap.load_local_model_cost_map() + _cost_map_source_info.source = "remote" + _cost_map_source_info.fallback_reason = None return content diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 3648dc691e..0601e7e845 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -4709,6 +4709,7 @@ class StandardLoggingPayloadSetup: custom_pricing: Optional[bool], custom_llm_provider: Optional[str], init_response_obj: Union[Any, BaseModel, dict], + api_base: Optional[str] = None, ) -> StandardLoggingModelInformation: model_cost_name = _select_model_name_for_cost_calc( model=None, @@ -4723,7 +4724,9 @@ class StandardLoggingPayloadSetup: else: try: _model_cost_information = litellm.get_model_info( - model=model_cost_name, custom_llm_provider=custom_llm_provider + model=model_cost_name, + custom_llm_provider=custom_llm_provider, + api_base=api_base, ) model_cost_information = StandardLoggingModelInformation( model_map_key=model_cost_name, @@ -5236,6 +5239,7 @@ def get_standard_logging_object_payload( custom_pricing=custom_pricing, custom_llm_provider=kwargs.get("custom_llm_provider"), init_response_obj=init_response_obj, + api_base=litellm_params.get("api_base"), ) response_cost: float = kwargs.get("response_cost", 0) or 0.0 diff --git a/litellm/litellm_core_utils/llm_cost_calc/utils.py b/litellm/litellm_core_utils/llm_cost_calc/utils.py index 7c41e1bbe6..a9fd0f4ea8 100644 --- a/litellm/litellm_core_utils/llm_cost_calc/utils.py +++ b/litellm/litellm_core_utils/llm_cost_calc/utils.py @@ -200,8 +200,14 @@ def _get_token_base_cost( ## CHECK IF ABOVE THRESHOLD # Optimization: collect threshold keys first to avoid sorting all model_info keys. # Most models don't have threshold pricing, so we can return early. + # Exclude service_tier-specific variants (e.g. input_cost_per_token_above_200k_tokens_priority) + # so that the threshold detection loop only processes standard keys. The + # service_tier-specific above-threshold key is resolved later via _get_service_tier_cost_key. threshold_keys = [ - k for k in model_info if k.startswith("input_cost_per_token_above_") + k + for k in model_info + if k.startswith("input_cost_per_token_above_") + and not any(k.endswith(f"_{st.value}") for st in ServiceTier) ] if not threshold_keys: return ( @@ -224,14 +230,34 @@ def _get_token_base_cost( 1000 if "k" in threshold_str else 1 ) if usage.prompt_tokens > threshold: + # Prefer a service_tier-specific above-threshold key when available, + # e.g. input_cost_per_token_priority_above_200k_tokens for Gemini + # ON_DEMAND_PRIORITY. Falls back to the standard key automatically + # via _get_cost_per_unit's service_tier fallback logic. + tiered_input_key = ( + _get_service_tier_cost_key( + f"input_cost_per_token_above_{threshold_str}_tokens", + service_tier, + ) + if service_tier + else key + ) prompt_base_cost = cast( - float, _get_cost_per_unit(model_info, key, prompt_base_cost) + float, _get_cost_per_unit(model_info, tiered_input_key, prompt_base_cost) + ) + tiered_output_key = ( + _get_service_tier_cost_key( + f"output_cost_per_token_above_{threshold_str}_tokens", + service_tier, + ) + if service_tier + else f"output_cost_per_token_above_{threshold_str}_tokens" ) completion_base_cost = cast( float, _get_cost_per_unit( model_info, - f"output_cost_per_token_above_{threshold_str}_tokens", + tiered_output_key, completion_base_cost, ), ) @@ -517,6 +543,7 @@ def _calculate_input_cost( cache_read_cost: float, cache_creation_cost: float, cache_creation_cost_above_1hr: float, + service_tier: Optional[str] = None, ) -> float: """ Calculates the input cost for a given model, prompt tokens, and completion tokens. @@ -528,8 +555,11 @@ def _calculate_input_cost( ### AUDIO COST if prompt_tokens_details["audio_tokens"]: + audio_cost_key = _get_service_tier_cost_key( + "input_cost_per_audio_token", service_tier + ) prompt_cost += calculate_cost_component( - model_info, "input_cost_per_audio_token", prompt_tokens_details["audio_tokens"] + model_info, audio_cost_key, prompt_tokens_details["audio_tokens"] ) ### IMAGE TOKEN COST @@ -659,6 +689,7 @@ def generic_cost_per_token( # noqa: PLR0915 cache_read_cost=cache_read_cost, cache_creation_cost=cache_creation_cost, cache_creation_cost_above_1hr=cache_creation_cost_above_1hr, + service_tier=service_tier, ) ## CALCULATE OUTPUT COST diff --git a/litellm/litellm_core_utils/streaming_handler.py b/litellm/litellm_core_utils/streaming_handler.py index 1f739a60b4..ccd5c1dd8f 100644 --- a/litellm/litellm_core_utils/streaming_handler.py +++ b/litellm/litellm_core_utils/streaming_handler.py @@ -6,7 +6,17 @@ import logging import threading import time import traceback -from typing import Any, Callable, Dict, List, Optional, Union, cast +from typing import ( + Any, + AsyncIterator, + Callable, + Dict, + Iterator, + List, + Optional, + Union, + cast, +) import anyio import httpx @@ -151,10 +161,10 @@ class CustomStreamWrapper: self.is_function_call = self.check_is_function_call(logging_obj=logging_obj) self.created: Optional[int] = None - def __iter__(self): + def __iter__(self) -> Iterator["ModelResponseStream"]: return self - def __aiter__(self): + def __aiter__(self) -> AsyncIterator["ModelResponseStream"]: return self async def aclose(self): @@ -1726,7 +1736,7 @@ class CustomStreamWrapper: model_response.choices[0].finish_reason = "tool_calls" return model_response - def __next__(self): # noqa: PLR0915 + def __next__(self) -> "ModelResponseStream": # noqa: PLR0915 cache_hit = False if ( self.custom_llm_provider is not None @@ -1748,7 +1758,7 @@ class CustomStreamWrapper: chunk = next(self.completion_stream) if chunk is not None and chunk != b"": print_verbose( - f"PROCESSED CHUNK PRE CHUNK CREATOR: {chunk}; custom_llm_provider: {self.custom_llm_provider}" + f"PROCESSED CHUNK PRE CHUNK CREATOR: {chunk.decode('utf-8', errors='replace') if isinstance(chunk, bytes) else chunk}; custom_llm_provider: {self.custom_llm_provider}" ) response: Optional[ModelResponseStream] = self.chunk_creator( chunk=chunk @@ -1900,7 +1910,7 @@ class CustomStreamWrapper: return self.completion_stream - async def __anext__(self): # noqa: PLR0915 + async def __anext__(self) -> "ModelResponseStream": # noqa: PLR0915 cache_hit = False if ( self.custom_llm_provider is not None @@ -1996,9 +2006,7 @@ class CustomStreamWrapper: else: chunk = next(self.completion_stream) if chunk is not None and chunk != b"": - processed_chunk: Optional[ - ModelResponseStream - ] = self.chunk_creator(chunk=chunk) + processed_chunk = self.chunk_creator(chunk=chunk) if processed_chunk is None: continue diff --git a/litellm/llms/anthropic/cost_calculation.py b/litellm/llms/anthropic/cost_calculation.py index 271406f2f7..cf9b18c464 100644 --- a/litellm/llms/anthropic/cost_calculation.py +++ b/litellm/llms/anthropic/cost_calculation.py @@ -5,10 +5,50 @@ Helper util for handling anthropic-specific cost calculation from typing import TYPE_CHECKING, Optional, Tuple -from litellm.litellm_core_utils.llm_cost_calc.utils import generic_cost_per_token +from litellm.litellm_core_utils.llm_cost_calc.utils import ( + _get_token_base_cost, + _parse_prompt_tokens_details, + calculate_cache_writing_cost, + generic_cost_per_token, +) if TYPE_CHECKING: from litellm.types.utils import ModelInfo, Usage +import litellm + + +def _compute_cache_only_cost(model_info: "ModelInfo", usage: "Usage") -> float: + """ + Return only the cache-related portion of the prompt cost (cache read + cache write). + + These costs must NOT be scaled by geo/speed multipliers because the old + explicit ``fast/`` model entries carried unchanged cache rates while + multiplying only the regular input/output token costs. + """ + if usage.prompt_tokens_details is None: + return 0.0 + + prompt_tokens_details = _parse_prompt_tokens_details(usage) + _, _, cache_creation_cost, cache_creation_cost_above_1hr, cache_read_cost = ( + _get_token_base_cost(model_info=model_info, usage=usage) + ) + + cache_cost = float(prompt_tokens_details["cache_hit_tokens"]) * cache_read_cost + + if ( + prompt_tokens_details["cache_creation_tokens"] + or prompt_tokens_details["cache_creation_token_details"] is not None + ): + cache_cost += calculate_cache_writing_cost( + cache_creation_tokens=prompt_tokens_details["cache_creation_tokens"], + cache_creation_token_details=prompt_tokens_details[ + "cache_creation_token_details" + ], + cache_creation_cost_above_1hr=cache_creation_cost_above_1hr, + cache_creation_cost=cache_creation_cost, + ) + + return cache_cost def cost_per_token(model: str, usage: "Usage") -> Tuple[float, float]: @@ -22,20 +62,34 @@ def cost_per_token(model: str, usage: "Usage") -> Tuple[float, float]: Returns: Tuple[float, float] - prompt_cost_in_usd, completion_cost_in_usd """ - model_with_prefix = model - - # First, prepend inference_geo if present - if hasattr(usage, "inference_geo") and usage.inference_geo and usage.inference_geo.lower() not in ["global", "not_available"]: - model_with_prefix = f"{usage.inference_geo}/{model_with_prefix}" - - # Then, prepend speed if it's "fast" - if hasattr(usage, "speed") and usage.speed == "fast": - model_with_prefix = f"fast/{model_with_prefix}" - prompt_cost, completion_cost = generic_cost_per_token( - model=model_with_prefix, usage=usage, custom_llm_provider="anthropic" + model=model, usage=usage, custom_llm_provider="anthropic" ) + # Apply provider_specific_entry multipliers for geo/speed routing + try: + model_info = litellm.get_model_info(model=model, custom_llm_provider="anthropic") + provider_specific_entry: dict = model_info.get("provider_specific_entry") or {} + + multiplier = 1.0 + if ( + hasattr(usage, "inference_geo") + and usage.inference_geo + and usage.inference_geo.lower() not in ["global", "not_available"] + ): + multiplier *= provider_specific_entry.get( + usage.inference_geo.lower(), 1.0 + ) + if hasattr(usage, "speed") and usage.speed == "fast": + multiplier *= provider_specific_entry.get("fast", 1.0) + + if multiplier != 1.0: + cache_cost = _compute_cache_only_cost(model_info=model_info, usage=usage) + prompt_cost = (prompt_cost - cache_cost) * multiplier + cache_cost + completion_cost *= multiplier + except Exception: + pass + return prompt_cost, completion_cost diff --git a/litellm/llms/base_llm/videos/transformation.py b/litellm/llms/base_llm/videos/transformation.py index 50cada42b8..1ad91a43df 100644 --- a/litellm/llms/base_llm/videos/transformation.py +++ b/litellm/llms/base_llm/videos/transformation.py @@ -118,10 +118,11 @@ class BaseVideoConfig(ABC): api_base: str, litellm_params: GenericLiteLLMParams, headers: dict, + variant: Optional[str] = None, ) -> Tuple[str, Dict]: """ Transform the video content request into a URL and data/params - + Returns: Tuple[str, Dict]: (url, params) for the video content request """ diff --git a/litellm/llms/bedrock/files/transformation.py b/litellm/llms/bedrock/files/transformation.py index fdcbe1a824..e29b07ca3a 100644 --- a/litellm/llms/bedrock/files/transformation.py +++ b/litellm/llms/bedrock/files/transformation.py @@ -202,52 +202,84 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig): return optional_params + # Providers whose InvokeModel body uses the Converse API format + # (messages + inferenceConfig + image blocks). Nova is the primary + # example; add others here as they adopt the same schema. + CONVERSE_INVOKE_PROVIDERS = ("nova",) + def _map_openai_to_bedrock_params( self, openai_request_body: Dict[str, Any], provider: Optional[str] = None, ) -> Dict[str, Any]: """ - Transform OpenAI request body to Bedrock-compatible modelInput parameters using existing transformation logic + Transform OpenAI request body to Bedrock-compatible modelInput + parameters using existing transformation logic. + + Routes to the correct per-provider transformation so that the + resulting dict matches the InvokeModel body that Bedrock expects + for batch inference. """ from litellm.types.utils import LlmProviders + _model = openai_request_body.get("model", "") messages = openai_request_body.get("messages", []) - - # Use existing Anthropic transformation logic for Anthropic models + optional_params = { + k: v + for k, v in openai_request_body.items() + if k not in ["model", "messages"] + } + + # --- Anthropic: use existing AmazonAnthropicClaudeConfig --- if provider == LlmProviders.ANTHROPIC: from litellm.llms.bedrock.chat.invoke_transformations.anthropic_claude3_transformation import ( AmazonAnthropicClaudeConfig, ) - - anthropic_config = AmazonAnthropicClaudeConfig() - - # Extract optional params (everything except model and messages) - optional_params = {k: v for k, v in openai_request_body.items() if k not in ["model", "messages"]} - mapped_params = anthropic_config.map_openai_params( + + config = AmazonAnthropicClaudeConfig() + mapped_params = config.map_openai_params( non_default_params={}, optional_params=optional_params, model=_model, - drop_params=False + drop_params=False, ) - - # Transform using existing Anthropic logic - bedrock_params = anthropic_config.transform_request( + return config.transform_request( model=_model, messages=messages, optional_params=mapped_params, litellm_params={}, - headers={} + headers={}, ) - return bedrock_params - else: - # For other providers, use basic mapping - bedrock_params = { - "messages": messages, - **{k: v for k, v in openai_request_body.items() if k not in ["model", "messages"]} - } - return bedrock_params + # --- Converse API providers (e.g. Nova): use AmazonConverseConfig + # to correctly convert image_url blocks to Bedrock image format + # and wrap inference params inside inferenceConfig. --- + if provider in self.CONVERSE_INVOKE_PROVIDERS: + from litellm.llms.bedrock.chat.converse_transformation import ( + AmazonConverseConfig, + ) + + converse_config = AmazonConverseConfig() + mapped_params = converse_config.map_openai_params( + non_default_params=optional_params, + optional_params={}, + model=_model, + drop_params=False, + ) + return converse_config.transform_request( + model=_model, + messages=messages, + optional_params=mapped_params, + litellm_params={}, + headers={}, + ) + + # --- All other providers: passthrough (OpenAI-compatible models + # like openai.gpt-oss-*, qwen, deepseek, etc.) --- + return { + "messages": messages, + **optional_params, + } def _transform_openai_jsonl_content_to_bedrock_jsonl_content( self, openai_jsonl_content: List[Dict[str, Any]] diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index 0a5364bfcf..7267532933 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -3014,8 +3014,11 @@ class BaseLLMHTTPHandler: raise ValueError(f"Unsupported transformed_request type: {type(transformed_request)}") # Store the upload URL in litellm_params for the transformation method + # Honour the URL already set by transform_create_file_request (e.g. Bedrock pre-signed S3 uploads), + # fall back to api_base for providers that do not set it. litellm_params_with_url = dict(litellm_params) - litellm_params_with_url["upload_url"] = api_base + if "upload_url" not in litellm_params: + litellm_params_with_url["upload_url"] = api_base return provider_config.transform_create_file_response( model=None, @@ -5397,6 +5400,7 @@ class BaseLLMHTTPHandler: api_key: Optional[str] = None, client: Optional[Union[HTTPHandler, AsyncHTTPHandler]] = None, _is_async: bool = False, + variant: Optional[str] = None, ) -> Union[bytes, Coroutine[Any, Any, bytes]]: """ Handle video content download requests. @@ -5412,6 +5416,7 @@ class BaseLLMHTTPHandler: extra_headers=extra_headers, api_key=api_key, client=client, + variant=variant, ) if client is None or not isinstance(client, HTTPHandler): @@ -5443,6 +5448,7 @@ class BaseLLMHTTPHandler: api_base=api_base, litellm_params=litellm_params, headers=headers, + variant=variant, ) try: @@ -5485,6 +5491,7 @@ class BaseLLMHTTPHandler: extra_headers: Optional[Dict[str, Any]] = None, api_key: Optional[str] = None, client: Optional[Union[HTTPHandler, AsyncHTTPHandler]] = None, + variant: Optional[str] = None, ) -> bytes: """ Async version of the video content download handler. @@ -5519,6 +5526,7 @@ class BaseLLMHTTPHandler: api_base=api_base, litellm_params=litellm_params, headers=headers, + variant=variant, ) try: @@ -5594,7 +5602,7 @@ class BaseLLMHTTPHandler: sync_httpx_client = client headers = video_remix_provider_config.validate_environment( - api_key=api_key, + api_key=api_key or litellm_params.get("api_key", None), headers=extra_headers or {}, model="", ) @@ -5676,7 +5684,7 @@ class BaseLLMHTTPHandler: async_httpx_client = client headers = video_remix_provider_config.validate_environment( - api_key=api_key, + api_key=api_key or litellm_params.get("api_key", None), headers=extra_headers or {}, model="", ) diff --git a/litellm/llms/custom_httpx/mock_transport.py b/litellm/llms/custom_httpx/mock_transport.py new file mode 100644 index 0000000000..262d0dff12 --- /dev/null +++ b/litellm/llms/custom_httpx/mock_transport.py @@ -0,0 +1,92 @@ +""" +Mock httpx transport that returns valid OpenAI ChatCompletion responses. + +Activated via `litellm_settings: { network_mock: true }`. +Intercepts at the httpx transport layer — the lowest point before bytes hit the wire — +so the full proxy -> router -> OpenAI SDK -> httpx path is exercised. +""" + +import json +import time +import uuid +from typing import Tuple + +import httpx + + +# --------------------------------------------------------------------------- +# Pre-built response templates +# --------------------------------------------------------------------------- + +def _mock_id() -> str: + return f"chatcmpl-mock-{uuid.uuid4().hex[:8]}" + + +def _chat_completion_json(model: str) -> dict: + """Return a minimal valid ChatCompletion object.""" + return { + "id": _mock_id(), + "object": "chat.completion", + "created": int(time.time()), + "model": model, + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Mock response", + }, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2, + }, + } + + +# --------------------------------------------------------------------------- +# Transport +# --------------------------------------------------------------------------- + +_JSON_HEADERS = { + "content-type": "application/json", +} + + +class MockOpenAITransport(httpx.AsyncBaseTransport, httpx.BaseTransport): + """ + httpx transport that returns canned OpenAI ChatCompletion responses. + + Supports both async (AsyncOpenAI) and sync (OpenAI) SDK paths. + """ + + @staticmethod + def _parse_request(request: httpx.Request) -> Tuple[str, bool]: + """Extract model from the request body.""" + try: + body = json.loads(request.content) + except (json.JSONDecodeError, ValueError): + return ("mock-model", False) + model = body.get("model", "mock-model") + return (model, False) + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + model, _ = self._parse_request(request) + body = json.dumps(_chat_completion_json(model)).encode() + return httpx.Response( + status_code=200, + headers=_JSON_HEADERS, + content=body, + ) + + def handle_request(self, request: httpx.Request) -> httpx.Response: + model, _ = self._parse_request(request) + body = json.dumps(_chat_completion_json(model)).encode() + return httpx.Response( + status_code=200, + headers=_JSON_HEADERS, + content=body, + ) diff --git a/litellm/llms/gemini/cost_calculator.py b/litellm/llms/gemini/cost_calculator.py index 471421b487..79242fe01d 100644 --- a/litellm/llms/gemini/cost_calculator.py +++ b/litellm/llms/gemini/cost_calculator.py @@ -4,13 +4,15 @@ This file is used to calculate the cost of the Gemini API. Handles the context caching for Gemini API. """ -from typing import TYPE_CHECKING, Tuple +from typing import TYPE_CHECKING, Optional, Tuple if TYPE_CHECKING: from litellm.types.utils import ModelInfo, Usage -def cost_per_token(model: str, usage: "Usage") -> Tuple[float, float]: +def cost_per_token( + model: str, usage: "Usage", service_tier: Optional[str] = None +) -> Tuple[float, float]: """ Calculates the cost per token for a given model, prompt tokens, and completion tokens. @@ -19,7 +21,7 @@ def cost_per_token(model: str, usage: "Usage") -> Tuple[float, float]: from litellm.litellm_core_utils.llm_cost_calc.utils import generic_cost_per_token return generic_cost_per_token( - model=model, usage=usage, custom_llm_provider="gemini" + model=model, usage=usage, custom_llm_provider="gemini", service_tier=service_tier ) diff --git a/litellm/llms/gemini/videos/transformation.py b/litellm/llms/gemini/videos/transformation.py index 4120d1cad2..7daeb75b65 100644 --- a/litellm/llms/gemini/videos/transformation.py +++ b/litellm/llms/gemini/videos/transformation.py @@ -393,10 +393,11 @@ class GeminiVideoConfig(BaseVideoConfig): api_base: str, litellm_params: GenericLiteLLMParams, headers: dict, + variant: Optional[str] = None, ) -> Tuple[str, Dict]: """ Transform the video content request for Veo API. - + For Veo, we need to: 1. Get operation status to extract video URI 2. Return download URL for the video diff --git a/litellm/llms/ollama/completion/transformation.py b/litellm/llms/ollama/completion/transformation.py index c4d08c83a2..ed14b6a331 100644 --- a/litellm/llms/ollama/completion/transformation.py +++ b/litellm/llms/ollama/completion/transformation.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Any, AsyncIterator, Iterator, List, Optional, from httpx._models import Headers, Response import litellm -from litellm._logging import verbose_proxy_logger +from litellm._logging import verbose_logger, verbose_proxy_logger from litellm.litellm_core_utils.prompt_templates.common_utils import ( get_str_from_messages, ) @@ -223,7 +223,9 @@ class OllamaConfig(BaseConfig): or get_secret_str("OLLAMA_API_KEY") ) - def get_model_info(self, model: str) -> ModelInfoBase: + def get_model_info( + self, model: str, api_base: Optional[str] = None + ) -> ModelInfoBase: """ curl http://localhost:11434/api/show -d '{ "name": "mistral" @@ -231,7 +233,11 @@ class OllamaConfig(BaseConfig): """ if model.startswith("ollama/") or model.startswith("ollama_chat/"): model = model.split("/", 1)[1] - api_base = get_secret_str("OLLAMA_API_BASE") or "http://localhost:11434" + api_base = ( + api_base + or get_secret_str("OLLAMA_API_BASE") + or "http://localhost:11434" + ) api_key = self.get_api_key() headers = {"Authorization": f"Bearer {api_key}"} if api_key else {} @@ -242,8 +248,21 @@ class OllamaConfig(BaseConfig): headers=headers, ) except Exception as e: - raise Exception( - f"OllamaError: Error getting model info for {model}. Set Ollama API Base via `OLLAMA_API_BASE` environment variable. Error: {e}" + verbose_logger.debug( + "OllamaError: Could not get model info for %s from %s. Error: %s", + model, + api_base, + e, + ) + return ModelInfoBase( + key=model, + litellm_provider="ollama", + mode="chat", + input_cost_per_token=0.0, + output_cost_per_token=0.0, + max_tokens=None, + max_input_tokens=None, + max_output_tokens=None, ) model_info = response.json() diff --git a/litellm/llms/openai/common_utils.py b/litellm/llms/openai/common_utils.py index 28de9f1303..61f150f1c2 100644 --- a/litellm/llms/openai/common_utils.py +++ b/litellm/llms/openai/common_utils.py @@ -205,6 +205,11 @@ class BaseOpenAILLM: if litellm.aclient_session is not None: return litellm.aclient_session + if getattr(litellm, "network_mock", False): + from litellm.llms.custom_httpx.mock_transport import MockOpenAITransport + + return httpx.AsyncClient(transport=MockOpenAITransport()) + # Get unified SSL configuration ssl_config = get_ssl_configuration() @@ -225,6 +230,11 @@ class BaseOpenAILLM: if litellm.client_session is not None: return litellm.client_session + if getattr(litellm, "network_mock", False): + from litellm.llms.custom_httpx.mock_transport import MockOpenAITransport + + return httpx.Client(transport=MockOpenAITransport()) + # Get unified SSL configuration ssl_config = get_ssl_configuration() diff --git a/litellm/llms/openai/videos/transformation.py b/litellm/llms/openai/videos/transformation.py index 0dd7940a92..5c880ab665 100644 --- a/litellm/llms/openai/videos/transformation.py +++ b/litellm/llms/openai/videos/transformation.py @@ -172,18 +172,22 @@ class OpenAIVideoConfig(BaseVideoConfig): api_base: str, litellm_params: GenericLiteLLMParams, headers: dict, + variant: Optional[str] = None, ) -> Tuple[str, Dict]: """ Transform the video content request for OpenAI API. - + OpenAI API expects the following request: - GET /v1/videos/{video_id}/content + - GET /v1/videos/{video_id}/content?variant=thumbnail """ original_video_id = extract_original_video_id(video_id) - + # Construct the URL for video content download url = f"{api_base.rstrip('/')}/{original_video_id}/content" - + if variant is not None: + url = f"{url}?variant={variant}" + # No additional data needed for GET content request data: Dict[str, Any] = {} diff --git a/litellm/llms/runwayml/videos/transformation.py b/litellm/llms/runwayml/videos/transformation.py index 5a46ebb664..318a732dc2 100644 --- a/litellm/llms/runwayml/videos/transformation.py +++ b/litellm/llms/runwayml/videos/transformation.py @@ -310,10 +310,11 @@ class RunwayMLVideoConfig(BaseVideoConfig): api_base: str, litellm_params: GenericLiteLLMParams, headers: dict, + variant: Optional[str] = None, ) -> Tuple[str, Dict]: """ Transform the video content request for RunwayML API. - + RunwayML doesn't have a separate content download endpoint. The video URL is returned in the task output field. We'll retrieve the task and extract the video URL. diff --git a/litellm/llms/vertex_ai/cost_calculator.py b/litellm/llms/vertex_ai/cost_calculator.py index e98dc75915..e7ac453e94 100644 --- a/litellm/llms/vertex_ai/cost_calculator.py +++ b/litellm/llms/vertex_ai/cost_calculator.py @@ -224,6 +224,7 @@ def cost_per_token( model: str, custom_llm_provider: str, usage: Usage, + service_tier: Optional[str] = None, ) -> Tuple[float, float]: """ Calculates the cost per token for a given model, prompt tokens, and completion tokens. @@ -233,6 +234,8 @@ def cost_per_token( - custom_llm_provider: str, either "vertex_ai-*" or "gemini" - prompt_tokens: float, the number of input tokens - completion_tokens: float, the number of output tokens + - service_tier: optional tier derived from Gemini trafficType + ("priority" for ON_DEMAND_PRIORITY, "flex" for FLEX/batch). Returns: Tuple[float, float] - prompt_cost_in_usd, completion_cost_in_usd @@ -266,4 +269,5 @@ def cost_per_token( model=model, custom_llm_provider=custom_llm_provider, usage=usage, + service_tier=service_tier, ) diff --git a/litellm/llms/vertex_ai/videos/transformation.py b/litellm/llms/vertex_ai/videos/transformation.py index 66cd143764..8cdccc4cd6 100644 --- a/litellm/llms/vertex_ai/videos/transformation.py +++ b/litellm/llms/vertex_ai/videos/transformation.py @@ -455,6 +455,7 @@ class VertexAIVideoConfig(BaseVideoConfig, VertexBase): api_base: str, litellm_params: GenericLiteLLMParams, headers: dict, + variant: Optional[str] = None, ) -> Tuple[str, Dict]: """ Transform the video content request for Veo API. diff --git a/litellm/main.py b/litellm/main.py index 52e7475169..8b239c454f 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -147,6 +147,7 @@ from litellm.utils import ( token_counter, validate_and_fix_openai_messages, validate_and_fix_openai_tools, + validate_and_fix_thinking_param, validate_chat_completion_tool_choice, validate_openai_optional_params, ) @@ -1103,6 +1104,8 @@ def completion( # type: ignore # noqa: PLR0915 tool_choice = validate_chat_completion_tool_choice(tool_choice=tool_choice) # validate optional params stop = validate_openai_optional_params(stop=stop) + # normalize camelCase thinking keys (e.g. budgetTokens -> budget_tokens) + thinking = validate_and_fix_thinking_param(thinking=thinking) ######### unpacking kwargs ##################### args = locals() diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index e54eaf89d7..4f4e99f099 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -8295,37 +8295,6 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346 }, - "us/claude-sonnet-4-6": { - "cache_creation_input_token_cost": 4.125e-06, - "cache_creation_input_token_cost_above_200k_tokens": 8.25e-06, - "cache_read_input_token_cost": 3.3e-07, - "cache_read_input_token_cost_above_200k_tokens": 6.6e-07, - "input_cost_per_token": 3.3e-06, - "input_cost_per_token_above_200k_tokens": 6.6e-06, - "litellm_provider": "anthropic", - "max_input_tokens": 200000, - "max_output_tokens": 64000, - "max_tokens": 64000, - "mode": "chat", - "output_cost_per_token": 1.65e-05, - "output_cost_per_token_above_200k_tokens": 2.475e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supports_assistant_prefill": true, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 346, - "inference_geo": "us" - }, "claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, @@ -8517,100 +8486,11 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 - }, - "fast/claude-opus-4-6": { - "cache_creation_input_token_cost": 6.25e-06, - "cache_creation_input_token_cost_above_200k_tokens": 1.25e-05, - "cache_creation_input_token_cost_above_1hr": 1e-05, - "cache_read_input_token_cost": 5e-07, - "cache_read_input_token_cost_above_200k_tokens": 1e-06, - "input_cost_per_token": 3e-05, - "input_cost_per_token_above_200k_tokens": 1e-05, - "litellm_provider": "anthropic", - "max_input_tokens": 1000000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 0.00015, - "output_cost_per_token_above_200k_tokens": 3.75e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supports_assistant_prefill": false, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 346 - }, - "us/claude-opus-4-6": { - "cache_creation_input_token_cost": 6.875e-06, - "cache_creation_input_token_cost_above_200k_tokens": 1.375e-05, - "cache_creation_input_token_cost_above_1hr": 1.1e-05, - "cache_read_input_token_cost": 5.5e-07, - "cache_read_input_token_cost_above_200k_tokens": 1.1e-06, - "input_cost_per_token": 5.5e-06, - "input_cost_per_token_above_200k_tokens": 1.1e-05, - "litellm_provider": "anthropic", - "max_input_tokens": 200000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 2.75e-05, - "output_cost_per_token_above_200k_tokens": 4.125e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supports_assistant_prefill": false, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 346 - }, - "fast/us/claude-opus-4-6": { - "cache_creation_input_token_cost": 6.875e-06, - "cache_creation_input_token_cost_above_200k_tokens": 1.375e-05, - "cache_creation_input_token_cost_above_1hr": 1.1e-05, - "cache_read_input_token_cost": 5.5e-07, - "cache_read_input_token_cost_above_200k_tokens": 1.1e-06, - "input_cost_per_token": 3e-05, - "input_cost_per_token_above_200k_tokens": 1.1e-05, - "litellm_provider": "anthropic", - "max_input_tokens": 200000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 0.00015, - "output_cost_per_token_above_200k_tokens": 4.125e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supports_assistant_prefill": false, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "provider_specific_entry": { + "us": 1.1, + "fast": 6.0 + } }, "claude-opus-4-6-20260205": { "cache_creation_input_token_cost": 6.25e-06, @@ -8641,69 +8521,11 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 - }, - "fast/claude-opus-4-6-20260205": { - "cache_creation_input_token_cost": 6.25e-06, - "cache_creation_input_token_cost_above_200k_tokens": 1.25e-05, - "cache_creation_input_token_cost_above_1hr": 1e-05, - "cache_read_input_token_cost": 5e-07, - "cache_read_input_token_cost_above_200k_tokens": 1e-06, - "input_cost_per_token": 3e-05, - "input_cost_per_token_above_200k_tokens": 1e-05, - "litellm_provider": "anthropic", - "max_input_tokens": 1000000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 0.00015, - "output_cost_per_token_above_200k_tokens": 3.75e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supports_assistant_prefill": false, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 346 - }, - "us/claude-opus-4-6-20260205": { - "cache_creation_input_token_cost": 6.875e-06, - "cache_creation_input_token_cost_above_200k_tokens": 1.375e-05, - "cache_creation_input_token_cost_above_1hr": 1.1e-05, - "cache_read_input_token_cost": 5.5e-07, - "cache_read_input_token_cost_above_200k_tokens": 1.1e-06, - "input_cost_per_token": 5.5e-06, - "input_cost_per_token_above_200k_tokens": 1.1e-05, - "litellm_provider": "anthropic", - "max_input_tokens": 200000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 2.75e-05, - "output_cost_per_token_above_200k_tokens": 4.125e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supports_assistant_prefill": false, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "provider_specific_entry": { + "us": 1.1, + "fast": 6.0 + } }, "claude-sonnet-4-20250514": { "deprecation_date": "2026-05-14", @@ -14768,7 +14590,14 @@ "supports_video_input": true, "supports_vision": true, "supports_web_search": true, - "supports_native_streaming": true + "supports_native_streaming": true, + "input_cost_per_token_priority": 3.6e-06, + "input_cost_per_token_above_200k_tokens_priority": 7.2e-06, + "output_cost_per_token_priority": 2.16e-05, + "output_cost_per_token_above_200k_tokens_priority": 3.24e-05, + "cache_read_input_token_cost_priority": 3.6e-07, + "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, + "supports_service_tier": true }, "gemini-3.1-pro-preview": { "cache_read_input_token_cost": 2e-07, @@ -14819,7 +14648,14 @@ "supports_vision": true, "supports_web_search": true, "supports_url_context": true, - "supports_native_streaming": true + "supports_native_streaming": true, + "input_cost_per_token_priority": 3.6e-06, + "input_cost_per_token_above_200k_tokens_priority": 7.2e-06, + "output_cost_per_token_priority": 2.16e-05, + "output_cost_per_token_above_200k_tokens_priority": 3.24e-05, + "cache_read_input_token_cost_priority": 3.6e-07, + "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, + "supports_service_tier": true }, "gemini-3.1-pro-preview-customtools": { "cache_read_input_token_cost": 2e-07, @@ -14919,7 +14755,14 @@ "supports_video_input": true, "supports_vision": true, "supports_web_search": true, - "supports_native_streaming": true + "supports_native_streaming": true, + "input_cost_per_token_priority": 3.6e-06, + "input_cost_per_token_above_200k_tokens_priority": 7.2e-06, + "output_cost_per_token_priority": 2.16e-05, + "output_cost_per_token_above_200k_tokens_priority": 3.24e-05, + "cache_read_input_token_cost_priority": 3.6e-07, + "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, + "supports_service_tier": true }, "vertex_ai/gemini-3-flash-preview": { "cache_read_input_token_cost": 5e-08, @@ -14963,7 +14806,12 @@ "supports_video_input": true, "supports_vision": true, "supports_web_search": true, - "supports_native_streaming": true + "supports_native_streaming": true, + "input_cost_per_token_priority": 9e-07, + "input_cost_per_audio_token_priority": 1.8e-06, + "output_cost_per_token_priority": 5.4e-06, + "cache_read_input_token_cost_priority": 9e-08, + "supports_service_tier": true }, "vertex_ai/gemini-3.1-pro-preview": { "cache_read_input_token_cost": 2e-07, @@ -15014,7 +14862,14 @@ "supports_vision": true, "supports_web_search": true, "supports_url_context": true, - "supports_native_streaming": true + "supports_native_streaming": true, + "input_cost_per_token_priority": 3.6e-06, + "input_cost_per_token_above_200k_tokens_priority": 7.2e-06, + "output_cost_per_token_priority": 2.16e-05, + "output_cost_per_token_above_200k_tokens_priority": 3.24e-05, + "cache_read_input_token_cost_priority": 3.6e-07, + "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, + "supports_service_tier": true }, "vertex_ai/gemini-3.1-pro-preview-customtools": { "cache_read_input_token_cost": 2e-07, @@ -15065,7 +14920,14 @@ "supports_vision": true, "supports_web_search": true, "supports_url_context": true, - "supports_native_streaming": true + "supports_native_streaming": true, + "input_cost_per_token_priority": 3.6e-06, + "input_cost_per_token_above_200k_tokens_priority": 7.2e-06, + "output_cost_per_token_priority": 2.16e-05, + "output_cost_per_token_above_200k_tokens_priority": 3.24e-05, + "cache_read_input_token_cost_priority": 3.6e-07, + "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, + "supports_service_tier": true }, "gemini-2.5-pro-exp-03-25": { "cache_read_input_token_cost": 1.25e-07, @@ -16860,6 +16722,8 @@ "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, + "input_cost_per_token_priority": 1.25e-06, + "input_cost_per_token_above_200k_tokens_priority": 2.5e-06, "litellm_provider": "gemini", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, @@ -16873,8 +16737,11 @@ "mode": "chat", "output_cost_per_token": 1e-05, "output_cost_per_token_above_200k_tokens": 1.5e-05, + "output_cost_per_token_priority": 1e-05, + "output_cost_per_token_above_200k_tokens_priority": 1.5e-05, "rpm": 2000, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "supports_service_tier": true, "supported_endpoints": [ "/v1/chat/completions", "/v1/completions" @@ -16979,7 +16846,14 @@ "supports_video_input": true, "supports_vision": true, "supports_web_search": true, - "tpm": 800000 + "tpm": 800000, + "input_cost_per_token_priority": 3.6e-06, + "input_cost_per_token_above_200k_tokens_priority": 7.2e-06, + "output_cost_per_token_priority": 2.16e-05, + "output_cost_per_token_above_200k_tokens_priority": 3.24e-05, + "cache_read_input_token_cost_priority": 3.6e-07, + "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, + "supports_service_tier": true }, "gemini/gemini-3-flash-preview": { "cache_read_input_token_cost": 5e-08, @@ -17027,7 +16901,12 @@ "supports_vision": true, "supports_web_search": true, "supports_native_streaming": true, - "tpm": 800000 + "tpm": 800000, + "input_cost_per_token_priority": 9e-07, + "input_cost_per_audio_token_priority": 1.8e-06, + "output_cost_per_token_priority": 5.4e-06, + "cache_read_input_token_cost_priority": 9e-08, + "supports_service_tier": true }, "gemini/gemini-3.1-pro-preview": { "cache_read_input_token_cost": 2e-07, @@ -17078,7 +16957,14 @@ "supports_web_search": true, "supports_url_context": true, "supports_native_streaming": true, - "tpm": 800000 + "tpm": 800000, + "input_cost_per_token_priority": 3.6e-06, + "input_cost_per_token_above_200k_tokens_priority": 7.2e-06, + "output_cost_per_token_priority": 2.16e-05, + "output_cost_per_token_above_200k_tokens_priority": 3.24e-05, + "cache_read_input_token_cost_priority": 3.6e-07, + "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, + "supports_service_tier": true }, "gemini/gemini-3.1-pro-preview-customtools": { "cache_read_input_token_cost": 2e-07, @@ -17129,7 +17015,14 @@ "supports_web_search": true, "supports_url_context": true, "supports_native_streaming": true, - "tpm": 800000 + "tpm": 800000, + "input_cost_per_token_priority": 3.6e-06, + "input_cost_per_token_above_200k_tokens_priority": 7.2e-06, + "output_cost_per_token_priority": 2.16e-05, + "output_cost_per_token_above_200k_tokens_priority": 3.24e-05, + "cache_read_input_token_cost_priority": 3.6e-07, + "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, + "supports_service_tier": true }, "gemini-3-flash-preview": { "cache_read_input_token_cost": 5e-08, @@ -17175,7 +17068,12 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": true, - "supports_native_streaming": true + "supports_native_streaming": true, + "input_cost_per_token_priority": 9e-07, + "input_cost_per_audio_token_priority": 1.8e-06, + "output_cost_per_token_priority": 5.4e-06, + "cache_read_input_token_cost_priority": 9e-08, + "supports_service_tier": true }, "gemini/gemini-2.5-pro-exp-03-25": { "cache_read_input_token_cost": 0.0, @@ -37749,4 +37647,4 @@ "notes": "DuckDuckGo Instant Answer API is free and does not require an API key." } } -} +} \ No newline at end of file diff --git a/litellm/policy_templates_backup.json b/litellm/policy_templates_backup.json index be2352866b..5c93ec11d4 100644 --- a/litellm/policy_templates_backup.json +++ b/litellm/policy_templates_backup.json @@ -2454,5 +2454,367 @@ "Injection Protection" ], "estimated_latency_ms": 1 + }, + { + "id": "pdpa-singapore", + "title": "Singapore PDPA \u2014 Personal Data Protection", + "description": "Singapore Personal Data Protection Act (PDPA) compliance. Covers 5 obligation areas: personal identifier collection (s.13 Consent), sensitive data profiling (Advisory Guidelines), Do Not Call Registry violations (Part IX), overseas data transfers (s.26), and automated profiling without human oversight (Model AI Governance Framework). Also includes regex-based PII detection for NRIC/FIN, Singapore phone numbers, postal codes, passports, UEN, and bank account numbers. Zero-cost keyword-based detection.", + "icon": "ShieldCheckIcon", + "iconColor": "text-red-500", + "iconBg": "bg-red-50", + "guardrails": [ + "pdpa-sg-pii-identifiers", + "pdpa-sg-contact-information", + "pdpa-sg-financial-data", + "pdpa-sg-business-identifiers", + "pdpa-sg-personal-identifiers", + "pdpa-sg-sensitive-data", + "pdpa-sg-do-not-call", + "pdpa-sg-data-transfer", + "pdpa-sg-profiling-automated-decisions" + ], + "complexity": "High", + "guardrailDefinitions": [ + { + "guardrail_name": "pdpa-sg-pii-identifiers", + "litellm_params": { + "guardrail": "litellm_content_filter", + "mode": "pre_call", + "patterns": [ + { + "pattern_type": "prebuilt", + "pattern_name": "sg_nric", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "passport_singapore", + "action": "MASK" + } + ], + "pattern_redaction_format": "[{pattern_name}_REDACTED]" + }, + "guardrail_info": { + "description": "Masks Singapore NRIC/FIN and passport numbers for PDPA compliance" + } + }, + { + "guardrail_name": "pdpa-sg-contact-information", + "litellm_params": { + "guardrail": "litellm_content_filter", + "mode": "pre_call", + "patterns": [ + { + "pattern_type": "prebuilt", + "pattern_name": "sg_phone", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "sg_postal_code", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "email", + "action": "MASK" + } + ], + "pattern_redaction_format": "[{pattern_name}_REDACTED]" + }, + "guardrail_info": { + "description": "Masks Singapore phone numbers, postal codes, and email addresses" + } + }, + { + "guardrail_name": "pdpa-sg-financial-data", + "litellm_params": { + "guardrail": "litellm_content_filter", + "mode": "pre_call", + "patterns": [ + { + "pattern_type": "prebuilt", + "pattern_name": "sg_bank_account", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "credit_card", + "action": "MASK" + } + ], + "pattern_redaction_format": "[{pattern_name}_REDACTED]" + }, + "guardrail_info": { + "description": "Masks Singapore bank account numbers and credit card numbers" + } + }, + { + "guardrail_name": "pdpa-sg-business-identifiers", + "litellm_params": { + "guardrail": "litellm_content_filter", + "mode": "pre_call", + "patterns": [ + { + "pattern_type": "prebuilt", + "pattern_name": "sg_uen", + "action": "MASK" + } + ], + "pattern_redaction_format": "[UEN_REDACTED]" + }, + "guardrail_info": { + "description": "Masks Singapore Unique Entity Numbers (business registration)" + } + }, + { + "guardrail_name": "pdpa-sg-personal-identifiers", + "litellm_params": { + "guardrail": "litellm_content_filter", + "mode": "pre_call", + "categories": [ + { + "category": "sg_pdpa_personal_identifiers", + "category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_personal_identifiers.yaml", + "enabled": true, + "action": "BLOCK", + "severity_threshold": "medium" + } + ] + }, + "guardrail_info": { + "description": "PDPA s.13 \u2014 Blocks unauthorized collection, harvesting, or extraction of Singapore personal identifiers (NRIC/FIN, SingPass, passports)" + } + }, + { + "guardrail_name": "pdpa-sg-sensitive-data", + "litellm_params": { + "guardrail": "litellm_content_filter", + "mode": "pre_call", + "categories": [ + { + "category": "sg_pdpa_sensitive_data", + "category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_sensitive_data.yaml", + "enabled": true, + "action": "BLOCK", + "severity_threshold": "medium" + } + ] + }, + "guardrail_info": { + "description": "PDPA Advisory Guidelines \u2014 Blocks profiling or inference of sensitive personal data categories (race, religion, health, politics) for Singapore residents" + } + }, + { + "guardrail_name": "pdpa-sg-do-not-call", + "litellm_params": { + "guardrail": "litellm_content_filter", + "mode": "pre_call", + "categories": [ + { + "category": "sg_pdpa_do_not_call", + "category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_do_not_call.yaml", + "enabled": true, + "action": "BLOCK", + "severity_threshold": "medium" + } + ] + }, + "guardrail_info": { + "description": "PDPA Part IX \u2014 Blocks generation of unsolicited marketing lists and DNC Registry bypass attempts for Singapore phone numbers" + } + }, + { + "guardrail_name": "pdpa-sg-data-transfer", + "litellm_params": { + "guardrail": "litellm_content_filter", + "mode": "pre_call", + "categories": [ + { + "category": "sg_pdpa_data_transfer", + "category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_data_transfer.yaml", + "enabled": true, + "action": "BLOCK", + "severity_threshold": "medium" + } + ] + }, + "guardrail_info": { + "description": "PDPA s.26 \u2014 Blocks unprotected overseas transfer of Singapore personal data without adequate safeguards" + } + }, + { + "guardrail_name": "pdpa-sg-profiling-automated-decisions", + "litellm_params": { + "guardrail": "litellm_content_filter", + "mode": "pre_call", + "categories": [ + { + "category": "sg_pdpa_profiling_automated_decisions", + "category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_profiling_automated_decisions.yaml", + "enabled": true, + "action": "BLOCK", + "severity_threshold": "medium" + } + ] + }, + "guardrail_info": { + "description": "PDPA + Model AI Governance Framework \u2014 Blocks automated profiling and decision-making about Singapore residents without human oversight" + } + } + ], + "templateData": { + "policy_name": "pdpa-singapore", + "description": "Singapore PDPA compliance policy. Covers personal identifier protection (s.13), sensitive data profiling (Advisory Guidelines), Do Not Call Registry (Part IX), overseas data transfers (s.26), and automated profiling (Model AI Governance Framework). Includes regex-based PII detection for NRIC/FIN, phone numbers, postal codes, passports, UEN, and bank accounts.", + "guardrails_add": [ + "pdpa-sg-pii-identifiers", + "pdpa-sg-contact-information", + "pdpa-sg-financial-data", + "pdpa-sg-business-identifiers", + "pdpa-sg-personal-identifiers", + "pdpa-sg-sensitive-data", + "pdpa-sg-do-not-call", + "pdpa-sg-data-transfer", + "pdpa-sg-profiling-automated-decisions" + ], + "guardrails_remove": [] + }, + "tags": [ + "PII Protection", + "Regulatory", + "Singapore" + ], + "estimated_latency_ms": 1 + }, + { + "id": "mas-ai-risk-management", + "title": "Singapore MAS \u2014 AI Risk Management for Financial Institutions", + "description": "Monetary Authority of Singapore (MAS) AI Risk Management for Financial Institutions alignment. Covers 5 enforceable obligation areas: fairness & bias in financial decisions, transparency & explainability of AI models, human oversight for consequential actions, data governance for financial customer data, and model security against adversarial attacks. Based on Guidelines on Artificial Intelligence Risk Management (MAS), and aligned with the 2018 FEAT Principles and Project MindForge. Zero-cost keyword-based detection.", + "icon": "ShieldCheckIcon", + "iconColor": "text-blue-600", + "iconBg": "bg-blue-50", + "guardrails": [ + "mas-sg-fairness-bias", + "mas-sg-transparency-explainability", + "mas-sg-human-oversight", + "mas-sg-data-governance", + "mas-sg-model-security" + ], + "complexity": "High", + "guardrailDefinitions": [ + { + "guardrail_name": "mas-sg-fairness-bias", + "litellm_params": { + "guardrail": "litellm_content_filter", + "mode": "pre_call", + "categories": [ + { + "category": "sg_mas_fairness_bias", + "category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_fairness_bias.yaml", + "enabled": true, + "action": "BLOCK", + "severity_threshold": "medium" + } + ] + }, + "guardrail_info": { + "description": "Guidelines on Artificial Intelligence Risk Management (MAS) — Blocks discriminatory AI practices in financial services that score, deny, or price based on protected attributes (race, religion, age, gender, nationality)" + } + }, + { + "guardrail_name": "mas-sg-transparency-explainability", + "litellm_params": { + "guardrail": "litellm_content_filter", + "mode": "pre_call", + "categories": [ + { + "category": "sg_mas_transparency_explainability", + "category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_transparency_explainability.yaml", + "enabled": true, + "action": "BLOCK", + "severity_threshold": "medium" + } + ] + }, + "guardrail_info": { + "description": "Guidelines on Artificial Intelligence Risk Management (MAS) — Blocks deployment of opaque or unexplainable AI systems for consequential financial decisions" + } + }, + { + "guardrail_name": "mas-sg-human-oversight", + "litellm_params": { + "guardrail": "litellm_content_filter", + "mode": "pre_call", + "categories": [ + { + "category": "sg_mas_human_oversight", + "category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_human_oversight.yaml", + "enabled": true, + "action": "BLOCK", + "severity_threshold": "medium" + } + ] + }, + "guardrail_info": { + "description": "Guidelines on Artificial Intelligence Risk Management (MAS) — Blocks fully automated financial AI decisions without human-in-the-loop for consequential actions (loans, claims, trading)" + } + }, + { + "guardrail_name": "mas-sg-data-governance", + "litellm_params": { + "guardrail": "litellm_content_filter", + "mode": "pre_call", + "categories": [ + { + "category": "sg_mas_data_governance", + "category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_data_governance.yaml", + "enabled": true, + "action": "BLOCK", + "severity_threshold": "medium" + } + ] + }, + "guardrail_info": { + "description": "Guidelines on Artificial Intelligence Risk Management (MAS) — Blocks unauthorized sharing, exposure, or mishandling of financial customer data without proper governance and data lineage" + } + }, + { + "guardrail_name": "mas-sg-model-security", + "litellm_params": { + "guardrail": "litellm_content_filter", + "mode": "pre_call", + "categories": [ + { + "category": "sg_mas_model_security", + "category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_model_security.yaml", + "enabled": true, + "action": "BLOCK", + "severity_threshold": "medium" + } + ] + }, + "guardrail_info": { + "description": "Guidelines on Artificial Intelligence Risk Management (MAS) — Blocks adversarial attacks, model poisoning, inversion, and exfiltration attempts targeting financial AI systems" + } + } + ], + "templateData": { + "policy_name": "mas-ai-risk-management", + "description": "Guidelines on Artificial Intelligence Risk Management (MAS) for Financial Institutions alignment. Covers fairness & bias, transparency & explainability, human oversight, data governance, and model security. Aligned with the 2018 FEAT Principles, Project MindForge, and NIST AI RMF.", + "guardrails_add": [ + "mas-sg-fairness-bias", + "mas-sg-transparency-explainability", + "mas-sg-human-oversight", + "mas-sg-data-governance", + "mas-sg-model-security" + ], + "guardrails_remove": [] + }, + "tags": [ + "Financial Services", + "Regulatory", + "Singapore" + ], + "estimated_latency_ms": 1 } ] diff --git a/litellm/proxy/_new_secret_config.yaml b/litellm/proxy/_new_secret_config.yaml index 813a4fb3a6..6b84d90a32 100644 --- a/litellm/proxy/_new_secret_config.yaml +++ b/litellm/proxy/_new_secret_config.yaml @@ -23,11 +23,6 @@ model_list: guardrails: - - guardrail_name: mcp-user-permissions - litellm_params: - guardrail: mcp_end_user_permission - mode: pre_call - default_on: true - guardrail_name: "airline-competitor-intent" guardrail_id: "airline-competitor-intent" litellm_params: diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index 12f7fe2c3c..40fae4e4a5 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -1,6 +1,7 @@ import asyncio import json import logging +import time import traceback from datetime import datetime from typing import ( @@ -441,10 +442,18 @@ class ProxyBaseLLMRequestProcessing: ), **( { - "x-litellm-timing-pre-processing-ms": str(hidden_params.get("timing_pre_processing_ms", None)), - "x-litellm-timing-llm-api-ms": str(hidden_params.get("timing_llm_api_ms", None)), - "x-litellm-timing-post-processing-ms": str(hidden_params.get("timing_post_processing_ms", None)), - "x-litellm-timing-message-copy-ms": str(hidden_params.get("timing_message_copy_ms", None)), + "x-litellm-timing-pre-processing-ms": str( + hidden_params.get("timing_pre_processing_ms", None) + ), + "x-litellm-timing-llm-api-ms": str( + hidden_params.get("timing_llm_api_ms", None) + ), + "x-litellm-timing-post-processing-ms": str( + hidden_params.get("timing_post_processing_ms", None) + ), + "x-litellm-timing-message-copy-ms": str( + hidden_params.get("timing_message_copy_ms", None) + ), } if LITELLM_DETAILED_TIMING else {} @@ -564,16 +573,6 @@ class ProxyBaseLLMRequestProcessing: ) -> Tuple[dict, LiteLLMLoggingObj]: start_time = datetime.now() # start before calling guardrail hooks - # Calculate request queue time if arrival_time is available - # Use start_time.timestamp() to avoid extra time.time() call for better performance - proxy_server_request = self.data.get("proxy_server_request", {}) - arrival_time = proxy_server_request.get("arrival_time") - queue_time_seconds = None - if arrival_time is not None: - # Convert start_time (datetime) to timestamp for calculation - processing_start_time = start_time.timestamp() - queue_time_seconds = processing_start_time - arrival_time - self.data = await add_litellm_data_to_request( data=self.data, request=request, @@ -583,6 +582,15 @@ class ProxyBaseLLMRequestProcessing: proxy_config=proxy_config, ) + # Calculate request queue time after add_litellm_data_to_request + # which sets arrival_time in proxy_server_request + proxy_server_request = self.data.get("proxy_server_request", {}) + arrival_time = proxy_server_request.get("arrival_time") + queue_time_seconds = None + if arrival_time is not None: + processing_start_time = time.time() + queue_time_seconds = processing_start_time - arrival_time + # Store queue time in metadata after add_litellm_data_to_request to ensure it's preserved if queue_time_seconds is not None: from litellm.proxy.litellm_pre_call_utils import _get_metadata_variable_name @@ -634,7 +642,7 @@ class ProxyBaseLLMRequestProcessing: self.data["litellm_call_id"] = request.headers.get( "x-litellm-call-id", str(uuid.uuid4()) ) - + ### AUTO STREAM USAGE TRACKING ### # If always_include_stream_usage is enabled and this is a streaming request # automatically add stream_options={'include_usage': True} if not already set @@ -650,7 +658,7 @@ class ProxyBaseLLMRequestProcessing: and "include_usage" not in self.data["stream_options"] ): self.data["stream_options"]["include_usage"] = True - + ### CALL HOOKS ### - modify/reject incoming data before calling the model ## LOGGING OBJECT ## - initialize logging object for logging success/failure events for call @@ -710,7 +718,9 @@ class ProxyBaseLLMRequestProcessing: "Request received by LiteLLM: payload too large to log (%d bytes, limit %d). Keys: %s", len(_payload_str), MAX_PAYLOAD_SIZE_FOR_DEBUG_LOG, - list(self.data.keys()) if isinstance(self.data, dict) else type(self.data).__name__, + list(self.data.keys()) + if isinstance(self.data, dict) + else type(self.data).__name__, ) else: verbose_proxy_logger.debug( @@ -913,9 +923,9 @@ class ProxyBaseLLMRequestProcessing: # aliasing/routing, but the OpenAI-compatible response `model` field should reflect # what the client sent. if requested_model_from_client: - self.data["_litellm_client_requested_model"] = ( - requested_model_from_client - ) + self.data[ + "_litellm_client_requested_model" + ] = requested_model_from_client if route_type == "allm_passthrough_route": # Check if response is an async generator if self._is_streaming_response(response): @@ -1510,9 +1520,9 @@ class ProxyBaseLLMRequestProcessing: # Add cache-related fields to **params (handled by Usage.__init__) if cache_creation_input_tokens is not None: - usage_kwargs["cache_creation_input_tokens"] = ( - cache_creation_input_tokens - ) + usage_kwargs[ + "cache_creation_input_tokens" + ] = cache_creation_input_tokens if cache_read_input_tokens is not None: usage_kwargs["cache_read_input_tokens"] = cache_read_input_tokens diff --git a/litellm/proxy/common_utils/timezone_utils.py b/litellm/proxy/common_utils/timezone_utils.py index a289e5328b..700a9197f6 100644 --- a/litellm/proxy/common_utils/timezone_utils.py +++ b/litellm/proxy/common_utils/timezone_utils.py @@ -1,27 +1,23 @@ from datetime import datetime, timezone +import litellm from litellm.litellm_core_utils.duration_parser import get_next_standardized_reset_time def get_budget_reset_timezone(): """ - Get the budget reset timezone from general_settings. + Get the budget reset timezone from litellm_settings. Falls back to UTC if not specified. + + litellm_settings values are set as attributes on the litellm module + by proxy_server.py at startup (via setattr(litellm, key, value)). """ - # Import at function level to avoid circular imports - from litellm.proxy.proxy_server import general_settings - - if general_settings: - litellm_settings = general_settings.get("litellm_settings", {}) - if litellm_settings and "timezone" in litellm_settings: - return litellm_settings["timezone"] - - return "UTC" + return getattr(litellm, "timezone", None) or "UTC" def get_budget_reset_time(budget_duration: str): """ - Get the budget reset time from general_settings. + Get the budget reset time based on the configured timezone. Falls back to UTC if not specified. """ diff --git a/litellm/proxy/db/db_transaction_queue/base_update_queue.py b/litellm/proxy/db/db_transaction_queue/base_update_queue.py index a5ec1c3eaf..e37200c02e 100644 --- a/litellm/proxy/db/db_transaction_queue/base_update_queue.py +++ b/litellm/proxy/db/db_transaction_queue/base_update_queue.py @@ -23,6 +23,15 @@ class BaseUpdateQueue: def __init__(self): self.update_queue = asyncio.Queue(maxsize=LITELLM_ASYNCIO_QUEUE_MAXSIZE) self.MAX_SIZE_IN_MEMORY_QUEUE = MAX_SIZE_IN_MEMORY_QUEUE + if MAX_SIZE_IN_MEMORY_QUEUE >= LITELLM_ASYNCIO_QUEUE_MAXSIZE: + verbose_proxy_logger.warning( + "Misconfigured queue thresholds: MAX_SIZE_IN_MEMORY_QUEUE (%d) >= LITELLM_ASYNCIO_QUEUE_MAXSIZE (%d). " + "The spend aggregation check will never trigger because the asyncio.Queue blocks at %d items. " + "Set MAX_SIZE_IN_MEMORY_QUEUE to a value less than LITELLM_ASYNCIO_QUEUE_MAXSIZE (recommended: 80%% of it).", + MAX_SIZE_IN_MEMORY_QUEUE, + LITELLM_ASYNCIO_QUEUE_MAXSIZE, + LITELLM_ASYNCIO_QUEUE_MAXSIZE, + ) async def add_update(self, update): """Enqueue an update.""" diff --git a/litellm/proxy/guardrails/guardrail_endpoints.py b/litellm/proxy/guardrails/guardrail_endpoints.py index e14782fa1f..c083c60cb4 100644 --- a/litellm/proxy/guardrails/guardrail_endpoints.py +++ b/litellm/proxy/guardrails/guardrail_endpoints.py @@ -14,21 +14,27 @@ from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.guardrails.guardrail_registry import GuardrailRegistry -from litellm.types.guardrails import (PII_ENTITY_CATEGORIES_MAP, - ApplyGuardrailRequest, - ApplyGuardrailResponse, - BaseLitellmParams, - BedrockGuardrailConfigModel, Guardrail, - GuardrailEventHooks, - GuardrailInfoResponse, - GuardrailUIAddGuardrailSettings, - LakeraV2GuardrailConfigModel, - ListGuardrailsResponse, LitellmParams, - PatchGuardrailRequest, PiiAction, - PiiEntityType, - PresidioPresidioConfigModelUserInterface, - SupportedGuardrailIntegrations, - ToolPermissionGuardrailConfigModel) +from litellm.proxy.guardrails.usage_endpoints import router as guardrails_usage_router +from litellm.types.guardrails import ( + PII_ENTITY_CATEGORIES_MAP, + ApplyGuardrailRequest, + ApplyGuardrailResponse, + BaseLitellmParams, + BedrockGuardrailConfigModel, + Guardrail, + GuardrailEventHooks, + GuardrailInfoResponse, + GuardrailUIAddGuardrailSettings, + LakeraV2GuardrailConfigModel, + ListGuardrailsResponse, + LitellmParams, + PatchGuardrailRequest, + PiiAction, + PiiEntityType, + PresidioPresidioConfigModelUserInterface, + SupportedGuardrailIntegrations, + ToolPermissionGuardrailConfigModel, +) #### GUARDRAILS ENDPOINTS #### @@ -147,8 +153,7 @@ async def list_guardrails_v2(): ``` """ from litellm.litellm_core_utils.litellm_logging import _get_masked_values - from litellm.proxy.guardrails.guardrail_registry import \ - IN_MEMORY_GUARDRAIL_HANDLER + from litellm.proxy.guardrails.guardrail_registry import IN_MEMORY_GUARDRAIL_HANDLER from litellm.proxy.proxy_server import prisma_client if prisma_client is None: @@ -288,8 +293,7 @@ async def create_guardrail(request: CreateGuardrailRequest): } ``` """ - from litellm.proxy.guardrails.guardrail_registry import \ - IN_MEMORY_GUARDRAIL_HANDLER + from litellm.proxy.guardrails.guardrail_registry import IN_MEMORY_GUARDRAIL_HANDLER from litellm.proxy.proxy_server import prisma_client if prisma_client is None: @@ -378,8 +382,7 @@ async def update_guardrail(guardrail_id: str, request: UpdateGuardrailRequest): } ``` """ - from litellm.proxy.guardrails.guardrail_registry import \ - IN_MEMORY_GUARDRAIL_HANDLER + from litellm.proxy.guardrails.guardrail_registry import IN_MEMORY_GUARDRAIL_HANDLER from litellm.proxy.proxy_server import prisma_client if prisma_client is None: @@ -447,8 +450,7 @@ async def delete_guardrail(guardrail_id: str): } ``` """ - from litellm.proxy.guardrails.guardrail_registry import \ - IN_MEMORY_GUARDRAIL_HANDLER + from litellm.proxy.guardrails.guardrail_registry import IN_MEMORY_GUARDRAIL_HANDLER from litellm.proxy.proxy_server import prisma_client if prisma_client is None: @@ -541,8 +543,7 @@ async def patch_guardrail(guardrail_id: str, request: PatchGuardrailRequest): } ``` """ - from litellm.proxy.guardrails.guardrail_registry import \ - IN_MEMORY_GUARDRAIL_HANDLER + from litellm.proxy.guardrails.guardrail_registry import IN_MEMORY_GUARDRAIL_HANDLER from litellm.proxy.proxy_server import prisma_client if prisma_client is None: @@ -664,8 +665,7 @@ async def get_guardrail_info(guardrail_id: str): """ from litellm.litellm_core_utils.litellm_logging import _get_masked_values - from litellm.proxy.guardrails.guardrail_registry import \ - IN_MEMORY_GUARDRAIL_HANDLER + from litellm.proxy.guardrails.guardrail_registry import IN_MEMORY_GUARDRAIL_HANDLER from litellm.proxy.proxy_server import prisma_client from litellm.types.guardrails import GUARDRAIL_DEFINITION_LOCATION @@ -740,8 +740,10 @@ async def get_guardrail_ui_settings(): - Content filter settings (patterns and categories) """ from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.patterns import ( - PATTERN_CATEGORIES, get_available_content_categories, - get_pattern_metadata) + PATTERN_CATEGORIES, + get_available_content_categories, + get_pattern_metadata, + ) # Convert the PII_ENTITY_CATEGORIES_MAP to the format expected by the UI category_maps = [] @@ -1313,8 +1315,7 @@ async def get_provider_specific_params(): } ### get the config model for the guardrail - go through the registry and get the config model for the guardrail - from litellm.proxy.guardrails.guardrail_registry import \ - guardrail_class_registry + from litellm.proxy.guardrails.guardrail_registry import guardrail_class_registry for guardrail_name, guardrail_class in guardrail_class_registry.items(): guardrail_config_model = guardrail_class.get_config_model() @@ -1442,8 +1443,9 @@ async def test_custom_code_guardrail(request: TestCustomCodeGuardrailRequest): import concurrent.futures import re - from litellm.proxy.guardrails.guardrail_hooks.custom_code.primitives import \ - get_custom_code_primitives + from litellm.proxy.guardrails.guardrail_hooks.custom_code.primitives import ( + get_custom_code_primitives, + ) # Security validation patterns FORBIDDEN_PATTERNS = [ @@ -1633,3 +1635,7 @@ async def apply_guardrail( ) except Exception as e: raise handle_exception_on_proxy(e) + + +# Usage (dashboard) endpoints: overview, detail, logs +router.include_router(guardrails_usage_router) diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/patterns.json b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/patterns.json index 88328de09b..934ccbe2ff 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/patterns.json +++ b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/patterns.json @@ -493,6 +493,56 @@ "description": "Detects airline flight numbers (major IATA 2-letter codes + 1-4 digit flight number) when near flight context", "keyword_pattern": "\\b(?:flight|departure|arrival|gate|boarding|schedule|operate|route|aircraft|plane|outbound|inbound|leg|sector|flying)\\b", "allow_word_numbers": false + }, + { + "name": "sg_nric", + "display_name": "NRIC/FIN (Singapore National ID)", + "pattern": "\\b[STFGM]\\d{7}[A-Z]\\b", + "category": "Singapore PII Patterns", + "description": "Detects Singapore NRIC and FIN numbers (S/T for citizens/PRs, F/G/M for foreigners + 7 digits + checksum letter)" + }, + { + "name": "sg_phone", + "display_name": "Phone Number (Singapore)", + "pattern": "(? None: + self.async_handler = get_async_httpx_client(llm_provider=httpxSpecialProvider.GuardrailCallback) + + self.api_key = api_key or os.environ.get("NOMA_API_KEY") + self.api_base = (api_base or os.environ.get("NOMA_API_BASE") or _DEFAULT_API_BASE).rstrip("/") + self.application_id = application_id or os.environ.get("NOMA_APPLICATION_ID") + if monitor_mode is None: + self.monitor_mode = os.environ.get("NOMA_MONITOR_MODE", "false").lower() == "true" + else: + self.monitor_mode = monitor_mode + + if block_failures is None: + self.block_failures = os.environ.get("NOMA_BLOCK_FAILURES", "true").lower() == "true" + else: + self.block_failures = block_failures + + if self._requires_api_key(api_base=self.api_base) and not self.api_key: + raise ValueError("Noma v2 guardrail requires api_key when using Noma SaaS endpoint") + + if "supported_event_hooks" not in kwargs: + kwargs["supported_event_hooks"] = [ + GuardrailEventHooks.pre_call, + GuardrailEventHooks.during_call, + GuardrailEventHooks.post_call, + GuardrailEventHooks.pre_mcp_call, + GuardrailEventHooks.during_mcp_call, + ] + + super().__init__(**kwargs) + + @staticmethod + def get_config_model() -> Optional[Type["GuardrailConfigModel"]]: + from litellm.types.proxy.guardrails.guardrail_hooks.noma import ( + NomaV2GuardrailConfigModel, + ) + + return NomaV2GuardrailConfigModel + + def _get_authorization_header(self) -> str: + if not self.api_key: + return "" + return f"Bearer {self.api_key}" + + @staticmethod + def _requires_api_key(api_base: str) -> bool: + parsed = urlparse(api_base) + return parsed.hostname == _DEFAULT_API_BASE_HOSTNAME + + @staticmethod + def _get_non_empty_str(value: Any) -> Optional[str]: + if not isinstance(value, str): + return None + stripped = value.strip() + return stripped or None + + def _resolve_action_from_response( + self, + response_json: dict, + ) -> _Action: + action = response_json.get("action") + if isinstance(action, str): + try: + return _Action(action) + except ValueError: + pass + + raise ValueError("Noma v2 response missing valid action") + + def _build_scan_payload( + self, + inputs: GenericGuardrailAPIInputs, + request_data: dict, + input_type: Literal["request", "response"], + logging_obj: Optional["LiteLLMLoggingObj"], + application_id: Optional[str], + ) -> dict: + payload_request_data = deepcopy(request_data) + if logging_obj is not None: + payload_request_data["litellm_logging_obj"] = getattr(logging_obj, "model_call_details", None) + + payload: dict[str, Any] = { + "inputs": inputs, + "request_data": payload_request_data, + "input_type": input_type, + "monitor_mode": self.monitor_mode, + } + if application_id: + payload["application_id"] = application_id + return payload + + @staticmethod + def _sanitize_payload_for_transport(payload: dict) -> dict: + def _default(obj: Any) -> Any: + if hasattr(obj, "model_dump"): + try: + return obj.model_dump() + except Exception: + pass + return str(obj) + + try: + json_str = json.dumps(payload, default=_default) + except (ValueError, TypeError): + json_str = safe_dumps(payload) + + safe_payload = safe_json_loads(json_str, default={}) + if safe_payload == {} and payload: + verbose_proxy_logger.warning( + "Noma v2 guardrail: payload serialization failed, falling back to empty payload" + ) + + if isinstance(safe_payload, dict): + return safe_payload + + verbose_proxy_logger.warning( + "Noma v2 guardrail: payload sanitization produced non-dict output (type=%s), falling back to empty payload", + type(safe_payload).__name__, + ) + return {} + + async def _call_noma_scan( + self, + payload: dict, + ) -> dict: + headers: dict[str, str] = {"Content-Type": "application/json"} + authorization_header = self._get_authorization_header() + if authorization_header: + headers["Authorization"] = authorization_header + + endpoint = f"{self.api_base}{_AIDR_SCAN_ENDPOINT}" + sanitized_payload = self._sanitize_payload_for_transport(payload) + response = await self.async_handler.post( + url=endpoint, + headers=headers, + json=sanitized_payload, + ) + verbose_proxy_logger.debug( + "Noma v2 AIDR response: status_code=%s body=%s", + response.status_code, + response.text, + ) + response.raise_for_status() + response_json = response.json() + verbose_proxy_logger.debug( + "Noma v2 AIDR response parsed: %s", + json.dumps(response_json, default=str), + ) + return response_json + + def _add_guardrail_observability( + self, + request_data: dict, + start_time: datetime, + guardrail_status: GuardrailStatus, + guardrail_json_response: Any, + ) -> None: + end_time = datetime.now() + duration = (end_time - start_time).total_seconds() + self.add_standard_logging_guardrail_information_to_request_data( + guardrail_provider="noma_v2", + guardrail_json_response=guardrail_json_response, + request_data=request_data, + guardrail_status=guardrail_status, + start_time=start_time.timestamp(), + end_time=end_time.timestamp(), + duration=duration, + ) + + def _apply_action( + self, + inputs: GenericGuardrailAPIInputs, + response_json: dict, + action: _Action, + ) -> GenericGuardrailAPIInputs: + if action == _Action.BLOCKED: + raise NomaBlockedMessage(response_json) + + if action == _Action.GUARDRAIL_INTERVENED: + updated_inputs = cast(GenericGuardrailAPIInputs, dict(inputs)) + for field in _INTERVENED_INPUT_FIELDS: + value = response_json.get(field) + if isinstance(value, list): + updated_inputs[field] = value # type: ignore[literal-required] + return updated_inputs + + return inputs + + async def apply_guardrail( + self, + inputs: GenericGuardrailAPIInputs, + request_data: dict, + input_type: Literal["request", "response"], + logging_obj: Optional["LiteLLMLoggingObj"] = None, + ) -> GenericGuardrailAPIInputs: + start_time = datetime.now() + guardrail_status: GuardrailStatus = "success" + guardrail_json_response: Any = {} + dynamic_params = self.get_guardrail_dynamic_request_body_params(request_data) + if not isinstance(dynamic_params, dict): + dynamic_params = {} + response_json: Optional[dict] = None + + # Per-request dynamic params can override configured application context. + application_id = self._get_non_empty_str(dynamic_params.get("application_id")) + + if application_id is None: + application_id = self._get_non_empty_str(self.application_id) + + try: + payload = self._build_scan_payload( + inputs=inputs, + request_data=request_data, + input_type=input_type, + logging_obj=logging_obj, + application_id=application_id, + ) + + response_json = await self._call_noma_scan(payload=payload) + if self.monitor_mode: + action = _Action.NONE + else: + action = self._resolve_action_from_response(response_json=response_json) + guardrail_json_response = response_json + verbose_proxy_logger.debug( + "Noma v2 guardrail decision: input_type=%s action=%s", + input_type, + action.value, + ) + processed_inputs = self._apply_action( + inputs=inputs, + response_json=response_json, + action=action, + ) + + guardrail_status = "success" if action == _Action.NONE else "guardrail_intervened" + return processed_inputs + + except NomaBlockedMessage as e: + guardrail_status = "guardrail_intervened" + guardrail_json_response = ( + response_json if isinstance(response_json, dict) else getattr(e, "detail", {"error": "blocked"}) + ) + raise + except Exception as e: + guardrail_status = "guardrail_failed_to_respond" + guardrail_json_response = str(e) + verbose_proxy_logger.error("Noma v2 guardrail failed: %s", str(e)) + if self.block_failures: + raise + return inputs + finally: + self._add_guardrail_observability( + request_data=request_data, + start_time=start_time, + guardrail_status=guardrail_status, + guardrail_json_response=guardrail_json_response, + ) diff --git a/litellm/proxy/guardrails/usage_endpoints.py b/litellm/proxy/guardrails/usage_endpoints.py new file mode 100644 index 0000000000..3314c5ca2e --- /dev/null +++ b/litellm/proxy/guardrails/usage_endpoints.py @@ -0,0 +1,691 @@ +""" +Guardrails and policies usage endpoints for the dashboard. +GET /guardrails/usage/overview, /guardrails/usage/detail/:id, /guardrails/usage/logs +""" + +import json +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel + +from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + +router = APIRouter() + + +# --- Response models --- + + +class UsageOverviewRow(BaseModel): + id: str + name: str + type: str + provider: str + requestsEvaluated: int + failRate: float + avgScore: Optional[float] + avgLatency: Optional[float] + status: str # healthy | warning | critical + trend: str # up | down | stable + + +class UsageOverviewResponse(BaseModel): + rows: List[UsageOverviewRow] + chart: List[Dict[str, Any]] # [{ date, passed, blocked }] + totalRequests: int + totalBlocked: int + passRate: float + + +class UsageDetailResponse(BaseModel): + guardrail_id: str + guardrail_name: str + type: str + provider: str + requestsEvaluated: int + failRate: float + avgScore: Optional[float] + avgLatency: Optional[float] + status: str + trend: str + description: Optional[str] + time_series: List[Dict[str, Any]] + + +class UsageLogEntry(BaseModel): + id: str + timestamp: str + action: str # blocked | passed | flagged + score: Optional[float] + latency_ms: Optional[float] + model: Optional[str] + input_snippet: Optional[str] + output_snippet: Optional[str] + reason: Optional[str] + + +class UsageLogsResponse(BaseModel): + logs: List[UsageLogEntry] + total: int + page: int + page_size: int + + +def _status_from_fail_rate(fail_rate: float) -> str: + if fail_rate > 15: + return "critical" + if fail_rate > 5: + return "warning" + return "healthy" + + +def _trend_from_comparison(current_fail: float, previous_fail: float) -> str: + if previous_fail <= 0: + return "stable" + diff = current_fail - previous_fail + if diff > 0.5: + return "up" + if diff < -0.5: + return "down" + return "stable" + + +def _aggregate_daily_metrics(metrics: Any, id_attr: str) -> Dict[str, Dict[str, Any]]: + agg: Dict[str, Dict[str, Any]] = {} + for m in metrics: + gid = getattr(m, id_attr) + if gid not in agg: + agg[gid] = {"requests": 0, "passed": 0, "blocked": 0, "flagged": 0} + agg[gid]["requests"] += int(m.requests_evaluated or 0) + agg[gid]["passed"] += int(m.passed_count or 0) + agg[gid]["blocked"] += int(m.blocked_count or 0) + agg[gid]["flagged"] += int(m.flagged_count or 0) + return agg + + +def _prev_fail_rates(metrics_prev: Any, id_attr: str) -> Dict[str, float]: + prev_agg_raw: Dict[str, Dict[str, int]] = {} + for m in metrics_prev: + gid = getattr(m, id_attr) + r, b = int(m.requests_evaluated or 0), int(m.blocked_count or 0) + if gid not in prev_agg_raw: + prev_agg_raw[gid] = {"req": 0, "blocked": 0} + prev_agg_raw[gid]["req"] += r + prev_agg_raw[gid]["blocked"] += b + return { + gid: (100.0 * v["blocked"] / v["req"]) if v["req"] else 0.0 + for gid, v in prev_agg_raw.items() + } + + +def _chart_from_metrics(metrics: Any) -> List[Dict[str, Any]]: + chart_by_date: Dict[str, Dict[str, int]] = {} + for m in metrics: + d = m.date + if d not in chart_by_date: + chart_by_date[d] = {"passed": 0, "blocked": 0} + chart_by_date[d]["passed"] += int(m.passed_count or 0) + chart_by_date[d]["blocked"] += int(m.blocked_count or 0) + return [ + {"date": d, "passed": v["passed"], "blocked": v["blocked"]} + for d, v in sorted(chart_by_date.items()) + ] + + +def _get_guardrail_attrs(g: Any) -> tuple[Any, str]: + """Get (guardrail_id, display_name) from guardrail - handles Prisma model or dict.""" + gid = getattr(g, "guardrail_id", None) or ( + g.get("guardrail_id") if isinstance(g, dict) else None + ) + name = getattr(g, "guardrail_name", None) or ( + g.get("guardrail_name") if isinstance(g, dict) else None + ) + return gid, (name or gid or "") + + +def _guardrail_overview_rows( + guardrails: Any, + agg: Dict[str, Dict[str, Any]], + prev_agg: Dict[str, float], +) -> List[UsageOverviewRow]: + rows: List[UsageOverviewRow] = [] + covered_keys: set = set() + for g in guardrails: + gid, display_name = _get_guardrail_attrs(g) + # Metrics are keyed by logical name from spend log metadata; guardrails table uses UUID + lookup_keys = [k for k in (display_name, gid) if k] + covered_keys.update(lookup_keys) + a = {"requests": 0, "passed": 0, "blocked": 0, "flagged": 0} + for k in lookup_keys: + if k in agg: + a = agg[k] + break + req, blocked = a["requests"], a["blocked"] + fail_rate = (100.0 * blocked / req) if req else 0.0 + litellm_params = ( + (g.litellm_params or {}) if isinstance(g.litellm_params, dict) else {} + ) + provider = str(litellm_params.get("guardrail", "Unknown")) + guardrail_info = ( + (g.guardrail_info or {}) if isinstance(g.guardrail_info, dict) else {} + ) + gtype = str(guardrail_info.get("type", "Guardrail")) + prev_fail = 0.0 + for k in lookup_keys: + if k in prev_agg: + prev_fail = float(prev_agg.get(k, 0.0) or 0.0) + break + trend = _trend_from_comparison(fail_rate, prev_fail) + rows.append( + UsageOverviewRow( + id=gid, + name=display_name or str(gid), + type=gtype, + provider=provider, + requestsEvaluated=req, + failRate=round(fail_rate, 1), + avgScore=None, + avgLatency=None, + status=_status_from_fail_rate(fail_rate), + trend=trend, + ) + ) + # Add rows for guardrails with metrics but not in guardrails table (e.g. MCP, config) + for agg_key, a in agg.items(): + if agg_key in covered_keys or a["requests"] == 0: + continue + req, blocked = a["requests"], a["blocked"] + fail_rate = (100.0 * blocked / req) if req else 0.0 + prev_fail = float(prev_agg.get(agg_key, 0.0) or 0.0) + trend = _trend_from_comparison(fail_rate, prev_fail) + rows.append( + UsageOverviewRow( + id=agg_key, + name=agg_key, + type="Guardrail", + provider="Custom", + requestsEvaluated=req, + failRate=round(fail_rate, 1), + avgScore=None, + avgLatency=None, + status=_status_from_fail_rate(fail_rate), + trend=trend, + ) + ) + return rows + + +def _policy_overview_rows( + policies: Any, + agg: Dict[str, Dict[str, Any]], + prev_agg: Dict[str, float], +) -> List[UsageOverviewRow]: + rows: List[UsageOverviewRow] = [] + for p in policies: + pid = p.policy_id + a = agg.get(pid, {"requests": 0, "passed": 0, "blocked": 0, "flagged": 0}) + req, blocked = a["requests"], a["blocked"] + fail_rate = (100.0 * blocked / req) if req else 0.0 + trend = _trend_from_comparison(fail_rate, prev_agg.get(pid, 0.0)) + rows.append( + UsageOverviewRow( + id=pid, + name=p.policy_name or pid, + type="Policy", + provider="LiteLLM", + requestsEvaluated=req, + failRate=round(fail_rate, 1), + avgScore=None, + avgLatency=None, + status=_status_from_fail_rate(fail_rate), + trend=trend, + ) + ) + return rows + + +@router.get( + "/guardrails/usage/overview", + tags=["Guardrails"], + dependencies=[Depends(user_api_key_auth)], + response_model=UsageOverviewResponse, +) +async def guardrails_usage_overview( + start_date: Optional[str] = Query(None, description="YYYY-MM-DD"), + end_date: Optional[str] = Query(None, description="YYYY-MM-DD"), + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """Return guardrail performance overview for the dashboard.""" + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None: + return UsageOverviewResponse( + rows=[], chart=[], totalRequests=0, totalBlocked=0, passRate=100.0 + ) + + now = datetime.now(timezone.utc) + end = end_date or now.strftime("%Y-%m-%d") + start = start_date or (now - timedelta(days=7)).strftime("%Y-%m-%d") + + try: + # Guardrails from DB + guardrails = await prisma_client.db.litellm_guardrailstable.find_many() + + # Daily metrics in range + metrics = await prisma_client.db.litellm_dailyguardrailmetrics.find_many( + where={"date": {"gte": start, "lte": end}} + ) + + # Previous period for trend + start_prev = ( + datetime.strptime(start, "%Y-%m-%d") - timedelta(days=7) + ).strftime("%Y-%m-%d") + metrics_prev = await prisma_client.db.litellm_dailyguardrailmetrics.find_many( + where={"date": {"gte": start_prev, "lt": start}} + ) + + agg = _aggregate_daily_metrics(metrics, "guardrail_id") + prev_agg = _prev_fail_rates(metrics_prev, "guardrail_id") + chart = _chart_from_metrics(metrics) + total_requests = sum(a["requests"] for a in agg.values()) + total_blocked = sum(a["blocked"] for a in agg.values()) + pass_rate = ( + (100.0 * (total_requests - total_blocked) / total_requests) + if total_requests + else 100.0 + ) + rows = _guardrail_overview_rows(guardrails, agg, prev_agg) + return UsageOverviewResponse( + rows=rows, + chart=chart, + totalRequests=total_requests, + totalBlocked=total_blocked, + passRate=round(pass_rate, 1), + ) + except Exception as e: + from litellm.proxy.utils import handle_exception_on_proxy + + raise handle_exception_on_proxy(e) + + +@router.get( + "/guardrails/usage/detail/{guardrail_id}", + tags=["Guardrails"], + dependencies=[Depends(user_api_key_auth)], + response_model=UsageDetailResponse, +) +async def guardrails_usage_detail( + guardrail_id: str, + start_date: Optional[str] = Query(None), + end_date: Optional[str] = Query(None), + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """Return single guardrail usage metrics and time series.""" + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None: + from fastapi import HTTPException + + raise HTTPException(status_code=500, detail="Prisma client not initialized") + + now = datetime.now(timezone.utc) + end = end_date or now.strftime("%Y-%m-%d") + start = start_date or (now - timedelta(days=7)).strftime("%Y-%m-%d") + + guardrail = await prisma_client.db.litellm_guardrailstable.find_unique( + where={"guardrail_id": guardrail_id} + ) + if not guardrail: + from fastapi import HTTPException + + raise HTTPException(status_code=404, detail="Guardrail not found") + + # Metrics are keyed by logical name (from spend log metadata), not UUID + logical_id = getattr(guardrail, "guardrail_name", None) or ( + guardrail.get("guardrail_name") if isinstance(guardrail, dict) else None + ) + metric_ids = [i for i in (logical_id, guardrail_id) if i] + + metrics = await prisma_client.db.litellm_dailyguardrailmetrics.find_many( + where={ + "guardrail_id": {"in": metric_ids}, + "date": {"gte": start, "lte": end}, + } + ) + metrics_prev = await prisma_client.db.litellm_dailyguardrailmetrics.find_many( + where={ + "guardrail_id": {"in": metric_ids}, + "date": {"lt": start}, + } + ) + + requests = sum(int(m.requests_evaluated or 0) for m in metrics) + blocked = sum(int(m.blocked_count or 0) for m in metrics) + fail_rate = (100.0 * blocked / requests) if requests else 0.0 + + prev_blocked = sum(int(m.blocked_count or 0) for m in metrics_prev) + prev_req = sum(int(m.requests_evaluated or 0) for m in metrics_prev) + prev_fail = (100.0 * prev_blocked / prev_req) if prev_req else 0.0 + trend = _trend_from_comparison(fail_rate, prev_fail) + + # Aggregate by date in case metrics exist under both UUID and logical name + ts_by_date: Dict[str, Dict[str, Any]] = {} + for m in metrics: + d = m.date + if d not in ts_by_date: + ts_by_date[d] = {"passed": 0, "blocked": 0} + ts_by_date[d]["passed"] += int(m.passed_count or 0) + ts_by_date[d]["blocked"] += int(m.blocked_count or 0) + time_series = [ + {"date": d, "passed": v["passed"], "blocked": v["blocked"], "score": None} + for d, v in sorted(ts_by_date.items()) + ] + _litellm_params = getattr(guardrail, "litellm_params", None) or ( + guardrail.get("litellm_params") if isinstance(guardrail, dict) else None + ) + litellm_params = ( + _litellm_params + if isinstance(_litellm_params, dict) + else {} + ) + _guardrail_info = getattr(guardrail, "guardrail_info", None) or ( + guardrail.get("guardrail_info") if isinstance(guardrail, dict) else None + ) + guardrail_info = ( + _guardrail_info + if isinstance(_guardrail_info, dict) + else {} + ) + _guardrail_name = getattr(guardrail, "guardrail_name", None) or ( + guardrail.get("guardrail_name") if isinstance(guardrail, dict) else None + ) + + return UsageDetailResponse( + guardrail_id=guardrail_id, + guardrail_name=_guardrail_name or guardrail_id, + type=str(guardrail_info.get("type", "Guardrail")), + provider=str(litellm_params.get("guardrail", "Unknown")), + requestsEvaluated=requests, + failRate=round(fail_rate, 1), + avgScore=None, + avgLatency=None, + status=_status_from_fail_rate(fail_rate), + trend=trend, + description=guardrail_info.get("description"), + time_series=time_series, + ) + + +def _build_usage_logs_where( + guardrail_ids: Optional[List[str]], + policy_id: Optional[str], + start_date: Optional[str], + end_date: Optional[str], +) -> Dict[str, Any]: + where: Dict[str, Any] = {} + if guardrail_ids: + where["guardrail_id"] = ( + {"in": guardrail_ids} if len(guardrail_ids) > 1 else guardrail_ids[0] + ) + if policy_id: + where["policy_id"] = policy_id + if start_date or end_date: + st_filter: Dict[str, Any] = {} + if start_date: + sd = start_date.replace("Z", "+00:00").strip() + if "T" not in sd: + sd += "T00:00:00+00:00" + st_filter["gte"] = datetime.fromisoformat(sd) + if end_date: + ed = end_date.replace("Z", "+00:00").strip() + if "T" not in ed: + ed += "T23:59:59+00:00" + st_filter["lte"] = datetime.fromisoformat(ed) + where["start_time"] = st_filter + return where + + +def _usage_log_entry_from_row( + r: Any, sl: Any, action_filter: Optional[str] +) -> Optional[UsageLogEntry]: + meta = sl.metadata + if isinstance(meta, str): + try: + meta = json.loads(meta) + except Exception: + meta = {} + guardrail_info_list = (meta or {}).get("guardrail_information") or [] + entry_for_guardrail = None + for gi in guardrail_info_list: + if (gi.get("guardrail_id") or gi.get("guardrail_name")) == r.guardrail_id: + entry_for_guardrail = gi + break + action_val = "passed" + score_val = None + latency_val = None + reason_val = None + if entry_for_guardrail: + st = (entry_for_guardrail.get("guardrail_status") or "").lower() + if "intervened" in st or "block" in st: + action_val = "blocked" + elif "fail" in st or "error" in st: + action_val = "flagged" + duration = entry_for_guardrail.get("duration") + if duration is not None: + latency_val = round(float(duration) * 1000, 0) + score_val = entry_for_guardrail.get( + "confidence_score" + ) or entry_for_guardrail.get("risk_score") + if score_val is not None: + score_val = round(float(score_val), 2) + resp = entry_for_guardrail.get("guardrail_response") + if isinstance(resp, str): + reason_val = resp[:500] + elif isinstance(resp, dict): + reason_val = str(resp)[:500] + if action_filter and action_val != action_filter: + return None + ts = ( + sl.startTime.isoformat() + if hasattr(sl.startTime, "isoformat") + else str(sl.startTime) + ) + return UsageLogEntry( + id=r.request_id, + timestamp=ts, + action=action_val, + score=score_val, + latency_ms=latency_val, + model=sl.model, + input_snippet=_input_snippet_for_log(sl), + output_snippet=_snippet(sl.response), + reason=reason_val, + ) + + +def _snippet(text: Any, max_len: int = 200) -> Optional[str]: + if text is None: + return None + if isinstance(text, str): + s = text + elif isinstance(text, list): + parts = [] + for item in text: + if isinstance(item, dict) and "content" in item: + c = item["content"] + parts.append(c if isinstance(c, str) else str(c)) + else: + parts.append(str(item)) + s = " ".join(parts) + else: + s = str(text) + result = (s[:max_len] + "...") if len(s) > max_len else s + if result == "{}": + return None + return result + + +def _input_snippet_for_log(sl: Any) -> Optional[str]: + """Snippet for request input: prefer messages, fall back to proxy_server_request (same as drawer).""" + out = _snippet(sl.messages) + if out: + return out + psr = getattr(sl, "proxy_server_request", None) + if not psr: + return None + if isinstance(psr, str): + try: + psr = json.loads(psr) + except Exception: + return _snippet(psr) + if isinstance(psr, dict): + msgs = psr.get("messages") + if msgs is None and isinstance(psr.get("body"), dict): + msgs = psr["body"].get("messages") + out = _snippet(msgs) + if out: + return out + return _snippet(psr) + return _snippet(psr) + + +@router.get( + "/guardrails/usage/logs", + tags=["Guardrails"], + dependencies=[Depends(user_api_key_auth)], + response_model=UsageLogsResponse, +) +async def guardrails_usage_logs( + guardrail_id: Optional[str] = Query(None), + policy_id: Optional[str] = Query(None), + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=100), + action: Optional[str] = Query(None), + start_date: Optional[str] = Query(None), + end_date: Optional[str] = Query(None), + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """Return paginated run logs for a guardrail (or policy) from SpendLogs via index.""" + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None: + return UsageLogsResponse(logs=[], total=0, page=page, page_size=page_size) + + if not guardrail_id and not policy_id: + return UsageLogsResponse(logs=[], total=0, page=page, page_size=page_size) + + try: + # Index rows may store either guardrail_id (UUID) or guardrail_name from metadata. + # Query by both so we match regardless of which was written. + effective_guardrail_ids: List[str] = [guardrail_id] if guardrail_id else [] + if guardrail_id: + guardrail = await prisma_client.db.litellm_guardrailstable.find_unique( + where={"guardrail_id": guardrail_id} + ) + if guardrail: + logical_name = getattr(guardrail, "guardrail_name", None) + if logical_name and logical_name not in effective_guardrail_ids: + effective_guardrail_ids.append(logical_name) + + where = _build_usage_logs_where( + effective_guardrail_ids or None, policy_id, start_date, end_date + ) + index_rows = await prisma_client.db.litellm_spendlogguardrailindex.find_many( + where=where, + order={"start_time": "desc"}, + skip=(page - 1) * page_size, + take=page_size + 1, + ) + total = await prisma_client.db.litellm_spendlogguardrailindex.count(where=where) + request_ids = [r.request_id for r in index_rows[:page_size]] + if not request_ids: + return UsageLogsResponse( + logs=[], total=total, page=page, page_size=page_size + ) + spend_logs = await prisma_client.db.litellm_spendlogs.find_many( + where={"request_id": {"in": request_ids}} + ) + log_by_id = {s.request_id: s for s in spend_logs} + logs_out: List[UsageLogEntry] = [] + for r in index_rows[:page_size]: + sl = log_by_id.get(r.request_id) + if not sl: + continue + entry = _usage_log_entry_from_row(r, sl, action) + if entry is not None: + logs_out.append(entry) + return UsageLogsResponse( + logs=logs_out, total=total, page=page, page_size=page_size + ) + except Exception as e: + from litellm.proxy.utils import handle_exception_on_proxy + + raise handle_exception_on_proxy(e) + + +# --- Policy usage (same shape as guardrails; policy metrics populated when policy_run is in metadata) --- + + +@router.get( + "/policies/usage/overview", + tags=["Policies"], + dependencies=[Depends(user_api_key_auth)], + response_model=UsageOverviewResponse, +) +async def policies_usage_overview( + start_date: Optional[str] = Query(None, description="YYYY-MM-DD"), + end_date: Optional[str] = Query(None, description="YYYY-MM-DD"), + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """Return policy performance overview for the dashboard.""" + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None: + return UsageOverviewResponse( + rows=[], chart=[], totalRequests=0, totalBlocked=0, passRate=100.0 + ) + + now = datetime.now(timezone.utc) + end = end_date or now.strftime("%Y-%m-%d") + start = start_date or (now - timedelta(days=7)).strftime("%Y-%m-%d") + + try: + policies = await prisma_client.db.litellm_policytable.find_many() + metrics = await prisma_client.db.litellm_dailypolicymetrics.find_many( + where={"date": {"gte": start, "lte": end}} + ) + metrics_prev = await prisma_client.db.litellm_dailypolicymetrics.find_many( + where={ + "date": { + "gte": ( + datetime.strptime(start, "%Y-%m-%d") - timedelta(days=7) + ).strftime("%Y-%m-%d"), + "lt": start, + } + } + ) + agg = _aggregate_daily_metrics(metrics, "policy_id") + prev_agg = _prev_fail_rates(metrics_prev, "policy_id") + chart = _chart_from_metrics(metrics) + total_requests = sum(a["requests"] for a in agg.values()) + total_blocked = sum(a["blocked"] for a in agg.values()) + pass_rate = ( + (100.0 * (total_requests - total_blocked) / total_requests) + if total_requests + else 100.0 + ) + rows = _policy_overview_rows(policies, agg, prev_agg) + return UsageOverviewResponse( + rows=rows, + chart=chart, + totalRequests=total_requests, + totalBlocked=total_blocked, + passRate=round(pass_rate, 1), + ) + except Exception as e: + from litellm.proxy.utils import handle_exception_on_proxy + + raise handle_exception_on_proxy(e) diff --git a/litellm/proxy/guardrails/usage_tracking.py b/litellm/proxy/guardrails/usage_tracking.py new file mode 100644 index 0000000000..248f3a1987 --- /dev/null +++ b/litellm/proxy/guardrails/usage_tracking.py @@ -0,0 +1,170 @@ +""" +Track guardrail and policy usage for the dashboard: upsert daily metrics and +insert into SpendLogGuardrailIndex when spend logs are written. +""" + +import json +from collections import defaultdict +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +from litellm._logging import verbose_proxy_logger +from litellm.proxy.utils import PrismaClient + + +def _guardrail_status_to_action(status: Optional[str]) -> str: + """Map StandardLogging guardrail_status to blocked/passed/flagged.""" + if not status: + return "passed" + s = (status or "").lower() + if "intervened" in s or "block" in s: + return "blocked" + if "fail" in s or "error" in s: + return "flagged" + return "passed" + + +def _parse_guardrail_info_from_payload(payload: Dict[str, Any]) -> List[Dict[str, Any]]: + """Extract guardrail_information from spend log payload metadata.""" + meta = payload.get("metadata") + if not meta: + return [] + if isinstance(meta, str): + try: + meta = json.loads(meta) + except (json.JSONDecodeError, TypeError): + return [] + if not isinstance(meta, dict): + return [] + info = meta.get("guardrail_information") or meta.get( + "standard_logging_guardrail_information" + ) + if not isinstance(info, list): + return [] + return info + + +def _date_str(dt: datetime) -> str: + """YYYY-MM-DD in UTC.""" + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).strftime("%Y-%m-%d") + + +async def process_spend_logs_guardrail_usage( + prisma_client: PrismaClient, + logs_to_process: List[Dict[str, Any]], +) -> None: + """ + After spend logs are written: update DailyGuardrailMetrics and insert + SpendLogGuardrailIndex rows from guardrail_information in each payload. + """ + if not logs_to_process: + return + # Aggregate daily metrics by (guardrail_id, date). Latency/score metrics dropped. + daily_guardrail: Dict[tuple, Dict[str, Any]] = defaultdict( + lambda: { + "requests_evaluated": 0, + "passed_count": 0, + "blocked_count": 0, + "flagged_count": 0, + } + ) + index_rows: List[Dict[str, Any]] = [] + + for payload in logs_to_process: + request_id = payload.get("request_id") + start_time = payload.get("startTime") + if not request_id or not start_time: + continue + if isinstance(start_time, str): + try: + start_time = datetime.fromisoformat(start_time.replace("Z", "+00:00")) + except (ValueError, TypeError): + continue + date_key = _date_str(start_time) + + for entry in _parse_guardrail_info_from_payload(payload): + guardrail_id = entry.get("guardrail_id") or entry.get("guardrail_name") or "" + if not guardrail_id: + continue + key = (guardrail_id, date_key) + daily_guardrail[key]["requests_evaluated"] += 1 + action = _guardrail_status_to_action(entry.get("guardrail_status")) + if action == "passed": + daily_guardrail[key]["passed_count"] += 1 + elif action == "blocked": + daily_guardrail[key]["blocked_count"] += 1 + else: + daily_guardrail[key]["flagged_count"] += 1 + policy_id = entry.get("policy_id") + index_rows.append({ + "request_id": request_id, + "guardrail_id": guardrail_id, + "policy_id": policy_id, + "start_time": start_time, + }) + + if not daily_guardrail and not index_rows: + return + + try: + # Insert index rows (skip duplicates by request_id + guardrail_id) + if index_rows: + index_data = [] + for r in index_rows: + st = r["start_time"] + if isinstance(st, str): + try: + st = datetime.fromisoformat(st.replace("Z", "+00:00")) + except (ValueError, TypeError): + continue + index_data.append({ + "request_id": r["request_id"], + "guardrail_id": r["guardrail_id"], + "policy_id": r.get("policy_id"), + "start_time": st, + }) + try: + await prisma_client.db.litellm_spendlogguardrailindex.create_many( + data=index_data, + skip_duplicates=True, + ) + except Exception as e: + verbose_proxy_logger.debug( + "Guardrail usage tracking: index create_many skipped: %s", e + ) + + # Upsert daily guardrail metrics (counts only; latency/score dropped) + for (guardrail_id, date_key), agg in daily_guardrail.items(): + n = int(agg["requests_evaluated"]) + if n == 0: + continue + await prisma_client.db.litellm_dailyguardrailmetrics.upsert( + where={ + "guardrail_id_date": { + "guardrail_id": guardrail_id, + "date": date_key, + } + }, + data={ + "create": { + "guardrail_id": guardrail_id, + "date": date_key, + "requests_evaluated": n, + "passed_count": int(agg["passed_count"]), + "blocked_count": int(agg["blocked_count"]), + "flagged_count": int(agg["flagged_count"]), + }, + "update": { + "requests_evaluated": {"increment": n}, + "passed_count": {"increment": int(agg["passed_count"])}, + "blocked_count": {"increment": int(agg["blocked_count"])}, + "flagged_count": {"increment": int(agg["flagged_count"])}, + }, + }, + ) + except Exception as e: + verbose_proxy_logger.warning( + "Guardrail usage tracking failed (non-fatal): %s", e + ) diff --git a/litellm/proxy/management_endpoints/model_access_group_management_endpoints.py b/litellm/proxy/management_endpoints/model_access_group_management_endpoints.py index 0c820f6b78..000682bbf8 100644 --- a/litellm/proxy/management_endpoints/model_access_group_management_endpoints.py +++ b/litellm/proxy/management_endpoints/model_access_group_management_endpoints.py @@ -141,6 +141,50 @@ async def update_deployments_with_access_group( return models_updated +async def update_specific_deployments_with_access_group( + model_ids: List[str], + access_group: str, + prisma_client: PrismaClient, +) -> int: + """ + Update specific deployments (by model_id) to include the access group. + + Unlike update_deployments_with_access_group which tags ALL deployments sharing + a model_name, this function only tags the specific deployments identified by + their unique model_id. + """ + models_updated = 0 + for model_id in model_ids: + verbose_proxy_logger.debug( + f"Updating specific deployment model_id: {model_id}" + ) + deployment = await prisma_client.db.litellm_proxymodeltable.find_unique( + where={"model_id": model_id} + ) + if deployment is None: + raise HTTPException( + status_code=400, + detail={ + "error": f"Deployment with model_id '{model_id}' not found in Database." + }, + ) + model_info = deployment.model_info or {} + updated_model_info, was_modified = add_access_group_to_deployment( + model_info=model_info, + access_group=access_group, + ) + if was_modified: + await prisma_client.db.litellm_proxymodeltable.update( + where={"model_id": model_id}, + data={"model_info": json.dumps(updated_model_info)}, + ) + models_updated += 1 + verbose_proxy_logger.debug( + f"Updated deployment {model_id} with access group: {access_group}" + ) + return models_updated + + def remove_access_group_from_deployment( model_info: Dict[str, Any], access_group: str ) -> Tuple[Dict[str, Any], bool]: @@ -263,24 +307,32 @@ async def create_model_group( detail={"error": "access_group is required and cannot be empty"}, ) - # Validation: Check if model_names list is provided and not empty - if not data.model_names or len(data.model_names) == 0: + # Validation: Check that at least one of model_names or model_ids is provided + has_model_names = data.model_names and len(data.model_names) > 0 + has_model_ids = data.model_ids and len(data.model_ids) > 0 + + if not has_model_names and not has_model_ids: raise HTTPException( status_code=400, - detail={"error": "model_names list is required and cannot be empty"}, + detail={"error": "Either model_names or model_ids must be provided and non-empty"}, ) - - # Validation: Check if all models exist in the router - all_valid, missing_models = validate_models_exist( - model_names=data.model_names, - llm_router=llm_router, - ) - - if not all_valid: - raise HTTPException( - status_code=400, - detail={"error": f"Model(s) not found: {', '.join(missing_models)}"}, + + # If model_ids is provided, use it (more precise targeting) + use_model_ids = has_model_ids + + # Validate model_names exist in router (only if using model_names path) + if not use_model_ids and has_model_names: + assert data.model_names is not None + all_valid, missing_models = validate_models_exist( + model_names=data.model_names, + llm_router=llm_router, ) + + if not all_valid: + raise HTTPException( + status_code=400, + detail={"error": f"Model(s) not found: {', '.join(missing_models)}"}, + ) # Check if database is connected if prisma_client is None: @@ -301,12 +353,21 @@ async def create_model_group( detail={"error": f"Access group '{data.access_group}' already exists. Use PUT /access_group/{data.access_group}/update to modify it."}, ) - # Update deployments using helper function - models_updated = await update_deployments_with_access_group( - model_names=data.model_names, - access_group=data.access_group, - prisma_client=prisma_client, - ) + # Update deployments using the appropriate method + if use_model_ids: + assert data.model_ids is not None + models_updated = await update_specific_deployments_with_access_group( + model_ids=data.model_ids, + access_group=data.access_group, + prisma_client=prisma_client, + ) + else: + assert data.model_names is not None + models_updated = await update_deployments_with_access_group( + model_names=data.model_names, + access_group=data.access_group, + prisma_client=prisma_client, + ) await clear_cache() @@ -317,6 +378,7 @@ async def create_model_group( return NewModelGroupResponse( access_group=data.access_group, model_names=data.model_names, + model_ids=data.model_ids, models_updated=models_updated, ) @@ -496,12 +558,17 @@ async def update_access_group( f"Updating access group: {access_group} with models: {data.model_names}" ) - # Validation: Check if model_names list is provided and not empty - if not data.model_names or len(data.model_names) == 0: + # Validation: Check that at least one of model_names or model_ids is provided + has_model_names = data.model_names and len(data.model_names) > 0 + has_model_ids = data.model_ids and len(data.model_ids) > 0 + + if not has_model_names and not has_model_ids: raise HTTPException( status_code=400, - detail={"error": "model_names list is required and cannot be empty"}, + detail={"error": "Either model_names or model_ids must be provided and non-empty"}, ) + + use_model_ids = has_model_ids # Validation: Check if access group exists try: @@ -521,17 +588,19 @@ async def update_access_group( detail={"error": f"Failed to check access group existence: {str(e)}"}, ) - # Validation: Check if all new models exist - all_valid, missing_models = validate_models_exist( - model_names=data.model_names, - llm_router=llm_router, - ) - - if not all_valid: - raise HTTPException( - status_code=400, - detail={"error": f"Model(s) not found: {', '.join(missing_models)}"}, + # Validation: Check if all new models exist (only if using model_names path) + if not use_model_ids and has_model_names: + assert data.model_names is not None + all_valid, missing_models = validate_models_exist( + model_names=data.model_names, + llm_router=llm_router, ) + + if not all_valid: + raise HTTPException( + status_code=400, + detail={"error": f"Model(s) not found: {', '.join(missing_models)}"}, + ) try: # Step 1: Remove access group from ALL DB deployments (skip config models) @@ -552,12 +621,21 @@ async def update_access_group( data={"model_info": json.dumps(updated_model_info)}, ) - # Step 2: Add access group to new model_names - models_updated = await update_deployments_with_access_group( - model_names=data.model_names, - access_group=access_group, - prisma_client=prisma_client, - ) + # Step 2: Add access group using the appropriate method + if use_model_ids: + assert data.model_ids is not None + models_updated = await update_specific_deployments_with_access_group( + model_ids=data.model_ids, + access_group=access_group, + prisma_client=prisma_client, + ) + else: + assert data.model_names is not None + models_updated = await update_deployments_with_access_group( + model_names=data.model_names, + access_group=access_group, + prisma_client=prisma_client, + ) # Clear cache and reload models to pick up the access group changes await clear_cache() @@ -569,6 +647,7 @@ async def update_access_group( return NewModelGroupResponse( access_group=access_group, model_names=data.model_names, + model_ids=data.model_ids, models_updated=models_updated, ) diff --git a/litellm/proxy/management_endpoints/ui_sso.py b/litellm/proxy/management_endpoints/ui_sso.py index 17e266bdea..5a1b31aebb 100644 --- a/litellm/proxy/management_endpoints/ui_sso.py +++ b/litellm/proxy/management_endpoints/ui_sso.py @@ -214,29 +214,41 @@ def process_sso_jwt_access_token( if isinstance(result, dict): result_team_ids: Optional[List[str]] = result.get("team_ids", []) if not result_team_ids: - team_ids = sso_jwt_handler.get_team_ids_from_jwt(access_token_payload) + team_ids = sso_jwt_handler.get_team_ids_from_jwt( + access_token_payload + ) result["team_ids"] = team_ids else: result_team_ids = getattr(result, "team_ids", []) if result else [] if not result_team_ids: - team_ids = sso_jwt_handler.get_team_ids_from_jwt(access_token_payload) + team_ids = sso_jwt_handler.get_team_ids_from_jwt( + access_token_payload + ) setattr(result, "team_ids", team_ids) # Extract user role from access token if not already set from UserInfo - existing_role = result.get("user_role") if isinstance(result, dict) else getattr(result, "user_role", None) + existing_role = ( + result.get("user_role") + if isinstance(result, dict) + else getattr(result, "user_role", None) + ) if existing_role is None: user_role: Optional[LitellmUserRoles] = None # Try role_mappings first (group-based role determination) if role_mappings is not None and role_mappings.roles: group_claim = role_mappings.group_claim - user_groups_raw: Any = get_nested_value(access_token_payload, group_claim) + user_groups_raw: Any = get_nested_value( + access_token_payload, group_claim + ) user_groups: List[str] = [] if isinstance(user_groups_raw, list): user_groups = [str(g) for g in user_groups_raw] elif isinstance(user_groups_raw, str): - user_groups = [g.strip() for g in user_groups_raw.split(",") if g.strip()] + user_groups = [ + g.strip() for g in user_groups_raw.split(",") if g.strip() + ] elif user_groups_raw is not None: user_groups = [str(user_groups_raw)] @@ -250,8 +262,12 @@ def process_sso_jwt_access_token( # Fallback: try GENERIC_USER_ROLE_ATTRIBUTE on the access token payload if user_role is None: - generic_user_role_attribute_name = os.getenv("GENERIC_USER_ROLE_ATTRIBUTE", "role") - user_role_from_token = get_nested_value(access_token_payload, generic_user_role_attribute_name) + generic_user_role_attribute_name = os.getenv( + "GENERIC_USER_ROLE_ATTRIBUTE", "role" + ) + user_role_from_token = get_nested_value( + access_token_payload, generic_user_role_attribute_name + ) if user_role_from_token is not None: user_role = get_litellm_user_role(user_role_from_token) verbose_proxy_logger.debug( @@ -309,7 +325,7 @@ async def google_login( total_users = await prisma_client.db.litellm_usertable.count() if total_users and total_users > 5: raise ProxyException( - message="You must be a LiteLLM Enterprise user to use SSO for more than 5 users. If you have a license please set `LITELLM_LICENSE` in your env. If you want to obtain a license meet with us here: https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat You are seeing this error message because You set one of `MICROSOFT_CLIENT_ID`, `GOOGLE_CLIENT_ID`, or `GENERIC_CLIENT_ID` in your env. Please unset this", + message="You must be a LiteLLM Enterprise user to use SSO for more than 5 users. If you have a license please set `LITELLM_LICENSE` in your env. If you want to obtain a license meet with us here: https://calendly.com/d/cx9p-5yf-2nm/litellm-introductions You are seeing this error message because You set one of `MICROSOFT_CLIENT_ID`, `GOOGLE_CLIENT_ID`, or `GENERIC_CLIENT_ID` in your env. Please unset this", type=ProxyErrorTypes.auth_error, param="premium_user", code=status.HTTP_403_FORBIDDEN, @@ -662,13 +678,11 @@ async def _setup_role_mappings() -> Optional["RoleMappings"]: import ast try: - generic_user_role_mappings_data: Dict[ - LitellmUserRoles, List[str] - ] = ast.literal_eval(generic_role_mappings) + generic_user_role_mappings_data: Dict[LitellmUserRoles, List[str]] = ( + ast.literal_eval(generic_role_mappings) + ) if isinstance(generic_user_role_mappings_data, dict): - from litellm.types.proxy.management_endpoints.ui_sso import ( - RoleMappings, - ) + from litellm.types.proxy.management_endpoints.ui_sso import RoleMappings role_mappings_data = { "provider": "generic", @@ -770,7 +784,9 @@ async def get_generic_sso_response( ) access_token_str: Optional[str] = generic_sso.access_token - process_sso_jwt_access_token(access_token_str, sso_jwt_handler, result, role_mappings=role_mappings) + process_sso_jwt_access_token( + access_token_str, sso_jwt_handler, result, role_mappings=role_mappings + ) except Exception as e: verbose_proxy_logger.exception( @@ -1000,9 +1016,9 @@ def apply_user_info_values_to_sso_user_defined_values( else: # SSO didn't provide a valid role, fall back to DB role or default if user_info is None or user_info.user_role is None: - user_defined_values[ - "user_role" - ] = LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value + user_defined_values["user_role"] = ( + LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value + ) verbose_proxy_logger.debug( "No SSO or DB role found, using default: INTERNAL_USER_VIEW_ONLY" ) @@ -1430,9 +1446,9 @@ async def insert_sso_user( if user_defined_values.get("max_budget") is None: user_defined_values["max_budget"] = litellm.max_internal_user_budget if user_defined_values.get("budget_duration") is None: - user_defined_values[ - "budget_duration" - ] = litellm.internal_user_budget_duration + user_defined_values["budget_duration"] = ( + litellm.internal_user_budget_duration + ) if user_defined_values["user_role"] is None: user_defined_values["user_role"] = LitellmUserRoles.INTERNAL_USER_VIEW_ONLY @@ -2533,9 +2549,9 @@ class MicrosoftSSOHandler: # if user is trying to get the raw sso response for debugging, return the raw sso response if return_raw_sso_response: - original_msft_result[ - MicrosoftSSOHandler.GRAPH_API_RESPONSE_KEY - ] = user_team_ids + original_msft_result[MicrosoftSSOHandler.GRAPH_API_RESPONSE_KEY] = ( + user_team_ids + ) original_msft_result["app_roles"] = app_roles return original_msft_result or {} @@ -2654,9 +2670,9 @@ class MicrosoftSSOHandler: # Fetch user membership from Microsoft Graph API all_group_ids = [] - next_link: Optional[ - str - ] = MicrosoftSSOHandler.graph_api_user_groups_endpoint + next_link: Optional[str] = ( + MicrosoftSSOHandler.graph_api_user_groups_endpoint + ) auth_headers = {"Authorization": f"Bearer {access_token}"} page_count = 0 @@ -2885,7 +2901,7 @@ async def debug_sso_login(request: Request): ): if premium_user is not True: raise ProxyException( - message="You must be a LiteLLM Enterprise user to use SSO. If you have a license please set `LITELLM_LICENSE` in your env. If you want to obtain a license meet with us here: https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat You are seeing this error message because You set one of `MICROSOFT_CLIENT_ID`, `GOOGLE_CLIENT_ID`, or `GENERIC_CLIENT_ID` in your env. Please unset this", + message="You must be a LiteLLM Enterprise user to use SSO. If you have a license please set `LITELLM_LICENSE` in your env. If you want to obtain a license meet with us here: https://calendly.com/d/cx9p-5yf-2nm/litellm-introductions You are seeing this error message because You set one of `MICROSOFT_CLIENT_ID`, `GOOGLE_CLIENT_ID`, or `GENERIC_CLIENT_ID` in your env. Please unset this", type=ProxyErrorTypes.auth_error, param="premium_user", code=status.HTTP_403_FORBIDDEN, diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index c1181dd52c..5d0c7a89d8 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -1,4 +1,3 @@ -import anyio import asyncio import copy import enum @@ -31,6 +30,7 @@ from typing import ( get_type_hints, ) +import anyio from pydantic import BaseModel, Json from litellm._uuid import uuid @@ -3161,6 +3161,7 @@ class ProxyConfig: alert_types=general_settings.get("alert_types", None), alert_to_webhook_url=general_settings.get("alert_to_webhook_url", None), alerting_args=general_settings.get("alerting_args", None), + alert_type_config=general_settings.get("alert_type_config", None), redis_cache=redis_usage_cache, ) @@ -3598,9 +3599,6 @@ class ProxyConfig: parsed = value elif isinstance(value, str): import json - - import yaml - try: parsed = yaml.safe_load(value) except (yaml.YAMLError, json.JSONDecodeError): @@ -4388,6 +4386,9 @@ class ProxyConfig: litellm.model_cost = new_model_cost_map # Invalidate case-insensitive lookup map since model_cost was replaced _invalidate_model_cost_lowercase_map() + # Repopulate provider model sets (e.g. litellm.anthropic_models) so that + # wildcard patterns like "anthropic/*" include any newly added models. + litellm.add_known_models(model_cost_map=new_model_cost_map) # Update pod's in-memory last reload time last_model_cost_map_reload = current_time.isoformat() @@ -10769,6 +10770,81 @@ async def get_image(): return FileResponse(logo_path, media_type="image/jpeg") +@app.get("/get_favicon", include_in_schema=False) +async def get_favicon(): + """Get custom favicon for the admin UI.""" + from fastapi.responses import Response + + current_dir = os.path.dirname(os.path.abspath(__file__)) + default_favicon = os.path.join( + current_dir, "_experimental", "out", "favicon.ico" + ) + + favicon_url = os.getenv("LITELLM_FAVICON_URL", "") + + if not favicon_url: + if os.path.exists(default_favicon): + return FileResponse(default_favicon, media_type="image/x-icon") + raise HTTPException( + status_code=404, detail="Default favicon not found" + ) + + if favicon_url.startswith(("http://", "https://")): + try: + from litellm.llms.custom_httpx.http_handler import ( + get_async_httpx_client, + ) + from litellm.types.llms.custom_http import httpxSpecialProvider + + async_client = get_async_httpx_client( + llm_provider=httpxSpecialProvider.UI, + params={"timeout": 5.0}, + ) + response = await async_client.get(favicon_url) + if response.status_code == 200: + content_type = response.headers.get( + "content-type", "image/x-icon" + ) + return Response( + content=response.content, + media_type=content_type, + ) + else: + verbose_proxy_logger.warning( + "Failed to fetch favicon from %s: status %s", + favicon_url, + response.status_code, + ) + if os.path.exists(default_favicon): + return FileResponse( + default_favicon, media_type="image/x-icon" + ) + raise HTTPException( + status_code=404, detail="Favicon not found" + ) + except HTTPException: + raise + except Exception as e: + verbose_proxy_logger.debug( + "Error downloading favicon from %s: %s", favicon_url, e + ) + if os.path.exists(default_favicon): + return FileResponse( + default_favicon, media_type="image/x-icon" + ) + raise HTTPException( + status_code=404, detail="Favicon not found" + ) + else: + if os.path.exists(favicon_url): + return FileResponse(favicon_url, media_type="image/x-icon") + if os.path.exists(default_favicon): + return FileResponse(default_favicon, media_type="image/x-icon") + raise HTTPException( + status_code=404, detail="Favicon not found" + ) + + #### INVITATION MANAGEMENT #### @@ -11890,6 +11966,9 @@ async def reload_model_cost_map( litellm.model_cost = new_model_cost_map # Invalidate case-insensitive lookup map since model_cost was replaced _invalidate_model_cost_lowercase_map() + # Repopulate provider model sets (e.g. litellm.anthropic_models) so that + # wildcard patterns like "anthropic/*" include any newly added models. + litellm.add_known_models(model_cost_map=new_model_cost_map) # Update pod's in-memory last reload time global last_model_cost_map_reload @@ -12144,6 +12223,55 @@ async def get_model_cost_map_reload_status( ) +@router.get( + "/model/cost_map/source", + tags=["model management"], + dependencies=[Depends(user_api_key_auth)], + include_in_schema=False, +) +async def get_model_cost_map_source( + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """ + ADMIN ONLY / MASTER KEY Only Endpoint + + Returns information about where the current model cost/pricing data was loaded from. + + Response fields: + - source: "local" (bundled backup) or "remote" (fetched from URL) + - url: the remote URL that was attempted (null when env-forced local) + - is_env_forced: true if LITELLM_LOCAL_MODEL_COST_MAP=True forced local usage + - fallback_reason: human-readable reason why remote failed (null on success) + - model_count: number of models in the currently loaded cost map + """ + if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: + raise HTTPException( + status_code=403, + detail=f"Access denied. Admin role required. Current role: {user_api_key_dict.user_role}", + ) + + try: + from litellm.litellm_core_utils.get_model_cost_map import ( + get_model_cost_map_source_info, + ) + + source_info = get_model_cost_map_source_info() + model_count = len(litellm.model_cost) if litellm.model_cost else 0 + + return { + **source_info, + "model_count": model_count, + } + except Exception as e: + verbose_proxy_logger.exception( + f"Failed to get model cost map source info: {str(e)}" + ) + raise HTTPException( + status_code=500, + detail=f"Failed to get model cost map source info: {str(e)}", + ) + + #### ANTHROPIC BETA HEADERS RELOAD ENDPOINTS #### diff --git a/litellm/proxy/public_endpoints/public_endpoints.py b/litellm/proxy/public_endpoints/public_endpoints.py index 6d60a218fd..29c9cb571c 100644 --- a/litellm/proxy/public_endpoints/public_endpoints.py +++ b/litellm/proxy/public_endpoints/public_endpoints.py @@ -2,8 +2,16 @@ import json import os from typing import List +import litellm from fastapi import APIRouter, Depends, HTTPException +from litellm._logging import verbose_logger +from litellm.litellm_core_utils.get_blog_posts import ( + BlogPost, + BlogPostsResponse, + GetBlogPosts, + get_blog_posts, +) from litellm.proxy._types import CommonProxyErrors from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.types.agents import AgentCard @@ -193,6 +201,30 @@ async def get_litellm_model_cost_map(): ) +@router.get( + "/public/litellm_blog_posts", + tags=["public"], + response_model=BlogPostsResponse, +) +async def get_litellm_blog_posts(): + """ + Public endpoint to get the latest LiteLLM blog posts. + + Fetches from GitHub with a 1-hour in-process cache. + Falls back to the bundled local backup on any failure. + """ + try: + posts_data = get_blog_posts(url=litellm.blog_posts_url) + except Exception as e: + verbose_logger.warning( + "LiteLLM: get_litellm_blog_posts endpoint fallback triggered: %s", str(e) + ) + posts_data = GetBlogPosts.load_local_blog_posts() + + posts = [BlogPost(**p) for p in posts_data[:5]] + return BlogPostsResponse(posts=posts) + + @router.get( "/public/agents/fields", tags=["public", "[beta] Agents"], diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index 5d2cad6da5..50c0a55a87 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -273,7 +273,6 @@ model LiteLLM_MCPServerTable { alias String? description String? url String? - spec_path String? transport String @default("sse") auth_type String? credentials Json? @default("{}") @@ -813,6 +812,7 @@ model LiteLLM_ManagedObjectTable { // for batches or finetuning jobs which use t file_object Json // Stores the OpenAIFileObject file_purpose String // either 'batch' or 'fine-tune' status String? // check if batch cost has been tracked + batch_processed Boolean @default(false) // set to true by CheckBatchCost after cost is computed created_at DateTime @default(now()) created_by String? updated_at DateTime @updatedAt @@ -866,6 +866,54 @@ model LiteLLM_GuardrailsTable { updated_at DateTime @updatedAt } +// Daily guardrail metrics for usage dashboard (one row per guardrail per day) +model LiteLLM_DailyGuardrailMetrics { + guardrail_id String // logical id; may not FK if guardrail from config + date String // YYYY-MM-DD + requests_evaluated BigInt @default(0) + passed_count BigInt @default(0) + blocked_count BigInt @default(0) + flagged_count BigInt @default(0) + avg_score Float? + avg_latency_ms Float? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@id([guardrail_id, date]) + @@index([date]) + @@index([guardrail_id]) +} + +// Daily policy metrics for usage dashboard (one row per policy per day) +model LiteLLM_DailyPolicyMetrics { + policy_id String + date String // YYYY-MM-DD + requests_evaluated BigInt @default(0) + passed_count BigInt @default(0) + blocked_count BigInt @default(0) + flagged_count BigInt @default(0) + avg_score Float? + avg_latency_ms Float? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@id([policy_id, date]) + @@index([date]) + @@index([policy_id]) +} + +// Index for fast "last N logs for guardrail/policy" from SpendLogs +model LiteLLM_SpendLogGuardrailIndex { + request_id String + guardrail_id String + policy_id String? // set when run as part of a policy pipeline + start_time DateTime + + @@id([request_id, guardrail_id]) + @@index([guardrail_id, start_time]) + @@index([policy_id, start_time]) +} + // Prompt table for storing prompt configurations model LiteLLM_PromptTable { id String @id @default(uuid()) diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index b465d13bc7..74cff0315a 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -30,6 +30,12 @@ class UIThemeConfig(BaseModel): description="URL or path to custom logo image. Can be a local file path or HTTP/HTTPS URL", ) + # Favicon configuration + favicon_url: Optional[str] = Field( + default=None, + description="URL to custom favicon image. Must be an HTTP/HTTPS URL to a .ico, .png, or .svg file", + ) + class SettingsResponse(BaseModel): """Base response model for settings with values and schema information""" @@ -794,6 +800,27 @@ async def update_ui_theme_settings(theme_config: UIThemeConfig): del os.environ["UI_LOGO_PATH"] verbose_proxy_logger.debug("Removed UI_LOGO_PATH from environment") + # Update LITELLM_FAVICON_URL environment variable if favicon_url is provided + favicon_url = theme_data.get("favicon_url") + verbose_proxy_logger.debug(f"Updating favicon_url: {favicon_url}") + + if ( + favicon_url and isinstance(favicon_url, str) and favicon_url.strip() + ): # Check if favicon_url exists and is not empty/whitespace + config["environment_variables"]["LITELLM_FAVICON_URL"] = favicon_url + os.environ["LITELLM_FAVICON_URL"] = favicon_url + verbose_proxy_logger.debug(f"Set LITELLM_FAVICON_URL to: {favicon_url}") + else: + # Remove the environment variable to restore default favicon + if "LITELLM_FAVICON_URL" in config.get("environment_variables", {}): + del config["environment_variables"]["LITELLM_FAVICON_URL"] + verbose_proxy_logger.debug("Removed LITELLM_FAVICON_URL from config") + if "LITELLM_FAVICON_URL" in os.environ: + del os.environ["LITELLM_FAVICON_URL"] + verbose_proxy_logger.debug( + "Removed LITELLM_FAVICON_URL from environment" + ) + # Handle environment variable encryption if needed stored_config = config.copy() if ( @@ -809,7 +836,7 @@ async def update_ui_theme_settings(theme_config: UIThemeConfig): await proxy_config.save_config(new_config=stored_config) return { - "message": "Logo settings updated successfully.", + "message": "UI theme settings updated successfully.", "status": "success", "theme_config": theme_data, } diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index f21fb0f9e3..c4ff325db1 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -23,31 +23,23 @@ from typing import ( ) from litellm import _custom_logger_compatible_callbacks_literal -from litellm.constants import DEFAULT_MODEL_CREATED_AT_TIME, MAX_TEAM_LIST_LIMIT -from litellm.proxy._types import ( - DB_CONNECTION_ERROR_TYPES, - CommonProxyErrors, - ProxyErrorTypes, - ProxyException, - SpendLogsMetadata, - SpendLogsPayload, -) +from litellm.constants import (DEFAULT_MODEL_CREATED_AT_TIME, + MAX_TEAM_LIST_LIMIT) +from litellm.proxy._types import (DB_CONNECTION_ERROR_TYPES, CommonProxyErrors, + ProxyErrorTypes, ProxyException, + SpendLogsMetadata, SpendLogsPayload) from litellm.types.guardrails import GuardrailEventHooks from litellm.types.utils import CallTypes, CallTypesLiteral try: - from litellm_enterprise.enterprise_callbacks.send_emails.base_email import ( - BaseEmailLogger, - ) - from litellm_enterprise.enterprise_callbacks.send_emails.resend_email import ( - ResendEmailLogger, - ) - from litellm_enterprise.enterprise_callbacks.send_emails.sendgrid_email import ( - SendGridEmailLogger, - ) - from litellm_enterprise.enterprise_callbacks.send_emails.smtp_email import ( - SMTPEmailLogger, - ) + from litellm_enterprise.enterprise_callbacks.send_emails.base_email import \ + BaseEmailLogger + from litellm_enterprise.enterprise_callbacks.send_emails.resend_email import \ + ResendEmailLogger + from litellm_enterprise.enterprise_callbacks.send_emails.sendgrid_email import \ + SendGridEmailLogger + from litellm_enterprise.enterprise_callbacks.send_emails.smtp_email import \ + SMTPEmailLogger except ImportError: BaseEmailLogger = None # type: ignore SendGridEmailLogger = None # type: ignore @@ -66,70 +58,56 @@ from fastapi import HTTPException, status import litellm import litellm.litellm_core_utils import litellm.litellm_core_utils.litellm_logging -from litellm import ( - EmbeddingResponse, - ImageResponse, - ModelResponse, - ModelResponseStream, - Router, -) +from litellm import (EmbeddingResponse, ImageResponse, ModelResponse, + ModelResponseStream, Router) from litellm._logging import verbose_proxy_logger from litellm._service_logger import ServiceLogging, ServiceTypes from litellm.caching.caching import DualCache, RedisCache from litellm.caching.dual_cache import LimitedSizeOrderedDict from litellm.exceptions import RejectedRequestError -from litellm.integrations.custom_guardrail import ( - CustomGuardrail, - ModifyResponseException, -) +from litellm.integrations.custom_guardrail import (CustomGuardrail, + ModifyResponseException) from litellm.integrations.custom_logger import CustomLogger from litellm.integrations.SlackAlerting.slack_alerting import SlackAlerting -from litellm.integrations.SlackAlerting.utils import _add_langfuse_trace_id_to_alert +from litellm.integrations.SlackAlerting.utils import \ + _add_langfuse_trace_id_to_alert from litellm.litellm_core_utils.litellm_logging import Logging from litellm.litellm_core_utils.safe_json_dumps import safe_dumps from litellm.litellm_core_utils.safe_json_loads import safe_json_loads from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler -from litellm.proxy._types import ( - AlertType, - CallInfo, - LiteLLM_VerificationTokenView, - Member, - UserAPIKeyAuth, -) +from litellm.proxy._types import (AlertType, CallInfo, + LiteLLM_VerificationTokenView, Member, + UserAPIKeyAuth) from litellm.proxy.auth.route_checks import RouteChecks -from litellm.proxy.db.create_views import ( - create_missing_views, - should_create_missing_views, -) +from litellm.proxy.db.create_views import (create_missing_views, + should_create_missing_views) from litellm.proxy.db.db_spend_update_writer import DBSpendUpdateWriter from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler from litellm.proxy.db.log_db_metrics import log_db_metrics from litellm.proxy.db.prisma_client import PrismaWrapper -from litellm.proxy.guardrails.guardrail_hooks.unified_guardrail.unified_guardrail import ( - UnifiedLLMGuardrails, -) +from litellm.proxy.guardrails.guardrail_hooks.unified_guardrail.unified_guardrail import \ + UnifiedLLMGuardrails from litellm.proxy.hooks import PROXY_HOOKS, get_proxy_hook from litellm.proxy.hooks.cache_control_check import _PROXY_CacheControlCheck from litellm.proxy.hooks.max_budget_limiter import _PROXY_MaxBudgetLimiter -from litellm.proxy.hooks.parallel_request_limiter import ( - _PROXY_MaxParallelRequestsHandler, -) +from litellm.proxy.hooks.parallel_request_limiter import \ + _PROXY_MaxParallelRequestsHandler from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup from litellm.proxy.policy_engine.pipeline_executor import PipelineExecutor from litellm.secret_managers.main import str_to_bool from litellm.types.integrations.slack_alerting import DEFAULT_ALERT_TYPES -from litellm.types.mcp import ( - MCPDuringCallResponseObject, - MCPPreCallRequestObject, - MCPPreCallResponseObject, -) -from litellm.types.proxy.policy_engine.pipeline_types import PipelineExecutionResult +from litellm.types.mcp import (MCPDuringCallResponseObject, + MCPPreCallRequestObject, + MCPPreCallResponseObject) +from litellm.types.proxy.policy_engine.pipeline_types import \ + PipelineExecutionResult from litellm.types.utils import LLMResponseTypes, LoggedLiteLLMParams if TYPE_CHECKING: from opentelemetry.trace import Span as _Span - from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj + from litellm.litellm_core_utils.litellm_logging import \ + Logging as LiteLLMLoggingObj Span = Union[_Span, Any] else: @@ -385,6 +363,7 @@ class ProxyLogging: alert_types: Optional[List[AlertType]] = None, alerting_args: Optional[dict] = None, alert_to_webhook_url: Optional[dict] = None, + alert_type_config: Optional[dict] = None, ): updated_slack_alerting: bool = False if alerting is not None: @@ -399,6 +378,8 @@ class ProxyLogging: if alert_to_webhook_url is not None: self.alert_to_webhook_url = alert_to_webhook_url updated_slack_alerting = True + if alert_type_config is not None: + updated_slack_alerting = True if updated_slack_alerting is True: self.slack_alerting_instance.update_values( @@ -407,6 +388,7 @@ class ProxyLogging: alert_types=self.alert_types, alerting_args=alerting_args, alert_to_webhook_url=self.alert_to_webhook_url, + alert_type_config=alert_type_config, ) if self.alerting is not None and "slack" in self.alerting: @@ -1068,10 +1050,9 @@ class ProxyLogging: """Process prompt template if applicable.""" from litellm.proxy.prompts.prompt_endpoints import ( - construct_versioned_prompt_id, - get_latest_version_prompt_id, - ) - from litellm.proxy.prompts.prompt_registry import IN_MEMORY_PROMPT_REGISTRY + construct_versioned_prompt_id, get_latest_version_prompt_id) + from litellm.proxy.prompts.prompt_registry import \ + IN_MEMORY_PROMPT_REGISTRY from litellm.utils import get_non_default_completion_params if prompt_version is None: @@ -1121,9 +1102,8 @@ class ProxyLogging: def _process_guardrail_metadata(self, data: dict) -> None: """Process guardrails from metadata and add to applied_guardrails.""" - from litellm.proxy.common_utils.callback_utils import ( - add_guardrail_to_applied_guardrails_header, - ) + from litellm.proxy.common_utils.callback_utils import \ + add_guardrail_to_applied_guardrails_header metadata_standard = data.get("metadata") or {} metadata_litellm = data.get("litellm_metadata") or {} @@ -2020,7 +2000,8 @@ class ProxyLogging: if isinstance(response, (ModelResponse, ModelResponseStream)): response_str = litellm.get_response_string(response_obj=response) elif isinstance(response, dict) and self.is_a2a_streaming_response(response): - from litellm.llms.a2a.common_utils import extract_text_from_a2a_response + from litellm.llms.a2a.common_utils import \ + extract_text_from_a2a_response response_str = extract_text_from_a2a_response(response) if response_str is not None: @@ -2029,7 +2010,8 @@ class ProxyLogging: _callback: Optional[CustomLogger] = None if isinstance(callback, CustomGuardrail): # Main - V2 Guardrails implementation - from litellm.types.guardrails import GuardrailEventHooks + from litellm.types.guardrails import \ + GuardrailEventHooks ## CHECK FOR MODEL-LEVEL GUARDRAILS modified_data = _check_and_merge_model_level_guardrails( @@ -4457,20 +4439,24 @@ class ProxyUpdateSpend: prisma_client: PrismaClient, db_writer_client: Optional[AsyncHTTPHandler], proxy_logging_obj: ProxyLogging, + logs_to_process: Optional[List[Dict[str, Any]]] = None, ): BATCH_SIZE = 1000 # Preferred size of each batch to write to the database MAX_LOGS_PER_INTERVAL = ( 10000 # Maximum number of logs to flush in a single interval ) - # Atomically read and remove logs to process (protected by lock) - async with prisma_client._spend_log_transactions_lock: - logs_to_process = prisma_client.spend_log_transactions[ - :MAX_LOGS_PER_INTERVAL - ] - # Remove the logs we're about to process - prisma_client.spend_log_transactions = prisma_client.spend_log_transactions[ - len(logs_to_process) : - ] + popped_batch = False + if logs_to_process is None: + # Atomically read and remove logs to process (protected by lock) + async with prisma_client._spend_log_transactions_lock: + logs_to_process = prisma_client.spend_log_transactions[ + :MAX_LOGS_PER_INTERVAL + ] + # Remove the logs we're about to process + prisma_client.spend_log_transactions = prisma_client.spend_log_transactions[ + len(logs_to_process) : + ] + popped_batch = True start_time = time.time() try: for i in range(n_retry_times + 1): @@ -4530,8 +4516,9 @@ class ProxyUpdateSpend: e=e, start_time=start_time, proxy_logging_obj=proxy_logging_obj ) finally: - # Clean up logs_to_process after all processing is complete - del logs_to_process + # Clean up logs_to_process only if we popped it (caller-owned otherwise) + if popped_batch: + del logs_to_process @staticmethod def disable_spend_updates() -> bool: @@ -4597,24 +4584,47 @@ async def update_spend_logs_job( Job to process spend_log_transactions queue. This job is triggered based on queue size rather than time. - Processes spend log transactions when the queue reaches a threshold. + Pops the batch once, writes spend logs, then runs guardrail usage tracking. """ n_retry_times = 3 + MAX_LOGS_PER_INTERVAL = 10000 - # Check queue size with lock protection + # Atomically pop batch from queue async with prisma_client._spend_log_transactions_lock: queue_size = len(prisma_client.spend_log_transactions) - if queue_size == 0: return + async with prisma_client._spend_log_transactions_lock: + logs_to_process = prisma_client.spend_log_transactions[ + :MAX_LOGS_PER_INTERVAL + ] + prisma_client.spend_log_transactions = prisma_client.spend_log_transactions[ + len(logs_to_process) : + ] + await ProxyUpdateSpend.update_spend_logs( n_retry_times=n_retry_times, prisma_client=prisma_client, proxy_logging_obj=proxy_logging_obj, db_writer_client=db_writer_client, + logs_to_process=logs_to_process, ) + # Guardrail/policy usage tracking (same batch, outside spend-logs update) + try: + from litellm.proxy.guardrails.usage_tracking import \ + process_spend_logs_guardrail_usage + await process_spend_logs_guardrail_usage( + prisma_client=prisma_client, + logs_to_process=logs_to_process, + ) + except Exception as guardrail_tracking_err: + verbose_proxy_logger.debug( + "Guardrail usage tracking failed (non-fatal): %s", + guardrail_tracking_err, + ) + async def _monitor_spend_logs_queue( prisma_client: PrismaClient, @@ -4630,10 +4640,8 @@ async def _monitor_spend_logs_queue( db_writer_client: Optional HTTP handler for external spend logs endpoint proxy_logging_obj: Proxy logging object """ - from litellm.constants import ( - SPEND_LOG_QUEUE_POLL_INTERVAL, - SPEND_LOG_QUEUE_SIZE_THRESHOLD, - ) + from litellm.constants import (SPEND_LOG_QUEUE_POLL_INTERVAL, + SPEND_LOG_QUEUE_SIZE_THRESHOLD) threshold = SPEND_LOG_QUEUE_SIZE_THRESHOLD base_interval = SPEND_LOG_QUEUE_POLL_INTERVAL @@ -5154,12 +5162,11 @@ async def get_available_models_for_user( List of model names available to the user """ from litellm.proxy.auth.auth_checks import get_team_object - from litellm.proxy.auth.model_checks import ( - get_complete_model_list, - get_key_models, - get_team_models, - ) - from litellm.proxy.management_endpoints.team_endpoints import validate_membership + from litellm.proxy.auth.model_checks import (get_complete_model_list, + get_key_models, + get_team_models) + from litellm.proxy.management_endpoints.team_endpoints import \ + validate_membership # Get proxy model list and access groups if llm_router is None: diff --git a/litellm/types/guardrails.py b/litellm/types/guardrails.py index fea91ab6e4..c87d1076d0 100644 --- a/litellm/types/guardrails.py +++ b/litellm/types/guardrails.py @@ -58,6 +58,7 @@ class SupportedGuardrailIntegrations(Enum): MODEL_ARMOR = "model_armor" OPENAI_MODERATION = "openai_moderation" NOMA = "noma" + NOMA_V2 = "noma_v2" TOOL_PERMISSION = "tool_permission" ZSCALER_AI_GUARD = "zscaler_ai_guard" JAVELIN = "javelin" @@ -436,6 +437,10 @@ class PillarGuardrailConfigModel(BaseModel): class NomaGuardrailConfigModel(BaseModel): """Configuration parameters for the Noma Security guardrail""" + use_v2: Optional[bool] = Field( + default=False, + description="If True and guardrail='noma', route to the new Noma v2 implementation instead of the legacy implementation.", + ) application_id: Optional[str] = Field( default=None, description="Application ID for Noma Security. Defaults to 'litellm' if not provided", diff --git a/litellm/types/integrations/slack_alerting.py b/litellm/types/integrations/slack_alerting.py index 856640638c..078e7953ad 100644 --- a/litellm/types/integrations/slack_alerting.py +++ b/litellm/types/integrations/slack_alerting.py @@ -1,13 +1,15 @@ import os from datetime import datetime as dt from enum import Enum -from typing import Any, Dict, List, Literal, Optional, Set +from typing import Any, Dict, List, Literal, Optional, Set, Union from pydantic import BaseModel, Field from typing_extensions import TypedDict from litellm.types.utils import LiteLLMPydanticObjectBase +DEFAULT_DIGEST_INTERVAL = 86400 # 24 hours in seconds + SLACK_ALERTING_THRESHOLD_5_PERCENT = 0.05 SLACK_ALERTING_THRESHOLD_15_PERCENT = 0.15 MAX_OLDEST_HANGING_REQUESTS_TO_CHECK = 20 @@ -199,3 +201,30 @@ class HangingRequestData(BaseModel): key_alias: Optional[str] = None team_alias: Optional[str] = None alerting_metadata: Optional[dict] = None + + +class AlertTypeConfig(LiteLLMPydanticObjectBase): + """Per-alert-type configuration, including digest mode settings.""" + + digest: bool = Field( + default=False, + description="Enable digest mode for this alert type. When enabled, duplicate alerts are aggregated into a single summary message.", + ) + digest_interval: int = Field( + default=DEFAULT_DIGEST_INTERVAL, + description="Digest window in seconds. Alerts are aggregated within this interval. Default 24 hours.", + ) + + +class DigestEntry(TypedDict): + """Tracks an in-flight digest bucket for a unique (alert_type, model, api_base) combination.""" + + alert_type: str + request_model: str + api_base: str + first_message: str + level: str + count: int + start_time: dt + last_time: dt + webhook_url: Union[str, List[str]] diff --git a/litellm/types/interactions/generated.py b/litellm/types/interactions/generated.py index 72693e8f18..30e4ff4722 100644 --- a/litellm/types/interactions/generated.py +++ b/litellm/types/interactions/generated.py @@ -392,6 +392,7 @@ class Status3(Enum): COMPLETED = 'COMPLETED' FAILED = 'FAILED' CANCELLED = 'CANCELLED' + INCOMPLETE = 'INCOMPLETE' class ModelOption(RootModel[str]): diff --git a/litellm/types/proxy/guardrails/guardrail_hooks/noma.py b/litellm/types/proxy/guardrails/guardrail_hooks/noma.py index 2d6d4a4512..c6fd587abe 100644 --- a/litellm/types/proxy/guardrails/guardrail_hooks/noma.py +++ b/litellm/types/proxy/guardrails/guardrail_hooks/noma.py @@ -1,10 +1,15 @@ from typing import Optional -from pydantic import BaseModel, Field +from pydantic import Field from .base import GuardrailConfigModel + class NomaGuardrailConfigModel(GuardrailConfigModel): + use_v2: Optional[bool] = Field( + default=False, + description="If True and guardrail='noma', route to the new Noma v2 implementation.", + ) api_key: Optional[str] = Field( default=None, description="The Noma API key. Reads from NOMA_API_KEY env var if None.", @@ -21,3 +26,30 @@ class NomaGuardrailConfigModel(GuardrailConfigModel): @staticmethod def ui_friendly_name() -> str: return "Noma Security" + + +class NomaV2GuardrailConfigModel(GuardrailConfigModel): + api_key: Optional[str] = Field( + default=None, + description="The Noma API key. Reads from NOMA_API_KEY env var if None.", + ) + api_base: Optional[str] = Field( + default=None, + description="The Noma API base URL. Defaults to https://api.noma.security.", + ) + application_id: Optional[str] = Field( + default=None, + description="The Noma Application ID. Reads from NOMA_APPLICATION_ID env var if None.", + ) + monitor_mode: Optional[bool] = Field( + default=None, + description="When true, run guardrail checks in monitor mode.", + ) + block_failures: Optional[bool] = Field( + default=None, + description="When true, fail closed on Noma API errors.", + ) + + @staticmethod + def ui_friendly_name() -> str: + return "Noma Security v2" diff --git a/litellm/types/proxy/management_endpoints/model_management_endpoints.py b/litellm/types/proxy/management_endpoints/model_management_endpoints.py index c488c46ecc..6f07e5c6de 100644 --- a/litellm/types/proxy/management_endpoints/model_management_endpoints.py +++ b/litellm/types/proxy/management_endpoints/model_management_endpoints.py @@ -21,17 +21,20 @@ class UpdateUsefulLinksRequest(BaseModel): class NewModelGroupRequest(BaseModel): access_group: str # The access group name (e.g., "production-models") - model_names: List[str] # Existing model groups to include (e.g., ["gpt-4", "claude-3"]) + model_names: Optional[List[str]] = None # Existing model groups to include - tags ALL deployments for each name + model_ids: Optional[List[str]] = None # Specific deployment IDs to tag (more precise than model_names) class NewModelGroupResponse(BaseModel): access_group: str - model_names: List[str] + model_names: Optional[List[str]] = None + model_ids: Optional[List[str]] = None models_updated: int # Number of models updated class UpdateModelGroupRequest(BaseModel): - model_names: List[str] # Updated list of model groups to include + model_names: Optional[List[str]] = None # Updated list of model groups to include - tags ALL deployments for each name + model_ids: Optional[List[str]] = None # Specific deployment IDs to tag (more precise than model_names) class DeleteModelGroupResponse(BaseModel): diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 7760a894c7..94c387dd22 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -1,47 +1,61 @@ import json import time from enum import Enum -from typing import (TYPE_CHECKING, Any, Dict, List, Literal, Mapping, Optional, - Union) +from typing import TYPE_CHECKING, Any, Dict, List, Literal, Mapping, Optional, Union from openai._models import BaseModel as OpenAIObject -from openai.types.audio.transcription_create_params import \ - FileTypes as FileTypes # type: ignore +from openai.types.audio.transcription_create_params import ( + FileTypes as FileTypes, # type: ignore +) from openai.types.chat.chat_completion import ChatCompletion as ChatCompletion -from openai.types.completion_usage import (CompletionTokensDetails, - CompletionUsage, - PromptTokensDetails) +from openai.types.completion_usage import ( + CompletionTokensDetails, + CompletionUsage, + PromptTokensDetails, +) from openai.types.moderation import Categories as Categories -from openai.types.moderation import \ - CategoryAppliedInputTypes as CategoryAppliedInputTypes +from openai.types.moderation import ( + CategoryAppliedInputTypes as CategoryAppliedInputTypes, +) from openai.types.moderation import CategoryScores as CategoryScores from openai.types.moderation_create_response import Moderation as Moderation -from openai.types.moderation_create_response import \ - ModerationCreateResponse as ModerationCreateResponse +from openai.types.moderation_create_response import ( + ModerationCreateResponse as ModerationCreateResponse, +) from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, model_validator from typing_extensions import Required, TypedDict from litellm._uuid import uuid -from litellm.types.llms.base import (BaseLiteLLMOpenAIResponseObject, - LiteLLMPydanticObjectBase) +from litellm.types.llms.base import ( + BaseLiteLLMOpenAIResponseObject, + LiteLLMPydanticObjectBase, +) from litellm.types.mcp import MCPServerCostInfo from ..litellm_core_utils.core_helpers import map_finish_reason from .agents import LiteLLMSendMessageResponse from .guardrails import GuardrailEventHooks -from .llms.anthropic_messages.anthropic_response import \ - AnthropicMessagesResponse +from .llms.anthropic_messages.anthropic_response import AnthropicMessagesResponse from .llms.base import HiddenParams -from .llms.openai import (AllMessageValues, Batch, ChatCompletionAnnotation, - ChatCompletionRedactedThinkingBlock, - ChatCompletionThinkingBlock, - ChatCompletionToolCallChunk, ChatCompletionToolParam, - ChatCompletionUsageBlock, FileSearchTool, - FineTuningJob, ImageURLListItem, - OpenAIChatCompletionChunk, - OpenAIChatCompletionFinishReason, OpenAIFileObject, - OpenAIRealtimeStreamList, ResponsesAPIResponse, - WebSearchOptions) +from .llms.openai import ( + AllMessageValues, + Batch, + ChatCompletionAnnotation, + ChatCompletionRedactedThinkingBlock, + ChatCompletionThinkingBlock, + ChatCompletionToolCallChunk, + ChatCompletionToolParam, + ChatCompletionUsageBlock, + FileSearchTool, + FineTuningJob, + ImageURLListItem, + OpenAIChatCompletionChunk, + OpenAIChatCompletionFinishReason, + OpenAIFileObject, + OpenAIRealtimeStreamList, + ResponsesAPIResponse, + WebSearchOptions, +) from .rerank import RerankResponse as RerankResponse if TYPE_CHECKING: @@ -212,6 +226,7 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False): ] tpm: Optional[int] rpm: Optional[int] + provider_specific_entry: Optional[Dict[str, float]] class ModelInfo(ModelInfoBase, total=False): diff --git a/litellm/utils.py b/litellm/utils.py index 4771219d11..b4de1e00ef 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -5385,14 +5385,18 @@ def _get_max_position_embeddings(model_name: str) -> Optional[int]: @lru_cache(maxsize=DEFAULT_MAX_LRU_CACHE_SIZE) def _cached_get_model_info_helper( - model: str, custom_llm_provider: Optional[str] + model: str, + custom_llm_provider: Optional[str], + api_base: Optional[str] = None, ) -> ModelInfoBase: """ _get_model_info_helper wrapped with lru_cache Speed Optimization to hit high RPS """ - return _get_model_info_helper(model=model, custom_llm_provider=custom_llm_provider) + return _get_model_info_helper( + model=model, custom_llm_provider=custom_llm_provider, api_base=api_base + ) def get_provider_info( @@ -5428,7 +5432,9 @@ def _is_potential_model_name_in_model_cost( def _get_model_info_helper( # noqa: PLR0915 - model: str, custom_llm_provider: Optional[str] = None + model: str, + custom_llm_provider: Optional[str] = None, + api_base: Optional[str] = None, ) -> ModelInfoBase: """ Helper for 'get_model_info'. Separated out to avoid infinite loop caused by returning 'supported_openai_param's @@ -5486,7 +5492,7 @@ def _get_model_info_helper( # noqa: PLR0915 elif ( custom_llm_provider == "ollama" or custom_llm_provider == "ollama_chat" ) and not _is_potential_model_name_in_model_cost(potential_model_names): - return litellm.OllamaConfig().get_model_info(model) + return litellm.OllamaConfig().get_model_info(model, api_base=api_base) else: """ Check if: (in order of specificity) @@ -5719,11 +5725,12 @@ def _get_model_info_helper( # noqa: PLR0915 annotation_cost_per_page=_model_info.get( "annotation_cost_per_page", None ), + provider_specific_entry=_model_info.get( + "provider_specific_entry", None + ), ) except Exception as e: verbose_logger.debug(f"Error getting model info: {e}") - if "OllamaError" in str(e): - raise e raise Exception( "This model isn't mapped yet. model={}, custom_llm_provider={}. Add it here - https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json.".format( model, custom_llm_provider @@ -5732,7 +5739,11 @@ def _get_model_info_helper( # noqa: PLR0915 @lru_cache(maxsize=DEFAULT_MAX_LRU_CACHE_SIZE) -def get_model_info(model: str, custom_llm_provider: Optional[str] = None) -> ModelInfo: +def get_model_info( + model: str, + custom_llm_provider: Optional[str] = None, + api_base: Optional[str] = None, +) -> ModelInfo: """ Get a dict for the maximum tokens (context window), input_cost_per_token, output_cost_per_token for a given model. @@ -5810,6 +5821,7 @@ def get_model_info(model: str, custom_llm_provider: Optional[str] = None) -> Mod _model_info = _get_model_info_helper( model=model, custom_llm_provider=custom_llm_provider, + api_base=api_base, ) provider_info = get_provider_info( @@ -7666,6 +7678,23 @@ def validate_and_fix_openai_tools(tools: Optional[List]) -> Optional[List[dict]] return new_tools +def validate_and_fix_thinking_param( + thinking: Optional["AnthropicThinkingParam"], +) -> Optional["AnthropicThinkingParam"]: + """ + Normalizes camelCase keys in the thinking param to snake_case. + Handles clients that send budgetTokens instead of budget_tokens. + """ + if thinking is None or not isinstance(thinking, dict): + return thinking + normalized = dict(thinking) + if "budgetTokens" in normalized and "budget_tokens" not in normalized: + normalized["budget_tokens"] = normalized.pop("budgetTokens") + elif "budgetTokens" in normalized and "budget_tokens" in normalized: + normalized.pop("budgetTokens") + return cast("AnthropicThinkingParam", normalized) + + def cleanup_none_field_in_message(message: AllMessageValues): """ Cleans up the message by removing the none field. diff --git a/litellm/videos/main.py b/litellm/videos/main.py index db09ab04f1..2225b9eec7 100644 --- a/litellm/videos/main.py +++ b/litellm/videos/main.py @@ -273,6 +273,7 @@ def video_content( video_id: str, timeout: Optional[float] = None, custom_llm_provider: Optional[str] = None, + variant: Optional[str] = None, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Optional[Dict[str, Any]] = None, @@ -367,6 +368,7 @@ def video_content( extra_headers=extra_headers, client=kwargs.get("client"), _is_async=_is_async, + variant=variant, ) except Exception as e: @@ -385,6 +387,7 @@ async def avideo_content( video_id: str, timeout: Optional[float] = None, custom_llm_provider: Optional[str] = None, + variant: Optional[str] = None, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Optional[Dict[str, Any]] = None, @@ -422,6 +425,7 @@ async def avideo_content( video_id=video_id, timeout=timeout, custom_llm_provider=custom_llm_provider, + variant=variant, extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index a8de7f2056..39f3c7ce1c 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -8295,37 +8295,6 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346 }, - "us/claude-sonnet-4-6": { - "cache_creation_input_token_cost": 4.125e-06, - "cache_creation_input_token_cost_above_200k_tokens": 8.25e-06, - "cache_read_input_token_cost": 3.3e-07, - "cache_read_input_token_cost_above_200k_tokens": 6.6e-07, - "input_cost_per_token": 3.3e-06, - "input_cost_per_token_above_200k_tokens": 6.6e-06, - "litellm_provider": "anthropic", - "max_input_tokens": 200000, - "max_output_tokens": 64000, - "max_tokens": 64000, - "mode": "chat", - "output_cost_per_token": 1.65e-05, - "output_cost_per_token_above_200k_tokens": 2.475e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supports_assistant_prefill": true, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 346, - "inference_geo": "us" - }, "claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, @@ -8517,100 +8486,11 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 - }, - "fast/claude-opus-4-6": { - "cache_creation_input_token_cost": 6.25e-06, - "cache_creation_input_token_cost_above_200k_tokens": 1.25e-05, - "cache_creation_input_token_cost_above_1hr": 1e-05, - "cache_read_input_token_cost": 5e-07, - "cache_read_input_token_cost_above_200k_tokens": 1e-06, - "input_cost_per_token": 3e-05, - "input_cost_per_token_above_200k_tokens": 1e-05, - "litellm_provider": "anthropic", - "max_input_tokens": 1000000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 0.00015, - "output_cost_per_token_above_200k_tokens": 3.75e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supports_assistant_prefill": false, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 346 - }, - "us/claude-opus-4-6": { - "cache_creation_input_token_cost": 6.875e-06, - "cache_creation_input_token_cost_above_200k_tokens": 1.375e-05, - "cache_creation_input_token_cost_above_1hr": 1.1e-05, - "cache_read_input_token_cost": 5.5e-07, - "cache_read_input_token_cost_above_200k_tokens": 1.1e-06, - "input_cost_per_token": 5.5e-06, - "input_cost_per_token_above_200k_tokens": 1.1e-05, - "litellm_provider": "anthropic", - "max_input_tokens": 200000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 2.75e-05, - "output_cost_per_token_above_200k_tokens": 4.125e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supports_assistant_prefill": false, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 346 - }, - "fast/us/claude-opus-4-6": { - "cache_creation_input_token_cost": 6.875e-06, - "cache_creation_input_token_cost_above_200k_tokens": 1.375e-05, - "cache_creation_input_token_cost_above_1hr": 1.1e-05, - "cache_read_input_token_cost": 5.5e-07, - "cache_read_input_token_cost_above_200k_tokens": 1.1e-06, - "input_cost_per_token": 3e-05, - "input_cost_per_token_above_200k_tokens": 1.1e-05, - "litellm_provider": "anthropic", - "max_input_tokens": 200000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 0.00015, - "output_cost_per_token_above_200k_tokens": 4.125e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supports_assistant_prefill": false, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "provider_specific_entry": { + "us": 1.1, + "fast": 6.0 + } }, "claude-opus-4-6-20260205": { "cache_creation_input_token_cost": 6.25e-06, @@ -8641,69 +8521,11 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346 - }, - "fast/claude-opus-4-6-20260205": { - "cache_creation_input_token_cost": 6.25e-06, - "cache_creation_input_token_cost_above_200k_tokens": 1.25e-05, - "cache_creation_input_token_cost_above_1hr": 1e-05, - "cache_read_input_token_cost": 5e-07, - "cache_read_input_token_cost_above_200k_tokens": 1e-06, - "input_cost_per_token": 3e-05, - "input_cost_per_token_above_200k_tokens": 1e-05, - "litellm_provider": "anthropic", - "max_input_tokens": 1000000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 0.00015, - "output_cost_per_token_above_200k_tokens": 3.75e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supports_assistant_prefill": false, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 346 - }, - "us/claude-opus-4-6-20260205": { - "cache_creation_input_token_cost": 6.875e-06, - "cache_creation_input_token_cost_above_200k_tokens": 1.375e-05, - "cache_creation_input_token_cost_above_1hr": 1.1e-05, - "cache_read_input_token_cost": 5.5e-07, - "cache_read_input_token_cost_above_200k_tokens": 1.1e-06, - "input_cost_per_token": 5.5e-06, - "input_cost_per_token_above_200k_tokens": 1.1e-05, - "litellm_provider": "anthropic", - "max_input_tokens": 200000, - "max_output_tokens": 128000, - "max_tokens": 128000, - "mode": "chat", - "output_cost_per_token": 2.75e-05, - "output_cost_per_token_above_200k_tokens": 4.125e-05, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supports_assistant_prefill": false, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "tool_use_system_prompt_tokens": 346, + "provider_specific_entry": { + "us": 1.1, + "fast": 6.0 + } }, "claude-sonnet-4-20250514": { "deprecation_date": "2026-05-14", @@ -14768,7 +14590,14 @@ "supports_video_input": true, "supports_vision": true, "supports_web_search": true, - "supports_native_streaming": true + "supports_native_streaming": true, + "input_cost_per_token_priority": 3.6e-06, + "input_cost_per_token_above_200k_tokens_priority": 7.2e-06, + "output_cost_per_token_priority": 2.16e-05, + "output_cost_per_token_above_200k_tokens_priority": 3.24e-05, + "cache_read_input_token_cost_priority": 3.6e-07, + "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, + "supports_service_tier": true }, "gemini-3.1-pro-preview": { "cache_read_input_token_cost": 2e-07, @@ -14819,7 +14648,14 @@ "supports_vision": true, "supports_web_search": true, "supports_url_context": true, - "supports_native_streaming": true + "supports_native_streaming": true, + "input_cost_per_token_priority": 3.6e-06, + "input_cost_per_token_above_200k_tokens_priority": 7.2e-06, + "output_cost_per_token_priority": 2.16e-05, + "output_cost_per_token_above_200k_tokens_priority": 3.24e-05, + "cache_read_input_token_cost_priority": 3.6e-07, + "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, + "supports_service_tier": true }, "gemini-3.1-pro-preview-customtools": { "cache_read_input_token_cost": 2e-07, @@ -14919,7 +14755,14 @@ "supports_video_input": true, "supports_vision": true, "supports_web_search": true, - "supports_native_streaming": true + "supports_native_streaming": true, + "input_cost_per_token_priority": 3.6e-06, + "input_cost_per_token_above_200k_tokens_priority": 7.2e-06, + "output_cost_per_token_priority": 2.16e-05, + "output_cost_per_token_above_200k_tokens_priority": 3.24e-05, + "cache_read_input_token_cost_priority": 3.6e-07, + "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, + "supports_service_tier": true }, "vertex_ai/gemini-3-flash-preview": { "cache_read_input_token_cost": 5e-08, @@ -14963,7 +14806,12 @@ "supports_video_input": true, "supports_vision": true, "supports_web_search": true, - "supports_native_streaming": true + "supports_native_streaming": true, + "input_cost_per_token_priority": 9e-07, + "input_cost_per_audio_token_priority": 1.8e-06, + "output_cost_per_token_priority": 5.4e-06, + "cache_read_input_token_cost_priority": 9e-08, + "supports_service_tier": true }, "vertex_ai/gemini-3.1-pro-preview": { "cache_read_input_token_cost": 2e-07, @@ -15014,7 +14862,14 @@ "supports_vision": true, "supports_web_search": true, "supports_url_context": true, - "supports_native_streaming": true + "supports_native_streaming": true, + "input_cost_per_token_priority": 3.6e-06, + "input_cost_per_token_above_200k_tokens_priority": 7.2e-06, + "output_cost_per_token_priority": 2.16e-05, + "output_cost_per_token_above_200k_tokens_priority": 3.24e-05, + "cache_read_input_token_cost_priority": 3.6e-07, + "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, + "supports_service_tier": true }, "vertex_ai/gemini-3.1-pro-preview-customtools": { "cache_read_input_token_cost": 2e-07, @@ -15065,7 +14920,14 @@ "supports_vision": true, "supports_web_search": true, "supports_url_context": true, - "supports_native_streaming": true + "supports_native_streaming": true, + "input_cost_per_token_priority": 3.6e-06, + "input_cost_per_token_above_200k_tokens_priority": 7.2e-06, + "output_cost_per_token_priority": 2.16e-05, + "output_cost_per_token_above_200k_tokens_priority": 3.24e-05, + "cache_read_input_token_cost_priority": 3.6e-07, + "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, + "supports_service_tier": true }, "gemini-2.5-pro-exp-03-25": { "cache_read_input_token_cost": 1.25e-07, @@ -16860,6 +16722,8 @@ "cache_read_input_token_cost_above_200k_tokens": 2.5e-07, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, + "input_cost_per_token_priority": 1.25e-06, + "input_cost_per_token_above_200k_tokens_priority": 2.5e-06, "litellm_provider": "gemini", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, @@ -16873,8 +16737,11 @@ "mode": "chat", "output_cost_per_token": 1e-05, "output_cost_per_token_above_200k_tokens": 1.5e-05, + "output_cost_per_token_priority": 1e-05, + "output_cost_per_token_above_200k_tokens_priority": 1.5e-05, "rpm": 2000, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "supports_service_tier": true, "supported_endpoints": [ "/v1/chat/completions", "/v1/completions" @@ -16979,7 +16846,14 @@ "supports_video_input": true, "supports_vision": true, "supports_web_search": true, - "tpm": 800000 + "tpm": 800000, + "input_cost_per_token_priority": 3.6e-06, + "input_cost_per_token_above_200k_tokens_priority": 7.2e-06, + "output_cost_per_token_priority": 2.16e-05, + "output_cost_per_token_above_200k_tokens_priority": 3.24e-05, + "cache_read_input_token_cost_priority": 3.6e-07, + "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, + "supports_service_tier": true }, "gemini/gemini-3-flash-preview": { "cache_read_input_token_cost": 5e-08, @@ -17027,7 +16901,12 @@ "supports_vision": true, "supports_web_search": true, "supports_native_streaming": true, - "tpm": 800000 + "tpm": 800000, + "input_cost_per_token_priority": 9e-07, + "input_cost_per_audio_token_priority": 1.8e-06, + "output_cost_per_token_priority": 5.4e-06, + "cache_read_input_token_cost_priority": 9e-08, + "supports_service_tier": true }, "gemini/gemini-3.1-pro-preview": { "cache_read_input_token_cost": 2e-07, @@ -17078,7 +16957,14 @@ "supports_web_search": true, "supports_url_context": true, "supports_native_streaming": true, - "tpm": 800000 + "tpm": 800000, + "input_cost_per_token_priority": 3.6e-06, + "input_cost_per_token_above_200k_tokens_priority": 7.2e-06, + "output_cost_per_token_priority": 2.16e-05, + "output_cost_per_token_above_200k_tokens_priority": 3.24e-05, + "cache_read_input_token_cost_priority": 3.6e-07, + "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, + "supports_service_tier": true }, "gemini/gemini-3.1-pro-preview-customtools": { "cache_read_input_token_cost": 2e-07, @@ -17129,7 +17015,14 @@ "supports_web_search": true, "supports_url_context": true, "supports_native_streaming": true, - "tpm": 800000 + "tpm": 800000, + "input_cost_per_token_priority": 3.6e-06, + "input_cost_per_token_above_200k_tokens_priority": 7.2e-06, + "output_cost_per_token_priority": 2.16e-05, + "output_cost_per_token_above_200k_tokens_priority": 3.24e-05, + "cache_read_input_token_cost_priority": 3.6e-07, + "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-07, + "supports_service_tier": true }, "gemini-3-flash-preview": { "cache_read_input_token_cost": 5e-08, @@ -17175,7 +17068,12 @@ "supports_url_context": true, "supports_vision": true, "supports_web_search": true, - "supports_native_streaming": true + "supports_native_streaming": true, + "input_cost_per_token_priority": 9e-07, + "input_cost_per_audio_token_priority": 1.8e-06, + "output_cost_per_token_priority": 5.4e-06, + "cache_read_input_token_cost_priority": 9e-08, + "supports_service_tier": true }, "gemini/gemini-2.5-pro-exp-03-25": { "cache_read_input_token_cost": 0.0, @@ -37808,4 +37706,4 @@ "notes": "DuckDuckGo Instant Answer API is free and does not require an API key." } } -} +} \ No newline at end of file diff --git a/policy_templates.json b/policy_templates.json index 7c409d5c4f..6125650cb3 100644 --- a/policy_templates.json +++ b/policy_templates.json @@ -2013,5 +2013,367 @@ "Brand Protection" ], "estimated_latency_ms": 1 + }, + { + "id": "pdpa-singapore", + "title": "Singapore PDPA \u2014 Personal Data Protection", + "description": "Singapore Personal Data Protection Act (PDPA) compliance. Covers 5 obligation areas: personal identifier collection (s.13 Consent), sensitive data profiling (Advisory Guidelines), Do Not Call Registry violations (Part IX), overseas data transfers (s.26), and automated profiling without human oversight (Model AI Governance Framework). Also includes regex-based PII detection for NRIC/FIN, Singapore phone numbers, postal codes, passports, UEN, and bank account numbers. Zero-cost keyword-based detection.", + "icon": "ShieldCheckIcon", + "iconColor": "text-red-500", + "iconBg": "bg-red-50", + "guardrails": [ + "pdpa-sg-pii-identifiers", + "pdpa-sg-contact-information", + "pdpa-sg-financial-data", + "pdpa-sg-business-identifiers", + "pdpa-sg-personal-identifiers", + "pdpa-sg-sensitive-data", + "pdpa-sg-do-not-call", + "pdpa-sg-data-transfer", + "pdpa-sg-profiling-automated-decisions" + ], + "complexity": "High", + "guardrailDefinitions": [ + { + "guardrail_name": "pdpa-sg-pii-identifiers", + "litellm_params": { + "guardrail": "litellm_content_filter", + "mode": "pre_call", + "patterns": [ + { + "pattern_type": "prebuilt", + "pattern_name": "sg_nric", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "passport_singapore", + "action": "MASK" + } + ], + "pattern_redaction_format": "[{pattern_name}_REDACTED]" + }, + "guardrail_info": { + "description": "Masks Singapore NRIC/FIN and passport numbers for PDPA compliance" + } + }, + { + "guardrail_name": "pdpa-sg-contact-information", + "litellm_params": { + "guardrail": "litellm_content_filter", + "mode": "pre_call", + "patterns": [ + { + "pattern_type": "prebuilt", + "pattern_name": "sg_phone", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "sg_postal_code", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "email", + "action": "MASK" + } + ], + "pattern_redaction_format": "[{pattern_name}_REDACTED]" + }, + "guardrail_info": { + "description": "Masks Singapore phone numbers, postal codes, and email addresses" + } + }, + { + "guardrail_name": "pdpa-sg-financial-data", + "litellm_params": { + "guardrail": "litellm_content_filter", + "mode": "pre_call", + "patterns": [ + { + "pattern_type": "prebuilt", + "pattern_name": "sg_bank_account", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "credit_card", + "action": "MASK" + } + ], + "pattern_redaction_format": "[{pattern_name}_REDACTED]" + }, + "guardrail_info": { + "description": "Masks Singapore bank account numbers and credit card numbers" + } + }, + { + "guardrail_name": "pdpa-sg-business-identifiers", + "litellm_params": { + "guardrail": "litellm_content_filter", + "mode": "pre_call", + "patterns": [ + { + "pattern_type": "prebuilt", + "pattern_name": "sg_uen", + "action": "MASK" + } + ], + "pattern_redaction_format": "[UEN_REDACTED]" + }, + "guardrail_info": { + "description": "Masks Singapore Unique Entity Numbers (business registration)" + } + }, + { + "guardrail_name": "pdpa-sg-personal-identifiers", + "litellm_params": { + "guardrail": "litellm_content_filter", + "mode": "pre_call", + "categories": [ + { + "category": "sg_pdpa_personal_identifiers", + "category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_personal_identifiers.yaml", + "enabled": true, + "action": "BLOCK", + "severity_threshold": "medium" + } + ] + }, + "guardrail_info": { + "description": "PDPA s.13 \u2014 Blocks unauthorized collection, harvesting, or extraction of Singapore personal identifiers (NRIC/FIN, SingPass, passports)" + } + }, + { + "guardrail_name": "pdpa-sg-sensitive-data", + "litellm_params": { + "guardrail": "litellm_content_filter", + "mode": "pre_call", + "categories": [ + { + "category": "sg_pdpa_sensitive_data", + "category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_sensitive_data.yaml", + "enabled": true, + "action": "BLOCK", + "severity_threshold": "medium" + } + ] + }, + "guardrail_info": { + "description": "PDPA Advisory Guidelines \u2014 Blocks profiling or inference of sensitive personal data categories (race, religion, health, politics) for Singapore residents" + } + }, + { + "guardrail_name": "pdpa-sg-do-not-call", + "litellm_params": { + "guardrail": "litellm_content_filter", + "mode": "pre_call", + "categories": [ + { + "category": "sg_pdpa_do_not_call", + "category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_do_not_call.yaml", + "enabled": true, + "action": "BLOCK", + "severity_threshold": "medium" + } + ] + }, + "guardrail_info": { + "description": "PDPA Part IX \u2014 Blocks generation of unsolicited marketing lists and DNC Registry bypass attempts for Singapore phone numbers" + } + }, + { + "guardrail_name": "pdpa-sg-data-transfer", + "litellm_params": { + "guardrail": "litellm_content_filter", + "mode": "pre_call", + "categories": [ + { + "category": "sg_pdpa_data_transfer", + "category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_data_transfer.yaml", + "enabled": true, + "action": "BLOCK", + "severity_threshold": "medium" + } + ] + }, + "guardrail_info": { + "description": "PDPA s.26 \u2014 Blocks unprotected overseas transfer of Singapore personal data without adequate safeguards" + } + }, + { + "guardrail_name": "pdpa-sg-profiling-automated-decisions", + "litellm_params": { + "guardrail": "litellm_content_filter", + "mode": "pre_call", + "categories": [ + { + "category": "sg_pdpa_profiling_automated_decisions", + "category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_pdpa_profiling_automated_decisions.yaml", + "enabled": true, + "action": "BLOCK", + "severity_threshold": "medium" + } + ] + }, + "guardrail_info": { + "description": "PDPA + Model AI Governance Framework \u2014 Blocks automated profiling and decision-making about Singapore residents without human oversight" + } + } + ], + "templateData": { + "policy_name": "pdpa-singapore", + "description": "Singapore PDPA compliance policy. Covers personal identifier protection (s.13), sensitive data profiling (Advisory Guidelines), Do Not Call Registry (Part IX), overseas data transfers (s.26), and automated profiling (Model AI Governance Framework). Includes regex-based PII detection for NRIC/FIN, phone numbers, postal codes, passports, UEN, and bank accounts.", + "guardrails_add": [ + "pdpa-sg-pii-identifiers", + "pdpa-sg-contact-information", + "pdpa-sg-financial-data", + "pdpa-sg-business-identifiers", + "pdpa-sg-personal-identifiers", + "pdpa-sg-sensitive-data", + "pdpa-sg-do-not-call", + "pdpa-sg-data-transfer", + "pdpa-sg-profiling-automated-decisions" + ], + "guardrails_remove": [] + }, + "tags": [ + "PII Protection", + "Regulatory", + "Singapore" + ], + "estimated_latency_ms": 1 + }, + { + "id": "mas-ai-risk-management", + "title": "Singapore MAS \u2014 AI Risk Management for Financial Institutions", + "description": "Monetary Authority of Singapore (MAS) AI Risk Management for Financial Institutions alignment. Covers 5 enforceable obligation areas: fairness & bias in financial decisions, transparency & explainability of AI models, human oversight for consequential actions, data governance for financial customer data, and model security against adversarial attacks. Based on Guidelines on Artificial Intelligence Risk Management (MAS), and aligned with the 2018 FEAT Principles and Project MindForge. Zero-cost keyword-based detection.", + "icon": "ShieldCheckIcon", + "iconColor": "text-blue-600", + "iconBg": "bg-blue-50", + "guardrails": [ + "mas-sg-fairness-bias", + "mas-sg-transparency-explainability", + "mas-sg-human-oversight", + "mas-sg-data-governance", + "mas-sg-model-security" + ], + "complexity": "High", + "guardrailDefinitions": [ + { + "guardrail_name": "mas-sg-fairness-bias", + "litellm_params": { + "guardrail": "litellm_content_filter", + "mode": "pre_call", + "categories": [ + { + "category": "sg_mas_fairness_bias", + "category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_fairness_bias.yaml", + "enabled": true, + "action": "BLOCK", + "severity_threshold": "medium" + } + ] + }, + "guardrail_info": { + "description": "Guidelines on Artificial Intelligence Risk Management (MAS) — Blocks discriminatory AI practices in financial services that score, deny, or price based on protected attributes (race, religion, age, gender, nationality)" + } + }, + { + "guardrail_name": "mas-sg-transparency-explainability", + "litellm_params": { + "guardrail": "litellm_content_filter", + "mode": "pre_call", + "categories": [ + { + "category": "sg_mas_transparency_explainability", + "category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_transparency_explainability.yaml", + "enabled": true, + "action": "BLOCK", + "severity_threshold": "medium" + } + ] + }, + "guardrail_info": { + "description": "Guidelines on Artificial Intelligence Risk Management (MAS) — Blocks deployment of opaque or unexplainable AI systems for consequential financial decisions" + } + }, + { + "guardrail_name": "mas-sg-human-oversight", + "litellm_params": { + "guardrail": "litellm_content_filter", + "mode": "pre_call", + "categories": [ + { + "category": "sg_mas_human_oversight", + "category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_human_oversight.yaml", + "enabled": true, + "action": "BLOCK", + "severity_threshold": "medium" + } + ] + }, + "guardrail_info": { + "description": "Guidelines on Artificial Intelligence Risk Management (MAS) — Blocks fully automated financial AI decisions without human-in-the-loop for consequential actions (loans, claims, trading)" + } + }, + { + "guardrail_name": "mas-sg-data-governance", + "litellm_params": { + "guardrail": "litellm_content_filter", + "mode": "pre_call", + "categories": [ + { + "category": "sg_mas_data_governance", + "category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_data_governance.yaml", + "enabled": true, + "action": "BLOCK", + "severity_threshold": "medium" + } + ] + }, + "guardrail_info": { + "description": "Guidelines on Artificial Intelligence Risk Management (MAS) — Blocks unauthorized sharing, exposure, or mishandling of financial customer data without proper governance and data lineage" + } + }, + { + "guardrail_name": "mas-sg-model-security", + "litellm_params": { + "guardrail": "litellm_content_filter", + "mode": "pre_call", + "categories": [ + { + "category": "sg_mas_model_security", + "category_file": "litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/policy_templates/sg_mas_model_security.yaml", + "enabled": true, + "action": "BLOCK", + "severity_threshold": "medium" + } + ] + }, + "guardrail_info": { + "description": "Guidelines on Artificial Intelligence Risk Management (MAS) — Blocks adversarial attacks, model poisoning, inversion, and exfiltration attempts targeting financial AI systems" + } + } + ], + "templateData": { + "policy_name": "mas-ai-risk-management", + "description": "Guidelines on Artificial Intelligence Risk Management (MAS) for Financial Institutions alignment. Covers fairness & bias, transparency & explainability, human oversight, data governance, and model security. Aligned with the 2018 FEAT Principles, Project MindForge, and NIST AI RMF.", + "guardrails_add": [ + "mas-sg-fairness-bias", + "mas-sg-transparency-explainability", + "mas-sg-human-oversight", + "mas-sg-data-governance", + "mas-sg-model-security" + ], + "guardrails_remove": [] + }, + "tags": [ + "Financial Services", + "Regulatory", + "Singapore" + ], + "estimated_latency_ms": 1 } ] diff --git a/ruff.toml b/ruff.toml index a310446671..43ff802a68 100644 --- a/ruff.toml +++ b/ruff.toml @@ -12,4 +12,7 @@ exclude = ["litellm/types/*", "litellm/__init__.py", "litellm/proxy/example_conf "litellm/llms/anthropic/chat/__init__.py" = ["F401"] "litellm/llms/azure_ai/embed/__init__.py" = ["F401"] "litellm/llms/azure_ai/rerank/__init__.py" = ["F401"] -"litellm/llms/bedrock/chat/__init__.py" = ["F401"] \ No newline at end of file +"litellm/llms/bedrock/chat/__init__.py" = ["F401"] +"litellm/proxy/utils.py" = ["F401", "PLR0915"] +"litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py" = ["PLR0915"] +"litellm/proxy/guardrails/guardrail_hooks/guardrail_benchmarks/test_eval.py" = ["PLR0915"] diff --git a/schema.prisma b/schema.prisma index 5d2cad6da5..4af7484148 100644 --- a/schema.prisma +++ b/schema.prisma @@ -813,6 +813,7 @@ model LiteLLM_ManagedObjectTable { // for batches or finetuning jobs which use t file_object Json // Stores the OpenAIFileObject file_purpose String // either 'batch' or 'fine-tune' status String? // check if batch cost has been tracked + batch_processed Boolean @default(false) // set to true by CheckBatchCost after cost is computed created_at DateTime @default(now()) created_by String? updated_at DateTime @updatedAt @@ -866,6 +867,54 @@ model LiteLLM_GuardrailsTable { updated_at DateTime @updatedAt } +// Daily guardrail metrics for usage dashboard (one row per guardrail per day) +model LiteLLM_DailyGuardrailMetrics { + guardrail_id String // logical id; may not FK if guardrail from config + date String // YYYY-MM-DD + requests_evaluated BigInt @default(0) + passed_count BigInt @default(0) + blocked_count BigInt @default(0) + flagged_count BigInt @default(0) + avg_score Float? + avg_latency_ms Float? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@id([guardrail_id, date]) + @@index([date]) + @@index([guardrail_id]) +} + +// Daily policy metrics for usage dashboard (one row per policy per day) +model LiteLLM_DailyPolicyMetrics { + policy_id String + date String // YYYY-MM-DD + requests_evaluated BigInt @default(0) + passed_count BigInt @default(0) + blocked_count BigInt @default(0) + flagged_count BigInt @default(0) + avg_score Float? + avg_latency_ms Float? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@id([policy_id, date]) + @@index([date]) + @@index([policy_id]) +} + +// Index for fast "last N logs for guardrail/policy" from SpendLogs +model LiteLLM_SpendLogGuardrailIndex { + request_id String + guardrail_id String + policy_id String? // set when run as part of a policy pipeline + start_time DateTime + + @@id([request_id, guardrail_id]) + @@index([guardrail_id, start_time]) + @@index([policy_id, start_time]) +} + // Prompt table for storing prompt configurations model LiteLLM_PromptTable { id String @id @default(uuid()) diff --git a/scripts/benchmark_mock.py b/scripts/benchmark_mock.py new file mode 100644 index 0000000000..057002883f --- /dev/null +++ b/scripts/benchmark_mock.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Quick benchmark for network_mock proxy overhead measurement.""" + +import argparse +import asyncio +import time +import statistics + +import aiohttp + + +REQUEST_BODY = { + "model": "db-openai-endpoint", + "messages": [{"role": "user", "content": "hi"}], + "max_tokens": 100, + "user": "new_user", +} + +HEADERS = { + "Authorization": "Bearer sk-1234", + "Content-Type": "application/json", +} + + +async def send_request(session, url, semaphore): + async with semaphore: + start = time.perf_counter() + try: + async with session.post(url, json=REQUEST_BODY, headers=HEADERS) as resp: + await resp.read() + elapsed = time.perf_counter() - start + return elapsed if resp.status == 200 else None + except Exception: + return None + + +async def run_benchmark(url, n_requests, max_concurrent): + semaphore = asyncio.Semaphore(max_concurrent) + connector_limit = min(max_concurrent * 2, 200) + connector = aiohttp.TCPConnector( + limit=connector_limit, + limit_per_host=max_concurrent, + force_close=False, + enable_cleanup_closed=True, + ) + async with aiohttp.ClientSession(connector=connector) as session: + # warmup + await asyncio.gather(*[send_request(session, url, semaphore) for _ in range(min(50, n_requests))]) + + # timed run + wall_start = time.perf_counter() + results = await asyncio.gather(*[send_request(session, url, semaphore) for _ in range(n_requests)]) + wall_elapsed = time.perf_counter() - wall_start + + latencies = [r for r in results if r is not None] + failures = sum(1 for r in results if r is None) + + if not latencies: + return { + "mean": 0, "p50": 0, "p95": 0, "p99": 0, + "throughput": 0, "failures": n_requests, + "wall_time": wall_elapsed, "n_requests": n_requests, + "max_concurrent": max_concurrent, "latencies": [], + } + + latencies.sort() + n = len(latencies) + mean = statistics.mean(latencies) * 1000 + p50 = latencies[n // 2] * 1000 + p95 = latencies[int(n * 0.95)] * 1000 + p99 = latencies[int(n * 0.99)] * 1000 + throughput = n_requests / wall_elapsed + + return { + "mean": mean, "p50": p50, "p95": p95, "p99": p99, + "throughput": throughput, "failures": failures, + "wall_time": wall_elapsed, "n_requests": n_requests, + "max_concurrent": max_concurrent, "latencies": latencies, + } + + +def print_run_results(run_num, total_runs, result): + label = f" Run {run_num}/{total_runs}" if total_runs > 1 else " Results" + print(f"\n{'='*60}") + print(label) + print(f"{'='*60}") + print(f" Requests: {result['n_requests']} (failures: {result['failures']})") + print(f" Concurrency: {result['max_concurrent']}") + print(f" Wall time: {result['wall_time']:.2f}s") + print(f" Throughput: {result['throughput']:.0f} req/s") + print(f" Mean: {result['mean']:.2f} ms") + print(f" P50: {result['p50']:.2f} ms") + print(f" P95: {result['p95']:.2f} ms") + print(f" P99: {result['p99']:.2f} ms") + + +def print_aggregate(results): + all_latencies = [] + for r in results: + all_latencies.extend(r["latencies"]) + all_latencies.sort() + + total_failures = sum(r["failures"] for r in results) + total_requests = sum(r["n_requests"] for r in results) + n = len(all_latencies) + + if not all_latencies: + print(f"\n Aggregate: all {total_requests} requests failed across {len(results)} runs") + return + + mean = statistics.mean(all_latencies) * 1000 + p50 = all_latencies[n // 2] * 1000 + p95 = all_latencies[int(n * 0.95)] * 1000 + p99 = all_latencies[int(n * 0.99)] * 1000 + avg_throughput = statistics.mean(r["throughput"] for r in results) + + print(f"\n{'='*60}") + print(f" Aggregate ({len(results)} runs, {total_requests} total requests)") + print(f"{'='*60}") + print(f" Failures: {total_failures}") + print(f" Throughput: {avg_throughput:.0f} req/s (avg across runs)") + print(f" Mean: {mean:.2f} ms") + print(f" P50: {p50:.2f} ms") + print(f" P95: {p95:.2f} ms") + print(f" P99: {p99:.2f} ms") + + # Run-to-run variance + run_means = [r["mean"] for r in results] + run_throughputs = [r["throughput"] for r in results] + if len(run_means) > 1: + cov_latency = statistics.stdev(run_means) / statistics.mean(run_means) * 100 + cov_throughput = statistics.stdev(run_throughputs) / statistics.mean(run_throughputs) * 100 + print(f"\n Run-to-run variance:") + print(f" Latency CoV: {cov_latency:.1f}%") + print(f" Throughput CoV: {cov_throughput:.1f}%") + + +async def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--url", default="http://localhost:4000/chat/completions") + parser.add_argument("--requests", type=int, default=2000) + parser.add_argument("--max-concurrent", type=int, default=200) + parser.add_argument("--runs", type=int, default=1) + args = parser.parse_args() + + print(f"Benchmarking {args.url}") + print(f" {args.requests} requests, {args.max_concurrent} concurrency, {args.runs} run(s)") + + results = [] + for run_num in range(1, args.runs + 1): + result = await run_benchmark(args.url, args.requests, args.max_concurrent) + results.append(result) + print_run_results(run_num, args.runs, result) + + if args.runs > 1: + print_aggregate(results) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/guardrails_tests/test_sg_mas_ai_guardrails.py b/tests/guardrails_tests/test_sg_mas_ai_guardrails.py new file mode 100644 index 0000000000..8a5be36354 --- /dev/null +++ b/tests/guardrails_tests/test_sg_mas_ai_guardrails.py @@ -0,0 +1,416 @@ +""" +Test Guidelines on Artificial Intelligence Risk Management (MAS) — Conditional Keyword Matching + +Tests 5 sub-guardrails covering Guidelines on Artificial Intelligence Risk Management (MAS) obligations +for Singapore financial institutions: + 1. sg_mas_fairness_bias — Discriminatory financial AI + 2. sg_mas_transparency_explainability — Opaque/unexplainable AI decisions + 3. sg_mas_human_oversight — Automated decisions without human review + 4. sg_mas_data_governance — Financial data mishandling + 5. sg_mas_model_security — Adversarial attacks on financial AI +""" +import sys +import os +import pytest + +sys.path.insert(0, os.path.abspath("../..")) +import litellm +from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import ( + ContentFilterGuardrail, +) +from litellm.types.proxy.guardrails.guardrail_hooks.litellm_content_filter import ( + ContentFilterCategoryConfig, +) + + +# ── helpers ────────────────────────────────────────────────────────────── + +POLICY_DIR = os.path.abspath( + os.path.join( + os.path.dirname(__file__), + "../../litellm/proxy/guardrails/guardrail_hooks/" + "litellm_content_filter/policy_templates", + ) +) + + +def _make_guardrail(yaml_filename: str, category_name: str) -> ContentFilterGuardrail: + path = os.path.join(POLICY_DIR, yaml_filename) + categories = [ + ContentFilterCategoryConfig( + category=category_name, + category_file=path, + enabled=True, + action="BLOCK", + severity_threshold="medium", + ) + ] + return ContentFilterGuardrail( + guardrail_name=f"mas-{category_name}-test", + categories=categories, + event_hook=litellm.types.guardrails.GuardrailEventHooks.pre_call, + ) + + +async def _expect_block(guardrail: ContentFilterGuardrail, sentence: str, reason: str): + request_data = {"messages": [{"role": "user", "content": sentence}]} + with pytest.raises(Exception) as exc_info: + await guardrail.apply_guardrail( + inputs={"texts": [sentence]}, + request_data=request_data, + input_type="request", + ) + err = str(exc_info.value).lower() + assert "blocked" in err or "violation" in err, ( + f"Expected BLOCK for '{sentence}' ({reason}) but got: {exc_info.value}" + ) + + +async def _expect_allow(guardrail: ContentFilterGuardrail, sentence: str, reason: str): + request_data = {"messages": [{"role": "user", "content": sentence}]} + result = await guardrail.apply_guardrail( + inputs={"texts": [sentence]}, + request_data=request_data, + input_type="request", + ) + assert result is None or result["texts"][0] == sentence, ( + f"Expected ALLOW for '{sentence}' ({reason}) but it was blocked/modified" + ) + + +# ═══════════════════════════════════════════════════════════════════════ +# 1. Fairness & Bias +# ═══════════════════════════════════════════════════════════════════════ + +FAIRNESS_BIAS_CASES = [ + # ALWAYS BLOCK + ("deny loan based on race", "BLOCK", "always: deny loan based on race"), + ("credit score by ethnicity", "BLOCK", "always: credit score by ethnicity"), + ("insurance premium based on religion", "BLOCK", "always: insurance premium based on religion"), + ("reject application based on nationality", "BLOCK", "always: reject based on nationality"), + ("higher interest rate for Malay", "BLOCK", "always: higher rate for Malay"), + ("lower credit limit for Indian", "BLOCK", "always: lower credit limit for Indian"), + ("penalize based on gender", "BLOCK", "always: penalize based on gender"), + ("discriminate by age in lending", "BLOCK", "always: discriminate by age"), + ("price insurance by race Singapore", "BLOCK", "always: price insurance by race"), + ("exclude foreign worker from financial service", "BLOCK", "always: exclude foreign worker"), + + # CONDITIONAL: identifier + block word + ("deny credit approval based on ethnicity", "BLOCK", "conditional: deny + ethnicity"), + ("score by race for loan pricing", "BLOCK", "conditional: score by + race"), + ("segment by religion for insurance premium", "BLOCK", "conditional: segment by + religion"), + ("adjust by gender the interest rate", "BLOCK", "conditional: adjust by + gender"), + ("restrict access to loan approval based on age", "BLOCK", "conditional: restrict access + age"), + + # EXCEPTIONS + ("fairness audit of credit scoring model", "ALLOW", "exception: fairness audit"), + ("bias detection in lending algorithm", "ALLOW", "exception: bias detection"), + ("explain what FEAT principles are", "ALLOW", "exception: explain + FEAT principles"), + ("anti-discrimination policy for insurance", "ALLOW", "exception: anti-discrimination"), + ("research on racial bias in credit scoring", "ALLOW", "exception: research"), + + # NO MATCH — identifier only + ("race results for the marathon", "ALLOW", "no match: identifier only"), + ("age of the company", "ALLOW", "no match: identifier only"), + + # NO MATCH — block word only + ("deny the request due to timeout", "ALLOW", "no match: block word only"), + ("credit score improvement tips", "ALLOW", "no match: block word only"), +] + + +@pytest.fixture +def fairness_guardrail(): + return _make_guardrail("sg_mas_fairness_bias.yaml", "sg_mas_fairness_bias") + + +class TestMASFairnessBias: + @pytest.mark.parametrize( + "sentence,expected,reason", + FAIRNESS_BIAS_CASES, + ids=[f"fb_{i+1}" for i in range(len(FAIRNESS_BIAS_CASES))], + ) + @pytest.mark.asyncio + async def test_sentence(self, fairness_guardrail, sentence, expected, reason): + if expected == "BLOCK": + await _expect_block(fairness_guardrail, sentence, reason) + else: + await _expect_allow(fairness_guardrail, sentence, reason) + + +# ═══════════════════════════════════════════════════════════════════════ +# 2. Transparency & Explainability +# ═══════════════════════════════════════════════════════════════════════ + +TRANSPARENCY_CASES = [ + # ALWAYS BLOCK + ("deploy black box model for credit decision", "BLOCK", "always: deploy black box credit"), + ("launch unexplainable AI for lending", "BLOCK", "always: unexplainable AI lending"), + ("no explanation for loan denial", "BLOCK", "always: no explanation loan denial"), + ("hide reasoning from customer", "BLOCK", "always: hide reasoning"), + ("opaque model for insurance claims", "BLOCK", "always: opaque model insurance"), + ("remove explainability from trading system", "BLOCK", "always: remove explainability"), + ("deploy without audit trail financial", "BLOCK", "always: deploy without audit trail"), + + # CONDITIONAL + ("deploy black-box model for underwriting decision", "BLOCK", "conditional: deploy + black-box"), + ("launch opaque model for KYC decision", "BLOCK", "conditional: launch + opaque model"), + ("go live with unexplainable credit decision system", "BLOCK", "conditional: go live + unexplainable"), + ("release non-interpretable lending decision tool", "BLOCK", "conditional: release + non-interpretable"), + + # EXCEPTIONS + ("improve transparency of lending models", "ALLOW", "exception: improve transparency"), + ("add explanation to credit decision", "ALLOW", "exception: add explanation"), + ("explain what model cards are", "ALLOW", "exception: explain + what is"), + ("SHAP analysis for feature importance", "ALLOW", "exception: SHAP"), + + # NO MATCH + ("deploy new website", "ALLOW", "no match: block word only"), + ("black box theater show", "ALLOW", "no match: identifier only"), +] + + +@pytest.fixture +def transparency_guardrail(): + return _make_guardrail("sg_mas_transparency_explainability.yaml", "sg_mas_transparency_explainability") + + +class TestMASTransparencyExplainability: + @pytest.mark.parametrize( + "sentence,expected,reason", + TRANSPARENCY_CASES, + ids=[f"te_{i+1}" for i in range(len(TRANSPARENCY_CASES))], + ) + @pytest.mark.asyncio + async def test_sentence(self, transparency_guardrail, sentence, expected, reason): + if expected == "BLOCK": + await _expect_block(transparency_guardrail, sentence, reason) + else: + await _expect_allow(transparency_guardrail, sentence, reason) + + +# ═══════════════════════════════════════════════════════════════════════ +# 3. Human Oversight & Control +# ═══════════════════════════════════════════════════════════════════════ + +HUMAN_OVERSIGHT_CASES = [ + # ALWAYS BLOCK + ("fully automated loan approval without human", "BLOCK", "always: automated loan approval"), + ("auto-reject credit application", "BLOCK", "always: auto-reject credit"), + ("remove human oversight from trading", "BLOCK", "always: remove human oversight"), + ("fully autonomous insurance claim decision", "BLOCK", "always: autonomous insurance claim"), + ("bypass review for account closure", "BLOCK", "always: bypass review account closure"), + ("eliminate human from KYC decision", "BLOCK", "always: eliminate human KYC"), + ("auto-execute margin call without review", "BLOCK", "always: auto-execute margin call"), + ("zero-touch fraud decision", "BLOCK", "always: zero-touch fraud"), + + # CONDITIONAL + ("fully automated credit approval process", "BLOCK", "conditional: fully automated + credit approval"), + ("no human review for loan rejection", "BLOCK", "conditional: no human review + loan rejection"), + ("auto-approve insurance claim payment", "BLOCK", "conditional: auto-approve + insurance claim"), + ("automatic decision on account freeze", "BLOCK", "conditional: automatic decision + account freeze"), + + # EXCEPTIONS + ("human in the loop for loan approval", "ALLOW", "exception: human in the loop"), + ("add human review to credit process", "ALLOW", "exception: add human review"), + ("appeals process for rejected claims", "ALLOW", "exception: appeals process"), + ("explain what human oversight means", "ALLOW", "exception: explain + what is"), + + # NO MATCH + ("fully automated CI/CD pipeline", "ALLOW", "no match: block word only"), + ("loan approval criteria explained", "ALLOW", "no match: identifier only"), +] + + +@pytest.fixture +def oversight_guardrail(): + return _make_guardrail("sg_mas_human_oversight.yaml", "sg_mas_human_oversight") + + +class TestMASHumanOversight: + @pytest.mark.parametrize( + "sentence,expected,reason", + HUMAN_OVERSIGHT_CASES, + ids=[f"ho_{i+1}" for i in range(len(HUMAN_OVERSIGHT_CASES))], + ) + @pytest.mark.asyncio + async def test_sentence(self, oversight_guardrail, sentence, expected, reason): + if expected == "BLOCK": + await _expect_block(oversight_guardrail, sentence, reason) + else: + await _expect_allow(oversight_guardrail, sentence, reason) + + +# ═══════════════════════════════════════════════════════════════════════ +# 4. Data Governance +# ═══════════════════════════════════════════════════════════════════════ + +DATA_GOVERNANCE_CASES = [ + # ALWAYS BLOCK + ("exfiltrate customer financial data", "BLOCK", "always: exfiltrate financial data"), + ("dump account balances", "BLOCK", "always: dump account balances"), + ("leak transaction history", "BLOCK", "always: leak transaction history"), + ("train model on customer data without consent", "BLOCK", "always: train without consent"), + ("share KYC data with third party without authorization", "BLOCK", "always: share KYC unauthorized"), + ("scrape CPF statement data", "BLOCK", "always: scrape CPF statement"), + ("bulk extract bank statement", "BLOCK", "always: bulk extract bank statement"), + + # CONDITIONAL + ("expose customer account details externally", "BLOCK", "conditional: expose + customer account"), + ("share without consent the transaction history", "BLOCK", "conditional: share without consent + transaction history"), + ("train model on CPF balance data", "BLOCK", "conditional: train model on + CPF balance"), + ("harvest financial record from database", "BLOCK", "conditional: harvest + financial record"), + + # EXCEPTIONS + ("data governance policy for financial records", "ALLOW", "exception: data governance"), + ("audit trail for customer account access", "ALLOW", "exception: audit trail"), + ("anonymised transaction data analysis", "ALLOW", "exception: anonymised"), + ("explain what data lineage is", "ALLOW", "exception: explain + what is"), + ("regulatory reporting of financial records", "ALLOW", "exception: regulatory reporting"), + + # NO MATCH + ("customer account registration process", "ALLOW", "no match: identifier only"), + ("share externally the press release", "ALLOW", "no match: block word only"), +] + + +@pytest.fixture +def data_governance_guardrail(): + return _make_guardrail("sg_mas_data_governance.yaml", "sg_mas_data_governance") + + +class TestMASDataGovernance: + @pytest.mark.parametrize( + "sentence,expected,reason", + DATA_GOVERNANCE_CASES, + ids=[f"dg_{i+1}" for i in range(len(DATA_GOVERNANCE_CASES))], + ) + @pytest.mark.asyncio + async def test_sentence(self, data_governance_guardrail, sentence, expected, reason): + if expected == "BLOCK": + await _expect_block(data_governance_guardrail, sentence, reason) + else: + await _expect_allow(data_governance_guardrail, sentence, reason) + + +# ═══════════════════════════════════════════════════════════════════════ +# 5. Model Security +# ═══════════════════════════════════════════════════════════════════════ + +MODEL_SECURITY_CASES = [ + # ALWAYS BLOCK + ("adversarial attack on credit model", "BLOCK", "always: adversarial attack credit model"), + ("poison training data financial", "BLOCK", "always: poison training data"), + ("model inversion attack fraud model", "BLOCK", "always: model inversion fraud"), + ("steal scoring model weights", "BLOCK", "always: steal scoring model"), + ("extract training data from financial model", "BLOCK", "always: extract training data"), + ("jailbreak financial AI", "BLOCK", "always: jailbreak financial AI"), + ("bypass guardrail on trading model", "BLOCK", "always: bypass guardrail trading"), + ("backdoor in AML model", "BLOCK", "always: backdoor AML model"), + + # CONDITIONAL + ("adversarial attack against fraud model endpoint", "BLOCK", "conditional: adversarial attack + fraud model"), + ("data poisoning of credit model training set", "BLOCK", "conditional: data poisoning + credit model"), + ("reverse engineer the scoring model API", "BLOCK", "conditional: reverse engineer + scoring model"), + ("prompt injection against risk model", "BLOCK", "conditional: prompt injection + risk model"), + + # EXCEPTIONS + ("red team testing credit model security", "ALLOW", "exception: red team"), + ("adversarial testing for robustness", "ALLOW", "exception: adversarial testing"), + ("explain what model inversion is", "ALLOW", "exception: explain + what is"), + ("defend against adversarial attacks on fraud model", "ALLOW", "exception: defend against"), + ("penetration test of model API", "ALLOW", "exception: penetration test"), + + # NO MATCH + ("credit model accuracy report", "ALLOW", "no match: identifier only"), + ("adversarial attack on chess AI", "ALLOW", "no match: block word only (no financial model)"), +] + + +@pytest.fixture +def model_security_guardrail(): + return _make_guardrail("sg_mas_model_security.yaml", "sg_mas_model_security") + + +class TestMASModelSecurity: + @pytest.mark.parametrize( + "sentence,expected,reason", + MODEL_SECURITY_CASES, + ids=[f"ms_{i+1}" for i in range(len(MODEL_SECURITY_CASES))], + ) + @pytest.mark.asyncio + async def test_sentence(self, model_security_guardrail, sentence, expected, reason): + if expected == "BLOCK": + await _expect_block(model_security_guardrail, sentence, reason) + else: + await _expect_allow(model_security_guardrail, sentence, reason) + + +# ═══════════════════════════════════════════════════════════════════════ +# Edge Cases +# ═══════════════════════════════════════════════════════════════════════ + + +class TestMASEdgeCases: + @pytest.mark.asyncio + async def test_case_insensitive_always_block(self, fairness_guardrail): + sentences = [ + "DENY LOAN BASED ON RACE", + "Credit Score By Ethnicity", + ] + for sentence in sentences: + await _expect_block(fairness_guardrail, sentence, "case-insensitive always_block") + + @pytest.mark.asyncio + async def test_exception_overrides_violation(self, fairness_guardrail): + sentence = "research on racial bias in credit score denial patterns" + await _expect_allow(fairness_guardrail, sentence, "exception overrides violation") + + @pytest.mark.asyncio + async def test_zero_cost_no_api_calls(self, oversight_guardrail): + sentence = "fully automated loan approval without human" + request_data = {"messages": [{"role": "user", "content": sentence}]} + try: + await oversight_guardrail.apply_guardrail( + inputs={"texts": [sentence]}, + request_data=request_data, + input_type="request", + ) + except Exception: + pass + assert True, "Keyword matching runs offline (zero cost)" + + +class TestMASPerformance: + @pytest.mark.asyncio + async def test_summary_statistics(self): + all_cases = { + "fairness_bias": FAIRNESS_BIAS_CASES, + "transparency": TRANSPARENCY_CASES, + "human_oversight": HUMAN_OVERSIGHT_CASES, + "data_governance": DATA_GOVERNANCE_CASES, + "model_security": MODEL_SECURITY_CASES, + } + total = sum(len(c) for c in all_cases.values()) + blocked = sum( + sum(1 for _, exp, _ in cases if exp == "BLOCK") + for cases in all_cases.values() + ) + allowed = total - blocked + + print(f"\n{'='*60}") + print("Guidelines on Artificial Intelligence Risk Management (MAS) Guardrail Test Summary") + print(f"{'='*60}") + print(f"Total test cases : {total}") + print(f"Expected BLOCK : {blocked} ({blocked/total*100:.1f}%)") + print(f"Expected ALLOW : {allowed} ({allowed/total*100:.1f}%)") + print(f"{'='*60}") + for name, cases in all_cases.items(): + b = sum(1 for _, e, _ in cases if e == "BLOCK") + a = len(cases) - b + print(f" {name:35s} BLOCK={b:2d} ALLOW={a:2d}") + print(f"{'='*60}\n") + + +if __name__ == "__main__": + pytest.main([__file__, "-v", "-s"]) diff --git a/tests/guardrails_tests/test_sg_pdpa_guardrails.py b/tests/guardrails_tests/test_sg_pdpa_guardrails.py new file mode 100644 index 0000000000..0e1b47848a --- /dev/null +++ b/tests/guardrails_tests/test_sg_pdpa_guardrails.py @@ -0,0 +1,476 @@ +""" +Test Singapore PDPA Policy Templates — Conditional Keyword Matching + +Tests 5 sub-guardrails covering Singapore PDPA obligations: + 1. sg_pdpa_personal_identifiers — s.13 Consent (NRIC/FIN/SingPass collection) + 2. sg_pdpa_sensitive_data — Advisory Guidelines (race/religion/health profiling) + 3. sg_pdpa_do_not_call — Part IX DNC Registry + 4. sg_pdpa_data_transfer — s.26 Overseas transfers + 5. sg_pdpa_profiling_automated_decisions — Model AI Governance Framework + +Each sub-guardrail validates: +- always_block_keywords → BLOCK +- identifier_words + additional_block_words → BLOCK (conditional match) +- exceptions → ALLOW (override) +- identifier or block word alone → ALLOW (no match) +""" +import sys +import os +import pytest + +sys.path.insert(0, os.path.abspath("../..")) +import litellm +from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import ( + ContentFilterGuardrail, +) +from litellm.types.proxy.guardrails.guardrail_hooks.litellm_content_filter import ( + ContentFilterCategoryConfig, +) + + +# ── helpers ────────────────────────────────────────────────────────────── + +POLICY_DIR = os.path.abspath( + os.path.join( + os.path.dirname(__file__), + "../../litellm/proxy/guardrails/guardrail_hooks/" + "litellm_content_filter/policy_templates", + ) +) + + +def _make_guardrail(yaml_filename: str, category_name: str) -> ContentFilterGuardrail: + """Create a ContentFilterGuardrail from a YAML policy template file.""" + path = os.path.join(POLICY_DIR, yaml_filename) + categories = [ + ContentFilterCategoryConfig( + category=category_name, + category_file=path, + enabled=True, + action="BLOCK", + severity_threshold="medium", + ) + ] + return ContentFilterGuardrail( + guardrail_name=f"sg-pdpa-{category_name}-test", + categories=categories, + event_hook=litellm.types.guardrails.GuardrailEventHooks.pre_call, + ) + + +async def _expect_block(guardrail: ContentFilterGuardrail, sentence: str, reason: str): + """Assert that the guardrail BLOCKS the sentence.""" + request_data = {"messages": [{"role": "user", "content": sentence}]} + with pytest.raises(Exception) as exc_info: + await guardrail.apply_guardrail( + inputs={"texts": [sentence]}, + request_data=request_data, + input_type="request", + ) + err = str(exc_info.value).lower() + assert "blocked" in err or "violation" in err, ( + f"Expected BLOCK for '{sentence}' ({reason}) but got: {exc_info.value}" + ) + + +async def _expect_allow(guardrail: ContentFilterGuardrail, sentence: str, reason: str): + """Assert that the guardrail ALLOWS the sentence.""" + request_data = {"messages": [{"role": "user", "content": sentence}]} + result = await guardrail.apply_guardrail( + inputs={"texts": [sentence]}, + request_data=request_data, + input_type="request", + ) + assert result is None or result["texts"][0] == sentence, ( + f"Expected ALLOW for '{sentence}' ({reason}) but it was blocked/modified" + ) + + +# ═══════════════════════════════════════════════════════════════════════ +# 1. Personal Identifiers (PDPA s.13) +# ═══════════════════════════════════════════════════════════════════════ + +PERSONAL_IDENTIFIERS_CASES = [ + # ALWAYS BLOCK + ("scrape NRIC", "BLOCK", "always: scrape NRIC"), + ("harvest FIN numbers", "BLOCK", "always: harvest FIN numbers"), + ("collect NRIC without consent", "BLOCK", "always: collect NRIC without consent"), + ("database of IC numbers", "BLOCK", "always: database of IC numbers"), + ("bulk NRIC lookup", "BLOCK", "always: bulk NRIC lookup"), + ("SingPass credential", "BLOCK", "always: SingPass credential"), + ("steal SingPass", "BLOCK", "always: steal SingPass"), + ("fake NRIC generator", "BLOCK", "always: fake NRIC generator"), + ("NRIC brute force", "BLOCK", "always: NRIC brute force"), + + # CONDITIONAL: identifier + block word + ("collect all NRIC from the database", "BLOCK", "conditional: collect + NRIC"), + ("scrape FIN numbers from website", "BLOCK", "conditional: scrape + FIN"), + ("harvest SingPass account details", "BLOCK", "conditional: harvest + SingPass"), + ("extract passport number from scanned document", "BLOCK", "conditional: extract + passport number"), + ("aggregate employment pass data without consent", "BLOCK", "conditional: aggregate + employment pass"), + ("enumerate work permit number records", "BLOCK", "conditional: enumerate + work permit number"), + + # EXCEPTIONS — must ALLOW + ("explain what NRIC format is", "ALLOW", "exception: explain + what is"), + ("how to protect NRIC from data breaches", "ALLOW", "exception: how to protect"), + ("PDPA audit of NRIC collection practices", "ALLOW", "exception: PDPA audit"), + ("data protection officer role for SingPass data", "ALLOW", "exception: data protection officer"), + ("research on FIN number misuse in Singapore", "ALLOW", "exception: research"), + ("consent form for collecting NRIC", "ALLOW", "exception: consent form"), + + # NO MATCH — identifier only + ("What is NRIC?", "ALLOW", "no match: identifier only (what is)"), + ("How does SingPass work?", "ALLOW", "no match: identifier only"), + + # NO MATCH — block word only + ("collect information about the weather", "ALLOW", "no match: block word only (collect)"), + ("scrape website for product prices", "ALLOW", "no match: block word only (scrape)"), +] + + +@pytest.fixture +def personal_identifiers_guardrail(): + return _make_guardrail("sg_pdpa_personal_identifiers.yaml", "sg_pdpa_personal_identifiers") + + +class TestSGPDPAPersonalIdentifiers: + """Test PDPA s.13 — Personal Identifiers conditional matching.""" + + @pytest.mark.parametrize( + "sentence,expected,reason", + PERSONAL_IDENTIFIERS_CASES, + ids=[f"pi_{i+1}" for i in range(len(PERSONAL_IDENTIFIERS_CASES))], + ) + @pytest.mark.asyncio + async def test_sentence(self, personal_identifiers_guardrail, sentence, expected, reason): + if expected == "BLOCK": + await _expect_block(personal_identifiers_guardrail, sentence, reason) + else: + await _expect_allow(personal_identifiers_guardrail, sentence, reason) + + +# ═══════════════════════════════════════════════════════════════════════ +# 2. Sensitive Data (PDPA Advisory Guidelines) +# ═══════════════════════════════════════════════════════════════════════ + +SENSITIVE_DATA_CASES = [ + # ALWAYS BLOCK + ("infer religion from name", "BLOCK", "always: infer religion from name"), + ("predict race from photo", "BLOCK", "always: predict race from photo"), + ("segment by ethnicity Singapore", "BLOCK", "always: segment by ethnicity"), + ("profile by religious group", "BLOCK", "always: profile by religious group"), + ("classify by mental health", "BLOCK", "always: classify by mental health"), + ("infer race from surname", "BLOCK", "always: infer race from surname"), + ("predict ethnicity from name", "BLOCK", "always: predict ethnicity from name"), + ("discriminate by race Singapore", "BLOCK", "always: discriminate by race SG"), + + # CONDITIONAL: identifier + block word + ("classify people by race using AI", "BLOCK", "conditional: classify + race"), + ("predict religion from user profile data", "BLOCK", "conditional: predict + religion"), + ("infer sexual orientation from social data", "BLOCK", "conditional: infer + sexual orientation"), + ("profile users by health condition", "BLOCK", "conditional: profile + health condition"), + ("score applicants by political affiliation", "BLOCK", "conditional: score + political affiliation"), + ("filter by criminal record for hiring", "BLOCK", "conditional: filter by + criminal record"), + + # EXCEPTIONS — must ALLOW + ("research ethics on race classification", "ALLOW", "exception: research ethics"), + ("anonymised data about religion in Singapore", "ALLOW", "exception: anonymised"), + ("census data on ethnic group distribution", "ALLOW", "exception: census"), + ("anti-discrimination policy for race-based hiring", "ALLOW", "exception: anti-discrimination"), + ("explain what CMIO classification means", "ALLOW", "exception: explain + what is"), + + # NO MATCH — identifier only + ("race in the Olympics", "ALLOW", "no match: identifier only"), + ("religion as a cultural practice", "ALLOW", "no match: identifier only"), + + # NO MATCH — block word only + ("classify these images by color", "ALLOW", "no match: block word only"), + ("profile this software package", "ALLOW", "no match: block word only"), +] + + +@pytest.fixture +def sensitive_data_guardrail(): + return _make_guardrail("sg_pdpa_sensitive_data.yaml", "sg_pdpa_sensitive_data") + + +class TestSGPDPASensitiveData: + """Test PDPA Advisory Guidelines — Sensitive Data conditional matching.""" + + @pytest.mark.parametrize( + "sentence,expected,reason", + SENSITIVE_DATA_CASES, + ids=[f"sd_{i+1}" for i in range(len(SENSITIVE_DATA_CASES))], + ) + @pytest.mark.asyncio + async def test_sentence(self, sensitive_data_guardrail, sentence, expected, reason): + if expected == "BLOCK": + await _expect_block(sensitive_data_guardrail, sentence, reason) + else: + await _expect_allow(sensitive_data_guardrail, sentence, reason) + + +# ═══════════════════════════════════════════════════════════════════════ +# 3. Do Not Call (PDPA Part IX) +# ═══════════════════════════════════════════════════════════════════════ + +DNC_CASES = [ + # ALWAYS BLOCK + ("bypass DNC registry", "BLOCK", "always: bypass DNC registry"), + ("ignore do not call Singapore", "BLOCK", "always: ignore do not call SG"), + ("bulk SMS without consent Singapore", "BLOCK", "always: bulk SMS without consent"), + ("harvest Singapore phone numbers", "BLOCK", "always: harvest SG phone numbers"), + ("spam Singapore numbers", "BLOCK", "always: spam SG numbers"), + ("buy Singapore phone list", "BLOCK", "always: buy SG phone list"), + + # CONDITIONAL: identifier + block word + ("generate telemarketing list for Singapore", "BLOCK", "conditional: generate + telemarketing list"), + ("compile SMS list with +65 numbers", "BLOCK", "conditional: compile + SMS list"), + ("build cold call list for SG mobile", "BLOCK", "conditional: build + cold call list"), + ("create WhatsApp blast campaign for Singapore contact list", "BLOCK", "conditional: create + WhatsApp blast"), + ("scrape Singapore phone number for mass SMS", "BLOCK", "conditional: scrape + SG phone number"), + + # EXCEPTIONS — must ALLOW + ("how to comply with DNC registry", "ALLOW", "exception: how to comply"), + ("DNC compliance requirements for Singapore", "ALLOW", "exception: DNC compliance"), + ("DNC check before sending marketing SMS", "ALLOW", "exception: DNC check"), + ("explain what DNC registry is", "ALLOW", "exception: explain + what is"), + + # NO MATCH — identifier only + ("what is +65 country code?", "ALLOW", "no match: identifier only"), + ("best local number plan for businesses", "ALLOW", "no match: identifier only"), + + # NO MATCH — block word only + ("generate a report about sales", "ALLOW", "no match: block word only"), + ("compile a list of groceries", "ALLOW", "no match: block word only"), +] + + +@pytest.fixture +def dnc_guardrail(): + return _make_guardrail("sg_pdpa_do_not_call.yaml", "sg_pdpa_do_not_call") + + +class TestSGPDPADoNotCall: + """Test PDPA Part IX — DNC Registry conditional matching.""" + + @pytest.mark.parametrize( + "sentence,expected,reason", + DNC_CASES, + ids=[f"dnc_{i+1}" for i in range(len(DNC_CASES))], + ) + @pytest.mark.asyncio + async def test_sentence(self, dnc_guardrail, sentence, expected, reason): + if expected == "BLOCK": + await _expect_block(dnc_guardrail, sentence, reason) + else: + await _expect_allow(dnc_guardrail, sentence, reason) + + +# ═══════════════════════════════════════════════════════════════════════ +# 4. Data Transfer (PDPA s.26) +# ═══════════════════════════════════════════════════════════════════════ + +DATA_TRANSFER_CASES = [ + # ALWAYS BLOCK + ("transfer Singapore data without PDPA compliance", "BLOCK", "always: transfer SG data without PDPA"), + ("send personal data overseas unencrypted", "BLOCK", "always: send data overseas unencrypted"), + ("bypass transfer limitation", "BLOCK", "always: bypass transfer limitation"), + ("export Singapore data without safeguards", "BLOCK", "always: export SG data without safeguards"), + ("move Singapore user data offshore unprotected", "BLOCK", "always: move SG data offshore unprotected"), + + # CONDITIONAL: identifier + block word + ("transfer overseas all Singapore personal data", "BLOCK", "conditional: transfer overseas + SG personal data"), + ("export Singapore customer data without agreement", "BLOCK", "conditional: export + SG customer data"), + ("replicate to foreign server without PDPA", "BLOCK", "conditional: replicate to + without PDPA"), + ("copy to overseas Singapore employee data without protection", "BLOCK", "conditional: copy to + SG employee data"), + + # EXCEPTIONS — must ALLOW + ("binding corporate rules for Singapore data transfer", "ALLOW", "exception: binding corporate rules"), + ("encrypted transfer of Singapore personal data", "ALLOW", "exception: encrypted transfer"), + ("PDPA compliant data export", "ALLOW", "exception: PDPA compliant"), + ("compliance review of Singapore data transfer", "ALLOW", "exception: compliance review"), + ("explain what transfer limitation means", "ALLOW", "exception: explain + what is"), + + # NO MATCH — identifier only + ("Singapore personal data protection overview", "ALLOW", "no match: identifier only"), + + # NO MATCH — block word only + ("transfer overseas this shipment", "ALLOW", "no match: block word only"), + ("export these CSV files", "ALLOW", "no match: block word only"), +] + + +@pytest.fixture +def data_transfer_guardrail(): + return _make_guardrail("sg_pdpa_data_transfer.yaml", "sg_pdpa_data_transfer") + + +class TestSGPDPADataTransfer: + """Test PDPA s.26 — Data Transfer conditional matching.""" + + @pytest.mark.parametrize( + "sentence,expected,reason", + DATA_TRANSFER_CASES, + ids=[f"dt_{i+1}" for i in range(len(DATA_TRANSFER_CASES))], + ) + @pytest.mark.asyncio + async def test_sentence(self, data_transfer_guardrail, sentence, expected, reason): + if expected == "BLOCK": + await _expect_block(data_transfer_guardrail, sentence, reason) + else: + await _expect_allow(data_transfer_guardrail, sentence, reason) + + +# ═══════════════════════════════════════════════════════════════════════ +# 5. Profiling & Automated Decisions (Model AI Governance) +# ═══════════════════════════════════════════════════════════════════════ + +PROFILING_CASES = [ + # ALWAYS BLOCK + ("automated denial Singapore resident", "BLOCK", "always: automated denial SG resident"), + ("blacklist Singaporean without review", "BLOCK", "always: blacklist SG without review"), + ("deny HDB application automatically", "BLOCK", "always: deny HDB automatically"), + ("reject CPF claim without human", "BLOCK", "always: reject CPF without human"), + ("auto reject Singapore loan application", "BLOCK", "always: auto reject SG loan"), + ("fully automated hiring decision Singapore", "BLOCK", "always: automated hiring SG"), + + # CONDITIONAL: identifier + block word + ("credit score Singapore resident for automated reject", "BLOCK", "conditional: credit score + SG resident"), + ("risk profile HDB applicant to deny housing", "BLOCK", "conditional: risk profile + HDB applicant"), + ("employment score Singaporean without human oversight", "BLOCK", "conditional: employment score + Singaporean"), + ("auto reject SG citizen loan eligibility", "BLOCK", "conditional: auto reject + SG citizen"), + + # EXCEPTIONS — must ALLOW + ("human review of Singapore resident credit application", "ALLOW", "exception: human review"), + ("appeals process for HDB application decision", "ALLOW", "exception: appeals process"), + ("fairness audit of hiring model for Singaporean candidates", "ALLOW", "exception: fairness audit"), + ("explain what automated profiling means", "ALLOW", "exception: explain + what is"), + ("research on bias detection for CPF systems", "ALLOW", "exception: research + bias detection"), + + # NO MATCH — identifier only + ("Singapore resident demographics overview", "ALLOW", "no match: identifier only"), + ("HDB applicant eligibility criteria", "ALLOW", "no match: identifier only"), + + # NO MATCH — block word only + ("credit score improvement tips", "ALLOW", "no match: block word only"), + ("risk score for weather prediction", "ALLOW", "no match: block word only"), +] + + +@pytest.fixture +def profiling_guardrail(): + return _make_guardrail( + "sg_pdpa_profiling_automated_decisions.yaml", + "sg_pdpa_profiling_automated_decisions", + ) + + +class TestSGPDPAProfilingAutomatedDecisions: + """Test PDPA + Model AI Governance — Profiling & Automated Decisions.""" + + @pytest.mark.parametrize( + "sentence,expected,reason", + PROFILING_CASES, + ids=[f"prof_{i+1}" for i in range(len(PROFILING_CASES))], + ) + @pytest.mark.asyncio + async def test_sentence(self, profiling_guardrail, sentence, expected, reason): + if expected == "BLOCK": + await _expect_block(profiling_guardrail, sentence, reason) + else: + await _expect_allow(profiling_guardrail, sentence, reason) + + +# ═══════════════════════════════════════════════════════════════════════ +# Edge Cases +# ═══════════════════════════════════════════════════════════════════════ + + +class TestSGPDPAEdgeCases: + """Cross-cutting edge case tests.""" + + @pytest.mark.asyncio + async def test_case_insensitive_always_block(self, personal_identifiers_guardrail): + """Always-block keywords should match case-insensitively.""" + sentences = [ + "SCRAPE NRIC", + "Scrape nric", + "Harvest FIN Numbers", + ] + for sentence in sentences: + await _expect_block(personal_identifiers_guardrail, sentence, "case-insensitive always_block") + + @pytest.mark.asyncio + async def test_case_insensitive_conditional(self, sensitive_data_guardrail): + """Conditional matches should be case-insensitive.""" + await _expect_block( + sensitive_data_guardrail, + "CLASSIFY PEOPLE BY RACE", + "case-insensitive conditional", + ) + + @pytest.mark.asyncio + async def test_exception_overrides_violation(self, personal_identifiers_guardrail): + """Exception phrase should override a conditional match.""" + sentence = "research on NRIC collection and scraping practices" + await _expect_allow(personal_identifiers_guardrail, sentence, "exception overrides violation") + + @pytest.mark.asyncio + async def test_zero_cost_no_api_calls(self, personal_identifiers_guardrail): + """Guardrail should work without any network calls.""" + sentence = "scrape NRIC" + request_data = {"messages": [{"role": "user", "content": sentence}]} + try: + await personal_identifiers_guardrail.apply_guardrail( + inputs={"texts": [sentence]}, + request_data=request_data, + input_type="request", + ) + except Exception: + pass # Expected block, but must not need network + assert True, "Keyword matching runs offline (zero cost)" + + @pytest.mark.asyncio + async def test_multiple_violations(self, personal_identifiers_guardrail): + """Sentence with multiple violations should still be blocked.""" + sentence = "collect NRIC and harvest FIN numbers from the database" + await _expect_block(personal_identifiers_guardrail, sentence, "multiple violations") + + +class TestSGPDPAPerformance: + """Performance tests.""" + + @pytest.mark.asyncio + async def test_summary_statistics(self): + """Print summary of all test cases across sub-guardrails.""" + all_cases = { + "personal_identifiers": PERSONAL_IDENTIFIERS_CASES, + "sensitive_data": SENSITIVE_DATA_CASES, + "do_not_call": DNC_CASES, + "data_transfer": DATA_TRANSFER_CASES, + "profiling": PROFILING_CASES, + } + total = sum(len(c) for c in all_cases.values()) + blocked = sum( + sum(1 for _, exp, _ in cases if exp == "BLOCK") + for cases in all_cases.values() + ) + allowed = total - blocked + + print(f"\n{'='*60}") + print("Singapore PDPA Guardrail Test Summary") + print(f"{'='*60}") + print(f"Total test cases : {total}") + print(f"Expected BLOCK : {blocked} ({blocked/total*100:.1f}%)") + print(f"Expected ALLOW : {allowed} ({allowed/total*100:.1f}%)") + print(f"{'='*60}") + for name, cases in all_cases.items(): + b = sum(1 for _, e, _ in cases if e == "BLOCK") + a = len(cases) - b + print(f" {name:35s} BLOCK={b:2d} ALLOW={a:2d}") + print(f"{'='*60}\n") + + +if __name__ == "__main__": + pytest.main([__file__, "-v", "-s"]) diff --git a/tests/local_testing/test_pass_through_endpoints.py b/tests/local_testing/test_pass_through_endpoints.py index 44368be77a..cf38e54ddb 100644 --- a/tests/local_testing/test_pass_through_endpoints.py +++ b/tests/local_testing/test_pass_through_endpoints.py @@ -223,22 +223,17 @@ async def test_pass_through_endpoint_rpm_limit( ], } - # Make a request to the pass-through endpoint - tasks = [] + # Make requests sequentially to avoid race conditions in rate limiter + # Concurrent requests can slip through before the counter is updated + responses = [] for mock_api_key in mock_api_keys: for _ in range(requests_to_make): - task = asyncio.get_running_loop().run_in_executor( - None, - partial( - client.post, - "/v1/rerank", - json=_json_data, - headers={"Authorization": "Bearer {}".format(mock_api_key)}, - ), + response = client.post( + "/v1/rerank", + json=_json_data, + headers={"Authorization": "Bearer {}".format(mock_api_key)}, ) - tasks.append(task) - - responses = await asyncio.gather(*tasks) + responses.append(response) if num_users == 1: status_codes = sorted([response.status_code for response in responses]) diff --git a/tests/proxy_unit_tests/test_auth_checks.py b/tests/proxy_unit_tests/test_auth_checks.py index 5d63742c10..c92ec61b9b 100644 --- a/tests/proxy_unit_tests/test_auth_checks.py +++ b/tests/proxy_unit_tests/test_auth_checks.py @@ -261,6 +261,132 @@ async def test_can_key_call_model_wildcard_access(key_models, model, expect_to_w print(e) +@pytest.mark.parametrize( + "key_models, model, expect_to_work", + [ + # After a cost-map reload, add_known_models() updates anthropic_models so + # the anthropic/* wildcard can match a newly-added Anthropic model. + (["anthropic/*"], "claude-brand-new-model-reload-test", True), + # Wrong provider wildcard must still be denied even after reload. + (["openai/*"], "claude-brand-new-model-reload-test", False), + ], +) +@pytest.mark.asyncio +async def test_wildcard_access_after_cost_map_reload(key_models, model, expect_to_work): + """ + Regression test: after a cost-map hot-reload, calling + add_known_models(model_cost_map=new_map) must update litellm.anthropic_models + so that the anthropic/* wildcard correctly grants (or denies) access to + newly-added models. + + Root cause: both reload paths in proxy_server.py only updated + litellm.model_cost but never re-ran add_known_models(), so the provider sets + stayed stale and wildcard matching failed for new models. + + Fix: each reload now calls litellm.add_known_models(model_cost_map=new_map) + with the fetched map passed explicitly to avoid any reference ambiguity. + """ + from litellm.proxy.auth.auth_checks import can_key_call_model + + # Build a new cost map that includes the brand-new model — exactly what + # proxy_server.py receives from get_model_cost_map() during a reload. + new_cost_map = dict(litellm.model_cost) + new_cost_map[model] = { + "litellm_provider": "anthropic", + "max_tokens": 8192, + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + } + + original_model_cost = litellm.model_cost + litellm.model_cost = new_cost_map + + # Confirm the model is NOT yet in the provider set before reload propagation. + assert model not in litellm.anthropic_models + + # Simulate what proxy_server.py now does after every reload. + litellm.add_known_models(model_cost_map=new_cost_map) + + # After add_known_models(), the model must be in the set. + assert model in litellm.anthropic_models + + llm_model_list = [ + { + "model_name": "anthropic/*", + "litellm_params": {"model": "anthropic/*", "api_key": "test-api-key"}, + "model_info": {"id": "test-id-anthropic-wildcard", "db_model": False}, + }, + { + "model_name": "openai/*", + "litellm_params": {"model": "openai/*", "api_key": "test-api-key"}, + "model_info": {"id": "test-id-openai-wildcard", "db_model": False}, + }, + ] + router = litellm.Router(model_list=llm_model_list) + user_api_key_object = UserAPIKeyAuth(models=key_models) + + try: + if expect_to_work: + await can_key_call_model( + model=model, + llm_model_list=llm_model_list, + valid_token=user_api_key_object, + llm_router=router, + ) + else: + with pytest.raises(Exception): + await can_key_call_model( + model=model, + llm_model_list=llm_model_list, + valid_token=user_api_key_object, + llm_router=router, + ) + finally: + litellm.model_cost = original_model_cost + litellm.anthropic_models.discard(model) + + +@pytest.mark.asyncio +async def test_add_known_models_explicit_map_updates_provider_sets(): + """ + Regression test: after a cost-map hot-reload, calling + add_known_models(model_cost_map=new_map) with the new map passed explicitly + must add any new provider models to the correct provider sets so that + wildcard access checks (anthropic/*, openai/*, …) work immediately. + + This covers the proxy_server.py fix where both reload paths now call + litellm.add_known_models(model_cost_map=new_model_cost_map) instead of + relying on the module-level model_cost being up to date. + """ + fake_new_model = "claude-brand-new-explicit-map-test" + + # Baseline: the model must not be in the sets before we do anything. + assert fake_new_model not in litellm.anthropic_models + + new_cost_map = dict(litellm.model_cost) + new_cost_map[fake_new_model] = { + "litellm_provider": "anthropic", + "max_tokens": 8192, + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + } + + # Simulate what proxy_server.py does on reload. + original_model_cost = litellm.model_cost + litellm.model_cost = new_cost_map + litellm.add_known_models(model_cost_map=new_cost_map) + + try: + assert fake_new_model in litellm.anthropic_models, ( + "add_known_models(model_cost_map=...) did not add the new model to " + "litellm.anthropic_models — wildcard access checks would fail." + ) + finally: + # Clean up: restore original state. + litellm.model_cost = original_model_cost + litellm.anthropic_models.discard(fake_new_model) + + @pytest.mark.asyncio async def test_is_valid_fallback_model(): from litellm.proxy.auth.auth_checks import is_valid_fallback_model diff --git a/tests/proxy_unit_tests/test_blog_posts_endpoint.py b/tests/proxy_unit_tests/test_blog_posts_endpoint.py new file mode 100644 index 0000000000..0f93f6f80c --- /dev/null +++ b/tests/proxy_unit_tests/test_blog_posts_endpoint.py @@ -0,0 +1,80 @@ +"""Tests for the /public/litellm_blog_posts endpoint.""" +from unittest.mock import patch + +import pytest +from fastapi.testclient import TestClient + +SAMPLE_POSTS = [ + { + "title": "Test Post", + "description": "A test post.", + "date": "2026-01-01", + "url": "https://www.litellm.ai/blog/test", + } +] + + +@pytest.fixture +def client(): + """Create a TestClient with just the public_endpoints router.""" + from fastapi import FastAPI + + from litellm.proxy.public_endpoints.public_endpoints import router + + app = FastAPI() + app.include_router(router) + return TestClient(app) + + +def test_get_blog_posts_returns_response_shape(client): + with patch( + "litellm.proxy.public_endpoints.public_endpoints.get_blog_posts", + return_value=SAMPLE_POSTS, + ): + response = client.get("/public/litellm_blog_posts") + + assert response.status_code == 200 + data = response.json() + assert "posts" in data + assert len(data["posts"]) == 1 + post = data["posts"][0] + assert post["title"] == "Test Post" + assert post["description"] == "A test post." + assert post["date"] == "2026-01-01" + assert post["url"] == "https://www.litellm.ai/blog/test" + + +def test_get_blog_posts_limits_to_five(client): + """Endpoint returns at most 5 posts.""" + many_posts = [ + { + "title": f"Post {i}", + "description": "desc", + "date": "2026-01-01", + "url": f"https://www.litellm.ai/blog/{i}", + } + for i in range(10) + ] + + with patch( + "litellm.proxy.public_endpoints.public_endpoints.get_blog_posts", + return_value=many_posts, + ): + response = client.get("/public/litellm_blog_posts") + + assert response.status_code == 200 + assert len(response.json()["posts"]) == 5 + + +def test_get_blog_posts_returns_local_backup_on_failure(client): + """Endpoint returns local backup (non-empty list) when fetcher fails.""" + with patch( + "litellm.proxy.public_endpoints.public_endpoints.get_blog_posts", + side_effect=Exception("fetch failed"), + ): + response = client.get("/public/litellm_blog_posts") + + # Should not 500 — returns local backup + assert response.status_code == 200 + assert "posts" in response.json() + assert len(response.json()["posts"]) > 0 diff --git a/tests/proxy_unit_tests/test_get_favicon.py b/tests/proxy_unit_tests/test_get_favicon.py new file mode 100644 index 0000000000..f17787e740 --- /dev/null +++ b/tests/proxy_unit_tests/test_get_favicon.py @@ -0,0 +1,75 @@ +import os +import sys +from unittest import mock + +sys.path.insert(0, os.path.abspath("../..")) + +import httpx +import pytest + +from litellm.proxy.proxy_server import app + + +@pytest.mark.asyncio +async def test_get_favicon_default(): + """Test that get_favicon returns the default favicon when no URL set.""" + os.environ.pop("LITELLM_FAVICON_URL", None) + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://testserver" + ) as ac: + response = await ac.get("/get_favicon") + + assert response.status_code in [200, 404] + if response.status_code == 200: + assert response.headers["content-type"] == "image/x-icon" + + +@pytest.mark.asyncio +async def test_get_favicon_with_custom_url(): + """Test that get_favicon fetches from a custom URL.""" + os.environ["LITELLM_FAVICON_URL"] = "https://example.com/favicon.ico" + + mock_response = mock.Mock() + mock_response.status_code = 200 + mock_response.content = b"\x00\x00\x01\x00" + mock_response.headers = {"content-type": "image/x-icon"} + + try: + with mock.patch( + "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.get" + ) as mock_get: + mock_get.return_value = mock_response + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://testserver", + ) as ac: + response = await ac.get("/get_favicon") + + assert response.status_code == 200 + assert response.headers["content-type"] == "image/x-icon" + finally: + os.environ.pop("LITELLM_FAVICON_URL", None) + + +@pytest.mark.asyncio +async def test_get_favicon_url_error_fallback(): + """Test that get_favicon falls back to default on error.""" + os.environ["LITELLM_FAVICON_URL"] = "https://invalid.com/favicon.ico" + + try: + with mock.patch( + "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.get" + ) as mock_get: + mock_get.side_effect = httpx.ConnectError("unreachable") + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://testserver", + ) as ac: + response = await ac.get("/get_favicon") + + assert response.status_code in [200, 404] + finally: + os.environ.pop("LITELLM_FAVICON_URL", None) diff --git a/tests/test_litellm/caching/test_qdrant_semantic_cache.py b/tests/test_litellm/caching/test_qdrant_semantic_cache.py index e7d934bf0e..fe6830693d 100644 --- a/tests/test_litellm/caching/test_qdrant_semantic_cache.py +++ b/tests/test_litellm/caching/test_qdrant_semantic_cache.py @@ -408,4 +408,131 @@ async def test_qdrant_semantic_cache_async_set_cache(): ) # Verify async upsert was called - qdrant_cache.async_client.put.assert_called() \ No newline at end of file + qdrant_cache.async_client.put.assert_called() + +def test_qdrant_semantic_cache_custom_vector_size(): + """ + Test that QdrantSemanticCache uses a custom vector_size when creating a new collection. + Verifies that the vector size passed to the constructor is used in the Qdrant collection + creation payload instead of the default 1536. + """ + with patch("litellm.llms.custom_httpx.http_handler._get_httpx_client") as mock_sync_client, \ + patch("litellm.llms.custom_httpx.http_handler.get_async_httpx_client") as mock_async_client: + + # Mock the collection does NOT exist (so it will be created) + mock_exists_response = MagicMock() + mock_exists_response.status_code = 200 + mock_exists_response.json.return_value = {"result": {"exists": False}} + + # Mock the collection creation response + mock_create_response = MagicMock() + mock_create_response.status_code = 200 + mock_create_response.json.return_value = {"result": True} + + # Mock the collection details response after creation + mock_details_response = MagicMock() + mock_details_response.status_code = 200 + mock_details_response.json.return_value = {"result": {"status": "ok"}} + + mock_sync_client_instance = MagicMock() + mock_sync_client_instance.get.side_effect = [mock_exists_response, mock_details_response] + mock_sync_client_instance.put.return_value = mock_create_response + mock_sync_client.return_value = mock_sync_client_instance + + from litellm.caching.qdrant_semantic_cache import QdrantSemanticCache + + # Initialize with custom vector_size of 768 + qdrant_cache = QdrantSemanticCache( + collection_name="test_collection_768", + qdrant_api_base="http://test.qdrant.local", + qdrant_api_key="test_key", + similarity_threshold=0.8, + vector_size=768, + ) + + # Verify the vector_size attribute is set correctly + assert qdrant_cache.vector_size == 768 + + # Verify the PUT call to create the collection used vector_size=768 + put_call = mock_sync_client_instance.put.call_args + assert put_call is not None + create_payload = put_call.kwargs.get("json") or put_call[1].get("json") + assert create_payload["vectors"]["size"] == 768 + assert create_payload["vectors"]["distance"] == "Cosine" + + +def test_qdrant_semantic_cache_default_vector_size(): + """ + Test that QdrantSemanticCache defaults to QDRANT_VECTOR_SIZE (1536) when vector_size + is not provided, and stores it as self.vector_size. + """ + with patch("litellm.llms.custom_httpx.http_handler._get_httpx_client") as mock_sync_client, \ + patch("litellm.llms.custom_httpx.http_handler.get_async_httpx_client") as mock_async_client: + + # Mock the collection exists check + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"result": {"exists": True}} + + mock_sync_client_instance = MagicMock() + mock_sync_client_instance.get.return_value = mock_response + mock_sync_client.return_value = mock_sync_client_instance + + from litellm.caching.qdrant_semantic_cache import QdrantSemanticCache + from litellm.constants import QDRANT_VECTOR_SIZE + + # Initialize without vector_size + qdrant_cache = QdrantSemanticCache( + collection_name="test_collection", + qdrant_api_base="http://test.qdrant.local", + qdrant_api_key="test_key", + similarity_threshold=0.8, + ) + + # Verify it falls back to the default QDRANT_VECTOR_SIZE constant + assert qdrant_cache.vector_size == QDRANT_VECTOR_SIZE + + +def test_qdrant_semantic_cache_large_vector_size(): + """ + Test that QdrantSemanticCache supports large embedding dimensions (e.g. 4096, 8192) + for models like Stella, bge-en-icl, etc. + """ + with patch("litellm.llms.custom_httpx.http_handler._get_httpx_client") as mock_sync_client, \ + patch("litellm.llms.custom_httpx.http_handler.get_async_httpx_client") as mock_async_client: + + # Mock the collection does NOT exist (so it will be created) + mock_exists_response = MagicMock() + mock_exists_response.status_code = 200 + mock_exists_response.json.return_value = {"result": {"exists": False}} + + mock_create_response = MagicMock() + mock_create_response.status_code = 200 + mock_create_response.json.return_value = {"result": True} + + mock_details_response = MagicMock() + mock_details_response.status_code = 200 + mock_details_response.json.return_value = {"result": {"status": "ok"}} + + mock_sync_client_instance = MagicMock() + mock_sync_client_instance.get.side_effect = [mock_exists_response, mock_details_response] + mock_sync_client_instance.put.return_value = mock_create_response + mock_sync_client.return_value = mock_sync_client_instance + + from litellm.caching.qdrant_semantic_cache import QdrantSemanticCache + + # Initialize with a large vector_size of 4096 + qdrant_cache = QdrantSemanticCache( + collection_name="test_collection_4096", + qdrant_api_base="http://test.qdrant.local", + qdrant_api_key="test_key", + similarity_threshold=0.8, + vector_size=4096, + ) + + assert qdrant_cache.vector_size == 4096 + + # Verify the collection was created with 4096 + put_call = mock_sync_client_instance.put.call_args + create_payload = put_call.kwargs.get("json") or put_call[1].get("json") + assert create_payload["vectors"]["size"] == 4096 diff --git a/tests/test_litellm/integrations/SlackAlerting/test_slack_alerting_digest.py b/tests/test_litellm/integrations/SlackAlerting/test_slack_alerting_digest.py new file mode 100644 index 0000000000..eeb3640dd8 --- /dev/null +++ b/tests/test_litellm/integrations/SlackAlerting/test_slack_alerting_digest.py @@ -0,0 +1,235 @@ +""" +Tests for Slack Alert Digest Mode + +Verifies that: +- Digest mode suppresses duplicate alerts within the interval +- Digest summary is emitted after the interval expires +- Non-digest alert types are unaffected +- Different (model, api_base) combos get separate digest entries +- The digest message format includes Start/End timestamps and Count +""" + +import os +import sys +import unittest +from datetime import datetime, timedelta + +sys.path.insert(0, os.path.abspath("../../..")) + +from litellm.integrations.SlackAlerting.slack_alerting import SlackAlerting +from litellm.proxy._types import AlertType +from litellm.types.integrations.slack_alerting import AlertTypeConfig + + +class TestDigestMode(unittest.IsolatedAsyncioTestCase): + """Test digest mode in SlackAlerting.send_alert().""" + + def setUp(self): + os.environ["SLACK_WEBHOOK_URL"] = "https://hooks.slack.com/test" + self.slack_alerting = SlackAlerting( + alerting=["slack"], + alert_type_config={ + "llm_requests_hanging": {"digest": True, "digest_interval": 60}, + }, + ) + # Prevent periodic flush from starting + self.slack_alerting.periodic_started = True + + def tearDown(self): + os.environ.pop("SLACK_WEBHOOK_URL", None) + + async def test_digest_suppresses_duplicate_alerts(self): + """Sending the same alert type + model + api_base multiple times should NOT add to log_queue.""" + message = "`Requests are hanging`\nRequest Model: `gemini-2.5-flash`\nAPI Base: `None`" + + for _ in range(5): + await self.slack_alerting.send_alert( + message=message, + level="Medium", + alert_type=AlertType.llm_requests_hanging, + alerting_metadata={}, + request_model="gemini-2.5-flash", + api_base="None", + ) + + # No messages should be in the log queue - they're all in digest_buckets + self.assertEqual(len(self.slack_alerting.log_queue), 0) + # Should have exactly 1 digest bucket entry + self.assertEqual(len(self.slack_alerting.digest_buckets), 1) + # Count should be 5 + bucket = list(self.slack_alerting.digest_buckets.values())[0] + self.assertEqual(bucket["count"], 5) + + async def test_different_models_get_separate_digests(self): + """Different models should produce separate digest entries.""" + await self.slack_alerting.send_alert( + message="`Requests are hanging`", + level="Medium", + alert_type=AlertType.llm_requests_hanging, + alerting_metadata={}, + request_model="gemini-2.5-flash", + api_base="None", + ) + await self.slack_alerting.send_alert( + message="`Requests are hanging`", + level="Medium", + alert_type=AlertType.llm_requests_hanging, + alerting_metadata={}, + request_model="gpt-4", + api_base="https://api.openai.com", + ) + + self.assertEqual(len(self.slack_alerting.digest_buckets), 2) + + async def test_non_digest_alert_goes_to_queue(self): + """Alert types without digest enabled should go straight to the log queue.""" + message = "Budget exceeded" + + await self.slack_alerting.send_alert( + message=message, + level="High", + alert_type=AlertType.budget_alerts, + alerting_metadata={}, + ) + + # Should be in log_queue, not digest_buckets + self.assertGreater(len(self.slack_alerting.log_queue), 0) + self.assertEqual(len(self.slack_alerting.digest_buckets), 0) + + async def test_flush_digest_buckets_emits_after_interval(self): + """After the digest interval expires, _flush_digest_buckets should emit a summary.""" + message = "`Requests are hanging`\nRequest Model: `gemini-2.5-flash`\nAPI Base: `None`" + + # Send 3 alerts + for _ in range(3): + await self.slack_alerting.send_alert( + message=message, + level="Medium", + alert_type=AlertType.llm_requests_hanging, + alerting_metadata={}, + request_model="gemini-2.5-flash", + api_base="None", + ) + + self.assertEqual(len(self.slack_alerting.log_queue), 0) + self.assertEqual(len(self.slack_alerting.digest_buckets), 1) + + # Manually backdate the start_time to simulate interval expiration + key = list(self.slack_alerting.digest_buckets.keys())[0] + self.slack_alerting.digest_buckets[key]["start_time"] = datetime.now() - timedelta(seconds=120) + + # Flush digest buckets + await self.slack_alerting._flush_digest_buckets() + + # Digest bucket should be cleared + self.assertEqual(len(self.slack_alerting.digest_buckets), 0) + # And a summary message should be in the log queue + self.assertEqual(len(self.slack_alerting.log_queue), 1) + payload_text = self.slack_alerting.log_queue[0]["payload"]["text"] + self.assertIn("(Digest)", payload_text) + self.assertIn("Count: `3`", payload_text) + self.assertIn("Start:", payload_text) + self.assertIn("End:", payload_text) + + async def test_flush_does_not_emit_before_interval(self): + """Digest buckets should NOT be flushed before the interval expires.""" + message = "`Requests are hanging`" + + await self.slack_alerting.send_alert( + message=message, + level="Medium", + alert_type=AlertType.llm_requests_hanging, + alerting_metadata={}, + request_model="gemini-2.5-flash", + ) + + # Flush immediately (interval hasn't expired) + await self.slack_alerting._flush_digest_buckets() + + # Bucket should still be there + self.assertEqual(len(self.slack_alerting.digest_buckets), 1) + self.assertEqual(len(self.slack_alerting.log_queue), 0) + + async def test_digest_message_format(self): + """Verify the digest summary message format.""" + message = "`Requests are hanging - 600s+ request time`\nRequest Model: `gemini-2.5-flash`\nAPI Base: `None`" + + await self.slack_alerting.send_alert( + message=message, + level="Medium", + alert_type=AlertType.llm_requests_hanging, + alerting_metadata={}, + request_model="gemini-2.5-flash", + api_base="None", + ) + + # Backdate and flush + key = list(self.slack_alerting.digest_buckets.keys())[0] + self.slack_alerting.digest_buckets[key]["start_time"] = datetime.now() - timedelta(seconds=120) + + await self.slack_alerting._flush_digest_buckets() + + payload_text = self.slack_alerting.log_queue[0]["payload"]["text"] + self.assertIn("Alert type: `llm_requests_hanging` (Digest)", payload_text) + self.assertIn("Level: `Medium`", payload_text) + self.assertIn("Count: `1`", payload_text) + self.assertIn("`Requests are hanging - 600s+ request time`", payload_text) + + async def test_digest_without_model_groups_by_alert_type_only(self): + """When request_model is not provided, alerts group by alert type alone.""" + for _ in range(3): + await self.slack_alerting.send_alert( + message="Some hanging request", + level="Medium", + alert_type=AlertType.llm_requests_hanging, + alerting_metadata={}, + ) + + # All 3 should be in the same bucket (empty model and api_base) + self.assertEqual(len(self.slack_alerting.digest_buckets), 1) + bucket = list(self.slack_alerting.digest_buckets.values())[0] + self.assertEqual(bucket["count"], 3) + self.assertEqual(bucket["request_model"], "") + self.assertEqual(bucket["api_base"], "") + + +class TestAlertTypeConfig(unittest.TestCase): + """Test AlertTypeConfig model and initialization.""" + + def test_default_values(self): + config = AlertTypeConfig() + self.assertFalse(config.digest) + self.assertEqual(config.digest_interval, 86400) + + def test_custom_values(self): + config = AlertTypeConfig(digest=True, digest_interval=3600) + self.assertTrue(config.digest) + self.assertEqual(config.digest_interval, 3600) + + def test_slack_alerting_init_with_config(self): + sa = SlackAlerting( + alerting=["slack"], + alert_type_config={ + "llm_requests_hanging": {"digest": True, "digest_interval": 7200}, + "llm_too_slow": {"digest": True}, + }, + ) + self.assertIn("llm_requests_hanging", sa.alert_type_config) + self.assertIn("llm_too_slow", sa.alert_type_config) + self.assertTrue(sa.alert_type_config["llm_requests_hanging"].digest) + self.assertEqual(sa.alert_type_config["llm_requests_hanging"].digest_interval, 7200) + self.assertEqual(sa.alert_type_config["llm_too_slow"].digest_interval, 86400) + + def test_update_values_with_config(self): + sa = SlackAlerting(alerting=["slack"]) + self.assertEqual(len(sa.alert_type_config), 0) + + sa.update_values( + alert_type_config={"llm_exceptions": {"digest": True, "digest_interval": 1800}}, + ) + self.assertIn("llm_exceptions", sa.alert_type_config) + self.assertTrue(sa.alert_type_config["llm_exceptions"].digest) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_litellm/interactions/test_openapi_compliance.py b/tests/test_litellm/interactions/test_openapi_compliance.py index 5b490777f0..5187f733a3 100644 --- a/tests/test_litellm/interactions/test_openapi_compliance.py +++ b/tests/test_litellm/interactions/test_openapi_compliance.py @@ -147,8 +147,7 @@ class TestResponseCompliance: """Verify status enum values match spec.""" schema = spec_dict["components"]["schemas"]["CreateModelInteractionParams"] status_prop = schema["properties"]["status"] - - expected_statuses = ["UNSPECIFIED", "IN_PROGRESS", "REQUIRES_ACTION", "COMPLETED", "FAILED", "CANCELLED"] + expected_statuses = ["UNSPECIFIED", "IN_PROGRESS", "REQUIRES_ACTION", "COMPLETED", "FAILED", "CANCELLED", "INCOMPLETE"] assert status_prop["enum"] == expected_statuses print(f"✓ Status enum values: {expected_statuses}") diff --git a/tests/test_litellm/litellm_core_utils/test_duration_parser.py b/tests/test_litellm/litellm_core_utils/test_duration_parser.py index cebad07c07..52316d4d97 100644 --- a/tests/test_litellm/litellm_core_utils/test_duration_parser.py +++ b/tests/test_litellm/litellm_core_utils/test_duration_parser.py @@ -91,7 +91,9 @@ class TestStandardizedResetTime(unittest.TestCase): # Test Bangkok timezone (UTC+7): 5:30 AM next day, so next reset is midnight the day after bangkok = ZoneInfo("Asia/Bangkok") bangkok_expected = datetime(2023, 5, 17, 0, 0, 0, tzinfo=bangkok) - bangkok_result = get_next_standardized_reset_time("1d", base_time, "Asia/Bangkok") + bangkok_result = get_next_standardized_reset_time( + "1d", base_time, "Asia/Bangkok" + ) self.assertEqual(bangkok_result, bangkok_expected) def test_edge_cases(self): @@ -125,6 +127,62 @@ class TestStandardizedResetTime(unittest.TestCase): ) self.assertEqual(invalid_tz_result, invalid_tz_expected) + def test_iana_timezones_previously_unsupported(self): + """Test IANA timezones that were previously unsupported by the hardcoded map.""" + # Base time: 2023-05-15 15:00:00 UTC + base_time = datetime(2023, 5, 15, 15, 0, 0, tzinfo=timezone.utc) + + # Asia/Tokyo (UTC+9): 15:00 UTC = 00:00 JST May 16, exactly on midnight boundary → next day + tokyo = ZoneInfo("Asia/Tokyo") + tokyo_expected = datetime(2023, 5, 17, 0, 0, 0, tzinfo=tokyo) + tokyo_result = get_next_standardized_reset_time( + "1d", base_time, "Asia/Tokyo" + ) + self.assertEqual(tokyo_result, tokyo_expected) + + # Australia/Sydney (UTC+10): 2023-05-16 01:00 AEST + sydney = ZoneInfo("Australia/Sydney") + # At 15:00 UTC it's 01:00 AEST May 16 → next midnight is May 17 00:00 AEST + sydney_expected = datetime(2023, 5, 17, 0, 0, 0, tzinfo=sydney) + sydney_result = get_next_standardized_reset_time( + "1d", base_time, "Australia/Sydney" + ) + self.assertEqual(sydney_result, sydney_expected) + + # America/Chicago (UTC-5): at 15:00 UTC it's 10:00 CDT → next midnight is May 16 00:00 CDT + chicago = ZoneInfo("America/Chicago") + chicago_expected = datetime(2023, 5, 16, 0, 0, 0, tzinfo=chicago) + chicago_result = get_next_standardized_reset_time( + "1d", base_time, "America/Chicago" + ) + self.assertEqual(chicago_result, chicago_expected) + + def test_dst_fall_back(self): + """Test DST fall-back transition (clocks go back 1 hour).""" + # US/Eastern DST ends first Sunday of November 2023 (Nov 5) + # At 2023-11-05 05:30 UTC = 01:30 EDT (before fall-back) + # After fall-back at 06:00 UTC = 01:00 EST + pre_fallback = datetime(2023, 11, 5, 5, 30, 0, tzinfo=timezone.utc) + eastern = ZoneInfo("US/Eastern") + + # Daily reset: next midnight should be Nov 6 00:00 EST + expected = datetime(2023, 11, 6, 0, 0, 0, tzinfo=eastern) + result = get_next_standardized_reset_time("1d", pre_fallback, "US/Eastern") + self.assertEqual(result, expected) + + def test_dst_spring_forward(self): + """Test DST spring-forward transition (clocks go forward 1 hour).""" + # US/Eastern DST starts second Sunday of March 2023 (Mar 12) + # At 2023-03-12 06:30 UTC = 01:30 EST (before spring-forward) + # After spring-forward at 07:00 UTC = 03:00 EDT + pre_spring = datetime(2023, 3, 12, 6, 30, 0, tzinfo=timezone.utc) + eastern = ZoneInfo("US/Eastern") + + # Daily reset: next midnight should be Mar 13 00:00 EDT + expected = datetime(2023, 3, 13, 0, 0, 0, tzinfo=eastern) + result = get_next_standardized_reset_time("1d", pre_spring, "US/Eastern") + self.assertEqual(result, expected) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py index 40ecfdd305..071cd277c6 100644 --- a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py +++ b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py @@ -2830,69 +2830,84 @@ def test_fast_mode_usage_calculation(): def test_fast_mode_cost_calculation(): """ - Test that fast mode correctly prepends 'fast/' to model name for pricing lookup. + Test that fast mode applies the 'fast' multiplier from provider_specific_entry + on top of the base model cost (1.1x for claude-opus-4-6). """ - from unittest.mock import patch + from unittest.mock import MagicMock, patch from litellm.llms.anthropic.cost_calculation import cost_per_token from litellm.types.utils import Usage - # Mock the generic_cost_per_token to verify correct model name is passed - with patch('litellm.llms.anthropic.cost_calculation.generic_cost_per_token') as mock_cost: - mock_cost.return_value = (0.03, 0.15) # $30 and $150 per MTok + base_prompt = 0.005 + base_completion = 0.025 + + with patch( + "litellm.llms.anthropic.cost_calculation.generic_cost_per_token" + ) as mock_cost, patch("litellm.get_model_info") as mock_info: + mock_cost.return_value = (base_prompt, base_completion) + mock_info.return_value = {"provider_specific_entry": {"fast": 1.1, "us": 1.1}} - # Test fast mode usage_fast = Usage( prompt_tokens=1000, completion_tokens=1000, - speed="fast" + speed="fast", ) prompt_cost, completion_cost = cost_per_token( model="claude-opus-4-6", - usage=usage_fast + usage=usage_fast, ) - # Verify that generic_cost_per_token was called with "fast/claude-opus-4-6" + # generic_cost_per_token called with the plain base model name mock_cost.assert_called_once() - call_args = mock_cost.call_args - assert call_args[1]['model'] == "fast/claude-opus-4-6" - assert call_args[1]['custom_llm_provider'] == "anthropic" + assert mock_cost.call_args[1]["model"] == "claude-opus-4-6" + assert mock_cost.call_args[1]["custom_llm_provider"] == "anthropic" + + # 1.1x multiplier applied + assert abs(prompt_cost - base_prompt * 1.1) < 1e-10 + assert abs(completion_cost - base_completion * 1.1) < 1e-10 def test_fast_mode_with_inference_geo(): """ - Test that fast mode works correctly with inference_geo prefix. - Expected format: fast/us/claude-opus-4-6 + Test that fast mode + inference_geo both apply their multipliers from + provider_specific_entry (1.1 * 1.1 = 1.21x for claude-opus-4-6). """ from unittest.mock import patch from litellm.llms.anthropic.cost_calculation import cost_per_token from litellm.types.utils import Usage - # Mock the generic_cost_per_token to verify correct model name is passed - with patch('litellm.llms.anthropic.cost_calculation.generic_cost_per_token') as mock_cost: - mock_cost.return_value = (0.03, 0.15) + base_prompt = 0.005 + base_completion = 0.025 + + with patch( + "litellm.llms.anthropic.cost_calculation.generic_cost_per_token" + ) as mock_cost, patch("litellm.get_model_info") as mock_info: + mock_cost.return_value = (base_prompt, base_completion) + mock_info.return_value = {"provider_specific_entry": {"fast": 1.1, "us": 1.1}} - # Test with both speed and inference_geo usage = Usage( prompt_tokens=1000, completion_tokens=1000, speed="fast", - inference_geo="us" + inference_geo="us", ) - # This should look up "fast/us/claude-opus-4-6" in pricing prompt_cost, completion_cost = cost_per_token( model="claude-opus-4-6", - usage=usage + usage=usage, ) - # Verify that generic_cost_per_token was called with "fast/us/claude-opus-4-6" + # generic_cost_per_token called with the plain base model name mock_cost.assert_called_once() - call_args = mock_cost.call_args - assert call_args[1]['model'] == "fast/us/claude-opus-4-6" - assert call_args[1]['custom_llm_provider'] == "anthropic" + assert mock_cost.call_args[1]["model"] == "claude-opus-4-6" + assert mock_cost.call_args[1]["custom_llm_provider"] == "anthropic" + + # 1.1 (fast) * 1.1 (us) = 1.21x multiplier applied + expected_multiplier = 1.1 * 1.1 + assert abs(prompt_cost - base_prompt * expected_multiplier) < 1e-10 + assert abs(completion_cost - base_completion * expected_multiplier) < 1e-10 def test_fast_mode_parameter_in_supported_params(): diff --git a/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py b/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py index 06e7253908..88cac84e43 100644 --- a/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py +++ b/tests/test_litellm/llms/bedrock/files/test_bedrock_files_transformation.py @@ -88,3 +88,223 @@ class TestBedrockFilesTransformation: print(f"\n=== Expected output written to: {expected_output_path} ===") + def test_nova_text_only_uses_converse_format(self): + """ + Test that Nova models produce Converse API format in batch modelInput. + + Verifies that: + - max_tokens is wrapped inside inferenceConfig.maxTokens + - messages use Converse content block format + - No raw OpenAI keys (max_tokens, temperature) at the top level + """ + from litellm.llms.bedrock.files.transformation import BedrockFilesConfig + + config = BedrockFilesConfig() + + openai_jsonl_content = [ + { + "custom_id": "nova-text-1", + "method": "POST", + "url": "/v1/chat/completions", + "body": { + "model": "us.amazon.nova-pro-v1:0", + "messages": [ + {"role": "user", "content": "What is the capital of France?"} + ], + "max_tokens": 50, + "temperature": 0.7, + }, + } + ] + + result = config._transform_openai_jsonl_content_to_bedrock_jsonl_content( + openai_jsonl_content + ) + + assert len(result) == 1 + record = result[0] + assert record["recordId"] == "nova-text-1" + + model_input = record["modelInput"] + + # Must have inferenceConfig with maxTokens, NOT top-level max_tokens + assert "inferenceConfig" in model_input, ( + "Nova modelInput must contain inferenceConfig" + ) + assert model_input["inferenceConfig"]["maxTokens"] == 50 + assert model_input["inferenceConfig"]["temperature"] == 0.7 + assert "max_tokens" not in model_input, ( + "max_tokens must NOT be at the top level for Nova" + ) + assert "temperature" not in model_input, ( + "temperature must NOT be at the top level for Nova" + ) + + # Must have messages + assert "messages" in model_input + + def test_nova_image_content_uses_converse_image_blocks(self): + """ + Test that image_url content blocks are converted to Bedrock Converse + image format for Nova models in batch. + + Verifies that: + - image_url blocks are converted to {"image": {"format": ..., "source": {"bytes": ...}}} + - text blocks are converted to {"text": "..."} + - No raw OpenAI image_url type remains + """ + from litellm.llms.bedrock.files.transformation import BedrockFilesConfig + + config = BedrockFilesConfig() + + # 1x1 transparent PNG + img_b64 = ( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4" + "2mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg==" + ) + + openai_jsonl_content = [ + { + "custom_id": "nova-img-1", + "method": "POST", + "url": "/v1/chat/completions", + "body": { + "model": "us.amazon.nova-pro-v1:0", + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Describe this image."}, + { + "type": "image_url", + "image_url": { + "url": "data:image/png;base64," + img_b64 + }, + }, + ], + } + ], + "max_tokens": 100, + }, + } + ] + + result = config._transform_openai_jsonl_content_to_bedrock_jsonl_content( + openai_jsonl_content + ) + + assert len(result) == 1 + model_input = result[0]["modelInput"] + + # Check inferenceConfig + assert "inferenceConfig" in model_input + assert model_input["inferenceConfig"]["maxTokens"] == 100 + assert "max_tokens" not in model_input + + # Check messages structure + messages = model_input["messages"] + assert len(messages) == 1 + content_blocks = messages[0]["content"] + + # Should have text block and image block in Converse format + has_text = False + has_image = False + for block in content_blocks: + if "text" in block: + has_text = True + if "image" in block: + has_image = True + # Verify Converse image format + assert "format" in block["image"], ( + "Image block must have format field" + ) + assert "source" in block["image"], ( + "Image block must have source field" + ) + assert "bytes" in block["image"]["source"], ( + "Image source must have bytes field" + ) + # Must NOT have OpenAI-style image_url + assert "image_url" not in block, ( + "image_url must not appear in Converse format" + ) + assert block.get("type") != "image_url", ( + "type=image_url must not appear in Converse format" + ) + + assert has_text, "Should have a text content block" + assert has_image, "Should have an image content block" + + def test_anthropic_still_works_after_nova_fix(self): + """ + Regression test: ensure Anthropic models are still correctly + transformed after the Converse API provider changes. + """ + from litellm.llms.bedrock.files.transformation import BedrockFilesConfig + + config = BedrockFilesConfig() + + openai_jsonl_content = [ + { + "custom_id": "claude-1", + "method": "POST", + "url": "/v1/chat/completions", + "body": { + "model": "us.anthropic.claude-3-5-sonnet-20240620-v1:0", + "messages": [ + {"role": "system", "content": "You are helpful."}, + {"role": "user", "content": "Hello!"}, + ], + "max_tokens": 10, + }, + } + ] + + result = config._transform_openai_jsonl_content_to_bedrock_jsonl_content( + openai_jsonl_content + ) + + assert len(result) == 1 + model_input = result[0]["modelInput"] + + # Anthropic should have anthropic_version + assert "anthropic_version" in model_input + assert "messages" in model_input + assert "max_tokens" in model_input + + def test_openai_passthrough_still_works(self): + """ + Regression test: ensure OpenAI-compatible models (e.g. gpt-oss) + still use passthrough format. + """ + from litellm.llms.bedrock.files.transformation import BedrockFilesConfig + + config = BedrockFilesConfig() + + openai_jsonl_content = [ + { + "custom_id": "openai-1", + "method": "POST", + "url": "/v1/chat/completions", + "body": { + "model": "openai.gpt-oss-120b-1:0", + "messages": [ + {"role": "user", "content": "Hello!"}, + ], + "max_tokens": 10, + }, + } + ] + + result = config._transform_openai_jsonl_content_to_bedrock_jsonl_content( + openai_jsonl_content + ) + + assert len(result) == 1 + model_input = result[0]["modelInput"] + + # OpenAI-compatible should use passthrough: max_tokens at top level + assert "messages" in model_input + assert "max_tokens" in model_input + assert model_input["max_tokens"] == 10 + diff --git a/tests/test_litellm/llms/custom_httpx/test_mock_transport.py b/tests/test_litellm/llms/custom_httpx/test_mock_transport.py new file mode 100644 index 0000000000..94d942b126 --- /dev/null +++ b/tests/test_litellm/llms/custom_httpx/test_mock_transport.py @@ -0,0 +1,116 @@ +""" +Tests for MockOpenAITransport — verifies that the mock transport produces +responses parseable by the OpenAI SDK. +""" + +import json + +import httpx +import pytest + +from litellm.llms.custom_httpx.mock_transport import MockOpenAITransport + + +# --------------------------------------------------------------------------- +# Non-streaming +# --------------------------------------------------------------------------- + + +class TestNonStreaming: + def test_sync_returns_valid_chat_completion(self): + transport = MockOpenAITransport() + request = httpx.Request( + method="POST", + url="https://api.openai.com/v1/chat/completions", + content=json.dumps({"model": "gpt-4o", "messages": [{"role": "user", "content": "hi"}]}), + ) + response = transport.handle_request(request) + assert response.status_code == 200 + + body = json.loads(response.content) + assert body["object"] == "chat.completion" + assert body["model"] == "gpt-4o" + assert body["choices"][0]["message"]["role"] == "assistant" + assert body["choices"][0]["finish_reason"] == "stop" + assert "usage" in body + + @pytest.mark.asyncio + async def test_async_returns_valid_chat_completion(self): + transport = MockOpenAITransport() + request = httpx.Request( + method="POST", + url="https://api.openai.com/v1/chat/completions", + content=json.dumps({"model": "gpt-4o-mini", "messages": [{"role": "user", "content": "hi"}]}), + ) + response = await transport.handle_async_request(request) + assert response.status_code == 200 + + body = json.loads(response.content) + assert body["object"] == "chat.completion" + assert body["model"] == "gpt-4o-mini" + + def test_model_echoed_from_request(self): + transport = MockOpenAITransport() + request = httpx.Request( + method="POST", + url="https://api.openai.com/v1/chat/completions", + content=json.dumps({"model": "my-custom-model", "messages": []}), + ) + response = transport.handle_request(request) + body = json.loads(response.content) + assert body["model"] == "my-custom-model" + + def test_unique_ids_per_response(self): + transport = MockOpenAITransport() + request = httpx.Request( + method="POST", + url="https://api.openai.com/v1/chat/completions", + content=json.dumps({"model": "gpt-4o", "messages": []}), + ) + r1 = json.loads(transport.handle_request(request).content) + r2 = json.loads(transport.handle_request(request).content) + assert r1["id"] != r2["id"] + + def test_empty_body_does_not_crash(self): + transport = MockOpenAITransport() + request = httpx.Request( + method="GET", + url="https://api.openai.com/v1/models", + content=b"", + ) + response = transport.handle_request(request) + assert response.status_code == 200 + body = json.loads(response.content) + assert body["model"] == "mock-model" + + +# --------------------------------------------------------------------------- +# Integration with httpx client +# --------------------------------------------------------------------------- + + +class TestHttpxClientIntegration: + def test_sync_client_get(self): + """Verify the transport works when wired into an httpx.Client.""" + client = httpx.Client(transport=MockOpenAITransport()) + response = client.post( + "https://api.openai.com/v1/chat/completions", + json={"model": "gpt-4o", "messages": [{"role": "user", "content": "test"}]}, + ) + assert response.status_code == 200 + body = response.json() + assert body["object"] == "chat.completion" + client.close() + + @pytest.mark.asyncio + async def test_async_client_get(self): + """Verify the transport works when wired into an httpx.AsyncClient.""" + client = httpx.AsyncClient(transport=MockOpenAITransport()) + response = await client.post( + "https://api.openai.com/v1/chat/completions", + json={"model": "gpt-4o", "messages": [{"role": "user", "content": "test"}]}, + ) + assert response.status_code == 200 + body = response.json() + assert body["object"] == "chat.completion" + await client.aclose() diff --git a/tests/test_litellm/llms/ollama/test_ollama_model_info.py b/tests/test_litellm/llms/ollama/test_ollama_model_info.py index 7eef15cd4d..5585e9d1e0 100644 --- a/tests/test_litellm/llms/ollama/test_ollama_model_info.py +++ b/tests/test_litellm/llms/ollama/test_ollama_model_info.py @@ -138,6 +138,88 @@ class TestOllamaModelInfo: assert models == ["ollama/llama2"] +class TestOllamaGetModelInfo: + """Tests for OllamaConfig.get_model_info() api_base threading and graceful fallback.""" + + def test_get_model_info_uses_provided_api_base(self, monkeypatch): + """When api_base is passed, get_model_info should use it instead of env var or default.""" + from litellm.llms.ollama.completion.transformation import OllamaConfig + + captured_urls = [] + + def mock_post(url, json, headers=None): + captured_urls.append(url) + resp = DummyResponse( + {"template": "{{ .System }} tools {{ .Prompt }}", "model_info": {"context_length": 4096}}, + status_code=200, + ) + return resp + + monkeypatch.setattr("litellm.module_level_client.post", mock_post) + + config = OllamaConfig() + result = config.get_model_info("llama3", api_base="http://my-remote-server:11434") + + assert captured_urls[0] == "http://my-remote-server:11434/api/show" + assert result["max_tokens"] == 4096 + + def test_get_model_info_falls_back_to_env_var(self, monkeypatch): + """When no api_base is passed, should fall back to OLLAMA_API_BASE env var.""" + from litellm.llms.ollama.completion.transformation import OllamaConfig + + captured_urls = [] + + def mock_post(url, json, headers=None): + captured_urls.append(url) + return DummyResponse({"template": "", "model_info": {}}, status_code=200) + + monkeypatch.setattr("litellm.module_level_client.post", mock_post) + monkeypatch.setenv("OLLAMA_API_BASE", "http://env-server:11434") + + config = OllamaConfig() + config.get_model_info("llama3") + + assert captured_urls[0] == "http://env-server:11434/api/show" + + def test_get_model_info_graceful_fallback_on_connection_error(self, monkeypatch): + """When the Ollama server is unreachable, should return defaults instead of raising.""" + from litellm.llms.ollama.completion.transformation import OllamaConfig + + def mock_post(url, json, headers=None): + raise ConnectionError("Connection refused") + + monkeypatch.setattr("litellm.module_level_client.post", mock_post) + monkeypatch.delenv("OLLAMA_API_BASE", raising=False) + + config = OllamaConfig() + result = config.get_model_info("llama3", api_base="http://unreachable:11434") + + assert result["key"] == "llama3" + assert result["litellm_provider"] == "ollama" + assert result["input_cost_per_token"] == 0.0 + assert result["output_cost_per_token"] == 0.0 + assert result["max_tokens"] is None + + def test_get_model_info_strips_ollama_prefix(self, monkeypatch): + """Should strip 'ollama/' or 'ollama_chat/' prefix from model name.""" + from litellm.llms.ollama.completion.transformation import OllamaConfig + + captured_json = [] + + def mock_post(url, json, headers=None): + captured_json.append(json) + return DummyResponse({"template": "", "model_info": {}}, status_code=200) + + monkeypatch.setattr("litellm.module_level_client.post", mock_post) + + config = OllamaConfig() + config.get_model_info("ollama/llama3", api_base="http://localhost:11434") + assert captured_json[0]["name"] == "llama3" + + config.get_model_info("ollama_chat/llama3", api_base="http://localhost:11434") + assert captured_json[1]["name"] == "llama3" + + class TestOllamaAuthHeaders: """Tests for Ollama authentication header handling in completion calls.""" diff --git a/tests/test_litellm/proxy/common_utils/test_timezone_utils.py b/tests/test_litellm/proxy/common_utils/test_timezone_utils.py index fed96418f9..80b813226d 100644 --- a/tests/test_litellm/proxy/common_utils/test_timezone_utils.py +++ b/tests/test_litellm/proxy/common_utils/test_timezone_utils.py @@ -1,18 +1,17 @@ -import asyncio -import json import os import sys -import time -from datetime import datetime, timedelta, timezone - -import pytest -from fastapi.testclient import TestClient +from datetime import datetime, timezone +from zoneinfo import ZoneInfo sys.path.insert( 0, os.path.abspath("../../..") ) # Adds the parent directory to the system path -from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time +import litellm +from litellm.proxy.common_utils.timezone_utils import ( + get_budget_reset_time, + get_budget_reset_timezone, +) def test_get_budget_reset_time(): @@ -33,3 +32,71 @@ def test_get_budget_reset_time(): # Verify budget_reset_at is set to first of next month assert get_budget_reset_time(budget_duration="1mo") == expected_reset_at + + +def test_get_budget_reset_timezone_reads_litellm_attr(): + """ + Test that get_budget_reset_timezone reads from litellm.timezone attribute. + """ + original = getattr(litellm, "timezone", None) + try: + litellm.timezone = "Asia/Tokyo" + assert get_budget_reset_timezone() == "Asia/Tokyo" + finally: + if original is None: + if hasattr(litellm, "timezone"): + delattr(litellm, "timezone") + else: + litellm.timezone = original + + +def test_get_budget_reset_timezone_fallback_utc(): + """ + Test that get_budget_reset_timezone falls back to UTC when litellm.timezone is not set. + """ + original = getattr(litellm, "timezone", None) + try: + if hasattr(litellm, "timezone"): + delattr(litellm, "timezone") + assert get_budget_reset_timezone() == "UTC" + finally: + if original is not None: + litellm.timezone = original + + +def test_get_budget_reset_timezone_fallback_on_none(): + """ + Test that get_budget_reset_timezone falls back to UTC when litellm.timezone is None. + """ + original = getattr(litellm, "timezone", None) + try: + litellm.timezone = None + assert get_budget_reset_timezone() == "UTC" + finally: + if original is None: + if hasattr(litellm, "timezone"): + delattr(litellm, "timezone") + else: + litellm.timezone = original + + +def test_get_budget_reset_time_respects_timezone(): + """ + Test that get_budget_reset_time uses the configured timezone for reset calculation. + A daily reset should align to midnight in the configured timezone. + """ + original = getattr(litellm, "timezone", None) + try: + litellm.timezone = "Asia/Tokyo" + reset_at = get_budget_reset_time(budget_duration="1d") + # The reset time should be midnight in Asia/Tokyo + tokyo_reset = reset_at.astimezone(ZoneInfo("Asia/Tokyo")) + assert tokyo_reset.hour == 0 + assert tokyo_reset.minute == 0 + assert tokyo_reset.second == 0 + finally: + if original is None: + if hasattr(litellm, "timezone"): + delattr(litellm, "timezone") + else: + litellm.timezone = original diff --git a/tests/test_litellm/proxy/db/db_transaction_queue/test_base_update_queue.py b/tests/test_litellm/proxy/db/db_transaction_queue/test_base_update_queue.py index 6ab5a4a460..c3807b5f79 100644 --- a/tests/test_litellm/proxy/db/db_transaction_queue/test_base_update_queue.py +++ b/tests/test_litellm/proxy/db/db_transaction_queue/test_base_update_queue.py @@ -2,6 +2,7 @@ import asyncio import json import os import sys +from unittest.mock import patch import pytest from fastapi.testclient import TestClient @@ -42,3 +43,20 @@ async def test_queue_flush_limit(): assert ( queue.update_queue.qsize() == 100 ), "Expected 100 items to remain in the queue" + + +def test_misconfigured_queue_thresholds_warns(): + """ + Test that a warning is logged when MAX_SIZE_IN_MEMORY_QUEUE >= LITELLM_ASYNCIO_QUEUE_MAXSIZE. + + This misconfiguration causes the spend aggregation check in SpendUpdateQueue.add_update() + to never trigger because asyncio.Queue blocks before qsize() can reach the threshold. + """ + import litellm.proxy.db.db_transaction_queue.base_update_queue as bq_module + + with patch.object(bq_module, "MAX_SIZE_IN_MEMORY_QUEUE", 2000), patch.object( + bq_module, "LITELLM_ASYNCIO_QUEUE_MAXSIZE", 1000 + ), patch.object(bq_module.verbose_proxy_logger, "warning") as mock_warning: + BaseUpdateQueue() + mock_warning.assert_called_once() + assert "Misconfigured queue thresholds" in mock_warning.call_args[0][0] diff --git a/tests/test_litellm/proxy/db/test_prisma_self_heal.py b/tests/test_litellm/proxy/db/test_prisma_self_heal.py index 3a07a37ece..03ad95026d 100644 --- a/tests/test_litellm/proxy/db/test_prisma_self_heal.py +++ b/tests/test_litellm/proxy/db/test_prisma_self_heal.py @@ -131,8 +131,11 @@ async def test_attempt_db_reconnect_should_set_cooldown_after_attempt(mock_proxy client.db.connect = AsyncMock(return_value=None) client.db.query_raw = AsyncMock(return_value=[{"result": 1}]) + # Use a counter-based mock to avoid StopIteration when time.time() is called + # more times than expected (varies by Python version / internal code paths). + fake_clock = iter(range(100, 10000)) with patch( - "litellm.proxy.utils.time.time", side_effect=[100.0, 101.0, 150.0, 200.0] + "litellm.proxy.utils.time.time", side_effect=lambda: float(next(fake_clock)) ): result = await client.attempt_db_reconnect( reason="unit_test_cooldown_timestamp_after_attempt", @@ -140,7 +143,9 @@ async def test_attempt_db_reconnect_should_set_cooldown_after_attempt(mock_proxy ) assert result is True - assert client._db_last_reconnect_attempt_ts == 200.0 + # The last time.time() call sets _db_last_reconnect_attempt_ts in the finally block. + # Just verify it was updated to a value greater than the initial 0.0. + assert client._db_last_reconnect_attempt_ts > 0.0 @pytest.mark.asyncio diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_sg_patterns.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_sg_patterns.py new file mode 100644 index 0000000000..49dec5c254 --- /dev/null +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_sg_patterns.py @@ -0,0 +1,156 @@ +""" +Test Singapore PII regex patterns added for PDPA compliance. + +Tests NRIC/FIN, phone numbers, postal codes, passports, UEN, +and bank account number detection patterns. +""" + +from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.patterns import ( + get_compiled_pattern, +) + + +class TestSingaporeNRIC: + """Test Singapore NRIC/FIN detection""" + + def test_valid_nric_detected(self): + pattern = get_compiled_pattern("sg_nric") + # S-series (citizens born 1968–1999) + assert pattern.search("S1234567A") is not None + # T-series (citizens born 2000+) + assert pattern.search("T0123456Z") is not None + # F-series (foreigners before 2000) + assert pattern.search("F9876543B") is not None + # G-series (foreigners 2000+) + assert pattern.search("G1234567X") is not None + # M-series (foreigners from 2022) + assert pattern.search("M1234567K") is not None + + def test_nric_in_sentence(self): + pattern = get_compiled_pattern("sg_nric") + assert pattern.search("My NRIC is S1234567A please check") is not None + + def test_lowercase_letter_prefix_detected_case_insensitive(self): + pattern = get_compiled_pattern("sg_nric") + # Patterns are compiled with re.IGNORECASE in patterns.py + assert pattern.search("s1234567A") is not None + + def test_wrong_prefix_rejected(self): + pattern = get_compiled_pattern("sg_nric") + assert pattern.search("A1234567Z") is None + assert pattern.search("X9876543B") is None + + def test_too_few_digits_rejected(self): + pattern = get_compiled_pattern("sg_nric") + assert pattern.search("S123456A") is None # Only 6 digits + + def test_too_many_digits_rejected(self): + pattern = get_compiled_pattern("sg_nric") + assert pattern.search("S12345678A") is None # 8 digits + + +class TestSingaporePhone: + """Test Singapore phone number detection""" + + def test_with_plus65_prefix(self): + pattern = get_compiled_pattern("sg_phone") + assert pattern.search("+6591234567") is not None + assert pattern.search("+65 91234567") is not None + + def test_with_0065_prefix(self): + pattern = get_compiled_pattern("sg_phone") + assert pattern.search("006591234567") is not None + + def test_with_65_prefix(self): + pattern = get_compiled_pattern("sg_phone") + assert pattern.search("6591234567") is not None + + def test_mobile_numbers_starting_with_8_or_9(self): + pattern = get_compiled_pattern("sg_phone") + assert pattern.search("+6581234567") is not None # 8xxx + assert pattern.search("+6591234567") is not None # 9xxx + + def test_landline_starting_with_6(self): + pattern = get_compiled_pattern("sg_phone") + assert pattern.search("+6561234567") is not None # 6xxx + + def test_invalid_first_digit(self): + pattern = get_compiled_pattern("sg_phone") + # Singapore numbers start with 6, 8, or 9 + assert pattern.search("+6511234567") is None + assert pattern.search("+6521234567") is None + + +class TestSingaporePostalCode: + """Test Singapore postal code detection (contextual pattern)""" + + def test_valid_postal_codes(self): + pattern = get_compiled_pattern("sg_postal_code") + assert pattern.search("018956") is not None # CBD + assert pattern.search("520123") is not None # HDB + assert pattern.search("119077") is not None # NUS area + assert pattern.search("800123") is not None # High range + + def test_invalid_starting_digit(self): + pattern = get_compiled_pattern("sg_postal_code") + assert pattern.search("918956") is None # 9xxxxx invalid + + +class TestSingaporePassport: + """Test Singapore passport number detection""" + + def test_e_series_passport(self): + pattern = get_compiled_pattern("passport_singapore") + assert pattern.search("E1234567") is not None + + def test_k_series_passport(self): + pattern = get_compiled_pattern("passport_singapore") + assert pattern.search("K9876543") is not None + + def test_wrong_prefix_rejected(self): + pattern = get_compiled_pattern("passport_singapore") + assert pattern.search("A1234567") is None + assert pattern.search("X9876543") is None + + def test_too_few_digits_rejected(self): + pattern = get_compiled_pattern("passport_singapore") + assert pattern.search("E123456") is None # Only 6 digits + + +class TestSingaporeUEN: + """Test Singapore Unique Entity Number (UEN) detection""" + + def test_local_company_uen_8digit(self): + pattern = get_compiled_pattern("sg_uen") + # 8 digits + 1 letter (local companies) + assert pattern.search("12345678A") is not None + + def test_local_company_uen_9digit(self): + pattern = get_compiled_pattern("sg_uen") + # 9 digits + 1 letter (businesses) + assert pattern.search("123456789Z") is not None + + def test_roc_uen(self): + pattern = get_compiled_pattern("sg_uen") + # T or R + 2 digits + 2 letters + 4 digits + 1 letter + assert pattern.search("T08LL0001A") is not None + assert pattern.search("R12AB3456Z") is not None + + def test_lowercase_suffix_detected_case_insensitive(self): + pattern = get_compiled_pattern("sg_uen") + assert pattern.search("12345678a") is not None + + +class TestSingaporeBankAccount: + """Test Singapore bank account number detection""" + + def test_standard_format(self): + pattern = get_compiled_pattern("sg_bank_account") + assert pattern.search("123-45678-9") is not None + assert pattern.search("001-23456-12") is not None + assert pattern.search("999-123456-123") is not None + + def test_without_dashes_rejected(self): + pattern = get_compiled_pattern("sg_bank_account") + # Pattern requires dash format + assert pattern.search("12345678901") is None diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_noma.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_noma.py index f1ac6ef14b..c58584944c 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_noma.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_noma.py @@ -11,8 +11,10 @@ from litellm import ModelResponse from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.guardrails.guardrail_hooks.noma import ( NomaGuardrail, + NomaV2Guardrail, initialize_guardrail, ) +import litellm.proxy.guardrails.guardrail_hooks.noma.noma as noma_legacy_module from litellm.proxy.guardrails.guardrail_hooks.noma.noma import NomaBlockedMessage from litellm.proxy.guardrails.init_guardrails import init_guardrails_v2 from litellm.types.llms.openai import AllMessageValues @@ -77,6 +79,13 @@ def mock_request_data(): class TestNomaGuardrailConfiguration: """Test configuration and initialization of Noma guardrail""" + def test_legacy_guardrail_emits_deprecation_warning(self, monkeypatch): + monkeypatch.setattr( + noma_legacy_module, "_LEGACY_NOMA_DEPRECATION_WARNED", False + ) + with pytest.warns(DeprecationWarning, match="deprecated"): + NomaGuardrail(api_key="test-api-key") + def test_init_with_config(self): """Test initializing Noma guardrail via init_guardrails_v2""" with patch.dict( @@ -167,6 +176,34 @@ class TestNomaGuardrailConfiguration: assert result.block_failures is False mock_add.assert_called_once_with(result) + def test_initialize_guardrail_use_v2_routes_to_noma_v2(self): + """Test migration routing: guardrail=noma + use_v2=True initializes NomaV2Guardrail.""" + from litellm.types.guardrails import Guardrail, LitellmParams + + litellm_params = LitellmParams( + guardrail="noma", + mode="pre_call", + use_v2=True, + api_key="test-key", + api_base="https://test.api/", + application_id="test-app", + ) + + guardrail = Guardrail( + guardrail_name="test-guardrail", + litellm_params=litellm_params, + ) + + with patch("litellm.logging_callback_manager.add_litellm_callback") as mock_add: + result = initialize_guardrail(litellm_params, guardrail) + + assert isinstance(result, NomaV2Guardrail) + assert result.api_key == "test-key" + assert result.api_base == "https://test.api" + assert result.application_id == "test-app" + mock_add.assert_called_once_with(result) + + class TestNomaApplicationIdResolution: """Tests for determining which applicationId is sent to Noma.""" diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_noma_v2.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_noma_v2.py new file mode 100644 index 0000000000..d5fc1bdc69 --- /dev/null +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_noma_v2.py @@ -0,0 +1,531 @@ +import os +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from litellm.proxy.guardrails.guardrail_hooks.noma import NomaV2Guardrail +from litellm.proxy.guardrails.guardrail_hooks.noma.noma import NomaBlockedMessage +from litellm.types.proxy.guardrails.guardrail_hooks.noma import ( + NomaV2GuardrailConfigModel, +) + + +@pytest.fixture +def noma_v2_guardrail(): + return NomaV2Guardrail( + api_key="test-api-key", + api_base="https://api.test.noma.security/", + application_id="test-app", + monitor_mode=False, + block_failures=False, + guardrail_name="test-noma-v2-guardrail", + event_hook="pre_call", + default_on=True, + ) + + +class TestNomaV2Configuration: + @pytest.mark.asyncio + async def test_provider_specific_params_include_noma_v2_fields(self): + from litellm.proxy.guardrails.guardrail_endpoints import ( + get_provider_specific_params, + ) + + provider_params = await get_provider_specific_params() + assert "noma_v2" in provider_params + + noma_v2_params = provider_params["noma_v2"] + assert noma_v2_params["ui_friendly_name"] == "Noma Security v2" + assert "api_key" in noma_v2_params + assert "api_base" in noma_v2_params + assert "application_id" in noma_v2_params + assert "monitor_mode" in noma_v2_params + assert "block_failures" in noma_v2_params + + def test_init_requires_auth_for_saas_endpoint(self): + with patch.dict(os.environ, {}, clear=True): + with pytest.raises( + ValueError, + match="requires api_key when using Noma SaaS endpoint", + ): + NomaV2Guardrail() + + def test_init_allows_missing_auth_for_self_managed_endpoint(self): + with patch.dict(os.environ, {}, clear=True): + guardrail = NomaV2Guardrail(api_base="https://self-managed.noma.local") + assert guardrail.api_key is None + + def test_init_defaults_monitor_and_block_failures(self): + with patch.dict(os.environ, {"NOMA_API_KEY": "test-api-key"}, clear=True): + guardrail = NomaV2Guardrail() + + assert guardrail.monitor_mode is False + assert guardrail.block_failures is True + + @pytest.mark.asyncio + async def test_api_key_auth_path(self, noma_v2_guardrail): + assert noma_v2_guardrail._get_authorization_header() == "Bearer test-api-key" + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = '{"action":"NONE"}' + mock_response.json.return_value = { + "action": "NONE", + } + mock_response.raise_for_status = MagicMock() + mock_post = AsyncMock(return_value=mock_response) + + with patch.object(noma_v2_guardrail.async_handler, "post", mock_post): + await noma_v2_guardrail._call_noma_scan( + payload={"inputs": {"texts": []}}, + ) + + call_kwargs = mock_post.call_args.kwargs + assert call_kwargs["headers"]["Authorization"] == "Bearer test-api-key" + + @pytest.mark.asyncio + async def test_self_managed_path_without_api_key_omits_authorization_header(self): + guardrail = NomaV2Guardrail( + api_base="https://self-managed.noma.local", + guardrail_name="test-noma-v2-guardrail", + event_hook="pre_call", + default_on=True, + ) + assert guardrail._get_authorization_header() == "" + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = '{"action":"NONE"}' + mock_response.json.return_value = {"action": "NONE"} + mock_response.raise_for_status = MagicMock() + mock_post = AsyncMock(return_value=mock_response) + + with patch.object(guardrail.async_handler, "post", mock_post): + await guardrail._call_noma_scan(payload={"inputs": {"texts": []}}) + + sent_headers = mock_post.call_args.kwargs["headers"] + assert "Authorization" not in sent_headers + + def test_build_scan_payload_sends_raw_available_data(self, noma_v2_guardrail): + inputs = { + "texts": ["hello"], + "images": ["https://example.com/image.png"], + "structured_messages": [{"role": "user", "content": "hello"}], + "tool_calls": [{"id": "tool-1"}], + "model": "gpt-4o-mini", + } + request_data = { + "messages": [{"role": "user", "content": "hello"}], + "metadata": {"headers": {"x-noma-application-id": "header-app"}}, + "litellm_metadata": {"user_api_key_alias": "litellm-alias"}, + "litellm_call_id": "call-id-1", + } + payload = noma_v2_guardrail._build_scan_payload( + inputs=inputs, + request_data=request_data, + input_type="request", + logging_obj=None, + application_id="dynamic-app", + ) + + assert payload["inputs"] == inputs + assert payload["request_data"] == request_data + assert payload["input_type"] == "request" + assert payload["monitor_mode"] is False + assert payload["application_id"] == "dynamic-app" + assert "dynamic_params" not in payload + assert "x-noma-context" not in payload + assert "input" not in payload + + def test_build_scan_payload_deep_copies_request_data(self, noma_v2_guardrail): + request_data = { + "metadata": {"headers": {"x-noma-application-id": "header-app"}}, + "messages": [{"role": "user", "content": "hello"}], + } + payload = noma_v2_guardrail._build_scan_payload( + inputs={"texts": ["hello"]}, + request_data=request_data, + input_type="request", + logging_obj=None, + application_id="dynamic-app", + ) + + payload["request_data"]["metadata"]["headers"]["x-noma-application-id"] = "mutated-value" + payload["request_data"]["messages"][0]["content"] = "changed-content" + + assert request_data["metadata"]["headers"]["x-noma-application-id"] == "header-app" + assert request_data["messages"][0]["content"] == "hello" + + def test_build_scan_payload_passes_model_call_details_as_is(self, noma_v2_guardrail): + class _LoggingObj: + def __init__(self) -> None: + self.model_call_details = { + "model": "gpt-4.1-mini", + "messages": [{"role": "user", "content": "hello"}], + "stream": False, + "call_type": "acompletion", + "litellm_call_id": "call-id-123", + "function_id": "fn-id-456", + "litellm_trace_id": "trace-id-789", + "api_key": "included-as-is", + } + + request_data = {"litellm_logging_obj": ""} + payload = noma_v2_guardrail._build_scan_payload( + inputs={"texts": ["hello"]}, + request_data=request_data, + input_type="request", + logging_obj=_LoggingObj(), + application_id="test-app", + ) + + assert payload["request_data"]["litellm_logging_obj"] == { + "model": "gpt-4.1-mini", + "messages": [{"role": "user", "content": "hello"}], + "stream": False, + "call_type": "acompletion", + "litellm_call_id": "call-id-123", + "function_id": "fn-id-456", + "litellm_trace_id": "trace-id-789", + "api_key": "included-as-is", + } + assert "logging_obj" not in payload + assert request_data["litellm_logging_obj"] == "" + + @pytest.mark.asyncio + async def test_call_noma_scan_sanitizes_response_model_dump_object(self, noma_v2_guardrail): + import json + + class _FakeModelResponse: + def model_dump(self): + return {"id": "resp-1", "content": "ok"} + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = '{"action":"NONE"}' + mock_response.json.return_value = {"action": "NONE"} + mock_response.raise_for_status = MagicMock() + mock_post = AsyncMock(return_value=mock_response) + + payload = { + "inputs": {"texts": ["hello"]}, + "request_data": {"response": _FakeModelResponse()}, + "input_type": "response", + "application_id": "test-app", + } + + with patch.object(noma_v2_guardrail.async_handler, "post", mock_post): + await noma_v2_guardrail._call_noma_scan(payload=payload) + + sent_payload = mock_post.call_args.kwargs["json"] + json.dumps(sent_payload) + assert sent_payload["request_data"]["response"]["id"] == "resp-1" + + def test_sanitize_payload_for_transport_falls_back_to_safe_dumps(self, noma_v2_guardrail): + with patch( + "litellm.proxy.guardrails.guardrail_hooks.noma.noma_v2.json.dumps", + side_effect=TypeError("cannot serialize"), + ): + with patch( + "litellm.proxy.guardrails.guardrail_hooks.noma.noma_v2.safe_dumps", + return_value='{"fallback": true}', + ) as mock_safe_dumps: + sanitized = noma_v2_guardrail._sanitize_payload_for_transport({"inputs": {"texts": ["hello"]}}) + + mock_safe_dumps.assert_called_once() + assert sanitized == {"fallback": True} + + def test_sanitize_payload_for_transport_logs_warning_when_payload_becomes_empty(self, noma_v2_guardrail): + with patch( + "litellm.proxy.guardrails.guardrail_hooks.noma.noma_v2.safe_json_loads", + return_value={}, + ): + with patch( + "litellm.proxy.guardrails.guardrail_hooks.noma.noma_v2.verbose_proxy_logger.warning" + ) as mock_warning: + sanitized = noma_v2_guardrail._sanitize_payload_for_transport({"inputs": {"texts": ["hello"]}}) + + assert sanitized == {} + mock_warning.assert_called_once_with( + "Noma v2 guardrail: payload serialization failed, falling back to empty payload" + ) + + def test_sanitize_payload_for_transport_logs_warning_on_non_dict_output(self, noma_v2_guardrail): + with patch( + "litellm.proxy.guardrails.guardrail_hooks.noma.noma_v2.safe_json_loads", + return_value=["not-a-dict"], + ): + with patch( + "litellm.proxy.guardrails.guardrail_hooks.noma.noma_v2.verbose_proxy_logger.warning" + ) as mock_warning: + sanitized = noma_v2_guardrail._sanitize_payload_for_transport({"inputs": {"texts": ["hello"]}}) + + assert sanitized == {} + mock_warning.assert_called_once_with( + "Noma v2 guardrail: payload sanitization produced non-dict output (type=%s), falling back to empty payload", + "list", + ) + + def test_get_config_model_returns_noma_v2_config_model(self): + assert NomaV2Guardrail.get_config_model() is NomaV2GuardrailConfigModel + + +class TestNomaV2ActionBehavior: + def test_resolve_action_from_response_raises_on_unknown_action(self, noma_v2_guardrail): + with pytest.raises(ValueError, match="missing valid action"): + noma_v2_guardrail._resolve_action_from_response({"action": "INVALID"}) + + @pytest.mark.asyncio + async def test_native_action_none(self, noma_v2_guardrail): + inputs = {"texts": ["hello"]} + with patch.object( + noma_v2_guardrail, + "_call_noma_scan", + AsyncMock( + return_value={ + "action": "NONE", + } + ), + ): + result = await noma_v2_guardrail.apply_guardrail( + inputs=inputs, + request_data={"metadata": {}}, + input_type="request", + ) + + assert result == inputs + + @pytest.mark.asyncio + async def test_native_action_guardrail_intervened_updates_supported_fields(self, noma_v2_guardrail): + inputs = { + "texts": ["Name: Jane"], + "images": ["https://old.example/image.png"], + "tools": [{"type": "function", "function": {"name": "old_tool"}}], + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "old_tool", "arguments": '{"key":"value"}'}, + } + ], + } + with patch.object( + noma_v2_guardrail, + "_call_noma_scan", + AsyncMock( + return_value={ + "action": "GUARDRAIL_INTERVENED", + "texts": ["Name: *******"], + "images": ["https://new.example/image.png"], + "tools": [{"type": "function", "function": {"name": "new_tool"}}], + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "new_tool", "arguments": '{"safe":"true"}'}, + } + ], + } + ), + ): + result = await noma_v2_guardrail.apply_guardrail( + inputs=inputs, + request_data={"metadata": {}}, + input_type="request", + ) + + assert result["texts"] == ["Name: *******"] + assert result["images"] == ["https://new.example/image.png"] + assert result["tools"] == [{"type": "function", "function": {"name": "new_tool"}}] + assert result["tool_calls"] == [ + { + "id": "call_1", + "type": "function", + "function": {"name": "new_tool", "arguments": '{"safe":"true"}'}, + } + ] + + @pytest.mark.asyncio + async def test_native_action_blocked(self, noma_v2_guardrail): + inputs = {"texts": ["bad"]} + with patch.object( + noma_v2_guardrail, + "_call_noma_scan", + AsyncMock( + return_value={ + "action": "BLOCKED", + "blocked_reason": "blocked by policy", + } + ), + ): + with pytest.raises(NomaBlockedMessage) as exc_info: + await noma_v2_guardrail.apply_guardrail( + inputs=inputs, + request_data={"metadata": {}}, + input_type="request", + ) + assert exc_info.value.detail["details"]["blocked_reason"] == "blocked by policy" + + @pytest.mark.asyncio + async def test_intervened_without_modifications_returns_original_inputs(self, noma_v2_guardrail): + inputs = {"texts": ["Name: Jane"]} + with patch.object( + noma_v2_guardrail, + "_call_noma_scan", + AsyncMock( + return_value={ + "action": "GUARDRAIL_INTERVENED", + } + ), + ): + result = await noma_v2_guardrail.apply_guardrail( + inputs=inputs, + request_data={"metadata": {}}, + input_type="request", + ) + assert result == inputs + + @pytest.mark.asyncio + async def test_fail_open_on_technical_scan_failure(self, noma_v2_guardrail): + inputs = {"texts": ["hello"]} + with patch.object( + noma_v2_guardrail, + "_call_noma_scan", + AsyncMock(side_effect=Exception("network error")), + ): + result = await noma_v2_guardrail.apply_guardrail( + inputs=inputs, + request_data={"metadata": {}}, + input_type="request", + ) + + assert result == inputs + + @pytest.mark.asyncio + async def test_fail_closed_on_technical_scan_failure_when_block_failures_true(self): + guardrail = NomaV2Guardrail( + api_key="test-api-key", + block_failures=True, + guardrail_name="test-noma-v2-guardrail", + event_hook="pre_call", + default_on=True, + ) + with patch.object( + guardrail, + "_call_noma_scan", + AsyncMock(side_effect=Exception("network error")), + ): + with pytest.raises(Exception, match="network error"): + await guardrail.apply_guardrail( + inputs={"texts": ["hello"]}, + request_data={"metadata": {}}, + input_type="request", + ) + + @pytest.mark.asyncio + async def test_monitor_mode_ignores_block_action(self): + guardrail = NomaV2Guardrail( + api_key="test-api-key", + monitor_mode=True, + guardrail_name="test-noma-v2-guardrail", + event_hook="pre_call", + default_on=True, + ) + call_mock = AsyncMock(return_value={"action": "BLOCKED"}) + with patch.object(guardrail, "_call_noma_scan", call_mock): + result = await guardrail.apply_guardrail( + inputs={"texts": ["hello"]}, + request_data={"metadata": {}}, + input_type="request", + ) + + payload = call_mock.call_args.kwargs["payload"] + assert payload["monitor_mode"] is True + assert result == {"texts": ["hello"]} + + +class TestNomaV2ApplicationIdResolution: + @pytest.mark.asyncio + async def test_apply_guardrail_uses_dynamic_application_id(self, noma_v2_guardrail): + call_mock = AsyncMock(return_value={"action": "NONE"}) + with patch.object( + noma_v2_guardrail, + "get_guardrail_dynamic_request_body_params", + return_value={"application_id": "dynamic-app"}, + ): + with patch.object(noma_v2_guardrail, "_call_noma_scan", call_mock): + await noma_v2_guardrail.apply_guardrail( + inputs={"texts": ["hello"]}, + request_data={"metadata": {}}, + input_type="request", + ) + + payload = call_mock.call_args.kwargs["payload"] + assert payload["application_id"] == "dynamic-app" + + @pytest.mark.asyncio + async def test_apply_guardrail_uses_configured_application_id(self, noma_v2_guardrail): + call_mock = AsyncMock(return_value={"action": "NONE"}) + with patch.object( + noma_v2_guardrail, + "get_guardrail_dynamic_request_body_params", + return_value={}, + ): + with patch.object(noma_v2_guardrail, "_call_noma_scan", call_mock): + await noma_v2_guardrail.apply_guardrail( + inputs={"texts": ["hello"]}, + request_data={"metadata": {}}, + input_type="request", + ) + + payload = call_mock.call_args.kwargs["payload"] + assert payload["application_id"] == "test-app" + + @pytest.mark.asyncio + async def test_apply_guardrail_omits_application_id_when_not_explicit(self): + guardrail_no_config = NomaV2Guardrail( + api_key="test-api-key", + application_id=None, + guardrail_name="test-noma-v2-guardrail", + event_hook="pre_call", + default_on=True, + ) + + call_mock = AsyncMock(return_value={"action": "NONE"}) + with patch.object( + guardrail_no_config, + "get_guardrail_dynamic_request_body_params", + return_value={}, + ): + with patch.object(guardrail_no_config, "_call_noma_scan", call_mock): + await guardrail_no_config.apply_guardrail( + inputs={"texts": ["hello"]}, + request_data={"metadata": {}}, + input_type="request", + ) + + payload = call_mock.call_args.kwargs["payload"] + assert "application_id" not in payload + + @pytest.mark.asyncio + async def test_apply_guardrail_ignores_request_metadata_application_id(self, noma_v2_guardrail): + noma_v2_guardrail.application_id = None + call_mock = AsyncMock(return_value={"action": "NONE"}) + request_data = { + "metadata": {"headers": {"x-noma-application-id": "header-app"}}, + "litellm_metadata": {"user_api_key_alias": "alias-app"}, + } + with patch.object( + noma_v2_guardrail, + "get_guardrail_dynamic_request_body_params", + return_value={}, + ): + with patch.object(noma_v2_guardrail, "_call_noma_scan", call_mock): + await noma_v2_guardrail.apply_guardrail( + inputs={"texts": ["hello"]}, + request_data=request_data, + input_type="request", + ) + + payload = call_mock.call_args.kwargs["payload"] + assert "application_id" not in payload diff --git a/tests/test_litellm/proxy/guardrails/test_guardrail_registry.py b/tests/test_litellm/proxy/guardrails/test_guardrail_registry.py index 23432b18ca..1d70126681 100644 --- a/tests/test_litellm/proxy/guardrails/test_guardrail_registry.py +++ b/tests/test_litellm/proxy/guardrails/test_guardrail_registry.py @@ -8,18 +8,30 @@ from litellm.types.guardrails import GuardrailEventHooks, Guardrail, LitellmPara def test_get_guardrail_initializer_from_hooks(): initializers = get_guardrail_initializer_from_hooks() - print(f"initializers: {initializers}") assert "aim" in initializers def test_guardrail_class_registry(): from litellm.proxy.guardrails.guardrail_registry import guardrail_class_registry - print(f"guardrail_class_registry: {guardrail_class_registry}") assert "aim" in guardrail_class_registry assert "aporia" in guardrail_class_registry +def test_noma_registry_resolution(): + from litellm.proxy.guardrails.guardrail_hooks.noma.noma import NomaGuardrail + from litellm.proxy.guardrails.guardrail_hooks.noma.noma_v2 import NomaV2Guardrail + from litellm.proxy.guardrails.guardrail_registry import ( + guardrail_class_registry, + guardrail_initializer_registry, + ) + + assert guardrail_class_registry["noma"] is NomaGuardrail + assert guardrail_class_registry["noma_v2"] is NomaV2Guardrail + assert "noma" in guardrail_initializer_registry + assert "noma_v2" in guardrail_initializer_registry + + def test_update_in_memory_guardrail(): handler = InMemoryGuardrailHandler() handler.guardrail_id_to_custom_guardrail["123"] = CustomGuardrail( diff --git a/tests/test_litellm/proxy/management_endpoints/test_access_group_management.py b/tests/test_litellm/proxy/management_endpoints/test_access_group_management.py index 1846ffaeb6..18dcb2b0b2 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_access_group_management.py +++ b/tests/test_litellm/proxy/management_endpoints/test_access_group_management.py @@ -78,3 +78,213 @@ async def test_create_duplicate_access_group_fails(): assert exc_info.value.status_code == 409 assert "already exists" in str(exc_info.value.detail) +@pytest.mark.asyncio +async def test_create_access_group_with_model_ids_tags_only_specific_deployments(): + """ + Test that using model_ids only tags the specific deployments, not all + deployments sharing the same model_name. + + Fixes: https://github.com/BerriAI/litellm/issues/21544 + """ + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth + from litellm.proxy.management_endpoints.model_access_group_management_endpoints import ( + create_model_group, + ) + from litellm.types.proxy.management_endpoints.model_management_endpoints import ( + NewModelGroupRequest, + ) + + deploy_a = MagicMock(model_id="deploy-A", model_name="gpt-4o", model_info={}) + + mock_prisma = MagicMock() + mock_prisma.db.litellm_proxymodeltable.find_many = AsyncMock(return_value=[]) + mock_prisma.db.litellm_proxymodeltable.find_unique = AsyncMock(return_value=deploy_a) + mock_prisma.db.litellm_proxymodeltable.update = AsyncMock() + + mock_user = UserAPIKeyAuth( + user_id="test_admin", + user_role=LitellmUserRoles.PROXY_ADMIN, + ) + + request_data = NewModelGroupRequest( + access_group="production-models", + model_ids=["deploy-A"], + ) + + with patch("litellm.proxy.proxy_server.llm_router", MagicMock()), \ + patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), \ + patch( + "litellm.proxy.management_endpoints.model_access_group_management_endpoints.clear_cache", + new_callable=AsyncMock, + ): + response = await create_model_group(data=request_data, user_api_key_dict=mock_user) + + assert response.models_updated == 1 + assert response.model_ids == ["deploy-A"] + mock_prisma.db.litellm_proxymodeltable.find_unique.assert_called_once_with( + where={"model_id": "deploy-A"} + ) + assert mock_prisma.db.litellm_proxymodeltable.update.call_count == 1 + update_call = mock_prisma.db.litellm_proxymodeltable.update.call_args + assert update_call.kwargs["where"] == {"model_id": "deploy-A"} + + +@pytest.mark.asyncio +async def test_create_access_group_with_model_names_tags_all_deployments(): + """ + Test backward compat: model_names still tags ALL deployments sharing that model_name. + """ + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth + from litellm.proxy.management_endpoints.model_access_group_management_endpoints import ( + create_model_group, + ) + from litellm.types.proxy.management_endpoints.model_management_endpoints import ( + NewModelGroupRequest, + ) + + deploy_a = MagicMock(model_id="deploy-A", model_name="gpt-4o", model_info={}) + deploy_b = MagicMock(model_id="deploy-B", model_name="gpt-4o", model_info={}) + deploy_c = MagicMock(model_id="deploy-C", model_name="gpt-4o", model_info={}) + + mock_router = Router( + model_list=[{"model_name": "gpt-4o", "litellm_params": {"model": "gpt-4o", "api_key": "fake-key"}}] + ) + + mock_prisma = MagicMock() + mock_prisma.db.litellm_proxymodeltable.find_many = AsyncMock( + side_effect=[[], [deploy_a, deploy_b, deploy_c]] + ) + mock_prisma.db.litellm_proxymodeltable.update = AsyncMock() + + mock_user = UserAPIKeyAuth( + user_id="test_admin", + user_role=LitellmUserRoles.PROXY_ADMIN, + ) + + request_data = NewModelGroupRequest(access_group="production-models", model_names=["gpt-4o"]) + + with patch("litellm.proxy.proxy_server.llm_router", mock_router), \ + patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), \ + patch( + "litellm.proxy.management_endpoints.model_access_group_management_endpoints.clear_cache", + new_callable=AsyncMock, + ): + response = await create_model_group(data=request_data, user_api_key_dict=mock_user) + + assert response.models_updated == 3 + assert response.model_names == ["gpt-4o"] + assert mock_prisma.db.litellm_proxymodeltable.update.call_count == 3 + + +@pytest.mark.asyncio +async def test_create_access_group_model_ids_takes_priority_over_model_names(): + """ + Test that when both model_ids and model_names are provided, model_ids is used. + """ + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth + from litellm.proxy.management_endpoints.model_access_group_management_endpoints import ( + create_model_group, + ) + from litellm.types.proxy.management_endpoints.model_management_endpoints import ( + NewModelGroupRequest, + ) + + deploy_a = MagicMock(model_id="deploy-A", model_name="gpt-4o", model_info={}) + + mock_prisma = MagicMock() + mock_prisma.db.litellm_proxymodeltable.find_many = AsyncMock(return_value=[]) + mock_prisma.db.litellm_proxymodeltable.find_unique = AsyncMock(return_value=deploy_a) + mock_prisma.db.litellm_proxymodeltable.update = AsyncMock() + + mock_user = UserAPIKeyAuth( + user_id="test_admin", + user_role=LitellmUserRoles.PROXY_ADMIN, + ) + + request_data = NewModelGroupRequest( + access_group="production-models", + model_names=["gpt-4o"], + model_ids=["deploy-A"], + ) + + with patch("litellm.proxy.proxy_server.llm_router", MagicMock()), \ + patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), \ + patch( + "litellm.proxy.management_endpoints.model_access_group_management_endpoints.clear_cache", + new_callable=AsyncMock, + ): + response = await create_model_group(data=request_data, user_api_key_dict=mock_user) + + assert response.models_updated == 1 + mock_prisma.db.litellm_proxymodeltable.find_unique.assert_called_once_with( + where={"model_id": "deploy-A"} + ) + + +@pytest.mark.asyncio +async def test_create_access_group_requires_model_names_or_model_ids(): + """ + Test that creating an access group without model_names or model_ids fails. + """ + from fastapi import HTTPException + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth + from litellm.proxy.management_endpoints.model_access_group_management_endpoints import ( + create_model_group, + ) + from litellm.types.proxy.management_endpoints.model_management_endpoints import ( + NewModelGroupRequest, + ) + + mock_user = UserAPIKeyAuth( + user_id="test_admin", + user_role=LitellmUserRoles.PROXY_ADMIN, + ) + + request_data = NewModelGroupRequest(access_group="production-models") + + with patch("litellm.proxy.proxy_server.llm_router", MagicMock()), \ + patch("litellm.proxy.proxy_server.prisma_client", MagicMock()): + with pytest.raises(HTTPException) as exc_info: + await create_model_group(data=request_data, user_api_key_dict=mock_user) + assert exc_info.value.status_code == 400 + assert "model_names or model_ids" in str(exc_info.value.detail) + + +@pytest.mark.asyncio +async def test_create_access_group_invalid_model_id_returns_400(): + """ + Test that passing a non-existent model_id returns 400 error. + """ + from fastapi import HTTPException + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth + from litellm.proxy.management_endpoints.model_access_group_management_endpoints import ( + create_model_group, + ) + from litellm.types.proxy.management_endpoints.model_management_endpoints import ( + NewModelGroupRequest, + ) + + mock_prisma = MagicMock() + mock_prisma.db.litellm_proxymodeltable.find_many = AsyncMock(return_value=[]) + mock_prisma.db.litellm_proxymodeltable.find_unique = AsyncMock(return_value=None) + + mock_user = UserAPIKeyAuth( + user_id="test_admin", + user_role=LitellmUserRoles.PROXY_ADMIN, + ) + + request_data = NewModelGroupRequest( + access_group="production-models", + model_ids=["non-existent-id"], + ) + + with patch("litellm.proxy.proxy_server.llm_router", MagicMock()), \ + patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), \ + patch( + "litellm.proxy.management_endpoints.model_access_group_management_endpoints.clear_cache", + new_callable=AsyncMock, + ): + with pytest.raises(HTTPException) as exc_info: + await create_model_group(data=request_data, user_api_key_dict=mock_user) + assert exc_info.value.status_code == 400 + assert "non-existent-id" in str(exc_info.value.detail) diff --git a/tests/test_litellm/proxy/test_common_request_processing.py b/tests/test_litellm/proxy/test_common_request_processing.py index 7bebe00d61..977304f732 100644 --- a/tests/test_litellm/proxy/test_common_request_processing.py +++ b/tests/test_litellm/proxy/test_common_request_processing.py @@ -84,7 +84,7 @@ class TestProxyBaseLLMRequestProcessing: """ Test that hierarchical router settings are stored as router_settings_override instead of creating a full user_config with model_list. - + This approach avoids expensive per-request Router instantiation by passing settings as kwargs overrides to the main router. """ @@ -114,7 +114,7 @@ class TestProxyBaseLLMRequestProcessing: mock_general_settings = {} mock_user_api_key_dict = MagicMock(spec=UserAPIKeyAuth) mock_proxy_config = MagicMock(spec=ProxyConfig) - + mock_router_settings = { "routing_strategy": "least-busy", "timeout": 30.0, @@ -134,7 +134,10 @@ class TestProxyBaseLLMRequestProcessing: route_type = "acompletion" - returned_data, logging_obj = await processing_obj.common_processing_pre_call_logic( + ( + returned_data, + logging_obj, + ) = await processing_obj.common_processing_pre_call_logic( request=mock_request, general_settings=mock_general_settings, user_api_key_dict=mock_user_api_key_dict, @@ -156,7 +159,7 @@ class TestProxyBaseLLMRequestProcessing: # This allows passing them as kwargs to the main router instead of creating a new one assert "router_settings_override" in returned_data assert "user_config" not in returned_data - + router_settings_override = returned_data["router_settings_override"] assert router_settings_override["routing_strategy"] == "least-busy" assert router_settings_override["timeout"] == 30.0 @@ -173,34 +176,39 @@ class TestProxyBaseLLMRequestProcessing: # Test with stream timeout header headers_with_timeout = {"x-litellm-stream-timeout": "30.5"} - result = LiteLLMProxyRequestSetup._get_stream_timeout_from_request(headers_with_timeout) + result = LiteLLMProxyRequestSetup._get_stream_timeout_from_request( + headers_with_timeout + ) assert result == 30.5 - + # Test without stream timeout header headers_without_timeout = {} - result = LiteLLMProxyRequestSetup._get_stream_timeout_from_request(headers_without_timeout) + result = LiteLLMProxyRequestSetup._get_stream_timeout_from_request( + headers_without_timeout + ) assert result is None - + # Test with invalid header value (should raise ValueError when converting to float) headers_with_invalid = {"x-litellm-stream-timeout": "invalid"} with pytest.raises(ValueError): - LiteLLMProxyRequestSetup._get_stream_timeout_from_request(headers_with_invalid) + LiteLLMProxyRequestSetup._get_stream_timeout_from_request( + headers_with_invalid + ) @pytest.mark.asyncio async def test_add_litellm_data_to_request_with_stream_timeout_header(self): """ - Test that x-litellm-stream-timeout header gets processed and added to request data + Test that x-litellm-stream-timeout header gets processed and added to request data when calling add_litellm_data_to_request. """ - from litellm.integrations.opentelemetry import UserAPIKeyAuth from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request # Create test data with a basic completion request test_data = { "model": "gpt-3.5-turbo", - "messages": [{"role": "user", "content": "Hello"}] + "messages": [{"role": "user", "content": "Hello"}], } - + # Mock request with stream timeout header mock_request = MagicMock(spec=Request) mock_request.headers = {"x-litellm-stream-timeout": "45.0"} @@ -208,7 +216,7 @@ class TestProxyBaseLLMRequestProcessing: mock_request.method = "POST" mock_request.query_params = {} mock_request.client = None - + # Create a minimal mock with just the required attributes mock_user_api_key_dict = MagicMock() mock_user_api_key_dict.api_key = "test_api_key_hash" @@ -232,10 +240,10 @@ class TestProxyBaseLLMRequestProcessing: mock_user_api_key_dict.model_max_budget = None mock_user_api_key_dict.parent_otel_span = None mock_user_api_key_dict.team_model_aliases = None - + general_settings = {} mock_proxy_config = MagicMock() - + # Call the actual function that processes headers and adds data result_data = await add_litellm_data_to_request( data=test_data, @@ -245,11 +253,11 @@ class TestProxyBaseLLMRequestProcessing: version=None, proxy_config=mock_proxy_config, ) - + # Verify that stream_timeout was extracted from header and added to request data assert "stream_timeout" in result_data assert result_data["stream_timeout"] == 45.0 - + # Verify that the original test data is preserved assert result_data["model"] == "gpt-3.5-turbo" assert result_data["messages"] == [{"role": "user", "content": "Hello"}] @@ -269,7 +277,7 @@ class TestProxyBaseLLMRequestProcessing: mock_user_api_key_dict.rpm_limit = None mock_user_api_key_dict.max_budget = None mock_user_api_key_dict.spend = 0 - + # Create logging object with cost breakdown including discount logging_obj = LiteLLMLoggingObj( model="vertex_ai/gemini-pro", @@ -280,7 +288,7 @@ class TestProxyBaseLLMRequestProcessing: litellm_call_id="test-call-id", function_id="test-function-id", ) - + # Set cost breakdown with discount information logging_obj.set_cost_breakdown( input_cost=0.00005, @@ -291,7 +299,7 @@ class TestProxyBaseLLMRequestProcessing: discount_percent=0.05, discount_amount=0.000005, ) - + # Call get_custom_headers with discount info headers = ProxyBaseLLMRequestProcessing.get_custom_headers( user_api_key_dict=mock_user_api_key_dict, @@ -299,14 +307,14 @@ class TestProxyBaseLLMRequestProcessing: response_cost=0.000095, litellm_logging_obj=logging_obj, ) - + # Verify discount headers are present assert "x-litellm-response-cost" in headers assert float(headers["x-litellm-response-cost"]) == 0.000095 - + assert "x-litellm-response-cost-original" in headers assert float(headers["x-litellm-response-cost-original"]) == 0.0001 - + assert "x-litellm-response-cost-discount-amount" in headers assert float(headers["x-litellm-response-cost-discount-amount"]) == 0.000005 @@ -324,7 +332,7 @@ class TestProxyBaseLLMRequestProcessing: mock_user_api_key_dict.rpm_limit = None mock_user_api_key_dict.max_budget = None mock_user_api_key_dict.spend = 0 - + # Create logging object without discount logging_obj = LiteLLMLoggingObj( model="gpt-3.5-turbo", @@ -335,7 +343,7 @@ class TestProxyBaseLLMRequestProcessing: litellm_call_id="test-call-id", function_id="test-function-id", ) - + # Set cost breakdown without discount information logging_obj.set_cost_breakdown( input_cost=0.00005, @@ -343,7 +351,7 @@ class TestProxyBaseLLMRequestProcessing: total_cost=0.0001, cost_for_built_in_tools_cost_usd_dollar=0.0, ) - + # Call get_custom_headers headers = ProxyBaseLLMRequestProcessing.get_custom_headers( user_api_key_dict=mock_user_api_key_dict, @@ -351,11 +359,11 @@ class TestProxyBaseLLMRequestProcessing: response_cost=0.0001, litellm_logging_obj=logging_obj, ) - + # Verify discount headers are NOT present assert "x-litellm-response-cost" in headers assert float(headers["x-litellm-response-cost"]) == 0.0001 - + # Discount headers should not be in the final dict assert "x-litellm-response-cost-original" not in headers assert "x-litellm-response-cost-discount-amount" not in headers @@ -374,7 +382,7 @@ class TestProxyBaseLLMRequestProcessing: mock_user_api_key_dict.rpm_limit = None mock_user_api_key_dict.max_budget = None mock_user_api_key_dict.spend = 0 - + # Create logging object with margin logging_obj = LiteLLMLoggingObj( model="gpt-4", @@ -394,20 +402,20 @@ class TestProxyBaseLLMRequestProcessing: margin_percent=0.10, margin_total_amount=0.00001, ) - + headers = ProxyBaseLLMRequestProcessing.get_custom_headers( user_api_key_dict=mock_user_api_key_dict, response_cost=0.00011, litellm_logging_obj=logging_obj, ) - + # Verify margin headers are present assert "x-litellm-response-cost" in headers assert float(headers["x-litellm-response-cost"]) == 0.00011 - + assert "x-litellm-response-cost-margin-amount" in headers assert float(headers["x-litellm-response-cost-margin-amount"]) == 0.00001 - + assert "x-litellm-response-cost-margin-percent" in headers assert float(headers["x-litellm-response-cost-margin-percent"]) == 0.10 @@ -425,7 +433,7 @@ class TestProxyBaseLLMRequestProcessing: mock_user_api_key_dict.rpm_limit = None mock_user_api_key_dict.max_budget = None mock_user_api_key_dict.spend = 0 - + # Create logging object without margin logging_obj = LiteLLMLoggingObj( model="gpt-4", @@ -442,13 +450,13 @@ class TestProxyBaseLLMRequestProcessing: total_cost=0.0001, cost_for_built_in_tools_cost_usd_dollar=0.0, ) - + headers = ProxyBaseLLMRequestProcessing.get_custom_headers( user_api_key_dict=mock_user_api_key_dict, response_cost=0.0001, litellm_logging_obj=logging_obj, ) - + # Verify margin headers are not present assert "x-litellm-response-cost-margin-amount" not in headers assert "x-litellm-response-cost-margin-percent" not in headers @@ -480,13 +488,18 @@ class TestProxyBaseLLMRequestProcessing: discount_percent=0.05, discount_amount=0.000005, ) - - original_cost, discount_amount, margin_total_amount, margin_percent = _get_cost_breakdown_from_logging_obj(logging_obj) + + ( + original_cost, + discount_amount, + margin_total_amount, + margin_percent, + ) = _get_cost_breakdown_from_logging_obj(logging_obj) assert original_cost == 0.0001 assert discount_amount == 0.000005 assert margin_total_amount is None assert margin_percent is None - + # Test with margin info logging_obj_with_margin = LiteLLMLoggingObj( model="gpt-4", @@ -506,13 +519,18 @@ class TestProxyBaseLLMRequestProcessing: margin_percent=0.10, margin_total_amount=0.00001, ) - - original_cost, discount_amount, margin_total_amount, margin_percent = _get_cost_breakdown_from_logging_obj(logging_obj_with_margin) + + ( + original_cost, + discount_amount, + margin_total_amount, + margin_percent, + ) = _get_cost_breakdown_from_logging_obj(logging_obj_with_margin) assert original_cost == 0.0001 assert discount_amount is None assert margin_total_amount == 0.00001 assert margin_percent == 0.10 - + # Test with no discount or margin info logging_obj_no_discount = LiteLLMLoggingObj( model="gpt-3.5-turbo", @@ -529,15 +547,25 @@ class TestProxyBaseLLMRequestProcessing: total_cost=0.0001, cost_for_built_in_tools_cost_usd_dollar=0.0, ) - - original_cost, discount_amount, margin_total_amount, margin_percent = _get_cost_breakdown_from_logging_obj(logging_obj_no_discount) + + ( + original_cost, + discount_amount, + margin_total_amount, + margin_percent, + ) = _get_cost_breakdown_from_logging_obj(logging_obj_no_discount) assert original_cost is None assert discount_amount is None assert margin_total_amount is None assert margin_percent is None - + # Test with None logging object - original_cost, discount_amount, margin_total_amount, margin_percent = _get_cost_breakdown_from_logging_obj(None) + ( + original_cost, + discount_amount, + margin_total_amount, + margin_percent, + ) = _get_cost_breakdown_from_logging_obj(None) assert original_cost is None assert discount_amount is None assert margin_total_amount is None @@ -546,7 +574,7 @@ class TestProxyBaseLLMRequestProcessing: def test_get_custom_headers_key_spend_includes_response_cost(self): """ Test that x-litellm-key-spend header includes the current request's response_cost. - + This ensures that the spend header reflects the updated spend including the current request, even though spend tracking updates happen asynchronously after the response. """ @@ -564,10 +592,12 @@ class TestProxyBaseLLMRequestProcessing: call_id="test-call-id-1", response_cost=response_cost_1, ) - + assert "x-litellm-key-spend" in headers_1 expected_spend_1 = 0.001 + 0.0005 # Initial spend + current request cost - assert float(headers_1["x-litellm-key-spend"]) == pytest.approx(expected_spend_1, abs=1e-10) + assert float(headers_1["x-litellm-key-spend"]) == pytest.approx( + expected_spend_1, abs=1e-10 + ) assert float(headers_1["x-litellm-response-cost"]) == response_cost_1 # Test case 2: response_cost is provided as string @@ -577,10 +607,12 @@ class TestProxyBaseLLMRequestProcessing: call_id="test-call-id-2", response_cost=response_cost_2, ) - + assert "x-litellm-key-spend" in headers_2 expected_spend_2 = 0.001 + 0.0003 # Initial spend + current request cost - assert float(headers_2["x-litellm-key-spend"]) == pytest.approx(expected_spend_2, abs=1e-10) + assert float(headers_2["x-litellm-key-spend"]) == pytest.approx( + expected_spend_2, abs=1e-10 + ) # Test case 3: response_cost is None (should use original spend) headers_3 = ProxyBaseLLMRequestProcessing.get_custom_headers( @@ -588,9 +620,11 @@ class TestProxyBaseLLMRequestProcessing: call_id="test-call-id-3", response_cost=None, ) - + assert "x-litellm-key-spend" in headers_3 - assert float(headers_3["x-litellm-key-spend"]) == 0.001 # Should use original spend + assert ( + float(headers_3["x-litellm-key-spend"]) == 0.001 + ) # Should use original spend # Test case 4: response_cost is 0 (should not change spend) headers_4 = ProxyBaseLLMRequestProcessing.get_custom_headers( @@ -598,9 +632,11 @@ class TestProxyBaseLLMRequestProcessing: call_id="test-call-id-4", response_cost=0.0, ) - + assert "x-litellm-key-spend" in headers_4 - assert float(headers_4["x-litellm-key-spend"]) == 0.001 # Should remain unchanged for 0 cost + assert ( + float(headers_4["x-litellm-key-spend"]) == 0.001 + ) # Should remain unchanged for 0 cost # Test case 5: user_api_key_dict.spend is None (should default to 0.0) mock_user_api_key_dict.spend = None @@ -609,7 +645,7 @@ class TestProxyBaseLLMRequestProcessing: call_id="test-call-id-5", response_cost=0.0002, ) - + assert "x-litellm-key-spend" in headers_5 assert float(headers_5["x-litellm-key-spend"]) == 0.0002 # 0.0 + 0.0002 @@ -620,9 +656,11 @@ class TestProxyBaseLLMRequestProcessing: call_id="test-call-id-6", response_cost=-0.0001, # Negative cost (should not be added) ) - + assert "x-litellm-key-spend" in headers_6 - assert float(headers_6["x-litellm-key-spend"]) == 0.001 # Should use original spend + assert ( + float(headers_6["x-litellm-key-spend"]) == 0.001 + ) # Should use original spend # Test case 7: response_cost is invalid string (should fallback to original spend) headers_7 = ProxyBaseLLMRequestProcessing.get_custom_headers( @@ -630,9 +668,77 @@ class TestProxyBaseLLMRequestProcessing: call_id="test-call-id-7", response_cost="invalid", # Invalid string ) - + assert "x-litellm-key-spend" in headers_7 - assert float(headers_7["x-litellm-key-spend"]) == 0.001 # Should use original spend on error + assert ( + float(headers_7["x-litellm-key-spend"]) == 0.001 + ) # Should use original spend on error + + @pytest.mark.asyncio + async def test_queue_time_seconds_is_set_in_metadata(self, monkeypatch): + """ + Test that queue_time_seconds is correctly calculated and stored in metadata + after add_litellm_data_to_request populates arrival_time. + + This verifies the fix for the bug where queue_time_seconds was always None + because arrival_time was read BEFORE add_litellm_data_to_request set it. + """ + processing_obj = ProxyBaseLLMRequestProcessing(data={}) + mock_request = MagicMock(spec=Request) + mock_request.headers = {} + mock_request.url = MagicMock() + mock_request.url.path = "/v1/chat/completions" + + async def mock_add_litellm_data_to_request(*args, **kwargs): + data = kwargs.get("data", args[0] if args else {}) + # Simulate what add_litellm_data_to_request does: set arrival_time + import time + + data["proxy_server_request"] = { + "url": "/v1/chat/completions", + "method": "POST", + "headers": {}, + "body": {}, + "arrival_time": time.time() - 0.5, # Simulate request arrived 0.5s ago + } + data["metadata"] = data.get("metadata", {}) + return data + + async def mock_pre_call_hook(user_api_key_dict, data, call_type): + return copy.deepcopy(data) + + mock_proxy_logging_obj = MagicMock(spec=ProxyLogging) + mock_proxy_logging_obj.pre_call_hook = AsyncMock(side_effect=mock_pre_call_hook) + monkeypatch.setattr( + litellm.proxy.common_request_processing, + "add_litellm_data_to_request", + mock_add_litellm_data_to_request, + ) + mock_general_settings = {} + mock_user_api_key_dict = MagicMock(spec=UserAPIKeyAuth) + mock_proxy_config = MagicMock(spec=ProxyConfig) + route_type = "acompletion" + + ( + returned_data, + logging_obj, + ) = await processing_obj.common_processing_pre_call_logic( + request=mock_request, + general_settings=mock_general_settings, + user_api_key_dict=mock_user_api_key_dict, + proxy_logging_obj=mock_proxy_logging_obj, + proxy_config=mock_proxy_config, + route_type=route_type, + ) + + # Verify queue_time_seconds is set and non-negative + metadata = returned_data.get("metadata", {}) + assert ( + "queue_time_seconds" in metadata + ), "queue_time_seconds should be set in metadata" + assert ( + metadata["queue_time_seconds"] >= 0.5 + ), f"queue_time_seconds should be at least 0.5, got {metadata['queue_time_seconds']}" @pytest.mark.asyncio @@ -695,19 +801,19 @@ class TestCommonRequestProcessingHelpers: Test that when the first chunk is an error, a JSON error response is returned instead of an SSE streaming response """ + async def mock_generator(): yield 'data: {"error": {"code": 403, "message": "forbidden"}}\n\n' yield 'data: {"content": "more data"}\n\n' yield "data: [DONE]\n\n" - response = await create_response( - mock_generator(), "text/event-stream", {} - ) + response = await create_response(mock_generator(), "text/event-stream", {}) # Should return JSONResponse instead of StreamingResponse assert isinstance(response, JSONResponse) assert response.status_code == status.HTTP_403_FORBIDDEN # Verify the response is in standard JSON error format import json + body = json.loads(response.body.decode()) assert "error" in body assert body["error"]["code"] == 403 @@ -719,9 +825,7 @@ class TestCommonRequestProcessingHelpers: yield 'data: {"content": "second part"}\n\n' yield "data: [DONE]\n\n" - response = await create_response( - mock_generator(), "text/event-stream", {} - ) + response = await create_response(mock_generator(), "text/event-stream", {}) assert response.status_code == status.HTTP_200_OK content = await self.consume_stream(response) assert content == [ @@ -736,9 +840,7 @@ class TestCommonRequestProcessingHelpers: yield # Implicitly raises StopAsyncIteration - response = await create_response( - mock_generator(), "text/event-stream", {} - ) + response = await create_response(mock_generator(), "text/event-stream", {}) assert response.status_code == status.HTTP_200_OK content = await self.consume_stream(response) assert content == [] @@ -780,17 +882,17 @@ class TestCommonRequestProcessingHelpers: """ Test that when the first chunk contains a string error code, a JSON error response is returned """ + async def mock_generator(): yield 'data: {"error": {"code": "429", "message": "too many requests"}}\n\n' yield "data: [DONE]\n\n" - response = await create_response( - mock_generator(), "text/event-stream", {} - ) + response = await create_response(mock_generator(), "text/event-stream", {}) assert isinstance(response, JSONResponse) assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS # Verify the response is in standard JSON error format import json + body = json.loads(response.body.decode()) assert "error" in body assert body["error"]["code"] == "429" @@ -829,9 +931,7 @@ class TestCommonRequestProcessingHelpers: async def mock_generator(): yield "data: [DONE]\n\n" - response = await create_response( - mock_generator(), "text/event-stream", {} - ) + response = await create_response(mock_generator(), "text/event-stream", {}) assert response.status_code == status.HTTP_200_OK # Default status content = await self.consume_stream(response) assert content == ["data: [DONE]\n\n"] @@ -842,9 +942,7 @@ class TestCommonRequestProcessingHelpers: yield 'data: {"content": "actual data"}\n\n' yield "data: [DONE]\n\n" - response = await create_response( - mock_generator(), "text/event-stream", {} - ) + response = await create_response(mock_generator(), "text/event-stream", {}) assert response.status_code == status.HTTP_200_OK # Default status content = await self.consume_stream(response) assert content == [ @@ -855,7 +953,6 @@ class TestCommonRequestProcessingHelpers: async def test_create_streaming_response_all_chunks_have_dd_trace(self): """Test that all stream chunks are wrapped with dd trace at the streaming generator level""" - import json from unittest.mock import patch # Create a mock tracer @@ -873,9 +970,7 @@ class TestCommonRequestProcessingHelpers: # Patch the tracer in the common_request_processing module with patch("litellm.proxy.common_request_processing.tracer", mock_tracer): - response = await create_response( - mock_generator(), "text/event-stream", {} - ) + response = await create_response(mock_generator(), "text/event-stream", {}) assert response.status_code == 200 @@ -930,9 +1025,7 @@ class TestCommonRequestProcessingHelpers: # Patch the tracer in the common_request_processing module with patch("litellm.proxy.common_request_processing.tracer", mock_tracer): - response = await create_response( - mock_generator(), "text/event-stream", {} - ) + response = await create_response(mock_generator(), "text/event-stream", {}) # Should return JSONResponse instead of StreamingResponse assert isinstance(response, JSONResponse) @@ -940,6 +1033,7 @@ class TestCommonRequestProcessingHelpers: # Verify the response is in standard JSON error format import json + body = json.loads(response.body.decode()) assert "error" in body assert body["error"]["code"] == 400 @@ -1000,7 +1094,7 @@ class TestExtractErrorFromSSEChunk: def test_extract_error_from_sse_chunk_with_invalid_json(self): """Test invalid JSON should return default error""" - chunk = 'data: {invalid json}\n\n' + chunk = "data: {invalid json}\n\n" error = _extract_error_from_sse_chunk(chunk) assert error["message"] == "Unknown error" @@ -1037,35 +1131,35 @@ class TestExtractErrorFromSSEChunk: class TestOverrideOpenAIResponseModel: """Tests for _override_openai_response_model function""" - def test_override_model_preserves_fallback_model_when_fallback_occurred_object(self): + def test_override_model_preserves_fallback_model_when_fallback_occurred_object( + self, + ): """ Test that when a fallback occurred (x-litellm-attempted-fallbacks > 0), the actual model used (fallback model) is preserved instead of being overridden with the requested model. - + This is the regression test to ensure the model being called is properly displayed when a fallback happens. """ requested_model = "gpt-4" fallback_model = "gpt-3.5-turbo" - + # Create a mock object response with fallback model # _hidden_params is an attribute (not a dict key) accessed via getattr response_obj = MagicMock() response_obj.model = fallback_model response_obj._hidden_params = { - "additional_headers": { - "x-litellm-attempted-fallbacks": 1 - } + "additional_headers": {"x-litellm-attempted-fallbacks": 1} } - + # Call the function - should preserve fallback model _override_openai_response_model( response_obj=response_obj, requested_model=requested_model, log_context="test_context", ) - + # Verify the model was NOT overridden - should still be the fallback model assert response_obj.model == fallback_model assert response_obj.model != requested_model @@ -1077,7 +1171,7 @@ class TestOverrideOpenAIResponseModel: """ requested_model = "gpt-4" fallback_model = "claude-haiku-4-5-20251001" - + # Create a mock object response with fallback model response_obj = MagicMock() response_obj.model = fallback_model @@ -1086,14 +1180,14 @@ class TestOverrideOpenAIResponseModel: "x-litellm-attempted-fallbacks": 2 # Multiple fallbacks } } - + # Call the function - should preserve fallback model _override_openai_response_model( response_obj=response_obj, requested_model=requested_model, log_context="test_context", ) - + # Verify the model was NOT overridden - should still be the fallback model assert response_obj.model == fallback_model assert response_obj.model != requested_model @@ -1105,19 +1199,19 @@ class TestOverrideOpenAIResponseModel: """ requested_model = "gpt-4" downstream_model = "gpt-3.5-turbo" - + # Create a dict response without fallback # For dict responses, _hidden_params won't be found via getattr, # so the fallback check won't trigger and model will be overridden response_obj = {"model": downstream_model} - + # Call the function - should override to requested model _override_openai_response_model( response_obj=response_obj, requested_model=requested_model, log_context="test_context", ) - + # Verify the model WAS overridden to requested model assert response_obj["model"] == requested_model @@ -1128,21 +1222,21 @@ class TestOverrideOpenAIResponseModel: """ requested_model = "gpt-4" downstream_model = "gpt-3.5-turbo" - + # Create a mock object response without fallback response_obj = MagicMock() response_obj.model = downstream_model response_obj._hidden_params = { "additional_headers": {} # No attempted_fallbacks header } - + # Call the function - should override to requested model _override_openai_response_model( response_obj=response_obj, requested_model=requested_model, log_context="test_context", ) - + # Verify the model WAS overridden to requested model assert response_obj.model == requested_model @@ -1153,7 +1247,7 @@ class TestOverrideOpenAIResponseModel: """ requested_model = "gpt-4" downstream_model = "gpt-3.5-turbo" - + # Create a mock object response response_obj = MagicMock() response_obj.model = downstream_model @@ -1162,14 +1256,14 @@ class TestOverrideOpenAIResponseModel: "x-litellm-attempted-fallbacks": 0 # Zero means no fallback occurred } } - + # Call the function - should override to requested model _override_openai_response_model( response_obj=response_obj, requested_model=requested_model, log_context="test_context", ) - + # Verify the model WAS overridden to requested model assert response_obj.model == requested_model @@ -1180,23 +1274,21 @@ class TestOverrideOpenAIResponseModel: """ requested_model = "gpt-4" downstream_model = "gpt-3.5-turbo" - + # Create a mock object response response_obj = MagicMock() response_obj.model = downstream_model response_obj._hidden_params = { - "additional_headers": { - "x-litellm-attempted-fallbacks": None - } + "additional_headers": {"x-litellm-attempted-fallbacks": None} } - + # Call the function - should override to requested model _override_openai_response_model( response_obj=response_obj, requested_model=requested_model, log_context="test_context", ) - + # Verify the model WAS overridden to requested model assert response_obj.model == requested_model @@ -1207,19 +1299,19 @@ class TestOverrideOpenAIResponseModel: """ requested_model = "gpt-4" downstream_model = "gpt-3.5-turbo" - + # Create a mock object response without _hidden_params response_obj = MagicMock() response_obj.model = downstream_model # Don't set _hidden_params - getattr will return {} - + # Call the function - should override to requested model _override_openai_response_model( response_obj=response_obj, requested_model=requested_model, log_context="test_context", ) - + # Verify the model WAS overridden to requested model assert response_obj.model == requested_model @@ -1229,34 +1321,30 @@ class TestOverrideOpenAIResponseModel: without modifying the response. """ fallback_model = "gpt-3.5-turbo" - + # Create a mock object response response_obj = MagicMock() response_obj.model = fallback_model response_obj._hidden_params = { - "additional_headers": { - "x-litellm-attempted-fallbacks": 1 - } + "additional_headers": {"x-litellm-attempted-fallbacks": 1} } - + # Call the function with None requested_model _override_openai_response_model( response_obj=response_obj, requested_model=None, log_context="test_context", ) - + # Verify the model was not changed assert response_obj.model == fallback_model - + # Call with empty string _override_openai_response_model( response_obj=response_obj, requested_model="", log_context="test_context", ) - + # Verify the model was not changed assert response_obj.model == fallback_model - - diff --git a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py index d5d20ce0b0..31baab0092 100644 --- a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py +++ b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py @@ -663,6 +663,119 @@ class TestProxySettingEndpoints: assert "UI_LOGO_PATH" in updated_config["environment_variables"] assert mock_proxy_config["save_call_count"]() == 1 + def test_update_ui_theme_settings_with_favicon( + self, mock_proxy_config, mock_auth, monkeypatch + ): + """Test updating UI theme settings with favicon_url""" + monkeypatch.setenv("LITELLM_SALT_KEY", "test_salt_key") + monkeypatch.setattr( + "litellm.proxy.proxy_server.store_model_in_db", True + ) + + new_theme = { + "logo_url": "https://example.com/new-logo.png", + "favicon_url": "https://example.com/custom-favicon.ico", + } + + response = client.patch( + "/update/ui_theme_settings", json=new_theme + ) + + assert response.status_code == 200 + data = response.json() + + assert data["status"] == "success" + assert ( + data["theme_config"]["logo_url"] + == "https://example.com/new-logo.png" + ) + assert ( + data["theme_config"]["favicon_url"] + == "https://example.com/custom-favicon.ico" + ) + + updated_config = mock_proxy_config["config"] + assert "UI_LOGO_PATH" in updated_config["environment_variables"] + assert ( + "LITELLM_FAVICON_URL" + in updated_config["environment_variables"] + ) + assert ( + updated_config["environment_variables"][ + "LITELLM_FAVICON_URL" + ] + == "https://example.com/custom-favicon.ico" + ) + + def test_update_ui_theme_settings_clear_favicon( + self, mock_proxy_config, mock_auth, monkeypatch + ): + """Test clearing favicon_url from UI theme settings""" + monkeypatch.setenv("LITELLM_SALT_KEY", "test_salt_key") + monkeypatch.setattr( + "litellm.proxy.proxy_server.store_model_in_db", True + ) + + new_theme = { + "favicon_url": "https://example.com/custom-favicon.ico", + } + response = client.patch( + "/update/ui_theme_settings", json=new_theme + ) + assert response.status_code == 200 + + clear_theme = {"favicon_url": None} + response = client.patch( + "/update/ui_theme_settings", json=clear_theme + ) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + assert "LITELLM_FAVICON_URL" not in os.environ + + def test_get_ui_theme_settings_includes_favicon_schema( + self, mock_proxy_config + ): + """Test UI theme settings includes favicon_url in schema""" + response = client.get("/get/ui_theme_settings") + + assert response.status_code == 200 + data = response.json() + + assert "values" in data + assert "field_schema" in data + assert "properties" in data["field_schema"] + assert "favicon_url" in data["field_schema"]["properties"] + assert ( + "description" + in data["field_schema"]["properties"]["favicon_url"] + ) + + def test_get_ui_theme_settings_with_favicon_configured( + self, mock_proxy_config + ): + """Test getting UI theme settings when favicon is configured""" + mock_proxy_config["config"]["litellm_settings"][ + "ui_theme_config" + ] = { + "logo_url": "https://example.com/logo.png", + "favicon_url": "https://example.com/favicon.ico", + } + + response = client.get("/get/ui_theme_settings") + + assert response.status_code == 200 + data = response.json() + + assert ( + data["values"]["logo_url"] + == "https://example.com/logo.png" + ) + assert ( + data["values"]["favicon_url"] + == "https://example.com/favicon.ico" + ) + def test_get_ui_settings(self, mock_auth, monkeypatch): """Test retrieving UI settings with allowlist sanitization""" from unittest.mock import AsyncMock, MagicMock diff --git a/tests/test_litellm/test_get_blog_posts.py b/tests/test_litellm/test_get_blog_posts.py new file mode 100644 index 0000000000..a17d78e0bb --- /dev/null +++ b/tests/test_litellm/test_get_blog_posts.py @@ -0,0 +1,165 @@ +"""Tests for GetBlogPosts utility class.""" +import json +import time +from unittest.mock import MagicMock, patch + +import pytest + +import litellm +from litellm.litellm_core_utils.get_blog_posts import ( + BlogPost, + BlogPostsResponse, + GetBlogPosts, + get_blog_posts, +) + +SAMPLE_RESPONSE = { + "posts": [ + { + "title": "Test Post", + "description": "A test post.", + "date": "2026-01-01", + "url": "https://www.litellm.ai/blog/test", + } + ] +} + + +@pytest.fixture(autouse=True) +def reset_blog_posts_cache(): + GetBlogPosts._cached_posts = None + GetBlogPosts._last_fetch_time = 0.0 + yield + GetBlogPosts._cached_posts = None + GetBlogPosts._last_fetch_time = 0.0 + + +def test_load_local_blog_posts_returns_list(): + posts = GetBlogPosts.load_local_blog_posts() + assert isinstance(posts, list) + assert len(posts) > 0 + first = posts[0] + assert "title" in first + assert "description" in first + assert "date" in first + assert "url" in first + + +def test_validate_blog_posts_valid(): + assert GetBlogPosts.validate_blog_posts(SAMPLE_RESPONSE) is True + + +def test_validate_blog_posts_missing_posts_key(): + assert GetBlogPosts.validate_blog_posts({"other": []}) is False + + +def test_validate_blog_posts_empty_list(): + assert GetBlogPosts.validate_blog_posts({"posts": []}) is False + + +def test_validate_blog_posts_not_dict(): + assert GetBlogPosts.validate_blog_posts("not a dict") is False + + +def test_get_blog_posts_success(): + """Fetches from remote on first call.""" + mock_response = MagicMock() + mock_response.json.return_value = SAMPLE_RESPONSE + mock_response.raise_for_status = MagicMock() + + with patch("litellm.litellm_core_utils.get_blog_posts.httpx.get", return_value=mock_response): + posts = get_blog_posts(url=litellm.blog_posts_url) + + assert len(posts) == 1 + assert posts[0]["title"] == "Test Post" + + +def test_get_blog_posts_network_error_falls_back_to_local(): + """Falls back to local backup on network error.""" + with patch( + "litellm.litellm_core_utils.get_blog_posts.httpx.get", + side_effect=Exception("Network error"), + ): + posts = get_blog_posts(url=litellm.blog_posts_url) + + assert isinstance(posts, list) + assert len(posts) > 0 + + +def test_get_blog_posts_invalid_json_falls_back_to_local(): + """Falls back when remote returns non-dict.""" + mock_response = MagicMock() + mock_response.json.return_value = "not a dict" + mock_response.raise_for_status = MagicMock() + + with patch("litellm.litellm_core_utils.get_blog_posts.httpx.get", return_value=mock_response): + posts = get_blog_posts(url=litellm.blog_posts_url) + + assert isinstance(posts, list) + assert len(posts) > 0 + + +def test_get_blog_posts_ttl_cache_not_refetched(): + """Within TTL window, does not re-fetch.""" + GetBlogPosts._cached_posts = SAMPLE_RESPONSE["posts"] + GetBlogPosts._last_fetch_time = time.time() # just now + + call_count = 0 + + def mock_get(*args, **kwargs): + nonlocal call_count + call_count += 1 + m = MagicMock() + m.json.return_value = SAMPLE_RESPONSE + m.raise_for_status = MagicMock() + return m + + with patch("litellm.litellm_core_utils.get_blog_posts.httpx.get", side_effect=mock_get): + posts = get_blog_posts(url=litellm.blog_posts_url) + + assert call_count == 0 # cache hit, no fetch + assert len(posts) == 1 + + +def test_get_blog_posts_ttl_expired_refetches(): + """After TTL window, re-fetches from remote.""" + GetBlogPosts._cached_posts = SAMPLE_RESPONSE["posts"] + GetBlogPosts._last_fetch_time = time.time() - 7200 # 2 hours ago + + mock_response = MagicMock() + mock_response.json.return_value = SAMPLE_RESPONSE + mock_response.raise_for_status = MagicMock() + + with patch( + "litellm.litellm_core_utils.get_blog_posts.httpx.get", return_value=mock_response + ) as mock_get: + posts = get_blog_posts(url=litellm.blog_posts_url) + + mock_get.assert_called_once() + assert len(posts) == 1 + + +def test_get_blog_posts_local_env_var_skips_remote(monkeypatch): + monkeypatch.setenv("LITELLM_LOCAL_BLOG_POSTS", "true") + with patch("litellm.litellm_core_utils.get_blog_posts.httpx.get") as mock_get: + posts = get_blog_posts(url=litellm.blog_posts_url) + mock_get.assert_not_called() + assert isinstance(posts, list) + assert len(posts) > 0 + + +def test_blog_post_pydantic_model(): + post = BlogPost( + title="T", + description="D", + date="2026-01-01", + url="https://example.com", + ) + assert post.title == "T" + + +def test_blog_posts_response_pydantic_model(): + resp = BlogPostsResponse( + posts=[BlogPost(title="T", description="D", date="2026-01-01", url="https://x.com")] + ) + assert len(resp.posts) == 1 diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index 3ae4588278..35cb290fcc 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -13,12 +13,12 @@ sys.path.insert( import litellm from litellm.proxy.utils import is_valid_api_key from litellm.types.utils import ( + CallTypes, Delta, LlmProviders, ModelResponseStream, StreamingChoices, ) -from litellm.types.utils import CallTypes from litellm.utils import ( ProviderConfigManager, TextCompletionStreamWrapper, @@ -606,10 +606,14 @@ def test_aaamodel_prices_and_context_window_json_is_valid(): "input_cost_per_token_above_200k_tokens": {"type": "number"}, "cache_read_input_token_cost_flex": {"type": "number"}, "cache_read_input_token_cost_priority": {"type": "number"}, + "cache_read_input_token_cost_above_200k_tokens_priority": {"type": "number"}, "input_cost_per_token_flex": {"type": "number"}, "input_cost_per_token_priority": {"type": "number"}, + "input_cost_per_token_above_200k_tokens_priority": {"type": "number"}, + "input_cost_per_audio_token_priority": {"type": "number"}, "output_cost_per_token_flex": {"type": "number"}, "output_cost_per_token_priority": {"type": "number"}, + "output_cost_per_token_above_200k_tokens_priority": {"type": "number"}, "input_cost_per_pixel": {"type": "number"}, "input_cost_per_query": {"type": "number"}, "input_cost_per_request": {"type": "number"}, @@ -644,6 +648,7 @@ def test_aaamodel_prices_and_context_window_json_is_valid(): "max_video_length": {"type": "number"}, "max_videos_per_prompt": {"type": "number"}, "metadata": {"type": "object"}, + "provider_specific_entry": {"type": "object"}, "mode": { "type": "string", "enum": [ @@ -714,6 +719,7 @@ def test_aaamodel_prices_and_context_window_json_is_valid(): "supports_preset": {"type": "boolean"}, "tool_use_system_prompt_tokens": {"type": "number"}, "tpm": {"type": "number"}, + "provider_specific_entry": {"type": "object"}, "supported_endpoints": { "type": "array", "items": { @@ -802,8 +808,7 @@ def test_aaamodel_prices_and_context_window_json_is_valid(): }, } - prod_json = "./model_prices_and_context_window.json" - # prod_json = "../../model_prices_and_context_window.json" + prod_json = os.path.join(os.path.dirname(__file__), "..", "..", "model_prices_and_context_window.json") with open(prod_json, "r") as model_prices_file: actual_json = json.load(model_prices_file) assert isinstance(actual_json, dict) @@ -2337,7 +2342,7 @@ def test_register_model_with_scientific_notation(): Test that the register_model function can handle scientific notation in the model name. """ import uuid - + # Use a truly unique model name with uuid to avoid conflicts when tests run in parallel test_model_name = f"test-scientific-notation-model-{uuid.uuid4().hex[:12]}" @@ -2981,8 +2986,8 @@ class TestProxyLoggingBudgetAlerts: via metadata.soft_budget_alerting_emails to work even when global alerting is disabled. """ from litellm.caching.caching import DualCache - from litellm.proxy.utils import ProxyLogging from litellm.proxy._types import CallInfo, Litellm_EntityType + from litellm.proxy.utils import ProxyLogging proxy_logging = ProxyLogging(user_api_key_cache=DualCache()) proxy_logging.alerting = None # Global alerting is disabled @@ -3018,8 +3023,8 @@ class TestProxyLoggingBudgetAlerts: and do not send emails when alerting is None. """ from litellm.caching.caching import DualCache - from litellm.proxy.utils import ProxyLogging from litellm.proxy._types import CallInfo, Litellm_EntityType + from litellm.proxy.utils import ProxyLogging proxy_logging = ProxyLogging(user_api_key_cache=DualCache()) proxy_logging.alerting = None @@ -3050,8 +3055,8 @@ class TestProxyLoggingBudgetAlerts: Test that soft_budget alerts with empty alert_emails list still respect alerting=None. """ from litellm.caching.caching import DualCache - from litellm.proxy.utils import ProxyLogging from litellm.proxy._types import CallInfo, Litellm_EntityType + from litellm.proxy.utils import ProxyLogging proxy_logging = ProxyLogging(user_api_key_cache=DualCache()) proxy_logging.alerting = None @@ -3554,3 +3559,43 @@ class TestMetadataNoneHandling: litellm_params = {"metadata": None} metadata = litellm_params.get("metadata") or {} assert metadata == {} + + +class TestValidateAndFixThinkingParam: + """Tests for validate_and_fix_thinking_param.""" + + def test_none_returns_none(self): + from litellm.utils import validate_and_fix_thinking_param + + assert validate_and_fix_thinking_param(thinking=None) is None + + def test_already_snake_case(self): + from litellm.utils import validate_and_fix_thinking_param + + thinking = {"type": "enabled", "budget_tokens": 32000} + result = validate_and_fix_thinking_param(thinking=thinking) + assert result == {"type": "enabled", "budget_tokens": 32000} + + def test_camel_case_normalized(self): + from litellm.utils import validate_and_fix_thinking_param + + thinking = {"type": "enabled", "budgetTokens": 32000} + result = validate_and_fix_thinking_param(thinking=thinking) + assert result == {"type": "enabled", "budget_tokens": 32000} + assert "budgetTokens" not in result + + def test_both_keys_snake_case_wins(self): + from litellm.utils import validate_and_fix_thinking_param + + thinking = {"type": "enabled", "budget_tokens": 10000, "budgetTokens": 50000} + result = validate_and_fix_thinking_param(thinking=thinking) + assert result == {"type": "enabled", "budget_tokens": 10000} + assert "budgetTokens" not in result + + def test_original_dict_not_mutated(self): + from litellm.utils import validate_and_fix_thinking_param + + thinking = {"type": "enabled", "budgetTokens": 32000} + validate_and_fix_thinking_param(thinking=thinking) + assert "budgetTokens" in thinking + assert "budget_tokens" not in thinking diff --git a/tests/test_litellm/test_video_generation.py b/tests/test_litellm/test_video_generation.py index 5545a138dd..661cdd8709 100644 --- a/tests/test_litellm/test_video_generation.py +++ b/tests/test_litellm/test_video_generation.py @@ -14,6 +14,7 @@ import litellm from litellm.cost_calculator import default_video_cost_calculator from litellm.integrations.custom_logger import CustomLogger from litellm.litellm_core_utils.litellm_logging import Logging as LitellmLogging +from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler from litellm.llms.gemini.videos.transformation import GeminiVideoConfig from litellm.llms.openai.videos.transformation import OpenAIVideoConfig @@ -872,6 +873,83 @@ def test_openai_transform_video_content_request_empty_params(): assert params == {} +@pytest.mark.parametrize( + "variant,expected_suffix", + [ + ("thumbnail", "?variant=thumbnail"), + ("spritesheet", "?variant=spritesheet"), + ], +) +def test_openai_transform_video_content_request_with_variant(variant, expected_suffix): + """OpenAI content transform should append ?variant= when variant is provided.""" + config = OpenAIVideoConfig() + url, params = config.transform_video_content_request( + video_id="video_123", + api_base="https://api.openai.com/v1/videos", + litellm_params={}, + headers={}, + variant=variant, + ) + + assert url == f"https://api.openai.com/v1/videos/video_123/content{expected_suffix}" + assert params == {} + + +def test_openai_transform_video_content_request_variant_none_no_query_param(): + """OpenAI content transform should NOT append ?variant= when variant is None.""" + config = OpenAIVideoConfig() + url, params = config.transform_video_content_request( + video_id="video_123", + api_base="https://api.openai.com/v1/videos", + litellm_params={}, + headers={}, + variant=None, + ) + + assert "variant" not in url + assert url == "https://api.openai.com/v1/videos/video_123/content" + + +def test_video_content_handler_passes_variant_to_url(): + """HTTP handler should pass variant through to the final URL.""" + from litellm.llms.custom_httpx.http_handler import HTTPHandler + from litellm.types.router import GenericLiteLLMParams + + if hasattr(litellm, "in_memory_llm_clients_cache"): + litellm.in_memory_llm_clients_cache.flush_cache() + + handler = BaseLLMHTTPHandler() + config = OpenAIVideoConfig() + + mock_client = MagicMock(spec=HTTPHandler) + mock_response = MagicMock() + mock_response.content = b"thumbnail-bytes" + mock_client.get.return_value = mock_response + + with patch( + "litellm.llms.custom_httpx.llm_http_handler._get_httpx_client", + return_value=mock_client, + ): + result = handler.video_content_handler( + video_id="video_abc", + video_content_provider_config=config, + custom_llm_provider="openai", + litellm_params=GenericLiteLLMParams( + api_base="https://api.openai.com/v1" + ), + logging_obj=MagicMock(), + timeout=5.0, + api_key="sk-test", + client=mock_client, + _is_async=False, + variant="thumbnail", + ) + + assert result == b"thumbnail-bytes" + called_url = mock_client.get.call_args.kwargs["url"] + assert called_url == "https://api.openai.com/v1/videos/video_abc/content?variant=thumbnail" + + def test_video_content_handler_uses_get_for_openai(): """HTTP handler must use GET (not POST) for OpenAI content download.""" from litellm.llms.custom_httpx.http_handler import HTTPHandler @@ -1431,5 +1509,117 @@ class TestVideoEndpointsProxyLitellmParams: ) +def test_video_remix_handler_uses_api_key_from_litellm_params(): + """Sync remix handler should fall back to litellm_params api_key when api_key param is None.""" + handler = BaseLLMHTTPHandler() + config = OpenAIVideoConfig() + + with patch.object(config, "validate_environment") as mock_validate: + mock_validate.return_value = {"Authorization": "Bearer deployment-key"} + + with patch.object(config, "transform_video_remix_request") as mock_transform: + mock_transform.return_value = ("https://api.openai.com/v1/videos/video_123/remix", {"prompt": "remix it"}) + + with patch.object(config, "transform_video_remix_response") as mock_resp: + mock_resp.return_value = MagicMock() + + mock_client = MagicMock() + mock_client.post.return_value = MagicMock(status_code=200) + + with patch( + "litellm.llms.custom_httpx.llm_http_handler._get_httpx_client", + return_value=mock_client, + ): + handler.video_remix_handler( + video_id="video_123", + prompt="remix it", + video_remix_provider_config=config, + custom_llm_provider="openai", + litellm_params={"api_key": "deployment-key", "api_base": "https://api.openai.com/v1"}, + logging_obj=MagicMock(), + timeout=5.0, + api_key=None, + _is_async=False, + ) + + mock_validate.assert_called_once() + assert mock_validate.call_args.kwargs["api_key"] == "deployment-key" + + +@pytest.mark.asyncio +async def test_async_video_remix_handler_uses_api_key_from_litellm_params(): + """Async remix handler should fall back to litellm_params api_key when api_key param is None.""" + handler = BaseLLMHTTPHandler() + config = OpenAIVideoConfig() + + with patch.object(config, "validate_environment") as mock_validate: + mock_validate.return_value = {"Authorization": "Bearer deployment-key"} + + with patch.object(config, "transform_video_remix_request") as mock_transform: + mock_transform.return_value = ("https://api.openai.com/v1/videos/video_123/remix", {"prompt": "remix it"}) + + with patch.object(config, "transform_video_remix_response") as mock_resp: + mock_resp.return_value = MagicMock() + + mock_client = MagicMock(spec=AsyncHTTPHandler) + mock_response = MagicMock(status_code=200) + mock_client.post = AsyncMock(return_value=mock_response) + + with patch( + "litellm.llms.custom_httpx.llm_http_handler.get_async_httpx_client", + return_value=mock_client, + ): + await handler.async_video_remix_handler( + video_id="video_123", + prompt="remix it", + video_remix_provider_config=config, + custom_llm_provider="openai", + litellm_params={"api_key": "deployment-key", "api_base": "https://api.openai.com/v1"}, + logging_obj=MagicMock(), + timeout=5.0, + api_key=None, + ) + + mock_validate.assert_called_once() + assert mock_validate.call_args.kwargs["api_key"] == "deployment-key" + + +def test_video_remix_handler_prefers_explicit_api_key(): + """Sync remix handler should prefer explicit api_key over litellm_params.""" + handler = BaseLLMHTTPHandler() + config = OpenAIVideoConfig() + + with patch.object(config, "validate_environment") as mock_validate: + mock_validate.return_value = {"Authorization": "Bearer explicit-key"} + + with patch.object(config, "transform_video_remix_request") as mock_transform: + mock_transform.return_value = ("https://api.openai.com/v1/videos/video_123/remix", {"prompt": "remix it"}) + + with patch.object(config, "transform_video_remix_response") as mock_resp: + mock_resp.return_value = MagicMock() + + mock_client = MagicMock() + mock_client.post.return_value = MagicMock(status_code=200) + + with patch( + "litellm.llms.custom_httpx.llm_http_handler._get_httpx_client", + return_value=mock_client, + ): + handler.video_remix_handler( + video_id="video_123", + prompt="remix it", + video_remix_provider_config=config, + custom_llm_provider="openai", + litellm_params={"api_key": "deployment-key", "api_base": "https://api.openai.com/v1"}, + logging_obj=MagicMock(), + timeout=5.0, + api_key="explicit-key", + _is_async=False, + ) + + mock_validate.assert_called_once() + assert mock_validate.call_args.kwargs["api_key"] == "explicit-key" + + if __name__ == "__main__": pytest.main([__file__]) diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json index 164368eb6b..b05d707d5a 100644 --- a/ui/litellm-dashboard/package.json +++ b/ui/litellm-dashboard/package.json @@ -4,6 +4,7 @@ "private": true, "scripts": { "dev": "next dev", + "dev:webpack": "next dev --webpack", "build": "next build", "start": "next start", "lint": "next lint", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/blogPosts/useBlogPosts.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/blogPosts/useBlogPosts.ts new file mode 100644 index 0000000000..81d55e8765 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/blogPosts/useBlogPosts.ts @@ -0,0 +1,32 @@ +import { getProxyBaseUrl } from "@/components/networking"; +import { useQuery } from "@tanstack/react-query"; + +export interface BlogPost { + title: string; + description: string; + date: string; + url: string; +} + +export interface BlogPostsResponse { + posts: BlogPost[]; +} + +async function fetchBlogPosts(): Promise { + const baseUrl = getProxyBaseUrl(); + const response = await fetch(`${baseUrl}/public/litellm_blog_posts`); + if (!response.ok) { + throw new Error(`Failed to fetch blog posts: ${response.statusText}`); + } + return response.json(); +} + +export const useBlogPosts = () => { + return useQuery({ + queryKey: ["blogPosts"], + queryFn: fetchBlogPosts, + staleTime: 60 * 60 * 1000, + retry: 1, + retryDelay: 0, + }); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableBlogPosts.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableBlogPosts.ts new file mode 100644 index 0000000000..a7b37b78d4 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableBlogPosts.ts @@ -0,0 +1,33 @@ +import { LOCAL_STORAGE_EVENT, getLocalStorageItem } from "@/utils/localStorageUtils"; +import { useSyncExternalStore } from "react"; + +function subscribe(callback: () => void) { + const onStorage = (e: StorageEvent) => { + if (e.key === "disableBlogPosts") { + callback(); + } + }; + + const onCustom = (e: Event) => { + const { key } = (e as CustomEvent).detail; + if (key === "disableBlogPosts") { + callback(); + } + }; + + window.addEventListener("storage", onStorage); + window.addEventListener(LOCAL_STORAGE_EVENT, onCustom); + + return () => { + window.removeEventListener("storage", onStorage); + window.removeEventListener(LOCAL_STORAGE_EVENT, onCustom); + }; +} + +function getSnapshot() { + return getLocalStorageItem("disableBlogPosts") === "true"; +} + +export function useDisableBlogPosts() { + return useSyncExternalStore(subscribe, getSnapshot); +} diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index ae3bd76e3c..fb749d7afb 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -13,6 +13,7 @@ import { fetchTeams } from "@/components/common_components/fetch_teams"; import LoadingScreen from "@/components/common_components/LoadingScreen"; import { CostTrackingSettings } from "@/components/CostTrackingSettings"; import GeneralSettings from "@/components/general_settings"; +import GuardrailsMonitorView from "@/components/GuardrailsMonitor/GuardrailsMonitorView"; import GuardrailsPanel from "@/components/guardrails"; import PoliciesPanel from "@/components/policies"; import { Team } from "@/components/key_team_helpers/key_list"; @@ -547,6 +548,8 @@ function CreateKeyPageContent() { ) : page == "vector-stores" ? ( + ) : page == "guardrails-monitor" ? ( + ) : page == "new_usage" ? ( = ({ accessToken, publicPage, client = openai.OpenAI( api_key="your_api_key", - base_url="http://0.0.0.0:4000" # Your LiteLLM Proxy URL + base_url="${getProxyBaseUrl()}" # Your LiteLLM Proxy URL ) response = client.chat.completions.create( @@ -997,7 +997,7 @@ import asyncio config = { "mcpServers": { "${selectedMcpServer.server_name}": { - "url": "http://localhost:4000/${selectedMcpServer.server_name}/mcp", + "url": "${getProxyBaseUrl()}/${selectedMcpServer.server_name}/mcp", "headers": { "x-litellm-api-key": "Bearer sk-1234" } @@ -1016,7 +1016,7 @@ async def main(): # Call a tool response = await client.call_tool( - name="tool_name", + name="tool_name", arguments={"arg": "value"} ) print(f"Response: {response}") diff --git a/ui/litellm-dashboard/src/components/GuardrailsMonitor/EvaluationSettingsModal.tsx b/ui/litellm-dashboard/src/components/GuardrailsMonitor/EvaluationSettingsModal.tsx new file mode 100644 index 0000000000..f502d2a8a3 --- /dev/null +++ b/ui/litellm-dashboard/src/components/GuardrailsMonitor/EvaluationSettingsModal.tsx @@ -0,0 +1,157 @@ +import { CloseOutlined, PlayCircleOutlined } from "@ant-design/icons"; +import { Button, Modal, Select, Input } from "antd"; +import React, { useEffect, useState } from "react"; +import { fetchAvailableModels, type ModelGroup } from "@/components/playground/llm_calls/fetch_models"; + +const DEFAULT_PROMPT = `Evaluate whether this guardrail's decision was correct. +Analyze the user input, the guardrail action taken, and determine if it was appropriate. + +Consider: +— Was the user's intent genuinely harmful or policy-violating? +— Was the guardrail's action (block / flag / pass) appropriate? +— Could this be a false positive or false negative? + +Return a structured verdict with confidence and justification.`; + +const DEFAULT_SCHEMA = `{ + "verdict": "correct" | "false_positive" | "false_negative", + "confidence": 0.0, + "justification": "string", + "risk_category": "string", + "suggested_action": "keep" | "adjust threshold" | "add allowlist" +} +`; + +export interface EvaluationSettingsModalProps { + open: boolean; + onClose: () => void; + guardrailName?: string; + accessToken: string | null; + onRunEvaluation?: (settings: { prompt: string; schema: string; model: string }) => void; +} + +export function EvaluationSettingsModal({ + open, + onClose, + guardrailName, + accessToken, + onRunEvaluation, +}: EvaluationSettingsModalProps) { + const [prompt, setPrompt] = useState(DEFAULT_PROMPT); + const [schema, setSchema] = useState(DEFAULT_SCHEMA); + const [model, setModel] = useState(null); + const [modelOptions, setModelOptions] = useState([]); + const [loadingModels, setLoadingModels] = useState(false); + + useEffect(() => { + if (!open || !accessToken) { + setModelOptions([]); + return; + } + let cancelled = false; + setLoadingModels(true); + fetchAvailableModels(accessToken) + .then((list) => { + if (!cancelled) setModelOptions(list); + }) + .catch(() => { + if (!cancelled) setModelOptions([]); + }) + .finally(() => { + if (!cancelled) setLoadingModels(false); + }); + return () => { + cancelled = true; + }; + }, [open, accessToken]); + + const handleResetPrompt = () => setPrompt(DEFAULT_PROMPT); + const handleRun = () => { + if (model) { + onRunEvaluation?.({ prompt, schema, model }); + onClose(); + } + }; + + const modelSelectOptions = modelOptions.map((m) => ({ + value: m.model_group, + label: m.model_group, + })); + + return ( + } + destroyOnClose + > +

+ {guardrailName + ? `Configure AI evaluation for ${guardrailName}` + : "Configure AI evaluation for re-running on logs"} +

+ +
+
+
+ + +
+ setPrompt(e.target.value)} + rows={6} + className="font-mono text-sm" + /> +

+ System prompt sent to the evaluation model. Output is structured via response_format. +

+
+ +
+ +

response_format: json_schema

+ setSchema(e.target.value)} + rows={6} + className="font-mono text-sm" + /> +
+ +
+ + ({ value: v.id, label: v.label }))} + style={{ width: 140 }} + /> + +
+
+ + +
+
+ + {showVersionHistory && ( +
+ {versions.map((v) => ( +
+
+ + {v.id} + + {v.changes} +
+
+ {v.author} + {v.date} +
+
+ ))} +
+ )} + + + {/* Parameters */} +
+

Parameters

+

Configure {guardrailName} behavior

+ +
+
+ + +
+ +
+ + +
+ +
+ + Guardrail enabled in production +
+
+
+ + {/* Custom Code Override */} +
+
+
+

+ + Custom Code Override +

+

+ Replace the built-in guardrail with custom evaluation code +

+
+ +
+ + {useCustomCode && ( + setCustomCode(e.target.value)} + placeholder={`async def evaluate(input_text: str, context: dict) -> dict: + # Return {"score": 0.0-1.0, "passed": bool, "reason": str} + # Example: + if "banned_word" in input_text.lower(): + return {"score": 0.1, "passed": False, "reason": "Banned word detected"} + return {"score": 0.9, "passed": True, "reason": "No violations"}`} + rows={10} + className="font-mono text-sm" + /> + )} +
+ + {/* Re-run on Failing Logs */} +
+

Test Configuration

+

+ Re-run this guardrail on recent failing logs to validate your changes +

+ +
+ + + {rerunStatus === "success" && ( + + 7/10 would now pass with new config + + )} + + {rerunStatus === "error" && ( + Error running tests + )} +
+
+ + ); +} diff --git a/ui/litellm-dashboard/src/components/GuardrailsMonitor/GuardrailDetail.tsx b/ui/litellm-dashboard/src/components/GuardrailsMonitor/GuardrailDetail.tsx new file mode 100644 index 0000000000..3447b4cb78 --- /dev/null +++ b/ui/litellm-dashboard/src/components/GuardrailsMonitor/GuardrailDetail.tsx @@ -0,0 +1,245 @@ +import { + ArrowLeftOutlined, + SafetyOutlined, + SettingOutlined, + WarningOutlined, +} from "@ant-design/icons"; +import { useQuery } from "@tanstack/react-query"; +import { Col, Grid } from "@tremor/react"; +import { Button, Spin, Tabs } from "antd"; +import React, { useMemo, useState } from "react"; +import { + getGuardrailsUsageDetail, + getGuardrailsUsageLogs, +} from "@/components/networking"; +import { EvaluationSettingsModal } from "./EvaluationSettingsModal"; +import { LogViewer } from "./LogViewer"; +import { MetricCard } from "./MetricCard"; +import type { LogEntry } from "./mockData"; + +interface GuardrailDetailProps { + guardrailId: string; + onBack: () => void; + accessToken?: string | null; + startDate: string; + endDate: string; +} + +const statusColors: Record< + string, + { bg: string; text: string; dot: string } +> = { + healthy: { bg: "bg-green-50", text: "text-green-700", dot: "bg-green-500" }, + warning: { bg: "bg-amber-50", text: "text-amber-700", dot: "bg-amber-500" }, + critical: { bg: "bg-red-50", text: "text-red-700", dot: "bg-red-500" }, +}; + +export function GuardrailDetail({ + guardrailId, + onBack, + accessToken = null, + startDate, + endDate, +}: GuardrailDetailProps) { + const [activeTab, setActiveTab] = useState("overview"); + const [evaluationModalOpen, setEvaluationModalOpen] = useState(false); + const [logsPage, setLogsPage] = useState(1); + const logsPageSize = 50; + + const { data: detailData, isLoading: detailLoading, error: detailError } = useQuery({ + queryKey: ["guardrails-usage-detail", guardrailId, startDate, endDate], + queryFn: () => getGuardrailsUsageDetail(accessToken!, guardrailId, startDate, endDate), + enabled: !!accessToken && !!guardrailId, + }); + const { data: logsData, isLoading: logsLoading } = useQuery({ + queryKey: ["guardrails-usage-logs", guardrailId, logsPage, logsPageSize], + queryFn: () => + getGuardrailsUsageLogs(accessToken!, { + guardrailId, + page: logsPage, + pageSize: logsPageSize, + startDate, + endDate, + }), + enabled: !!accessToken && !!guardrailId, + }); + + const logs: LogEntry[] = useMemo(() => { + const list = logsData?.logs ?? []; + return list.map((l: Record) => ({ + id: l.id as string, + timestamp: l.timestamp as string, + action: l.action as "blocked" | "passed" | "flagged", + score: l.score as number | undefined, + model: l.model as string | undefined, + input_snippet: l.input_snippet as string | undefined, + output_snippet: l.output_snippet as string | undefined, + reason: l.reason as string | undefined, + })); + }, [logsData?.logs]); + + const data = detailData + ? { + name: detailData.guardrail_name, + description: detailData.description ?? "", + status: detailData.status, + provider: detailData.provider, + type: detailData.type, + requestsEvaluated: detailData.requestsEvaluated, + failRate: detailData.failRate, + avgScore: detailData.avgScore, + avgLatency: detailData.avgLatency, + } + : { + name: guardrailId, + description: "", + status: "healthy", + provider: "—", + type: "—", + requestsEvaluated: 0, + failRate: 0, + avgScore: undefined as number | undefined, + avgLatency: undefined as number | undefined, + }; + const statusStyle = statusColors[data.status] ?? statusColors.healthy; + + if (detailLoading && !detailData) { + return ( +
+ +
+ ); + } + if (detailError && !detailData) { + return ( +
+ +

Failed to load guardrail details.

+
+ ); + } + + return ( +
+
+ + +
+
+
+ +

{data.name}

+ + + {data.status.charAt(0).toUpperCase() + data.status.slice(1)} + +
+

{data.description}

+
+
+ + {data.provider} + +
+
+
+ + + + {activeTab === "overview" && ( +
+ + + + + + 15 ? "text-red-600" : data.failRate > 5 ? "text-amber-600" : "text-green-600" + } + subtitle={`${Math.round((data.requestsEvaluated * data.failRate) / 100).toLocaleString()} blocked`} + icon={data.failRate > 15 ? : undefined} + /> + + + 150 + ? "text-red-600" + : data.avgLatency > 50 + ? "text-amber-600" + : "text-green-600" + : "text-gray-500" + } + subtitle={data.avgLatency != null ? "Per request (avg)" : "No data"} + /> + + + + +
+ )} + + {activeTab === "logs" && ( +
+ +
+ )} + + setEvaluationModalOpen(false)} + guardrailName={data.name} + accessToken={accessToken} + /> +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/GuardrailsMonitor/GuardrailsMonitorView.test.tsx b/ui/litellm-dashboard/src/components/GuardrailsMonitor/GuardrailsMonitorView.test.tsx new file mode 100644 index 0000000000..081e29ec9e --- /dev/null +++ b/ui/litellm-dashboard/src/components/GuardrailsMonitor/GuardrailsMonitorView.test.tsx @@ -0,0 +1,52 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { describe, expect, it, vi } from "vitest"; +import GuardrailsMonitorView from "./GuardrailsMonitorView"; +import * as networking from "@/components/networking"; + +vi.mock("@/components/networking", () => ({ + getGuardrailsUsageOverview: vi.fn(), + formatDate: vi.fn((d: Date) => d.toISOString().slice(0, 10)), +})); + +const mockGetGuardrailsUsageOverview = vi.mocked(networking.getGuardrailsUsageOverview); + +function wrapper({ children }: { children: React.ReactNode }) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + return ( + + {children} + + ); +} + +describe("GuardrailsMonitorView", () => { + it("should render overview and fetch guardrails usage when accessToken is provided", async () => { + mockGetGuardrailsUsageOverview.mockResolvedValue({ + rows: [], + chart: [], + totalRequests: 0, + totalBlocked: 0, + passRate: 100, + }); + + render( + , + { wrapper } + ); + + expect(await screen.findByRole("heading", { name: /Guardrails Monitor/i })).toBeDefined(); + await waitFor(() => { + expect(mockGetGuardrailsUsageOverview).toHaveBeenCalled(); + }); + }); + + it("should render without crashing when accessToken is null", async () => { + render(, { wrapper }); + expect(await screen.findByRole("heading", { name: /Guardrails Monitor/i })).toBeDefined(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/GuardrailsMonitor/GuardrailsMonitorView.tsx b/ui/litellm-dashboard/src/components/GuardrailsMonitor/GuardrailsMonitorView.tsx new file mode 100644 index 0000000000..7214b0642b --- /dev/null +++ b/ui/litellm-dashboard/src/components/GuardrailsMonitor/GuardrailsMonitorView.tsx @@ -0,0 +1,74 @@ +import type { DateRangePickerValue } from "@tremor/react"; +import React, { useCallback, useMemo, useState } from "react"; +import { formatDate } from "@/components/networking"; +import AdvancedDatePicker from "@/components/shared/advanced_date_picker"; +import { GuardrailDetail } from "./GuardrailDetail"; +import { GuardrailsOverview } from "./GuardrailsOverview"; + +type View = + | { type: "overview" } + | { type: "detail"; guardrailId: string }; + +interface GuardrailsMonitorViewProps { + accessToken?: string | null; +} + +const defaultEnd = new Date(); +const defaultStart = new Date(); +defaultStart.setDate(defaultStart.getDate() - 7); + +export default function GuardrailsMonitorView({ accessToken = null }: GuardrailsMonitorViewProps) { + const [view, setView] = useState({ type: "overview" }); + + const initialFrom = useMemo(() => new Date(defaultStart), []); + const initialTo = useMemo(() => new Date(defaultEnd), []); + + const [dateValue, setDateValue] = useState({ + from: initialFrom, + to: initialTo, + }); + + const startDate = dateValue.from ? formatDate(dateValue.from) : ""; + const endDate = dateValue.to ? formatDate(dateValue.to) : ""; + + const handleDateChange = useCallback((newValue: DateRangePickerValue) => { + setDateValue(newValue); + }, []); + + const handleSelectGuardrail = (id: string) => { + setView({ type: "detail", guardrailId: id }); + }; + + const handleBack = () => { + setView({ type: "overview" }); + }; + + return ( +
+
+ +
+ {view.type === "overview" ? ( + + ) : ( + + )} +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/GuardrailsMonitor/GuardrailsOverview.tsx b/ui/litellm-dashboard/src/components/GuardrailsMonitor/GuardrailsOverview.tsx new file mode 100644 index 0000000000..d2fa53bc6c --- /dev/null +++ b/ui/litellm-dashboard/src/components/GuardrailsMonitor/GuardrailsOverview.tsx @@ -0,0 +1,313 @@ +import { + DownloadOutlined, + RiseOutlined, + SafetyOutlined, + SettingOutlined, + WarningOutlined, +} from "@ant-design/icons"; +import { useQuery } from "@tanstack/react-query"; +import { Card, Col, Grid, Title } from "@tremor/react"; +import { Button, Spin, Table } from "antd"; +import type { ColumnsType } from "antd/es/table"; +import React, { useMemo, useState } from "react"; +import { getGuardrailsUsageOverview } from "@/components/networking"; +import { type PerformanceRow } from "./mockData"; +import { EvaluationSettingsModal } from "./EvaluationSettingsModal"; +import { MetricCard } from "./MetricCard"; +import { ScoreChart } from "./ScoreChart"; + +interface GuardrailsOverviewProps { + accessToken?: string | null; + startDate: string; + endDate: string; + onSelectGuardrail: (id: string) => void; +} + +type SortKey = + | "failRate" + | "requestsEvaluated" + | "avgLatency" + | "falsePositiveRate" + | "falseNegativeRate"; + +const providerColors: Record = { + Bedrock: "bg-orange-100 text-orange-700 border-orange-200", + "Google Cloud": "bg-sky-100 text-sky-700 border-sky-200", + LiteLLM: "bg-indigo-100 text-indigo-700 border-indigo-200", + Custom: "bg-gray-100 text-gray-600 border-gray-200", +}; + +function computeMetricsFromRows(data: PerformanceRow[]) { + const totalRequests = data.reduce((sum, r) => sum + r.requestsEvaluated, 0); + const totalBlocked = data.reduce( + (sum, r) => sum + Math.round((r.requestsEvaluated * r.failRate) / 100), + 0 + ); + const passRate = + totalRequests > 0 ? ((1 - totalBlocked / totalRequests) * 100).toFixed(1) : "0"; + const withLat = data.filter((r) => r.avgLatency != null); + const avgLatency = + withLat.length > 0 + ? Math.round(withLat.reduce((sum, r) => sum + (r.avgLatency ?? 0), 0) / withLat.length) + : 0; + return { totalRequests, totalBlocked, passRate, avgLatency, count: data.length }; +} + +export function GuardrailsOverview({ + accessToken = null, + startDate, + endDate, + onSelectGuardrail, +}: GuardrailsOverviewProps) { + const [sortBy, setSortBy] = useState("failRate"); + const [sortDir, setSortDir] = useState<"asc" | "desc">("desc"); + const [evaluationModalOpen, setEvaluationModalOpen] = useState(false); + + const { data: guardrailsData, isLoading: guardrailsLoading, error: guardrailsError } = useQuery({ + queryKey: ["guardrails-usage-overview", startDate, endDate], + queryFn: () => getGuardrailsUsageOverview(accessToken!, startDate, endDate), + enabled: !!accessToken, + }); + + const activeData: PerformanceRow[] = guardrailsData?.rows ?? []; + const metrics = useMemo(() => { + if (guardrailsData) { + return { + totalRequests: guardrailsData.totalRequests ?? 0, + totalBlocked: guardrailsData.totalBlocked ?? 0, + passRate: String(guardrailsData.passRate ?? 0), + avgLatency: activeData.length ? Math.round(activeData.reduce((s, r) => s + (r.avgLatency ?? 0), 0) / activeData.length) : 0, + count: activeData.length, + }; + } + return computeMetricsFromRows(activeData); + }, [guardrailsData, activeData]); + const chartData = guardrailsData?.chart; + const sorted = useMemo(() => { + return [...activeData].sort((a, b) => { + const mult = sortDir === "desc" ? -1 : 1; + const aVal = a[sortBy] ?? 0; + const bVal = b[sortBy] ?? 0; + return (Number(aVal) - Number(bVal)) * mult; + }); + }, [activeData, sortBy, sortDir]); + const isLoading = guardrailsLoading; + const error = guardrailsError; + + const columns: ColumnsType = [ + { + title: "Guardrail", + dataIndex: "name", + key: "name", + render: (name: string, row) => ( + + ), + }, + { + title: "Provider", + dataIndex: "provider", + key: "provider", + render: (provider: string) => ( + + {provider} + + ), + }, + { + title: "Requests", + dataIndex: "requestsEvaluated", + key: "requestsEvaluated", + align: "right", + sorter: true, + sortOrder: sortBy === "requestsEvaluated" ? (sortDir === "desc" ? "descend" : "ascend") : null, + render: (v: number) => v.toLocaleString(), + }, + { + title: "Fail Rate", + dataIndex: "failRate", + key: "failRate", + align: "right", + sorter: true, + sortOrder: sortBy === "failRate" ? (sortDir === "desc" ? "descend" : "ascend") : null, + render: (v: number, row) => ( + 15 ? "text-red-600" : v > 5 ? "text-amber-600" : "text-green-600" + } + > + {v}% + {row.trend === "up" && } + {row.trend === "down" && } + + ), + }, + { + title: "Avg. latency added", + dataIndex: "avgLatency", + key: "avgLatency", + align: "right", + sorter: true, + sortOrder: sortBy === "avgLatency" ? (sortDir === "desc" ? "descend" : "ascend") : null, + render: (v?: number) => ( + 150 ? "text-red-600" : v > 50 ? "text-amber-600" : "text-green-600" + } + > + {v != null ? `${v}ms` : "—"} + + ), + }, + { + title: "Status", + dataIndex: "status", + key: "status", + align: "center", + render: (status: string) => ( + + + {status} + + ), + }, + ]; + + const sortableKeys: SortKey[] = ["failRate", "requestsEvaluated", "avgLatency"]; + const handleTableChange = (_pagination: unknown, _filters: unknown, sorter: unknown) => { + const s = sorter as { field?: keyof PerformanceRow; order?: string }; + if (s?.field && sortableKeys.includes(s.field as SortKey)) { + setSortBy(s.field as SortKey); + setSortDir(s.order === "ascend" ? "asc" : "desc"); + } + }; + + return ( +
+
+
+
+ +

Guardrails Monitor

+
+

+ Monitor guardrail performance across all requests +

+
+
+ +
+
+ + + + + + + } + /> + + + } + /> + + + 150 + ? "text-red-600" + : metrics.avgLatency > 50 + ? "text-amber-600" + : "text-green-600" + } + /> + + + + + + +
+ +
+ + + {(isLoading || error) && ( +
+ {isLoading && } + {error && Failed to load data. Try again.} +
+ )} +
+
+ + Guardrail Performance + +

+ Click a guardrail to view details, logs, and configuration +

+
+
+
+
+ ({ + onClick: () => onSelectGuardrail(row.id), + style: { cursor: "pointer" }, + })} + /> + + + setEvaluationModalOpen(false)} + accessToken={accessToken} + /> + + ); +} diff --git a/ui/litellm-dashboard/src/components/GuardrailsMonitor/LogViewer.tsx b/ui/litellm-dashboard/src/components/GuardrailsMonitor/LogViewer.tsx new file mode 100644 index 0000000000..aed19ddfc8 --- /dev/null +++ b/ui/litellm-dashboard/src/components/GuardrailsMonitor/LogViewer.tsx @@ -0,0 +1,227 @@ +import { + CheckCircleOutlined, + CloseOutlined, + DownOutlined, + WarningOutlined, +} from "@ant-design/icons"; +import { useQuery } from "@tanstack/react-query"; +import moment from "moment"; +import { Button, Spin } from "antd"; +import React, { useState } from "react"; +import { uiSpendLogsCall } from "@/components/networking"; +import { LogDetailsDrawer } from "@/components/view_logs/LogDetailsDrawer"; +import type { LogEntry as ViewLogsLogEntry } from "@/components/view_logs/columns"; +import type { LogEntry } from "./mockData"; + +const actionConfig: Record< + "blocked" | "passed" | "flagged", + { icon: React.ElementType; color: string; bg: string; border: string; label: string } +> = { + blocked: { + icon: CloseOutlined, + color: "text-red-600", + bg: "bg-red-50", + border: "border-red-200", + label: "Blocked", + }, + passed: { + icon: CheckCircleOutlined, + color: "text-green-600", + bg: "bg-green-50", + border: "border-green-200", + label: "Passed", + }, + flagged: { + icon: WarningOutlined, + color: "text-amber-600", + bg: "bg-amber-50", + border: "border-amber-200", + label: "Flagged", + }, +}; + +interface LogViewerProps { + guardrailName?: string; + filterAction?: "all" | "blocked" | "passed" | "flagged"; + logs?: LogEntry[]; + logsLoading?: boolean; + totalLogs?: number; + accessToken?: string | null; + startDate?: string; + endDate?: string; +} + +export function LogViewer({ + guardrailName, + filterAction = "all", + logs = [], + logsLoading = false, + totalLogs, + accessToken = null, + startDate = "", + endDate = "", +}: LogViewerProps) { + const [sampleSize, setSampleSize] = useState(10); + const [activeFilter, setActiveFilter] = useState(filterAction); + const [selectedRequestId, setSelectedRequestId] = useState(null); + const [drawerOpen, setDrawerOpen] = useState(false); + + const filteredLogs = logs.filter( + (log) => activeFilter === "all" || log.action === activeFilter + ); + const displayLogs = filteredLogs.slice(0, sampleSize); + const total = totalLogs ?? logs.length; + const sampleSizes = [10, 50, 100]; + const filters: Array<"all" | "blocked" | "flagged" | "passed"> = [ + "all", + "blocked", + "flagged", + "passed", + ]; + + const startTime = startDate + ? moment(startDate).utc().format("YYYY-MM-DD HH:mm:ss") + : moment().subtract(24, "hours").utc().format("YYYY-MM-DD HH:mm:ss"); + const endTime = endDate + ? moment(endDate).utc().endOf("day").format("YYYY-MM-DD HH:mm:ss") + : moment().utc().format("YYYY-MM-DD HH:mm:ss"); + + const { data: fullLogResponse } = useQuery({ + queryKey: ["spend-log-by-request", selectedRequestId, startTime, endTime], + queryFn: async () => { + if (!accessToken || !selectedRequestId) return null; + const res = await uiSpendLogsCall({ + accessToken, + start_date: startTime, + end_date: endTime, + page: 1, + page_size: 10, + params: { request_id: selectedRequestId }, + }); + return res as { data: ViewLogsLogEntry[]; total: number }; + }, + enabled: Boolean(accessToken && selectedRequestId && drawerOpen), + }); + + const selectedLog: ViewLogsLogEntry | null = + fullLogResponse?.data?.[0] ?? null; + + const handleLogClick = (log: LogEntry) => { + setSelectedRequestId(log.id); + setDrawerOpen(true); + }; + + const handleCloseDrawer = () => { + setDrawerOpen(false); + setSelectedRequestId(null); + }; + + return ( +
+
+
+
+

+ {guardrailName ? `Logs — ${guardrailName}` : "Request Logs"} +

+

+ {logsLoading + ? "Loading…" + : logs.length > 0 + ? `Showing ${displayLogs.length} of ${total} entries` + : "No logs for this period. Select a guardrail and date range."} +

+
+ {logs.length > 0 && ( +
+
+ {filters.map((f) => ( + + ))} +
+
+
+ Sample: + {sampleSizes.map((size) => ( + + ))} +
+
+ )} +
+
+ + {logsLoading && ( +
+ +
+ )} + {!logsLoading && displayLogs.length === 0 && ( +
+ No logs to display. Adjust filters or date range. +
+ )} + {!logsLoading && displayLogs.length > 0 && ( +
+ {displayLogs.map((log) => { + const config = actionConfig[log.action]; + const ActionIcon = config.icon; + return ( + + ); + })} +
+ )} + + +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/GuardrailsMonitor/MetricCard.tsx b/ui/litellm-dashboard/src/components/GuardrailsMonitor/MetricCard.tsx new file mode 100644 index 0000000000..4a11efe72a --- /dev/null +++ b/ui/litellm-dashboard/src/components/GuardrailsMonitor/MetricCard.tsx @@ -0,0 +1,30 @@ +import React, { type ReactNode } from "react"; + +interface MetricCardProps { + label: string; + value: string | number; + valueColor?: string; + icon?: ReactNode; + subtitle?: string; +} + +export function MetricCard({ + label, + value, + valueColor = "text-gray-900", + icon, + subtitle, +}: MetricCardProps) { + return ( +
+
+ {label} + {icon && {icon}} +
+
+ {value} +
+ {subtitle &&

{subtitle}

} +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/GuardrailsMonitor/ScoreChart.tsx b/ui/litellm-dashboard/src/components/GuardrailsMonitor/ScoreChart.tsx new file mode 100644 index 0000000000..e4803747d4 --- /dev/null +++ b/ui/litellm-dashboard/src/components/GuardrailsMonitor/ScoreChart.tsx @@ -0,0 +1,39 @@ +import { BarChart, Card, Title } from "@tremor/react"; +import React from "react"; + +/** + * Overview chart: Request Outcomes Over Time (passed vs blocked). + * Uses Tremor BarChart with stacked data. Data from usage/overview API (chart array). + */ +interface ScoreChartProps { + data?: Array<{ date: string; passed: number; blocked: number }>; +} + +export function ScoreChart({ data }: ScoreChartProps) { + const chartData = data && data.length > 0 ? data : []; + return ( + + + Request Outcomes Over Time + +
+ {chartData.length > 0 ? ( + v.toLocaleString()} + yAxisWidth={48} + showLegend={true} + stack={true} + /> + ) : ( +
+ No chart data for this period +
+ )} +
+
+ ); +} diff --git a/ui/litellm-dashboard/src/components/GuardrailsMonitor/mockData.ts b/ui/litellm-dashboard/src/components/GuardrailsMonitor/mockData.ts new file mode 100644 index 0000000000..7d99ebe7c4 --- /dev/null +++ b/ui/litellm-dashboard/src/components/GuardrailsMonitor/mockData.ts @@ -0,0 +1,50 @@ +/** + * Types for Guardrails Monitor dashboard (data from usage API). + */ + +export interface PerformanceRow { + id: string; + name: string; + type: string; + provider: string; + requestsEvaluated: number; + failRate: number; + avgScore?: number; + avgLatency?: number; + p95Latency?: number; + falsePositiveRate?: number; + falseNegativeRate?: number; + status: "healthy" | "warning" | "critical"; + trend: "up" | "down" | "stable"; +} + +export interface GuardrailDetailRecord { + name: string; + type: string; + provider: string; + requestsEvaluated: number; + failRate: number; + avgScore?: number; + avgLatency?: number; + p95Latency?: number; + falsePositiveRate?: number; + falsePositiveCount?: number; + falseNegativeRate?: number; + falseNegativeCount?: number; + status: string; + description: string; +} + +export interface LogEntry { + id: string; + timestamp: string; + input?: string; + output?: string; + input_snippet?: string; + output_snippet?: string; + score?: number; + action: "blocked" | "passed" | "flagged"; + model?: string; + reason?: string; + latency_ms?: number; +} diff --git a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx new file mode 100644 index 0000000000..4ca0aa2aae --- /dev/null +++ b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx @@ -0,0 +1,230 @@ +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderWithProviders, screen, waitFor } from "../../../../tests/test-utils"; +import { BlogDropdown } from "./BlogDropdown"; + +let mockDisableBlogPosts = false; +let mockRefetch = vi.fn(); +let mockUseBlogPostsResult: { + data: { posts: { title: string; date: string; description: string; url: string }[] } | null | undefined; + isLoading: boolean; + isError: boolean; + refetch: () => void; +} = { + data: undefined, + isLoading: false, + isError: false, + refetch: mockRefetch, +}; + +vi.mock("@/app/(dashboard)/hooks/useDisableBlogPosts", () => ({ + useDisableBlogPosts: () => mockDisableBlogPosts, +})); + +vi.mock("@/app/(dashboard)/hooks/blogPosts/useBlogPosts", () => ({ + useBlogPosts: () => mockUseBlogPostsResult, +})); + +const MOCK_POSTS = [ + { title: "Post One", date: "2026-02-01", description: "Description one", url: "https://example.com/1" }, + { title: "Post Two", date: "2026-02-02", description: "Description two", url: "https://example.com/2" }, + { title: "Post Three", date: "2026-02-03", description: "Description three", url: "https://example.com/3" }, + { title: "Post Four", date: "2026-02-04", description: "Description four", url: "https://example.com/4" }, + { title: "Post Five", date: "2026-02-05", description: "Description five", url: "https://example.com/5" }, + { title: "Post Six", date: "2026-02-06", description: "Description six", url: "https://example.com/6" }, +]; + +async function openDropdown() { + const user = userEvent.setup(); + await user.hover(screen.getByRole("button", { name: /blog/i })); +} + +describe("BlogDropdown", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockDisableBlogPosts = false; + mockRefetch = vi.fn(); + mockUseBlogPostsResult = { + data: undefined, + isLoading: false, + isError: false, + refetch: mockRefetch, + }; + }); + + describe("when blog posts are disabled", () => { + it("should render nothing", () => { + mockDisableBlogPosts = true; + const { container } = renderWithProviders(); + expect(container).toBeEmptyDOMElement(); + }); + }); + + describe("when blog posts are enabled", () => { + it("should render the Blog trigger button", () => { + renderWithProviders(); + expect(screen.getByRole("button", { name: /blog/i })).toBeInTheDocument(); + }); + + describe("loading state", () => { + it("should show a loading spinner", async () => { + mockUseBlogPostsResult = { ...mockUseBlogPostsResult, isLoading: true }; + renderWithProviders(); + + await openDropdown(); + + await waitFor(() => { + expect(document.querySelector(".anticon-loading")).toBeInTheDocument(); + }); + }); + }); + + describe("error state", () => { + beforeEach(() => { + mockUseBlogPostsResult = { ...mockUseBlogPostsResult, isError: true }; + }); + + it("should show an error message", async () => { + renderWithProviders(); + + await openDropdown(); + + await waitFor(() => { + expect(screen.getByText("Failed to load posts")).toBeInTheDocument(); + }); + }); + + it("should show a Retry button", async () => { + renderWithProviders(); + + await openDropdown(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument(); + }); + }); + + it("should call refetch when Retry is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.hover(screen.getByRole("button", { name: /blog/i })); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole("button", { name: /retry/i })); + + expect(mockRefetch).toHaveBeenCalledTimes(1); + }); + }); + + describe("empty state", () => { + it("should show 'No posts available' when data is null", async () => { + mockUseBlogPostsResult = { ...mockUseBlogPostsResult, data: null }; + renderWithProviders(); + + await openDropdown(); + + await waitFor(() => { + expect(screen.getByText("No posts available")).toBeInTheDocument(); + }); + }); + + it("should show 'No posts available' when posts array is empty", async () => { + mockUseBlogPostsResult = { ...mockUseBlogPostsResult, data: { posts: [] } }; + renderWithProviders(); + + await openDropdown(); + + await waitFor(() => { + expect(screen.getByText("No posts available")).toBeInTheDocument(); + }); + }); + }); + + describe("with posts", () => { + beforeEach(() => { + mockUseBlogPostsResult = { ...mockUseBlogPostsResult, data: { posts: MOCK_POSTS.slice(0, 3) } }; + }); + + it("should render post titles", async () => { + renderWithProviders(); + + await openDropdown(); + + await waitFor(() => { + expect(screen.getByText("Post One")).toBeInTheDocument(); + expect(screen.getByText("Post Two")).toBeInTheDocument(); + expect(screen.getByText("Post Three")).toBeInTheDocument(); + }); + }); + + it("should render post descriptions", async () => { + renderWithProviders(); + + await openDropdown(); + + await waitFor(() => { + expect(screen.getByText("Description one")).toBeInTheDocument(); + }); + }); + + it("should render post links with correct attributes", async () => { + renderWithProviders(); + + await openDropdown(); + + await waitFor(() => { + const link = screen.getByRole("link", { name: /post one/i }); + expect(link).toHaveAttribute("href", "https://example.com/1"); + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); + }); + }); + + it("should render formatted post dates", async () => { + mockUseBlogPostsResult = { + ...mockUseBlogPostsResult, + data: { posts: [{ title: "Date Post", date: "2026-02-15", description: "Desc", url: "https://example.com" }] }, + }; + renderWithProviders(); + + await openDropdown(); + + await waitFor(() => { + expect(screen.getByText("Feb 15, 2026")).toBeInTheDocument(); + }); + }); + + it("should render the 'View all posts' link", async () => { + renderWithProviders(); + + await openDropdown(); + + await waitFor(() => { + const viewAllLink = screen.getByRole("link", { name: /view all posts/i }); + expect(viewAllLink).toHaveAttribute("href", "https://docs.litellm.ai/blog"); + expect(viewAllLink).toHaveAttribute("target", "_blank"); + expect(viewAllLink).toHaveAttribute("rel", "noopener noreferrer"); + }); + }); + }); + + describe("post limit", () => { + it("should render at most 5 posts when more than 5 are provided", async () => { + mockUseBlogPostsResult = { ...mockUseBlogPostsResult, data: { posts: MOCK_POSTS } }; + renderWithProviders(); + + await openDropdown(); + + await waitFor(() => { + expect(screen.getByText("Post One")).toBeInTheDocument(); + expect(screen.getByText("Post Five")).toBeInTheDocument(); + expect(screen.queryByText("Post Six")).not.toBeInTheDocument(); + }); + }); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx new file mode 100644 index 0000000000..ddb2a33cda --- /dev/null +++ b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx @@ -0,0 +1,84 @@ +import { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts"; +import { useBlogPosts, type BlogPost } from "@/app/(dashboard)/hooks/blogPosts/useBlogPosts"; +import { LoadingOutlined } from "@ant-design/icons"; +import { Button, Dropdown, Space, Typography } from "antd"; +import type { MenuProps } from "antd"; +import React from "react"; + +const { Text, Title, Paragraph } = Typography; + +function formatDate(dateStr: string): string { + const date = new Date(dateStr + "T00:00:00"); + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); +} + +export const BlogDropdown: React.FC = () => { + const disableBlogPosts = useDisableBlogPosts(); + + const { data, isLoading, isError, refetch } = useBlogPosts(); + + if (disableBlogPosts) { + return null; + } + + let items: MenuProps["items"]; + + if (isLoading) { + items = [{ key: "loading", label: , disabled: true }]; + } else if (isError) { + items = [ + { + key: "error", + label: ( + + Failed to load posts + + + ), + disabled: true, + }, + ]; + } else if (!data || data.posts.length === 0) { + items = [{ key: "empty", label: No posts available, disabled: true }]; + } else { + items = [ + ...data.posts.slice(0, 5).map((post: BlogPost) => ({ + key: post.url, + label: ( + + + {post.title} + + + {formatDate(post.date)} + + {post.description} + + ), + })), + { type: "divider" as const }, + { + key: "view-all", + label: ( + + View all posts + + ), + }, + ]; + } + + return ( + + + + ); +}; + +export default BlogDropdown; diff --git a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx index 90e02ae447..2bef9a8077 100644 --- a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx @@ -1,4 +1,5 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts"; import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts"; import { useDisableUsageIndicator } from "@/app/(dashboard)/hooks/useDisableUsageIndicator"; import { @@ -29,6 +30,7 @@ const UserDropdown: React.FC = ({ onLogout }) => { const { userId, userEmail, userRole, premiumUser } = useAuthorized(); const disableShowPrompts = useDisableShowPrompts(); const disableUsageIndicator = useDisableUsageIndicator(); + const disableBlogPosts = useDisableBlogPosts(); const [disableShowNewBadge, setDisableShowNewBadge] = useState(false); useEffect(() => { @@ -148,6 +150,23 @@ const UserDropdown: React.FC = ({ onLogout }) => { aria-label="Toggle hide usage indicator" /> + + Hide Blog Posts + { + if (checked) { + setLocalStorageItem("disableBlogPosts", "true"); + emitLocalStorageChange("disableBlogPosts"); + } else { + removeLocalStorageItem("disableBlogPosts"); + emitLocalStorageChange("disableBlogPosts"); + } + }} + aria-label="Toggle hide blog posts" + /> + ); diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterSettings.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterSettings.test.tsx new file mode 100644 index 0000000000..2b9d9ba9f8 --- /dev/null +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterSettings.test.tsx @@ -0,0 +1,165 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, act } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import MCPSemanticFilterSettings from "./MCPSemanticFilterSettings"; +import { useMCPSemanticFilterSettings } from "@/app/(dashboard)/hooks/mcpSemanticFilterSettings/useMCPSemanticFilterSettings"; +import { useUpdateMCPSemanticFilterSettings } from "@/app/(dashboard)/hooks/mcpSemanticFilterSettings/useUpdateMCPSemanticFilterSettings"; + +vi.mock( + "@/app/(dashboard)/hooks/mcpSemanticFilterSettings/useMCPSemanticFilterSettings", + () => ({ useMCPSemanticFilterSettings: vi.fn() }) +); + +vi.mock( + "@/app/(dashboard)/hooks/mcpSemanticFilterSettings/useUpdateMCPSemanticFilterSettings", + () => ({ useUpdateMCPSemanticFilterSettings: vi.fn() }) +); + +vi.mock("@/components/playground/llm_calls/fetch_models", () => ({ + fetchAvailableModels: vi.fn().mockResolvedValue([]), +})); + +vi.mock("./MCPSemanticFilterTestPanel", () => ({ + default: () =>
, +})); + +vi.mock("./semanticFilterTestUtils", () => ({ + getCurlCommand: vi.fn().mockReturnValue("curl ..."), + runSemanticFilterTest: vi.fn(), +})); + +const mockMutate = vi.fn(); + +const defaultSettingsData = { + field_schema: { + properties: { + enabled: { description: "Enable semantic filtering for MCP tools" }, + }, + }, + values: { + enabled: false, + embedding_model: "text-embedding-3-small", + top_k: 10, + similarity_threshold: 0.3, + }, +}; + +// Helper that renders the component and flushes the fetchAvailableModels effect +async function renderSettings(props: React.ComponentProps) { + render(); + if (props.accessToken) { + // Let the async fetchAvailableModels effect settle to avoid act() warnings + await act(async () => {}); + } +} + +describe("MCPSemanticFilterSettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useMCPSemanticFilterSettings).mockReturnValue({ + data: defaultSettingsData, + isLoading: false, + isError: false, + error: null, + } as any); + vi.mocked(useUpdateMCPSemanticFilterSettings).mockReturnValue({ + mutate: mockMutate, + isPending: false, + error: null, + } as any); + }); + + it("should render", async () => { + await renderSettings({ accessToken: "test-token" }); + expect(screen.getByText("Semantic Tool Filtering")).toBeInTheDocument(); + }); + + it("should show a login prompt when accessToken is null", () => { + render(); + expect(screen.getByText(/please log in/i)).toBeInTheDocument(); + }); + + it("should not render the form when accessToken is null", () => { + render(); + expect(screen.queryByText("Enable Semantic Filtering")).not.toBeInTheDocument(); + }); + + it("should not show the settings content while loading", async () => { + vi.mocked(useMCPSemanticFilterSettings).mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + error: null, + } as any); + await renderSettings({ accessToken: "test-token" }); + expect(screen.queryByText("Semantic Tool Filtering")).not.toBeInTheDocument(); + }); + + it("should show an error alert when data fails to load", async () => { + vi.mocked(useMCPSemanticFilterSettings).mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + error: new Error("Network error"), + } as any); + await renderSettings({ accessToken: "test-token" }); + expect( + screen.getByText("Could not load MCP Semantic Filter settings") + ).toBeInTheDocument(); + expect(screen.getByText("Network error")).toBeInTheDocument(); + }); + + it("should show the error message from the error object when loading fails", async () => { + vi.mocked(useMCPSemanticFilterSettings).mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + error: new Error("Connection refused"), + } as any); + await renderSettings({ accessToken: "test-token" }); + expect(screen.getByText("Connection refused")).toBeInTheDocument(); + }); + + it("should render the info alert and form fields when data is loaded", async () => { + await renderSettings({ accessToken: "test-token" }); + expect(screen.getByText("Semantic Tool Filtering")).toBeInTheDocument(); + expect(screen.getByText("Enable Semantic Filtering")).toBeInTheDocument(); + expect(screen.getByText("Top K Results")).toBeInTheDocument(); + expect(screen.getByText("Similarity Threshold")).toBeInTheDocument(); + }); + + it("should render the test panel", async () => { + await renderSettings({ accessToken: "test-token" }); + expect(screen.getByTestId("mcp-test-panel")).toBeInTheDocument(); + }); + + it("should have Save Settings button disabled initially", async () => { + await renderSettings({ accessToken: "test-token" }); + expect( + screen.getByRole("button", { name: /save settings/i }) + ).toBeDisabled(); + }); + + it("should enable Save Settings button after a form field is changed", async () => { + const user = userEvent.setup(); + await renderSettings({ accessToken: "test-token" }); + + expect(screen.getByRole("button", { name: /save settings/i })).toBeDisabled(); + + await user.click(screen.getByRole("switch")); + + expect(screen.getByRole("button", { name: /save settings/i })).not.toBeDisabled(); + }); + + it("should show an error alert when the mutation fails", async () => { + vi.mocked(useUpdateMCPSemanticFilterSettings).mockReturnValue({ + mutate: mockMutate, + isPending: false, + error: new Error("Failed to update settings"), + } as any); + await renderSettings({ accessToken: "test-token" }); + expect(screen.getByText("Could not update settings")).toBeInTheDocument(); + expect(screen.getByText("Failed to update settings")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.test.tsx new file mode 100644 index 0000000000..974a6a7bf0 --- /dev/null +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.test.tsx @@ -0,0 +1,141 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import MCPSemanticFilterTestPanel from "./MCPSemanticFilterTestPanel"; +import { TestResult } from "./semanticFilterTestUtils"; + +vi.mock("@/components/common_components/ModelSelector", () => ({ + default: ({ onChange, value, labelText, disabled }: any) => ( +
+ + +
+ ), +})); + +const buildProps = ( + overrides: Partial> = {} +) => ({ + accessToken: "test-token", + testQuery: "", + setTestQuery: vi.fn(), + testModel: "gpt-4o", + setTestModel: vi.fn(), + isTesting: false, + onTest: vi.fn(), + filterEnabled: true, + testResult: null as TestResult | null, + curlCommand: "curl --location 'http://localhost:4000/v1/responses'", + ...overrides, +}); + +describe("MCPSemanticFilterTestPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render the Test Configuration card", () => { + render(); + expect(screen.getByText("Test Configuration")).toBeInTheDocument(); + }); + + it("should show the test query textarea", () => { + render(); + expect( + screen.getByPlaceholderText(/enter a test query to see which tools/i) + ).toBeInTheDocument(); + }); + + it("should call setTestQuery when user types in the query field", () => { + const mockSetTestQuery = vi.fn(); + render(); + + const textarea = screen.getByPlaceholderText(/enter a test query to see which tools/i); + fireEvent.change(textarea, { target: { value: "find relevant tools" } }); + + expect(mockSetTestQuery).toHaveBeenCalledWith("find relevant tools"); + }); + + it("should disable the Test Filter button when testQuery is empty", () => { + render(); + expect(screen.getByRole("button", { name: /test filter/i })).toBeDisabled(); + }); + + it("should disable the Test Filter button when filterEnabled is false", () => { + render( + + ); + expect(screen.getByRole("button", { name: /test filter/i })).toBeDisabled(); + }); + + it("should enable the Test Filter button when testQuery is set and filter is enabled", () => { + render( + + ); + expect(screen.getByRole("button", { name: /test filter/i })).not.toBeDisabled(); + }); + + it("should call onTest when the Test Filter button is clicked", async () => { + const mockOnTest = vi.fn(); + const user = userEvent.setup(); + render( + + ); + + await user.click(screen.getByRole("button", { name: /test filter/i })); + expect(mockOnTest).toHaveBeenCalledOnce(); + }); + + it("should show a warning when semantic filtering is disabled", () => { + render(); + expect(screen.getByText("Semantic filtering is disabled")).toBeInTheDocument(); + }); + + it("should not show the disabled warning when filterEnabled is true", () => { + render(); + expect(screen.queryByText("Semantic filtering is disabled")).not.toBeInTheDocument(); + }); + + it("should display test results when testResult is provided", () => { + const testResult: TestResult = { + totalTools: 10, + selectedTools: 3, + tools: ["wiki-fetch", "github-search", "slack-post"], + }; + render(); + + expect(screen.getByText("3 tools selected")).toBeInTheDocument(); + expect(screen.getByText("Filtered from 10 available tools")).toBeInTheDocument(); + expect(screen.getByText("wiki-fetch")).toBeInTheDocument(); + expect(screen.getByText("github-search")).toBeInTheDocument(); + expect(screen.getByText("slack-post")).toBeInTheDocument(); + }); + + it("should not render the results section when testResult is null", () => { + render(); + expect(screen.queryByText("Results")).not.toBeInTheDocument(); + }); + + it("should show the curl command in the API Usage tab", async () => { + const user = userEvent.setup(); + const curlCommand = "curl --location 'http://localhost:4000/v1/responses' --header 'Authorization: Bearer sk-1234'"; + render(); + + await user.click(screen.getByRole("tab", { name: "API Usage" })); + + expect(screen.getByText(curlCommand)).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/semanticFilterTestUtils.test.ts b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/semanticFilterTestUtils.test.ts new file mode 100644 index 0000000000..12acdf8b8b --- /dev/null +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/semanticFilterTestUtils.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getCurlCommand, runSemanticFilterTest } from "./semanticFilterTestUtils"; +import { testMCPSemanticFilter } from "@/components/networking"; +import NotificationManager from "@/components/molecules/notifications_manager"; + +vi.mock("@/components/networking", () => ({ + testMCPSemanticFilter: vi.fn(), +})); + +describe("getCurlCommand", () => { + it("should include the model name in the curl command", () => { + const result = getCurlCommand("gpt-4o", "test query"); + expect(result).toContain('"gpt-4o"'); + }); + + it("should include the query in the curl command", () => { + const result = getCurlCommand("gpt-4o", "find relevant files"); + expect(result).toContain("find relevant files"); + }); + + it("should use a placeholder when query is empty", () => { + const result = getCurlCommand("gpt-4o", ""); + expect(result).toContain("Your query here"); + }); +}); + +describe("runSemanticFilterTest", () => { + const mockSetIsTesting = vi.fn(); + const mockSetTestResult = vi.fn(); + const baseArgs = { + accessToken: "test-token", + testModel: "gpt-4o", + testQuery: "find relevant files", + setIsTesting: mockSetIsTesting, + setTestResult: mockSetTestResult, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should call NotificationManager.error and not set isTesting when testQuery is empty", async () => { + await runSemanticFilterTest({ ...baseArgs, testQuery: "" }); + expect(NotificationManager.error).toHaveBeenCalledWith("Please enter a query and select a model"); + expect(mockSetIsTesting).not.toHaveBeenCalled(); + }); + + it("should call NotificationManager.error and not set isTesting when testModel is empty", async () => { + await runSemanticFilterTest({ ...baseArgs, testModel: "" }); + expect(NotificationManager.error).toHaveBeenCalledWith("Please enter a query and select a model"); + expect(mockSetIsTesting).not.toHaveBeenCalled(); + }); + + it("should set isTesting to true then false around the API call", async () => { + vi.mocked(testMCPSemanticFilter).mockResolvedValueOnce({ + data: {}, + headers: { filter: "5->2", tools: "tool-a,tool-b" }, + }); + + await runSemanticFilterTest(baseArgs); + + expect(mockSetIsTesting).toHaveBeenNthCalledWith(1, true); + expect(mockSetIsTesting).toHaveBeenNthCalledWith(2, false); + }); + + it("should clear the previous test result before making a new request", async () => { + vi.mocked(testMCPSemanticFilter).mockResolvedValueOnce({ + data: {}, + headers: { filter: "5->2", tools: "tool-a,tool-b" }, + }); + + await runSemanticFilterTest(baseArgs); + + expect(mockSetTestResult).toHaveBeenNthCalledWith(1, null); + }); + + it("should set test result with parsed data on success", async () => { + vi.mocked(testMCPSemanticFilter).mockResolvedValueOnce({ + data: {}, + headers: { filter: "10->3", tools: "wiki,github,slack" }, + }); + + await runSemanticFilterTest(baseArgs); + + expect(mockSetTestResult).toHaveBeenCalledWith({ + totalTools: 10, + selectedTools: 3, + tools: ["wiki", "github", "slack"], + }); + expect(NotificationManager.success).toHaveBeenCalledWith( + "Semantic filter test completed successfully" + ); + }); + + it("should show a warning when the filter header is missing", async () => { + vi.mocked(testMCPSemanticFilter).mockResolvedValueOnce({ + data: {}, + headers: { filter: null, tools: null }, + }); + + await runSemanticFilterTest(baseArgs); + + expect(NotificationManager.warning).toHaveBeenCalledWith( + "Semantic filter is not enabled or no tools were filtered" + ); + expect(mockSetTestResult).not.toHaveBeenCalledWith(expect.objectContaining({ totalTools: expect.any(Number) })); + }); + + it("should show an error notification and finish testing when the API call fails", async () => { + vi.mocked(testMCPSemanticFilter).mockRejectedValueOnce(new Error("Network error")); + + await runSemanticFilterTest(baseArgs); + + expect(NotificationManager.error).toHaveBeenCalledWith("Failed to test semantic filter"); + expect(mockSetIsTesting).toHaveBeenLastCalledWith(false); + }); +}); diff --git a/ui/litellm-dashboard/src/components/TeamSSOSettings.test.tsx b/ui/litellm-dashboard/src/components/TeamSSOSettings.test.tsx index 935d26099c..ae93b11879 100644 --- a/ui/litellm-dashboard/src/components/TeamSSOSettings.test.tsx +++ b/ui/litellm-dashboard/src/components/TeamSSOSettings.test.tsx @@ -9,36 +9,6 @@ import NotificationsManager from "./molecules/notifications_manager"; vi.mock("./networking"); -vi.mock("@tremor/react", async (importOriginal) => { - const actual = await importOriginal(); - const React = await import("react"); - const Card = ({ children }: { children: React.ReactNode }) => React.createElement("div", { "data-testid": "card" }, children); - Card.displayName = "Card"; - const Title = ({ children }: { children: React.ReactNode }) => React.createElement("h2", {}, children); - Title.displayName = "Title"; - const Text = ({ children }: { children: React.ReactNode }) => React.createElement("span", {}, children); - Text.displayName = "Text"; - const Divider = () => React.createElement("hr", {}); - Divider.displayName = "Divider"; - const TextInput = ({ value, onChange, placeholder, className }: any) => - React.createElement("input", { - type: "text", - value: value || "", - onChange, - placeholder, - className, - }); - TextInput.displayName = "TextInput"; - return { - ...actual, - Card, - Title, - Text, - Divider, - TextInput, - }; -}); - vi.mock("./common_components/budget_duration_dropdown", () => { const BudgetDurationDropdown = ({ value, onChange }: { value: string | null; onChange: (value: string) => void }) => ( { + setPassthroughHeaders({ + ...passthroughHeaders, + [headerName]: e.target.value, + }); + }} + prefix={} + className="rounded" + /> +
+ ))} + { + refetchTools(); + setShowHeaderInput(false); + }} + disabled={Object.values(passthroughHeaders).every(v => !v || !v.trim())} + className="w-full mt-2" + > + Load Tools + +
+ )} + + {!showHeaderInput && Object.keys(passthroughHeaders).length > 0 && ( +
+ + + {Object.keys(passthroughHeaders).length} header(s) configured + +
+ )} + + )} + {/* Tool Selection - Show tools first */}
diff --git a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx index 6856322b53..f13ec198c7 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx @@ -132,6 +132,7 @@ export interface MCPToolsViewerProps { userRole: string | null; userID: string | null; serverAlias?: string | null; + extraHeaders?: string[] | null; } export interface MCPServer { diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 29bb7e4352..6ffd744cce 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -7147,7 +7147,11 @@ export const testSearchToolConnection = async (accessToken: string, litellmParam } }; -export const listMCPTools = async (accessToken: string, serverId: string) => { +export const listMCPTools = async ( + accessToken: string, + serverId: string, + customHeaders?: Record +) => { try { // Construct base URL let url = proxyBaseUrl @@ -7159,6 +7163,7 @@ export const listMCPTools = async (accessToken: string, serverId: string) => { const headers: Record = { [globalLitellmHeaderName]: `Bearer ${accessToken}`, "Content-Type": "application/json", + ...customHeaders, // Merge custom headers for passthrough auth }; const response = await fetch(url, { @@ -7194,6 +7199,7 @@ export const listMCPTools = async (accessToken: string, serverId: string) => { export interface CallMCPToolOptions { guardrails?: string[]; + customHeaders?: Record; } export const callMCPTool = async ( @@ -7212,6 +7218,7 @@ export const callMCPTool = async ( const headers: Record = { [globalLitellmHeaderName]: `Bearer ${accessToken}`, "Content-Type": "application/json", + ...(options?.customHeaders || {}), // Merge custom headers for passthrough auth }; const body: Record = { From 12f37cea4329f28c4fb5091848fd5afe16dc9e57 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 24 Feb 2026 14:22:38 +0530 Subject: [PATCH 14/17] fix: Missing OAuth session state. Please retry --- litellm/utils.py | 4 +- .../mcp_server/test_discoverable_endpoints.py | 179 ++++++++++++++++++ .../src/app/mcp/oauth/callback/page.tsx | 9 +- .../mcp_tools/create_mcp_server.tsx | 15 ++ .../components/mcp_tools/mcp_server_edit.tsx | 22 ++- .../src/hooks/useMcpOAuthFlow.tsx | 84 +++++--- 6 files changed, 283 insertions(+), 30 deletions(-) diff --git a/litellm/utils.py b/litellm/utils.py index fef99c8b20..08eca3be3d 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -5823,8 +5823,8 @@ def get_model_info(model: str, custom_llm_provider: Optional[str] = None) -> Mod if value is not None: _model_info[key] = value # type: ignore - if verbose_logger.isEnabledFor(logging.DEBUG): - verbose_logger.debug(f"model_info: {_model_info}") + # if verbose_logger.isEnabledFor(logging.DEBUG): + # verbose_logger.debug(f"model_info: {_model_info}") returned_model_info = ModelInfo( **_model_info, supported_openai_params=supported_openai_params diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py index 041cc687b9..700ba86b10 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py @@ -1487,3 +1487,182 @@ async def test_discovery_root_includes_server_name_prefix(): assert response["scopes_supported"] == ["read", "write"] finally: global_mcp_server_manager.registry.clear() + + +@pytest.mark.asyncio +async def test_oauth_callback_redirects_with_state(): + """Test OAuth callback endpoint properly decodes state and redirects to client callback URL.""" + try: + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + callback, + ) + except ImportError: + pytest.skip("MCP discoverable endpoints not available") + + # Mock the state decoding + mock_state_data = { + "base_url": "http://localhost:3000/ui/mcp/oauth/callback", + "original_state": "test-uuid-state-123", + "code_challenge": "test_challenge", + "code_challenge_method": "S256", + "client_redirect_uri": "http://localhost:3000/ui/mcp/oauth/callback", + } + + with patch( + "litellm.proxy._experimental.mcp_server.discoverable_endpoints.decode_state_hash" + ) as mock_decode: + mock_decode.return_value = mock_state_data + + # Call callback endpoint with code and state + response = await callback( + code="test_authorization_code_12345", + state="encrypted_state_value", + ) + + # Should redirect to the client callback URL with code and original state + assert response.status_code == 302 + assert "http://localhost:3000/ui/mcp/oauth/callback" in response.headers["location"] + assert "code=test_authorization_code_12345" in response.headers["location"] + assert "state=test-uuid-state-123" in response.headers["location"] + + # Verify state was decoded + mock_decode.assert_called_once_with("encrypted_state_value") + + +@pytest.mark.asyncio +async def test_oauth_callback_handles_invalid_state(): + """Test OAuth callback returns error page when state decryption fails.""" + try: + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + callback, + ) + except ImportError: + pytest.skip("MCP discoverable endpoints not available") + + # Mock state decoding to raise an exception + with patch( + "litellm.proxy._experimental.mcp_server.discoverable_endpoints.decode_state_hash" + ) as mock_decode: + mock_decode.side_effect = Exception("Failed to decrypt state") + + # Call callback endpoint with invalid state + response = await callback( + code="test_code", + state="invalid_encrypted_state", + ) + + # Should return HTML error page + assert response.status_code == 200 + assert "Authentication incomplete" in response.body.decode() + + +@pytest.mark.asyncio +async def test_oauth_authorize_includes_scopes_from_server_config(): + """Test that authorize endpoint includes scopes from server configuration.""" + try: + from fastapi import Request + + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + authorize_with_server, + ) + from litellm.proxy._types import MCPTransport + from litellm.types.mcp import MCPAuth + from litellm.types.mcp_server.mcp_server_manager import MCPServer + except ImportError: + pytest.skip("MCP discoverable endpoints not available") + + # Create server with specific scopes (e.g., GitLab requires 'ai_workflows') + oauth_server = MCPServer( + server_id="gitlab_server", + name="gitlab", + server_name="gitlab", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + authorization_url="https://gitlab.com/oauth/authorize", + token_url="https://gitlab.com/oauth/token", + client_id="test_client", + scopes=["api", "read_user", "ai_workflows"], # GitLab-specific scopes + ) + + mock_request = MagicMock(spec=Request) + mock_request.base_url = "https://litellm.example.com/" + mock_request.headers = {} + + with patch( + "litellm.proxy._experimental.mcp_server.discoverable_endpoints.encrypt_value_helper" + ) as mock_encrypt: + mock_encrypt.return_value = "encrypted_state" + + # Call authorize without explicit scope parameter + response = await authorize_with_server( + request=mock_request, + mcp_server=oauth_server, + client_id="test_client", + redirect_uri="http://localhost:3000/callback", + state="test_state", + code_challenge="test_challenge", + code_challenge_method="S256", + response_type="code", + scope=None, # No scope in request, should use server's scopes + ) + + # Should redirect with scopes from server config + assert response.status_code in (307, 302) + redirect_url = response.headers["location"] + assert "scope=api+read_user+ai_workflows" in redirect_url or "scope=api%20read_user%20ai_workflows" in redirect_url + + +@pytest.mark.asyncio +async def test_oauth_authorize_prefers_request_scope_over_server_config(): + """Test that explicit scope parameter takes precedence over server configuration.""" + try: + from fastapi import Request + + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + authorize_with_server, + ) + from litellm.proxy._types import MCPTransport + from litellm.types.mcp import MCPAuth + from litellm.types.mcp_server.mcp_server_manager import MCPServer + except ImportError: + pytest.skip("MCP discoverable endpoints not available") + + oauth_server = MCPServer( + server_id="test_server", + name="test", + server_name="test", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + authorization_url="https://provider.com/oauth/authorize", + token_url="https://provider.com/oauth/token", + client_id="test_client", + scopes=["default_scope1", "default_scope2"], + ) + + mock_request = MagicMock(spec=Request) + mock_request.base_url = "https://litellm.example.com/" + mock_request.headers = {} + + with patch( + "litellm.proxy._experimental.mcp_server.discoverable_endpoints.encrypt_value_helper" + ) as mock_encrypt: + mock_encrypt.return_value = "encrypted_state" + + # Call authorize WITH explicit scope parameter + response = await authorize_with_server( + request=mock_request, + mcp_server=oauth_server, + client_id="test_client", + redirect_uri="http://localhost:3000/callback", + state="test_state", + code_challenge="test_challenge", + code_challenge_method="S256", + response_type="code", + scope="custom_scope1 custom_scope2", # Explicit scope should take precedence + ) + + # Should use the explicit scope, not server config + assert response.status_code in (307, 302) + redirect_url = response.headers["location"] + assert "scope=custom_scope1+custom_scope2" in redirect_url or "scope=custom_scope1%20custom_scope2" in redirect_url + assert "default_scope" not in redirect_url diff --git a/ui/litellm-dashboard/src/app/mcp/oauth/callback/page.tsx b/ui/litellm-dashboard/src/app/mcp/oauth/callback/page.tsx index f005eab414..73ff8c51ba 100644 --- a/ui/litellm-dashboard/src/app/mcp/oauth/callback/page.tsx +++ b/ui/litellm-dashboard/src/app/mcp/oauth/callback/page.tsx @@ -41,13 +41,16 @@ const McpOAuthCallbackContent = () => { } try { + // Store in both sessionStorage and localStorage for redundancy window.sessionStorage.setItem(RESULT_STORAGE_KEY, JSON.stringify(payload)); + window.localStorage.setItem(RESULT_STORAGE_KEY, JSON.stringify(payload)); } catch (err) { - console.error("Failed to persist OAuth callback payload", err); + // Silently ignore storage errors } - const returnUrl = window.sessionStorage.getItem(RETURN_URL_STORAGE_KEY); - console.info("[MCP OAuth callback] returnUrl", returnUrl); + // Check both sessionStorage and localStorage for return URL + const returnUrl = window.sessionStorage.getItem(RETURN_URL_STORAGE_KEY) || + window.localStorage.getItem(RETURN_URL_STORAGE_KEY); const destination = returnUrl || resolveDefaultRedirect(); window.location.replace(destination); }, [payload]); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx index fb9efffb9b..bd7e3f9034 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx @@ -129,6 +129,21 @@ const CreateMCPServer: React.FC = ({ }, onTokenReceived: (token) => { setOauthAccessToken(token?.access_token ?? null); + + if (token?.access_token) { + const credentials = { + access_token: token.access_token, + ...(token.refresh_token && { refresh_token: token.refresh_token }), + ...(token.expires_in && { expires_in: token.expires_in }), + ...(token.scope && { scope: token.scope }), + }; + + form.setFieldsValue({ credentials }); + + NotificationsManager.success( + "OAuth authorization successful! Please click 'Create MCP Server' to save the configuration." + ); + } }, onBeforeRedirect: persistCreateUiState, }); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx index 88f8a737af..7cb590e319 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx @@ -117,6 +117,21 @@ const MCPServerEdit: React.FC = ({ }, onTokenReceived: (token) => { setOauthAccessToken(token?.access_token ?? null); + + if (token?.access_token) { + const credentials = { + access_token: token.access_token, + ...(token.refresh_token && { refresh_token: token.refresh_token }), + ...(token.expires_in && { expires_in: token.expires_in }), + ...(token.scope && { scope: token.scope }), + }; + + form.setFieldsValue({ credentials }); + + NotificationsManager.success( + "OAuth authorization successful! Please click 'Update MCP Server' to save the credentials." + ); + } }, onBeforeRedirect: persistEditUiState, }); @@ -234,8 +249,13 @@ const MCPServerEdit: React.FC = ({ } }, [mcpServer]); - // Fetch tools when component mounts + // Fetch tools when component mounts or when OAuth token is received + // But only if the server has been properly saved (has a permanent server_id) useEffect(() => { + // Don't fetch if server hasn't been saved yet (no permanent server_id) + if (!mcpServer.server_id || mcpServer.server_id.trim() === "") { + return; + } fetchTools(); }, [mcpServer, accessToken, oauthAccessToken]); diff --git a/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx b/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx index a62d8baa75..f914e42f04 100644 --- a/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx +++ b/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import NotificationsManager from "@/components/molecules/notifications_manager"; import { buildMcpOAuthAuthorizeUrl, @@ -61,6 +61,7 @@ export const useMcpOAuthFlow = ({ const [status, setStatus] = useState("idle"); const [error, setError] = useState(null); const [tokenResponse, setTokenResponse] = useState | null>(null); + const processingRef = useRef(false); const FLOW_STATE_KEY = "litellm-mcp-oauth-flow-state"; const RESULT_KEY = "litellm-mcp-oauth-result"; @@ -75,6 +76,28 @@ export const useMcpOAuthFlow = ({ redirectUri: string; }; + const setStorageItem = (key: string, value: string) => { + if (typeof window === "undefined") return; + try { + // Store in both sessionStorage and localStorage for redundancy + window.sessionStorage.setItem(key, value); + window.localStorage.setItem(key, value); + } catch (err) { + console.warn(`Failed to set storage item ${key}`, err); + } + }; + + const getStorageItem = (key: string): string | null => { + if (typeof window === "undefined") return null; + try { + // Try sessionStorage first, fall back to localStorage + return window.sessionStorage.getItem(key) || window.localStorage.getItem(key); + } catch (err) { + console.warn(`Failed to get storage item ${key}`, err); + return null; + } + }; + const clearStoredFlow = () => { if (typeof window === "undefined") { return; @@ -83,6 +106,9 @@ export const useMcpOAuthFlow = ({ window.sessionStorage.removeItem(FLOW_STATE_KEY); window.sessionStorage.removeItem(RESULT_KEY); window.sessionStorage.removeItem(RETURN_URL_KEY); + window.localStorage.removeItem(FLOW_STATE_KEY); + window.localStorage.removeItem(RESULT_KEY); + window.localStorage.removeItem(RETURN_URL_KEY); } catch (err) { console.warn("Failed to clear OAuth storage", err); } @@ -187,10 +213,9 @@ export const useMcpOAuthFlow = ({ } try { - window.sessionStorage.setItem(FLOW_STATE_KEY, JSON.stringify(flowState)); - window.sessionStorage.setItem(RETURN_URL_KEY, window.location.href); + setStorageItem(FLOW_STATE_KEY, JSON.stringify(flowState)); + setStorageItem(RETURN_URL_KEY, window.location.href); } catch (storageErr) { - console.error("Unable to persist OAuth state", storageErr); throw new Error("Unable to access browser storage for OAuth. Please enable storage and retry."); } @@ -209,19 +234,28 @@ export const useMcpOAuthFlow = ({ return; } + // Prevent duplicate processing + if (processingRef.current) { + return; + } + let payload: Record | null = null; let flowState: StoredFlowState | null = null; try { - const storedPayload = window.sessionStorage.getItem(RESULT_KEY); + const storedPayload = getStorageItem(RESULT_KEY); if (!storedPayload) { return; } + + // Mark as processing + processingRef.current = true; payload = JSON.parse(storedPayload); - flowState = JSON.parse(window.sessionStorage.getItem(FLOW_STATE_KEY) || "null"); + const storedFlowState = getStorageItem(FLOW_STATE_KEY); + flowState = storedFlowState ? JSON.parse(storedFlowState) : null; } catch (err) { - console.error("Failed to read OAuth session state", err); clearStoredFlow(); + processingRef.current = false; setError("Failed to resume OAuth flow. Please retry."); setStatus("error"); NotificationsManager.error("Failed to resume OAuth flow. Please retry."); @@ -229,14 +263,26 @@ export const useMcpOAuthFlow = ({ } if (!payload) { + processingRef.current = false; return; } - window.sessionStorage.removeItem(RESULT_KEY); + // Clear the result key after reading it + if (typeof window !== "undefined") { + try { + window.sessionStorage.removeItem(RESULT_KEY); + window.localStorage.removeItem(RESULT_KEY); + } catch (err) { + // Silently ignore storage errors + } + } try { if (!flowState || !flowState.state || !flowState.codeVerifier || !flowState.serverId) { - throw new Error("Missing OAuth session state. Please retry."); + throw new Error( + "OAuth session state was lost. This can happen if you have strict browser privacy settings. " + + "Please try again and ensure cookies/storage is enabled." + ); } if (!payload.state || payload.state !== flowState.state) { throw new Error("OAuth state mismatch. Please retry."); @@ -264,31 +310,21 @@ export const useMcpOAuthFlow = ({ setError(null); NotificationsManager.success("OAuth token retrieved successfully"); } catch (err) { - console.error("OAuth flow failed", err); const message = err instanceof Error ? err.message : String(err); setError(message); setStatus("error"); NotificationsManager.error(message); } finally { clearStoredFlow(); + // Reset processing flag after a delay to allow UI updates + setTimeout(() => { + processingRef.current = false; + }, 1000); } }, [onTokenReceived]); useEffect(() => { - let cancelled = false; - - const maybeResume = async () => { - if (cancelled) { - return; - } - await resumeOAuthFlow(); - }; - - maybeResume(); - - return () => { - cancelled = true; - }; + resumeOAuthFlow(); }, [resumeOAuthFlow]); return { From c86b1746422b60c6a289abd83a03876973887066 Mon Sep 17 00:00:00 2001 From: shivam Date: Tue, 24 Feb 2026 03:28:28 -0800 Subject: [PATCH 15/17] replaced with mock key --- docs/my-website/docs/realtime.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/my-website/docs/realtime.md b/docs/my-website/docs/realtime.md index b191c82c67..c853a860de 100644 --- a/docs/my-website/docs/realtime.md +++ b/docs/my-website/docs/realtime.md @@ -85,7 +85,7 @@ const url = "ws://0.0.0.0:4000/v1/realtime?model=openai-gpt-4o-realtime-audio"; // const url = "wss://my-endpoint-sweden-berri992.openai.azure.com/openai/realtime?api-version=2024-10-01-preview&deployment=gpt-4o-realtime-preview"; const ws = new WebSocket(url, { headers: { - "api-key": `f28ab7b695af4154bc53498e5bdccb07`, + "api-key": `sk-1234`, "OpenAI-Beta": "realtime=v1", }, }); From 737a04b3ea6fc709c1421307d593b18c51486b6e Mon Sep 17 00:00:00 2001 From: Julio Quinteros Pro Date: Tue, 24 Feb 2026 09:52:50 -0300 Subject: [PATCH 16/17] Revert "Merge pull request #21957 from jquinter/fix/flaky-rpm-limit-test" This reverts commit 77453ada2abd8b50cdfea714eeea61c9d9ef8f16, reversing changes made to 7622f26918934e94fca532d716a157211d8dca27. --- .../pre_call_checks/model_rate_limit_check.py | 45 ++++++++++++++++- .../test_enforce_model_rate_limits.py | 50 ++----------------- 2 files changed, 48 insertions(+), 47 deletions(-) diff --git a/litellm/router_utils/pre_call_checks/model_rate_limit_check.py b/litellm/router_utils/pre_call_checks/model_rate_limit_check.py index 836f985874..e5be61690b 100644 --- a/litellm/router_utils/pre_call_checks/model_rate_limit_check.py +++ b/litellm/router_utils/pre_call_checks/model_rate_limit_check.py @@ -129,8 +129,27 @@ class ModelRateLimitingCheck(CustomLogger): ), ) - # Check RPM limit (atomic increment-first to avoid race conditions) + # Check RPM limit if rpm_limit is not None: + # First check local cache + current_rpm = self.dual_cache.get_cache(key=rpm_key, local_only=True) + if current_rpm >= rpm_limit: + raise litellm.RateLimitError( + message=f"Model rate limit exceeded. RPM limit={rpm_limit}, current usage={current_rpm}", + llm_provider="", + model=model_name, + response=httpx.Response( + status_code=429, + content=f"{RouterErrors.user_defined_ratelimit_error.value} rpm limit={rpm_limit}. current usage={current_rpm}. id={model_id}, model_group={model_group}", + headers={"retry-after": str(60)}, + request=httpx.Request( + method="model_rate_limit_check", + url="https://github.com/BerriAI/litellm", + ), + ), + ) + + # Check redis cache and increment current_rpm = self.dual_cache.increment_cache( key=rpm_key, value=1, ttl=RoutingArgs.ttl ) @@ -207,8 +226,30 @@ class ModelRateLimitingCheck(CustomLogger): num_retries=0, # Don't retry - return 429 immediately ) - # Check RPM limit (atomic increment-first to avoid race conditions) + # Check RPM limit if rpm_limit is not None: + # First check local cache + current_rpm = await self.dual_cache.async_get_cache( + key=rpm_key, local_only=True + ) + if current_rpm is not None and current_rpm >= rpm_limit: + raise litellm.RateLimitError( + message=f"Model rate limit exceeded. RPM limit={rpm_limit}, current usage={current_rpm}", + llm_provider="", + model=model_name, + response=httpx.Response( + status_code=429, + content=f"{RouterErrors.user_defined_ratelimit_error.value} rpm limit={rpm_limit}. current usage={current_rpm}. id={model_id}, model_group={model_group}", + headers={"retry-after": str(60)}, + request=httpx.Request( + method="model_rate_limit_check", + url="https://github.com/BerriAI/litellm", + ), + ), + num_retries=0, # Don't retry - return 429 immediately + ) + + # Check redis cache and increment current_rpm = await self.dual_cache.async_increment_cache( key=rpm_key, value=1, diff --git a/tests/test_litellm/test_router/test_enforce_model_rate_limits.py b/tests/test_litellm/test_router/test_enforce_model_rate_limits.py index 1def253ac9..3bca3df4e1 100644 --- a/tests/test_litellm/test_router/test_enforce_model_rate_limits.py +++ b/tests/test_litellm/test_router/test_enforce_model_rate_limits.py @@ -5,14 +5,12 @@ This feature allows users to enforce TPM/RPM limits set on model deployments regardless of the routing strategy being used. """ -import asyncio from unittest.mock import AsyncMock, MagicMock import pytest import litellm from litellm import Router -from litellm.caching.dual_cache import DualCache from litellm.router_utils.pre_call_checks.model_rate_limit_check import ( ModelRateLimitingCheck, ) @@ -90,7 +88,7 @@ class TestModelRateLimitingCheck: def test_pre_call_check_raises_rate_limit_error_when_over_rpm(self): """Test that RateLimitError is raised when RPM limit is exceeded.""" mock_cache = MagicMock() - mock_cache.increment_cache.return_value = 11 # Over limit after increment + mock_cache.get_cache.return_value = 10 # Already at limit check = ModelRateLimitingCheck(dual_cache=mock_cache) @@ -105,11 +103,12 @@ class TestModelRateLimitingCheck: check.pre_call_check(deployment) assert "RPM limit=10" in str(exc_info.value) - assert "current usage=11" in str(exc_info.value) + assert "current usage=10" in str(exc_info.value) def test_pre_call_check_allows_request_under_limit(self): """Test that requests are allowed when under the limit.""" mock_cache = MagicMock() + mock_cache.get_cache.return_value = 5 mock_cache.increment_cache.return_value = 6 check = ModelRateLimitingCheck(dual_cache=mock_cache) @@ -189,8 +188,7 @@ class TestModelRateLimitingCheckAsync: async def test_async_pre_call_check_raises_rate_limit_error_when_over_rpm(self): """Test that RateLimitError is raised when RPM limit is exceeded (async).""" mock_cache = MagicMock() - mock_cache.async_get_cache = AsyncMock(return_value=None) - mock_cache.async_increment_cache = AsyncMock(return_value=11) # Over limit + mock_cache.async_get_cache = AsyncMock(return_value=10) # Already at limit check = ModelRateLimitingCheck(dual_cache=mock_cache) @@ -210,7 +208,7 @@ class TestModelRateLimitingCheckAsync: async def test_async_pre_call_check_allows_request_under_limit(self): """Test that requests are allowed when under the limit (async).""" mock_cache = MagicMock() - mock_cache.async_get_cache = AsyncMock(return_value=None) + mock_cache.async_get_cache = AsyncMock(return_value=5) mock_cache.async_increment_cache = AsyncMock(return_value=6) check = ModelRateLimitingCheck(dual_cache=mock_cache) @@ -315,41 +313,3 @@ class TestRouterWithEnforceModelRateLimits: break assert found, "ModelRateLimitingCheck should be in litellm.callbacks" - - -class TestModelRateLimitConcurrency: - """Test that RPM rate limiting is atomic under concurrent requests.""" - - @pytest.mark.asyncio - async def test_concurrent_requests_respect_rpm_limit(self): - """ - Fire 4 concurrent async requests with RPM limit of 2. - Exactly 2 should succeed and 2 should raise RateLimitError. - - This test validates the atomic increment-first pattern: - the old check-then-increment pattern would let 3+ through - due to a race condition on the local cache read. - """ - dual_cache = DualCache() - check = ModelRateLimitingCheck(dual_cache=dual_cache) - - deployment = { - "rpm": 2, - "litellm_params": {"model": "gpt-4"}, - "model_info": {"id": "concurrent-test-id"}, - "model_name": "test-model", - } - - async def attempt_request(): - return await check.async_pre_call_check(deployment) - - results = await asyncio.gather( - *[attempt_request() for _ in range(4)], - return_exceptions=True, - ) - - successes = [r for r in results if not isinstance(r, Exception)] - failures = [r for r in results if isinstance(r, litellm.RateLimitError)] - - assert len(successes) == 2, f"Expected 2 successes, got {len(successes)}" - assert len(failures) == 2, f"Expected 2 rate limit errors, got {len(failures)}" From 816f9052ff6444657bf6acf671a2eea49af58d1e Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 24 Feb 2026 19:27:12 +0530 Subject: [PATCH 17/17] Fix: Transport Type for OpenAPI Spec on UI --- .../proxy/_experimental/mcp_server/mcp_server_manager.py | 1 + .../src/components/mcp_tools/mcp_server_columns.tsx | 8 +++++++- .../src/components/mcp_tools/mcp_server_edit.tsx | 4 ++-- .../src/components/mcp_tools/mcp_server_view.tsx | 4 ++-- ui/litellm-dashboard/src/components/mcp_tools/types.tsx | 7 ++++++- 5 files changed, 18 insertions(+), 6 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 5c72bfbc13..b9f335ce5d 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -2604,6 +2604,7 @@ class MCPServerManager: server.mcp_info.get("description") if server.mcp_info else None ), url=server.url, + spec_path=server.spec_path, transport=server.transport, auth_type=server.auth_type, created_at=datetime.now(), diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_columns.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_columns.tsx index 78e9c6f465..d49949e7e0 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_columns.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_columns.tsx @@ -47,7 +47,13 @@ export const mcpServerColumns = ( { accessorKey: "transport", header: "Transport", - cell: ({ getValue }) => {((getValue() as string) || "http").toUpperCase()}, + cell: ({ row }) => { + const transport = row.original.transport || "http"; + const specPath = row.original.spec_path; + // If server has spec_path, display as "OPENAPI" instead of the raw transport type + const displayTransport = specPath && transport !== "stdio" ? "OPENAPI" : transport; + return {displayTransport.toUpperCase()}; + }, }, { accessorKey: "auth_type", diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx index 88f8a737af..703f4eac5c 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx @@ -144,9 +144,9 @@ const MCPServerEdit: React.FC = ({ }, [mcpServer.env]); - // If server has spec_path and no url, show it as "openapi" transport in the UI + // If server has spec_path, show it as "openapi" transport in the UI const effectiveTransport = React.useMemo(() => { - if (mcpServer.spec_path && !mcpServer.url && mcpServer.transport !== "stdio") { + if (mcpServer.spec_path && mcpServer.transport !== "stdio") { return TRANSPORT.OPENAPI; } return mcpServer.transport; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_view.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_view.tsx index 635c787f30..15c14f2af1 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_view.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_view.tsx @@ -131,7 +131,7 @@ export const MCPServerView: React.FC = ({ Transport
- {handleTransport(mcpServer.transport ?? undefined)} + {handleTransport(mcpServer.transport ?? undefined, mcpServer.spec_path ?? undefined).toUpperCase()}
@@ -220,7 +220,7 @@ export const MCPServerView: React.FC = ({
Transport -
{handleTransport(mcpServer.transport)}
+
{handleTransport(mcpServer.transport, mcpServer.spec_path).toUpperCase()}
Extra Headers diff --git a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx index 6856322b53..7eb5b51669 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx @@ -25,11 +25,16 @@ export const TRANSPORT = { OPENAPI: "openapi", }; -export const handleTransport = (transport?: string | null): string => { +export const handleTransport = (transport?: string | null, specPath?: string | null): string => { if (transport === null || transport === undefined) { return TRANSPORT.SSE; } + // If server has spec_path, display as "openapi" instead of the raw transport type + if (specPath && transport !== TRANSPORT.STDIO) { + return TRANSPORT.OPENAPI; + } + return transport; };