Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_lit5214_custom_tiers

This commit is contained in:
Tin Chi Lo 2026-08-05 11:50:59 -07:00
commit c28dcf8007
73 changed files with 1184 additions and 223 deletions

View file

@ -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(

View file

View file

@ -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

View file

@ -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

View file

@ -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"):

View file

@ -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:

View file

@ -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:

View file

@ -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)

View file

@ -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 (

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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,
)

View file

@ -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

View file

@ -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}",

View file

@ -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)

View file

@ -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

View file

@ -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})

View file

@ -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)

View file

@ -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

View file

@ -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:

View file

@ -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(

View file

@ -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:

View file

@ -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}",

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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):

View file

@ -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,
)

View file

@ -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

View file

@ -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,
)

View file

@ -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,

View file

@ -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."""

View file

@ -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

View file

@ -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)

View file

@ -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."""

View file

@ -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

View file

@ -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():

View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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."""

View file

@ -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 }
"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 }
}

View file

@ -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: "/",

View file

@ -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",

View file

@ -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,
}),
}));

View file

@ -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<ProxySettings | undefined>(undefined);
useEffect(() => {
@ -36,7 +35,7 @@ export default function PlaygroundPage() {
initializeProxySettings();
}, [accessToken]);
if (isViewOnlyRole(userRole)) {
if (isViewOnly) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 p-8 text-center">
<h1 className="text-2xl font-semibold">Access Denied</h1>

View file

@ -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(<UserDropdown onLogout={mockOnLogout} />);
@ -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,
});

View file

@ -69,7 +69,7 @@ interface UserDropdownProps {
}
const UserDropdown: React.FC<UserDropdownProps> = ({ 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();

View file

@ -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",
});

View file

@ -81,7 +81,7 @@ interface SidebarAccountMenuProps {
}
const SidebarAccountMenu: React.FC<SidebarAccountMenuProps> = ({ 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();

View file

@ -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,

View file

@ -407,7 +407,7 @@ const Sidebar_: React.FC<SidebarProps> = ({
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<SidebarProps> = ({
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;

View file

@ -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<UserDashboardProps> = ({
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<UserDashboardProps> = ({
// check if userRole is defined
if (decoded.user_role) {
const formattedUserRole = formatUserRole(decoded.user_role);
setUserRole(formattedUserRole);
setUserRole(effectiveSessionRole(decoded.user_role));
} else {
}

View file

@ -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);

View file

@ -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);
});
});
});

View file

@ -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() ?? "");