From c11ebbed27a7b2871b0741af67504aafd7407e8e Mon Sep 17 00:00:00 2001 From: mateo Date: Wed, 12 Aug 2026 23:45:36 +0000 Subject: [PATCH 01/31] fix(batches): stop uncostable batches from starving the cost poll page Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../proxy/common_utils/check_batch_cost.py | 89 ++++++++- .../proxy_unit_tests/test_check_batch_cost.py | 180 ++++++++++++++++++ 2 files changed, 265 insertions(+), 4 deletions(-) diff --git a/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py b/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py index dc8f17fb665..73d5fef08e0 100644 --- a/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py +++ b/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py @@ -3,7 +3,7 @@ Polls LiteLLM_ManagedObjectTable to check if the batch job is complete, and if t """ from datetime import datetime, timedelta, timezone -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple +from typing import TYPE_CHECKING, Any, Dict, Final, List, Optional, Tuple from litellm._logging import verbose_proxy_logger from litellm._uuid import uuid @@ -23,6 +23,15 @@ if TYPE_CHECKING: CHECK_BATCH_COST_USER_AGENT = "LiteLLM Proxy/CheckBatchCost" +TERMINAL_MANAGED_OBJECT_STATUSES: Final[Tuple[str, ...]] = ( + "completed", + "complete", + "failed", + "expired", + "cancelled", + "stale_expired", +) + class CheckBatchCost: def __init__( @@ -132,11 +141,11 @@ class CheckBatchCost: in non-terminal states as 'stale_expired'. These will never complete and should not be polled. """ - cutoff = datetime.now(timezone.utc) - timedelta(days=MANAGED_OBJECT_STALENESS_CUTOFF_DAYS) - result = await self.prisma_client.db.litellm_managedobjecttable.update_many( + cutoff: Final = datetime.now(timezone.utc) - timedelta(days=MANAGED_OBJECT_STALENESS_CUTOFF_DAYS) + result: Final = await self.prisma_client.db.litellm_managedobjecttable.update_many( where={ "file_purpose": "batch", - "status": {"not_in": ["completed", "complete", "failed", "expired", "cancelled", "stale_expired"]}, + "status": {"not_in": list(TERMINAL_MANAGED_OBJECT_STATUSES)}, "created_at": {"lt": cutoff}, }, data={"status": "stale_expired"}, @@ -147,6 +156,26 @@ class CheckBatchCost: f"(older than {MANAGED_OBJECT_STALENESS_CUTOFF_DAYS} days) as stale_expired" ) + if not self._has_batch_processed_column: + return + + # A row already in a terminal status is never rewritten by the sweep above, so + # without this it keeps a poll-page slot forever and starves newer batches. + retired: Final = await self.prisma_client.db.litellm_managedobjecttable.update_many( + where={ + "file_purpose": "batch", + "batch_processed": False, + "status": {"in": ["complete", "completed"]}, + "created_at": {"lt": cutoff}, + }, + data={"batch_processed": True}, + ) + if retired > 0: + verbose_proxy_logger.warning( + f"CheckBatchCost: gave up on {retired} completed managed objects older than " + f"{MANAGED_OBJECT_STALENESS_CUTOFF_DAYS} days that were never costed" + ) + async def _fallback_find_jobs(self) -> list: """Query batch jobs without the batch_processed filter (for older schemas).""" return await self.prisma_client.db.litellm_managedobjecttable.find_many( @@ -167,6 +196,54 @@ class CheckBatchCost: order={"created_at": "asc"}, ) + async def _retire_job(self, job: "LiteLLM_ManagedObjectTable", reason: str) -> None: + """ + Take a row that can never be costed out of the poll page. Leaving it selectable + would burn one of the MAX_OBJECTS_PER_POLL_CYCLE slots on every future cycle, and + once enough such rows accumulate no newer batch is ever reached. Older schemas + without batch_processed can only be excluded through the status filter. + """ + data: Final = ( + {"batch_processed": True} + if self._has_batch_processed_column + else {"status": "stale_expired"} + ) + try: + await self.prisma_client.db.litellm_managedobjecttable.update( + where={"id": job.id}, + data=data, + ) + except Exception as db_err: + verbose_proxy_logger.error( + f"CheckBatchCost: failed to retire uncostable job {job.id} ({reason}): {db_err}" + ) + return + verbose_proxy_logger.warning( + f"CheckBatchCost: job {job.id} can never be costed ({reason}), " + "so it will no longer be polled" + ) + + @staticmethod + def _has_unified_id_without_model(job: "LiteLLM_ManagedObjectTable") -> bool: + """A unified id that decodes but carries no model_id can never be routed.""" + from litellm.proxy.openai_files_endpoints.common_utils import ( + _is_base64_encoded_unified_file_id, + get_model_id_from_unified_batch_id, + ) + + decoded: Final = _is_base64_encoded_unified_file_id(job.unified_object_id) + return bool(decoded) and get_model_id_from_unified_batch_id(decoded) is None + + @staticmethod + def _is_batch_gone_at_provider(error: Exception) -> bool: + """A 404 from the provider means it dropped its record of the batch, so no later + retrieve can ever succeed.""" + import openai + + from litellm.exceptions import NotFoundError + + return isinstance(error, (NotFoundError, openai.NotFoundError)) + @staticmethod def _record_error( prom_logger: Optional["PrometheusLogger"], error_type: str @@ -645,6 +722,8 @@ class CheckBatchCost: for job in jobs: routing = self._resolve_job_routing(job, prom_logger) if routing is None: + if self._has_unified_id_without_model(job): + await self._retire_job(job, "unified object id has no model id") continue model_id, batch_id = routing @@ -667,6 +746,8 @@ class CheckBatchCost: ) if prom_logger: prom_logger.record_check_batch_cost_error("provider_retrieval_error") + if self._is_batch_gone_at_provider(e): + await self._retire_job(job, f"batch {batch_id} no longer exists at the provider") continue ## RETRIEVE THE BATCH JOB OUTPUT FILE diff --git a/tests/proxy_unit_tests/test_check_batch_cost.py b/tests/proxy_unit_tests/test_check_batch_cost.py index 6d7ada17ec5..0418bff0cc3 100644 --- a/tests/proxy_unit_tests/test_check_batch_cost.py +++ b/tests/proxy_unit_tests/test_check_batch_cost.py @@ -1791,3 +1791,183 @@ class TestBatchCostAttribution: metadata = await instance._build_creator_attribution_metadata(self._job(), "batch-1") assert metadata["user_api_key_alias"] == "prod-key" + + +class TestPollPageStarvation: + """LIT-5462 regression: a row that can never be costed used to keep its slot in the + MAX_OBJECTS_PER_POLL_CYCLE page forever, so once enough of them accumulated no newer + batch was ever polled or costed.""" + + def _instance(self, prisma, llm_router): + from litellm_enterprise.proxy.common_utils.check_batch_cost import CheckBatchCost + + proxy_logging_obj = MagicMock() + proxy_logging_obj.get_proxy_hook.return_value = None + return CheckBatchCost( + proxy_logging_obj=proxy_logging_obj, + prisma_client=prisma, + llm_router=llm_router, + ) + + def _prisma(self, jobs): + prisma = MagicMock() + prisma.db.litellm_managedobjecttable.update_many = AsyncMock(return_value=0) + prisma.db.litellm_managedobjecttable.update = AsyncMock() + prisma.db.litellm_managedobjecttable.find_many = AsyncMock(return_value=jobs) + prisma.db.litellm_usertable.find_unique = AsyncMock(return_value=None) + return prisma + + def _job(self, job_id, unified_object_id): + job = MagicMock() + job.id = job_id + job.unified_object_id = unified_object_id + job.created_by = "user-1" + return job + + @staticmethod + def _encode(unified_id: str) -> str: + import base64 + + return base64.urlsafe_b64encode(unified_id.encode()).decode().rstrip("=") + + @pytest.mark.asyncio + async def test_unified_id_without_model_id_is_retired(self): + """A unified id that decodes but carries no model_id is unroutable no matter what + the config says, so it must leave the poll page instead of being retried forever.""" + prisma = self._prisma( + [self._job("job-no-model", self._encode("litellm_proxy;llm_batch_id:poison-no-model"))] + ) + llm_router = MagicMock() + llm_router.aretrieve_batch = AsyncMock() + + await self._instance(prisma, llm_router).check_batch_cost() + + llm_router.aretrieve_batch.assert_not_awaited() + prisma.db.litellm_managedobjecttable.update.assert_awaited_once() + call = prisma.db.litellm_managedobjecttable.update.call_args[1] + assert call["where"] == {"id": "job-no-model"} + assert call["data"] == {"batch_processed": True} + + @pytest.mark.asyncio + async def test_provider_404_retires_job(self): + """The provider dropping its record of the batch is permanent: no later retrieve + can succeed, so the row must stop occupying a slot.""" + import litellm + + prisma = self._prisma( + [ + self._job( + "job-gone", + self._encode("litellm_proxy;model_id:model-123;llm_batch_id:batch_deadbeef"), + ) + ] + ) + llm_router = MagicMock() + llm_router.aretrieve_batch = AsyncMock( + side_effect=litellm.NotFoundError( + message="No batch found with id 'batch_deadbeef'.", + model="model-123", + llm_provider="openai", + ) + ) + + await self._instance(prisma, llm_router).check_batch_cost() + + prisma.db.litellm_managedobjecttable.update.assert_awaited_once() + assert prisma.db.litellm_managedobjecttable.update.call_args[1]["data"] == { + "batch_processed": True + } + + @pytest.mark.asyncio + async def test_transient_provider_error_keeps_job_for_retry(self): + """A failure that may clear up (timeout, 5xx) must still leave the row unprocessed.""" + prisma = self._prisma( + [ + self._job( + "job-flaky", + self._encode("litellm_proxy;model_id:model-123;llm_batch_id:batch_flaky"), + ) + ] + ) + llm_router = MagicMock() + llm_router.aretrieve_batch = AsyncMock(side_effect=Exception("connection reset")) + + await self._instance(prisma, llm_router).check_batch_cost() + + prisma.db.litellm_managedobjecttable.update.assert_not_awaited() + + @pytest.mark.asyncio + async def test_retirement_falls_back_to_status_without_batch_processed_column(self): + """Older schemas have no batch_processed column, so the only way to stop selecting + the row is the status filter the poll query already applies.""" + prisma = self._prisma( + [self._job("job-legacy", self._encode("litellm_proxy;llm_batch_id:poison-no-model"))] + ) + instance = self._instance(prisma, MagicMock()) + instance._has_batch_processed_column = False + + await instance.check_batch_cost() + + assert prisma.db.litellm_managedobjecttable.update.call_args[1]["data"] == { + "status": "stale_expired" + } + + @pytest.mark.asyncio + async def test_stale_cleanup_gives_up_on_never_costed_completed_rows(self): + """A row already in a terminal status is never rewritten by the staleness sweep, so + it needs its own bound or it starves newer batches indefinitely.""" + prisma = self._prisma([]) + + await self._instance(prisma, MagicMock()).check_batch_cost() + + calls = prisma.db.litellm_managedobjecttable.update_many.call_args_list + assert len(calls) == 2, "expected the staleness sweep plus the never-costed sweep" + where = calls[1][1]["where"] + assert where["file_purpose"] == "batch" + assert where["batch_processed"] is False + assert where["status"] == {"in": ["complete", "completed"]} + assert "created_at" in where + assert calls[1][1]["data"] == {"batch_processed": True} + + @pytest.mark.asyncio + async def test_newer_batch_is_polled_once_dead_rows_are_retired(self): + """The end state the customer cares about: dead rows retire on the cycle they are + first seen, and the healthy batch behind them keeps getting polled.""" + dead_rows = [ + self._job("job-no-model", self._encode("litellm_proxy;llm_batch_id:poison-no-model")), + self._job( + "job-gone", + self._encode("litellm_proxy;model_id:model-123;llm_batch_id:batch_deadbeef"), + ), + ] + live_row = self._job( + "job-live", + self._encode("litellm_proxy;model_id:model-123;llm_batch_id:batch_live"), + ) + prisma = self._prisma(dead_rows + [live_row]) + + import litellm + + in_progress = MagicMock() + in_progress.status = "in_progress" + + async def _retrieve(model, batch_id, litellm_metadata): + if batch_id == "batch_deadbeef": + raise litellm.NotFoundError( + message="No batch found", model=model, llm_provider="openai" + ) + return in_progress + + llm_router = MagicMock() + llm_router.aretrieve_batch = AsyncMock(side_effect=_retrieve) + + await self._instance(prisma, llm_router).check_batch_cost() + + retired = [ + call[1]["where"]["id"] + for call in prisma.db.litellm_managedobjecttable.update.call_args_list + ] + assert retired == ["job-no-model", "job-gone"] + assert ( + llm_router.aretrieve_batch.await_args_list[-1][1]["batch_id"] == "batch_live" + ), "the newer healthy batch must still be polled in the same cycle" From da8414228831c36605bee568f3c90cc0d1c7b3f9 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:19:37 -0700 Subject: [PATCH 02/31] fix(batches): only trust a 404 from the batch's own deployment --- .../proxy/common_utils/check_batch_cost.py | 9 +++++- .../proxy_unit_tests/test_check_batch_cost.py | 29 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py b/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py index 73d5fef08e0..44371c8299a 100644 --- a/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py +++ b/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py @@ -244,6 +244,13 @@ class CheckBatchCost: return isinstance(error, (NotFoundError, openai.NotFoundError)) + def _batch_deployment_exists(self, model_id: str) -> bool: + """A 404 only proves the batch is gone when it came from the batch's own + deployment. Once that deployment leaves the router, default fallbacks can + silently send the retrieve to a provider that never saw the batch, so its + 404 must not retire the row; the staleness sweep bounds it instead.""" + return self.llm_router.get_deployment(model_id=model_id) is not None + @staticmethod def _record_error( prom_logger: Optional["PrometheusLogger"], error_type: str @@ -746,7 +753,7 @@ class CheckBatchCost: ) if prom_logger: prom_logger.record_check_batch_cost_error("provider_retrieval_error") - if self._is_batch_gone_at_provider(e): + if self._is_batch_gone_at_provider(e) and self._batch_deployment_exists(model_id): await self._retire_job(job, f"batch {batch_id} no longer exists at the provider") continue diff --git a/tests/proxy_unit_tests/test_check_batch_cost.py b/tests/proxy_unit_tests/test_check_batch_cost.py index 0418bff0cc3..fc3743a0490 100644 --- a/tests/proxy_unit_tests/test_check_batch_cost.py +++ b/tests/proxy_unit_tests/test_check_batch_cost.py @@ -1878,6 +1878,35 @@ class TestPollPageStarvation: "batch_processed": True } + @pytest.mark.asyncio + async def test_provider_404_with_deployment_gone_keeps_job(self): + """With the batch's own deployment removed from the router, default fallbacks can + send the retrieve to a provider that never saw the batch. That 404 proves nothing, + so the row must stay unprocessed instead of losing its spend forever.""" + import litellm + + prisma = self._prisma( + [ + self._job( + "job-misrouted", + self._encode("litellm_proxy;model_id:model-gone;llm_batch_id:batch_alive"), + ) + ] + ) + llm_router = MagicMock() + llm_router.get_deployment = MagicMock(return_value=None) + llm_router.aretrieve_batch = AsyncMock( + side_effect=litellm.NotFoundError( + message="No batch found with id 'batch_alive'.", + model="model-gone", + llm_provider="openai", + ) + ) + + await self._instance(prisma, llm_router).check_batch_cost() + + prisma.db.litellm_managedobjecttable.update.assert_not_awaited() + @pytest.mark.asyncio async def test_transient_provider_error_keeps_job_for_retry(self): """A failure that may clear up (timeout, 5xx) must still leave the row unprocessed.""" From 8947008fd2a9fbb461e18fbfbe2bd7c072c832f2 Mon Sep 17 00:00:00 2001 From: mateo Date: Thu, 13 Aug 2026 04:22:23 +0000 Subject: [PATCH 03/31] fix(batches): only retire on a 404 that names the batch Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../proxy/common_utils/check_batch_cost.py | 14 ++++++--- .../proxy_unit_tests/test_check_batch_cost.py | 31 ++++++++++++++++++- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py b/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py index 44371c8299a..818c44d2039 100644 --- a/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py +++ b/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py @@ -235,14 +235,18 @@ class CheckBatchCost: return bool(decoded) and get_model_id_from_unified_batch_id(decoded) is None @staticmethod - def _is_batch_gone_at_provider(error: Exception) -> bool: - """A 404 from the provider means it dropped its record of the batch, so no later - retrieve can ever succeed.""" + def _is_batch_gone_at_provider(error: Exception, batch_id: str) -> bool: + """ + A 404 naming the batch means the provider dropped its record of it, so no later + retrieve can ever succeed. A 404 about anything else, a renamed Azure deployment + or a fallback deployment that never saw this batch, is still fixable in config, so + it keeps retrying. + """ import openai from litellm.exceptions import NotFoundError - return isinstance(error, (NotFoundError, openai.NotFoundError)) + return isinstance(error, (NotFoundError, openai.NotFoundError)) and batch_id in str(error) def _batch_deployment_exists(self, model_id: str) -> bool: """A 404 only proves the batch is gone when it came from the batch's own @@ -753,7 +757,7 @@ class CheckBatchCost: ) if prom_logger: prom_logger.record_check_batch_cost_error("provider_retrieval_error") - if self._is_batch_gone_at_provider(e) and self._batch_deployment_exists(model_id): + if self._is_batch_gone_at_provider(e, batch_id) and self._batch_deployment_exists(model_id): await self._retire_job(job, f"batch {batch_id} no longer exists at the provider") continue diff --git a/tests/proxy_unit_tests/test_check_batch_cost.py b/tests/proxy_unit_tests/test_check_batch_cost.py index fc3743a0490..fa274324fd6 100644 --- a/tests/proxy_unit_tests/test_check_batch_cost.py +++ b/tests/proxy_unit_tests/test_check_batch_cost.py @@ -1983,7 +1983,9 @@ class TestPollPageStarvation: async def _retrieve(model, batch_id, litellm_metadata): if batch_id == "batch_deadbeef": raise litellm.NotFoundError( - message="No batch found", model=model, llm_provider="openai" + message=f"No batch found with id '{batch_id}'.", + model=model, + llm_provider="openai", ) return in_progress @@ -2000,3 +2002,30 @@ class TestPollPageStarvation: assert ( llm_router.aretrieve_batch.await_args_list[-1][1]["batch_id"] == "batch_live" ), "the newer healthy batch must still be polled in the same cycle" + + @pytest.mark.asyncio + async def test_404_that_does_not_name_the_batch_keeps_job_for_retry(self): + """A 404 about something other than the batch, e.g. a renamed Azure deployment, is + fixable in config, so the row must survive to be costed after the fix.""" + import litellm + + prisma = self._prisma( + [ + self._job( + "job-bad-deployment", + self._encode("litellm_proxy;model_id:model-123;llm_batch_id:batch_real"), + ) + ] + ) + llm_router = MagicMock() + llm_router.aretrieve_batch = AsyncMock( + side_effect=litellm.NotFoundError( + message="Error code: 404 - DeploymentNotFound", + model="model-123", + llm_provider="azure", + ) + ) + + await self._instance(prisma, llm_router).check_batch_cost() + + prisma.db.litellm_managedobjecttable.update.assert_not_awaited() From 63464974980e825ec226052d8ed2d786c8dde975 Mon Sep 17 00:00:00 2001 From: yassin Date: Thu, 13 Aug 2026 13:01:40 +0000 Subject: [PATCH 04/31] fix(ui): add nvidia riva to the model provider list Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../provider_create_fields.json | 38 ++++++++++ .../public_endpoints/test_public_endpoints.py | 33 +++++++++ .../components/provider_info_helpers.test.tsx | 15 ++++ .../src/components/provider_info_helpers.tsx | 69 ++++++++----------- 4 files changed, 113 insertions(+), 42 deletions(-) diff --git a/litellm/proxy/public_endpoints/provider_create_fields.json b/litellm/proxy/public_endpoints/provider_create_fields.json index fcc6aac1c14..e24e5b21583 100644 --- a/litellm/proxy/public_endpoints/provider_create_fields.json +++ b/litellm/proxy/public_endpoints/provider_create_fields.json @@ -2062,6 +2062,44 @@ ], "default_model_placeholder": "gpt-3.5-turbo" }, + { + "provider": "NVIDIA_RIVA", + "provider_display_name": "Nvidia Riva", + "litellm_provider": "nvidia_riva", + "credential_fields": [ + { + "key": "api_base", + "label": "API Base", + "placeholder": "grpc.nvcf.nvidia.com:443", + "tooltip": "host:port of the Riva gRPC endpoint. Use grpc.nvcf.nvidia.com:443 for NVCF-hosted Riva, or your own host (e.g. localhost:50051) when self-hosting. Riva has no public default, so this is required.", + "required": true, + "field_type": "text", + "options": null, + "default_value": null + }, + { + "key": "api_key", + "label": "API Key", + "placeholder": "nvapi-...", + "tooltip": "Sent as gRPC authorization metadata. Required for NVCF-hosted Riva, optional for self-hosted deployments without auth.", + "required": false, + "field_type": "password", + "options": null, + "default_value": null + }, + { + "key": "nvcf_function_id", + "label": "NVCF Function ID", + "placeholder": "1598d209-5e27-4d3c-8079-4751568b1081", + "tooltip": "NVCF function id of the hosted Riva model. Setting it turns on TLS and the function-id gRPC metadata. Leave empty for self-hosted Riva.", + "required": false, + "field_type": "text", + "options": null, + "default_value": null + } + ], + "default_model_placeholder": "nvidia_riva/nvidia/parakeet-ctc-1_1b-asr" + }, { "provider": "Ollama", "provider_display_name": "Ollama", diff --git a/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py b/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py index 88dc07e741b..ead5f4ab5cc 100644 --- a/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py +++ b/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py @@ -243,6 +243,39 @@ def test_bedrock_mantle_provider_fields(): assert fields_by_key["api_base"]["field_type"] == "text" +def test_nvidia_riva_provider_fields(): + """The Add Model provider dropdown is populated from /public/providers/fields, so a + missing entry meant Riva could not be added through the UI. Riva is gRPC only with no + public default endpoint, hence the required api_base, and NVCF hosted Riva cannot be + called without nvcf_function_id. + """ + app_instance = FastAPI() + app_instance.include_router(router) + test_client = TestClient(app_instance) + + response = test_client.get("/public/providers/fields") + assert response.status_code == 200 + providers = response.json() + + riva = next((p for p in providers if p["provider"] == "NVIDIA_RIVA"), None) + assert riva is not None, "NVIDIA Riva provider entry not found" + + assert riva["provider_display_name"] == "Nvidia Riva" + assert riva["litellm_provider"] == LlmProviders.NVIDIA_RIVA.value + assert riva["default_model_placeholder"].startswith("nvidia_riva/") + + fields_by_key = {f["key"]: f for f in riva["credential_fields"]} + + assert fields_by_key["api_base"]["required"] is True + assert fields_by_key["api_base"]["field_type"] == "text" + + assert fields_by_key["api_key"]["required"] is False + assert fields_by_key["api_key"]["field_type"] == "password" + + assert "nvcf_function_id" in fields_by_key + assert fields_by_key["nvcf_function_id"]["required"] is False + + def test_google_ai_studio_provider_fields_expose_api_base(): """The Google AI Studio (gemini) credential form must let admins set a custom api_base so they can point at a Gemini-compatible gateway (e.g. a self-hosted diff --git a/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx b/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx index 777cdc62987..f43200570b0 100644 --- a/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx +++ b/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx @@ -94,6 +94,17 @@ describe("provider_info_helpers", () => { expect(result.displayName).toBe(Providers.ZAI); }); + it("should resolve the nvidia_riva provider value to the Nvidia Riva display name and logo", () => { + // The backend registers nvidia_riva and it has a docs page, but the UI + // registry had no entry, so it could not be picked in Add Model and the + // slug rendered raw with no logo. + const result = getProviderLogoAndName("nvidia_riva"); + expect(result.displayName).toBe(Providers.NVIDIA_RIVA); + expect(provider_map.NVIDIA_RIVA).toBe("nvidia_riva"); + expect(result.logo).toBe(providerLogoMap[Providers.NVIDIA_RIVA]); + expect(result.logo).toBeTruthy(); + }); + it("should return provider value as display name when no mapping exists", () => { const unknownProvider = "unknown_provider"; const result = getProviderLogoAndName(unknownProvider); @@ -225,6 +236,10 @@ describe("provider_info_helpers", () => { expect(getPlaceholder(Providers.ZAI)).toBe("zai/glm-4.5"); }); + it("should return the riva asr placeholder for NVIDIA_RIVA provider", () => { + expect(getPlaceholder(Providers.NVIDIA_RIVA)).toBe("nvidia_riva/nvidia/parakeet-ctc-1_1b-asr"); + }); + it("should return default gpt-3.5-turbo placeholder for unknown provider", () => { expect(getPlaceholder("UnknownProvider" as any)).toBe("gpt-3.5-turbo"); }); diff --git a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx index fa6b3c79230..86d67867fcf 100644 --- a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx +++ b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx @@ -130,6 +130,7 @@ export enum Providers { NOVITA = "Novita", NSCALE = "Nscale", NVIDIA_NIM = "Nvidia Nim", + NVIDIA_RIVA = "Nvidia Riva", Ollama = "Ollama", OLLAMA_CHAT = "Ollama Chat", OOBABOOGA = "Oobabooga", @@ -238,6 +239,7 @@ export const provider_map: Record = { NOVITA: "novita", NSCALE: "nscale", NVIDIA_NIM: "nvidia_nim", + NVIDIA_RIVA: "nvidia_riva", Ollama: "ollama", OLLAMA_CHAT: "ollama_chat", OOBABOOGA: "oobabooga", @@ -334,6 +336,7 @@ export const providerLogoMap: Partial> = { [Providers.NEBIUS]: nebiusLogo.src, [Providers.NOVITA]: novitaLogo.src, [Providers.NVIDIA_NIM]: nvidiaNimLogo.src, + [Providers.NVIDIA_RIVA]: nvidiaNimLogo.src, [Providers.Ollama]: ollamaLogo.src, [Providers.OLLAMA_CHAT]: ollamaLogo.src, [Providers.OOBABOOGA]: openaiSmallLogo.src, @@ -400,50 +403,32 @@ export const getProviderLogoAndName = (providerValue: string): { logo: string; d return { logo, displayName }; }; -export const getPlaceholder = (selectedProvider: string): string => { - if (selectedProvider === Providers.AIML) { - return "aiml/flux-pro/v1.1"; - } else if (selectedProvider === Providers.Vertex_AI) { - return "gemini-pro"; - } else if (selectedProvider == Providers.Anthropic) { - return "claude-3-opus"; - } else if (selectedProvider == Providers.Bedrock) { - return "claude-3-opus"; - } else if (selectedProvider == Providers.SageMaker) { - return "sagemaker/jumpstart-dft-meta-textgeneration-llama-2-7b"; - } else if (selectedProvider == Providers.Google_AI_Studio) { - return "gemini-pro"; - } else if (selectedProvider == Providers.Azure_AI_Studio) { - return "azure_ai/command-r-plus"; - } else if (selectedProvider == Providers.Azure) { - return "my-deployment"; - } else if (selectedProvider == Providers.Oracle) { - return "oci/xai.grok-4"; - } else if (selectedProvider == Providers.Snowflake) { - return "snowflake/mistral-7b"; - } else if (selectedProvider == Providers.Voyage) { - return "voyage/"; - } else if (selectedProvider == Providers.JinaAI) { - return "jina_ai/"; - } else if (selectedProvider == Providers.VolcEngine) { - return "volcengine/"; - } else if (selectedProvider == Providers.DeepInfra) { - return "deepinfra/"; - } else if (selectedProvider == Providers.FalAI) { - return "fal_ai/fal-ai/flux-pro/v1.1-ultra"; - } else if (selectedProvider == Providers.RunwayML) { - return "runwayml/gen4_turbo"; - } else if (selectedProvider === Providers.WATSONX) { - return "watsonx/ibm/granite-3-3-8b-instruct"; - } else if (selectedProvider === Providers.Cursor) { - return "cursor/claude-4-sonnet"; - } else if (selectedProvider === Providers.ZAI) { - return "zai/glm-4.5"; - } else { - return "gpt-3.5-turbo"; - } +const providerPlaceholderMap: Partial> = { + [Providers.AIML]: "aiml/flux-pro/v1.1", + [Providers.Anthropic]: "claude-3-opus", + [Providers.Azure]: "my-deployment", + [Providers.Azure_AI_Studio]: "azure_ai/command-r-plus", + [Providers.Bedrock]: "claude-3-opus", + [Providers.Cursor]: "cursor/claude-4-sonnet", + [Providers.DeepInfra]: "deepinfra/", + [Providers.FalAI]: "fal_ai/fal-ai/flux-pro/v1.1-ultra", + [Providers.Google_AI_Studio]: "gemini-pro", + [Providers.JinaAI]: "jina_ai/", + [Providers.NVIDIA_RIVA]: "nvidia_riva/nvidia/parakeet-ctc-1_1b-asr", + [Providers.Oracle]: "oci/xai.grok-4", + [Providers.RunwayML]: "runwayml/gen4_turbo", + [Providers.SageMaker]: "sagemaker/jumpstart-dft-meta-textgeneration-llama-2-7b", + [Providers.Snowflake]: "snowflake/mistral-7b", + [Providers.Vertex_AI]: "gemini-pro", + [Providers.VolcEngine]: "volcengine/", + [Providers.Voyage]: "voyage/", + [Providers.WATSONX]: "watsonx/ibm/granite-3-3-8b-instruct", + [Providers.ZAI]: "zai/glm-4.5", }; +export const getPlaceholder = (selectedProvider: string): string => + providerPlaceholderMap[selectedProvider as Providers] ?? "gpt-3.5-turbo"; + export const getProviderModels = (provider: Providers, modelMap: any): Array => { let providerKey = provider; let custom_llm_provider = provider_map[providerKey]; From 3b09484344defaf64a475f97f9b5ad0de35bce8b Mon Sep 17 00:00:00 2001 From: mateo Date: Thu, 13 Aug 2026 19:48:16 +0000 Subject: [PATCH 05/31] refactor(batches): decode unified ids through the public helper Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../proxy/common_utils/check_batch_cost.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py b/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py index 818c44d2039..6fe37f0aacb 100644 --- a/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py +++ b/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py @@ -227,12 +227,15 @@ class CheckBatchCost: def _has_unified_id_without_model(job: "LiteLLM_ManagedObjectTable") -> bool: """A unified id that decodes but carries no model_id can never be routed.""" from litellm.proxy.openai_files_endpoints.common_utils import ( - _is_base64_encoded_unified_file_id, + convert_b64_uid_to_unified_uid, get_model_id_from_unified_batch_id, ) - decoded: Final = _is_base64_encoded_unified_file_id(job.unified_object_id) - return bool(decoded) and get_model_id_from_unified_batch_id(decoded) is None + decoded: Final = convert_b64_uid_to_unified_uid(job.unified_object_id) + return ( + decoded != job.unified_object_id + and get_model_id_from_unified_batch_id(decoded) is None + ) @staticmethod def _is_batch_gone_at_provider(error: Exception, batch_id: str) -> bool: From 8ada34fe2cfa2ae948a75f8b6bd6a3395a0c1274 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 13 Aug 2026 13:45:33 -0700 Subject: [PATCH 06/31] test(ui): characterise guardrails route behaviour before shadcn migration Rewrite the PatternModal test off antd class selectors onto role and text queries, and add characterisation tests for the guardrails components that had none: the keyword modal, the content filter display and configuration, the guardrail garden and the custom code modal. Also covers two behaviours the migration must preserve: the action and severity dropdowns in the content filter tables, and test playground state surviving a tab switch away and back. Every assertion here passes against the current antd and Tremor components so the same file can prove the shadcn versions unedited. --- .../_components/GuardrailsPanel.test.tsx | 32 +++- .../ContentFilterConfiguration.test.tsx | 136 +++++++++++++++ .../ContentFilterDisplay.test.tsx | 89 ++++++++++ .../ContentFilterManager.test.tsx | 24 +-- .../ContentFilterTables.test.tsx | 73 ++++++++ .../content_filter/KeywordModal.test.tsx | 87 ++++++++++ .../content_filter/PatternModal.test.tsx | 130 ++++++++------ .../custom_code/CustomCodeModal.test.tsx | 162 ++++++++++++++++++ .../_components/guardrail_garden.test.tsx | 104 +++++++++++ 9 files changed, 763 insertions(+), 74 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterConfiguration.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterDisplay.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/KeywordModal.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/custom_code/CustomCodeModal.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.test.tsx index 29655e5910a..88e52e0cdd1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.test.tsx @@ -35,10 +35,19 @@ vi.mock("./guardrail_info", () => ({ default: () =>
Mock Guardrail Info View
, })); -vi.mock("./GuardrailTestPlayground", () => ({ - __esModule: true, - default: () =>
Mock Guardrail Test Playground
, -})); +vi.mock("./GuardrailTestPlayground", async () => { + const { useState } = await import("react"); + const MockGuardrailTestPlayground = () => { + const [draft, setDraft] = useState(""); + return ( +
+
Mock Guardrail Test Playground
+ setDraft(e.target.value)} /> +
+ ); + }; + return { __esModule: true, default: MockGuardrailTestPlayground }; +}); vi.mock("./TeamGuardrailsTab", () => ({ TeamGuardrailsTab: () =>
Mock Team Guardrails Tab
, @@ -129,6 +138,21 @@ describe("GuardrailsPanel", () => { expect(mockGetGuardrailsList).toHaveBeenCalledTimes(2); }); + it("should keep test playground state when switching tabs away and back", async () => { + render(); + + fireEvent.click(screen.getByText("Test Playground")); + + const draft = await screen.findByLabelText("playground draft"); + fireEvent.change(draft, { target: { value: "keep me" } }); + expect(draft).toHaveValue("keep me"); + + fireEvent.click(screen.getByText("Guardrails")); + fireEvent.click(screen.getByText("Test Playground")); + + expect(await screen.findByLabelText("playground draft")).toHaveValue("keep me"); + }); + it("should not delete anything when the modal is cancelled", async () => { render(); fireEvent.click(screen.getByText("Guardrails")); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterConfiguration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterConfiguration.test.tsx new file mode 100644 index 00000000000..37a3d40128e --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterConfiguration.test.tsx @@ -0,0 +1,136 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderWithProviders, screen } from "@/../tests/test-utils"; +import userEvent from "@testing-library/user-event"; +import ContentFilterConfiguration from "./ContentFilterConfiguration"; + +vi.mock("@/components/networking", () => ({ + validateBlockedWordsFile: vi.fn(), + getCategoryYaml: vi.fn(), +})); + +vi.mock("@/components/molecules/notifications_manager", () => ({ + default: { success: vi.fn(), error: vi.fn(), fromBackend: vi.fn() }, +})); + +const PREBUILT = [ + { name: "us_ssn", display_name: "US Social Security Number", category: "PII Patterns", description: "d" }, +]; + +describe("ContentFilterConfiguration", () => { + const handlers = { + onPatternAdd: vi.fn(), + onPatternRemove: vi.fn(), + onPatternActionChange: vi.fn(), + onBlockedWordAdd: vi.fn(), + onBlockedWordRemove: vi.fn(), + onBlockedWordUpdate: vi.fn(), + }; + + const renderConfig = (overrides = {}) => + renderWithProviders( + , + ); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render the pattern and keyword sections", () => { + renderConfig(); + + expect(screen.getByText("Pattern Detection")).toBeInTheDocument(); + expect( + screen.getByText("Detect sensitive information using regex patterns (SSN, credit cards, API keys, etc.)"), + ).toBeInTheDocument(); + expect(screen.getByText("Blocked Keywords")).toBeInTheDocument(); + expect(screen.getByText("Block or mask specific sensitive terms and phrases")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /add prebuilt pattern/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /add custom regex/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /add keyword/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /upload yaml file/i })).toBeInTheDocument(); + }); + + it("should show the empty states for patterns and keywords", () => { + renderConfig(); + + expect(screen.getByText("No patterns added.")).toBeInTheDocument(); + expect(screen.getByText("No keywords added.")).toBeInTheDocument(); + }); + + it("should open the prebuilt pattern modal", async () => { + const user = userEvent.setup(); + renderConfig(); + + expect(screen.queryByText("Pattern type")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /add prebuilt pattern/i })); + + expect(await screen.findByText("Pattern type")).toBeInTheDocument(); + }); + + it("should open the custom regex modal", async () => { + const user = userEvent.setup(); + renderConfig(); + + expect(screen.queryByText("Add custom regex pattern")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /add custom regex/i })); + + expect(await screen.findByText("Add custom regex pattern")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("e.g., ID-[0-9]{6}")).toBeInTheDocument(); + }); + + it("should open the keyword modal", async () => { + const user = userEvent.setup(); + renderConfig(); + + expect(screen.queryByText("Add blocked keyword")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /add keyword/i })); + + expect(await screen.findByText("Add blocked keyword")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Enter sensitive keyword or phrase")).toBeInTheDocument(); + }); + + it("should list already selected patterns and keywords", () => { + renderConfig({ + selectedPatterns: [ + { + id: "pattern-1", + type: "prebuilt" as const, + name: "us_ssn", + display_name: "US Social Security Number", + action: "BLOCK" as const, + }, + ], + blockedWords: [{ id: "word-1", keyword: "secret", action: "MASK" as const, description: "Sensitive" }], + }); + + expect(screen.getByText("US Social Security Number")).toBeInTheDocument(); + expect(screen.getByText("secret")).toBeInTheDocument(); + expect(screen.queryByText("No patterns added.")).not.toBeInTheDocument(); + expect(screen.queryByText("No keywords added.")).not.toBeInTheDocument(); + }); + + it("should show only the keyword section when the keywords step is requested", () => { + renderConfig({ showStep: "keywords" }); + + expect(screen.getByText("Blocked Keywords")).toBeInTheDocument(); + expect(screen.queryByText("Pattern Detection")).not.toBeInTheDocument(); + }); + + it("should show only the pattern section when the patterns step is requested", () => { + renderConfig({ showStep: "patterns" }); + + expect(screen.getByText("Pattern Detection")).toBeInTheDocument(); + expect(screen.queryByText("Blocked Keywords")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterDisplay.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterDisplay.test.tsx new file mode 100644 index 00000000000..8fb48791f76 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterDisplay.test.tsx @@ -0,0 +1,89 @@ +import { describe, it, expect } from "vitest"; +import { renderWithProviders, screen } from "@/../tests/test-utils"; +import ContentFilterDisplay from "./ContentFilterDisplay"; + +const PATTERN = { + id: "pattern-1", + type: "prebuilt" as const, + name: "email", + display_name: "Email address", + action: "BLOCK" as const, +}; + +const KEYWORD = { + id: "word-1", + keyword: "secret", + action: "MASK" as const, + description: "Sensitive term", +}; + +const CATEGORY = { + id: "category-1", + category: "self_harm", + display_name: "Self Harm", + action: "BLOCK" as const, + severity_threshold: "high" as const, +}; + +describe("ContentFilterDisplay", () => { + it("should render nothing when there is no content filter data", () => { + const { container } = renderWithProviders(); + + expect(container).toBeEmptyDOMElement(); + }); + + it("should render the categories section with a configured count", () => { + renderWithProviders(); + + expect(screen.getByText("Content Categories")).toBeInTheDocument(); + expect(screen.getByText("1 categories configured")).toBeInTheDocument(); + expect(screen.getByText("Self Harm")).toBeInTheDocument(); + expect(screen.queryByText("Pattern Detection")).not.toBeInTheDocument(); + expect(screen.queryByText("Blocked Keywords")).not.toBeInTheDocument(); + }); + + it("should render the patterns section with a configured count", () => { + renderWithProviders(); + + expect(screen.getByText("Pattern Detection")).toBeInTheDocument(); + expect(screen.getByText("1 patterns configured")).toBeInTheDocument(); + expect(screen.getByText("Email address")).toBeInTheDocument(); + expect(screen.queryByText("Content Categories")).not.toBeInTheDocument(); + }); + + it("should render the keywords section with a configured count", () => { + renderWithProviders(); + + expect(screen.getByText("Blocked Keywords")).toBeInTheDocument(); + expect(screen.getByText("1 keywords configured")).toBeInTheDocument(); + expect(screen.getByText("secret")).toBeInTheDocument(); + expect(screen.getByText("Sensitive term")).toBeInTheDocument(); + }); + + it("should render every section when all three kinds of data are present", () => { + renderWithProviders(); + + expect(screen.getByText("Content Categories")).toBeInTheDocument(); + expect(screen.getByText("Pattern Detection")).toBeInTheDocument(); + expect(screen.getByText("Blocked Keywords")).toBeInTheDocument(); + }); + + it("should render category severity and action as static text in read-only mode", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("HIGH")).toBeInTheDocument(); + expect(screen.getByText("BLOCK")).toBeInTheDocument(); + expect(screen.getAllByRole("button", { name: /delete/i })).toHaveLength(2); + }); + + it("should render category severity and action as editable controls when not read-only", () => { + renderWithProviders( + , + ); + + expect(screen.queryByText("HIGH")).not.toBeInTheDocument(); + expect(screen.getAllByRole("button", { name: /delete/i })).toHaveLength(3); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterManager.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterManager.test.tsx index 66758ac72f1..c3fd2e3eb59 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterManager.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterManager.test.tsx @@ -2,7 +2,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import ContentFilterManager, { formatContentFilterDataForAPI } from "./ContentFilterManager"; -import React from "react"; const CONTENT_FILTER_GUARDRAIL_DATA = { litellm_params: { @@ -85,18 +84,7 @@ vi.mock("./ContentFilterDisplay", () => ({ ), })); -vi.mock("antd", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - Divider: ({ children }: { children: React.ReactNode }) =>
{children}
, - Alert: ({ message, type }: { message: React.ReactNode; type: string }) => ( -
- {message} -
- ), - }; -}); +const UNSAVED_CHANGES_TEXT = /You have unsaved changes to patterns or keywords/; describe("ContentFilterManager", () => { beforeEach(() => { @@ -117,7 +105,7 @@ describe("ContentFilterManager", () => { expect(screen.getByTestId("content-filter-config")).toBeInTheDocument(); }); - expect(screen.getByTestId("divider")).toHaveTextContent("Content Filter Configuration"); + expect(screen.getByText("Content Filter Configuration")).toBeInTheDocument(); }); it("should return null when guardrail is not litellm_content_filter", () => { @@ -275,15 +263,15 @@ describe("ContentFilterManager", () => { expect(screen.getByTestId("content-filter-config")).toBeInTheDocument(); }); - expect(screen.queryByTestId("unsaved-alert")).not.toBeInTheDocument(); + expect(screen.queryByText(UNSAVED_CHANGES_TEXT)).not.toBeInTheDocument(); await user.click(screen.getByRole("button", { name: /add pattern/i })); await waitFor(() => { - expect(screen.getByTestId("unsaved-alert")).toBeInTheDocument(); + expect(screen.getByText(UNSAVED_CHANGES_TEXT)).toBeInTheDocument(); }); - expect(screen.getByTestId("unsaved-alert")).toHaveTextContent(/unsaved changes.*Save Changes/i); + expect(screen.getByText(UNSAVED_CHANGES_TEXT)).toHaveTextContent(/Save Changes/i); }); it("should call onDataChange when patterns or keywords change", async () => { @@ -371,7 +359,7 @@ describe("ContentFilterManager", () => { ); await waitFor(() => { - expect(screen.getByTestId("divider")).toBeInTheDocument(); + expect(screen.getByText("Content Filter Configuration")).toBeInTheDocument(); }); expect(screen.queryByTestId("content-filter-config")).not.toBeInTheDocument(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterTables.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterTables.test.tsx index cc27394f2c3..c56ee8ff528 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterTables.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterTables.test.tsx @@ -130,4 +130,77 @@ describe("content filter tables", () => { expect(onCategoryRemove).toHaveBeenCalledWith("category-1"); }); + + it("should report a pattern action change", async () => { + const onActionChange = vi.fn(); + const user = userEvent.setup(); + + renderWithProviders( + , + ); + + await user.click(screen.getByRole("combobox")); + const maskOptions = await screen.findAllByText("Mask"); + await user.click(maskOptions[maskOptions.length - 1]); + + expect(onActionChange).toHaveBeenCalledWith("pattern-1", "MASK"); + }); + + it("should report a keyword action change", async () => { + const onActionChange = vi.fn(); + const user = userEvent.setup(); + + renderWithProviders( + , + ); + + await user.click(screen.getByRole("combobox")); + const maskOptions = await screen.findAllByText("Mask"); + await user.click(maskOptions[maskOptions.length - 1]); + + expect(onActionChange).toHaveBeenCalledWith("keyword-1", "action", "MASK"); + }); + + it("should report category severity and action changes", async () => { + const onSeverityChange = vi.fn(); + const onActionChange = vi.fn(); + const user = userEvent.setup(); + + renderWithProviders( + , + ); + + await user.click(screen.getAllByRole("combobox")[0]); + const lowOptions = await screen.findAllByText("Low"); + await user.click(lowOptions[lowOptions.length - 1]); + + expect(onSeverityChange).toHaveBeenCalledWith("category-1", "low"); + + await user.click(screen.getAllByRole("combobox")[1]); + const maskOptions = await screen.findAllByText("Mask"); + await user.click(maskOptions[maskOptions.length - 1]); + + expect(onActionChange).toHaveBeenCalledWith("category-1", "MASK"); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/KeywordModal.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/KeywordModal.test.tsx new file mode 100644 index 00000000000..dda479f1579 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/KeywordModal.test.tsx @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import KeywordModal from "./KeywordModal"; + +describe("KeywordModal", () => { + const handlers = { + onKeywordChange: vi.fn(), + onActionChange: vi.fn(), + onDescriptionChange: vi.fn(), + onAdd: vi.fn(), + onCancel: vi.fn(), + }; + + const renderModal = (overrides: Partial> = {}) => + render(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render the keyword, action and description fields", async () => { + renderModal(); + + expect(await screen.findByText("Add blocked keyword")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Enter sensitive keyword or phrase")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Explain why this keyword is sensitive")).toBeInTheDocument(); + expect(screen.getByText("Description (optional)")).toBeInTheDocument(); + expect( + screen.getByText("Choose what action the guardrail should take when this keyword is detected"), + ).toBeInTheDocument(); + }); + + it("should report keyword edits", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.type(await screen.findByPlaceholderText("Enter sensitive keyword or phrase"), "s"); + + expect(handlers.onKeywordChange).toHaveBeenCalledWith("s"); + }); + + it("should report description edits", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.type(await screen.findByPlaceholderText("Explain why this keyword is sensitive"), "x"); + + expect(handlers.onDescriptionChange).toHaveBeenCalledWith("x"); + }); + + it("should report the chosen action", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.click(await screen.findByRole("combobox")); + const maskOptions = await screen.findAllByText("Mask"); + await user.click(maskOptions[maskOptions.length - 1]); + + expect(handlers.onActionChange).toHaveBeenCalled(); + expect(handlers.onActionChange.mock.calls[0][0]).toBe("MASK"); + }); + + it("should show the current keyword and description values", async () => { + renderModal({ keyword: "secret", description: "sensitive term" }); + + expect(await screen.findByDisplayValue("secret")).toBeInTheDocument(); + expect(screen.getByDisplayValue("sensitive term")).toBeInTheDocument(); + }); + + it("should add and cancel through the footer buttons", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.click(await screen.findByRole("button", { name: "Add" })); + expect(handlers.onAdd).toHaveBeenCalledTimes(1); + + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(handlers.onCancel).toHaveBeenCalledTimes(1); + }); + + it("should not render its content when not visible", () => { + renderModal({ visible: false }); + + expect(screen.queryByText("Add blocked keyword")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/PatternModal.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/PatternModal.test.tsx index 4e6fd0db809..4311f4e13aa 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/PatternModal.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/PatternModal.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import PatternModal from "./PatternModal"; @@ -10,25 +10,25 @@ describe("PatternModal", () => { const mockOnActionChange = vi.fn(); const mockPrebuiltPatterns = [ - { name: "us_ssn", category: "PII Patterns", description: "US Social Security Number" }, - { name: "email", category: "PII Patterns", description: "Email addresses" }, - { name: "visa", category: "Financial Patterns", description: "Visa credit card numbers" }, - { name: "aws_access_key", category: "Credential Patterns", description: "AWS Access Keys" }, + { + name: "us_ssn", + display_name: "US Social Security Number", + category: "PII Patterns", + description: "US Social Security Number", + }, + { name: "email", display_name: "Email address", category: "PII Patterns", description: "Email addresses" }, + { name: "visa", display_name: "Visa card", category: "Financial Patterns", description: "Visa credit cards" }, + { + name: "aws_access_key", + display_name: "AWS access key", + category: "Credential Patterns", + description: "AWS Access Keys", + }, ]; const mockCategories = ["PII Patterns", "Financial Patterns", "Credential Patterns"]; - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("should show dropdown with prebuilt pattern options grouped by category", async () => { - /** - * Tests that the modal displays a dropdown with prebuilt patterns - * organized by category. This verifies the pattern selection UI is working. - */ - const user = userEvent.setup(); - + const renderModal = () => render( { />, ); - // Wait for modal to be visible - await waitFor(() => { - expect(screen.getByText("Add prebuilt pattern")).toBeInTheDocument(); - }); + beforeEach(() => { + vi.clearAllMocks(); + }); - // Find the pattern type dropdown by looking for the first combobox input - const comboboxes = screen.getAllByRole("combobox"); - const dropdown = comboboxes[0]; // First combobox is the pattern selector - expect(dropdown).toBeInTheDocument(); + it("should show prebuilt pattern options grouped by category and report the picked pattern", async () => { + const user = userEvent.setup(); + renderModal(); - // Click to open the dropdown - await user.click(dropdown); + expect(await screen.findByText("Add prebuilt pattern")).toBeInTheDocument(); - // Verify that pattern options are available in the dropdown - // Ant Design renders Select options in a portal, so we need to query the whole document - await waitFor(() => { - const options = document.querySelectorAll(".ant-select-item-option"); - expect(options.length).toBeGreaterThan(0); - }); + await user.click(screen.getAllByRole("combobox")[0]); - // Verify categories are shown as group labels - await waitFor(() => { - expect(document.body).toHaveTextContent("PII Patterns"); - expect(document.body).toHaveTextContent("Financial Patterns"); - expect(document.body).toHaveTextContent("Credential Patterns"); - }); + expect(await screen.findByText("PII Patterns")).toBeInTheDocument(); + expect(screen.getByText("Financial Patterns")).toBeInTheDocument(); + expect(screen.getByText("Credential Patterns")).toBeInTheDocument(); - // Verify pattern options are available - expect(document.body).toHaveTextContent("us_ssn"); - expect(document.body).toHaveTextContent("email"); - expect(document.body).toHaveTextContent("visa"); - expect(document.body).toHaveTextContent("aws_access_key"); + expect(screen.getByText("Email address")).toBeInTheDocument(); + expect(screen.getByText("Visa card")).toBeInTheDocument(); + expect(screen.getByText("AWS access key")).toBeInTheDocument(); - // Select a pattern by clicking on its option element - const ssnOption = Array.from(document.querySelectorAll(".ant-select-item-option")).find( - (el) => el.textContent === "us_ssn", - ) as HTMLElement; - await user.click(ssnOption); + const ssnOptions = await screen.findAllByText("US Social Security Number"); + await user.click(ssnOptions[ssnOptions.length - 1]); - // Verify the change handler was called with the pattern name - // Note: Ant Design Select calls onChange with (value, option), so we check if it was called expect(mockOnPatternNameChange).toHaveBeenCalled(); - const callArgs = mockOnPatternNameChange.mock.calls[0]; - expect(callArgs[0]).toBe("us_ssn"); + expect(mockOnPatternNameChange.mock.calls[0][0]).toBe("us_ssn"); + }); + + it("should report the chosen action", async () => { + const user = userEvent.setup(); + renderModal(); + + expect(await screen.findByText("Add prebuilt pattern")).toBeInTheDocument(); + + await user.click(screen.getAllByRole("combobox")[1]); + const maskOptions = await screen.findAllByText("Mask"); + await user.click(maskOptions[maskOptions.length - 1]); + + expect(mockOnActionChange).toHaveBeenCalled(); + expect(mockOnActionChange.mock.calls[0][0]).toBe("MASK"); + }); + + it("should add and cancel through the footer buttons", async () => { + const user = userEvent.setup(); + renderModal(); + + expect(await screen.findByText("Add prebuilt pattern")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Add" })); + expect(mockOnAdd).toHaveBeenCalledTimes(1); + + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(mockOnCancel).toHaveBeenCalledTimes(1); + }); + + it("should not render its content when not visible", () => { + render( + , + ); + + expect(screen.queryByText("Add prebuilt pattern")).not.toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/custom_code/CustomCodeModal.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/custom_code/CustomCodeModal.test.tsx new file mode 100644 index 00000000000..ff3432a0d82 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/custom_code/CustomCodeModal.test.tsx @@ -0,0 +1,162 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import CustomCodeModal from "./CustomCodeModal"; +import { createGuardrailCall, updateGuardrailCall, testCustomCodeGuardrail } from "@/components/networking"; + +vi.mock("@/components/networking", () => ({ + createGuardrailCall: vi.fn(), + updateGuardrailCall: vi.fn(), + testCustomCodeGuardrail: vi.fn(), +})); + +vi.mock("@/components/molecules/notifications_manager", () => ({ + default: { success: vi.fn(), error: vi.fn(), fromBackend: vi.fn() }, +})); + +const mockCreate = vi.mocked(createGuardrailCall); +const mockUpdate = vi.mocked(updateGuardrailCall); +const mockTest = vi.mocked(testCustomCodeGuardrail); + +describe("CustomCodeModal", () => { + const onClose = vi.fn(); + const onSuccess = vi.fn(); + + const renderModal = (overrides = {}) => + render(); + + beforeEach(() => { + vi.clearAllMocks(); + mockCreate.mockResolvedValue({} as never); + mockUpdate.mockResolvedValue({} as never); + }); + + it("should render the create heading and the editor scaffolding", async () => { + renderModal(); + + expect(await screen.findByText("Create Custom Guardrail")).toBeInTheDocument(); + expect(screen.getByText("Define custom logic using Python-like syntax")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("e.g., block-pii-custom")).toBeInTheDocument(); + expect(screen.getByText("Guardrail Name")).toBeInTheDocument(); + expect(screen.getByText("Mode (can select multiple)")).toBeInTheDocument(); + expect(screen.getByText("Available Primitives")).toBeInTheDocument(); + expect(screen.getByText("Python Logic")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /save guardrail/i })).toBeInTheDocument(); + }); + + it("should seed the editor with the empty template", async () => { + renderModal(); + + const editor = await screen.findByDisplayValue(/async def apply_guardrail/); + expect(editor).toBeInTheDocument(); + }); + + it("should not render its content when not visible", () => { + renderModal({ visible: false }); + + expect(screen.queryByText("Create Custom Guardrail")).not.toBeInTheDocument(); + }); + + it("should render the edit heading and existing values in edit mode", async () => { + renderModal({ + editData: { + guardrail_id: "g-1", + guardrail_name: "existing-guardrail", + litellm_params: { mode: "post_call", default_on: true, custom_code: "def apply_guardrail(): pass" }, + }, + }); + + expect(await screen.findByText("Edit Custom Guardrail")).toBeInTheDocument(); + expect(screen.getByDisplayValue("existing-guardrail")).toBeInTheDocument(); + expect(screen.getByDisplayValue("def apply_guardrail(): pass")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /update guardrail/i })).toBeInTheDocument(); + }); + + it("should keep save disabled until a guardrail name is entered", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.click(await screen.findByRole("button", { name: /save guardrail/i })); + expect(mockCreate).not.toHaveBeenCalled(); + + await user.type(screen.getByPlaceholderText("e.g., block-pii-custom"), "my-guardrail"); + await user.click(screen.getByRole("button", { name: /save guardrail/i })); + + await waitFor(() => { + expect(mockCreate).toHaveBeenCalledTimes(1); + }); + }); + + it("should create the guardrail with the entered name, mode and code", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.type(await screen.findByPlaceholderText("e.g., block-pii-custom"), "block-pii"); + await user.click(screen.getByRole("button", { name: /save guardrail/i })); + + await waitFor(() => { + expect(mockCreate).toHaveBeenCalled(); + }); + + const [token, payload] = mockCreate.mock.calls[0] as [string, Record]; + expect(token).toBe("test-token"); + expect(payload).toMatchObject({ + guardrail_name: "block-pii", + litellm_params: { guardrail: "custom_code", mode: ["pre_call"], default_on: false }, + }); + await waitFor(() => { + expect(onSuccess).toHaveBeenCalled(); + }); + }); + + it("should switch the editor contents when a template is chosen", async () => { + const user = userEvent.setup(); + renderModal(); + + expect(await screen.findByDisplayValue(/async def apply_guardrail/)).toBeInTheDocument(); + + const comboboxes = screen.getAllByRole("combobox"); + await user.click(comboboxes[comboboxes.length - 1]); + const options = await screen.findAllByText("Block SSN"); + await user.click(options[options.length - 1]); + + expect(await screen.findByDisplayValue(/SSN detected/)).toBeInTheDocument(); + }); + + it("should expand the test section and run a test against the backend", async () => { + const user = userEvent.setup(); + mockTest.mockResolvedValue({ success: true, result: { action: "allow" } } as never); + renderModal(); + + await user.click(await screen.findByText("Test Your Guardrail")); + + const runButton = await screen.findByRole("button", { name: /run test/i }); + await user.click(runButton); + + await waitFor(() => { + expect(mockTest).toHaveBeenCalled(); + }); + expect(await screen.findByText("Allowed")).toBeInTheDocument(); + }); + + it("should surface a backend test error", async () => { + const user = userEvent.setup(); + mockTest.mockResolvedValue({ success: false, error: "boom", error_type: "SyntaxError" } as never); + renderModal(); + + await user.click(await screen.findByText("Test Your Guardrail")); + await user.click(await screen.findByRole("button", { name: /run test/i })); + + expect(await screen.findByText("boom")).toBeInTheDocument(); + expect(screen.getByText("[SyntaxError]")).toBeInTheDocument(); + }); + + it("should cancel through the footer button", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.click(await screen.findByRole("button", { name: "Cancel" })); + + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden.test.tsx new file mode 100644 index 00000000000..e20c8a6fa55 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden.test.tsx @@ -0,0 +1,104 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import GuardrailGarden from "./guardrail_garden"; +import { ALL_CARDS } from "./guardrail_garden_data"; + +vi.mock("./guardrail_garden_detail", () => ({ + __esModule: true, + default: ({ card, onBack }: { card: { name: string }; onBack: () => void }) => ( +
+ Detail for {card.name} + +
+ ), +})); + +const LITELLM_CARDS = ALL_CARDS.filter((c) => c.category === "litellm"); +const PARTNER_CARDS = ALL_CARDS.filter((c) => c.category === "partner"); + +describe("GuardrailGarden", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const renderGarden = () => render(); + + it("should render both sections with their descriptions", () => { + renderGarden(); + + expect(screen.getByText("LiteLLM Content Filter")).toBeInTheDocument(); + expect( + screen.getByText( + "Built-in guardrails powered by LiteLLM. Zero latency, no external dependencies, no additional cost.", + ), + ).toBeInTheDocument(); + expect(screen.getByText("Partner Guardrails")).toBeInTheDocument(); + expect( + screen.getByText("Third-party guardrail integrations from leading AI security providers."), + ).toBeInTheDocument(); + }); + + it("should show a capped set of litellm cards behind a show all toggle", async () => { + const user = userEvent.setup(); + renderGarden(); + + expect(screen.getByText(`Show all (${LITELLM_CARDS.length})`)).toBeInTheDocument(); + expect(screen.getByText(LITELLM_CARDS[0].name)).toBeInTheDocument(); + expect(screen.queryByText(LITELLM_CARDS[LITELLM_CARDS.length - 1].name)).not.toBeInTheDocument(); + + await user.click(screen.getByText(`Show all (${LITELLM_CARDS.length})`)); + + expect(screen.getByText("Show less")).toBeInTheDocument(); + expect(screen.getByText(LITELLM_CARDS[LITELLM_CARDS.length - 1].name)).toBeInTheDocument(); + }); + + it("should always render every partner card", () => { + renderGarden(); + + PARTNER_CARDS.forEach((card) => { + expect(screen.getByText(card.name)).toBeInTheDocument(); + }); + }); + + it("should filter cards by the search query", async () => { + const user = userEvent.setup(); + renderGarden(); + + const target = PARTNER_CARDS[0]; + await user.type(screen.getByPlaceholderText("Search guardrails"), target.name); + + expect(await screen.findByText(target.name)).toBeInTheDocument(); + const otherPartner = PARTNER_CARDS.find((c) => c.name !== target.name); + if (otherPartner) { + expect(screen.queryByText(otherPartner.name)).not.toBeInTheDocument(); + } + }); + + it("should show an empty result set for a query that matches nothing", async () => { + const user = userEvent.setup(); + renderGarden(); + + await user.type(screen.getByPlaceholderText("Search guardrails"), "zzzzznotaguardrailzzzzz"); + + expect(screen.getByText("Show all (0)")).toBeInTheDocument(); + PARTNER_CARDS.forEach((card) => { + expect(screen.queryByText(card.name)).not.toBeInTheDocument(); + }); + }); + + it("should open the detail view for a clicked card and return to the garden", async () => { + const user = userEvent.setup(); + renderGarden(); + + const target = PARTNER_CARDS[0]; + await user.click(screen.getByText(target.name)); + + expect(await screen.findByText(`Detail for ${target.name}`)).toBeInTheDocument(); + expect(screen.queryByPlaceholderText("Search guardrails")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Back to garden" })); + + expect(await screen.findByPlaceholderText("Search guardrails")).toBeInTheDocument(); + }); +}); From 28032eb4c9acadaa10b10090cfb09ad94eb736e5 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 13 Aug 2026 14:01:00 -0700 Subject: [PATCH 07/31] test(ui): decouple usage-route tests from antd and Tremor markup Rewrites the usage route's library-coupled assertions to role and text queries so they characterise behaviour rather than the widget library that happens to render it. Every test here is green against the current antd and Tremor components and is meant to survive a migration unedited. Drops the wholesale vi.mock of antd, @ant-design/icons and @tremor/react in UsagePageView, UsageViewSelect and activity_metrics, and drives the real controls instead. Two of those mocks were hiding behaviour: activity_metrics asserted panel order through a heading role that only existed because the mock faked an h2, and the Tremor tab stubs flattened panel selection away entirely. Replaces DOM-shape lookups with anchors that do not move: a chart is found from its own heading rather than a fixed wrapper depth, an active panel is identified by the inactive markers both tab libraries set, and select options are matched by text since antd's real options carry no option role. Adds the missing characterisation test for team_multi_select, and covers the mount contracts the route depends on: Tremor keeps every tab panel mounted, and antd Collapse keeps a section mounted once it has been expanded, which is what preserves the view-mode state a model section owns. --- .../EntityUsage/EntityUsage.test.tsx | 66 +++-- .../EntityUsage/TopModelView.test.tsx | 53 ++-- .../components/UsageAIChatPanel.test.tsx | 5 +- .../components/UsagePageView.test.tsx | 239 +++--------------- .../UsageViewSelect/UsageViewSelect.test.tsx | 156 ++++-------- .../src/components/activity_metrics.test.tsx | 70 +++-- .../team_multi_select.test.tsx | 124 +++++++++ .../src/components/per_user_usage.test.tsx | 44 +++- .../components/user_agent_activity.test.tsx | 79 ++++-- 9 files changed, 397 insertions(+), 439 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/common_components/team_multi_select.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx index c85a9fb71f6..1f3dd5642ee 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx @@ -1,4 +1,4 @@ -import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import * as networking from "@/components/networking"; import EntityUsage from "./EntityUsage"; @@ -500,18 +500,33 @@ describe("EntityUsage", () => { expect(screen.getAllByText("Activity Metrics")[1]).toBeInTheDocument(); }); - const selectedPanels = (container: HTMLElement) => - Array.from(container.querySelectorAll("div.tremor-TabPanel-root")).filter( - (panel) => panel.getAttribute("aria-selected") === "true", - ); + // An inactive tab panel is marked aria-selected="false" by one tab library and hidden by the + // other, so treat either as "not on screen" and the assertion holds whichever one is rendering. + const isShowing = (element: HTMLElement): boolean => { + for (let node: HTMLElement | null = element; node; node = node.parentElement) { + if (node.hasAttribute("hidden")) return false; + if (node.getAttribute("aria-selected") === "false") return false; + } + return true; + }; - it.each([ + const showingCount = (marker: string): number => screen.queryAllByText(marker).filter(isShowing).length; + + const showingText = (text: string): HTMLElement => { + const [element] = screen.getAllByText(text).filter(isShowing); + expect(element).toBeDefined(); + return element; + }; + + const NON_TEAM_PANELS: [string, string][] = [ ["Cost", "Tag Spend Overview"], ["Model Activity", "metrics-source:model_groups"], ["Key Activity", "metrics-source:api_keys"], ["Endpoint Activity", "Endpoint Usage Panel"], - ])("shows only the %s panel for a non-team entity type", async (tabLabel, marker) => { - const { container } = render(); + ]; + + it.each(NON_TEAM_PANELS)("shows only the %s panel for a non-team entity type", async (tabLabel, marker) => { + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -521,19 +536,23 @@ describe("EntityUsage", () => { fireEvent.click(screen.getByText(tabLabel)); }); - const selected = selectedPanels(container); - expect(selected).toHaveLength(1); - expect(selected[0].textContent).toContain(marker); + expect(showingCount(marker)).toBeGreaterThan(0); + for (const [otherLabel, otherMarker] of NON_TEAM_PANELS) { + if (otherLabel === tabLabel) continue; + expect(showingCount(otherMarker)).toBe(0); + } }); - it.each([ + const TEAM_PANELS: [string, string][] = [ ["Cost", "Team Spend Overview"], ["Model Activity", "metrics-source:model_groups"], ["Agent Activity", "metrics-source:entities"], ["Key Activity", "metrics-source:api_keys"], ["Endpoint Activity", "Endpoint Usage Panel"], - ])("shows only the %s panel for the team entity type", async (tabLabel, marker) => { - const { container } = render(); + ]; + + it.each(TEAM_PANELS)("shows only the %s panel for the team entity type", async (tabLabel, marker) => { + render(); await waitFor(() => { expect(mockTeamDailyActivityCall).toHaveBeenCalled(); @@ -543,9 +562,11 @@ describe("EntityUsage", () => { fireEvent.click(screen.getByText(tabLabel)); }); - const selected = selectedPanels(container); - expect(selected).toHaveLength(1); - expect(selected[0].textContent).toContain(marker); + expect(showingCount(marker)).toBeGreaterThan(0); + for (const [otherLabel, otherMarker] of TEAM_PANELS) { + if (otherLabel === tabLabel) continue; + expect(showingCount(otherMarker)).toBe(0); + } }); it("should handle empty data gracefully", async () => { @@ -615,20 +636,19 @@ describe("EntityUsage", () => { fireEvent.click(screen.getByText("Model Activity")); }); - const modelActivityPanel = () => selectedPanels(container)[0] as HTMLElement; - expect(modelActivityPanel().textContent).toContain("metrics-source:model_groups"); + expect(showingCount("metrics-source:model_groups")).toBeGreaterThan(0); act(() => { - fireEvent.click(within(modelActivityPanel()).getByText("Litellm Model Name")); + fireEvent.click(showingText("Litellm Model Name")); }); - expect(modelActivityPanel().textContent).toContain("metrics-source:models"); + expect(showingCount("metrics-source:models")).toBeGreaterThan(0); act(() => { - fireEvent.click(within(modelActivityPanel()).getByText("Public Model Name")); + fireEvent.click(showingText("Public Model Name")); }); - expect(modelActivityPanel().textContent).toContain("metrics-source:model_groups"); + expect(showingCount("metrics-source:model_groups")).toBeGreaterThan(0); }); it("should display Top Agents title for agent entity type", async () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TopModelView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TopModelView.test.tsx index 2770bac47d9..961b19ab27a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TopModelView.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TopModelView.test.tsx @@ -10,6 +10,14 @@ describe("TopModelView", () => { mockSetTopModelsLimit.mockClear(); }); + // Which element a control library gives its label to is its own business, so drive the + // control by its visible text and judge the result by what the panel renders. + const clickControl = async (user: ReturnType, label: string) => { + await user.click(screen.getByText(label)); + }; + + const showsChart = (container: HTMLElement) => container.querySelector(".recharts-wrapper") !== null; + it("should render", () => { render(); expect(screen.getByText("Table View")).toBeInTheDocument(); @@ -17,12 +25,12 @@ describe("TopModelView", () => { it("should display table view button", () => { render(); - expect(screen.getByRole("button", { name: "Table View" })).toBeInTheDocument(); + expect(screen.getByText("Table View")).toBeInTheDocument(); }); it("should display chart view button", () => { render(); - expect(screen.getByRole("button", { name: "Chart View" })).toBeInTheDocument(); + expect(screen.getByText("Chart View")).toBeInTheDocument(); }); it("should display all table column headers", () => { @@ -60,27 +68,32 @@ describe("TopModelView", () => { expect(screen.getByText("50,000")).toBeInTheDocument(); }); + const oneModel = [{ key: "gpt-4", spend: 150.5, successful_requests: 100, failed_requests: 5, tokens: 50000 }]; + it("should switch to chart view when chart view button is clicked", async () => { const user = userEvent.setup(); - render(); + const { container } = render( + , + ); - const chartViewButton = screen.getByRole("button", { name: "Chart View" }); - await user.click(chartViewButton); + expect(showsChart(container)).toBe(false); + await clickControl(user, "Chart View"); - expect(chartViewButton).toHaveClass("bg-blue-100"); + expect(showsChart(container)).toBe(true); + expect(screen.queryByText("Spend (USD)")).not.toBeInTheDocument(); }); it("should switch to table view when table view button is clicked", async () => { const user = userEvent.setup(); - render(); + const { container } = render( + , + ); - const chartViewButton = screen.getByRole("button", { name: "Chart View" }); - const tableViewButton = screen.getByRole("button", { name: "Table View" }); + await clickControl(user, "Chart View"); + await clickControl(user, "Table View"); - await user.click(chartViewButton); - await user.click(tableViewButton); - - expect(tableViewButton).toHaveClass("bg-blue-100"); + expect(showsChart(container)).toBe(false); + expect(screen.getByText("Spend (USD)")).toBeInTheDocument(); }); it("renders one cyan bar per model with model names on the axis in chart view", async () => { @@ -108,7 +121,7 @@ describe("TopModelView", () => { />, ); - await user.click(screen.getByRole("button", { name: "Chart View" })); + await clickControl(user, "Chart View"); const bars = container.querySelectorAll("path.recharts-rectangle"); expect(bars).toHaveLength(2); @@ -118,19 +131,11 @@ describe("TopModelView", () => { expect(screen.getAllByText("claude-3").length).toBeGreaterThan(0); }); - it("should call setTopModelsLimit when limit is changed via Segmented control", async () => { + it("should call setTopModelsLimit when the limit control is changed", async () => { const user = userEvent.setup(); render(); - const limit10Radio = screen.getByRole("radio", { name: "10" }); - const limit10Label = limit10Radio.closest("label"); - if (limit10Label) { - await user.click(limit10Label); - } else { - // Fallback: click the div with title="10" - const limit10Div = screen.getByTitle("10"); - await user.click(limit10Div); - } + await clickControl(user, "10"); expect(mockSetTopModelsLimit).toHaveBeenCalledWith(10); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageAIChatPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageAIChatPanel.test.tsx index 54bf4fc25ce..d971f5d0506 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageAIChatPanel.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageAIChatPanel.test.tsx @@ -37,7 +37,10 @@ describe("UsageAIChatPanel", () => { it("should render model selector", () => { renderWithProviders(); - expect(screen.getByText("Select a model (optional, defaults to gpt-4o-mini)")).toBeInTheDocument(); + // One library paints the prompt as its own text node and the other leaves it on the input's + // placeholder attribute, so either one means the user is being told what to pick. + const prompt = "Select a model (optional, defaults to gpt-4o-mini)"; + expect(screen.queryAllByText(prompt).length + screen.queryAllByPlaceholderText(prompt).length).toBeGreaterThan(0); }); it("should render empty state message when no conversation", () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx index 9085cf961a9..9cd1e53c9b4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx @@ -4,6 +4,7 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser"; import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers"; import { act, fireEvent, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "@/../tests/test-utils"; import type { Organization } from "@/components/networking"; @@ -143,207 +144,6 @@ vi.mock("@/app/(dashboard)/hooks/users/useUsers", () => ({ useInfiniteUsers: vi.fn(), })); -vi.mock("antd", async (importOriginal) => { - const React = await import("react"); - const actual = await importOriginal(); - - function Select(props: any) { - const { value, onChange, options, ...rest } = props; - return React.createElement( - "select", - { - ...rest, - value, - onChange: (e: any) => onChange?.(e.target.value), - role: "combobox", - }, - options?.map((opt: any) => React.createElement("option", { key: opt.value, value: opt.value }, opt.label)), - ); - } - (Select as any).displayName = "AntdSelect"; - - function Alert(props: any) { - const { message, description, type, closable, onClose, ...rest } = props; - return React.createElement( - "div", - { ...rest, "data-testid": "antd-alert", "data-type": type }, - message && React.createElement("div", null, message), - description && React.createElement("div", null, description), - closable && React.createElement("button", { onClick: onClose, "aria-label": "Close" }, "×"), - ); - } - (Alert as any).displayName = "AntdAlert"; - - function Badge(props: any) { - const { count, color, children, ...rest } = props; - return React.createElement( - "div", - { ...rest, "data-testid": "antd-badge", "data-color": color }, - count && React.createElement("span", { "data-testid": "antd-badge-count" }, count), - children, - ); - } - (Badge as any).displayName = "AntdBadge"; - - function Table({ columns, dataSource, ...rest }: any) { - return React.createElement( - "div", - { ...rest, "data-testid": "antd-table" }, - columns?.map((col: any) => - React.createElement("div", { key: col.key, "data-testid": `column-${col.key}` }, col.title), - ), - dataSource?.map((row: any) => - React.createElement( - "div", - { key: row.key, "data-testid": `row-${row.key}` }, - columns?.map((col: any) => { - const value = col.render ? col.render(row[col.dataIndex], row) : row[col.dataIndex]; - return React.createElement("div", { key: col.key }, value); - }), - ), - ), - ); - } - (Table as any).displayName = "Table"; - - function Segmented(props: any) { - const { value, onChange, options, ...rest } = props; - return React.createElement( - "div", - { ...rest, "data-testid": "antd-segmented" }, - options?.map((opt: any) => - React.createElement( - "button", - { - key: opt.value, - onClick: () => onChange?.(opt.value), - "data-selected": value === opt.value, - }, - opt.label, - ), - ), - ); - } - (Segmented as any).displayName = "AntdSegmented"; - - function Tooltip(props: any) { - const { title, children, ...rest } = props; - return React.createElement("div", { ...rest, "data-testid": "antd-tooltip", title }, children); - } - (Tooltip as any).displayName = "AntdTooltip"; - - return { - ...actual, - Select, - Alert, - Badge, - Table, - Segmented, - Tooltip, - }; -}); - -vi.mock("@ant-design/icons", async () => { - const React = await import("react"); - - function Icon() { - return React.createElement("span"); - } - - function LoadingOutlined(props: any) { - return React.createElement("span", { "data-testid": "loading-icon", ...props }); - } - - return { - GlobalOutlined: Icon, - BankOutlined: Icon, - TeamOutlined: Icon, - ShoppingCartOutlined: Icon, - TagsOutlined: Icon, - RobotOutlined: Icon, - LineChartOutlined: Icon, - BarChartOutlined: Icon, - ClockCircleOutlined: Icon, - CalendarOutlined: Icon, - InfoCircleOutlined: Icon, - UserOutlined: Icon, - DownOutlined: Icon, - RightOutlined: Icon, - ExportOutlined: Icon, - LoadingOutlined, - }; -}); - -// Mock Tremor components -vi.mock("@tremor/react", async () => { - const React = await import("react"); - const actual = await import("@tremor/react"); - - function TabGroup({ children }: any) { - return React.createElement("div", { "data-testid": "tremor-tab-group" }, children); - } - - function TabList({ children }: any) { - return React.createElement("div", { "data-testid": "tremor-tab-list" }, children); - } - - function Tab({ children, ...props }: any) { - return React.createElement("button", { ...props, "data-testid": "tremor-tab" }, children); - } - - function TabPanels({ children }: any) { - return React.createElement("div", { "data-testid": "tremor-tab-panels" }, children); - } - - function TabPanel({ children }: any) { - return React.createElement("div", { "data-testid": "tremor-tab-panel" }, children); - } - - function Card({ children, ...props }: any) { - return React.createElement("div", { ...props, "data-testid": "tremor-card" }, children); - } - - function Grid({ children, numItems, ...props }: any) { - return React.createElement("div", { ...props, "data-testid": "tremor-grid" }, children); - } - - function Col({ children, numColSpan, ...props }: any) { - return React.createElement("div", { ...props, "data-testid": "tremor-col" }, children); - } - - function Title({ children, ...props }: any) { - return React.createElement("h2", { ...props, "data-testid": "tremor-title" }, children); - } - - function Text({ children, ...props }: any) { - return React.createElement("p", { ...props, "data-testid": "tremor-text" }, children); - } - - function Button({ children, icon, onClick, ...props }: any) { - return React.createElement( - "button", - { ...props, onClick, "data-testid": "tremor-button" }, - icon && React.createElement("span", { "data-testid": "tremor-button-icon" }), - children, - ); - } - - return { - ...actual, - TabGroup, - TabList, - Tab, - TabPanels, - TabPanel, - Card, - Grid, - Col, - Title, - Text, - Button, - }; -}); - describe("UsagePage", () => { const mockUserDailyActivityAggregatedCall = vi.mocked(networking.userDailyActivityAggregatedCall); const mockUserDailyActivityCall = vi.mocked(networking.userDailyActivityCall); @@ -885,6 +685,26 @@ describe("UsagePage", () => { }); describe("admin user selector", () => { + // Anchored on the field's own label, so it does not depend on which library draws the control. + const userSelectCombobox = (): HTMLElement => { + let node: HTMLElement | null = screen.getByText("Filter by user"); + while (node && !node.querySelector('[role="combobox"]')) { + node = node.parentElement; + } + const combobox = node?.querySelector('[role="combobox"]') ?? null; + expect(combobox).not.toBeNull(); + return combobox as HTMLElement; + }; + + const openUserSelect = async () => { + await userEvent.setup().click(userSelectCombobox()); + }; + + // One library paints the prompt as its own text node and the other leaves it on the input's + // placeholder attribute, so either one means the user is being told what to type. + const promptsWith = (text: string) => + screen.queryAllByText(text).length + screen.queryAllByPlaceholderText(text).length > 0; + it("should render user selector for admin users in global view", async () => { renderWithProviders(); @@ -892,10 +712,8 @@ describe("UsagePage", () => { expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); }); - // Admin should see the user selector select element with the placeholder attribute - const userSelects = screen.getAllByRole("combobox"); - const userSelect = userSelects.find((el) => el.getAttribute("placeholder") === "Select user to filter..."); - expect(userSelect).toBeDefined(); + expect(userSelectCombobox()).toBeInTheDocument(); + expect(promptsWith("Select user to filter...")).toBe(true); }); it("should format user options with alias when available", async () => { @@ -905,6 +723,8 @@ describe("UsagePage", () => { expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); }); + await openUserSelect(); + // User with alias should show "alias (id)" expect(screen.getByText("Alice (user-001)")).toBeInTheDocument(); // User without alias but with email should show "email (id)" @@ -958,6 +778,8 @@ describe("UsagePage", () => { expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); }); + await openUserSelect(); + // Duplicate user should appear only once const dupElements = screen.getAllByText("DupUser (user-dup)"); expect(dupElements).toHaveLength(1); @@ -1003,10 +825,9 @@ describe("UsagePage", () => { expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); }); - // Non-admin should not see the user selector - const userSelects = screen.getAllByRole("combobox"); - const userSelect = userSelects.find((el) => el.getAttribute("placeholder") === "Select user to filter..."); - expect(userSelect).toBeUndefined(); + // The admin case above proves this label is rendered when the selector exists, so its + // absence here is a live assertion rather than a query that can never match. + expect(screen.queryByText("Filter by user")).not.toBeInTheDocument(); }); it("should always pass own userId for non-admin users", async () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.test.tsx index dcc0ce06673..80005a20d05 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.test.tsx @@ -1,86 +1,16 @@ -import { act, fireEvent, render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { UsageViewSelect } from "./UsageViewSelect"; -vi.mock("antd", async () => { - const React = await import("react"); +const openMenu = async (user: ReturnType) => { + await user.click(screen.getByRole("combobox")); +}; - function Select(props: any) { - const { value, onChange, options, optionRender, labelRender, ...rest } = props; - - const optionElements = options?.map((opt: any) => - React.createElement("option", { key: opt.value, value: opt.value }, opt.label), - ); - - const optionRenderOutputs = options - ?.map((opt: any) => { - if (optionRender) { - const rendered = optionRender({ value: opt.value, label: opt.label }); - return React.createElement( - "div", - { - key: `option-render-${opt.value}`, - "data-testid": `option-render-${opt.value}`, - style: { display: "none" }, - }, - rendered, - ); - } - return null; - }) - .filter(Boolean); - - return React.createElement( - React.Fragment, - null, - React.createElement( - "select", - { - ...rest, - value, - onChange: (e: any) => onChange?.(e.target.value), - role: "combobox", - }, - optionElements, - ), - ...(optionRenderOutputs || []), - ); - } - (Select as any).displayName = "AntdSelect"; - - function Badge(props: any) { - const { count, color, children, ...rest } = props; - return React.createElement( - "span", - { ...rest, "data-testid": "antd-badge", "data-color": color }, - count && React.createElement("span", { "data-testid": "antd-badge-count" }, count), - children, - ); - } - (Badge as any).displayName = "AntdBadge"; - - return { Select, Badge }; -}); - -vi.mock("@ant-design/icons", async () => { - const React = await import("react"); - - function Icon(props: any) { - return React.createElement("span", { "data-testid": "antd-icon" }); - } - - return { - GlobalOutlined: Icon, - BankOutlined: Icon, - TeamOutlined: Icon, - ShoppingCartOutlined: Icon, - TagsOutlined: Icon, - RobotOutlined: Icon, - UserOutlined: Icon, - LineChartOutlined: Icon, - BarChartOutlined: Icon, - }; -}); +// The listbox is portalled outside the render container in both antd and Base UI, so an +// option is "offered" when the label appears more times on the page than inside the trigger. +const offers = (container: HTMLElement, label: string) => + screen.queryAllByText(label).length > within(container).queryAllByText(label).length; describe("UsageViewSelect", () => { const mockOnChange = vi.fn(); @@ -89,53 +19,73 @@ describe("UsageViewSelect", () => { mockOnChange.mockClear(); }); - it("should render", () => { - render(); + it("should render", async () => { + const user = userEvent.setup(); + const { container } = render(); expect(screen.getByText("Usage View")).toBeInTheDocument(); expect(screen.getByText("Select the usage data you want to view")).toBeInTheDocument(); expect(screen.getByRole("combobox")).toBeInTheDocument(); - expect(screen.getByRole("option", { name: "Your Usage" })).toBeInTheDocument(); + + await openMenu(user); + expect(offers(container, "Your Usage")).toBe(true); }); - it("should call onChange when value changes", () => { + it("should call onChange when value changes", async () => { + const user = userEvent.setup(); render(); - const select = screen.getByRole("combobox"); - act(() => { - fireEvent.change(select, { target: { value: "team" } }); - }); + await openMenu(user); + const matches = screen.getAllByText("Team Usage"); + await user.click(matches[matches.length - 1]); - expect(mockOnChange).toHaveBeenCalledWith("team"); + expect(mockOnChange).toHaveBeenCalled(); + expect(mockOnChange.mock.calls[0][0]).toBe("team"); }); - it("should show Tag Usage for non-admin users with tag usage permission", () => { - render(); + it("should show Tag Usage for non-admin users with tag usage permission", async () => { + const user = userEvent.setup(); + const { container } = render( + , + ); - expect(screen.getByRole("option", { name: "Tag Usage" })).toBeInTheDocument(); + await openMenu(user); + expect(offers(container, "Tag Usage")).toBe(true); }); - it("should hide Tag Usage for non-admin users without tag usage permission", () => { - render(); + it("should hide Tag Usage for non-admin users without tag usage permission", async () => { + const user = userEvent.setup(); + const { container } = render(); - expect(screen.queryByRole("option", { name: "Tag Usage" })).not.toBeInTheDocument(); + await openMenu(user); + expect(offers(container, "Tag Usage")).toBe(false); }); - it.each(["Organization Usage", "Agent Usage (A2A)"])("should show %s to an admin", (optionName) => { - render(); + it.each(["Organization Usage", "Agent Usage (A2A)"])("should show %s to an admin", async (optionName) => { + const user = userEvent.setup(); + const { container } = render(); - expect(screen.getByRole("option", { name: optionName })).toBeInTheDocument(); + await openMenu(user); + expect(offers(container, optionName)).toBe(true); }); - it.each(["Organization Usage", "Agent Usage (A2A)"])("should hide %s from an internal user", (optionName) => { - render(); + it.each(["Organization Usage", "Agent Usage (A2A)"])("should hide %s from an internal user", async (optionName) => { + const user = userEvent.setup(); + const { container } = render( + , + ); - expect(screen.queryByRole("option", { name: optionName })).not.toBeInTheDocument(); + await openMenu(user); + expect(offers(container, optionName)).toBe(false); }); - it.each(["Team Usage", "Tag Usage"])("should keep %s available to an internal user", (optionName) => { - render(); + it.each(["Team Usage", "Tag Usage"])("should keep %s available to an internal user", async (optionName) => { + const user = userEvent.setup(); + const { container } = render( + , + ); - expect(screen.getByRole("option", { name: optionName })).toBeInTheDocument(); + await openMenu(user); + expect(offers(container, optionName)).toBe(true); }); }); diff --git a/ui/litellm-dashboard/src/components/activity_metrics.test.tsx b/ui/litellm-dashboard/src/components/activity_metrics.test.tsx index cd37ea42a90..74d258e2bd0 100644 --- a/ui/litellm-dashboard/src/components/activity_metrics.test.tsx +++ b/ui/litellm-dashboard/src/components/activity_metrics.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import React from "react"; import { beforeAll, describe, expect, it, vi } from "vitest"; import { ActivityMetrics, formatKeyLabel, processActivityData } from "./activity_metrics"; @@ -15,38 +15,12 @@ beforeAll(() => { } }); -vi.mock("@tremor/react", () => ({ - Card: ({ children }: { children: React.ReactNode }) =>
{children}
, - Grid: ({ children }: { children: React.ReactNode }) =>
{children}
, - Text: ({ children }: { children: React.ReactNode }) => {children}, - Title: ({ children }: { children: React.ReactNode }) =>

{children}

, - AreaChart: () =>
AreaChart
, - BarChart: () =>
BarChart
, -})); - -vi.mock("antd", () => { - const CollapseComponent = ({ children }: { children: React.ReactNode }) =>
{children}
; - const PanelComponent = ({ children, header }: { children: React.ReactNode; header: React.ReactNode }) => ( -
-
{header}
-
{children}
-
- ); - PanelComponent.displayName = "Collapse.Panel"; - CollapseComponent.Panel = PanelComponent; - const TableComponent = ({ dataSource, columns }: { dataSource?: unknown[]; columns?: { title: string }[] }) => ( - - - {columns?.map((col, i) => )} - - {dataSource?.map((_, i) => )} -
{col.title}
- ); - return { - Collapse: CollapseComponent, - Table: TableComponent, - }; -}); +// Panel order is a contract; which element the label lands in is not, so compare document order. +const precedes = (firstLabel: string, secondLabel: string): boolean => { + const first = screen.getAllByText(firstLabel)[0]; + const second = screen.getAllByText(secondLabel)[0]; + return Boolean(first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING); +}; vi.mock("@/utils/dataUtils", async (importOriginal) => { const actual = await importOriginal(); @@ -256,10 +230,7 @@ describe("ActivityMetrics", () => { }; render(); - const headers = screen.getAllByRole("heading", { level: 2 }); - const gpt4Index = headers.findIndex((h) => h.textContent?.includes("GPT-4")); - const gpt35Index = headers.findIndex((h) => h.textContent?.includes("GPT-3.5")); - expect(gpt4Index).toBeLessThan(gpt35Index); + expect(precedes("GPT-4", "GPT-3.5")).toBe(true); }); it("should display model summary cards with correct values", () => { @@ -387,10 +358,27 @@ describe("ActivityMetrics", () => { }; render(); - const headings = screen.getAllByRole("heading", { level: 2 }); - const gpt4Index = headings.findIndex((h) => h.textContent?.includes("GPT-4")); - const unknownIndex = headings.findIndex((h) => h.textContent?.includes("Unknown")); - expect(gpt4Index).toBeLessThan(unknownIndex); + expect(precedes("GPT-4", "Unknown")).toBe(true); + }); + + // A model section owns view-mode state, so collapsing one must not throw its subtree away. + it("keeps a model section mounted once it has been expanded", () => { + const multipleModels: Record = { + "gpt-3.5": GPT_35_MODEL_DATA, + "gpt-4": { ...mockModelMetrics["gpt-4"], total_spend: 100.5 }, + }; + + render(); + + // Only the highest-spend section is expanded initially, so only its body is mounted. + const sectionsMounted = () => screen.getAllByText("Spend per day").length; + expect(sectionsMounted()).toBe(1); + + fireEvent.click(screen.getAllByText("GPT-3.5")[0]); + expect(sectionsMounted()).toBe(2); + + fireEvent.click(screen.getAllByText("GPT-3.5")[0]); + expect(sectionsMounted()).toBe(2); }); it("should display average tokens per successful request", () => { diff --git a/ui/litellm-dashboard/src/components/common_components/team_multi_select.test.tsx b/ui/litellm-dashboard/src/components/common_components/team_multi_select.test.tsx new file mode 100644 index 00000000000..82508472986 --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/team_multi_select.test.tsx @@ -0,0 +1,124 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useInfiniteTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; +import TeamMultiSelect from "./team_multi_select"; + +vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ + useInfiniteTeams: vi.fn(), +})); + +const team = (id: string, alias: string) => ({ team_id: id, team_alias: alias }); + +const mockTeamsResult = ( + overrides: Partial<{ + pages: { teams: ReturnType[] }[]; + isLoading: boolean; + hasNextPage: boolean; + isFetchingNextPage: boolean; + }> = {}, +) => { + const { pages = [{ teams: [team("team-1", "Alpha Team"), team("team-2", "Beta Team")] }], ...rest } = overrides; + return { + data: { pages }, + fetchNextPage: vi.fn(), + hasNextPage: false, + isFetchingNextPage: false, + isLoading: false, + ...rest, + }; +}; + +describe("TeamMultiSelect", () => { + const mockUseInfiniteTeams = vi.mocked(useInfiniteTeams); + + beforeEach(() => { + vi.clearAllMocks(); + mockUseInfiniteTeams.mockReturnValue(mockTeamsResult() as never); + }); + + const combobox = () => screen.getByRole("combobox"); + + // One library paints the prompt as its own text node and the other leaves it on the input's + // placeholder attribute, so either one means the user is being told what to type. + const promptsWith = (text: string) => + screen.queryAllByText(text).length + screen.queryAllByPlaceholderText(text).length > 0; + + it("renders a search control with the given placeholder", () => { + render(); + + expect(combobox()).toBeInTheDocument(); + expect(promptsWith("Search teams by alias...")).toBe(true); + }); + + it("offers every loaded team by alias and id", async () => { + const user = userEvent.setup(); + render(); + + await user.click(combobox()); + + expect(screen.getByText("Alpha Team")).toBeInTheDocument(); + expect(screen.getByText("(team-1)")).toBeInTheDocument(); + expect(screen.getByText("Beta Team")).toBeInTheDocument(); + expect(screen.getByText("(team-2)")).toBeInTheDocument(); + }); + + it("deduplicates a team that appears on more than one page", async () => { + mockUseInfiniteTeams.mockReturnValue( + mockTeamsResult({ + pages: [ + { teams: [team("team-1", "Alpha Team")] }, + { teams: [team("team-1", "Alpha Team"), team("team-2", "Beta Team")] }, + ], + }) as never, + ); + const user = userEvent.setup(); + render(); + + await user.click(combobox()); + + expect(screen.getAllByText("Alpha Team")).toHaveLength(1); + expect(screen.getByText("Beta Team")).toBeInTheDocument(); + }); + + it("reports the picked team id to onChange", async () => { + const onChange = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.click(combobox()); + const matches = screen.getAllByText("Beta Team"); + await user.click(matches[matches.length - 1]); + + expect(onChange).toHaveBeenCalled(); + expect(onChange.mock.calls[0][0]).toEqual(["team-2"]); + }); + + it("does not report a selection while disabled", async () => { + const onChange = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.click(combobox()); + + expect(screen.queryByText("Alpha Team")).not.toBeInTheDocument(); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("tells the user when there are no teams to pick", async () => { + mockUseInfiniteTeams.mockReturnValue(mockTeamsResult({ pages: [{ teams: [] }] }) as never); + const user = userEvent.setup(); + render(); + + await user.click(combobox()); + + // Substring match because one library appends an invisible word joiner for its live region. + expect(screen.getByText(/No teams found/)).toBeInTheDocument(); + }); + + it("passes the page size and organization filter through to the teams query", () => { + render(); + + expect(mockUseInfiniteTeams).toHaveBeenCalledWith(25, undefined, "org-7"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/per_user_usage.test.tsx b/ui/litellm-dashboard/src/components/per_user_usage.test.tsx index 66aaf015aca..443ae66af8f 100644 --- a/ui/litellm-dashboard/src/components/per_user_usage.test.tsx +++ b/ui/litellm-dashboard/src/components/per_user_usage.test.tsx @@ -29,6 +29,14 @@ const userRow = (userId: string, userAgent: string | null, successfulRequests: n spend: 1, }); +// The distribution panel owns the only chart in this component, so resolving it by slot keeps +// the assertions independent of how many wrappers the tab library puts around a panel. +const distributionChart = (): HTMLElement => { + const chart = document.querySelector('[data-slot="chart"]'); + expect(chart).not.toBeNull(); + return chart as HTMLElement; +}; + describe("PerUserUsage", () => { const mockPerUserAnalyticsCall = vi.mocked(networking.perUserAnalyticsCall); @@ -70,6 +78,22 @@ describe("PerUserUsage", () => { }); }); + it("keeps both tab panels mounted so switching tabs does not reset their state", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("u1")).toBeInTheDocument(); + }); + + // Still on the User Details tab: the distribution panel is mounted alongside it. + expect(screen.getByText("User Usage Distribution")).toBeInTheDocument(); + + fireEvent.click(screen.getByText("Usage Distribution")); + + // And the details panel survives the switch rather than unmounting. + expect(screen.getByText("u1")).toBeInTheDocument(); + }); + it("renders the usage distribution as a stacked bar chart with the explicit palette and users formatter", async () => { render(); @@ -79,26 +103,22 @@ describe("PerUserUsage", () => { fireEvent.click(screen.getByText("Usage Distribution")); - const panel = screen.getByText("User Usage Distribution").closest("div")?.parentElement; - expect(panel).not.toBeNull(); - await waitFor(() => { - expect(panel!.querySelectorAll("path.recharts-rectangle")).toHaveLength(4); + expect(distributionChart().querySelectorAll("path.recharts-rectangle")).toHaveLength(4); }); - const chart = panel!.querySelector('[data-slot="chart"]'); - expect(chart).not.toBeNull(); - expect(chart!.querySelectorAll(".recharts-bar")).toHaveLength(2); + const chart = distributionChart(); + expect(chart.querySelectorAll(".recharts-bar")).toHaveLength(2); - const rectangles = Array.from(chart!.querySelectorAll("path.recharts-rectangle")); + const rectangles = Array.from(chart.querySelectorAll("path.recharts-rectangle")); const fills = new Set(rectangles.map((rect) => rect.getAttribute("fill"))); expect(fills).toEqual(new Set(["var(--color-blue-500, #3b82f6)", "var(--color-green-500, #22c55e)"])); const xPositions = new Set(rectangles.map((rect) => rect.getAttribute("d")?.match(/^M\s*([\d.]+)/)?.[1])); expect(xPositions.size).toBe(3); - expect(chart!.textContent).toContain("curl/8.0"); - expect(chart!.textContent).toContain("Unknown"); + expect(chart.textContent).toContain("curl/8.0"); + expect(chart.textContent).toContain("Unknown"); for (const bucket of [ "1-9 requests", "10-99 requests", @@ -107,10 +127,10 @@ describe("PerUserUsage", () => { "10K-99.9K requests", "100K+ requests", ]) { - expect(chart!.textContent).toContain(bucket); + expect(chart.textContent).toContain(bucket); } - const tickTexts = Array.from(chart!.querySelectorAll(".recharts-cartesian-axis-tick-value")).map( + const tickTexts = Array.from(chart.querySelectorAll(".recharts-cartesian-axis-tick-value")).map( (tick) => tick.textContent ?? "", ); expect(tickTexts.some((tick) => / users$/.test(tick))).toBe(true); diff --git a/ui/litellm-dashboard/src/components/user_agent_activity.test.tsx b/ui/litellm-dashboard/src/components/user_agent_activity.test.tsx index b2facde3e65..07a03f4addf 100644 --- a/ui/litellm-dashboard/src/components/user_agent_activity.test.tsx +++ b/ui/litellm-dashboard/src/components/user_agent_activity.test.tsx @@ -177,40 +177,61 @@ describe("UserAgentActivity", () => { // Check that filter label is present expect(screen.getByText("Filter by User Agents")).toBeInTheDocument(); - // The Ant Design Select component should be in the document with placeholder - const selectElement = screen.getByText("All User Agents"); - expect(selectElement).toBeInTheDocument(); + // One library paints the prompt as its own text node and the other leaves it on the input's + // placeholder attribute, so either one means the user is being told what the filter does. + const prompts = + screen.queryAllByText("All User Agents").length + screen.queryAllByPlaceholderText("All User Agents").length; + expect(prompts).toBeGreaterThan(0); }); - const getPanelForTitle = (title: string): HTMLElement => { - // Assumes two wrapper divs between the Tremor and the panel root; update if Tremor's TabPanel depth changes. - const panel = screen.getByText(title).closest("div")?.parentElement; - expect(panel).not.toBeNull(); - return panel!; + // Walks up from the panel's heading to the nearest ancestor that owns a chart, so the + // assertions do not depend on how many wrappers the tab library puts around a panel. + const chartForTitle = (title: string): HTMLElement => { + let node: HTMLElement | null = screen.getByText(title); + while (node && !node.querySelector('[data-slot="chart"]')) { + node = node.parentElement; + } + const chart = node?.querySelector('[data-slot="chart"]') ?? null; + expect(chart).not.toBeNull(); + return chart as HTMLElement; }; - const expectStackedTwoCategoryChart = (panel: HTMLElement, firstBucketLabel: string) => { - const chart = panel.querySelector('[data-slot="chart"]'); - expect(chart).not.toBeNull(); - expect(chart!.querySelectorAll(".recharts-bar")).toHaveLength(2); + const expectStackedTwoCategoryChart = (chart: HTMLElement, firstBucketLabel: string) => { + expect(chart.querySelectorAll(".recharts-bar")).toHaveLength(2); - const rectangles = Array.from(chart!.querySelectorAll("path.recharts-rectangle")); + const rectangles = Array.from(chart.querySelectorAll("path.recharts-rectangle")); const fills = new Set(rectangles.map((rect) => rect.getAttribute("fill"))); expect(fills).toEqual(new Set(["var(--color-blue-500, #3b82f6)", "var(--color-cyan-500, #06b6d4)"])); const xPositions = new Set(rectangles.map((rect) => rect.getAttribute("d")?.match(/^M\s*([\d.]+)/)?.[1])); expect(xPositions.size).toBe(1); - expect(chart!.textContent).toContain("Chrome/1.0"); - expect(chart!.textContent).toContain("Firefox/2.0"); - expect(chart!.textContent).toContain(firstBucketLabel); + expect(chart.textContent).toContain("Chrome/1.0"); + expect(chart.textContent).toContain("Firefox/2.0"); + expect(chart.textContent).toContain(firstBucketLabel); - const tickTexts = Array.from(chart!.querySelectorAll(".recharts-cartesian-axis-tick-value")).map( + const tickTexts = Array.from(chart.querySelectorAll(".recharts-cartesian-axis-tick-value")).map( (tick) => tick.textContent ?? "", ); expect(tickTexts.some((tick) => /^\d+K$/.test(tick))).toBe(true); }; + it("keeps every tab panel mounted so switching tabs does not reset their state", async () => { + render(<UserAgentActivity {...defaultProps} />); + + await waitFor(() => { + expect(mockTagDauCall).toHaveBeenCalled(); + }); + + // No tab has been clicked: the inactive DAU/WAU/MAU panels are mounted alongside the active one. + expect(screen.getByText("Daily Active Users - Last 7 Days")).toBeInTheDocument(); + expect(screen.getByText("Weekly Active Users - Last 7 Weeks")).toBeInTheDocument(); + expect(screen.getByText("Monthly Active Users - Last 7 Months")).toBeInTheDocument(); + + // And so is the second panel of the outer tab group. + expect(screen.getByText("Per User Usage")).toBeInTheDocument(); + }); + it("renders the DAU chart stacked with default color cycle and abbreviated axis ticks", async () => { const firstBucketDate = new Date(); firstBucketDate.setDate(firstBucketDate.getDate() - 6); @@ -224,12 +245,16 @@ describe("UserAgentActivity", () => { render(<UserAgentActivity {...defaultProps} />); - const panel = getPanelForTitle("Daily Active Users - Last 7 Days"); await waitFor(() => { - expect(panel.querySelectorAll("path.recharts-rectangle")).toHaveLength(2); + expect( + chartForTitle("Daily Active Users - Last 7 Days").querySelectorAll("path.recharts-rectangle"), + ).toHaveLength(2); }); - expectStackedTwoCategoryChart(panel, firstBucketDate.toISOString().split("T")[0]); + expectStackedTwoCategoryChart( + chartForTitle("Daily Active Users - Last 7 Days"), + firstBucketDate.toISOString().split("T")[0], + ); }); it("renders the WAU chart stacked with week buckets and abbreviated axis ticks", async () => { @@ -242,12 +267,13 @@ describe("UserAgentActivity", () => { render(<UserAgentActivity {...defaultProps} />); - const panel = getPanelForTitle("Weekly Active Users - Last 7 Weeks"); await waitFor(() => { - expect(panel.querySelectorAll("path.recharts-rectangle")).toHaveLength(2); + expect( + chartForTitle("Weekly Active Users - Last 7 Weeks").querySelectorAll("path.recharts-rectangle"), + ).toHaveLength(2); }); - expectStackedTwoCategoryChart(panel, "Week 1"); + expectStackedTwoCategoryChart(chartForTitle("Weekly Active Users - Last 7 Weeks"), "Week 1"); }); it("renders the MAU chart stacked with month buckets and abbreviated axis ticks", async () => { @@ -260,11 +286,12 @@ describe("UserAgentActivity", () => { render(<UserAgentActivity {...defaultProps} />); - const panel = getPanelForTitle("Monthly Active Users - Last 7 Months"); await waitFor(() => { - expect(panel.querySelectorAll("path.recharts-rectangle")).toHaveLength(2); + expect( + chartForTitle("Monthly Active Users - Last 7 Months").querySelectorAll("path.recharts-rectangle"), + ).toHaveLength(2); }); - expectStackedTwoCategoryChart(panel, "Month 1"); + expectStackedTwoCategoryChart(chartForTitle("Monthly Active Users - Last 7 Months"), "Month 1"); }); }); From c5c98cf706638c2311409e533d0956620b625f3c Mon Sep 17 00:00:00 2001 From: Yuneng Jiang <yuneng@berri.ai> Date: Thu, 13 Aug 2026 14:56:58 -0700 Subject: [PATCH 08/31] test(ui): characterise GuardrailsOverview before the shadcn migration Covers the header and export action, all five summary metric cards, the table toolbar heading, the evaluation settings modal wiring, the busy state and the request failure message. Every assertion is role, title or text based so it holds against both the antd markup and its shadcn replacement, letting the migration commit land without editing this file --- .../_components/GuardrailsOverview.test.tsx | 70 ++++++++++++++++++- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.test.tsx index 9bfa71f77be..b616982d69b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -14,7 +14,7 @@ vi.mock("./ScoreChart", () => ({ })); vi.mock("./EvaluationSettingsModal", () => ({ - EvaluationSettingsModal: () => null, + EvaluationSettingsModal: ({ open }: { open: boolean }) => (open ? <div>Evaluation settings modal</div> : null), })); const mockGetGuardrailsUsageOverview = vi.mocked(networking.getGuardrailsUsageOverview); @@ -28,6 +28,18 @@ function wrapper({ children }: { children: React.ReactNode }) { return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>; } +function renderOverview(onSelectGuardrail = vi.fn()) { + return render( + <GuardrailsOverview + accessToken="test-token" + startDate="2026-08-01" + endDate="2026-08-12" + onSelectGuardrail={onSelectGuardrail} + />, + { wrapper }, + ); +} + describe("GuardrailsOverview", () => { beforeEach(() => { vi.clearAllMocks(); @@ -92,4 +104,58 @@ describe("GuardrailsOverview", () => { expect(onSelectGuardrail).toHaveBeenCalledWith("guardrail-low"); }); + + it("renders the page header and the export action", async () => { + renderOverview(); + + expect(await screen.findByRole("heading", { name: "Guardrails Monitor", level: 1 })).toBeInTheDocument(); + expect(screen.getByText("Monitor guardrail performance across all requests")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Export Data/i })).toBeInTheDocument(); + }); + + it("renders every summary metric card", async () => { + renderOverview(); + + expect(await screen.findByText("1,500")).toBeInTheDocument(); + expect(screen.getByText("Total Evaluations")).toBeInTheDocument(); + expect(screen.getByText("Blocked Requests")).toBeInTheDocument(); + expect(screen.getByText("84")).toBeInTheDocument(); + expect(screen.getByText("Pass Rate")).toBeInTheDocument(); + expect(screen.getByText("94.4%")).toBeInTheDocument(); + expect(screen.getByText("23ms")).toBeInTheDocument(); + expect(screen.getByText("Active Guardrails")).toBeInTheDocument(); + expect(screen.getByText("2")).toBeInTheDocument(); + }); + + it("renders the table toolbar heading and its description", async () => { + renderOverview(); + + expect(await screen.findByRole("heading", { name: "Guardrail Performance", level: 5 })).toBeInTheDocument(); + expect(screen.getByText("Click a guardrail to view details, logs, and configuration")).toBeInTheDocument(); + }); + + it("opens the evaluation settings modal from the toolbar action", async () => { + const user = userEvent.setup(); + renderOverview(); + + expect(screen.queryByText("Evaluation settings modal")).not.toBeInTheDocument(); + + await user.click(await screen.findByTitle("Evaluation settings")); + + expect(await screen.findByText("Evaluation settings modal")).toBeInTheDocument(); + }); + + it("marks the overview busy while the usage request is in flight", async () => { + mockGetGuardrailsUsageOverview.mockReturnValue(new Promise(() => {})); + renderOverview(); + + await waitFor(() => expect(document.querySelector('[aria-busy="true"]')).toBeInTheDocument()); + }); + + it("shows a failure message when the usage request rejects", async () => { + mockGetGuardrailsUsageOverview.mockRejectedValue(new Error("network down")); + renderOverview(); + + expect(await screen.findByText("Failed to load data. Try again.")).toBeInTheDocument(); + }); }); From b69177712efe0cc7752a5b53b72254fbf60a79bd Mon Sep 17 00:00:00 2001 From: Yuneng Jiang <yuneng@berri.ai> Date: Thu, 13 Aug 2026 15:02:40 -0700 Subject: [PATCH 09/31] refactor(ui): migrate usage to shadcn Replaces antd and Tremor markup on the usage route with the installed shadcn base-vega primitives. Behaviour is unchanged: the route's characterisation tests were rewritten to role and text queries in the previous commit, proven green against the antd components, and pass through this commit unedited. Tremor tab panels stayed mounted once rendered, so every migrated TabsContent and the collapsible model sections in activity_metrics carry keepMounted to keep view-mode and expansion state alive across tab switches. --- ui/litellm-dashboard/eslint-suppressions.json | 54 +- .../components/EndpointUsageTable.tsx | 18 +- .../components/EntityUsage/EntityUsage.tsx | 338 ++++---- .../EntityUsage/SpendByProvider.tsx | 44 +- .../components/EntityUsage/TopModelView.tsx | 47 +- .../components/UsageAIChatPanel.tsx | 62 +- .../_components/components/UsagePageView.tsx | 763 +++++++++--------- .../UsageViewSelect/UsageViewSelect.tsx | 99 +-- .../EntityUsageExportModal.tsx | 81 +- .../ExportFormatSelector.tsx | 34 +- .../EntityUsageExport/ExportTypeSelector.tsx | 55 +- .../EntityUsageExport/UsageExportHeader.tsx | 116 ++- .../src/components/activity_metrics.tsx | 401 +++++---- .../common_components/team_multi_select.tsx | 132 +-- .../src/components/per_user_usage.tsx | 225 +++--- .../src/components/user_agent_activity.tsx | 320 ++++---- 16 files changed, 1454 insertions(+), 1335 deletions(-) diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 2f4def9ffa2..c94686b2f1e 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1658,25 +1658,12 @@ "src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageTable.tsx": { "no-nested-ternary": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx": { "local/no-complex-jsx-arrow": { "count": 2 }, - "no-restricted-imports": { - "count": 2 - } - }, - "src/app/(dashboard)/usage/_components/components/EntityUsage/SpendByProvider.tsx": { - "no-restricted-imports": { - "count": 2 - } - }, - "src/app/(dashboard)/usage/_components/components/EntityUsage/TopModelView.tsx": { "no-restricted-imports": { "count": 1 } @@ -1685,9 +1672,6 @@ "no-nested-ternary": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/immutability": { "count": 1 } @@ -1703,7 +1687,7 @@ "count": 1 }, "no-restricted-imports": { - "count": 2 + "count": 1 }, "react-hooks/purity": { "count": 1 @@ -1712,14 +1696,6 @@ "count": 3 } }, - "src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.tsx": { - "local/no-complex-jsx-arrow": { - "count": 2 - }, - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts": { "react-hooks/refs": { "count": 1 @@ -1982,29 +1958,14 @@ "count": 1 } }, - "src/components/EntityUsageExport/EntityUsageExportModal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "src/components/EntityUsageExport/ExportFormatSelector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/EntityUsageExport/ExportSummary.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/components/EntityUsageExport/ExportTypeSelector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/EntityUsageExport/UsageExportHeader.tsx": { "no-restricted-imports": { - "count": 3 + "count": 1 } }, "src/components/EntityUsageExport/types.ts": { @@ -2289,9 +2250,6 @@ }, "no-nested-ternary": { "count": 1 - }, - "no-restricted-imports": { - "count": 2 } }, "src/components/add_model/AdaptiveRoutingConfig.tsx": { @@ -2794,9 +2752,6 @@ "src/components/common_components/team_multi_select.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/components/common_components/user_search_modal.tsx": { @@ -3171,9 +3126,6 @@ "local/filename-pascal-case": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -3714,7 +3666,7 @@ "count": 1 }, "no-restricted-imports": { - "count": 3 + "count": 1 }, "react-hooks/set-state-in-effect": { "count": 1 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageTable.tsx index 7b6bd57e758..5f72caae437 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageTable.tsx @@ -1,6 +1,6 @@ import React from "react"; -import { Progress } from "antd"; import type { ColumnDef } from "@tanstack/react-table"; +import { Meter, MeterIndicator, MeterTrack } from "@/components/ui/meter"; import { DataTable } from "@/components/shared/DataTable"; import { MoneyCell } from "@/components/shared/table_cells"; import { MetricWithMetadata } from "@/components/UsagePage/types"; @@ -53,19 +53,15 @@ const EndpointUsageTable: React.FC<EndpointUsageTableProps> = ({ endpointData }) const failurePercentage = record.api_requests > 0 ? (record.failed_requests / record.api_requests) * 100 : 0; const totalPercentage = successPercentage + failurePercentage; - const strokeColorConfig: Record<string, string> = { - "0%": "#22c55e", - }; - if (successPercentage > 0 && successPercentage < 100) { - strokeColorConfig[`${successPercentage}%`] = "#22c55e"; - strokeColorConfig[`${successPercentage + 0.01}%`] = "#ef4444"; - } - strokeColorConfig["100%"] = failurePercentage > 0 ? "#ef4444" : "#22c55e"; - return ( <div className="flex items-center space-x-3"> <div className="flex-1 relative"> - <Progress percent={totalPercentage} size="small" strokeColor={strokeColorConfig} showInfo={false} /> + {/* The failed share is the track showing through behind the successful share. */} + <Meter value={successPercentage} max={totalPercentage || 100} aria-label="Successful requests"> + <MeterTrack className={failurePercentage > 0 ? "bg-red-500" : undefined}> + <MeterIndicator className="bg-green-500" /> + </MeterTrack> + </Meter> </div> <div className="flex items-center space-x-2 text-sm min-w-[100px]"> <span className="text-green-600 font-medium">{record.successful_requests.toLocaleString()}</span> diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx index 3a22eb7fd10..2d6dbb823cc 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx @@ -14,23 +14,13 @@ import { MoneyCell } from "@/components/shared/table_cells"; import { Card as ShadcnCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { hasCapability, type Capability } from "@/utils/capabilities"; import { formatNumberWithCommas } from "@/utils/dataUtils"; -import { - Card, - Col, - DateRangePickerValue, - Grid, - Subtitle, - Tab, - TabGroup, - TabList, - TabPanel, - TabPanels, - Text, - Title, -} from "@tremor/react"; -import { DownOutlined, ExportOutlined, InfoCircleOutlined, LoadingOutlined, RightOutlined } from "@ant-design/icons"; +import type { DateRangePickerValue } from "@tremor/react"; +import { ChevronDown, ChevronRight, ExternalLink, Info, Loader2 } from "lucide-react"; import type { ColumnDef } from "@tanstack/react-table"; -import { Alert, Button, Tooltip } from "antd"; +import { Alert, AlertDescription } from "@/components/shared/Alert"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import React, { type ReactNode, useMemo, useState } from "react"; import TeamMultiSelect from "@/components/common_components/team_multi_select"; import { ActivityMetrics, processActivityData } from "@/components/activity_metrics"; @@ -341,23 +331,29 @@ const EntityUsage: React.FC<EntityUsageProps> = ({ [], ); - const chev = "text-gray-400 text-xs"; - const expandIcon = showCostBreakdown ? <DownOutlined className={chev} /> : <RightOutlined className={chev} />; - const infoIcon = <InfoCircleOutlined className="text-gray-400 hover:text-gray-600" />; + const chev = "size-3 text-gray-400"; + const expandIcon = showCostBreakdown ? <ChevronDown className={chev} /> : <ChevronRight className={chev} />; const renderSummaryTile = ({ title, value, className, tooltip, expandable }: SummaryTile) => ( - <Card + <ShadcnCard key={title} className={expandable ? "cursor-pointer hover:bg-gray-50 transition-colors" : undefined} onClick={expandable ? () => setShowCostBreakdown(!showCostBreakdown) : undefined} > - <div className="flex items-center gap-2"> - <Title>{title} - {tooltip ? {infoIcon} : null} - {expandable ? expandIcon : null} - - {value} - + +
+

{title}

+ {tooltip ? ( + + } /> + {tooltip} + + ) : null} + {expandable ? expandIcon : null} +
+

{value}

+
+ ); const breakdownTiles = showFlatCost && showCostBreakdown ? buildCostBreakdownTiles(spendData.metadata) : []; @@ -366,18 +362,18 @@ const EntityUsage: React.FC = ({ const modelViewTitle = modelViewType === "groups" ? "Top Public Model Names" : "Top Litellm Models"; const costPanel = ( - - - - {capitalizedEntityLabel} Spend Overview - - {summaryTiles.map(renderSummaryTile)} - - - +
+
+ + +

{capitalizedEntityLabel} Spend Overview

+
{summaryTiles.map(renderSummaryTile)}
+
+
+
{/* Daily Spend Chart */} - +
Daily Spend @@ -451,15 +447,15 @@ const EntityUsage: React.FC = ({ /> - +
{/* Entity Breakdown Section */} - - -
+
+ +
- Spend Per {capitalizedEntityLabel} - Showing Top 5 by Spend +

Spend Per {capitalizedEntityLabel}

+

Showing Top 5 by Spend

Get Started by Tracking cost per {capitalizedEntityLabel} = ({
- - +
+
= ({ ); }} /> - - +
+
entity.metrics.spend > 0)} @@ -509,61 +505,69 @@ const EntityUsage: React.FC = ({ noDataMessage={`No ${entityType} spend data`} size="compact" /> - - -
- - +
+
+ + +
{/* Top API Keys */} - - - Top Virtual Keys - - - +
+ + +

Top Virtual Keys

+ +
+
+
{/* Top Models */} - - -
- {entityType === "agent" ? "Top Agents" : modelViewTitle} - -
- -
- +
+ + +
+

+ {entityType === "agent" ? "Top Agents" : modelViewTitle} +

+ +
+ +
+
+
{showAgentBreakdown && ( - - - Top Agents Driving Spend - - - +
+ + +

Top Agents Driving Spend

+ +
+
+
)} {/* Spend by Provider */} - - -
- Provider Usage - - +
+ + +

Provider Usage

+
+
= ({ startAngle={90} endAngle={-270} /> - - +
+
= ({ noDataMessage="No provider usage data" size="compact" /> - - -
- - - +
+
+ + +
+
); const tabs: readonly { key: string; label: string; content: ReactNode }[] = [ @@ -620,80 +624,60 @@ const EntityUsage: React.FC = ({ return (
{isFetchingMore && ( - - - - Currently fetching spend data: fetched {progress.currentPage} / {progress.totalPages} pages. Charts will - update periodically as data loads. Moving off of this page will stop and reset this. To continue using - the UI in the meantime,{" "} - - open a new tab - - . - - -
- } - /> + + + + + Currently fetching spend data: fetched {progress.currentPage} / {progress.totalPages} pages. Charts will + update periodically as data loads. Moving off of this page will stop and reset this. To continue using the + UI in the meantime,{" "} + + open a new tab + + . + + + + )} {cancelled && ( - - Showing partial data ({progress.currentPage}/{progress.totalPages} pages loaded) - - } - /> + + + Showing partial data ({progress.currentPage}/{progress.totalPages} pages loaded) + + )} {agentIsFetchingMore && showAgentBreakdown && ( - - - - Currently fetching agent data: fetched {agentProgress.currentPage} / {agentProgress.totalPages} pages. - Charts will update periodically as data loads. Moving off of this page will stop and reset this. To - continue using the UI in the meantime,{" "} - - open a new tab - - . - - - - } - /> + + + + + Currently fetching agent data: fetched {agentProgress.currentPage} / {agentProgress.totalPages} pages. + Charts will update periodically as data loads. Moving off of this page will stop and reset this. To + continue using the UI in the meantime,{" "} + + open a new tab + + . + + + + )} {agentCancelled && showAgentBreakdown && ( - - Showing partial agent data ({agentProgress.currentPage}/{agentProgress.totalPages} pages loaded) - - } - /> + + + Showing partial agent data ({agentProgress.currentPage}/{agentProgress.totalPages} pages loaded) + + )} {entityType === "team" && (
- Filter by team +

Filter by team

)} @@ -710,18 +694,20 @@ const EntityUsage: React.FC = ({ filterMode={entityType === "user" ? "single" : "multiple"} teams={teams || []} /> - - + + {tabs.map(({ key, label }) => ( - {label} + + {label} + ))} - - - {tabs.map(({ key, content }) => ( - {content} - ))} - - + + {tabs.map(({ key, content }) => ( + + {content} + + ))} + ); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/SpendByProvider.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/SpendByProvider.tsx index ac8ae0e67e1..38f055f65f3 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/SpendByProvider.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/SpendByProvider.tsx @@ -2,10 +2,11 @@ import { DonutChart } from "@/components/shared/charts"; import { DataTable } from "@/components/shared/DataTable"; import { MoneyCell } from "@/components/shared/table_cells"; import { formatNumberWithCommas } from "@/utils/dataUtils"; -import { InfoCircleOutlined } from "@ant-design/icons"; +import { Info } from "lucide-react"; import type { ColumnDef } from "@tanstack/react-table"; -import { Card, Col, Grid, Switch, Title } from "@tremor/react"; -import { Tooltip } from "antd"; +import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Switch } from "@/components/ui/switch"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import React, { useState } from "react"; import { ProviderLogo } from "@/components/molecules/models/ProviderLogo"; import { ChartLoader } from "@/components/shared/chart_loader"; @@ -85,29 +86,30 @@ const SpendByProvider: React.FC = ({ loading, isDateChangi return ( -
- Spend by Provider -
+ + Spend by Provider +
- +
- - + + } /> + Requests that failed to route to a provider
- +
-
-
- {loading ? ( - - ) : ( - - + + + + {loading ? ( + + ) : ( +
= ({ loading, isDateChangi startAngle={90} endAngle={-270} /> - - = ({ loading, isDateChangi noDataMessage="No provider usage data" size="compact" /> - - - )} +
+ )} +
); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TopModelView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TopModelView.tsx index 2e2880e8763..2f5b750c118 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TopModelView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TopModelView.tsx @@ -1,7 +1,7 @@ import { BarChart } from "@/components/shared/charts"; import { DataTable } from "@/components/shared/DataTable"; import { MoneyCell } from "@/components/shared/table_cells"; -import { Segmented } from "antd"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useState } from "react"; import { formatNumberWithCommas } from "@/utils/dataUtils"; @@ -19,6 +19,8 @@ interface TopModelViewProps { setTopModelsLimit: (limit: number) => void; } +export const TOP_MODEL_LIMITS = [5, 10, 25, 50]; + export default function TopModelView({ topModels, topModelsLimit, setTopModelsLimit }: TopModelViewProps) { const [modelViewMode, setModelViewMode] = useState<"chart" | "table">("table"); @@ -58,30 +60,25 @@ export default function TopModelView({ topModels, topModelsLimit, setTopModelsLi return ( <>
- setTopModelsLimit(value as number)} - /> -
- - -
+ setTopModelsLimit(Number(value))}> + + {TOP_MODEL_LIMITS.map((limit) => ( + + {limit} + + ))} + + + setModelViewMode(value as "chart" | "table")}> + + + Table View + + + Chart View + + +
{modelViewMode === "chart" ? (
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageAIChatPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageAIChatPanel.tsx index 9d6b09353a2..21314594cb4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageAIChatPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageAIChatPanel.tsx @@ -1,10 +1,18 @@ import React, { useEffect, useRef, useState } from "react"; -import { Button, Select, Input, Spin } from "antd"; import ReactMarkdown from "react-markdown"; +import { Button } from "@/components/ui/button"; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@/components/ui/combobox"; +import { Textarea } from "@/components/ui/textarea"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { modelHubCall, usageAiChatStream, UsageAiToolCallEvent } from "@/components/networking"; -const { TextArea } = Input; - interface ToolCallStep { tool_name: string; tool_label: string; @@ -41,7 +49,7 @@ const ToolCallDisplay: React.FC<{ step: ToolCallStep }> = ({ step }) => {
{step.status === "running" ? ( - + ) : step.status === "error" ? ( ✗ ) : ( @@ -259,18 +267,29 @@ const UsageAIChatPanel: React.FC = ({ open, onClose, acce {/* Model selector */}
-