Merge pull request #26691 from BerriAI/litellm_team_search_credentials_metadata

feat(proxy): add team-level search provider credentials
This commit is contained in:
Sameer Kankute 2026-04-30 08:35:17 +05:30 committed by GitHub
commit 6588564a88
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 617 additions and 40 deletions

View File

@ -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[];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = ({
</AccordionBody>
</Accordion>
<Accordion className="mt-8 mb-8">
<AccordionHeader>
<b>Search Tool Settings</b>
</AccordionHeader>
<AccordionBody>
<Form.Item
label={
<span>
Allowed Search Tools{" "}
<Tooltip title="Select which search tools this team can access. Leave empty to allow all search tools.">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
name="object_permission_search_tools"
className="mt-4"
help="Restrict which configured search tools keys on this team may call."
>
<SearchToolSelector
onChange={(vals: string[]) => form.setFieldValue("object_permission_search_tools", vals)}
value={form.getFieldValue("object_permission_search_tools")}
accessToken={accessToken || ""}
placeholder="Select search tools (optional, empty = all allowed)"
/>
</Form.Item>
</AccordionBody>
</Accordion>
<Accordion className="mt-8 mb-8">
<AccordionHeader>
<b>Logging Settings</b>

View File

@ -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<TeamProps> = ({
}
}
// 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<TeamProps> = ({
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<TeamProps> = ({
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<TeamProps> = ({
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<TeamProps> = ({
</AccordionBody>
</Accordion>
<Accordion className="mt-8 mb-8">
<AccordionHeader>
<b>Search Tool Settings</b>
</AccordionHeader>
<AccordionBody>
<Form.Item
label={
<span>
Allowed Search Tools{" "}
<Tooltip title="Select which search tools this team can access. Leave empty to allow all search tools.">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
name="object_permission_search_tools"
className="mt-4"
help="Restrict which configured search tools keys on this team may call."
>
<SearchToolSelector
onChange={(vals: string[]) => form.setFieldValue("object_permission_search_tools", vals)}
value={form.getFieldValue("object_permission_search_tools")}
accessToken={accessToken || ""}
placeholder="Select search tools (optional, empty = all allowed)"
/>
</Form.Item>
</AccordionBody>
</Accordion>
<Accordion className="mt-8 mb-8">
<AccordionHeader>
<b>Logging Settings</b>

View File

@ -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<SearchToolSelectorProps> = ({
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 (
<Select
mode="multiple"
allowClear
showSearch
optionFilterProp="label"
placeholder={placeholder}
onChange={onChange}
value={value}
loading={loading}
className={className}
options={options}
style={{ width: "100%" }}
disabled={disabled}
/>
);
};
export default SearchToolSelector;

View File

@ -141,6 +141,7 @@ const FilterComponent: React.FC<FilterComponentProps> = ({
"Error Message",
"Key Hash",
"Model",
"Public model / search tool",
];
return (

View File

@ -13,6 +13,7 @@ interface ObjectPermission {
vector_stores: string[];
agents?: string[];
agent_access_groups?: string[];
search_tools?: string[];
}
interface ObjectPermissionsViewProps {
@ -35,6 +36,7 @@ export function ObjectPermissionsView({
const mcpToolsets = objectPermission?.mcp_toolsets || [];
const agents = objectPermission?.agents || [];
const agentAccessGroups = objectPermission?.agent_access_groups || [];
const searchTools = objectPermission?.search_tools || [];
const content = (
<div className={variant === "card" ? "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6" : "space-y-4"}>
@ -51,6 +53,16 @@ export function ObjectPermissionsView({
agentAccessGroups={agentAccessGroups}
accessToken={accessToken}
/>
<div className="rounded-md border border-gray-100 p-4">
<Text className="text-sm font-medium text-gray-800">Search tools</Text>
{searchTools.length === 0 ? (
<Text className="mt-1 block text-xs text-gray-500">
No restriction all configured search tools are allowed for this team.
</Text>
) : (
<Text className="mt-1 block text-xs text-gray-700">{searchTools.join(", ")}</Text>
)}
</div>
</div>
);

View File

@ -42,6 +42,7 @@ import { fetchMCPAccessGroups } from "../networking";
import ObjectPermissionsView from "../object_permissions_view";
import NumericalInput from "../shared/numerical_input";
import VectorStoreSelector from "../vector_store_management/VectorStoreSelector";
import SearchToolSelector from "../SearchTools/SearchToolSelector";
import EditLoggingSettings from "./EditLoggingSettings";
import RouterSettingsAccordion, { RouterSettingsAccordionRef } from "../common_components/RouterSettingsAccordion";
import MemberModal from "./EditMembership";
@ -118,6 +119,7 @@ export interface TeamData {
vector_stores: string[];
agents?: string[];
agent_access_groups?: string[];
search_tools?: string[];
};
team_member_budget_table: {
max_budget: number;
@ -593,6 +595,10 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
updateData.object_permission.vector_stores = values.vector_stores;
}
if (Array.isArray(values.object_permission_search_tools)) {
updateData.object_permission.search_tools = values.object_permission_search_tools;
}
// Pass access_group_ids to the update request
if (values.access_group_ids !== undefined) {
updateData.access_group_ids = values.access_group_ids;
@ -926,6 +932,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
models: info.models,
tpm_limit: info.tpm_limit,
rpm_limit: info.rpm_limit,
object_permission_search_tools: info.object_permission?.search_tools || [],
modelLimits: Array.from(
new Set([
...Object.keys(info.metadata?.model_tpm_limit ?? {}),
@ -1379,6 +1386,26 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
/>
</Form.Item>
<Accordion className="mt-4 mb-4">
<AccordionHeader>
<b>Search Tool Settings</b>
</AccordionHeader>
<AccordionBody>
<Form.Item
label="Allowed Search Tools"
name="object_permission_search_tools"
tooltip="Select which search tools this team can access. Leave empty to allow all search tools."
>
<SearchToolSelector
onChange={(vals: string[]) => form.setFieldValue("object_permission_search_tools", vals)}
value={form.getFieldValue("object_permission_search_tools")}
accessToken={accessToken || ""}
placeholder="Select search tools (optional, empty = all allowed)"
/>
</Form.Item>
</AccordionBody>
</Accordion>
<Form.Item label="Organization" name="organization_id">
<Select
allowClear
@ -1614,6 +1641,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
</pre>
</div>
)}
</div>
)}
</Card>

View File

@ -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<typeof import("./log_filter_logic")>();
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<typeof import("../networking")>();

View File

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

View File

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

View File

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