mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
Merge remote-tracking branch 'origin/litellm_internal_staging' into HEAD
This commit is contained in:
commit
24a49726c2
13 changed files with 879 additions and 559 deletions
|
|
@ -1483,7 +1483,7 @@ jobs:
|
|||
- run:
|
||||
name: Run tests
|
||||
command: |
|
||||
uv run --no-sync python -m pytest -vv tests/local_testing/test_basic_python_version.py -k "not v2_resolver"
|
||||
uv run --no-sync python -m pytest -vv tests/local_testing/test_basic_python_version.py -k "not legacy_resolver"
|
||||
|
||||
installing_litellm_on_python_3_13:
|
||||
docker:
|
||||
|
|
@ -1507,9 +1507,9 @@ jobs:
|
|||
- run:
|
||||
name: Run tests
|
||||
command: |
|
||||
uv run --no-sync python -m pytest -v tests/local_testing/test_basic_python_version.py -k "not v2_resolver"
|
||||
uv run --no-sync python -m pytest -v tests/local_testing/test_basic_python_version.py -k "not legacy_resolver"
|
||||
|
||||
installing_litellm_on_python_v2_migration_resolver:
|
||||
installing_litellm_on_python_legacy_migration_resolver:
|
||||
docker:
|
||||
- *python312_image
|
||||
- image: cimg/postgres:16.0@sha256:b125148bc76e8e8eee5eb3ad6020a3a14110a14e8192f1c645128afebe2e2f84
|
||||
|
|
@ -1536,10 +1536,10 @@ jobs:
|
|||
url: tcp://localhost:5432
|
||||
timeout: "60"
|
||||
- run:
|
||||
name: Run v2 migration resolver proxy smoke test
|
||||
name: Run legacy migration resolver proxy smoke test
|
||||
command: |
|
||||
uv run --no-sync python -m pytest -vv \
|
||||
tests/local_testing/test_basic_python_version.py::test_litellm_proxy_server_config_no_general_settings_v2_resolver
|
||||
tests/local_testing/test_basic_python_version.py::test_litellm_proxy_server_config_no_general_settings_legacy_resolver
|
||||
|
||||
helm_chart_testing:
|
||||
machine:
|
||||
|
|
@ -2879,7 +2879,8 @@ jobs:
|
|||
command: |
|
||||
if grep -q "Error: P1001: Can't reach database server at" docker_output.log && \
|
||||
(grep -q "Database setup failed after multiple retries" docker_output.log || \
|
||||
grep -q "ERROR: Application startup failed. Exiting." docker_output.log); then
|
||||
grep -q "ERROR: Application startup failed. Exiting." docker_output.log || \
|
||||
grep -q "Database migration cannot proceed" docker_output.log); then
|
||||
echo "Expected error found. Test passed."
|
||||
else
|
||||
echo "Expected error not found. Test failed."
|
||||
|
|
@ -3011,7 +3012,7 @@ workflows:
|
|||
filters: *main_branches
|
||||
- installing_litellm_on_python_3_13:
|
||||
filters: *main_branches
|
||||
- installing_litellm_on_python_v2_migration_resolver:
|
||||
- installing_litellm_on_python_legacy_migration_resolver:
|
||||
filters: *main_branches
|
||||
- helm_chart_testing:
|
||||
requires:
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ Same applies for filing bug reports and feature requests, with .github/ISSUE_TEM
|
|||
|
||||
If you're resolving a linear ticket, in the "## Linear ticket" section of the PR, say "Resolves LIT-1234", replacing "LIT-1234" with the actual ticket id that you're resolving. If you don't have the ticket id, don't make one up or search for it. Just leave the section blank
|
||||
|
||||
Never use `pytest` commands or the like as "Screenshots / Proof of Fix". We prefer curl'ing a live proxy instance running on localhost:4000 (I like to run it with `python litellm/proxy/proxy_cli.py --config litellm/proxy/dev_config.yaml --detailed_debug --reload --use_v2_migration_resolver 2>&1 | tee litellm.log`; the Admin UI dev server is `npm run dev` in `ui/litellm-dashboard`, served on port 3000) and showing both the command run and the output. Also, it should hit real LLM provider APIs, not mocks, and cost real $$$ because that is the most realistic test. The proof of fix should be exactly what the end user / customer would see / do. The run logs in PR #27703 is a prime example of how to do it (not a huge fan of using a python test script that future me and the team will have no visibility into; I prefer just curl commands or a short list of bash commands (e.g., using `for`)). If it's a UI thing, just tell me which URLs to go to (e.g., http://localhost:4000/ui/?page=logs), where to click, what fields to fill out, etc. along with the other commands to run in an ordered list, and I'll do it myself and post the screenshots after you make the PR
|
||||
Never use `pytest` commands or the like as "Screenshots / Proof of Fix". We prefer curl'ing a live proxy instance running on localhost:4000 (I like to run it with `python litellm/proxy/proxy_cli.py --config litellm/proxy/dev_config.yaml --detailed_debug --reload 2>&1 | tee litellm.log`; the Admin UI dev server is `npm run dev` in `ui/litellm-dashboard`, served on port 3000) and showing both the command run and the output. Also, it should hit real LLM provider APIs, not mocks, and cost real $$$ because that is the most realistic test. The proof of fix should be exactly what the end user / customer would see / do. The run logs in PR #27703 is a prime example of how to do it (not a huge fan of using a python test script that future me and the team will have no visibility into; I prefer just curl commands or a short list of bash commands (e.g., using `for`)). If it's a UI thing, just tell me which URLs to go to (e.g., http://localhost:4000/ui/?page=logs), where to click, what fields to fill out, etc. along with the other commands to run in an ordered list, and I'll do it myself and post the screenshots after you make the PR
|
||||
|
||||
If you ever write any human-facing text (pull requests, issues, commit messages, discussion posts, github comments, release notes, docs, etc.), always follow these guidelines to sound less AI-y:
|
||||
- don't use emojis
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ import subprocess
|
|||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from types import MappingProxyType
|
||||
from typing import Final, Optional
|
||||
|
||||
from litellm_proxy_extras._logging import logger
|
||||
from litellm_proxy_extras.replica_identity import (
|
||||
|
|
@ -50,6 +51,17 @@ _SPEND_LOGS_PK_CLAUSE_RE = re.compile(
|
|||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_PRISMA_ATTEMPTS: Final = 4
|
||||
|
||||
_TRANSIENT_PRISMA_FAILURES: Final = MappingProxyType(
|
||||
{
|
||||
"deadlock detected": "a deadlock on the migration advisory lock (a concurrent migrate deploy)",
|
||||
"P1001": "an unreachable database server",
|
||||
"P1002": "a database server that timed out",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
PARTITIONED_SPEND_LOGS_PUSH_ERROR = (
|
||||
"LiteLLM_SpendLogs is a partitioned table (see db_scripts/partition_spend_logs.sql), "
|
||||
"so its primary key must include the partition key (\"startTime\"). `prisma db push` "
|
||||
|
|
@ -274,6 +286,23 @@ class ProxyExtrasDBManager:
|
|||
env=prisma_env,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _transient_prisma_failure(stderr: str) -> str | None:
|
||||
"""Why a failed prisma command is worth retrying, or None.
|
||||
|
||||
v1 retried every failure, so it absorbed a database that was not up yet
|
||||
or another instance holding the migration lock. v2 fails fast, which is
|
||||
right for a broken migration and wrong for these.
|
||||
"""
|
||||
return next(
|
||||
(
|
||||
reason
|
||||
for marker, reason in _TRANSIENT_PRISMA_FAILURES.items()
|
||||
if marker in stderr
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_permission_error(error_message: str) -> bool:
|
||||
"""
|
||||
|
|
@ -655,7 +684,7 @@ class ProxyExtrasDBManager:
|
|||
@staticmethod
|
||||
def _setup_database_v2(use_migrate: bool) -> bool:
|
||||
"""
|
||||
v2 migration resolver (opt-in via --use_v2_migration_resolver).
|
||||
v2 migration resolver (what the proxy CLI selects by default).
|
||||
|
||||
Runs `prisma migrate deploy` and handles standard recovery paths
|
||||
(P3005 baseline, P3009/P3018 idempotent errors). Critically, it does
|
||||
|
|
@ -676,20 +705,46 @@ class ProxyExtrasDBManager:
|
|||
original_dir = os.getcwd()
|
||||
os.chdir(migrations_dir)
|
||||
try:
|
||||
subprocess.run(
|
||||
[_get_prisma_command(), "db", "push", "--accept-data-loss"],
|
||||
timeout=prisma_command_timeout(),
|
||||
check=True,
|
||||
env=_get_prisma_env(),
|
||||
for attempt in range(_PRISMA_ATTEMPTS):
|
||||
try:
|
||||
subprocess.run(
|
||||
[_get_prisma_command(), "db", "push", "--accept-data-loss"],
|
||||
timeout=prisma_command_timeout(),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=_get_prisma_env(),
|
||||
)
|
||||
return True
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.info(
|
||||
"prisma db push attempt %s timed out, retrying",
|
||||
attempt + 1,
|
||||
)
|
||||
time.sleep(random.randrange(5, 15))
|
||||
except subprocess.CalledProcessError as e:
|
||||
stderr = e.stderr or ""
|
||||
transient = ProxyExtrasDBManager._transient_prisma_failure(
|
||||
stderr
|
||||
)
|
||||
# Re-raise as RuntimeError so proxy_cli.py's
|
||||
# `except RuntimeError` catches it and exits cleanly.
|
||||
if transient is None or attempt == _PRISMA_ATTEMPTS - 1:
|
||||
raise RuntimeError(
|
||||
f"prisma db push failed.\n\nDetail: {e}"
|
||||
f"\n\nPrisma error:\n{stderr}"
|
||||
) from e
|
||||
logger.info(
|
||||
"prisma db push attempt %s failed on %s, retrying. "
|
||||
"Prisma error:\n%s",
|
||||
attempt + 1,
|
||||
transient,
|
||||
stderr,
|
||||
)
|
||||
time.sleep(random.randrange(5, 15))
|
||||
raise RuntimeError(
|
||||
f"prisma db push failed after {_PRISMA_ATTEMPTS} attempts."
|
||||
)
|
||||
return True
|
||||
except (
|
||||
subprocess.CalledProcessError,
|
||||
subprocess.TimeoutExpired,
|
||||
) as e:
|
||||
# Re-raise as RuntimeError so proxy_cli.py's
|
||||
# `except RuntimeError` catches it and exits cleanly.
|
||||
raise RuntimeError(f"prisma db push failed.\n\nDetail: {e}") from e
|
||||
finally:
|
||||
os.chdir(original_dir)
|
||||
|
||||
|
|
@ -699,7 +754,7 @@ class ProxyExtrasDBManager:
|
|||
original_dir = os.getcwd()
|
||||
os.chdir(migrations_dir)
|
||||
try:
|
||||
for attempt in range(4):
|
||||
for attempt in range(_PRISMA_ATTEMPTS):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[_get_prisma_command(), "migrate", "deploy"],
|
||||
|
|
@ -814,16 +869,36 @@ class ProxyExtrasDBManager:
|
|||
f"Manual intervention required.\n\nPrisma error:\n{stderr}"
|
||||
) from e
|
||||
|
||||
raise RuntimeError(
|
||||
"Database migration failed and cannot be auto-recovered. "
|
||||
f"Manual intervention required.\n\nPrisma error:\n{stderr}"
|
||||
) from e
|
||||
transient = ProxyExtrasDBManager._transient_prisma_failure(stderr)
|
||||
if transient is None:
|
||||
raise RuntimeError(
|
||||
"Database migration failed and cannot be auto-recovered. "
|
||||
f"Manual intervention required.\n\nPrisma error:\n{stderr}"
|
||||
) from e
|
||||
|
||||
if attempt == _PRISMA_ATTEMPTS - 1:
|
||||
raise RuntimeError(
|
||||
f"Database migration failed after "
|
||||
f"{_PRISMA_ATTEMPTS} attempts on {transient}. "
|
||||
"Check database connectivity and load."
|
||||
f"\n\nPrisma error:\n{stderr}"
|
||||
) from e
|
||||
|
||||
logger.info(
|
||||
"prisma migrate deploy attempt %s failed on %s, retrying. "
|
||||
"Prisma error:\n%s",
|
||||
attempt + 1,
|
||||
transient,
|
||||
stderr,
|
||||
)
|
||||
time.sleep(random.randrange(5, 15))
|
||||
continue
|
||||
|
||||
raise RuntimeError(
|
||||
"Database migration failed after 4 attempts (retry loop "
|
||||
"exhausted by timeouts or repeated idempotent-recovery "
|
||||
"continues). Check database connectivity, load, and "
|
||||
"_prisma_migrations ledger state."
|
||||
f"Database migration failed after {_PRISMA_ATTEMPTS} "
|
||||
"attempts (retry loop exhausted by timeouts or repeated "
|
||||
"idempotent-recovery continues). Check database connectivity, "
|
||||
"load, and _prisma_migrations ledger state."
|
||||
)
|
||||
finally:
|
||||
os.chdir(original_dir)
|
||||
|
|
@ -871,10 +946,11 @@ class ProxyExtrasDBManager:
|
|||
|
||||
Args:
|
||||
use_migrate: Whether to use prisma migrate instead of db push
|
||||
use_v2_resolver: Opt into the v2 migration resolver (safer during
|
||||
use_v2_resolver: Run the v2 migration resolver (safer during
|
||||
rolling deploys; does not run the diff-and-force recovery
|
||||
that causes schema thrashing). Defaults to False for
|
||||
backwards compatibility.
|
||||
that causes schema thrashing). Defaults to False here so
|
||||
direct callers keep the old behavior; the proxy CLI passes
|
||||
True, so the proxy's runtime default is v2.
|
||||
|
||||
Returns:
|
||||
bool: True if setup was successful, False otherwise
|
||||
|
|
@ -892,7 +968,7 @@ class ProxyExtrasDBManager:
|
|||
@staticmethod
|
||||
def _run_migrations(use_migrate: bool, use_v2_resolver: bool) -> bool:
|
||||
if use_v2_resolver:
|
||||
logger.info("Using v2 migration resolver (--use_v2_migration_resolver)")
|
||||
logger.info("Using v2 migration resolver")
|
||||
return ProxyExtrasDBManager._setup_database_v2(use_migrate=use_migrate)
|
||||
|
||||
schema_path = ProxyExtrasDBManager._get_prisma_dir() + "/schema.prisma"
|
||||
|
|
|
|||
|
|
@ -1,242 +0,0 @@
|
|||
"""Regression tests for ProxyExtrasDBManager v2 migration resolver.
|
||||
|
||||
The v2 resolver is opt-in via `--use_v2_migration_resolver` / the
|
||||
`use_v2_resolver=True` kwarg. These tests exercise the v2 path; the v1
|
||||
(default) behavior is unchanged from pre-fix.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm_proxy_extras.utils import (
|
||||
ProxyExtrasDBManager,
|
||||
_max_migration_timestamp,
|
||||
_migration_timestamp,
|
||||
)
|
||||
|
||||
|
||||
def _fake_migrate_deploy_failure(returncode: int, stderr: str):
|
||||
def _run(*args, **kwargs):
|
||||
raise subprocess.CalledProcessError(
|
||||
returncode=returncode,
|
||||
cmd=args[0],
|
||||
stderr=stderr,
|
||||
output="",
|
||||
)
|
||||
|
||||
return _run
|
||||
|
||||
|
||||
def test_v2_p3018_permission_error_raises_runtime_error(monkeypatch, tmp_path):
|
||||
"""v2: a permission failure during migrate deploy raises RuntimeError."""
|
||||
monkeypatch.setenv("DATABASE_URL", "postgresql://u:p@localhost:9/x")
|
||||
monkeypatch.setattr(
|
||||
ProxyExtrasDBManager, "_warn_if_db_ahead_of_head", lambda _: None
|
||||
)
|
||||
monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", lambda: str(tmp_path))
|
||||
(tmp_path / "schema.prisma").write_text("// stub")
|
||||
|
||||
stderr = (
|
||||
"Error: P3018\nMigration name: 20250326162113_baseline\n"
|
||||
"Database error code: 42501\npermission denied for schema public"
|
||||
)
|
||||
with patch("subprocess.run", side_effect=_fake_migrate_deploy_failure(1, stderr)):
|
||||
with pytest.raises(RuntimeError, match="permission"):
|
||||
ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True)
|
||||
|
||||
|
||||
def test_v2_non_idempotent_p3009_raises_runtime_error(monkeypatch, tmp_path):
|
||||
"""v2: a non-idempotent migration failure raises (no silent recovery)."""
|
||||
monkeypatch.setenv("DATABASE_URL", "postgresql://u:p@localhost:9/x")
|
||||
monkeypatch.setattr(
|
||||
ProxyExtrasDBManager, "_warn_if_db_ahead_of_head", lambda _: None
|
||||
)
|
||||
monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", lambda: str(tmp_path))
|
||||
(tmp_path / "schema.prisma").write_text("// stub")
|
||||
|
||||
stderr = (
|
||||
"Error: P3009\nMigration `20260101000000_genuinely_broken` failed\n"
|
||||
'Reason: syntax error at or near "BRKN" LINE 42'
|
||||
)
|
||||
with patch("subprocess.run", side_effect=_fake_migrate_deploy_failure(1, stderr)):
|
||||
with pytest.raises(RuntimeError, match="cannot be auto-recovered"):
|
||||
ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True)
|
||||
|
||||
|
||||
def test_strip_prisma_query_params_removes_connection_limit():
|
||||
"""DATABASE_URLs with Prisma-specific params should be parseable by psycopg."""
|
||||
url = "postgresql://u:p@h:5432/db?connection_limit=100&pool_timeout=60&sslmode=require"
|
||||
stripped = ProxyExtrasDBManager._strip_prisma_query_params(url)
|
||||
assert "connection_limit" not in stripped
|
||||
assert "pool_timeout" not in stripped
|
||||
assert "sslmode=require" in stripped
|
||||
|
||||
|
||||
def test_strip_prisma_query_params_passthrough_no_query():
|
||||
"""URLs without query strings are returned unchanged."""
|
||||
url = "postgresql://u:p@h:5432/db"
|
||||
assert ProxyExtrasDBManager._strip_prisma_query_params(url) == url
|
||||
|
||||
|
||||
def test_migration_timestamp_extracts_leading_digits():
|
||||
assert _migration_timestamp("20260101000000_add_foo") == 20260101000000
|
||||
assert _migration_timestamp("20250326162113_baseline") == 20250326162113
|
||||
|
||||
|
||||
def test_migration_timestamp_returns_zero_on_malformed():
|
||||
assert _migration_timestamp("0_init") == 0
|
||||
assert _migration_timestamp("not_a_migration") == 0
|
||||
|
||||
|
||||
def test_max_migration_timestamp():
|
||||
names = {"20250326000000_a", "20260415000000_b", "20251115000000_c"}
|
||||
assert _max_migration_timestamp(names) == 20260415000000
|
||||
|
||||
|
||||
def test_max_migration_timestamp_empty_set():
|
||||
assert _max_migration_timestamp(set()) == 0
|
||||
|
||||
|
||||
def test_v1_default_still_calls_resolve_all_migrations(monkeypatch, tmp_path):
|
||||
"""v1 (default) continues to call _resolve_all_migrations on the happy path.
|
||||
|
||||
This is the existing buggy behavior — we're not fixing it in v1, only
|
||||
offering v2 as opt-in. This test pins the default so that a future
|
||||
inadvertent default flip is caught.
|
||||
"""
|
||||
monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", lambda: str(tmp_path))
|
||||
(tmp_path / "schema.prisma").write_text("// stub")
|
||||
|
||||
# Stub `prisma migrate deploy` to claim success with pending migrations
|
||||
# applied, which is the code path that triggers the legacy post-migration
|
||||
# sanity check (a call to _resolve_all_migrations).
|
||||
class FakeResult:
|
||||
stdout = "Applied migration.\n"
|
||||
stderr = ""
|
||||
|
||||
def fake_run(cmd, *args, **kwargs):
|
||||
return FakeResult()
|
||||
|
||||
resolve_called = {"n": 0}
|
||||
|
||||
def fake_resolve(*args, **kwargs):
|
||||
resolve_called["n"] += 1
|
||||
|
||||
monkeypatch.setattr("subprocess.run", fake_run)
|
||||
monkeypatch.setattr(ProxyExtrasDBManager, "_resolve_all_migrations", fake_resolve)
|
||||
|
||||
ok = ProxyExtrasDBManager.setup_database(use_migrate=True) # v2 flag NOT set
|
||||
assert ok is True
|
||||
assert resolve_called["n"] == 1, "v1 default should still invoke the legacy path"
|
||||
|
||||
|
||||
def test_v2_db_push_wraps_subprocess_error_as_runtime_error(monkeypatch, tmp_path):
|
||||
"""v2: a failing `prisma db push` must raise RuntimeError, not leak
|
||||
CalledProcessError past proxy_cli.py's `except RuntimeError`."""
|
||||
monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", lambda: str(tmp_path))
|
||||
(tmp_path / "schema.prisma").write_text("// stub")
|
||||
|
||||
stderr = "db push error"
|
||||
with patch("subprocess.run", side_effect=_fake_migrate_deploy_failure(1, stderr)):
|
||||
with pytest.raises(RuntimeError, match="prisma db push failed"):
|
||||
ProxyExtrasDBManager.setup_database(use_migrate=False, use_v2_resolver=True)
|
||||
|
||||
|
||||
def test_v2_warn_ahead_of_head_swallows_db_errors(monkeypatch, tmp_path):
|
||||
"""_warn_if_db_ahead_of_head must never raise — it's informational.
|
||||
|
||||
Non-connection DB errors (e.g. InsufficientPrivilege from a user
|
||||
without SELECT on _prisma_migrations) must be caught, not propagated.
|
||||
"""
|
||||
import psycopg
|
||||
|
||||
monkeypatch.setenv("DATABASE_URL", "postgresql://u:p@localhost:9/x")
|
||||
monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", lambda: str(tmp_path))
|
||||
(tmp_path / "schema.prisma").write_text("// stub")
|
||||
|
||||
class _FakeConn:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def execute(self, *a, **kw):
|
||||
# Simulate an InsufficientPrivilege (subclass of DatabaseError).
|
||||
raise psycopg.errors.InsufficientPrivilege("permission denied")
|
||||
|
||||
def _fake_connect(*a, **kw):
|
||||
return _FakeConn()
|
||||
|
||||
monkeypatch.setattr("psycopg.connect", _fake_connect)
|
||||
|
||||
# Must not raise.
|
||||
ProxyExtrasDBManager._warn_if_db_ahead_of_head(str(tmp_path))
|
||||
|
||||
|
||||
def test_v2_resolve_specific_migration_failure_raises_runtime_error(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
"""If marking a migration as applied fails inside P3009 idempotent
|
||||
recovery, the subprocess error must be re-raised as RuntimeError so
|
||||
proxy_cli.py catches it cleanly (instead of leaking CalledProcessError)."""
|
||||
monkeypatch.setattr(
|
||||
ProxyExtrasDBManager, "_warn_if_db_ahead_of_head", lambda _: None
|
||||
)
|
||||
monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", lambda: str(tmp_path))
|
||||
(tmp_path / "schema.prisma").write_text("// stub")
|
||||
monkeypatch.setattr(
|
||||
ProxyExtrasDBManager, "_roll_back_migration", lambda *a, **kw: None
|
||||
)
|
||||
|
||||
# First call: migrate deploy -> P3009 idempotent error.
|
||||
# Recovery path tries _resolve_specific_migration; that also raises.
|
||||
def _failing_resolve(*a, **kw):
|
||||
raise subprocess.CalledProcessError(
|
||||
returncode=1,
|
||||
cmd="prisma migrate resolve --applied",
|
||||
stderr="resolve failed",
|
||||
output="",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
ProxyExtrasDBManager, "_resolve_specific_migration", _failing_resolve
|
||||
)
|
||||
|
||||
stderr = (
|
||||
"Error: P3009\nMigration `20260101000000_some_migration` failed\n"
|
||||
"relation already exists"
|
||||
)
|
||||
with patch("subprocess.run", side_effect=_fake_migrate_deploy_failure(1, stderr)):
|
||||
with pytest.raises(
|
||||
RuntimeError, match="Failed to mark migration .* as applied"
|
||||
):
|
||||
ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True)
|
||||
|
||||
|
||||
def test_v2_does_not_call_resolve_all_migrations(monkeypatch, tmp_path):
|
||||
"""v2 must never call _resolve_all_migrations — that's the bug it fixes."""
|
||||
monkeypatch.setattr(
|
||||
ProxyExtrasDBManager, "_warn_if_db_ahead_of_head", lambda _: None
|
||||
)
|
||||
monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", lambda: str(tmp_path))
|
||||
(tmp_path / "schema.prisma").write_text("// stub")
|
||||
|
||||
class FakeResult:
|
||||
stdout = "Applied migration.\n"
|
||||
stderr = ""
|
||||
|
||||
monkeypatch.setattr("subprocess.run", lambda *a, **kw: FakeResult())
|
||||
|
||||
resolve_called = {"n": 0}
|
||||
monkeypatch.setattr(
|
||||
ProxyExtrasDBManager,
|
||||
"_resolve_all_migrations",
|
||||
lambda *a, **kw: resolve_called.__setitem__("n", resolve_called["n"] + 1),
|
||||
)
|
||||
|
||||
ok = ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True)
|
||||
assert ok is True
|
||||
assert resolve_called["n"] == 0, "v2 must not invoke the diff-and-force recovery"
|
||||
|
|
@ -9984,6 +9984,22 @@
|
|||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"azure_ai/deepseek-v4-flash-0731": {
|
||||
"cache_read_input_token_cost": 2.8e-08,
|
||||
"deprecation_date": "2026-12-03",
|
||||
"input_cost_per_token": 1.9e-07,
|
||||
"litellm_provider": "azure_ai",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 5.1e-07,
|
||||
"source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/deepseek/",
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"azure_ai/embed-v-4-0": {
|
||||
"input_cost_per_token": 1.2e-07,
|
||||
"litellm_provider": "azure_ai",
|
||||
|
|
|
|||
|
|
@ -913,13 +913,14 @@ class ProxyInitializationHelpers:
|
|||
envvar="ENFORCE_PRISMA_MIGRATION_CHECK",
|
||||
)
|
||||
@click.option(
|
||||
"--use_v2_migration_resolver",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
"--use_v2_migration_resolver/--use_legacy_migration_resolver",
|
||||
default=True,
|
||||
help=(
|
||||
"Opt into the v2 migration resolver. Avoids the diff-and-force recovery "
|
||||
"path that can cause schema thrashing during rolling deploys where two "
|
||||
"LiteLLM versions contend for the same DB. Default is the v1 resolver."
|
||||
"Which database migration resolver to run at startup. The default v2 "
|
||||
"resolver avoids the diff-and-force recovery path that can cause schema "
|
||||
"thrashing during rolling deploys where two LiteLLM versions contend for "
|
||||
"the same DB. Pass --use_legacy_migration_resolver, or set "
|
||||
"USE_V2_MIGRATION_RESOLVER=false, to fall back to v1."
|
||||
),
|
||||
envvar="USE_V2_MIGRATION_RESOLVER",
|
||||
)
|
||||
|
|
@ -1310,10 +1311,11 @@ def run_server(
|
|||
else:
|
||||
if not use_v2_migration_resolver:
|
||||
print(
|
||||
"\033[1;33mLiteLLM Proxy: Using default (v1) migration resolver. "
|
||||
"If your deployment has seen schema thrashing during rolling "
|
||||
"deploys, try --use_v2_migration_resolver (safer: avoids the "
|
||||
"diff-and-force recovery that caused the thrash).\033[0m"
|
||||
"\033[1;33mLiteLLM Proxy: Using the legacy (v1) migration resolver. "
|
||||
"The default v2 resolver is safer: it avoids the diff-and-force "
|
||||
"recovery that caused schema thrashing during rolling deploys. "
|
||||
"Remove --use_legacy_migration_resolver / "
|
||||
"USE_V2_MIGRATION_RESOLVER=false to switch back to it.\033[0m"
|
||||
)
|
||||
try:
|
||||
setup_ok: Final = PrismaManager.setup_database(
|
||||
|
|
@ -1321,10 +1323,10 @@ def run_server(
|
|||
use_v2_resolver=use_v2_migration_resolver,
|
||||
)
|
||||
except RuntimeError as e:
|
||||
# Raised on unrecoverable migration errors: the v2
|
||||
# resolver's non-idempotent failures and permission
|
||||
# issues, and any `prisma db push` against a
|
||||
# partitioned LiteLLM_SpendLogs.
|
||||
# Raised on unrecoverable migration errors: permission
|
||||
# failures from either resolver, the v2 resolver's
|
||||
# non-idempotent failures, and any `prisma db push`
|
||||
# against a partitioned LiteLLM_SpendLogs.
|
||||
print(
|
||||
f"\033[1;31mLiteLLM Proxy: Database migration cannot proceed. {e}\033[0m",
|
||||
file=sys.stderr,
|
||||
|
|
|
|||
|
|
@ -9984,6 +9984,22 @@
|
|||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"azure_ai/deepseek-v4-flash-0731": {
|
||||
"cache_read_input_token_cost": 2.8e-08,
|
||||
"deprecation_date": "2026-12-03",
|
||||
"input_cost_per_token": 1.9e-07,
|
||||
"litellm_provider": "azure_ai",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 5.1e-07,
|
||||
"source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/deepseek/",
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"azure_ai/embed-v-4-0": {
|
||||
"input_cost_per_token": 1.2e-07,
|
||||
"litellm_provider": "azure_ai",
|
||||
|
|
|
|||
571
tests/litellm-proxy-extras/test_setup_database_fail_fast.py
Normal file
571
tests/litellm-proxy-extras/test_setup_database_fail_fast.py
Normal file
|
|
@ -0,0 +1,571 @@
|
|||
"""Regression tests for ProxyExtrasDBManager's v2 migration resolver.
|
||||
|
||||
v2 is the proxy CLI default; v1 stays reachable via the `use_v2_resolver`
|
||||
kwarg, which still defaults to False for direct callers.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm_proxy_extras.utils import (
|
||||
_PRISMA_ATTEMPTS,
|
||||
ProxyExtrasDBManager,
|
||||
_max_migration_timestamp,
|
||||
_migration_timestamp,
|
||||
)
|
||||
|
||||
|
||||
def _fake_migrate_deploy_failure(returncode: int, stderr: str):
|
||||
def _run(*args, **kwargs):
|
||||
raise subprocess.CalledProcessError(
|
||||
returncode=returncode,
|
||||
cmd=args[0],
|
||||
stderr=stderr,
|
||||
output="",
|
||||
)
|
||||
|
||||
return _run
|
||||
|
||||
|
||||
def test_v2_p3018_permission_error_raises_runtime_error(monkeypatch, tmp_path):
|
||||
"""v2: a permission failure during migrate deploy raises RuntimeError."""
|
||||
monkeypatch.setenv("DATABASE_URL", "postgresql://u:p@localhost:9/x")
|
||||
monkeypatch.setattr(
|
||||
ProxyExtrasDBManager, "_warn_if_db_ahead_of_head", lambda _: None
|
||||
)
|
||||
monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", lambda: str(tmp_path))
|
||||
(tmp_path / "schema.prisma").write_text("// stub")
|
||||
|
||||
stderr = (
|
||||
"Error: P3018\nMigration name: 20250326162113_baseline\n"
|
||||
"Database error code: 42501\npermission denied for schema public"
|
||||
)
|
||||
with patch("subprocess.run", side_effect=_fake_migrate_deploy_failure(1, stderr)):
|
||||
with pytest.raises(RuntimeError, match="permission"):
|
||||
ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True)
|
||||
|
||||
|
||||
def test_v2_non_idempotent_p3009_raises_runtime_error(monkeypatch, tmp_path):
|
||||
"""v2: a non-idempotent migration failure raises (no silent recovery)."""
|
||||
monkeypatch.setenv("DATABASE_URL", "postgresql://u:p@localhost:9/x")
|
||||
monkeypatch.setattr(
|
||||
ProxyExtrasDBManager, "_warn_if_db_ahead_of_head", lambda _: None
|
||||
)
|
||||
monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", lambda: str(tmp_path))
|
||||
(tmp_path / "schema.prisma").write_text("// stub")
|
||||
|
||||
stderr = (
|
||||
"Error: P3009\nMigration `20260101000000_genuinely_broken` failed\n"
|
||||
'Reason: syntax error at or near "BRKN" LINE 42'
|
||||
)
|
||||
with patch("subprocess.run", side_effect=_fake_migrate_deploy_failure(1, stderr)):
|
||||
with pytest.raises(RuntimeError, match="cannot be auto-recovered"):
|
||||
ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True)
|
||||
|
||||
|
||||
def test_strip_prisma_query_params_removes_connection_limit():
|
||||
"""DATABASE_URLs with Prisma-specific params should be parseable by psycopg."""
|
||||
url = "postgresql://u:p@h:5432/db?connection_limit=100&pool_timeout=60&sslmode=require"
|
||||
stripped = ProxyExtrasDBManager._strip_prisma_query_params(url)
|
||||
assert "connection_limit" not in stripped
|
||||
assert "pool_timeout" not in stripped
|
||||
assert "sslmode=require" in stripped
|
||||
|
||||
|
||||
def test_strip_prisma_query_params_passthrough_no_query():
|
||||
"""URLs without query strings are returned unchanged."""
|
||||
url = "postgresql://u:p@h:5432/db"
|
||||
assert ProxyExtrasDBManager._strip_prisma_query_params(url) == url
|
||||
|
||||
|
||||
def test_migration_timestamp_extracts_leading_digits():
|
||||
assert _migration_timestamp("20260101000000_add_foo") == 20260101000000
|
||||
assert _migration_timestamp("20250326162113_baseline") == 20250326162113
|
||||
|
||||
|
||||
def test_migration_timestamp_returns_zero_on_malformed():
|
||||
assert _migration_timestamp("0_init") == 0
|
||||
assert _migration_timestamp("not_a_migration") == 0
|
||||
|
||||
|
||||
def test_max_migration_timestamp():
|
||||
names = {"20250326000000_a", "20260415000000_b", "20251115000000_c"}
|
||||
assert _max_migration_timestamp(names) == 20260415000000
|
||||
|
||||
|
||||
def test_max_migration_timestamp_empty_set():
|
||||
assert _max_migration_timestamp(set()) == 0
|
||||
|
||||
|
||||
def test_v1_default_still_calls_resolve_all_migrations(monkeypatch, tmp_path):
|
||||
"""v1 (default) continues to call _resolve_all_migrations on the happy path.
|
||||
|
||||
This is the existing buggy behavior — we're not fixing it in v1, only
|
||||
offering v2 as opt-in. This test pins the default so that a future
|
||||
inadvertent default flip is caught.
|
||||
"""
|
||||
monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", lambda: str(tmp_path))
|
||||
(tmp_path / "schema.prisma").write_text("// stub")
|
||||
|
||||
# Stub `prisma migrate deploy` to claim success with pending migrations
|
||||
# applied, which is the code path that triggers the legacy post-migration
|
||||
# sanity check (a call to _resolve_all_migrations).
|
||||
class FakeResult:
|
||||
stdout = "Applied migration.\n"
|
||||
stderr = ""
|
||||
|
||||
def fake_run(cmd, *args, **kwargs):
|
||||
return FakeResult()
|
||||
|
||||
resolve_called = {"n": 0}
|
||||
|
||||
def fake_resolve(*args, **kwargs):
|
||||
resolve_called["n"] += 1
|
||||
|
||||
monkeypatch.setattr("subprocess.run", fake_run)
|
||||
monkeypatch.setattr(ProxyExtrasDBManager, "_resolve_all_migrations", fake_resolve)
|
||||
|
||||
ok = ProxyExtrasDBManager.setup_database(use_migrate=True) # v2 flag NOT set
|
||||
assert ok is True
|
||||
assert resolve_called["n"] == 1, "v1 default should still invoke the legacy path"
|
||||
|
||||
|
||||
def test_v2_db_push_wraps_subprocess_error_as_runtime_error(monkeypatch, tmp_path):
|
||||
"""v2: a failing `prisma db push` must raise RuntimeError, not leak
|
||||
CalledProcessError past proxy_cli.py's `except RuntimeError`."""
|
||||
monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", lambda: str(tmp_path))
|
||||
(tmp_path / "schema.prisma").write_text("// stub")
|
||||
|
||||
stderr = "db push error"
|
||||
with patch("subprocess.run", side_effect=_fake_migrate_deploy_failure(1, stderr)):
|
||||
with pytest.raises(RuntimeError, match="prisma db push failed"):
|
||||
ProxyExtrasDBManager.setup_database(use_migrate=False, use_v2_resolver=True)
|
||||
|
||||
|
||||
def test_v2_warn_ahead_of_head_swallows_db_errors(monkeypatch, tmp_path):
|
||||
"""_warn_if_db_ahead_of_head must never raise — it's informational.
|
||||
|
||||
Non-connection DB errors (e.g. InsufficientPrivilege from a user
|
||||
without SELECT on _prisma_migrations) must be caught, not propagated.
|
||||
"""
|
||||
import psycopg
|
||||
|
||||
monkeypatch.setenv("DATABASE_URL", "postgresql://u:p@localhost:9/x")
|
||||
monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", lambda: str(tmp_path))
|
||||
(tmp_path / "schema.prisma").write_text("// stub")
|
||||
|
||||
class _FakeConn:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def execute(self, *a, **kw):
|
||||
# Simulate an InsufficientPrivilege (subclass of DatabaseError).
|
||||
raise psycopg.errors.InsufficientPrivilege("permission denied")
|
||||
|
||||
connects = {"n": 0}
|
||||
|
||||
def _fake_connect(*a, **kw):
|
||||
connects["n"] += 1
|
||||
return _FakeConn()
|
||||
|
||||
monkeypatch.setattr("psycopg.connect", _fake_connect)
|
||||
|
||||
assert ProxyExtrasDBManager._warn_if_db_ahead_of_head(str(tmp_path)) is None
|
||||
assert connects["n"] == 1, "the failing query must actually have been reached"
|
||||
|
||||
|
||||
def test_v2_resolve_specific_migration_failure_raises_runtime_error(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
"""If marking a migration as applied fails inside P3009 idempotent
|
||||
recovery, the subprocess error must be re-raised as RuntimeError so
|
||||
proxy_cli.py catches it cleanly (instead of leaking CalledProcessError)."""
|
||||
monkeypatch.setattr(
|
||||
ProxyExtrasDBManager, "_warn_if_db_ahead_of_head", lambda _: None
|
||||
)
|
||||
monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", lambda: str(tmp_path))
|
||||
(tmp_path / "schema.prisma").write_text("// stub")
|
||||
monkeypatch.setattr(
|
||||
ProxyExtrasDBManager, "_roll_back_migration", lambda *a, **kw: None
|
||||
)
|
||||
|
||||
# First call: migrate deploy -> P3009 idempotent error.
|
||||
# Recovery path tries _resolve_specific_migration; that also raises.
|
||||
def _failing_resolve(*a, **kw):
|
||||
raise subprocess.CalledProcessError(
|
||||
returncode=1,
|
||||
cmd="prisma migrate resolve --applied",
|
||||
stderr="resolve failed",
|
||||
output="",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
ProxyExtrasDBManager, "_resolve_specific_migration", _failing_resolve
|
||||
)
|
||||
|
||||
stderr = (
|
||||
"Error: P3009\nMigration `20260101000000_some_migration` failed\n"
|
||||
"relation already exists"
|
||||
)
|
||||
with patch("subprocess.run", side_effect=_fake_migrate_deploy_failure(1, stderr)):
|
||||
with pytest.raises(
|
||||
RuntimeError, match=r"Failed to mark migration .* as applied"
|
||||
):
|
||||
ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True)
|
||||
|
||||
|
||||
def test_v2_does_not_call_resolve_all_migrations(monkeypatch, tmp_path):
|
||||
"""v2 must never call _resolve_all_migrations — that's the bug it fixes."""
|
||||
monkeypatch.setattr(
|
||||
ProxyExtrasDBManager, "_warn_if_db_ahead_of_head", lambda _: None
|
||||
)
|
||||
monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", lambda: str(tmp_path))
|
||||
(tmp_path / "schema.prisma").write_text("// stub")
|
||||
|
||||
class FakeResult:
|
||||
stdout = "Applied migration.\n"
|
||||
stderr = ""
|
||||
|
||||
monkeypatch.setattr("subprocess.run", lambda *a, **kw: FakeResult())
|
||||
|
||||
resolve_called = {"n": 0}
|
||||
monkeypatch.setattr(
|
||||
ProxyExtrasDBManager,
|
||||
"_resolve_all_migrations",
|
||||
lambda *a, **kw: resolve_called.__setitem__("n", resolve_called["n"] + 1),
|
||||
)
|
||||
|
||||
ok = ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True)
|
||||
assert ok is True
|
||||
assert resolve_called["n"] == 0, "v2 must not invoke the diff-and-force recovery"
|
||||
|
||||
|
||||
_DEADLOCK_STDERR = (
|
||||
"Error: ERROR: deadlock detected\n"
|
||||
"DETAIL: Process 277 waits for ExclusiveLock on advisory lock "
|
||||
"[17556,0,72707369,1]; blocked by process 278.\n"
|
||||
"Process 278 waits for ShareLock on virtual transaction 3/1041; "
|
||||
"blocked by process 277."
|
||||
)
|
||||
|
||||
|
||||
class _DeployApplied:
|
||||
stdout = "All migrations have been successfully applied."
|
||||
stderr = ""
|
||||
returncode = 0
|
||||
|
||||
|
||||
def _deploy_only(deploy_side_effect):
|
||||
"""subprocess.run stand-in that only intercepts `prisma migrate deploy`.
|
||||
|
||||
Scoped by argv so the Prisma toolchain check cannot consume the mock first.
|
||||
"""
|
||||
deploys = {"n": 0}
|
||||
|
||||
def _run(*args, **kwargs):
|
||||
cmd = args[0] if args else kwargs.get("args", [])
|
||||
if list(cmd)[-2:] == ["migrate", "deploy"]:
|
||||
deploys["n"] += 1
|
||||
return deploy_side_effect(deploys["n"], cmd)
|
||||
return _DeployApplied()
|
||||
|
||||
return _run, deploys
|
||||
|
||||
|
||||
def _prepare_v2_resolver(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("DATABASE_URL", "postgresql://u:p@localhost:9/x")
|
||||
monkeypatch.setattr(
|
||||
ProxyExtrasDBManager, "_warn_if_db_ahead_of_head", lambda _: None
|
||||
)
|
||||
monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", lambda: str(tmp_path))
|
||||
(tmp_path / "schema.prisma").write_text("// stub")
|
||||
monkeypatch.setattr("time.sleep", lambda *_a, **_k: None)
|
||||
|
||||
|
||||
def test_v2_retries_transient_advisory_lock_deadlock(monkeypatch, tmp_path):
|
||||
"""v2: replicas racing `migrate deploy` deadlock on Prisma's advisory
|
||||
lock, which is transient and must be retried rather than kill the boot."""
|
||||
_prepare_v2_resolver(monkeypatch, tmp_path)
|
||||
|
||||
def _side_effect(n, cmd):
|
||||
if n == 1:
|
||||
raise subprocess.CalledProcessError(
|
||||
returncode=1, cmd=cmd, stderr=_DEADLOCK_STDERR, output=""
|
||||
)
|
||||
return _DeployApplied()
|
||||
|
||||
run, deploys = _deploy_only(_side_effect)
|
||||
with patch("subprocess.run", side_effect=run):
|
||||
ok = ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True)
|
||||
|
||||
assert ok is True
|
||||
assert deploys["n"] == 2, "the deadlocked deploy must be retried, not raised"
|
||||
|
||||
|
||||
def test_v2_persistent_advisory_lock_deadlock_eventually_raises(monkeypatch, tmp_path):
|
||||
"""v2: the deadlock retry is bounded, so a deadlock that never clears
|
||||
still raises instead of looping or reporting success."""
|
||||
_prepare_v2_resolver(monkeypatch, tmp_path)
|
||||
|
||||
def _side_effect(n, cmd):
|
||||
raise subprocess.CalledProcessError(
|
||||
returncode=1, cmd=cmd, stderr=_DEADLOCK_STDERR, output=""
|
||||
)
|
||||
|
||||
run, deploys = _deploy_only(_side_effect)
|
||||
with patch("subprocess.run", side_effect=run):
|
||||
with pytest.raises(RuntimeError, match="after 4 attempts"):
|
||||
ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True)
|
||||
|
||||
assert deploys["n"] == 4
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"stderr",
|
||||
[
|
||||
"Error: P1001: Can't reach database server at `db`:`5432`",
|
||||
"Error: P1002: The database server was reached but timed out.",
|
||||
],
|
||||
)
|
||||
def test_v2_retries_transient_database_connectivity_errors(monkeypatch, tmp_path, stderr):
|
||||
"""v2: a database not accepting connections yet is retried, not fatal."""
|
||||
_prepare_v2_resolver(monkeypatch, tmp_path)
|
||||
|
||||
def _side_effect(n, cmd):
|
||||
if n == 1:
|
||||
raise subprocess.CalledProcessError(
|
||||
returncode=1, cmd=cmd, stderr=stderr, output=""
|
||||
)
|
||||
return _DeployApplied()
|
||||
|
||||
run, deploys = _deploy_only(_side_effect)
|
||||
with patch("subprocess.run", side_effect=run):
|
||||
ok = ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True)
|
||||
|
||||
assert ok is True
|
||||
assert deploys["n"] == 2, "an unreachable database must be retried, not raised"
|
||||
|
||||
|
||||
def test_v2_unreachable_database_still_fails_after_the_retries(monkeypatch, tmp_path):
|
||||
"""v2: a genuinely unreachable database still raises once the attempts
|
||||
are spent, rather than passing as a successful migration."""
|
||||
_prepare_v2_resolver(monkeypatch, tmp_path)
|
||||
|
||||
def _side_effect(n, cmd):
|
||||
raise subprocess.CalledProcessError(
|
||||
returncode=1,
|
||||
cmd=cmd,
|
||||
stderr="Error: P1001: Can't reach database server at `db`:`5432`",
|
||||
output="",
|
||||
)
|
||||
|
||||
run, deploys = _deploy_only(_side_effect)
|
||||
with patch("subprocess.run", side_effect=run):
|
||||
with pytest.raises(RuntimeError, match="after 4 attempts"):
|
||||
ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True)
|
||||
|
||||
assert deploys["n"] == 4
|
||||
|
||||
|
||||
def test_v2_exhausted_retries_report_the_prisma_error(monkeypatch, tmp_path, caplog):
|
||||
"""v2: retrying must not swallow Prisma's stderr, which is captured and is
|
||||
the only place the cause appears for an operator or a boot-log grep."""
|
||||
_prepare_v2_resolver(monkeypatch, tmp_path)
|
||||
stderr = "Error: P1001: Can't reach database server at `wrong`:`5432`"
|
||||
|
||||
def _side_effect(n, cmd):
|
||||
raise subprocess.CalledProcessError(
|
||||
returncode=1, cmd=cmd, stderr=stderr, output=""
|
||||
)
|
||||
|
||||
run, _ = _deploy_only(_side_effect)
|
||||
with caplog.at_level("INFO", logger="litellm_proxy_extras"):
|
||||
with patch("subprocess.run", side_effect=run):
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
ProxyExtrasDBManager.setup_database(
|
||||
use_migrate=True, use_v2_resolver=True
|
||||
)
|
||||
|
||||
assert "P1001" in str(exc_info.value)
|
||||
assert "P1001" in caplog.text
|
||||
|
||||
|
||||
def test_v2_db_push_retries_transient_failures(monkeypatch, tmp_path):
|
||||
"""v2: `prisma db push` retries a transient failure like v1 did.
|
||||
|
||||
Reached from the migrations Job (USE_PRISMA_DB_PUSH=true), not from the
|
||||
proxy CLI, whose --use_prisma_db_push has its own loop in prisma_client.
|
||||
"""
|
||||
_prepare_v2_resolver(monkeypatch, tmp_path)
|
||||
|
||||
pushes = {"n": 0}
|
||||
|
||||
def _run(*args, **kwargs):
|
||||
cmd = list(args[0] if args else kwargs.get("args", []))
|
||||
if cmd[-3:] != ["db", "push", "--accept-data-loss"]:
|
||||
return _DeployApplied()
|
||||
pushes["n"] += 1
|
||||
if pushes["n"] == 1:
|
||||
raise subprocess.CalledProcessError(
|
||||
returncode=1,
|
||||
cmd=cmd,
|
||||
stderr="Error: P1001: Can't reach database server at `db`:`5432`",
|
||||
output="",
|
||||
)
|
||||
return _DeployApplied()
|
||||
|
||||
monkeypatch.setattr(
|
||||
ProxyExtrasDBManager, "spend_logs_is_partitioned", lambda: False
|
||||
)
|
||||
with patch("subprocess.run", side_effect=_run):
|
||||
ok = ProxyExtrasDBManager.setup_database(use_migrate=False, use_v2_resolver=True)
|
||||
|
||||
assert ok is True
|
||||
assert pushes["n"] == 2
|
||||
|
||||
|
||||
def test_v2_db_push_retries_are_bounded_and_report_the_prisma_error(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
"""v2: a database that never comes back stops after _PRISMA_ATTEMPTS and
|
||||
surfaces the prisma error, rather than retrying the boot forever."""
|
||||
_prepare_v2_resolver(monkeypatch, tmp_path)
|
||||
|
||||
pushes = {"n": 0}
|
||||
|
||||
def _run(*args, **kwargs):
|
||||
cmd = list(args[0] if args else kwargs.get("args", []))
|
||||
if cmd[-3:] != ["db", "push", "--accept-data-loss"]:
|
||||
return _DeployApplied()
|
||||
pushes["n"] += 1
|
||||
raise subprocess.CalledProcessError(
|
||||
returncode=1,
|
||||
cmd=cmd,
|
||||
stderr="Error: P1001: Can't reach database server at `db`:`5432`",
|
||||
output="",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
ProxyExtrasDBManager, "spend_logs_is_partitioned", lambda: False
|
||||
)
|
||||
with patch("subprocess.run", side_effect=_run):
|
||||
with pytest.raises(RuntimeError) as exc:
|
||||
ProxyExtrasDBManager.setup_database(
|
||||
use_migrate=False, use_v2_resolver=True
|
||||
)
|
||||
|
||||
assert pushes["n"] == _PRISMA_ATTEMPTS
|
||||
assert "P1001" in str(exc.value)
|
||||
|
||||
|
||||
def _db_push_only(push_side_effect):
|
||||
"""subprocess.run stand-in that only intercepts `prisma db push`."""
|
||||
pushes = {"n": 0}
|
||||
|
||||
def _run(*args, **kwargs):
|
||||
cmd = list(args[0] if args else kwargs.get("args", []))
|
||||
if cmd[-3:] != ["db", "push", "--accept-data-loss"]:
|
||||
return _DeployApplied()
|
||||
pushes["n"] += 1
|
||||
return push_side_effect(pushes["n"], cmd)
|
||||
|
||||
return _run, pushes
|
||||
|
||||
|
||||
def _timed_out_for_real():
|
||||
"""Capture what subprocess.run really puts on a TimeoutExpired.
|
||||
|
||||
Under text=True it still leaves stderr as bytes, unlike CalledProcessError,
|
||||
so hardcoding a str here would test a shape production never sees. Derived
|
||||
at import, before any test patches subprocess.run.
|
||||
"""
|
||||
try:
|
||||
subprocess.run(
|
||||
["sh", "-c", "echo 'Error: P1001 unreachable' >&2; sleep 5"],
|
||||
timeout=0.2,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
except subprocess.TimeoutExpired as e:
|
||||
return e
|
||||
raise AssertionError("the helper command was supposed to time out")
|
||||
|
||||
|
||||
_TIMEOUT_TEMPLATE = _timed_out_for_real()
|
||||
|
||||
|
||||
def _real_timeout_expired(cmd):
|
||||
return subprocess.TimeoutExpired(
|
||||
cmd=cmd,
|
||||
timeout=_TIMEOUT_TEMPLATE.timeout,
|
||||
output=_TIMEOUT_TEMPLATE.stdout,
|
||||
stderr=_TIMEOUT_TEMPLATE.stderr,
|
||||
)
|
||||
|
||||
|
||||
def test_v2_db_push_retries_a_timeout(monkeypatch, tmp_path):
|
||||
"""v2: a `prisma db push` that times out is retried, not turned into a
|
||||
TypeError by classifying its bytes stderr as if it were text."""
|
||||
_prepare_v2_resolver(monkeypatch, tmp_path)
|
||||
|
||||
def _side_effect(n, cmd):
|
||||
if n == 1:
|
||||
raise _real_timeout_expired(cmd)
|
||||
return _DeployApplied()
|
||||
|
||||
monkeypatch.setattr(
|
||||
ProxyExtrasDBManager, "spend_logs_is_partitioned", lambda: False
|
||||
)
|
||||
run, pushes = _db_push_only(_side_effect)
|
||||
with patch("subprocess.run", side_effect=run):
|
||||
ok = ProxyExtrasDBManager.setup_database(use_migrate=False, use_v2_resolver=True)
|
||||
|
||||
assert ok is True
|
||||
assert pushes["n"] == 2
|
||||
|
||||
|
||||
def test_v2_db_push_timeouts_are_bounded(monkeypatch, tmp_path):
|
||||
"""v2: a `prisma db push` that never stops timing out gives up as a
|
||||
RuntimeError, which is the only exception proxy_cli.py exits cleanly on."""
|
||||
_prepare_v2_resolver(monkeypatch, tmp_path)
|
||||
|
||||
def _side_effect(n, cmd):
|
||||
raise _real_timeout_expired(cmd)
|
||||
|
||||
monkeypatch.setattr(
|
||||
ProxyExtrasDBManager, "spend_logs_is_partitioned", lambda: False
|
||||
)
|
||||
run, pushes = _db_push_only(_side_effect)
|
||||
with patch("subprocess.run", side_effect=run):
|
||||
with pytest.raises(RuntimeError, match=r"prisma db push failed after \d+"):
|
||||
ProxyExtrasDBManager.setup_database(use_migrate=False, use_v2_resolver=True)
|
||||
|
||||
assert pushes["n"] == _PRISMA_ATTEMPTS
|
||||
|
||||
|
||||
def test_v2_unclassified_failure_is_not_treated_as_transient(monkeypatch, tmp_path):
|
||||
"""v2: an unrecognised deploy failure still raises on the first attempt."""
|
||||
_prepare_v2_resolver(monkeypatch, tmp_path)
|
||||
|
||||
def _side_effect(n, cmd):
|
||||
raise subprocess.CalledProcessError(
|
||||
returncode=1,
|
||||
cmd=cmd,
|
||||
stderr="Error: relation \"LiteLLM_SpendLogs\" does not exist",
|
||||
output="",
|
||||
)
|
||||
|
||||
run, deploys = _deploy_only(_side_effect)
|
||||
with patch("subprocess.run", side_effect=run):
|
||||
with pytest.raises(RuntimeError, match="cannot be auto-recovered"):
|
||||
ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True)
|
||||
|
||||
assert deploys["n"] == 1
|
||||
|
||||
|
||||
|
|
@ -305,14 +305,16 @@ def _run_proxy_server_smoke_test(extra_proxy_args=None):
|
|||
|
||||
|
||||
def test_litellm_proxy_server_config_no_general_settings():
|
||||
"""Exercises the default (v1) migration resolver."""
|
||||
"""Exercises the default (v2) migration resolver."""
|
||||
_run_proxy_server_smoke_test()
|
||||
|
||||
|
||||
def test_litellm_proxy_server_config_no_general_settings_v2_resolver():
|
||||
"""Exercises the opt-in v2 migration resolver.
|
||||
def test_litellm_proxy_server_config_no_general_settings_legacy_resolver():
|
||||
"""Exercises the legacy (v1) migration resolver against a real database.
|
||||
|
||||
Runs in a separate CI job against a local Postgres to avoid collisions
|
||||
with the v1 variant when they share a database.
|
||||
v2 is the default, so the no-arg test above already covers it. This one is
|
||||
the only place the v1 opt-out gets real-DB migration plus proxy-boot
|
||||
coverage, and it runs in a separate CI job against its own Postgres to
|
||||
avoid collisions with the default variant.
|
||||
"""
|
||||
_run_proxy_server_smoke_test(extra_proxy_args=["--use_v2_migration_resolver"])
|
||||
_run_proxy_server_smoke_test(extra_proxy_args=["--use_legacy_migration_resolver"])
|
||||
|
|
|
|||
|
|
@ -1737,7 +1737,8 @@ class TestRunServerDbSetup:
|
|||
mock_atexit_register,
|
||||
mock_subprocess_run,
|
||||
):
|
||||
"""Test that use_prisma_db_push flag correctly controls PrismaManager.setup_database use_migrate parameter"""
|
||||
"""Which resolver and which migration mode run_server hands setup_database,
|
||||
across the db push flag, the v2/legacy flag pair and USE_V2_MIGRATION_RESOLVER."""
|
||||
from litellm.proxy.proxy_cli import run_server
|
||||
|
||||
# Mock subprocess.run to simulate prisma being available
|
||||
|
|
@ -1787,7 +1788,7 @@ class TestRunServerDbSetup:
|
|||
# use_prisma_db_push should be False (default), so use_migrate should be True
|
||||
run_server.main(["--local", "--skip_server_startup"], standalone_mode=False)
|
||||
mock_setup_database.assert_called_with(
|
||||
use_migrate=True, use_v2_resolver=False
|
||||
use_migrate=True, use_v2_resolver=True
|
||||
)
|
||||
|
||||
# Reset mocks
|
||||
|
|
@ -1802,9 +1803,38 @@ class TestRunServerDbSetup:
|
|||
standalone_mode=False,
|
||||
)
|
||||
mock_setup_database.assert_called_with(
|
||||
use_migrate=False, use_v2_resolver=False
|
||||
use_migrate=False, use_v2_resolver=True
|
||||
)
|
||||
|
||||
for argv, env_value, expected_v2 in (
|
||||
([], None, True),
|
||||
(["--use_v2_migration_resolver"], None, True),
|
||||
(["--use_legacy_migration_resolver"], None, False),
|
||||
([], "false", False),
|
||||
([], "true", True),
|
||||
(["--use_v2_migration_resolver"], "false", True),
|
||||
(["--use_legacy_migration_resolver"], "true", False),
|
||||
):
|
||||
mock_setup_database.reset_mock()
|
||||
mock_should_update_schema.reset_mock()
|
||||
mock_should_update_schema.return_value = True
|
||||
|
||||
resolver_env = (
|
||||
{"USE_V2_MIGRATION_RESOLVER": env_value}
|
||||
if env_value is not None
|
||||
else {}
|
||||
)
|
||||
os.environ.pop("USE_V2_MIGRATION_RESOLVER", None)
|
||||
with patch.dict(os.environ, resolver_env):
|
||||
run_server.main(
|
||||
["--local", "--skip_server_startup", *argv],
|
||||
standalone_mode=False,
|
||||
)
|
||||
assert mock_setup_database.call_args.kwargs == {
|
||||
"use_migrate": True,
|
||||
"use_v2_resolver": expected_v2,
|
||||
}, f"argv={argv} env={env_value}"
|
||||
|
||||
@patch("subprocess.run")
|
||||
@patch("atexit.register")
|
||||
@patch("litellm.proxy.db.prisma_client.PrismaManager.setup_database")
|
||||
|
|
@ -1869,7 +1899,7 @@ class TestRunServerDbSetup:
|
|||
)
|
||||
assert exc_info.value.code == 1
|
||||
mock_setup_database.assert_called_once_with(
|
||||
use_migrate=True, use_v2_resolver=False
|
||||
use_migrate=True, use_v2_resolver=True
|
||||
)
|
||||
|
||||
@patch("subprocess.run")
|
||||
|
|
@ -1981,7 +2011,6 @@ class TestRunServerDbSetup:
|
|||
use_migrate=True, use_v2_resolver=True
|
||||
)
|
||||
|
||||
|
||||
# --- Module-level helpers for worker startup hook tests ---
|
||||
|
||||
_dummy_hook_called = False
|
||||
|
|
|
|||
51
ui/litellm-dashboard/package-lock.json
generated
51
ui/litellm-dashboard/package-lock.json
generated
|
|
@ -4891,9 +4891,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.27",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz",
|
||||
"integrity": "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==",
|
||||
"version": "2.11.20",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz",
|
||||
"integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"baseline-browser-mapping": "dist/cli.cjs"
|
||||
|
|
@ -4939,9 +4939,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.2",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
|
||||
"integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
|
||||
"version": "4.28.8",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
|
||||
"integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -4959,11 +4959,11 @@
|
|||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.12",
|
||||
"caniuse-lite": "^1.0.30001782",
|
||||
"electron-to-chromium": "^1.5.328",
|
||||
"node-releases": "^2.0.36",
|
||||
"update-browserslist-db": "^1.2.3"
|
||||
"baseline-browser-mapping": "^2.11.12",
|
||||
"caniuse-lite": "^1.0.30001809",
|
||||
"electron-to-chromium": "^1.5.402",
|
||||
"node-releases": "^2.0.53",
|
||||
"update-browserslist-db": "^1.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"browserslist": "cli.js"
|
||||
|
|
@ -5042,9 +5042,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001791",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz",
|
||||
"integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==",
|
||||
"version": "1.0.30001810",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
|
||||
"integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
|
|
@ -5706,9 +5706,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.349",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz",
|
||||
"integrity": "sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==",
|
||||
"version": "1.5.416",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.416.tgz",
|
||||
"integrity": "sha512-K6bvB2BjnNrugtIih6ewlbBI9DXa976jIdiIlRLHhBoEI9a4JaQjjHyF+A1IQI543aQYR4LnmOrT/K5fZj0aPA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
|
|
@ -9901,11 +9901,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.38",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz",
|
||||
"integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==",
|
||||
"version": "2.0.54",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz",
|
||||
"integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/nuqs": {
|
||||
"version": "2.9.4",
|
||||
|
|
@ -12350,9 +12353,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz",
|
||||
"integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useState } from "react";
|
||||
import { ArrowLeft, Check, Copy, Link2 } from "lucide-react";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { buildMarketplaceSettingsSnippet, formatInstallCommand } from "./helpers";
|
||||
import { Plugin } from "./types";
|
||||
|
||||
|
|
@ -50,48 +51,37 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack }) => {
|
|||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: "24px 32px 24px 0" }}>
|
||||
<div className="py-6 pl-0 pr-8">
|
||||
{/* Back link */}
|
||||
<div
|
||||
onClick={onBack}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
color: "#5f6368",
|
||||
cursor: "pointer",
|
||||
fontSize: 14,
|
||||
marginBottom: 24,
|
||||
}}
|
||||
className="mb-6 inline-flex cursor-pointer items-center gap-1.5 text-sm text-muted-foreground"
|
||||
>
|
||||
<ArrowLeft className="size-3" />
|
||||
<span>Skills</span>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<h1 style={{ fontSize: 28, fontWeight: 400, color: "#202124", margin: 0, lineHeight: 1.2 }}>{skill.name}</h1>
|
||||
<div className="mb-2">
|
||||
<h1 className="m-0 text-[28px] font-normal leading-tight text-foreground">{skill.name}</h1>
|
||||
{skill.description && (
|
||||
<p style={{ fontSize: 14, color: "#5f6368", margin: "8px 0 0 0", lineHeight: 1.6 }}>{skill.description}</p>
|
||||
<p className="mb-0 ml-0 mr-0 mt-2 text-sm leading-relaxed text-muted-foreground">{skill.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div style={{ borderBottom: "1px solid #dadce0", marginBottom: 28, marginTop: 24 }}>
|
||||
<div style={{ display: "flex", gap: 0 }}>
|
||||
<div className="mb-7 mt-6 border-b border-border">
|
||||
<div className="flex">
|
||||
{tabs.map((tab) => (
|
||||
<div
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
style={{
|
||||
padding: "12px 20px",
|
||||
fontSize: 14,
|
||||
color: activeTab === tab.key ? "#1a73e8" : "#5f6368",
|
||||
borderBottom: activeTab === tab.key ? "3px solid #1a73e8" : "3px solid transparent",
|
||||
cursor: "pointer",
|
||||
fontWeight: activeTab === tab.key ? 500 : 400,
|
||||
marginBottom: -1,
|
||||
}}
|
||||
className={cn(
|
||||
"-mb-px cursor-pointer border-b-[3px] px-5 py-3 text-sm",
|
||||
activeTab === tab.key
|
||||
? "border-info font-medium text-info"
|
||||
: "border-transparent font-normal text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</div>
|
||||
|
|
@ -101,27 +91,23 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack }) => {
|
|||
|
||||
{/* Overview tab */}
|
||||
{activeTab === "overview" && (
|
||||
<div style={{ display: "flex", gap: 64 }}>
|
||||
<div className="flex gap-16">
|
||||
{/* Left column */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<h2 style={{ fontSize: 18, fontWeight: 400, color: "#202124", margin: "0 0 4px 0" }}>Skill Details</h2>
|
||||
<p style={{ fontSize: 13, color: "#5f6368", margin: "0 0 16px 0" }}>Metadata registered with this skill</p>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 14 }}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="m-0 mb-1 text-lg font-normal text-foreground">Skill Details</h2>
|
||||
<p className="m-0 mb-4 text-[13px] text-muted-foreground">Metadata registered with this skill</p>
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr style={{ borderBottom: "1px solid #dadce0" }}>
|
||||
<th style={{ textAlign: "left", padding: "12px 0", color: "#5f6368", fontWeight: 500, width: 160 }}>
|
||||
Property
|
||||
</th>
|
||||
<th style={{ textAlign: "left", padding: "12px 0", color: "#5f6368", fontWeight: 500 }}>
|
||||
{skill.name}
|
||||
</th>
|
||||
<tr className="border-b border-border">
|
||||
<th className="w-40 py-3 text-left font-medium text-muted-foreground">Property</th>
|
||||
<th className="py-3 text-left font-medium text-muted-foreground">{skill.name}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{detailRows.map((row, i) => (
|
||||
<tr key={i} style={{ borderBottom: "1px solid #f1f3f4" }}>
|
||||
<td style={{ padding: "12px 0", color: "#3c4043" }}>{row.property}</td>
|
||||
<td style={{ padding: "12px 0", color: "#202124" }}>{row.value}</td>
|
||||
<tr key={i} className="border-b border-border">
|
||||
<td className="py-3 text-foreground">{row.property}</td>
|
||||
<td className="py-3 text-foreground">{row.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
|
@ -129,38 +115,27 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack }) => {
|
|||
</div>
|
||||
|
||||
{/* Right sidebar */}
|
||||
<div style={{ width: 240, flexShrink: 0 }}>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ fontSize: 12, color: "#5f6368", marginBottom: 4 }}>Status</div>
|
||||
<div className="w-60 shrink-0">
|
||||
<div className="mb-6">
|
||||
<div className="mb-1 text-xs text-muted-foreground">Status</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
padding: "3px 10px",
|
||||
borderRadius: 12,
|
||||
backgroundColor: skill.enabled ? "#e6f4ea" : "#f1f3f4",
|
||||
color: skill.enabled ? "#137333" : "#5f6368",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
className={cn(
|
||||
"rounded-xl px-2.5 py-[3px] text-xs font-medium",
|
||||
skill.enabled ? "bg-success/10 text-success" : "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{skill.enabled ? "Public" : "Draft"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{sourceUrl && (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ fontSize: 12, color: "#5f6368", marginBottom: 4 }}>Source</div>
|
||||
<div className="mb-6">
|
||||
<div className="mb-1 text-xs text-muted-foreground">Source</div>
|
||||
<a
|
||||
href={sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "#1a73e8",
|
||||
wordBreak: "break-all",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
}}
|
||||
className="flex items-center gap-1 break-all text-[13px] text-info"
|
||||
>
|
||||
{sourceUrl.replace("https://", "")}
|
||||
<Link2 className="size-3 shrink-0" />
|
||||
|
|
@ -169,20 +144,13 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack }) => {
|
|||
)}
|
||||
|
||||
{skill.keywords && skill.keywords.length > 0 && (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ fontSize: 12, color: "#5f6368", marginBottom: 8 }}>Tags</div>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
|
||||
<div className="mb-6">
|
||||
<div className="mb-2 text-xs text-muted-foreground">Tags</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{skill.keywords.map((kw) => (
|
||||
<span
|
||||
key={kw}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
padding: "4px 12px",
|
||||
borderRadius: 16,
|
||||
border: "1px solid #dadce0",
|
||||
color: "#3c4043",
|
||||
backgroundColor: "#fff",
|
||||
}}
|
||||
className="rounded-2xl border border-border bg-card px-3 py-1 text-xs text-foreground"
|
||||
>
|
||||
{kw}
|
||||
</span>
|
||||
|
|
@ -192,10 +160,8 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack }) => {
|
|||
)}
|
||||
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: "#5f6368", marginBottom: 4 }}>Skill ID</div>
|
||||
<div style={{ fontSize: 12, fontFamily: "monospace", color: "#3c4043", wordBreak: "break-all" }}>
|
||||
{skill.id}
|
||||
</div>
|
||||
<div className="mb-1 text-xs text-muted-foreground">Skill ID</div>
|
||||
<div className="break-all font-mono text-xs text-foreground">{skill.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -203,93 +169,43 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack }) => {
|
|||
|
||||
{/* How to Use tab */}
|
||||
{activeTab === "usage" && (
|
||||
<div style={{ maxWidth: 640 }}>
|
||||
<h2 style={{ fontSize: 18, fontWeight: 400, color: "#202124", margin: "0 0 8px 0" }}>Using this skill</h2>
|
||||
<p style={{ fontSize: 14, color: "#5f6368", margin: "0 0 24px 0", lineHeight: 1.6 }}>
|
||||
<div className="max-w-[640px]">
|
||||
<h2 className="m-0 mb-2 text-lg font-normal text-foreground">Using this skill</h2>
|
||||
<p className="m-0 mb-6 text-sm leading-relaxed text-muted-foreground">
|
||||
Once your proxy is set as a marketplace, enable this skill in Claude Code with one command:
|
||||
</p>
|
||||
|
||||
{/* Install command */}
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid #dadce0",
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "10px 16px",
|
||||
backgroundColor: "#f8f9fa",
|
||||
borderBottom: "1px solid #dadce0",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 13, color: "#3c4043", fontWeight: 500 }}>Run in Claude Code</span>
|
||||
<div className="mb-6 overflow-hidden rounded-lg border border-border">
|
||||
<div className="flex items-center justify-between border-b border-border bg-muted px-4 py-2.5">
|
||||
<span className="text-[13px] font-medium text-foreground">Run in Claude Code</span>
|
||||
<button
|
||||
onClick={() => copyToClipboard(installCommand, "install")}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
fontSize: 12,
|
||||
color: copiedKey === "install" ? "#137333" : "#1a73e8",
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
padding: 0,
|
||||
}}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-xs",
|
||||
copiedKey === "install" ? "text-success" : "text-info",
|
||||
)}
|
||||
>
|
||||
{copiedKey === "install" ? <Check className="size-3" /> : <Copy className="size-3" />}
|
||||
{copiedKey === "install" ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: "14px 16px",
|
||||
fontSize: 14,
|
||||
fontFamily: "monospace",
|
||||
color: "#202124",
|
||||
backgroundColor: "#fff",
|
||||
}}
|
||||
>
|
||||
{installCommand}
|
||||
</pre>
|
||||
<pre className="m-0 bg-card px-4 py-3.5 font-mono text-sm text-foreground">{installCommand}</pre>
|
||||
</div>
|
||||
|
||||
{/* Shown when the marketplace catalog is stale and the plugin isn't found yet */}
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid #fce8b2",
|
||||
borderRadius: 8,
|
||||
padding: "12px 16px",
|
||||
backgroundColor: "#fefce8",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<p style={{ fontSize: 13, color: "#5f6368", lineHeight: 1.6, margin: "0 0 8px 0" }}>
|
||||
<div className="mb-4 rounded-lg border border-warning/30 bg-warning/10 px-4 py-3">
|
||||
<p className="m-0 mb-2 text-[13px] leading-relaxed text-muted-foreground">
|
||||
If you see "Plugin {skill.name} not found in marketplace", update the catalog first:
|
||||
</p>
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 13,
|
||||
fontFamily: "monospace",
|
||||
color: "#202124",
|
||||
backgroundColor: "transparent",
|
||||
}}
|
||||
>
|
||||
<pre className="m-0 bg-transparent font-mono text-[13px] text-foreground">
|
||||
/plugin marketplace update litellm
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: 13, color: "#5f6368", lineHeight: 1.6, margin: 0 }}>
|
||||
<p className="m-0 text-[13px] leading-relaxed text-muted-foreground">
|
||||
Don't have the marketplace configured yet?{" "}
|
||||
<span onClick={() => setActiveTab("setup")} style={{ color: "#1a73e8", cursor: "pointer" }}>
|
||||
<span onClick={() => setActiveTab("setup")} className="cursor-pointer text-info">
|
||||
See one-time setup →
|
||||
</span>
|
||||
</p>
|
||||
|
|
@ -298,126 +214,56 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack }) => {
|
|||
|
||||
{/* Setup tab (linked from usage) */}
|
||||
{activeTab === "setup" && (
|
||||
<div style={{ maxWidth: 640 }}>
|
||||
<h2 style={{ fontSize: 18, fontWeight: 400, color: "#202124", margin: "0 0 8px 0" }}>
|
||||
One-time marketplace setup
|
||||
</h2>
|
||||
<div className="max-w-[640px]">
|
||||
<h2 className="m-0 mb-2 text-lg font-normal text-foreground">One-time marketplace setup</h2>
|
||||
|
||||
{/* Option 1: single command — fastest path for most users */}
|
||||
<p style={{ fontSize: 14, color: "#5f6368", margin: "0 0 12px 0", lineHeight: 1.6 }}>
|
||||
<p className="m-0 mb-3 text-sm leading-relaxed text-muted-foreground">
|
||||
Run this command in Claude Code to register the marketplace:
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid #dadce0",
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "10px 16px",
|
||||
backgroundColor: "#f8f9fa",
|
||||
borderBottom: "1px solid #dadce0",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 13, color: "#3c4043", fontWeight: 500 }}>Run in Claude Code</span>
|
||||
<div className="mb-6 overflow-hidden rounded-lg border border-border">
|
||||
<div className="flex items-center justify-between border-b border-border bg-muted px-4 py-2.5">
|
||||
<span className="text-[13px] font-medium text-foreground">Run in Claude Code</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
||||
copyToClipboard(`/plugin marketplace add ${origin}/claude-code/marketplace.json`, "marketplace-cmd");
|
||||
}}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
fontSize: 12,
|
||||
color: copiedKey === "marketplace-cmd" ? "#137333" : "#1a73e8",
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
padding: 0,
|
||||
}}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-xs",
|
||||
copiedKey === "marketplace-cmd" ? "text-success" : "text-info",
|
||||
)}
|
||||
>
|
||||
{copiedKey === "marketplace-cmd" ? <Check className="size-3" /> : <Copy className="size-3" />}
|
||||
{copiedKey === "marketplace-cmd" ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: "14px 16px",
|
||||
fontSize: 13,
|
||||
fontFamily: "monospace",
|
||||
color: "#202124",
|
||||
backgroundColor: "#fff",
|
||||
}}
|
||||
>
|
||||
<pre className="m-0 bg-card px-4 py-3.5 font-mono text-[13px] text-foreground">
|
||||
{`/plugin marketplace add ${typeof window !== "undefined" ? window.location.origin : "<proxy-url>"}/claude-code/marketplace.json`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Option 2: settings.json — for persistent config or managed deployments.
|
||||
extraKnownMarketplaces requires source to be a nested object, not a flat string. */}
|
||||
<p style={{ fontSize: 14, color: "#5f6368", margin: "0 0 12px 0", lineHeight: 1.6 }}>
|
||||
Or add this to{" "}
|
||||
<code style={{ fontSize: 13, backgroundColor: "#f1f3f4", padding: "1px 6px", borderRadius: 4 }}>
|
||||
~/.claude/settings.json
|
||||
</code>{" "}
|
||||
<p className="m-0 mb-3 text-sm leading-relaxed text-muted-foreground">
|
||||
Or add this to <code className="rounded bg-muted px-1.5 py-px text-[13px]">~/.claude/settings.json</code>{" "}
|
||||
for a persistent configuration:
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid #dadce0",
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "10px 16px",
|
||||
backgroundColor: "#f8f9fa",
|
||||
borderBottom: "1px solid #dadce0",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 13, color: "#3c4043", fontWeight: 500 }}>~/.claude/settings.json</span>
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
<div className="flex items-center justify-between border-b border-border bg-muted px-4 py-2.5">
|
||||
<span className="text-[13px] font-medium text-foreground">~/.claude/settings.json</span>
|
||||
<button
|
||||
onClick={() => copyToClipboard(settingsSnippet, "settings")}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
fontSize: 12,
|
||||
color: copiedKey === "settings" ? "#137333" : "#1a73e8",
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
padding: 0,
|
||||
}}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-xs",
|
||||
copiedKey === "settings" ? "text-success" : "text-info",
|
||||
)}
|
||||
>
|
||||
{copiedKey === "settings" ? <Check className="size-3" /> : <Copy className="size-3" />}
|
||||
{copiedKey === "settings" ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: "14px 16px",
|
||||
fontSize: 13,
|
||||
fontFamily: "monospace",
|
||||
color: "#202124",
|
||||
backgroundColor: "#fff",
|
||||
}}
|
||||
>
|
||||
{settingsSnippet}
|
||||
</pre>
|
||||
<pre className="m-0 bg-card px-4 py-3.5 font-mono text-[13px] text-foreground">{settingsSnippet}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue