mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-02 04:12:36 +00:00
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
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run
* ⚡ feat: Immediate Conversation Title Generation Generate conversation titles as soon as the request is made (in parallel with the response, from the user's first message) as the new default, fixing the #13318 race where a transient /gen_title 404 left new chats stuck on "New Chat". - Add per-endpoint `titleTiming` ('immediate' | 'final') to baseEndpointSchema; `endpoints.all` acts as the global default, unset = immediate. Resolve via a new `resolveTitleTiming` helper (`all` takes precedence). - Fire title generation in parallel with `sendMessage`; `titleConvo` waits (bounded, abortable) for the agent run and titles from the user input only. Persist after the conversation row exists; defer `disposeClient` until the title settles. - Expose `titleGenerationTiming` via startup config; `useTitleGeneration` fetches eagerly in immediate mode with a bounded 404 retry and never treats a transient 404 as final. Skip title queueing for temporary conversations. - Supersedes #13329 while incorporating its bounded 404-retry. * 🩹 fix: Address Copilot review findings on title timing - Guard against an undefined conversationId in addTitle (skip + warn) so the gen_title cache key can't collide as `userId-undefined` and saveConvo is never called without a conversationId. - Gate the title `useQueries` on `enabled` so no /gen_title request fires while unauthenticated (e.g. after logout) even if the module queue holds IDs. - Drop the stale `conversationId` param from the titleConvo JSDoc. - Add a regression test for the undefined-conversationId guard. * 🧵 fix: Harden immediate-title edge cases from codex review - Cancel in-flight immediate title generation when the request aborts: thread job.abortController.signal through addTitle so pressing Stop on a new chat neither consumes the title model nor surfaces a title for a cancelled turn. - Preserve a locally-applied title when the final SSE event's conversation carries no title yet (built before the title was saved), so long immediate-mode responses no longer revert the chat to "New Chat" until reload. - Guarantee one full post-completion gen_title fetch cycle before giving up, so a `final`-mode title (generated only after the stream ends) is still fetched under a global `immediate` default instead of being stranded. - Add regression tests for the abort propagation and the undefined-conversationId guard. * 🔁 fix: Correct title abort, post-completion refetch, and replacement ordering Follow-up to codex review of the immediate-title fixes: - Use a dedicated title AbortController instead of `job.abortController`. The latter is also aborted by `completeJob` on *successful* completion, which cancelled any title slower than a short response. The title is now cancelled only on a real user Stop or when the stream is replaced; a completed-then- aborted title is discarded (no save, cache cleared) rather than persisted. - Reset (not remove) the post-completion title query: `resetQueries` refetches the mounted observer with a fresh retry budget, whereas `removeQueries` left it stuck in its error state, so the promised post-completion cycle never ran. - Run the job-replacement check before resolving `convoReady`, and on a replaced stream cancel/discard the stale title so a discarded prompt can't persist a title. * 🧷 fix: Tighten title abort ordering and endpoint-level timing resolution Follow-up to codex review: - Abort the title controller before resolving `convoReady` on a stopped turn, so the title task can't resume and persist before the later abort. - Cancel the title and unblock its waits on ANY send failure (not just user aborts): a preflight/quota failure before the run exists otherwise hangs `_waitForRun`, deferring client disposal until the 45s title timeout. - Resolve `titleTiming` for custom endpoints via `getCustomEndpointConfig` (their config lives under `endpoints.custom[]`, not `endpoints[endpoint]`). - Derive the startup `titleGenerationTiming` via `resolveTitleTiming` for the agents endpoint so an endpoint-level `final` (without `endpoints.all`) is honored client-side instead of defaulting to immediate and burning eager gen_title polls. * 🪢 fix: Per-agent title timing and safer abort/replacement handling Follow-up to codex review: - Resolve `titleTiming` from the agent's actual endpoint after initialization, so a per-endpoint `final` override on a custom/provider endpoint backing an (ephemeral) agent is honored instead of always using the `agents` endpoint's value. - Don't preserve a locally-fetched title on a stopped (unfinished) turn: the server cancels and discards that title, so keeping it client-side would diverge from server state and leave the stopped chat titled until reload. - On abort/replacement, only delete the cached title if it still holds THIS task's value — a replacement stream shares the `userId-conversationId` key and may have already cached its own valid title that must not be removed. * 🪞 fix: Mirror AgentClient title-config resolution for titleTiming Per maintainer guidance, keep titleTiming resolution identical to how `AgentClient#titleConvo` already resolves the endpoint config — `endpoints.all` is the intended global override and the agent's actual provider endpoint is used: - Resolve via `endpoints.all ?? endpoints[endpoint] ?? getProviderConfig(endpoint) .customEndpointConfig` (was using `getCustomEndpointConfig` directly). Going through `getProviderConfig` picks up its case-insensitive fallback for normalized provider names (e.g. `openrouter` → `OpenRouter`), so a custom endpoint's `titleTiming` is honored like its other title settings. - Add `titleTiming` to the Azure endpoint schema `.pick()` so `endpoints.azureOpenAI.titleTiming` is no longer silently stripped by Zod. Note: per-endpoint title settings being skipped when `endpoints.all` is present is the existing, intended global-override behavior — not changed here. * 🧪 test: Cover useTitleGeneration effect logic (integration) Adds a deterministic white-box integration test that drives the real hook's React effects with a controllable react-query surface, locking down the stateful decisions that previously had no coverage: - immediate mode fetches a queued conversation while its stream is still active - final mode gates until the stream completes, then becomes eligible - success applies the fetched title to the conversation caches - a 404 while active defers (removeQueries) instead of giving up - a 404 after completion forces a fresh fetch via resetQueries (post-completion remount) * feat: Stream immediate title events * style: Format title SSE handler * test: Preserve data-provider exports in OAuth mock * test: Isolate OAuth route API mock * test: Keep OAuth callback factory capture * fix: Replay streamed title events on resume * fix: Honor agents title timing precedence * style: Format title timing fixes
316 lines
11 KiB
JavaScript
316 lines
11 KiB
JavaScript
const express = require('express');
|
|
const {
|
|
isEnabled,
|
|
getBalanceConfig,
|
|
getCloudFrontConfig,
|
|
resolveBuildInfo,
|
|
resolveTitleTiming,
|
|
sanitizeModelSpecs,
|
|
} = require('@librechat/api');
|
|
const { EModelEndpoint, defaultSocialLogins } = require('librechat-data-provider');
|
|
const { logger, getTenantId, SystemCapabilities } = require('@librechat/data-schemas');
|
|
const { hasCapability } = require('~/server/middleware/roles/capabilities');
|
|
const { getLdapConfig } = require('~/server/services/Config/ldap');
|
|
const { getRumConfig } = require('~/server/services/Config/rum');
|
|
const { getAppConfig } = require('~/server/services/Config/app');
|
|
|
|
const router = express.Router();
|
|
const emailLoginEnabled =
|
|
process.env.ALLOW_EMAIL_LOGIN === undefined || isEnabled(process.env.ALLOW_EMAIL_LOGIN);
|
|
const passwordResetEnabled = isEnabled(process.env.ALLOW_PASSWORD_RESET);
|
|
|
|
const sharedLinksEnabled =
|
|
process.env.ALLOW_SHARED_LINKS === undefined || isEnabled(process.env.ALLOW_SHARED_LINKS);
|
|
|
|
const publicSharedLinksEnabled =
|
|
sharedLinksEnabled && isEnabled(process.env.ALLOW_SHARED_LINKS_PUBLIC);
|
|
|
|
const sharePointFilePickerEnabled = isEnabled(process.env.ENABLE_SHAREPOINT_FILEPICKER);
|
|
const openidReuseTokens = isEnabled(process.env.OPENID_REUSE_TOKENS);
|
|
|
|
/**
|
|
* Resolve build metadata eagerly at module load so the first `/api/config`
|
|
* request does not pay the cost of `execFileSync('git', ...)` on the hot path.
|
|
* The resolver caches its result after the first call.
|
|
*/
|
|
resolveBuildInfo();
|
|
|
|
function isBirthday() {
|
|
const today = new Date();
|
|
return today.getMonth() === 1 && today.getDate() === 11;
|
|
}
|
|
|
|
/**
|
|
* Pre-login fields rendered by the unauthenticated login, registration, password-reset,
|
|
* and email-verification pages. Any field added here is readable by anonymous callers
|
|
* of `GET /api/config`, so keep this set strictly to what those pages need.
|
|
*
|
|
* See client consumers under `client/src/components/Auth/` and `client/src/routes/Layouts/Startup.tsx`.
|
|
*/
|
|
function buildPreLoginPayload() {
|
|
const isOpenIdEnabled =
|
|
!!process.env.OPENID_CLIENT_ID &&
|
|
(isEnabled(process.env.OPENID_USE_PKCE) || !!process.env.OPENID_CLIENT_SECRET?.trim()) &&
|
|
!!process.env.OPENID_ISSUER &&
|
|
!!process.env.OPENID_SESSION_SECRET;
|
|
|
|
const isSamlEnabled =
|
|
!!process.env.SAML_ENTRY_POINT &&
|
|
!!process.env.SAML_ISSUER &&
|
|
!!process.env.SAML_CERT &&
|
|
!!process.env.SAML_SESSION_SECRET;
|
|
|
|
const ldap = getLdapConfig();
|
|
|
|
/** @type {Partial<TStartupConfig>} */
|
|
const payload = {
|
|
appTitle: process.env.APP_TITLE || 'LibreChat',
|
|
discordLoginEnabled: !!process.env.DISCORD_CLIENT_ID && !!process.env.DISCORD_CLIENT_SECRET,
|
|
facebookLoginEnabled: !!process.env.FACEBOOK_CLIENT_ID && !!process.env.FACEBOOK_CLIENT_SECRET,
|
|
githubLoginEnabled: !!process.env.GITHUB_CLIENT_ID && !!process.env.GITHUB_CLIENT_SECRET,
|
|
googleLoginEnabled: !!process.env.GOOGLE_CLIENT_ID && !!process.env.GOOGLE_CLIENT_SECRET,
|
|
appleLoginEnabled:
|
|
!!process.env.APPLE_CLIENT_ID &&
|
|
!!process.env.APPLE_TEAM_ID &&
|
|
!!process.env.APPLE_KEY_ID &&
|
|
!!process.env.APPLE_PRIVATE_KEY_PATH,
|
|
openidLoginEnabled: isOpenIdEnabled,
|
|
openidLabel: process.env.OPENID_BUTTON_LABEL || 'Continue with OpenID',
|
|
openidImageUrl: process.env.OPENID_IMAGE_URL,
|
|
openidAutoRedirect: isEnabled(process.env.OPENID_AUTO_REDIRECT),
|
|
samlLoginEnabled: !isOpenIdEnabled && isSamlEnabled,
|
|
samlLabel: process.env.SAML_BUTTON_LABEL,
|
|
samlImageUrl: process.env.SAML_IMAGE_URL,
|
|
serverDomain: process.env.DOMAIN_SERVER || 'http://localhost:3080',
|
|
emailLoginEnabled,
|
|
registrationEnabled: !ldap?.enabled && isEnabled(process.env.ALLOW_REGISTRATION),
|
|
socialLoginEnabled: isEnabled(process.env.ALLOW_SOCIAL_LOGIN),
|
|
emailEnabled:
|
|
(!!process.env.EMAIL_SERVICE || !!process.env.EMAIL_HOST) &&
|
|
!!process.env.EMAIL_USERNAME &&
|
|
!!process.env.EMAIL_PASSWORD &&
|
|
!!process.env.EMAIL_FROM,
|
|
passwordResetEnabled,
|
|
};
|
|
|
|
const minPasswordLength = parseInt(process.env.MIN_PASSWORD_LENGTH, 10);
|
|
if (minPasswordLength && !isNaN(minPasswordLength)) {
|
|
payload.minPasswordLength = minPasswordLength;
|
|
}
|
|
|
|
if (ldap) {
|
|
payload.ldap = ldap;
|
|
}
|
|
|
|
return payload;
|
|
}
|
|
|
|
/**
|
|
* Public share fields rendered by `client/src/components/Share/ShareView.tsx`.
|
|
* They remain off the default anonymous config used by login screens, and are
|
|
* exposed to anonymous callers only when the client asks for share context.
|
|
*/
|
|
function buildPublicSharePayload() {
|
|
/** @type {Partial<TStartupConfig>} */
|
|
const payload = {
|
|
analyticsGtmId: process.env.ANALYTICS_GTM_ID,
|
|
};
|
|
|
|
if (typeof process.env.CUSTOM_FOOTER === 'string') {
|
|
payload.customFooter = process.env.CUSTOM_FOOTER;
|
|
}
|
|
|
|
return payload;
|
|
}
|
|
|
|
/**
|
|
* Post-login fields appended only when `req.user` is present. These describe the
|
|
* authenticated UX (account-settings links, share-link feature flags, birthday icon,
|
|
* openid token-reuse marker) and are not needed on the pre-login screens, so they
|
|
* are not exposed to unauthenticated callers.
|
|
*/
|
|
function buildPostLoginPayload() {
|
|
/** @type {Partial<TStartupConfig>} */
|
|
const payload = {
|
|
showBirthdayIcon:
|
|
isBirthday() ||
|
|
isEnabled(process.env.SHOW_BIRTHDAY_ICON) ||
|
|
process.env.SHOW_BIRTHDAY_ICON === '',
|
|
helpAndFaqURL: process.env.HELP_AND_FAQ_URL || 'https://librechat.ai',
|
|
sharedLinksEnabled,
|
|
publicSharedLinksEnabled,
|
|
openidReuseTokens,
|
|
/** Read inline (not module-level) for per-request evaluation and test isolation */
|
|
allowAccountDeletion:
|
|
process.env.ALLOW_ACCOUNT_DELETION === undefined ||
|
|
isEnabled(process.env.ALLOW_ACCOUNT_DELETION),
|
|
};
|
|
|
|
return payload;
|
|
}
|
|
|
|
function buildBuildInfoPayload(interfaceConfig) {
|
|
if (interfaceConfig?.buildInfo === false) {
|
|
return undefined;
|
|
}
|
|
const info = resolveBuildInfo();
|
|
if (!info.commit && !info.branch && !info.buildDate) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
commit: info.commit,
|
|
commitShort: info.commitShort,
|
|
branch: info.branch,
|
|
buildDate: info.buildDate,
|
|
};
|
|
}
|
|
|
|
function buildWebSearchConfig(appConfig) {
|
|
const ws = appConfig?.webSearch;
|
|
if (!ws) {
|
|
return undefined;
|
|
}
|
|
const { searchProvider, scraperProvider, rerankerType } = ws;
|
|
if (!searchProvider && !scraperProvider && !rerankerType) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
...(searchProvider && { searchProvider }),
|
|
...(scraperProvider && { scraperProvider }),
|
|
...(rerankerType && { rerankerType }),
|
|
};
|
|
}
|
|
|
|
function buildCloudFrontStartupConfig() {
|
|
const config = getCloudFrontConfig();
|
|
if (
|
|
config?.imageSigning !== 'cookies' ||
|
|
!config.domain ||
|
|
!config.cookieDomain ||
|
|
!config.privateKey ||
|
|
!config.keyPairId
|
|
) {
|
|
return undefined;
|
|
}
|
|
|
|
return {
|
|
cookieRefresh: {
|
|
endpoint: '/api/auth/cloudfront/refresh',
|
|
domain: config.domain,
|
|
},
|
|
};
|
|
}
|
|
|
|
router.get('/', async function (req, res) {
|
|
try {
|
|
const preLoginPayload = buildPreLoginPayload();
|
|
const publicSharePayload = buildPublicSharePayload();
|
|
const rum = getRumConfig();
|
|
|
|
if (!req.user) {
|
|
const tenantId = getTenantId();
|
|
const baseConfig = await getAppConfig(tenantId ? { tenantId } : { baseOnly: true });
|
|
|
|
/** @type {Partial<TStartupConfig>} */
|
|
const payload = {
|
|
...preLoginPayload,
|
|
...(req.query.context === 'share' ? publicSharePayload : {}),
|
|
socialLogins: baseConfig?.registration?.socialLogins ?? defaultSocialLogins,
|
|
turnstile: baseConfig?.turnstileConfig,
|
|
...(rum ? { rum } : {}),
|
|
};
|
|
|
|
const interfaceConfig = baseConfig?.interfaceConfig;
|
|
const buildInfoDisabled = interfaceConfig?.buildInfo === false;
|
|
if (interfaceConfig?.privacyPolicy || interfaceConfig?.termsOfService || buildInfoDisabled) {
|
|
payload.interface = {};
|
|
if (interfaceConfig.privacyPolicy) {
|
|
payload.interface.privacyPolicy = interfaceConfig.privacyPolicy;
|
|
}
|
|
if (interfaceConfig.termsOfService) {
|
|
payload.interface.termsOfService = interfaceConfig.termsOfService;
|
|
}
|
|
if (buildInfoDisabled) {
|
|
payload.interface.buildInfo = false;
|
|
}
|
|
}
|
|
|
|
const unauthBuildInfo = buildBuildInfoPayload(interfaceConfig);
|
|
if (unauthBuildInfo) {
|
|
payload.buildInfo = unauthBuildInfo;
|
|
}
|
|
|
|
return res.status(200).send(payload);
|
|
}
|
|
|
|
const appConfig = await getAppConfig({
|
|
role: req.user.role,
|
|
userId: req.user.id,
|
|
tenantId: req.user.tenantId || getTenantId(),
|
|
});
|
|
|
|
const balanceConfig = getBalanceConfig(appConfig);
|
|
const cloudFront = buildCloudFrontStartupConfig();
|
|
|
|
/** @type {TStartupConfig} */
|
|
const payload = {
|
|
...preLoginPayload,
|
|
...publicSharePayload,
|
|
...buildPostLoginPayload(),
|
|
socialLogins: appConfig?.registration?.socialLogins ?? defaultSocialLogins,
|
|
interface: appConfig?.interfaceConfig,
|
|
titleGenerationTiming: resolveTitleTiming({
|
|
appConfig,
|
|
endpoint: EModelEndpoint.agents,
|
|
}),
|
|
turnstile: appConfig?.turnstileConfig,
|
|
modelSpecs: sanitizeModelSpecs(appConfig?.modelSpecs),
|
|
balance: balanceConfig,
|
|
bundlerURL: process.env.SANDPACK_BUNDLER_URL,
|
|
staticBundlerURL: process.env.SANDPACK_STATIC_BUNDLER_URL,
|
|
sharePointFilePickerEnabled,
|
|
sharePointBaseUrl: process.env.SHAREPOINT_BASE_URL,
|
|
sharePointPickerGraphScope: process.env.SHAREPOINT_PICKER_GRAPH_SCOPE,
|
|
sharePointPickerSharePointScope: process.env.SHAREPOINT_PICKER_SHAREPOINT_SCOPE,
|
|
conversationImportMaxFileSize: process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES
|
|
? parseInt(process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES, 10)
|
|
: 0,
|
|
...(cloudFront ? { cloudFront } : {}),
|
|
...(rum ? { rum } : {}),
|
|
};
|
|
|
|
const webSearch = buildWebSearchConfig(appConfig);
|
|
if (webSearch) {
|
|
payload.webSearch = webSearch;
|
|
}
|
|
|
|
const buildInfo = buildBuildInfoPayload(appConfig?.interfaceConfig);
|
|
if (buildInfo) {
|
|
payload.buildInfo = buildInfo;
|
|
}
|
|
|
|
if (!payload.allowAccountDeletion) {
|
|
try {
|
|
const userId = req.user.id ?? req.user._id?.toString();
|
|
if (userId) {
|
|
const canDelete = await hasCapability(
|
|
{ id: userId, role: req.user.role ?? '', tenantId: req.user.tenantId },
|
|
SystemCapabilities.ACCESS_ADMIN,
|
|
);
|
|
if (canDelete) {
|
|
payload.allowAccountDeletion = true;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
logger.warn(`[config] ACCESS_ADMIN capability check failed: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
return res.status(200).send(payload);
|
|
} catch (err) {
|
|
logger.error('Error in startup config', err);
|
|
return res.status(500).send({ error: err.message });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|