From b0b6678543a9573c23cfbafdc4fd40afd3bd225a Mon Sep 17 00:00:00 2001 From: mrinal Date: Tue, 29 Sep 2026 04:40:52 +0000 Subject: [PATCH] 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> --- .../websearch_interception/handler.py | 12 +++++ litellm/proxy/auth/auth_checks.py | 30 ++++++++++--- .../test_websearch_interception_handler.py | 44 ++++++++++++++++++- tests/unit/proxy/auth/test_auth_checks.py | 2 +- .../hooks/uiSettings/useUISettings.ts | 2 +- .../general_settings.integration.test.tsx | 33 +++++++++++++- .../_components/general_settings.tsx | 8 +++- .../components/object_permissions_view.tsx | 6 ++- .../searchToolPermissionCopy.test.ts | 10 +++++ .../search_tools/searchToolPermissionCopy.ts | 14 +++++- .../components/templates/key_info_view.tsx | 2 + 11 files changed, 148 insertions(+), 15 deletions(-) diff --git a/litellm/integrations/websearch_interception/handler.py b/litellm/integrations/websearch_interception/handler.py index 42fb98a7ca2..760a1fe4719 100644 --- a/litellm/integrations/websearch_interception/handler.py +++ b/litellm/integrations/websearch_interception/handler.py @@ -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], diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index d01bf4a3498..b118e2760c2 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -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: diff --git a/tests/unit/integrations/websearch_interception/test_websearch_interception_handler.py b/tests/unit/integrations/websearch_interception/test_websearch_interception_handler.py index 05a8c046093..4f7775b30de 100644 --- a/tests/unit/integrations/websearch_interception/test_websearch_interception_handler.py +++ b/tests/unit/integrations/websearch_interception/test_websearch_interception_handler.py @@ -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. diff --git a/tests/unit/proxy/auth/test_auth_checks.py b/tests/unit/proxy/auth/test_auth_checks.py index 56686c142d4..86e0d9ba302 100644 --- a/tests/unit/proxy/auth/test_auth_checks.py +++ b/tests/unit/proxy/auth/test_auth_checks.py @@ -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 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.ts index 749fc98c0d8..7c6176389ec 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.ts @@ -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. diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.integration.test.tsx index cf5cad564ea..4a9c9f4e944 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.integration.test.tsx @@ -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(); + 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), + ); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx index 3d0a2e438ac..79c949bd5b2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx @@ -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 = ({ accessToken, userRole, userID }) => { const [generalSettings, setGeneralSettings] = useState([]); + const queryClient = useQueryClient(); + const refreshUISettings = () => queryClient.invalidateQueries({ queryKey: uiSettingsKeys.all }); useEffect(() => { if (!accessToken) { @@ -253,7 +257,7 @@ const GeneralSettings: React.FC = ({ 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 = ({ accessToken, user } try { - deleteConfigFieldSetting(accessToken, fieldName); + deleteConfigFieldSetting(accessToken, fieldName).then(refreshUISettings); // update value in state const updatedSettings = generalSettings.map((setting) => diff --git a/ui/litellm-dashboard/src/components/object_permissions_view.tsx b/ui/litellm-dashboard/src/components/object_permissions_view.tsx index fcb8a7a3995..a2310690df7 100644 --- a/ui/litellm-dashboard/src/components/object_permissions_view.tsx +++ b/ui/litellm-dashboard/src/components/object_permissions_view.tsx @@ -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 = (
diff --git a/ui/litellm-dashboard/src/components/search_tools/searchToolPermissionCopy.test.ts b/ui/litellm-dashboard/src/components/search_tools/searchToolPermissionCopy.test.ts index 750d0f3bb19..46bd633a90c 100644 --- a/ui/litellm-dashboard/src/components/search_tools/searchToolPermissionCopy.test.ts +++ b/ui/litellm-dashboard/src/components/search_tools/searchToolPermissionCopy.test.ts @@ -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); + }); }); diff --git a/ui/litellm-dashboard/src/components/search_tools/searchToolPermissionCopy.ts b/ui/litellm-dashboard/src/components/search_tools/searchToolPermissionCopy.ts index 75d5f8d350d..f914917acb5 100644 --- a/ui/litellm-dashboard/src/components/search_tools/searchToolPermissionCopy.ts +++ b/ui/litellm-dashboard/src/components/search_tools/searchToolPermissionCopy.ts @@ -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; +}; diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx index 7eb09926caf..a0a8daca1d4 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx @@ -765,6 +765,7 @@ export default function KeyInfoView({ @@ -1171,6 +1172,7 @@ export default function KeyInfoView({