mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +01:00
Merge branch 'main' into feature/entra-id-azure-integration
This commit is contained in:
commit
a7cf1ae27b
241 changed files with 25653 additions and 3303 deletions
|
|
@ -785,3 +785,7 @@ OPENWEATHER_API_KEY=
|
|||
|
||||
# Cache connection status checks for this many milliseconds to avoid expensive verification
|
||||
# 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
|
||||
|
|
|
|||
14
.github/workflows/client.yml
vendored
14
.github/workflows/client.yml
vendored
|
|
@ -13,9 +13,14 @@ on:
|
|||
required: false
|
||||
default: 'Manual publish requested'
|
||||
|
||||
permissions:
|
||||
id-token: write # Required for OIDC trusted publishing
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-and-publish:
|
||||
runs-on: ubuntu-latest
|
||||
environment: publish # Must match npm trusted publisher config
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
|
@ -23,6 +28,10 @@ jobs:
|
|||
uses: actions/setup-node@v4
|
||||
with:
|
||||
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
|
||||
run: cd packages/client && npm ci
|
||||
|
|
@ -30,9 +39,6 @@ jobs:
|
|||
- name: Build client
|
||||
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
|
||||
id: check
|
||||
working-directory: packages/client
|
||||
|
|
@ -55,4 +61,4 @@ jobs:
|
|||
- name: Publish
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
working-directory: packages/client
|
||||
run: npm publish *.tgz --access public
|
||||
run: npm publish *.tgz --access public --provenance
|
||||
|
|
|
|||
13
.github/workflows/data-provider.yml
vendored
13
.github/workflows/data-provider.yml
vendored
|
|
@ -13,6 +13,10 @@ on:
|
|||
required: false
|
||||
default: 'Manual publish requested'
|
||||
|
||||
permissions:
|
||||
id-token: write # Required for OIDC trusted publishing
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -27,14 +31,17 @@ jobs:
|
|||
publish-npm:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment: publish # Must match npm trusted publisher config
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
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 run build
|
||||
- run: cd packages/data-provider && npm publish
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
|
||||
- run: cd packages/data-provider && npm publish --provenance
|
||||
|
|
|
|||
14
.github/workflows/data-schemas.yml
vendored
14
.github/workflows/data-schemas.yml
vendored
|
|
@ -13,9 +13,14 @@ on:
|
|||
required: false
|
||||
default: 'Manual publish requested'
|
||||
|
||||
permissions:
|
||||
id-token: write # Required for OIDC trusted publishing
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-and-publish:
|
||||
runs-on: ubuntu-latest
|
||||
environment: publish # Must match npm trusted publisher config
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
|
@ -23,6 +28,10 @@ jobs:
|
|||
uses: actions/setup-node@v4
|
||||
with:
|
||||
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
|
||||
run: cd packages/data-schemas && npm ci
|
||||
|
|
@ -30,9 +39,6 @@ jobs:
|
|||
- name: 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
|
||||
id: check
|
||||
working-directory: packages/data-schemas
|
||||
|
|
@ -55,4 +61,4 @@ jobs:
|
|||
- name: Publish
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
working-directory: packages/data-schemas
|
||||
run: npm publish *.tgz --access public
|
||||
run: npm publish *.tgz --access public --provenance
|
||||
|
|
|
|||
66
.github/workflows/dev-staging-images.yml
vendored
Normal file
66
.github/workflows/dev-staging-images.yml
vendored
Normal 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 }}
|
||||
|
||||
16
.github/workflows/eslint-ci.yml
vendored
16
.github/workflows/eslint-ci.yml
vendored
|
|
@ -35,8 +35,6 @@ jobs:
|
|||
|
||||
# Run ESLint on changed files within the api/ and client/ directories.
|
||||
- name: Run ESLint on changed files
|
||||
env:
|
||||
SARIF_ESLINT_IGNORE_SUPPRESSED: "true"
|
||||
run: |
|
||||
# Extract the base commit SHA from the pull_request event payload.
|
||||
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
|
||||
if [[ -z "$CHANGED_FILES" ]]; then
|
||||
echo "No matching files changed. Skipping ESLint."
|
||||
echo "UPLOAD_SARIF=false" >> $GITHUB_ENV
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Set variable to allow SARIF upload
|
||||
echo "UPLOAD_SARIF=true" >> $GITHUB_ENV
|
||||
|
||||
# Run ESLint
|
||||
npx eslint --no-error-on-unmatched-pattern \
|
||||
--config eslint.config.mjs \
|
||||
--format @microsoft/eslint-formatter-sarif \
|
||||
--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
|
||||
$CHANGED_FILES
|
||||
31
.gitignore
vendored
31
.gitignore
vendored
|
|
@ -138,3 +138,34 @@ helm/**/.values.yaml
|
|||
/.tabnine/
|
||||
/.codeium
|
||||
*.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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# v0.8.1-rc1
|
||||
# v0.8.1
|
||||
|
||||
# Base node image
|
||||
FROM node:20-alpine AS node
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# Dockerfile.multi
|
||||
# v0.8.1-rc1
|
||||
# v0.8.1
|
||||
|
||||
# Base for all builds
|
||||
FROM node:20-alpine AS base-min
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ const crypto = require('crypto');
|
|||
const fetch = require('node-fetch');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const {
|
||||
countTokens,
|
||||
getBalanceConfig,
|
||||
extractFileContext,
|
||||
encodeAndFormatAudios,
|
||||
|
|
@ -23,7 +24,6 @@ const { getMessages, saveMessage, updateMessage, saveConvo, getConvo } = require
|
|||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||
const { checkBalance } = require('~/models/balanceMethods');
|
||||
const { truncateToolCallOutputs } = require('./prompts');
|
||||
const countTokens = require('~/server/utils/countTokens');
|
||||
const { getFiles } = require('~/models/File');
|
||||
const TextStream = require('./TextStream');
|
||||
|
||||
|
|
@ -81,6 +81,7 @@ class BaseClient {
|
|||
throw new Error("Method 'getCompletion' must be implemented.");
|
||||
}
|
||||
|
||||
/** @type {sendCompletion} */
|
||||
async sendCompletion() {
|
||||
throw new Error("Method 'sendCompletion' must be implemented.");
|
||||
}
|
||||
|
|
@ -689,8 +690,7 @@ class BaseClient {
|
|||
});
|
||||
}
|
||||
|
||||
/** @type {string|string[]|undefined} */
|
||||
const completion = await this.sendCompletion(payload, opts);
|
||||
const { completion, metadata } = await this.sendCompletion(payload, opts);
|
||||
if (this.abortController) {
|
||||
this.abortController.requestCompleted = true;
|
||||
}
|
||||
|
|
@ -708,6 +708,7 @@ class BaseClient {
|
|||
iconURL: this.options.iconURL,
|
||||
endpoint: this.options.endpoint,
|
||||
...(this.metadata ?? {}),
|
||||
metadata,
|
||||
};
|
||||
|
||||
if (typeof completion === 'string') {
|
||||
|
|
@ -1212,8 +1213,8 @@ class BaseClient {
|
|||
this.options.req,
|
||||
attachments,
|
||||
{
|
||||
provider: this.options.agent?.provider,
|
||||
endpoint: this.options.agent?.endpoint,
|
||||
provider: this.options.agent?.provider ?? this.options.endpoint,
|
||||
endpoint: this.options.agent?.endpoint ?? this.options.endpoint,
|
||||
useResponsesApi: this.options.agent?.model_parameters?.useResponsesApi,
|
||||
},
|
||||
getStrategyFunctions,
|
||||
|
|
@ -1230,8 +1231,8 @@ class BaseClient {
|
|||
this.options.req,
|
||||
attachments,
|
||||
{
|
||||
provider: this.options.agent?.provider,
|
||||
endpoint: this.options.agent?.endpoint,
|
||||
provider: this.options.agent?.provider ?? this.options.endpoint,
|
||||
endpoint: this.options.agent?.endpoint ?? this.options.endpoint,
|
||||
},
|
||||
getStrategyFunctions,
|
||||
);
|
||||
|
|
@ -1245,8 +1246,8 @@ class BaseClient {
|
|||
this.options.req,
|
||||
attachments,
|
||||
{
|
||||
provider: this.options.agent?.provider,
|
||||
endpoint: this.options.agent?.endpoint,
|
||||
provider: this.options.agent?.provider ?? this.options.endpoint,
|
||||
endpoint: this.options.agent?.endpoint ?? this.options.endpoint,
|
||||
},
|
||||
getStrategyFunctions,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
const { getBasePath } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
|
||||
/**
|
||||
|
|
@ -32,6 +33,8 @@ function addImages(intermediateSteps, responseMessage) {
|
|||
return;
|
||||
}
|
||||
|
||||
const basePath = getBasePath();
|
||||
|
||||
// Correct any erroneous URLs in the responseMessage.text first
|
||||
intermediateSteps.forEach((step) => {
|
||||
const { observation } = step;
|
||||
|
|
@ -44,12 +47,14 @@ function addImages(intermediateSteps, responseMessage) {
|
|||
return;
|
||||
}
|
||||
const essentialImagePath = match[0];
|
||||
const fullImagePath = `${basePath}${essentialImagePath}`;
|
||||
|
||||
const regex = /!\[.*?\]\((.*?)\)/g;
|
||||
let matchErroneous;
|
||||
while ((matchErroneous = regex.exec(responseMessage.text)) !== null) {
|
||||
if (matchErroneous[1] && !matchErroneous[1].startsWith('/images/')) {
|
||||
responseMessage.text = responseMessage.text.replace(matchErroneous[1], essentialImagePath);
|
||||
if (matchErroneous[1] && !matchErroneous[1].startsWith(`${basePath}/images/`)) {
|
||||
// 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;
|
||||
}
|
||||
const observedImagePath = observation.match(/!\[[^(]*\]\([^)]*\)/g);
|
||||
if (observedImagePath && !responseMessage.text.includes(observedImagePath[0])) {
|
||||
responseMessage.text += '\n' + observedImagePath[0];
|
||||
logger.debug('[addImages] added image from intermediateSteps:', observedImagePath[0]);
|
||||
if (observedImagePath) {
|
||||
// Fix the image path to include base path if it doesn't already
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ describe('addImages', () => {
|
|||
|
||||
it('should append correctly from a real scenario', () => {
|
||||
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 imageMarkdown = '';
|
||||
intermediateSteps.push({ observation: imageMarkdown });
|
||||
|
|
@ -139,4 +139,108 @@ describe('addImages', () => {
|
|||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe('\n');
|
||||
});
|
||||
|
||||
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: '' });
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe('\n');
|
||||
});
|
||||
|
||||
it('should not prepend base path when image URL already has base path', () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||
intermediateSteps.push({ observation: '' });
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe('\n');
|
||||
});
|
||||
|
||||
it('should correct erroneous URLs with base path', () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||
responseMessage.text = '';
|
||||
intermediateSteps.push({ observation: '' });
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe('');
|
||||
});
|
||||
|
||||
it('should handle empty base path (root deployment)', () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/';
|
||||
intermediateSteps.push({ observation: '' });
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe('\n');
|
||||
});
|
||||
|
||||
it('should handle missing DOMAIN_CLIENT', () => {
|
||||
delete process.env.DOMAIN_CLIENT;
|
||||
intermediateSteps.push({ observation: '' });
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe('\n');
|
||||
});
|
||||
|
||||
it('should handle observation without image path match', () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||
intermediateSteps.push({ observation: '' });
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe('\n');
|
||||
});
|
||||
|
||||
it('should handle nested subdirectories in base path', () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/apps/librechat';
|
||||
intermediateSteps.push({ observation: '' });
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe('\n');
|
||||
});
|
||||
|
||||
it('should handle multiple observations with mixed base path scenarios', () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||
intermediateSteps.push({ observation: '' });
|
||||
intermediateSteps.push({ observation: '' });
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe(
|
||||
'\n\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle complex markdown with base path', () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||
const complexMarkdown = `
|
||||
# Document Title
|
||||

|
||||
Some text between images
|
||||

|
||||
`;
|
||||
intermediateSteps.push({ observation: complexMarkdown });
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe('\n');
|
||||
});
|
||||
|
||||
it('should handle URLs that are already absolute', () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||
intermediateSteps.push({ observation: '' });
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe('\n');
|
||||
});
|
||||
|
||||
it('should handle data URLs', () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||
intermediateSteps.push({
|
||||
observation:
|
||||
'',
|
||||
});
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe(
|
||||
'\n',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ describe('formatAgentMessages', () => {
|
|||
content: [
|
||||
{
|
||||
type: ContentTypes.TEXT,
|
||||
[ContentTypes.TEXT]: 'I\'ll search for that information.',
|
||||
[ContentTypes.TEXT]: "I'll search for that information.",
|
||||
tool_call_ids: ['search_1'],
|
||||
},
|
||||
{
|
||||
|
|
@ -144,7 +144,7 @@ describe('formatAgentMessages', () => {
|
|||
},
|
||||
{
|
||||
type: ContentTypes.TEXT,
|
||||
[ContentTypes.TEXT]: 'Now, I\'ll convert the temperature.',
|
||||
[ContentTypes.TEXT]: "Now, I'll convert the temperature.",
|
||||
tool_call_ids: ['convert_1'],
|
||||
},
|
||||
{
|
||||
|
|
@ -156,7 +156,7 @@ describe('formatAgentMessages', () => {
|
|||
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);
|
||||
|
||||
// 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[0]).toEqual({
|
||||
id: 'search_1',
|
||||
|
|
@ -187,7 +187,7 @@ describe('formatAgentMessages', () => {
|
|||
);
|
||||
|
||||
// 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[0]).toEqual({
|
||||
id: 'convert_1',
|
||||
|
|
@ -202,7 +202,7 @@ describe('formatAgentMessages', () => {
|
|||
|
||||
// Check final AIMessage
|
||||
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',
|
||||
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',
|
||||
content: [
|
||||
|
|
@ -240,7 +240,7 @@ describe('formatAgentMessages', () => {
|
|||
{
|
||||
role: 'assistant',
|
||||
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 },
|
||||
]);
|
||||
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[4].content).toBe('Sunny, 75°F');
|
||||
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
|
||||
|
|
|
|||
|
|
@ -82,7 +82,10 @@ const initializeFakeClient = (apiKey, options, fakeMessages) => {
|
|||
});
|
||||
|
||||
TestClient.sendCompletion = jest.fn(async () => {
|
||||
return 'Mock response text';
|
||||
return {
|
||||
completion: 'Mock response text',
|
||||
metadata: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
TestClient.getCompletion = jest.fn().mockImplementation(async (..._args) => {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ const { v4: uuidv4 } = require('uuid');
|
|||
const { Tool } = require('@langchain/core/tools');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { FileContext, ContentTypes } = require('librechat-data-provider');
|
||||
const { getBasePath } = require('@librechat/api');
|
||||
const paths = require('~/config/paths');
|
||||
|
||||
const displayMessage =
|
||||
|
|
@ -36,7 +37,7 @@ class StableDiffusionAPI extends Tool {
|
|||
this.description_for_model = `// Generate images and visuals using text.
|
||||
// Guidelines:
|
||||
// - 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: 
|
||||
// - ALWAYS include the markdown url in your final response to show the user: }/images/id.png)
|
||||
// - 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.
|
||||
// - Here's an example for generating a realistic portrait photo of a man:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
const { z } = require('zod');
|
||||
const { ProxyAgent, fetch } = require('undici');
|
||||
const { tool } = require('@langchain/core/tools');
|
||||
const { getApiKey } = require('./credentials');
|
||||
|
||||
|
|
@ -19,13 +20,19 @@ function createTavilySearchTool(fields = {}) {
|
|||
...kwargs,
|
||||
};
|
||||
|
||||
const response = await fetch('https://api.tavily.com/search', {
|
||||
const fetchOptions = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
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();
|
||||
if (!response.ok) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
const { z } = require('zod');
|
||||
const { ProxyAgent, fetch } = require('undici');
|
||||
const { Tool } = require('@langchain/core/tools');
|
||||
const { getEnvironmentVariable } = require('@langchain/core/utils/env');
|
||||
|
||||
|
|
@ -102,13 +103,19 @@ class TavilySearchResults extends Tool {
|
|||
...this.kwargs,
|
||||
};
|
||||
|
||||
const response = await fetch('https://api.tavily.com/search', {
|
||||
const fetchOptions = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
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();
|
||||
if (!response.ok) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
const { fetch, ProxyAgent } = require('undici');
|
||||
const TavilySearchResults = require('../TavilySearchResults');
|
||||
|
||||
jest.mock('node-fetch');
|
||||
jest.mock('undici');
|
||||
jest.mock('@langchain/core/utils/env');
|
||||
|
||||
describe('TavilySearchResults', () => {
|
||||
|
|
@ -13,6 +14,7 @@ describe('TavilySearchResults', () => {
|
|||
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
jest.clearAllMocks();
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
TAVILY_API_KEY: mockApiKey,
|
||||
|
|
@ -20,7 +22,6 @@ describe('TavilySearchResults', () => {
|
|||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
|
|
@ -35,4 +36,49 @@ describe('TavilySearchResults', () => {
|
|||
});
|
||||
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(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -78,15 +78,14 @@ const createFileSearchTool = async ({ userId, files, entity_id, fileCitations =
|
|||
return tool(
|
||||
async ({ query }) => {
|
||||
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);
|
||||
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
|
||||
* @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);
|
||||
|
||||
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
|
||||
|
|
@ -135,11 +134,16 @@ const createFileSearchTool = async ({ userId, files, entity_id, fileCitations =
|
|||
page: docInfo.metadata.page || null,
|
||||
})),
|
||||
)
|
||||
// TODO: results should be sorted by relevance, not distance
|
||||
.sort((a, b) => a.distance - b.distance)
|
||||
// TODO: make this configurable
|
||||
.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
|
||||
.map(
|
||||
(result, index) =>
|
||||
|
|
@ -169,11 +173,12 @@ const createFileSearchTool = async ({ userId, files, entity_id, fileCitations =
|
|||
? `
|
||||
|
||||
**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"
|
||||
- Page reference: "According to report.docx... \\ue202turn0file1"
|
||||
- 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.**`
|
||||
: ''
|
||||
}`,
|
||||
|
|
|
|||
|
|
@ -317,14 +317,22 @@ const loadTools = async ({
|
|||
requestedTools[tool] = async () => {
|
||||
toolContextMap[tool] = `# \`${tool}\`:
|
||||
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.
|
||||
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.
|
||||
5. **Tailor your approach to the query type** (academic, news, coding, etc.) while maintaining an expert, journalistic, unbiased tone.
|
||||
6. **Provide comprehensive information** with specific details, examples, and as much relevant context as possible from search results.
|
||||
7. **Avoid moralizing language.**
|
||||
`.trim();
|
||||
|
||||
**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.
|
||||
|
||||
**CITATION FORMAT - UNICODE ESCAPE SEQUENCES ONLY:**
|
||||
Use these EXACT escape sequences (copy verbatim): \\ue202 (before each anchor), \\ue200 (group start), \\ue201 (group end), \\ue203 (highlight start), \\ue204 (highlight end)
|
||||
|
||||
Anchor pattern: \\ue202turn{N}{type}{index} where N=turn number, type=search|news|image|ref, index=0,1,2...
|
||||
|
||||
**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({
|
||||
...result.authResult,
|
||||
onSearchResults,
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ const {
|
|||
} = require('./Project');
|
||||
const { removeAllPermissions } = require('~/server/services/PermissionService');
|
||||
const { getMCPServerTools } = require('~/server/services/Config');
|
||||
const { Agent, AclEntry } = require('~/db/models');
|
||||
const { getActions } = require('./Action');
|
||||
const { Agent } = require('~/db/models');
|
||||
|
||||
/**
|
||||
* Create an agent with the provided data.
|
||||
|
|
@ -539,6 +539,37 @@ const deleteAgent = async (searchParameter) => {
|
|||
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.
|
||||
* @param {Object} params - The parameters for getting accessible agents.
|
||||
|
|
@ -856,6 +887,7 @@ module.exports = {
|
|||
createAgent,
|
||||
updateAgent,
|
||||
deleteAgent,
|
||||
deleteUserAgents,
|
||||
getListAgents,
|
||||
revertAgentVersion,
|
||||
updateAgentProjects,
|
||||
|
|
|
|||
|
|
@ -346,8 +346,8 @@ async function getMessage({ user, messageId }) {
|
|||
*
|
||||
* @async
|
||||
* @function deleteMessages
|
||||
* @param {Object} filter - The filter criteria to find messages to delete.
|
||||
* @returns {Promise<Object>} The metadata with count of deleted messages.
|
||||
* @param {import('mongoose').FilterQuery<import('mongoose').Document>} filter - The filter criteria to find messages to delete.
|
||||
* @returns {Promise<import('mongoose').DeleteResult>} The metadata with count of deleted messages.
|
||||
* @throws {Error} If there is an error in deleting messages.
|
||||
*/
|
||||
async function deleteMessages(filter) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
const { ObjectId } = require('mongodb');
|
||||
const { escapeRegExp } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const {
|
||||
Constants,
|
||||
|
|
@ -13,8 +14,7 @@ const {
|
|||
getProjectByName,
|
||||
} = require('./Project');
|
||||
const { removeAllPermissions } = require('~/server/services/PermissionService');
|
||||
const { PromptGroup, Prompt } = require('~/db/models');
|
||||
const { escapeRegExp } = require('~/server/utils');
|
||||
const { PromptGroup, Prompt, AclEntry } = require('~/db/models');
|
||||
|
||||
/**
|
||||
* Create a pipeline for the aggregation to get prompt groups
|
||||
|
|
@ -591,6 +591,36 @@ module.exports = {
|
|||
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
|
||||
* @param {Partial<MongoPromptGroup>} filter - Filter to find prompt group
|
||||
|
|
|
|||
|
|
@ -136,10 +136,12 @@ const tokenValues = Object.assign(
|
|||
'claude-3.7-sonnet': { prompt: 3, completion: 15 },
|
||||
'claude-haiku-4-5': { prompt: 1, completion: 5 },
|
||||
'claude-opus-4': { prompt: 15, completion: 75 },
|
||||
'claude-opus-4-5': { prompt: 5, completion: 25 },
|
||||
'claude-sonnet-4': { prompt: 3, completion: 15 },
|
||||
'command-r': { prompt: 0.5, completion: 1.5 },
|
||||
'command-r-plus': { prompt: 3, completion: 15 },
|
||||
'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-r1': { prompt: 0.4, completion: 2.0 },
|
||||
'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-lite': { prompt: 0.1, completion: 0.4 },
|
||||
'gemini-2.5-pro': { prompt: 1.25, completion: 10 },
|
||||
'gemini-3': { prompt: 2, completion: 12 },
|
||||
'gemini-pro-vision': { prompt: 0.5, completion: 1.5 },
|
||||
grok: { prompt: 2.0, completion: 10.0 }, // Base pattern defaults to grok-2
|
||||
'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-fast': { prompt: 0.6, completion: 4 },
|
||||
'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 },
|
||||
'ministral-3b': { prompt: 0.04, completion: 0.04 },
|
||||
'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-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-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 },
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -766,6 +766,78 @@ describe('Deepseek Model Tests', () => {
|
|||
const result = tokenValues[valueKey].prompt && multiplier === tokenValues[valueKey].prompt;
|
||||
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', () => {
|
||||
|
|
@ -1040,6 +1112,7 @@ describe('getCacheMultiplier', () => {
|
|||
|
||||
describe('Google Model Tests', () => {
|
||||
const googleModels = [
|
||||
'gemini-3',
|
||||
'gemini-2.5-pro',
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.5-flash-lite',
|
||||
|
|
@ -1083,6 +1156,7 @@ describe('Google Model Tests', () => {
|
|||
|
||||
it('should map to the correct model keys', () => {
|
||||
const expected = {
|
||||
'gemini-3': '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',
|
||||
|
|
@ -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', () => {
|
||||
expect(getMultiplier({ model: 'xai/grok-3', tokenType: 'prompt' })).toBe(
|
||||
tokenValues['grok-3'].prompt,
|
||||
|
|
@ -1238,6 +1345,39 @@ describe('Grok Model Tests - Pricing', () => {
|
|||
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', () => {
|
||||
const modelVariations = [
|
||||
'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', () => {
|
||||
const modelVariations = [
|
||||
'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', () => {
|
||||
const modelVariations = [
|
||||
'claude-sonnet-4',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@librechat/backend",
|
||||
"version": "v0.8.1-rc1",
|
||||
"version": "v0.8.1",
|
||||
"description": "",
|
||||
"scripts": {
|
||||
"start": "echo 'please run this from the root directory'",
|
||||
|
|
@ -47,7 +47,7 @@
|
|||
"@langchain/google-genai": "^0.2.13",
|
||||
"@langchain/google-vertexai": "^0.2.13",
|
||||
"@langchain/textsplitters": "^0.1.0",
|
||||
"@librechat/agents": "^3.0.17",
|
||||
"@librechat/agents": "^3.0.50",
|
||||
"@librechat/api": "*",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||
|
|
@ -92,7 +92,7 @@
|
|||
"multer": "^2.0.2",
|
||||
"nanoid": "^3.3.7",
|
||||
"node-fetch": "^2.7.0",
|
||||
"nodemailer": "^7.0.9",
|
||||
"nodemailer": "^7.0.11",
|
||||
"ollama": "^0.5.0",
|
||||
"openai": "5.8.2",
|
||||
"openid-client": "^6.5.0",
|
||||
|
|
|
|||
|
|
@ -350,6 +350,9 @@ function disposeClient(client) {
|
|||
if (client.agentConfigs) {
|
||||
client.agentConfigs = null;
|
||||
}
|
||||
if (client.agentIdMap) {
|
||||
client.agentIdMap = null;
|
||||
}
|
||||
if (client.artifactPromises) {
|
||||
client.artifactPromises = null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,7 +82,15 @@ const refreshController = async (req, res) => {
|
|||
if (error || !user) {
|
||||
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 });
|
||||
} catch (error) {
|
||||
logger.error('[refreshController] OpenID token refresh error', error);
|
||||
|
|
|
|||
|
|
@ -3,32 +3,45 @@ const { Tools, CacheKeys, Constants, FileSources } = require('librechat-data-pro
|
|||
const {
|
||||
MCPOAuthHandler,
|
||||
MCPTokenStorage,
|
||||
mcpServersRegistry,
|
||||
normalizeHttpError,
|
||||
extractWebSearchEnvVars,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
getFiles,
|
||||
findToken,
|
||||
updateUser,
|
||||
deleteFiles,
|
||||
deleteConvos,
|
||||
deletePresets,
|
||||
deleteMessages,
|
||||
deleteUserById,
|
||||
deleteAllSharedLinks,
|
||||
deleteAllUserSessions,
|
||||
deleteAllSharedLinks,
|
||||
deleteUserById,
|
||||
deleteMessages,
|
||||
deletePresets,
|
||||
deleteConvos,
|
||||
deleteFiles,
|
||||
updateUser,
|
||||
findToken,
|
||||
getFiles,
|
||||
} = require('~/models');
|
||||
const {
|
||||
ConversationTag,
|
||||
Transaction,
|
||||
MemoryEntry,
|
||||
Assistant,
|
||||
AclEntry,
|
||||
Balance,
|
||||
Action,
|
||||
Group,
|
||||
Token,
|
||||
User,
|
||||
} = require('~/db/models');
|
||||
const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService');
|
||||
const { updateUserPluginsService, deleteUserKey } = require('~/server/services/UserService');
|
||||
const { verifyEmail, resendVerificationEmail } = require('~/server/services/AuthService');
|
||||
const { needsRefresh, getNewS3URL } = require('~/server/services/Files/S3/crud');
|
||||
const { processDeleteRequest } = require('~/server/services/Files/process');
|
||||
const { Transaction, Balance, User, Token } = require('~/db/models');
|
||||
const { getMCPManager, getFlowStateManager } = require('~/config');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
const { deleteToolCalls } = require('~/models/ToolCall');
|
||||
const { deleteUserPrompts } = require('~/models/Prompt');
|
||||
const { deleteUserAgents } = require('~/models/Agent');
|
||||
const { getLogStores } = require('~/cache');
|
||||
const { mcpServersRegistry } = require('@librechat/api');
|
||||
|
||||
const getUserController = async (req, res) => {
|
||||
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 Balance.deleteMany({ user: user._id }); // delete user balances
|
||||
await deletePresets(user.id); // delete user presets
|
||||
/* TODO: Delete Assistant Threads */
|
||||
try {
|
||||
await deleteConvos(user.id); // delete user convos
|
||||
} catch (error) {
|
||||
|
|
@ -249,7 +261,19 @@ const deleteUserController = async (req, res) => {
|
|||
await deleteUserFiles(req); // delete user files
|
||||
await deleteFiles(null, user.id); // delete database files in case of orphaned files from previous steps
|
||||
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}`);
|
||||
res.status(200).send({ message: 'User deleted' });
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -39,9 +39,9 @@ class ModelEndHandler {
|
|||
* @param {ModelEndData | undefined} data
|
||||
* @param {Record<string, unknown> | undefined} metadata
|
||||
* @param {StandardGraph} graph
|
||||
* @returns
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
handle(event, data, metadata, graph) {
|
||||
async handle(event, data, metadata, graph) {
|
||||
if (!graph || !metadata) {
|
||||
console.warn(`Graph or metadata not found in ${event} event`);
|
||||
return;
|
||||
|
|
@ -79,7 +79,7 @@ class ModelEndHandler {
|
|||
}
|
||||
}
|
||||
if (isGoogle || streamingDisabled || hasUnprocessedToolCalls) {
|
||||
handleToolCalls(toolCalls, metadata, graph);
|
||||
await handleToolCalls(toolCalls, metadata, graph);
|
||||
}
|
||||
|
||||
const usage = data?.output?.usage_metadata;
|
||||
|
|
@ -101,7 +101,7 @@ class ModelEndHandler {
|
|||
const stepKey = graph.getStepKey(metadata);
|
||||
const message_id = getMessageId(stepKey, graph) ?? '';
|
||||
if (message_id) {
|
||||
graph.dispatchRunStep(stepKey, {
|
||||
await graph.dispatchRunStep(stepKey, {
|
||||
type: StepTypes.MESSAGE_CREATION,
|
||||
message_creation: {
|
||||
message_id,
|
||||
|
|
@ -111,7 +111,7 @@ class ModelEndHandler {
|
|||
const stepId = graph.getStepIdByKey(stepKey);
|
||||
const content = data.output.content;
|
||||
if (typeof content === 'string') {
|
||||
graph.dispatchMessageDelta(stepId, {
|
||||
await graph.dispatchMessageDelta(stepId, {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
|
|
@ -120,7 +120,7 @@ class ModelEndHandler {
|
|||
],
|
||||
});
|
||||
} else if (content.every((c) => c.type?.startsWith('text'))) {
|
||||
graph.dispatchMessageDelta(stepId, {
|
||||
await graph.dispatchMessageDelta(stepId, {
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
|
@ -162,7 +162,7 @@ function getDefaultHandlers({ res, aggregateContent, toolEndCallback, collectedU
|
|||
}
|
||||
const handlers = {
|
||||
[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.ON_RUN_STEP]: {
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ const {
|
|||
logAxiosError,
|
||||
sanitizeTitle,
|
||||
resolveHeaders,
|
||||
createSafeUser,
|
||||
getBalanceConfig,
|
||||
memoryInstructions,
|
||||
getTransactionsConfig,
|
||||
|
|
@ -20,6 +21,7 @@ const {
|
|||
Providers,
|
||||
TitleMethod,
|
||||
formatMessage,
|
||||
labelContentByAgent,
|
||||
formatAgentMessages,
|
||||
getTokenCountForMessage,
|
||||
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 {
|
||||
constructor(options = {}) {
|
||||
super(null, options);
|
||||
|
|
@ -141,6 +198,8 @@ class AgentClient extends BaseClient {
|
|||
this.indexTokenCountMap = {};
|
||||
/** @type {(messages: BaseMessage[]) => Promise<void>} */
|
||||
this.processMemory;
|
||||
/** @type {Record<number, string> | null} */
|
||||
this.agentIdMap = null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -233,6 +292,12 @@ class AgentClient extends BaseClient {
|
|||
summary: this.shouldSummarize,
|
||||
});
|
||||
|
||||
orderedMessages = applyAgentLabelsToHistory(
|
||||
orderedMessages,
|
||||
this.options.agent,
|
||||
this.agentConfigs,
|
||||
);
|
||||
|
||||
let payload;
|
||||
/** @type {number | undefined} */
|
||||
let promptTokens;
|
||||
|
|
@ -612,7 +677,11 @@ class AgentClient extends BaseClient {
|
|||
userMCPAuthMap: opts.userMCPAuthMap,
|
||||
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,
|
||||
parentMessageId: this.parentMessageId,
|
||||
},
|
||||
user: this.options.req.user,
|
||||
user: createSafeUser(this.options.req.user),
|
||||
},
|
||||
recursionLimit: agentsEConfig?.recursionLimit ?? 25,
|
||||
signal: abortController.signal,
|
||||
|
|
@ -864,6 +933,7 @@ class AgentClient extends BaseClient {
|
|||
signal: abortController.signal,
|
||||
customHandlers: this.options.eventHandlers,
|
||||
requestBody: config.configurable.requestBody,
|
||||
user: createSafeUser(this.options.req?.user),
|
||||
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) {
|
||||
logger.error(
|
||||
'[api/server/controllers/agents/client.js #sendCompletion] Operation aborted',
|
||||
|
|
@ -935,6 +1023,9 @@ class AgentClient extends BaseClient {
|
|||
err,
|
||||
);
|
||||
}
|
||||
run = null;
|
||||
config = null;
|
||||
memoryPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1063,6 +1154,7 @@ class AgentClient extends BaseClient {
|
|||
if (clientOptions?.configuration?.defaultHeaders != null) {
|
||||
clientOptions.configuration.defaultHeaders = resolveHeaders({
|
||||
headers: clientOptions.configuration.defaultHeaders,
|
||||
user: createSafeUser(this.options.req?.user),
|
||||
body: {
|
||||
messageId: this.responseMessageId,
|
||||
conversationId: this.conversationId,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
const { sendEvent } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { Constants } = require('librechat-data-provider');
|
||||
const {
|
||||
sendEvent,
|
||||
sanitizeFileForTransmit,
|
||||
sanitizeMessageForTransmit,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
handleAbortError,
|
||||
createAbortController,
|
||||
|
|
@ -224,13 +228,13 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
|
|||
conversation.title =
|
||||
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) {
|
||||
userMessage.files = [];
|
||||
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)) {
|
||||
userMessage.files.push({ ...attachment });
|
||||
userMessage.files.push(sanitizeFileForTransmit(attachment));
|
||||
}
|
||||
}
|
||||
delete userMessage.image_urls;
|
||||
|
|
@ -245,7 +249,7 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
|
|||
final: true,
|
||||
conversation,
|
||||
title: conversation.title,
|
||||
requestMessage: userMessage,
|
||||
requestMessage: sanitizeMessageForTransmit(userMessage),
|
||||
responseMessage: finalResponse,
|
||||
});
|
||||
res.end();
|
||||
|
|
@ -273,7 +277,7 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
|
|||
final: true,
|
||||
conversation,
|
||||
title: conversation.title,
|
||||
requestMessage: userMessage,
|
||||
requestMessage: sanitizeMessageForTransmit(userMessage),
|
||||
responseMessage: finalResponse,
|
||||
error: { message: 'Request was aborted during completion' },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ const {
|
|||
const {
|
||||
Tools,
|
||||
Constants,
|
||||
SystemRoles,
|
||||
FileSources,
|
||||
ResourceType,
|
||||
AccessRoleIds,
|
||||
|
|
@ -20,6 +19,8 @@ const {
|
|||
PermissionBits,
|
||||
actionDelimiter,
|
||||
removeNullishValues,
|
||||
CacheKeys,
|
||||
Time,
|
||||
} = require('librechat-data-provider');
|
||||
const {
|
||||
getListAgentsByAccess,
|
||||
|
|
@ -45,6 +46,7 @@ const { updateAction, getActions } = require('~/models/Action');
|
|||
const { getCachedTools } = require('~/server/services/Config');
|
||||
const { deleteFileByFilter } = require('~/models/File');
|
||||
const { getCategoriesWithCounts } = require('~/models');
|
||||
const { getLogStores } = require('~/cache');
|
||||
|
||||
const systemTools = {
|
||||
[Tools.execute_code]: true,
|
||||
|
|
@ -52,6 +54,49 @@ const systemTools = {
|
|||
[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.
|
||||
* @route POST /Agents
|
||||
|
|
@ -142,10 +187,13 @@ const getAgentHandler = async (req, res, expandProperties = false) => {
|
|||
agent.version = agent.versions ? agent.versions.length : 0;
|
||||
|
||||
if (agent.avatar && agent.avatar?.source === FileSources.s3) {
|
||||
const originalUrl = agent.avatar.filepath;
|
||||
agent.avatar.filepath = await refreshS3Url(agent.avatar);
|
||||
if (originalUrl !== agent.avatar.filepath) {
|
||||
await updateAgent({ id }, { avatar: agent.avatar }, { updatingUserId: req.user.id });
|
||||
try {
|
||||
agent.avatar = {
|
||||
...agent.avatar,
|
||||
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 {
|
||||
const id = req.params.id;
|
||||
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
|
||||
convertOcrToContextInPlace(updateData);
|
||||
|
|
@ -342,21 +395,21 @@ const duplicateAgentHandler = async (req, res) => {
|
|||
const [domain] = action.action_id.split(actionDelimiter);
|
||||
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(
|
||||
{ action_id: newActionId },
|
||||
{
|
||||
metadata: action.metadata,
|
||||
metadata: filteredMetadata,
|
||||
agent_id: newAgentId,
|
||||
user: userId,
|
||||
},
|
||||
);
|
||||
|
||||
const filteredMetadata = { ...newAction.metadata };
|
||||
for (const field of sensitiveFields) {
|
||||
delete filteredMetadata[field];
|
||||
}
|
||||
|
||||
newAction.metadata = filteredMetadata;
|
||||
newActionsList.push(newAction);
|
||||
return fullActionId;
|
||||
};
|
||||
|
|
@ -463,13 +516,13 @@ const getListAgentsHandler = async (req, res) => {
|
|||
filter.is_promoted = { $ne: true };
|
||||
}
|
||||
|
||||
// Handle search filter
|
||||
// Handle search filter (escape regex and cap length)
|
||||
if (search && search.trim() !== '') {
|
||||
filter.$or = [
|
||||
{ name: { $regex: search.trim(), $options: 'i' } },
|
||||
{ description: { $regex: search.trim(), $options: 'i' } },
|
||||
];
|
||||
const safeSearch = escapeRegex(search.trim().slice(0, MAX_SEARCH_LEN));
|
||||
const regex = new RegExp(safeSearch, 'i');
|
||||
filter.$or = [{ name: regex }, { description: regex }];
|
||||
}
|
||||
|
||||
// Get agent IDs the user has VIEW access to via ACL
|
||||
const accessibleIds = await findAccessibleResources({
|
||||
userId,
|
||||
|
|
@ -477,10 +530,12 @@ const getListAgentsHandler = async (req, res) => {
|
|||
resourceType: ResourceType.AGENT,
|
||||
requiredPermissions: requiredPermission,
|
||||
});
|
||||
|
||||
const publiclyAccessibleIds = await findPubliclyAccessibleResources({
|
||||
resourceType: ResourceType.AGENT,
|
||||
requiredPermissions: PermissionBits.VIEW,
|
||||
});
|
||||
|
||||
// Use the new ACL-aware function
|
||||
const data = await getListAgentsByAccess({
|
||||
accessibleIds,
|
||||
|
|
@ -488,13 +543,31 @@ const getListAgentsHandler = async (req, res) => {
|
|||
limit,
|
||||
after: cursor,
|
||||
});
|
||||
if (data?.data?.length) {
|
||||
data.data = data.data.map((agent) => {
|
||||
if (publiclyAccessibleIds.some((id) => id.equals(agent._id))) {
|
||||
|
||||
const agents = data?.data ?? [];
|
||||
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;
|
||||
}
|
||||
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);
|
||||
} catch (error) {
|
||||
|
|
@ -517,28 +590,21 @@ const getListAgentsHandler = async (req, res) => {
|
|||
const uploadAgentAvatarHandler = async (req, res) => {
|
||||
try {
|
||||
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 });
|
||||
const { agent_id } = req.params;
|
||||
if (!agent_id) {
|
||||
return res.status(400).json({ message: 'Agent ID is required' });
|
||||
}
|
||||
|
||||
const isAdmin = req.user.role === SystemRoles.ADMIN;
|
||||
const existingAgent = await getAgent({ id: agent_id });
|
||||
|
||||
if (!existingAgent) {
|
||||
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 fileStrategy = getFileStrategy(appConfig, { isAvatar: true });
|
||||
const resizedBuffer = await resizeAvatar({
|
||||
|
|
@ -571,8 +637,6 @@ const uploadAgentAvatarHandler = async (req, res) => {
|
|||
}
|
||||
}
|
||||
|
||||
const promises = [];
|
||||
|
||||
const data = {
|
||||
avatar: {
|
||||
filepath: image.filepath,
|
||||
|
|
@ -580,17 +644,16 @@ const uploadAgentAvatarHandler = async (req, res) => {
|
|||
},
|
||||
};
|
||||
|
||||
promises.push(
|
||||
await updateAgent({ id: agent_id }, data, {
|
||||
updatingUserId: req.user.id,
|
||||
}),
|
||||
);
|
||||
|
||||
const resolved = await Promise.all(promises);
|
||||
res.status(201).json(resolved[0]);
|
||||
const updatedAgent = await updateAgent({ id: agent_id }, data, {
|
||||
updatingUserId: req.user.id,
|
||||
});
|
||||
res.status(201).json(updatedAgent);
|
||||
} catch (error) {
|
||||
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 });
|
||||
} finally {
|
||||
try {
|
||||
|
|
@ -629,21 +692,13 @@ const revertAgentVersionHandler = async (req, res) => {
|
|||
return res.status(400).json({ error: 'version_index is required' });
|
||||
}
|
||||
|
||||
const isAdmin = req.user.role === SystemRoles.ADMIN;
|
||||
const existingAgent = await getAgent({ id });
|
||||
|
||||
if (!existingAgent) {
|
||||
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',
|
||||
});
|
||||
}
|
||||
// Permissions are enforced via route middleware (ACL EDIT)
|
||||
|
||||
const updatedAgent = await revertAgentVersion({ id }, version_index);
|
||||
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ jest.mock('~/server/services/PermissionService', () => ({
|
|||
findPubliclyAccessibleResources: jest.fn().mockResolvedValue([]),
|
||||
grantPermission: jest.fn(),
|
||||
hasPublicPermission: jest.fn().mockResolvedValue(false),
|
||||
checkPermission: jest.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
jest.mock('~/models', () => ({
|
||||
|
|
@ -573,6 +574,68 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
|
|||
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 () => {
|
||||
mockReq.user.id = existingAgentAuthorId.toString();
|
||||
mockReq.params.id = existingAgentId;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
const { v4 } = require('uuid');
|
||||
const { sleep } = require('@librechat/agents');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { sendEvent, getBalanceConfig, getModelMaxTokens } = require('@librechat/api');
|
||||
const { sendEvent, getBalanceConfig, getModelMaxTokens, countTokens } = require('@librechat/api');
|
||||
const {
|
||||
Time,
|
||||
Constants,
|
||||
|
|
@ -33,7 +33,6 @@ const { getTransactions } = require('~/models/Transaction');
|
|||
const { checkBalance } = require('~/models/balanceMethods');
|
||||
const { getConvo } = require('~/models/Conversation');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
const { countTokens } = require('~/server/utils');
|
||||
const { getOpenAIClient } = require('./helpers');
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
const { v4 } = require('uuid');
|
||||
const { sleep } = require('@librechat/agents');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { sendEvent, getBalanceConfig, getModelMaxTokens } = require('@librechat/api');
|
||||
const { sendEvent, getBalanceConfig, getModelMaxTokens, countTokens } = require('@librechat/api');
|
||||
const {
|
||||
Time,
|
||||
Constants,
|
||||
|
|
@ -30,7 +30,6 @@ const { getTransactions } = require('~/models/Transaction');
|
|||
const { checkBalance } = require('~/models/balanceMethods');
|
||||
const { getConvo } = require('~/models/Conversation');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
const { countTokens } = require('~/server/utils');
|
||||
const { getOpenAIClient } = require('./helpers');
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -44,7 +44,13 @@ const getMCPTools = async (req, res) => {
|
|||
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) {
|
||||
logger.debug(`[getMCPTools] No tools found for server ${serverName}`);
|
||||
continue;
|
||||
|
|
|
|||
417
api/server/experimental.js
Normal file
417
api/server/experimental.js
Normal 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, '"');
|
||||
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);
|
||||
});
|
||||
|
|
@ -14,6 +14,7 @@ const {
|
|||
isEnabled,
|
||||
ErrorController,
|
||||
performStartupChecks,
|
||||
handleJsonParseError,
|
||||
initializeFileStorage,
|
||||
} = require('@librechat/api');
|
||||
const { connectDb, indexSync } = require('~/db');
|
||||
|
|
@ -81,6 +82,7 @@ const startServer = async () => {
|
|||
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());
|
||||
|
|
@ -126,7 +128,6 @@ const startServer = async () => {
|
|||
app.use('/api/presets', routes.presets);
|
||||
app.use('/api/prompts', routes.prompts);
|
||||
app.use('/api/categories', routes.categories);
|
||||
app.use('/api/tokenizer', routes.tokenizer);
|
||||
app.use('/api/endpoints', routes.endpoints);
|
||||
app.use('/api/balance', routes.balance);
|
||||
app.use('/api/models', routes.models);
|
||||
|
|
@ -213,6 +214,17 @@ process.on('uncaughtException', (err) => {
|
|||
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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { truncateText, smartTruncateText } = require('~/app/clients/prompts');
|
||||
const clearPendingReq = require('~/cache/clearPendingReq');
|
||||
|
|
@ -290,7 +290,7 @@ const createAbortController = (req, res, getAbortData, getReqData) => {
|
|||
title: conversation && !conversation.title ? null : conversation?.title || 'New Chat',
|
||||
final: true,
|
||||
conversation,
|
||||
requestMessage: userMessage,
|
||||
requestMessage: sanitizeMessageForTransmit(userMessage),
|
||||
responseMessage: responseMessage,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -61,18 +61,24 @@ async function buildEndpointOption(req, res, next) {
|
|||
|
||||
try {
|
||||
currentModelSpec.preset.spec = spec;
|
||||
if (currentModelSpec.iconURL != null && currentModelSpec.iconURL !== '') {
|
||||
currentModelSpec.preset.iconURL = currentModelSpec.iconURL;
|
||||
}
|
||||
parsedBody = parseCompactConvo({
|
||||
endpoint,
|
||||
endpointType,
|
||||
conversation: currentModelSpec.preset,
|
||||
});
|
||||
if (currentModelSpec.iconURL != null && currentModelSpec.iconURL !== '') {
|
||||
parsedBody.iconURL = currentModelSpec.iconURL;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error parsing model spec for endpoint ${endpoint}`, error);
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
const crypto = require('crypto');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
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 { getConvo } = require('~/models/Conversation');
|
||||
|
||||
|
|
@ -71,7 +71,7 @@ const sendError = async (req, res, options, callback) => {
|
|||
|
||||
return sendEvent(res, {
|
||||
final: true,
|
||||
requestMessage: query?.[0] ? query[0] : requestMessage,
|
||||
requestMessage: sanitizeMessageForTransmit(query?.[0] ?? requestMessage),
|
||||
responseMessage: errorMessage,
|
||||
conversation: convo,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
const jwt = require('jsonwebtoken');
|
||||
const { isEnabled } = require('@librechat/api');
|
||||
const createValidateImageRequest = require('~/server/middleware/validateImageRequest');
|
||||
|
||||
// Mock only isEnabled, keep getBasePath real so it reads process.env.DOMAIN_CLIENT
|
||||
jest.mock('@librechat/api', () => ({
|
||||
...jest.requireActual('@librechat/api'),
|
||||
isEnabled: jest.fn(),
|
||||
}));
|
||||
|
||||
const { isEnabled } = require('@librechat/api');
|
||||
|
||||
describe('validateImageRequest middleware', () => {
|
||||
let req, res, next, validateImageRequest;
|
||||
const validObjectId = '65cfb246f7ecadb8b1e8036b';
|
||||
|
|
@ -23,6 +26,7 @@ describe('validateImageRequest middleware', () => {
|
|||
next = jest.fn();
|
||||
process.env.JWT_REFRESH_SECRET = 'test-secret';
|
||||
process.env.OPENID_REUSE_TOKENS = 'false';
|
||||
delete process.env.DOMAIN_CLIENT; // Clear for tests without basePath
|
||||
|
||||
// Default: OpenID token reuse disabled
|
||||
isEnabled.mockReturnValue(false);
|
||||
|
|
@ -296,4 +300,175 @@ describe('validateImageRequest middleware', () => {
|
|||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
const cookies = require('cookie');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { isEnabled } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { isEnabled, getBasePath } = require('@librechat/api');
|
||||
|
||||
const OBJECT_ID_LENGTH = 24;
|
||||
const OBJECT_ID_PATTERN = /^[0-9a-f]{24}$/i;
|
||||
|
|
@ -124,14 +124,21 @@ function createValidateImageRequest(secureImageLinks) {
|
|||
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)) {
|
||||
logger.debug('[validateImageRequest] Image request validated');
|
||||
return next();
|
||||
}
|
||||
|
||||
const escapedUserId = userIdForPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const pathPattern = new RegExp(`^/images/${escapedUserId}/[^/]+$`);
|
||||
const pathPattern = new RegExp(
|
||||
`^${imagesPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/${escapedUserId}/[^/]+$`,
|
||||
);
|
||||
|
||||
if (pathPattern.test(fullPath)) {
|
||||
logger.debug('[validateImageRequest] Image request validated');
|
||||
|
|
|
|||
|
|
@ -146,7 +146,15 @@ router.delete(
|
|||
* @param {number} req.body.version_index - Index of the version to revert to.
|
||||
* @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.
|
||||
|
|
|
|||
|
|
@ -30,11 +30,46 @@ const publicSharedLinksEnabled =
|
|||
const sharePointFilePickerEnabled = isEnabled(process.env.ENABLE_SHAREPOINT_FILEPICKER);
|
||||
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) {
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
|
||||
const cachedStartupConfig = await cache.get(CacheKeys.STARTUP_CONFIG);
|
||||
if (cachedStartupConfig) {
|
||||
const appConfig = await getAppConfig({ role: req.user?.role });
|
||||
await getMCPServers(cachedStartupConfig, appConfig);
|
||||
res.send(cachedStartupConfig);
|
||||
return;
|
||||
}
|
||||
|
|
@ -126,35 +161,6 @@ router.get('/', async function (req, res) {
|
|||
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;
|
||||
if (
|
||||
webSearchConfig != null &&
|
||||
|
|
@ -184,6 +190,7 @@ router.get('/', async function (req, res) {
|
|||
}
|
||||
|
||||
await cache.set(CacheKeys.STARTUP_CONFIG, payload);
|
||||
await getMCPServers(payload, appConfig);
|
||||
return res.status(200).send(payload);
|
||||
} catch (err) {
|
||||
logger.error('Error in startup config', err);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
const accessPermissions = require('./accessPermissions');
|
||||
const assistants = require('./assistants');
|
||||
const categories = require('./categories');
|
||||
const tokenizer = require('./tokenizer');
|
||||
const endpoints = require('./endpoints');
|
||||
const staticRoute = require('./static');
|
||||
const messages = require('./messages');
|
||||
|
|
@ -53,7 +52,6 @@ module.exports = {
|
|||
messages,
|
||||
memories,
|
||||
endpoints,
|
||||
tokenizer,
|
||||
assistants,
|
||||
categories,
|
||||
staticRoute,
|
||||
|
|
|
|||
|
|
@ -429,13 +429,23 @@ router.get('/connection/status', requireJwtAuth, async (req, res) => {
|
|||
const connectionStatus = {};
|
||||
|
||||
for (const [serverName] of Object.entries(mcpConfig)) {
|
||||
connectionStatus[serverName] = await getServerConnectionStatus(
|
||||
user.id,
|
||||
serverName,
|
||||
appConnections,
|
||||
userConnections,
|
||||
oauthServers,
|
||||
);
|
||||
try {
|
||||
connectionStatus[serverName] = await getServerConnectionStatus(
|
||||
user.id,
|
||||
serverName,
|
||||
appConnections,
|
||||
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({
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
const express = require('express');
|
||||
const { unescapeLaTeX } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { ContentTypes } = require('librechat-data-provider');
|
||||
const { unescapeLaTeX, countTokens } = require('@librechat/api');
|
||||
const {
|
||||
saveConvo,
|
||||
getMessage,
|
||||
|
|
@ -14,7 +14,6 @@ const { findAllArtifacts, replaceArtifactContent } = require('~/server/services/
|
|||
const { requireJwtAuth, validateMessageReq } = require('~/server/middleware');
|
||||
const { cleanUpPrimaryKeyValue } = require('~/lib/utils/misc');
|
||||
const { getConvosQueried } = require('~/models/Conversation');
|
||||
const { countTokens } = require('~/server/utils');
|
||||
const { Message } = require('~/db/models');
|
||||
|
||||
const router = express.Router();
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ const {
|
|||
markPublicPromptGroups,
|
||||
buildPromptGroupFilter,
|
||||
formatPromptGroupsResponse,
|
||||
safeValidatePromptGroupUpdate,
|
||||
createEmptyPromptGroupsResponse,
|
||||
filterAccessibleIdsBySharedLogic,
|
||||
} = require('@librechat/api');
|
||||
|
|
@ -344,7 +345,16 @@ const patchPromptGroup = async (req, res) => {
|
|||
if (req.user.role === SystemRoles.ADMIN) {
|
||||
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);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
beforeEach(async () => {
|
||||
// Create multiple prompt groups for pagination testing
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -8,7 +8,12 @@ const {
|
|||
deleteUserController,
|
||||
getUserController,
|
||||
} = require('~/server/controllers/UserController');
|
||||
const { requireJwtAuth, canDeleteAccount, verifyEmailLimiter } = require('~/server/middleware');
|
||||
const {
|
||||
verifyEmailLimiter,
|
||||
configMiddleware,
|
||||
canDeleteAccount,
|
||||
requireJwtAuth,
|
||||
} = require('~/server/middleware');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
|
|
@ -16,7 +21,7 @@ router.get('/', requireJwtAuth, getUserController);
|
|||
router.get('/terms', requireJwtAuth, getTermsStatusController);
|
||||
router.post('/terms/accept', requireJwtAuth, acceptTermsController);
|
||||
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/resend', verifyEmailLimiter, resendVerificationController);
|
||||
|
||||
|
|
|
|||
|
|
@ -176,7 +176,7 @@ const registerUser = async (user, additionalData = {}) => {
|
|||
return { status: 404, message: errorMessage };
|
||||
}
|
||||
|
||||
const { email, password, name, username } = user;
|
||||
const { email, password, name, username, provider } = user;
|
||||
|
||||
let newUserId;
|
||||
try {
|
||||
|
|
@ -207,7 +207,7 @@ const registerUser = async (user, additionalData = {}) => {
|
|||
|
||||
const salt = bcrypt.genSaltSync(10);
|
||||
const newUserData = {
|
||||
provider: 'local',
|
||||
provider: provider ?? 'local',
|
||||
email,
|
||||
username,
|
||||
name,
|
||||
|
|
@ -412,7 +412,7 @@ const setAuthTokens = async (userId, res, _session = null) => {
|
|||
* @param {string} [userId] - Optional MongoDB user ID for image path validation
|
||||
* @returns {String} - access token
|
||||
*/
|
||||
const setOpenIDAuthTokens = (tokenset, res, userId) => {
|
||||
const setOpenIDAuthTokens = (tokenset, res, userId, existingRefreshToken) => {
|
||||
try {
|
||||
if (!tokenset) {
|
||||
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');
|
||||
return;
|
||||
}
|
||||
if (!tokenset.access_token || !tokenset.refresh_token) {
|
||||
logger.error('[setOpenIDAuthTokens] No access or refresh token found in tokenset');
|
||||
if (!tokenset.access_token) {
|
||||
logger.error('[setOpenIDAuthTokens] No access token found in tokenset');
|
||||
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,
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@ async function updateMCPServerTools({ userId, serverName, tools }) {
|
|||
const serverTools = {};
|
||||
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) {
|
||||
const name = `${tool.name}${mcpDelimiter}${serverName}`;
|
||||
serverTools[name] = {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
const OpenAI = require('openai');
|
||||
const { ProxyAgent } = require('undici');
|
||||
const { isUserProvided } = require('@librechat/api');
|
||||
const { ErrorTypes, EModelEndpoint } = require('librechat-data-provider');
|
||||
const {
|
||||
getUserKeyValues,
|
||||
|
|
@ -7,7 +8,6 @@ const {
|
|||
checkUserKeyExpiry,
|
||||
} = require('~/server/services/UserService');
|
||||
const OAIClient = require('~/app/clients/OpenAIClient');
|
||||
const { isUserProvided } = require('~/server/utils');
|
||||
|
||||
const initializeClient = async ({ req, res, endpointOption, version, initAppClient = false }) => {
|
||||
const { PROXY, OPENAI_ORGANIZATION, ASSISTANTS_API_KEY, ASSISTANTS_BASE_URL } = process.env;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,4 @@
|
|||
const {
|
||||
resolveHeaders,
|
||||
isUserProvided,
|
||||
getOpenAIConfig,
|
||||
getCustomEndpointConfig,
|
||||
} = require('@librechat/api');
|
||||
const { isUserProvided, getOpenAIConfig, getCustomEndpointConfig } = require('@librechat/api');
|
||||
const {
|
||||
CacheKeys,
|
||||
ErrorTypes,
|
||||
|
|
@ -34,14 +29,6 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid
|
|||
const CUSTOM_API_KEY = extractEnvVariable(endpointConfig.apiKey);
|
||||
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)) {
|
||||
throw new Error(`Missing API Key for ${endpoint}.`);
|
||||
}
|
||||
|
|
@ -108,7 +95,7 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid
|
|||
}
|
||||
|
||||
const customOptions = {
|
||||
headers: resolvedHeaders,
|
||||
headers: endpointConfig.headers,
|
||||
addParams: endpointConfig.addParams,
|
||||
dropParams: endpointConfig.dropParams,
|
||||
customParams: endpointConfig.customParams,
|
||||
|
|
|
|||
|
|
@ -69,17 +69,21 @@ describe('custom/initializeClient', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('calls resolveHeaders with headers, user, and body for body placeholder support', async () => {
|
||||
const { resolveHeaders } = require('@librechat/api');
|
||||
await initializeClient({ req: mockRequest, res: mockResponse, optionsOnly: true });
|
||||
expect(resolveHeaders).toHaveBeenCalledWith({
|
||||
headers: { 'x-user': '{{LIBRECHAT_USER_ID}}', 'x-email': '{{LIBRECHAT_USER_EMAIL}}' },
|
||||
user: { id: 'user-123', email: 'test@example.com', role: 'user' },
|
||||
/**
|
||||
* Note: Request-based Header Resolution is deferred until right before LLM request is made
|
||||
body: { endpoint: 'test-endpoint' }, // body - supports {{LIBRECHAT_BODY_*}} placeholders
|
||||
*/
|
||||
it('stores original template headers for deferred resolution', async () => {
|
||||
/**
|
||||
* Note: Request-based Header Resolution is deferred until right before LLM request is made
|
||||
* in the OpenAIClient or AgentClient, not during initialization.
|
||||
* This test verifies that the initialize function completes successfully with optionsOnly flag,
|
||||
* and that headers are passed through to be resolved later during the actual LLM request.
|
||||
*/
|
||||
const result = await initializeClient({
|
||||
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 () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
function isKnownCustomProvider(provider) {
|
||||
return [Providers.XAI, Providers.OLLAMA, Providers.DEEPSEEK, Providers.OPENROUTER].includes(
|
||||
return [Providers.XAI, Providers.DEEPSEEK, Providers.OPENROUTER].includes(
|
||||
provider?.toLowerCase() || '',
|
||||
);
|
||||
}
|
||||
|
||||
const providerConfigMap = {
|
||||
[Providers.XAI]: initCustom,
|
||||
[Providers.OLLAMA]: initCustom,
|
||||
[Providers.DEEPSEEK]: initCustom,
|
||||
[Providers.OPENROUTER]: initCustom,
|
||||
[EModelEndpoint.openAI]: initOpenAI,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ const fs = require('fs').promises;
|
|||
const FormData = require('form-data');
|
||||
const { Readable } = require('stream');
|
||||
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 { getAppConfig } = require('~/server/services/Config');
|
||||
|
||||
|
|
@ -34,6 +35,34 @@ const MIME_TO_EXTENSION_MAP = {
|
|||
'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.
|
||||
* @param {string} mimeType - The MIME type.
|
||||
|
|
@ -172,10 +201,9 @@ class STTService {
|
|||
model: sttSchema.model,
|
||||
};
|
||||
|
||||
if (language) {
|
||||
/** Converted locale code (e.g., "en-US") to ISO-639-1 format (e.g., "en") */
|
||||
const isoLanguage = language.split('-')[0];
|
||||
data.language = isoLanguage;
|
||||
const validLanguage = getValidatedLanguageCode(language);
|
||||
if (validLanguage) {
|
||||
data.language = validLanguage;
|
||||
}
|
||||
|
||||
const headers = {
|
||||
|
|
@ -220,10 +248,9 @@ class STTService {
|
|||
contentType: audioFile.mimetype,
|
||||
});
|
||||
|
||||
if (language) {
|
||||
/** Converted locale code (e.g., "en-US") to ISO-639-1 format (e.g., "en") */
|
||||
const isoLanguage = language.split('-')[0];
|
||||
formData.append('language', isoLanguage);
|
||||
const validLanguage = getValidatedLanguageCode(language);
|
||||
if (validLanguage) {
|
||||
formData.append('language', validLanguage);
|
||||
}
|
||||
|
||||
const headers = {
|
||||
|
|
@ -266,8 +293,14 @@ class STTService {
|
|||
language,
|
||||
);
|
||||
|
||||
const options = { headers };
|
||||
|
||||
if (process.env.PROXY) {
|
||||
options.httpsAgent = new HttpsProxyAgent(process.env.PROXY);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post(url, data, { headers });
|
||||
const response = await axios.post(url, data, options);
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error('Invalid response from the STT API');
|
||||
|
|
@ -279,7 +312,7 @@ class STTService {
|
|||
|
||||
return response.data.text.trim();
|
||||
} catch (error) {
|
||||
logger.error(`STT request failed for provider ${provider}:`, error);
|
||||
logAxiosError({ message: `STT request failed for provider ${provider}:`, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -309,7 +342,7 @@ class STTService {
|
|||
const text = await this.sttRequest(provider, sttSchema, { audioBuffer, audioFile, language });
|
||||
res.json({ text });
|
||||
} 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);
|
||||
} finally {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
const axios = require('axios');
|
||||
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 { getRandomVoiceId, createChunkProcessor, splitTextIntoChunks } = require('./streamAudio');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
|
|
@ -266,10 +267,14 @@ class TTSService {
|
|||
|
||||
const options = { headers, responseType: stream ? 'stream' : 'arraybuffer' };
|
||||
|
||||
if (process.env.PROXY) {
|
||||
options.httpsAgent = new HttpsProxyAgent(process.env.PROXY);
|
||||
}
|
||||
|
||||
try {
|
||||
return await axios.post(url, data, options);
|
||||
} catch (error) {
|
||||
logger.error(`TTS request failed for provider ${provider}:`, error);
|
||||
logAxiosError({ message: `TTS request failed for provider ${provider}:`, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -325,7 +330,10 @@ class TTSService {
|
|||
break;
|
||||
}
|
||||
} 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) {
|
||||
return res.status(500).end();
|
||||
}
|
||||
|
|
@ -337,7 +345,7 @@ class TTSService {
|
|||
res.end();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error creating the audio stream:', error);
|
||||
logAxiosError({ message: '[TTS] Error creating the audio stream:', error });
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).send('An error occurred');
|
||||
}
|
||||
|
|
@ -407,7 +415,10 @@ class TTSService {
|
|||
break;
|
||||
}
|
||||
} 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) {
|
||||
return res.status(500).end();
|
||||
}
|
||||
|
|
@ -424,7 +435,7 @@ class TTSService {
|
|||
res.end();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch audio:', error);
|
||||
logAxiosError({ message: '[TTS] Failed to fetch audio:', error });
|
||||
if (!res.headersSent) {
|
||||
res.status(500).end();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
const path = require('path');
|
||||
const { v4 } = require('uuid');
|
||||
const axios = require('axios');
|
||||
const { logAxiosError } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { getCodeBaseURL } = require('@librechat/agents');
|
||||
const { logAxiosError, getBasePath } = require('@librechat/api');
|
||||
const {
|
||||
Tools,
|
||||
FileContext,
|
||||
|
|
@ -41,11 +41,12 @@ const processCodeOutput = async ({
|
|||
const appConfig = req.config;
|
||||
const currentDate = new Date();
|
||||
const baseURL = getCodeBaseURL();
|
||||
const basePath = getBasePath();
|
||||
const fileExt = path.extname(name);
|
||||
if (!fileExt || !imageExtRegex.test(name)) {
|
||||
return {
|
||||
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 */
|
||||
expiresAt: currentDate.getTime() + 86400000,
|
||||
conversationId,
|
||||
|
|
|
|||
|
|
@ -169,14 +169,24 @@ function extractFirebaseFilePath(urlString) {
|
|||
const deleteFirebaseFile = async (req, file) => {
|
||||
if (file.embedded && process.env.RAG_API_URL) {
|
||||
const jwtToken = req.headers.authorization.split(' ')[1];
|
||||
axios.delete(`${process.env.RAG_API_URL}/documents`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${jwtToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
accept: 'application/json',
|
||||
},
|
||||
data: [file.file_id],
|
||||
});
|
||||
try {
|
||||
await axios.delete(`${process.env.RAG_API_URL}/documents`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${jwtToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
accept: 'application/json',
|
||||
},
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -210,14 +210,24 @@ const deleteLocalFile = async (req, file) => {
|
|||
|
||||
if (file.embedded && process.env.RAG_API_URL) {
|
||||
const jwtToken = generateShortLivedToken(req.user.id);
|
||||
axios.delete(`${process.env.RAG_API_URL}/documents`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${jwtToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
accept: 'application/json',
|
||||
},
|
||||
data: [file.file_id],
|
||||
});
|
||||
try {
|
||||
await axios.delete(`${process.env.RAG_API_URL}/documents`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${jwtToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
accept: 'application/json',
|
||||
},
|
||||
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}`)) {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
const axios = require('axios');
|
||||
const { Providers } = require('@librechat/agents');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||
const { logAxiosError, inputSchema, processModelData } = require('@librechat/api');
|
||||
const { EModelEndpoint, defaultModels, CacheKeys } = require('librechat-data-provider');
|
||||
const { logAxiosError, inputSchema, processModelData, isUserProvided } = require('@librechat/api');
|
||||
const {
|
||||
CacheKeys,
|
||||
defaultModels,
|
||||
KnownEndpoints,
|
||||
EModelEndpoint,
|
||||
} = require('librechat-data-provider');
|
||||
const { OllamaClient } = require('~/app/clients/OllamaClient');
|
||||
const { isUserProvided } = require('~/server/utils');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
const { extractBaseURL } = require('~/utils');
|
||||
|
||||
|
|
@ -68,7 +71,7 @@ const fetchModels = async ({
|
|||
return models;
|
||||
}
|
||||
|
||||
if (name && name.toLowerCase().startsWith(Providers.OLLAMA)) {
|
||||
if (name && name.toLowerCase().startsWith(KnownEndpoints.ollama)) {
|
||||
try {
|
||||
return await OllamaClient.fetchModels(baseURL, { headers, user: userObject });
|
||||
} catch (ollamaError) {
|
||||
|
|
@ -80,7 +83,9 @@ const fetchModels = async ({
|
|||
|
||||
try {
|
||||
const options = {
|
||||
headers: {},
|
||||
headers: {
|
||||
...(headers ?? {}),
|
||||
},
|
||||
timeout: 5000,
|
||||
};
|
||||
|
||||
|
|
@ -101,7 +106,7 @@ const fetchModels = async ({
|
|||
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) {
|
||||
url.searchParams.append('user', user);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
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', () => {
|
||||
it('should split a string by commas and trim each value', () => {
|
||||
const input = ' model1, model2 , model3,model4 ';
|
||||
|
|
@ -410,6 +536,64 @@ describe('getAnthropicModels', () => {
|
|||
const models = await getAnthropicModels();
|
||||
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', () => {
|
||||
|
|
|
|||
|
|
@ -292,7 +292,7 @@ const ensurePrincipalExists = async function (principal) {
|
|||
let existingUser = await findUser({ idOnTheSource: principal.idOnTheSource });
|
||||
|
||||
if (!existingUser) {
|
||||
existingUser = await findUser({ email: principal.email.toLowerCase() });
|
||||
existingUser = await findUser({ email: principal.email });
|
||||
}
|
||||
|
||||
if (existingUser) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
const path = require('path');
|
||||
const { v4 } = require('uuid');
|
||||
const { countTokens, escapeRegExp } = require('@librechat/api');
|
||||
const {
|
||||
Constants,
|
||||
ContentTypes,
|
||||
|
|
@ -8,7 +9,6 @@ const {
|
|||
} = require('librechat-data-provider');
|
||||
const { retrieveAndProcessFile } = require('~/server/services/Files/process');
|
||||
const { recordMessage, getMessages } = require('~/models/Message');
|
||||
const { countTokens, escapeRegExp } = require('~/server/utils');
|
||||
const { spendTokens } = require('~/models/spendTokens');
|
||||
const { saveConvo } = require('~/models/Conversation');
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -10,14 +10,6 @@ const {
|
|||
const { sendEvent } = require('@librechat/api');
|
||||
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 base = { message: true, initial: true };
|
||||
|
|
@ -181,7 +173,6 @@ function generateConfig(key, baseURL, endpoint) {
|
|||
module.exports = {
|
||||
handleText,
|
||||
formatSteps,
|
||||
escapeRegExp,
|
||||
formatAction,
|
||||
isUserProvided,
|
||||
generateConfig,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
const removePorts = require('./removePorts');
|
||||
const countTokens = require('./countTokens');
|
||||
const handleText = require('./handleText');
|
||||
const sendEmail = require('./sendEmail');
|
||||
const queue = require('./queue');
|
||||
|
|
@ -7,7 +6,6 @@ const files = require('./files');
|
|||
|
||||
module.exports = {
|
||||
...handleText,
|
||||
countTokens,
|
||||
removePorts,
|
||||
sendEmail,
|
||||
...files,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
const cookies = require('cookie');
|
||||
const jwksRsa = require('jwks-rsa');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||
|
|
@ -40,13 +41,18 @@ const openIdJwtLogin = (openIdConfig) => {
|
|||
{
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
secretOrKeyProvider: jwksRsa.passportJwtSecret(jwksRsaOptions),
|
||||
passReqToCallback: true,
|
||||
},
|
||||
/**
|
||||
* @param {import('@librechat/api').ServerRequest} req
|
||||
* @param {import('openid-client').IDToken} payload
|
||||
* @param {import('passport-jwt').VerifyCallback} done
|
||||
*/
|
||||
async (payload, done) => {
|
||||
async (req, payload, done) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
const rawToken = authHeader?.replace('Bearer ', '');
|
||||
|
||||
const { user, error, migration } = await findOpenIDUser({
|
||||
findUser,
|
||||
email: payload?.email,
|
||||
|
|
@ -77,6 +83,18 @@ const openIdJwtLogin = (openIdConfig) => {
|
|||
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);
|
||||
} else {
|
||||
logger.warn(
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
logger.error('[openidStrategy] login failed', err);
|
||||
done(err);
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ jest.mock('~/server/services/Config', () => ({
|
|||
jest.mock('@librechat/api', () => ({
|
||||
...jest.requireActual('@librechat/api'),
|
||||
isEnabled: jest.fn(() => false),
|
||||
isEmailDomainAllowed: jest.fn(() => true),
|
||||
findOpenIDUser: jest.requireActual('@librechat/api').findOpenIDUser,
|
||||
getBalanceConfig: jest.fn(() => ({
|
||||
enabled: false,
|
||||
})),
|
||||
|
|
@ -446,6 +448,46 @@ describe('setupOpenId', () => {
|
|||
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 () => {
|
||||
// Act
|
||||
const { user } = await validate(tokenset);
|
||||
|
|
|
|||
|
|
@ -172,6 +172,7 @@ describe('socialLogin', () => {
|
|||
|
||||
/** Verify both searches happened */
|
||||
expect(findUser).toHaveBeenNthCalledWith(1, { googleId: googleId });
|
||||
/** Email passed as-is; findUser implementation handles case normalization */
|
||||
expect(findUser).toHaveBeenNthCalledWith(2, { email: email });
|
||||
expect(findUser).toHaveBeenCalledTimes(2);
|
||||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,10 @@ module.exports = {
|
|||
clientId: 'fake_client_id',
|
||||
clientSecret: 'fake_client_secret',
|
||||
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(() => ({
|
||||
authorizationUrl: jest.fn().mockReturnValue('mock_auth_url'),
|
||||
callback: jest.fn().mockResolvedValue({
|
||||
|
|
|
|||
|
|
@ -1,17 +1,11 @@
|
|||
const { createFileSearchTool } = require('../../../../../app/clients/tools/util/fileSearch');
|
||||
const axios = require('axios');
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('../../../../../models', () => ({
|
||||
Files: {
|
||||
find: jest.fn(),
|
||||
},
|
||||
jest.mock('axios');
|
||||
jest.mock('@librechat/api', () => ({
|
||||
generateShortLivedToken: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../../../server/services/Files/VectorDB/crud', () => ({
|
||||
queryVectors: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../../../config', () => ({
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: {
|
||||
warn: 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(() => {
|
||||
jest.clearAllMocks();
|
||||
process.env.RAG_API_URL = 'http://localhost:8000';
|
||||
});
|
||||
|
||||
// Test only the specific changes: file_id and page metadata additions
|
||||
it('should add file_id and page to search result format', async () => {
|
||||
const mockFiles = [{ file_id: 'test-file-123' }];
|
||||
const mockResults = [
|
||||
{
|
||||
describe('error cases should return tuple with undefined as second value', () => {
|
||||
it('should return tuple when no files provided', async () => {
|
||||
const fileSearchTool = await createFileSearchTool({
|
||||
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: [
|
||||
[
|
||||
{
|
||||
page_content: 'test content',
|
||||
metadata: { source: 'test.pdf', page: 1 },
|
||||
page_content: 'This is test content from the document',
|
||||
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({
|
||||
userId: 'user1',
|
||||
files: mockFiles,
|
||||
entity_id: 'agent-123',
|
||||
const fileSearchTool = await createFileSearchTool({
|
||||
userId: 'user1',
|
||||
files: [{ file_id: 'file-123', filename: 'test.pdf' }],
|
||||
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
|
||||
fileSearchTool.func = jest.fn().mockImplementation(async () => {
|
||||
// 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
|
||||
},
|
||||
];
|
||||
it('should include file citations in description when enabled', async () => {
|
||||
generateShortLivedToken.mockReturnValue('mock-jwt-token');
|
||||
|
||||
// NEW: Internal data section for processAgentResponse
|
||||
const internalData = formattedResults
|
||||
.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`,
|
||||
)
|
||||
.join('\n---\n');
|
||||
const mockApiResponse = {
|
||||
data: [
|
||||
[
|
||||
{
|
||||
page_content: 'Content with citations',
|
||||
metadata: { source: '/path/to/doc.pdf', page: 3 },
|
||||
},
|
||||
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
|
||||
expect(result).toContain('File_ID: test-file-123');
|
||||
expect(result).toContain('Page: 1');
|
||||
expect(result).toContain('<!-- INTERNAL_DATA_START -->');
|
||||
expect(result).toContain('<!-- INTERNAL_DATA_END -->');
|
||||
const mockResponse1 = {
|
||||
data: [
|
||||
[
|
||||
{
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1828,7 +1828,7 @@
|
|||
* @param {onTokenProgress} opts.onProgress - Callback function to handle token progress
|
||||
* @param {AbortController} opts.abortController - AbortController instance
|
||||
* @param {Record<string, Record<string, string>>} [opts.userMCPAuthMap]
|
||||
* @returns {Promise<string>}
|
||||
* @returns {Promise<{ content: Promise<MessageContentComplex[]>; metadata: Record<string, unknown>; }>}
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
|
|
|
|||
|
|
@ -275,6 +275,9 @@ describe('getModelMaxTokens', () => {
|
|||
expect(getModelMaxTokens('gemini-1.5-pro-preview-0409', EModelEndpoint.google)).toBe(
|
||||
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(
|
||||
maxTokensMap[EModelEndpoint.google]['gemini-2.5-pro'],
|
||||
);
|
||||
|
|
@ -662,7 +665,7 @@ describe('Meta Models Tests', () => {
|
|||
|
||||
test('should match Deepseek model variations', () => {
|
||||
expect(getModelMaxTokens('deepseek-chat')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['deepseek'],
|
||||
maxTokensMap[EModelEndpoint.openAI]['deepseek-chat'],
|
||||
);
|
||||
expect(getModelMaxTokens('deepseek-coder')).toBe(
|
||||
maxTokensMap[EModelEndpoint.openAI]['deepseek'],
|
||||
|
|
@ -674,6 +677,20 @@ describe('Meta Models Tests', () => {
|
|||
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', () => {
|
||||
|
|
@ -702,11 +719,42 @@ describe('Meta Models Tests', () => {
|
|||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
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', () => {
|
||||
test('should process Meta model data correctly', () => {
|
||||
const input = {
|
||||
|
|
@ -775,6 +823,16 @@ describe('Grok Model Tests - Tokens', () => {
|
|||
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', () => {
|
||||
// Vision models should match before general models
|
||||
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);
|
||||
// Grok 4 model
|
||||
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');
|
||||
// Grok 4 model
|
||||
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', () => {
|
||||
|
|
@ -838,6 +908,12 @@ describe('Grok Model Tests - Tokens', () => {
|
|||
expect(matchModelName('xai/grok-3-mini-fast')).toBe('grok-3-mini-fast');
|
||||
// Grok 4 model
|
||||
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', () => {
|
||||
const modelVariations = [
|
||||
'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', () => {
|
||||
const modelVariations = [
|
||||
'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', () => {
|
||||
const modelVariations = [
|
||||
'claude-sonnet-4',
|
||||
|
|
|
|||
BIN
bun.lockb
BIN
bun.lockb
Binary file not shown.
|
|
@ -1,4 +1,4 @@
|
|||
/** v0.8.1-rc1 */
|
||||
/** v0.8.1 */
|
||||
module.exports = {
|
||||
roots: ['<rootDir>/src'],
|
||||
testEnvironment: 'jsdom',
|
||||
|
|
@ -41,7 +41,6 @@ module.exports = {
|
|||
'jest-file-loader',
|
||||
},
|
||||
transformIgnorePatterns: ['node_modules/?!@zattoo/use-double-click'],
|
||||
preset: 'ts-jest',
|
||||
setupFilesAfterEnv: ['@testing-library/jest-dom/extend-expect', '<rootDir>/test/setupTests.js'],
|
||||
clearMocks: true,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@librechat/frontend",
|
||||
"version": "v0.8.1-rc1",
|
||||
"version": "v0.8.1",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
|
@ -64,6 +64,7 @@
|
|||
"copy-to-clipboard": "^3.3.3",
|
||||
"cross-env": "^7.0.3",
|
||||
"date-fns": "^3.3.1",
|
||||
"dompurify": "^3.3.0",
|
||||
"downloadjs": "^1.4.7",
|
||||
"export-from-json": "^1.7.2",
|
||||
"filenamify": "^6.0.0",
|
||||
|
|
@ -147,7 +148,6 @@
|
|||
"postcss-loader": "^7.1.0",
|
||||
"postcss-preset-env": "^8.2.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"ts-jest": "^29.4.5",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^6.4.1",
|
||||
"vite-plugin-compression2": "^2.2.1",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
|||
import { Toast, ThemeProvider, ToastProvider } from '@librechat/client';
|
||||
import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query';
|
||||
import { ScreenshotProvider, useApiErrorBoundary } from './hooks';
|
||||
import WakeLockManager from '~/components/System/WakeLockManager';
|
||||
import { getThemeFromEnv } from './utils/getThemeFromEnv';
|
||||
import { initializeFontSize } from '~/store/fontSize';
|
||||
import { LiveAnnouncer } from '~/a11y';
|
||||
|
|
@ -51,6 +52,7 @@ const App = () => {
|
|||
<ToastProvider>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<RouterProvider router={router} />
|
||||
<WakeLockManager />
|
||||
<ReactQueryDevtools initialIsOpen={false} position="top-right" />
|
||||
<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" />
|
||||
|
|
|
|||
|
|
@ -41,4 +41,8 @@ export type AgentForm = {
|
|||
recursion_limit?: number;
|
||||
support_contact?: SupportContact;
|
||||
category: string;
|
||||
// Avatar management fields
|
||||
avatar_file?: File | null;
|
||||
avatar_preview?: string | null;
|
||||
avatar_action?: 'upload' | 'reset' | null;
|
||||
} & TAgentCapabilities;
|
||||
|
|
|
|||
|
|
@ -59,7 +59,12 @@ export default function ArtifactVersion({
|
|||
<TooltipAnchor
|
||||
description={localize('com_ui_change_version')}
|
||||
render={
|
||||
<Button size="icon" variant="ghost" asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
asChild
|
||||
aria-label={localize('com_ui_change_version')}
|
||||
>
|
||||
<MenuButton>
|
||||
<History
|
||||
size={18}
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ function AuthLayout({
|
|||
|
||||
<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">
|
||||
{!hasStartupConfigError && !isFetching && (
|
||||
{!hasStartupConfigError && !isFetching && header && (
|
||||
<h1
|
||||
className="mb-4 text-center text-3xl font-semibold text-black dark:text-white"
|
||||
style={{ userSelect: 'none' }}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { useOutletContext, useSearchParams } from 'react-router-dom';
|
||||
import type { TLoginLayoutContext } from '~/common';
|
||||
|
|
@ -104,7 +104,7 @@ function Login() {
|
|||
{' '}
|
||||
{localize('com_auth_no_account')}{' '}
|
||||
<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"
|
||||
>
|
||||
{localize('com_auth_sign_up')}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Turnstile } from '@marsidev/react-turnstile';
|
|||
import { ThemeContext, Spinner, Button, isDark } from '@librechat/client';
|
||||
import { useNavigate, useOutletContext, useLocation } from 'react-router-dom';
|
||||
import { useRegisterUserMutation } from 'librechat-data-provider/react-query';
|
||||
import { loginPage } from 'librechat-data-provider';
|
||||
import type { TRegisterUser, TError } from 'librechat-data-provider';
|
||||
import type { TLoginLayoutContext } from '~/common';
|
||||
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">
|
||||
{localize('com_auth_already_have_account')}{' '}
|
||||
<a
|
||||
href="/login"
|
||||
href={loginPage()}
|
||||
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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useState, ReactNode } from 'react';
|
|||
import { Spinner, Button } from '@librechat/client';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { useRequestPasswordResetMutation } from 'librechat-data-provider/react-query';
|
||||
import { loginPage } from 'librechat-data-provider';
|
||||
import type { TRequestPasswordReset, TRequestPasswordResetResponse } from 'librechat-data-provider';
|
||||
import type { TLoginLayoutContext } from '~/common';
|
||||
import type { FC } from 'react';
|
||||
|
|
@ -26,7 +27,7 @@ const ResetPasswordBodyText = () => {
|
|||
<p>{localize('com_auth_reset_password_if_email_exists')}</p>
|
||||
<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"
|
||||
href="/login"
|
||||
href={loginPage()}
|
||||
>
|
||||
{localize('com_auth_back_to_login')}
|
||||
</a>
|
||||
|
|
@ -134,7 +135,7 @@ function RequestPasswordReset() {
|
|||
{isLoading ? <Spinner /> : localize('com_auth_continue')}
|
||||
</Button>
|
||||
<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"
|
||||
>
|
||||
{localize('com_auth_back_to_login')}
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ const BookmarkForm = ({
|
|||
}
|
||||
const allTags =
|
||||
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({
|
||||
message: localize('com_ui_bookmarks_create_exists'),
|
||||
status: 'warning',
|
||||
|
|
|
|||
499
client/src/components/Bookmarks/__tests__/BookmarkForm.test.tsx
Normal file
499
client/src/components/Bookmarks/__tests__/BookmarkForm.test.tsx
Normal 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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -11,6 +11,7 @@ import BookmarkMenu from './Menus/BookmarkMenu';
|
|||
import { TemporaryChat } from './TemporaryChat';
|
||||
import AddMultiConvo from './AddMultiConvo';
|
||||
import { useHasAccess } from '~/hooks';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
|
||||
const defaultInterface = getConfigDefaults().interface;
|
||||
|
||||
|
|
@ -38,24 +39,24 @@ export default function Header() {
|
|||
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="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={`flex items-center gap-2 ${
|
||||
!isSmallScreen ? 'transition-all duration-200 ease-in-out' : ''
|
||||
} ${
|
||||
!navVisible
|
||||
? 'translate-x-0 opacity-100'
|
||||
: 'pointer-events-none translate-x-[-100px] opacity-0'
|
||||
}`}
|
||||
>
|
||||
<OpenSidebar setNavVisible={setNavVisible} className="max-md:hidden" />
|
||||
<HeaderNewChat />
|
||||
</div>
|
||||
<div
|
||||
className={`flex items-center gap-2 ${
|
||||
!isSmallScreen ? 'transition-all duration-200 ease-in-out' : ''
|
||||
} ${!navVisible ? 'translate-x-0' : 'translate-x-[-100px]'}`}
|
||||
>
|
||||
<div className="mx-1 flex items-center">
|
||||
<AnimatePresence initial={false}>
|
||||
{!navVisible && (
|
||||
<motion.div
|
||||
className={`flex items-center gap-2`}
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={{ width: 'auto', opacity: 1 }}
|
||||
exit={{ width: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
key="header-buttons"
|
||||
>
|
||||
<OpenSidebar setNavVisible={setNavVisible} className="max-md:hidden" />
|
||||
<HeaderNewChat />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className={navVisible ? 'flex items-center gap-2' : 'ml-2 flex items-center gap-2'}>
|
||||
<ModelSelector startupConfig={startupConfig} />
|
||||
{interfaceConfig.presets === true && interfaceConfig.modelSelect && <PresetsMenu />}
|
||||
{hasAccessToBookmarks === true && <BookmarkMenu />}
|
||||
|
|
|
|||
|
|
@ -260,37 +260,50 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
|||
<FileFormChat conversation={conversation} />
|
||||
{endpoint && (
|
||||
<div className={cn('flex', isRTL ? 'flex-row-reverse' : 'flex-row')}>
|
||||
<TextareaAutosize
|
||||
{...registerProps}
|
||||
ref={(e) => {
|
||||
ref(e);
|
||||
(textAreaRef as React.MutableRefObject<HTMLTextAreaElement | null>).current = e;
|
||||
}}
|
||||
disabled={disableInputs || isNotAppendable}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={handleKeyUp}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
id={mainTextareaId}
|
||||
tabIndex={0}
|
||||
data-testid="text-input"
|
||||
rows={1}
|
||||
onFocus={() => {
|
||||
handleFocusOrClick();
|
||||
setIsTextAreaFocused(true);
|
||||
}}
|
||||
onBlur={setIsTextAreaFocused.bind(null, false)}
|
||||
aria-label={localize('com_ui_message_input')}
|
||||
onClick={handleFocusOrClick}
|
||||
style={{ height: 44, overflowY: 'auto' }}
|
||||
className={cn(
|
||||
baseClasses,
|
||||
removeFocusRings,
|
||||
'transition-[max-height] duration-200 disabled:cursor-not-allowed',
|
||||
<div className="relative flex-1">
|
||||
<TextareaAutosize
|
||||
{...registerProps}
|
||||
ref={(e) => {
|
||||
ref(e);
|
||||
(textAreaRef as React.MutableRefObject<HTMLTextAreaElement | null>).current =
|
||||
e;
|
||||
}}
|
||||
disabled={disableInputs || isNotAppendable}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={handleKeyUp}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
id={mainTextareaId}
|
||||
tabIndex={0}
|
||||
data-testid="text-input"
|
||||
rows={1}
|
||||
onFocus={() => {
|
||||
handleFocusOrClick();
|
||||
setIsTextAreaFocused(true);
|
||||
}}
|
||||
onBlur={setIsTextAreaFocused.bind(null, false)}
|
||||
aria-label={localize('com_ui_message_input')}
|
||||
onClick={handleFocusOrClick}
|
||||
style={{ height: 44, overflowY: 'auto' }}
|
||||
className={cn(
|
||||
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 className="flex flex-col items-start justify-start pt-1.5">
|
||||
</div>
|
||||
<div className="flex flex-col items-start justify-start pr-2.5 pt-1.5">
|
||||
<CollapseChat
|
||||
isCollapsed={isCollapsed}
|
||||
isScrollable={isMoreThanThreeRows}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React, { useMemo } from 'react';
|
|||
import { useRecoilValue } from 'recoil';
|
||||
import { OGDialog, OGDialogTemplate } from '@librechat/client';
|
||||
import {
|
||||
inferMimeType,
|
||||
EToolResources,
|
||||
EModelEndpoint,
|
||||
defaultAgentCapabilities,
|
||||
|
|
@ -56,18 +57,26 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD
|
|||
const _options: FileOption[] = [];
|
||||
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
|
||||
if (isDocumentSupportedProvider(endpointType) || isDocumentSupportedProvider(currentProvider)) {
|
||||
const isGoogleProvider = currentProvider === EModelEndpoint.google;
|
||||
const validFileTypes = isGoogleProvider
|
||||
? files.every(
|
||||
(file) =>
|
||||
file.type?.startsWith('image/') ||
|
||||
file.type?.startsWith('video/') ||
|
||||
file.type?.startsWith('audio/') ||
|
||||
file.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?.startsWith('video/') ||
|
||||
type?.startsWith('audio/') ||
|
||||
type === 'application/pdf'
|
||||
);
|
||||
})
|
||||
: files.every((file) => {
|
||||
const type = getFileType(file);
|
||||
return type?.startsWith('image/') || type === 'application/pdf';
|
||||
});
|
||||
|
||||
_options.push({
|
||||
label: localize('com_ui_upload_provider'),
|
||||
|
|
@ -81,7 +90,7 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD
|
|||
label: localize('com_ui_upload_image_input'),
|
||||
value: undefined,
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import { EModelEndpoint, isDocumentSupportedProvider } from 'librechat-data-provider';
|
||||
import {
|
||||
EModelEndpoint,
|
||||
isDocumentSupportedProvider,
|
||||
inferMimeType,
|
||||
} from 'librechat-data-provider';
|
||||
|
||||
describe('DragDropModal - Provider Detection', () => {
|
||||
describe('endpointType priority over currentProvider', () => {
|
||||
|
|
@ -118,4 +122,59 @@ describe('DragDropModal - Provider Detection', () => {
|
|||
).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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -166,6 +166,7 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
|
|||
<TooltipAnchor
|
||||
className="absolute bottom-[27px] right-2"
|
||||
description={localize('com_ui_happy_birthday')}
|
||||
aria-label={localize('com_ui_happy_birthday')}
|
||||
>
|
||||
<BirthdayIcon />
|
||||
</TooltipAnchor>
|
||||
|
|
|
|||
|
|
@ -180,6 +180,10 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
|
|||
}
|
||||
}, [isPromptOpen, zoom]);
|
||||
|
||||
const imageDetailsLabel = isPromptOpen
|
||||
? localize('com_ui_hide_image_details')
|
||||
: localize('com_ui_show_image_details');
|
||||
|
||||
return (
|
||||
<OGDialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<OGDialogContent
|
||||
|
|
@ -198,6 +202,7 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
|
|||
onClick={() => onOpenChange(false)}
|
||||
variant="ghost"
|
||||
className="h-10 w-10 p-0 hover:bg-surface-hover"
|
||||
aria-label={localize('com_ui_close')}
|
||||
>
|
||||
<X className="size-7 sm:size-6" />
|
||||
</Button>
|
||||
|
|
@ -208,7 +213,12 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
|
|||
<TooltipAnchor
|
||||
description={localize('com_ui_reset_zoom')}
|
||||
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" />
|
||||
</Button>
|
||||
}
|
||||
|
|
@ -217,22 +227,24 @@ export default function DialogImage({ isOpen, onOpenChange, src = '', downloadIm
|
|||
<TooltipAnchor
|
||||
description={localize('com_ui_download')}
|
||||
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" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipAnchor
|
||||
description={
|
||||
isPromptOpen
|
||||
? localize('com_ui_hide_image_details')
|
||||
: localize('com_ui_show_image_details')
|
||||
}
|
||||
description={imageDetailsLabel}
|
||||
render={
|
||||
<Button
|
||||
onClick={() => setIsPromptOpen(!isPromptOpen)}
|
||||
variant="ghost"
|
||||
className="h-10 w-10 p-0"
|
||||
aria-label={imageDetailsLabel}
|
||||
>
|
||||
{isPromptOpen ? (
|
||||
<PanelLeftOpen className="size-7 sm:size-6" />
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useState, useRef, useMemo } from 'react';
|
||||
import { Skeleton } from '@librechat/client';
|
||||
import { LazyLoadImage } from 'react-lazy-load-image-component';
|
||||
import { apiBaseUrl } from 'librechat-data-provider';
|
||||
import { cn, scaleImage } from '~/utils';
|
||||
import DialogImage from './DialogImage';
|
||||
|
||||
|
|
@ -36,6 +37,24 @@ const Image = ({
|
|||
|
||||
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(
|
||||
() =>
|
||||
scaleImage({
|
||||
|
|
@ -48,7 +67,7 @@ const Image = ({
|
|||
|
||||
const downloadImage = async () => {
|
||||
try {
|
||||
const response = await fetch(imagePath);
|
||||
const response = await fetch(absoluteImageUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch image: ${response.status}`);
|
||||
}
|
||||
|
|
@ -67,7 +86,7 @@ const Image = ({
|
|||
} catch (error) {
|
||||
console.error('Download failed:', error);
|
||||
const link = document.createElement('a');
|
||||
link.href = imagePath;
|
||||
link.href = absoluteImageUrl;
|
||||
link.download = altText || 'image.png';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
|
@ -97,7 +116,7 @@ const Image = ({
|
|||
'opacity-100 transition-opacity duration-100',
|
||||
isLoaded ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
src={imagePath}
|
||||
src={absoluteImageUrl}
|
||||
style={{
|
||||
width: `${scaledWidth}`,
|
||||
height: 'auto',
|
||||
|
|
@ -117,7 +136,7 @@ const Image = ({
|
|||
<DialogImage
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
src={imagePath}
|
||||
src={absoluteImageUrl}
|
||||
downloadImage={downloadImage}
|
||||
args={args}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { ArtifactProvider, CodeBlockProvider } from '~/Providers';
|
|||
import MarkdownErrorBoundary from './MarkdownErrorBoundary';
|
||||
import { langSubset, preprocessLaTeX } from '~/utils';
|
||||
import { unicodeCitation } from '~/components/Web';
|
||||
import { code, a, p } from './MarkdownComponents';
|
||||
import { code, a, p, img } from './MarkdownComponents';
|
||||
import store from '~/store';
|
||||
|
||||
type TContentProps = {
|
||||
|
|
@ -81,6 +81,7 @@ const Markdown = memo(({ content = '', isLatestMessage }: TContentProps) => {
|
|||
code,
|
||||
a,
|
||||
p,
|
||||
img,
|
||||
artifact: Artifact,
|
||||
citation: Citation,
|
||||
'highlighted-text': HighlightedText,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue