Merge branch 'main' into feature/entra-id-azure-integration

This commit is contained in:
victorbjor 2025-12-15 15:40:47 +01:00 committed by GitHub
commit a7cf1ae27b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
241 changed files with 25653 additions and 3303 deletions

View file

@ -785,3 +785,7 @@ OPENWEATHER_API_KEY=
# Cache connection status checks for this many milliseconds to avoid expensive verification # Cache connection status checks for this many milliseconds to avoid expensive verification
# MCP_CONNECTION_CHECK_TTL=60000 # MCP_CONNECTION_CHECK_TTL=60000
# Skip code challenge method validation (e.g., for AWS Cognito that supports S256 but doesn't advertise it)
# When set to true, forces S256 code challenge even if not advertised in .well-known/openid-configuration
# MCP_SKIP_CODE_CHALLENGE_CHECK=false

View file

@ -13,9 +13,14 @@ on:
required: false required: false
default: 'Manual publish requested' default: 'Manual publish requested'
permissions:
id-token: write # Required for OIDC trusted publishing
contents: read
jobs: jobs:
build-and-publish: build-and-publish:
runs-on: ubuntu-latest runs-on: ubuntu-latest
environment: publish # Must match npm trusted publisher config
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -23,6 +28,10 @@ jobs:
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '20.x' node-version: '20.x'
registry-url: 'https://registry.npmjs.org'
- name: Update npm for OIDC support
run: npm install -g npm@latest # Must be 11.5.1+ for provenance
- name: Install client dependencies - name: Install client dependencies
run: cd packages/client && npm ci run: cd packages/client && npm ci
@ -30,9 +39,6 @@ jobs:
- name: Build client - name: Build client
run: cd packages/client && npm run build run: cd packages/client && npm run build
- name: Set up npm authentication
run: echo "//registry.npmjs.org/:_authToken=${{ secrets.PUBLISH_NPM_TOKEN }}" > ~/.npmrc
- name: Check version change - name: Check version change
id: check id: check
working-directory: packages/client working-directory: packages/client
@ -55,4 +61,4 @@ jobs:
- name: Publish - name: Publish
if: steps.check.outputs.skip != 'true' if: steps.check.outputs.skip != 'true'
working-directory: packages/client working-directory: packages/client
run: npm publish *.tgz --access public run: npm publish *.tgz --access public --provenance

View file

@ -13,6 +13,10 @@ on:
required: false required: false
default: 'Manual publish requested' default: 'Manual publish requested'
permissions:
id-token: write # Required for OIDC trusted publishing
contents: read
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -27,14 +31,17 @@ jobs:
publish-npm: publish-npm:
needs: build needs: build
runs-on: ubuntu-latest runs-on: ubuntu-latest
environment: publish # Must match npm trusted publisher config
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 20
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'
- name: Update npm for OIDC support
run: npm install -g npm@latest # Must be 11.5.1+ for provenance
- run: cd packages/data-provider && npm ci - run: cd packages/data-provider && npm ci
- run: cd packages/data-provider && npm run build - run: cd packages/data-provider && npm run build
- run: cd packages/data-provider && npm publish - run: cd packages/data-provider && npm publish --provenance
env:
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}

View file

@ -13,9 +13,14 @@ on:
required: false required: false
default: 'Manual publish requested' default: 'Manual publish requested'
permissions:
id-token: write # Required for OIDC trusted publishing
contents: read
jobs: jobs:
build-and-publish: build-and-publish:
runs-on: ubuntu-latest runs-on: ubuntu-latest
environment: publish # Must match npm trusted publisher config
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -23,6 +28,10 @@ jobs:
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '20.x' node-version: '20.x'
registry-url: 'https://registry.npmjs.org'
- name: Update npm for OIDC support
run: npm install -g npm@latest # Must be 11.5.1+ for provenance
- name: Install dependencies - name: Install dependencies
run: cd packages/data-schemas && npm ci run: cd packages/data-schemas && npm ci
@ -30,9 +39,6 @@ jobs:
- name: Build - name: Build
run: cd packages/data-schemas && npm run build run: cd packages/data-schemas && npm run build
- name: Set up npm authentication
run: echo "//registry.npmjs.org/:_authToken=${{ secrets.PUBLISH_NPM_TOKEN }}" > ~/.npmrc
- name: Check version change - name: Check version change
id: check id: check
working-directory: packages/data-schemas working-directory: packages/data-schemas
@ -55,4 +61,4 @@ jobs:
- name: Publish - name: Publish
if: steps.check.outputs.skip != 'true' if: steps.check.outputs.skip != 'true'
working-directory: packages/data-schemas working-directory: packages/data-schemas
run: npm publish *.tgz --access public run: npm publish *.tgz --access public --provenance

View file

