From 63464974980e825ec226052d8ed2d786c8dde975 Mon Sep 17 00:00:00 2001 From: yassin Date: Thu, 13 Aug 2026 13:01:40 +0000 Subject: [PATCH 01/49] 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 cfbd43172aab1614f07193dc498b72b7cb02f670 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:49:07 -0700 Subject: [PATCH 02/49] fix(scripts): end make check with a ran/skipped summary and verdict --- scripts/pre_commit_lint.sh | 26 ++++++++++++++ tests/test_litellm/test_pre_commit_lint.py | 40 ++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/scripts/pre_commit_lint.sh b/scripts/pre_commit_lint.sh index afe55603466..e19012613cd 100755 --- a/scripts/pre_commit_lint.sh +++ b/scripts/pre_commit_lint.sh @@ -281,4 +281,30 @@ if [ -n "${gen_pid:-}" ]; then cat "$gen_log"; rm -f "$gen_log" fi +summary_item() { + local check_name=$1 triggered=$2 skip_reason=$3 + if [ -n "$triggered" ]; then + echo " ran: $check_name" + else + echo " skipped: $check_name ($skip_reason)" + fi +} + +echo "check: summary" +summary_item "Python lint (make lint)" "$litellm_py_files" "no litellm/ Python files in scope" +summary_item "tests/e2e checks (basedpyright + raw HTTP client ban)" "$e2e_py_files" "no tests/e2e Python files in scope" +summary_item "dashboard lint (prettier + eslint + lint budgets)" "$ui_prettier_changed$ui_eslint_changed" "no dashboard files in scope" +summary_item "dashboard API-type sync (npm run gen:api)" "$spec_files" "no litellm/proxy, litellm/types, or generator files in scope" + +if [ -z "$litellm_py_files$e2e_py_files$ui_prettier_changed$ui_eslint_changed$spec_files" ]; then + echo "check: NOTE - no gating lint check matches the files in scope, so nothing ran:" >&2 + printf '%s\n' "$scope" | sed 's/^/ /' >&2 + echo " A pass here is a no-op, not a lint verdict." >&2 +fi + +if [ "$status" -eq 0 ]; then + echo "check: PASS" +else + echo "check: FAIL" +fi exit $status diff --git a/tests/test_litellm/test_pre_commit_lint.py b/tests/test_litellm/test_pre_commit_lint.py index 35d98903226..7ce16032641 100644 --- a/tests/test_litellm/test_pre_commit_lint.py +++ b/tests/test_litellm/test_pre_commit_lint.py @@ -384,3 +384,43 @@ def test_a_failing_block_fails_the_whole_run(tmp_path: Path, fail: str, message: proc = _run(repo, bin_dir, {"STUB_FAIL": fail}) assert proc.returncode == 1 assert message in proc.stdout + proc.stderr + + +def test_run_ends_with_a_summary_of_ran_and_skipped_blocks(tmp_path: Path) -> None: + repo, bin_dir = _sandbox(tmp_path) + proc = _run(repo, bin_dir, {}) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert "check: summary" in proc.stdout + assert "ran: Python lint (make lint)" in proc.stdout + assert "ran: dashboard lint (prettier + eslint + lint budgets)" in proc.stdout + assert "ran: dashboard API-type sync (npm run gen:api)" in proc.stdout + assert "skipped: tests/e2e checks (basedpyright + raw HTTP client ban) (no tests/e2e Python files in scope)" in proc.stdout + assert "check: PASS" in proc.stdout + assert "check: FAIL" not in proc.stdout + + +def test_staged_files_matching_no_check_print_an_explicit_noop_note_and_nonempty_log(tmp_path: Path) -> None: + repo, bin_dir = _sandbox(tmp_path) + _commit_all(repo, "base") + tests_dir = repo / "tests" / "test_litellm" + tests_dir.mkdir(parents=True) + (tests_dir / "test_x.py").write_text("def test_x() -> None: ...\n") + subprocess.run(["git", "add", "tests"], cwd=repo, check=True) + proc = _run(repo, bin_dir, {}) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert "no gating lint check matches the files in scope, so nothing ran" in proc.stdout + assert "tests/test_litellm/test_x.py" in proc.stdout + assert "a no-op, not a lint verdict" in proc.stdout + assert "check: PASS" in proc.stdout + assert "linting Python" not in proc.stdout + log = (repo / ".git" / "pre_commit_lint.log").read_text() + assert "check: summary" in log + assert "skipped: Python lint (make lint) (no litellm/ Python files in scope)" in log + + +def test_failing_run_ends_with_a_fail_verdict(tmp_path: Path) -> None: + repo, bin_dir = _sandbox(tmp_path) + proc = _run(repo, bin_dir, {"STUB_FAIL": "make-lint"}) + assert proc.returncode == 1 + assert "check: FAIL" in proc.stdout + assert "check: PASS" not in proc.stdout From 909a2e6232957b104cb2fd3ad18d14f3e2c1bbd8 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Thu, 13 Aug 2026 18:56:37 -0700 Subject: [PATCH 03/49] perf(spend-logs): bound retention cleanup so one run cannot saturate the database (#36594) --- litellm/constants.py | 3 + litellm/proxy/_types.py | 16 + .../db_transaction_queue/spend_log_cleanup.py | 453 ++++++++++-- .../spend_log_cleanup_metrics.py | 122 ++++ .../spend_logs_partition_manager.py | 128 +++- litellm/proxy/proxy_server.py | 28 +- .../test_spend_logs_partition_manager.py | 171 ++++- tests/test_litellm/proxy/test_proxy_server.py | 85 +++ .../proxy/test_spend_log_cleanup.py | 665 +++++++++++++++++- .../hooks/proxyConfig/useProxyConfig.ts | 4 + .../useStoreRequestInSpendLogs.ts | 12 +- .../LoggingSettings/LoggingSettings.test.tsx | 319 ++++++++- .../LoggingSettings/LoggingSettings.tsx | 229 ++++-- ui/litellm-dashboard/src/lib/http/schema.d.ts | 20 + 14 files changed, 2050 insertions(+), 205 deletions(-) create mode 100644 litellm/proxy/db/db_transaction_queue/spend_log_cleanup_metrics.py diff --git a/litellm/constants.py b/litellm/constants.py index 554165f5d39..e9d2d719ae2 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -1491,6 +1491,9 @@ SPEND_LOG_CLEANUP_MAX_CONSECUTIVE_BATCH_FAILURES = int(os.getenv("SPEND_LOG_CLEA SPEND_LOG_CLEANUP_BATCH_FAILURE_BACKOFF_SECONDS: Final = float( os.getenv("SPEND_LOG_CLEANUP_BATCH_FAILURE_BACKOFF_SECONDS", 0.5) ) +SPEND_LOG_CLEANUP_RUN_BUDGET_SECONDS: Final = float(os.getenv("SPEND_LOG_CLEANUP_RUN_BUDGET_SECONDS", "300")) +SPEND_LOG_CLEANUP_BATCH_TIMEOUT_SECONDS: Final = float(os.getenv("SPEND_LOG_CLEANUP_BATCH_TIMEOUT_SECONDS", "30")) +SPEND_LOG_CLEANUP_REMAINING_COUNT_CAP: Final = int(os.getenv("SPEND_LOG_CLEANUP_REMAINING_COUNT_CAP", "100000")) TOOL_SPEND_TOP_TOOLS: Final = 100 SPEND_LOG_PARTITION_INTERVAL: Final = os.getenv("SPEND_LOG_PARTITION_INTERVAL", "day") SPEND_LOG_PARTITION_PRECREATE_AHEAD: Final = int(os.getenv("SPEND_LOG_PARTITION_PRECREATE_AHEAD", 7)) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 6c01575e271..385f39e02a4 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -2514,6 +2514,22 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase): None, description="If True and LiteLLM_SpendLogs has been converted to a range-partitioned table (db_scripts/partition_spend_logs.sql), retention cleanup drops expired partitions instead of deleting rows, and pre-creates upcoming partitions. Default is False.", ) + maximum_spend_logs_cleanup_batch_size: int | None = Field( + None, + description="Rows deleted per DELETE statement by the spend log cleanup job. Defaults to 1000.", + ) + maximum_spend_logs_cleanup_max_batches: int | None = Field( + None, + description="Maximum DELETE statements the spend log cleanup job issues per table per run. Defaults to 500.", + ) + maximum_spend_logs_cleanup_run_budget: str | None = Field( + None, + description="Wall-clock budget for one spend log cleanup run (e.g. '5m'), shared across every table it prunes. A run that hits the budget stops and the next run resumes from where it left off. Defaults to '5m'.", + ) + maximum_spend_logs_cleanup_batch_timeout: str | None = Field( + None, + description="Postgres statement_timeout and lock_timeout applied to each spend log cleanup delete batch (e.g. '30s'), so cleanup cannot hold row locks or a connection indefinitely. Defaults to '30s'.", + ) mcp_internal_ip_ranges: list[str] | None = Field( None, description="Custom CIDR ranges that define internal/private networks for MCP access control. When set, only these ranges are treated as internal. Defaults to RFC 1918 private ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8).", diff --git a/litellm/proxy/db/db_transaction_queue/spend_log_cleanup.py b/litellm/proxy/db/db_transaction_queue/spend_log_cleanup.py index 9f01c719a5f..d19023862cb 100644 --- a/litellm/proxy/db/db_transaction_queue/spend_log_cleanup.py +++ b/litellm/proxy/db/db_transaction_queue/spend_log_cleanup.py @@ -1,22 +1,60 @@ import asyncio +import time +from dataclasses import dataclass from datetime import datetime, timedelta, timezone -from typing import Final +from typing import Final, Literal, TypeAlias + +from pydantic import BaseModel, TypeAdapter from litellm._logging import verbose_proxy_logger from litellm.caching import RedisCache from litellm.constants import ( SPEND_LOG_CLEANUP_BATCH_FAILURE_BACKOFF_SECONDS, SPEND_LOG_CLEANUP_BATCH_SIZE, + SPEND_LOG_CLEANUP_BATCH_TIMEOUT_SECONDS, SPEND_LOG_CLEANUP_JOB_NAME, SPEND_LOG_CLEANUP_MAX_CONSECUTIVE_BATCH_FAILURES, + SPEND_LOG_CLEANUP_REMAINING_COUNT_CAP, + SPEND_LOG_CLEANUP_RUN_BUDGET_SECONDS, SPEND_LOG_RUN_LOOPS, ) from litellm.litellm_core_utils.duration_parser import duration_in_seconds +from litellm.proxy.db.db_transaction_queue.spend_log_cleanup_metrics import ( + RunOutcome, + SpendLogCleanupMetrics, +) from litellm.proxy.db.db_transaction_queue.spend_logs_partition_manager import ( + RemainingTimeoutMs, SpendLogsPartitionManager, ) from litellm.proxy.utils import PrismaClient +StopReason: TypeAlias = Literal["exhausted", "budget_exhausted", "batch_cap_reached", "aborted"] + + +@dataclass(frozen=True, slots=True) +class TableCleanupResult: + """Outcome of pruning one table, so the caller can report why a run ended.""" + + rows_deleted: int + stop_reason: StopReason + + +class _RemainingRow(BaseModel): + """One row of the capped outstanding-rows probe, validated out of prisma's untyped result.""" + + remaining: int + + +_REMAINING_ROWS: Final = TypeAdapter(list[_RemainingRow]) + +SPEND_LOG_CLEANUP_BOUND_SETTINGS: Final = ( + "maximum_spend_logs_cleanup_batch_size", + "maximum_spend_logs_cleanup_max_batches", + "maximum_spend_logs_cleanup_run_budget", + "maximum_spend_logs_cleanup_batch_timeout", +) + class SpendLogCleanup: """ @@ -26,6 +64,24 @@ class SpendLogCleanup: dropping whole partitions (instant, frees disk immediately). Otherwise it falls back to deleting logs in batches. Uses PodLockManager to ensure only one pod runs cleanup in multi-pod deployments. + + Every run is bounded so it can never monopolise the database: a wall-clock + budget shared across all tables, a per-table batch cap, and a Postgres + statement/lock timeout on every statement the job issues, deletes and the + outstanding-rows probe alike. A run that hits a bound stops cleanly and the + next run resumes from where it left off, because the cutoff is recomputed + and deleted rows are gone. + + The budget is a hard wall clock, not an advisory one. Every statement this + job issues, deletes, the outstanding-rows probe and partition DDL alike, is + issued with a timeout clamped to the budget that is still left, so one + started just under the deadline is cancelled by Postgres at the deadline + rather than running a further batch timeout past it. No statement is issued + at all once the budget is spent, which is why the probe is skipped on that + path. Partition DDL additionally carries a lock_timeout, because it takes an + ACCESS EXCLUSIVE lock and would otherwise queue behind a long-running reader + for as long as that reader lives; a partition this run cannot get is left + for the next one. """ def __init__( @@ -34,17 +90,88 @@ class SpendLogCleanup: redis_cache: RedisCache | None = None, partition_manager: SpendLogsPartitionManager | None = None, ): - self.batch_size = SPEND_LOG_CLEANUP_BATCH_SIZE self.retention_seconds: int | None = None self.partition_manager = partition_manager or SpendLogsPartitionManager() from litellm.proxy.proxy_server import general_settings as default_settings self.general_settings = general_settings or default_settings + self._refresh_bounds() from litellm.proxy.proxy_server import proxy_logging_obj pod_lock_manager: Final = proxy_logging_obj.db_spend_update_writer.pod_lock_manager self.pod_lock_manager = pod_lock_manager - verbose_proxy_logger.info("SpendLogCleanup initialized with batch size: %s", self.batch_size) + verbose_proxy_logger.info( + "SpendLogCleanup initialized: batch_size=%s max_batches=%s run_budget=%ss batch_timeout=%ss", + self.batch_size, + self.max_batches, + self.run_budget_seconds, + self.batch_timeout_seconds, + ) + + def _refresh_bounds(self) -> None: + """ + Re-read every bound in SPEND_LOG_CLEANUP_BOUND_SETTINGS from settings. + + The scheduler holds one long-lived instance, so a bound captured at + construction would never reflect a dashboard change. general_settings is + the same dict the periodic config reload mutates in place, so reading it + per run is what makes these knobs live. Every bound falls back to its + shipped default, so clearing a field restores that default. + """ + self.batch_size: int = self._positive_int_setting( + "maximum_spend_logs_cleanup_batch_size", SPEND_LOG_CLEANUP_BATCH_SIZE + ) + self.max_batches: int = self._positive_int_setting( + "maximum_spend_logs_cleanup_max_batches", SPEND_LOG_RUN_LOOPS + ) + self.run_budget_seconds: float = self._duration_setting( + "maximum_spend_logs_cleanup_run_budget", SPEND_LOG_CLEANUP_RUN_BUDGET_SECONDS + ) + self.batch_timeout_seconds: float = self._duration_setting( + "maximum_spend_logs_cleanup_batch_timeout", SPEND_LOG_CLEANUP_BATCH_TIMEOUT_SECONDS + ) + + def _positive_int_setting(self, setting_name: str, default: int) -> int: + """ + Read a positive-integer knob, falling back to the default when unset or unusable. + """ + raw: Final = self.general_settings.get(setting_name) + if raw is None: + return default + try: + parsed: Final = int(raw) + except (TypeError, ValueError): + verbose_proxy_logger.warning("Invalid %s value: %s, using default %s", setting_name, raw, default) + return default + if parsed <= 0: + verbose_proxy_logger.warning("%s must be positive, got %s, using default %s", setting_name, parsed, default) + return default + return parsed + + def _duration_setting(self, setting_name: str, default_seconds: float) -> float: + """ + Read a duration knob (e.g. '5m'), falling back to the default when unset or unusable. + + The knob must never be able to remove the bound it exists to enforce, so + anything the parser rejects (including the non-finite spellings 'inf' and + 'nan') and anything non-positive falls back rather than being honoured. + """ + raw: Final = self.general_settings.get(setting_name) + if raw is None: + return default_seconds + try: + parsed: Final = float(duration_in_seconds(str(raw))) + except (ValueError, TypeError) as e: + verbose_proxy_logger.warning( + "Invalid %s value: %s (%s), using default %ss", setting_name, raw, e, default_seconds + ) + return default_seconds + if parsed <= 0: + verbose_proxy_logger.warning( + "%s must be a positive duration, got %s, using default %ss", setting_name, raw, default_seconds + ) + return default_seconds + return parsed def _retention_seconds_for(self, setting_name: str) -> int | None: """ @@ -78,6 +205,91 @@ class SpendLogCleanup: self.retention_seconds = self._retention_seconds_for("maximum_spend_logs_retention_period") return self.retention_seconds is not None + def _timeout_ms(self, deadline: float) -> int: + """ + The per-statement bound in milliseconds: the batch timeout, or whatever + is left of the run budget, whichever is smaller. + + Clamping to the remaining budget is what makes the budget a real + wall-clock bound rather than an advisory one. Postgres offers no "stop + at time T", only a per-statement duration, so a statement issued just + under the deadline would otherwise run a full batch timeout past it, and + with several tables those overruns stack. + + Interpolating this into SQL is safe by construction: an int cannot carry + SQL, and SET does not accept a bind parameter. + """ + remaining_ms: Final = int((deadline - time.monotonic()) * 1000) + return max(1, min(int(self.batch_timeout_seconds * 1000), remaining_ms)) + + def _remaining_timeout_ms(self, deadline: float) -> RemainingTimeoutMs: + """ + The per-statement bound for work this job delegates, as a callable. + + Partition maintenance issues one statement per partition, so handing it a + number would bound each statement by the budget that was left before the + FIRST one and never by what remains. Re-evaluating per statement is what + makes the loop itself bounded, and None tells the callee to stop rather + than issue a statement it has no budget for. + """ + + def remaining() -> int | None: + return None if time.monotonic() >= deadline else self._timeout_ms(deadline) + + return remaining + + async def _execute_delete_batch( + self, prisma_client: PrismaClient, delete_sql: str, cutoff_date: datetime, deadline: float + ) -> int | None: + """ + Run one delete batch under a Postgres statement and lock timeout. + + The timeouts are what actually bound the work: a Prisma transaction + timeout cannot interrupt a statement that is already executing, so + without these a single batch blocked behind a lock would hold its + connection, and the row locks it already took, indefinitely. SET LOCAL + scopes both to this transaction so the pooled connection is unaffected. + + Returns the row count, or None when the driver returned something that + is not a row count. That is a contract violation rather than a transient + fault, so the caller stops instead of retrying. + """ + timeout_ms: Final = self._timeout_ms(deadline) + async with prisma_client.db.tx() as tx: + await tx.execute_raw(f"SET LOCAL statement_timeout = {timeout_ms}") + await tx.execute_raw(f"SET LOCAL lock_timeout = {timeout_ms}") + deleted_result: Final = await tx.execute_raw(delete_sql, cutoff_date, self.batch_size) + return deleted_result if isinstance(deleted_result, int) else None + + async def _count_remaining( + self, prisma_client: PrismaClient, cutoff_date: datetime, table_name: str, time_column: str, deadline: float + ) -> int | None: + """ + Count expired rows still outstanding, stopping at a cap. + + An uncapped COUNT(*) over an expired backlog would itself be the kind of + long scan this job exists to avoid, so the probe reads at most + SPEND_LOG_CLEANUP_REMAINING_COUNT_CAP index entries. A result equal to + the cap means "at least this many". + """ + count_sql: Final = f""" + SELECT count(*)::int AS remaining FROM ( + SELECT 1 FROM "{table_name}" + WHERE "{time_column}" < $1::timestamptz + LIMIT $2 + ) capped + """ + try: + async with prisma_client.db.tx() as tx: + await tx.execute_raw(f"SET LOCAL statement_timeout = {self._timeout_ms(deadline)}") + rows: Final = _REMAINING_ROWS.validate_python( + await tx.query_raw(count_sql, cutoff_date, SPEND_LOG_CLEANUP_REMAINING_COUNT_CAP) + ) + except Exception as e: # noqa: BLE001 - an observability probe must never fail the cleanup run + verbose_proxy_logger.warning("Could not count remaining %s rows: %s", table_name, e) + return None + return rows[0].remaining if rows else None + async def _delete_old_rows_batched( self, prisma_client: PrismaClient, @@ -85,10 +297,14 @@ class SpendLogCleanup: table_name: str, key_columns: tuple[str, ...], time_column: str, - ) -> int: + deadline: float, + ) -> TableCleanupResult: """ - Helper method to delete a table's rows older than the cutoff in batches. - Returns the total number of rows deleted. + Delete a table's rows older than the cutoff in batches. + + Stops at whichever bound is reached first: the backlog running out, the + shared wall-clock deadline, the per-table batch cap, or too many + consecutive batch failures. """ key_list: Final = ", ".join(f'"{col}"' for col in key_columns) delete_sql: Final = f""" @@ -103,23 +319,46 @@ class SpendLogCleanup: run_count = 0 consecutive_failures = 0 while True: - if run_count > SPEND_LOG_RUN_LOOPS: + if time.monotonic() >= deadline: + verbose_proxy_logger.info( + "Run budget exhausted during %s cleanup after %d rows; the next run resumes from here", + table_name, + total_deleted, + ) + return await self._finish_table( + prisma_client, cutoff_date, table_name, time_column, total_deleted, "budget_exhausted", deadline + ) + if run_count >= self.max_batches: verbose_proxy_logger.info( "Max batches reached for %s cleanup, remaining rows will be deleted in next run", table_name ) - break - # Step 1: Find rows and delete them in one go without fetching to application - # Delete in batches, limited by self.batch_size - try: - deleted_result = await prisma_client.db.execute_raw( - delete_sql, - cutoff_date, - self.batch_size, + return await self._finish_table( + prisma_client, cutoff_date, table_name, time_column, total_deleted, "batch_cap_reached", deadline ) + # Find rows and delete them in one go without fetching to application + batch_started_at = time.monotonic() + try: + batch_result = await self._execute_delete_batch(prisma_client, delete_sql, cutoff_date, deadline) except Exception as batch_exc: + if time.monotonic() >= deadline: + # The statement timeout was clamped to the budget that was + # left, so this batch was cancelled by the deadline itself. + # That is the bound working, not a database fault, and + # counting it would both inflate the failure metric and push + # every budget-exhausted run toward the abort threshold. + verbose_proxy_logger.info( + "Run budget exhausted mid-batch during %s cleanup after %d rows; " + "the next run resumes from here", + table_name, + total_deleted, + ) + return await self._finish_table( + prisma_client, cutoff_date, table_name, time_column, total_deleted, "budget_exhausted", deadline + ) # A single batch failure (e.g. Prisma/DB timeout) must not abort # the whole run — subsequent batches may still succeed. consecutive_failures += 1 + SpendLogCleanupMetrics.record_batch_failure(table_name) verbose_proxy_logger.exception( "%s cleanup batch failed " "(run_count=%d, consecutive_failures=%d, batch_size=%d, " @@ -140,28 +379,31 @@ class SpendLogCleanup: consecutive_failures, total_deleted, ) - break + return await self._finish_table( + prisma_client, cutoff_date, table_name, time_column, total_deleted, "aborted", deadline + ) await asyncio.sleep(SPEND_LOG_CLEANUP_BATCH_FAILURE_BACKOFF_SECONDS) continue - consecutive_failures = 0 - - deleted_count = 0 - if isinstance(deleted_result, int): - deleted_count = deleted_result - else: + if batch_result is None: verbose_proxy_logger.error( - "Unexpected execute_raw return type for %s cleanup: %s; aborting cleanup to avoid infinite loop", + "Unexpected execute_raw return type for %s cleanup; aborting cleanup to avoid infinite loop", table_name, - type(deleted_result), ) - break + return await self._finish_table( + prisma_client, cutoff_date, table_name, time_column, total_deleted, "aborted", deadline + ) + consecutive_failures = 0 + deleted_count = batch_result + SpendLogCleanupMetrics.record_batch(table_name, deleted_count, time.monotonic() - batch_started_at) verbose_proxy_logger.info("Deleted %s %s rows in this batch", deleted_count, table_name) if deleted_count == 0: verbose_proxy_logger.info("No more %s rows to delete. Total deleted: %s", table_name, total_deleted) - break + return await self._finish_table( + prisma_client, cutoff_date, table_name, time_column, total_deleted, "exhausted", deadline + ) total_deleted += deleted_count run_count += 1 @@ -169,18 +411,49 @@ class SpendLogCleanup: # Add a small sleep to prevent overwhelming the database await asyncio.sleep(0.1) - return total_deleted + async def _finish_table( + self, + prisma_client: PrismaClient, + cutoff_date: datetime, + table_name: str, + time_column: str, + rows_deleted: int, + stop_reason: StopReason, + deadline: float, + ) -> TableCleanupResult: + """ + Publish how much of this table is still outstanding, then report the run's result. - async def _delete_old_logs(self, prisma_client: PrismaClient, cutoff_date: datetime) -> int: + The probe is skipped once the budget is spent. It is the one piece of + work that would otherwise be ISSUED after the deadline, and every table + exits through here, including the ones a spent run never started, so + keeping it would put one more statement per table past the bound. A run + that ends this way already reports "budget_exhausted", which tells an + operator the backlog was not drained; the gauge simply keeps its value + from the last run that finished inside its budget. + """ + if time.monotonic() >= deadline: + return TableCleanupResult(rows_deleted=rows_deleted, stop_reason=stop_reason) + remaining: Final = await self._count_remaining(prisma_client, cutoff_date, table_name, time_column, deadline) + if remaining is not None: + SpendLogCleanupMetrics.set_rows_remaining(table_name, remaining) + return TableCleanupResult(rows_deleted=rows_deleted, stop_reason=stop_reason) + + async def _delete_old_logs( + self, prisma_client: PrismaClient, cutoff_date: datetime, deadline: float + ) -> TableCleanupResult: return await self._delete_old_rows_batched( prisma_client, cutoff_date, table_name="LiteLLM_SpendLogs", key_columns=("request_id", "startTime"), time_column="startTime", + deadline=deadline, ) - async def _delete_old_tool_index_rows(self, prisma_client: PrismaClient, cutoff_date: datetime) -> int: + async def _delete_old_tool_index_rows( + self, prisma_client: PrismaClient, cutoff_date: datetime, deadline: float + ) -> TableCleanupResult: # SpendLogToolIndex rows are derived from spend logs, so they expire on the # same cutoff; rows older than retention point at already-deleted logs. return await self._delete_old_rows_batched( @@ -189,17 +462,87 @@ class SpendLogCleanup: table_name="LiteLLM_SpendLogToolIndex", key_columns=("request_id", "tool_name"), time_column="start_time", + deadline=deadline, ) - async def _delete_old_autorouter_session_rows(self, prisma_client: PrismaClient, cutoff_date: datetime) -> int: + async def _delete_old_autorouter_session_rows( + self, prisma_client: PrismaClient, cutoff_date: datetime, deadline: float + ) -> TableCleanupResult: return await self._delete_old_rows_batched( prisma_client, cutoff_date, table_name="LiteLLM_AutoRouterSession", key_columns=("api_key", "session_id", "router_name"), time_column="last_turn_at", + deadline=deadline, ) + async def _clean_spend_log_tables( + self, prisma_client: PrismaClient, deadline: float + ) -> tuple[TableCleanupResult, ...]: + """ + Prune the spend logs and the tool index rows derived from them. + + When the table is range-partitioned, whole expired partitions are dropped + first because that reclaims disk immediately. Expired rows can still sit in + the DEFAULT partition (backfill, coverage gaps) or in a partition that spans + the cutoff, so retention still deletes those stragglers row-wise. + """ + cutoff_date: Final = datetime.now(timezone.utc) - timedelta(seconds=float(self.retention_seconds or 0)) + verbose_proxy_logger.info("Removing logs older than %s", cutoff_date.isoformat()) + + # Partition maintenance is DDL taking an ACCESS EXCLUSIVE lock, so it is + # only STARTED while the run still has budget, and each statement carries + # the same timeouts the batches do. Without those, a DROP would queue + # behind any long-running reader for as long as that reader lives, which + # is the one way this job could still outlast its budget without bound. + remaining_timeout_ms: Final = self._remaining_timeout_ms(deadline) + if time.monotonic() >= deadline: + verbose_proxy_logger.info("Run budget already spent, skipping partition maintenance this run") + elif self.general_settings.get( + "use_spend_logs_partitioning", False + ) and await self.partition_manager.is_partitioned(prisma_client, remaining_timeout_ms): + await self.partition_manager.ensure_partitions(prisma_client, remaining_timeout_ms) + dropped: Final = await self.partition_manager.drop_partitions_older_than( + prisma_client, cutoff_date, remaining_timeout_ms + ) + verbose_proxy_logger.info("Dropped %d expired spend-log partitions: %s", len(dropped), dropped) + + logs_result: Final = await self._delete_old_logs(prisma_client, cutoff_date, deadline) + verbose_proxy_logger.info("Deleted %s logs", logs_result.rows_deleted) + + index_result: Final = await self._delete_old_tool_index_rows(prisma_client, cutoff_date, deadline) + verbose_proxy_logger.info("Deleted %s expired tool index rows", index_result.rows_deleted) + return (logs_result, index_result) + + async def _clean_session_rollup( + self, prisma_client: PrismaClient, retention_seconds: int, deadline: float + ) -> tuple[TableCleanupResult, ...]: + """ + Prune auto-router session rollup rows, which carry their own retention horizon. + """ + session_cutoff: Final = datetime.now(timezone.utc) - timedelta(seconds=float(retention_seconds)) + sessions_result: Final = await self._delete_old_autorouter_session_rows(prisma_client, session_cutoff, deadline) + verbose_proxy_logger.info("Deleted %s expired auto-router session rollup rows", sessions_result.rows_deleted) + return (sessions_result,) + + @staticmethod + def _run_outcome(results: tuple[TableCleanupResult, ...]) -> RunOutcome: + """ + Report the most operationally significant reason the run stopped. + + A bound that was hit matters more than a table that simply ran dry, so + those win over "completed", and an abort wins over everything. + """ + reasons: Final = frozenset(result.stop_reason for result in results) + if "aborted" in reasons: + return "aborted" + if "budget_exhausted" in reasons: + return "budget_exhausted" + if "batch_cap_reached" in reasons: + return "batch_cap_reached" + return "completed" + async def cleanup_old_spend_logs(self, prisma_client: PrismaClient) -> None: """ Main cleanup function. Deletes old spend logs in batches. @@ -209,16 +552,19 @@ class SpendLogCleanup: lock_acquired = False try: verbose_proxy_logger.info("Cleanup job triggered at %s", datetime.now()) + self._refresh_bounds() delete_spend_logs: Final = self._should_delete_spend_logs() autorouter_retention_seconds: Final = self._retention_seconds_for( "maximum_autorouter_session_retention_period" ) if not delete_spend_logs and autorouter_retention_seconds is None: + SpendLogCleanupMetrics.record_run("skipped_disabled") return if delete_spend_logs and self.retention_seconds is None: verbose_proxy_logger.error("Retention seconds is None, cannot proceed with cleanup") + SpendLogCleanupMetrics.record_run("skipped_disabled") return # If we have a pod lock manager, try to acquire the lock @@ -235,43 +581,23 @@ class SpendLogCleanup: if not lock_acquired: verbose_proxy_logger.info("Another pod is already running cleanup") + SpendLogCleanupMetrics.record_run("skipped_locked") return - if delete_spend_logs and self.retention_seconds is not None: - cutoff_date: Final = datetime.now(timezone.utc) - timedelta(seconds=float(self.retention_seconds)) - verbose_proxy_logger.info("Removing logs older than %s", cutoff_date.isoformat()) + deadline: Final = time.monotonic() + self.run_budget_seconds - if self.general_settings.get( - "use_spend_logs_partitioning", False - ) and await self.partition_manager.is_partitioned(prisma_client): - await self.partition_manager.ensure_partitions(prisma_client) - dropped: Final = await self.partition_manager.drop_partitions_older_than(prisma_client, cutoff_date) - verbose_proxy_logger.info( - "Dropped %d expired spend-log partitions: %s", - len(dropped), - dropped, - ) - # DROP only reclaims whole expired partitions. Expired rows can - # still sit in the DEFAULT partition (backfill, coverage gaps) - # or in a partition that spans the cutoff, so retention must - # also delete those stragglers row-wise. - total_deleted = await self._delete_old_logs(prisma_client, cutoff_date) - verbose_proxy_logger.info( - "Deleted %s expired logs not covered by dropped partitions", total_deleted - ) - else: - total_deleted = await self._delete_old_logs(prisma_client, cutoff_date) - verbose_proxy_logger.info("Deleted %s logs", total_deleted) + spend_log_results: Final = ( + await self._clean_spend_log_tables(prisma_client, deadline) + if delete_spend_logs and self.retention_seconds is not None + else () + ) + session_results: Final = ( + await self._clean_session_rollup(prisma_client, autorouter_retention_seconds, deadline) + if autorouter_retention_seconds is not None + else () + ) - index_deleted: Final = await self._delete_old_tool_index_rows(prisma_client, cutoff_date) - verbose_proxy_logger.info("Deleted %s expired tool index rows", index_deleted) - - if autorouter_retention_seconds is not None: - session_cutoff: Final = datetime.now(timezone.utc) - timedelta( - seconds=float(autorouter_retention_seconds) - ) - sessions_deleted: Final = await self._delete_old_autorouter_session_rows(prisma_client, session_cutoff) - verbose_proxy_logger.info("Deleted %s expired auto-router session rollup rows", sessions_deleted) + SpendLogCleanupMetrics.record_run(self._run_outcome(spend_log_results + session_results)) except Exception as e: # .exception() captures the traceback; str(e) alone on a Prisma/DB @@ -281,6 +607,7 @@ class SpendLogCleanup: type(e).__name__, e, ) + SpendLogCleanupMetrics.record_run("aborted") return # Return after error handling finally: # Only release the lock if it was actually acquired diff --git a/litellm/proxy/db/db_transaction_queue/spend_log_cleanup_metrics.py b/litellm/proxy/db/db_transaction_queue/spend_log_cleanup_metrics.py new file mode 100644 index 00000000000..340aeab938c --- /dev/null +++ b/litellm/proxy/db/db_transaction_queue/spend_log_cleanup_metrics.py @@ -0,0 +1,122 @@ +""" +Prometheus metrics for the spend-log retention cleanup job. + +The job runs in the background on a single elected pod, so its cost is invisible +from request-path metrics. These instruments make a run's database footprint +observable: how much it deleted, how long each batch took, how much work is +still outstanding, and why a run stopped. + +``prometheus_client`` is an optional dependency, so every recorder degrades to a +no-op when it is absent. +""" + +from typing import TYPE_CHECKING, Final, Literal, TypeAlias + +from litellm._logging import verbose_proxy_logger + +if TYPE_CHECKING: + # aliased so the annotations below cannot be mistaken for collections.Counter + from prometheus_client import Counter as PrometheusCounter + from prometheus_client import Gauge as PrometheusGauge + from prometheus_client import Histogram as PrometheusHistogram + +RunOutcome: TypeAlias = Literal[ + "completed", + "budget_exhausted", + "batch_cap_reached", + "skipped_locked", + "skipped_disabled", + "aborted", +] + +_BATCH_DURATION_BUCKETS: Final = (0.005, 0.025, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0) +_TABLE_LABEL: Final = ("table",) +_OUTCOME_LABEL: Final = ("outcome",) + + +class SpendLogCleanupMetrics: + """ + Lazily-registered Prometheus instruments for the retention cleanup job. + + Registration is deferred to first use so that importing this module never + touches the Prometheus registry, which keeps it safe to import from the + proxy regardless of whether Prometheus is a configured callback. + """ + + _initialized: bool = False + rows_deleted: "PrometheusCounter | None" = None + batch_duration: "PrometheusHistogram | None" = None + rows_remaining: "PrometheusGauge | None" = None + batch_failures: "PrometheusCounter | None" = None + runs: "PrometheusCounter | None" = None + + @classmethod + def _ensure_initialized(cls) -> None: + if cls._initialized: + return + cls._initialized = True + try: + # prometheus_client is an optional extra, so it is resolved here rather + # than at module import: this module is reachable from proxy startup + # regardless of whether Prometheus is a configured callback. + from prometheus_client import Counter, Gauge, Histogram + + cls.rows_deleted = Counter( + "litellm_spend_log_cleanup_rows_deleted_total", + "Rows deleted by the spend-log retention cleanup job", + labelnames=_TABLE_LABEL, + ) + cls.batch_duration = Histogram( + "litellm_spend_log_cleanup_batch_duration_seconds", + "Wall-clock duration of one retention cleanup delete batch", + labelnames=_TABLE_LABEL, + buckets=_BATCH_DURATION_BUCKETS, + ) + cls.rows_remaining = Gauge( + "litellm_spend_log_cleanup_rows_remaining", + "Expired rows still awaiting deletion, counted only up to " + "SPEND_LOG_CLEANUP_REMAINING_COUNT_CAP so the probe itself cannot scan a " + "large table; a value equal to that cap means at least that many remain", + labelnames=_TABLE_LABEL, + multiprocess_mode="livemax", + ) + cls.batch_failures = Counter( + "litellm_spend_log_cleanup_batch_failures_total", + "Retention cleanup delete batches that raised", + labelnames=_TABLE_LABEL, + ) + cls.runs = Counter( + "litellm_spend_log_cleanup_runs_total", + "Retention cleanup runs, labelled by why the run ended", + labelnames=_OUTCOME_LABEL, + ) + except Exception as e: # noqa: BLE001 - a metrics problem must never fail the cleanup run + # Covers the extra being absent, a duplicate registration (repeated + # imports under a test runner), and registry misconfiguration alike. + verbose_proxy_logger.warning("Could not register spend-log cleanup metrics: %s", e) + + @classmethod + def record_batch(cls, table_name: str, rows_deleted: int, duration_seconds: float) -> None: + cls._ensure_initialized() + if cls.rows_deleted is not None: + cls.rows_deleted.labels(table=table_name).inc(rows_deleted) + if cls.batch_duration is not None: + cls.batch_duration.labels(table=table_name).observe(duration_seconds) + + @classmethod + def record_batch_failure(cls, table_name: str) -> None: + cls._ensure_initialized() + if cls.batch_failures is not None: + cls.batch_failures.labels(table=table_name).inc() + + @classmethod + def set_rows_remaining(cls, table_name: str, remaining: int) -> None: + cls._ensure_initialized() + if cls.rows_remaining is not None: + cls.rows_remaining.labels(table=table_name).set(remaining) + + @classmethod + def record_run(cls, outcome: RunOutcome) -> None: + cls._ensure_initialized() + if cls.runs is not None: + cls.runs.labels(outcome=outcome).inc() diff --git a/litellm/proxy/db/db_transaction_queue/spend_logs_partition_manager.py b/litellm/proxy/db/db_transaction_queue/spend_logs_partition_manager.py index df17721d8e5..221c142d9d3 100644 --- a/litellm/proxy/db/db_transaction_queue/spend_logs_partition_manager.py +++ b/litellm/proxy/db/db_transaction_queue/spend_logs_partition_manager.py @@ -14,8 +14,9 @@ keeps the batched-DELETE path, so existing deployments are untouched. """ import re +from collections.abc import Callable from datetime import date, datetime, timedelta, timezone -from typing import Final +from typing import TYPE_CHECKING, Final, TypeAlias from litellm._logging import verbose_proxy_logger from litellm.constants import ( @@ -23,8 +24,23 @@ from litellm.constants import ( SPEND_LOG_PARTITION_PRECREATE_AHEAD, ) +if TYPE_CHECKING: + from litellm.proxy.utils import PrismaClient + SPEND_LOGS_TABLE: Final = "LiteLLM_SpendLogs" +RemainingTimeoutMs: TypeAlias = Callable[[], "int | None"] +""" +The per-statement bound in milliseconds, or None once the caller's budget is +spent. + +Injected rather than passed as a number so it is re-evaluated before EVERY +statement: a value read once at entry would let a loop issue N statements each +bounded by the budget that was left before the first of them, which is not a +bound on the loop at all. The caller owns the policy; this module only asks how +much time it may still use. +""" + PartitionInterval = str # "day" | "week" | "month" VALID_PARTITION_INTERVALS: Final = {"day", "week", "month"} @@ -116,21 +132,26 @@ class SpendLogsPartitionManager: self.interval = interval self.precreate_ahead = precreate_ahead - async def is_partitioned(self, prisma_client) -> bool: + async def is_partitioned(self, prisma_client: "PrismaClient", remaining_timeout_ms: RemainingTimeoutMs) -> bool: + budget_ms: Final = remaining_timeout_ms() + if budget_ms is None: + return False try: - rows: Final = await prisma_client.db.query_raw( - """ - SELECT EXISTS ( - SELECT 1 - FROM pg_partitioned_table pt - JOIN pg_class c ON c.oid = pt.partrelid - JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE c.relname = $1 - AND n.nspname = current_schema() - ) AS partitioned - """, - SPEND_LOGS_TABLE, - ) + async with prisma_client.db.tx() as tx: + await tx.execute_raw(f"SET LOCAL statement_timeout = {budget_ms}") + rows: Final = await tx.query_raw( + """ + SELECT EXISTS ( + SELECT 1 + FROM pg_partitioned_table pt + JOIN pg_class c ON c.oid = pt.partrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relname = $1 + AND n.nspname = current_schema() + ) AS partitioned + """, + SPEND_LOGS_TABLE, + ) except Exception as e: verbose_proxy_logger.warning( "Could not determine if %s is partitioned, assuming it is not: %s", @@ -140,7 +161,25 @@ class SpendLogsPartitionManager: return False return bool(rows and rows[0].get("partitioned")) - async def ensure_partitions(self, prisma_client) -> list[str]: + @staticmethod + async def _execute_bounded_ddl(prisma_client: "PrismaClient", statement: str, timeout_ms: int) -> None: + """ + Run one DDL statement under a Postgres statement and lock timeout. + + Partition DDL takes an ACCESS EXCLUSIVE lock, so an unbounded statement + queues behind any long-running reader for as long as that reader lives, + and the caller's run budget cannot cut it short. lock_timeout bounds the + wait for the lock and statement_timeout bounds the work itself, so a + partition this run cannot get is simply left for the next one. + """ + async with prisma_client.db.tx() as tx: + await tx.execute_raw(f"SET LOCAL statement_timeout = {timeout_ms}") + await tx.execute_raw(f"SET LOCAL lock_timeout = {timeout_ms}") + await tx.execute_raw(statement) + + async def ensure_partitions( + self, prisma_client: "PrismaClient", remaining_timeout_ms: RemainingTimeoutMs + ) -> list[str]: """ Ensure the current and upcoming partitions exist, returning the names now present. CREATE TABLE IF NOT EXISTS is a no-op for partitions that @@ -150,42 +189,61 @@ class SpendLogsPartitionManager: for name, lower, upper in upcoming_partitions( datetime.now(timezone.utc).date(), self.interval, self.precreate_ahead ): + budget_ms = remaining_timeout_ms() + if budget_ms is None: + verbose_proxy_logger.info("Run budget spent, leaving the remaining partitions for the next run") + break try: - await prisma_client.db.execute_raw( + await self._execute_bounded_ddl( + prisma_client, f'CREATE TABLE IF NOT EXISTS "{name}" ' f'PARTITION OF "{SPEND_LOGS_TABLE}" ' - f"FOR VALUES FROM ('{lower.isoformat()}') TO ('{upper.isoformat()}')" + f"FOR VALUES FROM ('{lower.isoformat()}') TO ('{upper.isoformat()}')", + budget_ms, ) ensured.append(name) except Exception as e: verbose_proxy_logger.warning("Failed to ensure spend-log partition %s: %s", name, e) return ensured - async def _list_partitions(self, prisma_client) -> list[tuple[str, datetime | None]]: - rows: Final = await prisma_client.db.query_raw( - """ - SELECT c.relname AS name, - pg_get_expr(c.relpartbound, c.oid) AS bound - FROM pg_inherits i - JOIN pg_class c ON c.oid = i.inhrelid - JOIN pg_class p ON p.oid = i.inhparent - JOIN pg_namespace n ON n.oid = p.relnamespace - WHERE p.relname = $1 - AND n.nspname = current_schema() - """, - SPEND_LOGS_TABLE, - ) + async def _list_partitions( + self, prisma_client: "PrismaClient", timeout_ms: int + ) -> list[tuple[str, datetime | None]]: + async with prisma_client.db.tx() as tx: + await tx.execute_raw(f"SET LOCAL statement_timeout = {timeout_ms}") + rows: Final = await tx.query_raw( + """ + SELECT c.relname AS name, + pg_get_expr(c.relpartbound, c.oid) AS bound + FROM pg_inherits i + JOIN pg_class c ON c.oid = i.inhrelid + JOIN pg_class p ON p.oid = i.inhparent + JOIN pg_namespace n ON n.oid = p.relnamespace + WHERE p.relname = $1 + AND n.nspname = current_schema() + """, + SPEND_LOGS_TABLE, + ) return [(row["name"], parse_partition_upper_bound(row.get("bound") or "")) for row in rows] - async def drop_partitions_older_than(self, prisma_client, cutoff: datetime) -> list[str]: + async def drop_partitions_older_than( + self, prisma_client: "PrismaClient", cutoff: datetime, remaining_timeout_ms: RemainingTimeoutMs + ) -> list[str]: """DROP every partition whose whole range is older than `cutoff`.""" + list_budget_ms: Final = remaining_timeout_ms() + if list_budget_ms is None: + return [] cutoff_naive: Final = cutoff.astimezone(timezone.utc).replace(tzinfo=None) - partitions: Final = await self._list_partitions(prisma_client) + partitions: Final = await self._list_partitions(prisma_client, list_budget_ms) to_drop: Final = select_partitions_to_drop(partitions, cutoff_naive) dropped: Final[list[str]] = [] for name in to_drop: + budget_ms = remaining_timeout_ms() + if budget_ms is None: + verbose_proxy_logger.info("Run budget spent, leaving the remaining partitions for the next run") + break try: - await prisma_client.db.execute_raw(f'DROP TABLE IF EXISTS "{name}"') + await self._execute_bounded_ddl(prisma_client, f'DROP TABLE IF EXISTS "{name}"', budget_ms) dropped.append(name) except Exception as e: verbose_proxy_logger.warning("Failed to drop spend-log partition %s: %s", name, e) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 968c645ffcd..b1b5a7ffbe5 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -367,7 +367,10 @@ from litellm.proxy.config_resolvers.alerting import ( ) from litellm.proxy.container_endpoints.endpoints import router as container_router from litellm.proxy.credential_endpoints.endpoints import router as credential_router -from litellm.proxy.db.db_transaction_queue.spend_log_cleanup import SpendLogCleanup +from litellm.proxy.db.db_transaction_queue.spend_log_cleanup import ( + SPEND_LOG_CLEANUP_BOUND_SETTINGS, + SpendLogCleanup, +) from litellm.proxy.db.exception_handler import ( PrismaDBExceptionHandler, call_with_db_reconnect_retry, @@ -4079,6 +4082,7 @@ class ProxyConfig: # precedence over stale DB-cached values for these specific keys # during periodic config reloads (_update_general_settings). self._yaml_general_settings_keys: set[str] = set() # mutable-ok: populated once at startup, read-only thereafter # fmt: skip + self._yaml_spend_log_cleanup_bounds: dict[str, object] = {} # mutable-ok: snapshot of YAML bounds at load time # fmt: skip def is_yaml(self, config_file_path: str) -> bool: if not os.path.isfile(config_file_path): @@ -5015,6 +5019,12 @@ class ProxyConfig: # These keys take precedence over DB-cached values during periodic # reloads (see _update_general_settings). self._yaml_general_settings_keys = set(general_settings.keys()) # mutable-ok: snapshot of YAML keys at load time # fmt: skip + # The VALUES matter for the cleanup bounds, not just which keys were + # set: clearing one from the dashboard has to fall back to what the + # YAML declared, and a set of names cannot answer that. + self._yaml_spend_log_cleanup_bounds = { # mutable-ok: snapshot of YAML bounds at load time # fmt: skip + key: general_settings[key] for key in SPEND_LOG_CLEANUP_BOUND_SETTINGS if key in general_settings + } ### LOAD KEY MANAGEMENT SETTINGS FIRST (needed for custom secret manager) ### key_management_settings: Final = general_settings.get("key_management_settings", None) @@ -6299,6 +6309,18 @@ class ProxyConfig: if old_session_value != new_session_value: await self._reschedule_spend_log_cleanup_job() + ## SPEND LOG CLEANUP BOUNDS ## + # The dashboard writes these straight to the DB, so without copying them + # here the running cleanup job never sees them. A key the DB no longer + # carries was cleared from the dashboard, and falls back to whatever + # config.yaml declared, or to None (the shipped default) when it declared + # nothing. Leaving the deleted DB value in memory would keep enforcing the + # bound the operator just removed. + for cleanup_key in SPEND_LOG_CLEANUP_BOUND_SETTINGS: + general_settings[cleanup_key] = _general_settings.get( + cleanup_key, self._yaml_spend_log_cleanup_bounds.get(cleanup_key) + ) + for key in ( "user_url_allowed_hosts", "user_url_validation", @@ -15529,6 +15551,10 @@ _GENERAL_SETTINGS_CONFIG_LIST_FIELD_TYPES: Final[Mapping[str, str]] = MappingPro "store_model_in_db": "Boolean", "store_prompts_in_spend_logs": "Boolean", "maximum_spend_logs_retention_period": "String", + "maximum_spend_logs_cleanup_batch_size": "Integer", + "maximum_spend_logs_cleanup_max_batches": "Integer", + "maximum_spend_logs_cleanup_run_budget": "String", + "maximum_spend_logs_cleanup_batch_timeout": "String", "mcp_internal_ip_ranges": "List", "mcp_trusted_proxy_ranges": "List", "mcp_xff_num_trusted_hops": "Integer", diff --git a/tests/test_litellm/proxy/db/db_transaction_queue/test_spend_logs_partition_manager.py b/tests/test_litellm/proxy/db/db_transaction_queue/test_spend_logs_partition_manager.py index 289de707387..e949afce57b 100644 --- a/tests/test_litellm/proxy/db/db_transaction_queue/test_spend_logs_partition_manager.py +++ b/tests/test_litellm/proxy/db/db_transaction_queue/test_spend_logs_partition_manager.py @@ -3,6 +3,7 @@ Tests for SpendLogsPartitionManager: partition naming/bounds math, retention selection, the non-partitioned no-op safety path, and the drop/ensure SQL flow. """ +from contextlib import asynccontextmanager from datetime import date, datetime, timezone from unittest.mock import AsyncMock, MagicMock @@ -19,6 +20,46 @@ from litellm.proxy.db.db_transaction_queue.spend_logs_partition_manager import ( ) +DDL_TIMEOUT_MS = 30000 + + +def _budget(ms: "int | None" = DDL_TIMEOUT_MS): + """The injected per-statement bound: a callable re-read before each statement.""" + return lambda: ms + + +def _wire_tx(db) -> list[str]: + """ + Model the prisma seam the partition DDL uses. + + Every statement this manager issues, DDL and catalog query alike, runs inside + db.tx() so it can carry SET LOCAL timeouts. Those SET LOCAL statements are + collected in the returned list rather than forwarded, so assertions on + db.execute_raw and db.query_raw still see only the real statements. + """ + session_settings: list[str] = [] + + @asynccontextmanager + async def _tx(): + tx = MagicMock() + + async def _execute_raw(sql, *args): + if sql.lstrip().upper().startswith("SET LOCAL"): + session_settings.append(sql.strip()) + return 0 + return await db.execute_raw(sql, *args) + + async def _query_raw(sql, *args): + return await db.query_raw(sql, *args) + + tx.execute_raw = _execute_raw + tx.query_raw = _query_raw + yield tx + + db.tx = _tx + return session_settings + + def test_period_start_per_interval(): d = date(2026, 6, 3) # a Wednesday assert period_start(d, "day") == date(2026, 6, 3) @@ -78,11 +119,13 @@ async def test_is_partitioned_true_and_false(): client_true = MagicMock() client_true.db.query_raw = AsyncMock(return_value=[{"partitioned": True}]) - assert await mgr.is_partitioned(client_true) is True + _wire_tx(client_true.db) + assert await mgr.is_partitioned(client_true, _budget()) is True client_false = MagicMock() client_false.db.query_raw = AsyncMock(return_value=[{"partitioned": False}]) - assert await mgr.is_partitioned(client_false) is False + _wire_tx(client_false.db) + assert await mgr.is_partitioned(client_false, _budget()) is False @pytest.mark.asyncio @@ -94,13 +137,14 @@ async def test_catalog_queries_are_scoped_to_current_schema(): mgr = SpendLogsPartitionManager() client = MagicMock() client.db.query_raw = AsyncMock(return_value=[]) + _wire_tx(client.db) - await mgr.is_partitioned(client) + await mgr.is_partitioned(client, _budget()) is_partitioned_sql = client.db.query_raw.call_args.args[0] assert "pg_namespace" in is_partitioned_sql assert "current_schema()" in is_partitioned_sql - await mgr._list_partitions(client) + await mgr._list_partitions(client, DDL_TIMEOUT_MS) list_sql = client.db.query_raw.call_args.args[0] assert "pg_namespace" in list_sql assert "current_schema()" in list_sql @@ -112,7 +156,10 @@ async def test_is_partitioned_swallows_errors_and_returns_false(): mgr = SpendLogsPartitionManager() client = MagicMock() client.db.query_raw = AsyncMock(side_effect=Exception("db down")) - assert await mgr.is_partitioned(client) is False + # Wire the real seam: without it the async with itself raises, and the test + # would pass on the wrong exception. + _wire_tx(client.db) + assert await mgr.is_partitioned(client, _budget()) is False @pytest.mark.asyncio @@ -133,9 +180,10 @@ async def test_drop_partitions_older_than_drops_expired_only(): ] ) client.db.execute_raw = AsyncMock(return_value=0) + _wire_tx(client.db) cutoff = datetime(2026, 6, 5, 0, 0, 0, tzinfo=timezone.utc) - dropped = await mgr.drop_partitions_older_than(client, cutoff) + dropped = await mgr.drop_partitions_older_than(client, cutoff, _budget()) assert dropped == ["LiteLLM_SpendLogs_p20260601"] executed = " ".join(call.args[0] for call in client.db.execute_raw.call_args_list) @@ -149,8 +197,9 @@ async def test_ensure_partitions_issues_create_for_each_period(): mgr = SpendLogsPartitionManager(interval="day", precreate_ahead=2) client = MagicMock() client.db.execute_raw = AsyncMock(return_value=0) + _wire_tx(client.db) - created = await mgr.ensure_partitions(client) + created = await mgr.ensure_partitions(client, _budget()) assert len(created) == 3 # current + 2 ahead assert client.db.execute_raw.await_count == 3 @@ -159,6 +208,105 @@ async def test_ensure_partitions_issues_create_for_each_period(): assert "CREATE TABLE IF NOT EXISTS" in first_sql +@pytest.mark.asyncio +async def test_partition_ddl_carries_a_statement_and_lock_timeout(): + """ + Partition DDL takes an ACCESS EXCLUSIVE lock, so an unbounded DROP queues + behind any long-running reader for as long as that reader lives. That is the + one path by which cleanup could outlast its run budget without bound, and + lock_timeout is what bounds the wait rather than only the work. + """ + mgr = SpendLogsPartitionManager(interval="day", precreate_ahead=0) + client = MagicMock() + client.db.execute_raw = AsyncMock(return_value=0) + client.db.query_raw = AsyncMock( + return_value=[ + { + "name": "LiteLLM_SpendLogs_p20260601", + "bound": "FOR VALUES FROM ('2026-06-01 00:00:00') TO ('2026-06-02 00:00:00')", + } + ] + ) + session_settings = _wire_tx(client.db) + + await mgr.ensure_partitions(client, _budget(7000)) + await mgr.drop_partitions_older_than(client, datetime(2026, 6, 5, tzinfo=timezone.utc), _budget(7000)) + + # Three statements were issued: the CREATE, the catalog list the drop needs, + # and the DROP. All three carry a statement timeout; only the two that take + # a lock also carry a lock timeout, since the catalog read takes none. + assert session_settings.count("SET LOCAL statement_timeout = 7000") == 3 + assert session_settings.count("SET LOCAL lock_timeout = 7000") == 2 + + +@pytest.mark.asyncio +async def test_catalog_queries_carry_a_statement_timeout(): + """ + Bounding only the DDL leaves the two catalog lookups as statements this job + issues with no bound at all, so a run could still outlast its budget waiting + on one. Every statement the manager issues carries the caller's timeout. + """ + mgr = SpendLogsPartitionManager() + client = MagicMock() + client.db.query_raw = AsyncMock(return_value=[]) + session_settings = _wire_tx(client.db) + + await mgr.is_partitioned(client, _budget(4000)) + assert session_settings == ["SET LOCAL statement_timeout = 4000"], ( + f"is_partitioned issued no statement timeout: {session_settings}" + ) + + session_settings.clear() + await mgr._list_partitions(client, 4000) + assert session_settings == ["SET LOCAL statement_timeout = 4000"], ( + f"_list_partitions issued no statement timeout: {session_settings}" + ) + + +@pytest.mark.asyncio +async def test_partition_loops_stop_when_the_budget_runs_out_mid_way(): + """ + Each loop issues one statement per partition, so a bound read once at entry + would let N statements each run for the budget that was left before the + first of them. The bound is re-read per statement and the loop stops. + """ + mgr = SpendLogsPartitionManager(interval="day", precreate_ahead=4) + client = MagicMock() + client.db.execute_raw = AsyncMock(return_value=0) + _wire_tx(client.db) + + # Budget for two statements, then spent. + calls = {"n": 0} + + def budget() -> "int | None": + calls["n"] += 1 + return 5000 if calls["n"] <= 2 else None + + created = await mgr.ensure_partitions(client, budget) + + assert len(created) == 2, f"the loop ran past its budget and created {len(created)}" + assert client.db.execute_raw.await_count == 2 + + +@pytest.mark.asyncio +async def test_partition_maintenance_issues_nothing_when_the_budget_is_already_spent(): + """A run with no budget left must not issue even the catalog lookups.""" + mgr = SpendLogsPartitionManager(interval="day", precreate_ahead=2) + client = MagicMock() + client.db.execute_raw = AsyncMock(return_value=0) + client.db.query_raw = AsyncMock(return_value=[]) + _wire_tx(client.db) + + spent = _budget(None) + + assert await mgr.is_partitioned(client, spent) is False + assert await mgr.ensure_partitions(client, spent) == [] + assert await mgr.drop_partitions_older_than(client, datetime(2026, 6, 5, tzinfo=timezone.utc), spent) == [] + + client.db.execute_raw.assert_not_awaited() + client.db.query_raw.assert_not_awaited() + + def test_unsupported_interval_raises(): with pytest.raises(ValueError): period_start(date(2026, 6, 1), "year") @@ -178,8 +326,9 @@ async def test_ensure_partitions_continues_when_one_create_fails(): mgr = SpendLogsPartitionManager(interval="day", precreate_ahead=2) client = MagicMock() client.db.execute_raw = AsyncMock(side_effect=[0, Exception("overlap"), 0]) + _wire_tx(client.db) - created = await mgr.ensure_partitions(client) + created = await mgr.ensure_partitions(client, _budget()) # the failed partition is skipped, the others still created assert len(created) == 2 @@ -202,8 +351,9 @@ async def test_invalid_interval_does_not_abort_ensure_partitions(): mgr = SpendLogsPartitionManager(interval="fortnight", precreate_ahead=1) client = MagicMock() client.db.execute_raw = AsyncMock(return_value=0) + _wire_tx(client.db) - created = await mgr.ensure_partitions(client) + created = await mgr.ensure_partitions(client, _budget()) assert len(created) == 2 # current + 1 ahead, day-based fallback @@ -225,9 +375,10 @@ async def test_drop_partitions_continues_when_one_drop_fails(): ] ) client.db.execute_raw = AsyncMock(side_effect=[Exception("locked"), 0]) + _wire_tx(client.db) cutoff = datetime(2026, 6, 10, 0, 0, 0, tzinfo=timezone.utc) - dropped = await mgr.drop_partitions_older_than(client, cutoff) + dropped = await mgr.drop_partitions_older_than(client, cutoff, _budget()) # both were eligible; the first drop failed so only the second is reported assert dropped == ["LiteLLM_SpendLogs_p20260602"] diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 57b2c874962..918d39646b0 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -6872,6 +6872,91 @@ async def test_update_general_settings_propagates_apply_user_budget_to_team_keys assert ps.general_settings["apply_user_budget_to_team_keys"] is True +@pytest.mark.asyncio +async def test_update_general_settings_propagates_spend_log_cleanup_bounds(): + """The dashboard writes the cleanup bounds straight to the DB config, so + without runtime propagation the scheduled job never sees them and the knobs + do nothing until the process restarts.""" + from litellm.proxy.db.db_transaction_queue.spend_log_cleanup import ( + SPEND_LOG_CLEANUP_BOUND_SETTINGS, + ) + from litellm.proxy.proxy_server import ProxyConfig + + proxy_config = ProxyConfig() + db_settings = { + "maximum_spend_logs_cleanup_batch_size": 2000, + "maximum_spend_logs_cleanup_max_batches": 250, + "maximum_spend_logs_cleanup_run_budget": "90s", + "maximum_spend_logs_cleanup_batch_timeout": "10s", + } + assert set(db_settings) == set(SPEND_LOG_CLEANUP_BOUND_SETTINGS) + + with patch("litellm.proxy.proxy_server.general_settings", {}): + await proxy_config._update_general_settings(db_general_settings=db_settings) + + import litellm.proxy.proxy_server as ps + + assert {key: ps.general_settings.get(key) for key in db_settings} == db_settings + + +@pytest.mark.asyncio +async def test_update_general_settings_clears_a_spend_log_cleanup_bound_dropped_from_the_db(): + """Blanking the field in the dashboard deletes the key outright, so leaving + the last value in memory would keep a bound the operator just removed.""" + from litellm.proxy.proxy_server import ProxyConfig + + proxy_config = ProxyConfig() + + with patch( + "litellm.proxy.proxy_server.general_settings", + {"maximum_spend_logs_cleanup_run_budget": "90s", "maximum_spend_logs_cleanup_batch_timeout": "10s"}, + ): + await proxy_config._update_general_settings( + db_general_settings={"maximum_spend_logs_cleanup_batch_timeout": "10s"} + ) + + import litellm.proxy.proxy_server as ps + + assert ps.general_settings["maximum_spend_logs_cleanup_run_budget"] is None + assert ps.general_settings["maximum_spend_logs_cleanup_batch_timeout"] == "10s" + + +@pytest.mark.asyncio +async def test_update_general_settings_keeps_a_yaml_set_spend_log_cleanup_bound(): + """A YAML-set bound never appears in the DB object, so treating its absence + as a dashboard clear would discard the deployed config on every reload.""" + from litellm.proxy.proxy_server import ProxyConfig + + proxy_config = ProxyConfig() + proxy_config._yaml_spend_log_cleanup_bounds = {"maximum_spend_logs_cleanup_run_budget": "90s"} + + with patch("litellm.proxy.proxy_server.general_settings", {"maximum_spend_logs_cleanup_run_budget": "90s"}): + await proxy_config._update_general_settings(db_general_settings={"store_model_in_db": True}) + + import litellm.proxy.proxy_server as ps + + assert ps.general_settings["maximum_spend_logs_cleanup_run_budget"] == "90s" + + +@pytest.mark.asyncio +async def test_update_general_settings_clearing_a_db_override_falls_back_to_the_yaml_bound(): + """Clearing a dashboard override of a YAML-declared bound must restore the + YAML value. Leaving the deleted override in memory would keep enforcing the + bound the operator just removed, until the process restarted.""" + from litellm.proxy.proxy_server import ProxyConfig + + proxy_config = ProxyConfig() + proxy_config._yaml_spend_log_cleanup_bounds = {"maximum_spend_logs_cleanup_run_budget": "90s"} + + # Memory currently holds the dashboard override, and the DB no longer carries it. + with patch("litellm.proxy.proxy_server.general_settings", {"maximum_spend_logs_cleanup_run_budget": "30s"}): + await proxy_config._update_general_settings(db_general_settings={"store_model_in_db": True}) + + import litellm.proxy.proxy_server as ps + + assert ps.general_settings["maximum_spend_logs_cleanup_run_budget"] == "90s" + + @pytest.mark.asyncio async def test_update_general_settings_apply_user_budget_to_team_keys_yaml_wins(): """A DB value must not silently override an explicit YAML setting on reload.""" diff --git a/tests/test_litellm/proxy/test_spend_log_cleanup.py b/tests/test_litellm/proxy/test_spend_log_cleanup.py index 03eef14dacb..87fbdd4c933 100644 --- a/tests/test_litellm/proxy/test_spend_log_cleanup.py +++ b/tests/test_litellm/proxy/test_spend_log_cleanup.py @@ -2,12 +2,66 @@ Test cases for spend log cleanup functionality """ +import asyncio +import math +import time +from contextlib import asynccontextmanager from datetime import datetime, timedelta, timezone from unittest.mock import AsyncMock, MagicMock import pytest -from litellm.proxy.db.db_transaction_queue.spend_log_cleanup import SpendLogCleanup +from litellm.constants import ( + SPEND_LOG_CLEANUP_BATCH_SIZE, + SPEND_LOG_CLEANUP_REMAINING_COUNT_CAP, + SPEND_LOG_CLEANUP_RUN_BUDGET_SECONDS, +) +from litellm.proxy.db.db_transaction_queue.spend_log_cleanup import ( + SPEND_LOG_CLEANUP_BOUND_SETTINGS, + SpendLogCleanup, + TableCleanupResult, +) +from litellm.proxy.db.db_transaction_queue.spend_log_cleanup_metrics import ( + SpendLogCleanupMetrics, +) + + +def _far_deadline() -> float: + """A run deadline far enough out that only the other bounds can stop a batch loop.""" + return time.monotonic() + 3600 + + +def _wire_tx(db): + """ + Model the prisma seam the cleanup job actually uses. + + Every statement the job issues runs inside db.tx() so it can carry a SET + LOCAL statement_timeout. Batch and probe statements are forwarded to + db.execute_raw and db.query_raw, which is what tests configure and assert + on, while the SET LOCAL statements are answered here so they neither consume + a side_effect entry nor show up in the recorded call list. Lookup is + deferred to call time so this can be wired before a test assigns its own + execute_raw. + """ + + @asynccontextmanager + async def _tx(): + tx = MagicMock() + + async def _execute_raw(sql, *args): + if sql.lstrip().upper().startswith("SET LOCAL"): + return 0 + return await db.execute_raw(sql, *args) + + async def _query_raw(sql, *args): + return await db.query_raw(sql, *args) + + tx.execute_raw = _execute_raw + tx.query_raw = _query_raw + yield tx + + db.tx = _tx + db.query_raw = AsyncMock(return_value=[{"remaining": 0}]) def test_spend_log_cleanup_cron_scheduling(): @@ -49,6 +103,7 @@ def test_spend_log_cleanup_cron_scheduler_integration(): # Mock scheduler mock_scheduler = MagicMock() mock_prisma_client = MagicMock() + _wire_tx(mock_prisma_client.db) mock_cleanup_instance = MagicMock() # Test Case 1: Cron-based scheduling @@ -155,7 +210,9 @@ async def test_cleanup_old_spend_logs_batch_deletion(): # Setup Prisma client mock_prisma_client = MagicMock() + _wire_tx(mock_prisma_client.db) mock_db = MagicMock() + _wire_tx(mock_db) # Mock execute_raw to return deleted counts (3 spend-log batches, then the # tool-index cleanup's first batch returning 0) @@ -207,7 +264,9 @@ async def test_cleanup_old_spend_logs_retention_period_cutoff(): """ # Setup Prisma client mock_prisma_client = MagicMock() + _wire_tx(mock_prisma_client.db) mock_db = MagicMock() + _wire_tx(mock_db) mock_db.execute_raw = AsyncMock(return_value=0) mock_prisma_client.db = mock_db @@ -244,6 +303,7 @@ async def test_cleanup_drops_partitions_when_enabled_and_partitioned(): from unittest.mock import AsyncMock, MagicMock mock_prisma_client = MagicMock() + _wire_tx(mock_prisma_client.db) mock_prisma_client.db.execute_raw = AsyncMock(return_value=0) partition_manager = MagicMock() @@ -285,6 +345,7 @@ async def test_cleanup_uses_delete_when_partitioning_not_enabled(): from unittest.mock import AsyncMock, MagicMock mock_prisma_client = MagicMock() + _wire_tx(mock_prisma_client.db) mock_prisma_client.db.execute_raw = AsyncMock(side_effect=[10, 0, 0]) partition_manager = MagicMock() @@ -316,6 +377,7 @@ async def test_cleanup_uses_delete_when_not_partitioned(): from unittest.mock import AsyncMock, MagicMock mock_prisma_client = MagicMock() + _wire_tx(mock_prisma_client.db) mock_prisma_client.db.execute_raw = AsyncMock(side_effect=[10, 0, 0]) partition_manager = MagicMock() @@ -346,6 +408,7 @@ async def test_cleanup_old_spend_logs_no_retention_period(): Test that no logs are deleted when no retention period is set """ mock_prisma_client = MagicMock() + _wire_tx(mock_prisma_client.db) mock_prisma_client.db.execute_raw = AsyncMock() cleaner = SpendLogCleanup(general_settings={}) # no retention @@ -361,6 +424,7 @@ async def test_lock_not_released_when_not_acquired(): before the lock is ever acquired. """ mock_prisma_client = MagicMock() + _wire_tx(mock_prisma_client.db) mock_prisma_client.db.execute_raw = AsyncMock() mock_redis_cache = MagicMock() @@ -418,7 +482,9 @@ async def test_delete_old_logs_aborts_on_non_int_execute_raw_return(): """should abort deletion loop immediately when execute_raw returns a non-int (e.g. None or dict), preventing an infinite loop.""" mock_prisma_client = MagicMock() + _wire_tx(mock_prisma_client.db) mock_db = MagicMock() + _wire_tx(mock_db) mock_db.execute_raw = AsyncMock(return_value=None) mock_prisma_client.db = mock_db @@ -427,17 +493,19 @@ async def test_delete_old_logs_aborts_on_non_int_execute_raw_return(): ) cutoff_date = datetime.now(timezone.utc) - timedelta(days=7) - total_deleted = await cleaner._delete_old_logs(mock_prisma_client, cutoff_date) + result = await cleaner._delete_old_logs(mock_prisma_client, cutoff_date, _far_deadline()) assert mock_db.execute_raw.call_count == 1 - assert total_deleted == 0 + assert result.rows_deleted == 0 @pytest.mark.asyncio async def test_delete_old_logs_continues_on_valid_int_return(): """should continue deletion loop across batches when execute_raw returns valid int counts.""" mock_prisma_client = MagicMock() + _wire_tx(mock_prisma_client.db) mock_db = MagicMock() + _wire_tx(mock_db) mock_db.execute_raw = AsyncMock(side_effect=[500, 300, 0]) mock_prisma_client.db = mock_db @@ -446,35 +514,37 @@ async def test_delete_old_logs_continues_on_valid_int_return(): ) cutoff_date = datetime.now(timezone.utc) - timedelta(days=7) - total_deleted = await cleaner._delete_old_logs(mock_prisma_client, cutoff_date) + result = await cleaner._delete_old_logs(mock_prisma_client, cutoff_date, _far_deadline()) assert mock_db.execute_raw.call_count == 3 - assert total_deleted == 800 + assert result.rows_deleted == 800 @pytest.mark.asyncio -async def test_delete_old_rows_stops_at_max_batches(monkeypatch): - """The run-loop backstop must halt a cleanup that keeps finding rows, so a - huge backlog is spread across scheduled runs instead of one unbounded loop.""" - import litellm.proxy.db.db_transaction_queue.spend_log_cleanup as cleanup_module - - monkeypatch.setattr(cleanup_module, "SPEND_LOG_RUN_LOOPS", 2) - +async def test_delete_old_rows_stops_at_max_batches(): + """The batch cap must halt a cleanup that keeps finding rows, so a huge + backlog is spread across scheduled runs instead of one unbounded loop, and + the operator-facing knob must mean exactly the number of statements it names.""" mock_prisma_client = MagicMock() + _wire_tx(mock_prisma_client.db) mock_db = MagicMock() + _wire_tx(mock_db) mock_db.execute_raw = AsyncMock(return_value=1000) mock_prisma_client.db = mock_db cleaner = SpendLogCleanup( - general_settings={"maximum_spend_logs_retention_period": "7d"} + general_settings={ + "maximum_spend_logs_retention_period": "7d", + "maximum_spend_logs_cleanup_max_batches": 2, + } ) cutoff_date = datetime.now(timezone.utc) - timedelta(days=7) - total_deleted = await cleaner._delete_old_logs(mock_prisma_client, cutoff_date) + result = await cleaner._delete_old_logs(mock_prisma_client, cutoff_date, _far_deadline()) - # run_count exceeds the cap only after 3 full batches (0, 1, 2) - assert mock_db.execute_raw.call_count == 3 - assert total_deleted == 3000 + assert mock_db.execute_raw.call_count == 2 + assert result.rows_deleted == 2000 + assert result.stop_reason == "batch_cap_reached" @pytest.mark.asyncio @@ -482,7 +552,9 @@ async def test_delete_old_tool_index_rows_deletes_on_composite_key(): """Tool index rows are derived from spend logs and expire on the same cutoff; the delete must match on the table's composite primary key.""" mock_prisma_client = MagicMock() + _wire_tx(mock_prisma_client.db) mock_db = MagicMock() + _wire_tx(mock_db) mock_db.execute_raw = AsyncMock(side_effect=[5, 0]) mock_prisma_client.db = mock_db @@ -491,9 +563,9 @@ async def test_delete_old_tool_index_rows_deletes_on_composite_key(): ) cutoff_date = datetime.now(timezone.utc) - timedelta(days=7) - total_deleted = await cleaner._delete_old_tool_index_rows(mock_prisma_client, cutoff_date) + result = await cleaner._delete_old_tool_index_rows(mock_prisma_client, cutoff_date, _far_deadline()) - assert total_deleted == 5 + assert result.rows_deleted == 5 delete_sql = mock_db.execute_raw.call_args_list[0][0][0] assert 'DELETE FROM "LiteLLM_SpendLogToolIndex"' in delete_sql assert 'WHERE ("request_id", "tool_name") IN' in delete_sql @@ -513,7 +585,9 @@ async def test_delete_old_logs_continues_after_single_batch_failure(monkeypatch) ) mock_prisma_client = MagicMock() + _wire_tx(mock_prisma_client.db) mock_db = MagicMock() + _wire_tx(mock_db) # batch 1 succeeds, batch 2 raises (one-off DB timeout), batches 3-4 succeed, # batch 5 returns 0 → loop exits naturally. mock_db.execute_raw = AsyncMock( @@ -526,11 +600,11 @@ async def test_delete_old_logs_continues_after_single_batch_failure(monkeypatch) ) cutoff_date = datetime.now(timezone.utc) - timedelta(days=7) - total_deleted = await cleaner._delete_old_logs(mock_prisma_client, cutoff_date) + result = await cleaner._delete_old_logs(mock_prisma_client, cutoff_date, _far_deadline()) # All 5 batches should have been attempted; 100 + 200 + 50 = 350 deleted. assert mock_db.execute_raw.call_count == 5 - assert total_deleted == 350 + assert result.rows_deleted == 350 @pytest.mark.asyncio @@ -548,7 +622,9 @@ async def test_delete_old_logs_aborts_after_consecutive_failures(monkeypatch): ) mock_prisma_client = MagicMock() + _wire_tx(mock_prisma_client.db) mock_db = MagicMock() + _wire_tx(mock_db) # Every batch raises — must abort after exactly 3 attempts, not loop forever. mock_db.execute_raw = AsyncMock( side_effect=ConnectionError("simulated persistent DB outage") @@ -560,10 +636,10 @@ async def test_delete_old_logs_aborts_after_consecutive_failures(monkeypatch): ) cutoff_date = datetime.now(timezone.utc) - timedelta(days=7) - total_deleted = await cleaner._delete_old_logs(mock_prisma_client, cutoff_date) + result = await cleaner._delete_old_logs(mock_prisma_client, cutoff_date, _far_deadline()) assert mock_db.execute_raw.call_count == 3 - assert total_deleted == 0 + assert result.rows_deleted == 0 @pytest.mark.asyncio @@ -580,7 +656,9 @@ async def test_delete_old_logs_resets_consecutive_failures_on_success(monkeypatc ) mock_prisma_client = MagicMock() + _wire_tx(mock_prisma_client.db) mock_db = MagicMock() + _wire_tx(mock_db) # Pattern: fail, fail, success (resets counter), fail, fail, success, done. # Without reset, three of these would trip abort; with reset, they don't. mock_db.execute_raw = AsyncMock( @@ -601,10 +679,10 @@ async def test_delete_old_logs_resets_consecutive_failures_on_success(monkeypatc ) cutoff_date = datetime.now(timezone.utc) - timedelta(days=7) - total_deleted = await cleaner._delete_old_logs(mock_prisma_client, cutoff_date) + result = await cleaner._delete_old_logs(mock_prisma_client, cutoff_date, _far_deadline()) assert mock_db.execute_raw.call_count == 7 - assert total_deleted == 150 + assert result.rows_deleted == 150 @pytest.mark.asyncio @@ -617,6 +695,7 @@ async def test_cleanup_uses_logger_exception_for_full_traceback(monkeypatch): monkeypatch.setattr(cleanup_module, "verbose_proxy_logger", mock_logger) mock_prisma_client = MagicMock() + _wire_tx(mock_prisma_client.db) # Force the outer try/except to fire by making _should_delete_spend_logs raise. cleaner = cleanup_module.SpendLogCleanup( general_settings={"maximum_spend_logs_retention_period": "7d"} @@ -653,7 +732,9 @@ async def test_cleanup_releases_lock_after_persistent_batch_failures(monkeypatch ) mock_prisma_client = MagicMock() + _wire_tx(mock_prisma_client.db) mock_db = MagicMock() + _wire_tx(mock_db) mock_db.execute_raw = AsyncMock(side_effect=TimeoutError("DB down")) mock_prisma_client.db = mock_db @@ -698,6 +779,7 @@ def _mock_prisma_for_retention(side_effect: list) -> "MagicMock": from unittest.mock import AsyncMock, MagicMock client = MagicMock() + _wire_tx(client.db) client.db.execute_raw = AsyncMock(side_effect=side_effect) return client @@ -753,3 +835,536 @@ async def test_no_retention_keys_means_no_cleanup_at_all(): cleaner.pod_lock_manager = None await cleaner.cleanup_old_spend_logs(client) assert client.db.execute_raw.await_count == 0 + + +@pytest.mark.asyncio +async def test_run_budget_stops_the_loop_and_leaves_the_backlog_for_the_next_run(): + """ + The wall-clock budget is the bound that keeps a large backlog from turning + into one multi-hour run. With rows always available, the loop must stop on + the deadline rather than on the batch cap, and must report that reason so + operators can tell a budgeted stop from a drained table. + """ + mock_prisma_client = MagicMock() + _wire_tx(mock_prisma_client.db) + mock_db = MagicMock() + _wire_tx(mock_db) + mock_db.execute_raw = AsyncMock(return_value=1000) + mock_prisma_client.db = mock_db + + cleaner = SpendLogCleanup( + general_settings={ + "maximum_spend_logs_retention_period": "7d", + # Comfortably more batches than a sub-second budget can reach (each + # batch sleeps 0.1s), but small enough that a broken deadline fails + # this test in seconds instead of hanging it + "maximum_spend_logs_cleanup_max_batches": 50, + } + ) + + cutoff_date = datetime.now(timezone.utc) - timedelta(days=7) + started_at = time.monotonic() + result = await cleaner._delete_old_logs(mock_prisma_client, cutoff_date, time.monotonic() + 0.25) + elapsed = time.monotonic() - started_at + + assert result.stop_reason == "budget_exhausted" + assert elapsed < 3, f"budgeted run overran its deadline: {elapsed}s" + assert mock_db.execute_raw.call_count < 50 + assert result.rows_deleted > 0 + + +@pytest.mark.asyncio +async def test_run_budget_is_shared_across_tables_not_granted_per_table(): + """ + A per-table budget would let a run take N times the configured bound. The + deadline is computed once per run, so once it is spent on the first table + the later tables must stop immediately rather than each getting a fresh one. + """ + mock_prisma_client = MagicMock() + _wire_tx(mock_prisma_client.db) + mock_db = MagicMock() + _wire_tx(mock_db) + mock_db.execute_raw = AsyncMock(return_value=1000) + mock_prisma_client.db = mock_db + + cleaner = SpendLogCleanup( + general_settings={ + "maximum_spend_logs_retention_period": "7d", + "maximum_autorouter_session_retention_period": "365d", + # Comfortably more batches than a sub-second budget can reach (each + # batch sleeps 0.1s), but small enough that a broken deadline fails + # this test in seconds instead of hanging it + "maximum_spend_logs_cleanup_max_batches": 50, + "maximum_spend_logs_cleanup_run_budget": "1s", + } + ) + cleaner.pod_lock_manager = None + + started_at = time.monotonic() + await cleaner.cleanup_old_spend_logs(mock_prisma_client) + elapsed = time.monotonic() - started_at + + # three tables are eligible; a per-table budget would push this past 3s + assert elapsed < 2.5, f"budget was granted per table, not per run: {elapsed}s" + tables_touched = {call[0][0].split('"')[1] for call in mock_db.execute_raw.call_args_list} + assert "LiteLLM_SpendLogs" in tables_touched + + +@pytest.mark.asyncio +async def test_each_batch_carries_a_statement_and_lock_timeout(): + """ + A Prisma transaction timeout cannot interrupt a statement already running, + so the Postgres statement_timeout and lock_timeout are the only things + stopping one batch from holding row locks and a pooled connection + indefinitely. Both must be set, inside the batch's own transaction, and + scoped with SET LOCAL so the pooled connection is left unchanged. + """ + recorded: list[str] = [] + + mock_prisma_client = MagicMock() + mock_db = MagicMock() + + @asynccontextmanager + async def _tx(): + tx = MagicMock() + + async def _execute_raw(sql, *args): + recorded.append(sql.strip()) + return 0 + + tx.execute_raw = _execute_raw + yield tx + + mock_db.tx = _tx + mock_db.query_raw = AsyncMock(return_value=[{"remaining": 0}]) + mock_prisma_client.db = mock_db + + cleaner = SpendLogCleanup( + general_settings={ + "maximum_spend_logs_retention_period": "7d", + "maximum_spend_logs_cleanup_batch_timeout": "12s", + } + ) + + await cleaner._delete_old_logs( + mock_prisma_client, datetime.now(timezone.utc) - timedelta(days=7), _far_deadline() + ) + + assert "SET LOCAL statement_timeout = 12000" in recorded + assert "SET LOCAL lock_timeout = 12000" in recorded + # the timeouts must precede the delete they are meant to bound + assert recorded.index("SET LOCAL statement_timeout = 12000") < next( + i for i, sql in enumerate(recorded) if sql.startswith("DELETE") + ) + + +@pytest.mark.parametrize( + "setting_value", + ["inf", "-inf", "nan", "1e400", "0s", "-5m", "not-a-duration"], +) +def test_a_non_finite_or_non_positive_budget_falls_back_to_the_default(setting_value): + """ + The knob must not be able to remove the bound it exists to enforce. + + 'inf', 'nan' and '1e400' are the spellings that would turn the deadline + into no deadline at all, and '0s' and '-5m' would make every run stop before + deleting anything. All of them must land on the default rather than being + honoured, and the resulting budget must be usable arithmetic. + """ + cleaner = SpendLogCleanup( + general_settings={ + "maximum_spend_logs_retention_period": "7d", + "maximum_spend_logs_cleanup_run_budget": setting_value, + } + ) + + assert cleaner.run_budget_seconds == SPEND_LOG_CLEANUP_RUN_BUDGET_SECONDS + assert math.isfinite(cleaner.run_budget_seconds) + assert cleaner.run_budget_seconds > 0 + + +@pytest.mark.parametrize("setting_value", [0, -1, "abc", "", 2.9]) +def test_a_bad_batch_size_falls_back_to_the_default(setting_value): + """A zero or negative batch size would make every DELETE a no-op and the + loop spin, so unusable values must fall back rather than be honoured.""" + cleaner = SpendLogCleanup( + general_settings={ + "maximum_spend_logs_retention_period": "7d", + "maximum_spend_logs_cleanup_batch_size": setting_value, + } + ) + + assert cleaner.batch_size >= 1 + + +def test_operator_knobs_override_the_env_defaults(): + """The knobs are meant to be reachable from general_settings (and therefore + from the admin UI), not only from environment variables.""" + cleaner = SpendLogCleanup( + general_settings={ + "maximum_spend_logs_retention_period": "7d", + "maximum_spend_logs_cleanup_batch_size": 250, + "maximum_spend_logs_cleanup_max_batches": 7, + "maximum_spend_logs_cleanup_run_budget": "90s", + "maximum_spend_logs_cleanup_batch_timeout": "2m", + } + ) + + assert cleaner.batch_size == 250 + assert cleaner.max_batches == 7 + assert cleaner.run_budget_seconds == 90 + assert cleaner.batch_timeout_seconds == 120 + + +_BOUND_SETTING_CASES = ( + ("maximum_spend_logs_cleanup_batch_size", 137, "batch_size", 137), + ("maximum_spend_logs_cleanup_max_batches", 9, "max_batches", 9), + ("maximum_spend_logs_cleanup_run_budget", "45s", "run_budget_seconds", 45.0), + ("maximum_spend_logs_cleanup_batch_timeout", "8s", "batch_timeout_seconds", 8.0), +) + + +@pytest.mark.parametrize("setting_name, setting_value, attribute, expected", _BOUND_SETTING_CASES) +@pytest.mark.asyncio +async def test_a_bound_changed_after_construction_reaches_the_next_run( + setting_name, setting_value, attribute, expected +): + """The scheduler holds one long-lived instance and the config reload mutates + general_settings in place, so a bound captured at construction would leave + every dashboard change inert until the process restarts.""" + settings = {"maximum_spend_logs_retention_period": "7d"} + cleaner = SpendLogCleanup(general_settings=settings) + cleaner.pod_lock_manager = None + assert getattr(cleaner, attribute) != expected + + settings[setting_name] = setting_value + + await cleaner.cleanup_old_spend_logs(_mock_prisma_for_retention([0, 0])) + + assert getattr(cleaner, attribute) == expected + + +@pytest.mark.parametrize("cleared_to_none", [True, False]) +@pytest.mark.asyncio +async def test_a_bound_cleared_after_construction_falls_back_to_its_default(cleared_to_none): + """Blanking the field in the dashboard has to restore the shipped default + rather than leave the operator's old bound in force, whether the reload + spells the clear as an explicit None or as an absent key.""" + settings = {"maximum_spend_logs_retention_period": "7d", "maximum_spend_logs_cleanup_batch_size": 137} + cleaner = SpendLogCleanup(general_settings=settings) + cleaner.pod_lock_manager = None + assert cleaner.batch_size == 137 + + if cleared_to_none: + settings["maximum_spend_logs_cleanup_batch_size"] = None + else: + del settings["maximum_spend_logs_cleanup_batch_size"] + + await cleaner.cleanup_old_spend_logs(_mock_prisma_for_retention([0, 0])) + + assert cleaner.batch_size == SPEND_LOG_CLEANUP_BATCH_SIZE + + +def test_every_declared_bound_setting_is_covered_by_a_live_reread_case(): + """A bound added to the declared set without a live-reread case would be + propagated by the proxy and then ignored by the running job.""" + assert {case[0] for case in _BOUND_SETTING_CASES} == set(SPEND_LOG_CLEANUP_BOUND_SETTINGS) + + +@pytest.mark.asyncio +async def test_remaining_rows_probe_is_capped_so_it_cannot_scan_the_table(): + """The remaining-eligible-rows metric must never itself become the long + scan this job exists to avoid, so its probe carries a LIMIT.""" + mock_prisma_client = MagicMock() + _wire_tx(mock_prisma_client.db) + mock_db = MagicMock() + _wire_tx(mock_db) + mock_db.execute_raw = AsyncMock(return_value=0) + mock_prisma_client.db = mock_db + + cleaner = SpendLogCleanup(general_settings={"maximum_spend_logs_retention_period": "7d"}) + + await cleaner._delete_old_logs( + mock_prisma_client, datetime.now(timezone.utc) - timedelta(days=7), _far_deadline() + ) + + count_sql = mock_db.query_raw.call_args[0][0] + assert "count(*)" in count_sql + assert "LIMIT $2" in count_sql + assert mock_db.query_raw.call_args[0][2] == SPEND_LOG_CLEANUP_REMAINING_COUNT_CAP + + +@pytest.mark.asyncio +async def test_a_run_skipped_because_another_pod_holds_the_lock_is_reported(): + """Operators need to tell "nothing to do" apart from "someone else is doing + it", so a lock-skipped run is recorded under its own outcome.""" + recorded: list[str] = [] + original_record_run = SpendLogCleanupMetrics.record_run + + mock_prisma_client = MagicMock() + _wire_tx(mock_prisma_client.db) + + cleaner = SpendLogCleanup(general_settings={"maximum_spend_logs_retention_period": "7d"}) + cleaner.pod_lock_manager = MagicMock() + cleaner.pod_lock_manager.redis_cache = MagicMock() + cleaner.pod_lock_manager.acquire_lock = AsyncMock(return_value=False) + cleaner.pod_lock_manager.release_lock = AsyncMock() + + SpendLogCleanupMetrics.record_run = classmethod(lambda cls, outcome: recorded.append(outcome)) + try: + await cleaner.cleanup_old_spend_logs(mock_prisma_client) + finally: + SpendLogCleanupMetrics.record_run = original_record_run + + assert recorded == ["skipped_locked"] + cleaner.pod_lock_manager.release_lock.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_the_outstanding_rows_probe_carries_a_statement_timeout(): + """ + The probe is a statement like any other, so if it were issued bare a slow one + would hold a connection past the budget the job advertises, which is exactly + what the bounds exist to prevent. With budget to spare it carries the same + per-statement timeout the delete batches do. + """ + recorded: list[str] = [] + + mock_prisma_client = MagicMock() + mock_db = MagicMock() + + @asynccontextmanager + async def _tx(): + tx = MagicMock() + + async def _execute_raw(sql, *args): + recorded.append(sql.strip()) + return 0 + + async def _query_raw(sql, *args): + recorded.append(sql.strip()) + return [{"remaining": 7}] + + tx.execute_raw = _execute_raw + tx.query_raw = _query_raw + yield tx + + mock_db.tx = _tx + mock_prisma_client.db = mock_db + + cleaner = SpendLogCleanup( + general_settings={ + "maximum_spend_logs_retention_period": "7d", + "maximum_spend_logs_cleanup_batch_timeout": "8s", + } + ) + + remaining = await cleaner._count_remaining( + mock_prisma_client, + datetime.now(timezone.utc) - timedelta(days=7), + "LiteLLM_SpendLogs", + "startTime", + _far_deadline(), + ) + + assert remaining == 7 + count_index = next(i for i, sql in enumerate(recorded) if sql.startswith("SELECT count(*)")) + assert "SET LOCAL statement_timeout = 8000" in recorded[:count_index], ( + f"the probe ran without a statement timeout: {recorded}" + ) + + +@pytest.mark.asyncio +async def test_a_statement_timeout_is_clamped_to_the_budget_that_is_left(): + """ + Postgres has no 'stop at time T', only a per-statement duration, so a batch + issued just under the deadline would run a whole batch timeout past it and + the run budget would be advisory. Clamping the timeout to the remaining + budget is what makes the budget a real wall clock. + """ + recorded: list[str] = [] + client = MagicMock() + + @asynccontextmanager + async def _tx(): + tx = MagicMock() + + async def _execute_raw(sql, *args): + recorded.append(sql.strip()) + return 0 + + tx.execute_raw = _execute_raw + tx.query_raw = AsyncMock(return_value=[{"remaining": 0}]) + yield tx + + client.db.tx = _tx + + cleaner = SpendLogCleanup( + general_settings={ + "maximum_spend_logs_retention_period": "7d", + "maximum_spend_logs_cleanup_batch_timeout": "30s", + } + ) + + # Only 2s of budget left against a 30s batch timeout. + await cleaner._execute_delete_batch(client, "DELETE FROM x", datetime.now(timezone.utc), time.monotonic() + 2) + + timeouts = [sql for sql in recorded if "statement_timeout" in sql] + assert timeouts, f"no statement timeout was issued: {recorded}" + issued_ms = int(timeouts[0].split("=")[1].strip()) + assert issued_ms <= 2000, f"the batch was given {issued_ms}ms with only 2000ms of budget left" + + +@pytest.mark.asyncio +async def test_no_statement_is_issued_once_the_budget_is_spent(): + """ + Every table exits through _finish_table, including the ones a spent run never + started, so an unconditional probe there would put one more statement per + table past the bound. + """ + client = _mock_prisma_for_retention([0, 0]) + cleaner = SpendLogCleanup(general_settings={"maximum_spend_logs_retention_period": "7d"}) + + result = await cleaner._finish_table( + client, + datetime.now(timezone.utc) - timedelta(days=7), + "LiteLLM_SpendLogs", + "startTime", + 123, + "budget_exhausted", + time.monotonic() - 1, + ) + + assert result.rows_deleted == 123 + assert result.stop_reason == "budget_exhausted" + client.db.query_raw.assert_not_called() + + +@pytest.mark.asyncio +async def test_a_batch_cancelled_by_the_deadline_is_budget_exhaustion_not_a_failure(monkeypatch): + """ + Clamping the timeout means the last batch of a budget-exhausted run is + cancelled by the deadline itself. Counting that as a batch failure would + inflate the failure metric on every such run and walk it toward the abort + threshold, so it has to be classified as the bound working. + """ + failures: list[str] = [] + client = MagicMock() + _wire_tx(client.db) + + # The deadline has to pass DURING the batch, not before it: a deadline + # already spent is caught by the loop's own check and no batch is ever + # issued, which would exercise none of the classification under test. + async def _cancelled_after_the_deadline(sql, *args): + await asyncio.sleep(0.05) + raise Exception("canceling statement due to statement timeout") + + client.db.execute_raw = _cancelled_after_the_deadline + + cleaner = SpendLogCleanup(general_settings={"maximum_spend_logs_retention_period": "7d"}) + monkeypatch.setattr(SpendLogCleanupMetrics, "record_batch_failure", lambda table: failures.append(table)) + + result = await cleaner._delete_old_logs( + client, datetime.now(timezone.utc) - timedelta(days=7), time.monotonic() + 0.02 + ) + + assert result.stop_reason == "budget_exhausted" + assert failures == [], f"a deadline cancellation was recorded as a batch failure: {failures}" + + +@pytest.mark.asyncio +async def test_partition_maintenance_is_skipped_once_the_run_budget_is_spent(): + """ + Dropping a partition is DDL holding an ACCESS EXCLUSIVE lock, and unlike a + delete batch it cannot be cut short once it has started. A run whose budget is + already gone must therefore not start it at all; the next tick picks it up. + """ + mock_prisma_client = MagicMock() + _wire_tx(mock_prisma_client.db) + mock_db = MagicMock() + _wire_tx(mock_db) + mock_db.execute_raw = AsyncMock(return_value=0) + mock_prisma_client.db = mock_db + + partition_manager = MagicMock() + partition_manager.is_partitioned = AsyncMock(return_value=True) + partition_manager.ensure_partitions = AsyncMock() + partition_manager.drop_partitions_older_than = AsyncMock(return_value=[]) + + cleaner = SpendLogCleanup( + general_settings={ + "maximum_spend_logs_retention_period": "7d", + "use_spend_logs_partitioning": True, + }, + partition_manager=partition_manager, + ) + cleaner._should_delete_spend_logs() + + # a deadline already in the past is what a run that spent its budget on an + # earlier table looks like + await cleaner._clean_spend_log_tables(mock_prisma_client, time.monotonic() - 1) + + partition_manager.ensure_partitions.assert_not_awaited() + partition_manager.drop_partitions_older_than.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_partition_maintenance_still_runs_while_the_run_has_budget(): + """The skip above must be caused by the spent budget, not by breaking the + partition path outright.""" + mock_prisma_client = MagicMock() + _wire_tx(mock_prisma_client.db) + mock_db = MagicMock() + _wire_tx(mock_db) + mock_db.execute_raw = AsyncMock(return_value=0) + mock_prisma_client.db = mock_db + + partition_manager = MagicMock() + partition_manager.is_partitioned = AsyncMock(return_value=True) + partition_manager.ensure_partitions = AsyncMock() + partition_manager.drop_partitions_older_than = AsyncMock(return_value=["LiteLLM_SpendLogs_p20260601"]) + + cleaner = SpendLogCleanup( + general_settings={ + "maximum_spend_logs_retention_period": "7d", + "use_spend_logs_partitioning": True, + }, + partition_manager=partition_manager, + ) + cleaner._should_delete_spend_logs() + + await cleaner._clean_spend_log_tables(mock_prisma_client, _far_deadline()) + + partition_manager.ensure_partitions.assert_awaited_once() + partition_manager.drop_partitions_older_than.assert_awaited_once() + + +@pytest.mark.parametrize( + "stop_reasons, expected", + [ + (("exhausted",), "completed"), + (("exhausted", "exhausted"), "completed"), + (("exhausted", "batch_cap_reached"), "batch_cap_reached"), + (("batch_cap_reached", "exhausted"), "batch_cap_reached"), + (("exhausted", "budget_exhausted"), "budget_exhausted"), + (("budget_exhausted", "exhausted"), "budget_exhausted"), + (("batch_cap_reached", "budget_exhausted"), "budget_exhausted"), + (("budget_exhausted", "batch_cap_reached"), "budget_exhausted"), + (("exhausted", "aborted"), "aborted"), + (("aborted", "exhausted"), "aborted"), + (("budget_exhausted", "aborted"), "aborted"), + (("aborted", "budget_exhausted"), "aborted"), + (("aborted", "budget_exhausted", "batch_cap_reached"), "aborted"), + ], +) +def test_the_reported_run_outcome_is_the_most_significant_reason_in_any_order(stop_reasons, expected): + """ + The run outcome answers "why did this run stop", so a table that merely ran + dry must never mask one that hit a bound, and an abort must outrank both. + + Both orders of every pair are covered because this folds several per-table + results into one answer: a first-match-wins implementation would pass on + whichever order happened to be written and fail on its mirror. + """ + results = tuple(TableCleanupResult(rows_deleted=0, stop_reason=reason) for reason in stop_reasons) + assert SpendLogCleanup._run_outcome(results) == expected diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/proxyConfig/useProxyConfig.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/proxyConfig/useProxyConfig.ts index 485c7cc1f92..02c82611848 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/proxyConfig/useProxyConfig.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/proxyConfig/useProxyConfig.ts @@ -17,6 +17,10 @@ export enum ConfigType { */ export enum GeneralSettingsFieldName { MAXIMUM_SPEND_LOGS_RETENTION_PERIOD = "maximum_spend_logs_retention_period", + MAXIMUM_SPEND_LOGS_CLEANUP_BATCH_SIZE = "maximum_spend_logs_cleanup_batch_size", + MAXIMUM_SPEND_LOGS_CLEANUP_MAX_BATCHES = "maximum_spend_logs_cleanup_max_batches", + MAXIMUM_SPEND_LOGS_CLEANUP_RUN_BUDGET = "maximum_spend_logs_cleanup_run_budget", + MAXIMUM_SPEND_LOGS_CLEANUP_BATCH_TIMEOUT = "maximum_spend_logs_cleanup_batch_timeout", // Add more field names here as needed } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/storeRequestInSpendLogs/useStoreRequestInSpendLogs.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/storeRequestInSpendLogs/useStoreRequestInSpendLogs.ts index 88a37b30291..ca57c01679d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/storeRequestInSpendLogs/useStoreRequestInSpendLogs.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/storeRequestInSpendLogs/useStoreRequestInSpendLogs.ts @@ -6,6 +6,10 @@ import { proxyConfigKeys } from "../proxyConfig/useProxyConfig"; export interface StoreRequestInSpendLogsParams { store_prompts_in_spend_logs: boolean; maximum_spend_logs_retention_period?: string; + maximum_spend_logs_cleanup_batch_size?: number; + maximum_spend_logs_cleanup_max_batches?: number; + maximum_spend_logs_cleanup_run_budget?: string; + maximum_spend_logs_cleanup_batch_timeout?: string; } export interface StoreRequestInSpendLogsResponse { @@ -19,6 +23,8 @@ const performStoreRequestInSpendLogs = async ( const proxyBaseUrl = getProxyBaseUrl(); const url = proxyBaseUrl ? `${proxyBaseUrl}/config/update` : `/config/update`; + const { store_prompts_in_spend_logs, ...optionalSettings } = params; + const response = await fetch(url, { method: "POST", headers: { @@ -27,10 +33,8 @@ const performStoreRequestInSpendLogs = async ( }, body: JSON.stringify({ general_settings: { - store_prompts_in_spend_logs: params.store_prompts_in_spend_logs, - ...(params.maximum_spend_logs_retention_period && { - maximum_spend_logs_retention_period: params.maximum_spend_logs_retention_period, - }), + store_prompts_in_spend_logs, + ...optionalSettings, }, }), }); diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/LoggingSettings/LoggingSettings.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/LoggingSettings/LoggingSettings.test.tsx index d90720df3c2..a5ef93d2151 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/LoggingSettings/LoggingSettings.test.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/LoggingSettings/LoggingSettings.test.tsx @@ -1,4 +1,8 @@ -import { useDeleteProxyConfigField, useProxyConfig } from "@/app/(dashboard)/hooks/proxyConfig/useProxyConfig"; +import { + DeleteProxyConfigFieldRequest, + useDeleteProxyConfigField, + useProxyConfig, +} from "@/app/(dashboard)/hooks/proxyConfig/useProxyConfig"; import { useStoreRequestInSpendLogs } from "@/app/(dashboard)/hooks/storeRequestInSpendLogs/useStoreRequestInSpendLogs"; import NotificationsManager from "@/components/molecules/notifications_manager"; import { parseErrorMessage } from "@/components/shared/errorUtils"; @@ -40,6 +44,64 @@ describe("LoggingSettings", () => { const mockDeleteField = vi.fn(); const mockRefetch = vi.fn(); + const clearedFieldNames = (): string[] => + mockDeleteField.mock.calls.map((call) => (call[0] as DeleteProxyConfigFieldRequest).field_name); + + // Every optional knob already persisted. Clearing is only ever issued for a + // field that has a stored value, so any test about the clear path has to say + // so; the default mock below is an empty config, which is a proxy that has + // never saved these settings and therefore has nothing to clear. + const withEveryOptionalFieldStored = () => + mockUseProxyConfig.mockReturnValue({ + data: [ + { + field_name: "maximum_spend_logs_retention_period", + field_type: "string", + field_description: "Maximum retention period", + field_value: "30d", + stored_in_db: true, + }, + { + field_name: "maximum_spend_logs_cleanup_batch_size", + field_type: "Integer", + field_description: "Rows per delete", + field_value: 2000, + stored_in_db: true, + }, + { + field_name: "maximum_spend_logs_cleanup_max_batches", + field_type: "Integer", + field_description: "Deletes per table per run", + field_value: 50, + stored_in_db: true, + }, + { + field_name: "maximum_spend_logs_cleanup_run_budget", + field_type: "string", + field_description: "Wall clock budget per run", + field_value: "90s", + stored_in_db: true, + }, + { + field_name: "maximum_spend_logs_cleanup_batch_timeout", + field_type: "string", + field_description: "Statement and lock timeout per batch", + field_value: "10s", + stored_in_db: true, + }, + ], + isLoading: false, + refetch: mockRefetch, + } as unknown as ReturnType); + + // Blank every optional input the form rendered from stored values, which is + // what an admin does to reset a knob to its default. + const blankEveryOptionalField = async (user: ReturnType) => { + for (const placeholder of ["e.g., 7d, 30d", "e.g., 1000", "e.g., 500", "e.g., 5m", "e.g., 30s"]) { + await user.clear(screen.getByPlaceholderText(placeholder)); + } + }; + beforeEach(() => { vi.resetAllMocks(); mockUseStoreRequestInSpendLogs.mockReturnValue({ @@ -68,6 +130,19 @@ describe("LoggingSettings", () => { expect(screen.getByRole("button", { name: "Save Settings" })).toBeInTheDocument(); }); + it("should render a control for every spend logs cleanup knob", () => { + renderWithProviders(); + + expect(screen.getByLabelText("Spend Logs Cleanup Batch Size (Optional)")).toBeInTheDocument(); + expect(screen.getByLabelText("Spend Logs Cleanup Max Batches (Optional)")).toBeInTheDocument(); + expect(screen.getByLabelText("Spend Logs Cleanup Run Budget (Optional)")).toBeInTheDocument(); + expect(screen.getByLabelText("Spend Logs Cleanup Batch Timeout (Optional)")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("e.g., 1000")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("e.g., 500")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("e.g., 5m")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("e.g., 30s")).toBeInTheDocument(); + }); + it("should toggle store prompts switch", async () => { const user = userEvent.setup(); renderWithProviders(); @@ -94,6 +169,9 @@ describe("LoggingSettings", () => { it("should submit form with store prompts enabled and retention period", async () => { const user = userEvent.setup(); + mockDeleteField.mockImplementation((_params, options) => { + options?.onSettled?.(); + }); mockMutate.mockImplementation((_params, options) => { options?.onSuccess?.(); }); @@ -110,7 +188,6 @@ describe("LoggingSettings", () => { await user.click(saveButton); await waitFor(() => { - expect(mockDeleteField).not.toHaveBeenCalled(); expect(mockMutate).toHaveBeenCalledWith( { store_prompts_in_spend_logs: true, @@ -119,9 +196,42 @@ describe("LoggingSettings", () => { expect.any(Object), ); }); + expect(clearedFieldNames()).not.toContain("maximum_spend_logs_retention_period"); }); - it("should delete retention period field when left empty on submit", async () => { + it("should submit every spend logs cleanup setting that has a value", async () => { + const user = userEvent.setup(); + mockMutate.mockImplementation((_params, options) => { + options?.onSuccess?.(); + }); + + renderWithProviders(); + + await user.click(screen.getByRole("switch")); + await user.type(screen.getByPlaceholderText("e.g., 7d, 30d"), "30d"); + await user.type(screen.getByPlaceholderText("e.g., 1000"), "2000"); + await user.type(screen.getByPlaceholderText("e.g., 500"), "50"); + await user.type(screen.getByPlaceholderText("e.g., 5m"), "90s"); + await user.type(screen.getByPlaceholderText("e.g., 30s"), "10s"); + + await user.click(screen.getByRole("button", { name: "Save Settings" })); + + const expectedParams = { + store_prompts_in_spend_logs: true, + maximum_spend_logs_retention_period: "30d", + maximum_spend_logs_cleanup_batch_size: 2000, + maximum_spend_logs_cleanup_max_batches: 50, + maximum_spend_logs_cleanup_run_budget: "90s", + maximum_spend_logs_cleanup_batch_timeout: "10s", + }; + + await waitFor(() => { + expect(mockMutate).toHaveBeenCalledWith(expectedParams, expect.any(Object)); + }); + expect(mockDeleteField).not.toHaveBeenCalled(); + }); + + it("should omit blank cleanup settings from the save payload instead of sending empty values", async () => { const user = userEvent.setup(); mockDeleteField.mockImplementation((_params, options) => { options?.onSettled?.(); @@ -132,11 +242,70 @@ describe("LoggingSettings", () => { renderWithProviders(); + await user.type(screen.getByPlaceholderText("e.g., 1000"), "2000"); + await user.click(screen.getByRole("button", { name: "Save Settings" })); + + await waitFor(() => { + expect(mockMutate).toHaveBeenCalled(); + }); + + const submittedParams = mockMutate.mock.calls[0][0]; + expect(submittedParams).not.toHaveProperty("maximum_spend_logs_retention_period"); + expect(submittedParams).not.toHaveProperty("maximum_spend_logs_cleanup_max_batches"); + expect(submittedParams).not.toHaveProperty("maximum_spend_logs_cleanup_run_budget"); + expect(submittedParams).not.toHaveProperty("maximum_spend_logs_cleanup_batch_timeout"); + expect(submittedParams).toEqual({ + store_prompts_in_spend_logs: false, + maximum_spend_logs_cleanup_batch_size: 2000, + }); + }); + + it("should clear the stored value of every cleanup setting left blank", async () => { + const user = userEvent.setup(); + withEveryOptionalFieldStored(); + mockDeleteField.mockImplementation((_params, options) => { + options?.onSettled?.(); + }); + mockMutate.mockImplementation((_params, options) => { + options?.onSuccess?.(); + }); + + renderWithProviders(); + + await blankEveryOptionalField(user); + await user.type(screen.getByPlaceholderText("e.g., 5m"), "10m"); + await user.click(screen.getByRole("button", { name: "Save Settings" })); + + await waitFor(() => { + expect(mockMutate).toHaveBeenCalled(); + }); + + expect(clearedFieldNames().sort()).toEqual([ + "maximum_spend_logs_cleanup_batch_size", + "maximum_spend_logs_cleanup_batch_timeout", + "maximum_spend_logs_cleanup_max_batches", + "maximum_spend_logs_retention_period", + ]); + }); + + it("should delete retention period field when left empty on submit", async () => { + const user = userEvent.setup(); + withEveryOptionalFieldStored(); + mockDeleteField.mockImplementation((_params, options) => { + options?.onSettled?.(); + }); + mockMutate.mockImplementation((_params, options) => { + options?.onSuccess?.(); + }); + + renderWithProviders(); + + await blankEveryOptionalField(user); const saveButton = screen.getByRole("button", { name: "Save Settings" }); await user.click(saveButton); await waitFor(() => { - expect(mockDeleteField).toHaveBeenCalled(); + expect(clearedFieldNames()).toContain("maximum_spend_logs_retention_period"); expect(mockMutate).toHaveBeenCalledWith( { store_prompts_in_spend_logs: false, @@ -234,6 +403,38 @@ describe("LoggingSettings", () => { stored_in_db: true, field_default_value: undefined, }, + { + field_name: "maximum_spend_logs_cleanup_batch_size", + field_type: "Integer", + field_description: "Rows per delete", + field_value: 2000, + stored_in_db: true, + field_default_value: 1000, + }, + { + field_name: "maximum_spend_logs_cleanup_max_batches", + field_type: "Integer", + field_description: "Deletes per table per run", + field_value: 50, + stored_in_db: true, + field_default_value: 500, + }, + { + field_name: "maximum_spend_logs_cleanup_run_budget", + field_type: "string", + field_description: "Wall clock budget per run", + field_value: "90s", + stored_in_db: true, + field_default_value: "5m", + }, + { + field_name: "maximum_spend_logs_cleanup_batch_timeout", + field_type: "string", + field_description: "Statement and lock timeout per batch", + field_value: "10s", + stored_in_db: true, + field_default_value: "30s", + }, ], isLoading: false, refetch: mockRefetch, @@ -246,6 +447,10 @@ describe("LoggingSettings", () => { expect(switchElement).toBeChecked(); expect(retentionInput).toHaveValue("30d"); + expect(screen.getByPlaceholderText("e.g., 1000")).toHaveDisplayValue("2000"); + expect(screen.getByPlaceholderText("e.g., 500")).toHaveDisplayValue("50"); + expect(screen.getByPlaceholderText("e.g., 5m")).toHaveValue("90s"); + expect(screen.getByPlaceholderText("e.g., 30s")).toHaveValue("10s"); }); it("should reflect persisted values that arrive after the initial loading render", async () => { @@ -307,11 +512,11 @@ describe("LoggingSettings", () => { expect(skeletons.length).toBeGreaterThan(0); }); - it("should continue with update even if deleteField fails", async () => { + it("should report an error and not claim success when clearing a field fails", async () => { const user = userEvent.setup(); - const deleteError = new Error("Field does not exist"); + withEveryOptionalFieldStored(); mockDeleteField.mockImplementation((_params, options) => { - options?.onError?.(deleteError); + options?.onError?.(new Error("Field does not exist")); options?.onSettled?.(); }); mockMutate.mockImplementation((_params, options) => { @@ -320,19 +525,52 @@ describe("LoggingSettings", () => { renderWithProviders(); + await blankEveryOptionalField(user); + const saveButton = screen.getByRole("button", { name: "Save Settings" }); + await user.click(saveButton); + + await waitFor(() => { + expect(mockNotificationsManager.fromBackend).toHaveBeenCalled(); + }); + // the old value is still in force server side, so an unqualified success + // notification would tell the admin the opposite of what happened + expect(mockNotificationsManager.success).not.toHaveBeenCalled(); + }); + + it("should clear fields one at a time, never concurrently", async () => { + const user = userEvent.setup(); + withEveryOptionalFieldStored(); + let inFlight = 0; + let maxInFlight = 0; + + mockDeleteField.mockImplementation((_params, options) => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + // Settle on a microtask rather than synchronously, so a parallel + // implementation genuinely overlaps: Promise.all would issue every call + // before any of them settles, driving inFlight to the number of fields. + void Promise.resolve().then(() => { + inFlight -= 1; + options?.onSettled?.(); + }); + }); + mockMutate.mockImplementation((_params, options) => { + options?.onSuccess?.(); + }); + + renderWithProviders(); + + await blankEveryOptionalField(user); const saveButton = screen.getByRole("button", { name: "Save Settings" }); await user.click(saveButton); await waitFor(() => { - expect(mockDeleteField).toHaveBeenCalled(); - expect(mockMutate).toHaveBeenCalledWith( - { - store_prompts_in_spend_logs: false, - }, - expect.any(Object), - ); expect(mockNotificationsManager.success).toHaveBeenCalled(); }); + // /config/field/delete rewrites the whole general_settings object, so two of + // them in flight at once means the later write restores what the earlier cleared + expect(maxInFlight).toBe(1); + expect(mockDeleteField.mock.calls.length).toBeGreaterThan(1); }); it("should submit with only store prompts enabled when retention is empty", async () => { @@ -353,7 +591,6 @@ describe("LoggingSettings", () => { await user.click(saveButton); await waitFor(() => { - expect(mockDeleteField).toHaveBeenCalled(); expect(mockMutate).toHaveBeenCalledWith( { store_prompts_in_spend_logs: true, @@ -361,5 +598,57 @@ describe("LoggingSettings", () => { expect.any(Object), ); }); + // nothing is stored for the blank fields, so there is nothing to clear + expect(mockDeleteField).not.toHaveBeenCalled(); + }); + + it("should save on a proxy that has never stored these settings, without clearing anything", async () => { + // The first save on a new deployment: no general_settings row exists, so + // /config/field/delete answers 400 for every blank field. Issuing those + // clears anyway failed the whole save and persisted nothing. + const user = userEvent.setup(); + mockDeleteField.mockImplementation((_params, options) => { + options?.onError?.(new Error("Field name=... not in config")); + options?.onSettled?.(); + }); + mockMutate.mockImplementation((_params, options) => { + options?.onSuccess?.(); + }); + + renderWithProviders(); + + const switchElement = screen.getByRole("switch"); + await user.click(switchElement); + await user.click(screen.getByRole("button", { name: "Save Settings" })); + + await waitFor(() => { + expect(mockNotificationsManager.success).toHaveBeenCalled(); + }); + expect(mockDeleteField).not.toHaveBeenCalled(); + expect(mockMutate).toHaveBeenCalledWith({ store_prompts_in_spend_logs: true }, expect.any(Object)); + expect(mockNotificationsManager.fromBackend).not.toHaveBeenCalled(); + }); + + it("should still clear a field that does have a stored value", async () => { + // The guard above must not turn into "never clear anything": a field the + // admin blanks out that IS stored still has to be deleted server side. + const user = userEvent.setup(); + withEveryOptionalFieldStored(); + mockDeleteField.mockImplementation((_params, options) => { + options?.onSettled?.(); + }); + mockMutate.mockImplementation((_params, options) => { + options?.onSuccess?.(); + }); + + renderWithProviders(); + + await user.clear(screen.getByPlaceholderText("e.g., 5m")); + await user.click(screen.getByRole("button", { name: "Save Settings" })); + + await waitFor(() => { + expect(mockNotificationsManager.success).toHaveBeenCalled(); + }); + expect(clearedFieldNames()).toContain("maximum_spend_logs_cleanup_run_budget"); }); }); diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/LoggingSettings/LoggingSettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/LoggingSettings/LoggingSettings.tsx index 0d156a8dc10..07928768cd4 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/LoggingSettings/LoggingSettings.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/LoggingSettings/LoggingSettings.tsx @@ -13,43 +13,165 @@ import { import NotificationsManager from "@/components/molecules/notifications_manager"; import { parseErrorMessage } from "@/components/shared/errorUtils"; import { ClockCircleOutlined } from "@ant-design/icons"; -import { Button, Card, Form, Input, Skeleton, Space, Switch, Typography } from "antd"; -import React, { useMemo } from "react"; +import { Button, Card, Form, Input, InputNumber, Skeleton, Space, Switch, Typography } from "antd"; +import React, { useCallback, useMemo } from "react"; + +const STORE_PROMPTS_FIELD_NAME = "store_prompts_in_spend_logs"; + +interface OptionalField { + readonly name: GeneralSettingsFieldName; + readonly kind: "duration" | "count"; + readonly label: string; + readonly placeholder: string; + readonly fallbackTooltip: string; +} + +const OPTIONAL_FIELDS: readonly OptionalField[] = [ + { + name: GeneralSettingsFieldName.MAXIMUM_SPEND_LOGS_RETENTION_PERIOD, + kind: "duration", + label: "Maximum Spend Logs Retention Period (Optional)", + placeholder: "e.g., 7d, 30d", + fallbackTooltip: + "Set the maximum retention period for spend logs (e.g., '7d' for 7 days, '30d' for 30 days). Leave empty for no limit.", + }, + { + name: GeneralSettingsFieldName.MAXIMUM_SPEND_LOGS_CLEANUP_BATCH_SIZE, + kind: "count", + label: "Spend Logs Cleanup Batch Size (Optional)", + placeholder: "e.g., 1000", + fallbackTooltip: "Rows deleted per DELETE statement during cleanup. Leave empty to use the default of 1000.", + }, + { + name: GeneralSettingsFieldName.MAXIMUM_SPEND_LOGS_CLEANUP_MAX_BATCHES, + kind: "count", + label: "Spend Logs Cleanup Max Batches (Optional)", + placeholder: "e.g., 500", + fallbackTooltip: + "Maximum number of DELETE statements run per table per cleanup run. Leave empty to use the default of 500.", + }, + { + name: GeneralSettingsFieldName.MAXIMUM_SPEND_LOGS_CLEANUP_RUN_BUDGET, + kind: "duration", + label: "Spend Logs Cleanup Run Budget (Optional)", + placeholder: "e.g., 5m", + fallbackTooltip: + "Wall-clock budget for a whole cleanup run, shared across every table it cleans (e.g., '5m'). Leave empty to use the default of 5m.", + }, + { + name: GeneralSettingsFieldName.MAXIMUM_SPEND_LOGS_CLEANUP_BATCH_TIMEOUT, + kind: "duration", + label: "Spend Logs Cleanup Batch Timeout (Optional)", + placeholder: "e.g., 30s", + fallbackTooltip: + "Postgres statement and lock timeout applied to each cleanup batch, so cleanup never monopolizes a connection (e.g., '30s'). Leave empty to use the default of 30s.", + }, +]; + +interface LoggingSettingsFormValues { + store_prompts_in_spend_logs: boolean; + maximum_spend_logs_retention_period?: string | null; + maximum_spend_logs_cleanup_batch_size?: number | null; + maximum_spend_logs_cleanup_max_batches?: number | null; + maximum_spend_logs_cleanup_run_budget?: string | null; + maximum_spend_logs_cleanup_batch_timeout?: string | null; +} + +const hasDuration = (value: string | null | undefined): value is string => + typeof value === "string" && value.trim() !== ""; + +const hasCount = (value: number | null | undefined): value is number => + typeof value === "number" && Number.isFinite(value); + +const buildUpdateParams = (formValues: LoggingSettingsFormValues): StoreRequestInSpendLogsParams => ({ + store_prompts_in_spend_logs: formValues.store_prompts_in_spend_logs, + ...(hasDuration(formValues.maximum_spend_logs_retention_period) && { + maximum_spend_logs_retention_period: formValues.maximum_spend_logs_retention_period, + }), + ...(hasCount(formValues.maximum_spend_logs_cleanup_batch_size) && { + maximum_spend_logs_cleanup_batch_size: formValues.maximum_spend_logs_cleanup_batch_size, + }), + ...(hasCount(formValues.maximum_spend_logs_cleanup_max_batches) && { + maximum_spend_logs_cleanup_max_batches: formValues.maximum_spend_logs_cleanup_max_batches, + }), + ...(hasDuration(formValues.maximum_spend_logs_cleanup_run_budget) && { + maximum_spend_logs_cleanup_run_budget: formValues.maximum_spend_logs_cleanup_run_budget, + }), + ...(hasDuration(formValues.maximum_spend_logs_cleanup_batch_timeout) && { + maximum_spend_logs_cleanup_batch_timeout: formValues.maximum_spend_logs_cleanup_batch_timeout, + }), +}); + +// A blank field only needs clearing when something is actually stored for it. +// Asking the proxy to clear a field it has no value for is a 400 whenever no +// general_settings row exists at all, which is the state of every deployment +// that has never saved one, so clearing unconditionally would fail the first +// save on a new proxy and take the rest of the form down with it. +const omittedFieldNames = ( + updateParams: StoreRequestInSpendLogsParams, + isStored: (name: GeneralSettingsFieldName) => boolean, +): readonly GeneralSettingsFieldName[] => + OPTIONAL_FIELDS.map((field) => field.name).filter((name) => !(name in updateParams) && isStored(name)); const LoggingSettings: React.FC = () => { - const [form] = Form.useForm(); + const [form] = Form.useForm(); const { mutate, isPending } = useStoreRequestInSpendLogs(); const { mutate: deleteField, isPending: isDeletingField } = useDeleteProxyConfigField(); const { data: proxyConfigData, isLoading: isLoadingConfig } = useProxyConfig(ConfigType.GENERAL_SETTINGS); + const describeField = (name: string, fallback: string) => + proxyConfigData?.find((field) => field.field_name === name)?.field_description || fallback; + + const storedValue = useCallback( + (name: string) => proxyConfigData?.find((field) => field.field_name === name)?.field_value, + [proxyConfigData], + ); + + const isStored = (name: GeneralSettingsFieldName) => { + const value = storedValue(name); + return value !== null && value !== undefined; + }; + const initialValues = useMemo(() => { - if (!proxyConfigData) { - return { - store_prompts_in_spend_logs: false, - maximum_spend_logs_retention_period: undefined, - }; - } - - const storePromptsField = proxyConfigData.find((field) => field.field_name === "store_prompts_in_spend_logs"); - const retentionPeriodField = proxyConfigData.find( - (field) => field.field_name === "maximum_spend_logs_retention_period", - ); - return { - store_prompts_in_spend_logs: storePromptsField?.field_value ?? false, - maximum_spend_logs_retention_period: retentionPeriodField?.field_value ?? undefined, + store_prompts_in_spend_logs: storedValue(STORE_PROMPTS_FIELD_NAME) ?? false, + ...Object.fromEntries(OPTIONAL_FIELDS.map((field) => [field.name, storedValue(field.name)])), }; - }, [proxyConfigData]); + }, [storedValue]); - const handleFormSubmit = (formValues: StoreRequestInSpendLogsParams) => { - const retentionPeriodValue = formValues.maximum_spend_logs_retention_period; - const hasRetentionPeriod = typeof retentionPeriodValue === "string" && retentionPeriodValue.trim() !== ""; + // Resolves to the field name when clearing it failed, or null when it worked. + const clearStoredField = (fieldName: GeneralSettingsFieldName) => + new Promise((resolve) => { + let failed = false; + deleteField( + { config_type: ConfigType.GENERAL_SETTINGS, field_name: fieldName }, + { + onError: () => { + failed = true; + }, + onSettled: () => resolve(failed ? fieldName : null), + }, + ); + }); - const updateParams: StoreRequestInSpendLogsParams = { - store_prompts_in_spend_logs: formValues.store_prompts_in_spend_logs, - ...(hasRetentionPeriod && { maximum_spend_logs_retention_period: retentionPeriodValue }), - }; + // Clearing a field rewrites the whole stored general_settings object server + // side, so these must run one at a time: in parallel the last write back wins + // and silently restores the fields the earlier ones just cleared. + const clearStoredFieldsInSequence = async ( + fieldNames: readonly GeneralSettingsFieldName[], + ): Promise => { + const failed: GeneralSettingsFieldName[] = []; + for (const fieldName of fieldNames) { + const failure = await clearStoredField(fieldName); + if (failure !== null) { + failed.push(failure); + } + } + return failed; + }; + const handleFormSubmit = (formValues: LoggingSettingsFormValues) => { + const updateParams = buildUpdateParams(formValues); const submitUpdate = () => mutate(updateParams, { onSuccess: () => NotificationsManager.success("Spend logs settings updated successfully"), @@ -57,21 +179,21 @@ const LoggingSettings: React.FC = () => { NotificationsManager.fromBackend("Failed to save spend logs settings: " + parseErrorMessage(error)), }); - if (hasRetentionPeriod) { + const fieldsToClear = omittedFieldNames(updateParams, isStored); + if (fieldsToClear.length === 0) { submitUpdate(); return; } - deleteField( - { - config_type: ConfigType.GENERAL_SETTINGS, - field_name: GeneralSettingsFieldName.MAXIMUM_SPEND_LOGS_RETENTION_PERIOD, - }, - { - onError: (deleteError) => console.warn("Failed to delete retention period field (may not exist):", deleteError), - onSettled: submitUpdate, - }, - ); + void clearStoredFieldsInSequence(fieldsToClear).then((failed) => { + if (failed.length > 0) { + // Reporting an unqualified success here would tell the admin a setting + // was reset to its default while the old value is still in force. + NotificationsManager.fromBackend(`Failed to clear saved value for: ${failed.join(", ")}`); + return; + } + submitUpdate(); + }); }; return ( @@ -87,27 +209,30 @@ const LoggingSettings: React.FC = () => {
f.field_name === "store_prompts_in_spend_logs")?.field_description || - "When enabled, prompts will be stored in spend logs for tracking and analysis purposes." - } + name={STORE_PROMPTS_FIELD_NAME} + tooltip={describeField( + STORE_PROMPTS_FIELD_NAME, + "When enabled, prompts will be stored in spend logs for tracking and analysis purposes.", + )} valuePropName="checked" > - f.field_name === "maximum_spend_logs_retention_period") - ?.field_description || - "Set the maximum retention period for spend logs (e.g., '7d' for 7 days, '30d' for 30 days). Leave empty for no limit." - } - > - } /> - + {OPTIONAL_FIELDS.map((field) => ( + + {field.kind === "duration" ? ( + } /> + ) : ( + + )} + + ))} - - ), - disabled: true, - }, - ]; - } else if (!data || data.posts.length === 0) { - items = [{ key: "empty", label: No posts available, disabled: true }]; - } else { - items = [ - ...data.posts.slice(0, 5).map((post: BlogPost) => ({ - key: post.url, - label: ( - - - {post.title} - - - {formatDate(post.date)} - - {post.description} - - ), - })), - { type: "divider" as const }, - { - key: "view-all", - label: ( + if (isError) { + return ( +
+ Failed to load posts + +
+ ); + } + + if (!data || data.posts.length === 0) { + return
No posts available
; + } + + return ( + <> + {data.posts.slice(0, 5).map((post: BlogPost) => ( + + +
+ {post.title} +
+ + {formatDate(post.date)} + +

{post.description}

+
+
+ ))} + + View all posts - ), - }, - ]; - } + + + ); + }; // Blog opens a post list; Docs is a single outbound link — navbar adds a layout-only chevron there for alignment. return ( - - - + + + + {renderMenuContent()} + + ); }; diff --git a/ui/litellm-dashboard/src/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons.tsx b/ui/litellm-dashboard/src/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons.tsx index f6a43196a32..8ec31d74cd6 100644 --- a/ui/litellm-dashboard/src/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons.tsx @@ -1,6 +1,6 @@ import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts"; -import { GithubOutlined, SlackOutlined } from "@ant-design/icons"; -import { Tooltip } from "antd"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { Github, Slack } from "lucide-react"; import React from "react"; const iconBtnClass = @@ -18,28 +18,40 @@ export const CommunityEngagementButtons: React.FC = () => { className="flex items-center gap-0.5 rounded-md border border-gray-200/80 bg-gray-50 px-0.5 py-0" aria-label="Community links" > - - - - - - - - - - + + + + } + > + + + LiteLLM Slack community + + + + } + > + + + LiteLLM on GitHub + + ); }; diff --git a/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx b/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx index a3d5db4afd7..f3adcf6d8be 100644 --- a/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx @@ -5,8 +5,11 @@ import { useHideAutoRouterAnnouncement, } from "@/app/(dashboard)/hooks/useHideAutoRouterAnnouncement"; import { emitLocalStorageChange, setLocalStorageItem } from "@/utils/localStorageUtils"; -import { BellOutlined } from "@ant-design/icons"; -import { Badge, Button, Popover, Typography } from "antd"; +import { Badge } from "@/components/ui/badge"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverDescription, PopoverTitle, PopoverTrigger } from "@/components/ui/popover"; +import { cn } from "@/lib/cva.config"; +import { Bell } from "lucide-react"; import React, { useState } from "react"; export const AUTO_ROUTER_DOCS_URL = "https://docs.litellm.ai/docs/proxy/auto_routing"; @@ -24,18 +27,21 @@ export const NotificationsBell: React.FC = () => { const content = (
- - LiteLLM Auto Router - - + LiteLLM Auto Router + Route every request to the cheapest model that can handle it, no prompt changes needed. - +
- + {hasUnread ? ( - ) : null} @@ -44,16 +50,17 @@ export const NotificationsBell: React.FC = () => { ); return ( - - + + + {hasUnread ? : null} + + + {content} ); }; diff --git a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx index 28e981c57a1..1d92ef246cd 100644 --- a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx @@ -9,23 +9,17 @@ import { setLocalStorageItem, } from "@/utils/localStorageUtils"; import { navAccountDisplayName } from "@/components/Navbar/navDisplayName"; -import { - CrownOutlined, - DownOutlined, - LogoutOutlined, - MailOutlined, - SafetyOutlined, - UserOutlined, -} from "@ant-design/icons"; -import type { MenuProps } from "antd"; -import { Button, Divider, Dropdown, Space, Switch, Tag, Tooltip, Typography } from "antd"; -import { ChevronsUpDown } from "lucide-react"; +import { ChevronDown, ChevronsUpDown, Crown, LogOut, Mail, ShieldCheck, User } from "lucide-react"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Separator } from "@/components/ui/separator"; +import { Switch } from "@/components/ui/switch"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import CopyButton from "@/components/shared/CopyButton"; import { cn } from "@/lib/cva.config"; import React, { useEffect, useState } from "react"; -const { Text } = Typography; - function hueFromString(seed: string): number { let h = 0; for (let i = 0; i < seed.length; i += 1) { @@ -80,60 +74,57 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar setDisableShowNewBadge(storedValue === "true"); }, []); - const userItems: MenuProps["items"] = [ - { - key: "logout", - label: ( - - - Logout - - ), - onClick: onLogout, - }, - ]; - const renderUserInfoSection = () => ( - - - - - {userEmail || "-"} - +
+
+
+ + {userEmail || "-"} +
{premiumUser ? ( - } color="gold"> + + Premium - + ) : ( - - }>Standard - + + + }> + + Standard + + Upgrade to Premium for advanced features + + )} - - - - - - User ID - - - {userId || "-"} - - - - - - Role - - {userRole} - - - - Hide New Feature Indicators +
+ +
+
+ + User ID +
+
+ + {userId || "-"} + + +
+
+
+
+ + Role +
+ {userRole} +
+ +
+ Hide New Feature Indicators { + onCheckedChange={(checked) => { setDisableShowNewBadge(checked); if (checked) { setLocalStorageItem("disableShowNewBadge", "true"); @@ -145,13 +136,13 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar }} aria-label="Toggle hide new feature indicators" /> - - - Hide All Prompts +
+
+ Hide All Prompts { + onCheckedChange={(checked) => { if (checked) { setLocalStorageItem("disableShowPrompts", "true"); emitLocalStorageChange("disableShowPrompts"); @@ -162,13 +153,13 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar }} aria-label="Toggle hide all prompts" /> - - - Hide Blog Posts +
+
+ Hide Blog Posts { + onCheckedChange={(checked) => { if (checked) { setLocalStorageItem("disableBlogPosts", "true"); emitLocalStorageChange("disableBlogPosts"); @@ -179,13 +170,13 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar }} aria-label="Toggle hide blog posts" /> - - - Hide Bouncing Icon +
+
+ Hide Bouncing Icon { + onCheckedChange={(checked) => { if (checked) { setLocalStorageItem("disableBouncingIcon", "true"); emitLocalStorageChange("disableBouncingIcon"); @@ -196,8 +187,8 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar }} aria-label="Toggle hide bouncing icon" /> - - +
+
); const seed = userEmail || userId || "user"; @@ -206,30 +197,21 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar const displayName = navAccountDisplayName(userEmail, userId); return ( - ( -
- {renderUserInfoSection()} - - {React.cloneElement(menu as React.ReactElement, { - style: { boxShadow: "none" }, - })} -
- )} - > + {variant === "sidebar" ? ( - + ) : ( - + + )} -
+ + {renderUserInfoSection()} + + + + ); }; diff --git a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx index 09a4538ae18..f1dec137305 100644 --- a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx @@ -1,9 +1,12 @@ import React from "react"; import { usePathname } from "next/navigation"; -import { Dropdown } from "antd"; -import { AppstoreOutlined, CheckOutlined } from "@ant-design/icons"; -import { ChevronsUpDown } from "lucide-react"; -import type { MenuProps } from "antd"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Check, ChevronsUpDown, LayoutGrid } from "lucide-react"; import { usePluginMode } from "@/contexts/PluginModeContext"; import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; import { migratedHref } from "@/utils/migratedPages"; @@ -11,6 +14,13 @@ import { migratedHref } from "@/utils/migratedPages"; const GATEWAY = "ai-gateway"; const CHAT = "chat"; +interface ViewSwitcherItem { + key: string; + label: React.ReactNode; + disabled?: boolean; + onClick?: () => void; +} + export default function ViewSwitcher() { const { mode, setMode, plugins } = usePluginMode(); const { data: uiSettings } = useUISettings(); @@ -29,15 +39,25 @@ export default function ViewSwitcher() { ...plugins.map((p) => ({ key: p.name, label: p.display_name })), ]; - const chatItem = chatEnabled + const selectMode = (key: string) => { + setMode(key); + // The chat route lives outside the dashboard SPA shell that reacts to `mode`, + // so switching modes from there needs a real navigation, not just state. + if (isChatRoute) { + window.location.assign(migratedHref("")); + } + }; + + const chatItem: ViewSwitcherItem = chatEnabled ? { key: CHAT, label: (
Chat - {isChatRoute && } + {isChatRoute && }
), + onClick: () => window.location.assign(migratedHref(CHAT)), } : { key: CHAT, @@ -52,44 +72,43 @@ export default function ViewSwitcher() { ), }; - const items: MenuProps["items"] = [ + const items: ViewSwitcherItem[] = [ ...modeEntries.map((e) => ({ key: e.key, label: (
{e.label} - {!isChatRoute && e.key === mode && } + {!isChatRoute && e.key === mode && }
), + onClick: () => selectMode(e.key), })), chatItem, ]; - const onClick: MenuProps["onClick"] = ({ key }) => { - if (key === CHAT) { - window.location.assign(migratedHref(CHAT)); - return; - } - setMode(key); - // The chat route lives outside the dashboard SPA shell that reacts to `mode`, - // so switching modes from there needs a real navigation, not just state. - if (isChatRoute) { - window.location.assign(migratedHref("")); - } - }; - return ( - - - + + + {items.map((item) => ( + + {item.label} + + ))} + + ); } diff --git a/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.test.tsx b/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.test.tsx index a51d6ba055d..0930270eb4b 100644 --- a/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.test.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.test.tsx @@ -1,32 +1,21 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi, beforeEach } from "vitest"; -// Mock the useWorker hook const mockUseWorker = vi.fn(); vi.mock("@/hooks/useWorker", () => ({ useWorker: () => mockUseWorker(), })); -// Mock antd Select -vi.mock("antd", () => ({ - Select: ({ value, options, onChange, style, disabled, ...props }: any) => ( - - ), -})); - -// Mock icon -vi.mock("@ant-design/icons", () => ({ - CloudServerOutlined: () => , -})); - import WorkerDropdown from "./WorkerDropdown"; +async function openWorkerList(user: ReturnType) { + await user.click(screen.getByRole("combobox")); + await waitFor(() => { + expect(screen.getByRole("combobox")).toHaveAttribute("aria-expanded", "true"); + }); +} + describe("WorkerDropdown", () => { const mockOnWorkerSwitch = vi.fn(); const workers = [ @@ -61,31 +50,7 @@ describe("WorkerDropdown", () => { expect(container).toBeEmptyDOMElement(); }); - it("renders the select when isControlPlane and selectedWorker exist", () => { - mockUseWorker.mockReturnValue({ - isControlPlane: true, - selectedWorker: workers[0], - workers, - }); - - render(); - expect(screen.getByTestId("worker-select")).toBeInTheDocument(); - }); - - it("renders all worker options", () => { - mockUseWorker.mockReturnValue({ - isControlPlane: true, - selectedWorker: workers[0], - workers, - }); - - render(); - expect(screen.getByText("Worker 1")).toBeInTheDocument(); - expect(screen.getByText("Worker 2")).toBeInTheDocument(); - expect(screen.getByText("Worker 3")).toBeInTheDocument(); - }); - - it("sets current worker as selected value", () => { + it("renders a collapsed worker combobox when isControlPlane and selectedWorker exist", () => { mockUseWorker.mockReturnValue({ isControlPlane: true, selectedWorker: workers[1], @@ -93,37 +58,109 @@ describe("WorkerDropdown", () => { }); render(); - const select = screen.getByTestId("worker-select") as HTMLSelectElement; - expect(select.value).toBe("w2"); + expect(screen.getByRole("combobox")).toHaveAttribute("aria-expanded", "false"); }); - it("disables the currently selected worker in options", () => { + it("reveals every worker only once the combobox is opened", async () => { mockUseWorker.mockReturnValue({ isControlPlane: true, - selectedWorker: workers[0], + selectedWorker: workers[1], workers, }); - - render(); - const options = screen.getAllByRole("option"); - const selectedOption = options.find((opt) => (opt as HTMLOptionElement).value === "w1"); - expect(selectedOption).toBeDisabled(); - }); - - it("calls onWorkerSwitch when selection changes", async () => { - mockUseWorker.mockReturnValue({ - isControlPlane: true, - selectedWorker: workers[0], - workers, - }); - - render(); - const select = screen.getByTestId("worker-select"); - - const { default: userEvent } = await import("@testing-library/user-event"); const user = userEvent.setup(); - await user.selectOptions(select, "w2"); - expect(mockOnWorkerSwitch).toHaveBeenCalledWith("w2"); + render(); + expect(screen.queryAllByRole("option")).toHaveLength(0); + expect(screen.queryByText("Worker 1")).not.toBeInTheDocument(); + expect(screen.queryByText("Worker 3")).not.toBeInTheDocument(); + + await openWorkerList(user); + + await waitFor(() => { + expect(screen.getByText("Worker 1")).toBeInTheDocument(); + }); + expect(screen.getAllByText("Worker 2").length).toBeGreaterThan(0); + expect(screen.getByText("Worker 3")).toBeInTheDocument(); + }); + + it("marks exactly one option as selected, the current worker", async () => { + mockUseWorker.mockReturnValue({ + isControlPlane: true, + selectedWorker: workers[1], + workers, + }); + const user = userEvent.setup(); + + render(); + await openWorkerList(user); + + await waitFor(() => { + const selected = screen.getAllByRole("option").filter((o) => o.getAttribute("aria-selected") === "true"); + expect(selected).toHaveLength(1); + expect(selected[0]).toHaveAccessibleName("Worker 2"); + }); + }); + + it("calls onWorkerSwitch with the id of the worker that was picked", async () => { + mockUseWorker.mockReturnValue({ + isControlPlane: true, + selectedWorker: workers[1], + workers, + }); + const user = userEvent.setup(); + + render(); + await openWorkerList(user); + await waitFor(() => { + expect(screen.getByText("Worker 3")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText("Worker 3")); + + expect(mockOnWorkerSwitch).toHaveBeenCalledWith("w3"); + }); + + it("does not call onWorkerSwitch when the already-current worker is picked", async () => { + mockUseWorker.mockReturnValue({ + isControlPlane: true, + selectedWorker: workers[1], + workers, + }); + const user = userEvent.setup(); + + render(); + await openWorkerList(user); + await waitFor(() => { + expect(screen.getByText("Worker 3")).toBeInTheDocument(); + }); + + for (const currentWorkerNode of screen.getAllByText("Worker 2")) { + fireEvent.click(currentWorkerNode); + } + + expect(mockOnWorkerSwitch).not.toHaveBeenCalled(); + }); + + it("filters the worker options by the typed search text", async () => { + mockUseWorker.mockReturnValue({ + isControlPlane: true, + selectedWorker: workers[1], + workers, + }); + const user = userEvent.setup(); + + render(); + await openWorkerList(user); + await waitFor(() => { + expect(screen.getByText("Worker 1")).toBeInTheDocument(); + }); + + await user.clear(screen.getByRole("combobox")); + await user.type(screen.getByRole("combobox"), "worker 3"); + + await waitFor(() => { + expect(screen.queryByText("Worker 1")).not.toBeInTheDocument(); + }); + expect(screen.getByText("Worker 3")).toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.tsx index 186cc611117..432bab8c9ef 100644 --- a/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.tsx @@ -1,35 +1,66 @@ "use client"; import React from "react"; -import { Select } from "antd"; -import { CloudServerOutlined } from "@ant-design/icons"; +import { Server } from "lucide-react"; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@/components/ui/combobox"; +import { InputGroupAddon } from "@/components/ui/input-group"; import { useWorker } from "@/hooks/useWorker"; interface WorkerDropdownProps { onWorkerSwitch: (workerId: string) => void; } +interface WorkerOption { + label: string; + value: string; + disabled: boolean; +} + const WorkerDropdown: React.FC = ({ onWorkerSwitch }) => { const { isControlPlane, selectedWorker, workers } = useWorker(); if (!isControlPlane || !selectedWorker) return null; + const options: WorkerOption[] = workers.map((w) => ({ + label: w.name, + value: w.worker_id, + disabled: w.worker_id === selectedWorker.worker_id, + })); + return ( - setDomainFilter(val)} - style={{ width: 160 }} - options={domains.map((d) => ({ label: d, value: d }))} - /> - } - placeholder="Search by name, namespace, or tag…" - value={search} - onChange={(e) => setSearch(e.target.value)} - style={{ width: 280 }} - allowClear - /> + items={domainItems} + value={domainFilter ?? ALL_DOMAINS} + onValueChange={(val) => setDomainFilter(val === null || val === ALL_DOMAINS ? undefined : val)} + > + + + + + {domainItems.map((item) => ( + + {item.label} + + ))} + + + + + + + setSearch(e.target.value)} + /> + {search !== "" && ( + + setSearch("")} + > + + + + )} +
= ({ accessTok }; return ( - +
setIsExpanded(!isExpanded)}>
- Link Management +

Link Management

Manage the links that are displayed under 'Useful Links' on the public model hub.

@@ -243,7 +244,7 @@ const UsefulLinksManagement: React.FC = ({ accessTok {isExpanded && (
- Add New Link +

Add New Link

@@ -288,7 +289,7 @@ const UsefulLinksManagement: React.FC = ({ accessTok
- Manage Existing Links +

Manage Existing Links

= ({ accessTok
- + - Display Name - URL - Actions + Display Name + URL + Actions - + {links.map((link, index) => ( diff --git a/ui/litellm-dashboard/src/components/AIHub/forms/MakeAgentPublicForm.test.tsx b/ui/litellm-dashboard/src/components/AIHub/forms/MakeAgentPublicForm.test.tsx index 67c6d7d6cc9..d72cfcbc037 100644 --- a/ui/litellm-dashboard/src/components/AIHub/forms/MakeAgentPublicForm.test.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/forms/MakeAgentPublicForm.test.tsx @@ -12,67 +12,8 @@ vi.mock("../../networking", () => ({ import { makeAgentsPublicCall } from "../../networking"; const mockMakeAgentsPublicCall = vi.mocked(makeAgentsPublicCall); -// Mock antd components -vi.mock("antd", () => ({ - Modal: ({ open, title, children, onCancel, footer }: any) => - open ? ( -
-
{title}
- {children} - {footer} -
- ) : null, - Form: Object.assign(({ children, form }: any) => {children}, { - useForm: () => [ - { - resetFields: vi.fn(), - validateFields: vi.fn(), - getFieldsValue: vi.fn(), - setFieldsValue: vi.fn(), - }, - vi.fn(), - ], - Item: ({ children }: any) =>
{children}
, - }), - Steps: Object.assign( - ({ children, current, className }: any) => ( -
- {children} -
- ), - { - Step: ({ title }: any) =>
{title}
, - }, - ), - Button: ({ children, onClick, disabled, loading, ...props }: any) => ( - - ), - Checkbox: ({ checked, indeterminate, onChange, children, disabled }: any) => ( - - ), -})); - -// Mock @tremor/react components -vi.mock("@tremor/react", () => ({ - Text: ({ children, className }: any) => {children}, - Title: ({ children }: any) =>

{children}

, - Badge: ({ children, color, size }: any) => ( - - {children} - - ), -})); +const expectDisabledControl = (element: HTMLElement) => + expect(element.hasAttribute("disabled") || element.getAttribute("aria-disabled") === "true").toBe(true); describe("MakeAgentPublicForm", () => { const mockProps = { @@ -143,7 +84,7 @@ describe("MakeAgentPublicForm", () => { expect(screen.getByText("Select Agents to Make Public")).toBeInTheDocument(); // Select all agents using the select all checkbox - const selectAllCheckbox = screen.getByLabelText("Select All (2)"); + const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All (2)" }); await act(async () => { fireEvent.click(selectAllCheckbox); }); @@ -169,7 +110,7 @@ describe("MakeAgentPublicForm", () => { render(); // Select all agents - const selectAllCheckbox = screen.getByLabelText("Select All (2)"); + const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All (2)" }); await act(async () => { fireEvent.click(selectAllCheckbox); }); @@ -232,6 +173,8 @@ describe("MakeAgentPublicForm", () => { const checkboxes = screen.getAllByRole("checkbox"); await act(async () => { fireEvent.click(checkboxes[0]); // Click select all to select all + }); + await act(async () => { fireEvent.click(checkboxes[0]); // Click select all again to deselect all }); @@ -256,8 +199,8 @@ describe("MakeAgentPublicForm", () => { expect(screen.getByText("No agents available.")).toBeInTheDocument(); // Select All checkbox should be disabled - const selectAllCheckbox = screen.getByLabelText("Select All"); - expect(selectAllCheckbox).toBeDisabled(); + const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All" }); + expectDisabledControl(selectAllCheckbox); // Next button should be disabled const nextButton = screen.getByRole("button", { name: "Next" }); @@ -332,7 +275,7 @@ describe("MakeAgentPublicForm", () => { // Select all should be indeterminate now const selectAllCheckbox = checkboxes[0]; - expect(selectAllCheckbox).toHaveAttribute("data-indeterminate", "true"); + expect(selectAllCheckbox).toBePartiallyChecked(); }); it("should display skills overflow text when agent has more than 3 skills", () => { @@ -395,7 +338,7 @@ describe("MakeAgentPublicForm", () => { expect(mockProps.onClose).not.toHaveBeenCalled(); }); - it("should show loading state during submit", async () => { + it("should not complete the flow until the submit request resolves", async () => { let resolvePromise: (value: any) => void = () => {}; const pendingPromise = new Promise((resolve) => { resolvePromise = resolve; @@ -420,9 +363,11 @@ describe("MakeAgentPublicForm", () => { fireEvent.click(submitButton); }); - // Check loading state - expect(submitButton).toHaveAttribute("data-loading", "true"); - expect(submitButton).toBeDisabled(); + // While the request is in flight the flow must not have completed + expect(mockMakeAgentsPublicCall).toHaveBeenCalledTimes(1); + expect(mockProps.onSuccess).not.toHaveBeenCalled(); + expect(mockProps.onClose).not.toHaveBeenCalled(); + expect(screen.getByText("Confirm Making Agents Public")).toBeInTheDocument(); // Resolve the promise resolvePromise({}); @@ -441,7 +386,7 @@ describe("MakeAgentPublicForm", () => { render(); // Modal should not be rendered - expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); expect(screen.queryByText("Make Agents Public")).not.toBeInTheDocument(); }); @@ -500,6 +445,6 @@ describe("MakeAgentPublicForm", () => { // Select all should be indeterminate const selectAllCheckbox = checkboxes[0]; - expect(selectAllCheckbox).toHaveAttribute("data-indeterminate", "true"); + expect(selectAllCheckbox).toBePartiallyChecked(); }); }); diff --git a/ui/litellm-dashboard/src/components/AIHub/forms/MakeAgentPublicForm.tsx b/ui/litellm-dashboard/src/components/AIHub/forms/MakeAgentPublicForm.tsx index 0ed73872cee..82336206858 100644 --- a/ui/litellm-dashboard/src/components/AIHub/forms/MakeAgentPublicForm.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/forms/MakeAgentPublicForm.tsx @@ -1,11 +1,15 @@ import React, { useState, useEffect } from "react"; -import { Modal, Form, Steps, Button, Checkbox } from "antd"; -import { Text, Title, Badge } from "@tremor/react"; +import { Loader2 } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { cn } from "@/lib/cva.config"; import { makeAgentsPublicCall } from "../../networking"; import NotificationsManager from "../../molecules/notifications_manager"; import { AgentHubData } from "@/components/AIHub/AgentHubTableColumns"; -const { Step } = Steps; +const STEP_TITLES = ["Select Agents", "Confirm"]; interface MakeAgentPublicFormProps { visible: boolean; @@ -25,12 +29,10 @@ const MakeAgentPublicForm: React.FC = ({ const [currentStep, setCurrentStep] = useState(0); const [selectedAgents, setSelectedAgents] = useState>(new Set()); const [loading, setLoading] = useState(false); - const [form] = Form.useForm(); const handleClose = () => { setCurrentStep(0); setSelectedAgents(new Set()); - form.resetFields(); onClose(); }; @@ -113,29 +115,30 @@ const MakeAgentPublicForm: React.FC = ({ return (
- Select Agents to Make Public +

Select Agents to Make Public

- handleSelectAll(e.target.checked)} - disabled={agentHubData.length === 0} - > +
- +

Select the agents you want to be visible on the public model hub. Users will still require a valid Virtual Key to use these agents. - +

{agentHubData.length === 0 ? (
- No agents available. +

No agents available.

) : ( agentHubData.map((agent) => { @@ -144,25 +147,23 @@ const MakeAgentPublicForm: React.FC = ({
handleAgentSelection(agentId, e.target.checked)} + onCheckedChange={(checked) => handleAgentSelection(agentId, checked === true)} /> -
+
- {agent.name} - - v{agent.version} - +

{agent.name}

+ v{agent.version}
- {agent.description} +

{agent.description}

{agent.skills && agent.skills.length > 0 && (
{agent.skills.slice(0, 3).map((skill) => ( - + {skill.name} ))} {agent.skills.length > 3 && ( - +{agent.skills.length - 3} more +

+{agent.skills.length - 3} more

)}
)} @@ -176,9 +177,9 @@ const MakeAgentPublicForm: React.FC = ({ {selectedAgents.size > 0 && (
- +

{selectedAgents.size} agent{selectedAgents.size !== 1 ? "s" : ""} selected - +

)}
@@ -188,33 +189,31 @@ const MakeAgentPublicForm: React.FC = ({ const renderStep2Content = () => { return (
- Confirm Making Agents Public +

Confirm Making Agents Public

- +

Warning: Once you make these agents public, anyone who can go to the{" "} /ui/model_hub_table will be able to know they exist on the proxy. - +

- Agents to be made public: +

Agents to be made public:

{Array.from(selectedAgents).map((agentId) => { const agent = agentHubData.find((a) => (a.agent_id || a.name) === agentId); return (
-
+
- {agent?.name || agentId} - {agent && ( - - v{agent.version} - - )} +

{agent?.name || agentId}

+ {agent && v{agent.version}}
- {agent?.description && {agent.description}} + {agent?.description && ( +

{agent.description}

+ )}
); @@ -224,10 +223,10 @@ const MakeAgentPublicForm: React.FC = ({
- +

Total: {selectedAgents.size} agent{selectedAgents.size !== 1 ? "s" : ""} will be made public - +

); @@ -247,7 +246,7 @@ const MakeAgentPublicForm: React.FC = ({ const renderStepButtons = () => { return (
- @@ -259,7 +258,8 @@ const MakeAgentPublicForm: React.FC = ({ )} {currentStep === 1 && ( - )} @@ -269,24 +269,42 @@ const MakeAgentPublicForm: React.FC = ({ }; return ( - -
- - - - + !open && handleClose()} disablePointerDismissal> + + + Make Agents Public + - {renderStepContent()} - {renderStepButtons()} - -
+
+
    + {STEP_TITLES.map((title, index) => ( +
  1. + + {index + 1} + + + {title} + +
  2. + ))} +
+ + {renderStepContent()} + {renderStepButtons()} +
+ + ); }; diff --git a/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.test.tsx b/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.test.tsx index 994a920b2e4..dda6a56146a 100644 --- a/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.test.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.test.tsx @@ -12,83 +12,8 @@ vi.mock("../../networking", () => ({ import { makeMCPPublicCall } from "../../networking"; const mockMakeMCPPublicCall = vi.mocked(makeMCPPublicCall); -// Mock antd components -vi.mock("antd", () => ({ - Modal: ({ open, title, children, onCancel, footer }: any) => - open ? ( -
-
{title}
- {children} - {footer} -
- ) : null, - Form: Object.assign(({ children, form }: any) =>
{children}, { - useForm: () => [ - { - resetFields: vi.fn(), - validateFields: vi.fn(), - getFieldsValue: vi.fn(), - setFieldsValue: vi.fn(), - }, - vi.fn(), - ], - Item: ({ children }: any) =>
{children}
, - }), - Steps: Object.assign( - ({ children, current, className }: any) => ( -
- {children} -
- ), - { - Step: ({ title }: any) =>
{title}
, - }, - ), - Button: ({ children, onClick, disabled, loading, ...props }: any) => ( - - ), - Checkbox: ({ checked, indeterminate, onChange, children, disabled }: any) => ( - - ), -})); - -// Additional @tremor/react mocks. -// NOTE: the comment used to say "Button is already mocked globally" — that was -// incorrect. A file-level vi.mock fully replaces the setup-level mock from -// tests/setupTests.ts, so we must re-apply the Button/Tooltip overrides here. -// Without them, the real Tremor Button leaks through and its useTooltip(300) -// schedules a native setTimeout that can fire post-teardown -> "window is not defined". -vi.mock("@tremor/react", async (importOriginal) => { - const actual = await importOriginal(); - const React = await import("react"); - return { - ...actual, - Text: ({ children, className }: any) => {children}, - Title: ({ children }: any) =>

{children}

, - Badge: ({ children, color, size }: any) => ( - - {children} - - ), - Button: React.forwardRef(({ children, ...props }, ref) => ( - - )), - Tooltip: ({ children }: any) => <>{children}, - }; -}); +const expectDisabledControl = (element: HTMLElement) => + expect(element.hasAttribute("disabled") || element.getAttribute("aria-disabled") === "true").toBe(true); describe("MakeMCPPublicForm", () => { const mockProps = { @@ -182,7 +107,7 @@ describe("MakeMCPPublicForm", () => { expect(screen.getByText("Select MCP Servers to Make Public")).toBeInTheDocument(); // Select all servers using the select all checkbox - const selectAllCheckbox = screen.getByLabelText("Select All (2)"); + const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All (2)" }); await act(async () => { fireEvent.click(selectAllCheckbox); }); @@ -208,7 +133,7 @@ describe("MakeMCPPublicForm", () => { render(); // Select all servers - const selectAllCheckbox = screen.getByLabelText("Select All (2)"); + const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All (2)" }); await act(async () => { fireEvent.click(selectAllCheckbox); }); @@ -271,6 +196,8 @@ describe("MakeMCPPublicForm", () => { const checkboxes = screen.getAllByRole("checkbox"); await act(async () => { fireEvent.click(checkboxes[0]); // Click select all to select all + }); + await act(async () => { fireEvent.click(checkboxes[0]); // Click select all again to deselect all }); @@ -295,8 +222,8 @@ describe("MakeMCPPublicForm", () => { expect(screen.getByText("No MCP servers available.")).toBeInTheDocument(); // Select All checkbox should be disabled - const selectAllCheckbox = screen.getByLabelText("Select All"); - expect(selectAllCheckbox).toBeDisabled(); + const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All" }); + expectDisabledControl(selectAllCheckbox); // Next button should be disabled const nextButton = screen.getByRole("button", { name: "Next" }); @@ -371,7 +298,7 @@ describe("MakeMCPPublicForm", () => { // Select all should be indeterminate now const selectAllCheckbox = checkboxes[0]; - expect(selectAllCheckbox).toHaveAttribute("data-indeterminate", "true"); + expect(selectAllCheckbox).toBePartiallyChecked(); }); it("should display tools overflow text when server has more than 3 tools", () => { @@ -428,7 +355,7 @@ describe("MakeMCPPublicForm", () => { expect(mockProps.onClose).not.toHaveBeenCalled(); }); - it("should show loading state during submit", async () => { + it("should not complete the flow until the submit request resolves", async () => { let resolvePromise: (value: any) => void = () => {}; const pendingPromise = new Promise((resolve) => { resolvePromise = resolve; @@ -453,9 +380,11 @@ describe("MakeMCPPublicForm", () => { fireEvent.click(submitButton); }); - // Check loading state - expect(submitButton).toHaveAttribute("data-loading", "true"); - expect(submitButton).toBeDisabled(); + // While the request is in flight the flow must not have completed + expect(mockMakeMCPPublicCall).toHaveBeenCalledTimes(1); + expect(mockProps.onSuccess).not.toHaveBeenCalled(); + expect(mockProps.onClose).not.toHaveBeenCalled(); + expect(screen.getByText("Confirm Making MCP Servers Public")).toBeInTheDocument(); // Resolve the promise resolvePromise({}); @@ -474,7 +403,7 @@ describe("MakeMCPPublicForm", () => { render(); // Modal should not be rendered - expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); expect(screen.queryByText("Make MCP Servers Public")).not.toBeInTheDocument(); }); @@ -569,6 +498,6 @@ describe("MakeMCPPublicForm", () => { // Select all should be indeterminate const selectAllCheckbox = checkboxes[0]; - expect(selectAllCheckbox).toHaveAttribute("data-indeterminate", "true"); + expect(selectAllCheckbox).toBePartiallyChecked(); }); }); diff --git a/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.tsx b/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.tsx index b590c3cc1dd..7ef42883400 100644 --- a/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.tsx @@ -1,11 +1,25 @@ import React, { useState, useEffect } from "react"; -import { Modal, Form, Steps, Button, Checkbox } from "antd"; -import { Text, Title, Badge } from "@tremor/react"; +import { Loader2 } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { cn } from "@/lib/cva.config"; import { makeMCPPublicCall } from "../../networking"; import NotificationsManager from "../../molecules/notifications_manager"; import { MCPServerData } from "@/components/AIHub/MCPHubTableColumns"; -const { Step } = Steps; +const STEP_TITLES = ["Select Servers", "Confirm"]; + +const statusVariant = (status?: string) => { + if (status === "active" || status === "healthy") { + return "default" as const; + } + if (status === "inactive" || status === "unhealthy") { + return "destructive" as const; + } + return "outline" as const; +}; interface MakeMCPPublicFormProps { visible: boolean; @@ -25,12 +39,10 @@ const MakeMCPPublicForm: React.FC = ({ const [currentStep, setCurrentStep] = useState(0); const [selectedServers, setSelectedServers] = useState>(new Set()); const [loading, setLoading] = useState(false); - const [form] = Form.useForm(); const handleClose = () => { setCurrentStep(0); setSelectedServers(new Set()); - form.resetFields(); onClose(); }; @@ -114,29 +126,30 @@ const MakeMCPPublicForm: React.FC = ({ return (
- Select MCP Servers to Make Public +

Select MCP Servers to Make Public

- handleSelectAll(e.target.checked)} - disabled={mcpHubData.length === 0} - > +
- +

Select the MCP servers you want to be visible on the public model hub. Users will still require a valid Virtual Key to use these servers. - +

{mcpHubData.length === 0 ? (
- No MCP servers available. +

No MCP servers available.

) : ( mcpHubData.map((server) => { @@ -148,42 +161,25 @@ const MakeMCPPublicForm: React.FC = ({ > handleServerSelection(server.server_id, e.target.checked)} + onCheckedChange={(checked) => handleServerSelection(server.server_id, checked === true)} /> -
-
- {server.server_name} - {isPublic && ( - - Public - - )} - - {server.transport} - - - {server.status || "unknown"} - +
+
+

{server.server_name}

+ {isPublic && Public} + {server.transport} + {server.status || "unknown"}
- {server.description || server.url} +

{server.description || server.url}

{server.allowed_tools && server.allowed_tools.length > 0 && (
{server.allowed_tools.slice(0, 3).map((tool, idx) => ( - + {tool} ))} {server.allowed_tools.length > 3 && ( - +{server.allowed_tools.length - 3} more +

+{server.allowed_tools.length - 3} more

)}
)} @@ -197,9 +193,9 @@ const MakeMCPPublicForm: React.FC = ({ {selectedServers.size > 0 && (
- +

{selectedServers.size} MCP server{selectedServers.size !== 1 ? "s" : ""} selected - +

)}
@@ -209,48 +205,37 @@ const MakeMCPPublicForm: React.FC = ({ const renderStep2Content = () => { return (
- Confirm Making MCP Servers Public +

Confirm Making MCP Servers Public

- +

Warning: Once you make these MCP servers public, anyone who can go to the{" "} /ui/model_hub_table will be able to know they exist on the proxy. - +

- MCP Servers to be made public: +

MCP Servers to be made public:

{Array.from(selectedServers).map((serverId) => { const server = mcpHubData.find((s) => s.server_id === serverId); return (
-
-
- {server?.server_name || serverId} +
+
+

{server?.server_name || serverId}

{server && ( <> - - {server.transport} - - - {server.status || "unknown"} - + {server.transport} + {server.status || "unknown"} )}
- {server?.description && {server.description}} - {server?.url && {server.url}} + {server?.description && ( +

{server.description}

+ )} + {server?.url &&

{server.url}

}
); @@ -260,10 +245,10 @@ const MakeMCPPublicForm: React.FC = ({
- +

Total: {selectedServers.size} MCP server{selectedServers.size !== 1 ? "s" : ""} will be made public - +

); @@ -283,7 +268,7 @@ const MakeMCPPublicForm: React.FC = ({ const renderStepButtons = () => { return (
- @@ -295,7 +280,8 @@ const MakeMCPPublicForm: React.FC = ({ )} {currentStep === 1 && ( - )} @@ -305,24 +291,42 @@ const MakeMCPPublicForm: React.FC = ({ }; return ( - -
- - - - + !open && handleClose()} disablePointerDismissal> + + + Make MCP Servers Public + - {renderStepContent()} - {renderStepButtons()} - -
+
+
    + {STEP_TITLES.map((title, index) => ( +
  1. + + {index + 1} + + + {title} + +
  2. + ))} +
+ + {renderStepContent()} + {renderStepButtons()} +
+ + ); }; diff --git a/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.test.tsx b/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.test.tsx index 2b57535f3ad..d7d3b0935dd 100644 --- a/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.test.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.test.tsx @@ -29,67 +29,8 @@ vi.mock("../../networking", () => ({ import { makeModelGroupPublic } from "../../networking"; const mockMakeModelGroupPublic = vi.mocked(makeModelGroupPublic); -// Mock antd components -vi.mock("antd", () => ({ - Modal: ({ open, title, children, onCancel, footer }: any) => - open ? ( -
-
{title}
- {children} - {footer} -
- ) : null, - Form: Object.assign(({ children, form }: any) =>
{children}, { - useForm: () => [ - { - resetFields: vi.fn(), - validateFields: vi.fn(), - getFieldsValue: vi.fn(), - setFieldsValue: vi.fn(), - }, - vi.fn(), - ], - Item: ({ children }: any) =>
{children}
, - }), - Steps: Object.assign( - ({ children, current, className }: any) => ( -
- {children} -
- ), - { - Step: ({ title }: any) =>
{title}
, - }, - ), - Button: ({ children, onClick, disabled, loading, ...props }: any) => ( - - ), - Checkbox: ({ checked, indeterminate, onChange, children, disabled }: any) => ( - - ), -})); - -// Mock @tremor/react components -vi.mock("@tremor/react", () => ({ - Text: ({ children, className }: any) => {children}, - Title: ({ children }: any) =>

{children}

, - Badge: ({ children, color, size }: any) => ( - - {children} - - ), -})); +const expectDisabledControl = (element: HTMLElement) => + expect(element.hasAttribute("disabled") || element.getAttribute("aria-disabled") === "true").toBe(true); // Mock ModelFilters component vi.mock("../../model_filters", () => ({ @@ -190,7 +131,7 @@ describe("MakeModelPublicForm", () => { expect(screen.getByText("Select Models to Make Public")).toBeInTheDocument(); // Select all models using the select all checkbox - const selectAllCheckbox = screen.getByLabelText("Select All (2)"); + const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All (2)" }); await act(async () => { fireEvent.click(selectAllCheckbox); }); @@ -216,7 +157,7 @@ describe("MakeModelPublicForm", () => { render(); // Select all models - const selectAllCheckbox = screen.getByLabelText("Select All (2)"); + const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All (2)" }); await act(async () => { fireEvent.click(selectAllCheckbox); }); @@ -279,6 +220,8 @@ describe("MakeModelPublicForm", () => { const checkboxes = screen.getAllByRole("checkbox"); await act(async () => { fireEvent.click(checkboxes[0]); // Click select all to select all + }); + await act(async () => { fireEvent.click(checkboxes[0]); // Click select all again to deselect all }); @@ -303,8 +246,8 @@ describe("MakeModelPublicForm", () => { expect(screen.getByText("No models match the current filters.")).toBeInTheDocument(); // Select All checkbox should be disabled - const selectAllCheckbox = screen.getByLabelText("Select All"); - expect(selectAllCheckbox).toBeDisabled(); + const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All" }); + expectDisabledControl(selectAllCheckbox); // Next button should be disabled const nextButton = screen.getByRole("button", { name: "Next" }); @@ -379,7 +322,7 @@ describe("MakeModelPublicForm", () => { // Select all should be indeterminate now const selectAllCheckbox = checkboxes[0]; - expect(selectAllCheckbox).toHaveAttribute("data-indeterminate", "true"); + expect(selectAllCheckbox).toBePartiallyChecked(); }); it("should display model badges and information", () => { @@ -428,7 +371,7 @@ describe("MakeModelPublicForm", () => { expect(mockProps.onClose).not.toHaveBeenCalled(); }); - it("should show loading state during submit", async () => { + it("should not complete the flow until the submit request resolves", async () => { let resolvePromise: (value: any) => void = () => {}; const pendingPromise = new Promise((resolve) => { resolvePromise = resolve; @@ -453,9 +396,11 @@ describe("MakeModelPublicForm", () => { fireEvent.click(submitButton); }); - // Check loading state - expect(submitButton).toHaveAttribute("data-loading", "true"); - expect(submitButton).toBeDisabled(); + // While the request is in flight the flow must not have completed + expect(mockMakeModelGroupPublic).toHaveBeenCalledTimes(1); + expect(mockProps.onSuccess).not.toHaveBeenCalled(); + expect(mockProps.onClose).not.toHaveBeenCalled(); + expect(screen.getByText("Confirm Making Models Public")).toBeInTheDocument(); // Resolve the promise resolvePromise({}); @@ -474,7 +419,7 @@ describe("MakeModelPublicForm", () => { render(); // Modal should not be rendered - expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); expect(screen.queryByText("Make Models Public")).not.toBeInTheDocument(); }); @@ -521,15 +466,14 @@ describe("MakeModelPublicForm", () => { // Select all should be indeterminate const selectAllCheckbox = checkboxes[0]; - expect(selectAllCheckbox).toHaveAttribute("data-indeterminate", "true"); + expect(selectAllCheckbox).toBePartiallyChecked(); }); it("should show selected count", () => { render(); // Should show that 1 model is selected (gpt-3.5-turbo is preselected) - expect(screen.getByText("1")).toBeInTheDocument(); - expect(screen.getByText("model selected")).toBeInTheDocument(); + expect(screen.getByText("model selected")).toHaveTextContent("1 model selected"); }); it("should show confirmation step with selected models", async () => { diff --git a/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.tsx b/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.tsx index 2d0ae1a0e2b..28a34ee1f1a 100644 --- a/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.tsx @@ -1,11 +1,15 @@ import React, { useState, useCallback, useEffect } from "react"; -import { Modal, Form, Steps, Button, Checkbox } from "antd"; -import { Text, Title, Badge } from "@tremor/react"; +import { Loader2 } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { cn } from "@/lib/cva.config"; import { makeModelGroupPublic } from "../../networking"; import ModelFilters from "../../model_filters"; import NotificationsManager from "../../molecules/notifications_manager"; -const { Step } = Steps; +const STEP_TITLES = ["Select Models", "Confirm"]; interface ModelGroupInfo { model_group: string; @@ -44,13 +48,11 @@ const MakeModelPublicForm: React.FC = ({ const [selectedModels, setSelectedModels] = useState>(new Set()); const [filteredData, setFilteredData] = useState([]); const [loading, setLoading] = useState(false); - const [form] = Form.useForm(); const handleClose = () => { setCurrentStep(0); setSelectedModels(new Set()); setFilteredData([]); - form.resetFields(); onClose(); }; @@ -138,23 +140,24 @@ const MakeModelPublicForm: React.FC = ({ return (
- Select Models to Make Public +

Select Models to Make Public

- handleSelectAll(e.target.checked)} - disabled={filteredData.length === 0} - > +
- +

Select the models you want to be visible on the public model hub. Users will still require a valid Virtual Key to use these models. - +

{/* Filters */} = ({
{filteredData.length === 0 ? (
- No models match the current filters. +

No models match the current filters.

) : ( filteredData.map((model) => ( @@ -178,20 +181,16 @@ const MakeModelPublicForm: React.FC = ({ > handleModelSelection(model.model_group, e.target.checked)} + onCheckedChange={(checked) => handleModelSelection(model.model_group, checked === true)} /> -
-
- {model.model_group} - {model.mode && ( - - {model.mode} - - )} +
+
+

{model.model_group}

+ {model.mode && {model.mode}}
{model.providers.map((provider) => ( - + {provider} ))} @@ -205,9 +204,9 @@ const MakeModelPublicForm: React.FC = ({ {selectedModels.size > 0 && (
- +

{selectedModels.size} model{selectedModels.size !== 1 ? "s" : ""} selected - +

)}
@@ -217,29 +216,29 @@ const MakeModelPublicForm: React.FC = ({ const renderStep2Content = () => { return (
- Confirm Making Models Public +

Confirm Making Models Public

- +

Warning: Once you make these models public, anyone who can go to the{" "} /ui/model_hub_table will be able to know they exist on the proxy. - +

- Models to be made public: +

Models to be made public:

{Array.from(selectedModels).map((modelGroup) => { const model = modelHubData.find((m) => m.model_group === modelGroup); return (
-
- {modelGroup} +
+

{modelGroup}

{model && (
{model.providers.map((provider) => ( - + {provider} ))} @@ -254,10 +253,10 @@ const MakeModelPublicForm: React.FC = ({
- +

Total: {selectedModels.size} model{selectedModels.size !== 1 ? "s" : ""} will be made public - +

); @@ -277,7 +276,7 @@ const MakeModelPublicForm: React.FC = ({ const renderStepButtons = () => { return (
- @@ -289,7 +288,8 @@ const MakeModelPublicForm: React.FC = ({ )} {currentStep === 1 && ( - )} @@ -299,24 +299,42 @@ const MakeModelPublicForm: React.FC = ({ }; return ( - -
- - - - + !open && handleClose()} disablePointerDismissal> + + + Make Models Public + - {renderStepContent()} - {renderStepButtons()} - -
+
+
    + {STEP_TITLES.map((title, index) => ( +
  1. + + {index + 1} + + + {title} + +
  2. + ))} +
+ + {renderStepContent()} + {renderStepButtons()} +
+ + ); }; From 07492314a84fbbeb121e6717f6400000db7e8470 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 14 Aug 2026 02:23:24 -0700 Subject: [PATCH 21/49] fix(ui): announce the account popover as a dialog, not a menu The panel holds switches and ordinary buttons rather than menu items, so menu semantics promised keyboard behavior it does not provide. --- .../src/components/Navbar/UserDropdown/UserDropdown.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx index 1d92ef246cd..50c44367020 100644 --- a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx @@ -208,7 +208,7 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar collapsed ? "justify-center px-0 py-1" : "gap-2.5 px-2 py-1.5 text-left", )} aria-label={`Account menu — ${userRole ?? "Unknown role"} — signed in as ${userEmail || userId || "unknown"}`} - aria-haspopup="menu" + aria-haspopup="dialog" title={collapsed ? displayName : undefined} /> } @@ -235,7 +235,7 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar type="button" className="flex! max-w-[min(200px,34vw)] items-center gap-2 rounded-md! py-0.5! pl-1! pr-2! transition-colors hover:bg-gray-100!" aria-label={`Account menu — ${userRole ?? "Unknown role"} — signed in as ${userEmail || userId || "unknown"}`} - aria-haspopup="menu" + aria-haspopup="dialog" /> } > From c344b9a0520cfc2f7f8a84611a1e98afdbe7c69c Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 14 Aug 2026 02:25:18 -0700 Subject: [PATCH 22/49] fix(ui): give the request details drawer an accessible name Screen readers announced an unnamed dialog. The visible header is a custom layout, so the title is visually hidden to keep the drawer layout unchanged. --- .../view_logs/LogDetailsDrawer/LogDetailContent.test.tsx | 3 --- .../view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx | 5 ++++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.test.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.test.tsx index 000e6ccd567..01b040b5c17 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.test.tsx @@ -318,8 +318,6 @@ describe("LogDetailContent", () => { render(); expect(screen.getByText("Response Cache")).toBeInTheDocument(); - // Response Cache is the only metric with an info tooltip in this fixture, so an - // unscoped lookup still pins the docs link to that label. const infoIcons = screen.getAllByRole("img", { name: /info/i }); expect(infoIcons).toHaveLength(1); await user.hover(infoIcons[0]); @@ -345,7 +343,6 @@ describe("LogDetailContent", () => { ); expect(screen.getByText("Prompt Cache Read Tokens")).toBeInTheDocument(); - // Prompt Cache Read Tokens is the only metric with an info tooltip in this fixture. const infoIcons = screen.getAllByRole("img", { name: /info/i }); expect(infoIcons).toHaveLength(1); await user.hover(infoIcons[0]); diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx index 40e6e6f2051..83049a12fd2 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from "react"; import { Bot, Check, ChevronLeft, ChevronRight, Copy, Sparkles, Wrench } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { Sheet, SheetContent } from "@/components/ui/sheet"; +import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { LogEntry } from "../columns"; import { AutoRouterIcon, useIsAutoRoutedModelGroup } from "@/components/shared/table_cells"; @@ -310,6 +310,9 @@ export function LogDetailsDrawer({ className="gap-0 overflow-hidden p-0 data-[side=right]:sm:max-w-none" style={{ width: DRAWER_WIDTH }} > + + {logEntry?.request_id ? `Request ${logEntry.request_id} details` : "Request details"} +
{!isSidebarCollapsed ? (
record.user_id ?? record.user_email ?? JSON.stringify(record)} - pagination={false} - size="small" - scroll={{ x: "max-content" }} - locale={emptyText ? { emptyText } : undefined} - /> +
+ + + User Email + User ID + + {roleTooltip ? ( + + {roleColumnTitle} + + + + + ) : ( + roleColumnTitle + )} + + {extraColumns.map((column, columnIndex) => ( + {extraColumnTitle(column)} + ))} + Actions + + + + {members.length === 0 ? ( + + + {emptyText ?? "No data"} + + + ) : ( + members.map((member, memberIndex) => ( + + {member.user_email || "-"} + + {member.user_id === "default_user_id" ? ( + + ) : ( + member.user_id || "-" + )} + + + + {member.role?.toLowerCase() === "admin" || member.role?.toLowerCase() === "org_admin" ? ( + + ) : ( + + )} + {member.role || "-"} + + + {extraColumns.map((column, columnIndex) => ( + {extraColumnCell(column, member, memberIndex)} + ))} + + {canEdit ? ( + + onEdit(member)} + /> + {(!showDeleteForMember || showDeleteForMember(member)) && ( + onDelete(member)} + /> + )} + + ) : null} + + + )) + )} + +
{onAddMember && canEdit && ( - )} - +
); } diff --git a/ui/litellm-dashboard/src/components/common_components/NewBadge.tsx b/ui/litellm-dashboard/src/components/common_components/NewBadge.tsx index fe2c9d7cf93..0184616803e 100644 --- a/ui/litellm-dashboard/src/components/common_components/NewBadge.tsx +++ b/ui/litellm-dashboard/src/components/common_components/NewBadge.tsx @@ -1,4 +1,4 @@ -import { Badge } from "antd"; +import { Badge } from "@/components/ui/badge"; import { useDisableShowNewBadge } from "@/app/(dashboard)/hooks/useDisableShowNewBadge"; export default function NewBadge({ children, dot = false }: { children?: React.ReactNode; dot?: boolean }) { @@ -8,11 +8,14 @@ export default function NewBadge({ children, dot = false }: { children?: React.R return children ? <>{children} : null; } + const badge = dot ? : New; + return children ? ( - + {children} - + {badge} + ) : ( - + badge ); } From fbc56c3b7bb3f911ff913989639fd86fbb1e64c3 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 14 Aug 2026 02:57:55 -0700 Subject: [PATCH 24/49] test(ui): assert the publish button is disabled while submitting The migration closed a double submit hole that antd left open, but the rewritten tests only proved the flow had not completed, so removing the guard would not have failed them. Verified by mutation: dropping disabled={loading} fails exactly this case. --- .../AIHub/forms/MakeAgentPublicForm.test.tsx | 12 ++++-------- .../AIHub/forms/MakeMCPPublicForm.test.tsx | 12 ++++-------- .../AIHub/forms/MakeModelPublicForm.test.tsx | 13 ++++--------- 3 files changed, 12 insertions(+), 25 deletions(-) diff --git a/ui/litellm-dashboard/src/components/AIHub/forms/MakeAgentPublicForm.test.tsx b/ui/litellm-dashboard/src/components/AIHub/forms/MakeAgentPublicForm.test.tsx index d72cfcbc037..a55beaf517f 100644 --- a/ui/litellm-dashboard/src/components/AIHub/forms/MakeAgentPublicForm.test.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/forms/MakeAgentPublicForm.test.tsx @@ -115,7 +115,6 @@ describe("MakeAgentPublicForm", () => { fireEvent.click(selectAllCheckbox); }); - // Navigate to confirm step const nextButton = screen.getByRole("button", { name: "Next" }); await act(async () => { fireEvent.click(nextButton); @@ -126,7 +125,6 @@ describe("MakeAgentPublicForm", () => { expect(screen.getByText("Confirm Making Agents Public")).toBeInTheDocument(); }); - // Submit const submitButton = screen.getByRole("button", { name: "Make Public" }); await act(async () => { fireEvent.click(submitButton); @@ -312,7 +310,6 @@ describe("MakeAgentPublicForm", () => { render(); - // Navigate to confirm step const nextButton = screen.getByRole("button", { name: "Next" }); await act(async () => { fireEvent.click(nextButton); @@ -322,7 +319,6 @@ describe("MakeAgentPublicForm", () => { expect(screen.getByText("Confirm Making Agents Public")).toBeInTheDocument(); }); - // Submit const submitButton = screen.getByRole("button", { name: "Make Public" }); await act(async () => { fireEvent.click(submitButton); @@ -347,7 +343,6 @@ describe("MakeAgentPublicForm", () => { render(); - // Navigate to confirm step const nextButton = screen.getByRole("button", { name: "Next" }); await act(async () => { fireEvent.click(nextButton); @@ -357,19 +352,20 @@ describe("MakeAgentPublicForm", () => { expect(screen.getByText("Confirm Making Agents Public")).toBeInTheDocument(); }); - // Submit const submitButton = screen.getByRole("button", { name: "Make Public" }); await act(async () => { fireEvent.click(submitButton); }); - // While the request is in flight the flow must not have completed + expectDisabledControl(submitButton); + await act(async () => { + fireEvent.click(submitButton); + }); expect(mockMakeAgentsPublicCall).toHaveBeenCalledTimes(1); expect(mockProps.onSuccess).not.toHaveBeenCalled(); expect(mockProps.onClose).not.toHaveBeenCalled(); expect(screen.getByText("Confirm Making Agents Public")).toBeInTheDocument(); - // Resolve the promise resolvePromise({}); await waitFor(() => { expect(mockProps.onSuccess).toHaveBeenCalled(); diff --git a/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.test.tsx b/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.test.tsx index dda6a56146a..ff385b3ed7c 100644 --- a/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.test.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.test.tsx @@ -138,7 +138,6 @@ describe("MakeMCPPublicForm", () => { fireEvent.click(selectAllCheckbox); }); - // Navigate to confirm step const nextButton = screen.getByRole("button", { name: "Next" }); await act(async () => { fireEvent.click(nextButton); @@ -149,7 +148,6 @@ describe("MakeMCPPublicForm", () => { expect(screen.getByText("Confirm Making MCP Servers Public")).toBeInTheDocument(); }); - // Submit const submitButton = screen.getByRole("button", { name: "Make Public" }); await act(async () => { fireEvent.click(submitButton); @@ -329,7 +327,6 @@ describe("MakeMCPPublicForm", () => { render(); - // Navigate to confirm step const nextButton = screen.getByRole("button", { name: "Next" }); await act(async () => { fireEvent.click(nextButton); @@ -339,7 +336,6 @@ describe("MakeMCPPublicForm", () => { expect(screen.getByText("Confirm Making MCP Servers Public")).toBeInTheDocument(); }); - // Submit const submitButton = screen.getByRole("button", { name: "Make Public" }); await act(async () => { fireEvent.click(submitButton); @@ -364,7 +360,6 @@ describe("MakeMCPPublicForm", () => { render(); - // Navigate to confirm step const nextButton = screen.getByRole("button", { name: "Next" }); await act(async () => { fireEvent.click(nextButton); @@ -374,19 +369,20 @@ describe("MakeMCPPublicForm", () => { expect(screen.getByText("Confirm Making MCP Servers Public")).toBeInTheDocument(); }); - // Submit const submitButton = screen.getByRole("button", { name: "Make Public" }); await act(async () => { fireEvent.click(submitButton); }); - // While the request is in flight the flow must not have completed + expectDisabledControl(submitButton); + await act(async () => { + fireEvent.click(submitButton); + }); expect(mockMakeMCPPublicCall).toHaveBeenCalledTimes(1); expect(mockProps.onSuccess).not.toHaveBeenCalled(); expect(mockProps.onClose).not.toHaveBeenCalled(); expect(screen.getByText("Confirm Making MCP Servers Public")).toBeInTheDocument(); - // Resolve the promise resolvePromise({}); await waitFor(() => { expect(mockProps.onSuccess).toHaveBeenCalled(); diff --git a/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.test.tsx b/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.test.tsx index d7d3b0935dd..ac0df137f6a 100644 --- a/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.test.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.test.tsx @@ -162,7 +162,6 @@ describe("MakeModelPublicForm", () => { fireEvent.click(selectAllCheckbox); }); - // Navigate to confirm step const nextButton = screen.getByRole("button", { name: "Next" }); await act(async () => { fireEvent.click(nextButton); @@ -173,7 +172,6 @@ describe("MakeModelPublicForm", () => { expect(screen.getByText("Confirm Making Models Public")).toBeInTheDocument(); }); - // Submit const submitButton = screen.getByRole("button", { name: "Make Public" }); await act(async () => { fireEvent.click(submitButton); @@ -345,7 +343,6 @@ describe("MakeModelPublicForm", () => { render(); - // Navigate to confirm step const nextButton = screen.getByRole("button", { name: "Next" }); await act(async () => { fireEvent.click(nextButton); @@ -355,7 +352,6 @@ describe("MakeModelPublicForm", () => { expect(screen.getByText("Confirm Making Models Public")).toBeInTheDocument(); }); - // Submit const submitButton = screen.getByRole("button", { name: "Make Public" }); await act(async () => { fireEvent.click(submitButton); @@ -380,7 +376,6 @@ describe("MakeModelPublicForm", () => { render(); - // Navigate to confirm step const nextButton = screen.getByRole("button", { name: "Next" }); await act(async () => { fireEvent.click(nextButton); @@ -390,19 +385,20 @@ describe("MakeModelPublicForm", () => { expect(screen.getByText("Confirm Making Models Public")).toBeInTheDocument(); }); - // Submit const submitButton = screen.getByRole("button", { name: "Make Public" }); await act(async () => { fireEvent.click(submitButton); }); - // While the request is in flight the flow must not have completed + expectDisabledControl(submitButton); + await act(async () => { + fireEvent.click(submitButton); + }); expect(mockMakeModelGroupPublic).toHaveBeenCalledTimes(1); expect(mockProps.onSuccess).not.toHaveBeenCalled(); expect(mockProps.onClose).not.toHaveBeenCalled(); expect(screen.getByText("Confirm Making Models Public")).toBeInTheDocument(); - // Resolve the promise resolvePromise({}); await waitFor(() => { expect(mockProps.onSuccess).toHaveBeenCalled(); @@ -479,7 +475,6 @@ describe("MakeModelPublicForm", () => { it("should show confirmation step with selected models", async () => { render(); - // Navigate to confirm step const nextButton = screen.getByRole("button", { name: "Next" }); await act(async () => { fireEvent.click(nextButton); From 617ad8194c0a7642b0c21ad5acb9020ce2d4ac00 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 14 Aug 2026 03:30:31 -0700 Subject: [PATCH 25/49] refactor(ui): migrate key info and permissions views off antd and tremor Replaces Ant Design and Tremor in the key info header and detail view, the agent and vector store permission panels, and the team member permissions table. - antd Popover, Dropdown and Modal become HoverCard, DropdownMenu and Dialog, and Tremor TabGroup becomes Tabs with keepMounted so panel state survives a tab switch the way Tremor's did - the key id copy control moves to the shared CopyButton, which also fixes an icon that rendered at 24px because it inherited the heading font size - antd Checkbox onChange becomes onCheckedChange - every public prop signature is unchanged, since these are shared views - three member permission tests were passing vacuously: they searched for an unchecked box by reading .checked, which is undefined on a Base UI checkbox, so the assertions sat inside an if that never ran. They now scope the checkbox to its own row and assert the toggle, the save and the revert - drops the eslint suppressions these files no longer need --- ui/litellm-dashboard/eslint-suppressions.json | 21 -- .../permissions/AgentPermissions.tsx | 31 +- .../permissions/VectorStorePermissions.tsx | 12 +- .../team/member_permissions.test.tsx | 91 +++-- .../components/team/member_permissions.tsx | 41 ++- .../components/templates/KeyInfoHeader.tsx | 252 +++++++------ .../KeyInfoView.handleKeyUpdate.test.tsx | 13 - .../components/templates/key_info_view.tsx | 338 ++++++++++-------- 8 files changed, 416 insertions(+), 383 deletions(-) diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 5e322598a10..d0a5e44e424 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -2940,11 +2940,6 @@ "count": 1 } }, - "src/components/permissions/AgentPermissions.tsx": { - "no-restricted-imports": { - "count": 2 - } - }, "src/components/permissions/MCPServerPermissions.tsx": { "no-nested-ternary": { "count": 3 @@ -2953,11 +2948,6 @@ "count": 2 } }, - "src/components/permissions/VectorStorePermissions.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/policies/PolicySelector.tsx": { "no-nested-ternary": { "count": 1 @@ -3242,9 +3232,6 @@ "local/filename-pascal-case": { "count": 1 }, - "no-restricted-imports": { - "count": 2 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -3259,11 +3246,6 @@ "count": 1 } }, - "src/components/templates/KeyInfoHeader.tsx": { - "no-restricted-imports": { - "count": 2 - } - }, "src/components/templates/key_edit_view.tsx": { "local/filename-pascal-case": { "count": 1 @@ -3293,9 +3275,6 @@ "no-nested-ternary": { "count": 1 }, - "no-restricted-imports": { - "count": 2 - }, "react-hooks/set-state-in-effect": { "count": 1 } diff --git a/ui/litellm-dashboard/src/components/permissions/AgentPermissions.tsx b/ui/litellm-dashboard/src/components/permissions/AgentPermissions.tsx index 11951b2decb..ee6fbbd89f5 100644 --- a/ui/litellm-dashboard/src/components/permissions/AgentPermissions.tsx +++ b/ui/litellm-dashboard/src/components/permissions/AgentPermissions.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from "react"; -import { Text, Badge } from "@tremor/react"; import { UserGroupIcon } from "@heroicons/react/outline"; -import { Tooltip } from "antd"; +import { Badge } from "@/components/ui/badge"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { getAgentsList } from "../networking"; interface Agent { @@ -58,10 +58,8 @@ export function AgentPermissions({ agents, agentAccessGroups = [], accessToken }
- Agents - - {totalCount} - +

Agents

+ {totalCount}
{totalCount > 0 ? ( @@ -71,14 +69,17 @@ export function AgentPermissions({ agents, agentAccessGroups = [], accessToken }
{item.type === "agent" ? ( - -
- - - {getAgentDisplayName(item.value)} - -
-
+ + + }> + + + {getAgentDisplayName(item.value)} + + + {`Full ID: ${item.value}`} + + ) : (
@@ -96,7 +97,7 @@ export function AgentPermissions({ agents, agentAccessGroups = [], accessToken } ) : (
- No agents or access groups configured +

No agents or access groups configured

)}
diff --git a/ui/litellm-dashboard/src/components/permissions/VectorStorePermissions.tsx b/ui/litellm-dashboard/src/components/permissions/VectorStorePermissions.tsx index 8541d65e11f..6bf79d8a632 100644 --- a/ui/litellm-dashboard/src/components/permissions/VectorStorePermissions.tsx +++ b/ui/litellm-dashboard/src/components/permissions/VectorStorePermissions.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from "react"; -import { Text, Badge } from "@tremor/react"; import { DatabaseIcon } from "@heroicons/react/outline"; +import { Badge } from "@/components/ui/badge"; import { vectorStoreListCall } from "../networking"; interface VectorStoreDetails { @@ -52,10 +52,8 @@ export function VectorStorePermissions({ vectorStores, accessToken }: VectorStor
- Vector Stores - - {vectorStores.length} - +

Vector Stores

+ {vectorStores.length}
{vectorStores.length > 0 ? ( @@ -63,7 +61,7 @@ export function VectorStorePermissions({ vectorStores, accessToken }: VectorStor {vectorStores.map((store, index) => (
{getVectorStoreDisplayName(store)}
@@ -72,7 +70,7 @@ export function VectorStorePermissions({ vectorStores, accessToken }: VectorStor ) : (
- No vector stores configured +

No vector stores configured

)}
diff --git a/ui/litellm-dashboard/src/components/team/member_permissions.test.tsx b/ui/litellm-dashboard/src/components/team/member_permissions.test.tsx index 10c78331c68..652d4f8e685 100644 --- a/ui/litellm-dashboard/src/components/team/member_permissions.test.tsx +++ b/ui/litellm-dashboard/src/components/team/member_permissions.test.tsx @@ -1,5 +1,5 @@ import * as networking from "@/components/networking"; -import { act, fireEvent, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, screen, waitFor, within } from "@testing-library/react"; import { renderWithProviders } from "../../../tests/test-utils"; import { afterEach, describe, expect, it, vi } from "vitest"; import MemberPermissions from "./member_permissions"; @@ -9,6 +9,9 @@ vi.mock("@/components/networking", () => ({ teamPermissionsUpdateCall: vi.fn(), })); +const checkboxFor = (endpoint: string) => + within(screen.getByText(endpoint).closest("tr") as HTMLElement).getByRole("checkbox"); + describe("MemberPermissions", () => { afterEach(() => { vi.clearAllMocks(); @@ -69,32 +72,27 @@ describe("MemberPermissions", () => { expect(screen.getByText("Member Permissions")).toBeInTheDocument(); }); - const checkboxes = screen.getAllByRole("checkbox"); - const unselectedCheckbox = checkboxes.find((cb) => !(cb as HTMLInputElement).checked); + expect(checkboxFor("/key/generate")).toBeChecked(); + expect(checkboxFor("/key/list")).not.toBeChecked(); - if (unselectedCheckbox) { - await act(async () => { - fireEvent.click(unselectedCheckbox); - }); + await act(async () => { + fireEvent.click(checkboxFor("/key/list")); + }); - await waitFor(() => { - const saveButton = screen.getByRole("button", { name: /save changes/i }); - expect(saveButton).toBeInTheDocument(); - }); + expect(checkboxFor("/key/list")).toBeChecked(); - const saveButton = screen.getByRole("button", { name: /save changes/i }); - await act(async () => { - fireEvent.click(saveButton); - }); + const saveButton = await screen.findByRole("button", { name: /save changes/i }); + await act(async () => { + fireEvent.click(saveButton); + }); - await waitFor(() => { - expect(networking.teamPermissionsUpdateCall).toHaveBeenCalledWith( - "token-123", - "team-123", - expect.arrayContaining(["/key/generate", "/key/list"]), - ); - }); - } + await waitFor(() => { + expect(networking.teamPermissionsUpdateCall).toHaveBeenCalledWith( + "token-123", + "team-123", + expect.arrayContaining(["/key/generate", "/key/list"]), + ); + }); }); it("should render team daily activity permission with correct method and description", async () => { @@ -123,11 +121,13 @@ describe("MemberPermissions", () => { expect(screen.getByText("Member Permissions")).toBeInTheDocument(); }); - const checkboxes = screen.getAllByRole("checkbox"); - checkboxes.forEach((checkbox) => { - expect(checkbox).toBeDisabled(); + expect(checkboxFor("/key/list")).not.toBeChecked(); + + await act(async () => { + fireEvent.click(checkboxFor("/key/list")); }); + expect(checkboxFor("/key/list")).not.toBeChecked(); expect(screen.queryByRole("button", { name: /save changes/i })).not.toBeInTheDocument(); }); @@ -143,32 +143,27 @@ describe("MemberPermissions", () => { expect(screen.getByText("Member Permissions")).toBeInTheDocument(); }); - const checkboxes = screen.getAllByRole("checkbox"); - const unselectedCheckbox = checkboxes.find((cb) => !(cb as HTMLInputElement).checked); + await act(async () => { + fireEvent.click(checkboxFor("/key/list")); + }); - if (unselectedCheckbox) { - await act(async () => { - fireEvent.click(unselectedCheckbox); - }); + expect(checkboxFor("/key/list")).toBeChecked(); - await waitFor(() => { - const resetButton = screen.getByRole("button", { name: /reset/i }); - expect(resetButton).toBeInTheDocument(); - }); + vi.mocked(networking.getTeamPermissionsCall).mockResolvedValueOnce({ + all_available_permissions: ["/key/generate", "/key/list"], + team_member_permissions: ["/key/generate"], + }); - vi.mocked(networking.getTeamPermissionsCall).mockResolvedValueOnce({ - all_available_permissions: ["/key/generate", "/key/list"], - team_member_permissions: ["/key/generate"], - }); + const resetButton = await screen.findByRole("button", { name: /reset/i }); + await act(async () => { + fireEvent.click(resetButton); + }); - const resetButton = screen.getByRole("button", { name: /reset/i }); - await act(async () => { - fireEvent.click(resetButton); - }); + await waitFor(() => { + expect(networking.getTeamPermissionsCall).toHaveBeenCalledTimes(2); + }); - await waitFor(() => { - expect(networking.getTeamPermissionsCall).toHaveBeenCalledTimes(2); - }); - } + expect(checkboxFor("/key/list")).not.toBeChecked(); + expect(screen.queryByRole("button", { name: /save changes/i })).not.toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/components/team/member_permissions.tsx b/ui/litellm-dashboard/src/components/team/member_permissions.tsx index 5bbd82f4a5d..62c7d1f96da 100644 --- a/ui/litellm-dashboard/src/components/team/member_permissions.tsx +++ b/ui/litellm-dashboard/src/components/team/member_permissions.tsx @@ -1,7 +1,9 @@ import { getTeamPermissionsCall, teamPermissionsUpdateCall } from "@/components/networking"; -import { ReloadOutlined, SaveOutlined } from "@ant-design/icons"; -import { Card, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Text, Title } from "@tremor/react"; -import { Button, Checkbox, Empty } from "antd"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { RotateCw, Save } from "lucide-react"; import React, { useEffect, useState } from "react"; import NotificationsManager from "../molecules/notifications_manager"; import { getPermissionInfo } from "./permission_definitions"; @@ -75,36 +77,38 @@ const MemberPermissions: React.FC = ({ teamId, accessTok const hasPermissions = permissions.length > 0; return ( - +
- Member Permissions +

Member Permissions

{canEditTeam && hasChanges && (
- -
)}
- Control what team members can do when they are not team admins. +

Control what team members can do when they are not team admins.

{hasPermissions ? (
- - +
+ - Method - Endpoint - Description - + Method + Endpoint + Description + Allow Access - + - + {permissions.map((permission) => { const permInfo = getPermissionInfo(permission); @@ -125,8 +129,9 @@ const MemberPermissions: React.FC = ({ teamId, accessTok {permInfo.description} handlePermissionChange(permission, e.target.checked)} + onCheckedChange={(checked) => handlePermissionChange(permission, checked)} disabled={!canEditTeam} /> @@ -138,7 +143,7 @@ const MemberPermissions: React.FC = ({ teamId, accessTok ) : (
- +

No permissions available

)} diff --git a/ui/litellm-dashboard/src/components/templates/KeyInfoHeader.tsx b/ui/litellm-dashboard/src/components/templates/KeyInfoHeader.tsx index d0dd782a697..f31a265da87 100644 --- a/ui/litellm-dashboard/src/components/templates/KeyInfoHeader.tsx +++ b/ui/litellm-dashboard/src/components/templates/KeyInfoHeader.tsx @@ -1,27 +1,35 @@ import React from "react"; -import { Button, Typography, Tooltip, Space, Divider, Flex, Popover, Dropdown, Tag } from "antd"; -import type { MenuProps } from "antd"; import { - ArrowLeftOutlined, - SyncOutlined, - DeleteOutlined, - PlusOutlined, - UserOutlined, - CalendarOutlined, - ClockCircleOutlined, - ThunderboltOutlined, - SafetyCertificateOutlined, - TransactionOutlined, - FieldTimeOutlined, - MoreOutlined, - StopOutlined, - CheckCircleOutlined, -} from "@ant-design/icons"; + ArrowLeft, + ArrowLeftRight, + Ban, + Calendar, + CircleCheck, + Clock, + MoreVertical, + Plus, + RefreshCw, + ShieldCheck, + Timer, + Trash2, + User, + Zap, +} from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"; +import { Separator } from "@/components/ui/separator"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import CopyButton from "@/components/shared/CopyButton"; import LabeledField from "../common_components/LabeledField"; import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag"; -const { Title, Text } = Typography; - export interface KeyInfoData { keyName: string; keyId: string; @@ -52,14 +60,12 @@ interface KeyInfoHeaderProps { function UserField({ userAlias, userEmail, userId }: { userAlias?: string | null; userEmail: string; userId: string }) { const labelEl = ( - - - - - - User - - +
+ + + + User +
); const isEmpty = !userAlias && !userEmail && !userId; @@ -68,7 +74,7 @@ function UserField({ userAlias, userEmail, userId }: { userAlias?: string | null
{labelEl}
- - + -
); @@ -87,14 +93,12 @@ function UserField({ userAlias, userEmail, userId }: { userAlias?: string | null
{label} {value ? ( - - {value} - +
+ + {value} + + +
) : ( - )} @@ -108,11 +112,18 @@ function UserField({ userAlias, userEmail, userId }: { userAlias?: string | null
{labelEl}
- - - - - + + + + + } + /> + + {popoverContent} + +
); @@ -122,11 +133,14 @@ function UserField({ userAlias, userEmail, userId }: { userAlias?: string | null
{labelEl}
- - - {displayValue} - - + + {displayValue}} + /> + + {popoverContent} + +
); @@ -146,104 +160,124 @@ export function KeyInfoHeader({ regenerateDisabled = false, regenerateTooltip, }: KeyInfoHeaderProps) { - const destructiveActionItems: MenuProps["items"] = [ - ...(onToggleBlocked - ? [ - isBlocked - ? { key: "unblock", label: "Unblock Key", icon: } - : { key: "block", label: "Block Key", icon: , danger: true }, - ] - : []), - ...(onResetSpend - ? [{ key: "reset-spend", label: "Reset Spend", icon: , danger: true }] - : []), - { key: "delete", label: "Delete Key", icon: , danger: true }, - ]; - - const handleDestructiveActionClick: MenuProps["onClick"] = ({ key }) => { - if (key === "block" || key === "unblock") onToggleBlocked?.(); - if (key === "reset-spend") onResetSpend?.(); - if (key === "delete") onDelete?.(); - }; + const regenerateButton = ( + + + + ); return (
{onCreateNew && (
-
)}
-
- -
- - + <div className="flex items-start justify-between" style={{ marginBottom: 20 }}> + <div className="min-w-0"> + <div className="flex items-center gap-2"> + <h3 className="m-0 flex items-center gap-1 text-2xl font-semibold"> {data.keyName} - + + {isBlocked && ( - }> + + Blocked - + )} - - - Key ID: {data.keyId} - +
+
+ Key ID: {data.keyId} + +
{canModifyKey && ( - - - - - - - -
- - +
+
- } /> - + } /> +
- + - - } /> +
+ } /> } + icon={} truncate copyable defaultUserIdCheck /> - +
- + - - } /> - } /> - - +
+ } /> + } /> +
+
); } diff --git a/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx b/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx index 374e36029a0..42d1884e563 100644 --- a/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx @@ -182,19 +182,6 @@ vi.mock("@heroicons/react/outline", async () => { return { ArrowLeftIcon, TrashIcon, RefreshIcon }; }); -vi.mock("lucide-react", async () => { - const React = await import("react"); - function CopyIcon() { - return React.createElement("span"); - } - (CopyIcon as any).displayName = "CopyIcon"; - function CheckIcon() { - return React.createElement("span"); - } - (CheckIcon as any).displayName = "CheckIcon"; - return { CopyIcon, CheckIcon }; -}); - // Heavy children -> async factories & local React vi.mock("../organisms/RegenerateKeyModal", () => { function RegenerateKeyModal() { diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx index 15d14d5abf5..a2d926dff8a 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx @@ -4,9 +4,12 @@ import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings" import useTeams from "@/app/(dashboard)/hooks/useTeams"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { mapEmptyStringToNull } from "@/utils/keyUpdateUtils"; -import { ArrowLeftIcon } from "@heroicons/react/outline"; -import { Badge, Button, Card, Grid, Tab, TabGroup, TabList, TabPanel, TabPanels, Text, Title } from "@tremor/react"; -import { Modal, Tag } from "antd"; +import { ArrowLeft } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { KeyInfoHeader } from "./KeyInfoHeader"; import { useEffect, useState } from "react"; import { isProxyAdminRole, isUserTeamAdminForSingleTeam, rolesWithWriteAccess } from "../../utils/roles"; @@ -150,10 +153,11 @@ export default function KeyInfoView({ if (!currentKeyData) { return (
- - Key not found +

Key not found

); } @@ -534,93 +538,111 @@ export default function KeyInfoView({ /> {/* Reset Spend Confirmation Modal */} - setIsResetSpendModalOpen(false)} - okText="Reset" - okButtonProps={{ danger: true }} - confirmLoading={resetSpendLoading} - > -

- Reset spend for {currentKeyData?.key_alias || currentKeyData?.token_id || "this key"} to{" "} - $0? -

-

- Current spend: ${formatNumberWithCommas(currentKeyData.spend, 4)}. Spend history is preserved - in logs. This resets the current period spend counter, the same as an automatic budget reset. -

-
+ setIsResetSpendModalOpen(open)}> + + + Reset Key Spend + +

+ Reset spend for {currentKeyData?.key_alias || currentKeyData?.token_id || "this key"} to{" "} + $0? +

+

+ Current spend: ${formatNumberWithCommas(currentKeyData.spend, 4)}. Spend history is + preserved in logs. This resets the current period spend counter, the same as an automatic budget reset. +

+ + + + +
+
- setIsBlockModalOpen(false)} - okText={isBlocked ? "Unblock" : "Block"} - okButtonProps={isBlocked ? undefined : { danger: true }} - confirmLoading={blockLoading} - > -

- {isBlocked ? "Unblock" : "Block"}{" "} - {currentKeyData?.key_alias || currentKeyData?.token_id || "this key"}? -

-

- {isBlocked - ? "Requests using this key will be accepted again." - : "Requests using this key will be rejected with a 401 error until it is unblocked. The key is not deleted and can be unblocked at any time."} -

-
+ setIsBlockModalOpen(open)}> + + + {isBlocked ? "Unblock Key" : "Block Key"} + +

+ {isBlocked ? "Unblock" : "Block"}{" "} + {currentKeyData?.key_alias || currentKeyData?.token_id || "this key"}? +

+

+ {isBlocked + ? "Requests using this key will be accepted again." + : "Requests using this key will be rejected with a 401 error until it is unblocked. The key is not deleted and can be unblocked at any time."} +

+ + + + +
+
- - - Overview - Settings - + + + Overview + Settings + - +
{/* Overview Panel */} - - - - Spend + +
+ +

Spend

- ${formatNumberWithCommas(currentKeyData.spend, 4)} - of {budgetDisplay} +

${formatNumberWithCommas(currentKeyData.spend, 4)}

+

of {budgetDisplay}

{currentKeyData.budget_reset_at && ( - Resets {formatTimestamp(currentKeyData.budget_reset_at)} +

Resets {formatTimestamp(currentKeyData.budget_reset_at)}

)}
- - Rate Limits + +

Rate Limits

- TPM: {currentKeyData.tpm_limit !== null ? currentKeyData.tpm_limit : "Unlimited"} - RPM: {currentKeyData.rpm_limit !== null ? currentKeyData.rpm_limit : "Unlimited"} +

+ TPM: {currentKeyData.tpm_limit !== null ? currentKeyData.tpm_limit : "Unlimited"} +

+

+ RPM: {currentKeyData.rpm_limit !== null ? currentKeyData.rpm_limit : "Unlimited"} +

{Boolean(currentKeyData.metadata?.throttle_on_budget_exceeded) && ( - Throttle on budget exceeded: Yes +

Throttle on budget exceeded: Yes

)}
- - Models + +

Models

{currentKeyData.models && currentKeyData.models.length > 0 ? ( currentKeyData.models.map((model, index) => ( - + {model} )) ) : ( - No models specified +

No models specified

)}
- + - - Guardrails + +

Guardrails

{Array.isArray(currentKeyData.metadata?.guardrails) && currentKeyData.metadata.guardrails.length > 0 ? (
{currentKeyData.metadata.guardrails.map((guardrail: string, index: number) => ( - + {guardrail} ))}
) : ( - No guardrails configured +

No guardrails configured

)} {typeof currentKeyData.metadata?.disable_global_guardrails === "boolean" && currentKeyData.metadata.disable_global_guardrails === true && (
- Global Guardrails Disabled + Global Guardrails Disabled
)}
- - Policies + +

Policies

{Array.isArray(currentKeyData.metadata?.policies) && currentKeyData.metadata.policies.length > 0 ? (
{currentKeyData.metadata.policies.map((policy: string, index: number) => (
- {policy} - {loadingPolicies && Loading guardrails...} + + {policy} + + {loadingPolicies &&

Loading guardrails...

}
{!loadingPolicies && policyGuardrails[policy] && policyGuardrails[policy].length > 0 && (
- Resolved Guardrails: +

Resolved Guardrails:

{policyGuardrails[policy].map((guardrail: string, gIndex: number) => ( - + {guardrail} ))} @@ -675,7 +699,7 @@ export default function KeyInfoView({ ))}
) : ( - No policies configured +

No policies configured

)} @@ -697,15 +721,19 @@ export default function KeyInfoView({ nextRotationAt={currentKeyData.next_rotation_at} variant="card" /> - - +
+ {/* Settings Panel */} - - + +
- Key Settings - {!isEditing && canModifyKey && } +

Key Settings

+ {!isEditing && canModifyKey && ( + + )}
{isEditing ? ( @@ -722,29 +750,29 @@ export default function KeyInfoView({ ) : (
- Key ID - {currentKeyData.token_id || currentKeyData.token} +

Key ID

+

{currentKeyData.token_id || currentKeyData.token}

- Key Alias - {currentKeyData.key_alias || "Not Set"} +

Key Alias

+

{currentKeyData.key_alias || "Not Set"}

- Secret Key - {currentKeyData.key_name} +

Secret Key

+

{currentKeyData.key_name}

- Team ID - {currentKeyData.team_id || "Not Set"} +

Team ID

+

{currentKeyData.team_id || "Not Set"}

{enableProjectsUI && (
- Project - +

Project

+

{currentKeyData.project_id ? (() => { const project = projects?.find((p) => p.project_id === currentKeyData.project_id); @@ -753,41 +781,43 @@ export default function KeyInfoView({ : currentKeyData.project_id; })() : "Not Set"} - +

)}
- Organization - {(currentKeyData.organization_id ?? currentKeyData.org_id) || "Not Set"} +

Organization

+

{(currentKeyData.organization_id ?? currentKeyData.org_id) || "Not Set"}

- Created - {formatTimestamp(currentKeyData.created_at)} +

Created

+

{formatTimestamp(currentKeyData.created_at)}

{lastRegeneratedAt && (
- Last Regenerated +

Last Regenerated

- {formatTimestamp(lastRegeneratedAt)} - - Recent - +

{formatTimestamp(lastRegeneratedAt)}

+ Recent
)}
- Expires - {currentKeyData.expires ? formatTimestamp(currentKeyData.expires) : "Never"} +

Expires

+

+ {currentKeyData.expires ? formatTimestamp(currentKeyData.expires) : "Never"} +

{Boolean(currentKeyData.metadata?.enable_prompt_caching) && (
- Prompt Caching - Enabled (auto-injects cache_control markers on Anthropic and Bedrock Claude requests) +

Prompt Caching

+

+ Enabled (auto-injects cache_control markers on Anthropic and Bedrock Claude requests) +

)} @@ -802,31 +832,31 @@ export default function KeyInfoView({ />
- Spend - ${formatNumberWithCommas(currentKeyData.spend, 4)} USD +

Spend

+

${formatNumberWithCommas(currentKeyData.spend, 4)} USD

- Budget - +

Budget

+

{currentKeyData.max_budget !== null ? `$${formatNumberWithCommas(currentKeyData.max_budget, 2)}` : "Unlimited"} - +

- Budget Reset - +

Budget Reset

+

{currentKeyData.budget_reset_at ? `${currentKeyData.budget_duration ? `Every ${currentKeyData.budget_duration}, next ` : ""}${formatTimestamp(currentKeyData.budget_reset_at)}` : "Never"} - +

{currentKeyData.budget_fallbacks && Object.keys(currentKeyData.budget_fallbacks).length > 0 && (
- Budget Fallbacks +

Budget Fallbacks

{Object.entries(currentKeyData.budget_fallbacks).map(([model, fallbacks]) => (
@@ -841,7 +871,7 @@ export default function KeyInfoView({ {hasRouterSettings(currentKeyData.router_settings) && (
- Router Settings +

Router Settings

@@ -849,7 +879,7 @@ export default function KeyInfoView({ )}
- Tags +

Tags

{Array.isArray(currentKeyData.metadata?.tags) && currentKeyData.metadata.tags.length > 0 ? currentKeyData.metadata.tags.map((tag, index) => ( @@ -862,8 +892,8 @@ export default function KeyInfoView({
- Prompts - +

Prompts

+

{Array.isArray(currentKeyData.metadata?.prompts) && currentKeyData.metadata.prompts.length > 0 ? currentKeyData.metadata.prompts.map((prompt, index) => ( @@ -871,11 +901,11 @@ export default function KeyInfoView({ )) : "No prompts specified"} - +

- Allowed Routes +

Allowed Routes

{Array.isArray(currentKeyData.allowed_routes) && currentKeyData.allowed_routes.length > 0 ? ( currentKeyData.allowed_routes.map((route, index) => ( @@ -884,14 +914,14 @@ export default function KeyInfoView({ )) ) : ( - All routes allowed + All routes allowed )}
- Allowed Pass Through Routes - +

Allowed Pass Through Routes

+

{Array.isArray(currentKeyData.metadata?.allowed_passthrough_routes) && currentKeyData.metadata.allowed_passthrough_routes.length > 0 ? currentKeyData.metadata.allowed_passthrough_routes.map((route, index) => ( @@ -900,22 +930,22 @@ export default function KeyInfoView({ )) : "No pass through routes specified"} - +

- Disable Global Guardrails - +

Disable Global Guardrails

+

{currentKeyData.metadata?.disable_global_guardrails === true ? ( - Enabled - Global guardrails bypassed + Enabled - Global guardrails bypassed ) : ( - Disabled - Global guardrails active + Disabled - Global guardrails active )} - +

- Models +

Models

{currentKeyData.models && currentKeyData.models.length > 0 ? ( currentKeyData.models.map((model, index) => ( @@ -924,56 +954,60 @@ export default function KeyInfoView({ )) ) : ( - No models specified +

No models specified

)}
- Rate Limits - TPM: {currentKeyData.tpm_limit !== null ? currentKeyData.tpm_limit : "Unlimited"} - RPM: {currentKeyData.rpm_limit !== null ? currentKeyData.rpm_limit : "Unlimited"} - +

Rate Limits

+

+ TPM: {currentKeyData.tpm_limit !== null ? currentKeyData.tpm_limit : "Unlimited"} +

+

+ RPM: {currentKeyData.rpm_limit !== null ? currentKeyData.rpm_limit : "Unlimited"} +

+

Max Parallel Requests:{" "} {currentKeyData.max_parallel_requests !== null ? currentKeyData.max_parallel_requests : "Unlimited"} - - +

+

Model TPM Limits:{" "} {currentKeyData.metadata?.model_tpm_limit ? JSON.stringify(currentKeyData.metadata.model_tpm_limit) : "Unlimited"} - - +

+

Model RPM Limits:{" "} {currentKeyData.metadata?.model_rpm_limit ? JSON.stringify(currentKeyData.metadata.model_rpm_limit) : "Unlimited"} - - +

+

Tag RPM Limits:{" "} {currentKeyData.metadata?.tag_rpm_limit && Object.keys(currentKeyData.metadata.tag_rpm_limit).length > 0 ? JSON.stringify(currentKeyData.metadata.tag_rpm_limit) : "Unlimited"} - - +

+

Estimated Output Tokens:{" "} {currentKeyData.metadata?.default_estimated_output_tokens != null ? String(currentKeyData.metadata.default_estimated_output_tokens) : "Default"} - - +

+

Estimated Output Tokens Per Model:{" "} {currentKeyData.metadata?.default_estimated_output_tokens_per_model ? JSON.stringify(currentKeyData.metadata.default_estimated_output_tokens_per_model) : "Default"} - +

- Metadata +

Metadata

                       {formatMetadataForDisplay(stripTagsFromMetadata(currentKeyData.metadata))}
                     
@@ -999,9 +1033,9 @@ export default function KeyInfoView({
)} - - - + +
+
); } From 3465ba4914858ab16f032c8d619ef21cb532bcdd Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 14 Aug 2026 03:52:41 -0700 Subject: [PATCH 26/49] refactor(ui): migrate router settings and shared badges off antd and tremor Replaces Ant Design and Tremor in the fallbacks views, the router general settings panel, and the two shared banner and badge components. - Tremor Card, Table and Icon become the ui/card, ui/table and lucide equivalents, reproducing Tremor's icon box so click targets keep their size - antd Alert becomes a composed role="alert" region, since the shadcn CLI's alert pulls in class-variance-authority, which this repo does not have - antd InputNumber becomes a native number input, and Switch onChange becomes onCheckedChange - shadcn TableCell ships whitespace-nowrap where Tremor's did not, so cells holding model names and setting descriptions get whitespace-normal back - adds a DeprecationBanner test covering naming, the link, and dismissal, proven against the antd version first and mutation checked - drops the eslint suppressions these files no longer need --- ui/litellm-dashboard/eslint-suppressions.json | 21 -- .../_components/general_settings.test.tsx | 6 +- .../_components/general_settings.tsx | 246 ++++++++++-------- .../src/components/BetaBadge.tsx | 11 +- .../src/components/DeprecationBanner.test.tsx | 44 ++++ .../src/components/DeprecationBanner.tsx | 61 +++-- .../Fallbacks/EditFallbacks.tsx | 15 +- .../RouterSettings/Fallbacks/Fallbacks.tsx | 115 ++++---- 8 files changed, 306 insertions(+), 213 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/DeprecationBanner.test.tsx diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 5e322598a10..927d0d2b08f 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1405,9 +1405,6 @@ "no-nested-ternary": { "count": 1 }, - "no-restricted-imports": { - "count": 2 - }, "prefer-const": { "count": 2 } @@ -1761,11 +1758,6 @@ "count": 1 } }, - "src/components/BetaBadge.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/CloudZeroCostTracking/CloudZeroCreateModal.tsx": { "no-restricted-imports": { "count": 1 @@ -1789,11 +1781,6 @@ "count": 1 } }, - "src/components/DeprecationBanner.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/EntityUsageExport/ExportSummary.tsx": { "no-restricted-imports": { "count": 1 @@ -1995,11 +1982,6 @@ "count": 1 } }, - "src/components/Settings/RouterSettings/Fallbacks/EditFallbacks.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/Settings/RouterSettings/Fallbacks/FallbackGroupConfig.tsx": { "local/no-complex-jsx-arrow": { "count": 1 @@ -2017,9 +1999,6 @@ } }, "src/components/Settings/RouterSettings/Fallbacks/Fallbacks.tsx": { - "no-restricted-imports": { - "count": 2 - }, "prefer-const": { "count": 2 } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.test.tsx index 9ffcbfc9975..0f3ba6c3471 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.test.tsx @@ -62,6 +62,8 @@ const settingsRow = async (fieldName: string) => { return row as HTMLElement; }; +const numericValueIn = (row: HTMLElement) => Number((within(row).getByRole("spinbutton") as HTMLInputElement).value); + describe("GeneralSettings General tab", () => { beforeEach(() => { vi.mocked(getGeneralSettingsCall).mockResolvedValue([...SETTINGS_FIXTURE.map((s) => ({ ...s }))]); @@ -87,7 +89,7 @@ describe("GeneralSettings General tab", () => { await user.click(screen.getByText("General")); const row = await settingsRow("max_ui_session_budget"); - expect(within(row).getByRole("spinbutton")).toHaveValue("7.50"); + expect(numericValueIn(row)).toBe(7.5); const actionCell = row.querySelectorAll("td")[3]; const resetIcon = actionCell.querySelector("svg"); @@ -95,7 +97,7 @@ describe("GeneralSettings General tab", () => { await user.click(resetIcon as unknown as Element); expect(deleteConfigFieldSetting).toHaveBeenCalledWith("token", "max_ui_session_budget"); - expect(within(row).getByRole("spinbutton")).toHaveValue("1.00"); + expect(numericValueIn(row)).toBe(1); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx index ed7b17067d5..2a5b94b2fd7 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx @@ -1,22 +1,14 @@ import React, { useState, useEffect } from "react"; -import { - Card, - Table, - TableHead, - TableRow, - TableHeaderCell, - TableCell, - TableBody, - Title, - Text, - Button, - Icon, - Switch, -} from "@tremor/react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { getGeneralSettingsCall, updateConfigFieldSetting, deleteConfigFieldSetting } from "@/components/networking"; -import { InputNumber, Select as AntdSelect } from "antd"; -import { TrashIcon } from "@heroicons/react/outline"; +import { Trash2 } from "lucide-react"; import { StatusBadge } from "@/components/shared/table_cells"; import RouterSettings from "@/components/router_settings"; @@ -44,16 +36,22 @@ export interface generalSettingsItem { field_default_value?: any; } +const NUMERIC_INPUT_WIDTH = "w-36"; + +const toNumericValue = (raw: string): number | null => (raw === "" ? null : Number(raw)); + const SettingValueEditor: React.FC<{ setting: generalSettingsItem; onChange: (fieldName: string, newValue: any) => void; }> = ({ setting, onChange }) => { if (setting.field_type === "Integer") { return ( - onChange(setting.field_name, newValue)} + className={NUMERIC_INPUT_WIDTH} + value={setting.field_value ?? ""} + onChange={(event) => onChange(setting.field_name, toNumericValue(event.target.value))} /> ); } @@ -61,42 +59,55 @@ const SettingValueEditor: React.FC<{ return ( onChange(setting.field_name, checked)} + onCheckedChange={(checked) => onChange(setting.field_name, checked)} /> ); } if (setting.field_type === "Float") { return ( - onChange(setting.field_name, newValue)} + className={NUMERIC_INPUT_WIDTH} + value={setting.field_value ?? ""} + onChange={(event) => onChange(setting.field_name, toNumericValue(event.target.value))} /> ); } if (setting.field_type === "Dollar") { return ( - onChange(setting.field_name, newValue)} - /> + + $ + onChange(setting.field_name, toNumericValue(event.target.value))} + /> + ); } if (setting.field_type === "Select") { return ( - ({ label: option, value: option }))} - onChange={(newValue) => onChange(setting.field_name, newValue ?? "")} - /> + ); } return null; @@ -131,33 +142,43 @@ export const PromptCachingPanel: React.FC<{ return ( - Prompt Caching + + Prompt Caching -
-
- Automatic Anthropic prompt caching -

{enableSetting.field_description}

-
- persist(ENABLE_ANTHROPIC_PROMPT_CACHING, checked)} /> -
- - {ttlSetting && (
-
- Cache lifetime (TTL) -

{ttlSetting.field_description}

+
+

Automatic Anthropic prompt caching

+

{enableSetting.field_description}

- ({ label: option, value: option }))} - onChange={(newValue) => persist(ANTHROPIC_PROMPT_CACHING_TTL, newValue ?? "")} - /> + persist(ENABLE_ANTHROPIC_PROMPT_CACHING, checked)} />
- )} + + {ttlSetting && ( +
+
+

Cache lifetime (TTL)

+

{ttlSetting.field_description}

+
+ +
+ )} + ); }; @@ -254,55 +275,60 @@ const GeneralSettings: React.FC = ({ accessToken, user -
- - - Setting - Value - Status - Action - - - - {generalSettings - .filter((value) => value.field_type !== "TypedDictionary" && value.field_tab !== PROMPT_CACHING_TAB) - .map((value, index) => ( - - - {value.field_name} -

- {value.field_description} -

-
- - - - - {value.stored_in_db == true ? ( - - ) : value.stored_in_db == false ? ( - - ) : ( - - )} - - - - handleResetField(value.field_name)}> - Reset - - -
- ))} -
-
+ + + + + Setting + Value + Status + Action + + + + {generalSettings + .filter((value) => value.field_type !== "TypedDictionary" && value.field_tab !== PROMPT_CACHING_TAB) + .map((value, index) => ( + + +

{value.field_name}

+

+ {value.field_description} +

+
+ + + + + {value.stored_in_db == true ? ( + + ) : value.stored_in_db == false ? ( + + ) : ( + + )} + + + + handleResetField(value.field_name)} + className="inline-flex shrink-0 cursor-pointer items-center justify-center px-1.5 py-1.5 text-red-500" + > + + + +
+ ))} +
+
+
diff --git a/ui/litellm-dashboard/src/components/BetaBadge.tsx b/ui/litellm-dashboard/src/components/BetaBadge.tsx index 7c4ef04417e..4e2195d1c36 100644 --- a/ui/litellm-dashboard/src/components/BetaBadge.tsx +++ b/ui/litellm-dashboard/src/components/BetaBadge.tsx @@ -1,4 +1,4 @@ -import { Badge } from "antd"; +import { Badge } from "@/components/ui/badge"; import { useDisableShowNewBadge } from "@/app/(dashboard)/hooks/useDisableShowNewBadge"; export default function BetaBadge({ children, dot = false }: { children?: React.ReactNode; dot?: boolean }) { @@ -8,11 +8,14 @@ export default function BetaBadge({ children, dot = false }: { children?: React. return children ? <>{children} : null; } + const badge = dot ? : Beta; + return children ? ( - + {children} - + {badge} + ) : ( - + badge ); } diff --git a/ui/litellm-dashboard/src/components/DeprecationBanner.test.tsx b/ui/litellm-dashboard/src/components/DeprecationBanner.test.tsx new file mode 100644 index 00000000000..596ad2a1626 --- /dev/null +++ b/ui/litellm-dashboard/src/components/DeprecationBanner.test.tsx @@ -0,0 +1,44 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { DeprecationBanner } from "./DeprecationBanner"; + +describe("DeprecationBanner", () => { + it("names the deprecated feature in the heading and the body", () => { + render(); + + expect(screen.getByText("Memory is on a draft deprecation list")).toBeInTheDocument(); + expect(screen.getByText(/Memory is one of several experimental features/)).toBeInTheDocument(); + }); + + it("states the target removal date and that the list is not final", () => { + render(); + + expect(screen.getByText(/as early as September 1, 2026/)).toBeInTheDocument(); + expect(screen.getByText(/This list is a draft and is not final/)).toBeInTheDocument(); + }); + + it("links to the deprecation discussion in a new tab without leaking the opener", () => { + render(); + + const link = screen.getByRole("link", { name: "deprecation discussion" }); + expect(link).toHaveAttribute("href", "https://github.com/BerriAI/litellm/discussions/32090"); + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); + }); + + it("exposes a named close control", () => { + render(); + + expect(screen.getByRole("button", { name: /close/i })).toBeInTheDocument(); + }); + + it("hides the banner once the close control is used", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /close/i })); + + expect(screen.queryByText("Memory is on a draft deprecation list")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/DeprecationBanner.tsx b/ui/litellm-dashboard/src/components/DeprecationBanner.tsx index 33c75ec22e3..9df34636f82 100644 --- a/ui/litellm-dashboard/src/components/DeprecationBanner.tsx +++ b/ui/litellm-dashboard/src/components/DeprecationBanner.tsx @@ -1,8 +1,8 @@ "use client"; -import React from "react"; +import React, { useState } from "react"; import Link from "next/link"; -import { Alert } from "antd"; +import { Info, X } from "lucide-react"; const DEPRECATION_DISCUSSION_URL = "https://github.com/BerriAI/litellm/discussions/32090"; const DEPRECATION_TARGET_DATE = "September 1, 2026"; @@ -11,21 +11,42 @@ interface DeprecationBannerProps { featureName: string; } -export const DeprecationBanner: React.FC = ({ featureName }) => ( - - {`${featureName} is one of several experimental features we're considering removing, potentially as early as ${DEPRECATION_TARGET_DATE}. This list is a draft and is not final. If you rely on this feature, please share feedback on the `} - - deprecation discussion - - . - - } - type="info" - showIcon - closable - style={{ marginBottom: 16 }} - /> -); +export const DeprecationBanner: React.FC = ({ featureName }) => { + const [isClosed, setIsClosed] = useState(false); + + if (isClosed) { + return null; + } + + return ( +
+ +
+

{`${featureName} is on a draft deprecation list`}

+

+ {`${featureName} is one of several experimental features we're considering removing, potentially as early as ${DEPRECATION_TARGET_DATE}. This list is a draft and is not final. If you rely on this feature, please share feedback on the `} + + deprecation discussion + + . +

+
+ +
+ ); +}; diff --git a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/EditFallbacks.tsx b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/EditFallbacks.tsx index 938e1104301..3efdcfd6b51 100644 --- a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/EditFallbacks.tsx +++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/EditFallbacks.tsx @@ -4,9 +4,9 @@ * Reuses FallbackGroupConfig with the primary model locked */ -import { Button } from "antd"; +import { Button } from "@/components/ui/button"; import { useQuery } from "@tanstack/react-query"; -import { Pencil } from "lucide-react"; +import { LoaderCircle, Pencil } from "lucide-react"; import React, { useMemo, useState } from "react"; import { fetchAvailableModels } from "@/components/llm_calls/fetch_models"; import NotificationManager from "../../../molecules/notifications_manager"; @@ -88,16 +88,11 @@ export default function EditFallbacks({ disablePrimaryModel />
- -
diff --git a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.tsx b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.tsx index 4aa9fb15705..f82780f0c73 100644 --- a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.tsx +++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.tsx @@ -1,7 +1,7 @@ import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap"; -import { ArrowRightIcon, PencilAltIcon, PlayIcon, TrashIcon } from "@heroicons/react/outline"; -import { Icon, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "@tremor/react"; -import { Tooltip, Typography } from "antd"; +import { ArrowRight, Pencil, Play, Trash2 } from "lucide-react"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import openai from "openai"; import React, { useEffect, useState } from "react"; import DeleteResourceModal from "../../../common_components/DeleteResourceModal"; @@ -18,12 +18,14 @@ type Fallbacks = FallbackEntry[]; const modelCardClass = "inline-flex items-center gap-2 px-2.5 py-1 rounded-md border border-gray-200 bg-gray-50 text-sm font-medium text-gray-800 shrink-0"; +const iconWrapperClass = "inline-flex shrink-0 items-center justify-center px-1.5 py-1.5"; + function renderModelNameCell(modelName: string, getProviderFromModel?: (modelName: string) => string): React.ReactNode { const provider = getProviderFromModel?.(modelName) ?? modelName; return ( - {modelName} + {modelName} ); } @@ -41,19 +43,23 @@ function renderFallbacksChain( return ( - {modelName} + {modelName} ); }; return ( - + {list.map((model, i) => ( - {i > 0 && } + {i > 0 && ( + + + + )} ))} @@ -248,7 +254,7 @@ const Fallbacks: React.FC = ({ accessToken, userRole, userID }) const canModify = isProxyAdminRole(userRole ?? ""); return ( - <> + {canModify && ( = ({ accessToken, userRole, userID }) )} {!hasFallbacks ? (
- + No fallbacks configured. Add fallbacks to automatically try another model when the primary fails. - +
) : ( - + - Model Name - Fallbacks - Actions + Model Name + Fallbacks + Actions - + {routerSettings["fallbacks"].map((item: FallbackEntry, index: number) => Object.entries(item).map(([key, value]) => ( - {renderModelNameCell(key, getProviderFromModel)} - + + {renderModelNameCell(key, getProviderFromModel)} + + {renderFallbacksChain(key, Array.isArray(value) ? value : [], getProviderFromModel)} {canModify && ( <> - - testFallbackModelResponse(Object.keys(item)[0], accessToken || "")} - className="cursor-pointer hover:text-blue-600" - /> - - - handleEditClick(item)} - onKeyDown={(e) => e.key === "Enter" && handleEditClick(item)} - className="cursor-pointer inline-flex" + + testFallbackModelResponse(Object.keys(item)[0], accessToken || "")} + className={`${iconWrapperClass} cursor-pointer hover:text-blue-600`} + /> + } > - - + + + Test fallback - - handleDeleteClick(item)} - onKeyDown={(e) => e.key === "Enter" && handleDeleteClick(item)} - className="cursor-pointer inline-flex" + + handleEditClick(item)} + onKeyDown={(e) => e.key === "Enter" && handleEditClick(item)} + className={`${iconWrapperClass} cursor-pointer hover:text-blue-600`} + /> + } > - - + + + Edit fallback + + + handleDeleteClick(item)} + onKeyDown={(e) => e.key === "Enter" && handleDeleteClick(item)} + className={`${iconWrapperClass} cursor-pointer hover:text-red-600`} + /> + } + > + + + Delete fallback )} @@ -350,7 +373,7 @@ const Fallbacks: React.FC = ({ accessToken, userRole, userID }) onOk={handleDeleteConfirm} confirmLoading={isDeleting} /> - + ); }; From 3a537cce4d9ba30b30e23e3346d4fbfe6dc0b1c2 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 14 Aug 2026 04:48:49 -0700 Subject: [PATCH 27/49] refactor(ui): move the model hub and model select onto shadcn primitives Rebuilds public_model_hub, MakeSkillPublicForm, ModelSelect and the guardrail LogViewer on the in-repo shadcn layer, so they inherit the dashboard's design tokens instead of styling themselves through Ant Design and Tremor. Public prop signatures are unchanged, so no caller moves. The two teams e2e steps that reached into antd's Select internals now drive the combobox through its test id, role and data-slot instead. --- tests/e2e/ui/tests/proxy-admin/teams.spec.ts | 16 +- ui/litellm-dashboard/eslint-suppressions.json | 14 - .../GuardrailsMonitor/LogViewer.tsx | 25 +- .../ModelSelect/ModelSelect.test.tsx | 280 +-- .../components/ModelSelect/ModelSelect.tsx | 228 ++- .../MakeSkillPublicForm.tsx | 165 +- .../src/components/public_model_hub.test.tsx | 15 + .../src/components/public_model_hub.tsx | 1741 +++++++++-------- 8 files changed, 1261 insertions(+), 1223 deletions(-) diff --git a/tests/e2e/ui/tests/proxy-admin/teams.spec.ts b/tests/e2e/ui/tests/proxy-admin/teams.spec.ts index 92d22f11f4d..3a63c5be940 100644 --- a/tests/e2e/ui/tests/proxy-admin/teams.spec.ts +++ b/tests/e2e/ui/tests/proxy-admin/teams.spec.ts @@ -47,10 +47,10 @@ test.describe("Proxy Admin - Teams", () => { // Fill Team Name — the input has id="team_alias" await dialog.locator("#team_alias").fill(uniqueAlias); - // Select models — the models multi-select is inside the modal - // Click to open dropdown, select "All Proxy Models" - await dialog.locator(".ant-select-selection-overflow").first().click(); - await page.locator(".ant-select-dropdown:visible").getByText("All Proxy Models").click(); + // Select models — the models multi-select is inside the modal. Its popup is + // portaled to the body, so scope the option lookup to the page, not the dialog. + await dialog.getByTestId("create-team-models-select").getByRole("combobox").click(); + await page.getByRole("option", { name: "All Proxy Models", exact: true }).click(); await page.keyboard.press("Escape"); // Submit — click the submit button inside the dialog (not the header button) @@ -191,11 +191,11 @@ test.describe("Proxy Admin - Teams", () => { const modelsSelect = page.locator("[data-testid='models-select']"); await expect(modelsSelect).toBeVisible({ timeout: 10_000 }); - const anthropicTag = modelsSelect - .locator(".ant-select-selection-item") + const anthropicChip = modelsSelect + .locator('[data-slot="combobox-chip"]') .filter({ hasText: "fake-anthropic-claude" }); - await expect(anthropicTag).toBeVisible({ timeout: 5_000 }); - await anthropicTag.locator(".ant-select-selection-item-remove").click(); + await expect(anthropicChip).toBeVisible({ timeout: 5_000 }); + await anthropicChip.locator('[data-slot="combobox-chip-remove"]').click(); await page.getByRole("button", { name: "Save Changes" }).click(); diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 5e322598a10..eb3352a0e30 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1830,9 +1830,6 @@ "src/components/GuardrailsMonitor/LogViewer.tsx": { "no-nested-ternary": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/components/HelpLink.test.tsx": { @@ -1845,11 +1842,6 @@ "count": 1 } }, - "src/components/ModelSelect/ModelSelect.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx": { "max-nested-callbacks": { "count": 12 @@ -2341,9 +2333,6 @@ } }, "src/components/claude_code_plugins/MakeSkillPublicForm.tsx": { - "no-restricted-imports": { - "count": 2 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -2982,9 +2971,6 @@ }, "max-lines": { "count": 1 - }, - "no-restricted-imports": { - "count": 2 } }, "src/components/query_param_input.tsx": { diff --git a/ui/litellm-dashboard/src/components/GuardrailsMonitor/LogViewer.tsx b/ui/litellm-dashboard/src/components/GuardrailsMonitor/LogViewer.tsx index c3671fc9e2c..d41b29f8218 100644 --- a/ui/litellm-dashboard/src/components/GuardrailsMonitor/LogViewer.tsx +++ b/ui/litellm-dashboard/src/components/GuardrailsMonitor/LogViewer.tsx @@ -1,8 +1,9 @@ -import { CheckCircleOutlined, CloseOutlined, DownOutlined, WarningOutlined } from "@ant-design/icons"; +import { CircleCheck, ChevronDown, TriangleAlert, X } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import moment from "moment"; -import { Button, Spin } from "antd"; import React, { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { uiSpendLogsCall } from "@/components/networking"; import { LogDetailsDrawer } from "@/components/view_logs/LogDetailsDrawer"; import type { LogEntry as ViewLogsLogEntry } from "@/components/view_logs/columns"; @@ -13,21 +14,21 @@ const actionConfig: Record< { icon: React.ElementType; color: string; bg: string; border: string; label: string } > = { blocked: { - icon: CloseOutlined, + icon: X, color: "text-red-600", bg: "bg-red-50", border: "border-red-200", label: "Blocked", }, passed: { - icon: CheckCircleOutlined, + icon: CircleCheck, color: "text-green-600", bg: "bg-green-50", border: "border-green-200", label: "Passed", }, flagged: { - icon: WarningOutlined, + icon: TriangleAlert, color: "text-amber-600", bg: "bg-amber-50", border: "border-amber-200", @@ -125,8 +126,8 @@ export function LogViewer({ {filters.map((f) => ( ); })} diff --git a/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.test.tsx b/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.test.tsx index e253bc4c0ef..eeaeac541bd 100644 --- a/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.test.tsx +++ b/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.test.tsx @@ -1,6 +1,6 @@ import type { ProxyModel } from "@/app/(dashboard)/hooks/models/useModels"; import type { Organization } from "@/components/networking"; -import { screen, waitFor } from "@testing-library/react"; +import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../../../tests/test-utils"; @@ -22,64 +22,6 @@ vi.mock("@/app/(dashboard)/hooks/users/useCurrentUser", () => ({ useCurrentUser: vi.fn(), })); -vi.mock("antd", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - Select: ({ - value, - onChange, - options, - "data-testid": dataTestId, - allowClear, - maxTagCount, - maxTagPlaceholder, - mode, - ...props - }: any) => { - // Simulate maxTagCount responsive behavior - if value length > 5, call maxTagPlaceholder - const shouldShowPlaceholder = maxTagCount === "responsive" && Array.isArray(value) && value.length > 5; - const visibleValues = shouldShowPlaceholder ? value.slice(0, 5) : value; - const omittedValues = shouldShowPlaceholder ? value.slice(5).map((v: string) => ({ value: v, label: v })) : []; - - return ( -
- - {shouldShowPlaceholder && maxTagPlaceholder && ( -
{maxTagPlaceholder(omittedValues)}
- )} -
- ); - }, - Skeleton: { - Input: ({ active, block }: any) =>
, - }, - Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, - }; -}); - import { useAllProxyModels } from "@/app/(dashboard)/hooks/models/useModels"; import { useOrganization } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; import { useTeam } from "@/app/(dashboard)/hooks/teams/useTeams"; @@ -108,6 +50,14 @@ const createMockOrganization = (models: string[]): Organization => ({ members: null, }); +const openModelList = async (user: ReturnType) => { + await user.click(screen.getAllByRole("combobox")[0]); + await screen.findByRole("listbox"); +}; + +const expectOffered = (label: string) => expect(screen.queryAllByText(label).length).toBeGreaterThan(0); +const expectNotOffered = (label: string) => expect(screen.queryAllByText(label)).toHaveLength(0); + describe("ModelSelect", () => { const mockProxyModels: ProxyModel[] = [ { id: "gpt-4", object: "model", created: 1234567890, owned_by: "openai" }, @@ -138,21 +88,26 @@ describe("ModelSelect", () => { } as any); }); - it("should render with all option groups", async () => { + it("should offer every model and wildcard under its group heading", async () => { + const user = userEvent.setup(); renderWithProviders( , ); - await waitFor(() => { - expect(screen.getByTestId("model-select")).toBeInTheDocument(); - expect(screen.getByText("gpt-4")).toBeInTheDocument(); - expect(screen.getByText("claude-3")).toBeInTheDocument(); - expect(screen.getByText("All Openai models")).toBeInTheDocument(); - expect(screen.getByText("All Anthropic models")).toBeInTheDocument(); - }); + await openModelList(user); + + expectOffered("Wildcard Options"); + expectOffered("gpt-4"); + expectOffered("claude-3"); + expectOffered("All Openai models"); + expectOffered("All Anthropic models"); }); - it("should show skeleton loader when any data is loading", () => { + it("should offer nothing to select while any dependency is loading", () => { + const { unmount: unmountReady } = renderWithProviders(); + expect(screen.getAllByRole("combobox")).toHaveLength(1); + unmountReady(); + const loadingScenarios = [ { hook: mockUseAllProxyModels, context: "user" as const }, { hook: mockUseTeam, context: "team" as const, props: { teamID: "team-1" } }, @@ -168,30 +123,24 @@ describe("ModelSelect", () => { const { unmount } = renderWithProviders(); - expect(screen.getByTestId("skeleton-input")).toBeInTheDocument(); + expect(screen.queryAllByRole("combobox")).toHaveLength(0); unmount(); }); }); - it("should handle model selection and onChange", async () => { + it("should report the picked model to onChange", async () => { const user = userEvent.setup(); renderWithProviders( , ); - await waitFor(() => { - expect(screen.getByTestId("model-select")).toBeInTheDocument(); - }); + await openModelList(user); + await user.click(screen.getAllByText("gpt-4")[0]); - const select = screen.getByRole("listbox"); - await user.selectOptions(select, "gpt-4"); expect(mockOnChange).toHaveBeenCalledWith(["gpt-4"]); - - await user.selectOptions(select, ["gpt-4", "claude-3"]); - expect(mockOnChange).toHaveBeenCalled(); }); - it("should handle special options correctly", async () => { + it("should offer both special options when they are enabled", async () => { const user = userEvent.setup(); mockUseOrganization.mockReturnValue({ data: createMockOrganization(["all-proxy-models"]), @@ -207,33 +156,32 @@ describe("ModelSelect", () => { />, ); - await waitFor(() => { - expect(screen.getByText("All Proxy Models")).toBeInTheDocument(); - expect(screen.getByText("No Default Models")).toBeInTheDocument(); - }); + await openModelList(user); - const select = screen.getByRole("listbox"); - await user.selectOptions(select, ["all-proxy-models", "no-default-models"]); - expect(mockOnChange).toHaveBeenCalledWith(["no-default-models"]); + expectOffered("Special Options"); + expectOffered("All Proxy Models"); + expectOffered("No Default Models"); }); - it("should disable models when special option is selected", async () => { + it("should replace an existing selection when a special option is picked", async () => { + const user = userEvent.setup(); + renderWithProviders( , ); - await waitFor(() => { - expect(screen.getByRole("option", { name: "gpt-4" })).toBeDisabled(); - expect(screen.getByRole("option", { name: "All Openai models" })).toBeDisabled(); - }); + await openModelList(user); + await user.click(screen.getAllByText("No Default Models")[0]); + + expect(mockOnChange).toHaveBeenCalledWith(["no-default-models"]); }); - it("should filter models based on context", async () => { + it("should filter the offered models by context", async () => { const testCases = [ { name: "user context with includeUserModels", @@ -340,6 +288,7 @@ describe("ModelSelect", () => { ]; for (const testCase of testCases) { + const user = userEvent.setup(); testCase.setup(); const { unmount } = renderWithProviders( { />, ); - await waitFor(() => { - testCase.expectedVisible.forEach((model) => { - expect(screen.getByText(model)).toBeInTheDocument(); - }); - testCase.expectedHidden.forEach((model) => { - expect(screen.queryByText(model)).not.toBeInTheDocument(); - }); - }); + await openModelList(user); + testCase.expectedVisible.forEach(expectOffered); + testCase.expectedHidden.forEach(expectNotOffered); unmount(); vi.clearAllMocks(); @@ -368,7 +312,7 @@ describe("ModelSelect", () => { } }); - it("should show All Proxy Models option based on conditions", async () => { + it("should offer All Proxy Models only when the context allows it", async () => { const testCases = [ { name: "when showAllProxyModelsOverride is true", @@ -426,6 +370,7 @@ describe("ModelSelect", () => { ]; for (const testCase of testCases) { + const user = userEvent.setup(); testCase.setup(); const { unmount } = renderWithProviders( { />, ); - await waitFor(() => { - if (testCase.shouldShow) { - expect(screen.getByText("All Proxy Models")).toBeInTheDocument(); - } else { - expect(screen.queryByText("All Proxy Models")).not.toBeInTheDocument(); - expect(screen.getByText("No Default Models")).toBeInTheDocument(); - } - }); + await openModelList(user); + if (testCase.shouldShow) { + expectOffered("All Proxy Models"); + } else { + expectNotOffered("All Proxy Models"); + expectOffered("No Default Models"); + } unmount(); vi.clearAllMocks(); @@ -454,27 +398,6 @@ describe("ModelSelect", () => { } }); - it("should deduplicate models with same id", async () => { - const duplicateModels: ProxyModel[] = [ - { id: "gpt-4", object: "model", created: 1234567890, owned_by: "openai" }, - { id: "gpt-4", object: "model", created: 1234567890, owned_by: "openai" }, - ]; - - mockUseAllProxyModels.mockReturnValue({ - data: { data: duplicateModels }, - isLoading: false, - } as any); - - renderWithProviders( - , - ); - - await waitFor(() => { - const gpt4Options = screen.getAllByText("gpt-4"); - expect(gpt4Options.length).toBeGreaterThan(0); - }); - }); - it("should use custom dataTestId when provided", async () => { renderWithProviders( { />, ); - await waitFor(() => { - expect(screen.getByTestId("custom-test-id")).toBeInTheDocument(); - }); + expect(await screen.findByTestId("custom-test-id")).toBeInTheDocument(); }); it("should return all proxy models for team context when organization has empty models array", async () => { + const user = userEvent.setup(); mockUseTeam.mockReturnValue({ data: { team_id: "team-1", team_alias: "Test Team", models: [] }, isLoading: false, @@ -503,52 +425,66 @@ describe("ModelSelect", () => { renderWithProviders(); - await waitFor(() => { - expect(screen.getByText("gpt-4")).toBeInTheDocument(); - expect(screen.getByText("claude-3")).toBeInTheDocument(); - }); + await openModelList(user); + + expectOffered("gpt-4"); + expectOffered("claude-3"); }); - it("should disable No Default Models when all-proxy-models is selected", async () => { - mockUseOrganization.mockReturnValue({ - data: createMockOrganization(["all-proxy-models"]), - isLoading: false, - } as any); + it("should not offer a special options group when includeSpecialOptions is omitted", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await openModelList(user); + + expectNotOffered("Special Options"); + expectNotOffered("All Proxy Models"); + expectNotOffered("No Default Models"); + expectOffered("Models"); + }); + + it("should mark models and wildcards unselectable while a special option is selected", async () => { + const user = userEvent.setup(); renderWithProviders( , ); - await waitFor(() => { - const noDefaultOption = screen.getByRole("option", { name: "No Default Models" }); - expect(noDefaultOption).toBeDisabled(); - }); + await openModelList(user); + + expect(screen.getByRole("option", { name: "gpt-4" })).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("option", { name: "All Openai models" })).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("option", { name: "No Default Models" })).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("option", { name: "All Proxy Models" })).not.toHaveAttribute("aria-disabled", "true"); }); - it("should not render an empty optgroup when includeSpecialOptions is omitted", async () => { - renderWithProviders(); + it("should list a duplicated proxy model only once", async () => { + const user = userEvent.setup(); + mockUseAllProxyModels.mockReturnValue({ + data: { + data: [ + { id: "gpt-4", object: "model", created: 1234567890, owned_by: "openai" }, + { id: "gpt-4", object: "model", created: 1234567890, owned_by: "openai" }, + ], + }, + isLoading: false, + } as any); - await waitFor(() => { - expect(screen.getByTestId("model-select")).toBeInTheDocument(); - }); + renderWithProviders( + , + ); - const optgroups = document.querySelectorAll("optgroup"); - // Wildcard Options + Models — no blank leading group - expect(optgroups.length).toBe(2); - optgroups.forEach((g) => { - expect(g.getAttribute("label")).toBeTruthy(); - }); + await openModelList(user); + + expect(screen.getAllByRole("option", { name: "gpt-4" })).toHaveLength(1); }); - it("should render maxTagPlaceholder when many items are selected", async () => { - // Create many models to trigger maxTagCount responsive behavior - const manyModels: ProxyModel[] = Array.from({ length: 20 }, (_, i) => ({ + it("should collapse selections past the chip limit into a labelled overflow count", async () => { + const manyModels: ProxyModel[] = Array.from({ length: 8 }, (_, i) => ({ id: `model-${i}`, object: "model", created: 1234567890, @@ -560,22 +496,18 @@ describe("ModelSelect", () => { isLoading: false, } as any); - const selectedValues = manyModels.slice(0, 10).map((m) => m.id); - renderWithProviders( m.id)} context="user" options={{ showAllProxyModelsOverride: true }} />, ); - await waitFor(() => { - expect(screen.getByTestId("model-select")).toBeInTheDocument(); - // Verify maxTagPlaceholder is rendered with omitted values - expect(screen.getByTestId("max-tag-placeholder")).toBeInTheDocument(); - expect(screen.getByText(/\+5 more/)).toBeInTheDocument(); - }); + expect(await screen.findByText("+3 more")).toBeInTheDocument(); + expect(screen.getByLabelText("model-0")).toBeInTheDocument(); + expect(screen.getByLabelText("model-4")).toBeInTheDocument(); + expect(screen.queryByLabelText("model-5")).not.toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.tsx b/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.tsx index e993fed2408..55aa1f1ec5f 100644 --- a/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.tsx +++ b/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.tsx @@ -2,7 +2,22 @@ import { ProxyModel, useAllProxyModels } from "@/app/(dashboard)/hooks/models/us import { useOrganization } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; import { useTeam } from "@/app/(dashboard)/hooks/teams/useTeams"; import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser"; -import { Select, Skeleton, Tooltip } from "antd"; +import { + Combobox, + ComboboxChip, + ComboboxChips, + ComboboxChipsInput, + ComboboxCollection, + ComboboxContent, + ComboboxEmpty, + ComboboxGroup, + ComboboxItem, + ComboboxLabel, + ComboboxList, + ComboboxValue, +} from "@/components/ui/combobox"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Organization, Team } from "../networking"; import { splitWildcardModels } from "./modelUtils"; @@ -21,6 +36,8 @@ export const MODEL_SENTINEL_OPTIONS = [ MODEL_SELECT_NO_DEFAULT_MODELS_SPECIAL_VALUE, ] as const; +const MAX_VISIBLE_MODEL_CHIPS = 5; + export interface ModelSelectProps { teamID?: string; organizationID?: string; @@ -37,6 +54,17 @@ export interface ModelSelectProps { style?: React.CSSProperties; } +type ModelOption = { + label: string; + value: string; + disabled?: boolean; +}; + +type ModelOptionGroup = { + label: string; + items: ModelOption[]; +}; + type FilterContextArgs = { allProxyModels: string[]; selectedTeam?: Team; @@ -109,10 +137,11 @@ export const ModelSelect = (props: ModelSelectProps) => { showAllProxyModelsOverride || (organizationHasAllProxyModels && includeSpecialOptions) || context === "global"; if (isLoading) { - return ; + return ; } - const handleChange = (values: string[]) => { + const handleChange = (selected: ModelOption[]) => { + const values = selected.map((option) => option.value); const specialValues = values.filter(isSpecialOption); let finalValues: string[]; @@ -133,85 +162,122 @@ export const ModelSelect = (props: ModelSelectProps) => { }); const { wildcard, regular } = splitWildcardModels(filteredModels); - return ( - setSearchTerm(e.target.value)} - className="border border-gray-300 rounded-lg pl-10 pr-4 py-2 w-full text-sm focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white" - /> -
- -
- Provider: - -
-
- Mode: - -
-
- Features: - -
- - - model.model_group || String(index)} - sortingMode="client" - sorting={modelSorting} - onSortingChange={setModelSorting} - isLoading={loading} - loadingMessage="Loading models…" - noDataMessage={ - - } - size="compact" - /> - -
- - Showing {filteredData.length} of {modelHubData?.length || 0} models - -
- - - {/* Agents Tab */} - {agentHubData && Array.isArray(agentHubData) && agentHubData.length > 0 && ( - -
- Available Agents -
- - {/* Filters */} -
-
-
- Search Agents: - - - -
-
- - setAgentSearchTerm(e.target.value)} - className="border border-gray-300 rounded-lg pl-10 pr-4 py-2 w-full text-sm focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white" - /> -
-
-
- Skills: - -
-
- - agent.name || String(index)} - sortingMode="client" - sorting={agentSorting} - onSortingChange={setAgentSorting} - isLoading={agentLoading} - loadingMessage="Loading agents…" - noDataMessage={ - - } - size="compact" - /> - -
- - Showing {filteredAgentData.length} of {agentHubData?.length || 0} agents - -
-
- )} - - {/* MCP Servers Tab */} - {mcpHubData && Array.isArray(mcpHubData) && mcpHubData.length > 0 && ( - -
- Available MCP Servers -
- - {/* Filters */} -
-
-
- Search MCP Servers: - - - -
-
- - setMcpSearchTerm(e.target.value)} - className="border border-gray-300 rounded-lg pl-10 pr-4 py-2 w-full text-sm focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white" - /> -
-
-
- Transport: - -
-
- - server.server_id || String(index)} - sortingMode="client" - sorting={mcpSorting} - onSortingChange={setMcpSorting} - isLoading={mcpLoading} - loadingMessage="Loading MCP servers…" - noDataMessage={ - - } - size="compact" - /> - -
- - Showing {filteredMcpData.length} of {mcpHubData?.length || 0} MCP servers - -
-
- )} - - {/* Skill Hub Tab */} - - - - - - - - {/* Model Details Modal */} - - {selectedModel?.model_group || "Model Details"} - {selectedModel && ( - - copyToClipboard(selectedModel.model_group)} - className="cursor-pointer text-gray-500 hover:text-blue-500 w-4 h-4" - /> - - )} - - } - width={1000} - open={isModalVisible} - footer={null} - onOk={handleModalOk} - onCancel={handleModalCancel} - > - {selectedModel && ( -
- {/* Model Overview */} -
- Model Overview -
-
- Model Name: - {selectedModel.model_group} -
-
- Mode: - {selectedModel.mode || "Not specified"} -
-
- Providers: -
- {(selectedModel.providers ?? []).map((provider) => { - const { logo } = getProviderLogoAndName(provider); - return ( - -
- {logo && ( - {provider} { - (e.target as HTMLImageElement).style.display = "none"; - }} - /> - )} - {provider} -
-
- ); - })} -
-
-
- - {/* Wildcard Routing Note */} - {selectedModel.model_group.includes("*") && ( -
-
- -
- Wildcard Routing - - This model uses wildcard routing. You can pass any value where you see the{" "} - * symbol. - - - For example, with{" "} - - {selectedModel.model_group} - - , you can use any string ( - - {selectedModel.model_group.replaceAll("*", "my-custom-value")} - - ) that matches this pattern. - -
-
-
- )} -
- - {/* Token and Cost Information */} -
- Token & Cost Information -
-
- Max Input Tokens: - {selectedModel.max_input_tokens?.toLocaleString() || "Not specified"} -
-
- Max Output Tokens: - {selectedModel.max_output_tokens?.toLocaleString() || "Not specified"} -
-
- Input Cost per 1M Tokens: - - {selectedModel.input_cost_per_token - ? formatCost(selectedModel.input_cost_per_token) - : "Not specified"} - -
-
- Output Cost per 1M Tokens: - - {selectedModel.output_cost_per_token - ? formatCost(selectedModel.output_cost_per_token) - : "Not specified"} - -
-
-
- - {/* Capabilities */} -
- Capabilities -
- {(() => { - const capabilities = getModelCapabilities(selectedModel); - const colors = ["green", "blue", "purple", "orange", "red", "yellow"]; - - if (capabilities.length === 0) { - return No special capabilities listed; - } - - return capabilities.map((capability, index) => ( - - {formatCapabilityName(capability)} - - )); - })()} -
-
- - {/* Rate Limits */} - {(selectedModel.tpm || selectedModel.rpm) && ( -
- Rate Limits -
- {selectedModel.tpm && ( -
- Tokens per Minute: - {selectedModel.tpm.toLocaleString()} -
- )} - {selectedModel.rpm && ( -
- Requests per Minute: - {selectedModel.rpm.toLocaleString()} -
- )} -
-
- )} - - {/* Supported OpenAI Parameters */} - {selectedModel.supported_openai_params && selectedModel.supported_openai_params.length > 0 && ( -
- Supported OpenAI Parameters -
- {selectedModel.supported_openai_params.map((param) => ( - - {param} - + +

{title}

+ ))} -
- )} + + )} - {/* Usage Example */} -
- Usage Example -
-
-                    {(() => {
-                      const codeSnippet = generateCodeSnippet({
-                        apiKeySource: "custom",
-                        accessToken: null,
-                        apiKey: "your_api_key",
-                        inputMessage: "Hello, how are you?",
-                        chatHistory: [{ role: "user", content: "Hello, how are you?", isImage: false } as MessageType],
-                        selectedTags: [],
-                        selectedVectorStores: [],
-                        selectedGuardrails: [],
-                        selectedPolicies: [],
-                        selectedMCPServers: [],
-                        endpointType: getEndpointType(selectedModel.mode || "chat"),
-                        selectedModel: selectedModel.model_group,
-                        selectedSdk: "openai",
-                      });
-                      return codeSnippet;
-                    })()}
-                  
+ {/* Health and Endpoint Status - only shown when not embedded */} + {!isEmbedded && ( + +

Health and Endpoint Status

+
+

Service status: {serviceStatus}

-
- -
-
-
- )} - + + )} - {/* Agent Details Modal */} - - {selectedAgent?.name || "Agent Details"} - {selectedAgent && ( - - copyToClipboard(selectedAgent.name)} - className="cursor-pointer text-gray-500 hover:text-blue-500 w-4 h-4" - /> - - )} -
- } - width={1000} - open={isAgentModalVisible} - footer={null} - onOk={handleAgentModalOk} - onCancel={handleAgentModalCancel} - > - {selectedAgent && ( -
- {/* Agent Overview */} -
- Agent Overview -
-
- Name: - {selectedAgent.name} + {/* Tabs for Models and Agents */} + + + + Model Hub + {hasAgents && Agent Hub} + {hasMcpServers && MCP Hub} + Skill Hub + + + {/* Models Tab */} + +
+

Available Models

-
- Version: - {selectedAgent.version} -
-
- Description: - {selectedAgent.description} -
- {selectedAgent.url && ( + + {/* Filters */} +
- URL: - - {selectedAgent.url} - +
+

Search Models:

+ + } /> + + Smart search with relevance ranking - finds models containing your search terms, ranked by + relevance. Try searching 'xai grok-4', 'claude-4', 'gpt-4', or + 'sonnet' + + +
+
+ + setSearchTerm(e.target.value)} + className="border border-gray-300 rounded-lg pl-10 pr-4 py-2 w-full text-sm focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white" + /> +
+
+
+

Provider:

+ setSelectedProviders(values)} + > + + + {(values: string[]) => + values.map((provider) => ( + + {provider} + + )) + } + + + + + No providers found + + {(provider: string) => { + const { logo } = getProviderLogoAndName(provider); + return ( + + + {logo && ( + {provider} { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + )} + {provider} + + + ); + }} + + + +
+
+

Mode:

+ +
+
+

Features:

+
- )} -
-
- - {/* Capabilities */} - {selectedAgent.capabilities && ( -
- Capabilities -
- {Object.entries(selectedAgent.capabilities) - .filter(([_, value]) => value === true) - .map(([key]) => ( - - {key} - - ))}
-
- )} - {/* Skills */} - {selectedAgent.skills && selectedAgent.skills.length > 0 && ( -
- Skills -
- {selectedAgent.skills.map((skill, index) => ( -
-
+ model.model_group || String(index)} + sortingMode="client" + sorting={modelSorting} + onSortingChange={setModelSorting} + isLoading={loading} + loadingMessage="Loading models…" + noDataMessage={ + + } + size="compact" + /> + +
+

+ Showing {filteredData.length} of {modelHubData?.length || 0} models +

+
+ + + {/* Agents Tab */} + {hasAgents && ( + +
+

Available Agents

+
+ + {/* Filters */} +
+
+
+

Search Agents:

+ + } /> + Search agents by name or description + +
+
+ + setAgentSearchTerm(e.target.value)} + className="border border-gray-300 rounded-lg pl-10 pr-4 py-2 w-full text-sm focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white" + /> +
+
+
+

Skills:

+ +
+
+ + agent.name || String(index)} + sortingMode="client" + sorting={agentSorting} + onSortingChange={setAgentSorting} + isLoading={agentLoading} + loadingMessage="Loading agents…" + noDataMessage={ + + } + size="compact" + /> + +
+

+ Showing {filteredAgentData.length} of {agentHubData?.length || 0} agents +

+
+
+ )} + + {/* MCP Servers Tab */} + {hasMcpServers && ( + +
+

Available MCP Servers

+
+ + {/* Filters */} +
+
+
+

Search MCP Servers:

+ + } /> + Search MCP servers by name or description + +
+
+ + setMcpSearchTerm(e.target.value)} + className="border border-gray-300 rounded-lg pl-10 pr-4 py-2 w-full text-sm focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white" + /> +
+
+
+

Transport:

+ +
+
+ + server.server_id || String(index)} + sortingMode="client" + sorting={mcpSorting} + onSortingChange={setMcpSorting} + isLoading={mcpLoading} + loadingMessage="Loading MCP servers…" + noDataMessage={ + + } + size="compact" + /> + +
+

+ Showing {filteredMcpData.length} of {mcpHubData?.length || 0} MCP servers +

+
+
+ )} + + {/* Skill Hub Tab */} + + + + + +
+ + {/* Model Details Modal */} + !open && handleModalCancel()}> + + + + {selectedModel?.model_group || "Model Details"} + {selectedModel && ( + + copyToClipboard(selectedModel.model_group)} + className="cursor-pointer text-gray-500 hover:text-blue-500 w-4 h-4 shrink-0" + /> + } + /> + Copy model name + + )} + + + {selectedModel && ( +
+ {/* Model Overview */} +
+

Model Overview

+
+
+

Model Name:

+

{selectedModel.model_group}

+
+
+

Mode:

+

{selectedModel.mode || "Not specified"}

+
+
+

Providers:

+
+ {(selectedModel.providers ?? []).map((provider) => { + const { logo } = getProviderLogoAndName(provider); + return ( + +
+ {logo && ( + {provider} { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + )} + {provider} +
+
+ ); + })} +
+
+
+ + {/* Wildcard Routing Note */} + {selectedModel.model_group.includes("*") && ( +
+
+
- {skill.name} - {skill.description} +

Wildcard Routing

+

+ This model uses wildcard routing. You can pass any value where you see the{" "} + * symbol. +

+

+ For example, with{" "} + + {selectedModel.model_group} + + , you can use any string ( + + {selectedModel.model_group.replaceAll("*", "my-custom-value")} + + ) that matches this pattern. +

- {skill.tags && skill.tags.length > 0 && ( -
- {skill.tags.map((tag) => ( - - {tag} - - ))} +
+ )} +
+ + {/* Token and Cost Information */} +
+

Token & Cost Information

+
+
+

Max Input Tokens:

+

{selectedModel.max_input_tokens?.toLocaleString() || "Not specified"}

+
+
+

Max Output Tokens:

+

{selectedModel.max_output_tokens?.toLocaleString() || "Not specified"}

+
+
+

Input Cost per 1M Tokens:

+

+ {selectedModel.input_cost_per_token + ? formatCost(selectedModel.input_cost_per_token) + : "Not specified"} +

+
+
+

Output Cost per 1M Tokens:

+

+ {selectedModel.output_cost_per_token + ? formatCost(selectedModel.output_cost_per_token) + : "Not specified"} +

+
+
+
+ + {/* Capabilities */} +
+

Capabilities

+
+ {(() => { + const capabilities = getModelCapabilities(selectedModel); + + if (capabilities.length === 0) { + return

No special capabilities listed

; + } + + return capabilities.map((capability) => ( + + {formatCapabilityName(capability)} + + )); + })()} +
+
+ + {/* Rate Limits */} + {(selectedModel.tpm || selectedModel.rpm) && ( +
+

Rate Limits

+
+ {selectedModel.tpm && ( +
+

Tokens per Minute:

+

{selectedModel.tpm.toLocaleString()}

+
+ )} + {selectedModel.rpm && ( +
+

Requests per Minute:

+

{selectedModel.rpm.toLocaleString()}

)}
- ))} -
-
- )} - - {/* Input/Output Modes */} -
- Input/Output Modes -
-
- Input Modes: -
- {(selectedAgent.defaultInputModes ?? []).map((mode) => ( - - {mode} - - ))}
-
+ )} + + {/* Supported OpenAI Parameters */} + {selectedModel.supported_openai_params && selectedModel.supported_openai_params.length > 0 && ( +
+

Supported OpenAI Parameters

+
+ {selectedModel.supported_openai_params.map((param) => ( + + {param} + + ))} +
+
+ )} + + {/* Usage Example */}
- Output Modes: -
- {(selectedAgent.defaultOutputModes ?? []).map((mode) => ( - - {mode} - - ))} +

Usage Example

+
+
+                        {(() => {
+                          const codeSnippet = generateCodeSnippet({
+                            apiKeySource: "custom",
+                            accessToken: null,
+                            apiKey: "your_api_key",
+                            inputMessage: "Hello, how are you?",
+                            chatHistory: [
+                              { role: "user", content: "Hello, how are you?", isImage: false } as MessageType,
+                            ],
+                            selectedTags: [],
+                            selectedVectorStores: [],
+                            selectedGuardrails: [],
+                            selectedPolicies: [],
+                            selectedMCPServers: [],
+                            endpointType: getEndpointType(selectedModel.mode || "chat"),
+                            selectedModel: selectedModel.model_group,
+                            selectedSdk: "openai",
+                          });
+                          return codeSnippet;
+                        })()}
+                      
+
+
+
-
- - {/* Documentation */} - {selectedAgent.documentationUrl && ( -
- Documentation - - - View Documentation - -
)} + +
- {/* A2A Usage Example */} -
- Usage Example (A2A Protocol) + {/* Agent Details Modal */} + !open && handleAgentModalCancel()}> + + + + {selectedAgent?.name || "Agent Details"} + {selectedAgent && ( + + copyToClipboard(selectedAgent.name)} + className="cursor-pointer text-gray-500 hover:text-blue-500 w-4 h-4 shrink-0" + /> + } + /> + Copy agent name + + )} + + + {selectedAgent && ( +
+ {/* Agent Overview */} +
+

Agent Overview

+
+
+

Name:

+

{selectedAgent.name}

+
+
+

Version:

+

{selectedAgent.version}

+
+
+

Description:

+

{selectedAgent.description}

+
+ {selectedAgent.url && ( + + )} +
+
- {/* Step 1: Retrieve Agent Card */} -
- Step 1: Retrieve Agent Card -
-
-                      {`base_url = '${selectedAgent.url}'
+                  {/* Capabilities */}
+                  {selectedAgent.capabilities && (
+                    
+

Capabilities

+
+ {Object.entries(selectedAgent.capabilities) + .filter(([_, value]) => value === true) + .map(([key]) => ( + + {key} + + ))} +
+
+ )} + + {/* Skills */} + {selectedAgent.skills && selectedAgent.skills.length > 0 && ( +
+

Skills

+
+ {selectedAgent.skills.map((skill, index) => ( +
+
+
+

{skill.name}

+

{skill.description}

+
+
+ {skill.tags && skill.tags.length > 0 && ( +
+ {skill.tags.map((tag) => ( + + {tag} + + ))} +
+ )} +
+ ))} +
+
+ )} + + {/* Input/Output Modes */} +
+

Input/Output Modes

+
+
+

Input Modes:

+
+ {(selectedAgent.defaultInputModes ?? []).map((mode) => ( + + {mode} + + ))} +
+
+
+

Output Modes:

+
+ {(selectedAgent.defaultOutputModes ?? []).map((mode) => ( + + {mode} + + ))} +
+
+
+
+ + {/* Documentation */} + {selectedAgent.documentationUrl && ( +
+

Documentation

+ + + View Documentation + +
+ )} + + {/* A2A Usage Example */} +
+

Usage Example (A2A Protocol)

+ + {/* Step 1: Retrieve Agent Card */} +
+

Step 1: Retrieve Agent Card

+
+
+                          {`base_url = '${selectedAgent.url}'
 
 resolver = A2ACardResolver(
     httpx_client=httpx_client,
@@ -1251,12 +1275,12 @@ if _public_card.supports_authenticated_extended_card:
             f'Failed to fetch extended agent card: {e_extended}. Will proceed with public card.',
             exc_info=True,
         )`}
-                    
-
-
-
+
+
+ -
-
+ copyToClipboard(codeSnippet); + }} + className="text-sm text-blue-600 hover:text-blue-800 cursor-pointer" + > + Copy to clipboard + +
+
- {/* Step 2: Call the Agent */} -
- Step 2: Call the Agent -
-
-                      {`client = A2AClient(
+                    {/* Step 2: Call the Agent */}
+                    
+

Step 2: Call the Agent

+
+
+                          {`client = A2AClient(
     httpx_client=httpx_client, agent_card=final_agent_card_to_use
 )
 
@@ -1333,12 +1357,12 @@ request = SendMessageRequest(
 
 response = await client.send_message(request)
 print(response.model_dump(mode='json', exclude_none=True))`}
-                    
-
-
-
+
+
+ + copyToClipboard(codeSnippet); + }} + className="text-sm text-blue-600 hover:text-blue-800 cursor-pointer" + > + Copy to clipboard + +
+
-
-
- )} - - - {/* MCP Server Details Modal */} - - {selectedMcpServer?.server_name || "MCP Server Details"} - {selectedMcpServer && ( - - copyToClipboard(selectedMcpServer.server_name)} - className="cursor-pointer text-gray-500 hover:text-blue-500 w-4 h-4" - /> - )} -
- } - width={1000} - open={isMcpModalVisible} - footer={null} - onOk={handleMcpModalOk} - onCancel={handleMcpModalCancel} - > - {selectedMcpServer && ( -
- {/* Server Overview */} -
- Server Overview -
+ + + + {/* MCP Server Details Modal */} + !open && handleMcpModalCancel()}> + + + + {selectedMcpServer?.server_name || "MCP Server Details"} + {selectedMcpServer && ( + + copyToClipboard(selectedMcpServer.server_name)} + className="cursor-pointer text-gray-500 hover:text-blue-500 w-4 h-4 shrink-0" + /> + } + /> + Copy server name + + )} + + + {selectedMcpServer && ( +
+ {/* Server Overview */}
- Server Name: - {selectedMcpServer.server_name} +

Server Overview

+
+
+

Server Name:

+

{selectedMcpServer.server_name}

+
+
+

Transport:

+ {selectedMcpServer.transport} +
+ {selectedMcpServer.alias && ( +
+

Alias:

+

{selectedMcpServer.alias}

+
+ )} +
+

Auth Type:

+ + {selectedMcpServer.auth_type} + +
+
+

Description:

+

{selectedMcpServer.mcp_info?.description || "-"}

+
+
-
- Transport: - {selectedMcpServer.transport} -
- {selectedMcpServer.alias && ( + + {/* Additional Info */} + {selectedMcpServer.mcp_info && Object.keys(selectedMcpServer.mcp_info).length > 0 && (
- Alias: - {selectedMcpServer.alias} +

Additional Information

+
+
+                          {JSON.stringify(selectedMcpServer.mcp_info, null, 2)}
+                        
+
)} + + {/* Usage Example */}
- Auth Type: - - {selectedMcpServer.auth_type} - -
-
- Description: - {selectedMcpServer.mcp_info?.description || "-"} -
-
-
- - {/* Additional Info */} - {selectedMcpServer.mcp_info && Object.keys(selectedMcpServer.mcp_info).length > 0 && ( -
- Additional Information -
-
{JSON.stringify(selectedMcpServer.mcp_info, null, 2)}
-
-
- )} - - {/* Usage Example */} -
- Usage Example -
-
-                    {`# Using MCP Server with Python FastMCP
+                    

Usage Example

+
+
+                        {`# Using MCP Server with Python FastMCP
 
 from fastmcp import Client
 import asyncio
@@ -1474,12 +1501,12 @@ async def main():
 
 if __name__ == "__main__":
     asyncio.run(main())`}
-                  
-
-
-
+
+
+ + copyToClipboard(codeSnippet); + }} + className="text-sm text-blue-600 hover:text-blue-800 cursor-pointer" + > + Copy to clipboard + +
+
-
-
- )} -
- + )} + + + + ); }; From 26e055248b2dab8ed5835b6dbed6e082612b2754 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 14 Aug 2026 04:57:13 -0700 Subject: [PATCH 28/49] refactor(ui): give MemberTable its own extra-column type extraColumns was typed as antd's ColumnsType while the adapter only honoured string/ReactNode titles, plain-string dataIndex values and element/string/number render results, so several valid antd column forms produced blank cells. MemberTableColumn now describes exactly what the table renders, and a column with a dataIndex but no render falls back to the member value instead of rendering nothing. --- ui/litellm-dashboard/eslint-suppressions.json | 8 ----- .../common_components/MemberTable.tsx | 32 +++++++++---------- .../organization/organization_view.tsx | 5 ++- 3 files changed, 17 insertions(+), 28 deletions(-) diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 53768c6f945..85bc950af97 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -2398,11 +2398,6 @@ "count": 2 } }, - "src/components/common_components/MemberTable.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/common_components/MetadataKeyValueFields.test.tsx": { "no-restricted-imports": { "count": 1 @@ -2889,9 +2884,6 @@ "src/components/organization/organization_view.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/components/page_utils.test.ts": { diff --git a/ui/litellm-dashboard/src/components/common_components/MemberTable.tsx b/ui/litellm-dashboard/src/components/common_components/MemberTable.tsx index 3f4cdf5931b..19c2377bafe 100644 --- a/ui/litellm-dashboard/src/components/common_components/MemberTable.tsx +++ b/ui/litellm-dashboard/src/components/common_components/MemberTable.tsx @@ -3,11 +3,17 @@ import { Member } from "@/components/networking"; import { StatusBadge } from "@/components/shared/table_cells"; import { Button } from "@/components/ui/button"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import type { ColumnsType } from "antd/es/table"; import { Crown, Info, User, UserPlus } from "lucide-react"; import React from "react"; import TableIconActionButton from "./IconActionButton/TableIconActionButtons/TableIconActionButton"; +export interface MemberTableColumn { + title: React.ReactNode; + key: React.Key; + dataIndex?: keyof Member; + render?: (value: Member[keyof Member], member: Member, index: number) => React.ReactNode; +} + export interface MemberTableProps { members: Member[]; canEdit: boolean; @@ -16,22 +22,14 @@ export interface MemberTableProps { onAddMember?: () => void; roleColumnTitle?: string; roleTooltip?: string; - extraColumns?: ColumnsType; + extraColumns?: MemberTableColumn[]; showDeleteForMember?: (member: Member) => boolean; emptyText?: string; } -type ExtraColumn = ColumnsType[number]; - -const extraColumnTitle = (column: ExtraColumn): React.ReactNode => - typeof column.title === "function" ? null : column.title; - -const extraColumnCell = (column: ExtraColumn, member: Member, index: number): React.ReactNode => { - const dataIndex = "dataIndex" in column && typeof column.dataIndex === "string" ? column.dataIndex : undefined; - const value = dataIndex ? member[dataIndex as keyof Member] : undefined; - const rendered = column.render?.(value, member, index); - if (typeof rendered === "string" || typeof rendered === "number") return rendered; - return React.isValidElement(rendered) ? rendered : null; +const extraColumnCell = (column: MemberTableColumn, member: Member, index: number): React.ReactNode => { + const value = column.dataIndex ? member[column.dataIndex] : undefined; + return column.render ? column.render(value, member, index) : value; }; const STICKY_ACTIONS_CLASS = "sticky right-0 w-[120px] bg-background"; @@ -70,8 +68,8 @@ export default function MemberTable({ roleColumnTitle )} - {extraColumns.map((column, columnIndex) => ( - {extraColumnTitle(column)} + {extraColumns.map((column) => ( + {column.title} ))} Actions
@@ -104,8 +102,8 @@ export default function MemberTable({ {member.role || "-"} - {extraColumns.map((column, columnIndex) => ( - {extraColumnCell(column, member, memberIndex)} + {extraColumns.map((column) => ( + {extraColumnCell(column, member, memberIndex)} ))} {canEdit ? ( diff --git a/ui/litellm-dashboard/src/components/organization/organization_view.tsx b/ui/litellm-dashboard/src/components/organization/organization_view.tsx index a9f79810e1d..8c1607088c9 100644 --- a/ui/litellm-dashboard/src/components/organization/organization_view.tsx +++ b/ui/litellm-dashboard/src/components/organization/organization_view.tsx @@ -11,10 +11,9 @@ import { formatNumberWithCommas } from "@/utils/dataUtils"; import { teamDetailHref } from "@/utils/entityLinks"; import { createTeamAliasMap } from "@/utils/teamUtils"; import { BadgeLink } from "@/components/shared/BadgeLink"; -import type { ColumnsType } from "antd/es/table"; import { ArrowLeft } from "lucide-react"; import React, { useMemo, useState } from "react"; -import MemberTable from "../common_components/MemberTable"; +import MemberTable, { type MemberTableColumn } from "../common_components/MemberTable"; import UserSearchModal from "../common_components/user_search_modal"; import NotificationsManager from "../molecules/notifications_manager"; import { @@ -122,7 +121,7 @@ const OrganizationInfoView: React.FC = ({ return
Organization not found
; } - const orgExtraColumns: ColumnsType = [ + const orgExtraColumns: MemberTableColumn[] = [ { title: "Spend (USD)", key: "spend", From afff1b08fa877a4fc1b0aa89ffd43b2b4aeb876f Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 14 Aug 2026 06:09:10 -0700 Subject: [PATCH 29/49] refactor(ui): move the shared dropdowns and selectors onto shadcn primitives Rebuilds the thirteen form-free components under common_components on the in-repo shadcn layer, so they inherit the dashboard's design tokens instead of styling themselves through Ant Design and Tremor. SearchSelect and the three dropdowns that wrap it now forward an optional input id, so an antd Form.Item label still resolves to its control. The e2e steps that reached into antd's Select and Modal internals now go through the test id, role and data-slot. --- .../tests/internal-user/internalUser.spec.ts | 7 +- .../internal-user/internalUserNoTeam.spec.ts | 14 +- .../internalUserWithTeams.spec.ts | 4 +- .../e2e/ui/tests/modelsPage/addModel.spec.ts | 6 +- tests/e2e/ui/tests/proxy-admin/keys.spec.ts | 8 +- tests/e2e/ui/tests/proxy-admin/teams.spec.ts | 2 +- .../e2e/ui/tests/team-admin/teamAdmin.spec.ts | 6 +- ui/litellm-dashboard/eslint-suppressions.json | 62 -------- .../_components/AccessGroupsPage.test.tsx | 5 +- .../view_users/user_info_view.test.tsx | 2 +- .../DefaultProxyAdminTag.tsx | 13 +- .../common_components/DeleteResourceModal.tsx | 147 +++++++++--------- .../common_components/ModelAliasManager.tsx | 23 +-- .../common_components/ModelSelector.test.tsx | 35 ++--- .../common_components/ModelSelector.tsx | 48 +++--- .../OrganizationDropdown.test.tsx | 24 ++- .../OrganizationDropdown.tsx | 49 +++--- .../PassThroughRoutesSelector.tsx | 50 ++---- .../PremiumLoggingSettings.tsx | 5 +- .../common_components/ProjectDropdown.tsx | 57 ++++--- .../RouterSettingsAccordion.test.tsx | 8 - .../RouterSettingsAccordion.tsx | 26 ++-- .../budget_duration_dropdown.tsx | 37 +++-- .../common_components/chartUtils.test.tsx | 56 +++---- .../common_components/chartUtils.tsx | 4 +- .../routerSettingsWiring.test.tsx | 10 +- .../common_components/simple_table.tsx | 14 +- .../common_components/team_dropdown.tsx | 92 ++++------- .../src/components/shared/SearchSelect.tsx | 3 + .../src/components/team/TeamInfo.test.tsx | 8 +- .../templates/key_edit_view.test.tsx | 25 +-- 31 files changed, 363 insertions(+), 487 deletions(-) diff --git a/tests/e2e/ui/tests/internal-user/internalUser.spec.ts b/tests/e2e/ui/tests/internal-user/internalUser.spec.ts index 07a75dc007d..b8424b06115 100644 --- a/tests/e2e/ui/tests/internal-user/internalUser.spec.ts +++ b/tests/e2e/ui/tests/internal-user/internalUser.spec.ts @@ -19,12 +19,11 @@ test.describe("Internal User", () => { // Open the team dropdown — seeded internal user is a member of // e2e-team-crud and e2e-team-org, so we expect at least the CRUD alias. - const teamSelect = page.locator(".ant-select", { hasText: "Search or select a team" }); + const teamSelect = page.getByTestId("team-dropdown").getByRole("combobox"); await teamSelect.click(); await page.keyboard.type(E2E_TEAM_CRUD_ALIAS); - await expect(page.locator(".ant-select-dropdown:visible").getByText(E2E_TEAM_CRUD_ALIAS).first()).toBeVisible({ - timeout: 5_000, - }); + const dropdown = page.locator('[data-slot="combobox-content"]:visible'); + await expect(dropdown.getByText(E2E_TEAM_CRUD_ALIAS).first()).toBeVisible({ timeout: 5_000 }); }); test("Team info page omits the Settings tab for non-admin members", async ({ page }) => { diff --git a/tests/e2e/ui/tests/internal-user/internalUserNoTeam.spec.ts b/tests/e2e/ui/tests/internal-user/internalUserNoTeam.spec.ts index 1b048198456..c44305187f1 100644 --- a/tests/e2e/ui/tests/internal-user/internalUserNoTeam.spec.ts +++ b/tests/e2e/ui/tests/internal-user/internalUserNoTeam.spec.ts @@ -27,18 +27,18 @@ test.describe("Internal User with no team memberships", () => { await page.getByRole("button", { name: /Create New Key/i }).click(); await expect(page.getByText("Key Ownership")).toBeVisible({ timeout: 10_000 }); - const teamSelect = page.locator(".ant-select", { hasText: "Search or select a team" }); + const teamSelect = page.getByTestId("team-dropdown").getByRole("combobox"); await teamSelect.click(); - const dropdown = page.locator(".ant-select-dropdown:visible").first(); + const dropdown = page.locator('[data-slot="combobox-content"]:visible').first(); await expect(dropdown).toBeVisible({ timeout: 5_000 }); // Wait for the settled-empty state, not a transient one. The dropdown shows - // a spinner while teams load and only swaps in "No teams found" once the - // request resolves with nothing (team_dropdown.tsx renders the spinner when - // isLoading and this copy otherwise). Asserting on it means a regression - // where teams DO load for this user fails here instead of racing a one-shot - // count() against an in-flight request. + // "Loading teams…" while teams load and only swaps in "No teams found" once + // the request resolves with nothing (team_dropdown.tsx passes both copies to + // PaginatedSearchSelect). Asserting on it means a regression where teams DO + // load for this user fails here instead of racing a one-shot count() against + // an in-flight request. await expect(dropdown.getByText("No teams found")).toBeVisible({ timeout: 10_000 }); await expect(dropdown.getByRole("option")).toHaveCount(0); }); diff --git a/tests/e2e/ui/tests/internal-user/internalUserWithTeams.spec.ts b/tests/e2e/ui/tests/internal-user/internalUserWithTeams.spec.ts index 7d5058a8140..68319154554 100644 --- a/tests/e2e/ui/tests/internal-user/internalUserWithTeams.spec.ts +++ b/tests/e2e/ui/tests/internal-user/internalUserWithTeams.spec.ts @@ -18,10 +18,10 @@ test.describe("Internal User with team memberships", () => { await page.getByRole("button", { name: /Create New Key/i }).click(); await expect(page.getByText("Key Ownership")).toBeVisible({ timeout: 10_000 }); - const teamSelect = page.locator(".ant-select", { hasText: "Search or select a team" }); + const teamSelect = page.getByTestId("team-dropdown").getByRole("combobox"); await teamSelect.click(); - const dropdown = page.locator(".ant-select-dropdown:visible").first(); + const dropdown = page.locator('[data-slot="combobox-content"]:visible').first(); await expect(dropdown).toBeVisible({ timeout: 5_000 }); // Both seeded memberships render, and nothing else does — proving the diff --git a/tests/e2e/ui/tests/modelsPage/addModel.spec.ts b/tests/e2e/ui/tests/modelsPage/addModel.spec.ts index 461d9dfd9f8..1b11ea69f97 100644 --- a/tests/e2e/ui/tests/modelsPage/addModel.spec.ts +++ b/tests/e2e/ui/tests/modelsPage/addModel.spec.ts @@ -328,11 +328,11 @@ test.describe("Add Model", () => { const teamByokRow = page.locator(".ant-form-item", { hasText: "Team-BYOK Model" }); await teamByokRow.getByRole("switch").click(); - // TeamDropdown's options carry custom markup and no role="option", so match by text. - const teamDropdown = page.getByTestId("team-dropdown"); + // TeamDropdown options show the alias above the team id, so match on the id line by text. + const teamDropdown = page.getByTestId("team-dropdown").getByRole("combobox"); await expect(teamDropdown).toBeVisible({ timeout: 5_000 }); await teamDropdown.click(); - const teamOption = page.locator(".ant-select-dropdown:visible").getByText(E2E_TEAM_CRUD_ID).first(); + const teamOption = page.locator('[data-slot="combobox-content"]:visible').getByText(E2E_TEAM_CRUD_ID).first(); await expect(teamOption).toBeVisible({ timeout: 5_000 }); await teamOption.click(); diff --git a/tests/e2e/ui/tests/proxy-admin/keys.spec.ts b/tests/e2e/ui/tests/proxy-admin/keys.spec.ts index 1ff3ef274b6..d9b0f959c9f 100644 --- a/tests/e2e/ui/tests/proxy-admin/keys.spec.ts +++ b/tests/e2e/ui/tests/proxy-admin/keys.spec.ts @@ -40,11 +40,11 @@ test.describe("Proxy Admin - Keys", () => { const keyName = `e2e-admin-key-${Date.now()}`; await page.getByTestId("base-input").fill(keyName); - // Select team — the team dropdown has placeholder "Search or select a team" - const teamSelect = page.locator(".ant-select", { hasText: "Search or select a team" }); + // Select team + const teamSelect = page.getByTestId("team-dropdown").getByRole("combobox"); await teamSelect.click(); await page.keyboard.type(E2E_TEAM_CRUD_ALIAS); - await page.locator(".ant-select-dropdown:visible").getByText(E2E_TEAM_CRUD_ALIAS).first().click(); + await page.locator('[data-slot="combobox-content"]:visible').getByText(E2E_TEAM_CRUD_ALIAS).first().click(); // Select models await page.locator(".ant-select-selection-overflow").click(); @@ -157,7 +157,7 @@ test.describe("Proxy Admin - Keys", () => { await page.getByRole("button", { name: "More key actions" }).click(); await page.getByRole("menuitem", { name: "Delete Key" }).click(); - const modal = page.locator(".ant-modal:visible"); + const modal = page.getByRole("dialog", { name: "Delete Key" }); await expect(modal).toBeVisible({ timeout: 5_000 }); await modal.locator("input").fill(E2E_DELETE_KEY_ALIAS); diff --git a/tests/e2e/ui/tests/proxy-admin/teams.spec.ts b/tests/e2e/ui/tests/proxy-admin/teams.spec.ts index 92d22f11f4d..37693c5d49d 100644 --- a/tests/e2e/ui/tests/proxy-admin/teams.spec.ts +++ b/tests/e2e/ui/tests/proxy-admin/teams.spec.ts @@ -129,7 +129,7 @@ test.describe("Proxy Admin - Teams", () => { await teamRow.locator('[data-testid^="team-actions-"]').click(); await page.getByTestId("team-action-delete").click(); - const modal = page.locator(".ant-modal:visible"); + const modal = page.getByRole("dialog", { name: "Delete Team?" }); await expect(modal).toBeVisible({ timeout: 5_000 }); await modal.locator("input").fill(E2E_TEAM_DELETE_ALIAS); await modal.getByRole("button", { name: /Force Delete|Delete/i }).click(); diff --git a/tests/e2e/ui/tests/team-admin/teamAdmin.spec.ts b/tests/e2e/ui/tests/team-admin/teamAdmin.spec.ts index be4526f7089..d71d5e6c0fe 100644 --- a/tests/e2e/ui/tests/team-admin/teamAdmin.spec.ts +++ b/tests/e2e/ui/tests/team-admin/teamAdmin.spec.ts @@ -105,7 +105,7 @@ test.describe("Team Admin", () => { await expect(row).toBeVisible({ timeout: 10_000 }); await row.getByTestId("delete-member").click(); - const modal = page.locator(".ant-modal:visible"); + const modal = page.getByRole("dialog", { name: "Delete Team Member" }); await expect(modal).toBeVisible({ timeout: 5_000 }); const remove = await captureRequestBody(page, { method: "POST", urlIncludes: "/team/member_delete" }, async () => { @@ -139,10 +139,10 @@ test.describe("Team Admin", () => { await page.getByTestId("base-input").fill(keyName); // Team selector — same locator pattern as the proxy-admin keys test. - const teamSelect = page.locator(".ant-select", { hasText: "Search or select a team" }); + const teamSelect = page.getByTestId("team-dropdown").getByRole("combobox"); await teamSelect.click(); await page.keyboard.type(E2E_TEAM_CRUD_ALIAS); - await page.locator(".ant-select-dropdown:visible").getByText(E2E_TEAM_CRUD_ALIAS).first().click(); + await page.locator('[data-slot="combobox-content"]:visible').getByText(E2E_TEAM_CRUD_ALIAS).first().click(); // Models — pick "All Team Models" await page.locator(".ant-select-selection-overflow").click(); diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 5e322598a10..346fa4eaee5 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -2377,15 +2377,7 @@ "count": 1 } }, - "src/components/common_components/DefaultProxyAdminTag.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/common_components/DeleteResourceModal.tsx": { - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -2434,17 +2426,11 @@ } }, "src/components/common_components/ModelAliasManager.tsx": { - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 1 } }, "src/components/common_components/ModelSelector.tsx": { - "no-restricted-imports": { - "count": 2 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -2454,14 +2440,6 @@ "count": 1 } }, - "src/components/common_components/OrganizationDropdown.tsx": { - "local/no-complex-jsx-arrow": { - "count": 1 - }, - "no-restricted-imports": { - "count": 1 - } - }, "src/components/common_components/PassThroughGuardrailsSection.tsx": { "no-restricted-imports": { "count": 2 @@ -2470,29 +2448,11 @@ "count": 1 } }, - "src/components/common_components/PassThroughRoutesSelector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/common_components/PassThroughSecuritySection.tsx": { "no-restricted-imports": { "count": 2 } }, - "src/components/common_components/PremiumLoggingSettings.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "src/components/common_components/ProjectDropdown.tsx": { - "local/no-complex-jsx-arrow": { - "count": 1 - }, - "no-restricted-imports": { - "count": 1 - } - }, "src/components/common_components/RateLimitTypeFormItem.test.tsx": { "no-restricted-imports": { "count": 1 @@ -2503,22 +2463,9 @@ "count": 1 } }, - "src/components/common_components/RouterSettingsAccordion.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/common_components/budget_duration_dropdown.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 - } - }, - "src/components/common_components/chartUtils.test.tsx": { - "no-restricted-imports": { - "count": 1 } }, "src/components/common_components/chartUtils.tsx": { @@ -2527,9 +2474,6 @@ }, "no-nested-ternary": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/components/common_components/check_openapi_schema.tsx": { @@ -2554,17 +2498,11 @@ }, "no-nested-ternary": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/components/common_components/team_dropdown.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/components/common_components/team_multi_select.tsx": { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.test.tsx index a1484ffb5c5..5e212901bd1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.test.tsx @@ -1,4 +1,5 @@ import { renderWithProviders, screen, within } from "@/../tests/test-utils"; +import { waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { AccessGroupsPage } from "./AccessGroupsPage"; @@ -215,7 +216,9 @@ describe("AccessGroupsPage", () => { await user.click(await openRowMenu(user, "ag-1")); const dialog = screen.getByRole("dialog", { name: "Delete Access Group" }); await user.click(within(dialog).getByRole("button", { name: "Cancel" })); - expect(screen.queryByRole("dialog", { name: "Delete Access Group" })).not.toBeInTheDocument(); + await waitFor(() => { + expect(screen.queryByRole("dialog", { name: "Delete Access Group" })).not.toBeInTheDocument(); + }); expect(mockMutate).not.toHaveBeenCalled(); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.test.tsx index 237e3b2c842..29c9b17cb21 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.test.tsx @@ -244,7 +244,7 @@ describe("UserInfoView", () => { }); // The DeleteResourceModal's OK button has text "Delete" - find it within the modal - const modal = screen.getByText("Remove from Team").closest(".ant-modal") as HTMLElement; + const modal = screen.getByRole("dialog", { name: "Remove from Team" }); const deleteConfirmButton = within(modal).getByRole("button", { name: /delete/i }); await user.click(deleteConfirmButton); diff --git a/ui/litellm-dashboard/src/components/common_components/DefaultProxyAdminTag.tsx b/ui/litellm-dashboard/src/components/common_components/DefaultProxyAdminTag.tsx index e42e3cee6da..9ec24bb929b 100644 --- a/ui/litellm-dashboard/src/components/common_components/DefaultProxyAdminTag.tsx +++ b/ui/litellm-dashboard/src/components/common_components/DefaultProxyAdminTag.tsx @@ -1,6 +1,4 @@ -import { Tag, Typography } from "antd"; - -const { Text } = Typography; +import { Badge } from "@/components/ui/badge"; const DEFAULT_USER_ID = "default_user_id"; @@ -8,15 +6,10 @@ interface DefaultProxyAdminTagProps { userId: string | null | undefined; } -/** - * Renders "Default Proxy Admin" as a blue Tag when the given userId is - * the well-known `default_user_id`, otherwise renders the raw value as - * plain text. - */ export default function DefaultProxyAdminTag({ userId }: DefaultProxyAdminTagProps) { if (userId === DEFAULT_USER_ID) { - return Default Proxy Admin; + return Default Proxy Admin; } - return {userId}; + return {userId}; } diff --git a/ui/litellm-dashboard/src/components/common_components/DeleteResourceModal.tsx b/ui/litellm-dashboard/src/components/common_components/DeleteResourceModal.tsx index 5a6160483a0..b45f164b2a5 100644 --- a/ui/litellm-dashboard/src/components/common_components/DeleteResourceModal.tsx +++ b/ui/litellm-dashboard/src/components/common_components/DeleteResourceModal.tsx @@ -1,6 +1,10 @@ -import { Alert, Card, Descriptions, Input, Modal, Typography, theme } from "antd"; -import { ExclamationCircleOutlined } from "@ant-design/icons"; +import { CircleAlert } from "lucide-react"; import React, { useState, useEffect } from "react"; +import { Alert, AlertTitle } from "@/components/shared/Alert"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"; interface DeleteResourceModalProps { isOpen: boolean; @@ -8,12 +12,11 @@ interface DeleteResourceModalProps { alertMessage?: string; message: string; resourceInformationTitle?: string; - resourceInformation?: Array< - { - label: string; - value: string | number | undefined | null; - } & Omit, "children"> - >; + resourceInformation?: Array<{ + label: string; + value: string | number | undefined | null; + code?: boolean; + }>; onCancel: () => void; onOk: () => void; confirmLoading: boolean; @@ -32,8 +35,6 @@ export default function DeleteResourceModal({ confirmLoading, requiredConfirmation, }: DeleteResourceModalProps) { - const { Text } = Typography; - const { token } = theme.useToken(); const [requiredConfirmationInput, setRequiredConfirmationInput] = useState(""); useEffect(() => { @@ -43,69 +44,69 @@ export default function DeleteResourceModal({ }, [isOpen]); return ( - -
- {alertMessage && } - - - {resourceInformation && - resourceInformation.map(({ label, value, ...textProps }) => ( - {label}}> - {value ?? "-"} - - ))} - - -
- {message} -
- {requiredConfirmation && ( -
- - Type - - {requiredConfirmation} - - to confirm deletion: - - setRequiredConfirmationInput(e.target.value)} - placeholder={requiredConfirmation} - className="rounded-md" - prefix={} - autoFocus - /> + !open && onCancel()}> + + + {title} + +
+ {alertMessage && ( + + {alertMessage} + + )} + + {resourceInformationTitle && ( + + {resourceInformationTitle} + + )} + +
+ {resourceInformation?.map(({ label, value, code }) => ( + +
{label}
+
{code ? {value ?? "-"} : value ?? "-"}
+
+ ))} +
+
+
+
+ {message}
- )} -
- + {requiredConfirmation && ( +
+

+ Type {requiredConfirmation} to confirm deletion: +

+ + + + + setRequiredConfirmationInput(e.target.value)} + placeholder={requiredConfirmation} + autoFocus + /> + +
+ )} +
+ + + + + + ); } diff --git a/ui/litellm-dashboard/src/components/common_components/ModelAliasManager.tsx b/ui/litellm-dashboard/src/components/common_components/ModelAliasManager.tsx index c3540ce757a..9b89d85507b 100644 --- a/ui/litellm-dashboard/src/components/common_components/ModelAliasManager.tsx +++ b/ui/litellm-dashboard/src/components/common_components/ModelAliasManager.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from "react"; import { PlusCircleIcon, PencilIcon, TrashIcon } from "@heroicons/react/outline"; -import { Card, Title, Text, Table, TableHead, TableHeaderCell, TableBody, TableRow, TableCell } from "@tremor/react"; +import { Card, CardTitle } from "@/components/ui/card"; +import { Table, TableHeader, TableHead, TableBody, TableRow, TableCell } from "@/components/ui/table"; import ModelSelector from "./ModelSelector"; import NotificationsManager from "../molecules/notifications_manager"; @@ -141,7 +142,7 @@ const ModelAliasManager: React.FC = ({ return (
- Add New Alias +

Add New Alias

@@ -186,17 +187,17 @@ const ModelAliasManager: React.FC = ({
- Manage Existing Aliases +

Manage Existing Aliases

- + - Alias Name - Target Model - Actions + Alias Name + Target Model + Actions - + {aliases.map((alias) => ( @@ -284,9 +285,9 @@ const ModelAliasManager: React.FC = ({ {/* Configuration Example */} {showExampleConfig && ( - - Configuration Example - Here's how your current aliases would look in the config: + + Configuration Example +

Here's how your current aliases would look in the config:

model_aliases: diff --git a/ui/litellm-dashboard/src/components/common_components/ModelSelector.test.tsx b/ui/litellm-dashboard/src/components/common_components/ModelSelector.test.tsx index 857e4d3296b..bd7b56f1817 100644 --- a/ui/litellm-dashboard/src/components/common_components/ModelSelector.test.tsx +++ b/ui/litellm-dashboard/src/components/common_components/ModelSelector.test.tsx @@ -1,28 +1,20 @@ import { act, fireEvent, render, screen } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; import ModelSelector from "./ModelSelector"; vi.mock("@/components/llm_calls/fetch_models", () => ({ fetchAvailableModels: vi.fn().mockResolvedValue([]), })); -const openCustomModelInput = () => { - const selector = document.querySelector(".ant-select-selector"); - expect(selector).toBeTruthy(); - act(() => { - fireEvent.mouseDown(selector!); - }); - act(() => { - fireEvent.click(screen.getByText("Enter custom model")); - }); +const openCustomModelInput = async () => { + const user = userEvent.setup(); + await user.click(screen.getByRole("combobox")); + await user.click(await screen.findByText("Enter custom model")); return screen.getByPlaceholderText("Enter custom model name"); }; describe("ModelSelector custom model debounce", () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - afterEach(() => { act(() => { vi.runOnlyPendingTimers(); @@ -30,11 +22,12 @@ describe("ModelSelector custom model debounce", () => { vi.useRealTimers(); }); - it("does not call onChange before the debounce wait elapses", () => { + it("does not call onChange before the debounce wait elapses", async () => { const onChange = vi.fn(); render(); - const input = openCustomModelInput(); + const input = await openCustomModelInput(); + vi.useFakeTimers(); act(() => { fireEvent.change(input, { target: { value: "gpt-4o" } }); @@ -49,11 +42,12 @@ describe("ModelSelector custom model debounce", () => { expect(onChange).not.toHaveBeenCalled(); }); - it("calls onChange exactly once with the last typed value after the wait", () => { + it("calls onChange exactly once with the last typed value after the wait", async () => { const onChange = vi.fn(); render(); - const input = openCustomModelInput(); + const input = await openCustomModelInput(); + vi.useFakeTimers(); act(() => { fireEvent.change(input, { target: { value: "g" } }); @@ -71,11 +65,12 @@ describe("ModelSelector custom model debounce", () => { expect(onChange).toHaveBeenCalledWith("gpt-5.2"); }); - it("does not call onChange when unmounted mid-wait", () => { + it("does not call onChange when unmounted mid-wait", async () => { const onChange = vi.fn(); const { unmount } = render(); - const input = openCustomModelInput(); + const input = await openCustomModelInput(); + vi.useFakeTimers(); act(() => { fireEvent.change(input, { target: { value: "gpt-4o" } }); diff --git a/ui/litellm-dashboard/src/components/common_components/ModelSelector.tsx b/ui/litellm-dashboard/src/components/common_components/ModelSelector.tsx index f2621cd1acb..a50131256fa 100644 --- a/ui/litellm-dashboard/src/components/common_components/ModelSelector.tsx +++ b/ui/litellm-dashboard/src/components/common_components/ModelSelector.tsx @@ -1,8 +1,8 @@ import React, { useState, useEffect } from "react"; -import { TextInput, Text } from "@tremor/react"; -import { Select } from "antd"; -import { RobotOutlined } from "@ant-design/icons"; +import { Bot } from "lucide-react"; import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer"; +import { Input } from "@/components/ui/input"; +import { SearchSelect } from "@/components/shared/SearchSelect"; import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models"; const MODEL_SELECT_DEBOUNCE_MS = 500; @@ -80,32 +80,30 @@ const ModelSelector: React.FC = ({ return (
{showLabel && ( - - {labelText} - +

+ {labelText} +

)} - { - if (!option) return false; - const org = organizations?.find((o) => o.organization_id === option.key); - if (!org) return false; - - const searchTerm = input.toLowerCase().trim(); - const orgAlias = (org.organization_alias || "").toLowerCase(); - const orgId = (org.organization_id || "").toLowerCase(); - - return orgAlias.includes(searchTerm) || orgId.includes(searchTerm); - }} - > - {organizations?.map((org) => ( - - {org.organization_alias}{" "} - ({org.organization_id}) - - ))} - +
+ ({ + label: org.organization_alias || org.organization_id, + value: org.organization_id, + sublabel: org.organization_id, + }))} + value={value} + onValueChange={(organizationId) => onChange?.(organizationId)} + placeholder={placeholder} + emptyText={loading ? "Loading organizations…" : "No organizations found"} + disabled={disabled} + inputId={id} + /> +
); }; diff --git a/ui/litellm-dashboard/src/components/common_components/PassThroughRoutesSelector.tsx b/ui/litellm-dashboard/src/components/common_components/PassThroughRoutesSelector.tsx index e02125dea56..330f852383d 100644 --- a/ui/litellm-dashboard/src/components/common_components/PassThroughRoutesSelector.tsx +++ b/ui/litellm-dashboard/src/components/common_components/PassThroughRoutesSelector.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from "react"; -import { Select } from "antd"; +import { MultiSelect, type MultiSelectOption } from "@/components/shared/MultiSelect"; import { getPassThroughEndpointsCall } from "../networking"; interface PassThroughRoutesSelectorProps { @@ -17,6 +17,11 @@ interface PassThroughEndpoint { methods?: string[]; } +const routeOption = (endpoint: PassThroughEndpoint): MultiSelectOption => ({ + label: endpoint.methods?.length ? `${endpoint.methods.join(", ")} ${endpoint.path}` : endpoint.path, + value: endpoint.path, +}); + const PassThroughRoutesSelector: React.FC = ({ onChange, value, @@ -26,7 +31,7 @@ const PassThroughRoutesSelector: React.FC = ({ disabled = false, teamId, }) => { - const [passThroughRoutes, setPassThroughRoutes] = useState>([]); + const [passThroughRoutes, setPassThroughRoutes] = useState([]); const [loading, setLoading] = useState(false); useEffect(() => { @@ -37,27 +42,7 @@ const PassThroughRoutesSelector: React.FC = ({ try { const response = await getPassThroughEndpointsCall(accessToken, teamId); if (response.endpoints) { - const routes = response.endpoints.flatMap((endpoint: PassThroughEndpoint) => { - const path = endpoint.path; - const methods = endpoint.methods; - - // If methods are specified, create one entry per method - if (methods && methods.length > 0) { - return methods.map((method) => ({ - label: `${method} ${path}`, - value: path, // Keep value as path for backward compatibility - })); - } - - // If no methods specified, show just the path (all methods supported) - return [ - { - label: path, - value: path, - }, - ]; - }); - setPassThroughRoutes(routes); + setPassThroughRoutes(response.endpoints.map(routeOption)); } } catch (error) { console.error("Error fetching pass through routes:", error); @@ -70,19 +55,16 @@ const PassThroughRoutesSelector: React.FC = ({ }, [accessToken, teamId]); return ( - ({ + label: project.project_alias || project.project_id, + value: project.project_id, + sublabel: project.project_id, + })) + } value={value} - onChange={onChange} + onValueChange={(projectId) => onChange?.(projectId)} + placeholder="Search or select a project" + emptyText={loading ? "Loading projects…" : "No projects found"} disabled={disabled} - loading={loading} - allowClear - notFoundContent={loading ? } size="small" /> : undefined} - filterOption={(input, option) => { - if (!option) return false; - const project = filtered?.find((p) => p.project_id === option.key); - if (!project) return false; - - const searchTerm = input.toLowerCase().trim(); - const alias = (project.project_alias || "").toLowerCase(); - const id = (project.project_id || "").toLowerCase(); - - return alias.includes(searchTerm) || id.includes(searchTerm); - }} - optionFilterProp="children" - > - {!loading && - filtered?.map((project) => ( - - {project.project_alias || project.project_id}{" "} - ({project.project_id}) - - ))} - + inputId={id} + /> ); }; diff --git a/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.test.tsx b/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.test.tsx index 5ac3b8b2b64..6f819607e3f 100644 --- a/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.test.tsx +++ b/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.test.tsx @@ -21,14 +21,6 @@ vi.mock("../Settings/RouterSettings/Fallbacks/FallbackSelectionForm", () => ({ ), })); -vi.mock("@tremor/react", () => ({ - TabGroup: ({ children }: { children: ReactNode }) =>
{children}
, - TabList: ({ children }: { children: ReactNode }) =>
{children}
, - Tab: ({ children }: { children: ReactNode }) =>
{children}
, - TabPanels: ({ children }: { children: ReactNode }) =>
{children}
, - TabPanel: ({ children }: { children: ReactNode }) =>
{children}
, -})); - vi.mock("../router_settings/RouterSettingsForm", () => ({ default: ({ value, diff --git a/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.tsx b/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.tsx index 56227abe9ea..7570806182d 100644 --- a/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.tsx +++ b/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState, useImperativeHandle, forwardRef, useRef } from "react"; -import { TabPanel, TabPanels, TabGroup, TabList, Tab } from "@tremor/react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useQuery } from "@tanstack/react-query"; import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer"; import { getRouterSettingsCall } from "../networking"; @@ -344,13 +344,13 @@ const RouterSettingsAccordion = forwardRef - - - Loadbalancing - Fallbacks - - - + + + Loadbalancing + Fallbacks + +
+ - - + + - - - + +
+
); }, diff --git a/ui/litellm-dashboard/src/components/common_components/budget_duration_dropdown.tsx b/ui/litellm-dashboard/src/components/common_components/budget_duration_dropdown.tsx index 4db36f27553..e283f0550ec 100644 --- a/ui/litellm-dashboard/src/components/common_components/budget_duration_dropdown.tsx +++ b/ui/litellm-dashboard/src/components/common_components/budget_duration_dropdown.tsx @@ -1,10 +1,16 @@ import React from "react"; -import { Select } from "antd"; - -const { Option } = Select; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; export const NEVER_RESETS_BUDGET_DURATION = "none"; +const DURATION_LABELS: Record = { + [NEVER_RESETS_BUDGET_DURATION]: "Never resets", + "1h": "hourly", + "24h": "daily", + "7d": "weekly", + "30d": "monthly", +}; + interface BudgetDurationDropdownProps { value?: string | null; onChange?: (value: string | undefined) => void; @@ -24,18 +30,21 @@ const BudgetDurationDropdown: React.FC = ({ }) => { return ( ); }; diff --git a/ui/litellm-dashboard/src/components/common_components/chartUtils.test.tsx b/ui/litellm-dashboard/src/components/common_components/chartUtils.test.tsx index b924021863a..01a4d8373c4 100644 --- a/ui/litellm-dashboard/src/components/common_components/chartUtils.test.tsx +++ b/ui/litellm-dashboard/src/components/common_components/chartUtils.test.tsx @@ -1,9 +1,11 @@ import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import { CustomLegend, CustomTooltip } from "./chartUtils"; -import type { CustomTooltipProps } from "@tremor/react"; +import type { ChartTooltipProps } from "@/components/shared/charts/chart_tooltip"; import { SpendMetrics } from "../UsagePage/types"; +type TooltipPayload = NonNullable; + describe("CustomTooltip", () => { const mockPayload = [ { @@ -28,9 +30,9 @@ describe("CustomTooltip", () => { ]; it("should render", () => { - const props: CustomTooltipProps = { + const props: ChartTooltipProps = { active: true, - payload: mockPayload, + payload: mockPayload as unknown as TooltipPayload, label: "2024-01-15", }; render(); @@ -38,9 +40,9 @@ describe("CustomTooltip", () => { }); it("should return null when not active", () => { - const props: CustomTooltipProps = { + const props: ChartTooltipProps = { active: false, - payload: mockPayload, + payload: mockPayload as unknown as TooltipPayload, label: "2024-01-15", }; const { container } = render(); @@ -48,9 +50,9 @@ describe("CustomTooltip", () => { }); it("should return null when payload is empty", () => { - const props: CustomTooltipProps = { + const props: ChartTooltipProps = { active: true, - payload: [], + payload: [] as unknown as TooltipPayload, label: "2024-01-15", }; const { container } = render(); @@ -58,9 +60,9 @@ describe("CustomTooltip", () => { }); it("should display formatted category names", () => { - const props: CustomTooltipProps = { + const props: ChartTooltipProps = { active: true, - payload: mockPayload, + payload: mockPayload as unknown as TooltipPayload, label: "2024-01-15", }; render(); @@ -89,9 +91,9 @@ describe("CustomTooltip", () => { }, }, ]; - const props: CustomTooltipProps = { + const props: ChartTooltipProps = { active: true, - payload: payloadWithUnderscores, + payload: payloadWithUnderscores as unknown as TooltipPayload, label: "2024-01-15", }; render(); @@ -120,9 +122,9 @@ describe("CustomTooltip", () => { }, }, ]; - const props: CustomTooltipProps = { + const props: ChartTooltipProps = { active: true, - payload: spendPayload, + payload: spendPayload as unknown as TooltipPayload, label: "2024-01-15", }; render(); @@ -130,9 +132,9 @@ describe("CustomTooltip", () => { }); it("should format non-spend numeric values with locale string", () => { - const props: CustomTooltipProps = { + const props: ChartTooltipProps = { active: true, - payload: mockPayload, + payload: mockPayload as unknown as TooltipPayload, label: "2024-01-15", }; render(); @@ -161,9 +163,9 @@ describe("CustomTooltip", () => { }, }, ]; - const props: CustomTooltipProps = { + const props: ChartTooltipProps = { active: true, - payload: payloadWithUndefined, + payload: payloadWithUndefined as unknown as TooltipPayload, label: "2024-01-15", }; render(); @@ -211,9 +213,9 @@ describe("CustomTooltip", () => { }, }, ]; - const props: CustomTooltipProps = { + const props: ChartTooltipProps = { active: true, - payload: multiplePayload, + payload: multiplePayload as unknown as TooltipPayload, label: "2024-01-15", }; render(); @@ -222,9 +224,9 @@ describe("CustomTooltip", () => { }); it("should convert color names to hex values", () => { - const props: CustomTooltipProps = { + const props: ChartTooltipProps = { active: true, - payload: mockPayload, + payload: mockPayload as unknown as TooltipPayload, label: "2024-01-15", }; const { container } = render(); @@ -254,9 +256,9 @@ describe("CustomTooltip", () => { }, }, ]; - const props: CustomTooltipProps = { + const props: ChartTooltipProps = { active: true, - payload: payloadWithHexColor, + payload: payloadWithHexColor as unknown as TooltipPayload, label: "2024-01-15", }; const { container } = render(); @@ -286,9 +288,9 @@ describe("CustomTooltip", () => { }, }, ]; - const props: CustomTooltipProps = { + const props: ChartTooltipProps = { active: true, - payload: payloadWithoutDataKey as any, + payload: payloadWithoutDataKey as unknown as TooltipPayload, label: "2024-01-15", }; render(); @@ -304,9 +306,9 @@ describe("CustomTooltip", () => { payload: undefined, }, ]; - const props: CustomTooltipProps = { + const props: ChartTooltipProps = { active: true, - payload: payloadWithoutPayload as any, + payload: payloadWithoutPayload as unknown as TooltipPayload, label: "2024-01-15", }; render(); diff --git a/ui/litellm-dashboard/src/components/common_components/chartUtils.tsx b/ui/litellm-dashboard/src/components/common_components/chartUtils.tsx index c0930f290f6..0abf004803b 100644 --- a/ui/litellm-dashboard/src/components/common_components/chartUtils.tsx +++ b/ui/litellm-dashboard/src/components/common_components/chartUtils.tsx @@ -1,4 +1,4 @@ -import type { CustomTooltipProps } from "@tremor/react"; +import type { ChartTooltipProps } from "@/components/shared/charts/chart_tooltip"; import { SpendMetrics } from "../UsagePage/types"; interface ChartDataPoint { @@ -16,7 +16,7 @@ const colorNameToHex: { [key: string]: string } = { emerald: "#37bc7d", }; -export const CustomTooltip = ({ active, payload, label }: CustomTooltipProps) => { +export const CustomTooltip = ({ active, payload, label }: ChartTooltipProps) => { if (active && payload && payload.length) { const formatCategoryName = (name: string): string => { return name diff --git a/ui/litellm-dashboard/src/components/common_components/routerSettingsWiring.test.tsx b/ui/litellm-dashboard/src/components/common_components/routerSettingsWiring.test.tsx index d6ed0ae07db..15c52e71e8f 100644 --- a/ui/litellm-dashboard/src/components/common_components/routerSettingsWiring.test.tsx +++ b/ui/litellm-dashboard/src/components/common_components/routerSettingsWiring.test.tsx @@ -1,6 +1,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen, waitFor } from "@testing-library/react"; -import type { ReactElement, ReactNode } from "react"; +import type { ReactElement } from "react"; import { describe, expect, it, vi } from "vitest"; import type { FallbackGroup } from "../Settings/RouterSettings/Fallbacks/FallbackGroupConfig"; import type { RouterSettingsFormValue } from "../router_settings/RouterSettingsForm"; @@ -16,14 +16,6 @@ vi.mock("@/components/llm_calls/fetch_models", () => ({ fetchAvailableModelsForTeam: vi.fn().mockResolvedValue([]), })); -vi.mock("@tremor/react", () => ({ - TabGroup: ({ children }: { children: ReactNode }) =>
{children}
, - TabList: ({ children }: { children: ReactNode }) =>
{children}
, - Tab: ({ children }: { children: ReactNode }) =>
{children}
, - TabPanels: ({ children }: { children: ReactNode }) =>
{children}
, - TabPanel: ({ children }: { children: ReactNode }) =>
{children}
, -})); - vi.mock("../router_settings/RouterSettingsForm", () => ({ default: ({ value }: { value: RouterSettingsFormValue }) => (
{JSON.stringify(value.routerSettings)}
diff --git a/ui/litellm-dashboard/src/components/common_components/simple_table.tsx b/ui/litellm-dashboard/src/components/common_components/simple_table.tsx index 4a858a346d4..17e8d46d21d 100644 --- a/ui/litellm-dashboard/src/components/common_components/simple_table.tsx +++ b/ui/litellm-dashboard/src/components/common_components/simple_table.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Table, TableHead, TableRow, TableHeaderCell, TableBody, TableCell, Text } from "@tremor/react"; +import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"; export interface SimpleTableColumn { header: string; @@ -31,20 +31,20 @@ export function SimpleTable({ }: SimpleTableProps) { return (
- + {columns.map((column, index) => ( - + {column.header} - + ))} - + {isLoading ? ( - {loadingMessage} + {loadingMessage} ) : data.length > 0 ? ( @@ -60,7 +60,7 @@ export function SimpleTable({ ) : ( - {emptyMessage} + {emptyMessage} )} diff --git a/ui/litellm-dashboard/src/components/common_components/team_dropdown.tsx b/ui/litellm-dashboard/src/components/common_components/team_dropdown.tsx index 7d27886c7f5..35121f41598 100644 --- a/ui/litellm-dashboard/src/components/common_components/team_dropdown.tsx +++ b/ui/litellm-dashboard/src/components/common_components/team_dropdown.tsx @@ -1,13 +1,8 @@ -import React, { useMemo, useState, type UIEvent } from "react"; -import { Select, Typography } from "antd"; -import { LoadingOutlined } from "@ant-design/icons"; -import { useDebouncedState } from "@tanstack/react-pacer/debouncer"; +import React, { useMemo, useState } from "react"; +import { PaginatedSearchSelect } from "@/components/shared/PaginatedSearchSelect"; import { useInfiniteTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; -import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; import { Team } from "../key_team_helpers/key_list"; -const { Text } = Typography; - interface TeamDropdownProps { value?: string; onChange?: (value: string) => void; @@ -17,10 +12,9 @@ interface TeamDropdownProps { /** Filter teams by organization. */ organizationId?: string | null; pageSize?: number; + id?: string; } -const SCROLL_THRESHOLD = 0.8; - const TeamDropdown: React.FC = ({ value, onChange, @@ -28,15 +22,13 @@ const TeamDropdown: React.FC = ({ disabled, organizationId, pageSize = 20, + id, }) => { - const [searchInput, setSearchInput] = useState(""); - const [debouncedSearch, setDebouncedSearch] = useDebouncedState("", { - wait: DEBOUNCE_WAIT_MS, - }); + const [search, setSearch] = useState(""); const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteTeams( pageSize, - debouncedSearch || undefined, + search || undefined, organizationId, ); @@ -54,59 +46,35 @@ const TeamDropdown: React.FC = ({ return result; }, [data]); - const handlePopupScroll = (e: UIEvent) => { - const target = e.currentTarget; - const scrollRatio = (target.scrollTop + target.clientHeight) / target.scrollHeight; - if (scrollRatio >= SCROLL_THRESHOLD && hasNextPage && !isFetchingNextPage) { - fetchNextPage(); - } - }; - - const handleSearch = (val: string) => { - setSearchInput(val); - setDebouncedSearch(val); - }; - - const handleChange = (teamId: string | undefined) => { - onChange?.(teamId ?? ""); + const handleChange = (teamId: string) => { + onChange?.(teamId); if (onTeamSelect) { - const team = teamId ? teams.find((t) => t.team_id === teamId) ?? null : null; - onTeamSelect(team); + onTeamSelect(teamId ? teams.find((t) => t.team_id === teamId) ?? null : null); } }; return ( - +
+ ({ + label: team.team_alias || team.team_id, + value: team.team_id, + sublabel: team.team_id, + }))} + value={value || undefined} + onValueChange={handleChange} + onSearchChange={setSearch} + onLoadMore={fetchNextPage} + hasNextPage={hasNextPage} + isLoading={isLoading} + isFetchingNextPage={isFetchingNextPage} + placeholder="Search or select a team" + emptyText="No teams found" + loadingText="Loading teams…" + disabled={disabled} + inputId={id} + /> +
); }; diff --git a/ui/litellm-dashboard/src/components/shared/SearchSelect.tsx b/ui/litellm-dashboard/src/components/shared/SearchSelect.tsx index f67a1cffa1d..1bfc19cbbe2 100644 --- a/ui/litellm-dashboard/src/components/shared/SearchSelect.tsx +++ b/ui/litellm-dashboard/src/components/shared/SearchSelect.tsx @@ -24,6 +24,7 @@ interface SearchSelectProps { emptyText?: string; disabled?: boolean; className?: string; + inputId?: string; } const matchesQuery = (option: SearchSelectOption, query: string): boolean => { @@ -40,6 +41,7 @@ export function SearchSelect({ emptyText = "No results", disabled = false, className, + inputId, }: SearchSelectProps) { const selected = options.find((option) => option.value === value) ?? null; @@ -54,6 +56,7 @@ export function SearchSelect({ disabled={disabled} > { const user = userEvent.setup({ delay: null }); const resetBudgetItem = await openSettingsEditorForTeam(user, { budget_duration: "30d" }); - const clearIcon = resetBudgetItem.querySelector(".ant-select-clear"); - expect(clearIcon).not.toBeNull(); - fireEvent.mouseDown(clearIcon as Element); + await user.click(within(resetBudgetItem).getByRole("combobox")); + await user.click(await screen.findByText("Never resets")); await waitFor(() => { expect(within(resetBudgetItem).getByText("Never resets")).toBeInTheDocument(); @@ -1554,13 +1553,14 @@ describe("TeamInfoView", () => { await user.click(within(routesFormItem).getByRole("combobox")); - const option = await screen.findByTitle("POST /bedrock-passthrough"); + const option = await screen.findByText("POST /bedrock-passthrough"); await user.click(option); await waitFor(() => { expect(within(routesFormItem).getByText(/\/bedrock-passthrough/)).toBeInTheDocument(); }); + await user.keyboard("{Escape}"); await user.click(screen.getByRole("button", { name: /save changes/i })); await waitFor(() => { diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx index caad6fc30fc..cdbd3197f7d 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx @@ -961,9 +961,8 @@ describe("KeyEditView", () => { ); const resetBudgetItem = (await screen.findByText("Reset Budget")).closest(".ant-form-item") as HTMLElement; - const clearIcon = resetBudgetItem.querySelector(".ant-select-clear"); - expect(clearIcon).not.toBeNull(); - fireEvent.mouseDown(clearIcon as Element); + await userEvent.click(within(resetBudgetItem).getByRole("combobox")); + await userEvent.click(await screen.findByText("Never resets")); await waitFor(() => { expect(within(resetBudgetItem).getByText("Never resets")).toBeInTheDocument(); @@ -995,7 +994,8 @@ describe("KeyEditView", () => { ); const resetBudgetItem = (await screen.findByText("Reset Budget")).closest(".ant-form-item") as HTMLElement; - fireEvent.mouseDown(resetBudgetItem.querySelector(".ant-select-clear") as Element); + await userEvent.click(within(resetBudgetItem).getByRole("combobox")); + await userEvent.click(await screen.findByText("Never resets")); await userEvent.click(screen.getByRole("button", { name: /save changes/i })); @@ -1251,9 +1251,10 @@ describe("KeyEditView", () => { expect(screen.getByText("Organization")).toBeInTheDocument(); }); - const orgFormItem = screen.getByText("Organization").closest(".ant-form-item"); - const disabledSelect = orgFormItem?.querySelector(".ant-select-disabled"); - expect(disabledSelect).toBeTruthy(); + const orgFormItem = screen.getByText("Organization").closest(".ant-form-item") as HTMLElement; + await userEvent.click(within(orgFormItem).getByRole("combobox")); + + expect(screen.queryByText("Engineering")).not.toBeInTheDocument(); }); it("should not disable the organization dropdown for admin users", async () => { @@ -1273,9 +1274,10 @@ describe("KeyEditView", () => { expect(screen.getByText("Organization")).toBeInTheDocument(); }); - const orgFormItem = screen.getByText("Organization").closest(".ant-form-item"); - const disabledSelect = orgFormItem?.querySelector(".ant-select-disabled"); - expect(disabledSelect).toBeFalsy(); + const orgFormItem = screen.getByText("Organization").closest(".ant-form-item") as HTMLElement; + await userEvent.click(within(orgFormItem).getByRole("combobox")); + + expect(await screen.findByText("Engineering")).toBeInTheDocument(); }); it("should initialize organization from keyData", async () => { @@ -1296,8 +1298,9 @@ describe("KeyEditView", () => { />, ); + const orgFormItem = (await screen.findByText("Organization")).closest(".ant-form-item") as HTMLElement; await waitFor(() => { - expect(screen.getByText("Engineering")).toBeInTheDocument(); + expect(within(orgFormItem).getByRole("combobox")).toHaveValue("Engineering"); }); }); }); From 0ee47a00283926bf0ac7a89b801b5513dee9084f Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 14 Aug 2026 06:12:28 -0700 Subject: [PATCH 30/49] test(ui): cover appending a second model in ModelSelect The rewritten suite only ever picked one ordinary model, so a regression that replaced the selection instead of appending to it would have gone unnoticed. The case passes against the antd version too, so it pins behavior the migration preserves rather than adds. --- .../components/ModelSelect/ModelSelect.test.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.test.tsx b/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.test.tsx index eeaeac541bd..34a21122027 100644 --- a/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.test.tsx +++ b/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.test.tsx @@ -140,6 +140,23 @@ describe("ModelSelect", () => { expect(mockOnChange).toHaveBeenCalledWith(["gpt-4"]); }); + it("should append a second model to the existing selection", async () => { + const user = userEvent.setup(); + renderWithProviders( + , + ); + + await openModelList(user); + await user.click(screen.getAllByText("claude-3")[0]); + + expect(mockOnChange).toHaveBeenCalledWith(["gpt-4", "claude-3"]); + }); + it("should offer both special options when they are enabled", async () => { const user = userEvent.setup(); mockUseOrganization.mockReturnValue({ From d7ec4d98b100b90cf6c08f10cd2c310d839c2d33 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 14 Aug 2026 06:22:06 -0700 Subject: [PATCH 31/49] test(ui): spread the real lucide-react module in the KeyInfoView mock The mock returned only CopyIcon and CheckIcon, so any icon a child later imports resolves to undefined. DeleteResourceModal now renders CircleAlert, which broke all twelve cases in this file. --- .../templates/KeyInfoView.handleKeyUpdate.test.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx b/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx index 374e36029a0..b87beed048a 100644 --- a/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx @@ -182,7 +182,8 @@ vi.mock("@heroicons/react/outline", async () => { return { ArrowLeftIcon, TrashIcon, RefreshIcon }; }); -vi.mock("lucide-react", async () => { +vi.mock("lucide-react", async (importOriginal) => { + const actual = await importOriginal(); const React = await import("react"); function CopyIcon() { return React.createElement("span"); @@ -192,7 +193,7 @@ vi.mock("lucide-react", async () => { return React.createElement("span"); } (CheckIcon as any).displayName = "CheckIcon"; - return { CopyIcon, CheckIcon }; + return { ...actual, CopyIcon, CheckIcon }; }); // Heavy children -> async factories & local React From 362875a7e695fb4a14f11526667f34e14e787bb9 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 14 Aug 2026 06:29:49 -0700 Subject: [PATCH 32/49] fix(ui): hold the delete dialog open mid-deletion and keep unmatched select values DeleteResourceModal let escape, the backdrop and the close button dismiss it while the delete request was still in flight. SearchSelect blanked its field whenever the value was missing from options, which happens while they load; it now falls back to the raw value the way PaginatedSearchSelect already did. --- .../common_components/DeleteResourceModal.test.tsx | 14 ++++++++++++++ .../common_components/DeleteResourceModal.tsx | 2 +- .../src/components/shared/SearchSelect.test.tsx | 7 +++++++ .../src/components/shared/SearchSelect.tsx | 9 +++++++-- 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/ui/litellm-dashboard/src/components/common_components/DeleteResourceModal.test.tsx b/ui/litellm-dashboard/src/components/common_components/DeleteResourceModal.test.tsx index e27a60cc866..465f7fcfcf0 100644 --- a/ui/litellm-dashboard/src/components/common_components/DeleteResourceModal.test.tsx +++ b/ui/litellm-dashboard/src/components/common_components/DeleteResourceModal.test.tsx @@ -159,6 +159,20 @@ describe("DeleteResourceModal", () => { expect(cancelButton).toBeDisabled(); }); + it("should call onCancel when escape is pressed and no deletion is in flight", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await user.keyboard("{Escape}"); + expect(mockOnCancel).toHaveBeenCalled(); + }); + + it("should ignore escape while confirmLoading is true so the modal cannot close mid-deletion", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await user.keyboard("{Escape}"); + expect(mockOnCancel).not.toHaveBeenCalled(); + }); + it("should disable delete button when confirmLoading is true even if requiredConfirmation matches", async () => { const user = userEvent.setup(); renderWithProviders(); diff --git a/ui/litellm-dashboard/src/components/common_components/DeleteResourceModal.tsx b/ui/litellm-dashboard/src/components/common_components/DeleteResourceModal.tsx index b45f164b2a5..42baae3d86c 100644 --- a/ui/litellm-dashboard/src/components/common_components/DeleteResourceModal.tsx +++ b/ui/litellm-dashboard/src/components/common_components/DeleteResourceModal.tsx @@ -44,7 +44,7 @@ export default function DeleteResourceModal({ }, [isOpen]); return ( - !open && onCancel()}> + !open && !confirmLoading && onCancel()}> {title} diff --git a/ui/litellm-dashboard/src/components/shared/SearchSelect.test.tsx b/ui/litellm-dashboard/src/components/shared/SearchSelect.test.tsx index acf50d282b4..5e8d63eda08 100644 --- a/ui/litellm-dashboard/src/components/shared/SearchSelect.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/SearchSelect.test.tsx @@ -21,6 +21,13 @@ describe("SearchSelect", () => { expect(screen.getByRole("combobox")).toHaveValue("Growth"); }); + it("shows a value the options do not carry yet instead of blanking the field", () => { + const { rerender } = render(); + expect(screen.getByRole("combobox")).toHaveValue("team-2"); + rerender(); + expect(screen.getByRole("combobox")).toHaveValue("Growth"); + }); + it("shows a clear control only when a value is selected", () => { const { rerender } = render(); expect(document.querySelector('[data-slot="combobox-clear"]')).toBeNull(); diff --git a/ui/litellm-dashboard/src/components/shared/SearchSelect.tsx b/ui/litellm-dashboard/src/components/shared/SearchSelect.tsx index 1bfc19cbbe2..c6ae11f5729 100644 --- a/ui/litellm-dashboard/src/components/shared/SearchSelect.tsx +++ b/ui/litellm-dashboard/src/components/shared/SearchSelect.tsx @@ -43,11 +43,16 @@ export function SearchSelect({ className, inputId, }: SearchSelectProps) { - const selected = options.find((option) => option.value === value) ?? null; + const selected = + value === undefined || value === "" + ? null + : options.find((option) => option.value === value) ?? { label: value, value }; + const items = + selected !== null && !options.some((option) => option.value === selected.value) ? [selected, ...options] : options; return ( onValueChange(item?.value ?? "")} isItemEqualToValue={(a: SearchSelectOption, b: SearchSelectOption) => a.value === b.value} From 15a331f6df9051ae4251e4159bdcbf51c9a60d82 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 14 Aug 2026 06:52:06 -0700 Subject: [PATCH 33/49] refactor(ui): move the root-level dashboard components onto shadcn primitives Rebuilds nine components under src/components on the in-repo shadcn layer: both banners, the navbar chrome, the onboarding link dialog, the model filters, the model group alias table, the object permissions and logging settings views, and the user dashboard grid. Every public prop signature is unchanged, so no caller moves. --- ui/litellm-dashboard/eslint-suppressions.json | 31 ------- .../src/components/DebugWarningBanner.tsx | 26 +++--- .../components/LicenseExpiryBanner.test.tsx | 21 +++-- .../src/components/LicenseExpiryBanner.tsx | 30 +++--- .../src/components/logging_settings_view.tsx | 22 +++-- .../src/components/model_filters.tsx | 12 +-- .../components/model_group_alias_settings.tsx | 39 ++++---- .../src/components/navbar.tsx | 18 ++-- .../components/object_permissions_view.tsx | 17 ++-- .../src/components/onboarding_link.test.tsx | 91 ++++++++++++++++++- .../src/components/onboarding_link.tsx | 65 ++++++------- .../src/components/user_dashboard.tsx | 9 +- 12 files changed, 223 insertions(+), 158 deletions(-) diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 5e322598a10..2e72df1225b 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1784,11 +1784,6 @@ "count": 1 } }, - "src/components/DebugWarningBanner.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/DeprecationBanner.tsx": { "no-restricted-imports": { "count": 1 @@ -1840,11 +1835,6 @@ "count": 1 } }, - "src/components/LicenseExpiryBanner.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/ModelSelect/ModelSelect.tsx": { "no-restricted-imports": { "count": 1 @@ -2696,9 +2686,6 @@ "src/components/logging_settings_view.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/components/mcp_server_management/MCPServerSelector.tsx": { @@ -2764,18 +2751,12 @@ "src/components/model_filters.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/components/model_group_alias_settings.tsx": { "local/filename-pascal-case": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -2837,9 +2818,6 @@ "src/components/navbar.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/components/networking.tsx": { @@ -2865,17 +2843,11 @@ "src/components/object_permissions_view.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/components/onboarding_link.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 2 } }, "src/components/organisms/RegenerateKeyModal.tsx": { @@ -3486,9 +3458,6 @@ "local/filename-pascal-case": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "prefer-const": { "count": 1 }, diff --git a/ui/litellm-dashboard/src/components/DebugWarningBanner.tsx b/ui/litellm-dashboard/src/components/DebugWarningBanner.tsx index 94474e78b14..9591fe4bb1f 100644 --- a/ui/litellm-dashboard/src/components/DebugWarningBanner.tsx +++ b/ui/litellm-dashboard/src/components/DebugWarningBanner.tsx @@ -1,7 +1,8 @@ "use client"; import React from "react"; -import { Alert } from "antd"; +import { TriangleAlert } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert"; import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails"; interface DebugWarningBannerProps { @@ -17,19 +18,14 @@ export const DebugWarningBanner: React.FC = ({ accessTo } return ( - - Detailed debug logging (LITELLM_LOG=DEBUG) is currently enabled. This mode logs extensive - diagnostic information and will significantly degrade performance. It should only be used for troubleshooting - and disabled in production environments. - - } - type="warning" - showIcon - banner - style={{ marginBottom: 0, borderRadius: 0 }} - /> + + + Performance Warning: Detailed Debug Mode Active + + Detailed debug logging (LITELLM_LOG=DEBUG) is currently enabled. This mode logs extensive + diagnostic information and will significantly degrade performance. It should only be used for troubleshooting + and disabled in production environments. + + ); }; diff --git a/ui/litellm-dashboard/src/components/LicenseExpiryBanner.test.tsx b/ui/litellm-dashboard/src/components/LicenseExpiryBanner.test.tsx index d6b419ace7c..627d108e65f 100644 --- a/ui/litellm-dashboard/src/components/LicenseExpiryBanner.test.tsx +++ b/ui/litellm-dashboard/src/components/LicenseExpiryBanner.test.tsx @@ -42,18 +42,22 @@ describe("LicenseExpiryBannerView", () => { expect(container).toBeEmptyDOMElement(); }); - it("shows a dismissible amber warning within 30 days", () => { + it("shows a dismissible warning within 30 days", () => { const { container } = render(); + expect(screen.getByRole("alert")).toBeInTheDocument(); + expect(container.querySelector(".lucide-triangle-alert")).toBeInTheDocument(); expect(screen.getByText(/expires in 20 days/)).toBeInTheDocument(); - expect(container.querySelector(".ant-alert-warning")).toBeInTheDocument(); - expect(screen.queryByRole("button")).toBeInTheDocument(); + expect(screen.getByText(/Renew before it lapses to keep enterprise features/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /close/i })).toBeInTheDocument(); expect(screen.getByRole("link", { name: "sales@berri.ai" })).toHaveAttribute("href", "mailto:sales@berri.ai"); }); - it("shows a non-dismissible red critical alert within 7 days", () => { + it("shows a non-dismissible critical alert within 7 days", () => { const { container } = render(); + expect(screen.getByRole("alert")).toBeInTheDocument(); + expect(container.querySelector(".lucide-circle-alert")).toBeInTheDocument(); expect(screen.getByText(/expires in 5 days/)).toBeInTheDocument(); - expect(container.querySelector(".ant-alert-error")).toBeInTheDocument(); + expect(screen.getByText(/Renew now to avoid losing enterprise features/)).toBeInTheDocument(); expect(screen.queryByRole("button")).not.toBeInTheDocument(); }); @@ -62,18 +66,19 @@ describe("LicenseExpiryBannerView", () => { expect(screen.getByText(/expires today/)).toBeInTheDocument(); }); - it("shows a non-dismissible red expired alert stating features are disabled", () => { + it("shows a non-dismissible expired alert stating features are disabled", () => { const { container } = render(); + expect(screen.getByRole("alert")).toBeInTheDocument(); + expect(container.querySelector(".lucide-circle-alert")).toBeInTheDocument(); expect(screen.getByText(/expired on/)).toBeInTheDocument(); expect(screen.getByText(/features are now disabled/i)).toBeInTheDocument(); - expect(container.querySelector(".ant-alert-error")).toBeInTheDocument(); expect(screen.queryByRole("button")).not.toBeInTheDocument(); }); it("hides the warning after dismissal and stays hidden within the session", () => { const expiration = daysFromNow(20); const { unmount } = render(); - fireEvent.click(screen.getByRole("button")); + fireEvent.click(screen.getByRole("button", { name: /close/i })); expect(screen.queryByText(/expires in 20 days/)).not.toBeInTheDocument(); unmount(); diff --git a/ui/litellm-dashboard/src/components/LicenseExpiryBanner.tsx b/ui/litellm-dashboard/src/components/LicenseExpiryBanner.tsx index c3b20b5fac0..5867a45bc31 100644 --- a/ui/litellm-dashboard/src/components/LicenseExpiryBanner.tsx +++ b/ui/litellm-dashboard/src/components/LicenseExpiryBanner.tsx @@ -1,7 +1,9 @@ "use client"; import React, { useState } from "react"; -import { Alert } from "antd"; +import { CircleAlert, TriangleAlert, X } from "lucide-react"; +import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/shared/Alert"; +import { Button } from "@/components/ui/button"; import { LicenseInfo } from "@/components/networking"; import { useLicenseInfo } from "@/app/(dashboard)/hooks/license/useLicenseInfo"; import { formatExpiryDate, getDaysUntilExpiration, getLicenseExpiryTier } from "@/utils/licenseUtils"; @@ -76,16 +78,22 @@ export const LicenseExpiryBannerView: React.FC = ( }; return ( - + + {tier === "warning" ? ( + + ) : ( + + )} + {message} + {description} + {isDismissible && ( + + + + )} + ); }; diff --git a/ui/litellm-dashboard/src/components/logging_settings_view.tsx b/ui/litellm-dashboard/src/components/logging_settings_view.tsx index 97eca9d6247..ac3d688308d 100644 --- a/ui/litellm-dashboard/src/components/logging_settings_view.tsx +++ b/ui/litellm-dashboard/src/components/logging_settings_view.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Tag } from "antd"; +import { Badge } from "@/components/ui/badge"; import { CogIcon, BanIcon } from "@heroicons/react/outline"; import { callbackInfo, callback_map, reverse_callback_map } from "./callback_info_helpers"; import { Logo } from "@/components/molecules/logo/Logo"; @@ -29,16 +29,16 @@ export function LoggingSettingsView({ return callbackDisplayName || callbackName; }; - const getEventTypeColor = (eventType: string): string | undefined => { + const getEventTypeVariant = (eventType: string): React.ComponentProps["variant"] => { switch (eventType) { case "success": - return "green"; + return "default"; case "failure": - return "red"; + return "destructive"; case "success_and_failure": - return "blue"; + return "secondary"; default: - return undefined; + return "outline"; } }; @@ -62,7 +62,7 @@ export function LoggingSettingsView({
Logging Integrations - {loggingConfigs.length} + {loggingConfigs.length}
{loggingConfigs.length > 0 ? ( @@ -88,7 +88,9 @@ export function LoggingSettingsView({ - {getEventTypeLabel(config.callback_type)} + + {getEventTypeLabel(config.callback_type)} + ); })} @@ -106,7 +108,7 @@ export function LoggingSettingsView({
Disabled Callbacks - {disabledCallbacks.length} + {disabledCallbacks.length}
{disabledCallbacks.length > 0 ? ( @@ -131,7 +133,7 @@ export function LoggingSettingsView({ Disabled for this key - Disabled + Disabled ); })} diff --git a/ui/litellm-dashboard/src/components/model_filters.tsx b/ui/litellm-dashboard/src/components/model_filters.tsx index bc9ca6bab44..5db144a6be4 100644 --- a/ui/litellm-dashboard/src/components/model_filters.tsx +++ b/ui/litellm-dashboard/src/components/model_filters.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useMemo, useRef } from "react"; -import { Card, Text } from "@tremor/react"; +import { Card } from "@/components/ui/card"; interface ModelGroupInfo { model_group: string; @@ -125,7 +125,7 @@ const ModelFilters: React.FC = ({ const filtersContent = (
- Search Models: +

Search Models:

= ({ />
- Provider: +

Provider:

- Mode: +

Mode:

- Features: +

Features:

- + - Alias Name - Target Model Group - Actions + Alias Name + Target Model Group + Actions - + {aliases.map((alias) => ( @@ -275,8 +276,12 @@ const ModelGroupAliasSettings: React.FC = ({ ) : ( <> - {alias.aliasName} - {alias.targetModelGroup} + + {alias.aliasName} + + + {alias.targetModelGroup} +
{/* Configuration Example */} - - Configuration Example - - Here's how your current aliases would look in the config.yaml: - + + Configuration Example +

Here's how your current aliases would look in the config.yaml:

router_settings: diff --git a/ui/litellm-dashboard/src/components/navbar.tsx b/ui/litellm-dashboard/src/components/navbar.tsx index c999ee8035e..6ce92fb9449 100644 --- a/ui/litellm-dashboard/src/components/navbar.tsx +++ b/ui/litellm-dashboard/src/components/navbar.tsx @@ -8,8 +8,8 @@ import { useTheme } from "@/contexts/ThemeContext"; import { clearTokenCookies } from "@/utils/cookieUtils"; import { clearStoredReturnUrl, getLoginUrl } from "@/utils/returnUrlUtils"; import useProxySettings from "@/app/(dashboard)/hooks/proxySettings/useProxySettings"; -import { DownOutlined, MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons"; -import { Tag } from "antd"; +import { Badge } from "@/components/ui/badge"; +import { ChevronDown, PanelLeftClose, PanelLeftOpen } from "lucide-react"; import Link from "next/link"; import React from "react"; import { BlogDropdown } from "./Navbar/BlogDropdown/BlogDropdown"; @@ -71,7 +71,13 @@ const Navbar: React.FC = ({ className="mr-2 flex h-9 w-9 items-center justify-center rounded-md text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900" title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"} > - {sidebarCollapsed ? : } + + {sidebarCollapsed ? ( + + ) : ( + + )} + )} @@ -98,7 +104,7 @@ const Navbar: React.FC = ({ 🌑 )} - + = ({ > v{version} - +
)}
@@ -138,7 +144,7 @@ const Navbar: React.FC = ({ > Docs {/* Layout parity with Blog chevron — intentional single-level link */} - + diff --git a/ui/litellm-dashboard/src/components/object_permissions_view.tsx b/ui/litellm-dashboard/src/components/object_permissions_view.tsx index 327e127e8fe..c7baa3d52c2 100644 --- a/ui/litellm-dashboard/src/components/object_permissions_view.tsx +++ b/ui/litellm-dashboard/src/components/object_permissions_view.tsx @@ -1,5 +1,4 @@ import React from "react"; -import { Text } from "@tremor/react"; import VectorStorePermissions from "./permissions/VectorStorePermissions"; import MCPServerPermissions from "./permissions/MCPServerPermissions"; import AgentPermissions from "./permissions/AgentPermissions"; @@ -38,14 +37,14 @@ export function ObjectPermissionsView({ accessToken={accessToken} /> -
- Search tools +
+

Search tools

{searchTools.length === 0 ? ( - +

No restriction — all configured search tools are allowed for this team. - +

) : ( - {searchTools.join(", ")} +

{searchTools.join(", ")}

)}
@@ -56,8 +55,8 @@ export function ObjectPermissionsView({
- Object Permissions - Access control for Vector Stores and MCP Servers +

Object Permissions

+

Access control for Vector Stores and MCP Servers

{content} @@ -67,7 +66,7 @@ export function ObjectPermissionsView({ return (
- Object Permissions +

Object Permissions

{content}
); diff --git a/ui/litellm-dashboard/src/components/onboarding_link.test.tsx b/ui/litellm-dashboard/src/components/onboarding_link.test.tsx index 039d5e250da..a7d5a2cd4f6 100644 --- a/ui/litellm-dashboard/src/components/onboarding_link.test.tsx +++ b/ui/litellm-dashboard/src/components/onboarding_link.test.tsx @@ -1,5 +1,22 @@ -import { describe, it, expect } from "vitest"; -import { buildOnboardingUrl } from "./onboarding_link"; +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import OnboardingModal, { buildOnboardingUrl, InvitationLink } from "./onboarding_link"; + +vi.mock("./molecules/notifications_manager", () => ({ default: { success: vi.fn() } })); + +const invitation: InvitationLink = { + id: "inv-123", + user_id: "user-abc", + is_accepted: false, + accepted_at: null, + expires_at: new Date("2026-09-01"), + created_at: new Date("2026-08-01"), + created_by: "admin", + updated_at: new Date("2026-08-01"), + updated_by: "admin", + has_user_setup_sso: false, +}; describe("buildOnboardingUrl", () => { it("points the invitation link at the dedicated /ui/onboarding route", () => { @@ -68,3 +85,73 @@ describe("buildOnboardingUrl", () => { ).toBe(""); }); }); + +describe("OnboardingModal", () => { + it("renders nothing until it is opened", () => { + render( + , + ); + + expect(screen.queryByText("http://localhost:4000/ui/onboarding?invitation_id=inv-123")).not.toBeInTheDocument(); + }); + + it("shows the invitation url, the user id and an invitation-flavoured copy button", async () => { + render( + , + ); + + expect(await screen.findByText("http://localhost:4000/ui/onboarding?invitation_id=inv-123")).toBeInTheDocument(); + expect(screen.getByText("user-abc")).toBeInTheDocument(); + expect(screen.getAllByText("Invitation Link").length).toBeGreaterThan(0); + expect(screen.getByRole("button", { name: "Copy invitation link" })).toBeInTheDocument(); + expect(screen.getByText(/Copy and send the generated link to onboard this user/)).toBeInTheDocument(); + }); + + it("switches every label and the url to the reset-password flow", async () => { + render( + , + ); + + expect( + await screen.findByText("http://localhost:4000/ui/onboarding?invitation_id=inv-123&action=reset_password"), + ).toBeInTheDocument(); + expect(screen.getAllByText("Reset Password Link").length).toBeGreaterThan(0); + expect(screen.getByRole("button", { name: "Copy password reset link" })).toBeInTheDocument(); + expect( + screen.getByText(/Copy and send the generated link to the user to reset their password/), + ).toBeInTheDocument(); + }); + + it("closes through setIsInvitationLinkModalVisible when the close control is used", async () => { + const user = userEvent.setup(); + const setVisible = vi.fn(); + render( + , + ); + + await user.click(await screen.findByRole("button", { name: /close/i })); + + expect(setVisible).toHaveBeenCalledWith(false); + }); +}); diff --git a/ui/litellm-dashboard/src/components/onboarding_link.tsx b/ui/litellm-dashboard/src/components/onboarding_link.tsx index b5f3c6d3e17..f24e3709794 100644 --- a/ui/litellm-dashboard/src/components/onboarding_link.tsx +++ b/ui/litellm-dashboard/src/components/onboarding_link.tsx @@ -1,7 +1,7 @@ import React from "react"; -import { Button, Modal, Typography } from "antd"; import { CopyToClipboard } from "react-copy-to-clipboard"; -import { Text } from "@tremor/react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import NotificationsManager from "./molecules/notifications_manager"; export interface InvitationLink { @@ -58,10 +58,7 @@ export default function OnboardingModal({ invitationLinkData, modalType = "invitation", }: OnboardingProps) { - const { Paragraph } = Typography; - const handleInvitationOk = () => { - setIsInvitationLinkModalVisible(false); - }; + const linkLabel = modalType === "invitation" ? "Invitation Link" : "Reset Password Link"; const handleInvitationCancel = () => { setIsInvitationLinkModalVisible(false); @@ -76,36 +73,30 @@ export default function OnboardingModal({ }); return ( - - - {modalType === "invitation" - ? "Copy and send the generated link to onboard this user to the proxy." - : "Copy and send the generated link to the user to reset their password."} - -
- User ID - {invitationLinkData?.user_id} -
-
- {modalType === "invitation" ? "Invitation Link" : "Reset Password Link"} - - {getInvitationUrl()} - -
-
- NotificationsManager.success("Copied!")}> - - -
-
+ !open && handleInvitationCancel()}> + + + {linkLabel} + +

+ {modalType === "invitation" + ? "Copy and send the generated link to onboard this user to the proxy." + : "Copy and send the generated link to the user to reset their password."} +

+
+ User ID + {invitationLinkData?.user_id} +
+
+ {linkLabel} + {getInvitationUrl()} +
+
+ NotificationsManager.success("Copied!")}> + + +
+
+
); } diff --git a/ui/litellm-dashboard/src/components/user_dashboard.tsx b/ui/litellm-dashboard/src/components/user_dashboard.tsx index 1de232fadb8..ce5337aa7a7 100644 --- a/ui/litellm-dashboard/src/components/user_dashboard.tsx +++ b/ui/litellm-dashboard/src/components/user_dashboard.tsx @@ -1,6 +1,5 @@ "use client"; import { clearTokenCookies, getCookie } from "@/utils/cookieUtils"; -import { Col, Grid } from "@tremor/react"; import { jwtDecode } from "jwt-decode"; import React, { useEffect, useState } from "react"; import { fetchTeams } from "./common_components/fetch_teams"; @@ -218,8 +217,8 @@ const UserDashboard: React.FC = ({ return (
- -
+
+
= ({ ) : undefined } /> - - +
+
); }; From 4544f7dbaddef304799718c7f3899f1bf95b7ba4 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 14 Aug 2026 07:10:13 -0700 Subject: [PATCH 34/49] revert(ui): keep the onboarding link modal on antd The invitation dialog opens over the still-antd Invite User modal. Lifting only the shadcn dialog content above antd's mask leaves its own backdrop underneath, so an outside click reaches the wrong modal. Adding a second backdrop stops that but does not restore dismissal, and the same hazard already ships in three guardrails modals, so the stacking needs one shared fix rather than a fourth local workaround. --- ui/litellm-dashboard/eslint-suppressions.json | 3 + .../src/components/onboarding_link.test.tsx | 91 +------------------ .../src/components/onboarding_link.tsx | 65 +++++++------ 3 files changed, 42 insertions(+), 117 deletions(-) diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 2e72df1225b..e0ad8ca1606 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -2848,6 +2848,9 @@ "src/components/onboarding_link.tsx": { "local/filename-pascal-case": { "count": 1 + }, + "no-restricted-imports": { + "count": 2 } }, "src/components/organisms/RegenerateKeyModal.tsx": { diff --git a/ui/litellm-dashboard/src/components/onboarding_link.test.tsx b/ui/litellm-dashboard/src/components/onboarding_link.test.tsx index a7d5a2cd4f6..039d5e250da 100644 --- a/ui/litellm-dashboard/src/components/onboarding_link.test.tsx +++ b/ui/litellm-dashboard/src/components/onboarding_link.test.tsx @@ -1,22 +1,5 @@ -import { describe, it, expect, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import OnboardingModal, { buildOnboardingUrl, InvitationLink } from "./onboarding_link"; - -vi.mock("./molecules/notifications_manager", () => ({ default: { success: vi.fn() } })); - -const invitation: InvitationLink = { - id: "inv-123", - user_id: "user-abc", - is_accepted: false, - accepted_at: null, - expires_at: new Date("2026-09-01"), - created_at: new Date("2026-08-01"), - created_by: "admin", - updated_at: new Date("2026-08-01"), - updated_by: "admin", - has_user_setup_sso: false, -}; +import { describe, it, expect } from "vitest"; +import { buildOnboardingUrl } from "./onboarding_link"; describe("buildOnboardingUrl", () => { it("points the invitation link at the dedicated /ui/onboarding route", () => { @@ -85,73 +68,3 @@ describe("buildOnboardingUrl", () => { ).toBe(""); }); }); - -describe("OnboardingModal", () => { - it("renders nothing until it is opened", () => { - render( - , - ); - - expect(screen.queryByText("http://localhost:4000/ui/onboarding?invitation_id=inv-123")).not.toBeInTheDocument(); - }); - - it("shows the invitation url, the user id and an invitation-flavoured copy button", async () => { - render( - , - ); - - expect(await screen.findByText("http://localhost:4000/ui/onboarding?invitation_id=inv-123")).toBeInTheDocument(); - expect(screen.getByText("user-abc")).toBeInTheDocument(); - expect(screen.getAllByText("Invitation Link").length).toBeGreaterThan(0); - expect(screen.getByRole("button", { name: "Copy invitation link" })).toBeInTheDocument(); - expect(screen.getByText(/Copy and send the generated link to onboard this user/)).toBeInTheDocument(); - }); - - it("switches every label and the url to the reset-password flow", async () => { - render( - , - ); - - expect( - await screen.findByText("http://localhost:4000/ui/onboarding?invitation_id=inv-123&action=reset_password"), - ).toBeInTheDocument(); - expect(screen.getAllByText("Reset Password Link").length).toBeGreaterThan(0); - expect(screen.getByRole("button", { name: "Copy password reset link" })).toBeInTheDocument(); - expect( - screen.getByText(/Copy and send the generated link to the user to reset their password/), - ).toBeInTheDocument(); - }); - - it("closes through setIsInvitationLinkModalVisible when the close control is used", async () => { - const user = userEvent.setup(); - const setVisible = vi.fn(); - render( - , - ); - - await user.click(await screen.findByRole("button", { name: /close/i })); - - expect(setVisible).toHaveBeenCalledWith(false); - }); -}); diff --git a/ui/litellm-dashboard/src/components/onboarding_link.tsx b/ui/litellm-dashboard/src/components/onboarding_link.tsx index f24e3709794..b5f3c6d3e17 100644 --- a/ui/litellm-dashboard/src/components/onboarding_link.tsx +++ b/ui/litellm-dashboard/src/components/onboarding_link.tsx @@ -1,7 +1,7 @@ import React from "react"; +import { Button, Modal, Typography } from "antd"; import { CopyToClipboard } from "react-copy-to-clipboard"; -import { Button } from "@/components/ui/button"; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Text } from "@tremor/react"; import NotificationsManager from "./molecules/notifications_manager"; export interface InvitationLink { @@ -58,7 +58,10 @@ export default function OnboardingModal({ invitationLinkData, modalType = "invitation", }: OnboardingProps) { - const linkLabel = modalType === "invitation" ? "Invitation Link" : "Reset Password Link"; + const { Paragraph } = Typography; + const handleInvitationOk = () => { + setIsInvitationLinkModalVisible(false); + }; const handleInvitationCancel = () => { setIsInvitationLinkModalVisible(false); @@ -73,30 +76,36 @@ export default function OnboardingModal({ }); return ( - !open && handleInvitationCancel()}> - - - {linkLabel} - -

- {modalType === "invitation" - ? "Copy and send the generated link to onboard this user to the proxy." - : "Copy and send the generated link to the user to reset their password."} -

-
- User ID - {invitationLinkData?.user_id} -
-
- {linkLabel} - {getInvitationUrl()} -
-
- NotificationsManager.success("Copied!")}> - - -
-
-
+ + + {modalType === "invitation" + ? "Copy and send the generated link to onboard this user to the proxy." + : "Copy and send the generated link to the user to reset their password."} + +
+ User ID + {invitationLinkData?.user_id} +
+
+ {modalType === "invitation" ? "Invitation Link" : "Reset Password Link"} + + {getInvitationUrl()} + +
+
+ NotificationsManager.success("Copied!")}> + + +
+
); } From 3170fff768e0c196dc472b80ffb4483541bd7091 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 14 Aug 2026 07:49:33 -0700 Subject: [PATCH 35/49] refactor(ui): move the settings page and bulk user invite onto shadcn primitives Rebuilds settings.tsx and bulk_create_users_button.tsx on the in-repo shadcn layer. The settings callback form moves from antd Form to react-hook-form with the shared Field primitives, and the CSV drop zone replaces antd Upload with a native file input plus drag handlers. Both public prop signatures are unchanged, so no caller moves. --- ui/litellm-dashboard/eslint-suppressions.json | 9 - .../bulk_create_users_button.test.tsx | 49 +- .../components/bulk_create_users_button.tsx | 789 +++++++++--------- .../src/components/settings.test.tsx | 179 ++-- .../src/components/settings.tsx | 639 +++++++------- 5 files changed, 900 insertions(+), 765 deletions(-) diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 5e322598a10..aad9b63f8b3 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -2295,9 +2295,6 @@ "local/filename-pascal-case": { "count": 1 }, - "no-restricted-imports": { - "count": 2 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -3061,15 +3058,9 @@ "local/filename-pascal-case": { "count": 1 }, - "local/no-complex-jsx-arrow": { - "count": 4 - }, "no-nested-ternary": { "count": 2 }, - "no-restricted-imports": { - "count": 3 - }, "prefer-const": { "count": 4 } diff --git a/ui/litellm-dashboard/src/components/bulk_create_users_button.test.tsx b/ui/litellm-dashboard/src/components/bulk_create_users_button.test.tsx index 7397eaa6b24..fff03ed8e82 100644 --- a/ui/litellm-dashboard/src/components/bulk_create_users_button.test.tsx +++ b/ui/litellm-dashboard/src/components/bulk_create_users_button.test.tsx @@ -1,4 +1,5 @@ -import { render } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { describe, it, expect, vi } from "vitest"; import BulkCreateUsersButton from "./bulk_create_users_button"; @@ -20,9 +21,55 @@ vi.mock("./molecules/notifications_manager", () => ({ }, })); +const csvFile = () => + new File(["user_email,user_role\nnew.hire@example.com,internal_user\n"], "users.csv", { type: "text/csv" }); + +const openUploadStep = async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByText("+ Bulk Invite Users")); + return user; +}; + describe("BulkCreateUsersButton", () => { it("should render", () => { const { getByText } = render(); expect(getByText("+ Bulk Invite Users")).toBeInTheDocument(); }); + + it("parses a CSV chosen through the file input", async () => { + await openUploadStep(); + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(fileInput, { target: { files: [csvFile()] } }); + + expect(await screen.findByText("new.hire@example.com")).toBeInTheDocument(); + }); + + it("parses a CSV dropped onto the drop zone", async () => { + await openUploadStep(); + + const dropZone = screen.getByLabelText(/drag and drop your csv file here/i).closest("label"); + fireEvent.drop(dropZone as HTMLLabelElement, { dataTransfer: { files: [csvFile()], types: ["Files"] } }); + + expect(await screen.findByText("new.hire@example.com")).toBeInTheDocument(); + }); + + it("exposes the drop zone as a label for a keyboard-reachable file input", async () => { + await openUploadStep(); + + const fileInput = screen.getByLabelText(/drag and drop your csv file here/i) as HTMLInputElement; + expect(fileInput).toHaveAttribute("type", "file"); + expect(fileInput).toHaveAttribute("accept", ".csv"); + expect(fileInput).toBeVisible(); + + const dropZone = fileInput.closest("label") as HTMLLabelElement; + expect(fileInput.id).not.toBe(""); + expect(dropZone.htmlFor).toBe(fileInput.id); + + const danglingLabels = [...document.querySelectorAll("label[for]")].filter( + (label) => document.getElementById(label.getAttribute("for") as string) === null, + ); + expect(danglingLabels).toEqual([]); + }); }); diff --git a/ui/litellm-dashboard/src/components/bulk_create_users_button.tsx b/ui/litellm-dashboard/src/components/bulk_create_users_button.tsx index 8faff9ca72f..faaf2cbf7a5 100644 --- a/ui/litellm-dashboard/src/components/bulk_create_users_button.tsx +++ b/ui/litellm-dashboard/src/components/bulk_create_users_button.tsx @@ -1,14 +1,8 @@ import React, { useState, useEffect } from "react"; -import { Text } from "@tremor/react"; -import { Button, Modal, Table, Upload, Typography } from "antd"; -import { - UploadOutlined, - DownloadOutlined, - WarningOutlined, - FileTextOutlined, - DeleteOutlined, - FileExclamationOutlined, -} from "@ant-design/icons"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Download, FileText, FileWarning, Trash2, TriangleAlert, Upload } from "lucide-react"; import { userCreateCall, invitationCreateCall, getProxyUISettings } from "./networking"; import Papa from "papaparse"; import { CheckCircleIcon, XCircleIcon, ExclamationIcon } from "@heroicons/react/outline"; @@ -38,6 +32,8 @@ interface UserData { invitation_link?: string; } +const PREVIEW_PAGE_SIZE = 5; + // Define an interface for the UI settings interface UISettings { PROXY_BASE_URL: string | null; @@ -61,6 +57,9 @@ const BulkCreateUsersButton: React.FC = ({ const [selectedFile, setSelectedFile] = useState(null); const [uiSettings, setUISettings] = useState(null); const [baseUrl, setBaseUrl] = useState("http://localhost:4000"); + const [isDraggingOver, setIsDraggingOver] = useState(false); + const [pageIndex, setPageIndex] = useState(0); + const csvInputId = React.useId(); useEffect(() => { // Get UI settings @@ -93,7 +92,7 @@ const BulkCreateUsersButton: React.FC = ({ if (file.type !== "text/csv" && !file.name.endsWith(".csv")) { setFileError(`Invalid file type: ${file.name}. Please upload a CSV file (.csv extension).`); NotificationsManager.fromBackend("Invalid file type. Please upload a CSV file."); - return false; + return; } // Check file size (limit to 5MB) @@ -101,7 +100,7 @@ const BulkCreateUsersButton: React.FC = ({ setFileError( `File is too large (${(file.size / (1024 * 1024)).toFixed(1)} MB). Please upload a CSV file smaller than 5MB.`, ); - return false; + return; } Papa.parse(file, { @@ -262,7 +261,27 @@ const BulkCreateUsersButton: React.FC = ({ }, header: false, }); - return false; + }; + + const handleFileInputChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + handleFileUpload(file); + } + }; + + const handleDragOver = (event: React.DragEvent) => { + event.preventDefault(); + setIsDraggingOver(true); + }; + + const handleDrop = (event: React.DragEvent) => { + event.preventDefault(); + setIsDraggingOver(false); + const file = event.dataTransfer.files?.[0]; + if (file) { + handleFileUpload(file); + } }; const removeSelectedFile = () => { @@ -273,6 +292,12 @@ const BulkCreateUsersButton: React.FC = ({ setFileError(null); }; + const resetParsedData = () => { + setParsedData([]); + setParseError(null); + setPageIndex(0); + }; + const handleBulkCreate = async () => { setIsProcessing(true); const updatedData = parsedData.map((user) => ({ ...user, status: "pending" })); @@ -434,340 +459,395 @@ const BulkCreateUsersButton: React.FC = ({ window.URL.revokeObjectURL(url); }; - const columns = [ - { - title: "Row", - dataIndex: "rowNumber", - key: "rowNumber", - width: 80, - }, - { - title: "Email", - dataIndex: "user_email", - key: "user_email", - }, - { - title: "Role", - dataIndex: "user_role", - key: "user_role", - }, - { - title: "Teams", - dataIndex: "teams", - key: "teams", - }, - { - title: "Budget", - dataIndex: "max_budget", - key: "max_budget", - }, - { - title: "Status", - key: "status", - render: (_: any, record: UserData) => { - if (!record.isValid) { - return ( -
-
- - Invalid -
- {record.error && {record.error}} -
- ); - } - if (!record.status || record.status === "pending") { - return Pending; - } - if (record.status === "success") { - return ( -
-
- - Success -
- {record.invitation_link && ( -
-
- {record.invitation_link} - NotificationsManager.success("Invitation link copied!")} - > - - -
-
- )} -
- ); - } - return ( -
-
- - Failed -
- {record.error && {JSON.stringify(record.error)}} + const renderStatusCell = (record: UserData) => { + if (!record.isValid) { + return ( +
+
+ + Invalid
- ); - }, - }, - ]; + {record.error && {record.error}} +
+ ); + } + if (!record.status || record.status === "pending") { + return Pending; + } + if (record.status === "success") { + return ( +
+
+ + Success +
+ {record.invitation_link && ( +
+
+ {record.invitation_link} + NotificationsManager.success("Invitation link copied!")} + > + + +
+
+ )} +
+ ); + } + return ( +
+
+ + Failed +
+ {record.error && {JSON.stringify(record.error)}} +
+ ); + }; + + const pageCount = Math.max(1, Math.ceil(parsedData.length / PREVIEW_PAGE_SIZE)); + const currentPage = Math.min(pageIndex, pageCount - 1); + const visibleRows = parsedData.slice(currentPage * PREVIEW_PAGE_SIZE, (currentPage + 1) * PREVIEW_PAGE_SIZE); return ( <> - - setIsModalVisible(false)} - bodyStyle={{ maxHeight: "70vh", overflow: "auto" }} - footer={null} - > -
- {/* Step indicator */} - {parsedData.length === 0 ? ( -
-
-
- 1 -
-

Download and fill the template

-
- -
-

Add multiple users at once by following these steps:

-
    -
  1. Download our CSV template
  2. -
  3. Add your users' information to the spreadsheet
  4. -
  5. Save the file and upload it here
  6. -
  7. After creation, download the results file containing the Virtual Keys for each user
  8. -
- -
-

Template Column Names

-
-
-
-
-

user_email

-

User's email address (required)

-
-
-
-
-
-

user_role

-

- User's role (one of: "proxy_admin", "proxy_admin_viewer", - "internal_user", "internal_user_viewer") -

-
-
-
-
-
-

teams

-

- Comma-separated team IDs (e.g., "team-1,team-2") -

-
-
-
-
-
-

max_budget

-

Maximum budget as a number (e.g., "100")

-
-
-
-
-
-

budget_duration

-

- Budget reset period (e.g., "30d", "1mo") -

-
-
-
-
-
-

models

-

- Comma-separated allowed models (e.g., "gpt-3.5-turbo,gpt-4") -

-
-
+ !open && setIsModalVisible(false)}> + + + Bulk Invite Users + +
+ {/* Step indicator */} + {parsedData.length === 0 ? ( +
+
+
+ 1
+

Download and fill the template

- -
+
+

Add multiple users at once by following these steps:

+
    +
  1. Download our CSV template
  2. +
  3. Add your users' information to the spreadsheet
  4. +
  5. Save the file and upload it here
  6. +
  7. After creation, download the results file containing the Virtual Keys for each user
  8. +
-
-
- 2 -
-

Upload your completed CSV

-
- -
- {selectedFile ? ( -
-
-
- {fileError ? ( - - ) : ( - - )} +
+

Template Column Names

+
+
+
- - {selectedFile.name} - - - {(selectedFile.size / 1024).toFixed(1)} KB • {new Date().toLocaleDateString()} - +

user_email

+

User's email address (required)

- -
- - {fileError ? ( -
- - {fileError} -
- ) : ( - !csvStructureError && ( -
-
-
-
- Processing... +
+
+
+

user_role

+

+ User's role (one of: "proxy_admin", "proxy_admin_viewer", + "internal_user", "internal_user_viewer") +

+
+
+
+
+
+

teams

+

+ Comma-separated team IDs (e.g., "team-1,team-2") +

+
+
+
+
+
+

max_budget

+

Maximum budget as a number (e.g., "100")

+
+
+
+
+
+

budget_duration

+

+ Budget reset period (e.g., "30d", "1mo") +

+
+
+
+
+
+

models

+

+ Comma-separated allowed models (e.g., "gpt-3.5-turbo,gpt-4") +

- ) - )} -
- ) : ( - -
- -

Drag and drop your CSV file here

-

or

- -

Only CSV files (.csv) are supported

-
-
- )} - - {csvStructureError && ( -
-
- -
- - CSV Structure Error - - - {csvStructureError} - - - Please download our template and ensure your CSV follows the required format. -
- )} -
-
- ) : ( -
-
-
- 3 + +
-

- {parsedData.some((user) => user.status === "success" || user.status === "failed") - ? "User Creation Results" - : "Review and create users"} -

-
- {parseError && ( -
-
- -
- {parseError} - {parsedData.some((user) => !user.isValid) && ( -
    -
  • Check the table below for specific errors in each row
  • -
  • - Common issues include invalid email formats, missing required fields, or incorrect role - values -
  • -
  • Fix these issues in your CSV file and upload again
  • -
+
+
+ 2 +
+

Upload your completed CSV

+
+ +
+ {selectedFile ? ( +
+
+
+ {fileError ? ( + + ) : ( + + )} +
+ + {selectedFile.name} + + + {(selectedFile.size / 1024).toFixed(1)} KB • {new Date().toLocaleDateString()} + +
+
+ +
+ + {fileError ? ( +
+ + {fileError} +
+ ) : ( + !csvStructureError && ( +
+
+
+
+ Processing... +
+ ) )}
-
-
- )} + ) : ( + + )} -
-
-
- {parsedData.some((user) => user.status === "success" || user.status === "failed") ? ( -
- Creation Summary - - {parsedData.filter((d) => d.status === "success").length} Successful - - {parsedData.some((d) => d.status === "failed") && ( - - {parsedData.filter((d) => d.status === "failed").length} Failed - + {csvStructureError && ( +
+
+ +
+ CSV Structure Error +

{csvStructureError}

+

+ Please download our template and ensure your CSV follows the required format. +

+
+
+
+ )} +
+
+ ) : ( +
+
+
+ 3 +
+

+ {parsedData.some((user) => user.status === "success" || user.status === "failed") + ? "User Creation Results" + : "Review and create users"} +

+
+ + {parseError && ( +
+
+ +
+

{parseError}

+ {parsedData.some((user) => !user.isValid) && ( +
    +
  • Check the table below for specific errors in each row
  • +
  • + Common issues include invalid email formats, missing required fields, or incorrect role + values +
  • +
  • Fix these issues in your CSV file and upload again
  • +
)}
- ) : ( -
- User Preview - - {parsedData.filter((d) => d.isValid).length} of {parsedData.length} users valid - +
+
+ )} + +
+
+
+ {parsedData.some((user) => user.status === "success" || user.status === "failed") ? ( +
+

Creation Summary

+

+ {parsedData.filter((d) => d.status === "success").length} Successful +

+ {parsedData.some((d) => d.status === "failed") && ( +

+ {parsedData.filter((d) => d.status === "failed").length} Failed +

+ )} +
+ ) : ( +
+

User Preview

+

+ {parsedData.filter((d) => d.isValid).length} of {parsedData.length} users valid +

+
+ )} +
+ + {!parsedData.some((user) => user.status === "success" || user.status === "failed") && ( +
+ +
)}
- {!parsedData.some((user) => user.status === "success" || user.status === "failed") && ( -
+ {parsedData.some((user) => user.status === "success") && ( +
+
+
+ +
+
+

User creation complete

+

+ Next step: Download the credentials file containing + Virtual Keys and invitation links. Users will need these Virtual Keys to make LLM requests + through LiteLLM. +

+
+
+
+ )} + +
+
+ + + Row + Email + Role + Teams + Budget + Status + + + + {visibleRows.map((record) => ( + + {record.rowNumber} + {record.user_email} + {record.user_role} + {record.teams} + {record.max_budget} + {renderStatusCell(record)} + + ))} + +
+
+ + {pageCount > 1 && ( +
+ + Page {currentPage + 1} of {pageCount} + + +
+ )} + + {!parsedData.some((user) => user.status === "success" || user.status === "failed") && ( +
+
)} -
- {parsedData.some((user) => user.status === "success") && ( -
-
-
- -
-
- User creation complete - - Next step: Download the credentials file containing - Virtual Keys and invitation links. Users will need these Virtual Keys to make LLM requests - through LiteLLM. - -
+ {parsedData.some((user) => user.status === "success" || user.status === "failed") && ( +
+ +
-
- )} - - (!record.isValid ? "bg-red-50" : "")} - /> - - {!parsedData.some((user) => user.status === "success" || user.status === "failed") && ( -
- - -
- )} - - {parsedData.some((user) => user.status === "success" || user.status === "failed") && ( -
- - -
- )} + )} + - - )} - - + )} + + + ); }; diff --git a/ui/litellm-dashboard/src/components/settings.test.tsx b/ui/litellm-dashboard/src/components/settings.test.tsx index c9bcc1eb5b9..62efa1dc372 100644 --- a/ui/litellm-dashboard/src/components/settings.test.tsx +++ b/ui/litellm-dashboard/src/components/settings.test.tsx @@ -1,8 +1,8 @@ -import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { Form } from "antd"; +import { FormProvider, useForm } from "react-hook-form"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { alertingSettingsCall, getCallbackConfigsCall, getCallbacksCall } from "./networking"; +import { alertingSettingsCall, getCallbackConfigsCall, getCallbacksCall, setCallbacksCall } from "./networking"; import Settings, { backendCallbackLogoSrc, CallbackSelector } from "./settings"; vi.mock("./networking", () => ({ @@ -114,42 +114,20 @@ describe("Settings", () => { }); }); - it("should display edit modal with fields when edit is clicked", async () => { - const mockCallback = { - name: "langfuse", - variables: { - LANGFUSE_PUBLIC_KEY: "test-public-key", - LANGFUSE_SECRET_KEY: "test-secret-key", - LANGFUSE_HOST: "https://test.langfuse.com", - SLACK_WEBHOOK_URL: null, - OPENMETER_API_KEY: null, - }, - }; - - const mockCallbackConfig = { - id: "langfuse", - displayName: "Langfuse", - dynamic_params: { - LANGFUSE_PUBLIC_KEY: { - type: "text", - ui_name: "Public Key", - required: true, - }, - LANGFUSE_SECRET_KEY: { - type: "password", - ui_name: "Secret Key", - required: true, - }, - LANGFUSE_HOST: { - type: "text", - ui_name: "Host", - required: false, - }, - }, - }; - + const openLangfuseEditModal = async () => { mockGetCallbacksCall.mockResolvedValue({ - callbacks: [mockCallback], + callbacks: [ + { + name: "langfuse", + variables: { + LANGFUSE_PUBLIC_KEY: "test-public-key", + LANGFUSE_SECRET_KEY: "test-secret-key", + LANGFUSE_HOST: "https://test.langfuse.com", + SLACK_WEBHOOK_URL: null, + OPENMETER_API_KEY: null, + }, + }, + ], available_callbacks: { langfuse: { litellm_callback_name: "langfuse", @@ -160,30 +138,118 @@ describe("Settings", () => { alerts: [], }); - mockGetCallbackConfigsCall.mockResolvedValue([mockCallbackConfig]); + mockGetCallbackConfigsCall.mockResolvedValue([ + { + id: "langfuse", + displayName: "Langfuse", + dynamic_params: { + LANGFUSE_PUBLIC_KEY: { type: "text", ui_name: "Public Key", required: true }, + LANGFUSE_SECRET_KEY: { type: "password", ui_name: "Secret Key", required: true }, + LANGFUSE_HOST: { type: "text", ui_name: "Host", required: false }, + }, + }, + ]); const user = userEvent.setup(); - const { getByText } = render(); + render(); await waitFor(() => { - expect(getByText("Active Logging Callbacks")).toBeInTheDocument(); + expect(screen.getByText("Active Logging Callbacks")).toBeInTheDocument(); }); await waitFor(() => { - expect(getByText("Langfuse")).toBeInTheDocument(); + expect(screen.getByText("Langfuse")).toBeInTheDocument(); }); await user.click(screen.getByTestId("callback-actions-langfuse-success")); await user.click(await screen.findByTestId("callback-action-edit")); await waitFor(() => { - expect(getByText("Edit Callback Settings")).toBeInTheDocument(); + expect(screen.getByText("Edit Callback Settings")).toBeInTheDocument(); + }); + + return user; + }; + + it("should display edit modal with fields when edit is clicked", async () => { + await openLangfuseEditModal(); + + await waitFor(() => { + expect(screen.getByText("Public Key")).toBeInTheDocument(); + expect(screen.getByText("Secret Key")).toBeInTheDocument(); + expect(screen.getByText("Host")).toBeInTheDocument(); }); await waitFor(() => { - expect(getByText("Public Key")).toBeInTheDocument(); - expect(getByText("Secret Key")).toBeInTheDocument(); - expect(getByText("Host")).toBeInTheDocument(); + expect(screen.getByLabelText("Public Key")).toHaveValue("test-public-key"); + }); + expect(screen.getByLabelText("Secret Key")).toHaveValue("test-secret-key"); + expect(screen.getByLabelText("Host")).toHaveValue("https://test.langfuse.com"); + + const danglingLabels = [...document.querySelectorAll("label[for]")].filter( + (label) => document.getElementById(label.getAttribute("for") as string) === null, + ); + expect(danglingLabels).toEqual([]); + }); + + it("should post the edited callback variables when the edit modal is saved", async () => { + const user = await openLangfuseEditModal(); + + await waitFor(() => { + expect(screen.getByLabelText("Host")).toHaveValue("https://test.langfuse.com"); + }); + + await user.clear(screen.getByLabelText("Host")); + await user.type(screen.getByLabelText("Host"), "https://edited.langfuse.com"); + await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Save Changes" })); + + await waitFor(() => { + expect(vi.mocked(setCallbacksCall)).toHaveBeenCalledWith("token", { + environment_variables: { + callback: "langfuse", + LANGFUSE_PUBLIC_KEY: "test-public-key", + LANGFUSE_SECRET_KEY: "test-secret-key", + LANGFUSE_HOST: "https://edited.langfuse.com", + }, + litellm_settings: { success_callback: ["langfuse"] }, + }); + }); + }); + + it("should block the edit submit when a required field is emptied", async () => { + const user = await openLangfuseEditModal(); + + await waitFor(() => { + expect(screen.getByLabelText("Public Key")).toHaveValue("test-public-key"); + }); + + await user.clear(screen.getByLabelText("Public Key")); + await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Save Changes" })); + + expect(await screen.findByText("Please enter the public key")).toBeInTheDocument(); + expect(vi.mocked(setCallbacksCall)).not.toHaveBeenCalled(); + }); + + it("should send the typed webhook url for an alert type when the alerting tab is saved", async () => { + const user = userEvent.setup(); + render(); + + await user.click(await screen.findByRole("tab", { name: "Alerting Types" })); + + const webhookInput = document.querySelector('input[name="llm_exceptions"]') as HTMLInputElement; + expect(webhookInput).not.toBeNull(); + await user.type(webhookInput, "https://hooks.example.com/llm-exceptions"); + + await user.click(screen.getByRole("button", { name: "Save Changes" })); + + await waitFor(() => { + expect(vi.mocked(setCallbacksCall)).toHaveBeenCalledWith("token", { + general_settings: expect.objectContaining({ + alert_to_webhook_url: expect.objectContaining({ + llm_exceptions: "https://hooks.example.com/llm-exceptions", + }), + }), + }); }); }); @@ -252,6 +318,19 @@ describe("backendCallbackLogoSrc", () => { }); }); +const CallbackSelectorHarness = ({ + callbackConfigs, +}: { + callbackConfigs: { id: string; displayName: string; logo?: string }[]; +}) => { + const form = useForm>(); + return ( + + + + ); +}; + describe("CallbackSelector logos", () => { it("resolves backend logos per entry: bare filename, external url, and missing logo", async () => { const callbackConfigs = [ @@ -260,13 +339,9 @@ describe("CallbackSelector logos", () => { { id: "nologo", displayName: "NoLogo" }, ]; - render( -
- - , - ); + render(); - fireEvent.mouseDown(screen.getByRole("combobox")); + await userEvent.click(screen.getByRole("combobox")); expect(await screen.findByAltText("Langfuse logo")).toHaveAttribute("src", "/ui/assets/logos/langfuse.png"); expect(screen.getByAltText("Hosted logo")).toHaveAttribute("src", "https://logos.example.com/hosted.png"); diff --git a/ui/litellm-dashboard/src/components/settings.tsx b/ui/litellm-dashboard/src/components/settings.tsx index 904fd4d611e..34fd9af06db 100644 --- a/ui/litellm-dashboard/src/components/settings.tsx +++ b/ui/litellm-dashboard/src/components/settings.tsx @@ -1,31 +1,26 @@ -import { - Button, - Card, - Grid, - SelectItem, - Switch, - Tab, - TabGroup, - Table, - TableBody, - TableCell, - TableHead, - TableHeaderCell, - TableRow, - TabList, - TabPanel, - TabPanels, - Text, - TextInput, -} from "@tremor/react"; import React, { useEffect, useState } from "react"; +import { Controller, FormProvider, useForm, useFormContext } from "react-hook-form"; -import { Button as Button2, Form, Input, Modal, Select } from "antd"; +import { Field, FieldError, FieldLabel } from "@/components/shared/form/field"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@/components/ui/combobox"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import EmailSettings from "./email_settings"; import { Logo } from "@/components/molecules/logo/Logo"; import NotificationsManager from "./molecules/notifications_manager"; -import FormItem from "antd/es/form/FormItem"; import AlertingSettings from "./alerting/alerting_settings"; import CloudZeroCostTracking from "./CloudZeroCostTracking/CloudZeroCostTracking"; import DeleteResourceModal from "./common_components/DeleteResourceModal"; @@ -46,6 +41,8 @@ interface SettingsPageProps { premiumUser: boolean; } +type CallbackFormValues = Record; + const assetsLogoFolder = "/ui/assets/logos/"; export const backendCallbackLogoSrc = (logo: string | null | undefined): string | undefined => { @@ -61,6 +58,9 @@ interface DynamicParamsFieldsProps { } const DynamicParamsFields: React.FC = ({ params, callbackConfigs, selectedCallback }) => { + const { register, formState } = useFormContext(); + const fieldIdPrefix = React.useId(); + if (!params || params.length === 0) { return null; } @@ -73,54 +73,51 @@ const DynamicParamsFields: React.FC = ({ params, callb const paramType = paramConfig.type || "text"; const fieldLabel = paramConfig.ui_name || param.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()); const isRequired = paramConfig.required || false; + const fieldId = `${fieldIdPrefix}-${param}`; + const registration = register( + param, + isRequired ? { required: `Please enter the ${fieldLabel.toLowerCase()}` } : undefined, + ); return ( - {fieldLabel} } - name={param} - key={param} - className="mb-4" - rules={ - isRequired - ? [ - { - required: true, - message: `Please enter the ${fieldLabel.toLowerCase()}`, - }, - ] - : undefined - } - > + + + {fieldLabel} + {paramType === "password" ? ( - ) : paramType === "number" ? ( ) : ( - + )} - + + ); })} ); }; +interface CallbackConfigOption { + id: string; + displayName: string; + logo?: string | null; +} + // Shared component for rendering callback selector interface CallbackSelectorProps { callbackConfigs: any[]; @@ -135,42 +132,64 @@ export const CallbackSelector: React.FC = ({ onCallbackChange, disabled = false, }) => { + const { control } = useFormContext(); + const inputId = React.useId(); + const selectedConfig = callbackConfigs.find((config) => config.id === selectedCallback) ?? null; + return ( - - - + rules={disabled ? undefined : { required: "Please select a callback" }} + render={({ field, fieldState }) => ( + + Callback + { + field.onChange(config?.id ?? ""); + onCallbackChange(config?.id ?? ""); + }} + isItemEqualToValue={(a: CallbackConfigOption, b: CallbackConfigOption) => a.id === b.id} + itemToStringLabel={(config: CallbackConfigOption) => config.displayName} + filter={(config: CallbackConfigOption, query: string) => + config.id.toLowerCase().includes(query.trim().toLowerCase()) + } + disabled={disabled} + > + + + No results + + {(callbackConfig: CallbackConfigOption) => ( + +
+
+ +
+ {callbackConfig.displayName} +
+
+ )} +
+
+
+ +
+ )} + /> ); }; @@ -206,8 +225,8 @@ const Settings: React.FC = ({ accessToken, userRole, userID, const [callbacks, setCallbacks] = useState([]); const [isLoadingCallbacks, setIsLoadingCallbacks] = useState(true); const [alerts, setAlerts] = useState([]); - const [addForm] = Form.useForm(); - const [editForm] = Form.useForm(); + const addForm = useForm({ shouldUnregister: true }); + const editForm = useForm({ shouldUnregister: true }); const [selectedCallback, setSelectedCallback] = useState(null); const [catchAllWebhookURL, setCatchAllWebhookURL] = useState(""); const [alertToWebhooks, setAlertToWebhooks] = useState>({}); @@ -254,7 +273,7 @@ const Settings: React.FC = ({ accessToken, userRole, userID, const normalized = Object.fromEntries( Object.entries(selectedEditCallback.variables || {}).map(([k, v]) => [k, v ?? ""]), ); - editForm.setFieldsValue({ + editForm.reset({ ...normalized, callback: selectedEditCallback.name, }); @@ -337,11 +356,11 @@ const Settings: React.FC = ({ accessToken, userRole, userID, if (isEdit) { setShowEditCallback(false); - editForm.resetFields(); + editForm.reset(); setSelectedEditCallback(null); } else { setShowAddCallbacksModal(false); - addForm.resetFields(); + addForm.reset(); setSelectedCallback(null); setSelectedCallbackParams([]); } @@ -383,6 +402,23 @@ const Settings: React.FC = ({ accessToken, userRole, userID, setSelectedCallbackParams(params); }; + const closeAddCallbackModal = () => { + setShowAddCallbacksModal(false); + setSelectedCallback(null); + setSelectedCallbackParams([]); + }; + + const cancelAddCallback = () => { + closeAddCallbackModal(); + addForm.reset(); + }; + + const closeEditCallbackModal = () => { + setShowEditCallback(false); + setSelectedEditCallback(null); + editForm.reset(); + }; + const handleSaveAlerts = async () => { if (!accessToken) { return; @@ -447,257 +483,216 @@ const Settings: React.FC = ({ accessToken, userRole, userID, return (
- - - - Logging Callbacks - CloudZero Cost Tracking - Alerting Types - Alerting Settings - Email Alerts - - - - setShowAddCallbacksModal(true)} - onEdit={(cb) => { - setSelectedEditCallback(cb); - setShowEditCallback(true); - }} - onDelete={(cb) => handleDeleteCallback(cb)} - onTest={async (cb) => { - try { - await serviceHealthCheck(accessToken, cb.name); - NotificationsManager.success("Health check triggered"); - } catch (error) { - NotificationsManager.fromBackend(parseErrorMessage(error)); - } - }} - /> - - -
- -
-
- - - - Alerts are only supported for Slack Webhook URLs. Get your webhook urls from{" "} - - here - - -
- - - - - Slack Webhook URL - - +
+ + + Logging Callbacks + CloudZero Cost Tracking + Alerting Types + Alerting Settings + Email Alerts + + + setShowAddCallbacksModal(true)} + onEdit={(cb) => { + setSelectedEditCallback(cb); + setShowEditCallback(true); + }} + onDelete={(cb) => handleDeleteCallback(cb)} + onTest={async (cb) => { + try { + await serviceHealthCheck(accessToken, cb.name); + NotificationsManager.success("Health check triggered"); + } catch (error) { + NotificationsManager.fromBackend(parseErrorMessage(error)); + } + }} + /> + + +
+ +
+
+ + +

+ Alerts are only supported for Slack Webhook URLs. Get your webhook urls from{" "} + + here + +

+
+ + + + + Slack Webhook URL + + - - {Object.entries(alerts_to_UI_NAME).map(([key, value], index) => ( - - - {key == "region_outage_alerts" ? ( - premiumUser ? ( - handleSwitchChange(key)} - /> - ) : ( - - ) - ) : ( + + {Object.entries(alerts_to_UI_NAME).map(([key, value], index) => ( + + + {key == "region_outage_alerts" ? ( + premiumUser ? ( handleSwitchChange(key)} + onCheckedChange={() => handleSwitchChange(key)} /> - )} - - - {value} - - - - - - ))} - -
- + ) : ( + + ) + ) : ( + handleSwitchChange(key)} + /> + )} + + +

{value}

+
+ + + + + ))} + + + - - - - - - - - - - - - + + + + + + + + + + +
- { - setShowAddCallbacksModal(false); - setSelectedCallback(null); - setSelectedCallbackParams([]); - }} - footer={null} - > - - {" "} - LiteLLM Docs: Logging - + !open && closeAddCallbackModal()}> + + + Add Logging Callback + + + {" "} + LiteLLM Docs: Logging + -
- - - - -
- { - setShowAddCallbacksModal(false); - setSelectedCallback(null); - setSelectedCallbackParams([]); - addForm.resetFields(); - }} - disabled={isAddingCallback} - > - Cancel - - - {isAddingCallback ? "Adding..." : "Add Callback"} - -
- -
- - { - setShowEditCallback(false); - setSelectedEditCallback(null); - editForm.resetFields(); - }} - footer={null} - > -
- {selectedEditCallback && ( - <> + + {}} - disabled={true} + selectedCallback={selectedCallback} + onCallbackChange={handleSelectedCallbackChange} /> - - )} -
- { - setShowEditCallback(false); - setSelectedEditCallback(null); - editForm.resetFields(); - }} - disabled={isUpdatingCallback} - > - Cancel - - { - editForm.submit(); - }} - loading={isUpdatingCallback} - disabled={isUpdatingCallback} - > - {isUpdatingCallback ? "Saving..." : "Save Changes"} - -
- -
+
+ + +
+ + + + + + !open && closeEditCallbackModal()}> + + + Edit Callback Settings + + +
+ {selectedEditCallback && ( + <> + {}} + disabled={true} + /> + + + + )} + +
+ + +
+ +
+
+
Date: Fri, 14 Aug 2026 09:36:16 -0700 Subject: [PATCH 36/49] refactor(ui): move the cost tracking components onto shadcn primitives Rebuilds the provider discount and margin tables, the pricing calculator and its multi-cost results on the in-repo shadcn layer, and swaps the imperative antd modal.confirm removals for AlertDialog. Row actions gained accessible names, which replace the Tremor stub mocks the tests used to drive. cost_tracking_settings keeps its two antd Modals and Forms, since they wrap the two add forms that stay on antd for now. --- ui/litellm-dashboard/eslint-suppressions.json | 14 +- .../cost_tracking_settings.test.tsx | 83 ++++- .../_components/cost_tracking_settings.tsx | 285 +++++++------- .../pricing_calculator/index.test.tsx | 39 +- .../_components/pricing_calculator/index.tsx | 227 ++++++------ .../multi_cost_results.test.tsx | 90 +++-- .../pricing_calculator/multi_cost_results.tsx | 349 +++++++++--------- .../provider_discount_table.test.tsx | 204 ++++++---- .../_components/provider_discount_table.tsx | 106 +++--- .../provider_margin_table.test.tsx | 158 +++++--- .../_components/provider_margin_table.tsx | 129 ++++--- 11 files changed, 982 insertions(+), 702 deletions(-) diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 5e322598a10..7738a9e46d1 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -228,7 +228,7 @@ "count": 2 }, "no-restricted-imports": { - "count": 2 + "count": 1 } }, "src/app/(dashboard)/cost-tracking/_components/how_it_works.tsx": { @@ -239,9 +239,6 @@ "src/app/(dashboard)/cost-tracking/_components/pricing_calculator/index.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.test.tsx": { @@ -252,9 +249,6 @@ "src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 2 } }, "src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_export_dropdown.tsx": { @@ -275,17 +269,11 @@ "src/app/(dashboard)/cost-tracking/_components/provider_discount_table.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/app/(dashboard)/cost-tracking/_components/provider_margin_table.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/app/(dashboard)/cost-tracking/_components/use_discount_config.ts": { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.test.tsx index 0dae83ba808..c53b7b618b2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.test.tsx @@ -8,25 +8,29 @@ import CostTrackingSettings from "./cost_tracking_settings"; // Mock sub-hooks so we can control their state without network calls const mockDiscountConfig = vi.fn(() => ({})); const mockMarginConfig = vi.fn(() => ({})); +const mockRemoveDiscount = vi.fn(); +const mockRemoveMargin = vi.fn(); + +const stableDiscountCallbacks = { + fetchDiscountConfig: vi.fn().mockResolvedValue(undefined), + handleAddProvider: vi.fn().mockResolvedValue(true), + handleRemoveProvider: mockRemoveDiscount, + handleDiscountChange: vi.fn().mockResolvedValue(undefined), +}; + +const stableMarginCallbacks = { + fetchMarginConfig: vi.fn().mockResolvedValue(undefined), + handleAddMargin: vi.fn().mockResolvedValue(true), + handleRemoveMargin: mockRemoveMargin, + handleMarginChange: vi.fn().mockResolvedValue(undefined), +}; vi.mock("./use_discount_config", () => ({ - useDiscountConfig: () => ({ - discountConfig: mockDiscountConfig(), - fetchDiscountConfig: vi.fn().mockResolvedValue(undefined), - handleAddProvider: vi.fn().mockResolvedValue(true), - handleRemoveProvider: vi.fn().mockResolvedValue(undefined), - handleDiscountChange: vi.fn().mockResolvedValue(undefined), - }), + useDiscountConfig: () => ({ discountConfig: mockDiscountConfig(), ...stableDiscountCallbacks }), })); vi.mock("./use_margin_config", () => ({ - useMarginConfig: () => ({ - marginConfig: mockMarginConfig(), - fetchMarginConfig: vi.fn().mockResolvedValue(undefined), - handleAddMargin: vi.fn().mockResolvedValue(true), - handleRemoveMargin: vi.fn().mockResolvedValue(undefined), - handleMarginChange: vi.fn().mockResolvedValue(undefined), - }), + useMarginConfig: () => ({ marginConfig: mockMarginConfig(), ...stableMarginCallbacks }), })); vi.mock("./pricing_calculator/index", () => ({ @@ -153,6 +157,57 @@ describe("CostTrackingSettings", () => { }); }); + describe("removing a configured provider", () => { + const expandAndRemove = async (section: string, actionName: string) => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByText(section).closest("button")!); + await user.click(await screen.findByRole("button", { name: actionName })); + + return user; + }; + + it("should ask to confirm before removing a discount", async () => { + mockDiscountConfig.mockReturnValue({ openai: 0.05 }); + + await expandAndRemove("Provider Discounts", "Remove discount for openai"); + + expect(await screen.findByRole("button", { name: "Remove" })).toBeInTheDocument(); + expect(screen.getByText(/are you sure you want to remove the discount for openai\?/i)).toBeInTheDocument(); + expect(mockRemoveDiscount).not.toHaveBeenCalled(); + }); + + it("should remove the discount once removal is confirmed", async () => { + mockDiscountConfig.mockReturnValue({ openai: 0.05 }); + + const user = await expandAndRemove("Provider Discounts", "Remove discount for openai"); + await user.click(await screen.findByRole("button", { name: "Remove" })); + + expect(mockRemoveDiscount).toHaveBeenCalledWith("openai"); + }); + + it("should leave the discount in place when the confirmation is cancelled", async () => { + mockDiscountConfig.mockReturnValue({ openai: 0.05 }); + + const user = await expandAndRemove("Provider Discounts", "Remove discount for openai"); + await user.click(await screen.findByRole("button", { name: "Cancel" })); + + expect(mockRemoveDiscount).not.toHaveBeenCalled(); + expect(screen.queryByRole("button", { name: "Remove" })).not.toBeInTheDocument(); + }); + + it("should remove the margin once removal is confirmed", async () => { + mockMarginConfig.mockReturnValue({ openai: 0.1 }); + + const user = await expandAndRemove("Fee/Price Margin", "Remove margin for openai"); + expect(screen.getByText(/are you sure you want to remove the margin for openai\?/i)).toBeInTheDocument(); + await user.click(await screen.findByRole("button", { name: "Remove" })); + + expect(mockRemoveMargin).toHaveBeenCalledWith("openai"); + }); + }); + describe("empty state messages", () => { it("should show the empty state message when no discount config is loaded", async () => { mockDiscountConfig.mockReturnValue({}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.tsx index b32e7afd756..ba2d830ae7b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.tsx @@ -1,25 +1,25 @@ import React, { useState, useEffect } from "react"; -import { - Title, - Text, - Button, - Accordion, - AccordionHeader, - AccordionBody, - TabGroup, - TabList, - Tab, - TabPanels, - TabPanel, -} from "@tremor/react"; +import { ChevronDown } from "lucide-react"; import { Modal, Form } from "antd"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { CostTrackingSettingsProps } from "./types"; import ProviderDiscountTable from "./provider_discount_table"; import AddProviderForm from "./add_provider_form"; import ProviderMarginTable from "./provider_margin_table"; import AddMarginForm from "./add_margin_form"; import PricingCalculator from "./pricing_calculator/index"; -import { ExclamationCircleOutlined } from "@ant-design/icons"; import { DocsMenu } from "@/components/HelpLink"; import HowItWorks from "./how_it_works"; import { useDiscountConfig } from "./use_discount_config"; @@ -31,6 +31,29 @@ const DOCS_LINKS = [ { label: "Spend tracking", href: "https://docs.litellm.ai/docs/proxy/cost_tracking" }, ]; +const REMOVAL_COPY = { + discount: { title: "Remove Provider Discount", noun: "discount" }, + margin: { title: "Remove Provider Margin", noun: "margin" }, +} as const; + +interface PendingRemoval { + kind: keyof typeof REMOVAL_COPY; + provider: string; + displayName: string; +} + +const SECTION_HEADER_CLASS = "group/section flex w-full items-center justify-between px-6 py-4 text-left"; + +const SectionHeader: React.FC<{ title: string; description: string }> = ({ title, description }) => ( + +
+ {title} + {description} +
+ +
+); + const CostTrackingSettings: React.FC = ({ userID, userRole, accessToken }) => { const [selectedProvider, setSelectedProvider] = useState(undefined); const [newDiscount, setNewDiscount] = useState(""); @@ -42,9 +65,9 @@ const CostTrackingSettings: React.FC = ({ userID, use const [percentageValue, setPercentageValue] = useState(""); const [fixedAmountValue, setFixedAmountValue] = useState(""); const [models, setModels] = useState([]); + const [pendingRemoval, setPendingRemoval] = useState(null); const [form] = Form.useForm(); const [marginForm] = Form.useForm(); - const [modal, contextHolder] = Modal.useModal(); const isProxyAdmin = userRole === "proxy_admin" || userRole === "Admin"; @@ -104,16 +127,18 @@ const CostTrackingSettings: React.FC = ({ userID, use handleAddProvider(); }; - const handleRemoveProvider = async (provider: string, providerDisplayName: string) => { - modal.confirm({ - title: "Remove Provider Discount", - icon: , - content: `Are you sure you want to remove the discount for ${providerDisplayName}?`, - okText: "Remove", - okType: "danger", - cancelText: "Cancel", - onOk: () => removeProvider(provider), - }); + const handleRemoveProvider = (provider: string, providerDisplayName: string) => { + setPendingRemoval({ kind: "discount", provider, displayName: providerDisplayName }); + }; + + const handleConfirmRemoval = () => { + if (!pendingRemoval) return; + if (pendingRemoval.kind === "discount") { + removeProvider(pendingRemoval.provider); + } else { + removeMargin(pendingRemoval.provider); + } + setPendingRemoval(null); }; const handleAddMargin = async () => { @@ -141,16 +166,8 @@ const CostTrackingSettings: React.FC = ({ userID, use setMarginType("percentage"); }; - const handleRemoveMargin = async (provider: string, providerDisplayName: string) => { - modal.confirm({ - title: "Remove Provider Margin", - icon: , - content: `Are you sure you want to remove the margin for ${providerDisplayName}?`, - okText: "Remove", - okType: "danger", - cancelText: "Cancel", - onOk: () => removeMargin(provider), - }); + const handleRemoveMargin = (provider: string, providerDisplayName: string) => { + setPendingRemoval({ kind: "margin", provider, displayName: providerDisplayName }); }; if (!accessToken) { @@ -159,18 +176,16 @@ const CostTrackingSettings: React.FC = ({ userID, use return (
- {contextHolder} - {/* Header Section - Outside the card */}
- Cost Tracking Settings +

Cost Tracking Settings

- +

Configure cost discounts and margins for different LLM providers. Changes are saved automatically. - +

@@ -178,90 +193,78 @@ const CostTrackingSettings: React.FC = ({ userID, use
{/* Accordion 1: Provider Discounts - Only for proxy admins */} {isProxyAdmin && ( - - -
- Provider Discounts - - Apply percentage-based discounts to reduce costs for specific providers - -
-
- - - - Discounts - Test It - - - -
-
- + + + + + + Discounts + Test It + + +
+
+ +
+ {isFetching ? ( +
+

Loading configuration...

- {isFetching ? ( -
- Loading configuration... -
- ) : Object.keys(discountConfig).length > 0 ? ( - - ) : ( -
- - - - No provider discounts configured - - Click "Add Provider Discount" to get started - -
- )} -
- - -
- -
-
- - - - + ) : Object.keys(discountConfig).length > 0 ? ( + + ) : ( +
+ + + +

No provider discounts configured

+

Click "Add Provider Discount" to get started

+
+ )} +
+ + +
+ +
+
+ + + )} {/* Accordion 2: Fee/Price Margin - Only for proxy admins */} {isProxyAdmin && ( - - -
- Fee/Price Margin - - Add fees or margins to LLM costs for internal billing and cost recovery - -
-
- + + +
{isFetching ? (
- Loading configuration... +

Loading configuration...

) : Object.keys(marginConfig).length > 0 ? ( = ({ userID, use d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> - No provider margins configured - Click "Add Provider Margin" to get started +

No provider margins configured

+

Click "Add Provider Margin" to get started

)}
-
-
+ + )} {/* Accordion 3: Pricing Calculator - Available to all roles */} - - -
- Pricing Calculator - - Estimate LLM costs based on expected token usage and request volume - -
-
- + + +
-
-
+ +
+ {pendingRemoval && ( + !open && setPendingRemoval(null)}> + + + {REMOVAL_COPY[pendingRemoval.kind].title} + + Are you sure you want to remove the {REMOVAL_COPY[pendingRemoval.kind].noun} for{" "} + {pendingRemoval.displayName}? + + + + Cancel + + Remove + + + + + )} + @@ -328,10 +347,10 @@ const CostTrackingSettings: React.FC = ({ userID, use }} >
- +

Select a provider and set its discount percentage. Enter a value between 0% and 100% (e.g., 5 for a 5% discount). - +

= ({ userID, use }} >
- +

Select a provider (or "Global" for all providers) and configure the margin. You can use percentage-based or fixed amount. - +

+ within(screen.getByRole("table")) + .getAllByRole("row") + .filter((row) => within(row).queryAllByRole("combobox").length > 0); + +const deleteButtonIn = (row: HTMLElement): HTMLElement => { + const cells = within(row).getAllByRole("cell"); + return within(cells[cells.length - 1]).getByRole("button"); +}; + describe("PricingCalculator", () => { beforeEach(() => { vi.clearAllMocks(); @@ -124,8 +134,31 @@ describe("PricingCalculator", () => { it("should render column headers for Model, Input Tokens, and Output Tokens", () => { renderWithProviders(); - expect(screen.getByText("Model")).toBeInTheDocument(); - expect(screen.getByText("Input Tokens")).toBeInTheDocument(); - expect(screen.getByText("Output Tokens")).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Model" })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Input Tokens" })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Output Tokens" })).toBeInTheDocument(); + }); + + it("should render a numeric field for input tokens, output tokens and requests", () => { + renderWithProviders(); + expect(screen.getAllByRole("spinbutton")).toHaveLength(3); + }); + + it("should offer a model picker per row", () => { + renderWithProviders(); + expect(screen.getAllByRole("combobox")).toHaveLength(1); + }); + + it("should remove a row when its delete button is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /add another model/i })); + const withTwoRows = dataRows(); + expect(withTwoRows).toHaveLength(2); + + await user.click(deleteButtonIn(withTwoRows[1])); + + expect(dataRows()).toHaveLength(1); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/index.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/index.tsx index 9b355e55c1c..f3bd74260ad 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/index.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/index.tsx @@ -1,6 +1,10 @@ import React, { useState, useCallback } from "react"; -import { Table, Select, InputNumber, Button, Radio } from "antd"; -import { DeleteOutlined, PlusOutlined } from "@ant-design/icons"; +import { Plus, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { SearchSelect } from "@/components/shared/SearchSelect"; import { PricingCalculatorProps, ModelEntry } from "./types"; import MultiCostResults from "./multi_cost_results"; import { useMultiCostEstimate } from "./use_multi_cost_estimate"; @@ -63,132 +67,115 @@ const PricingCalculator: React.FC = ({ accessToken, mode const multiModelResult = getMultiModelResult(entries); - const columns = [ - { - title: "Model", - dataIndex: "model", - key: "model", - width: "35%", - render: (_: string, record: ModelEntry) => ( - + handleEntryChange(record.id, "input_tokens", e.target.value === "" ? 0 : Number(e.target.value)) + } + /> + + + + handleEntryChange(record.id, "output_tokens", e.target.value === "" ? 0 : Number(e.target.value)) + } + /> + + + + handleEntryChange( + record.id, + requestsField, + e.target.value === "" ? undefined : Number(e.target.value), + ) + } + /> + + + + + + ))} + + + + + + + + +
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.test.tsx index 04ef60469f0..b17dd2cb859 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.test.tsx @@ -85,6 +85,14 @@ function emptyMultiResult(): MultiModelResult { }; } +const expandToggle = (): HTMLElement => screen.getByRole("button", { name: /cost breakdown for / }); + +const shownBreakdown = (): HTMLElement | null => { + const label = screen.queryByText("Total/Request"); + if (label === null) return null; + return label.closest("[style*='display: none']") === null ? label : null; +}; + describe("MultiCostResults", () => { beforeEach(() => { vi.clearAllMocks(); @@ -200,40 +208,78 @@ describe("MultiCostResults", () => { expect(screen.getByRole("button", { name: /export/i })).toBeInTheDocument(); }); + it("should render a column header for each summary column", () => { + renderWithProviders(); + + expect(screen.getByRole("columnheader", { name: "Model" })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Per Request" })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Margin Fee" })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Daily" })).toBeInTheDocument(); + }); + + it("should not show the model breakdown before the row is expanded", () => { + renderWithProviders(); + expect(shownBreakdown()).toBeNull(); + }); + it("should expand the model breakdown row when the expand button is clicked", async () => { const user = userEvent.setup(); renderWithProviders(); - // The expand column renders a button (RightOutlined icon) for rows without errors - const expandButtons = screen.getAllByRole("button"); - // Find the small expand button (not the Export button) - const expandButton = expandButtons.find((btn) => !btn.textContent?.toLowerCase().includes("export")); - expect(expandButton).toBeDefined(); + await user.click(expandToggle()); - await user.click(expandButton!); - - // After expanding, the SingleModelBreakdown should be visible - expect(screen.getByText("Total/Request")).toBeInTheDocument(); + expect(shownBreakdown()).toBeVisible(); + expect(screen.getByText("Daily Total (100 req)")).toBeInTheDocument(); }); - it("should show the collapse icon after expanding a row", async () => { + it("should collapse the model breakdown again on a second click", async () => { const user = userEvent.setup(); renderWithProviders(); - const getExpandButton = () => { - const allButtons = screen.getAllByRole("button"); - return allButtons.find((btn) => !btn.textContent?.toLowerCase().includes("export")); - }; + await user.click(expandToggle()); + expect(shownBreakdown()).toBeVisible(); - // Before expand: button has the "down" aria-label (RightOutlined renders as down in ant icons) - // Just verify clicking works and the breakdown content appears - await user.click(getExpandButton()!); - expect(screen.getByText("Total/Request")).toBeInTheDocument(); + await user.click(expandToggle()); + expect(shownBreakdown()).toBeNull(); + }); - // After a second click, the row collapses — content may be hidden or removed - await user.click(getExpandButton()!); - // The expanded content should no longer be visible - expect(screen.queryByText("Total/Request")).not.toBeVisible(); + it("should name the breakdown toggle and report its expanded state", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const toggle = screen.getByRole("button", { name: "Show cost breakdown for gpt-4" }); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + + await user.click(toggle); + + const collapseToggle = screen.getByRole("button", { name: "Hide cost breakdown for gpt-4" }); + expect(collapseToggle).toHaveAttribute("aria-expanded", "true"); + }); + + it("should not offer an expand toggle for a row that failed", () => { + renderWithProviders( + , + ); + + expect(screen.getAllByRole("button", { name: /cost breakdown for / })).toHaveLength(1); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.tsx index 3ea7ea58127..b8375b930c9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.tsx @@ -1,7 +1,11 @@ import React, { useState } from "react"; -import { Text, Button } from "@tremor/react"; -import { Card, Statistic, Row, Col, Divider, Spin, Table, Tag } from "antd"; -import { LoadingOutlined, DownOutlined, RightOutlined } from "@ant-design/icons"; +import { ChevronDown, ChevronRight } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { CostEstimateResponse } from "../types"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { MultiModelResult } from "./types"; @@ -41,55 +45,57 @@ const SingleModelBreakdown: React.FC<{
{loading && (
- } size="small" /> + Updating...
)}
-
- Total/Request - {formatCost(result.cost_per_request)} +
+

Total/Request

+

{formatCost(result.cost_per_request)}

-
- Input Cost - {formatCost(result.input_cost_per_request)} +
+

Input Cost

+

{formatCost(result.input_cost_per_request)}

-
- Output Cost - {formatCost(result.output_cost_per_request)} +
+

Output Cost

+

{formatCost(result.output_cost_per_request)}

-
- Margin Fee - 0 ? "text-amber-600" : ""}`}> +
+

Margin Fee

+

0 ? "text-amber-600" : ""}`}> {formatCost(result.margin_cost_per_request)} - +

{periodCost !== null && (
-
- +
+

{periodLabel} Total ({formatRequests(periodRequests)} req) - - +

+

{formatCost(periodCost)} - +

-
- {periodLabel} Input - {formatCost(periodInputCost)} +
+

{periodLabel} Input

+

{formatCost(periodInputCost)}

-
- {periodLabel} Output - {formatCost(periodOutputCost)} +
+

{periodLabel} Output

+

{formatCost(periodOutputCost)}

-
- {periodLabel} Margin Fee - 0 ? "text-amber-600" : ""}`}> +
+

{periodLabel} Margin Fee

+

0 ? "text-amber-600" : ""}`}> {formatCost(periodMarginCost)} - +

)} @@ -124,7 +130,7 @@ const MultiCostResults: React.FC = ({ multiResult, timePe if (!hasAnyResult && !isAnyLoading && !hasAnyError) { return (
- Select models above to see cost estimates +

Select models above to see cost estimates

); } @@ -133,8 +139,8 @@ const MultiCostResults: React.FC = ({ multiResult, timePe if (!hasAnyResult && isAnyLoading && !hasAnyError) { return (
- } /> - Calculating costs... + +

Calculating costs...

); } @@ -143,10 +149,10 @@ const MultiCostResults: React.FC = ({ multiResult, timePe if (!hasAnyResult && hasAnyError) { return (
- +
- Cost Estimates - {isAnyLoading && } size="small" />} +

Cost Estimates

+ {isAnyLoading && }
{/* Error Messages */} {errorEntries.map((e) => ( @@ -174,102 +180,10 @@ const MultiCostResults: React.FC = ({ multiResult, timePe const hasMargin = multiResult.totals.margin_per_request > 0; const periodLabel = timePeriod === "day" ? "Daily" : "Monthly"; - const periodCostKey = timePeriod === "day" ? "daily_cost" : "monthly_cost"; - - const summaryColumns = [ - { - title: "Model", - dataIndex: "model", - key: "model", - render: ( - text: string, - record: { - id: string; - provider?: string | null; - error?: string | null; - loading?: boolean; - hasZeroCost?: boolean | null; - }, - ) => ( -
-
- {text} - {record.provider && ( - - {record.provider} - - )} - {record.loading && } size="small" />} -
- {record.error &&
⚠️ {record.error}
} - {record.hasZeroCost && !record.error && ( -
- ⚠️ No pricing data found for this model. Set base_model in config. -
- )} -
- ), - }, - { - title: "Per Request", - dataIndex: "cost_per_request", - key: "cost_per_request", - align: "right" as const, - render: (value: number | null, record: { error?: string | null }) => - record.error ? ( - - - ) : ( - {formatCost(value)} - ), - }, - { - title: "Margin Fee", - dataIndex: "margin_cost_per_request", - key: "margin_cost_per_request", - align: "right" as const, - render: (value: number | null, record: { error?: string | null }) => - record.error ? ( - - - ) : ( - 0 ? "text-amber-600" : "text-gray-400"}`}> - {formatCost(value)} - - ), - }, - { - title: periodLabel, - dataIndex: periodCostKey, - key: "period_cost", - align: "right" as const, - render: (value: number | null, record: { error?: string | null }) => - record.error ? ( - - - ) : ( - {formatCost(value)} - ), - }, - { - title: "", - key: "expand", - width: 40, - render: (_: unknown, record: { id: string; error?: string | null }) => - record.error ? null : ( - - ), - }, - ]; // Include both valid results and errors in the table data const allEntriesWithModels = multiResult.entries.filter((e) => e.entry.model); const summaryData = allEntriesWithModels.map((e) => ({ - key: e.entry.id, id: e.entry.id, model: e.result?.model || e.entry.model, provider: e.result?.provider, @@ -284,78 +198,153 @@ const MultiCostResults: React.FC = ({ multiResult, timePe return (
- +
- Cost Estimates +

Cost Estimates

- {isAnyLoading && } size="small" />} + {isAnyLoading && }
{/* Combined Totals - Always show when there are results */} - - - - Total Per Request} - value={formatCost(multiResult.totals.cost_per_request)} - valueStyle={{ color: "#1890ff", fontSize: "18px", fontFamily: "monospace" }} - /> - - - Total {periodLabel}} - value={formatCost(timePeriod === "day" ? multiResult.totals.daily_cost : multiResult.totals.monthly_cost)} - valueStyle={{ - color: timePeriod === "day" ? "#52c41a" : "#722ed1", - fontSize: "18px", - fontFamily: "monospace", - }} - /> - - + +
+
+ Total Per Request +
+ {formatCost(multiResult.totals.cost_per_request)} +
+
+
+ Total {periodLabel} +
+ {formatCost(timePeriod === "day" ? multiResult.totals.daily_cost : multiResult.totals.monthly_cost)} +
+
+
{hasMargin && ( - - +
+
Margin Fee/Request
-
+
{formatCost(multiResult.totals.margin_per_request)}
- - +
+
{periodLabel} Margin Fee
-
+
{formatCost(timePeriod === "day" ? multiResult.totals.daily_margin : multiResult.totals.monthly_margin)}
- - +
+
)} {/* Per-Model Table */} {summaryData.length > 0 && ( - { - const entry = validEntries.find((e) => e.entry.id === record.id); - if (!entry?.result) return null; +
+ + + Model + Per Request + Margin Fee + {periodLabel} + + Cost breakdown + + + + + {summaryData.map((record) => { + const isExpanded = expandedModels.has(record.id); + const periodCost = timePeriod === "day" ? record.daily_cost : record.monthly_cost; + const breakdownEntry = validEntries.find((e) => e.entry.id === record.id); return ( -
- -
+ + + +
+
+ {record.model} + {record.provider && ( + + {record.provider} + + )} + {record.loading && } +
+ {record.error && ( +
⚠️ {record.error}
+ )} + {record.hasZeroCost && !record.error && ( +
+ ⚠️ No pricing data found for this model. Set base_model in config. +
+ )} +
+
+ + {record.error ? ( + - + ) : ( + {formatCost(record.cost_per_request)} + )} + + + {record.error ? ( + - + ) : ( + 0 ? "text-amber-600" : "text-gray-400"}`} + > + {formatCost(record.margin_cost_per_request)} + + )} + + + {record.error ? ( + - + ) : ( + {formatCost(periodCost)} + )} + + + {!record.error && ( + + )} + +
+ {isExpanded && breakdownEntry?.result && ( + + +
+ +
+
+
+ )} +
); - }, - showExpandColumn: false, - }} - /> + })} +
+
)}
); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/provider_discount_table.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/provider_discount_table.test.tsx index f9a0a40f07d..24280873cf0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/provider_discount_table.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/provider_discount_table.test.tsx @@ -5,49 +5,21 @@ import userEvent from "@testing-library/user-event"; import { renderWithProviders } from "../../../../../tests/test-utils"; import ProviderDiscountTable from "./provider_discount_table"; -vi.mock("@heroicons/react/outline", () => ({ - TrashIcon: function TrashIcon() { - return null; - }, - PencilAltIcon: function PencilAltIcon() { - return null; - }, - CheckIcon: function CheckIcon() { - return null; - }, - XIcon: function XIcon() { - return null; - }, -})); - -vi.mock("@tremor/react", () => ({ - Table: ({ children }: any) => {children}
, - TableHead: ({ children }: any) => {children}, - TableRow: ({ children }: any) => {children}, - TableHeaderCell: ({ children }: any) => {children}, - TableBody: ({ children }: any) => {children}, - TableCell: ({ children }: any) => {children}, - Text: ({ children }: any) => {children}, - TextInput: ({ value, onValueChange, onKeyDown, placeholder, ...rest }: any) => ( - onValueChange?.(e.target.value)} - onKeyDown={onKeyDown} - placeholder={placeholder} - {...rest} - /> - ), - Icon: ({ icon: IconComponent, onClick }: any) => { - const name = IconComponent?.displayName ?? IconComponent?.name ?? "icon"; - return + + + ) : ( + <> +

{(row.discount * 100).toFixed(1)}%

+ + + )} +
+ ); + }, width: "250px", }, { @@ -125,12 +138,15 @@ const ProviderDiscountTable: React.FC = ({ cell: (row) => { const { displayName } = getProviderLogoAndName(row.provider); return ( - onRemoveProvider(row.provider, displayName)} className="cursor-pointer hover:text-red-600" - /> + > + + ); }, width: "80px", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/provider_margin_table.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/provider_margin_table.test.tsx index 170e61141b6..dd478571568 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/provider_margin_table.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/provider_margin_table.test.tsx @@ -6,43 +6,15 @@ import { renderWithProviders } from "../../../../../tests/test-utils"; import ProviderMarginTable from "./provider_margin_table"; import { Providers, providerLogoMap } from "@/components/provider_info_helpers"; -vi.mock("@heroicons/react/outline", () => ({ - TrashIcon: function TrashIcon() { - return null; - }, - PencilAltIcon: function PencilAltIcon() { - return null; - }, - CheckIcon: function CheckIcon() { - return null; - }, - XIcon: function XIcon() { - return null; - }, -})); +const ROW_ACTION_NAME = { + edit: /^Edit margin for /, + save: /^Save margin for /, + cancel: /^Cancel editing margin for /, + remove: /^Remove margin for /, +} as const; -vi.mock("@tremor/react", () => ({ - Table: ({ children }: any) => {children}
, - TableHead: ({ children }: any) => {children}, - TableRow: ({ children }: any) => {children}, - TableHeaderCell: ({ children }: any) => {children}, - TableBody: ({ children }: any) => {children}, - TableCell: ({ children }: any) => {children}, - Text: ({ children }: any) => {children}, - TextInput: ({ value, onValueChange, placeholder, autoFocus, className }: any) => ( - onValueChange?.(e.target.value)} - placeholder={placeholder} - autoFocus={autoFocus} - className={className} - /> - ), - Icon: ({ icon: IconComponent, onClick }: any) => { - const name = IconComponent?.displayName ?? IconComponent?.name ?? "icon"; - return + + + ) : ( + <> +

{formatMargin(row.margin)}

+ + + )} +
+ ); + }, width: "350px", }, { header: "Actions", cell: (row) => { - const displayName = row.provider === "global" ? "Global" : getProviderLogoAndName(row.provider).displayName; + const displayName = marginRowDisplayName(row.provider); return ( - onRemoveProvider(row.provider, displayName)} className="cursor-pointer hover:text-red-600" - /> + > + + ); }, width: "80px", From 7e375ed6e8ca6371a02b7bb2a22c001a8d0c6435 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 14 Aug 2026 09:48:49 -0700 Subject: [PATCH 37/49] chore: retrigger e2e gate From e1f3d6e158559b37145cd9cecc2648143123efb3 Mon Sep 17 00:00:00 2001 From: Armaan Sandhu <74664101+Ar-maan05@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:22:58 -0400 Subject: [PATCH 38/49] feat(proxy): serve Anthropic-native /v1/models for Claude Code gateway discovery (#35455) * feat(proxy): serve Anthropic-native /v1/models for Claude Code gateway discovery * refactor(proxy): move Anthropic model-list formatter into llms/anthropic/common_utils * fix(proxy): make model_list request param optional for direct callers * style: apply ruff format to changed lines * style: satisfy ruff strict-rule budget (UP006, I001) * style: satisfy type-discipline budget (LIT002 mutable-ok, LIT009 pyright ignore) * style: satisfy LIT001/LIT010 and drop explanatory comment per contributor rules * fix(proxy): translate team model names in the Anthropic /v1/models response * ci: trigger buildkite status report * feat(proxy): carry token limits into the Anthropic-native /v1/models entries * fix(proxy): cast the injected request so the anthropic-version guard is a real comparison * fix(proxy): explain the model listing casts so the type-discipline gate passes --------- Co-authored-by: yuneng-jiang Co-authored-by: Yassin Kortam --- litellm/llms/anthropic/common_utils.py | 39 +++++++ litellm/proxy/proxy_server.py | 18 +++ .../anthropic/test_anthropic_common_utils.py | 79 ++++++++++++++ .../proxy/proxy_server/test_routes_models.py | 103 ++++++++++++++++++ 4 files changed, 239 insertions(+) diff --git a/litellm/llms/anthropic/common_utils.py b/litellm/llms/anthropic/common_utils.py index 9aa5a4f465f..b444c77d718 100644 --- a/litellm/llms/anthropic/common_utils.py +++ b/litellm/llms/anthropic/common_utils.py @@ -5,6 +5,7 @@ This file contains common utils for anthropic calls. import copy import re from collections.abc import Mapping, Sequence +from datetime import datetime, timezone from types import MappingProxyType from typing import Any, Final, Literal @@ -12,6 +13,7 @@ import httpx from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError import litellm +from litellm.constants import DEFAULT_MODEL_CREATED_AT_TIME from litellm.litellm_core_utils.prompt_templates.common_utils import ( get_file_ids_from_messages, ) @@ -28,6 +30,7 @@ from litellm.types.llms.anthropic import ( AnthropicMcpServerTool, ) from litellm.types.llms.openai import AllMessageValues +from litellm.types.proxy.model_listing import ModelInfoResponse _BEDROCK_VERSION_SUFFIX_RE: Final = re.compile(r"-v\d+(?::\d+)?$") _INFERENCE_PROFILE_MINOR_RE: Final = re.compile(r":\d+$") @@ -1221,3 +1224,39 @@ def process_anthropic_headers(headers: httpx.Headers | dict) -> dict: additional_headers: Final = {**llm_response_headers, **openai_headers} return additional_headers + + +def _anthropic_model_entry(model: ModelInfoResponse, created_at: str) -> Mapping[str, object]: + token_limits: Final = ( + ("max_input_tokens", model.get("max_input_tokens")), + ("max_tokens", model.get("max_output_tokens")), + ) + return { # mutable-ok: JSON response body, serialized by the route and never mutated + "type": "model", + "id": model["id"], + "display_name": model["id"], + "created_at": created_at, + **{name: limit for name, limit in token_limits if limit is not None}, # mutable-ok: merged into the body above + } + + +def create_anthropic_model_list_response(models: Sequence[ModelInfoResponse]) -> Mapping[str, object]: + """Build the Anthropic-native /v1/models envelope. + + Clients that send an anthropic-version header parse the Anthropic Models API + shape (type/display_name/created_at plus has_more/first_id/last_id) and filter + the list themselves, so every model is returned here. The token limits carry + over from the OpenAI-shaped listing, named as the Messages API names them + """ + created_at: Final = ( + datetime.fromtimestamp(DEFAULT_MODEL_CREATED_AT_TIME, tz=timezone.utc).isoformat().replace("+00:00", "Z") + ) + data: Final = [ # mutable-ok: JSON response body, serialized by the route and never mutated + _anthropic_model_entry(model, created_at) for model in models + ] + return { # mutable-ok: JSON response body, serialized by the route and never mutated + "data": data, + "has_more": False, + "first_id": models[0]["id"] if models else None, + "last_id": models[-1]["id"] if models else None, + } diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index b1b5a7ffbe5..359187f81cb 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -9493,6 +9493,7 @@ class ProxyStartupEvent: "/models", dependencies=[Depends(user_api_key_auth)], tags=["model management"] ) # if project requires model list async def model_list( + request: Request = None, # pyright: ignore[reportArgumentType] # FastAPI always injects the Request; the None default only serves direct in-process callers user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), return_wildcard_routes: bool | None = False, team_id: str | None = None, @@ -9529,6 +9530,9 @@ async def model_list( settings: Final = cast(dict[str, object], general_settings) # any-ok: legacy settings + from litellm.llms.anthropic.common_utils import ( + create_anthropic_model_list_response, + ) from litellm.proxy.management_endpoints.common_utils import ( _user_has_admin_privileges, ) @@ -9536,6 +9540,12 @@ async def model_list( create_model_info_response, get_available_models_for_user, ) + from litellm.types.proxy.model_listing import ModelInfoResponse + + http_request: Final = cast(Request | None, request) # cast-ok: in-process callers pass no request + wants_anthropic_format: Final = ( + http_request is not None and http_request.headers.get("anthropic-version") is not None + ) # Validate scope parameter if provided if scope is not None and scope != "expand": @@ -9619,6 +9629,10 @@ async def model_list( model_info["id"] = response_id model_data.append(model_info) + if wants_anthropic_format: + admin_listing: Final = cast(Sequence[ModelInfoResponse], model_data) # cast-ok: rows built above + return create_anthropic_model_list_response(admin_listing) + return dict( data=model_data, object="list", @@ -9659,6 +9673,10 @@ async def model_list( model_info["id"] = response_id model_data.append(model_info) + if wants_anthropic_format: + listing: Final = cast(Sequence[ModelInfoResponse], model_data) # cast-ok: rows built above + return create_anthropic_model_list_response(listing) + return dict( data=model_data, object="list", diff --git a/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py b/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py index 9df72108332..431030bcf2e 100644 --- a/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py +++ b/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py @@ -2028,3 +2028,82 @@ class TestCapabilityProbeUsesCallerProvider: AnthropicModelInfo._is_adaptive_thinking_model("claude-opus-4-8", "anthropic") is True ) +def test_create_anthropic_model_list_response_shape(): + from litellm.llms.anthropic.common_utils import ( + create_anthropic_model_list_response, + ) + + response = create_anthropic_model_list_response( + [ + {"id": "claude-opus-4-6", "object": "model", "created": 0, "owned_by": "openai"}, + {"id": "gpt-4o", "object": "model", "created": 0, "owned_by": "openai"}, + {"id": "claude-haiku-4-5", "object": "model", "created": 0, "owned_by": "openai"}, + ] + ) + + assert "object" not in response + assert response["has_more"] is False + assert response["first_id"] == "claude-opus-4-6" + assert response["last_id"] == "claude-haiku-4-5" + assert [m["id"] for m in response["data"]] == [ + "claude-opus-4-6", + "gpt-4o", + "claude-haiku-4-5", + ] + for entry in response["data"]: + assert entry["type"] == "model" + assert entry["display_name"] == entry["id"] + # ISO 8601 with a Z suffix, as the Anthropic Models API returns. + assert entry["created_at"].endswith("Z") + assert "+00:00" not in entry["created_at"] + assert "max_input_tokens" not in entry + assert "max_tokens" not in entry + + +def test_create_anthropic_model_list_response_carries_token_limits(): + from litellm.llms.anthropic.common_utils import ( + create_anthropic_model_list_response, + ) + + response = create_anthropic_model_list_response( + [ + { + "id": "claude-opus-4-6", + "object": "model", + "created": 0, + "owned_by": "openai", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + }, + { + "id": "input-only", + "object": "model", + "created": 0, + "owned_by": "openai", + "max_input_tokens": 8192, + }, + {"id": "unknown-limits", "object": "model", "created": 0, "owned_by": "openai"}, + ] + ) + + opus, input_only, unknown = response["data"] + assert opus["max_input_tokens"] == 200000 + assert opus["max_tokens"] == 64000 + assert "max_output_tokens" not in opus + assert input_only["max_input_tokens"] == 8192 + assert "max_tokens" not in input_only + assert "max_input_tokens" not in unknown + assert "max_tokens" not in unknown + + +def test_create_anthropic_model_list_response_empty(): + from litellm.llms.anthropic.common_utils import ( + create_anthropic_model_list_response, + ) + + response = create_anthropic_model_list_response([]) + + assert response["data"] == [] + assert response["has_more"] is False + assert response["first_id"] is None + assert response["last_id"] is None \ No newline at end of file diff --git a/tests/test_litellm/proxy/proxy_server/test_routes_models.py b/tests/test_litellm/proxy/proxy_server/test_routes_models.py index 381835fbc14..f18c5998b8c 100644 --- a/tests/test_litellm/proxy/proxy_server/test_routes_models.py +++ b/tests/test_litellm/proxy/proxy_server/test_routes_models.py @@ -99,6 +99,62 @@ def test_get_models_happy_path(client, auth_as, patched_models, path): } +@pytest.mark.parametrize("path", ["/v1/models", "/models"]) +def test_get_models_anthropic_format_when_header_present( + client, auth_as, patched_models, path +): + """Pins: ``GET /v1/models`` returns the Anthropic-native models shape when + the caller sends an ``anthropic-version`` header (Claude Code gateway + discovery), while the default OpenAI shape is unchanged without it.""" + with auth_as(): + response = client.get(path, headers={"anthropic-version": "2023-06-01"}) + assert response.status_code == 200 + body = response.json() + assert "object" not in body + assert body["has_more"] is False + assert body["first_id"] == "gpt-4" + assert body["last_id"] == "claude-sonnet" + assert [m["id"] for m in body["data"]] == ["gpt-4", "claude-sonnet"] + for entry in body["data"]: + assert entry["type"] == "model" + assert entry["display_name"] == entry["id"] + assert entry["created_at"].endswith("Z") + + +@pytest.mark.parametrize("path", ["/v1/models", "/models"]) +def test_anthropic_format_exposes_token_limits( + client, auth_as, patched_models, monkeypatch, path +): + """Claude Code sizes requests off the listing, so the Anthropic-native entries + carry the same token limits the OpenAI listing resolves, with the output budget + named max_tokens as the Messages API names it.""" + from litellm.proxy import utils as proxy_utils + + def _create_model_info_response(model_id, provider="openai", **kwargs): + if model_id != "claude-sonnet": + return _stub_model_info_response(model_id=model_id, provider=provider) + return { + **_stub_model_info_response(model_id=model_id, provider=provider), + "max_input_tokens": 200000, + "max_output_tokens": 64000, + } + + monkeypatch.setattr( + proxy_utils, "create_model_info_response", _create_model_info_response + ) + + with auth_as(): + response = client.get(path, headers={"anthropic-version": "2023-06-01"}) + + assert response.status_code == 200 + gpt_4, claude = response.json()["data"] + assert claude["max_input_tokens"] == 200000 + assert claude["max_tokens"] == 64000 + assert "max_output_tokens" not in claude + assert "max_input_tokens" not in gpt_4 + assert "max_tokens" not in gpt_4 + + @pytest.mark.parametrize("path", ["/v1/models", "/models"]) def test_get_models_invalid_scope_returns_400(client, auth_as, patched_models, path): """Pins: ``GET /v1/models``, ``GET /models`` (error path: invalid scope).""" @@ -130,3 +186,50 @@ def test_get_model_by_id_not_found(client, auth_as, patched_models, path): response = client.get(path) assert response.status_code == 404 assert "not found" in response.text.lower() + + +@pytest.mark.parametrize("params", [{}, {"scope": "expand"}]) +def test_anthropic_format_returns_public_team_model_name( + client, auth_as, patched_models, monkeypatch, params +): + """Regression: the Anthropic-native listing must go through the same team + name translation as the OpenAI listing, so a caller never sees the internal + ``model_name_{team_id}_{uuid}`` routing key.""" + from litellm.proxy import utils as proxy_utils + from litellm.proxy.auth import model_checks + + internal_name = "model_name_team-1_c0ffee" + + patched_models.get_model_list = MagicMock( + return_value=[ + { + "model_name": internal_name, + "model_info": { + "team_id": "team-1", + "team_public_model_name": "gpt-4-team", + }, + } + ] + ) + patched_models.get_model_names = MagicMock(return_value=[internal_name]) + + async def _fake_get_available_models_for_user(**kwargs): + return [internal_name] + + monkeypatch.setattr( + proxy_utils, + "get_available_models_for_user", + _fake_get_available_models_for_user, + ) + monkeypatch.setattr( + model_checks, "get_complete_model_list", lambda **kwargs: [internal_name] + ) + + with auth_as(): + response = client.get( + "/v1/models", params=params, headers={"anthropic-version": "2023-06-01"} + ) + + assert response.status_code == 200 + assert [m["id"] for m in response.json()["data"]] == ["gpt-4-team"] + assert internal_name not in response.text From 4974290d3f393c434b954693bb47e8679f4a112f Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 14 Aug 2026 10:33:27 -0700 Subject: [PATCH 39/49] fix(ui): keep the cost tracking removal confirmation open until it settles The discount and margin removal confirmation used AlertDialogAction, which renders AlertDialogPrimitive.Close and dismisses the dialog on click. The dialog therefore disappeared while the removal request was still in flight, leaving the admin with no sign that anything happened and free to fire a duplicate removal. Swap the confirm control for a plain destructive Button, track an isRemoving pending state that disables Cancel and relabels Remove to "Removing...", and clear the pending removal in a finally block once the request settles. --- .../cost_tracking_settings.test.tsx | 28 +++++++++++++++++- .../_components/cost_tracking_settings.tsx | 29 +++++++++++-------- 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.test.tsx index c53b7b618b2..716654e914c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.test.tsx @@ -1,6 +1,6 @@ import React from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; -import { screen } from "@testing-library/react"; +import { act, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { renderWithProviders } from "../../../../../tests/test-utils"; import CostTrackingSettings from "./cost_tracking_settings"; @@ -197,6 +197,32 @@ describe("CostTrackingSettings", () => { expect(screen.queryByRole("button", { name: "Remove" })).not.toBeInTheDocument(); }); + it("should hold the confirmation open while the removal is still in flight", async () => { + mockDiscountConfig.mockReturnValue({ openai: 0.05 }); + let settleRemoval: () => void = () => {}; + mockRemoveDiscount.mockReturnValue( + new Promise((resolve) => { + settleRemoval = resolve; + }), + ); + + const user = await expandAndRemove("Provider Discounts", "Remove discount for openai"); + await user.click(await screen.findByRole("button", { name: "Remove" })); + + const removing = await screen.findByRole("button", { name: "Removing…" }); + expect(removing).toBeDisabled(); + expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled(); + + await act(async () => { + settleRemoval(); + }); + + await waitFor(() => { + expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); + }); + expect(mockRemoveDiscount).toHaveBeenCalledWith("openai"); + }); + it("should remove the margin once removal is confirmed", async () => { mockMarginConfig.mockReturnValue({ openai: 0.1 }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.tsx index ba2d830ae7b..7f86bad3028 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.tsx @@ -3,7 +3,6 @@ import { ChevronDown } from "lucide-react"; import { Modal, Form } from "antd"; import { AlertDialog, - AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, @@ -66,6 +65,7 @@ const CostTrackingSettings: React.FC = ({ userID, use const [fixedAmountValue, setFixedAmountValue] = useState(""); const [models, setModels] = useState([]); const [pendingRemoval, setPendingRemoval] = useState(null); + const [isRemoving, setIsRemoving] = useState(false); const [form] = Form.useForm(); const [marginForm] = Form.useForm(); @@ -131,14 +131,19 @@ const CostTrackingSettings: React.FC = ({ userID, use setPendingRemoval({ kind: "discount", provider, displayName: providerDisplayName }); }; - const handleConfirmRemoval = () => { + const handleConfirmRemoval = async () => { if (!pendingRemoval) return; - if (pendingRemoval.kind === "discount") { - removeProvider(pendingRemoval.provider); - } else { - removeMargin(pendingRemoval.provider); + setIsRemoving(true); + try { + if (pendingRemoval.kind === "discount") { + await removeProvider(pendingRemoval.provider); + } else { + await removeMargin(pendingRemoval.provider); + } + } finally { + setIsRemoving(false); + setPendingRemoval(null); } - setPendingRemoval(null); }; const handleAddMargin = async () => { @@ -311,7 +316,7 @@ const CostTrackingSettings: React.FC = ({ userID, use
{pendingRemoval && ( - !open && setPendingRemoval(null)}> + !open && !isRemoving && setPendingRemoval(null)}> {REMOVAL_COPY[pendingRemoval.kind].title} @@ -321,10 +326,10 @@ const CostTrackingSettings: React.FC = ({ userID, use - Cancel - - Remove - + Cancel + From 7da8a3bef505b05dbc95d0885cee8a2fc6f22549 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 14 Aug 2026 10:43:15 -0700 Subject: [PATCH 40/49] test(ui): build the deferred removal with Promise.withResolvers The pending-state test seeded its deferred promise by declaring the resolver with let and reassigning it inside the executor. Promise.withResolvers is the standard way to get the same handle without the reassignment, and the assertions are unchanged. --- .../_components/cost_tracking_settings.test.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.test.tsx index 716654e914c..03cef2a66b8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.test.tsx @@ -199,12 +199,8 @@ describe("CostTrackingSettings", () => { it("should hold the confirmation open while the removal is still in flight", async () => { mockDiscountConfig.mockReturnValue({ openai: 0.05 }); - let settleRemoval: () => void = () => {}; - mockRemoveDiscount.mockReturnValue( - new Promise((resolve) => { - settleRemoval = resolve; - }), - ); + const { promise, resolve: settleRemoval } = Promise.withResolvers(); + mockRemoveDiscount.mockReturnValue(promise); const user = await expandAndRemove("Provider Discounts", "Remove discount for openai"); await user.click(await screen.findByRole("button", { name: "Remove" })); From fe61fa12e4daa46caa29133769349b5aea037f54 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 14 Aug 2026 11:01:43 -0700 Subject: [PATCH 41/49] refactor(ui): declare DateRangePickerValue locally instead of importing it from tremor DateRangePickerValue is a plain object shape, not a component, so the twelve files that used it were each carrying a no-restricted-imports suppression for a type that tremor declares as { from?: Date; to?: Date; selectValue?: string }. Declare that shape in components/shared/date_picker_types.ts and point every consumer at it, which drops ten suppressions from the baseline. advanced_date_picker and usage_date_picker keep their tremor imports: they still render tremor Button, Text and DateRangePicker, and moving DateRangePicker itself needs react-day-picker. --- ui/litellm-dashboard/eslint-suppressions.json | 40 ------------------- .../caching/_components/cache_dashboard.tsx | 2 +- .../_components/GuardrailsMonitorView.tsx | 2 +- .../components/EntityUsage/EntityUsage.tsx | 2 +- .../_components/components/UsagePageView.tsx | 2 +- .../EntityUsageExport/ExportSummary.tsx | 2 +- .../EntityUsageExport/UsageExportHeader.tsx | 2 +- .../src/components/EntityUsageExport/types.ts | 2 +- .../EntityUsageExport/utils.test.ts | 2 +- .../src/components/EntityUsageExport/utils.ts | 2 +- .../shared/advanced_date_picker.tsx | 3 +- .../components/shared/date_picker_types.ts | 5 +++ .../components/shared/usage_date_picker.tsx | 3 +- .../src/components/user_agent_activity.tsx | 2 +- 14 files changed, 19 insertions(+), 52 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/shared/date_picker_types.ts diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index f73e3e6dda3..040809e53f0 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -140,9 +140,6 @@ "local/filename-pascal-case": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/purity": { "count": 1 }, @@ -301,11 +298,6 @@ "count": 3 } }, - "src/app/(dashboard)/guardrails-monitor/_components/GuardrailsMonitorView.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.tsx": { "no-nested-ternary": { "count": 5 @@ -1484,9 +1476,6 @@ "src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx": { "local/no-complex-jsx-arrow": { "count": 2 - }, - "no-restricted-imports": { - "count": 1 } }, "src/app/(dashboard)/usage/_components/components/UsageAIChatPanel.tsx": { @@ -1507,9 +1496,6 @@ "no-nested-ternary": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/purity": { "count": 1 }, @@ -1736,32 +1722,9 @@ "count": 1 } }, - "src/components/EntityUsageExport/ExportSummary.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "src/components/EntityUsageExport/UsageExportHeader.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "src/components/EntityUsageExport/types.ts": { - "no-restricted-imports": { - "count": 1 - } - }, - "src/components/EntityUsageExport/utils.test.ts": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/EntityUsageExport/utils.ts": { "max-params": { "count": 3 - }, - "no-restricted-imports": { - "count": 1 } }, "src/components/GuardrailSettingsView.tsx": { @@ -3222,9 +3185,6 @@ "local/filename-pascal-case": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 1 } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx index 5167ac16542..e35c0103f7c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx @@ -1,4 +1,4 @@ -import { DateRangePickerValue } from "@tremor/react"; +import type { DateRangePickerValue } from "@/components/shared/date_picker_types"; import React, { useEffect, useState } from "react"; import NotificationsManager from "@/components/molecules/notifications_manager"; import UsageDatePicker from "@/components/shared/usage_date_picker"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsMonitorView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsMonitorView.tsx index 14849438135..3b5cc156242 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsMonitorView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsMonitorView.tsx @@ -1,4 +1,4 @@ -import type { DateRangePickerValue } from "@tremor/react"; +import type { DateRangePickerValue } from "@/components/shared/date_picker_types"; import React, { useCallback, useMemo, useState } from "react"; import { formatDate } from "@/components/networking"; import AdvancedDatePicker from "@/components/shared/advanced_date_picker"; 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 2d6dbb823cc..c69764df471 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,7 +14,7 @@ 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 type { DateRangePickerValue } from "@tremor/react"; +import type { DateRangePickerValue } from "@/components/shared/date_picker_types"; import { ChevronDown, ChevronRight, ExternalLink, Info, Loader2 } from "lucide-react"; import type { ColumnDef } from "@tanstack/react-table"; import { Alert, AlertDescription } from "@/components/shared/Alert"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx index a716d59bdc4..96cb0098a3a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx @@ -7,7 +7,7 @@ */ import { ChevronDown, ChevronRight, Download, ExternalLink, Info, Loader2, Sparkles, X } from "lucide-react"; -import type { DateRangePickerValue } from "@tremor/react"; +import type { DateRangePickerValue } from "@/components/shared/date_picker_types"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { BarChart } from "@/components/shared/charts"; diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/ExportSummary.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/ExportSummary.tsx index bec65db9309..e5d96e0b145 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/ExportSummary.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/ExportSummary.tsx @@ -1,5 +1,5 @@ import React from "react"; -import type { DateRangePickerValue } from "@tremor/react"; +import type { DateRangePickerValue } from "@/components/shared/date_picker_types"; interface ExportSummaryProps { dateRange: DateRangePickerValue; diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx index 78781df5948..adacdf16f76 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx @@ -1,4 +1,4 @@ -import type { DateRangePickerValue } from "@tremor/react"; +import type { DateRangePickerValue } from "@/components/shared/date_picker_types"; import { Download } from "lucide-react"; import React, { useState } from "react"; import { Button } from "@/components/ui/button"; diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts index d0c3235c4e8..30714ad632d 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts @@ -1,4 +1,4 @@ -import type { DateRangePickerValue } from "@tremor/react"; +import type { DateRangePickerValue } from "@/components/shared/date_picker_types"; import type { Team } from "@/components/key_team_helpers/key_list"; export type ExportFormat = "csv" | "json"; diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts index 08ca298c1f1..97f14e2d3d0 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts @@ -1,4 +1,4 @@ -import type { DateRangePickerValue } from "@tremor/react"; +import type { DateRangePickerValue } from "@/components/shared/date_picker_types"; import Papa from "papaparse"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { EntitySpendData, ExportScope } from "./types"; diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts index 9adcb50206d..de637d5d627 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts @@ -1,5 +1,5 @@ import { formatNumberWithCommas } from "@/utils/dataUtils"; -import type { DateRangePickerValue } from "@tremor/react"; +import type { DateRangePickerValue } from "@/components/shared/date_picker_types"; import Papa from "papaparse"; import type { EntityBreakdown, EntitySpendData, EntityType, ExportMetadata, ExportScope } from "./types"; diff --git a/ui/litellm-dashboard/src/components/shared/advanced_date_picker.tsx b/ui/litellm-dashboard/src/components/shared/advanced_date_picker.tsx index 013ccd118e9..03d5e1bc630 100644 --- a/ui/litellm-dashboard/src/components/shared/advanced_date_picker.tsx +++ b/ui/litellm-dashboard/src/components/shared/advanced_date_picker.tsx @@ -1,5 +1,6 @@ import { CalendarOutlined, ClockCircleOutlined } from "@ant-design/icons"; -import { Button, DateRangePickerValue, Text } from "@tremor/react"; +import { Button, Text } from "@tremor/react"; +import type { DateRangePickerValue } from "./date_picker_types"; import moment from "moment"; import React, { useCallback, useEffect, useRef, useState } from "react"; diff --git a/ui/litellm-dashboard/src/components/shared/date_picker_types.ts b/ui/litellm-dashboard/src/components/shared/date_picker_types.ts new file mode 100644 index 00000000000..130c7e3b59a --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/date_picker_types.ts @@ -0,0 +1,5 @@ +export type DateRangePickerValue = { + from?: Date; + to?: Date; + selectValue?: string; +}; diff --git a/ui/litellm-dashboard/src/components/shared/usage_date_picker.tsx b/ui/litellm-dashboard/src/components/shared/usage_date_picker.tsx index 821dee65a3e..390da4c8f88 100644 --- a/ui/litellm-dashboard/src/components/shared/usage_date_picker.tsx +++ b/ui/litellm-dashboard/src/components/shared/usage_date_picker.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useState, useRef } from "react"; -import { DateRangePicker, DateRangePickerValue, Text } from "@tremor/react"; +import { DateRangePicker, Text } from "@tremor/react"; +import type { DateRangePickerValue } from "./date_picker_types"; interface UsageDatePickerProps { value: DateRangePickerValue; diff --git a/ui/litellm-dashboard/src/components/user_agent_activity.tsx b/ui/litellm-dashboard/src/components/user_agent_activity.tsx index a05db3313ab..ca6c2953dfe 100644 --- a/ui/litellm-dashboard/src/components/user_agent_activity.tsx +++ b/ui/litellm-dashboard/src/components/user_agent_activity.tsx @@ -17,7 +17,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { BarChart } from "@/components/shared/charts"; import { userAgentSummaryCall, tagDauCall, tagWauCall, tagMauCall, tagDistinctCall } from "./networking"; import PerUserUsage from "./per_user_usage"; -import type { DateRangePickerValue } from "@tremor/react"; +import type { DateRangePickerValue } from "@/components/shared/date_picker_types"; import { ChartLoader } from "./shared/chart_loader"; // New interfaces for the updated API response From caf305f732cb2d48ce65d798c2ad0704dd934c33 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 14 Aug 2026 11:08:09 -0700 Subject: [PATCH 42/49] refactor(ui): move MCP permission panels onto shadcn primitives Replaces antd Radio, Checkbox and Tooltip, plus Tremor Text and Badge, with the in-repo shadcn equivalents across the three MCP permission panels, and drops the no-restricted-imports suppressions they no longer need. Also removes the stale suppression on settings.test.tsx, which imports neither library. The tool rows keep their existing click-to-toggle behaviour: the row owns the toggle and the checkbox no longer carries its own change handler, since Base UI replays the click through a hidden input that reaches the row on its own. Adds payload-level tests for the risk-group view covering group clear, mixed-state re-arm, single-tool toggles from both the box and the row, and a controlled round trip proving each control re-renders from the permissions it emitted. --- ui/litellm-dashboard/eslint-suppressions.json | 14 -- .../MCPToolPermissions.test.tsx | 133 +++++++++++++++++- .../MCPToolPermissions.tsx | 48 ++++--- .../mcp_tools/McpCrudPermissionPanel.tsx | 18 +-- .../permissions/MCPServerPermissions.tsx | 23 +-- 5 files changed, 175 insertions(+), 61 deletions(-) diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index f73e3e6dda3..d583aea1114 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -2504,9 +2504,6 @@ "src/components/mcp_server_management/MCPToolPermissions.tsx": { "local/no-complex-jsx-arrow": { "count": 1 - }, - "no-restricted-imports": { - "count": 2 } }, "src/components/mcp_tools/ByokCredentialModal.tsx": { @@ -2525,9 +2522,6 @@ "src/components/mcp_tools/McpCrudPermissionPanel.tsx": { "no-nested-ternary": { "count": 3 - }, - "no-restricted-imports": { - "count": 2 } }, "src/components/mcp_tools/types.tsx": { @@ -2720,9 +2714,6 @@ "src/components/permissions/MCPServerPermissions.tsx": { "no-nested-ternary": { "count": 3 - }, - "no-restricted-imports": { - "count": 2 } }, "src/components/policies/PolicySelector.tsx": { @@ -2816,11 +2807,6 @@ "count": 1 } }, - "src/components/settings.test.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/settings.tsx": { "local/filename-pascal-case": { "count": 1 diff --git a/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.test.tsx b/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.test.tsx index 42ecdd6fd9b..4b5447dfd89 100644 --- a/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.test.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; @@ -63,13 +64,14 @@ describe("MCPToolPermissions", () => { expect(screen.getByText("read_wiki_structure")).toBeInTheDocument(); }); - // Switch to Flat List view for predictable checkbox ordering - const flatListOption = screen.getByText("Flat List"); - await userEvent.click(flatListOption); + // Switch to Flat List view, and prove the view actually switched: the flat + // list is the only view that renders the description inline after a dash. + await userEvent.click(screen.getByText("Flat List")); + expect(screen.getByRole("radio", { name: "Flat List" })).toBeChecked(); + expect(await screen.findByText("- Get documentation topics")).toBeInTheDocument(); - // Click the first checkbox to deselect read_wiki_structure - const checkboxes = screen.getAllByRole("checkbox"); - await userEvent.click(checkboxes[0]); + // Deselect read_wiki_structure + await userEvent.click(screen.getByRole("checkbox", { name: "read_wiki_structure" })); // Verify onChange was called with read_wiki_structure removed expect(mockOnChange).toHaveBeenCalledWith({ @@ -184,4 +186,123 @@ describe("MCPToolPermissions", () => { [mockServerId]: [], }); }); + + describe("risk-group (CRUD) view", () => { + const crudTools = [ + { name: "list_documents", description: "List every document" }, + { name: "get_document", description: "Fetch one document" }, + { name: "delete_document", description: "Destroy a document" }, + ]; + const allCrudToolNames = crudTools.map((t) => t.name); + + const renderCrudView = (toolPermissions: Record, onChange: () => void) => { + vi.mocked(networking.fetchMCPServers).mockResolvedValue([ + { server_id: mockServerId, server_name: mockServerName, alias: mockServerName }, + ]); + vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: crudTools, error: false }); + + renderWithProviders( + , + ); + }; + + it("removes a whole risk group from the saved payload when its group toggle is cleared", async () => { + const mockOnChange = vi.fn(); + renderCrudView({ [mockServerId]: allCrudToolNames }, mockOnChange); + + const readGroupToggle = await screen.findByRole("checkbox", { name: "Allow all Read tools" }); + expect(readGroupToggle).toBeChecked(); + + await userEvent.click(readGroupToggle); + + // Both read-classified tools drop out; the delete-classified one survives. + expect(mockOnChange).toHaveBeenCalledWith({ [mockServerId]: ["delete_document"] }); + }); + + it("adds the rest of a partially-allowed risk group when its mixed toggle is clicked", async () => { + const mockOnChange = vi.fn(); + renderCrudView({ [mockServerId]: ["list_documents"] }, mockOnChange); + + const readGroupToggle = await screen.findByRole("checkbox", { name: "Allow all Read tools" }); + expect(readGroupToggle).toBePartiallyChecked(); + + await userEvent.click(readGroupToggle); + + expect(mockOnChange).toHaveBeenCalledWith({ [mockServerId]: ["list_documents", "get_document"] }); + }); + + it("toggles a single tool exactly once when its checkbox is clicked inside the clickable row", async () => { + const mockOnChange = vi.fn(); + renderCrudView({ [mockServerId]: allCrudToolNames }, mockOnChange); + + await userEvent.click(await screen.findByRole("checkbox", { name: "delete_document" })); + + // The surrounding row is itself clickable, so a click that bubbles would + // toggle twice and the permission would silently stay allowed. + expect(mockOnChange).toHaveBeenCalledTimes(1); + expect(mockOnChange).toHaveBeenCalledWith({ [mockServerId]: ["list_documents", "get_document"] }); + }); + + it("toggles a single tool when the row around its checkbox is clicked", async () => { + const mockOnChange = vi.fn(); + renderCrudView({ [mockServerId]: allCrudToolNames }, mockOnChange); + + await userEvent.click(await screen.findByText("Destroy a document")); + + expect(mockOnChange).toHaveBeenCalledTimes(1); + expect(mockOnChange).toHaveBeenCalledWith({ [mockServerId]: ["list_documents", "get_document"] }); + }); + + it("re-renders each checkbox from the permissions it emitted", async () => { + // Drives the panel from real parent state so the assertions cover the full + // round trip: click, emitted payload, then the state the panel renders back. + const Harness = () => { + const [permissions, setPermissions] = useState>({ + [mockServerId]: allCrudToolNames, + }); + return ( + <> + + {(permissions[mockServerId] ?? []).join(",")} + + ); + }; + + vi.mocked(networking.fetchMCPServers).mockResolvedValue([ + { server_id: mockServerId, server_name: mockServerName, alias: mockServerName }, + ]); + vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: crudTools, error: false }); + renderWithProviders(); + + const deleteTool = await screen.findByRole("checkbox", { name: "delete_document" }); + const readGroupToggle = screen.getByRole("checkbox", { name: "Allow all Read tools" }); + expect(deleteTool).toBeChecked(); + expect(readGroupToggle).toBeChecked(); + + await userEvent.click(deleteTool); + expect(deleteTool).not.toBeChecked(); + expect(screen.getByRole("status")).toHaveTextContent("list_documents,get_document"); + + // Clearing one tool of the Read group must leave that group's toggle mixed. + await userEvent.click(screen.getByRole("checkbox", { name: "get_document" })); + expect(readGroupToggle).toBePartiallyChecked(); + expect(screen.getByRole("status")).toHaveTextContent("list_documents"); + + // Re-arming the group restores both read tools and leaves delete blocked. + await userEvent.click(readGroupToggle); + expect(readGroupToggle).toBeChecked(); + expect(screen.getByRole("status")).toHaveTextContent("list_documents,get_document"); + expect(screen.getByRole("checkbox", { name: "delete_document" })).not.toBeChecked(); + }); + }); }); diff --git a/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.tsx b/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.tsx index e05352d434f..d99dc3077d5 100644 --- a/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.tsx +++ b/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.tsx @@ -1,8 +1,8 @@ import React, { useEffect, useRef, useState, useMemo } from "react"; import { listMCPTools } from "../networking"; import { MCPTool, MCPServer } from "../mcp_tools/types"; -import { Text } from "@tremor/react"; -import { Spin, Radio } from "antd"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { useMCPServers } from "../../app/(dashboard)/hooks/mcpServers/useMCPServers"; import McpCrudPermissionPanel from "../mcp_tools/McpCrudPermissionPanel"; import { classifyToolOp } from "../../utils/mcpToolCrudClassification"; @@ -121,22 +121,27 @@ const MCPToolPermissions: React.FC = ({ {/* Header */}
- {serverName} - {server.description && {server.description}} +

{serverName}

+ {server.description &&

{server.description}

}
{!disabled && tools.length > 0 && ( - setViewModes((prev) => ({ ...prev, [server.server_id]: e.target.value }))} - size="small" - optionType="button" - buttonStyle="solid" - options={[ - { label: "Risk Groups", value: "crud" }, - { label: "Flat List", value: "flat" }, - ]} - /> + onValueChange={(next) => + setViewModes((prev) => ({ ...prev, [server.server_id]: next as "crud" | "flat" })) + } + className="flex w-auto items-center gap-4" + > + + + )} {!disabled && ( <> @@ -166,16 +171,16 @@ const MCPToolPermissions: React.FC = ({ {/* Loading */} {isLoading && (
- - Loading tools... + +

Loading tools...

)} {/* Error */} {error && !isLoading && (
- Unable to load tools - {error} +

Unable to load tools

+

{error}

)} @@ -198,6 +203,7 @@ const MCPToolPermissions: React.FC = ({
{ if (disabled) return; @@ -211,8 +217,8 @@ const MCPToolPermissions: React.FC = ({ />
- {tool.name} - - {tool.description || "No description"} +

{tool.name}

+

- {tool.description || "No description"}

@@ -224,7 +230,7 @@ const MCPToolPermissions: React.FC = ({ {/* Empty State */} {!isLoading && !error && tools.length === 0 && (
- No tools available +

No tools available

)}
diff --git a/ui/litellm-dashboard/src/components/mcp_tools/McpCrudPermissionPanel.tsx b/ui/litellm-dashboard/src/components/mcp_tools/McpCrudPermissionPanel.tsx index 9cbf7025eaf..1b17f000584 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/McpCrudPermissionPanel.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/McpCrudPermissionPanel.tsx @@ -10,8 +10,7 @@ */ import React, { useMemo, useState } from "react"; -import { Checkbox } from "antd"; -import { Text } from "@tremor/react"; +import { Checkbox } from "@/components/ui/checkbox"; import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"; import { CrudOp, MCPToolEntry, CRUD_GROUP_META, groupToolsByCrud } from "../../utils/mcpToolCrudClassification"; @@ -187,14 +186,13 @@ const McpCrudPermissionPanel: React.FC = ({ {!readOnly && (
- - {fullyAllowed ? "All on" : partial ? "Partial" : "All off"} - +

{fullyAllowed ? "All on" : partial ? "Partial" : "All off"}

{/* Checkbox supports `indeterminate`; Switch does not. */} toggleGroup(op, e.target.checked)} + onCheckedChange={(checked) => toggleGroup(op, checked)} onClick={(e) => e.stopPropagation()} />
@@ -228,16 +226,18 @@ const McpCrudPermissionPanel: React.FC = ({ } ${allowed ? "" : "opacity-60"}`} onClick={() => toggleTool(tool.name)} > + {/* The row's onClick is the single toggle path. Giving this checkbox its + own change handler as well would toggle twice per click on the box. */} toggleTool(tool.name)} disabled={readOnly} onClick={(e) => e.stopPropagation()} />
- {tool.name} +

{tool.name}

{tool.description && ( - {tool.description} +

{tool.description}

)}
- MCP Servers - +

MCP Servers

+ {blocksAllMcpServers ? "Blocked" : grantsAllProxyMcpServers ? "All" : totalCount}
@@ -120,14 +120,14 @@ export function MCPServerPermissions({ {blocksAllMcpServers ? (
- +

No MCP servers — this key is blocked from all MCP servers, including its team's servers - +

) : grantsAllProxyMcpServers ? (
- All Proxy MCP Servers +

All Proxy MCP Servers

) : totalCount > 0 ? (
@@ -146,13 +146,14 @@ export function MCPServerPermissions({ >
{item.type === "server" ? ( - -
+ + }> {getMCPServerDisplayName(item.value)} -
+ + {`Full ID: ${item.value}`}
) : (
@@ -256,7 +257,7 @@ export function MCPServerPermissions({ ) : (
- No MCP servers, access groups, or toolsets configured +

No MCP servers, access groups, or toolsets configured

)}
From aa093980b18d7f2415bb95ed2de243c6d18bee30 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 14 Aug 2026 11:15:30 -0700 Subject: [PATCH 43/49] refactor(ui): migrate ten small dashboard files off antd and tremor Moves the onboarding views, router settings inputs, tag rate limit editor, fallback buttons, created-key display and the shared numerical input onto the in-repo shadcn layer. Each control has a direct equivalent, so this is a like-for-like swap with no layout changes and no new styling. Router settings saves by reading input values straight off the DOM with document.querySelector('input[name="..."]'), a path no test covered. Adds a regression test that types into a field and asserts the typed value reaches the payload, so the name attribute contract stays enforced. Also adds tests for TagRateLimitEditor, which had none and whose RPM cell switched from antd InputNumber to a native number input. --- ui/litellm-dashboard/eslint-suppressions.json | 42 ------ .../onboarding/OnboardingErrorView.test.tsx | 6 +- .../app/onboarding/OnboardingErrorView.tsx | 19 +-- .../onboarding/OnboardingLoadingView.test.tsx | 8 +- .../app/onboarding/OnboardingLoadingView.tsx | 5 +- .../RouterSettings/Fallbacks/AddFallbacks.tsx | 23 ++- .../TagRateLimitEditor.test.tsx | 133 ++++++++++++++++++ .../key_team_helpers/TagRateLimitEditor.tsx | 17 ++- .../LatencyBasedConfiguration.tsx | 2 +- .../ReliabilityRetriesSection.tsx | 2 +- .../TagFilteringToggle.test.tsx | 5 + .../router_settings/TagFilteringToggle.tsx | 10 +- .../components/router_settings/index.test.tsx | 27 ++++ .../src/components/router_settings/index.tsx | 8 +- .../components/shared/CreatedKeyDisplay.tsx | 6 +- .../src/components/shared/numerical_input.tsx | 9 +- 16 files changed, 225 insertions(+), 97 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/key_team_helpers/TagRateLimitEditor.test.tsx diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index f73e3e6dda3..82697c75b83 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1667,21 +1667,11 @@ "count": 1 } }, - "src/app/onboarding/OnboardingErrorView.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/onboarding/OnboardingFormBody.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/app/onboarding/OnboardingLoadingView.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/AIHub/ModelHubTable.test.tsx": { "max-params": { "count": 1 @@ -1884,9 +1874,6 @@ } }, "src/components/Settings/RouterSettings/Fallbacks/AddFallbacks.tsx": { - "no-restricted-imports": { - "count": 2 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -2425,11 +2412,6 @@ "count": 1 } }, - "src/components/key_team_helpers/TagRateLimitEditor.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/key_team_helpers/fetch_available_models_team_key.tsx": { "local/filename-pascal-case": { "count": 1 @@ -2761,17 +2743,9 @@ "count": 1 } }, - "src/components/router_settings/LatencyBasedConfiguration.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/router_settings/ReliabilityRetriesSection.tsx": { "no-nested-ternary": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/components/router_settings/RoutingStrategySelector.tsx": { @@ -2779,18 +2753,10 @@ "count": 1 } }, - "src/components/router_settings/TagFilteringToggle.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/router_settings/index.tsx": { "local/filename-pascal-case": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "prefer-const": { "count": 2 } @@ -2832,11 +2798,6 @@ "count": 4 } }, - "src/components/shared/CreatedKeyDisplay.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/shared/advanced_date_picker.tsx": { "local/filename-pascal-case": { "count": 1 @@ -2901,9 +2862,6 @@ "src/components/shared/numerical_input.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/components/shared/table_cells/cell_tooltip.tsx": { diff --git a/ui/litellm-dashboard/src/app/onboarding/OnboardingErrorView.test.tsx b/ui/litellm-dashboard/src/app/onboarding/OnboardingErrorView.test.tsx index bfbfb8fdd5d..f0bfdfef40d 100644 --- a/ui/litellm-dashboard/src/app/onboarding/OnboardingErrorView.test.tsx +++ b/ui/litellm-dashboard/src/app/onboarding/OnboardingErrorView.test.tsx @@ -9,6 +9,11 @@ describe("OnboardingErrorView", () => { expect(screen.getByText("Failed to load invitation")).toBeInTheDocument(); }); + it("should expose the failure as an alert to assistive technology", () => { + render(); + expect(screen.getByRole("alert")).toHaveTextContent("Failed to load invitation"); + }); + it("should show the expiry description", () => { render(); expect(screen.getByText("The invitation link may be invalid or expired.")).toBeInTheDocument(); @@ -16,7 +21,6 @@ describe("OnboardingErrorView", () => { it("should render a Back to Login link pointing to /ui/login/", () => { render(); - // antd Button with href renders as an element const link = screen.getByRole("link", { name: "Back to Login" }); expect(link).toHaveAttribute("href", "/ui/login/"); }); diff --git a/ui/litellm-dashboard/src/app/onboarding/OnboardingErrorView.tsx b/ui/litellm-dashboard/src/app/onboarding/OnboardingErrorView.tsx index 3de9a9ffaae..2fa35205a82 100644 --- a/ui/litellm-dashboard/src/app/onboarding/OnboardingErrorView.tsx +++ b/ui/litellm-dashboard/src/app/onboarding/OnboardingErrorView.tsx @@ -1,18 +1,21 @@ import React from "react"; -import { Alert, Button } from "antd"; +import { CircleAlert } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert"; +import { buttonVariants } from "@/components/ui/button"; import { getLoginUrl } from "@/utils/returnUrlUtils"; export function OnboardingErrorView() { return ( ); diff --git a/ui/litellm-dashboard/src/app/onboarding/OnboardingLoadingView.test.tsx b/ui/litellm-dashboard/src/app/onboarding/OnboardingLoadingView.test.tsx index 21c5ccf69d0..755647fa3fc 100644 --- a/ui/litellm-dashboard/src/app/onboarding/OnboardingLoadingView.test.tsx +++ b/ui/litellm-dashboard/src/app/onboarding/OnboardingLoadingView.test.tsx @@ -1,12 +1,12 @@ import React from "react"; -import { render } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import { describe, it, expect } from "vitest"; import { OnboardingLoadingView } from "./OnboardingLoadingView"; describe("OnboardingLoadingView", () => { - it("should render a spinner container", () => { - const { container } = render(); - expect(container.firstChild).toBeInTheDocument(); + it("should expose the loading state to assistive technology", () => { + render(); + expect(screen.getByRole("status", { name: "Loading invitation" })).toBeInTheDocument(); }); it("should apply centering layout classes", () => { diff --git a/ui/litellm-dashboard/src/app/onboarding/OnboardingLoadingView.tsx b/ui/litellm-dashboard/src/app/onboarding/OnboardingLoadingView.tsx index 7efa1d2504f..4d5d2a1371e 100644 --- a/ui/litellm-dashboard/src/app/onboarding/OnboardingLoadingView.tsx +++ b/ui/litellm-dashboard/src/app/onboarding/OnboardingLoadingView.tsx @@ -1,11 +1,10 @@ import React from "react"; -import { Spin } from "antd"; -import { LoadingOutlined } from "@ant-design/icons"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; export function OnboardingLoadingView() { return (
- } size="large" /> +
); } diff --git a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/AddFallbacks.tsx b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/AddFallbacks.tsx index 6b2950b450d..2bd17bffaba 100644 --- a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/AddFallbacks.tsx +++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/AddFallbacks.tsx @@ -4,9 +4,9 @@ * Works with forms - reads from and writes to router_settings.fallbacks */ -import { Button as TremorButton } from "@tremor/react"; -import { Button } from "antd"; import React, { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import MessageManager from "@/components/molecules/message_manager"; import NotificationManager from "../../../molecules/notifications_manager"; import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models"; @@ -119,13 +119,10 @@ export default function AddFallbacks({ accessToken, value = [], onChange }: AddF return (
- setIsModalVisible(true)} - icon={() => +} - > + 0 && (
- -
diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/TagRateLimitEditor.test.tsx b/ui/litellm-dashboard/src/components/key_team_helpers/TagRateLimitEditor.test.tsx new file mode 100644 index 00000000000..ba53837dc4d --- /dev/null +++ b/ui/litellm-dashboard/src/components/key_team_helpers/TagRateLimitEditor.test.tsx @@ -0,0 +1,133 @@ +import React, { useState } from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect } from "vitest"; +import { TagRateLimitEditor, TagRateLimitEntry, tagLimitsToRows, tagRowsToLimits } from "./TagRateLimitEditor"; + +// The editor is controlled, so multi-character typing only behaves realistically +// when the parent feeds each change back in. +function Harness({ initial = [] as TagRateLimitEntry[], onValue }: { initial?: TagRateLimitEntry[]; onValue?: any }) { + const [rows, setRows] = useState(initial); + return ( + { + setRows(next); + onValue?.(next); + }} + /> + ); +} + +const rowsWith = (tag: string, rpm: number | null): TagRateLimitEntry[] => [{ id: "r1", tag, rpm_limit: rpm }]; + +describe("TagRateLimitEditor", () => { + it("should render one tag and one RPM field per row", () => { + render(); + expect(screen.getByRole("textbox", { name: "Tag" })).toHaveValue("cell-1"); + expect(screen.getByRole("spinbutton", { name: "RPM limit" })).toHaveValue(100); + }); + + it("should add a row when Add Tag Limit is clicked", async () => { + const user = userEvent.setup(); + render(); + expect(screen.queryAllByRole("textbox", { name: "Tag" })).toHaveLength(0); + + await user.click(screen.getByRole("button", { name: /add tag limit/i })); + + expect(screen.getAllByRole("textbox", { name: "Tag" })).toHaveLength(1); + }); + + it("should let the user type a tag name", async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByRole("textbox", { name: "Tag" }), "cell-2"); + + expect(screen.getByRole("textbox", { name: "Tag" })).toHaveValue("cell-2"); + }); + + // The RPM cell feeds tagRowsToLimits, which drops any entry whose limit is not + // typeof "number". A string would silently discard the user's limit. + it("should record the typed RPM limit as a number, not a string", async () => { + const user = userEvent.setup(); + const seen: TagRateLimitEntry[][] = []; + render( seen.push(v)} />); + + await user.type(screen.getByRole("spinbutton", { name: "RPM limit" }), "60"); + + const latest = seen[seen.length - 1][0]; + expect(latest.rpm_limit).toBe(60); + expect(typeof latest.rpm_limit).toBe("number"); + }); + + it("should reset the RPM limit to null when the field is cleared", async () => { + const user = userEvent.setup(); + const seen: TagRateLimitEntry[][] = []; + render( seen.push(v)} />); + + await user.clear(screen.getByRole("spinbutton", { name: "RPM limit" })); + + expect(seen[seen.length - 1][0].rpm_limit).toBeNull(); + }); + + it("should remove only the clicked row", async () => { + const user = userEvent.setup(); + const initial: TagRateLimitEntry[] = [ + { id: "r1", tag: "keep-me", rpm_limit: 10 }, + { id: "r2", tag: "delete-me", rpm_limit: 20 }, + ]; + render(); + + await user.click(screen.getAllByRole("button", { name: "Remove tag limit" })[1]); + + const tags = screen.getAllByRole("textbox", { name: "Tag" }); + expect(tags).toHaveLength(1); + expect(tags[0]).toHaveValue("keep-me"); + }); + + it("should not submit the surrounding form when a row is removed", async () => { + const user = userEvent.setup(); + let submitted = false; + render( + { + submitted = true; + }} + > + + , + ); + + await user.click(screen.getByRole("button", { name: "Remove tag limit" })); + + expect(submitted).toBe(false); + expect(screen.queryAllByRole("textbox", { name: "Tag" })).toHaveLength(0); + }); +}); + +describe("tagRowsToLimits", () => { + it("should map named rows with numeric limits into the rpm map", () => { + expect(tagRowsToLimits([{ id: "a", tag: "cell-1", rpm_limit: 60 }])).toEqual({ tag_rpm_limit: { "cell-1": 60 } }); + }); + + it("should drop rows with a blank tag or a null limit", () => { + const rows: TagRateLimitEntry[] = [ + { id: "a", tag: " ", rpm_limit: 60 }, + { id: "b", tag: "cell-2", rpm_limit: null }, + ]; + expect(tagRowsToLimits(rows)).toEqual({ tag_rpm_limit: {} }); + }); +}); + +describe("tagLimitsToRows", () => { + it("should rebuild rows from a stored rpm map", () => { + const rows = tagLimitsToRows({ "cell-1": 60 }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ tag: "cell-1", rpm_limit: 60 }); + }); + + it("should ignore non-numeric entries", () => { + expect(tagLimitsToRows({ "cell-1": "sixty" })).toEqual([]); + }); +}); diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/TagRateLimitEditor.tsx b/ui/litellm-dashboard/src/components/key_team_helpers/TagRateLimitEditor.tsx index ee022ee9a75..151e1593765 100644 --- a/ui/litellm-dashboard/src/components/key_team_helpers/TagRateLimitEditor.tsx +++ b/ui/litellm-dashboard/src/components/key_team_helpers/TagRateLimitEditor.tsx @@ -1,5 +1,6 @@ -import { Button, Input, InputNumber } from "antd"; import React from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; export interface TagRateLimitEntry { // Stable identity for React list keys so deleting a middle row doesn't shift @@ -72,25 +73,29 @@ export function TagRateLimitEditor({ value, onChange }: TagRateLimitEditorProps) {value.map((row, idx) => (
updateRow(idx, "tag", e.target.value)} placeholder="Tag (e.g. cell-1)" style={{ width: 180 }} /> - updateRow(idx, "rpm_limit", v ?? null)} + value={row.rpm_limit ?? ""} + onChange={(e) => updateRow(idx, "rpm_limit", e.target.value === "" ? null : Number(e.target.value))} placeholder="RPM" style={{ width: 120 }} /> -
))} - +
); diff --git a/ui/litellm-dashboard/src/components/shared/CreatedKeyDisplay.tsx b/ui/litellm-dashboard/src/components/shared/CreatedKeyDisplay.tsx index cbfd5b2f7fa..bcca3e698a5 100644 --- a/ui/litellm-dashboard/src/components/shared/CreatedKeyDisplay.tsx +++ b/ui/litellm-dashboard/src/components/shared/CreatedKeyDisplay.tsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; import { CopyToClipboard } from "react-copy-to-clipboard"; -import { Button } from "antd"; +import { Button } from "@/components/ui/button"; import MessageManager from "@/components/molecules/message_manager"; interface CreatedKeyDisplayProps { @@ -41,9 +41,7 @@ const CreatedKeyDisplay: React.FC = ({ apiKey }) => {
- +
); diff --git a/ui/litellm-dashboard/src/components/shared/numerical_input.tsx b/ui/litellm-dashboard/src/components/shared/numerical_input.tsx index 2682635dc27..c8c5d353a6d 100644 --- a/ui/litellm-dashboard/src/components/shared/numerical_input.tsx +++ b/ui/litellm-dashboard/src/components/shared/numerical_input.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { NumberInput } from "@tremor/react"; +import { Input } from "@/components/ui/input"; interface NumericalInputProps { step?: number; @@ -7,7 +7,7 @@ interface NumericalInputProps { placeholder?: string; min?: number; max?: number; - onChange?: any; // Using any to avoid type conflicts with Tremor's NumberInput + onChange?: any; // Using any to avoid type conflicts with callers that pass antd Form handlers [key: string]: any; } @@ -20,7 +20,7 @@ interface NumericalInputProps { * @param {number} [props.min] - Minimum value * @param {number} [props.max] - Maximum value * @param {Function} [props.onChange] - On change handler - * @param {any} props.rest - Additional props passed to NumberInput + * @param {any} props.rest - Additional props passed to Input */ const NumericalInput: React.FC = ({ step = 0.01, @@ -32,7 +32,8 @@ const NumericalInput: React.FC = ({ ...rest }) => { return ( - event.currentTarget.blur()} step={step} style={style} From 538f5b3e8411db86169d022a308e00ac2717ec0a Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 14 Aug 2026 11:21:01 -0700 Subject: [PATCH 44/49] refactor(ui): drop explanatory comments from the migration tests --- .../components/key_team_helpers/TagRateLimitEditor.test.tsx | 4 ---- .../src/components/router_settings/index.test.tsx | 3 --- .../src/components/shared/numerical_input.tsx | 2 +- 3 files changed, 1 insertion(+), 8 deletions(-) diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/TagRateLimitEditor.test.tsx b/ui/litellm-dashboard/src/components/key_team_helpers/TagRateLimitEditor.test.tsx index ba53837dc4d..85f02f2045e 100644 --- a/ui/litellm-dashboard/src/components/key_team_helpers/TagRateLimitEditor.test.tsx +++ b/ui/litellm-dashboard/src/components/key_team_helpers/TagRateLimitEditor.test.tsx @@ -4,8 +4,6 @@ import userEvent from "@testing-library/user-event"; import { describe, it, expect } from "vitest"; import { TagRateLimitEditor, TagRateLimitEntry, tagLimitsToRows, tagRowsToLimits } from "./TagRateLimitEditor"; -// The editor is controlled, so multi-character typing only behaves realistically -// when the parent feeds each change back in. function Harness({ initial = [] as TagRateLimitEntry[], onValue }: { initial?: TagRateLimitEntry[]; onValue?: any }) { const [rows, setRows] = useState(initial); return ( @@ -47,8 +45,6 @@ describe("TagRateLimitEditor", () => { expect(screen.getByRole("textbox", { name: "Tag" })).toHaveValue("cell-2"); }); - // The RPM cell feeds tagRowsToLimits, which drops any entry whose limit is not - // typeof "number". A string would silently discard the user's limit. it("should record the typed RPM limit as a number, not a string", async () => { const user = userEvent.setup(); const seen: TagRateLimitEntry[][] = []; diff --git a/ui/litellm-dashboard/src/components/router_settings/index.test.tsx b/ui/litellm-dashboard/src/components/router_settings/index.test.tsx index 788886f30c7..72657069cde 100644 --- a/ui/litellm-dashboard/src/components/router_settings/index.test.tsx +++ b/ui/litellm-dashboard/src/components/router_settings/index.test.tsx @@ -134,9 +134,6 @@ describe("RouterSettings", () => { ); }); - // handleSaveChanges reads each setting's value straight off the DOM via - // document.querySelector('input[name="..."]'), so the payload only stays correct - // while the rendered input keeps its name attribute and its live value. it("should send the edited input value, not the loaded one, on Save Changes", async () => { const user = userEvent.setup(); renderWithProviders(); diff --git a/ui/litellm-dashboard/src/components/shared/numerical_input.tsx b/ui/litellm-dashboard/src/components/shared/numerical_input.tsx index c8c5d353a6d..2356bd5600f 100644 --- a/ui/litellm-dashboard/src/components/shared/numerical_input.tsx +++ b/ui/litellm-dashboard/src/components/shared/numerical_input.tsx @@ -7,7 +7,7 @@ interface NumericalInputProps { placeholder?: string; min?: number; max?: number; - onChange?: any; // Using any to avoid type conflicts with callers that pass antd Form handlers + onChange?: any; [key: string]: any; } From c51c5f1821df27ec245df6b32a7806547ebb727c Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 14 Aug 2026 11:24:47 -0700 Subject: [PATCH 45/49] refactor(ui): drop narration comments from the MCP permission panels --- .../mcp_server_management/MCPToolPermissions.test.tsx | 10 ---------- .../components/mcp_tools/McpCrudPermissionPanel.tsx | 2 -- 2 files changed, 12 deletions(-) diff --git a/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.test.tsx b/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.test.tsx index 4b5447dfd89..91f1a45f858 100644 --- a/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.test.tsx @@ -64,13 +64,10 @@ describe("MCPToolPermissions", () => { expect(screen.getByText("read_wiki_structure")).toBeInTheDocument(); }); - // Switch to Flat List view, and prove the view actually switched: the flat - // list is the only view that renders the description inline after a dash. await userEvent.click(screen.getByText("Flat List")); expect(screen.getByRole("radio", { name: "Flat List" })).toBeChecked(); expect(await screen.findByText("- Get documentation topics")).toBeInTheDocument(); - // Deselect read_wiki_structure await userEvent.click(screen.getByRole("checkbox", { name: "read_wiki_structure" })); // Verify onChange was called with read_wiki_structure removed @@ -220,7 +217,6 @@ describe("MCPToolPermissions", () => { await userEvent.click(readGroupToggle); - // Both read-classified tools drop out; the delete-classified one survives. expect(mockOnChange).toHaveBeenCalledWith({ [mockServerId]: ["delete_document"] }); }); @@ -242,8 +238,6 @@ describe("MCPToolPermissions", () => { await userEvent.click(await screen.findByRole("checkbox", { name: "delete_document" })); - // The surrounding row is itself clickable, so a click that bubbles would - // toggle twice and the permission would silently stay allowed. expect(mockOnChange).toHaveBeenCalledTimes(1); expect(mockOnChange).toHaveBeenCalledWith({ [mockServerId]: ["list_documents", "get_document"] }); }); @@ -259,8 +253,6 @@ describe("MCPToolPermissions", () => { }); it("re-renders each checkbox from the permissions it emitted", async () => { - // Drives the panel from real parent state so the assertions cover the full - // round trip: click, emitted payload, then the state the panel renders back. const Harness = () => { const [permissions, setPermissions] = useState>({ [mockServerId]: allCrudToolNames, @@ -293,12 +285,10 @@ describe("MCPToolPermissions", () => { expect(deleteTool).not.toBeChecked(); expect(screen.getByRole("status")).toHaveTextContent("list_documents,get_document"); - // Clearing one tool of the Read group must leave that group's toggle mixed. await userEvent.click(screen.getByRole("checkbox", { name: "get_document" })); expect(readGroupToggle).toBePartiallyChecked(); expect(screen.getByRole("status")).toHaveTextContent("list_documents"); - // Re-arming the group restores both read tools and leaves delete blocked. await userEvent.click(readGroupToggle); expect(readGroupToggle).toBeChecked(); expect(screen.getByRole("status")).toHaveTextContent("list_documents,get_document"); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/McpCrudPermissionPanel.tsx b/ui/litellm-dashboard/src/components/mcp_tools/McpCrudPermissionPanel.tsx index 1b17f000584..3df421f600f 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/McpCrudPermissionPanel.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/McpCrudPermissionPanel.tsx @@ -226,8 +226,6 @@ const McpCrudPermissionPanel: React.FC = ({ } ${allowed ? "" : "opacity-60"}`} onClick={() => toggleTool(tool.name)} > - {/* The row's onClick is the single toggle path. Giving this checkbox its - own change handler as well would toggle twice per click on the box. */} Date: Fri, 14 Aug 2026 19:39:02 +0100 Subject: [PATCH 46/49] fix(main): an explicit provider outranks a known OpenAI model name (#36800) * fix(main): an explicit provider outranks a known OpenAI model name completion() picks the OpenAI handler whenever `model in litellm.open_ai_chat_completion_models`, and that clause is evaluated before the gemini and vertex_ai branches. get_llm_provider() already resolves those names to "openai", so the clause only adds anything when the provider is something else, and then it silently overrides it: the config built for the requested provider is handed to the OpenAI handler. For gemini that is fatal. VertexGeminiConfig.transform_request raises NotImplementedError by design, since Vertex builds its request in its own handler, so `gemini/gpt-4o` dies in async_transform_request before anything is sent. register_model() reaches the same state without an odd model id: an entry claiming litellm_provider "openai" adds its name to open_ai_chat_completion_models, so one mislabelled pricing entry reroutes every later call to that model in the process. The name clause now applies only when no other provider was resolved. * test(main): move the routing regression into the mapped test file CLAUDE.md asks bug fixes to extend the mapped test file, so these belong in tests/test_litellm/test_main.py rather than a module of their own. They also no longer swap out the provider handler objects. Both Gemini cases inject an HTTPHandler whose post() answers like generativelanguage does, then assert the URL the request went to and read the reply back; the OpenAI case injects an OpenAI client and patches its own raw-response create. That asserts the endpoint the call reaches instead of which attribute the test replaced, and matches the neighbouring tests in the file. --- litellm/main.py | 7 ++- tests/test_litellm/test_main.py | 103 ++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/litellm/main.py b/litellm/main.py index 16eff5a0f3e..04ae410db6f 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -5616,7 +5616,12 @@ def completion( elif custom_llm_provider == "hosted_vllm": response = _complete_hosted_vllm(_dispatch_ctx) elif ( - model in litellm.open_ai_chat_completion_models + # A known OpenAI model name only decides the route when nothing else + # resolved a provider. get_llm_provider() already maps these names to + # "openai", so a different value here was asked for explicitly (or came + # from a register_model entry), and the provider config built for it + # would be handed to the OpenAI handler. + (model in litellm.open_ai_chat_completion_models and custom_llm_provider in (None, "openai")) or custom_llm_provider == "custom_openai" or custom_llm_provider == "deepinfra" or custom_llm_provider == "perplexity" diff --git a/tests/test_litellm/test_main.py b/tests/test_litellm/test_main.py index 9e160370048..58373df024c 100644 --- a/tests/test_litellm/test_main.py +++ b/tests/test_litellm/test_main.py @@ -1,3 +1,5 @@ +import contextlib +import copy import json import os import sys @@ -2461,3 +2463,104 @@ async def test_acompletion_forwards_aws_credentials_through_responses_bridge( finally: litellm.disable_aiohttp_transport = original_disable_aiohttp litellm.in_memory_llm_clients_cache.flush_cache() + + +_GEMINI_RESPONSE_BODY = { + "candidates": [{"content": {"parts": [{"text": "hello"}], "role": "model"}, "finishReason": "STOP"}], + "usageMetadata": {"promptTokenCount": 2, "candidatesTokenCount": 1, "totalTokenCount": 3}, +} + + +def _gemini_client_returning_a_reply(): + """An injected HTTP client whose post() answers like generativelanguage does.""" + from litellm.llms.custom_httpx.http_handler import HTTPHandler + + client = HTTPHandler() + request = httpx.Request("POST", "https://generativelanguage.googleapis.com/") + post = MagicMock(return_value=httpx.Response(200, json=_GEMINI_RESPONSE_BODY, request=request)) + return client, post + + +@pytest.fixture +def restore_model_registry(): + """litellm.model_cost and the provider name sets are module-global. + + register_model merges into the existing entry in place, hence the deep copy. + """ + model_cost = copy.deepcopy(litellm.model_cost) + openai_models = set(litellm.open_ai_chat_completion_models) + yield + litellm.model_cost.clear() + litellm.model_cost.update(model_cost) + litellm.open_ai_chat_completion_models.clear() + litellm.open_ai_chat_completion_models.update(openai_models) + + +def test_openai_model_name_does_not_outrank_explicit_provider(): + """`gemini/gpt-4o` goes to Google, not to litellm's OpenAI handler. + + completion() checks `model in litellm.open_ai_chat_completion_models` ahead of + the gemini branch, so the call used to reach the OpenAI handler carrying + VertexGeminiConfig, whose transform_request raises NotImplementedError. + """ + assert "gpt-4o" in litellm.open_ai_chat_completion_models + client, post = _gemini_client_returning_a_reply() + + with patch.object(client, "post", new=post): + response = litellm.completion( + model="gemini/gpt-4o", + messages=[{"role": "user", "content": "hello"}], + api_key="test-api-key", + client=client, + ) + + assert "generativelanguage.googleapis.com" in post.call_args.kwargs["url"] + assert "models/gpt-4o" in post.call_args.kwargs["url"] + assert response.choices[0].message.content == "hello" + + +def test_mislabelled_pricing_entry_does_not_reroute_provider(restore_model_registry): + """register_model is the other way into the same failure. + + An entry claiming litellm_provider "openai" adds its name to + open_ai_chat_completion_models, so one mislabelled price reroutes every later + call to that model in the process. + """ + litellm.register_model( + { + "gemini-2.5-pro": { + "litellm_provider": "openai", + "mode": "chat", + "input_cost_per_token": 1e-06, + "output_cost_per_token": 4e-06, + } + } + ) + assert "gemini-2.5-pro" in litellm.open_ai_chat_completion_models + client, post = _gemini_client_returning_a_reply() + + with patch.object(client, "post", new=post): + response = litellm.completion( + model="gemini/gemini-2.5-pro", + messages=[{"role": "user", "content": "hello"}], + api_key="test-api-key", + client=client, + ) + + assert "generativelanguage.googleapis.com" in post.call_args.kwargs["url"] + assert response.choices[0].message.content == "hello" + + +def test_openai_model_without_a_provider_still_routes_to_openai(): + from openai import OpenAI + + client = OpenAI(api_key="fake-key") + raw_response = client.chat.completions.with_raw_response + with patch.object(raw_response, "create") as mock_create, contextlib.suppress(Exception): + litellm.completion( + model="gpt-4o", + messages=[{"role": "user", "content": "hello"}], + client=client, + ) + + mock_create.assert_called() From b9d2fd0ee9ec66b39a0c3e33a6bc32299019e2c5 Mon Sep 17 00:00:00 2001 From: Fahima Mokhtari Date: Fri, 14 Aug 2026 19:39:35 +0100 Subject: [PATCH 47/49] fix(exception_mapping): bare 429 in an error body no longer outranks the status code (#36705) is_error_str_rate_limit treats any standalone 429 in the stringified exception as a rate limit, and for openai-compatible providers that check runs before the status-code branch. Providers echo the request back in validation errors, so a 400 whose body happens to contain a 429 comes out as RateLimitError. Tokenised prompts hit this routinely, since 429 is an ordinary token id (" that" in several tokenisers) and an echoed prompt_token_ids array is enough: {"error":{"message":"`tools` must not be an empty array", "type":"invalid_request_error","code":400}, "prompt_token_ids":[9906,429,1234]} The mislabel is not cosmetic. RateLimitError tells callers and routers to retry, so a request that cannot succeed gets replayed, and the failure is booked against provider throttling rather than the caller. Against DeepInfra, one recurring 400 ("`tools` must not be an empty array") came back as a rate limit in 77 of 198 occurrences, the split depending only on whether the echoed prompt contained 429. 16482 narrowed '"429" in error_str' to \b429\b after a false positive on 'asbjdad429addad'. Word boundaries cannot separate a real 429 from a token id, so the same class of false positive survives. is_error_str_rate_limit now takes an optional status_code, and the bare-number branch fires only when no explicit status contradicts it. The status is read off an arbitrary exception, so a non-integer is treated as unknown and left to the existing behaviour. The repo has a single call site. The phrase branches are untouched, so a provider reporting a real rate limit in the message text under a non-429 status still maps to RateLimitError (11455). This is not "status code wins". Tests cover the matcher (suppressed under a 400; still detected with no status, None, 429, or a non-integer status; phrase honoured under a 400) and exception_type end to end (400 with 429 in the echoed body -> BadRequestError, real 429 -> RateLimitError). Reverting the source change fails the latter. --- .../exception_mapping_utils.py | 15 +++- .../test_exception_mapping_utils.py | 82 +++++++++++++++++++ 2 files changed, 93 insertions(+), 4 deletions(-) diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index bad8e93e0c5..d23466938f2 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -34,12 +34,16 @@ class ExceptionCheckers: """ @staticmethod - def is_error_str_rate_limit(error_str: str) -> bool: + def is_error_str_rate_limit(error_str: str, status_code: int | None = None) -> bool: """ Check if an error string indicates a rate limit error. Args: error_str: The error string to check + status_code: The HTTP status the provider returned, when known. Gates only the + bare-number branch: providers echo the request back in validation errors and + 429 is an ordinary token id, so an echoed prompt can put a standalone 429 in + the body of a 400. The phrase branches stay ungated (#11455). Returns: True if the error indicates a rate limit, False otherwise @@ -47,8 +51,9 @@ class ExceptionCheckers: if not isinstance(error_str, str): return False - # Only treat 429 as a rate limit signal when it appears as a standalone token - if re.search(r"\b429\b", error_str): + # A standalone 429 counts unless the provider's own status says otherwise. The + # status is read off an arbitrary exception, so a non-integer means "unknown". + if re.search(r"\b429\b", error_str) and (not isinstance(status_code, int) or status_code == 429): return True _error_str_lower: Final = error_str.lower() @@ -280,7 +285,9 @@ def _map_openai_exception( else: exception_provider = custom_llm_provider[0].upper() + custom_llm_provider[1:] + "Exception" - if ExceptionCheckers.is_error_str_rate_limit(error_str): + if ExceptionCheckers.is_error_str_rate_limit( + error_str, status_code=getattr(original_exception, "status_code", None) + ): raise RateLimitError( message=f"RateLimitError: {exception_provider} - {message}", model=model, diff --git a/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py b/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py index 1fcee1b1c42..d5676aaf288 100644 --- a/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py +++ b/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py @@ -133,6 +133,40 @@ class TestExceptionCheckers: result = ExceptionCheckers.is_error_str_rate_limit(error_str) assert result is True + def test_bare_429_in_body_is_ignored_when_status_code_says_otherwise(self): + """A 429 echoed back inside a 400's body is not a rate limit. + + Word boundaries don't help: 429 is an ordinary token id (" that" in several + tokenisers), so an echoed prompt_token_ids array reads as a standalone 429. + """ + error_str = ( + '{"error":{"message":"`tools` must not be an empty array",' + '"type":"invalid_request_error"},' + '"prompt_token_ids":[9906,429,1234]}' + ) + assert ExceptionCheckers.is_error_str_rate_limit(error_str, status_code=400) is False + + def test_bare_429_still_detected_without_a_status_code(self): + """With no status available, a standalone 429 still counts (unchanged behaviour).""" + + assert ExceptionCheckers.is_error_str_rate_limit("HTTP 429 Too Many Requests") is True + assert ExceptionCheckers.is_error_str_rate_limit("HTTP 429 Too Many Requests", status_code=None) is True + assert ExceptionCheckers.is_error_str_rate_limit("HTTP 429 Too Many Requests", status_code=429) is True + + def test_non_integer_status_code_does_not_suppress_bare_429(self): + """A non-integer status counts as unknown, not as a contradiction.""" + + assert ExceptionCheckers.is_error_str_rate_limit("HTTP 429 Too Many Requests", status_code="not-an-int") is True + + def test_rate_limit_phrase_is_honoured_under_a_non_429_status(self): + """Phrase matching stays ungated: some providers report a real rate limit in + the text under a non-429 status (#11455).""" + + assert ( + ExceptionCheckers.is_error_str_rate_limit("FireworksException - rate limit exceeded", status_code=400) + is True + ) + def test_is_azure_content_policy_violation_error_with_policy_violation_text(self): """Test detection of Azure content policy violation with explicit policy violation text""" @@ -300,6 +334,54 @@ def test_lemonade_context_window_error_mapping(): assert excinfo.value.model == model +def test_openai_compatible_400_with_bare_429_in_body_maps_to_bad_request(): + """A provider 400 whose echoed body contains a 429 must stay a 400. + + ``is_error_str_rate_limit`` runs before the status-code branch for + openai-compatible providers, so a validation error echoing the request back came + out as RateLimitError, which tells the caller to retry a request that cannot + succeed and books the failure against provider throttling. + """ + error_message = ( + '{"error":{"message":"`tools` must not be an empty array",' + '"type":"invalid_request_error","code":400},' + '"prompt_token_ids":[9906,429,1234]}' + ) + original_exception = OpenAIError( + status_code=400, + message=error_message, + headers={}, + ) + + with pytest.raises(litellm.BadRequestError) as excinfo: + exception_type( + model="deepseek-ai/DeepSeek-V3", + original_exception=original_exception, + custom_llm_provider="deepinfra", + ) + + assert excinfo.value.status_code == 400 + assert excinfo.value.llm_provider == "deepinfra" + + +def test_openai_compatible_429_still_maps_to_rate_limit(): + """A real 429 still maps to RateLimitError.""" + original_exception = OpenAIError( + status_code=429, + message='{"error":{"message":"Too Many Requests","type":"rate_limit_error"}}', + headers={}, + ) + + with pytest.raises(litellm.RateLimitError) as excinfo: + exception_type( + model="deepseek-ai/DeepSeek-V3", + original_exception=original_exception, + custom_llm_provider="deepinfra", + ) + + assert excinfo.value.status_code == 429 + + @pytest.mark.parametrize( "error_message", [ From 865ed96765798c380634413b92d14f86e7de950e Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:04:01 -0700 Subject: [PATCH 48/49] fix(proxy): force prisma recreate on postgres cached-plan error (#36428) `_query_first_with_cached_plan_fallback` recovers from Postgres's "cached plan must not change result type" by recreating the Prisma client, which drops both the server-side plans and the engine's client-side statement-name cache. Since #30183 the shared reconnect path probes the writer with `SELECT 1` first and skips the recreate when it answers, which is right for the IAM token refresh it was added for and wrong here: the connection is healthy, it is the session's prepared statements that are stale, so the probe always passes and always vetoes the recreate. Callers now pass `force_recreate` to skip that probe, and only the cached-plan fallback does. Getting past the probe is not enough on its own. Both cooldown checks would still skip the recreate for 15 seconds after any earlier reconnect, which outlives the 10 second auth retry window, so a migration landing in that window kept 503ing. `force=True` would fix that but would also let every concurrent caller of the same burst kill the engine the first one just built. The caller instead names the engine it observed before the query, and the cooldown is waived only while that engine is still the live one, so the first caller repairs the pool and the rest fall back to the normal cooldown. That engine has to be the one the query actually ran on. `query_first` is a top-level read, so with a read replica configured it is dispatched to the reader and it is the reader's prepared statements that go stale, while `writer_db` names a different engine with its own counter. The observation and the cooldown comparison both go through `read_db`, added alongside `writer_db` and backed by a `read_target` property on the routing wrapper that `__getattr__` now dispatches through so the two cannot drift. The observation carries the wrapper, not just its generation. `read_db` resolves to the reader while it is available and to the writer once it is not, and those counters are independent and both start at zero, so comparing a bare number across that switch pits one engine's counter against another's. Equal by coincidence waives the cooldown for an engine already replaced; unequal gates a caller that needs the recreate. Identity settles it, and is sound because the engine object is never re-pointed without the generation also moving. Three smaller holes on the way out. The waiver is withdrawn once a repair of that same engine has been tried and failed, so a burst collapses onto one attempt instead of each caller running its own recreate serially; the record is keyed per engine rather than counted globally, so an unrelated reconnect failure cannot suppress a stale reader's recovery and a writer failure cannot evict the reader's record. And a forced recreate that the optimistic-lock guard declines is no longer reported as a success on either the direct or the heavy path, since the routing wrapper leaves the reader untouched in that case; a decline is deliberately not counted as a failure, so the caller's own backoff still gets its waiver on the next attempt. A decline on the heavy path clears the dead-engine flag before raising. The clear after the cycle is skipped by any raise, which is right for a failure and wrong here, and the non-forced path already clears it on a decline, so this restores that policy rather than inventing one. Stranding the flag would route the next cycle back down the probe-free heavy branch, where the refreshed generation matches and the recreate kills the healthy engine a refresh just spawned, which is #29176. Clearing that flag is necessary and not sufficient. The escalation check re-arms it whenever the consecutive-failure count sits at the threshold, so a decline that left the count alone sent the very next attempt back down the same path. A decline is raised only at the generation guard, and the generation moves only after a replacement connects, so a decline is proof that a replacement succeeded and the count is reset on it. Fixes #36418 Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/db/routing_prisma_wrapper.py | 14 +- litellm/proxy/utils.py | 303 ++++++++-- .../test_prisma_client_get_data.py | 71 ++- .../test_prisma_client_reconnect.py | 516 +++++++++++++++++- 4 files changed, 868 insertions(+), 36 deletions(-) diff --git a/litellm/proxy/db/routing_prisma_wrapper.py b/litellm/proxy/db/routing_prisma_wrapper.py index 8287ef1addf..5aeb52be535 100644 --- a/litellm/proxy/db/routing_prisma_wrapper.py +++ b/litellm/proxy/db/routing_prisma_wrapper.py @@ -103,6 +103,17 @@ class RoutingPrismaWrapper: def reader(self) -> PrismaWrapper: return self._reader + @property + def read_target(self) -> PrismaWrapper: + """The wrapper `_TOP_LEVEL_READ_METHODS` dispatch to right now. + + Callers that need to reason about the engine a read actually ran on + (e.g. recovering from prepared statements that went stale on it) must + consult this rather than `writer`, and `__getattr__` routes through it + so the two cannot drift apart. + """ + return self._writer if self._reader_unavailable else self._reader + @property def reader_unavailable(self) -> bool: return self._reader_unavailable @@ -254,8 +265,7 @@ class RoutingPrismaWrapper: def __getattr__(self, name: str) -> Any: if name in _TOP_LEVEL_READ_METHODS: - target: Final = self._writer if self._reader_unavailable else self._reader - return getattr(target, name) + return getattr(self.read_target, name) writer_attr: Final = getattr(self._writer, name) # Per-model action accessors are non-callable instances that expose # both `find_many` and `create`. Methods like execute_raw / batch_ / diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index d3ca2fa64ed..ec1aa262736 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -16,6 +16,7 @@ from dataclasses import dataclass, field from datetime import date, datetime, timedelta, timezone from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText +from types import MappingProxyType from typing import TYPE_CHECKING, Any, ClassVar, Final, Literal, Optional, TypeVar, Union, cast, overload from litellm import _custom_logger_compatible_callbacks_literal @@ -3006,6 +3007,62 @@ async def prefetch_config_params(prisma_client: "PrismaClient | None", param_nam ) +class _ForcedRecreateDeclined(Exception): + """A forced recreate was declined by the engine-generation guard. + + Distinct from a reconnect *failure*: the machinery worked, it just found + that another path had already replaced the writer, so it left the engines + alone. The caller's engine may still be poisoned, so the cycle must not + report success, but it must not count as a failure either, or the record + of what could not be repaired would gate the retry that recovers. + """ + + +@dataclass(frozen=True, slots=True) +class _StaleReadEngine: + """The read engine a query observed, identified rather than only counted. + + `PrismaClient.read_db` resolves to the reader while it is available and to + the writer once it is not, and the two carry independent generation + counters that both start at zero and advance on the same reconnect + cadence. A bare generation compared across that switch would silently pit + one engine's counter against another's, so the wrapper is carried with the + number and a switch counts as the engine having moved. + + Holding the wrapper itself rather than its `id()` is load-bearing, not + incidental: the strong reference keeps the wrapper alive, so its address + cannot be recycled under a stored observation and match an unrelated + engine later. It is only free because writer and reader both live as long + as the client does; a replaceable reader would make this a retention leak. + """ + + wrapper: PrismaWrapper + generation: int + + @classmethod + def observe(cls, wrapper: PrismaWrapper) -> "_StaleReadEngine": + return cls(wrapper=wrapper, generation=wrapper.engine_generation) + + def is_still_live(self, current: PrismaWrapper) -> bool: + """Whether this exact engine is still serving reads, unreplaced. + + A True answer must never be the only thing standing between a poisoned + engine and its repair. The generation moves only after a replacement + connects, and a recreate whose connect raises leaves it unmoved until + some later recreate succeeds, so this can report an engine as live + after it has stopped working. What bounds that is the failed-repair + record in `_cooldown_applies`, written by a repair attempt that fails + rather than by whatever broke the engine: the two need not be the same + recreate, since the synchronous token-refresh fallback in + `PrismaWrapper.__getattr__` recreates outside the reconnect machinery + and records nothing. The record is written only for callers that named + an engine, and it collapses the rest of the burst for up to one + cooldown window rather than guaranteeing a repair, since the cooldown + conjunct underneath it still expires and lets a later caller retry. + """ + return self.wrapper is current and self.generation == current.engine_generation + + class PrismaClient: spend_log_transactions: list = [] _spend_log_transactions_lock = asyncio.Lock() @@ -3153,6 +3210,14 @@ class PrismaClient: float(os.getenv("PRISMA_AUTH_RECONNECT_LOCK_TIMEOUT_SECONDS", "0.1")), ) self._consecutive_reconnect_failures: int = 0 + # Last generation of each read engine whose repair was attempted and + # failed. Scoped to the engine rather than counted globally so an + # unrelated reconnect failure cannot suppress a stale reader's + # recovery, and keyed per wrapper rather than held in one slot so a + # writer failure cannot evict the reader's record and hand the waiver + # back to a caller whose engine is still unrepaired. Bounded at two + # entries: a client has one writer and at most one reader. + self._failed_recreate_generations: Mapping[PrismaWrapper, int] = MappingProxyType({}) self._reconnect_escalation_threshold: int = max(1, int(os.getenv("PRISMA_RECONNECT_ESCALATION_THRESHOLD", "3"))) self._engine_pidfd: int = -1 self._engine_pid: int = 0 @@ -3168,6 +3233,19 @@ class PrismaClient: return self.db.writer return self.db + @property + def read_db(self) -> PrismaWrapper: + """Underlying wrapper that top-level reads are dispatched to. + + Identical to `writer_db` without a read replica. With one configured + it is the reader, which is the engine `query_first` actually runs on, + so anything reasoning about the state of the connection that served a + read has to consult this rather than the writer. + """ + if isinstance(self.db, RoutingPrismaWrapper): + return self.db.read_target + return self.db + def tx(self) -> "TransactionManager": """Open an interactive transaction on the writer. @@ -3391,18 +3469,30 @@ class PrismaClient: `attempt_db_reconnect`, which is singleflight: when a schema change poisons every pooled connection at once, the first cached-plan error recreates the client and the concurrent waiters reuse that single - recreate instead of racing to kill each other's fresh engine. We then - retry the identical query exactly once. + recreate instead of racing to kill each other's fresh engine. We pass + `force_recreate` so the reconnect skips its `SELECT 1` liveness probe: + the connection is healthy here, it is the prepared statements on it + that are stale, so a passing probe would otherwise skip the recreate + and leave the retry to hit the same error. We then retry the identical + query exactly once. The retry reuses the original query byte-for-byte. Mutating the SQL (e.g. injecting a unique comment) would defeat PostgreSQL's plan cache, forcing a fresh plan on every request and pegging the database CPU. - If the reconnect is skipped because a recent reconnect is still within - its cooldown, the retry runs against the same connection and may fail - again; the get_data backoff decorator re-runs the lookup and a later - attempt reconnects once the cooldown elapses. + The reconnect cooldown must not gate the engine this query itself saw + as stale, or a migration landing within the cooldown of an earlier + reconnect leaves auth failing until it elapses. The engine observed + before the query names it, so the reconnect bypasses the cooldown only + while that same engine is still the live one. + + It is observed from `read_db`, not `writer_db`: `query_first` is a + top-level read, so with a read replica configured it runs on the reader + and it is the reader's prepared statements that went stale. Naming the + writer here would let an unrelated writer reconnect re-arm the cooldown + while the reader stayed poisoned. """ + stale_read_engine: Final = _StaleReadEngine.observe(self.read_db) try: return await self.db.query_first(sql_query, *args) except Exception as e: @@ -3414,7 +3504,11 @@ class PrismaClient: "query. This may occur during rolling deployments when schema " "changes are applied." ) - await self.attempt_db_reconnect(reason="postgres_cached_plan_error") + await self.attempt_db_reconnect( + reason="postgres_cached_plan_error", + force_recreate=True, + stale_read_engine=stale_read_engine, + ) return await self.db.query_first(sql_query, *args) @backoff.on_exception( @@ -4697,7 +4791,11 @@ class PrismaClient: self._cleanup_engine_watcher() asyncio.create_task(self._start_engine_watcher()) - async def _run_reconnect_cycle(self, timeout_seconds: float | None = None) -> None: + async def _run_reconnect_cycle( + self, + timeout_seconds: float | None = None, + force_recreate: bool = False, + ) -> None: """ Run a reconnect cycle with a single overall timeout budget. @@ -4708,6 +4806,11 @@ class PrismaClient: the client via the non-blocking kill-then-construct flow rather than calling disconnect(), which blocks the event loop on the synchronous subprocess.Popen.wait() inside prisma-client-py (see issue #26191). + + `force_recreate` skips the direct path's liveness probe, for callers + whose failure lives in the session state rather than the connection + (stale prepared statements after a schema change): a reachable writer + proves nothing about those, so the probe must not veto the recreate. """ effective_timeout: Final = ( timeout_seconds if timeout_seconds is not None else self._db_watchdog_reconnect_timeout_seconds @@ -4747,8 +4850,29 @@ class PrismaClient: # direct path there is no SELECT 1 probe here, so the generation # guard is the only thing standing between a crash-reconnect and # a refresh that raced it. - await self.db.recreate_prisma_client(db_url, expected_generation=expected_generation) + recreated: Final = await self.db.recreate_prisma_client(db_url, expected_generation=expected_generation) await self._start_engine_watcher() + # Same contract as the direct path below: a forced caller asked + # for its engine to be replaced, so a decline is not a success. + # Reachable here because the escalation threshold flips + # `_engine_confirmed_dead`, which routes the next cycle, forced + # callers included, down this branch. + if force_recreate is True and recreated is False: + # Clear the dead-engine flag first, restoring the policy the + # non-forced path already has: a decline does not raise for + # it, so it falls through to the clear below. Only the + # forced branch would strand the flag, and stranding it + # routes the next cycle back down this probe-free branch, + # where the refreshed generation now matches and the + # recreate kills the healthy engine a refresh just spawned + # (#29176). This has to stay AFTER `_start_engine_watcher` + # above: clearing the flag while the watcher is still torn + # down would be worse than either alone. + self._engine_confirmed_dead = False + raise _ForcedRecreateDeclined( + "Forced Prisma recreate declined by the generation guard; " + "the engine that failed was not replaced" + ) await asyncio.wait_for(_do_heavy_reconnect(), timeout=effective_timeout) # Only clear the "dead engine" flag after the heavy reconnect @@ -4773,44 +4897,106 @@ class PrismaClient: # detect a refresh that landed since cycle entry and skip the # redundant restart. writer: Final = self.writer_db - try: - await writer.query_raw("SELECT 1") - verbose_proxy_logger.info( - "Writer healthy on probe; skipping recreate (engine " - "likely already replaced by a token refresh)." - ) - if isinstance(self.db, RoutingPrismaWrapper): - self.db.mark_writer_recovered() - await self._start_engine_watcher() - return - except Exception as probe_err: - verbose_proxy_logger.warning( - "Writer probe failed (%s); recreating Prisma client.", - probe_err, - ) + if force_recreate is False: + try: + await writer.query_raw("SELECT 1") + verbose_proxy_logger.info( + "Writer healthy on probe; skipping recreate (engine " + "likely already replaced by a token refresh)." + ) + if isinstance(self.db, RoutingPrismaWrapper): + self.db.mark_writer_recovered() + await self._start_engine_watcher() + return + except Exception as probe_err: + verbose_proxy_logger.warning( + "Writer probe failed (%s); recreating Prisma client.", + probe_err, + ) # Fresh Prisma client + new engine subprocess. The previous # "lightweight" path called `disconnect()` which blocks the # event loop on `subprocess.Popen.wait()`; since that call # ends up killing the engine anyway, we do it non-blockingly # via `_kill_engine_process` inside `recreate_prisma_client`. self._cleanup_engine_watcher() - await self.db.recreate_prisma_client(db_url, expected_generation=expected_generation) + recreated: Final = await self.db.recreate_prisma_client(db_url, expected_generation=expected_generation) await self._start_engine_watcher() # Smoke-test the writer specifically; query_raw on the routing # wrapper sends to the reader, which would not validate the - # newly-recreated writer engine. + # newly-recreated writer engine. The reader is left to the + # caller's own retried query, a stronger check than SELECT 1, + # and a reader that fails to come back sets `_reader_unavailable` + # so reads fall through to the writer just recreated here. await self.writer_db.query_raw("SELECT 1") + # A recreate can decline: the optimistic-lock guard no-ops when + # the writer generation moved since cycle entry, and the routing + # wrapper then leaves the reader untouched as well. Callers that + # merely suspect a transport blip are happy either way, but a + # forced caller asked for this engine to be replaced because its + # session state is poisoned, and it was not. Do not report that + # as a success: it would reset the consecutive-failure count and + # log a repair that never happened. + if force_recreate is True and recreated is False: + raise _ForcedRecreateDeclined( + "Forced Prisma recreate declined by the generation guard; " + "the engine that failed was not replaced" + ) await asyncio.wait_for(_do_direct_reconnect(), timeout=effective_timeout) + def _cooldown_applies(self, stale_read_engine: "_StaleReadEngine | None") -> bool: + """ + Whether the reconnect cooldown should still gate this caller. + + The cooldown collapses a burst of callers onto one recreate, so it + keeps gating a caller whose named engine has already been replaced: + that recreate is the one it was waiting for. While that engine is still + the live one the damage is still being served, so deferring to an + unrelated reconnect's cooldown would leave it broken until the cooldown + elapses. + + A named engine always describes the one that served the failing read + (see `_query_first_with_cached_plan_fallback`), so it is compared + against `read_db`, identity included: `read_db` can resolve to a + different wrapper than it did at observation time. + + The waiver is withdrawn once a repair of this same engine has been + tried and failed. A failed recreate leaves the generation where it was, + so without this every queued caller would still see its own engine live + and run its own full recreate serially instead of collapsing onto one + attempt, which is what the cooldown is for. The record is scoped to the + engine rather than to a global failure count: an unrelated reconnect + failing somewhere else says nothing about whether this engine can be + repaired, and gating on it would suppress the recovery this method + exists to allow. + + The record is never cleared, and does not need to be. Generations are + monotonic per wrapper, so once the engine is repaired every later + caller names a higher one and the entry can never match again. And this + method is only ever the first half of the gate: the cooldown window + itself still expires, so an engine that can never be repaired degrades + to the plain cooldown rather than being suppressed forever. + """ + if stale_read_engine is None: + return True + if self._failed_recreate_generations.get(stale_read_engine.wrapper) == stale_read_engine.generation: + return True + return not stale_read_engine.is_still_live(self.read_db) + async def _attempt_reconnect_inside_lock( self, force: bool, reason: str, timeout_seconds: float | None, + force_recreate: bool = False, + stale_read_engine: "_StaleReadEngine | None" = None, ) -> bool: now: Final = time.time() - if force is False and now - self._db_last_reconnect_attempt_ts < self._db_reconnect_cooldown_seconds: + if ( + force is False + and self._cooldown_applies(stale_read_engine) + and now - self._db_last_reconnect_attempt_ts < self._db_reconnect_cooldown_seconds + ): verbose_proxy_logger.debug( "Skipping DB reconnect attempt inside lock due to cooldown. reason=%s", reason, @@ -4834,12 +5020,43 @@ class PrismaClient: reconnect_succeeded = False try: - await self._run_reconnect_cycle(timeout_seconds=timeout_seconds) + await self._run_reconnect_cycle(timeout_seconds=timeout_seconds, force_recreate=force_recreate) reconnect_succeeded = True self._consecutive_reconnect_failures = 0 verbose_proxy_logger.info("Prisma DB reconnect succeeded. reason=%s", reason) + except _ForcedRecreateDeclined as declined: + # A decline is raised only when the recreate returns False, which + # happens only at the generation guard, and the generation moves + # only after a replacement has connected. So a decline is proof + # that a replacement SUCCEEDED, and zeroing a consecutive-failure + # count on that proof is right by definition rather than by + # analogy to what a reported success used to do. Note what it + # proves is that the WRITER was replaced, not that this caller's + # engine was repaired: on a read replica the reader can still be + # poisoned, since the wrapper returns before touching it. Leaving + # the count at the threshold would let the escalation check above + # re-arm the dead-engine flag on the very next attempt and send a + # healthy replacement back down the probe-free heavy path. + self._consecutive_reconnect_failures = 0 + verbose_proxy_logger.warning("Prisma DB reconnect declined. reason=%s detail=%s", reason, declined) except Exception as reconnect_err: self._consecutive_reconnect_failures += 1 + # Remember WHICH engine could not be repaired, so the rest of this + # caller's burst collapses onto the cooldown instead of each + # retrying the recreate that just failed. Recorded only for a + # caller that named a generation: a watchdog or transport-error + # reconnect failing here is unrelated to any stale read engine and + # must not suppress its waiver. + if stale_read_engine is not None: + # Key off the wrapper the CALLER named, never a freshly resolved + # `read_db`. A failed reader recreate is itself what marks the + # reader unavailable, so re-resolving here would file the + # reader's failure under the writer: the poisoned reader would + # lose its record and the healthy writer would gain a spurious + # one, wrong in both directions at once. + self._failed_recreate_generations = MappingProxyType( + {**self._failed_recreate_generations, stale_read_engine.wrapper: stale_read_engine.generation} + ) verbose_proxy_logger.error( "Prisma DB reconnect failed (%d consecutive). reason=%s error=%s", self._consecutive_reconnect_failures, @@ -4857,15 +5074,35 @@ class PrismaClient: force: bool = False, timeout_seconds: float | None = None, lock_timeout_seconds: float | None = None, + force_recreate: bool = False, + stale_read_engine: "_StaleReadEngine | None" = None, ) -> bool: """ Attempt to reconnect the Prisma client in a singleflight manner. + `force` bypasses the cooldown unconditionally; `force_recreate` + bypasses the liveness probe that would otherwise skip recreating a + reachable engine; `stale_read_engine` bypasses the cooldown only while + the engine that produced the caller's failure is still the live one + (see `_cooldown_applies`). + + A `force_recreate` caller can also get False for a third reason: the + generation guard declined because another path had already replaced + the engine, which is a successful outcome reported as False. Callers + that branch on the return value (`exception_handler` raises on False, + `auth_checks` retries only on True) would misread that as a dead end, + and are safe today only because neither passes `force_recreate`. Do + not add it to one of them without revisiting how it reads the result. + Returns: bool: True if reconnection succeeded, else False. """ now: Final = time.time() - if force is False and now - self._db_last_reconnect_attempt_ts < self._db_reconnect_cooldown_seconds: + if ( + force is False + and self._cooldown_applies(stale_read_engine) + and now - self._db_last_reconnect_attempt_ts < self._db_reconnect_cooldown_seconds + ): verbose_proxy_logger.debug( "Skipping DB reconnect attempt due to cooldown. reason=%s", reason, @@ -4874,7 +5111,9 @@ class PrismaClient: if lock_timeout_seconds is None: async with self._db_reconnect_lock: - return await self._attempt_reconnect_inside_lock(force, reason, timeout_seconds) + return await self._attempt_reconnect_inside_lock( + force, reason, timeout_seconds, force_recreate, stale_read_engine + ) lock_acquired_by_timeout_task = False @@ -4923,7 +5162,9 @@ class PrismaClient: return False try: - return await self._attempt_reconnect_inside_lock(force, reason, timeout_seconds) + return await self._attempt_reconnect_inside_lock( + force, reason, timeout_seconds, force_recreate, stale_read_engine + ) finally: self._db_reconnect_lock.release() diff --git a/tests/test_litellm/proxy/utils/prisma_and_spend/test_prisma_client_get_data.py b/tests/test_litellm/proxy/utils/prisma_and_spend/test_prisma_client_get_data.py index 87063bdf00b..ed1317e647d 100644 --- a/tests/test_litellm/proxy/utils/prisma_and_spend/test_prisma_client_get_data.py +++ b/tests/test_litellm/proxy/utils/prisma_and_spend/test_prisma_client_get_data.py @@ -17,13 +17,14 @@ import hashlib import json from datetime import datetime, timedelta, timezone from types import SimpleNamespace -from typing import Any +from typing import Any, Final from unittest.mock import AsyncMock, MagicMock import pytest from fastapi import HTTPException from litellm.proxy._types import LiteLLM_VerificationTokenView +from litellm.proxy.db.routing_prisma_wrapper import RoutingPrismaWrapper from litellm.proxy.utils import PrismaClient @@ -270,6 +271,9 @@ async def test_query_first_with_cached_plan_fallback_reconnects_then_retries_ide assert retry_call.args == first_call.args == (original_query, "abc") reconnect.assert_awaited_once() assert reconnect.await_args.kwargs.get("force", False) is False + # https://github.com/BerriAI/litellm/issues/36418: without this the healthy + # writer probe skips the recreate and the stale plans survive the retry + assert reconnect.await_args.kwargs.get("force_recreate") is True assert [name for name, *_ in manager.mock_calls] == [ "query_first", "attempt_db_reconnect", @@ -564,3 +568,68 @@ async def test_get_data_team_keys_forward_limit_as_take( "where": {"team_id": "team-1"}, "include": {"litellm_budget_table": True}, } + + +@pytest.mark.asyncio +async def test_query_first_with_cached_plan_fallback_reports_pre_query_engine_generation( + prisma_client: PrismaClient, +) -> None: + """The generation is snapshotted before the query, not after it fails: it + names the engine that prepared the stale statement, which is what lets the + reconnect bypass an unrelated cooldown while that engine is still live + (https://github.com/BerriAI/litellm/issues/36418). Reading it after the + failure would miss a recreate that landed in between and force a + needless second one.""" + prisma_client.db.engine_generation = 3 + + async def _fail_then_bump(*args: Any, **kwargs: Any) -> dict[str, str]: + if prisma_client.db.engine_generation == 3: + prisma_client.db.engine_generation = 4 + raise RuntimeError("cached plan must not change result type") + return {"token": "abc"} + + prisma_client.db.query_first = AsyncMock(side_effect=_fail_then_bump) + prisma_client.attempt_db_reconnect = AsyncMock(return_value=True) + + await prisma_client._query_first_with_cached_plan_fallback("SELECT 1") + + kwargs = prisma_client.attempt_db_reconnect.await_args.kwargs + assert kwargs.get("stale_read_engine").generation == 3 + + +@pytest.mark.asyncio +async def test_query_first_with_cached_plan_fallback_reports_the_reader_generation( + prisma_client: PrismaClient, +) -> None: + """With a read replica configured the query runs on the READER, so the + reader's generation is the one that names the engine holding the stale + prepared statement. Snapshotting the writer's instead would let an + unrelated writer reconnect re-arm the cooldown while the reader stayed + poisoned (https://github.com/BerriAI/litellm/issues/36418). The two + generations are deliberately far apart so only the right one matches.""" + writer = MagicMock(name="writer") + writer.engine_generation = 99 + writer.query_first = AsyncMock(return_value={"token": "wrong-engine"}) + reader = MagicMock(name="reader") + reader.engine_generation = 3 + reader.query_first = AsyncMock( + side_effect=[RuntimeError("cached plan must not change result type"), {"token": "abc"}] + ) + prisma_client.db = RoutingPrismaWrapper(writer=writer, reader=reader) + prisma_client.attempt_db_reconnect = AsyncMock(return_value=True) + + await prisma_client._query_first_with_cached_plan_fallback("SELECT 1") + + reported: Final = prisma_client.attempt_db_reconnect.await_args.kwargs.get("stale_read_engine") + pinned = { + "reported_generation": reported.generation, + "reported_the_reader_itself": reported.wrapper is reader, + "reader_served_the_query": reader.query_first.await_count, + "writer_served_the_query": writer.query_first.await_count, + } + assert pinned == { + "reported_generation": 3, + "reported_the_reader_itself": True, + "reader_served_the_query": 2, + "writer_served_the_query": 0, + } diff --git a/tests/test_litellm/proxy/utils/prisma_and_spend/test_prisma_client_reconnect.py b/tests/test_litellm/proxy/utils/prisma_and_spend/test_prisma_client_reconnect.py index 867554157fd..719d7cc73f5 100644 --- a/tests/test_litellm/proxy/utils/prisma_and_spend/test_prisma_client_reconnect.py +++ b/tests/test_litellm/proxy/utils/prisma_and_spend/test_prisma_client_reconnect.py @@ -7,17 +7,34 @@ Symbols pinned here: - ``PrismaClient.start_db_health_watchdog_task`` - ``PrismaClient.stop_db_health_watchdog_task`` - ``PrismaClient._db_health_watchdog_loop`` + +Note on fixtures for the routing tests: the reader and the writer carry +independent generation counters, so a fixture that gives them far-apart values +reads clearly and proves nothing about identity, because comparing the numbers +alone already yields the right answer. Pick values so that ONLY the mechanism +under test can produce the expected result, which for identity means two +engines whose generations deliberately coincide. + +Note on what to assert: pin the requirement, not the mechanism. An assertion +that restates what the implementation currently does can only ever agree with +it, including when it is wrong, so it ends up defending the defect from being +corrected. One here did exactly that, asserting that a declined heavy-path +recreate leaves the dead-engine flag set, which read as a faithful description +and was a reintroduction of #29176. "A later cycle must not kill a healthy +engine" would have failed against it whatever mechanism produced it. """ from __future__ import annotations import asyncio -from typing import Any +import time +from typing import Any, Final from unittest.mock import AsyncMock, MagicMock import pytest -from litellm.proxy.utils import PrismaClient +from litellm.proxy.db.routing_prisma_wrapper import RoutingPrismaWrapper +from litellm.proxy.utils import PrismaClient, _StaleReadEngine @pytest.mark.asyncio @@ -96,6 +113,48 @@ async def test_run_reconnect_cycle_direct_path_recreates_when_probe_fails( } +@pytest.mark.asyncio +async def test_run_reconnect_cycle_force_recreate_skips_probe_and_recreates( + prisma_client: PrismaClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """A healthy writer must not veto the recreate when the caller already + knows the session state is poisoned (stale prepared statements after a + schema change). Regression for + https://github.com/BerriAI/litellm/issues/36418.""" + monkeypatch.setenv("DATABASE_URL", "postgres://x:y@h:5432/db") + prisma_client._engine_confirmed_dead = False + prisma_client._engine_pid = 0 + prisma_client._start_engine_watcher = AsyncMock() + prisma_client._cleanup_engine_watcher = MagicMock() + + writer = prisma_client.db + writer.recreate_prisma_client = AsyncMock() + writer.query_raw = AsyncMock(return_value=[{"?column?": 1}]) + + await prisma_client._run_reconnect_cycle(timeout_seconds=5, force_recreate=True) + pinned = { + "recreate_called": writer.recreate_prisma_client.await_count, + "writer_query_raw_calls": writer.query_raw.await_count, + } + assert pinned == {"recreate_called": 1, "writer_query_raw_calls": 1} + + +@pytest.mark.asyncio +async def test_attempt_db_reconnect_forwards_force_recreate_to_cycle( + prisma_client: PrismaClient, +) -> None: + """Regression for https://github.com/BerriAI/litellm/issues/36418: the flag + has to survive both hops (attempt_db_reconnect -> inside-lock -> cycle), + otherwise the cached-plan caller silently gets a probe-gated reconnect.""" + prisma_client._db_last_reconnect_attempt_ts = 0.0 + prisma_client._run_reconnect_cycle = AsyncMock() + + ok = await prisma_client.attempt_db_reconnect(reason="explicit", force_recreate=True) + + assert ok is True + assert prisma_client._run_reconnect_cycle.await_args.kwargs.get("force_recreate") is True + + @pytest.mark.asyncio async def test_run_reconnect_cycle_passes_writer_generation_to_recreate( prisma_client: PrismaClient, monkeypatch: pytest.MonkeyPatch @@ -584,3 +643,456 @@ async def test_run_reconnect_cycle_heavy_path_forwards_entry_generation_to_recre kwargs = prisma_client.db.recreate_prisma_client.await_args.kwargs assert kwargs.get("expected_generation") == 4 + + +@pytest.mark.asyncio +async def test_attempt_db_reconnect_bypasses_cooldown_for_still_live_stale_engine( + prisma_client: PrismaClient, +) -> None: + """A schema change landing inside the cooldown of an earlier reconnect used + to leave auth failing until the cooldown elapsed. While the engine the + caller's failure came from is still the live one, the cooldown must not + gate the recreate. Regression for + https://github.com/BerriAI/litellm/issues/36418.""" + prisma_client.db.engine_generation = 7 + prisma_client._db_last_reconnect_attempt_ts = time.time() + prisma_client._run_reconnect_cycle = AsyncMock() + + ok = await prisma_client.attempt_db_reconnect( + reason="postgres_cached_plan_error", + force_recreate=True, + stale_read_engine=_StaleReadEngine(wrapper=prisma_client.read_db, generation=7), + ) + + assert ok is True + assert prisma_client._run_reconnect_cycle.await_count == 1 + + +@pytest.mark.asyncio +async def test_attempt_db_reconnect_honors_cooldown_once_stale_engine_replaced( + prisma_client: PrismaClient, +) -> None: + """The bypass is scoped to the damaged engine: once a concurrent recreate + has replaced it, the cooldown must still collapse the rest of the burst + onto that recreate instead of killing the fresh engine.""" + prisma_client.db.engine_generation = 8 + prisma_client._db_last_reconnect_attempt_ts = time.time() + prisma_client._run_reconnect_cycle = AsyncMock() + + ok = await prisma_client.attempt_db_reconnect( + reason="postgres_cached_plan_error", + force_recreate=True, + stale_read_engine=_StaleReadEngine(wrapper=prisma_client.read_db, generation=7), + ) + + assert ok is False + prisma_client._run_reconnect_cycle.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_attempt_db_reconnect_keeps_cooldown_for_callers_without_generation( + prisma_client: PrismaClient, +) -> None: + """Watchdog and transport-error callers name no generation, so they keep + the plain cooldown behaviour.""" + prisma_client._db_last_reconnect_attempt_ts = time.time() + prisma_client._run_reconnect_cycle = AsyncMock() + + ok = await prisma_client.attempt_db_reconnect(reason="watchdog_probe_failed") + + assert ok is False + prisma_client._run_reconnect_cycle.assert_not_awaited() + + +def _routing_client(prisma_client: PrismaClient, reader_generation: int, writer_generation: int) -> tuple[Any, Any]: + """Wire ``prisma_client.db`` to a routing wrapper with distinct engines. + + Returns the (writer, reader) mocks so a test can move either generation + independently, which is the only way to tell the two counters apart. + """ + writer = MagicMock(name="writer") + writer.engine_generation = writer_generation + reader = MagicMock(name="reader") + reader.engine_generation = reader_generation + prisma_client.db = RoutingPrismaWrapper(writer=writer, reader=reader) + return writer, reader + + +@pytest.mark.asyncio +async def test_attempt_db_reconnect_reads_generation_from_the_reader_that_served_the_query( + prisma_client: PrismaClient, +) -> None: + """``query_first`` is a top-level read, so with a replica configured the + stale prepared statements are on the READER. A writer reconnect that moved + the writer generation must not re-arm the cooldown while the reader the + query actually failed on is still the live, poisoned one. Regression for + https://github.com/BerriAI/litellm/issues/36418.""" + _routing_client(prisma_client, reader_generation=7, writer_generation=99) + prisma_client._db_last_reconnect_attempt_ts = time.time() + prisma_client._run_reconnect_cycle = AsyncMock() + + ok = await prisma_client.attempt_db_reconnect( + reason="postgres_cached_plan_error", + force_recreate=True, + stale_read_engine=_StaleReadEngine(wrapper=prisma_client.read_db, generation=7), + ) + + assert ok is True + assert prisma_client._run_reconnect_cycle.await_count == 1 + + +@pytest.mark.asyncio +async def test_attempt_db_reconnect_honors_cooldown_once_the_reader_itself_was_replaced( + prisma_client: PrismaClient, +) -> None: + """The mirror of the above: once the reader has been replaced, the recreate + the caller needed has already happened, so the cooldown collapses the rest + of the burst even though the writer generation never moved.""" + _routing_client(prisma_client, reader_generation=8, writer_generation=99) + prisma_client._db_last_reconnect_attempt_ts = time.time() + prisma_client._run_reconnect_cycle = AsyncMock() + + ok = await prisma_client.attempt_db_reconnect( + reason="postgres_cached_plan_error", + force_recreate=True, + stale_read_engine=_StaleReadEngine(wrapper=prisma_client.read_db, generation=7), + ) + + assert ok is False + prisma_client._run_reconnect_cycle.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_attempt_db_reconnect_gates_when_reads_moved_to_an_engine_of_the_same_generation( + prisma_client: PrismaClient, +) -> None: + """The counters are per engine, so the reader and the writer can sit on the + same number at the same time. Once the reader goes unavailable reads move to + the writer, and the caller's poisoned reader is no longer serving anything, + so the cooldown should gate it. Comparing generations alone cannot tell the + two apart and would hand out the waiver here: the generations are equal on + purpose, which is what makes this the case identity has to decide.""" + writer, reader = _routing_client(prisma_client, reader_generation=5, writer_generation=5) + stale: Final = _StaleReadEngine(wrapper=reader, generation=5) + prisma_client.db._reader_unavailable = True + prisma_client._db_last_reconnect_attempt_ts = time.time() + prisma_client._run_reconnect_cycle = AsyncMock() + + ok = await prisma_client.attempt_db_reconnect( + reason="postgres_cached_plan_error", + force_recreate=True, + stale_read_engine=stale, + ) + + pinned = { + "reads_now_served_by_the_writer": prisma_client.read_db is writer, + "generations_coincide": reader.engine_generation == writer.engine_generation, + # `_cooldown_applies` gates on the failed-repair record OR on liveness, + # and either alone produces this result. Pin that the record is empty, + # or a stray entry would make this pass while testing the other gate. + "no_failed_repair_recorded": dict(prisma_client._failed_recreate_generations) == {}, + "recovered": ok, + "cycles_run": prisma_client._run_reconnect_cycle.await_count, + } + assert pinned == { + "reads_now_served_by_the_writer": True, + "generations_coincide": True, + "no_failed_repair_recorded": True, + "recovered": False, + "cycles_run": 0, + } + + +@pytest.mark.asyncio +async def test_failed_repair_of_one_engine_is_not_evicted_by_a_failure_on_the_other( + prisma_client: PrismaClient, +) -> None: + """The record is kept per engine. Held in a single slot, a failed writer + repair would evict the reader's record, and the next caller naming the + reader's still-unrepaired generation would get the waiver back and run its + own redundant cycle, which is the burst the record exists to collapse.""" + writer, reader = _routing_client(prisma_client, reader_generation=5, writer_generation=3) + stale_reader: Final = _StaleReadEngine(wrapper=reader, generation=5) + stale_writer: Final = _StaleReadEngine(wrapper=writer, generation=3) + prisma_client._db_last_reconnect_attempt_ts = 0.0 + prisma_client._run_reconnect_cycle = AsyncMock(side_effect=RuntimeError("engine spawn failed")) + + await prisma_client.attempt_db_reconnect( + reason="postgres_cached_plan_error", force_recreate=True, stale_read_engine=stale_reader + ) + prisma_client.db._reader_unavailable = True + await prisma_client.attempt_db_reconnect( + reason="postgres_cached_plan_error", force_recreate=True, stale_read_engine=stale_writer + ) + prisma_client.db._reader_unavailable = False + cycles_before_the_reader_returns: Final = prisma_client._run_reconnect_cycle.await_count + + await prisma_client.attempt_db_reconnect( + reason="postgres_cached_plan_error", force_recreate=True, stale_read_engine=stale_reader + ) + + pinned = { + "cycles_before": cycles_before_the_reader_returns, + "cycles_after": prisma_client._run_reconnect_cycle.await_count, + } + assert pinned == {"cycles_before": 2, "cycles_after": 2} + + +@pytest.mark.asyncio +async def test_attempt_db_reconnect_withdraws_the_waiver_after_this_generation_failed_to_repair( + prisma_client: PrismaClient, +) -> None: + """A failed recreate leaves the generation where it was, so without a record + of the failure every queued caller of the same burst would still see its own + generation live and run its own full recreate serially instead of collapsing + onto one attempt. Drives two callers rather than presetting the record, so + the record has to actually be written by the failure.""" + prisma_client.db.engine_generation = 7 + prisma_client._db_last_reconnect_attempt_ts = 0.0 + prisma_client._run_reconnect_cycle = AsyncMock(side_effect=RuntimeError("engine spawn failed")) + + first = await prisma_client.attempt_db_reconnect( + reason="postgres_cached_plan_error", + force_recreate=True, + stale_read_engine=_StaleReadEngine(wrapper=prisma_client.read_db, generation=7), + ) + second = await prisma_client.attempt_db_reconnect( + reason="postgres_cached_plan_error", + force_recreate=True, + stale_read_engine=_StaleReadEngine(wrapper=prisma_client.read_db, generation=7), + ) + + pinned = { + "first": first, + "second": second, + "cycles_run": prisma_client._run_reconnect_cycle.await_count, + } + assert pinned == {"first": False, "second": False, "cycles_run": 1} + + +@pytest.mark.asyncio +async def test_attempt_db_reconnect_keeps_the_waiver_after_an_unrelated_reconnect_failure( + prisma_client: PrismaClient, +) -> None: + """The failure record is scoped to the generation it was trying to repair. + A watchdog or transport-error reconnect names no generation, so its failure + says nothing about whether a stale read engine can be repaired and must not + gate it: gating on a global failure count would 503 authentication for the + length of the cooldown.""" + prisma_client.db.engine_generation = 7 + prisma_client._db_last_reconnect_attempt_ts = 0.0 + prisma_client._run_reconnect_cycle = AsyncMock(side_effect=RuntimeError("watchdog reconnect failed")) + + unrelated = await prisma_client.attempt_db_reconnect(reason="watchdog_probe_failed") + # Read before the second call: a global failure gate would be armed here, + # and the recovering reconnect below resets the counter either way. + failures_left_by_the_unrelated_reconnect: Final = prisma_client._consecutive_reconnect_failures + + prisma_client._run_reconnect_cycle = AsyncMock() + cached_plan = await prisma_client.attempt_db_reconnect( + reason="postgres_cached_plan_error", + force_recreate=True, + stale_read_engine=_StaleReadEngine(wrapper=prisma_client.read_db, generation=7), + ) + + pinned = { + "unrelated_failed": unrelated, + "failures_left_by_the_unrelated_reconnect": failures_left_by_the_unrelated_reconnect, + "cached_plan_recovered": cached_plan, + "cycles_run_for_cached_plan": prisma_client._run_reconnect_cycle.await_count, + } + assert pinned == { + "unrelated_failed": False, + "failures_left_by_the_unrelated_reconnect": 1, + "cached_plan_recovered": True, + "cycles_run_for_cached_plan": 1, + } + + +@pytest.mark.asyncio +async def test_forced_recreate_declined_by_the_generation_guard_is_not_reported_as_success( + prisma_client: PrismaClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """``recreate_prisma_client`` declines when the writer generation moved + since cycle entry, and the routing wrapper then leaves the reader untouched + too. A forced caller asked for its engine to be replaced and it was not, so + reporting success would reset the consecutive-failure count and log a repair + that never happened. The declined attempt must equally not count as a + failure, or the caller's own backoff would be gated on its next try.""" + monkeypatch.setenv("DATABASE_URL", "postgres://x:y@h:5432/db") + prisma_client._engine_confirmed_dead = False + prisma_client._engine_pid = 0 + prisma_client._start_engine_watcher = AsyncMock() + prisma_client._cleanup_engine_watcher = MagicMock() + prisma_client._db_last_reconnect_attempt_ts = 0.0 + prisma_client._consecutive_reconnect_failures = 0 + + writer = prisma_client.db + writer.recreate_prisma_client = AsyncMock(return_value=False) + writer.query_raw = AsyncMock(return_value=[{"?column?": 1}]) + + ok = await prisma_client.attempt_db_reconnect( + reason="postgres_cached_plan_error", + force_recreate=True, + ) + + pinned = { + "reported_success": ok, + "recreate_attempted": writer.recreate_prisma_client.await_count, + "consecutive_failures": prisma_client._consecutive_reconnect_failures, + } + assert pinned == { + "reported_success": False, + "recreate_attempted": 1, + "consecutive_failures": 0, + } + + +@pytest.mark.asyncio +async def test_unforced_recreate_declined_by_the_generation_guard_still_succeeds( + prisma_client: PrismaClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """The decline is only an error for a caller that forced the recreate. A + transport-blip caller is happy to learn another path already replaced the + engine, so its reconnect still reports success.""" + monkeypatch.setenv("DATABASE_URL", "postgres://x:y@h:5432/db") + prisma_client._engine_confirmed_dead = False + prisma_client._engine_pid = 0 + prisma_client._start_engine_watcher = AsyncMock() + prisma_client._cleanup_engine_watcher = MagicMock() + prisma_client._db_last_reconnect_attempt_ts = 0.0 + + writer = prisma_client.db + writer.recreate_prisma_client = AsyncMock(return_value=False) + # First call is the liveness probe, which must fail so the recreate is + # reached at all; the second is the post-recreate smoke test. + writer.query_raw = AsyncMock(side_effect=[Exception("probe fails"), [{"?column?": 1}]]) + + ok = await prisma_client.attempt_db_reconnect(reason="transport_blip") + + pinned = {"reported_success": ok, "recreate_attempted": writer.recreate_prisma_client.await_count} + assert pinned == {"reported_success": True, "recreate_attempted": 1} + + +@pytest.mark.asyncio +async def test_heavy_path_forced_recreate_declined_is_not_reported_as_success( + prisma_client: PrismaClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """A forced caller reaches the heavy branch too: the escalation threshold + flips ``_engine_confirmed_dead`` after repeated failures, and every cycle + after that takes the dead-engine path. A decline there has to be treated + exactly as it is on the direct path, or the escalation itself reintroduces + the success that never happened.""" + monkeypatch.setenv("DATABASE_URL", "postgres://x:y@h:5432/db") + prisma_client._engine_confirmed_dead = True + prisma_client._engine_pid = 1234 + prisma_client._start_engine_watcher = AsyncMock() + prisma_client._cleanup_engine_watcher = MagicMock() + prisma_client._db_last_reconnect_attempt_ts = 0.0 + prisma_client._consecutive_reconnect_failures = 0 + monkeypatch.setattr(PrismaClient, "_reap_all_zombies", staticmethod(lambda: set())) + + prisma_client.db.recreate_prisma_client = AsyncMock(return_value=False) + + ok = await prisma_client.attempt_db_reconnect( + reason="postgres_cached_plan_error", + force_recreate=True, + ) + + pinned = { + "reported_success": ok, + "recreate_attempted": prisma_client.db.recreate_prisma_client.await_count, + "consecutive_failures": prisma_client._consecutive_reconnect_failures, + # The dead-engine flag must be CLEARED. A raise normally skips the + # clear, which is right for a failure and wrong here: the guard + # declined because another path had already replaced the engine, so it + # is alive. Leaving it set routes the next cycle back down this + # probe-free branch, where the recreate would kill that healthy engine. + "engine_still_confirmed_dead": prisma_client._engine_confirmed_dead, + } + assert pinned == { + "reported_success": False, + "recreate_attempted": 1, + "consecutive_failures": 0, + "engine_still_confirmed_dead": False, + } + + +@pytest.mark.asyncio +async def test_declined_heavy_recreate_disarms_escalation_for_the_next_attempt( + prisma_client: PrismaClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """Clearing the dead-engine flag on a decline is not enough on its own. The + escalation check re-arms that flag whenever the consecutive-failure count is + still at the threshold, so a decline that left the count alone would send + the very next attempt back down the probe-free heavy path and recreate over + the healthy engine another path had just installed. Drives the SECOND + attempt, because the first one alone cannot show this.""" + monkeypatch.setenv("DATABASE_URL", "postgres://x:y@h:5432/db") + prisma_client._engine_pid = 1234 + prisma_client._start_engine_watcher = AsyncMock() + prisma_client._cleanup_engine_watcher = MagicMock() + prisma_client._db_last_reconnect_attempt_ts = 0.0 + monkeypatch.setattr(PrismaClient, "_reap_all_zombies", staticmethod(lambda: set())) + # Escalation already armed by earlier genuine failures. + prisma_client._consecutive_reconnect_failures = prisma_client._reconnect_escalation_threshold + prisma_client.db.recreate_prisma_client = AsyncMock(return_value=False) + prisma_client.db.query_raw = AsyncMock(return_value=[{"?column?": 1}]) + + armed: Final = prisma_client._engine_confirmed_dead is False and prisma_client._consecutive_reconnect_failures > 0 + + await prisma_client.attempt_db_reconnect(reason="postgres_cached_plan_error", force_recreate=True) + + # Kept as its own assert, not folded into the judgement below. These are two + # claims about two moments, the first being a precondition for the second + # meaning anything, and a single combined comparison would hide which one + # failed from both the traceback and a mutation report. + assert { + "escalation_was_armed_by_the_count": armed, + "failures": prisma_client._consecutive_reconnect_failures, + "engine_confirmed_dead": prisma_client._engine_confirmed_dead, + } == {"escalation_was_armed_by_the_count": True, "failures": 0, "engine_confirmed_dead": False} + + prisma_client._db_last_reconnect_attempt_ts = 0.0 + await prisma_client.attempt_db_reconnect(reason="postgres_cached_plan_error", force_recreate=True) + + # The requirement: a later cycle must not reclassify the healthy replacement + # as dead and restart it through the probe-free path. + assert prisma_client._engine_confirmed_dead is False + + +@pytest.mark.asyncio +async def test_unrelated_reconnect_failure_does_not_erase_the_burst_record( + prisma_client: PrismaClient, +) -> None: + """The failure record names one engine, so a caller that names none must + not overwrite it. Otherwise a watchdog failure landing between two callers + of the same burst clears the record and the second caller runs its own full + recreate against the engine the first one just failed to repair.""" + prisma_client.db.engine_generation = 7 + prisma_client._db_last_reconnect_attempt_ts = 0.0 + stale: Final = _StaleReadEngine(wrapper=prisma_client.read_db, generation=7) + prisma_client._run_reconnect_cycle = AsyncMock(side_effect=RuntimeError("engine spawn failed")) + + await prisma_client.attempt_db_reconnect( + reason="postgres_cached_plan_error", + force_recreate=True, + stale_read_engine=stale, + ) + # force=True the way the engine-death callers do, so this one actually + # reaches the failure branch instead of being skipped by the cooldown the + # first caller just stamped. + await prisma_client.attempt_db_reconnect(reason="engine_process_death", force=True) + cycles_before_the_second_burst_caller: Final = prisma_client._run_reconnect_cycle.await_count + + await prisma_client.attempt_db_reconnect( + reason="postgres_cached_plan_error", + force_recreate=True, + stale_read_engine=stale, + ) + + pinned = { + "cycles_before": cycles_before_the_second_burst_caller, + "cycles_after": prisma_client._run_reconnect_cycle.await_count, + } + assert pinned == {"cycles_before": 2, "cycles_after": 2} From 29fe342eadd76a9e7c40a274c15d22606d95096e Mon Sep 17 00:00:00 2001 From: Ahmed N <34286755+hMED22@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:12:37 +0100 Subject: [PATCH 49/49] fix(transcription): stop a zero output rate from zeroing transcription cost (#36914) cost_per_second treated a declared-but-zero output_cost_per_second as a real rate, so the output branch claimed the call and the elif locked out input_cost_per_second. Every transcription model shipping output_cost_per_second 0.0 next to a real input rate billed $0, which covers 43 of the 55 per-second entries in the cost map: all 36 deepgram models, both assemblyai, both elevenlabs scribe, both groq whisper and azure-stt. Custom deployments pairing the two fields the same way billed $0 as well Take the output branch only when that rate is actually billable, so a zero falls through to the input rate. Entries that duplicate one rate into both fields, whisper-1 among them, keep billing exactly what they bill today --- litellm/llms/openai/cost_calculation.py | 7 +- .../llms/openai/test_cost_calculation.py | 83 +++++++++++++++++++ 2 files changed, 87 insertions(+), 3 deletions(-) create mode 100644 tests/test_litellm/llms/openai/test_cost_calculation.py diff --git a/litellm/llms/openai/cost_calculation.py b/litellm/llms/openai/cost_calculation.py index eafabdb880d..0352d246c09 100644 --- a/litellm/llms/openai/cost_calculation.py +++ b/litellm/llms/openai/cost_calculation.py @@ -109,15 +109,16 @@ def cost_per_second(model: str, custom_llm_provider: str | None, duration: float prompt_cost = 0.0 completion_cost = 0.0 ## Speech / Audio cost calculation - if "output_cost_per_second" in model_info and model_info["output_cost_per_second"] is not None: + output_cost_per_second: Final = model_info.get("output_cost_per_second") + if output_cost_per_second is not None and output_cost_per_second > 0: verbose_logger.debug( "For model=%s - output_cost_per_second: %s; duration: %s", model, - model_info.get("output_cost_per_second"), + output_cost_per_second, duration, ) ## COST PER SECOND ## - completion_cost = model_info["output_cost_per_second"] * duration + completion_cost = output_cost_per_second * duration elif "input_cost_per_second" in model_info and model_info["input_cost_per_second"] is not None: verbose_logger.debug( "For model=%s - input_cost_per_second: %s; duration: %s", diff --git a/tests/test_litellm/llms/openai/test_cost_calculation.py b/tests/test_litellm/llms/openai/test_cost_calculation.py new file mode 100644 index 00000000000..9b6aec1966c --- /dev/null +++ b/tests/test_litellm/llms/openai/test_cost_calculation.py @@ -0,0 +1,83 @@ +"""Tests for per-second transcription cost calculation.""" + +import pytest + +import litellm +from litellm.llms.openai.cost_calculation import cost_per_second + + +def _register_stt(name: str, **pricing: float) -> None: + litellm.register_model( + { + name: { + "mode": "audio_transcription", + "litellm_provider": "openai", + **pricing, + } + }, + persist_across_reloads=False, + ) + + +def test_input_rate_bills_when_output_rate_is_zero(): + """A declared-but-zero output rate must not suppress the real input rate.""" + _register_stt( + "test-stt-zero-output", + input_cost_per_second=5e-05, + output_cost_per_second=0.0, + ) + + prompt_cost, completion_cost = cost_per_second( + model="test-stt-zero-output", custom_llm_provider="openai", duration=300.0 + ) + + assert prompt_cost == pytest.approx(0.015) + assert completion_cost == 0.0 + + +def test_output_rate_takes_precedence_when_both_are_billable(): + """Entries duplicating one rate into both fields must not be billed twice.""" + _register_stt( + "test-stt-both-rates", + input_cost_per_second=1e-04, + output_cost_per_second=1e-04, + ) + + prompt_cost, completion_cost = cost_per_second( + model="test-stt-both-rates", custom_llm_provider="openai", duration=10.0 + ) + + assert prompt_cost + completion_cost == pytest.approx(1e-03) + + +def test_output_rate_alone_still_bills(): + _register_stt("test-stt-output-only", output_cost_per_second=3e-05) + + prompt_cost, completion_cost = cost_per_second( + model="test-stt-output-only", custom_llm_provider="openai", duration=60.0 + ) + + assert prompt_cost == 0.0 + assert completion_cost == pytest.approx(1.8e-03) + + +@pytest.mark.parametrize( + "model, provider", + [ + ("deepgram/nova-3", "deepgram"), + ("groq/whisper-large-v3", "groq"), + ("elevenlabs/scribe_v1", "elevenlabs"), + ("assemblyai/best", "assemblyai"), + ("whisper-1", "openai"), + ], +) +def test_shipped_per_second_models_bill_a_non_zero_cost(model, provider): + prompt_cost, completion_cost = cost_per_second(model=model, custom_llm_provider=provider, duration=60.0) + + assert prompt_cost + completion_cost > 0.0 + + +def test_whisper_bills_its_documented_rate_once(): + prompt_cost, completion_cost = cost_per_second(model="whisper-1", custom_llm_provider="openai", duration=30.0) + + assert prompt_cost + completion_cost == pytest.approx(0.003)