From 469d5126f69aac6e8cd9eb7d8c3346dc5f357e04 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:23:02 -0700 Subject: [PATCH 1/4] fix(lint): bring basedpyright rule counts back under their budget limits --- .../enterprise_callbacks/__init__.py | 0 .../pagerduty/__init__.py | 0 .../send_emails/__init__.py | 0 .../integrations/__init__.py | 0 .../litellm_core_utils/__init__.py | 0 .../proxy/hooks/__init__.py | 0 .../proxy/vector_stores/__init__.py | 0 enterprise/litellm_enterprise/py.typed | 0 .../litellm_enterprise/types/__init__.py | 0 .../types/enterprise_callbacks/__init__.py | 0 .../types/proxy/__init__.py | 0 .../litellm_proxy_extras/py.typed | 0 litellm/__init__.py | 36 +++++++++---------- litellm/_lazy_imports.py | 10 +++--- .../litellm_core_utils/audio_utils/utils.py | 3 +- .../prompt_templates/factory.py | 24 ++++++------- .../bedrock/chat/converse_transformation.py | 10 +++--- litellm/llms/xai/oauth.py | 2 +- .../mcp_server/elicitation_handler.py | 11 +++++- .../mcp_server/sampling_handler.py | 10 +++++- .../proxy/_experimental/mcp_server/server.py | 10 +++--- .../example_config_yaml/custom_guardrail.py | 3 +- .../example_config_yaml/custom_handler.py | 6 ++-- litellm/types/adapter.py | 4 +-- litellm/types/google_genai/main.py | 4 +-- litellm/types/integrations/argilla.py | 5 +-- litellm/types/llms/anthropic_skills.py | 6 ++-- litellm/types/llms/azure_ai.py | 2 +- litellm/types/llms/custom_llm.py | 4 +-- litellm/types/llms/databricks.py | 11 ++---- litellm/types/llms/ollama.py | 10 +----- litellm/types/llms/openrouter.py | 4 +-- litellm/types/llms/rerank.py | 11 +----- .../internal_user_endpoints.py | 5 ++- 34 files changed, 86 insertions(+), 105 deletions(-) create mode 100644 enterprise/litellm_enterprise/enterprise_callbacks/__init__.py create mode 100644 enterprise/litellm_enterprise/enterprise_callbacks/pagerduty/__init__.py create mode 100644 enterprise/litellm_enterprise/enterprise_callbacks/send_emails/__init__.py create mode 100644 enterprise/litellm_enterprise/integrations/__init__.py create mode 100644 enterprise/litellm_enterprise/litellm_core_utils/__init__.py create mode 100644 enterprise/litellm_enterprise/proxy/hooks/__init__.py create mode 100644 enterprise/litellm_enterprise/proxy/vector_stores/__init__.py create mode 100644 enterprise/litellm_enterprise/py.typed create mode 100644 enterprise/litellm_enterprise/types/__init__.py create mode 100644 enterprise/litellm_enterprise/types/enterprise_callbacks/__init__.py create mode 100644 enterprise/litellm_enterprise/types/proxy/__init__.py create mode 100644 litellm-proxy-extras/litellm_proxy_extras/py.typed diff --git a/enterprise/litellm_enterprise/enterprise_callbacks/__init__.py b/enterprise/litellm_enterprise/enterprise_callbacks/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/enterprise/litellm_enterprise/enterprise_callbacks/pagerduty/__init__.py b/enterprise/litellm_enterprise/enterprise_callbacks/pagerduty/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/__init__.py b/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/enterprise/litellm_enterprise/integrations/__init__.py b/enterprise/litellm_enterprise/integrations/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/enterprise/litellm_enterprise/litellm_core_utils/__init__.py b/enterprise/litellm_enterprise/litellm_core_utils/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/enterprise/litellm_enterprise/proxy/hooks/__init__.py b/enterprise/litellm_enterprise/proxy/hooks/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/enterprise/litellm_enterprise/proxy/vector_stores/__init__.py b/enterprise/litellm_enterprise/proxy/vector_stores/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/enterprise/litellm_enterprise/py.typed b/enterprise/litellm_enterprise/py.typed new file mode 100644 index 00000000000..e69de29bb2d diff --git a/enterprise/litellm_enterprise/types/__init__.py b/enterprise/litellm_enterprise/types/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/enterprise/litellm_enterprise/types/enterprise_callbacks/__init__.py b/enterprise/litellm_enterprise/types/enterprise_callbacks/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/enterprise/litellm_enterprise/types/proxy/__init__.py b/enterprise/litellm_enterprise/types/proxy/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm-proxy-extras/litellm_proxy_extras/py.typed b/litellm-proxy-extras/litellm_proxy_extras/py.typed new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/__init__.py b/litellm/__init__.py index 319da4e25eb..89310120768 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -2150,9 +2150,9 @@ def __getattr__(name: str) -> Any: # Lazy load encoding from main.py to avoid heavy tiktoken import if name == "encoding": - from ._lazy_imports import _get_litellm_globals + from ._lazy_imports import get_litellm_globals - _globals = _get_litellm_globals() + _globals = get_litellm_globals() # Check if already cached if "encoding" not in _globals: from .main import encoding as _encoding @@ -2162,9 +2162,9 @@ def __getattr__(name: str) -> Any: # Lazy load bedrock_tool_name_mappings instance if name == "bedrock_tool_name_mappings": - from ._lazy_imports import _get_litellm_globals + from ._lazy_imports import get_litellm_globals - _globals = _get_litellm_globals() + _globals = get_litellm_globals() # Check if already cached if "bedrock_tool_name_mappings" not in _globals: from .llms.bedrock.chat.invoke_handler import ( @@ -2176,9 +2176,9 @@ def __getattr__(name: str) -> Any: # Lazy load AzureOpenAIError exception class if name == "AzureOpenAIError": - from ._lazy_imports import _get_litellm_globals + from ._lazy_imports import get_litellm_globals - _globals = _get_litellm_globals() + _globals = get_litellm_globals() # Check if already cached if "AzureOpenAIError" not in _globals: from .llms.azure.common_utils import AzureOpenAIError as _AzureOpenAIError @@ -2188,9 +2188,9 @@ def __getattr__(name: str) -> Any: # Lazy load openaiOSeriesConfig instance if name == "openaiOSeriesConfig": - from ._lazy_imports import _get_litellm_globals + from ._lazy_imports import get_litellm_globals - _globals = _get_litellm_globals() + _globals = get_litellm_globals() if "openaiOSeriesConfig" not in _globals: # Import the config class and instantiate it config_class = __getattr__("OpenAIOSeriesConfig") @@ -2206,9 +2206,9 @@ def __getattr__(name: str) -> Any: "nvidiaNimEmbeddingConfig": "NvidiaNimEmbeddingConfig", } if name in _config_instances: - from ._lazy_imports import _get_litellm_globals + from ._lazy_imports import get_litellm_globals - _globals = _get_litellm_globals() + _globals = get_litellm_globals() if name not in _globals: # Import the config class and instantiate it config_class = __getattr__(_config_instances[name]) @@ -2221,9 +2221,9 @@ def __getattr__(name: str) -> Any: # Lazy load provider_list if name == "provider_list": - from ._lazy_imports import _get_litellm_globals + from ._lazy_imports import get_litellm_globals - _globals = _get_litellm_globals() + _globals = get_litellm_globals() # Check if already cached if "provider_list" not in _globals: # LlmProviders is eagerly imported above, so we can import it directly @@ -2234,9 +2234,9 @@ def __getattr__(name: str) -> Any: # Lazy load priority_reservation_settings instance if name == "priority_reservation_settings": - from ._lazy_imports import _get_litellm_globals + from ._lazy_imports import get_litellm_globals - _globals = _get_litellm_globals() + _globals = get_litellm_globals() # Check if already cached if "priority_reservation_settings" not in _globals: # Import the class and instantiate it @@ -2246,9 +2246,9 @@ def __getattr__(name: str) -> Any: # Lazy load logging_callback_manager instance if name == "logging_callback_manager": - from ._lazy_imports import _get_litellm_globals + from ._lazy_imports import get_litellm_globals - _globals = _get_litellm_globals() + _globals = get_litellm_globals() # Check if already cached if "logging_callback_manager" not in _globals: # Import the class and instantiate it @@ -2258,9 +2258,9 @@ def __getattr__(name: str) -> Any: # Lazy load _service_logger module if name == "_service_logger": - from ._lazy_imports import _get_litellm_globals + from ._lazy_imports import get_litellm_globals - _globals = _get_litellm_globals() + _globals = get_litellm_globals() # Check if already cached if "_service_logger" not in _globals: # Import the module lazily diff --git a/litellm/_lazy_imports.py b/litellm/_lazy_imports.py index 63142ee4f2f..933464d3f23 100644 --- a/litellm/_lazy_imports.py +++ b/litellm/_lazy_imports.py @@ -54,7 +54,7 @@ from ._lazy_imports_registry import ( ) -def _get_litellm_globals() -> dict: +def get_litellm_globals() -> dict: """ Get the globals dictionary of the litellm module. @@ -233,7 +233,7 @@ def _generic_lazy_import(name: str, import_map: dict[str, tuple[str, str]], cate raise AttributeError(f"{category} lazy import: unknown attribute {name!r}") # Step 2: Get the cache (where we store imported things) - _globals: Final = _get_litellm_globals() + _globals: Final = get_litellm_globals() # Step 3: If we've already imported it, just return the cached version if name in _globals: @@ -332,7 +332,7 @@ def _lazy_import_utils_module(name: str) -> Any: Handler for utils module lazy imports. This uses a custom implementation because utils module needs to use - _get_utils_globals() instead of _get_litellm_globals() for caching. + _get_utils_globals() instead of get_litellm_globals() for caching. """ # Check if this attribute exists in our map if name not in _UTILS_MODULE_IMPORT_MAP: @@ -379,7 +379,7 @@ def _lazy_import_llm_client_cache(name: str) -> Any: - "in_memory_llm_clients_cache" is a singleton instance of that class So we need custom logic to handle both cases. """ - _globals: Final = _get_litellm_globals() + _globals: Final = get_litellm_globals() # If already cached, return it if name in _globals: @@ -412,7 +412,7 @@ def _lazy_import_http_handlers(name: str) -> Any: - They need configuration (timeout, etc.) from the module globals - They use factory functions instead of direct instantiation """ - _globals: Final = _get_litellm_globals() + _globals: Final = get_litellm_globals() if name == "module_level_aclient": # Create an async HTTP client using the factory function diff --git a/litellm/litellm_core_utils/audio_utils/utils.py b/litellm/litellm_core_utils/audio_utils/utils.py index 0f9addb16f7..3b3775a8fe6 100644 --- a/litellm/litellm_core_utils/audio_utils/utils.py +++ b/litellm/litellm_core_utils/audio_utils/utils.py @@ -180,6 +180,7 @@ def get_audio_file_content_hash(file_obj: FileTypes) -> str: if isinstance(file_obj, tuple): if len(file_obj) < 2: fallback_filename = str(file_obj[0]) if len(file_obj) > 0 else None + file_content_obj = None else: fallback_filename = str(file_obj[0]) if file_obj[0] is not None else None file_content_obj = file_obj[1] @@ -206,7 +207,7 @@ def get_audio_file_content_hash(file_obj: FileTypes) -> str: except OSError: fallback_filename = str(file_content_obj) file_content = None - elif hasattr(file_content_obj, "read"): + elif file_content_obj is not None and hasattr(file_content_obj, "read"): try: current_position: Final = file_content_obj.tell() if hasattr(file_content_obj, "tell") else None if hasattr(file_content_obj, "seek"): diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index d51d31eaa3b..3a1a426eaa9 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -3684,7 +3684,7 @@ def _convert_to_bedrock_tool_call_invoke( # cache_control applies to the whole original # tool call; attach after the last split block. if tool.get("cache_control", None) is not None: - _cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block( + _cache_point_block = litellm.AmazonConverseConfig().get_cache_point_block( {"cache_control": tool["cache_control"]}, block_type="content_block", model=model, @@ -3701,7 +3701,7 @@ def _convert_to_bedrock_tool_call_invoke( # Check for cache_control and add a separate cachePoint block if tool.get("cache_control", None) is not None: - cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block( + cache_point_block = litellm.AmazonConverseConfig().get_cache_point_block( {"cache_control": tool["cache_control"]}, block_type="content_block", model=model, @@ -4360,7 +4360,7 @@ class BedrockConverseMessagesProcessor: elif element["type"] == "document": _part = BedrockConverseMessagesProcessor._process_document_message(element) _parts.append(_part) - _cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block( + _cache_point_block = litellm.AmazonConverseConfig().get_cache_point_block( message_block=cast(OpenAIMessageContentListBlock, element), block_type="content_block", model=model, @@ -4370,7 +4370,7 @@ class BedrockConverseMessagesProcessor: user_content.extend(_parts) elif message_block["content"] and isinstance(message_block["content"], str): _part = BedrockContentBlock(text=messages[msg_i]["content"]) - _cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block( + _cache_point_block = litellm.AmazonConverseConfig().get_cache_point_block( message_block, block_type="content_block", model=model ) user_content.append(_part) @@ -4417,7 +4417,7 @@ class BedrockConverseMessagesProcessor: # Add a separate cachePoint block if cache_control is present if tool_msg_cache_control is not None: - cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block( + cache_point_block = litellm.AmazonConverseConfig().get_cache_point_block( {"cache_control": tool_msg_cache_control}, block_type="content_block", model=model, @@ -4496,7 +4496,7 @@ class BedrockConverseMessagesProcessor: assistants_part = await BedrockImageProcessor.process_image_async(image_url=image_url) assistants_parts.append(assistants_part) # Add cache point block for assistant content elements - _cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block( + _cache_point_block = litellm.AmazonConverseConfig().get_cache_point_block( message_block=cast(OpenAIMessageContentListBlock, element), block_type="content_block", model=model, @@ -4510,7 +4510,7 @@ class BedrockConverseMessagesProcessor: assistant_content.append(BedrockContentBlock(text=_assistant_content)) # If content is empty/whitespace, skip it (don't add a placeholder) # Add cache point block for assistant string content - _cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block( + _cache_point_block = litellm.AmazonConverseConfig().get_cache_point_block( assistant_message_block, block_type="content_block", model=model ) if _cache_point_block is not None: @@ -4733,7 +4733,7 @@ def _bedrock_converse_messages_pt( elif element["type"] == "document": _part = BedrockConverseMessagesProcessor._process_document_message(element) _parts.append(_part) - _cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block( + _cache_point_block = litellm.AmazonConverseConfig().get_cache_point_block( message_block=cast(OpenAIMessageContentListBlock, element), block_type="content_block", model=model, @@ -4743,7 +4743,7 @@ def _bedrock_converse_messages_pt( user_content.extend(_parts) elif message_block["content"] and isinstance(message_block["content"], str): _part = BedrockContentBlock(text=messages[msg_i]["content"]) - _cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block( + _cache_point_block = litellm.AmazonConverseConfig().get_cache_point_block( message_block, block_type="content_block", model=model ) user_content.append(_part) @@ -4792,7 +4792,7 @@ def _bedrock_converse_messages_pt( # Add a separate cachePoint block if cache_control is present if tool_msg_cache_control is not None: - cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block( + cache_point_block = litellm.AmazonConverseConfig().get_cache_point_block( {"cache_control": tool_msg_cache_control}, block_type="content_block", model=model, @@ -4874,7 +4874,7 @@ def _bedrock_converse_messages_pt( assistants_part = BedrockImageProcessor.process_image_sync(image_url=image_url) assistants_parts.append(assistants_part) # Add cache point block for assistant content elements - _cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block( + _cache_point_block = litellm.AmazonConverseConfig().get_cache_point_block( message_block=cast(OpenAIMessageContentListBlock, element), block_type="content_block", model=model, @@ -4887,7 +4887,7 @@ def _bedrock_converse_messages_pt( if _assistant_content.strip(): assistant_content.append(BedrockContentBlock(text=_assistant_content)) # Add cache point block for assistant string content - _cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block( + _cache_point_block = litellm.AmazonConverseConfig().get_cache_point_block( assistant_message_block, block_type="content_block", model=model ) if _cache_point_block is not None: diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index 0b1689b8ee4..193987a3543 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -1081,7 +1081,7 @@ class AmazonConverseConfig(BaseConfig): optional_params["maxTokens"] = thinking_token_budget + DEFAULT_MAX_TOKENS @overload - def _get_cache_point_block( + def get_cache_point_block( self, message_block: OpenAIMessageContentListBlock | ChatCompletionUserMessage @@ -1093,7 +1093,7 @@ class AmazonConverseConfig(BaseConfig): pass @overload - def _get_cache_point_block( + def get_cache_point_block( self, message_block: OpenAIMessageContentListBlock | ChatCompletionUserMessage @@ -1104,7 +1104,7 @@ class AmazonConverseConfig(BaseConfig): ) -> ContentBlock | None: pass - def _get_cache_point_block( + def get_cache_point_block( self, message_block: OpenAIMessageContentListBlock | ChatCompletionUserMessage @@ -1149,14 +1149,14 @@ class AmazonConverseConfig(BaseConfig): system_prompt_indices.append(idx) if isinstance(message["content"], str) and message["content"]: system_content_blocks.append(SystemContentBlock(text=message["content"])) - cache_block = self._get_cache_point_block(message, block_type="system", model=model) + cache_block = self.get_cache_point_block(message, block_type="system", model=model) if cache_block: system_content_blocks.append(cache_block) elif isinstance(message["content"], list): for m in message["content"]: if m.get("type") == "text" and m.get("text"): system_content_blocks.append(SystemContentBlock(text=m["text"])) - cache_block = self._get_cache_point_block(m, block_type="system", model=model) + cache_block = self.get_cache_point_block(m, block_type="system", model=model) if cache_block: system_content_blocks.append(cache_block) if len(system_prompt_indices) > 0: diff --git a/litellm/llms/xai/oauth.py b/litellm/llms/xai/oauth.py index 8f303e9585f..37dae93a725 100644 --- a/litellm/llms/xai/oauth.py +++ b/litellm/llms/xai/oauth.py @@ -40,7 +40,7 @@ class XAIOAuthLoginRequiredError(XAIOAuthError): class _CallbackHandler(BaseHTTPRequestHandler): - server: "_CallbackServer" + server: "_CallbackServer" # pyright: ignore[reportIncompatibleVariableOverride] # stdlib stubs type server as BaseServer; _CallbackServer is the only server this handler is registered on def do_GET(self) -> None: parsed: Final = urlparse(self.path) diff --git a/litellm/proxy/_experimental/mcp_server/elicitation_handler.py b/litellm/proxy/_experimental/mcp_server/elicitation_handler.py index 66c262a6eb9..ce7e963f55f 100644 --- a/litellm/proxy/_experimental/mcp_server/elicitation_handler.py +++ b/litellm/proxy/_experimental/mcp_server/elicitation_handler.py @@ -9,10 +9,19 @@ MCP Spec Reference: https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation """ -from typing import Any, Final, Union +from typing import TYPE_CHECKING, Any, Final, Union from litellm._logging import verbose_logger +if TYPE_CHECKING: + from mcp.types import ( + ElicitRequestFormParams, + ElicitRequestParams, + ElicitRequestURLParams, + ElicitResult, + ErrorData, + ) + # Guard imports that require the mcp package try: from mcp.types import ( diff --git a/litellm/proxy/_experimental/mcp_server/sampling_handler.py b/litellm/proxy/_experimental/mcp_server/sampling_handler.py index 45490385df8..0f5c02bd781 100644 --- a/litellm/proxy/_experimental/mcp_server/sampling_handler.py +++ b/litellm/proxy/_experimental/mcp_server/sampling_handler.py @@ -18,7 +18,15 @@ if typing.TYPE_CHECKING: from fastapi import Request from mcp.client.session import ClientSession from mcp.shared.context import RequestContext - from mcp.types import ContentBlock, SamplingMessageContentBlock + from mcp.types import ( + ContentBlock, + CreateMessageResult, + CreateMessageResultWithTools, + ErrorData, + SamplingMessageContentBlock, + TextContent, + ToolUseContent, + ) from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.utils import ProxyLogging diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index f2267bbcf7f..ef7bfd4b4f6 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -79,6 +79,8 @@ from litellm.types.utils import CallTypes, StandardLoggingMCPToolCall from litellm.utils import Rules, client, function_setup if TYPE_CHECKING: + from mcp.server.session import ServerSession as _McpServerSession + from litellm.proxy._experimental.mcp_server.db import OAuthCredentialPayload # Short-lived in-memory cache for BYOK credentials. @@ -144,10 +146,6 @@ try: # Robust auth lookup keyed by session_object. _session_obj_auth_storage: "weakref.WeakKeyDictionary[Any, MCPAuthenticatedUser]" = weakref.WeakKeyDictionary() - - active_mcp_session_var: Final[contextvars.ContextVar[_McpServerSession | None]] = contextvars.ContextVar( - "active_mcp_session", default=None - ) except ImportError as e: verbose_logger.debug("MCP module not found: %s", e) MCP_AVAILABLE = False @@ -163,6 +161,10 @@ except ImportError as e: Server = None TextResourceContents = None +active_mcp_session_var: Final[contextvars.ContextVar["_McpServerSession | None"]] = contextvars.ContextVar( + "active_mcp_session", default=None +) + # Global variables to track initialization _SESSION_MANAGERS_INITIALIZED = False diff --git a/litellm/proxy/example_config_yaml/custom_guardrail.py b/litellm/proxy/example_config_yaml/custom_guardrail.py index 2f53bb4675a..979976ddfc4 100644 --- a/litellm/proxy/example_config_yaml/custom_guardrail.py +++ b/litellm/proxy/example_config_yaml/custom_guardrail.py @@ -1,11 +1,10 @@ -from typing import Any, Dict, Final, List, Literal, Optional, Union +from typing import Dict, Final, Optional, Union import litellm from litellm._logging import verbose_proxy_logger from litellm.caching.caching import DualCache from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.proxy._types import UserAPIKeyAuth -from litellm.proxy.guardrails.guardrail_helpers import should_proceed_based_on_metadata from litellm.types.utils import CallTypesLiteral # Global counter for tracking which guardrail was called (for load balancing tests) diff --git a/litellm/proxy/example_config_yaml/custom_handler.py b/litellm/proxy/example_config_yaml/custom_handler.py index 3bf998c726a..c0483dd3304 100644 --- a/litellm/proxy/example_config_yaml/custom_handler.py +++ b/litellm/proxy/example_config_yaml/custom_handler.py @@ -1,9 +1,7 @@ -import time -from typing import Any, Final, Optional +from typing import Final import litellm -from litellm import CustomLLM, ImageObject, ImageResponse, completion, get_llm_provider -from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler +from litellm import CustomLLM from litellm.types.utils import ModelResponse diff --git a/litellm/types/adapter.py b/litellm/types/adapter.py index 2995cfbc1c2..924fabcb86d 100644 --- a/litellm/types/adapter.py +++ b/litellm/types/adapter.py @@ -1,6 +1,4 @@ -from typing import List - -from typing_extensions import Dict, Required, TypedDict, override +from typing_extensions import TypedDict from litellm.integrations.custom_logger import CustomLogger diff --git a/litellm/types/google_genai/main.py b/litellm/types/google_genai/main.py index 467db318057..876a4d4533e 100644 --- a/litellm/types/google_genai/main.py +++ b/litellm/types/google_genai/main.py @@ -1,8 +1,6 @@ # Import types from the Google GenAI SDK -from typing import TYPE_CHECKING, Any, Dict, List, Optional, TypeAlias +from typing import TYPE_CHECKING, Any, Dict, Optional -from pydantic import BaseModel -from typing_extensions import TypedDict from litellm.types.llms.openai import BaseLiteLLMOpenAIResponseObject diff --git a/litellm/types/integrations/argilla.py b/litellm/types/integrations/argilla.py index 52dad347304..2def010a722 100644 --- a/litellm/types/integrations/argilla.py +++ b/litellm/types/integrations/argilla.py @@ -1,7 +1,4 @@ -import os -from datetime import datetime as dt -from enum import Enum -from typing import Any, Dict, Final, List, Literal, Optional, Set +from typing import Any, Dict, Final, List from typing_extensions import TypedDict diff --git a/litellm/types/llms/anthropic_skills.py b/litellm/types/llms/anthropic_skills.py index 22257888493..0659b499bcc 100644 --- a/litellm/types/llms/anthropic_skills.py +++ b/litellm/types/llms/anthropic_skills.py @@ -2,10 +2,10 @@ Type definitions for Anthropic Skills API """ -from typing import Any, Dict, List, Literal, Optional, Union +from typing import Any, Dict, List, Optional -from pydantic import BaseModel, Field -from typing_extensions import Required, TypedDict +from pydantic import BaseModel +from typing_extensions import TypedDict # Skills API Request Types diff --git a/litellm/types/llms/azure_ai.py b/litellm/types/llms/azure_ai.py index ddc9dbe3c55..49b7349c67e 100644 --- a/litellm/types/llms/azure_ai.py +++ b/litellm/types/llms/azure_ai.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, Final, Iterable, List, Literal, Optional, Union +from typing import List, Literal from typing_extensions import Required, TypedDict diff --git a/litellm/types/llms/custom_llm.py b/litellm/types/llms/custom_llm.py index d5499a41944..e57a7a28007 100644 --- a/litellm/types/llms/custom_llm.py +++ b/litellm/types/llms/custom_llm.py @@ -1,6 +1,4 @@ -from typing import List - -from typing_extensions import Dict, Required, TypedDict, override +from typing_extensions import TypedDict from litellm.llms.custom_llm import CustomLLM diff --git a/litellm/types/llms/databricks.py b/litellm/types/llms/databricks.py index 46f988ae4a0..c2bd0aa92bd 100644 --- a/litellm/types/llms/databricks.py +++ b/litellm/types/llms/databricks.py @@ -1,19 +1,12 @@ -import json -from typing import Any, Dict, Final, List, Literal, Optional, Union +from typing import Any, Dict, List, Literal, Optional, Union from pydantic import BaseModel from typing_extensions import ( - Protocol, Required, - Self, TypedDict, - TypeGuard, - get_origin, - override, - runtime_checkable, ) -from .openai import ChatCompletionToolCallChunk, ChatCompletionUsageBlock +from .openai import ChatCompletionUsageBlock class GenericStreamingChunk(TypedDict, total=False): diff --git a/litellm/types/llms/ollama.py b/litellm/types/llms/ollama.py index ca28120dd9d..9fcb6b755bd 100644 --- a/litellm/types/llms/ollama.py +++ b/litellm/types/llms/ollama.py @@ -1,16 +1,8 @@ -import json -from typing import Any, List, Optional, Union +from typing import List -from pydantic import BaseModel from typing_extensions import ( - Protocol, Required, - Self, TypedDict, - TypeGuard, - get_origin, - override, - runtime_checkable, ) diff --git a/litellm/types/llms/openrouter.py b/litellm/types/llms/openrouter.py index 39ed7e104fb..73bf647d4ea 100644 --- a/litellm/types/llms/openrouter.py +++ b/litellm/types/llms/openrouter.py @@ -1,6 +1,4 @@ -import json -from enum import Enum -from typing import Any, Dict, List, Literal, Optional, Tuple, Union +from typing import Dict from typing_extensions import TypedDict diff --git a/litellm/types/llms/rerank.py b/litellm/types/llms/rerank.py index fac093161c1..83cdb1caa0b 100644 --- a/litellm/types/llms/rerank.py +++ b/litellm/types/llms/rerank.py @@ -1,16 +1,7 @@ -import json -from enum import Enum -from typing import Any, Dict, List, Literal, Optional, Tuple, Union +from typing import Optional from typing_extensions import ( - Protocol, - Required, - Self, TypedDict, - TypeGuard, - get_origin, - override, - runtime_checkable, ) diff --git a/litellm/types/proxy/management_endpoints/internal_user_endpoints.py b/litellm/types/proxy/management_endpoints/internal_user_endpoints.py index faf2660a6f8..16f4c45e2f4 100644 --- a/litellm/types/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/types/proxy/management_endpoints/internal_user_endpoints.py @@ -1,7 +1,6 @@ -from typing import Any, Dict, Final, List, Literal, Optional, Union +from typing import Any, Dict, Final, List, Optional -from fastapi import HTTPException -from pydantic import BaseModel, EmailStr, field_validator +from pydantic import BaseModel, field_validator from litellm.proxy._types import ( LiteLLM_UserTableWithKeyCount, From 64f83a23e1874ef98d06302279edba59865f6e8f Mon Sep 17 00:00:00 2001 From: Mateo Wang <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:44:02 -0700 Subject: [PATCH 2/4] Revert "chore(ui): zero stale headroom on local dashboard eslint budgets" --- ui/litellm-dashboard/eslint-budgets.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ui/litellm-dashboard/eslint-budgets.json b/ui/litellm-dashboard/eslint-budgets.json index 3526d71ce90..f08e1bb6160 100644 --- a/ui/litellm-dashboard/eslint-budgets.json +++ b/ui/litellm-dashboard/eslint-budgets.json @@ -1,8 +1,8 @@ { "@typescript-eslint/no-explicit-any": { "max": 2040, "target": 1500 }, - "no-console": { "max": 12, "target": 0 }, - "complexity": { "max": 121, "target": 80 }, - "max-depth": { "max": 55, "target": 30 }, - "local/no-large-inline-object-arg": { "max": 469, "target": 300 }, - "local/no-long-condition-chain": { "max": 217, "target": 120 } + "no-console": { "max": 484, "target": 0 }, + "complexity": { "max": 140, "target": 80 }, + "max-depth": { "max": 70, "target": 30 }, + "local/no-large-inline-object-arg": { "max": 560, "target": 300 }, + "local/no-long-condition-chain": { "max": 265, "target": 120 } } From 85aad29885fcf556b0e2dbf202c03167e87fec90 Mon Sep 17 00:00:00 2001 From: Mateo Wang <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:45:56 -0700 Subject: [PATCH 3/4] chore: make no-console max 12 --- ui/litellm-dashboard/eslint-budgets.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/eslint-budgets.json b/ui/litellm-dashboard/eslint-budgets.json index f08e1bb6160..c4f078f2ff2 100644 --- a/ui/litellm-dashboard/eslint-budgets.json +++ b/ui/litellm-dashboard/eslint-budgets.json @@ -1,6 +1,6 @@ { "@typescript-eslint/no-explicit-any": { "max": 2040, "target": 1500 }, - "no-console": { "max": 484, "target": 0 }, + "no-console": { "max": 12, "target": 0 }, "complexity": { "max": 140, "target": 80 }, "max-depth": { "max": 70, "target": 30 }, "local/no-large-inline-object-arg": { "max": 560, "target": 300 }, From 2792887e47d698edaeb8a2d0ad1abfc9576609a6 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Wed, 5 Aug 2026 11:33:55 -0700 Subject: [PATCH 4/4] fix(proxy): give proxy_admin_viewer read parity with proxy_admin (#35851) * fix(proxy): give proxy_admin_viewer read parity with proxy_admin Route-level checks already default-allow management GETs for the viewer role, but ~15 handlers compared user_role to PROXY_ADMIN only, dropping viewers into regular-user scoping (/key/list, /user/info, /model/info, guardrails, prompts, agents, memory, workflows, MCP catalog, coordination redis settings, credential migration check, enterprise projects). Swap those read paths to user_api_key_has_admin_view; write gates unchanged. The dashboard now presents the viewer session as Admin for all gating (effectiveSessionRole) so every page fetches with admin visibility, with userRoleLabel/isViewOnly preserving the account-menu label and the playground cost guard. The server remains the write authority. * refactor(agents): remove side-effectful health_check param from GET /v1/agents Addresses a security review finding on the admin viewer read parity change: listing agents with health_check=true made the proxy issue a server-side GET to every agent URL, so a read-scoped caller could trigger request fan-out beyond their object permissions. The list endpoint is now a pure read for every role. Removes the query param, the URL probing helper and its timeouts, the AgentHealthCheck httpx provider tag, and the dashboard's Health Check toggle. Requests still passing health_check=true get the full list back with the param ignored. * fix(proxy): keep credential encryption check proxy_admin only The residual scan behind GET /credentials/migrate-encryption/check loads every model, credential, MCP, team, and verification-token row and runs a decryption attempt on each stored value. Extending it to proxy_admin_viewer let a read-only account repeatedly trigger deployment-wide scans, so the route keeps its original full-admin gate. * fix(agents): restore health_check, keep list fast path proxy_admin only Restores the agent health_check feature exactly as before this PR: the query param, the URL probing helper, the httpx provider tag, and the dashboard toggle all return, so existing callers keep the filtering contract. The viewer expansion is instead reverted at its source: the GET /v1/agents admin fast path stays PROXY_ADMIN only, so a proxy_admin_viewer goes through the object-permission scoped branch as before and cannot fan out health checks beyond their allowlist. The viewer read of a single agent stays viewer-inclusive since it has no side effects. --- .../management_endpoints/project_endpoints.py | 4 +- .../mcp_server/rest_endpoints.py | 10 +- litellm/proxy/agent_endpoints/endpoints.py | 13 +- litellm/proxy/auth/auth_checks.py | 3 - litellm/proxy/auth/route_checks.py | 6 +- .../proxy/guardrails/guardrail_endpoints.py | 4 +- .../coordination_redis_endpoints.py | 4 +- .../internal_user_endpoints.py | 11 +- .../key_management_endpoints.py | 3 +- .../workflow_management_endpoints.py | 19 ++- litellm/proxy/memory/memory_endpoints.py | 3 +- litellm/proxy/prompts/prompt_endpoints.py | 25 ++-- litellm/proxy/proxy_server.py | 4 +- .../proxy/agent_endpoints/test_endpoints.py | 87 +++++++++++ .../proxy/auth/test_auth_checks.py | 22 +++ .../proxy/auth/test_route_checks.py | 54 +++++++ .../guardrails/test_guardrail_endpoints.py | 136 +++++++++++++++++ .../test_coordination_redis_endpoints.py | 44 ++++++ .../test_internal_user_endpoints.py | 47 ++++-- .../test_key_management_endpoints.py | 119 +++++++++++++++ .../test_workflow_management_endpoints.py | 132 +++++++++++++++- .../proxy/memory/test_memory_endpoints.py | 74 ++++++++- .../proxy/prompts/test_prompt_endpoints.py | 141 ++++++++++++++++++ .../test_team_model_name_translation.py | 42 ++++++ .../(dashboard)/hooks/useAuthorized.test.ts | 40 +++++ .../app/(dashboard)/hooks/useAuthorized.ts | 6 +- .../app/(dashboard)/playground/page.test.tsx | 1 + .../src/app/(dashboard)/playground/page.tsx | 5 +- .../Navbar/UserDropdown/UserDropdown.test.tsx | 12 +- .../Navbar/UserDropdown/UserDropdown.tsx | 2 +- .../SidebarAccountMenu.test.tsx | 12 +- .../SidebarAccountMenu/SidebarAccountMenu.tsx | 2 +- .../src/components/leftnav.test.tsx | 10 +- .../src/components/leftnav.tsx | 3 +- .../src/components/user_dashboard.tsx | 28 +--- .../src/contexts/AuthContext.tsx | 4 +- ui/litellm-dashboard/src/utils/roles.test.ts | 64 ++++++++ ui/litellm-dashboard/src/utils/roles.ts | 12 ++ 38 files changed, 1094 insertions(+), 114 deletions(-) diff --git a/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py b/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py index 9d668985eb8..1f693526d1f 100644 --- a/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py +++ b/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py @@ -831,7 +831,7 @@ async def project_info( ) # Check if user has access to this project (admin or team member) - is_admin = user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN + is_admin = user_api_key_has_admin_view(user_api_key_dict) is_team_member = False if project.team_id and user_api_key_dict.user_id: @@ -886,7 +886,7 @@ async def list_projects( ) # If proxy admin, get all projects - if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN: + if user_api_key_has_admin_view(user_api_key_dict): projects: Sequence[ prisma_models.LiteLLM_ProjectTable ] = await prisma_client.db.litellm_projecttable.find_many( diff --git a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py index 3fb8e6fe9bb..76618e0f742 100644 --- a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py @@ -30,7 +30,11 @@ from litellm.proxy._experimental.mcp_server.utils import ( get_server_prefix, merge_mcp_headers, ) -from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth +from litellm.proxy._types import ( + LitellmUserRoles, + UserAPIKeyAuth, + user_api_key_has_admin_view, +) from litellm.proxy.auth.ip_address_utils import IPAddressUtils from litellm.proxy.auth.user_api_key_auth import user_api_key_auth @@ -738,9 +742,7 @@ if MCP_AVAILABLE: # The full catalog (allowlist filter skipped) is admin-only so the # REST endpoint can't be used to enumerate deliberately-disabled tools. - apply_tool_filters: Final = not ( - include_disabled_tools and user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN - ) + apply_tool_filters: Final = not (include_disabled_tools and user_api_key_has_admin_view(user_api_key_dict)) if server_id is None: server_id = mcp_server_name diff --git a/litellm/proxy/agent_endpoints/endpoints.py b/litellm/proxy/agent_endpoints/endpoints.py index f729d422d1d..1f9c6e1cc05 100644 --- a/litellm/proxy/agent_endpoints/endpoints.py +++ b/litellm/proxy/agent_endpoints/endpoints.py @@ -21,7 +21,12 @@ import litellm from litellm._logging import verbose_proxy_logger from litellm.litellm_core_utils.litellm_logging import _get_masked_values from litellm.llms.custom_httpx.http_handler import get_async_httpx_client -from litellm.proxy._types import CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth +from litellm.proxy._types import ( + CommonProxyErrors, + LitellmUserRoles, + UserAPIKeyAuth, + user_api_key_has_admin_view, +) from litellm.proxy.a2a.agent_card import ( SUPPORTED_A2A_PROTOCOL_VERSIONS, merge_agent_card, @@ -468,11 +473,7 @@ async def get_agent_by_id( """ await check_feature_access_for_user(user_api_key_dict, "agents") - is_admin = ( - user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN - or user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value - ) - if not is_admin: + if not user_api_key_has_admin_view(user_api_key_dict): from litellm.proxy.agent_endpoints.auth.agent_permission_handler import ( AgentRequestHandler, ) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 5ef4eb471ad..f17c9fff31a 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -832,9 +832,6 @@ def _is_user_proxy_admin(user_obj: LiteLLM_UserTable | None): if user_obj.user_role is not None and user_obj.user_role == LitellmUserRoles.PROXY_ADMIN.value: return True - if user_obj.user_role is not None and user_obj.user_role == LitellmUserRoles.PROXY_ADMIN.value: - return True - return False diff --git a/litellm/proxy/auth/route_checks.py b/litellm/proxy/auth/route_checks.py index 8a34438b141..04eb7ab326b 100644 --- a/litellm/proxy/auth/route_checks.py +++ b/litellm/proxy/auth/route_checks.py @@ -260,7 +260,11 @@ class RouteChecks: query_params: Final = request.query_params user_id: Final = query_params.get("user_id") verbose_proxy_logger.debug("user_id: %s & valid_token.user_id: %s", user_id, valid_token.user_id) - if user_id and user_id != valid_token.user_id: + if ( + user_id + and user_id != valid_token.user_id + and _user_role != LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value + ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"key not allowed to access this user's info. user_id={user_id}, key's user_id={valid_token.user_id}", diff --git a/litellm/proxy/guardrails/guardrail_endpoints.py b/litellm/proxy/guardrails/guardrail_endpoints.py index 79c4362055e..aef5f2deac4 100644 --- a/litellm/proxy/guardrails/guardrail_endpoints.py +++ b/litellm/proxy/guardrails/guardrail_endpoints.py @@ -212,7 +212,7 @@ async def list_guardrails_v2( from litellm.proxy.guardrails.guardrail_registry import IN_MEMORY_GUARDRAIL_HANDLER from litellm.proxy.proxy_server import prisma_client - is_admin: Final = user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN + is_admin: Final = _user_has_admin_view(user_api_key_dict) try: guardrails = ( @@ -944,7 +944,7 @@ async def get_guardrail_submission( if prisma_client is None: raise HTTPException(status_code=500, detail="Prisma client not initialized") - is_admin: Final = user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN + is_admin: Final = _user_has_admin_view(user_api_key_dict) try: row: Final = await _guardrails_table(prisma_client).find_unique(where={"guardrail_id": guardrail_id}) diff --git a/litellm/proxy/management_endpoints/coordination_redis_endpoints.py b/litellm/proxy/management_endpoints/coordination_redis_endpoints.py index 2cfb5cd8793..fe9a613656d 100644 --- a/litellm/proxy/management_endpoints/coordination_redis_endpoints.py +++ b/litellm/proxy/management_endpoints/coordination_redis_endpoints.py @@ -33,6 +33,7 @@ from litellm.proxy._types import ( LitellmTableNames, LitellmUserRoles, UserAPIKeyAuth, + user_api_key_has_admin_view, ) from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.utils import invalidate_config_param @@ -302,7 +303,8 @@ async def get_coordination_redis_settings( - fields: all configurable settings with their metadata (type, description, default, section) - source: "coordination_redis" | "cache_backend" | "environment" | null """ - _enforce_proxy_admin(user_api_key_dict) + if not user_api_key_has_admin_view(user_api_key_dict): + _enforce_proxy_admin(user_api_key_dict) settings: Final = await _current_coordination_redis_settings() source: Final = _coordination_redis_source(settings) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 97b4ec76c50..cefc7371ce6 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -714,11 +714,10 @@ def _enforce_user_info_access(user_id: str | None, user_api_key_dict: UserAPIKey """ if user_id is None: return - # Only true proxy admin bypasses ownership. PROXY_ADMIN_VIEW_ONLY is - # subject to the same `user_id == valid_token.user_id` rule that - # `RouteChecks.non_proxy_admin_allowed_routes_check` applies upstream - # for the `/user/info` route. - if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN: + # Admin-view roles (PROXY_ADMIN and PROXY_ADMIN_VIEW_ONLY) bypass + # ownership, mirroring the `/user/info` carve-out that + # `RouteChecks.non_proxy_admin_allowed_routes_check` applies upstream. + if _user_has_admin_view(user_api_key_dict): return if user_id == user_api_key_dict.user_id: return @@ -862,7 +861,7 @@ async def user_info( raise Exception( "Database not connected. Connect a database to your proxy - https://docs.litellm.ai/docs/simple_proxy#managing-auth---virtual-keys" ) - if user_id is None and user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN: + if user_id is None and _user_has_admin_view(user_api_key_dict): return await _get_user_info_for_proxy_admin(user_api_key_dict=user_api_key_dict) elif user_id is None: user_id = user_api_key_dict.user_id diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index e4def45892b..068429890c8 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -78,6 +78,7 @@ from litellm.proxy.management_endpoints.common_utils import ( _is_user_team_admin, _set_object_metadata_field, _team_member_has_permission, + _user_has_admin_view, validate_finite_spend, ) from litellm.proxy.management_endpoints.model_management_endpoints import ( @@ -5102,7 +5103,7 @@ async def validate_key_list_check( key_hash: str | None, prisma_client: PrismaClient, ) -> LiteLLM_UserTable | None: - if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value: + if _user_has_admin_view(user_api_key_dict): return None if user_api_key_dict.user_id is None: diff --git a/litellm/proxy/management_endpoints/workflow_management_endpoints.py b/litellm/proxy/management_endpoints/workflow_management_endpoints.py index 7e2c7404199..70a6cc507f5 100644 --- a/litellm/proxy/management_endpoints/workflow_management_endpoints.py +++ b/litellm/proxy/management_endpoints/workflow_management_endpoints.py @@ -25,7 +25,12 @@ except ImportError: from pydantic import BaseModel from litellm._logging import verbose_proxy_logger -from litellm.proxy._types import CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth +from litellm.proxy._types import ( + CommonProxyErrors, + LitellmUserRoles, + UserAPIKeyAuth, + user_api_key_has_admin_view, +) from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.repositories.table_repositories import ( WorkflowEventRepository, @@ -47,6 +52,10 @@ def _is_admin(user_api_key_dict: UserAPIKeyAuth) -> bool: return user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value +def _read_scope_caller(user_api_key_dict: UserAPIKeyAuth) -> UserAPIKeyAuth | None: + return None if user_api_key_has_admin_view(user_api_key_dict) else user_api_key_dict + + def _caller_key(user_api_key_dict: UserAPIKeyAuth) -> str | None: """Return the hashed key token that identifies this caller, or None for master key.""" return user_api_key_dict.token @@ -199,7 +208,7 @@ async def list_workflow_runs( where["status"] = {"in": statuses} if len(statuses) > 1 else statuses[0] # Non-admin callers are scoped to their own key. - if not _is_admin(user_api_key_dict): + if not user_api_key_has_admin_view(user_api_key_dict): caller: Final = _caller_key(user_api_key_dict) if caller: where["created_by"] = caller @@ -238,7 +247,7 @@ async def get_workflow_run( ) if run is None: raise HTTPException(status_code=404, detail=f"Run '{run_id}' not found") - if not _is_admin(user_api_key_dict): + if not user_api_key_has_admin_view(user_api_key_dict): caller: Final = _caller_key(user_api_key_dict) if not caller or run.created_by != caller: raise HTTPException(status_code=404, detail=f"Run '{run_id}' not found") @@ -377,7 +386,7 @@ async def list_workflow_events( if prisma_client is None: raise HTTPException(status_code=500, detail=CommonProxyErrors.db_not_connected_error.value) - await _require_run(prisma_client, run_id, user_api_key_dict) + await _require_run(prisma_client, run_id, _read_scope_caller(user_api_key_dict)) try: events: Final = await WorkflowEventRepository(prisma_client).table.find_many( @@ -461,7 +470,7 @@ async def list_workflow_messages( if prisma_client is None: raise HTTPException(status_code=500, detail=CommonProxyErrors.db_not_connected_error.value) - await _require_run(prisma_client, run_id, user_api_key_dict) + await _require_run(prisma_client, run_id, _read_scope_caller(user_api_key_dict)) try: messages: Final = await WorkflowMessageRepository(prisma_client).table.find_many( diff --git a/litellm/proxy/memory/memory_endpoints.py b/litellm/proxy/memory/memory_endpoints.py index 33d131bf3b2..987823d987f 100644 --- a/litellm/proxy/memory/memory_endpoints.py +++ b/litellm/proxy/memory/memory_endpoints.py @@ -27,6 +27,7 @@ from litellm.proxy._types import ( CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth, + user_api_key_has_admin_view, ) from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.repositories.table_repositories import MemoryRepository @@ -66,7 +67,7 @@ def _visibility_filter(user_api_key_dict: UserAPIKeyAuth) -> dict | None: Prisma `where` fragment restricting rows to those the caller can see. Returns None for admins (no restriction). """ - if _is_admin(user_api_key_dict): + if user_api_key_has_admin_view(user_api_key_dict): return None ors: Final[list[dict]] = [] if user_api_key_dict.user_id: diff --git a/litellm/proxy/prompts/prompt_endpoints.py b/litellm/proxy/prompts/prompt_endpoints.py index 4ac88f87596..d8e9f8dfaee 100644 --- a/litellm/proxy/prompts/prompt_endpoints.py +++ b/litellm/proxy/prompts/prompt_endpoints.py @@ -18,7 +18,12 @@ from fastapi import ( from pydantic import BaseModel from litellm._logging import verbose_proxy_logger -from litellm.proxy._types import CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth +from litellm.proxy._types import ( + CommonProxyErrors, + LitellmUserRoles, + UserAPIKeyAuth, + user_api_key_has_admin_view, +) from litellm.proxy.auth.auth_utils import is_request_body_safe from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.common_utils.path_utils import safe_filename @@ -317,7 +322,6 @@ async def list_prompts( } ``` """ - from litellm.proxy._types import LitellmUserRoles from litellm.proxy.prompts.prompt_registry import IN_MEMORY_PROMPT_REGISTRY # check key metadata for prompts @@ -347,10 +351,7 @@ async def list_prompts( prompt_list.append(prompt_copy) return ListPromptsResponse(prompts=prompt_list) # check if user is proxy admin - show all prompts - if user_api_key_dict.user_role is not None and ( - user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN - or user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value - ): + if user_api_key_has_admin_view(user_api_key_dict): # Get all prompts and filter to show only the latest version of each all_prompts = list(IN_MEMORY_PROMPT_REGISTRY.IN_MEMORY_PROMPTS.values()) if environment: @@ -422,10 +423,7 @@ async def get_prompt_versions( from litellm.proxy.proxy_server import prisma_client # Only allow proxy admins to view version history - if user_api_key_dict.user_role is None or ( - user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN - and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value - ): + if not user_api_key_has_admin_view(user_api_key_dict): raise HTTPException(status_code=403, detail="Only proxy admins can view prompt versions") base_prompt_id: Final = get_base_prompt_id(prompt_id=prompt_id) @@ -581,12 +579,7 @@ async def get_prompt_info( prompts = cast(list[str] | None, user_api_key_dict.metadata.get("prompts", None)) if prompts is not None and prompt_id not in prompts: raise HTTPException(status_code=400, detail=f"Prompt {prompt_id} not found") - if user_api_key_dict.user_role is not None and ( - user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN - or user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value - ): - pass - else: + if not user_api_key_has_admin_view(user_api_key_dict): raise HTTPException( status_code=403, detail=f"You are not authorized to access this prompt. Your role - {user_api_key_dict.user_role}, Your key's prompts - {prompts}", diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 3cb2f795c61..61c6ce22a91 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -8876,7 +8876,7 @@ async def model_list( # Check if scope=expand is requested and user has admin privileges should_expand_scope = False if scope == "expand": - should_expand_scope = await _user_has_admin_privileges( + should_expand_scope = _user_has_admin_view(user_api_key_dict) or await _user_has_admin_privileges( user_api_key_dict=user_api_key_dict, prisma_client=prisma_client, user_api_key_cache=user_api_key_cache, @@ -11479,7 +11479,7 @@ async def _populate_team_access_on_models( """ user_teams: list[str] | Literal["*"] | None = None direct_access_models: list[str] = [] - if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN: + if _user_has_admin_view(user_api_key_dict): user_teams = "*" direct_access_models = llm_router.get_model_ids(exclude_team_models=True) # has access to all models elif user_api_key_dict.user_id is not None: diff --git a/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py b/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py index bcd3333baf9..3e097711ad7 100644 --- a/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py +++ b/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py @@ -368,6 +368,17 @@ class TestAgentByIdKeyRedaction: assert resp.status_code == 200 assert resp.json()["keys"] is None + def test_view_only_admin_reads_a_denied_agent_but_still_without_keys(self): + """proxy_admin_viewer skips the per-agent object_permission gate (denied + here) yet stays on the redacted response path.""" + with patch( + "litellm.proxy.agent_endpoints.auth.agent_permission_handler.AgentRequestHandler.is_agent_allowed", + AsyncMock(return_value=False), + ): + resp = self._get_as(LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY) + assert resp.status_code == 200 + assert resp.json()["keys"] is None + # ---------- RBAC enforcement tests ---------- @@ -469,6 +480,82 @@ class TestAgentRBACInternalUserViewOnly: assert resp.status_code == 403 +class TestAgentRBACProxyAdminViewOnly: + """Read-only proxy admins go through the object-permission scoped branch on + GET /v1/agents (the admin fast path stays full PROXY_ADMIN only, so viewers + cannot fan out health checks beyond their allowlist), and secret unredaction + also stays gated on full PROXY_ADMIN.""" + + @pytest.fixture(autouse=True) + def _setup(self, monkeypatch): + from litellm.proxy.agent_endpoints import agent_registry as ar_mod + + self.viewer_client = _make_app_with_role(LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY) + self.admin_client = _make_app_with_role(LitellmUserRoles.PROXY_ADMIN) + self.agents = [ + AgentResponse( + agent_id=f"agent-{index}", + agent_name=f"Agent {index}", + agent_card_params=_sample_agent_card_params(), + litellm_params={"api_key": "sk-super-secret-agent-key"}, + ) + for index in (1, 2) + ] + self.mock_registry = MagicMock() + self.mock_registry.get_agent_list = MagicMock(return_value=self.agents) + monkeypatch.setattr(ar_mod, "global_agent_registry", self.mock_registry) + + self.allowed_agents_spy = AsyncMock(return_value=["someone-elses-agent"]) + monkeypatch.setattr( + "litellm.proxy.agent_endpoints.auth.agent_permission_handler.AgentRequestHandler.get_allowed_agents", + self.allowed_agents_spy, + ) + + def _list_agents(self, test_client: TestClient): + key_row = MagicMock() + key_row.token = "hash-aaa" + key_row.agent_id = "agent-1" + key_row.key_alias = "primary" + key_row.key_name = "sk-...aaa" + + with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma: + mock_prisma.db.litellm_agentstable.find_many = AsyncMock(return_value=[]) + mock_prisma.db.litellm_verificationtoken.find_many = AsyncMock( + return_value=[key_row] + ) + return test_client.get("/v1/agents", headers={"Authorization": "Bearer k"}) + + def test_should_scope_view_only_admin_to_allowed_agents(self): + """The key/team allowlist here excludes every registered agent; a viewer + on the admin fast path would see everything, so an empty response pins + that viewers stay in the scoped branch.""" + resp = self._list_agents(self.viewer_client) + + assert resp.status_code == 200 + assert resp.json() == [] + self.allowed_agents_spy.assert_awaited_once() + + def test_should_still_redact_secrets_for_view_only_admin(self): + """An unrestricted viewer (empty allowlist means no restrictions) sees the + same agents as an admin but with keys stripped and litellm_params masked.""" + self.allowed_agents_spy.return_value = [] + viewer_resp = self._list_agents(self.viewer_client) + admin_resp = self._list_agents(self.admin_client) + + assert viewer_resp.status_code == 200 + viewer_by_id = {agent["agent_id"]: agent for agent in viewer_resp.json()} + assert set(viewer_by_id) == {"agent-1", "agent-2"} + assert viewer_by_id["agent-1"]["keys"] is None + assert "sk-super-secret-agent-key" not in viewer_resp.text + + admin_by_id = {agent["agent_id"]: agent for agent in admin_resp.json()} + assert admin_by_id["agent-1"]["keys"][0]["token"] == "hash-aaa" + assert ( + admin_by_id["agent-1"]["litellm_params"]["api_key"] + == "sk-super-secret-agent-key" + ) + + class TestAgentRBACProxyAdmin: """Proxy admins should have full CRUD access to agents.""" diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index d1b5395c73d..a5211ba83e7 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -5462,3 +5462,25 @@ async def test_get_project_object_db_fetch_returns_cached_obj(): assert isinstance(result, LiteLLM_ProjectTableCachedObj) assert result.project_id == "p-1" assert result.project_alias == "proj" + + +def test_is_user_proxy_admin_rejects_view_only_admin(): + """This predicate skips `non_proxy_admin_allowed_routes_check` entirely, so an + Admin Viewer answering True here would gain every write route. Read parity for + that role belongs in the route checks, never here.""" + from litellm.proxy.auth.auth_checks import _is_user_proxy_admin + + viewer = LiteLLM_UserTable( + user_id="viewer_user", + user_email="viewer@example.com", + user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value, + ) + admin = LiteLLM_UserTable( + user_id="admin_user", + user_email="admin@example.com", + user_role=LitellmUserRoles.PROXY_ADMIN.value, + ) + + assert _is_user_proxy_admin(user_obj=viewer) is False + assert _is_user_proxy_admin(user_obj=admin) is True + assert _is_user_proxy_admin(user_obj=None) is False diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index 87f5187b5a1..9285b997efc 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -3192,3 +3192,57 @@ def test_internal_user_blocked_from_search_tool_writes(route): assert "Only proxy admin" in str(exc_info.value) assert f"Route={route}" in str(exc_info.value) assert "Your role=internal_user" in str(exc_info.value) + + +def test_proxy_admin_viewer_can_read_another_users_info(): + """Admin Viewer has read parity with Proxy Admin, so the /user/info + key-ownership gate must not apply to it — the Users page reads every row.""" + user_obj = LiteLLM_UserTable( + user_id="viewer_user", + user_email="viewer@example.com", + user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value, + ) + valid_token = UserAPIKeyAuth( + user_id="viewer_user", + user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value, + ) + request = MagicMock(spec=Request) + request.query_params = {"user_id": "some_other_user"} + + RouteChecks.non_proxy_admin_allowed_routes_check( + user_obj=user_obj, + _user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value, + route="/user/info", + request=request, + valid_token=valid_token, + request_data={}, + ) + + +def test_internal_user_still_blocked_from_another_users_info(): + """The Admin Viewer carve-out above must stay scoped to that role; internal + users keep hitting the ownership 403.""" + user_obj = LiteLLM_UserTable( + user_id="internal_user", + user_email="user@example.com", + user_role=LitellmUserRoles.INTERNAL_USER.value, + ) + valid_token = UserAPIKeyAuth( + user_id="internal_user", + user_role=LitellmUserRoles.INTERNAL_USER.value, + ) + request = MagicMock(spec=Request) + request.query_params = {"user_id": "some_other_user"} + + with pytest.raises(HTTPException) as exc_info: + RouteChecks.non_proxy_admin_allowed_routes_check( + user_obj=user_obj, + _user_role=LitellmUserRoles.INTERNAL_USER.value, + route="/user/info", + request=request, + valid_token=valid_token, + request_data={}, + ) + + assert exc_info.value.status_code == 403 + assert "key not allowed to access this user's info" in str(exc_info.value.detail) diff --git a/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py b/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py index 1c452e2fb6c..e1dd6b7d48b 100644 --- a/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py +++ b/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py @@ -339,6 +339,109 @@ async def test_list_guardrails_v2_masks_sensitive_data_in_config_guardrails(mock assert params["mode"] == "during_call" +@pytest.mark.asyncio +async def test_list_guardrails_v2_admin_viewer_sees_guardrails_of_teams_they_are_not_in( + mocker, +): + """ + proxy_admin_viewer reads the same unscoped list as proxy_admin: a team-owned + guardrail must surface even though the viewer belongs to no teams. + """ + other_team_guardrail = { + "guardrail_id": "other-team-guardrail", + "guardrail_name": "Other Team Guardrail", + "litellm_params": {"guardrail": "bedrock", "mode": "pre_call"}, + "guardrail_info": {"description": "owned by a team the viewer is not in"}, + "team_id": "team-viewer-is-not-in", + "created_at": datetime.now(), + "updated_at": datetime.now(), + } + + mock_prisma_client = mocker.Mock() + mock_prisma_client.db = mocker.Mock() + mock_prisma_client.db.litellm_guardrailstable = mocker.Mock() + mock_prisma_client.db.litellm_guardrailstable.find_many = AsyncMock( + return_value=[other_team_guardrail] + ) + + mock_in_memory_handler = mocker.Mock() + mock_in_memory_handler.list_in_memory_guardrails.return_value = [] + + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + mocker.patch( + "litellm.proxy.guardrails.guardrail_registry.IN_MEMORY_GUARDRAIL_HANDLER", + mock_in_memory_handler, + ) + mock_get_user_team_ids = mocker.patch( + "litellm.proxy.guardrails.guardrail_endpoints._get_user_team_ids", + AsyncMock(return_value=[]), + ) + + viewer_auth = UserAPIKeyAuth( + user_id="viewer-1", user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY + ) + response = await list_guardrails_v2(user_api_key_dict=viewer_auth) + + assert [g.guardrail_id for g in response.guardrails] == ["other-team-guardrail"] + mock_get_user_team_ids.assert_not_called() + + +@pytest.mark.asyncio +async def test_list_guardrails_v2_masks_sensitive_data_for_admin_viewer(mocker): + """ + Read parity for proxy_admin_viewer must not also hand out unmasked secrets. + The guardrail is team-owned so it only reaches the viewer via the admin path. + """ + other_team_guardrail_with_secrets = { + "guardrail_id": "other-team-secret-guardrail", + "guardrail_name": "Other Team Guardrail with Secrets", + "litellm_params": { + "guardrail": "azure/text_moderations", + "mode": "pre_call", + "api_key": "sk-viewer-must-not-see-this", + }, + "guardrail_info": {}, + "team_id": "team-viewer-is-not-in", + "created_at": datetime.now(), + "updated_at": datetime.now(), + } + + mock_prisma_client = mocker.Mock() + mock_prisma_client.db = mocker.Mock() + mock_prisma_client.db.litellm_guardrailstable = mocker.Mock() + mock_prisma_client.db.litellm_guardrailstable.find_many = AsyncMock( + return_value=[other_team_guardrail_with_secrets] + ) + + mock_in_memory_handler = mocker.Mock() + mock_in_memory_handler.list_in_memory_guardrails.return_value = [] + + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + mocker.patch( + "litellm.proxy.guardrails.guardrail_registry.IN_MEMORY_GUARDRAIL_HANDLER", + mock_in_memory_handler, + ) + mocker.patch( + "litellm.proxy.guardrails.guardrail_endpoints._get_user_team_ids", + AsyncMock(return_value=[]), + ) + + viewer_auth = UserAPIKeyAuth( + user_id="viewer-1", user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY + ) + response = await list_guardrails_v2(user_api_key_dict=viewer_auth) + + guardrail = next( + g + for g in response.guardrails + if g.guardrail_id == "other-team-secret-guardrail" + ) + params = guardrail.litellm_params.model_dump() + assert params["api_key"] != "sk-viewer-must-not-see-this" + assert "****" in str(params["api_key"]) + assert params["guardrail"] == "azure/text_moderations" + + @pytest.mark.asyncio async def test_get_guardrail_info_from_db(mocker, mock_prisma_client): """Test getting guardrail info from DB""" @@ -2037,6 +2140,39 @@ async def test_get_guardrail_submission_non_admin_other_team_forbidden(mocker): assert exc_info.value.status_code == 403 +@pytest.mark.asyncio +async def test_get_guardrail_submission_admin_viewer_other_team_allowed(mocker): + """proxy_admin_viewer reads any team's submission without the membership check.""" + mock_prisma = mocker.Mock() + row = mocker.Mock( + guardrail_id="sub-1", + guardrail_name="team-guard", + status="pending_review", + team_id="team-other", + litellm_params={}, + guardrail_info={}, + submitted_at=None, + reviewed_at=None, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + mock_prisma.db.litellm_guardrailstable.find_unique = AsyncMock(return_value=row) + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma) + mock_get_user_team_ids = mocker.patch( + "litellm.proxy.guardrails.guardrail_endpoints._get_user_team_ids", + AsyncMock(return_value=[]), + ) + user = UserAPIKeyAuth( + user_id="viewer-1", user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY + ) + + result = await get_guardrail_submission("sub-1", user) + + assert result.guardrail_id == "sub-1" + assert result.team_id == "team-other" + mock_get_user_team_ids.assert_not_called() + + @pytest.mark.asyncio async def test_approve_guardrail_submission_success(mocker): """Approve sets status to active and initializes guardrail in memory.""" diff --git a/tests/test_litellm/proxy/management_endpoints/test_coordination_redis_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_coordination_redis_endpoints.py index 4e6bfc4c063..2e78a4ca0e3 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_coordination_redis_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_coordination_redis_endpoints.py @@ -210,6 +210,27 @@ async def test_get_rejects_non_admin(): assert exc_info.value.status_code == 403 +@pytest.mark.asyncio +async def test_get_allows_proxy_admin_viewer(): + """proxy_admin_viewer has READ parity with proxy_admin; credentials stay redacted.""" + with ( + patch( + "litellm.proxy.proxy_server.prisma_client", + _prisma_with_general_settings({"coordination_redis": _SAVED_SETTINGS}), + ), + patch("litellm.proxy.proxy_server.proxy_config", _proxy_config()), + ): + response = await get_coordination_redis_settings( + user_api_key_dict=UserAPIKeyAuth( + api_key="hashed", user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY + ) + ) + + assert response.source == "coordination_redis" + assert response.values["host"] == "coord-redis.example.com" + assert response.values["password"] == _REDACTED_VALUE + + def test_fields_cover_every_coordination_redis_param(): """The declarative field list drives the Admin UI form; it must stay in sync with the model the backend validates against.""" @@ -437,6 +458,18 @@ async def test_update_rejects_non_admin(): assert exc_info.value.status_code == 403 +@pytest.mark.asyncio +async def test_update_rejects_proxy_admin_viewer(): + """READ parity for proxy_admin_viewer must not leak into the save endpoint.""" + with pytest.raises(HTTPException) as exc_info: + await update_coordination_redis_settings( + request=CoordinationRedisSettingsRequest(settings={"host": "coord-redis.example.com"}), + user_api_key_dict=UserAPIKeyAuth(api_key="hashed", user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY), + litellm_changed_by=None, + ) + assert exc_info.value.status_code == 403 + + # ── POST /coordination_redis/settings/test ──────────────────────────────────── @@ -575,3 +608,14 @@ async def test_connection_test_rejects_non_admin(): user_api_key_dict=UserAPIKeyAuth(api_key="hashed", user_role=LitellmUserRoles.INTERNAL_USER), ) assert exc_info.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_connection_test_rejects_proxy_admin_viewer(): + """Dialing a caller-supplied Redis is a write-shaped action; viewers stay out.""" + with pytest.raises(HTTPException) as exc_info: + await check_coordination_redis_connection( + request=CoordinationRedisSettingsRequest(settings={"host": "coord-redis.example.com"}), + user_api_key_dict=UserAPIKeyAuth(api_key="hashed", user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY), + ) + assert exc_info.value.status_code == 403 diff --git a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py index a37f7ca764d..aab9a0b4fd0 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py @@ -1383,6 +1383,39 @@ async def test_user_info_nonexistent_user(mocker): assert f"User {nonexistent_user_id} not found" in str(exc_info.value.message) +@pytest.mark.asyncio +async def test_user_info_no_user_id_view_only_admin_gets_proxy_admin_payload(mocker): + """PROXY_ADMIN_VIEW_ONLY must take the proxy-admin branch; otherwise /user/info + silently narrows to the viewer's own row instead of the whole tenant.""" + from fastapi import Request + + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth, UserInfoResponse + from litellm.proxy.management_endpoints.internal_user_endpoints import user_info + + mock_prisma_client = mocker.MagicMock() + mock_prisma_client.get_data = mocker.AsyncMock(return_value=None) + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + + admin_payload = UserInfoResponse(user_id=None, user_info=None, keys=[], teams=[]) + mock_get_user_info_for_proxy_admin = mocker.AsyncMock(return_value=admin_payload) + mocker.patch( + "litellm.proxy.management_endpoints.internal_user_endpoints._get_user_info_for_proxy_admin", + mock_get_user_info_for_proxy_admin, + ) + + viewer = UserAPIKeyAuth( + user_id="viewer", user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value + ) + mock_request = mocker.MagicMock(spec=Request) + + response = await user_info( + user_id=None, user_api_key_dict=viewer, request=mock_request + ) + + mock_get_user_info_for_proxy_admin.assert_awaited_once_with(user_api_key_dict=viewer) + assert response is admin_payload + + @pytest.mark.asyncio async def test_new_user_default_teams_flow(mocker): """ @@ -3213,13 +3246,9 @@ def test_enforce_user_info_access_admin_bypass(): _enforce_user_info_access(user_id="someone_else", user_api_key_dict=admin) -def test_enforce_user_info_access_view_only_admin_blocked_from_other_users(): - """PROXY_ADMIN_VIEW_ONLY is not a true admin for /user/info — the upstream - route check applies the same `user_id == valid_token.user_id` rule, so the - re-check here must mirror that and deny cross-user lookups.""" - import pytest - from fastapi import HTTPException - +def test_enforce_user_info_access_view_only_admin_can_read_other_users(): + """PROXY_ADMIN_VIEW_ONLY has read parity with PROXY_ADMIN, so the ownership + re-check must wave it through for another user's id.""" from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth from litellm.proxy.management_endpoints.internal_user_endpoints import ( _enforce_user_info_access, @@ -3229,9 +3258,7 @@ def test_enforce_user_info_access_view_only_admin_blocked_from_other_users(): user_id="viewer", user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value, ) - with pytest.raises(HTTPException) as exc_info: - _enforce_user_info_access(user_id="someone_else", user_api_key_dict=viewer) - assert exc_info.value.status_code == 403 + _enforce_user_info_access(user_id="someone_else", user_api_key_dict=viewer) def test_enforce_user_info_access_view_only_admin_can_read_own(): diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index cf9aa477112..e8709f3af34 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -8006,6 +8006,74 @@ async def test_validate_key_list_check_key_hash_not_found(): assert "Key Hash not found" in exc_info.value.message +@pytest.mark.asyncio +async def test_validate_key_list_check_proxy_admin_viewer_skips_db_lookup(): + """proxy_admin_viewer takes the same unscoped read fast-path as proxy_admin, so no + user row is fetched and none of the user/team scoping filters apply.""" + mock_prisma_client = AsyncMock() + mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock( + return_value=LiteLLM_UserTable( + user_id="viewer-user", + user_email="viewer@example.com", + teams=[], + organization_memberships=[], + ) + ) + + user_api_key_dict = UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, + user_id="viewer-user", + ) + + result = await validate_key_list_check( + user_api_key_dict=user_api_key_dict, + user_id="someone-else", + team_id="team-viewer-is-not-in", + organization_id=None, + key_alias=None, + key_hash=None, + prisma_client=mock_prisma_client, + ) + + assert result is None + mock_prisma_client.db.litellm_usertable.find_unique.assert_not_awaited() + assert mock_prisma_client.mock_calls == [] + + +@pytest.mark.asyncio +async def test_validate_key_list_check_internal_user_cannot_query_other_user(): + """Admin-view parity must not leak past the admin roles: an internal user still + cannot list another user's keys.""" + mock_prisma_client = AsyncMock() + mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock( + return_value=LiteLLM_UserTable( + user_id="test-user", + user_email="test@example.com", + teams=[], + organization_memberships=[], + ) + ) + + user_api_key_dict = UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + user_id="test-user", + ) + + with pytest.raises(ProxyException) as exc_info: + await validate_key_list_check( + user_api_key_dict=user_api_key_dict, + user_id="other-user", + team_id=None, + organization_id=None, + key_alias=None, + key_hash=None, + prisma_client=mock_prisma_client, + ) + + assert exc_info.value.code == "403" + assert "not authorized to check another user's keys" in exc_info.value.message + + @pytest.mark.asyncio async def test_key_with_budget_id_does_not_store_budget_duration(): """ @@ -15323,3 +15391,54 @@ async def test_rotate_master_key_rotates_sso_identity_assertions( prisma_client=mock_prisma_client, new_master_key="sk-new-master-key", ) + + +@pytest.mark.asyncio +async def test_check_encryption_endpoint_rejects_proxy_admin_viewer(): + """The residual scan walks and decrypt-classifies every credential-bearing table, + so it stays proxy_admin-only despite being read-only.""" + from litellm.proxy.management_endpoints import credential_migration as cm + from litellm.proxy.management_endpoints.key_management_endpoints import ( + check_encryption_endpoint, + ) + + user_api_key_dict = UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, + user_id="viewer-user", + ) + mock_check = AsyncMock(return_value=cm.MigrationReport()) + + with patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), patch.object( + cm, "check_encryption", mock_check + ): + with pytest.raises(HTTPException) as exc_info: + await check_encryption_endpoint(user_api_key_dict=user_api_key_dict) + + assert exc_info.value.status_code == 403 + mock_check.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_migrate_encryption_endpoint_rejects_proxy_admin_viewer(): + """The re-encryption write sibling is also proxy_admin-only.""" + from litellm.proxy.management_endpoints import credential_migration as cm + from litellm.proxy.management_endpoints.key_management_endpoints import ( + migrate_encryption_endpoint, + ) + + user_api_key_dict = UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, + user_id="viewer-user", + ) + mock_migrate = AsyncMock(return_value=cm.MigrationReport()) + + with patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), patch.object( + cm, "migrate_encryption", mock_migrate + ): + with pytest.raises(HTTPException) as exc_info: + await migrate_encryption_endpoint( + user_api_key_dict=user_api_key_dict, dry_run=False + ) + + assert exc_info.value.status_code == 403 + mock_migrate.assert_not_awaited() diff --git a/tests/test_litellm/proxy/management_endpoints/test_workflow_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_workflow_management_endpoints.py index a337ff6d888..27adb3e0892 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_workflow_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_workflow_management_endpoints.py @@ -3,19 +3,25 @@ Unit tests for workflow management endpoints (/v1/workflows/runs/*). Uses FastAPI TestClient with a mocked prisma_client. """ +import asyncio import os import sys from datetime import datetime, timezone from typing import Any from unittest.mock import AsyncMock, MagicMock, patch -from fastapi import FastAPI +import pytest +from fastapi import FastAPI, HTTPException from fastapi.testclient import TestClient from prisma.errors import UniqueViolationError sys.path.insert(0, os.path.abspath("../../..")) -from litellm.proxy.management_endpoints.workflow_management_endpoints import router +from litellm.proxy.management_endpoints.workflow_management_endpoints import ( + _read_scope_caller, + _require_run, + router, +) # --------------------------------------------------------------------------- @@ -140,6 +146,31 @@ def _override_auth_user_with_token(token: str = "tok-abc") -> Any: return auth +def _override_auth_admin_viewer(token: str = "tok-viewer") -> Any: + """Viewer carries a real token, so a re-scoped read path would be observable.""" + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth + + auth = UserAPIKeyAuth( + api_key="sk-viewer", + user_id="viewer-1", + user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, + ) + auth.token = token + return auth + + +def _override_auth_internal_user(token: str = "tok-internal") -> Any: + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth + + auth = UserAPIKeyAuth( + api_key="sk-internal", + user_id="user-2", + user_role=LitellmUserRoles.INTERNAL_USER, + ) + auth.token = token + return auth + + # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- @@ -609,3 +640,100 @@ class TestTenantIsolation: resp = client.get("/v1/workflows/runs/run-1") assert resp.status_code == 200 + + +class TestAdminViewerReadParity: + """proxy_admin_viewer reads every run; write paths stay on the strict admin gate.""" + + def _make_app_with_auth(self, auth_fn): + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + + self._prisma = _make_prisma_client() + app = _make_app() + app.dependency_overrides[user_api_key_auth] = auth_fn + return TestClient(app, raise_server_exceptions=True) + + def test_read_scope_caller_drops_scope_for_admin_viewer_only(self): + """None means 'no ownership filter'; every other non-admin role keeps its caller.""" + internal = _override_auth_internal_user() + assert _read_scope_caller(_override_auth_admin_viewer()) is None + assert _read_scope_caller(internal) is internal + + @patch("litellm.proxy.proxy_server.prisma_client") + def test_admin_viewer_list_not_scoped(self, mock_pc): + client = self._make_app_with_auth(_override_auth_admin_viewer) + mock_pc.db = self._prisma.db + self._prisma.db.litellm_workflowrun.find_many = AsyncMock(return_value=[]) + + resp = client.get("/v1/workflows/runs") + assert resp.status_code == 200 + call_kwargs = self._prisma.db.litellm_workflowrun.find_many.call_args[1] + assert "created_by" not in call_kwargs["where"] + + @patch("litellm.proxy.proxy_server.prisma_client") + def test_admin_viewer_get_other_owners_run_succeeds(self, mock_pc): + client = self._make_app_with_auth(_override_auth_admin_viewer) + mock_pc.db = self._prisma.db + self._prisma.db.litellm_workflowrun.find_unique = AsyncMock( + return_value=_make_run(created_by="tok-other-owner") + ) + + resp = client.get("/v1/workflows/runs/run-1") + assert resp.status_code == 200 + + @patch("litellm.proxy.proxy_server.prisma_client") + def test_admin_viewer_lists_other_owners_events(self, mock_pc): + client = self._make_app_with_auth(_override_auth_admin_viewer) + mock_pc.db = self._prisma.db + self._prisma.db.litellm_workflowrun.find_unique = AsyncMock( + return_value=_make_run(created_by="tok-other-owner") + ) + self._prisma.db.litellm_workflowevent.find_many = AsyncMock( + return_value=[_make_event(sequence_number=0)] + ) + + resp = client.get("/v1/workflows/runs/run-1/events") + assert resp.status_code == 200 + assert resp.json()["count"] == 1 + + @patch("litellm.proxy.proxy_server.prisma_client") + def test_admin_viewer_lists_other_owners_messages(self, mock_pc): + client = self._make_app_with_auth(_override_auth_admin_viewer) + mock_pc.db = self._prisma.db + self._prisma.db.litellm_workflowrun.find_unique = AsyncMock( + return_value=_make_run(created_by="tok-other-owner") + ) + self._prisma.db.litellm_workflowmessage.find_many = AsyncMock( + return_value=[_make_message(sequence_number=0)] + ) + + resp = client.get("/v1/workflows/runs/run-1/messages") + assert resp.status_code == 200 + assert resp.json()["count"] == 1 + + @patch("litellm.proxy.proxy_server.prisma_client") + def test_admin_viewer_cannot_update_other_owners_run(self, mock_pc): + """Read parity must not become write parity: PATCH still passes the caller through.""" + client = self._make_app_with_auth(_override_auth_admin_viewer) + mock_pc.db = self._prisma.db + self._prisma.db.litellm_workflowrun.find_unique = AsyncMock( + return_value=_make_run(created_by="tok-other-owner") + ) + self._prisma.db.litellm_workflowrun.update = AsyncMock( + return_value=_make_run(status="completed") + ) + + resp = client.patch("/v1/workflows/runs/run-1", json={"status": "completed"}) + assert resp.status_code == 404 + self._prisma.db.litellm_workflowrun.update.assert_not_awaited() + + def test_require_run_still_scopes_when_handed_a_viewer(self): + """Only read callers pass None; the helper itself never loosened.""" + prisma = _make_prisma_client() + prisma.db.litellm_workflowrun.find_unique = AsyncMock( + return_value=_make_run(created_by="tok-other-owner") + ) + + with pytest.raises(HTTPException) as exc_info: + asyncio.run(_require_run(prisma, "run-1", _override_auth_admin_viewer())) + assert exc_info.value.status_code == 404 diff --git a/tests/test_litellm/proxy/memory/test_memory_endpoints.py b/tests/test_litellm/proxy/memory/test_memory_endpoints.py index ca011c77af8..ec81ef2ff7a 100644 --- a/tests/test_litellm/proxy/memory/test_memory_endpoints.py +++ b/tests/test_litellm/proxy/memory/test_memory_endpoints.py @@ -19,7 +19,7 @@ from fastapi.testclient import TestClient sys.path.insert(0, os.path.abspath("../../..")) from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth -from litellm.proxy.memory.memory_endpoints import router +from litellm.proxy.memory.memory_endpoints import _visibility_filter, router def _make_row( @@ -218,6 +218,14 @@ def _admin_auth() -> UserAPIKeyAuth: ) +def _admin_viewer_auth() -> UserAPIKeyAuth: + return UserAPIKeyAuth( + api_key="sk-viewer", + user_id="viewer", + user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, + ) + + def _patch_prisma(prisma: Any): """Patch the endpoint module's _require_prisma to return our fake.""" return patch( @@ -913,3 +921,67 @@ class TestMemoryEndpoints: with _patch_prisma(self.prisma): resp = client.delete("/v1/memory/notes") assert resp.status_code == 404 + + def test_visibility_filter_unscoped_for_admin_viewer(self): + """ + proxy_admin_viewer reads with the same unscoped filter as proxy_admin; + every other role stays row-restricted. + """ + assert _visibility_filter(_admin_viewer_auth()) is None + assert _visibility_filter(_user_auth("user-a", "team-a")) is not None + + def test_list_memory_admin_viewer_sees_all(self): + """Read parity end-to-end: the viewer's own user_id/team_id must not filter the list.""" + table = self.prisma.db.litellm_memorytable + table.rows.extend( + [ + _make_row(memory_id="m1", key="a", user_id="user-a", team_id=None), + _make_row(memory_id="m2", key="b", user_id="user-b", team_id="team-b"), + ] + ) + client = _make_client(_admin_viewer_auth()) + with _patch_prisma(self.prisma): + resp = client.get("/v1/memory") + assert resp.status_code == 200, resp.text + body = resp.json() + assert {m["key"] for m in body["memories"]} == {"a", "b"} + assert body["total"] == 2 + + def test_put_memory_admin_viewer_cannot_overwrite_foreign_row(self): + """ + Read parity must not become write parity: the viewer now SEES this row + (403, not 404) but `_assert_write_access` still refuses the write. + """ + table = self.prisma.db.litellm_memorytable + table.rows.append( + _make_row( + memory_id="m1", + key="user_role", + value="A's notes", + user_id="user-a", + team_id="team-a", + ) + ) + client = _make_client(_admin_viewer_auth()) + with _patch_prisma(self.prisma): + resp = client.put("/v1/memory/user_role", json={"value": "viewer overwrite"}) + assert resp.status_code == 403, resp.text + assert table.rows[0].value == "A's notes" + + def test_delete_memory_admin_viewer_cannot_delete_foreign_row(self): + """Same write gate as the PUT case, for DELETE.""" + table = self.prisma.db.litellm_memorytable + table.rows.append( + _make_row( + memory_id="m1", + key="user_role", + value="A's notes", + user_id="user-a", + team_id="team-a", + ) + ) + client = _make_client(_admin_viewer_auth()) + with _patch_prisma(self.prisma): + resp = client.delete("/v1/memory/user_role") + assert resp.status_code == 403, resp.text + assert len(table.rows) == 1 diff --git a/tests/test_litellm/proxy/prompts/test_prompt_endpoints.py b/tests/test_litellm/proxy/prompts/test_prompt_endpoints.py index 39b6bce46fa..57ad6acae3b 100644 --- a/tests/test_litellm/proxy/prompts/test_prompt_endpoints.py +++ b/tests/test_litellm/proxy/prompts/test_prompt_endpoints.py @@ -319,3 +319,144 @@ class TestPromptVersionsEndpoint: assert exc_info.value.status_code == 404 assert "No versions found" in exc_info.value.detail + + +class TestAdminViewerReadAccess: + """ + proxy_admin_viewer has READ parity with proxy_admin on the prompt read endpoints + """ + + @pytest.mark.asyncio + async def test_list_prompts_returns_all_prompts_for_admin_viewer(self): + """A role without admin view falls through to the empty-list branch here.""" + from unittest.mock import patch + + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth + from litellm.proxy.prompts.prompt_endpoints import list_prompts + + viewer = UserAPIKeyAuth( + api_key="test_key", user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY + ) + + mock_prompts = { + "jack.v1": PromptSpec( + prompt_id="jack.v1", + litellm_params=PromptLiteLLMParams( + prompt_id="jack", + prompt_integration="dotprompt", + dotprompt_content="v1", + ), + prompt_info=PromptInfo(prompt_type="db"), + ), + "jack.v2": PromptSpec( + prompt_id="jack.v2", + litellm_params=PromptLiteLLMParams( + prompt_id="jack", + prompt_integration="dotprompt", + dotprompt_content="v2", + ), + prompt_info=PromptInfo(prompt_type="db"), + ), + "jane.v1": PromptSpec( + prompt_id="jane.v1", + litellm_params=PromptLiteLLMParams( + prompt_id="jane", + prompt_integration="dotprompt", + dotprompt_content="jane", + ), + prompt_info=PromptInfo(prompt_type="db"), + ), + } + + with patch( + "litellm.proxy.prompts.prompt_registry.IN_MEMORY_PROMPT_REGISTRY" + ) as mock_registry: + mock_registry.IN_MEMORY_PROMPTS = mock_prompts + + response = await list_prompts(user_api_key_dict=viewer) + + assert sorted(p.prompt_id for p in response.prompts) == ["jack", "jane"] + jack = next(p for p in response.prompts if p.prompt_id == "jack") + assert jack.litellm_params.dotprompt_content == "v2" + + @pytest.mark.asyncio + async def test_get_prompt_versions_allows_admin_viewer(self): + """Version history used to 403 anyone who was not exactly proxy_admin.""" + from unittest.mock import patch + + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth + from litellm.proxy.prompts.prompt_endpoints import get_prompt_versions + + viewer = UserAPIKeyAuth( + api_key="test_key", user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY + ) + + mock_prompts = { + "jack.v1": PromptSpec( + prompt_id="jack.v1", + litellm_params=PromptLiteLLMParams( + prompt_id="jack", + prompt_integration="dotprompt", + dotprompt_content="v1", + ), + prompt_info=PromptInfo(prompt_type="db"), + ), + "jack.v2": PromptSpec( + prompt_id="jack.v2", + litellm_params=PromptLiteLLMParams( + prompt_id="jack", + prompt_integration="dotprompt", + dotprompt_content="v2", + ), + prompt_info=PromptInfo(prompt_type="db"), + ), + } + + with ( + patch("litellm.proxy.proxy_server.prisma_client", None), + patch( + "litellm.proxy.prompts.prompt_registry.IN_MEMORY_PROMPT_REGISTRY" + ) as mock_registry, + ): + mock_registry.IN_MEMORY_PROMPTS = mock_prompts + + response = await get_prompt_versions( + prompt_id="jack", user_api_key_dict=viewer + ) + + assert [p.version for p in response.prompts] == [2, 1] + + @pytest.mark.asyncio + async def test_get_prompt_info_allows_admin_viewer(self): + """Prompt info used to 403 anyone who was not exactly proxy_admin.""" + from unittest.mock import patch + + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth + from litellm.proxy.prompts.prompt_endpoints import get_prompt_info + + viewer = UserAPIKeyAuth( + api_key="test_key", user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY + ) + + with ( + patch("litellm.proxy.proxy_server.prisma_client", None), + patch( + "litellm.proxy.prompts.prompt_registry.IN_MEMORY_PROMPT_REGISTRY" + ) as mock_registry, + ): + mock_registry.get_prompt_by_id.return_value = PromptSpec( + prompt_id="jack.v2", + litellm_params=PromptLiteLLMParams( + prompt_id="jack", + prompt_integration="dotprompt", + dotprompt_content="v2", + ), + prompt_info=PromptInfo(prompt_type="db"), + ) + mock_registry.IN_MEMORY_PROMPTS = {"jack.v1": {}, "jack.v2": {}} + mock_registry.get_prompt_callback_by_id.return_value = None + + response = await get_prompt_info(prompt_id="jack", user_api_key_dict=viewer) + + assert response.prompt_spec.prompt_id == "jack" + assert response.prompt_spec.version == 2 diff --git a/tests/test_litellm/proxy/proxy_server/test_team_model_name_translation.py b/tests/test_litellm/proxy/proxy_server/test_team_model_name_translation.py index 577af3dcffc..e73f1d08cb5 100644 --- a/tests/test_litellm/proxy/proxy_server/test_team_model_name_translation.py +++ b/tests/test_litellm/proxy/proxy_server/test_team_model_name_translation.py @@ -538,6 +538,48 @@ async def test_populate_team_access_sets_direct_access_false_by_default(monkeypa assert by_id["global-id-1"]["model_info"]["direct_access"] is True +@pytest.mark.asyncio +async def test_populate_team_access_gives_view_only_admin_full_admin_scope(monkeypatch): + """proxy_admin_viewer reads with admin scope - every team ("*") plus direct access + to all non-team models - instead of being narrowed to its own user row.""" + team_row = _team_row() + global_row = { + "model_name": "gpt-4o", + "litellm_params": {"model": "gpt-4o"}, + "model_info": {"id": "global-id-1", "db_model": False}, + } + router = MagicMock() + router.get_model_ids.return_value = ["global-id-1"] + + get_all_team_models = AsyncMock(return_value={"byok-id-1": ["team-abc-123"]}) + monkeypatch.setattr(ps, "get_all_team_models", get_all_team_models) + + prisma_client = MagicMock() + prisma_client.db.litellm_usertable.find_unique = AsyncMock( + return_value=LiteLLM_UserTable(user_id="viewer", teams=[], models=[]) + ) + + viewer = UserAPIKeyAuth( + user_id="viewer", + user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, + team_models=[], + ) + result = await ps._populate_team_access_on_models( + user_api_key_dict=viewer, + prisma_client=prisma_client, + llm_router=router, + all_models=[team_row, global_row], + ) + + assert get_all_team_models.await_args.kwargs["user_teams"] == "*" + router.get_model_ids.assert_called_once_with(exclude_team_models=True) + prisma_client.db.litellm_usertable.find_unique.assert_not_awaited() + + by_id = {m["model_info"]["id"]: m for m in result} + assert by_id["byok-id-1"]["model_info"]["access_via_team_ids"] == ["team-abc-123"] + assert by_id["global-id-1"]["model_info"]["direct_access"] is True + + @pytest.mark.asyncio async def test_model_info_v1_team_id_without_db_fails_fast(monkeypatch): """`teamId` without a connected DB raises 500 before any enrichment work runs.""" diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.test.ts index 567ca911458..bb14f6c3d21 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.test.ts @@ -152,6 +152,8 @@ describe("useAuthorized", () => { expect(result.current.userId).toBe("user-1"); expect(result.current.userEmail).toBe("user@example.com"); expect(result.current.userRole).toBe("Admin"); + expect(result.current.userRoleLabel).toBe("Admin"); + expect(result.current.isViewOnly).toBe(false); expect(result.current.premiumUser).toBe(true); expect(result.current.disabledPersonalKeyCreation).toBe(false); expect(result.current.showSSOBanner).toBe(true); @@ -159,6 +161,44 @@ describe("useAuthorized", () => { expect(clearTokenCookiesMock).not.toHaveBeenCalled(); }); + it("should present proxy_admin_viewer as Admin while flagging it view-only", async () => { + getUiConfigMock.mockResolvedValue({ + server_root_path: "/", + proxy_base_url: null, + auto_redirect_to_sso: false, + admin_ui_disabled: false, + sso_configured: false, + }); + + const decodedPayload = { + key: "api-key-456", + user_id: "user-2", + user_email: "viewer@example.com", + user_role: "proxy_admin_viewer", + premium_user: true, + disabled_non_admin_personal_key_creation: false, + login_method: "username_password", + }; + + decodeTokenMock.mockReturnValue(decodedPayload); + checkTokenValidityMock.mockReturnValue(true); + + const token = createJwt(decodedPayload); + document.cookie = `token=${token}; path=/;`; + + const { result } = renderHook(() => useAuthorized(), { wrapper }); + + await waitFor(() => { + expect(result.current.token).toBe(token); + }); + + expect(result.current.userRole).toBe("Admin"); + expect(result.current.userRoleLabel).toBe("Admin Viewer"); + expect(result.current.isViewOnly).toBe(true); + expect(replaceMock).not.toHaveBeenCalled(); + expect(clearTokenCookiesMock).not.toHaveBeenCalled(); + }); + it("should clear cookies and redirect on an invalid token", async () => { getUiConfigMock.mockResolvedValue({ server_root_path: "/", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts index bb22ebf5edc..40d1ec09d1f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts @@ -5,7 +5,7 @@ import { clearTokenCookies, getCookie } from "@/utils/cookieUtils"; import { checkTokenValidity, decodeToken } from "@/utils/jwtUtils"; import { buildLoginUrlWithReturn, getLoginUrl, storeReturnUrl } from "@/utils/returnUrlUtils"; import { useCallback, useEffect, useMemo } from "react"; -import { formatUserRole } from "@/utils/roles"; +import { effectiveSessionRole, formatUserRole, isViewOnlySessionRole } from "@/utils/roles"; import { useUIConfig } from "./uiConfig/useUIConfig"; const useAuthorized = () => { @@ -45,7 +45,9 @@ const useAuthorized = () => { accessToken: decoded?.key ?? null, userId: decoded?.user_id ?? null, userEmail: decoded?.user_email ?? null, - userRole: formatUserRole(decoded?.user_role), + userRole: effectiveSessionRole(decoded?.user_role), + userRoleLabel: formatUserRole(decoded?.user_role), + isViewOnly: isViewOnlySessionRole(decoded?.user_role), premiumUser: decoded?.premium_user ?? null, disabledPersonalKeyCreation: decoded?.disabled_non_admin_personal_key_creation ?? null, showSSOBanner: decoded?.login_method === "username_password", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/page.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/page.test.tsx index 54e99d9db29..85e19d7d251 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/page.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/page.test.tsx @@ -10,6 +10,7 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ accessToken: "sk-test", userId: "user-1", userRole: authState.userRole, + isViewOnly: ["Admin Viewer", "Internal Viewer"].includes(authState.userRole), disabledPersonalKeyCreation: false, }), })); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/page.tsx index 8986084b1a7..a4ea85311c4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/page.tsx @@ -9,7 +9,6 @@ import { TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react"; import { DeprecationBanner } from "@/components/DeprecationBanner"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { fetchProxySettings } from "@/utils/proxyUtils"; -import { isViewOnlyRole } from "@/utils/roles"; interface ProxySettings { PROXY_BASE_URL?: string; @@ -17,7 +16,7 @@ interface ProxySettings { } export default function PlaygroundPage() { - const { accessToken, userRole, userId, disabledPersonalKeyCreation, token } = useAuthorized(); + const { accessToken, userRole, userId, disabledPersonalKeyCreation, token, isViewOnly } = useAuthorized(); const [proxySettings, setProxySettings] = useState(undefined); useEffect(() => { @@ -36,7 +35,7 @@ export default function PlaygroundPage() { initializeProxySettings(); }, [accessToken]); - if (isViewOnlyRole(userRole)) { + if (isViewOnly) { return (

