Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion litellm-proxy-extras/litellm_proxy_extras/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import re
import shutil
import subprocess
import sys
import sysconfig
import tempfile
import time
from pathlib import Path
Expand All @@ -19,14 +21,41 @@ def str_to_bool(value: Optional[str]) -> bool:


def _get_prisma_env() -> dict:
"""Get environment variables for Prisma, handling offline mode if configured."""
"""Get environment variables for Prisma, handling offline mode if configured.

Also injects the running interpreter's scripts directory (the venv ``bin/``
dir) to the front of PATH so the ``prisma-client-py`` console-script is
found by ``/bin/sh`` without needing to activate the venv. This fixes the
silent failure where ``prisma db push`` and ``prisma migrate deploy`` exit 0
but never create tables when invoked by absolute path outside an active venv
(Symptom 3 of issue #26097).
"""
prisma_env = os.environ.copy()
if str_to_bool(os.getenv("PRISMA_OFFLINE_MODE")):
# These env vars prevent Prisma from attempting downloads
prisma_env["NPM_CONFIG_PREFER_OFFLINE"] = "true"
prisma_env["NPM_CONFIG_CACHE"] = os.getenv(
"NPM_CONFIG_CACHE", "/app/.cache/npm"
)

# Inject the venv scripts dir so prisma-client-py resolves without
# venv activation. Needed for db push and migrate deploy just as much
# as for db generate — all three shell out via /bin/sh which only finds
# the binary if it is on PATH.
scripts_dir = sysconfig.get_path("scripts")
if not scripts_dir:
scripts_dir = os.path.dirname(os.path.abspath(sys.executable))
Comment thread
steveonjava marked this conversation as resolved.
if scripts_dir:
existing_path = prisma_env.get("PATH", "")
path_entries = existing_path.split(os.pathsep) if existing_path else []
if scripts_dir not in path_entries or path_entries[0] != scripts_dir:
# Remove any existing occurrence and place scripts_dir at index 0.
filtered = [p for p in path_entries if p != scripts_dir]
remaining = os.pathsep.join(filtered)
prisma_env["PATH"] = (
scripts_dir + os.pathsep + remaining if remaining else scripts_dir
)

return prisma_env


Expand Down Expand Up @@ -924,6 +953,7 @@ def setup_database(
[_get_prisma_command(), "db", "push", "--accept-data-loss"],
timeout=60,
check=True,
env=_get_prisma_env(),
)
return True
except subprocess.TimeoutExpired:
Expand Down
155 changes: 155 additions & 0 deletions litellm-proxy-extras/tests/test_prisma_env_path_injection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
"""Tests for _get_prisma_env() venv PATH injection (Symptom 3 of issue #26097).

Symptom 3: after `pip install 'litellm[proxy]'`, running the proxy by absolute
path in a non-activated venv causes `prisma db push` to exit 0 without creating
tables because `prisma-client-py` is not on PATH when /bin/sh forks for the
generator. The fix: _get_prisma_env() prepends the interpreter's scripts dir.
"""

import os
from unittest.mock import MagicMock, patch

from litellm_proxy_extras.utils import ProxyExtrasDBManager, _get_prisma_env

# ---------------------------------------------------------------------------
# Unit tests for _get_prisma_env() PATH injection
# ---------------------------------------------------------------------------


def test_get_prisma_env_injects_scripts_dir_at_index_0(monkeypatch):
"""Scripts dir appears at PATH index 0 when venv is not activated."""
fake_scripts = "/fake/venv/bin"
# Remove scripts dir from PATH so it's not already there
stripped_path = "/usr/local/bin:/usr/bin:/bin"
monkeypatch.setenv("PATH", stripped_path)
with (patch("sysconfig.get_path", return_value=fake_scripts),):
env = _get_prisma_env()

path_entries = env["PATH"].split(os.pathsep)
assert (
path_entries[0] == fake_scripts
), f"Expected scripts dir at index 0, got: {env['PATH']}"
# Original PATH entries preserved after the injected dir
assert "/usr/local/bin" in path_entries
assert "/usr/bin" in path_entries


def test_get_prisma_env_promotes_scripts_dir_to_index_0(monkeypatch):
"""Scripts dir is promoted to index 0 if already in PATH at a later position."""
fake_scripts = "/fake/venv/bin"
path_with_scripts_at_end = f"/usr/bin:/bin:{fake_scripts}"
monkeypatch.setenv("PATH", path_with_scripts_at_end)
with patch("sysconfig.get_path", return_value=fake_scripts):
env = _get_prisma_env()

path_entries = env["PATH"].split(os.pathsep)
assert path_entries[0] == fake_scripts
# Must appear exactly once
assert path_entries.count(fake_scripts) == 1