@ -0,0 +1,66 @@
name: Docker Dev Staging Images Build
on:
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- target: api-build
file: Dockerfile.multi
image_name: lc-dev-staging-api
- target: node
file: Dockerfile
image_name: lc-dev-staging
steps:
# Check out the repository
- name: Checkout
uses: actions/checkout@v4
# Set up QEMU
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
# Set up Docker Buildx
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# Log in to GitHub Container Registry
- name: Log in to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Login to Docker Hub
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# Prepare the environment
- name: Prepare environment
run: |
cp .env.example .env
# Build and push Docker images for each target
- name: Build and push Docker images
uses: docker/build-push-action@v5
with:
context: .
file: ${{ matrix.file }}
push: true
tags: |
ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:${{ github.sha }}
ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:latest
${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:${{ github.sha }}
${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:latest
platforms: linux/amd64,linux/arm64
target: ${{ matrix.target }}

View file

@ -35,8 +35,6 @@ jobs:
# Run ESLint on changed files within the api/ and client/ directories. # Run ESLint on changed files within the api/ and client/ directories.
- name: Run ESLint on changed files - name: Run ESLint on changed files
env:
SARIF_ESLINT_IGNORE_SUPPRESSED: "true"
run: | run: |
# Extract the base commit SHA from the pull_request event payload. # Extract the base commit SHA from the pull_request event payload.
BASE_SHA=$(jq --raw-output .pull_request.base.sha "$GITHUB_EVENT_PATH") BASE_SHA=$(jq --raw-output .pull_request.base.sha "$GITHUB_EVENT_PATH")
@ -52,22 +50,10 @@ jobs:
# Ensure there are files to lint before running ESLint # Ensure there are files to lint before running ESLint
if [[ -z "$CHANGED_FILES" ]]; then if [[ -z "$CHANGED_FILES" ]]; then
echo "No matching files changed. Skipping ESLint." echo "No matching files changed. Skipping ESLint."
echo "UPLOAD_SARIF=false" >> $GITHUB_ENV
exit 0 exit 0
fi fi
# Set variable to allow SARIF upload
echo "UPLOAD_SARIF=true" >> $GITHUB_ENV
# Run ESLint # Run ESLint
npx eslint --no-error-on-unmatched-pattern \ npx eslint --no-error-on-unmatched-pattern \
--config eslint.config.mjs \ --config eslint.config.mjs \
--format @microsoft/eslint-formatter-sarif \ $CHANGED_FILES
--output-file eslint-results.sarif $CHANGED_FILES || true
- name: Upload analysis results to GitHub
if: env.UPLOAD_SARIF == 'true'
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: eslint-results.sarif
wait-for-processing: true

31
.gitignore vendored
View file

@ -138,3 +138,34 @@ helm/**/.values.yaml
/.tabnine/ /.tabnine/
/.codeium /.codeium
*.local.md *.local.md
# Removed Windows wrapper files per user request
hive-mind-prompt-*.txt
# Claude Flow generated files
.claude/settings.local.json
.mcp.json
claude-flow.config.json
.swarm/
.hive-mind/
.claude-flow/
memory/
coordination/
memory/claude-flow-data.json
memory/sessions/*
!memory/sessions/README.md
memory/agents/*
!memory/agents/README.md
coordination/memory_bank/*
coordination/subtasks/*
coordination/orchestration/*
*.db
*.db-journal
*.db-wal
*.sqlite
*.sqlite-journal
*.sqlite-wal
claude-flow
# Removed Windows wrapper files per user request
hive-mind-prompt-*.txt

View file

@ -1,4 +1,4 @@
# v0.8.1-rc1 # v0.8.1
# Base node image # Base node image
FROM node:20-alpine AS node FROM node:20-alpine AS node

View file

@ -1,5 +1,5 @@
# Dockerfile.multi # Dockerfile.multi
# v0.8.1-rc1 # v0.8.1
# Base for all builds # Base for all builds
FROM node:20-alpine AS base-min FROM node:20-alpine AS base-min

View file

@ -2,6 +2,7 @@ const crypto = require('crypto');
const fetch = require('node-fetch'); const fetch = require('node-fetch');
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { const {
countTokens,
getBalanceConfig, getBalanceConfig,
extractFileContext, extractFileContext,
encodeAndFormatAudios, encodeAndFormatAudios,
@ -23,7 +24,6 @@ const { getMessages, saveMessage, updateMessage, saveConvo, getConvo } = require
const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { checkBalance } = require('~/models/balanceMethods'); const { checkBalance } = require('~/models/balanceMethods');
const { truncateToolCallOutputs } = require('./prompts'); const { truncateToolCallOutputs } = require('./prompts');
const countTokens = require('~/server/utils/countTokens');
const { getFiles } = require('~/models/File'); const { getFiles } = require('~/models/File');
const TextStream = require('./TextStream'); const TextStream = require('./TextStream');
@ -81,6 +81,7 @@ class BaseClient {
throw new Error("Method 'getCompletion' must be implemented."); throw new Error("Method 'getCompletion' must be implemented.");
} }
/** @type {sendCompletion} */
async sendCompletion() { async sendCompletion() {
throw new Error("Method 'sendCompletion' must be implemented."); throw new Error("Method 'sendCompletion' must be implemented.");
} }
@ -689,8 +690,7 @@ class BaseClient {
}); });
} }
/** @type {string|string[]|undefined} */ const { completion, metadata } = await this.sendCompletion(payload, opts);
const completion = await this.sendCompletion(payload, opts);
if (this.abortController) { if (this.abortController) {
this.abortController.requestCompleted = true; this.abortController.requestCompleted = true;
} }
@ -708,6 +708,7 @@ class BaseClient {
iconURL: this.options.iconURL, iconURL: this.options.iconURL,
endpoint: this.options.endpoint, endpoint: this.options.endpoint,
...(this.metadata ?? {}), ...(this.metadata ?? {}),
metadata,
}; };
if (typeof completion === 'string') { if (typeof completion === 'string') {
@ -1212,8 +1213,8 @@ class BaseClient {
this.options.req, this.options.req,
attachments, attachments,
{ {
provider: this.options.agent?.provider, provider: this.options.agent?.provider ?? this.options.endpoint,
endpoint: this.options.agent?.endpoint, endpoint: this.options.agent?.endpoint ?? this.options.endpoint,
useResponsesApi: this.options.agent?.model_parameters?.useResponsesApi, useResponsesApi: this.options.agent?.model_parameters?.useResponsesApi,
}, },
getStrategyFunctions, getStrategyFunctions,
@ -1230,8 +1231,8 @@ class BaseClient {
this.options.req, this.options.req,
attachments, attachments,
{ {
provider: this.options.agent?.provider, provider: this.options.agent?.provider ?? this.options.endpoint,
endpoint: this.options.agent?.endpoint, endpoint: this.options.agent?.endpoint ?? this.options.endpoint,
}, },
getStrategyFunctions, getStrategyFunctions,
); );
@ -1245,8 +1246,8 @@ class BaseClient {
this.options.req, this.options.req,
attachments, attachments,
{ {
provider: this.options.agent?.provider, provider: this.options.agent?.provider ?? this.options.endpoint,
endpoint: this.options.agent?.endpoint, endpoint: this.options.agent?.endpoint ?? this.options.endpoint,
}, },
getStrategyFunctions, getStrategyFunctions,
); );

View file

@ -1,3 +1,4 @@
const { getBasePath } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
/** /**
@ -32,6 +33,8 @@ function addImages(intermediateSteps, responseMessage) {
return; return;
} }
const basePath = getBasePath();
// Correct any erroneous URLs in the responseMessage.text first // Correct any erroneous URLs in the responseMessage.text first
intermediateSteps.forEach((step) => { intermediateSteps.forEach((step) => {
const { observation } = step; const { observation } = step;
@ -44,12 +47,14 @@ function addImages(intermediateSteps, responseMessage) {
return; return;
} }
const essentialImagePath = match[0]; const essentialImagePath = match[0];
const fullImagePath = `${basePath}${essentialImagePath}`;
const regex = /!\[.*?\]\((.*?)\)/g; const regex = /!\[.*?\]\((.*?)\)/g;
let matchErroneous; let matchErroneous;
while ((matchErroneous = regex.exec(responseMessage.text)) !== null) { while ((matchErroneous = regex.exec(responseMessage.text)) !== null) {
if (matchErroneous[1] && !matchErroneous[1].startsWith('/images/')) { if (matchErroneous[1] && !matchErroneous[1].startsWith(`${basePath}/images/`)) {
responseMessage.text = responseMessage.text.replace(matchErroneous[1], essentialImagePath); // Replace with the full path including base path
responseMessage.text = responseMessage.text.replace(matchErroneous[1], fullImagePath);
} }
} }
}); });
@ -61,9 +66,23 @@ function addImages(intermediateSteps, responseMessage) {
return; return;
} }
const observedImagePath = observation.match(/!\[[^(]*\]\([^)]*\)/g); const observedImagePath = observation.match(/!\[[^(]*\]\([^)]*\)/g);
if (observedImagePath && !responseMessage.text.includes(observedImagePath[0])) { if (observedImagePath) {
responseMessage.text += '\n' + observedImagePath[0]; // Fix the image path to include base path if it doesn't already
logger.debug('[addImages] added image from intermediateSteps:', observedImagePath[0]); let imageMarkdown = observedImagePath[0];
const urlMatch = imageMarkdown.match(/\(([^)]+)\)/);
if (
urlMatch &&
urlMatch[1] &&
!urlMatch[1].startsWith(`${basePath}/images/`) &&
urlMatch[1].startsWith('/images/')
) {
imageMarkdown = imageMarkdown.replace(urlMatch[1], `${basePath}${urlMatch[1]}`);
}
if (!responseMessage.text.includes(imageMarkdown)) {
responseMessage.text += '\n' + imageMarkdown;
logger.debug('[addImages] added image from intermediateSteps:', imageMarkdown);
}
} }
}); });
} }

View file

@ -74,7 +74,7 @@ describe('addImages', () => {
it('should append correctly from a real scenario', () => { it('should append correctly from a real scenario', () => {
responseMessage.text = responseMessage.text =
'Here is the generated image based on your request. It depicts a surreal landscape filled with floating musical notes. The style is impressionistic, with vibrant sunset hues dominating the scene. At the center, there\'s a silhouette of a grand piano, adding a dreamy emotion to the overall image. This could serve as a unique and creative music album cover. Would you like to make any changes or generate another image?'; "Here is the generated image based on your request. It depicts a surreal landscape filled with floating musical notes. The style is impressionistic, with vibrant sunset hues dominating the scene. At the center, there's a silhouette of a grand piano, adding a dreamy emotion to the overall image. This could serve as a unique and creative music album cover. Would you like to make any changes or generate another image?";
const originalText = responseMessage.text; const originalText = responseMessage.text;
const imageMarkdown = '![generated image](/images/img-RnVWaYo2Yg4x3e0isICiMuf5.png)'; const imageMarkdown = '![generated image](/images/img-RnVWaYo2Yg4x3e0isICiMuf5.png)';
intermediateSteps.push({ observation: imageMarkdown }); intermediateSteps.push({ observation: imageMarkdown });
@ -139,4 +139,108 @@ describe('addImages', () => {
addImages(intermediateSteps, responseMessage); addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe('\n![image1](/images/image1.png)'); expect(responseMessage.text).toBe('\n![image1](/images/image1.png)');
}); });
describe('basePath functionality', () => {
let originalDomainClient;
beforeEach(() => {
originalDomainClient = process.env.DOMAIN_CLIENT;
});
afterEach(() => {
process.env.DOMAIN_CLIENT = originalDomainClient;
});
it('should prepend base path to image URLs when DOMAIN_CLIENT is set', () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
intermediateSteps.push({ observation: '![desc](/images/test.png)' });
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe('\n![desc](/librechat/images/test.png)');
});
it('should not prepend base path when image URL already has base path', () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
intermediateSteps.push({ observation: '![desc](/librechat/images/test.png)' });
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe('\n![desc](/librechat/images/test.png)');
});
it('should correct erroneous URLs with base path', () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
responseMessage.text = '![desc](sandbox:/images/test.png)';
intermediateSteps.push({ observation: '![desc](/images/test.png)' });
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe('![desc](/librechat/images/test.png)');
});
it('should handle empty base path (root deployment)', () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/';
intermediateSteps.push({ observation: '![desc](/images/test.png)' });
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe('\n![desc](/images/test.png)');
});
it('should handle missing DOMAIN_CLIENT', () => {
delete process.env.DOMAIN_CLIENT;
intermediateSteps.push({ observation: '![desc](/images/test.png)' });
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe('\n![desc](/images/test.png)');
});
it('should handle observation without image path match', () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
intermediateSteps.push({ observation: '![desc](not-an-image-path)' });
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe('\n![desc](not-an-image-path)');
});
it('should handle nested subdirectories in base path', () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/apps/librechat';
intermediateSteps.push({ observation: '![desc](/images/test.png)' });
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe('\n![desc](/apps/librechat/images/test.png)');
});
it('should handle multiple observations with mixed base path scenarios', () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
intermediateSteps.push({ observation: '![desc1](/images/test1.png)' });
intermediateSteps.push({ observation: '![desc2](/librechat/images/test2.png)' });
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe(
'\n![desc1](/librechat/images/test1.png)\n![desc2](/librechat/images/test2.png)',
);
});
it('should handle complex markdown with base path', () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
const complexMarkdown = `
# Document Title
![image1](/images/image1.png)
Some text between images
![image2](/images/image2.png)
`;
intermediateSteps.push({ observation: complexMarkdown });
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe('\n![image1](/librechat/images/image1.png)');
});
it('should handle URLs that are already absolute', () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
intermediateSteps.push({ observation: '![desc](https://example.com/image.png)' });
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe('\n![desc](https://example.com/image.png)');
});
it('should handle data URLs', () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
intermediateSteps.push({
observation:
'![desc](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==)',
});
addImages(intermediateSteps, responseMessage);
expect(responseMessage.text).toBe(
'\n![desc](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==)',
);
});
});
}); });

View file

@ -130,7 +130,7 @@ describe('formatAgentMessages', () => {
content: [ content: [
{ {
type: ContentTypes.TEXT, type: ContentTypes.TEXT,
[ContentTypes.TEXT]: 'I\'ll search for that information.', [ContentTypes.TEXT]: "I'll search for that information.",
tool_call_ids: ['search_1'], tool_call_ids: ['search_1'],
}, },
{ {
@ -144,7 +144,7 @@ describe('formatAgentMessages', () => {
}, },
{ {
type: ContentTypes.TEXT, type: ContentTypes.TEXT,
[ContentTypes.TEXT]: 'Now, I\'ll convert the temperature.', [ContentTypes.TEXT]: "Now, I'll convert the temperature.",
tool_call_ids: ['convert_1'], tool_call_ids: ['convert_1'],
}, },
{ {
@ -156,7 +156,7 @@ describe('formatAgentMessages', () => {
output: '23.89°C', output: '23.89°C',
}, },
}, },
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Here\'s your answer.' }, { type: ContentTypes.TEXT, [ContentTypes.TEXT]: "Here's your answer." },
], ],
}, },
]; ];
@ -171,7 +171,7 @@ describe('formatAgentMessages', () => {
expect(result[4]).toBeInstanceOf(AIMessage); expect(result[4]).toBeInstanceOf(AIMessage);
// Check first AIMessage // Check first AIMessage
expect(result[0].content).toBe('I\'ll search for that information.'); expect(result[0].content).toBe("I'll search for that information.");
expect(result[0].tool_calls).toHaveLength(1); expect(result[0].tool_calls).toHaveLength(1);
expect(result[0].tool_calls[0]).toEqual({ expect(result[0].tool_calls[0]).toEqual({
id: 'search_1', id: 'search_1',
@ -187,7 +187,7 @@ describe('formatAgentMessages', () => {
); );
// Check second AIMessage // Check second AIMessage
expect(result[2].content).toBe('Now, I\'ll convert the temperature.'); expect(result[2].content).toBe("Now, I'll convert the temperature.");
expect(result[2].tool_calls).toHaveLength(1); expect(result[2].tool_calls).toHaveLength(1);
expect(result[2].tool_calls[0]).toEqual({ expect(result[2].tool_calls[0]).toEqual({
id: 'convert_1', id: 'convert_1',
@ -202,7 +202,7 @@ describe('formatAgentMessages', () => {
// Check final AIMessage // Check final AIMessage
expect(result[4].content).toStrictEqual([ expect(result[4].content).toStrictEqual([
{ [ContentTypes.TEXT]: 'Here\'s your answer.', type: ContentTypes.TEXT }, { [ContentTypes.TEXT]: "Here's your answer.", type: ContentTypes.TEXT },
]); ]);
}); });
@ -217,7 +217,7 @@ describe('formatAgentMessages', () => {
role: 'assistant', role: 'assistant',
content: [{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'How can I help you?' }], content: [{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'How can I help you?' }],
}, },
{ role: 'user', content: 'What\'s the weather?' }, { role: 'user', content: "What's the weather?" },
{ {
role: 'assistant', role: 'assistant',
content: [ content: [
@ -240,7 +240,7 @@ describe('formatAgentMessages', () => {
{ {
role: 'assistant', role: 'assistant',
content: [ content: [
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Here\'s the weather information.' }, { type: ContentTypes.TEXT, [ContentTypes.TEXT]: "Here's the weather information." },
], ],
}, },
]; ];
@ -265,12 +265,12 @@ describe('formatAgentMessages', () => {
{ [ContentTypes.TEXT]: 'How can I help you?', type: ContentTypes.TEXT }, { [ContentTypes.TEXT]: 'How can I help you?', type: ContentTypes.TEXT },
]); ]);
expect(result[2].content).toStrictEqual([ expect(result[2].content).toStrictEqual([
{ [ContentTypes.TEXT]: 'What\'s the weather?', type: ContentTypes.TEXT }, { [ContentTypes.TEXT]: "What's the weather?", type: ContentTypes.TEXT },
]); ]);
expect(result[3].content).toBe('Let me check that for you.'); expect(result[3].content).toBe('Let me check that for you.');
expect(result[4].content).toBe('Sunny, 75°F'); expect(result[4].content).toBe('Sunny, 75°F');
expect(result[5].content).toStrictEqual([ expect(result[5].content).toStrictEqual([
{ [ContentTypes.TEXT]: 'Here\'s the weather information.', type: ContentTypes.TEXT }, { [ContentTypes.TEXT]: "Here's the weather information.", type: ContentTypes.TEXT },
]); ]);
// Check that there are no consecutive AIMessages // Check that there are no consecutive AIMessages

View file

@ -82,7 +82,10 @@ const initializeFakeClient = (apiKey, options, fakeMessages) => {
}); });
TestClient.sendCompletion = jest.fn(async () => { TestClient.sendCompletion = jest.fn(async () => {
return 'Mock response text'; return {
completion: 'Mock response text',
metadata: undefined,
};
}); });
TestClient.getCompletion = jest.fn().mockImplementation(async (..._args) => { TestClient.getCompletion = jest.fn().mockImplementation(async (..._args) => {

View file

@ -8,6 +8,7 @@ const { v4: uuidv4 } = require('uuid');
const { Tool } = require('@langchain/core/tools'); const { Tool } = require('@langchain/core/tools');
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { FileContext, ContentTypes } = require('librechat-data-provider'); const { FileContext, ContentTypes } = require('librechat-data-provider');
const { getBasePath } = require('@librechat/api');
const paths = require('~/config/paths'); const paths = require('~/config/paths');
const displayMessage = const displayMessage =
@ -36,7 +37,7 @@ class StableDiffusionAPI extends Tool {
this.description_for_model = `// Generate images and visuals using text. this.description_for_model = `// Generate images and visuals using text.
// Guidelines: // Guidelines:
// - ALWAYS use {{"prompt": "7+ detailed keywords", "negative_prompt": "7+ detailed keywords"}} structure for queries. // - ALWAYS use {{"prompt": "7+ detailed keywords", "negative_prompt": "7+ detailed keywords"}} structure for queries.
// - ALWAYS include the markdown url in your final response to show the user: ![caption](/images/id.png) // - ALWAYS include the markdown url in your final response to show the user: ![caption](${getBasePath()}/images/id.png)
// - Visually describe the moods, details, structures, styles, and/or proportions of the image. Remember, the focus is on visual attributes. // - Visually describe the moods, details, structures, styles, and/or proportions of the image. Remember, the focus is on visual attributes.
// - Craft your input by "showing" and not "telling" the imagery. Think in terms of what you'd want to see in a photograph or a painting. // - Craft your input by "showing" and not "telling" the imagery. Think in terms of what you'd want to see in a photograph or a painting.
// - Here's an example for generating a realistic portrait photo of a man: // - Here's an example for generating a realistic portrait photo of a man:

View file

@ -1,4 +1,5 @@
const { z } = require('zod'); const { z } = require('zod');
const { ProxyAgent, fetch } = require('undici');
const { tool } = require('@langchain/core/tools'); const { tool } = require('@langchain/core/tools');
const { getApiKey } = require('./credentials'); const { getApiKey } = require('./credentials');
@ -19,13 +20,19 @@ function createTavilySearchTool(fields = {}) {
...kwargs, ...kwargs,
}; };
const response = await fetch('https://api.tavily.com/search', { const fetchOptions = {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(requestBody), body: JSON.stringify(requestBody),
}); };
if (process.env.PROXY) {
fetchOptions.dispatcher = new ProxyAgent(process.env.PROXY);
}
const response = await fetch('https://api.tavily.com/search', fetchOptions);
const json = await response.json(); const json = await response.json();
if (!response.ok) { if (!response.ok) {

View file

@ -1,4 +1,5 @@
const { z } = require('zod'); const { z } = require('zod');
const { ProxyAgent, fetch } = require('undici');
const { Tool } = require('@langchain/core/tools'); const { Tool } = require('@langchain/core/tools');
const { getEnvironmentVariable } = require('@langchain/core/utils/env'); const { getEnvironmentVariable } = require('@langchain/core/utils/env');
@ -102,13 +103,19 @@ class TavilySearchResults extends Tool {
...this.kwargs, ...this.kwargs,
}; };
const response = await fetch('https://api.tavily.com/search', { const fetchOptions = {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(requestBody), body: JSON.stringify(requestBody),
}); };
if (process.env.PROXY) {
fetchOptions.dispatcher = new ProxyAgent(process.env.PROXY);
}
const response = await fetch('https://api.tavily.com/search', fetchOptions);
const json = await response.json(); const json = await response.json();
if (!response.ok) { if (!response.ok) {

View file

@ -1,6 +1,7 @@
const { fetch, ProxyAgent } = require('undici');
const TavilySearchResults = require('../TavilySearchResults'); const TavilySearchResults = require('../TavilySearchResults');
jest.mock('node-fetch'); jest.mock('undici');
jest.mock('@langchain/core/utils/env'); jest.mock('@langchain/core/utils/env');
describe('TavilySearchResults', () => { describe('TavilySearchResults', () => {
@ -13,6 +14,7 @@ describe('TavilySearchResults', () => {
beforeEach(() => { beforeEach(() => {
jest.resetModules(); jest.resetModules();
jest.clearAllMocks();
process.env = { process.env = {
...originalEnv, ...originalEnv,
TAVILY_API_KEY: mockApiKey, TAVILY_API_KEY: mockApiKey,
@ -20,7 +22,6 @@ describe('TavilySearchResults', () => {
}); });
afterEach(() => { afterEach(() => {
jest.clearAllMocks();
process.env = originalEnv; process.env = originalEnv;
}); });
@ -35,4 +36,49 @@ describe('TavilySearchResults', () => {
}); });
expect(instance.apiKey).toBe(mockApiKey); expect(instance.apiKey).toBe(mockApiKey);
}); });
describe('proxy support', () => {
const mockResponse = {
ok: true,
json: jest.fn().mockResolvedValue({ results: [] }),
};
beforeEach(() => {
fetch.mockResolvedValue(mockResponse);
});
it('should use ProxyAgent when PROXY env var is set', async () => {
const proxyUrl = 'http://proxy.example.com:8080';
process.env.PROXY = proxyUrl;
const mockProxyAgent = { type: 'proxy-agent' };
ProxyAgent.mockImplementation(() => mockProxyAgent);
const instance = new TavilySearchResults({ TAVILY_API_KEY: mockApiKey });
await instance._call({ query: 'test query' });
expect(ProxyAgent).toHaveBeenCalledWith(proxyUrl);
expect(fetch).toHaveBeenCalledWith(
'https://api.tavily.com/search',
expect.objectContaining({
dispatcher: mockProxyAgent,
}),
);
});
it('should not use ProxyAgent when PROXY env var is not set', async () => {
delete process.env.PROXY;
const instance = new TavilySearchResults({ TAVILY_API_KEY: mockApiKey });
await instance._call({ query: 'test query' });
expect(ProxyAgent).not.toHaveBeenCalled();
expect(fetch).toHaveBeenCalledWith(
'https://api.tavily.com/search',
expect.not.objectContaining({
dispatcher: expect.anything(),
}),
);
});
});
}); });

View file

@ -78,15 +78,14 @@ const createFileSearchTool = async ({ userId, files, entity_id, fileCitations =
return tool( return tool(
async ({ query }) => { async ({ query }) => {
if (files.length === 0) { if (files.length === 0) {
return 'No files to search. Instruct the user to add files for the search.'; return ['No files to search. Instruct the user to add files for the search.', undefined];
} }
const jwtToken = generateShortLivedToken(userId); const jwtToken = generateShortLivedToken(userId);
if (!jwtToken) { if (!jwtToken) {
return 'There was an error authenticating the file search request.'; return ['There was an error authenticating the file search request.', undefined];
} }
/** /**
*
* @param {import('librechat-data-provider').TFile} file * @param {import('librechat-data-provider').TFile} file
* @returns {{ file_id: string, query: string, k: number, entity_id?: string }} * @returns {{ file_id: string, query: string, k: number, entity_id?: string }}
*/ */
@ -122,7 +121,7 @@ const createFileSearchTool = async ({ userId, files, entity_id, fileCitations =
const validResults = results.filter((result) => result !== null); const validResults = results.filter((result) => result !== null);
if (validResults.length === 0) { if (validResults.length === 0) {
return 'No results found or errors occurred while searching the files.'; return ['No results found or errors occurred while searching the files.', undefined];
} }
const formattedResults = validResults const formattedResults = validResults
@ -135,11 +134,16 @@ const createFileSearchTool = async ({ userId, files, entity_id, fileCitations =
page: docInfo.metadata.page || null, page: docInfo.metadata.page || null,
})), })),
) )
// TODO: results should be sorted by relevance, not distance
.sort((a, b) => a.distance - b.distance) .sort((a, b) => a.distance - b.distance)
// TODO: make this configurable
.slice(0, 10); .slice(0, 10);
if (formattedResults.length === 0) {
return [
'No content found in the files. The files may not have been processed correctly or you may need to refine your query.',
undefined,
];
}
const formattedString = formattedResults const formattedString = formattedResults
.map( .map(
(result, index) => (result, index) =>
@ -169,11 +173,12 @@ const createFileSearchTool = async ({ userId, files, entity_id, fileCitations =
? ` ? `
**CITE FILE SEARCH RESULTS:** **CITE FILE SEARCH RESULTS:**
Use anchor markers immediately after statements derived from file content. Reference the filename in your text: Use the EXACT anchor markers shown below (copy them verbatim) immediately after statements derived from file content. Reference the filename in your text:
- File citation: "The document.pdf states that... \\ue202turn0file0" - File citation: "The document.pdf states that... \\ue202turn0file0"
- Page reference: "According to report.docx... \\ue202turn0file1" - Page reference: "According to report.docx... \\ue202turn0file1"
- Multi-file: "Multiple sources confirm... \\ue200\\ue202turn0file0\\ue202turn0file1\\ue201" - Multi-file: "Multiple sources confirm... \\ue200\\ue202turn0file0\\ue202turn0file1\\ue201"
**CRITICAL:** Output these escape sequences EXACTLY as shown (e.g., \\ue202turn0file0). Do NOT substitute with other characters like or similar symbols.
**ALWAYS mention the filename in your text before the citation marker. NEVER use markdown links or footnotes.**` **ALWAYS mention the filename in your text before the citation marker. NEVER use markdown links or footnotes.**`
: '' : ''
}`, }`,

View file

@ -317,14 +317,22 @@ const loadTools = async ({
requestedTools[tool] = async () => { requestedTools[tool] = async () => {
toolContextMap[tool] = `# \`${tool}\`: toolContextMap[tool] = `# \`${tool}\`:
Current Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}' })} Current Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}' })}
1. **Execute immediately without preface** when using \`${tool}\`.
2. **After the search, begin with a brief summary** that directly addresses the query without headers or explaining your process. **Execute immediately without preface.** After search, provide a brief summary addressing the query directly, then structure your response with clear Markdown formatting (## headers, lists, tables). Cite sources properly, tailor tone to query type, and provide comprehensive details.
3. **Structure your response clearly** using Markdown formatting (Level 2 headers for sections, lists for multiple points, tables for comparisons).
4. **Cite sources properly** according to the citation anchor format, utilizing group anchors when appropriate. **CITATION FORMAT - UNICODE ESCAPE SEQUENCES ONLY:**
5. **Tailor your approach to the query type** (academic, news, coding, etc.) while maintaining an expert, journalistic, unbiased tone. Use these EXACT escape sequences (copy verbatim): \\ue202 (before each anchor), \\ue200 (group start), \\ue201 (group end), \\ue203 (highlight start), \\ue204 (highlight end)
6. **Provide comprehensive information** with specific details, examples, and as much relevant context as possible from search results.
7. **Avoid moralizing language.** Anchor pattern: \\ue202turn{N}{type}{index} where N=turn number, type=search|news|image|ref, index=0,1,2...
`.trim();
**Examples (copy these exactly):**
- Single: "Statement.\\ue202turn0search0"
- Multiple: "Statement.\\ue202turn0search0\\ue202turn0news1"
- Group: "Statement. \\ue200\\ue202turn0search0\\ue202turn0news1\\ue201"
- Highlight: "\\ue203Cited text.\\ue204\\ue202turn0search0"
- Image: "See photo\\ue202turn0image0."
**CRITICAL:** Output escape sequences EXACTLY as shown. Do NOT substitute with or other symbols. Place anchors AFTER punctuation. Cite every non-obvious fact/quote. NEVER use markdown links, [1], footnotes, or HTML tags.`.trim();
return createSearchTool({ return createSearchTool({
...result.authResult, ...result.authResult,
onSearchResults, onSearchResults,

View file

@ -12,8 +12,8 @@ const {
} = require('./Project'); } = require('./Project');
const { removeAllPermissions } = require('~/server/services/PermissionService'); const { removeAllPermissions } = require('~/server/services/PermissionService');
const { getMCPServerTools } = require('~/server/services/Config'); const { getMCPServerTools } = require('~/server/services/Config');
const { Agent, AclEntry } = require('~/db/models');
const { getActions } = require('./Action'); const { getActions } = require('./Action');
const { Agent } = require('~/db/models');
/** /**
* Create an agent with the provided data. * Create an agent with the provided data.
@ -539,6 +539,37 @@ const deleteAgent = async (searchParameter) => {
return agent; return agent;
}; };
/**
* Deletes all agents created by a specific user.
* @param {string} userId - The ID of the user whose agents should be deleted.
* @returns {Promise<void>} A promise that resolves when all user agents have been deleted.
*/
const deleteUserAgents = async (userId) => {
try {
const userAgents = await getAgents({ author: userId });
if (userAgents.length === 0) {
return;
}
const agentIds = userAgents.map((agent) => agent.id);
const agentObjectIds = userAgents.map((agent) => agent._id);
for (const agentId of agentIds) {
await removeAgentFromAllProjects(agentId);
}
await AclEntry.deleteMany({
resourceType: ResourceType.AGENT,
resourceId: { $in: agentObjectIds },
});
await Agent.deleteMany({ author: userId });
} catch (error) {
logger.error('[deleteUserAgents] General error:', error);
}
};
/** /**
* Get agents by accessible IDs with optional cursor-based pagination. * Get agents by accessible IDs with optional cursor-based pagination.
* @param {Object} params - The parameters for getting accessible agents. * @param {Object} params - The parameters for getting accessible agents.
@ -856,6 +887,7 @@ module.exports = {
createAgent, createAgent,
updateAgent, updateAgent,
deleteAgent, deleteAgent,
deleteUserAgents,
getListAgents, getListAgents,
revertAgentVersion, revertAgentVersion,
updateAgentProjects, updateAgentProjects,

View file

@ -346,8 +346,8 @@ async function getMessage({ user, messageId }) {
* *
* @async * @async
* @function deleteMessages * @function deleteMessages
* @param {Object} filter - The filter criteria to find messages to delete. * @param {import('mongoose').FilterQuery<import('mongoose').Document>} filter - The filter criteria to find messages to delete.
* @returns {Promise<Object>} The metadata with count of deleted messages. * @returns {Promise<import('mongoose').DeleteResult>} The metadata with count of deleted messages.
* @throws {Error} If there is an error in deleting messages. * @throws {Error} If there is an error in deleting messages.
*/ */
async function deleteMessages(filter) { async function deleteMessages(filter) {

View file

@ -1,4 +1,5 @@
const { ObjectId } = require('mongodb'); const { ObjectId } = require('mongodb');
const { escapeRegExp } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { const {
Constants, Constants,
@ -13,8 +14,7 @@ const {
getProjectByName, getProjectByName,
} = require('./Project'); } = require('./Project');
const { removeAllPermissions } = require('~/server/services/PermissionService'); const { removeAllPermissions } = require('~/server/services/PermissionService');
const { PromptGroup, Prompt } = require('~/db/models'); const { PromptGroup, Prompt, AclEntry } = require('~/db/models');
const { escapeRegExp } = require('~/server/utils');
/** /**
* Create a pipeline for the aggregation to get prompt groups * Create a pipeline for the aggregation to get prompt groups
@ -591,6 +591,36 @@ module.exports = {
return { prompt: 'Prompt deleted successfully' }; return { prompt: 'Prompt deleted successfully' };
} }
}, },
/**
* Delete all prompts and prompt groups created by a specific user.
* @param {ServerRequest} req - The server request object.
* @param {string} userId - The ID of the user whose prompts and prompt groups are to be deleted.
*/
deleteUserPrompts: async (req, userId) => {
try {
const promptGroups = await getAllPromptGroups(req, { author: new ObjectId(userId) });
if (promptGroups.length === 0) {
return;
}
const groupIds = promptGroups.map((group) => group._id);
for (const groupId of groupIds) {
await removeGroupFromAllProjects(groupId);
}
await AclEntry.deleteMany({
resourceType: ResourceType.PROMPTGROUP,
resourceId: { $in: groupIds },
});
await PromptGroup.deleteMany({ author: new ObjectId(userId) });
await Prompt.deleteMany({ author: new ObjectId(userId) });
} catch (error) {
logger.error('[deleteUserPrompts] General error:', error);
}
},
/** /**
* Update prompt group * Update prompt group
* @param {Partial<MongoPromptGroup>} filter - Filter to find prompt group * @param {Partial<MongoPromptGroup>} filter - Filter to find prompt group

View file

@ -136,10 +136,12 @@ const tokenValues = Object.assign(
'claude-3.7-sonnet': { prompt: 3, completion: 15 }, 'claude-3.7-sonnet': { prompt: 3, completion: 15 },
'claude-haiku-4-5': { prompt: 1, completion: 5 }, 'claude-haiku-4-5': { prompt: 1, completion: 5 },
'claude-opus-4': { prompt: 15, completion: 75 }, 'claude-opus-4': { prompt: 15, completion: 75 },
'claude-opus-4-5': { prompt: 5, completion: 25 },
'claude-sonnet-4': { prompt: 3, completion: 15 }, 'claude-sonnet-4': { prompt: 3, completion: 15 },
'command-r': { prompt: 0.5, completion: 1.5 }, 'command-r': { prompt: 0.5, completion: 1.5 },
'command-r-plus': { prompt: 3, completion: 15 }, 'command-r-plus': { prompt: 3, completion: 15 },
'command-text': { prompt: 1.5, completion: 2.0 }, 'command-text': { prompt: 1.5, completion: 2.0 },
'deepseek-chat': { prompt: 0.28, completion: 0.42 },
'deepseek-reasoner': { prompt: 0.28, completion: 0.42 }, 'deepseek-reasoner': { prompt: 0.28, completion: 0.42 },
'deepseek-r1': { prompt: 0.4, completion: 2.0 }, 'deepseek-r1': { prompt: 0.4, completion: 2.0 },
'deepseek-v3': { prompt: 0.2, completion: 0.8 }, 'deepseek-v3': { prompt: 0.2, completion: 0.8 },
@ -156,6 +158,7 @@ const tokenValues = Object.assign(
'gemini-2.5-flash': { prompt: 0.3, completion: 2.5 }, 'gemini-2.5-flash': { prompt: 0.3, completion: 2.5 },
'gemini-2.5-flash-lite': { prompt: 0.1, completion: 0.4 }, 'gemini-2.5-flash-lite': { prompt: 0.1, completion: 0.4 },
'gemini-2.5-pro': { prompt: 1.25, completion: 10 }, 'gemini-2.5-pro': { prompt: 1.25, completion: 10 },
'gemini-3': { prompt: 2, completion: 12 },
'gemini-pro-vision': { prompt: 0.5, completion: 1.5 }, 'gemini-pro-vision': { prompt: 0.5, completion: 1.5 },
grok: { prompt: 2.0, completion: 10.0 }, // Base pattern defaults to grok-2 grok: { prompt: 2.0, completion: 10.0 }, // Base pattern defaults to grok-2
'grok-beta': { prompt: 5.0, completion: 15.0 }, 'grok-beta': { prompt: 5.0, completion: 15.0 },
@ -171,6 +174,9 @@ const tokenValues = Object.assign(
'grok-3-mini': { prompt: 0.3, completion: 0.5 }, 'grok-3-mini': { prompt: 0.3, completion: 0.5 },
'grok-3-mini-fast': { prompt: 0.6, completion: 4 }, 'grok-3-mini-fast': { prompt: 0.6, completion: 4 },
'grok-4': { prompt: 3.0, completion: 15.0 }, 'grok-4': { prompt: 3.0, completion: 15.0 },
'grok-4-fast': { prompt: 0.2, completion: 0.5 },
'grok-4-1-fast': { prompt: 0.2, completion: 0.5 }, // covers reasoning & non-reasoning variants
'grok-code-fast': { prompt: 0.2, completion: 1.5 },
codestral: { prompt: 0.3, completion: 0.9 }, codestral: { prompt: 0.3, completion: 0.9 },
'ministral-3b': { prompt: 0.04, completion: 0.04 }, 'ministral-3b': { prompt: 0.04, completion: 0.04 },
'ministral-8b': { prompt: 0.1, completion: 0.1 }, 'ministral-8b': { prompt: 0.1, completion: 0.1 },
@ -237,8 +243,14 @@ const cacheTokenValues = {
'claude-3.5-haiku': { write: 1, read: 0.08 }, 'claude-3.5-haiku': { write: 1, read: 0.08 },
'claude-3-5-haiku': { write: 1, read: 0.08 }, 'claude-3-5-haiku': { write: 1, read: 0.08 },
'claude-3-haiku': { write: 0.3, read: 0.03 }, 'claude-3-haiku': { write: 0.3, read: 0.03 },
'claude-haiku-4-5': { write: 1.25, read: 0.1 },
'claude-sonnet-4': { write: 3.75, read: 0.3 }, 'claude-sonnet-4': { write: 3.75, read: 0.3 },
'claude-opus-4': { write: 18.75, read: 1.5 }, 'claude-opus-4': { write: 18.75, read: 1.5 },
'claude-opus-4-5': { write: 6.25, read: 0.5 },
// DeepSeek models - cache hit: $0.028/1M, cache miss: $0.28/1M
deepseek: { write: 0.28, read: 0.028 },
'deepseek-chat': { write: 0.28, read: 0.028 },
'deepseek-reasoner': { write: 0.28, read: 0.028 },
}; };
/** /**

View file

@ -766,6 +766,78 @@ describe('Deepseek Model Tests', () => {
const result = tokenValues[valueKey].prompt && multiplier === tokenValues[valueKey].prompt; const result = tokenValues[valueKey].prompt && multiplier === tokenValues[valueKey].prompt;
expect(result).toBe(true); expect(result).toBe(true);
}); });
it('should return correct pricing for deepseek-chat', () => {
expect(getMultiplier({ model: 'deepseek-chat', tokenType: 'prompt' })).toBe(
tokenValues['deepseek-chat'].prompt,
);
expect(getMultiplier({ model: 'deepseek-chat', tokenType: 'completion' })).toBe(
tokenValues['deepseek-chat'].completion,
);
expect(tokenValues['deepseek-chat'].prompt).toBe(0.28);
expect(tokenValues['deepseek-chat'].completion).toBe(0.42);
});
it('should return correct pricing for deepseek-reasoner', () => {
expect(getMultiplier({ model: 'deepseek-reasoner', tokenType: 'prompt' })).toBe(
tokenValues['deepseek-reasoner'].prompt,
);
expect(getMultiplier({ model: 'deepseek-reasoner', tokenType: 'completion' })).toBe(
tokenValues['deepseek-reasoner'].completion,
);
expect(tokenValues['deepseek-reasoner'].prompt).toBe(0.28);
expect(tokenValues['deepseek-reasoner'].completion).toBe(0.42);
});
it('should handle DeepSeek model name variations with provider prefixes', () => {
const modelVariations = [
'deepseek/deepseek-chat',
'openrouter/deepseek-chat',
'deepseek/deepseek-reasoner',
];
modelVariations.forEach((model) => {
const promptMultiplier = getMultiplier({ model, tokenType: 'prompt' });
const completionMultiplier = getMultiplier({ model, tokenType: 'completion' });
expect(promptMultiplier).toBe(0.28);
expect(completionMultiplier).toBe(0.42);
});
});
it('should return correct cache multipliers for DeepSeek models', () => {
expect(getCacheMultiplier({ model: 'deepseek-chat', cacheType: 'write' })).toBe(
cacheTokenValues['deepseek-chat'].write,
);
expect(getCacheMultiplier({ model: 'deepseek-chat', cacheType: 'read' })).toBe(
cacheTokenValues['deepseek-chat'].read,
);
expect(getCacheMultiplier({ model: 'deepseek-reasoner', cacheType: 'write' })).toBe(
cacheTokenValues['deepseek-reasoner'].write,
);
expect(getCacheMultiplier({ model: 'deepseek-reasoner', cacheType: 'read' })).toBe(
cacheTokenValues['deepseek-reasoner'].read,
);
});
it('should return correct cache pricing values for DeepSeek models', () => {
expect(cacheTokenValues['deepseek-chat'].write).toBe(0.28);
expect(cacheTokenValues['deepseek-chat'].read).toBe(0.028);
expect(cacheTokenValues['deepseek-reasoner'].write).toBe(0.28);
expect(cacheTokenValues['deepseek-reasoner'].read).toBe(0.028);
expect(cacheTokenValues['deepseek'].write).toBe(0.28);
expect(cacheTokenValues['deepseek'].read).toBe(0.028);
});
it('should handle DeepSeek cache multipliers with model variations', () => {
const modelVariations = ['deepseek/deepseek-chat', 'openrouter/deepseek-reasoner'];
modelVariations.forEach((model) => {
const writeMultiplier = getCacheMultiplier({ model, cacheType: 'write' });
const readMultiplier = getCacheMultiplier({ model, cacheType: 'read' });
expect(writeMultiplier).toBe(0.28);
expect(readMultiplier).toBe(0.028);
});
});
}); });
describe('Qwen3 Model Tests', () => { describe('Qwen3 Model Tests', () => {
@ -1040,6 +1112,7 @@ describe('getCacheMultiplier', () => {
describe('Google Model Tests', () => { describe('Google Model Tests', () => {
const googleModels = [ const googleModels = [
'gemini-3',
'gemini-2.5-pro', 'gemini-2.5-pro',
'gemini-2.5-flash', 'gemini-2.5-flash',
'gemini-2.5-flash-lite', 'gemini-2.5-flash-lite',
@ -1083,6 +1156,7 @@ describe('Google Model Tests', () => {
it('should map to the correct model keys', () => { it('should map to the correct model keys', () => {
const expected = { const expected = {
'gemini-3': 'gemini-3',
'gemini-2.5-pro': 'gemini-2.5-pro', 'gemini-2.5-pro': 'gemini-2.5-pro',
'gemini-2.5-flash': 'gemini-2.5-flash', 'gemini-2.5-flash': 'gemini-2.5-flash',
'gemini-2.5-flash-lite': 'gemini-2.5-flash-lite', 'gemini-2.5-flash-lite': 'gemini-2.5-flash-lite',
@ -1203,6 +1277,39 @@ describe('Grok Model Tests - Pricing', () => {
); );
}); });
test('should return correct prompt and completion rates for Grok 4 Fast model', () => {
expect(getMultiplier({ model: 'grok-4-fast', tokenType: 'prompt' })).toBe(
tokenValues['grok-4-fast'].prompt,
);
expect(getMultiplier({ model: 'grok-4-fast', tokenType: 'completion' })).toBe(
tokenValues['grok-4-fast'].completion,
);
});
test('should return correct prompt and completion rates for Grok 4.1 Fast models', () => {
expect(getMultiplier({ model: 'grok-4-1-fast-reasoning', tokenType: 'prompt' })).toBe(
tokenValues['grok-4-1-fast'].prompt,
);
expect(getMultiplier({ model: 'grok-4-1-fast-reasoning', tokenType: 'completion' })).toBe(
tokenValues['grok-4-1-fast'].completion,
);
expect(getMultiplier({ model: 'grok-4-1-fast-non-reasoning', tokenType: 'prompt' })).toBe(
tokenValues['grok-4-1-fast'].prompt,
);
expect(getMultiplier({ model: 'grok-4-1-fast-non-reasoning', tokenType: 'completion' })).toBe(
tokenValues['grok-4-1-fast'].completion,
);
});
test('should return correct prompt and completion rates for Grok Code Fast model', () => {
expect(getMultiplier({ model: 'grok-code-fast-1', tokenType: 'prompt' })).toBe(
tokenValues['grok-code-fast'].prompt,
);
expect(getMultiplier({ model: 'grok-code-fast-1', tokenType: 'completion' })).toBe(
tokenValues['grok-code-fast'].completion,
);
});
test('should return correct prompt and completion rates for Grok 3 models with prefixes', () => { test('should return correct prompt and completion rates for Grok 3 models with prefixes', () => {
expect(getMultiplier({ model: 'xai/grok-3', tokenType: 'prompt' })).toBe( expect(getMultiplier({ model: 'xai/grok-3', tokenType: 'prompt' })).toBe(
tokenValues['grok-3'].prompt, tokenValues['grok-3'].prompt,
@ -1238,6 +1345,39 @@ describe('Grok Model Tests - Pricing', () => {
tokenValues['grok-4'].completion, tokenValues['grok-4'].completion,
); );
}); });
test('should return correct prompt and completion rates for Grok 4 Fast model with prefixes', () => {
expect(getMultiplier({ model: 'xai/grok-4-fast', tokenType: 'prompt' })).toBe(
tokenValues['grok-4-fast'].prompt,
);
expect(getMultiplier({ model: 'xai/grok-4-fast', tokenType: 'completion' })).toBe(
tokenValues['grok-4-fast'].completion,
);
});
test('should return correct prompt and completion rates for Grok 4.1 Fast models with prefixes', () => {
expect(getMultiplier({ model: 'xai/grok-4-1-fast-reasoning', tokenType: 'prompt' })).toBe(
tokenValues['grok-4-1-fast'].prompt,
);
expect(getMultiplier({ model: 'xai/grok-4-1-fast-reasoning', tokenType: 'completion' })).toBe(
tokenValues['grok-4-1-fast'].completion,
);
expect(getMultiplier({ model: 'xai/grok-4-1-fast-non-reasoning', tokenType: 'prompt' })).toBe(
tokenValues['grok-4-1-fast'].prompt,
);
expect(
getMultiplier({ model: 'xai/grok-4-1-fast-non-reasoning', tokenType: 'completion' }),
).toBe(tokenValues['grok-4-1-fast'].completion);
});
test('should return correct prompt and completion rates for Grok Code Fast model with prefixes', () => {
expect(getMultiplier({ model: 'xai/grok-code-fast-1', tokenType: 'prompt' })).toBe(
tokenValues['grok-code-fast'].prompt,
);
expect(getMultiplier({ model: 'xai/grok-code-fast-1', tokenType: 'completion' })).toBe(
tokenValues['grok-code-fast'].completion,
);
});
}); });
}); });
@ -1370,6 +1510,15 @@ describe('Claude Model Tests', () => {
); );
}); });
it('should return correct prompt and completion rates for Claude Opus 4.5', () => {
expect(getMultiplier({ model: 'claude-opus-4-5', tokenType: 'prompt' })).toBe(
tokenValues['claude-opus-4-5'].prompt,
);
expect(getMultiplier({ model: 'claude-opus-4-5', tokenType: 'completion' })).toBe(
tokenValues['claude-opus-4-5'].completion,
);
});
it('should handle Claude Haiku 4.5 model name variations', () => { it('should handle Claude Haiku 4.5 model name variations', () => {
const modelVariations = [ const modelVariations = [
'claude-haiku-4-5', 'claude-haiku-4-5',
@ -1392,6 +1541,28 @@ describe('Claude Model Tests', () => {
}); });
}); });
it('should handle Claude Opus 4.5 model name variations', () => {
const modelVariations = [
'claude-opus-4-5',
'claude-opus-4-5-20250420',
'claude-opus-4-5-latest',
'anthropic/claude-opus-4-5',
'claude-opus-4-5/anthropic',
'claude-opus-4-5-preview',
];
modelVariations.forEach((model) => {
const valueKey = getValueKey(model);
expect(valueKey).toBe('claude-opus-4-5');
expect(getMultiplier({ model, tokenType: 'prompt' })).toBe(
tokenValues['claude-opus-4-5'].prompt,
);
expect(getMultiplier({ model, tokenType: 'completion' })).toBe(
tokenValues['claude-opus-4-5'].completion,
);
});
});
it('should handle Claude 4 model name variations with different prefixes and suffixes', () => { it('should handle Claude 4 model name variations with different prefixes and suffixes', () => {
const modelVariations = [ const modelVariations = [
'claude-sonnet-4', 'claude-sonnet-4',
@ -1438,6 +1609,15 @@ describe('Claude Model Tests', () => {
); );
}); });
it('should return correct cache rates for Claude Opus 4.5', () => {
expect(getCacheMultiplier({ model: 'claude-opus-4-5', cacheType: 'write' })).toBe(
cacheTokenValues['claude-opus-4-5'].write,
);
expect(getCacheMultiplier({ model: 'claude-opus-4-5', cacheType: 'read' })).toBe(
cacheTokenValues['claude-opus-4-5'].read,
);
});
it('should handle Claude 4 model cache rates with different prefixes and suffixes', () => { it('should handle Claude 4 model cache rates with different prefixes and suffixes', () => {
const modelVariations = [ const modelVariations = [
'claude-sonnet-4', 'claude-sonnet-4',

View file

@ -1,6 +1,6 @@
{ {
"name": "@librechat/backend", "name": "@librechat/backend",
"version": "v0.8.1-rc1", "version": "v0.8.1",
"description": "", "description": "",
"scripts": { "scripts": {
"start": "echo 'please run this from the root directory'", "start": "echo 'please run this from the root directory'",
@ -47,7 +47,7 @@
"@langchain/google-genai": "^0.2.13", "@langchain/google-genai": "^0.2.13",
"@langchain/google-vertexai": "^0.2.13", "@langchain/google-vertexai": "^0.2.13",
"@langchain/textsplitters": "^0.1.0", "@langchain/textsplitters": "^0.1.0",
"@librechat/agents": "^3.0.17", "@librechat/agents": "^3.0.50",
"@librechat/api": "*", "@librechat/api": "*",
"@librechat/data-schemas": "*", "@librechat/data-schemas": "*",
"@microsoft/microsoft-graph-client": "^3.0.7", "@microsoft/microsoft-graph-client": "^3.0.7",
@ -92,7 +92,7 @@
"multer": "^2.0.2", "multer": "^2.0.2",
"nanoid": "^3.3.7", "nanoid": "^3.3.7",
"node-fetch": "^2.7.0", "node-fetch": "^2.7.0",
"nodemailer": "^7.0.9", "nodemailer": "^7.0.11",
"ollama": "^0.5.0", "ollama": "^0.5.0",
"openai": "5.8.2", "openai": "5.8.2",
"openid-client": "^6.5.0", "openid-client": "^6.5.0",

View file

@ -350,6 +350,9 @@ function disposeClient(client) {
if (client.agentConfigs) { if (client.agentConfigs) {
client.agentConfigs = null; client.agentConfigs = null;
} }
if (client.agentIdMap) {
client.agentIdMap = null;
}
if (client.artifactPromises) { if (client.artifactPromises) {
client.artifactPromises = null; client.artifactPromises = null;
} }

View file

@ -82,7 +82,15 @@ const refreshController = async (req, res) => {
if (error || !user) { if (error || !user) {
return res.status(401).redirect('/login'); return res.status(401).redirect('/login');
} }
const token = setOpenIDAuthTokens(tokenset, res, user._id.toString()); const token = setOpenIDAuthTokens(tokenset, res, user._id.toString(), refreshToken);
user.federatedTokens = {
access_token: tokenset.access_token,
id_token: tokenset.id_token,
refresh_token: refreshToken,
expires_at: claims.exp,
};
return res.status(200).send({ token, user }); return res.status(200).send({ token, user });
} catch (error) { } catch (error) {
logger.error('[refreshController] OpenID token refresh error', error); logger.error('[refreshController] OpenID token refresh error', error);

View file

@ -3,32 +3,45 @@ const { Tools, CacheKeys, Constants, FileSources } = require('librechat-data-pro
const { const {
MCPOAuthHandler, MCPOAuthHandler,
MCPTokenStorage, MCPTokenStorage,
mcpServersRegistry,
normalizeHttpError, normalizeHttpError,
extractWebSearchEnvVars, extractWebSearchEnvVars,
} = require('@librechat/api'); } = require('@librechat/api');
const { const {
getFiles,
findToken,
updateUser,
deleteFiles,
deleteConvos,
deletePresets,
deleteMessages,
deleteUserById,
deleteAllSharedLinks,
deleteAllUserSessions, deleteAllUserSessions,
deleteAllSharedLinks,
deleteUserById,
deleteMessages,
deletePresets,
deleteConvos,
deleteFiles,
updateUser,
findToken,
getFiles,
} = require('~/models'); } = require('~/models');
const {
ConversationTag,
Transaction,
MemoryEntry,
Assistant,
AclEntry,
Balance,
Action,
Group,
Token,
User,
} = require('~/db/models');
const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService'); const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService');
const { updateUserPluginsService, deleteUserKey } = require('~/server/services/UserService'); const { updateUserPluginsService, deleteUserKey } = require('~/server/services/UserService');
const { verifyEmail, resendVerificationEmail } = require('~/server/services/AuthService'); const { verifyEmail, resendVerificationEmail } = require('~/server/services/AuthService');
const { needsRefresh, getNewS3URL } = require('~/server/services/Files/S3/crud'); const { needsRefresh, getNewS3URL } = require('~/server/services/Files/S3/crud');
const { processDeleteRequest } = require('~/server/services/Files/process'); const { processDeleteRequest } = require('~/server/services/Files/process');
const { Transaction, Balance, User, Token } = require('~/db/models');
const { getMCPManager, getFlowStateManager } = require('~/config'); const { getMCPManager, getFlowStateManager } = require('~/config');
const { getAppConfig } = require('~/server/services/Config'); const { getAppConfig } = require('~/server/services/Config');
const { deleteToolCalls } = require('~/models/ToolCall'); const { deleteToolCalls } = require('~/models/ToolCall');
const { deleteUserPrompts } = require('~/models/Prompt');
const { deleteUserAgents } = require('~/models/Agent');
const { getLogStores } = require('~/cache'); const { getLogStores } = require('~/cache');
const { mcpServersRegistry } = require('@librechat/api');
const getUserController = async (req, res) => { const getUserController = async (req, res) => {
const appConfig = await getAppConfig({ role: req.user?.role }); const appConfig = await getAppConfig({ role: req.user?.role });
@ -237,7 +250,6 @@ const deleteUserController = async (req, res) => {
await deleteUserKey({ userId: user.id, all: true }); // delete user keys await deleteUserKey({ userId: user.id, all: true }); // delete user keys
await Balance.deleteMany({ user: user._id }); // delete user balances await Balance.deleteMany({ user: user._id }); // delete user balances
await deletePresets(user.id); // delete user presets await deletePresets(user.id); // delete user presets
/* TODO: Delete Assistant Threads */
try { try {
await deleteConvos(user.id); // delete user convos await deleteConvos(user.id); // delete user convos
} catch (error) { } catch (error) {
@ -249,7 +261,19 @@ const deleteUserController = async (req, res) => {
await deleteUserFiles(req); // delete user files await deleteUserFiles(req); // delete user files
await deleteFiles(null, user.id); // delete database files in case of orphaned files from previous steps await deleteFiles(null, user.id); // delete database files in case of orphaned files from previous steps
await deleteToolCalls(user.id); // delete user tool calls await deleteToolCalls(user.id); // delete user tool calls
/* TODO: queue job for cleaning actions and assistants of non-existant users */ await deleteUserAgents(user.id); // delete user agents
await Assistant.deleteMany({ user: user.id }); // delete user assistants
await ConversationTag.deleteMany({ user: user.id }); // delete user conversation tags
await MemoryEntry.deleteMany({ userId: user.id }); // delete user memory entries
await deleteUserPrompts(req, user.id); // delete user prompts
await Action.deleteMany({ user: user.id }); // delete user actions
await Token.deleteMany({ userId: user.id }); // delete user OAuth tokens
await Group.updateMany(
// remove user from all groups
{ memberIds: user.id },
{ $pull: { memberIds: user.id } },
);
await AclEntry.deleteMany({ principalId: user._id }); // delete user ACL entries
logger.info(`User deleted account. Email: ${user.email} ID: ${user.id}`); logger.info(`User deleted account. Email: ${user.email} ID: ${user.id}`);
res.status(200).send({ message: 'User deleted' }); res.status(200).send({ message: 'User deleted' });
} catch (err) { } catch (err) {

View file

@ -39,9 +39,9 @@ class ModelEndHandler {
* @param {ModelEndData | undefined} data * @param {ModelEndData | undefined} data
* @param {Record<string, unknown> | undefined} metadata * @param {Record<string, unknown> | undefined} metadata
* @param {StandardGraph} graph * @param {StandardGraph} graph
* @returns * @returns {Promise<void>}
*/ */
handle(event, data, metadata, graph) { async handle(event, data, metadata, graph) {
if (!graph || !metadata) { if (!graph || !metadata) {
console.warn(`Graph or metadata not found in ${event} event`); console.warn(`Graph or metadata not found in ${event} event`);
return; return;
@ -79,7 +79,7 @@ class ModelEndHandler {
} }
} }
if (isGoogle || streamingDisabled || hasUnprocessedToolCalls) { if (isGoogle || streamingDisabled || hasUnprocessedToolCalls) {
handleToolCalls(toolCalls, metadata, graph); await handleToolCalls(toolCalls, metadata, graph);
} }
const usage = data?.output?.usage_metadata; const usage = data?.output?.usage_metadata;
@ -101,7 +101,7 @@ class ModelEndHandler {
const stepKey = graph.getStepKey(metadata); const stepKey = graph.getStepKey(metadata);
const message_id = getMessageId(stepKey, graph) ?? ''; const message_id = getMessageId(stepKey, graph) ?? '';
if (message_id) { if (message_id) {
graph.dispatchRunStep(stepKey, { await graph.dispatchRunStep(stepKey, {
type: StepTypes.MESSAGE_CREATION, type: StepTypes.MESSAGE_CREATION,
message_creation: { message_creation: {
message_id, message_id,
@ -111,7 +111,7 @@ class ModelEndHandler {
const stepId = graph.getStepIdByKey(stepKey); const stepId = graph.getStepIdByKey(stepKey);
const content = data.output.content; const content = data.output.content;
if (typeof content === 'string') { if (typeof content === 'string') {
graph.dispatchMessageDelta(stepId, { await graph.dispatchMessageDelta(stepId, {
content: [ content: [
{ {
type: 'text', type: 'text',
@ -120,7 +120,7 @@ class ModelEndHandler {
], ],
}); });
} else if (content.every((c) => c.type?.startsWith('text'))) { } else if (content.every((c) => c.type?.startsWith('text'))) {
graph.dispatchMessageDelta(stepId, { await graph.dispatchMessageDelta(stepId, {
content, content,
}); });
} }
@ -162,7 +162,7 @@ function getDefaultHandlers({ res, aggregateContent, toolEndCallback, collectedU
} }
const handlers = { const handlers = {
[GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(collectedUsage), [GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(collectedUsage),
[GraphEvents.TOOL_END]: new ToolEndHandler(toolEndCallback), [GraphEvents.TOOL_END]: new ToolEndHandler(toolEndCallback, logger),
[GraphEvents.CHAT_MODEL_STREAM]: new ChatModelStreamHandler(), [GraphEvents.CHAT_MODEL_STREAM]: new ChatModelStreamHandler(),
[GraphEvents.ON_RUN_STEP]: { [GraphEvents.ON_RUN_STEP]: {
/** /**

View file

@ -9,6 +9,7 @@ const {
logAxiosError, logAxiosError,
sanitizeTitle, sanitizeTitle,
resolveHeaders, resolveHeaders,
createSafeUser,
getBalanceConfig, getBalanceConfig,
memoryInstructions, memoryInstructions,
getTransactionsConfig, getTransactionsConfig,
@ -20,6 +21,7 @@ const {
Providers, Providers,
TitleMethod, TitleMethod,
formatMessage, formatMessage,
labelContentByAgent,
formatAgentMessages, formatAgentMessages,
getTokenCountForMessage, getTokenCountForMessage,
createMetadataAggregator, createMetadataAggregator,
@ -92,6 +94,61 @@ function logToolError(graph, error, toolId) {
}); });
} }
/**
* Applies agent labeling to conversation history when multi-agent patterns are detected.
* Labels content parts by their originating agent to prevent identity confusion.
*
* @param {TMessage[]} orderedMessages - The ordered conversation messages
* @param {Agent} primaryAgent - The primary agent configuration
* @param {Map<string, Agent>} agentConfigs - Map of additional agent configurations
* @returns {TMessage[]} Messages with agent labels applied where appropriate
*/
function applyAgentLabelsToHistory(orderedMessages, primaryAgent, agentConfigs) {
const shouldLabelByAgent = (primaryAgent.edges?.length ?? 0) > 0 || (agentConfigs?.size ?? 0) > 0;
if (!shouldLabelByAgent) {
return orderedMessages;
}
const processedMessages = [];
for (let i = 0; i < orderedMessages.length; i++) {
const message = orderedMessages[i];
/** @type {Record<string, string>} */
const agentNames = { [primaryAgent.id]: primaryAgent.name || 'Assistant' };
if (agentConfigs) {
for (const [agentId, agentConfig] of agentConfigs.entries()) {
agentNames[agentId] = agentConfig.name || agentConfig.id;
}
}
if (
!message.isCreatedByUser &&
message.metadata?.agentIdMap &&
Array.isArray(message.content)
) {
try {
const labeledContent = labelContentByAgent(
message.content,
message.metadata.agentIdMap,
agentNames,
);
processedMessages.push({ ...message, content: labeledContent });
} catch (error) {
logger.error('[AgentClient] Error applying agent labels to message:', error);
processedMessages.push(message);
}
} else {
processedMessages.push(message);
}
}
return processedMessages;
}
class AgentClient extends BaseClient { class AgentClient extends BaseClient {
constructor(options = {}) { constructor(options = {}) {
super(null, options); super(null, options);
@ -141,6 +198,8 @@ class AgentClient extends BaseClient {
this.indexTokenCountMap = {}; this.indexTokenCountMap = {};
/** @type {(messages: BaseMessage[]) => Promise<void>} */ /** @type {(messages: BaseMessage[]) => Promise<void>} */
this.processMemory; this.processMemory;
/** @type {Record<number, string> | null} */
this.agentIdMap = null;
} }
/** /**
@ -233,6 +292,12 @@ class AgentClient extends BaseClient {
summary: this.shouldSummarize, summary: this.shouldSummarize,
}); });
orderedMessages = applyAgentLabelsToHistory(
orderedMessages,
this.options.agent,
this.agentConfigs,
);
let payload; let payload;
/** @type {number | undefined} */ /** @type {number | undefined} */
let promptTokens; let promptTokens;
@ -612,7 +677,11 @@ class AgentClient extends BaseClient {
userMCPAuthMap: opts.userMCPAuthMap, userMCPAuthMap: opts.userMCPAuthMap,
abortController: opts.abortController, abortController: opts.abortController,
}); });
return filterMalformedContentParts(this.contentParts);
const completion = filterMalformedContentParts(this.contentParts);
const metadata = this.agentIdMap ? { agentIdMap: this.agentIdMap } : undefined;
return { completion, metadata };
} }
/** /**
@ -788,7 +857,7 @@ class AgentClient extends BaseClient {
conversationId: this.conversationId, conversationId: this.conversationId,
parentMessageId: this.parentMessageId, parentMessageId: this.parentMessageId,
}, },
user: this.options.req.user, user: createSafeUser(this.options.req.user),
}, },
recursionLimit: agentsEConfig?.recursionLimit ?? 25, recursionLimit: agentsEConfig?.recursionLimit ?? 25,
signal: abortController.signal, signal: abortController.signal,
@ -864,6 +933,7 @@ class AgentClient extends BaseClient {
signal: abortController.signal, signal: abortController.signal,
customHandlers: this.options.eventHandlers, customHandlers: this.options.eventHandlers,
requestBody: config.configurable.requestBody, requestBody: config.configurable.requestBody,
user: createSafeUser(this.options.req?.user),
tokenCounter: createTokenCounter(this.getEncoding()), tokenCounter: createTokenCounter(this.getEncoding()),
}); });
@ -902,6 +972,24 @@ class AgentClient extends BaseClient {
); );
}); });
} }
try {
/** Capture agent ID map if we have edges or multiple agents */
const shouldStoreAgentMap =
(this.options.agent.edges?.length ?? 0) > 0 || (this.agentConfigs?.size ?? 0) > 0;
if (shouldStoreAgentMap && run?.Graph) {
const contentPartAgentMap = run.Graph.getContentPartAgentMap();
if (contentPartAgentMap && contentPartAgentMap.size > 0) {
this.agentIdMap = Object.fromEntries(contentPartAgentMap);
logger.debug('[AgentClient] Captured agent ID map:', {
totalParts: this.contentParts.length,
mappedParts: Object.keys(this.agentIdMap).length,
});
}
}
} catch (error) {
logger.error('[AgentClient] Error capturing agent ID map:', error);
}
} catch (err) { } catch (err) {
logger.error( logger.error(
'[api/server/controllers/agents/client.js #sendCompletion] Operation aborted', '[api/server/controllers/agents/client.js #sendCompletion] Operation aborted',
@ -935,6 +1023,9 @@ class AgentClient extends BaseClient {
err, err,
); );
} }
run = null;
config = null;
memoryPromise = null;
} }
} }
@ -1063,6 +1154,7 @@ class AgentClient extends BaseClient {
if (clientOptions?.configuration?.defaultHeaders != null) { if (clientOptions?.configuration?.defaultHeaders != null) {
clientOptions.configuration.defaultHeaders = resolveHeaders({ clientOptions.configuration.defaultHeaders = resolveHeaders({
headers: clientOptions.configuration.defaultHeaders, headers: clientOptions.configuration.defaultHeaders,
user: createSafeUser(this.options.req?.user),
body: { body: {
messageId: this.responseMessageId, messageId: this.responseMessageId,
conversationId: this.conversationId, conversationId: this.conversationId,

View file

@ -1,6 +1,10 @@
const { sendEvent } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { Constants } = require('librechat-data-provider'); const { Constants } = require('librechat-data-provider');
const {
sendEvent,
sanitizeFileForTransmit,
sanitizeMessageForTransmit,
} = require('@librechat/api');
const { const {
handleAbortError, handleAbortError,
createAbortController, createAbortController,
@ -224,13 +228,13 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
conversation.title = conversation.title =
conversation && !conversation.title ? null : conversation?.title || 'New Chat'; conversation && !conversation.title ? null : conversation?.title || 'New Chat';
// Process files if needed // Process files if needed (sanitize to remove large text fields before transmission)
if (req.body.files && client.options?.attachments) { if (req.body.files && client.options?.attachments) {
userMessage.files = []; userMessage.files = [];
const messageFiles = new Set(req.body.files.map((file) => file.file_id)); const messageFiles = new Set(req.body.files.map((file) => file.file_id));
for (let attachment of client.options.attachments) { for (const attachment of client.options.attachments) {
if (messageFiles.has(attachment.file_id)) { if (messageFiles.has(attachment.file_id)) {
userMessage.files.push({ ...attachment }); userMessage.files.push(sanitizeFileForTransmit(attachment));
} }
} }
delete userMessage.image_urls; delete userMessage.image_urls;
@ -245,7 +249,7 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
final: true, final: true,
conversation, conversation,
title: conversation.title, title: conversation.title,
requestMessage: userMessage, requestMessage: sanitizeMessageForTransmit(userMessage),
responseMessage: finalResponse, responseMessage: finalResponse,
}); });
res.end(); res.end();
@ -273,7 +277,7 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
final: true, final: true,
conversation, conversation,
title: conversation.title, title: conversation.title,
requestMessage: userMessage, requestMessage: sanitizeMessageForTransmit(userMessage),
responseMessage: finalResponse, responseMessage: finalResponse,
error: { message: 'Request was aborted during completion' }, error: { message: 'Request was aborted during completion' },
}); });

View file

@ -11,7 +11,6 @@ const {
const { const {
Tools, Tools,
Constants, Constants,
SystemRoles,
FileSources, FileSources,
ResourceType, ResourceType,
AccessRoleIds, AccessRoleIds,
@ -20,6 +19,8 @@ const {
PermissionBits, PermissionBits,
actionDelimiter, actionDelimiter,
removeNullishValues, removeNullishValues,
CacheKeys,
Time,
} = require('librechat-data-provider'); } = require('librechat-data-provider');
const { const {
getListAgentsByAccess, getListAgentsByAccess,
@ -45,6 +46,7 @@ const { updateAction, getActions } = require('~/models/Action');
const { getCachedTools } = require('~/server/services/Config'); const { getCachedTools } = require('~/server/services/Config');
const { deleteFileByFilter } = require('~/models/File'); const { deleteFileByFilter } = require('~/models/File');
const { getCategoriesWithCounts } = require('~/models'); const { getCategoriesWithCounts } = require('~/models');
const { getLogStores } = require('~/cache');
const systemTools = { const systemTools = {
[Tools.execute_code]: true, [Tools.execute_code]: true,
@ -52,6 +54,49 @@ const systemTools = {
[Tools.web_search]: true, [Tools.web_search]: true,
}; };
const MAX_SEARCH_LEN = 100;
const escapeRegex = (str = '') => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
/**
* Opportunistically refreshes S3-backed avatars for agent list responses.
* Only list responses are refreshed because they're the highest-traffic surface and
* the avatar URLs have a short-lived TTL. The refresh is cached per-user for 30 minutes
* via {@link CacheKeys.S3_EXPIRY_INTERVAL} so we refresh once per interval at most.
* @param {Array} agents - Agents being enriched with S3-backed avatars
* @param {string} userId - User identifier used for the cache refresh key
*/
const refreshListAvatars = async (agents, userId) => {
if (!agents?.length) {
return;
}
const cache = getLogStores(CacheKeys.S3_EXPIRY_INTERVAL);
const refreshKey = `${userId}:agents_list`;
const alreadyChecked = await cache.get(refreshKey);
if (alreadyChecked) {
return;
}
await Promise.all(
agents.map(async (agent) => {
if (agent?.avatar?.source !== FileSources.s3 || !agent?.avatar?.filepath) {
return;
}
try {
const newPath = await refreshS3Url(agent.avatar);
if (newPath && newPath !== agent.avatar.filepath) {
agent.avatar = { ...agent.avatar, filepath: newPath };
}
} catch (err) {
logger.debug('[/Agents] Avatar refresh error for list item', err);
}
}),
);
await cache.set(refreshKey, true, Time.THIRTY_MINUTES);
};
/** /**
* Creates an Agent. * Creates an Agent.
* @route POST /Agents * @route POST /Agents
@ -142,10 +187,13 @@ const getAgentHandler = async (req, res, expandProperties = false) => {
agent.version = agent.versions ? agent.versions.length : 0; agent.version = agent.versions ? agent.versions.length : 0;
if (agent.avatar && agent.avatar?.source === FileSources.s3) { if (agent.avatar && agent.avatar?.source === FileSources.s3) {
const originalUrl = agent.avatar.filepath; try {
agent.avatar.filepath = await refreshS3Url(agent.avatar); agent.avatar = {
if (originalUrl !== agent.avatar.filepath) { ...agent.avatar,
await updateAgent({ id }, { avatar: agent.avatar }, { updatingUserId: req.user.id }); filepath: await refreshS3Url(agent.avatar),
};
} catch (e) {
logger.warn('[/Agents/:id] Failed to refresh S3 URL', e);
} }
} }
@ -209,7 +257,12 @@ const updateAgentHandler = async (req, res) => {
try { try {
const id = req.params.id; const id = req.params.id;
const validatedData = agentUpdateSchema.parse(req.body); const validatedData = agentUpdateSchema.parse(req.body);
const { _id, ...updateData } = removeNullishValues(validatedData); // Preserve explicit null for avatar to allow resetting the avatar
const { avatar: avatarField, _id, ...rest } = validatedData;
const updateData = removeNullishValues(rest);
if (avatarField === null) {
updateData.avatar = avatarField;
}
// Convert OCR to context in incoming updateData // Convert OCR to context in incoming updateData
convertOcrToContextInPlace(updateData); convertOcrToContextInPlace(updateData);
@ -342,21 +395,21 @@ const duplicateAgentHandler = async (req, res) => {
const [domain] = action.action_id.split(actionDelimiter); const [domain] = action.action_id.split(actionDelimiter);
const fullActionId = `${domain}${actionDelimiter}${newActionId}`; const fullActionId = `${domain}${actionDelimiter}${newActionId}`;
// Sanitize sensitive metadata before persisting
const filteredMetadata = { ...(action.metadata || {}) };
for (const field of sensitiveFields) {
delete filteredMetadata[field];
}
const newAction = await updateAction( const newAction = await updateAction(
{ action_id: newActionId }, { action_id: newActionId },
{ {
metadata: action.metadata, metadata: filteredMetadata,
agent_id: newAgentId, agent_id: newAgentId,
user: userId, user: userId,
}, },
); );
const filteredMetadata = { ...newAction.metadata };
for (const field of sensitiveFields) {
delete filteredMetadata[field];
}
newAction.metadata = filteredMetadata;
newActionsList.push(newAction); newActionsList.push(newAction);
return fullActionId; return fullActionId;
}; };
@ -463,13 +516,13 @@ const getListAgentsHandler = async (req, res) => {
filter.is_promoted = { $ne: true }; filter.is_promoted = { $ne: true };
} }
// Handle search filter // Handle search filter (escape regex and cap length)
if (search && search.trim() !== '') { if (search && search.trim() !== '') {
filter.$or = [ const safeSearch = escapeRegex(search.trim().slice(0, MAX_SEARCH_LEN));
{ name: { $regex: search.trim(), $options: 'i' } }, const regex = new RegExp(safeSearch, 'i');
{ description: { $regex: search.trim(), $options: 'i' } }, filter.$or = [{ name: regex }, { description: regex }];
];
} }
// Get agent IDs the user has VIEW access to via ACL // Get agent IDs the user has VIEW access to via ACL
const accessibleIds = await findAccessibleResources({ const accessibleIds = await findAccessibleResources({
userId, userId,
@ -477,10 +530,12 @@ const getListAgentsHandler = async (req, res) => {
resourceType: ResourceType.AGENT, resourceType: ResourceType.AGENT,
requiredPermissions: requiredPermission, requiredPermissions: requiredPermission,
}); });
const publiclyAccessibleIds = await findPubliclyAccessibleResources({ const publiclyAccessibleIds = await findPubliclyAccessibleResources({
resourceType: ResourceType.AGENT, resourceType: ResourceType.AGENT,
requiredPermissions: PermissionBits.VIEW, requiredPermissions: PermissionBits.VIEW,
}); });
// Use the new ACL-aware function // Use the new ACL-aware function
const data = await getListAgentsByAccess({ const data = await getListAgentsByAccess({
accessibleIds, accessibleIds,
@ -488,13 +543,31 @@ const getListAgentsHandler = async (req, res) => {
limit, limit,
after: cursor, after: cursor,
}); });
if (data?.data?.length) {
data.data = data.data.map((agent) => { const agents = data?.data ?? [];
if (publiclyAccessibleIds.some((id) => id.equals(agent._id))) { if (!agents.length) {
return res.json(data);
}
const publicSet = new Set(publiclyAccessibleIds.map((oid) => oid.toString()));
data.data = agents.map((agent) => {
try {
if (agent?._id && publicSet.has(agent._id.toString())) {
agent.isPublic = true; agent.isPublic = true;
} }
return agent; } catch (e) {
}); // Silently ignore mapping errors
void e;
}
return agent;
});
// Opportunistically refresh S3 avatar URLs for list results with caching
try {
await refreshListAvatars(data.data, req.user.id);
} catch (err) {
logger.debug('[/Agents] Skipping avatar refresh for list', err);
} }
return res.json(data); return res.json(data);
} catch (error) { } catch (error) {
@ -517,28 +590,21 @@ const getListAgentsHandler = async (req, res) => {
const uploadAgentAvatarHandler = async (req, res) => { const uploadAgentAvatarHandler = async (req, res) => {
try { try {
const appConfig = req.config; const appConfig = req.config;
if (!req.file) {
return res.status(400).json({ message: 'No file uploaded' });
}
filterFile({ req, file: req.file, image: true, isAvatar: true }); filterFile({ req, file: req.file, image: true, isAvatar: true });
const { agent_id } = req.params; const { agent_id } = req.params;
if (!agent_id) { if (!agent_id) {
return res.status(400).json({ message: 'Agent ID is required' }); return res.status(400).json({ message: 'Agent ID is required' });
} }
const isAdmin = req.user.role === SystemRoles.ADMIN;
const existingAgent = await getAgent({ id: agent_id }); const existingAgent = await getAgent({ id: agent_id });
if (!existingAgent) { if (!existingAgent) {
return res.status(404).json({ error: 'Agent not found' }); return res.status(404).json({ error: 'Agent not found' });
} }
const isAuthor = existingAgent.author.toString() === req.user.id.toString();
const hasEditPermission = existingAgent.isCollaborative || isAdmin || isAuthor;
if (!hasEditPermission) {
return res.status(403).json({
error: 'You do not have permission to modify this non-collaborative agent',
});
}
const buffer = await fs.readFile(req.file.path); const buffer = await fs.readFile(req.file.path);
const fileStrategy = getFileStrategy(appConfig, { isAvatar: true }); const fileStrategy = getFileStrategy(appConfig, { isAvatar: true });
const resizedBuffer = await resizeAvatar({ const resizedBuffer = await resizeAvatar({
@ -571,8 +637,6 @@ const uploadAgentAvatarHandler = async (req, res) => {
} }
} }
const promises = [];
const data = { const data = {
avatar: { avatar: {
filepath: image.filepath, filepath: image.filepath,
@ -580,17 +644,16 @@ const uploadAgentAvatarHandler = async (req, res) => {
}, },
}; };
promises.push( const updatedAgent = await updateAgent({ id: agent_id }, data, {
await updateAgent({ id: agent_id }, data, { updatingUserId: req.user.id,
updatingUserId: req.user.id, });
}), res.status(201).json(updatedAgent);
);
const resolved = await Promise.all(promises);
res.status(201).json(resolved[0]);
} catch (error) { } catch (error) {
const message = 'An error occurred while updating the Agent Avatar'; const message = 'An error occurred while updating the Agent Avatar';
logger.error(message, error); logger.error(
`[/:agent_id/avatar] ${message} (${req.params?.agent_id ?? 'unknown agent'})`,
error,
);
res.status(500).json({ message }); res.status(500).json({ message });
} finally { } finally {
try { try {
@ -629,21 +692,13 @@ const revertAgentVersionHandler = async (req, res) => {
return res.status(400).json({ error: 'version_index is required' }); return res.status(400).json({ error: 'version_index is required' });
} }
const isAdmin = req.user.role === SystemRoles.ADMIN;
const existingAgent = await getAgent({ id }); const existingAgent = await getAgent({ id });
if (!existingAgent) { if (!existingAgent) {
return res.status(404).json({ error: 'Agent not found' }); return res.status(404).json({ error: 'Agent not found' });
} }
const isAuthor = existingAgent.author.toString() === req.user.id.toString(); // Permissions are enforced via route middleware (ACL EDIT)
const hasEditPermission = existingAgent.isCollaborative || isAdmin || isAuthor;
if (!hasEditPermission) {
return res.status(403).json({
error: 'You do not have permission to modify this non-collaborative agent',
});
}
const updatedAgent = await revertAgentVersion({ id }, version_index); const updatedAgent = await revertAgentVersion({ id }, version_index);

View file

@ -47,6 +47,7 @@ jest.mock('~/server/services/PermissionService', () => ({
findPubliclyAccessibleResources: jest.fn().mockResolvedValue([]), findPubliclyAccessibleResources: jest.fn().mockResolvedValue([]),
grantPermission: jest.fn(), grantPermission: jest.fn(),
hasPublicPermission: jest.fn().mockResolvedValue(false), hasPublicPermission: jest.fn().mockResolvedValue(false),
checkPermission: jest.fn().mockResolvedValue(true),
})); }));
jest.mock('~/models', () => ({ jest.mock('~/models', () => ({
@ -573,6 +574,68 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
expect(updatedAgent.version).toBe(agentInDb.versions.length); expect(updatedAgent.version).toBe(agentInDb.versions.length);
}); });
test('should allow resetting avatar when value is explicitly null', async () => {
await Agent.updateOne(
{ id: existingAgentId },
{
avatar: {
filepath: 'https://example.com/avatar.png',
source: 's3',
},
},
);
mockReq.user.id = existingAgentAuthorId.toString();
mockReq.params.id = existingAgentId;
mockReq.body = {
avatar: null,
};
await updateAgentHandler(mockReq, mockRes);
const updatedAgent = mockRes.json.mock.calls[0][0];
expect(updatedAgent.avatar).toBeNull();
const agentInDb = await Agent.findOne({ id: existingAgentId });
expect(agentInDb.avatar).toBeNull();
});
test('should ignore avatar field when value is undefined', async () => {
const originalAvatar = {
filepath: 'https://example.com/original.png',
source: 's3',
};
await Agent.updateOne({ id: existingAgentId }, { avatar: originalAvatar });
mockReq.user.id = existingAgentAuthorId.toString();
mockReq.params.id = existingAgentId;
mockReq.body = {
avatar: undefined,
};
await updateAgentHandler(mockReq, mockRes);
const agentInDb = await Agent.findOne({ id: existingAgentId });
expect(agentInDb.avatar.filepath).toBe(originalAvatar.filepath);
expect(agentInDb.avatar.source).toBe(originalAvatar.source);
});
test('should not bump version when no mutable fields change', async () => {
const existingAgent = await Agent.findOne({ id: existingAgentId });
const originalVersionCount = existingAgent.versions.length;
mockReq.user.id = existingAgentAuthorId.toString();
mockReq.params.id = existingAgentId;
mockReq.body = {
avatar: undefined,
};
await updateAgentHandler(mockReq, mockRes);
const agentInDb = await Agent.findOne({ id: existingAgentId });
expect(agentInDb.versions.length).toBe(originalVersionCount);
});
test('should handle validation errors properly', async () => { test('should handle validation errors properly', async () => {
mockReq.user.id = existingAgentAuthorId.toString(); mockReq.user.id = existingAgentAuthorId.toString();
mockReq.params.id = existingAgentId; mockReq.params.id = existingAgentId;

View file

@ -1,7 +1,7 @@
const { v4 } = require('uuid'); const { v4 } = require('uuid');
const { sleep } = require('@librechat/agents'); const { sleep } = require('@librechat/agents');
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { sendEvent, getBalanceConfig, getModelMaxTokens } = require('@librechat/api'); const { sendEvent, getBalanceConfig, getModelMaxTokens, countTokens } = require('@librechat/api');
const { const {
Time, Time,
Constants, Constants,
@ -33,7 +33,6 @@ const { getTransactions } = require('~/models/Transaction');
const { checkBalance } = require('~/models/balanceMethods'); const { checkBalance } = require('~/models/balanceMethods');
const { getConvo } = require('~/models/Conversation'); const { getConvo } = require('~/models/Conversation');
const getLogStores = require('~/cache/getLogStores'); const getLogStores = require('~/cache/getLogStores');
const { countTokens } = require('~/server/utils');
const { getOpenAIClient } = require('./helpers'); const { getOpenAIClient } = require('./helpers');
/** /**

View file

@ -1,7 +1,7 @@
const { v4 } = require('uuid'); const { v4 } = require('uuid');
const { sleep } = require('@librechat/agents'); const { sleep } = require('@librechat/agents');
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { sendEvent, getBalanceConfig, getModelMaxTokens } = require('@librechat/api'); const { sendEvent, getBalanceConfig, getModelMaxTokens, countTokens } = require('@librechat/api');
const { const {
Time, Time,
Constants, Constants,
@ -30,7 +30,6 @@ const { getTransactions } = require('~/models/Transaction');
const { checkBalance } = require('~/models/balanceMethods'); const { checkBalance } = require('~/models/balanceMethods');
const { getConvo } = require('~/models/Conversation'); const { getConvo } = require('~/models/Conversation');
const getLogStores = require('~/cache/getLogStores'); const getLogStores = require('~/cache/getLogStores');
const { countTokens } = require('~/server/utils');
const { getOpenAIClient } = require('./helpers'); const { getOpenAIClient } = require('./helpers');
/** /**

View file

@ -44,7 +44,13 @@ const getMCPTools = async (req, res) => {
continue; continue;
} }
const serverTools = await mcpManager.getServerToolFunctions(userId, serverName); let serverTools;
try {
serverTools = await mcpManager.getServerToolFunctions(userId, serverName);
} catch (error) {
logger.error(`[getMCPTools] Error fetching tools for server ${serverName}:`, error);
continue;
}
if (!serverTools) { if (!serverTools) {
logger.debug(`[getMCPTools] No tools found for server ${serverName}`); logger.debug(`[getMCPTools] No tools found for server ${serverName}`);
continue; continue;

417
api/server/experimental.js Normal file
View file

@ -0,0 +1,417 @@
require('dotenv').config();
const fs = require('fs');
const path = require('path');
require('module-alias')({ base: path.resolve(__dirname, '..') });
const cluster = require('cluster');
const Redis = require('ioredis');
const cors = require('cors');
const axios = require('axios');
const express = require('express');
const passport = require('passport');
const compression = require('compression');
const cookieParser = require('cookie-parser');
const { logger } = require('@librechat/data-schemas');
const mongoSanitize = require('express-mongo-sanitize');
const {
isEnabled,
ErrorController,
performStartupChecks,
handleJsonParseError,
initializeFileStorage,
} = require('@librechat/api');
const { connectDb, indexSync } = require('~/db');
const initializeOAuthReconnectManager = require('./services/initializeOAuthReconnectManager');
const createValidateImageRequest = require('./middleware/validateImageRequest');
const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies');
const { updateInterfacePermissions } = require('~/models/interface');
const { checkMigrations } = require('./services/start/migration');
const initializeMCPs = require('./services/initializeMCPs');
const configureSocialLogins = require('./socialLogins');
const { getAppConfig } = require('./services/Config');
const staticCache = require('./utils/staticCache');
const noIndex = require('./middleware/noIndex');
const { seedDatabase } = require('~/models');
const routes = require('./routes');
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;
/** Number of worker processes to spawn (simulating multiple pods) */
const workers = Number(process.env.CLUSTER_WORKERS) || 4;
/** Helper to wrap log messages for better visibility */
const wrapLogMessage = (msg) => {
return `\n${'='.repeat(50)}\n${msg}\n${'='.repeat(50)}`;
};
/**
* Flushes the Redis cache on startup
* This ensures a clean state for testing multi-pod MCP connection issues
*/
const flushRedisCache = async () => {
/** Skip cache flush if Redis is not enabled */
if (!isEnabled(process.env.USE_REDIS)) {
logger.info('Redis is not enabled, skipping cache flush');
return;
}
const redisConfig = {
host: process.env.REDIS_HOST || 'localhost',
port: process.env.REDIS_PORT || 6379,
};
if (process.env.REDIS_PASSWORD) {
redisConfig.password = process.env.REDIS_PASSWORD;
}
/** Handle Redis Cluster configuration */
if (isEnabled(process.env.USE_REDIS_CLUSTER) || process.env.REDIS_URI?.includes(',')) {
logger.info('Detected Redis Cluster configuration');
const uris = process.env.REDIS_URI?.split(',').map((uri) => {
const url = new URL(uri.trim());
return {
host: url.hostname,
port: parseInt(url.port || '6379', 10),
};
});
const redis = new Redis.Cluster(uris, {
redisOptions: {
password: process.env.REDIS_PASSWORD,
},
});
try {
logger.info('Attempting to connect to Redis Cluster...');
await redis.ping();
logger.info('Connected to Redis Cluster. Executing flushall...');
const result = await Promise.race([
redis.flushall(),
new Promise((_, reject) => setTimeout(() => reject(new Error('Flush timeout')), 10000)),
]);
logger.info('Redis Cluster cache flushed successfully', { result });
} catch (err) {
logger.error('Error while flushing Redis Cluster cache:', err);
throw err;
} finally {
redis.disconnect();
}
return;
}
/** Handle single Redis instance */
const redis = new Redis(redisConfig);
try {
logger.info('Attempting to connect to Redis...');
await redis.ping();
logger.info('Connected to Redis. Executing flushall...');
const result = await Promise.race([
redis.flushall(),
new Promise((_, reject) => setTimeout(() => reject(new Error('Flush timeout')), 5000)),
]);
logger.info('Redis cache flushed successfully', { result });
} catch (err) {
logger.error('Error while flushing Redis cache:', err);
throw err;
} finally {
redis.disconnect();
}
};
/**
* Master process
* Manages worker processes and handles graceful shutdowns
*/
if (cluster.isMaster) {
logger.info(wrapLogMessage(`Master ${process.pid} is starting...`));
logger.info(`Spawning ${workers} workers to simulate multi-pod environment`);
let activeWorkers = 0;
const startTime = Date.now();
/** Flush Redis cache before starting workers */
flushRedisCache()
.then(() => {
logger.info('Cache flushed, forking workers...');
for (let i = 0; i < workers; i++) {
cluster.fork();
}
})
.catch((err) => {
logger.error('Unable to flush Redis cache, not forking workers:', err);
process.exit(1);
});
/** Track worker lifecycle */
cluster.on('online', (worker) => {
activeWorkers++;
const uptime = ((Date.now() - startTime) / 1000).toFixed(2);
logger.info(
`Worker ${worker.process.pid} is online (${activeWorkers}/${workers}) after ${uptime}s`,
);
/** Notify the last worker to perform one-time initialization tasks */
if (activeWorkers === workers) {
const allWorkers = Object.values(cluster.workers);
const lastWorker = allWorkers[allWorkers.length - 1];
if (lastWorker) {
logger.info(wrapLogMessage(`All ${workers} workers are online`));
lastWorker.send({ type: 'last-worker' });
}
}
});
cluster.on('exit', (worker, code, signal) => {
activeWorkers--;
logger.error(
`Worker ${worker.process.pid} died (${activeWorkers}/${workers}). Code: ${code}, Signal: ${signal}`,
);
logger.info('Starting a new worker to replace it...');
cluster.fork();
});
/** Graceful shutdown on SIGTERM/SIGINT */
const shutdown = () => {
logger.info('Master received shutdown signal, terminating workers...');
for (const id in cluster.workers) {
cluster.workers[id].kill();
}
setTimeout(() => {
logger.info('Forcing shutdown after timeout');
process.exit(0);
}, 10000);
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
} else {
/**
* Worker process
* Each worker runs a full Express server instance
*/
const app = express();
const startServer = async () => {
logger.info(`Worker ${process.pid} initializing...`);
if (typeof Bun !== 'undefined') {
axios.defaults.headers.common['Accept-Encoding'] = 'gzip';
}
/** Connect to MongoDB */
await connectDb();
logger.info(`Worker ${process.pid}: Connected to MongoDB`);
/** Background index sync (non-blocking) */
indexSync().catch((err) => {
logger.error(`[Worker ${process.pid}][indexSync] Background sync failed:`, err);
});
app.disable('x-powered-by');
app.set('trust proxy', trusted_proxy);
/** Seed database (idempotent) */
await seedDatabase();
/** Initialize app configuration */
const appConfig = await getAppConfig();
initializeFileStorage(appConfig);
await performStartupChecks(appConfig);
await updateInterfacePermissions(appConfig);
/** Load index.html for SPA serving */
const indexPath = path.join(appConfig.paths.dist, 'index.html');
let indexHTML = fs.readFileSync(indexPath, 'utf8');
/** Support serving in subdirectory if DOMAIN_CLIENT is set */
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}"`);
}
}
/** Health check endpoint */
app.get('/health', (_req, res) => res.status(200).send('OK'));
/** Middleware */
app.use(noIndex);
app.use(express.json({ limit: '3mb' }));
app.use(express.urlencoded({ extended: true, limit: '3mb' }));
app.use(handleJsonParseError);
app.use(mongoSanitize());
app.use(cors());
app.use(cookieParser());
if (!isEnabled(DISABLE_COMPRESSION)) {
app.use(compression());
} else {
logger.warn('Response compression has been disabled via DISABLE_COMPRESSION.');
}
app.use(staticCache(appConfig.paths.dist));
app.use(staticCache(appConfig.paths.fonts));
app.use(staticCache(appConfig.paths.assets));
if (!ALLOW_SOCIAL_LOGIN) {
logger.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);
}
/** Routes */
app.use('/oauth', routes.oauth);
app.use('/api/auth', routes.auth);
app.use('/api/actions', routes.actions);
app.use('/api/keys', routes.keys);
app.use('/api/user', routes.user);
app.use('/api/search', routes.search);
app.use('/api/edit', routes.edit);
app.use('/api/messages', routes.messages);
app.use('/api/convos', routes.convos);
app.use('/api/presets', routes.presets);
app.use('/api/prompts', routes.prompts);
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/plugins', routes.plugins);
app.use('/api/config', routes.config);
app.use('/api/assistants', routes.assistants);
app.use('/api/files', await routes.files.initialize());
app.use('/images/', createValidateImageRequest(appConfig.secureImageLinks), routes.staticRoute);
app.use('/api/share', routes.share);
app.use('/api/roles', routes.roles);
app.use('/api/agents', routes.agents);
app.use('/api/banner', routes.banner);
app.use('/api/memories', routes.memories);
app.use('/api/permissions', routes.accessPermissions);
app.use('/api/tags', routes.tags);
app.use('/api/mcp', routes.mcp);
/** Error handler */
app.use(ErrorController);
/** SPA fallback - serve index.html for all unmatched routes */
app.use((req, res) => {
res.set({
'Cache-Control': process.env.INDEX_CACHE_CONTROL || 'no-cache, no-store, must-revalidate',
Pragma: process.env.INDEX_PRAGMA || 'no-cache',
Expires: process.env.INDEX_EXPIRES || '0',
});
const lang = req.cookies.lang || req.headers['accept-language']?.split(',')[0] || 'en-US';
const saneLang = lang.replace(/"/g, '&quot;');
let updatedIndexHtml = indexHTML.replace(/lang="en-US"/g, `lang="${saneLang}"`);
res.type('html');
res.send(updatedIndexHtml);
});
/** Start listening on shared port (cluster will distribute connections) */
app.listen(port, host, async () => {
logger.info(
`Worker ${process.pid} started: Server listening at http://${
host == '0.0.0.0' ? 'localhost' : host
}:${port}`,
);
/** Initialize MCP servers and OAuth reconnection for this worker */
await initializeMCPs();
await initializeOAuthReconnectManager();
await checkMigrations();
});
/** Handle inter-process messages from master */
process.on('message', async (msg) => {
if (msg.type === 'last-worker') {
logger.info(
wrapLogMessage(
`Worker ${process.pid} is the last worker and can perform special initialization tasks`,
),
);
/** Add any one-time initialization tasks here */
/** For example: scheduled jobs, cleanup tasks, etc. */
}
});
};
startServer().catch((err) => {
logger.error(`Failed to start worker ${process.pid}:`, err);
process.exit(1);
});
/** Export app for testing purposes (only available in worker processes) */
module.exports = app;
}
/**
* Uncaught exception handler
* Filters out known non-critical errors
*/
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;
}
process.exit(1);
});

View file

@ -14,6 +14,7 @@ const {
isEnabled, isEnabled,
ErrorController, ErrorController,
performStartupChecks, performStartupChecks,
handleJsonParseError,
initializeFileStorage, initializeFileStorage,
} = require('@librechat/api'); } = require('@librechat/api');
const { connectDb, indexSync } = require('~/db'); const { connectDb, indexSync } = require('~/db');
@ -81,6 +82,7 @@ const startServer = async () => {
app.use(noIndex); app.use(noIndex);
app.use(express.json({ limit: '3mb' })); app.use(express.json({ limit: '3mb' }));
app.use(express.urlencoded({ extended: true, limit: '3mb' })); app.use(express.urlencoded({ extended: true, limit: '3mb' }));
app.use(handleJsonParseError);
app.use(mongoSanitize()); app.use(mongoSanitize());
app.use(cors()); app.use(cors());
app.use(cookieParser()); app.use(cookieParser());
@ -126,7 +128,6 @@ const startServer = async () => {
app.use('/api/presets', routes.presets); app.use('/api/presets', routes.presets);
app.use('/api/prompts', routes.prompts); app.use('/api/prompts', routes.prompts);
app.use('/api/categories', routes.categories); app.use('/api/categories', routes.categories);
app.use('/api/tokenizer', routes.tokenizer);
app.use('/api/endpoints', routes.endpoints); app.use('/api/endpoints', routes.endpoints);
app.use('/api/balance', routes.balance); app.use('/api/balance', routes.balance);
app.use('/api/models', routes.models); app.use('/api/models', routes.models);
@ -213,6 +214,17 @@ process.on('uncaughtException', (err) => {
return; 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;
}
process.exit(1); process.exit(1);
}); });