Access Denied

diff --git a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.test.tsx b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.test.tsx index 31ddae31798..cad5ced340e 100644 --- a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.test.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.test.tsx @@ -6,7 +6,7 @@ import UserDropdown from "./UserDropdown"; let mockUseAuthorizedImpl = () => ({ userId: "test-user-id", userEmail: "test@example.com", - userRole: "Admin", + userRoleLabel: "Admin", premiumUser: false, }); @@ -44,7 +44,7 @@ describe("UserDropdown", () => { mockUseAuthorizedImpl = () => ({ userId: "test-user-id", userEmail: "test@example.com", - userRole: "Admin", + userRoleLabel: "Admin", premiumUser: false, }); mockUseDisableShowPromptsImpl = () => false; @@ -115,7 +115,7 @@ describe("UserDropdown", () => { mockUseAuthorizedImpl = () => ({ userId: "test-user-id", userEmail: "test@example.com", - userRole: "Admin", + userRoleLabel: "Admin", premiumUser: true, }); @@ -238,7 +238,7 @@ describe("UserDropdown", () => { mockUseAuthorizedImpl = () => ({ userId: "default_user_id", userEmail: null as any, - userRole: "Admin", + userRoleLabel: "Admin", premiumUser: false, }); renderWithProviders(); @@ -250,7 +250,7 @@ describe("UserDropdown", () => { mockUseAuthorizedImpl = () => ({ userId: "test-user-id", userEmail: null as any, - userRole: "Admin", + userRoleLabel: "Admin", premiumUser: false, }); @@ -268,7 +268,7 @@ describe("UserDropdown", () => { mockUseAuthorizedImpl = () => ({ userId: null as any, userEmail: "test@example.com", - userRole: "Admin", + userRoleLabel: "Admin", premiumUser: false, }); diff --git a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx index a71fc1b97a8..28e981c57a1 100644 --- a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx @@ -69,7 +69,7 @@ interface UserDropdownProps { } const UserDropdown: React.FC = ({ onLogout, variant = "navbar", collapsed = false }) => { - const { userId, userEmail, userRole, premiumUser } = useAuthorized(); + const { userId, userEmail, userRoleLabel: userRole, premiumUser } = useAuthorized(); const disableShowPrompts = useDisableShowPrompts(); const disableBlogPosts = useDisableBlogPosts(); const disableBouncingIcon = useDisableBouncingIcon(); diff --git a/ui/litellm-dashboard/src/components/SidebarAccountMenu/SidebarAccountMenu.test.tsx b/ui/litellm-dashboard/src/components/SidebarAccountMenu/SidebarAccountMenu.test.tsx index 1e4eb5b5af4..9d56a889ed4 100644 --- a/ui/litellm-dashboard/src/components/SidebarAccountMenu/SidebarAccountMenu.test.tsx +++ b/ui/litellm-dashboard/src/components/SidebarAccountMenu/SidebarAccountMenu.test.tsx @@ -6,7 +6,7 @@ import SidebarAccountMenu from "./SidebarAccountMenu"; interface AuthMock { userId: string | null; userEmail: string | null; - userRole: string; + userRoleLabel: string; premiumUser: boolean; accessToken: string; } @@ -14,7 +14,7 @@ interface AuthMock { let mockUseAuthorizedImpl: () => AuthMock = () => ({ userId: "test-user-id", userEmail: "test@example.com", - userRole: "Admin", + userRoleLabel: "Admin", premiumUser: false, accessToken: "test-token", }); @@ -74,7 +74,7 @@ describe("SidebarAccountMenu", () => { mockUseAuthorizedImpl = () => ({ userId: "test-user-id", userEmail: "test@example.com", - userRole: "Admin", + userRoleLabel: "Admin", premiumUser: false, accessToken: "test-token", }); @@ -127,7 +127,7 @@ describe("SidebarAccountMenu", () => { mockUseAuthorizedImpl = () => ({ userId: "test-user-id", userEmail: "test@example.com", - userRole: "Admin", + userRoleLabel: "Admin", premiumUser: true, accessToken: "test-token", }); @@ -273,7 +273,7 @@ describe("SidebarAccountMenu", () => { mockUseAuthorizedImpl = () => ({ userId: "default_user_id", userEmail: null, - userRole: "Admin", + userRoleLabel: "Admin", premiumUser: false, accessToken: "test-token", }); @@ -286,7 +286,7 @@ describe("SidebarAccountMenu", () => { mockUseAuthorizedImpl = () => ({ userId: "test-user-id", userEmail: null, - userRole: "Admin", + userRoleLabel: "Admin", premiumUser: false, accessToken: "test-token", }); diff --git a/ui/litellm-dashboard/src/components/SidebarAccountMenu/SidebarAccountMenu.tsx b/ui/litellm-dashboard/src/components/SidebarAccountMenu/SidebarAccountMenu.tsx index b7a16bcf09a..d1bed9370b4 100644 --- a/ui/litellm-dashboard/src/components/SidebarAccountMenu/SidebarAccountMenu.tsx +++ b/ui/litellm-dashboard/src/components/SidebarAccountMenu/SidebarAccountMenu.tsx @@ -81,7 +81,7 @@ interface SidebarAccountMenuProps { } const SidebarAccountMenu: React.FC = ({ onLogout, collapsed = false }) => { - const { userId, userEmail, userRole, premiumUser, accessToken } = useAuthorized(); + const { userId, userEmail, userRoleLabel: userRole, premiumUser, accessToken } = useAuthorized(); const { data: healthData } = useHealthReadinessDetails(accessToken); const version = healthData?.litellm_version; const disableShowPrompts = useDisableShowPrompts(); diff --git a/ui/litellm-dashboard/src/components/leftnav.test.tsx b/ui/litellm-dashboard/src/components/leftnav.test.tsx index e07d0bb26eb..a5b273a0f56 100644 --- a/ui/litellm-dashboard/src/components/leftnav.test.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.test.tsx @@ -19,6 +19,7 @@ const { mockUseAuthorized, mockUseOrganizations } = vi.hoisted(() => { userId: "test-user-id", accessToken: "test-access-token", userRole: "admin", + isViewOnly: false, token: "test-token", userEmail: "test@example.com", premiumUser: false, @@ -156,12 +157,15 @@ describe("Sidebar (leftnav)", () => { describe("Admin Viewer parity", () => { // Admin Viewer follows a "read parity with Proxy Admin, no writes, no - // cost-incurring actions" rule. Playground stays hidden (incurs LLM - // cost); Models + Endpoints and Agents must be visible read-only. + // cost-incurring actions" rule. The session hook presents the viewer as + // an admin (`userRole: "admin"`) with `isViewOnly: true`; Playground + // stays hidden (incurs LLM cost) via the isViewOnly flag, while every + // admin page (Models + Endpoints, Agents, Logs, ...) is visible read-only. const adminViewerAuth = { userId: "admin-viewer-user-id", accessToken: "test-access-token", - userRole: "admin_viewer", + userRole: "admin", + isViewOnly: true, token: "test-token", userEmail: "viewer@example.com", premiumUser: false, diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index cd92fc5bedb..f08092d0e38 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -407,7 +407,7 @@ const Sidebar_: React.FC = ({ disableVectorStoresForInternalUsers, allowVectorStoresForTeamAdmins, }) => { - const { userId, accessToken, userRole } = useAuthorized(); + const { userId, accessToken, userRole, isViewOnly } = useAuthorized(); const { data: organizations } = useOrganizations(); const { data: teams } = useTeams(); const { logoUrl } = useTheme(); @@ -449,6 +449,7 @@ const Sidebar_: React.FC = ({ return items .map((item) => ({ ...item, children: item.children ? filterItemsByRole(item.children) : undefined })) .filter((item) => { + if (item.key === "llm-playground" && isViewOnly) return false; if (item.key === "organizations" || item.key === "users") { const hasRoleAccess = !item.roles || item.roles.includes(userRole) || isOrgAdmin; if (!hasRoleAccess) return false; diff --git a/ui/litellm-dashboard/src/components/user_dashboard.tsx b/ui/litellm-dashboard/src/components/user_dashboard.tsx index 1b8afecb619..1ed4e1d0bba 100644 --- a/ui/litellm-dashboard/src/components/user_dashboard.tsx +++ b/ui/litellm-dashboard/src/components/user_dashboard.tsx @@ -5,6 +5,7 @@ import { jwtDecode } from "jwt-decode"; import React, { useEffect, useState } from "react"; import { fetchTeams } from "./common_components/fetch_teams"; import { KeyResponse, Team } from "./key_team_helpers/key_list"; +import { effectiveSessionRole } from "@/utils/roles"; import { getProxyBaseUrl, getProxyUISettings, @@ -97,30 +98,6 @@ const UserDashboard: React.FC = ({ return () => window.removeEventListener("beforeunload", handleBeforeUnload); }, []); - function formatUserRole(userRole: string) { - if (!userRole) { - return "Undefined Role"; - } - switch (userRole.toLowerCase()) { - case "app_owner": - return "App Owner"; - case "demo_app_owner": - return "App Owner"; - case "proxy_admin": - return "Admin"; - case "proxy_admin_viewer": - return "Admin Viewer"; - case "app_user": - return "App User"; - case "internal_user": - return "Internal User"; - case "internal_user_viewer": - return "Internal Viewer"; - default: - return "Unknown Role"; - } - } - // console.log(`selectedTeam: ${Object.entries(selectedTeam)}`); // Moved useEffect inside the component and used a condition to run fetch only if the params are available useEffect(() => { @@ -134,8 +111,7 @@ const UserDashboard: React.FC = ({ // check if userRole is defined if (decoded.user_role) { - const formattedUserRole = formatUserRole(decoded.user_role); - setUserRole(formattedUserRole); + setUserRole(effectiveSessionRole(decoded.user_role)); } else { } diff --git a/ui/litellm-dashboard/src/contexts/AuthContext.tsx b/ui/litellm-dashboard/src/contexts/AuthContext.tsx index 3693d858952..123feb18a6c 100644 --- a/ui/litellm-dashboard/src/contexts/AuthContext.tsx +++ b/ui/litellm-dashboard/src/contexts/AuthContext.tsx @@ -4,7 +4,7 @@ import React, { createContext, useContext, useEffect, useState } from "react"; import { jwtDecode } from "jwt-decode"; import { clearTokenCookies, getCookie } from "@/utils/cookieUtils"; import { isJwtExpired } from "@/utils/jwtUtils"; -import { formatUserRole } from "@/utils/roles"; +import { effectiveSessionRole } from "@/utils/roles"; import { getUiConfig, setGlobalLitellmHeaderName } from "@/components/networking"; function deleteCookie(name: string, path = "/") { @@ -107,7 +107,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setDisabledPersonalKeyCreation(decoded.disabled_non_admin_personal_key_creation); if (decoded.user_role) { - setUserRole(formatUserRole(decoded.user_role)); + setUserRole(effectiveSessionRole(decoded.user_role)); } if (decoded.user_email) { setUserEmail(decoded.user_email); diff --git a/ui/litellm-dashboard/src/utils/roles.test.ts b/ui/litellm-dashboard/src/utils/roles.test.ts index 9a8a5a9c0c4..83f633bc299 100644 --- a/ui/litellm-dashboard/src/utils/roles.test.ts +++ b/ui/litellm-dashboard/src/utils/roles.test.ts @@ -1,9 +1,11 @@ import { describe, it, expect } from "vitest"; import { + effectiveSessionRole, isAdminRole, isProxyAdminRole, isUserTeamAdminForAnyTeam, isUserTeamAdminForSingleTeam, + isViewOnlySessionRole, rolesAllowedToViewWriteScopedPages, rolesWithWriteAccess, } from "./roles"; @@ -172,4 +174,66 @@ describe("roles", () => { expect(rolesAllowedToViewWriteScopedPages.length).toBeGreaterThan(rolesWithWriteAccess.length); }); }); + + describe("effectiveSessionRole", () => { + it("normalizes proxy_admin_viewer to Admin", () => { + expect(effectiveSessionRole("proxy_admin_viewer")).toBe("Admin"); + }); + + it("keeps proxy_admin as Admin", () => { + expect(effectiveSessionRole("proxy_admin")).toBe("Admin"); + }); + + it("gives proxy_admin_viewer the same session role as proxy_admin", () => { + expect(effectiveSessionRole("proxy_admin_viewer")).toBe(effectiveSessionRole("proxy_admin")); + }); + + it("lets a normalized proxy_admin_viewer pass admin-tier role gates", () => { + expect(rolesWithWriteAccess).toContain(effectiveSessionRole("proxy_admin_viewer")); + }); + + it("does not collapse internal_user_viewer into an admin role", () => { + expect(effectiveSessionRole("internal_user_viewer")).toBe("Internal Viewer"); + expect(rolesWithWriteAccess).not.toContain(effectiveSessionRole("internal_user_viewer")); + }); + + it("leaves other roles untouched", () => { + expect(effectiveSessionRole("internal_user")).toBe("Internal User"); + expect(effectiveSessionRole("org_admin")).toBe("Org Admin"); + }); + + it("returns Undefined Role for a missing role", () => { + expect(effectiveSessionRole(undefined)).toBe("Undefined Role"); + expect(effectiveSessionRole("")).toBe("Undefined Role"); + }); + }); + + describe("isViewOnlySessionRole", () => { + it("returns true for proxy_admin_viewer", () => { + expect(isViewOnlySessionRole("proxy_admin_viewer")).toBe(true); + }); + + it("returns false for proxy_admin", () => { + expect(isViewOnlySessionRole("proxy_admin")).toBe(false); + }); + + it("returns true for internal_user_viewer", () => { + expect(isViewOnlySessionRole("internal_user_viewer")).toBe(true); + }); + + it("returns false for internal_user and org_admin", () => { + expect(isViewOnlySessionRole("internal_user")).toBe(false); + expect(isViewOnlySessionRole("org_admin")).toBe(false); + }); + + it("returns false for a missing role", () => { + expect(isViewOnlySessionRole(undefined)).toBe(false); + expect(isViewOnlySessionRole("")).toBe(false); + }); + + it("stays true for proxy_admin_viewer even though its session role reads as Admin", () => { + expect(effectiveSessionRole("proxy_admin_viewer")).toBe("Admin"); + expect(isViewOnlySessionRole("proxy_admin_viewer")).toBe(true); + }); + }); }); diff --git a/ui/litellm-dashboard/src/utils/roles.ts b/ui/litellm-dashboard/src/utils/roles.ts index 2137d6cfaf2..8d226313f78 100644 --- a/ui/litellm-dashboard/src/utils/roles.ts +++ b/ui/litellm-dashboard/src/utils/roles.ts @@ -65,3 +65,15 @@ export const formatUserRole = (userRole: string): string => { return "Unknown Role"; } }; + +const viewOnlyRawRoles = ["proxy_admin_viewer", "internal_user_viewer", "internal_viewer"]; + +export const effectiveSessionRole = (rawUserRole?: string): string => { + if (rawUserRole?.toLowerCase() === "proxy_admin_viewer") { + return "Admin"; + } + return formatUserRole(rawUserRole ?? ""); +}; + +export const isViewOnlySessionRole = (rawUserRole?: string): boolean => + viewOnlyRawRoles.includes(rawUserRole?.toLowerCase() ?? "");