diff --git a/.gitignore b/.gitignore index 079690550..dcca25df3 100644 --- a/.gitignore +++ b/.gitignore @@ -138,3 +138,6 @@ helm/**/.values.yaml /.tabnine/ /.codeium *.local.md + +/litellm/ +docker-compose.yml \ No newline at end of file diff --git a/api/app/clients/OpenAIClient.js b/api/app/clients/OpenAIClient.js index 119447467..7292861db 100644 --- a/api/app/clients/OpenAIClient.js +++ b/api/app/clients/OpenAIClient.js @@ -1222,6 +1222,16 @@ ${convo} }); } + // Handle guardrails parameter + const guardrails = this.options.req?.body?.guardrails; + if (guardrails && Array.isArray(guardrails) && guardrails.length > 0) { + modelOptions.guardrails = guardrails; + logger.debug('[OpenAIClient] chatCompletion: added guardrails', { + guardrails: guardrails, + modelOptions, + }); + } + /** Note: OpenAI Web Search models do not support any known parameters besdies `max_tokens` */ if (modelOptions.model && /gpt-4o.*search/.test(modelOptions.model)) { const searchExcludeParams = [ diff --git a/api/server/controllers/auth/LogoutController.js b/api/server/controllers/auth/LogoutController.js index 02d3d0302..089a8480a 100644 --- a/api/server/controllers/auth/LogoutController.js +++ b/api/server/controllers/auth/LogoutController.js @@ -5,6 +5,20 @@ const { logoutUser } = require('~/server/services/AuthService'); const { getOpenIdConfig } = require('~/strategies'); const logoutController = async (req, res) => { + // Entry log to confirm controller is invoked + try { + logger.info('[logoutController] invoked', { + method: req.method, + path: req.originalUrl || req.url, + hasCookiesHeader: Boolean(req.headers.cookie), + hasAuthHeader: Boolean(req.headers.authorization), + userId: req.user?.id || req.user?._id || null, + openidId: req.user?.openidId || null, + query: req.query || {}, + }); + } catch (e) { + console.error('[logoutController] Logging failed during invocation:', e.message); + } const refreshToken = req.headers.cookie ? cookies.parse(req.headers.cookie).refreshToken : null; try { const logout = await logoutUser(req, refreshToken); @@ -12,6 +26,12 @@ const logoutController = async (req, res) => { res.clearCookie('refreshToken'); res.clearCookie('token_provider'); const response = { message }; + // Log any incoming redirect hints on request + if (req.query && (req.query.redirect || req.query.redirect_uri)) { + logger.info('[logoutController] query redirect detected', { + redirect: req.query.redirect || req.query.redirect_uri, + }); + } if ( req.user.openidId != null && isEnabled(process.env.OPENID_USE_END_SESSION_ENDPOINT) && @@ -27,7 +47,17 @@ const logoutController = async (req, res) => { ? openIdConfig.serverMetadata().end_session_endpoint : null; if (endSessionEndpoint) { - response.redirect = endSessionEndpoint; + const postLogoutRedirect = process.env.OPENID_POST_LOGOUT_REDIRECT_URI; + const clientId = process.env.OPENID_CLIENT_ID; + let logoutUrl = `${endSessionEndpoint}`; + if (clientId) { + logoutUrl += `${logoutUrl.includes('?') ? '&' : '?'}client_id=${encodeURIComponent(clientId)}`; + } + if (postLogoutRedirect) { + logoutUrl += `${logoutUrl.includes('?') ? '&' : '?'}post_logout_redirect_uri=${encodeURIComponent(postLogoutRedirect)}`; + } + response.redirect = logoutUrl; + // logger.info('[logoutController] end_session_endpoint', { endSessionEndpoint, logoutUrl }); } else { logger.warn( '[logoutController] end_session_endpoint not found in OpenID issuer metadata. Please verify that the issuer is correct.', @@ -35,6 +65,11 @@ const logoutController = async (req, res) => { } } } + try { + logger.info('[logoutController] responding', { status, response }); + } catch (e) { + console.error('[logoutController] Logging failed during response:', e.message); + } return res.status(status).send(response); } catch (err) { logger.error('[logoutController]', err); diff --git a/api/server/index.js b/api/server/index.js index c084267ad..b5dd737bd 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -144,6 +144,7 @@ const startServer = async () => { app.use('/api/tags', routes.tags); app.use('/api/mcp', routes.mcp); + app.use('/api/guardrails', routes.guardrails); app.use(ErrorController); diff --git a/api/server/routes/guardrails.js b/api/server/routes/guardrails.js new file mode 100644 index 000000000..ecdcffc82 --- /dev/null +++ b/api/server/routes/guardrails.js @@ -0,0 +1,53 @@ +const express = require('express'); +const { fetchGuardrails } = require('~/server/services/GuardrailsService'); +const { logger } = require('~/utils'); +const configMiddleware = require('~/server/middleware/config/app'); + +const router = express.Router(); + +router.use(configMiddleware); + +/** + * GET /api/guardrails + * Fetches available guardrails from LiteLLM + */ +router.get('/', async (req, res) => { + try { + // Resolve LiteLLM from custom endpoints by name (e.g., name: "litellm") + const litellmConfig = req.config?.endpoints?.custom?.find( + (ep) => (ep?.name || '').toLowerCase() === 'litellm' + ); + + + if (!litellmConfig) { + return res + .status(400) + .json({ error: 'LiteLLM endpoint not configured in LibreChat config' }); + } + + const baseURL = litellmConfig.baseURL; + const apiKey = litellmConfig.apiKey; + + if (!baseURL) { + return res.status(400).json({ error: 'LiteLLM base URL not configured' }); + } + + const headers = {}; + + // Use authorization from request or config + if (req.headers.authorization) { + headers.Authorization = req.headers.authorization; + } else if (apiKey) { + headers.Authorization = `Bearer ${apiKey}`; + } + + const guardrails = await fetchGuardrails(baseURL, headers); + + res.json({ guardrails }); + } catch (error) { + logger.error('[GuardrailsRoute] Error fetching guardrails:', error); + res.status(500).json({ error: 'Failed to fetch guardrails' }); + } +}); + +module.exports = router; diff --git a/api/server/routes/index.js b/api/server/routes/index.js index adaca3859..a87147618 100644 --- a/api/server/routes/index.js +++ b/api/server/routes/index.js @@ -27,9 +27,11 @@ const edit = require('./edit'); const keys = require('./keys'); const user = require('./user'); const mcp = require('./mcp'); +const guardrails = require('./guardrails'); module.exports = { mcp, + guardrails, edit, auth, keys, diff --git a/api/server/services/GuardrailsService.js b/api/server/services/GuardrailsService.js new file mode 100644 index 000000000..49a37d640 --- /dev/null +++ b/api/server/services/GuardrailsService.js @@ -0,0 +1,34 @@ +const { logger } = require('~/utils'); + +/** + * Fetches available guardrails from LiteLLM endpoint + * @param {string} baseURL - The LiteLLM base URL + * @param {Object} headers - Request headers + * @returns {Promise} Array of available guardrails + */ +async function fetchGuardrails(baseURL, headers = {}) { + try { + const response = await fetch(`${baseURL}/guardrails/list`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + }); + + if (!response.ok) { + logger.warn('[GuardrailsService] Failed to fetch guardrails:', response.status, response.statusText); + return []; + } + + const data = await response.json(); + return data.guardrails || []; + } catch (error) { + logger.error('[GuardrailsService] Error fetching guardrails:', error); + return []; + } +} + +module.exports = { + fetchGuardrails, +}; \ No newline at end of file diff --git a/client/src/components/Chat/Menus/Endpoints/ModelSelector.tsx b/client/src/components/Chat/Menus/Endpoints/ModelSelector.tsx index d9464182b..202812240 100644 --- a/client/src/components/Chat/Menus/Endpoints/ModelSelector.tsx +++ b/client/src/components/Chat/Menus/Endpoints/ModelSelector.tsx @@ -2,16 +2,13 @@ import React, { useMemo } from 'react'; import type { ModelSelectorProps } from '~/common'; import { ModelSelectorProvider, useModelSelectorContext } from './ModelSelectorContext'; import { ModelSelectorChatProvider } from './ModelSelectorChatContext'; -import { - renderModelSpecs, - renderEndpoints, - renderSearchResults, - renderCustomGroups, -} from './components'; +import { renderModelSpecs, renderEndpoints, renderSearchResults } from './components'; import { getSelectedIcon, getDisplayValue } from './utils'; import { CustomMenu as Menu } from './CustomMenu'; import DialogManager from './DialogManager'; import { useLocalize } from '~/hooks'; +import GuardrailsSelect from '~/components/Input/GuardrailsSelect'; + function ModelSelectorContent() { const localize = useLocalize(); @@ -81,6 +78,7 @@ function ModelSelectorContent() { endpoint: values.endpoint || '', model: values.model || '', modelSpec: values.modelSpec || '', + guardrails: values.guardrails || [], }); }} onSearch={(value) => setSearchValue(value)} @@ -91,18 +89,34 @@ function ModelSelectorContent() { renderSearchResults(searchResults, localize, searchValue) ) : ( <> - {/* Render ungrouped modelSpecs (no group field) */} - {renderModelSpecs( - modelSpecs?.filter((spec) => !spec.group) || [], - selectedValues.modelSpec || '', - )} - {/* Render endpoints (will include grouped specs matching endpoint names) */} + {renderModelSpecs(modelSpecs, selectedValues.modelSpec || '')} {renderEndpoints(mappedEndpoints ?? [])} - {/* Render custom groups (specs with group field not matching any endpoint) */} - {renderCustomGroups(modelSpecs || [], mappedEndpoints ?? [])} + {/* Guardrails section - only show when a model is selected */} + {selectedValues.model && ( +
+
+ Guardrails +
+ (value) => { + console.log('setOption called with:', key, value); + setSelectedValues({ + ...selectedValues, + [key]: value + }); + }} + showAbove={false} + /> +
+ )} )} + + (value: any) => void; + showAbove?: boolean; +} + +export default function GuardrailsSelect({ + conversation, + setOption, + showAbove = false, +}: GuardrailsSelectProps) { + + console.log("GuardrailsSelect rendered"); // <-- Add this + console.log("Conversation:", conversation); // <-- Check if conversation is defined + + const localize = useLocalize(); + const [isOpen, setIsOpen] = useState(false); + const [selectedGuardrails, setSelectedGuardrails] = useState([]); + const menuRef = React.useRef(null); + + console.log("Selected Guardrails:", selectedGuardrails); // <-- Check if selectedGuardrails is defined + + useOnClickOutside(menuRef, () => setIsOpen(false), () => { + console.log("Menu clicked outside"); // <-- Add this + }); + + const { data: guardrails = [], isLoading, error } = useGuardrails(); + + console.log("Guardrails data from hook:", guardrails); // <-- Add this + console.log("isLoading:", isLoading, "error:", error); // <-- Check loading/error state + + // Initialize selected guardrails from conversation + useEffect(() => { + if (conversation?.guardrails) { + setSelectedGuardrails(conversation.guardrails); + console.log("Selected Guardrails:", selectedGuardrails); // <-- Check if selectedGuardrails is defined + + } + }, [conversation?.guardrails]); + + const handleGuardrailToggle = (guardrailName: string) => { + console.log('handleGuardrailToggle called with:', guardrailName); + setSelectedGuardrails(prev => { + const newSelection = prev.includes(guardrailName) + ? prev.filter(name => name !== guardrailName) + : [...prev, guardrailName]; + + console.log('Calling setOption with guardrails:', newSelection); + setOption('guardrails')(newSelection); + return newSelection; + }); + }; + + + + if (isLoading) { + console.log("Loading guardrails..."); // <-- Check if guardrails is loading + return ( +
+ + Loading guardrails... +
+ ); + } + + if (error) { + console.log("Error loading guardrails:", error); // <-- Check if error is defined + return ( +
+ + Failed to load guardrails +
+ ); + } + + if (guardrails.length === 0) { + console.log("Guardrails:", guardrails); // <-- Check if guardrails is defined + return null; + } + + return ( +
+ {}}> + {() => ( + <> + { + setIsOpen(!isOpen); + console.log("ListboxButton clicked"); + console.log("isOpen:", isOpen); + }} + + > + + + + + + {selectedGuardrails.length === 0 + ? 'No guardrails selected' + : `${selectedGuardrails.length} guardrail${selectedGuardrails.length > 1 ? 's' : ''} selected` + } + + + + + + + + + {isOpen && createPortal( +
+ {guardrails.map((guardrailName: string) => { + const isSelected = selectedGuardrails.includes(guardrailName); + return ( +
{ + console.log('Option clicked:', guardrailName); + handleGuardrailToggle(guardrailName); + }} + > +
+ + {guardrailName} + {isSelected && ( + + + + )} +
+
+ ); + })} +
, + document.body + )} + + )} +
+
+ ); +} diff --git a/client/src/components/Input/ModelSelect/Anthropic.tsx b/client/src/components/Input/ModelSelect/Anthropic.tsx index e3cdc5fc9..7bf1448b5 100644 --- a/client/src/components/Input/ModelSelect/Anthropic.tsx +++ b/client/src/components/Input/ModelSelect/Anthropic.tsx @@ -24,4 +24,4 @@ export default function Anthropic({ )} /> ); -} +} \ No newline at end of file diff --git a/client/src/components/Input/ModelSelect/Google.tsx b/client/src/components/Input/ModelSelect/Google.tsx index 870d91ee6..e736967c0 100644 --- a/client/src/components/Input/ModelSelect/Google.tsx +++ b/client/src/components/Input/ModelSelect/Google.tsx @@ -24,4 +24,4 @@ export default function Google({ )} /> ); -} +} \ No newline at end of file diff --git a/client/src/components/Input/ModelSelect/ModelSelect.tsx b/client/src/components/Input/ModelSelect/ModelSelect.tsx index 959fd60ec..640f55b66 100644 --- a/client/src/components/Input/ModelSelect/ModelSelect.tsx +++ b/client/src/components/Input/ModelSelect/ModelSelect.tsx @@ -47,4 +47,4 @@ export default function ModelSelect({ popover={popover} /> ); -} +} \ No newline at end of file diff --git a/client/src/components/Input/ModelSelect/MultiSelectDropDown.tsx b/client/src/components/Input/ModelSelect/MultiSelectDropDown.tsx index 6a1deee15..98aa96266 100644 --- a/client/src/components/Input/ModelSelect/MultiSelectDropDown.tsx +++ b/client/src/components/Input/ModelSelect/MultiSelectDropDown.tsx @@ -226,4 +226,4 @@ function MultiSelectDropDown({ ); } -export default MultiSelectDropDown; +export default MultiSelectDropDown; \ No newline at end of file diff --git a/client/src/components/Input/ModelSelect/OpenAI.tsx b/client/src/components/Input/ModelSelect/OpenAI.tsx index f885950de..9ec1894e5 100644 --- a/client/src/components/Input/ModelSelect/OpenAI.tsx +++ b/client/src/components/Input/ModelSelect/OpenAI.tsx @@ -24,4 +24,4 @@ export default function OpenAI({ )} /> ); -} +} \ No newline at end of file diff --git a/client/src/hooks/AuthContext.tsx b/client/src/hooks/AuthContext.tsx index d9d583783..39dc07b99 100644 --- a/client/src/hooks/AuthContext.tsx +++ b/client/src/hooks/AuthContext.tsx @@ -38,6 +38,7 @@ const AuthContextProvider = ({ const [error, setError] = useState(undefined); const [isAuthenticated, setIsAuthenticated] = useState(false); const logoutRedirectRef = useRef(undefined); + const logoutInProgressRef = useRef(false); const { data: userRole = null } = useGetRole(SystemRoles.USER, { enabled: !!(isAuthenticated && (user?.role ?? '')), @@ -120,6 +121,8 @@ const AuthContextProvider = ({ if (redirect) { logoutRedirectRef.current = redirect; } + // mark logout in progress to avoid triggering a silent refresh + logoutInProgressRef.current = true; logoutUser.mutate(undefined); }, [logoutUser], @@ -136,6 +139,11 @@ const AuthContextProvider = ({ console.log('Test mode. Skipping silent refresh.'); return; } + // If a logout is in progress, skip attempting a silent refresh + if (logoutInProgressRef.current) { + console.log('Logout in progress. Skipping silent refresh.'); + return; + } refreshToken.mutate(undefined, { onSuccess: (data: t.TRefreshTokenResponse | undefined) => { const { user, token = '' } = data ?? {}; diff --git a/client/src/hooks/useGuardrails.ts b/client/src/hooks/useGuardrails.ts new file mode 100644 index 000000000..99d83a056 --- /dev/null +++ b/client/src/hooks/useGuardrails.ts @@ -0,0 +1,30 @@ +import { useQuery } from '@tanstack/react-query'; +import { useGetEndpointsQuery } from '~/data-provider'; + +interface Guardrail { + guardrail_name: string; +} + +interface GuardrailsResponse { + guardrails: Guardrail[]; +} + +const fetchGuardrails = async (): Promise => { + const response = await fetch('/api/guardrails'); + if (!response.ok) { + throw new Error('Failed to fetch guardrails'); + } + const data = await response.json(); + console.log("Guardrails data from hook:", data); + return data.guardrails?.map((g: any) => g.guardrail_name) || []; + +}; + +export const useGuardrails = () => { + return useQuery({ + queryKey: ['guardrails'], + queryFn: () => fetchGuardrails(), + staleTime: 5 * 60 * 1000, // 5 minutes + retry: 1, + }); +}; diff --git a/docker-compose.yml b/docker-compose.yml index 9a3e4bbd5..e5f30552b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,7 +9,11 @@ services: depends_on: - mongodb - rag_api - image: ghcr.io/danny-avila/librechat-dev:latest + # image: ghcr.io/danny-avila/librechat-dev:latest + build: + context: . + dockerfile: Dockerfile + image: librechat-dev-local restart: always user: "${UID}:${GID}" extra_hosts: diff --git a/litellm/litellm-config.yml b/litellm/litellm-config.yml new file mode 100644 index 000000000..a8b33a96a --- /dev/null +++ b/litellm/litellm-config.yml @@ -0,0 +1,76 @@ +model_list: + - model_name: gemini-2.5-flash + litellm_params: + model: gemini/gemini-2.5-flash + system_message: | + You are a helpful assistant. + + You may receive text that contains placeholders or tagged entities such as: + , , , , , etc. + + Each tag indicates the type of entity it represents: + - → a person’s name + - → an email address + - → a phone number + - → an organization + - → a location + + Rules: + 1. Always preserve the tags and their contents exactly as they appear in the input. Never modify or remove them. + 2. Use the entity type for reasoning and context understanding — e.g., treat as a human name, as an organization name, etc. + 3. Do **not** mention that these entities are masked, anonymized, redacted, or placeholders. + 4. Do **not** attempt to replace, shorten, or interpret the internal values. + 5. Respond naturally as if the entities were real, while keeping the tags intact. + + Example behavior: + User: 527bd5b5 met 5844a15e at c2b4e8. What happened next? + Assistant: 527bd5b5 and 5844a15e continued their discussion at c2b4e8. + + +litellm_settings: + set_verbose: true + success_callback: ["langfuse"] + failure_callback: ["langfuse"] + # Enable detailed logging + disable_guardrail_logging: false + # Track costs + track_cost_per_model: true + track_cost_per_user: true + # Log everything for debugging + log_input_output: true + log_async: true + # Enable proper demasking + enable_guardrail_demasking: true + preserve_original_mapping: true + +guardrails: + - guardrail_name: "presidio-pii" + litellm_params: + guardrail: presidio + presidio_analyzer_api_base: "http://presidio-analyzer:3000" # analyzer + presidio_anonymizer_api_base: "http://presidio-anonymizer:3000" # anonymizer + #default_on: true + mode: + - "pre_call" + - "post_call" + presidio_language: "en" + output_parse_pii: true + # Enable proper demasking with original mapping + presidio_anonymize_only: false + presidio_keep_original: true + presidio_restore_original: true + pii_entities_config: + PERSON: "MASK" + EMAIL_ADDRESS: "MASK" + +# guardrails: +# - guardrail_name: protecto +# litellm_params: +# guardrail: protecto +# mode: +# - pre_call +# - post_call +# default_on: true +# protecto_api_base: os.environ/PROTECTO_API_BASE +# protecto_auth_token: os.environ/PROTECTO_AUTH_TOKEN +# output_parse_pii: true diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index a279f4f84..abcc1e79d 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -1294,4 +1294,4 @@ export const compactAgentsBaseSchema = tConversationSchema.pick({ export const compactAgentsSchema = compactAgentsBaseSchema .transform((obj) => removeNullishValues(obj)) - .catch(() => ({})); + .catch(() => ({})); \ No newline at end of file