diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260429120000_search_tools_on_object_permission/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260429120000_search_tools_on_object_permission/migration.sql new file mode 100644 index 0000000000..bffdaaebc5 --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260429120000_search_tools_on_object_permission/migration.sql @@ -0,0 +1,2 @@ +-- Search tool allowlists live on LiteLLM_ObjectPermissionTable (with agents, MCP, vector stores). +ALTER TABLE "LiteLLM_ObjectPermissionTable" ADD COLUMN IF NOT EXISTS "search_tools" TEXT[] DEFAULT ARRAY[]::TEXT[]; diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index fd54ed5243..a9d3911c07 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -277,6 +277,7 @@ model LiteLLM_ObjectPermissionTable { models String[] @default([]) blocked_tools String[] @default([]) // Tool names blocked for any key/team/user with this permission mcp_toolsets String[] @default([]) // Toolset IDs granted to this key/team/user + search_tools String[] @default([]) // search_tool_name values this key/team/user may call teams LiteLLM_TeamTable[] projects LiteLLM_ProjectTable[] verification_tokens LiteLLM_VerificationToken[] diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 92c920ca59..fd4d4df241 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -904,6 +904,7 @@ class LiteLLM_ObjectPermissionBase(LiteLLMPydanticObjectBase): agents: Optional[List[str]] = None agent_access_groups: Optional[List[str]] = None models: Optional[List[str]] = None + search_tools: Optional[List[str]] = None class BudgetLimitEntry(LiteLLMPydanticObjectBase): @@ -1934,6 +1935,7 @@ class LiteLLM_ObjectPermissionTable(LiteLLMPydanticObjectBase): agent_access_groups: Optional[List[str]] = [] mcp_toolsets: Optional[List[str]] = None blocked_tools: Optional[List[str]] = [] + search_tools: Optional[List[str]] = [] class LiteLLM_TeamTable(TeamBase): diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 450816c3bf..65638ed6c1 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -2963,6 +2963,116 @@ async def can_user_call_model( ) +def _search_tool_names_from_object_permission( + object_permission: Optional[LiteLLM_ObjectPermissionTable], +) -> List[str]: + """Return allowlisted search tool names from object_permission (empty = unrestricted).""" + if object_permission is None: + return [] + raw = object_permission.search_tools + if not raw: + return [] + return list(raw) + + +def _can_object_call_search_tool( + search_tool_name: str, + allowed_search_tools: List[str], + object_type: Literal["key", "team", "project"], +) -> Literal[True]: + """ + Check if an object (key/team/project) can access a specific search tool. + + Similar to _can_object_call_model but for search tools. + + Args: + search_tool_name: The search tool being requested + allowed_search_tools: List of allowed search tool names for this object + object_type: Type of object for error messaging + + Returns: + True if access is allowed + + Raises: + ProxyException if access is denied + """ + # Empty list means all search tools are allowed + if not allowed_search_tools: + return True + + # Check if the search tool is in the allowlist + if search_tool_name in allowed_search_tools: + return True + + # Access denied + raise ProxyException( + message=f"{object_type.capitalize()} not allowed to access search tool: {search_tool_name}. " + f"Allowed search tools: {allowed_search_tools}", + type=ProxyErrorTypes.key_model_access_denied, + param="search_tool_name", + code=status.HTTP_403_FORBIDDEN, + ) + + +async def can_key_call_search_tool( + search_tool_name: str, + valid_token: UserAPIKeyAuth, +) -> Literal[True]: + """ + Check if a key can access a specific search tool. + + Similar to can_key_call_model but for search tools. + + Args: + search_tool_name: The search tool being requested + valid_token: The authenticated key + + Returns: + True if access is allowed + + Raises: + ProxyException if access is denied + """ + return _can_object_call_search_tool( + search_tool_name=search_tool_name, + allowed_search_tools=_search_tool_names_from_object_permission( + valid_token.object_permission + ), + object_type="key", + ) + + +async def can_team_call_search_tool( + search_tool_name: str, + team_object: Optional[LiteLLM_TeamTable], +) -> Literal[True]: + """ + Check if a team can access a specific search tool. + + Similar to can_team_access_model but for search tools. + + Args: + search_tool_name: The search tool being requested + team_object: The team object + + Returns: + True if access is allowed + + Raises: + ProxyException if access is denied + """ + if team_object is None: + return True + + return _can_object_call_search_tool( + search_tool_name=search_tool_name, + allowed_search_tools=_search_tool_names_from_object_permission( + team_object.object_permission + ), + object_type="team", + ) + + async def is_valid_fallback_model( model: str, llm_router: Optional[Router], diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index a424f1558f..8129fb0de5 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -65,6 +65,7 @@ from litellm.proxy.management_helpers.object_permission_utils import ( attach_object_permission_to_dict, handle_update_object_permission_common, validate_key_mcp_servers_against_team, + validate_key_search_tools_against_team, ) from litellm.proxy.management_helpers.team_member_permission_checks import ( TeamMemberPermissionChecks, @@ -768,6 +769,10 @@ async def _common_key_generation_helper( # noqa: PLR0915 object_permission=data_json.get("object_permission"), team_obj=team_table, ) + await validate_key_search_tools_against_team( + object_permission=data_json.get("object_permission"), + team_obj=team_table, + ) data_json = await _set_object_permission( data_json=data_json, @@ -2010,6 +2015,10 @@ async def _validate_mcp_servers_for_key_update( object_permission=object_permission_dict, team_obj=effective_team_obj, ) + await validate_key_search_tools_against_team( + object_permission=object_permission_dict, + team_obj=effective_team_obj, + ) async def _validate_update_key_data( diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index f254fea3e7..e29b67724c 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -2049,8 +2049,11 @@ async def _process_team_members( # Resolve allowed_models: explicit request value, or fall back to team's default_team_member_models member_allowed_models = data.allowed_models - if member_allowed_models is None and complete_team_data.default_team_member_models: - member_allowed_models = complete_team_data.default_team_member_models + team_default_member_models = getattr( + complete_team_data, "default_team_member_models", None + ) + if member_allowed_models is None and team_default_member_models: + member_allowed_models = team_default_member_models if isinstance(data.member, Member): try: diff --git a/litellm/proxy/management_helpers/object_permission_utils.py b/litellm/proxy/management_helpers/object_permission_utils.py index 410f636693..eb90d1b5ca 100644 --- a/litellm/proxy/management_helpers/object_permission_utils.py +++ b/litellm/proxy/management_helpers/object_permission_utils.py @@ -335,8 +335,9 @@ async def validate_key_mcp_servers_against_team( disallowed_servers = requested_servers - all_allowed_servers if disallowed_servers: if team_obj is not None: + team_id = team_obj.team_id detail = ( - f"Key requests MCP servers not allowed by team '{team_obj.team_id}': " + f"Key requests MCP servers not allowed by team '{team_id}': " f"{sorted(disallowed_servers)}. " f"Team allows: {sorted(team_allowed_servers)}. " f"Global (allow_all_keys) servers: {sorted(allow_all_keys_servers)}." @@ -365,8 +366,9 @@ async def validate_key_mcp_servers_against_team( disallowed_groups = requested_access_groups - team_access_groups if disallowed_groups: if team_obj is not None: + team_id = team_obj.team_id detail = ( - f"Key requests MCP access groups not allowed by team '{team_obj.team_id}': " + f"Key requests MCP access groups not allowed by team '{team_id}': " f"{sorted(disallowed_groups)}. " f"Team allows: {sorted(team_access_groups)}." ) @@ -390,13 +392,60 @@ async def validate_key_mcp_servers_against_team( if team_mcp_toolsets: disallowed_toolsets = requested_toolsets - set(team_mcp_toolsets) if disallowed_toolsets: + team_id = team_obj.team_id raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail={ "error": ( - f"Key requests MCP toolsets not allowed by team '{team_obj.team_id}': " + f"Key requests MCP toolsets not allowed by team '{team_id}': " f"{sorted(disallowed_toolsets)}. " f"Team allows: {sorted(team_mcp_toolsets)}." ) }, ) + + +def _extract_requested_search_tools(object_permission: Optional[dict]) -> List[str]: + """Return search_tool_name values from a key's object_permission dict.""" + if not object_permission or not isinstance(object_permission, dict): + return [] + raw = object_permission.get("search_tools") + if not isinstance(raw, list): + return [] + return [str(x) for x in raw if x] + + +async def validate_key_search_tools_against_team( + object_permission: Optional[dict], + team_obj: Optional["LiteLLM_TeamTableCachedObj"], +) -> None: + """ + Validate key object_permission.search_tools is a subset of the team's allowlist. + + Empty team allowlist means no restriction at team layer (skip). + """ + requested = _extract_requested_search_tools(object_permission) + if not requested: + return + + team_tools: List[str] = [] + if team_obj is not None and team_obj.object_permission is not None: + st = team_obj.object_permission.search_tools + if st: + team_tools = list(st) + + if not team_tools: + return + + disallowed = set(requested) - set(team_tools) + if disallowed: + team_id = team_obj.team_id if team_obj is not None else "unknown" + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "error": ( + f"Key requests search tools not allowed by team '{team_id}': " + f"{sorted(disallowed)}. Team allows: {sorted(team_tools)}." + ) + }, + ) diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index fd54ed5243..a9d3911c07 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -277,6 +277,7 @@ model LiteLLM_ObjectPermissionTable { models String[] @default([]) blocked_tools String[] @default([]) // Tool names blocked for any key/team/user with this permission mcp_toolsets String[] @default([]) // Toolset IDs granted to this key/team/user + search_tools String[] @default([]) // search_tool_name values this key/team/user may call teams LiteLLM_TeamTable[] projects LiteLLM_ProjectTable[] verification_tokens LiteLLM_VerificationToken[] diff --git a/litellm/proxy/search_endpoints/endpoints.py b/litellm/proxy/search_endpoints/endpoints.py index 8bed5b5407..15ed858b98 100644 --- a/litellm/proxy/search_endpoints/endpoints.py +++ b/litellm/proxy/search_endpoints/endpoints.py @@ -134,10 +134,48 @@ async def search( if "search_tool_name" in data and data["search_tool_name"]: data["model"] = data["search_tool_name"] + search_tool_name_value = data["search_tool_name"] + + # Authorization check: verify key can access this search tool + from litellm.proxy.auth.auth_checks import ( + can_key_call_search_tool, + can_team_call_search_tool, + get_team_object, + ) + + try: + # Check key-level access + await can_key_call_search_tool( + search_tool_name=search_tool_name_value, + valid_token=user_api_key_dict, + ) + + # Check team-level access if key is associated with a team + if user_api_key_dict.team_id: + from litellm.proxy.proxy_server import ( + prisma_client, + proxy_logging_obj, + user_api_key_cache, + ) + + team_object = await get_team_object( + team_id=user_api_key_dict.team_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=user_api_key_dict.parent_otel_span, + proxy_logging_obj=proxy_logging_obj, + ) + await can_team_call_search_tool( + search_tool_name=search_tool_name_value, + team_object=team_object, + ) + except Exception as e: + verbose_proxy_logger.error( + f"Search tool authorization failed for {search_tool_name_value}: {str(e)}" + ) + raise if llm_router is not None and hasattr(llm_router, "search_tools"): - search_tool_name_value = data["search_tool_name"] - verbose_proxy_logger.debug( f"Search endpoint - Looking for search_tool_name: {search_tool_name_value}. " f"Available search tools in router: {[tool.get('search_tool_name') for tool in llm_router.search_tools]}. " @@ -163,6 +201,16 @@ async def search( data["metadata"] = {} data["metadata"]["model_group"] = search_tool_name_value + # Ensure team context is available to search router credential resolution. + # add_litellm_data_to_request() also injects these values, but this keeps + # search endpoint behavior explicit and resilient for direct router paths. + if "metadata" not in data or not isinstance(data.get("metadata"), dict): + data["metadata"] = {} + if getattr(user_api_key_dict, "team_metadata", None) is not None: + data["metadata"]["user_api_key_team_metadata"] = user_api_key_dict.team_metadata + if getattr(user_api_key_dict, "team_id", None) is not None: + data["metadata"]["user_api_key_team_id"] = user_api_key_dict.team_id + # Process request using ProxyBaseLLMRequestProcessing processor = ProxyBaseLLMRequestProcessing(data=data) try: diff --git a/litellm/router_utils/search_api_router.py b/litellm/router_utils/search_api_router.py index a26aa7e71e..9bcbcd8365 100644 --- a/litellm/router_utils/search_api_router.py +++ b/litellm/router_utils/search_api_router.py @@ -8,7 +8,7 @@ import asyncio import random import traceback from functools import partial -from typing import Any, Callable +from typing import Any, Callable, Dict, Optional, Tuple from litellm._logging import verbose_router_logger @@ -20,6 +20,28 @@ class SearchAPIRouter: Provides methods for search tool selection, load balancing, and fallback handling. """ + @staticmethod + def _resolve_search_provider_credentials( + *, + tool_litellm_params: Dict[str, Any], + ) -> Tuple[Optional[str], Optional[str]]: + """ + Resolve search provider credentials from tool configuration ONLY. + + Credentials are stored only in search_tool.litellm_params, never in team/key metadata. + This ensures secrets are not exposed in team/key API responses. + + Args: + tool_litellm_params: Search tool litellm_params with credentials + + Returns: + Tuple of (api_key, api_base) from tool configuration + """ + resolved_api_key: Optional[str] = tool_litellm_params.get("api_key") + resolved_api_base: Optional[str] = tool_litellm_params.get("api_base") + + return resolved_api_key, resolved_api_base + @staticmethod async def update_router_search_tools(router_instance: Any, search_tools: list): """ @@ -198,14 +220,15 @@ class SearchAPIRouter: # Extract search provider and other params from litellm_params litellm_params = selected_tool.get("litellm_params", {}) search_provider = litellm_params.get("search_provider") - api_key = litellm_params.get("api_key") - api_base = litellm_params.get("api_base") - if not search_provider: raise ValueError( f"search_provider not found in litellm_params for search tool '{search_tool_name}'" ) + api_key, api_base = SearchAPIRouter._resolve_search_provider_credentials( + tool_litellm_params=litellm_params, + ) + verbose_router_logger.debug( f"Selected search tool with provider: {search_provider}" ) diff --git a/schema.prisma b/schema.prisma index fd54ed5243..a9d3911c07 100644 --- a/schema.prisma +++ b/schema.prisma @@ -277,6 +277,7 @@ model LiteLLM_ObjectPermissionTable { models String[] @default([]) blocked_tools String[] @default([]) // Tool names blocked for any key/team/user with this permission mcp_toolsets String[] @default([]) // Toolset IDs granted to this key/team/user + search_tools String[] @default([]) // search_tool_name values this key/team/user may call teams LiteLLM_TeamTable[] projects LiteLLM_ProjectTable[] verification_tokens LiteLLM_VerificationToken[] diff --git a/tests/test_litellm/proxy/management_helpers/test_object_permission_utils.py b/tests/test_litellm/proxy/management_helpers/test_object_permission_utils.py index 1b157a2f6b..b36383dfd9 100644 --- a/tests/test_litellm/proxy/management_helpers/test_object_permission_utils.py +++ b/tests/test_litellm/proxy/management_helpers/test_object_permission_utils.py @@ -16,6 +16,7 @@ from litellm.proxy.management_helpers.object_permission_utils import ( _resolve_team_allowed_mcp_servers, _set_object_permission, validate_key_mcp_servers_against_team, + validate_key_search_tools_against_team, ) @@ -453,3 +454,52 @@ async def test_resolve_team_allowed_mcp_servers_dict_tool_permissions( result = await _resolve_team_allowed_mcp_servers(mock_perm) assert result == {"server-a"} + + +# ---- Tests for validate_key_search_tools_against_team ---- + + +def _make_team_obj_search(team_id="team-1", search_tools=None): + mock_team = MagicMock() + mock_team.team_id = team_id + if search_tools is not None: + mock_team.object_permission = MagicMock(spec=LiteLLM_ObjectPermissionTable) + mock_team.object_permission.search_tools = search_tools + else: + mock_team.object_permission = None + return mock_team + + +@pytest.mark.asyncio +async def test_validate_search_tools_no_key_request(): + await validate_key_search_tools_against_team( + object_permission=None, + team_obj=_make_team_obj_search(search_tools=["t1"]), + ) + + +@pytest.mark.asyncio +async def test_validate_search_tools_team_unrestricted(): + """Empty team search allowlist means unrestricted — key subset check skipped.""" + await validate_key_search_tools_against_team( + object_permission={"search_tools": ["any-tool"]}, + team_obj=_make_team_obj_search(search_tools=[]), + ) + + +@pytest.mark.asyncio +async def test_validate_search_tools_subset_ok(): + await validate_key_search_tools_against_team( + object_permission={"search_tools": ["t1"]}, + team_obj=_make_team_obj_search(search_tools=["t1", "t2"]), + ) + + +@pytest.mark.asyncio +async def test_validate_search_tools_raises_when_not_subset(): + with pytest.raises(HTTPException) as exc: + await validate_key_search_tools_against_team( + object_permission={"search_tools": ["bad"]}, + team_obj=_make_team_obj_search(search_tools=["t1"]), + ) + assert exc.value.status_code == 403 diff --git a/ui/litellm-dashboard/next.config.mjs b/ui/litellm-dashboard/next.config.mjs index bdf492de33..cfaeb24dc5 100644 --- a/ui/litellm-dashboard/next.config.mjs +++ b/ui/litellm-dashboard/next.config.mjs @@ -7,6 +7,11 @@ const __dirname = path.dirname(__filename); const nextConfig = { output: "export", + // Required with output: "export" — default image optimizer runs only in server mode. + // See https://nextjs.org/docs/messages/export-image-api + images: { + unoptimized: true, + }, basePath: "", assetPrefix: "/litellm-asset-prefix", turbopack: { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/teams/components/modals/CreateTeamModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/modals/CreateTeamModal.tsx index 1859970091..1a8c6632a0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/teams/components/modals/CreateTeamModal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/components/modals/CreateTeamModal.tsx @@ -15,10 +15,18 @@ import ModelAliasManager from "@/components/common_components/ModelAliasManager" import React, { useEffect, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import NotificationsManager from "@/components/molecules/notifications_manager"; -import { fetchMCPAccessGroups, getGuardrailsList, getPoliciesList, Organization, Team, teamCreateCall } from "@/components/networking"; +import { + fetchMCPAccessGroups, + getGuardrailsList, + getPoliciesList, + Organization, + Team, + teamCreateCall, +} from "@/components/networking"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { organizationKeys } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; import MCPToolPermissions from "@/components/mcp_server_management/MCPToolPermissions"; +import SearchToolSelector from "@/components/SearchTools/SearchToolSelector"; interface ModelAliases { [key: string]: string; @@ -212,15 +220,27 @@ const CreateTeamModal = ({ } } - // Transform allowed_vector_store_ids and allowed_mcp_servers_and_groups into object_permission + // Transform integrations into object_permission (vector stores, MCP, agents, search tools) + const hasAgents = + formValues.allowed_agents_and_groups && + ((formValues.allowed_agents_and_groups.agents?.length ?? 0) > 0 || + (formValues.allowed_agents_and_groups.accessGroups?.length ?? 0) > 0); + const hasSearchTools = + Array.isArray(formValues.object_permission_search_tools) && + formValues.object_permission_search_tools.length > 0; + if ( (formValues.allowed_vector_store_ids && formValues.allowed_vector_store_ids.length > 0) || (formValues.allowed_mcp_servers_and_groups && (formValues.allowed_mcp_servers_and_groups.servers?.length > 0 || formValues.allowed_mcp_servers_and_groups.accessGroups?.length > 0 || - formValues.allowed_mcp_servers_and_groups.toolPermissions)) + formValues.allowed_mcp_servers_and_groups.toolPermissions)) || + hasAgents || + hasSearchTools ) { - formValues.object_permission = {}; + if (!formValues.object_permission) { + formValues.object_permission = {}; + } if (formValues.allowed_vector_store_ids && formValues.allowed_vector_store_ids.length > 0) { formValues.object_permission.vector_stores = formValues.allowed_vector_store_ids; delete formValues.allowed_vector_store_ids; @@ -238,9 +258,6 @@ const CreateTeamModal = ({ // Add tool permissions separately if (formValues.mcp_tool_permissions && Object.keys(formValues.mcp_tool_permissions).length > 0) { - if (!formValues.object_permission) { - formValues.object_permission = {}; - } formValues.object_permission.mcp_tool_permissions = formValues.mcp_tool_permissions; delete formValues.mcp_tool_permissions; } @@ -248,9 +265,6 @@ const CreateTeamModal = ({ // Handle agent permissions if (formValues.allowed_agents_and_groups) { const { agents, accessGroups } = formValues.allowed_agents_and_groups; - if (!formValues.object_permission) { - formValues.object_permission = {}; - } if (agents && agents.length > 0) { formValues.object_permission.agents = agents; } @@ -259,6 +273,11 @@ const CreateTeamModal = ({ } delete formValues.allowed_agents_and_groups; } + + if (hasSearchTools) { + formValues.object_permission.search_tools = formValues.object_permission_search_tools; + delete formValues.object_permission_search_tools; + } } // Transform allowed_mcp_access_groups into object_permission @@ -729,6 +748,34 @@ const CreateTeamModal = ({ + + + Search Tool Settings + + + + Allowed Search Tools{" "} + + + + + } + name="object_permission_search_tools" + className="mt-4" + help="Restrict which configured search tools keys on this team may call." + > + form.setFieldValue("object_permission_search_tools", vals)} + value={form.getFieldValue("object_permission_search_tools")} + accessToken={accessToken || ""} + placeholder="Select search tools (optional, empty = all allowed)" + /> + + + + Logging Settings diff --git a/ui/litellm-dashboard/src/components/OldTeams.tsx b/ui/litellm-dashboard/src/components/OldTeams.tsx index 8349e271b8..abc26c4cd4 100644 --- a/ui/litellm-dashboard/src/components/OldTeams.tsx +++ b/ui/litellm-dashboard/src/components/OldTeams.tsx @@ -57,9 +57,16 @@ import type { KeyResponse, Team } from "./key_team_helpers/key_list"; import MCPServerSelector from "./mcp_server_management/MCPServerSelector"; import MCPToolPermissions from "./mcp_server_management/MCPToolPermissions"; import NotificationsManager from "./molecules/notifications_manager"; -import { Organization, fetchMCPAccessGroups, getGuardrailsList, getPoliciesList, teamDeleteCall } from "./networking"; +import { + Organization, + fetchMCPAccessGroups, + getGuardrailsList, + getPoliciesList, + teamDeleteCall, +} from "./networking"; import NumericalInput from "./shared/numerical_input"; import VectorStoreSelector from "./vector_store_management/VectorStoreSelector"; +import SearchToolSelector from "./SearchTools/SearchToolSelector"; interface TeamProps { teams: Team[] | null; @@ -505,7 +512,10 @@ const Teams: React.FC = ({ } } - // Transform allowed_vector_store_ids and allowed_mcp_servers_and_groups into object_permission + const hasSearchTools = + Array.isArray(formValues.object_permission_search_tools) && + formValues.object_permission_search_tools.length > 0; + if ( (formValues.allowed_vector_store_ids && formValues.allowed_vector_store_ids.length > 0) || (formValues.allowed_mcp_servers_and_groups && @@ -513,7 +523,9 @@ const Teams: React.FC = ({ formValues.allowed_mcp_servers_and_groups.accessGroups?.length > 0 || formValues.allowed_mcp_servers_and_groups.toolPermissions)) ) { - formValues.object_permission = {}; + if (!formValues.object_permission) { + formValues.object_permission = {}; + } if (formValues.allowed_vector_store_ids && formValues.allowed_vector_store_ids.length > 0) { formValues.object_permission.vector_stores = formValues.allowed_vector_store_ids; delete formValues.allowed_vector_store_ids; @@ -529,11 +541,7 @@ const Teams: React.FC = ({ delete formValues.allowed_mcp_servers_and_groups; } - // Add tool permissions separately if (formValues.mcp_tool_permissions && Object.keys(formValues.mcp_tool_permissions).length > 0) { - if (!formValues.object_permission) { - formValues.object_permission = {}; - } formValues.object_permission.mcp_tool_permissions = formValues.mcp_tool_permissions; delete formValues.mcp_tool_permissions; } @@ -563,6 +571,14 @@ const Teams: React.FC = ({ delete formValues.allowed_agents_and_groups; } + if (hasSearchTools) { + if (!formValues.object_permission) { + formValues.object_permission = {}; + } + formValues.object_permission.search_tools = formValues.object_permission_search_tools; + delete formValues.object_permission_search_tools; + } + // Add model_aliases if any are defined if (Object.keys(modelAliases).length > 0) { formValues.model_aliases = modelAliases; @@ -1516,6 +1532,34 @@ const Teams: React.FC = ({ + + + Search Tool Settings + + + + Allowed Search Tools{" "} + + + + + } + name="object_permission_search_tools" + className="mt-4" + help="Restrict which configured search tools keys on this team may call." + > + form.setFieldValue("object_permission_search_tools", vals)} + value={form.getFieldValue("object_permission_search_tools")} + accessToken={accessToken || ""} + placeholder="Select search tools (optional, empty = all allowed)" + /> + + + + Logging Settings diff --git a/ui/litellm-dashboard/src/components/SearchTools/SearchToolSelector.tsx b/ui/litellm-dashboard/src/components/SearchTools/SearchToolSelector.tsx new file mode 100644 index 0000000000..c93ff35e7d --- /dev/null +++ b/ui/litellm-dashboard/src/components/SearchTools/SearchToolSelector.tsx @@ -0,0 +1,69 @@ +import React, { useEffect, useState } from "react"; +import { Select } from "antd"; +import { fetchSearchTools } from "../networking"; + +export interface SearchToolSelectorProps { + onChange: (selected: string[]) => void; + value?: string[]; + className?: string; + accessToken: string; + placeholder?: string; + disabled?: boolean; +} + +const SearchToolSelector: React.FC = ({ + onChange, + value, + className, + accessToken, + placeholder = "Select search tools (optional)", + disabled = false, +}) => { + const [options, setOptions] = useState<{ label: string; value: string }[]>([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + const load = async () => { + if (!accessToken) return; + setLoading(true); + try { + const data = await fetchSearchTools(accessToken); + const tools = Array.isArray(data?.search_tools) + ? data.search_tools + : Array.isArray(data?.data) + ? data.data + : []; + setOptions( + tools + .map((tool: { search_tool_name?: string }) => tool?.search_tool_name) + .filter((name: unknown): name is string => typeof name === "string" && name.length > 0) + .map((name: string) => ({ label: name, value: name })), + ); + } catch (e) { + console.error("Failed to load search tools:", e); + } finally { + setLoading(false); + } + }; + load(); + }, [accessToken]); + + return ( + = ({ )} + )} diff --git a/ui/litellm-dashboard/src/components/view_logs/index.test.tsx b/ui/litellm-dashboard/src/components/view_logs/index.test.tsx index 427c55c92b..937844e1c1 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.test.tsx @@ -7,15 +7,25 @@ import type { Row } from "@tanstack/react-table"; import { renderWithProviders } from "../../../tests/test-utils"; const mockHandleFilterResetFromHook = vi.fn(); -vi.mock("./log_filter_logic", () => ({ - useLogFilterLogic: vi.fn(() => ({ - filters: {}, - filteredLogs: { data: [], total: 0, page: 1, page_size: 50, total_pages: 1 }, - allTeams: [], - handleFilterChange: vi.fn(), - handleFilterReset: mockHandleFilterResetFromHook, - })), -})); +vi.mock("./log_filter_logic", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useLogFilterLogic: vi.fn(() => ({ + filters: {}, + filteredLogs: { + data: [], + total: 0, + page: 1, + page_size: 50, + total_pages: 1, + }, + allTeams: [], + handleFilterChange: vi.fn(), + handleFilterReset: mockHandleFilterResetFromHook, + })), + }; +}); vi.mock("../networking", async (importOriginal) => { const actual = await importOriginal(); diff --git a/ui/litellm-dashboard/src/components/view_logs/index.tsx b/ui/litellm-dashboard/src/components/view_logs/index.tsx index 8a015d8305..2f9e8fe878 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.tsx @@ -24,7 +24,7 @@ import { ConfigInfoMessage } from "./ConfigInfoMessage"; import { AGENT_CALL_TYPES, ERROR_CODE_OPTIONS, MCP_CALL_TYPES, QUICK_SELECT_OPTIONS } from "./constants"; import { CostBreakdownViewer } from "./CostBreakdownViewer"; import { ErrorViewer } from "./ErrorViewer"; -import { useLogFilterLogic } from "./log_filter_logic"; +import { FILTER_KEYS, useLogFilterLogic } from "./log_filter_logic"; import { LogDetailsDrawer } from "./LogDetailsDrawer"; import { getTimeRangeDisplay } from "./logs_utils"; import { RequestResponsePanel } from "./RequestResponsePanel"; @@ -415,6 +415,11 @@ export default function SpendLogsTable({ label: "Model", customComponent: PaginatedModelSelect, }, + { + name: FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL, + label: "Public model / search tool", + isSearchable: false, + }, { name: "Key Alias", label: "Key Alias", diff --git a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx index 8ccf0a9e1b..17c5077152 100644 --- a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx @@ -112,6 +112,7 @@ describe("useLogFilterLogic", () => { expect(filters["Key Alias"]).toBe(""); expect(filters["Error Code"]).toBe(""); expect(filters["Error Message"]).toBe(""); + expect(filters["Public model / search tool"]).toBe(""); }); it("should return all logs when no filters are applied", () => { @@ -200,6 +201,51 @@ describe("useLogFilterLogic", () => { ); }); + it("should pass model param and filter search-tool rows by spend log model column", async () => { + const searchRows = [ + createLogEntry({ + request_id: "s1", + call_type: "asearch", + model: "tavily-marketing", + model_id: "", + team_id: "team-x", + }), + ]; + vi.mocked(uiSpendLogsCall).mockResolvedValue(createPaginatedResponse(searchRows)); + const logs = createPaginatedResponse([ + ...searchRows, + createLogEntry({ + request_id: "c1", + call_type: "chat", + model: "gpt-4o", + model_id: "mid-1", + team_id: "team-x", + }), + ]); + const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper }); + + act(() => { + result.current.handleFilterChange({ "Public model / search tool": "tavily-marketing" }); + }); + + await waitFor( + () => { + expect(result.current.filteredLogs.data).toHaveLength(1); + expect(result.current.filteredLogs.data[0].model).toBe("tavily-marketing"); + expect(result.current.filteredLogs.data[0].call_type).toBe("asearch"); + }, + { timeout: 500 }, + ); + + expect(vi.mocked(uiSpendLogsCall)).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ + model: "tavily-marketing", + }), + }), + ); + }); + it("should filter logs by api_key when Key Hash filter is set", async () => { const filteredLog = createLogEntry({ request_id: "req-1", api_key: "key-x" }); vi.mocked(uiSpendLogsCall).mockResolvedValue( diff --git a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx index a538872bfd..8f916999c1 100644 --- a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx @@ -9,11 +9,14 @@ import { defaultPageSize } from "../constants"; import { PaginatedResponse } from "."; import type { LogsSortField } from "./columns"; -const FILTER_KEYS = { +/** Spend log `model` column (LLM public model name or `search_tool_name` for /search). */ +export const FILTER_KEYS = { TEAM_ID: "Team ID", KEY_HASH: "Key Hash", REQUEST_ID: "Request ID", MODEL: "Model", + /** Exact match on LiteLLM_SpendLogs.model — use for search tools and public model names. */ + PUBLIC_MODEL_OR_SEARCH_TOOL: "Public model / search tool", USER_ID: "User ID", END_USER: "End User", STATUS: "Status", @@ -58,6 +61,7 @@ export function useLogFilterLogic({ [FILTER_KEYS.KEY_HASH]: "", [FILTER_KEYS.REQUEST_ID]: "", [FILTER_KEYS.MODEL]: "", + [FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL]: "", [FILTER_KEYS.USER_ID]: "", [FILTER_KEYS.END_USER]: "", [FILTER_KEYS.STATUS]: "", @@ -107,6 +111,7 @@ export function useLogFilterLogic({ end_user: filters[FILTER_KEYS.END_USER] || undefined, status_filter: filters[FILTER_KEYS.STATUS] || undefined, model_id: filters[FILTER_KEYS.MODEL] || undefined, + model: filters[FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL] || undefined, key_alias: filters[FILTER_KEYS.KEY_ALIAS] || undefined, error_code: filters[FILTER_KEYS.ERROR_CODE] || undefined, error_message: filters[FILTER_KEYS.ERROR_MESSAGE] || undefined, @@ -155,7 +160,8 @@ export function useLogFilterLogic({ filters[FILTER_KEYS.END_USER] || filters[FILTER_KEYS.ERROR_CODE] || filters[FILTER_KEYS.ERROR_MESSAGE] || - filters[FILTER_KEYS.MODEL] + filters[FILTER_KEYS.MODEL] || + filters[FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL] ), [filters], ); @@ -218,6 +224,11 @@ export function useLogFilterLogic({ filteredData = filteredData.filter((log) => log.model_id === filters[FILTER_KEYS.MODEL]); } + if (filters[FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL]) { + const m = filters[FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL]; + filteredData = filteredData.filter((log) => log.model === m); + } + if (filters[FILTER_KEYS.KEY_HASH]) { filteredData = filteredData.filter((log) => log.api_key === filters[FILTER_KEYS.KEY_HASH]); }