View file

@ -1,5 +1,5 @@
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { countTokens, isEnabled, sendEvent } = require('@librechat/api'); const { countTokens, isEnabled, sendEvent, sanitizeMessageForTransmit } = require('@librechat/api');
const { isAssistantsEndpoint, ErrorTypes, Constants } = require('librechat-data-provider'); const { isAssistantsEndpoint, ErrorTypes, Constants } = require('librechat-data-provider');
const { truncateText, smartTruncateText } = require('~/app/clients/prompts'); const { truncateText, smartTruncateText } = require('~/app/clients/prompts');
const clearPendingReq = require('~/cache/clearPendingReq'); const clearPendingReq = require('~/cache/clearPendingReq');
@ -290,7 +290,7 @@ const createAbortController = (req, res, getAbortData, getReqData) => {
title: conversation && !conversation.title ? null : conversation?.title || 'New Chat', title: conversation && !conversation.title ? null : conversation?.title || 'New Chat',
final: true, final: true,
conversation, conversation,
requestMessage: userMessage, requestMessage: sanitizeMessageForTransmit(userMessage),
responseMessage: responseMessage, responseMessage: responseMessage,
}; };
}; };

View file

@ -61,18 +61,24 @@ async function buildEndpointOption(req, res, next) {
try { try {
currentModelSpec.preset.spec = spec; currentModelSpec.preset.spec = spec;
if (currentModelSpec.iconURL != null && currentModelSpec.iconURL !== '') {
currentModelSpec.preset.iconURL = currentModelSpec.iconURL;
}
parsedBody = parseCompactConvo({ parsedBody = parseCompactConvo({
endpoint, endpoint,
endpointType, endpointType,
conversation: currentModelSpec.preset, conversation: currentModelSpec.preset,
}); });
if (currentModelSpec.iconURL != null && currentModelSpec.iconURL !== '') {
parsedBody.iconURL = currentModelSpec.iconURL;
}
} catch (error) { } catch (error) {
logger.error(`Error parsing model spec for endpoint ${endpoint}`, error); logger.error(`Error parsing model spec for endpoint ${endpoint}`, error);
return handleError(res, { text: 'Error parsing model spec' }); return handleError(res, { text: 'Error parsing model spec' });
} }
} else if (parsedBody.spec && appConfig.modelSpecs?.list) {
// Non-enforced mode: if spec is selected, derive iconURL from model spec
const modelSpec = appConfig.modelSpecs.list.find((s) => s.name === parsedBody.spec);
if (modelSpec?.iconURL) {
parsedBody.iconURL = modelSpec.iconURL;
}
} }
try { try {

View file

@ -1,7 +1,7 @@
const crypto = require('crypto'); const crypto = require('crypto');
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { parseConvo } = require('librechat-data-provider'); const { parseConvo } = require('librechat-data-provider');
const { sendEvent, handleError } = require('@librechat/api'); const { sendEvent, handleError, sanitizeMessageForTransmit } = require('@librechat/api');
const { saveMessage, getMessages } = require('~/models/Message'); const { saveMessage, getMessages } = require('~/models/Message');
const { getConvo } = require('~/models/Conversation'); const { getConvo } = require('~/models/Conversation');
@ -71,7 +71,7 @@ const sendError = async (req, res, options, callback) => {
return sendEvent(res, { return sendEvent(res, {
final: true, final: true,
requestMessage: query?.[0] ? query[0] : requestMessage, requestMessage: sanitizeMessageForTransmit(query?.[0] ?? requestMessage),
responseMessage: errorMessage, responseMessage: errorMessage,
conversation: convo, conversation: convo,
}); });

View file

@ -1,11 +1,14 @@
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const { isEnabled } = require('@librechat/api');
const createValidateImageRequest = require('~/server/middleware/validateImageRequest'); const createValidateImageRequest = require('~/server/middleware/validateImageRequest');
// Mock only isEnabled, keep getBasePath real so it reads process.env.DOMAIN_CLIENT
jest.mock('@librechat/api', () => ({ jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
isEnabled: jest.fn(), isEnabled: jest.fn(),
})); }));
const { isEnabled } = require('@librechat/api');
describe('validateImageRequest middleware', () => { describe('validateImageRequest middleware', () => {
let req, res, next, validateImageRequest; let req, res, next, validateImageRequest;
const validObjectId = '65cfb246f7ecadb8b1e8036b'; const validObjectId = '65cfb246f7ecadb8b1e8036b';
@ -23,6 +26,7 @@ describe('validateImageRequest middleware', () => {
next = jest.fn(); next = jest.fn();
process.env.JWT_REFRESH_SECRET = 'test-secret'; process.env.JWT_REFRESH_SECRET = 'test-secret';
process.env.OPENID_REUSE_TOKENS = 'false'; process.env.OPENID_REUSE_TOKENS = 'false';
delete process.env.DOMAIN_CLIENT; // Clear for tests without basePath
// Default: OpenID token reuse disabled // Default: OpenID token reuse disabled
isEnabled.mockReturnValue(false); isEnabled.mockReturnValue(false);
@ -296,4 +300,175 @@ describe('validateImageRequest middleware', () => {
expect(res.send).toHaveBeenCalledWith('Access Denied'); expect(res.send).toHaveBeenCalledWith('Access Denied');
}); });
}); });
describe('basePath functionality', () => {
let originalDomainClient;
beforeEach(() => {
originalDomainClient = process.env.DOMAIN_CLIENT;
});
afterEach(() => {
process.env.DOMAIN_CLIENT = originalDomainClient;
});
test('should validate image paths with base path', async () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/librechat/images/${validObjectId}/test.jpg`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should validate agent avatar paths with base path', async () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/librechat/images/${validObjectId}/agent-avatar.png`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should reject image paths without base path when DOMAIN_CLIENT is set', async () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/images/${validObjectId}/test.jpg`;
await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
});
test('should handle empty base path (root deployment)', async () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/';
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/images/${validObjectId}/test.jpg`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should handle missing DOMAIN_CLIENT', async () => {
delete process.env.DOMAIN_CLIENT;
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/images/${validObjectId}/test.jpg`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should handle nested subdirectories in base path', async () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/apps/librechat';
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/apps/librechat/images/${validObjectId}/test.jpg`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should prevent path traversal with base path', async () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/librechat/images/${validObjectId}/../../../etc/passwd`;
await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
});
test('should handle URLs with query parameters and base path', async () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/librechat/images/${validObjectId}/test.jpg?version=1`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should handle URLs with fragments and base path', async () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/librechat/images/${validObjectId}/test.jpg#section`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should handle HTTPS URLs with base path', async () => {
process.env.DOMAIN_CLIENT = 'https://example.com/librechat';
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/librechat/images/${validObjectId}/test.jpg`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should handle invalid DOMAIN_CLIENT gracefully', async () => {
process.env.DOMAIN_CLIENT = 'not-a-valid-url';
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/images/${validObjectId}/test.jpg`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should handle OpenID flow with base path', async () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
process.env.OPENID_REUSE_TOKENS = 'true';
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}; token_provider=openid; openid_user_id=${validToken}`;
req.originalUrl = `/librechat/images/${validObjectId}/test.jpg`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
});
}); });

View file

@ -1,7 +1,7 @@
const cookies = require('cookie'); const cookies = require('cookie');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { isEnabled, getBasePath } = require('@librechat/api');
const OBJECT_ID_LENGTH = 24; const OBJECT_ID_LENGTH = 24;
const OBJECT_ID_PATTERN = /^[0-9a-f]{24}$/i; const OBJECT_ID_PATTERN = /^[0-9a-f]{24}$/i;
@ -124,14 +124,21 @@ function createValidateImageRequest(secureImageLinks) {
return res.status(403).send('Access Denied'); return res.status(403).send('Access Denied');
} }
const agentAvatarPattern = /^\/images\/[a-f0-9]{24}\/agent-[^/]*$/; const basePath = getBasePath();
const imagesPath = `${basePath}/images`;
const agentAvatarPattern = new RegExp(
`^${imagesPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/[a-f0-9]{24}/agent-[^/]*$`,
);
if (agentAvatarPattern.test(fullPath)) { if (agentAvatarPattern.test(fullPath)) {
logger.debug('[validateImageRequest] Image request validated'); logger.debug('[validateImageRequest] Image request validated');
return next(); return next();
} }
const escapedUserId = userIdForPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const escapedUserId = userIdForPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pathPattern = new RegExp(`^/images/${escapedUserId}/[^/]+$`); const pathPattern = new RegExp(
`^${imagesPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/${escapedUserId}/[^/]+$`,
);
if (pathPattern.test(fullPath)) { if (pathPattern.test(fullPath)) {
logger.debug('[validateImageRequest] Image request validated'); logger.debug('[validateImageRequest] Image request validated');

View file

@ -146,7 +146,15 @@ router.delete(
* @param {number} req.body.version_index - Index of the version to revert to. * @param {number} req.body.version_index - Index of the version to revert to.
* @returns {Agent} 200 - success response - application/json * @returns {Agent} 200 - success response - application/json
*/ */
router.post('/:id/revert', checkGlobalAgentShare, v1.revertAgentVersion); router.post(
'/:id/revert',
checkGlobalAgentShare,
canAccessAgentResource({
requiredPermission: PermissionBits.EDIT,
resourceIdParam: 'id',
}),
v1.revertAgentVersion,
);
/** /**
* Returns a list of agents. * Returns a list of agents.

View file

@ -30,11 +30,46 @@ const publicSharedLinksEnabled =
const sharePointFilePickerEnabled = isEnabled(process.env.ENABLE_SHAREPOINT_FILEPICKER); const sharePointFilePickerEnabled = isEnabled(process.env.ENABLE_SHAREPOINT_FILEPICKER);
const openidReuseTokens = isEnabled(process.env.OPENID_REUSE_TOKENS); const openidReuseTokens = isEnabled(process.env.OPENID_REUSE_TOKENS);
/**
* Fetches MCP servers from registry and adds them to the payload.
* Registry now includes all configured servers (from YAML) plus inspection data when available.
* Always fetches fresh to avoid caching incomplete initialization state.
*/
const getMCPServers = async (payload, appConfig) => {
try {
if (appConfig?.mcpConfig == null) {
return;
}
const mcpManager = getMCPManager();
if (!mcpManager) {
return;
}
const mcpServers = await mcpServersRegistry.getAllServerConfigs();
if (!mcpServers) return;
for (const serverName in mcpServers) {
if (!payload.mcpServers) {
payload.mcpServers = {};
}
const serverConfig = mcpServers[serverName];
payload.mcpServers[serverName] = removeNullishValues({
startup: serverConfig?.startup,
chatMenu: serverConfig?.chatMenu,
isOAuth: serverConfig.requiresOAuth,
customUserVars: serverConfig?.customUserVars,
});
}
} catch (error) {
logger.error('Error loading MCP servers', error);
}
};
router.get('/', async function (req, res) { router.get('/', async function (req, res) {
const cache = getLogStores(CacheKeys.CONFIG_STORE); const cache = getLogStores(CacheKeys.CONFIG_STORE);
const cachedStartupConfig = await cache.get(CacheKeys.STARTUP_CONFIG); const cachedStartupConfig = await cache.get(CacheKeys.STARTUP_CONFIG);
if (cachedStartupConfig) { if (cachedStartupConfig) {
const appConfig = await getAppConfig({ role: req.user?.role });
await getMCPServers(cachedStartupConfig, appConfig);
res.send(cachedStartupConfig); res.send(cachedStartupConfig);
return; return;
} }
@ -126,35 +161,6 @@ router.get('/', async function (req, res) {
payload.minPasswordLength = minPasswordLength; payload.minPasswordLength = minPasswordLength;
} }
const getMCPServers = async () => {
try {
if (appConfig?.mcpConfig == null) {
return;
}
const mcpManager = getMCPManager();
if (!mcpManager) {
return;
}
const mcpServers = await mcpServersRegistry.getAllServerConfigs();
if (!mcpServers) return;
for (const serverName in mcpServers) {
if (!payload.mcpServers) {
payload.mcpServers = {};
}
const serverConfig = mcpServers[serverName];
payload.mcpServers[serverName] = removeNullishValues({
startup: serverConfig?.startup,
chatMenu: serverConfig?.chatMenu,
isOAuth: serverConfig.requiresOAuth,
customUserVars: serverConfig?.customUserVars,
});
}
} catch (error) {
logger.error('Error loading MCP servers', error);
}
};
await getMCPServers();
const webSearchConfig = appConfig?.webSearch; const webSearchConfig = appConfig?.webSearch;
if ( if (
webSearchConfig != null && webSearchConfig != null &&
@ -184,6 +190,7 @@ router.get('/', async function (req, res) {
} }
await cache.set(CacheKeys.STARTUP_CONFIG, payload); await cache.set(CacheKeys.STARTUP_CONFIG, payload);
await getMCPServers(payload, appConfig);
return res.status(200).send(payload); return res.status(200).send(payload);
} catch (err) { } catch (err) {
logger.error('Error in startup config', err); logger.error('Error in startup config', err);

View file

@ -1,7 +1,6 @@
const accessPermissions = require('./accessPermissions'); const accessPermissions = require('./accessPermissions');
const assistants = require('./assistants'); const assistants = require('./assistants');
const categories = require('./categories'); const categories = require('./categories');
const tokenizer = require('./tokenizer');
const endpoints = require('./endpoints'); const endpoints = require('./endpoints');
const staticRoute = require('./static'); const staticRoute = require('./static');
const messages = require('./messages'); const messages = require('./messages');
@ -53,7 +52,6 @@ module.exports = {
messages, messages,
memories, memories,
endpoints, endpoints,
tokenizer,
assistants, assistants,
categories, categories,
staticRoute, staticRoute,

View file

@ -429,13 +429,23 @@ router.get('/connection/status', requireJwtAuth, async (req, res) => {
const connectionStatus = {}; const connectionStatus = {};
for (const [serverName] of Object.entries(mcpConfig)) { for (const [serverName] of Object.entries(mcpConfig)) {
connectionStatus[serverName] = await getServerConnectionStatus( try {
user.id, connectionStatus[serverName] = await getServerConnectionStatus(
serverName, user.id,
appConnections, serverName,
userConnections, appConnections,
oauthServers, userConnections,
); oauthServers,
);
} catch (error) {
const message = `Failed to get status for server "${serverName}"`;
logger.error(`[MCP Connection Status] ${message},`, error);
connectionStatus[serverName] = {
connectionState: 'error',
requiresOAuth: oauthServers.has(serverName),
error: message,
};
}
} }
res.json({ res.json({

View file

@ -1,7 +1,7 @@
const express = require('express'); const express = require('express');
const { unescapeLaTeX } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { ContentTypes } = require('librechat-data-provider'); const { ContentTypes } = require('librechat-data-provider');
const { unescapeLaTeX, countTokens } = require('@librechat/api');
const { const {
saveConvo, saveConvo,
getMessage, getMessage,
@ -14,7 +14,6 @@ const { findAllArtifacts, replaceArtifactContent } = require('~/server/services/
const { requireJwtAuth, validateMessageReq } = require('~/server/middleware'); const { requireJwtAuth, validateMessageReq } = require('~/server/middleware');
const { cleanUpPrimaryKeyValue } = require('~/lib/utils/misc'); const { cleanUpPrimaryKeyValue } = require('~/lib/utils/misc');
const { getConvosQueried } = require('~/models/Conversation'); const { getConvosQueried } = require('~/models/Conversation');
const { countTokens } = require('~/server/utils');
const { Message } = require('~/db/models'); const { Message } = require('~/db/models');
const router = express.Router(); const router = express.Router();

View file

@ -5,6 +5,7 @@ const {
markPublicPromptGroups, markPublicPromptGroups,
buildPromptGroupFilter, buildPromptGroupFilter,
formatPromptGroupsResponse, formatPromptGroupsResponse,
safeValidatePromptGroupUpdate,
createEmptyPromptGroupsResponse, createEmptyPromptGroupsResponse,
filterAccessibleIdsBySharedLogic, filterAccessibleIdsBySharedLogic,
} = require('@librechat/api'); } = require('@librechat/api');
@ -344,7 +345,16 @@ const patchPromptGroup = async (req, res) => {
if (req.user.role === SystemRoles.ADMIN) { if (req.user.role === SystemRoles.ADMIN) {
delete filter.author; delete filter.author;
} }
const promptGroup = await updatePromptGroup(filter, req.body);
const validationResult = safeValidatePromptGroupUpdate(req.body);
if (!validationResult.success) {
return res.status(400).send({
error: 'Invalid request body',
details: validationResult.error.errors,
});
}
const promptGroup = await updatePromptGroup(filter, validationResult.data);
res.status(200).send(promptGroup); res.status(200).send(promptGroup);
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);

View file

@ -544,6 +544,169 @@ describe('Prompt Routes - ACL Permissions', () => {
}); });
}); });
describe('PATCH /api/prompts/groups/:groupId - Update Prompt Group Security', () => {
let testGroup;
beforeEach(async () => {
// Create a prompt group
testGroup = await PromptGroup.create({
name: 'Security Test Group',
category: 'security-test',
author: testUsers.owner._id,
authorName: testUsers.owner.name,
productionId: new ObjectId(),
});
// Grant owner permissions
await grantPermission({
principalType: PrincipalType.USER,
principalId: testUsers.owner._id,
resourceType: ResourceType.PROMPTGROUP,
resourceId: testGroup._id,
accessRoleId: AccessRoleIds.PROMPTGROUP_OWNER,
grantedBy: testUsers.owner._id,
});
});
afterEach(async () => {
await PromptGroup.deleteMany({});
await AclEntry.deleteMany({});
});
it('should allow updating allowed fields (name, category, oneliner)', async () => {
const updateData = {
name: 'Updated Group Name',
category: 'updated-category',
oneliner: 'Updated description',
};
const response = await request(app)
.patch(`/api/prompts/groups/${testGroup._id}`)
.send(updateData)
.expect(200);
expect(response.body.name).toBe(updateData.name);
expect(response.body.category).toBe(updateData.category);
expect(response.body.oneliner).toBe(updateData.oneliner);
});
it('should reject request with author field (400 Bad Request)', async () => {
const maliciousUpdate = {
name: 'Legit Update',
author: testUsers.noAccess._id.toString(), // Try to change ownership
};
const response = await request(app)
.patch(`/api/prompts/groups/${testGroup._id}`)
.send(maliciousUpdate)
.expect(400);
// Verify the request was rejected
expect(response.body.error).toBe('Invalid request body');
expect(response.body.details).toBeDefined();
});
it('should reject request with authorName field (400 Bad Request)', async () => {
const maliciousUpdate = {
name: 'Legit Update',
authorName: 'Malicious Author Name',
};
const response = await request(app)
.patch(`/api/prompts/groups/${testGroup._id}`)
.send(maliciousUpdate)
.expect(400);
// Verify the request was rejected
expect(response.body.error).toBe('Invalid request body');
});
it('should reject request with _id field (400 Bad Request)', async () => {
const newId = new ObjectId();
const maliciousUpdate = {
name: 'Legit Update',
_id: newId.toString(),
};
const response = await request(app)
.patch(`/api/prompts/groups/${testGroup._id}`)
.send(maliciousUpdate)
.expect(400);
// Verify the request was rejected
expect(response.body.error).toBe('Invalid request body');
});
it('should reject request with productionId field (400 Bad Request)', async () => {
const newProductionId = new ObjectId();
const maliciousUpdate = {
name: 'Legit Update',
productionId: newProductionId.toString(),
};
const response = await request(app)
.patch(`/api/prompts/groups/${testGroup._id}`)
.send(maliciousUpdate)
.expect(400);
// Verify the request was rejected
expect(response.body.error).toBe('Invalid request body');
});
it('should reject request with createdAt field (400 Bad Request)', async () => {
const maliciousDate = new Date('2020-01-01');
const maliciousUpdate = {
name: 'Legit Update',
createdAt: maliciousDate.toISOString(),
};
const response = await request(app)
.patch(`/api/prompts/groups/${testGroup._id}`)
.send(maliciousUpdate)
.expect(400);
// Verify the request was rejected
expect(response.body.error).toBe('Invalid request body');
});
it('should reject request with __v field (400 Bad Request)', async () => {
const maliciousUpdate = {
name: 'Legit Update',
__v: 999,
};
const response = await request(app)
.patch(`/api/prompts/groups/${testGroup._id}`)
.send(maliciousUpdate)
.expect(400);
// Verify the request was rejected
expect(response.body.error).toBe('Invalid request body');
});
it('should reject request with multiple sensitive fields (400 Bad Request)', async () => {
const maliciousUpdate = {
name: 'Legit Update',
author: testUsers.noAccess._id.toString(),
authorName: 'Hacker',
_id: new ObjectId().toString(),
productionId: new ObjectId().toString(),
createdAt: new Date('2020-01-01').toISOString(),
__v: 999,
};
const response = await request(app)
.patch(`/api/prompts/groups/${testGroup._id}`)
.send(maliciousUpdate)
.expect(400);
// Verify the request was rejected with validation errors
expect(response.body.error).toBe('Invalid request body');
expect(response.body.details).toBeDefined();
expect(Array.isArray(response.body.details)).toBe(true);
});
});
describe('Pagination', () => { describe('Pagination', () => {
beforeEach(async () => { beforeEach(async () => {
// Create multiple prompt groups for pagination testing // Create multiple prompt groups for pagination testing

View file

@ -1,19 +0,0 @@
const express = require('express');
const { logger } = require('@librechat/data-schemas');
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
const { countTokens } = require('~/server/utils');
const router = express.Router();
router.post('/', requireJwtAuth, async (req, res) => {
try {
const { arg } = req.body;
const count = await countTokens(arg?.text ?? arg);
res.send({ count });
} catch (e) {
logger.error('[/tokenizer] Error counting tokens', e);
res.status(500).json('Error counting tokens');
}
});
module.exports = router;

View file

@ -8,7 +8,12 @@ const {
deleteUserController, deleteUserController,
getUserController, getUserController,
} = require('~/server/controllers/UserController'); } = require('~/server/controllers/UserController');
const { requireJwtAuth, canDeleteAccount, verifyEmailLimiter } = require('~/server/middleware'); const {
verifyEmailLimiter,
configMiddleware,
canDeleteAccount,
requireJwtAuth,
} = require('~/server/middleware');
const router = express.Router(); const router = express.Router();
@ -16,7 +21,7 @@ router.get('/', requireJwtAuth, getUserController);
router.get('/terms', requireJwtAuth, getTermsStatusController); router.get('/terms', requireJwtAuth, getTermsStatusController);
router.post('/terms/accept', requireJwtAuth, acceptTermsController); router.post('/terms/accept', requireJwtAuth, acceptTermsController);
router.post('/plugins', requireJwtAuth, updateUserPluginsController); router.post('/plugins', requireJwtAuth, updateUserPluginsController);
router.delete('/delete', requireJwtAuth, canDeleteAccount, deleteUserController); router.delete('/delete', requireJwtAuth, canDeleteAccount, configMiddleware, deleteUserController);
router.post('/verify', verifyEmailController); router.post('/verify', verifyEmailController);
router.post('/verify/resend', verifyEmailLimiter, resendVerificationController); router.post('/verify/resend', verifyEmailLimiter, resendVerificationController);

View file

@ -176,7 +176,7 @@ const registerUser = async (user, additionalData = {}) => {
return { status: 404, message: errorMessage }; return { status: 404, message: errorMessage };
} }
const { email, password, name, username } = user; const { email, password, name, username, provider } = user;
let newUserId; let newUserId;
try { try {
@ -207,7 +207,7 @@ const registerUser = async (user, additionalData = {}) => {
const salt = bcrypt.genSaltSync(10); const salt = bcrypt.genSaltSync(10);
const newUserData = { const newUserData = {
provider: 'local', provider: provider ?? 'local',
email, email,
username, username,
name, name,
@ -412,7 +412,7 @@ const setAuthTokens = async (userId, res, _session = null) => {
* @param {string} [userId] - Optional MongoDB user ID for image path validation * @param {string} [userId] - Optional MongoDB user ID for image path validation
* @returns {String} - access token * @returns {String} - access token
*/ */
const setOpenIDAuthTokens = (tokenset, res, userId) => { const setOpenIDAuthTokens = (tokenset, res, userId, existingRefreshToken) => {
try { try {
if (!tokenset) { if (!tokenset) {
logger.error('[setOpenIDAuthTokens] No tokenset found in request'); logger.error('[setOpenIDAuthTokens] No tokenset found in request');
@ -427,11 +427,25 @@ const setOpenIDAuthTokens = (tokenset, res, userId) => {
logger.error('[setOpenIDAuthTokens] No tokenset found in request'); logger.error('[setOpenIDAuthTokens] No tokenset found in request');
return; return;
} }
if (!tokenset.access_token || !tokenset.refresh_token) { if (!tokenset.access_token) {
logger.error('[setOpenIDAuthTokens] No access or refresh token found in tokenset'); logger.error('[setOpenIDAuthTokens] No access token found in tokenset');
return; return;
} }
res.cookie('refreshToken', tokenset.refresh_token, {
const refreshToken = tokenset.refresh_token || existingRefreshToken;
if (!refreshToken) {
logger.error('[setOpenIDAuthTokens] No refresh token available');
return;
}
res.cookie('refreshToken', refreshToken, {
expires: expirationDate,
httpOnly: true,
secure: isProduction,
sameSite: 'strict',
});
res.cookie('openid_access_token', tokenset.access_token, {
expires: expirationDate, expires: expirationDate,
httpOnly: true, httpOnly: true,
secure: isProduction, secure: isProduction,

View file

@ -16,6 +16,11 @@ async function updateMCPServerTools({ userId, serverName, tools }) {
const serverTools = {}; const serverTools = {};
const mcpDelimiter = Constants.mcp_delimiter; const mcpDelimiter = Constants.mcp_delimiter;
if (tools == null || tools.length === 0) {
logger.debug(`[MCP Cache] No tools to update for server ${serverName} (user: ${userId})`);
return serverTools;
}
for (const tool of tools) { for (const tool of tools) {
const name = `${tool.name}${mcpDelimiter}${serverName}`; const name = `${tool.name}${mcpDelimiter}${serverName}`;
serverTools[name] = { serverTools[name] = {

View file

@ -1,5 +1,6 @@
const OpenAI = require('openai'); const OpenAI = require('openai');
const { ProxyAgent } = require('undici'); const { ProxyAgent } = require('undici');
const { isUserProvided } = require('@librechat/api');
const { ErrorTypes, EModelEndpoint } = require('librechat-data-provider'); const { ErrorTypes, EModelEndpoint } = require('librechat-data-provider');
const { const {
getUserKeyValues, getUserKeyValues,
@ -7,7 +8,6 @@ const {
checkUserKeyExpiry, checkUserKeyExpiry,
} = require('~/server/services/UserService'); } = require('~/server/services/UserService');
const OAIClient = require('~/app/clients/OpenAIClient'); const OAIClient = require('~/app/clients/OpenAIClient');
const { isUserProvided } = require('~/server/utils');
const initializeClient = async ({ req, res, endpointOption, version, initAppClient = false }) => { const initializeClient = async ({ req, res, endpointOption, version, initAppClient = false }) => {
const { PROXY, OPENAI_ORGANIZATION, ASSISTANTS_API_KEY, ASSISTANTS_BASE_URL } = process.env; const { PROXY, OPENAI_ORGANIZATION, ASSISTANTS_API_KEY, ASSISTANTS_BASE_URL } = process.env;

View file

@ -1,9 +1,4 @@
const { const { isUserProvided, getOpenAIConfig, getCustomEndpointConfig } = require('@librechat/api');
resolveHeaders,
isUserProvided,
getOpenAIConfig,
getCustomEndpointConfig,
} = require('@librechat/api');
const { const {
CacheKeys, CacheKeys,
ErrorTypes, ErrorTypes,
@ -34,14 +29,6 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid
const CUSTOM_API_KEY = extractEnvVariable(endpointConfig.apiKey); const CUSTOM_API_KEY = extractEnvVariable(endpointConfig.apiKey);
const CUSTOM_BASE_URL = extractEnvVariable(endpointConfig.baseURL); const CUSTOM_BASE_URL = extractEnvVariable(endpointConfig.baseURL);
/** Intentionally excludes passing `body`, i.e. `req.body`, as
* values may not be accurate until `AgentClient` is initialized
*/
let resolvedHeaders = resolveHeaders({
headers: endpointConfig.headers,
user: req.user,
});
if (CUSTOM_API_KEY.match(envVarRegex)) { if (CUSTOM_API_KEY.match(envVarRegex)) {
throw new Error(`Missing API Key for ${endpoint}.`); throw new Error(`Missing API Key for ${endpoint}.`);
} }
@ -108,7 +95,7 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid
} }
const customOptions = { const customOptions = {
headers: resolvedHeaders, headers: endpointConfig.headers,
addParams: endpointConfig.addParams, addParams: endpointConfig.addParams,
dropParams: endpointConfig.dropParams, dropParams: endpointConfig.dropParams,
customParams: endpointConfig.customParams, customParams: endpointConfig.customParams,

View file

@ -69,17 +69,21 @@ describe('custom/initializeClient', () => {
}); });
}); });
it('calls resolveHeaders with headers, user, and body for body placeholder support', async () => { it('stores original template headers for deferred resolution', async () => {
const { resolveHeaders } = require('@librechat/api'); /**
await initializeClient({ req: mockRequest, res: mockResponse, optionsOnly: true }); * Note: Request-based Header Resolution is deferred until right before LLM request is made
expect(resolveHeaders).toHaveBeenCalledWith({ * in the OpenAIClient or AgentClient, not during initialization.
headers: { 'x-user': '{{LIBRECHAT_USER_ID}}', 'x-email': '{{LIBRECHAT_USER_EMAIL}}' }, * This test verifies that the initialize function completes successfully with optionsOnly flag,
user: { id: 'user-123', email: 'test@example.com', role: 'user' }, * and that headers are passed through to be resolved later during the actual LLM request.
/** */
* Note: Request-based Header Resolution is deferred until right before LLM request is made const result = await initializeClient({
body: { endpoint: 'test-endpoint' }, // body - supports {{LIBRECHAT_BODY_*}} placeholders req: mockRequest,
*/ res: mockResponse,
optionsOnly: true,
}); });
// Verify that options are returned for later use
expect(result).toBeDefined();
expect(result).toHaveProperty('useLegacyContent', true);
}); });
it('throws if endpoint config is missing', async () => { it('throws if endpoint config is missing', async () => {

View file

@ -12,14 +12,13 @@ const initGoogle = require('~/server/services/Endpoints/google/initialize');
* @returns {boolean} - True if the provider is a known custom provider, false otherwise * @returns {boolean} - True if the provider is a known custom provider, false otherwise
*/ */
function isKnownCustomProvider(provider) { function isKnownCustomProvider(provider) {
return [Providers.XAI, Providers.OLLAMA, Providers.DEEPSEEK, Providers.OPENROUTER].includes( return [Providers.XAI, Providers.DEEPSEEK, Providers.OPENROUTER].includes(
provider?.toLowerCase() || '', provider?.toLowerCase() || '',
); );
} }
const providerConfigMap = { const providerConfigMap = {
[Providers.XAI]: initCustom, [Providers.XAI]: initCustom,
[Providers.OLLAMA]: initCustom,
[Providers.DEEPSEEK]: initCustom, [Providers.DEEPSEEK]: initCustom,
[Providers.OPENROUTER]: initCustom, [Providers.OPENROUTER]: initCustom,
[EModelEndpoint.openAI]: initOpenAI, [EModelEndpoint.openAI]: initOpenAI,

View file

@ -3,7 +3,8 @@ const fs = require('fs').promises;
const FormData = require('form-data'); const FormData = require('form-data');
const { Readable } = require('stream'); const { Readable } = require('stream');
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { genAzureEndpoint } = require('@librechat/api'); const { HttpsProxyAgent } = require('https-proxy-agent');
const { genAzureEndpoint, logAxiosError } = require('@librechat/api');
const { extractEnvVariable, STTProviders } = require('librechat-data-provider'); const { extractEnvVariable, STTProviders } = require('librechat-data-provider');
const { getAppConfig } = require('~/server/services/Config'); const { getAppConfig } = require('~/server/services/Config');
@ -34,6 +35,34 @@ const MIME_TO_EXTENSION_MAP = {
'audio/x-flac': 'flac', 'audio/x-flac': 'flac',
}; };
/**
* Validates and extracts ISO-639-1 language code from a locale string.
* @param {string} language - The language/locale string (e.g., "en-US", "en", "zh-CN")
* @returns {string|null} The ISO-639-1 language code (e.g., "en") or null if invalid
*/
function getValidatedLanguageCode(language) {
try {
if (!language) {
return null;
}
const normalizedLanguage = language.toLowerCase();
const isValidLocaleCode = /^[a-z]{2}(-[a-z]{2})?$/.test(normalizedLanguage);
if (isValidLocaleCode) {
return normalizedLanguage.split('-')[0];
}
logger.warn(
`[STT] Invalid language format "${language}". Expected ISO-639-1 locale code like "en-US" or "en". Skipping language parameter.`,
);
return null;
} catch (error) {
logger.error(`[STT] Error validating language code "${language}":`, error);
return null;
}
}
/** /**
* Gets the file extension from the MIME type. * Gets the file extension from the MIME type.
* @param {string} mimeType - The MIME type. * @param {string} mimeType - The MIME type.
@ -172,10 +201,9 @@ class STTService {
model: sttSchema.model, model: sttSchema.model,
}; };
if (language) { const validLanguage = getValidatedLanguageCode(language);
/** Converted locale code (e.g., "en-US") to ISO-639-1 format (e.g., "en") */ if (validLanguage) {
const isoLanguage = language.split('-')[0]; data.language = validLanguage;
data.language = isoLanguage;
} }
const headers = { const headers = {
@ -220,10 +248,9 @@ class STTService {
contentType: audioFile.mimetype, contentType: audioFile.mimetype,
}); });
if (language) { const validLanguage = getValidatedLanguageCode(language);
/** Converted locale code (e.g., "en-US") to ISO-639-1 format (e.g., "en") */ if (validLanguage) {
const isoLanguage = language.split('-')[0]; formData.append('language', validLanguage);
formData.append('language', isoLanguage);
} }
const headers = { const headers = {
@ -266,8 +293,14 @@ class STTService {
language, language,
); );
const options = { headers };
if (process.env.PROXY) {
options.httpsAgent = new HttpsProxyAgent(process.env.PROXY);
}
try { try {
const response = await axios.post(url, data, { headers }); const response = await axios.post(url, data, options);
if (response.status !== 200) { if (response.status !== 200) {
throw new Error('Invalid response from the STT API'); throw new Error('Invalid response from the STT API');
@ -279,7 +312,7 @@ class STTService {
return response.data.text.trim(); return response.data.text.trim();
} catch (error) { } catch (error) {
logger.error(`STT request failed for provider ${provider}:`, error); logAxiosError({ message: `STT request failed for provider ${provider}:`, error });
throw error; throw error;
} }
} }
@ -309,7 +342,7 @@ class STTService {
const text = await this.sttRequest(provider, sttSchema, { audioBuffer, audioFile, language }); const text = await this.sttRequest(provider, sttSchema, { audioBuffer, audioFile, language });
res.json({ text }); res.json({ text });
} catch (error) { } catch (error) {
logger.error('An error occurred while processing the audio:', error); logAxiosError({ message: 'An error occurred while processing the audio:', error });
res.sendStatus(500); res.sendStatus(500);
} finally { } finally {
try { try {

View file

@ -1,6 +1,7 @@
const axios = require('axios'); const axios = require('axios');
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { genAzureEndpoint } = require('@librechat/api'); const { HttpsProxyAgent } = require('https-proxy-agent');
const { genAzureEndpoint, logAxiosError } = require('@librechat/api');
const { extractEnvVariable, TTSProviders } = require('librechat-data-provider'); const { extractEnvVariable, TTSProviders } = require('librechat-data-provider');
const { getRandomVoiceId, createChunkProcessor, splitTextIntoChunks } = require('./streamAudio'); const { getRandomVoiceId, createChunkProcessor, splitTextIntoChunks } = require('./streamAudio');
const { getAppConfig } = require('~/server/services/Config'); const { getAppConfig } = require('~/server/services/Config');
@ -266,10 +267,14 @@ class TTSService {
const options = { headers, responseType: stream ? 'stream' : 'arraybuffer' }; const options = { headers, responseType: stream ? 'stream' : 'arraybuffer' };
if (process.env.PROXY) {
options.httpsAgent = new HttpsProxyAgent(process.env.PROXY);
}
try { try {
return await axios.post(url, data, options); return await axios.post(url, data, options);
} catch (error) { } catch (error) {
logger.error(`TTS request failed for provider ${provider}:`, error); logAxiosError({ message: `TTS request failed for provider ${provider}:`, error });
throw error; throw error;
} }
} }
@ -325,7 +330,10 @@ class TTSService {
break; break;
} }
} catch (innerError) { } catch (innerError) {
logger.error('Error processing manual update:', chunk, innerError); logAxiosError({
message: `[TTS] Error processing manual update for chunk: ${chunk?.text?.substring(0, 50)}...`,
error: innerError,
});
if (!res.headersSent) { if (!res.headersSent) {
return res.status(500).end(); return res.status(500).end();
} }
@ -337,7 +345,7 @@ class TTSService {
res.end(); res.end();
} }
} catch (error) { } catch (error) {
logger.error('Error creating the audio stream:', error); logAxiosError({ message: '[TTS] Error creating the audio stream:', error });
if (!res.headersSent) { if (!res.headersSent) {
return res.status(500).send('An error occurred'); return res.status(500).send('An error occurred');
} }
@ -407,7 +415,10 @@ class TTSService {
break; break;
} }
} catch (innerError) { } catch (innerError) {
logger.error('Error processing audio stream update:', update, innerError); logAxiosError({
message: `[TTS] Error processing audio stream update: ${update?.text?.substring(0, 50)}...`,
error: innerError,
});
if (!res.headersSent) { if (!res.headersSent) {
return res.status(500).end(); return res.status(500).end();
} }
@ -424,7 +435,7 @@ class TTSService {
res.end(); res.end();
} }
} catch (error) { } catch (error) {
logger.error('Failed to fetch audio:', error); logAxiosError({ message: '[TTS] Failed to fetch audio:', error });
if (!res.headersSent) { if (!res.headersSent) {
res.status(500).end(); res.status(500).end();
} }

View file

@ -1,9 +1,9 @@
const path = require('path'); const path = require('path');
const { v4 } = require('uuid'); const { v4 } = require('uuid');
const axios = require('axios'); const axios = require('axios');
const { logAxiosError } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { getCodeBaseURL } = require('@librechat/agents'); const { getCodeBaseURL } = require('@librechat/agents');
const { logAxiosError, getBasePath } = require('@librechat/api');
const { const {
Tools, Tools,
FileContext, FileContext,
@ -41,11 +41,12 @@ const processCodeOutput = async ({
const appConfig = req.config; const appConfig = req.config;
const currentDate = new Date(); const currentDate = new Date();
const baseURL = getCodeBaseURL(); const baseURL = getCodeBaseURL();
const basePath = getBasePath();
const fileExt = path.extname(name); const fileExt = path.extname(name);
if (!fileExt || !imageExtRegex.test(name)) { if (!fileExt || !imageExtRegex.test(name)) {
return { return {
filename: name, filename: name,
filepath: `/api/files/code/download/${session_id}/${id}`, filepath: `${basePath}/api/files/code/download/${session_id}/${id}`,
/** Note: expires 24 hours after creation */ /** Note: expires 24 hours after creation */
expiresAt: currentDate.getTime() + 86400000, expiresAt: currentDate.getTime() + 86400000,
conversationId, conversationId,

View file

@ -169,14 +169,24 @@ function extractFirebaseFilePath(urlString) {
const deleteFirebaseFile = async (req, file) => { const deleteFirebaseFile = async (req, file) => {
if (file.embedded && process.env.RAG_API_URL) { if (file.embedded && process.env.RAG_API_URL) {
const jwtToken = req.headers.authorization.split(' ')[1]; const jwtToken = req.headers.authorization.split(' ')[1];
axios.delete(`${process.env.RAG_API_URL}/documents`, { try {
headers: { await axios.delete(`${process.env.RAG_API_URL}/documents`, {
Authorization: `Bearer ${jwtToken}`, headers: {
'Content-Type': 'application/json', Authorization: `Bearer ${jwtToken}`,
accept: 'application/json', 'Content-Type': 'application/json',
}, accept: 'application/json',
data: [file.file_id], },
}); data: [file.file_id],
});
} catch (error) {
if (error.response?.status === 404) {
logger.warn(
`[deleteFirebaseFile] Document ${file.file_id} not found in RAG API, may have been deleted already`,
);
} else {
logger.error('[deleteFirebaseFile] Error deleting document from RAG API:', error);
}
}
} }
const fileName = extractFirebaseFilePath(file.filepath); const fileName = extractFirebaseFilePath(file.filepath);

View file

@ -210,14 +210,24 @@ const deleteLocalFile = async (req, file) => {
if (file.embedded && process.env.RAG_API_URL) { if (file.embedded && process.env.RAG_API_URL) {
const jwtToken = generateShortLivedToken(req.user.id); const jwtToken = generateShortLivedToken(req.user.id);
axios.delete(`${process.env.RAG_API_URL}/documents`, { try {
headers: { await axios.delete(`${process.env.RAG_API_URL}/documents`, {
Authorization: `Bearer ${jwtToken}`, headers: {
'Content-Type': 'application/json', Authorization: `Bearer ${jwtToken}`,
accept: 'application/json', 'Content-Type': 'application/json',
}, accept: 'application/json',
data: [file.file_id], },
}); data: [file.file_id],
});
} catch (error) {
if (error.response?.status === 404) {
logger.warn(
`[deleteLocalFile] Document ${file.file_id} not found in RAG API, may have been deleted already`,
);
} else {
logger.error('[deleteLocalFile] Error deleting document from RAG API:', error);
}
}
} }
if (cleanFilepath.startsWith(`/uploads/${req.user.id}`)) { if (cleanFilepath.startsWith(`/uploads/${req.user.id}`)) {

View file

@ -1,11 +1,14 @@
const axios = require('axios'); const axios = require('axios');
const { Providers } = require('@librechat/agents');
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { HttpsProxyAgent } = require('https-proxy-agent'); const { HttpsProxyAgent } = require('https-proxy-agent');
const { logAxiosError, inputSchema, processModelData } = require('@librechat/api'); const { logAxiosError, inputSchema, processModelData, isUserProvided } = require('@librechat/api');
const { EModelEndpoint, defaultModels, CacheKeys } = require('librechat-data-provider'); const {
CacheKeys,
defaultModels,
KnownEndpoints,
EModelEndpoint,
} = require('librechat-data-provider');
const { OllamaClient } = require('~/app/clients/OllamaClient'); const { OllamaClient } = require('~/app/clients/OllamaClient');
const { isUserProvided } = require('~/server/utils');
const getLogStores = require('~/cache/getLogStores'); const getLogStores = require('~/cache/getLogStores');
const { extractBaseURL } = require('~/utils'); const { extractBaseURL } = require('~/utils');
@ -68,7 +71,7 @@ const fetchModels = async ({
return models; return models;
} }
if (name && name.toLowerCase().startsWith(Providers.OLLAMA)) { if (name && name.toLowerCase().startsWith(KnownEndpoints.ollama)) {
try { try {
return await OllamaClient.fetchModels(baseURL, { headers, user: userObject }); return await OllamaClient.fetchModels(baseURL, { headers, user: userObject });
} catch (ollamaError) { } catch (ollamaError) {
@ -80,7 +83,9 @@ const fetchModels = async ({
try { try {
const options = { const options = {
headers: {}, headers: {
...(headers ?? {}),
},
timeout: 5000, timeout: 5000,
}; };
@ -101,7 +106,7 @@ const fetchModels = async ({
options.headers['OpenAI-Organization'] = process.env.OPENAI_ORGANIZATION; options.headers['OpenAI-Organization'] = process.env.OPENAI_ORGANIZATION;
} }
const url = new URL(`${baseURL}${azure ? '' : '/models'}`); const url = new URL(`${baseURL.replace(/\/+$/, '')}${azure ? '' : '/models'}`);
if (user && userIdQuery) { if (user && userIdQuery) {
url.searchParams.append('user', user); url.searchParams.append('user', user);
} }

View file

@ -81,6 +81,70 @@ describe('fetchModels', () => {
); );
}); });
it('should pass custom headers to the API request', async () => {
const customHeaders = {
'X-Custom-Header': 'custom-value',
'X-API-Version': 'v2',
};
await fetchModels({
user: 'user123',
apiKey: 'testApiKey',
baseURL: 'https://api.test.com',
name: 'TestAPI',
headers: customHeaders,
});
expect(axios.get).toHaveBeenCalledWith(
expect.stringContaining('https://api.test.com/models'),
expect.objectContaining({
headers: expect.objectContaining({
'X-Custom-Header': 'custom-value',
'X-API-Version': 'v2',
Authorization: 'Bearer testApiKey',
}),
}),
);
});
it('should handle null headers gracefully', async () => {
await fetchModels({
user: 'user123',
apiKey: 'testApiKey',
baseURL: 'https://api.test.com',
name: 'TestAPI',
headers: null,
});
expect(axios.get).toHaveBeenCalledWith(
expect.stringContaining('https://api.test.com/models'),
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer testApiKey',
}),
}),
);
});
it('should handle undefined headers gracefully', async () => {
await fetchModels({
user: 'user123',
apiKey: 'testApiKey',
baseURL: 'https://api.test.com',
name: 'TestAPI',
headers: undefined,
});
expect(axios.get).toHaveBeenCalledWith(
expect.stringContaining('https://api.test.com/models'),
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer testApiKey',
}),
}),
);
});
afterEach(() => { afterEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
}); });
@ -372,6 +436,68 @@ describe('fetchModels with Ollama specific logic', () => {
}); });
}); });
describe('fetchModels URL construction with trailing slashes', () => {
beforeEach(() => {
axios.get.mockResolvedValue({
data: {
data: [{ id: 'model-1' }, { id: 'model-2' }],
},
});
});
afterEach(() => {
jest.clearAllMocks();
});
it('should not create double slashes when baseURL has a trailing slash', async () => {
await fetchModels({
user: 'user123',
apiKey: 'testApiKey',
baseURL: 'https://api.test.com/v1/',
name: 'TestAPI',
});
expect(axios.get).toHaveBeenCalledWith('https://api.test.com/v1/models', expect.any(Object));
});
it('should handle baseURL without trailing slash normally', async () => {
await fetchModels({
user: 'user123',
apiKey: 'testApiKey',
baseURL: 'https://api.test.com/v1',
name: 'TestAPI',
});
expect(axios.get).toHaveBeenCalledWith('https://api.test.com/v1/models', expect.any(Object));
});
it('should handle baseURL with multiple trailing slashes', async () => {
await fetchModels({
user: 'user123',
apiKey: 'testApiKey',
baseURL: 'https://api.test.com/v1///',
name: 'TestAPI',
});
expect(axios.get).toHaveBeenCalledWith('https://api.test.com/v1/models', expect.any(Object));
});
it('should correctly append query params after stripping trailing slashes', async () => {
await fetchModels({
user: 'user123',
apiKey: 'testApiKey',
baseURL: 'https://api.test.com/v1/',
name: 'TestAPI',
userIdQuery: true,
});
expect(axios.get).toHaveBeenCalledWith(
'https://api.test.com/v1/models?user=user123',
expect.any(Object),
);
});
});
describe('splitAndTrim', () => { describe('splitAndTrim', () => {
it('should split a string by commas and trim each value', () => { it('should split a string by commas and trim each value', () => {
const input = ' model1, model2 , model3,model4 '; const input = ' model1, model2 , model3,model4 ';
@ -410,6 +536,64 @@ describe('getAnthropicModels', () => {
const models = await getAnthropicModels(); const models = await getAnthropicModels();
expect(models).toEqual(['claude-1', 'claude-2']); expect(models).toEqual(['claude-1', 'claude-2']);
}); });
it('should use Anthropic-specific headers when fetching models', async () => {
delete process.env.ANTHROPIC_MODELS;
process.env.ANTHROPIC_API_KEY = 'test-anthropic-key';
axios.get.mockResolvedValue({
data: {
data: [{ id: 'claude-3' }, { id: 'claude-4' }],
},
});
await fetchModels({
user: 'user123',
apiKey: 'test-anthropic-key',
baseURL: 'https://api.anthropic.com/v1',
name: EModelEndpoint.anthropic,
});
expect(axios.get).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: {
'x-api-key': 'test-anthropic-key',
'anthropic-version': expect.any(String),
},
}),
);
});
it('should pass custom headers for Anthropic endpoint', async () => {
const customHeaders = {
'X-Custom-Header': 'custom-value',
};
axios.get.mockResolvedValue({
data: {
data: [{ id: 'claude-3' }],
},
});
await fetchModels({
user: 'user123',
apiKey: 'test-anthropic-key',
baseURL: 'https://api.anthropic.com/v1',
name: EModelEndpoint.anthropic,
headers: customHeaders,
});
expect(axios.get).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: {
'x-api-key': 'test-anthropic-key',
'anthropic-version': expect.any(String),
},
}),
);
});
}); });
describe('getGoogleModels', () => { describe('getGoogleModels', () => {

View file

@ -292,7 +292,7 @@ const ensurePrincipalExists = async function (principal) {
let existingUser = await findUser({ idOnTheSource: principal.idOnTheSource }); let existingUser = await findUser({ idOnTheSource: principal.idOnTheSource });
if (!existingUser) { if (!existingUser) {
existingUser = await findUser({ email: principal.email.toLowerCase() }); existingUser = await findUser({ email: principal.email });
} }
if (existingUser) { if (existingUser) {

View file

@ -1,5 +1,6 @@
const path = require('path'); const path = require('path');
const { v4 } = require('uuid'); const { v4 } = require('uuid');
const { countTokens, escapeRegExp } = require('@librechat/api');
const { const {
Constants, Constants,
ContentTypes, ContentTypes,
@ -8,7 +9,6 @@ const {
} = require('librechat-data-provider'); } = require('librechat-data-provider');
const { retrieveAndProcessFile } = require('~/server/services/Files/process'); const { retrieveAndProcessFile } = require('~/server/services/Files/process');
const { recordMessage, getMessages } = require('~/models/Message'); const { recordMessage, getMessages } = require('~/models/Message');
const { countTokens, escapeRegExp } = require('~/server/utils');
const { spendTokens } = require('~/models/spendTokens'); const { spendTokens } = require('~/models/spendTokens');
const { saveConvo } = require('~/models/Conversation'); const { saveConvo } = require('~/models/Conversation');

View file

@ -1,37 +0,0 @@
const { Tiktoken } = require('tiktoken/lite');
const { logger } = require('@librechat/data-schemas');
const p50k_base = require('tiktoken/encoders/p50k_base.json');
const cl100k_base = require('tiktoken/encoders/cl100k_base.json');
/**
* Counts the number of tokens in a given text using a specified encoding model.
*
* This function utilizes the 'Tiktoken' library to encode text based on the selected model.
* It supports two models, 'text-davinci-003' and 'gpt-3.5-turbo', each with its own encoding strategy.
* For 'text-davinci-003', the 'p50k_base' encoder is used, whereas for other models, the 'cl100k_base' encoder is applied.
* In case of an error during encoding, the error is logged, and the function returns 0.
*
* @async
* @param {string} text - The text to be tokenized. Defaults to an empty string if not provided.
* @param {string} modelName - The name of the model used for tokenizing. Defaults to 'gpt-3.5-turbo'.
* @returns {Promise<number>} The number of tokens in the provided text. Returns 0 if an error occurs.
* @throws Logs the error to a logger and rethrows if any error occurs during tokenization.
*/
const countTokens = async (text = '', modelName = 'gpt-3.5-turbo') => {
let encoder = null;
try {
const model = modelName.includes('text-davinci-003') ? p50k_base : cl100k_base;
encoder = new Tiktoken(model.bpe_ranks, model.special_tokens, model.pat_str);
const tokens = encoder.encode(text);
encoder.free();
return tokens.length;
} catch (e) {
logger.error('[countTokens]', e);
if (encoder) {
encoder.free();
}
return 0;
}
};
module.exports = countTokens;

View file

@ -10,14 +10,6 @@ const {
const { sendEvent } = require('@librechat/api'); const { sendEvent } = require('@librechat/api');
const partialRight = require('lodash/partialRight'); const partialRight = require('lodash/partialRight');
/** Helper function to escape special characters in regex
* @param {string} string - The string to escape.
* @returns {string} The escaped string.
*/
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
const addSpaceIfNeeded = (text) => (text.length > 0 && !text.endsWith(' ') ? text + ' ' : text); const addSpaceIfNeeded = (text) => (text.length > 0 && !text.endsWith(' ') ? text + ' ' : text);
const base = { message: true, initial: true }; const base = { message: true, initial: true };
@ -181,7 +173,6 @@ function generateConfig(key, baseURL, endpoint) {
module.exports = { module.exports = {
handleText, handleText,
formatSteps, formatSteps,
escapeRegExp,
formatAction, formatAction,
isUserProvided, isUserProvided,
generateConfig, generateConfig,

View file

@ -1,5 +1,4 @@
const removePorts = require('./removePorts'); const removePorts = require('./removePorts');
const countTokens = require('./countTokens');
const handleText = require('./handleText'); const handleText = require('./handleText');
const sendEmail = require('./sendEmail'); const sendEmail = require('./sendEmail');
const queue = require('./queue'); const queue = require('./queue');
@ -7,7 +6,6 @@ const files = require('./files');
module.exports = { module.exports = {
...handleText, ...handleText,
countTokens,
removePorts, removePorts,
sendEmail, sendEmail,
...files, ...files,

View file

@ -1,3 +1,4 @@
const cookies = require('cookie');
const jwksRsa = require('jwks-rsa'); const jwksRsa = require('jwks-rsa');
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { HttpsProxyAgent } = require('https-proxy-agent'); const { HttpsProxyAgent } = require('https-proxy-agent');
@ -40,13 +41,18 @@ const openIdJwtLogin = (openIdConfig) => {
{ {
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKeyProvider: jwksRsa.passportJwtSecret(jwksRsaOptions), secretOrKeyProvider: jwksRsa.passportJwtSecret(jwksRsaOptions),
passReqToCallback: true,
}, },
/** /**
* @param {import('@librechat/api').ServerRequest} req
* @param {import('openid-client').IDToken} payload * @param {import('openid-client').IDToken} payload
* @param {import('passport-jwt').VerifyCallback} done * @param {import('passport-jwt').VerifyCallback} done
*/ */
async (payload, done) => { async (req, payload, done) => {
try { try {
const authHeader = req.headers.authorization;
const rawToken = authHeader?.replace('Bearer ', '');
const { user, error, migration } = await findOpenIDUser({ const { user, error, migration } = await findOpenIDUser({
findUser, findUser,
email: payload?.email, email: payload?.email,
@ -77,6 +83,18 @@ const openIdJwtLogin = (openIdConfig) => {
await updateUser(user.id, updateData); await updateUser(user.id, updateData);
} }
const cookieHeader = req.headers.cookie;
const parsedCookies = cookieHeader ? cookies.parse(cookieHeader) : {};
const accessToken = parsedCookies.openid_access_token;
const refreshToken = parsedCookies.refreshToken;
user.federatedTokens = {
access_token: accessToken || rawToken,
id_token: rawToken,
refresh_token: refreshToken,
expires_at: payload.exp,
};
done(null, user); done(null, user);
} else { } else {
logger.warn( logger.warn(

View file

@ -543,7 +543,15 @@ async function setupOpenId() {
}, },
); );
done(null, { ...user, tokenset }); done(null, {
...user,
tokenset,
federatedTokens: {
access_token: tokenset.access_token,
refresh_token: tokenset.refresh_token,
expires_at: tokenset.expires_at,
},
});
} catch (err) { } catch (err) {
logger.error('[openidStrategy] login failed', err); logger.error('[openidStrategy] login failed', err);
done(err); done(err);

View file

@ -18,6 +18,8 @@ jest.mock('~/server/services/Config', () => ({
jest.mock('@librechat/api', () => ({ jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'), ...jest.requireActual('@librechat/api'),
isEnabled: jest.fn(() => false), isEnabled: jest.fn(() => false),
isEmailDomainAllowed: jest.fn(() => true),
findOpenIDUser: jest.requireActual('@librechat/api').findOpenIDUser,
getBalanceConfig: jest.fn(() => ({ getBalanceConfig: jest.fn(() => ({
enabled: false, enabled: false,
})), })),
@ -446,6 +448,46 @@ describe('setupOpenId', () => {
expect(callOptions.params?.code_challenge_method).toBeUndefined(); expect(callOptions.params?.code_challenge_method).toBeUndefined();
}); });
it('should attach federatedTokens to user object for token propagation', async () => {
// Arrange - setup tokenset with access token, refresh token, and expiration
const tokensetWithTokens = {
...tokenset,
access_token: 'mock_access_token_abc123',
refresh_token: 'mock_refresh_token_xyz789',
expires_at: 1234567890,
};
// Act - validate with the tokenset containing tokens
const { user } = await validate(tokensetWithTokens);
// Assert - verify federatedTokens object is attached with correct values
expect(user.federatedTokens).toBeDefined();
expect(user.federatedTokens).toEqual({
access_token: 'mock_access_token_abc123',
refresh_token: 'mock_refresh_token_xyz789',
expires_at: 1234567890,
});
});
it('should include tokenset along with federatedTokens', async () => {
// Arrange
const tokensetWithTokens = {
...tokenset,
access_token: 'test_access_token',
refresh_token: 'test_refresh_token',
expires_at: 9999999999,
};
// Act
const { user } = await validate(tokensetWithTokens);
// Assert - both tokenset and federatedTokens should be present
expect(user.tokenset).toBeDefined();
expect(user.federatedTokens).toBeDefined();
expect(user.tokenset.access_token).toBe('test_access_token');
expect(user.federatedTokens.access_token).toBe('test_access_token');
});
it('should set role to "ADMIN" if OPENID_ADMIN_ROLE is set and user has that role', async () => { it('should set role to "ADMIN" if OPENID_ADMIN_ROLE is set and user has that role', async () => {
// Act // Act
const { user } = await validate(tokenset); const { user } = await validate(tokenset);

View file

@ -172,6 +172,7 @@ describe('socialLogin', () => {
/** Verify both searches happened */ /** Verify both searches happened */
expect(findUser).toHaveBeenNthCalledWith(1, { googleId: googleId }); expect(findUser).toHaveBeenNthCalledWith(1, { googleId: googleId });
/** Email passed as-is; findUser implementation handles case normalization */
expect(findUser).toHaveBeenNthCalledWith(2, { email: email }); expect(findUser).toHaveBeenNthCalledWith(2, { email: email });
expect(findUser).toHaveBeenCalledTimes(2); expect(findUser).toHaveBeenCalledTimes(2);

View file

@ -40,6 +40,10 @@ module.exports = {
clientId: 'fake_client_id', clientId: 'fake_client_id',
clientSecret: 'fake_client_secret', clientSecret: 'fake_client_secret',
issuer: 'https://fake-issuer.com', issuer: 'https://fake-issuer.com',
serverMetadata: jest.fn().mockReturnValue({
jwks_uri: 'https://fake-issuer.com/.well-known/jwks.json',
end_session_endpoint: 'https://fake-issuer.com/logout',
}),
Client: jest.fn().mockImplementation(() => ({ Client: jest.fn().mockImplementation(() => ({
authorizationUrl: jest.fn().mockReturnValue('mock_auth_url'), authorizationUrl: jest.fn().mockReturnValue('mock_auth_url'),
callback: jest.fn().mockResolvedValue({ callback: jest.fn().mockResolvedValue({

View file

@ -1,17 +1,11 @@
const { createFileSearchTool } = require('../../../../../app/clients/tools/util/fileSearch'); const axios = require('axios');
// Mock dependencies jest.mock('axios');
jest.mock('../../../../../models', () => ({ jest.mock('@librechat/api', () => ({
Files: { generateShortLivedToken: jest.fn(),
find: jest.fn(),
},
})); }));
jest.mock('../../../../../server/services/Files/VectorDB/crud', () => ({ jest.mock('@librechat/data-schemas', () => ({
queryVectors: jest.fn(),
}));
jest.mock('../../../../../config', () => ({
logger: { logger: {
warn: jest.fn(), warn: jest.fn(),
error: jest.fn(), error: jest.fn(),
@ -19,68 +13,220 @@ jest.mock('../../../../../config', () => ({
}, },
})); }));
const { queryVectors } = require('../../../../../server/services/Files/VectorDB/crud'); jest.mock('~/models/File', () => ({
getFiles: jest.fn().mockResolvedValue([]),
}));
describe('fileSearch.js - test only new file_id and page additions', () => { jest.mock('~/server/services/Files/permissions', () => ({
filterFilesByAgentAccess: jest.fn((options) => Promise.resolve(options.files)),
}));
const { createFileSearchTool } = require('~/app/clients/tools/util/fileSearch');
const { generateShortLivedToken } = require('@librechat/api');
describe('fileSearch.js - tuple return validation', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
process.env.RAG_API_URL = 'http://localhost:8000';
}); });
// Test only the specific changes: file_id and page metadata additions describe('error cases should return tuple with undefined as second value', () => {
it('should add file_id and page to search result format', async () => { it('should return tuple when no files provided', async () => {
const mockFiles = [{ file_id: 'test-file-123' }]; const fileSearchTool = await createFileSearchTool({
const mockResults = [ userId: 'user1',
{ files: [],
});
const result = await fileSearchTool.func({ query: 'test query' });
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(2);
expect(result[0]).toBe('No files to search. Instruct the user to add files for the search.');
expect(result[1]).toBeUndefined();
});
it('should return tuple when JWT token generation fails', async () => {
generateShortLivedToken.mockReturnValue(null);
const fileSearchTool = await createFileSearchTool({
userId: 'user1',
files: [{ file_id: 'file-1', filename: 'test.pdf' }],
});
const result = await fileSearchTool.func({ query: 'test query' });
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(2);
expect(result[0]).toBe('There was an error authenticating the file search request.');
expect(result[1]).toBeUndefined();
});
it('should return tuple when no valid results found', async () => {
generateShortLivedToken.mockReturnValue('mock-jwt-token');
axios.post.mockRejectedValue(new Error('API Error'));
const fileSearchTool = await createFileSearchTool({
userId: 'user1',
files: [{ file_id: 'file-1', filename: 'test.pdf' }],
});
const result = await fileSearchTool.func({ query: 'test query' });
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(2);
expect(result[0]).toBe('No results found or errors occurred while searching the files.');
expect(result[1]).toBeUndefined();
});
});
describe('success cases should return tuple with artifact object', () => {
it('should return tuple with formatted results and sources artifact', async () => {
generateShortLivedToken.mockReturnValue('mock-jwt-token');
const mockApiResponse = {
data: [ data: [
[ [
{ {
page_content: 'test content', page_content: 'This is test content from the document',
metadata: { source: 'test.pdf', page: 1 }, metadata: { source: '/path/to/test.pdf', page: 1 },
}, },
0.3, 0.2,
],
[
{
page_content: 'Additional relevant content',
metadata: { source: '/path/to/test.pdf', page: 2 },
},
0.35,
], ],
], ],
}, };
];
queryVectors.mockResolvedValue(mockResults); axios.post.mockResolvedValue(mockApiResponse);
const fileSearchTool = await createFileSearchTool({ const fileSearchTool = await createFileSearchTool({
userId: 'user1', userId: 'user1',
files: mockFiles, files: [{ file_id: 'file-123', filename: 'test.pdf' }],
entity_id: 'agent-123', entity_id: 'agent-456',
});
const result = await fileSearchTool.func({ query: 'test query' });
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(2);
const [formattedString, artifact] = result;
expect(typeof formattedString).toBe('string');
expect(formattedString).toContain('File: test.pdf');
expect(formattedString).toContain('Relevance:');
expect(formattedString).toContain('This is test content from the document');
expect(formattedString).toContain('Additional relevant content');
expect(artifact).toBeDefined();
expect(artifact).toHaveProperty('file_search');
expect(artifact.file_search).toHaveProperty('sources');
expect(artifact.file_search).toHaveProperty('fileCitations', false);
expect(Array.isArray(artifact.file_search.sources)).toBe(true);
expect(artifact.file_search.sources.length).toBe(2);
const source = artifact.file_search.sources[0];
expect(source).toMatchObject({
type: 'file',
fileId: 'file-123',
fileName: 'test.pdf',
content: expect.any(String),
relevance: expect.any(Number),
pages: [1],
pageRelevance: { 1: expect.any(Number) },
});
}); });
// Mock the tool's function to return the formatted result it('should include file citations in description when enabled', async () => {
fileSearchTool.func = jest.fn().mockImplementation(async () => { generateShortLivedToken.mockReturnValue('mock-jwt-token');
// Simulate the new format with file_id and page
const formattedResults = [
{
filename: 'test.pdf',
content: 'test content',
distance: 0.3,
file_id: 'test-file-123', // NEW: added file_id
page: 1, // NEW: added page
},
];
// NEW: Internal data section for processAgentResponse const mockApiResponse = {
const internalData = formattedResults data: [
.map( [
(result) => {
`File: ${result.filename}\nFile_ID: ${result.file_id}\nRelevance: ${(1.0 - result.distance).toFixed(4)}\nPage: ${result.page || 'N/A'}\nContent: ${result.content}\n`, page_content: 'Content with citations',
) metadata: { source: '/path/to/doc.pdf', page: 3 },
.join('\n---\n'); },
0.15,
],
],
};
return `File: test.pdf\nRelevance: 0.7000\nContent: test content\n\n<!-- INTERNAL_DATA_START -->\n${internalData}\n<!-- INTERNAL_DATA_END -->`; axios.post.mockResolvedValue(mockApiResponse);
const fileSearchTool = await createFileSearchTool({
userId: 'user1',
files: [{ file_id: 'file-789', filename: 'doc.pdf' }],
fileCitations: true,
});
const result = await fileSearchTool.func({ query: 'test query' });
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(2);
const [formattedString, artifact] = result;
expect(formattedString).toContain('Anchor:');
expect(formattedString).toContain('\\ue202turn0file0');
expect(artifact.file_search.fileCitations).toBe(true);
}); });
const result = await fileSearchTool.func('test'); it('should handle multiple files correctly', async () => {
generateShortLivedToken.mockReturnValue('mock-jwt-token');
// Verify the new additions const mockResponse1 = {
expect(result).toContain('File_ID: test-file-123'); data: [
expect(result).toContain('Page: 1'); [
expect(result).toContain('<!-- INTERNAL_DATA_START -->'); {
expect(result).toContain('<!-- INTERNAL_DATA_END -->'); page_content: 'Content from file 1',
metadata: { source: '/path/to/file1.pdf', page: 1 },
},
0.25,
],
],
};
const mockResponse2 = {
data: [
[
{
page_content: 'Content from file 2',
metadata: { source: '/path/to/file2.pdf', page: 1 },
},
0.15,
],
],
};
axios.post.mockResolvedValueOnce(mockResponse1).mockResolvedValueOnce(mockResponse2);
const fileSearchTool = await createFileSearchTool({
userId: 'user1',
files: [
{ file_id: 'file-1', filename: 'file1.pdf' },
{ file_id: 'file-2', filename: 'file2.pdf' },
],
});
const result = await fileSearchTool.func({ query: 'test query' });
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(2);
const [formattedString, artifact] = result;
expect(formattedString).toContain('file1.pdf');
expect(formattedString).toContain('file2.pdf');
expect(artifact.file_search.sources).toHaveLength(2);
// Results are sorted by distance (ascending), so file-2 (0.15) comes before file-1 (0.25)
expect(artifact.file_search.sources[0].fileId).toBe('file-2');
expect(artifact.file_search.sources[1].fileId).toBe('file-1');
});
}); });
}); });

View file

@ -1828,7 +1828,7 @@
* @param {onTokenProgress} opts.onProgress - Callback function to handle token progress * @param {onTokenProgress} opts.onProgress - Callback function to handle token progress
* @param {AbortController} opts.abortController - AbortController instance * @param {AbortController} opts.abortController - AbortController instance
* @param {Record<string, Record<string, string>>} [opts.userMCPAuthMap] * @param {Record<string, Record<string, string>>} [opts.userMCPAuthMap]
* @returns {Promise<string>} * @returns {Promise<{ content: Promise<MessageContentComplex[]>; metadata: Record<string, unknown>; }>}
* @memberof typedefs * @memberof typedefs
*/ */

View file

@ -275,6 +275,9 @@ describe('getModelMaxTokens', () => {
expect(getModelMaxTokens('gemini-1.5-pro-preview-0409', EModelEndpoint.google)).toBe( expect(getModelMaxTokens('gemini-1.5-pro-preview-0409', EModelEndpoint.google)).toBe(
maxTokensMap[EModelEndpoint.google]['gemini-1.5'], maxTokensMap[EModelEndpoint.google]['gemini-1.5'],
); );
expect(getModelMaxTokens('gemini-3', EModelEndpoint.google)).toBe(
maxTokensMap[EModelEndpoint.google]['gemini-3'],
);
expect(getModelMaxTokens('gemini-2.5-pro', EModelEndpoint.google)).toBe( expect(getModelMaxTokens('gemini-2.5-pro', EModelEndpoint.google)).toBe(
maxTokensMap[EModelEndpoint.google]['gemini-2.5-pro'], maxTokensMap[EModelEndpoint.google]['gemini-2.5-pro'],
); );
@ -662,7 +665,7 @@ describe('Meta Models Tests', () => {
test('should match Deepseek model variations', () => { test('should match Deepseek model variations', () => {
expect(getModelMaxTokens('deepseek-chat')).toBe( expect(getModelMaxTokens('deepseek-chat')).toBe(
maxTokensMap[EModelEndpoint.openAI]['deepseek'], maxTokensMap[EModelEndpoint.openAI]['deepseek-chat'],
); );
expect(getModelMaxTokens('deepseek-coder')).toBe( expect(getModelMaxTokens('deepseek-coder')).toBe(
maxTokensMap[EModelEndpoint.openAI]['deepseek'], maxTokensMap[EModelEndpoint.openAI]['deepseek'],
@ -674,6 +677,20 @@ describe('Meta Models Tests', () => {
maxTokensMap[EModelEndpoint.openAI]['deepseek.r1'], maxTokensMap[EModelEndpoint.openAI]['deepseek.r1'],
); );
}); });
test('should return 128000 context tokens for all DeepSeek models', () => {
expect(getModelMaxTokens('deepseek-chat')).toBe(128000);
expect(getModelMaxTokens('deepseek-reasoner')).toBe(128000);
expect(getModelMaxTokens('deepseek-r1')).toBe(128000);
expect(getModelMaxTokens('deepseek-v3')).toBe(128000);
expect(getModelMaxTokens('deepseek.r1')).toBe(128000);
});
test('should handle DeepSeek models with provider prefixes', () => {
expect(getModelMaxTokens('deepseek/deepseek-chat')).toBe(128000);
expect(getModelMaxTokens('openrouter/deepseek-reasoner')).toBe(128000);
expect(getModelMaxTokens('openai/deepseek-v3')).toBe(128000);
});
}); });
describe('matchModelName', () => { describe('matchModelName', () => {
@ -702,11 +719,42 @@ describe('Meta Models Tests', () => {
}); });
test('should match Deepseek model variations', () => { test('should match Deepseek model variations', () => {
expect(matchModelName('deepseek-chat')).toBe('deepseek'); expect(matchModelName('deepseek-chat')).toBe('deepseek-chat');
expect(matchModelName('deepseek-coder')).toBe('deepseek'); expect(matchModelName('deepseek-coder')).toBe('deepseek');
}); });
}); });
describe('DeepSeek Max Output Tokens', () => {
const { getModelMaxOutputTokens } = require('@librechat/api');
test('should return correct max output tokens for deepseek-chat', () => {
expect(getModelMaxOutputTokens('deepseek-chat')).toBe(8000);
expect(getModelMaxOutputTokens('deepseek-chat', EModelEndpoint.openAI)).toBe(8000);
expect(getModelMaxOutputTokens('deepseek-chat', EModelEndpoint.custom)).toBe(8000);
});
test('should return correct max output tokens for deepseek-reasoner', () => {
expect(getModelMaxOutputTokens('deepseek-reasoner')).toBe(64000);
expect(getModelMaxOutputTokens('deepseek-reasoner', EModelEndpoint.openAI)).toBe(64000);
expect(getModelMaxOutputTokens('deepseek-reasoner', EModelEndpoint.custom)).toBe(64000);
});
test('should return correct max output tokens for deepseek-r1', () => {
expect(getModelMaxOutputTokens('deepseek-r1')).toBe(64000);
expect(getModelMaxOutputTokens('deepseek-r1', EModelEndpoint.openAI)).toBe(64000);
});
test('should return correct max output tokens for deepseek base pattern', () => {
expect(getModelMaxOutputTokens('deepseek')).toBe(8000);
expect(getModelMaxOutputTokens('deepseek-v3')).toBe(8000);
});
test('should handle DeepSeek models with provider prefixes for max output tokens', () => {
expect(getModelMaxOutputTokens('deepseek/deepseek-chat')).toBe(8000);
expect(getModelMaxOutputTokens('openrouter/deepseek-reasoner')).toBe(64000);
});
});
describe('processModelData with Meta models', () => { describe('processModelData with Meta models', () => {
test('should process Meta model data correctly', () => { test('should process Meta model data correctly', () => {
const input = { const input = {
@ -775,6 +823,16 @@ describe('Grok Model Tests - Tokens', () => {
expect(getModelMaxTokens('grok-4-0709')).toBe(256000); expect(getModelMaxTokens('grok-4-0709')).toBe(256000);
}); });
test('should return correct tokens for Grok 4 Fast and Grok 4.1 Fast models', () => {
expect(getModelMaxTokens('grok-4-fast')).toBe(2000000);
expect(getModelMaxTokens('grok-4-1-fast-reasoning')).toBe(2000000);
expect(getModelMaxTokens('grok-4-1-fast-non-reasoning')).toBe(2000000);
});
test('should return correct tokens for Grok Code Fast model', () => {
expect(getModelMaxTokens('grok-code-fast-1')).toBe(256000);
});
test('should handle partial matches for Grok models with prefixes', () => { test('should handle partial matches for Grok models with prefixes', () => {
// Vision models should match before general models // Vision models should match before general models
expect(getModelMaxTokens('xai/grok-2-vision-1212')).toBe(32768); expect(getModelMaxTokens('xai/grok-2-vision-1212')).toBe(32768);
@ -794,6 +852,12 @@ describe('Grok Model Tests - Tokens', () => {
expect(getModelMaxTokens('xai/grok-3-mini-fast')).toBe(131072); expect(getModelMaxTokens('xai/grok-3-mini-fast')).toBe(131072);
// Grok 4 model // Grok 4 model
expect(getModelMaxTokens('xai/grok-4-0709')).toBe(256000); expect(getModelMaxTokens('xai/grok-4-0709')).toBe(256000);
// Grok 4 Fast and 4.1 Fast models
expect(getModelMaxTokens('xai/grok-4-fast')).toBe(2000000);
expect(getModelMaxTokens('xai/grok-4-1-fast-reasoning')).toBe(2000000);
expect(getModelMaxTokens('xai/grok-4-1-fast-non-reasoning')).toBe(2000000);
// Grok Code Fast model
expect(getModelMaxTokens('xai/grok-code-fast-1')).toBe(256000);
}); });
}); });
@ -817,6 +881,12 @@ describe('Grok Model Tests - Tokens', () => {
expect(matchModelName('grok-3-mini-fast')).toBe('grok-3-mini-fast'); expect(matchModelName('grok-3-mini-fast')).toBe('grok-3-mini-fast');
// Grok 4 model // Grok 4 model
expect(matchModelName('grok-4-0709')).toBe('grok-4'); expect(matchModelName('grok-4-0709')).toBe('grok-4');
// Grok 4 Fast and 4.1 Fast models
expect(matchModelName('grok-4-fast')).toBe('grok-4-fast');
expect(matchModelName('grok-4-1-fast-reasoning')).toBe('grok-4-1-fast');
expect(matchModelName('grok-4-1-fast-non-reasoning')).toBe('grok-4-1-fast');
// Grok Code Fast model
expect(matchModelName('grok-code-fast-1')).toBe('grok-code-fast');
}); });
test('should match Grok model variations with prefixes', () => { test('should match Grok model variations with prefixes', () => {
@ -838,6 +908,12 @@ describe('Grok Model Tests - Tokens', () => {
expect(matchModelName('xai/grok-3-mini-fast')).toBe('grok-3-mini-fast'); expect(matchModelName('xai/grok-3-mini-fast')).toBe('grok-3-mini-fast');
// Grok 4 model // Grok 4 model
expect(matchModelName('xai/grok-4-0709')).toBe('grok-4'); expect(matchModelName('xai/grok-4-0709')).toBe('grok-4');
// Grok 4 Fast and 4.1 Fast models
expect(matchModelName('xai/grok-4-fast')).toBe('grok-4-fast');
expect(matchModelName('xai/grok-4-1-fast-reasoning')).toBe('grok-4-1-fast');
expect(matchModelName('xai/grok-4-1-fast-non-reasoning')).toBe('grok-4-1-fast');
// Grok Code Fast model
expect(matchModelName('xai/grok-code-fast-1')).toBe('grok-code-fast');
}); });
}); });
}); });
@ -861,6 +937,15 @@ describe('Claude Model Tests', () => {
); );
}); });
it('should return correct context length for Claude Opus 4.5', () => {
expect(getModelMaxTokens('claude-opus-4-5', EModelEndpoint.anthropic)).toBe(
maxTokensMap[EModelEndpoint.anthropic]['claude-opus-4-5'],
);
expect(getModelMaxTokens('claude-opus-4-5')).toBe(
maxTokensMap[EModelEndpoint.anthropic]['claude-opus-4-5'],
);
});
it('should handle Claude Haiku 4.5 model name variations', () => { it('should handle Claude Haiku 4.5 model name variations', () => {
const modelVariations = [ const modelVariations = [
'claude-haiku-4-5', 'claude-haiku-4-5',
@ -880,6 +965,25 @@ describe('Claude Model Tests', () => {
}); });
}); });
it('should handle Claude Opus 4.5 model name variations', () => {
const modelVariations = [
'claude-opus-4-5',
'claude-opus-4-5-20250420',
'claude-opus-4-5-latest',
'anthropic/claude-opus-4-5',
'claude-opus-4-5/anthropic',
'claude-opus-4-5-preview',
];
modelVariations.forEach((model) => {
const modelKey = findMatchingPattern(model, maxTokensMap[EModelEndpoint.anthropic]);
expect(modelKey).toBe('claude-opus-4-5');
expect(getModelMaxTokens(model, EModelEndpoint.anthropic)).toBe(
maxTokensMap[EModelEndpoint.anthropic]['claude-opus-4-5'],
);
});
});
it('should match model names correctly for Claude Haiku 4.5', () => { it('should match model names correctly for Claude Haiku 4.5', () => {
const modelVariations = [ const modelVariations = [
'claude-haiku-4-5', 'claude-haiku-4-5',
@ -895,6 +999,21 @@ describe('Claude Model Tests', () => {
}); });
}); });
it('should match model names correctly for Claude Opus 4.5', () => {
const modelVariations = [
'claude-opus-4-5',
'claude-opus-4-5-20250420',
'claude-opus-4-5-latest',
'anthropic/claude-opus-4-5',
'claude-opus-4-5/anthropic',
'claude-opus-4-5-preview',
];
modelVariations.forEach((model) => {
expect(matchModelName(model, EModelEndpoint.anthropic)).toBe('claude-opus-4-5');
});
});
it('should handle Claude 4 model name variations with different prefixes and suffixes', () => { it('should handle Claude 4 model name variations with different prefixes and suffixes', () => {
const modelVariations = [ const modelVariations = [
'claude-sonnet-4', 'claude-sonnet-4',

12229
bun.lock Normal file

File diff suppressed because it is too large Load diff

BIN
bun.lockb

Binary file not shown.

View file

@ -1,4 +1,4 @@
/** v0.8.1-rc1 */ /** v0.8.1 */
module.exports = { module.exports = {
roots: ['<rootDir>/src'], roots: ['<rootDir>/src'],
testEnvironment: 'jsdom', testEnvironment: 'jsdom',
@ -41,7 +41,6 @@ module.exports = {
'jest-file-loader', 'jest-file-loader',
}, },
transformIgnorePatterns: ['node_modules/?!@zattoo/use-double-click'], transformIgnorePatterns: ['node_modules/?!@zattoo/use-double-click'],
preset: 'ts-jest',
setupFilesAfterEnv: ['@testing-library/jest-dom/extend-expect', '<rootDir>/test/setupTests.js'], setupFilesAfterEnv: ['@testing-library/jest-dom/extend-expect', '<rootDir>/test/setupTests.js'],
clearMocks: true, clearMocks: true,
}; };

View file

@ -1,6 +1,6 @@
{ {
"name": "@librechat/frontend", "name": "@librechat/frontend",
"version": "v0.8.1-rc1", "version": "v0.8.1",
"description": "", "description": "",
"type": "module", "type": "module",
"scripts": { "scripts": {
@ -64,6 +64,7 @@
"copy-to-clipboard": "^3.3.3", "copy-to-clipboard": "^3.3.3",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"date-fns": "^3.3.1", "date-fns": "^3.3.1",
"dompurify": "^3.3.0",
"downloadjs": "^1.4.7", "downloadjs": "^1.4.7",
"export-from-json": "^1.7.2", "export-from-json": "^1.7.2",
"filenamify": "^6.0.0", "filenamify": "^6.0.0",
@ -147,7 +148,6 @@
"postcss-loader": "^7.1.0", "postcss-loader": "^7.1.0",
"postcss-preset-env": "^8.2.0", "postcss-preset-env": "^8.2.0",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"ts-jest": "^29.4.5",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"vite": "^6.4.1", "vite": "^6.4.1",
"vite-plugin-compression2": "^2.2.1", "vite-plugin-compression2": "^2.2.1",

View file

@ -8,6 +8,7 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { Toast, ThemeProvider, ToastProvider } from '@librechat/client'; import { Toast, ThemeProvider, ToastProvider } from '@librechat/client';
import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query';
import { ScreenshotProvider, useApiErrorBoundary } from './hooks'; import { ScreenshotProvider, useApiErrorBoundary } from './hooks';
import WakeLockManager from '~/components/System/WakeLockManager';
import { getThemeFromEnv } from './utils/getThemeFromEnv'; import { getThemeFromEnv } from './utils/getThemeFromEnv';
import { initializeFontSize } from '~/store/fontSize'; import { initializeFontSize } from '~/store/fontSize';
import { LiveAnnouncer } from '~/a11y'; import { LiveAnnouncer } from '~/a11y';
@ -51,6 +52,7 @@ const App = () => {
<ToastProvider> <ToastProvider>
<DndProvider backend={HTML5Backend}> <DndProvider backend={HTML5Backend}>
<RouterProvider router={router} /> <RouterProvider router={router} />
<WakeLockManager />
<ReactQueryDevtools initialIsOpen={false} position="top-right" /> <ReactQueryDevtools initialIsOpen={false} position="top-right" />
<Toast /> <Toast />
<RadixToast.Viewport className="pointer-events-none fixed inset-0 z-[1000] mx-auto my-2 flex max-w-[560px] flex-col items-stretch justify-start md:pb-5" /> <RadixToast.Viewport className="pointer-events-none fixed inset-0 z-[1000] mx-auto my-2 flex max-w-[560px] flex-col items-stretch justify-start md:pb-5" />

View file

@ -41,4 +41,8 @@ export type AgentForm = {
recursion_limit?: number; recursion_limit?: number;
support_contact?: SupportContact; support_contact?: SupportContact;
category: string; category: string;
// Avatar management fields
avatar_file?: File | null;
avatar_preview?: string | null;
avatar_action?: 'upload' | 'reset' | null;
} & TAgentCapabilities; } & TAgentCapabilities;

View file

@ -59,7 +59,12 @@ export default function ArtifactVersion({
<TooltipAnchor <TooltipAnchor
description={localize('com_ui_change_version')} description={localize('com_ui_change_version')}
render={ render={
<Button size="icon" variant="ghost" asChild> <Button
size="icon"
variant="ghost"
asChild
aria-label={localize('com_ui_change_version')}
>
<MenuButton> <MenuButton>
<History <History
size={18} size={18}

View file

@ -75,7 +75,7 @@ function AuthLayout({
<div className="flex flex-grow items-center justify-center"> <div className="flex flex-grow items-center justify-center">
<div className="w-authPageWidth overflow-hidden bg-white px-6 py-4 dark:bg-gray-900 sm:max-w-md sm:rounded-lg"> <div className="w-authPageWidth overflow-hidden bg-white px-6 py-4 dark:bg-gray-900 sm:max-w-md sm:rounded-lg">
{!hasStartupConfigError && !isFetching && ( {!hasStartupConfigError && !isFetching && header && (
<h1 <h1
className="mb-4 text-center text-3xl font-semibold text-black dark:text-white" className="mb-4 text-center text-3xl font-semibold text-black dark:text-white"
style={{ userSelect: 'none' }} style={{ userSelect: 'none' }}

View file

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { ErrorTypes } from 'librechat-data-provider'; import { ErrorTypes, registerPage } from 'librechat-data-provider';
import { OpenIDIcon, useToastContext } from '@librechat/client'; import { OpenIDIcon, useToastContext } from '@librechat/client';
import { useOutletContext, useSearchParams } from 'react-router-dom'; import { useOutletContext, useSearchParams } from 'react-router-dom';
import type { TLoginLayoutContext } from '~/common'; import type { TLoginLayoutContext } from '~/common';
@ -104,7 +104,7 @@ function Login() {
{' '} {' '}
{localize('com_auth_no_account')}{' '} {localize('com_auth_no_account')}{' '}
<a <a
href="/register" href={registerPage()}
className="inline-flex p-1 text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300" className="inline-flex p-1 text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
> >
{localize('com_auth_sign_up')} {localize('com_auth_sign_up')}

View file

@ -4,6 +4,7 @@ import { Turnstile } from '@marsidev/react-turnstile';
import { ThemeContext, Spinner, Button, isDark } from '@librechat/client'; import { ThemeContext, Spinner, Button, isDark } from '@librechat/client';
import { useNavigate, useOutletContext, useLocation } from 'react-router-dom'; import { useNavigate, useOutletContext, useLocation } from 'react-router-dom';
import { useRegisterUserMutation } from 'librechat-data-provider/react-query'; import { useRegisterUserMutation } from 'librechat-data-provider/react-query';
import { loginPage } from 'librechat-data-provider';
import type { TRegisterUser, TError } from 'librechat-data-provider'; import type { TRegisterUser, TError } from 'librechat-data-provider';
import type { TLoginLayoutContext } from '~/common'; import type { TLoginLayoutContext } from '~/common';
import { useLocalize, TranslationKeys } from '~/hooks'; import { useLocalize, TranslationKeys } from '~/hooks';
@ -213,7 +214,7 @@ const Registration: React.FC = () => {
<p className="my-4 text-center text-sm font-light text-gray-700 dark:text-white"> <p className="my-4 text-center text-sm font-light text-gray-700 dark:text-white">
{localize('com_auth_already_have_account')}{' '} {localize('com_auth_already_have_account')}{' '}
<a <a
href="/login" href={loginPage()}
aria-label="Login" aria-label="Login"
className="inline-flex p-1 text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300" className="inline-flex p-1 text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
> >

View file

@ -3,6 +3,7 @@ import { useState, ReactNode } from 'react';
import { Spinner, Button } from '@librechat/client'; import { Spinner, Button } from '@librechat/client';
import { useOutletContext } from 'react-router-dom'; import { useOutletContext } from 'react-router-dom';
import { useRequestPasswordResetMutation } from 'librechat-data-provider/react-query'; import { useRequestPasswordResetMutation } from 'librechat-data-provider/react-query';
import { loginPage } from 'librechat-data-provider';
import type { TRequestPasswordReset, TRequestPasswordResetResponse } from 'librechat-data-provider'; import type { TRequestPasswordReset, TRequestPasswordResetResponse } from 'librechat-data-provider';
import type { TLoginLayoutContext } from '~/common'; import type { TLoginLayoutContext } from '~/common';
import type { FC } from 'react'; import type { FC } from 'react';
@ -26,7 +27,7 @@ const ResetPasswordBodyText = () => {
<p>{localize('com_auth_reset_password_if_email_exists')}</p> <p>{localize('com_auth_reset_password_if_email_exists')}</p>
<a <a
className="inline-flex text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300" className="inline-flex text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
href="/login" href={loginPage()}
> >
{localize('com_auth_back_to_login')} {localize('com_auth_back_to_login')}
</a> </a>
@ -134,7 +135,7 @@ function RequestPasswordReset() {
{isLoading ? <Spinner /> : localize('com_auth_continue')} {isLoading ? <Spinner /> : localize('com_auth_continue')}
</Button> </Button>
<a <a
href="/login" href={loginPage()}
className="block text-center text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300" className="block text-center text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
> >
{localize('com_auth_back_to_login')} {localize('com_auth_back_to_login')}

View file

@ -72,7 +72,7 @@ const BookmarkForm = ({
} }
const allTags = const allTags =
queryClient.getQueryData<TConversationTag[]>([QueryKeys.conversationTags]) ?? []; queryClient.getQueryData<TConversationTag[]>([QueryKeys.conversationTags]) ?? [];
if (allTags.some((tag) => tag.tag === data.tag)) { if (allTags.some((tag) => tag.tag === data.tag && tag.tag !== bookmark?.tag)) {
showToast({ showToast({
message: localize('com_ui_bookmarks_create_exists'), message: localize('com_ui_bookmarks_create_exists'),
status: 'warning', status: 'warning',

View file

@ -0,0 +1,499 @@
import React, { createRef } from 'react';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import BookmarkForm from '../BookmarkForm';
import type { TConversationTag } from 'librechat-data-provider';
const mockMutate = jest.fn();
const mockShowToast = jest.fn();
const mockGetQueryData = jest.fn();
const mockSetOpen = jest.fn();
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string, params?: Record<string, unknown>) => {
const translations: Record<string, string> = {
com_ui_bookmarks_title: 'Title',
com_ui_bookmarks_description: 'Description',
com_ui_bookmarks_edit: 'Edit Bookmark',
com_ui_bookmarks_new: 'New Bookmark',
com_ui_bookmarks_create_exists: 'This bookmark already exists',
com_ui_bookmarks_add_to_conversation: 'Add to current conversation',
com_ui_bookmarks_tag_exists: 'A bookmark with this title already exists',
com_ui_field_required: 'This field is required',
com_ui_field_max_length: `${params?.field || 'Field'} must be less than ${params?.length || 0} characters`,
};
return translations[key] || key;
},
}));
jest.mock('@librechat/client', () => {
const ActualReact = jest.requireActual<typeof import('react')>('react');
return {
Checkbox: ({
checked,
onCheckedChange,
value,
...props
}: {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
value: string;
}) =>
ActualReact.createElement('input', {
type: 'checkbox',
checked,
onChange: (e: React.ChangeEvent<HTMLInputElement>) => onCheckedChange(e.target.checked),
value,
...props,
}),
Label: ({ children, ...props }: { children: React.ReactNode }) =>
ActualReact.createElement('label', props, children),
TextareaAutosize: ActualReact.forwardRef<
HTMLTextAreaElement,
React.TextareaHTMLAttributes<HTMLTextAreaElement>
>((props, ref) => ActualReact.createElement('textarea', { ref, ...props })),
Input: ActualReact.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
(props, ref) => ActualReact.createElement('input', { ref, ...props }),
),
useToastContext: () => ({
showToast: mockShowToast,
}),
};
});
jest.mock('~/Providers/BookmarkContext', () => ({
useBookmarkContext: () => ({
bookmarks: [],
}),
}));
jest.mock('@tanstack/react-query', () => ({
useQueryClient: () => ({
getQueryData: mockGetQueryData,
}),
}));
jest.mock('~/utils', () => ({
cn: (...classes: (string | undefined | null | boolean)[]) => classes.filter(Boolean).join(' '),
logger: {
log: jest.fn(),
},
}));
const createMockBookmark = (overrides?: Partial<TConversationTag>): TConversationTag => ({
_id: 'bookmark-1',
user: 'user-1',
tag: 'Test Bookmark',
description: 'Test description',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
count: 1,
position: 0,
...overrides,
});
const createMockMutation = (isLoading = false) => ({
mutate: mockMutate,
isLoading,
isError: false,
isSuccess: false,
data: undefined,
error: null,
reset: jest.fn(),
mutateAsync: jest.fn(),
status: 'idle' as const,
variables: undefined,
context: undefined,
failureCount: 0,
failureReason: null,
isPaused: false,
isIdle: true,
submittedAt: 0,
});
describe('BookmarkForm - Bookmark Editing', () => {
const formRef = createRef<HTMLFormElement>();
beforeEach(() => {
jest.clearAllMocks();
mockGetQueryData.mockReturnValue([]);
});
describe('Editing only the description (tag unchanged)', () => {
it('should allow submitting when only the description is changed', async () => {
const existingBookmark = createMockBookmark({
tag: 'My Bookmark',
description: 'Original description',
});
mockGetQueryData.mockReturnValue([existingBookmark]);
render(
<BookmarkForm
bookmark={existingBookmark}
mutation={
createMockMutation() as ReturnType<
typeof import('~/data-provider').useConversationTagMutation
>
}
setOpen={mockSetOpen}
formRef={formRef}
/>,
);
const descriptionInput = screen.getByRole('textbox', { name: /description/i });
await act(async () => {
fireEvent.change(descriptionInput, { target: { value: 'Updated description' } });
});
await act(async () => {
fireEvent.submit(formRef.current!);
});
await waitFor(() => {
expect(mockMutate).toHaveBeenCalledWith(
expect.objectContaining({
tag: 'My Bookmark',
description: 'Updated description',
}),
);
});
expect(mockShowToast).not.toHaveBeenCalled();
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
it('should not submit when both tag and description are unchanged', async () => {
const existingBookmark = createMockBookmark({
tag: 'My Bookmark',
description: 'Same description',
});
mockGetQueryData.mockReturnValue([existingBookmark]);
render(
<BookmarkForm
bookmark={existingBookmark}
mutation={
createMockMutation() as ReturnType<
typeof import('~/data-provider').useConversationTagMutation
>
}
setOpen={mockSetOpen}
formRef={formRef}
/>,
);
await act(async () => {
fireEvent.submit(formRef.current!);
});
await waitFor(() => {
expect(mockMutate).not.toHaveBeenCalled();
});
expect(mockSetOpen).not.toHaveBeenCalled();
});
});
describe('Renaming a tag to an existing tag (should show error)', () => {
it('should show error toast when renaming to an existing tag name (via allTags)', async () => {
const existingBookmark = createMockBookmark({
tag: 'Original Tag',
description: 'Description',
});
const otherBookmark = createMockBookmark({
_id: 'bookmark-2',
tag: 'Existing Tag',
description: 'Other description',
});
mockGetQueryData.mockReturnValue([existingBookmark, otherBookmark]);
render(
<BookmarkForm
bookmark={existingBookmark}
mutation={
createMockMutation() as ReturnType<
typeof import('~/data-provider').useConversationTagMutation
>
}
setOpen={mockSetOpen}
formRef={formRef}
/>,
);
const tagInput = screen.getByLabelText('Edit Bookmark');
await act(async () => {
fireEvent.change(tagInput, { target: { value: 'Existing Tag' } });
});
await act(async () => {
fireEvent.submit(formRef.current!);
});
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith({
message: 'This bookmark already exists',
status: 'warning',
});
});
expect(mockMutate).not.toHaveBeenCalled();
expect(mockSetOpen).not.toHaveBeenCalled();
});
it('should show error toast when renaming to an existing tag name (via tags prop)', async () => {
const existingBookmark = createMockBookmark({
tag: 'Original Tag',
description: 'Description',
});
mockGetQueryData.mockReturnValue([existingBookmark]);
render(
<BookmarkForm
tags={['Existing Tag', 'Another Tag']}
bookmark={existingBookmark}
mutation={
createMockMutation() as ReturnType<
typeof import('~/data-provider').useConversationTagMutation
>
}
setOpen={mockSetOpen}
formRef={formRef}
/>,
);
const tagInput = screen.getByLabelText('Edit Bookmark');
await act(async () => {
fireEvent.change(tagInput, { target: { value: 'Existing Tag' } });
});
await act(async () => {
fireEvent.submit(formRef.current!);
});
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith({
message: 'This bookmark already exists',
status: 'warning',
});
});
expect(mockMutate).not.toHaveBeenCalled();
expect(mockSetOpen).not.toHaveBeenCalled();
});
});
describe('Renaming a tag to a new tag (should succeed)', () => {
it('should allow renaming to a completely new tag name', async () => {
const existingBookmark = createMockBookmark({
tag: 'Original Tag',
description: 'Description',
});
mockGetQueryData.mockReturnValue([existingBookmark]);
render(
<BookmarkForm
bookmark={existingBookmark}
mutation={
createMockMutation() as ReturnType<
typeof import('~/data-provider').useConversationTagMutation
>
}
setOpen={mockSetOpen}
formRef={formRef}
/>,
);
const tagInput = screen.getByLabelText('Edit Bookmark');
await act(async () => {
fireEvent.change(tagInput, { target: { value: 'Brand New Tag' } });
});
await act(async () => {
fireEvent.submit(formRef.current!);
});
await waitFor(() => {
expect(mockMutate).toHaveBeenCalledWith(
expect.objectContaining({
tag: 'Brand New Tag',
description: 'Description',
}),
);
});
expect(mockShowToast).not.toHaveBeenCalled();
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
it('should allow keeping the same tag name when editing (not trigger duplicate error)', async () => {
const existingBookmark = createMockBookmark({
tag: 'My Bookmark',
description: 'Original description',
});
mockGetQueryData.mockReturnValue([existingBookmark]);
render(
<BookmarkForm
bookmark={existingBookmark}
mutation={
createMockMutation() as ReturnType<
typeof import('~/data-provider').useConversationTagMutation
>
}
setOpen={mockSetOpen}
formRef={formRef}
/>,
);
const descriptionInput = screen.getByRole('textbox', { name: /description/i });
await act(async () => {
fireEvent.change(descriptionInput, { target: { value: 'New description' } });
});
await act(async () => {
fireEvent.submit(formRef.current!);
});
await waitFor(() => {
expect(mockMutate).toHaveBeenCalledWith(
expect.objectContaining({
tag: 'My Bookmark',
description: 'New description',
}),
);
});
expect(mockShowToast).not.toHaveBeenCalled();
});
});
describe('Validation interaction between different data sources', () => {
it('should check both tags prop and allTags query data for duplicates', async () => {
const existingBookmark = createMockBookmark({
tag: 'Original Tag',
description: 'Description',
});
const queryDataBookmark = createMockBookmark({
_id: 'bookmark-query',
tag: 'Query Data Tag',
});
mockGetQueryData.mockReturnValue([existingBookmark, queryDataBookmark]);
render(
<BookmarkForm
tags={['Props Tag']}
bookmark={existingBookmark}
mutation={
createMockMutation() as ReturnType<
typeof import('~/data-provider').useConversationTagMutation
>
}
setOpen={mockSetOpen}
formRef={formRef}
/>,
);
const tagInput = screen.getByLabelText('Edit Bookmark');
await act(async () => {
fireEvent.change(tagInput, { target: { value: 'Props Tag' } });
});
await act(async () => {
fireEvent.submit(formRef.current!);
});
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith({
message: 'This bookmark already exists',
status: 'warning',
});
});
expect(mockMutate).not.toHaveBeenCalled();
});
it('should not trigger mutation when mutation is loading', async () => {
const existingBookmark = createMockBookmark({
tag: 'My Bookmark',
description: 'Description',
});
mockGetQueryData.mockReturnValue([existingBookmark]);
render(
<BookmarkForm
bookmark={existingBookmark}
mutation={
createMockMutation(true) as ReturnType<
typeof import('~/data-provider').useConversationTagMutation
>
}
setOpen={mockSetOpen}
formRef={formRef}
/>,
);
const descriptionInput = screen.getByRole('textbox', { name: /description/i });
await act(async () => {
fireEvent.change(descriptionInput, { target: { value: 'Updated description' } });
});
await act(async () => {
fireEvent.submit(formRef.current!);
});
await waitFor(() => {
expect(mockMutate).not.toHaveBeenCalled();
});
});
it('should handle empty allTags gracefully', async () => {
const existingBookmark = createMockBookmark({
tag: 'My Bookmark',
description: 'Description',
});
mockGetQueryData.mockReturnValue(null);
render(
<BookmarkForm
bookmark={existingBookmark}
mutation={
createMockMutation() as ReturnType<
typeof import('~/data-provider').useConversationTagMutation
>
}
setOpen={mockSetOpen}
formRef={formRef}
/>,
);
const tagInput = screen.getByLabelText('Edit Bookmark');
await act(async () => {
fireEvent.change(tagInput, { target: { value: 'New Tag' } });
});
await act(async () => {
fireEvent.submit(formRef.current!);
});
await waitFor(() => {
expect(mockMutate).toHaveBeenCalledWith(
expect.objectContaining({
tag: 'New Tag',
}),
);
});
});
});
});

View file

@ -11,6 +11,7 @@ import BookmarkMenu from './Menus/BookmarkMenu';
import { TemporaryChat } from './TemporaryChat'; import { TemporaryChat } from './TemporaryChat';
import AddMultiConvo from './AddMultiConvo'; import AddMultiConvo from './AddMultiConvo';
import { useHasAccess } from '~/hooks'; import { useHasAccess } from '~/hooks';
import { AnimatePresence, motion } from 'framer-motion';
const defaultInterface = getConfigDefaults().interface; const defaultInterface = getConfigDefaults().interface;
@ -38,24 +39,24 @@ export default function Header() {
return ( return (
<div className="sticky top-0 z-10 flex h-14 w-full items-center justify-between bg-white p-2 font-semibold text-text-primary dark:bg-gray-800"> <div className="sticky top-0 z-10 flex h-14 w-full items-center justify-between bg-white p-2 font-semibold text-text-primary dark:bg-gray-800">
<div className="hide-scrollbar flex w-full items-center justify-between gap-2 overflow-x-auto"> <div className="hide-scrollbar flex w-full items-center justify-between gap-2 overflow-x-auto">
<div className="mx-1 flex items-center gap-2"> <div className="mx-1 flex items-center">
<div <AnimatePresence initial={false}>
className={`flex items-center gap-2 ${ {!navVisible && (
!isSmallScreen ? 'transition-all duration-200 ease-in-out' : '' <motion.div
} ${ className={`flex items-center gap-2`}
!navVisible initial={{ width: 0, opacity: 0 }}
? 'translate-x-0 opacity-100' animate={{ width: 'auto', opacity: 1 }}
: 'pointer-events-none translate-x-[-100px] opacity-0' exit={{ width: 0, opacity: 0 }}
}`} transition={{ duration: 0.2 }}
> key="header-buttons"
<OpenSidebar setNavVisible={setNavVisible} className="max-md:hidden" /> >
<HeaderNewChat /> <OpenSidebar setNavVisible={setNavVisible} className="max-md:hidden" />
</div> <HeaderNewChat />
<div </motion.div>
className={`flex items-center gap-2 ${ )}
!isSmallScreen ? 'transition-all duration-200 ease-in-out' : '' </AnimatePresence>
} ${!navVisible ? 'translate-x-0' : 'translate-x-[-100px]'}`}
> <div className={navVisible ? 'flex items-center gap-2' : 'ml-2 flex items-center gap-2'}>
<ModelSelector startupConfig={startupConfig} /> <ModelSelector startupConfig={startupConfig} />
{interfaceConfig.presets === true && interfaceConfig.modelSelect && <PresetsMenu />} {interfaceConfig.presets === true && interfaceConfig.modelSelect && <PresetsMenu />}
{hasAccessToBookmarks === true && <BookmarkMenu />} {hasAccessToBookmarks === true && <BookmarkMenu />}

View file

@ -260,37 +260,50 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
<FileFormChat conversation={conversation} /> <FileFormChat conversation={conversation} />
{endpoint && ( {endpoint && (
<div className={cn('flex', isRTL ? 'flex-row-reverse' : 'flex-row')}> <div className={cn('flex', isRTL ? 'flex-row-reverse' : 'flex-row')}>
<TextareaAutosize <div className="relative flex-1">
{...registerProps} <TextareaAutosize
ref={(e) => { {...registerProps}
ref(e); ref={(e) => {
(textAreaRef as React.MutableRefObject<HTMLTextAreaElement | null>).current = e; ref(e);
}} (textAreaRef as React.MutableRefObject<HTMLTextAreaElement | null>).current =
disabled={disableInputs || isNotAppendable} e;
onPaste={handlePaste} }}
onKeyDown={handleKeyDown} disabled={disableInputs || isNotAppendable}
onKeyUp={handleKeyUp} onPaste={handlePaste}
onCompositionStart={handleCompositionStart} onKeyDown={handleKeyDown}
onCompositionEnd={handleCompositionEnd} onKeyUp={handleKeyUp}
id={mainTextareaId} onCompositionStart={handleCompositionStart}
tabIndex={0} onCompositionEnd={handleCompositionEnd}
data-testid="text-input" id={mainTextareaId}
rows={1} tabIndex={0}
onFocus={() => { data-testid="text-input"
handleFocusOrClick(); rows={1}
setIsTextAreaFocused(true); onFocus={() => {
}} handleFocusOrClick();
onBlur={setIsTextAreaFocused.bind(null, false)} setIsTextAreaFocused(true);
aria-label={localize('com_ui_message_input')} }}
onClick={handleFocusOrClick} onBlur={setIsTextAreaFocused.bind(null, false)}
style={{ height: 44, overflowY: 'auto' }} aria-label={localize('com_ui_message_input')}
className={cn( onClick={handleFocusOrClick}
baseClasses, style={{ height: 44, overflowY: 'auto' }}
removeFocusRings, className={cn(
'transition-[max-height] duration-200 disabled:cursor-not-allowed', baseClasses,
removeFocusRings,
'scrollbar-hover transition-[max-height] duration-200 disabled:cursor-not-allowed',
)}
/>
{isCollapsed && (
<div
className="pointer-events-none absolute bottom-0 left-0 right-0 h-10 transition-all duration-200"
style={{
backdropFilter: 'blur(2px)',
WebkitMaskImage: 'linear-gradient(to top, black 15%, transparent 75%)',
maskImage: 'linear-gradient(to top, black 15%, transparent 75%)',
}}
/>
)} )}
/> </div>
<div className="flex flex-col items-start justify-start pt-1.5"> <div className="flex flex-col items-start justify-start pr-2.5 pt-1.5">
<CollapseChat <CollapseChat
isCollapsed={isCollapsed} isCollapsed={isCollapsed}
isScrollable={isMoreThanThreeRows} isScrollable={isMoreThanThreeRows}

View file

@ -2,6 +2,7 @@ import React, { useMemo } from 'react';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { OGDialog, OGDialogTemplate } from '@librechat/client'; import { OGDialog, OGDialogTemplate } from '@librechat/client';
import { import {
inferMimeType,
EToolResources, EToolResources,
EModelEndpoint, EModelEndpoint,
defaultAgentCapabilities, defaultAgentCapabilities,
@ -56,18 +57,26 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD
const _options: FileOption[] = []; const _options: FileOption[] = [];
const currentProvider = provider || endpoint; const currentProvider = provider || endpoint;
/** Helper to get inferred MIME type for a file */
const getFileType = (file: File) => inferMimeType(file.name, file.type);
// Check if provider supports document upload // Check if provider supports document upload
if (isDocumentSupportedProvider(endpointType) || isDocumentSupportedProvider(currentProvider)) { if (isDocumentSupportedProvider(endpointType) || isDocumentSupportedProvider(currentProvider)) {
const isGoogleProvider = currentProvider === EModelEndpoint.google; const isGoogleProvider = currentProvider === EModelEndpoint.google;
const validFileTypes = isGoogleProvider const validFileTypes = isGoogleProvider
? files.every( ? files.every((file) => {
(file) => const type = getFileType(file);
file.type?.startsWith('image/') || return (
file.type?.startsWith('video/') || type?.startsWith('image/') ||
file.type?.startsWith('audio/') || type?.startsWith('video/') ||
file.type === 'application/pdf', type?.startsWith('audio/') ||
) type === 'application/pdf'
: files.every((file) => file.type?.startsWith('image/') || file.type === 'application/pdf'); );
})
: files.every((file) => {
const type = getFileType(file);
return type?.startsWith('image/') || type === 'application/pdf';
});
_options.push({ _options.push({
label: localize('com_ui_upload_provider'), label: localize('com_ui_upload_provider'),
@ -81,7 +90,7 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD
label: localize('com_ui_upload_image_input'), label: localize('com_ui_upload_image_input'),
value: undefined, value: undefined,
icon: <ImageUpIcon className="icon-md" />, icon: <ImageUpIcon className="icon-md" />,
condition: files.every((file) => file.type?.startsWith('image/')), condition: files.every((file) => getFileType(file)?.startsWith('image/')),
}); });
} }
if (capabilities.fileSearchEnabled && fileSearchAllowedByAgent) { if (capabilities.fileSearchEnabled && fileSearchAllowedByAgent) {

View file

@ -1,4 +1,8 @@
import { EModelEndpoint, isDocumentSupportedProvider } from 'librechat-data-provider'; import {
EModelEndpoint,
isDocumentSupportedProvider,
inferMimeType,
} from 'librechat-data-provider';
describe('DragDropModal - Provider Detection', () => { describe('DragDropModal - Provider Detection', () => {
describe('endpointType priority over currentProvider', () => { describe('endpointType priority over currentProvider', () => {
@ -118,4 +122,59 @@ describe('DragDropModal - Provider Detection', () => {
).toBe(true); ).toBe(true);
}); });
}); });
describe('HEIC/HEIF file type inference', () => {
it('should infer image/heic for .heic files when browser returns empty type', () => {
const fileName = 'photo.heic';
const browserType = '';
const inferredType = inferMimeType(fileName, browserType);
expect(inferredType).toBe('image/heic');
});
it('should infer image/heif for .heif files when browser returns empty type', () => {
const fileName = 'photo.heif';
const browserType = '';
const inferredType = inferMimeType(fileName, browserType);
expect(inferredType).toBe('image/heif');
});
it('should handle uppercase .HEIC extension', () => {
const fileName = 'IMG_1234.HEIC';
const browserType = '';
const inferredType = inferMimeType(fileName, browserType);
expect(inferredType).toBe('image/heic');
});
it('should preserve browser-provided type when available', () => {
const fileName = 'photo.jpg';
const browserType = 'image/jpeg';
const inferredType = inferMimeType(fileName, browserType);
expect(inferredType).toBe('image/jpeg');
});
it('should not override browser type even if extension differs', () => {
const fileName = 'renamed.heic';
const browserType = 'image/png';
const inferredType = inferMimeType(fileName, browserType);
expect(inferredType).toBe('image/png');
});
it('should correctly identify HEIC as image type for upload options', () => {
const heicType = inferMimeType('photo.heic', '');
expect(heicType.startsWith('image/')).toBe(true);
});
it('should return empty string for unknown extension with no browser type', () => {
const fileName = 'file.xyz';
const browserType = '';
const inferredType = inferMimeType(fileName, browserType);
expect(inferredType).toBe('');
});
});
}); });

View file

@ -166,6 +166,7 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
<TooltipAnchor <TooltipAnchor
className="absolute bottom-[27px] right-2" className="absolute bottom-[27px] right-2"
description={localize('com_ui_happy_birthday')} description={localize('com_ui_happy_birthday')}
aria-label={localize('com_ui_happy_birthday')}
> >
<BirthdayIcon /> <BirthdayIcon />
</TooltipAnchor> </TooltipAnchor>

View file

@ -180,6 +180,10 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
} }
}, [isPromptOpen, zoom]); }, [isPromptOpen, zoom]);
const imageDetailsLabel = isPromptOpen
? localize('com_ui_hide_image_details')
: localize('com_ui_show_image_details');
return ( return (
<OGDialog open={isOpen} onOpenChange={onOpenChange}> <OGDialog open={isOpen} onOpenChange={onOpenChange}>
<OGDialogContent <OGDialogContent
@ -198,6 +202,7 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
variant="ghost" variant="ghost"
className="h-10 w-10 p-0 hover:bg-surface-hover" className="h-10 w-10 p-0 hover:bg-surface-hover"
aria-label={localize('com_ui_close')}
> >
<X className="size-7 sm:size-6" /> <X className="size-7 sm:size-6" />
</Button> </Button>
@ -208,7 +213,12 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
<TooltipAnchor <TooltipAnchor
description={localize('com_ui_reset_zoom')} description={localize('com_ui_reset_zoom')}
render={ render={
<Button onClick={resetZoom} variant="ghost" className="h-10 w-10 p-0"> <Button
onClick={resetZoom}
variant="ghost"
className="h-10 w-10 p-0"
aria-label={localize('com_ui_reset_zoom')}
>
<RotateCcw className="size-6" /> <RotateCcw className="size-6" />
</Button> </Button>
} }
@ -217,22 +227,24 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
<TooltipAnchor <TooltipAnchor
description={localize('com_ui_download')} description={localize('com_ui_download')}
render={ render={
<Button onClick={() => downloadImage()} variant="ghost" className="h-10 w-10 p-0"> <Button
onClick={() => downloadImage()}
variant="ghost"
className="h-10 w-10 p-0"
aria-label={localize('com_ui_download')}
>
<ArrowDownToLine className="size-6" /> <ArrowDownToLine className="size-6" />
</Button> </Button>
} }
/> />
<TooltipAnchor <TooltipAnchor
description={ description={imageDetailsLabel}
isPromptOpen
? localize('com_ui_hide_image_details')
: localize('com_ui_show_image_details')
}
render={ render={
<Button <Button
onClick={() => setIsPromptOpen(!isPromptOpen)} onClick={() => setIsPromptOpen(!isPromptOpen)}
variant="ghost" variant="ghost"
className="h-10 w-10 p-0" className="h-10 w-10 p-0"
aria-label={imageDetailsLabel}
> >
{isPromptOpen ? ( {isPromptOpen ? (
<PanelLeftOpen className="size-7 sm:size-6" /> <PanelLeftOpen className="size-7 sm:size-6" />

View file

@ -1,6 +1,7 @@
import React, { useState, useRef, useMemo } from 'react'; import React, { useState, useRef, useMemo } from 'react';
import { Skeleton } from '@librechat/client'; import { Skeleton } from '@librechat/client';
import { LazyLoadImage } from 'react-lazy-load-image-component'; import { LazyLoadImage } from 'react-lazy-load-image-component';
import { apiBaseUrl } from 'librechat-data-provider';
import { cn, scaleImage } from '~/utils'; import { cn, scaleImage } from '~/utils';
import DialogImage from './DialogImage'; import DialogImage from './DialogImage';
@ -36,6 +37,24 @@ const Image = ({
const handleImageLoad = () => setIsLoaded(true); const handleImageLoad = () => setIsLoaded(true);
// Fix image path to include base path for subdirectory deployments
const absoluteImageUrl = useMemo(() => {
if (!imagePath) return imagePath;
// If it's already an absolute URL or doesn't start with /images/, return as is
if (
imagePath.startsWith('http') ||
imagePath.startsWith('data:') ||
!imagePath.startsWith('/images/')
) {
return imagePath;
}
// Get the base URL and prepend it to the image path
const baseURL = apiBaseUrl();
return `${baseURL}${imagePath}`;
}, [imagePath]);
const { width: scaledWidth, height: scaledHeight } = useMemo( const { width: scaledWidth, height: scaledHeight } = useMemo(
() => () =>
scaleImage({ scaleImage({
@ -48,7 +67,7 @@ const Image = ({
const downloadImage = async () => { const downloadImage = async () => {
try { try {
const response = await fetch(imagePath); const response = await fetch(absoluteImageUrl);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.status}`); throw new Error(`Failed to fetch image: ${response.status}`);
} }
@ -67,7 +86,7 @@ const Image = ({
} catch (error) { } catch (error) {
console.error('Download failed:', error); console.error('Download failed:', error);
const link = document.createElement('a'); const link = document.createElement('a');
link.href = imagePath; link.href = absoluteImageUrl;
link.download = altText || 'image.png'; link.download = altText || 'image.png';
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
@ -97,7 +116,7 @@ const Image = ({
'opacity-100 transition-opacity duration-100', 'opacity-100 transition-opacity duration-100',
isLoaded ? 'opacity-100' : 'opacity-0', isLoaded ? 'opacity-100' : 'opacity-0',
)} )}
src={imagePath} src={absoluteImageUrl}
style={{ style={{
width: `${scaledWidth}`, width: `${scaledWidth}`,
height: 'auto', height: 'auto',
@ -117,7 +136,7 @@ const Image = ({
<DialogImage <DialogImage
isOpen={isOpen} isOpen={isOpen}
onOpenChange={setIsOpen} onOpenChange={setIsOpen}
src={imagePath} src={absoluteImageUrl}
downloadImage={downloadImage} downloadImage={downloadImage}
args={args} args={args}
/> />

View file

@ -14,7 +14,7 @@ import { ArtifactProvider, CodeBlockProvider } from '~/Providers';
import MarkdownErrorBoundary from './MarkdownErrorBoundary'; import MarkdownErrorBoundary from './MarkdownErrorBoundary';
import { langSubset, preprocessLaTeX } from '~/utils'; import { langSubset, preprocessLaTeX } from '~/utils';
import { unicodeCitation } from '~/components/Web'; import { unicodeCitation } from '~/components/Web';
import { code, a, p } from './MarkdownComponents'; import { code, a, p, img } from './MarkdownComponents';
import store from '~/store'; import store from '~/store';
type TContentProps = { type TContentProps = {
@ -81,6 +81,7 @@ const Markdown = memo(({ content = '', isLatestMessage }: TContentProps) => {
code, code,
a, a,
p, p,
img,
artifact: Artifact, artifact: Artifact,
citation: Citation, citation: Citation,
'highlighted-text': HighlightedText, 'highlighted-text': HighlightedText,

Some files were not shown because too many files have changed in this diff Show more