def test_get_prisma_env_handles_empty_path(monkeypatch):
"""Empty PATH results in PATH containing only the scripts dir."""
fake_scripts = "/fake/venv/bin"
monkeypatch.setenv("PATH", "")
with patch("sysconfig.get_path", return_value=fake_scripts):
env = _get_prisma_env()

assert env["PATH"] == fake_scripts
assert (
os.pathsep not in env["PATH"] or env["PATH"].rstrip(os.pathsep) == fake_scripts
)


def test_get_prisma_env_falls_back_to_dirname_when_sysconfig_falsy(monkeypatch):
"""Falls back to dirname(sys.executable) when sysconfig returns falsy."""
fake_exe = "/fake/venv/bin/python3"
monkeypatch.setenv("PATH", "/usr/bin")
with (
patch("sysconfig.get_path", return_value=None),
patch("sys.executable", fake_exe),
):
env = _get_prisma_env()

expected_scripts = os.path.dirname(os.path.abspath(fake_exe))
path_entries = env["PATH"].split(os.pathsep)
assert path_entries[0] == expected_scripts


def test_get_prisma_env_sets_offline_vars_when_enabled(monkeypatch):
"""PRISMA_OFFLINE_MODE vars are still set alongside the PATH injection."""
monkeypatch.setenv("PRISMA_OFFLINE_MODE", "true")
monkeypatch.setenv("PATH", "/usr/bin")
with patch("sysconfig.get_path", return_value="/venv/bin"):
env = _get_prisma_env()

assert env.get("NPM_CONFIG_PREFER_OFFLINE") == "true"
assert "NPM_CONFIG_CACHE" in env
# PATH injection still happens
assert env["PATH"].startswith("/venv/bin")


def test_get_prisma_env_noop_when_scripts_dir_already_at_index_0(monkeypatch):
"""No duplicate entry when scripts dir is already at index 0."""
fake_scripts = "/fake/venv/bin"
monkeypatch.setenv("PATH", f"{fake_scripts}:/usr/bin:/bin")
with patch("sysconfig.get_path", return_value=fake_scripts):
env = _get_prisma_env()

path_entries = env["PATH"].split(os.pathsep)
assert path_entries[0] == fake_scripts
assert path_entries.count(fake_scripts) == 1


# ---------------------------------------------------------------------------
# Integration: db push subprocess receives patched env with scripts_dir
# ---------------------------------------------------------------------------


def test_db_push_subprocess_receives_scripts_dir_on_path(monkeypatch, tmp_path):
"""setup_database(use_migrate=False) passes env with scripts_dir to subprocess.

This is the Symptom 3 integration guard: the subprocess call must receive
the patched env dict that has the venv scripts dir at PATH index 0. Without
it, prisma db push exits 0 but the generator fails silently and tables are
never created.
"""
fake_scripts = "/fake/venv/bin"
monkeypatch.setenv("PATH", "/usr/bin:/bin")
monkeypatch.setenv("DATABASE_URL", "postgresql://u:p@localhost:5432/testdb")

(tmp_path / "schema.prisma").write_text("// stub schema\n")

captured_calls = []

def fake_subprocess_run(args, **kwargs):
captured_calls.append({"args": list(args), "env": kwargs.get("env", {})})
return MagicMock(returncode=0)

with (
patch("sysconfig.get_path", return_value=fake_scripts),
patch.object(
ProxyExtrasDBManager, "_get_prisma_dir", return_value=str(tmp_path)
),
patch("litellm_proxy_extras.utils._get_prisma_command", return_value="prisma"),
patch("subprocess.run", side_effect=fake_subprocess_run),
):
result = ProxyExtrasDBManager.setup_database(use_migrate=False)

assert result is True, "setup_database should return True on success"
assert (
len(captured_calls) == 1
), f"Expected 1 subprocess call, got {len(captured_calls)}"

received_env = captured_calls[0]["env"]
assert received_env is not None, "subprocess.run must be called with env="
path_entries = received_env.get("PATH", "").split(os.pathsep)
assert path_entries[0] == fake_scripts, (
f"Expected scripts dir '{fake_scripts}' at PATH index 0 in subprocess env. "
f"Got PATH: {received_env.get('PATH')}"
)

call_args = captured_calls[0]["args"]
assert "db" in call_args
assert "push" in call_args
assert "--accept-data-loss" in call_args
139 changes: 139 additions & 0 deletions litellm/proxy/client/cli/commands/db.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
"""Database management commands for the LiteLLM proxy CLI."""

import os
import subprocess
import sys
import sysconfig
from typing import Callable, Optional

import click

# The Prisma schema ships inside the litellm package itself (litellm/proxy/
# schema.prisma); PrismaManager resolves that directory. This is the same
# schema `db push` operates on, so `db generate` stays consistent with it.
from litellm.proxy.db.prisma_client import PrismaManager

# The prisma *binary* discovery and PATH-injection helpers live in
# litellm-proxy-extras. These are optional dependencies; the ImportError is
# handled at call time so `litellm` works without the proxy extras installed.
try:
from litellm_proxy_extras.utils import (
_get_prisma_command as _imported_prisma_command,
_get_prisma_env as _imported_prisma_env,
)

