From 001ef8ff8b46b6dca1c7aae9dd742753f59a7178 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 20 Apr 2026 21:30:33 -0400 Subject: [PATCH 01/16] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=20refactor:=20Deriv?= =?UTF-8?q?e=20App=20Version=20from=20Root=20package.json=20+=20Add=20buil?= =?UTF-8?q?dInfo=20Schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hardcoded `Constants.VERSION` in `data-provider` is now replaced at rollup build time via `@rollup/plugin-replace`, sourcing from the root `package.json` so version bumps are a single-file change. Adds the shape needed by the rest of the series: - `interface.buildInfo` boolean flag (default `true`) — lets self-hosters opt out of exposing commit/branch/date. - `buildInfo` on `TStartupConfig` — commit/commitShort/branch/buildDate. - `SettingsTabValues.ABOUT` — new settings tab enum value. Ref: https://github.com/danny-avila/LibreChat/issues/12406 --- packages/data-provider/rollup.config.js | 7 ++++++- packages/data-provider/src/config.ts | 16 ++++++++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/data-provider/rollup.config.js b/packages/data-provider/rollup.config.js index 95cb5206d8d..72772f7da6a 100644 --- a/packages/data-provider/rollup.config.js +++ b/packages/data-provider/rollup.config.js @@ -1,6 +1,7 @@ import typescript from 'rollup-plugin-typescript2'; import resolve from '@rollup/plugin-node-resolve'; import pkg from './package.json'; +import rootPkg from '../../package.json'; import peerDepsExternal from 'rollup-plugin-peer-deps-external'; import commonjs from '@rollup/plugin-commonjs'; import replace from '@rollup/plugin-replace'; @@ -10,7 +11,11 @@ const plugins = [ peerDepsExternal(), resolve(), replace({ - __IS_DEV__: process.env.NODE_ENV === 'development', + preventAssignment: true, + values: { + __IS_DEV__: process.env.NODE_ENV === 'development', + __LIBRECHAT_VERSION__: rootPkg.version, + }, }), commonjs(), typescript({ diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 077c72757e5..e9104a25186 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -960,6 +960,7 @@ export const interfaceSchema = z .optional(), fileSearch: z.boolean().optional(), fileCitations: z.boolean().optional(), + buildInfo: z.boolean().optional(), remoteAgents: z .object({ use: z.boolean().optional(), @@ -1020,6 +1021,7 @@ export const interfaceSchema = z }, fileSearch: true, fileCitations: true, + buildInfo: true, remoteAgents: { use: false, create: false, @@ -1134,6 +1136,12 @@ export type TStartupConfig = { >; mcpPlaceholder?: string; conversationImportMaxFileSize?: number; + buildInfo?: { + commit?: string | null; + commitShort?: string | null; + branch?: string | null; + buildDate?: string | null; + }; }; export enum OCRStrategy { @@ -2106,6 +2114,10 @@ export enum SettingsTabValues { * Tab for Personalization Settings */ PERSONALIZATION = 'personalization', + /** + * Tab for About / Build Info + */ + ABOUT = 'about', } export enum STTProviders { @@ -2140,8 +2152,8 @@ export enum TTSProviders { /** Enum for app-wide constants */ export enum Constants { - /** Key for the app's version. */ - VERSION = 'v0.8.6-rc1', + /** Key for the app's version. Replaced at build time by rollup from the root `package.json`. */ + VERSION = '__LIBRECHAT_VERSION__', /** Key for the Custom Config's version (librechat.yaml). */ CONFIG_VERSION = '1.3.11', /** Standard value for the first message's `parentMessageId` value, to indicate no parent exists. */ From c17f094a79e3ac5db192148e147d2e2d1d2678bc Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 20 Apr 2026 21:30:51 -0400 Subject: [PATCH 02/16] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=20feat:=20Add=20Bui?= =?UTF-8?q?ld=20Metadata=20Resolver=20and=20Expose=20via=20/api/config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `resolveBuildInfo()` in `@librechat/api` that surfaces commit SHA, branch, and build date from (in order) `BUILD_*` env vars, then local git metadata. Result is cached per-process. `/api/config` includes a `buildInfo` field on both authenticated and anonymous responses when `interface.buildInfo !== false` and at least one resolver field is populated. Omitted entirely otherwise. Designed so pre-built Docker images carry metadata via build-arg while source installs pick it up from `.git` — no manual version tracking. Ref: https://github.com/danny-avila/LibreChat/issues/12406 --- api/server/routes/__tests__/config.spec.js | 83 ++++++++++++++++++++++ api/server/routes/config.js | 27 +++++++ packages/api/src/app/buildInfo.spec.ts | 64 +++++++++++++++++ packages/api/src/app/buildInfo.ts | 71 ++++++++++++++++++ packages/api/src/app/index.ts | 2 + 5 files changed, 247 insertions(+) create mode 100644 packages/api/src/app/buildInfo.spec.ts create mode 100644 packages/api/src/app/buildInfo.ts diff --git a/api/server/routes/__tests__/config.spec.js b/api/server/routes/__tests__/config.spec.js index 52a843116c6..a053210a100 100644 --- a/api/server/routes/__tests__/config.spec.js +++ b/api/server/routes/__tests__/config.spec.js @@ -21,9 +21,16 @@ jest.mock('@librechat/data-schemas', () => ({ })); const mockGetCloudFrontConfig = jest.fn(() => null); +const mockResolveBuildInfo = jest.fn(() => ({ + commit: null, + commitShort: null, + branch: null, + buildDate: null, +})); jest.mock('@librechat/api', () => ({ ...jest.requireActual('@librechat/api'), getCloudFrontConfig: (...args) => mockGetCloudFrontConfig(...args), + resolveBuildInfo: (...args) => mockResolveBuildInfo(...args), })); const request = require('supertest'); @@ -63,6 +70,12 @@ const mockUser = { afterEach(() => { jest.resetAllMocks(); + mockResolveBuildInfo.mockReturnValue({ + commit: null, + commitShort: null, + branch: null, + buildDate: null, + }); delete process.env.APP_TITLE; delete process.env.CHECK_BALANCE; delete process.env.START_BALANCE; @@ -446,4 +459,74 @@ describe('GET /api/config', () => { expect(response.body).toHaveProperty('error'); }); }); + + describe('buildInfo payload', () => { + const populatedBuildInfo = { + commit: 'abcdef1234567890abcdef1234567890abcdef12', + commitShort: 'abcdef1', + branch: 'dev', + buildDate: '2026-04-20T12:00:00Z', + }; + + it('includes buildInfo in authenticated response when interface flag is not explicitly disabled', async () => { + mockGetAppConfig.mockResolvedValue(baseAppConfig); + mockResolveBuildInfo.mockReturnValue(populatedBuildInfo); + const app = createApp(mockUser); + + const response = await request(app).get('/api/config'); + + expect(response.body.buildInfo).toEqual(populatedBuildInfo); + }); + + it('omits buildInfo when interface.buildInfo is false', async () => { + mockGetAppConfig.mockResolvedValue({ + ...baseAppConfig, + interfaceConfig: { ...baseAppConfig.interfaceConfig, buildInfo: false }, + }); + mockResolveBuildInfo.mockReturnValue(populatedBuildInfo); + const app = createApp(mockUser); + + const response = await request(app).get('/api/config'); + + expect(response.body).not.toHaveProperty('buildInfo'); + }); + + it('omits buildInfo when all resolver fields are null', async () => { + mockGetAppConfig.mockResolvedValue(baseAppConfig); + mockResolveBuildInfo.mockReturnValue({ + commit: null, + commitShort: null, + branch: null, + buildDate: null, + }); + const app = createApp(mockUser); + + const response = await request(app).get('/api/config'); + + expect(response.body).not.toHaveProperty('buildInfo'); + }); + + it('includes buildInfo in unauthenticated response when flag is not disabled', async () => { + mockGetAppConfig.mockResolvedValue(baseAppConfig); + mockResolveBuildInfo.mockReturnValue(populatedBuildInfo); + const app = createApp(null); + + const response = await request(app).get('/api/config'); + + expect(response.body.buildInfo).toEqual(populatedBuildInfo); + }); + + it('omits buildInfo in unauthenticated response when interface.buildInfo is false', async () => { + mockGetAppConfig.mockResolvedValue({ + ...baseAppConfig, + interfaceConfig: { ...baseAppConfig.interfaceConfig, buildInfo: false }, + }); + mockResolveBuildInfo.mockReturnValue(populatedBuildInfo); + const app = createApp(null); + + const response = await request(app).get('/api/config'); + + expect(response.body).not.toHaveProperty('buildInfo'); + }); + }); }); diff --git a/api/server/routes/config.js b/api/server/routes/config.js index 33d1a8a3250..cbfc7040560 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -3,6 +3,7 @@ const { isEnabled, getBalanceConfig, getCloudFrontConfig, + resolveBuildInfo, sanitizeModelSpecs, } = require('@librechat/api'); const { defaultSocialLogins } = require('librechat-data-provider'); @@ -105,6 +106,22 @@ function buildSharedPayload() { 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) { @@ -169,6 +186,11 @@ router.get('/', async function (req, res) { } } + const unauthBuildInfo = buildBuildInfoPayload(interfaceConfig); + if (unauthBuildInfo) { + payload.buildInfo = unauthBuildInfo; + } + return res.status(200).send(payload); } @@ -205,6 +227,11 @@ router.get('/', async function (req, res) { 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(); diff --git a/packages/api/src/app/buildInfo.spec.ts b/packages/api/src/app/buildInfo.spec.ts new file mode 100644 index 00000000000..f6f6167adc3 --- /dev/null +++ b/packages/api/src/app/buildInfo.spec.ts @@ -0,0 +1,64 @@ +import { resolveBuildInfo, __resetBuildInfoCacheForTests } from './buildInfo'; + +describe('resolveBuildInfo', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + __resetBuildInfoCacheForTests(); + delete process.env.BUILD_COMMIT; + delete process.env.BUILD_BRANCH; + delete process.env.BUILD_DATE; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it('prefers BUILD_* env vars over git', () => { + process.env.BUILD_COMMIT = 'abcdef1234567890abcdef1234567890abcdef12'; + process.env.BUILD_BRANCH = 'release/v1.0'; + process.env.BUILD_DATE = '2026-04-20T12:00:00Z'; + + const info = resolveBuildInfo(); + + expect(info.commit).toBe('abcdef1234567890abcdef1234567890abcdef12'); + expect(info.commitShort).toBe('abcdef1'); + expect(info.branch).toBe('release/v1.0'); + expect(info.buildDate).toBe('2026-04-20T12:00:00Z'); + }); + + it('returns null for any field that cannot be resolved', () => { + __resetBuildInfoCacheForTests(); + const info = resolveBuildInfo(); + // commit/branch may or may not resolve depending on whether the test env has git — + // but buildDate has no fallback, so it must be null here. + expect(info.buildDate).toBeNull(); + }); + + it('treats empty/whitespace env vars as unset', () => { + process.env.BUILD_COMMIT = ' '; + process.env.BUILD_BRANCH = ''; + process.env.BUILD_DATE = ' '; + + const info = resolveBuildInfo(); + + expect(info.buildDate).toBeNull(); + // commit/branch will fall back to git or null — can't assert exact value, just that empty env was ignored + expect(info.commit).not.toBe(' '); + expect(info.branch).not.toBe(''); + }); + + it('normalizes detached-HEAD branch to null', () => { + process.env.BUILD_BRANCH = 'HEAD'; + const info = resolveBuildInfo(); + expect(info.branch).toBeNull(); + }); + + it('caches result across calls', () => { + process.env.BUILD_COMMIT = 'cached1234567890cached1234567890cached12'; + const first = resolveBuildInfo(); + process.env.BUILD_COMMIT = 'different1234567890different1234567890de'; + const second = resolveBuildInfo(); + expect(second.commit).toBe(first.commit); + }); +}); diff --git a/packages/api/src/app/buildInfo.ts b/packages/api/src/app/buildInfo.ts new file mode 100644 index 00000000000..d8445bff03d --- /dev/null +++ b/packages/api/src/app/buildInfo.ts @@ -0,0 +1,71 @@ +import { execFileSync } from 'child_process'; +import { logger } from '@librechat/data-schemas'; + +export interface BuildInfo { + commit: string | null; + commitShort: string | null; + branch: string | null; + buildDate: string | null; +} + +let cached: BuildInfo | null = null; + +function safeGit(args: string[]): string | null { + try { + const out = execFileSync('git', args, { + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 2000, + encoding: 'utf8', + }); + const trimmed = out.trim(); + return trimmed.length > 0 ? trimmed : null; + } catch { + return null; + } +} + +function normalize(value: string | undefined): string | null { + if (typeof value !== 'string') { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +/** + * Resolves the current deployment's build info from (in order): explicit env vars, + * then local git metadata. Used by `/api/config` to expose commit/branch to clients + * when `interface.buildInfo` is enabled. + */ +export function resolveBuildInfo(): BuildInfo { + if (cached) { + return cached; + } + + const commit = normalize(process.env.BUILD_COMMIT) ?? safeGit(['rev-parse', 'HEAD']); + const branch = + normalize(process.env.BUILD_BRANCH) ?? safeGit(['rev-parse', '--abbrev-ref', 'HEAD']); + const buildDate = normalize(process.env.BUILD_DATE); + + const info: BuildInfo = { + commit, + commitShort: commit ? commit.slice(0, 7) : null, + branch: branch === 'HEAD' ? null : branch, + buildDate, + }; + + cached = info; + + if (!info.commit && !info.branch && !info.buildDate) { + logger.debug( + '[buildInfo] no BUILD_* env vars set and git metadata unavailable; buildInfo will be empty', + ); + } + + return info; +} + +/** Test hook — resets the in-process cache. Not exported from the package barrel. */ +export function __resetBuildInfoCacheForTests(): void { + cached = null; +} diff --git a/packages/api/src/app/index.ts b/packages/api/src/app/index.ts index a2eef88ae11..570727fc356 100644 --- a/packages/api/src/app/index.ts +++ b/packages/api/src/app/index.ts @@ -6,3 +6,5 @@ export * from './cdn'; export * from './checks'; export * from './resolve'; export * from './shutdown'; +export { resolveBuildInfo } from './buildInfo'; +export type { BuildInfo } from './buildInfo'; From 6bcd697b10dcb1bc39321183dca3710fec5df078 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 20 Apr 2026 21:31:53 -0400 Subject: [PATCH 03/16] =?UTF-8?q?=E2=84=B9=EF=B8=8F=20feat:=20Add=20Settin?= =?UTF-8?q?gs=20=E2=86=92=20About=20Panel=20with=20Diagnostics=20Copy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Settings tab that renders the running build's version, commit (short SHA), branch, and build date in a monospaced block alongside a "Copy diagnostics" button that emits a preformatted text blob for pasting into support issues. Tab is hidden when `interface.buildInfo` is set to `false`. Reads from `startupConfig.buildInfo` provided by `/api/config`. Ref: https://github.com/danny-avila/LibreChat/issues/12406 --- client/src/components/Nav/Settings.tsx | 19 ++- .../Nav/SettingsTabs/About/About.tsx | 120 ++++++++++++++++++ .../src/components/Nav/SettingsTabs/index.ts | 1 + client/src/locales/en/translation.json | 10 ++ 4 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 client/src/components/Nav/SettingsTabs/About/About.tsx diff --git a/client/src/components/Nav/Settings.tsx b/client/src/components/Nav/Settings.tsx index 4a2f1aacdfd..eef6eb0ca84 100644 --- a/client/src/components/Nav/Settings.tsx +++ b/client/src/components/Nav/Settings.tsx @@ -1,7 +1,7 @@ import React, { useState, useRef } from 'react'; import * as Tabs from '@radix-ui/react-tabs'; import { SettingsTabValues } from 'librechat-data-provider'; -import { MessageSquare, Command, DollarSign } from 'lucide-react'; +import { MessageSquare, Command, DollarSign, Info } from 'lucide-react'; import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'; import { GearIcon, @@ -21,6 +21,7 @@ import { Data, Balance, Account, + About, } from './SettingsTabs'; import usePersonalizationAccess from '~/hooks/usePersonalizationAccess'; import { useLocalize, TranslationKeys } from '~/hooks'; @@ -34,6 +35,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) { const [activeTab, setActiveTab] = useState(SettingsTabValues.GENERAL); const tabRefs = useRef({}); const { hasAnyPersonalizationFeature, hasMemoryOptOut } = usePersonalizationAccess(); + const aboutEnabled = startupConfig?.interface?.buildInfo !== false; const handleKeyDown = (event: React.KeyboardEvent) => { const tabs: SettingsTabValues[] = [ @@ -45,6 +47,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) { SettingsTabValues.DATA, ...(startupConfig?.balance?.enabled ? [SettingsTabValues.BALANCE] : []), SettingsTabValues.ACCOUNT, + ...(aboutEnabled ? [SettingsTabValues.ABOUT] : []), ]; const currentIndex = tabs.indexOf(activeTab); @@ -121,6 +124,15 @@ export default function Settings({ open, onOpenChange }: TDialogProps) { icon: , label: 'com_nav_setting_account', }, + ...(aboutEnabled + ? [ + { + value: SettingsTabValues.ABOUT, + icon: