diff --git a/litellm/litellm_core_utils/logging_worker.py b/litellm/litellm_core_utils/logging_worker.py index 41d2af27eeb..77792671f6f 100644 --- a/litellm/litellm_core_utils/logging_worker.py +++ b/litellm/litellm_core_utils/logging_worker.py @@ -5,7 +5,7 @@ import asyncio import atexit import contextvars import logging -from collections.abc import Coroutine +from collections.abc import Coroutine, Iterator from typing import Final from typing_extensions import TypedDict @@ -61,6 +61,19 @@ class LoggingWorker: # Register cleanup handler to flush remaining events on exit atexit.register(self._flush_on_exit) + @staticmethod + def _drain_pending(queue: "asyncio.Queue[LoggingTask]") -> tuple[LoggingTask, ...]: + """Pop every task still queued, without awaiting them, so they can be moved to another queue.""" + + def _pop_until_empty() -> Iterator[LoggingTask]: + while True: + try: + yield queue.get_nowait() + except asyncio.QueueEmpty: + return + + return tuple(_pop_until_empty()) + def _ensure_queue(self) -> None: """Initialize the queue if it doesn't exist or if event loop has changed.""" try: @@ -69,14 +82,27 @@ class LoggingWorker: # No running loop, can't initialize return - # Check if we need to reinitialize due to event loop change + # The queue, semaphore and worker task are all bound to the loop that created them. On a + # loop change we hand the still-pending tasks to a fresh queue instead of dropping them, + # so queued spend-logging coroutines are not silently discarded (and never left un-awaited). if self._queue is not None and self._bound_loop is not current_loop: - verbose_logger.debug("LoggingWorker: Event loop changed, reinitializing queue and worker") - # Clear old state - these are bound to the old loop - self._queue = None + carried_over: Final = self._drain_pending(self._queue) + new_queue: Final[asyncio.Queue[LoggingTask]] = asyncio.Queue(maxsize=self.max_queue_size) + for carried_task in carried_over: + new_queue.put_nowait(carried_task) + if carried_over: + verbose_logger.warning( + "LoggingWorker: event loop changed; carried %d pending logging task(s) onto the new loop", + len(carried_over), + ) + else: + verbose_logger.debug("LoggingWorker: Event loop changed, reinitializing queue and worker") self._sem = None self._worker_task = None self._running_tasks.clear() + self._queue = new_queue + self._bound_loop = current_loop + return if self._queue is None: self._queue = asyncio.Queue(maxsize=self.max_queue_size) diff --git a/litellm/proxy/rag_endpoints/endpoints.py b/litellm/proxy/rag_endpoints/endpoints.py index 9e2b1c9d82d..4d62f1d6d71 100644 --- a/litellm/proxy/rag_endpoints/endpoints.py +++ b/litellm/proxy/rag_endpoints/endpoints.py @@ -27,6 +27,13 @@ from litellm.proxy.common_utils.http_parsing_utils import ( _safe_get_request_headers, get_form_data, ) +from litellm.proxy.rag_endpoints.upload_security import ( + MAX_UPLOAD_SIZE_BYTES, + EicarTestMalwareScanner, + MalwareScanner, + RejectedUpload, + validate_upload, +) from litellm.proxy.vector_store_endpoints.utils import ( assert_user_can_access_vector_store_id, ) @@ -287,8 +294,22 @@ async def _save_vector_store_to_db_from_rag_ingest( verbose_proxy_logger.exception("Failed to save vector store %s to database: %s", vector_store_id, db_error) +def _secure_uploaded_file( + file_data: tuple[str, bytes, str], + scanner: MalwareScanner, +) -> tuple[str, bytes, str]: + validation: Final = validate_upload(content=file_data[1], scanner=scanner) + if isinstance(validation, RejectedUpload): + raise HTTPException( + status_code=400, + detail={"error": validation.message, "reason": validation.reason.value}, + ) + return validation.safe_filename, file_data[1], validation.content_type + + async def parse_rag_ingest_request( request: Request, + scanner: MalwareScanner, ) -> tuple[dict[str, Any], tuple[str, bytes, str] | None, str | None, str | None]: """ Parse RAG ingest request. @@ -297,6 +318,11 @@ async def parse_rag_ingest_request( - Form: file + request JSON in form field - JSON body for URL-based ingestion + Uploaded file bytes are validated against the vector-store upload controls + (size limit, format allowlist with content inspection, archive rejection, + and the injected malware scanner) and given a server-generated filename + before they are returned. + Returns: Tuple of (ingest_options, file_data, file_url, file_id) """ @@ -315,7 +341,7 @@ async def parse_rag_ingest_request( # Get file file_obj = form_data.get("file") if file_obj is not None and hasattr(file_obj, "read"): - file_content = await file_obj.read() + file_content = await file_obj.read(MAX_UPLOAD_SIZE_BYTES + 1) file_data = (file_obj.filename, file_content, file_obj.content_type) # Parse JSON from 'request' form field (contains full request body as JSON) @@ -357,6 +383,10 @@ async def parse_rag_ingest_request( detail={"error": "Must provide file, file_url, or file_id"}, ) + secured_file_data: Final[tuple[str, bytes, str] | None] = ( + _secure_uploaded_file(file_data, scanner) if file_data is not None else None + ) + if "vector_store" not in ingest_options: raise HTTPException( status_code=400, @@ -398,7 +428,7 @@ async def parse_rag_ingest_request( }, ) - return ingest_options, file_data, file_url, file_id + return ingest_options, secured_file_data, file_url, file_id @router.post( @@ -461,7 +491,9 @@ async def rag_ingest( try: # Parse request - ingest_options, file_data, file_url, file_id = await parse_rag_ingest_request(request) + ingest_options, file_data, file_url, file_id = await parse_rag_ingest_request( + request, scanner=EicarTestMalwareScanner() + ) # INTERNAL_USER_VIEW_ONLY can ingest to existing vector stores only if user_api_key_dict.user_role == LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value and not ingest_options.get( diff --git a/litellm/proxy/rag_endpoints/upload_security.py b/litellm/proxy/rag_endpoints/upload_security.py new file mode 100644 index 00000000000..f0318f2f709 --- /dev/null +++ b/litellm/proxy/rag_endpoints/upload_security.py @@ -0,0 +1,289 @@ +"""Security controls for vector-store file uploads. + +Content is classified by inspecting its actual bytes (magic signatures and a +strict UTF-8 decode), never by trusting the client-supplied filename or +content-type. Uploads are restricted to an allowlist of non-executable formats, +capped in size, screened for archives, and passed through a dependency-injected +malware scanner before they are accepted. Accepted uploads are given a +server-generated filename so the client-controlled name never reaches storage. +""" + +from __future__ import annotations + +import uuid +from collections.abc import Mapping +from dataclasses import dataclass +from enum import Enum +from types import MappingProxyType +from typing import Final, Protocol, TypeAlias, runtime_checkable + +from typing_extensions import assert_never + +MAX_UPLOAD_SIZE_BYTES: Final = 512 * 1024 * 1024 + +EICAR_TEST_SIGNATURE: Final = b"X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*" + +_ARCHIVE_MAGIC_PREFIXES: Final[tuple[bytes, ...]] = ( + b"PK\x03\x04", + b"PK\x05\x06", + b"PK\x07\x08", + b"\x1f\x8b", + b"\xfd7zXZ\x00", + b"7z\xbc\xaf\x27\x1c", + b"Rar!\x1a\x07\x00", + b"Rar!\x1a\x07\x01\x00", + b"\x04\x22\x4d\x18", + b"\x28\xb5\x2f\xfd", +) + +_ARCHIVE_MAGIC_PREFIXES_ASCII_AMBIGUOUS: Final[tuple[bytes, ...]] = (b"BZh",) + +_EXECUTABLE_MAGIC_PREFIXES: Final[tuple[bytes, ...]] = ( + b"\x7fELF", + b"\xca\xfe\xba\xbe", + b"\xfe\xed\xfa\xce", + b"\xfe\xed\xfa\xcf", + b"\xce\xfa\xed\xfe", + b"\xcf\xfa\xed\xfe", + b"\x00asm", +) + +_EXECUTABLE_MAGIC_PREFIXES_ASCII_AMBIGUOUS: Final[tuple[bytes, ...]] = (b"MZ", b"dex\n") + +_TAR_USTAR_MAGIC: Final = b"ustar" +_TAR_USTAR_OFFSET: Final = 257 + +_UTF8_BOM: Final = b"\xef\xbb\xbf" + + +class DetectedFormat(str, Enum): + PDF = "pdf" + TEXT = "text" + + +class DisallowedKind(str, Enum): + ARCHIVE = "archive" + EXECUTABLE = "executable" + UNKNOWN_BINARY = "unknown_binary" + + +class RejectionReason(str, Enum): + EMPTY_FILE = "empty_file" + FILE_TOO_LARGE = "file_too_large" + ARCHIVE_NOT_ALLOWED = "archive_not_allowed" + EXECUTABLE_NOT_ALLOWED = "executable_not_allowed" + UNSUPPORTED_FORMAT = "unsupported_format" + MALWARE_DETECTED = "malware_detected" + MALWARE_SCAN_ERROR = "malware_scan_error" + + +class ScanVerdict(str, Enum): + CLEAN = "clean" + INFECTED = "infected" + ERROR = "error" + + +@dataclass(frozen=True, slots=True) +class ScanResult: + verdict: ScanVerdict + signature: str | None = None + + +@runtime_checkable +class MalwareScanner(Protocol): + def scan(self, content: bytes) -> ScanResult: ... + + +@dataclass(frozen=True, slots=True) +class EicarTestMalwareScanner: + """Placeholder scanner that only flags the EICAR anti-malware test file. + + It exists to prove the scan hook is wired end to end and to satisfy the + EICAR retest; it provides no real protection. Inject a scanner backed by a + real engine through the ``scanner`` parameter of :func:`validate_upload` to + screen production uploads. + """ + + def scan(self, content: bytes) -> ScanResult: + if EICAR_TEST_SIGNATURE in content: + return ScanResult(verdict=ScanVerdict.INFECTED, signature="EICAR-STANDARD-ANTIVIRUS-TEST-FILE") + return ScanResult(verdict=ScanVerdict.CLEAN) + + +@dataclass(frozen=True, slots=True) +class AllowedContent: + format: DetectedFormat + + +@dataclass(frozen=True, slots=True) +class DisallowedContent: + kind: DisallowedKind + + +ContentInspection: TypeAlias = AllowedContent | DisallowedContent + + +@dataclass(frozen=True, slots=True) +class SecuredUpload: + safe_filename: str + content_type: str + detected_format: DetectedFormat + size_bytes: int + + +@dataclass(frozen=True, slots=True) +class RejectedUpload: + reason: RejectionReason + message: str + + +UploadValidation: TypeAlias = SecuredUpload | RejectedUpload + +_SAFE_EXTENSION: Final[Mapping[DetectedFormat, str]] = MappingProxyType( + { + DetectedFormat.PDF: "pdf", + DetectedFormat.TEXT: "txt", + } +) + +_SAFE_CONTENT_TYPE: Final[Mapping[DetectedFormat, str]] = MappingProxyType( + { + DetectedFormat.PDF: "application/pdf", + DetectedFormat.TEXT: "text/plain", + } +) + + +def _starts_with_any(content: bytes, prefixes: tuple[bytes, ...]) -> bool: + return any(content.startswith(prefix) for prefix in prefixes) + + +def _is_archive(content: bytes) -> bool: + if _starts_with_any(content, _ARCHIVE_MAGIC_PREFIXES): + return True + tar_magic_end: Final = _TAR_USTAR_OFFSET + len(_TAR_USTAR_MAGIC) + if len(content) >= tar_magic_end and content[_TAR_USTAR_OFFSET:tar_magic_end] == _TAR_USTAR_MAGIC: + return True + return _starts_with_any(content, _ARCHIVE_MAGIC_PREFIXES_ASCII_AMBIGUOUS) and not _is_utf8_text(content) + + +def _is_utf8_text(content: bytes) -> bool: + if b"\x00" in content: + return False + try: + content.decode("utf-8") + except UnicodeDecodeError: + return False + return True + + +def _is_executable_binary(content: bytes) -> bool: + if _starts_with_any(content, _EXECUTABLE_MAGIC_PREFIXES): + return True + return _starts_with_any(content, _EXECUTABLE_MAGIC_PREFIXES_ASCII_AMBIGUOUS) and not _is_utf8_text(content) + + +def _looks_like_shebang(content: bytes) -> bool: + body: Final = content.removeprefix(_UTF8_BOM).lstrip() + return body.startswith(b"#!") + + +def inspect_content(content: bytes) -> ContentInspection: + if _looks_like_shebang(content): + return DisallowedContent(DisallowedKind.EXECUTABLE) + if content.startswith(b"%PDF-"): + return AllowedContent(DetectedFormat.PDF) + if _is_archive(content): + return DisallowedContent(DisallowedKind.ARCHIVE) + if _is_executable_binary(content): + return DisallowedContent(DisallowedKind.EXECUTABLE) + if _is_utf8_text(content): + return AllowedContent(DetectedFormat.TEXT) + return DisallowedContent(DisallowedKind.UNKNOWN_BINARY) + + +def generate_safe_filename(detected_format: DetectedFormat) -> str: + return f"{uuid.uuid4().hex}.{_SAFE_EXTENSION[detected_format]}" + + +def _reject_disallowed(kind: DisallowedKind) -> RejectedUpload: + match kind: + case DisallowedKind.ARCHIVE: + return RejectedUpload( + RejectionReason.ARCHIVE_NOT_ALLOWED, + "Archive uploads are not allowed.", + ) + case DisallowedKind.EXECUTABLE: + return RejectedUpload( + RejectionReason.EXECUTABLE_NOT_ALLOWED, + "Executable uploads are not allowed.", + ) + case DisallowedKind.UNKNOWN_BINARY: + return RejectedUpload( + RejectionReason.UNSUPPORTED_FORMAT, + "Only PDF and UTF-8 text documents are accepted.", + ) + assert_never(kind) + + +def _scan_rejection(content: bytes, scanner: MalwareScanner) -> RejectedUpload | None: + result: Final = scanner.scan(content) + match result.verdict: + case ScanVerdict.CLEAN: + return None + case ScanVerdict.INFECTED: + return RejectedUpload( + RejectionReason.MALWARE_DETECTED, + f"Uploaded file was flagged by malware scanning ({result.signature or 'unknown signature'}).", + ) + case ScanVerdict.ERROR: + return RejectedUpload( + RejectionReason.MALWARE_SCAN_ERROR, + "Malware scanning could not complete; upload rejected.", + ) + assert_never(result.verdict) + + +def validate_upload( + *, + content: bytes, + scanner: MalwareScanner, + max_size_bytes: int = MAX_UPLOAD_SIZE_BYTES, +) -> UploadValidation: + size: Final = len(content) + if size == 0: + return RejectedUpload(RejectionReason.EMPTY_FILE, "Uploaded file is empty.") + if size > max_size_bytes: + return RejectedUpload( + RejectionReason.FILE_TOO_LARGE, + f"Uploaded file is {size} bytes, exceeding the {max_size_bytes}-byte limit.", + ) + + inspection: Final = inspect_content(content) + if isinstance(inspection, DisallowedContent): + return _reject_disallowed(inspection.kind) + + scan_rejection: Final = _scan_rejection(content, scanner) + if scan_rejection is not None: + return scan_rejection + + return SecuredUpload( + safe_filename=generate_safe_filename(inspection.format), + content_type=_SAFE_CONTENT_TYPE[inspection.format], + detected_format=inspection.format, + size_bytes=size, + ) + + +def _sanitize_header_filename(filename: str) -> str: + stripped: Final = "".join(char for char in filename if char not in '"\\\r\n').strip() + return stripped or "download" + + +def safe_download_headers(filename: str) -> Mapping[str, str]: + return MappingProxyType( + { + "Content-Disposition": f'attachment; filename="{_sanitize_header_filename(filename)}"', + "X-Content-Type-Options": "nosniff", + } + ) diff --git a/litellm/proxy/vector_store_files_endpoints/endpoints.py b/litellm/proxy/vector_store_files_endpoints/endpoints.py index c9b89bcd390..957ed9fd0b9 100644 --- a/litellm/proxy/vector_store_files_endpoints/endpoints.py +++ b/litellm/proxy/vector_store_files_endpoints/endpoints.py @@ -17,6 +17,7 @@ from litellm.proxy.openai_files_endpoints.common_utils import ( handle_model_based_routing, prepare_data_with_credentials, ) +from litellm.proxy.rag_endpoints.upload_security import safe_download_headers from litellm.proxy.vector_store_endpoints.utils import ( assert_user_can_access_vector_store_id, is_allowed_to_call_vector_store_files_endpoint, @@ -885,6 +886,9 @@ async def vector_store_file_content( if original_managed_file_id: response = _replace_file_id_in_response(response, original_managed_file_id) + for header_name, header_value in safe_download_headers(file_id).items(): + fastapi_response.headers[header_name] = header_value + return response except Exception as e: # noqa: BLE001 raise await processor._handle_llm_api_exception( diff --git a/litellm/utils.py b/litellm/utils.py index b026d33271f..74a12bfc716 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -6528,13 +6528,6 @@ def acreate(*args, **kwargs): ## Thin client to handle the acreate langchain ca return litellm.acompletion(*args, **kwargs) -def prompt_token_calculator(model, messages): - text: Final = " ".join(message["content"] for message in messages) - if "claude" in model: - return token_counter(model=model, text=text) - return len(_get_default_encoding().encode(text)) - - def valid_model(model): try: # for a given model name, check if the user has the right permissions to access the model diff --git a/ruff-strict-budget.json b/ruff-strict-budget.json index 3b3c3e8ae5b..03318718fb5 100644 --- a/ruff-strict-budget.json +++ b/ruff-strict-budget.json @@ -1,6 +1,6 @@ { "ANN001": { - "limit": 3020 + "limit": 3018 }, "ANN002": { "limit": 71 @@ -9,7 +9,7 @@ "limit": 827 }, "ANN201": { - "limit": 2017 + "limit": 2016 }, "ANN202": { "limit": 852 diff --git a/tests/e2e/batches/COVERAGE.md b/tests/e2e/batches/COVERAGE.md index f02d4eb4fe4..6d50cb436e2 100644 --- a/tests/e2e/batches/COVERAGE.md +++ b/tests/e2e/batches/COVERAGE.md @@ -81,6 +81,21 @@ File delete asserts `object=="file"` and `deleted==True`. | `capabilities.py` | the provider x scenario matrix + per-provider /model/new params + id-shape classifiers + per-provider raw-id assertion | | `conftest.py` | session-scoped batch deployment registration and teardown | | `test_batches_e2e.py` | parametrized lifecycle with per-endpoint output assertions, file upload/delete outputs, key-model-access denial, per-backend content download, failure paths, second-hop routing, terminal state + cost | +| `test_managed_files_enforcement_e2e.py` | require_managed_files enforcement pins; deselected unless `E2E_MANAGED_FILES_STACK` is set (see below) | + +## require_managed_files enforcement (separate stack phase) + +`litellm_settings.require_managed_files` is a boot-time module global with no per-key +or runtime override, and turning it on 400s every upload that lacks +`target_model_names`, including the files_settings-routed `provider_fallback` +scenario above. So its pins cannot share a proxy with the rest of this suite: +`test_managed_files_enforcement_e2e.py` carries the `managed_files` marker, is +deselected unless `E2E_MANAGED_FILES_STACK` is set (the same pattern as the `weekly` +marker), and the PR gate runs it in a sequential phase after the main suite, against +the same ephemeral stack redeployed with the flag on. The pins: upload without +`target_model_names` is a 400, upload carrying a `model` param is a 400, a raw +provider file id on retrieve is a 400, and another user's managed unified id is a +403 while the owning user still retrieves it. ## Failure paths diff --git a/tests/e2e/batches/conftest.py b/tests/e2e/batches/conftest.py index 73e8918e2ee..3b133fab680 100644 --- a/tests/e2e/batches/conftest.py +++ b/tests/e2e/batches/conftest.py @@ -12,12 +12,14 @@ the proxy config. from __future__ import annotations +import os from typing import Iterator import pytest from batch_client import BatchClient, build_client from capabilities import PROVIDERS +from e2e_config import MANAGED_FILES_OPT_IN_ENV from e2e_http import NoBody from proxy_client import ProxyClient @@ -29,6 +31,22 @@ def pytest_configure(config: pytest.Config) -> None: ) +def pytest_collection_modifyitems( + config: pytest.Config, items: list[pytest.Item] +) -> None: + if os.environ.get(MANAGED_FILES_OPT_IN_ENV): + return + deselected = [ + item for item in items if item.get_closest_marker("managed_files") is not None + ] + if not deselected: + return + config.hook.pytest_deselected(items=deselected) + items[:] = [ + item for item in items if item.get_closest_marker("managed_files") is None + ] + + @pytest.fixture(scope="session") def client(proxy: ProxyClient) -> BatchClient: return build_client(proxy) diff --git a/tests/e2e/batches/test_managed_files_enforcement_e2e.py b/tests/e2e/batches/test_managed_files_enforcement_e2e.py new file mode 100644 index 00000000000..7ad0b16adc3 --- /dev/null +++ b/tests/e2e/batches/test_managed_files_enforcement_e2e.py @@ -0,0 +1,118 @@ +"""Live e2e pins for litellm_settings.require_managed_files enforcement. + +require_managed_files is a boot-time module global, so these tests need a proxy +whose config enables it. The main ephemeral stack can never run with it on: the +flag would 400 every files_settings-routed upload in the rest of the suite. The +PR gate instead reconfigures the same stack sequentially after the main run and +executes only this file with E2E_MANAGED_FILES_STACK set; without that env every +test here is deselected (see conftest.py, mirroring the weekly marker). + +Pins: an upload without target_model_names is rejected 400, an upload that also +carries a model param is rejected 400, a raw provider file id is rejected 400 on +retrieve, and another user's managed unified file id is denied 403 while the +owning user still retrieves it. +""" + +from __future__ import annotations + +import json +from typing import Iterator + +import pytest + +from batch_client import BatchClient, FileObject +from capabilities import batch_model_name, is_managed_id, openai_batch_params +from e2e_config import unique_marker +from e2e_http import FileUploadForm, Result, UnknownApiError, unwrap +from lifecycle import ResourceManager + +pytestmark = [pytest.mark.e2e, pytest.mark.managed_files] + +UPLOAD_ROW = "llm.files.openai.require_managed_files_upload.nonstream.works" +ISOLATION_ROW = "llm.files.openai.require_managed_files_isolation.nonstream.works" + + +def batch_jsonl(model: str) -> bytes: + line = { + "custom_id": "req-1", + "method": "POST", + "url": "/v1/chat/completions", + "body": { + "model": model, + "messages": [{"role": "user", "content": "ping"}], + "max_tokens": 8, + }, + } + return (json.dumps(line) + "\n").encode() + + +def expect_api_error(result: Result[FileObject], status: int, needle: str) -> None: + match result: + case UnknownApiError(status_code=code, body=body) if code == status: + assert needle in body, f"expected {needle!r} in HTTP {status} body: {body[:300]}" + case _: + raise AssertionError(f"expected HTTP {status} containing {needle!r}, got: {result}") + + +@pytest.fixture(scope="module") +def managed_model(client: BatchClient) -> Iterator[str]: + model_name = batch_model_name("managed-files-openai") + model_id = client.create_model(model_name, openai_batch_params()) + yield model_name + client.delete_model(model_id) + + +@pytest.mark.covers(UPLOAD_ROW) +def test_upload_without_target_model_names_rejected( + client: BatchClient, scoped_key: str, managed_model: str +) -> None: + result = client.upload_file( + content=batch_jsonl(managed_model), + form=FileUploadForm(purpose="batch"), + key=scoped_key, + ) + expect_api_error(result, 400, "target_model_names is required") + + +@pytest.mark.covers(UPLOAD_ROW) +def test_upload_with_model_param_rejected( + client: BatchClient, scoped_key: str, managed_model: str +) -> None: + result = client.upload_file( + content=batch_jsonl(managed_model), + form=FileUploadForm(purpose="batch", target_model_names=managed_model), + model=managed_model, + key=scoped_key, + ) + expect_api_error(result, 400, "model is not allowed") + + +@pytest.mark.covers(ISOLATION_ROW) +def test_raw_provider_file_id_rejected(client: BatchClient, scoped_key: str) -> None: + result = client.retrieve_file("file-e2e-raw-provider-id", key=scoped_key) + expect_api_error(result, 400, "Raw provider file ids cannot be used") + + +@pytest.mark.covers(ISOLATION_ROW) +def test_cross_user_managed_id_denied_owner_allowed( + client: BatchClient, resources: ResourceManager, managed_model: str +) -> None: + run = unique_marker() + owner_key = resources.key(user_id=f"managed-files-owner-{run}") + other_key = resources.key(user_id=f"managed-files-other-{run}") + + uploaded = unwrap( + client.upload_file( + content=batch_jsonl(managed_model), + form=FileUploadForm(purpose="batch", target_model_names=managed_model), + key=owner_key, + ) + ) + resources.defer(lambda: client.delete_file(uploaded.id, key=owner_key)) + assert is_managed_id(uploaded.id), f"expected a managed unified file id, got {uploaded.id}" + + denied = client.retrieve_file(uploaded.id, key=other_key) + expect_api_error(denied, 403, "does not have access to this managed file") + + retrieved = unwrap(client.retrieve_file(uploaded.id, key=owner_key)) + assert retrieved.id == uploaded.id diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index dbe2d6e514e..1bc3ed98eb6 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -51,6 +51,10 @@ def pytest_configure(config: pytest.Config) -> None: "markers", "weekly: real-provider anomaly load test that spends real money; deselected unless E2E_WEEKLY_ANOMALY is set", ) + config.addinivalue_line( + "markers", + "managed_files: needs a proxy running with require_managed_files enabled; deselected unless E2E_MANAGED_FILES_STACK is set", + ) def pytest_sessionstart(session: pytest.Session) -> None: diff --git a/tests/e2e/coverage_registry/llm_nonconversational.yaml b/tests/e2e/coverage_registry/llm_nonconversational.yaml index e6f08123b7c..47d296e61f3 100644 --- a/tests/e2e/coverage_registry/llm_nonconversational.yaml +++ b/tests/e2e/coverage_registry/llm_nonconversational.yaml @@ -45,6 +45,8 @@ - {id: llm.files.bedrock.upload.nonstream.works, module: llm, tier: P0, subject_endpoint: files, route: bedrock_converse, capability: basic, streaming: nonstream, assertions: [works], source: "batches/capabilities.py:59", rationale: "Bedrock file upload to S3"} - {id: llm.files.gemini.upload.nonstream.works, module: llm, tier: P1, subject_endpoint: files, route: gemini, capability: basic, streaming: nonstream, assertions: [works], source: "test_batches_e2e.py", rationale: "Gemini Files API upload via proxy"} - {id: llm.files.hosted_vllm.upload.nonstream.works, module: llm, tier: P1, subject_endpoint: files, route: hosted_vllm, capability: basic, streaming: nonstream, assertions: [works], source: "test_batches_e2e.py", rationale: "hosted_vllm OpenAI-compatible file upload"} +- {id: llm.files.openai.require_managed_files_upload.nonstream.works, module: llm, tier: P1, subject_endpoint: files, route: openai, capability: input_validation, streaming: nonstream, assertions: [works], source: "test_managed_files_enforcement_e2e.py / LIT-5902", rationale: "With require_managed_files enabled, an upload without target_model_names and an upload carrying a model param are both rejected 400; runs only in the sequential managed-files stack phase (E2E_MANAGED_FILES_STACK)"} +- {id: llm.files.openai.require_managed_files_isolation.nonstream.works, module: llm, tier: P1, subject_endpoint: files, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "test_managed_files_enforcement_e2e.py / LIT-5902", rationale: "With require_managed_files enabled, a raw provider file id is rejected 400 and another user's managed unified id is denied 403 while the owner still retrieves it; runs only in the managed-files stack phase"} - {id: llm.rerank.cohere.basic.nonstream.works, module: llm, tier: P1, subject_endpoint: rerank, route: cohere, capability: basic, streaming: nonstream, assertions: [works], source: "test_rerank_e2e.py:29", rationale: "Cohere rerank, top_n + relevance_score"} - {id: llm.files.openai.content.nonstream.works, module: llm, tier: P0, subject_endpoint: files, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "test_batches_e2e.py", rationale: "GET /v1/files/{id}/content returns uploaded batch JSONL bytes"} - {id: llm.files.azure_openai.content.nonstream.works, module: llm, tier: P0, subject_endpoint: files, route: azure_openai, capability: basic, streaming: nonstream, assertions: [works], source: "test_batches_e2e.py / LIT-5730", rationale: "GET /v1/files/{id}/content on an Azure unified file returns the uploaded JSONL bytes verbatim"} diff --git a/tests/e2e/e2e_config.py b/tests/e2e/e2e_config.py index 21a7a8c478a..21c5a338dc3 100644 --- a/tests/e2e/e2e_config.py +++ b/tests/e2e/e2e_config.py @@ -133,6 +133,7 @@ LOAD_MAX_SERIAL_LATENCY_SECONDS = float(os.environ.get("E2E_LOAD_MAX_SERIAL_LATE LOAD_MIN_CONCURRENCY_EFFICIENCY = float(os.environ.get("E2E_LOAD_MIN_CONCURRENCY_EFFICIENCY", "0.8")) WEEKLY_ANOMALY_OPT_IN_ENV = "E2E_WEEKLY_ANOMALY" +MANAGED_FILES_OPT_IN_ENV = "E2E_MANAGED_FILES_STACK" ANOMALY_SESSIONS = int(os.environ.get("E2E_ANOMALY_SESSIONS", "6")) ANOMALY_TURNS_PER_SESSION = int(os.environ.get("E2E_ANOMALY_TURNS_PER_SESSION", "6")) ANOMALY_TURN_ATTEMPTS = int(os.environ.get("E2E_ANOMALY_TURN_ATTEMPTS", "3")) diff --git a/tests/e2e/pytest.ini b/tests/e2e/pytest.ini index 8feb4505ce3..8ef9afbfa1c 100644 --- a/tests/e2e/pytest.ini +++ b/tests/e2e/pytest.ini @@ -7,3 +7,4 @@ markers = e2e: live test that requires a running proxy and real provider keys load: heavy throughput/load test; collected last so it never perturbs latency-sensitive suites weekly: real-provider anomaly load test that spends real money; deselected unless E2E_WEEKLY_ANOMALY is set + managed_files: needs a proxy running with require_managed_files enabled; deselected unless E2E_MANAGED_FILES_STACK is set diff --git a/tests/test_litellm/litellm_core_utils/test_logging_worker.py b/tests/test_litellm/litellm_core_utils/test_logging_worker.py index 978f22ca2e4..80c9585dae9 100644 --- a/tests/test_litellm/litellm_core_utils/test_logging_worker.py +++ b/tests/test_litellm/litellm_core_utils/test_logging_worker.py @@ -413,3 +413,42 @@ class TestLoggingWorker: assert worker2._bound_loop is not None await worker2.stop() + + def test_event_loop_change_carries_pending_tasks_over(self): + """Regression (LIT-6028): a loop change must not silently drop queued coroutines. + + Before the fix ``_ensure_queue`` nulled ``self._queue`` on a loop change, discarding + every pending ``LoggingTask`` (each an un-awaited spend-logging coroutine). The tasks + must instead be moved onto the queue bound to the new loop and still execute there. + """ + worker = LoggingWorker(timeout=1.0, max_queue_size=10) + executed: list[int] = [] + + async def spend_log(index: int) -> None: + executed.append(index) + + async def enqueue_on_first_loop() -> None: + worker._ensure_queue() + for i in range(5): + worker.enqueue(spend_log(i)) + assert worker._queue is not None + assert worker._queue.qsize() == 5 + + asyncio.run(enqueue_on_first_loop()) + + stale_queue = worker._queue + assert stale_queue is not None + + async def rebind_on_second_loop() -> None: + worker._ensure_queue() + assert worker._queue is not None + # A fresh queue bound to the new loop, holding every carried-over task (not dropped). + assert worker._queue is not stale_queue + assert worker._queue.qsize() == 5 + while not worker._queue.empty(): + task = worker._queue.get_nowait() + await task["context"].run(asyncio.create_task, task["coroutine"]) + + asyncio.run(rebind_on_second_loop()) + + assert sorted(executed) == [0, 1, 2, 3, 4] diff --git a/tests/test_litellm/proxy/rag_endpoints/test_rag_endpoints.py b/tests/test_litellm/proxy/rag_endpoints/test_rag_endpoints.py index b08de04e801..abbf6892a98 100644 --- a/tests/test_litellm/proxy/rag_endpoints/test_rag_endpoints.py +++ b/tests/test_litellm/proxy/rag_endpoints/test_rag_endpoints.py @@ -322,3 +322,92 @@ def test_rag_query_stream_returns_event_stream(client_internal_user): assert response.headers.get("content-type", "").startswith("text/event-stream") assert '"object":"chat.completion.chunk"' in response.text assert "data: [DONE]" in response.text + + +EICAR = r"X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*" +INGEST_REQUEST = '{"ingest_options":{"vector_store":{"custom_llm_provider":"openai"}}}' + + +def _multipart_ingest_request(*, filename: str, content: bytes, content_type: str): + from starlette.requests import Request + + boundary = "litellmuploadtestboundary" + head = ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n' + f"Content-Type: {content_type}\r\n\r\n" + ).encode() + tail = ( + f"\r\n--{boundary}\r\n" + f'Content-Disposition: form-data; name="request"\r\n\r\n' + f"{INGEST_REQUEST}\r\n" + f"--{boundary}--\r\n" + ).encode() + body = head + content + tail + scope = { + "type": "http", + "method": "POST", + "path": "/v1/rag/ingest", + "headers": [ + (b"content-type", f"multipart/form-data; boundary={boundary}".encode()), + (b"content-length", str(len(body)).encode()), + ], + "state": {}, + } + + async def receive(): + return {"type": "http.request", "body": body, "more_body": False} + + return Request(scope, receive) + + +class TestVectorStoreUploadControls: + """End-to-end enforcement of pentest M4 upload controls on /v1/rag/ingest.""" + + def test_eicar_upload_blocked_by_malware_scanner(self, client_internal_user): + response = client_internal_user.post( + "/v1/rag/ingest", + files={"file": ("clean_name.txt", io.BytesIO(EICAR.encode()), "text/plain")}, + data={"request": INGEST_REQUEST}, + ) + assert response.status_code == 400, response.text + assert response.json()["detail"]["reason"] == "malware_detected" + + def test_executable_upload_rejected(self, client_internal_user): + elf = b"\x7fELF\x02\x01\x01\x00" + b"\x00" * 40 + response = client_internal_user.post( + "/v1/rag/ingest", + files={"file": ("doc.txt", io.BytesIO(elf), "text/plain")}, + data={"request": INGEST_REQUEST}, + ) + assert response.status_code == 400, response.text + assert response.json()["detail"]["reason"] == "executable_not_allowed" + + def test_zip_archive_upload_rejected(self, client_internal_user): + response = client_internal_user.post( + "/v1/rag/ingest", + files={"file": ("doc.pdf", io.BytesIO(b"PK\x03\x04\x14\x00\x00\x00payload"), "application/pdf")}, + data={"request": INGEST_REQUEST}, + ) + assert response.status_code == 400, response.text + assert response.json()["detail"]["reason"] == "archive_not_allowed" + + async def test_clean_text_upload_gets_server_generated_filename(self): + from litellm.proxy.rag_endpoints.endpoints import parse_rag_ingest_request + from litellm.proxy.rag_endpoints.upload_security import EicarTestMalwareScanner + + request = _multipart_ingest_request( + filename="../../etc/passwd", + content=b"benign document text\n", + content_type="text/plain", + ) + _options, file_data, _url, _file_id = await parse_rag_ingest_request( + request, scanner=EicarTestMalwareScanner() + ) + assert file_data is not None + server_filename, content_bytes, secured_content_type = file_data + assert server_filename != "../../etc/passwd" + assert "/" not in server_filename and "\\" not in server_filename + assert server_filename.endswith(".txt") + assert secured_content_type == "text/plain" + assert content_bytes == b"benign document text\n" diff --git a/tests/test_litellm/proxy/rag_endpoints/test_upload_security.py b/tests/test_litellm/proxy/rag_endpoints/test_upload_security.py new file mode 100644 index 00000000000..84375080904 --- /dev/null +++ b/tests/test_litellm/proxy/rag_endpoints/test_upload_security.py @@ -0,0 +1,176 @@ +"""Unit tests for vector-store upload security controls. + +These pin the pentest M4 remediation: an allowlist enforced by real content +inspection (not extension/mime trust), a size cap, archive and executable +rejection, server-generated filenames, safe download headers, and a +dependency-injected malware scanner validated with the EICAR test file. +""" + +from dataclasses import dataclass + +import pytest + +from litellm.proxy.rag_endpoints.upload_security import ( + EICAR_TEST_SIGNATURE, + DetectedFormat, + EicarTestMalwareScanner, + RejectedUpload, + RejectionReason, + ScanResult, + ScanVerdict, + SecuredUpload, + generate_safe_filename, + inspect_content, + safe_download_headers, + validate_upload, +) + + +@dataclass(frozen=True) +class _StubScanner: + result: ScanResult + + def scan(self, content: bytes) -> ScanResult: + return self.result + + +_CLEAN_SCANNER = _StubScanner(ScanResult(ScanVerdict.CLEAN)) +_INFECTED_SCANNER = _StubScanner(ScanResult(ScanVerdict.INFECTED, signature="Test.Sig")) +_ERROR_SCANNER = _StubScanner(ScanResult(ScanVerdict.ERROR)) + +_PDF_BYTES = b"%PDF-1.7\n1 0 obj<<>>endobj\n" +_TEXT_BYTES = "the quick brown fox\n".encode("utf-8") +_ELF_BYTES = b"\x7fELF\x02\x01\x01\x00" + b"\x00" * 32 +_PE_BYTES = b"MZ\x90\x00\x03\x00\x00\x00\x04\x00\x00\x00" +_ZIP_BYTES = b"PK\x03\x04\x14\x00\x00\x00" +_GZIP_BYTES = b"\x1f\x8b\x08\x00\x00\x00\x00\x00" +_SHEBANG_BYTES = b"#!/bin/bash\nrm -rf /\n" + + +def _tar_bytes() -> bytes: + header = bytearray(512) + header[257:262] = b"ustar" + return bytes(header) + + +def _expect_rejected(content: bytes, reason: RejectionReason, *, max_size_bytes: int = 512 * 1024 * 1024) -> None: + result = validate_upload(content=content, scanner=_CLEAN_SCANNER, max_size_bytes=max_size_bytes) + assert isinstance(result, RejectedUpload), f"expected rejection, got {result!r}" + assert result.reason is reason, f"expected {reason}, got {result.reason}" + + +def test_empty_file_rejected(): + _expect_rejected(b"", RejectionReason.EMPTY_FILE) + + +def test_oversized_file_rejected(): + _expect_rejected(b"%PDF-" + b"a" * 100, RejectionReason.FILE_TOO_LARGE, max_size_bytes=10) + + +def test_zip_archive_rejected(): + _expect_rejected(_ZIP_BYTES, RejectionReason.ARCHIVE_NOT_ALLOWED) + + +def test_gzip_archive_rejected(): + _expect_rejected(_GZIP_BYTES, RejectionReason.ARCHIVE_NOT_ALLOWED) + + +def test_tar_archive_rejected(): + _expect_rejected(_tar_bytes(), RejectionReason.ARCHIVE_NOT_ALLOWED) + + +def test_elf_executable_rejected(): + _expect_rejected(_ELF_BYTES, RejectionReason.EXECUTABLE_NOT_ALLOWED) + + +def test_windows_pe_executable_rejected(): + _expect_rejected(_PE_BYTES, RejectionReason.EXECUTABLE_NOT_ALLOWED) + + +def test_shebang_script_rejected(): + _expect_rejected(_SHEBANG_BYTES, RejectionReason.EXECUTABLE_NOT_ALLOWED) + + +def test_unknown_binary_rejected(): + _expect_rejected(b"\x89\x01\x02\x00\xff\xfe garbage", RejectionReason.UNSUPPORTED_FORMAT) + + +def test_pdf_accepted_with_server_filename_and_content_type(): + result = validate_upload(content=_PDF_BYTES, scanner=_CLEAN_SCANNER) + assert isinstance(result, SecuredUpload) + assert result.detected_format is DetectedFormat.PDF + assert result.content_type == "application/pdf" + assert result.safe_filename.endswith(".pdf") + assert result.size_bytes == len(_PDF_BYTES) + + +def test_utf8_text_accepted(): + result = validate_upload(content=_TEXT_BYTES, scanner=_CLEAN_SCANNER) + assert isinstance(result, SecuredUpload) + assert result.detected_format is DetectedFormat.TEXT + assert result.content_type == "text/plain" + assert result.safe_filename.endswith(".txt") + + +def test_inspect_content_classifies_directly(): + from litellm.proxy.rag_endpoints.upload_security import AllowedContent, DisallowedContent, DisallowedKind + + assert inspect_content(_PDF_BYTES) == AllowedContent(DetectedFormat.PDF) + assert inspect_content(_TEXT_BYTES) == AllowedContent(DetectedFormat.TEXT) + assert inspect_content(_ZIP_BYTES) == DisallowedContent(DisallowedKind.ARCHIVE) + assert inspect_content(_ELF_BYTES) == DisallowedContent(DisallowedKind.EXECUTABLE) + + +def test_server_generated_filenames_are_unique_and_ignore_client_name(): + first = generate_safe_filename(DetectedFormat.PDF) + second = generate_safe_filename(DetectedFormat.PDF) + assert first != second + assert first.endswith(".pdf") + assert "/" not in first and "\\" not in first + + +def test_malware_hook_blocks_infected_clean_format(): + result = validate_upload(content=_TEXT_BYTES, scanner=_INFECTED_SCANNER) + assert isinstance(result, RejectedUpload) + assert result.reason is RejectionReason.MALWARE_DETECTED + assert "Test.Sig" in result.message + + +def test_malware_scan_error_fails_closed(): + result = validate_upload(content=_TEXT_BYTES, scanner=_ERROR_SCANNER) + assert isinstance(result, RejectedUpload) + assert result.reason is RejectionReason.MALWARE_SCAN_ERROR + + +def test_injected_clean_scanner_allows_valid_file(): + result = validate_upload(content=_TEXT_BYTES, scanner=_CLEAN_SCANNER) + assert isinstance(result, SecuredUpload) + + +def test_eicar_default_scanner_flags_only_eicar(): + scanner = EicarTestMalwareScanner() + assert scanner.scan(EICAR_TEST_SIGNATURE).verdict is ScanVerdict.INFECTED + assert scanner.scan(b"totally benign text").verdict is ScanVerdict.CLEAN + + +def test_eicar_upload_passes_format_but_blocked_by_scanner(): + """EICAR is valid ASCII text, so only the malware hook can stop it.""" + format_only = validate_upload(content=EICAR_TEST_SIGNATURE, scanner=_CLEAN_SCANNER) + assert isinstance(format_only, SecuredUpload) + + scanned = validate_upload(content=EICAR_TEST_SIGNATURE, scanner=EicarTestMalwareScanner()) + assert isinstance(scanned, RejectedUpload) + assert scanned.reason is RejectionReason.MALWARE_DETECTED + + +def test_safe_download_headers_force_attachment_and_nosniff(): + headers = safe_download_headers("file_abc123") + assert headers["Content-Disposition"] == 'attachment; filename="file_abc123"' + assert headers["X-Content-Type-Options"] == "nosniff" + + +@pytest.mark.parametrize("hostile", ['a"; drop', "a\r\nSet-Cookie: x=1", "../../etc/passwd", ""]) +def test_safe_download_headers_sanitize_injection(hostile): + disposition = safe_download_headers(hostile)["Content-Disposition"] + assert "\r" not in disposition and "\n" not in disposition + assert disposition.count('"') == 2 diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index 27cf9067914..77dca9322c6 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -1,7 +1,6 @@ import json import logging import os -import sys from typing import Final from unittest.mock import AsyncMock, MagicMock, patch @@ -42,7 +41,6 @@ from litellm.utils import ( get_prompt_cache_min_tokens, is_cached_message, is_prompt_caching_valid_prompt, - prompt_token_calculator, ) # Adds the parent directory to the system path @@ -4979,19 +4977,3 @@ def test_completion_does_not_leak_rust_flag_into_provider_request_body(): create_kwargs = mock_client.chat.completions.with_raw_response.create.call_args.kwargs assert "rust" not in create_kwargs assert "rust" not in (create_kwargs.get("extra_body") or {}) - - -def test_prompt_token_calculator_counts_claude_without_the_anthropic_sdk(): - """ - The claude branch used to call the anthropic SDK's `count_tokens`, which the SDK - removed, so every claude call raised AttributeError. Counting must work with - `anthropic` unimportable. - """ - messages: Final = [{"role": "user", "content": "the quick brown fox jumps over the lazy dog"}] - - with patch.dict(sys.modules, {"anthropic": None}): - claude_tokens = prompt_token_calculator("claude-sonnet-4-5", messages) - gpt_tokens = prompt_token_calculator("gpt-4o", messages) - - assert claude_tokens == 9 - assert gpt_tokens == 9 diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index b7c578d8ec6..03de475c8c3 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -2218,11 +2218,6 @@ "count": 1 } }, - "src/components/ui/meter.tsx": { - "local/filename-pascal-case": { - "count": 1 - } - }, "src/components/ui/popover.tsx": { "local/filename-pascal-case": { "count": 1 @@ -2253,11 +2248,6 @@ "count": 1 } }, - "src/components/ui/sidebar.tsx": { - "local/filename-pascal-case": { - "count": 1 - } - }, "src/components/ui/skeleton.tsx": { "local/filename-pascal-case": { "count": 1 diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json index 564f32e2573..d2a64b93384 100644 --- a/ui/litellm-dashboard/package-lock.json +++ b/ui/litellm-dashboard/package-lock.json @@ -17,7 +17,8 @@ "@tanstack/react-query": "5.100.7", "@tanstack/react-table": "8.21.3", "@types/papaparse": "5.5.2", - "cva": "1.0.0-beta.4", + "class-variance-authority": "0.7.1", + "clsx": "^2.1.1", "date-fns": "^4.4.0", "dayjs": "1.11.19", "jwt-decode": "4.0.0", @@ -5161,6 +5162,18 @@ "node": ">= 16" } }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -5299,26 +5312,6 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, - "node_modules/cva": { - "version": "1.0.0-beta.4", - "resolved": "https://registry.npmjs.org/cva/-/cva-1.0.0-beta.4.tgz", - "integrity": "sha512-F/JS9hScapq4DBVQXcK85l9U91M6ePeXoBMSp7vypzShoefUBxjQTo3g3935PUHgQd+IW77DjbPRIxugy4/GCQ==", - "license": "Apache-2.0", - "dependencies": { - "clsx": "^2.1.1" - }, - "funding": { - "url": "https://polar.sh/cva" - }, - "peerDependencies": { - "typescript": ">= 4.5.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/d3-array": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", @@ -12175,7 +12168,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json index ff6448ad75c..ededdfb4606 100644 --- a/ui/litellm-dashboard/package.json +++ b/ui/litellm-dashboard/package.json @@ -33,7 +33,8 @@ "@tanstack/react-query": "5.100.7", "@tanstack/react-table": "8.21.3", "@types/papaparse": "5.5.2", - "cva": "1.0.0-beta.4", + "class-variance-authority": "0.7.1", + "clsx": "^2.1.1", "date-fns": "^4.4.0", "dayjs": "1.11.19", "jwt-decode": "4.0.0", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx index b5c04029d69..9e8ea64fc45 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx @@ -5,6 +5,7 @@ import { Logo } from "@/components/molecules/logo/Logo"; import { Bot, Check, CircleCheck, Key, LayoutGrid } from "lucide-react"; import CreatedKeyDisplay from "@/components/shared/CreatedKeyDisplay"; import { Badge } from "@/components/ui/badge"; +import { StatusBadge } from "@/components/shared/table_cells/status_badge"; import { Button } from "@/components/ui/button"; import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { Input } from "@/components/ui/input"; @@ -764,9 +765,7 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok Custom / Other - - GENERIC - + For agents that don't follow a standard protocol, just needs a virtual key @@ -935,7 +934,7 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok )} - Recommended + diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/UserEnvVarsModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/UserEnvVarsModal.tsx index ea2addcd5f1..66b33ff2db8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/UserEnvVarsModal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/UserEnvVarsModal.tsx @@ -10,6 +10,7 @@ import { FormField } from "@/components/shared/form/FormField"; import { Alert, AlertTitle } from "@/components/shared/Alert"; import { PasswordInput } from "@/components/shared/PasswordInput"; import { Badge } from "@/components/ui/badge"; +import { StatusBadge } from "@/components/shared/table_cells/status_badge"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; @@ -133,7 +134,7 @@ const UserEnvVarsModal: React.FC = ({ server, open, acces
Set your credentials - Per-user +
{displayName}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/old-usage/_components/usage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/old-usage/_components/usage.tsx index aca1db0fbae..42be1b34f07 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/old-usage/_components/usage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/old-usage/_components/usage.tsx @@ -17,7 +17,7 @@ import { ComboboxValue, useComboboxAnchor, } from "@/components/ui/combobox"; -import { Meter, MeterIndicator, MeterTrack } from "@/components/ui/meter"; +import { Meter, MeterIndicator, MeterTrack } from "@/components/shared/Meter"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatComposer.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatComposer.tsx index 6ae7b855f1e..4925e775ac7 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatComposer.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatComposer.tsx @@ -94,7 +94,16 @@ export function ChatComposer({ /> )} - + { + if ((event.target as HTMLElement).closest("button")) { + return; + } + event.currentTarget.parentElement?.querySelector("[data-slot=input-group-control]")?.focus(); + }} + >
{tools}
{isLoading && onCancel ? ( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_policy_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_policy_form.tsx index 4214db277a3..403562bc7a3 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_policy_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_policy_form.tsx @@ -10,6 +10,7 @@ import { SearchSelect } from "@/components/shared/SearchSelect"; import { FieldGroup } from "@/components/shared/form/field"; import { FormField } from "@/components/shared/form/FormField"; import { Badge } from "@/components/ui/badge"; +import { StatusBadge } from "@/components/shared/table_cells/status_badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; @@ -466,9 +467,7 @@ const AddPolicyForm: React.FC = ({
{resolvedGuardrails.map((g) => ( - - {g} - + ))}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx index d7a4fce4beb..f94240f3c9e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx @@ -9,7 +9,7 @@ import { StatusBadge } from "@/components/shared/table_cells/status_badge"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Meter, MeterIndicator, MeterTrack } from "@/components/ui/meter"; +import { Meter, MeterIndicator, MeterTrack } from "@/components/shared/Meter"; import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { EditProjectModal } from "./ProjectModals/EditProjectModal"; import { ProjectKeysSection } from "./ProjectKeysSection"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageTable.tsx index 384590f754d..3d19d825c0c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageTable.tsx @@ -1,6 +1,6 @@ import React from "react"; import type { ColumnDef } from "@tanstack/react-table"; -import { Meter, MeterIndicator, MeterTrack } from "@/components/ui/meter"; +import { Meter, MeterIndicator, MeterTrack } from "@/components/shared/Meter"; import { DataTable } from "@/components/shared/DataTable"; import { MoneyCell } from "@/components/shared/table_cells"; import { MetricWithMetadata } from "@/components/UsagePage/types"; diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/RoleMappings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/RoleMappings.tsx index 2ea78f4b12b..966a2a9904d 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/RoleMappings.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/RoleMappings.tsx @@ -1,7 +1,7 @@ import type { RoleMappings as RoleMappingsType } from "@/app/(dashboard)/hooks/sso/useSSOSettings"; import type { ColumnDef } from "@tanstack/react-table"; import { DataTable } from "@/components/shared/DataTable"; -import { Badge } from "@/components/ui/badge"; +import { StatusBadge } from "@/components/shared/table_cells/status_badge"; import { Card, CardContent } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; import { Users } from "lucide-react"; @@ -34,9 +34,7 @@ export default function RoleMappings({ roleMappings }: { roleMappings: RoleMappi row.original.groups.length > 0 ? (
{row.original.groups.map((group, index) => ( - - {group} - + ))}
) : ( diff --git a/ui/litellm-dashboard/src/components/SidebarUsageCard.tsx b/ui/litellm-dashboard/src/components/SidebarUsageCard.tsx index 76240565cda..effb6c29e8e 100644 --- a/ui/litellm-dashboard/src/components/SidebarUsageCard.tsx +++ b/ui/litellm-dashboard/src/components/SidebarUsageCard.tsx @@ -2,7 +2,7 @@ import { useLicenseInfo } from "@/app/(dashboard)/hooks/license/useLicenseInfo"; import { formatExpirationStatus } from "@/utils/licenseUtils"; import { Button } from "@/components/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; -import { Meter, MeterIndicator, MeterLabel, MeterTrack } from "@/components/ui/meter"; +import { Meter, MeterIndicator, MeterLabel, MeterTrack } from "@/components/shared/Meter"; import { useQuery } from "@tanstack/react-query"; import { Award, ChevronDown, Loader2 } from "lucide-react"; import { getRemainingUsers } from "./networking"; diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index 7fef1e62a6a..b58d7bd0d02 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -20,7 +20,7 @@ import { SidebarMenuSub, SidebarSeparator, sidebarMenuButtonVariants, -} from "@/components/ui/sidebar"; +} from "@/components/shared/Sidebar"; import { Activity, BarChart3, diff --git a/ui/litellm-dashboard/src/components/shared/Alert.tsx b/ui/litellm-dashboard/src/components/shared/Alert.tsx index e69a79d4629..490fcba6ef5 100644 --- a/ui/litellm-dashboard/src/components/shared/Alert.tsx +++ b/ui/litellm-dashboard/src/components/shared/Alert.tsx @@ -1,25 +1,29 @@ import * as React from "react"; -import { type VariantProps } from "cva"; -import { cn, cva } from "@/lib/cva.config"; +import { cva, type VariantProps } from "class-variance-authority"; -const alertVariants = cva({ - base: "group/alert relative grid w-full gap-0.5 rounded-lg border px-4 py-3 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2.5 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4", - variants: { - variant: { - default: "bg-card text-card-foreground", - destructive: "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current", - info: "border-info/20 bg-info/5 text-info *:[svg]:text-current", - success: "border-success/20 bg-success/5 text-success *:[svg]:text-current", - warning: "border-warning/20 bg-warning/5 text-warning *:[svg]:text-current", - error: - "border-destructive/20 bg-destructive/10 text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-destructive", +import { cn } from "@/lib/cva.config"; + +const alertVariants = cva( + "group/alert relative grid w-full gap-0.5 rounded-lg border px-4 py-3 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2.5 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current", + info: "border-info/20 bg-info/5 text-info *:[svg]:text-current", + success: "border-success/20 bg-success/5 text-success *:[svg]:text-current", + warning: "border-warning/20 bg-warning/5 text-warning *:[svg]:text-current", + error: + "border-destructive/20 bg-destructive/10 text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", }, }, - defaultVariants: { - variant: "default", - }, -}); +); type AlertProps = React.ComponentProps<"div"> & VariantProps; diff --git a/ui/litellm-dashboard/src/components/ui/meter.test.tsx b/ui/litellm-dashboard/src/components/shared/Meter.test.tsx similarity index 99% rename from ui/litellm-dashboard/src/components/ui/meter.test.tsx rename to ui/litellm-dashboard/src/components/shared/Meter.test.tsx index 70aedefee40..1fbb785e55d 100644 --- a/ui/litellm-dashboard/src/components/ui/meter.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/Meter.test.tsx @@ -1,6 +1,6 @@ import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; -import { Meter, MeterIndicator, MeterLabel, MeterTrack } from "./meter"; +import { Meter, MeterIndicator, MeterLabel, MeterTrack } from "./Meter"; const renderMeter = (value: number, max: number) => render( diff --git a/ui/litellm-dashboard/src/components/ui/meter.tsx b/ui/litellm-dashboard/src/components/shared/Meter.tsx similarity index 91% rename from ui/litellm-dashboard/src/components/ui/meter.tsx rename to ui/litellm-dashboard/src/components/shared/Meter.tsx index ac18829efa9..26c60f3361f 100644 --- a/ui/litellm-dashboard/src/components/ui/meter.tsx +++ b/ui/litellm-dashboard/src/components/shared/Meter.tsx @@ -1,13 +1,13 @@ "use client"; import { Meter as MeterPrimitive } from "@base-ui/react/meter"; -import { type VariantProps } from "cva"; import * as React from "react"; -import { cn, cva } from "@/lib/cva.config"; +import { cva, type VariantProps } from "class-variance-authority"; -const meterIndicatorVariants = cva({ - base: "h-full rounded-full transition-[width] duration-300", +import { cn } from "@/lib/cva.config"; + +const meterIndicatorVariants = cva("h-full rounded-full transition-[width] duration-300", { variants: { tone: { default: "bg-primary", diff --git a/ui/litellm-dashboard/src/components/shared/Sidebar.test.tsx b/ui/litellm-dashboard/src/components/shared/Sidebar.test.tsx new file mode 100644 index 00000000000..8c0d2790489 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/Sidebar.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { SidebarMenuButton, sidebarMenuButtonVariants } from "./Sidebar"; + +const CVA_CONFIG_KEYS = ["base", "variants", "defaultVariants"]; + +describe("sidebarMenuButtonVariants", () => { + it("emits its base classes rather than the names of its own config keys", () => { + const emitted = sidebarMenuButtonVariants({}).split(" "); + + expect(emitted).toContain("rounded-md"); + expect(emitted).toContain("text-sidebar-foreground/70"); + expect(CVA_CONFIG_KEYS.filter((key) => emitted.includes(key))).toEqual([]); + }); + + it("applies the isActive variant on top of the base classes", () => { + const active = sidebarMenuButtonVariants({ isActive: true }).split(" "); + + expect(active).toContain("bg-sidebar-accent"); + expect(active).toContain("rounded-md"); + expect(sidebarMenuButtonVariants({ isActive: false }).split(" ")).not.toContain("bg-sidebar-accent"); + }); +}); + +describe("SidebarMenuButton", () => { + it("renders the variant classes onto the button", () => { + render(Keys); + const button = screen.getByRole("button", { name: "Keys" }); + + expect(button).toHaveClass("bg-sidebar-accent", "rounded-md"); + for (const key of CVA_CONFIG_KEYS) { + expect(button).not.toHaveClass(key); + } + }); +}); diff --git a/ui/litellm-dashboard/src/components/ui/sidebar.tsx b/ui/litellm-dashboard/src/components/shared/Sidebar.tsx similarity index 91% rename from ui/litellm-dashboard/src/components/ui/sidebar.tsx rename to ui/litellm-dashboard/src/components/shared/Sidebar.tsx index a0c94ab6fee..06e19d1b674 100644 --- a/ui/litellm-dashboard/src/components/ui/sidebar.tsx +++ b/ui/litellm-dashboard/src/components/shared/Sidebar.tsx @@ -2,9 +2,10 @@ import * as React from "react"; import { Button as ButtonPrimitive } from "@base-ui/react/button"; -import { type VariantProps } from "cva"; -import { cn, cva } from "@/lib/cva.config"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/cva.config"; type SidebarContextValue = { collapsed: boolean }; const SidebarContext = React.createContext({ collapsed: false }); @@ -136,8 +137,8 @@ const SidebarMenuBadge = React.forwardRefsvg]:size-[18px] [&>svg]:shrink-0", "group-data-[collapsed=true]/sidebar:mx-auto group-data-[collapsed=true]/sidebar:size-9 group-data-[collapsed=true]/sidebar:justify-center group-data-[collapsed=true]/sidebar:gap-0 group-data-[collapsed=true]/sidebar:px-0", - ].join(" "), - variants: { - isActive: { - true: "bg-sidebar-accent text-sidebar-accent-foreground before:absolute before:inset-y-1.5 before:left-0 before:w-[3px] before:rounded-r-full before:bg-sidebar-primary group-data-[collapsed=true]/sidebar:before:hidden", - false: "", - }, - size: { - default: "h-[34px]", - sub: "h-[34px]", + ], + { + variants: { + isActive: { + true: "bg-sidebar-accent text-sidebar-accent-foreground before:absolute before:inset-y-1.5 before:left-0 before:w-[3px] before:rounded-r-full before:bg-sidebar-primary group-data-[collapsed=true]/sidebar:before:hidden", + false: "", + }, + size: { + default: "h-[34px]", + sub: "h-[34px]", + }, }, + defaultVariants: { isActive: false, size: "default" }, }, - defaultVariants: { isActive: false, size: "default" }, -}); +); type SidebarMenuButtonProps = ButtonPrimitive.Props & VariantProps; diff --git a/ui/litellm-dashboard/src/components/shared/form/field.tsx b/ui/litellm-dashboard/src/components/shared/form/field.tsx index 36ce691827c..a5bf896313a 100644 --- a/ui/litellm-dashboard/src/components/shared/form/field.tsx +++ b/ui/litellm-dashboard/src/components/shared/form/field.tsx @@ -1,11 +1,12 @@ "use client"; import * as React from "react"; -import { type VariantProps } from "cva"; import { Label } from "@/components/ui/label"; import { Separator } from "@/components/ui/separator"; -import { cn, cva } from "@/lib/cva.config"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/cva.config"; const FieldSet = React.forwardRef>( ({ className, ...props }, ref) => ( @@ -51,8 +52,7 @@ const FieldGroup = React.forwardRef.sr-only]:w-auto", diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/spend_budget_cell.tsx b/ui/litellm-dashboard/src/components/shared/table_cells/spend_budget_cell.tsx index 943b9aa766c..127c7202121 100644 --- a/ui/litellm-dashboard/src/components/shared/table_cells/spend_budget_cell.tsx +++ b/ui/litellm-dashboard/src/components/shared/table_cells/spend_budget_cell.tsx @@ -1,7 +1,7 @@ "use client"; import { InheritedBudgetHint, type InheritedBudgetGate } from "@/components/shared/InheritedBudgetHint"; -import { Meter, MeterIndicator, MeterTrack } from "@/components/ui/meter"; +import { Meter, MeterIndicator, MeterTrack } from "@/components/shared/Meter"; import { formatNumberWithCommas, getSpendString } from "@/utils/dataUtils"; interface SpendBudgetCellProps { diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/status_badge.tsx b/ui/litellm-dashboard/src/components/shared/table_cells/status_badge.tsx index dbd4d511d89..cc7f32d0bd6 100644 --- a/ui/litellm-dashboard/src/components/shared/table_cells/status_badge.tsx +++ b/ui/litellm-dashboard/src/components/shared/table_cells/status_badge.tsx @@ -22,11 +22,16 @@ interface StatusBadgeProps { label: string; tooltip?: React.ReactNode; dataTestId?: string; + className?: string; } -export function StatusBadge({ tone, label, tooltip, dataTestId }: StatusBadgeProps) { +export function StatusBadge({ tone, label, tooltip, dataTestId, className }: StatusBadgeProps) { const badge = ( - + {label} ); diff --git a/ui/litellm-dashboard/src/components/ui/badge.tsx b/ui/litellm-dashboard/src/components/ui/badge.tsx index ec5417d227a..0117fd4252e 100644 --- a/ui/litellm-dashboard/src/components/ui/badge.tsx +++ b/ui/litellm-dashboard/src/components/ui/badge.tsx @@ -1,43 +1,49 @@ -import * as React from "react"; import { mergeProps } from "@base-ui/react/merge-props"; import { useRender } from "@base-ui/react/use-render"; -import { type VariantProps } from "cva"; +import { cva, type VariantProps } from "class-variance-authority"; -import { cn, cva } from "@/lib/cva.config"; +import { cn } from "@/lib/cva.config"; -const badgeVariants = cva({ - base: "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", - variants: { - variant: { - default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", - secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", - destructive: - "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", - success: "bg-success/10 text-success dark:bg-success/20 [a]:hover:bg-success/20", - warning: "bg-warning/10 text-warning dark:bg-warning/20 [a]:hover:bg-warning/20", - info: "bg-info/10 text-info dark:bg-info/20 [a]:hover:bg-info/20", - outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", - ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", - link: "text-primary underline-offset-4 hover:underline", +const badgeVariants = cva( + "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", + destructive: + "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", + outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", + ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", + link: "text-primary underline-offset-4 hover:underline", + }, + }, + defaultVariants: { + variant: "default", }, }, - defaultVariants: { - variant: "default", - }, -}); - -type BadgeProps = useRender.ComponentProps<"span"> & VariantProps; - -const Badge = React.forwardRef( - ({ className, variant = "default", render, ...props }, ref) => - useRender({ - defaultTagName: "span", - ref, - props: mergeProps<"span">({ className: cn(badgeVariants({ variant }), className) }, props), - render, - state: { slot: "badge", variant }, - }), ); -Badge.displayName = "Badge"; + +function Badge({ + className, + variant = "default", + render, + ...props +}: useRender.ComponentProps<"span"> & VariantProps) { + return useRender({ + defaultTagName: "span", + props: mergeProps<"span">( + { + className: cn(badgeVariants({ variant }), className), + }, + props, + ), + render, + state: { + slot: "badge", + variant, + }, + }); +} export { Badge, badgeVariants }; diff --git a/ui/litellm-dashboard/src/components/ui/button-group.tsx b/ui/litellm-dashboard/src/components/ui/button-group.tsx index bfbd6577044..4ad0b493e65 100644 --- a/ui/litellm-dashboard/src/components/ui/button-group.tsx +++ b/ui/litellm-dashboard/src/components/ui/button-group.tsx @@ -1,24 +1,26 @@ import { mergeProps } from "@base-ui/react/merge-props"; import { useRender } from "@base-ui/react/use-render"; -import { type VariantProps } from "cva"; +import { cva, type VariantProps } from "class-variance-authority"; -import { cn, cva } from "@/lib/cva.config"; +import { cn } from "@/lib/cva.config"; import { Separator } from "@/components/ui/separator"; -const buttonGroupVariants = cva({ - base: "flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1", - variants: { - orientation: { - horizontal: - "*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-md! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0", - vertical: - "flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-md! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0", +const buttonGroupVariants = cva( + "flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1", + { + variants: { + orientation: { + horizontal: + "*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-md! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0", + vertical: + "flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-md! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0", + }, + }, + defaultVariants: { + orientation: "horizontal", }, }, - defaultVariants: { - orientation: "horizontal", - }, -}); +); function ButtonGroup({ className, diff --git a/ui/litellm-dashboard/src/components/ui/button.tsx b/ui/litellm-dashboard/src/components/ui/button.tsx index b60160567b6..4fb8eb0d27b 100644 --- a/ui/litellm-dashboard/src/components/ui/button.tsx +++ b/ui/litellm-dashboard/src/components/ui/button.tsx @@ -1,57 +1,51 @@ -import * as React from "react"; import { Button as ButtonPrimitive } from "@base-ui/react/button"; -import { type VariantProps } from "cva"; +import { cva, type VariantProps } from "class-variance-authority"; -import { cn, cva } from "@/lib/cva.config"; +import { cn } from "@/lib/cva.config"; -const buttonVariants = cva({ - base: "group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", - variants: { - variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/80", - outline: - "border-border bg-background shadow-xs hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", - secondary: - "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", - ghost: - "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", - destructive: - "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", - link: "text-primary underline-offset-4 hover:underline", +const buttonVariants = cva( + "group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/80", + outline: + "border-border bg-background shadow-xs hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", + ghost: + "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", + destructive: + "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: + "h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5", + lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + icon: "size-9", + "icon-xs": + "size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3", + "icon-sm": "size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md", + "icon-lg": "size-10", + }, }, - size: { - default: - "h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", - xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", - sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5", - lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", - icon: "size-9", - "icon-xs": - "size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3", - "icon-sm": "size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md", - "icon-lg": "size-10", + defaultVariants: { + variant: "default", + size: "default", }, }, - defaultVariants: { - variant: "default", - size: "default", - }, -}); - -type ButtonProps = ButtonPrimitive.Props & VariantProps; - -const Button = React.forwardRef( - ({ className, variant = "default", size = "default", ...props }, ref) => { - return ( - - ); - }, ); -Button.displayName = "Button"; + +function Button({ + className, + variant = "default", + size = "default", + ...props +}: ButtonPrimitive.Props & VariantProps) { + return ; +} export { Button, buttonVariants }; diff --git a/ui/litellm-dashboard/src/components/ui/input-group.tsx b/ui/litellm-dashboard/src/components/ui/input-group.tsx index f1a5c356f9a..78eb24f97ad 100644 --- a/ui/litellm-dashboard/src/components/ui/input-group.tsx +++ b/ui/litellm-dashboard/src/components/ui/input-group.tsx @@ -1,9 +1,9 @@ "use client"; import * as React from "react"; -import { type VariantProps } from "cva"; +import { cva, type VariantProps } from "class-variance-authority"; -import { cn, cva } from "@/lib/cva.config"; +import { cn } from "@/lib/cva.config"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; @@ -22,21 +22,23 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) { ); } -const inputGroupAddonVariants = cva({ - base: "flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4", - variants: { - align: { - "inline-start": "order-first pl-2 has-[>button]:-ml-1 has-[>kbd]:ml-[-0.15rem]", - "inline-end": "order-last pr-2 has-[>button]:-mr-1 has-[>kbd]:mr-[-0.15rem]", - "block-start": - "order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2", - "block-end": "order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2", +const inputGroupAddonVariants = cva( + "flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4", + { + variants: { + align: { + "inline-start": "order-first pl-2 has-[>button]:-ml-1 has-[>kbd]:ml-[-0.15rem]", + "inline-end": "order-last pr-2 has-[>button]:-mr-1 has-[>kbd]:mr-[-0.15rem]", + "block-start": + "order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2", + "block-end": "order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2", + }, + }, + defaultVariants: { + align: "inline-start", }, }, - defaultVariants: { - align: "inline-start", - }, -}); +); function InputGroupAddon({ className, @@ -53,15 +55,14 @@ function InputGroupAddon({ if ((e.target as HTMLElement).closest("button")) { return; } - e.currentTarget.parentElement?.querySelector("[data-slot=input-group-control]")?.focus(); + e.currentTarget.parentElement?.querySelector("input")?.focus(); }} {...props} /> ); } -const inputGroupButtonVariants = cva({ - base: "flex items-center gap-2 text-sm shadow-none", +const inputGroupButtonVariants = cva("flex items-center gap-2 text-sm shadow-none", { variants: { size: { xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5", @@ -75,16 +76,18 @@ const inputGroupButtonVariants = cva({ }, }); -const InputGroupButton = React.forwardRef< - React.ComponentRef, - Omit, "size" | "type"> & - VariantProps & { - type?: "button" | "submit" | "reset"; - } ->(({ className, type = "button", variant = "ghost", size = "xs", ...props }, ref) => { +function InputGroupButton({ + className, + type = "button", + variant = "ghost", + size = "xs", + ...props +}: Omit, "size" | "type"> & + VariantProps & { + type?: "button" | "submit" | "reset"; + }) { return (