_PROXY_EXTRAS_AVAILABLE = True
except ImportError:
_imported_prisma_command = None
_imported_prisma_env = None
_PROXY_EXTRAS_AVAILABLE = False

# Bind to explicitly typed module-level names. litellm-proxy-extras ships no
# py.typed marker, so its exports are untyped (Any) at this import boundary;
# the annotations pin the signatures back down. These are the names the tests
# patch. The `any-ok` markers acknowledge the unavoidable untyped third-party
# boundary (the annotation is the concrete type the rest of the file relies on).
_get_prisma_command: Optional[Callable[[], str]] = (
_imported_prisma_command # any-ok: untyped optional import from litellm-proxy-extras
)
_get_prisma_env: Optional[Callable[[], dict[str, str]]] = (
_imported_prisma_env # any-ok: untyped optional import from litellm-proxy-extras
)


def _get_venv_scripts_dir() -> str:
"""Return the directory that holds console scripts for the running interpreter.

The prisma engine shells out to the ``prisma-client-py`` console script via
``/bin/sh``, which only finds it when that directory is on PATH. In an
*activated* venv it is; when ``litellm-proxy`` is invoked by absolute path
without activation, it is not. We derive the directory from the current
interpreter so the caller never has to activate the venv.

``sysconfig.get_path("scripts")`` is the authoritative answer; we fall back
to ``dirname(sys.executable)`` if it is empty (defensive: should not happen
on a normal CPython install).
"""
scripts_dir = sysconfig.get_path("scripts")
if not scripts_dir:
scripts_dir = os.path.dirname(os.path.abspath(sys.executable))
return scripts_dir


def _get_generate_env() -> dict[str, str]:
"""Build the subprocess env for ``db generate``.

Delegates to the shared ``_get_prisma_env()`` which now injects the venv
scripts dir for all Prisma subprocesses (generate, db push, migrate deploy).
Kept as a named wrapper for backward-compat with existing tests.
"""
if _get_prisma_env is not None:
result = _get_prisma_env()
if result is not None:
return result
return os.environ.copy()


@click.group()
def db() -> None:
"""Database management commands."""


@db.command(name="generate")
def db_generate() -> None:
"""Generate the Prisma client using litellm's bundled schema.

Runs: prisma generate --schema <path_to_schema.prisma>

The schema is resolved from the litellm package (litellm/proxy/schema.prisma)
via PrismaManager, which is the same schema `db push` operates on. This keeps
generate consistent with the existing push path and closes the gap where there
was no out-of-the-box `generate` step after a plain pip install.
"""
if not _PROXY_EXTRAS_AVAILABLE:
click.echo(
"Error: litellm-proxy-extras is not installed. "
"Run: pip install 'litellm[proxy]'",
err=True,
)
raise SystemExit(1)

prisma_dir = PrismaManager._get_prisma_dir()
schema_path = os.path.join(prisma_dir, "schema.prisma")

if not os.path.exists(schema_path):
click.echo(
f"Error: schema.prisma not found at {schema_path}. "
"Your litellm-proxy-extras installation may be incomplete.",
err=True,
)
raise SystemExit(1)

if _get_prisma_command is None:
click.echo(
"Error: litellm-proxy-extras is not installed. "
"Run: pip install 'litellm[proxy]'",
err=True,
)
raise SystemExit(1)

click.echo(f"Generating Prisma client from {schema_path} ...")
command: list[str] = [
_get_prisma_command(),
"generate",
"--schema",
schema_path,
]
try:
subprocess.run(
command,
check=True,
env=_get_generate_env(),
)
click.echo("Prisma client generated successfully.")
except subprocess.CalledProcessError as e:
click.echo(
f"Error: prisma generate failed (exit {e.returncode}).",
err=True,
)
raise SystemExit(e.returncode)
3 changes: 3 additions & 0 deletions litellm/proxy/client/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from .commands.auth import get_stored_api_key, login, logout, whoami
from .commands.chat import chat
from .commands.credentials import credentials
from .commands.db import db
from .commands.http import http
from .commands.keys import keys

Expand Down Expand Up @@ -99,6 +100,8 @@ def version(ctx: click.Context):
cli.add_command(login)
cli.add_command(logout)
cli.add_command(whoami)
# Add the db command group
cli.add_command(db)
# Add the models command group
cli.add_command(models)
# Add the credentials command group
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@ asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "session"
markers = [
"asyncio: mark test as an asyncio test",
"integration: mark test as an integration test requiring real binaries/processes",
"limit_leaks: mark test with memory limit for leak detection (e.g., '40 MB')",
"no_parallel: mark test to run sequentially (not in parallel) - typically for memory measurement tests",
]
Expand Down
Loading
Loading