litellm/tests/integration/database/test_migration_entrypoint.py
devin-ai-integration[bot] 514bc181d6
test(integration): regression tests for July cost tracking, budgeting and spend bugs (#42694)
* test(integration): streamed Bedrock Messages usage cost equals the recorded spend (Pylon #6667)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): echoed cost-map model info is not persisted as deployment overrides (Pylon #6844)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): reset sweep runs on one pod per tick while replicas share the lease (Pylon #6521)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): bedrock post-call guardrail scans streamed Anthropic Messages tool use without 500 (Pylon #6503)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): realtime cached audio tokens bill at the audio cache-read rate (Pylon #6704)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): legacy GET /spend/logs returns at most the 10000 most recent rows and flags truncation (Pylon #6752)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): itemize Responses API cache write tokens as cache creation cost (Pylon #6454)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): migration entrypoint deploys pending migrations before proxy startup (Pylon #6649)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): opted-in team keys stop at the owner's personal budget (Pylon #6641)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): guardrail information stays in the spend log when the caller sends metadata on /v1/messages (Pylon #6614)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): key model allowlist is enforced on Bedrock passthrough routes (Pylon #6419)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): JWT mapped key backfills a null user email from token claims (Pylon #6266)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): scheduled budget reset recovers from a transient DB transport failure (Pylon #6582)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): team key lists models granted through a team access group (Pylon #6044)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): JWT subject without team claim lands in the configured default team (Pylon #5895)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): end-user spend lands for a key without user_id when the auth cache is Redis (Pylon #6021)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): stale-low redis counter still blocks team member over budget (Pylon #5824)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): prompt-carrying spend rows are written in byte-bounded statements (Pylon #6083)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): logs UI session_total_spend sums every round of a multi-round session (Pylon #5928)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): config.yaml guardrails are served by the guardrail usage detail and overview (Pylon #5813)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): plain chat request skips the object permission lookup (Pylon #5965)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): register july accounting regression contracts

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): isolate cost map override clear on owned proxy

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): make reset lease claim and db relay refusal deterministic

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): poll pg_stat settle, bound unbanned relay refusals, clear reset lease on teardown

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): bound relay refusals so the budget sweep can reconnect

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: kerry <kerry@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-23 09:51:56 -07:00

99 lines
4.3 KiB
Python

import os
import shutil
import subprocess
import sys
import uuid
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import Final
from urllib.parse import urlsplit, urlunsplit
import psycopg
import pytest
from integration._support.client import Gateway
from integration._support.process import owned_proxy
from psycopg import sql
from psycopg.rows import dict_row
REPO_ROOT: Final = Path(__file__).resolve().parents[3]
PRISMA_DIR: Final = REPO_ROOT / "litellm-proxy-extras" / "litellm_proxy_extras"
MISSING_MIGRATION: Final = "20260626120000_add_mcp_tool_search_enabled"
SHIPPED_MIGRATIONS: Final = tuple(sorted(path.name for path in (PRISMA_DIR / "migrations").iterdir() if path.is_dir()))
@contextmanager
def fresh_database() -> Iterator[str]:
name: Final = f"integration_upgrade_{uuid.uuid4().hex}"
admin_url: Final = os.environ["DATABASE_URL"]
parsed: Final = urlsplit(admin_url)
with psycopg.connect(admin_url, autocommit=True) as admin:
admin.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(name)))
try:
yield urlunsplit(parsed._replace(path=f"/{name}"))
finally:
admin.execute(sql.SQL("DROP DATABASE {} WITH (FORCE)").format(sql.Identifier(name)))
def deploy_older_schema(database_url: str, directory: Path) -> None:
older: Final = directory / "older-release"
(older / "migrations").mkdir(parents=True)
shutil.copy(PRISMA_DIR / "schema.prisma", older / "schema.prisma")
shutil.copy(PRISMA_DIR / "migrations" / "migration_lock.toml", older / "migrations" / "migration_lock.toml")
for name in (name for name in SHIPPED_MIGRATIONS if name < MISSING_MIGRATION):
shutil.copytree(PRISMA_DIR / "migrations" / name, older / "migrations" / name)
subprocess.run(
[sys.executable, "-I", "-m", "prisma", "migrate", "deploy", "--schema", str(older / "schema.prisma")],
check=True,
capture_output=True,
text=True,
timeout=300,
env={**os.environ, "DATABASE_URL": database_url},
)
def applied_migrations(database_url: str) -> tuple[str, ...]:
with psycopg.connect(database_url, row_factory=dict_row) as connection:
rows: Final = connection.execute(
'SELECT migration_name FROM "_prisma_migrations" '
"WHERE finished_at IS NOT NULL AND rolled_back_at IS NULL ORDER BY migration_name"
).fetchall()
return tuple(str(row["migration_name"]) for row in rows)
def object_permission_columns(database_url: str) -> tuple[str, ...]:
with psycopg.connect(database_url, row_factory=dict_row) as connection:
rows: Final = connection.execute(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'LiteLLM_ObjectPermissionTable' AND column_name = 'mcp_tool_search_enabled'"
).fetchall()
return tuple(str(row["column_name"]) for row in rows)
@pytest.mark.covers("other.database.migrations.entrypoint_deploys_pending_migrations_before_startup")
def test_migration_entrypoint_upgrades_an_older_schema_so_the_proxy_serves_mcp_tools(
gateway: Gateway, tmp_path: Path
) -> None:
with fresh_database() as database_url:
deploy_older_schema(database_url, tmp_path)
assert object_permission_columns(database_url) == ()
assert applied_migrations(database_url) == tuple(
name for name in SHIPPED_MIGRATIONS if name < MISSING_MIGRATION
)
entrypoint: Final = subprocess.run(
[sys.executable, "-I", "-m", "litellm.proxy.prisma_migration"],
capture_output=True,
text=True,
timeout=300,
cwd=REPO_ROOT,
env={**os.environ, "DATABASE_URL": database_url},
)
assert entrypoint.returncode == 0, entrypoint.stdout + entrypoint.stderr
assert object_permission_columns(database_url) == ("mcp_tool_search_enabled",), entrypoint.stdout
assert applied_migrations(database_url) == SHIPPED_MIGRATIONS, entrypoint.stdout
with owned_proxy(
gateway, tmp_path, {"DATABASE_URL": database_url, "DISABLE_SCHEMA_UPDATE": "true"}
) as upgraded:
tools: Final = upgraded.request("GET", "/mcp-rest/tools/list")
assert tools.status_code == 200, tools.text
assert tools.json()["tools"] == [], tools.text