Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -833,6 +833,9 @@ ALLOW_SHARED_LINKS_PUBLIC=false
# If you have another service in front of your LibreChat doing compression, disable express based compression here
# DISABLE_COMPRESSION=true

# Serve precompressed Brotli versions of static app assets when available.
# ENABLE_STATIC_ASSET_BROTLI=true

# If you have gzipped version of uploaded image images in the same folder, this will enable gzip scan and serving of these images
# Note: The images folder will be scanned on startup and a ma kept in memory. Be careful for large number of images.
# ENABLE_IMAGE_OUTPUT_GZIP_SCAN=true
Expand Down
51 changes: 45 additions & 6 deletions .github/workflows/cache-integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: '24.16.0'
cache: 'npm'

- name: Install Redis tools
run: |
Expand All @@ -57,14 +56,54 @@ jobs:
redis-cli -p 7002 cluster info || exit 1
redis-cli -p 7003 cluster info || exit 1

- name: Restore node_modules cache
id: cache-node-modules
uses: actions/cache@v4
with:
path: |
node_modules
api/node_modules
packages/api/node_modules
packages/data-provider/node_modules
packages/data-schemas/node_modules
key: node-modules-backend-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }}

- name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: npm ci

- name: Build packages
run: |
npm run build:data-provider
npm run build:data-schemas
npm run build:api
- name: Restore data-provider build cache
id: cache-data-provider
uses: actions/cache@v4
with:
path: packages/data-provider/dist
key: build-data-provider-${{ runner.os }}-${{ hashFiles('packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}

- name: Build data-provider
if: steps.cache-data-provider.outputs.cache-hit != 'true'
run: npm run build:data-provider

- name: Restore data-schemas build cache
id: cache-data-schemas
uses: actions/cache@v4
with:
path: packages/data-schemas/dist
key: build-data-schemas-${{ runner.os }}-${{ hashFiles('packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}

- name: Build data-schemas
if: steps.cache-data-schemas.outputs.cache-hit != 'true'
run: npm run build:data-schemas

- name: Restore api build cache
id: cache-api
uses: actions/cache@v4
with:
path: packages/api/dist
key: build-api-${{ runner.os }}-${{ hashFiles('packages/api/src/**', 'packages/api/tsconfig*.json', 'packages/api/tsdown.config.mjs', 'packages/api/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json') }}

- name: Build api
if: steps.cache-api.outputs.cache-hit != 'true'
run: npm run build:api

- name: Run all cache integration tests (Single Redis Node)
working-directory: packages/api
Expand Down
11 changes: 8 additions & 3 deletions .github/workflows/frontend-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ on:
- 'client/**'
- 'packages/client/**'
- 'packages/data-provider/**'
- '.github/workflows/frontend-review.yml'

permissions:
contents: read
Expand Down Expand Up @@ -170,10 +171,14 @@ jobs:
working-directory: client

test-windows:
name: 'Tests: Windows'
name: 'Tests: Windows (shard ${{ matrix.shard }}/4)'
needs: build
runs-on: windows-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4

Expand Down Expand Up @@ -209,8 +214,8 @@ jobs:
name: build-client-package
path: packages/client/dist

- name: Run unit tests
run: npm run test:ci --verbose
- name: Run unit tests (shard ${{ matrix.shard }}/4)
run: npm run test:ci -- --shard=${{ matrix.shard }}/4
working-directory: client

build-verify:
Expand Down
17 changes: 17 additions & 0 deletions .github/workflows/gitnexus-index.yml
Original file line number Diff line number Diff line change
Expand Up @@ -180,12 +180,29 @@ jobs:
fetch-depth: 1
persist-credentials: false

# HuggingFace throttles anonymous model downloads from shared GHA
# runner IPs (429s or stalled transfers). Cache the embedding model
# across runs so warm runs never touch HF at all.
- name: Cache HuggingFace embedding model
if: steps.flags.outputs.enable_embeddings == 'true'
uses: actions/cache@v4
with:
path: ${{ runner.temp }}/hf-cache
key: hf-model-snowflake-arctic-embed-xs-v1

- name: Run GitNexus Analyze
working-directory: ${{ runner.temp }}
env:
ENABLE_EMBEDDINGS: ${{ steps.flags.outputs.enable_embeddings }}
FORCE: ${{ inputs.force }}
GITNEXUS_BIN: ${{ runner.temp }}/gitnexus-cli/node_modules/.bin/gitnexus
# Fail soft in ~2 min on stalled downloads instead of eating the
# 25-min job budget; HF_TOKEN lifts the anonymous rate limit on
# cold-cache runs (empty when the secret is unset — safe no-op).
HF_DOWNLOAD_TIMEOUT_MS: '60000'
HF_HOME: ${{ runner.temp }}/hf-cache
HF_MAX_ATTEMPTS: '2'
HF_TOKEN: ${{ secrets.HF_TOKEN }}
NPM_CONFIG_AUDIT: false
NPM_CONFIG_CACHE: ${{ runner.temp }}/gitnexus-npm-cache
NPM_CONFIG_FUND: false
Expand Down
1 change: 1 addition & 0 deletions api/server/controllers/agents/__tests__/openai.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ jest.mock('@librechat/api', () => ({
resolveRecursionLimit: jest.fn().mockReturnValue(50),
createToolExecuteHandler: jest.fn().mockReturnValue({ handle: jest.fn() }),
isChatCompletionValidationFailure: jest.fn().mockReturnValue(false),
findPiiMatchInMessages: jest.fn().mockReturnValue(null),
discoverConnectedAgents: jest.fn().mockResolvedValue({
agentConfigs: new Map(),
edges: [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ jest.mock('@librechat/api', () => ({
buildResponse: jest.fn().mockReturnValue({ id: 'resp_123', output: [] }),
generateResponseId: jest.fn().mockReturnValue('resp_mock-123'),
isValidationFailure: jest.fn().mockReturnValue(false),
findPiiMatchInMessages: jest.fn().mockReturnValue(null),
emitResponseCreated: jest.fn(),
createResponseContext: jest.fn().mockReturnValue({ responseId: 'resp_123' }),
createResponseTracker: jest.fn().mockReturnValue({
Expand Down
12 changes: 12 additions & 0 deletions api/server/controllers/agents/openai.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const {
recordCollectedUsage,
getTransactionsConfig,
resolveRecursionLimit,
findPiiMatchInMessages,
discoverConnectedAgents,
getRemoteAgentPermissions,
createToolExecuteHandler,
Expand Down Expand Up @@ -177,6 +178,17 @@ const OpenAIChatCompletionController = async (req, res) => {
);
}

const piiHit = findPiiMatchInMessages(request.messages, appConfig?.messageFilter?.pii);
if (piiHit != null) {
return sendErrorResponse(
res,
400,
`Message contains a ${piiHit.label}. Remove it and try again.`,
'invalid_request_error',
'message_filter_pii_block',
);
}

const responseId = `chatcmpl-${nanoid()}`;
const created = Math.floor(Date.now() / 1000);

Expand Down
22 changes: 17 additions & 5 deletions api/server/controllers/agents/responses.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,19 @@ const {
const {
createRun,
buildToolSet,
loadSkillStates,
resolveAgentScopedSkillIds,
createSafeUser,
initializeAgent,
loadSkillStates,
getBalanceConfig,
injectSkillPrimes,
extractManualSkills,
recordCollectedUsage,
getTransactionsConfig,
extractManualSkills,
injectSkillPrimes,
createToolExecuteHandler,
findPiiMatchInMessages,
discoverConnectedAgents,
createToolExecuteHandler,
getRemoteAgentPermissions,
resolveAgentScopedSkillIds,
// Responses API
writeDone,
buildResponse,
Expand Down Expand Up @@ -575,6 +576,17 @@ const createResponse = async (req, res) => {
typeof request.input === 'string' ? request.input : request.input,
);

const piiHit = findPiiMatchInMessages(inputMessages, appConfig?.messageFilter?.pii);
if (piiHit != null) {
return sendResponsesErrorResponse(
res,
400,
`Message contains a ${piiHit.label}. Remove it and try again.`,
'invalid_request',
'message_filter_pii_block',
);
}

// Merge previous messages with new input
const allMessages = [...previousMessages, ...inputMessages];

Expand Down
3 changes: 2 additions & 1 deletion api/server/routes/agents/chat.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const express = require('express');
const { generateCheckAccess, skipAgentCheck } = require('@librechat/api');
const { createMessageFilterPii, generateCheckAccess, skipAgentCheck } = require('@librechat/api');
const { PermissionTypes, Permissions, PermissionBits } = require('librechat-data-provider');
const {
moderateText,
Expand All @@ -25,6 +25,7 @@ const checkAgentResourceAccess = canAccessAgentFromBody({
requiredPermission: PermissionBits.VIEW,
});

router.use(createMessageFilterPii({ getConfig: (req) => req.config?.messageFilter?.pii }));
router.use(moderateText);
router.use(checkAgentAccess);
router.use(checkAgentResourceAccess);
Expand Down
59 changes: 58 additions & 1 deletion api/server/utils/__tests__/staticCache.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ const request = require('supertest');
const zlib = require('zlib');
const staticCache = require('../staticCache');

const binaryParser = (res, callback) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => callback(null, Buffer.concat(chunks)));
};

describe('staticCache', () => {
let app;
let testDir;
Expand Down Expand Up @@ -36,10 +42,15 @@ describe('staticCache', () => {
fs.writeFileSync(manifestFile, jsonContent);
fs.writeFileSync(swFile, swContent);

// Create gzipped versions of some files
// Create precompressed versions of some files
fs.writeFileSync(testFile + '.gz', zlib.gzipSync(jsContent));
fs.writeFileSync(testFile + '.br', zlib.brotliCompressSync(jsContent));
fs.writeFileSync(path.join(testDir, 'test.css'), 'body { color: red; }');
fs.writeFileSync(path.join(testDir, 'test.css.gz'), zlib.gzipSync('body { color: red; }'));
fs.writeFileSync(
path.join(testDir, 'test.css.br'),
zlib.brotliCompressSync('body { color: red; }'),
);

// Create a file that only exists in gzipped form
fs.writeFileSync(
Expand Down Expand Up @@ -67,6 +78,7 @@ describe('staticCache', () => {
delete process.env.NODE_ENV;
delete process.env.STATIC_CACHE_S_MAX_AGE;
delete process.env.STATIC_CACHE_MAX_AGE;
delete process.env.ENABLE_STATIC_ASSET_BROTLI;
});
describe('cache headers in production', () => {
beforeEach(() => {
Expand Down Expand Up @@ -193,6 +205,51 @@ describe('staticCache', () => {
process.env.NODE_ENV = 'production';
});

it('should serve Brotli files when client accepts Brotli encoding', async () => {
process.env.ENABLE_STATIC_ASSET_BROTLI = 'true';
app.use(staticCache(testDir, { skipGzipScan: false }));

const response = await request(app)
.get('/test.js')
.set('Accept-Encoding', 'br, gzip, deflate')
.buffer(true)
.parse(binaryParser)
.expect(200);

expect(response.headers['content-encoding']).toBe('br');
expect(response.headers['content-type']).toMatch(/javascript/);
expect(response.headers['cache-control']).toBe('public, max-age=172800, s-maxage=86400');
expect(zlib.brotliDecompressSync(response.body).toString()).toBe('console.log("test");');
});

it('should prefer Brotli over gzip when both encodings are accepted', async () => {
process.env.ENABLE_STATIC_ASSET_BROTLI = 'true';
app.use(staticCache(testDir, { skipGzipScan: false }));

const response = await request(app)
.get('/test.css')
.set('Accept-Encoding', 'gzip, br')
.buffer(true)
.parse(binaryParser)
.expect(200);

expect(response.headers['content-encoding']).toBe('br');
expect(response.headers['content-type']).toMatch(/css/);
expect(zlib.brotliDecompressSync(response.body).toString()).toBe('body { color: red; }');
});

it('should keep serving gzip when Brotli is not enabled', async () => {
app.use(staticCache(testDir, { skipGzipScan: false }));

const response = await request(app)
.get('/test.js')
.set('Accept-Encoding', 'br, gzip, deflate')
.expect(200);

expect(response.headers['content-encoding']).toBe('gzip');
expect(response.text).toBe('console.log("test");');
});

it('should serve gzipped files when client accepts gzip encoding', async () => {
app.use(staticCache(testDir, { skipGzipScan: false }));

Expand Down
8 changes: 5 additions & 3 deletions api/server/utils/staticCache.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ const oneDayInSeconds = 24 * 60 * 60;

const sMaxAge = process.env.STATIC_CACHE_S_MAX_AGE || oneDayInSeconds;
const maxAge = process.env.STATIC_CACHE_MAX_AGE || oneDayInSeconds * 2;
const isEnabled = (value) => value === true || String(value).toLowerCase() === 'true';

/**
* Creates an Express static middleware with optional gzip compression and configurable caching
* Creates an Express static middleware with optional precompressed asset serving and configurable caching
*
* @param {string} staticPath - The file system path to serve static files from
* @param {Object} [options={}] - Configuration options
Expand All @@ -18,6 +19,7 @@ const maxAge = process.env.STATIC_CACHE_MAX_AGE || oneDayInSeconds * 2;
*/
function staticCache(staticPath, options = {}) {
const { noCache = false, skipGzipScan = false } = options;
const enableBrotli = isEnabled(process.env.ENABLE_STATIC_ASSET_BROTLI);

const setHeaders = (res, filePath) => {
if (process.env.NODE_ENV?.toLowerCase() !== 'production') {
Expand Down Expand Up @@ -51,8 +53,8 @@ function staticCache(staticPath, options = {}) {
});
} else {
return expressStaticGzip(staticPath, {
enableBrotli: false,
orderPreference: ['gz'],
enableBrotli,
orderPreference: enableBrotli ? ['br', 'gz'] : ['gz'],
setHeaders,
index: false,
});
Expand Down
7 changes: 5 additions & 2 deletions client/src/@types/i18next.d.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { defaultNS, resources } from '~/locales/i18n';
import translationEn from '~/locales/en/translation.json';
import { defaultNS } from '~/locales/i18n';

declare module 'i18next' {
interface CustomTypeOptions {
defaultNS: typeof defaultNS;
resources: typeof resources.en;
resources: {
translation: typeof translationEn;
};
strictKeyChecks: true;
}
}
2 changes: 2 additions & 0 deletions client/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Toast, ThemeProvider, ToastProvider } from '@librechat/client';
import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query';
import { ScreenshotProvider, useApiErrorBoundary } from './hooks';
import WakeLockManager from '~/components/System/WakeLockManager';
import LanguageSync from '~/components/System/LanguageSync';
import { getThemeFromEnv } from './utils/getThemeFromEnv';
import { initializeFontSize } from '~/store/fontSize';
import { LiveAnnouncer } from '~/a11y';
Expand Down Expand Up @@ -47,6 +48,7 @@ const App = () => {
return (
<QueryClientProvider client={queryClient}>
<RecoilRoot>
<LanguageSync />
<LiveAnnouncer>
<ThemeProvider
// Only pass initialTheme and themeRGB if environment theme exists
Expand Down
Loading
Loading