mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-11-12 09:45:47 +00:00
* 🧬 feat: Re-Derive Azure Groups, Vertex AI, Custom Parameters and Spec Defaults for Config Profiles AppService derives part of the served config from librechat.yaml at startup, and config profiles merged into that output without deriving it again. Azure `groups` now merge by `group` like `endpoints.custom` by `name`: a profile's partial group merges over the group of that name, groups it does not name stay, indexed writes are refused, change batches take entry ops on them, and groups that do not resolve over librechat.yaml and Base (or whose written `${VAR}` is not set) answer `invalid_azure_groups`. Each merged group keeps the key below it unless the profile supplies one, so the rebinding guard drops a key a partial item moves and a key a lower layer dropped stays dropped; Azure re-entry compares groups merged the same way. With `CONFIG_REDERIVE_ENDPOINTS=true` (opt-in for this release), a profile's Azure `groups` resolve into what the Azure clients read (unconditional before, now behind the switch so stored group overrides do not go live on upgrade), a profile's `endpoints.anthropic.vertex` derives `vertexConfig` and the Anthropic models it serves where librechat.yaml configures Vertex AI, and the Azure Assistants endpoint follows a profile's `assistants`. Startup logs the documents that hold such overrides, the groups that would not resolve over librechat.yaml and Base, and the Vertex AI overrides that never apply. Always on: Vertex AI's key file and region are base-only, a profile may turn Vertex AI on only where librechat.yaml configures it, its settings may reference only librechat.yaml's own Vertex AI variables (reads return them resolved), a profile's `vertexConfig` is never applied and writes skip it (`derived_path`, `rules.derivedPaths`), a model's own `deploymentName` resolves its `${VAR}` like the default one, the region, project ID and the deployment name each model's requests send (the model name where the client sends it as written) must stay in their URL host label or path segment, a `${VAR}` there is a whole `${NAME}` reference only, the Anthropic client refuses any other deployment name before building a request, and an invalid setup answers `invalid_vertex_config`. The rebinding guard no longer binds the derived Vertex AI region, which is librechat.yaml's. Custom endpoint `customParams` are expanded by `expandCustomEndpoint` into the served copy, so `appConfig.config` stays librechat.yaml as written, and each endpoint a profile writes is expanded again from its merged form (a changed `defaultParamsEndpoint`, OpenRouter defaults for a profile's own endpoint); invalid definitions answer `invalid_param_definitions`. After tenant scoping, `getAppConfig` derives `modelSelect`, `parameters` and `presets` again for the specs a principal is served where nothing sets them, the agents `memory` capability follows the merged memory config, and a model spec naming an unconfigured endpoint answers `unknown_spec_endpoint`. Meta flags: `azureGroupMergeKey`, `customParamsExpansion`, `specInterfaceRederive` and `endpointRederive`. * 🪜 fix: Judge Re-Derived Config on What Every Profile Layer Leaves Two re-derivations decided whether a profile had set a field from whether any layer ever set it, so a lower profile's value tombstoned by a higher one still counted as set. - Vertex AI (CONFIG_REDERIVE_ENDPOINTS=true): the marker that keeps a profile's `endpoints.anthropic.models` over Vertex AI's is cleared by a later tombstone of those models (or of `endpoints.anthropic` or `endpoints`), and such a tombstone derives Vertex AI again, so the Anthropic endpoint serves Vertex AI's models instead of none. A tombstone of librechat.yaml's own models does the same. - Spec-dependent controls: `modelSelect`, `parameters` and `presets` are derived again where their served value differs from the base config's, not only where the librechat.yaml-shaped value does, so a control one profile sets and a higher one tombstones, or a tombstone of one librechat.yaml leaves unset, follows the specs served instead of being dropped. The memory capability, Azure Assistants, Azure groups and custom parameters already derive from the merged result; tests now cover each set in one layer and tombstoned in a higher one, and the reverse. * 🧮 fix: Derive Each Config Part From What the Layers Leave A tombstone of an object above a derived part's inputs, alone or followed by a layer that recreates part of that object, left the part without what startup derives it to: - An Azure endpoint recreated after a tombstone of `endpoints.azureOpenAI` or `endpoints` had no groupMap, modelGroupMap or model lists, so its model lists fell back to the environment's default Azure models, which requests could not map to a group. It now serves no group, with or without CONFIG_REDERIVE_ENDPOINTS (completeAzureResolution). - A tombstone of the whole `interface` left modelSelect, parameters and presets out, which the client reads as off. They are derived from the specs served into a new interface. - A tombstone of `endpoints.agents` or `endpoints` left no agents endpoint, while a tombstone of its capabilities served the defaults. The agents endpoint startup derives is served there. - A tombstone of a Vertex AI setting that leaves the setup unable to load kept the vertexConfig derived below it. Vertex AI is off there, with a warning. A table in service.spec.ts runs every derived part against every way a layer changes its inputs (set, unset, a tombstone of the path and of each object above it, a layer recreating after such a tombstone, Base on either side, tenant scoping, the switch off) through getAppConfig, and compares each cell with what startup derives from the librechat.yaml the layers leave. * 🧭 fix: Serve Only Model Specs Whose Endpoint the Layers Leave A tombstone of `endpoints.custom` or `endpoints` in a higher profile (or the Base profile) left the model specs of the custom endpoints it removes served, librechat.yaml's or a lower profile's: the spec list pointed at endpoints /api/endpoints no longer offers, with the model selector, parameters and presets still off for them. The write check only ran when the write held `modelSpecs.list`, and the merge served stored specs as they were. After the last layer, the merge now serves only the specs whose endpoint is a built-in endpoint or a custom endpoint the YAML-shaped config the layers leave defines, the rule startup skips librechat.yaml's specs by (deriveServedSpecs). A removed endpoint hides its specs until a layer defines it again; where no spec is left, none is served (as with a tenant-hidden endpoint) and the spec-dependent controls come back on. Tombstones of a built-in endpoint's settings keep its specs, as startup does, and `addedEndpoints` stays as merged. A stored spec for an endpoint that is not configured is left out on its own instead of being served; writes of one still answer 400 unknown_spec_endpoint. The derived-part table in service.spec.ts gains the model specs served (with addedEndpoints and the controls they decide) against tombstones of `endpoints.custom`, `endpoints`, and each of `endpoints.openAI`, `azureOpenAI` and `anthropic`: by a profile, by Base under a profile, a lower profile under a higher one, recreated above or by the same layer, with no spec left, and with the switch off. * 🧩 fix: Reconcile Re-Derived Config With Dev's Keyed Tombstones, Write Report and Read APIs - Keyed tombstones of Azure groups (endpoints.azureOpenAI.groups["g"], now merged by key) resolve the groups left again where the merge resolves them, and stay inert without CONFIG_REDERIVE_ENDPOINTS. - Keyed tombstones count as touching endpoints.custom, so served specs and custom parameters are derived again after one. - The served-spec derivation keeps modelSpecs while added endpoints remain, as the hidden endpoint scoping does; hidden endpoint names leave addedEndpoints. - Derived paths join the held paths every write skips, built on first use. - Tests follow the merged behaviour: Azure groups merge by group, the agents endpoint is derived again below a tombstone.
686 lines
26 KiB
JavaScript
686 lines
26 KiB
JavaScript
require('../config/credentials');
|
|
|
|
const telemetry = require('./telemetry');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
require('module-alias')({ base: path.resolve(__dirname, '..') });
|
|
const cors = require('cors');
|
|
const axios = require('axios');
|
|
const express = require('express');
|
|
const mongoose = require('mongoose');
|
|
const passport = require('passport');
|
|
const compression = require('compression');
|
|
const cookieParser = require('cookie-parser');
|
|
const mongoSanitize = require('express-mongo-sanitize');
|
|
const { logger, runAsSystem } = require('@librechat/data-schemas');
|
|
const {
|
|
isEnabled,
|
|
issueCsp,
|
|
apiNotFound,
|
|
createMetrics,
|
|
applyCspNonce,
|
|
createCspPolicy,
|
|
shellCacheHeaders,
|
|
escapeHtmlAttribute,
|
|
ErrorController,
|
|
memoryDiagnostics,
|
|
createSecurityHeaders,
|
|
performStartupChecks,
|
|
handleJsonParseError,
|
|
excludeRumBodyParser,
|
|
GenerationJobManager,
|
|
QUERY_DEVTOOLS_HEADER,
|
|
createStreamServices,
|
|
agentStartupIngressMiddleware,
|
|
agentStartupTelemetryMiddleware,
|
|
initializeFileStorage,
|
|
initializeDeploymentSkills,
|
|
initializeDeploymentPlugins,
|
|
getDeploymentPluginSkills,
|
|
getDeploymentPluginHookCapabilities,
|
|
registerDeploymentPluginHooks,
|
|
hasDeploymentPluginHooks,
|
|
hasDeploymentPluginToolApprovalHooks,
|
|
setPluginHookSource,
|
|
loadToolApprovalHooks,
|
|
maybeInjectQueryDevtoolsBootstrap,
|
|
injectConfiguredFooterBootstrap,
|
|
injectColorModeHandoff,
|
|
readBundledThemeBoot,
|
|
createDeploymentThemeShell,
|
|
preAuthTenantMiddleware,
|
|
requestContextMiddleware,
|
|
registerShutdownTask,
|
|
getRemainingShutdownMs,
|
|
registerBackgroundTaskShutdown,
|
|
configureServerTimeouts,
|
|
createReadinessHandler,
|
|
keyvRedisClient,
|
|
ioredisClient,
|
|
cacheConfig,
|
|
setupGracefulShutdown,
|
|
updateInterfacePermissions,
|
|
configureMessageFilterRegexValidator,
|
|
configureFileConfigRegexEngine,
|
|
configureAgentEventRuntime,
|
|
createAgentEventTerminalHandler,
|
|
createScheduleWriteGate,
|
|
startCodeEnvironmentLifecycleReconciler,
|
|
waitForKeyvRedisClient,
|
|
warnOnUnreachableDeliveryPaths,
|
|
createCodeApiUploadRegistry,
|
|
createRequestSanitizer,
|
|
reportEndpointOverrides,
|
|
} = require('@librechat/api');
|
|
const { connectDb, indexSync } = require('~/db');
|
|
const {
|
|
updateAccessPermissions,
|
|
sweepOrphanedPreviews,
|
|
getRoleByName,
|
|
seedDatabase,
|
|
listAllConfigs,
|
|
recordAuditEntry,
|
|
} = require('~/models');
|
|
const initializeOAuthReconnectManager = require('./services/initializeOAuthReconnectManager');
|
|
const { capabilityContextMiddleware } = require('./middleware/roles/capabilities');
|
|
const createValidateImageRequest = require('./middleware/validateImageRequest');
|
|
const { initializeGitHubSkillSync } = require('./services/Skills/sync');
|
|
const { startSkillDeletionSweep } = require('./services/Skills/deletion');
|
|
const { initializeAgentTriggerService } = require('./services/Agents/triggers');
|
|
const { resumeAgentEventDetachedAction } = require('./services/Agents/detachedActionResume');
|
|
const { initializeScheduleEngine, recordExpiredScheduleApproval } = require('./services/Schedules');
|
|
const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies');
|
|
const { startExpiredFileSweep } = require('./services/Files/process');
|
|
const { checkMigrations } = require('./services/start/migration');
|
|
const optionalJwtAuth = require('./middleware/optionalJwtAuth');
|
|
const initializeMCPs = require('./services/initializeMCPs');
|
|
const { configureSubagentTaskRouting } = require('./services/Endpoints/agents/subagentThreadStore');
|
|
const configureSocialLogins = require('./socialLogins');
|
|
const createSpaFallback = require('./utils/fallback');
|
|
const { getAppConfig, rederiveEndpoints } = require('./services/Config');
|
|
const staticCache = require('./utils/staticCache');
|
|
const noIndex = require('./middleware/noIndex');
|
|
const agentEventMethods = require('~/models');
|
|
|
|
/** Route admin file-config MIME patterns through a linear-time engine (ReDoS-safe) on upload. */
|
|
configureFileConfigRegexEngine();
|
|
|
|
/** Reject messageFilter PII patterns the RE2 runtime engine cannot compile, at config load. */
|
|
configureMessageFilterRegexValidator();
|
|
|
|
const { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION, TRUST_PROXY } = process.env ?? {};
|
|
|
|
// Allow PORT=0 to be used for automatic free port assignment
|
|
const port = isNaN(Number(PORT)) ? 3080 : Number(PORT);
|
|
const host = HOST || 'localhost';
|
|
const trusted_proxy = Number(TRUST_PROXY) || 1; /* trust first proxy by default */
|
|
|
|
const app = express();
|
|
app.locals.codeApiUploadRegistry = createCodeApiUploadRegistry();
|
|
let serverReady = false;
|
|
/** @type {import('@librechat/api').ScheduleEngineState} */
|
|
let scheduleEngineState = 'starting';
|
|
|
|
const SERVER_NOT_READY_CODE = 'SERVER_NOT_READY';
|
|
const CHAT_START_RETRY_AFTER_SECONDS = '1';
|
|
|
|
const rejectChatStartsUntilReady = (req, res, next) => {
|
|
if (serverReady || req.method !== 'POST' || req.path === '/abort') {
|
|
return next();
|
|
}
|
|
|
|
res.set('Retry-After', CHAT_START_RETRY_AFTER_SECONDS);
|
|
return res.status(503).json({
|
|
code: SERVER_NOT_READY_CODE,
|
|
error: 'Server is still starting. Please retry shortly.',
|
|
});
|
|
};
|
|
|
|
const rejectScheduleWritesUntilReady = createScheduleWriteGate({
|
|
getState: () => scheduleEngineState,
|
|
retryAfterSeconds: CHAT_START_RETRY_AFTER_SECONDS,
|
|
});
|
|
|
|
const configureGenerationStreams = () => {
|
|
const streamServices = createStreamServices();
|
|
GenerationJobManager.configure({
|
|
...streamServices,
|
|
cleanupOnComplete: !isEnabled(process.env.STREAM_KEEP_COMPLETED_JOBS),
|
|
});
|
|
GenerationJobManager.setApprovalExpiredHandler(recordExpiredScheduleApproval);
|
|
GenerationJobManager.setAdmissionGuard(agentEventMethods.isAgentTriggerPrincipalActive);
|
|
GenerationJobManager.setTerminalHostActionHandler(
|
|
createAgentEventTerminalHandler(agentEventMethods, {
|
|
resumeDetachedAction: resumeAgentEventDetachedAction,
|
|
}),
|
|
);
|
|
GenerationJobManager.initialize();
|
|
// Stop active generations and close their SSE streams while the HTTP server drains.
|
|
registerShutdownTask(
|
|
'generation job manager prepare',
|
|
() => GenerationJobManager.prepareForShutdown(),
|
|
{
|
|
phase: 'pre-drain',
|
|
priority: 100,
|
|
},
|
|
);
|
|
/** Spend the shutdown budget that is actually left waiting for detached generations to
|
|
* record their own provider drains, holding back a reserve for the tasks after this one.
|
|
* Abandoning an unrecorded drain fences the next generation permanently. */
|
|
const destroyGenerationJobManager = () => {
|
|
const remaining = getRemainingShutdownMs();
|
|
return GenerationJobManager.destroy(
|
|
remaining == null
|
|
? undefined
|
|
: { settlementBudgetMs: Math.max(0, remaining - SHUTDOWN_TEARDOWN_RESERVE_MS) },
|
|
);
|
|
};
|
|
// Tear down stream resources before shared caches and telemetry exporters shut down.
|
|
registerShutdownTask('generation job manager', destroyGenerationJobManager, { priority: 100 });
|
|
};
|
|
|
|
/** Reserved for the shutdown tasks that run after the generation job manager. */
|
|
const SHUTDOWN_TEARDOWN_RESERVE_MS = 10_000;
|
|
|
|
const startServer = async () => {
|
|
await waitForKeyvRedisClient();
|
|
const { metricsMiddleware, metricsRouter } = createMetrics({
|
|
collectAgentEventActorStorageMetrics: () =>
|
|
runAsSystem(async () => {
|
|
const now = new Date();
|
|
const [receiptMetrics, reconciliationMetrics] = await Promise.all([
|
|
agentEventMethods.getAgentEventActorReceiptStorageMetrics(now),
|
|
agentEventMethods.getAgentEventActorReconciliationStorageMetrics(now),
|
|
]);
|
|
return {
|
|
...receiptMetrics,
|
|
pendingReconciliations: reconciliationMetrics.pending,
|
|
oldestPendingAgeSeconds: reconciliationMetrics.oldestPendingAgeSeconds,
|
|
};
|
|
}),
|
|
});
|
|
if (!process.env.METRICS_SECRET) {
|
|
logger.warn('[metrics] METRICS_SECRET is not set - /metrics will return 401 for all requests');
|
|
}
|
|
|
|
if (typeof Bun !== 'undefined') {
|
|
axios.defaults.headers.common['Accept-Encoding'] = 'gzip';
|
|
}
|
|
await connectDb();
|
|
|
|
logger.info('Connected to MongoDB');
|
|
startCodeEnvironmentLifecycleReconciler({ mongoose });
|
|
indexSync().catch((err) => {
|
|
logger.error('[indexSync] Background sync failed:', err);
|
|
});
|
|
|
|
app.disable('x-powered-by');
|
|
app.set('trust proxy', trusted_proxy);
|
|
|
|
/* Registered ahead of every route so health checks carry the headers too. */
|
|
const securityHeaders = createSecurityHeaders();
|
|
if (securityHeaders) {
|
|
app.use(securityHeaders);
|
|
}
|
|
|
|
if (isEnabled(process.env.TRUST_TENANT_HEADER)) {
|
|
logger.warn(
|
|
'[Security] TRUST_TENANT_HEADER is active. Ensure your reverse proxy strips and sets ' +
|
|
'X-Tenant-Id — untrusted clients must not be able to supply it directly.',
|
|
);
|
|
} else if (isEnabled(process.env.TENANT_ISOLATION_STRICT)) {
|
|
logger.warn(
|
|
'[Security] TENANT_ISOLATION_STRICT is active while TRUST_TENANT_HEADER is disabled. ' +
|
|
'Pre-authentication tenant headers will be ignored.',
|
|
);
|
|
}
|
|
|
|
await runAsSystem(seedDatabase);
|
|
/* Recover stuck `status: 'pending'` records from a crash mid-render.
|
|
* `runAsSystem` is required — `File` is tenant-isolated and strict
|
|
* mode rejects unscoped queries. Lazy sweep in the preview endpoint
|
|
* covers anything younger than the boot cutoff. */
|
|
runAsSystem(sweepOrphanedPreviews).catch((err) => {
|
|
logger.error('[sweepOrphanedPreviews] Background sweep failed:', err);
|
|
});
|
|
const appConfig = await getAppConfig({ baseOnly: true });
|
|
await configureSubagentTaskRouting(appConfig?.endpoints?.agents?.subagentActivity);
|
|
registerBackgroundTaskShutdown({
|
|
interruptGraceMs: appConfig?.endpoints?.agents?.backgroundTasks?.shutdownInterruptGraceMs,
|
|
});
|
|
configureAgentEventRuntime(appConfig?.endpoints?.agents?.eventDriven);
|
|
warnOnUnreachableDeliveryPaths(appConfig);
|
|
initializeFileStorage(appConfig);
|
|
const projectRoot = path.resolve(__dirname, '../..');
|
|
// Plugin hooks execute only when the operator opts in via DEPLOYMENT_PLUGIN_HOOKS;
|
|
// without it, declared hook documents load as parsed-but-inert with a warning.
|
|
await initializeDeploymentPlugins({
|
|
projectRoot,
|
|
hookCapabilities: getDeploymentPluginHookCapabilities(),
|
|
});
|
|
// Hand the run seam its plugin-hook source without a packages/api-internal
|
|
// agents -> plugins import (see agents/hooks/source.ts).
|
|
setPluginHookSource({
|
|
hasHooks: hasDeploymentPluginHooks,
|
|
hasToolApprovalHooks: hasDeploymentPluginToolApprovalHooks,
|
|
register: registerDeploymentPluginHooks,
|
|
});
|
|
await initializeDeploymentSkills({
|
|
projectRoot,
|
|
additionalSkills: getDeploymentPluginSkills(),
|
|
});
|
|
initializeGitHubSkillSync(appConfig);
|
|
startSkillDeletionSweep(appConfig.skillDeletion);
|
|
startExpiredFileSweep({ appConfig, loadAppConfig: getAppConfig });
|
|
// Register any programmatic tool-approval policy hooks declared in
|
|
// `endpoints.agents.toolApproval.hooks`. Honor the `enabled` kill switch: when tool
|
|
// approval is off we pass no hooks, so a disabled endpoint imports/runs nothing (and any
|
|
// previously loaded batch is unregistered). Hooks are read from the BASE config only —
|
|
// they register once, process-wide; per-user/tenant differences belong inside the hook
|
|
// (via its context), not in per-override module lists.
|
|
const toolApproval = appConfig?.endpoints?.agents?.toolApproval;
|
|
await loadToolApprovalHooks(toolApproval?.enabled ? toolApproval.hooks : undefined, {
|
|
basePath: path.resolve(__dirname, '../..'),
|
|
});
|
|
await runAsSystem(async () => {
|
|
await performStartupChecks(appConfig);
|
|
await updateInterfacePermissions({
|
|
appConfig,
|
|
getRoleByName,
|
|
updateAccessPermissions,
|
|
recordAuditEntry,
|
|
});
|
|
});
|
|
|
|
/* Route modules build their rate limiters as they load, so they load only after the
|
|
* startup checks have applied `rateLimits` from librechat.yaml. */
|
|
const routes = require('./routes');
|
|
|
|
const indexPath = path.join(appConfig.paths.dist, 'index.html');
|
|
let indexHTML = fs.readFileSync(indexPath, 'utf8');
|
|
|
|
// In order to provide support to serving the application in a sub-directory
|
|
// We need to update the base href if the DOMAIN_CLIENT is specified and not the root path
|
|
if (process.env.DOMAIN_CLIENT) {
|
|
const clientUrl = new URL(process.env.DOMAIN_CLIENT);
|
|
const baseHref = clientUrl.pathname.endsWith('/')
|
|
? clientUrl.pathname
|
|
: `${clientUrl.pathname}/`;
|
|
if (baseHref !== '/') {
|
|
logger.info(`Setting base href to ${baseHref}`);
|
|
indexHTML = indexHTML.replace(/base href="\/"/, `base href="${baseHref}"`);
|
|
}
|
|
}
|
|
|
|
/* The composer lays out against whether a footer bar sits beneath it, and
|
|
`/api/config` answers that only after it has painted. One shell serves every
|
|
request, before there is a caller whose overrides could be resolved, so the
|
|
answer is the deployment's base configuration; `/api/config` resolves the
|
|
caller's and the client prefers it. */
|
|
indexHTML = injectConfiguredFooterBootstrap(indexHTML, {
|
|
customFooter: process.env.CUSTOM_FOOTER,
|
|
landingFooter: appConfig?.interfaceConfig?.landingFooter,
|
|
});
|
|
|
|
indexHTML = injectColorModeHandoff(indexHTML, appConfig.interfaceConfig?.colorModeFromUrl);
|
|
const indexShell = createDeploymentThemeShell(
|
|
indexHTML,
|
|
appConfig.interfaceConfig?.theme,
|
|
readBundledThemeBoot(appConfig.paths.dist),
|
|
);
|
|
|
|
const cspPolicy = createCspPolicy();
|
|
const shellCache = shellCacheHeaders(cspPolicy != null);
|
|
|
|
const sendIndexHtml = (req, res) => {
|
|
res.set(shellCache);
|
|
res.vary(QUERY_DEVTOOLS_HEADER);
|
|
|
|
const lang = req.cookies.lang || req.headers['accept-language']?.split(',')[0] || 'en-US';
|
|
const saneLang = escapeHtmlAttribute(lang);
|
|
let updatedIndexHtml = indexShell(req, res).replace(
|
|
/lang="en-US"/g,
|
|
() => `lang="${saneLang}"`,
|
|
);
|
|
updatedIndexHtml = maybeInjectQueryDevtoolsBootstrap(updatedIndexHtml, req);
|
|
|
|
/* Nonce last: every injected script above must be stamped too. */
|
|
if (cspPolicy) {
|
|
const csp = issueCsp(cspPolicy);
|
|
res.set(csp.headerName, csp.headerValue);
|
|
updatedIndexHtml = applyCspNonce(updatedIndexHtml, csp.nonce);
|
|
}
|
|
|
|
res.type('html');
|
|
res.send(updatedIndexHtml);
|
|
};
|
|
|
|
app.get('/health', (_req, res) => res.status(200).send('OK'));
|
|
app.get('/livez', (_req, res) => res.status(200).send('OK'));
|
|
app.get(
|
|
'/readyz',
|
|
createReadinessHandler(() => serverReady, {
|
|
useRedis: cacheConfig.USE_REDIS,
|
|
ioredis: ioredisClient,
|
|
keyv: keyvRedisClient,
|
|
}),
|
|
);
|
|
|
|
/* Middleware */
|
|
app.use(requestContextMiddleware);
|
|
app.use('/api/agents/chat', agentStartupIngressMiddleware);
|
|
app.use(metricsMiddleware);
|
|
app.use(noIndex);
|
|
app.use(excludeRumBodyParser(express.json({ limit: '3mb' })));
|
|
app.use(excludeRumBodyParser(express.urlencoded({ extended: true, limit: '3mb' })));
|
|
app.use(handleJsonParseError);
|
|
|
|
/**
|
|
* Express 5 Compatibility: Make req.query writable for mongoSanitize
|
|
* In Express 5, req.query is read-only by default, but express-mongo-sanitize needs to modify it
|
|
*/
|
|
app.use((req, _res, next) => {
|
|
Object.defineProperty(req, 'query', {
|
|
...Object.getOwnPropertyDescriptor(req, 'query'),
|
|
value: req.query,
|
|
writable: true,
|
|
});
|
|
next();
|
|
});
|
|
|
|
app.use(createRequestSanitizer(mongoSanitize));
|
|
app.use(cors());
|
|
app.use(cookieParser());
|
|
|
|
if (!isEnabled(DISABLE_COMPRESSION)) {
|
|
app.use(compression());
|
|
} else {
|
|
console.warn('Response compression has been disabled via DISABLE_COMPRESSION.');
|
|
}
|
|
|
|
app.get('/index.html', sendIndexHtml);
|
|
app.use(staticCache(appConfig.paths.dist));
|
|
app.use(staticCache(appConfig.paths.fonts));
|
|
app.use(staticCache(appConfig.paths.assets));
|
|
|
|
if (telemetry.enabled) {
|
|
app.use(telemetry.telemetryMiddleware);
|
|
}
|
|
app.use('/api/agents/chat', agentStartupTelemetryMiddleware);
|
|
|
|
if (!ALLOW_SOCIAL_LOGIN) {
|
|
console.warn('Social logins are disabled. Set ALLOW_SOCIAL_LOGIN=true to enable them.');
|
|
}
|
|
|
|
/* OAUTH */
|
|
app.use(passport.initialize());
|
|
passport.use(jwtLogin());
|
|
passport.use(passportLogin());
|
|
|
|
/* LDAP Auth */
|
|
if (process.env.LDAP_URL && process.env.LDAP_USER_SEARCH_BASE) {
|
|
passport.use(ldapLogin);
|
|
}
|
|
|
|
if (isEnabled(ALLOW_SOCIAL_LOGIN)) {
|
|
await configureSocialLogins(app, appConfig);
|
|
}
|
|
|
|
/* Per-request capability cache — must be registered before any route that calls hasCapability */
|
|
app.use(capabilityContextMiddleware);
|
|
|
|
/* Pre-auth tenant context for unauthenticated routes that need tenant scoping.
|
|
* The reverse proxy / auth gateway sets `X-Tenant-Id` header for multi-tenant deployments. */
|
|
app.use('/oauth', preAuthTenantMiddleware, routes.oauth);
|
|
/* API Endpoints */
|
|
app.use('/api/auth', preAuthTenantMiddleware, routes.auth);
|
|
app.use('/api/insights', routes.insights);
|
|
app.use('/api/admin', routes.adminAuth);
|
|
app.use('/api/admin/config', routes.adminConfig);
|
|
app.use('/api/admin/code-environments', routes.adminCodeEnvironments);
|
|
app.use('/api/code-environments', routes.codeEnvironments);
|
|
app.use('/api/admin/langfuse', routes.adminLangfuse);
|
|
app.use('/api/admin/grants', routes.adminGrants);
|
|
app.use('/api/admin/groups', routes.adminGroups);
|
|
app.use('/api/admin/roles', routes.adminRoles);
|
|
app.use('/api/admin/skills', routes.adminSkills);
|
|
app.use('/api/admin/users', routes.adminUsers);
|
|
app.use('/api/admin/audit-log', routes.adminAuditLog);
|
|
app.use('/api/actions', routes.actions);
|
|
app.use('/api/keys', routes.keys);
|
|
app.use('/api/api-keys', routes.apiKeys);
|
|
app.use('/api/user', routes.user);
|
|
app.use('/api/search', routes.search);
|
|
app.use('/api/messages', routes.messages);
|
|
app.use('/api/convos', routes.convos);
|
|
app.use('/api/pull-requests', preAuthTenantMiddleware, routes.pullRequests);
|
|
app.use('/api/traces', routes.traces);
|
|
app.use('/api/presets', routes.presets);
|
|
app.use('/api/projects', routes.projects);
|
|
app.use('/api/prompts', routes.prompts);
|
|
app.use('/api/skills', routes.skills);
|
|
app.use('/api/categories', routes.categories);
|
|
app.use('/api/endpoints', routes.endpoints);
|
|
app.use('/api/balance', routes.balance);
|
|
app.use('/api/models', routes.models);
|
|
app.use('/api/config', preAuthTenantMiddleware, optionalJwtAuth, routes.config);
|
|
app.use('/api/assistants', routes.assistants);
|
|
app.use('/api/files', await routes.files.initialize());
|
|
app.use(
|
|
'/images/',
|
|
createValidateImageRequest({
|
|
secureImageLinks: appConfig.secureImageLinks,
|
|
}),
|
|
routes.staticRoute,
|
|
);
|
|
app.use('/api/share', preAuthTenantMiddleware, routes.share);
|
|
app.use('/api/roles', routes.roles);
|
|
app.use('/api/agents/chat', rejectChatStartsUntilReady);
|
|
app.use('/api/agents', routes.agents);
|
|
app.use('/api/banner', routes.banner);
|
|
app.use('/api/memories', routes.memories);
|
|
app.use('/api/schedules', rejectScheduleWritesUntilReady, routes.schedules);
|
|
app.use('/api/permissions', routes.accessPermissions);
|
|
|
|
app.use('/api/tags', routes.tags);
|
|
app.use('/api/mcp', routes.mcp);
|
|
app.use('/api/rum', routes.rum);
|
|
|
|
app.use('/metrics', metricsRouter);
|
|
|
|
app.use('/api', routes.openapi);
|
|
|
|
/** 404 for unmatched API routes */
|
|
app.use('/api', apiNotFound);
|
|
|
|
/** SPA fallback - serve index.html for all unmatched routes */
|
|
app.use(createSpaFallback(sendIndexHtml));
|
|
|
|
/** Record trace errors before the final error controller. */
|
|
if (telemetry.enabled) {
|
|
app.use(telemetry.telemetryErrorMiddleware);
|
|
}
|
|
/** Error handler (must be last - Express identifies error middleware by its 4-arg signature) */
|
|
app.use(ErrorController);
|
|
|
|
configureGenerationStreams();
|
|
|
|
const server = app.listen(port, host, async (err) => {
|
|
if (err) {
|
|
logger.error('Failed to start server:', err);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (host === '0.0.0.0') {
|
|
logger.info(
|
|
`Server listening on all interfaces at port ${port}. Use http://localhost:${port} to access it`,
|
|
);
|
|
} else {
|
|
logger.info(`Server listening at http://${host == '0.0.0.0' ? 'localhost' : host}:${port}`);
|
|
}
|
|
|
|
/**
|
|
* The listen callback is async, so any rejection from these awaits would
|
|
* otherwise be detached from `startServer().catch(...)` (which only
|
|
* catches errors that happen before `app.listen`). Without explicit
|
|
* handling, the global `unhandledRejection` handler would swallow init
|
|
* failures and leave the server listening but only partially
|
|
* initialized — passing liveness checks while serving broken requests.
|
|
*/
|
|
try {
|
|
await runAsSystem(async () => {
|
|
await initializeMCPs();
|
|
await initializeOAuthReconnectManager();
|
|
});
|
|
/** Logs only, so readiness never waits on reading every config document. */
|
|
reportEndpointOverrides({
|
|
listConfigs: () => runAsSystem(() => listAllConfigs()),
|
|
baseYaml: appConfig?.config ?? {},
|
|
rederiveEndpoints,
|
|
}).catch((err) => {
|
|
logger.error('[config] Failed to report endpoint overrides:', err);
|
|
});
|
|
await checkMigrations();
|
|
|
|
const inspectFlags = process.execArgv.some((arg) => arg.startsWith('--inspect'));
|
|
if (inspectFlags || isEnabled(process.env.MEM_DIAG)) {
|
|
memoryDiagnostics.start();
|
|
}
|
|
await initializeAgentTriggerService({
|
|
address: server.address(),
|
|
completionResultBatchSize:
|
|
appConfig?.endpoints?.agents?.backgroundTasks?.completionResultBatchSize,
|
|
completionReceiptBatching:
|
|
appConfig?.endpoints?.agents?.backgroundTasks?.completionReceiptBatching,
|
|
deadLetterRetentionDays: appConfig?.endpoints?.agents?.eventDriven?.deadLetterRetentionDays,
|
|
idlePolling: appConfig?.endpoints?.agents?.eventDriven?.idlePolling,
|
|
});
|
|
const scheduleEngineArmed = (await initializeScheduleEngine()) != null;
|
|
scheduleEngineState = scheduleEngineArmed ? 'armed' : 'unavailable';
|
|
if (!scheduleEngineArmed) {
|
|
// Terminal, not transient: arming is attempted once, so schedule writes are refused
|
|
// for the life of this process. Logged at error level because the only other signal
|
|
// an operator gets is a 503 on every write — every other health signal stays green.
|
|
logger.error(
|
|
'[schedules] write routes are PERMANENTLY unavailable in this process: the engine did not arm. ' +
|
|
'Resolve the cause logged above and restart.',
|
|
);
|
|
}
|
|
serverReady = true;
|
|
logger.info('Server readiness checks passing.');
|
|
} catch (initErr) {
|
|
serverReady = false;
|
|
logger.error('Post-listen initialization failed:', initErr);
|
|
process.exit(1);
|
|
}
|
|
});
|
|
|
|
configureServerTimeouts(server);
|
|
logger.info('HTTP server timeout configuration', {
|
|
keepAliveTimeout: server.keepAliveTimeout,
|
|
keepAliveTimeoutBuffer: server.keepAliveTimeoutBuffer,
|
|
headersTimeout: server.headersTimeout,
|
|
requestTimeout: server.requestTimeout,
|
|
});
|
|
|
|
setupGracefulShutdown(server);
|
|
};
|
|
|
|
/**
|
|
* Boot rejections (e.g. `connectDb`, `getAppConfig`, `performStartupChecks`)
|
|
* must remain fail-fast: a half-initialized process with no listening HTTP
|
|
* server should die immediately so the orchestrator restarts it, instead of
|
|
* being kept alive by the `unhandledRejection` handler below until the
|
|
* liveness probe eventually times out. Mirrors the pattern in
|
|
* `experimental.js`.
|
|
*/
|
|
startServer().catch((err) => {
|
|
logger.error('Failed to start server:', err);
|
|
process.exit(1);
|
|
});
|
|
|
|
let messageCount = 0;
|
|
process.on('uncaughtException', (err) => {
|
|
if (!err.message.includes('fetch failed')) {
|
|
logger.error('There was an uncaught error:', err);
|
|
}
|
|
|
|
if (err.message && err.message?.toLowerCase()?.includes('abort')) {
|
|
logger.warn('There was an uncatchable abort error.');
|
|
return;
|
|
}
|
|
|
|
if (err.message.includes('GoogleGenerativeAI')) {
|
|
logger.warn(
|
|
'\n\n`GoogleGenerativeAI` errors cannot be caught due to an upstream issue, see: https://github.com/google-gemini/generative-ai-js/issues/303',
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (err.message.includes('fetch failed')) {
|
|
if (messageCount === 0) {
|
|
logger.warn('Meilisearch error, search will be disabled');
|
|
messageCount++;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (err.message.includes('OpenAIError') || err.message.includes('ChatCompletionMessage')) {
|
|
logger.error(
|
|
'\n\nAn Uncaught `OpenAIError` error may be due to your reverse-proxy setup or stream configuration, or a bug in the `openai` node package.',
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (err.stack && err.stack.includes('@librechat/agents')) {
|
|
logger.error(
|
|
'\n\nAn error occurred in the agents system. The error has been logged and the app will continue running.',
|
|
{
|
|
message: err.message,
|
|
stack: err.stack,
|
|
},
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (isEnabled(process.env.CONTINUE_ON_UNCAUGHT_EXCEPTION)) {
|
|
logger.error('Unhandled error encountered. The app will continue running.', {
|
|
name: err?.name,
|
|
message: err?.message,
|
|
stack: err?.stack,
|
|
});
|
|
return;
|
|
}
|
|
|
|
process.exit(1);
|
|
});
|
|
|
|
/**
|
|
* Unhandled promise rejection handler.
|
|
*
|
|
* Node 15+ terminates the process by default when a promise rejection is
|
|
* unhandled. MCP OAuth reconnect storms and streamable-HTTP transport resets
|
|
* can produce transient fire-and-forget rejections (ECONNRESET, token refresh
|
|
* races) that are recoverable — the server should log and keep serving other
|
|
* requests rather than silently crash under load.
|
|
*
|
|
* Non-Error reasons are forwarded as-is so structured payloads (e.g.
|
|
* `{ code: "ECONNRESET", errno: -104 }`) survive instead of being collapsed to
|
|
* "[object Object]" by `String()`.
|
|
*/
|
|
process.on('unhandledRejection', (reason) => {
|
|
if (reason instanceof Error) {
|
|
logger.error('Unhandled promise rejection. The app will continue running.', {
|
|
name: reason.name,
|
|
message: reason.message,
|
|
stack: reason.stack,
|
|
cause: reason.cause,
|
|
});
|
|
return;
|
|
}
|
|
logger.error('Unhandled promise rejection. The app will continue running.', { reason });
|
|
});
|
|
|
|
/** Export app for easier testing purposes */
|
|
module.exports = app;
|