diff --git a/README.md b/README.md index e927c80b8b4..d2cceafddf0 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,10 @@ response = completion(model="openai/gpt-4o", messages=[{"role": "user", "content response = completion(model="anthropic/claude-sonnet-4-20250514", messages=[{"role": "user", "content": "Hello!"}]) ``` +By default, LiteLLM loads `.env` when imported in DEV mode. To disable this for the SDK and proxy, set +`LITELLM_DISABLE_DOTENV=1` in the process environment before importing LiteLLM. Setting it inside `.env` is too late +because LiteLLM reads the flag before loading that file + ### AI Gateway (Proxy Server) [**Getting Started - E2E Tutorial**](https://docs.litellm.ai/docs/proxy/docker_quick_start) - Setup virtual keys, make your first request diff --git a/litellm/__init__.py b/litellm/__init__.py index c8df4394a06..f2b42054241 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -11,12 +11,14 @@ warnings.filterwarnings("ignore", message=".*Accessing the.*attribute on the ins # cannot enforce it at runtime, which floods proxy boot once such a type is schema-walked warnings.filterwarnings("ignore", message=".*`ReadOnly` qualifier.*") ### INIT VARIABLES ######################### -import threading import os +import threading # Load .env before any other litellm imports so env vars (e.g. LITELLM_UI_SESSION_DURATION) are available import dotenv as _dotenv +from ._dotenv_loader import should_load_dotenv as _should_load_dotenv + def _dev_env_hot_reload_enabled() -> bool: """The proxy exports this flag when started with ``--reload``. A reloaded @@ -26,7 +28,7 @@ def _dev_env_hot_reload_enabled() -> bool: return os.getenv("LITELLM_DEV_ENV_HOT_RELOAD") == "True" -if os.getenv("LITELLM_MODE", "DEV") == "DEV": +if _should_load_dotenv(): _dotenv.load_dotenv(override=_dev_env_hot_reload_enabled()) from collections.abc import Mapping, Sequence diff --git a/litellm/_dotenv_loader.py b/litellm/_dotenv_loader.py new file mode 100644 index 00000000000..399505bf892 --- /dev/null +++ b/litellm/_dotenv_loader.py @@ -0,0 +1,9 @@ +import os +from typing import Final + +_TRUTHY_VALUES: Final = frozenset({"1", "true", "t", "yes", "y"}) + + +def should_load_dotenv() -> bool: + disable_dotenv: Final = os.getenv("LITELLM_DISABLE_DOTENV", "").strip().casefold() + return os.getenv("LITELLM_MODE", "DEV") == "DEV" and disable_dotenv not in _TRUTHY_VALUES diff --git a/litellm/proxy/proxy_cli.py b/litellm/proxy/proxy_cli.py index ac69f2e3894..2ff008cb0c7 100644 --- a/litellm/proxy/proxy_cli.py +++ b/litellm/proxy/proxy_cli.py @@ -18,6 +18,7 @@ from dotenv import load_dotenv from pydantic import BaseModel, ConfigDict import litellm +from litellm._dotenv_loader import should_load_dotenv from litellm.constants import DEFAULT_NUM_WORKERS_LITELLM_PROXY from litellm.proxy.db.pgbouncer import ( PgBouncerError, @@ -52,7 +53,7 @@ sys.path.append(os.getcwd()) config_filename: Final = "litellm.secrets" litellm_mode: Final = os.getenv("LITELLM_MODE", "DEV") # "PRODUCTION", "DEV" -if litellm_mode == "DEV": +if should_load_dotenv(): load_dotenv() from enum import Enum diff --git a/tests/test_litellm/test__dotenv_loader.py b/tests/test_litellm/test__dotenv_loader.py new file mode 100644 index 00000000000..c242bb26c05 --- /dev/null +++ b/tests/test_litellm/test__dotenv_loader.py @@ -0,0 +1,84 @@ +import os +import subprocess +import sys +import textwrap +from collections.abc import Mapping +from pathlib import Path +from typing import Final + +import pytest + + +_CONTROL_ENV_VARIABLES: Final = frozenset( + { + "LITELLM_DISABLE_DOTENV", + "LITELLM_DEV_ENV_HOT_RELOAD", + "LITELLM_DOTENV_LOADED", + "LITELLM_MODE", + } +) +_REPOSITORY_ROOT: Final = Path(__file__).resolve().parents[2] + + +def _dotenv_was_loaded(module: str, environment: Mapping[str, str]) -> bool: + process_environment: Final = { + key: value for key, value in os.environ.items() if key not in _CONTROL_ENV_VARIABLES + } | dict(environment) + script: Final = textwrap.dedent( + f""" + import os + import dotenv + + def load_dotenv(*args, **kwargs): + os.environ["LITELLM_DOTENV_LOADED"] = "1" + return True + + dotenv.load_dotenv = load_dotenv + import {module} + print(f"LITELLM_DOTENV_LOADED={{os.environ.get('LITELLM_DOTENV_LOADED', '0')}}") + """ + ) + result: Final = subprocess.run( + [sys.executable, "-c", script], + cwd=_REPOSITORY_ROOT, + env=process_environment, + capture_output=True, + text=True, + check=True, + ) + marker: Final = "LITELLM_DOTENV_LOADED=" + output_line: Final = next(line for line in result.stdout.splitlines() if line.startswith(marker)) + return output_line.removeprefix(marker) == "1" + + +def test_default_dev_mode_loads_dotenv() -> None: + assert _dotenv_was_loaded("litellm", {}) + + +@pytest.mark.parametrize("value", ("1", "true", "t", "yes", "y", "TRUE", "YeS")) +def test_disable_dotenv_truthy_values_prevent_loading(value: str) -> None: + assert not _dotenv_was_loaded("litellm", {"LITELLM_DISABLE_DOTENV": value}) + + +def test_disable_dotenv_false_value_preserves_loading() -> None: + assert _dotenv_was_loaded("litellm", {"LITELLM_DISABLE_DOTENV": "false"}) + + +def test_production_mode_still_skips_dotenv() -> None: + assert not _dotenv_was_loaded("litellm", {"LITELLM_MODE": "PRODUCTION"}) + + +def test_disable_dotenv_wins_over_dev_hot_reload() -> None: + environment: Final = { + "LITELLM_DISABLE_DOTENV": "1", + "LITELLM_DEV_ENV_HOT_RELOAD": "True", + } + assert not _dotenv_was_loaded("litellm", environment) + + +def test_proxy_cli_respects_disable_dotenv() -> None: + assert not _dotenv_was_loaded("litellm.proxy.proxy_cli", {"LITELLM_DISABLE_DOTENV": "1"}) + + +def test_proxy_cli_default_dev_mode_loads_dotenv() -> None: + assert _dotenv_was_loaded("litellm.proxy.proxy_cli", {})