fix(proxy): authorize web search fallback and refresh search deny UI copy

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
mrinal 2026-09-29 04:40:52 +00:00
parent 023a02ac65
commit b0b6678543
11 changed files with 148 additions and 15 deletions

View file

@ -1556,6 +1556,8 @@ class WebSearchInterceptionLogger(CustomLogger):
tool_params: Final[_SearchToolLitellmParams] = search_tool.get("litellm_params", {}) or {}
search_litellm_params = dict[str, object](tool_params)
search_provider = tool_params.get("search_provider")
else:
self._authorize_unregistered_search_fallback(kwargs=kwargs)
# Fallback to perplexity if no router or no search tools configured
if not search_provider:
@ -1623,6 +1625,16 @@ class WebSearchInterceptionLogger(CustomLogger):
verbose_logger.error("WebSearchInterception: Search failed for '%s': %s", query, e)
raise
def _authorize_unregistered_search_fallback(self, kwargs: Mapping[str, object] | None) -> None:
user_api_key_auth: Final = self._get_user_api_key_auth_from_kwargs(kwargs)
if user_api_key_auth is None:
return
from litellm.proxy.auth.auth_checks import check_unregistered_search_fallback
from litellm.proxy.proxy_server import general_settings
check_unregistered_search_fallback(valid_token=user_api_key_auth, general_settings=general_settings)
async def _authorize_search_tool(
self,
search_tool: Mapping[str, object],

View file

@ -5404,13 +5404,20 @@ async def get_user_object_permission(
if object_permission is None:
raise ProxyException(
message=f"Search tool grants of user {user_id} could not be loaded",
type=ProxyErrorTypes.key_model_access_denied,
type=ProxyErrorTypes.no_db_connection,
param="search_tool_name",
code=status.HTTP_403_FORBIDDEN,
code=status.HTTP_503_SERVICE_UNAVAILABLE,
)
return object_permission
def _is_search_default_deny_applied(valid_token: UserAPIKeyAuth, general_settings: Mapping[str, object]) -> bool:
return (
general_settings.get("default_search_list_deny") is True
and valid_token.user_role != LitellmUserRoles.PROXY_ADMIN
)
async def resolve_search_tool_grants(
valid_token: UserAPIKeyAuth,
team_object: LiteLLM_TeamTable | None,
@ -5422,10 +5429,7 @@ async def resolve_search_tool_grants(
``general_settings.default_search_list_deny`` turns a missing, null or empty grant into a denial for
every caller except a proxy admin. The user layer is only consulted in that mode.
"""
default_deny: Final = (
general_settings.get("default_search_list_deny") is True
and valid_token.user_role != LitellmUserRoles.PROXY_ADMIN
)
default_deny: Final = _is_search_default_deny_applied(valid_token, general_settings)
user_id: Final = valid_token.user_id
return SearchToolGrants(
key=tuple(_search_tool_names_from_object_permission(valid_token.object_permission)),
@ -5462,6 +5466,20 @@ async def can_caller_call_search_tool(
)
def check_unregistered_search_fallback(
valid_token: UserAPIKeyAuth,
general_settings: Mapping[str, object],
) -> Literal[True]:
if _is_search_default_deny_applied(valid_token, general_settings):
raise ProxyException(
message="No registered search tool is available and general_settings.default_search_list_deny is enabled",
type=ProxyErrorTypes.key_model_access_denied,
param="search_tool_name",
code=status.HTTP_403_FORBIDDEN,
)
return True
def can_grants_view_search_tool(search_tool_name: str, grants: SearchToolGrants) -> bool:
"""Boolean variant of check_search_tool_grants used to scope /search_tools/list."""
try:

View file

@ -13,7 +13,13 @@ from litellm.integrations.websearch_interception.handler import (
WebSearchInterceptionLogger,
)
from litellm.llms.base_llm.search.transformation import SearchResponse
from litellm.proxy._types import LiteLLM_ObjectPermissionTable, LiteLLM_TeamTable, ProxyException, UserAPIKeyAuth
from litellm.proxy._types import (
LiteLLM_ObjectPermissionTable,
LiteLLM_TeamTable,
LitellmUserRoles,
ProxyException,
UserAPIKeyAuth,
)
from litellm.types.utils import LlmProviders
@ -660,6 +666,42 @@ async def test_execute_search_key_without_grant_is_denied_under_default_search_l
mock_asearch.assert_not_awaited()
@pytest.mark.parametrize(
"general_settings, user_role, expect_search",
[
({}, None, True),
({"default_search_list_deny": False}, None, True),
({"default_search_list_deny": True}, None, False),
({"default_search_list_deny": True}, LitellmUserRoles.PROXY_ADMIN, True),
],
)
@pytest.mark.asyncio
async def test_execute_search_unregistered_fallback_follows_default_search_list_deny(
monkeypatch, general_settings, user_role, expect_search
):
import litellm
from litellm.proxy import proxy_server
logger = WebSearchInterceptionLogger(enabled_providers=["bedrock"])
router = MagicMock()
router.search_tools = []
mock_asearch = AsyncMock(return_value=SearchResponse(object="search", results=[]))
monkeypatch.setattr(proxy_server, "llm_router", router)
monkeypatch.setattr(proxy_server, "general_settings", general_settings)
monkeypatch.setattr(litellm, "asearch", mock_asearch)
kwargs = {"metadata": {"user_api_key_auth": UserAPIKeyAuth(user_id="user-1", user_role=user_role)}}
if expect_search:
await logger._execute_search("what is litellm", kwargs=kwargs)
assert mock_asearch.await_args.kwargs["search_provider"] == "perplexity"
else:
with pytest.raises(ProxyException) as exc_info:
await logger._execute_search("what is litellm", kwargs=kwargs)
assert exc_info.value.code == "403"
mock_asearch.assert_not_awaited()
@pytest.mark.asyncio
async def test_async_pre_call_deployment_hook_provider_from_top_level_kwargs():
"""Test that async_pre_call_deployment_hook finds custom_llm_provider at top-level kwargs.

View file

@ -1732,7 +1732,7 @@ async def test_get_user_object_permission_denies_when_the_linked_row_cannot_be_r
with pytest.raises(_ProxyException) as exc_info:
await get_user_object_permission("user-1", UserAPIKeyAuth(user_id="user-1"))
assert exc_info.value.code == "403"
assert exc_info.value.code == "503"
@pytest.mark.asyncio

View file

@ -2,7 +2,7 @@ import { getUiSettings } from "@/components/networking";
import { useQuery } from "@tanstack/react-query";
import { createQueryKeys } from "../common/queryKeysFactory";
const uiSettingsKeys = createQueryKeys("uiSettings");
export const uiSettingsKeys = createQueryKeys("uiSettings");
/**
* UI settings, cached for an hour by default because they rarely change.

View file

@ -1,4 +1,11 @@
import { fireEvent, renderWithProviders, screen, within } from "../../../../../tests/test-utils";
import {
fireEvent,
renderWithProviders,
screen,
testQueryClient,
waitFor,
within,
} from "../../../../../tests/test-utils";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import GeneralSettings from "./general_settings";
@ -253,3 +260,27 @@ it("should delete only the Default setting and retain explicit false and zero",
["token", "synthetic_count", 0],
]);
});
it("refetches UI settings after a general setting is updated so dependent screens see the new value", async () => {
vi.mocked(getGeneralSettingsCall).mockResolvedValue([
{
field_name: "default_search_list_deny",
field_type: "Boolean",
field_value: true,
field_description: "search deny",
stored_in_db: null,
},
]);
testQueryClient.setQueryData(["uiSettings", "list", { params: {} }], { values: { default_search_list_deny: false } });
const user = userEvent.setup();
renderWithProviders(<GeneralSettings accessToken="token" userRole="Admin" userID="user" />);
await user.click(screen.getByRole("tab", { name: "General" }));
await user.click(
within(await screen.findByRole("row", { name: /default_search_list_deny/ })).getByRole("button", {
name: "Update",
}),
);
await waitFor(() =>
expect(testQueryClient.getQueryState(["uiSettings", "list", { params: {} }])?.isInvalidated).toBe(true),
);
});

View file

@ -9,6 +9,8 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { getGeneralSettingsCall, updateConfigFieldSetting, deleteConfigFieldSetting } from "@/components/networking";
import { Trash2 } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { uiSettingsKeys } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import { StatusBadge } from "@/components/shared/table_cells";
import RouterSettings from "@/components/router_settings";
@ -221,6 +223,8 @@ export const PromptCachingPanel: React.FC<{
const GeneralSettings: React.FC<GeneralSettingsPageProps> = ({ accessToken, userRole, userID }) => {
const [generalSettings, setGeneralSettings] = useState<generalSettingsItem[]>([]);
const queryClient = useQueryClient();
const refreshUISettings = () => queryClient.invalidateQueries({ queryKey: uiSettingsKeys.all });
useEffect(() => {
if (!accessToken) {
@ -253,7 +257,7 @@ const GeneralSettings: React.FC<GeneralSettingsPageProps> = ({ accessToken, user
return;
}
try {
updateConfigFieldSetting(accessToken, fieldName, fieldValue);
updateConfigFieldSetting(accessToken, fieldName, fieldValue).then(refreshUISettings);
// update value in state
const updatedSettings = generalSettings.map((setting) =>
@ -271,7 +275,7 @@ const GeneralSettings: React.FC<GeneralSettingsPageProps> = ({ accessToken, user
}
try {
deleteConfigFieldSetting(accessToken, fieldName);
deleteConfigFieldSetting(accessToken, fieldName).then(refreshUISettings);
// update value in state
const updatedSettings = generalSettings.map((setting) =>

View file

@ -4,7 +4,7 @@ import MCPServerPermissions from "./permissions/MCPServerPermissions";
import AgentPermissions from "./permissions/AgentPermissions";
import type { ObjectPermission } from "./object_permission_types";
import type { InheritedGrant } from "./permissions/inheritedGrants";
import { searchToolPermissionCopy } from "./search_tools/searchToolPermissionCopy";
import { searchToolPermissionCopy, type SearchToolPermissionScope } from "./search_tools/searchToolPermissionCopy";
import { useDefaultSearchListDeny } from "@/app/(dashboard)/hooks/uiSettings/useDefaultSearchListDeny";
interface ObjectPermissionsViewProps {
@ -14,6 +14,7 @@ interface ObjectPermissionsViewProps {
variant?: "card" | "inline";
className?: string;
accessToken?: string | null;
searchToolScope?: SearchToolPermissionScope;
}
export function ObjectPermissionsView({
@ -23,6 +24,7 @@ export function ObjectPermissionsView({
variant = "card",
className = "",
accessToken,
searchToolScope = "team",
}: ObjectPermissionsViewProps) {
const vectorStores = objectPermission?.vector_stores || [];
const mcpServers = objectPermission?.mcp_servers || [];
@ -33,7 +35,7 @@ export function ObjectPermissionsView({
const agentAccessGroups = objectPermission?.agent_access_groups || [];
const searchTools = objectPermission?.search_tools || [];
const skills = objectPermission?.skills || [];
const searchToolCopy = searchToolPermissionCopy(useDefaultSearchListDeny());
const searchToolCopy = searchToolPermissionCopy(useDefaultSearchListDeny(), searchToolScope);
const content = (
<div className={variant === "card" ? "grid grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3 gap-6" : "space-y-4"}>

View file

@ -16,4 +16,14 @@ describe("searchToolPermissionCopy", () => {
expect(copy.emptyState).toContain("no search tool is allowed");
expect(Object.values(copy).join(" ")).not.toContain("all allowed");
});
it("describes an empty key-level list as deferring to the team or user grant", () => {
expect(searchToolPermissionCopy(true, "key").emptyState).toBe(
"No key-level search tools. Default search list deny is on, so this key can use only the search tools granted to its team or user.",
);
expect(searchToolPermissionCopy(false, "key").emptyState).toBe(
"No key-level restriction: this key can use any search tool its team or user allows.",
);
expect(searchToolPermissionCopy(true, "key").hint).toBe(searchToolPermissionCopy(true).hint);
});
});

View file

@ -4,8 +4,18 @@ export interface SearchToolPermissionCopy {
emptyState: string;
}
export const searchToolPermissionCopy = (defaultSearchListDeny: boolean): SearchToolPermissionCopy =>
export type SearchToolPermissionScope = "team" | "key";
const keyEmptyState = (defaultSearchListDeny: boolean): string =>
defaultSearchListDeny
? "No key-level search tools. Default search list deny is on, so this key can use only the search tools granted to its team or user."
: "No key-level restriction: this key can use any search tool its team or user allows.";
export const searchToolPermissionCopy = (
defaultSearchListDeny: boolean,
scope: SearchToolPermissionScope = "team",
): SearchToolPermissionCopy => {
const copy = defaultSearchListDeny
? {
hint: "Select which search tools this team can access. Default search list deny is on, so leaving this empty denies every search tool.",
placeholder: "Select search tools (empty = none allowed)",
@ -16,3 +26,5 @@ export const searchToolPermissionCopy = (defaultSearchListDeny: boolean): Search
placeholder: "Select search tools (optional, empty = all allowed)",
emptyState: "No restriction: all configured search tools are allowed for this team.",
};
return scope === "key" ? { ...copy, emptyState: keyEmptyState(defaultSearchListDeny) } : copy;
};

View file

@ -765,6 +765,7 @@ export default function KeyInfoView({
<Card className="block p-6">
<ObjectPermissionsView
objectPermission={currentKeyData.object_permission}
searchToolScope="key"
variant="inline"
accessToken={accessToken}
/>
@ -1171,6 +1172,7 @@ export default function KeyInfoView({
<ObjectPermissionsView
objectPermission={currentKeyData.object_permission}
searchToolScope="key"
variant="inline"
className="pt-4 border-t border-border"
accessToken={accessToken}