mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
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:
parent
023a02ac65
commit
b0b6678543
11 changed files with 148 additions and 15 deletions
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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"}>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue