mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-09 22:31:41 +00:00
merge(litellm_internal_staging): bring hosted_vllm videos branch up to date
This commit is contained in:
commit
fe03df4551
44 changed files with 1128 additions and 305 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
289
litellm/proxy/rag_endpoints/upload_security.py
Normal file
289
litellm/proxy/rag_endpoints/upload_security.py
Normal file
|
|
@ -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",
|
||||
}
|
||||
)
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
118
tests/e2e/batches/test_managed_files_enforcement_e2e.py
Normal file
118
tests/e2e/batches/test_managed_files_enforcement_e2e.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
176
tests/test_litellm/proxy/rag_endpoints/test_upload_security.py
Normal file
176
tests/test_litellm/proxy/rag_endpoints/test_upload_security.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
37
ui/litellm-dashboard/package-lock.json
generated
37
ui/litellm-dashboard/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
<span className="block">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="font-medium text-warning">Custom / Other</span>
|
||||
<Badge variant="warning" className="h-4 px-1 text-[10px]">
|
||||
GENERIC
|
||||
</Badge>
|
||||
<StatusBadge tone="warning" label="GENERIC" className="h-4 px-1 text-[10px]" />
|
||||
</span>
|
||||
<span className="block text-xs whitespace-normal text-warning">
|
||||
For agents that don't follow a standard protocol, just needs a virtual key
|
||||
|
|
@ -935,7 +934,7 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="success">Recommended</Badge>
|
||||
<StatusBadge tone="success" label="Recommended" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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<UserEnvVarsModalProps> = ({ server, open, acces
|
|||
<DialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<DialogTitle className="text-base font-semibold">Set your credentials</DialogTitle>
|
||||
<Badge variant="info">Per-user</Badge>
|
||||
<StatusBadge tone="info" label="Per-user" />
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{displayName}</span>
|
||||
</DialogHeader>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -94,7 +94,16 @@ export function ChatComposer({
|
|||
/>
|
||||
)}
|
||||
|
||||
<InputGroupAddon align="block-end" className="justify-between gap-2 px-3 pb-3 pt-1">
|
||||
<InputGroupAddon
|
||||
align="block-end"
|
||||
className="justify-between gap-2 px-3 pb-3 pt-1"
|
||||
onClick={(event) => {
|
||||
if ((event.target as HTMLElement).closest("button")) {
|
||||
return;
|
||||
}
|
||||
event.currentTarget.parentElement?.querySelector<HTMLElement>("[data-slot=input-group-control]")?.focus();
|
||||
}}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-1">{tools}</div>
|
||||
|
||||
{isLoading && onCancel ? (
|
||||
|
|
|
|||
|
|
@ -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<AddPolicyFormProps> = ({
|
|||
</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{resolvedGuardrails.map((g) => (
|
||||
<Badge key={g} variant="info">
|
||||
{g}
|
||||
</Badge>
|
||||
<StatusBadge key={g} tone="info" label={g} />
|
||||
))}
|
||||
</div>
|
||||
</AlertDescription>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{row.original.groups.map((group, index) => (
|
||||
<Badge key={index} variant="info">
|
||||
{group}
|
||||
</Badge>
|
||||
<StatusBadge key={index} tone="info" label={group} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import {
|
|||
SidebarMenuSub,
|
||||
SidebarSeparator,
|
||||
sidebarMenuButtonVariants,
|
||||
} from "@/components/ui/sidebar";
|
||||
} from "@/components/shared/Sidebar";
|
||||
import {
|
||||
Activity,
|
||||
BarChart3,
|
||||
|
|
|
|||
|
|
@ -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<typeof alertVariants>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
@ -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",
|
||||
36
ui/litellm-dashboard/src/components/shared/Sidebar.test.tsx
Normal file
36
ui/litellm-dashboard/src/components/shared/Sidebar.test.tsx
Normal file
|
|
@ -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(<SidebarMenuButton isActive>Keys</SidebarMenuButton>);
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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<SidebarContextValue>({ collapsed: false });
|
||||
|
|
@ -136,8 +137,8 @@ const SidebarMenuBadge = React.forwardRef<HTMLSpanElement, React.ComponentPropsW
|
|||
);
|
||||
SidebarMenuBadge.displayName = "SidebarMenuBadge";
|
||||
|
||||
const sidebarMenuButtonVariants = cva({
|
||||
base: [
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
[
|
||||
"group/menu-btn relative flex w-full items-center gap-2.5 overflow-hidden rounded-md px-2.5 text-left text-[13px] font-medium no-underline",
|
||||
"text-sidebar-foreground/70 outline-none transition-colors",
|
||||
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
|
|
@ -145,19 +146,21 @@ const sidebarMenuButtonVariants = cva({
|
|||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
"[&>svg]: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<typeof sidebarMenuButtonVariants>;
|
||||
|
||||
|
|
@ -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<HTMLFieldSetElement, React.ComponentPropsWithoutRef<"fieldset">>(
|
||||
({ className, ...props }, ref) => (
|
||||
|
|
@ -51,8 +52,7 @@ const FieldGroup = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutR
|
|||
);
|
||||
FieldGroup.displayName = "FieldGroup";
|
||||
|
||||
const fieldVariants = cva({
|
||||
base: "group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
|
||||
const fieldVariants = cva("group/field flex w-full gap-3 data-[invalid=true]:text-destructive", {
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
<Badge variant="outline" data-testid={dataTestId} className={cn("whitespace-nowrap font-normal", TONE_CLASS[tone])}>
|
||||
<Badge
|
||||
variant="outline"
|
||||
data-testid={dataTestId}
|
||||
className={cn("whitespace-nowrap font-normal", TONE_CLASS[tone], className)}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<typeof badgeVariants>;
|
||||
|
||||
const Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(
|
||||
({ 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<typeof badgeVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
props: mergeProps<"span">(
|
||||
{
|
||||
className: cn(badgeVariants({ variant }), className),
|
||||
},
|
||||
props,
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "badge",
|
||||
variant,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<typeof buttonVariants>;
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant = "default", size = "default", ...props }, ref) => {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
ref={ref}
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return <ButtonPrimitive data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />;
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
|
|
|
|||
|
|
@ -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<HTMLElement>("[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<typeof Button>,
|
||||
Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
|
||||
VariantProps<typeof inputGroupButtonVariants> & {
|
||||
type?: "button" | "submit" | "reset";
|
||||
}
|
||||
>(({ className, type = "button", variant = "ghost", size = "xs", ...props }, ref) => {
|
||||
function InputGroupButton({
|
||||
className,
|
||||
type = "button",
|
||||
variant = "ghost",
|
||||
size = "xs",
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
|
||||
VariantProps<typeof inputGroupButtonVariants> & {
|
||||
type?: "button" | "submit" | "reset";
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
type={type}
|
||||
data-size={size}
|
||||
variant={variant}
|
||||
|
|
@ -92,8 +95,7 @@ const InputGroupButton = React.forwardRef<
|
|||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
InputGroupButton.displayName = "InputGroupButton";
|
||||
}
|
||||
|
||||
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
|
|
@ -107,38 +109,30 @@ function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
|||
);
|
||||
}
|
||||
|
||||
const InputGroupInput = React.forwardRef<HTMLInputElement, React.ComponentPropsWithoutRef<"input">>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 aria-invalid:ring-0 dark:bg-transparent",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
InputGroupInput.displayName = "InputGroupInput";
|
||||
function InputGroupInput({ className, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 aria-invalid:ring-0 dark:bg-transparent",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const InputGroupTextarea = React.forwardRef<HTMLTextAreaElement, React.ComponentPropsWithoutRef<"textarea">>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<Textarea
|
||||
ref={ref}
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 aria-invalid:ring-0 dark:bg-transparent",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
InputGroupTextarea.displayName = "InputGroupTextarea";
|
||||
function InputGroupTextarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<Textarea
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 aria-invalid:ring-0 dark:bg-transparent",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { InputGroup, InputGroupAddon, InputGroupButton, InputGroupText, InputGroupInput, InputGroupTextarea };
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
|
||||
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";
|
||||
|
||||
function Tabs({ className, orientation = "horizontal", ...props }: TabsPrimitive.Root.Props) {
|
||||
return (
|
||||
|
|
@ -16,18 +16,20 @@ function Tabs({ className, orientation = "horizontal", ...props }: TabsPrimitive
|
|||
);
|
||||
}
|
||||
|
||||
const tabsListVariants = cva({
|
||||
base: "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-9 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-9 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
import { defineConfig } from "cva";
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export const { cva, cx, compose } = defineConfig({
|
||||
hooks: {
|
||||
onComplete: (className) => twMerge(className),
|
||||
},
|
||||
});
|
||||
export { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
export const cn = cx;
|
||||
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
|
||||
|
||||
export const cx = cn;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue