LibreChat/packages/data-provider/src/api-endpoints.ts
Dustin Healy af795be0c2
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
🪢 feat: Langfuse Fanout Connection Setting (#14108)
* feat: encrypt tenant Langfuse secret in admin config

Add generic per-field secret encryption to the admin config layer: registered
secret paths (langfuse.secretKey) are encrypted with encryptV3 on write and a
non-secret fingerprint companion is stored. Admin config reads (base + per
principal) redact registered secrets so they are never returned; the fingerprint
is kept so the UI can show which key is configured.

The Langfuse fanout read path decrypts the tenant secret before export. Adds
secretKeyFingerprint to langfuseConfigSchema and tests for the encrypt/redact
policy.

* fix(api): secure admin config secret handling

* fix(api): preserve encrypted langfuse config secrets

* fix(api): couple config secret fingerprint deletion

* fix(api): read langfuse fanout collector url from env

* fix(api): display langfuse secret key hint

* fix(api): remove langfuse secret fingerprint breadcrumbs

* fix(api): use langfuse destination keys for tenant config

* fix(api): remove langfuse config compatibility fallbacks

* refactor(api): simplify langfuse secret helpers

* refactor(api): simplify langfuse config secret handling

* feat: in-app Langfuse connection settings panel

Add a discoverable, admin-gated Langfuse connection panel inside LibreChat
Settings (Dify-style): enable toggle, host, public key, masked write-only secret,
configured-key fingerprint, and a test-connection action. Backed by a dedicated
/api/admin/langfuse/connection endpoint that encrypts the secret at rest, returns
metadata plus fingerprint on read, and validates credentials. Builds on the
per-field encryption and fanout decrypt from the langfuse-config-encryption branch.

* refactor: align Langfuse secret field to CustomUserVars pattern

Use the established SecretInput plus Set/Unset state pill (com_ui_set/com_ui_unset)
from the MCP CustomUserVars UI for the saved-secret state, instead of a bespoke
masked input.

* fix: drop em dash from saved-secret placeholder

* feat: show loading state on Langfuse test connection button

* feat: gate in-app Langfuse settings on fanout config and admin role

* test: align Langfuse connection spec with SecretInput refactor

* feat(langfuse): refine tenant connection controls

* fix(admin): refine Langfuse connection verification

* fix(langfuse): refine tenant connection settings

* fix(langfuse): simplify export enablement controls

* fix(langfuse): validate tenant export configuration

* fix(langfuse): align startup fanout gate

* fix(admin): time out Langfuse verification

* fix(ui): rename Langfuse connection setting

* fix(admin): enforce Langfuse config capability

* feat(langfuse): require explicit tenant export activation

* feat(langfuse): support single-tenant connection settings

* fix(i18n): remove obsolete integrations label

* fix(langfuse): authenticate ingestion verification

* fix(langfuse): validate public key independently

* fix(langfuse): localize connection errors

* perf(config): skip Langfuse checks for non-admins

* fix(langfuse): preserve trace sampling for feedback

* test(langfuse): fix feedback sampling fixture

* fix(langfuse): align secret preview field

* fix(langfuse): harden connection settings state

* fix(langfuse): preserve trace destination state

* fix(langfuse): enforce tenant-wide routing invariants

* fix(langfuse): preserve verified connection invariants

* fix(langfuse): preserve stable project identity

* fix(langfuse): warm project identity asynchronously

---------

Co-authored-by: Ravi Kumar L <ravi.lazar@clickhouse.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
2026-07-29 18:33:10 -04:00

535 lines
19 KiB
TypeScript

import type { StartupConfigContext } from './config';
import type { AssistantsEndpoint } from './schemas';
import { ResourceType } from './accessPermissions';
import * as q from './types/queries';
let BASE_URL = '';
if (
typeof process === 'undefined' ||
(process as typeof process & { browser?: boolean }).browser === true
) {
// process is only available in node context, or process.browser is true in client-side code
// This is to ensure that the BASE_URL is set correctly based on the <base>
// element in the HTML document, if it exists.
const baseEl = document.querySelector('base');
BASE_URL = baseEl?.getAttribute('href') || '/';
}
if (BASE_URL && BASE_URL.endsWith('/')) {
BASE_URL = BASE_URL.slice(0, -1);
}
export const apiBaseUrl = () => BASE_URL;
// Testing this buildQuery function
const buildQuery = (params: Record<string, unknown>): string => {
const query = Object.entries(params)
.filter(([, value]) => {
if (Array.isArray(value)) {
return value.length > 0;
}
return value !== undefined && value !== null && value !== '';
})
.map(([key, value]) => {
if (Array.isArray(value)) {
return value.map((v) => `${key}=${encodeURIComponent(v)}`).join('&');
}
return `${key}=${encodeURIComponent(String(value))}`;
})
.join('&');
return query ? `?${query}` : '';
};
export const health = () => `${BASE_URL}/health`;
export const user = () => `${BASE_URL}/api/user`;
export const balance = () => `${BASE_URL}/api/balance`;
export const userPlugins = () => `${BASE_URL}/api/user/plugins`;
export const deleteUser = () => `${BASE_URL}/api/user/delete`;
const messagesRoot = `${BASE_URL}/api/messages`;
export const messages = (params: q.MessagesListParams) => {
const { conversationId, messageId, ...rest } = params;
if (conversationId && messageId) {
return `${messagesRoot}/${conversationId}/${messageId}`;
}
if (conversationId) {
return `${messagesRoot}/${conversationId}`;
}
return `${messagesRoot}${buildQuery(rest)}`;
};
export const messagesArtifacts = (messageId: string) => `${messagesRoot}/artifact/${messageId}`;
export const messagesBranch = () => `${messagesRoot}/branch`;
const shareRoot = `${BASE_URL}/api/share`;
export const shareMessages = (shareId: string) => `${shareRoot}/${shareId}`;
export const forkSharedMessages = (shareId: string) => `${shareRoot}/${shareId}/fork`;
export const sharedStartupConfig = (shareId: string) => `${shareMessages(shareId)}/config`;
export const getSharedLink = (conversationId: string) => `${shareRoot}/link/${conversationId}`;
export const getSharedLinks = (
pageSize: number,
sortBy: 'title' | 'createdAt',
sortDirection: 'asc' | 'desc',
search?: string,
cursor?: string,
) =>
`${shareRoot}?pageSize=${pageSize}&sortBy=${sortBy}&sortDirection=${sortDirection}${
search ? `&search=${search}` : ''
}${cursor ? `&cursor=${cursor}` : ''}`;
export const createSharedLink = (conversationId: string) => `${shareRoot}/${conversationId}`;
export const updateSharedLink = (shareId: string) => `${shareRoot}/${shareId}`;
/** Share-scoped file routes: serve snapshotted files via shared-link permission. */
export const sharedFile = (shareId: string, fileId: string) =>
`${shareRoot}/${shareId}/files/${encodeURIComponent(fileId)}`;
export const sharedFileDownload = (shareId: string, fileId: string) =>
`${sharedFile(shareId, fileId)}/download`;
export const sharedFilePreview = (shareId: string, fileId: string) =>
`${sharedFile(shareId, fileId)}/preview`;
const keysEndpoint = `${BASE_URL}/api/keys`;
export const keys = () => keysEndpoint;
export const userKeyQuery = (name: string) => `${keysEndpoint}?name=${name}`;
export const revokeUserKey = (name: string) => `${keysEndpoint}/${name}`;
export const revokeAllUserKeys = () => `${keysEndpoint}?all=true`;
const apiKeysEndpoint = `${BASE_URL}/api/api-keys`;
export const apiKeys = () => apiKeysEndpoint;
export const apiKeyById = (id: string) => `${apiKeysEndpoint}/${id}`;
export const conversationsRoot = `${BASE_URL}/api/convos`;
export const conversations = (params: q.ConversationListParams) => {
return `${conversationsRoot}${buildQuery(params)}`;
};
export const conversationById = (id: string) => `${conversationsRoot}/${id}`;
export const genTitle = (conversationId: string) =>
`${conversationsRoot}/gen_title/${encodeURIComponent(conversationId)}`;
export const updateConversation = () => `${conversationsRoot}/update`;
export const archiveConversation = () => `${conversationsRoot}/archive`;
export const pinConversation = () => `${conversationsRoot}/pin`;
export const deleteConversation = () => `${conversationsRoot}`;
export const deleteAllConversation = () => `${conversationsRoot}/all`;
export const importConversation = () => `${conversationsRoot}/import`;
export const forkConversation = () => `${conversationsRoot}/fork`;
export const duplicateConversation = () => `${conversationsRoot}/duplicate`;
export const projectsRoot = `${BASE_URL}/api/projects`;
export const projects = (params: q.ProjectListParams = {}) => {
return `${projectsRoot}${buildQuery(params)}`;
};
export const projectById = (id: string) => `${projectsRoot}/${encodeURIComponent(id)}`;
export const projectConversation = (conversationId: string) =>
`${projectsRoot}/conversations/${encodeURIComponent(conversationId)}`;
export const search = (q: string, cursor?: string | null) =>
`${BASE_URL}/api/search?q=${q}${cursor ? `&cursor=${cursor}` : ''}`;
export const searchEnabled = () => `${BASE_URL}/api/search/enable`;
export const presets = () => `${BASE_URL}/api/presets`;
export const deletePreset = () => `${BASE_URL}/api/presets/delete`;
export const aiEndpoints = () => `${BASE_URL}/api/endpoints`;
export const tokenConfig = () => `${BASE_URL}/api/endpoints/token-config`;
export const models = () => `${BASE_URL}/api/models`;
export const tokenizer = () => `${BASE_URL}/api/tokenizer`;
export const login = () => `${BASE_URL}/api/auth/login`;
export const logout = () => `${BASE_URL}/api/auth/logout`;
export const register = () => `${BASE_URL}/api/auth/register`;
export const loginFacebook = () => `${BASE_URL}/api/auth/facebook`;
export const loginGoogle = () => `${BASE_URL}/api/auth/google`;
export const refreshToken = (retry?: boolean) =>
`${BASE_URL}/api/auth/refresh${retry === true ? '?retry=true' : ''}`;
export const requestPasswordReset = () => `${BASE_URL}/api/auth/requestPasswordReset`;
export const resetPassword = () => `${BASE_URL}/api/auth/resetPassword`;
export const verifyEmail = () => `${BASE_URL}/api/user/verify`;
// Auth page URLs (for client-side navigation and redirects)
export const loginPage = () => `${BASE_URL}/login`;
export const registerPage = () => `${BASE_URL}/register`;
const REDIRECT_PARAM = 'redirect_to';
const LOGIN_PATH_RE = /(?:^|\/)login(?:\/|$)/;
/**
* Builds a `/login?redirect_to=...` URL from the given or current location.
* Returns plain `/login` (no param) when already on a login route to prevent recursive nesting.
*/
export function buildLoginRedirectUrl(pathname?: string, search?: string, hash?: string): string {
const p = pathname ?? window.location.pathname;
if (LOGIN_PATH_RE.test(p)) {
return '/login';
}
const s = search ?? window.location.search;
const h = hash ?? window.location.hash;
const stripped =
BASE_URL && (p === BASE_URL || p.startsWith(BASE_URL + '/'))
? p.slice(BASE_URL.length) || '/'
: p;
const currentPath = `${stripped}${s}${h}`;
if (!currentPath || currentPath === '/') {
return '/login';
}
return `/login?${REDIRECT_PARAM}=${encodeURIComponent(currentPath)}`;
}
export const resendVerificationEmail = () => `${BASE_URL}/api/user/verify/resend`;
export const plugins = () => `${BASE_URL}/api/plugins`;
export const mcpReinitialize = (serverName: string) =>
`${BASE_URL}/api/mcp/${serverName}/reinitialize`;
export const mcpConnectionStatus = () => `${BASE_URL}/api/mcp/connection/status`;
export const mcpServerConnectionStatus = (serverName: string) =>
`${BASE_URL}/api/mcp/connection/status/${serverName}`;
export const mcpAuthValues = (serverName: string) => {
return `${BASE_URL}/api/mcp/${serverName}/auth-values`;
};
export const cancelMCPOAuth = (serverName: string) => {
return `${BASE_URL}/api/mcp/oauth/cancel/${serverName}`;
};
export const mcpOAuthBind = (serverName: string) => `${BASE_URL}/api/mcp/${serverName}/oauth/bind`;
export const actionOAuthBind = (actionId: string) =>
`${BASE_URL}/api/actions/${actionId}/oauth/bind`;
export const config = (context?: StartupConfigContext) =>
`${BASE_URL}/api/config${buildQuery({ context })}`;
export const prompts = () => `${BASE_URL}/api/prompts`;
export const addPromptToGroup = (groupId: string) =>
`${BASE_URL}/api/prompts/groups/${groupId}/prompts`;
export const assistants = ({
path = '',
options,
version,
endpoint,
isAvatar,
}: {
path?: string;
options?: object;
endpoint?: AssistantsEndpoint;
version: number | string;
isAvatar?: boolean;
}) => {
let url = isAvatar === true ? `${images()}/assistants` : `${BASE_URL}/api/assistants/v${version}`;
if (path && path !== '') {
url += `/${path}`;
}
if (endpoint) {
options = {
...(options ?? {}),
endpoint,
};
}
if (options && Object.keys(options).length > 0) {
const queryParams = new URLSearchParams(options as Record<string, string>).toString();
url += `?${queryParams}`;
}
return url;
};
export const agents = ({ path = '', options }: { path?: string; options?: object }) => {
let url = `${BASE_URL}/api/agents`;
if (path && path !== '') {
url += `/${path}`;
}
if (options && Object.keys(options).length > 0) {
const queryParams = new URLSearchParams(options as Record<string, string>).toString();
url += `?${queryParams}`;
}
return url;
};
export const activeJobs = () => `${BASE_URL}/api/agents/chat/active`;
export const mcp = {
tools: `${BASE_URL}/api/mcp/tools`,
servers: `${BASE_URL}/api/mcp/servers`,
};
export const mcpServer = (serverName: string) => `${BASE_URL}/api/mcp/servers/${serverName}`;
export const revertAgentVersion = (agent_id: string) => `${agents({ path: `${agent_id}/revert` })}`;
export const files = () => `${BASE_URL}/api/files`;
export const fileUpload = () => `${BASE_URL}/api/files`;
export const fileDelete = () => `${BASE_URL}/api/files`;
export const fileDownload = (userId: string, fileId: string) =>
`${BASE_URL}/api/files/download/${userId}/${fileId}`;
/* Deferred-preview lifecycle endpoint. Returns
* `{ status, text?, textFormat?, previewError? }` so the frontend can
* poll while background HTML extraction is in flight. See PR #12957. */
export const filePreview = (fileId: string) =>
`${BASE_URL}/api/files/${encodeURIComponent(fileId)}/preview`;
export const fileConfig = () => `${BASE_URL}/api/files/config`;
/** Owner-scoped usage touch so queued attachments outlive the upload-window TTL. */
export const fileUsage = () => `${BASE_URL}/api/files/usage`;
export const agentFiles = (agentId: string) => `${BASE_URL}/api/files/agent/${agentId}`;
export const images = () => `${files()}/images`;
export const avatar = () => `${images()}/avatar`;
export const speech = () => `${files()}/speech`;
export const speechToText = () => `${speech()}/stt`;
export const textToSpeech = () => `${speech()}/tts`;
export const textToSpeechManual = () => `${textToSpeech()}/manual`;
export const textToSpeechVoices = () => `${textToSpeech()}/voices`;
export const getCustomConfigSpeech = () => `${speech()}/config/get`;
export const getPromptGroup = (_id: string) => `${prompts()}/groups/${_id}`;
export const getPromptGroupsWithFilters = (filter: object) => {
let url = `${prompts()}/groups`;
// Filter out undefined/null values
const cleanedFilter = Object.entries(filter).reduce(
(acc, [key, value]) => {
if (value !== undefined && value !== null && value !== '') {
acc[key] = value;
}
return acc;
},
{} as Record<string, string>,
);
if (Object.keys(cleanedFilter).length > 0) {
const queryParams = new URLSearchParams(cleanedFilter).toString();
url += `?${queryParams}`;
}
return url;
};
export const getPromptsWithFilters = (filter: object) => {
let url = prompts();
if (Object.keys(filter).length > 0) {
const queryParams = new URLSearchParams(filter as Record<string, string>).toString();
url += `?${queryParams}`;
}
return url;
};
export const getPrompt = (_id: string) => `${prompts()}/${_id}`;
export const getRandomPrompts = (limit: number, skip: number) =>
`${prompts()}/random?limit=${limit}&skip=${skip}`;
export const postPrompt = prompts;
export const updatePromptGroup = getPromptGroup;
export const recordPromptGroupUsage = (groupId: string) => `${prompts()}/groups/${groupId}/use`;
export const updatePromptLabels = (_id: string) => `${getPrompt(_id)}/labels`;
export const updatePromptTag = (_id: string) => `${getPrompt(_id)}/tags/production`;
export const deletePromptGroup = getPromptGroup;
export const deletePrompt = ({ _id, groupId }: { _id: string; groupId: string }) => {
return `${prompts()}/${_id}?groupId=${groupId}`;
};
export const getCategories = () => `${BASE_URL}/api/categories`;
export const getAllPromptGroups = () => `${prompts()}/all`;
/* Skills */
export const skills = () => `${BASE_URL}/api/skills`;
export const importSkill = () => `${skills()}/import`;
export const getSkill = (id: string) => `${skills()}/${encodeURIComponent(id)}`;
export const listSkillsWithFilters = (
filter: Record<string, string | number | undefined | null>,
) => {
const cleaned = Object.entries(filter).reduce(
(acc, [key, value]) => {
if (value !== undefined && value !== null && value !== '') {
acc[key] = String(value);
}
return acc;
},
{} as Record<string, string>,
);
const query =
Object.keys(cleaned).length > 0 ? `?${new URLSearchParams(cleaned).toString()}` : '';
return `${skills()}${query}`;
};
export const skillFiles = (id: string) => `${getSkill(id)}/files`;
export const skillFile = (id: string, relativePath: string) =>
`${skillFiles(id)}/${encodeURIComponent(relativePath)}`;
export const adminSkillsSync = () => `${BASE_URL}/api/admin/skills/sync`;
export const adminSkillsSyncStatus = () => `${adminSkillsSync()}/status`;
export const adminSkillsSyncRun = () => `${adminSkillsSync()}/run`;
export const adminSkillsSyncCredential = (credentialKey: string) =>
`${adminSkillsSync()}/credentials/${encodeURIComponent(credentialKey)}`;
/**
* Skill filesystem tree (phase 2). URL shape mirrors the original UI PR so
* the tree hooks keep their call surface. `path` is pre-encoded by the
* caller (e.g. `${nodeId}/content`).
*/
export const skillTree = ({ skillId, path = '' }: { skillId: string; path?: string }) => {
let url = `${BASE_URL}/api/skills/${encodeURIComponent(skillId)}/tree`;
if (path) {
url += `/${path}`;
}
return url;
};
/* Skill active states (per-user overrides) */
export const skillStates = () => `${BASE_URL}/api/user/settings/skills/active`;
/* Langfuse connection (admin) */
export const adminLangfuseConnection = () => `${BASE_URL}/api/admin/langfuse/connection`;
export const adminLangfuseConnectionTest = () => `${adminLangfuseConnection()}/test`;
/* Tool favorites (starred marketplace items) */
export const toolFavorites = () => `${BASE_URL}/api/user/settings/favorites/tools`;
export const toolFavorite = (itemType: string, itemId: string) =>
`${toolFavorites()}/${itemType}/${encodeURIComponent(itemId)}`;
/* Roles */
export const roles = () => `${BASE_URL}/api/roles`;
export const adminRoles = () => `${BASE_URL}/api/admin/roles`;
export const getRole = (roleName: string) => `${roles()}/${encodeURIComponent(roleName)}`;
export const updatePromptPermissions = (roleName: string) => `${getRole(roleName)}/prompts`;
export const updateMemoryPermissions = (roleName: string) => `${getRole(roleName)}/memories`;
export const updateAgentPermissions = (roleName: string) => `${getRole(roleName)}/agents`;
export const updatePeoplePickerPermissions = (roleName: string) =>
`${getRole(roleName)}/people-picker`;
export const updateMCPServersPermissions = (roleName: string) => `${getRole(roleName)}/mcp-servers`;
export const updateRemoteAgentsPermissions = (roleName: string) =>
`${getRole(roleName)}/remote-agents`;
export const updateMarketplacePermissions = (roleName: string) =>
`${getRole(roleName)}/marketplace`;
export const updateSkillPermissions = (roleName: string) => `${getRole(roleName)}/skills`;
/* Conversation Tags */
export const conversationTags = (tag?: string) =>
`${BASE_URL}/api/tags${tag != null && tag ? `/${encodeURIComponent(tag)}` : ''}`;
export const conversationTagsList = (pageNumber: string, sort?: string, order?: string) =>
`${conversationTags()}/list?pageNumber=${pageNumber}${sort ? `&sort=${sort}` : ''}${
order ? `&order=${order}` : ''
}`;
export const addTagToConversation = (conversationId: string) =>
`${conversationTags()}/convo/${conversationId}`;
export const userTerms = () => `${BASE_URL}/api/user/terms`;
export const acceptUserTerms = () => `${BASE_URL}/api/user/terms/accept`;
export const banner = () => `${BASE_URL}/api/banner`;
// Message Feedback
export const feedback = (conversationId: string, messageId: string) =>
`${BASE_URL}/api/messages/${conversationId}/${messageId}/feedback`;
// Two-Factor Endpoints
export const enableTwoFactor = () => `${BASE_URL}/api/auth/2fa/enable`;
export const verifyTwoFactor = () => `${BASE_URL}/api/auth/2fa/verify`;
export const confirmTwoFactor = () => `${BASE_URL}/api/auth/2fa/confirm`;
export const disableTwoFactor = () => `${BASE_URL}/api/auth/2fa/disable`;
export const regenerateBackupCodes = () => `${BASE_URL}/api/auth/2fa/backup/regenerate`;
export const verifyTwoFactorTemp = () => `${BASE_URL}/api/auth/2fa/verify-temp`;
/* Memories */
export const memories = () => `${BASE_URL}/api/memories`;
export const memory = (key: string, agentId?: string) =>
`${memories()}/${encodeURIComponent(key)}${agentId ? `?agentId=${encodeURIComponent(agentId)}` : ''}`;
export const memoryPreferences = () => `${memories()}/preferences`;
export const searchPrincipals = (params: q.PrincipalSearchParams) => {
const { q: query, limit, types } = params;
let url = `${BASE_URL}/api/permissions/search-principals?q=${encodeURIComponent(query)}`;
if (limit !== undefined) {
url += `&limit=${limit}`;
}
if (types && types.length > 0) {
url += `&types=${types.join(',')}`;
}
return url;
};
export const getAccessRoles = (resourceType: ResourceType) =>
`${BASE_URL}/api/permissions/${resourceType}/roles`;
export const getResourcePermissions = (resourceType: ResourceType, resourceId: string) =>
`${BASE_URL}/api/permissions/${resourceType}/${resourceId}`;
export const updateResourcePermissions = (resourceType: ResourceType, resourceId: string) =>
`${BASE_URL}/api/permissions/${resourceType}/${resourceId}`;
export const getEffectivePermissions = (resourceType: ResourceType, resourceId: string) =>
`${BASE_URL}/api/permissions/${resourceType}/${resourceId}/effective`;
export const getAllEffectivePermissions = (resourceType: ResourceType) =>
`${BASE_URL}/api/permissions/${resourceType}/effective/all`;
// SharePoint Graph API Token
export const graphToken = (scopes: string) =>
`${BASE_URL}/api/auth/graph-token?scopes=${encodeURIComponent(scopes)}`;