mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-06 07:48:06 +00:00
Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_shadcn_shared_team_details_0812
This commit is contained in:
commit
21c55d2907
23 changed files with 1923 additions and 161 deletions
|
|
@ -3,7 +3,7 @@ import json
|
|||
import os
|
||||
from collections.abc import Callable, Mapping
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, NamedTuple
|
||||
|
||||
import httpx
|
||||
from pydantic import (
|
||||
|
|
@ -73,6 +73,27 @@ else:
|
|||
Span = Any
|
||||
|
||||
|
||||
class ReconcileOutcome(NamedTuple):
|
||||
"""What a model reconcile observed, captured while it still held the reconcile
|
||||
lock.
|
||||
|
||||
Both fields have to be read under that lock to be worth anything. ``live_after``
|
||||
in particular is the router's serving state the instant this reconcile finished,
|
||||
which is NOT the same as what a later snapshot would see: any other model write
|
||||
admitted in between briefly un-serves every db model (see ``clear_cache``), so a
|
||||
caller that re-snapshots at verdict time can observe that hole and blame its own
|
||||
reload for it.
|
||||
|
||||
- ``still_desired``: the db + config ids the reconcile reconciled against, or None
|
||||
when no reconcile ran and the desired set is therefore unknown.
|
||||
- ``live_after``: the ids the router served immediately after the reconcile, or
|
||||
None when no reconcile ran.
|
||||
"""
|
||||
|
||||
still_desired: frozenset[str] | None
|
||||
live_after: frozenset[str] | None
|
||||
|
||||
|
||||
class SupportedDBObjectType(str, enum.Enum):
|
||||
"""
|
||||
Supported database object types for fine-grained DB storage control.
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ from litellm.proxy._types import (
|
|||
PrismaCompatibleUpdateDBModel,
|
||||
ProxyErrorTypes,
|
||||
ProxyException,
|
||||
ReconcileOutcome,
|
||||
TeamModelAddRequest,
|
||||
TeamModelDeleteRequest,
|
||||
UserAPIKeyAuth,
|
||||
|
|
@ -534,7 +535,7 @@ async def patch_model(
|
|||
|
||||
# Clear cache and reload models (uses config setting or defaults to preserving config models for DB updates)
|
||||
live_before_reload: Final = live_model_ids_snapshot()
|
||||
still_desired_ids: Final = await clear_cache()
|
||||
reload_outcome: Final = await clear_cache()
|
||||
|
||||
## CREATE AUDIT LOG ##
|
||||
asyncio.create_task(
|
||||
|
|
@ -554,7 +555,8 @@ async def patch_model(
|
|||
before=live_before_reload,
|
||||
written_models=[(model_id, getattr(updated_model, "model_info", None))],
|
||||
action="update",
|
||||
still_desired=still_desired_ids,
|
||||
still_desired=reload_outcome.still_desired,
|
||||
live_after=reload_outcome.live_after,
|
||||
)
|
||||
|
||||
return updated_model
|
||||
|
|
@ -640,7 +642,7 @@ async def _set_model_blocked_status(
|
|||
)
|
||||
|
||||
live_before_reload: Final = live_model_ids_snapshot()
|
||||
still_desired_ids: Final = await clear_cache()
|
||||
reload_outcome: Final = await clear_cache()
|
||||
|
||||
asyncio.create_task(
|
||||
create_object_audit_log(
|
||||
|
|
@ -661,7 +663,8 @@ async def _set_model_blocked_status(
|
|||
before=live_before_reload,
|
||||
written_models=[(data.model_id, getattr(updated_model, "model_info", None))],
|
||||
action=action,
|
||||
still_desired=still_desired_ids,
|
||||
still_desired=reload_outcome.still_desired,
|
||||
live_after=reload_outcome.live_after,
|
||||
)
|
||||
|
||||
return updated_model
|
||||
|
|
@ -1033,9 +1036,15 @@ async def delete_team_models(
|
|||
if deleted_model_ids:
|
||||
await publish_config_change(redis_cache=coordination_redis_cache(), object_type="litellm_proxymodeltable")
|
||||
|
||||
# Under MODEL_RECONCILE_LOCK, for the same reason as delete_model: the rows are
|
||||
# gone, but a reconcile holding a pre-delete snapshot would upsert these ids back
|
||||
# onto this pod. The lock orders the eviction after any in-flight reconcile.
|
||||
if llm_router is not None:
|
||||
for model_id in deleted_model_ids:
|
||||
llm_router.delete_deployment(id=model_id)
|
||||
from litellm.proxy.proxy_server import MODEL_RECONCILE_LOCK
|
||||
|
||||
async with MODEL_RECONCILE_LOCK:
|
||||
for model_id in deleted_model_ids:
|
||||
llm_router.delete_deployment(id=model_id)
|
||||
|
||||
return deleted_model_ids
|
||||
|
||||
|
|
@ -1355,6 +1364,7 @@ async def delete_model(
|
|||
"""
|
||||
|
||||
from litellm.proxy.proxy_server import (
|
||||
MODEL_RECONCILE_LOCK,
|
||||
llm_router,
|
||||
premium_user,
|
||||
prisma_client,
|
||||
|
|
@ -1403,8 +1413,15 @@ async def delete_model(
|
|||
)
|
||||
|
||||
## DELETE FROM ROUTER ##
|
||||
# Under MODEL_RECONCILE_LOCK. The db row is already gone, but a reconcile
|
||||
# that snapshotted the db BEFORE that delete still lists this id as desired,
|
||||
# and its _add_deployment upserts the deployment straight back -- leaving
|
||||
# this pod serving a model the database no longer has, until the next
|
||||
# reconcile. Taking the lock orders this eviction after any such in-flight
|
||||
# reconcile's re-add, so the eviction is the last word.
|
||||
if llm_router is not None:
|
||||
llm_router.delete_deployment(id=model_info.id)
|
||||
async with MODEL_RECONCILE_LOCK:
|
||||
llm_router.delete_deployment(id=model_info.id)
|
||||
|
||||
# Runs after the row delete so the sibling check sees post-delete state.
|
||||
if model_params.model_info.team_id is not None:
|
||||
|
|
@ -1579,7 +1596,7 @@ async def add_new_model(
|
|||
"""
|
||||
|
||||
live_before_reload: Final = live_model_ids_snapshot()
|
||||
still_desired_ids: frozenset[str] | None = None
|
||||
reload_outcome: ReconcileOutcome = ReconcileOutcome(still_desired=None, live_after=None)
|
||||
try:
|
||||
_original_litellm_model_name: Final = model_params.model_name
|
||||
if model_params.model_info.team_id is None:
|
||||
|
|
@ -1594,7 +1611,7 @@ async def add_new_model(
|
|||
user_api_key_dict=user_api_key_dict,
|
||||
prisma_client=prisma_client,
|
||||
)
|
||||
still_desired_ids = await proxy_config.add_deployment(
|
||||
reload_outcome = await proxy_config.add_deployment(
|
||||
prisma_client=prisma_client, proxy_logging_obj=proxy_logging_obj
|
||||
)
|
||||
# don't let failed slack alert block the /model/new response
|
||||
|
|
@ -1641,7 +1658,8 @@ async def add_new_model(
|
|||
before=live_before_reload,
|
||||
written_models=[(model_response.model_id, getattr(model_response, "model_info", None))],
|
||||
action="create",
|
||||
still_desired=still_desired_ids,
|
||||
still_desired=reload_outcome.still_desired,
|
||||
live_after=reload_outcome.live_after,
|
||||
)
|
||||
|
||||
return model_response
|
||||
|
|
@ -1768,7 +1786,7 @@ async def update_model(
|
|||
|
||||
# Clear cache and reload models (uses config setting or defaults to preserving config models for DB updates)
|
||||
live_before_reload: Final = live_model_ids_snapshot()
|
||||
still_desired_ids: Final = await clear_cache()
|
||||
reload_outcome: Final = await clear_cache()
|
||||
## CREATE AUDIT LOG ##
|
||||
asyncio.create_task(
|
||||
create_object_audit_log(
|
||||
|
|
@ -1795,7 +1813,8 @@ async def update_model(
|
|||
before=live_before_reload,
|
||||
written_models=[(_model_id, getattr(model_response, "model_info", None))],
|
||||
action="update",
|
||||
still_desired=still_desired_ids,
|
||||
still_desired=reload_outcome.still_desired,
|
||||
live_after=reload_outcome.live_after,
|
||||
)
|
||||
|
||||
return model_response
|
||||
|
|
@ -2100,6 +2119,7 @@ def reload_serving_verdict(
|
|||
written_models: Sequence[tuple[str, object]],
|
||||
written_must_serve: bool,
|
||||
still_desired: frozenset[str] | None = None,
|
||||
live_after: frozenset[str] | None = None,
|
||||
) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
||||
"""Judge a write-triggered reload by diffing the router's serving state instead of
|
||||
trusting any layer of the reload stack to report its own failure.
|
||||
|
|
@ -2121,9 +2141,16 @@ def reload_serving_verdict(
|
|||
yet polled, so the reload dropping it is the reconcile working rather than damage.
|
||||
Without it (no reconcile ran) every drop is reported, which is the safe direction.
|
||||
|
||||
``live_after`` is the router's serving state captured by the reload itself, while it
|
||||
still held MODEL_RECONCILE_LOCK. Pass it whenever the caller has it: re-reading the
|
||||
router here instead means sampling it after the lock was released, where the NEXT
|
||||
reconcile's leading wipe (clear_cache un-serves every db model before reloading
|
||||
them) shows up as this reload having dropped them. Falling back to a fresh read is
|
||||
only correct when no reconcile ran and there is nothing to be concurrent with.
|
||||
|
||||
Returns (written ids violating their obligation, collateral ids no longer served).
|
||||
"""
|
||||
now: Final = live_model_ids_snapshot()
|
||||
now: Final = live_model_ids_snapshot() if live_after is None else live_after
|
||||
written_ids: Final = frozenset(model_id for model_id, _ in written_models)
|
||||
if written_must_serve:
|
||||
missing = tuple(
|
||||
|
|
@ -2143,16 +2170,23 @@ def raise_if_reload_degraded_serving(
|
|||
written_models: Sequence[tuple[str, object]],
|
||||
action: str,
|
||||
still_desired: frozenset[str] | None = None,
|
||||
live_after: frozenset[str] | None = None,
|
||||
) -> None:
|
||||
"""The caller-visible error this pod's model-write endpoints owe their caller when
|
||||
the model they wrote is not being served after the reload they triggered. The DB
|
||||
write is durable either way and every other pod reloads on its own interval; this
|
||||
speaks only for the handling pod."""
|
||||
speaks only for the handling pod.
|
||||
|
||||
Callers hold a ReconcileOutcome from the reload; pass BOTH of its fields. Supplying
|
||||
still_desired without live_after mixes a snapshot taken under the reconcile lock
|
||||
with one taken after it was released, which is what makes a concurrent model write
|
||||
look like collateral damage."""
|
||||
missing, collateral = reload_serving_verdict(
|
||||
before=before,
|
||||
written_models=written_models,
|
||||
written_must_serve=True,
|
||||
still_desired=still_desired,
|
||||
live_after=live_after,
|
||||
)
|
||||
if not missing and not collateral:
|
||||
return
|
||||
|
|
@ -2179,14 +2213,20 @@ def raise_if_reload_degraded_serving(
|
|||
)
|
||||
|
||||
|
||||
async def clear_cache() -> frozenset[str] | None:
|
||||
async def clear_cache() -> ReconcileOutcome:
|
||||
"""
|
||||
Clear router caches and reload models.
|
||||
|
||||
Returns the db + config id set the reload reconciled against, or None when no
|
||||
reload ran, so callers can pass it to raise_if_reload_degraded_serving.
|
||||
Returns what the reload saw (see ReconcileOutcome) so callers can pass it to
|
||||
raise_if_reload_degraded_serving.
|
||||
|
||||
Runs under MODEL_RECONCILE_LOCK for its whole extent, not just the reload at the
|
||||
end, so the auto-router reset and the reload that rebuilds those routers are atomic
|
||||
to any other reconcile. The inner call is _add_deployment_locked because
|
||||
add_deployment would re-acquire the same non-reentrant lock and deadlock.
|
||||
"""
|
||||
from litellm.proxy.proxy_server import (
|
||||
MODEL_RECONCILE_LOCK,
|
||||
llm_router,
|
||||
prisma_client,
|
||||
proxy_config,
|
||||
|
|
@ -2196,61 +2236,88 @@ async def clear_cache() -> frozenset[str] | None:
|
|||
|
||||
if llm_router is None or prisma_client is None:
|
||||
verbose_proxy_logger.debug("llm_router or prisma_client is None, skipping cache clear")
|
||||
return None
|
||||
return ReconcileOutcome(still_desired=None, live_after=None)
|
||||
|
||||
try:
|
||||
# Only clear DB models, preserve config models
|
||||
verbose_proxy_logger.debug("Clearing only DB models, preserving config models")
|
||||
async with MODEL_RECONCILE_LOCK:
|
||||
try:
|
||||
# Only clear DB models, preserve config models
|
||||
verbose_proxy_logger.debug("Clearing only DB models, preserving config models")
|
||||
|
||||
# Get current models and filter out DB models
|
||||
current_models: Final = llm_router.model_list.copy()
|
||||
config_models: Final = []
|
||||
db_model_ids: Final = []
|
||||
# Get current models and filter out DB models
|
||||
current_models: Final = llm_router.model_list.copy()
|
||||
config_models: Final = []
|
||||
db_model_ids: Final = []
|
||||
|
||||
for model in current_models:
|
||||
model_info = model.get("model_info", {})
|
||||
if model_info.get("db_model", False):
|
||||
# This is a DB model, mark for deletion
|
||||
db_model_ids.append(model_info.get("id"))
|
||||
else:
|
||||
# This is a config model, preserve it
|
||||
config_models.append(model)
|
||||
db_router_names: Final = set()
|
||||
|
||||
# Clear only DB models
|
||||
for model_id in db_model_ids:
|
||||
llm_router.delete_deployment(id=model_id)
|
||||
for model in current_models:
|
||||
model_info = model.get("model_info", {})
|
||||
if model_info.get("db_model", False):
|
||||
db_model_ids.append(model_info.get("id"))
|
||||
# Auto-router deployments (and only those) are wiped here, in the
|
||||
# same pass, so the reload rebuilds them -- see the comment below.
|
||||
model_name = model.get("model_name")
|
||||
if model_name is not None and str(model.get("litellm_params", {}).get("model", "")).startswith(
|
||||
"auto_router/"
|
||||
):
|
||||
db_router_names.add(model_name)
|
||||
router_model_id = model_info.get("id")
|
||||
if router_model_id is not None:
|
||||
llm_router.delete_deployment(id=router_model_id)
|
||||
else:
|
||||
# This is a config model, preserved by the reconcile below
|
||||
config_models.append(model)
|
||||
|
||||
# Clear only DB-backed auto-router-family entries, keyed by model_name, so the
|
||||
# reload below rebuilds them fresh. A blanket .clear() would also drop config-defined
|
||||
# routers, which are never re-added below (add_deployment only reloads DB models),
|
||||
# leaving them permanently unroutable until a full proxy restart for every tenant.
|
||||
# Restrict to deployments whose model is actually an auto_router/* so a config
|
||||
# router that merely shares a model_name with a regular DB model isn't evicted. The
|
||||
# auto_router/ prefix also covers quality_router/ and adaptive_router/, so pop the
|
||||
# name from every router registry (no-op where absent); missing quality/adaptive
|
||||
# entries would otherwise make init raise "already exists" on reload and abort it.
|
||||
db_router_names: Final = {
|
||||
model.get("model_name")
|
||||
for model in current_models
|
||||
if model.get("model_name") is not None
|
||||
and model.get("model_info", {}).get("db_model", False)
|
||||
and str(model.get("litellm_params", {}).get("model", "")).startswith("auto_router/")
|
||||
}
|
||||
for model_name in db_router_names:
|
||||
llm_router.auto_routers.pop(model_name, None)
|
||||
llm_router.complexity_routers.pop(model_name, None)
|
||||
llm_router.adaptive_routers.pop(model_name, None)
|
||||
llm_router.quality_routers.pop(model_name, None)
|
||||
# ORDINARY db deployments are deliberately NOT wiped. This used to
|
||||
# delete_deployment() every db model before the reload put them back, which
|
||||
# left the router serving ZERO db models for the whole width of the reload
|
||||
# -- a real data-plane hole that every inference request landing in it fell
|
||||
# into. It was also redundant for them: the reload's _delete_deployment
|
||||
# evicts exactly the ids the db no longer lists, and upsert_deployment
|
||||
# pops-and-re-adds a deployment whose params changed while no-opping one
|
||||
# that did not, so the reconcile converges on its own. Every mutation is
|
||||
# visible to that comparison -- `blocked` and (for premium) `updated_at`
|
||||
# are written into model_info.
|
||||
#
|
||||
# AUTO-ROUTER db deployments are the exception and ARE wiped -- in the
|
||||
# classification pass above, together with the strategy entries popped
|
||||
# just below. Their strategy registries are keyed
|
||||
# by model_name, which no deployment-id reconcile touches, so they have to
|
||||
# be popped and rebuilt here. But the rebuild only happens on the ADD path:
|
||||
# Router.upsert_deployment returns early when a deployment is unchanged and
|
||||
# never reaches add_deployment -> _add_deployment ->
|
||||
# init_auto_router_deployment, which is what repopulates the registries.
|
||||
# Popping without deleting would therefore strip every db-backed auto,
|
||||
# complexity, adaptive and quality router on this pod and never put it back,
|
||||
# so ANY unrelated model write would leave them unroutable until a restart.
|
||||
# Deleting the deployment forces upsert down the add path, which rebuilds
|
||||
# both the deployment and its strategy entry.
|
||||
#
|
||||
# That pass restricts the wipe to deployments whose model is actually an
|
||||
# auto_router/* so a config router that merely shares a model_name with a
|
||||
# regular db model isn't evicted -- config routers are never re-added by the
|
||||
# reload (it only reloads db models) and would be permanently unroutable.
|
||||
# The auto_router/ prefix also covers quality_router/ and adaptive_router/,
|
||||
# so pop the name from every registry (no-op where absent); a missing
|
||||
# quality/adaptive entry would otherwise make init raise "already exists"
|
||||
# on reload and abort it.
|
||||
for model_name in db_router_names:
|
||||
llm_router.auto_routers.pop(model_name, None)
|
||||
llm_router.complexity_routers.pop(model_name, None)
|
||||
llm_router.adaptive_routers.pop(model_name, None)
|
||||
llm_router.quality_routers.pop(model_name, None)
|
||||
|
||||
# Reload only DB models
|
||||
still_desired_ids: Final = await proxy_config.add_deployment(
|
||||
prisma_client=prisma_client, proxy_logging_obj=proxy_logging_obj
|
||||
)
|
||||
# Reload only DB models. _add_deployment_locked, not add_deployment: this
|
||||
# coroutine already holds MODEL_RECONCILE_LOCK and asyncio.Lock is not
|
||||
# reentrant, so the public wrapper would deadlock against itself.
|
||||
outcome: Final = await proxy_config._add_deployment_locked(
|
||||
prisma_client=prisma_client, proxy_logging_obj=proxy_logging_obj
|
||||
)
|
||||
|
||||
verbose_proxy_logger.debug(
|
||||
"Cleared %s DB models, preserved %s config models", len(db_model_ids), len(config_models)
|
||||
)
|
||||
return still_desired_ids
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception("Failed to clear cache and reload models. Due to error - %s", e)
|
||||
return None
|
||||
verbose_proxy_logger.debug(
|
||||
"Reconciled %s DB models, preserved %s config models", len(db_model_ids), len(config_models)
|
||||
)
|
||||
return outcome
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception("Failed to clear cache and reload models. Due to error - %s", e)
|
||||
return ReconcileOutcome(still_desired=None, live_after=None)
|
||||
|
|
|
|||
|
|
@ -467,6 +467,7 @@ from litellm.proxy.management_endpoints.model_management_endpoints import (
|
|||
_add_model_to_db,
|
||||
_add_team_model_to_db,
|
||||
_deduplicate_litellm_router_models,
|
||||
live_model_ids_snapshot,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.model_management_endpoints import (
|
||||
router as model_management_router,
|
||||
|
|
@ -2166,6 +2167,15 @@ experimental = False
|
|||
#### GLOBAL VARIABLES ####
|
||||
llm_router: Router | None = None
|
||||
llm_model_list: list | None = None
|
||||
# Serializes every model reconcile (ProxyConfig.add_deployment and clear_cache) so the
|
||||
# read-modify-write of llm_router above is atomic. Without it, two concurrent model
|
||||
# writes each reconcile the router against their OWN db snapshot, and the one holding
|
||||
# the older snapshot evicts the deployment the newer one just added -- the db keeps the
|
||||
# row, this pod stops serving it. Control-plane only (model create/update/delete and
|
||||
# the config-sync tick), never on a completion path, so the serialization is free.
|
||||
# Module-level rather than per-ProxyConfig because llm_router is a module global and a
|
||||
# second ProxyConfig instance must not get its own independent lock over it.
|
||||
MODEL_RECONCILE_LOCK: Final = asyncio.Lock()
|
||||
general_settings: dict = {}
|
||||
config_passthrough_endpoints: list[dict[str, Any]] | None = None
|
||||
log_file: Final = "api_log.json"
|
||||
|
|
@ -6456,16 +6466,37 @@ class ProxyConfig:
|
|||
self,
|
||||
prisma_client: PrismaClient,
|
||||
proxy_logging_obj: ProxyLogging,
|
||||
) -> frozenset[str] | None:
|
||||
) -> ReconcileOutcome:
|
||||
"""
|
||||
- Check db for new models
|
||||
- Check if model id's in router already
|
||||
- If not, add to router
|
||||
|
||||
Returns the ids the db + config say should be served after the reconcile, or
|
||||
None when no reconcile ran. Callers that judge their own reload need it to tell
|
||||
a deliberate eviction from a deployment that went missing.
|
||||
Serialized against every other model reconcile by MODEL_RECONCILE_LOCK, because
|
||||
the work below is a read-modify-write of the shared ``llm_router`` global: it
|
||||
reads the db into a snapshot and then makes the router match that snapshot. Two
|
||||
of those interleaving is not a lost update but an eviction -- the request whose
|
||||
snapshot predates the other's commit reconciles the newer model *out* of the
|
||||
router, since _delete_deployment removes every live deployment absent from the
|
||||
snapshot it was handed. The model stays in the db and this pod stops serving it
|
||||
until some later reload puts it back.
|
||||
|
||||
Returns what the reconcile saw, captured before the lock is released so a
|
||||
caller's verdict cannot be corrupted by the next reconcile's own in-flight
|
||||
window. See ReconcileOutcome.
|
||||
"""
|
||||
async with MODEL_RECONCILE_LOCK:
|
||||
return await self._add_deployment_locked(prisma_client=prisma_client, proxy_logging_obj=proxy_logging_obj)
|
||||
|
||||
async def _add_deployment_locked(
|
||||
self,
|
||||
prisma_client: PrismaClient,
|
||||
proxy_logging_obj: ProxyLogging,
|
||||
) -> ReconcileOutcome:
|
||||
"""add_deployment's body, minus the locking. MODEL_RECONCILE_LOCK MUST already
|
||||
be held. Split out for the one caller that has to hold the lock across more than
|
||||
this reconcile -- clear_cache, which un-serves every db model before calling it
|
||||
and would deadlock on a re-acquire."""
|
||||
global llm_router, llm_model_list, master_key, general_settings
|
||||
|
||||
still_desired_ids: frozenset[str] | None = None
|
||||
|
|
@ -6508,7 +6539,12 @@ class ProxyConfig:
|
|||
except Exception as e:
|
||||
verbose_proxy_logger.exception("litellm.proxy.proxy_server.py::ProxyConfig:add_deployment - %s", e)
|
||||
|
||||
return still_desired_ids
|
||||
# Read while the lock is still held: once it is released the next reconcile can
|
||||
# begin, and clear_cache's leading wipe would make this look like a mass drop.
|
||||
return ReconcileOutcome(
|
||||
still_desired=still_desired_ids,
|
||||
live_after=None if still_desired_ids is None else live_model_ids_snapshot(),
|
||||
)
|
||||
|
||||
def start_config_sync_subscriber(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ Mock LLM server for UI e2e tests.
|
|||
Responds to OpenAI-format endpoints with canned responses.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
import uuid
|
||||
|
|
@ -117,4 +118,12 @@ async def embeddings(request: Request):
|
|||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="127.0.0.1", port=8090)
|
||||
# The port is overridable so two checkouts can run the harness at the same
|
||||
# time; the default keeps every existing caller (run_e2e.sh, the CircleCI
|
||||
# job, the e2e chart's sidecar) working untouched.
|
||||
#
|
||||
# The HOST is deliberately NOT configurable. Binding loopback is what makes
|
||||
# this reachable at 127.0.0.1:8090 from inside the proxy's own pod, which is
|
||||
# the contract the deployed config.yml and the e2e values file are written
|
||||
# against.
|
||||
uvicorn.run(app, host="127.0.0.1", port=int(os.environ.get("MOCK_LLM_PORT", "8090")))
|
||||
|
|
|
|||
65
tests/e2e/ui/helpers/mcp.ts
Normal file
65
tests/e2e/ui/helpers/mcp.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { expect, Page as PwPage } from "@playwright/test";
|
||||
import { navigateToPage } from "./navigation";
|
||||
import { Page } from "../fixtures/pages";
|
||||
import { masterKey } from "./traffic";
|
||||
|
||||
/** Creates an MCP server through the UI's discovery to custom-form flow and returns its name. */
|
||||
export async function createMcpServer(page: PwPage, url: string): Promise<string> {
|
||||
await navigateToPage(page, Page.McpServers);
|
||||
|
||||
await page.getByRole("button", { name: /Add New MCP Server/i }).click();
|
||||
const discovery = page.getByRole("dialog").filter({ hasText: "Add MCP Server" });
|
||||
await expect(discovery).toBeVisible({ timeout: 5_000 });
|
||||
await discovery.getByRole("button", { name: /Custom Server/i }).click();
|
||||
|
||||
const formModal = page.locator(".ant-modal:visible").filter({ hasText: "MCP Server Name" });
|
||||
await expect(formModal).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// validateMCPServerName rejects spaces and hyphens; the worker index avoids a same-millisecond collision.
|
||||
const name = `e2e_mcp_${process.env.TEST_WORKER_INDEX ?? "0"}_${Date.now()}`;
|
||||
await formModal.locator('input[id="server_name"]').fill(name);
|
||||
|
||||
const transportField = formModal.locator(".ant-form-item", { hasText: "Transport Type" });
|
||||
await transportField.locator(".ant-select").click();
|
||||
await page.locator(".ant-select-dropdown:visible").getByText("Streamable HTTP").click();
|
||||
|
||||
await formModal.locator('input[id="url"]').fill(url);
|
||||
|
||||
// The auth_type Form.Item has no label prop, so anchor on the enclosing Collapse panel.
|
||||
const authSection = formModal.locator(".ant-collapse-item", { hasText: /^Authentication/ });
|
||||
await authSection.locator(".ant-form-item").first().locator(".ant-select").click();
|
||||
await page.locator(".ant-select-dropdown:visible").getByText("None", { exact: true }).click();
|
||||
|
||||
await formModal.getByRole("button", { name: /^Add MCP Server$/ }).click();
|
||||
await expect(page.getByText("MCP Server created successfully").first()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const card = page.getByTestId("mcp-servers-grid").getByText(name).first();
|
||||
await expect(card).toBeVisible({ timeout: 10_000 });
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes every server carrying `serverName`. Leaked servers break unrelated MCP specs: the page
|
||||
* reaches out to each one it lists, so unreachable leftovers stall networkidle until it times out.
|
||||
* Errors are swallowed because this runs from afterEach.
|
||||
*/
|
||||
export async function deleteMcpServerByName(page: PwPage, serverName: string): Promise<void> {
|
||||
const headers = { Authorization: `Bearer ${masterKey()}` };
|
||||
try {
|
||||
const res = await page.request.get("/v1/mcp/server", { headers });
|
||||
if (!res.ok()) return;
|
||||
const servers = (await res.json()) as { server_id: string; server_name?: string }[];
|
||||
for (const server of servers.filter((candidate) => candidate.server_name === serverName)) {
|
||||
await page.request.delete(`/v1/mcp/server/${server.server_id}`, { headers });
|
||||
}
|
||||
} catch {
|
||||
// best effort, see above
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens a server from the grid and switches to its MCP Tools tab. */
|
||||
export async function openMcpToolsTab(page: PwPage, serverName: string): Promise<void> {
|
||||
await page.getByTestId("mcp-servers-grid").getByText(serverName).first().click();
|
||||
await expect(page.getByRole("button", { name: /Back to All Servers/i })).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByRole("tab", { name: "MCP Tools" }).click();
|
||||
}
|
||||
46
tests/e2e/ui/helpers/playground.ts
Normal file
46
tests/e2e/ui/helpers/playground.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { expect, type Locator, type Page as PlaywrightPage } from "@playwright/test";
|
||||
import { navigateToPage, dismissFeedbackPopup } from "./navigation";
|
||||
import { Page } from "../fixtures/pages";
|
||||
|
||||
/** Controls for the Test Key / Playground page, shared with the router-fallback specs. */
|
||||
|
||||
/**
|
||||
* The configuration panel is rendered twice, docked and overlay, with one visible at a time.
|
||||
* Every control is narrowed to the visible copy or it trips strict mode against its hidden twin.
|
||||
*/
|
||||
export const onlyVisible = (locator: Locator): Locator => locator.filter({ visible: true }).first();
|
||||
|
||||
/** The model dropdown, addressed by the placeholder it shows before selection. */
|
||||
export const modelSelect = (page: PlaywrightPage): Locator =>
|
||||
onlyVisible(page.locator('.ant-select:has(.ant-select-selection-placeholder:text-is("Select a Model"))'));
|
||||
|
||||
/** Send button is icon-only (an up-arrow), so there is no accessible name. */
|
||||
export const sendButton = (page: PlaywrightPage): Locator => onlyVisible(page.locator("button:has(.anticon-arrow-up)"));
|
||||
|
||||
/** The Virtual Key Source dropdown, addressed by its currently selected label. */
|
||||
export const keySourceSelect = (page: PlaywrightPage, current: string): Locator =>
|
||||
onlyVisible(page.locator(`.ant-select:has(.ant-select-selection-item[title="${current}"])`));
|
||||
|
||||
export async function openPlayground(page: PlaywrightPage): Promise<void> {
|
||||
await navigateToPage(page, Page.LlmPlayground);
|
||||
await dismissFeedbackPopup(page);
|
||||
await expect(onlyVisible(page.getByText("Virtual Key Source"))).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function selectModel(page: PlaywrightPage, model: string): Promise<void> {
|
||||
const select = modelSelect(page);
|
||||
await select.click();
|
||||
// Virtualized: options outside the rendered window are absent from the DOM, so search first.
|
||||
await select.locator("input.ant-select-selection-search-input").fill(model);
|
||||
// antd portals its dropdown to the body; options carry the value as `title`.
|
||||
await onlyVisible(page.locator(`.ant-select-item-option[title="${model}"]`)).click({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
export async function sendMessage(page: PlaywrightPage, message: string): Promise<void> {
|
||||
const input = onlyVisible(page.getByPlaceholder("Type your message", { exact: false }));
|
||||
await expect(input).toBeVisible({ timeout: 15_000 });
|
||||
await input.fill(message);
|
||||
await sendButton(page).click();
|
||||
}
|
||||
28
tests/e2e/ui/helpers/roundTrip.ts
Normal file
28
tests/e2e/ui/helpers/roundTrip.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { expect, Page } from "@playwright/test";
|
||||
import { masterKey } from "./traffic";
|
||||
|
||||
/**
|
||||
* Runs `action` and returns the parsed body of the first matching request.
|
||||
*
|
||||
* `action` is a callback so the listener is armed before the click; awaiting the
|
||||
* click first lets the request go by, and the test then hangs until timeout.
|
||||
*/
|
||||
export async function captureRequestBody(
|
||||
page: Page,
|
||||
match: { method: string; urlIncludes: string },
|
||||
action: () => Promise<void>,
|
||||
): Promise<Record<string, any>> {
|
||||
const pending = page.waitForRequest((req) => req.method() === match.method && req.url().includes(match.urlIncludes));
|
||||
await action();
|
||||
const request = await pending;
|
||||
return JSON.parse(request.postData() ?? "{}") as Record<string, any>;
|
||||
}
|
||||
|
||||
/** Reads an endpoint as the master key, so a failure is bad data and not an expired UI token. */
|
||||
export async function readBack<T = any>(page: Page, endpoint: string): Promise<T> {
|
||||
const res = await page.request.get(endpoint, {
|
||||
headers: { Authorization: `Bearer ${masterKey()}` },
|
||||
});
|
||||
expect(res.ok(), `GET ${endpoint}`).toBe(true);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
125
tests/e2e/ui/helpers/traffic.ts
Normal file
125
tests/e2e/ui/helpers/traffic.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { APIRequestContext, expect } from "@playwright/test";
|
||||
|
||||
/** Model names served by fixtures/config.yml, both backed by the mock LLM server. */
|
||||
export const CHAT_MODEL_A = "fake-openai-gpt-4";
|
||||
export const CHAT_MODEL_B = "fake-anthropic-claude";
|
||||
|
||||
/** The only completion text fixtures/mock_llm_server/server.py ever returns. */
|
||||
export const MOCK_RESPONSE_TEXT = "This is a mock response.";
|
||||
|
||||
export const masterKey = (): string => process.env.LITELLM_MASTER_KEY || "sk-1234";
|
||||
|
||||
const rootPath = (): string => process.env.SERVER_ROOT_PATH ?? "";
|
||||
|
||||
interface ChatOptions {
|
||||
model: string;
|
||||
prompt: string;
|
||||
apiKey?: string;
|
||||
/** Sent as `user`, which lands in the spend log's end_user column. */
|
||||
endUser?: string;
|
||||
}
|
||||
|
||||
/** POST /v1/chat/completions and return the completion id (the Logs Request ID). */
|
||||
export async function sendChatCompletion(request: APIRequestContext, opts: ChatOptions): Promise<string> {
|
||||
const res = await request.post(`${rootPath()}/v1/chat/completions`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${opts.apiKey ?? masterKey()}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
data: {
|
||||
model: opts.model,
|
||||
messages: [{ role: "user", content: opts.prompt }],
|
||||
...(opts.endUser ? { user: opts.endUser } : {}),
|
||||
},
|
||||
});
|
||||
expect(res.ok(), `chat completion for ${opts.model} failed (${res.status()}): ${await res.text()}`).toBe(true);
|
||||
const body = await res.json();
|
||||
expect(body.choices?.[0]?.message?.content).toContain(MOCK_RESPONSE_TEXT);
|
||||
return body.id as string;
|
||||
}
|
||||
|
||||
/** `key` is the sk- value to authenticate with; `token` is its hash, which spend aggregates are keyed by. */
|
||||
export async function createVirtualKey(
|
||||
request: APIRequestContext,
|
||||
data: Record<string, unknown> = {},
|
||||
): Promise<{ key: string; token: string; alias?: string }> {
|
||||
const res = await request.post(`${rootPath()}/key/generate`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${masterKey()}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
data,
|
||||
});
|
||||
expect(res.ok(), `key generate failed (${res.status()}): ${await res.text()}`).toBe(true);
|
||||
const body = await res.json();
|
||||
return {
|
||||
key: body.key as string,
|
||||
token: (body.token ?? body.token_id) as string,
|
||||
alias: body.key_alias as string | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Spend logs are flushed on a timer, so an assertion straight after a completion races the writer. */
|
||||
export async function waitForSpendLog(
|
||||
request: APIRequestContext,
|
||||
requestId: string,
|
||||
timeoutMs = 60_000,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastStatus = 0;
|
||||
while (Date.now() < deadline) {
|
||||
const res = await request.get(`${rootPath()}/spend/logs?request_id=${encodeURIComponent(requestId)}`, {
|
||||
headers: { Authorization: `Bearer ${masterKey()}` },
|
||||
});
|
||||
lastStatus = res.status();
|
||||
if (res.ok()) {
|
||||
const body = await res.json();
|
||||
const rows = Array.isArray(body) ? body : (body?.data ?? []);
|
||||
if (rows.length > 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 2_000));
|
||||
}
|
||||
throw new Error(`spend log for request ${requestId} never appeared (last /spend/logs status ${lastStatus})`);
|
||||
}
|
||||
|
||||
const isoDay = (d: Date): string => d.toISOString().slice(0, 10);
|
||||
|
||||
/**
|
||||
* The Usage page reads /user/daily/activity, a rollup written by a background job, and fetches it once
|
||||
* on mount. Navigating before the rollup lands leaves a stale render that never refreshes.
|
||||
*/
|
||||
export async function waitForKeyInDailyActivity(
|
||||
request: APIRequestContext,
|
||||
keyToken: string,
|
||||
timeoutMs = 120_000,
|
||||
): Promise<void> {
|
||||
const now = new Date();
|
||||
const start = new Date(now);
|
||||
start.setDate(start.getDate() - 7);
|
||||
const query = `start_date=${isoDay(start)}&end_date=${isoDay(now)}`;
|
||||
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastStatus = 0;
|
||||
while (Date.now() < deadline) {
|
||||
const res = await request.get(`${rootPath()}/user/daily/activity?${query}`, {
|
||||
headers: { Authorization: `Bearer ${masterKey()}` },
|
||||
});
|
||||
lastStatus = res.status();
|
||||
if (res.ok()) {
|
||||
const body = await res.json();
|
||||
const seen = (body?.results ?? []).some(
|
||||
(day: { breakdown?: { api_keys?: Record<string, unknown> } }) => keyToken in (day.breakdown?.api_keys ?? {}),
|
||||
);
|
||||
if (seen) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 3_000));
|
||||
}
|
||||
throw new Error(
|
||||
`key ${keyToken} never appeared in /user/daily/activity (last status ${lastStatus}); ` +
|
||||
"the daily spend rollup may not be running",
|
||||
);
|
||||
}
|
||||
|
|
@ -12,6 +12,10 @@ set -euo pipefail
|
|||
# ./run_e2e.sh --repeat-each=5 # Run each test 5 times
|
||||
# ./run_e2e.sh --headed # Run with browser visible
|
||||
#
|
||||
# Ports default to 4000 / 5432 / 8090 and can be moved when another checkout
|
||||
# already holds them:
|
||||
# PROXY_PORT=4100 POSTGRES_PORT=5532 MOCK_LLM_PORT=8190 ./run_e2e.sh
|
||||
#
|
||||
# In CI (CI=true), expects:
|
||||
# - PostgreSQL already running on 127.0.0.1:5432
|
||||
# - DATABASE_URL already set
|
||||
|
|
@ -28,12 +32,50 @@ MOCK_PID=""
|
|||
PROXY_PID=""
|
||||
PROXY_LOG=""
|
||||
|
||||
# Ports, overridable so two checkouts can run this harness at the same time --
|
||||
# otherwise a second run aborts on "port 4000 is in use" and the only way out is
|
||||
# to stop someone else's stack. Defaults are the historical values, so an unset
|
||||
# environment behaves exactly as before (CI, the CircleCI job and the docs all
|
||||
# assume 4000/5432/8090).
|
||||
PROXY_PORT="${PROXY_PORT:-4000}"
|
||||
POSTGRES_PORT="${POSTGRES_PORT:-5432}"
|
||||
MOCK_LLM_PORT="${MOCK_LLM_PORT:-8090}"
|
||||
export MOCK_LLM_PORT
|
||||
|
||||
# --- Ensure common tool paths are available (local dev only) ---
|
||||
if [ "$IS_CI" = "false" ]; then
|
||||
for p in /usr/local/bin /opt/homebrew/bin "$HOME/.local/bin" /opt/homebrew/opt/postgresql@14/bin /opt/homebrew/opt/libpq/bin; do
|
||||
[ -d "$p" ] && export PATH="$p:$PATH"
|
||||
done
|
||||
[ -s "$HOME/.nvm/nvm.sh" ] && source "$HOME/.nvm/nvm.sh"
|
||||
# Sourcing nvm only makes `nvm` available -- it leaves you on whatever the
|
||||
# default alias points at, which is frequently an older Node than the
|
||||
# dashboard's engines allow. `npm install` then fails EBADENGINE, npm exits
|
||||
# non-zero, and because the install below is `--silent ... || true` the error
|
||||
# is swallowed and the run dies later with the far less obvious
|
||||
# "sh: next: command not found".
|
||||
#
|
||||
# So select a Node that satisfies ui/litellm-dashboard's engines.node, and if
|
||||
# none is available say so here rather than 200 lines downstream.
|
||||
if [ -s "$HOME/.nvm/nvm.sh" ]; then
|
||||
# shellcheck disable=SC1091
|
||||
source "$HOME/.nvm/nvm.sh"
|
||||
required_major="$(sed -nE 's/.*"node"[[:space:]]*:[[:space:]]*">=?([0-9]+).*/\1/p' \
|
||||
"$DASHBOARD_DIR/package.json" 2>/dev/null | head -1)"
|
||||
if [ -n "$required_major" ]; then
|
||||
current_major="$(node --version 2>/dev/null | sed -E 's/^v([0-9]+).*/\1/')"
|
||||
if [ -z "$current_major" ] || [ "$current_major" -lt "$required_major" ]; then
|
||||
echo "Node $(node --version 2>/dev/null || echo 'not found') is below the dashboard's required v${required_major}; selecting a newer one via nvm"
|
||||
nvm use "$required_major" >/dev/null 2>&1 || nvm use --lts >/dev/null 2>&1 || true
|
||||
current_major="$(node --version 2>/dev/null | sed -E 's/^v([0-9]+).*/\1/')"
|
||||
if [ -z "$current_major" ] || [ "$current_major" -lt "$required_major" ]; then
|
||||
echo "Error: ui/litellm-dashboard requires Node >= v${required_major}, and no such version is installed."
|
||||
echo " Install one with: nvm install ${required_major}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo "Using Node $(node --version) / npm $(npm --version)"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Cleanup on exit ---
|
||||
|
|
@ -47,7 +89,11 @@ cleanup() {
|
|||
fi
|
||||
echo "Done."
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
on_signal() {
|
||||
exit 130
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap on_signal INT TERM
|
||||
|
||||
# --- Pre-flight checks ---
|
||||
for cmd in python3 npx uv; do
|
||||
|
|
@ -59,9 +105,14 @@ if [ "$IS_CI" = "false" ]; then
|
|||
for cmd in docker psql; do
|
||||
command -v "$cmd" >/dev/null 2>&1 || { echo "Error: $cmd not found."; exit 1; }
|
||||
done
|
||||
for port in 4000 5432 8090; do
|
||||
if lsof -ti ":$port" >/dev/null 2>&1; then
|
||||
echo "Error: port $port is in use"
|
||||
# Only a LISTENER conflicts with us. Without -sTCP:LISTEN this also matches
|
||||
# ESTABLISHED sockets, so an unrelated *outbound* connection from this machine
|
||||
# to someone else's :5432 (a psql session, a running app, a Prisma engine
|
||||
# talking to a remote database) aborts the run with "port 5432 is in use"
|
||||
# while nothing is actually bound locally.
|
||||
for port in "$PROXY_PORT" "$POSTGRES_PORT" "$MOCK_LLM_PORT"; do
|
||||
if lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1; then
|
||||
echo "Error: port $port is in use (override with PROXY_PORT / POSTGRES_PORT / MOCK_LLM_PORT)"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
|
@ -69,12 +120,12 @@ if [ "$IS_CI" = "false" ]; then
|
|||
export POSTGRES_USER="e2euser"
|
||||
export POSTGRES_PASSWORD="$(openssl rand -hex 32)"
|
||||
export POSTGRES_DB="litellm_e2e"
|
||||
export DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@127.0.0.1:5432/${POSTGRES_DB}"
|
||||
export DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@127.0.0.1:${POSTGRES_PORT}/${POSTGRES_DB}"
|
||||
|
||||
echo "=== Starting PostgreSQL ==="
|
||||
docker run -d --rm --name "$CONTAINER_NAME" \
|
||||
-e POSTGRES_USER -e POSTGRES_PASSWORD -e POSTGRES_DB \
|
||||
-p 127.0.0.1:5432:5432 \
|
||||
-p "127.0.0.1:${POSTGRES_PORT}:5432" \
|
||||
postgres:16
|
||||
|
||||
echo "Waiting for PostgreSQL..."
|
||||
|
|
@ -91,8 +142,13 @@ fi
|
|||
|
||||
# --- Credentials ---
|
||||
export LITELLM_MASTER_KEY="sk-1234"
|
||||
export MOCK_LLM_URL="http://127.0.0.1:8090/v1"
|
||||
export MOCK_LLM_URL="http://127.0.0.1:${MOCK_LLM_PORT}/v1"
|
||||
export DISABLE_SCHEMA_UPDATE="true"
|
||||
# The suite resolves its target from E2E_UI_BASE_URL (constants.ts), which
|
||||
# otherwise defaults to :4000 -- so without this a relocated stack would be
|
||||
# built and booted correctly and then tested against whatever happens to be
|
||||
# listening on the default port.
|
||||
export E2E_UI_BASE_URL="${E2E_UI_BASE_URL:-http://127.0.0.1:${PROXY_PORT}}"
|
||||
# Ensure the proxy serves UI at /ui (not behind a subpath)
|
||||
export SERVER_ROOT_PATH=""
|
||||
# Boot with an external logout URL so proxyLogoutUrl.spec.ts can assert the
|
||||
|
|
@ -108,7 +164,11 @@ export LITELLM_LICENSE="${LITELLM_LICENSE:-}"
|
|||
# --- Rebuild UI from source ---
|
||||
echo "=== Building UI from source ==="
|
||||
cd "$DASHBOARD_DIR"
|
||||
npm install --silent 2>/dev/null || true
|
||||
# NOT silenced, and NOT `|| true`. Swallowing this is what turns a one-line
|
||||
# EBADENGINE ("dashboard requires node >=24, you have v20") into the
|
||||
# considerably less helpful "sh: next: command not found" from the build below,
|
||||
# because the deps that provide `next` were never installed.
|
||||
npm install
|
||||
npm run build
|
||||
# Copy the fresh build to the proxy's static UI directory
|
||||
cp -r "$DASHBOARD_DIR/out/" "$REPO_ROOT/litellm/proxy/_experimental/out/"
|
||||
|
|
@ -139,7 +199,7 @@ uv run --no-sync python "$SCRIPT_DIR/fixtures/mock_llm_server/server.py" &
|
|||
MOCK_PID=$!
|
||||
|
||||
for i in $(seq 1 15); do
|
||||
if curl -sf http://127.0.0.1:8090/health >/dev/null 2>&1; then break; fi
|
||||
if curl -sf http://127.0.0.1:${MOCK_LLM_PORT}/health >/dev/null 2>&1; then break; fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
|
|
@ -149,7 +209,7 @@ cd "$REPO_ROOT"
|
|||
PROXY_LOG="${TMPDIR:-/tmp}/litellm-e2e-proxy-$$.log"
|
||||
uv run --no-sync python -m litellm.proxy.proxy_cli \
|
||||
--config "$SCRIPT_DIR/fixtures/config.yml" \
|
||||
--port 4000 >"$PROXY_LOG" 2>&1 &
|
||||
--port "$PROXY_PORT" >"$PROXY_LOG" 2>&1 &
|
||||
PROXY_PID=$!
|
||||
|
||||
echo "Waiting for proxy (logs: $PROXY_LOG)..."
|
||||
|
|
@ -160,7 +220,7 @@ for i in $(seq 1 180); do
|
|||
tail -n 100 "$PROXY_LOG"
|
||||
exit 1
|
||||
fi
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:4000/health -H "Authorization: Bearer $LITELLM_MASTER_KEY" 2>/dev/null || true)
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:${PROXY_PORT}/health -H "Authorization: Bearer $LITELLM_MASTER_KEY" 2>/dev/null || true)
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
PROXY_READY=1
|
||||
break
|
||||
|
|
@ -188,9 +248,38 @@ PGPASSWORD="$DB_PASS" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAM
|
|||
# --- Playwright ---
|
||||
echo "=== Installing Playwright dependencies ==="
|
||||
cd "$SCRIPT_DIR"
|
||||
npm install --silent 2>/dev/null || true
|
||||
# Same reasoning as the dashboard install above: a failure here means the suite
|
||||
# has no @playwright/test, and the run should say that rather than fail later.
|
||||
npm install
|
||||
npx playwright install chromium --with-deps 2>/dev/null || npx playwright install chromium
|
||||
|
||||
# Authoring a new spec means running it over and over against a stack that is
|
||||
# already up -- rebuilding the UI and re-seeding for every iteration costs
|
||||
# minutes each time. E2E_KEEP_ALIVE brings the stack up, then blocks, so you can
|
||||
# run `npx playwright test <spec>` yourself from another shell against it.
|
||||
# Ctrl-C here tears everything down through the usual trap.
|
||||
if [ "${E2E_KEEP_ALIVE:-0}" = "1" ]; then
|
||||
cat <<EOF
|
||||
|
||||
=== Stack is up (E2E_KEEP_ALIVE=1); not running tests ===
|
||||
UI / API : http://127.0.0.1:${PROXY_PORT}
|
||||
Mock LLM : http://127.0.0.1:${MOCK_LLM_PORT}/v1
|
||||
Database : $DATABASE_URL
|
||||
Proxy log: $PROXY_LOG
|
||||
|
||||
Run specs against it from $SCRIPT_DIR:
|
||||
npx playwright test --config playwright.config.ts <spec>
|
||||
|
||||
Press Ctrl-C to tear the stack down.
|
||||
EOF
|
||||
while kill -0 "$PROXY_PID" 2>/dev/null; do
|
||||
sleep 5
|
||||
done
|
||||
echo "Error: proxy process exited unexpectedly. Proxy output:"
|
||||
tail -n 100 "$PROXY_LOG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Running Playwright tests ==="
|
||||
npx playwright test --config playwright.config.ts "$@"
|
||||
EXIT_CODE=$?
|
||||
|
|
|
|||
221
tests/e2e/ui/tests/logs/logs.spec.ts
Normal file
221
tests/e2e/ui/tests/logs/logs.spec.ts
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import { test, expect, type Locator, type Page as PlaywrightPage } from "@playwright/test";
|
||||
import { ADMIN_STORAGE_PATH } from "../../constants";
|
||||
import { navigateToPage, dismissFeedbackPopup } from "../../helpers/navigation";
|
||||
import { Page } from "../../fixtures/pages";
|
||||
import { CHAT_MODEL_A, MOCK_RESPONSE_TEXT, sendChatCompletion, waitForSpendLog } from "../../helpers/traffic";
|
||||
|
||||
/**
|
||||
* Anchored to traffic this spec generates itself, with a unique prompt and end user per run, so it
|
||||
* neither depends on seeded spend rows nor collides with other specs under parallelism.
|
||||
*/
|
||||
|
||||
const uniqueSuffix = (): string => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
/**
|
||||
* Walking up from the label is the only stable handle: the header carries no role, test id or class,
|
||||
* and its copy button is icon-only with a hover-only tooltip.
|
||||
*/
|
||||
const sectionHeader = (drawer: Locator, label: "Input" | "Output"): Locator =>
|
||||
drawer.getByText(label, { exact: true }).locator("xpath=../../..");
|
||||
|
||||
/** Every tab stays mounted, so the DOM holds four tables at once; scope to the visible one. */
|
||||
const requestLogsRows = (page: PlaywrightPage): Locator =>
|
||||
page.locator("table").filter({ visible: true }).first().locator("tbody tr");
|
||||
|
||||
const visibleTestId = (page: PlaywrightPage, id: string): Locator => page.getByTestId(id).filter({ visible: true });
|
||||
|
||||
/** Open the Logs page and filter the table down to a single request id. */
|
||||
async function openLogsForRequest(page: PlaywrightPage, requestId: string): Promise<Locator> {
|
||||
await navigateToPage(page, Page.Logs);
|
||||
await dismissFeedbackPopup(page);
|
||||
|
||||
const search = visibleTestId(page, "datatable-search");
|
||||
await expect(search).toBeVisible({ timeout: 20_000 });
|
||||
await search.fill(requestId);
|
||||
|
||||
const row = requestLogsRows(page).filter({ hasText: requestId });
|
||||
await expect(row, `no logs row for request ${requestId}`).toHaveCount(1, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
test.describe("Logs page", () => {
|
||||
test.use({
|
||||
storageState: ADMIN_STORAGE_PATH,
|
||||
// The copy buttons go through navigator.clipboard, which rejects without these.
|
||||
permissions: ["clipboard-read", "clipboard-write"],
|
||||
});
|
||||
|
||||
test("a served request expands to its request and response", async ({ page, request }) => {
|
||||
const prompt = `logs-detail-prompt-${uniqueSuffix()}`;
|
||||
const requestId = await sendChatCompletion(request, {
|
||||
model: CHAT_MODEL_A,
|
||||
prompt,
|
||||
});
|
||||
await waitForSpendLog(request, requestId);
|
||||
|
||||
const row = await openLogsForRequest(page, requestId);
|
||||
|
||||
// Expand: clicking the row opens the detail drawer for that request.
|
||||
await row.click();
|
||||
const drawer = page.locator(".ant-drawer-content").first();
|
||||
await expect(drawer).toBeVisible({ timeout: 20_000 });
|
||||
await expect(drawer.getByText("Request & Response")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// The prompt we sent and the mock server's reply are both rendered.
|
||||
await expect(drawer.getByText(prompt, { exact: false })).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(drawer.getByText(MOCK_RESPONSE_TEXT, { exact: false }).first()).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
// Split out because only the copy path needs a secure context; folding it in would
|
||||
// take the drawer-rendering coverage down with it.
|
||||
test("the drawer copies the request and the response to the clipboard", async ({ page, request }) => {
|
||||
// `navigator.clipboard` is undefined outside a secure context, and handleCopy calls
|
||||
// writeText unguarded, so on plain HTTP served from a hostname the click throws and no
|
||||
// toast renders. Skipped rather than weakened so the product gap stays visible.
|
||||
await page.goto("/ui");
|
||||
const isSecure = await page.evaluate(() => window.isSecureContext);
|
||||
test.skip(!isSecure, "origin is not a secure context, so navigator.clipboard is unavailable");
|
||||
|
||||
const prompt = `logs-copy-prompt-${uniqueSuffix()}`;
|
||||
const requestId = await sendChatCompletion(request, {
|
||||
model: CHAT_MODEL_A,
|
||||
prompt,
|
||||
});
|
||||
await waitForSpendLog(request, requestId);
|
||||
|
||||
const row = await openLogsForRequest(page, requestId);
|
||||
await row.click();
|
||||
const drawer = page.locator(".ant-drawer-content").first();
|
||||
await expect(drawer).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// Copy request: the Input card's copy button puts the prompt on the clipboard.
|
||||
await sectionHeader(drawer, "Input").getByRole("button").click();
|
||||
await expect(page.getByText("Input copied")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toContain(prompt);
|
||||
|
||||
// Copy response: the Output card's copy button puts the completion on it.
|
||||
await sectionHeader(drawer, "Output").getByRole("button").click();
|
||||
await expect(page.getByText("Output copied")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toContain(MOCK_RESPONSE_TEXT);
|
||||
});
|
||||
|
||||
test("the Input card collapses and expands", async ({ page, request }) => {
|
||||
const prompt = `logs-collapse-prompt-${uniqueSuffix()}`;
|
||||
const requestId = await sendChatCompletion(request, {
|
||||
model: CHAT_MODEL_A,
|
||||
prompt,
|
||||
});
|
||||
await waitForSpendLog(request, requestId);
|
||||
|
||||
const row = await openLogsForRequest(page, requestId);
|
||||
await row.click();
|
||||
|
||||
const drawer = page.locator(".ant-drawer-content").first();
|
||||
await expect(drawer.getByText("Request & Response")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// The body collapses via `max-height: 0; overflow: hidden`, which zeroes its own bounding
|
||||
// box, so the wrapper reads as hidden while the clipped text node inside it does not.
|
||||
const header = sectionHeader(drawer, "Input");
|
||||
const body = header.locator("xpath=following-sibling::div[1]");
|
||||
await expect(header.locator(".anticon-up")).toBeVisible();
|
||||
await expect(body).toBeVisible();
|
||||
|
||||
await header.click();
|
||||
await expect(header.locator(".anticon-down")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(body).toBeHidden({ timeout: 10_000 });
|
||||
|
||||
await header.click();
|
||||
await expect(header.locator(".anticon-up")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(body).toBeVisible({ timeout: 10_000 });
|
||||
await expect(drawer.getByText(prompt, { exact: false })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("the JSON view exposes Request and Response tabs", async ({ page, request }) => {
|
||||
const prompt = `logs-json-prompt-${uniqueSuffix()}`;
|
||||
const requestId = await sendChatCompletion(request, {
|
||||
model: CHAT_MODEL_A,
|
||||
prompt,
|
||||
});
|
||||
await waitForSpendLog(request, requestId);
|
||||
|
||||
const row = await openLogsForRequest(page, requestId);
|
||||
await row.click();
|
||||
|
||||
const drawer = page.locator(".ant-drawer-content").first();
|
||||
await expect(drawer.getByText("Request & Response")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// antd Radio.Button hides the <input> under its <label>, which intercepts
|
||||
// the pointer event — click the label, not the radio.
|
||||
await drawer.locator("label.ant-radio-button-wrapper").filter({ hasText: "JSON" }).click();
|
||||
|
||||
const requestTab = drawer.getByRole("tab", { name: "Request" });
|
||||
await expect(requestTab).toBeVisible({ timeout: 10_000 });
|
||||
await requestTab.click();
|
||||
await expect(drawer.getByText(prompt, { exact: false }).first()).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await drawer.getByRole("tab", { name: "Response" }).click();
|
||||
await expect(drawer.getByText(MOCK_RESPONSE_TEXT, { exact: false }).first()).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("the End User filter narrows the table to that customer", async ({ page, request }) => {
|
||||
const endUser = `logs-end-user-${uniqueSuffix()}`;
|
||||
const minePrompt = `logs-filter-mine-${uniqueSuffix()}`;
|
||||
const otherPrompt = `logs-filter-other-${uniqueSuffix()}`;
|
||||
|
||||
const mineId = await sendChatCompletion(request, {
|
||||
model: CHAT_MODEL_A,
|
||||
prompt: minePrompt,
|
||||
endUser,
|
||||
});
|
||||
const otherId = await sendChatCompletion(request, {
|
||||
model: CHAT_MODEL_A,
|
||||
prompt: otherPrompt,
|
||||
});
|
||||
await waitForSpendLog(request, mineId);
|
||||
await waitForSpendLog(request, otherId);
|
||||
|
||||
await navigateToPage(page, Page.Logs);
|
||||
await dismissFeedbackPopup(page);
|
||||
|
||||
// Both requests are in the unfiltered table.
|
||||
await expect(requestLogsRows(page).filter({ hasText: mineId })).toHaveCount(1, { timeout: 30_000 });
|
||||
await expect(requestLogsRows(page).filter({ hasText: otherId })).toHaveCount(1, { timeout: 30_000 });
|
||||
|
||||
await visibleTestId(page, "datatable-filters-trigger").click();
|
||||
const filters = page.getByRole("dialog").filter({ hasText: "Narrow down request logs" });
|
||||
await expect(filters).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const endUserInput = filters.getByPlaceholder("Search an end user");
|
||||
await endUserInput.click();
|
||||
await endUserInput.fill(endUser);
|
||||
// The combobox popup is portaled to the body, so it is outside the filter
|
||||
// dialog's subtree — scope the option lookup to the page, not the dialog.
|
||||
await page.getByRole("option", { name: endUser, exact: true }).click({ timeout: 30_000 });
|
||||
await filters.getByRole("button", { name: "Apply Filters" }).click();
|
||||
|
||||
// Only the request tagged with this end user survives the filter.
|
||||
await expect(requestLogsRows(page).filter({ hasText: otherId })).toHaveCount(0, { timeout: 30_000 });
|
||||
await expect(requestLogsRows(page).filter({ hasText: mineId })).toHaveCount(1);
|
||||
await expect(requestLogsRows(page)).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
92
tests/e2e/ui/tests/mcp/mcpServerEdit.spec.ts
Normal file
92
tests/e2e/ui/tests/mcp/mcpServerEdit.spec.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { test, expect, type Page as PlaywrightPage } from "@playwright/test";
|
||||
import { ADMIN_STORAGE_PATH } from "../../constants";
|
||||
import { createMcpServer, deleteMcpServerByName } from "../../helpers/mcp";
|
||||
import { captureRequestBody, readBack } from "../../helpers/roundTrip";
|
||||
|
||||
/**
|
||||
* Editing and deleting an MCP server, verified against the API. The reported failures are all on
|
||||
* this side: renames that need repeating, deletes that need two attempts, each toasting success on
|
||||
* the failing attempt. The URL is unreachable on purpose; only persistence is under test here.
|
||||
*/
|
||||
const UNREACHABLE_URL = "https://e2e-fake-mcp.test.local/mcp";
|
||||
|
||||
/** GET /v1/mcp/server returns a bare array of servers (useMCPServers types it MCPServer[]). */
|
||||
async function findServerByName(page: PlaywrightPage, serverName: string): Promise<Record<string, any> | undefined> {
|
||||
const servers = await readBack<Record<string, any>[]>(page, "/v1/mcp/server");
|
||||
return servers.find((server) => server.server_name === serverName);
|
||||
}
|
||||
|
||||
test.describe("MCP Servers - edit and delete", () => {
|
||||
test.use({ storageState: ADMIN_STORAGE_PATH });
|
||||
|
||||
let serverName: string;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
serverName = await createMcpServer(page, UNREACHABLE_URL);
|
||||
});
|
||||
|
||||
// The rename test leaves an unreachable server behind, which slows the MCP page for later tests.
|
||||
test.afterEach(async ({ page }) => {
|
||||
await deleteMcpServerByName(page, serverName);
|
||||
});
|
||||
|
||||
test("Renaming a server's alias persists", async ({ page }) => {
|
||||
const before = await findServerByName(page, serverName);
|
||||
expect(before, `created server ${serverName} readable from /v1/mcp/server`).toBeTruthy();
|
||||
|
||||
await page.getByTestId("mcp-servers-grid").getByText(serverName).first().click();
|
||||
await expect(page.getByRole("button", { name: /Back to All Servers/i })).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// exact: the server view also renders a "Network Settings" tab.
|
||||
await page.getByRole("tab", { name: "Settings", exact: true }).click();
|
||||
|
||||
// A card click may land straight in edit mode, so only click the button when it rendered.
|
||||
const editSettings = page.getByRole("button", { name: "Edit Settings" });
|
||||
if (await editSettings.isVisible().catch(() => false)) {
|
||||
await editSettings.click();
|
||||
}
|
||||
|
||||
// The create modal stays mounted behind the view with its own #alias and Save.
|
||||
const settingsPanel = page.getByRole("tabpanel", { name: "Settings" });
|
||||
|
||||
const newAlias = `${serverName}_renamed`;
|
||||
const aliasInput = settingsPanel.locator('input[id="alias"]');
|
||||
await expect(aliasInput).toBeVisible({ timeout: 10_000 });
|
||||
await aliasInput.fill(newAlias);
|
||||
|
||||
const update = await captureRequestBody(page, { method: "PUT", urlIncludes: "/v1/mcp/server" }, async () => {
|
||||
await settingsPanel.getByRole("button", { name: "Save Changes" }).click();
|
||||
});
|
||||
expect(update.alias, "new alias on the wire").toBe(newAlias);
|
||||
// An unidentified target is one way a save succeeds and changes nothing.
|
||||
expect(update.server_id, "update targets the server being edited").toBe(before?.server_id);
|
||||
|
||||
// The reported symptom is a first save that returns success and does not stick.
|
||||
await expect
|
||||
.poll(async () => (await findServerByName(page, serverName))?.alias, {
|
||||
message: `alias for ${serverName} did not persist after one save`,
|
||||
timeout: 15_000,
|
||||
})
|
||||
.toBe(newAlias);
|
||||
});
|
||||
|
||||
test("Deleting a server removes it", async ({ page }) => {
|
||||
expect(await findServerByName(page, serverName), `created server ${serverName} exists`).toBeTruthy();
|
||||
|
||||
const card = page.getByTestId("mcp-servers-grid").locator("div").filter({ hasText: serverName }).first();
|
||||
await card.getByRole("button", { name: "Server actions" }).click();
|
||||
await page.getByRole("menuitem", { name: "Delete" }).click();
|
||||
|
||||
const dialog = page.getByRole("alertdialog");
|
||||
await expect(dialog.getByText("Delete MCP Server?")).toBeVisible({ timeout: 5_000 });
|
||||
await dialog.getByRole("button", { name: "Delete", exact: true }).click();
|
||||
|
||||
// One attempt has to be enough; the report is a delete that needs two.
|
||||
await expect
|
||||
.poll(async () => await findServerByName(page, serverName), {
|
||||
message: `server ${serverName} still present after one delete`,
|
||||
timeout: 15_000,
|
||||
})
|
||||
.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -2,6 +2,7 @@ import { test, expect } from "@playwright/test";
|
|||
import { ADMIN_STORAGE_PATH } from "../../constants";
|
||||
import { navigateToPage } from "../../helpers/navigation";
|
||||
import { Page } from "../../fixtures/pages";
|
||||
import { deleteMcpServerByName } from "../../helpers/mcp";
|
||||
|
||||
// Coverage scope: only the happy-path Streamable HTTP + None auth create flow.
|
||||
// See E2E_COVERAGE.md (#29 row) for the full list of uncovered MCP surfaces
|
||||
|
|
@ -11,6 +12,15 @@ import { Page } from "../../fixtures/pages";
|
|||
test.describe("MCP Servers", () => {
|
||||
test.use({ storageState: ADMIN_STORAGE_PATH });
|
||||
|
||||
let createdServerName = "";
|
||||
|
||||
// The server this test creates is unreachable, and the MCP page contacts
|
||||
// every server it lists, so leaving it behind slows down every later MCP
|
||||
// test. See deleteMcpServerByName for what that actually cost.
|
||||
test.afterEach(async ({ page }) => {
|
||||
if (createdServerName) await deleteMcpServerByName(page, createdServerName);
|
||||
});
|
||||
|
||||
test("Add a custom MCP server via the discovery → custom form", async ({ page }) => {
|
||||
await navigateToPage(page, Page.McpServers);
|
||||
|
||||
|
|
@ -25,6 +35,7 @@ test.describe("MCP Servers", () => {
|
|||
|
||||
// Name — no spaces or hyphens per validateMCPServerName
|
||||
const uniqueName = `e2e_mcp_${Date.now()}`;
|
||||
createdServerName = uniqueName;
|
||||
await formModal.locator('input[id="server_name"]').fill(uniqueName);
|
||||
|
||||
// Transport: Streamable HTTP — the only value the proxy actually accepts is "http"
|
||||
|
|
@ -48,8 +59,6 @@ test.describe("MCP Servers", () => {
|
|||
// Submit
|
||||
await formModal.getByRole("button", { name: /^Add MCP Server$/ }).click();
|
||||
|
||||
// No teardown needed — the e2e runner spins up a fresh DB per invocation.
|
||||
|
||||
// Success toast and the new card in the server grid. Scope the lookup to
|
||||
// the MCP servers grid so the form modal's `server_name` input — which
|
||||
// still holds the timestamped value during its close animation — can't
|
||||
|
|
|
|||
80
tests/e2e/ui/tests/mcp/mcpTools.spec.ts
Normal file
80
tests/e2e/ui/tests/mcp/mcpTools.spec.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { test, expect, Locator } from "@playwright/test";
|
||||
import { ADMIN_STORAGE_PATH } from "../../constants";
|
||||
import { createMcpServer, deleteMcpServerByName, openMcpToolsTab } from "../../helpers/mcp";
|
||||
|
||||
// Listing and calling MCP tools, which needs a server that really answers; the create-only spec
|
||||
// points at an unreachable URL on purpose.
|
||||
//
|
||||
// This spec makes a read-only network call to DeepWiki's public MCP server, from the proxy rather
|
||||
// than the browser. It needs no credentials, so there is no secret to leak from a public repo.
|
||||
//
|
||||
// A DeepWiki outage turns this red for something that is not a litellm regression. That is left
|
||||
// visible rather than auto-skipped: skipping on connection trouble also skips when the proxy's own
|
||||
// MCP client breaks, which is the regression this exists to catch. E2E_SKIP_EXTERNAL_MCP=1 opts out.
|
||||
const MCP_SERVER_URL = "https://mcp.deepwiki.com/mcp";
|
||||
const TOOL_NAME = "read_wiki_structure";
|
||||
const TOOL_ARG_REPO = "BerriAI/litellm";
|
||||
|
||||
// Match the h4 heading, not page text: a tool whose description names another tool trips strict mode.
|
||||
const toolCard = (list: Locator, name: string): Locator =>
|
||||
list.locator("h4.font-mono").filter({ hasText: new RegExp(`^${name}$`) });
|
||||
|
||||
test.describe("MCP Tools", () => {
|
||||
test.use({ storageState: ADMIN_STORAGE_PATH });
|
||||
test.skip(!!process.env.E2E_SKIP_EXTERNAL_MCP, "E2E_SKIP_EXTERNAL_MCP is set");
|
||||
|
||||
let serverName: string;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
serverName = await createMcpServer(page, MCP_SERVER_URL);
|
||||
await openMcpToolsTab(page, serverName);
|
||||
});
|
||||
|
||||
// The MCP page contacts every server it lists, so leaks slow later tests run by run.
|
||||
test.afterEach(async ({ page }) => {
|
||||
await deleteMcpServerByName(page, serverName);
|
||||
});
|
||||
|
||||
test("MCP Tools tab lists the tools the upstream server advertises", async ({ page }) => {
|
||||
// Fetched through the proxy on mount, so allow for a cold upstream connection.
|
||||
const toolList = page.locator(".mcp-tools-scrollable");
|
||||
await expect(toolList).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Non-empty would still pass if the proxy returned some other server's tools.
|
||||
await expect(toolCard(toolList, TOOL_NAME)).toBeVisible();
|
||||
await expect(toolCard(toolList, "ask_question")).toBeVisible();
|
||||
await expect(toolCard(toolList, "read_wiki_contents")).toBeVisible();
|
||||
|
||||
// No other tool's name or description contains this string, so exactly one card survives.
|
||||
await page.getByPlaceholder("Search tools...").fill(TOOL_NAME);
|
||||
await expect(toolList.locator("h4.font-mono")).toHaveCount(1);
|
||||
await expect(toolCard(toolList, TOOL_NAME)).toBeVisible();
|
||||
});
|
||||
|
||||
test("Calling a tool from the Test Tool panel returns the upstream result", async ({ page }) => {
|
||||
const toolList = page.locator(".mcp-tools-scrollable");
|
||||
await expect(toolList).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await toolCard(toolList, TOOL_NAME).click();
|
||||
|
||||
// Selecting a tool swaps the right-hand pane in for the empty state.
|
||||
await expect(page.getByText("Test Tool:", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText("Ready to Call Tool")).toBeVisible();
|
||||
|
||||
// The form is generated from the tool's inputSchema, so `repoName` proves the schema
|
||||
// round-tripped through the proxy instead of the panel falling back to a generic field.
|
||||
const repoInput = page.locator('input[id="repoName"]');
|
||||
await expect(repoInput).toBeVisible();
|
||||
await repoInput.fill(TOOL_ARG_REPO);
|
||||
|
||||
await page.getByRole("button", { name: "Call Tool", exact: true }).click();
|
||||
|
||||
await expect(page.getByText("Tool executed successfully")).toBeVisible({ timeout: 60_000 });
|
||||
// read_wiki_structure answers with the repo's outline, so the pane must name the repo.
|
||||
await expect(page.getByText(TOOL_ARG_REPO).first()).toBeVisible();
|
||||
|
||||
// A second call is offered rather than the button resetting to its
|
||||
// first-run label.
|
||||
await expect(page.getByRole("button", { name: "Call Again", exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,8 +1,25 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { test, expect, type Page as PlaywrightPage } from "@playwright/test";
|
||||
import { ADMIN_STORAGE_PATH, E2E_TEAM_CRUD_ID } from "../../constants";
|
||||
import { Role, users } from "../../fixtures/users";
|
||||
import { navigateToPage } from "../../helpers/navigation";
|
||||
import { Page } from "../../fixtures/pages";
|
||||
import { captureRequestBody, readBack } from "../../helpers/roundTrip";
|
||||
import { sendChatCompletion } from "../../helpers/traffic";
|
||||
|
||||
/** The mock LLM as the proxy reaches it: same host locally, a sidecar in the deployed stack. */
|
||||
const MOCK_LLM_BASE = `http://127.0.0.1:${process.env.MOCK_LLM_PORT ?? "8090"}/v1`;
|
||||
|
||||
/** GET /model/info?litellm_model_id= returns {data: [row]}, the deployment as stored. */
|
||||
async function readDeployment(page: PlaywrightPage, modelId: string): Promise<Record<string, any> | undefined> {
|
||||
const body = await readBack<{ data: Record<string, any>[] }>(page, `/model/info?litellm_model_id=${modelId}`);
|
||||
return body.data[0];
|
||||
}
|
||||
|
||||
/** GET /v2/model/info lists every deployment; created models are found by model_name. */
|
||||
async function findDeploymentByName(page: PlaywrightPage, modelName: string): Promise<Record<string, any> | undefined> {
|
||||
const body = await readBack<{ data: Record<string, any>[] }>(page, "/v2/model/info");
|
||||
return body.data.find((row) => row.model_name === modelName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to select a provider from the Add Model form dropdown.
|
||||
|
|
@ -18,6 +35,28 @@ async function selectProvider(page: any, providerName: string) {
|
|||
test.describe("Add Model", () => {
|
||||
test.use({ storageState: ADMIN_STORAGE_PATH });
|
||||
|
||||
// Set by the UI-add test below. The deployed stack keeps its database, so a leak
|
||||
// pollutes every later Models table and readback.
|
||||
let uiAddedModelName = "";
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
if (!uiAddedModelName) return;
|
||||
const name = uiAddedModelName;
|
||||
uiAddedModelName = "";
|
||||
try {
|
||||
const stored = await findDeploymentByName(page, name);
|
||||
const id = stored?.model_info?.id;
|
||||
if (id) {
|
||||
await page.request.post("/model/delete", {
|
||||
headers: { Authorization: `Bearer ${users[Role.ProxyAdmin].password}` },
|
||||
data: { id },
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Teardown must never turn a passing test red or mask a real failure.
|
||||
}
|
||||
});
|
||||
|
||||
test("Able to see all models for a specific provider in the model dropdown", async ({ page }) => {
|
||||
await navigateToPage(page, Page.Models);
|
||||
await page.getByRole("tab", { name: "Add Model" }).click();
|
||||
|
|
@ -37,15 +76,14 @@ test.describe("Add Model", () => {
|
|||
const modelName = `e2e-team-model-${Date.now()}`;
|
||||
|
||||
// Create a team-scoped model via API so the test has something to edit.
|
||||
// The e2e runner spins up a fresh postgres container per invocation, so
|
||||
// there's no cleanup step — the DB is thrown away at the end of the run.
|
||||
const createResponse = await page.request.post("/model/new", {
|
||||
headers: { Authorization: `Bearer ${masterKey}` },
|
||||
data: {
|
||||
model_name: modelName,
|
||||
litellm_params: {
|
||||
model: "openai/fake-gpt-4",
|
||||
api_base: "http://127.0.0.1:8090/v1",
|
||||
// Never called, but the port moves when two checkouts run side by side.
|
||||
api_base: `http://127.0.0.1:${process.env.MOCK_LLM_PORT ?? "8090"}/v1`,
|
||||
api_key: "fake-key",
|
||||
tpm: 100,
|
||||
rpm: 200,
|
||||
|
|
@ -55,7 +93,10 @@ test.describe("Add Model", () => {
|
|||
},
|
||||
},
|
||||
});
|
||||
expect(createResponse.ok()).toBe(true);
|
||||
// A bare toBe(true) sends you looking at the UI for a setup call that never landed.
|
||||
expect(createResponse.ok(), `/model/new failed: ${createResponse.status()} ${await createResponse.text()}`).toBe(
|
||||
true,
|
||||
);
|
||||
const createdModelId = (await createResponse.json()).model_info?.id;
|
||||
expect(createdModelId, "model id from /model/new").toBeTruthy();
|
||||
|
||||
|
|
@ -76,11 +117,93 @@ test.describe("Add Model", () => {
|
|||
await page.getByPlaceholder("Enter TPM").fill("999");
|
||||
await page.getByPlaceholder("Enter RPM").fill("888");
|
||||
|
||||
await page.getByRole("button", { name: "Save Changes" }).click();
|
||||
// handleModelUpdate PATCHes the whole litellm_params blob, so pin what goes on the wire.
|
||||
const patch = await captureRequestBody(
|
||||
page,
|
||||
{ method: "PATCH", urlIncludes: `/model/${createdModelId}/update` },
|
||||
async () => {
|
||||
await page.getByRole("button", { name: "Save Changes" }).click();
|
||||
},
|
||||
);
|
||||
expect(Number(patch.litellm_params?.tpm), "new TPM on the wire").toBe(999);
|
||||
expect(Number(patch.litellm_params?.rpm), "new RPM on the wire").toBe(888);
|
||||
|
||||
// Verify the new values render back in view mode
|
||||
await expect(page.getByText("999", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText("888", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// View mode re-renders from the form's own state, so read the deployment back.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const stored = await readDeployment(page, createdModelId);
|
||||
return [Number(stored?.litellm_params?.tpm), Number(stored?.litellm_params?.rpm)];
|
||||
},
|
||||
{ message: "TPM/RPM did not persist on the deployment", timeout: 15_000 },
|
||||
)
|
||||
.toEqual([999, 888]);
|
||||
|
||||
// Pin the fields this edit had no business changing; dropping them looks identical in the UI.
|
||||
const after = await readDeployment(page, createdModelId);
|
||||
expect(after?.litellm_params?.model, "upstream model untouched by a limits edit").toBe("openai/fake-gpt-4");
|
||||
expect(after?.model_info?.team_id, "team ownership untouched by a limits edit").toBe(E2E_TEAM_CRUD_ID);
|
||||
});
|
||||
|
||||
test("Add a model through the UI, pass Test Connect, and serve traffic with it", async ({ page, request }) => {
|
||||
// Every other test here stops at "the row appears", which an unroutable model also does.
|
||||
// OpenAI-Compatible exposes API Base, so this points at the mock LLM and needs no credential.
|
||||
await navigateToPage(page, Page.Models);
|
||||
await page.getByRole("tab", { name: "Add Model" }).click();
|
||||
|
||||
// Labels come from /public/providers/fields, not the frontend Providers enum, and the two differ.
|
||||
await selectProvider(page, "OpenAI-Compatible Endpoints");
|
||||
|
||||
const publicName = `e2e-ui-added-${Date.now()}`;
|
||||
uiAddedModelName = publicName;
|
||||
|
||||
// The model picker's "custom" entry reveals the free-text name field.
|
||||
await page.locator(".ant-select-selection-overflow").first().click();
|
||||
await page.locator(".ant-select-dropdown:visible").getByText("Custom Model Name (Enter below)").click();
|
||||
await page.keyboard.press("Escape");
|
||||
await page.getByPlaceholder("Enter custom model name").fill(publicName);
|
||||
|
||||
// By Form.Item id, not placeholder: placeholders change with the provider selection.
|
||||
await page.locator("#api_base").fill(MOCK_LLM_BASE);
|
||||
await page.locator("#api_key").fill("fake-key");
|
||||
|
||||
await page.getByRole("button", { name: "Test Connect" }).click();
|
||||
await expect(page.getByText("Connection Test Results")).toBeVisible({ timeout: 10_000 });
|
||||
// Assert the success panel is present; "no failure yet" is also true mid-flight.
|
||||
await expect(page.getByTestId("connection-success-msg")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// The modal swallows the Add click. Scope to the footer: the dismiss X is also named "Close".
|
||||
const resultsModal = page.locator(".ant-modal:visible").filter({ hasText: "Connection Test Results" });
|
||||
await resultsModal.locator(".ant-modal-footer").getByRole("button", { name: "Close" }).click();
|
||||
await expect(resultsModal).toBeHidden({ timeout: 5_000 });
|
||||
|
||||
const created = await captureRequestBody(page, { method: "POST", urlIncludes: "/model/new" }, async () => {
|
||||
await page.getByRole("button", { name: "Add Model" }).last().click();
|
||||
});
|
||||
expect(created.model_name, "the model is created under the name that was typed").toBe(publicName);
|
||||
expect(created.litellm_params?.api_base, "the api base survives the form").toBe(MOCK_LLM_BASE);
|
||||
|
||||
await expect(page.getByText("created successfully")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Serving one request is the only assertion that rules out a dropped api_base or an
|
||||
// unregistered name. Polled because /model/new returns before the router reloads.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
try {
|
||||
await sendChatCompletion(request, { model: publicName, prompt: `hello from ${publicName}` });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ message: `model ${publicName} was added through the UI but never served a request`, timeout: 30_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test("Test connection with bad credentials shows failure", async ({ page }) => {
|
||||
|
|
@ -126,7 +249,13 @@ test.describe("Add Model", () => {
|
|||
await apiKeyInput.fill("sk-any-key-for-add-test");
|
||||
|
||||
// Click Add Model button by its text
|
||||
await page.getByRole("button", { name: "Add Model" }).last().click();
|
||||
const created = await captureRequestBody(page, { method: "POST", urlIncludes: "/model/new" }, async () => {
|
||||
await page.getByRole("button", { name: "Add Model" }).last().click();
|
||||
});
|
||||
// The form sends custom_llm_provider separately from the name, so both halves have to arrive.
|
||||
expect(created.model_name, "the selected model is what goes on the wire").toBe("claude-haiku-4-5");
|
||||
expect(created.litellm_params?.model, "the model name goes on the wire").toBe("claude-haiku-4-5");
|
||||
expect(created.litellm_params?.custom_llm_provider, "the picked provider goes on the wire").toBe("anthropic");
|
||||
|
||||
// Wait for success notification
|
||||
await expect(page.getByText("created successfully")).toBeVisible({ timeout: 15_000 });
|
||||
|
|
@ -148,19 +277,20 @@ test.describe("Add Model", () => {
|
|||
// Verify the model name appears in the table body
|
||||
const tableBody = page.locator("table tbody");
|
||||
await expect(tableBody.getByText("claude-haiku-4-5").first()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// A row proves the name is there, not what the deployment routes to.
|
||||
const stored = await findDeploymentByName(page, "claude-haiku-4-5");
|
||||
expect(stored, "created model readable from /v2/model/info").toBeTruthy();
|
||||
expect(stored?.litellm_params?.model, "stored deployment keeps the model name").toBe("claude-haiku-4-5");
|
||||
expect(stored?.litellm_params?.custom_llm_provider, "stored deployment keeps its provider").toBe("anthropic");
|
||||
});
|
||||
|
||||
test("Add team-only model via Team-BYOK toggle and verify it appears with the team", async ({ page, request }) => {
|
||||
// The Team-BYOK switch is gated on `premiumUser` — without a license set
|
||||
// for the proxy under test, the toggle is disabled and this manual-QA
|
||||
// step cannot be exercised.
|
||||
// The Team-BYOK switch is gated on premiumUser; without a license the toggle is disabled.
|
||||
test.skip(!process.env.LITELLM_LICENSE, "LITELLM_LICENSE not set in test env — Team-BYOK switch is disabled");
|
||||
|
||||
// Make the test idempotent across retries and local reruns: delete any
|
||||
// Cohere model already scoped to the e2e team before we start, and again
|
||||
// after we finish. The sibling "Add wildcard route" test creates a
|
||||
// team-less Cohere wildcard, so we only target rows that have BOTH the
|
||||
// cohere/* model_name AND team_id == e2e-team-crud.
|
||||
// Idempotent across reruns. Only target rows with both the cohere name and the e2e team,
|
||||
// so the sibling wildcard test's team-less model is left alone.
|
||||
const masterKey = users[Role.ProxyAdmin].password;
|
||||
const auth = { Authorization: `Bearer ${masterKey}` };
|
||||
const deleteTeamScopedCohereModels = async () => {
|
||||
|
|
@ -198,11 +328,7 @@ test.describe("Add Model", () => {
|
|||
const teamByokRow = page.locator(".ant-form-item", { hasText: "Team-BYOK Model" });
|
||||
await teamByokRow.getByRole("switch").click();
|
||||
|
||||
// The Team dropdown appears underneath once the switch is on. TeamDropdown
|
||||
// renders its Select.Option children with custom <span>/<Text> markup, so
|
||||
// the popup items don't carry role="option" — match by text content,
|
||||
// scoped to the visible dropdown so a stale tag elsewhere in the form
|
||||
// can't satisfy it.
|
||||
// TeamDropdown's options carry custom markup and no role="option", so match by text.
|
||||
const teamDropdown = page.getByTestId("team-dropdown");
|
||||
await expect(teamDropdown).toBeVisible({ timeout: 5_000 });
|
||||
await teamDropdown.click();
|
||||
|
|
@ -212,36 +338,27 @@ test.describe("Add Model", () => {
|
|||
|
||||
await page.getByRole("button", { name: "Add Model" }).last().click();
|
||||
|
||||
// Scope the success toast to antd's notification container so a stale
|
||||
// success message from an earlier test in the same context can't satisfy
|
||||
// the assertion.
|
||||
// Scope to antd's notification container so a stale toast can't satisfy this.
|
||||
await expect(page.locator(".ant-notification").getByText("created successfully").last()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
// Verify the model is now in All Models with the team_id attached. The
|
||||
// Models table renders team-scoped models with the team id in the row.
|
||||
// The Models table renders team-scoped models with the team id in the row.
|
||||
await page.getByRole("tab", { name: "All Models" }).click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
// Match the sibling tests in this file — networkidle fires before the
|
||||
// table finishes re-rendering, so give it the same 2s settle before
|
||||
// searching.
|
||||
// networkidle fires before the table finishes re-rendering.
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
await page.getByPlaceholder("Search model names").fill("cohere");
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Confirm the search returned at least one result — gives a clear
|
||||
// failure message when the table is empty instead of timing out on a
|
||||
// row assertion.
|
||||
// Clearer failure than timing out on a row assertion when the table is empty.
|
||||
await expect(page.getByTestId("pagination-range")).toHaveText(/Showing \d+-\d+ of \d+/, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
// Stronger than "the team appears somewhere in tbody" — pin the assertion
|
||||
// to a single row that has BOTH the cohere model_name AND the seeded
|
||||
// team, so a stale cohere row from "Add wildcard route" (no team) can't
|
||||
// satisfy the check. The Team ID column renders the id, not the alias.
|
||||
// Pin to one row carrying both the name and the team, so the sibling test's
|
||||
// team-less cohere row can't satisfy it.
|
||||
const teamCohereRow = page
|
||||
.locator("table tbody tr")
|
||||
.filter({ hasText: "cohere/" })
|
||||
|
|
@ -270,7 +387,11 @@ test.describe("Add Model", () => {
|
|||
await apiKeyInput.fill("sk-any-key-for-wildcard-test");
|
||||
|
||||
// Click Add Model button by its text
|
||||
await page.getByRole("button", { name: "Add Model" }).last().click();
|
||||
const created = await captureRequestBody(page, { method: "POST", urlIncludes: "/model/new" }, async () => {
|
||||
await page.getByRole("button", { name: "Add Model" }).last().click();
|
||||
});
|
||||
// A wildcard with the star stripped becomes a plain "cohere" deployment that matches nothing.
|
||||
expect(created.model_name, "the wildcard route goes on the wire intact").toBe("cohere/*");
|
||||
|
||||
// Wait for success notification
|
||||
await expect(page.getByText("created successfully")).toBeVisible({ timeout: 15_000 });
|
||||
|
|
@ -292,5 +413,10 @@ test.describe("Add Model", () => {
|
|||
// Verify the wildcard model appears in the table body (wildcard models show as "cohere/*")
|
||||
const tableBody = page.locator("table tbody");
|
||||
await expect(tableBody.getByText("cohere/").first()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// "cohere/" in the table also matches a plain cohere deployment; require the wildcard exactly.
|
||||
const stored = await findDeploymentByName(page, "cohere/*");
|
||||
expect(stored, "wildcard deployment readable from /v2/model/info").toBeTruthy();
|
||||
expect(stored?.litellm_params?.model, "stored deployment keeps the wildcard route").toBe("cohere/*");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
50
tests/e2e/ui/tests/playground/playground.spec.ts
Normal file
50
tests/e2e/ui/tests/playground/playground.spec.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { ADMIN_STORAGE_PATH } from "../../constants";
|
||||
import { CHAT_MODEL_A, CHAT_MODEL_B, MOCK_RESPONSE_TEXT, createVirtualKey } from "../../helpers/traffic";
|
||||
import { keySourceSelect, onlyVisible, openPlayground, selectModel, sendMessage } from "../../helpers/playground";
|
||||
|
||||
/**
|
||||
* The one flow that exercises the dashboard's own LLM call path rather than an admin CRUD endpoint,
|
||||
* so it covers the UI's auth header, endpoint selection and streaming render.
|
||||
*/
|
||||
test.describe("Playground", () => {
|
||||
test.use({ storageState: ADMIN_STORAGE_PATH });
|
||||
|
||||
for (const model of [CHAT_MODEL_A, CHAT_MODEL_B]) {
|
||||
test(`chats with ${model} using the current UI session`, async ({ page }) => {
|
||||
await openPlayground(page);
|
||||
|
||||
// "Current UI Session" is the default: the logged-in admin's key, nothing pasted.
|
||||
await expect(onlyVisible(page.getByTitle("Current UI Session"))).toBeVisible();
|
||||
|
||||
await selectModel(page, model);
|
||||
const prompt = `playground ping for ${model}`;
|
||||
await sendMessage(page, prompt);
|
||||
|
||||
// Our prompt is echoed into the transcript, and the mock server replies.
|
||||
await expect(page.getByText(prompt, { exact: false }).first()).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText(MOCK_RESPONSE_TEXT, { exact: false }).first()).toBeVisible({ timeout: 60_000 });
|
||||
});
|
||||
}
|
||||
|
||||
test("chats using a pasted virtual key instead of the UI session", async ({ page, request }) => {
|
||||
const { key } = await createVirtualKey(request, {
|
||||
key_alias: `e2e-playground-${Date.now()}`,
|
||||
});
|
||||
|
||||
await openPlayground(page);
|
||||
|
||||
// Switch the source to "Virtual Key" and paste the key we just minted.
|
||||
await keySourceSelect(page, "Current UI Session").click();
|
||||
await onlyVisible(page.locator('.ant-select-item-option[title="Virtual Key"]')).click({ timeout: 15_000 });
|
||||
|
||||
const keyInput = onlyVisible(page.getByPlaceholder("Enter custom Virtual Key"));
|
||||
await expect(keyInput).toBeVisible({ timeout: 10_000 });
|
||||
await keyInput.fill(key);
|
||||
|
||||
await selectModel(page, CHAT_MODEL_A);
|
||||
await sendMessage(page, "playground ping via virtual key");
|
||||
|
||||
await expect(page.getByText(MOCK_RESPONSE_TEXT, { exact: false }).first()).toBeVisible({ timeout: 60_000 });
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { test, expect, type Page as PlaywrightPage } from "@playwright/test";
|
||||
import {
|
||||
ADMIN_STORAGE_PATH,
|
||||
E2E_DELETE_KEY_ALIAS,
|
||||
|
|
@ -9,6 +9,19 @@ import {
|
|||
} from "../../constants";
|
||||
import { Page } from "../../fixtures/pages";
|
||||
import { navigateToPage, dismissFeedbackPopup } from "../../helpers/navigation";
|
||||
import { captureRequestBody, readBack } from "../../helpers/roundTrip";
|
||||
|
||||
/**
|
||||
* Looks a key up by alias, undefined when none carries it. `return_full_object=true` is what makes
|
||||
* the row carry token / models / tpm_limit; without it the response is aliases only.
|
||||
*/
|
||||
async function findKeyByAlias(page: PlaywrightPage, alias: string): Promise<Record<string, any> | undefined> {
|
||||
const body = await readBack<{ keys: Record<string, any>[] }>(
|
||||
page,
|
||||
`/key/list?key_alias=${encodeURIComponent(alias)}&return_full_object=true&size=100`,
|
||||
);
|
||||
return body.keys.find((row) => row.key_alias === alias);
|
||||
}
|
||||
|
||||
test.describe("Proxy Admin - Keys", () => {
|
||||
test.use({ storageState: ADMIN_STORAGE_PATH });
|
||||
|
|
@ -47,12 +60,21 @@ test.describe("Proxy Admin - Keys", () => {
|
|||
|
||||
// Verify the new key appears in the table
|
||||
await expect(page.getByText(keyName)).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// The row above renders from the create response the UI already holds, so it proves nothing.
|
||||
const persisted = await findKeyByAlias(page, keyName);
|
||||
expect(persisted, `key ${keyName} readable from /key/list`).toBeTruthy();
|
||||
expect(typeof persisted?.team_id, "created key is owned by a team, not orphaned").toBe("string");
|
||||
});
|
||||
|
||||
test("Regenerate key", async ({ page }) => {
|
||||
await navigateToPage(page, Page.ApiKeys);
|
||||
await dismissFeedbackPopup(page);
|
||||
|
||||
// Capture the old token first: a modal with a Copy button only proves the UI rendered.
|
||||
const before = await findKeyByAlias(page, E2E_REGENERATE_KEY_ALIAS);
|
||||
expect(before?.token, `seeded key ${E2E_REGENERATE_KEY_ALIAS} has a token`).toBeTruthy();
|
||||
|
||||
// Key IDs are rendered as buttons in the table
|
||||
const keyRow = page.locator("tr", { hasText: E2E_REGENERATE_KEY_ALIAS });
|
||||
await expect(keyRow).toBeVisible({ timeout: 10_000 });
|
||||
|
|
@ -70,12 +92,24 @@ test.describe("Proxy Admin - Keys", () => {
|
|||
|
||||
// Success view shows a Copy button in the footer (text varies between modal versions)
|
||||
await expect(modal.getByRole("button", { name: /Copy.*Key/ })).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// The token must be replaced and the alias kept; orphaning it looks identical from the modal.
|
||||
await expect
|
||||
.poll(async () => (await findKeyByAlias(page, E2E_REGENERATE_KEY_ALIAS))?.token, {
|
||||
message: `token for ${E2E_REGENERATE_KEY_ALIAS} did not change after regenerate`,
|
||||
timeout: 15_000,
|
||||
})
|
||||
.not.toBe(before?.token);
|
||||
});
|
||||
|
||||
test("Update key TPM and RPM limits", async ({ page }) => {
|
||||
await navigateToPage(page, Page.ApiKeys);
|
||||
await dismissFeedbackPopup(page);
|
||||
|
||||
// Snapshot first, so the end assertions can tell an isolated edit from a collateral one.
|
||||
const before = await findKeyByAlias(page, E2E_UPDATE_LIMITS_KEY_ALIAS);
|
||||
expect(before, `seeded key ${E2E_UPDATE_LIMITS_KEY_ALIAS} exists`).toBeTruthy();
|
||||
|
||||
const keyRow = page.locator("tr", { hasText: E2E_UPDATE_LIMITS_KEY_ALIAS });
|
||||
await expect(keyRow).toBeVisible({ timeout: 10_000 });
|
||||
await keyRow.locator("button").first().click();
|
||||
|
|
@ -87,10 +121,27 @@ test.describe("Proxy Admin - Keys", () => {
|
|||
|
||||
await page.getByRole("spinbutton", { name: "TPM Limit" }).fill("123");
|
||||
await page.getByRole("spinbutton", { name: "RPM Limit" }).fill("456");
|
||||
await page.getByRole("button", { name: "Save Changes" }).click();
|
||||
|
||||
const update = await captureRequestBody(page, { method: "POST", urlIncludes: "/key/update" }, async () => {
|
||||
await page.getByRole("button", { name: "Save Changes" }).click();
|
||||
});
|
||||
|
||||
// The form posts limits at the top level. Compare numerically: the spinbutton yields either type.
|
||||
expect(Number(update.tpm_limit), "TPM limit on the wire").toBe(123);
|
||||
expect(Number(update.rpm_limit), "RPM limit on the wire").toBe(456);
|
||||
|
||||
await expect(page.getByRole("paragraph").filter({ hasText: "TPM: 123" })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByRole("paragraph").filter({ hasText: "RPM: 456" })).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Read the key back; the rendering above comes from a response the UI already holds.
|
||||
const after = await findKeyByAlias(page, E2E_UPDATE_LIMITS_KEY_ALIAS);
|
||||
expect(after, "key still readable after update").toBeTruthy();
|
||||
expect(Number(after?.tpm_limit), "TPM limit persisted").toBe(123);
|
||||
expect(Number(after?.rpm_limit), "RPM limit persisted").toBe(456);
|
||||
|
||||
// Not hypothetical: bumping a key's budget wiped its MCP toolset (PR #34452), toast said success.
|
||||
expect(after?.models, "editing limits left the key's models untouched").toEqual(before?.models);
|
||||
expect(after?.team_id, "editing limits left the key's team untouched").toEqual(before?.team_id);
|
||||
});
|
||||
|
||||
test("Delete key", async ({ page }) => {
|
||||
|
|
@ -115,6 +166,14 @@ test.describe("Proxy Admin - Keys", () => {
|
|||
await deleteButton.click();
|
||||
|
||||
await expect(page.getByText(/Key deleted/i).first()).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// The key is gone when the management API stops returning it, not when the toast says so.
|
||||
await expect
|
||||
.poll(async () => await findKeyByAlias(page, E2E_DELETE_KEY_ALIAS), {
|
||||
message: `key ${E2E_DELETE_KEY_ALIAS} still readable from /key/list after delete`,
|
||||
timeout: 15_000,
|
||||
})
|
||||
.toBeUndefined();
|
||||
});
|
||||
|
||||
test("See internal user keys in team", async ({ page }) => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { test, expect, type Page as PlaywrightPage } from "@playwright/test";
|
||||
import {
|
||||
ADMIN_STORAGE_PATH,
|
||||
E2E_TEAM_CRUD_ID,
|
||||
|
|
@ -8,6 +8,22 @@ import {
|
|||
} from "../../constants";
|
||||
import { Page } from "../../fixtures/pages";
|
||||
import { navigateToPage, dismissFeedbackPopup, clickTeamId } from "../../helpers/navigation";
|
||||
import { readBack } from "../../helpers/roundTrip";
|
||||
|
||||
/** GET /team/list returns a bare array of teams, each carrying team_alias/team_id. */
|
||||
async function findTeamByAlias(page: PlaywrightPage, alias: string): Promise<Record<string, any> | undefined> {
|
||||
const teams = await readBack<Record<string, any>[]>(page, "/team/list");
|
||||
return teams.find((team) => team.team_alias === alias);
|
||||
}
|
||||
|
||||
/** GET /team/info nests the record under `team_info`; membership lives in members_with_roles. */
|
||||
async function teamMemberEmails(page: PlaywrightPage, teamId: string): Promise<string[]> {
|
||||
const info = await readBack<{ team_info: { members_with_roles?: { user_email?: string }[] } }>(
|
||||
page,
|
||||
`/team/info?team_id=${encodeURIComponent(teamId)}`,
|
||||
);
|
||||
return (info.team_info.members_with_roles ?? []).map((member) => member.user_email ?? "").filter(Boolean);
|
||||
}
|
||||
|
||||
test.describe("Proxy Admin - Teams", () => {
|
||||
test.use({ storageState: ADMIN_STORAGE_PATH });
|
||||
|
|
@ -42,6 +58,11 @@ test.describe("Proxy Admin - Teams", () => {
|
|||
|
||||
// Verify success notification
|
||||
await expect(page.getByText("Team created").first()).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// A create that drops its model selection still toasts success.
|
||||
const created = await findTeamByAlias(page, uniqueAlias);
|
||||
expect(created, `team ${uniqueAlias} readable from /team/list`).toBeTruthy();
|
||||
expect(created?.models, "created team kept its model selection").toBeTruthy();
|
||||
});
|
||||
|
||||
test("Invite a user to a team", async ({ page }) => {
|
||||
|
|
@ -71,6 +92,14 @@ test.describe("Proxy Admin - Teams", () => {
|
|||
await modal.getByRole("button", { name: /Add Member/i }).click();
|
||||
|
||||
await expect(page.getByText(/member.*added|success/i).first()).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// The toast is matched loosely enough (/success/i) that almost any notification satisfies it.
|
||||
await expect
|
||||
.poll(async () => await teamMemberEmails(page, E2E_TEAM_CRUD_ID), {
|
||||
message: "invited user never appeared in the team's members",
|
||||
timeout: 15_000,
|
||||
})
|
||||
.toContain("invitable@test.local");
|
||||
});
|
||||
|
||||
test("Edit team member for team proxy admin does not belong to", async ({ page }) => {
|
||||
|
|
@ -106,6 +135,14 @@ test.describe("Proxy Admin - Teams", () => {
|
|||
await modal.getByRole("button", { name: /Force Delete|Delete/i }).click();
|
||||
|
||||
await expect(teamRow).not.toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// A row vanishing is local state, which happens whether or not the delete landed.
|
||||
await expect
|
||||
.poll(async () => await findTeamByAlias(page, E2E_TEAM_DELETE_ALIAS), {
|
||||
message: `team ${E2E_TEAM_DELETE_ALIAS} still readable from /team/list after delete`,
|
||||
timeout: 15_000,
|
||||
})
|
||||
.toBeUndefined();
|
||||
});
|
||||
|
||||
test("Team in org - edit team member", async ({ page }) => {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { ADMIN_STORAGE_PATH } from "../../constants";
|
|||
import { navigateToPage } from "../../helpers/navigation";
|
||||
import { Page } from "../../fixtures/pages";
|
||||
import { Role, users } from "../../fixtures/users";
|
||||
import { MOCK_RESPONSE_TEXT } from "../../helpers/traffic";
|
||||
import { openPlayground, selectModel, sendMessage } from "../../helpers/playground";
|
||||
// Type-only import of the OpenAPI-generated backend schema, erased at runtime by
|
||||
// esbuild. It types the round-trips below so mistakes surface in the editor; the live
|
||||
// test against the real proxy is what actually enforces the contract.
|
||||
|
|
@ -79,7 +81,9 @@ test.describe("Router Settings - Fallbacks", () => {
|
|||
await primarySelect.click();
|
||||
await page.keyboard.type(PRIMARY);
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(modal.getByRole("tab", { name: PRIMARY })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(modal.getByRole("tab", { name: PRIMARY })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
const fallbackSelect = modal.locator(".ant-select").filter({ hasText: "Select fallback models" });
|
||||
await fallbackSelect.click();
|
||||
|
|
@ -88,7 +92,9 @@ test.describe("Router Settings - Fallbacks", () => {
|
|||
await page.keyboard.press("Escape");
|
||||
// The Fallback Chain helper text reads "(N/10 used)"; once it ticks to 1 the
|
||||
// selection has been recorded.
|
||||
await expect(modal.getByText("(1/10 used)")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(modal.getByText("(1/10 used)")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// Save
|
||||
await modal.getByRole("button", { name: /Save All Configurations/i }).click();
|
||||
|
|
@ -111,7 +117,9 @@ test.describe("Router Settings - Fallbacks", () => {
|
|||
type ConfigYAML = components["schemas"]["ConfigYAML"];
|
||||
type RouterSettingsResponse = components["schemas"]["RouterSettingsResponse"];
|
||||
|
||||
const ADMIN_AUTH = { Authorization: `Bearer ${users[Role.ProxyAdmin].password}` };
|
||||
const ADMIN_AUTH = {
|
||||
Authorization: `Bearer ${users[Role.ProxyAdmin].password}`,
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply a router_settings patch through the typed /config/update contract. The
|
||||
|
|
@ -172,13 +180,17 @@ test.describe("Router Settings - Loadbalancing", () => {
|
|||
// The ticket's core symptom was that a refresh showed the old value.
|
||||
await navigateToPage(page, Page.RouterSettings);
|
||||
await page.getByRole("tab", { name: "Loadbalancing" }).click();
|
||||
await expect(page.locator('input[name="num_retries"]')).toHaveValue("5", { timeout: 15_000 });
|
||||
await expect(page.locator('input[name="num_retries"]')).toHaveValue("5", {
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
// The typed backend read agrees the change persisted.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const res = await request.get(`/router/settings`, { headers: ADMIN_AUTH });
|
||||
const res = await request.get(`/router/settings`, {
|
||||
headers: ADMIN_AUTH,
|
||||
});
|
||||
const data = (await res.json()) as RouterSettingsResponse;
|
||||
return data.current_values?.num_retries;
|
||||
},
|
||||
|
|
@ -187,3 +199,91 @@ test.describe("Router Settings - Loadbalancing", () => {
|
|||
.toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The test above proves the UI can record a fallback; this proves the fallback is honoured. The
|
||||
* primary is created here because every fixture model is mock-backed and cannot fail on demand.
|
||||
*/
|
||||
test.describe("Router Settings - Fallbacks serve the request", () => {
|
||||
test.use({ storageState: ADMIN_STORAGE_PATH });
|
||||
|
||||
const BROKEN_PRIMARY = "e2e-broken-primary";
|
||||
let brokenModelId: string | null = null;
|
||||
|
||||
/** Drop only this test's fallback entry, leaving any others untouched. */
|
||||
async function clearBrokenFallback(request: import("@playwright/test").APIRequestContext) {
|
||||
const current = await request.get("/get/config/callbacks", {
|
||||
headers: ADMIN_AUTH,
|
||||
});
|
||||
if (!current.ok()) return;
|
||||
const router = (await current.json())?.router_settings ?? {};
|
||||
const existing: Array<Record<string, string[]>> = Array.isArray(router.fallbacks) ? router.fallbacks : [];
|
||||
await patchRouterSettings(request, {
|
||||
fallbacks: existing.filter((entry) => !(entry && BROKEN_PRIMARY in entry)),
|
||||
} as Partial<NonNullable<ConfigYAML["router_settings"]>>);
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await clearBrokenFallback(request);
|
||||
|
||||
// Port 9 is the discard service: nothing listens, so the connection is
|
||||
// refused immediately rather than hanging until a timeout.
|
||||
const res = await request.post("/model/new", {
|
||||
headers: ADMIN_AUTH,
|
||||
data: {
|
||||
model_name: BROKEN_PRIMARY,
|
||||
litellm_params: {
|
||||
model: "openai/broken",
|
||||
api_base: "http://127.0.0.1:9/v1",
|
||||
api_key: "fake",
|
||||
timeout: 5,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok(), `creating the broken primary failed: ${res.status()} ${await res.text()}`).toBeTruthy();
|
||||
brokenModelId = (await res.json())?.model_id ?? null;
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
await clearBrokenFallback(request);
|
||||
if (brokenModelId) {
|
||||
await request.post("/model/delete", {
|
||||
headers: ADMIN_AUTH,
|
||||
data: { id: brokenModelId },
|
||||
});
|
||||
brokenModelId = null;
|
||||
}
|
||||
});
|
||||
|
||||
test("a request to an unreachable model is answered by its fallback", async ({ page, request }) => {
|
||||
const chat = async () =>
|
||||
request.post("/v1/chat/completions", {
|
||||
headers: { ...ADMIN_AUTH, "Content-Type": "application/json" },
|
||||
data: {
|
||||
model: BROKEN_PRIMARY,
|
||||
messages: [{ role: "user", content: "fallback probe" }],
|
||||
},
|
||||
});
|
||||
|
||||
// The control: it proves the reply below could only have come from the fallback.
|
||||
expect((await chat()).status(), "broken primary unexpectedly succeeded on its own").toBeGreaterThanOrEqual(400);
|
||||
|
||||
await patchRouterSettings(request, {
|
||||
fallbacks: [{ [BROKEN_PRIMARY]: [PRIMARY] }],
|
||||
} as Partial<NonNullable<ConfigYAML["router_settings"]>>);
|
||||
|
||||
// Same call now succeeds, served by the fallback model.
|
||||
await expect
|
||||
.poll(async () => (await chat()).status(), {
|
||||
timeout: 30_000,
|
||||
message: "fallback never took effect",
|
||||
})
|
||||
.toBe(200);
|
||||
|
||||
// And the playground renders a reply for a model whose own upstream is down.
|
||||
await openPlayground(page);
|
||||
await selectModel(page, BROKEN_PRIMARY);
|
||||
await sendMessage(page, "fallback probe from the playground");
|
||||
await expect(page.getByText(MOCK_RESPONSE_TEXT, { exact: false }).first()).toBeVisible({ timeout: 60_000 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { test, expect, type Page as PlaywrightPage } from "@playwright/test";
|
||||
import {
|
||||
E2E_INTERNAL_USER_KEY_ALIAS,
|
||||
E2E_TEAM_CRUD_ALIAS,
|
||||
|
|
@ -6,13 +6,30 @@ import {
|
|||
TEAM_ADMIN_STORAGE_PATH,
|
||||
} from "../../constants";
|
||||
import { Page } from "../../fixtures/pages";
|
||||
import { navigateToPage, dismissFeedbackPopup } from "../../helpers/navigation";
|
||||
import { navigateToPage, dismissFeedbackPopup, clickTeamId } from "../../helpers/navigation";
|
||||
import { captureRequestBody, readBack } from "../../helpers/roundTrip";
|
||||
|
||||
async function clickTeamId(page: import("@playwright/test").Page, teamId: string) {
|
||||
const cell = page.locator("td").filter({ hasText: teamId }).first();
|
||||
await expect(cell).toBeVisible({ timeout: 10_000 });
|
||||
await cell.click();
|
||||
await expect(page.getByText("Back to Teams")).toBeVisible({ timeout: 10_000 });
|
||||
/**
|
||||
* Every identifier a roster is addressable by. Which of user_id / user_email is populated depends on
|
||||
* how the member got there, so flatten both and let assertions name whichever the test typed.
|
||||
*/
|
||||
async function teamMemberIdentities(page: PlaywrightPage, teamId: string): Promise<string[]> {
|
||||
const info = await readBack<{ team_info: { members_with_roles?: { user_id?: string; user_email?: string }[] } }>(
|
||||
page,
|
||||
`/team/info?team_id=${encodeURIComponent(teamId)}`,
|
||||
);
|
||||
return (info.team_info.members_with_roles ?? []).flatMap((member) =>
|
||||
[member.user_id, member.user_email].filter((value): value is string => Boolean(value)),
|
||||
);
|
||||
}
|
||||
|
||||
/** See keys.spec.ts -- return_full_object is what makes the row carry team_id. */
|
||||
async function findKeyByAlias(page: PlaywrightPage, alias: string): Promise<Record<string, any> | undefined> {
|
||||
const body = await readBack<{ keys: Record<string, any>[] }>(
|
||||
page,
|
||||
`/key/list?key_alias=${encodeURIComponent(alias)}&return_full_object=true&size=100`,
|
||||
);
|
||||
return body.keys.find((row) => row.key_alias === alias);
|
||||
}
|
||||
|
||||
test.describe("Team Admin", () => {
|
||||
|
|
@ -56,9 +73,22 @@ test.describe("Team Admin", () => {
|
|||
await expect(emailOption).toBeAttached({ timeout: 10_000 });
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await modal.getByRole("button", { name: /Add Member/i }).click();
|
||||
const add = await captureRequestBody(page, { method: "POST", urlIncludes: "/team/member_add" }, async () => {
|
||||
await modal.getByRole("button", { name: /Add Member/i }).click();
|
||||
});
|
||||
// An add carrying the wrong team_id still toasts success, and the member lands elsewhere.
|
||||
expect(add.team_id, "add targets the team being viewed").toBe(E2E_TEAM_CRUD_ID);
|
||||
expect(add.member?.user_email, "the typed email is what goes on the wire").toBe("invitable-team@test.local");
|
||||
|
||||
await expect(page.getByText("Team member added successfully").first()).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Membership is the point of the flow, so read the roster back.
|
||||
await expect
|
||||
.poll(async () => await teamMemberIdentities(page, E2E_TEAM_CRUD_ID), {
|
||||
message: "added member never appeared in the team's roster",
|
||||
timeout: 15_000,
|
||||
})
|
||||
.toContain("invitable-team@test.local");
|
||||
});
|
||||
|
||||
test("Team admin can remove a member from their team", async ({ page }) => {
|
||||
|
|
@ -77,9 +107,25 @@ test.describe("Team Admin", () => {
|
|||
|
||||
const modal = page.locator(".ant-modal:visible");
|
||||
await expect(modal).toBeVisible({ timeout: 5_000 });
|
||||
await modal.getByRole("button", { name: /^Delete$/ }).click();
|
||||
|
||||
const remove = await captureRequestBody(page, { method: "POST", urlIncludes: "/team/member_delete" }, async () => {
|
||||
await modal.getByRole("button", { name: /^Delete$/ }).click();
|
||||
});
|
||||
// Removing the wrong member is exactly what a success toast hides, so pin both halves.
|
||||
expect(remove.team_id, "delete targets the team being viewed").toBe(E2E_TEAM_CRUD_ID);
|
||||
expect([remove.user_id, remove.user_email], "delete identifies the member whose row was clicked").toContain(
|
||||
"e2e-removable-member",
|
||||
);
|
||||
|
||||
await expect(page.getByText("Team member removed successfully").first()).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// The row disappearing is local state, which happens whether or not the write landed.
|
||||
await expect
|
||||
.poll(async () => await teamMemberIdentities(page, E2E_TEAM_CRUD_ID), {
|
||||
message: "removed member is still on the team",
|
||||
timeout: 15_000,
|
||||
})
|
||||
.not.toContain("e2e-removable-member");
|
||||
});
|
||||
|
||||
test("Team admin can create a team key with All Team Models", async ({ page }) => {
|
||||
|
|
@ -103,11 +149,20 @@ test.describe("Team Admin", () => {
|
|||
await page.locator(".ant-select-dropdown:visible").getByText("All Team Models").click();
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.getByRole("button", { name: "Create Key", exact: true }).click();
|
||||
const generate = await captureRequestBody(page, { method: "POST", urlIncludes: "/key/generate" }, async () => {
|
||||
await page.getByRole("button", { name: "Create Key", exact: true }).click();
|
||||
});
|
||||
expect(generate.team_id, "the selected team goes on the wire").toBe(E2E_TEAM_CRUD_ID);
|
||||
|
||||
await expect(page.getByText("Save your Key")).toBeVisible({ timeout: 10_000 });
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await expect(page.getByText(keyName)).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// A team-admin key that comes back unscoped, or scoped elsewhere, is a privilege and
|
||||
// billing problem that only a read-back sees.
|
||||
const persisted = await findKeyByAlias(page, keyName);
|
||||
expect(persisted, `key ${keyName} readable from /key/list`).toBeTruthy();
|
||||
expect(persisted?.team_id, "the key is owned by the team admin's own team").toBe(E2E_TEAM_CRUD_ID);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
74
tests/e2e/ui/tests/usage/usagePage.spec.ts
Normal file
74
tests/e2e/ui/tests/usage/usagePage.spec.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { test, expect, type Locator, type Page as PlaywrightPage } from "@playwright/test";
|
||||
import { ADMIN_STORAGE_PATH } from "../../constants";
|
||||
import { navigateToPage, dismissFeedbackPopup } from "../../helpers/navigation";
|
||||
import { Page } from "../../fixtures/pages";
|
||||
import {
|
||||
CHAT_MODEL_A,
|
||||
createVirtualKey,
|
||||
sendChatCompletion,
|
||||
waitForKeyInDailyActivity,
|
||||
waitForSpendLog,
|
||||
} from "../../helpers/traffic";
|
||||
|
||||
/** Covers /ui/usage. The legacy /ui/old-usage view is deprecated and deliberately not covered. */
|
||||
|
||||
/** Stepping up from the title is exact; the page renders several other tables. */
|
||||
const topKeysCard = (page: PlaywrightPage): Locator =>
|
||||
page.getByText("Top Virtual Keys", { exact: true }).locator("xpath=..");
|
||||
|
||||
async function openUsage(page: PlaywrightPage): Promise<Locator> {
|
||||
await navigateToPage(page, Page.NewUsage);
|
||||
await dismissFeedbackPopup(page);
|
||||
const card = topKeysCard(page);
|
||||
await expect(card).toBeVisible({ timeout: 30_000 });
|
||||
// Widen past the default top-5 so other keys in the database cannot crowd this one out.
|
||||
await card.locator(".ant-segmented-item").filter({ hasText: /^50$/ }).click();
|
||||
return card;
|
||||
}
|
||||
|
||||
test.describe("Usage page", () => {
|
||||
test.use({ storageState: ADMIN_STORAGE_PATH });
|
||||
|
||||
test("Top Virtual Keys lists a key that served traffic, toggles views, and opens key info", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const alias = `e2e-usage-key-${Date.now()}`;
|
||||
const { key, token } = await createVirtualKey(request, {
|
||||
key_alias: alias,
|
||||
});
|
||||
|
||||
const requestId = await sendChatCompletion(request, {
|
||||
model: CHAT_MODEL_A,
|
||||
prompt: `usage ping for ${alias}`,
|
||||
apiKey: key,
|
||||
});
|
||||
await waitForSpendLog(request, requestId);
|
||||
// Must land in the aggregate before the page mounts — it fetches once.
|
||||
await waitForKeyInDailyActivity(request, token);
|
||||
|
||||
const card = await openUsage(page);
|
||||
|
||||
// Table view (the default): the key is listed by its alias.
|
||||
const row = card.locator("tbody tr").filter({ hasText: alias });
|
||||
await expect(row, `${alias} missing from Top Virtual Keys`).toHaveCount(1, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// Chart view swaps the table out for the bar chart, and back.
|
||||
await card.getByText("Chart View", { exact: true }).click();
|
||||
await expect(card.locator("tbody tr")).toHaveCount(0, { timeout: 10_000 });
|
||||
await card.getByText("Table View", { exact: true }).click();
|
||||
await expect(row).toHaveCount(1, { timeout: 10_000 });
|
||||
|
||||
// Clicking the Key ID cell fetches key info and opens the detail panel.
|
||||
// The alias is already in the row behind the modal, so match the panel's own controls.
|
||||
await row.locator("td").first().click();
|
||||
const keyInfo = page.getByRole("tab", { name: "Overview", exact: true });
|
||||
await expect(keyInfo, "key info panel did not open").toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(page.getByRole("tab", { name: "Settings", exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Back to Keys", { exact: false })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
|
@ -18,6 +19,7 @@ from litellm.proxy._types import (
|
|||
LiteLLM_TeamTable,
|
||||
LitellmUserRoles,
|
||||
Member,
|
||||
ReconcileOutcome,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.model_management_endpoints import (
|
||||
|
|
@ -103,11 +105,11 @@ class MockProxyConfig:
|
|||
self.success = success
|
||||
self.deployment_called = False
|
||||
|
||||
async def add_deployment(self, prisma_client, proxy_logging_obj):
|
||||
async def _add_deployment_locked(self, prisma_client, proxy_logging_obj):
|
||||
self.deployment_called = True
|
||||
if not self.success:
|
||||
raise Exception("Failed to add deployment")
|
||||
return True
|
||||
return ReconcileOutcome(still_desired=frozenset(), live_after=frozenset())
|
||||
|
||||
|
||||
class TestModelManagementAuthChecks:
|
||||
|
|
@ -409,7 +411,9 @@ class TestClearCache:
|
|||
mock_router.model_list = ["openai/gpt-4o", "openai/gpt-4o-mini"]
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.add_deployment = AsyncMock(return_value=True)
|
||||
mock_config._add_deployment_locked = AsyncMock(
|
||||
return_value=ReconcileOutcome(still_desired=frozenset(), live_after=frozenset())
|
||||
)
|
||||
|
||||
mock_prisma = MagicMock()
|
||||
mock_logging = MagicMock()
|
||||
|
|
@ -430,7 +434,9 @@ class TestClearCache:
|
|||
@pytest.mark.asyncio
|
||||
async def test_clear_cache_preserve_config_models(self):
|
||||
"""
|
||||
Test that clear_cache clears DB models and preserves config models.
|
||||
clear_cache resets DB-backed auto-router entries and delegates every deployment
|
||||
change to the reload, leaving config models untouched. It must not wipe
|
||||
deployments itself -- see the delete_deployment assertion below.
|
||||
"""
|
||||
from litellm.proxy.management_endpoints.model_management_endpoints import (
|
||||
clear_cache,
|
||||
|
|
@ -463,7 +469,9 @@ class TestClearCache:
|
|||
mock_router.complexity_routers = {"db-complexity-router": MagicMock(), "config-router": MagicMock()}
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.add_deployment = AsyncMock(return_value=True)
|
||||
mock_config._add_deployment_locked = AsyncMock(
|
||||
return_value=ReconcileOutcome(still_desired=frozenset(), live_after=frozenset())
|
||||
)
|
||||
|
||||
mock_prisma = MagicMock()
|
||||
mock_logging = MagicMock()
|
||||
|
|
@ -477,7 +485,10 @@ class TestClearCache:
|
|||
):
|
||||
await clear_cache()
|
||||
|
||||
# Should have called delete_deployment for both DB models
|
||||
# clear_cache must wipe ONLY the db auto-router deployments -- the ones whose
|
||||
# strategy entries are popped below and can only be rebuilt via the add path.
|
||||
# Ordinary db models are left alone: wiping them un-served every db model for
|
||||
# the width of the reload, and the reconcile converges without it.
|
||||
assert mock_router.delete_deployment.call_count == 2
|
||||
mock_router.delete_deployment.assert_any_call(id="db-model-1")
|
||||
mock_router.delete_deployment.assert_any_call(id="db-model-2")
|
||||
|
|
@ -491,11 +502,70 @@ class TestClearCache:
|
|||
assert "config-router" in mock_router.auto_routers
|
||||
assert "config-router" in mock_router.complexity_routers
|
||||
|
||||
# Should have called add_deployment to reload DB models
|
||||
mock_config.add_deployment.assert_called_once_with(
|
||||
# Should have called the already-locked reload to restore DB models
|
||||
mock_config._add_deployment_locked.assert_called_once_with(
|
||||
prisma_client=mock_prisma, proxy_logging_obj=mock_logging
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_cache_wipes_auto_routers_but_leaves_ordinary_db_models(self):
|
||||
"""An ordinary db model must survive clear_cache; a db auto-router must not.
|
||||
|
||||
Two separate hazards meet here, and fixing one naively breaks the other:
|
||||
|
||||
- Wiping ordinary db models un-serves EVERY db model for the width of the
|
||||
reload. The reconcile converges without that, so the wipe is a pure
|
||||
data-plane hole.
|
||||
- NOT wiping a db auto-router strands it. Its strategy registries are keyed by
|
||||
model_name and are popped here, but Router.upsert_deployment returns early
|
||||
for an unchanged deployment and never reaches the add path that rebuilds
|
||||
them. Any unrelated model write would then leave every db-backed auto,
|
||||
complexity, adaptive and quality router unroutable until a restart.
|
||||
|
||||
So the wipe is scoped to exactly the auto-router deployments.
|
||||
"""
|
||||
from litellm.proxy.management_endpoints.model_management_endpoints import (
|
||||
clear_cache,
|
||||
)
|
||||
|
||||
mock_router = MagicMock()
|
||||
mock_router.model_list = [
|
||||
{
|
||||
"model_name": "ordinary-db-model",
|
||||
"model_info": {"id": "db-ordinary-1", "db_model": True},
|
||||
"litellm_params": {"model": "openai/gpt-4o"},
|
||||
},
|
||||
{
|
||||
"model_name": "db-auto-router",
|
||||
"model_info": {"id": "db-auto-1", "db_model": True},
|
||||
"litellm_params": {"model": "auto_router/db-auto-router"},
|
||||
},
|
||||
]
|
||||
mock_router.delete_deployment = MagicMock(return_value=True)
|
||||
mock_router.auto_routers = {"db-auto-router": MagicMock()}
|
||||
mock_router.complexity_routers = {}
|
||||
mock_router.adaptive_routers = {}
|
||||
mock_router.quality_routers = {}
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config._add_deployment_locked = AsyncMock(
|
||||
return_value=ReconcileOutcome(still_desired=frozenset(), live_after=frozenset())
|
||||
)
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.llm_router", mock_router),
|
||||
patch("litellm.proxy.proxy_server.proxy_config", mock_config),
|
||||
patch("litellm.proxy.proxy_server.prisma_client", MagicMock()),
|
||||
patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock()),
|
||||
patch("litellm.proxy.proxy_server.verbose_proxy_logger"),
|
||||
):
|
||||
await clear_cache()
|
||||
|
||||
# The auto-router deployment is wiped so the reload takes the add path and
|
||||
# rebuilds its strategy entry; the ordinary db model is never touched.
|
||||
mock_router.delete_deployment.assert_called_once_with(id="db-auto-1")
|
||||
assert "db-auto-router" not in mock_router.auto_routers
|
||||
|
||||
|
||||
class TestClearCachePreservesConfigRouters:
|
||||
"""
|
||||
|
|
@ -534,7 +604,9 @@ class TestClearCachePreservesConfigRouters:
|
|||
}
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.add_deployment = AsyncMock(return_value=True)
|
||||
mock_config._add_deployment_locked = AsyncMock(
|
||||
return_value=ReconcileOutcome(still_desired=frozenset(), live_after=frozenset())
|
||||
)
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.llm_router", mock_router),
|
||||
|
|
@ -574,7 +646,9 @@ class TestClearCachePreservesConfigRouters:
|
|||
mock_router.complexity_routers = {"shared-name": MagicMock()}
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.add_deployment = AsyncMock(return_value=True)
|
||||
mock_config._add_deployment_locked = AsyncMock(
|
||||
return_value=ReconcileOutcome(still_desired=frozenset(), live_after=frozenset())
|
||||
)
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.llm_router", mock_router),
|
||||
|
|
@ -616,7 +690,9 @@ class TestClearCachePreservesConfigRouters:
|
|||
mock_router.adaptive_routers = {"a1": MagicMock()}
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.add_deployment = AsyncMock(return_value=True)
|
||||
mock_config._add_deployment_locked = AsyncMock(
|
||||
return_value=ReconcileOutcome(still_desired=frozenset(), live_after=frozenset())
|
||||
)
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.llm_router", mock_router),
|
||||
|
|
@ -839,7 +915,9 @@ class TestUpdateModel:
|
|||
),
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.model_management_endpoints.clear_cache",
|
||||
new=AsyncMock(return_value=None),
|
||||
new=AsyncMock(
|
||||
return_value=ReconcileOutcome(still_desired=None, live_after=None)
|
||||
),
|
||||
) as mock_clear_cache,
|
||||
):
|
||||
await update_model(
|
||||
|
|
@ -1885,7 +1963,9 @@ class TestAddAndDeleteModelLifecycle:
|
|||
mock_prisma.db.litellm_proxymodeltable.delete = AsyncMock(return_value=db_row)
|
||||
|
||||
mock_proxy_config = MagicMock()
|
||||
mock_proxy_config.add_deployment = AsyncMock()
|
||||
mock_proxy_config._add_deployment_locked = AsyncMock(
|
||||
return_value=ReconcileOutcome(still_desired=frozenset(), live_after=frozenset())
|
||||
)
|
||||
|
||||
mock_router = MagicMock()
|
||||
mock_router.delete_deployment = MagicMock()
|
||||
|
|
@ -3223,7 +3303,9 @@ class TestPatchModelBlockedAuthGate:
|
|||
),
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.model_management_endpoints.clear_cache",
|
||||
new=AsyncMock(return_value=None),
|
||||
new=AsyncMock(
|
||||
return_value=ReconcileOutcome(still_desired=None, live_after=None)
|
||||
),
|
||||
),
|
||||
):
|
||||
result = await patch_model(
|
||||
|
|
@ -3381,6 +3463,281 @@ class TestWriteSurfacesReloadDrop:
|
|||
)
|
||||
|
||||
|
||||
class TestConcurrentModelWritesDoNotEvictEachOther:
|
||||
"""Two model writes racing on one pod must not un-serve each other's deployments,
|
||||
and neither may report the other's in-flight reload as damage of its own.
|
||||
|
||||
The reconcile is a read-modify-write of the shared ``llm_router`` global: read the db
|
||||
into a snapshot, then make the router match that snapshot. Unserialized, the request
|
||||
holding the older snapshot deletes the deployment the newer one just added, because
|
||||
_delete_deployment evicts every live id absent from the snapshot it was handed. The
|
||||
row survives in the db, so the damage is invisible there -- the pod just stops
|
||||
serving a model it was told to serve.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconciles_serialize_so_no_stale_snapshot_can_evict(self, monkeypatch):
|
||||
"""MODEL_RECONCILE_LOCK admits one reconcile at a time.
|
||||
|
||||
The fake body awaits, which is the whole point: without the lock the gather below
|
||||
parks all five inside the critical section at that await and observed depth goes
|
||||
to 5. Asserting depth never exceeds 1 is what pins the fix -- deleting the
|
||||
`async with` makes this fail rather than merely getting slower.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from litellm.proxy._types import ReconcileOutcome
|
||||
from litellm.proxy.proxy_server import ProxyConfig
|
||||
|
||||
depth = 0
|
||||
observed_max = 0
|
||||
|
||||
async def fake_locked(self, **kwargs):
|
||||
nonlocal depth, observed_max
|
||||
depth += 1
|
||||
observed_max = max(observed_max, depth)
|
||||
await asyncio.sleep(0)
|
||||
depth -= 1
|
||||
return ReconcileOutcome(still_desired=frozenset(), live_after=frozenset())
|
||||
|
||||
monkeypatch.setattr(ProxyConfig, "_add_deployment_locked", fake_locked)
|
||||
config = ProxyConfig()
|
||||
|
||||
await asyncio.gather(
|
||||
*[
|
||||
config.add_deployment(prisma_client=MagicMock(), proxy_logging_obj=MagicMock())
|
||||
for _ in range(5)
|
||||
]
|
||||
)
|
||||
|
||||
assert observed_max == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_cache_reloads_under_the_lock_without_deadlocking(self, monkeypatch):
|
||||
"""clear_cache un-serves every db model before reloading, so it has to hold the
|
||||
lock across the pair -- and therefore must call the already-locked reload.
|
||||
|
||||
asyncio.Lock is not reentrant: routing this back through the public
|
||||
add_deployment would block forever on a lock this coroutine already owns, taking
|
||||
every model write on the pod down with it. The timeout is the assertion.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import litellm
|
||||
from litellm.proxy._types import ReconcileOutcome
|
||||
from litellm.proxy.management_endpoints.model_management_endpoints import clear_cache
|
||||
from litellm.proxy.proxy_server import ProxyConfig
|
||||
|
||||
live_router = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "gpt-4o",
|
||||
"litellm_params": {"model": "gpt-4o"},
|
||||
"model_info": {"id": "m-db", "db_model": True},
|
||||
}
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", live_router)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", MagicMock())
|
||||
|
||||
async def fake_locked(self, **kwargs):
|
||||
return ReconcileOutcome(still_desired=frozenset({"m-db"}), live_after=frozenset({"m-db"}))
|
||||
|
||||
monkeypatch.setattr(ProxyConfig, "_add_deployment_locked", fake_locked)
|
||||
|
||||
outcome = await asyncio.wait_for(clear_cache(), timeout=5)
|
||||
|
||||
assert outcome.still_desired == frozenset({"m-db"})
|
||||
assert outcome.live_after == frozenset({"m-db"})
|
||||
|
||||
def test_verdict_trusts_the_lock_captured_snapshot_over_a_live_reread(self, monkeypatch):
|
||||
"""Given live_after, the verdict judges the router as it stood when the reload
|
||||
finished -- not as it stands now.
|
||||
|
||||
Re-reading here would sample the router after the lock was released, which is
|
||||
exactly where the next writer's clear_cache has every db model deleted and not
|
||||
yet re-added. That hole is another request's in-flight state; blaming this
|
||||
request's reload for it is the 500 that made concurrent model creates fail.
|
||||
"""
|
||||
import litellm
|
||||
from litellm.proxy._types import ProxyException
|
||||
from litellm.proxy.management_endpoints.model_management_endpoints import (
|
||||
raise_if_reload_degraded_serving,
|
||||
reload_serving_verdict,
|
||||
)
|
||||
|
||||
# The router as another writer's clear_cache leaves it mid-wipe: db models gone.
|
||||
mid_wipe_router = litellm.Router(model_list=[])
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", mid_wipe_router)
|
||||
|
||||
healthy_after_reload = frozenset({"m-live", "m-neighbour"})
|
||||
|
||||
_, collateral = reload_serving_verdict(
|
||||
before=frozenset({"m-live", "m-neighbour"}),
|
||||
written_models=[("m-live", None)],
|
||||
written_must_serve=True,
|
||||
still_desired=healthy_after_reload,
|
||||
live_after=healthy_after_reload,
|
||||
)
|
||||
assert collateral == ()
|
||||
|
||||
assert (
|
||||
raise_if_reload_degraded_serving(
|
||||
before=frozenset({"m-live", "m-neighbour"}),
|
||||
written_models=[("m-live", None)],
|
||||
action="create",
|
||||
still_desired=healthy_after_reload,
|
||||
live_after=healthy_after_reload,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
# Same inputs, no lock-captured snapshot: the mid-wipe router is read live and
|
||||
# the neighbour looks like collateral. This is the pre-fix behaviour, kept to
|
||||
# show the parameter is what carries the difference.
|
||||
with pytest.raises(ProxyException, match="m-neighbour"):
|
||||
raise_if_reload_degraded_serving(
|
||||
before=frozenset({"m-live", "m-neighbour"}),
|
||||
written_models=[("m-live", None)],
|
||||
action="create",
|
||||
still_desired=healthy_after_reload,
|
||||
)
|
||||
|
||||
|
||||
class TestDeleteEvictionsHoldTheReconcileLock:
|
||||
"""A delete evicts from ``llm_router`` directly instead of reconciling, so it must
|
||||
take MODEL_RECONCILE_LOCK to do it.
|
||||
|
||||
The db row is gone by then, but a reconcile that snapshotted the db BEFORE the row
|
||||
was deleted still lists that id as desired, and its ``_add_deployment`` upserts the
|
||||
deployment back. Unserialized, the eviction can land while that reconcile is
|
||||
mid-flight and simply be undone -- the pod keeps serving a model the database no
|
||||
longer has, until the next reconcile happens to notice. Taking the lock orders the
|
||||
eviction after any in-flight reconcile, making it the last word.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def _assert_evicts_under_lock(monkeypatch, call_endpoint, model_id: str) -> None:
|
||||
"""Run ``call_endpoint`` with the lock already held and assert it blocks.
|
||||
|
||||
Holding MODEL_RECONCILE_LOCK stands in for a reconcile that is mid-flight. If
|
||||
the eviction takes the lock it cannot run until we release; if it does not, it
|
||||
runs straight through and the deployment is evicted while the "reconcile" is
|
||||
still in its critical section -- exactly the interleaving that resurrects it.
|
||||
|
||||
Each test gets a FRESH lock. asyncio.Lock binds itself to the event loop of its
|
||||
first contended acquire and raises on every other loop afterwards, so a shared
|
||||
module-level lock contended here would poison the next asyncio test in this
|
||||
process. The proxy has one event loop for its lifetime, so this is a test-only
|
||||
concern -- but it means any future test that contends this lock must patch its
|
||||
own, exactly as here.
|
||||
"""
|
||||
lock = asyncio.Lock()
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.MODEL_RECONCILE_LOCK", lock)
|
||||
|
||||
async with lock:
|
||||
task = asyncio.create_task(call_endpoint())
|
||||
# Give the endpoint every chance to reach (and get stuck on) the lock.
|
||||
for _ in range(50):
|
||||
await asyncio.sleep(0)
|
||||
assert not task.done(), (
|
||||
f"deleting {model_id} did not wait for MODEL_RECONCILE_LOCK -- an "
|
||||
f"in-flight reconcile can resurrect the deployment it just evicted"
|
||||
)
|
||||
await asyncio.wait_for(task, timeout=5)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_model_waits_for_an_in_flight_reconcile(self, monkeypatch):
|
||||
from litellm.proxy.management_endpoints.model_management_endpoints import (
|
||||
ModelInfoDelete,
|
||||
delete_model,
|
||||
)
|
||||
|
||||
model_id = "m-doomed"
|
||||
row = MagicMock()
|
||||
row.model_dump.return_value = {
|
||||
"model_name": "gpt-4o",
|
||||
"litellm_params": {"model": "openai/gpt-4o"},
|
||||
"model_info": {"id": model_id},
|
||||
}
|
||||
table = MagicMock()
|
||||
table.find_unique = AsyncMock(return_value=row)
|
||||
table.delete = AsyncMock(return_value=row)
|
||||
|
||||
prisma = MagicMock()
|
||||
prisma.db.litellm_proxymodeltable = table
|
||||
|
||||
router = MagicMock()
|
||||
router.delete_deployment = MagicMock(return_value=True)
|
||||
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", prisma)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", router)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", True)
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.management_endpoints.model_management_endpoints.ModelManagementAuthChecks.can_user_make_model_call",
|
||||
AsyncMock(return_value=True),
|
||||
)
|
||||
|
||||
async def call() -> None:
|
||||
await delete_model(
|
||||
model_info=ModelInfoDelete(id=model_id),
|
||||
user_api_key_dict=UserAPIKeyAuth(
|
||||
user_id="admin",
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN,
|
||||
api_key="sk-admin",
|
||||
),
|
||||
)
|
||||
|
||||
await self._assert_evicts_under_lock(monkeypatch, call, model_id)
|
||||
router.delete_deployment.assert_called_once_with(id=model_id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_team_models_waits_for_an_in_flight_reconcile(self, monkeypatch):
|
||||
from litellm.proxy.management_endpoints.model_management_endpoints import (
|
||||
delete_team_models,
|
||||
)
|
||||
|
||||
model_id = "m-team-doomed"
|
||||
router = MagicMock()
|
||||
router.delete_deployment = MagicMock(return_value=True)
|
||||
|
||||
# _get_team_deployments filters by the model_name prefix, then confirms
|
||||
# model_info["team_id"] Python-side, so the row must satisfy both.
|
||||
deleted_row = MagicMock()
|
||||
deleted_row.model_id = model_id
|
||||
deleted_row.model_name = "model_name_team-1_gpt-4o"
|
||||
deleted_row.model_info = {"id": model_id, "team_id": "team-1"}
|
||||
|
||||
tx = MagicMock()
|
||||
tx.litellm_proxymodeltable.find_many = AsyncMock(return_value=[deleted_row])
|
||||
tx.litellm_proxymodeltable.delete_many = AsyncMock(return_value=1)
|
||||
|
||||
tx_ctx = MagicMock()
|
||||
tx_ctx.__aenter__ = AsyncMock(return_value=tx)
|
||||
tx_ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
prisma = MagicMock()
|
||||
prisma.db.tx = MagicMock(return_value=tx_ctx)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.management_endpoints.model_management_endpoints.publish_config_change",
|
||||
AsyncMock(return_value=None),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.management_endpoints.model_management_endpoints.coordination_redis_cache",
|
||||
MagicMock(return_value=None),
|
||||
)
|
||||
|
||||
async def call() -> None:
|
||||
await delete_team_models(
|
||||
team_ids=["team-1"], prisma_client=prisma, llm_router=router
|
||||
)
|
||||
|
||||
await self._assert_evicts_under_lock(monkeypatch, call, model_id)
|
||||
router.delete_deployment.assert_called_once_with(id=model_id)
|
||||
|
||||
|
||||
class TestModelInfoAsMapping:
|
||||
"""The model_info column reaches consumers as a dict or as its JSON string; this is
|
||||
the single owner of that parse, and None means no usable mapping."""
|
||||
|
|
|
|||
|
|
@ -8,7 +8,12 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from litellm.proxy._types import LiteLLM_ProxyModelTable, LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy._types import (
|
||||
LiteLLM_ProxyModelTable,
|
||||
LitellmUserRoles,
|
||||
ReconcileOutcome,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.model_management_endpoints import (
|
||||
_merged_ptu_model_info,
|
||||
_raise_if_ptu_cost_attribution_disabled,
|
||||
|
|
@ -623,7 +628,12 @@ class TestAddNewModelPtuGate:
|
|||
add_team_model_to_db = AsyncMock(return_value=db_row)
|
||||
|
||||
mock_proxy_config = MagicMock()
|
||||
mock_proxy_config.add_deployment = AsyncMock(return_value=None)
|
||||
# Both fields None: no reconcile state was captured, so the serving verdict
|
||||
# falls back to reading the router live -- which is what mock_router below
|
||||
# drives. These tests are about the PTU gate, not the reload verdict.
|
||||
mock_proxy_config.add_deployment = AsyncMock(
|
||||
return_value=ReconcileOutcome(still_desired=None, live_after=None)
|
||||
)
|
||||
|
||||
mock_router = MagicMock()
|
||||
mock_router.get_model_ids.return_value = [model_id]
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from litellm.proxy._types import (
|
|||
BlockModelRequest,
|
||||
LitellmUserRoles,
|
||||
ProxyException,
|
||||
ReconcileOutcome,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.types.router import RouterRateLimitError
|
||||
|
|
@ -36,7 +37,12 @@ def _setup_model_block_mocks(monkeypatch, *, updated_blocked: bool):
|
|||
mock_router = MagicMock()
|
||||
mock_router.get_model_ids.return_value = [model_id]
|
||||
|
||||
mock_clear_cache = AsyncMock(return_value=None)
|
||||
# No reconcile ran in these tests, so both fields are None and the verdict falls
|
||||
# back to reading the router live -- which is what the get_model_ids side_effects
|
||||
# below drive.
|
||||
mock_clear_cache = AsyncMock(
|
||||
return_value=ReconcileOutcome(still_desired=None, live_after=None)
|
||||
)
|
||||
mock_audit_log = AsyncMock(return_value=None)
|
||||
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue