From b9e5eec28c233f5a8b8ddf869488021857989bde Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:59:13 -0700 Subject: [PATCH 1/6] test(e2e): pin require_managed_files enforcement behind a marker-gated stack phase --- tests/e2e/batches/COVERAGE.md | 15 +++ tests/e2e/batches/conftest.py | 18 +++ .../test_managed_files_enforcement_e2e.py | 118 ++++++++++++++++++ tests/e2e/conftest.py | 4 + .../llm_nonconversational.yaml | 2 + tests/e2e/e2e_config.py | 1 + tests/e2e/pytest.ini | 1 + 7 files changed, 159 insertions(+) create mode 100644 tests/e2e/batches/test_managed_files_enforcement_e2e.py 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 From ac29505f3d739e6f65af901bc8c05b4f0280d310 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:41:15 -0700 Subject: [PATCH 2/6] feat(proxy): enforce vector-store upload security controls on /v1/rag/ingest Uploaded files reaching the RAG ingest path were trusted by client filename and content-type, so archives and executable scripts were ingested and malicious content was never screened. Enforce controls at the upload boundary before the file leaves the proxy: - classify content by magic bytes and a strict UTF-8 decode, never by the client filename or content-type - allowlist PDF and UTF-8 text; reject archives and executables/scripts - cap upload size (512MB) via a bounded read - run every accepted upload through a dependency-injected malware scanner, failing closed on scan error; the default scanner flags the EICAR test file so the hook is validated end to end - give accepted uploads a server-generated filename so the client name never reaches storage - set Content-Disposition attachment and X-Content-Type-Options nosniff on vector-store file downloads --- litellm/proxy/rag_endpoints/endpoints.py | 38 ++- .../proxy/rag_endpoints/upload_security.py | 278 ++++++++++++++++++ .../vector_store_files_endpoints/endpoints.py | 4 + .../proxy/rag_endpoints/test_rag_endpoints.py | 89 ++++++ .../rag_endpoints/test_upload_security.py | 176 +++++++++++ 5 files changed, 582 insertions(+), 3 deletions(-) create mode 100644 litellm/proxy/rag_endpoints/upload_security.py create mode 100644 tests/test_litellm/proxy/rag_endpoints/test_upload_security.py 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..e8e347a6eb8 --- /dev/null +++ b/litellm/proxy/rag_endpoints/upload_security.py @@ -0,0 +1,278 @@ +"""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"BZh", + 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", +) + +_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", + b"dex\n", +) + +_TAR_USTAR_MAGIC: Final = b"ustar" +_TAR_USTAR_OFFSET: Final = 257 + + +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) + return len(content) >= tar_magic_end and content[_TAR_USTAR_OFFSET:tar_magic_end] == _TAR_USTAR_MAGIC + + +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 content.startswith(b"MZ") and not _is_utf8_text(content) + + +def inspect_content(content: bytes) -> ContentInspection: + if content.startswith(b"#!"): + 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/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 From 6afe0d73c8f44447e0ad0e45ef334360f53d0aa2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 20:17:36 +0000 Subject: [PATCH 3/6] fix(rag): tighten shebang and ASCII-magic content classification Reject shebangs even when preceded by a UTF-8 BOM or leading whitespace, and stop misclassifying UTF-8 text that happens to start with the ASCII-printable magics BZh (bzip2) or dex\\n (Android DEX) as archives or executables by applying the same UTF-8 carve-out already used for MZ --- .../proxy/rag_endpoints/upload_security.py | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/litellm/proxy/rag_endpoints/upload_security.py b/litellm/proxy/rag_endpoints/upload_security.py index e8e347a6eb8..f0318f2f709 100644 --- a/litellm/proxy/rag_endpoints/upload_security.py +++ b/litellm/proxy/rag_endpoints/upload_security.py @@ -28,7 +28,6 @@ _ARCHIVE_MAGIC_PREFIXES: Final[tuple[bytes, ...]] = ( b"PK\x05\x06", b"PK\x07\x08", b"\x1f\x8b", - b"BZh", b"\xfd7zXZ\x00", b"7z\xbc\xaf\x27\x1c", b"Rar!\x1a\x07\x00", @@ -37,6 +36,8 @@ _ARCHIVE_MAGIC_PREFIXES: Final[tuple[bytes, ...]] = ( 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", @@ -45,12 +46,15 @@ _EXECUTABLE_MAGIC_PREFIXES: Final[tuple[bytes, ...]] = ( b"\xce\xfa\xed\xfe", b"\xcf\xfa\xed\xfe", b"\x00asm", - b"dex\n", ) +_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" @@ -158,7 +162,9 @@ 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) - return len(content) >= tar_magic_end and content[_TAR_USTAR_OFFSET:tar_magic_end] == _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: @@ -174,11 +180,16 @@ def _is_utf8_text(content: bytes) -> bool: def _is_executable_binary(content: bytes) -> bool: if _starts_with_any(content, _EXECUTABLE_MAGIC_PREFIXES): return True - return content.startswith(b"MZ") and not _is_utf8_text(content) + 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 content.startswith(b"#!"): + if _looks_like_shebang(content): return DisallowedContent(DisallowedKind.EXECUTABLE) if content.startswith(b"%PDF-"): return AllowedContent(DetectedFormat.PDF) From 12a34a10d8cddfe5d8812ef7eba74911760f940d Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:46:09 -0700 Subject: [PATCH 4/6] fix(logging_worker): carry queued tasks across event-loop change instead of dropping them LoggingWorker._ensure_queue nulled self._queue on a loop change, discarding every pending LoggingTask (each an un-awaited spend-logging coroutine) with no counter and only a debug log. SDK callers using asyncio.run() per request and mixed sync/async processes rebind the queue's loop and silently lose spend rows and observability events. Drain the stale queue and move the pending tasks onto a fresh queue bound to the new loop, warn with the carried-over count, and keep flush()/join() honest since the queue is no longer thrown away. Adds a regression test that fills the queue before the loop change and asserts every task survives and still executes. --- litellm/litellm_core_utils/logging_worker.py | 36 ++++++++++++++--- .../litellm_core_utils/test_logging_worker.py | 39 +++++++++++++++++++ 2 files changed, 70 insertions(+), 5 deletions(-) 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/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] From 47c988e05c4e5a54cef4abd97cae0a888754c341 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Mon, 24 Aug 2026 14:10:31 -0700 Subject: [PATCH 5/6] refactor(ui): move the dashboard onto class-variance-authority (#38125) The dashboard used `cva@1.0.0-beta.4` with the object-argument API behind `@/lib/cva.config`, while shadcn emits `class-variance-authority` with the positional API. Every `shadcn add` of a cva-based primitive therefore needed a hand fix-up before it compiled, which meant `components/ui/` could never match a fresh CLI run and `shadcn add --diff` reported the whole file as changed instead of showing real upstream drift. Swap the dependency, and regenerate `badge`, `button`, `button-group`, `input-group` and `tabs` straight from the base-vega registry so they are now byte-identical to the CLI output plus prettier. Two primitives could not be regenerated because they are local code rather than registry items, so they move out of `components/ui/`: `sidebar` (203 lines against upstream's 730, and only `leftnav` consumes it) and `meter` (no registry entry at all, it wraps Base UI's Meter). The customisations that were baked into the regenerated files move to wrappers, following the rule that `components/ui/` holds CLI output and anything on top of it lives outside: - badge carried info, success and warning variants that duplicated the existing `StatusBadge` tone map, so its five call sites now use `StatusBadge`, which gains an optional `className` - input-group's addon focuses `[data-slot=input-group-control]` rather than upstream's `input`, which matters because the chat composer puts a textarea there. That handler now sits at the one call site that needs it `cx` keeps its previous twMerge behaviour. It came from the old `defineConfig({hooks: {onComplete: twMerge}})`, and CVA's own `cx` is plain clsx, so pointing it at `cn` avoids silently dropping conflict resolution in the six files that use it. `Sidebar.test.tsx` covers the failure mode this migration can hide: passing the object form to the positional API is accepted by clsx and renders the literal class string "base variants defaultVariants", so the component loses every style while the type checker and the existing suite stay green. --- ui/litellm-dashboard/eslint-suppressions.json | 10 -- ui/litellm-dashboard/package-lock.json | 37 +++--- ui/litellm-dashboard/package.json | 3 +- .../agents/_components/add_agent_form.tsx | 7 +- .../_components/UserEnvVarsModal.tsx | 3 +- .../old-usage/_components/usage.tsx | 2 +- .../components/chat_ui/ChatComposer.tsx | 11 +- .../policies/_components/add_policy_form.tsx | 5 +- .../_components/ProjectDetailsPage.tsx | 2 +- .../components/EndpointUsageTable.tsx | 2 +- .../SSOSettings/RoleMappings.tsx | 6 +- .../src/components/SidebarUsageCard.tsx | 2 +- .../src/components/leftnav.tsx | 2 +- .../src/components/shared/Alert.tsx | 38 +++--- .../meter.test.tsx => shared/Meter.test.tsx} | 2 +- .../{ui/meter.tsx => shared/Meter.tsx} | 8 +- .../src/components/shared/Sidebar.test.tsx | 36 ++++++ .../{ui/sidebar.tsx => shared/Sidebar.tsx} | 33 ++--- .../src/components/shared/form/field.tsx | 8 +- .../shared/table_cells/spend_budget_cell.tsx | 2 +- .../shared/table_cells/status_badge.tsx | 9 +- .../src/components/ui/badge.tsx | 74 ++++++------ .../src/components/ui/button-group.tsx | 30 ++--- .../src/components/ui/button.tsx | 90 +++++++------- .../src/components/ui/input-group.tsx | 114 +++++++++--------- .../src/components/ui/tabs.tsx | 26 ++-- ui/litellm-dashboard/src/lib/cva.config.ts | 12 +- 27 files changed, 304 insertions(+), 270 deletions(-) rename ui/litellm-dashboard/src/components/{ui/meter.test.tsx => shared/Meter.test.tsx} (99%) rename ui/litellm-dashboard/src/components/{ui/meter.tsx => shared/Meter.tsx} (91%) create mode 100644 ui/litellm-dashboard/src/components/shared/Sidebar.test.tsx rename ui/litellm-dashboard/src/components/{ui/sidebar.tsx => shared/Sidebar.tsx} (91%) 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 (