diff --git a/litellm/constants.py b/litellm/constants.py index 7423d9b2211..715d57e594d 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -715,6 +715,7 @@ openai_compatible_endpoints: List = [ "https://api.clarifai.com/v2/ext/openai/v1", "https://api.libertai.io/v1", "https://pinstripes.io/v1", + "https://api.meta.ai/v1", ] @@ -781,6 +782,7 @@ openai_compatible_providers: List = [ "ragflow", "pinstripes", # Pinstripes - JSON-configured provider "darkbloom", + "meta", # Meta Model API (Muse Spark) - JSON-configured provider ] openai_text_completion_compatible_providers: List = [ # providers that support `/v1/completions` "together_ai", diff --git a/litellm/integrations/custom_guardrail.py b/litellm/integrations/custom_guardrail.py index 59d37639098..ae82e7992ed 100644 --- a/litellm/integrations/custom_guardrail.py +++ b/litellm/integrations/custom_guardrail.py @@ -757,6 +757,12 @@ class CustomGuardrail(CustomLogger): # raw provider JSON so redaction is not duplicated upstream). clean_guardrail_response = redact_nested_match_and_regex_keys(clean_guardrail_response) + from litellm.litellm_core_utils.sensitive_data_masker import ( + mask_credentials_in_payload, + ) + + clean_guardrail_response = mask_credentials_in_payload(clean_guardrail_response) + slg = StandardLoggingGuardrailInformation( guardrail_name=self.guardrail_name, guardrail_provider=guardrail_provider, diff --git a/litellm/litellm_core_utils/get_llm_provider_logic.py b/litellm/litellm_core_utils/get_llm_provider_logic.py index 61a73201c43..fa8ffe1ba1d 100644 --- a/litellm/litellm_core_utils/get_llm_provider_logic.py +++ b/litellm/litellm_core_utils/get_llm_provider_logic.py @@ -346,6 +346,9 @@ def get_llm_provider( elif endpoint == "https://pinstripes.io/v1": custom_llm_provider = "pinstripes" dynamic_api_key = get_secret_str("PINSTRIPES_API_KEY") + elif endpoint == "https://api.meta.ai/v1": + custom_llm_provider = "meta" + dynamic_api_key = get_secret_str("META_API_KEY") if api_base is not None and not isinstance(api_base, str): raise Exception("api base needs to be a string. api_base={}".format(api_base)) diff --git a/litellm/litellm_core_utils/sensitive_data_masker.py b/litellm/litellm_core_utils/sensitive_data_masker.py index 1f3a6961f39..7861e13bae5 100644 --- a/litellm/litellm_core_utils/sensitive_data_masker.py +++ b/litellm/litellm_core_utils/sensitive_data_masker.py @@ -1,6 +1,8 @@ from collections.abc import Mapping from typing import Any, Dict, List, Optional, Set +from pydantic import BaseModel + from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER @@ -153,6 +155,39 @@ def mask_sensitive_structure(data: object) -> object: return _error_masker.mask(data) +def mask_credentials_in_payload(data: object) -> object: + """Return a copy of ``data`` where string values under sensitive-named keys + are masked but every other value (``None``, ``int``, ``float``, ``bool``, + ``bytes``, ``datetime``, tuples, sets, typed objects) is preserved by + identity, and dicts/lists are rebuilt structurally. + + Use this for logging payloads that carry response data through to + SpendLogs / OTel / Langfuse, where :meth:`SensitiveDataMasker.mask`'s + config-dump semantics (``None`` -> ``"None"``, tuples stringified, + objects flattened via ``__dict__``) would silently distort the record. + + Sensitive-key detection is delegated to the shared + :class:`SensitiveDataMasker` so pattern updates stay in one place. + """ + return _walk_payload(data, key_is_sensitive=False, depth=0) + + +def _walk_payload(node: object, key_is_sensitive: bool, depth: int) -> object: + if depth >= DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER: + return node + if isinstance(node, Mapping): + return {k: _walk_payload(v, _default_masker.is_sensitive_key(k), depth + 1) for k, v in node.items()} + if isinstance(node, list): + return [_walk_payload(item, key_is_sensitive, depth + 1) for item in node] + if isinstance(node, tuple): + return tuple(_walk_payload(item, key_is_sensitive, depth + 1) for item in node) + if isinstance(node, BaseModel): + return _walk_payload(node.model_dump(), key_is_sensitive, depth) + if key_is_sensitive and isinstance(node, str) and node: + return _default_masker._mask_value(node) + return node + + def mask_sensitive_keys(data: Dict[str, Any], sensitive_fields: Set[str]) -> Dict[str, Any]: """Return a new dict with values masked for keys listed in ``sensitive_fields``. diff --git a/litellm/llms/openai_like/dynamic_config.py b/litellm/llms/openai_like/dynamic_config.py index 3c763ed9b9b..31c913d5d4e 100644 --- a/litellm/llms/openai_like/dynamic_config.py +++ b/litellm/llms/openai_like/dynamic_config.py @@ -91,7 +91,7 @@ def create_config_class(provider: SimpleProviderConfig): def get_supported_openai_params(self, model: str) -> list: """Get supported OpenAI params, excluding tool-related params for models that don't support function calling.""" - from litellm.utils import supports_function_calling + from litellm.utils import supports_function_calling, supports_reasoning supported_params = super().get_supported_openai_params(model=model) @@ -113,6 +113,10 @@ def create_config_class(provider: SimpleProviderConfig): f"function calling — removed tool-related params from supported params." ) + _supports_reasoning = supports_reasoning(model=model, custom_llm_provider=provider.slug) + if _supports_reasoning and "reasoning_effort" not in supported_params: + supported_params.append("reasoning_effort") + return supported_params def map_openai_params( diff --git a/litellm/llms/openai_like/messages/transformation.py b/litellm/llms/openai_like/messages/transformation.py index 0df8c6e830b..4963bcca9ac 100644 --- a/litellm/llms/openai_like/messages/transformation.py +++ b/litellm/llms/openai_like/messages/transformation.py @@ -1,8 +1,11 @@ from typing import Any, Optional +import litellm from litellm.llms.anthropic.experimental_pass_through.messages.transformation import ( AnthropicMessagesConfig, ) +from litellm.llms.openai_like.json_loader import SimpleProviderConfig +from litellm.secret_managers.main import get_secret_str DEFAULT_ANTHROPIC_API_VERSION = "2023-06-01" @@ -67,3 +70,65 @@ class OpenAILikeAnthropicMessagesConfig(AnthropicMessagesConfig): if base.endswith("/v1"): base = base[: -len("/v1")] return f"{base}/v1/messages" + + +class JSONProviderAnthropicMessagesConfig(OpenAILikeAnthropicMessagesConfig): + """ + Provider-level native Anthropic Messages passthrough for JSON-configured + OpenAI-compatible providers whose ``supported_endpoints`` in providers.json + includes ``"/v1/messages"``. Resolves the api key and api base from the + provider's configured env vars, then forwards the Anthropic payload + untranslated like ``OpenAILikeAnthropicMessagesConfig``. + """ + + def __init__(self, provider: SimpleProviderConfig): + super().__init__() + self._provider = provider + + def should_strip_billing_metadata(self) -> bool: + return True + + def _resolve_api_key(self, api_key: Optional[str]) -> Optional[str]: + return api_key or get_secret_str(self._provider.api_key_env) or litellm.api_key + + def _resolve_api_base(self, api_base: Optional[str]) -> str: + env_api_base = get_secret_str(self._provider.api_base_env) if self._provider.api_base_env else None + return api_base or env_api_base or self._provider.base_url + + def validate_anthropic_messages_environment( + self, + headers: dict[str, str], + model: str, + messages: list[Any], + optional_params: dict, + litellm_params: dict, + api_key: Optional[str] = None, + api_base: Optional[str] = None, + ) -> tuple[dict[str, str], Optional[str]]: + return super().validate_anthropic_messages_environment( + headers=headers, + model=model, + messages=messages, + optional_params=optional_params, + litellm_params=litellm_params, + api_key=self._resolve_api_key(api_key), + api_base=api_base, + ) + + def get_complete_url( + self, + api_base: Optional[str], + api_key: Optional[str], + model: str, + optional_params: dict, + litellm_params: dict, + stream: Optional[bool] = None, + ) -> str: + return super().get_complete_url( + api_base=self._resolve_api_base(api_base), + api_key=api_key, + model=model, + optional_params=optional_params, + litellm_params=litellm_params, + stream=stream, + ) diff --git a/litellm/llms/openai_like/providers.json b/litellm/llms/openai_like/providers.json index d87346fea70..164100d4194 100644 --- a/litellm/llms/openai_like/providers.json +++ b/litellm/llms/openai_like/providers.json @@ -168,6 +168,13 @@ }, "supported_endpoints": ["/v1/chat/completions", "/v1/responses"] }, + "meta": { + "base_url": "https://api.meta.ai/v1", + "api_key_env": "META_API_KEY", + "api_base_env": "META_API_BASE", + "base_class": "openai_gpt", + "supported_endpoints": ["/v1/chat/completions", "/v1/responses", "/v1/messages"] + }, "pinstripes": { "base_url": "https://pinstripes.io/v1", "api_key_env": "PINSTRIPES_API_KEY", diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index a111f301d11..8be2e5d01c5 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -25501,6 +25501,42 @@ "supports_function_calling": true, "supports_tool_choice": false }, + "meta/muse-spark-1.1": { + "cache_read_input_token_cost": 1.5e-07, + "input_cost_per_token": 1.25e-06, + "litellm_provider": "meta", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 4.25e-06, + "source": "https://dev.meta.ai/docs/getting-started/pricing-rate-limits", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses", + "/v1/messages" + ], + "supported_modalities": [ + "text", + "image", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_minimal_reasoning_effort": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "supports_xhigh_reasoning_effort": true + }, "meta_llama/Llama-3.3-70B-Instruct": { "litellm_provider": "meta_llama", "max_input_tokens": 128000, diff --git a/litellm/proxy/_experimental/mcp_server/db.py b/litellm/proxy/_experimental/mcp_server/db.py index 10081ce19de..e4f8b0c331d 100644 --- a/litellm/proxy/_experimental/mcp_server/db.py +++ b/litellm/proxy/_experimental/mcp_server/db.py @@ -3,7 +3,7 @@ import binascii import hashlib import json from datetime import datetime, timedelta, timezone -from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Set, Union, cast +from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Iterable, List, Optional, Set, Union, cast from litellm._logging import verbose_proxy_logger from litellm._uuid import uuid @@ -558,7 +558,11 @@ async def delete_mcp_server_from_virtualkey(): pass -async def delete_mcp_server(prisma_client: PrismaClient, server_id: str) -> Optional[LiteLLM_MCPServerTable]: +async def delete_mcp_server( + prisma_client: PrismaClient, + server_id: str, + invalidate_token_cache: Optional[Callable[[str, str], Awaitable[None]]] = None, +) -> Optional[LiteLLM_MCPServerTable]: """ Delete the mcp server from the db by server_id @@ -569,6 +573,12 @@ async def delete_mcp_server(prisma_client: PrismaClient, server_id: str) -> Opti caller-visible error. Each table is cleaned independently so a failure on one still attempts the other. + Each enumerated credential row's user also gets their cached per-user token + invalidated (legacy cache + v2 store, via invalidate_token_cache, defaulting + to the manager's shared invalidation): the caches are keyed by + (user_id, server_id), so without this a re-created server reusing the same + server_id would serve tokens minted for the deleted server until TTL. + Returns the deleted mcp server record if it exists, otherwise None """ deleted_server = await MCPServerRepository(prisma_client).table.delete( @@ -577,6 +587,18 @@ async def delete_mcp_server(prisma_client: PrismaClient, server_id: str) -> Opti }, ) if deleted_server is not None: + credential_user_ids: List[str] = [] + try: + credential_rows = await prisma_client.db.litellm_mcpusercredentials.find_many( + where={"server_id": server_id} + ) + credential_user_ids = [row.user_id for row in credential_rows] + except Exception as e: # noqa: BLE001 - enumeration is best-effort; cached tokens expire by TTL + verbose_proxy_logger.warning( + "MCP server %s deleted but per-user credential enumeration failed; cached tokens expire by TTL: %s", + server_id, + e, + ) for model, label in ( (prisma_client.db.litellm_mcpusercredentials, "credential"), (prisma_client.db.litellm_mcpuserenvvars, "env var"), @@ -591,6 +613,15 @@ async def delete_mcp_server(prisma_client: PrismaClient, server_id: str) -> Opti label, e, ) + if credential_user_ids: + if invalidate_token_cache is None: + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + + invalidate_token_cache = global_mcp_server_manager.invalidate_user_oauth_token_cache + for user_id in credential_user_ids: + await invalidate_token_cache(user_id, server_id) return deleted_server @@ -1070,6 +1101,103 @@ async def list_user_oauth_credentials( return results +def _decrypted_credential_field(creds: Dict[str, object], field: str) -> object: + """Return one credential field decrypted with the global salt key; non-string and legacy + plaintext values come back unchanged (decrypt_value_helper returns the original on failure).""" + value = creds.get(field) + if not isinstance(value, str): + return value + return decrypt_value_helper( + value=value, + key=field, + exception_type="debug", + return_original_value=True, + ) + + +def mcp_oauth_token_identity(server: object) -> tuple[object, ...]: + """The upstream-OAuth-token-determining fields of an MCP server: the resource/audience (url, or + spec_path for OpenAPI servers), the OAuth mode/grant (auth_type, oauth2_flow), the + authorization-server endpoints, and the OAuth client + scopes. Mirrors the dashboard's + getOAuthAuthorizationIdentity. When any of these change on a server update, previously stored + per-user tokens were minted for the old identity and are stale. Excludes transport and + delegate_auth_to_upstream, which do not affect what token is minted (RFC 8707/8693). + + client_id/client_secret are compared decrypted: stored values are NaCl-encrypted with a fresh + nonce on every write, so comparing ciphertext would flag every routine save as an identity + change and purge tokens that are still valid.""" + creds = getattr(server, "credentials", None) + if isinstance(creds, str): + try: + parsed: object = json.loads(creds) + except ValueError: + parsed = None + else: + parsed = creds + creds_dict: Dict[str, object] = parsed if isinstance(parsed, dict) else {} + return ( + getattr(server, "url", None), + getattr(server, "spec_path", None), + getattr(server, "auth_type", None), + getattr(server, "oauth2_flow", None), + getattr(server, "authorization_url", None), + getattr(server, "token_url", None), + getattr(server, "registration_url", None), + _decrypted_credential_field(creds_dict, "client_id"), + _decrypted_credential_field(creds_dict, "client_secret"), + creds_dict.get("scopes"), + ) + + +async def purge_user_oauth_credentials_for_server( + prisma_client: PrismaClient, + server_id: str, + invalidate_token_cache: Optional[Callable[[str, str], Awaitable[None]]] = None, +) -> int: + """Delete every stored per-user OAuth token for a server and invalidate each user's cached + token everywhere it can be served from (the legacy per-user token cache and the v2 per-user OAuth + token store), so no user keeps a token minted for a superseded configuration. Called when a server + update changes a mint-relevant field (see mcp_oauth_token_identity). Returns the number of rows + removed. + + LiteLLM_MCPUserCredentials also stores BYOK API keys in the same column; only rows whose payload + decodes as an OAuth2 credential (see _decode_oauth_payload) are deleted, because a config change + only invalidates minted tokens, never a user's own stored key. Rows are therefore deleted per + (user_id, server_id) pair rather than by a blanket server_id filter. An OAuth row inserted while + the purge runs for a user not yet enumerated survives; a re-auth completing in the window for an + already-enumerated user is deleted along with the stale row (the pair delete cannot tell them + apart), which costs that user one extra re-auth and nothing else. + + invalidate_token_cache is injectable for tests; it defaults to the manager's shared + invalidate_user_oauth_token_cache, the single invalidation point for per-user tokens.""" + repo = MCPUserCredentialsRepository(prisma_client) + rows = await repo.table.find_many(where={"server_id": server_id}) + oauth_rows = [row for row in rows if _decode_oauth_payload(row.credential_b64) is not None] + if not oauth_rows: + return 0 + deleted_count = await repo.table.delete_many( + where={"server_id": server_id, "user_id": {"in": [row.user_id for row in oauth_rows]}} + ) + if invalidate_token_cache is None: + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + + invalidate_token_cache = global_mcp_server_manager.invalidate_user_oauth_token_cache + + for row in oauth_rows: + await invalidate_token_cache(row.user_id, server_id) + if deleted_count != len(oauth_rows): + verbose_proxy_logger.warning( + "MCP server %s: purge removed %d OAuth credential row(s) but %d were enumerated; " + "row(s) were deleted concurrently during the purge", + server_id, + deleted_count, + len(oauth_rows), + ) + return deleted_count + + async def refresh_user_oauth_token( prisma_client: PrismaClient, user_id: str, diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 356ed7a2729..8dd9949e17b 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -57,7 +57,11 @@ from litellm.proxy._experimental.mcp_server.elicitation_handler import ( from litellm.proxy._experimental.mcp_server.sampling_handler import ( MCP_SAMPLING_AVAILABLE, ) -from litellm.proxy._experimental.mcp_server.oauth2_token_cache import resolve_mcp_auth +from litellm.proxy._experimental.mcp_server.oauth2_token_cache import ( + MCPPerUserTokenCache, + mcp_per_user_token_cache, + resolve_mcp_auth, +) from litellm.proxy._experimental.mcp_server.outbound_credentials import ( Error, Ok, @@ -799,10 +803,12 @@ class MCPServerManager: self, cred_provider: Optional[UpstreamCredentialProvider] = None, per_user_oauth_token_store: Optional[InvalidatableOAuthTokenStore] = None, + per_user_token_cache: Optional[MCPPerUserTokenCache] = None, ): self._per_user_oauth_token_store = per_user_oauth_token_store or LazyPerUserOAuthTokenStore( self.get_mcp_server_by_id ) + self._per_user_token_cache = per_user_token_cache or mcp_per_user_token_cache self._cred_provider = cred_provider or UpstreamCredentialProvider( oauth_token_store=self._per_user_oauth_token_store, token_exchanger=build_token_exchanger(), @@ -4053,10 +4059,13 @@ class MCPServerManager: return await self._cred_provider.has_user_token(to_subject(user_api_key_auth, None), spec) async def invalidate_user_oauth_token_cache(self, user_id: str, server_id: str) -> None: - """Drop the v2 chain's cached token for ``(user_id, server_id)`` after the credential row - changes (re-auth, revoke), so the next resolve reads the new row instead of serving the - replaced token until its cache TTL. Best-effort: a cache-drop failure is logged, never - raised, because the DB write already succeeded and the TTL remains the backstop. + """Drop every cached token for ``(user_id, server_id)`` after the credential row changes + (re-auth, revoke, config-change purge): the v2 chain's cache and the legacy per-user token + cache, so the next resolve reads the new row instead of serving the replaced token until its + cache TTL, whichever path resolves it. This is the single invalidation point for per-user + OAuth tokens; callers must not evict individual caches directly. Best-effort: a cache-drop + failure is logged, never raised, because the DB write already succeeded and the TTL remains + the backstop. """ try: await self._per_user_oauth_token_store.invalidate(user_id, server_id) @@ -4064,6 +4073,12 @@ class MCPServerManager: verbose_logger.warning( "Failed to invalidate cached MCP OAuth token for user=%s server=%s: %s", user_id, server_id, exc ) + try: + await self._per_user_token_cache.delete(user_id, server_id) + except Exception as exc: # noqa: BLE001 - cache drop is best-effort; TTL is the backstop + verbose_logger.warning( + "Failed to drop legacy cached MCP OAuth token for user=%s server=%s: %s", user_id, server_id, exc + ) async def _resolve_oauth2_headers_for_tool_call( self, diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index e7fee8d6eb2..cd8103abf5e 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -98,7 +98,10 @@ from litellm.repositories.user_repository import UserRepository from litellm.router import Router from litellm.utils import get_utc_datetime -from .auth_checks_organization import organization_role_based_access_check +from .auth_checks_organization import ( + add_team_org_context_to_request_body, + organization_role_based_access_check, +) from .auth_utils import get_model_from_request if TYPE_CHECKING: @@ -707,10 +710,28 @@ async def common_checks( # 10 [OPTIONAL] Organization RBAC checks organization_role_based_access_check(user_object=user_object, route=route, request_body=request_body) + async def _fetch_team_org_id(team_id: str) -> Optional[str]: + try: + team = await get_team_object( + team_id=team_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + except HTTPException: + return None + return team.organization_id + + request_body_for_route_check = await add_team_org_context_to_request_body( + route=route, + request_body=request_body, + fetch_team_org_id=_fetch_team_org_id, + ) + _is_route_allowed = _is_api_route_allowed( route=route, request=request, - request_data=request_body, + request_data=request_body_for_route_check, valid_token=valid_token, user_obj=user_object, ) diff --git a/litellm/proxy/auth/auth_checks_organization.py b/litellm/proxy/auth/auth_checks_organization.py index 44c1d158cbe..b4caff9b8ee 100644 --- a/litellm/proxy/auth/auth_checks_organization.py +++ b/litellm/proxy/auth/auth_checks_organization.py @@ -2,7 +2,7 @@ Auth Checks for Organizations """ -from typing import Dict, List, Optional, Tuple +from typing import Awaitable, Callable, Dict, List, Optional, Tuple from fastapi import status @@ -170,3 +170,33 @@ def _user_is_org_admin( # User must be admin of ALL requested orgs, not just any one return all(org_id in admin_org_ids for org_id in candidate_org_ids) + + +TEAM_ORG_CONTEXT_ROUTES = frozenset({"/team/update"}) + + +async def add_team_org_context_to_request_body( + route: str, + request_body: dict, + fetch_team_org_id: Callable[[str], Awaitable[Optional[str]]], +) -> dict: + """ + Return a copy of request_body with organization_id resolved from the target + team when the route identifies the team by team_id and the caller did not + pass organization_id. This lets an org admin of the team's own org reach the + org-scoped branch of the route gate (which keys off organization_id) without + the client having to send it. Returns request_body unchanged when it does + not apply, so callers that already pass organization_id and non-team routes + are untouched. + """ + if route not in TEAM_ORG_CONTEXT_ROUTES: + return request_body + if request_body.get("organization_id"): + return request_body + team_id = request_body.get("team_id") + if not isinstance(team_id, str) or not team_id: + return request_body + org_id = await fetch_team_org_id(team_id) + if not org_id: + return request_body + return {**request_body, "organization_id": org_id} diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index c9952b245c7..907a17d76d9 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -125,7 +125,9 @@ if MCP_AVAILABLE: get_user_env_vars_bulk, get_user_oauth_credential, list_user_oauth_credentials, + mcp_oauth_token_identity, merge_user_env_vars, + purge_user_oauth_credentials_for_server, reject_mcp_server, store_user_credential, store_user_oauth_credential, @@ -2318,6 +2320,19 @@ if MCP_AVAILABLE: }, ) + # Snapshot the pre-update identity so we can detect a mint-relevant change below. The read is + # advisory (it only feeds the stale-token purge decision), so a failure skips the purge with a + # warning instead of failing the edit, whose primary job is the update itself. + try: + old_server_record = await get_mcp_server(prisma_client, payload.server_id) + except Exception as exc: # noqa: BLE001 - advisory read; invalidation is best-effort end-to-end + verbose_logger.warning( + "MCP server %s: could not snapshot the pre-update record; skipping the stale-token check: %s", + payload.server_id, + exc, + ) + old_server_record = None + # try to update the mcp server mcp_server_record_updated = await update_mcp_server( prisma_client, @@ -2336,6 +2351,30 @@ if MCP_AVAILABLE: # Ensure registry is up to date by reloading from database await global_mcp_server_manager.reload_servers_from_database() + # If a field that determines which upstream OAuth token gets minted changed (url/audience, OAuth + # mode/grant, authorization-server endpoints, or the OAuth client + scopes), every stored per-user + # token was minted for the old configuration and is stale. Purge them (DB + cache) so the next + # tool call re-authorizes instead of forwarding a token for a resource/AS/client that no longer + # matches. Best-effort: a purge failure must not fail the update, whose primary job already + # succeeded. + if old_server_record is not None and mcp_oauth_token_identity(old_server_record) != mcp_oauth_token_identity( + mcp_server_record_updated + ): + try: + purged = await purge_user_oauth_credentials_for_server(prisma_client, payload.server_id) + if purged: + verbose_logger.info( + "MCP server %s: purged %d stale per-user OAuth token(s) after a mint-relevant config change", + payload.server_id, + purged, + ) + except Exception as exc: # noqa: BLE001 - purge is best-effort; the server update already succeeded + verbose_logger.warning( + "MCP server %s: failed to purge stale per-user OAuth tokens after config change: %s", + payload.server_id, + exc, + ) + # TODO: Enterprise: Finish audit log trail if litellm.store_audit_logs: pass diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 6b99cfa3314..8d474e2e880 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -2533,9 +2533,10 @@ class StandardLoggingMCPToolCall(TypedDict, total=False): mcp_server_resource: Optional[str] """ - The upstream MCP server resource identifier (scheme + host + path) the tool call was - forwarded to. Redacted for logging: userinfo, query string, and fragment are stripped so an - upstream URL carrying an embedded token or secret query parameter never reaches log metadata. + The origin (scheme + host + port) of the upstream MCP server the tool call was forwarded + to. Redacted for logging: userinfo, the path, the query string, and the fragment are all + stripped, because hosted MCP servers routinely embed the credential in the URL path and + this value is readable by callers via request logs. Records which upstream received a relayed request; never a credential. """ @@ -3397,6 +3398,7 @@ class LlmProviders(str, Enum): LIBERTAI = "libertai" PINSTRIPES = "pinstripes" DARKBLOOM = "darkbloom" + META = "meta" LITELLM_AGENT = "litellm_agent" CURSOR = "cursor" BEDROCK_MANTLE = "bedrock_mantle" diff --git a/litellm/utils.py b/litellm/utils.py index 19c2fe16085..5af7b62b332 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -8028,6 +8028,16 @@ class ProviderConfigManager: ) return GithubCopilotAnthropicMessagesConfig() + + from litellm.llms.openai_like.json_loader import JSONProviderRegistry + + json_provider = JSONProviderRegistry.get(provider.value) + if json_provider is not None and "/v1/messages" in json_provider.supported_endpoints: + from litellm.llms.openai_like.messages.transformation import ( + JSONProviderAnthropicMessagesConfig, + ) + + return JSONProviderAnthropicMessagesConfig(json_provider) return None @staticmethod diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index d6e4a265da0..08456386362 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -25659,6 +25659,42 @@ "supports_function_calling": true, "supports_tool_choice": false }, + "meta/muse-spark-1.1": { + "cache_read_input_token_cost": 1.5e-07, + "input_cost_per_token": 1.25e-06, + "litellm_provider": "meta", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 4.25e-06, + "source": "https://dev.meta.ai/docs/getting-started/pricing-rate-limits", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses", + "/v1/messages" + ], + "supported_modalities": [ + "text", + "image", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_minimal_reasoning_effort": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "supports_xhigh_reasoning_effort": true + }, "meta_llama/Llama-3.3-70B-Instruct": { "litellm_provider": "meta_llama", "max_input_tokens": 128000, diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index 3034ada56ba..65db63dc045 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -1984,6 +1984,23 @@ "interactions": true } }, + "meta": { + "display_name": "Meta Model API (`meta`)", + "url": "https://docs.litellm.ai/docs/providers/meta", + "endpoints": { + "chat_completions": true, + "messages": true, + "responses": true, + "embeddings": false, + "image_generations": false, + "audio_transcriptions": false, + "audio_speech": false, + "moderations": false, + "batches": false, + "rerank": false, + "a2a": false + } + }, "pinstripes": { "display_name": "Pinstripes (`pinstripes`)", "url": "https://docs.litellm.ai/docs/providers/pinstripes", diff --git a/tests/code_coverage_tests/recursive_detector.py b/tests/code_coverage_tests/recursive_detector.py index 6d0e12314f0..2af14e1e544 100644 --- a/tests/code_coverage_tests/recursive_detector.py +++ b/tests/code_coverage_tests/recursive_detector.py @@ -36,6 +36,7 @@ IGNORE_FUNCTIONS = [ "_collect_argument_paths", # max depth set. "_split_text", # max depth set. "_mask_sequence", # max depth set. + "_walk_payload", # max depth set (DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER). "_delete_nested_value_custom", # max depth set (bounded by number of path segments). "filter_exceptions_from_params", # max depth set (default 20) to prevent infinite recursion. "__getattr__", # lazy loading pattern in litellm/__init__.py with proper caching to prevent infinite recursion. diff --git a/tests/e2e/batches/capabilities.py b/tests/e2e/batches/capabilities.py index 522e3162e24..51e69fb5805 100644 --- a/tests/e2e/batches/capabilities.py +++ b/tests/e2e/batches/capabilities.py @@ -44,10 +44,10 @@ class Provider: ) case "azure": return LiteLLMParamsBody( - model="azure/gpt-4.1-mini-batch", + model="azure/gpt-5.4-mini-batch", api_base="os.environ/AZURE_API_BASE", api_key="os.environ/AZURE_API_KEY", - api_version="2024-07-01-preview", + api_version="2025-04-01-preview", ) case "vertex_ai": return LiteLLMParamsBody( @@ -97,7 +97,7 @@ class Capability: PROVIDERS: tuple[Provider, ...] = ( Provider("openai", "openai-batch", "gpt-4o-mini", can_cancel=True, can_list=True), - Provider("azure", "azure-batch", "gpt-4.1-mini-batch", can_cancel=True, can_list=True), + Provider("azure", "azure-batch", "gpt-5.4-mini-batch", can_cancel=True, can_list=True), Provider( "vertex_ai", "vertex-batch", "gemini-2.5-flash", can_cancel=True, can_list=True ), diff --git a/tests/e2e/llm_translation/realtime/REALTIME_COVERAGE_MATRIX.md b/tests/e2e/llm_translation/realtime/REALTIME_COVERAGE_MATRIX.md index ff8b3441d86..ffec7176f10 100644 --- a/tests/e2e/llm_translation/realtime/REALTIME_COVERAGE_MATRIX.md +++ b/tests/e2e/llm_translation/realtime/REALTIME_COVERAGE_MATRIX.md @@ -43,7 +43,7 @@ at call time. The provider table below is the source of truth; edit `PROVIDERS` | gemini | `gemini-realtime` | `gemini/gemini-3.1-flash-live-preview` | | vertex_ai | `vertex-realtime` | `vertex_ai/gemini-live-2.5-flash-preview-native-audio-09-2025` | -Bedrock and xai (`xai/grok-4-1-fast-non-reasoning`) are supported by the proxy but +Bedrock and xai (`xai/grok-4-1-fast`) are supported by the proxy but kept commented out in `PROVIDERS` until they pass end-to-end here; re-enable them by uncommenting their entry. diff --git a/tests/e2e/llm_translation/realtime/realtime_client.py b/tests/e2e/llm_translation/realtime/realtime_client.py index ef7834d6bbe..51052abe227 100644 --- a/tests/e2e/llm_translation/realtime/realtime_client.py +++ b/tests/e2e/llm_translation/realtime/realtime_client.py @@ -95,7 +95,7 @@ PROVIDERS = ( # "xai", # "xai-realtime", # LiteLLMParamsBody( - # model="xai/grok-4-1-fast-non-reasoning", + # model="xai/grok-4-1-fast", # api_key="os.environ/XAI_API_KEY", # ), # ), # TODO: Enable once xai Grok Voice realtime is passing end-to-end here diff --git a/tests/e2e/llm_translation/realtime/test_realtime_pipecat_audio_e2e.py b/tests/e2e/llm_translation/realtime/test_realtime_pipecat_audio_e2e.py index 31c038b4e02..935d7528c1b 100644 --- a/tests/e2e/llm_translation/realtime/test_realtime_pipecat_audio_e2e.py +++ b/tests/e2e/llm_translation/realtime/test_realtime_pipecat_audio_e2e.py @@ -96,7 +96,7 @@ SERVER_VAD_SETTINGS = rt_events.SessionProperties( noise_reduction=rt_events.InputAudioNoiseReduction(type="near_field"), turn_detection=rt_events.TurnDetection( type="server_vad", - threshold=0.8, + threshold=0.5, prefix_padding_ms=300, silence_duration_ms=700, ), diff --git a/tests/e2e/llm_translation/test_cache_control.py b/tests/e2e/llm_translation/test_cache_control.py new file mode 100644 index 00000000000..fda05cdc950 --- /dev/null +++ b/tests/e2e/llm_translation/test_cache_control.py @@ -0,0 +1,163 @@ +"""Live e2e: provider-specific /chat/completions features take real effect. + +Each case asserts the feature actually happened, not just a 200. Coverage matrix +(register-on-demand deployments, deleted on teardown): + +- Bedrock (anthropic claude-haiku-4-5): prompt caching. A large cacheable prefix + marked with ``cache_control`` is sent twice; the second call must report + cache-read usage tokens > 0. service_tier is out of scope for Bedrock; AWS + Bedrock does not expose an OpenAI-style request service tier, so that cell is + intentionally not covered here. +- Vertex (gemini-2.5-flash): prompt caching via ``cache_control`` context + caching; the second identical call must report cached prompt tokens > 0. + +service_tier lives in test_provider_features_e2e.py. + +The provider-native cache_control request shape is not expressible with the +shared ``ChatBody`` (whose content is a plain string), so the cacheable body is +modelled locally with typed content blocks. +""" + +from __future__ import annotations + +import time + +import pytest +from pydantic import BaseModel + +from e2e_config import unique_marker +from e2e_http import Result, unwrap +from lifecycle import ResourceManager +from models import ChatResponse, LiteLLMParamsBody, Usage +from passthrough_client import PassthroughClient +import os + +pytestmark = pytest.mark.e2e + +BEDROCK_MODEL = "bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0" +VERTEX_MODEL = "vertex_ai/gemini-2.5-flash" + + +class CacheControl(BaseModel): + type: str = "ephemeral" + + +class TextBlock(BaseModel): + type: str = "text" + text: str + cache_control: CacheControl | None = None + + +class RichMessage(BaseModel): + role: str + content: list[TextBlock] + + +class CacheChatBody(BaseModel): + model: str + messages: list[RichMessage] + max_tokens: int = 64 + cache: dict[str, bool] = {"no-cache": True} + + +def _cacheable_prefix() -> str: + """A prefix long enough to clear provider minimum cacheable sizes (Haiku is + 2048 tokens), unique per run so the first call writes and the second reads.""" + marker = unique_marker() + body = " ".join( + f"Cacheable reference paragraph {index} for run {marker}." for index in range(600) + ) + return f"{body}\nEnd of reference material {marker}." + + +def _cached_read_tokens(usage: Usage | None) -> int: + """Cache-read tokens however the provider reports them: Anthropic-style + ``cache_read_input_tokens`` or OpenAI-style ``prompt_tokens_details.cached_tokens``.""" + if usage is None: + return 0 + if usage.cache_read_input_tokens: + return usage.cache_read_input_tokens + if usage.prompt_tokens_details and usage.prompt_tokens_details.cached_tokens: + return usage.prompt_tokens_details.cached_tokens + return 0 + + +def _cache_chat( + client: PassthroughClient, key: str, model: str, prefix: str +) -> Result[ChatResponse]: + body = CacheChatBody( + model=model, + messages=[ + RichMessage( + role="system", + content=[TextBlock(text=prefix, cache_control=CacheControl())], + ), + RichMessage(role="user", content=[TextBlock(text="Reply with one word.")]), + ], + ) + return client.gateway.transport.post( + "/chat/completions", + headers=client.gateway.transport.bearer(key), + json=body, + response_type=ChatResponse, + ) + + +def _assert_cache_read_on_second_call( + client: PassthroughClient, key: str, model: str +) -> None: + prefix = _cacheable_prefix() + + first = unwrap(_cache_chat(client, key, model, prefix)) + assert first.choices, f"{model}: first cache-priming call returned no choices: {first}" + + read_tokens = 0 + deadline = time.monotonic() + 30.0 + while time.monotonic() < deadline: + second = unwrap(_cache_chat(client, key, model, prefix)) + read_tokens = _cached_read_tokens(second.usage) + if read_tokens > 0: + break + time.sleep(3.0) + + assert read_tokens > 0, ( + f"{model}: second identical call reported no cache-read tokens " + f"({second.usage}); prompt caching did not take effect" + ) + + +class TestCacheControl: + @pytest.mark.covers( + "llm.chat_completions.bedrock_converse.prompt_cache_5m.nonstream.works", + exercised_on=[], + ) + def test_bedrock_prompt_caching_reads_cache( + self, client: PassthroughClient, resources: ResourceManager + ) -> None: + model = f"e2e-bedrock-cache-{unique_marker()}" + model_id = client.gateway.create_model( + model, + LiteLLMParamsBody(model=BEDROCK_MODEL, aws_region_name="us-east-1"), + ) + resources.defer(lambda: client.gateway.delete_model(model_id)) + _assert_cache_read_on_second_call(client, resources.key(), model) + + @pytest.mark.covers( + "llm.chat_completions.vertex.prompt_cache_5m.nonstream.works", + exercised_on=[], + ) + def test_vertex_prompt_caching_reads_cache( + self, client: PassthroughClient, resources: ResourceManager + ) -> None: + model = f"e2e-vertex-cache-{unique_marker()}" + model_id = client.gateway.create_model( + model, + LiteLLMParamsBody( + model=VERTEX_MODEL, + vertex_project=os.environ.get("VERTEXAI_PROJECT"), + vertex_location="us-central1", + vertex_credentials=os.environ.get("VERTEXAI_CREDENTIALS"), + ), + ) + resources.defer(lambda: client.gateway.delete_model(model_id)) + _assert_cache_read_on_second_call(client, resources.key(), model) diff --git a/tests/e2e/llm_translation/test_provider_features_e2e.py b/tests/e2e/llm_translation/test_provider_features_e2e.py index d272fffa9b8..822a1d8d4d5 100644 --- a/tests/e2e/llm_translation/test_provider_features_e2e.py +++ b/tests/e2e/llm_translation/test_provider_features_e2e.py @@ -1,4 +1,4 @@ -"""Live e2e for model-specific request features: service_tier and prompt caching. +"""Live e2e for model-specific request features: service_tier. Each case asserts the feature took effect, not just a 200. @@ -11,81 +11,22 @@ avoided here because it is capacity-constrained and returns a transient 429 when flex resources are unavailable. Bedrock and Vertex do not accept service_tier, so that cell is OpenAI-only by design. -Prompt caching is asserted through provider prompt-cache usage tokens. The -deterministic path is explicit ``cache_control`` on an Anthropic-family model -(here Bedrock's Claude): a large cacheable prefix is sent twice and the second -call must report ``cache_read_input_tokens > 0``. OpenAI and Gemini only offer -implicit automatic caching, which does not deterministically produce a cache read -within a test window (verified: repeated >3k-token prompts kept -``prompt_tokens_details.cached_tokens`` at 0), so those caching cells are out of -scope here and covered only by the explicit-cache-control Bedrock case. +Prompt caching lives in test_cache_control.py. """ from __future__ import annotations import pytest -from pydantic import BaseModel, ConfigDict, Field from e2e_config import unique_marker from e2e_http import unwrap from lifecycle import ResourceManager -from models import ChatBody, ChatMessage, ChatResponse, LiteLLMParamsBody +from models import ChatBody, ChatMessage, LiteLLMParamsBody from passthrough_client import PassthroughClient pytestmark = pytest.mark.e2e SERVICE_TIER = "priority" -CACHE_MIN_READ_TOKENS = 1 - - -class CacheControl(BaseModel): - type: str = "ephemeral" - - -class CacheTextBlock(BaseModel): - type: str = "text" - text: str - cache_control: CacheControl | None = None - - -class RichMessage(BaseModel): - role: str - content: list[CacheTextBlock] - - -class CacheDirective(BaseModel): - """litellm per-request cache control. ``no-cache`` forces the proxy to skip its - own response cache and make a fresh provider call, so the second identical - request actually reaches Bedrock and reads the provider prompt cache instead of - being served the first response verbatim (which would report cache_read=0).""" - - model_config = ConfigDict(populate_by_name=True) - no_cache: bool = Field(default=True, alias="no-cache") - - -class CacheChatBody(BaseModel): - model: str - messages: list[RichMessage] - max_tokens: int - cache: CacheDirective = CacheDirective() - - -def cacheable_prefix() -> str: - return ( - "You are a policy compliance auditor. The following corpus is the immutable " - "reference the assistant must consult on every turn. " - ) + ("Clause: obey all safety, formatting, and citation rules exactly. " * 400) - - -def post_chat(client: PassthroughClient, key: str, body: BaseModel) -> ChatResponse: - return unwrap( - client.gateway.transport.post( - "/chat/completions", - headers=client.gateway.transport.bearer(key), - json=body, - response_type=ChatResponse, - ) - ) class TestServiceTier: @@ -120,50 +61,3 @@ class TestServiceTier: f"service_tier not honored: sent {SERVICE_TIER!r}, response reported " f"{response.service_tier!r} ({response})" ) - - -class TestPromptCaching: - @pytest.mark.covers( - "llm.chat_completions.bedrock_converse.prompt_cache_5m.nonstream.works", - exercised_on=[], - ) - def test_bedrock_cache_control_produces_cache_read( - self, client: PassthroughClient, resources: ResourceManager - ) -> None: - model = f"e2e-bedrock-cache-{unique_marker()}" - model_id = client.gateway.create_model( - model, - LiteLLMParamsBody( - model="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0", - aws_region_name="us-east-1", - ), - ) - resources.defer(lambda: client.gateway.delete_model(model_id)) - key = resources.key() - - body = CacheChatBody( - model=model, - max_tokens=32, - messages=[ - RichMessage( - role="user", - content=[ - CacheTextBlock( - text=cacheable_prefix(), cache_control=CacheControl() - ), - CacheTextBlock(text="Answer in one word: acknowledged?"), - ], - ) - ], - ) - - first = post_chat(client, key, body) - assert first.usage is not None, f"first call reported no usage: {first}" - - second = post_chat(client, key, body) - assert second.usage is not None, f"second call reported no usage: {second}" - cache_read = second.usage.cache_read_input_tokens - assert cache_read is not None and cache_read >= CACHE_MIN_READ_TOKENS, ( - "second identical request did not read the prompt cache: " - f"cache_read_input_tokens={cache_read!r} (usage={second.usage})" - ) diff --git a/tests/proxy_behavior/management/test_team_update.py b/tests/proxy_behavior/management/test_team_update.py index 9cb2b0fecda..23ea89fa74d 100644 --- a/tests/proxy_behavior/management/test_team_update.py +++ b/tests/proxy_behavior/management/test_team_update.py @@ -105,27 +105,35 @@ async def test_team_update_authz_matrix( assert row.team_alias != MARKER_ALIAS, "denied but team mutated" -async def test_team_update_requires_proxy_admin_without_org_context( +async def test_team_update_org_admin_resolved_from_team_without_org_context( proxy_client, prisma, scratch, world ): - """With no organization_id in the body the route gate has no org context - and falls back to proxy-admin-only: an org admin of the team's own org - is 401, PROXY_ADMIN is 200.""" + """With no organization_id in the body the route gate resolves the target + team's org from team_id, so an org admin of the team's own org is allowed + (200), same as PROXY_ADMIN. A team admin of that same team stays denied + (401): the resolution grants org admins access, not team admins.""" await _seed_target(prisma, world, "alpha", scratch.prefix) - denied = await proxy_client.post( + allowed_org_admin = await proxy_client.post( "/team/update", headers={"Authorization": f"Bearer {world.keys[Actor.ORG_ADMIN].cleartext}"}, json={"team_id": scratch.prefix, "team_alias": MARKER_ALIAS}, ) - assert denied.status_code == 401, denied.text + assert allowed_org_admin.status_code == 200, allowed_org_admin.text - allowed = await proxy_client.post( + allowed_proxy_admin = await proxy_client.post( "/team/update", headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"}, json={"team_id": scratch.prefix, "team_alias": MARKER_ALIAS}, ) - assert allowed.status_code == 200, allowed.text + assert allowed_proxy_admin.status_code == 200, allowed_proxy_admin.text + + denied_team_admin = await proxy_client.post( + "/team/update", + headers={"Authorization": f"Bearer {world.keys[Actor.TEAM_ADMIN].cleartext}"}, + json={"team_id": scratch.prefix, "team_alias": MARKER_ALIAS}, + ) + assert denied_team_admin.status_code == 401, denied_team_admin.text # Relocation gate — moving a team to a different org. The scratch team starts diff --git a/tests/test_litellm/integrations/test_custom_guardrail.py b/tests/test_litellm/integrations/test_custom_guardrail.py index 29e9f4529fc..d300f326b9e 100644 --- a/tests/test_litellm/integrations/test_custom_guardrail.py +++ b/tests/test_litellm/integrations/test_custom_guardrail.py @@ -833,6 +833,173 @@ class TestGuardrailSensitiveFieldStripping: assert "sk-secret" not in serialized +class TestGuardrailResponseCredentialMasking: + """LIT-4314 issue B regression: credentials embedded in guardrail_response + (via team callback_vars flowing through data["metadata"]) must be masked at + the construction seam so every downstream sink (SpendLogs, OTel, Langfuse, + custom loggers) sees masked values rather than plaintext. + """ + + def _make_guardrail(self): + from litellm.types.guardrails import GuardrailEventHooks + + return CustomGuardrail( + guardrail_name="test_guardrail", + event_hook=GuardrailEventHooks.pre_call, + ) + + def test_callback_vars_api_key_is_masked(self): + import json + + guardrail = self._make_guardrail() + request_data: dict = {"metadata": {}} + plaintext_key = "lsv2_pt_abcdef1234567890" + + guardrail.add_standard_logging_guardrail_information_to_request_data( + guardrail_json_response={ + "model": "gpt-4o-mini", + "messages": [{"role": "user", "content": "hi"}], + "metadata_snapshot": { + "callback_vars": { + "langsmith_api_key": plaintext_key, + "langsmith_project": "proj-name", + } + }, + }, + request_data=request_data, + guardrail_status="success", + duration=1.0, + ) + + logged = request_data["metadata"]["standard_logging_guardrail_information"][0][ + "guardrail_response" + ] + + masked_key = logged["metadata_snapshot"]["callback_vars"]["langsmith_api_key"] + assert masked_key != plaintext_key + assert "*" in masked_key + assert plaintext_key not in json.dumps(request_data) + + assert logged["model"] == "gpt-4o-mini" + assert logged["messages"] == [{"role": "user", "content": "hi"}] + assert ( + logged["metadata_snapshot"]["callback_vars"]["langsmith_project"] + == "proj-name" + ) + + def test_nested_user_api_key_auth_metadata_is_masked(self): + import json + + guardrail = self._make_guardrail() + request_data: dict = {"metadata": {}} + token_value = "1b01552f6e52e0d41963dd6a185bd6b074624e330999534ca7ff5adfdf622dfc" + + guardrail.add_standard_logging_guardrail_information_to_request_data( + guardrail_json_response={ + "evaluated_metadata": { + "user_api_key_auth": { + "token": token_value, + "api_key": token_value, + "metadata": { + "callback_vars": { + "langsmith_api_key": "lsv2_pt_super_secret_value_1234", + } + }, + } + } + }, + request_data=request_data, + guardrail_status="success", + ) + + serialized = json.dumps(request_data) + assert token_value not in serialized + assert "lsv2_pt_super_secret_value_1234" not in serialized + + def test_secret_fields_pop_still_runs(self): + import json + + guardrail = self._make_guardrail() + request_data: dict = {"metadata": {}} + + guardrail.add_standard_logging_guardrail_information_to_request_data( + guardrail_json_response={ + "model": "gpt-4", + "secret_fields": { + "raw_headers": { + "authorization": "Bearer sk-live-should-not-appear", + } + }, + }, + request_data=request_data, + guardrail_status="success", + ) + + serialized = json.dumps(request_data) + assert "secret_fields" not in serialized + assert "sk-live-should-not-appear" not in serialized + + def test_match_and_regex_redaction_still_runs(self): + guardrail = self._make_guardrail() + request_data: dict = {"metadata": {}} + + guardrail.add_standard_logging_guardrail_information_to_request_data( + guardrail_json_response={ + "filters": [{"regex": r"\d{3}-\d{2}-\d{4}", "action": "BLOCKED"}] + }, + request_data=request_data, + guardrail_status="success", + ) + + slg = request_data["metadata"]["standard_logging_guardrail_information"][0] + assert slg["guardrail_response"]["filters"][0]["regex"] == "[REDACTED]" + + def test_scalar_types_pass_through_unchanged(self): + guardrail = self._make_guardrail() + request_data: dict = {"metadata": {}} + + guardrail.add_standard_logging_guardrail_information_to_request_data( + guardrail_json_response={ + "flagged": True, + "score": 0.94, + "tokens_used": 42, + "categories": ["pii", "toxicity"], + }, + request_data=request_data, + guardrail_status="success", + ) + + logged = request_data["metadata"]["standard_logging_guardrail_information"][0][ + "guardrail_response" + ] + assert logged["flagged"] is True + assert logged["score"] == 0.94 + assert logged["tokens_used"] == 42 + assert logged["categories"] == ["pii", "toxicity"] + + def test_masking_reveals_prefix_and_suffix(self): + guardrail = self._make_guardrail() + request_data: dict = {"metadata": {}} + plaintext = "lsv2_pt_abcdef1234567890" + + guardrail.add_standard_logging_guardrail_information_to_request_data( + guardrail_json_response={ + "metadata_snapshot": { + "callback_vars": {"langsmith_api_key": plaintext} + } + }, + request_data=request_data, + guardrail_status="success", + ) + + masked = request_data["metadata"]["standard_logging_guardrail_information"][0][ + "guardrail_response" + ]["metadata_snapshot"]["callback_vars"]["langsmith_api_key"] + assert masked != plaintext + assert masked.startswith(plaintext[:4]) + assert masked.endswith(plaintext[-4:]) + + class TestCustomGuardrailPassthroughSupport: """Tests for passthrough endpoint guardrail support - Issue fixes.""" diff --git a/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py b/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py index 9b0116e6979..ba8540f81e3 100644 --- a/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py +++ b/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py @@ -240,3 +240,78 @@ def test_mask_sensitive_structure_masks_credentials_nested_in_config_shape(): [{"primary-group": [{"model": "gpt-4o", "api_key": secret}]}] ) assert secret not in str(masked) + + +def test_mask_credentials_in_payload_preserves_none_and_scalars(): + """The payload variant does not distort JSON-shaped values: None stays None, + ints/floats/bools stay themselves, lists stay lists. This is what makes it + safe for logging pipelines that persist the record verbatim.""" + from litellm.litellm_core_utils.sensitive_data_masker import mask_credentials_in_payload + + result = mask_credentials_in_payload( + { + "reason": None, + "confidence": 0.42, + "flagged": True, + "tokens_used": 17, + "categories": ["pii", "toxicity"], + "nested": {"end_user_id": None}, + } + ) + assert result == { + "reason": None, + "confidence": 0.42, + "flagged": True, + "tokens_used": 17, + "categories": ["pii", "toxicity"], + "nested": {"end_user_id": None}, + } + + +def test_mask_credentials_in_payload_masks_inside_pydantic_models(): + """A Pydantic model reached during the walk gets dumped to a dict so its + sensitive-named string fields are masked. Without this the credentials + inside a nested ``UserAPIKeyAuth`` in a guardrail_response reach the + logging pipeline unmasked once JSON serialization flattens it.""" + from pydantic import BaseModel + + from litellm.litellm_core_utils.sensitive_data_masker import mask_credentials_in_payload + + class Auth(BaseModel): + token: str = "1b01552f6e52e0d41963dd6a185bd6b074624e330999534ca7ff5adfdf622dfc" + team_alias: str = "acme" + + result = mask_credentials_in_payload({"user_api_key_auth": Auth()}) + auth_dict = result["user_api_key_auth"] + assert isinstance(auth_dict, dict) + assert auth_dict["team_alias"] == "acme" + assert ( + auth_dict["token"] + != "1b01552f6e52e0d41963dd6a185bd6b074624e330999534ca7ff5adfdf622dfc" + ) + assert "*" in auth_dict["token"] + + +def test_mask_credentials_in_payload_masks_only_sensitive_string_leaves(): + """Sensitive-named string leaves get masked; sibling non-string values + (including None) under the same key stay verbatim.""" + from litellm.litellm_core_utils.sensitive_data_masker import mask_credentials_in_payload + + plaintext = "lsv2_pt_abcdef1234567890" + result = mask_credentials_in_payload( + { + "model": "gpt-4o-mini", + "callback_vars": { + "langsmith_api_key": plaintext, + "langsmith_project": "proj", + "extra_token_count": 5, + }, + } + ) + assert result["model"] == "gpt-4o-mini" + assert result["callback_vars"]["langsmith_project"] == "proj" + assert result["callback_vars"]["extra_token_count"] == 5 + masked = result["callback_vars"]["langsmith_api_key"] + assert masked != plaintext + assert masked.startswith(plaintext[:4]) + assert masked.endswith(plaintext[-4:]) diff --git a/tests/test_litellm/llms/openai_like/test_meta_provider.py b/tests/test_litellm/llms/openai_like/test_meta_provider.py new file mode 100644 index 00000000000..11b78828da6 --- /dev/null +++ b/tests/test_litellm/llms/openai_like/test_meta_provider.py @@ -0,0 +1,224 @@ +""" +Tests for the Meta Model API (Muse Spark) provider configuration and integration. +""" + +import litellm + + +class TestMetaProviderConfig: + def test_meta_in_provider_list(self): + from litellm import LlmProviders + + assert hasattr(LlmProviders, "META") + assert LlmProviders.META.value == "meta" + assert "meta" in litellm.provider_list + + def test_meta_json_config_exists(self): + from litellm.llms.openai_like.json_loader import JSONProviderRegistry + + assert JSONProviderRegistry.exists("meta") + + meta = JSONProviderRegistry.get("meta") + assert meta is not None + assert meta.base_url == "https://api.meta.ai/v1" + assert meta.api_key_env == "META_API_KEY" + assert meta.api_base_env == "META_API_BASE" + + def test_meta_supports_responses_api(self): + from litellm.llms.openai_like.json_loader import JSONProviderRegistry + + assert JSONProviderRegistry.supports_responses_api("meta") + + def test_meta_in_openai_compatible_providers(self): + from litellm.constants import openai_compatible_providers + + assert "meta" in openai_compatible_providers + + def test_meta_provider_resolution(self): + from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider + + model, provider, api_key, api_base = get_llm_provider( + model="meta/muse-spark-1.1", + custom_llm_provider=None, + api_base=None, + api_key="sk-test", + ) + + assert model == "muse-spark-1.1" + assert provider == "meta" + assert api_base == "https://api.meta.ai/v1" + + def test_meta_api_base_override(self): + from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider + + model, provider, api_key, api_base = get_llm_provider( + model="meta/muse-spark-1.1", + custom_llm_provider=None, + api_base="https://custom.meta.ai/v1", + api_key="sk-test", + ) + + assert provider == "meta" + assert api_base == "https://custom.meta.ai/v1" + assert api_key == "sk-test" + + def test_meta_url_autodetection(self): + from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider + + model, provider, api_key, api_base = get_llm_provider( + model="muse-spark-1.1", + custom_llm_provider=None, + api_base="https://api.meta.ai/v1", + api_key=None, + ) + assert provider == "meta" + assert api_base == "https://api.meta.ai/v1" + + def test_meta_router_config(self): + from litellm import Router + + router = Router( + model_list=[ + { + "model_name": "muse-spark", + "litellm_params": { + "model": "meta/muse-spark-1.1", + "api_key": "test-key", + }, + } + ] + ) + + assert len(router.model_list) == 1 + assert router.model_list[0]["model_name"] == "muse-spark" + + +class TestMetaReasoningParams: + def test_muse_spark_supports_reasoning_effort(self): + params = litellm.get_supported_openai_params( + model="muse-spark-1.1", custom_llm_provider="meta" + ) + assert params is not None + assert "reasoning_effort" in params + + def test_reasoning_effort_mapped_through(self): + cfg = litellm.ProviderConfigManager.get_provider_chat_config( + model="muse-spark-1.1", provider=litellm.LlmProviders.META + ) + assert cfg is not None + mapped = cfg.map_openai_params( + non_default_params={"reasoning_effort": "xhigh"}, + optional_params={}, + model="muse-spark-1.1", + drop_params=False, + ) + assert mapped["reasoning_effort"] == "xhigh" + + def test_reasoning_effort_gated_on_capability(self): + """A meta model without reasoning metadata must not advertise reasoning_effort.""" + params = litellm.get_supported_openai_params( + model="some-non-reasoning-model", custom_llm_provider="meta" + ) + assert params is not None + assert "reasoning_effort" not in params + + +class TestMetaAnthropicMessages: + def test_meta_resolves_native_messages_config(self): + from litellm.llms.openai_like.messages.transformation import ( + JSONProviderAnthropicMessagesConfig, + ) + + cfg = litellm.ProviderConfigManager.get_provider_anthropic_messages_config( + model="muse-spark-1.1", provider=litellm.LlmProviders.META + ) + assert isinstance(cfg, JSONProviderAnthropicMessagesConfig) + + def test_json_provider_without_messages_endpoint_resolves_none(self): + cfg = litellm.ProviderConfigManager.get_provider_anthropic_messages_config( + model="some-model", provider=litellm.LlmProviders.PINSTRIPES + ) + assert cfg is None + + def test_complete_url_defaults_to_meta_base(self): + from litellm.llms.openai_like.json_loader import JSONProviderRegistry + from litellm.llms.openai_like.messages.transformation import ( + JSONProviderAnthropicMessagesConfig, + ) + + provider = JSONProviderRegistry.get("meta") + assert provider is not None + cfg = JSONProviderAnthropicMessagesConfig(provider) + + url = cfg.get_complete_url( + api_base=None, + api_key="sk-test", + model="muse-spark-1.1", + optional_params={}, + litellm_params={}, + ) + assert url == "https://api.meta.ai/v1/messages" + + override_url = cfg.get_complete_url( + api_base="https://custom.meta.ai/v1", + api_key="sk-test", + model="muse-spark-1.1", + optional_params={}, + litellm_params={}, + ) + assert override_url == "https://custom.meta.ai/v1/messages" + + def test_api_key_resolved_from_env(self, monkeypatch): + from litellm.llms.openai_like.json_loader import JSONProviderRegistry + from litellm.llms.openai_like.messages.transformation import ( + JSONProviderAnthropicMessagesConfig, + ) + + monkeypatch.setenv("META_API_KEY", "sk-env-key") + provider = JSONProviderRegistry.get("meta") + assert provider is not None + cfg = JSONProviderAnthropicMessagesConfig(provider) + + headers, _ = cfg.validate_anthropic_messages_environment( + headers={}, + model="muse-spark-1.1", + messages=[{"role": "user", "content": "hi"}], + optional_params={}, + litellm_params={}, + api_key=None, + api_base=None, + ) + assert headers["authorization"] == "Bearer sk-env-key" + assert headers["anthropic-version"] == "2023-06-01" + + +class TestMuseSparkModelInfo: + def test_muse_spark_pricing_and_capabilities(self): + info = litellm.get_model_info("meta/muse-spark-1.1") + + assert info["litellm_provider"] == "meta" + assert info["input_cost_per_token"] == 1.25e-06 + assert info["output_cost_per_token"] == 4.25e-06 + assert info["cache_read_input_token_cost"] == 1.5e-07 + assert info["max_input_tokens"] == 1048576 + assert info["supports_reasoning"] is True + assert info["supports_web_search"] is True + assert info["supports_vision"] is True + assert info["supports_function_calling"] is True + assert info["supports_prompt_caching"] is True + + def test_muse_spark_cost_calculation(self): + from litellm import completion_cost + from litellm.types.utils import ModelResponse, Usage + + response = ModelResponse( + model="muse-spark-1.1", + usage=Usage(prompt_tokens=1000, completion_tokens=500, total_tokens=1500), + ) + cost = completion_cost( + completion_response=response, + model="meta/muse-spark-1.1", + custom_llm_provider="meta", + ) + expected = 1000 * 1.25e-06 + 500 * 4.25e-06 + assert abs(cost - expected) < 1e-12 diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_db_credentials.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_db_credentials.py index 8b8b8a363d5..7269774442b 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_db_credentials.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_db_credentials.py @@ -11,6 +11,7 @@ keeps a plain-base64 fallback on read so existing rows continue to work. import base64 import json from datetime import datetime, timedelta, timezone +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock import pytest @@ -63,6 +64,264 @@ def _legacy_row(payload: str): return row +def _identity_server(**overrides): + base = dict( + url="https://up.example.com/mcp", + auth_type="oauth2", + oauth2_flow="authorization_code", + authorization_url="https://idp.example.com/authorize", + token_url="https://idp.example.com/token", + registration_url="https://idp.example.com/register", + credentials={"client_id": "cid", "client_secret": "csec", "scopes": ["a"]}, + server_name="srv", + description="d", + ) + base.update(overrides) + return SimpleNamespace(**base) + + +@pytest.mark.parametrize( + "overrides", + [ + {"url": "https://other.example.com/mcp"}, + {"spec_path": "https://up.example.com/openapi.json"}, + {"auth_type": "oauth_delegate"}, + {"oauth2_flow": "client_credentials"}, + {"authorization_url": "https://other.example.com/authorize"}, + {"token_url": "https://other.example.com/token"}, + {"registration_url": "https://other.example.com/register"}, + {"credentials": {"client_id": "new", "client_secret": "csec", "scopes": ["a"]}}, + {"credentials": {"client_id": "cid", "client_secret": "rotated", "scopes": ["a"]}}, + {"credentials": {"client_id": "cid", "client_secret": "csec", "scopes": ["b"]}}, + ], +) +def test_mcp_oauth_token_identity_changes_on_mint_relevant_fields(overrides): + from litellm.proxy._experimental.mcp_server.db import mcp_oauth_token_identity + + assert mcp_oauth_token_identity(_identity_server()) != mcp_oauth_token_identity(_identity_server(**overrides)) + + +@pytest.mark.parametrize( + "overrides", + [ + {"server_name": "renamed"}, + {"description": "changed"}, + ], +) +def test_mcp_oauth_token_identity_stable_on_non_mint_fields(overrides): + from litellm.proxy._experimental.mcp_server.db import mcp_oauth_token_identity + + assert mcp_oauth_token_identity(_identity_server()) == mcp_oauth_token_identity(_identity_server(**overrides)) + + +def _encrypted_creds_json(client_id: str = "cid", client_secret: str = "csec") -> str: + from litellm.proxy._experimental.mcp_server.db import encrypt_credentials + + encrypted = encrypt_credentials( + credentials={"client_id": client_id, "client_secret": client_secret, "scopes": ["a"]}, + encryption_key=None, + ) + return json.dumps(encrypted) + + +def test_mcp_oauth_token_identity_stable_across_reencryption(): + """Stored client_id/client_secret are NaCl-encrypted with a fresh nonce on every write, so two + saves of the SAME plaintext produce different ciphertext. The identity must compare decrypted + values; comparing ciphertext would flag every routine save as a mint-relevant change and purge + per-user tokens that are still valid.""" + from litellm.proxy._experimental.mcp_server.db import mcp_oauth_token_identity + + first = _encrypted_creds_json() + second = _encrypted_creds_json() + assert first != second + + assert mcp_oauth_token_identity(_identity_server(credentials=first)) == mcp_oauth_token_identity( + _identity_server(credentials=second) + ) + + +def test_mcp_oauth_token_identity_detects_change_under_encryption(): + from litellm.proxy._experimental.mcp_server.db import mcp_oauth_token_identity + + unchanged = _identity_server(credentials=_encrypted_creds_json()) + changed = _identity_server(credentials=_encrypted_creds_json(client_id="other")) + assert mcp_oauth_token_identity(unchanged) != mcp_oauth_token_identity(changed) + + +def _oauth_row(user_id: str, server_id: str = "srv-1"): + """A stored per-user OAuth token row (payload tagged type=oauth2, legacy plain-base64 encoding).""" + row = _legacy_row(json.dumps({"type": "oauth2", "access_token": "tok-" + user_id})) + row.user_id = user_id + row.server_id = server_id + return row + + +def _byok_row(user_id: str, server_id: str = "srv-1"): + """A stored BYOK API key row: the same column, but the payload is a plain string, not OAuth JSON.""" + row = _legacy_row("sk-byok-" + user_id) + row.user_id = user_id + row.server_id = server_id + return row + + +@pytest.mark.asyncio +async def test_purge_user_oauth_credentials_for_server_invalidates_each_user(): + """The purge must route each (user, server) row through the invalidator exactly once.""" + from litellm.proxy._experimental.mcp_server.db import purge_user_oauth_credentials_for_server + + prisma = MagicMock() + prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[_oauth_row("alice"), _oauth_row("bob")]) + prisma.db.litellm_mcpusercredentials.delete_many = AsyncMock(return_value=2) + + invalidations = [] + + async def record_invalidation(user_id: str, server_id: str) -> None: + invalidations.append((user_id, server_id)) + + purged = await purge_user_oauth_credentials_for_server(prisma, "srv-1", invalidate_token_cache=record_invalidation) + + assert purged == 2 + prisma.db.litellm_mcpusercredentials.delete_many.assert_awaited_once_with( + where={"server_id": "srv-1", "user_id": {"in": ["alice", "bob"]}} + ) + assert set(invalidations) == {("alice", "srv-1"), ("bob", "srv-1")} + + +@pytest.mark.asyncio +async def test_purge_user_oauth_credentials_for_server_spares_byok_rows(): + """Regression: the purge used to delete_many on server_id alone, wiping BYOK API keys that share + the LiteLLM_MCPUserCredentials table. Only rows holding an OAuth2 payload may be deleted (one + batched query filtered to their user_ids), and only their users' token caches invalidated.""" + from litellm.proxy._experimental.mcp_server.db import purge_user_oauth_credentials_for_server + + prisma = MagicMock() + prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[_byok_row("carol"), _oauth_row("alice")]) + prisma.db.litellm_mcpusercredentials.delete_many = AsyncMock(return_value=1) + + invalidations = [] + + async def record_invalidation(user_id: str, server_id: str) -> None: + invalidations.append((user_id, server_id)) + + purged = await purge_user_oauth_credentials_for_server(prisma, "srv-1", invalidate_token_cache=record_invalidation) + + assert purged == 1 + prisma.db.litellm_mcpusercredentials.delete_many.assert_awaited_once_with( + where={"server_id": "srv-1", "user_id": {"in": ["alice"]}} + ) + assert invalidations == [("alice", "srv-1")] + + +@pytest.mark.asyncio +async def test_purge_user_oauth_credentials_for_server_all_byok_is_noop(): + """An api_key (BYOK-only) server whose identity tuple changes (e.g. its url) must purge nothing.""" + from litellm.proxy._experimental.mcp_server.db import purge_user_oauth_credentials_for_server + + prisma = MagicMock() + prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[_byok_row("carol"), _byok_row("dave")]) + prisma.db.litellm_mcpusercredentials.delete_many = AsyncMock() + + purged = await purge_user_oauth_credentials_for_server(prisma, "srv-1") + + assert purged == 0 + prisma.db.litellm_mcpusercredentials.delete_many.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_purge_user_oauth_credentials_for_server_defaults_to_manager_invalidator(monkeypatch): + """When no invalidator is injected, the purge must resolve to the manager's shared + invalidate_user_oauth_token_cache, the single point covering both the legacy per-user token cache + and the v2 per-user OAuth token store; a wrong or no-op default silently leaves every cache + serving tokens minted for the superseded config.""" + from litellm.proxy._experimental.mcp_server import mcp_server_manager + from litellm.proxy._experimental.mcp_server.db import purge_user_oauth_credentials_for_server + + prisma = MagicMock() + prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[_oauth_row("alice")]) + prisma.db.litellm_mcpusercredentials.delete_many = AsyncMock(return_value=1) + + shared_invalidator = AsyncMock() + monkeypatch.setattr( + mcp_server_manager.global_mcp_server_manager, + "invalidate_user_oauth_token_cache", + shared_invalidator, + ) + + purged = await purge_user_oauth_credentials_for_server(prisma, "srv-1") + + assert purged == 1 + shared_invalidator.assert_awaited_once_with("alice", "srv-1") + + +@pytest.mark.asyncio +async def test_purge_user_oauth_credentials_for_server_logs_raced_rows(monkeypatch): + from litellm.proxy._experimental.mcp_server import db as db_module + from litellm.proxy._experimental.mcp_server.db import purge_user_oauth_credentials_for_server + + prisma = MagicMock() + prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[_oauth_row("alice")]) + prisma.db.litellm_mcpusercredentials.delete_many = AsyncMock(return_value=0) + warning = MagicMock() + monkeypatch.setattr(db_module.verbose_proxy_logger, "warning", warning) + + purged = await purge_user_oauth_credentials_for_server(prisma, "srv-1", invalidate_token_cache=AsyncMock()) + + assert purged == 0 + warning.assert_called_once() + + +@pytest.mark.asyncio +async def test_delete_mcp_server_invalidates_cached_tokens_for_enumerated_users(): + """Deleting a server must invalidate each enumerated user's cached per-user token: the caches are + keyed by (user_id, server_id), so a re-created server reusing the same server_id would otherwise + serve tokens minted for the deleted server until TTL.""" + from litellm.proxy._experimental.mcp_server.db import delete_mcp_server + + prisma = MagicMock() + prisma.db.litellm_mcpservertable.delete = AsyncMock(return_value=MagicMock(server_id="srv-1")) + prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[_oauth_row("alice"), _byok_row("bob")]) + prisma.db.litellm_mcpusercredentials.delete_many = AsyncMock(return_value=2) + prisma.db.litellm_mcpuserenvvars.delete_many = AsyncMock(return_value=0) + + invalidations = [] + + async def record_invalidation(user_id: str, server_id: str) -> None: + invalidations.append((user_id, server_id)) + + deleted = await delete_mcp_server(prisma, "srv-1", invalidate_token_cache=record_invalidation) + + assert deleted is not None + assert set(invalidations) == {("alice", "srv-1"), ("bob", "srv-1")} + + +@pytest.mark.asyncio +async def test_delete_mcp_server_returns_none_without_cleanup_when_server_missing(): + from litellm.proxy._experimental.mcp_server.db import delete_mcp_server + + prisma = MagicMock() + prisma.db.litellm_mcpservertable.delete = AsyncMock(return_value=None) + prisma.db.litellm_mcpusercredentials.find_many = AsyncMock() + + deleted = await delete_mcp_server(prisma, "srv-1", invalidate_token_cache=AsyncMock()) + + assert deleted is None + prisma.db.litellm_mcpusercredentials.find_many.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_purge_user_oauth_credentials_for_server_noop_when_empty(): + from litellm.proxy._experimental.mcp_server.db import purge_user_oauth_credentials_for_server + + prisma = MagicMock() + prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[]) + prisma.db.litellm_mcpusercredentials.delete_many = AsyncMock() + + purged = await purge_user_oauth_credentials_for_server(prisma, "srv-1") + + assert purged == 0 + prisma.db.litellm_mcpusercredentials.delete_many.assert_not_awaited() + + def _stored_value(prisma) -> str: """Pull the credential_b64 value passed to the most recent upsert call.""" call = prisma.db.litellm_mcpusercredentials.upsert.call_args @@ -136,9 +395,7 @@ async def test_store_user_oauth_credential_does_not_persist_plaintext(): access_token = "ya29.a0AfH6SMBverysecretaccesstoken" prisma = _make_prisma_with_existing(row=None) - await store_user_oauth_credential( - prisma, "alice", "srv-1", access_token, refresh_token="rfr-xyz" - ) + await store_user_oauth_credential(prisma, "alice", "srv-1", access_token, refresh_token="rfr-xyz") stored = _stored_value(prisma) try: @@ -221,9 +478,7 @@ async def test_byok_guard_rejects_overwriting_encrypted_byok(): encrypted_row = MagicMock() encrypted_row.credential_b64 = _stored_value(prisma) - prisma.db.litellm_mcpusercredentials.find_unique = AsyncMock( - return_value=encrypted_row - ) + prisma.db.litellm_mcpusercredentials.find_unique = AsyncMock(return_value=encrypted_row) with pytest.raises(ValueError, match="could not be verified as an OAuth2"): await store_user_oauth_credential(prisma, "alice", "srv-1", "tok") @@ -265,18 +520,14 @@ async def test_list_oauth_credentials_filters_byok_and_returns_payloads(): "connected_at": "2024-01-01T00:00:00Z", } legacy_row = MagicMock() - legacy_row.credential_b64 = base64.urlsafe_b64encode( - json.dumps(legacy_payload).encode() - ).decode() + legacy_row.credential_b64 = base64.urlsafe_b64encode(json.dumps(legacy_payload).encode()).decode() legacy_row.server_id = "srv-legacy" byok_row = MagicMock() byok_row.credential_b64 = base64.urlsafe_b64encode(b"plain-byok-key").decode() byok_row.server_id = "srv-byok" - prisma.db.litellm_mcpusercredentials.find_many = AsyncMock( - return_value=[encrypted_row, legacy_row, byok_row] - ) + prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[encrypted_row, legacy_row, byok_row]) results = await list_user_oauth_credentials(prisma, "alice") @@ -326,9 +577,7 @@ async def test_rotate_re_encrypts_byok_with_new_key(monkeypatch): prisma.db.litellm_mcpusercredentials.update = AsyncMock() new_master_key = "rotated-salt-key-9999-9999-9999-9999" - await rotate_mcp_user_credentials_master_key( - prisma_client=prisma, new_master_key=new_master_key - ) + await rotate_mcp_user_credentials_master_key(prisma_client=prisma, new_master_key=new_master_key) update_call = prisma.db.litellm_mcpusercredentials.update.call_args new_stored = update_call.kwargs["data"]["credential_b64"] @@ -356,19 +605,13 @@ async def test_rotate_migrates_legacy_plaintext_rows(monkeypatch): legacy_row.user_id = "alice" legacy_row.server_id = "srv-legacy" legacy_row.credential_b64 = base64.urlsafe_b64encode(b"legacy-plain").decode() - prisma.db.litellm_mcpusercredentials.find_many = AsyncMock( - return_value=[legacy_row] - ) + prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[legacy_row]) prisma.db.litellm_mcpusercredentials.update = AsyncMock() new_key = "another-rotation-key-aaaa-bbbb-cccc-dddd" - await rotate_mcp_user_credentials_master_key( - prisma_client=prisma, new_master_key=new_key - ) + await rotate_mcp_user_credentials_master_key(prisma_client=prisma, new_master_key=new_key) - new_stored = prisma.db.litellm_mcpusercredentials.update.call_args.kwargs["data"][ - "credential_b64" - ] + new_stored = prisma.db.litellm_mcpusercredentials.update.call_args.kwargs["data"]["credential_b64"] monkeypatch.setenv("LITELLM_SALT_KEY", new_key) assert ( decrypt_value_helper( @@ -396,14 +639,10 @@ async def test_rotate_skips_undecodable_rows(): good_row.server_id = "srv-ok" good_row.credential_b64 = base64.urlsafe_b64encode(b"good-byok").decode() - prisma.db.litellm_mcpusercredentials.find_many = AsyncMock( - return_value=[bad_row, good_row] - ) + prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[bad_row, good_row]) prisma.db.litellm_mcpusercredentials.update = AsyncMock() - await rotate_mcp_user_credentials_master_key( - prisma_client=prisma, new_master_key="new-key-xxxx" - ) + await rotate_mcp_user_credentials_master_key(prisma_client=prisma, new_master_key="new-key-xxxx") # Only one update call — the good row. assert prisma.db.litellm_mcpusercredentials.update.call_count == 1 @@ -419,9 +658,7 @@ def _oauth_cred(access_token="at-live", refresh_token=None, expires_in_seconds=N if refresh_token is not None: cred["refresh_token"] = refresh_token if expires_in_seconds is not None: - cred["expires_at"] = ( - datetime.now(timezone.utc) + timedelta(seconds=expires_in_seconds) - ).isoformat() + cred["expires_at"] = (datetime.now(timezone.utc) + timedelta(seconds=expires_in_seconds)).isoformat() return cred @@ -438,12 +675,7 @@ def test_expiry_buffer_treats_soon_to_expire_as_expired(): cred = _oauth_cred(expires_in_seconds=30) assert is_oauth_credential_expired(cred, buffer_seconds=60) is True # A token comfortably beyond the buffer stays valid. - assert ( - is_oauth_credential_expired( - _oauth_cred(expires_in_seconds=600), buffer_seconds=60 - ) - is False - ) + assert is_oauth_credential_expired(_oauth_cred(expires_in_seconds=600), buffer_seconds=60) is False def test_expiry_past_is_expired_regardless_of_buffer(): @@ -465,9 +697,7 @@ async def test_resolve_returns_valid_token_without_refreshing(monkeypatch): refresh = AsyncMock() monkeypatch.setattr(db_mod, "refresh_user_oauth_token", refresh) - cred = _oauth_cred( - access_token="at-live", refresh_token="rt-1", expires_in_seconds=600 - ) + cred = _oauth_cred(access_token="at-live", refresh_token="rt-1", expires_in_seconds=600) result = await resolve_valid_user_oauth_token( user_id="alice", server=MagicMock(), cred=cred, prisma_client=MagicMock() ) @@ -483,15 +713,11 @@ async def test_resolve_refreshes_expired_token_with_refresh_token(monkeypatch): # new token rather than returning None (which left the UI tool list empty). import litellm.proxy._experimental.mcp_server.db as db_mod - refreshed = _oauth_cred( - access_token="at-fresh", refresh_token="rt-2", expires_in_seconds=3600 - ) + refreshed = _oauth_cred(access_token="at-fresh", refresh_token="rt-2", expires_in_seconds=3600) refresh = AsyncMock(return_value=refreshed) monkeypatch.setattr(db_mod, "refresh_user_oauth_token", refresh) - expired = _oauth_cred( - access_token="at-dead", refresh_token="rt-1", expires_in_seconds=-5 - ) + expired = _oauth_cred(access_token="at-dead", refresh_token="rt-1", expires_in_seconds=-5) result = await resolve_valid_user_oauth_token( user_id="alice", server=MagicMock(), cred=expired, prisma_client=MagicMock() ) @@ -510,9 +736,7 @@ async def test_resolve_refreshes_token_expiring_within_buffer(monkeypatch): refresh = AsyncMock(return_value=refreshed) monkeypatch.setattr(db_mod, "refresh_user_oauth_token", refresh) - soon = _oauth_cred( - access_token="at-soon", refresh_token="rt-1", expires_in_seconds=30 - ) + soon = _oauth_cred(access_token="at-soon", refresh_token="rt-1", expires_in_seconds=30) result = await resolve_valid_user_oauth_token( user_id="alice", server=MagicMock(), cred=soon, prisma_client=MagicMock() ) @@ -546,9 +770,7 @@ async def test_resolve_returns_none_when_refresh_fails(monkeypatch): refresh = AsyncMock(return_value=None) monkeypatch.setattr(db_mod, "refresh_user_oauth_token", refresh) - expired = _oauth_cred( - access_token="at-dead", refresh_token="rt-1", expires_in_seconds=-5 - ) + expired = _oauth_cred(access_token="at-dead", refresh_token="rt-1", expires_in_seconds=-5) result = await resolve_valid_user_oauth_token( user_id="alice", server=MagicMock(), cred=expired, prisma_client=MagicMock() ) @@ -565,9 +787,7 @@ async def test_resolve_returns_none_for_missing_credential(monkeypatch): monkeypatch.setattr(db_mod, "refresh_user_oauth_token", refresh) assert ( - await resolve_valid_user_oauth_token( - user_id="alice", server=MagicMock(), cred=None, prisma_client=MagicMock() - ) + await resolve_valid_user_oauth_token(user_id="alice", server=MagicMock(), cred=None, prisma_client=MagicMock()) is None ) assert ( @@ -601,19 +821,13 @@ async def test_rotate_user_env_vars_re_encrypts_with_new_key(monkeypatch): encrypted_old = encrypt_value_helper(json.dumps(values)) prisma = MagicMock() - prisma.db.litellm_mcpuserenvvars.find_many = AsyncMock( - return_value=[_env_var_row(encrypted_old)] - ) + prisma.db.litellm_mcpuserenvvars.find_many = AsyncMock(return_value=[_env_var_row(encrypted_old)]) prisma.db.litellm_mcpuserenvvars.update = AsyncMock() new_master_key = "rotated-env-key-1111-2222-3333-4444" - await rotate_mcp_user_env_vars_master_key( - prisma_client=prisma, new_master_key=new_master_key - ) + await rotate_mcp_user_env_vars_master_key(prisma_client=prisma, new_master_key=new_master_key) - new_stored = prisma.db.litellm_mcpuserenvvars.update.call_args.kwargs["data"][ - "values_b64" - ] + new_stored = prisma.db.litellm_mcpuserenvvars.update.call_args.kwargs["data"]["values_b64"] assert new_stored != encrypted_old, "rotation must produce different ciphertext" monkeypatch.setenv("LITELLM_SALT_KEY", new_master_key) @@ -630,18 +844,14 @@ async def test_rotate_user_env_vars_re_encrypts_with_new_key(monkeypatch): async def test_rotate_user_env_vars_skips_undecryptable_rows(): # A corrupt row must be skipped (not overwritten) so recoverable data is # preserved and one bad row does not abort the rest of the rotation. - good = _env_var_row( - encrypt_value_helper(json.dumps({"A": "1"})), server_id="srv-ok" - ) + good = _env_var_row(encrypt_value_helper(json.dumps({"A": "1"})), server_id="srv-ok") bad = _env_var_row("!!! not encrypted !!!", server_id="srv-corrupt") prisma = MagicMock() prisma.db.litellm_mcpuserenvvars.find_many = AsyncMock(return_value=[bad, good]) prisma.db.litellm_mcpuserenvvars.update = AsyncMock() - await rotate_mcp_user_env_vars_master_key( - prisma_client=prisma, new_master_key="new-key-xxxx" - ) + await rotate_mcp_user_env_vars_master_key(prisma_client=prisma, new_master_key="new-key-xxxx") assert prisma.db.litellm_mcpuserenvvars.update.call_count == 1 where = prisma.db.litellm_mcpuserenvvars.update.call_args.kwargs["where"] @@ -669,9 +879,7 @@ async def test_refresh_user_oauth_token_uses_client_secret_basic(monkeypatch): monkeypatch.setattr(db_mod, "get_async_httpx_client", lambda **kwargs: mock_client) monkeypatch.setattr(db_mod, "store_user_oauth_credential", AsyncMock()) - monkeypatch.setattr( - db_mod, "get_user_oauth_credential", AsyncMock(return_value={"access_token": "new-at"}) - ) + monkeypatch.setattr(db_mod, "get_user_oauth_credential", AsyncMock(return_value={"access_token": "new-at"})) result = await db_mod.refresh_user_oauth_token( prisma_client=MagicMock(), @@ -710,9 +918,7 @@ async def test_refresh_user_oauth_token_defaults_to_client_secret_post(monkeypat monkeypatch.setattr(db_mod, "get_async_httpx_client", lambda **kwargs: mock_client) monkeypatch.setattr(db_mod, "store_user_oauth_credential", AsyncMock()) - monkeypatch.setattr( - db_mod, "get_user_oauth_credential", AsyncMock(return_value={"access_token": "new-at"}) - ) + monkeypatch.setattr(db_mod, "get_user_oauth_credential", AsyncMock(return_value={"access_token": "new-at"})) await db_mod.refresh_user_oauth_token( prisma_client=MagicMock(), diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py index bba0eb31cfb..7d25e0ba493 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py @@ -6869,6 +6869,7 @@ async def test_call_tool_with_legacy_db_m2m_server_resolves_oauth2_flow(): (None, None), ("", None), ("not a url", None), + ("http://[::1", None), ], ) def test_redact_mcp_resource_url_strips_credentials(url, expected): diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py index e8fca9ac6ab..a18e1ac2c44 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py @@ -3327,9 +3327,35 @@ class TestMCPServerManager: await manager.invalidate_user_oauth_token_cache("alice", "srv-1") assert store.invalidations == [("alice", "srv-1")] + @pytest.mark.asyncio + async def test_invalidate_user_oauth_token_cache_drops_legacy_cache_too(self): + """A per-user token can be served from the legacy per-user token cache as well as the v2 + store; the shared invalidation must evict both, or the path not evicted keeps serving a + token minted for a replaced credential row until its TTL.""" + + class _Store: + async def fetch(self, user_id: str, server_id: str): + return None + + async def invalidate(self, user_id: str, server_id: str) -> None: + return None + + class _LegacyCache: + def __init__(self) -> None: + self.deletes: list[tuple[str, str]] = [] + + async def delete(self, user_id: str, server_id: str) -> None: + self.deletes.append((user_id, server_id)) + + legacy_cache = _LegacyCache() + manager = MCPServerManager(per_user_oauth_token_store=_Store(), per_user_token_cache=legacy_cache) + await manager.invalidate_user_oauth_token_cache("alice", "srv-1") + assert legacy_cache.deletes == [("alice", "srv-1")] + @pytest.mark.asyncio async def test_invalidate_user_oauth_token_cache_swallows_store_errors(self): - """A cache-drop failure must not fail the credential write that triggered it.""" + """A cache-drop failure must not fail the credential write that triggered it, and the + legacy cache must still be evicted after the v2 store drop fails.""" class _Store: async def fetch(self, user_id: str, server_id: str): @@ -3338,7 +3364,35 @@ class TestMCPServerManager: async def invalidate(self, user_id: str, server_id: str) -> None: raise RuntimeError("redis down") - manager = MCPServerManager(per_user_oauth_token_store=_Store()) + class _LegacyCache: + def __init__(self) -> None: + self.deletes: list[tuple[str, str]] = [] + + async def delete(self, user_id: str, server_id: str) -> None: + self.deletes.append((user_id, server_id)) + + legacy_cache = _LegacyCache() + manager = MCPServerManager(per_user_oauth_token_store=_Store(), per_user_token_cache=legacy_cache) + await manager.invalidate_user_oauth_token_cache("alice", "srv-1") + assert legacy_cache.deletes == [("alice", "srv-1")] + + @pytest.mark.asyncio + async def test_invalidate_user_oauth_token_cache_swallows_legacy_cache_errors(self): + """The legacy cache drop is best-effort like the v2 drop: a failure must be logged, never + raised into the credential write that triggered the invalidation.""" + + class _Store: + async def fetch(self, user_id: str, server_id: str): + return None + + async def invalidate(self, user_id: str, server_id: str) -> None: + return None + + class _RaisingLegacyCache: + async def delete(self, user_id: str, server_id: str) -> None: + raise RuntimeError("redis down") + + manager = MCPServerManager(per_user_oauth_token_store=_Store(), per_user_token_cache=_RaisingLegacyCache()) await manager.invalidate_user_oauth_token_cache("alice", "srv-1") @pytest.mark.asyncio diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index d623149ff6a..204e6a671e3 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -2595,6 +2595,135 @@ def test_org_admin_of_multiple_orgs_can_operate_on_both(): assert _user_is_org_admin({"organizations": ["org-A", "org-B"]}, user_obj) is True +# ── LIT-4221: /team/update org-context resolution from team_id ──────────────── +from litellm.proxy.auth.auth_checks_organization import ( + add_team_org_context_to_request_body, +) + + +@pytest.mark.asyncio +async def test_add_team_org_context_resolves_org_from_team(): + """For /team/update with only team_id, the target team's org is resolved and + injected so the org-admin route gate can see it. This is what lets an org + admin update a team budget from the Hub UI, which sends team_id, not + organization_id (LIT-4221).""" + + async def fetch(team_id: str): + assert team_id == "team-1" + return "org-1" + + out = await add_team_org_context_to_request_body( + route="/team/update", + request_body={"team_id": "team-1", "max_budget": 42}, + fetch_team_org_id=fetch, + ) + assert out == {"team_id": "team-1", "max_budget": 42, "organization_id": "org-1"} + + +@pytest.mark.asyncio +async def test_add_team_org_context_noop_when_org_id_already_present(): + """If the caller already passed organization_id, no lookup happens and the + body is returned unchanged.""" + + async def fetch(team_id: str): + raise AssertionError("must not resolve when organization_id is present") + + body = {"team_id": "team-1", "organization_id": "org-explicit"} + out = await add_team_org_context_to_request_body( + route="/team/update", request_body=body, fetch_team_org_id=fetch + ) + assert out == body + + +@pytest.mark.asyncio +async def test_add_team_org_context_noop_for_other_routes(): + """Only /team/update opts into org resolution; other routes are untouched.""" + + async def fetch(team_id: str): + raise AssertionError("must not resolve for a non-opted-in route") + + body = {"team_id": "team-1"} + out = await add_team_org_context_to_request_body( + route="/team/delete", request_body=body, fetch_team_org_id=fetch + ) + assert out == body + + +@pytest.mark.asyncio +async def test_add_team_org_context_noop_when_team_has_no_org(): + """A standalone team (no org) resolves to None, so nothing is injected and + the org-admin branch stays unreachable (no blanket access).""" + + async def fetch(team_id: str): + return None + + body = {"team_id": "team-1"} + out = await add_team_org_context_to_request_body( + route="/team/update", request_body=body, fetch_team_org_id=fetch + ) + assert out == body + + +def test_team_update_gate_allows_org_admin_with_resolved_org(): + """Post-resolution (organization_id present), an org admin of that org clears + the gate for /team/update.""" + user_obj = _make_org_admin_user("org-1") + valid_token = UserAPIKeyAuth(user_id="org-admin-user", user_role=LitellmUserRoles.INTERNAL_USER.value) + request = MagicMock(spec=Request) + request.method = "POST" + request.query_params = {} + + RouteChecks.non_proxy_admin_allowed_routes_check( + user_obj=user_obj, + _user_role=LitellmUserRoles.INTERNAL_USER.value, + route="/team/update", + request=request, + valid_token=valid_token, + request_data={"team_id": "team-1", "organization_id": "org-1"}, + ) + + +def test_team_update_gate_rejects_without_org_context(): + """Without organization_id (i.e. resolution found no org, or a non-org-admin), + the gate still rejects /team/update — the fix adds no blanket allow. Guards + against re-widening the route (e.g. dropping it into self_managed_routes).""" + user_obj = _make_org_admin_user("org-1") + valid_token = UserAPIKeyAuth(user_id="org-admin-user", user_role=LitellmUserRoles.INTERNAL_USER.value) + request = MagicMock(spec=Request) + request.method = "POST" + request.query_params = {} + + with pytest.raises(Exception): + RouteChecks.non_proxy_admin_allowed_routes_check( + user_obj=user_obj, + _user_role=LitellmUserRoles.INTERNAL_USER.value, + route="/team/update", + request=request, + valid_token=valid_token, + request_data={"team_id": "team-1", "max_budget": 42}, + ) + + +def test_team_update_gate_rejects_cross_org_admin_with_resolved_org(): + """Even after the target team's org is resolved, an org admin of a DIFFERENT + org is rejected at the gate (no cross-org escalation).""" + user_obj = _make_org_admin_user("org-1") + valid_token = UserAPIKeyAuth(user_id="org-admin-user", user_role=LitellmUserRoles.INTERNAL_USER.value) + request = MagicMock(spec=Request) + request.method = "POST" + request.query_params = {} + + with pytest.raises(Exception): + RouteChecks.non_proxy_admin_allowed_routes_check( + user_obj=user_obj, + _user_role=LitellmUserRoles.INTERNAL_USER.value, + route="/team/update", + request=request, + valid_token=valid_token, + request_data={"team_id": "team-1", "organization_id": "org-2"}, + ) + + @pytest.mark.asyncio async def test_initialize_pass_through_registers_wildcard_for_auth_subpath(): """ diff --git a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py index 86bbce36de3..a02acc02502 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py @@ -5134,3 +5134,93 @@ def test_stamp_oauth2_flow_ignores_non_oauth2(): payload = _oauth2_create_payload(auth_type="none") mgmt_endpoints.stamp_omitted_oauth2_flow(payload) assert payload.oauth2_flow is None + + +async def _run_edit(old_record, updated_record, purge_mock=None): + from litellm.proxy.management_endpoints.mcp_management_endpoints import edit_mcp_server + + server_id = updated_record.server_id + with ( + patch("litellm.proxy.management_endpoints.mcp_management_endpoints.MCP_AVAILABLE", True), + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw", + return_value=MagicMock(), + ), + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.get_mcp_server", + AsyncMock(side_effect=old_record) + if isinstance(old_record, Exception) + else AsyncMock(return_value=old_record), + ), + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.update_mcp_server", + AsyncMock(return_value=updated_record), + ), + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.validate_and_normalize_mcp_server_payload", + autospec=True, + ), + patch("litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager") as mock_manager, + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.purge_user_oauth_credentials_for_server", + purge_mock if purge_mock is not None else AsyncMock(return_value=1), + ) as mock_purge, + ): + mock_manager.update_server = AsyncMock() + mock_manager.reload_servers_from_database = AsyncMock() + payload = UpdateMCPServerRequest(server_id=server_id, alias=updated_record.alias, url=updated_record.url) + user_auth = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN) + result = await edit_mcp_server(payload=payload, user_api_key_dict=user_auth) + return result, mock_purge + + +@pytest.mark.asyncio +async def test_edit_mcp_server_purges_user_tokens_on_mint_relevant_change(): + server_id = str(uuid.uuid4()) + old = generate_mock_mcp_server_db_record(server_id=server_id, url="https://old.example.com/mcp") + updated = generate_mock_mcp_server_db_record(server_id=server_id, url="https://new.example.com/mcp") + + result, mock_purge = await _run_edit(old, updated) + + assert result.server_id == server_id + mock_purge.assert_awaited_once() + assert mock_purge.await_args.args[1] == server_id + + +@pytest.mark.asyncio +async def test_edit_mcp_server_skips_purge_when_identity_unchanged(): + server_id = str(uuid.uuid4()) + old = generate_mock_mcp_server_db_record(server_id=server_id, alias="Before") + updated = generate_mock_mcp_server_db_record(server_id=server_id, alias="After") + + result, mock_purge = await _run_edit(old, updated) + + assert result.server_id == server_id + mock_purge.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_edit_mcp_server_purge_failure_does_not_fail_the_edit(): + """The purge is best-effort: a purge exception after a successful update must be swallowed and + logged, never turned into an error response for an edit whose primary job already succeeded.""" + server_id = str(uuid.uuid4()) + old = generate_mock_mcp_server_db_record(server_id=server_id, url="https://old.example.com/mcp") + updated = generate_mock_mcp_server_db_record(server_id=server_id, url="https://new.example.com/mcp") + + result, mock_purge = await _run_edit(old, updated, purge_mock=AsyncMock(side_effect=RuntimeError("db down"))) + + assert result.server_id == server_id + mock_purge.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_edit_mcp_server_snapshot_failure_skips_purge_but_edit_succeeds(): + """The pre-update snapshot read is advisory (it only feeds the purge decision); a read failure + must skip the stale-token check with a warning, never fail the edit itself.""" + server_id = str(uuid.uuid4()) + updated = generate_mock_mcp_server_db_record(server_id=server_id, url="https://new.example.com/mcp") + + result, mock_purge = await _run_edit(RuntimeError("db read failed"), updated) + + assert result.server_id == server_id + mock_purge.assert_not_awaited() diff --git a/tests/test_litellm/test_muse_spark_1_1_model_metadata.py b/tests/test_litellm/test_muse_spark_1_1_model_metadata.py new file mode 100644 index 00000000000..540b97884dc --- /dev/null +++ b/tests/test_litellm/test_muse_spark_1_1_model_metadata.py @@ -0,0 +1,63 @@ +import json +from pathlib import Path + +from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider + +MUSE_SPARK_MODEL = "meta/muse-spark-1.1" + + +def test_muse_spark_1_1_model_info(): + json_path = Path(__file__).parents[2] / "model_prices_and_context_window.json" + with open(json_path) as f: + model_cost = json.load(f) + + info = model_cost.get(MUSE_SPARK_MODEL) + assert info is not None, f"{MUSE_SPARK_MODEL} not found in model_prices_and_context_window.json" + + assert info["litellm_provider"] == "meta" + assert info["mode"] == "chat" + + assert info["input_cost_per_token"] == 1.25e-06 + assert info["output_cost_per_token"] == 4.25e-06 + assert info["cache_read_input_token_cost"] == 1.5e-07 + + assert info["max_input_tokens"] == 1048576 + assert info["max_output_tokens"] == 131072 + assert info["max_tokens"] == 131072 + + assert info["supports_function_calling"] is True + assert info["supports_parallel_function_calling"] is True + assert info["supports_prompt_caching"] is True + assert info["supports_reasoning"] is True + assert info["supports_response_schema"] is True + assert info["supports_tool_choice"] is True + assert info["supports_vision"] is True + assert info["supports_pdf_input"] is True + assert info["supports_web_search"] is True + assert info["supports_minimal_reasoning_effort"] is True + assert info["supports_xhigh_reasoning_effort"] is True + + assert info["supported_endpoints"] == ["/v1/chat/completions", "/v1/responses", "/v1/messages"] + assert info["supported_modalities"] == ["text", "image", "video"] + assert info["supported_output_modalities"] == ["text"] + + routed_model, provider, _, api_base = get_llm_provider(model=MUSE_SPARK_MODEL, api_key="sk-test") + assert routed_model == "muse-spark-1.1" + assert provider == "meta" + assert api_base == "https://api.meta.ai/v1" + + +def test_muse_spark_1_1_backup_matches_main(): + """Ensure the bundled model cost map stays in sync with the canonical file.""" + repo_root = Path(__file__).parents[2] + main_path = repo_root / "model_prices_and_context_window.json" + backup_path = repo_root / "litellm" / "model_prices_and_context_window_backup.json" + + with open(main_path) as f: + main_cost = json.load(f) + with open(backup_path) as f: + backup_cost = json.load(f) + + assert backup_cost.get(MUSE_SPARK_MODEL) == main_cost.get(MUSE_SPARK_MODEL), ( + f"{MUSE_SPARK_MODEL} differs between main and backup model cost maps" + ) diff --git a/ui/litellm-dashboard/eslint-metrics.json b/ui/litellm-dashboard/eslint-metrics.json index 42169372dd6..d6c88f704fa 100644 --- a/ui/litellm-dashboard/eslint-metrics.json +++ b/ui/litellm-dashboard/eslint-metrics.json @@ -1,6 +1,6 @@ { - "@typescript-eslint/no-explicit-any": 1980, - "complexity": 128, + "@typescript-eslint/no-explicit-any": 1978, + "complexity": 129, "local/no-large-inline-object-arg": 513, "local/no-long-condition-chain": 233, "max-depth": 59, diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index fe8f182c106..32ab92cbcc9 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -4,32 +4,40 @@ "count": 1 } }, - "src/app/(dashboard)/api-reference/APIReferenceView.tsx": { + "src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx": { + "no-restricted-imports": { + "count": 1 + }, + "react-hooks/set-state-in-effect": { + "count": 1 + } + }, + "src/app/(dashboard)/api-reference/_components/APIReferenceView.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/app/(dashboard)/budgets/components/budget_modal.tsx": { + "src/app/(dashboard)/budgets/_components/budget_modal.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/app/(dashboard)/budgets/components/budget_panel.test.tsx": { + "src/app/(dashboard)/budgets/_components/budget_panel.test.tsx": { "unused-imports/no-unused-imports": { "count": 2 } }, - "src/app/(dashboard)/budgets/components/budget_panel.tsx": { + "src/app/(dashboard)/budgets/_components/budget_panel.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/app/(dashboard)/budgets/components/edit_budget_modal.tsx": { + "src/app/(dashboard)/budgets/_components/edit_budget_modal.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/app/(dashboard)/caching/components/cache_dashboard.tsx": { + "src/app/(dashboard)/caching/_components/cache_dashboard.tsx": { "no-restricted-imports": { "count": 1 }, @@ -40,17 +48,17 @@ "count": 2 } }, - "src/app/(dashboard)/caching/components/cache_health.tsx": { + "src/app/(dashboard)/caching/_components/cache_health.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/app/(dashboard)/caching/components/cache_settings/RedisTypeSelector.tsx": { + "src/app/(dashboard)/caching/_components/cache_settings/RedisTypeSelector.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/app/(dashboard)/caching/components/cache_settings/index.tsx": { + "src/app/(dashboard)/caching/_components/cache_settings/index.tsx": { "no-restricted-imports": { "count": 1 }, @@ -136,36 +144,26 @@ "count": 2 } }, - "src/app/(dashboard)/guardrails-monitor/components/EvaluationSettingsModal.tsx": { + "src/app/(dashboard)/guardrails-monitor/_components/EvaluationSettingsModal.tsx": { "react-hooks/set-state-in-effect": { "count": 1 } }, - "src/app/(dashboard)/guardrails-monitor/components/GuardrailDetail.tsx": { + "src/app/(dashboard)/guardrails-monitor/_components/GuardrailDetail.tsx": { "no-nested-ternary": { "count": 3 } }, - "src/app/(dashboard)/guardrails-monitor/components/GuardrailsMonitorView.tsx": { + "src/app/(dashboard)/guardrails-monitor/_components/GuardrailsMonitorView.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/app/(dashboard)/guardrails-monitor/components/GuardrailsOverview.tsx": { + "src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.tsx": { "no-nested-ternary": { "count": 8 } }, - "src/app/(dashboard)/guardrails-monitor/components/ScoreChart.test.tsx": { - "react/display-name": { - "count": 1 - } - }, - "src/app/(dashboard)/guardrails-monitor/components/ScoreChart.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails.ts": { "no-restricted-syntax": { "count": 1 @@ -326,7 +324,7 @@ "count": 2 } }, - "src/app/(dashboard)/memory/components/MemoryView.tsx": { + "src/app/(dashboard)/memory/_components/MemoryView.tsx": { "react-hooks/set-state-in-effect": { "count": 1 } @@ -373,6 +371,22 @@ "count": 1 } }, + "src/app/(dashboard)/old-usage/_components/usage.tsx": { + "no-restricted-imports": { + "count": 2 + }, + "react-hooks/immutability": { + "count": 1 + }, + "react-hooks/purity": { + "count": 1 + } + }, + "src/app/(dashboard)/organizations/_components/organizations.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "src/app/(dashboard)/playground/components/chat_ui/AdditionalModelSettings.tsx": { "no-restricted-imports": { "count": 1 @@ -522,7 +536,7 @@ "count": 1 } }, - "src/app/(dashboard)/projects/components/ProjectDetailsPage.tsx": { + "src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx": { "no-nested-ternary": { "count": 3 }, @@ -530,17 +544,17 @@ "count": 1 } }, - "src/app/(dashboard)/projects/components/ProjectKeysSection.tsx": { + "src/app/(dashboard)/projects/_components/ProjectKeysSection.tsx": { "react-hooks/set-state-in-effect": { "count": 1 } }, - "src/app/(dashboard)/projects/components/ProjectModals/ProjectBaseForm.tsx": { + "src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.tsx": { "react-hooks/set-state-in-effect": { "count": 2 } }, - "src/app/(dashboard)/projects/components/ProjectsPage.tsx": { + "src/app/(dashboard)/projects/_components/ProjectsPage.tsx": { "react-hooks/set-state-in-effect": { "count": 1 } @@ -649,6 +663,14 @@ "count": 1 } }, + "src/app/(dashboard)/router-settings/_components/general_settings.tsx": { + "no-nested-ternary": { + "count": 3 + }, + "no-restricted-imports": { + "count": 2 + } + }, "src/app/(dashboard)/search-tools/_components/CreateSearchTools.tsx": { "no-restricted-imports": { "count": 1 @@ -851,14 +873,6 @@ "count": 1 } }, - "src/components/AdminPanel.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, "src/components/CreateUserButton.tsx": { "no-restricted-imports": { "count": 1 @@ -1520,14 +1534,6 @@ "count": 1 } }, - "src/components/general_settings.tsx": { - "no-nested-ternary": { - "count": 3 - }, - "no-restricted-imports": { - "count": 2 - } - }, "src/components/guardrails.tsx": { "react-hooks/set-state-in-effect": { "count": 1 @@ -2028,11 +2034,6 @@ "count": 1 } }, - "src/components/organizations.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/page_utils.test.ts": { "max-nested-callbacks": { "count": 3 @@ -2371,17 +2372,6 @@ "count": 1 } }, - "src/components/usage.tsx": { - "no-restricted-imports": { - "count": 2 - }, - "react-hooks/immutability": { - "count": 1 - }, - "react-hooks/purity": { - "count": 1 - } - }, "src/components/user_agent_activity.tsx": { "no-restricted-imports": { "count": 2 diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json index ae3660f59e9..56c0a4f9500 100644 --- a/ui/litellm-dashboard/package-lock.json +++ b/ui/litellm-dashboard/package-lock.json @@ -34,6 +34,7 @@ "react-json-view-lite": "2.5.0", "react-markdown": "9.1.0", "react-syntax-highlighter": "15.6.6", + "recharts": "3.9.2", "remark-gfm": "4.0.1", "tailwind-merge": "3.4.0", "uuid": "14.0.0" @@ -2927,6 +2928,32 @@ "npm": ">=9.5.0" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.61.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", @@ -3284,6 +3311,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -3804,6 +3843,42 @@ "react-dom": ">=16.6.0" } }, + "node_modules/@tremor/react/node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/@tremor/react/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/@tremor/react/node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@tremor/react/node_modules/tailwind-merge": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", @@ -3814,6 +3889,28 @@ "url": "https://github.com/sponsors/dcastil" } }, + "node_modules/@tremor/react/node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -4069,6 +4166,12 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.60.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz", @@ -6309,6 +6412,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz", + "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -6872,9 +6985,9 @@ } }, "node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, "node_modules/expect-type": { @@ -6901,9 +7014,9 @@ "license": "MIT" }, "node_modules/fast-equals": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", - "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.1.tgz", + "integrity": "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==", "license": "MIT", "engines": { "node": ">=6.0.0" @@ -7688,6 +7801,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "11.1.11", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.11.tgz", + "integrity": "sha512-qzXuyXAkPySAGYkfsAwodDPWT8Zm7/Uo5BNt4BjhMhG5WlWyZZ4wQqnWwdS8kjlQ1Cwu6gjw3A6+0gTQwlyYtw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -11589,7 +11712,6 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, "license": "MIT" }, "node_modules/react-json-view-lite": { @@ -11631,6 +11753,29 @@ "react": ">=18" } }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-smooth": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", @@ -11707,26 +11852,33 @@ } }, "node_modules/recharts": { - "version": "2.15.4", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", - "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.9.2.tgz", + "integrity": "sha512-G4fy+Pk46RaXgwWMh+Nzhyo/lbFAVqXo9gtetlyehe6Ehge9CsgDuOTwQDD+i1+llaLktNBiNq4bhnGlDRXFtw==", "license": "MIT", + "workspaces": [ + "www" + ], "dependencies": { - "clsx": "^2.0.0", - "eventemitter3": "^4.0.1", - "lodash": "^4.17.21", - "react-is": "^18.3.1", - "react-smooth": "^4.0.4", - "recharts-scale": "^0.4.4", - "tiny-invariant": "^1.3.1", - "victory-vendor": "^36.6.8" + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" }, "engines": { - "node": ">=14" + "node": ">=18" }, "peerDependencies": { - "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/recharts-scale": { @@ -11738,12 +11890,6 @@ "decimal.js-light": "^2.4.1" } }, - "node_modules/recharts/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT" - }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -11758,6 +11904,21 @@ "node": ">=8" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -13429,9 +13590,9 @@ } }, "node_modules/victory-vendor": { - "version": "36.9.2", - "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", - "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", "license": "MIT AND ISC", "dependencies": { "@types/d3-array": "^3.0.3", diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json index 1b0ce315e4d..1747a40da56 100644 --- a/ui/litellm-dashboard/package.json +++ b/ui/litellm-dashboard/package.json @@ -50,6 +50,7 @@ "react-json-view-lite": "2.5.0", "react-markdown": "9.1.0", "react-syntax-highlighter": "15.6.6", + "recharts": "3.9.2", "remark-gfm": "4.0.1", "tailwind-merge": "3.4.0", "uuid": "14.0.0" diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/components/AccessGroupsDetailsPage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/access-groups/components/AccessGroupsDetailsPage.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/components/AccessGroupsDetailsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/access-groups/components/AccessGroupsDetailsPage.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/components/AccessGroupsModal/AccessGroupBaseForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupBaseForm.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/access-groups/components/AccessGroupsModal/AccessGroupBaseForm.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupBaseForm.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/components/AccessGroupsModal/AccessGroupCreateModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupCreateModal.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/access-groups/components/AccessGroupsModal/AccessGroupCreateModal.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupCreateModal.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/components/AccessGroupsModal/AccessGroupEditModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupEditModal.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/access-groups/components/AccessGroupsModal/AccessGroupEditModal.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupEditModal.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/components/AccessGroupsPage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/access-groups/components/AccessGroupsPage.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/components/AccessGroupsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/access-groups/components/AccessGroupsPage.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/components/types.ts b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/types.ts similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/access-groups/components/types.ts rename to ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/types.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/page.tsx index ae4712b826e..4e9f7031c6d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { AccessGroupsPage } from "./components/AccessGroupsPage"; +import { AccessGroupsPage } from "./_components/AccessGroupsPage"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; export default function AccessGroups() { diff --git a/ui/litellm-dashboard/src/components/AdminPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/admin-panel/_components/AdminPanel.test.tsx similarity index 96% rename from ui/litellm-dashboard/src/components/AdminPanel.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/admin-panel/_components/AdminPanel.test.tsx index 7d1d2f46cf1..220db23338e 100644 --- a/ui/litellm-dashboard/src/components/AdminPanel.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/admin-panel/_components/AdminPanel.test.tsx @@ -8,34 +8,34 @@ const mockGetAllowedIPs = vi.fn(); const mockAddAllowedIP = vi.fn(); const mockDeleteAllowedIP = vi.fn(); -vi.mock("./networking", () => ({ +vi.mock("@/components/networking", () => ({ getSSOSettings: (...args: unknown[]) => mockGetSSOSettings(...args), getAllowedIPs: (...args: unknown[]) => mockGetAllowedIPs(...args), addAllowedIP: (...args: unknown[]) => mockAddAllowedIP(...args), deleteAllowedIP: (...args: unknown[]) => mockDeleteAllowedIP(...args), })); -vi.mock("./constants", () => ({ +vi.mock("@/components/constants", () => ({ useBaseUrl: () => "http://localhost:4000", })); -vi.mock("./Settings/AdminSettings/SSOSettings/SSOSettings", () => ({ +vi.mock("@/components/Settings/AdminSettings/SSOSettings/SSOSettings", () => ({ default: () =>
SSO Settings
, })); -vi.mock("./Settings/AdminSettings/UISettings/UISettings", () => ({ +vi.mock("@/components/Settings/AdminSettings/UISettings/UISettings", () => ({ default: () =>
UI Settings
, })); -vi.mock("./SCIM", () => ({ +vi.mock("@/components/SCIM", () => ({ default: () =>
SCIM Config
, })); -vi.mock("./SSOModals", () => ({ +vi.mock("@/components/SSOModals", () => ({ default: () =>
SSO Modals
, })); -vi.mock("./UIAccessControlForm", () => ({ +vi.mock("@/components/UIAccessControlForm", () => ({ default: () =>
UI Access Control Form
, })); diff --git a/ui/litellm-dashboard/src/components/AdminPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx similarity index 93% rename from ui/litellm-dashboard/src/components/AdminPanel.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx index 7867c184ed2..611efd6a588 100644 --- a/ui/litellm-dashboard/src/components/AdminPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx @@ -16,18 +16,18 @@ import { } from "@tremor/react"; import { Alert, Button as Button2, Form, Input, Modal, Space, Tabs, Typography } from "antd"; import React, { useEffect, useState } from "react"; -import NewBadge from "./common_components/NewBadge"; -import { useBaseUrl } from "./constants"; -import NotificationsManager from "./molecules/notifications_manager"; -import { addAllowedIP, deleteAllowedIP, getAllowedIPs, getSSOSettings } from "./networking"; -import SCIMConfig from "./SCIM"; -import LoggingSettings from "./Settings/AdminSettings/LoggingSettings/LoggingSettings"; -import SSOSettings from "./Settings/AdminSettings/SSOSettings/SSOSettings"; -import UISettings from "./Settings/AdminSettings/UISettings/UISettings"; -import HashicorpVault from "./Settings/AdminSettings/HashicorpVault/HashicorpVault"; -import PluginSettings from "./Settings/AdminSettings/PluginSettings/PluginSettings"; -import SSOModals from "./SSOModals"; -import UIAccessControlForm from "./UIAccessControlForm"; +import NewBadge from "@/components/common_components/NewBadge"; +import { useBaseUrl } from "@/components/constants"; +import NotificationsManager from "@/components/molecules/notifications_manager"; +import { addAllowedIP, deleteAllowedIP, getAllowedIPs, getSSOSettings } from "@/components/networking"; +import SCIMConfig from "@/components/SCIM"; +import LoggingSettings from "@/components/Settings/AdminSettings/LoggingSettings/LoggingSettings"; +import SSOSettings from "@/components/Settings/AdminSettings/SSOSettings/SSOSettings"; +import UISettings from "@/components/Settings/AdminSettings/UISettings/UISettings"; +import HashicorpVault from "@/components/Settings/AdminSettings/HashicorpVault/HashicorpVault"; +import PluginSettings from "@/components/Settings/AdminSettings/PluginSettings/PluginSettings"; +import SSOModals from "@/components/SSOModals"; +import UIAccessControlForm from "@/components/UIAccessControlForm"; const { Title, Paragraph, Text } = Typography; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/admin-panel/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/admin-panel/page.tsx index aac835b02fc..47076acc9f0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/admin-panel/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/admin-panel/page.tsx @@ -1,6 +1,6 @@ "use client"; -import AdminPanel from "@/components/AdminPanel"; +import AdminPanel from "./_components/AdminPanel"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import useProxySettings from "@/app/(dashboard)/hooks/proxySettings/useProxySettings"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/api-reference/APIReferenceView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/api-reference/_components/APIReferenceView.test.tsx similarity index 97% rename from ui/litellm-dashboard/src/app/(dashboard)/api-reference/APIReferenceView.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/api-reference/_components/APIReferenceView.test.tsx index a73973bd742..66fa0dfa63f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/api-reference/APIReferenceView.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/api-reference/_components/APIReferenceView.test.tsx @@ -2,7 +2,7 @@ import { render } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import APIReferenceView from "./APIReferenceView"; -vi.mock("./components/CodeBlock", () => ({ +vi.mock("@/components/CodeBlock", () => ({ __esModule: true, default: ({ code }: { code: string }) =>
{code}
, })); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/api-reference/APIReferenceView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/api-reference/_components/APIReferenceView.tsx similarity index 97% rename from ui/litellm-dashboard/src/app/(dashboard)/api-reference/APIReferenceView.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/api-reference/_components/APIReferenceView.tsx index 5861cc87e5b..333bd1cad13 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/api-reference/APIReferenceView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/api-reference/_components/APIReferenceView.tsx @@ -1,8 +1,8 @@ "use client"; import React from "react"; import { Text, Tab, TabGroup, TabList, TabPanel, TabPanels, Grid } from "@tremor/react"; -import CodeBlock from "./components/CodeBlock"; -import DocLink from "@/app/(dashboard)/api-reference/components/DocLink"; +import CodeBlock from "@/components/CodeBlock"; +import DocLink from "./DocLink"; interface ApiRefProps { proxySettings: { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/api-reference/components/DocLink.tsx b/ui/litellm-dashboard/src/app/(dashboard)/api-reference/_components/DocLink.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/api-reference/components/DocLink.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/api-reference/_components/DocLink.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/api-reference/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/api-reference/page.tsx index 42cf094f0bb..d7c977b0870 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/api-reference/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/api-reference/page.tsx @@ -1,6 +1,6 @@ "use client"; -import APIReferenceView from "@/app/(dashboard)/api-reference/APIReferenceView"; +import APIReferenceView from "./_components/APIReferenceView"; import { DeprecationBanner } from "@/components/DeprecationBanner"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import useProxySettings from "@/app/(dashboard)/hooks/proxySettings/useProxySettings"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/components/budget_modal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_modal.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/budgets/components/budget_modal.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_modal.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/components/budget_panel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/budgets/components/budget_panel.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/components/budget_panel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/budgets/components/budget_panel.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/components/constants.ts b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/constants.ts similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/budgets/components/constants.ts rename to ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/constants.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/components/edit_budget_modal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/edit_budget_modal.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/budgets/components/edit_budget_modal.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/edit_budget_modal.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/page.tsx index 547699411e7..ca34589a679 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/budgets/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/budgets/page.tsx @@ -1,6 +1,6 @@ "use client"; -import BudgetPanel from "./components/budget_panel"; +import BudgetPanel from "./_components/budget_panel"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; export default function Budgets() { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/components/cache_dashboard.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/caching/components/cache_dashboard.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/components/cache_health.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_health.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/caching/components/cache_health.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_health.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/components/cache_settings/CacheFieldSection.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/CacheFieldSection.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/caching/components/cache_settings/CacheFieldSection.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/CacheFieldSection.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/components/cache_settings/CacheFormField.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/CacheFormField.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/caching/components/cache_settings/CacheFormField.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/CacheFormField.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/components/cache_settings/RedisTypeSelector.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/RedisTypeSelector.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/caching/components/cache_settings/RedisTypeSelector.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/RedisTypeSelector.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/components/cache_settings/RedisTypeSelector.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/RedisTypeSelector.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/caching/components/cache_settings/RedisTypeSelector.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/RedisTypeSelector.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/components/cache_settings/cacheSettingsFields.ts b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/cacheSettingsFields.ts similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/caching/components/cache_settings/cacheSettingsFields.ts rename to ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/cacheSettingsFields.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/components/cache_settings/cacheSettingsUtils.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/cacheSettingsUtils.test.ts similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/caching/components/cache_settings/cacheSettingsUtils.test.ts rename to ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/cacheSettingsUtils.test.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/components/cache_settings/cacheSettingsUtils.ts b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/cacheSettingsUtils.ts similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/caching/components/cache_settings/cacheSettingsUtils.ts rename to ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/cacheSettingsUtils.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/components/cache_settings/index.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/index.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/caching/components/cache_settings/index.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/index.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/components/cache_settings/index.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/index.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/caching/components/cache_settings/index.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_settings/index.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/components/response_time_indicator.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/response_time_indicator.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/caching/components/response_time_indicator.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/caching/_components/response_time_indicator.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/page.tsx index 0ef88ec9eb5..33f3e81c689 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/caching/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/page.tsx @@ -1,6 +1,6 @@ "use client"; -import CacheDashboard from "./components/cache_dashboard"; +import CacheDashboard from "./_components/cache_dashboard"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; export default function Caching() { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/how_it_works.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/how_it_works.test.tsx index 711a8795f15..a574f4b628e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/how_it_works.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/how_it_works.test.tsx @@ -5,7 +5,7 @@ import userEvent from "@testing-library/user-event"; import { renderWithProviders } from "../../../../../tests/test-utils"; import HowItWorks from "./how_it_works"; -vi.mock("@/app/(dashboard)/api-reference/components/CodeBlock", () => ({ +vi.mock("@/components/CodeBlock", () => ({ default: ({ code }: { code: string }) =>
{code}
, })); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/how_it_works.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/how_it_works.tsx index 79abf6baa31..5fa27551d16 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/how_it_works.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/components/how_it_works.tsx @@ -1,6 +1,6 @@ import React, { useState, useMemo } from "react"; import { Text, TextInput } from "@tremor/react"; -import CodeBlock from "@/app/(dashboard)/api-reference/components/CodeBlock"; +import CodeBlock from "@/components/CodeBlock"; const HowItWorks: React.FC = () => { const [responseCost, setResponseCost] = useState(""); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/components/EvaluationSettingsModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/EvaluationSettingsModal.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/components/EvaluationSettingsModal.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/EvaluationSettingsModal.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/components/GuardrailConfig.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailConfig.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/components/GuardrailConfig.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailConfig.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/components/GuardrailConfig.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailConfig.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/components/GuardrailConfig.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailConfig.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/components/GuardrailDetail.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailDetail.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/components/GuardrailDetail.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailDetail.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/components/GuardrailsMonitorView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsMonitorView.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/components/GuardrailsMonitorView.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsMonitorView.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/components/GuardrailsMonitorView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsMonitorView.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/components/GuardrailsMonitorView.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsMonitorView.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/components/GuardrailsOverview.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/components/GuardrailsOverview.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/ScoreChart.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/ScoreChart.test.tsx new file mode 100644 index 00000000000..dba34ea9a86 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/ScoreChart.test.tsx @@ -0,0 +1,42 @@ +import React from "react"; +import { describe, it, expect } from "vitest"; +import { screen } from "@testing-library/react"; +import { renderWithProviders } from "../../../../../tests/test-utils"; +import { ScoreChart } from "./ScoreChart"; + +describe("ScoreChart", () => { + it("should render the title", () => { + renderWithProviders(); + + expect(screen.getByText("Request Outcomes Over Time")).toBeInTheDocument(); + }); + + it("should show empty state when no data is provided", () => { + renderWithProviders(); + + expect(screen.getByText("No chart data for this period")).toBeInTheDocument(); + }); + + it("should show empty state when data is an empty array", () => { + renderWithProviders(); + + expect(screen.getByText("No chart data for this period")).toBeInTheDocument(); + }); + + it("should render the chart when data is provided", () => { + const data = [ + { date: "2026-03-01", passed: 10, blocked: 2 }, + { date: "2026-03-02", passed: 15, blocked: 1 }, + ]; + + const { container } = renderWithProviders(); + + expect(screen.queryByText("No chart data for this period")).not.toBeInTheDocument(); + expect(screen.getByText("passed")).toBeInTheDocument(); + expect(screen.getByText("blocked")).toBeInTheDocument(); + expect(screen.getAllByText(/2026-03-01/).length).toBeGreaterThan(0); + expect(screen.getAllByText(/2026-03-02/).length).toBeGreaterThan(0); + const bars = container.querySelectorAll(".recharts-bar"); + expect(bars).toHaveLength(2); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/ScoreChart.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/ScoreChart.tsx new file mode 100644 index 00000000000..bc11a6fd3e0 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/ScoreChart.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import { BarChart } from "@/components/shared/charts"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +/** + * Overview chart: Request Outcomes Over Time (passed vs blocked). + * Stacked bar chart. Data from usage/overview API (chart array). + */ +interface ScoreChartProps { + data?: Array<{ date: string; passed: number; blocked: number }>; +} + +export function ScoreChart({ data }: ScoreChartProps) { + const chartData = data && data.length > 0 ? data : []; + + return ( + + + Request Outcomes Over Time + + +
+ {chartData.length > 0 ? ( + v.toLocaleString()} + yAxisWidth={48} + showLegend={true} + stack={true} + className="h-full" + /> + ) : ( +
+ No chart data for this period +
+ )} +
+
+
+ ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/components/ScoreChart.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/components/ScoreChart.test.tsx deleted file mode 100644 index 3a36eb9621e..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/components/ScoreChart.test.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React from "react"; -import { describe, it, expect, vi } from "vitest"; -import { screen } from "@testing-library/react"; -import { renderWithProviders } from "../../../../../tests/test-utils"; -import { ScoreChart } from "./ScoreChart"; - -vi.mock("@tremor/react", async (importOriginal) => { - const actual = await importOriginal(); - // Re-apply the global Button/Tooltip overrides from tests/setupTests.ts. A file-level - // vi.mock fully replaces the setup-level mock, so without this the real Tremor Button - // leaks through and its useTooltip(300) schedules a native setTimeout that can fire - // post-teardown -> "window is not defined". - return { - ...actual, - BarChart: ({ data, categories }: { data: any[]; categories: string[] }) => ( -
- {data.map((d, i) => ( - - {d.date}: {categories.map((c) => `${c}=${d[c]}`).join(", ")} - - ))} -
- ), - Button: React.forwardRef(({ children, ...props }, ref) => ( - - )), - Tooltip: ({ children }: { children?: React.ReactNode }) => <>{children}, - }; -}); - -describe("ScoreChart", () => { - it("should render the title", () => { - renderWithProviders(); - - expect(screen.getByText("Request Outcomes Over Time")).toBeInTheDocument(); - }); - - it("should show empty state when no data is provided", () => { - renderWithProviders(); - - expect(screen.getByText("No chart data for this period")).toBeInTheDocument(); - }); - - it("should show empty state when data is an empty array", () => { - renderWithProviders(); - - expect(screen.getByText("No chart data for this period")).toBeInTheDocument(); - }); - - it("should render the chart when data is provided", () => { - const data = [ - { date: "2026-03-01", passed: 10, blocked: 2 }, - { date: "2026-03-02", passed: 15, blocked: 1 }, - ]; - - renderWithProviders(); - - expect(screen.queryByText("No chart data for this period")).not.toBeInTheDocument(); - expect(screen.getByText(/2026-03-01/)).toBeInTheDocument(); - expect(screen.getByText(/2026-03-02/)).toBeInTheDocument(); - }); -}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/components/ScoreChart.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/components/ScoreChart.tsx deleted file mode 100644 index daa6054a552..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/components/ScoreChart.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { BarChart, Card, Title } from "@tremor/react"; -import React from "react"; - -/** - * Overview chart: Request Outcomes Over Time (passed vs blocked). - * Uses Tremor BarChart with stacked data. Data from usage/overview API (chart array). - */ -interface ScoreChartProps { - data?: Array<{ date: string; passed: number; blocked: number }>; -} - -export function ScoreChart({ data }: ScoreChartProps) { - const chartData = data && data.length > 0 ? data : []; - - return ( - - Request Outcomes Over Time -
- {chartData.length > 0 ? ( - v.toLocaleString()} - yAxisWidth={48} - showLegend={true} - stack={true} - /> - ) : ( -
- No chart data for this period -
- )} -
-
- ); -} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/page.tsx index 388ed168f17..0c4e69c2d80 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/page.tsx @@ -1,6 +1,6 @@ "use client"; -import GuardrailsMonitorView from "./components/GuardrailsMonitorView"; +import GuardrailsMonitorView from "./_components/GuardrailsMonitorView"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; export default function GuardrailsMonitor() { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/memory/components/MemoryEditModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryEditModal.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/memory/components/MemoryEditModal.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryEditModal.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/memory/components/MemoryView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryView.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/memory/components/MemoryView.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryView.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/memory/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/memory/page.tsx index 031a027d518..b88996c5396 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/memory/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/memory/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { MemoryView } from "./components/MemoryView"; +import { MemoryView } from "./_components/MemoryView"; import { DeprecationBanner } from "@/components/DeprecationBanner"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; diff --git a/ui/litellm-dashboard/src/components/usage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/old-usage/_components/usage.tsx similarity index 99% rename from ui/litellm-dashboard/src/components/usage.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/old-usage/_components/usage.tsx index 91c12fd1fa2..01f8cb1cd45 100644 --- a/ui/litellm-dashboard/src/components/usage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/old-usage/_components/usage.tsx @@ -14,9 +14,9 @@ import { import React, { useState, useEffect } from "react"; -import ViewUserSpend from "./view_user_spend"; -import { ProxySettings } from "./user_dashboard"; -import UsageDatePicker from "./shared/usage_date_picker"; +import ViewUserSpend from "@/components/view_user_spend"; +import { ProxySettings } from "@/components/user_dashboard"; +import UsageDatePicker from "@/components/shared/usage_date_picker"; import { Grid, Col, @@ -48,8 +48,8 @@ import { adminGlobalActivity, adminGlobalActivityPerModel, getProxyUISettings, -} from "./networking"; -import TopKeyView from "./UsagePage/components/EntityUsage/TopKeyView"; +} from "@/components/networking"; +import TopKeyView from "@/components/UsagePage/components/EntityUsage/TopKeyView"; import { MoneyCell } from "@/components/shared/table_cells"; import { formatNumberWithCommas } from "@/utils/dataUtils"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/old-usage/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/old-usage/page.tsx index cc1f2c35e44..138dd97e5e8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/old-usage/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/old-usage/page.tsx @@ -1,6 +1,6 @@ "use client"; -import Usage from "@/components/usage"; +import Usage from "./_components/usage"; import { DeprecationBanner } from "@/components/DeprecationBanner"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; diff --git a/ui/litellm-dashboard/src/components/organizations.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/organizations.test.tsx similarity index 87% rename from ui/litellm-dashboard/src/components/organizations.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/organizations.test.tsx index 9be31be6170..75a6d30ac2e 100644 --- a/ui/litellm-dashboard/src/components/organizations.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/organizations.test.tsx @@ -3,11 +3,11 @@ import { render } from "@testing-library/react"; import React from "react"; import { describe, expect, it, vi } from "vitest"; -vi.mock("./vector_store_management/VectorStoreSelector", () => ({ +vi.mock("@/components/vector_store_management/VectorStoreSelector", () => ({ __esModule: true, default: () => null, })); -vi.mock("./mcp_server_management/MCPServerSelector", () => ({ +vi.mock("@/components/mcp_server_management/MCPServerSelector", () => ({ __esModule: true, default: () => null, })); diff --git a/ui/litellm-dashboard/src/components/organizations.tsx b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/organizations.tsx similarity index 96% rename from ui/litellm-dashboard/src/components/organizations.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/organizations.tsx index edebc17087a..d3af5b62668 100644 --- a/ui/litellm-dashboard/src/components/organizations.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/organizations.tsx @@ -28,16 +28,21 @@ import { Form, Input, Modal, Select as Select2, Tooltip } from "antd"; import { useQueryClient } from "@tanstack/react-query"; import React, { useState } from "react"; import { DateCell, IdCell, MoneyCell } from "@/components/shared/table_cells"; -import DeleteResourceModal from "./common_components/DeleteResourceModal"; -import TableIconActionButton from "./common_components/IconActionButton/TableIconActionButtons/TableIconActionButton"; -import { getModelDisplayName } from "./key_team_helpers/fetch_available_models_team_key"; -import MCPServerSelector from "./mcp_server_management/MCPServerSelector"; -import { ModelSelect } from "./ModelSelect/ModelSelect"; -import NotificationsManager from "./molecules/notifications_manager"; -import { Organization, organizationCreateCall, organizationDeleteCall, organizationListCall } from "./networking"; -import OrganizationInfoView from "./organization/organization_view"; -import NumericalInput from "./shared/numerical_input"; -import VectorStoreSelector from "./vector_store_management/VectorStoreSelector"; +import DeleteResourceModal from "@/components/common_components/DeleteResourceModal"; +import TableIconActionButton from "@/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton"; +import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key"; +import MCPServerSelector from "@/components/mcp_server_management/MCPServerSelector"; +import { ModelSelect } from "@/components/ModelSelect/ModelSelect"; +import NotificationsManager from "@/components/molecules/notifications_manager"; +import { + Organization, + organizationCreateCall, + organizationDeleteCall, + organizationListCall, +} from "@/components/networking"; +import OrganizationInfoView from "@/components/organization/organization_view"; +import NumericalInput from "@/components/shared/numerical_input"; +import VectorStoreSelector from "@/components/vector_store_management/VectorStoreSelector"; interface OrganizationsTableProps { userRole: string; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/organizations/page.tsx index 87e0faf9cce..649e54f63eb 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/organizations/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/organizations/page.tsx @@ -1,6 +1,6 @@ "use client"; -import OrganizationsTable from "@/components/organizations"; +import OrganizationsTable from "./_components/organizations"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; export default function OrganizationsPage() { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/AgentBuilderView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/AgentBuilderView.tsx index 35f5dcf06c0..d4333b95c62 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/AgentBuilderView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/AgentBuilderView.tsx @@ -11,7 +11,7 @@ import { } from "@ant-design/icons"; import { Button, Input, Modal, Select, Spin, Tabs } from "antd"; import React, { useCallback, useEffect, useState } from "react"; -import CodeBlock from "@/app/(dashboard)/api-reference/components/CodeBlock"; +import CodeBlock from "@/components/CodeBlock"; import NotificationsManager from "@/components/molecules/notifications_manager"; import { keyCreateCall, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectDetailsPage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectDetailsPage.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectDetailsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectDetailsPage.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectKeysSection.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysSection.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectKeysSection.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysSection.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectKeysSection.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysSection.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectKeysSection.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysSection.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectKeysTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysTable.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectKeysTable.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysTable.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectKeysTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysTable.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectKeysTable.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectKeysTable.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectModals/CreateProjectModal.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/CreateProjectModal.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectModals/CreateProjectModal.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/CreateProjectModal.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectModals/CreateProjectModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/CreateProjectModal.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectModals/CreateProjectModal.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/CreateProjectModal.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectModals/EditProjectModal.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/EditProjectModal.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectModals/EditProjectModal.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/EditProjectModal.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectModals/EditProjectModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/EditProjectModal.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectModals/EditProjectModal.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/EditProjectModal.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectModals/ProjectBaseForm.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectModals/ProjectBaseForm.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectModals/ProjectBaseForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectModals/ProjectBaseForm.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectModals/projectFormUtils.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/projectFormUtils.test.ts similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectModals/projectFormUtils.test.ts rename to ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/projectFormUtils.test.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectModals/projectFormUtils.ts b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/projectFormUtils.ts similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectModals/projectFormUtils.ts rename to ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/projectFormUtils.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectsPage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.test.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectsPage.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectsPage.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectsPage.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/page.tsx index 62b67118109..2ba014592c9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { ProjectsPage } from "./components/ProjectsPage"; +import { ProjectsPage } from "./_components/ProjectsPage"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; export default function Projects() { diff --git a/ui/litellm-dashboard/src/components/general_settings.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx similarity index 96% rename from ui/litellm-dashboard/src/components/general_settings.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx index 038547c6e0e..3955e80f5e9 100644 --- a/ui/litellm-dashboard/src/components/general_settings.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx @@ -13,14 +13,14 @@ import { Switch, } from "@tremor/react"; import { TabPanel, TabPanels, TabGroup, TabList, Tab } from "@tremor/react"; -import { getGeneralSettingsCall, updateConfigFieldSetting, deleteConfigFieldSetting } from "./networking"; +import { getGeneralSettingsCall, updateConfigFieldSetting, deleteConfigFieldSetting } from "@/components/networking"; import { InputNumber } from "antd"; import { TrashIcon } from "@heroicons/react/outline"; import { StatusBadge } from "@/components/shared/table_cells"; -import RouterSettings from "./router_settings"; -import Fallbacks from "./Settings/RouterSettings/Fallbacks/Fallbacks"; -import RoutingGroups from "./routing_groups"; +import RouterSettings from "@/components/router_settings"; +import Fallbacks from "@/components/Settings/RouterSettings/Fallbacks/Fallbacks"; +import RoutingGroups from "@/components/routing_groups"; interface GeneralSettingsPageProps { accessToken: string | null; userRole: string | null; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/page.tsx index 46029b529ec..90f41ac58a4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/page.tsx @@ -1,6 +1,6 @@ "use client"; -import GeneralSettings from "@/components/general_settings"; +import GeneralSettings from "./_components/general_settings"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; export default function RouterSettingsPage() { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/api-reference/components/CodeBlock.tsx b/ui/litellm-dashboard/src/components/CodeBlock.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/api-reference/components/CodeBlock.tsx rename to ui/litellm-dashboard/src/components/CodeBlock.tsx diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx index eecbc253b6b..02374a3ffa4 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx @@ -374,6 +374,68 @@ describe("CreateMCPServer", () => { expect(credentials.access_token).toBeUndefined(); }); + it.each([ + ["true_passthrough", "True Passthrough (no LiteLLM auth)"], + ["oauth_delegate", "OAuth Delegate (client-supplied upstream token)"], + ])("persists only tool config on create for %s; the token stays browser-held", async (_authType, optionLabel) => { + oauthHook.tokenResponse = { access_token: "upstream-tok", token_type: "Bearer" }; + await selectHttpTransport(); + + const user = userEvent.setup({ delay: null }); + await user.type(getServerNameInput(), "CF_Server"); + await user.type(screen.getByPlaceholderText("https://your-mcp-server.com"), "https://example.com/mcp"); + + await selectAntOption("Authentication", optionLabel); + + await waitFor(() => expect(oauthHook.onTokenReceived).toBeTruthy()); + await act(async () => { + oauthHook.onTokenReceived!({ access_token: "upstream-tok", token_type: "Bearer" }, undefined); + }); + + fireEvent.click(screen.getByRole("button", { name: "Disable all tools" })); + + // Previewing and configuring must stay stateless: nothing is persisted anywhere (server row, + // per-user DB credential, sessionStorage) until the admin submits. + expect(networking.createMCPServer).not.toHaveBeenCalled(); + expect(networking.storeMCPOAuthUserCredential).not.toHaveBeenCalled(); + expect(setToken).not.toHaveBeenCalled(); + + const createdServer = { + server_id: "new-cf-server", + server_name: "CF_Server", + alias: "CF_Server", + url: "https://example.com/mcp", + transport: "http", + auth_type: _authType, + created_at: "2024-01-01T00:00:00Z", + created_by: "user-1", + updated_at: "2024-01-01T00:00:00Z", + updated_by: "user-1", + }; + vi.mocked(networking.createMCPServer).mockResolvedValue(createdServer); + + const submitButton = screen.getByRole("button", { name: "Add MCP Server" }); + await act(async () => { + fireEvent.click(submitButton); + }); + + await waitFor(() => expect(networking.createMCPServer).toHaveBeenCalledTimes(1)); + const [, payload] = vi.mocked(networking.createMCPServer).mock.calls[0]; + + // Only the tool configuration persists on the server row; the upstream token appears nowhere + // in the create payload and no per-user DB credential is written. The token is committed to + // sessionStorage only, keyed to the created server. + expect(payload.allowed_tools).toEqual([]); + expect(payload.credentials).toBeUndefined(); + expect(JSON.stringify(payload)).not.toContain("upstream-tok"); + expect(networking.storeMCPOAuthUserCredential).not.toHaveBeenCalled(); + expect(setToken).toHaveBeenCalledWith( + "new-cf-server", + expect.objectContaining({ access_token: "upstream-tok" }), + undefined, + ); + }); + it("should not show auth value field when None auth type is selected", async () => { await selectHttpTransport(); @@ -681,6 +743,93 @@ describe("CreateMCPServer", () => { // Asserted in setupOAuthInteractive }); + it("invalidates the held token when the auth mode changes after Authorize & Fetch", async () => { + await setupOAuthInteractive(); + const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com"); + await act(async () => { + fireEvent.change(urlInput, { target: { value: "https://a.example.com/mcp" } }); + }); + act(() => { + oauthHook.onTokenReceived?.({ access_token: "tok-a" }, { clientId: "client-a", clientSecret: "secret-a" }); + }); + oauthHook.reset.mockClear(); + + // Switching the Authentication mode changes the OAuth identity, so the held token is discarded. + await selectAntOption("Authentication", "True Passthrough (no LiteLLM auth)"); + + await waitFor(() => expect(oauthHook.reset).toHaveBeenCalled()); + }); + + it("does NOT invalidate the held token when a non-mint field (server name) changes", async () => { + await setupOAuthInteractive(); + const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com"); + await act(async () => { + fireEvent.change(urlInput, { target: { value: "https://a.example.com/mcp" } }); + }); + act(() => { + oauthHook.onTokenReceived?.({ access_token: "tok-a" }, { clientId: "client-a", clientSecret: "secret-a" }); + }); + oauthHook.reset.mockClear(); + + const nameInput = document.getElementById("server_name") as HTMLInputElement; + await act(async () => { + fireEvent.change(nameInput, { target: { value: "Renamed_Server" } }); + }); + + // server_name is not part of the OAuth identity, so the held token must survive the edit. + await waitFor(() => expect(screen.getAllByRole("button", { name: "Add MCP Server" }).length).toBeGreaterThan(0)); + expect(oauthHook.reset).not.toHaveBeenCalled(); + }); + + it("does not refetch the tool preview with a discarded token after invalidation", async () => { + // Regression: handleFormValuesChange used to publish the pre-reset antd snapshot into + // formValues after clearHeldOAuthToken, so useTestMCPConnection kept the discarded OAuth + // material (the DCR client minted for the old identity) and sent it on the next tool-preview + // request. + await setupOAuthInteractive(); + const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com"); + await act(async () => { + fireEvent.change(urlInput, { target: { value: "https://a.example.com/mcp" } }); + }); + act(() => { + oauthHook.onTokenReceived?.({ access_token: "stale-tok" }, { clientId: "client-a", clientSecret: "secret-a" }); + }); + const nameInput = document.getElementById("server_name") as HTMLInputElement; + await act(async () => { + fireEvent.change(nameInput, { target: { value: "Sync_FormValues" } }); + }); + vi.mocked(networking.testMCPToolsListRequest).mockClear(); + + await selectAntOption("Authentication", "API Key"); + + await waitFor(() => expect(vi.mocked(networking.testMCPToolsListRequest)).toHaveBeenCalled()); + for (const call of vi.mocked(networking.testMCPToolsListRequest).mock.calls) { + expect(call[1]?.credentials?.client_id).not.toBe("client-a"); + expect(call[1]?.credentials?.client_secret).not.toBe("secret-a"); + } + }); + + it("keeps the held token on an http to sse switch with the same url", async () => { + // Same url means the same resource/audience (RFC 8707): the minted token is still valid, so a + // pure transport swap between the two MCP wire protocols must not force a re-authorize. + await setupOAuthInteractive(); + const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com"); + await act(async () => { + fireEvent.change(urlInput, { target: { value: "https://a.example.com/mcp" } }); + }); + act(() => { + oauthHook.onTokenReceived?.({ access_token: "tok-a" }, { clientId: "client-a", clientSecret: "secret-a" }); + }); + oauthHook.reset.mockClear(); + + await selectAntOption("Transport Type", "Server-Sent Events (SSE)"); + + await waitFor(() => { + expect(screen.getByPlaceholderText("https://your-mcp-server.com")).toBeInTheDocument(); + }); + expect(oauthHook.reset).not.toHaveBeenCalled(); + }); + it("includes token_validation in payload when token_validation_json is filled with valid JSON", async () => { vi.mocked(networking.createMCPServer).mockResolvedValue({ server_id: "new-server-oauth", diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx index 0b39add234c..eb48fd02474 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx @@ -15,6 +15,9 @@ import { MCP_OAUTH2_FLOW_M2M, MCP_OAUTH2_FLOW_INTERACTIVE, isClientForwardedTokenMode, + getOAuthAuthorizationIdentity, + CLEARED_ON_INVALIDATION, + isHeldOAuthTokenStale, } from "./types"; import OAuthFormFields from "./OAuthFormFields"; import TruePassthroughWarning from "./TruePassthroughWarning"; @@ -99,7 +102,10 @@ const CreateMCPServer: React.FC = ({ const [oauthAccessToken, setOauthAccessToken] = useState(null); const [logoUrl, setLogoUrl] = useState(undefined); const [oauthDocsUrl, setOauthDocsUrl] = useState(null); - const [authorizedUrl, setAuthorizedUrl] = useState(undefined); + // The OAuth authorization identity (see getOAuthAuthorizationIdentity) captured at the moment a token + // was fetched; undefined when no valid token is held. If any mint-relevant field diverges from this, + // the held token is stale and is discarded so the admin must re-authorize. + const [authorizedIdentity, setAuthorizedIdentity] = useState(undefined); // Single hook call shared by MCPConnectionStatus and MCPToolConfiguration to avoid duplicate requests. const { @@ -125,12 +131,6 @@ const CreateMCPServer: React.FC = ({ const isAwsSigV4AuthType = authType === AUTH_TYPE.AWS_SIGV4; const isM2MFlow = isOAuthAuthType && formValues.oauth_flow_type === OAUTH_FLOW.M2M; - const getOAuthAuthorizationTarget = (values: Record): string | undefined => { - const transport = values.transport || transportType; - const target = transport === TRANSPORT.OPENAPI ? values.spec_path : values.url; - return typeof target === "string" ? target : undefined; - }; - const persistCreateUiState = () => { if (typeof window === "undefined") { return; @@ -207,6 +207,7 @@ const CreateMCPServer: React.FC = ({ // and committed to sessionStorage on submit; it must never be written into form.credentials, // which would persist it as server-level credentials on the created server row. Mirrors the // edit form's onTokenReceived early return. + setAuthorizedIdentity(getOAuthAuthorizationIdentity(form.getFieldsValue(true))); NotificationsManager.success( "Token held for this browser session. Tools can now be previewed and configured; nothing will be saved to LiteLLM.", ); @@ -223,7 +224,9 @@ const CreateMCPServer: React.FC = ({ }; form.setFieldsValue({ credentials }); - setAuthorizedUrl(getOAuthAuthorizationTarget(form.getFieldsValue(true))); + // Capture the identity AFTER writing the DCR'd credentials so the held token is not spuriously + // invalidated by its own credential write. + setAuthorizedIdentity(getOAuthAuthorizationIdentity(form.getFieldsValue(true))); NotificationsManager.success( "OAuth authorization successful! Please click 'Create MCP Server' to save the configuration.", @@ -233,13 +236,23 @@ const CreateMCPServer: React.FC = ({ flowSource: "create", }); - const clearAuthorizedOAuthState = (values: Record) => { - form.resetFields(["credentials", "authorization_url", "token_url", "registration_url"]); - form.setFieldsValue(values); + // Discard the held browser-authorized token and its tool preview when the authorization identity + // changes (or the modal closes). The CLEARED_ON_INVALIDATION form fields (shared with the edit form + // via types.tsx) are reset too; whatever the admin just changed (passed via changedValues) is + // re-applied so the invalidation never wipes their in-flight edit. Admin-typed endpoint fields are + // left alone (see CLEARED_ON_INVALIDATION). + const clearHeldOAuthToken = (changedValues: Record = {}) => { setOauthAccessToken(null); clearTools(); resetOAuthFlow(); - setAuthorizedUrl(undefined); + setAuthorizedIdentity(undefined); + form.resetFields([...CLEARED_ON_INVALIDATION]); + const preserved = Object.fromEntries( + CLEARED_ON_INVALIDATION.filter((key) => key in changedValues).map((key) => [key, changedValues[key]]), + ); + if (Object.keys(preserved).length > 0) { + form.setFieldsValue(preserved); + } }; React.useEffect(() => { @@ -576,22 +589,11 @@ const CreateMCPServer: React.FC = ({ ? { url: undefined, command: undefined, args: undefined, env: undefined } : { spec_path: undefined, command: undefined, args: undefined, env: undefined }; - const nextValues = - authorizedUrl === undefined - ? transportValues - : { - ...transportValues, - credentials: undefined, - authorization_url: undefined, - token_url: undefined, - registration_url: undefined, - }; - - if (authorizedUrl !== undefined) { - clearAuthorizedOAuthState(nextValues); - } else { - form.setFieldsValue(nextValues); + form.setFieldsValue(transportValues); + if (isHeldOAuthTokenStale(form.getFieldsValue(true), authorizedIdentity)) { + clearHeldOAuthToken(); } + setFormValues(form.getFieldsValue(true)); }; // Generate options with existing groups and potential new group @@ -652,27 +654,21 @@ const CreateMCPServer: React.FC = ({ setOauthAccessToken(null); clearTools(); resetOAuthFlow(); - setAuthorizedUrl(undefined); + setAuthorizedIdentity(undefined); } }, [isModalVisible, form, clearTools, resetOAuthFlow]); const isAdmin = isAdminRole(userRole); const handleFormValuesChange = (changedValues: Record, allValues: Record) => { - const changedAuthorizationTarget = "url" in changedValues || "spec_path" in changedValues; - if ( - changedAuthorizationTarget && - authorizedUrl !== undefined && - getOAuthAuthorizationTarget(allValues) !== authorizedUrl - ) { - const invalidated = { - credentials: undefined, - authorization_url: changedValues.authorization_url, - token_url: changedValues.token_url, - registration_url: changedValues.registration_url, - }; - clearAuthorizedOAuthState(invalidated); - setFormValues({ ...allValues, ...invalidated }); + // Any change to a mint-relevant field (url, auth_type, oauth_flow_type, client creds/scopes, or the + // authorization/token/registration endpoints — see getOAuthAuthorizationIdentity) makes a held token + // stale, so discard it and force a fresh authorize. When that happens, formValues must be rebuilt + // from the form's post-reset state, not the pre-reset allValues snapshot: the snapshot still holds + // the discarded token in credentials, and useTestMCPConnection reads formValues for tool preview. + if (isHeldOAuthTokenStale(allValues, authorizedIdentity)) { + clearHeldOAuthToken(changedValues); + setFormValues({ ...form.getFieldsValue(true), ...changedValues }); return; } setFormValues(allValues); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx index d55f993b926..adb3e161da5 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx @@ -10,6 +10,7 @@ vi.mock("../networking", () => ({ updateMCPServer: vi.fn(), listMCPTools: vi.fn().mockResolvedValue({ tools: [], error: null }), storeMCPOAuthUserCredential: vi.fn().mockResolvedValue({}), + testMCPToolsListRequest: vi.fn().mockResolvedValue({ tools: [], error: null }), })); vi.mock("../molecules/notifications_manager", () => ({ @@ -22,15 +23,22 @@ vi.mock("../molecules/notifications_manager", () => ({ const mockOauth: { tokenResponse: any; getTemporaryPayload: (() => Record | null) | null; -} = { tokenResponse: null, getTemporaryPayload: null }; + onTokenReceived: ((token: Record | null) => void) | null; + reset: ReturnType; +} = { tokenResponse: null, getTemporaryPayload: null, onTokenReceived: null, reset: vi.fn() }; vi.mock("@/hooks/useMcpOAuthFlow", () => ({ - useMcpOAuthFlow: (opts: { getTemporaryPayload?: () => Record | null }) => { + useMcpOAuthFlow: (opts: { + getTemporaryPayload?: () => Record | null; + onTokenReceived?: (token: Record | null) => void; + }) => { mockOauth.getTemporaryPayload = opts?.getTemporaryPayload ?? null; + mockOauth.onTokenReceived = opts?.onTokenReceived ?? null; return { startOAuthFlow: vi.fn(), status: "idle", error: null, tokenResponse: mockOauth.tokenResponse, + reset: mockOauth.reset, }; }, })); @@ -92,10 +100,12 @@ vi.mock("./mcp_tool_configuration", () => ({ const mockGetToken = vi.fn(); const mockIsTokenValid = vi.fn(); const mockSetToken = vi.fn(); +const mockRemoveToken = vi.fn(); vi.mock("@/utils/mcpTokenStore", () => ({ getToken: (...args: any[]) => mockGetToken(...args), isTokenValid: (...args: any[]) => mockIsTokenValid(...args), setToken: (...args: any[]) => mockSetToken(...args), + removeToken: (...args: unknown[]) => mockRemoveToken(...args), })); // ── fixtures ────────────────────────────────────────────────────────────────── @@ -451,6 +461,159 @@ describe("MCPServerEdit (auth type switch)", () => { }); }); +describe("MCPServerEdit OAuth token invalidation", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const renderOAuthEdit = () => + render( + , + ); + + it("invalidates a session-authorized token when the transport switches to stdio", async () => { + // Switching to stdio clears url/auth_type via programmatic form.setFieldsValue, which antd does + // not report through onValuesChange; the explicit recheck in handleTransportChange must catch it. + // Regression: the token used to survive this switch (sessionStorage + hook state kept the old + // token minted for the http url). + renderOAuthEdit(); + + act(() => { + mockOauth.onTokenReceived?.({ access_token: "tok-1" }); + }); + mockOauth.reset.mockClear(); + + await selectAntOption("Transport Type", "Standard Input/Output (stdio)"); + + await waitFor(() => expect(mockOauth.reset).toHaveBeenCalled()); + expect(mockRemoveToken).toHaveBeenCalledWith("oauth_server_1", undefined); + }); + + it("invalidates a session-authorized token when the server URL changes", async () => { + renderOAuthEdit(); + + act(() => { + mockOauth.onTokenReceived?.({ access_token: "tok-1" }); + }); + mockOauth.reset.mockClear(); + + const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com"); + await act(async () => { + fireEvent.change(urlInput, { target: { value: "https://other.example.com/mcp" } }); + }); + + await waitFor(() => expect(mockOauth.reset).toHaveBeenCalled()); + expect(mockRemoveToken).toHaveBeenCalledWith("oauth_server_1", undefined); + }); + + it("previews tools with a staged interactive OAuth token before it is saved", async () => { + // Regression: for authorization_code the fetch went by server_id only, relying on the stored DB + // credential, so a token authorized in this edit session gave an empty preview until the admin + // saved; the create form previews the identical state via the config-based preview endpoint. + mockOauth.tokenResponse = { access_token: "staged-obo-tok" }; + + renderOAuthEdit(); + + await waitFor(() => { + expect(vi.mocked(networking.testMCPToolsListRequest)).toHaveBeenCalledWith( + "access-token", + // oauth2_flow must be explicit: the preview endpoint infers client_credentials from + // inherited client_id/client_secret/token_url and would strip the staged bearer. + expect.objectContaining({ + server_id: "oauth_server_1", + url: "https://example.com/mcp", + oauth2_flow: "authorization_code", + }), + "staged-obo-tok", + ); + }); + expect(networking.listMCPTools).not.toHaveBeenCalled(); + // Previewing must stay stateless: the staged token is committed only by an explicit Save + // (storeMCPOAuthUserCredential for authorization_code, setToken for the client-forwarded modes). + expect(networking.storeMCPOAuthUserCredential).not.toHaveBeenCalled(); + expect(mockSetToken).not.toHaveBeenCalled(); + expect(networking.updateMCPServer).not.toHaveBeenCalled(); + mockOauth.tokenResponse = null; + }); + + it("previews an OpenAPI server's staged token against its spec_path", async () => { + mockOauth.tokenResponse = { access_token: "staged-obo-tok" }; + + render( + , + ); + + await waitFor(() => { + expect(vi.mocked(networking.testMCPToolsListRequest)).toHaveBeenCalledWith( + "access-token", + expect.objectContaining({ spec_path: "https://example.com/openapi.json" }), + "staged-obo-tok", + ); + }); + mockOauth.tokenResponse = null; + }); + + it("keeps the admin's in-flight endpoint edits when the token is invalidated", async () => { + // Regression: invalidation used to form.resetFields the endpoint fields; with the edit Form's + // initialValues that silently reverted an admin-corrected token_url back to the saved (wrong) + // value while still looking plausible. Only credentials (the minted material) may be wiped. + renderOAuthEdit(); + + const tokenUrlInput = screen.getByPlaceholderText("https://example.com/oauth/token"); + await act(async () => { + fireEvent.change(tokenUrlInput, { target: { value: "https://corrected.example.com/token" } }); + }); + + act(() => { + mockOauth.onTokenReceived?.({ access_token: "tok-1" }); + }); + mockOauth.reset.mockClear(); + + const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com"); + await act(async () => { + fireEvent.change(urlInput, { target: { value: "https://moved.example.com/mcp" } }); + }); + + await waitFor(() => expect(mockOauth.reset).toHaveBeenCalled()); + expect((screen.getByPlaceholderText("https://example.com/oauth/token") as HTMLInputElement).value).toBe( + "https://corrected.example.com/token", + ); + }); + + it("keeps a session-authorized token on an http to sse switch with the same url", async () => { + // Same url means the same resource/audience (RFC 8707): the minted token is still valid, so a + // pure transport swap between the two MCP wire protocols must not force a re-authorize. + renderOAuthEdit(); + + act(() => { + mockOauth.onTokenReceived?.({ access_token: "tok-1" }); + }); + mockOauth.reset.mockClear(); + + await selectAntOption("Transport Type", "Server-Sent Events (SSE)"); + + expect(mockOauth.reset).not.toHaveBeenCalled(); + expect(mockRemoveToken).not.toHaveBeenCalled(); + }); +}); + describe("MCPServerEdit (tool allowlist)", () => { beforeEach(() => { vi.clearAllMocks(); @@ -1210,6 +1373,7 @@ describe("MCPServerEdit (OAuth token persistence on save)", () => { expect(networking.storeMCPOAuthUserCredential).not.toHaveBeenCalled(); const [, payload] = vi.mocked(networking.updateMCPServer).mock.calls[0]; expect(payload.credentials).toBeUndefined(); + expect(JSON.stringify(payload)).not.toContain("cf-tok"); }, ); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx index ee7938d2904..7446c96c40e 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx @@ -5,6 +5,9 @@ import { Button, TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/rea import { AUTH_TYPE, isClientForwardedTokenMode, + getOAuthAuthorizationIdentity, + CLEARED_ON_INVALIDATION, + isHeldOAuthTokenStale, OAUTH_FLOW, MCP_OAUTH2_FLOW_M2M, MCP_OAUTH2_FLOW_INTERACTIVE, @@ -14,8 +17,8 @@ import { getMcpOAuthMode, oauth2FlowToFormValue, } from "./types"; -import { updateMCPServer, listMCPTools, storeMCPOAuthUserCredential } from "../networking"; -import { getToken, isTokenValid, setToken } from "@/utils/mcpTokenStore"; +import { updateMCPServer, listMCPTools, storeMCPOAuthUserCredential, testMCPToolsListRequest } from "../networking"; +import { getToken, isTokenValid, removeToken, setToken } from "@/utils/mcpTokenStore"; import { buildMcpPassthroughAuthHeader } from "@/utils/mcpHeaderUtils"; import MCPServerCostConfig from "./mcp_server_cost_config"; import MCPPermissionManagement from "./MCPPermissionManagement"; @@ -136,11 +139,17 @@ const MCPServerEdit: React.FC = ({ // that read only mcpServer.auth_type go stale the moment the admin switches modes in the form. const getEffectiveAuthType = () => form.getFieldValue("auth_type") ?? mcpServer.auth_type; + // The OAuth authorization identity (see getOAuthAuthorizationIdentity) captured when a token is fetched + // in this edit session; undefined when none is held. If a mint-relevant field later diverges from it, + // the held token (hook response + sessionStorage) is discarded so the admin must re-authorize. + const authorizedIdentityRef = React.useRef(undefined); + const { startOAuthFlow, status: oauthStatus, error: oauthError, tokenResponse: oauthTokenResponse, + reset: resetOAuthFlow, } = useMcpOAuthFlow({ accessToken, getCredentials: () => form.getFieldValue("credentials"), @@ -183,6 +192,7 @@ const MCPServerEdit: React.FC = ({ return; } + authorizedIdentityRef.current = getOAuthAuthorizationIdentity(form.getFieldsValue(true)); if (isClientForwardedTokenMode(getEffectiveAuthType())) { const browserHeldToken = { access_token: token.access_token, @@ -205,6 +215,8 @@ const MCPServerEdit: React.FC = ({ }; form.setFieldsValue({ credentials }); + // Re-capture after writing credentials so the token is not invalidated by its own credential write. + authorizedIdentityRef.current = getOAuthAuthorizationIdentity(form.getFieldsValue(true)); NotificationsManager.success( "OAuth authorization successful! Please click 'Update MCP Server' to save the credentials.", @@ -378,6 +390,91 @@ const MCPServerEdit: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [mcpServer, accessToken, userID, oauthTokenResponse?.access_token]); + // Invalidate a token authorized in this edit session once any mint-relevant field diverges from the + // identity it was minted against (url, auth_type, oauth_flow_type, client creds/scopes, or the + // authorization/token/registration endpoints — see getOAuthAuthorizationIdentity). Discards the hook + // token (resetOAuthFlow, which re-runs fetchTools to prompt a fresh authorize), the sessionStorage + // token (removeToken, browser-held modes), and the fetched token/DCR client in the shared + // CLEARED_ON_INVALIDATION form fields; the admin's in-flight edit is re-applied so it is never wiped. + // Only fires when a token was actually authorized here (ref set), so a token already valid for the + // saved server on mount is left untouched. Driven from onValuesChange for user input, plus an explicit + // recheck after programmatic setFieldsValue paths (handleTransportChange), which antd does not report + // through onValuesChange. + const clearHeldOAuthToken = (changedValues: Record = {}) => { + authorizedIdentityRef.current = undefined; + if (mcpServer.server_id) { + removeToken(mcpServer.server_id, userID); + } + setTools([]); + resetOAuthFlow(); + form.resetFields([...CLEARED_ON_INVALIDATION]); + const preserved = Object.fromEntries( + CLEARED_ON_INVALIDATION.filter((key) => key in changedValues).map((key) => [key, changedValues[key]]), + ); + if (Object.keys(preserved).length > 0) { + form.setFieldsValue(preserved); + } + }; + + const handleFormValuesChange = (changedValues: Record) => { + if (isHeldOAuthTokenStale(form.getFieldsValue(true), authorizedIdentityRef.current)) { + clearHeldOAuthToken(changedValues); + } + }; + + // A token authorized in this edit session for interactive OAuth (authorization_code) is only + // committed to the DB on save, so a plain by-server_id listing cannot use it and the preview would + // stay empty until the admin saves; the create form previews the identical state through the + // config-based preview endpoint, which takes the staged token explicitly. Returns false when there + // is no staged interactive token so fetchTools falls through to the by-server_id listing. + const previewWithStagedInteractiveToken = async ( + isPassthrough: boolean, + isBrowserHeldTokenMode: boolean, + ): Promise => { + const stagedToken = + !isPassthrough && !isBrowserHeldTokenMode && getEffectiveAuthType() === AUTH_TYPE.OAUTH2 + ? oauthTokenResponse?.access_token + : undefined; + if (!stagedToken) { + return false; + } + setIsLoadingTools(true); + setToolsError(null); + try { + const values = form.getFieldsValue(true); + const rawTransport = values.transport || mcpServer.transport; + // oauth2_flow must be explicit: the preview endpoint infers client_credentials from the + // inherited client_id/client_secret/token_url (common once DCR or discovery filled them) and + // would strip the staged bearer to preview as M2M. spec_path keeps OpenAPI servers on the + // spec-based preview path, mirroring the create form's config. + const previewConfig = { + server_id: mcpServer.server_id, + server_name: values.server_name || mcpServer.server_name || mcpServer.alias, + url: values.url || mcpServer.url, + spec_path: values.spec_path || mcpServer.spec_path, + transport: rawTransport === TRANSPORT.OPENAPI ? TRANSPORT.HTTP : rawTransport, + auth_type: AUTH_TYPE.OAUTH2, + oauth2_flow: MCP_OAUTH2_FLOW_INTERACTIVE, + authorization_url: values.authorization_url, + token_url: values.token_url, + registration_url: values.registration_url, + }; + const toolsResponse = await testMCPToolsListRequest(accessToken, previewConfig, stagedToken); + if (toolsResponse.tools && !toolsResponse.error) { + setTools(toolsResponse.tools); + } else { + setTools([]); + setToolsError(toolsResponse.message || "Failed to load tools"); + } + } catch (error) { + setTools([]); + setToolsError(error instanceof Error ? error.message : "Failed to load tools"); + } finally { + setIsLoadingTools(false); + } + return true; + }; + const fetchTools = async () => { if (!accessToken || !mcpServer.server_id) return; @@ -393,6 +490,10 @@ const MCPServerEdit: React.FC = ({ delegate_auth_to_upstream: mcpServer.delegate_auth_to_upstream, }) === "passthrough"; const isBrowserHeldTokenMode = isClientForwardedTokenMode(getEffectiveAuthType()); + + if (await previewWithStagedInteractiveToken(isPassthrough, isBrowserHeldTokenMode)) { + return; + } if (isPassthrough || isBrowserHeldTokenMode) { const token = oauthTokenResponse?.access_token ?? @@ -496,6 +597,9 @@ const MCPServerEdit: React.FC = ({ stdio_config: undefined, }); } + if (isHeldOAuthTokenStale(form.getFieldsValue(true), authorizedIdentityRef.current)) { + clearHeldOAuthToken(); + } }; const handleSave = async (values: Record) => { @@ -805,7 +909,13 @@ const MCPServerEdit: React.FC = ({ -
+ { + // Regression: the identity used to pick the audience from spec_path only when values.transport was + // OPENAPI, but the create form keeps transport in component state, so values.transport was absent and + // spec_path edits on OpenAPI servers never invalidated a held token. + it("changes when spec_path changes even when transport is absent from form values", () => { + const authorized = { auth_type: AUTH_TYPE.OAUTH2, spec_path: "https://a.example.com/openapi.json" }; + const edited = { auth_type: AUTH_TYPE.OAUTH2, spec_path: "https://b.example.com/openapi.json" }; + expect(getOAuthAuthorizationIdentity(edited)).not.toBe(getOAuthAuthorizationIdentity(authorized)); + expect(isHeldOAuthTokenStale(edited, getOAuthAuthorizationIdentity(authorized))).toBe(true); + }); + + it("changes when url changes", () => { + const authorized = { auth_type: AUTH_TYPE.OAUTH2, url: "https://a.example.com/mcp" }; + const edited = { auth_type: AUTH_TYPE.OAUTH2, url: "https://b.example.com/mcp" }; + expect(getOAuthAuthorizationIdentity(edited)).not.toBe(getOAuthAuthorizationIdentity(authorized)); + }); + + it("is stable across non-mint fields", () => { + const authorized = { auth_type: AUTH_TYPE.OAUTH2, url: "https://a.example.com/mcp", server_name: "one" }; + const renamed = { auth_type: AUTH_TYPE.OAUTH2, url: "https://a.example.com/mcp", server_name: "two" }; + expect(getOAuthAuthorizationIdentity(renamed)).toBe(getOAuthAuthorizationIdentity(authorized)); + expect(isHeldOAuthTokenStale(renamed, getOAuthAuthorizationIdentity(authorized))).toBe(false); + }); +}); + describe("handleTransport", () => { it("should default to SSE when transport is null", () => { expect(handleTransport(null)).toBe(TRANSPORT.SSE); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx index dca9e574e22..3eba8b30968 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx @@ -57,6 +57,55 @@ export const OAUTH_FLOW = { M2M: "m2m", }; +// The fields that determine which upstream OAuth token "Authorize & Fetch" mints: the resource/audience +// (url, or spec_path for OpenAPI servers), the OAuth mode/grant (auth_type, oauth_flow_type), the OAuth +// client and requested scope (credentials.client_id / client_secret / scopes), and the authorization-server +// endpoints (authorization_url / token_url / registration_url). Grounded in RFC 8707 / RFC 8693 and the MCP +// auth spec: an access token is bound to exactly this tuple (resource/audience + scope + client + issuer), so +// a previously authorized token is stale if and only if this identity changes and must be re-minted. +// url and spec_path are compared independently rather than selected by transport: the create form keeps +// transport in component state, not in form values, so a transport-conditional target would silently pin the +// audience to a missing url and never fire for spec_path edits on OpenAPI servers. Mirrors the backend's +// mcp_oauth_token_identity. Deliberately EXCLUDES: transport itself (http<->sse on the same url is the same +// audience; a switch to/from OpenAPI shows up as url/spec_path changes because each form clears the field the +// new transport does not use), delegate_auth_to_upstream (a downstream-usage toggle that is never sent to the +// authorize request), and all metadata/RBAC/routing fields. Shared by the create and edit forms so their +// invalidation logic cannot drift. +export const getOAuthAuthorizationIdentity = (values: Record): string => { + const credentials = (values.credentials ?? {}) as Record; + const identity = { + url: typeof values.url === "string" ? values.url : null, + spec_path: typeof values.spec_path === "string" ? values.spec_path : null, + auth_type: values.auth_type ?? null, + oauth_flow_type: values.oauth_flow_type ?? null, + client_id: credentials.client_id ?? null, + client_secret: credentials.client_secret ?? null, + scopes: credentials.scopes ?? null, + authorization_url: values.authorization_url ?? null, + token_url: values.token_url ?? null, + registration_url: values.registration_url ?? null, + }; + return JSON.stringify(identity); +}; + +// The form fields wiped when a held OAuth token is invalidated: only `credentials`, which holds the +// minted material (the fetched token + DCR client). The authorization/token/registration endpoint +// fields are deliberately NOT wiped: nothing programmatic ever writes them (upstream discovery happens +// backend-side), so they only ever hold admin input, and resetting them would wipe it (create) or +// silently revert it to the saved record (edit, whose Form has initialValues). Shared by the create and +// edit forms so what gets wiped cannot drift. +export const CLEARED_ON_INVALIDATION = ["credentials"] as const; + +// True when a token was authorized in this session (authorizedIdentity recorded at mint time) and the +// form's current identity no longer matches it. Every invalidation decision in both forms goes through +// this single check: onValuesChange for user edits, and an explicit recheck after any programmatic +// form.setFieldsValue (antd does not fire onValuesChange for those), so a missed event path cannot let a +// stale token survive. +export const isHeldOAuthTokenStale = ( + values: Record, + authorizedIdentity: string | undefined, +): boolean => authorizedIdentity !== undefined && getOAuthAuthorizationIdentity(values) !== authorizedIdentity; + // Backend value of `oauth2_flow` that marks a machine-to-machine server. Distinct // from the UI-local OAUTH_FLOW.M2M ("m2m"); this is what the API actually returns. export const MCP_OAUTH2_FLOW_M2M = "client_credentials"; diff --git a/ui/litellm-dashboard/src/components/shared/charts/area_chart.test.tsx b/ui/litellm-dashboard/src/components/shared/charts/area_chart.test.tsx new file mode 100644 index 00000000000..cd033c5ce27 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/charts/area_chart.test.tsx @@ -0,0 +1,36 @@ +import { render } from "@testing-library/react"; +import React from "react"; +import { describe, expect, it } from "vitest"; +import { AreaChart } from "./area_chart"; + +const data = [ + { date: "2026-03-01", tokens: 100, requests: 10 }, + { date: "2026-03-02", tokens: 150, requests: 12 }, +]; + +describe("AreaChart", () => { + it("renders one area per category with the mapped stroke colors", () => { + const { container } = render( + , + ); + + const curves = Array.from(container.querySelectorAll("path.recharts-area-curve")); + expect(curves).toHaveLength(2); + const strokes = new Set(curves.map((curve) => curve.getAttribute("stroke"))); + expect(strokes).toEqual(new Set(["var(--color-blue-500, #3b82f6)", "var(--color-cyan-500, #06b6d4)"])); + }); + + it("renders a fade-out gradient fill per category", () => { + const { container } = render( + , + ); + + const gradients = container.querySelectorAll("defs linearGradient"); + expect(gradients).toHaveLength(2); + const areas = Array.from(container.querySelectorAll("path.recharts-area-area")); + expect(areas).toHaveLength(2); + for (const area of areas) { + expect(area.getAttribute("fill")).toMatch(/^url\(#fill-/); + } + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/charts/area_chart.tsx b/ui/litellm-dashboard/src/components/shared/charts/area_chart.tsx new file mode 100644 index 00000000000..794baa13cf7 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/charts/area_chart.tsx @@ -0,0 +1,92 @@ +"use client"; + +import * as React from "react"; +import { Area, AreaChart as RechartsAreaChart, CartesianGrid, XAxis, YAxis } from "recharts"; +import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, type ChartConfig } from "@/components/ui/chart"; +import { cn } from "@/lib/cva.config"; +import { ValueTooltip, type ChartTooltipComponent } from "./chart_tooltip"; +import { categoryFills, type ChartColor } from "./colors"; + +export type AreaChartProps> = { + data: readonly TDatum[]; + index: string; + categories: readonly string[]; + colors?: readonly ChartColor[]; + valueFormatter?: (value: number) => string; + yAxisWidth?: number; + showLegend?: boolean; + showGridLines?: boolean; + showTooltip?: boolean; + customTooltip?: ChartTooltipComponent; + className?: string; + style?: React.CSSProperties; +}; + +export function AreaChart>({ + data, + index, + categories, + colors, + valueFormatter, + yAxisWidth = 56, + showLegend = true, + showGridLines = true, + showTooltip = true, + customTooltip, + className, + style, +}: AreaChartProps) { + const gradientId = React.useId().replace(/:/g, ""); + const fills = categoryFills(categories.length, colors); + const config: ChartConfig = Object.fromEntries(categories.map((category) => [category, { label: category }])); + const TooltipContent = customTooltip ?? ValueTooltip; + + return ( + + + + {categories.map((category, i) => ( + + + + + ))} + + {showGridLines && } + + + {showTooltip && ( + ( + + )} + /> + )} + {showLegend && ( + } + /> + )} + {categories.map((category, i) => ( + + ))} + + + ); +} diff --git a/ui/litellm-dashboard/src/components/shared/charts/bar_chart.test.tsx b/ui/litellm-dashboard/src/components/shared/charts/bar_chart.test.tsx new file mode 100644 index 00000000000..d5253c86c6f --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/charts/bar_chart.test.tsx @@ -0,0 +1,119 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import React from "react"; +import { describe, expect, it, vi } from "vitest"; +import { BarChart } from "./bar_chart"; + +const data = [ + { date: "2026-03-01", passed: 10, blocked: 2 }, + { date: "2026-03-02", passed: 15, blocked: 1 }, +]; + +describe("BarChart", () => { + it("renders one bar series per category with the mapped tremor colors", () => { + const { container } = render( + , + ); + + const rectangles = Array.from(container.querySelectorAll("path.recharts-rectangle")); + expect(rectangles).toHaveLength(4); + const fills = new Set(rectangles.map((rect) => rect.getAttribute("fill"))); + expect(fills).toEqual(new Set(["var(--color-green-500, #22c55e)", "var(--color-red-500, #ef4444)"])); + }); + + it("falls back to the tremor default color cycle when no colors are passed", () => { + const { container } = render(); + + const fills = new Set( + Array.from(container.querySelectorAll("path.recharts-rectangle")).map((rect) => rect.getAttribute("fill")), + ); + expect(fills).toEqual(new Set(["var(--color-blue-500, #3b82f6)", "var(--color-cyan-500, #06b6d4)"])); + }); + + it("fires onValueChange with the datum and clicked category", () => { + const onValueChange = vi.fn(); + const { container } = render( + , + ); + + const firstRect = container.querySelector("path.recharts-rectangle"); + expect(firstRect).not.toBeNull(); + fireEvent.click(firstRect!); + + expect(onValueChange).toHaveBeenCalledTimes(1); + const expectedClickItem = { + date: "2026-03-01", + passed: 10, + blocked: 2, + categoryClicked: "passed", + }; + expect(onValueChange).toHaveBeenCalledWith(expectedClickItem); + }); + + it("renders category labels on the y axis in vertical layout", () => { + render( + , + ); + + expect(screen.getAllByText("alpha").length).toBeGreaterThan(0); + expect(screen.getAllByText("beta").length).toBeGreaterThan(0); + }); + + it("applies valueFormatter to the value axis ticks", () => { + render( + `${v} req`} + />, + ); + + expect(screen.getAllByText(/ req$/).length).toBeGreaterThan(0); + }); + + it("renders a legend by default, matching tremor, and hides it when showLegend is false", () => { + const { container, rerender } = render( + , + ); + expect(screen.getByText("passed")).toBeInTheDocument(); + expect(container.querySelector(".recharts-legend-wrapper")).not.toBeNull(); + + rerender(); + expect(screen.queryByText("passed")).not.toBeInTheDocument(); + }); + + it("emits no per-chart style tag; colors flow through fills, not CSS vars", () => { + const { container } = render( + , + ); + expect(container.querySelector("style")).toBeNull(); + }); + + it("stacks bars into a single column per index when stack is set", () => { + const { container } = render( + , + ); + + const xPositions = Array.from(container.querySelectorAll("path.recharts-rectangle")).map( + (rect) => rect.getAttribute("d")?.split(",")[0], + ); + expect(new Set(xPositions).size).toBe(2); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/charts/bar_chart.tsx b/ui/litellm-dashboard/src/components/shared/charts/bar_chart.tsx new file mode 100644 index 00000000000..6ee3319dc10 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/charts/bar_chart.tsx @@ -0,0 +1,119 @@ +"use client"; + +import * as React from "react"; +import { Bar, BarChart as RechartsBarChart, CartesianGrid, XAxis, YAxis } from "recharts"; +import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, type ChartConfig } from "@/components/ui/chart"; +import { cn } from "@/lib/cva.config"; +import { ValueTooltip, type ChartTooltipComponent } from "./chart_tooltip"; +import { categoryFills, type ChartColor } from "./colors"; + +export type BarChartProps> = { + data: readonly TDatum[]; + index: string; + categories: readonly string[]; + colors?: readonly ChartColor[]; + valueFormatter?: (value: number) => string; + stack?: boolean; + layout?: "horizontal" | "vertical"; + yAxisWidth?: number; + tickGap?: number; + showLegend?: boolean; + showXAxis?: boolean; + showGridLines?: boolean; + showTooltip?: boolean; + customTooltip?: ChartTooltipComponent; + onValueChange?: (item: TDatum & { categoryClicked: string }) => void; + className?: string; + style?: React.CSSProperties; +}; + +export function BarChart>({ + data, + index, + categories, + colors, + valueFormatter, + stack = false, + layout = "horizontal", + yAxisWidth = 56, + tickGap = 5, + showLegend = true, + showXAxis = true, + showGridLines = true, + showTooltip = true, + customTooltip, + onValueChange, + className, + style, +}: BarChartProps) { + const fills = categoryFills(categories.length, colors); + const config: ChartConfig = Object.fromEntries(categories.map((category) => [category, { label: category }])); + const vertical = layout === "vertical"; + const TooltipContent = customTooltip ?? ValueTooltip; + + return ( + + + {showGridLines && } + {vertical ? ( + + ) : ( + + )} + {vertical ? ( + + ) : ( + + )} + {showTooltip && ( + ( + + )} + /> + )} + {showLegend && ( + } + /> + )} + {categories.map((category, i) => ( + { + if (item.payload) onValueChange({ ...item.payload, categoryClicked: category }); + } + : undefined + } + /> + ))} + + + ); +} diff --git a/ui/litellm-dashboard/src/components/shared/charts/chart_legend.test.tsx b/ui/litellm-dashboard/src/components/shared/charts/chart_legend.test.tsx new file mode 100644 index 00000000000..889927aca43 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/charts/chart_legend.test.tsx @@ -0,0 +1,32 @@ +import { render, screen } from "@testing-library/react"; +import React from "react"; +import { describe, expect, it } from "vitest"; +import { CustomLegend } from "./chart_legend"; + +describe("CustomLegend", () => { + it("renders title-cased category names without the metrics prefix", () => { + render(); + + expect(screen.getByText("Total Tokens")).toBeInTheDocument(); + expect(screen.getByText("Spend")).toBeInTheDocument(); + }); + + it("matches colors to categories by index with theme-var values", () => { + const { container } = render( + , + ); + + const dots = Array.from(container.querySelectorAll('span[style*="background-color"]')); + expect(dots[0]?.getAttribute("style")).toContain("--color-blue-500"); + expect(dots[1]?.getAttribute("style")).toContain("--color-green-500"); + }); + + it("cycles colors when there are more categories than colors", () => { + const { container } = render( + , + ); + + const dots = Array.from(container.querySelectorAll('span[style*="background-color"]')); + expect(dots[2]?.getAttribute("style")).toContain("--color-blue-500"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/charts/chart_legend.tsx b/ui/litellm-dashboard/src/components/shared/charts/chart_legend.tsx new file mode 100644 index 00000000000..da252d8bf63 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/charts/chart_legend.tsx @@ -0,0 +1,25 @@ +"use client"; + +import * as React from "react"; +import { formatCategoryName } from "./chart_tooltip"; +import { chartColorValue, type ChartColor } from "./colors"; + +export const CustomLegend = ({ + categories, + colors, +}: { + categories: readonly string[]; + colors: readonly ChartColor[]; +}) => ( +
+ {categories.map((category, idx) => ( +
+ +

{formatCategoryName(category)}

+
+ ))} +
+); diff --git a/ui/litellm-dashboard/src/components/shared/charts/chart_tooltip.test.tsx b/ui/litellm-dashboard/src/components/shared/charts/chart_tooltip.test.tsx new file mode 100644 index 00000000000..7afc7532760 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/charts/chart_tooltip.test.tsx @@ -0,0 +1,101 @@ +import { render, screen } from "@testing-library/react"; +import React from "react"; +import { describe, expect, it } from "vitest"; +import { CustomTooltip, ValueTooltip, type ChartTooltipProps } from "./chart_tooltip"; + +const metricsPayload = ( + dataKey: string, + value: number, + color = "#3b82f6", +): NonNullable[number] => + ({ + dataKey, + value, + color, + payload: { + date: "2026-01-15", + metrics: { + total_tokens: 1000, + prompt_tokens: 600, + completion_tokens: 400, + spend: 1234.567, + api_requests: 10, + }, + }, + }) as NonNullable[number]; + +describe("CustomTooltip", () => { + it("returns null when not active or payload is empty", () => { + const inactive = render( + , + ); + expect(inactive.container.firstChild).toBeNull(); + + const empty = render(); + expect(empty.container.firstChild).toBeNull(); + }); + + it("renders the label and title-cased category names without the metrics prefix", () => { + render(); + + expect(screen.getByText("2026-01-15")).toBeInTheDocument(); + expect(screen.getByText("Total Tokens")).toBeInTheDocument(); + expect(screen.getByText("1,000")).toBeInTheDocument(); + }); + + it("formats spend values as dollars with two decimals", () => { + render(); + + expect(screen.getByText("$1,234.57")).toBeInTheDocument(); + }); + + it("shows N/A for metrics missing from the row payload", () => { + render(); + + expect(screen.getByText("N/A")).toBeInTheDocument(); + }); + + it("uses the series color for the indicator dot", () => { + const { container } = render( + , + ); + + const dot = container.querySelector('span[style*="background-color"]'); + expect(dot?.getAttribute("style")).toContain("--color-blue-500"); + }); +}); + +describe("ValueTooltip", () => { + const payload = [ + { + dataKey: "passed", + name: "passed", + value: 1000, + color: "#22c55e", + payload: { date: "2026-01-15", passed: 1000 }, + } as NonNullable[number], + ]; + + it("returns null when not active", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("renders label, series name, and locale-formatted value by default", () => { + render(); + + expect(screen.getByText("2026-01-15")).toBeInTheDocument(); + expect(screen.getByText("passed")).toBeInTheDocument(); + expect(screen.getByText("1,000")).toBeInTheDocument(); + }); + + it("applies the valueFormatter to values", () => { + render( `$${v}`} />); + + expect(screen.getByText("$1000")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/charts/chart_tooltip.tsx b/ui/litellm-dashboard/src/components/shared/charts/chart_tooltip.tsx new file mode 100644 index 00000000000..2644b8f720c --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/charts/chart_tooltip.tsx @@ -0,0 +1,97 @@ +"use client"; + +import * as React from "react"; +import type { TooltipContentProps, TooltipValueType } from "recharts"; + +export type ChartTooltipProps = Pick< + TooltipContentProps, + "active" | "payload" | "label" +>; + +export type ChartTooltipComponent = React.ComponentType; + +export const formatCategoryName = (name: string): string => + name + .replace("metrics.", "") + .replace(/_/g, " ") + .split(" ") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); + +export const ValueTooltip = ({ + active, + payload, + label, + valueFormatter, +}: ChartTooltipProps & { valueFormatter?: (value: number) => string }) => { + if (!active || !payload || payload.length === 0) return null; + + const formatValue = (value: unknown): string => { + if (typeof value === "number") return valueFormatter ? valueFormatter(value) : value.toLocaleString(); + return value == null ? "" : String(value); + }; + + return ( +
+ {label != null &&

{String(label)}

} +
+ {payload.map((item, idx) => ( +
+
+ + {String(item.name ?? item.dataKey ?? "")} +
+ {formatValue(item.value)} +
+ ))} +
+
+ ); +}; + +const rawMetricValue = (row: unknown, dataKey: string): number | undefined => { + if (typeof row !== "object" || row === null || !("metrics" in row)) return undefined; + const metrics = (row as { metrics: unknown }).metrics; + if (typeof metrics !== "object" || metrics === null) return undefined; + const metricKey = dataKey.substring(dataKey.indexOf(".") + 1); + const value = (metrics as Record)[metricKey]; + return typeof value === "number" ? value : undefined; +}; + +const formatMetricValue = (rawValue: number | undefined, isSpend: boolean): string => { + if (rawValue === undefined) return "N/A"; + if (isSpend) return `$${rawValue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + return rawValue.toLocaleString(); +}; + +export const CustomTooltip = ({ active, payload, label }: ChartTooltipProps) => { + if (!active || !payload || payload.length === 0) return null; + + return ( +
+

{label == null ? "" : String(label)}

+ {payload.map((item) => { + const dataKey = item.dataKey?.toString(); + if (!dataKey || !item.payload) return null; + + const formattedValue = formatMetricValue(rawMetricValue(item.payload, dataKey), dataKey.includes("spend")); + + return ( +
+
+ +

{formatCategoryName(dataKey)}

+
+

{formattedValue}

+
+ ); + })} +
+ ); +}; diff --git a/ui/litellm-dashboard/src/components/shared/charts/colors.ts b/ui/litellm-dashboard/src/components/shared/charts/colors.ts new file mode 100644 index 00000000000..c30f58e9e4d --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/charts/colors.ts @@ -0,0 +1,58 @@ +export const CHART_COLOR_HEX = { + slate: "#64748b", + gray: "#6b7280", + zinc: "#71717a", + neutral: "#737373", + stone: "#78716c", + red: "#ef4444", + orange: "#f97316", + amber: "#f59e0b", + yellow: "#eab308", + lime: "#84cc16", + green: "#22c55e", + emerald: "#10b981", + teal: "#14b8a6", + cyan: "#06b6d4", + sky: "#0ea5e9", + blue: "#3b82f6", + indigo: "#6366f1", + violet: "#8b5cf6", + purple: "#a855f7", + fuchsia: "#d946ef", + pink: "#ec4899", + rose: "#f43f5e", +} as const; + +export type ChartColor = keyof typeof CHART_COLOR_HEX; + +export const DEFAULT_COLOR_CYCLE: readonly ChartColor[] = [ + "blue", + "cyan", + "sky", + "indigo", + "violet", + "purple", + "fuchsia", + "slate", + "gray", + "zinc", + "neutral", + "stone", + "red", + "orange", + "amber", + "yellow", + "lime", + "green", + "emerald", + "teal", + "pink", + "rose", +]; + +export const chartColorValue = (color: ChartColor): string => `var(--color-${color}-500, ${CHART_COLOR_HEX[color]})`; + +export const categoryFills = (count: number, colors?: readonly ChartColor[]): readonly string[] => { + const cycle = colors && colors.length > 0 ? colors : DEFAULT_COLOR_CYCLE; + return Array.from({ length: count }, (_, i) => chartColorValue(cycle[i % cycle.length])); +}; diff --git a/ui/litellm-dashboard/src/components/shared/charts/donut_chart.test.tsx b/ui/litellm-dashboard/src/components/shared/charts/donut_chart.test.tsx new file mode 100644 index 00000000000..123c6cad0ec --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/charts/donut_chart.test.tsx @@ -0,0 +1,38 @@ +import { render } from "@testing-library/react"; +import React from "react"; +import { describe, expect, it } from "vitest"; +import { DonutChart } from "./donut_chart"; + +const data = [ + { provider: "openai", spend: 40 }, + { provider: "anthropic", spend: 30 }, + { provider: "bedrock", spend: 20 }, +]; + +describe("DonutChart", () => { + it("renders one sector per datum, cycling the given colors", () => { + const { container } = render( + , + ); + + const sectors = Array.from(container.querySelectorAll(".recharts-pie-sector path")); + expect(sectors).toHaveLength(3); + expect(sectors.map((sector) => sector.getAttribute("fill"))).toEqual([ + "var(--color-cyan-500, #06b6d4)", + "var(--color-blue-500, #3b82f6)", + "var(--color-cyan-500, #06b6d4)", + ]); + }); + + it("renders a full pie when variant is pie and a hollow donut otherwise", () => { + const { container: donut } = render(); + const { container: pie } = render( + , + ); + + const donutPath = donut.querySelector(".recharts-pie-sector path")?.getAttribute("d") ?? ""; + const piePath = pie.querySelector(".recharts-pie-sector path")?.getAttribute("d") ?? ""; + expect(donutPath).not.toEqual(piePath); + expect((donutPath.match(/A/g) ?? []).length).toBeGreaterThan((piePath.match(/A/g) ?? []).length); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/charts/donut_chart.tsx b/ui/litellm-dashboard/src/components/shared/charts/donut_chart.tsx new file mode 100644 index 00000000000..c2ce8c02e35 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/charts/donut_chart.tsx @@ -0,0 +1,67 @@ +"use client"; + +import * as React from "react"; +import { Cell, Pie, PieChart } from "recharts"; +import { ChartContainer, ChartTooltip, type ChartConfig } from "@/components/ui/chart"; +import { cn } from "@/lib/cva.config"; +import { ValueTooltip } from "./chart_tooltip"; +import { categoryFills, type ChartColor } from "./colors"; + +export type DonutChartProps> = { + data: readonly TDatum[]; + index: string; + category: string; + colors?: readonly ChartColor[]; + variant?: "donut" | "pie"; + valueFormatter?: (value: number) => string; + showTooltip?: boolean; + className?: string; + style?: React.CSSProperties; +}; + +export function DonutChart>({ + data, + index, + category, + colors, + variant = "donut", + valueFormatter, + showTooltip = true, + className, + style, +}: DonutChartProps) { + const fills = categoryFills(data.length, colors); + const config: ChartConfig = Object.fromEntries( + data.map((datum, i) => { + const name = String(datum[index] ?? i); + return [name, { label: name }]; + }), + ); + + return ( + + + {showTooltip && ( + ( + + )} + /> + )} + + {data.map((datum, i) => ( + + ))} + + + + ); +} diff --git a/ui/litellm-dashboard/src/components/shared/charts/index.ts b/ui/litellm-dashboard/src/components/shared/charts/index.ts new file mode 100644 index 00000000000..ba0a7544ddb --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/charts/index.ts @@ -0,0 +1,12 @@ +export { AreaChart, type AreaChartProps } from "./area_chart"; +export { BarChart, type BarChartProps } from "./bar_chart"; +export { CustomLegend } from "./chart_legend"; +export { + CustomTooltip, + ValueTooltip, + formatCategoryName, + type ChartTooltipComponent, + type ChartTooltipProps, +} from "./chart_tooltip"; +export { CHART_COLOR_HEX, DEFAULT_COLOR_CYCLE, categoryFills, chartColorValue, type ChartColor } from "./colors"; +export { DonutChart, type DonutChartProps } from "./donut_chart"; diff --git a/ui/litellm-dashboard/src/components/ui/card.tsx b/ui/litellm-dashboard/src/components/ui/card.tsx new file mode 100644 index 00000000000..3fc0aa65264 --- /dev/null +++ b/ui/litellm-dashboard/src/components/ui/card.tsx @@ -0,0 +1,86 @@ +import * as React from "react"; + +import { cn } from "@/lib/cva.config"; + +const Card = React.forwardRef & { size?: "default" | "sm" }>( + ({ className, size = "default", ...props }, ref) => ( +
img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(4)] *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", + className, + )} + {...props} + /> + ), +); +Card.displayName = "Card"; + +const CardHeader = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +CardHeader.displayName = "CardHeader"; + +const CardTitle = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +CardTitle.displayName = "CardTitle"; + +const CardDescription = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +CardDescription.displayName = "CardDescription"; + +const CardAction = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +CardAction.displayName = "CardAction"; + +const CardContent = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +CardContent.displayName = "CardContent"; + +const CardFooter = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +CardFooter.displayName = "CardFooter"; + +export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }; diff --git a/ui/litellm-dashboard/src/components/ui/chart.test.tsx b/ui/litellm-dashboard/src/components/ui/chart.test.tsx new file mode 100644 index 00000000000..8b70a6e3246 --- /dev/null +++ b/ui/litellm-dashboard/src/components/ui/chart.test.tsx @@ -0,0 +1,38 @@ +import { render } from "@testing-library/react"; +import * as React from "react"; +import { describe, expect, it } from "vitest"; +import { ChartContainer } from "./chart"; + +describe("ChartStyle hardening", () => { + it("sanitizes config keys and strips structural characters from color values", () => { + const { container } = render( + " }, + }} + > + + , + ); + + const style = container.querySelector("style"); + expect(style).not.toBeNull(); + const css = style!.innerHTML; + + expect(css).toContain("--color-metrics_total_tokens: var(--color-blue-500, #3b82f6);"); + expect(css).not.toContain("metrics.total_tokens"); + expect(css).toContain("--color-evil_key:"); + expect(css).not.toContain("<"); + expect((css.match(/{/g) ?? []).length).toBe((css.match(/}/g) ?? []).length); + }); + + it("emits no style tag when no config entry has a color", () => { + const { container } = render( + + + , + ); + expect(container.querySelector("style")).toBeNull(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/ui/chart.tsx b/ui/litellm-dashboard/src/components/ui/chart.tsx new file mode 100644 index 00000000000..14e10b9f06f --- /dev/null +++ b/ui/litellm-dashboard/src/components/ui/chart.tsx @@ -0,0 +1,324 @@ +"use client"; + +import * as React from "react"; +import * as RechartsPrimitive from "recharts"; +import type { TooltipValueType } from "recharts"; + +import { cn } from "@/lib/cva.config"; + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const; + +const INITIAL_DIMENSION = { width: 320, height: 200 } as const; +type TooltipNameType = number | string; + +export type ChartConfig = Record< + string, + { + label?: React.ReactNode; + icon?: React.ComponentType; + } & ({ color?: string; theme?: never } | { color?: never; theme: Record }) +>; + +type ChartContextProps = { + config: ChartConfig; +}; + +const ChartContext = React.createContext(null); + +function useChart() { + const context = React.useContext(ChartContext); + + if (!context) { + throw new Error("useChart must be used within a "); + } + + return context; +} + +const ChartContainer = React.forwardRef< + HTMLDivElement, + React.ComponentPropsWithoutRef<"div"> & { + config: ChartConfig; + children: React.ComponentProps["children"]; + initialDimension?: { + width: number; + height: number; + }; + } +>(({ id, className, children, config, initialDimension = INITIAL_DIMENSION, ...props }, ref) => { + const uniqueId = React.useId(); + const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`; + + return ( + +
+ + + {children} + +
+
+ ); +}); +ChartContainer.displayName = "ChartContainer"; + +const cssVarName = (key: string) => key.replace(/[^a-zA-Z0-9_-]/g, "_"); +const cssColorValue = (color: string) => color.replace(/[;{}<>]/g, ""); + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter(([, config]) => config.theme ?? config.color); + + if (!colorConfig.length) { + return null; + } + + return ( +