Skip to content
Draft
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
29 changes: 29 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Repository-root pytest configuration.

After the SQLAlchemy big-bang migration, the ``prisma`` PyPI package is
no longer installed but a handful of test modules still do ``from prisma
... import ...``. We install a stand-in for the ``prisma`` namespace at
plugin-load time -- *before* pytest collects test modules -- so those
imports resolve without rewriting every test file in this PR.

The stand-in lives in ``tests/_prisma_compat.py`` and re-exports the
LiteLLM-native error classes from ``litellm.proxy.db.sqlmodel.errors``.
"""

from __future__ import annotations

import os
import sys

# Make ``tests/_prisma_compat`` importable regardless of where pytest is
# invoked from.
_TESTS_DIR = os.path.join(os.path.dirname(__file__), "tests")
if _TESTS_DIR not in sys.path:
sys.path.insert(0, _TESTS_DIR)

try:
from _prisma_compat import install as _install_prisma_compat

_install_prisma_compat()
except ImportError: # pragma: no cover -- shim is best-effort
pass
4 changes: 2 additions & 2 deletions litellm/proxy/_experimental/mcp_server/toolset_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ async def update_mcp_toolset(
data=data_dict,
)
except Exception as e:
from prisma.errors import RecordNotFoundError
from litellm.proxy.db.sqlmodel.errors import RecordNotFoundError

if isinstance(e, RecordNotFoundError):
return None
Expand All @@ -109,7 +109,7 @@ async def delete_mcp_toolset(
where={"toolset_id": toolset_id}
)
except Exception as e:
from prisma.errors import RecordNotFoundError
from litellm.proxy.db.sqlmodel.errors import RecordNotFoundError

if isinstance(e, RecordNotFoundError):
return None
Expand Down
26 changes: 13 additions & 13 deletions litellm/proxy/db/exception_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,24 +43,24 @@ def is_database_connection_error(e: Exception) -> bool:
to True so genuine outages that don't match a specific subclass
still trigger the fallback.
"""
import prisma
from litellm.proxy.db.sqlmodel import errors as prisma_errors

# Explicit data-layer exclusion: DB IS reachable, fallback must
# NOT fire.
data_layer_errors = (
prisma.errors.DataError,
prisma.errors.UniqueViolationError,
prisma.errors.ForeignKeyViolationError,
prisma.errors.MissingRequiredValueError,
prisma.errors.RawQueryError,
prisma.errors.TableNotFoundError,
prisma.errors.RecordNotFoundError,
prisma_errors.DataError,
prisma_errors.UniqueViolationError,
prisma_errors.ForeignKeyViolationError,
prisma_errors.MissingRequiredValueError,
prisma_errors.RawQueryError,
prisma_errors.TableNotFoundError,
prisma_errors.RecordNotFoundError,
)
if isinstance(e, data_layer_errors):
return False
if isinstance(e, DB_CONNECTION_ERROR_TYPES):
return True
if isinstance(e, prisma.errors.PrismaError):
if isinstance(e, prisma_errors.PrismaError):
return True
if isinstance(e, ProxyException) and e.type == ProxyErrorTypes.no_db_connection:
return True
Expand All @@ -75,19 +75,19 @@ def is_database_transport_error(e: Exception) -> bool:
Use this for reconnect logic — data-layer errors like UniqueViolationError
mean the DB IS reachable, so reconnecting would be pointless.
"""
import prisma
from litellm.proxy.db.sqlmodel import errors as prisma_errors

if isinstance(e, DB_CONNECTION_ERROR_TYPES):
return True
if isinstance(
e,
(
prisma.errors.ClientNotConnectedError,
prisma.errors.HTTPClientClosedError,
prisma_errors.ClientNotConnectedError,
prisma_errors.HTTPClientClosedError,
),
):
return True
if isinstance(e, prisma.errors.PrismaError):
if isinstance(e, prisma_errors.PrismaError):
error_message = str(e).lower()
connection_keywords = (
"can't reach database server",
Expand Down
2 changes: 1 addition & 1 deletion litellm/proxy/db/log_db_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ def _is_exception_related_to_db(e: Exception) -> bool:
"""

import httpx
from prisma.errors import PrismaError
from litellm.proxy.db.sqlmodel.errors import PrismaError

return isinstance(e, (PrismaError, httpx.ConnectError, httpx.TimeoutException))

Expand Down
14 changes: 13 additions & 1 deletion litellm/proxy/db/prisma_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,19 @@ async def recreate_prisma_client(
new URL is passed explicitly via `datasource={"url": ...}` (Prisma
does not auto-read alternate env vars like DATABASE_URL_READ_REPLICA).
"""
from prisma import Prisma # type: ignore
# NOTE: ``PrismaWrapper`` is dead code after the SQLAlchemy big-bang
# migration -- ``PrismaClient.__init__`` no longer instantiates one.
# We keep the class for backwards-compat with tests that import it
# directly, and guard the prisma-client-py import so module load
# succeeds when the ``prisma`` PyPI package is no longer installed.
try:
from prisma import Prisma # type: ignore[import-not-found]
except ImportError as exc: # pragma: no cover - migration transitional
raise RuntimeError(
"PrismaWrapper.recreate_prisma_client is no longer supported "
"after the SQLAlchemy migration. Use LiteLLMDB / "
"PrismaCompatClient from litellm.proxy.db.sqlmodel."
) from exc

old_engine_pid = self._get_engine_pid()
if old_engine_pid > 0:
Expand Down
116 changes: 116 additions & 0 deletions litellm/proxy/db/sqlmodel/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# Prisma -> SQLModel migration -- Phase 1

This package is the foundation for migrating `litellm`'s proxy persistence
layer from Prisma (`prisma-client-py==0.11.0`) to SQLModel/SQLAlchemy. The
migration is multi-phase by necessity -- the proxy has ~1,680 Prisma client
call sites across ~147 production files plus ~177 test files, so any
"big-bang" cutover would be unreviewable and unsafe.

## What this Phase ships

| Artefact | Purpose |
|---|---|
| `schema_parser.py` | Tiny pure-Python parser for the subset of Prisma DSL actually used by `schema.prisma`. Used by the parity test and the generator. |
| `_generate.py` | Code generator that emits SQLModel class definitions from a parsed schema. Run it manually after schema changes. |
| `models.py` | SQLModel classes for **all 64 models** in `schema.prisma`. Hand-editable; the generator only seeds the file. |
| `tests/test_litellm/proxy/db/sqlmodel_orm/test_schema_parser.py` | Unit tests for the parser. |
| `tests/test_litellm/proxy/db/sqlmodel_orm/test_parity.py` | Parity test: every Prisma model has a matching SQLModel class with matching columns, nullability, primary keys, uniques, and indexes. Also asserts that the committed `models.py` is byte-identical to a fresh generator run. |

**Nothing in this package is wired into the runtime proxy yet.** Importing
the module has no effect on the existing Prisma-backed code paths -- the
generated classes simply sit alongside the Prisma client and are guarded
against drift by CI.

## Why a generator at all?

The schema is the source of truth and changes frequently. Hand-writing 64
SQLModel classes against a 1,378-line Prisma schema invites typos and
silent drift. The generator gives us one well-tested translation rule per
Prisma construct (`@id`, `@@index`, `String[]`, `@updatedAt`, etc.) and the
parity test catches any regression in either the schema or the generator.

When subsequent phases need to add SQLAlchemy-only behaviour (custom
relationships, hybrid properties, `Mapped[...]` annotations, etc.), edit
`models.py` by hand. The generator's output should still load and the
parity test should still pass; if they don't, the schema and the SQLModel
layer have diverged.

## Re-running the generator

```bash
uv run python -m litellm.proxy.db.sqlmodel._generate \
--schema schema.prisma \
--out litellm/proxy/db/sqlmodel/models.py
```

The parity test fails CI if a schema change isn't accompanied by a
regenerated `models.py`.

## Subsequent phases

The work below is the responsibility of follow-up PRs, in roughly this
order. Each phase is independently testable; do not bundle them.

1. **Session abstraction.** Introduce a thin `DBSession` interface that
wraps the existing `prisma_client` today and a SQLAlchemy
`AsyncSession` tomorrow. Land with zero behaviour change. This is the
prerequisite for incrementally swapping call sites.
2. **CI: keep `schema.prisma` and `models.py` in sync.** Add a workflow
that runs the parity test on every PR (the test already exists -- this
step is just enabling it as a required check).
3. **Port the raw-SQL hotspots.** ~288 `query_raw` / `execute_raw` calls
across ~37 files (concentrated in `spend_management_endpoints.py`,
`db/create_views.py`, focus/cloudzero exporters). These are the
easiest call sites to migrate -- the SQL is already there; we just
swap the executor to a SQLAlchemy `session.execute(text(...))`.
4. **Migrate per-table call sites.** ~55 tables touched across ~1,680
Prisma-client call sites. Parallelise by feature area
(keys/teams/users -> spend/logs -> MCP/managed objects ->
adaptive router/workflows). The session abstraction from phase 1 lets
each call site flip independently.
5. **Replace the custom Prisma reliability layer.** The current
`PrismaWrapper` (RDS IAM token rotation), `RoutingPrismaWrapper`
(read/write split), and `PrismaDBExceptionHandler` (~10 distinct
error type classifications) all need SQLAlchemy-native equivalents.
6. **Swap migrations to Alembic.** The current `litellm-proxy-extras`
package bundles 123 Prisma migration files. Establish an Alembic
baseline matching the live schema, with a documented "first-run
after upgrade" path for existing deployments. The 10 / 123
migrations that contain DML need careful translation; the rest are
pure DDL and can be folded into the baseline for fresh installs.
7. **Tear out Prisma.** Remove `prisma==0.11.0` from `pyproject.toml`,
`prisma generate` from all 7 Dockerfiles, the CI workflows that run
it, the 3 `schema.prisma` copies (with their `check-schema-sync` and
`sync-schema` workflows), and the `litellm-proxy-extras` migration
bundle.

## Risks and gotchas surfaced during Phase 1

* **Reserved attribute names.** SQLModel/SQLAlchemy reserve `metadata`
and `registry` on the mapped class. Several Prisma models have a
`metadata Json` column. The generator emits these as Python attribute
`metadata_` while keeping the on-disk column name `metadata` via
`sa_column_kwargs={'name': 'metadata'}`. Migration of call sites must
use `MyTable.metadata_` in Python.
* **`String[]` (Postgres array columns).** Prisma maps `String[]` to a
Postgres `text[]`. The generator uses
`sqlalchemy.dialects.postgresql.ARRAY(Text())`, which is
Postgres-specific. SQLite-backed test environments will need a
separate fixture path -- this is identical to the current Prisma
situation (`prisma-client-py` on SQLite already requires manual JSON
emulation).
* **`Json` columns default-text quoting.** Prisma's `@default("[]")` and
`@default("{}")` emit `'[]'::jsonb` / `'{}'::jsonb` as the Postgres
`DEFAULT`. The generator preserves both the Python `default_factory`
*and* the `server_default` so migrated rows behave identically when
the column is omitted from an INSERT.
* **`@updatedAt`.** Prisma updates the column from the client. The
generator translates this to a SQLAlchemy `onupdate=lambda: ...
utcnow()` so the behaviour persists when ported off Prisma.
* **`cuid()`.** Only `LiteLLM_CronJob.cronjob_id` uses it. The generator
treats it as opaque-string-equivalent to `uuid()` (which is what every
consumer already assumes).
* **Enums.** The single Prisma enum (`JobStatus`) is emitted as a Python
`str`-Enum and the column is stored as `Text` to match what
`prisma-client-py` already does on Postgres. A real `sa.Enum` can be
introduced later if any call site benefits.
30 changes: 30 additions & 0 deletions litellm/proxy/db/sqlmodel/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""SQLModel-based ORM definitions for the LiteLLM proxy database.

This package is the foundation for migrating proxy persistence from Prisma to
SQLModel/SQLAlchemy. **Phase 1** (this module's current state) introduces:

* :mod:`schema_parser` -- a small ``schema.prisma`` parser used by the
parity test (and by future code generators).
* :mod:`models` -- hand-maintained, generator-seeded SQLModel classes that
mirror every model in the canonical ``schema.prisma``.
* A parity test (in ``tests/test_litellm/proxy/db/sqlmodel/``) that fails
CI if the SQLModel definitions drift from the Prisma schema.

Nothing in this package is wired into the runtime proxy yet -- importing it
has no effect on existing Prisma-backed code paths. Subsequent phases will:

1. introduce a ``DBSession`` abstraction wrapping Prisma today and SQLAlchemy
tomorrow,
2. port raw-SQL call sites (``query_raw`` / ``execute_raw``) onto SQLAlchemy,
3. migrate per-table call sites (~55 tables) behind the abstraction,
4. rebuild the ``PrismaWrapper`` / ``RoutingPrismaWrapper`` / exception
classifier as SQLAlchemy-native components,
5. swap the migration tool from Prisma to Alembic with a baseline derived
from the current schema state.

See ``litellm/proxy/db/sqlmodel/README.md`` for the full plan.
"""

from litellm.proxy.db.sqlmodel.models import ALL_MODELS

__all__ = ["ALL_MODELS"]
Loading
Loading